From 1cd98ed0e6c4fd2f8ea12840f4141b66a0080319 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Tue, 16 Jun 2026 11:36:30 -0500 Subject: [PATCH] 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. --- .../3-solutioning/bmad-architecture/SKILL.md | 2 +- .../assets/spine-template.md | 92 ++++------------- .../bmad-architecture/scripts/lint_spine.py | 98 ++++++++----------- .../scripts/tests/test_lint_spine.py | 52 +++++----- 4 files changed, 95 insertions(+), 149 deletions(-) diff --git a/src/bmm-skills/3-solutioning/bmad-architecture/SKILL.md b/src/bmm-skills/3-solutioning/bmad-architecture/SKILL.md index 8b2797f49..61a49ee8a 100644 --- a/src/bmm-skills/3-solutioning/bmad-architecture/SKILL.md +++ b/src/bmm-skills/3-solutioning/bmad-architecture/SKILL.md @@ -67,7 +67,7 @@ The spine's pre-handoff review — full mechanics in `references/reviewer-gate.m 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. 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. +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. 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/scripts/lint_spine.py b/src/bmm-skills/3-solutioning/bmad-architecture/scripts/lint_spine.py index 88fa3e06c..0836a90a3 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,53 @@ 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.""" 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 + ver_idx = 1 + for i, raw in enumerate(body.splitlines()): + if HEADING.match(raw): + in_stack = re.match(r"^##\s+Stack\s*$", raw) is not None + header_seen = False + ver_idx = 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() == "version": + ver_idx = j + continue + name = cells[0] if cells 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 +205,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..9f69ffc19 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 @@ -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,15 +174,17 @@ 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)