Compare commits

...

5 Commits

Author SHA1 Message Date
Dov Benyomin Sohacheski c1593598d2
Merge d9d44ae439 into 07d34fb43a 2026-06-17 22:29:07 -05:00
Brian d9d44ae439
Merge branch 'main' into fix/gitignore-default-user-config 2026-06-17 22:29:05 -05:00
SLUR 07d34fb43a
fix: adjust nav height for dual announcement banners (#2473) 2026-06-17 22:28:07 -05:00
Davor Racic 5bcc235cdb
fix(installer): guard WSL installs from Windows Node (#2470) 2026-06-17 22:19:18 -05:00
Brian 2417f0048d
bmad-architecture: lean directives, breadth coverage, redesigned spine template (#2475)
* bmad-architecture: breadth coverage + lean directives; reviewer reports to subfolder

- Inline forward-readiness (inherit upstream silently; thin input -> suggest bmad-spec or hybrid-capture) and brownfield (ratify, don't re-tell) directives in place of a standalone spine-checklist
- Use {workflow.persistent_facts} instead of hardcoded project-context.md
- Reviewer gate writes per-reviewer reports to reviews/ subfolder so the deliverable folder stays clean
- Require breadth coverage at distill and in the gate rubric: every altitude-owned dimension decided/deferred/open, flagging the operational/environmental envelope a domain-focused draft skips
- Trim repeated directives and human-facing justification prose

* Redesign spine template and move stack pinning to a body table

Rework spine-template.md so it stops forcing fixed structure: guidance
moves into single-line HTML comments (stripped at distill), the always-two
diagrams and empty-mermaid render bugs are gone, and the structural-seed
framing opens up so the operational/environmental envelope isn't skipped.
Stack moves from nested frontmatter into a ## Stack | Name | Version | table.

lint_spine.py drops the frontmatter dep check for find_unpinned_stack, which
parses the Stack table and flags real-name/blank-version rows while skipping
{token} skeletons. Tests reworked to match; 24 passing.

SKILL.md Finalize tightened to act-then-strip template comments and sweep
altitude-owned breadth.

* Harden find_unpinned_stack: blank fences, locate columns, looser heading

Address PR review (CodeRabbit + Augment): find_unpinned_stack scanned raw
body, so pipe-rows or ## headings inside a fenced block could be misread as
live Stack content and misreport version_pin. Now blanks fences first, like
find_placeholders and find_ad_issues, honoring the linter's fences-are-non-live
contract.

Also locate both Name and Version columns from the header (a reordered table
now pairs name to version correctly) and match the heading on a word boundary
(## Stack & Versions still counts). Add regression tests for fenced rows,
fenced headings, renamed heading, and reordered columns (28 passing).

Reword stale 'unpinned deps' / 'unpinned dependency versions' to 'unpinned
Stack versions' to match the body-table model.
2026-06-17 16:00:52 -05:00
9 changed files with 383 additions and 164 deletions

View File

@ -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 <decision|constraint|version|assumption|question|direction|event> --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

View File

@ -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.
<!-- TEMPLATE GUIDE — act on these comments, then delete them; never emit a comment in the finished spine. This is a shape, not a script: keep only the sections this spine needs and cut the rest (no empty headers). A small intent may be just paradigm + a few ADs + conventions; a platform earns more. An inherited epic spine is usually mostly Inherited Invariants + a thin Deferred. Decisions, not rationale (rationale lives in the memlog). Carry shape in diagrams; prose only where it must. -->
## 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.
<!-- Name the pattern (a known one loads a whole model for free) and map its layers to namespaces/directories. The smallest, most durable thing here. -->
## 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.
<!-- Only when this spine inherits a higher-altitude parent. The parent's ADs/conventions/paradigm that bind here, by their ORIGINAL ids — read-only, never renumbered, not re-derived. A local decision that contradicts one is a conflict to surface, not an override. Cut this section otherwise. -->
| 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)
```
<!-- The durable heart: calls a future builder can't read off compliant code. One block per decision: stable ascending id (never reused/renumbered), Binds, Prevents (the divergence), Rule (enforceable). Tag [ADOPTED] when the user or existing reality settled it. Include a dependency-direction diagram (who may depend on whom) — it IS a rule; author it as valid mermaid, never an empty graph. -->
### 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.
<!-- Defaults that bind where independent builders would drift. Cut rows that don't apply; add rows the project needs. -->
| 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
<!-- SEED — verified current at authoring; the code owns this once it exists. Name + version only; the why lives in the memlog. One row per language, framework, key dependency, platform, or chain that's pinned. -->
| 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}<br/>{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}"
```
<!-- The shapes worth fixing at cold-start — not a fixed list. Include only what's non-obvious at this altitude, and use as many diagrams as convey it, each as VALID mermaid (never a placeholder or empty graph). Candidates: system/container/context view; DEPLOYMENT & ENVIRONMENTS and external provider/infra topology (cover the operational envelope here when this altitude owns it — don't let it fall through); core-entity ERD (names + relationships only; an attribute that's itself an invariant is an AD, not a diagram); a minimal source tree. The code owns the detail — this is scaffold, not a mirror to maintain. -->
```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.
<!-- Present when a spec drove this run. Bridges the spec's capabilities to where they live + what governs them; the consistency auditor's checklist. Cut otherwise. -->
| 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.
<!-- Decisions intentionally pushed down, each with the reason it can wait — including whole dimensions this altitude doesn't own yet. The half of the contract that keeps the spine lean. -->

View File

@ -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 25 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 25 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.

View File

@ -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
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
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:
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"key_deps entry {name!r} has no version pin",
"location": f"{SPINE} frontmatter stack.key_deps",
"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

View File

@ -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<!-- SEED — verified current 2026-06 -->\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 = (

View File

@ -3516,6 +3516,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
// ============================================================

View File

@ -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';

View File

@ -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,
};

View File

@ -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;