specpro-cli 0.1.0__py3-none-any.whl

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 (76) hide show
  1. specpro_cli/__init__.py +16 -0
  2. specpro_cli/assets/commands/specpro.analyze.md +1102 -0
  3. specpro_cli/assets/commands/specpro.checklist.md +335 -0
  4. specpro_cli/assets/commands/specpro.clarify.md +581 -0
  5. specpro_cli/assets/commands/specpro.constitution.md +488 -0
  6. specpro_cli/assets/commands/specpro.feature.md +115 -0
  7. specpro_cli/assets/commands/specpro.implement.md +1881 -0
  8. specpro_cli/assets/commands/specpro.manual-test.md +206 -0
  9. specpro_cli/assets/commands/specpro.plan.md +3284 -0
  10. specpro_cli/assets/commands/specpro.qc.md +1489 -0
  11. specpro_cli/assets/commands/specpro.scenarios.md +154 -0
  12. specpro_cli/assets/commands/specpro.specify.md +1449 -0
  13. specpro_cli/assets/commands/specpro.status.md +863 -0
  14. specpro_cli/assets/commands/specpro.tasks.md +1207 -0
  15. specpro_cli/assets/commands/specpro.test-implement.md +462 -0
  16. specpro_cli/assets/commands/specpro.test-plan.md +383 -0
  17. specpro_cli/assets/commands/specpro.user-manual.md +178 -0
  18. specpro_cli/assets/scripts/bash/check-anti-coupling.sh +293 -0
  19. specpro_cli/assets/scripts/bash/check-prerequisites.sh +176 -0
  20. specpro_cli/assets/scripts/bash/common.sh +88 -0
  21. specpro_cli/assets/scripts/bash/create-new-feature.sh +336 -0
  22. specpro_cli/assets/scripts/bash/qc-auto-fix.sh +121 -0
  23. specpro_cli/assets/scripts/bash/setup-plan.sh +60 -0
  24. specpro_cli/assets/scripts/bash/verify-cumulative-records.sh +203 -0
  25. specpro_cli/assets/scripts/bash/verify-deliverables-tracked.sh +147 -0
  26. specpro_cli/assets/scripts/bash/verify-deployment.sh +239 -0
  27. specpro_cli/assets/scripts/bash/verify-frontmatter-yaml.sh +63 -0
  28. specpro_cli/assets/scripts/bash/verify-ledger.sh +376 -0
  29. specpro_cli/assets/scripts/bash/verify-shapes.sh +1082 -0
  30. specpro_cli/assets/scripts/git-hooks/pre-commit +243 -0
  31. specpro_cli/assets/scripts/install-git-hooks.sh +67 -0
  32. specpro_cli/assets/scripts/powershell/check-anti-coupling.ps1 +249 -0
  33. specpro_cli/assets/scripts/powershell/check-prerequisites.ps1 +148 -0
  34. specpro_cli/assets/scripts/powershell/common.ps1 +95 -0
  35. specpro_cli/assets/scripts/powershell/create-new-feature.ps1 +229 -0
  36. specpro_cli/assets/scripts/powershell/qc-auto-fix.ps1 +110 -0
  37. specpro_cli/assets/scripts/powershell/setup-plan.ps1 +61 -0
  38. specpro_cli/assets/scripts/powershell/verify-cumulative-records.ps1 +133 -0
  39. specpro_cli/assets/scripts/powershell/verify-deliverables-tracked.ps1 +112 -0
  40. specpro_cli/assets/scripts/powershell/verify-deployment.ps1 +278 -0
  41. specpro_cli/assets/scripts/powershell/verify-frontmatter-yaml.ps1 +56 -0
  42. specpro_cli/assets/scripts/powershell/verify-ledger.ps1 +383 -0
  43. specpro_cli/assets/scripts/powershell/verify-shapes.ps1 +978 -0
  44. specpro_cli/assets/templates/agent-context-template.md +49 -0
  45. specpro_cli/assets/templates/assumptions-template.md +248 -0
  46. specpro_cli/assets/templates/checklist-template.md +40 -0
  47. specpro_cli/assets/templates/clarifications-template.md +155 -0
  48. specpro_cli/assets/templates/constitution-template.md +50 -0
  49. specpro_cli/assets/templates/feature-spec-template.md +66 -0
  50. specpro_cli/assets/templates/plan-overview-template.md +150 -0
  51. specpro_cli/assets/templates/plan-template.md +387 -0
  52. specpro_cli/assets/templates/protocol-golden-bytes-guide.md +195 -0
  53. specpro_cli/assets/templates/requirements-template.md +356 -0
  54. specpro_cli/assets/templates/spec-template.md +267 -0
  55. specpro_cli/assets/templates/tasks-template.md +252 -0
  56. specpro_cli/assets/templates/test-tasks-template.md +174 -0
  57. specpro_cli/cli/__init__.py +5 -0
  58. specpro_cli/cli/cmd_init.py +416 -0
  59. specpro_cli/cli/cmd_remove.py +122 -0
  60. specpro_cli/cli/entry.py +181 -0
  61. specpro_cli/integrations/__init__.py +36 -0
  62. specpro_cli/integrations/base.py +601 -0
  63. specpro_cli/integrations/claude/__init__.py +101 -0
  64. specpro_cli/integrations/copilot/__init__.py +153 -0
  65. specpro_cli/integrations/cursor_agent/__init__.py +51 -0
  66. specpro_cli/integrations/gemini/__init__.py +44 -0
  67. specpro_cli/integrations/opencode/__init__.py +48 -0
  68. specpro_cli/integrations/qodercli/__init__.py +54 -0
  69. specpro_cli/integrations/registry.py +88 -0
  70. specpro_cli/packaged/__init__.py +5 -0
  71. specpro_cli/packaged/sync.py +106 -0
  72. specpro_cli-0.1.0.dist-info/METADATA +117 -0
  73. specpro_cli-0.1.0.dist-info/RECORD +76 -0
  74. specpro_cli-0.1.0.dist-info/WHEEL +4 -0
  75. specpro_cli-0.1.0.dist-info/entry_points.txt +2 -0
  76. specpro_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,376 @@
1
+ #!/bin/bash
2
+ #
3
+ # Ledger Integrity Verification Script
4
+ # Verifies specs/implement_issues.md structural invariants.
5
+ #
6
+ # Why this exists (2026-09-12): the ledger is a POSITION-STRUCTURED file —
7
+ # an entry's section is decided purely by where it sits in the file. Every
8
+ # writer that "appends" therefore silently depends on which section happens
9
+ # to be last, and that fact changes as sections are added. Observed failure:
10
+ # ISS-049 (a [tasks]-side finding) was appended to the END OF THE FILE and
11
+ # landed in [test-plan], whose consumer has no mandate to act on it — the
12
+ # entry looked registered, the statistics table looked plausible, nothing
13
+ # errored. Two statistics drifts were found in the same review, also silent.
14
+ #
15
+ # Invariants checked:
16
+ # 1. The four phase sections exist, in canonical order
17
+ # 2. Every issue entry belongs to one of those sections (nothing orphaned
18
+ # outside a section — the "appended past the sentinel" signature)
19
+ # 3. The statistics table row for each section matches its actual entry count
20
+ # 4. Pending-Items names only entries that exist as pending entries
21
+ # 5. The file ends with exactly one newline (append-based writers rely on it;
22
+ # a missing trailing newline fuses the next entry onto the previous line)
23
+ # 6. Every entry block carries exactly ONE Related line, and every field line
24
+ # lies INSIDE some entry block. A block runs to the nearest of: the next
25
+ # entry, the next section heading, the sentinel. Both directions matter —
26
+ # a tail split off into the next block and a tail written past a boundary
27
+ # are the same defect seen from two sides.
28
+ #
29
+ # Usage: .specpro/scripts/bash/verify-ledger.sh
30
+ # LEDGER=<path> .specpro/scripts/bash/verify-ledger.sh (verify a copy — used by its own tests)
31
+ # Exit: 0 = all invariants hold; 1 = at least one violation (details printed)
32
+
33
+ set -uo pipefail
34
+
35
+ LEDGER="${LEDGER:-specs/implement_issues.md}"
36
+ # The contract is resolved RELATIVE TO THE LEDGER, not to a fixed path: in the
37
+ # two-repository layout this script runs from the root repo (ledger at
38
+ # specs/implement_issues.md) AND from inside the specs repo (ledger at
39
+ # implement_issues.md), and a hardcoded specs/contracts/... resolves to nothing
40
+ # in the second case — where the verifier then refuses to run at all. The hook
41
+ # caught exactly that on the first commit after this change.
42
+ CONTRACT="${CONTRACT:-$(dirname "$LEDGER")/contracts/ledger.md}"
43
+
44
+ # --- 0. the section list is DERIVED, not transcribed -------------------------
45
+ # `## Sections and their consumers` in the contract is the single source for which
46
+ # sections exist, in what order. This array used to be a hand-copied duplicate of
47
+ # that table, and the two diverged exactly the way two sources of one fact do: the
48
+ # contract gained a row for a section the ledger did not have yet, and **nothing
49
+ # reported it** — invariant #1 was violated for as long as that lasted. A copy is
50
+ # not a check; deriving one from the other is.
51
+ SECTIONS=()
52
+ if [ -f "$CONTRACT" ]; then
53
+ while IFS= read -r s; do
54
+ # Table rows look like: | `<name>` | <consumer> | <what it does> |
55
+ [ -n "$s" ] && SECTIONS+=("$s")
56
+ done < <(sed -n '/^## Sections and their consumers/,/^## /p' "$CONTRACT" \
57
+ | sed -n 's/^| `\[\([a-z-]*\)\]`.*/\1/p')
58
+ fi
59
+
60
+ if [ "${#SECTIONS[@]}" -eq 0 ]; then
61
+ # ⚠️ An empty list is NOT "nothing to check": every section-level check below
62
+ # would then pass vacuously, and that is indistinguishable from a clean ledger.
63
+ echo "✗ could not derive the section list from $CONTRACT" >&2
64
+ echo " A verifier with no sections to check reports success without checking anything." >&2
65
+ echo " Fix: run from the repository root, or set CONTRACT=<path to the ledger contract>." >&2
66
+ exit 1
67
+ fi
68
+
69
+ FAIL=0
70
+
71
+ # --- 0b. and the comparison runs BOTH ways -----------------------------------
72
+ # Deriving the list closes "the contract names a section the ledger lacks". It does
73
+ # NOT close the reverse: a section present in the ledger but absent from the contract
74
+ # is simply never iterated, so it is never checked — and the run still reports clean.
75
+ # That is the same silent-uncoverage the derived list was meant to remove, one
76
+ # direction over.
77
+ if [ -f "$LEDGER" ]; then
78
+ in_ledger=$(grep -oE '^## \[[a-z-]+\] Phase Issues$' "$LEDGER" | sed 's/^## \[\([a-z-]*\)\].*/\1/' | sort -u)
79
+ in_contract=$(printf '%s\n' "${SECTIONS[@]}" | sort -u)
80
+ undeclared=$(comm -23 <(printf '%s\n' "$in_ledger") <(printf '%s\n' "$in_contract"))
81
+ if [ -n "$undeclared" ]; then
82
+ echo "✗ section(s) present in the ledger but absent from the contract's table:" >&2
83
+ printf ' %s\n' $undeclared >&2
84
+ echo " → nothing checks them: the verifier iterates the contract's list." >&2
85
+ echo " Add the row to $CONTRACT (with its consumer), or remove the section." >&2
86
+ exit 1
87
+ fi
88
+ fi
89
+
90
+ if [ ! -f "$LEDGER" ]; then
91
+ echo "✗ $LEDGER not found (run from the repository root)" >&2
92
+ exit 1
93
+ fi
94
+
95
+ echo "Ledger integrity: $LEDGER"
96
+ echo
97
+
98
+ # --- 1. sections exist, in canonical order -----------------------------------
99
+ seen=""
100
+ order_ok=1
101
+ for s in "${SECTIONS[@]}"; do
102
+ line=$(grep -n "^## \[$s\] Phase Issues$" "$LEDGER" | head -1 | cut -d: -f1)
103
+ if [ -z "$line" ]; then
104
+ echo "✗ missing section: ## [$s] Phase Issues"
105
+ FAIL=1; order_ok=0
106
+ else
107
+ seen="$seen $line"
108
+ printf " ✓ [%-9s] heading at line %s\n" "$s" "$line"
109
+ fi
110
+ done
111
+ if [ "$order_ok" = 1 ]; then
112
+ sorted=$(echo $seen | tr ' ' '\n' | sort -n | tr '\n' ' ')
113
+ [ "$(echo $seen)" = "${sorted% }" ] || { echo "✗ sections are not in canonical order"; FAIL=1; }
114
+ fi
115
+
116
+ # --- 2. no orphaned entries (entry outside every section) --------------------
117
+ # An entry is orphaned when it appears after the last section's heading but
118
+ # outside it — in practice: appended after the end-of-file sentinel.
119
+ last_heading=$(grep -n "^## \[.*\] Phase Issues$" "$LEDGER" | tail -1 | cut -d: -f1)
120
+ sentinel=$(grep -n "^-->$" "$LEDGER" | tail -1 | cut -d: -f1)
121
+ if [ -n "$sentinel" ] && [ -n "$last_heading" ] && [ "$sentinel" -gt "$last_heading" ]; then
122
+ orphan=$(awk -v s="$sentinel" 'NR>s && /^- \[[ x]\] ISS-/' "$LEDGER")
123
+ if [ -n "$orphan" ]; then
124
+ echo "✗ orphaned entry after the end-of-file sentinel (line $sentinel):"
125
+ echo "$orphan" | cut -c1-90 | sed 's/^/ /'
126
+ echo " → it belongs to no section; move it into its own section"
127
+ FAIL=1
128
+ else
129
+ echo " ✓ no entries appended past the end-of-file sentinel"
130
+ fi
131
+ fi
132
+
133
+ # --- 3. statistics table matches actual counts -------------------------------
134
+ echo
135
+ for s in "${SECTIONS[@]}"; do
136
+ h=$(grep -n "^## \[$s\] Phase Issues$" "$LEDGER" | head -1 | cut -d: -f1)
137
+ [ -z "$h" ] && continue
138
+ # Section body runs until the next '## [' heading, the end-of-file sentinel,
139
+ # or EOF — whichever comes first. Stopping at the sentinel matters: content
140
+ # appended past it is orphaned and must NOT be counted as part of the last
141
+ # section (otherwise one misfiling is reported twice, as an orphan AND as a
142
+ # count mismatch, and the count mismatch points at the wrong section).
143
+ next=$(awk -v s="$h" 'NR>s && (/^## \[/ || /^-->$/){print NR; exit}' "$LEDGER")
144
+ if [ -n "$next" ]; then
145
+ body=$(awk -v a="$h" -v b="$next" 'NR>a && NR<b' "$LEDGER")
146
+ else
147
+ body=$(awk -v a="$h" 'NR>a' "$LEDGER")
148
+ fi
149
+ res=$(echo "$body" | grep -c "^- \[x\] ISS-")
150
+ pen=$(echo "$body" | grep -c "^- \[ \] ISS-")
151
+ tot=$((res + pen))
152
+ row=$(grep "^| \[$s\]" "$LEDGER" | head -1)
153
+ t_tot=$(echo "$row" | awk -F'|' '{gsub(/ /,"",$3); print $3}')
154
+ t_res=$(echo "$row" | awk -F'|' '{gsub(/ /,"",$4); print $4}')
155
+ t_pen=$(echo "$row" | awk -F'|' '{gsub(/ /,"",$5); print $5}')
156
+ if [ "$tot" = "$t_tot" ] && [ "$res" = "$t_res" ] && [ "$pen" = "$t_pen" ]; then
157
+ printf " ✓ [%-9s] actual %s/%s/%s = table\n" "$s" "$tot" "$res" "$pen"
158
+ else
159
+ printf " ✗ [%-9s] actual %s/%s/%s ≠ table %s/%s/%s\n" "$s" "$tot" "$res" "$pen" "$t_tot" "$t_res" "$t_pen"
160
+ # Direction matters — the two directions have different root causes and
161
+ # different correct fixes. Telling the reader to "fix the table" is the
162
+ # wrong advice for the over-count direction (observed 2026-09-13): there
163
+ # the entry was never written, and the fix is to write it.
164
+ if [ "$t_tot" -gt "$tot" ] 2>/dev/null; then
165
+ echo " → table EXCEEDS the section by $((t_tot - tot)): the summary claims entries that do not exist."
166
+ echo " Signature of a SKIPPED WRITE — most often an idempotency guard that searched"
167
+ echo " the whole file for the ID and matched a mention (the statistics Pending-Items"
168
+ echo " column, the Last Updated line, a cross-reference) instead of the entry itself."
169
+ echo " Fix: write the missing entry body (and confirm the guard matches '^- \\[[x ]\\] ISS-<N>:' )."
170
+ elif [ "$t_tot" -lt "$tot" ] 2>/dev/null; then
171
+ echo " → section EXCEEDS the table by $((tot - t_tot)): the derived summary was not updated."
172
+ echo " Fix: update the table (the section entries are authoritative)."
173
+ else
174
+ echo " → counts agree but the resolved/pending split differs; reconcile against the section."
175
+ fi
176
+ FAIL=1
177
+ fi
178
+ done
179
+
180
+ # --- 4. Pending-Items column names only entries that exist -------------------
181
+ # ID-level check, independent of the counts: every ID listed in a section's
182
+ # Pending-Items column must exist as a pending entry in THAT section. This is
183
+ # the direct signature of the skipped-write defect — a summary naming an entry
184
+ # that was never written. Counts can coincide (one skipped + one un-bumped
185
+ # elsewhere) while this still fails, so it is not redundant with check 3.
186
+ echo
187
+ for s in "${SECTIONS[@]}"; do
188
+ h=$(grep -n "^## \[$s\] Phase Issues$" "$LEDGER" | head -1 | cut -d: -f1)
189
+ [ -z "$h" ] && continue
190
+ next=$(awk -v s="$h" 'NR>s && (/^## \[/ || /^-->$/){print NR; exit}' "$LEDGER")
191
+ if [ -n "$next" ]; then
192
+ body=$(awk -v a="$h" -v b="$next" 'NR>a && NR<b' "$LEDGER")
193
+ else
194
+ body=$(awk -v a="$h" 'NR>a' "$LEDGER")
195
+ fi
196
+ named=$(grep "^| \[$s\]" "$LEDGER" | head -1 | awk -F'|' '{print $6}' | grep -oE 'ISS-[0-9]+' | sort -u)
197
+ if [ -z "$named" ]; then
198
+ printf " ✓ [%-9s] Pending-Items column names no IDs\n" "$s"
199
+ continue
200
+ fi
201
+ miss=""
202
+ for id in $named; do
203
+ # A herestring, not a pipe. Under `pipefail`, `echo "$body" | grep -q …` can
204
+ # return 141: grep exits early on a match, echo takes SIGPIPE writing the rest,
205
+ # and the pipeline's status then reports failure. An entry that IS present reads
206
+ # as missing — and only sometimes, because the race is decided by scheduling.
207
+ # A verdict that is not a function of the input is not a verdict.
208
+ grep -qE "^- \[ \] ${id}:" <<<"$body" || miss="$miss $id"
209
+ done
210
+ if [ -z "$miss" ]; then
211
+ printf " ✓ [%-9s] every named pending ID exists as an entry\n" "$s"
212
+ else
213
+ printf " ✗ [%-9s] Pending-Items names %s but no such pending entry exists in this section\n" "$s" "$miss"
214
+ echo " → the summary names an entry the file does not contain — a skipped or lost write,"
215
+ echo " not a formatting slip. Write the entry body, or correct the column if it was over-bumped."
216
+ FAIL=1
217
+ fi
218
+ done
219
+
220
+ # --- 4b. a completed hand-off names an entry that EXISTS in its target section --------
221
+ # ISS-110 / ISS-93 / ISS-90 ③: a decision whose closing line was "hand this to
222
+ # /specpro-tasks --review-issues" created no `[tasks]` entry — so that command read an
223
+ # EMPTY queue and exited having done nothing, while the decision read as complete. The
224
+ # channel was never the problem (登记即路由 — any command may append to any section);
225
+ # what was missing was the OBLIGATION to write the entry, and a point where its absence
226
+ # stops something. This is that point.
227
+ #
228
+ # ⚠️ **The anchor is a LINE-START form, never a full-text match** (TOOL-009). The ledger is
229
+ # full of prose ABOUT hand-offs — this very comment is an example — and a check that
230
+ # matched a mention would fire on the discussion instead of the act. The form is fixed by
231
+ # the rule in `commands/specpro.plan.md`, and it is fixed precisely so this can be judged
232
+ # mechanically: an intention scattered through prose has no shape to test.
233
+ #
234
+ # ⚠️ **Only `[x]` entries are checked.** A `[ ]` entry has not handed anything off yet;
235
+ # demanding its target exist would punish the correct order of work.
236
+ #
237
+ # ⚠️ The arrow is the literal `→`. It is part of the anchor, not decoration: a rule that
238
+ # can be written three ways cannot be found by one pattern.
239
+ echo
240
+ handoffs=$(awk '
241
+ /^- \[[ x]\] ISS-[0-9]+:/ { cur = $0 }
242
+ /^ \*\*移交\*\*: \[[a-z-]+\] → ISS-[0-9]+/ { if (cur ~ /^- \[x\]/) print NR "\t" cur "\t" $0 }
243
+ ' "$LEDGER")
244
+
245
+ if [ -z "$handoffs" ]; then
246
+ echo " ✓ no completed entry carries a hand-off line (nothing to resolve)"
247
+ else
248
+ bad=0
249
+ while IFS="$(printf '\t')" read -r lineno entry_line handoff_line; do
250
+ target=$(printf '%s' "$handoff_line" | sed -E 's/.*\[([a-z-]+)\].*/\1/')
251
+ hid=$(printf '%s' "$handoff_line" | grep -oE 'ISS-[0-9]+' | head -1)
252
+ src=$(printf '%s' "$entry_line" | grep -oE 'ISS-[0-9]+' | head -1)
253
+
254
+ # (a) the target must be a partition the contract actually defines. A hand-off to
255
+ # a partition nobody consumes is a dead end wearing a route's clothes (Q2).
256
+ if ! printf '%s\n' "${SECTIONS[@]}" | grep -qx "$target"; then
257
+ printf " ✗ hand-off at line %s targets [%s], which the contract does not define\n" "$lineno" "$target"
258
+ echo " → the named partition has no consumer; a hand-off there is a dead end."
259
+ bad=1
260
+ continue
261
+ fi
262
+
263
+ # (b) the named entry must exist IN THAT SECTION. Not "somewhere in the file" —
264
+ # the whole point of a section is that a specific consumer reads it.
265
+ h=$(grep -n "^## \[$target\] Phase Issues$" "$LEDGER" | head -1 | cut -d: -f1)
266
+ next=$(awk -v s="$h" 'NR>s && (/^## \[/ || /^-->$/){print NR; exit}' "$LEDGER")
267
+ if [ -n "$next" ]; then
268
+ body=$(awk -v a="$h" -v b="$next" 'NR>a && NR<b' "$LEDGER")
269
+ else
270
+ body=$(awk -v a="$h" 'NR>a' "$LEDGER")
271
+ fi
272
+ # A herestring rather than a pipe — see check 4's note on SIGPIPE under `pipefail`.
273
+ if grep -qE "^- \[[ x]\] ${hid}:" <<<"$body"; then
274
+ printf " ✓ hand-off %s → [%s] %s resolves\n" "$src" "$target" "$hid"
275
+ else
276
+ printf " ✗ hand-off at line %s names %s in [%s], but no such entry exists there\n" \
277
+ "$lineno" "$hid" "$target"
278
+ echo " → the decision says the work moved; the target section says nothing arrived."
279
+ echo " Register the entry in that section (登记即路由), or drop the hand-off line."
280
+ bad=1
281
+ fi
282
+ done <<EOF
283
+ $handoffs
284
+ EOF
285
+ [ "$bad" = 0 ] || FAIL=1
286
+ fi
287
+
288
+ # --- 5. exactly one trailing newline ----------------------------------------
289
+ echo
290
+ if [ -z "$(tail -c 1 "$LEDGER")" ] && [ -n "$(tail -c 2 "$LEDGER" | head -c 1)" ]; then
291
+ echo " ✓ single trailing newline"
292
+ else
293
+ echo " ✗ file does not end with exactly one newline"
294
+ echo " → append-based writers will fuse the next entry onto the previous line"
295
+ FAIL=1
296
+ fi
297
+
298
+ # --- 6. every entry block is bounded, and carries exactly one tail --------
299
+ # The ledger is position-structured at TWO levels: an entry is decided by
300
+ # position within a section, and a section by position within the file. The
301
+ # checks above see neither: a misplaced tail changes no count, and check 2
302
+ # looks only at entry HEADERS. Observed three times — twice a `**Resolution**`
303
+ # landed past the end-of-file sentinel, once past a SECTION boundary. Same
304
+ # writer bug, different boundary; each time every check above reported OK.
305
+ echo
306
+ if ! BOUND=$(awk '
307
+ function flush_block() {
308
+ if (entry == "") return
309
+ if (rel != 1 || res > 1) printf "BLOCK|%s|%d|%d\n", eid, rel, res
310
+ }
311
+ /^- \[[ x]\] ISS-/ {
312
+ flush_block()
313
+ entry = $0; rel = 0; res = 0
314
+ eid = (match($0, /ISS-[0-9]+/)) ? substr($0, RSTART, RLENGTH) : "?"
315
+ next
316
+ }
317
+ /^## \[/ || /^-->/ { flush_block(); entry = ""; next }
318
+ /^ \*\*Related\*\*:/ { if (entry == "") printf "STRAY|%d|Related\n", NR; else rel++ }
319
+ /^ \*\*Resolution\*\*/ { if (entry == "") printf "STRAY|%d|Resolution\n", NR; else res++ }
320
+ END { flush_block() }
321
+ ' "$LEDGER"); then
322
+ echo " ✗ the block-bounds check itself could not be evaluated (awk returned non-zero)"
323
+ echo " → a check that fails to run must NOT report as passing: an empty result"
324
+ echo " from a broken expression is indistinguishable from a clean ledger."
325
+ FAIL=1
326
+ elif [ -z "$BOUND" ]; then
327
+ echo " ✓ every entry block is bounded and carries exactly one Related line"
328
+ else
329
+ while IFS='|' read -r kind a b c; do
330
+ [ -z "$kind" ] && continue
331
+ if [ "$kind" = "BLOCK" ]; then
332
+ printf " ✗ %s: block carries Related=%s Resolution=%s (want 1 and <=1)\n" "$a" "$b" "$c"
333
+ echo " → the block does not end where it should: a tail line was split off,"
334
+ echo " or a foreign line was absorbed."
335
+ else
336
+ printf " ✗ line %s carries a %s OUTSIDE any entry block\n" "$a" "$b"
337
+ echo " → a field line was written past a boundary; it belongs to no entry,"
338
+ echo " so no reader will ever attribute it correctly."
339
+ fi
340
+ done <<< "$BOUND"
341
+ FAIL=1
342
+ fi
343
+
344
+ # --- 7. entry IDs are globally unique ---------------------------------------
345
+ # Why this exists (T206 / ISS-166): every consumer ADDRESSES an entry by its ID —
346
+ # `grep -qE '^- \[[x ]\] ISS-<N>:'` is the marking form this repo documents. Two entries
347
+ # sharing one make that address name neither of them, and NOTHING ELSE reports it: the
348
+ # statistics table still reconciles (bump the count and it agrees), the section checks
349
+ # still pass, the sentinel still holds. The collision is silent until someone reads the
350
+ # whole file — the failure TOOL-011 recorded for its own numbering, in a file that has
351
+ # since been retired while this one inherited none of it.
352
+ #
353
+ # ⚠️ `sort -n`, never `sort`: the IDs are variable width, so `ISS-100` sorts BEFORE
354
+ # `ISS-97` lexicographically. That is also why "take the next number" must use a numeric
355
+ # sort — see the same note in commands/specpro.implement.md.
356
+ echo
357
+ DUP=$(grep -oE '^- \[[x ]\] ISS-[0-9]+:' "$LEDGER" | grep -oE '[0-9]+' | sort -n | uniq -d)
358
+ if [ -z "$DUP" ]; then
359
+ echo " ✓ entry IDs are unique"
360
+ else
361
+ echo " ✗ these entry IDs are used more than once:$(printf ' %s' $DUP)"
362
+ echo " → an ID is how a --review-issues run ADDRESSES an entry; two entries"
363
+ echo " sharing one mean the address names neither of them, and the counts"
364
+ echo " still reconcile, so nothing else would have reported this."
365
+ FAIL=1
366
+ fi
367
+
368
+ # --- verdict -----------------------------------------------------------------
369
+ echo
370
+ if [ "$FAIL" = 0 ]; then
371
+ echo "✓ ledger integrity: all invariants hold"
372
+ exit 0
373
+ else
374
+ echo "✗ ledger integrity: VIOLATIONS FOUND (see above)"
375
+ exit 1
376
+ fi