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.
This commit is contained in:
Brian Madison 2026-06-16 11:36:30 -05:00
parent f20aa0defe
commit 1cd98ed0e6
4 changed files with 95 additions and 149 deletions

View File

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

View File

@ -8,106 +8,58 @@ scope: '{what this spine governs}'
status: draft # draft · final status: draft # draft · final
created: '{date}' created: '{date}'
updated: '{date}' updated: '{date}'
stack: # SEED — verified current at authoring; the code owns this once it exists binds: [] # capability / unit IDs governed (from the driving spec; at epic altitude, also the inherited parent AD ids)
languages: []
frameworks: []
key_deps: [] # name@version
binds: [] # capability / unit IDs governed (from the driving spec; at epic altitude, also the parent AD IDs inherited)
sources: [] sources: []
companions: [] companions: []
--- ---
# Architecture Spine — {name} # Architecture Spine — {name}
> A consistency contract, not a design document. It fixes the **invariants** that keep the <!-- 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. -->
> 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 ## Design Paradigm
Name the pattern — a known one loads a whole model for free — and map its layers to namespaces / <!-- 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. -->
directories. The smallest, most durable thing in the file.
## Inherited Invariants ## Inherited Invariants
Present only when this spine inherits a parent at a higher altitude (e.g. an epic spine under a <!-- 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. -->
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 | | 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 ## Invariants & Rules
The durable heart: the calls a future builder can't read from compliant code. Each `AD-n` has a <!-- 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. -->
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} ### 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} - **Prevents:** {the divergence this stops}
- **Rule:** {the constraint downstream must follow} - **Rule:** {the constraint downstream must follow}
## Consistency Conventions ## Consistency Conventions
The defaults that bind everything where independent builders would otherwise drift. Cut rows that <!-- Defaults that bind where independent builders would drift. Cut rows that don't apply; add rows the project needs. -->
don't apply.
| Concern | Convention | | Concern | Convention |
| --- | --- | | --- | --- |
| Naming (entities, files, interfaces, events) | | | 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) | | | 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 ## Structural Seed
Cold-start scaffolding, kept minimal — include an item only where its shape is non-obvious at this <!-- 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. -->
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}"
```
```text ```text
{root}/ {root}/
@ -116,14 +68,12 @@ erDiagram
## Capability → Architecture Map ## 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. Bridges the spec's capabilities to where they live + what governs them; the consistency auditor's checklist. Cut otherwise. -->
Present when a spec drove this run.
| Capability / Area | Lives in | Governed by | | 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 ## Deferred
Decisions intentionally pushed down, each with the reason it can wait. The half of the contract <!-- 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. -->
that keeps the spine lean.

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} - placeholder literal TBD / TODO / "similar to AD-n" / unfilled {template-token}
- ad_id duplicate or non-monotonic AD-n identifiers - ad_id duplicate or non-monotonic AD-n identifiers
- ad_fields an AD-n block missing Binds / Prevents / Rule - 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 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 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 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] = [] findings: list[dict] = []
lines = frontmatter.splitlines() in_stack = False
in_key_deps = False header_seen = False
key_indent = 0 ver_idx = 1
for raw in lines: for i, raw in enumerate(body.splitlines()):
stripped = raw.strip() if HEADING.match(raw):
if not stripped or stripped.startswith("#"): in_stack = re.match(r"^##\s+Stack\s*$", raw) is not None
header_seen = False
ver_idx = 1
continue continue
indent = len(raw) - len(raw.lstrip()) if not in_stack or not raw.lstrip().startswith("|"):
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
continue continue
if in_key_deps: if set(raw.strip()) <= set("|-: "):
if indent <= key_indent and not stripped.startswith("-"): continue # separator row
in_key_deps = False cells = _table_cells(raw)
continue if not header_seen:
if stripped.startswith("-"): header_seen = True
# block-sequence form: `- name@version` for j, c in enumerate(cells):
_check_dep(_strip_comment(stripped[1:]).strip().strip("'\""), findings) if c.lower() == "version":
else: ver_idx = j
# map form: `name: version` — pinned iff a non-empty value is present continue
mm = re.match(r"([^:]+):\s*(.*)$", stripped) name = cells[0] if cells else ""
if mm: version = cells[ver_idx] if len(cells) > ver_idx else ""
name = mm.group(1).strip().strip("'\"") if not name or TEMPLATE_TOKEN.search(name):
val = _strip_comment(mm.group(2)).strip().strip("'\"") continue
if name and not val: if not version or TEMPLATE_TOKEN.search(version):
findings.append({ findings.append({
"category": "version_pin", "category": "version_pin",
"severity": "medium", "severity": "medium",
"detail": f"key_deps entry {name!r} has no version pin", "detail": f"Stack entry {name!r} has no version",
"location": f"{SPINE} frontmatter stack.key_deps", "location": f"{SPINE} (line {offset + i + 1})",
}) })
return findings return findings
def _strip_comment(s: str) -> str: def _table_cells(row: str) -> list[str]:
"""Drop a trailing YAML ` # comment`, leaving an inline `name@1.2` intact.""" """Split a markdown table row into trimmed cells, dropping the leading/trailing pipe."""
return re.sub(r"(^|\s)#.*$", "", s) s = row.strip()
if s.startswith("|"):
s = s[1:]
def _check_dep(item: str, findings: list[dict]) -> None: if s.endswith("|"):
if not item or item.startswith("#"): s = s[:-1]
return return [c.strip() for c in s.split("|")]
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 lint(text: str) -> dict: def lint(text: str) -> dict:
@ -217,7 +205,7 @@ def lint(text: str) -> dict:
findings += find_frontmatter_placeholders(frontmatter) findings += find_frontmatter_placeholders(frontmatter)
findings += find_placeholders(body, offset) findings += find_placeholders(body, offset)
findings += find_ad_issues(body, offset) findings += find_ad_issues(body, offset)
findings += find_unpinned_deps(frontmatter) findings += find_unpinned_stack(body, offset)
counts: dict[str, int] = {} counts: dict[str, int] = {}
for f in findings: for f in findings:
counts[f["severity"]] = counts.get(f["severity"], 0) + 1 counts[f["severity"]] = counts.get(f["severity"], 0) + 1

View File

@ -26,10 +26,6 @@ _SPEC.loader.exec_module(lint_spine)
CLEAN = """--- CLEAN = """---
name: 'Demo' name: 'Demo'
stack:
key_deps:
- fastapi@0.115
- pydantic@2.9
--- ---
## Invariants & Rules ## Invariants & Rules
@ -50,6 +46,13 @@ stack:
flowchart LR flowchart LR
A --> B{decision} 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(): def test_unpinned_dep_caught():
text = CLEAN.replace("- fastapi@0.115", "- fastapi") text = CLEAN.replace("| fastapi | 0.115 |", "| fastapi | |")
result = lint_spine.lint(text) result = lint_spine.lint(text)
assert "version_pin" in cats(result) assert "version_pin" in cats(result)
def test_inline_key_deps_unpinned(): def test_placeholder_version_caught():
text = CLEAN.replace(" key_deps:\n - fastapi@0.115\n - pydantic@2.9", " key_deps: [fastapi, redis@7]") text = CLEAN.replace("| fastapi | 0.115 |", "| fastapi | {pin} |")
result = lint_spine.lint(text) result = lint_spine.lint(text)
pins = [f for f in result["findings"] if f["category"] == "version_pin"] assert any(f["category"] == "version_pin" and "fastapi" in f["detail"] for f in result["findings"])
assert len(pins) == 1 and "fastapi" in pins[0]["detail"]
def test_empty_key_deps_ok(): def test_no_stack_section_ok():
text = CLEAN.replace(" key_deps:\n - fastapi@0.115\n - pydantic@2.9", " key_deps: []") text = CLEAN.split("## Stack")[0]
result = lint_spine.lint(text) result = lint_spine.lint(text)
assert "version_pin" not in cats(result) assert "version_pin" not in cats(result)
def test_yaml_comments_not_parsed_as_deps(): def test_stack_skeleton_row_not_version_pinned():
# a SEED comment on the key_deps line must not read as an unpinned dependency # a leftover {token} name is the placeholder pass's job, not a double-reported version_pin
text = CLEAN.replace( text = CLEAN.replace("| fastapi | 0.115 |", "| {language / framework} | {pinned version} |")
" key_deps:\n - fastapi@0.115\n - pydantic@2.9", result = lint_spine.lint(text)
" key_deps: # SEED — verified current 2026-06\n - fastapi@0.115 # web framework", 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) result = lint_spine.lint(text)
assert "version_pin" not in cats(result) 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(): def test_frontmatter_value_with_dashes_not_truncated():
# a value containing '---' must not be read as the closing fence (line-exact close) # 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) result = lint_spine.lint(text)
assert any(f["category"] == "version_pin" for f in result["findings"]) # read past the inline --- 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 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(): def test_stack_table_flags_only_the_unpinned_row():
text = "---\nstack:\n key_deps:\n fastapi: '0.115'\n redis:\n---\n\n## Invariants\n" text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Name | Version |\n| --- | --- |\n"
"| fastapi | 0.115 |\n| redis | |\n")
result = lint_spine.lint(text) result = lint_spine.lint(text)
pins = [f for f in result["findings"] if f["category"] == "version_pin"] pins = [f for f in result["findings"] if f["category"] == "version_pin"]
assert len(pins) == 1 and "redis" in pins[0]["detail"] assert len(pins) == 1 and "redis" in pins[0]["detail"]
def test_map_form_key_deps_pinned_ok(): def test_stack_table_all_pinned_ok():
text = "---\nstack:\n key_deps:\n fastapi: '0.115'\n---\n\n## Invariants\n" text = ("---\nname: 'x'\n---\n\n## Stack\n\n| Name | Version |\n| --- | --- |\n"
"| fastapi | 0.115 |\n")
result = lint_spine.lint(text) result = lint_spine.lint(text)
assert "version_pin" not in cats(result) assert "version_pin" not in cats(result)