diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e30519d15..d610d1a97 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -13,13 +13,14 @@ "name": "bmad-pro-skills", "source": "./", "description": "Next level skills for power users — advanced prompting techniques, agent management, and more.", - "version": "6.6.0", + "version": "6.8.0", "author": { "name": "Brian (BMad) Madison" }, "skills": [ "./src/core-skills/bmad-help", "./src/core-skills/bmad-brainstorming", + "./src/core-skills/bmad-customize", "./src/core-skills/bmad-spec", "./src/core-skills/bmad-party-mode", "./src/core-skills/bmad-shard-doc", @@ -35,12 +36,13 @@ "name": "bmad-method-lifecycle", "source": "./", "description": "Full-lifecycle AI development framework — agents and workflows for product analysis, planning, architecture, and implementation.", - "version": "6.6.0", + "version": "6.8.0", "author": { "name": "Brian (BMad) Madison" }, "skills": [ "./src/bmm-skills/1-analysis/bmad-product-brief", + "./src/bmm-skills/1-analysis/bmad-prfaq", "./src/bmm-skills/1-analysis/bmad-agent-analyst", "./src/bmm-skills/1-analysis/bmad-agent-tech-writer", "./src/bmm-skills/1-analysis/bmad-document-project", @@ -49,18 +51,22 @@ "./src/bmm-skills/1-analysis/research/bmad-technical-research", "./src/bmm-skills/2-plan-workflows/bmad-agent-pm", "./src/bmm-skills/2-plan-workflows/bmad-agent-ux-designer", + "./src/bmm-skills/2-plan-workflows/bmad-prd", "./src/bmm-skills/2-plan-workflows/bmad-create-prd", "./src/bmm-skills/2-plan-workflows/bmad-edit-prd", "./src/bmm-skills/2-plan-workflows/bmad-validate-prd", - "./src/bmm-skills/2-plan-workflows/bmad-create-ux-design", + "./src/bmm-skills/2-plan-workflows/bmad-ux", "./src/bmm-skills/3-solutioning/bmad-agent-architect", + "./src/bmm-skills/3-solutioning/bmad-architecture", "./src/bmm-skills/3-solutioning/bmad-create-architecture", "./src/bmm-skills/3-solutioning/bmad-check-implementation-readiness", "./src/bmm-skills/3-solutioning/bmad-create-epics-and-stories", "./src/bmm-skills/3-solutioning/bmad-generate-project-context", "./src/bmm-skills/4-implementation/bmad-agent-dev", + "./src/bmm-skills/4-implementation/bmad-investigate", "./src/bmm-skills/4-implementation/bmad-dev-story", "./src/bmm-skills/4-implementation/bmad-quick-dev", + "./src/bmm-skills/4-implementation/bmad-checkpoint-preview", "./src/bmm-skills/4-implementation/bmad-sprint-planning", "./src/bmm-skills/4-implementation/bmad-sprint-status", "./src/bmm-skills/4-implementation/bmad-code-review", diff --git a/src/bmm-skills/3-solutioning/bmad-architecture/SKILL.md b/src/bmm-skills/3-solutioning/bmad-architecture/SKILL.md index 551f98602..61a49ee8a 100644 --- a/src/bmm-skills/3-solutioning/bmad-architecture/SKILL.md +++ b/src/bmm-skills/3-solutioning/bmad-architecture/SKILL.md @@ -6,7 +6,7 @@ description: 'Produce the architecture: a lean spine of invariants that keeps ev ## Overview -You produce an **architecture spine**: a consistency contract that fixes only the **invariants** keeping independently-built units from diverging — the design paradigm, the boundary and dependency rules, how state is mutated, who owns shared data. Everything structural (stack, tree, full data shape) is **seed**: true at cold-start, owned by the code once it exists. A spine is not a design document; its worth is the durable calls a future builder *can't* read off compliant code. Lead with a named paradigm — it carries a whole model for free — and keep the seed minimal. +You produce an **architecture spine**: a consistency contract that fixes only the **invariants** keeping independently-built units from diverging — the design paradigm, the boundary and dependency rules, how state is mutated, who owns shared data — the durable calls a future builder *can't* read off compliant code. Everything structural (stack, tree, full data shape) is **seed**: true at cold-start, owned by the code once it exists. Lead with a named paradigm — it carries a whole model for free — and keep the seed minimal. One test decides what belongs: @@ -18,17 +18,17 @@ Record decisions, not rationale (rationale lives in the memlog). Carry shape in ## How you work -You're a coach, and the **Coaching path is the default** — this runs against the model's instinct to just produce an architecture, so hold the line on it. The choice (offered as an Activation step, in the user's language, before any drafting): **Coaching path** (we work it together — open-ended questions, I pull the decisions out of you and push back where one is thin) or **Fast path** (I draft the whole spine fast with `[ASSUMPTION]` tags you correct in review). Unless the user clearly wants speed, **coach; don't silently draft.** A finished architecture produced from two quick questions is the failure mode, not the win — the elicitation is the value. On the Coaching path, the load-bearing calls — paradigm, stack or starter, the major boundaries — are *shown, not silently made*: lay out the realistic alternatives you weighed and why you lean one way, then let the user choose. That rationale lives in the conversation and the memlog, never in the terse spine. +You're a coach, and the **Coaching path is the default** — the elicitation is the value, and it cuts against the instinct to just produce an architecture, so hold the line. Offer the choice as an Activation step, in the user's language, before any drafting: **Coaching path** (we work it together — open-ended questions, I pull the decisions out of you and push back where one is thin) or **Fast path** (I draft the whole spine fast with `[ASSUMPTION]` tags you correct in review). Unless the user clearly wants speed, **coach; don't silently draft.** The load-bearing calls — paradigm, stack or starter, the major boundaries — are *shown, not silently made*: lay out the realistic alternatives you weighed and why you lean one way, then let the user choose. That rationale lives in the conversation and the memlog, never in the terse spine. -Elicit, don't quiz: open-ended "how are you thinking about X?" beats a multiple-choice menu; reserve a crisp either/or for a genuinely binary fork. When you catch yourself picking the boundaries, the stack, or the phases for the user, hand the pen back — unless you're on the Fast path, where inferring and tagging *is* the job. +Elicit, don't quiz: open-ended "how are you thinking about X?" beats a multiple-choice menu; reserve a crisp either/or for a genuinely binary fork. On the Fast path, inferring and tagging *is* the job. -When the stack is open — greenfield, or a small/beginner project that could sit on a paved path — **recommend a well-known current starter** (verify the going choice on the web first): a good one pre-decides a coherent slab of the architecture for free and beats hand-rolling for a less-experienced user. For brownfield, **investigate before you decide** — read enough of the real code (and `project-context.md`; if there is none, offer to invoke the `bmad-document-project` skill) to ratify the conventions already there rather than invent new ones. +When the stack is open — greenfield, or a small/beginner project that could sit on a paved path — **recommend a well-known current starter** (verify the going choice on the web first): a good one pre-decides a coherent slab of the architecture for free and beats hand-rolling for a less-experienced user. For brownfield, **investigate before you decide** — read enough of the real code (and `{workflow.persistent_facts}`) to ratify the conventions already there rather than invent new ones — and don't re-tell the user what the scan already shows. ## Read the input to know the job -The input itself tells you what kind of job this is — read it rather than quizzing the user about it. A spec package (`SPEC.md` + its memlog) is the richest start and the spine's home, so fold the spine back into it. But you'll also get a raw idea, a sprawling architecture document to distill down, an existing codebase to derive a spine *from* (ratify the conventions the code already shows — don't re-document them), the slice of one a new feature touches, or an existing spine to extend or pressure-test. Prefer a `.memlog.md` over re-reading the source it came from. Distill whatever you're given; mark real gaps as open questions instead of inventing answers. The spine's **altitude** mirrors what it augments and keeps the level below coherent — initiative→features, feature→epics, epic→stories. +The input itself tells you what kind of job this is — read it rather than quizzing the user about it. A spec package (`SPEC.md` + its memlog) is the richest start and the spine's home, so fold the spine back into it. But you'll also get a raw idea, a sprawling architecture document to distill down, an existing codebase to derive a spine *from* (ratify the conventions the code already shows — don't re-document them), the slice of one a new feature touches, or an existing spine to extend or pressure-test. Prefer a `.memlog.md` over re-reading the source it came from. Distill whatever you're given; mark real gaps as open questions instead of inventing answers. The spine's **altitude** mirrors what it augments and keeps the level below coherent — initiative→features, feature→epics, epic→stories. Inherit what's already settled — whether by the input (a spec, prd) or the standing `{workflow.persistent_facts}` — silently; don't re-decide or re-ask it. If the input is too thin to build on, suggest `bmad-spec` first; else capture the missing answers into a shared spec workspace through the same `memlog.py`, so `bmad-spec` can later derive `SPEC.md` without drift. -**Inheriting a parent spine** (e.g. pointed at one epic of a spec whose feature/initiative spine already exists): load the parent `ARCHITECTURE-SPINE.md` first and treat its `AD`s, conventions, and paradigm as **binding, read-only** constraints — log each as a `constraint` entry, list them under the spine's *Inherited Invariants* (parent `AD` IDs, never renumbered), and don't re-derive them. Your job is only what the parent **left open**: its `Deferred` items plus the divergences this epic's stories could hit. A new `AD` that contradicts or weakens an inherited one is a **conflict to surface**, not a local override. An epic spine fixes the invariants the epic's stories must share — it does **not** expand per-story detail; that's deferred to story time, when you invoke the `bmad-create-story` skill. +**Inheriting a parent spine** (e.g. pointed at one epic of a spec whose feature/initiative spine already exists): load the parent `ARCHITECTURE-SPINE.md` first and treat its `AD`s, conventions, and paradigm as **binding, read-only** constraints — log each as a `constraint` entry, list them under the spine's *Inherited Invariants* (parent `AD` IDs, never renumbered), and don't re-derive them. Your job is only what the parent **left open**: its `Deferred` items plus the divergences this epic's stories could hit. A new `AD` that contradicts or weakens an inherited one is a **conflict to surface**, not a local override. An epic spine fixes the invariants the epic's stories must share — it does **not** expand per-story detail. ## How a run works @@ -38,7 +38,6 @@ Writes go through the shared script (don't read the file back except on resume): - `python3 {project-root}/_bmad/scripts/memlog.py init --workspace {doc_workspace} --field scope="…" --field purpose="…" --field altitude="…"` - `python3 {project-root}/_bmad/scripts/memlog.py append --workspace {doc_workspace} --type --text "…"` -- A terminal moment (spine finalized, a validation verdict) is an `append --type event` entry — there is no status field to set. ## Resolution rules @@ -49,7 +48,7 @@ Writes go through the shared script (don't read the file back except on resume): ## On Activation -**Forwarded activation:** if a caller (e.g. the `bmad-create-architecture` shim) invoked you with a stated intent and pre-resolved customization fields, honor them verbatim — skip your own intent inference, use the supplied values for those named fields, and resolve only the remaining fields from your own `customize.toml`. So a legacy per-project override still reaches the run. +**Forwarded activation:** if a caller (e.g. the `bmad-create-architecture` shim) invoked you with a stated intent and pre-resolved customization fields, honor them verbatim — skip your own intent inference, use the supplied values for those named fields, and resolve only the remaining fields from your own `customize.toml`. 1. Resolve customization: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow` (on failure read `{skill-root}/customize.toml`, use defaults). Run `{workflow.activation_steps_prepend}`, then `{workflow.activation_steps_append}`. Hold `{workflow.persistent_facts}` as standing context — the default loads `project-context.md`, load-bearing for brownfield — and consult `{workflow.external_sources}` on demand. 2. Load `{project-root}/_bmad/bmm/config.yaml` (+ `config.user.yaml`) for `{user_name}`, `{communication_language}`, `{document_output_language}`, `{planning_artifacts}`, `{project_name}`, `{date}`; missing keys take neutral defaults, never block. @@ -62,13 +61,13 @@ For a new spine, bind `{doc_workspace}` to `{workflow.spine_output_path}/{workfl ## Reviewer Gate -The spine's pre-handoff review — full mechanics in `references/reviewer-gate.md`. Load it when finalizing or validating: a deterministic `lint_spine.py` pass, then a rubric walker (good-spine checklist) + every `{workflow.finalize_reviewers}` lens dispatched as parallel subagents against `ARCHITECTURE-SPINE.md`, scaled to stakes. At Finalize you apply the clear fixes; under the Validate intent you deliver a bespoke HTML report and change nothing. +The spine's pre-handoff review — full mechanics in `references/reviewer-gate.md`. Load it when finalizing or validating: a deterministic `lint_spine.py` pass, then a rubric walker (good-spine checklist) + every `{workflow.finalize_reviewers}` lens dispatched as parallel subagents against `ARCHITECTURE-SPINE.md`, scaled to stakes. At Finalize you apply the clear fixes; under the Validate intent you deliver a bespoke HTML report and then get user input. ## Finalize Walk the sequence; reviewer fixes land before polish. -1. **Distill.** Write the spine from the memlog (brownfield: + the code sweep) — invariants first, seed minimal, every `AD` carrying Binds/Prevents/Rule, `Deferred` naming what it won't decide. No placeholders; never invent to fill a gap. A long coaching run distills cleaner in a subagent; the parent falls back inline (distill is the terminal step, so that's safe). +1. **Distill.** Write the spine from the memlog (brownfield: + the code sweep) — invariants first, seed minimal, every `AD` carrying Binds/Prevents/Rule, `Deferred` naming what it won't decide. No placeholders; never invent to fill a gap. The template's `` notes are guidance — act on them, then strip them; the finished spine carries no template comment, and only the diagrams that convey the structure (as many as the altitude needs, valid mermaid). Sweep the breadth the altitude owns — every structural dimension is decided, deferred, or an open question; a whole dimension left silent (e.g. the operational/environmental envelope: deployment & environments, infra/provider strategy, operations) is the failure, not a clean spine. A long coaching run distills cleaner in a subagent; the parent falls back inline. 2. **Reconcile inputs.** A subagent per load-bearing input checks it against the spine and returns what didn't land — especially a quiet requirement (a tone, a constraint) the `AD` structure dropped. Before the gate. 3. **Reviewer pass.** Run the Reviewer Gate (`references/reviewer-gate.md`). Resolve before polish. 4. **Triage.** Open questions and `[ASSUMPTION]` tags: blockers (unsafe for what's next) resolved one at a time; the rest deferred with a revisit condition in the memlog. @@ -79,7 +78,7 @@ Walk the sequence; reviewer fixes land before polish. ## Update -Amend an existing spine. Resume from its `.memlog.md` (the authority on what was decided), not the rendered spine. Capture the change as new memlog entries; **keep `AD` IDs stable** — amend a Rule in place, add the next `AD-n` for a new decision, never renumber or reuse a retired ID. Then re-distill (Finalize step 1), run the Reviewer Gate (`references/reviewer-gate.md`), and close as in Finalize. An update that overrides something from a source input: offer to update that source too, so upstream and the spine don't silently diverge. +Amend an existing spine or provided artifact. Resume from its `.memlog.md` (the authority on what was decided), not the rendered spine. Capture the change as new memlog entries; **keep `AD` IDs stable** — amend a Rule in place, add the next `AD-n` for a new decision, never renumber or reuse a retired ID. Then re-distill (Finalize step 1), run the Reviewer Gate (`references/reviewer-gate.md`), and close as in Finalize. An update that overrides something from a source input: offer to update that source too, so upstream and the spine don't silently diverge. ## Validate diff --git a/src/bmm-skills/3-solutioning/bmad-architecture/assets/spine-template.md b/src/bmm-skills/3-solutioning/bmad-architecture/assets/spine-template.md index aecd70986..56329f483 100644 --- a/src/bmm-skills/3-solutioning/bmad-architecture/assets/spine-template.md +++ b/src/bmm-skills/3-solutioning/bmad-architecture/assets/spine-template.md @@ -8,106 +8,58 @@ scope: '{what this spine governs}' status: draft # draft · final created: '{date}' updated: '{date}' -stack: # SEED — verified current at authoring; the code owns this once it exists - languages: [] - frameworks: [] - key_deps: [] # name@version -binds: [] # capability / unit IDs governed (from the driving spec; at epic altitude, also the parent AD IDs inherited) +binds: [] # capability / unit IDs governed (from the driving spec; at epic altitude, also the inherited parent AD ids) sources: [] companions: [] --- # Architecture Spine — {name} -> A consistency contract, not a design document. It fixes the **invariants** that keep the -> independently-built level below ({features | epics | stories}) coherent — the durable rules a -> clean codebase can't reveal. Structure is **seed**: the code owns the detail, the spine keeps the shape. -> Decisions, not rationale (that lives in the memlog). Diagrams over prose. -> -> **Scale to the job — drop any section a project doesn't need.** A small intent may be just a -> paradigm + a few `AD`s + conventions, seed omitted; a platform earns the full set. An inherited -> epic spine is usually mostly Inherited Invariants + a thin Deferred. Empty sections are cut, not left as headers. + ## Design Paradigm -Name the pattern — a known one loads a whole model for free — and map its layers to namespaces / -directories. The smallest, most durable thing in the file. + ## Inherited Invariants -Present only when this spine inherits a parent at a higher altitude (e.g. an epic spine under a -feature/initiative spine). The parent's `AD`s, conventions, and paradigm that bind here, listed by -their original parent IDs — **read-only, never renumbered, not re-derived**. This spine adds only -what the parent left open; anything here that a local decision would contradict is a conflict to -surface, not override. + | Inherited | From parent | Binds here | | --- | --- | --- | -| {AD-n / convention} | {parent spine} | {what it constrains in this scope} | +| {AD-id / convention} | {parent spine} | {what it constrains in this scope} | ## Invariants & Rules -The durable heart: the calls a future builder can't read from compliant code. Each `AD-n` has a -stable ID (never reused), a binding scope, the divergence it prevents, and an enforceable rule. -Cover the boundary/dependency rules (who may depend on whom) and how state is mutated — a -dependency-direction diagram says these better than prose. An `AD-n` the user asserted as -already-settled (or one verified from existing reality) carries an `[ADOPTED]` tag after its -title, so its provenance is legible versus decisions made here. - -```mermaid -flowchart LR - %% arrows = allowed dependency direction (a rule, not just structure) -``` + ### AD-1 — {decision} -- **Binds:** {capability / unit IDs, areas, or `all`} +- **Binds:** {capability / unit ids / fr/nfr's, areas, or `all`} - **Prevents:** {the divergence this stops} - **Rule:** {the constraint downstream must follow} ## Consistency Conventions -The defaults that bind everything where independent builders would otherwise drift. Cut rows that -don't apply. + | Concern | Convention | | --- | --- | | Naming (entities, files, interfaces, events) | | -| Data & formats (IDs, dates, error shapes, envelopes) | | +| Data & formats (ids, dates, error shapes, envelopes) | | | State & cross-cutting (mutation, errors, logging, config, auth) | | +## Stack + + + +| Name | Version | +| --- | --- | +| {language / framework / key dep / platform / chain} | {pinned version} | + ## Structural Seed -Cold-start scaffolding, kept minimal — include an item only where its shape is non-obvious at this -altitude (at epic altitude the parent usually already fixed it, so the seed is often empty). The code -owns the **detail** (every file, every column); once code exists it becomes the source of truth for -detail, and this seed is a starting scaffold, not a mirror to maintain against it. Evolve a seed item -only when the **shape** itself changes — a new container, a new core entity, a stack bump — and let -the memlog keep the history. - -- **Stack & Versions** — the substrate (mirrors frontmatter `stack`). -- **System Shape** — a container/context view (at epic altitude, the slice of the parent system this scope touches). Use `flowchart` with a `subgraph` per boundary; C4 mermaid is experimental and won't render in most viewers. -- **Core Entities** — an ERD of entities and their relationships. Names and relationships only; attributes belong to the code unless one is itself an invariant (then it's an `AD`, not seed). -- **Project Structure** — a minimal source tree, only as deep as consistency needs. - -```mermaid -flowchart TD - user(["{actor}"]) - subgraph sys["{system boundary}"] - a["{container}
{tech} — {role}"] - end - db[("{datastore}")] - ext["{external system}"] - user --> a - a --> db - a -->|{via port}| ext -``` - -```mermaid -erDiagram - ENTITY_A ||--o{ ENTITY_B : "{relationship}" - ENTITY_B ||--o| ENTITY_C : "{relationship}" -``` + ```text {root}/ @@ -116,14 +68,12 @@ erDiagram ## Capability → Architecture Map -Bridges the spec's capabilities to the architecture (and is the consistency auditor's checklist). -Present when a spec drove this run. + | Capability / Area | Lives in | Governed by | | --- | --- | --- | -| {CAP-n / area} | {component / module} | {AD-n, convention, paradigm} | +| {CAP-id / area} | {component / module} | {AD-id, convention, paradigm} | ## Deferred -Decisions intentionally pushed down, each with the reason it can wait. The half of the contract -that keeps the spine lean. + diff --git a/src/bmm-skills/3-solutioning/bmad-architecture/references/reviewer-gate.md b/src/bmm-skills/3-solutioning/bmad-architecture/references/reviewer-gate.md index 729844c46..175676413 100644 --- a/src/bmm-skills/3-solutioning/bmad-architecture/references/reviewer-gate.md +++ b/src/bmm-skills/3-solutioning/bmad-architecture/references/reviewer-gate.md @@ -2,12 +2,12 @@ The spine's pre-handoff review. Runs at Finalize (after distill + reconcile) and *is* the Validate intent. The difference is the ending: at Finalize you apply the clear fixes yourself; under Validate you report and don't change the spine. -Cheap deterministic pass first: `python3 {skill-root}/scripts/lint_spine.py --workspace {doc_workspace}` settles the mechanical misses (placeholders, duplicate `AD` IDs, missing Binds/Prevents/Rule, unpinned deps), so reviewers spend judgment on the semantic half. +Cheap deterministic pass first: `python3 {skill-root}/scripts/lint_spine.py --workspace {doc_workspace}` settles the mechanical misses (placeholders, duplicate `AD` IDs, missing Binds/Prevents/Rule, unpinned Stack versions), so reviewers spend judgment on the semantic half. Assemble the menu: a **rubric walker** that judges the spine against the good-spine checklist below, **+ every entry in `{workflow.finalize_reviewers}`**, + ad-hoc lenses you invent or offer as the spine's rigor, altitude, and criticality warrant — a security/compliance lens for regulated stakes, a seam reviewer cross-team, a data-integrity lens for a heavy data model. Scale *whether and how heavily the gate runs* to the stakes: a throwaway prototype may run it quietly or skip the gate entirely; a high-criticality or platform-altitude spine earns more lenses and the explicit all / subset / skip menu. But once the gate runs, the `{workflow.finalize_reviewers}` always run — they are the configured floor, never cherry-picked out; only the ad-hoc lenses are optional. (Headless never skips the gate.) -Dispatch every entry as a **parallel subagent against `ARCHITECTURE-SPINE.md`** (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. An inline self-check does not count: the independent context is the point, because a fresh reviewer finds the divergences the author talks past. If subagents are unavailable, run sequentially — write the file first, then flush it from context. +Dispatch every entry as a **parallel subagent against `ARCHITECTURE-SPINE.md`** (prefix convention: `skill:` / `file:` / plain text). Each writes its full review to `{doc_workspace}/reviews/review-{slug}.md` — a subfolder, so the gate's scratch stays out of the deliverable folder — and returns ONLY a compact summary (verdict, top 2–5 findings, file path) — the parent never holds full review text. An inline self-check does not count: the independent context is the point, because a fresh reviewer finds the divergences the author talks past. If subagents are unavailable, run sequentially — write the file first, then flush it from context. -**Good-spine checklist** (what the rubric walker judges): it fixes the real divergence points for the level below and misses none; every `AD`'s Rule is enforceable and actually prevents its stated divergence; nothing under Deferred could let two units diverge; named tech is verified-current; it ratifies rather than contradicts a brownfield codebase; if a spec drove it, it covers that spec's capabilities; and if a parent spine is inherited, no new `AD` weakens or contradicts an inherited one. +**Good-spine checklist** (what the rubric walker judges): it fixes the real divergence points for the level below and misses none; every `AD`'s Rule is enforceable and actually prevents its stated divergence; nothing under Deferred could let two units diverge; named tech is verified-current; it ratifies rather than contradicts a brownfield codebase; if a spec drove it, it covers that spec's capabilities; if a parent spine is inherited, no new `AD` weakens or contradicts an inherited one; and every dimension the altitude owns is decided, deferred, or an open question — a whole dimension left silent is a finding, especially the operational/environmental envelope (deployment & environments, infra/provider strategy, operations) a domain-focused draft skips. Surface findings tiered, never dumped: a one-sentence gate verdict, then critical + high; medium/low roll into a tail ("plus N more in {file}"). Per finding: autofix, discuss, defer to Deferred / open items, or ignore. **At Finalize this is your own gate — apply the clear fixes rather than handing over a list; surface only what genuinely needs the user.** Under the **Validate intent**, fold every reviewer's output into one bespoke HTML + markdown report and open the HTML. diff --git a/src/bmm-skills/3-solutioning/bmad-architecture/scripts/lint_spine.py b/src/bmm-skills/3-solutioning/bmad-architecture/scripts/lint_spine.py index 88fa3e06c..d583a528d 100644 --- a/src/bmm-skills/3-solutioning/bmad-architecture/scripts/lint_spine.py +++ b/src/bmm-skills/3-solutioning/bmad-architecture/scripts/lint_spine.py @@ -13,7 +13,7 @@ It reads ARCHITECTURE-SPINE.md from a workspace and reports, as compact JSON on - placeholder literal TBD / TODO / "similar to AD-n" / unfilled {template-token} - ad_id duplicate or non-monotonic AD-n identifiers - ad_fields an AD-n block missing Binds / Prevents / Rule - - version_pin a frontmatter key_deps entry with no @version + - version_pin a ## Stack table row with no version Fenced code blocks are blanked (replaced with equal-count blank lines) before scanning, so mermaid and source trees don't trip false positives AND reported line numbers still line up @@ -150,65 +150,62 @@ def find_ad_issues(body: str, offset: int) -> list[dict]: return findings -def find_unpinned_deps(frontmatter: str) -> list[dict]: +def find_unpinned_stack(body: str, offset: int) -> list[dict]: + """Flag a `## Stack` table row that names something but leaves its version blank or a + placeholder. Pinning lives in the body table now, not frontmatter. A row whose name is + still a `{token}` skeleton is left to the placeholder pass, not double-reported here. + + Fences are blanked first (like find_placeholders / find_ad_issues), so a pipe-row or + heading inside a code block is never read as live Stack content. The heading match is + `## Stack` with a word boundary, so a renamed heading (`## Stack & Versions`) still + counts. Name and Version columns are located from the header row, so a reordered table + pairs name to version correctly; both default to the canonical positions (0, 1).""" findings: list[dict] = [] - lines = frontmatter.splitlines() - in_key_deps = False - key_indent = 0 - for raw in lines: - stripped = raw.strip() - if not stripped or stripped.startswith("#"): + in_stack = False + header_seen = False + name_idx, ver_idx = 0, 1 + scan = blank_fences(body) + for i, raw in enumerate(scan.splitlines()): + if HEADING.match(raw): + in_stack = re.match(r"^##\s+Stack\b", raw) is not None + header_seen = False + name_idx, ver_idx = 0, 1 continue - indent = len(raw) - len(raw.lstrip()) - m = re.match(r"key_deps:\s*(.*)$", stripped) - if m: - in_key_deps = True - key_indent = indent - inline = _strip_comment(m.group(1)).strip() - if inline and inline not in ("[]", "[ ]"): - # inline list form: key_deps: [a@1, b] — consumed here, no block follows - for item in re.findall(r"[^\[\],]+", inline.strip("[]")): - _check_dep(item.strip().strip("'\""), findings) - in_key_deps = False + if not in_stack or not raw.lstrip().startswith("|"): continue - if in_key_deps: - if indent <= key_indent and not stripped.startswith("-"): - in_key_deps = False - continue - if stripped.startswith("-"): - # block-sequence form: `- name@version` - _check_dep(_strip_comment(stripped[1:]).strip().strip("'\""), findings) - else: - # map form: `name: version` — pinned iff a non-empty value is present - mm = re.match(r"([^:]+):\s*(.*)$", stripped) - if mm: - name = mm.group(1).strip().strip("'\"") - val = _strip_comment(mm.group(2)).strip().strip("'\"") - if name and not val: - findings.append({ - "category": "version_pin", - "severity": "medium", - "detail": f"key_deps entry {name!r} has no version pin", - "location": f"{SPINE} frontmatter stack.key_deps", - }) + if set(raw.strip()) <= set("|-: "): + continue # separator row + cells = _table_cells(raw) + if not header_seen: + header_seen = True + for j, c in enumerate(cells): + if c.lower() == "name": + name_idx = j + elif c.lower() == "version": + ver_idx = j + continue + name = cells[name_idx] if len(cells) > name_idx else "" + version = cells[ver_idx] if len(cells) > ver_idx else "" + if not name or TEMPLATE_TOKEN.search(name): + continue + if not version or TEMPLATE_TOKEN.search(version): + findings.append({ + "category": "version_pin", + "severity": "medium", + "detail": f"Stack entry {name!r} has no version", + "location": f"{SPINE} (line {offset + i + 1})", + }) return findings -def _strip_comment(s: str) -> str: - """Drop a trailing YAML ` # comment`, leaving an inline `name@1.2` intact.""" - return re.sub(r"(^|\s)#.*$", "", s) - - -def _check_dep(item: str, findings: list[dict]) -> None: - if not item or item.startswith("#"): - return - if "@" not in item: - findings.append({ - "category": "version_pin", - "severity": "medium", - "detail": f"key_deps entry {item!r} has no @version pin", - "location": f"{SPINE} frontmatter stack.key_deps", - }) +def _table_cells(row: str) -> list[str]: + """Split a markdown table row into trimmed cells, dropping the leading/trailing pipe.""" + s = row.strip() + if s.startswith("|"): + s = s[1:] + if s.endswith("|"): + s = s[:-1] + return [c.strip() for c in s.split("|")] def lint(text: str) -> dict: @@ -217,7 +214,7 @@ def lint(text: str) -> dict: findings += find_frontmatter_placeholders(frontmatter) findings += find_placeholders(body, offset) findings += find_ad_issues(body, offset) - findings += find_unpinned_deps(frontmatter) + findings += find_unpinned_stack(body, offset) counts: dict[str, int] = {} for f in findings: counts[f["severity"]] = counts.get(f["severity"], 0) + 1 diff --git a/src/bmm-skills/3-solutioning/bmad-architecture/scripts/tests/test_lint_spine.py b/src/bmm-skills/3-solutioning/bmad-architecture/scripts/tests/test_lint_spine.py index 220bb42e9..55cf7482d 100644 --- a/src/bmm-skills/3-solutioning/bmad-architecture/scripts/tests/test_lint_spine.py +++ b/src/bmm-skills/3-solutioning/bmad-architecture/scripts/tests/test_lint_spine.py @@ -6,7 +6,7 @@ The spine under test: a clean spine lints empty; the linter catches exactly the mechanical defects a prompt is unreliable at — literal placeholders, AD-n id breakage, -AD-n blocks missing required fields, and unpinned dependency versions. +AD-n blocks missing required fields, and unpinned Stack versions. """ import importlib.util import json @@ -26,10 +26,6 @@ _SPEC.loader.exec_module(lint_spine) CLEAN = """--- name: 'Demo' -stack: - key_deps: - - fastapi@0.115 - - pydantic@2.9 --- ## Invariants & Rules @@ -50,6 +46,13 @@ stack: flowchart LR A --> B{decision} ``` + +## Stack + +| Name | Version | +| --- | --- | +| fastapi | 0.115 | +| pydantic | 2.9 | """ @@ -108,30 +111,32 @@ def test_missing_field_caught(): def test_unpinned_dep_caught(): - text = CLEAN.replace("- fastapi@0.115", "- fastapi") + text = CLEAN.replace("| fastapi | 0.115 |", "| fastapi | |") result = lint_spine.lint(text) assert "version_pin" in cats(result) -def test_inline_key_deps_unpinned(): - text = CLEAN.replace(" key_deps:\n - fastapi@0.115\n - pydantic@2.9", " key_deps: [fastapi, redis@7]") +def test_placeholder_version_caught(): + text = CLEAN.replace("| fastapi | 0.115 |", "| fastapi | {pin} |") result = lint_spine.lint(text) - pins = [f for f in result["findings"] if f["category"] == "version_pin"] - assert len(pins) == 1 and "fastapi" in pins[0]["detail"] + assert any(f["category"] == "version_pin" and "fastapi" in f["detail"] for f in result["findings"]) -def test_empty_key_deps_ok(): - text = CLEAN.replace(" key_deps:\n - fastapi@0.115\n - pydantic@2.9", " key_deps: []") +def test_no_stack_section_ok(): + text = CLEAN.split("## Stack")[0] result = lint_spine.lint(text) assert "version_pin" not in cats(result) -def test_yaml_comments_not_parsed_as_deps(): - # a SEED comment on the key_deps line must not read as an unpinned dependency - text = CLEAN.replace( - " key_deps:\n - fastapi@0.115\n - pydantic@2.9", - " key_deps: # SEED — verified current 2026-06\n - fastapi@0.115 # web framework", - ) +def test_stack_skeleton_row_not_version_pinned(): + # a leftover {token} name is the placeholder pass's job, not a double-reported version_pin + text = CLEAN.replace("| fastapi | 0.115 |", "| {language / framework} | {pinned version} |") + result = lint_spine.lint(text) + assert "version_pin" not in cats(result) + + +def test_stack_html_comment_not_parsed_as_row(): + text = CLEAN.replace("## Stack\n", "## Stack\n\n\n") result = lint_spine.lint(text) assert "version_pin" not in cats(result) @@ -153,7 +158,8 @@ def test_no_frontmatter_body_still_scanned(): def test_frontmatter_value_with_dashes_not_truncated(): # a value containing '---' must not be read as the closing fence (line-exact close) - text = "---\nscope: 'phase 1 --- phase 2'\nstack:\n key_deps:\n - fastapi\n---\n\n## Invariants\n" + text = ("---\nname: 'x'\nscope: 'phase 1 --- phase 2'\n---\n\n" + "## Stack\n\n| Name | Version |\n| --- | --- |\n| fastapi | |\n") result = lint_spine.lint(text) assert any(f["category"] == "version_pin" for f in result["findings"]) # read past the inline --- @@ -168,19 +174,55 @@ def test_ad_heading_in_fence_not_counted(): assert result["ok"] is True # the fenced AD-2 is not a live AD → no ad_fields/ad_id finding -def test_map_form_key_deps_unpinned_caught(): - text = "---\nstack:\n key_deps:\n fastapi: '0.115'\n redis:\n---\n\n## Invariants\n" +def test_stack_table_flags_only_the_unpinned_row(): + text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Name | Version |\n| --- | --- |\n" + "| fastapi | 0.115 |\n| redis | |\n") result = lint_spine.lint(text) pins = [f for f in result["findings"] if f["category"] == "version_pin"] assert len(pins) == 1 and "redis" in pins[0]["detail"] -def test_map_form_key_deps_pinned_ok(): - text = "---\nstack:\n key_deps:\n fastapi: '0.115'\n---\n\n## Invariants\n" +def test_stack_table_all_pinned_ok(): + text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Name | Version |\n| --- | --- |\n" + "| fastapi | 0.115 |\n") result = lint_spine.lint(text) assert "version_pin" not in cats(result) +def test_fenced_stack_rows_not_parsed(): + # an illustrative fenced table under ## Stack must not be read as live rows (fences are + # blanked first, like every other pass) — a blank-version row inside a fence is not a finding + text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Name | Version |\n| --- | --- |\n" + "| fastapi | 0.115 |\n\n```text\n| example | |\n```\n") + result = lint_spine.lint(text) + assert "version_pin" not in cats(result) + + +def test_fenced_stack_heading_not_live(): + # a `## Stack` heading shown inside a code fence is not the live Stack section + text = ("---\nname: 'x'\n---\n\n## Docs\n\n```md\n## Stack\n\n| foo | |\n```\n") + result = lint_spine.lint(text) + assert "version_pin" not in cats(result) + + +def test_renamed_stack_heading_still_scanned(): + # the heading match is word-boundary, so a varied `## Stack` heading still counts + text = ("---\nname: 'x'\n---\n\n## Stack & Versions\n\n| Name | Version |\n| --- | --- |\n" + "| redis | |\n") + result = lint_spine.lint(text) + pins = [f for f in result["findings"] if f["category"] == "version_pin"] + assert len(pins) == 1 and "redis" in pins[0]["detail"] + + +def test_reordered_columns_pair_name_to_version(): + # Version-then-Name header: the unpinned row must still be flagged by its real name + text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Version | Name |\n| --- | --- |\n" + "| 0.115 | fastapi |\n| | redis |\n") + result = lint_spine.lint(text) + pins = [f for f in result["findings"] if f["category"] == "version_pin"] + assert len(pins) == 1 and "redis" in pins[0]["detail"] + + def test_placeholder_line_number_is_absolute(): # a TBD after a multi-line fence reports its real file line (fence blanked, not collapsed) text = ( diff --git a/src/bmm-skills/4-implementation/bmad-retrospective/SKILL.md b/src/bmm-skills/4-implementation/bmad-retrospective/SKILL.md index 07aec498a..46998b6b2 100644 --- a/src/bmm-skills/4-implementation/bmad-retrospective/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-retrospective/SKILL.md @@ -350,6 +350,7 @@ Amelia (Developer): "I found our retrospectives from Epic {{prev_epic_num}}. Let **Action Item Follow-Through:** - For each action item from Epic {{prev_epic_num}} retro, check if it was completed + - Cross-check the action_items section in {sprint_status_file} (if present) for Epic {{prev_epic_num}} entries and their current status - Look for evidence in current epic's story records - Mark each action item: ✅ Completed, ⏳ In Progress, ❌ Not Addressed @@ -1403,6 +1404,19 @@ Amelia (Developer): "See you all when prep work is done. Meeting adjourned!" Find development_status key "epic-{{epic_number}}-retrospective" Verify current status (typically "optional" or "pending") Update development_status["epic-{{epic_number}}-retrospective"] = "done" +Append each Epic {{epic_number}} action item to the action_items section, creating the section after development_status if missing. One entry per item: + +```yaml +action_items: + - epic: {{epic_number}} + action: "{{action_description}}" + owner: "{{owner}}" + status: open +``` + +Quote action and owner values so punctuation (e.g., "#") cannot break YAML parsing + +Update Epic {{prev_epic_num}} action_items entries based on Step 4 follow-through: ✅ Completed → done, ⏳ In Progress → in-progress, ❌ Not Addressed → keep existing status (do not modify) Update last_updated field to current date Save file, preserving ALL comments and structure including STATUS DEFINITIONS @@ -1412,6 +1426,7 @@ Amelia (Developer): "See you all when prep work is done. Meeting adjourned!" Retrospective key: epic-{{epic_number}}-retrospective Status: {{previous_status}} → done +Action items recorded: {{action_count}} diff --git a/src/bmm-skills/4-implementation/bmad-sprint-planning/SKILL.md b/src/bmm-skills/4-implementation/bmad-sprint-planning/SKILL.md index dd7bfa55b..c56f9091b 100644 --- a/src/bmm-skills/4-implementation/bmad-sprint-planning/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-sprint-planning/SKILL.md @@ -151,6 +151,7 @@ development_status: - If existing `{status_file}` exists and has more advanced status, preserve it - Never downgrade status (e.g., don't change `done` to `ready-for-dev`) +- If existing `{status_file}` has an `action_items` section, carry it over unchanged **Status Flow Reference:** @@ -194,12 +195,18 @@ development_status: # - optional: Can be completed but not required # - done: Retrospective has been completed # +# Action Item Status: +# - open: Committed during a retrospective, not yet addressed +# - in-progress: Actively being worked on +# - done: Completed +# # WORKFLOW NOTES: # =============== # - Epic transitions to 'in-progress' automatically when first story is created # - Stories can be worked in parallel if team capacity allows # - Developer typically creates next story after previous one is 'done' to incorporate learnings # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) +# - Retrospective appends its action items to action_items; sprint-status surfaces open ones generated: { date } last_updated: { date } @@ -215,6 +222,7 @@ development_status: Write the complete sprint status YAML to {status_file} CRITICAL: Metadata appears TWICE - once as comments (#) for documentation, once as YAML key:value fields for parsing Ensure all items are ordered: epic, its stories, its retrospective, next epic... +If the existing file had an action_items section, write it back unchanged after development_status @@ -223,7 +231,8 @@ development_status: - [ ] Every epic in epic files appears in {status_file} - [ ] Every story in epic files appears in {status_file} - [ ] Every epic has a corresponding retrospective entry -- [ ] No items in {status_file} that don't exist in epic files +- [ ] No development_status items in {status_file} that don't exist in epic files +- [ ] action_items section (if it existed) carried over unchanged - [ ] All status values are legal (match state machine definitions) - [ ] File is valid YAML syntax @@ -291,6 +300,16 @@ optional ↔ done - **optional**: Ready to be conducted but not required - **done**: Finished +**Action Item Status:** + +``` +open → in-progress → done +``` + +- **open**: Committed during a retrospective, not yet addressed +- **in-progress**: Actively being worked on +- **done**: Completed + ### Guidelines 1. **Epic Activation**: Mark epic as `in-progress` when starting work on its first story diff --git a/src/bmm-skills/4-implementation/bmad-sprint-planning/checklist.md b/src/bmm-skills/4-implementation/bmad-sprint-planning/checklist.md index 7c20b1f37..2ec5045bc 100644 --- a/src/bmm-skills/4-implementation/bmad-sprint-planning/checklist.md +++ b/src/bmm-skills/4-implementation/bmad-sprint-planning/checklist.md @@ -7,7 +7,8 @@ - [ ] Every epic found in epic\*.md files appears in sprint-status.yaml - [ ] Every story found in epic\*.md files appears in sprint-status.yaml - [ ] Every epic has a corresponding retrospective entry -- [ ] No items in sprint-status.yaml that don't exist in epic files +- [ ] No development_status items in sprint-status.yaml that don't exist in epic files +- [ ] action_items section (if it existed) carried over unchanged ### Parsing Verification diff --git a/src/bmm-skills/4-implementation/bmad-sprint-planning/sprint-status-template.yaml b/src/bmm-skills/4-implementation/bmad-sprint-planning/sprint-status-template.yaml index d454f930c..8b91ff748 100644 --- a/src/bmm-skills/4-implementation/bmad-sprint-planning/sprint-status-template.yaml +++ b/src/bmm-skills/4-implementation/bmad-sprint-planning/sprint-status-template.yaml @@ -26,11 +26,17 @@ # - optional: Can be completed but not required # - done: Retrospective has been completed # +# Action Item Status: +# - open: Committed during a retrospective, not yet addressed +# - in-progress: Actively being worked on +# - done: Completed +# # WORKFLOW NOTES: # =============== # - Mark epic as 'in-progress' when starting work on its first story # - Developer typically creates next story ONLY after previous one is 'done' to incorporate learnings # - Dev moves story to 'review', then Dev runs code-review (fresh context, ideally different LLM) +# - Retrospective appends its action items to action_items; sprint-status surfaces open ones # EXAMPLE STRUCTURE (your actual epics/stories will replace these): @@ -54,3 +60,10 @@ development_status: 2-2-chat-interface: backlog 2-3-llm-integration: backlog epic-2-retrospective: optional + +# Action items committed during retrospectives (section created by the retrospective workflow) +action_items: + - epic: 1 + action: "Add error-handling review to the code review checklist" + owner: "Charlie" + status: open diff --git a/src/bmm-skills/4-implementation/bmad-sprint-status/SKILL.md b/src/bmm-skills/4-implementation/bmad-sprint-status/SKILL.md index cad4f0df0..0e060a684 100644 --- a/src/bmm-skills/4-implementation/bmad-sprint-status/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-sprint-status/SKILL.md @@ -112,12 +112,14 @@ Run `/bmad:bmm:workflows:sprint-planning` to generate it, then rerun sprint-stat Map legacy epic status "contexted" → "in-progress" Count epic statuses: backlog, in-progress, done Count retrospective statuses: optional, done + Parse action_items list if present. Set open_action_items = entries with status "open" or "in-progress" Validate all statuses against known values: - Valid story statuses: backlog, ready-for-dev, in-progress, review, done, drafted (legacy) - Valid epic statuses: backlog, in-progress, done, contexted (legacy) - Valid retrospective statuses: optional, done +- Valid action item statuses: open, in-progress, done @@ -132,6 +134,7 @@ Run `/bmad:bmm:workflows:sprint-planning` to generate it, then rerun sprint-stat - Stories: backlog, ready-for-dev, in-progress, review, done - Epics: backlog, in-progress, done - Retrospectives: optional, done +- Action items: open, in-progress, done How should these be corrected? {{#each invalid_entries}} @@ -181,6 +184,14 @@ Enter corrections (e.g., "1=in-progress, 2=backlog") or "skip" to continue witho **Next Recommendation:** /bmad:bmm:workflows:{{next_workflow_id}} ({{next_story_id}}) +{{#if open_action_items}} +**Open Action Items:** +{{#each open_action_items}} + +- {{action}} — {{status}} (epic {{epic}}, owner: {{owner}}) + {{/each}} + {{/if}} + {{#if risks}} **Risks:** {{#each risks}} @@ -243,6 +254,7 @@ If the command targets a story, set `story_key={{next_story_id}}` when prompted. epic_backlog = {{epic_backlog}} epic_in_progress = {{epic_in_progress}} epic_done = {{epic_done}} + open_action_items = {{open_action_items}} risks = {{risks}} Return to caller @@ -283,6 +295,7 @@ If the command targets a story, set `story_key={{next_story_id}}` when prompted. - Stories: backlog, ready-for-dev, in-progress, review, done (legacy: drafted) - Epics: backlog, in-progress, done (legacy: contexted) - Retrospectives: optional, done +- Action items (if present): open, in-progress, done is_valid = false error = "Invalid status values: {{invalid_entries}}" diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 1317bbbf5..f511b4376 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -3456,6 +3456,125 @@ async function runTests() { console.log(''); + // ============================================================ + // Test Suite 47: WSL shell using Windows Node guard + // ============================================================ + console.log(`${colors.yellow}Test Suite 47: WSL Windows Node guard${colors.reset}\n`); + + try { + const wslNodeCheck = require('../tools/installer/core/wsl-node-check'); + + let detection = wslNodeCheck.detectWindowsNodeFromWsl({ + platform: 'win32', + env: { WSL_DISTRO_NAME: 'Ubuntu-26.04' }, + cwd: String.raw`C:\Windows`, + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + assert(detection.isMismatch === true, 'detects Windows Node launched from WSL via WSL_DISTRO_NAME'); + + detection = wslNodeCheck.detectWindowsNodeFromWsl({ + platform: 'win32', + env: { PWD: '/home/devuser/projects/md2pdf' }, + cwd: String.raw`\\wsl.localhost\Ubuntu-26.04\home\devuser\projects\md2pdf`, + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + assert(detection.isMismatch === true, 'detects Windows Node launched from WSL via Linux PWD / WSL UNC cwd'); + + detection = wslNodeCheck.detectWindowsNodeFromWsl({ + platform: 'win32', + env: {}, + cwd: String.raw`\\wsl$\Ubuntu-26.04\home\devuser\projects\md2pdf`, + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + assert(detection.isMismatch === true, 'detects Windows Node launched from WSL via legacy WSL UNC cwd'); + + detection = wslNodeCheck.detectWindowsNodeFromWsl({ + platform: 'linux', + env: { WSL_DISTRO_NAME: 'Ubuntu-26.04', PWD: '/home/devuser/projects/md2pdf' }, + cwd: '/home/devuser/projects/md2pdf', + execPath: '/usr/bin/node', + }); + assert(detection.isMismatch === false, 'allows native Linux Node inside WSL'); + + detection = wslNodeCheck.detectWindowsNodeFromWsl({ + platform: 'win32', + env: { PWD: String.raw`C:\Users\devuser\project` }, + cwd: String.raw`C:\Users\devuser\project`, + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + assert(detection.isMismatch === false, 'allows normal Windows Node outside WSL'); + + detection = wslNodeCheck.detectWindowsNodeFromWsl({ + platform: 'win32', + env: { PWD: '/c/Users/devuser/project' }, + cwd: String.raw`C:\Users\devuser\project`, + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + assert(detection.isMismatch === false, 'allows Git Bash Windows-drive PWD outside WSL'); + + detection = wslNodeCheck.detectWindowsNodeFromWsl({ + platform: 'win32', + env: { PWD: '/cygdrive/c/Users/devuser/project' }, + cwd: String.raw`C:\Users\devuser\project`, + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + assert(detection.isMismatch === false, 'allows Cygwin Windows-drive PWD outside WSL'); + + const message = wslNodeCheck.formatWindowsNodeFromWslMessage({ + isMismatch: true, + reason: 'WSL_DISTRO_NAME is set', + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + assert(message.includes('Install Node.js inside WSL'), 'guard message tells user to install Node.js inside WSL'); + assert(message.includes(String.raw`C:\Program Files\nodejs\node.exe`), 'guard message includes detected Windows Node path'); + + const promptsModule = require('../tools/installer/prompts'); + const real = { + detectWindowsNodeFromWsl: wslNodeCheck.detectWindowsNodeFromWsl, + log: promptsModule.log, + exit: process.exit, + }; + const seen = { errors: [], exit: [] }; + wslNodeCheck.detectWindowsNodeFromWsl = () => ({ + isMismatch: true, + reason: 'WSL_INTEROP is set', + execPath: String.raw`C:\Program Files\nodejs\node.exe`, + }); + promptsModule.log = { + error: async (m) => void seen.errors.push(m), + info: async () => {}, + success: async () => {}, + warn: async () => {}, + message: async () => {}, + step: async () => {}, + }; + process.exit = (code) => { + seen.exit.push(code); + throw new Error('__stub_exit__'); + }; + + try { + let threw = false; + try { + await wslNodeCheck.checkWindowsNodeFromWsl(); + } catch (error) { + threw = error.message === '__stub_exit__'; + } + assert(threw && seen.exit[0] === 1, 'guard exits with code 1 when Windows Node is launched from WSL'); + assert(seen.errors[0].includes('Windows Node.js was launched from a WSL shell'), 'guard logs the mismatch explanation'); + } finally { + wslNodeCheck.detectWindowsNodeFromWsl = real.detectWindowsNodeFromWsl; + promptsModule.log = real.log; + process.exit = real.exit; + } + } catch (error) { + console.log(`${colors.red}Test Suite 47 setup failed: ${error.message}${colors.reset}`); + console.log(error.stack); + failed++; + } + + console.log(''); + // ============================================================ // Summary // ============================================================ diff --git a/tools/installer/commands/install.js b/tools/installer/commands/install.js index 1dfe6fb70..42f6213de 100644 --- a/tools/installer/commands/install.js +++ b/tools/installer/commands/install.js @@ -75,6 +75,9 @@ module.exports = { return; } + const { checkWindowsNodeFromWsl } = require('../core/wsl-node-check'); + await checkWindowsNodeFromWsl(); + // Set debug flag as environment variable for all components if (options.debug) { process.env.BMAD_DEBUG_MANIFEST = 'true'; diff --git a/tools/installer/core/wsl-node-check.js b/tools/installer/core/wsl-node-check.js new file mode 100644 index 000000000..261ebf2d7 --- /dev/null +++ b/tools/installer/core/wsl-node-check.js @@ -0,0 +1,109 @@ +const prompts = require('../prompts'); + +const WSL_UNC_PATTERN = /^\\\\wsl(?:\.localhost|\$)?\\/i; + +function normalizePath(value) { + return typeof value === 'string' ? value.replaceAll('/', '\\').toLowerCase() : ''; +} + +function isLinuxStylePath(value) { + return ( + typeof value === 'string' && + value.startsWith('/') && + !value.startsWith('//') && + !/^\/[a-z](?:\/|$)/i.test(value) && + !/^\/cygdrive\/[a-z](?:\/|$)/i.test(value) + ); +} + +function isWslUncPath(value) { + return WSL_UNC_PATTERN.test(value || ''); +} + +/** + * Detect the broken interop case where WSL resolved node/npx to Windows. + * @param {Object} [runtime] + * @param {string} [runtime.platform] + * @param {Object} [runtime.env] + * @param {string} [runtime.cwd] + * @param {string} [runtime.execPath] + * @returns {{isMismatch: boolean, reason: string|null, execPath: string}} + */ +function detectWindowsNodeFromWsl(runtime = {}) { + const platform = runtime.platform || process.platform; + const env = runtime.env || process.env; + const cwd = runtime.cwd || safeCwd(); + const execPath = runtime.execPath || process.execPath || ''; + + if (platform !== 'win32') { + return { isMismatch: false, reason: null, execPath }; + } + + if (env.WSL_DISTRO_NAME) { + return { isMismatch: true, reason: 'WSL_DISTRO_NAME is set', execPath }; + } + + if (env.WSL_INTEROP) { + return { isMismatch: true, reason: 'WSL_INTEROP is set', execPath }; + } + + if (isLinuxStylePath(env.PWD)) { + return { isMismatch: true, reason: 'PWD is a Linux path', execPath }; + } + + if (isWslUncPath(cwd)) { + return { isMismatch: true, reason: 'current directory is a WSL UNC path', execPath }; + } + + const normalizedExecPath = normalizePath(execPath); + if (normalizedExecPath.includes('\\wsl$\\') || normalizedExecPath.includes('\\wsl.localhost\\')) { + return { isMismatch: true, reason: 'Node executable path is under a WSL UNC path', execPath }; + } + + return { isMismatch: false, reason: null, execPath }; +} + +function safeCwd() { + try { + return process.cwd(); + } catch { + return ''; + } +} + +function formatWindowsNodeFromWslMessage(detection) { + const lines = [ + 'Windows Node.js was launched from a WSL shell.', + '', + 'This usually means Node.js is not installed inside the WSL distro, so WSL resolved `node`/`npx` to Windows.', + 'The installer cannot safely continue because Linux paths may be interpreted as Windows paths.', + '', + 'Install Node.js inside WSL, then rerun the same command from the WSL terminal.', + ]; + + if (detection.execPath) { + lines.push('', `Detected Node executable: ${detection.execPath}`); + } + + if (detection.reason) { + lines.push(`Detection signal: ${detection.reason}`); + } + + return lines.join('\n'); +} + +async function checkWindowsNodeFromWsl() { + const detection = module.exports.detectWindowsNodeFromWsl(); + if (!detection.isMismatch) { + return detection; + } + + await prompts.log.error(formatWindowsNodeFromWslMessage(detection)); + process.exit(1); +} + +module.exports = { + checkWindowsNodeFromWsl, + detectWindowsNodeFromWsl, + formatWindowsNodeFromWslMessage, +}; diff --git a/website/src/styles/custom.css b/website/src/styles/custom.css index 6ab5b2ee5..23b0dfb30 100644 --- a/website/src/styles/custom.css +++ b/website/src/styles/custom.css @@ -15,7 +15,7 @@ ============================================ */ :root { --ai-banner-height: 2.75rem; - --sl-nav-height: 6.25rem; /* Base nav height (~3.5rem) + banner height (2.75rem) */ + --sl-nav-height: 9rem; /* Base nav (3.5rem) + two announcement banners (2.75rem each) */ /* Full-width content - override Starlight's default 45rem/67.5rem */ --sl-content-width: 65rem;