bmad-method 6.8.1-next.10 → 6.8.1-next.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bmm-skills/3-solutioning/bmad-architecture/SKILL.md +10 -11
- package/src/bmm-skills/3-solutioning/bmad-architecture/assets/spine-template.md +21 -71
- package/src/bmm-skills/3-solutioning/bmad-architecture/references/reviewer-gate.md +3 -3
- package/src/bmm-skills/3-solutioning/bmad-architecture/scripts/lint_spine.py +52 -55
- package/src/bmm-skills/3-solutioning/bmad-architecture/scripts/tests/test_lint_spine.py +65 -23
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@ description: 'Produce the architecture: a lean spine of invariants that keeps ev
|
|
|
6
6
|
|
|
7
7
|
## Overview
|
|
8
8
|
|
|
9
|
-
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.
|
|
9
|
+
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.
|
|
10
10
|
|
|
11
11
|
One test decides what belongs:
|
|
12
12
|
|
|
@@ -18,17 +18,17 @@ Record decisions, not rationale (rationale lives in the memlog). Carry shape in
|
|
|
18
18
|
|
|
19
19
|
## How you work
|
|
20
20
|
|
|
21
|
-
You're a coach, and the **Coaching path is the default** —
|
|
21
|
+
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.
|
|
22
22
|
|
|
23
|
-
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.
|
|
23
|
+
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.
|
|
24
24
|
|
|
25
|
-
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 `
|
|
25
|
+
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.
|
|
26
26
|
|
|
27
27
|
## Read the input to know the job
|
|
28
28
|
|
|
29
|
-
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.
|
|
29
|
+
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.
|
|
30
30
|
|
|
31
|
-
**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
|
|
31
|
+
**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.
|
|
32
32
|
|
|
33
33
|
## How a run works
|
|
34
34
|
|
|
@@ -38,7 +38,6 @@ Writes go through the shared script (don't read the file back except on resume):
|
|
|
38
38
|
|
|
39
39
|
- `python3 {project-root}/_bmad/scripts/memlog.py init --workspace {doc_workspace} --field scope="…" --field purpose="…" --field altitude="…"`
|
|
40
40
|
- `python3 {project-root}/_bmad/scripts/memlog.py append --workspace {doc_workspace} --type <decision|constraint|version|assumption|question|direction|event> --text "…"`
|
|
41
|
-
- A terminal moment (spine finalized, a validation verdict) is an `append --type event` entry — there is no status field to set.
|
|
42
41
|
|
|
43
42
|
## Resolution rules
|
|
44
43
|
|
|
@@ -49,7 +48,7 @@ Writes go through the shared script (don't read the file back except on resume):
|
|
|
49
48
|
|
|
50
49
|
## On Activation
|
|
51
50
|
|
|
52
|
-
**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`.
|
|
51
|
+
**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`.
|
|
53
52
|
|
|
54
53
|
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.
|
|
55
54
|
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
|
|
|
62
61
|
|
|
63
62
|
## Reviewer Gate
|
|
64
63
|
|
|
65
|
-
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
|
|
64
|
+
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.
|
|
66
65
|
|
|
67
66
|
## Finalize
|
|
68
67
|
|
|
69
68
|
Walk the sequence; reviewer fixes land before polish.
|
|
70
69
|
|
|
71
|
-
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.
|
|
70
|
+
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.
|
|
72
71
|
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.
|
|
73
72
|
3. **Reviewer pass.** Run the Reviewer Gate (`references/reviewer-gate.md`). Resolve before polish.
|
|
74
73
|
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.
|
|
|
79
78
|
|
|
80
79
|
## Update
|
|
81
80
|
|
|
82
|
-
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.
|
|
81
|
+
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.
|
|
83
82
|
|
|
84
83
|
## Validate
|
|
85
84
|
|
|
@@ -8,106 +8,58 @@ scope: '{what this spine governs}'
|
|
|
8
8
|
status: draft # draft · final
|
|
9
9
|
created: '{date}'
|
|
10
10
|
updated: '{date}'
|
|
11
|
-
|
|
12
|
-
languages: []
|
|
13
|
-
frameworks: []
|
|
14
|
-
key_deps: [] # name@version
|
|
15
|
-
binds: [] # capability / unit IDs governed (from the driving spec; at epic altitude, also the parent AD IDs inherited)
|
|
11
|
+
binds: [] # capability / unit IDs governed (from the driving spec; at epic altitude, also the inherited parent AD ids)
|
|
16
12
|
sources: []
|
|
17
13
|
companions: []
|
|
18
14
|
---
|
|
19
15
|
|
|
20
16
|
# Architecture Spine — {name}
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
> independently-built level below ({features | epics | stories}) coherent — the durable rules a
|
|
24
|
-
> clean codebase can't reveal. Structure is **seed**: the code owns the detail, the spine keeps the shape.
|
|
25
|
-
> Decisions, not rationale (that lives in the memlog). Diagrams over prose.
|
|
26
|
-
>
|
|
27
|
-
> **Scale to the job — drop any section a project doesn't need.** A small intent may be just a
|
|
28
|
-
> paradigm + a few `AD`s + conventions, seed omitted; a platform earns the full set. An inherited
|
|
29
|
-
> epic spine is usually mostly Inherited Invariants + a thin Deferred. Empty sections are cut, not left as headers.
|
|
18
|
+
<!-- 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. -->
|
|
30
19
|
|
|
31
20
|
## Design Paradigm
|
|
32
21
|
|
|
33
|
-
Name the pattern
|
|
34
|
-
directories. The smallest, most durable thing in the file.
|
|
22
|
+
<!-- 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. -->
|
|
35
23
|
|
|
36
24
|
## Inherited Invariants
|
|
37
25
|
|
|
38
|
-
|
|
39
|
-
feature/initiative spine). The parent's `AD`s, conventions, and paradigm that bind here, listed by
|
|
40
|
-
their original parent IDs — **read-only, never renumbered, not re-derived**. This spine adds only
|
|
41
|
-
what the parent left open; anything here that a local decision would contradict is a conflict to
|
|
42
|
-
surface, not override.
|
|
26
|
+
<!-- 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. -->
|
|
43
27
|
|
|
44
28
|
| Inherited | From parent | Binds here |
|
|
45
29
|
| --- | --- | --- |
|
|
46
|
-
| {AD-
|
|
30
|
+
| {AD-id / convention} | {parent spine} | {what it constrains in this scope} |
|
|
47
31
|
|
|
48
32
|
## Invariants & Rules
|
|
49
33
|
|
|
50
|
-
The durable heart:
|
|
51
|
-
stable ID (never reused), a binding scope, the divergence it prevents, and an enforceable rule.
|
|
52
|
-
Cover the boundary/dependency rules (who may depend on whom) and how state is mutated — a
|
|
53
|
-
dependency-direction diagram says these better than prose. An `AD-n` the user asserted as
|
|
54
|
-
already-settled (or one verified from existing reality) carries an `[ADOPTED]` tag after its
|
|
55
|
-
title, so its provenance is legible versus decisions made here.
|
|
56
|
-
|
|
57
|
-
```mermaid
|
|
58
|
-
flowchart LR
|
|
59
|
-
%% arrows = allowed dependency direction (a rule, not just structure)
|
|
60
|
-
```
|
|
34
|
+
<!-- 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. -->
|
|
61
35
|
|
|
62
36
|
### AD-1 — {decision}
|
|
63
37
|
|
|
64
|
-
- **Binds:** {capability / unit
|
|
38
|
+
- **Binds:** {capability / unit ids / fr/nfr's, areas, or `all`}
|
|
65
39
|
- **Prevents:** {the divergence this stops}
|
|
66
40
|
- **Rule:** {the constraint downstream must follow}
|
|
67
41
|
|
|
68
42
|
## Consistency Conventions
|
|
69
43
|
|
|
70
|
-
|
|
71
|
-
don't apply.
|
|
44
|
+
<!-- Defaults that bind where independent builders would drift. Cut rows that don't apply; add rows the project needs. -->
|
|
72
45
|
|
|
73
46
|
| Concern | Convention |
|
|
74
47
|
| --- | --- |
|
|
75
48
|
| Naming (entities, files, interfaces, events) | |
|
|
76
|
-
| Data & formats (
|
|
49
|
+
| Data & formats (ids, dates, error shapes, envelopes) | |
|
|
77
50
|
| State & cross-cutting (mutation, errors, logging, config, auth) | |
|
|
78
51
|
|
|
79
|
-
##
|
|
52
|
+
## Stack
|
|
80
53
|
|
|
81
|
-
|
|
82
|
-
altitude (at epic altitude the parent usually already fixed it, so the seed is often empty). The code
|
|
83
|
-
owns the **detail** (every file, every column); once code exists it becomes the source of truth for
|
|
84
|
-
detail, and this seed is a starting scaffold, not a mirror to maintain against it. Evolve a seed item
|
|
85
|
-
only when the **shape** itself changes — a new container, a new core entity, a stack bump — and let
|
|
86
|
-
the memlog keep the history.
|
|
87
|
-
|
|
88
|
-
- **Stack & Versions** — the substrate (mirrors frontmatter `stack`).
|
|
89
|
-
- **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.
|
|
90
|
-
- **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).
|
|
91
|
-
- **Project Structure** — a minimal source tree, only as deep as consistency needs.
|
|
92
|
-
|
|
93
|
-
```mermaid
|
|
94
|
-
flowchart TD
|
|
95
|
-
user(["{actor}"])
|
|
96
|
-
subgraph sys["{system boundary}"]
|
|
97
|
-
a["{container}<br/>{tech} — {role}"]
|
|
98
|
-
end
|
|
99
|
-
db[("{datastore}")]
|
|
100
|
-
ext["{external system}"]
|
|
101
|
-
user --> a
|
|
102
|
-
a --> db
|
|
103
|
-
a -->|{via port}| ext
|
|
104
|
-
```
|
|
54
|
+
<!-- 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. -->
|
|
105
55
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
56
|
+
| Name | Version |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| {language / framework / key dep / platform / chain} | {pinned version} |
|
|
59
|
+
|
|
60
|
+
## Structural Seed
|
|
61
|
+
|
|
62
|
+
<!-- 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. -->
|
|
111
63
|
|
|
112
64
|
```text
|
|
113
65
|
{root}/
|
|
@@ -116,14 +68,12 @@ erDiagram
|
|
|
116
68
|
|
|
117
69
|
## Capability → Architecture Map
|
|
118
70
|
|
|
119
|
-
Bridges the spec's capabilities to
|
|
120
|
-
Present when a spec drove this run.
|
|
71
|
+
<!-- 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. -->
|
|
121
72
|
|
|
122
73
|
| Capability / Area | Lives in | Governed by |
|
|
123
74
|
| --- | --- | --- |
|
|
124
|
-
| {CAP-
|
|
75
|
+
| {CAP-id / area} | {component / module} | {AD-id, convention, paradigm} |
|
|
125
76
|
|
|
126
77
|
## Deferred
|
|
127
78
|
|
|
128
|
-
Decisions intentionally pushed down, each with the reason it can wait. The half of the contract
|
|
129
|
-
that keeps the spine lean.
|
|
79
|
+
<!-- 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. -->
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
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.
|
|
4
4
|
|
|
5
|
-
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
|
|
5
|
+
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.
|
|
6
6
|
|
|
7
7
|
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.)
|
|
8
8
|
|
|
9
|
-
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.
|
|
9
|
+
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.
|
|
10
10
|
|
|
11
|
-
**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;
|
|
11
|
+
**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.
|
|
12
12
|
|
|
13
13
|
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.
|
|
@@ -13,7 +13,7 @@ It reads ARCHITECTURE-SPINE.md from a workspace and reports, as compact JSON on
|
|
|
13
13
|
- placeholder literal TBD / TODO / "similar to AD-n" / unfilled {template-token}
|
|
14
14
|
- ad_id duplicate or non-monotonic AD-n identifiers
|
|
15
15
|
- ad_fields an AD-n block missing Binds / Prevents / Rule
|
|
16
|
-
- version_pin a
|
|
16
|
+
- version_pin a ## Stack table row with no version
|
|
17
17
|
|
|
18
18
|
Fenced code blocks are blanked (replaced with equal-count blank lines) before scanning, so
|
|
19
19
|
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]:
|
|
|
150
150
|
return findings
|
|
151
151
|
|
|
152
152
|
|
|
153
|
-
def
|
|
153
|
+
def find_unpinned_stack(body: str, offset: int) -> list[dict]:
|
|
154
|
+
"""Flag a `## Stack` table row that names something but leaves its version blank or a
|
|
155
|
+
placeholder. Pinning lives in the body table now, not frontmatter. A row whose name is
|
|
156
|
+
still a `{token}` skeleton is left to the placeholder pass, not double-reported here.
|
|
157
|
+
|
|
158
|
+
Fences are blanked first (like find_placeholders / find_ad_issues), so a pipe-row or
|
|
159
|
+
heading inside a code block is never read as live Stack content. The heading match is
|
|
160
|
+
`## Stack` with a word boundary, so a renamed heading (`## Stack & Versions`) still
|
|
161
|
+
counts. Name and Version columns are located from the header row, so a reordered table
|
|
162
|
+
pairs name to version correctly; both default to the canonical positions (0, 1)."""
|
|
154
163
|
findings: list[dict] = []
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if
|
|
164
|
+
in_stack = False
|
|
165
|
+
header_seen = False
|
|
166
|
+
name_idx, ver_idx = 0, 1
|
|
167
|
+
scan = blank_fences(body)
|
|
168
|
+
for i, raw in enumerate(scan.splitlines()):
|
|
169
|
+
if HEADING.match(raw):
|
|
170
|
+
in_stack = re.match(r"^##\s+Stack\b", raw) is not None
|
|
171
|
+
header_seen = False
|
|
172
|
+
name_idx, ver_idx = 0, 1
|
|
173
|
+
continue
|
|
174
|
+
if not in_stack or not raw.lstrip().startswith("|"):
|
|
175
|
+
continue
|
|
176
|
+
if set(raw.strip()) <= set("|-: "):
|
|
177
|
+
continue # separator row
|
|
178
|
+
cells = _table_cells(raw)
|
|
179
|
+
if not header_seen:
|
|
180
|
+
header_seen = True
|
|
181
|
+
for j, c in enumerate(cells):
|
|
182
|
+
if c.lower() == "name":
|
|
183
|
+
name_idx = j
|
|
184
|
+
elif c.lower() == "version":
|
|
185
|
+
ver_idx = j
|
|
161
186
|
continue
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if
|
|
165
|
-
in_key_deps = True
|
|
166
|
-
key_indent = indent
|
|
167
|
-
inline = _strip_comment(m.group(1)).strip()
|
|
168
|
-
if inline and inline not in ("[]", "[ ]"):
|
|
169
|
-
# inline list form: key_deps: [a@1, b] — consumed here, no block follows
|
|
170
|
-
for item in re.findall(r"[^\[\],]+", inline.strip("[]")):
|
|
171
|
-
_check_dep(item.strip().strip("'\""), findings)
|
|
172
|
-
in_key_deps = False
|
|
187
|
+
name = cells[name_idx] if len(cells) > name_idx else ""
|
|
188
|
+
version = cells[ver_idx] if len(cells) > ver_idx else ""
|
|
189
|
+
if not name or TEMPLATE_TOKEN.search(name):
|
|
173
190
|
continue
|
|
174
|
-
if
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
else:
|
|
182
|
-
# map form: `name: version` — pinned iff a non-empty value is present
|
|
183
|
-
mm = re.match(r"([^:]+):\s*(.*)$", stripped)
|
|
184
|
-
if mm:
|
|
185
|
-
name = mm.group(1).strip().strip("'\"")
|
|
186
|
-
val = _strip_comment(mm.group(2)).strip().strip("'\"")
|
|
187
|
-
if name and not val:
|
|
188
|
-
findings.append({
|
|
189
|
-
"category": "version_pin",
|
|
190
|
-
"severity": "medium",
|
|
191
|
-
"detail": f"key_deps entry {name!r} has no version pin",
|
|
192
|
-
"location": f"{SPINE} frontmatter stack.key_deps",
|
|
193
|
-
})
|
|
191
|
+
if not version or TEMPLATE_TOKEN.search(version):
|
|
192
|
+
findings.append({
|
|
193
|
+
"category": "version_pin",
|
|
194
|
+
"severity": "medium",
|
|
195
|
+
"detail": f"Stack entry {name!r} has no version",
|
|
196
|
+
"location": f"{SPINE} (line {offset + i + 1})",
|
|
197
|
+
})
|
|
194
198
|
return findings
|
|
195
199
|
|
|
196
200
|
|
|
197
|
-
def
|
|
198
|
-
"""
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if "@" not in item:
|
|
206
|
-
findings.append({
|
|
207
|
-
"category": "version_pin",
|
|
208
|
-
"severity": "medium",
|
|
209
|
-
"detail": f"key_deps entry {item!r} has no @version pin",
|
|
210
|
-
"location": f"{SPINE} frontmatter stack.key_deps",
|
|
211
|
-
})
|
|
201
|
+
def _table_cells(row: str) -> list[str]:
|
|
202
|
+
"""Split a markdown table row into trimmed cells, dropping the leading/trailing pipe."""
|
|
203
|
+
s = row.strip()
|
|
204
|
+
if s.startswith("|"):
|
|
205
|
+
s = s[1:]
|
|
206
|
+
if s.endswith("|"):
|
|
207
|
+
s = s[:-1]
|
|
208
|
+
return [c.strip() for c in s.split("|")]
|
|
212
209
|
|
|
213
210
|
|
|
214
211
|
def lint(text: str) -> dict:
|
|
@@ -217,7 +214,7 @@ def lint(text: str) -> dict:
|
|
|
217
214
|
findings += find_frontmatter_placeholders(frontmatter)
|
|
218
215
|
findings += find_placeholders(body, offset)
|
|
219
216
|
findings += find_ad_issues(body, offset)
|
|
220
|
-
findings +=
|
|
217
|
+
findings += find_unpinned_stack(body, offset)
|
|
221
218
|
counts: dict[str, int] = {}
|
|
222
219
|
for f in findings:
|
|
223
220
|
counts[f["severity"]] = counts.get(f["severity"], 0) + 1
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
The spine under test: a clean spine lints empty; the linter catches exactly the
|
|
8
8
|
mechanical defects a prompt is unreliable at — literal placeholders, AD-n id breakage,
|
|
9
|
-
AD-n blocks missing required fields, and unpinned
|
|
9
|
+
AD-n blocks missing required fields, and unpinned Stack versions.
|
|
10
10
|
"""
|
|
11
11
|
import importlib.util
|
|
12
12
|
import json
|
|
@@ -26,10 +26,6 @@ _SPEC.loader.exec_module(lint_spine)
|
|
|
26
26
|
|
|
27
27
|
CLEAN = """---
|
|
28
28
|
name: 'Demo'
|
|
29
|
-
stack:
|
|
30
|
-
key_deps:
|
|
31
|
-
- fastapi@0.115
|
|
32
|
-
- pydantic@2.9
|
|
33
29
|
---
|
|
34
30
|
|
|
35
31
|
## Invariants & Rules
|
|
@@ -50,6 +46,13 @@ stack:
|
|
|
50
46
|
flowchart LR
|
|
51
47
|
A --> B{decision}
|
|
52
48
|
```
|
|
49
|
+
|
|
50
|
+
## Stack
|
|
51
|
+
|
|
52
|
+
| Name | Version |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| fastapi | 0.115 |
|
|
55
|
+
| pydantic | 2.9 |
|
|
53
56
|
"""
|
|
54
57
|
|
|
55
58
|
|
|
@@ -108,30 +111,32 @@ def test_missing_field_caught():
|
|
|
108
111
|
|
|
109
112
|
|
|
110
113
|
def test_unpinned_dep_caught():
|
|
111
|
-
text = CLEAN.replace("
|
|
114
|
+
text = CLEAN.replace("| fastapi | 0.115 |", "| fastapi | |")
|
|
112
115
|
result = lint_spine.lint(text)
|
|
113
116
|
assert "version_pin" in cats(result)
|
|
114
117
|
|
|
115
118
|
|
|
116
|
-
def
|
|
117
|
-
text = CLEAN.replace("
|
|
119
|
+
def test_placeholder_version_caught():
|
|
120
|
+
text = CLEAN.replace("| fastapi | 0.115 |", "| fastapi | {pin} |")
|
|
118
121
|
result = lint_spine.lint(text)
|
|
119
|
-
|
|
120
|
-
assert len(pins) == 1 and "fastapi" in pins[0]["detail"]
|
|
122
|
+
assert any(f["category"] == "version_pin" and "fastapi" in f["detail"] for f in result["findings"])
|
|
121
123
|
|
|
122
124
|
|
|
123
|
-
def
|
|
124
|
-
text = CLEAN.
|
|
125
|
+
def test_no_stack_section_ok():
|
|
126
|
+
text = CLEAN.split("## Stack")[0]
|
|
125
127
|
result = lint_spine.lint(text)
|
|
126
128
|
assert "version_pin" not in cats(result)
|
|
127
129
|
|
|
128
130
|
|
|
129
|
-
def
|
|
130
|
-
# a
|
|
131
|
-
text = CLEAN.replace(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
131
|
+
def test_stack_skeleton_row_not_version_pinned():
|
|
132
|
+
# a leftover {token} name is the placeholder pass's job, not a double-reported version_pin
|
|
133
|
+
text = CLEAN.replace("| fastapi | 0.115 |", "| {language / framework} | {pinned version} |")
|
|
134
|
+
result = lint_spine.lint(text)
|
|
135
|
+
assert "version_pin" not in cats(result)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_stack_html_comment_not_parsed_as_row():
|
|
139
|
+
text = CLEAN.replace("## Stack\n", "## Stack\n\n<!-- SEED — verified current 2026-06 -->\n")
|
|
135
140
|
result = lint_spine.lint(text)
|
|
136
141
|
assert "version_pin" not in cats(result)
|
|
137
142
|
|
|
@@ -153,7 +158,8 @@ def test_no_frontmatter_body_still_scanned():
|
|
|
153
158
|
|
|
154
159
|
def test_frontmatter_value_with_dashes_not_truncated():
|
|
155
160
|
# a value containing '---' must not be read as the closing fence (line-exact close)
|
|
156
|
-
text = "---\nscope: 'phase 1 --- phase 2'\
|
|
161
|
+
text = ("---\nname: 'x'\nscope: 'phase 1 --- phase 2'\n---\n\n"
|
|
162
|
+
"## Stack\n\n| Name | Version |\n| --- | --- |\n| fastapi | |\n")
|
|
157
163
|
result = lint_spine.lint(text)
|
|
158
164
|
assert any(f["category"] == "version_pin" for f in result["findings"]) # read past the inline ---
|
|
159
165
|
|
|
@@ -168,19 +174,55 @@ def test_ad_heading_in_fence_not_counted():
|
|
|
168
174
|
assert result["ok"] is True # the fenced AD-2 is not a live AD → no ad_fields/ad_id finding
|
|
169
175
|
|
|
170
176
|
|
|
171
|
-
def
|
|
172
|
-
text = "---\
|
|
177
|
+
def test_stack_table_flags_only_the_unpinned_row():
|
|
178
|
+
text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Name | Version |\n| --- | --- |\n"
|
|
179
|
+
"| fastapi | 0.115 |\n| redis | |\n")
|
|
173
180
|
result = lint_spine.lint(text)
|
|
174
181
|
pins = [f for f in result["findings"] if f["category"] == "version_pin"]
|
|
175
182
|
assert len(pins) == 1 and "redis" in pins[0]["detail"]
|
|
176
183
|
|
|
177
184
|
|
|
178
|
-
def
|
|
179
|
-
text = "---\
|
|
185
|
+
def test_stack_table_all_pinned_ok():
|
|
186
|
+
text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Name | Version |\n| --- | --- |\n"
|
|
187
|
+
"| fastapi | 0.115 |\n")
|
|
188
|
+
result = lint_spine.lint(text)
|
|
189
|
+
assert "version_pin" not in cats(result)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_fenced_stack_rows_not_parsed():
|
|
193
|
+
# an illustrative fenced table under ## Stack must not be read as live rows (fences are
|
|
194
|
+
# blanked first, like every other pass) — a blank-version row inside a fence is not a finding
|
|
195
|
+
text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Name | Version |\n| --- | --- |\n"
|
|
196
|
+
"| fastapi | 0.115 |\n\n```text\n| example | |\n```\n")
|
|
197
|
+
result = lint_spine.lint(text)
|
|
198
|
+
assert "version_pin" not in cats(result)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_fenced_stack_heading_not_live():
|
|
202
|
+
# a `## Stack` heading shown inside a code fence is not the live Stack section
|
|
203
|
+
text = ("---\nname: 'x'\n---\n\n## Docs\n\n```md\n## Stack\n\n| foo | |\n```\n")
|
|
180
204
|
result = lint_spine.lint(text)
|
|
181
205
|
assert "version_pin" not in cats(result)
|
|
182
206
|
|
|
183
207
|
|
|
208
|
+
def test_renamed_stack_heading_still_scanned():
|
|
209
|
+
# the heading match is word-boundary, so a varied `## Stack` heading still counts
|
|
210
|
+
text = ("---\nname: 'x'\n---\n\n## Stack & Versions\n\n| Name | Version |\n| --- | --- |\n"
|
|
211
|
+
"| redis | |\n")
|
|
212
|
+
result = lint_spine.lint(text)
|
|
213
|
+
pins = [f for f in result["findings"] if f["category"] == "version_pin"]
|
|
214
|
+
assert len(pins) == 1 and "redis" in pins[0]["detail"]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_reordered_columns_pair_name_to_version():
|
|
218
|
+
# Version-then-Name header: the unpinned row must still be flagged by its real name
|
|
219
|
+
text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Version | Name |\n| --- | --- |\n"
|
|
220
|
+
"| 0.115 | fastapi |\n| | redis |\n")
|
|
221
|
+
result = lint_spine.lint(text)
|
|
222
|
+
pins = [f for f in result["findings"] if f["category"] == "version_pin"]
|
|
223
|
+
assert len(pins) == 1 and "redis" in pins[0]["detail"]
|
|
224
|
+
|
|
225
|
+
|
|
184
226
|
def test_placeholder_line_number_is_absolute():
|
|
185
227
|
# a TBD after a multi-line fence reports its real file line (fence blanked, not collapsed)
|
|
186
228
|
text = (
|