Compare commits
3 Commits
49a9069225
...
36ab53112a
| Author | SHA1 | Date |
|---|---|---|
|
|
36ab53112a | |
|
|
fbb48ed711 | |
|
|
b9431d6d99 |
|
|
@ -43,15 +43,27 @@ Inside the spec folder:
|
||||||
|
|
||||||
```
|
```
|
||||||
<spec-folder>/
|
<spec-folder>/
|
||||||
SPEC.md ← uppercase, the kernel
|
SPEC.md ← uppercase, the kernel — DERIVED from .memlog.md, never hand-edited
|
||||||
<companion-1>.md ← optional, content-typed (e.g. glossary.md)
|
<companion-1>.md ← optional, content-typed (e.g. glossary.md); spec-authored ones are derived too
|
||||||
<companion-2>.md
|
<companion-2>.md
|
||||||
.decision-log.md ← canonical memory for this spec
|
.memlog.md ← canonical, append-only memory; what SPEC.md is distilled from
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Memory and derivation
|
||||||
|
|
||||||
|
`.memlog.md` is canonical — an append-only, chronological record of every decision, constraint, capability (with its stable `CAP-N`), assumption, open question, and bit of user direction, one line each in the order it happened, never edited or reordered. `SPEC.md` and every spec-authored companion are **derived on each run** from the memlog (the decision-of-record) plus the sources it cites for raw content — never hand-patched.
|
||||||
|
|
||||||
|
Deriving the contract from a living log instead of editing the contract in place is what lets the steps around the spec (PRD, UX, architecture, epics) run in any order and feed the same spec without merge drift: the log only accumulates, the artifact is re-rendered. So the spec is updated *only* by re-deriving it here — bmad-spec is its single writer; a hand-edit to `SPEC.md` from outside is unsupported and is overwritten on the next derive.
|
||||||
|
|
||||||
|
Writes go through the shared script — `{project-root}/_bmad/scripts/memlog.py`, the same location as `resolve_customization.py` (atomic; never read it back except to resume):
|
||||||
|
|
||||||
|
- `python3 {project-root}/_bmad/scripts/memlog.py init --workspace {spec-folder} --field topic="<what is being specced>"` — once, at create.
|
||||||
|
- `python3 {project-root}/_bmad/scripts/memlog.py append --workspace {spec-folder} --type <decision|constraint|capability|assumption|question|direction|note|event> --text "<one-line gist, reason included>"` — as each lands.
|
||||||
|
- Terminal moments (a validation verdict, "spec finalized") are `--type event` entries; the memlog carries no status field.
|
||||||
|
|
||||||
## The Operation
|
## The Operation
|
||||||
|
|
||||||
Read the input and its ancillary linked materials. If there is no input, follow the no-input branch in **Workspace** (ask or block). If a prior `SPEC.md` exists at the target folder, read it too — the operation becomes an update. Preserve capability IDs; new capabilities get the next unused `CAP-N`; never reuse retired IDs. Otherwise this is a create.
|
Read the input and its ancillary linked materials. If there is no input, follow the no-input branch in **Workspace** (ask or block). If a prior `.memlog.md` exists at the target folder, read it — the operation becomes an update, and the memlog (not the rendered `SPEC.md`) is the authority on what was decided and on capability IDs. Preserve those IDs; new capabilities get the next unused `CAP-N`; never reuse retired IDs. Otherwise this is a create, and the first move is `memlog.py init`.
|
||||||
|
|
||||||
When the input is structured and pre-sorted (a PRD with an addendum, a GDD, a brief produced by an upstream BMad skill), trust the authored separation: lift kernel-fitting content into SPEC.md, lift overflow into appropriately-named companions. When the input is mixed (a brain dump, a transcript, an RFC, a customer email), do the sorting yourself: walk each claim, apply the three-lens load-bearing test (Spec Law rule 7), and route to the kernel field or a companion.
|
When the input is structured and pre-sorted (a PRD with an addendum, a GDD, a brief produced by an upstream BMad skill), trust the authored separation: lift kernel-fitting content into SPEC.md, lift overflow into appropriately-named companions. When the input is mixed (a brain dump, a transcript, an RFC, a customer email), do the sorting yourself: walk each claim, apply the three-lens load-bearing test (Spec Law rule 7), and route to the kernel field or a companion.
|
||||||
|
|
||||||
|
|
@ -59,6 +71,8 @@ Distill the input into the five-field kernel using `{workflow.spec_template}` as
|
||||||
|
|
||||||
Write lean from the first pass: every sentence must earn its place. Decoration costs tokens and dilutes downstream readers.
|
Write lean from the first pass: every sentence must earn its place. Decoration costs tokens and dilutes downstream readers.
|
||||||
|
|
||||||
|
Log each decision, capability, constraint, and accepted change to `.memlog.md` as it is made — that running record is what the render reads. Because the log is append-only, a later entry supersedes an earlier one on the same point while the history stays intact. When two currently-live sources or companions disagree on the same field, or an either/or never got resolved, surface it to the user rather than silently choosing — the resolution is itself a new memlog entry.
|
||||||
|
|
||||||
If the input is genuinely too thin to distill (e.g. "an app for hikers" with no surrounding context), stop and suggest `bmad-prd` (or sibling ceremony skill). This skill distills; it does not coach.
|
If the input is genuinely too thin to distill (e.g. "an app for hikers" with no surrounding context), stop and suggest `bmad-prd` (or sibling ceremony skill). This skill distills; it does not coach.
|
||||||
|
|
||||||
## Load-bearing
|
## Load-bearing
|
||||||
|
|
@ -94,7 +108,7 @@ Every spec must satisfy these eight rules. The operation aims for them; the self
|
||||||
5. **Success signal is concrete enough to test or demonstrate against.** "Users love it" doesn't qualify.
|
5. **Success signal is concrete enough to test or demonstrate against.** "Users love it" doesn't qualify.
|
||||||
6. **Capability IDs are stable and unique.** Never reused, never renumbered.
|
6. **Capability IDs are stable and unique.** Never reused, never renumbered.
|
||||||
7. **Preservation.** Every load-bearing source claim lands in SPEC.md or a companion. Wrapper ceremony does not.
|
7. **Preservation.** Every load-bearing source claim lands in SPEC.md or a companion. Wrapper ceremony does not.
|
||||||
8. **Lean prose.** Every sentence carries load-bearing content. Cut decoration, hedges, backstory, throat-clearing. Applies to SPEC.md, companions, and `.decision-log.md`.
|
8. **Lean prose.** Every sentence carries load-bearing content. Cut decoration, hedges, backstory, throat-clearing. Applies to SPEC.md, companions, and `.memlog.md`.
|
||||||
|
|
||||||
## Self-Validate
|
## Self-Validate
|
||||||
|
|
||||||
|
|
@ -104,7 +118,7 @@ After every create or update, sweep the resulting artifact in **two passes** bef
|
||||||
|
|
||||||
**Pass 2 — Preservation.** Walk the source claim by claim. Confirm each load-bearing claim landed in SPEC.md or a companion. Wrapper-ceremony drops are logged under "Wrapper-only content" so the drop is on the record, not silent.
|
**Pass 2 — Preservation.** Walk the source claim by claim. Confirm each load-bearing claim landed in SPEC.md or a companion. Wrapper-ceremony drops are logged under "Wrapper-only content" so the drop is on the record, not silent.
|
||||||
|
|
||||||
Append a one-paragraph verdict to `.decision-log.md` covering both passes. In interactive mode, review the verdict with the user. In headless mode, `.decision-log.md` is one of the files returned, so the caller (or its downstream LLM) reads the verdict there.
|
Record the verdict for each pass to `.memlog.md` (`append --type event`). In interactive mode, review it with the user. In headless mode, `.memlog.md` is one of the files returned, so the caller (or its downstream LLM) reads the verdict there.
|
||||||
|
|
||||||
## Spec with no change signal
|
## Spec with no change signal
|
||||||
|
|
||||||
|
|
@ -120,10 +134,10 @@ Run `{workflow.on_complete}` if set.
|
||||||
|
|
||||||
## After Spec is Output
|
## After Spec is Output
|
||||||
|
|
||||||
Any update to spec regarding assumptions, open questions, or other changes should be appended to that source's decision log also and offer to update the source.
|
Any update to the spec — resolved assumptions, answered open questions, other changes — is appended to `.memlog.md` as it happens. When a change overrides something that came from a source input, offer to update that source too, so upstream and the spec don't silently diverge.
|
||||||
|
|
||||||
## Frontmatter conventions
|
## Frontmatter conventions
|
||||||
|
|
||||||
- `companions:` array of `.md` files downstream MUST read alongside SPEC.md to have the full contract. Paths may point inside the spec folder (spec-authored companions like `glossary.md`) or outside it (adopted companions like `../planning-artifacts/ux-designs/ux-foo-bar-2026-05-23/DESIGN.md`). The split between spec-authored and adopted is implicit by path; downstream treats both the same.
|
- `companions:` array of `.md` files downstream MUST read alongside SPEC.md to have the full contract. Paths may point inside the spec folder (spec-authored companions like `glossary.md`) or outside it (adopted companions like `../planning-artifacts/ux-designs/ux-foo-bar-2026-05-23/DESIGN.md`). The split between spec-authored and adopted is implicit by path; downstream treats both the same.
|
||||||
- `sources:` array of paths to files that were **fully absorbed** into the SPEC, with no remaining downstream value (e.g., a PRD whose every load-bearing claim is now in the kernel). Listed for audit and for bmad-spec to re-read on update. Downstream does NOT read these. Files that downstream still needs to read belong in `companions:`, not here.
|
- `sources:` array of paths to files that were **fully absorbed** into the SPEC, with no remaining downstream value (e.g., a PRD whose every load-bearing claim is now in the kernel). Listed for audit and for bmad-spec to re-read on update. Downstream does NOT read these. Files that downstream still needs to read belong in `companions:`, not here.
|
||||||
- **Do not list** decision logs, README files, organizational artifacts, or any operational record of how upstream skills produced their artifacts. Those are not source content; they are process metadata that downstream consumers don't need.
|
- **Do not list** the memlog, README files, organizational artifacts, or any operational record of how upstream skills produced their artifacts. Those are not source content; they are process metadata that downstream consumers don't need.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Headless JSON Response
|
# Headless JSON Response
|
||||||
|
|
||||||
The default invocation is headless: input goes in, JSON comes out. The contract is intentionally tiny — return the outcome and the files touched. Anything else a caller needs is inside those files (SPEC.md, companions, `.decision-log.md`).
|
The default invocation is headless: input goes in, JSON comes out. The contract is intentionally tiny — return the outcome and the files touched. Anything else a caller needs is inside those files (SPEC.md, companions, `.memlog.md`).
|
||||||
|
|
||||||
## Success
|
## Success
|
||||||
|
|
||||||
|
|
@ -10,12 +10,12 @@ The default invocation is headless: input goes in, JSON comes out. The contract
|
||||||
"files": [
|
"files": [
|
||||||
"_bmad-output/specs/spec-quarter-drop/SPEC.md",
|
"_bmad-output/specs/spec-quarter-drop/SPEC.md",
|
||||||
"_bmad-output/specs/spec-quarter-drop/glossary.md",
|
"_bmad-output/specs/spec-quarter-drop/glossary.md",
|
||||||
"_bmad-output/specs/spec-quarter-drop/.decision-log.md"
|
"_bmad-output/specs/spec-quarter-drop/.memlog.md"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`files` lists every file written or modified in this run, in any order. The spec folder, kernel filename, decision log location, capabilities, companions, and verdict are all readable from those files; no need to re-encode them in the response.
|
`files` lists every file written or modified in this run, in any order. The spec folder, kernel filename, memlog location, capabilities, companions, and verdict are all readable from those files; no need to re-encode them in the response.
|
||||||
|
|
||||||
## Blocked
|
## Blocked
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
id: SPEC-{slug}
|
id: SPEC-{slug}
|
||||||
companions: [] # files downstream MUST read alongside SPEC.md. Paths may point inside the spec folder (spec-authored) or outside it (adopted from an upstream skill).
|
companions: [] # files downstream MUST read alongside SPEC.md. Paths may point inside the spec folder (spec-authored) or outside it (adopted from an upstream skill).
|
||||||
sources: [] # files fully absorbed into the SPEC (audit only; downstream does NOT read these). Never decision logs.
|
sources: [] # files fully absorbed into the SPEC (audit only; downstream does NOT read these). Never the memlog.
|
||||||
---
|
---
|
||||||
|
|
||||||
> **Canonical contract.** This SPEC and the files in `companions:` are the complete, preservation-validated contract for what to build, test, and validate. Source documents listed in frontmatter are for traceability only — consult them only if you need narrative rationale or prose color this contract intentionally omits.
|
> **Canonical contract.** This SPEC and the files in `companions:` are the complete, preservation-validated contract for what to build, test, and validate. Source documents listed in frontmatter are for traceability only — consult them only if you need narrative rationale or prose color this contract intentionally omits.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.8"
|
||||||
|
# ///
|
||||||
|
"""memlog — an append-only memory log: LLM-optimal working memory for a skill.
|
||||||
|
|
||||||
|
A memlog is the dense, chronological record of everything that mattered in a piece of
|
||||||
|
work — every item the user generated or accepted — kept minimal like human memory: only
|
||||||
|
what's important, never bloated. It persists ACROSS sessions, so a fresh session can
|
||||||
|
load it and continue. It is NOT a deliverable; downstream artifacts (a brief, a PRD, a
|
||||||
|
deck, a report) are *derived* from it on demand. The host skill supplies the vocabulary
|
||||||
|
by how it calls `append` — the tool stays neutral.
|
||||||
|
|
||||||
|
It is a FLAT log: there are no sections or grouping. Every entry is one line, recorded
|
||||||
|
at the END in the order it happened. The chronology itself is the structure — an event
|
||||||
|
like "started technique X" is just another entry, same as an idea or an insight.
|
||||||
|
|
||||||
|
Three invariants make it trustworthy:
|
||||||
|
|
||||||
|
1. Append-only, chronological. Entries land at the end, in the order they happen.
|
||||||
|
Nothing is ever inserted backward, reordered, edited, or removed. There is no
|
||||||
|
edit or delete subcommand by design; history is never rewritten.
|
||||||
|
2. Write-only / blind. Every command is an atomic, context-free write and echoes the
|
||||||
|
new state as one line of JSON, so the caller never re-reads the file mid-session.
|
||||||
|
The one time the file is read is on resume — and the caller reads it itself, not
|
||||||
|
via this script.
|
||||||
|
3. No lifecycle status. A memory log has no "complete" flag. Whether the work is done,
|
||||||
|
blocked, or paused is itself a fact that happened, so it is recorded as an entry
|
||||||
|
(e.g. `append --type event --text "session complete"`), never as frontmatter the
|
||||||
|
log would have to mutate. The chronology stays the single source of truth, and a
|
||||||
|
resume learns the state by reading the last entries — the same way it learns
|
||||||
|
everything else.
|
||||||
|
|
||||||
|
Atomicity: every write goes to a temp file, is flushed and fsync'd, then atomically
|
||||||
|
renamed over the target, so a crash never leaves a half-written entry.
|
||||||
|
|
||||||
|
The file shape (.memlog.md):
|
||||||
|
|
||||||
|
---
|
||||||
|
topic: Onboarding flow for a budgeting app
|
||||||
|
goal: lift week-1 retention
|
||||||
|
updated: 2026-06-07T14:22
|
||||||
|
---
|
||||||
|
|
||||||
|
- (note) user picked techniques: SCAMPER, then Six Thinking Hats
|
||||||
|
- (technique) started SCAMPER
|
||||||
|
- (idea) skip the signup wall: let people try with sample data first
|
||||||
|
- (idea) auto-import one bank account so the first screen shows real numbers
|
||||||
|
- (question) is open-banking consent too heavy for step one?
|
||||||
|
- (insight) the "scary numbers" risk and the "real numbers" idea are one lever: show real data, pre-categorized
|
||||||
|
- (direction) optimize for the anxious first-timer, not the power user
|
||||||
|
- (decision) lead with one pre-categorized account; defer multi-account import
|
||||||
|
- (event) session complete
|
||||||
|
|
||||||
|
Each entry may carry an optional `--type` — what KIND it is (idea, insight, question,
|
||||||
|
decision, direction, assumption, gap, note, event, …) — and an optional `--by` naming
|
||||||
|
who it came from (e.g. `user`, `coach`), for sessions where authorship matters. Both
|
||||||
|
render into one short inline tag: `(idea)`, `(idea by user)`, `(by coach)`. Omit them
|
||||||
|
for a plain note. The host skill names the vocabulary; the script does not enforce one.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
init (--workspace DIR | --path FILE) [--field k=v ...] create the memlog (errors if it exists)
|
||||||
|
append (--workspace DIR | --path FILE) --text STR [--type T] [--by W] append one entry at the end
|
||||||
|
set (--workspace DIR | --path FILE) --key K --value V set/replace a descriptive frontmatter field
|
||||||
|
|
||||||
|
Addressing: `--workspace` is the run folder, and the memlog is always {workspace}/.memlog.md.
|
||||||
|
`--path` points straight at the memlog file instead, for callers that already hold the path.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations # keep type-hint syntax lazy so the script runs on 3.8+
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MEMLOG = ".memlog.md"
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> str:
|
||||||
|
return datetime.now().strftime("%Y-%m-%dT%H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(args) -> Path:
|
||||||
|
"""The memlog file, from either addressing mode: {workspace}/.memlog.md or an explicit --path."""
|
||||||
|
return Path(args.path) if args.path else Path(args.workspace) / MEMLOG
|
||||||
|
|
||||||
|
|
||||||
|
def split(text: str) -> tuple[dict, str]:
|
||||||
|
"""Return (frontmatter dict in source order, body str). Frontmatter is plain key: value.
|
||||||
|
|
||||||
|
The closing fence is the first line that is *exactly* `---`, so a `---` inside a
|
||||||
|
field value (topic/goal are free user text) never truncates the frontmatter.
|
||||||
|
"""
|
||||||
|
lines = text.splitlines()
|
||||||
|
if not lines or lines[0] != "---":
|
||||||
|
raise ValueError(".memlog.md has no frontmatter")
|
||||||
|
end = next((i for i in range(1, len(lines)) if lines[i] == "---"), None)
|
||||||
|
if end is None:
|
||||||
|
raise ValueError(".memlog.md frontmatter is not terminated")
|
||||||
|
meta: dict[str, str] = {}
|
||||||
|
for line in lines[1:end]:
|
||||||
|
if ":" in line:
|
||||||
|
k, v = line.split(":", 1)
|
||||||
|
meta[k.strip()] = v.strip()
|
||||||
|
return meta, "\n".join(lines[end + 1:]).lstrip("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def render(meta: dict, body: str) -> str:
|
||||||
|
# Neutralize newlines in values so a multi-line field can't break the fence on re-read.
|
||||||
|
fm = "\n".join(f"{k}: {' '.join(str(v).splitlines())}" for k, v in meta.items())
|
||||||
|
return "---\n" + fm + "\n---\n\n" + body.rstrip("\n") + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def touch(meta: dict) -> None:
|
||||||
|
"""Stamp `updated` and keep it last so the field order stays predictable."""
|
||||||
|
meta.pop("updated", None)
|
||||||
|
meta["updated"] = now()
|
||||||
|
|
||||||
|
|
||||||
|
def write_atomic(path: Path, text: str) -> None:
|
||||||
|
"""Temp + flush + fsync + atomic rename, so a crash never half-writes an entry."""
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def entry_count(body: str) -> int:
|
||||||
|
return sum(1 for ln in body.splitlines() if ln.startswith("- "))
|
||||||
|
|
||||||
|
|
||||||
|
def ack(path: Path, body: str) -> None:
|
||||||
|
"""Echo new state so the caller never re-reads the file to know where it stands."""
|
||||||
|
print(json.dumps({
|
||||||
|
"ok": True,
|
||||||
|
"memlog": str(path),
|
||||||
|
"entries": entry_count(body),
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_init(args) -> int:
|
||||||
|
path = resolve(args)
|
||||||
|
if path.exists():
|
||||||
|
print(f"error: {path} already exists; use append/set to update it", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
meta: dict[str, str] = {}
|
||||||
|
for pair in args.field or []:
|
||||||
|
if "=" not in pair:
|
||||||
|
print(f"error: --field expects key=value, got {pair!r}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
k, v = pair.split("=", 1)
|
||||||
|
meta[k.strip()] = v.strip()
|
||||||
|
touch(meta)
|
||||||
|
write_atomic(path, render(meta, ""))
|
||||||
|
ack(path, "")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_append(args) -> int:
|
||||||
|
path = resolve(args)
|
||||||
|
meta, body = split(path.read_text(encoding="utf-8"))
|
||||||
|
text = " ".join(args.text.split()) # collapse newlines/runs → one-line entry, no prose bloat
|
||||||
|
label = args.type or ""
|
||||||
|
if args.by:
|
||||||
|
label = f"{label} by {args.by}".strip() # attribution: "(idea by user)" / "(by coach)"
|
||||||
|
tag = f"({label}) " if label else ""
|
||||||
|
entry = f"- {tag}{text}"
|
||||||
|
body = (body.rstrip("\n") + "\n" + entry) if body.strip() else entry # always at the end
|
||||||
|
touch(meta)
|
||||||
|
write_atomic(path, render(meta, body))
|
||||||
|
ack(path, body)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_set(args) -> int:
|
||||||
|
path = resolve(args)
|
||||||
|
meta, body = split(path.read_text(encoding="utf-8"))
|
||||||
|
meta[args.key] = args.value
|
||||||
|
touch(meta)
|
||||||
|
write_atomic(path, render(meta, body))
|
||||||
|
ack(path, body)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def add_target(sp) -> None:
|
||||||
|
"""Every command addresses the memlog the same way: a run folder or an explicit path."""
|
||||||
|
g = sp.add_mutually_exclusive_group(required=True)
|
||||||
|
g.add_argument("--workspace", help="run folder; the memlog is {workspace}/.memlog.md")
|
||||||
|
g.add_argument("--path", help="explicit memlog file path (alternative to --workspace)")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
|
||||||
|
pi = sub.add_parser("init", help="create the memlog")
|
||||||
|
add_target(pi)
|
||||||
|
pi.add_argument("--field", action="append", metavar="KEY=VALUE", help="frontmatter field (repeatable)")
|
||||||
|
pi.set_defaults(func=cmd_init)
|
||||||
|
|
||||||
|
pa = sub.add_parser("append", help="append one entry at the end")
|
||||||
|
add_target(pa)
|
||||||
|
pa.add_argument("--text", required=True)
|
||||||
|
pa.add_argument("--type", help="entry kind, rendered as an inline tag")
|
||||||
|
pa.add_argument("--by", help="who the entry came from (e.g. user, coach); rendered into the tag")
|
||||||
|
pa.set_defaults(func=cmd_append)
|
||||||
|
|
||||||
|
pset = sub.add_parser("set", help="set a descriptive frontmatter field")
|
||||||
|
add_target(pset)
|
||||||
|
pset.add_argument("--key", required=True)
|
||||||
|
pset.add_argument("--value", required=True)
|
||||||
|
pset.set_defaults(func=cmd_set)
|
||||||
|
|
||||||
|
args = p.parse_args(argv)
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -0,0 +1,306 @@
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = ["pytest>=8.0"]
|
||||||
|
# ///
|
||||||
|
"""Tests for memlog.py. Run: uv run --with pytest pytest scripts/tests/test_memlog.py
|
||||||
|
|
||||||
|
The spine under test is the flat, append-only, chronological invariant: every entry is
|
||||||
|
one line recorded at the end in the order it happened — no sections, no grouping, and no
|
||||||
|
lifecycle status the log would have to mutate.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
import memlog # noqa: E402
|
||||||
|
|
||||||
|
MEMLOG = ".memlog.md"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ws(tmp_path):
|
||||||
|
return str(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def read(ws):
|
||||||
|
return (Path(ws) / MEMLOG).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def body_of(ws):
|
||||||
|
return memlog.split(read(ws))[1]
|
||||||
|
|
||||||
|
|
||||||
|
def entries(ws):
|
||||||
|
return [ln for ln in body_of(ws).splitlines() if ln.startswith("- ")]
|
||||||
|
|
||||||
|
|
||||||
|
def init(ws, **fields):
|
||||||
|
fields = fields or {"topic": "Reinvent the lunchbox", "goal": "ideas for a pitch"}
|
||||||
|
argv = ["init", "--workspace", ws]
|
||||||
|
for k, v in fields.items():
|
||||||
|
argv += ["--field", f"{k}={v}"]
|
||||||
|
assert memlog.main(argv) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def append(ws, text, entry_type=None, by=None):
|
||||||
|
argv = ["append", "--workspace", ws, "--text", text]
|
||||||
|
if entry_type:
|
||||||
|
argv += ["--type", entry_type]
|
||||||
|
if by:
|
||||||
|
argv += ["--by", by]
|
||||||
|
assert memlog.main(argv) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- init ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_init_writes_frontmatter_fields(ws):
|
||||||
|
init(ws)
|
||||||
|
meta, body = memlog.split(read(ws))
|
||||||
|
assert meta["topic"] == "Reinvent the lunchbox"
|
||||||
|
assert meta["goal"] == "ideas for a pitch"
|
||||||
|
assert "updated" in meta
|
||||||
|
assert body.strip() == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_has_no_lifecycle_status(ws):
|
||||||
|
# A memory log carries no "status" flag; completion is an appended entry, not frontmatter.
|
||||||
|
init(ws)
|
||||||
|
meta, _ = memlog.split(read(ws))
|
||||||
|
assert "status" not in meta
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_arbitrary_fields(ws):
|
||||||
|
init(ws, topic="T", audience="board")
|
||||||
|
meta, _ = memlog.split(read(ws))
|
||||||
|
assert meta["audience"] == "board"
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_refuses_overwrite(ws):
|
||||||
|
init(ws)
|
||||||
|
assert memlog.main(["init", "--workspace", ws, "--field", "topic=other"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_creates_missing_workspace(tmp_path):
|
||||||
|
nested = str(tmp_path / "a" / "b")
|
||||||
|
assert memlog.main(["init", "--workspace", nested, "--field", "topic=T"]) == 0
|
||||||
|
assert (Path(nested) / MEMLOG).is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_rejects_malformed_field(ws):
|
||||||
|
assert memlog.main(["init", "--workspace", ws, "--field", "noequals"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# --- addressing: --workspace and --path are interchangeable --------------
|
||||||
|
|
||||||
|
def test_path_addressing_targets_the_file_directly(tmp_path):
|
||||||
|
target = tmp_path / "run" / ".memlog.md"
|
||||||
|
assert memlog.main(["init", "--path", str(target), "--field", "topic=T"]) == 0
|
||||||
|
assert target.is_file()
|
||||||
|
assert memlog.main(["append", "--path", str(target), "--text", "an idea", "--type", "idea"]) == 0
|
||||||
|
body = memlog.split(target.read_text(encoding="utf-8"))[1]
|
||||||
|
assert "- (idea) an idea" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_and_path_resolve_to_same_file(ws):
|
||||||
|
init(ws)
|
||||||
|
via_path = str(Path(ws) / MEMLOG)
|
||||||
|
assert memlog.main(["append", "--path", via_path, "--text", "from path"]) == 0
|
||||||
|
assert memlog.main(["append", "--workspace", ws, "--text", "from workspace"]) == 0
|
||||||
|
assert entries(ws) == ["- from path", "- from workspace"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_is_required(ws):
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
memlog.main(["append", "--text", "orphan"]) # neither --workspace nor --path
|
||||||
|
|
||||||
|
|
||||||
|
# --- append: flat chronological order is the whole point -----------------
|
||||||
|
|
||||||
|
def test_append_lands_at_end_in_order(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "first")
|
||||||
|
append(ws, "second")
|
||||||
|
append(ws, "third")
|
||||||
|
assert entries(ws) == ["- first", "- second", "- third"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_sections_or_headings_ever(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "started foo", entry_type="technique")
|
||||||
|
append(ws, "an idea", entry_type="idea")
|
||||||
|
append(ws, "started bar", entry_type="technique")
|
||||||
|
assert "## " not in body_of(ws) # the flat log never grows headings
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_renders_as_inline_tag(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "the earth revolves around the sun", entry_type="idea")
|
||||||
|
append(ws, "how do we handle stampede?", entry_type="question")
|
||||||
|
body = body_of(ws)
|
||||||
|
assert "- (idea) the earth revolves around the sun" in body
|
||||||
|
assert "- (question) how do we handle stampede?" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_without_type_is_plain_note(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "bare entry")
|
||||||
|
assert entries(ws) == ["- bare entry"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_is_an_entry_not_a_status(ws):
|
||||||
|
# The documented way to mark a session done: append it. Frontmatter never gains a status.
|
||||||
|
init(ws)
|
||||||
|
append(ws, "session complete", entry_type="event")
|
||||||
|
meta, _ = memlog.split(read(ws))
|
||||||
|
assert "status" not in meta
|
||||||
|
assert entries(ws)[-1] == "- (event) session complete"
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_collapses_newlines_into_one_line(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "line one\nline two\n spaced out")
|
||||||
|
assert entries(ws) == ["- line one line two spaced out"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_revisited_technique_is_just_a_later_entry(ws):
|
||||||
|
# the user's model: switching techniques is an entry, not a section to return to
|
||||||
|
init(ws)
|
||||||
|
append(ws, "started SCAMPER", entry_type="technique")
|
||||||
|
append(ws, "magnetic latch", entry_type="idea")
|
||||||
|
append(ws, "started Six Hats", entry_type="technique")
|
||||||
|
append(ws, "stale data risk", entry_type="idea")
|
||||||
|
append(ws, "started SCAMPER", entry_type="technique") # back to SCAMPER — just appended again
|
||||||
|
append(ws, "stackable tiers", entry_type="idea")
|
||||||
|
assert entries(ws) == [
|
||||||
|
"- (technique) started SCAMPER",
|
||||||
|
"- (idea) magnetic latch",
|
||||||
|
"- (technique) started Six Hats",
|
||||||
|
"- (idea) stale data risk",
|
||||||
|
"- (technique) started SCAMPER",
|
||||||
|
"- (idea) stackable tiers",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_by_renders_attribution_in_tag(ws):
|
||||||
|
# Creative Partner mode must record whose idea each one was
|
||||||
|
init(ws)
|
||||||
|
append(ws, "magnetic latch lid", entry_type="idea", by="user")
|
||||||
|
append(ws, "lid doubles as a plate", entry_type="idea", by="coach")
|
||||||
|
body = body_of(ws)
|
||||||
|
assert "- (idea by user) magnetic latch lid" in body
|
||||||
|
assert "- (idea by coach) lid doubles as a plate" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_by_without_type_renders_alone(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "off-the-cuff thought", by="coach")
|
||||||
|
assert entries(ws) == ["- (by coach) off-the-cuff thought"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_heterogeneous_entry_types_coexist(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "an idea", entry_type="idea")
|
||||||
|
append(ws, "an open question", entry_type="question")
|
||||||
|
append(ws, "a decision we made", entry_type="decision")
|
||||||
|
append(ws, "user wants mobile-first", entry_type="direction")
|
||||||
|
body = body_of(ws)
|
||||||
|
for tag in ("(idea)", "(question)", "(decision)", "(direction)"):
|
||||||
|
assert tag in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_free_vocabulary_is_not_enforced(ws):
|
||||||
|
# The tool is neutral: any --type the host skill names renders verbatim.
|
||||||
|
init(ws)
|
||||||
|
append(ws, "a custom kind", entry_type="crack")
|
||||||
|
append(ws, "another", entry_type="lock")
|
||||||
|
body = body_of(ws)
|
||||||
|
assert "- (crack) a custom kind" in body
|
||||||
|
assert "- (lock) another" in body
|
||||||
|
|
||||||
|
|
||||||
|
# --- set: generic descriptive frontmatter, no lifecycle semantics --------
|
||||||
|
|
||||||
|
def test_set_adds_field(ws):
|
||||||
|
init(ws)
|
||||||
|
memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
|
||||||
|
assert memlog.split(read(ws))[0]["mode"] == "partner"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_replaces_field(ws):
|
||||||
|
init(ws, topic="T", mode="facilitator")
|
||||||
|
memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
|
||||||
|
assert memlog.split(read(ws))[0]["mode"] == "partner"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_preserves_body(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "keep me", entry_type="idea")
|
||||||
|
memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
|
||||||
|
meta, body = memlog.split(read(ws))
|
||||||
|
assert meta["mode"] == "partner"
|
||||||
|
assert "- (idea) keep me" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_updated_stays_last(ws):
|
||||||
|
init(ws)
|
||||||
|
memlog.main(["set", "--workspace", ws, "--key", "owner", "--value", "BMad"])
|
||||||
|
meta = memlog.split(read(ws))[0]
|
||||||
|
assert list(meta)[-1] == "updated"
|
||||||
|
|
||||||
|
|
||||||
|
# --- robustness ---------------------------------------------------------
|
||||||
|
|
||||||
|
def test_roundtrip_render_is_stable(ws):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "one", entry_type="idea")
|
||||||
|
first = read(ws)
|
||||||
|
meta, body = memlog.split(first)
|
||||||
|
assert memlog.render(meta, body) == first
|
||||||
|
|
||||||
|
|
||||||
|
def test_commas_in_field_survive(ws):
|
||||||
|
init(ws, topic="cars, trains, and planes")
|
||||||
|
append(ws, "z", entry_type="idea")
|
||||||
|
meta, _ = memlog.split(read(ws))
|
||||||
|
assert meta["topic"] == "cars, trains, and planes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_triple_dash_in_field_does_not_corrupt_frontmatter(ws):
|
||||||
|
# A `---` inside a value must NOT be read as the closing fence: topic stays intact
|
||||||
|
# and the body never leaks frontmatter text.
|
||||||
|
init(ws, topic="Pricing --- tiers --- and add-ons")
|
||||||
|
append(ws, "an idea", entry_type="idea")
|
||||||
|
meta, body = memlog.split(read(ws))
|
||||||
|
assert meta["topic"] == "Pricing --- tiers --- and add-ons"
|
||||||
|
assert entries(ws) == ["- (idea) an idea"]
|
||||||
|
assert "topic:" not in body # frontmatter never bled into the body
|
||||||
|
|
||||||
|
|
||||||
|
def test_newline_in_field_is_neutralized(ws):
|
||||||
|
# A value carrying a newline can't break the fence on the next round-trip.
|
||||||
|
memlog.main(["init", "--workspace", ws, "--field", "topic=line one\nline two"])
|
||||||
|
append(ws, "x", entry_type="idea")
|
||||||
|
meta, _ = memlog.split(read(ws))
|
||||||
|
assert "\n" not in meta["topic"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_emits_json_ack(ws, capsys):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "x", entry_type="idea")
|
||||||
|
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
||||||
|
assert out["ok"] is True
|
||||||
|
assert out["entries"] == 1
|
||||||
|
assert out["memlog"].endswith(MEMLOG)
|
||||||
|
assert "status" not in out # no lifecycle status
|
||||||
|
assert "section" not in out # sections are gone
|
||||||
|
|
||||||
|
|
||||||
|
def test_ack_entry_count_climbs(ws, capsys):
|
||||||
|
init(ws)
|
||||||
|
append(ws, "a")
|
||||||
|
append(ws, "b")
|
||||||
|
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
||||||
|
assert out["entries"] == 2
|
||||||
|
|
@ -3273,6 +3273,51 @@ async function runTests() {
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Test Suite 45: _cleanupSkillDirs prunes empty parent dirs (#empty-bmm-folders)
|
||||||
|
// ============================================================
|
||||||
|
console.log(`${colors.yellow}Test Suite 45: cleanup prunes empty skill-group dirs${colors.reset}\n`);
|
||||||
|
|
||||||
|
let root45;
|
||||||
|
try {
|
||||||
|
root45 = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-cleanup-test-'));
|
||||||
|
const bmadDir45 = path.join(root45, '_bmad');
|
||||||
|
await fs.ensureDir(path.join(bmadDir45, '_config'));
|
||||||
|
|
||||||
|
// Two skills nested under the same grouping dir (1-analysis), plus a
|
||||||
|
// module-level file that must survive the cleanup.
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(bmadDir45, '_config', 'skill-manifest.csv'),
|
||||||
|
[
|
||||||
|
'canonicalId,name,description,module,path',
|
||||||
|
'"bmad-agent-analyst","bmad-agent-analyst","fixture","bmm","_bmad/bmm/1-analysis/bmad-agent-analyst/SKILL.md"',
|
||||||
|
'"bmad-research","bmad-research","fixture","bmm","_bmad/bmm/1-analysis/research/bmad-research/SKILL.md"',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
await fs.ensureDir(path.join(bmadDir45, 'bmm', '1-analysis', 'bmad-agent-analyst'));
|
||||||
|
await fs.writeFile(path.join(bmadDir45, 'bmm', '1-analysis', 'bmad-agent-analyst', 'SKILL.md'), 'x');
|
||||||
|
await fs.ensureDir(path.join(bmadDir45, 'bmm', '1-analysis', 'research', 'bmad-research'));
|
||||||
|
await fs.writeFile(path.join(bmadDir45, 'bmm', '1-analysis', 'research', 'bmad-research', 'SKILL.md'), 'x');
|
||||||
|
await fs.writeFile(path.join(bmadDir45, 'bmm', 'config.yaml'), 'module: bmm\n');
|
||||||
|
|
||||||
|
const installer45 = new Installer();
|
||||||
|
await installer45._cleanupSkillDirs(bmadDir45);
|
||||||
|
|
||||||
|
assert(!(await fs.pathExists(path.join(bmadDir45, 'bmm', '1-analysis'))), 'empty skill-group dir is pruned after cleanup');
|
||||||
|
assert(!(await fs.pathExists(path.join(bmadDir45, 'bmm', '1-analysis', 'research'))), 'empty nested skill-group dir is pruned');
|
||||||
|
assert(await fs.pathExists(path.join(bmadDir45, 'bmm', 'config.yaml')), 'module-level files are preserved');
|
||||||
|
assert(await fs.pathExists(bmadDir45), 'bmad root is never removed');
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${colors.red}Test Suite 45 setup failed: ${error.message}${colors.reset}`);
|
||||||
|
console.log(error.stack);
|
||||||
|
failed++;
|
||||||
|
} finally {
|
||||||
|
if (root45) await fs.remove(root45).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Summary
|
// Summary
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -419,10 +419,35 @@ class Installer {
|
||||||
const sourceDir = path.dirname(path.join(bmadDir, relativePath));
|
const sourceDir = path.dirname(path.join(bmadDir, relativePath));
|
||||||
if (await fs.pathExists(sourceDir)) {
|
if (await fs.pathExists(sourceDir)) {
|
||||||
await fs.remove(sourceDir);
|
await fs.remove(sourceDir);
|
||||||
|
await this._removeEmptyParents(path.dirname(sourceDir), bmadDir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove now-empty parent directories left behind after skill dir cleanup.
|
||||||
|
* Walks up from dir, stopping at (and never removing) bmadDir. Best-effort:
|
||||||
|
* a directory that vanishes or fills in mid-walk just ends the walk.
|
||||||
|
* @param {string} dir - Directory to start walking up from
|
||||||
|
* @param {string} bmadDir - BMAD installation directory (boundary)
|
||||||
|
*/
|
||||||
|
async _removeEmptyParents(dir, bmadDir) {
|
||||||
|
let current = dir;
|
||||||
|
while (true) {
|
||||||
|
// Path-boundary check (not a string prefix, so siblings like _bmad2 don't match).
|
||||||
|
const rel = path.relative(bmadDir, current);
|
||||||
|
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) break;
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(current);
|
||||||
|
if (entries.length > 0) break;
|
||||||
|
await fs.rmdir(current);
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = path.dirname(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async _readSkillManifestRows(bmadDir) {
|
async _readSkillManifestRows(bmadDir) {
|
||||||
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
|
const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
|
||||||
if (!(await fs.pathExists(csvPath))) return [];
|
if (!(await fs.pathExists(csvPath))) return [];
|
||||||
|
|
@ -630,6 +655,7 @@ class Installer {
|
||||||
/**
|
/**
|
||||||
* Sync src/scripts/* → _bmad/scripts/ so shared Python scripts
|
* Sync src/scripts/* → _bmad/scripts/ so shared Python scripts
|
||||||
* (e.g. resolve_customization.py) are available at install time.
|
* (e.g. resolve_customization.py) are available at install time.
|
||||||
|
* Excludes dev-only tests and Python caches so they don't ship to users.
|
||||||
* Wipes the destination first so files removed or renamed in source
|
* Wipes the destination first so files removed or renamed in source
|
||||||
* don't linger and get recorded as installed. Also seeds
|
* don't linger and get recorded as installed. Also seeds
|
||||||
* _bmad/custom/.gitignore on fresh installs so *.user.toml overrides
|
* _bmad/custom/.gitignore on fresh installs so *.user.toml overrides
|
||||||
|
|
@ -643,7 +669,12 @@ class Installer {
|
||||||
|
|
||||||
await fs.remove(paths.scriptsDir);
|
await fs.remove(paths.scriptsDir);
|
||||||
await fs.ensureDir(paths.scriptsDir);
|
await fs.ensureDir(paths.scriptsDir);
|
||||||
await fs.copy(srcScriptsDir, paths.scriptsDir, { overwrite: true });
|
// Ship only the runtime scripts — dev-only tests and Python caches must not land in user projects.
|
||||||
|
const isInstallable = (srcPath) => {
|
||||||
|
const base = path.basename(srcPath);
|
||||||
|
return base !== 'tests' && base !== '__pycache__' && base !== '.pytest_cache' && !base.endsWith('.pyc');
|
||||||
|
};
|
||||||
|
await fs.copy(srcScriptsDir, paths.scriptsDir, { overwrite: true, filter: isInstallable });
|
||||||
await this._trackFilesRecursive(paths.scriptsDir);
|
await this._trackFilesRecursive(paths.scriptsDir);
|
||||||
|
|
||||||
const customGitignore = path.join(paths.customDir, '.gitignore');
|
const customGitignore = path.join(paths.customDir, '.gitignore');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue