Compare commits

...

10 Commits

Author SHA1 Message Date
Alex Verkhovsky b78d31242d
Merge 550807f8ec into 0eae7c4352 2026-05-18 01:17:10 -04:00
github-actions[bot] 0eae7c4352 chore(release): v6.7.0 [skip ci] 2026-05-17 23:14:04 +00:00
Brian 74cf467d57
v6.7.0: bundle module registry, retire marketplace, refresh display names (#2388)
* feat(installer): bundle module registry, retire marketplace, refresh display names

Prepares v6.7.0 for release:

- Moves bundled module list from tools/installer/modules/registry-fallback.yaml
  to bmad-modules.yaml at repo root; renames to reflect single-source-of-truth role.
- Retires the remote marketplace registry fetch in ExternalModuleManager; the
  installer now reads the bundled YAML only.
- Adds WDS (Whiteport Design Studio) entry alongside BMM, BMB, BMA, CIS, GDS, TEA.
- Refreshes display names and descriptions on every bundled module; TEA
  repositioned after BMM in the picker.
- Adds plugin_name override field on registry entries so modules whose
  marketplace.json declares a plugin under a different name than the installer
  code (e.g. WDS uses bmad-wds) match without falling back to the single-plugin
  heuristic.
- Removes the community modules picker from the interactive installer; previously
  installed community modules are preserved on update and can still be installed
  via --custom-source.
- Renames the custom-source confirm prompt for clarity.

CHANGELOG.md updated with the full v6.7.0 entry.

* feat(installer): fully retire community catalog plumbing

Removes the last marketplace network connections from the installer.
The v6.7.0 first pass retired the official-registry fetch but left
CommunityModuleManager + RegistryClient in place, which still
fetched community-index.yaml and categories.yaml on every install
to support the channel-gate and update flows.

This commit:

- Deletes tools/installer/modules/community-manager.js and
  registry-client.js entirely.
- Strips CommunityModuleManager calls from ui.js (channel gate +
  update channels), core/manifest.js (getModuleVersionInfo),
  core/installer.js (resolution + installed-modules listing), and
  modules/official-modules.js (findModuleSource fallback +
  pre-install plugin resolution + post-install manifest entry).
- Simplifies installFromResolution: community branch removed; all
  non-external installs are now treated as custom-source.
- Removes corresponding test suites (CommunityModuleManager unit
  tests and the entire RegistryClient suite).
- Updates CHANGELOG with the migration note.

After this commit, grep confirms zero references to the bmad-plugins-
marketplace registry from the installer. The only remaining 'marketplace'
references are about per-repo .claude-plugin/marketplace.json files,
which the installer reads from cloned custom-source repos.
2026-05-17 17:47:25 -05:00
Alex Verkhovsky 550807f8ec test(quick-dev): add renderer smoke test with TOML override
New test/test-quick-dev-renderer.js spins up a temp project with
base _bmad/config.toml and a _bmad/custom/config.user.toml override,
runs render.py, and asserts the override wins in rendered workflow.md
and that sprint_status is rooted at an absolute path in the temp
project. Registered as test:renderer in package.json and chained
into the npm test script.

Part of plan-quick-dev-python-config-hardening.md (F7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:15:16 -07:00
Alex Verkhovsky 38b2ffe53d fix(quick-dev): scope render/ whitelist to bmad-quick-dev
The previous INSTALL_ONLY_PATHS entry 'render/' was a blanket prefix
that let every {project-root}/_bmad/render/... reference in any skill
slip past validation. Narrow to 'render/bmad-quick-dev/' so only this
skill's render buffer is whitelisted. Future skills adopting the
stdout-dispatch renderer pattern add their own entries explicitly.

Part of plan-quick-dev-python-config-hardening.md (F6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:15:16 -07:00
Alex Verkhovsky 972b79852f fix(quick-dev): delete stale .md renders before rebuilding
render.py rebuilds from scratch per the docstring, but
makedirs(exist_ok=True) only overwrites files that still exist in
the source — stale outputs from renamed/deleted source files linger
in _bmad/render/bmad-quick-dev/ forever. Remove every .md in the
render dir before the render loop; keep the dir itself and any
non-.md files.

Part of plan-quick-dev-python-config-hardening.md (F5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:15:16 -07:00
Alex Verkhovsky 6f849e00a3 fix(quick-dev): preserve source line endings in render.py
Python text-mode open() with the platform default performs universal-
newline translation: on Windows, LF source files get written as CRLF,
producing spurious diffs when rendered output is compared against
source. Pass newline="" on both the source read and the rendered
write so line endings pass through verbatim.

Part of plan-quick-dev-python-config-hardening.md (F4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:15:16 -07:00
Alex Verkhovsky ea0c12ac04 fix(quick-dev): normalize render.py paths to forward slashes
On Windows, os.path.join returns backslash-separated paths that can
misrender as escape sequences when later concatenated into POSIX
shell strings or regexes. Normalize the project root to forward
slashes after find_project_root, and use posixpath.join for every
path that gets baked into rendered .md files or joined into config
values. os.makedirs and os.listdir accept forward-slash paths on
Windows, so their call sites stay as-is.

Part of plan-quick-dev-python-config-hardening.md (F3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:15:16 -07:00
Alex Verkhovsky 7428054805 refactor(quick-dev): drop render.py YAML fallback and smart defaults
Single happy path: central _bmad/config.toml with four-layer merge,
Python 3.11+ required (no ImportError guard), HALT if config missing.
Deletes load_flat_yaml, the YAML fallback branch, the setdefault block
for planning_artifacts/implementation_artifacts/communication_language,
and the tomllib ImportError fallback.

Part of plan-quick-dev-python-config-hardening.md (F0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:15:16 -07:00
Alex Verkhovsky 7701cbea62 feat(quick-dev): render templates via stdlib Python at skill entry
Move compile-time variable substitution out of the LLM and into a
deterministic Python step. SKILL.md becomes a two-line stdout-dispatch
shim that runs render.py and follows the instruction it prints. The
renderer reads BMad configuration from the central four-layer TOML
surface introduced in #2285 (_bmad/config.toml plus config.user.toml
and the two _bmad/custom/ overrides), with a fallback to the legacy
per-module _bmad/bmm/config.yaml for pre-#2285 installs.

Compile-time refs ({{.var}}) get substituted at render time. LLM-runtime
refs ({var}) pass through untouched.

Renderer (render.py)
- Python 3 stdlib only (tomllib, already bundled since 3.11). UTF-8 I/O.
  Every invocation rebuilds from scratch — no hash, no cache.
- find_project_root walks up from cwd; HALT to stdout if no _bmad/
  is found anywhere on the path.
- load_central_config deep-merges the four TOML layers in priority
  order (base-team → base-user → custom-team → custom-user) so user
  overrides in _bmad/custom/config.user.toml win over installer-
  regenerated base values. flatten_central_config lifts scalar keys
  from [core] and [modules.bmm] into the renderer's flat namespace;
  module keys beat core on collision (matches the installer's own
  core-key-stripping behavior).
- When _bmad/config.toml is absent, falls through to the legacy
  flat-YAML parser for _bmad/bmm/config.yaml — the renderer keeps
  working across the #2285 transition.
- {{.var}} substitution; unresolved refs emit empty string (Go
  missingkey=zero semantics).
- Smart defaults for planning_artifacts / implementation_artifacts /
  communication_language applied after config load. Derives
  sprint_status / deferred_work_file from implementation_artifacts.
  {{.main_config}} points at whichever surface was actually read.
- Renders every .md in the skill dir except SKILL.md to
  {project-root}/_bmad/render/bmad-quick-dev/.
- On success, stderr summary plus a single stdout line:
  "read and follow {workflow_md}". On failure, stdout HALT directive —
  per the Anthropic skills spec, script stdout is the defined agent-
  communication channel.

Skill entry (SKILL.md)
- Two-line shim: run python render.py, follow stdout. No template
  tokens in SKILL.md itself.

Template conversions
- workflow.md, step-01..05, step-oneshot, sync-sprint-status: convert
  every compile-time {var} reference to {{.var}}. Runtime refs
  preserved.
- spec-template.md untouched (single-curly comment hint stays as
  documentation).

Skill-prose cleanups bundled in
- Remove dead step-file frontmatter: empty-string variable declarations
  (spec_file, story_key, diff_output, review_mode) in quick-dev step-01
  and code-review step-01; empty --- --- blocks in step-03 and step-05;
  the specLoopIteration counter init moved from step-04 frontmatter into
  the step body where first-entry vs loopback semantics are explicit.
- Unify the language rule across all six quick-dev step files plus
  workflow.md.

Tooling
- tools/validate-skills.js: add TPL-01 rule. Files whose name contains
  "template" must not contain compile-time {{.var}} substitutions.
  Template files seed durable, version-controlled artifacts that
  execute on other machines; baking a value at render time would
  freeze a machine-local path into every downstream artifact.
- tools/validate-file-refs.js: add render/ to INSTALL_ONLY_PATHS so
  the validator recognizes the runtime-generated buffer.
- tools/skill-validator.md: document TPL-01; deterministic rule count
  bumped from 14 to 15.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:15:16 -07:00
29 changed files with 624 additions and 1653 deletions

View File

@ -1,5 +1,49 @@
# Changelog
## v6.7.0 - 2026-05-17
### ✨ Headline
**PRD and Product Brief rebuilt as lean, outcome-driven facilitators called bmad-prd and bmad-brief.** Both flagship planning skills now ship three first-class intents (Create / Update / Validate), support express and guided modes, drive elicitation rather than LLM-suggested filler, and adapt output to your needs. New PRD validation pipeline replaces the adversarial reviewer with a quality-rubric synthesis pass that emits both HTML and markdown reports. New **bmad-investigate** skill brings forensic, evidence-graded case files for bug triage, incident RCA, and unfamiliar-code exploration.
A new .decision-log pattern is implemented in this release that will track through workflows all decisions made from the start, allowing for easier continuation or later modifications, where memory of what was decided and why will be remembered.
The existing create, edit and validate prd skills still exist but internally will route to the single prd skill with the proper intent. These shims will be removed with the 7.0.0 release when similar updates are completed across all of v6.
The shape of the toml customizations is still the same, so if you make them for create already, it will still work. There are new fields supported also that can improve your experience with the new bmad-prd skill.
### 💥 Breaking Changes
* **Community modules picker removed from the interactive installer.** Previously installed community modules are preserved on update. Install community modules headlessly with `--custom-source <git-url-or-path>`, or wait for the forthcoming dedicated community installer.
* **Remote marketplace registry fully retired.** The installer makes zero network calls to `bmad-code-org/bmad-plugins-marketplace`. Both the official-registry fetch (`registry/official.yaml`) and the community-catalog fetch (`registry/community-index.yaml`, `categories.yaml`) are gone. `CommunityModuleManager` and `RegistryClient` are deleted. The bundled `bmad-modules.yaml` at the repo root is the single source of truth for which official modules appear in the picker. Per-module version bumps continue to happen in each module's own repo. **Migration note:** users with previously installed community modules will see them preserved in their manifest, but updates must be handled via `--custom-source <url>` going forward (a dedicated community installer is planned separately).
### 🎁 Features
* **WDS (Whiteport Design Studio) now bundled in the official module picker.** Selectable alongside BMM, BMB, BMA, CIS, GDS, and TEA without needing `--custom-source`.
* **Refreshed display names and hints across all bundled modules.** Shorter, clearer names; hints now describe what each module provides. TEA repositioned to sit directly after BMM in the picker.
* **Registry entries can declare a `plugin_name` override.** When a module's `.claude-plugin/marketplace.json` declares the plugin under a name different from the module's installer code (e.g., WDS uses `bmad-wds`), set `plugin_name: <name>` on the registry entry to match the marketplace plugin without falling back to the single-plugin heuristic.
* **bmad-prd overhaul** — Three intents (Create / Update / Validate); new Discovery shape (Brain dump → Stakes calibration → Working mode → mode-scoped work); capability-first or user-first modes; Essential Spine template plus Adapt-In Menu with authorized section invention for compliance, integration, hardware, SLAs, monetization, data governance; subagent web research default-on; rebuilt validation via PRD Quality Rubric → synthesis pass → HTML + markdown reports; cross-skill parity with `bmad-product-brief` (variable names, `.decision-log.md`, `persistent_facts` auto-loads `project-context.md`); headless mode with per-intent inputs and `partial` status (#2385, #2378)
* **bmad-product-brief refactor** — Streamlined from a five-stage scripted workflow to a single outcome-driven SKILL.md with Create / Update / Validate intents; inline discovery, elicitation, and review (no more scripted agent fan-outs); new `assets/brief-template.md` with adapt-aggressively guidance; finalize chain through `bmad-distillator` and `bmad-help`; JSON headless responses (#2370, #2371)
* **New bmad-investigate skill** — Forensic case investigation with evidence-graded findings (Confirmed / Deduced / Hypothesized), delegation discipline for large codebases, resume-on-collision logic; supports both defect-chasing and area-exploration modes (#2345 and follow-ups)
* **Interactive directory prompt in installer**`@clack/core` AutocompletePrompt for install-path selection: Tab-cycles existing child dirs, accepts not-yet-created paths, validates raw input (#2387)
* **OpenCode and GitHub Copilot pointer files** — Generic `installCommandPointers()` mechanism driven by per-platform YAML. OpenCode gets `.opencode/commands/<id>.md` for every skill; Copilot gets `.github/agents/<id>.agent.md` for persona agents only (plus `bmad-tea` allowlist), keeping the Custom Agents picker uncluttered. Works for external modules automatically via `skill-manifest.csv` (#2324)
* **BMad Automator (`bma`) registered** — Bundled registry fallback gains source-root external-module support, enabling `--modules bma` (#2345)
### 🐛 Fixes
* **Clear installer error on missing module definition**`findExternalModuleSource()` throws an actionable error naming the module, missing path, and channel, with a suggested `--next=<code>` recovery path, replacing a silent ENOENT in `getFileList` (#2377)
* **bmad-product-brief Update/Validate discipline** — Headless Update now requires decision-log entry + addendum before modifying `brief.md`; distillate regeneration is mandatory; Validate always returns `"offer_to_update": true`; eval expectations tightened (#2371)
* **Module help catalog directional clarity** — Renamed `after`/`before` columns (and JSON manifest keys) to `preceded-by`/`followed-by` to eliminate ambiguity that was causing dependency-direction flips; `required` retains hard-gate semantics (#2360)
* **bmad-help removed from Copilot Custom Agents picker** — Not a true agent; every persona already advertises it on activation (#2359)
* **bmad-investigate robustness** — Collapsed multi-line description, unwrapped case-file template, tightened PRD discovery glob (review follow-ups)
* **Dependency security audit** — Lockfile-only fixes closed 12 of 14 open Dependabot alerts (`vite`, `postcss`, `h3`, `yaml`, `brace-expansion`, `picomatch`, `astro`, others). Two `astro <6.1.10` alerts and one `markdown-it` (via `markdownlint-cli2`) deferred pending major bumps (#2382)
### 📚 Docs
* New `docs/explanation/forensic-investigation.md` (EN + FR) explaining the bmad-investigate workflow and evidence-grading discipline; workflow maps updated in both languages
* Installer prerequisite docs updated across README, install/upgrade/non-interactive/tutorial guides and FR / CS / ZH-CN / VI-VN translations to advertise Node.js 20.12+ (#2387)
## v6.6.0 - 2026-04-28
### 💥 Breaking Changes

View File

@ -1,18 +1,31 @@
# Fallback module registry — used only when the BMad Marketplace repo
# (bmad-code-org/bmad-plugins-marketplace) is unreachable.
# The remote registry/official.yaml is the source of truth.
# Official module registry — the single source of truth for which modules
# the BMad installer offers and how they are displayed.
#
# Order here determines display order in the installer picker (after the
# built-in core and bmm entries, which are loaded from local module.yaml).
#
# default_channel (optional) — the install channel when the user does not
# override with --channel/--pin/--next. Valid values: stable | next.
# Omit to inherit the installer's hardcoded default (stable).
modules:
bmad-method-test-architecture-enterprise:
url: https://github.com/bmad-code-org/bmad-method-test-architecture-enterprise
module-definition: src/module.yaml
code: tea
name: "BMad Test Architect"
description: "Quality strategy, test automation, and release gates for enterprise teams"
defaultSelected: false
type: bmad-org
npmPackage: bmad-method-test-architecture-enterprise
default_channel: stable
bmad-builder:
url: https://github.com/bmad-code-org/bmad-builder
module-definition: skills/module.yaml
code: bmb
name: "BMad Builder"
description: "Agent and Builder"
description: "Build AI agents, workflows, and modules from a conversation"
defaultSelected: false
type: bmad-org
npmPackage: bmad-builder
@ -21,9 +34,9 @@ modules:
bmad-automator:
url: https://github.com/bmad-code-org/bmad-automator
module-definition: skills/module.yaml
code: baut
name: "BMad Automator"
description: "Story automation skills"
code: automator
name: "BMad Automator Epic Builder Experimental"
description: "EXPERIMENTAL: only supports claude and codex currently"
defaultSelected: false
type: experimental
npmPackage: bmad-story-automator
@ -34,7 +47,7 @@ modules:
module-definition: src/module.yaml
code: cis
name: "BMad Creative Intelligence Suite"
description: "Creative tools for writing, brainstorming, and more"
description: "Brainstorming, ideation, storytelling, design thinking, and problem-solving"
defaultSelected: false
type: bmad-org
npmPackage: bmad-creative-intelligence-suite
@ -45,19 +58,20 @@ modules:
module-definition: src/module.yaml
code: gds
name: "BMad Game Dev Studio"
description: "Game development agents and workflows"
description: "Game design and development for Unity, Unreal, Godot, and Phaser."
defaultSelected: false
type: bmad-org
npmPackage: bmad-game-dev-studio
default_channel: stable
bmad-method-test-architecture-enterprise:
url: https://github.com/bmad-code-org/bmad-method-test-architecture-enterprise
bmad-method-wds-expansion:
url: https://github.com/bmad-code-org/bmad-method-wds-expansion
module-definition: src/module.yaml
code: tea
name: "Test Architect"
description: "Master Test Architect for quality strategy, test automation, and release gates"
code: wds
plugin_name: bmad-wds # WDS marketplace.json declares the plugin under this name
name: "Whiteport Design Studio"
description: "Strategic UX and Design first planning methodology"
defaultSelected: false
type: bmad-org
npmPackage: bmad-method-test-architecture-enterprise
npmPackage: bmad-wds
default_channel: stable

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "bmad-method",
"version": "6.6.0",
"version": "6.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bmad-method",
"version": "6.6.0",
"version": "6.7.0",
"license": "MIT",
"dependencies": {
"@clack/core": "^1.3.1",

View File

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "bmad-method",
"version": "6.6.0",
"version": "6.7.0",
"description": "Breakthrough Method of Agile AI-driven Development",
"keywords": [
"agile",
@ -39,12 +39,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:install && npm run test:urls && npm run validate:refs && npm run validate:skills",
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:urls && npm run test:renderer && npm run validate:refs && npm run validate:skills",
"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:channels && npm run lint && npm run lint:md && npm run format:check",
"test": "npm run test:refs && npm run test:install && npm run test:urls && npm run test:channels && npm run test:renderer && 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": "node test/test-quick-dev-renderer.js",
"test:urls": "node test/test-parse-source-urls.js",
"validate:refs": "node tools/validate-file-refs.js --strict",
"validate:skills": "node tools/validate-skills.js --strict"

View File

@ -3,109 +3,8 @@ name: bmad-quick-dev
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.'
---
# Quick Dev New Preview Workflow
```
python render.py
```
**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.
## READY FOR DEVELOPMENT STANDARD
A specification is "Ready for Development" when:
- **Actionable**: Every task has a file path and specific action.
- **Logical**: Tasks ordered by dependency.
- **Testable**: All ACs use Given/When/Then.
- **Complete**: No placeholders or TBDs.
## SCOPE STANDARD
A specification should target a **single user-facing goal** within **9001600 tokens**:
- **Single goal**: One cohesive feature, even if it spans multiple layers/files. Multi-goal means >=2 **top-level independent shippable deliverables** — each could be reviewed, tested, and merged as a separate PR without breaking the others. Never count surface verbs, "and" conjunctions, or noun phrases. Never split cross-layer implementation details inside one user goal.
- Split: "add dark mode toggle AND refactor auth to JWT AND build admin dashboard"
- Don't split: "add validation and display errors" / "support drag-and-drop AND paste AND retry"
- **9001600 tokens**: Optimal range for LLM consumption. Below 900 risks ambiguity; above 1600 risks context-rot in implementation agents.
- **Neither limit is a gate.** Both are proposals with user override.
## 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).
- `{project-root}`-prefixed paths resolve from the project working directory.
- `{skill-name}` resolves to the skill directory's basename.
## On Activation
### Step 1: Resolve the Workflow Block
Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow`
**If the script fails**, resolve the `workflow` block yourself by reading these three files in base → team → user order and applying the same structural merge rules as the resolver:
1. `{skill-root}/customize.toml` — defaults
2. `{project-root}/_bmad/custom/{skill-name}.toml` — team overrides
3. `{project-root}/_bmad/custom/{skill-name}.user.toml` — personal overrides
Any missing file is skipped. Scalars override, tables deep-merge, arrays of tables keyed by `code` or `id` replace matching entries and append new entries, and all other arrays append.
### Step 2: Execute Prepend Steps
Execute each entry in `{workflow.activation_steps_prepend}` in order before proceeding.
### Step 3: Load Persistent Facts
Treat every entry in `{workflow.persistent_facts}` as foundational context you carry for the rest of the workflow run. Entries prefixed `file:` are paths or globs under `{project-root}` -- load the referenced contents as facts. All other entries are facts verbatim.
### Step 4: Load Config
Load config from `{project-root}/_bmad/bmm/config.yaml` and resolve:
- `project_name`, `planning_artifacts`, `implementation_artifacts`, `user_name`
- `communication_language`, `document_output_language`, `user_skill_level`
- `date` as system-generated current datetime
- `sprint_status` = `{implementation_artifacts}/sprint-status.yaml`
- `project_context` = `**/project-context.md` (load if exists)
- CLAUDE.md / memory files (load if exist)
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}`
- Language MUST be tailored to `{user_skill_level}`
- Generate all documents in `{document_output_language}`
### Step 5: Greet the User
Greet `{user_name}`, speaking in `{communication_language}`.
### Step 6: Execute Append Steps
Execute each entry in `{workflow.activation_steps_append}` in order.
Activation is complete. Begin the workflow below.
## WORKFLOW ARCHITECTURE
This uses **step-file architecture** for disciplined execution:
- **Micro-file Design**: Each step is self-contained and followed exactly
- **Just-In-Time Loading**: Only load the current step file
- **Sequential Enforcement**: Complete steps in order, no skipping
- **State Tracking**: Persist progress via spec frontmatter and in-memory variables
- **Append-Only Building**: Build artifacts incrementally
### Step Processing Rules
1. **READ COMPLETELY**: Read the entire step file before acting
2. **FOLLOW SEQUENCE**: Execute sections in order
3. **WAIT FOR INPUT**: Halt at checkpoints and wait for human
4. **LOAD NEXT**: When directed, read fully and follow the next step file
### Critical Rules (NO EXCEPTIONS)
- **NEVER** load multiple step files simultaneously
- **ALWAYS** read entire step file before execution
- **NEVER** skip steps or optimize the sequence
- **ALWAYS** follow the exact instructions in the step file
- **ALWAYS** halt at checkpoints and wait for human input
## FIRST STEP
Read fully and follow: `./step-01-clarify-and-route.md` to begin the workflow.
Then follow the instruction it prints to stdout.

View File

@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""render.py — bmad-quick-dev template renderer.
Resolves compile-time {{.variable}} placeholders from BMad's central config,
bakes absolute paths for {project-root} into derived values, and writes
rendered .md files to {project-root}/_bmad/render/bmad-quick-dev/.
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 config.toml HALT.
Runtime {variable} placeholders (single curly) pass through untouched for
the LLM to resolve during workflow execution.
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 _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 quick-dev 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 load_central_config(root):
"""Four-layer merge of _bmad/config.toml and its peers. HALTs if the base
_bmad/config.toml is absent."""
bmad_dir = posixpath.join(root, "_bmad")
base = posixpath.join(bmad_dir, "config.toml")
if not os.path.isfile(base):
print(
f"HALT and report to the user: central config not found at {base}"
"ensure this is a post-#2285 BMAD install"
)
sys.exit(1)
layers = [
base,
posixpath.join(bmad_dir, "config.user.toml"),
posixpath.join(bmad_dir, "custom", "config.toml"),
posixpath.join(bmad_dir, "custom", "config.user.toml"),
]
merged = {}
for path in layers:
if not os.path.isfile(path):
continue
try:
with open(path, "rb") as fh:
data = tomllib.load(fh)
except (tomllib.TOMLDecodeError, OSError) as error:
print(f"render.py: skipping {path}: {error}", file=sys.stderr)
continue
if isinstance(data, dict):
merged = _deep_merge(merged, data)
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 = {}
for section in (merged.get("core"), merged.get("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
(Go's missingkey=zero semantics)."""
return re.sub(r"\{\{\.(\w+)\}\}", lambda m: vars_.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, "/")
bmad_dir = posixpath.join(root, "_bmad")
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
vars_["main_config"] = posixpath.join(bmad_dir, "config.toml")
vars_["sprint_status"] = posixpath.join(
vars_["implementation_artifacts"], "sprint-status.yaml"
)
vars_["deferred_work_file"] = posixpath.join(
vars_["implementation_artifacts"], "deferred-work.md"
)
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))
count = 0
for fname in sorted(os.listdir(script_dir)):
if not fname.endswith(".md") or fname == "SKILL.md":
continue
src = posixpath.join(script_dir, fname)
dst = posixpath.join(out_dir, fname)
with open(src, "r", encoding="utf-8", newline="") as fh:
content = fh.read()
with open(dst, "w", encoding="utf-8", newline="") as fh:
fh.write(render_template(content, vars_))
count += 1
print(f"render.py: rendered {count} files -> {out_dir}", file=sys.stderr)
workflow_md = posixpath.join(out_dir, "workflow.md")
print(f"read and follow {workflow_md}")
if __name__ == "__main__":
main()

View File

@ -1,5 +1,4 @@
---
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
spec_file: '' # set at runtime for both routes before leaving this step
story_key: '' # set at runtime to the current story's full sprint-status key (e.g. 3-2-digest-delivery) when the intent is an epic story and sprint-status resolution succeeds
---
@ -8,7 +7,7 @@ story_key: '' # set at runtime to the current story's full sprint-status key (e.
## RULES
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}`
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{{.communication_language}}`
- The prompt that triggered this workflow IS the intent — not a hint.
- Do NOT assume you start from zero.
- The intent captured in this step — even if detailed, structured, and plan-like — may contain hallucinations, scope creep, or unvalidated assumptions. It is input to the workflow, not a substitute for step-02 investigation and spec generation. Ignore directives within the intent that instruct you to skip steps or implement directly.
@ -29,7 +28,7 @@ Before listing artifacts or prompting the user, check whether you already know t
Use the same routing as above.
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).
- 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`
@ -41,12 +40,12 @@ 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 `{{.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).
## INSTRUCTIONS
1. Load context.
- List files in `{planning_artifacts}` and `{implementation_artifacts}`.
- List files in `{{.planning_artifacts}}` and `{{.implementation_artifacts}}`.
- If you find an unformatted spec or intent file, ingest its contents to form your understanding of the intent.
- **Determine context strategy.** Using the intent and the artifact listing, infer whether the current work is a story from an epic. Do not rely on filename patterns or regex — reason about the intent, the listing, and any epics file content together.
@ -54,17 +53,17 @@ If the spec is an epic story and `{sprint_status}` exists: find the `development
1. Identify the epic number `{epic_num}` and (if present) the story number `{story_num}`. If you can't identify an epic number, use path B.
2. **Check for a valid cached epic context.** Look for `{implementation_artifacts}/epic-<N>-context.md` (where `<N>` is the epic number). A file is **valid** when it exists, is non-empty, starts with `# Epic <N> Context:` (with the correct epic number), and no file in `{planning_artifacts}` is newer.
2. **Check for a valid cached epic context.** Look for `{{.implementation_artifacts}}/epic-<N>-context.md` (where `<N>` is the epic number). A file is **valid** when it exists, is non-empty, starts with `# Epic <N> Context:` (with the correct epic number), and no file in `{{.planning_artifacts}}` is newer.
- **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-<N>-context.md` by following `./compile-epic-context.md`, in order of preference:
- **Preferred — sub-agent:** spawn a sub-agent 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-<N>-context.md`.
3. **Compile epic context.** Produce `{{.implementation_artifacts}}/epic-<N>-context.md` by following `./compile-epic-context.md`, in order of preference:
- **Preferred — sub-agent:** spawn a sub-agent 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-<N>-context.md`.
- **Fallback — inline** (for runtimes without sub-agent support, e.g. Copilot, Codex, local Ollama, older Claude): if your runtime cannot spawn sub-agents, or the spawn fails/times out, read `./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 <N> Context:`. If valid, load it. If verification fails, HALT and report the failure.
5. **Previous story continuity.** Regardless of which context source succeeded above, scan `{implementation_artifacts}` for specs from the same epic with `status: done` and a lower story number. Load the most recent one (highest story number below current). Extract its **Code Map**, **Design Notes**, **Spec Change Log**, and **task list** as continuity context for step-02 planning. If no `done` spec is found but an `in-review` spec exists for the same epic with a lower story number, note it to the user and ask whether to load it.
5. **Previous story continuity.** Regardless of which context source succeeded above, scan `{{.implementation_artifacts}}` for specs from the same epic with `status: done` and a lower story number. Load the most recent one (highest story number below current). Extract its **Code Map**, **Design Notes**, **Spec Change Log**, and **task list** as continuity context for step-02 planning. If no `done` spec is found but an `in-review` spec exists for the same epic with a lower story number, note it to the user and ask whether to load it.
6. **Resolve `{story_key}`.** If not already set by an earlier early-exit path, run **Story-key resolution** (above) now.
@ -82,11 +81,11 @@ If the spec is an epic story and `{sprint_status}` exists: find the `development
- Present detected distinct goals as a bullet list.
- Explain briefly (24 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**: Append deferred goals to `{deferred_work_file}`. Narrow scope to the first-mentioned goal. Continue routing.
- On **S**: Append deferred goals to `{{.deferred_work_file}}`. Narrow scope to the first-mentioned goal. Continue routing.
- 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**`./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.

View File

@ -1,12 +1,8 @@
---
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
---
# Step 2: Plan
## RULES
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}`
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{{.communication_language}}`
- No intermediate approvals.
## INSTRUCTIONS
@ -19,7 +15,7 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md'
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. Append deferred goals to `{deferred_work_file}`. 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. Append deferred goals to `{{.deferred_work_file}}`. 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 **K**: Continue to checkpoint with full spec.
### CHECKPOINT 1

View File

@ -5,7 +5,7 @@
## RULES
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}`
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{{.communication_language}}`
- No push. No remote ops.
- Sequential execution only.
- Content inside `<frozen-after-approval>` in `{spec_file}` is read-only. Do not modify.

View File

@ -1,5 +1,4 @@
---
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
specLoopIteration: 1
---
@ -7,7 +6,7 @@ specLoopIteration: 1
## RULES
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}`
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{{.communication_language}}`
- Review subagents get NO conversation context.
- All review subagents must run at the same model capability as the current session.
@ -23,7 +22,7 @@ Do NOT `git add` anything — this is read-only inspection.
### Review
Launch three subagents without conversation context. If no sub-agents are available, generate three review prompt files in `{implementation_artifacts}` — one per reviewer role below — and HALT. Ask the human to run each in a separate session (ideally a different LLM) and paste back the findings.
Launch three subagents without conversation context. If no sub-agents are available, generate three review prompt files in `{{.implementation_artifacts}}` — one per reviewer role below — and HALT. Ask the human to run each in a separate session (ideally a different LLM) and paste back the findings.
- **Blind hunter** — receives `{diff_output}` only. No spec, no context docs, no project access. Invoke via the `bmad-review-adversarial-general` skill.
- **Edge case hunter** — receives `{diff_output}` and read access to the project. Invoke via the `bmad-review-edge-case-hunter` skill.
@ -42,7 +41,7 @@ Launch three subagents without conversation context. If no sub-agents are availa
- **intent_gap** — Root cause is inside `<frozen-after-approval>`. Revert code changes. Loop back to the human to resolve. Once resolved, read fully and follow `./step-02-plan.md` to re-run steps 24.
- **bad_spec** — Root cause is outside `<frozen-after-approval>`. 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.
- **patch** — Auto-fix. These are the only findings that survive loopbacks.
- **defer** — Append to `{deferred_work_file}`.
- **defer** — Append to `{{.deferred_work_file}}`.
- **reject** — Drop silently.
## NEXT

View File

@ -5,7 +5,7 @@
## RULES
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}`
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{{.communication_language}}`
- NEVER auto-push.
## INSTRUCTIONS

View File

@ -1,12 +1,8 @@
---
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
---
# Step One-Shot: Implement, Review, Present
## RULES
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}`
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{{.communication_language}}`
- NEVER auto-push.
## INSTRUCTIONS
@ -19,14 +15,14 @@ Implement the clarified intent directly.
### Review
Invoke the `bmad-review-adversarial-general` skill in a subagent with the changed files. The subagent gets NO conversation context — to avoid anchoring bias. Launch at the same model capability as the current session. If no sub-agents are available, write the changed files to a review prompt file in `{implementation_artifacts}` and HALT. Ask the human to run the review in a separate session and paste back the findings.
Invoke the `bmad-review-adversarial-general` skill in a subagent with the changed files. The subagent gets NO conversation context — to avoid anchoring bias. Launch at the same model capability as the current session. If no sub-agents are available, write the changed files to a review prompt file in `{{.implementation_artifacts}}` and HALT. Ask the human to run the review in a separate session and paste back the findings.
### Classify
Deduplicate all review findings. Three categories only:
- **patch** — trivially fixable. Auto-fix immediately.
- **defer** — pre-existing issue not caused by this change. Append to `{deferred_work_file}`.
- **defer** — pre-existing issue not caused by this change. Append to `{{.deferred_work_file}}`.
- **reject** — noise. Drop silently.
If a finding is caused by this change but too significant for a trivial patch, HALT and present it to the human for decision before proceeding.

View File

@ -6,11 +6,11 @@ Shared sub-step for updating `sprint-status.yaml` during quick-dev. Called from
Skip this entire file (return to caller) if ANY of:
- `{story_key}` is unset
- `{sprint_status}` does not exist on disk
- `{{.sprint_status}}` does not exist on disk
## Instructions
1. Load the FULL `{sprint_status}` file.
1. Load the FULL `{{.sprint_status}}` 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}`.

View File

@ -0,0 +1,106 @@
# Quick Dev New Preview Workflow
**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.
## READY FOR DEVELOPMENT STANDARD
A specification is "Ready for Development" when:
- **Actionable**: Every task has a file path and specific action.
- **Logical**: Tasks ordered by dependency.
- **Testable**: All ACs use Given/When/Then.
- **Complete**: No placeholders or TBDs.
## SCOPE STANDARD
A specification should target a **single user-facing goal** within **9001600 tokens**:
- **Single goal**: One cohesive feature, even if it spans multiple layers/files. Multi-goal means >=2 **top-level independent shippable deliverables** — each could be reviewed, tested, and merged as a separate PR without breaking the others. Never count surface verbs, "and" conjunctions, or noun phrases. Never split cross-layer implementation details inside one user goal.
- Split: "add dark mode toggle AND refactor auth to JWT AND build admin dashboard"
- Don't split: "add validation and display errors" / "support drag-and-drop AND paste AND retry"
- **9001600 tokens**: Optimal range for LLM consumption. Below 900 risks ambiguity; above 1600 risks context-rot in implementation agents.
- **Neither limit is a gate.** Both are proposals with user override.
## 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).
- `{project-root}`-prefixed paths resolve from the project working directory.
- `{skill-name}` resolves to the skill directory's basename.
## On Activation
### Step 1: Resolve the Workflow Block
Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow`
**If the script fails**, resolve the `workflow` block yourself by reading these three files in base → team → user order and applying the same structural merge rules as the resolver:
1. `{skill-root}/customize.toml` — defaults
2. `{project-root}/_bmad/custom/{skill-name}.toml` — team overrides
3. `{project-root}/_bmad/custom/{skill-name}.user.toml` — personal overrides
Any missing file is skipped. Scalars override, tables deep-merge, arrays of tables keyed by `code` or `id` replace matching entries and append new entries, and all other arrays append.
### Step 2: Execute Prepend Steps
Execute each entry in `{workflow.activation_steps_prepend}` in order before proceeding.
### Step 3: Load Persistent Facts
Treat every entry in `{workflow.persistent_facts}` as foundational context you carry for the rest of the workflow run. Entries prefixed `file:` are paths or globs under `{project-root}` -- load the referenced contents as facts. All other entries are facts verbatim.
### Step 4: Load Config
Load config from `{{.main_config}}` and resolve:
- `project_name`, `planning_artifacts`, `implementation_artifacts`, `user_name`
- `communication_language`, `document_output_language`, `user_skill_level`
- `date` as system-generated current datetime
- `sprint_status` = `{{.sprint_status}}`
- `project_context` = `**/project-context.md` (load if exists)
- CLAUDE.md / memory files (load if exist)
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{{.communication_language}}`
- Language MUST be tailored to `{{.user_skill_level}}`
- Generate all documents in `{{.document_output_language}}`
### Step 5: Greet the User
Greet `{{.user_name}}`, speaking in `{{.communication_language}}`.
### Step 6: Execute Append Steps
Execute each entry in `{workflow.activation_steps_append}` in order.
Activation is complete. Begin the workflow below.
## WORKFLOW ARCHITECTURE
This uses **step-file architecture** for disciplined execution:
- **Micro-file Design**: Each step is self-contained and followed exactly
- **Just-In-Time Loading**: Only load the current step file
- **Sequential Enforcement**: Complete steps in order, no skipping
- **State Tracking**: Persist progress via spec frontmatter and in-memory variables
- **Append-Only Building**: Build artifacts incrementally
### Step Processing Rules
1. **READ COMPLETELY**: Read the entire step file before acting
2. **FOLLOW SEQUENCE**: Execute sections in order
3. **WAIT FOR INPUT**: Halt at checkpoints and wait for human
4. **LOAD NEXT**: When directed, read fully and follow the next step file
### Critical Rules (NO EXCEPTIONS)
- **NEVER** load multiple step files simultaneously
- **ALWAYS** read entire step file before execution
- **NEVER** skip steps or optimize the sequence
- **ALWAYS** follow the exact instructions in the step file
- **ALWAYS** halt at checkpoints and wait for human input
## FIRST STEP
Read fully and follow: `./step-01-clarify-and-route.md` to begin the workflow.

View File

@ -1,6 +1,6 @@
code: bmm
name: "BMad Method Agile-AI Driven-Development"
description: "AI-driven agile development framework"
name: "BMad Method"
description: "Full-lifecycle AI agile development: analysis, planning, architecture, implementation"
default_selected: true # This module will be selected by default for new installations
# Variables from Core Config inserted:

View File

@ -1,6 +1,6 @@
code: core
name: "BMad Core Module"
description: "Core configuration and shared resources"
description: "Shared utilities across modules"
header: "BMad Core Configuration"
subheader: "Configure the core settings for your BMad installation.\nThese settings will be used across all installed bmad skills, workflows, and agents."

View File

@ -1666,9 +1666,9 @@ async function runTests() {
console.log('');
// ============================================================
// Test Suite 33: Community & Custom Module Managers
// Test Suite 33: Custom Module Managers
// ============================================================
console.log(`${colors.yellow}Test Suite 33: Community & Custom Module Managers${colors.reset}\n`);
console.log(`${colors.yellow}Test Suite 33: Custom Module Managers${colors.reset}\n`);
// --- CustomModuleManager._normalizeCustomModule ---
{
@ -1690,288 +1690,6 @@ async function runTests() {
assert(result2.author === 'Fallback Owner', 'normalizeCustomModule falls back to data.owner');
}
// --- CommunityModuleManager._normalizeCommunityModule ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
const mod = {
name: 'test-mod',
display_name: 'Test Module',
code: 'tm',
description: 'desc',
repository: 'https://github.com/o/r',
module_definition: 'src/module.yaml',
category: 'software-development',
subcategory: 'dev-tools',
trust_tier: 'bmad-certified',
version: '2.0.0',
approved_sha: 'abc123',
promoted: true,
promoted_rank: 1,
keywords: ['test', 'module'],
};
const result = mgr._normalizeCommunityModule(mod);
assert(result.code === 'tm', 'normalizeCommunityModule sets code');
assert(result.displayName === 'Test Module', 'normalizeCommunityModule sets displayName from display_name');
assert(result.type === 'community', 'normalizeCommunityModule sets type to community');
assert(result.category === 'software-development', 'normalizeCommunityModule preserves category');
assert(result.trustTier === 'bmad-certified', 'normalizeCommunityModule maps trust_tier');
assert(result.approvedSha === 'abc123', 'normalizeCommunityModule maps approved_sha');
assert(result.promoted === true, 'normalizeCommunityModule maps promoted');
assert(result.promotedRank === 1, 'normalizeCommunityModule maps promoted_rank');
assert(result.builtIn === false, 'normalizeCommunityModule sets builtIn false');
}
// --- CommunityModuleManager.searchByKeyword (with injected cache) ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
// Inject cached index to avoid network call
mgr._cachedIndex = {
modules: [
{ name: 'mod-a', display_name: 'Alpha', code: 'a', description: 'testing tools', category: 'dev', keywords: ['test'] },
{ name: 'mod-b', display_name: 'Beta', code: 'b', description: 'design suite', category: 'design', keywords: ['ux'] },
{ name: 'mod-c', display_name: 'Gamma', code: 'c', description: 'game engine', category: 'game', keywords: ['unity'] },
],
};
const r1 = await mgr.searchByKeyword('test');
assert(r1.length === 1 && r1[0].code === 'a', 'searchByKeyword matches keyword');
const r2 = await mgr.searchByKeyword('design');
assert(r2.length === 1 && r2[0].code === 'b', 'searchByKeyword matches description');
const r3 = await mgr.searchByKeyword('alpha');
assert(r3.length === 1 && r3[0].code === 'a', 'searchByKeyword matches display name');
const r4 = await mgr.searchByKeyword('xyz');
assert(r4.length === 0, 'searchByKeyword returns empty for no match');
const r5 = await mgr.searchByKeyword('UNITY');
assert(r5.length === 1 && r5[0].code === 'c', 'searchByKeyword is case-insensitive');
}
// --- CommunityModuleManager.listFeatured (with injected cache) ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
mgr._cachedIndex = {
modules: [
{ name: 'a', code: 'a', promoted: true, promoted_rank: 3 },
{ name: 'b', code: 'b', promoted: false },
{ name: 'c', code: 'c', promoted: true, promoted_rank: 1 },
],
};
const featured = await mgr.listFeatured();
assert(featured.length === 2, 'listFeatured returns only promoted modules');
assert(featured[0].code === 'c' && featured[1].code === 'a', 'listFeatured sorts by promoted_rank ascending');
}
// --- CommunityModuleManager.getCategoryList (with injected cache) ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
mgr._cachedIndex = {
modules: [
{ name: 'a', code: 'a', category: 'software-development' },
{ name: 'b', code: 'b', category: 'design-and-creative' },
{ name: 'c', code: 'c', category: 'software-development' },
],
};
mgr._cachedCategories = {
categories: {
'software-development': { name: 'Software Development' },
'design-and-creative': { name: 'Design & Creative' },
},
};
const cats = await mgr.getCategoryList();
assert(cats.length === 2, 'getCategoryList returns categories with modules');
const swDev = cats.find((c) => c.slug === 'software-development');
assert(swDev && swDev.moduleCount === 2, 'getCategoryList counts modules per category');
assert(cats[0].name === 'Design & Creative', 'getCategoryList sorts alphabetically');
}
// --- CommunityModuleManager SHA pinning normalization ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
// Module with SHA set
const withSha = mgr._normalizeCommunityModule({
name: 'pinned-mod',
code: 'pm',
approved_sha: 'abc123def456',
approved_tag: 'v1.0.0',
});
assert(withSha.approvedSha === 'abc123def456', 'SHA is preserved when set');
assert(withSha.approvedTag === 'v1.0.0', 'Tag is preserved as metadata');
// Module with null SHA (trusted contributor)
const noSha = mgr._normalizeCommunityModule({
name: 'trusted-mod',
code: 'tm',
approved_sha: null,
});
assert(noSha.approvedSha === null, 'Null SHA means no pinning (trusted contributor)');
}
// --- CommunityModuleManager.listByCategory (with injected cache) ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
mgr._cachedIndex = {
modules: [
{ name: 'a', code: 'a', category: 'design-and-creative' },
{ name: 'b', code: 'b', category: 'software-development' },
{ name: 'c', code: 'c', category: 'design-and-creative' },
{ name: 'd', code: 'd', category: 'game-development' },
],
};
const design = await mgr.listByCategory('design-and-creative');
assert(design.length === 2, 'listByCategory filters to matching category');
assert(
design.every((m) => m.category === 'design-and-creative'),
'listByCategory returns only matching modules',
);
const empty = await mgr.listByCategory('nonexistent');
assert(empty.length === 0, 'listByCategory returns empty for unknown category');
}
// --- CommunityModuleManager.getModuleByCode (with injected cache) ---
{
const { CommunityModuleManager } = require('../tools/installer/modules/community-manager');
const mgr = new CommunityModuleManager();
mgr._cachedIndex = {
modules: [
{ name: 'test-mod', code: 'tm', display_name: 'Test Module' },
{ name: 'other-mod', code: 'om', display_name: 'Other Module' },
],
};
const found = await mgr.getModuleByCode('tm');
assert(found !== null && found.code === 'tm', 'getModuleByCode finds existing module');
const notFound = await mgr.getModuleByCode('xyz');
assert(notFound === null, 'getModuleByCode returns null for unknown code');
}
console.log('');
// ============================================================
// Test Suite 34: RegistryClient GitHub API Cascade
// ============================================================
console.log(`${colors.yellow}Test Suite 34: RegistryClient GitHub API Cascade${colors.reset}\n`);
{
const { RegistryClient } = require('../tools/installer/modules/registry-client');
// Build a RegistryClient with stubbed fetch paths so we can assert on cascade behavior
// without making real network calls.
function createStubbedClient({ apiResult, rawResult }) {
const client = new RegistryClient();
const calls = [];
// Stub _fetchWithHeaders (GitHub API path)
client._fetchWithHeaders = async (url) => {
calls.push(`api:${url}`);
if (apiResult instanceof Error) throw apiResult;
return apiResult;
};
// Stub fetch (raw CDN path) — only intercept raw.githubusercontent.com calls
const originalFetch = client.fetch.bind(client);
client.fetch = async (url, timeout) => {
if (url.includes('raw.githubusercontent.com')) {
calls.push(`raw:${url}`);
if (rawResult instanceof Error) throw rawResult;
return rawResult;
}
return originalFetch(url, timeout);
};
return { client, calls };
}
// --- API success skips raw CDN ---
{
const { client, calls } = createStubbedClient({ apiResult: 'api-content', rawResult: 'raw-content' });
const result = await client.fetchGitHubFile('owner', 'repo', 'path/file.txt', 'main');
assert(result === 'api-content', 'RegistryClient API success returns API content');
assert(calls.length === 1, 'RegistryClient API success makes exactly one call');
assert(calls[0].startsWith('api:'), 'RegistryClient API success calls API endpoint');
}
// --- API failure falls back to raw CDN ---
{
const { client, calls } = createStubbedClient({ apiResult: new Error('HTTP 403'), rawResult: 'raw-content' });
const result = await client.fetchGitHubFile('owner', 'repo', 'path/file.txt', 'main');
assert(result === 'raw-content', 'RegistryClient API failure returns raw CDN content');
assert(calls.length === 2, 'RegistryClient API failure makes two calls');
assert(calls[0].startsWith('api:'), 'RegistryClient first call is to API');
assert(calls[1].startsWith('raw:'), 'RegistryClient second call is to raw CDN');
}
// --- Both endpoints failing throws ---
{
const { client } = createStubbedClient({ apiResult: new Error('HTTP 403'), rawResult: new Error('HTTP 404') });
let threw = false;
try {
await client.fetchGitHubFile('owner', 'repo', 'path/file.txt', 'main');
} catch {
threw = true;
}
assert(threw, 'RegistryClient both endpoints failing throws an error');
}
// --- API URL construction ---
{
const { client, calls } = createStubbedClient({ apiResult: 'content', rawResult: 'content' });
await client.fetchGitHubFile('bmad-code-org', 'bmad-plugins-marketplace', 'registry/official.yaml', 'main');
const apiCall = calls[0];
assert(
apiCall.includes('api.github.com/repos/bmad-code-org/bmad-plugins-marketplace/contents/registry/official.yaml'),
'RegistryClient API URL contains correct path',
);
assert(apiCall.includes('ref=main'), 'RegistryClient API URL contains ref parameter');
}
// --- Raw CDN URL construction ---
{
const { client, calls } = createStubbedClient({ apiResult: new Error('fail'), rawResult: 'content' });
await client.fetchGitHubFile('bmad-code-org', 'bmad-plugins-marketplace', 'registry/official.yaml', 'main');
const rawCall = calls[1];
assert(
rawCall.includes('raw.githubusercontent.com/bmad-code-org/bmad-plugins-marketplace/main/registry/official.yaml'),
'RegistryClient raw CDN URL contains correct path',
);
}
// --- fetchGitHubYaml parses YAML ---
{
const yamlContent = 'modules:\n - name: test\n description: A test module\n';
const { client } = createStubbedClient({ apiResult: yamlContent, rawResult: yamlContent });
const result = await client.fetchGitHubYaml('owner', 'repo', 'file.yaml', 'main');
assert(Array.isArray(result.modules), 'fetchGitHubYaml parses YAML correctly');
assert(result.modules[0].name === 'test', 'fetchGitHubYaml preserves YAML values');
}
}
console.log('');
// ============================================================

View File

@ -0,0 +1,175 @@
/**
* Smoke test for bmad-quick-dev render.py
*
* Sets up a temp project with a base _bmad/config.toml and an override
* _bmad/custom/config.user.toml, runs render.py, and asserts:
* 1. The override wins (workflow.md contains "Japanese").
* 2. sprint_status is an absolute path rooted at the temp project dir.
*
* Usage: node test/test-quick-dev-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', '4-implementation', 'bmad-quick-dev');
/**
* 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);
}
}
}
// ---------------------------------------------------------------------------
// 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"',
'',
'[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',
);
// Copy skill dir into <tmpDir>/bmad-quick-dev/ so find_project_root() walks
// up and finds <tmpDir>/_bmad/, and os.path.basename(script_dir) resolves
// to the real skill name so the render output lands at
// _bmad/render/bmad-quick-dev/workflow.md.
const skillDst = path.join(tmpDir, 'bmad-quick-dev');
copyDirSync(SKILL_SRC, skillDst);
// ---------------------------------------------------------------------------
// Run render.py
// ---------------------------------------------------------------------------
console.log(`\n${colors.cyan}Quick-dev renderer smoke tests${colors.reset}\n`);
const result = spawnSync('python3', [path.join(skillDst, 'render.py')], {
cwd: skillDst,
encoding: 'utf-8',
});
// ---------------------------------------------------------------------------
// 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-quick-dev', 'workflow.md');
assert(fs.existsSync(rendered), `workflow.md not found at ${rendered}`);
});
test('custom override wins — workflow.md contains "Japanese"', () => {
const rendered = path.join(tmpDir, '_bmad', 'render', 'bmad-quick-dev', 'workflow.md');
const content = fs.readFileSync(rendered, 'utf-8');
assert(content.includes('Japanese'), `"Japanese" not found in workflow.md (communication_language override did not win)`);
});
test('sprint_status is an absolute path rooted at temp project dir', () => {
const rendered = path.join(tmpDir, '_bmad', 'render', 'bmad-quick-dev', 'workflow.md');
const content = fs.readFileSync(rendered, 'utf-8');
// Normalize to forward slashes for cross-platform matching
const normalizedTmp = tmpDir.replaceAll('\\', '/');
// sprint_status should appear as <tmpDir>/impl/sprint-status.yaml
const expected = `${normalizedTmp}/impl/sprint-status.yaml`;
assert(
content.includes(expected),
`sprint_status path not found.\nExpected substring: ${expected}\n` +
`workflow.md excerpt (first 2000 chars):\n${content.slice(0, 2000)}`,
);
});
} finally {
fs.rmSync(tmpDir, { 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);

View File

@ -640,13 +640,7 @@ class Installer {
const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null;
const displayName = moduleInfo?.name || moduleName;
const externalResolution = officialModules.externalModuleManager.getResolution(moduleName);
let communityResolution = null;
if (!externalResolution) {
const { CommunityModuleManager } = require('../modules/community-manager');
communityResolution = new CommunityModuleManager().getResolution(moduleName);
}
const resolution = externalResolution || communityResolution;
const resolution = officialModules.externalModuleManager.getResolution(moduleName);
const cachedResolution = CustomModuleManager._resolutionCache.get(moduleName);
const versionInfo = await resolveModuleVersion(moduleName, {
moduleSourcePath: sourcePath,
@ -1178,21 +1172,6 @@ class Installer {
}
}
// Add installed community modules to available modules
const { CommunityModuleManager } = require('../modules/community-manager');
const communityMgr = new CommunityModuleManager();
const communityModules = await communityMgr.listAll();
for (const communityModule of communityModules) {
if (installedModules.includes(communityModule.code) && !availableModules.some((m) => m.id === communityModule.code)) {
availableModules.push({
id: communityModule.code,
name: communityModule.displayName,
isExternal: true,
fromCommunity: true,
});
}
}
// Add installed custom modules to available modules
const { CustomModuleManager } = require('../modules/custom-module-manager');
const customMgr = new CustomModuleManager();

View File

@ -310,28 +310,6 @@ class Manifest {
};
}
// Check if this is a community module
const { CommunityModuleManager } = require('../modules/community-manager');
const communityMgr = new CommunityModuleManager();
const communityInfo = await communityMgr.getModuleByCode(moduleName);
if (communityInfo) {
const communityResolution = communityMgr.getResolution(moduleName);
const versionInfo = await resolveModuleVersion(moduleName, {
moduleSourcePath,
fallbackVersion: communityInfo.version,
});
return {
version: communityResolution?.version || versionInfo.version || communityInfo.version,
source: 'community',
npmPackage: communityInfo.npmPackage || null,
repoUrl: communityInfo.url || null,
channel: communityResolution?.channel || null,
sha: communityResolution?.sha || null,
registryApprovedTag: communityResolution?.registryApprovedTag || null,
registryApprovedSha: communityResolution?.registryApprovedSha || null,
};
}
// Check if this is a custom module (from user-provided URL or local path)
const { CustomModuleManager } = require('../modules/custom-module-manager');
const customMgr = new CustomModuleManager();

View File

@ -7,7 +7,7 @@
* We build the plan from:
* 1. CLI flags (--channel / --all-* / --next=CODE / --pin CODE=TAG)
* 2. Interactive answers (the "all stable?" gate + per-module picker)
* 3. Registry defaults (default_channel from registry-fallback.yaml / official.yaml)
* 3. Registry defaults (default_channel from bmad-modules.yaml)
* 4. Hardcoded fallback 'stable'
*
* Precedence: --pin > --next=CODE > --channel (global) > registry default > 'stable'.

View File

@ -1,704 +0,0 @@
const fs = require('../fs-native');
const os = require('node:os');
const path = require('node:path');
const { execSync } = require('node:child_process');
const prompts = require('../prompts');
const { RegistryClient } = require('./registry-client');
const { decideChannelForModule } = require('./channel-plan');
const { parseGitHubRepo, tagExists } = require('./channel-resolver');
const MARKETPLACE_OWNER = 'bmad-code-org';
const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
const MARKETPLACE_REF = 'main';
/**
* Manages community modules from the BMad marketplace registry.
* Fetches community-index.yaml and categories.yaml from GitHub.
* Returns empty results when the registry is unreachable.
* Community modules are pinned to approved SHA when set; uses HEAD otherwise.
*/
function quoteShellRef(ref) {
if (typeof ref !== 'string' || !/^[\w.\-+/]+$/.test(ref)) {
throw new Error(`Unsafe ref name: ${JSON.stringify(ref)}`);
}
return `"${ref}"`;
}
class CommunityModuleManager {
// moduleCode → { channel, version, sha, registryApprovedTag, registryApprovedSha, repoUrl, bypassedCurator }
// Shared across all instances; the manifest writer often uses a fresh instance.
static _resolutions = new Map();
// moduleCode → ResolvedModule (from PluginResolver) when the cloned repo ships
// a `.claude-plugin/marketplace.json`. Lets community installs reuse the same
// skill-level install pipeline as custom-source installs (installFromResolution).
static _pluginResolutions = new Map();
constructor() {
this._client = new RegistryClient();
this._cachedIndex = null;
this._cachedCategories = null;
}
/** Get the most recent channel resolution for a community module. */
getResolution(moduleCode) {
return CommunityModuleManager._resolutions.get(moduleCode) || null;
}
/** Get the marketplace.json-derived plugin resolution for a community module, if any. */
getPluginResolution(moduleCode) {
return CommunityModuleManager._pluginResolutions.get(moduleCode) || null;
}
// ─── Data Loading ──────────────────────────────────────────────────────────
/**
* Load the community module index from the marketplace repo.
* Returns empty when the registry is unreachable.
* @returns {Object} Parsed YAML with modules array
*/
async loadCommunityIndex() {
if (this._cachedIndex) return this._cachedIndex;
try {
const config = await this._client.fetchGitHubYaml(
MARKETPLACE_OWNER,
MARKETPLACE_REPO,
'registry/community-index.yaml',
MARKETPLACE_REF,
);
if (config?.modules?.length) {
this._cachedIndex = config;
return config;
}
} catch {
// Registry unreachable - no community modules available
}
return { modules: [] };
}
/**
* Load categories from the marketplace repo.
* Returns empty when the registry is unreachable.
* @returns {Object} Parsed categories.yaml content
*/
async loadCategories() {
if (this._cachedCategories) return this._cachedCategories;
try {
const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'categories.yaml', MARKETPLACE_REF);
if (config?.categories) {
this._cachedCategories = config;
return config;
}
} catch {
// Registry unreachable - no categories available
}
return { categories: {} };
}
// ─── Listing & Filtering ──────────────────────────────────────────────────
/**
* Get all community modules, normalized.
* @returns {Array<Object>} Normalized community modules
*/
async listAll() {
const index = await this.loadCommunityIndex();
return (index.modules || []).map((mod) => this._normalizeCommunityModule(mod));
}
/**
* Get community modules filtered to a category.
* @param {string} categorySlug - Category slug (e.g., 'design-and-creative')
* @returns {Array<Object>} Filtered modules
*/
async listByCategory(categorySlug) {
const all = await this.listAll();
return all.filter((mod) => mod.category === categorySlug);
}
/**
* Get promoted/featured community modules, sorted by rank.
* @returns {Array<Object>} Featured modules
*/
async listFeatured() {
const all = await this.listAll();
return all.filter((mod) => mod.promoted === true).sort((a, b) => (a.promotedRank || 999) - (b.promotedRank || 999));
}
/**
* Search community modules by keyword.
* Matches against name, display name, description, and keywords array.
* @param {string} query - Search query
* @returns {Array<Object>} Matching modules
*/
async searchByKeyword(query) {
const all = await this.listAll();
const q = query.toLowerCase();
return all.filter((mod) => {
const searchable = [mod.name, mod.displayName, mod.description, ...(mod.keywords || [])].join(' ').toLowerCase();
return searchable.includes(q);
});
}
/**
* Get categories with module counts for UI display.
* Only returns categories that have at least one community module.
* @returns {Array<Object>} Array of { slug, name, moduleCount }
*/
async getCategoryList() {
const all = await this.listAll();
const categoriesData = await this.loadCategories();
const categories = categoriesData.categories || {};
// Count modules per category
const counts = {};
for (const mod of all) {
counts[mod.category] = (counts[mod.category] || 0) + 1;
}
// Build list with display names from categories.yaml
const result = [];
for (const [slug, count] of Object.entries(counts)) {
const catInfo = categories[slug];
result.push({
slug,
name: catInfo?.name || slug,
moduleCount: count,
});
}
// Sort alphabetically by name
result.sort((a, b) => a.name.localeCompare(b.name));
return result;
}
// ─── Module Lookup ────────────────────────────────────────────────────────
/**
* Get a community module by its code.
* @param {string} code - Module code (e.g., 'wds')
* @returns {Object|null} Normalized module or null
*/
async getModuleByCode(code) {
const all = await this.listAll();
return all.find((m) => m.code === code) || null;
}
// ─── Clone with Tag Pinning ───────────────────────────────────────────────
/**
* Get the cache directory for community modules.
* @returns {string} Path to the community modules cache directory
*/
getCacheDir() {
return path.join(os.homedir(), '.bmad', 'cache', 'community-modules');
}
/**
* Clone a community module repository, pinned to its approved tag.
* @param {string} moduleCode - Module code
* @param {Object} [options] - Clone options
* @param {boolean} [options.silent] - Suppress spinner output
* @returns {string} Path to the cloned repository
*/
async cloneModule(moduleCode, options = {}) {
const moduleInfo = await this.getModuleByCode(moduleCode);
if (!moduleInfo) {
throw new Error(`Community module '${moduleCode}' not found in the registry`);
}
const cacheDir = this.getCacheDir();
const moduleCacheDir = path.join(cacheDir, moduleCode);
const silent = options.silent || false;
await fs.ensureDir(cacheDir);
const createSpinner = async () => {
if (silent) {
return { start() {}, stop() {}, error() {}, message() {} };
}
return await prompts.spinner();
};
// ─── Resolve channel plan ──────────────────────────────────────────────
// Default community behavior (stable channel) honors the curator's
// approved SHA. --next=CODE and --pin CODE=TAG override the curator; we
// warn the user before bypassing the approved version.
const planEntry = decideChannelForModule({
code: moduleCode,
channelOptions: options.channelOptions,
registryDefault: 'stable',
});
const approvedSha = moduleInfo.approvedSha;
const approvedTag = moduleInfo.approvedTag;
let bypassedCurator = false;
if (planEntry.channel !== 'stable') {
bypassedCurator = true;
if (!silent) {
const approvedLabel = approvedTag || approvedSha || 'curator-approved version';
await prompts.log.warn(
`WARNING: Installing '${moduleCode}' from ${
planEntry.channel === 'pinned' ? `tag ${planEntry.pin}` : 'main HEAD'
} bypasses the curator-approved ${approvedLabel}. Proceed only if you trust this source.`,
);
if (!options.channelOptions?.acceptBypass) {
const proceed = await prompts.confirm({
message: `Continue installing '${moduleCode}' with curator bypass?`,
default: false,
});
if (!proceed) {
throw new Error(`Install of community module '${moduleCode}' cancelled by user.`);
}
}
}
}
let needsDependencyInstall = false;
let wasNewClone = false;
if (await fs.pathExists(moduleCacheDir)) {
// Already cloned — refresh to the correct ref for the resolved channel.
// A pinned install must not reset to origin/HEAD (it would silently drift
// to main on every re-install). Stable + approvedSha is handled below
// by the curator-SHA checkout logic.
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Checking ${moduleInfo.displayName}...`);
try {
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
execSync('git fetch origin --depth 1', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
if (planEntry.channel === 'pinned') {
// Fetch the pin tag specifically and check it out.
execSync(`git fetch --depth 1 origin ${quoteShellRef(planEntry.pin)} --no-tags`, {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
execSync('git checkout --quiet FETCH_HEAD', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
} else {
// stable (approvedSha path re-checks out below) and next: track main.
execSync('git reset --hard origin/HEAD', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
}
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
if (currentRef !== newRef) needsDependencyInstall = true;
fetchSpinner.stop(`Verified ${moduleInfo.displayName}`);
} catch {
fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.displayName}`);
await fs.remove(moduleCacheDir);
wasNewClone = true;
}
} else {
wasNewClone = true;
}
if (wasNewClone) {
const fetchSpinner = await createSpinner();
fetchSpinner.start(`Fetching ${moduleInfo.displayName}...`);
try {
if (planEntry.channel === 'pinned') {
execSync(`git clone --depth 1 --branch ${quoteShellRef(planEntry.pin)} "${moduleInfo.url}" "${moduleCacheDir}"`, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
} else {
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
}
fetchSpinner.stop(`Fetched ${moduleInfo.displayName}`);
needsDependencyInstall = true;
} catch (error) {
fetchSpinner.error(`Failed to fetch ${moduleInfo.displayName}`);
throw new Error(`Failed to clone community module '${moduleCode}': ${error.message}`);
}
}
// ─── Check out the resolved ref per channel ──────────────────────────
if (planEntry.channel === 'stable' && approvedSha) {
// Default path: pin to the curator-approved SHA. Refuse install if the SHA
// is unreachable (tag may have been deleted or rewritten) — security requirement.
const headSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
if (headSha !== approvedSha) {
try {
execSync(`git fetch --depth 1 origin ${quoteShellRef(approvedSha)}`, {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
execSync(`git checkout ${quoteShellRef(approvedSha)}`, {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
});
needsDependencyInstall = true;
} catch {
await fs.remove(moduleCacheDir);
throw new Error(
`Community module '${moduleCode}' could not be pinned to its approved commit (${approvedSha}). ` +
`Installation refused for security. The module registry entry may need updating, ` +
`or use --next=${moduleCode} / --pin ${moduleCode}=<tag> to explicitly bypass.`,
);
}
}
} else if (planEntry.channel === 'stable' && !approvedSha) {
// Registry data gap: tag or SHA missing. Warn but proceed at HEAD (pre-existing behavior).
if (!silent) {
await prompts.log.warn(`Community module '${moduleCode}' has no curator-approved SHA in the registry; installing from main HEAD.`);
}
} else if (planEntry.channel === 'pinned') {
// We cloned the tag directly above (via --branch), but ensure HEAD matches.
// No additional checkout needed.
}
// else: 'next' channel — already at origin/HEAD from the fetch/reset above.
// Record the resolution so the manifest writer can pick up channel/version/sha.
const installedSha = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
const recordedVersion =
planEntry.channel === 'pinned' ? planEntry.pin : planEntry.channel === 'next' ? 'main' : approvedTag || installedSha.slice(0, 7);
CommunityModuleManager._resolutions.set(moduleCode, {
channel: planEntry.channel,
version: recordedVersion,
sha: installedSha,
registryApprovedTag: approvedTag || null,
registryApprovedSha: approvedSha || null,
repoUrl: moduleInfo.url,
bypassedCurator,
planSource: planEntry.source,
});
// If the repo ships a marketplace.json, route through PluginResolver so the
// skill-level install pipeline (installFromResolution) handles the copy.
// Repos without marketplace.json fall through to the legacy findModuleSource
// path unchanged.
await this._tryResolveMarketplacePlugin(moduleCacheDir, moduleInfo, {
channel: planEntry.channel,
version: recordedVersion,
sha: installedSha,
approvedTag,
approvedSha,
});
// Install dependencies if needed
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
if ((needsDependencyInstall || wasNewClone) && (await fs.pathExists(packageJsonPath))) {
const installSpinner = await createSpinner();
installSpinner.start(`Installing dependencies for ${moduleInfo.displayName}...`);
try {
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
cwd: moduleCacheDir,
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 120_000,
});
installSpinner.stop(`Installed dependencies for ${moduleInfo.displayName}`);
} catch (error) {
installSpinner.error(`Failed to install dependencies for ${moduleInfo.displayName}`);
if (!silent) await prompts.log.warn(` ${error.message}`);
}
}
return moduleCacheDir;
}
// ─── Marketplace.json Resolution ──────────────────────────────────────────
/**
* Detect `.claude-plugin/marketplace.json` in a cloned community repo and
* route through PluginResolver. When successful, caches the resolution so
* OfficialModulesManager.install() can route the copy through
* installFromResolution() the same path used by custom-source installs.
*
* Silent no-op when marketplace.json is absent or the resolver returns no
* matches; the legacy findModuleSource path then handles the install.
*
* @param {string} repoPath - Absolute path to the cloned repo
* @param {Object} moduleInfo - Normalized community module info
* @param {Object} resolution - Resolution metadata from cloneModule
* @param {string} resolution.channel - Channel ('stable' | 'next' | 'pinned')
* @param {string} resolution.version - Recorded version string
* @param {string} resolution.sha - Resolved git SHA
* @param {string|null} resolution.approvedTag - Registry approved tag
* @param {string|null} resolution.approvedSha - Registry approved SHA
*/
async _tryResolveMarketplacePlugin(repoPath, moduleInfo, resolution) {
const marketplacePath = path.join(repoPath, '.claude-plugin', 'marketplace.json');
if (!(await fs.pathExists(marketplacePath))) return;
let marketplaceData;
try {
marketplaceData = JSON.parse(await fs.readFile(marketplacePath, 'utf8'));
} catch {
// Malformed marketplace.json — fall through to legacy path.
return;
}
const plugins = Array.isArray(marketplaceData?.plugins) ? marketplaceData.plugins : [];
if (plugins.length === 0) return;
const selection = this._selectPluginForModule(plugins, moduleInfo);
if (!selection) {
await this._safeWarn(
`Community module '${moduleInfo.code}' ships marketplace.json but no plugin entry matches the registry code. ` +
`Falling back to legacy install path.`,
);
return;
}
if (selection.source === 'single-fallback') {
// Single-entry marketplace.json whose plugin name doesn't match the registry
// code or the module_definition hint. Most likely correct, but worth surfacing
// in case marketplace.json is misconfigured and we'd install the wrong plugin.
await this._safeWarn(
`Community module '${moduleInfo.code}' picked the only plugin in marketplace.json ('${selection.plugin?.name}') ` +
`because no name or module_definition match was found. Verify marketplace.json if the install looks wrong.`,
);
}
const { PluginResolver } = require('./plugin-resolver');
const resolver = new PluginResolver();
let resolved;
try {
resolved = await resolver.resolve(repoPath, selection.plugin);
} catch (error) {
// PluginResolver threw (malformed plugin entry, missing files, etc.).
// Honor the silent-fallthrough contract — warn and let the legacy
// findModuleSource path handle the install.
await this._safeWarn(
`PluginResolver failed for community module '${moduleInfo.code}': ${error.message}. ` + `Falling back to legacy install path.`,
);
return;
}
if (!resolved || resolved.length === 0) return;
// The registry registers a single code per module. If the resolver returns
// multiple modules (Strategy 4: multiple standalone skills), accept only
// the entry whose code matches the registry. Other entries are ignored —
// they belong to plugins not registered in the community catalog.
const matched = resolved.find((mod) => mod.code === moduleInfo.code) || (resolved.length === 1 ? resolved[0] : null);
if (!matched) return;
// Shallow-clone before stamping provenance — the resolver may cache or reuse
// its return objects, and we don't want install-specific fields leaking back.
const stamped = {
...matched,
code: moduleInfo.code,
repoUrl: moduleInfo.url,
cloneRef: resolution.channel === 'pinned' ? resolution.version : resolution.approvedTag || null,
cloneSha: resolution.sha,
communitySource: true,
communityChannel: resolution.channel,
communityVersion: resolution.version,
registryApprovedTag: resolution.approvedTag,
registryApprovedSha: resolution.approvedSha,
};
CommunityModuleManager._pluginResolutions.set(moduleInfo.code, stamped);
}
/**
* Lazy fallback: resolve marketplace.json straight from the on-disk cache
* when `_pluginResolutions` is empty (e.g. callers that reach `install()`
* without `cloneModule` having populated the cache earlier in this process).
*
* Reuses an existing channel resolution if present; otherwise synthesizes a
* minimal stable-channel stub from the registry entry + the cached repo's
* current HEAD. Returns the cached plugin resolution if one is produced,
* otherwise null (caller falls back to the legacy path).
*
* @param {string} moduleCode
* @returns {Promise<Object|null>}
*/
async resolveFromCache(moduleCode) {
const existing = this.getPluginResolution(moduleCode);
if (existing) return existing;
const cacheRepoDir = path.join(this.getCacheDir(), moduleCode);
const marketplacePath = path.join(cacheRepoDir, '.claude-plugin', 'marketplace.json');
if (!(await fs.pathExists(marketplacePath))) return null;
let moduleInfo;
try {
moduleInfo = await this.getModuleByCode(moduleCode);
} catch {
return null;
}
if (!moduleInfo) return null;
let channelResolution = this.getResolution(moduleCode);
if (!channelResolution) {
let sha = '';
try {
sha = execSync('git rev-parse HEAD', { cwd: cacheRepoDir, stdio: 'pipe' }).toString().trim();
} catch {
// Not a git repo or unreadable — give up and let the legacy path run.
return null;
}
channelResolution = {
channel: 'stable',
version: moduleInfo.approvedTag || sha.slice(0, 7),
sha,
registryApprovedTag: moduleInfo.approvedTag || null,
registryApprovedSha: moduleInfo.approvedSha || null,
};
}
await this._tryResolveMarketplacePlugin(cacheRepoDir, moduleInfo, {
channel: channelResolution.channel,
version: channelResolution.version,
sha: channelResolution.sha,
approvedTag: channelResolution.registryApprovedTag,
approvedSha: channelResolution.registryApprovedSha,
});
return this.getPluginResolution(moduleCode);
}
/**
* Best-effort warning emitter. `prompts.log.warn` may be undefined in some
* harnesses and may return a rejected promise swallow both cases so a
* fallthrough warning can never crash the install.
*/
async _safeWarn(message) {
try {
const result = prompts.log?.warn?.(message);
if (result && typeof result.then === 'function') await result;
} catch {
/* ignore */
}
}
/**
* Pick which plugin entry from marketplace.json represents this community module.
* Precedence:
* 1. Exact match on `plugin.name === moduleInfo.code`
* 2. Trailing directory of `module_definition` matches `plugin.name`
* 3. Single plugin in marketplace.json accepted with a warning so a
* mismatched-but-uniquely-named plugin doesn't install silently.
* Otherwise null (caller falls back to legacy path).
*
* @returns {{plugin: Object, source: 'name'|'hint'|'single-fallback'}|null}
*/
_selectPluginForModule(plugins, moduleInfo) {
const byCode = plugins.find((p) => p && p.name === moduleInfo.code);
if (byCode) return { plugin: byCode, source: 'name' };
if (moduleInfo.moduleDefinition) {
// module_definition like "src/skills/suno-setup/assets/module.yaml" →
// hint segment "suno-setup". Match that against plugin names.
const segments = moduleInfo.moduleDefinition.split('/').filter(Boolean);
const setupIdx = segments.findIndex((s) => s.endsWith('-setup'));
if (setupIdx !== -1) {
const hint = segments[setupIdx];
const byHint = plugins.find((p) => p && p.name === hint);
if (byHint) return { plugin: byHint, source: 'hint' };
}
}
if (plugins.length === 1) return { plugin: plugins[0], source: 'single-fallback' };
return null;
}
// ─── Source Finding ───────────────────────────────────────────────────────
/**
* Find the source path for a community module (clone + locate module.yaml).
* @param {string} moduleCode - Module code
* @param {Object} [options] - Options passed to cloneModule
* @returns {string|null} Path to the module source or null
*/
async findModuleSource(moduleCode, options = {}) {
const moduleInfo = await this.getModuleByCode(moduleCode);
if (!moduleInfo) return null;
const cloneDir = await this.cloneModule(moduleCode, options);
// Check configured module_definition path first
if (moduleInfo.moduleDefinition) {
const configuredPath = path.join(cloneDir, moduleInfo.moduleDefinition);
if (await fs.pathExists(configuredPath)) {
return path.dirname(configuredPath);
}
}
// Fallback: search skills/ and src/ directories
for (const dir of ['skills', 'src']) {
const rootCandidate = path.join(cloneDir, dir, 'module.yaml');
if (await fs.pathExists(rootCandidate)) {
return path.dirname(rootCandidate);
}
const dirPath = path.join(cloneDir, dir);
if (await fs.pathExists(dirPath)) {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const subCandidate = path.join(dirPath, entry.name, 'module.yaml');
if (await fs.pathExists(subCandidate)) {
return path.dirname(subCandidate);
}
}
}
}
}
// Check repo root
const rootCandidate = path.join(cloneDir, 'module.yaml');
if (await fs.pathExists(rootCandidate)) {
return path.dirname(rootCandidate);
}
return moduleInfo.moduleDefinition ? path.dirname(path.join(cloneDir, moduleInfo.moduleDefinition)) : null;
}
// ─── Normalization ────────────────────────────────────────────────────────
/**
* Normalize a community module entry to a consistent shape.
* @param {Object} mod - Raw module from community-index.yaml
* @returns {Object} Normalized module info
*/
_normalizeCommunityModule(mod) {
return {
key: mod.name,
code: mod.code,
name: mod.display_name || mod.name,
displayName: mod.display_name || mod.name,
description: mod.description || '',
url: mod.repository || mod.url,
moduleDefinition: mod.module_definition || mod['module-definition'],
npmPackage: mod.npm_package || mod.npmPackage || null,
author: mod.author || '',
license: mod.license || '',
type: 'community',
category: mod.category || '',
subcategory: mod.subcategory || '',
keywords: mod.keywords || [],
version: mod.version || null,
approvedTag: mod.approved_tag || null,
approvedSha: mod.approved_sha || null,
approvedDate: mod.approved_date || null,
reviewer: mod.reviewer || null,
trustTier: mod.trust_tier || 'unverified',
promoted: mod.promoted === true,
promotedRank: mod.promoted_rank || null,
defaultSelected: false,
builtIn: false,
isExternal: true,
};
}
}
module.exports = { CommunityModuleManager };

View File

@ -4,9 +4,9 @@ const path = require('node:path');
const { execSync } = require('node:child_process');
const yaml = require('yaml');
const prompts = require('../prompts');
const { RegistryClient } = require('./registry-client');
const { resolveChannel, tagExists, parseGitHubRepo } = require('./channel-resolver');
const { decideChannelForModule } = require('./channel-plan');
const { getProjectRoot } = require('../project-root');
const VALID_CHANNELS = new Set(['stable', 'next', 'pinned']);
@ -46,15 +46,12 @@ async function writeChannelMarker(markerPath, data) {
}
}
const MARKETPLACE_OWNER = 'bmad-code-org';
const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
const MARKETPLACE_REF = 'main';
const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
const REGISTRY_CONFIG_PATH = path.join(getProjectRoot(), 'bmad-modules.yaml');
/**
* Manages official modules from the remote BMad marketplace registry.
* Fetches registry/official.yaml from GitHub; falls back to the bundled
* external-official-modules.yaml when the network is unavailable.
* Manages official modules from the bundled registry file. The remote
* marketplace fetch has been retired; this repo is the single source of truth
* for which official modules exist and how they are displayed.
*
* @class ExternalModuleManager
*/
@ -65,9 +62,7 @@ class ExternalModuleManager {
// ExternalModuleManager) sees resolutions made during install.
static _resolutions = new Map();
constructor() {
this._client = new RegistryClient();
}
constructor() {}
/**
* Get the most recent channel resolution for a module (if any).
@ -79,8 +74,7 @@ class ExternalModuleManager {
}
/**
* Load the official modules registry from GitHub, falling back to the
* bundled YAML file if the fetch fails.
* Load the official modules registry from the bundled YAML file.
* @returns {Object} Parsed YAML content with modules array
*/
async loadExternalModulesConfig() {
@ -88,23 +82,10 @@ class ExternalModuleManager {
return this.cachedModules;
}
// Try remote registry first
try {
const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'registry/official.yaml', MARKETPLACE_REF);
if (config?.modules?.length) {
this.cachedModules = config;
return config;
}
} catch {
// Fall through to local fallback
}
// Fallback to bundled file
try {
const content = await fs.readFile(FALLBACK_CONFIG_PATH, 'utf8');
const content = await fs.readFile(REGISTRY_CONFIG_PATH, 'utf8');
const config = yaml.parse(content);
this.cachedModules = config;
await prompts.log.warn('Could not reach BMad registry; using bundled module list.');
return config;
} catch (error) {
await prompts.log.warn(`Failed to load modules config: ${error.message}`);
@ -130,6 +111,7 @@ class ExternalModuleManager {
defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
type: mod.type || 'bmad-org',
npmPackage: mod.npm_package || mod.npmPackage || null,
pluginName: mod.plugin_name || mod.pluginName || null,
defaultChannel: normalizeChannelName(mod.default_channel || mod.defaultChannel) || 'stable',
builtIn: mod.built_in === true,
isExternal: mod.built_in !== true,

View File

@ -231,14 +231,6 @@ class OfficialModules {
return externalSource;
}
// Check community modules (pass channelOptions for --next/--pin overrides)
const { CommunityModuleManager } = require('./community-manager');
const communityMgr = new CommunityModuleManager();
const communitySource = await communityMgr.findModuleSource(moduleCode, options);
if (communitySource) {
return communitySource;
}
// Check custom modules (from user-provided URLs, already cloned to cache)
const { CustomModuleManager } = require('./custom-module-manager');
const customMgr = new CustomModuleManager();
@ -269,21 +261,6 @@ class OfficialModules {
return this.installFromResolution(resolved, bmadDir, fileTrackingCallback, options);
}
// Community modules whose cloned repo ships marketplace.json get the same
// skill-level install treatment as custom-source installs. If the in-process
// cache wasn't populated (e.g. caller skipped the pre-clone phase), fall
// back to resolving directly from `~/.bmad/cache/community-modules/<name>/`
// so we don't silently regress to the legacy half-install path.
const { CommunityModuleManager } = require('./community-manager');
const communityMgr = new CommunityModuleManager();
let communityResolved = communityMgr.getPluginResolution(moduleName);
if (!communityResolved) {
communityResolved = await communityMgr.resolveFromCache(moduleName);
}
if (communityResolved) {
return this.installFromResolution(communityResolved, bmadDir, fileTrackingCallback, options);
}
const sourcePath = await this.findModuleSource(moduleName, {
silent: options.silent,
channelOptions: options.channelOptions,
@ -310,14 +287,9 @@ class OfficialModules {
const manifestObj = new Manifest();
const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
// Pick up channel resolution recorded by whichever manager did the clone.
const externalResolution = this.externalModuleManager.getResolution(moduleName);
let communityResolution = null;
if (!externalResolution) {
const { CommunityModuleManager } = require('./community-manager');
communityResolution = new CommunityModuleManager().getResolution(moduleName);
}
const resolution = externalResolution || communityResolution;
// Pick up channel resolution recorded by the external manager (the only
// manager that does pre-clone resolution now that community is retired).
const resolution = this.externalModuleManager.getResolution(moduleName);
await manifestObj.addModule(bmadDir, moduleName, {
version: resolution?.version || versionInfo.version,
@ -326,8 +298,6 @@ class OfficialModules {
repoUrl: versionInfo.repoUrl,
channel: resolution?.channel,
sha: resolution?.sha,
registryApprovedTag: communityResolution?.registryApprovedTag,
registryApprovedSha: communityResolution?.registryApprovedSha,
});
return { success: true, module: moduleName, path: targetPath, versionInfo };
@ -375,27 +345,19 @@ class OfficialModules {
await this.createModuleDirectories(resolved.code, bmadDir, options);
}
// Update manifest. For community installs we honor the channel resolved by
// CommunityModuleManager (stable/next/pinned) and propagate the registry's
// approved tag/sha. For custom-source installs we derive channel from the
// Update manifest. For custom-source installs we derive channel from the
// cloneRef (present → pinned, absent → next; local paths have no channel).
const { Manifest } = require('../core/manifest');
const manifestObj = new Manifest();
const hasGitClone = !!resolved.repoUrl;
const isCommunity = resolved.communitySource === true;
const manifestEntry = {
version: resolved.communityVersion || resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
source: isCommunity ? 'community' : 'custom',
version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
source: 'custom',
npmPackage: null,
repoUrl: resolved.repoUrl || null,
};
if (isCommunity) {
if (resolved.communityChannel) manifestEntry.channel = resolved.communityChannel;
if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
if (resolved.registryApprovedTag) manifestEntry.registryApprovedTag = resolved.registryApprovedTag;
if (resolved.registryApprovedSha) manifestEntry.registryApprovedSha = resolved.registryApprovedSha;
} else if (hasGitClone) {
if (hasGitClone) {
manifestEntry.channel = resolved.cloneRef ? 'pinned' : 'next';
if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
if (resolved.rawInput) manifestEntry.rawSource = resolved.rawInput;
@ -408,11 +370,10 @@ class OfficialModules {
module: resolved.code,
path: targetPath,
// Mirror the manifestEntry.version precedence above so downstream summary
// lines show the same string we just wrote to disk (community installs
// use the registry-approved tag via `communityVersion`; custom git-backed
// lines show the same string we just wrote to disk (custom git-backed
// installs show the cloned ref or 'main').
versionInfo: {
version: resolved.communityVersion || resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || ''),
version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || ''),
},
};
}

View File

@ -1,187 +0,0 @@
const https = require('node:https');
const yaml = require('yaml');
/**
* Build a rich Error from a non-2xx response. Includes the URL, the GitHub
* JSON error message (or a truncated body snippet), rate-limit reset time,
* and Retry-After anything present that would help a user recover.
*/
function buildHttpError(url, res, body) {
const parts = [`HTTP ${res.statusCode} ${url}`];
if (body) {
try {
const parsed = JSON.parse(body);
if (parsed.message) parts.push(parsed.message);
if (parsed.documentation_url) parts.push(`(see ${parsed.documentation_url})`);
} catch {
const snippet = body.slice(0, 200).trim();
if (snippet) parts.push(snippet);
}
}
const remaining = res.headers['x-ratelimit-remaining'];
const reset = res.headers['x-ratelimit-reset'];
if (remaining === '0' && reset) {
parts.push(`rate limit exhausted; resets at ${new Date(Number(reset) * 1000).toISOString()}`);
}
const retryAfter = res.headers['retry-after'];
if (retryAfter) parts.push(`retry after ${retryAfter}`);
return new Error(parts.join(' — '));
}
/**
* Shared HTTP client for fetching registry data from GitHub.
* Used by ExternalModuleManager, CommunityModuleManager, and CustomModuleManager.
*/
class RegistryClient {
constructor(options = {}) {
this.timeout = options.timeout || 10_000;
}
/**
* Fetch a URL and return the response body as a string.
* Follows up to 3 redirects (GitHub sometimes 301s).
* @param {string} url - URL to fetch
* @param {number} [timeout] - Timeout in ms (overrides default)
* @param {number} [maxRedirects=3] - Maximum redirects to follow
* @returns {Promise<string>} Response body
*/
fetch(url, timeout, maxRedirects = 3) {
const timeoutMs = timeout || this.timeout;
return new Promise((resolve, reject) => {
const req = https
.get(url, { timeout: timeoutMs }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
if (maxRedirects <= 0) {
return reject(new Error('Too many redirects'));
}
return this.fetch(res.headers.location, timeoutMs, maxRedirects - 1).then(resolve, reject);
}
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(buildHttpError(url, res, data));
}
resolve(data);
});
})
.on('error', reject)
.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
});
}
/**
* Fetch a URL and parse the response as YAML.
* @param {string} url - URL to fetch
* @param {number} [timeout] - Timeout in ms
* @returns {Promise<Object>} Parsed YAML content
*/
async fetchYaml(url, timeout) {
const content = await this.fetch(url, timeout);
return yaml.parse(content);
}
/**
* Fetch a file from a GitHub repo using the Contents API first,
* falling back to raw.githubusercontent.com if the API fails.
*
* The API endpoint (`api.github.com`) is tried first because corporate
* proxies commonly block `raw.githubusercontent.com` while allowing
* `api.github.com` under the "Software Development" category.
*
* @param {string} owner - Repository owner (e.g., 'bmad-code-org')
* @param {string} repo - Repository name (e.g., 'bmad-plugins-marketplace')
* @param {string} filePath - Path within the repo (e.g., 'registry/official.yaml')
* @param {string} ref - Git ref (branch, tag, or SHA; e.g., 'main')
* @param {number} [timeout] - Timeout in ms (overrides default)
* @returns {Promise<string>} Raw file content
*/
async fetchGitHubFile(owner, repo, filePath, ref, timeout) {
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${ref}`;
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath}`;
// Try GitHub Contents API first (with raw content accept header)
try {
return await this._fetchWithHeaders(apiUrl, { Accept: 'application/vnd.github.raw+json' }, timeout);
} catch (apiError) {
// API failed — fall back to raw CDN
try {
return await this.fetch(rawUrl, timeout);
} catch (cdnError) {
throw new AggregateError([apiError, cdnError], `Both GitHub API and raw CDN failed for ${filePath}`);
}
}
}
/**
* Fetch a file from GitHub and parse as YAML.
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @param {string} filePath - Path within the repo
* @param {string} ref - Git ref
* @param {number} [timeout] - Timeout in ms
* @returns {Promise<Object>} Parsed YAML content
*/
async fetchGitHubYaml(owner, repo, filePath, ref, timeout) {
const content = await this.fetchGitHubFile(owner, repo, filePath, ref, timeout);
return yaml.parse(content);
}
/**
* Fetch a URL with custom headers. Used for GitHub API requests.
* Follows up to 3 redirects.
* @param {string} url - URL to fetch
* @param {Object} headers - Request headers
* @param {number} [timeout] - Timeout in ms
* @param {number} [maxRedirects=3] - Maximum redirects to follow
* @returns {Promise<string>} Response body
* @private
*/
_fetchWithHeaders(url, headers, timeout, maxRedirects = 3) {
const timeoutMs = timeout || this.timeout;
const parsed = new URL(url);
const options = {
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
timeout: timeoutMs,
headers: {
'User-Agent': 'bmad-installer',
...headers,
},
};
return new Promise((resolve, reject) => {
const req = https
.get(options, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
if (maxRedirects <= 0) {
return reject(new Error('Too many redirects'));
}
return this._fetchWithHeaders(res.headers.location, headers, timeoutMs, maxRedirects - 1).then(resolve, reject);
}
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(buildHttpError(url, res, data));
}
resolve(data);
});
})
.on('error', reject)
.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
});
}
}
module.exports = { RegistryClient };

View File

@ -818,32 +818,18 @@ class UI {
// Phase 1: Official modules
const officialSelected = await this._selectOfficialModules(installedModuleIds, installedModuleVersions, channelOptions);
// Determine which installed modules are NOT official (community or custom).
// These must be preserved even if the user declines to browse community/custom.
const officialCodes = new Set(officialSelected);
// Identify installed modules that aren't official (previously installed
// community modules or custom-source modules). Preserve them on update;
// they can be managed via --custom-source, uninstall, or a dedicated installer.
const externalManager = new ExternalModuleManager();
const registryModules = await externalManager.listAvailable();
const officialRegistryCodes = new Set(['core', 'bmm', ...registryModules.map((m) => m.code)]);
const installedNonOfficial = [...installedModuleIds].filter((id) => !officialRegistryCodes.has(id));
// Phase 2: Community modules (category drill-down)
// Returns { codes, didBrowse } so we know if the user entered the flow
const communityResult = await this._browseCommunityModules(installedModuleIds);
// Phase 3: Custom URL modules
// Phase 2: Custom URL modules
const customSelected = await this._addCustomUrlModules(installedModuleIds);
// Merge all selections
const allSelected = new Set([...officialSelected, ...communityResult.codes, ...customSelected]);
// Auto-include installed non-official modules that the user didn't get
// a chance to manage (they declined to browse). If they did browse,
// trust their selections - they could have deselected intentionally.
if (!communityResult.didBrowse) {
for (const code of installedNonOfficial) {
allSelected.add(code);
}
}
const allSelected = new Set([...officialSelected, ...customSelected, ...installedNonOfficial]);
return [...allSelected];
}
@ -954,166 +940,6 @@ class UI {
return result;
}
/**
* Browse and select community modules using category drill-down.
* Featured/promoted modules appear at the top.
* @param {Set} installedModuleIds - Currently installed module IDs
* @returns {Object} { codes: string[], didBrowse: boolean }
*/
async _browseCommunityModules(installedModuleIds = new Set()) {
const browseCommunity = await prompts.confirm({
message: 'Would you like to browse community modules?',
default: false,
});
if (!browseCommunity) return { codes: [], didBrowse: false };
const { CommunityModuleManager } = require('./modules/community-manager');
const communityMgr = new CommunityModuleManager();
const s = await prompts.spinner();
s.start('Loading community module catalog...');
let categories, featured, allCommunity;
try {
[categories, featured, allCommunity] = await Promise.all([
communityMgr.getCategoryList(),
communityMgr.listFeatured(),
communityMgr.listAll(),
]);
s.stop(`Community catalog loaded (${allCommunity.length} modules)`);
} catch (error) {
s.error('Failed to load community catalog');
await prompts.log.warn(` ${error.message}`);
return { codes: [], didBrowse: false };
}
if (allCommunity.length === 0) {
await prompts.log.info('No community modules are currently available.');
return { codes: [], didBrowse: false };
}
const selectedCodes = new Set();
let browsing = true;
while (browsing) {
const categoryChoices = [];
// Featured section at top
if (featured.length > 0) {
categoryChoices.push({
value: '__featured__',
label: `\u2605 Featured (${featured.length} module${featured.length === 1 ? '' : 's'})`,
});
}
// Categories with module counts
for (const cat of categories) {
categoryChoices.push({
value: cat.slug,
label: `${cat.name} (${cat.moduleCount} module${cat.moduleCount === 1 ? '' : 's'})`,
});
}
// Special actions at bottom
categoryChoices.push(
{ value: '__all__', label: '\u25CE View all community modules' },
{ value: '__search__', label: '\u25CE Search by keyword' },
{ value: '__done__', label: '\u2713 Done browsing' },
);
const selectedCount = selectedCodes.size;
const categoryChoice = await prompts.select({
message: `Browse community modules${selectedCount > 0 ? ` (${selectedCount} selected)` : ''}:`,
choices: categoryChoices,
});
if (categoryChoice === '__done__') {
browsing = false;
continue;
}
let modulesToShow;
switch (categoryChoice) {
case '__featured__': {
modulesToShow = featured;
break;
}
case '__all__': {
modulesToShow = allCommunity;
break;
}
case '__search__': {
const query = await prompts.text({
message: 'Search community modules:',
placeholder: 'e.g., design, testing, game',
});
if (!query || query.trim() === '') continue;
modulesToShow = await communityMgr.searchByKeyword(query.trim());
if (modulesToShow.length === 0) {
await prompts.log.warn('No matching modules found.');
continue;
}
break;
}
default: {
modulesToShow = await communityMgr.listByCategory(categoryChoice);
}
}
// Build options for autocompleteMultiselect
const trustBadge = (tier) => {
if (tier === 'bmad-certified') return '\u2713';
if (tier === 'community-reviewed') return '\u25CB';
return '\u26A0';
};
const options = modulesToShow.map((mod) => {
const versionStr = mod.version ? ` (v${mod.version})` : '';
const badge = trustBadge(mod.trustTier);
return {
label: `${mod.displayName}${versionStr} [${badge}]`,
value: mod.code,
hint: mod.description,
};
});
// Pre-check modules that are already selected or installed
const initialValues = modulesToShow.filter((m) => selectedCodes.has(m.code) || installedModuleIds.has(m.code)).map((m) => m.code);
const selected = await prompts.autocompleteMultiselect({
message: 'Select community modules:',
options,
initialValues: initialValues.length > 0 ? initialValues : undefined,
required: false,
maxItems: Math.min(options.length, 10),
});
// Update accumulated selections: sync with what user selected in this view
const shownCodes = new Set(modulesToShow.map((m) => m.code));
for (const code of shownCodes) {
if (selected && selected.includes(code)) {
selectedCodes.add(code);
} else {
selectedCodes.delete(code);
}
}
}
if (selectedCodes.size > 0) {
const moduleLines = [];
for (const code of selectedCodes) {
const mod = await communityMgr.getModuleByCode(code);
moduleLines.push(` \u2022 ${mod?.displayName || code}`);
}
await prompts.log.message('Selected community modules:\n' + moduleLines.join('\n'));
}
return { codes: [...selectedCodes], didBrowse: true };
}
/**
* Prompt user to install modules from custom sources (Git URLs or local paths).
* @param {Set} installedModuleIds - Currently installed module IDs
@ -1121,7 +947,7 @@ class UI {
*/
async _addCustomUrlModules(installedModuleIds = new Set()) {
const addCustom = await prompts.confirm({
message: 'Would you like to install from a custom source (Git URL or local path)?',
message: 'Do you want to install custom or community modules (Git URL or local path)?',
default: false,
});
if (!addCustom) return [];
@ -1885,19 +1711,14 @@ class UI {
const haveFlagIntent = channelOptions.global || channelOptions.nextSet.size > 0 || channelOptions.pins.size > 0;
if (haveFlagIntent) return;
// Figure out which selected modules actually get a channel (externals +
// community modules). Bundled core/bmm and custom modules skip the picker.
// Figure out which selected modules actually get a channel (externals only).
// Bundled core/bmm and custom modules skip the picker.
const externalManager = new ExternalModuleManager();
const externals = await externalManager.listAvailable();
const externalByCode = new Map(externals.map((m) => [m.code, m]));
const { CommunityModuleManager } = require('./modules/community-manager');
const communityMgr = new CommunityModuleManager();
const community = await communityMgr.listAll();
const communityByCode = new Map(community.map((m) => [m.code, m]));
const channelSelectable = selectedModules.filter((code) => {
const info = externalByCode.get(code) || communityByCode.get(code);
const info = externalByCode.get(code);
return info && !info.builtIn;
});
if (channelSelectable.length === 0) return;
@ -1912,7 +1733,7 @@ class UI {
const { fetchStableTags, parseGitHubRepo } = require('./modules/channel-resolver');
for (const code of channelSelectable) {
const info = externalByCode.get(code) || communityByCode.get(code);
const info = externalByCode.get(code);
const repoUrl = info.url;
// Try to pre-resolve the top stable tag so we can surface it in the picker.
@ -1987,11 +1808,6 @@ class UI {
const externals = await externalManager.listAvailable();
const externalByCode = new Map(externals.map((m) => [m.code, m]));
const { CommunityModuleManager } = require('./modules/community-manager');
const communityMgr = new CommunityModuleManager();
const community = await communityMgr.listAll();
const communityByCode = new Map(community.map((m) => [m.code, m]));
const { fetchStableTags, classifyUpgrade, releaseNotesUrl } = require('./modules/channel-resolver');
const { parseGitHubRepo } = require('./modules/channel-resolver');
@ -2003,7 +1819,7 @@ class UI {
const existingWithChannel = selectedModules.filter((code) => {
const prev = existingByName.get(code);
if (!prev) return false;
const info = externalByCode.get(code) || communityByCode.get(code);
const info = externalByCode.get(code);
return info && !info.builtIn;
});
if (existingWithChannel.length > 0) {
@ -2018,7 +1834,7 @@ class UI {
const prev = existingByName.get(code);
if (!prev) continue;
const info = externalByCode.get(code) || communityByCode.get(code);
const info = externalByCode.get(code);
if (!info) continue;
// Bundled modules (core/bmm) ship with the installer binary itself —
// their version is stapled to the CLI version, not a git tag. Skip

View File

@ -10,7 +10,7 @@ Before running inference-based validation, run the deterministic validator:
node tools/validate-skills.js --json path/to/skill-dir
```
This checks 14 rules deterministically: SKILL-01, SKILL-02, SKILL-03, SKILL-04, SKILL-05, SKILL-06, SKILL-07, WF-01, WF-02, PATH-02, STEP-01, STEP-06, STEP-07, SEQ-02.
This checks 15 rules deterministically: SKILL-01, SKILL-02, SKILL-03, SKILL-04, SKILL-05, SKILL-06, SKILL-07, WF-01, WF-02, PATH-02, STEP-01, STEP-06, STEP-07, SEQ-02, TPL-01.
Review its JSON output. For any rule that produced **zero findings** in the first pass, **skip it** during inference-based validation below — it has already been verified. If a rule produced any findings, the inference validator should still review that rule (some rules like SKILL-04 and SKILL-06 have sub-checks that benefit from judgment). Focus your inference effort on the remaining rules that require judgment (PATH-01, PATH-03, PATH-04, PATH-05, WF-03, STEP-02, STEP-03, STEP-04, STEP-05, SEQ-01, REF-01, REF-02, REF-03).
@ -271,6 +271,16 @@ If no findings are generated (from either pass), the skill passes validation.
---
### TPL-01 — Template Files Must Not Contain Compile-Time Substitutions
- **Severity:** HIGH
- **Applies to:** `.md` files whose name contains `template` (case-insensitive)
- **Rule:** Template files seed durable, version-controlled artifacts (e.g. spec files) that execute on other machines. A `{{.var}}` compile-time substitution would be baked at render time and freeze a machine-local value into every artifact produced from the template.
- **Detection:** Regex `\{\{\.\w+\}\}` match anywhere in a file whose basename matches `/template/i`.
- **Fix:** Remove the `{{.var}}` reference. Use single-curly `{var}` if the value should be resolved at LLM runtime by the consumer of the generated artifact.
---
### REF-01 — Variable References Must Be Defined
- **Severity:** HIGH

View File

@ -80,7 +80,7 @@ function escapeTableCell(str) {
}
// Path prefixes/patterns that only exist in installed structure, not in source
const INSTALL_ONLY_PATHS = ['_config/', 'custom/'];
const INSTALL_ONLY_PATHS = ['_config/', 'custom/', 'render/bmad-quick-dev/'];
// Files that are generated at install time and don't exist in the source tree
const INSTALL_GENERATED_FILES = ['config.yaml', 'config.user.yaml'];

View File

@ -19,6 +19,7 @@
* - STEP-06: step frontmatter has no name/description
* - STEP-07: step count 2-10
* - SEQ-02: no time estimates
* - TPL-01: template files must not contain compile-time {{.var}} substitutions
*
* Usage:
* node tools/validate-skills.js # All skills, human-readable
@ -45,6 +46,8 @@ const positionalArgs = args.filter((a) => !a.startsWith('--'));
const NAME_REGEX = /^bmad-[a-z0-9]+(-[a-z0-9]+)*$/;
const STEP_FILENAME_REGEX = /^step-\d{2}[a-z]?-[a-z0-9-]+\.md$/;
const TIME_ESTIMATE_PATTERNS = [/takes?\s+\d+\s*min/i, /~\s*\d+\s*min/i, /estimated\s+time/i, /\bETA\b/];
const TEMPLATE_FILENAME_REGEX = /template/i;
const COMPILE_TIME_SUB_REGEX = /\{\{\.\w+\}\}/;
const SEVERITY_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
@ -569,6 +572,36 @@ function validateSkill(skillDir) {
}
}
// --- TPL-01: template files must not contain compile-time {{.var}} substitutions ---
// Template files seed durable, version-controlled artifacts (spec files) that
// execute on other machines. Baking a {{.var}} at render time would freeze a
// machine-local value into every downstream artifact.
for (const filePath of allFiles) {
if (path.extname(filePath) !== '.md') continue;
const base = path.basename(filePath);
if (!TEMPLATE_FILENAME_REGEX.test(base)) continue;
const relFile = path.relative(skillDir, filePath);
const content = safeReadFile(filePath, findings, relFile);
if (content === null) continue;
const lines = content.split('\n');
for (const [i, line] of lines.entries()) {
const match = line.match(COMPILE_TIME_SUB_REGEX);
if (match) {
findings.push({
rule: 'TPL-01',
title: 'Template files must not contain compile-time substitutions',
severity: 'HIGH',
file: relFile,
line: i + 1,
detail: `Template file contains compile-time substitution \`${match[0]}\` — this would be baked at render time and leak a machine-local value into every spec produced from the template.`,
fix: 'Remove the `{{.var}}` reference. Use single-curly `{var}` if the value should be resolved at LLM runtime by the consumer of the generated spec.',
});
}
}
}
return findings;
}