Merge branch 'main' into docs/ko-kr-translation
This commit is contained in:
commit
b0f818ecf9
44
CHANGELOG.md
44
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
[](https://www.npmjs.com/package/bmad-method)
|
||||
[](LICENSE)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.python.org)
|
||||
[](https://docs.astral.sh/uv/)
|
||||
[](https://discord.gg/gk8jAdXWmj)
|
||||
|
|
@ -36,7 +36,7 @@ Traditional AI tools do the thinking for you, producing average results. BMad ag
|
|||
|
||||
## Quick Start
|
||||
|
||||
**Prerequisites**: [Node.js](https://nodejs.org) v20+ · [Python](https://www.python.org) 3.10+ · [uv](https://docs.astral.sh/uv/)
|
||||
**Prerequisites**: [Node.js](https://nodejs.org) v20.12+ · [Python](https://www.python.org) 3.10+ · [uv](https://docs.astral.sh/uv/)
|
||||
|
||||
```bash
|
||||
npx bmad-method install
|
||||
|
|
@ -82,11 +82,11 @@ BMad Method extends with official modules for specialized domains. Available dur
|
|||
[BMad Method Docs Site](https://docs.bmad-method.org) — Tutorials, guides, concepts, and reference
|
||||
|
||||
**Quick links:**
|
||||
|
||||
- [Getting Started Tutorial](https://docs.bmad-method.org/tutorials/getting-started/)
|
||||
- [Upgrading from Previous Versions](https://docs.bmad-method.org/how-to/upgrade-to-v6/)
|
||||
- [Test Architect Documentation](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/)
|
||||
|
||||
|
||||
## Community
|
||||
|
||||
- [Discord](https://discord.gg/gk8jAdXWmj) — Get help, share ideas, collaborate
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -16,7 +16,7 @@ Pokud chcete použít neinteraktivní instalátor a zadat všechny možnosti na
|
|||
- Aktualizujete stávající instalaci BMad
|
||||
|
||||
:::note[Předpoklady]
|
||||
- **Node.js** 20+ (vyžadováno pro instalátor)
|
||||
- **Node.js** 20.12+ (vyžadováno pro instalátor)
|
||||
- **Git** (doporučeno)
|
||||
- **AI nástroj** (Claude Code, Cursor nebo podobný)
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ Použijte příznaky příkazové řádky k neinteraktivní instalaci BMad. To j
|
|||
- Rychlé instalace se známými konfiguracemi
|
||||
|
||||
:::note[Předpoklady]
|
||||
Vyžaduje [Node.js](https://nodejs.org) v20+ a `npx` (součástí npm).
|
||||
Vyžaduje [Node.js](https://nodejs.org) v20.12+ a `npx` (součástí npm).
|
||||
:::
|
||||
|
||||
## Dostupné příznaky
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Použijte instalátor BMad pro upgrade z v4 na v6, který zahrnuje automatickou
|
|||
- Máte existující plánovací artefakty k zachování
|
||||
|
||||
:::note[Předpoklady]
|
||||
- Node.js 20+
|
||||
- Node.js 20.12+
|
||||
- Existující instalace BMad v4
|
||||
:::
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Vytvářejte software rychleji pomocí pracovních postupů řízených AI se sp
|
|||
- Efektivně používat agenty a pracovní postupy
|
||||
|
||||
:::note[Předpoklady]
|
||||
- **Node.js 20+** — Vyžadováno pro instalátor
|
||||
- **Node.js 20.12+** — Vyžadováno pro instalátor
|
||||
- **Git** — Doporučeno pro správu verzí
|
||||
- **AI-powered IDE** — Claude Code, Cursor nebo podobné
|
||||
- **Nápad na projekt** — I jednoduchý stačí pro učení
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Si vous souhaitez utiliser un installateur non interactif et fournir toutes les
|
|||
- Mettre à jour une installation BMad existante
|
||||
|
||||
:::note[Prérequis]
|
||||
- **Node.js** 20+ (requis pour l'installateur)
|
||||
- **Node.js** 20.12+ (requis pour l'installateur)
|
||||
- **Git** (recommandé)
|
||||
- **Outil d'IA** (Claude Code, Cursor, ou similaire)
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ Utilisez les options de ligne de commande pour installer BMad de manière non-in
|
|||
- Installations rapides avec des configurations connues
|
||||
|
||||
:::note[Prérequis]
|
||||
Nécessite [Node.js](https://nodejs.org) v20+ et `npx` (inclus avec npm).
|
||||
Nécessite [Node.js](https://nodejs.org) v20.12+ et `npx` (inclus avec npm).
|
||||
:::
|
||||
|
||||
## Options disponibles
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Utilisez l'installateur BMad pour passer de la v4 à la v6, qui inclut une déte
|
|||
- Vous avez des artefacts de planification existants à préserver
|
||||
|
||||
:::note[Prérequis]
|
||||
- Node.js 20+
|
||||
- Node.js 20.12+
|
||||
- Installation BMad v4 existante
|
||||
:::
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Construisez des logiciels plus rapidement en utilisant des workflows propulsés
|
|||
- Utiliser efficacement les agents et les workflows
|
||||
|
||||
:::note[Prérequis]
|
||||
- **Node.js 20+** — Requis pour l'installateur
|
||||
- **Node.js 20.12+** — Requis pour l'installateur
|
||||
- **Git** — Recommandé pour le contrôle de version
|
||||
- **IDE IA** — Claude Code, Cursor, ou similaire
|
||||
- **Une idée de projet** — Même simple, elle fonctionne pour apprendre
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Use `npx bmad-method install` to set up BMad in your project. One command handle
|
|||
|
||||
:::note[Prerequisites]
|
||||
|
||||
- **Node.js** 20+ (the installer requires it)
|
||||
- **Node.js** 20.12+ (the installer requires it)
|
||||
- **Git** (for cloning external modules)
|
||||
- **An AI tool** such as Claude Code or Cursor (run `npx bmad-method install --list-tools` to see all supported tools)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ Use the BMad installer to add modules from the community registry, third-party G
|
|||
- Installing modules from a private or self-hosted Git server
|
||||
|
||||
:::note[Prerequisites]
|
||||
Requires [Node.js](https://nodejs.org) v20+ and `npx` (included with npm). Custom and community modules can be selected during a fresh install or added to an existing installation.
|
||||
Requires [Node.js](https://nodejs.org) v20.12+ and `npx` (included with npm). Custom and community modules can be selected during a fresh install or added to an existing installation.
|
||||
:::
|
||||
|
||||
## Community Modules
|
||||
|
|
@ -68,7 +68,7 @@ Select **Yes**, then provide a source:
|
|||
| Input Type | Example |
|
||||
| --------------------- | ------------------------------------------------- |
|
||||
| HTTPS URL (any host) | `https://github.com/org/repo` |
|
||||
| HTTP URL (any host) | `http://host/org/repo` |
|
||||
| HTTP URL (any host) | `http://host/org/repo` |
|
||||
| HTTPS URL with subdir | `https://github.com/org/repo/tree/main/my-module` |
|
||||
| SSH URL | `git@github.com:org/repo.git` |
|
||||
| Local path | `/Users/me/projects/my-module` |
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ Use the BMad installer to upgrade from v4 to v6, which includes automatic detect
|
|||
|
||||
:::note[Prerequisites]
|
||||
|
||||
- Node.js 20+
|
||||
- Node.js 20.12+
|
||||
- Existing BMad v4 installation
|
||||
:::
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
title: "Getting Started"
|
||||
title: 'Getting Started'
|
||||
description: Install BMad and build your first project
|
||||
---
|
||||
|
||||
|
|
@ -14,11 +14,12 @@ Build software faster using AI-powered workflows with specialized agents that gu
|
|||
- Use agents and workflows effectively
|
||||
|
||||
:::note[Prerequisites]
|
||||
- **Node.js 20+** — Required for the installer
|
||||
|
||||
- **Node.js 20.12+** — Required for the installer
|
||||
- **Git** — Recommended for version control
|
||||
- **AI-powered IDE** — Claude Code, Cursor, or similar
|
||||
- **A project idea** — Even a simple one works for learning
|
||||
:::
|
||||
:::
|
||||
|
||||
:::tip[The Easiest Path]
|
||||
**Install** → `npx bmad-method install`
|
||||
|
|
@ -50,6 +51,7 @@ bmad-help I have an idea for a SaaS product, I already know all the features I w
|
|||
```
|
||||
|
||||
BMad-Help will respond with:
|
||||
|
||||
- What's recommended for your situation
|
||||
- What the first required task is
|
||||
- What the rest of the process looks like
|
||||
|
|
@ -66,12 +68,12 @@ After installing BMad, invoke the `bmad-help` skill immediately. It will detect
|
|||
|
||||
BMad helps you build software through guided workflows with specialized AI agents. The process follows four phases:
|
||||
|
||||
| Phase | Name | What Happens |
|
||||
| ----- | -------------- | --------------------------------------------------- |
|
||||
| 1 | Analysis | Brainstorming, research, product brief or PRFAQ *(optional)* |
|
||||
| 2 | Planning | Create requirements (PRD or spec) |
|
||||
| 3 | Solutioning | Design architecture *(BMad Method/Enterprise only)* |
|
||||
| 4 | Implementation | Build epic by epic, story by story |
|
||||
| Phase | Name | What Happens |
|
||||
| ----- | -------------- | ------------------------------------------------------------ |
|
||||
| 1 | Analysis | Brainstorming, research, product brief or PRFAQ _(optional)_ |
|
||||
| 2 | Planning | Create requirements (PRD or spec) |
|
||||
| 3 | Solutioning | Design architecture _(BMad Method/Enterprise only)_ |
|
||||
| 4 | Implementation | Build epic by epic, story by story |
|
||||
|
||||
**[Open the Workflow Map](../reference/workflow-map.md)** to explore phases, workflows, and context management.
|
||||
|
||||
|
|
@ -100,6 +102,7 @@ If you want the newest prerelease build instead of the default release channel,
|
|||
When prompted to select modules, choose **BMad Method**.
|
||||
|
||||
The installer creates two folders:
|
||||
|
||||
- `_bmad/` — agents, workflows, tasks, and configuration
|
||||
- `_bmad-output/` — empty for now, but this is where your artifacts will be saved
|
||||
|
||||
|
|
@ -134,6 +137,7 @@ Create it manually at `_bmad-output/project-context.md` or generate it after arc
|
|||
### Phase 1: Analysis (Optional)
|
||||
|
||||
All workflows in this phase are optional. [**Not sure which to use?**](../explanation/analysis-phase.md)
|
||||
|
||||
- **brainstorming** (`bmad-brainstorming`) — Guided ideation
|
||||
- **research** (`bmad-market-research` / `bmad-domain-research` / `bmad-technical-research`) — Market, domain, and technical research
|
||||
- **product-brief** (`bmad-product-brief`) — Recommended foundation document when your concept is clear
|
||||
|
|
@ -142,16 +146,19 @@ All workflows in this phase are optional. [**Not sure which to use?**](../explan
|
|||
### Phase 2: Planning (Required)
|
||||
|
||||
**For BMad Method and Enterprise tracks:**
|
||||
|
||||
1. Run `bmad-prd` in a new chat — state your intent (Create / Update / Validate) or let the skill ask
|
||||
2. Output: `prd.md`, `addendum.md`, `decision-log.md`
|
||||
|
||||
:::note[`bmad-prd` intents]
|
||||
|
||||
- **Create** — coached discovery from scratch; the skill names the workspace folder and guides you to a PRD you're proud of
|
||||
- **Update** — point it at an existing PRD and a change signal; it surfaces conflicts before applying changes
|
||||
- **Validate** — critique a finished PRD against a checklist and produce an HTML findings report
|
||||
:::
|
||||
:::
|
||||
|
||||
**For Quick Flow track:**
|
||||
|
||||
- Run `bmad-quick-dev` — it handles planning and implementation in a single workflow, skip to implementation
|
||||
|
||||
:::note[UX Design (Optional)]
|
||||
|
|
@ -161,6 +168,7 @@ If your project has a user interface, invoke the **UX-Designer agent** (`bmad-ag
|
|||
### Phase 3: Solutioning (BMad Method/Enterprise)
|
||||
|
||||
**Create Architecture**
|
||||
|
||||
1. Invoke the **Architect agent** (`bmad-agent-architect`) in a new chat
|
||||
2. Run `bmad-create-architecture` (`bmad-create-architecture`)
|
||||
3. Output: Architecture document with technical decisions
|
||||
|
|
@ -168,14 +176,15 @@ If your project has a user interface, invoke the **UX-Designer agent** (`bmad-ag
|
|||
**Create Epics and Stories**
|
||||
|
||||
:::tip[V6 Improvement]
|
||||
Epics and stories are now created *after* architecture. This produces better quality stories because architecture decisions (database, API patterns, tech stack) directly affect how work should be broken down.
|
||||
Epics and stories are now created _after_ architecture. This produces better quality stories because architecture decisions (database, API patterns, tech stack) directly affect how work should be broken down.
|
||||
:::
|
||||
|
||||
1. Invoke the **PM agent** (`bmad-agent-pm`) in a new chat
|
||||
2. Run `bmad-create-epics-and-stories` (`bmad-create-epics-and-stories`)
|
||||
3. The workflow uses both PRD and Architecture to create technically-informed stories
|
||||
|
||||
**Implementation Readiness Check** *(Highly Recommended)*
|
||||
**Implementation Readiness Check** _(Highly Recommended)_
|
||||
|
||||
1. Invoke the **Architect agent** (`bmad-agent-architect`) in a new chat
|
||||
2. Run `bmad-check-implementation-readiness` (`bmad-check-implementation-readiness`)
|
||||
3. Validates cohesion across all planning documents
|
||||
|
|
@ -192,11 +201,11 @@ Invoke the **Developer agent** (`bmad-agent-dev`) and run `bmad-sprint-planning`
|
|||
|
||||
For each story, repeat this cycle with fresh chats:
|
||||
|
||||
| Step | Agent | Workflow | Command | Purpose |
|
||||
| ---- | ----- | -------------- | -------------------------- | ---------------------------------- |
|
||||
| 1 | DEV | `bmad-create-story` | `bmad-create-story` | Create story file from epic |
|
||||
| 2 | DEV | `bmad-dev-story` | `bmad-dev-story` | Implement the story |
|
||||
| 3 | DEV | `bmad-code-review` | `bmad-code-review` | Quality validation *(recommended)* |
|
||||
| Step | Agent | Workflow | Command | Purpose |
|
||||
| ---- | ----- | ------------------- | ------------------- | ---------------------------------- |
|
||||
| 1 | DEV | `bmad-create-story` | `bmad-create-story` | Create story file from epic |
|
||||
| 2 | DEV | `bmad-dev-story` | `bmad-dev-story` | Implement the story |
|
||||
| 3 | DEV | `bmad-code-review` | `bmad-code-review` | Quality validation _(recommended)_ |
|
||||
|
||||
After completing all stories in an epic, invoke the **Developer agent** (`bmad-agent-dev`) and run `bmad-retrospective` (`bmad-retrospective`).
|
||||
|
||||
|
|
@ -227,18 +236,18 @@ your-project/
|
|||
|
||||
## Quick Reference
|
||||
|
||||
| Workflow | Command | Agent | Purpose |
|
||||
| ------------------------------------- | ------------------------------------------ | --------- | ----------------------------------------------- |
|
||||
| **`bmad-help`** ⭐ | `bmad-help` | Any | **Your intelligent guide — ask anything!** |
|
||||
| `bmad-prd` | `bmad-prd` | Any | Create, update, or validate a PRD |
|
||||
| `bmad-create-architecture` | `bmad-create-architecture` | Architect | Create architecture document |
|
||||
| `bmad-generate-project-context` | `bmad-generate-project-context` | Analyst | Create project context file |
|
||||
| `bmad-create-epics-and-stories` | `bmad-create-epics-and-stories` | PM | Break down PRD into epics |
|
||||
| `bmad-check-implementation-readiness` | `bmad-check-implementation-readiness` | Architect | Validate planning cohesion |
|
||||
| `bmad-sprint-planning` | `bmad-sprint-planning` | DEV | Initialize sprint tracking |
|
||||
| `bmad-create-story` | `bmad-create-story` | DEV | Create a story file |
|
||||
| `bmad-dev-story` | `bmad-dev-story` | DEV | Implement a story |
|
||||
| `bmad-code-review` | `bmad-code-review` | DEV | Review implemented code |
|
||||
| Workflow | Command | Agent | Purpose |
|
||||
| ------------------------------------- | ------------------------------------- | --------- | ------------------------------------------ |
|
||||
| **`bmad-help`** ⭐ | `bmad-help` | Any | **Your intelligent guide — ask anything!** |
|
||||
| `bmad-prd` | `bmad-prd` | Any | Create, update, or validate a PRD |
|
||||
| `bmad-create-architecture` | `bmad-create-architecture` | Architect | Create architecture document |
|
||||
| `bmad-generate-project-context` | `bmad-generate-project-context` | Analyst | Create project context file |
|
||||
| `bmad-create-epics-and-stories` | `bmad-create-epics-and-stories` | PM | Break down PRD into epics |
|
||||
| `bmad-check-implementation-readiness` | `bmad-check-implementation-readiness` | Architect | Validate planning cohesion |
|
||||
| `bmad-sprint-planning` | `bmad-sprint-planning` | DEV | Initialize sprint tracking |
|
||||
| `bmad-create-story` | `bmad-create-story` | DEV | Create a story file |
|
||||
| `bmad-dev-story` | `bmad-dev-story` | DEV | Implement a story |
|
||||
| `bmad-code-review` | `bmad-code-review` | DEV | Review implemented code |
|
||||
|
||||
## Common Questions
|
||||
|
||||
|
|
@ -258,6 +267,7 @@ Not strictly. Once you learn the flow, you can run workflows directly using the
|
|||
|
||||
:::tip[First Stop: BMad-Help]
|
||||
**Invoke `bmad-help` anytime** — it's the fastest way to get unstuck. Ask it anything:
|
||||
|
||||
- "What should I do after installing?"
|
||||
- "I'm stuck on workflow X"
|
||||
- "What are my options for Y?"
|
||||
|
|
@ -272,10 +282,11 @@ BMad-Help inspects your project, detects what you've completed, and tells you ex
|
|||
## Key Takeaways
|
||||
|
||||
:::tip[Remember These]
|
||||
|
||||
- **Start with `bmad-help`** — Your intelligent guide that knows your project and options
|
||||
- **Always use fresh chats** — Start a new chat for each workflow
|
||||
- **Track matters** — Quick Flow uses `bmad-quick-dev`; Method/Enterprise need PRD and architecture
|
||||
- **BMad-Help runs automatically** — Every workflow ends with guidance on what's next
|
||||
:::
|
||||
:::
|
||||
|
||||
Ready to start? Install BMad, invoke `bmad-help`, and let your intelligent guide lead the way.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Nếu bạn muốn dùng trình cài đặt không tương tác và cung cấp t
|
|||
- Cập nhật bản cài đặt BMad hiện tại
|
||||
|
||||
:::note[Điều kiện tiên quyết]
|
||||
- **Node.js** 20+ (bắt buộc cho trình cài đặt)
|
||||
- **Node.js** 20.12+ (bắt buộc cho trình cài đặt)
|
||||
- **Git** (khuyến nghị)
|
||||
- **Công cụ AI** (Claude Code, Cursor, hoặc tương tự)
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ Sử dụng trình cài đặt BMad để thêm module từ kho cộng đồng (
|
|||
- Cài module từ máy chủ Git riêng tư hoặc tự host
|
||||
|
||||
:::note[Điều kiện tiên quyết]
|
||||
Yêu cầu [Node.js](https://nodejs.org) v20+ và `npx` đi kèm npm. Bạn có thể chọn module tùy chỉnh và module cộng đồng trong lúc cài mới, hoặc thêm chúng vào một bản cài hiện có.
|
||||
Yêu cầu [Node.js](https://nodejs.org) v20.12+ và `npx` đi kèm npm. Bạn có thể chọn module tùy chỉnh và module cộng đồng trong lúc cài mới, hoặc thêm chúng vào một bản cài hiện có.
|
||||
:::
|
||||
|
||||
## Module cộng đồng
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ Sử dụng các cờ dòng lệnh để cài đặt BMad mà không cần tươ
|
|||
- Cài đặt nhanh với cấu hình đã biết trước
|
||||
|
||||
:::note[Điều kiện tiên quyết]
|
||||
Yêu cầu [Node.js](https://nodejs.org) v20+ và `npx` (đi kèm với npm).
|
||||
Yêu cầu [Node.js](https://nodejs.org) v20.12+ và `npx` (đi kèm với npm).
|
||||
:::
|
||||
|
||||
## Các cờ khả dụng
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Sử dụng trình cài đặt BMad để nâng cấp từ v4 lên v6, bao gồm
|
|||
- Bạn có các planning artifact hiện có cần giữ lại
|
||||
|
||||
:::note[Điều kiện tiên quyết]
|
||||
- Node.js 20+
|
||||
- Node.js 20.12+
|
||||
- Bản cài đặt BMad v4 hiện có
|
||||
:::
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Xây dựng phần mềm nhanh hơn bằng các workflow vận hành bởi AI, v
|
|||
- Sử dụng agent và workflow hiệu quả
|
||||
|
||||
:::note[Điều kiện tiên quyết]
|
||||
- **Node.js 20+** — Bắt buộc cho trình cài đặt
|
||||
- **Node.js 20.12+** — Bắt buộc cho trình cài đặt
|
||||
- **Git** — Khuyến nghị để quản lý phiên bản
|
||||
- **IDE có AI** — Claude Code, Cursor hoặc công cụ tương tự
|
||||
- **Một ý tưởng dự án** — Chỉ cần đơn giản cũng đủ để học
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ sidebar:
|
|||
- 更新现有的 BMad 安装
|
||||
|
||||
:::note[前置条件]
|
||||
- **Node.js** 20+(安装程序必需)
|
||||
- **Node.js** 20.12+(安装程序必需)
|
||||
- **Git**(推荐)
|
||||
- **AI 工具**(Claude Code、Cursor 或类似工具)
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ sidebar:
|
|||
- 从私有或自托管 Git 服务器安装模块
|
||||
|
||||
:::note[前置条件]
|
||||
需要 [Node.js](https://nodejs.org) v20+ 和 `npx`(npm 自带)。自定义和社区模块可以在全新安装时选择,也可以添加到现有安装中。
|
||||
需要 [Node.js](https://nodejs.org) v20.12+ 和 `npx`(npm 自带)。自定义和社区模块可以在全新安装时选择,也可以添加到现有安装中。
|
||||
:::
|
||||
|
||||
## 社区模块
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ sidebar:
|
|||
- 使用已知配置的快速安装
|
||||
|
||||
:::note[前置条件]
|
||||
需要 [Node.js](https://nodejs.org) v20+ 和 `npx`(随 npm 附带)。
|
||||
需要 [Node.js](https://nodejs.org) v20.12+ 和 `npx`(随 npm 附带)。
|
||||
:::
|
||||
|
||||
## 可用参数(Flags)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ sidebar:
|
|||
- 你有要保留的规划产物或进行中的开发工作
|
||||
|
||||
:::note[前置条件]
|
||||
- Node.js 20+
|
||||
- Node.js 20.12+
|
||||
- 现有 BMad v4 安装
|
||||
:::
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ description: 安装 BMad 并构建你的第一个项目
|
|||
- 有效使用智能体和工作流
|
||||
|
||||
:::note[前置条件]
|
||||
- **Node.js 20+** — 安装程序必需
|
||||
- **Node.js 20.12+** — 安装程序必需
|
||||
- **Git** — 推荐用于版本控制
|
||||
- **AI 驱动的 IDE** — Claude Code、Cursor 或类似工具
|
||||
- **一个项目想法** — 即使是简单的想法也可以用于学习
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
{
|
||||
"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.0.0",
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@clack/core": "^1.3.1",
|
||||
"@clack/prompts": "^1.4.0",
|
||||
"@kayvan/markdown-tree-parser": "^1.6.1",
|
||||
"chalk": "^4.1.2",
|
||||
"commander": "^14.0.0",
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
"yaml-lint": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=20.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/compiler": {
|
||||
|
|
@ -752,24 +752,31 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@clack/core": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0.tgz",
|
||||
"integrity": "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz",
|
||||
"integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picocolors": "^1.0.0",
|
||||
"fast-wrap-ansi": "^0.2.0",
|
||||
"sisteransi": "^1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/prompts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.0.tgz",
|
||||
"integrity": "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz",
|
||||
"integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/core": "1.0.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"@clack/core": "1.3.1",
|
||||
"fast-string-width": "^3.0.2",
|
||||
"fast-wrap-ansi": "^0.2.0",
|
||||
"sisteransi": "^1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ctrl/tinycolor": {
|
||||
|
|
@ -7332,6 +7339,30 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-string-truncated-width": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz",
|
||||
"integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-string-width": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz",
|
||||
"integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-string-truncated-width": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-wrap-ansi": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz",
|
||||
"integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-string-width": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -66,8 +66,8 @@
|
|||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/core": "^1.0.0",
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@clack/core": "^1.3.1",
|
||||
"@clack/prompts": "^1.4.0",
|
||||
"@kayvan/markdown-tree-parser": "^1.6.1",
|
||||
"chalk": "^4.1.2",
|
||||
"commander": "^14.0.0",
|
||||
|
|
@ -103,7 +103,7 @@
|
|||
"yaml-lint": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=20.12.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
|
|
|||
|
|
@ -15,21 +15,21 @@ At the opening greeting, let the user know they can invoke `bmad-party-mode` for
|
|||
|
||||
## On Activation
|
||||
|
||||
1. Resolve customization: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow`. On failure, surface the diagnostic and halt.
|
||||
1. Resolve customization: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly and use defaults.
|
||||
2. Execute each entry in `{workflow.activation_steps_prepend}` in order.
|
||||
3. Treat every entry in `{workflow.persistent_facts}` as foundational context for the rest of the run. Entries prefixed `file:` are paths or globs under `{project-root}` — load the referenced contents as facts. All other entries are facts verbatim.
|
||||
4. Note `{workflow.external_sources}` as a registry of external systems available for consultation when the conversation surfaces a relevant need — knowledge bases, internal MCP tools, reference systems. Do not query preemptively; consult each only when its directive matches the moment. If a named tool is unavailable at runtime, fall back to standard behavior and note the gap when relevant.
|
||||
4. `{workflow.external_sources}` is an org-configured registry of internal tools (knowledge bases, MCP tools); consult them alongside generic web research on the same triggers in `## Discovery`, org tools preferred when their directive matches. If a named tool is unavailable at runtime, fall back to standard behavior and note the gap when relevant.
|
||||
5. Load `{project-root}/_bmad/bmm/config.yaml` (and `config.user.yaml` if present). Resolve `{user_name}`, `{communication_language}`, `{document_output_language}`, `{planning_artifacts}`, `{project_name}`, `{date}`.
|
||||
6. Greet `{user_name}` in `{communication_language}`. Detect intent (create / update / validate). If interactive and intent is unclear, ask; for headless behavior see `## Headless Mode`.
|
||||
6. Greet `{user_name}` in `{communication_language}` — and stay in `{communication_language}` for every turn for the entire run, not just the greeting. Detect intent (create / update / validate). If interactive and intent is unclear, ask; for headless behavior see `## Headless Mode`.
|
||||
7. Execute each entry in `{workflow.activation_steps_append}` in order.
|
||||
|
||||
## Intent Operating Modes
|
||||
|
||||
**Create.** A brief the user is proud of, that meets their needs, drawn out through real conversation — do not assume: instead converse and understand, and then help craft the best product brief for their needs. Begin in `## Discovery` before drafting; the brief comes after the picture is on the table. Shape follows the product and need. Treat `{workflow.brief_template}` as a starting structure, not a contract: drop sections that do not earn their place, add sections the product needs, reorder freely - create sections for specialized domains or concerns also as needed. The brief serves the product's story, not the template's shape. Bind `{doc_workspace}` to a fresh folder at `{workflow.output_dir}/{workflow.output_folder_name}/` and write `brief.md` there with YAML frontmatter (title, status, created, updated). For Update and Validate, `{doc_workspace}` is the existing folder of the brief being targeted.
|
||||
**Create.** A brief the user is proud of, that meets their needs, drawn out through real conversation — do not assume: instead converse and understand, and then help craft the best product brief for their needs. Begin in `## Discovery` before drafting; the brief comes after the picture is on the table. Shape follows the product and need. Treat `{workflow.brief_template}` as a starting structure, not a contract: drop sections that do not earn their place, add sections the product needs, reorder freely - create sections for specialized domains or concerns also as needed. The brief serves the product's story, not the template's shape. Bind `{doc_workspace}` to a fresh folder at `{workflow.brief_output_path}/{workflow.run_folder_pattern}/` and write `brief.md` there with YAML frontmatter (title, status, created, updated). For Update and Validate, `{doc_workspace}` is the existing folder of the brief being targeted.
|
||||
|
||||
**Update.** Reconcile an existing brief with a change signal. Before proposing changes, read the brief, addendum, `decision-log.md`, and original inputs — and run the `## Discovery` posture against the change signal (a patch applied without context becomes drift). Surface conflicts with prior decisions before changing. Headless override: log the reversal to `decision-log.md`, then apply; halt `blocked` if intent is ambiguous. If the change is fundamental, offer Create instead of patching.
|
||||
**Update.** Reconcile an existing brief with a change signal. Before proposing changes, read the brief, addendum, `.decision-log.md`, and original inputs — and run the `## Discovery` posture against the change signal (a patch applied without context becomes drift). Surface conflicts with prior decisions before changing. Headless override: log the reversal to `.decision-log.md`, then apply; halt `blocked` if intent is ambiguous. If the change is fundamental, offer Create instead of patching.
|
||||
|
||||
**Validate.** Honest critique against the brief's own purpose. Read the brief, the addendum if present, `decision-log.md`, and any original inputs first — a validation that ignores prior decisions, rejected ideas, or context the user supplied is shallow. Cite specific lines. Caveat what cannot be evaluated. Return inline — no separate file unless asked. Always offer to roll findings into an Update, even in headless mode — include `"offer_to_update": true` in the JSON status block.
|
||||
**Validate.** Honest critique against the brief's own purpose. Read the brief, the addendum if present, `.decision-log.md`, and any original inputs first — a validation that ignores prior decisions, rejected ideas, or context the user supplied is shallow. Cite specific lines. Caveat what cannot be evaluated. Return inline — no separate file unless asked. Always offer to roll findings into an Update, even in headless mode — include `"offer_to_update": true` in the JSON status block.
|
||||
|
||||
## Headless Mode
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ When invoked headless, do not ask. Complete the intent using what is provided, w
|
|||
"intent": "create",
|
||||
"brief": "{doc_workspace}/brief.md",
|
||||
"addendum": "{doc_workspace}/addendum.md",
|
||||
"decision_log": "{doc_workspace}/decision-log.md",
|
||||
"decision_log": "{doc_workspace}/.decision-log.md",
|
||||
"open_questions": [],
|
||||
"external_handoffs": [
|
||||
{"directive": "Confluence upload", "tool": "corp:confluence_upload", "url": "https://confluence.corp/PROD/123", "status": "ok"}
|
||||
|
|
@ -61,20 +61,27 @@ Omit keys for artifacts that were not produced.
|
|||
|
||||
## Discovery
|
||||
|
||||
Conversationally surface what the user brings, why this brief exists, and the domain — echo back how each shapes your approach. Open with space for the full picture: invite a brain dump and ask up front for any source material they already have (memo, deck, transcript, prior brief, slack thread). Read what exists first; ask only what is missing. After the dump, a simple "anything else?" often surfaces what they almost forgot. Drill into specifics only after the broad shape is on the table; premature granular questions interrupt the dump and miss the room. Get a read on stakes early (passion project, internal pitch, investor input, public launch), and let that calibrate how hard you push. Suggest research (web, competitive, market) only when the stakes warrant it.
|
||||
Conversationally surface what the user brings, why this brief exists, and the domain — echo back how each shapes your approach. Open with space for the full picture: invite a brain dump and ask up front for any source material they already have (memo, deck, transcript, prior brief, slack thread). Read what exists first; ask only what is missing. After the dump, a simple "anything else?" often surfaces what they almost forgot. Drill into specifics only after the broad shape is on the table; premature granular questions interrupt the dump and miss the room. Get a read on stakes early (passion project, internal pitch, investor input, public launch), and let that calibrate how hard you push. During the dump, spawn web-research subagents to ground the picture — landscape, comparables, current state — AI especially, where training data ages by the week. Subagent searches; parent gets a digest. Deep work (full market sizing, exhaustive teardowns) → suggest `bmad-market-research` or `bmad-domain-research`.
|
||||
|
||||
Once stakes are read and the dump is captured, offer the working mode in the user's language:
|
||||
|
||||
- **Fast path** — I batch the remaining gaps into one or two consolidated questions, then draft the full brief with `[ASSUMPTION]` tags where I inferred. You review and we iterate. Best for "I'm pitching tomorrow."
|
||||
- **Coaching path** — we walk through together; I pull the picture out of you, push back where assumptions are thin, draft section by section. Best for "I want a brief I'm proud of and time isn't the constraint."
|
||||
|
||||
The workspace persists; stop and resume freely. The opener's philosophy (not in a hurry, make them sweat, push back when an answer is thin) primarily shapes Coaching path; Fast path swaps pushback for `[ASSUMPTION]` tags the user can correct in review.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Right-size to purpose.** A passion project does not need investor-grade rigor. A VC pitch input does. Read the room.
|
||||
- **Persistence is real-time.** Once Create intent is confirmed, the workspace (run folder, `brief.md` skeleton with `status: draft`, `decision-log.md`) exists on disk and the user knows the path.
|
||||
- **File roles.** `decision-log.md` is canonical memory and audit trail — every decision, change, and override (including headless overrides) is recorded there as the conversation unfolds. `addendum.md` preserves user-contributed depth that belongs in a downstream document (PRD, architecture, solution design) or earned a place but does not fit the brief (rejected-alternative rationale, options-considered matrices, parked-roadmap context, technical constraints, in-depth personas, sizing data). Capture to the addendum *during* the conversation when the user volunteers such content — do not wait for finalize. Audit and override information never goes in the addendum.
|
||||
- **Persistence is real-time.** Once Create intent is confirmed, the workspace (run folder, `brief.md` skeleton with `status: draft`, `.decision-log.md`) exists on disk and the user knows the path.
|
||||
- **File roles.** `.decision-log.md` is canonical memory and audit trail — every decision, change, and override (including headless overrides) is recorded there as the conversation unfolds. `addendum.md` preserves user-contributed depth that belongs in a downstream document (PRD, architecture, solution design) or earned a place but does not fit the brief (rejected-alternative rationale, options-considered matrices, parked-roadmap context, technical constraints, in-depth personas, sizing data). Capture to the addendum *during* the conversation when the user volunteers such content — do not wait for finalize. Audit and override information never goes in the addendum.
|
||||
- **Continuity across sessions.** If a prior in-progress draft for this project exists, the user is offered to resume.
|
||||
- **Extract, don't ingest.** Source artifacts (provided by the user or discovered during the run — transcripts, brainstorms, research reports, code, web results, prior briefs) enter the parent conversation as relevance-filtered extracts, not loaded wholesale. Subagents do the extraction against the user's stated focus; the parent context stays lean.
|
||||
- **Length and coherence.** Aim for 1-2 pages — if it is longer, the detail belongs in the addendum. Structure in service of the product; downstream consumers (PRD workflow, etc.) read this, so coherent shape matters.
|
||||
|
||||
## Finalize
|
||||
|
||||
1. Decision log audit + addendum review: the user ends this step with an explicit, shared accounting of how the meaningful contents of `decision-log.md` were handled — captured in the brief, captured in `addendum.md` (which may already hold detail captured during the conversation — see `## Constraints` for what belongs there), or set aside as process noise.
|
||||
1. Decision log audit + addendum review: the user ends this step with an explicit, shared accounting of how the meaningful contents of `.decision-log.md` were handled — captured in the brief, captured in `addendum.md` (which may already hold detail captured during the conversation — see `## Constraints` for what belongs there), or set aside as process noise.
|
||||
2. Polish: apply each entry in `{workflow.doc_standards}` (a `skill:`, `file:`, or plain-text directive) to `brief.md` (and `addendum.md` if it exists). Run passes as parallel subagents - apply all doc standards to `brief.md` first, then `addendum.md` so we present a high-quality draft for the user to review and finalize.
|
||||
3. External handoffs: execute each entry in `{workflow.external_handoffs}` to route artifacts beyond local files (Confluence, Notion, ticket systems, etc.) — each directive names the MCP tool and the fields it needs. Invoke the tool, capture any URLs or IDs returned, and surface them in the user message. If a named tool is unavailable, skip that handoff and flag it; local files always exist regardless.
|
||||
4. Tell the user it is ready: local paths and external destinations (URLs returned from handoffs). Invoke `bmad-help` to suggest what next steps make sense in the bmad method ecosystem.
|
||||
|
|
|
|||
|
|
@ -23,11 +23,15 @@ activation_steps_append = []
|
|||
# (standards, compliance constraints, stylistic guardrails).
|
||||
# Each entry is either a literal sentence, a skill prefixed with `skill:`, or a `file:`-prefixed path/glob
|
||||
# whose contents are loaded as facts.
|
||||
# Default is empty. Common opt-ins (set in your team/user override TOML):
|
||||
# "file:{project-root}/_bmad-output/planning-artifacts/project-context.md" # bmad-generate-project-context output
|
||||
# "skill:acme-co:terms-and-conditions" # a skill that contains some relevant info to the documents that may be generated
|
||||
# "Elvis has left the building" # generic agent instructions
|
||||
persistent_facts = []
|
||||
#
|
||||
# Default loads project-context.md if bmad-generate-project-context has produced one — this gives
|
||||
# the facilitator persistent awareness of the project's tech, domain, and constraints without
|
||||
# re-asking. Common opt-ins (set in team/user override TOML):
|
||||
# "skill:acme-co:terms-and-conditions" # a skill that contains some relevant info
|
||||
# "Elvis has left the building" # generic agent instruction
|
||||
persistent_facts = [
|
||||
"file:{project-root}/**/project-context.md",
|
||||
]
|
||||
|
||||
# Executed when the workflow completes (after the user has been told the
|
||||
# brief is ready). Accepts either a string scalar (single instruction)
|
||||
|
|
@ -39,9 +43,10 @@ on_complete = ""
|
|||
# to enforce a different structure (e.g. regulated-industry, investor-deck).
|
||||
brief_template = "assets/brief-template.md"
|
||||
|
||||
# Run folder location. The brief and optional addendum land inside `{output_dir}/{output_folder_name}/`.
|
||||
output_dir = "{planning_artifacts}/briefs"
|
||||
output_folder_name = "brief-{project_name}-{date}"
|
||||
# Run folder location. The brief and optional addendum land inside `{brief_output_path}/{run_folder_pattern}/`.
|
||||
# Resume-check scans `{brief_output_path}` for prior unfinished runs.
|
||||
brief_output_path = "{planning_artifacts}/briefs"
|
||||
run_folder_pattern = "brief-{project_name}-{date}"
|
||||
|
||||
# Document standards applied to human-consumed docs at finalize. Each entry is
|
||||
# a `skill:`, `file:`, or plain-text directive; the parent LLM applies the
|
||||
|
|
|
|||
|
|
@ -1,90 +1,87 @@
|
|||
---
|
||||
name: bmad-prd
|
||||
description: Create, update, validate, or analyze a PRD. Use when the user wants help producing, editing, validating, or analyzing a PRD.
|
||||
description: Create, update, or validate a PRD. Use when the user wants help producing, editing, or validating a PRD.
|
||||
---
|
||||
# BMad PRD
|
||||
|
||||
## Overview
|
||||
You are a master facilitator and coach helping the user create, edit, or validate a high quality PRD scoped to the level and rigor appropriate to their stated needs. Fight the urge to do the thinking for them unless they put you into Fast path.
|
||||
|
||||
You are an expert PM facilitator. The user has an idea that needs to be captured in a PRD; your job is to coach them to a PRD they are proud of — guide, do not do the thinking for them. Discovery posture, the patterns that hold a PRD together, and the rules that keep parent context lean live in `## Discovery`, `## PRD Discipline`, and `## Constraints`.
|
||||
## Conventions
|
||||
|
||||
At the opening greeting, let the user know they can invoke the skills `bmad-party-mode` for multi-agent perspectives or `bmad-advanced-elicitation` for deeper exploration at any point.
|
||||
- Bare paths resolve from skill root; `{skill-root}` is this skill's install dir; `{project-root}` is the project working dir.
|
||||
- `{workflow.<name>}` resolves to fields in `customize.toml`'s `[workflow]` table (overrides win per BMad merge rules).
|
||||
- `{doc_workspace}` is the bound run folder.
|
||||
- **File roles.** `.decision-log.md` is canonical memory and audit trail — every decision, change, and override (including headless overrides) is recorded there as the conversation unfolds. `addendum.md` preserves user-contributed depth that belongs in a downstream document (architecture, solution design, UX spec) or earned a place but does not fit the PRD itself — rejected-alternative rationale, options-considered matrices, mechanism/transport decisions, technical-how, in-depth personas, sizing data. Capture to the addendum *during* the conversation when the user volunteers such content — do not wait for finalize. Audit and override information never goes in the addendum.
|
||||
|
||||
## On Activation
|
||||
|
||||
1. Resolve customization: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow`. On failure, surface the diagnostic and halt.
|
||||
2. Execute each entry in `{workflow.activation_steps_prepend}` in order.
|
||||
3. Treat every entry in `{workflow.persistent_facts}` as foundational context. Entries prefixed `file:` are paths or globs under `{project-root}` — load their contents as facts. All others are facts verbatim.
|
||||
4. Note `{workflow.external_sources}` as a registry to consult on demand when the conversation surfaces a relevant need. Do not query preemptively. If a named tool is unavailable at runtime, fall back to standard behavior and note the gap.
|
||||
5. Load `{project-root}/_bmad/bmm/config.yaml` (and `config.user.yaml` if present). Resolve `{user_name}`, `{communication_language}`, `{document_output_language}`, `{planning_artifacts}`, `{project_name}`, `{date}`.
|
||||
6. Detect mode and intent. If headless (no interactive user), read `references/headless.md` and follow it for the whole run with matched intent. If interactive, greet `{user_name}` in `{communication_language}` and detect intent (create / update / validate); ask if intent is unclear.
|
||||
7. Execute each entry in `{workflow.activation_steps_append}` in order.
|
||||
1. Resolve customization: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly and use defaults.
|
||||
2. Run `{workflow.activation_steps_prepend}`. Treat `{workflow.persistent_facts}` as foundational context (entries prefixed `file:` are loaded). `{workflow.external_sources}` is an org-configured registry of internal tools (knowledge bases, MCP tools); consult them alongside generic web research on the same triggers, org tools preferred when their directive matches. Research itself fires during Discovery — see **Research subagents**.
|
||||
3. Load `{project-root}/_bmad/bmm/config.yaml` (+ `config.user.yaml` if present). Resolve `{user_name}`, `{communication_language}`, `{document_output_language}`, `{planning_artifacts}`, `{project_name}`, `{date}`. Missing keys → neutral defaults; never block.
|
||||
4. If headless, follow `references/headless.md` for the whole run. Otherwise greet the user **by name** using `{user_name}` and **in their language** using `{communication_language}` — and stay in `{communication_language}` for every turn for the entire run, not just the greeting. In the greeting, let the user know that at any point they can invoke `bmad-party-mode` for multi-agent perspectives or `bmad-advanced-elicitation` for deeper exploration on a specific section. Then scan for misroute on the first message: if the signal points elsewhere (game → BMad GDS; express build → `bmad-quick-dev`; one-pager → `bmad-product-brief`; vet product idea → `bmad-prfaq`; agent skill or custom agent → `bmad-workflow-builder`), suggest they might want the other options before continuing.
|
||||
5. Detect intent: **Create** (no PRD), **Update** (existing PRD), **Validate** (critique only). If ambiguous, ask. For Create intent, before binding a fresh workspace, scan `{workflow.prd_output_path}` for prior in-progress runs (folders matching `{workflow.run_folder_pattern}` whose `prd.md` frontmatter `status` is not `final`); if any exist, offer to resume rather than starting over.
|
||||
6. Run `{workflow.activation_steps_append}`.
|
||||
|
||||
## Intent Operating Modes
|
||||
## Intent Modes
|
||||
|
||||
**Create.** A PRD the user is proud of, drawn out through real conversation. Discovery first, drafting second. Bind `{doc_workspace}` to a fresh folder at `{workflow.output_dir}/{workflow.output_folder_name}/` and write `prd.md` there with YAML frontmatter (title, created, updated). Version and state transitions live in `decision-log.md`. For Update and Validate, `{doc_workspace}` is the existing folder of the PRD being targeted. When drafting is complete, proceed to `## Finalize`.
|
||||
**Create.** Bind `{doc_workspace}` to `{workflow.prd_output_path}/{workflow.run_folder_pattern}/`. Write `prd.md` with YAML frontmatter (title, status, created, updated — initial `status: draft`), and create the `.decision-log.md` skeleton at the workspace root so subsequent decisions land in a known file. Tell the user the path. Run `## Discovery`, then `## Finalize`.
|
||||
|
||||
**Update.** Reconcile an existing PRD with a change signal. Orient via source extractors (see `## Constraints` → Extract, don't ingest) against the PRD, addendum, `decision-log.md`, and original inputs — then run the `## Discovery` posture against the change signal. Surface conflicts with prior decisions before changing. If the change is fundamental, offer Create instead of patching. When changes are applied, proceed to `## Finalize`.
|
||||
**Update.** Reconcile the PRD with a change signal. Source-extract against PRD, addendum, `.decision-log.md`, and original inputs (extract, don't ingest). If `.decision-log.md` is missing, spawn a one-time bootstrap subagent to reverse-engineer a thin log from the PRD before continuing. Surface conflicts with prior decisions before applying. Then `## Finalize`.
|
||||
|
||||
**Validate** (or *analyze*). Critique an existing PRD against `{workflow.validation_checklist}`. Standalone — does NOT enter `## Finalize`. Orient via source extractors against `decision-log.md` and any original inputs to give the validator context. Spawn the validator subagent against `prd.md` (and `addendum.md` if present); produce findings and a validation report per `references/validation-render.md`. Always offer to roll findings into an Update.
|
||||
**Validate** (or *analyze*). Critique without changing. Load `references/validate.md`.
|
||||
|
||||
## Discovery
|
||||
|
||||
Open with space for the full picture: invite a brain dump, inputs, ideas, WHY they are doing this. Read what exists first; ask only what is missing. After the dump, a simple "anything else?" often surfaces what they almost forgot.
|
||||
Order: **Brain dump → Stakes calibration → Working mode → mode-scoped work.** Get to working mode fast — two or three turns, not ten. Users in a hurry must not be held hostage by upstream probing.
|
||||
|
||||
Before drafting, read the situation across four dimensions — they determine the PRD's shape:
|
||||
**Brain dump.** Always the first move, even when the user opens with paragraphs of context (that is intake, not the dump). Ask for verbal context *and* any existing inputs they want you to read — product brief, research, customer transcripts, competitive analysis, prior PRD draft, design docs. Paths or paste; big docs are fine, you will subagent-extract. A simple "anything else?" surfaces what they almost forgot.
|
||||
|
||||
- **Stakes.** Calibrates rigor, section depth, and which adapt-in clusters apply.
|
||||
- **Audience.** Drives tone, evidence requirements, and approval sections.
|
||||
- **Existing inputs.** Existing artifacts mean those parts of the PRD reference, not relitigate. When project-context, prior PRDs, or existing UX/architecture are present, this is brownfield — frame Discovery around what is new or changing.
|
||||
- **Downstream depth.** Whole spec for a small build, or top of a chain through UX → architecture → epics → stories? Affects how much the PRD encodes vs. defers.
|
||||
**Research subagents (default).** During Discovery, spawn web-research subagents to ground the picture: what exists in the space, how comparables position themselves, current landscape. Subagent does the search; parent receives a digest.
|
||||
|
||||
**Right-skill check.** Once the situation is read, sanity-check that PRD is the best tool. Three cases where it isn't:
|
||||
**Elicitation, not direction.** Discovery pulls the user's vision out; it does not insert yours. Open-ended "tell me about X" beats multiple choice. When you find yourself naming wedges, picking MVP cuts, or proposing phases, stop — you have crossed from elicitation into authoring. Hand the pen back. Infer-and-confirm ("I'm assuming X works like Y — right?") is fine; quizzing the user through a tree of LLM-shaped choices is not.
|
||||
|
||||
- **Games** → suggest `bmad-gds` for the Game Design Document.
|
||||
- **Small scope + wants a captured artifact** (small tweak to an existing codebase, single doc to point at) → stay here and produce an *all-inclusive document*: lean spine plus inline Stories via the adapt-in Stories cluster.
|
||||
- **Express implementation** (wants to build now, no planning chain or captured artifact needed) → suggest `bmad-quick-dev`.
|
||||
**Stakes calibration.** One short probe before working mode: hobby / internal / launch — enough to calibrate rigor and section depth. Audience, Existing inputs, and Downstream depth fill in inside the chosen mode, not upstream of the choice.
|
||||
|
||||
Surface these honestly and let the user choose; if they prefer this skill anyway, proceed with the right-sized version.
|
||||
**Working mode.** Offer the choice in the user's language:
|
||||
|
||||
Coach, do not quiz. Push hardest on PRD Discipline risks — unexamined assumptions, capability-vs-implementation confusion, term drift, scope creep, ambiguity for downstream readers. Suggest research if needed and have subagents use web search tools as needed.
|
||||
- **Fast path** — I batch remaining gaps into one or two consolidated questions, then draft the full PRD with `[ASSUMPTION]` tags where I inferred. You review and we iterate. The initial quality depends on how much you gave me upfront.
|
||||
- **Coaching path** — we walk PM-thinking sections together. Once chosen, I ask which entry point fits: **Vision + Features** (capability-first — for enterprise, dev products, internal tools, anyone who thinks in features), **Personas + Journeys** (user-first — for consumer, UX-heavy, multi-stakeholder products), or *let me suggest* based on what I heard. The chosen entry sets the section order.
|
||||
|
||||
**Working mode.** Once the situational read is complete, offer the user a choice before proceeding — one sentence per option:
|
||||
The workspace persists; stop and resume freely.
|
||||
|
||||
- **Express:** resolve any remaining critical gaps in a short batch, then draft the full PRD at once.
|
||||
- **Facilitative:** work through the sections that require PM thinking before drafting, using the techniques in `references/facilitation-guide.md`. Capture all decisions in the log, section to section. Draft after the key sections are walked. The goal is that the user has authored the thinking — not just answered intake questions.
|
||||
**Concern scan.** As you read what the user gave you, name the concerns this product actually carries — compliance, integration density, operational SLAs, hardware constraints, public-API contracts, monetization, data governance, whatever applies. The list is open; recognize what's there, do not classify into a fixed shape. These concerns drive which template sections to pull in from the Adapt-In Menu and which to invent when no cluster names them.
|
||||
|
||||
In both modes, resolve decisions conversationally rather than silently deferring them into `[ASSUMPTION]` tags. Only use `[ASSUMPTION]` when the answer requires research or external input the PM cannot provide in the moment.
|
||||
**User Journeys are captured, not authored.** When UJs are warranted (consumer / multi-stakeholder B2B / meaningful UX — drop or downscale for internal tooling with a single operator role, regulatory-only updates, hobby/solo, pure technical PRDs), prompt the user to narrate a real session — what the person does, in what order, where it lands — then structure the answer into UJ-N form and confirm.
|
||||
|
||||
## PRD Discipline
|
||||
|
||||
- **Features grouped, FRs nested.** Features open with behavioral description; FRs nested and numbered globally for stable IDs. Cross-cutting NFRs in their own section; skip traceability matrices.
|
||||
- **Capabilities, not implementation.** FRs describe what users or systems can do, not how. Tech choices go in addendum.
|
||||
- **No innovation theater.** Don't fabricate novelty; add a differentiation section only when Discovery surfaced something genuinely novel.
|
||||
- **Personas, when used, are research-grounded or marked `[ILLUSTRATIVE]`.** Invented detail is *persona theater* — false specificity the team builds for. Personas must drive decisions; two to four max.
|
||||
- **Domain awareness.** Regulatory or compliance constraints surface in the PRD, not deferred to architecture.
|
||||
- **Right-size to purpose.** Section depth and adapt-in clusters follow project type and stakes — the template's adapt-in menu names the standard clusters.
|
||||
- **Non-Goals explicit.** Pair with inline `[NON-GOAL for MVP]` and `[v2 — out of MVP]` callouts so omissions aren't silently assumed.
|
||||
- **Never silently de-scope.** Nothing the user explicitly included drops without asking. Propose phasing; never impose it.
|
||||
- **Counter-metrics named.** When Success Metrics is present, name what NOT to optimize.
|
||||
- **Assumptions visible.** Inferences without direct user confirmation are tagged `[ASSUMPTION: ...]` inline and indexed at the end.
|
||||
- **`[NOTE FOR PM]` callouts** at decision points the user deferred or left tension on.
|
||||
**Shape.** Features grouped; FRs nested with globally numbered stable IDs. Cross-cutting NFRs in their own section; skip traceability matrices. Capabilities, not implementation — tech choices live in `addendum.md`. Treat `{workflow.prd_template}` as expert prior knowledge, not a checklist. The **Essential Spine** is the expected default — present it unless the product genuinely doesn't need a section, and when you drop one, do so for a reason a reviewer would agree with. The **Adapt-In Menu** is conditional: pull in the clusters the product's concerns need to best define the requirements. When the product carries a concern the menu doesn't name, invent the section — name it well, decide what belongs in it, place it where it serves the reader or the PRD. Reorder and combine for readability. Never include a section because it appears; never skip a concern because no template section covered it. Counter-metrics named when Success Metrics exist.
|
||||
|
||||
## Constraints
|
||||
**Extract, don't ingest.** Source documents go to subagents for extraction; the parent assembles from extracts. Only load source documents into the parent context wholesale when no subagents are available.
|
||||
|
||||
- **Persistence is near real-time.** Create the workspace (`prd.md` skeleton, `decision-log.md`) on disk the moment Create intent is confirmed; tell the user the path.
|
||||
- **File roles.** `decision-log.md` — every decision, change, and version transition, in real time. `addendum.md` — depth that doesn't fit PRD shape: rejected alternatives, technical detail, ops/cost, competitive analysis. Capture technical-how detail to addendum immediately when the user volunteers it.
|
||||
- **Continuity across sessions.** If a prior draft exists in `{workflow.output_dir}`, offer to resume; surface open items first.
|
||||
- **Extract, don't ingest.** Never load source documents into the parent context wholesale. Delegate to subagents to extract what's relevant; the parent assembles from extracts.
|
||||
- **Downstream workflows run in fresh context.** This skill's output is `prd.md` (and optional `addendum.md`). Never invoke downstream workflows or produce separate handoff artifacts.
|
||||
**Length scales with stakes.** Hobby / solo PRDs aim for about two pages. Internal tools land around five to eight. Launch and chain-top PRDs run as long as their FRs and concerns require. Whatever the length, detail that doesn't earn its place in the PRD's main narrative belongs in `addendum.md` — moving overflow there is correct; padding the PRD to look thorough is not.
|
||||
|
||||
## Reviewer Gate
|
||||
|
||||
Used by the Validate intent and at Finalize step 3.
|
||||
|
||||
Assemble the menu: rubric walker against `{workflow.validation_checklist_template}` (the PRD quality rubric) + each entry in `{workflow.finalize_reviewers}` + any ad-hoc reviewers the artifact warrants. Stakes-calibrated — hobby/solo may run quietly or skip; higher stakes get the explicit all/subset/skip menu.
|
||||
|
||||
Dispatch entries as parallel subagents against `prd.md` (and `addendum.md` if present) using the standard prefix convention (`skill:` / `file:` / plain text). Each writes its full review to `{doc_workspace}/review-{slug}.md` and returns ONLY a compact summary (verdict, top 2-5 findings, file path) — the parent never holds full review text. The rubric walker uses the prompt and output format in `references/validate.md`. If subagents are unavailable, run sequentially: write the file *before* anything else, then flush the review from working context.
|
||||
|
||||
Surface findings tiered, never dumped. Lead with a one-sentence gate verdict, then walk critical + high findings; medium/low roll into a single tail ("plus N more in {file}"). Read the full `review-{slug}.md` only when the user drills into a specific finding. Per finding: autofix, discuss, defer to open items, or ignore.
|
||||
|
||||
Under Validate intent, the parent additionally runs the synthesis pipeline in `references/validate.md` — folding every selected reviewer's output into a single HTML + markdown report and opening the HTML.
|
||||
|
||||
## Finalize
|
||||
|
||||
1. Decision log audit: walk `decision-log.md` with the user — each entry captured in PRD, in addendum, or set aside.
|
||||
2. Input reconciliation: subagent per user-supplied input against `prd.md` + `addendum.md`; surface gaps, especially qualitative ideas (tone, voice, feel) the FR structure silently drops. Must happen before polish.
|
||||
3. Discipline pass: validator subagent against `prd.md` with `{workflow.validation_checklist}`. Findings stay in-conversation — autofix obvious issues, ask on ambiguous ones. No report file is written. Resolve before polish.
|
||||
4. Open-items review: triage all Open Questions, `[ASSUMPTION]` tags, and `[NOTE FOR PM]` callouts. Surface only phase-blockers one at a time; resolve before calling the PRD ready. Log deferred items to `decision-log.md`. If phase-blocking count is high, flag it.
|
||||
5. Polish: apply `{workflow.doc_standards}` to `prd.md` and `addendum.md` via parallel subagents.
|
||||
6. External handoffs: execute `{workflow.external_handoffs}` entries; surface returned URLs/IDs. Skip and flag unavailable tools.
|
||||
7. Record finalization to `decision-log.md`. Share all artifact paths. Invoke `bmad-help` to share possible steps.
|
||||
Tell the user the sequence in one sentence, then walk it. Polish goes last so it does not redo work after reviewer fixes.
|
||||
|
||||
1. **Decision log audit.** Walk `.decision-log.md` with the user; each entry captured in PRD, in addendum, or set aside.
|
||||
2. **Input reconciliation.** Subagent per user-supplied input against `prd.md` + `addendum.md`. Each writes its extract to `{doc_workspace}/reconcile-{slug}.md` and returns ONLY a compact summary (input name, gaps 2-5, file path). Surface gaps — especially qualitative ideas (tone, voice, feel) the FR structure silently drops. Must happen before polish.
|
||||
3. **Reviewer pass.** Run `## Reviewer Gate`. Resolve before polish.
|
||||
4. **Triage open items.** All Open Questions, `[ASSUMPTION]` tags, `[NOTE FOR PM]` callouts. Phase-blockers (would make the PRD unsafe for UX/architecture/epics) surfaced one at a time and resolved; non-blockers deferred with owner + revisit condition logged to `.decision-log.md`. If phase-blocker count is high, flag it.
|
||||
5. **Polish.** Apply `{workflow.doc_standards}` to `prd.md` and `addendum.md` in declared order (structural passes before prose — prose should not polish soon-to-be-cut text). Parallelize across documents, sequential within.
|
||||
6. **External handoffs.** Execute `{workflow.external_handoffs}`; surface returned URLs/IDs. Skip and flag unavailable tools.
|
||||
7. **Close.** Set `prd.md` frontmatter `status: final` and `updated` to `{date}` so future invocations distinguish this PRD from in-progress drafts. Record finalization to `.decision-log.md`. Share artifact paths. Common next: `bmad-create-ux-design`, `bmad-create-architecture`, `bmad-create-epics-and-stories`; invoke `bmad-help` for authoritative routing.
|
||||
8. Run `{workflow.on_complete}` if non-empty.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ Every headless run ends with one of these payloads. Omit keys for artifacts not
|
|||
"intent": "create",
|
||||
"prd": "{doc_workspace}/prd.md",
|
||||
"addendum": "{doc_workspace}/addendum.md",
|
||||
"decision_log": "{doc_workspace}/decision-log.md",
|
||||
"decision_log": "{doc_workspace}/.decision-log.md",
|
||||
"open_questions": [],
|
||||
"assumptions": [],
|
||||
"external_handoffs": [
|
||||
|
|
@ -34,7 +34,7 @@ Every headless run ends with one of these payloads. Omit keys for artifacts not
|
|||
"status": "complete",
|
||||
"intent": "update",
|
||||
"prd": "{doc_workspace}/prd.md",
|
||||
"decision_log": "{doc_workspace}/decision-log.md",
|
||||
"decision_log": "{doc_workspace}/.decision-log.md",
|
||||
"changes_summary": "1-3 sentences describing what changed and why",
|
||||
"conflicts_with_prior_decisions": [],
|
||||
"open_questions": [],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
# PRD Template — A Menu, Not a Skeleton
|
||||
|
||||
This is a menu of sections the facilitator picks from based on what the product, the stakes, the audience, and the existing inputs actually need. Hobby projects use the essential spine and stop. Enterprise initiatives, regulated submissions, and consumer launches add clusters from the adapt-in menu below. **Never include a section just because it appears here.** Drop, reorder, rename, combine — whatever the PRD needs.
|
||||
|
||||
---
|
||||
# PRD Template
|
||||
|
||||
## Essential Spine *(almost always present)*
|
||||
|
||||
|
|
@ -34,15 +30,30 @@ updated: {YYYY-MM-DD}
|
|||
[Who this is explicitly not for in v1.]
|
||||
|
||||
### 2.4 Key User Journeys
|
||||
*Named flows the product enables — one line each, numbered globally as UJ-1 through UJ-N for downstream traceability. Detailed flow design (steps, screens, edge flows) is the job of the UX workflow, not this PRD. Features in §4 may reference journeys by ID inline ("realizes UJ-3").*
|
||||
*Named-persona narratives the product enables. Numbered globally as UJ-1 through UJ-N. FRs reference journeys by ID inline ("realizes UJ-3"); SMs may also cross-reference. If a UX doc already exists, mirror its UJ IDs here and point to the source.*
|
||||
|
||||
- **UJ-1** — [Named flow, one line: who does what, to what end.]
|
||||
- **UJ-2** — ...
|
||||
**Default shape:** a named scene with entry state, path, climax, and resolution. Each beat forces specificity the team would otherwise leave implicit — auth assumptions, screen order, what tells the user value landed. Read together as a short narrative; the example below shows the form.
|
||||
|
||||
[For hobby/utility projects, 1-3 journeys may be enough. For complex multi-feature products (onboarding, checkout, multi-step approvals), expand. For libraries/CLIs with minimal flow, reduce to a single line or collapse into §2.2 JTBD.]
|
||||
- **UJ-1. {One-line title — persona doing the thing.}**
|
||||
- **Persona + context:** one line, grounded enough to explain the *why*.
|
||||
- **Entry state:** authenticated? which surface? coming from where?
|
||||
- **Path:** 3-5 concrete beats — taps, screens, decisions.
|
||||
- **Climax:** the moment value is delivered and how the user knows.
|
||||
- **Resolution:** state they're left in, what's next.
|
||||
- **Edge case** *(optional)*: one real failure mode and what the user does next.
|
||||
|
||||
*Written out, that becomes:*
|
||||
> **UJ-3. Priya checks the trip damage before she's even home.**
|
||||
> Priya, budgeting on a single income with a new baby, finishes a grocery run and gets in the car. Already authenticated via biometric on a previous session. She opens the app, taps the FAB camera, and scans the receipt. The app OCRs the total and shows a single-screen overlay: this trip $84.20, weekly cap $250, $172.10 remaining, three days left in the week. She closes the app and drives home. **Edge case:** if she scanned a receipt earlier today, the app asks whether this replaces or adds to that trip before counting it against the cap.
|
||||
|
||||
- **UJ-2. ...**
|
||||
|
||||
**Scope dial:**
|
||||
- **Lighter** — hobby/solo, library/CLI, or when the UJ is essentially a JTBD restated: a single sentence works (`{Persona}, {context}, {what they do and why}.`).
|
||||
- **Heavier** — auth, multi-device handoff, complex navigation, or anything feeding downstream UX/architecture: add a numbered Flow, an Edge cases list, and a capability → FR mapping (`The system must {capability}. → FR-N`).
|
||||
|
||||
## 3. Glossary
|
||||
*Downstream workflows and readers must use these terms exactly.*
|
||||
*Downstream workflows and readers must use these terms exactly. FRs, UJs, and SMs use Glossary terms verbatim; introducing a synonym anywhere in the PRD is a discipline violation. If §4 introduces a new domain noun, add it to the Glossary in the same pass.*
|
||||
|
||||
- **Term** — Definition. Relationships to other Glossary terms. Cardinality where relevant.
|
||||
- **Term** — ...
|
||||
|
|
@ -53,11 +64,22 @@ updated: {YYYY-MM-DD}
|
|||
*Each subsection is a coherent feature: behavioral description first, FRs nested under it, optional feature-specific NFRs and notes. FRs are numbered globally (FR-1 through FR-N) so downstream artifacts have stable references even if features get reorganized. Reference user journeys by ID inline ("realizes UJ-2") where the chain matters.*
|
||||
|
||||
### 4.1 {Feature Name}
|
||||
**Description:** [Behavioral narrative — how this feature works, who uses it, the user experience, edge cases. Use Glossary terms exactly. Embed inline `[ASSUMPTION: ...]` tags where you inferred without confirmation.]
|
||||
**Description:** [Behavioral narrative — how this feature works, who uses it, the user experience, edge cases. Realizes UJ-X, UJ-Y. Use Glossary terms exactly. Embed inline `[ASSUMPTION: ...]` tags where you inferred without confirmation.]
|
||||
|
||||
**Functional Requirements:**
|
||||
- **FR-1** — [Actor] can [capability] [under conditions / with measurement].
|
||||
- **FR-2** — ...
|
||||
|
||||
#### FR-1: {Short capability name}
|
||||
|
||||
[Actor] can [capability] [under conditions]. Realizes UJ-X.
|
||||
|
||||
**Consequences (testable):**
|
||||
- {Specific testable condition, e.g. "System returns HTTP 429 when request rate exceeds 100/sec per merchant."}
|
||||
- {Another testable condition.}
|
||||
|
||||
**Out of Scope:** *(optional — what this FR explicitly does NOT cover)*
|
||||
- {bound}
|
||||
|
||||
#### FR-2: ...
|
||||
|
||||
**Feature-specific NFRs:** *(only if any apply uniquely to this feature)*
|
||||
- Performance / security / accessibility / etc. specific to this feature.
|
||||
|
|
@ -80,14 +102,16 @@ updated: {YYYY-MM-DD}
|
|||
|
||||
## 7. Success Metrics
|
||||
|
||||
*Each SM cross-references the FR(s) it validates. Counter-metrics counterbalance specific primary or secondary metrics.*
|
||||
|
||||
**Primary**
|
||||
- Metric — definition, target.
|
||||
- **SM-1**: Metric — definition, target. Validates FR-X, FR-Y.
|
||||
|
||||
**Secondary**
|
||||
- Metric — definition, target.
|
||||
- **SM-2**: Metric — definition, target. Validates FR-Z.
|
||||
|
||||
**Counter-metrics (do not optimize)**
|
||||
- Metric — why this should *not* be optimized.
|
||||
- **SM-C1**: Metric — why this should *not* be optimized. Counterbalances SM-1.
|
||||
|
||||
[Length scales with stakes. Hobby/utility PRD: a single sentence may be enough ("Success: I use this weekly and don't abandon it after a month"). Public launch / enterprise: full quantitative breakdown with measurement methods. Counter-metrics are as load-bearing as primary metrics — they prevent the architect from optimizing the wrong thing and the dev from gaming the wrong target.]
|
||||
|
||||
|
|
@ -142,17 +166,3 @@ updated: {YYYY-MM-DD}
|
|||
### Small-scope all-inclusive *(use when scope is 1-2 stories' worth and the user wants a single captured artifact — chosen during the Right-skill check in Discovery)*
|
||||
- **Stories** — story-level specs listed inline at the end of the doc. Each story: *"As a [persona], I can [action] [under conditions]. Acceptance: [testable criteria]."* Numbered Story-1, Story-2, ... for reference. Pair with very lean §1 Vision, §2 Target User (often just JTBD + one UJ), §3 Glossary (handful of terms), §4 Features (often a single feature), §6 MVP Scope (in/out very tight). The whole doc fits on a page or two and captures intent + implementable stories in one place. If the user doesn't want the captured artifact at all, `bmad-quick-dev` is the better path — this cluster is only for "I want a doc *and* the stories."
|
||||
|
||||
---
|
||||
|
||||
## Notes for the facilitator
|
||||
|
||||
- **The essential spine is the floor, not the ceiling.** A hobby PRD might keep all ten sections short. An enterprise PRD layers many clusters from the adapt-in menu.
|
||||
- **§3 Glossary before §4 Features.** Mechanics never introduce a new domain noun without adding it to the Glossary in the same pass. Persona, JTBD, and Journeys may use Glossary terms before §3 formally defines them — context is inferable; the Glossary is for downstream anchoring.
|
||||
- **§2.4 Key User Journeys are brief.** One line each. Numbered globally (UJ-1 through UJ-N) so architecture, epics, stories, and tickets can reference them by stable ID. Detailed flow design happens in the UX workflow — not here.
|
||||
- **§4 Features pattern at every scale.** Description → FRs nested → optional NFRs → optional notes. Hobby PRD: one short paragraph and three FRs per feature. Enterprise feature: multi-paragraph description, fifteen FRs, several feature-specific NFRs, open questions. Same shape, different depth.
|
||||
- **`[ASSUMPTION]`, `[NON-GOAL]`, `[v2 — out of MVP]`, `[NOTE FOR PM]` callouts are first-class.** They signal to downstream readers and the next session of work. Every `[ASSUMPTION]` lands in §9 Assumptions Index.
|
||||
- **When UX is *input* to the PRD** (journeys already designed elsewhere): §2.4 names the journeys by ID and points to the existing UX doc. Reference, do not duplicate.
|
||||
- **When UX is *output* of the PRD** (no UX work yet — downstream `bmad-create-ux-design` will produce it): §2.4 captures the PM's intent on which journeys exist; UX elaborates them into detailed flows downstream.
|
||||
- **§7 Success Metrics scales with stakes** but is always present. Counter-metrics matter as much as primary metrics — they shape what NOT to optimize.
|
||||
- **Small-scope all-inclusive option.** When scope is genuinely 1-2 stories and the user wants a single artifact instead of running a separate `bmad-create-story` workflow, add the adapt-in *Stories* cluster: lean §1-§6 plus inline §Stories at the end. The whole doc fits on a page or two. This is a valid PRD shape for tiny work — don't apologize for it.
|
||||
- **Adapt the section numbering.** The spine uses 0-9; adapt-in additions slot in wherever they read best (e.g., Aesthetic & Tone before §3 if branding is foundational, Compliance after §5 Non-Goals, Constraints & Guardrails between Features and Non-Goals, Stories at the very end after Assumptions Index).
|
||||
|
|
|
|||
|
|
@ -1,30 +1,135 @@
|
|||
# PRD Validation Checklist
|
||||
# PRD Quality Rubric
|
||||
|
||||
Loaded by the PRD validator subagent. For each item, return `{id, status: pass|fail|warn|n/a, severity: low|medium|high|critical, location, note}`. Skip items not applicable to the agreed stakes. Cite specific PRD locations — never abstract criticism.
|
||||
A judgment rubric for the validator subagent. Walk the PRD with these dimensions in mind and write substantive findings — not box-ticking. The goal is a review that tells the user whether this PRD is *good*, not whether it has the right section headers.
|
||||
|
||||
## Quality
|
||||
Most PRDs do not need every dimension scrutinized equally. Calibrate to the agreed stakes, the PRD's shape (consumer product, internal tool, regulatory update, technical capability spec), and what the PRD itself is trying to do. Be specific — cite locations, quote phrases, name what's missing. Abstract criticism is failure of nerve.
|
||||
|
||||
- **Q-1. Information density.** Sentences carry weight. Flag filler, hedging, and conversational padding.
|
||||
- **Q-2. Measurability.** Where measurement matters, FRs and Success Metrics are measurable; subjective adjectives flagged. Counter-metrics named when Success Metrics exist.
|
||||
- **Q-3. Traceability.** Where the chain matters, FRs name their link to a user journey or success criterion inline.
|
||||
- **Q-4. Vision and JTBDs concrete.** Vision is specific and stands alone — not a generic feature list. JTBDs are audience-grounded, not abstract.
|
||||
- **Q-5. Non-Goals explicit.** A Non-Goals section is present where it would do real work; inline `[NON-GOAL]` and `[v2]` callouts where omissions would otherwise be silently assumed.
|
||||
- **Q-6. Dual-audience and self-contained.** Each section makes sense pulled out alone (cross-references via Glossary terms, not "see above"); the PRD is readable by humans and structured cleanly for downstream source-extraction by UX, architecture, and story-creation workflows.
|
||||
## How to use this rubric
|
||||
|
||||
## Discipline
|
||||
1. Read the full PRD (and addendum.md if present) before writing anything.
|
||||
2. For each of the seven dimensions below, form a judgment — *strong / adequate / thin / broken* — backed by specifics from the PRD.
|
||||
3. Write findings only where they add information. A `strong` dimension may need no findings; a `broken` one needs concrete, fixable ones.
|
||||
4. Severity ranks impact on the PRD's usefulness, not how easy the fix is. A vague Vision statement is *critical* even though it's a one-paragraph fix; a glossary drift might be *low* even though it appears in many places.
|
||||
5. The overall verdict is your synthesis — 2–3 sentences that name what holds up and what's at risk. Earn it with the dimension judgments.
|
||||
|
||||
- **D-1. Capabilities, not implementation.** FRs describe what users/systems can do, not how. Flag technology names, library choices, architecture decisions.
|
||||
- **D-2. Input fidelity.** Requirements from input documents (brief, research, prior PRD) are still in scope or explicitly handled via Non-Goals or `[ASSUMPTION]`.
|
||||
- **D-3. Personas grounded.** If personas exist, they are research-grounded or marked `[ILLUSTRATIVE]`. Each persona drives at least one decision.
|
||||
- **D-4. No innovation theater.** Novelty claims are real, not invented.
|
||||
## Output format
|
||||
|
||||
## Structural integrity
|
||||
Write findings to `{doc_workspace}/review-rubric.md`:
|
||||
|
||||
- **S-1. Glossary integrity.** Every domain noun is defined in the Glossary and used identically throughout. Flag drift (case, plural, synonyms) and candidate missing-term entries.
|
||||
- **S-2. ID continuity.** FR / UJ / Story IDs are contiguous, unique, and cross-references resolve.
|
||||
- **S-3. Assumptions Index.** Every inline `[ASSUMPTION: ...]` appears in the Assumptions Index and vice versa.
|
||||
- **S-4. Open-items density.** Count Open Questions + `[ASSUMPTION]` + `[NOTE FOR PM]`. Red flag if density is high relative to the agreed stakes.
|
||||
```markdown
|
||||
# PRD Quality Review — {prd_name}
|
||||
|
||||
## Stakes-gated
|
||||
## Overall verdict
|
||||
[2–3 sentences. What holds up, what's at risk. Earned by the dimension judgments below.]
|
||||
|
||||
- **STK-1. Required sections.** The PRD includes the sections the agreed stakes and product type warrant.
|
||||
## Decision-readiness — [strong | adequate | thin | broken]
|
||||
[1–3 paragraphs of judgment with specific PRD locations.]
|
||||
|
||||
### Findings
|
||||
- **[critical|high|medium|low]** [Title] (§ location) — [Note]. *Fix:* [suggested fix].
|
||||
|
||||
## Substance over theater — [verdict]
|
||||
...
|
||||
|
||||
(repeat for each dimension)
|
||||
|
||||
## Mechanical notes
|
||||
[Glossary drift, ID continuity, broken cross-refs, Assumptions Index roundtrip. Lighter weight — these matter for downstream but don't drive the overall verdict.]
|
||||
```
|
||||
|
||||
## The seven dimensions
|
||||
|
||||
### 1. Decision-readiness
|
||||
|
||||
Can a decision-maker act on this PRD? Are the trade-offs surfaced honestly, or has the PRD smoothed everything to neutral? Would someone pushing back find their objection acknowledged or dodged?
|
||||
|
||||
Look for:
|
||||
- Decisions that are stated as decisions, not buried as "considerations."
|
||||
- Trade-offs named with what was given up, not just what was chosen.
|
||||
- Open Questions that are actually open — not rhetorical questions with an answer in the next sentence.
|
||||
- `[NOTE FOR PM]` callouts at real tensions, not at safe checkpoints.
|
||||
|
||||
Red flag: a PRD where every choice "balances" everything, every NFR is "important," every persona "values" the product.
|
||||
|
||||
### 2. Substance over theater
|
||||
|
||||
Is the content earned, or is it furniture? Distinguish:
|
||||
|
||||
- **Persona theater** — Personas that don't drive a single decision in the PRD. More than four personas. Personas whose only function is to make the PRD look thorough.
|
||||
- **Innovation theater** — claimed novelty that isn't novel. Differentiation sections written because the template had one, not because Discovery surfaced something.
|
||||
- **NFR theater** — copied boilerplate ("system must be scalable / secure / reliable") without product-specific thresholds.
|
||||
- **Vision theater** — a Vision statement that could swap into any PRD in this category without change.
|
||||
|
||||
Flag what reads like furniture, even if it's well-written furniture.
|
||||
|
||||
### 3. Strategic coherence
|
||||
|
||||
Does the PRD have a thesis? Do the features serve a unified arc, or is it a list of capabilities someone wanted?
|
||||
|
||||
Look for:
|
||||
- A stated thesis the PRD bets on (problem framing, user insight, market move).
|
||||
- Feature prioritization that follows from the thesis — not from "what's easy first."
|
||||
- Success Metrics that validate the thesis, not metrics that just measure activity (DAU/MAU when the thesis is about engagement quality is a tell).
|
||||
- Counter-metrics named when SMs exist.
|
||||
- Coherent MVP scope kind — problem-solving, experience, platform, or revenue — with scope logic that matches.
|
||||
|
||||
Red flag: a PRD that reads as a backlog with section headings.
|
||||
|
||||
### 4. Done-ness clarity
|
||||
|
||||
Would an engineer reading this PRD know what "done" looks like for each FR?
|
||||
|
||||
Look for:
|
||||
- FRs with at least one testable consequence per FR — verifiable condition, measurable outcome.
|
||||
- "System handles X gracefully," "reasonable performance," "user-friendly" — flag every one.
|
||||
- Acceptance criteria implied or explicit. Sometimes the FR's consequences carry this; sometimes the PRD genuinely needs an Acceptance section.
|
||||
- For non-functional sections (UX, performance, security): bounds, not adjectives.
|
||||
|
||||
This is the dimension downstream story creation will lean on hardest. Be unforgiving here.
|
||||
|
||||
### 5. Scope honesty
|
||||
|
||||
Are omissions explicit, or is the reader meant to infer them?
|
||||
|
||||
Look for:
|
||||
- A Non-Goals section where it would do real work — and `[NON-GOAL for MVP]` callouts where omissions could be silently assumed.
|
||||
- `[ASSUMPTION: …]` tags on inferences the user didn't directly confirm, indexed at the end.
|
||||
- `[NOTE FOR PM]` callouts at deferred decisions and unresolved tensions.
|
||||
- De-scoping proposed honestly, not done silently.
|
||||
|
||||
Open-items density: count Open Questions + `[ASSUMPTION]` + `[NOTE FOR PM]` callouts relative to stakes. High counts on a low-stakes PRD is fine; high counts on a green-light-to-build PRD is a blocker.
|
||||
|
||||
### 6. Downstream usability
|
||||
|
||||
If this PRD feeds UX, architecture, or story creation, can those workflows source-extract from it cleanly?
|
||||
|
||||
Look for:
|
||||
- Glossary present; every domain noun used identically across FRs, UJs, SM definitions.
|
||||
- FR / UJ / SM IDs contiguous, unique, and cross-references that resolve.
|
||||
- Each section makes sense pulled out alone — cross-references via Glossary terms, not "see above."
|
||||
- UJs each name a persona from §2 by exact label; no floating UJs.
|
||||
|
||||
For standalone PRDs (no downstream), this dimension matters less — say so.
|
||||
|
||||
### 7. Shape fit
|
||||
|
||||
Has the PRD been forced into a shape that doesn't match the product?
|
||||
|
||||
- Consumer product / multi-stakeholder B2B / meaningful UX → UJs and personas are load-bearing.
|
||||
- Internal tool, single-operator role → capability spec shape; UJs may be overhead; SMs may be operational rather than user-facing.
|
||||
- Regulatory or compliance update → constraint traceability is non-negotiable; UJs may be irrelevant.
|
||||
- Hobby / solo → rigor light, substance bar still applies.
|
||||
- Brownfield → existing-code references must be accurate; new UJs and existing UJs must be distinguished.
|
||||
- Chain-top (feeds UX → architecture → stories) → downstream usability matters more; standalone PRDs can be lighter on traceability.
|
||||
|
||||
Flag PRDs that are over-formalized (UJ density for a single-operator tool) or under-formalized (consumer product with no personas or UJs).
|
||||
|
||||
## Mechanical notes
|
||||
|
||||
Cover these as a tail section, not a primary dimension. They matter for downstream but don't drive the verdict on whether the PRD is good.
|
||||
|
||||
- Glossary drift (case, plural, synonyms across the PRD).
|
||||
- ID continuity (gaps, duplicates, unresolved cross-references).
|
||||
- Assumptions Index roundtrip (every inline `[ASSUMPTION]` indexed; index entries all appear inline).
|
||||
- UJ persona linkage (each UJ names a defined persona by exact label).
|
||||
- Required sections present for the agreed stakes and product type.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,30 @@
|
|||
<!DOCTYPE html>
|
||||
<!--
|
||||
PRD Validation Report — skeleton template.
|
||||
|
||||
This file is a starter the synthesis pass fills in directly. There is no
|
||||
substitution engine. The LLM:
|
||||
1. Reads {doc_workspace}/review-rubric.md and every review-{slug}.md from
|
||||
additional reviewers.
|
||||
2. Copies this skeleton.
|
||||
3. Replaces the placeholder content (everything between TEMPLATE markers)
|
||||
with the consolidated review, preserving the structure and CSS.
|
||||
4. Writes the result to {doc_workspace}/validation-report.html.
|
||||
5. Writes a markdown twin to {doc_workspace}/validation-report.md.
|
||||
|
||||
Visual rules the LLM must preserve:
|
||||
- The container width, the color tokens, the typography.
|
||||
- One dimension = one collapsible <section class="dimension">.
|
||||
- Verdict pill uses the verdict-* class matching its judgment.
|
||||
- Severity badge uses the sev-* class matching its level.
|
||||
- Each extra reviewer (adversarial, etc.) gets its own collapsible section
|
||||
below the rubric dimensions.
|
||||
- The footer always shows the artifact paths and timestamp.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>PRD Validation: $prd_name</title>
|
||||
<title>PRD Validation: TEMPLATE_PRD_NAME</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #fafaf9;
|
||||
|
|
@ -10,14 +32,17 @@
|
|||
--border: #e7e5e4;
|
||||
--text: #1c1917;
|
||||
--muted: #78716c;
|
||||
--pass: #22c55e;
|
||||
--warn: #eab308;
|
||||
--fail: #ef4444;
|
||||
--na: #94a3b8;
|
||||
|
||||
--verdict-strong: #16a34a;
|
||||
--verdict-adequate: #65a30d;
|
||||
--verdict-thin: #d97706;
|
||||
--verdict-broken: #dc2626;
|
||||
|
||||
--sev-low: #64748b;
|
||||
--sev-medium: #ca8a04;
|
||||
--sev-high: #ea580c;
|
||||
--sev-critical: #dc2626;
|
||||
|
||||
--grade-exc: #16a34a;
|
||||
--grade-good: #65a30d;
|
||||
--grade-fair: #d97706;
|
||||
|
|
@ -29,14 +54,14 @@
|
|||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.55;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
}
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 32px 24px 64px; }
|
||||
|
||||
header.report-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding-bottom: 16px;
|
||||
|
|
@ -63,63 +88,90 @@
|
|||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--muted);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
padding: 18px 22px;
|
||||
margin-bottom: 24px;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-size: 15.5px;
|
||||
}
|
||||
.synthesis:empty { display: none; }
|
||||
.synthesis p { margin: 0 0 10px; }
|
||||
.synthesis p:last-child { margin-bottom: 0; }
|
||||
|
||||
.scoreboard {
|
||||
.dimension-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.dim-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 24px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.score-bar { margin: 0 0 14px; line-height: 0; }
|
||||
.score-stats { display: flex; gap: 22px; font-size: 14px; flex-wrap: wrap; }
|
||||
.score-stats span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.score-stats .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
|
||||
.dot-pass { background: var(--pass); }
|
||||
.dot-warn { background: var(--warn); }
|
||||
.dot-fail { background: var(--fail); }
|
||||
.dot-na { background: var(--na); }
|
||||
.total-count { margin-left: auto; color: var(--muted); }
|
||||
.dim-card .dim-name { font-size: 13px; color: var(--muted); margin-bottom: 6px; }
|
||||
.dim-card .dim-verdict { font-size: 14px; font-weight: 600; }
|
||||
|
||||
section.category { margin-bottom: 16px; }
|
||||
section.category details {
|
||||
section.dimension, section.reviewer-section { margin-bottom: 14px; }
|
||||
section.dimension details, section.reviewer-section details {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
section.category summary {
|
||||
section summary {
|
||||
padding: 14px 20px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
section.category summary::-webkit-details-marker { display: none; }
|
||||
section.category summary::before {
|
||||
section summary::-webkit-details-marker { display: none; }
|
||||
section summary::before {
|
||||
content: "▸";
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
color: var(--muted);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
section.category details[open] summary::before { transform: rotate(90deg); }
|
||||
section.category summary h2 { display: inline; margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.005em; }
|
||||
section.category .count { color: var(--muted); font-weight: 400; margin-left: 6px; font-size: 14px; }
|
||||
section details[open] summary::before { transform: rotate(90deg); }
|
||||
section summary h2 {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.005em;
|
||||
flex: 1;
|
||||
}
|
||||
.verdict-pill {
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: white;
|
||||
}
|
||||
.verdict-strong { background: var(--verdict-strong); }
|
||||
.verdict-adequate { background: var(--verdict-adequate); }
|
||||
.verdict-thin { background: var(--verdict-thin); }
|
||||
.verdict-broken { background: var(--verdict-broken); }
|
||||
|
||||
article.finding { padding: 16px 20px; border-top: 1px solid var(--border); }
|
||||
article.finding-fail { background: rgba(239, 68, 68, 0.025); }
|
||||
.dim-body { padding: 4px 20px 18px; }
|
||||
.dim-judgment { color: var(--text); font-size: 14.5px; }
|
||||
.dim-judgment p { margin: 0 0 10px; }
|
||||
|
||||
.findings-list { padding: 0 20px 4px; }
|
||||
article.finding {
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
article.finding:first-child { border-top: none; }
|
||||
article.finding header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.badge {
|
||||
font-size: 10.5px;
|
||||
|
|
@ -130,18 +182,32 @@
|
|||
letter-spacing: 0.04em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.badge-pass { background: rgba(34, 197, 94, 0.12); color: #15803d; }
|
||||
.badge-warn { background: rgba(234, 179, 8, 0.14); color: #854d0e; }
|
||||
.badge-fail { background: rgba(239, 68, 68, 0.12); color: #b91c1c; }
|
||||
.badge-na { background: rgba(148, 163, 184, 0.16); color: #475569; }
|
||||
.badge-sev-low { background: rgba(100, 116, 139, 0.12); color: var(--sev-low); }
|
||||
.badge-sev-medium { background: rgba(202, 138, 4, 0.14); color: var(--sev-medium); }
|
||||
.badge-sev-high { background: rgba(234, 88, 12, 0.14); color: var(--sev-high); }
|
||||
.badge-sev-critical { background: rgba(220, 38, 38, 0.14); color: var(--sev-critical); }
|
||||
.finding-id { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; color: var(--muted); }
|
||||
.finding-title { margin: 0; font-size: 15px; font-weight: 500; flex: 1; min-width: 200px; }
|
||||
.finding-location, .finding-note, .finding-fix { margin-top: 6px; font-size: 14px; color: var(--text); }
|
||||
.finding-location strong, .finding-fix strong { color: var(--muted); font-weight: 500; }
|
||||
.finding-location { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; color: var(--muted); }
|
||||
.finding-note, .finding-fix { margin-top: 6px; font-size: 14px; }
|
||||
.finding-fix strong { color: var(--muted); font-weight: 500; }
|
||||
|
||||
.reviewer-source {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
.mechanical {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
margin-top: 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.mechanical h3 { margin: 0 0 10px; font-size: 14px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.mechanical ul { margin: 0; padding-left: 20px; }
|
||||
.mechanical li { margin-bottom: 4px; color: var(--text); }
|
||||
|
||||
footer.report-footer {
|
||||
margin-top: 40px;
|
||||
|
|
@ -156,33 +222,102 @@
|
|||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<!-- TEMPLATE: header. Fill prd name, prd path, grade text & class. -->
|
||||
<header class="report-header">
|
||||
<div class="title">
|
||||
<h1>$prd_name — Validation Report</h1>
|
||||
<div class="subtitle">$prd_path</div>
|
||||
<h1>TEMPLATE_PRD_NAME — Validation Report</h1>
|
||||
<div class="subtitle">TEMPLATE_PRD_PATH</div>
|
||||
</div>
|
||||
<div class="grade $grade_class">$grade</div>
|
||||
<div class="grade TEMPLATE_GRADE_CLASS">TEMPLATE_GRADE</div>
|
||||
</header>
|
||||
|
||||
<div class="synthesis">$overall_synthesis</div>
|
||||
|
||||
<div class="scoreboard">
|
||||
<div class="score-bar">$score_svg</div>
|
||||
<div class="score-stats">
|
||||
<span><span class="dot dot-pass"></span>$passed pass</span>
|
||||
<span><span class="dot dot-warn"></span>$warned warn</span>
|
||||
<span><span class="dot dot-fail"></span>$failed fail</span>
|
||||
<span><span class="dot dot-na"></span>$na n/a</span>
|
||||
<span class="total-count">$total items checked</span>
|
||||
</div>
|
||||
<!-- TEMPLATE: overall synthesis paragraphs. Lift directly from
|
||||
review-rubric.md "Overall verdict" section; expand if extra reviewers
|
||||
materially shift the picture. Wrap each paragraph in <p>. -->
|
||||
<div class="synthesis">
|
||||
<p>TEMPLATE_SYNTHESIS_PARAGRAPH</p>
|
||||
</div>
|
||||
|
||||
$categories_html
|
||||
<!-- TEMPLATE: dimension summary cards. One per rubric dimension. The
|
||||
dim-verdict text uses one of: strong | adequate | thin | broken. -->
|
||||
<div class="dimension-summary">
|
||||
<div class="dim-card">
|
||||
<div class="dim-name">Decision-readiness</div>
|
||||
<div class="dim-verdict" style="color: var(--verdict-TEMPLATE_VERDICT)">TEMPLATE_VERDICT_TEXT</div>
|
||||
</div>
|
||||
<!-- repeat for each of the seven dimensions -->
|
||||
</div>
|
||||
|
||||
<!-- TEMPLATE: one section per rubric dimension. Skip a dimension entirely
|
||||
if the rubric review marked it n/a for this PRD (e.g. downstream
|
||||
usability for a standalone PRD). Open the section by default if
|
||||
verdict is thin or broken. -->
|
||||
<section class="dimension">
|
||||
<details open>
|
||||
<summary>
|
||||
<h2>Decision-readiness</h2>
|
||||
<span class="verdict-pill verdict-TEMPLATE_VERDICT">TEMPLATE_VERDICT_TEXT</span>
|
||||
</summary>
|
||||
<div class="dim-body">
|
||||
<div class="dim-judgment">
|
||||
<p>TEMPLATE_DIMENSION_JUDGMENT</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="findings-list">
|
||||
<!-- TEMPLATE: zero or more findings -->
|
||||
<article class="finding">
|
||||
<header>
|
||||
<span class="badge badge-sev-TEMPLATE_SEVERITY">TEMPLATE_SEVERITY</span>
|
||||
<h3 class="finding-title">TEMPLATE_FINDING_TITLE</h3>
|
||||
<span class="finding-location">TEMPLATE_LOCATION</span>
|
||||
</header>
|
||||
<div class="finding-note">TEMPLATE_FINDING_NOTE</div>
|
||||
<div class="finding-fix"><strong>Fix:</strong> TEMPLATE_SUGGESTED_FIX</div>
|
||||
</article>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- TEMPLATE: one section per extra reviewer that ran (adversarial, etc.).
|
||||
Skip this block entirely if only the rubric walker ran. -->
|
||||
<section class="reviewer-section">
|
||||
<details>
|
||||
<summary>
|
||||
<h2>Adversarial review</h2>
|
||||
<span class="reviewer-source">TEMPLATE_REVIEWER_SOURCE_FILE</span>
|
||||
</summary>
|
||||
<div class="dim-body">
|
||||
<div class="dim-judgment">
|
||||
<p>TEMPLATE_REVIEWER_PREAMBLE</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="findings-list">
|
||||
<article class="finding">
|
||||
<header>
|
||||
<span class="badge badge-sev-TEMPLATE_SEVERITY">TEMPLATE_SEVERITY</span>
|
||||
<h3 class="finding-title">TEMPLATE_FINDING_TITLE</h3>
|
||||
<span class="finding-location">TEMPLATE_LOCATION</span>
|
||||
</header>
|
||||
<div class="finding-note">TEMPLATE_FINDING_NOTE</div>
|
||||
<div class="finding-fix"><strong>Fix:</strong> TEMPLATE_SUGGESTED_FIX</div>
|
||||
</article>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<!-- TEMPLATE: mechanical notes — short, bulleted. Skip if there are none. -->
|
||||
<div class="mechanical">
|
||||
<h3>Mechanical notes</h3>
|
||||
<ul>
|
||||
<li>TEMPLATE_MECHANICAL_NOTE</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<footer class="report-footer">
|
||||
<div class="meta">
|
||||
<span>Checklist: $checklist_path</span>
|
||||
<span>Generated: $timestamp</span>
|
||||
<span>Rubric: TEMPLATE_RUBRIC_PATH</span>
|
||||
<span>Generated: TEMPLATE_TIMESTAMP</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -43,23 +43,28 @@ on_complete = ""
|
|||
# to enforce a different structure (e.g. regulated-industry, internal-tool, investor-input).
|
||||
prd_template = "assets/prd-template.md"
|
||||
|
||||
# Validation checklist used at the Validate intent and at Finalize step 3.
|
||||
# A subagent walks the checklist against prd.md and returns structured findings.
|
||||
# Override the path in team/user TOML to enforce an org-specific checklist
|
||||
# (regulated-industry compliance, investor-pitch standards, etc.).
|
||||
validation_checklist = "assets/prd-validation-checklist.md"
|
||||
# PRD quality rubric used at the Validate intent and at Finalize step 3.
|
||||
# A subagent walks the rubric against prd.md and writes a substantive review
|
||||
# organized by quality dimensions (decision-readiness, substance, strategic
|
||||
# coherence, etc.). Override the path in team/user TOML to enforce an
|
||||
# org-specific rubric (regulated-industry compliance, investor-pitch standards,
|
||||
# etc.). The filename "checklist" is retained for back-compat with override
|
||||
# files; the content is a judgment rubric, not a boolean checklist.
|
||||
validation_checklist_template = "assets/prd-validation-checklist.md"
|
||||
|
||||
# HTML template used to render validation findings into a styled, scannable
|
||||
# report. The renderer (scripts/render-validation-html.py) substitutes
|
||||
# structured findings + summary stats into this template; the template is
|
||||
# fully overridable to match org branding. The default uses inline CSS, no
|
||||
# external dependencies, and native HTML <details> for collapse — no JS.
|
||||
# HTML skeleton the synthesis pass fills directly when consolidating reviewer
|
||||
# outputs into a validation report. No substitution engine — the parent LLM
|
||||
# reads every {doc_workspace}/review-*.md, fills the skeleton's TEMPLATE_*
|
||||
# placeholders, and writes the result. Fully overridable to match org branding.
|
||||
# Uses inline CSS, no external dependencies, and native HTML <details> for
|
||||
# collapse — no JS.
|
||||
validation_report_template = "assets/validation-report-template.html"
|
||||
|
||||
# Run folder location. The PRD, optional addendum, decision log, and optional
|
||||
# validation report all land inside `{output_dir}/{output_folder_name}/`.
|
||||
output_dir = "{planning_artifacts}/prds"
|
||||
output_folder_name = "prd-{project_name}-{date}"
|
||||
# validation report all land inside `{prd_output_path}/{run_folder_pattern}/`.
|
||||
# Resume-check scans `{prd_output_path}` for prior unfinished runs.
|
||||
prd_output_path = "{planning_artifacts}/prds"
|
||||
run_folder_pattern = "prd-{project_name}-{date}"
|
||||
|
||||
# Document standards applied to human-consumed docs at finalize. Each entry is
|
||||
# a `skill:`, `file:`, or plain-text directive; the parent LLM applies the
|
||||
|
|
@ -90,6 +95,11 @@ doc_standards = [
|
|||
# tool needs. If a named MCP tool is unavailable at runtime, the LLM falls
|
||||
# back to standard behavior and notes the gap. Empty by default.
|
||||
#
|
||||
# Lifecycle note: distinct from persistent_facts. persistent_facts are loaded
|
||||
# once at activation and kept in mind for the whole run; external_sources are
|
||||
# a registry consulted on demand and only when the conversation surfaces a
|
||||
# matching need.
|
||||
#
|
||||
# Examples (set in team/user override TOML):
|
||||
# "When researching internal product context, consult corp:kb_search (database='product-docs') before web search."
|
||||
# "For competitive landscape during Discovery, query corp:competitive_db with category={project_name}."
|
||||
|
|
@ -106,8 +116,32 @@ external_sources = []
|
|||
# status; local files always exist regardless. Fires automatically — users
|
||||
# can opt out in their prompt for a specific run. Empty by default.
|
||||
#
|
||||
# Lifecycle note: distinct from persistent_facts and external_sources.
|
||||
# Fired once at Finalize step 6, never during Discovery or drafting.
|
||||
#
|
||||
# Examples (set in team/user override TOML):
|
||||
# "After finalize, upload prd.md and addendum.md to Confluence via corp:confluence_upload (space_key='PROD', parent_page='PRDs', label='prd', author={user_name})."
|
||||
# "Mirror the PRD to Notion via notion:create_page (database_id='abc123', title='PRD: '+{project_name})."
|
||||
# "When the PRD references a parent initiative, link via corp:jira_link on the epic key in frontmatter."
|
||||
external_handoffs = []
|
||||
|
||||
# --- Finalize reviewers ---
|
||||
# Reviewers spawned at Finalize step 3 (and at the Validate intent) alongside
|
||||
# the structural checklist validator. The authoring skill assembles the gate
|
||||
# menu (validator + these reviewers + any ad-hoc reviewers it judges warranted
|
||||
# by the artifact content) and lets the user pick all, a subset, or skip. Gate
|
||||
# UX is stakes-calibrated: hobby/solo scope may run defaults quietly or skip;
|
||||
# higher stakes get the explicit menu.
|
||||
#
|
||||
# Entries follow the standard prefix convention (same as persistent_facts and
|
||||
# doc_standards):
|
||||
# "skill:NAME" invoke the named review skill as a subagent against prd.md
|
||||
# "file:PATH" load the file as a review prompt; spawn an adversarial
|
||||
# subagent applying that prompt to prd.md
|
||||
# plain text use the text directly as the subagent's review prompt
|
||||
#
|
||||
# Override TOML may append additional reviewers. Arrays append per BMad rules.
|
||||
#
|
||||
# Resolved on-demand by the authoring skill (not pulled at activation): only
|
||||
# when entering the Validate intent or assembling the gate at Finalize step 3.
|
||||
finalize_reviewers = []
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
# PRD Facilitation Guide
|
||||
|
||||
Per-section conversation techniques for facilitative mode. Each entry names the coaching move that makes the section's conversation productive — not a checklist, a posture. Skip sections the PM has already resolved; spend more time where thinking is thin.
|
||||
|
||||
---
|
||||
|
||||
## Users and Personas
|
||||
|
||||
**The move:** Ground personas in real people, not archetypes.
|
||||
|
||||
Ask the PM to describe a specific person they have observed or talked to — not a type, an actual human. "Who is the clerk at your store? Tell me about them." Invented detail (name, age, backstory from nowhere) is persona theater — the team builds for a fiction. If the PM says "someone like..." push gently: "Is there a real person you're thinking of?"
|
||||
|
||||
Once grounded: what does that person want to accomplish in the time they interact with this product? What would make them say this is easier than what they do today? What would make them abandon it?
|
||||
|
||||
For the remote user or secondary persona: same grounding, different question — what question do they need answered in under ten seconds, and what do they do if they can't get it?
|
||||
|
||||
Mark anything the PM could not ground in observation as `[ILLUSTRATIVE]` — and note it's a hypothesis to validate, not a spec to build for.
|
||||
|
||||
---
|
||||
|
||||
## Core User Journeys
|
||||
|
||||
**The move:** Story structure, not use-case list.
|
||||
|
||||
For each primary journey, walk through four beats:
|
||||
|
||||
- **Opening scene** — where do we meet this person, what is their situation right now, what pain or need is present?
|
||||
- **Rising action** — what steps do they take, what do they discover or decide along the way?
|
||||
- **Climax** — the moment the product delivers real value; the thing they could not do before
|
||||
- **Resolution** — what is their new reality; how is their situation different?
|
||||
|
||||
After each journey: what could go wrong at the climax? What is the recovery path? This is where edge cases that matter surface — not invented error states, but real failure modes for this person.
|
||||
|
||||
Explicitly name what capability each journey reveals. "This journey requires the operator to log an entry with no internet — which means we need a decision on whether that's in or out of MVP." Journeys produce capability requirements; make the link visible.
|
||||
|
||||
---
|
||||
|
||||
## Key Feature Decisions
|
||||
|
||||
**The move:** Surface the assumptions that would otherwise be silent.
|
||||
|
||||
Before the draft exists, there are decisions the agent would silently make and the PM would never know were made. These are the ones worth a thirty-second conversation:
|
||||
|
||||
- Decisions that drive the core UX model (e.g., one record per day vs. many; who can edit vs. view; what happens when the expected input doesn't exist)
|
||||
- Decisions where the "obvious" choice has real consequences the PM may not have considered
|
||||
- Decisions that, if wrong, require structural changes to fix later
|
||||
|
||||
For each: state what you inferred, name the alternative, ask which is right. Do not present options as a quiz — present your inference and invite correction. "I'm assuming one sales tally per day replaces rather than adds. Is that right, or should the operator be able to log multiple?" Resolve and move on. Only tag as `[ASSUMPTION]` when the answer requires external input or research the PM cannot provide now.
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundary
|
||||
|
||||
**The move:** Establish MVP philosophy before listing features.
|
||||
|
||||
Before asking what is in or out, ask what kind of MVP this is:
|
||||
|
||||
- **Problem-solving MVP** — the minimum that proves the core problem is solved; rough edges acceptable
|
||||
- **Experience MVP** — the minimum that proves the interaction model works; quality matters
|
||||
- **Platform MVP** — the minimum infrastructure other things can build on; completeness of the base matters
|
||||
- **Revenue MVP** — the minimum someone will pay for; business viability is the test
|
||||
|
||||
The answer changes what "minimum" means. A problem-solving MVP for a personal-use tool has different scope logic than an experience MVP aimed at non-tech-savvy users who will bounce at the first confusion.
|
||||
|
||||
Once the philosophy is named, non-goals do as much work as in-scope items. Probe for the things the PM is tempted to add. "What keeps almost making it onto the list?" For each: is it truly out of MVP, or does it need to be in because the MVP fails without it?
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
**The move:** Push every adjective to a measurement.
|
||||
|
||||
"Users will love it" — what does that mean in behavior? "It'll be fast" — fast at what, for whom, measured how? "Good adoption" — what percentage, by when, doing what? Every quality claim needs a measurement or it is not a success criterion, it is a wish.
|
||||
|
||||
For each metric surfaced: connect it back to the product's differentiator. If the differentiator is simplicity for non-tech users, the primary metric should measure whether non-tech users successfully complete the core action without help — not session count or feature usage breadth.
|
||||
|
||||
Name counter-metrics explicitly — what this product should *not* optimize for. These prevent the wrong thing being built: more entries per day is not better if the goal is accurate daily records; longer dashboard sessions may indicate a broken IA, not high engagement. Counter-metrics are as load-bearing as primary metrics for downstream readers.
|
||||
|
||||
For low-stakes or personal-use products: one sentence is enough. "Success: I use this daily and it replaces the notebook within a month." Do not impose metric rigor where the stakes do not warrant it.
|
||||
|
|
@ -2,23 +2,38 @@
|
|||
|
||||
Load this file when bmad-prd is invoked headless (no interactive user). Follow it for the whole run.
|
||||
|
||||
## Detection
|
||||
|
||||
Headless mode is in effect when any of the following is true:
|
||||
|
||||
- the invoking caller sets a `headless: true` flag (or equivalent argument the harness exposes),
|
||||
- the invocation is from another skill or a non-interactive runner (no TTY, no user message stream),
|
||||
- `{workflow.activation_steps_prepend}` includes an entry that explicitly declares headless,
|
||||
- the first message comes from an automation context that pre-supplies all inputs and asks for an artifact path back.
|
||||
|
||||
When ambiguous, default to interactive.
|
||||
|
||||
## Inputs the caller is expected to provide
|
||||
|
||||
The caller passes inputs in their first message (free-form structured payload; no fixed schema, but every field below should be present when applicable):
|
||||
|
||||
- `intent` — `"create"`, `"update"`, or `"validate"`. If absent, infer from the artifact set.
|
||||
- For **Create**: a brief or product spec the LLM works from (plain text, file path, or URL), plus any persona/scope notes; `doc_workspace` if a specific run folder is required (otherwise the workflow binds the default).
|
||||
- For **Update**: the existing `prd.md` path (or a workspace path that contains one), and a change signal (the request: what to change and why).
|
||||
- For **Validate**: the existing `prd.md` path (or workspace path), and optionally a checklist override path. Workspace defaults to the PRD's containing directory.
|
||||
|
||||
Anything the caller does not provide is either inferred from inputs/workspace or recorded as `assumptions[]` / `open_questions[]` in the JSON status. Do not invent persona detail, success metrics, or scope decisions to fill gaps — record them.
|
||||
|
||||
## General
|
||||
|
||||
Do not ask. Complete the intent using what is provided, what exists in `{doc_workspace}`, or what you can discover yourself. If intent remains ambiguous after inference, halt with a `blocked` JSON status and a `reason` field — do not prompt. Do not greet.
|
||||
Do not ask. Complete the intent using what is provided, what exists in `{doc_workspace}`, or what you can discover yourself. If intent remains ambiguous after inference, halt with `status: "blocked"` and a `reason` field — do not prompt. Do not greet.
|
||||
|
||||
End with a JSON response listing status, intent, and artifact paths. The `intent` field must match the detected intent: `"create"`, `"update"`, or `"validate"`. Omit keys for artifacts not produced. Full schemas with examples for each intent are in `assets/headless-schemas.md`. Minimal shape:
|
||||
Populate `assumptions[]` with every value you inferred without direct caller confirmation; populate `open_questions[]` with every gap that needs a human decision. Use `status: "partial"` when the artifact was produced but `open_questions[]` is non-empty or critical inputs were inferred (Create with no brief; Update with a vague signal acted on best-effort; Validate that could not load the checklist). `complete` = stands on its own; `partial` = caller should review before downstream use; `blocked` = no artifact produced.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "complete",
|
||||
"intent": "validate",
|
||||
"validation_report": "{doc_workspace}/validation-report.md",
|
||||
"offer_to_update": true
|
||||
}
|
||||
```
|
||||
End with the JSON response (full schemas with examples in `assets/headless-schemas.md`). The `intent` field must match the detected intent. Omit keys for artifacts not produced.
|
||||
|
||||
## Mode-specific overrides
|
||||
|
||||
**Update.** Log the reversal to `decision-log.md`, then apply. Halt `blocked` if intent is ambiguous.
|
||||
**Update.** Apply the change, log to `.decision-log.md` with rationale, and surface any conflict-with-prior-decision in `conflicts_with_prior_decisions[]` in the JSON status. Halt `blocked` if intent is ambiguous.
|
||||
|
||||
**Validate.** Always write `validation-report.md` to `{doc_workspace}` regardless of finding count. Always include `"offer_to_update": true` in the JSON status block.
|
||||
**Validate.** Always write both `validation-report.html` and `validation-report.md` to `{doc_workspace}` regardless of finding count. Always include `"offer_to_update": true` in the JSON status. Skip the browser-open step in `references/validate.md` — write the artifacts and return.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
# Validate
|
||||
|
||||
The Validate intent playbook. Standalone — this intent critiques an existing PRD without changing it and ends after the user has seen the report; it does not run Finalize. The synthesis pipeline below is also reused for mid-session report requests during Create/Update.
|
||||
|
||||
## Orient
|
||||
|
||||
Source-extract against `.decision-log.md`, any original inputs, and the PRD/addendum themselves. Delegate to subagents per PRD Discipline → "Extract, don't ingest" (in SKILL.md); the parent assembles from extracts.
|
||||
|
||||
## Run the Reviewer Gate
|
||||
|
||||
Run the Reviewer Gate (see SKILL.md) against `prd.md` (and `addendum.md` if present). The rubric walker is the default entry in the gate menu; under Validate intent it additionally runs the synthesis pipeline below. The Finalize discipline pass during Create/Update does NOT render a report — findings stay in-conversation.
|
||||
|
||||
## Rubric-walker pipeline
|
||||
|
||||
The rubric walker is the primary review entry. Spawn it as a subagent with this prompt:
|
||||
|
||||
> You are validating a PRD against the quality rubric at `{workflow.validation_checklist_template}`. Read the full rubric first, then read `prd.md` (and `addendum.md` if present). Form a judgment per dimension — *strong / adequate / thin / broken* — and write findings only where they add information. Cite specific PRD locations and quote phrases. Severity ranks impact on the PRD's usefulness, not how easy the fix is. Write your review to `{doc_workspace}/review-rubric.md` in the format the rubric specifies. Return ONLY a compact summary (overall verdict, dimension verdicts, finding counts by severity, file path).
|
||||
|
||||
The Reviewer Gate may also dispatch additional reviewers from `{workflow.finalize_reviewers}` (adversarial-general by default) and any ad-hoc reviewers the parent judges warranted. Each writes its review to `{doc_workspace}/review-{slug}.md` and returns a compact summary. Run in parallel.
|
||||
|
||||
## Synthesis pipeline
|
||||
|
||||
Once every selected reviewer has returned, the parent synthesizes one consolidated report. **Do not skip this step under Validate intent** — it produces the persistent artifact the user opens.
|
||||
|
||||
### Inputs
|
||||
|
||||
- `{doc_workspace}/review-rubric.md` — primary, structured by the seven dimensions
|
||||
- Zero or more `{doc_workspace}/review-{slug}.md` files — extra reviewers (adversarial, etc.)
|
||||
- `{workflow.validation_report_template}` — the HTML skeleton
|
||||
|
||||
### What the synthesis pass does
|
||||
|
||||
1. Read every reviewer file in `{doc_workspace}/review-*.md`.
|
||||
2. Fill the HTML skeleton:
|
||||
- **Header.** PRD name, path. Grade derived from the rubric verdicts and severity counts: *Excellent* = all dimensions strong/adequate, no high/critical findings · *Good* = ≤1 thin dimension, no critical findings · *Fair* = multiple thin dimensions or any high finding · *Poor* = any broken dimension or any critical finding. Set the matching `grade-excellent | grade-good | grade-fair | grade-poor` class.
|
||||
- **Synthesis block.** Lift the rubric's *Overall verdict* paragraph as the lead; if adversarial or ad-hoc reviewers materially shift the picture, add a second paragraph that names what they surfaced.
|
||||
- **Dimension summary cards.** One per dimension that was assessed. Colored verdict text. Skip dimensions the rubric marked n/a for this PRD (e.g. downstream usability for a standalone PRD).
|
||||
- **Dimension sections.** One `<section class="dimension">` per assessed dimension, in rubric order. `<details open>` for *thin* and *broken*; closed for *strong* and *adequate*. Each contains the dimension judgment (the prose from review-rubric.md) and the findings list.
|
||||
- **Reviewer sections.** One `<section class="reviewer-section">` per extra reviewer that ran. The source file path goes in the `<span class="reviewer-source">`. Closed by default. Adversarial findings keep their adversarial voice — do not soften.
|
||||
- **Mechanical notes.** Bullet list from the rubric's "Mechanical notes" section. Skip the block if empty.
|
||||
- **Footer.** Rubric path, ISO timestamp.
|
||||
3. Write the filled HTML to `{doc_workspace}/validation-report.html`.
|
||||
4. Write the markdown twin to `{doc_workspace}/validation-report.md` (same content, grouped by severity rather than by dimension — see format below; this is the canonical form for downstream re-reading).
|
||||
5. Open the HTML in the default browser:
|
||||
```bash
|
||||
python3 -c "import webbrowser, pathlib; webbrowser.open(pathlib.Path('{doc_workspace}/validation-report.html').resolve().as_uri())"
|
||||
```
|
||||
Skip the open step in headless mode (see `references/headless.md`).
|
||||
|
||||
### Markdown twin format
|
||||
|
||||
```markdown
|
||||
# Validation Report — {prd_name}
|
||||
|
||||
- **PRD:** `{prd_path}`
|
||||
- **Rubric:** `{rubric_path}`
|
||||
- **Run at:** {ISO timestamp}
|
||||
- **Grade:** {Excellent | Good | Fair | Poor}
|
||||
|
||||
## Overall verdict
|
||||
{synthesis paragraphs}
|
||||
|
||||
## Dimension verdicts
|
||||
- Decision-readiness — {verdict}
|
||||
- Substance over theater — {verdict}
|
||||
- (etc. for each assessed dimension)
|
||||
|
||||
## Findings by severity
|
||||
|
||||
### Critical (n)
|
||||
**[Dimension or Reviewer]** — Title (§ location)
|
||||
{Note}
|
||||
Fix: {suggested fix}
|
||||
|
||||
### High (n)
|
||||
...
|
||||
|
||||
### Medium (n)
|
||||
...
|
||||
|
||||
### Low (n)
|
||||
...
|
||||
|
||||
## Mechanical notes
|
||||
- {bullet}
|
||||
|
||||
## Reviewer files
|
||||
- `review-rubric.md`
|
||||
- `review-adversarial-general.md` (if present)
|
||||
- (etc.)
|
||||
```
|
||||
|
||||
Re-running validation overwrites the consolidated report in place. The individual `review-*.md` files are preserved so the user can drill in.
|
||||
|
||||
## Close
|
||||
|
||||
Surface artifact paths; the rendered HTML/markdown is the persistent artifact. Always offer to roll findings into an Update.
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
# Validation Rendering
|
||||
|
||||
How the validator subagent's findings become a validation report. Loaded only when the user has explicitly asked for analysis — either Validate intent or a mid-session report request. The Finalize discipline pass during Create/Update does NOT render a report; its findings stay in-conversation.
|
||||
|
||||
## Validator subagent output contract
|
||||
|
||||
The subagent walks `{workflow.validation_checklist}` against `prd.md` (and `addendum.md` if present) and writes `{doc_workspace}/validation-findings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"prd_name": "Plantsona",
|
||||
"prd_path": "{doc_workspace}/prd.md",
|
||||
"checklist_path": "{workflow.validation_checklist}",
|
||||
"timestamp": "2026-05-11T09:14:00",
|
||||
"overall_synthesis": "2-3 sentences of judgment about the PRD's overall state — what holds up, what's at risk. Written by the subagent, not the parent.",
|
||||
"findings": [
|
||||
{
|
||||
"id": "Q-2",
|
||||
"category": "Quality",
|
||||
"title": "Measurability",
|
||||
"status": "warn",
|
||||
"severity": "medium",
|
||||
"location": "§16 Success Metrics, lines 408-422",
|
||||
"note": "Success Metrics list is measurable but counter-metrics are named only for premium conversion. Other metrics lack paired counter-metrics.",
|
||||
"suggested_fix": "Add counter-metrics for engagement (e.g., DAU/MAU) and seasonal cadence."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Per-finding fields:
|
||||
|
||||
- `id` (required) — checklist item ID (e.g., `Q-1`, `D-2`, `STK-1`, or org-custom prefixes).
|
||||
- `category` (optional) — explicit category name; if omitted, the renderer maps from the ID prefix.
|
||||
- `title` (optional but recommended) — the checklist item's short name.
|
||||
- `status` — `pass` | `warn` | `fail` | `n/a`.
|
||||
- `severity` — `low` | `medium` | `high` | `critical`.
|
||||
- `location` (optional) — section/line/range in the PRD where the finding lives. Cite specifics, never abstract criticism.
|
||||
- `note` (optional) — the finding itself, in one or two sentences.
|
||||
- `suggested_fix` (optional) — concrete next action.
|
||||
|
||||
## Rendering invocation
|
||||
|
||||
After the subagent writes findings:
|
||||
|
||||
```bash
|
||||
python3 {skill-root}/scripts/render-validation-html.py \
|
||||
--findings {doc_workspace}/validation-findings.json \
|
||||
--template {workflow.validation_report_template} \
|
||||
--output {doc_workspace}/validation-report.html \
|
||||
--open
|
||||
```
|
||||
|
||||
Include `--open` for interactive runs (auto-opens in default browser). Omit `--open` in headless runs.
|
||||
|
||||
The script writes two artifacts side-by-side: the HTML report at `--output`, and a markdown companion at the same path with `.md` extension (e.g. `validation-report.md`). Both are always produced when the script runs — trigger gating happens upstream (the script is only invoked when the user has asked for analysis). It computes pass/warn/fail/na counts, derives a grade (Excellent / Good / Fair / Poor) from critical-fail and total-fail counts, renders an inline SVG score bar in the HTML, groups findings by category, and returns a one-line JSON summary on stdout: `{"output": "...", "markdown": "...", "grade": "...", "stats": {...}}`.
|
||||
|
||||
Re-running validation overwrites the existing report files in place. Markdown form is what Update mode reads when rolling findings into a revision.
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# ///
|
||||
"""Render a PRD validation findings JSON into HTML + markdown reports.
|
||||
|
||||
Reads structured findings produced by the validator subagent, groups them by
|
||||
category (explicit `category` field, else derived from ID prefix), computes a
|
||||
pass/warn/fail summary and grade, substitutes into the configured HTML
|
||||
template, writes a markdown companion at the same path with `.md` extension,
|
||||
and optionally opens the HTML in the default browser.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import string
|
||||
import sys
|
||||
import webbrowser
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
CATEGORY_FROM_PREFIX = {
|
||||
"Q": "Quality",
|
||||
"D": "Discipline",
|
||||
"S": "Structural integrity",
|
||||
"STK": "Stakes-gated",
|
||||
"M": "Mechanical",
|
||||
}
|
||||
|
||||
CATEGORY_ORDER = ["Quality", "Discipline", "Structural integrity", "Stakes-gated", "Mechanical"]
|
||||
|
||||
|
||||
def category_for(finding: dict) -> str:
|
||||
explicit = finding.get("category")
|
||||
if explicit:
|
||||
return explicit
|
||||
fid = finding.get("id", "")
|
||||
prefix = fid.split("-", 1)[0] if "-" in fid else fid
|
||||
return CATEGORY_FROM_PREFIX.get(prefix, prefix or "Other")
|
||||
|
||||
|
||||
def compute_stats(findings: list[dict]) -> dict:
|
||||
total = len(findings)
|
||||
by_status = {"pass": 0, "warn": 0, "fail": 0, "n/a": 0}
|
||||
failed_critical = 0
|
||||
failed_high = 0
|
||||
for f in findings:
|
||||
status = (f.get("status") or "n/a").lower()
|
||||
if status in by_status:
|
||||
by_status[status] += 1
|
||||
if status == "fail":
|
||||
sev = (f.get("severity") or "low").lower()
|
||||
if sev == "critical":
|
||||
failed_critical += 1
|
||||
elif sev == "high":
|
||||
failed_high += 1
|
||||
return {
|
||||
"total": total,
|
||||
"passed": by_status["pass"],
|
||||
"warned": by_status["warn"],
|
||||
"failed": by_status["fail"],
|
||||
"na": by_status["n/a"],
|
||||
"failed_critical": failed_critical,
|
||||
"failed_high": failed_high,
|
||||
}
|
||||
|
||||
|
||||
def grade_from(stats: dict) -> tuple[str, str]:
|
||||
if stats["failed_critical"] > 0:
|
||||
return "Poor", "grade-poor"
|
||||
if stats["failed_high"] >= 1 or stats["failed"] >= 4:
|
||||
return "Fair", "grade-fair"
|
||||
if stats["failed"] > 0 or stats["warned"] > 2:
|
||||
return "Good", "grade-good"
|
||||
return "Excellent", "grade-excellent"
|
||||
|
||||
|
||||
def render_score_bar(stats: dict, width: int = 480, height: int = 22) -> str:
|
||||
total = max(stats["total"], 1)
|
||||
p = stats["passed"] / total * width
|
||||
w = stats["warned"] / total * width
|
||||
f = stats["failed"] / total * width
|
||||
n = stats["na"] / total * width
|
||||
return (
|
||||
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" '
|
||||
f'aria-label="Pass / warn / fail / n-a breakdown">'
|
||||
f'<rect x="0" y="0" width="{p:.1f}" height="{height}" fill="#22c55e"/>'
|
||||
f'<rect x="{p:.1f}" y="0" width="{w:.1f}" height="{height}" fill="#eab308"/>'
|
||||
f'<rect x="{p + w:.1f}" y="0" width="{f:.1f}" height="{height}" fill="#ef4444"/>'
|
||||
f'<rect x="{p + w + f:.1f}" y="0" width="{n:.1f}" height="{height}" fill="#94a3b8"/>'
|
||||
f"</svg>"
|
||||
)
|
||||
|
||||
|
||||
def render_finding(f: dict) -> str:
|
||||
status = (f.get("status") or "n/a").lower()
|
||||
severity = (f.get("severity") or "low").lower()
|
||||
fid = html.escape(f.get("id") or "")
|
||||
title = html.escape(f.get("title") or fid)
|
||||
location = html.escape(f.get("location") or "")
|
||||
note = html.escape(f.get("note") or "")
|
||||
fix = html.escape(f.get("suggested_fix") or "")
|
||||
|
||||
status_class = "na" if status == "n/a" else status
|
||||
parts = [
|
||||
f'<article class="finding finding-{status_class}">',
|
||||
'<header>',
|
||||
f'<span class="badge badge-status badge-{status_class}">{status.upper()}</span>',
|
||||
f'<span class="badge badge-severity badge-sev-{severity}">{severity}</span>',
|
||||
f'<span class="finding-id">{fid}</span>',
|
||||
f'<h3 class="finding-title">{title}</h3>',
|
||||
'</header>',
|
||||
]
|
||||
if location:
|
||||
parts.append(f'<div class="finding-location"><strong>Location:</strong> {location}</div>')
|
||||
if note:
|
||||
parts.append(f'<div class="finding-note">{note}</div>')
|
||||
if fix:
|
||||
parts.append(f'<div class="finding-fix"><strong>Suggested fix:</strong> {fix}</div>')
|
||||
parts.append("</article>")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def render_category(name: str, findings: list[dict]) -> str:
|
||||
items = "\n".join(render_finding(f) for f in findings)
|
||||
name_e = html.escape(name)
|
||||
return (
|
||||
f'<section class="category">'
|
||||
f"<details open>"
|
||||
f'<summary><h2>{name_e} <span class="count">({len(findings)})</span></h2></summary>'
|
||||
f"{items}"
|
||||
f"</details>"
|
||||
f"</section>"
|
||||
)
|
||||
|
||||
|
||||
SEVERITY_ORDER = ["critical", "high", "medium", "low"]
|
||||
|
||||
|
||||
def render_finding_md(f: dict) -> str:
|
||||
status = (f.get("status") or "n/a").upper()
|
||||
severity = (f.get("severity") or "low").lower()
|
||||
fid = f.get("id") or ""
|
||||
title = f.get("title") or fid
|
||||
location = f.get("location") or ""
|
||||
note = f.get("note") or ""
|
||||
fix = f.get("suggested_fix") or ""
|
||||
|
||||
lines = [f"### [{status}] {fid} — {title} _(severity: {severity})_"]
|
||||
if location:
|
||||
lines.append(f"- **Location:** {location}")
|
||||
if note:
|
||||
lines.append(f"- **Finding:** {note}")
|
||||
if fix:
|
||||
lines.append(f"- **Suggested fix:** {fix}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_markdown_report(data: dict, findings: list[dict], stats: dict, grade: str) -> str:
|
||||
prd_name = data.get("prd_name") or "PRD"
|
||||
prd_path = data.get("prd_path") or ""
|
||||
checklist_path = data.get("checklist_path") or ""
|
||||
timestamp = data.get("timestamp") or datetime.now().isoformat(timespec="seconds")
|
||||
synthesis = data.get("overall_synthesis") or ""
|
||||
|
||||
out = [
|
||||
f"# Validation Report — {prd_name}",
|
||||
"",
|
||||
f"- **PRD:** `{prd_path}`",
|
||||
f"- **Checklist:** `{checklist_path}`",
|
||||
f"- **Run at:** {timestamp}",
|
||||
f"- **Grade:** {grade}",
|
||||
"",
|
||||
f"**Summary:** {stats['passed']} pass · {stats['warned']} warn · {stats['failed']} fail · {stats['na']} n/a "
|
||||
f"(total {stats['total']}; critical fails: {stats['failed_critical']}, high fails: {stats['failed_high']})",
|
||||
]
|
||||
if synthesis:
|
||||
out += ["", "## Overall synthesis", "", synthesis]
|
||||
|
||||
# Group by severity then status: failed criticals first, then highs, etc.
|
||||
by_sev: dict[str, list[dict]] = {s: [] for s in SEVERITY_ORDER}
|
||||
other: list[dict] = []
|
||||
for f in findings:
|
||||
sev = (f.get("severity") or "low").lower()
|
||||
if sev in by_sev:
|
||||
by_sev[sev].append(f)
|
||||
else:
|
||||
other.append(f)
|
||||
|
||||
out += ["", "## Findings by severity"]
|
||||
any_findings = False
|
||||
for sev in SEVERITY_ORDER:
|
||||
items = by_sev[sev]
|
||||
if not items:
|
||||
continue
|
||||
any_findings = True
|
||||
out += ["", f"### {sev.capitalize()} ({len(items)})", ""]
|
||||
out += [render_finding_md(f) for f in items]
|
||||
if other:
|
||||
any_findings = True
|
||||
out += ["", f"### Other ({len(other)})", ""]
|
||||
out += [render_finding_md(f) for f in other]
|
||||
if not any_findings:
|
||||
out += ["", "_No findings._"]
|
||||
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(description="Render PRD validation findings to HTML.")
|
||||
parser.add_argument("--findings", required=True, help="Path to validation-findings.json")
|
||||
parser.add_argument("--template", required=True, help="Path to HTML template")
|
||||
parser.add_argument("--output", required=True, help="Path to write the rendered HTML")
|
||||
parser.add_argument("--open", action="store_true", help="Open the rendered HTML in the default browser")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
findings_path = Path(args.findings)
|
||||
template_path = Path(args.template)
|
||||
output_path = Path(args.output)
|
||||
|
||||
try:
|
||||
data = json.loads(findings_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
print(f"error: findings file not found: {findings_path}", file=sys.stderr)
|
||||
return 1
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"error: findings file is not valid JSON ({findings_path}): {e}", file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
template = template_path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
print(f"error: template file not found: {template_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
findings = data.get("findings", []) or []
|
||||
|
||||
by_cat: dict[str, list[dict]] = {}
|
||||
for f in findings:
|
||||
by_cat.setdefault(category_for(f), []).append(f)
|
||||
|
||||
sorted_cats = sorted(
|
||||
by_cat.keys(),
|
||||
key=lambda c: (CATEGORY_ORDER.index(c) if c in CATEGORY_ORDER else 99, c),
|
||||
)
|
||||
categories_html = "\n".join(render_category(c, by_cat[c]) for c in sorted_cats)
|
||||
|
||||
stats = compute_stats(findings)
|
||||
grade, grade_class = grade_from(stats)
|
||||
score_svg = render_score_bar(stats)
|
||||
|
||||
timestamp = data.get("timestamp") or datetime.now().isoformat(timespec="seconds")
|
||||
substitutions = {
|
||||
"prd_name": html.escape(str(data.get("prd_name") or "PRD")),
|
||||
"prd_path": html.escape(str(data.get("prd_path") or "")),
|
||||
"checklist_path": html.escape(str(data.get("checklist_path") or "")),
|
||||
"timestamp": html.escape(timestamp),
|
||||
"overall_synthesis": html.escape(str(data.get("overall_synthesis") or "")),
|
||||
"grade": grade,
|
||||
"grade_class": grade_class,
|
||||
"total": str(stats["total"]),
|
||||
"passed": str(stats["passed"]),
|
||||
"failed": str(stats["failed"]),
|
||||
"warned": str(stats["warned"]),
|
||||
"na": str(stats["na"]),
|
||||
"score_svg": score_svg,
|
||||
"categories_html": categories_html,
|
||||
}
|
||||
|
||||
rendered = string.Template(template).safe_substitute(substitutions)
|
||||
output_path.write_text(rendered, encoding="utf-8")
|
||||
|
||||
md_path = output_path.with_suffix(".md")
|
||||
md_path.write_text(render_markdown_report(data, findings, stats, grade), encoding="utf-8")
|
||||
|
||||
print(json.dumps({
|
||||
"output": str(output_path),
|
||||
"markdown": str(md_path),
|
||||
"grade": grade,
|
||||
"stats": stats,
|
||||
}))
|
||||
|
||||
if args.open:
|
||||
webbrowser.open(output_path.resolve().as_uri())
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 || ''),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -10,6 +10,9 @@
|
|||
let _clack = null;
|
||||
let _clackCore = null;
|
||||
let _picocolors = null;
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
/**
|
||||
* Lazy-load @clack/prompts (ESM module)
|
||||
|
|
@ -575,6 +578,151 @@ async function autocomplete(options) {
|
|||
return result;
|
||||
}
|
||||
|
||||
function hasPathSeparator(value) {
|
||||
return value.endsWith('/') || value.endsWith('\\');
|
||||
}
|
||||
|
||||
function expandHome(input) {
|
||||
if (!input) return input;
|
||||
if (input === '~') return os.homedir();
|
||||
if (input.startsWith('~/') || input.startsWith('~\\')) {
|
||||
return path.join(os.homedir(), input.slice(2));
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function toDirectoryOption(value, label = value, synthetic = false) {
|
||||
return { value, label, synthetic };
|
||||
}
|
||||
|
||||
function isExistingDirectory(value) {
|
||||
try {
|
||||
return fs.existsSync(value) && fs.statSync(value).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function listDirectoryOptions(input, options) {
|
||||
const cwd = options.cwd || process.cwd();
|
||||
const rawInput = input.trim();
|
||||
const expandedInput = expandHome(rawInput);
|
||||
const trailingSep = hasPathSeparator(rawInput) || hasPathSeparator(expandedInput);
|
||||
const resolvedInput = expandedInput ? path.resolve(cwd, expandedInput) : cwd;
|
||||
const browseDir = expandedInput && !trailingSep && !isExistingDirectory(resolvedInput) ? path.dirname(resolvedInput) : resolvedInput;
|
||||
const prefix = expandedInput && browseDir !== resolvedInput ? path.basename(resolvedInput).toLowerCase() : '';
|
||||
const results = [];
|
||||
|
||||
if (!trailingSep && isExistingDirectory(resolvedInput)) {
|
||||
results.push(toDirectoryOption(resolvedInput, `. (use this directory)`));
|
||||
}
|
||||
|
||||
if (isExistingDirectory(browseDir)) {
|
||||
try {
|
||||
for (const entry of fs.readdirSync(browseDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (prefix && !entry.name.toLowerCase().startsWith(prefix)) continue;
|
||||
const fullPath = path.join(browseDir, entry.name);
|
||||
if (!results.some((option) => option.value === fullPath)) {
|
||||
results.push(toDirectoryOption(fullPath));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable directories; validation still reports path issues.
|
||||
}
|
||||
}
|
||||
|
||||
const validation = options.validate?.(rawInput);
|
||||
const hasMatchingOption = results.some((option) => option.value === resolvedInput);
|
||||
if (expandedInput && !validation && !hasMatchingOption) {
|
||||
results.unshift(toDirectoryOption(resolvedInput, `Create/use: ${resolvedInput}`, true));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory prompt with autocomplete candidates and create-directory support.
|
||||
* Uses @clack/core directly so typed paths that do not exist yet can still be
|
||||
* submitted when validation allows creating them.
|
||||
* @param {Object} options - Prompt options
|
||||
* @param {string} options.message - Prompt message
|
||||
* @param {string} [options.default] - Default directory
|
||||
* @param {string} [options.placeholder] - Placeholder text
|
||||
* @param {Function} [options.validate] - Sync validation function
|
||||
* @returns {Promise<string>} Selected or typed directory path
|
||||
*/
|
||||
async function directory(options) {
|
||||
const core = await getClackCore();
|
||||
const color = await getPicocolors();
|
||||
const tabCompletion = {
|
||||
prefix: '',
|
||||
index: -1,
|
||||
options: [],
|
||||
lastValue: '',
|
||||
};
|
||||
|
||||
let prompt;
|
||||
prompt = new core.AutocompletePrompt({
|
||||
initialValue: options.default,
|
||||
options: () => listDirectoryOptions(prompt?.userInput || '', options),
|
||||
filter: () => true,
|
||||
validate: (value) => options.validate?.(value ?? prompt.userInput),
|
||||
render() {
|
||||
const title = `${color.gray('◆')} ${options.message}`;
|
||||
const bar = color.gray('│');
|
||||
const barEnd = color.gray('└');
|
||||
const userInput = this.userInput;
|
||||
const placeholder = options.placeholder || options.default;
|
||||
const inputDisplay = userInput ? this.userInputWithCursor : `${color.inverse(color.hidden('_'))}${color.dim(placeholder || '')}`;
|
||||
const errorLine = this.state === 'error' ? [`${color.yellow('│')} ${color.yellow(this.error)}`] : [];
|
||||
|
||||
switch (this.state) {
|
||||
case 'submit': {
|
||||
return `${color.gray('◇')} ${options.message}\n${bar} ${color.dim(this.value || '')}`;
|
||||
}
|
||||
case 'cancel': {
|
||||
return `${color.gray('◇')} ${options.message}\n${bar} ${color.strikethrough(color.dim(userInput || ''))}`;
|
||||
}
|
||||
default: {
|
||||
return [title, `${bar} ${inputDisplay}`, ...errorLine, barEnd].join('\n');
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const hasSetUserInput = typeof prompt._setUserInput === 'function';
|
||||
const hasClearUserInput = typeof prompt._clearUserInput === 'function';
|
||||
|
||||
prompt.on('key', (_, key) => {
|
||||
if (key?.name !== 'tab') return;
|
||||
if (!hasSetUserInput) return; // @clack/core API surface changed — skip Tab silently.
|
||||
const currentInput = prompt.userInput;
|
||||
const isContinuingCycle = tabCompletion.lastValue && currentInput === tabCompletion.lastValue;
|
||||
const completionOptions = isContinuingCycle ? tabCompletion.options : prompt.filteredOptions.filter((option) => !option.synthetic);
|
||||
if (completionOptions.length === 0) return;
|
||||
|
||||
if (isContinuingCycle) {
|
||||
tabCompletion.index = (tabCompletion.index + 1) % completionOptions.length;
|
||||
} else {
|
||||
tabCompletion.prefix = currentInput;
|
||||
tabCompletion.options = completionOptions;
|
||||
tabCompletion.index = 0;
|
||||
}
|
||||
|
||||
const focusedOption = completionOptions[tabCompletion.index];
|
||||
if (!focusedOption) return;
|
||||
const completedValue = focusedOption.value;
|
||||
tabCompletion.lastValue = completedValue;
|
||||
if (hasClearUserInput) prompt._clearUserInput();
|
||||
prompt._setUserInput(completedValue, true);
|
||||
});
|
||||
|
||||
const result = await prompt.prompt();
|
||||
await handleCancel(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color utility (picocolors instance from @clack/prompts)
|
||||
* @returns {Promise<Object>} The color utility (picocolors)
|
||||
|
|
@ -694,6 +842,7 @@ module.exports = {
|
|||
multiselect,
|
||||
autocompleteMultiselect,
|
||||
autocomplete,
|
||||
directory,
|
||||
confirm,
|
||||
text,
|
||||
password,
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
@ -1436,7 +1262,7 @@ class UI {
|
|||
*/
|
||||
async promptForDirectory() {
|
||||
// Use sync validation because @clack/prompts doesn't support async validate
|
||||
const directory = await prompts.text({
|
||||
const directory = await prompts.directory({
|
||||
message: 'Installation directory:',
|
||||
default: process.cwd(),
|
||||
placeholder: process.cwd(),
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue