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.
- package/CHANGELOG.md +49 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +2 -1
- package/SKILL-CARD.md +1 -1
- package/bin/task-pipeline.js +70 -9
- package/evals/cases/evidence-docs.json +188 -0
- package/evals/cases/project-audit.json +188 -0
- package/evals/cases/task-pipeline.json +191 -0
- package/package.json +5 -4
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/skills/evidence-docs/SKILL.md +16 -11
- package/plugins/task-pipeline/skills/evidence-docs/references/GENERATED.md +8 -0
- package/plugins/task-pipeline/skills/evidence-docs/references/documentation.md +472 -0
- package/plugins/task-pipeline/skills/evidence-docs/references/gates.md +645 -0
- package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +274 -0
- package/plugins/task-pipeline/skills/evidence-docs/references/learned.md +292 -0
- package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +551 -0
- package/plugins/task-pipeline/skills/evidence-docs/references/setup.md +149 -0
- package/plugins/task-pipeline/skills/evidence-docs/templates/decisions.md +50 -0
- package/plugins/task-pipeline/skills/evidence-docs/templates/docgate.sh +537 -0
- package/plugins/task-pipeline/skills/project-audit/SKILL.md +66 -25
- package/plugins/task-pipeline/skills/project-audit/scripts/audit.py +11 -0
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +71 -54
- package/plugins/task-pipeline/skills/task-pipeline/execution-attempt.schema.json +68 -0
- package/plugins/task-pipeline/skills/task-pipeline/execution-packet.example.json +42 -0
- package/plugins/task-pipeline/skills/task-pipeline/execution-packet.schema.json +217 -0
- package/plugins/task-pipeline/skills/task-pipeline/execution-result.example.json +49 -0
- package/plugins/task-pipeline/skills/task-pipeline/execution-result.schema.json +261 -0
- package/plugins/task-pipeline/skills/task-pipeline/graph.example.json +10 -1
- package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +172 -2
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +50 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/acceptance.md +7 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/artifacts.md +23 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/audit.md +6 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/backlog.md +8 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/browser.md +8 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/build.md +32 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/companion-skills.md +14 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/decomposition.md +83 -2
- package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +53 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/documentation.md +3 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +27 -8
- package/plugins/task-pipeline/skills/task-pipeline/references/model-tiering.md +19 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/planning.md +203 -26
- package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +26 -8
- package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +7 -1
- package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +686 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/execution_authority.py +271 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +415 -18
- package/plugins/task-pipeline/skills/task-pipeline/scripts/packet.py +400 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +2 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/browser-claims.json +54 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/finding-evidence.json +42 -0
- 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
|