From b05a563272138da7aea40fb5e06968233bb3b8b4 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Sat, 1 Aug 2026 10:41:25 -0700 Subject: [PATCH] feat: unify build skills on shared renderer --- package.json | 7 +- src/bmm-skills/ship/bmad-build-auto/SKILL.md | 2 +- src/bmm-skills/ship/bmad-build/SKILL.md | 8 +- src/bmm-skills/ship/bmad-build/render.py | 420 -------------- .../bmad-build/step-01-clarify-and-route.md | 24 +- .../ship/bmad-build/step-02-plan.md | 6 +- .../ship/bmad-build/step-03-implement.md | 4 +- .../ship/bmad-build/step-04-review.md | 8 +- .../ship/bmad-build/step-05-present.md | 2 +- .../ship/bmad-build/step-oneshot.md | 10 +- .../ship/bmad-build/sync-sprint-status.md | 4 +- src/bmm-skills/ship/bmad-build/workflow.md | 8 +- src/scripts/render_skill.py | 3 +- test/test-build-auto-contract.js | 137 ----- test/test-build-auto-renderer.js | 124 +++- test/test-build-renderer.js | 528 ------------------ test/test-installation-components.js | 66 ++- 17 files changed, 215 insertions(+), 1146 deletions(-) delete mode 100644 src/bmm-skills/ship/bmad-build/render.py delete mode 100644 test/test-build-auto-contract.js delete mode 100644 test/test-build-renderer.js diff --git a/package.json b/package.json index 8473b0a47f..cf6cff764e 100644 --- a/package.json +++ b/package.json @@ -40,14 +40,13 @@ "lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix", "lint:md": "markdownlint-cli2 \"**/*.md\"", "prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0", - "quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:site-url && npm run test:install && npm run test:urls && npm run test:renderer && npm run test:retrospective && npm run test:build-auto-contract && npm run validate:refs && npm run validate:skills && npm run docs:validate-sidebar", + "quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:site-url && npm run test:install && npm run test:urls && npm run test:renderer && npm run test:retrospective && npm run validate:refs && npm run validate:skills && npm run docs:validate-sidebar", "rebundle": "node tools/installer/bundlers/bundle-web.js rebundle", - "test": "npm run test:refs && npm run test:install && npm run test:urls && npm run test:site-url && npm run test:channels && npm run test:renderer && npm run test:retrospective && npm run test:build-auto-contract && npm run test:skills && npm run lint && npm run lint:md && npm run format:check", - "test:build-auto-contract": "node test/test-build-auto-contract.js", + "test": "npm run test:refs && npm run test:install && npm run test:urls && npm run test:site-url && npm run test:channels && npm run test:renderer && npm run test:retrospective && npm run test:skills && npm run lint && npm run lint:md && npm run format:check", "test:channels": "node test/test-installer-channels.js", "test:install": "node test/test-installation-components.js", "test:refs": "node test/test-file-refs-csv.js", - "test:renderer": "uv run --python 3.11 python -m unittest src/scripts/tests/test_config_utils.py src/scripts/tests/test_resolve_config.py src/scripts/tests/test_resolve_customization.py && node test/test-build-renderer.js && node test/test-build-auto-renderer.js", + "test:renderer": "uv run --python 3.11 python -m unittest src/scripts/tests/test_config_utils.py src/scripts/tests/test_resolve_config.py src/scripts/tests/test_resolve_customization.py && node test/test-build-auto-renderer.js", "test:retrospective": "uv run --python 3.11 src/bmm-skills/ship/bmad-retrospective/scripts/tests/test_git_evidence.py && uv run --python 3.11 src/bmm-skills/ship/bmad-retrospective/scripts/tests/test_sprint_status.py", "test:site-url": "node test/test-site-url.mjs", "test:skills": "node test/test-validate-skills.js", diff --git a/src/bmm-skills/ship/bmad-build-auto/SKILL.md b/src/bmm-skills/ship/bmad-build-auto/SKILL.md index 7525f93fb9..193d367c8f 100644 --- a/src/bmm-skills/ship/bmad-build-auto/SKILL.md +++ b/src/bmm-skills/ship/bmad-build-auto/SKILL.md @@ -3,7 +3,7 @@ name: bmad-build-auto description: 'One iteration of an unattended development loop. Use when invoked by name.' --- -Run this single command exactly once, substituting the absolute project and skill roots without changing the working directory: +Run the following command exactly once without changing the current working directory. Replace `{project-root}` with the absolute path to the project root and `{skill-root}` with the absolute path to this skill's directory: ```bash uv run --no-cache "{project-root}/_bmad/scripts/render_skill.py" --project-root "{project-root}" --skill "{skill-root}" diff --git a/src/bmm-skills/ship/bmad-build/SKILL.md b/src/bmm-skills/ship/bmad-build/SKILL.md index e0e3c648de..e7cbbf80b8 100644 --- a/src/bmm-skills/ship/bmad-build/SKILL.md +++ b/src/bmm-skills/ship/bmad-build/SKILL.md @@ -3,11 +3,11 @@ name: bmad-build description: 'Implements any user intent, requirement, story, bug fix or change request by producing clean working code artifacts that follow the project''s existing architecture, patterns and conventions. Use when the user wants to build, fix, tweak, refactor, add or modify any code, component or feature.' --- -Run this, substituting `{skill-root}` with the absolute path to this skill's base directory, without changing the cwd: +Run the following command exactly once without changing the current working directory. Replace `{project-root}` with the absolute path to the project root and `{skill-root}` with the absolute path to this skill's directory: ```bash -uv run --no-cache {skill-root}/render.py +uv run --no-cache "{project-root}/_bmad/scripts/render_skill.py" --project-root "{project-root}" --skill "{skill-root}" ``` -- **On success:** follow the instruction it prints to stdout; ignore stderr. -- **On any failure** (including `uv` not being installed): report what it printed and HALT. +- On success, read and follow the one absolute `workflow.md` instruction printed to stdout. +- On failure (including `uv` being unavailable), report the command output and HALT. Do not run any workflow source directly. diff --git a/src/bmm-skills/ship/bmad-build/render.py b/src/bmm-skills/ship/bmad-build/render.py deleted file mode 100644 index 3b33ce0275..0000000000 --- a/src/bmm-skills/ship/bmad-build/render.py +++ /dev/null @@ -1,420 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.11" -# /// -"""render.py — bmad-build template renderer. - -Resolves compile-time {{.variable}} placeholders from BMad's central config, -bakes absolute paths for {project-root} into derived values, resolves and -inlines the skill's [workflow] customization block, and writes rendered .md -files to {project-root}/_bmad/render/bmad-build/. - -Config: four-layer merge of _bmad/config.toml + config.user.toml + -custom/config.toml + custom/config.user.toml (post-#2285 installs). -Keys surface from [core] and [modules.bmm]. Missing or unparseable -config.toml → HALT. A {{.var}} referenced by this skill's .md sources but -absent from the merged config → HALT (never a silent empty substitution). -Optional layers may be missing, but one that exists and cannot be parsed -or read → HALT. - -Customization: three-layer merge of {skill}/customize.toml + -_bmad/custom/bmad-build.toml + .user.toml (same structural rules as -resolve_customization.py). The resolved [workflow] values fill {workflow.*} -placeholders, so this skill needs no runtime resolve_customization.py call. -Other single-curly placeholders ({project-root}, {spec_file}, ...) pass -through untouched for the LLM to resolve during workflow execution. The sole -exception is {skill-root} in resolved review-layer instructions, which must be -an absolute prompt-file path before those instructions reach the parent LLM. - -Every invocation rebuilds from scratch — no hash, no cache. -Python 3.11+ stdlib only. UTF-8 I/O. -""" - -import os -import posixpath -import re -import sys -import tomllib - - -def find_project_root(): - """Walk up from cwd until a _bmad/ directory is found. On failure, print a - HALT instruction to stdout and exit non-zero.""" - current = os.path.abspath(os.getcwd()) - while True: - candidate = os.path.join(current, "_bmad") - if os.path.isdir(candidate): - return current - parent = os.path.dirname(current) - if parent == current: - print( - f"HALT and report to the user: no _bmad/ directory found walking up from {os.getcwd()}" - ) - sys.exit(1) - current = parent - - -def load_toml(path, required=False): - """Load a TOML file. Only absence is negotiable: a missing optional file - returns {} (customization layers are optional), a missing required file - HALTs. A file that exists but cannot be parsed or read always HALTs — - stdout is how this script signals workflow halts to its LLM caller — the - user wrote it to be honored, and silently continuing with {} would discard - their customizations with no failure signal.""" - if not os.path.isfile(path): - if required: - print( - f"HALT and report to the user: required config file not found: {path} — " - "ensure this is a post-#2285 BMAD install" - ) - sys.exit(1) - return {} - try: - with open(path, "rb") as fh: - parsed = tomllib.load(fh) - except tomllib.TOMLDecodeError as error: - print(f"HALT and report to the user: failed to parse {path}: {error}") - sys.exit(1) - except OSError as error: - print(f"HALT and report to the user: failed to read {path}: {error}") - sys.exit(1) - if not isinstance(parsed, dict): - return {} - return parsed - - -def _deep_merge(base, override): - """Dict-aware deep merge. Lists and scalars: override wins (we don't need - the full keyed-merge semantics of resolve_config.py — build only reads - flat scalars out of [core] and [modules.bmm]).""" - if isinstance(base, dict) and isinstance(override, dict): - result = dict(base) - for key, value in override.items(): - result[key] = _deep_merge(result[key], value) if key in result else value - return result - return override - - -def _detect_keyed_merge_field(items): - """Return 'code' or 'id' if every table item carries that same field. - Mixed or partial arrays return None and fall through to append.""" - if not items or not all(isinstance(item, dict) for item in items): - return None - for candidate in ("code", "id"): - if all(item.get(candidate) is not None for item in items): - return candidate - return None - - -def _merge_by_key(base, override, key_name): - result = [] - index_by_key = {} - for item in base: - if not isinstance(item, dict): - continue - if item.get(key_name) is not None: - index_by_key[item[key_name]] = len(result) - result.append(dict(item)) - for item in override: - if not isinstance(item, dict): - result.append(item) - continue - key = item.get(key_name) - if key is not None and key in index_by_key: - result[index_by_key[key]] = dict(item) - else: - if key is not None: - index_by_key[key] = len(result) - result.append(dict(item)) - return result - - -def _merge_arrays(base, override): - """Shape-aware array merge: keyed merge if every item has code/id, else append.""" - base_arr = base if isinstance(base, list) else [] - override_arr = override if isinstance(override, list) else [] - keyed_field = _detect_keyed_merge_field(base_arr + override_arr) - if keyed_field: - return _merge_by_key(base_arr, override_arr, keyed_field) - return base_arr + override_arr - - -def _structural_merge(base, override): - """Faithful port of resolve_customization.py's deep_merge: tables deep-merge, - arrays-of-tables keyed by code/id replace-then-append (other arrays append), - scalars override. Used only for the [workflow] customization layers — the - central-config path keeps its own simpler _deep_merge. Duplicated rather than - imported to keep this skill self-contained.""" - if isinstance(base, dict) and isinstance(override, dict): - result = dict(base) - for key, over_val in override.items(): - result[key] = ( - _structural_merge(result[key], over_val) if key in result else over_val - ) - return result - if isinstance(base, list) and isinstance(override, list): - return _merge_arrays(base, override) - return override - - -def resolve_workflow(root, skill_dir, skill_name): - """Resolve the [workflow] customization block via the three-layer merge - (skill defaults -> team -> user), highest priority last. Same structural - rules as resolve_customization.py. All three layers are optional: a missing - file is skipped, but an unparseable one HALTs (via load_toml).""" - defaults = load_toml(posixpath.join(skill_dir, "customize.toml")) - custom_dir = posixpath.join(root, "_bmad", "custom") - team = load_toml(posixpath.join(custom_dir, f"{skill_name}.toml")) - user = load_toml(posixpath.join(custom_dir, f"{skill_name}.user.toml")) - merged = _structural_merge(defaults, team) - merged = _structural_merge(merged, user) - workflow = merged.get("workflow") - return workflow if isinstance(workflow, dict) else {} - - -def load_central_config(root): - """Four-layer merge of _bmad/config.toml and its peers (highest priority - last). HALTs if the base _bmad/config.toml is missing or unparseable.""" - bmad_dir = posixpath.join(root, "_bmad") - base_team = load_toml(posixpath.join(bmad_dir, "config.toml"), required=True) - base_user = load_toml(posixpath.join(bmad_dir, "config.user.toml")) - custom_team = load_toml(posixpath.join(bmad_dir, "custom", "config.toml")) - custom_user = load_toml(posixpath.join(bmad_dir, "custom", "config.user.toml")) - - merged = _deep_merge(base_team, base_user) - merged = _deep_merge(merged, custom_team) - merged = _deep_merge(merged, custom_user) - return merged - - -def flatten_central_config(merged): - """Lift scalar keys from [core] and [modules.bmm] into a single namespace. - Module keys take precedence on collision (installer strips core keys from - module buckets, so collisions shouldn't happen in practice).""" - flat = {} - modules = merged.get("modules") - modules = modules if isinstance(modules, dict) else {} - for section in (merged.get("core"), modules.get("bmm")): - if not isinstance(section, dict): - continue - for key, value in section.items(): - if isinstance(value, bool): - flat[key] = "true" if value else "false" - elif isinstance(value, (str, int, float)): - flat[key] = str(value) - return flat - - -def render_template(content, vars_): - """Resolve {{.var}} substitutions. Unresolved references emit an empty string, - but main() HALTs on any missing reference before rendering starts, so this - fallback never fires in practice.""" - return re.sub(r"\{\{\.(\w+)\}\}", lambda m: vars_.get(m.group(1), ""), content) - - -def collect_missing_vars(sources, vars_): - """Map each {{.var}} name referenced by the source .md files but absent from - the merged config to the files that reference it. A missing key must HALT: - missingkey=zero rendering would bake a corrupted workflow (empty paths, - blank language lines) with no failure signal.""" - missing = {} - for fname, content in sources: - for name in re.findall(r"\{\{\.(\w+)\}\}", content): - if name not in vars_: - files = missing.setdefault(name, []) - if fname not in files: - files.append(fname) - return missing - - -def _scalar_str(value): - """Stringify a scalar for inline rendering: booleans lowercase (matching - BMad config conventions), None as empty, everything else via str().""" - if value is None: - return "" - if isinstance(value, bool): - return "true" if value else "false" - return str(value) - - -# [workflow] keys holding review layers ([[workflow.review_layers]] tables with -# id/name/instruction/when fields). This renderer knows this skill's -# customization schema outright — layer semantics are materialized here, not -# interpreted by the LLM at run time. -_REVIEW_LAYER_KEYS = ("review_layers", "oneshot_review_layers") - - -def expand_review_layer_skill_roots(workflow, skill_root): - """Expand only {skill-root} inside resolved review-layer instructions. - - Review layers originate in customization after regular template rendering, - so their prompt paths need this explicit pass. Runtime placeholders remain - untouched for the parent to supply to each child at dispatch time. - """ - expanded_workflow = dict(workflow) - for key in _REVIEW_LAYER_KEYS: - layers = workflow.get(key) - if not isinstance(layers, list): - continue - expanded_layers = [] - for layer in layers: - if not isinstance(layer, dict): - expanded_layers.append(layer) - continue - expanded_layer = dict(layer) - instruction = expanded_layer.get("instruction") - if isinstance(instruction, str): - expanded_layer["instruction"] = instruction.replace( - "{skill-root}", skill_root - ) - expanded_layers.append(expanded_layer) - expanded_workflow[key] = expanded_layers - return expanded_workflow - - -def _render_review_layers(layers): - """Materialize review layers into direct invocation blocks. A layer with an - empty or missing instruction is disabled (that is how an override turns off - a default layer) and drops out entirely. A `when` condition is the one part - that stays with the LLM: it renders as a run-time guard line. No active - layers renders as the HALT instruction the workflow would otherwise have to - derive from an empty list.""" - active = [ - layer - for layer in layers - if isinstance(layer, dict) and _scalar_str(layer.get("instruction")).strip() - ] - if not active: - return ( - "No review layers are active. HALT with status `blocked` and " - "blocking condition `no active review layers`." - ) - blocks = [] - for layer in active: - title = ( - _scalar_str(layer.get("name")).strip() - or _scalar_str(layer.get("id")).strip() - or "Review layer" - ) - lines = [f"#### {title}", ""] - when = _scalar_str(layer.get("when")).strip() - if when: - lines.append( - "Run this layer only if the following holds in the " - f"current context: `{when}`" - ) - lines.append("") - lines.append(_scalar_str(layer.get("instruction")).strip("\n")) - blocks.append("\n".join(lines)) - return "\n\n".join(blocks) - - -def _render_workflow_value(key, value): - """Format a resolved [workflow] value for inline substitution. Review-layer - keys materialize as invocation blocks; other lists render as markdown - bullets (empty -> '_None._'); scalars render verbatim. Each list item uses - the same scalar formatting so booleans stay consistent. Entries are emitted - as-is so runtime placeholders like {project-root} or {diff_output} survive - for the LLM to resolve.""" - if key in _REVIEW_LAYER_KEYS and isinstance(value, list): - return _render_review_layers(value) - if isinstance(value, list): - if not value: - return "_None._" - return "\n".join(f"- {_scalar_str(item)}" for item in value) - return _scalar_str(value) - - -def render_workflow(content, workflow): - """Resolve {workflow.} placeholders from the resolved [workflow] block. - Unknown keys emit an empty string (missingkey=zero, matching render_template). - Distinct regex from render_template so single-curly runtime placeholders - elsewhere are untouched.""" - return re.sub( - r"\{workflow\.(\w+)\}", - lambda m: _render_workflow_value(m.group(1), workflow.get(m.group(1))), - content, - ) - - -def main(): - script_dir = os.path.dirname(os.path.abspath(__file__)) - skill_name = os.path.basename(script_dir) - root = find_project_root() - root = root.replace(os.sep, "/") - - vars_ = flatten_central_config(load_central_config(root)) - - for key in list(vars_.keys()): - vars_[key] = vars_[key].replace("{project-root}", root) - - vars_["project_root"] = root - - # Guarded ahead of the general missing-vars scan: sprint_status and - # deferred_work_file derive from it below, and unlike the scan (absent - # keys only) this also HALTs on a present-but-empty value. - implementation_artifacts = vars_.get("implementation_artifacts", "").strip() - if not implementation_artifacts: - print( - "HALT and report to the user: config is missing `implementation_artifacts` " - "(expected under [core] or [modules.bmm] in _bmad/config.toml)" - ) - sys.exit(1) - - vars_["sprint_status"] = posixpath.join( - implementation_artifacts, "sprint-status.yaml" - ) - vars_["deferred_work_file"] = posixpath.join( - implementation_artifacts, "deferred-work.md" - ) - - sources = [] - for fname in sorted(os.listdir(script_dir)): - if not fname.endswith(".md") or fname == "SKILL.md": - continue - with open( - posixpath.join(script_dir, fname), "r", encoding="utf-8", newline="" - ) as fh: - sources.append((fname, fh.read())) - - missing = collect_missing_vars(sources, vars_) - if missing: - details = "; ".join( - f"`{name}` (referenced by {', '.join(files)})" - for name, files in sorted(missing.items()) - ) - print( - f"HALT and report to the user: config is missing {details} " - "(expected under [core] or [modules.bmm] in _bmad/config.toml)" - ) - sys.exit(1) - - skill_dir = script_dir.replace(os.sep, "/") - workflow = resolve_workflow(root, skill_dir, skill_name) - open_spec = workflow.get("open_spec") - if not isinstance(open_spec, str): - print( - "HALT and report to the user: customization `workflow.open_spec` " - "must be a string" - ) - sys.exit(1) - workflow = expand_review_layer_skill_roots(workflow, skill_dir) - - out_dir = posixpath.join(root, "_bmad", "render", skill_name) - os.makedirs(out_dir, exist_ok=True) - - for fname in os.listdir(out_dir): - if fname.endswith(".md"): - os.remove(posixpath.join(out_dir, fname)) - - for fname, content in sources: - dst = posixpath.join(out_dir, fname) - with open(dst, "w", encoding="utf-8", newline="") as fh: - fh.write(render_workflow(render_template(content, vars_), workflow)) - - workflow_md = posixpath.join(out_dir, "workflow.md") - print(f"read and follow {workflow_md}") - - -if __name__ == "__main__": - main() diff --git a/src/bmm-skills/ship/bmad-build/step-01-clarify-and-route.md b/src/bmm-skills/ship/bmad-build/step-01-clarify-and-route.md index 1313fef65c..9535b5ac5b 100644 --- a/src/bmm-skills/ship/bmad-build/step-01-clarify-and-route.md +++ b/src/bmm-skills/ship/bmad-build/step-01-clarify-and-route.md @@ -20,7 +20,7 @@ Before listing artifacts or prompting the user, check whether you already know t 1. Explicit argument Did the user pass a specific file path, spec name, or clear instruction this message? - - If it points to a file that matches the spec template (has `status` frontmatter with a recognized value: draft, ready-for-dev, in-progress, in-review, or done) → set `spec_file`. Before exiting, run **Story-key resolution** (below). Then **EARLY EXIT** to the appropriate step (step-02 for draft, step-03 for ready/in-progress, step-04 for review). For `done`, ingest as context and proceed to INSTRUCTIONS — do not resume. + - If it points to a file that matches the spec template (has `status` frontmatter with a recognized value: draft, ready-for-dev, in-progress, in-review, or done) → set `spec_file`. Before exiting, run **Story-key resolution** (below). Then **EARLY EXIT** to the appropriate step: `draft` → `[[bmad-snapshot:step-02-plan.md]]`, `ready-for-dev`/`in-progress` → `[[bmad-snapshot:step-03-implement.md]]`, `in-review` → `[[bmad-snapshot:step-04-review.md]]`. For `done`, ingest as context and proceed to INSTRUCTIONS — do not resume. - Anything else (intent files, external docs, plans, descriptions) → ingest it as starting intent and proceed to INSTRUCTIONS. Do not attempt to infer a workflow state from it. 2. Recent conversation @@ -29,9 +29,9 @@ Before listing artifacts or prompting the user, check whether you already know t 3. Otherwise — scan artifacts and ask - Active specs (`draft`, `ready-for-dev`, `in-progress`, `in-review`) in `{{.implementation_artifacts}}`? → List them and HALT. Ask user which to resume (or `[N]` for new). - - If `draft` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT** → `./step-02-plan.md` (resume planning from the draft) - - If `ready-for-dev` or `in-progress` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT** → `./step-03-implement.md` - - If `in-review` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT** → `./step-04-review.md` + - If `draft` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT** → `[[bmad-snapshot:step-02-plan.md]]` (resume planning from the draft) + - If `ready-for-dev` or `in-progress` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT** → `[[bmad-snapshot:step-03-implement.md]]` + - If `in-review` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT** → `[[bmad-snapshot:step-04-review.md]]` - Unformatted spec or intent file lacking `status` frontmatter? → Suggest treating its contents as the starting intent. Do NOT attempt to infer a state and resume it. Never ask extra questions if you already understand what the user intends. @@ -40,7 +40,7 @@ Never ask extra questions if you already understand what the user intends. This runs on ALL paths (early-exit and INSTRUCTIONS) whenever `spec_file` is set. Determine whether the spec is an epic story — use the spec's filename, frontmatter, and any loaded epics file to identify `epic_num` and `story_num`. If the spec is not an epic story, skip silently and leave `story_key` unset. -If the spec is an epic story and `{{.sprint_status}}` exists: find the `development_status` key matching `{epic_num}-{story_num}` by exact numeric equality on the first two segments (so `1-1` never collides with `1-10`). Exactly one match → set `story_key` to that full key. Zero or multiple matches → leave `story_key` unset (warn on multiple). +If the spec is an epic story and `{{.implementation_artifacts}}/sprint-status.yaml` exists: find the `development_status` key matching `{epic_num}-{story_num}` by exact numeric equality on the first two segments (so `1-1` never collides with `1-10`). Exactly one match → set `story_key` to that full key. Zero or multiple matches → leave `story_key` unset (warn on multiple). ## INSTRUCTIONS @@ -57,9 +57,9 @@ If the spec is an epic story and `{{.sprint_status}}` exists: find the `developm - **If valid:** load it as the primary planning context. Do not load raw planning docs (PRD, architecture, UX, etc.). Skip to step 5. - **If missing, empty, or invalid:** continue to step 3. - 3. **Compile epic context.** Produce `{{.implementation_artifacts}}/epic--context.md` by following `./compile-epic-context.md`, in order of preference: - - **Preferred — subagent:** spawn a subagent synchronously (wait for it to return in this turn) with `./compile-epic-context.md` as its prompt. Pass it the epic number, the epics file path, the `{{.planning_artifacts}}` directory, and the output path `{{.implementation_artifacts}}/epic--context.md`. - - **Fallback — inline** (for runtimes without subagent support, e.g. Copilot, Codex, local Ollama, older Claude): if your runtime cannot spawn subagents, or the spawn fails/times out, read `./compile-epic-context.md` yourself and follow its instructions to produce the same output file. + 3. **Compile epic context.** Produce `{{.implementation_artifacts}}/epic--context.md` by following `[[bmad-snapshot:compile-epic-context.md]]`, in order of preference: + - **Preferred — subagent:** spawn a subagent synchronously (wait for it to return in this turn) with `[[bmad-snapshot:compile-epic-context.md]]` as its prompt. Pass it the epic number, the epics file path, the `{{.planning_artifacts}}` directory, and the output path `{{.implementation_artifacts}}/epic--context.md`. + - **Fallback — inline** (for runtimes without subagent support, e.g. Copilot, Codex, local Ollama, older Claude): if your runtime cannot spawn subagents, or the spawn fails/times out, read `[[bmad-snapshot:compile-epic-context.md]]` yourself and follow its instructions to produce the same output file. 4. **Verify.** After compilation, verify the output file exists, is non-empty, and starts with `# Epic Context:`. If valid, load it. If verification fails, HALT and report the failure. @@ -81,7 +81,7 @@ If the spec is an epic story and `{{.sprint_status}}` exists: find the `developm - Present detected distinct goals as a bullet list. - Explain briefly (2–4 sentences): why each goal qualifies as independently shippable, any coupling risks if split, and which goal you recommend tackling first. - HALT and ask human: `[S] Split — pick first goal, defer the rest` | `[K] Keep all goals — accept the risks` - - On **S**: For each deferred goal, append one new entry to `{{.deferred_work_file}}` using this format. Do not modify existing entries or look for duplicates. Narrow scope to the first-mentioned goal. Continue routing. + - On **S**: For each deferred goal, append one new entry to `{{.implementation_artifacts}}/deferred-work.md` using this format. Do not modify existing entries or look for duplicates. Narrow scope to the first-mentioned goal. Continue routing. ```markdown - source_spec: none summary: @@ -90,14 +90,14 @@ If the spec is an epic story and `{{.sprint_status}}` exists: find the `developm - On **K**: Proceed as-is. 5. Route — choose exactly one: - Derive a valid kebab-case slug from the clarified intent. If the intent references a tracking identifier (story number, issue number, ticket ID), lead the slug with it (e.g. `3-2-digest-delivery`, `gh-47-fix-auth`). If `{{.implementation_artifacts}}/spec-{slug}.md` already exists: if its status is `draft`, treat it as the same work and resume it (set `spec_file` to that path, **EARLY EXIT** → `./step-02-plan.md`); otherwise append `-2`, `-3`, etc. Set `spec_file` = `{{.implementation_artifacts}}/spec-{slug}.md`. + Derive a valid kebab-case slug from the clarified intent. If the intent references a tracking identifier (story number, issue number, ticket ID), lead the slug with it (e.g. `3-2-digest-delivery`, `gh-47-fix-auth`). If `{{.implementation_artifacts}}/spec-{slug}.md` already exists: if its status is `draft`, treat it as the same work and resume it (set `spec_file` to that path, **EARLY EXIT** → `[[bmad-snapshot:step-02-plan.md]]`); otherwise append `-2`, `-3`, etc. Set `spec_file` = `{{.implementation_artifacts}}/spec-{slug}.md`. **a) One-shot** — zero blast radius: no plausible path by which this change causes unintended consequences elsewhere. Clear intent, no architectural decisions. - **EARLY EXIT** → `./step-oneshot.md` + **EARLY EXIT** → `[[bmad-snapshot:step-oneshot.md]]` **b) Plan-code-review** — everything else. When uncertain whether blast radius is truly zero, choose this path. ## NEXT -Read fully and follow `./step-02-plan.md` +Read fully and follow `[[bmad-snapshot:step-02-plan.md]]` diff --git a/src/bmm-skills/ship/bmad-build/step-02-plan.md b/src/bmm-skills/ship/bmad-build/step-02-plan.md index b429acc182..fd57589513 100644 --- a/src/bmm-skills/ship/bmad-build/step-02-plan.md +++ b/src/bmm-skills/ship/bmad-build/step-02-plan.md @@ -9,13 +9,13 @@ 1. Draft resume check. If `{spec_file}` exists with `status: draft`, read it and capture the verbatim `...` block as `preserved_intent`. Otherwise `preserved_intent` is empty. 2. Investigate codebase. _Isolate deep exploration in synchronous subagents/tasks where available. To prevent context snowballing, instruct subagents to give you distilled summaries only._ Decide which findings actually matter for execution — the specific files, symbols/lines, reuse points, and read-only constraints — and carry those forward for the Code Map. This is where the investigation lands: the spec preserves it so it is never re-narrated to the implementer at dispatch time. -3. Read `./spec-template.md` fully. Fill it out based on the intent and investigation, resolving the template's `date` field to the current system date. Drain the investigation into the `## Code Map` section — annotated paths, symbol/line anchors, reuse pointers, and read-only evidence — so the spec is the implementer's investigation map and the step-03 handoff need only point at it. If `preserved_intent` is non-empty, replace the `` block in the spec you just filled out with `preserved_intent`, before writing. Write the result to `{spec_file}`. +3. Read `[[bmad-snapshot:spec-template.md]]` fully. Fill it out based on the intent and investigation, resolving the template's `date` field to the current system date. Drain the investigation into the `## Code Map` section — annotated paths, symbol/line anchors, reuse pointers, and read-only evidence — so the spec is the implementer's investigation map and the step-03 handoff need only point at it. If `preserved_intent` is non-empty, replace the `` block in the spec you just filled out with `preserved_intent`, before writing. Write the result to `{spec_file}`. 4. Self-review against READY FOR DEVELOPMENT standard. 5. If intent gaps exist, do not fantasize, do not leave open questions, HALT and ask the human. 6. Token count check (see SCOPE STANDARD). If spec exceeds 1600 tokens: - Show user the token count. - HALT and ask human: `[S] Split — carve off secondary goals` | `[K] Keep full spec — accept the risks` - - On **S**: Propose the split — name each secondary goal. For each deferred goal, append one new entry to `{{.deferred_work_file}}` using this format. Do not modify existing entries or look for duplicates. Rewrite the current spec to cover only the main goal — do not surgically carve sections out; regenerate the spec for the narrowed scope. Continue to checkpoint. + - On **S**: Propose the split — name each secondary goal. For each deferred goal, append one new entry to `{{.implementation_artifacts}}/deferred-work.md` using this format. Do not modify existing entries or look for duplicates. Rewrite the current spec to cover only the main goal — do not surgically carve sections out; regenerate the spec for the narrowed scope. Continue to checkpoint. ```markdown - source_spec: `{spec_file}` summary: @@ -44,4 +44,4 @@ HALT and ask human: `[A] Approve` | `[E] Edit` ## NEXT -Read fully and follow `./step-03-implement.md` +Read fully and follow `[[bmad-snapshot:step-03-implement.md]]` diff --git a/src/bmm-skills/ship/bmad-build/step-03-implement.md b/src/bmm-skills/ship/bmad-build/step-03-implement.md index d377fc7944..33579f1c36 100644 --- a/src/bmm-skills/ship/bmad-build/step-03-implement.md +++ b/src/bmm-skills/ship/bmad-build/step-03-implement.md @@ -24,7 +24,7 @@ Capture `baseline_commit` (current HEAD, or `NO_VCS` if version control is unava Change `{spec_file}` status to `in-progress` in the frontmatter before starting implementation. -Follow `./sync-sprint-status.md` with `target_status` = `in-progress`. +Follow `[[bmad-snapshot:sync-sprint-status.md]]` with `target_status` = `in-progress`. Execute the implementation handoff below: substitute the runtime placeholders (e.g. `{spec_file}`) into it, then follow it verbatim. @@ -46,4 +46,4 @@ If `{spec_file}`'s `` block contains an I/O & Edge-Case M ## NEXT -Read fully and follow `./step-04-review.md` +Read fully and follow `[[bmad-snapshot:step-04-review.md]]` diff --git a/src/bmm-skills/ship/bmad-build/step-04-review.md b/src/bmm-skills/ship/bmad-build/step-04-review.md index 4d46f71a5e..1477a9f7a7 100644 --- a/src/bmm-skills/ship/bmad-build/step-04-review.md +++ b/src/bmm-skills/ship/bmad-build/step-04-review.md @@ -39,10 +39,10 @@ If a layer's instruction requires subagents and none are available, for each suc - **defer** — pre-existing issue not caused by this story, surfaced incidentally by the review. Collect for later focused attention. - **reject** — noise. Drop silently. When unsure between defer and reject, prefer reject — only defer findings you are confident are real. 4. Process findings in cascading order. If intent_gap or bad_spec findings exist, they trigger a loopback — lower findings are moot since code will be re-derived. If neither exists, process patch and defer normally. Before each loopback, read `{spec_file}` frontmatter `review_loop_iteration` (missing means `0`), increment it by 1, and write it back. If it exceeds 5, HALT and escalate to the human. - - **intent_gap** — Root cause is inside ``. Revert code changes. Loop back to the human to resolve. Once resolved, read fully and follow `./step-02-plan.md` to re-run steps 2–4. - - **bad_spec** — Root cause is outside ``. Before reverting code: extract KEEP instructions for positive preservation (what worked well and must survive re-derivation). Revert code changes. Read the `## Spec Change Log` in `{spec_file}` and strictly respect all logged constraints when amending the non-frozen sections that contain the root cause. Append a new change-log entry recording: the triggering finding, what was amended, the known-bad state avoided, and the KEEP instructions. Read fully and follow `./step-03-implement.md` to re-derive the code, then this step will run again. + - **intent_gap** — Root cause is inside ``. Revert code changes. Loop back to the human to resolve. Once resolved, read fully and follow `[[bmad-snapshot:step-02-plan.md]]` to re-run steps 2–4. + - **bad_spec** — Root cause is outside ``. Before reverting code: extract KEEP instructions for positive preservation (what worked well and must survive re-derivation). Revert code changes. Read the `## Spec Change Log` in `{spec_file}` and strictly respect all logged constraints when amending the non-frozen sections that contain the root cause. Append a new change-log entry recording: the triggering finding, what was amended, the known-bad state avoided, and the KEEP instructions. Read fully and follow `[[bmad-snapshot:step-03-implement.md]]` to re-derive the code, then this step will run again. - **patch** — Auto-fix. These are the only findings that survive loopbacks. If the step-03 implementation subagent can be re-engaged with its context intact, send it all patch findings in one synchronous message — for each: the file, what is wrong, and what the fix must do. If it cannot be re-engaged, apply the patches yourself. Then re-run the checks in `{spec_file}`'s `## Verification` section, if present; if verification fails and the failure cannot be fixed, HALT and escalate to the human. - - **defer** — Append one new entry to `{{.deferred_work_file}}` using this format. Do not modify existing entries or look for duplicates. + - **defer** — Append one new entry to `{{.implementation_artifacts}}/deferred-work.md` using this format. Do not modify existing entries or look for duplicates. ```markdown - source_spec: `{spec_file}` summary: @@ -52,4 +52,4 @@ If a layer's instruction requires subagents and none are available, for each suc ## NEXT -Read fully and follow `./step-05-present.md` +Read fully and follow `[[bmad-snapshot:step-05-present.md]]` diff --git a/src/bmm-skills/ship/bmad-build/step-05-present.md b/src/bmm-skills/ship/bmad-build/step-05-present.md index 9e7f7af400..23ba72843c 100644 --- a/src/bmm-skills/ship/bmad-build/step-05-present.md +++ b/src/bmm-skills/ship/bmad-build/step-05-present.md @@ -52,7 +52,7 @@ When there is only one concern, omit the bold label — just list the stops dire Change `{spec_file}` status to `done` in the frontmatter. -Follow `./sync-sprint-status.md` with `target_status` = `review`. +Follow `[[bmad-snapshot:sync-sprint-status.md]]` with `target_status` = `review`. ### Commit and Complete diff --git a/src/bmm-skills/ship/bmad-build/step-oneshot.md b/src/bmm-skills/ship/bmad-build/step-oneshot.md index 3be28b3a75..1b4b20a122 100644 --- a/src/bmm-skills/ship/bmad-build/step-oneshot.md +++ b/src/bmm-skills/ship/bmad-build/step-oneshot.md @@ -11,7 +11,7 @@ ### Implement -Follow `./sync-sprint-status.md` with `target_status` = `in-progress`. +Follow `[[bmad-snapshot:sync-sprint-status.md]]` with `target_status` = `in-progress`. Implement the clarified intent directly. @@ -28,7 +28,7 @@ If a layer's instruction requires subagents and none are available, for each suc Deduplicate all review findings. Three categories only: - **patch** — trivially fixable. Auto-fix immediately. -- **defer** — pre-existing issue not caused by this change. Append one new entry to `{{.deferred_work_file}}` using this format. Do not modify existing entries or look for duplicates. +- **defer** — pre-existing issue not caused by this change. Append one new entry to `{{.implementation_artifacts}}/deferred-work.md` using this format. Do not modify existing entries or look for duplicates. ```markdown - source_spec: `{spec_file}` summary: @@ -42,13 +42,13 @@ If a finding is caused by this change but too significant for a trivial patch, H Set `title` = a concise title derived from the clarified intent. -Write `{spec_file}` using `./spec-template.md`. Fill only these sections — delete all others: +Write `{spec_file}` using `[[bmad-snapshot:spec-template.md]]`. Fill only these sections — delete all others: 1. **Frontmatter** — set `title: '{title}'`, `type`, `created`, `status: 'done'`. Add `route: 'one-shot'`. 2. **Title and Intent** — `# {title}` heading and `## Intent` with **Problem** and **Approach** lines. Reuse the summary you already generated for the terminal. -3. **Suggested Review Order** — append after Intent. Build using the same convention as `./step-05-present.md` § "Generate Suggested Review Order" (spec-file-relative links, concern-based ordering, ultra-concise framing). +3. **Suggested Review Order** — append after Intent. Build using the same convention as `[[bmad-snapshot:step-05-present.md]]` § "Generate Suggested Review Order" (spec-file-relative links, concern-based ordering, ultra-concise framing). -Follow `./sync-sprint-status.md` with `target_status` = `review`. +Follow `[[bmad-snapshot:sync-sprint-status.md]]` with `target_status` = `review`. ### Commit diff --git a/src/bmm-skills/ship/bmad-build/sync-sprint-status.md b/src/bmm-skills/ship/bmad-build/sync-sprint-status.md index 53e669f7f7..0fe825ef23 100644 --- a/src/bmm-skills/ship/bmad-build/sync-sprint-status.md +++ b/src/bmm-skills/ship/bmad-build/sync-sprint-status.md @@ -6,11 +6,11 @@ Shared sub-step for updating `sprint-status.yaml` during build. Called from any Skip this entire file (return to caller) if ANY of: - `story_key` is unset -- `{{.sprint_status}}` does not exist on disk +- `{{.implementation_artifacts}}/sprint-status.yaml` does not exist on disk ## Instructions -1. Load the FULL `{{.sprint_status}}` file. +1. Load the FULL `{{.implementation_artifacts}}/sprint-status.yaml` file. 2. Find the `development_status` entry matching `{story_key}`. If not found, warn the user once (`"{story_key} not found in sprint-status; skipping sprint sync"`) and return to caller. 3. **Idempotency check.** If `development_status[{story_key}]` is already at `target_status` or a later state (`review` is later than `in-progress`; `done` is later than both), return to caller — no write needed. Never regress a story's status. 4. Set `development_status[{story_key}]` to `{target_status}`. diff --git a/src/bmm-skills/ship/bmad-build/workflow.md b/src/bmm-skills/ship/bmad-build/workflow.md index b95f6bd9e4..ce7a80accb 100644 --- a/src/bmm-skills/ship/bmad-build/workflow.md +++ b/src/bmm-skills/ship/bmad-build/workflow.md @@ -2,7 +2,7 @@ **Goal:** Turn user intent into a hardened, reviewable artifact. -**CRITICAL:** If a step says "read fully and follow step-XX", you read and follow step-XX. No exceptions. +**CRITICAL:** If a step directs you to another snapshot file, read it fully and follow it. No exceptions. Subagents, when the capability is available, are an important part of this workflow. Use them as directed by the workflow steps. If you need an explicit user instruction to run them, ask once now for the whole workflow run. @@ -30,10 +30,8 @@ A specification should target a **single user-facing goal** within **900–1600 ## Conventions -- Bare paths (e.g. `step-01-clarify-and-route.md`) resolve from the skill root. -- `{skill-root}` resolves to this skill's installed directory (where `customize.toml` lives). +- Every operational cross-file reference in this workflow is an absolute snapshot path. Open it directly; do not resolve it relative to a skill directory. - `{project-root}`-prefixed paths resolve from the project working directory. -- `{skill-name}` resolves to the skill directory's basename. - Whenever this workflow captures or records a version-control revision, obtain the full canonical identifier directly from version control and preserve it verbatim. ## On Activation @@ -83,4 +81,4 @@ This uses **step-file architecture** for disciplined execution: ## FIRST STEP -Read fully and follow: `./step-01-clarify-and-route.md` to begin the workflow. +Read fully and follow: `[[bmad-snapshot:step-01-clarify-and-route.md]]` to begin the workflow. diff --git a/src/scripts/render_skill.py b/src/scripts/render_skill.py index d88fd6e7bb..e0667e81b1 100644 --- a/src/scripts/render_skill.py +++ b/src/scripts/render_skill.py @@ -177,7 +177,8 @@ def _format_review_layers(layers: list[dict[str, str]]) -> str: def _resolve_customization_value(value: Any, default: Any, label: str) -> tuple[Any, str]: if isinstance(default, str): - resolved = _require_string(value, label, allow_empty=not default.strip()) + allow_empty = not default.strip() or label == "customization.workflow.open_spec" + resolved = _require_string(value, label, allow_empty=allow_empty) return resolved, resolved if isinstance(default, list): if default and all(isinstance(item, dict) for item in default): diff --git a/test/test-build-auto-contract.js b/test/test-build-auto-contract.js deleted file mode 100644 index 85190502f8..0000000000 --- a/test/test-build-auto-contract.js +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Regression coverage for bmad-build-auto's deferred-finding contract. - * - * Ensures the canonical source keeps: - * 1. Machine-readable `deferred` frontmatter on the spec template. - * 2. Review-step instructions that persist deferred findings only in the spec. - * 3. Reference docs that tell orchestrators to read deferred findings from the spec. - */ - -'use strict'; - -const fs = require('node:fs'); -const path = require('node:path'); -const yaml = require('yaml'); - -const colors = { - reset: '\u001B[0m', - green: '\u001B[32m', - red: '\u001B[31m', - cyan: '\u001B[36m', -}; - -let totalTests = 0; -let passedTests = 0; -const failures = []; - -function test(name, fn) { - totalTests++; - try { - fn(); - passedTests++; - console.log(` ${colors.green}\u2713${colors.reset} ${name}`); - } catch (error) { - console.log(` ${colors.red}\u2717${colors.reset} ${name} ${colors.red}${error.message}${colors.reset}`); - failures.push({ name, message: error.message }); - } -} - -function assert(condition, message) { - if (!condition) throw new Error(message); -} - -function read(relativePath) { - return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf-8'); -} - -function parseFrontmatter(content, relativePath) { - assert(content.startsWith('---\n'), `${relativePath} must start with a frontmatter delimiter`); - const end = content.indexOf('\n---\n', 4); - assert(end !== -1, `${relativePath} must close its frontmatter delimiter`); - return yaml.parse(content.slice(4, end)); -} - -function dedent(content) { - const lines = content.split('\n'); - const indents = lines.filter((line) => line.trim()).map((line) => line.match(/^ */)[0].length); - const width = Math.min(...indents); - return lines.map((line) => line.slice(width)).join('\n'); -} - -console.log(`\n${colors.cyan}bmad-build-auto deferred contract${colors.reset}\n`); - -test('spec template exposes machine-readable deferred frontmatter', () => { - const relativePath = 'src/bmm-skills/ship/bmad-build-auto/spec-template.md'; - const frontmatter = parseFrontmatter(read(relativePath), relativePath); - assert(Array.isArray(frontmatter.deferred), 'spec-template.md frontmatter must declare deferred as a list'); - assert(frontmatter.deferred.length === 0, 'spec-template.md deferred list must start empty'); -}); - -test('build-auto steps preserve their frontmatter boundaries', () => { - const root = 'src/bmm-skills/ship/bmad-build-auto'; - const stepOnePath = `${root}/step-01-clarify-and-route.md`; - const stepOneFrontmatter = parseFrontmatter(read(stepOnePath), stepOnePath); - assert(stepOneFrontmatter.spec_file === '', 'step-01 must define spec_file in frontmatter'); - assert(stepOneFrontmatter.spec_folder === '', 'step-01 must define spec_folder in frontmatter'); - assert(stepOneFrontmatter.story_id === '', 'step-01 must define story_id in frontmatter'); - - for (const filename of ['step-02-plan.md', 'step-04-review.md']) { - const relativePath = `${root}/${filename}`; - const content = read(relativePath); - if (content.startsWith('---\n')) parseFrontmatter(content, relativePath); - } -}); - -test('review step safely records deferred findings only in the spec', () => { - const content = read('src/bmm-skills/ship/bmad-build-auto/step-04-review.md'); - assert(content.includes('If the field is absent'), 'step-04-review.md must initialize deferred for legacy specs'); - assert(content.includes('never add a second `deferred:` key'), 'step-04-review.md must forbid duplicate deferred keys'); - assert(content.includes('parse the complete frontmatter as YAML'), 'step-04-review.md must validate the updated frontmatter'); - assert(!content.includes('deferred_work_file'), 'step-04-review.md must not mention a deferred-work ledger path'); - assert(!content.includes('deferred-work.md'), 'step-04-review.md must not mention the deferred-work ledger artifact'); - - const example = content.match(/```yaml\n([\s\S]*?)\n[ \t]*```/); - assert(example, 'step-04-review.md must include the deferred YAML example'); - const specialCharacters = dedent(example[1]) - .replace('', 'Parser fails: malformed # input') - .replace('', 'Observed: value # remains data\n Second evidence line'); - const parsed = yaml.parse(specialCharacters); - assert(parsed.deferred[0].summary === 'Parser fails: malformed # input', 'summary example must preserve YAML-special characters'); - assert( - parsed.deferred[0].evidence === 'Observed: value # remains data\nSecond evidence line', - 'evidence example must preserve YAML-special characters and line breaks', - ); -}); - -test('reference docs direct orchestrators to the spec deferred list', () => { - const content = read('docs/reference/build-auto.md'); - assert( - content.includes('Read deferred findings from the spec frontmatter `deferred:` list'), - 'docs/reference/build-auto.md must tell orchestrators where to read deferred findings', - ); - assert(!content.includes('deferred-work.md'), 'docs/reference/build-auto.md must not describe a deferred-work ledger artifact'); -}); - -test('Chinese reference documents the same deferred contract', () => { - const content = read('docs/zh-cn/reference/build-auto.md'); - assert(content.includes('spec frontmatter 的 `deferred:` list'), 'Chinese reference must direct orchestrators to the deferred list'); - assert(!content.includes('deferred-work.md'), 'Chinese reference must not describe a deferred-work ledger artifact'); -}); - -console.log(`\n${colors.cyan}${'═'.repeat(55)}${colors.reset}`); -console.log(`${colors.cyan}Test Results:${colors.reset}`); -console.log(` Total: ${totalTests}`); -console.log(` Passed: ${colors.green}${passedTests}${colors.reset}`); -console.log(` Failed: ${passedTests === totalTests ? colors.green : colors.red}${totalTests - passedTests}${colors.reset}`); -console.log(`${colors.cyan}${'═'.repeat(55)}${colors.reset}\n`); - -if (failures.length > 0) { - console.log(`${colors.red}FAILED TESTS:${colors.reset}\n`); - for (const failure of failures) { - console.log(`${colors.red}\u2717${colors.reset} ${failure.name}`); - console.log(` ${failure.message}\n`); - } - process.exit(1); -} - -console.log(`${colors.green}All tests passed!${colors.reset}\n`); diff --git a/test/test-build-auto-renderer.js b/test/test-build-auto-renderer.js index a14b6c4562..761c07fc4a 100644 --- a/test/test-build-auto-renderer.js +++ b/test/test-build-auto-renderer.js @@ -1,6 +1,6 @@ // Test only deterministic renderer behavior. // Do not test model inference or assert prose copied verbatim from skill sources. -/** Black-box tests for the installed build-auto immutable snapshot renderer. */ +/** Black-box tests for the shared immutable snapshot renderer, covering both bmad-build-auto and bmad-build. */ 'use strict'; const crypto = require('node:crypto'); @@ -11,7 +11,8 @@ const { spawn, spawnSync } = require('node:child_process'); const REPO = path.resolve(__dirname, '..'); const SCRIPT_SRC = path.join(REPO, 'src', 'scripts'); -const SKILL_SRC = path.join(REPO, 'src', 'bmm-skills', 'ship', 'bmad-build-auto'); +const SKILLS_SRC = path.join(REPO, 'src', 'bmm-skills', 'ship'); +const DEFAULT_SKILL = 'bmad-build-auto'; const tempDirs = []; let total = 0; let passed = 0; @@ -67,7 +68,7 @@ function baseConfig(extra = '') { ].join('\n'); } -function fixture({ sharedBmad, config = baseConfig(), projectName = 'project' } = {}) { +function fixture({ sharedBmad, config = baseConfig(), projectName = 'project', skillName = DEFAULT_SKILL } = {}) { const outer = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-build-auto-render-')); tempDirs.push(outer); const project = path.join(outer, projectName); @@ -78,12 +79,12 @@ function fixture({ sharedBmad, config = baseConfig(), projectName = 'project' } for (const name of ['config_utils.py', 'render_skill.py']) { fs.copyFileSync(path.join(SCRIPT_SRC, name), path.join(bmad, 'scripts', name)); } - copyDir(SKILL_SRC, path.join(bmad, 'bmm', 'bmad-build-auto')); + copyDir(path.join(SKILLS_SRC, skillName), path.join(bmad, 'bmm', skillName)); fs.writeFileSync(path.join(bmad, 'config.toml'), config, 'utf8'); } fs.symlinkSync(bmad, path.join(project, '_bmad'), process.platform === 'win32' ? 'junction' : 'dir'); fs.mkdirSync(path.join(project, 'nested', 'cwd'), { recursive: true }); - return { outer, project, bmad, skill: path.join(bmad, 'bmm', 'bmad-build-auto') }; + return { outer, project, bmad, skillName, skill: path.join(bmad, 'bmm', skillName) }; } function run(fix, cwd = fix.project) { @@ -251,7 +252,7 @@ async function main() { assert(result.status !== 0 && result.stdout.startsWith('HALT:'), 'malformed config did not HALT'); assert(!result.stdout.includes('read and follow') && !result.stderr.includes('Traceback'), 'failure leaked dispatch/traceback'); fs.rmSync(path.join(invalid.bmad, 'custom', 'config.toml')); - fs.writeFileSync(path.join(invalid.bmad, 'custom', 'bmad-build-auto.toml'), '[workflow\nbad', 'utf8'); + fs.writeFileSync(path.join(invalid.bmad, 'custom', `${invalid.skillName}.toml`), '[workflow\nbad', 'utf8'); result = run(invalid); assert(result.status !== 0 && result.stdout.includes('failed to parse'), 'malformed customization did not HALT'); }); @@ -264,7 +265,7 @@ async function main() { const keyed = fixture(); fs.mkdirSync(path.join(keyed.bmad, 'custom'), { recursive: true }); fs.writeFileSync( - path.join(keyed.bmad, 'custom', 'bmad-build-auto.toml'), + path.join(keyed.bmad, 'custom', `${keyed.skillName}.toml`), '[[workflow.review_layers]]\nid = 42\nname = "bad"\ninstruction = "bad"\n', 'utf8', ); @@ -277,7 +278,7 @@ async function main() { const literal = '[[bmad-snapshot:step-04-review.md]]'; const compileLiteral = '{workflow.implementation_handoff}'; fs.writeFileSync( - path.join(custom.bmad, 'custom', 'bmad-build-auto.user.toml'), + path.join(custom.bmad, 'custom', `${custom.skillName}.user.toml`), `[workflow]\non_complete = "Preserve ${literal} and ${compileLiteral} as prose"\n`, 'utf8', ); @@ -290,7 +291,7 @@ async function main() { const reviewed = fixture(); fs.mkdirSync(path.join(reviewed.bmad, 'custom'), { recursive: true }); fs.writeFileSync( - path.join(reviewed.bmad, 'custom', 'bmad-build-auto.toml'), + path.join(reviewed.bmad, 'custom', `${reviewed.skillName}.toml`), [ '[[workflow.review_layers]]', 'id = "blind-hunter"', @@ -308,7 +309,7 @@ async function main() { const ids = ['blind-hunter', 'edge-case-hunter', 'verification-gap', 'intent-alignment']; fs.writeFileSync( - path.join(reviewed.bmad, 'custom', 'bmad-build-auto.toml'), + path.join(reviewed.bmad, 'custom', `${reviewed.skillName}.toml`), ids.map((id) => `[[workflow.review_layers]]\nid = "${id}"\nname = "disabled"\ninstruction = ""\n`).join('\n'), 'utf8', ); @@ -392,7 +393,7 @@ async function main() { .toLowerCase() .replaceAll(/[^a-z0-9]+/g, '-'); const rootHash = hash(Buffer.from(fs.realpathSync(broken.project))).slice(0, 12); - const namespace = path.join(stable.bmad, 'render', 'bmad-build-auto', `${slug}-${rootHash}`); + const namespace = path.join(stable.bmad, 'render', stable.skillName, `${slug}-${rootHash}`); fs.writeFileSync(namespace, 'not a directory', 'utf8'); const result = run(broken); assert(result.status !== 0 && result.stdout.startsWith('HALT:'), 'publication failure did not HALT'); @@ -413,8 +414,107 @@ async function main() { assert(fs.readFileSync(workflow, 'utf8').endsWith('corrupt'), 'corrupt generation was overwritten'); }); + test('bmad-build renders through the same shared snapshot contract', () => { + const build = fixture({ skillName: 'bmad-build' }); + const output = entry(run(build)); + const dir = path.dirname(output); + assert(path.basename(output) === 'workflow.md', `dispatch is not a snapshot workflow.md: ${output}`); + assert(output.includes(`${path.sep}render${path.sep}bmad-build${path.sep}`), 'bmad-build snapshot namespace missing'); + + const markdown = Object.entries(bytesByName(dir)) + .filter(([name]) => name.endsWith('.md')) + .map(([, content]) => content.toString('utf8')) + .join('\n'); + assert(!markdown.includes('{{.'), 'config token survived'); + assert(!markdown.includes('{workflow.'), 'customization token survived'); + assert(!markdown.includes('[[bmad-snapshot:'), 'snapshot token survived'); + assert(!/`\.{1,2}\/[^`]*\.md`/.test(markdown), 'relative skill-root reference survived'); + assert(!markdown.includes('resolve_customization.py'), 'legacy renderer script referenced'); + assert(!markdown.includes('main_config'), 'legacy config variable referenced'); + + const renderRoot = path.join(fs.realpathSync(build.project), '_bmad', 'render'); + const referenced = new Set(); + for (const match of markdown.matchAll(/`(\/[^`]+\.md)`/g)) { + const target = match[1]; + if (!target.startsWith(`${renderRoot}${path.sep}`)) continue; + assert(target.startsWith(`${dir}${path.sep}`), `cross-generation reference: ${target}`); + assert(fs.existsSync(target), `snapshot reference does not resolve: ${target}`); + referenced.add(path.relative(dir, target)); + } + // Every published step must be reachable, which also keeps the loop above non-vacuous. + for (const name of Object.keys(bytesByName(dir))) { + if (!/^(?:step-|sync-sprint-status)/.test(name)) continue; + assert(referenced.has(name), `published step is unreachable from the snapshot: ${name}`); + } + + const prompt = path.join(dir, 'review-prompts', 'edge-case-hunter.md'); + assert(fs.existsSync(prompt), 'review prompt was not published into the snapshot'); + assert(markdown.includes(prompt), 'snapshot reviewer path missing'); + + const review = fs.readFileSync(path.join(dir, 'step-04-review.md'), 'utf8'); + for (const heading of [ + '#### Blind Hunter (`blind-hunter`)', + '#### Edge Case Hunter (`edge-case-hunter`)', + '#### Verification Gap Reviewer (`verification-gap`)', + ]) { + assert(review.includes(heading), `default review layer missing: ${heading}`); + } + assert(review.includes('{diff_output}'), 'runtime placeholder was removed from review layers'); + + const oneshot = fs.readFileSync(path.join(dir, 'step-oneshot.md'), 'utf8'); + assert(oneshot.includes('#### Blind Hunter (`blind-hunter`)'), 'oneshot review layer block missing'); + + // The spec editor handoff must reach both terminal routes (#2652). + const present = fs.readFileSync(path.join(dir, 'step-05-present.md'), 'utf8'); + assert(present.includes('code -r'), 'open_spec default missing from step-05-present.md'); + assert(oneshot.includes('code -r'), 'open_spec default missing from step-oneshot.md'); + assert(/^Offer to push\b/m.test(present), 'standalone "Offer to push" line was lost'); + + const artifacts = `${fs.realpathSync(build.project)}/implementation`; + assert(markdown.includes(`${artifacts}/sprint-status.yaml`), 'sprint-status path was not baked absolute'); + assert(markdown.includes(`${artifacts}/deferred-work.md`), 'deferred-work path was not baked absolute'); + + for (const name of ['step-01-clarify-and-route.md', 'step-02-plan.md', 'step-04-review.md', 'step-oneshot.md']) { + const site = fs.readFileSync(path.join(dir, name), 'utf8'); + assert(site.includes(`${artifacts}/deferred-work.md`), `${name} does not contain the deferred-work path`); + } + + const shipped = fs.readFileSync(path.join(SKILLS_SRC, 'bmad-build', 'customize.toml'), 'utf8'); + assert( + !shipped.includes('{absolute-root}') && !shipped.includes('{absolute-spec-file}'), + 'legacy absolute-path token in customize.toml', + ); + }); + + test('empty open_spec override disables automatic opening', () => { + const build = fixture({ skillName: 'bmad-build' }); + fs.mkdirSync(path.join(build.bmad, 'custom'), { recursive: true }); + fs.writeFileSync(path.join(build.bmad, 'custom', `${build.skillName}.user.toml`), '[workflow]\nopen_spec = ""\n', 'utf8'); + const dir = path.dirname(entry(run(build))); + for (const name of ['step-05-present.md', 'step-oneshot.md']) { + const rendered = fs.readFileSync(path.join(dir, name), 'utf8'); + assert(!rendered.includes('code -r'), `open_spec default survived in ${name}`); + assert(!rendered.includes('spec was sent'), `opening summary survived in ${name}`); + assert(rendered.includes('Suggested Review Order'), `review trail generation disappeared from ${name}`); + } + }); + + test('the command shipped in SKILL.md dispatches for both skills', () => { + for (const skillName of [DEFAULT_SKILL, 'bmad-build']) { + const fix = fixture({ skillName }); + const fenced = fs.readFileSync(path.join(fix.skill, 'SKILL.md'), 'utf8').match(/```bash\n([\s\S]*?)```/); + assert(fenced, `${skillName}: SKILL.md ships no bash command block`); + const command = fenced[1].trim().replaceAll('{project-root}', fix.project).replaceAll('{skill-root}', fix.skill); + assert(!command.includes('{'), `${skillName}: unsubstituted placeholder in shipped command: ${command}`); + // Run it verbatim from a nested cwd — no --python pin, exactly as an agent would. + const dispatched = entry(spawnSync(command, { cwd: path.join(fix.project, 'nested', 'cwd'), shell: true, encoding: 'utf8' })); + assert(path.basename(dispatched) === 'workflow.md', `${skillName}: shipped command did not dispatch workflow.md`); + assert(fs.existsSync(dispatched), `${skillName}: dispatched entry does not exist`); + } + }); + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); - console.log(`\n${passed}/${total} build-auto renderer tests passed`); + console.log(`\n${passed}/${total} shared renderer tests passed`); process.exitCode = passed === total ? 0 : 1; } diff --git a/test/test-build-renderer.js b/test/test-build-renderer.js deleted file mode 100644 index 594bc7a356..0000000000 --- a/test/test-build-renderer.js +++ /dev/null @@ -1,528 +0,0 @@ -// Test only deterministic renderer behavior. -// Do not test model inference or assert prose copied verbatim from skill sources. -/** - * Smoke test for bmad-build render.py - * - * Sets up a temp project with base + override config layers and a - * _bmad/custom/bmad-build.user.toml [workflow] override, runs render.py, - * and asserts: - * 1. The central-config override wins (step files' language line contains "Japanese"). - * 2. sprint_status is an absolute path rooted at the temp project dir. - * 3. [workflow] customization is self-resolved and inlined: prepend bullet, - * persistent_facts append (base kept), empty list -> _None._, on_complete - * and open_spec scalars baked into step-05/step-oneshot. - * 4. Review layers materialize as direct invocation blocks: default layers - * become #### sections in step-04, an override replacing a layer by id - * wins, an empty-instruction override drops its layer, a `when` renders - * as a run-time guard, runtime placeholders like {diff_output} survive, - * and disabling every layer renders the HALT instruction. - * 5. No {workflow.*} placeholder or resolve_customization.py call survives - * in any rendered file. - * - * Usage: node test/test-build-renderer.js - * Exit codes: 0 = all tests pass, 1 = test failures - */ - -'use strict'; - -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { spawnSync } = require('node:child_process'); - -// ANSI color codes (same as other test files) -const colors = { - reset: '\u001B[0m', - green: '\u001B[32m', - red: '\u001B[31m', - cyan: '\u001B[36m', -}; - -let totalTests = 0; -let passedTests = 0; -const failures = []; - -function test(name, fn) { - totalTests++; - try { - fn(); - passedTests++; - console.log(` ${colors.green}\u2713${colors.reset} ${name}`); - } catch (error) { - console.log(` ${colors.red}\u2717${colors.reset} ${name} ${colors.red}${error.message}${colors.reset}`); - failures.push({ name, message: error.message }); - } -} - -function assert(condition, message) { - if (!condition) throw new Error(message); -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const SKILL_SRC = path.join(__dirname, '..', 'src', 'bmm-skills', 'ship', 'bmad-build'); - -/** - * Recursively copy a directory (stdlib only, no fs.cp to stay >=20 compat). - */ -function copyDirSync(src, dst) { - fs.mkdirSync(dst, { recursive: true }); - for (const entry of fs.readdirSync(src, { withFileTypes: true })) { - const srcPath = path.join(src, entry.name); - const dstPath = path.join(dst, entry.name); - if (entry.isDirectory()) { - copyDirSync(srcPath, dstPath); - } else { - fs.copyFileSync(srcPath, dstPath); - } - } -} - -// Extra one-off temp projects created by makeProject(); cleaned up in finally. -const extraTmpDirs = []; - -/** - * Spin up an isolated temp project with the given _bmad/config.toml body and a - * copy of the skill dir, so a single bad-config scenario can be rendered in - * isolation. Returns { dir, skillDst }; the caller runs render.py against it. - */ -function makeProject(configText) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-renderer-halt-')); - extraTmpDirs.push(dir); - fs.mkdirSync(path.join(dir, '_bmad'), { recursive: true }); - fs.writeFileSync(path.join(dir, '_bmad', 'config.toml'), configText, 'utf-8'); - const skillDst = path.join(dir, 'bmad-build'); - copyDirSync(SKILL_SRC, skillDst); - return { dir, skillDst }; -} - -// --------------------------------------------------------------------------- -// Test fixture setup -// --------------------------------------------------------------------------- - -const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-renderer-test-')); - -try { - // _bmad/config.toml — base layer - fs.mkdirSync(path.join(tmpDir, '_bmad'), { recursive: true }); - fs.writeFileSync( - path.join(tmpDir, '_bmad', 'config.toml'), - [ - '[core]', - 'communication_language = "French"', - 'document_output_language = "Klingon"', - '', - '[modules.bmm]', - 'planning_artifacts = "{project-root}/plan"', - 'implementation_artifacts = "{project-root}/impl"', - ].join('\n'), - 'utf-8', - ); - - // _bmad/custom/config.user.toml — override layer (should win) - fs.mkdirSync(path.join(tmpDir, '_bmad', 'custom'), { recursive: true }); - fs.writeFileSync( - path.join(tmpDir, '_bmad', 'custom', 'config.user.toml'), - ['[core]', 'communication_language = "Japanese"'].join('\n'), - 'utf-8', - ); - - // _bmad/custom/bmad-build.user.toml — [workflow] customization override. - // Exercises render.py's self-resolution: array append (persistent_facts), - // list inlining (activation_steps_prepend), and scalar override (on_complete), - // all baked into the rendered output with no runtime resolve_customization.py. - fs.writeFileSync( - path.join(tmpDir, '_bmad', 'custom', 'bmad-build.user.toml'), - [ - '[workflow]', - 'activation_steps_prepend = ["TEST_PREPEND_STEP"]', - 'persistent_facts = ["TEST_EXTRA_FACT"]', - 'on_complete = "TEST_ON_COMPLETE_INSTRUCTION"', - 'open_spec = """', - 'TEST_OPEN_SPEC_LINE_ONE', - 'TEST_OPEN_SPEC_LINE_TWO', - '"""', - '', - '[[workflow.review_layers]]', - 'id = "edge-case-hunter"', - 'name = "Replaced Layer"', - 'when = "TEST_WHEN_CONDITION"', - 'instruction = "TEST_REPLACED_LAYER_INSTRUCTION"', - '', - '[[workflow.review_layers]]', - 'id = "verification-gap"', - 'instruction = ""', - ].join('\n'), - 'utf-8', - ); - - // Copy skill dir into /bmad-build/ so find_project_root() walks - // up and finds /_bmad/, and os.path.basename(script_dir) resolves - // to the real skill name so the render output lands at - // _bmad/render/bmad-build/workflow.md. - const skillDst = path.join(tmpDir, 'bmad-build'); - copyDirSync(SKILL_SRC, skillDst); - - // --------------------------------------------------------------------------- - // Run render.py - // --------------------------------------------------------------------------- - - console.log(`\n${colors.cyan}Build renderer smoke tests${colors.reset}\n`); - - const result = spawnSync('python3', [path.join(skillDst, 'render.py')], { - cwd: skillDst, - encoding: 'utf-8', - }); - - const renderDir = path.join(tmpDir, '_bmad', 'render', 'bmad-build'); - const readRendered = (name) => fs.readFileSync(path.join(renderDir, name), 'utf-8'); - const renderedMdFiles = () => fs.readdirSync(renderDir).filter((f) => f.endsWith('.md')); - - // --------------------------------------------------------------------------- - // Tests - // --------------------------------------------------------------------------- - - test('render.py exits with code 0', () => { - assert(result.status === 0, `exit code ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); - }); - - test('workflow.md exists in render output', () => { - const rendered = path.join(tmpDir, '_bmad', 'render', 'bmad-build', 'workflow.md'); - assert(fs.existsSync(rendered), `workflow.md not found at ${rendered}`); - }); - - test('custom override wins — communication_language baked into step files', () => { - const content = readRendered('step-01-clarify-and-route.md'); - assert(content.includes('Japanese'), 'communication_language override (Japanese) did not win in the step-01 language line'); - }); - - test('document_output_language bakes into the per-step language line', () => { - const content = readRendered('step-01-clarify-and-route.md'); - assert(content.includes('Klingon'), 'document_output_language not baked into the step-01 language line'); - }); - - test('sprint_status is an absolute path rooted at temp project dir', () => { - const content = readRendered('sync-sprint-status.md'); - // Normalize to forward slashes for cross-platform matching - const normalizedTmp = tmpDir.replaceAll('\\', '/'); - // sprint_status should appear as /impl/sprint-status.yaml - const expected = `${normalizedTmp}/impl/sprint-status.yaml`; - assert( - content.includes(expected), - `sprint_status path not found.\nExpected substring: ${expected}\n` + - `sync-sprint-status.md excerpt (first 2000 chars):\n${content.slice(0, 2000)}`, - ); - }); - - test('workflow override — prepend step inlined as a bullet', () => { - const content = readRendered('workflow.md'); - assert(content.includes('- TEST_PREPEND_STEP'), 'activation_steps_prepend not inlined as a bullet'); - }); - - test('workflow override — persistent_facts append (base kept, override added)', () => { - const content = readRendered('workflow.md'); - assert(content.includes('- TEST_EXTRA_FACT'), 'override persistent_fact not inlined'); - assert(content.includes('project-context.md'), 'base persistent_fact dropped — append semantics broken'); - }); - - test('empty activation_steps_append renders the _None._ sentinel', () => { - const content = readRendered('workflow.md'); - assert(content.includes('_None._'), '_None._ sentinel missing for empty list'); - }); - - test('on_complete scalar inlined into step-05 and step-oneshot', () => { - for (const file of ['step-05-present.md', 'step-oneshot.md']) { - assert(readRendered(file).includes('TEST_ON_COMPLETE_INSTRUCTION'), `on_complete not inlined into ${file}`); - } - }); - - test('open_spec scalar inlined into step-05 and step-oneshot', () => { - for (const file of ['step-05-present.md', 'step-oneshot.md']) { - const content = readRendered(file); - assert( - content.includes('\nTEST_OPEN_SPEC_LINE_ONE\nTEST_OPEN_SPEC_LINE_TWO\n'), - `multiline open_spec not inlined at top-level in ${file}`, - ); - } - }); - - test('step-05 keeps the push offer separate from summary content', () => { - const content = readRendered('step-05-present.md'); - assert(!content.includes('Include:\n\n- Offer to push'), 'push offer remains under a dangling Include list'); - assert(content.includes('\n\nOffer to push and/or create a pull request.\n'), 'standalone push offer missing'); - }); - - test('open_spec default and examples use only established runtime path placeholders', () => { - const content = fs.readFileSync(path.join(skillDst, 'customize.toml'), 'utf-8'); - assert(content.includes('code -r "{project-root}" "{spec_file}"'), 'default supported-placeholder command missing'); - assert(!content.includes('{absolute-root}'), 'invented {absolute-root} placeholder remains in customize.toml'); - assert(!content.includes('{absolute-spec-file}'), 'invented {absolute-spec-file} placeholder remains in customize.toml'); - }); - - test('review layers materialize as invocation blocks in step-04', () => { - const content = readRendered('step-04-review.md'); - const expectedPromptPath = `${skillDst.replaceAll('\\', '/')}/review-prompts/adversarial.md`; - assert(content.includes('#### Blind Hunter'), 'default review layer not rendered as a #### invocation block'); - assert(!content.includes('- id:'), 'layer table data leaked into the rendered output'); - assert(content.includes(expectedPromptPath), 'reviewer prompt path was not expanded to the absolute skill path'); - assert(!content.includes('{skill-root}'), '{skill-root} survived in rendered review instructions'); - assert(content.includes('{diff_output}'), 'runtime {diff_output} placeholder did not survive rendering'); - // Diff is plain text after "Review content:", not trapped under a blockquote. - assert(content.includes('Review content:\n\n{diff_output}'), 'review content + diff not laid out as plain multi-line text'); - assert(!content.includes('> {diff_output}'), '{diff_output} still blockquote-prefixed'); - assert(!content.includes('{review_content}'), 'rendered review layers still reference a {review_content} file slot'); - }); - - test('one-shot review layers use direct child file loading after rendering', () => { - const content = readRendered('step-oneshot.md'); - const expectedPromptPath = `${skillDst.replaceAll('\\', '/')}/review-prompts/adversarial.md`; - assert(content.includes(expectedPromptPath), 'one-shot reviewer prompt path was not expanded to the absolute skill path'); - assert(!content.includes('{skill-root}'), '{skill-root} survived in rendered one-shot review instructions'); - assert( - content.includes('Review content: the changed files in the current worktree.'), - 'one-shot review target is no longer the changed files in the current worktree', - ); - assert(!content.includes('{review_content}'), 'one-shot layers still reference a {review_content} file slot'); - assert(content.includes('Do not invoke any skill.'), 'one-shot reviewer may invoke another skill'); - }); - - test('review layer override replaces the matching default by id', () => { - const content = readRendered('step-04-review.md'); - assert(content.includes('#### Replaced Layer'), 'override layer name not used as block title'); - assert(content.includes('TEST_REPLACED_LAYER_INSTRUCTION'), 'override layer instruction not inlined'); - assert(!content.includes('review-prompts/edge-case-hunter.md'), 'replaced default layer instruction still present'); - assert(content.includes('review-prompts/adversarial.md'), 'untouched default layer dropped by keyed merge'); - }); - - test('empty-instruction override drops its layer entirely', () => { - const content = readRendered('step-04-review.md'); - assert(!content.includes('verification-gap'), 'disabled layer id still present in rendered output'); - assert(!content.includes('Verification Gap Reviewer'), 'disabled layer name still present in rendered output'); - }); - - test('when condition renders as a run-time guard line', () => { - const content = readRendered('step-04-review.md'); - assert( - content.includes('Run this layer only if the following holds in the current context: `TEST_WHEN_CONDITION`'), - 'when condition not rendered as a guard line', - ); - }); - - test('disabling every layer renders the HALT instruction', () => { - // Second render pass: replace the override file so every default layer - // (and the oneshot route's only layer) is disabled, then re-render. - fs.writeFileSync( - path.join(tmpDir, '_bmad', 'custom', 'bmad-build.user.toml'), - [ - '[workflow]', - '', - '[[workflow.review_layers]]', - 'id = "blind-hunter"', - 'instruction = ""', - '', - '[[workflow.review_layers]]', - 'id = "edge-case-hunter"', - 'instruction = ""', - '', - '[[workflow.review_layers]]', - 'id = "verification-gap"', - 'instruction = ""', - '', - '[[workflow.oneshot_review_layers]]', - 'id = "blind-hunter"', - 'instruction = ""', - ].join('\n'), - 'utf-8', - ); - const rerun = spawnSync('python3', [path.join(skillDst, 'render.py')], { - cwd: skillDst, - encoding: 'utf-8', - }); - assert(rerun.status === 0, `re-render exit code ${rerun.status}\nstderr: ${rerun.stderr}`); - const halt = 'No review layers are active. HALT with status `blocked` and blocking condition `no active review layers`.'; - for (const file of ['step-04-review.md', 'step-oneshot.md']) { - assert(readRendered(file).includes(halt), `HALT instruction missing from ${file}`); - } - for (const file of ['step-05-present.md', 'step-oneshot.md']) { - const content = readRendered(file); - assert(content.includes('code -r "{project-root}" "{spec_file}"'), `default root-first VS Code command missing from ${file}`); - assert(!content.includes('{absolute-root}'), `invented {absolute-root} placeholder survived in ${file}`); - assert(!content.includes('{absolute-spec-file}'), `invented {absolute-spec-file} placeholder survived in ${file}`); - } - }); - - test('no {workflow.*} placeholder survives in any rendered file', () => { - const leaks = renderedMdFiles().filter((f) => readRendered(f).includes('{workflow.')); - assert(leaks.length === 0, `{workflow.*} leaked in: ${leaks.join(', ')}`); - }); - - test('no resolve_customization.py reference survives in any rendered file', () => { - const leaks = renderedMdFiles().filter((f) => readRendered(f).includes('resolve_customization.py')); - assert(leaks.length === 0, `resolve_customization.py still referenced in: ${leaks.join(', ')}`); - }); - - test('no main_config reference survives in any rendered file', () => { - const leaks = renderedMdFiles().filter((f) => readRendered(f).includes('main_config')); - assert(leaks.length === 0, `main_config still referenced in: ${leaks.join(', ')} (the runtime config re-read was removed)`); - }); - - // --------------------------------------------------------------------------- - // Bad-config HALTs cleanly (never a raw Python traceback) - // --------------------------------------------------------------------------- - - test('missing implementation_artifacts HALTs cleanly (no traceback)', () => { - const { skillDst: dst } = makeProject(['[core]', 'communication_language = "French"'].join('\n')); - const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' }); - assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`); - assert( - res.stdout.includes('HALT and report to the user: config is missing `implementation_artifacts`'), - `stdout missing the implementation_artifacts HALT directive.\nstdout: ${res.stdout}`, - ); - assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`); - }); - - test('missing planning_artifacts HALTs cleanly (no traceback)', () => { - // implementation_artifacts is present, so this exercises the general - // missing-vars scan rather than the dedicated guard. - const { skillDst: dst } = makeProject( - [ - '[core]', - 'communication_language = "French"', - 'document_output_language = "Klingon"', - 'implementation_artifacts = "{project-root}/impl"', - ].join('\n'), - ); - const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' }); - assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`); - assert( - res.stdout.includes('HALT and report to the user: config is missing') && res.stdout.includes('`planning_artifacts`'), - `stdout missing the planning_artifacts HALT directive.\nstdout: ${res.stdout}`, - ); - assert( - res.stdout.includes('step-01-clarify-and-route.md'), - `HALT directive does not name the referencing file.\nstdout: ${res.stdout}`, - ); - assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`); - }); - - test('unparseable customization override HALTs cleanly (no traceback)', () => { - const { dir, skillDst: dst } = makeProject( - [ - '[core]', - 'communication_language = "French"', - 'document_output_language = "Klingon"', - 'planning_artifacts = "{project-root}/plan"', - 'implementation_artifacts = "{project-root}/impl"', - ].join('\n'), - ); - fs.mkdirSync(path.join(dir, '_bmad', 'custom'), { recursive: true }); - fs.writeFileSync(path.join(dir, '_bmad', 'custom', 'bmad-build.user.toml'), '[workflow\non_complete = broken', 'utf-8'); - const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' }); - assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`); - assert( - res.stdout.includes('HALT and report to the user: failed to parse') && res.stdout.includes('bmad-build.user.toml'), - `stdout missing the failed-to-parse HALT directive naming the override file.\nstdout: ${res.stdout}`, - ); - assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`); - }); - - test('empty open_spec customization renders as an explicit disable', () => { - const { dir, skillDst: dst } = makeProject( - [ - '[core]', - 'communication_language = "French"', - 'document_output_language = "Klingon"', - 'planning_artifacts = "{project-root}/plan"', - 'implementation_artifacts = "{project-root}/impl"', - ].join('\n'), - ); - fs.mkdirSync(path.join(dir, '_bmad', 'custom'), { recursive: true }); - fs.writeFileSync(path.join(dir, '_bmad', 'custom', 'bmad-build.user.toml'), '[workflow]\nopen_spec = ""\n', 'utf-8'); - const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' }); - assert(res.status === 0, `expected exit 0, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`); - const renderDir = path.join(dir, '_bmad', 'render', 'bmad-build'); - for (const file of ['step-05-present.md', 'step-oneshot.md']) { - const content = fs.readFileSync(path.join(renderDir, file), 'utf-8'); - assert(!content.includes('code -r'), `default open_spec survived empty override in ${file}`); - assert(!content.includes('Suggested Review Order to jump'), `navigation output survived empty override in ${file}`); - assert(!content.includes('spec was sent'), `opening summary survived empty override in ${file}`); - assert(content.includes('Suggested Review Order'), `spec review trail generation disappeared from ${file}`); - } - }); - - test('non-string open_spec customization HALTs cleanly', () => { - const { dir, skillDst: dst } = makeProject( - [ - '[core]', - 'communication_language = "French"', - 'document_output_language = "Klingon"', - 'planning_artifacts = "{project-root}/plan"', - 'implementation_artifacts = "{project-root}/impl"', - ].join('\n'), - ); - fs.mkdirSync(path.join(dir, '_bmad', 'custom'), { recursive: true }); - fs.writeFileSync( - path.join(dir, '_bmad', 'custom', 'bmad-build.user.toml'), - '[workflow]\nopen_spec = ["not", "an", "instruction"]\n', - 'utf-8', - ); - const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' }); - assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`); - assert( - res.stdout.includes('customization `workflow.open_spec` must be a string'), - `stdout missing open_spec type error.\nstdout: ${res.stdout}`, - ); - assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`); - }); - - test('non-table [modules] does not crash the renderer', () => { - const { dir, skillDst: dst } = makeProject( - [ - 'modules = "oops-not-a-table"', - '', - '[core]', - 'communication_language = "French"', - 'document_output_language = "Klingon"', - 'planning_artifacts = "{project-root}/plan"', - 'implementation_artifacts = "{project-root}/impl"', - ].join('\n'), - ); - const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' }); - assert(res.status === 0, `expected exit 0, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`); - assert(!res.stderr.includes('Traceback'), `renderer crashed on non-table modules:\n${res.stderr}`); - assert( - fs.existsSync(path.join(dir, '_bmad', 'render', 'bmad-build', 'workflow.md')), - 'workflow.md not rendered when [modules] was a non-table scalar', - ); - }); -} finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - for (const dir of extraTmpDirs) { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - -// --------------------------------------------------------------------------- -// Summary -// --------------------------------------------------------------------------- - -console.log(`\n${colors.cyan}${'═'.repeat(55)}${colors.reset}`); -console.log(`${colors.cyan}Test Results:${colors.reset}`); -console.log(` Total: ${totalTests}`); -console.log(` Passed: ${colors.green}${passedTests}${colors.reset}`); -console.log(` Failed: ${passedTests === totalTests ? colors.green : colors.red}${totalTests - passedTests}${colors.reset}`); -console.log(`${colors.cyan}${'═'.repeat(55)}${colors.reset}\n`); - -if (failures.length > 0) { - console.log(`${colors.red}FAILED TESTS:${colors.reset}\n`); - for (const failure of failures) { - console.log(`${colors.red}\u2717${colors.reset} ${failure.name}`); - console.log(` ${failure.message}\n`); - } - process.exit(1); -} - -console.log(`${colors.green}All tests passed!${colors.reset}\n`); -process.exit(0); diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 6818112c4c..0da6a6aa83 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -3616,9 +3616,9 @@ async function runTests() { console.log(''); // ============================================================ - // Test Suite 49: build-auto renderer installation surface + // Test Suite 49: shared renderer installation surface for both build skills // ============================================================ - console.log(`${colors.yellow}Test Suite 49: build-auto renderer installation surface${colors.reset}\n`); + console.log(`${colors.yellow}Test Suite 49: shared renderer installation surface for both build skills${colors.reset}\n`); let root49; try { @@ -3666,6 +3666,7 @@ async function runTests() { const scripts49 = path.join(bmadDir49, 'scripts'); const skill49 = path.join(bmadDir49, 'bmm', 'ship', 'bmad-build-auto'); + const skill49Build = path.join(bmadDir49, 'bmm', 'ship', 'bmad-build'); assert(await fs.pathExists(path.join(scripts49, 'render_skill.py')), 'shared render_skill.py reaches installed _bmad/scripts'); assert(await fs.pathExists(path.join(scripts49, 'config_utils.py')), 'shared config utility reaches installed _bmad/scripts'); assert(!(await fs.pathExists(path.join(scripts49, 'tests'))), 'shared-script development tests are excluded from install'); @@ -3678,8 +3679,30 @@ async function runTests() { ); assert(!skillSource49.includes('uv run --python'), 'build-auto does not pin an exact Python series'); assert(!(await fs.pathExists(path.join(skill49, 'render.toml'))), 'installed skill has no duplicate render contract'); + assert(!(await fs.pathExists(path.join(skill49, 'render.py'))), 'no skill-local renderer reaches installed build-auto'); assert(await fs.pathExists(path.join(skill49, 'workflow.md')), 'build-auto workflow source reaches installed skill surface'); assert(await fs.pathExists(path.join(skill49, 'step-04-review.md')), 'build-auto step sources reach installed skill surface'); + // Compare against build-auto's own shipped command rather than a second hardcoded + // literal, so the two skills cannot drift apart while both still match this file. + const fenced49 = skillSource49.match(/```bash\n([\s\S]*?)```/); + assert( + fenced49 !== null && fenced49[1].includes('render_skill.py'), + 'build-auto ships its renderer invocation as a fenced bash command', + skillSource49, + ); + const sharedInvocation49 = fenced49 === null ? '' : fenced49[1].trim(); + assert(await fs.pathExists(path.join(skill49Build, 'SKILL.md')), 'build entry reaches installed skill surface'); + const buildSource49 = await fs.readFile(path.join(skill49Build, 'SKILL.md'), 'utf8'); + assert( + sharedInvocation49 !== '' && buildSource49.includes(sharedInvocation49), + 'build dispatches the same shared renderer invocation as build-auto', + `build-auto ships: ${sharedInvocation49}\nbuild ships: ${buildSource49}`, + ); + assert(!buildSource49.includes('uv run --python'), 'build does not pin an exact Python series'); + assert(!(await fs.pathExists(path.join(skill49Build, 'render.py'))), 'the retired skill-local renderer never reaches installed build'); + assert(!(await fs.pathExists(path.join(skill49Build, 'render.toml'))), 'installed build has no duplicate render contract'); + assert(await fs.pathExists(path.join(skill49Build, 'workflow.md')), 'build workflow source reaches installed skill surface'); + assert(await fs.pathExists(path.join(skill49Build, 'step-04-review.md')), 'build step sources reach installed skill surface'); assert( (await fs.readFile(renderGitignore49, 'utf8')) === '*\n!.gitignore\n', 'generated render snapshots are ignored by installed projects', @@ -3701,17 +3724,50 @@ async function runTests() { ].join('\n'), 'utf8', ); + // The harness pins the interpreter to keep this scratch run deterministic; the shipped + // SKILL.md command must not pin one, and test/test-build-auto-renderer.js executes that + // unpinned form verbatim for both skills. + const renderOptions49 = { encoding: 'utf8', timeout: 120_000 }; const render49 = spawnSync( 'uv', ['run', '--python', '3.11', path.join(scripts49, 'render_skill.py'), '--project-root', root49, '--skill', skill49], - { encoding: 'utf8' }, + renderOptions49, + ); + assert( + !render49.error && typeof render49.stdout === 'string', + 'shared renderer is spawnable for the installed build-auto tree', + String(render49.error || 'uv produced no stdout'), ); - const dispatch49 = render49.stdout.trim().replace(/^read and follow /, ''); + const dispatch49 = (render49.stdout || '').trim().replace(/^read and follow /, ''); assert( - render49.status === 0 && path.isAbsolute(dispatch49) && (await fs.pathExists(dispatch49)), + render49.status === 0 && + path.isAbsolute(dispatch49) && + dispatch49.includes(`${path.sep}render${path.sep}bmad-build-auto${path.sep}`) && + path.basename(dispatch49) === 'workflow.md' && + (await fs.pathExists(dispatch49)), 'installer-produced build-auto tree renders and dispatches end to end', `${render49.stdout}${render49.stderr}`, ); + const render49Build = spawnSync( + 'uv', + ['run', '--python', '3.11', path.join(scripts49, 'render_skill.py'), '--project-root', root49, '--skill', skill49Build], + renderOptions49, + ); + assert( + !render49Build.error && typeof render49Build.stdout === 'string', + 'shared renderer is spawnable for the installed build tree', + String(render49Build.error || 'uv produced no stdout'), + ); + const dispatch49Build = (render49Build.stdout || '').trim().replace(/^read and follow /, ''); + assert( + render49Build.status === 0 && + path.isAbsolute(dispatch49Build) && + dispatch49Build.includes(`${path.sep}render${path.sep}bmad-build${path.sep}`) && + path.basename(dispatch49Build) === 'workflow.md' && + (await fs.pathExists(dispatch49Build)), + 'installer-produced build tree renders and dispatches end to end', + `${render49Build.stdout}${render49Build.stderr}`, + ); const resolveCustomization49 = spawnSync( 'uv', [