task-pipeline-skill 1.7.0 → 1.7.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,103 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.7.2 — 2026-08-03
4
+
5
+ ### Fixed — nine findings from a post-release investigation of v1.7.1
6
+
7
+ The release was audited against the skill's own ladder, bottom-up, findings ordered
8
+ by seam. Every one below was proven before it was fixed and again after.
9
+
10
+ **The gate enforced one of the two register shapes it promises.** `documentation.md`
11
+ permits two decision homes — `docs/DECISIONS.md` or `docs/adr/` — and says they owe
12
+ the same six things; `docgate.sh` parsed only the first. Measured on a real ADR
13
+ project: **eight of ten sections went `dormant`**, dormant is green by design, and a
14
+ planted propagation violation was not caught. The gate now builds a **normalised
15
+ entry index** from whichever home exists, so no section knows which shape it is
16
+ reading, and holding both at once is itself an error. Seven planted defects on the
17
+ ADR shape, all firing.
18
+
19
+ Two of those probes exposed bugs in the checks rather than in the fixtures — the
20
+ fixture is derived from `templates/adr.md`'s own fenced example, so it cannot drift
21
+ from the documented format. HTML comments were not stripped, so a status line
22
+ carrying `<!-- or: Superseded by ADR-0012 -->` made an entry read as retired *and*
23
+ invented an undefined id; and the first fix dropped the line that **opened** the
24
+ comment, throwing away the `Status:` before it. Duplicate ADR numbers were counted
25
+ from the entry index, whose one-row-per-id dedupe swallowed exactly the second file
26
+ this check exists to find — it counts filenames now, because the filename is the
27
+ allocator.
28
+
29
+ **Exit 0 was not proof that the gate had looked.** Every section can go `dormant`,
30
+ so a gate blind to a shape passes identically to one that reads it. The validator
31
+ now asserts the seeded run **reports which shape it found** and **ran at least N
32
+ live checks** — the difference between "it did not fail" and "it looked".
33
+
34
+ **The Doc Loop was declared cross-cutting and appeared in no stage doctrine.**
35
+ `brainstorm.md`, `spec.md`, `build.md`, `review.md` and `acceptance.md` had zero
36
+ mentions of it — so the flow as an agent *executes* it never ran the loop, because
37
+ an agent opens the stage file, not the orchestrator's summary. All five now say
38
+ where a settled decision goes, and it is a guard.
39
+
40
+ Most of that gap was at stage 5, which settles more decisions than any other stage
41
+ and runs in an isolated worktree with parallel implementers. The rule is now
42
+ explicit and argued from the same physics as the existing parallel-fan-out rule:
43
+ **a subagent never writes the register** — append-only shared state cannot be
44
+ hand-merged across worktrees, and an id cannot be *reserved* from a branch that
45
+ cannot see the other writers. Decisions ride the implementer report and the ledger;
46
+ the orchestrator runs the loop after integration, as a single writer.
47
+
48
+ **`hooks.md` stated an external contract from memory.** Re-fetched from the Claude
49
+ Code hooks reference and corrected: `permissionDecision` has **four** values, not
50
+ one; there are **35** events, not the four listed; `effort`, `agent_id` and
51
+ `agent_type` were missing from the stdin fields; `if` is evaluated on five tool
52
+ events and its Bash matching is best-effort. The reference also says outright that
53
+ **exit 1 is non-blocking "even though 1 is the conventional Unix failure code"** —
54
+ which is the sharpest possible argument for the `|| exit 2` this file already
55
+ required. Provenance and fetch date are now in the file, because stage 1 of this
56
+ pipeline exists for exactly this.
57
+
58
+ **Smaller, and all real:** a "we don't document" escape hatch in `documentation.md`
59
+ contradicted the seeding rule four lines above it and the stage-0 gate; the stage-9
60
+ config gate carried both the retired criterion *"docs in sync with code"* and the
61
+ sentence declaring it retired; a lost edit meant the grill never asked the
62
+ documentation-regime question the brief had a field for — and the sweep-drift guard
63
+ missed it because it compared **stage numbers**, which both files still matched, so
64
+ it now compares topics per stage (measured: zero false positives, including the
65
+ legitimate case where the brief splits one grill row into two); `acceptance.md`
66
+ never mentioned the documentation gate it is supposed to prove; and nested bold in
67
+ the stage-10 gate criterion inverted the emphasis of everything after "every
68
+ deletion logged".
69
+
70
+ Three new guards, each with a negative self-test watched failing.
71
+ **46 of 46 guards provably reject their planted defect.**
72
+
73
+ ## v1.7.1 — 2026-08-03
74
+
75
+ ### Fixed — the tag-ancestry gate had been failing on every release since v1.6.1
76
+
77
+ The step that exists so *"a release does not live only on a tag"* — added after the
78
+ v1.4.4 incident — **failed on every tag from v1.6.1 onward**, and nobody saw it,
79
+ because `release` ran green beside it and the failing log showed only the echoed
80
+ script with no output of its own.
81
+
82
+ `git fetch --tags` without `--force` aborts with *"would clobber existing tag"* when
83
+ the checkout has already created `refs/tags/<tag>` locally and the tag is
84
+ **annotated**. `set -eu` then killed the step before its first `echo` — which is why
85
+ the log was blank and read as though the ancestry check itself had found something.
86
+ v1.6.1 was the first tag cut with `git tag -a`; every one since inherited it.
87
+
88
+ Two changes, and the second matters more than the first:
89
+
90
+ - `--force` on the tag fetch, with a printed note if it still degrades.
91
+ - **The step now says which failure it is.** If `refs/remotes/origin/main` cannot be
92
+ resolved, every tag looks like an orphan and the old wording reported a
93
+ catastrophe that was really a missing ref. It now fails with that sentence
94
+ instead, and on success prints the tag count and the `main` it checked against.
95
+
96
+ This is the shape the repo's own doctrine names: a gate whose exit code nobody reads
97
+ is a gate that has stopped guarding — `references/gates.md` → *A gate's exit code is
98
+ part of its output*, and `references/learned.md` rule 11. It was found by watching
99
+ the release this release shipped, which is the only reason it was found at all.
100
+
3
101
  ## v1.7.0 — 2026-08-03
4
102
 
5
103
  ### Added — documentation is a deliverable, and it has a gate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "task-pipeline-skill",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "Full-cycle delivery pipeline for coding agents: a mandatory built-in intake grill, then 10 gated stages (docs, brainstorm+decompose, spec, plan, build, tests, lint/deploy, post-deploy, docs/wiki, acceptance). Every stage's doctrine ships inside the skill — no companion plugin required. This package is the installer CLI.",
5
5
  "bin": {
6
6
  "task-pipeline": "bin/task-pipeline.js"
@@ -2,7 +2,7 @@
2
2
  "name": "task-pipeline",
3
3
  "displayName": "Task Pipeline",
4
4
  "description": "Runs a substantial task through a mandatory built-in intake grill, then 10 gated stages (docs, brainstorm+decompose, spec, plan, subagent build, tests, lint/deploy, post-deploy, docs/wiki, acceptance). Every stage's doctrine is built into the skill — no companion plugin required — with typed auto/manual gates, a frozen requirement spine that must close with evidence, a loop guard that breaks churn, one provider-agnostic model confirmed up front, and an optional super-ux UX track for user-facing work.",
5
- "version": "1.7.0",
5
+ "version": "1.7.2",
6
6
  "author": {
7
7
  "name": "ssheleg",
8
8
  "url": "https://x.com/sshlg93"
@@ -151,7 +151,7 @@
151
151
  ],
152
152
  "gate": {
153
153
  "type": "auto",
154
- "check": "the stage-0 source ledger is the work list — every source the harvest read is updated if this run changed or disproved it; docs in sync with code in the same change; wiki synced via wiki-update when obsidian-wiki is installed (absent → recommended once, never a blocker); THE CODE GRAPH REFRESHED — '/graphify . --update' where graphify-out/ exists (references/knowledge-graph.md), or the reason it was skipped written into the carry-over ledger; absent → recommended once, never a blocker. The close-out has three artifacts, not two: the graph is what the NEXT run's harvest queries first, so a stale one is a false premise carrying the authority of a machine. Then the cheap half of the GRAPH-VS-DOCS DIVERGENCE CHECK: a hub 'graphify god-nodes' reports that no document names is an undocumented seam, an edge the docs deny is either a leak in the code or a lie in the docs, and a doc naming a module the graph has no node for describes something that no longer exists — doc-side findings fixed here, absences handed to stage 10's ladder walk as REQ rows. Docs living in another repository are outward — proposed with an explicit go, or carried over with the exact edit; dangling links fixed. THE PROPAGATION SWEEP RUNS FIRST (references/documentation.md): the source ledger names the documents this run READ, the matrix in docs/DOCMAP.md names the documents it OWES, and they are not the same list — the document nobody read is exactly the document nobody updated. Walk the matrix row for every change type this run produced; every settled thing carries an id in the register; every answered question is flipped to Resolved-> that id; every document named in a 'Consequences / affects:' line cites its decision. THEN THE DOCUMENTATION GATE IS GREEN — the command docs/DOCMAP.md names (bash scripts/check-docs.sh by default) — with its RATCHET COUNTS PRINTED beside the verdict and any skipped check stating that it skipped (references/gates.md), so 'green' reads as 'green, and here is exactly what was not looked at'. This replaced the unfalsifiable 'docs in sync with code', which named no artefact and no command. The as-built record is written and reconciled, and the carry-over count is printed beside this verdict"
154
+ "check": "the stage-0 source ledger is the work list — every source the harvest read is updated if this run changed or disproved it; host module docs and runbooks updated in the same change; wiki synced via wiki-update when obsidian-wiki is installed (absent → recommended once, never a blocker); THE CODE GRAPH REFRESHED — '/graphify . --update' where graphify-out/ exists (references/knowledge-graph.md), or the reason it was skipped written into the carry-over ledger; absent → recommended once, never a blocker. The close-out has three artifacts, not two: the graph is what the NEXT run's harvest queries first, so a stale one is a false premise carrying the authority of a machine. Then the cheap half of the GRAPH-VS-DOCS DIVERGENCE CHECK: a hub 'graphify god-nodes' reports that no document names is an undocumented seam, an edge the docs deny is either a leak in the code or a lie in the docs, and a doc naming a module the graph has no node for describes something that no longer exists — doc-side findings fixed here, absences handed to stage 10's ladder walk as REQ rows. Docs living in another repository are outward — proposed with an explicit go, or carried over with the exact edit; dangling links fixed. THE PROPAGATION SWEEP RUNS FIRST (references/documentation.md): the source ledger names the documents this run READ, the matrix in docs/DOCMAP.md names the documents it OWES, and they are not the same list — the document nobody read is exactly the document nobody updated. Walk the matrix row for every change type this run produced; every settled thing carries an id in the register; every answered question is flipped to Resolved-> that id; every document named in a 'Consequences / affects:' line cites its decision. THEN THE DOCUMENTATION GATE IS GREEN — the command docs/DOCMAP.md names (bash scripts/check-docs.sh by default) — with its RATCHET COUNTS PRINTED beside the verdict and any skipped check stating that it skipped (references/gates.md), so 'green' reads as 'green, and here is exactly what was not looked at'. This replaced the unfalsifiable 'docs in sync with code', which named no artefact and no command. The as-built record is written and reconciled, and the carry-over count is printed beside this verdict"
155
155
  }
156
156
  },
157
157
  {
@@ -87,6 +87,12 @@ Run: <branch/commit range> · Date: YYYY-MM-DD
87
87
  | `deferred` | agreed not to do it now | the operator's agreement **and** a tracker entry |
88
88
  | `dropped` | agreed it isn't wanted | the operator's agreement + the reason |
89
89
 
90
+ **A `dropped` is a scope decision, and it outlives this run.** The operator's
91
+ agreement closes the row here; the **Doc Loop**
92
+ ([`documentation.md`](documentation.md)) is what stops the same requirement being
93
+ re-proposed next quarter by someone who never saw this table. A `deferred` needs its
94
+ tracker entry, not an entry in the register — it is a schedule, not a decision.
95
+
90
96
  Those four are the only ways a requirement may close. Anything that fits none of
91
97
  them is `unknown`, and **`unknown` fails the gate** — that is the whole mechanism:
92
98
  the run cannot end while a requirement is still unclassified.
@@ -187,8 +193,13 @@ All of:
187
193
  bottom-up, findings ordered by seam, absences turned into REQ rows **before**
188
194
  the table was written, and the two pass counts recorded.
189
195
  2. **Every check this gate leans on has been seen failing** at least once against a
190
- planted defect (`audit.md` → *Exit criterion*). An unproven check's green is not
191
- evidence.
196
+ planted defect (`audit.md` → *Exit criterion*; the procedure, with the commands,
197
+ is [`gates.md`](gates.md) → *Probing*). An unproven check's green is not
198
+ evidence. That includes **the documentation gate** the project's doc map names
199
+ ([`documentation.md`](documentation.md)) — stage 9 ran it, this stage is where it
200
+ is *proven*, and its **ratchet counts are printed beside this verdict**. A
201
+ documentation gate is the easiest one in a run to inherit unproven, because it
202
+ was green the first time anyone looked at it.
192
203
  3. **Every REQ has a status** — none `unknown`, none blank.
193
204
  4. **Every `verified` carries evidence** of the kind above.
194
205
  5. **Every `partial` names what's missing** and where it's tracked.
@@ -88,6 +88,18 @@ it arms the stage-3 UX track ([`spec.md`](spec.md) → *UX track*). When it's
88
88
  genuinely borderline, record "yes": a false positive costs one extra chain, a false
89
89
  negative ships an unspecified interface.
90
90
 
91
+ ## The approved design is a set of decisions — record them
92
+
93
+ An approved approach is a decision, and so is each alternative rejected for a reason
94
+ worth remembering. Run the **Doc Loop** ([`documentation.md`](documentation.md)) on
95
+ the ones that will outlive this run: the shape chosen, the boundary drawn, the option
96
+ deliberately not taken. Not every preference — the ones a future reader would
97
+ otherwise re-litigate from scratch, which is the same test an ADR applies.
98
+
99
+ The cost of skipping it is specific: a design approved in conversation and recorded
100
+ only in the spec dies with that spec, and the next run re-opens a question the
101
+ operator already answered.
102
+
91
103
  ## GATE (manual)
92
104
 
93
105
  The operator approves the design **and** the UI verdict is recorded **and every REQ
@@ -177,6 +177,35 @@ Record the implementer's agent identity: fix rounds 1–3 resume it.
177
177
  > commit range, a one-line test summary, and your concerns. Ask before starting
178
178
  > if anything in the brief is ambiguous — questions are cheaper than rework.
179
179
 
180
+ ### 4.1a Decisions settled inside a task — and who may write them down
181
+
182
+ The report above already asks for *"decisions you made"*, and stage 5 settles more
183
+ of them than any other stage: an interface picked between two tasks, a ruling on a
184
+ review finding, a constraint discovered in the code. Every one is a Doc Loop trigger
185
+ ([`documentation.md`](documentation.md)) — and this is the one stage where running
186
+ that loop naively breaks something.
187
+
188
+ **A subagent never writes the register.** Not a style rule; the same physical
189
+ argument as §4.2 below, one level up:
190
+
191
+ - The register is **append-only shared state**. Two implementers appending in two
192
+ worktrees conflict on the one file a project cannot afford to hand-merge, and the
193
+ loser's entry is the one that quietly disappears.
194
+ - An **id cannot be reserved from inside an isolated branch.** Reserving *is* an
195
+ arbitration between concurrent writers, and a worktree is by construction unable
196
+ to see the other writers ([`documentation.md`](documentation.md) → *Registers are
197
+ shared state*).
198
+
199
+ So the route is fixed: a decision settled in a task goes into the **implementer
200
+ report**, and into the **carry-over ledger** if it outlives the task — and the
201
+ **orchestrator runs the Doc Loop after integration**, on the base branch, as a
202
+ single writer. Nothing is lost and nothing collides.
203
+
204
+ **What that costs, said out loud:** between the ruling and the entry there is a
205
+ window in which the decision exists only in a report. That is exactly why the gate
206
+ below harvests every report and parked finding into the ledger **before the scratch
207
+ workspace is deleted** — the ledger is what survives the window.
208
+
180
209
  ### 4.2 Parallel groups — when fan-out is allowed
181
210
 
182
211
  The plan's parallel groups ([`planning.md`](planning.md)) describe what *may* run
@@ -353,7 +382,10 @@ code quality); the full test suite green; every open finding either fixed or par
353
382
  with a ruling; **every parked finding and implementer concern harvested into the
354
383
  carry-over ledger** — the workspace is deleted, so nothing may stay only there;
355
384
  no task left BLOCKED; the branch integrated per the brief's policy — or the
356
- operator explicitly told you to leave it, and that is recorded. Verify it yourself;
385
+ operator explicitly told you to leave it, and that is recorded. **Every decision a
386
+ task settled has run the Doc Loop after integration, written by the orchestrator on
387
+ the base branch** (§4.1a — a subagent never writes the register), or is sitting in
388
+ the ledger with its entry still owed. Verify it yourself;
357
389
  a red suite or an unresolved BLOCKED does not advance to stage 6.
358
390
 
359
391
  ## Rationalizations
@@ -31,8 +31,12 @@ A project with no answers gets them seeded. The seeding is itself recorded as th
31
31
  first entry in the register, which is the cheapest possible demonstration that the
32
32
  register works.
33
33
 
34
- **A project that answers "we don't document" has still answered.** Write that down,
35
- with the date, and the next agent stops re-deciding it every run.
34
+ **There is no "we don't document" answer.** The four questions have answers in every
35
+ repository the smallest one still decides *somewhere* that a thing is true — and
36
+ the only choice is whether that answer is written down or re-derived by each new
37
+ reader. What scales down is **volume**, never the rules: a register with three
38
+ entries is a register, and the seeded gate is green on exactly those three
39
+ ([`gates.md`](gates.md) → *progressive arming*).
36
40
 
37
41
  ---
38
42
 
@@ -131,6 +131,7 @@ explicit "stop and ask me here":
131
131
  |---|---|
132
132
  | run-wide | the model decision ([`model-tiering.md`](model-tiering.md)); what to decide autonomously vs escalate |
133
133
  | 0 Harvest | doc sources beyond this repo — other repos, hosted doc systems, the knowledge wiki, **the code graph** ([`knowledge-graph.md`](knowledge-graph.md): built / installed-not-built / absent) — and whether stage 9 may write to them (another repo is outward: propose + PR, never a direct push) |
134
+ | 0 Docs regime | where settled things live (the decision home — **one** per project, and an existing `docs/adr/` **is** it), who may write it, whether a lease mechanism is present or the run is `ungated`, the gate command and its ratchet floors, and whether this run may raise a floor ([`documentation.md`](documentation.md)) |
134
135
  | 1 Docs | external libs/APIs/SDKs in play; any private ones context7 can't resolve → where their docs live |
135
136
  | 2 Decompose | is this a platform (several capabilities/surfaces) or one module? if platform: deploy cadence — per module or once at the end |
136
137
  | 2–3 Spec | UI verdict (arms super-ux); any scenario-tracing waiver |
@@ -16,13 +16,29 @@ The gap between "the rule exists" and "the rule is enforced" is invisible from
16
16
  inside a transcript, and a false guarantee is worse than a stated absence: everyone
17
17
  downstream stops checking.
18
18
 
19
+ > **Provenance.** Every contract below is quoted from the Claude Code hooks
20
+ > reference (`code.claude.com/docs/en/hooks`), fetched **2026-08-03**. Re-fetch
21
+ > before relying on it: this is an external API, and stage 1 of this very pipeline
22
+ > exists because a contract recalled from memory is a contract that has already
23
+ > moved. Where the reference and this file disagree, the reference wins and this
24
+ > file is the bug.
25
+
19
26
  ## The events
20
27
 
28
+ There are **35** hook events. These are the ones this pipeline reaches for; the
29
+ reference has the rest, grouped as session lifecycle, per-turn, tool execution,
30
+ subagents and tasks, file and config changes, compaction, worktrees, display, and
31
+ MCP elicitation.
32
+
21
33
  | Event | Fires | Used for |
22
34
  |---|---|---|
23
35
  | `SessionStart` | session opens (matcher `startup\|resume`) | register the run, print the board, name the one next action |
24
- | `PreToolUse` | before a tool call | **block** — the only event that can refuse |
25
- | `PostToolUse` | after every tool call | bookkeeping: renew a lease, stamp a marker |
36
+ | `PreToolUse` | before a tool call | **block** — the event that can refuse |
37
+ | `PostToolUse` | after a tool call | bookkeeping: renew a lease, stamp a marker |
38
+ | `Stop` | the turn ends | a last-word check — the run's own gate, not the repo's |
39
+ | `SubagentStart` · `SubagentStop` | a subagent starts or finishes | stage 5 runs implementers as subagents; this is where a per-agent identity or ledger line belongs |
40
+ | `WorktreeCreate` · `WorktreeRemove` | a worktree appears or goes | stage 5 isolates in worktrees; a guard that must not fire inside one can key off these |
41
+ | `PreCompact` · `PostCompact` | context is compacted | flush anything that only lives in context — the ledger exists because this happens |
26
42
  | `SessionEnd` | session closes | release leases, flush the journal |
27
43
 
28
44
  ## The `PreToolUse` contract
@@ -38,22 +54,44 @@ A hook blocks a call in **either** of two ways:
38
54
  "permissionDecisionReason":"docs gate failed: 2 undefined ids in docs/ARCHITECTURE.md"}}
39
55
  ```
40
56
 
57
+ `permissionDecision` takes **four** values, not one: `allow` (permit it), `deny`
58
+ (block it), `ask` (escalate to the user), `defer` (fall through to the normal
59
+ permission flow). Exit 0 with empty stdout means `defer` by omission. A hook may
60
+ also rewrite the call instead of judging it, by returning `updatedInput` in the
61
+ same block — which is a different power from blocking and worth knowing before you
62
+ reach for it.
63
+
41
64
  **Any other exit code is a non-blocking error**: execution continues and stderr is
42
- shown in the transcript. So **a crashing guard fails open** it stops guarding and
43
- nothing announces that it has. Write the guard to `exit 2` on its own internal
44
- errors, or accept that a typo in it silently removes the protection everyone
45
- believes is there.
65
+ shown in the transcript. The reference is explicit that **exit 1 is treated as
66
+ non-blocking, "even though 1 is the conventional Unix failure code"** so the
67
+ single most likely way to write a guard, `command || exit 1`, is the one that does
68
+ not guard. And on exit 2 Claude Code **ignores stdout and any JSON in it**; only
69
+ stderr is read back.
70
+
71
+ So **a crashing guard fails open** — it stops guarding and nothing announces that
72
+ it has. Write the guard to `exit 2` on its own internal errors, or accept that a
73
+ typo in it silently removes the protection everyone believes is there.
46
74
 
47
75
  That asymmetry is the whole reason this file leads with the limit: a hook is the
48
76
  strongest rung and the one whose failure is quietest.
49
77
 
50
78
  ## What the hook receives
51
79
 
52
- JSON on stdin: `session_id`, `prompt_id`, `transcript_path`, `cwd`,
53
- `permission_mode`, `hook_event_name`, `tool_name`, `tool_input`, `tool_use_id`.
80
+ JSON on stdin. Common to every event: `session_id`, `transcript_path`, `cwd`,
81
+ `permission_mode` (`default` · `plan` · `acceptEdits` · `auto` · `dontAsk` ·
82
+ `bypassPermissions`), `effort` (an object with `level`), `hook_event_name`, and —
83
+ inside a subagent — `agent_id` and `agent_type`. `prompt_id` is present from a
84
+ recent version onward, so treat it as optional unless you pin one.
54
85
 
55
- `tool_input` is where the target lives — `file_path` for an edit, `command` for a
56
- Bash call. Parse it; do not infer the target from anything else in the environment.
86
+ Tool events add `tool_name`, `tool_input` and `tool_use_id`. **`tool_input` is
87
+ where the target lives** `file_path` for an edit, `command` for a Bash call.
88
+ Parse it; do not infer the target from anything else in the environment
89
+ ([`learned.md`](learned.md) rule 15: a heuristic over strings the environment also
90
+ produces matched the throwaway shell of every tool call).
91
+
92
+ `agent_id` matters more here than it looks: stage 5 runs implementers as subagents
93
+ in worktrees, and a guard that must behave differently for the orchestrator and for
94
+ an implementer has exactly one honest way to tell them apart.
57
95
 
58
96
  ## Where it lives
59
97
 
@@ -77,6 +115,17 @@ machine, and the first surprising denial is debugged in the wrong project.
77
115
  - for a specific shell command, add `"if": "Bash(git commit *)"` beside
78
116
  `"matcher": "Bash"`.
79
117
 
118
+ `if` uses **permission-rule syntax** (`Bash(git *)`, `Edit(*.ts)`) and is evaluated
119
+ **only on tool events** — `PreToolUse`, `PostToolUse`, `PostToolUseFailure`,
120
+ `PermissionRequest`, `PermissionDenied`. Anywhere else it is inert, which is a
121
+ silent way to write a guard that never fires.
122
+
123
+ **For Bash the match is best-effort — the reference's own word.** It inspects
124
+ subcommands, `$()` expansions and backticks, and strips leading `FOO=bar`
125
+ assignments before matching. So `if` is a good **filter** and a bad **boundary**:
126
+ narrow with it to keep the hook cheap, then re-check the real target inside the
127
+ script before refusing anything.
128
+
80
129
  Match as **narrowly** as the rule allows. A `"*"` matcher on a blocking event puts
81
130
  your script in the path of every tool call the agent makes.
82
131
 
@@ -182,6 +182,19 @@ A checklist you run yourself, inline. No subagent:
182
182
  `depends:` points at a task that really produces what's consumed.
183
183
  6. **DoD present and verifiable** on every task.
184
184
 
185
+ ## This stage settles nothing — and that is a rule, not an omission
186
+
187
+ Planning **translates** decisions; it does not make them. So unlike stages 2, 3, 5
188
+ and 10 there is no Doc Loop trigger here ([`documentation.md`](documentation.md)),
189
+ and the reason is worth stating, because an unstated exclusion is indistinguishable
190
+ from a gap.
191
+
192
+ The consequence is the working rule: **if writing the plan forces a choice, the
193
+ choice belongs to a lower layer.** A contract that turns out underspecified goes
194
+ back to stage 3 and is recorded there; a scope question goes back to the operator.
195
+ A decision first made while sequencing tasks is a decision nothing downstream will
196
+ ever find, because nobody reads a plan after the build.
197
+
185
198
  ## GATE (auto)
186
199
 
187
200
  **Set equality first:** the REQ ids in the brief equal the union of `Implements:`
@@ -167,6 +167,13 @@ finding either.
167
167
  > which can stand and why. Findings only, with severity and a concrete failure
168
168
  > scenario each. Return the findings as your final message.
169
169
 
170
+ **A ruling that parks a finding is a decision.** *"This stands, and here is why"* is
171
+ exactly the sentence a future reader will hit in the code and re-litigate, so a
172
+ ruling that outlives the run goes through the **Doc Loop**
173
+ ([`documentation.md`](documentation.md)) — via the report and the ledger, written by
174
+ the orchestrator after integration, never by a subagent
175
+ ([`build.md`](build.md) → *§4.1a*).
176
+
170
177
  Run the final review on the **run's confirmed model** like everything else
171
178
  ([`model-tiering.md`](model-tiering.md)). It is the one review that sees the whole
172
179
  change, so if the run is on a tier below the most capable one available, say so and
@@ -134,6 +134,18 @@ Read what you wrote with fresh eyes and fix inline. No subagent, no second pass:
134
134
  5. **Ambiguity:** can any requirement be read two ways? Pick one and say it.
135
135
  6. **Traceability (UI):** does every user-facing requirement name its scenario ID?
136
136
 
137
+ ## Locked contracts are decisions — the register, not only the spec
138
+
139
+ This stage settles more than any other: a schema, a signature, a status vocabulary,
140
+ an error shape. Each has a life longer than the document it is written into, so run
141
+ the **Doc Loop** ([`documentation.md`](documentation.md)) for the ones that bind
142
+ future work — record the entry, propagate by the matrix, and cite the id from the
143
+ spec section instead of restating the reasoning there.
144
+
145
+ **A spec states a contract; the register makes it addressable.** A spec is per-run
146
+ and the next one supersedes it; an id survives. If this stage settled something and
147
+ no entry names it, the run has agreed to decide it again later.
148
+
137
149
  ## GATE (manual)
138
150
 
139
151
  > "Spec written and committed to `<path>`. Review it and tell me if anything should
@@ -382,7 +382,8 @@ stages/agents/types (see SKILL.md → *Bring your own skills*).
382
382
  notices it is the same one. So, in this order: **prune first** (every standing
383
383
  instruction against its three retirement triggers — it became a check, its
384
384
  surface is gone, it hasn't fired in five run stamps — and the list held to its cap
385
- of ten, every deletion logged), **stamp the run**, then **write an entry only if
385
+ of ten, every deletion logged **in the archive, with the commit that retired it**),
386
+ **stamp the run**, then **write an entry only if
386
387
  the run diverged** (symptom · the stage it surfaced at · the stage that *owned* it
387
388
  · root cause · fix, mechanical before instruction before expiring note · the check
388
389
  that catches it next time). Every run prunes and stamps; a retro left empty after
@@ -390,9 +391,9 @@ stages/agents/types (see SKILL.md → *Bring your own skills*).
390
391
  instructions in full next time, which is why the cap is not negotiable.
391
392
  - **GATE (manual):** the ladder walk ran and its absences became REQ rows before
392
393
  the table was written; **the retrospective is written — prune before entry, the
393
- list at or under its cap, every deletion logged **in the archive with its commit**,
394
+ list at or under its cap, every deletion logged in the archive with its commit,
394
395
  entries older than five run stamps rotated into `docs/superpowers/retro/`, the run
395
- stamped **with its commit**, every SHA in either file resolvable, and the
396
+ stamped with its commit, every SHA in either file resolvable, and the
396
397
  counts printed beside this verdict**; **the documentation gate has been seen
397
398
  failing once against a planted defect and its ratchet counts are printed**
398
399
  ([`gates.md`](gates.md)); **every repository is closed — the parent included:
@@ -18,12 +18,20 @@
18
18
  # PROGRESSIVE ARMING: a section whose input does not exist yet prints
19
19
  # "dormant: … — no <artefact> yet" and does NOT fail. Dormant is visible so it is
20
20
  # not forgotten, and green so a freshly seeded project does not start red.
21
+ #
22
+ # TWO REGISTER SHAPES, ONE CONTRACT: the project's decision home is either
23
+ # docs/DECISIONS.md (ids DEC-####) or docs/adr/NNNN-slug.md (ids ADR-NNNN). Every
24
+ # section below reads a NORMALISED INDEX built from whichever exists, so neither
25
+ # shape is a second-class citizen. Having both is itself an error: one home per
26
+ # project. Reading only one shape is how a fully populated ADR register sat behind
27
+ # eight green "dormant" lines while a planted violation went uncaught.
21
28
 
22
29
  set -u
23
30
 
24
31
  FAIL=0
25
32
  DOCS_DIR=${DOCS_DIR:-docs}
26
33
  DEC_FILE=${DEC_FILE:-$DOCS_DIR/DECISIONS.md}
34
+ ADR_DIR=${ADR_DIR:-$DOCS_DIR/adr}
27
35
  OQ_FILE=${OQ_FILE:-$DOCS_DIR/OPEN_QUESTIONS.md}
28
36
  MAP_FILE=${MAP_FILE:-$DOCS_DIR/DOCMAP.md}
29
37
  RETRO_GLOB=${RETRO_GLOB:-$DOCS_DIR/superpowers}
@@ -40,12 +48,31 @@ ok() { echo "ok: $*"; }
40
48
  skipmsg() { echo "skip: $*"; }
41
49
  dormant() { echo "dormant: $*"; }
42
50
 
43
- # Strip fenced code blocks: sample content is not a claim about this repository.
51
+ # Strip what a reader never sees: fenced code blocks AND html comments. Sample
52
+ # content is not a claim about this repository. The comment half is not fussiness —
53
+ # a status line carrying `<!-- or: Superseded by ADR-0012 -->` made an entry read as
54
+ # retired and invented an undefined id, from one aside nobody renders.
44
55
  # awk, because sed -i is not portable and this must run identically everywhere.
45
- strip_fences() {
56
+ strip_asides() {
46
57
  awk '
47
58
  /^[ \t]*(```|~~~)/ { infence = !infence; print ""; next }
48
- { if (infence) print ""; else print }
59
+ infence { print ""; next }
60
+ {
61
+ line = $0
62
+ # A comment carried over from an earlier line.
63
+ if (incomment) {
64
+ if (match(line, /-->/)) { line = substr(line, RSTART + RLENGTH); incomment = 0 }
65
+ else { print ""; next }
66
+ }
67
+ # Comments that open and close on this line.
68
+ while (match(line, /<!--.*-->/)) sub(/<!--.*-->/, "", line)
69
+ # An opener with no closer: KEEP THE PREFIX. Dropping the whole line threw
70
+ # away the "- **Status:** Accepted" that preceded the comment, and the entry
71
+ # then had no status at all — the id vanished from the index and every
72
+ # document citing it was reported as citing something undefined.
73
+ if (match(line, /<!--/)) { line = substr(line, 1, RSTART - 1); incomment = 1 }
74
+ print line
75
+ }
49
76
  ' "$1"
50
77
  }
51
78
 
@@ -63,17 +90,82 @@ fi
63
90
  while IFS= read -r f; do
64
91
  [ -f "$f" ] || continue
65
92
  flat=$(echo "$f" | tr '/' '_')
66
- strip_fences "$f" > "$TMP/s_$flat"
93
+ strip_asides "$f" > "$TMP/s_$flat"
67
94
  done < "$TMP/files"
68
95
 
69
96
  flat_of() { echo "$TMP/s_$(echo "$1" | tr '/' '_')"; }
70
97
 
98
+ # ---------- 0. the decision home — exactly one, and which shape ----------
99
+ # entries: ID <TAB> FILE <TAB> STATUS-LINE edges: SRC <TAB> MARKER <TAB> TARGET
100
+ # conseq: ID <TAB> DOC
101
+ : > "$TMP/entries"; : > "$TMP/edges"; : > "$TMP/conseq"
102
+ SHAPE="none"; ID_PREFIX=""
103
+
104
+ have_reg=0; [ -f "$DEC_FILE" ] && have_reg=1
105
+ have_adr=0
106
+ if [ -d "$ADR_DIR" ]; then
107
+ find "$ADR_DIR" -type f -name '[0-9][0-9][0-9][0-9]-*.md' 2>/dev/null | sort > "$TMP/adrfiles"
108
+ [ -s "$TMP/adrfiles" ] && have_adr=1
109
+ fi
110
+
111
+ if [ "$have_reg" = "1" ] && [ "$have_adr" = "1" ]; then
112
+ err "two decision homes: $DEC_FILE and $ADR_DIR both hold entries — one project, one register (references/documentation.md)"
113
+ fi
114
+
115
+ # Pull the six owed fields out of one entry body on stdin, for id $1 in file $2.
116
+ harvest_entry() { # id file (body on stdin)
117
+ _id=$1; _file=$2
118
+ while IFS= read -r line; do
119
+ case "$line" in
120
+ *'Status:'*)
121
+ grep -q "^$_id " "$TMP/entries" 2>/dev/null || \
122
+ printf '%s\t%s\t%s\n' "$_id" "$_file" "$line" >> "$TMP/entries" ;;
123
+ *'Consequences / affects:'*)
124
+ echo "$line" | grep -o '`[^`]*`' | tr -d '`' | while IFS= read -r doc; do
125
+ case "$doc" in *.md) printf '%s\t%s\n' "$_id" "$doc" >> "$TMP/conseq" ;; esac
126
+ done ;;
127
+ *'Supersedes:'*|*'Contradicts:'*|*'Refines:'*)
128
+ _mk=$(echo "$line" | grep -o 'Supersedes\|Contradicts\|Refines' | head -1)
129
+ echo "$line" | grep -o '\(DEC\|ADR\)-[0-9][0-9]*' | while IFS= read -r tgt; do
130
+ [ "$tgt" = "$_id" ] || printf '%s\t%s\t%s\n' "$_id" "$_mk" "$tgt" >> "$TMP/edges"
131
+ done ;;
132
+ esac
133
+ done
134
+ }
135
+
136
+ if [ "$have_reg" = "1" ]; then
137
+ SHAPE="register"; ID_PREFIX="DEC"
138
+ # Split the fence-stripped register into one body per "### DEC-####" heading.
139
+ awk -v out="$TMP" '
140
+ /^### DEC-[0-9]+/ { id=$2; sub(/[^A-Za-z0-9-].*/,"",id); n++; f=out "/e_" n; ids[n]=id }
141
+ n { print > f }
142
+ END { for (i=1;i<=n;i++) print ids[i] > (out "/e_ids") }
143
+ ' "$(flat_of "$DEC_FILE")"
144
+ if [ -f "$TMP/e_ids" ]; then
145
+ _n=0
146
+ while IFS= read -r _id; do
147
+ _n=$((_n + 1))
148
+ harvest_entry "$_id" "$DEC_FILE" < "$TMP/e_$_n"
149
+ done < "$TMP/e_ids"
150
+ fi
151
+ elif [ "$have_adr" = "1" ]; then
152
+ SHAPE="adr"; ID_PREFIX="ADR"
153
+ while IFS= read -r af; do
154
+ _num=$(basename "$af" | sed 's/^\([0-9][0-9]*\)-.*/\1/')
155
+ harvest_entry "ADR-$_num" "$af" < "$(flat_of "$af")"
156
+ done < "$TMP/adrfiles"
157
+ fi
158
+
159
+ DECS=$(wc -l < "$TMP/entries" | tr -d ' ')
160
+ case "$SHAPE" in
161
+ none) dormant "decision home — neither $DEC_FILE nor $ADR_DIR/NNNN-*.md yet" ;;
162
+ *) ok "decision home: $SHAPE ($DECS entr$([ "$DECS" = "1" ] && echo y || echo ies), ids $ID_PREFIX-####)" ;;
163
+ esac
164
+
71
165
  # ---------- 1. relative links resolve ----------
72
- _bad=0
73
166
  while IFS= read -r f; do
74
167
  [ -f "$f" ] || continue
75
168
  dir=$(dirname "$f")
76
- # grep -o on the link target; keep the line number for the message.
77
169
  grep -n -o '](\([^) ]*\))' "$(flat_of "$f")" 2>/dev/null |
78
170
  sed 's/](\(.*\))/\1/' |
79
171
  while IFS=: read -r ln target; do
@@ -82,28 +174,22 @@ while IFS= read -r f; do
82
174
  esac
83
175
  base=${target%%#*}
84
176
  [ -n "$base" ] || continue
85
- if [ ! -e "$dir/$base" ]; then
86
- echo "$f:$ln: dangling link -> $target" >> "$TMP/badlinks"
87
- fi
177
+ [ -e "$dir/$base" ] || echo "$f:$ln: dangling link -> $target" >> "$TMP/badlinks"
88
178
  done
89
179
  done < "$TMP/files"
90
180
  if [ -s "$TMP/badlinks" ] 2>/dev/null; then
91
- _bad=$(wc -l < "$TMP/badlinks" | tr -d ' ')
92
- err "$_bad dangling relative link(s):"; sed 's/^/ /' "$TMP/badlinks"
181
+ err "$(wc -l < "$TMP/badlinks" | tr -d ' ') dangling relative link(s):"
182
+ sed 's/^/ /' "$TMP/badlinks"
93
183
  else
94
184
  ok "relative links resolve ($FILE_COUNT files)"
95
185
  fi
96
186
 
97
187
  # ---------- 2. every id referenced is defined ----------
98
- if [ ! -f "$DEC_FILE" ]; then
99
- dormant "id integrity — no $DEC_FILE yet"
100
- DECS=0; OQS=0
188
+ if [ "$SHAPE" = "none" ]; then
189
+ dormant "id integrity — no decision home yet"
190
+ OQS=0
101
191
  else
102
- # Read the FENCE-STRIPPED copy: a heading inside a ``` block is sample content,
103
- # not a definition. Reading the raw file here counts the format example as an
104
- # entry, and then "Next free ID" is wrong against a decision that never existed.
105
- grep -o '^### DEC-[0-9][0-9]*' "$(flat_of "$DEC_FILE")" | sed 's/^### //' | sort -u > "$TMP/dec_def"
106
- DECS=$(wc -l < "$TMP/dec_def" | tr -d ' ')
192
+ cut -f1 "$TMP/entries" | sort -u > "$TMP/dec_def"
107
193
  : > "$TMP/oq_def"
108
194
  [ -f "$OQ_FILE" ] && grep -o '^| *OQ-[0-9][0-9]*' "$(flat_of "$OQ_FILE")" | sed 's/^| *//' | sort -u > "$TMP/oq_def"
109
195
  OQS=$(wc -l < "$TMP/oq_def" | tr -d ' ')
@@ -113,7 +199,7 @@ else
113
199
  while IFS= read -r f; do
114
200
  [ -f "$f" ] || continue
115
201
  grep -v 'Next free ID' "$(flat_of "$f")" 2>/dev/null |
116
- grep -o '\(DEC\|OQ\)-[0-9][0-9]*' | sed "s|^|$f |" >> "$TMP/refs"
202
+ grep -o "\($ID_PREFIX\|OQ\)-[0-9][0-9]*" | sed "s|^|$f |" >> "$TMP/refs"
117
203
  done < "$TMP/files"
118
204
 
119
205
  : > "$TMP/undef"
@@ -127,7 +213,10 @@ else
127
213
  fi
128
214
  fi
129
215
 
130
- # ---------- 3. "Next free ID" == max defined + 1 ----------
216
+ # ---------- 3. the id allocator is sound ----------
217
+ # Register shape: a stated "Next free ID" must equal max defined + 1.
218
+ # ADR shape: there is no such line — the filename IS the allocator, so the sound
219
+ # check is that no two files claim one number.
131
220
  check_next_free() {
132
221
  _file=$1; _prefix=$2; _deffile=$3
133
222
  [ -f "$_file" ] || { dormant "next-free-$_prefix — no $_file yet"; return; }
@@ -141,27 +230,46 @@ check_next_free() {
141
230
  # an octal digit, the expansion errors, the `if` takes its else branch and the
142
231
  # check prints ok. It passed for every id ending 0-7 and was silent for 8 and 9.
143
232
  # Found by the probe; the check was wrong, not the probe.
233
+ _claim_raw=$_claim
144
234
  _claim=$(echo "$_claim" | sed 's/^0*//'); [ -n "$_claim" ] || _claim=0
145
235
  if [ ! -s "$_deffile" ]; then _max=0; else
146
236
  _max=$(sed "s/^$_prefix-//" "$_deffile" | sed 's/^0*//' | sort -n | tail -1)
147
237
  [ -n "${_max:-}" ] || _max=0
148
238
  fi
149
239
  _want=$((_max + 1))
150
- if [ "$((_claim))" -ne "$_want" ]; then
240
+ if [ "$_claim" -ne "$_want" ]; then
151
241
  err "$_file: 'Next free ID' claims $_prefix-$_claim, highest defined is $_max (expected $_want)"
152
242
  else
153
- ok "next free $_prefix id is correct ($_prefix-$_claim)"
243
+ ok "next free $_prefix id is correct ($_prefix-$_claim_raw)"
154
244
  fi
155
245
  }
156
- check_next_free "$DEC_FILE" DEC "$TMP/dec_def"
157
- check_next_free "$OQ_FILE" OQ "$TMP/oq_def"
246
+ case "$SHAPE" in
247
+ register) check_next_free "$DEC_FILE" DEC "$TMP/dec_def" ;;
248
+ adr)
249
+ # Count from the FILENAMES, not from the entry index. The index keeps one row
250
+ # per id on purpose (an entry has one status line), and that dedupe silently
251
+ # swallowed the very thing this check looks for: a second file claiming a
252
+ # number already taken. The filename is the allocator, so the filename is what
253
+ # gets counted. The check was wrong, not the probe.
254
+ _dupes=$(sed 's|.*/||; s|^\([0-9][0-9]*\)-.*|\1|' "$TMP/adrfiles" | sort | uniq -d | tr '\n' ' ')
255
+ _adr_n=$(wc -l < "$TMP/adrfiles" | tr -d ' ')
256
+ if [ -n "$(echo "$_dupes" | tr -d ' ')" ]; then
257
+ err "duplicate ADR number(s) — two files claim one id: $_dupes"
258
+ else
259
+ ok "ADR numbers are unique ($_adr_n files)"
260
+ fi ;;
261
+ *) dormant "id allocator — no decision home yet" ;;
262
+ esac
263
+ check_next_free "$OQ_FILE" OQ "$TMP/oq_def"
158
264
 
159
265
  # ---------- 4. a stated register size equals the computed one ----------
160
266
  # Compute, never restate: a number written in prose is a number that goes stale.
161
- if [ -f "$DEC_FILE" ] && grep -q 'Register size:' "$DEC_FILE" 2>/dev/null; then
162
- _stated=$(grep -o 'Register size:\** *\**[0-9][0-9]*' "$DEC_FILE" | head -1 | grep -o '[0-9][0-9]*')
267
+ _size_src=""
268
+ [ "$SHAPE" = "register" ] && _size_src=$DEC_FILE
269
+ if [ -n "$_size_src" ] && grep -q 'Register size:' "$_size_src" 2>/dev/null; then
270
+ _stated=$(grep -o 'Register size:\** *\**[0-9][0-9]*' "$_size_src" | head -1 | grep -o '[0-9][0-9]*')
163
271
  if [ "${_stated:-x}" != "$DECS" ]; then
164
- err "$DEC_FILE: states 'Register size: $_stated', computed $DECS"
272
+ err "$_size_src: states 'Register size: $_stated', computed $DECS"
165
273
  else
166
274
  ok "stated register size matches the computed one ($DECS)"
167
275
  fi
@@ -173,35 +281,27 @@ fi
173
281
  # A document named in an entry's "Consequences / affects:" line must cite that
174
282
  # entry. Writing down where a decision must propagate and then not propagating is
175
283
  # the exact failure the loop exists to prevent.
176
- if [ ! -f "$DEC_FILE" ]; then
177
- dormant "propagation no $DEC_FILE yet"
178
- PROP_MISSING=0
284
+ PROP_MISSING=0
285
+ if [ "$SHAPE" = "none" ]; then
286
+ dormant "propagation — no decision home yet"
179
287
  else
180
288
  : > "$TMP/prop"
181
- _cur=""
182
- while IFS= read -r line; do
183
- case "$line" in
184
- '### DEC-'*) _cur=$(echo "$line" | grep -o 'DEC-[0-9][0-9]*') ;;
185
- *'Consequences / affects:'*)
186
- [ -n "$_cur" ] || continue
187
- echo "$line" | grep -o '`[^`]*`' | tr -d '`' | while IFS= read -r doc; do
188
- case "$doc" in *.md) ;; *) continue ;; esac
189
- [ -f "$doc" ] || { echo "$_cur $doc MISSINGFILE" >> "$TMP/prop"; continue; }
190
- if grep -q "$_cur" "$doc"; then :; else echo "$_cur $doc NOCITE" >> "$TMP/prop"; fi
191
- done ;;
192
- esac
193
- done < "$(flat_of "$DEC_FILE")"
194
- PROP_MISSING=0
289
+ while IFS="$(printf '\t')" read -r id doc; do
290
+ [ -n "${doc:-}" ] || continue
291
+ if [ ! -f "$doc" ]; then echo "$id -> $doc (MISSINGFILE)" >> "$TMP/prop"
292
+ elif grep -q "$id" "$doc"; then :
293
+ else echo "$id -> $doc (NOCITE)" >> "$TMP/prop"; fi
294
+ done < "$TMP/conseq"
195
295
  [ -f "$TMP/prop" ] && PROP_MISSING=$(wc -l < "$TMP/prop" | tr -d ' ')
196
296
  : > "$TMP/prop_new"
197
297
  if [ "$PROP_MISSING" -gt 0 ]; then
198
- while read -r id doc why; do
199
- _n=$(echo "$id" | sed 's/^DEC-//' | sed 's/^0*//'); [ -n "$_n" ] || _n=0
200
- [ "$_n" -ge "$PROP_FLOOR" ] && echo "$id -> $doc ($why)" >> "$TMP/prop_new"
298
+ while IFS= read -r row; do
299
+ _n=$(echo "$row" | grep -o '[0-9][0-9]*' | head -1 | sed 's/^0*//'); [ -n "$_n" ] || _n=0
300
+ [ "$_n" -ge "$PROP_FLOOR" ] && echo "$row" >> "$TMP/prop_new"
201
301
  done < "$TMP/prop"
202
302
  fi
203
303
  if [ -s "$TMP/prop_new" ] 2>/dev/null; then
204
- err "decision(s) naming a document that does not cite them (floor DEC-$PROP_FLOOR):"
304
+ err "entr(y|ies) naming a document that does not cite them (floor $ID_PREFIX-$PROP_FLOOR):"
205
305
  sed 's/^/ /' "$TMP/prop_new"
206
306
  else
207
307
  ok "consequences propagate (backlog below the floor: $PROP_MISSING)"
@@ -211,30 +311,23 @@ fi
211
311
  # ---------- 6. supersede / contradict annotates the target ----------
212
312
  # One word for "adds to" and "replaces a clause of" is unenforceable, so the
213
313
  # markers are distinct and only two of them oblige the target to say so.
214
- if [ ! -f "$DEC_FILE" ]; then
215
- dormant "supersede annotations — no $DEC_FILE yet"
314
+ if [ "$SHAPE" = "none" ]; then
315
+ dormant "supersede annotations — no decision home yet"
216
316
  else
217
317
  : > "$TMP/ann"
218
- _cur=""
219
- while IFS= read -r line; do
220
- case "$line" in
221
- '### DEC-'*) _cur=$(echo "$line" | grep -o 'DEC-[0-9][0-9]*') ;;
222
- *'Supersedes:'*|*'Contradicts:'*)
223
- [ -n "$_cur" ] || continue
224
- echo "$line" | grep -o 'DEC-[0-9][0-9]*' | while IFS= read -r target; do
225
- [ "$target" = "$_cur" ] && continue
226
- _status=$(awk -v t="### $target " '
227
- index($0, t) == 1 { found = 1; next }
228
- found && /^### DEC-/ { exit }
229
- found && /Status:/ { print; exit }
230
- ' "$(flat_of "$DEC_FILE")")
231
- case "$_status" in
232
- *"$_cur"*) ;;
233
- *) echo "$target: status line does not record that $_cur retires or contradicts it" >> "$TMP/ann" ;;
234
- esac
235
- done ;;
236
- esac
237
- done < "$(flat_of "$DEC_FILE")"
318
+ while IFS="$(printf '\t')" read -r src marker target; do
319
+ [ -n "${target:-}" ] || continue
320
+ [ "$marker" = "Refines" ] && continue # additive: no annotation owed
321
+ _status=$(grep "^$target " "$TMP/entries" | head -1 | cut -f3)
322
+ if [ -z "$_status" ]; then
323
+ echo "$src $marker $target: target is not a defined entry" >> "$TMP/ann"
324
+ else
325
+ case "$_status" in
326
+ *"$src"*) ;;
327
+ *) echo "$target: status line does not record that $src ${marker}s it" >> "$TMP/ann" ;;
328
+ esac
329
+ fi
330
+ done < "$TMP/edges"
238
331
  if [ -s "$TMP/ann" ] 2>/dev/null; then
239
332
  err "unannotated supersede/contradict target(s):"; sed 's/^/ /' "$TMP/ann"
240
333
  else
