task-pipeline-skill 1.85.2 → 1.86.1

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/CONTRIBUTING.md +2 -2
  3. package/README.md +2 -1
  4. package/SKILL-CARD.md +1 -1
  5. package/bin/task-pipeline.js +70 -9
  6. package/evals/cases/evidence-docs.json +188 -0
  7. package/evals/cases/project-audit.json +188 -0
  8. package/evals/cases/task-pipeline.json +191 -0
  9. package/package.json +5 -4
  10. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  11. package/plugins/task-pipeline/skills/evidence-docs/SKILL.md +16 -11
  12. package/plugins/task-pipeline/skills/evidence-docs/references/GENERATED.md +8 -0
  13. package/plugins/task-pipeline/skills/evidence-docs/references/documentation.md +472 -0
  14. package/plugins/task-pipeline/skills/evidence-docs/references/gates.md +645 -0
  15. package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +274 -0
  16. package/plugins/task-pipeline/skills/evidence-docs/references/learned.md +292 -0
  17. package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +551 -0
  18. package/plugins/task-pipeline/skills/evidence-docs/references/setup.md +149 -0
  19. package/plugins/task-pipeline/skills/evidence-docs/templates/decisions.md +50 -0
  20. package/plugins/task-pipeline/skills/evidence-docs/templates/docgate.sh +537 -0
  21. package/plugins/task-pipeline/skills/project-audit/SKILL.md +66 -25
  22. package/plugins/task-pipeline/skills/project-audit/scripts/audit.py +11 -0
  23. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +71 -54
  24. package/plugins/task-pipeline/skills/task-pipeline/execution-attempt.schema.json +68 -0
  25. package/plugins/task-pipeline/skills/task-pipeline/execution-packet.example.json +42 -0
  26. package/plugins/task-pipeline/skills/task-pipeline/execution-packet.schema.json +217 -0
  27. package/plugins/task-pipeline/skills/task-pipeline/execution-result.example.json +49 -0
  28. package/plugins/task-pipeline/skills/task-pipeline/execution-result.schema.json +261 -0
  29. package/plugins/task-pipeline/skills/task-pipeline/graph.example.json +10 -1
  30. package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +172 -2
  31. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +50 -1
  32. package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +7 -0
  33. package/plugins/task-pipeline/skills/task-pipeline/references/artifacts.md +23 -0
  34. package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +6 -0
  35. package/plugins/task-pipeline/skills/task-pipeline/references/backlog.md +8 -1
  36. package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +8 -0
  37. package/plugins/task-pipeline/skills/task-pipeline/references/build.md +32 -0
  38. package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +14 -3
  39. package/plugins/task-pipeline/skills/task-pipeline/references/decomposition.md +83 -2
  40. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +53 -0
  41. package/plugins/task-pipeline/skills/task-pipeline/references/documentation.md +3 -0
  42. package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +27 -8
  43. package/plugins/task-pipeline/skills/task-pipeline/references/model-tiering.md +19 -0
  44. package/plugins/task-pipeline/skills/task-pipeline/references/planning.md +203 -26
  45. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
  46. package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +26 -8
  47. package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +7 -1
  48. package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +686 -0
  49. package/plugins/task-pipeline/skills/task-pipeline/scripts/execution_authority.py +271 -0
  50. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +415 -18
  51. package/plugins/task-pipeline/skills/task-pipeline/scripts/packet.py +400 -0
  52. package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +2 -0
  53. package/plugins/task-pipeline/skills/task-pipeline/templates/browser-claims.json +54 -0
  54. package/plugins/task-pipeline/skills/task-pipeline/templates/finding-evidence.json +42 -0
  55. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +2 -2
@@ -0,0 +1,537 @@
1
+ #!/usr/bin/env bash
2
+ # check-docs.sh — the documentation gate for <project>.
3
+ #
4
+ # Seeded by task-pipeline (references/gates.md). IT IS YOURS NOW: extend it here,
5
+ # section by section. Each section is independent and removable.
6
+ #
7
+ # SCOPE: walks the markdown under docs/ (and this repository's own root .md files).
8
+ # It does NOT check: prose meaning, whether a citation is the RIGHT one, code,
9
+ # another repository's documents, or anything inside a fenced code block.
10
+ # Read this header before quoting a green from here as evidence.
11
+ #
12
+ # EXIT CODE IS THE OUTPUT: non-zero on any failure. Nothing may run after the
13
+ # VERDICT block at the bottom — a gate that appended a check after its verdict
14
+ # printed FAIL and returned 0, and CI was green over it for an unknown period.
15
+ #
16
+ # PORTABLE to macOS bash 3.2: no grep -P, no sed -i, no readarray, no mapfile.
17
+ #
18
+ # PROGRESSIVE ARMING: a section whose input does not exist yet prints
19
+ # "dormant: … — no <artefact> yet" and does NOT fail. Dormant is visible so it is
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.
28
+
29
+ set -u
30
+
31
+ FAIL=0
32
+ DOCS_DIR=${DOCS_DIR:-docs}
33
+ DEC_FILE=${DEC_FILE:-$DOCS_DIR/DECISIONS.md}
34
+ ADR_DIR=${ADR_DIR:-$DOCS_DIR/adr}
35
+ OQ_FILE=${OQ_FILE:-$DOCS_DIR/OPEN_QUESTIONS.md}
36
+ MAP_FILE=${MAP_FILE:-$DOCS_DIR/DOCMAP.md}
37
+ # The artifact root is RESOLVED, not assumed. It was renamed `superpowers` → `evidence`
38
+ # on 2026-08-13 (v1.53.0 made it resolvable, v0.46.0 moved the family), and this default
39
+ # still named the old one — so in every migrated project the SHA and propagation sections
40
+ # below found no corpus and went **dormant**, which reads exactly like having nothing to
41
+ # check. Measured 2026-08-16 on this skill's own repository. Prefer the new name, fall
42
+ # back to the old, and a project that has neither gets the dormant message it deserves.
43
+ if [ -z "${RETRO_GLOB:-}" ]; then
44
+ if [ -d "$DOCS_DIR/evidence" ]; then RETRO_GLOB="$DOCS_DIR/evidence"
45
+ elif [ -d "$DOCS_DIR/superpowers" ]; then RETRO_GLOB="$DOCS_DIR/superpowers"
46
+ else RETRO_GLOB="$DOCS_DIR/evidence"
47
+ fi
48
+ fi
49
+
50
+ # ---------- ratchets: a floor may only fall. Raising one is a decision. ----------
51
+ # THE TWO FLOORS ARE DIFFERENT KINDS. Mixing them up is why this is spelled out.
52
+ #
53
+ # PROP_FLOOR is an ID THRESHOLD, not a count. An entry whose number is >= the floor
54
+ # must have propagated; everything older is a counted backlog that may only shrink.
55
+ # ADOPTING THIS GATE IN AN EXISTING REPOSITORY MEANS SETTING IT TO THE NEXT FREE
56
+ # ID: from today the rule binds, and the history becomes one printed number instead
57
+ # of a thousand failures nobody will fix. Lower it as tranches are cleared.
58
+ PROP_FLOOR=${PROP_FLOOR:-0} # id threshold — entries >= this must propagate
59
+ #
60
+ # RESIDUE_FLOOR is a COUNT: how many unmarked citations of retired decisions are
61
+ # tolerated. On adoption set it to what the repository measurably has today, then
62
+ # only ever lower it.
63
+ RESIDUE_FLOOR=${RESIDUE_FLOOR:-0} # count — tolerated unmarked citations, today's number
64
+
65
+ TMP=$(mktemp -d 2>/dev/null || mktemp -d -t docgate)
66
+ trap 'rm -rf "$TMP"' EXIT
67
+
68
+ err() { echo "ERR: $*"; FAIL=1; }
69
+ ok() { echo "ok: $*"; }
70
+ skipmsg() { echo "skip: $*"; }
71
+ dormant() { echo "dormant: $*"; }
72
+
73
+ # Strip what a reader never sees: fenced code blocks AND html comments. Sample
74
+ # content is not a claim about this repository. The comment half is not fussiness —
75
+ # a status line carrying `<!-- or: Superseded by ADR-0012 -->` made an entry read as
76
+ # retired and invented an undefined id, from one aside nobody renders.
77
+ # awk, because sed -i is not portable and this must run identically everywhere.
78
+ strip_asides() {
79
+ awk '
80
+ /^[ \t]*(```|~~~)/ { infence = !infence; print ""; next }
81
+ infence { print ""; next }
82
+ # A line describing a PLANTED defect quotes ids that were never meant to exist —
83
+ # that is the payload of a negative self-test, written down so the incident can be
84
+ # read later. It is sample content by the same argument as a fenced block, and the
85
+ # checker cannot otherwise tell an id being USED from an id being DISCUSSED. Found
86
+ # 2026-08-16: a retro entry recording `planted DEC-0009 while the highest defined id
87
+ # was DEC-0001` was reported as citing an undefined decision.
88
+ /planted/ { print ""; next }
89
+ {
90
+ line = $0
91
+ # A comment carried over from an earlier line.
92
+ if (incomment) {
93
+ if (match(line, /-->/)) { line = substr(line, RSTART + RLENGTH); incomment = 0 }
94
+ else { print ""; next }
95
+ }
96
+ # Comments that open and close on this line.
97
+ while (match(line, /<!--.*-->/)) sub(/<!--.*-->/, "", line)
98
+ # An opener with no closer: KEEP THE PREFIX. Dropping the whole line threw
99
+ # away the "- **Status:** Accepted" that preceded the comment, and the entry
100
+ # then had no status at all — the id vanished from the index and every
101
+ # document citing it was reported as citing something undefined.
102
+ if (match(line, /<!--/)) { line = substr(line, 1, RSTART - 1); incomment = 1 }
103
+ print line
104
+ }
105
+ ' "$1"
106
+ }
107
+
108
+ # Every markdown file in scope, one per line.
109
+ find "$DOCS_DIR" -type f -name '*.md' 2>/dev/null | sort > "$TMP/files" || true
110
+ find . -maxdepth 1 -type f -name '*.md' 2>/dev/null | sort >> "$TMP/files" || true
111
+ FILE_COUNT=$(wc -l < "$TMP/files" | tr -d ' ')
112
+
113
+ if [ "$FILE_COUNT" = "0" ]; then
114
+ echo "FAIL: documentation gate — no markdown found in $DOCS_DIR/ or the repository root."
115
+ echo " Seed the doc map and the registers first (task-pipeline stage 0, phase 1b),"
116
+ echo " or point DOCS_DIR at wherever this project keeps its documentation."
117
+ exit 1
118
+ fi
119
+
120
+ # Fence-stripped copies, addressed by a flattened path.
121
+ while IFS= read -r f; do
122
+ [ -f "$f" ] || continue
123
+ flat=$(echo "$f" | tr '/' '_')
124
+ strip_asides "$f" > "$TMP/s_$flat"
125
+ done < "$TMP/files"
126
+
127
+ flat_of() { echo "$TMP/s_$(echo "$1" | tr '/' '_')"; }
128
+
129
+ # ---------- 0. the decision home — exactly one, and which shape ----------
130
+ # entries: ID <TAB> FILE <TAB> STATUS-LINE edges: SRC <TAB> MARKER <TAB> TARGET
131
+ # conseq: ID <TAB> DOC
132
+ : > "$TMP/entries"; : > "$TMP/edges"; : > "$TMP/conseq"
133
+ SHAPE="none"; ID_PREFIX=""
134
+
135
+ have_reg=0; [ -f "$DEC_FILE" ] && have_reg=1
136
+ have_adr=0
137
+ if [ -d "$ADR_DIR" ]; then
138
+ find "$ADR_DIR" -type f -name '[0-9][0-9][0-9][0-9]-*.md' 2>/dev/null | sort > "$TMP/adrfiles"
139
+ [ -s "$TMP/adrfiles" ] && have_adr=1
140
+ fi
141
+
142
+ if [ "$have_reg" = "1" ] && [ "$have_adr" = "1" ]; then
143
+ err "two decision homes: $DEC_FILE and $ADR_DIR both hold entries — one project, one register (references/documentation.md)"
144
+ fi
145
+
146
+ # Pull the six owed fields out of one entry body on stdin, for id $1 in file $2.
147
+ harvest_entry() { # id file (body on stdin)
148
+ _id=$1; _file=$2
149
+ while IFS= read -r line; do
150
+ case "$line" in
151
+ *'Status:'*)
152
+ grep -q "^$_id " "$TMP/entries" 2>/dev/null || \
153
+ printf '%s\t%s\t%s\n' "$_id" "$_file" "$line" >> "$TMP/entries" ;;
154
+ *'Consequences / affects:'*)
155
+ echo "$line" | grep -o '`[^`]*`' | tr -d '`' | while IFS= read -r doc; do
156
+ case "$doc" in *.md) printf '%s\t%s\n' "$_id" "$doc" >> "$TMP/conseq" ;; esac
157
+ done ;;
158
+ *'Supersedes:'*|*'Contradicts:'*|*'Refines:'*)
159
+ _mk=$(echo "$line" | grep -o 'Supersedes\|Contradicts\|Refines' | head -1)
160
+ echo "$line" | grep -o '\(DEC\|ADR\)-[0-9][0-9]*' | while IFS= read -r tgt; do
161
+ [ "$tgt" = "$_id" ] || printf '%s\t%s\t%s\n' "$_id" "$_mk" "$tgt" >> "$TMP/edges"
162
+ done ;;
163
+ esac
164
+ done
165
+ }
166
+
167
+ if [ "$have_reg" = "1" ]; then
168
+ SHAPE="register"; ID_PREFIX="DEC"
169
+ # Split the fence-stripped register into one body per "### DEC-####" heading.
170
+ awk -v out="$TMP" '
171
+ /^### DEC-[0-9]+/ { id=$2; sub(/[^A-Za-z0-9-].*/,"",id); n++; f=out "/e_" n; ids[n]=id }
172
+ n { print > f }
173
+ END { for (i=1;i<=n;i++) print ids[i] > (out "/e_ids") }
174
+ ' "$(flat_of "$DEC_FILE")"
175
+ if [ -f "$TMP/e_ids" ]; then
176
+ _n=0
177
+ while IFS= read -r _id; do
178
+ _n=$((_n + 1))
179
+ harvest_entry "$_id" "$DEC_FILE" < "$TMP/e_$_n"
180
+ done < "$TMP/e_ids"
181
+ fi
182
+ elif [ "$have_adr" = "1" ]; then
183
+ SHAPE="adr"; ID_PREFIX="ADR"
184
+ while IFS= read -r af; do
185
+ _num=$(basename "$af" | sed 's/^\([0-9][0-9]*\)-.*/\1/')
186
+ harvest_entry "ADR-$_num" "$af" < "$(flat_of "$af")"
187
+ done < "$TMP/adrfiles"
188
+ fi
189
+
190
+ DECS=$(wc -l < "$TMP/entries" | tr -d ' ')
191
+ case "$SHAPE" in
192
+ none) dormant "decision home — neither $DEC_FILE nor $ADR_DIR/NNNN-*.md yet" ;;
193
+ *) ok "decision home: $SHAPE ($DECS entr$([ "$DECS" = "1" ] && echo y || echo ies), ids $ID_PREFIX-####)" ;;
194
+ esac
195
+
196
+ # ---------- 1. relative links resolve ----------
197
+ while IFS= read -r f; do
198
+ [ -f "$f" ] || continue
199
+ dir=$(dirname "$f")
200
+ grep -n -o '](\([^) ]*\))' "$(flat_of "$f")" 2>/dev/null |
201
+ sed 's/](\(.*\))/\1/' |
202
+ while IFS=: read -r ln target; do
203
+ case "$target" in
204
+ http://*|https://*|mailto:*|'#'*|'') continue ;;
205
+ esac
206
+ base=${target%%#*}
207
+ [ -n "$base" ] || continue
208
+ [ -e "$dir/$base" ] || echo "$f:$ln: dangling link -> $target" >> "$TMP/badlinks"
209
+ done
210
+ done < "$TMP/files"
211
+ if [ -s "$TMP/badlinks" ] 2>/dev/null; then
212
+ err "$(wc -l < "$TMP/badlinks" | tr -d ' ') dangling relative link(s):"
213
+ sed 's/^/ /' "$TMP/badlinks"
214
+ else
215
+ ok "relative links resolve ($FILE_COUNT files)"
216
+ fi
217
+
218
+ # ---------- 2. every id referenced is defined ----------
219
+ if [ "$SHAPE" = "none" ]; then
220
+ dormant "id integrity — no decision home yet"
221
+ OQS=0
222
+ else
223
+ cut -f1 "$TMP/entries" | sort -u > "$TMP/dec_def"
224
+ : > "$TMP/oq_def"
225
+ [ -f "$OQ_FILE" ] && grep -o '^| *OQ-[0-9][0-9]*' "$(flat_of "$OQ_FILE")" | sed 's/^| *//' | sort -u > "$TMP/oq_def"
226
+ OQS=$(wc -l < "$TMP/oq_def" | tr -d ' ')
227
+ cat "$TMP/dec_def" "$TMP/oq_def" | sort -u > "$TMP/defined"
228
+
229
+ : > "$TMP/refs"
230
+ while IFS= read -r f; do
231
+ [ -f "$f" ] || continue
232
+ grep -v 'Next free ID' "$(flat_of "$f")" 2>/dev/null |
233
+ grep -o "\($ID_PREFIX\|OQ\)-[0-9][0-9]*" | sed "s|^|$f |" >> "$TMP/refs"
234
+ done < "$TMP/files"
235
+
236
+ : > "$TMP/undef"
237
+ sort -u "$TMP/refs" | while read -r f id; do
238
+ grep -qx "$id" "$TMP/defined" || echo "$f: $id referenced, never defined" >> "$TMP/undef"
239
+ done
240
+ if [ -s "$TMP/undef" ] 2>/dev/null; then
241
+ err "undefined id(s):"; sed 's/^/ /' "$TMP/undef"
242
+ else
243
+ ok "every referenced id is defined ($DECS decisions, $OQS open questions)"
244
+ fi
245
+ fi
246
+
247
+ # ---------- 3. the id allocator is sound ----------
248
+ # Register shape: a stated "Next free ID" must equal max defined + 1.
249
+ # ADR shape: there is no such line — the filename IS the allocator, so the sound
250
+ # check is that no two files claim one number.
251
+ check_next_free() {
252
+ _file=$1; _prefix=$2; _deffile=$3
253
+ [ -f "$_file" ] || { dormant "next-free-$_prefix — no $_file yet"; return; }
254
+ _claim=$(grep -o "Next free ID:\** *\`\?$_prefix-[0-9][0-9]*" "$_file" | head -1 |
255
+ grep -o '[0-9][0-9]*$')
256
+ if [ -z "${_claim:-}" ]; then
257
+ err "$_file: no parsable 'Next free ID: \`$_prefix-NNNN\`' line"
258
+ return
259
+ fi
260
+ # Strip leading zeros BEFORE any arithmetic: bash reads 0009 as octal, 9 is not
261
+ # an octal digit, the expansion errors, the `if` takes its else branch and the
262
+ # check prints ok. It passed for every id ending 0-7 and was silent for 8 and 9.
263
+ # Found by the probe; the check was wrong, not the probe.
264
+ _claim_raw=$_claim
265
+ _claim=$(echo "$_claim" | sed 's/^0*//'); [ -n "$_claim" ] || _claim=0
266
+ if [ ! -s "$_deffile" ]; then _max=0; else
267
+ _max=$(sed "s/^$_prefix-//" "$_deffile" | sed 's/^0*//' | sort -n | tail -1)
268
+ [ -n "${_max:-}" ] || _max=0
269
+ fi
270
+ _want=$((_max + 1))
271
+ if [ "$_claim" -ne "$_want" ]; then
272
+ err "$_file: 'Next free ID' claims $_prefix-$_claim, highest defined is $_max (expected $_want)"
273
+ else
274
+ ok "next free $_prefix id is correct ($_prefix-$_claim_raw)"
275
+ fi
276
+ }
277
+ case "$SHAPE" in
278
+ register) check_next_free "$DEC_FILE" DEC "$TMP/dec_def" ;;
279
+ adr)
280
+ # Count from the FILENAMES, not from the entry index. The index keeps one row
281
+ # per id on purpose (an entry has one status line), and that dedupe silently
282
+ # swallowed the very thing this check looks for: a second file claiming a
283
+ # number already taken. The filename is the allocator, so the filename is what
284
+ # gets counted. The check was wrong, not the probe.
285
+ _dupes=$(sed 's|.*/||; s|^\([0-9][0-9]*\)-.*|\1|' "$TMP/adrfiles" | sort | uniq -d | tr '\n' ' ')
286
+ _adr_n=$(wc -l < "$TMP/adrfiles" | tr -d ' ')
287
+ if [ -n "$(echo "$_dupes" | tr -d ' ')" ]; then
288
+ err "duplicate ADR number(s) — two files claim one id: $_dupes"
289
+ else
290
+ ok "ADR numbers are unique ($_adr_n files)"
291
+ fi ;;
292
+ *) dormant "id allocator — no decision home yet" ;;
293
+ esac
294
+ check_next_free "$OQ_FILE" OQ "$TMP/oq_def"
295
+
296
+ # ---------- 4. a stated register size equals the computed one ----------
297
+ # Compute, never restate: a number written in prose is a number that goes stale.
298
+ _size_src=""
299
+ [ "$SHAPE" = "register" ] && _size_src=$DEC_FILE
300
+ if [ -n "$_size_src" ] && grep -q 'Register size:' "$_size_src" 2>/dev/null; then
301
+ _stated=$(grep -o 'Register size:\** *\**[0-9][0-9]*' "$_size_src" | head -1 | grep -o '[0-9][0-9]*')
302
+ if [ "${_stated:-x}" != "$DECS" ]; then
303
+ err "$_size_src: states 'Register size: $_stated', computed $DECS"
304
+ else
305
+ ok "stated register size matches the computed one ($DECS)"
306
+ fi
307
+ else
308
+ dormant "register-size cross-check — no 'Register size:' line stated"
309
+ fi
310
+
311
+ # ---------- 5. consequences propagation (RATCHETED) ----------
312
+ # A document named in an entry's "Consequences / affects:" line must cite that
313
+ # entry. Writing down where a decision must propagate and then not propagating is
314
+ # the exact failure the loop exists to prevent.
315
+ PROP_MISSING=0
316
+ if [ "$SHAPE" = "none" ]; then
317
+ dormant "propagation — no decision home yet"
318
+ else
319
+ : > "$TMP/prop"
320
+ while IFS="$(printf '\t')" read -r id doc; do
321
+ [ -n "${doc:-}" ] || continue
322
+ if [ ! -f "$doc" ]; then echo "$id -> $doc (MISSINGFILE)" >> "$TMP/prop"
323
+ elif grep -q "$id" "$doc"; then :
324
+ else echo "$id -> $doc (NOCITE)" >> "$TMP/prop"; fi
325
+ done < "$TMP/conseq"
326
+ [ -f "$TMP/prop" ] && PROP_MISSING=$(wc -l < "$TMP/prop" | tr -d ' ')
327
+ : > "$TMP/prop_new"
328
+ if [ "$PROP_MISSING" -gt 0 ]; then
329
+ while IFS= read -r row; do
330
+ _n=$(echo "$row" | grep -o '[0-9][0-9]*' | head -1 | sed 's/^0*//'); [ -n "$_n" ] || _n=0
331
+ [ "$_n" -ge "$PROP_FLOOR" ] && echo "$row" >> "$TMP/prop_new"
332
+ done < "$TMP/prop"
333
+ fi
334
+ if [ -s "$TMP/prop_new" ] 2>/dev/null; then
335
+ err "entr(y|ies) naming a document that does not cite them (floor $ID_PREFIX-$PROP_FLOOR):"
336
+ sed 's/^/ /' "$TMP/prop_new"
337
+ else
338
+ ok "consequences propagate (backlog below the floor: $PROP_MISSING)"
339
+ fi
340
+ fi
341
+
342
+ # ---------- 6. supersede / contradict annotates the target ----------
343
+ # One word for "adds to" and "replaces a clause of" is unenforceable, so the
344
+ # markers are distinct and only two of them oblige the target to say so.
345
+ if [ "$SHAPE" = "none" ]; then
346
+ dormant "supersede annotations — no decision home yet"
347
+ else
348
+ : > "$TMP/ann"
349
+ while IFS="$(printf '\t')" read -r src marker target; do
350
+ [ -n "${target:-}" ] || continue
351
+ [ "$marker" = "Refines" ] && continue # additive: no annotation owed
352
+ _status=$(grep "^$target " "$TMP/entries" | head -1 | cut -f3)
353
+ if [ -z "$_status" ]; then
354
+ echo "$src $marker $target: target is not a defined entry" >> "$TMP/ann"
355
+ else
356
+ case "$_status" in
357
+ *"$src"*) ;;
358
+ *) echo "$target: status line does not record that $src ${marker}s it" >> "$TMP/ann" ;;
359
+ esac
360
+ fi
361
+ done < "$TMP/edges"
362
+ if [ -s "$TMP/ann" ] 2>/dev/null; then
363
+ err "unannotated supersede/contradict target(s):"; sed 's/^/ /' "$TMP/ann"
364
+ else
365
+ ok "supersede and contradict targets are annotated"
366
+ fi
367
+ fi
368
+
369
+ # ---------- 7. retired-decision residue (RATCHETED) ----------
370
+ # A document citing a WHOLLY RETIRED id must say so in the same breath. The unit
371
+ # is a line: one marker on it exempts every id on it. That is a real blind spot,
372
+ # measured and accepted — a tighter window produced mostly noise, and a gate that
373
+ # is mostly noise is a gate people switch off.
374
+ RESIDUE=0
375
+ if [ "$SHAPE" = "none" ]; then
376
+ dormant "retired residue — no decision home yet"
377
+ else
378
+ : > "$TMP/retired"
379
+ while IFS="$(printf '\t')" read -r id file status; do
380
+ case "$status" in
381
+ *Superseded\ by*|*Reversed*) echo "$id" >> "$TMP/retired" ;;
382
+ esac
383
+ done < "$TMP/entries"
384
+ if [ -s "$TMP/retired" ] 2>/dev/null; then
385
+ : > "$TMP/res"
386
+ while IFS= read -r f; do
387
+ [ -f "$f" ] || continue
388
+ [ "$f" = "$DEC_FILE" ] && continue
389
+ case "$f" in "$ADR_DIR"/*) continue ;; esac
390
+ while IFS= read -r rid; do
391
+ grep -n "$rid" "$(flat_of "$f")" 2>/dev/null | while IFS=: read -r ln text; do
392
+ case "$text" in
393
+ *supersed*|*Supersed*|*retired*|*Retired*|*reversed*|*Reversed*) ;;
394
+ *) echo "$f:$ln cites $rid (retired) without saying so" >> "$TMP/res" ;;
395
+ esac
396
+ done
397
+ done < "$TMP/retired"
398
+ done < "$TMP/files"
399
+ [ -f "$TMP/res" ] && RESIDUE=$(wc -l < "$TMP/res" | tr -d ' ')
400
+ if [ "$RESIDUE" -gt "$RESIDUE_FLOOR" ]; then
401
+ err "$RESIDUE unmarked citation(s) of retired decisions (floor $RESIDUE_FLOOR):"
402
+ sed 's/^/ /' "$TMP/res"
403
+ else
404
+ ok "no unmarked citation of a retired decision (residue $RESIDUE, floor $RESIDUE_FLOOR)"
405
+ fi
406
+ else
407
+ ok "no retired decisions yet"
408
+ fi
409
+ fi
410
+
411
+ # ---------- 8. status vocabularies are closed ----------
412
+ # An unrecognised status is worse than a missing one: it looks answered, and every
413
+ # check on that row skips in silence.
414
+ if [ "$SHAPE" = "none" ]; then
415
+ dormant "decision status vocabulary — no decision home yet"
416
+ else
417
+ : > "$TMP/vocab"
418
+ while IFS="$(printf '\t')" read -r id file status; do
419
+ case "$status" in
420
+ *Accepted*|*Superseded\ by*|*Reversed*) ;;
421
+ *) echo "$file: $id has an unknown status ->${status#*Status:}" >> "$TMP/vocab" ;;
422
+ esac
423
+ done < "$TMP/entries"
424
+ if [ -s "$TMP/vocab" ] 2>/dev/null; then
425
+ err "decision status vocabulary:"; sed 's/^/ /' "$TMP/vocab"
426
+ else
427
+ ok "decision statuses are inside the closed vocabulary"
428
+ fi
429
+ fi
430
+
431
+ if [ -f "$OQ_FILE" ]; then
432
+ : > "$TMP/oqvocab"
433
+ grep -n '^| *OQ-[0-9]' "$(flat_of "$OQ_FILE")" 2>/dev/null | while IFS= read -r row; do
434
+ case "$row" in
435
+ *'| Open '*|*'| Open|'*|*Open\ \|*|*Resolved→*|*Dropped*) ;;
436
+ *) echo "$OQ_FILE:${row%%:*}: unknown question status" >> "$TMP/oqvocab" ;;
437
+ esac
438
+ done
439
+ if [ -s "$TMP/oqvocab" ] 2>/dev/null; then
440
+ err "open-question status vocabulary:"; sed 's/^/ /' "$TMP/oqvocab"
441
+ else
442
+ ok "open-question statuses are inside the closed vocabulary"
443
+ fi
444
+ else
445
+ dormant "open-question status vocabulary — no $OQ_FILE yet"
446
+ fi
447
+
448
+ # ---------- 9. every commit SHA named in the retro resolves ----------
449
+ # A file:line rots at the next edit; a SHA carries the diff, the message and the
450
+ # parent forever. A document may not send a reader to something absent.
451
+ # `[ -d .git ]` is the wrong question and it silently disabled this whole section for
452
+ # every submodule and every linked worktree — where `.git` is a FILE holding a `gitdir:`
453
+ # pointer. Measured 2026-08-16 on this skill's own repository, checked out as a submodule:
454
+ # the section printed `skip` while five SHAs in the archive did not resolve at all. Ask git
455
+ # whether it is inside a work tree; it knows about all three shapes and this does not.
456
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
457
+ skipmsg "commit-SHA resolution — not a git working tree"
458
+ elif [ ! -d "$RETRO_GLOB" ]; then
459
+ dormant "commit-SHA resolution — no $RETRO_GLOB yet"
460
+ else
461
+ : > "$TMP/sha"
462
+ # An enumerated exception, never a floor. A commit whose history was rewritten before any
463
+ # of this was gated cannot be repaired without inventing a mapping, and a frozen record of
464
+ # a past run is not rewritten. Such SHAs are listed by name in a `docgate:known-dead`
465
+ # marker inside the retro corpus itself — one home, with the reason in prose beside it —
466
+ # so this passes over exactly those and still fails on the next one.
467
+ DEAD=$(grep -rho 'docgate:known-dead[^>]*' "$RETRO_GLOB" 2>/dev/null | sed 's/docgate:known-dead//' | tr -s ' \n' ' ')
468
+ find "$RETRO_GLOB" -type f -name '*.md' 2>/dev/null | sort | while IFS= read -r f; do
469
+ grep -n -o '`[0-9a-f][0-9a-f]*`' "$(flat_of "$f")" 2>/dev/null |
470
+ while IFS=: read -r ln tok; do
471
+ s=$(echo "$tok" | tr -d '`')
472
+ case ${#s} in 7|8|9|10|11|12|40) ;; *) continue ;; esac
473
+ case " $DEAD " in *" $s "*) continue ;; esac
474
+ if ! git rev-parse --verify --quiet "$s^{commit}" >/dev/null 2>&1; then
475
+ echo "$f:$ln: commit \`$s\` does not resolve" >> "$TMP/sha"
476
+ # Resolving is the weaker half. A commit that was AMENDED AWAY still resolves on
477
+ # the machine that amended it and reaches no clone — measured 2026-08-16, twice in
478
+ # one close-out: the stamp named the run's commit, the commit was amended to carry
479
+ # the stamp, and the SHA the stamp recorded stopped being reachable. Ask the
480
+ # question a reader actually has: is it in this history at all.
481
+ elif ! git merge-base --is-ancestor "$s^{commit}" HEAD >/dev/null 2>&1; then
482
+ echo "$f:$ln: commit \`$s\` resolves but is NOT reachable from HEAD — amended away, or on a branch this checkout does not have" >> "$TMP/sha"
483
+ fi
484
+ done
485
+ done
486
+ if [ -s "$TMP/sha" ] 2>/dev/null; then
487
+ err "commit reference(s) a clone could not follow:"; sed 's/^/ /' "$TMP/sha"
488
+ else
489
+ ok "every commit reference in $RETRO_GLOB resolves AND is reachable from HEAD"
490
+ fi
491
+ fi
492
+
493
+ # ---------- 10. the doc map and the registers agree, BOTH directions ----------
494
+ # The direction that feels redundant is the one that finds things: a register the
495
+ # map never names is a register nobody is told about.
496
+ if [ ! -f "$MAP_FILE" ]; then
497
+ dormant "doc-map coverage — no $MAP_FILE yet"
498
+ else
499
+ # Forward direction is scoped to the "## Registers" table: that table is a CLAIM
500
+ # about what exists. The SSOT table below it legitimately names documents a young
501
+ # project has not written yet, and failing on those would make the gate seed red.
502
+ # TABLE ROWS ONLY. Reading every backtick in the section swept up the prose note
503
+ # under the table ("an existing docs/adr/ IS the register") and reported it as a
504
+ # missing file — a claim the note never made. A row is a claim; a sentence is not.
505
+ : > "$TMP/map"
506
+ awk '/^## Registers/ { on = 1; next } on && /^## / { exit } on && /^\|/ { print }' \
507
+ "$(flat_of "$MAP_FILE")" | grep -o '`[^`]*`' | tr -d '`' | sort -u > "$TMP/map"
508
+ : > "$TMP/mapmiss"
509
+ while IFS= read -r doc; do
510
+ case "$doc" in *'<'*|*'>'*) continue ;; esac
511
+ case "$doc" in *.md|*/) ;; *) continue ;; esac
512
+ [ -e "$doc" ] || [ -e "${doc%/}" ] ||
513
+ echo "$MAP_FILE names $doc, which does not exist" >> "$TMP/mapmiss"
514
+ done < "$TMP/map"
515
+ for reg in "$DEC_FILE" "$OQ_FILE"; do
516
+ [ -f "$reg" ] || continue
517
+ grep -q "$(basename "$reg")" "$TMP/map" ||
518
+ echo "$reg exists but $MAP_FILE never names it" >> "$TMP/mapmiss"
519
+ done
520
+ if [ "$SHAPE" = "adr" ]; then
521
+ grep -q "adr" "$TMP/map" ||
522
+ echo "$ADR_DIR is this project's decision home but $MAP_FILE never names it" >> "$TMP/mapmiss"
523
+ fi
524
+ if [ -s "$TMP/mapmiss" ] 2>/dev/null; then
525
+ err "doc map / register disagreement:"; sed 's/^/ /' "$TMP/mapmiss"
526
+ else
527
+ ok "doc map and registers agree in both directions"
528
+ fi
529
+ fi
530
+
531
+ # ---------- VERDICT — nothing may run after this block ----------
532
+ if [ "$FAIL" -ne 0 ]; then
533
+ echo "FAIL: documentation gate"
534
+ exit 1
535
+ fi
536
+ 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)"
537
+ exit 0