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 +98 -0
- package/package.json +1 -1
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.example.json +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +13 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/brainstorm.md +12 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/build.md +33 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/documentation.md +6 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +59 -10
- package/plugins/task-pipeline/skills/task-pipeline/references/planning.md +13 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/review.md +7 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/spec.md +12 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +4 -3
- package/plugins/task-pipeline/skills/task-pipeline/templates/docgate.sh +194 -99
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.
|
|
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.
|
|
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
|
|
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
|
|
191
|
-
|
|
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.
|
|
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
|
-
**
|
|
35
|
-
|
|
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
|
|
25
|
-
| `PostToolUse` | after
|
|
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.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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`, `
|
|
53
|
-
`permission_mode
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
56
|
+
strip_asides() {
|
|
46
57
|
awk '
|
|
47
58
|
/^[ \t]*(```|~~~)/ { infence = !infence; print ""; next }
|
|
48
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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 [
|
|
99
|
-
dormant "id integrity — no
|
|
100
|
-
|
|
188
|
+
if [ "$SHAPE" = "none" ]; then
|
|
189
|
+
dormant "id integrity — no decision home yet"
|
|
190
|
+
OQS=0
|
|
101
191
|
else
|
|
102
|
-
|
|
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
|
|
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.
|
|
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 [ "$
|
|
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-$
|
|
243
|
+
ok "next free $_prefix id is correct ($_prefix-$_claim_raw)"
|
|
154
244
|
fi
|
|
155
245
|
}
|
|
156
|
-
|
|
157
|
-
check_next_free "$
|
|
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
|
-
|
|
162
|
-
|
|
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 "$
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
284
|
+
PROP_MISSING=0
|
|
285
|
+
if [ "$SHAPE" = "none" ]; then
|
|
286
|
+
dormant "propagation — no decision home yet"
|
|
179
287
|
else
|
|
180
288
|
: > "$TMP/prop"
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
199
|
-
_n=$(echo "$
|
|
200
|
-
[ "$_n" -ge "$PROP_FLOOR" ] && echo "$
|
|
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 "
|
|
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 [
|
|
215
|
-
dormant "supersede annotations — no
|
|
314
|
+
if [ "$SHAPE" = "none" ]; then
|
|
315
|
+
dormant "supersede annotations — no decision home yet"
|
|
216
316
|
else
|
|
217
317
|
: > "$TMP/ann"
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
343
|
+
RESIDUE=0
|
|
344
|
+
if [ "$SHAPE" = "none" ]; then
|
|
345
|
+
dormant "retired residue — no decision home yet"
|
|
253
346
|
else
|
|
254
347
|
: > "$TMP/retired"
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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 < "$
|
|
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 [
|
|
383
|
+
if [ "$SHAPE" = "none" ]; then
|
|
384
|
+
dormant "decision status vocabulary — no decision home yet"
|
|
385
|
+
else
|
|
298
386
|
: > "$TMP/vocab"
|
|
299
|
-
|
|
300
|
-
case "$
|
|
301
|
-
*Accepted*|*Superseded\ by*|*Reversed
|
|
302
|
-
*) echo "$
|
|
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
|
|
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 '`[^`]
|
|
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
|
-
|
|
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
|