@@ -247,29 +340,22 @@ fi
247
340
  # is a line: one marker on it exempts every id on it. That is a real blind spot,
248
341
  # measured and accepted — a tighter window produced mostly noise, and a gate that
249
342
  # is mostly noise is a gate people switch off.
250
- if [ ! -f "$DEC_FILE" ]; then
251
- dormant "retired residue no $DEC_FILE yet"
252
- RESIDUE=0
343
+ RESIDUE=0
344
+ if [ "$SHAPE" = "none" ]; then
345
+ dormant "retired residue — no decision home yet"
253
346
  else
254
347
  : > "$TMP/retired"
255
- _cur=""
256
- while IFS= read -r line; do
257
- case "$line" in
258
- '### DEC-'*) _cur=$(echo "$line" | grep -o 'DEC-[0-9][0-9]*') ;;
259
- *'Status:'*)
260
- [ -n "$_cur" ] || continue
261
- case "$line" in
262
- *Superseded\ by*|*Reversed*) echo "$_cur" >> "$TMP/retired" ;;
263
- esac
264
- _cur="" ;;
348
+ while IFS="$(printf '\t')" read -r id file status; do
349
+ case "$status" in
350
+ *Superseded\ by*|*Reversed*) echo "$id" >> "$TMP/retired" ;;
265
351
  esac
266
- done < "$(flat_of "$DEC_FILE")"
267
- RESIDUE=0
352
+ done < "$TMP/entries"
268
353
  if [ -s "$TMP/retired" ] 2>/dev/null; then
269
354
  : > "$TMP/res"
270
355
  while IFS= read -r f; do
271
356
  [ -f "$f" ] || continue
272
357
  [ "$f" = "$DEC_FILE" ] && continue
358
+ case "$f" in "$ADR_DIR"/*) continue ;; esac
273
359
  while IFS= read -r rid; do
274
360
  grep -n "$rid" "$(flat_of "$f")" 2>/dev/null | while IFS=: read -r ln text; do
275
361
  case "$text" in
@@ -294,28 +380,28 @@ fi
294
380
  # ---------- 8. status vocabularies are closed ----------
295
381
  # An unrecognised status is worse than a missing one: it looks answered, and every
296
382
  # check on that row skips in silence.
297
- if [ -f "$DEC_FILE" ]; then
383
+ if [ "$SHAPE" = "none" ]; then
384
+ dormant "decision status vocabulary — no decision home yet"
385
+ else
298
386
  : > "$TMP/vocab"
299
- grep -n 'Status:' "$(flat_of "$DEC_FILE")" 2>/dev/null | while IFS= read -r hit; do
300
- case "$hit" in
301
- *Accepted*|*Superseded\ by*|*Reversed*|*'`Status`'*) ;;
302
- *) echo "$DEC_FILE:${hit%%:*}: unknown decision status -> ${hit#*Status:}" >> "$TMP/vocab" ;;
387
+ while IFS="$(printf '\t')" read -r id file status; do
388
+ case "$status" in
389
+ *Accepted*|*Superseded\ by*|*Reversed*) ;;
390
+ *) echo "$file: $id has an unknown status ->${status#*Status:}" >> "$TMP/vocab" ;;
303
391
  esac
304
- done
392
+ done < "$TMP/entries"
305
393
  if [ -s "$TMP/vocab" ] 2>/dev/null; then
306
394
  err "decision status vocabulary:"; sed 's/^/ /' "$TMP/vocab"
307
395
  else
308
396
  ok "decision statuses are inside the closed vocabulary"
309
397
  fi
310
- else
311
- dormant "decision status vocabulary — no $DEC_FILE yet"
312
398
  fi
313
399
 
314
400
  if [ -f "$OQ_FILE" ]; then
315
401
  : > "$TMP/oqvocab"
316
402
  grep -n '^| *OQ-[0-9]' "$(flat_of "$OQ_FILE")" 2>/dev/null | while IFS= read -r row; do
317
403
  case "$row" in
318
- *'| Open '*|*'| Open|'*|*Open\ \|*|*Resolved→DEC-*|*Dropped*) ;;
404
+ *'| Open '*|*'| Open|'*|*Open\ \|*|*Resolved→*|*Dropped*) ;;
319
405
  *) echo "$OQ_FILE:${row%%:*}: unknown question status" >> "$TMP/oqvocab" ;;
320
406
  esac
321
407
  done
@@ -362,19 +448,28 @@ else
362
448
  # Forward direction is scoped to the "## Registers" table: that table is a CLAIM
363
449
  # about what exists. The SSOT table below it legitimately names documents a young
364
450
  # project has not written yet, and failing on those would make the gate seed red.
451
+ # TABLE ROWS ONLY. Reading every backtick in the section swept up the prose note
452
+ # under the table ("an existing docs/adr/ IS the register") and reported it as a
453
+ # missing file — a claim the note never made. A row is a claim; a sentence is not.
365
454
  : > "$TMP/map"
366
- awk '/^## Registers/ { on = 1; next } on && /^## / { exit } on { print }' \
367
- "$(flat_of "$MAP_FILE")" | grep -o '`[^`]*\.md`' | tr -d '`' | sort -u > "$TMP/map"
455
+ awk '/^## Registers/ { on = 1; next } on && /^## / { exit } on && /^\|/ { print }' \
456
+ "$(flat_of "$MAP_FILE")" | grep -o '`[^`]*`' | tr -d '`' | sort -u > "$TMP/map"
368
457
  : > "$TMP/mapmiss"
369
458
  while IFS= read -r doc; do
370
459
  case "$doc" in *'<'*|*'>'*) continue ;; esac
371
- [ -e "$doc" ] || echo "$MAP_FILE names $doc, which does not exist" >> "$TMP/mapmiss"
460
+ case "$doc" in *.md|*/) ;; *) continue ;; esac
461
+ [ -e "$doc" ] || [ -e "${doc%/}" ] ||
462
+ echo "$MAP_FILE names $doc, which does not exist" >> "$TMP/mapmiss"
372
463
  done < "$TMP/map"
373
464
  for reg in "$DEC_FILE" "$OQ_FILE"; do
374
465
  [ -f "$reg" ] || continue
375
466
  grep -q "$(basename "$reg")" "$TMP/map" ||
376
467
  echo "$reg exists but $MAP_FILE never names it" >> "$TMP/mapmiss"
377
468
  done
469
+ if [ "$SHAPE" = "adr" ]; then
470
+ grep -q "adr" "$TMP/map" ||
471
+ echo "$ADR_DIR is this project's decision home but $MAP_FILE never names it" >> "$TMP/mapmiss"
472
+ fi
378
473
  if [ -s "$TMP/mapmiss" ] 2>/dev/null; then
379
474
  err "doc map / register disagreement:"; sed 's/^/ /' "$TMP/mapmiss"
380
475
  else
@@ -387,5 +482,5 @@ if [ "$FAIL" -ne 0 ]; then
387
482
  echo "FAIL: documentation gate"
388
483
  exit 1
389
484
  fi
390
- echo "OK: documentation gate — ${DECS:-0} decisions · ${OQS:-0} open questions · propagation backlog ${PROP_MISSING:-0} (floor $PROP_FLOOR) · retired residue ${RESIDUE:-0} (floor $RESIDUE_FLOOR)"
485
+ echo "OK: documentation gate — shape $SHAPE · ${DECS:-0} decisions · ${OQS:-0} open questions · propagation backlog ${PROP_MISSING:-0} (floor $PROP_FLOOR) · retired residue ${RESIDUE:-0} (floor $RESIDUE_FLOOR)"
391
486
  exit 0