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:
parent
f20aa0defe
commit
1cd98ed0e6
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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. -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue