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,383 @@
1
+ #!/usr/bin/env pwsh
2
+ <#
3
+ .SYNOPSIS
4
+ Verify the implementation-issues ledger's structural invariants.
5
+ .DESCRIPTION
6
+ Contract-equivalent to `scripts/bash/verify-ledger.sh`. The contract is deliberately
7
+ strong here: **for the same ledger, both implementations print byte-identical output
8
+ and return the same exit code** — this script is invoked from command documents and
9
+ from a pre-commit hook, and a check that disagrees with its twin across platforms is
10
+ worse than one that is merely missing, because each side looks authoritative.
11
+
12
+ Invariants, in the order the Bash side checks them:
13
+ 1. the five sections exist and are in canonical order
14
+ 2. no entry sits outside every section (appended past the sentinel)
15
+ 3. each section's statistics row matches its actual entry counts
16
+ 5. every ID named in a Pending-Items column exists as a pending entry
17
+ 4. the file ends with exactly one newline
18
+ (The numbering matches the Bash source; 4 and 5 are ordered by their position there.)
19
+
20
+ .OUTPUTS
21
+ Exit 0 = all invariants hold; 1 = at least one violation, details printed.
22
+ #>
23
+ Set-StrictMode -Version Latest
24
+ $ErrorActionPreference = 'Stop'
25
+
26
+ $LEDGER = if ($env:LEDGER) { $env:LEDGER } else { 'specs/implement_issues.md' }
27
+ $script:FAIL = 0
28
+
29
+ if (-not (Test-Path -LiteralPath $LEDGER -PathType Leaf)) {
30
+ Write-Error "✗ $LEDGER not found (run from the repository root)"
31
+ exit 1
32
+ }
33
+
34
+ # --- 0. the section list is DERIVED, not transcribed ---------------------------------
35
+ # ⚠️ This block was `@('specify','plan','tasks','test-plan')` — a hand-copied duplicate
36
+ # of the contract's table. The bash side removed that copy in ISS-88, and the two then
37
+ # diverged exactly the way two sources of one fact do: the contract gained
38
+ # `[constitution]`, this side never learned about it, and **nothing compared them**.
39
+ # A copy is not a check; deriving one from the other is.
40
+ $CONTRACT = if ($env:CONTRACT) { $env:CONTRACT }
41
+ else { Join-Path (Split-Path -Parent $LEDGER) 'contracts/ledger.md' }
42
+
43
+ $SECTIONS = @()
44
+ if (Test-Path -LiteralPath $CONTRACT -PathType Leaf) {
45
+ $inTable = $false
46
+ foreach ($line in Get-Content -LiteralPath $CONTRACT) {
47
+ if ($line -match '^## Sections and their consumers') { $inTable = $true; continue }
48
+ if ($inTable -and $line -match '^## ') { break }
49
+ if ($inTable -and $line -match '^\| `\[([a-z-]+)\]`') { $SECTIONS += $Matches[1] }
50
+ }
51
+ }
52
+
53
+ if ($SECTIONS.Count -eq 0) {
54
+ # ⚠️ An empty list is NOT "nothing to check": every section-level check below would
55
+ # pass vacuously, and that is indistinguishable from a clean ledger.
56
+ [Console]::Error.WriteLine("✗ could not derive the section list from $CONTRACT")
57
+ [Console]::Error.WriteLine(" A verifier with no sections to check reports success without checking anything.")
58
+ [Console]::Error.WriteLine(" Fix: run from the repository root, or set CONTRACT=<path to the ledger contract>.")
59
+ exit 1
60
+ }
61
+
62
+ # --- 0b. and the comparison runs BOTH ways -------------------------------------------
63
+ # Deriving the list closes "the contract names a section the ledger lacks". It does NOT
64
+ # close the reverse: a section present in the ledger but absent from the contract is
65
+ # simply never iterated, so it is never checked — and the run still reports clean.
66
+ $inLedger = @(Get-Content -LiteralPath $LEDGER |
67
+ Where-Object { $_ -match '^## \[[a-z-]+\] Phase Issues$' } |
68
+ ForEach-Object { [regex]::Match($_, '^## \[([a-z-]+)\]').Groups[1].Value } |
69
+ Sort-Object -Unique)
70
+ $undeclared = @($inLedger | Where-Object { $SECTIONS -notcontains $_ })
71
+ if ($undeclared.Count -gt 0) {
72
+ [Console]::Error.WriteLine("✗ section(s) present in the ledger but absent from the contract's table:")
73
+ foreach ($u in $undeclared) { [Console]::Error.WriteLine(" $u") }
74
+ [Console]::Error.WriteLine(" → nothing checks them: the verifier iterates the contract's list.")
75
+ [Console]::Error.WriteLine(" Add the row to $CONTRACT (with its consumer), or remove the section.")
76
+ exit 1
77
+ }
78
+
79
+ # The whole ledger as an array of lines. Every check below reads this rather than
80
+ # re-reading the file, so a mid-run edit cannot make two checks disagree.
81
+ $lines = Get-Content -LiteralPath $LEDGER
82
+
83
+ function Get-SectionHeadingLine {
84
+ param([string]$Section)
85
+ for ($i = 0; $i -lt $lines.Count; $i++) {
86
+ if ($lines[$i] -ceq "## [$Section] Phase Issues") { return $i } # 0-based
87
+ }
88
+ return -1
89
+ }
90
+
91
+ function Get-SectionBody {
92
+ param([int]$HeadingIndex)
93
+ # Body runs to the next '## [' heading, the sentinel, or EOF — whichever is first.
94
+ # Stopping at the sentinel matters: content appended past it is orphaned and must
95
+ # NOT be counted in the last section, or one misfiling is reported twice.
96
+ $end = $lines.Count
97
+ for ($i = $HeadingIndex + 1; $i -lt $lines.Count; $i++) {
98
+ if ($lines[$i] -match '^## \[' -or $lines[$i] -ceq '-->') { $end = $i; break }
99
+ }
100
+ if ($end -le $HeadingIndex + 1) { return @() }
101
+ return $lines[($HeadingIndex + 1)..($end - 1)]
102
+ }
103
+
104
+ Write-Output "Ledger integrity: $LEDGER"
105
+ Write-Output ''
106
+
107
+ # --- 1. sections exist, in canonical order --------------------------------------------
108
+ $headings = @()
109
+ foreach ($s in $SECTIONS) {
110
+ $idx = Get-SectionHeadingLine $s
111
+ if ($idx -lt 0) {
112
+ Write-Output "✗ missing section: ## [$s] Phase Issues"
113
+ $script:FAIL = 1
114
+ } else {
115
+ $headings += $idx
116
+ Write-Output (" ✓ [{0,-9}] heading at line {1}" -f $s, ($idx + 1))
117
+ }
118
+ }
119
+ if ($headings.Count -eq $SECTIONS.Count) {
120
+ $sorted = $headings | Sort-Object
121
+ if (($headings -join ',') -ne ($sorted -join ',')) {
122
+ Write-Output '✗ sections are not in canonical order'
123
+ $script:FAIL = 1
124
+ }
125
+ }
126
+
127
+ # --- 2. no orphaned entries (entry outside every section) -----------------------------
128
+ $lastHeading = -1
129
+ for ($i = $lines.Count - 1; $i -ge 0; $i--) {
130
+ if ($lines[$i] -match '^## \[.*\] Phase Issues$') { $lastHeading = $i; break }
131
+ }
132
+ $sentinel = -1
133
+ for ($i = $lines.Count - 1; $i -ge 0; $i--) {
134
+ if ($lines[$i] -ceq '-->') { $sentinel = $i; break }
135
+ }
136
+ if ($sentinel -ge 0 -and $lastHeading -ge 0 -and $sentinel -gt $lastHeading) {
137
+ # ⚠️ `$a..$b` with $a > $b yields a DESCENDING range in PowerShell, and indexing
138
+ # past the end throws — where Bash's `awk 'NR>s'` simply yields nothing. So the
139
+ # empty range has to be tested for rather than relied on to be empty.
140
+ $orphan = @()
141
+ if ($sentinel + 1 -le $lines.Count - 1) {
142
+ $orphan = @($lines[($sentinel + 1)..($lines.Count - 1)] | Where-Object { $_ -match '^- \[[ x]\] ISS-' })
143
+ }
144
+ if ($orphan.Count -gt 0) {
145
+ Write-Output "✗ orphaned entry after the end-of-file sentinel (line $($sentinel + 1)):"
146
+ foreach ($o in $orphan) {
147
+ $trimmed = if ($o.Length -gt 90) { $o.Substring(0, 90) } else { $o }
148
+ Write-Output " $trimmed"
149
+ }
150
+ Write-Output ' → it belongs to no section; move it into its own section'
151
+ $script:FAIL = 1
152
+ } else {
153
+ Write-Output ' ✓ no entries appended past the end-of-file sentinel'
154
+ }
155
+ }
156
+
157
+ # --- 3. statistics table matches actual counts ----------------------------------------
158
+ Write-Output ''
159
+ foreach ($s in $SECTIONS) {
160
+ $h = Get-SectionHeadingLine $s
161
+ if ($h -lt 0) { continue }
162
+ $body = Get-SectionBody $h
163
+ $res = @($body | Where-Object { $_ -match '^- \[x\] ISS-' }).Count
164
+ $pen = @($body | Where-Object { $_ -match '^- \[ \] ISS-' }).Count
165
+ $tot = $res + $pen
166
+
167
+ $row = $lines | Where-Object { $_ -match "^\| \[$s\]" } | Select-Object -First 1
168
+ $cells = $row -split '\|'
169
+ $tTot = if ($cells.Count -gt 2) { $cells[2].Trim() } else { '' }
170
+ $tRes = if ($cells.Count -gt 3) { $cells[3].Trim() } else { '' }
171
+ $tPen = if ($cells.Count -gt 4) { $cells[4].Trim() } else { '' }
172
+
173
+ if ($tot -eq [int]$tTot -and $res -eq [int]$tRes -and $pen -eq [int]$tPen) {
174
+ Write-Output (" ✓ [{0,-9}] actual {1}/{2}/{3} = table" -f $s, $tot, $res, $pen)
175
+ } else {
176
+ Write-Output (" ✗ [{0,-9}] actual {1}/{2}/{3} ≠ table {4}/{5}/{6}" -f $s, $tot, $res, $pen, $tTot, $tRes, $tPen)
177
+ # Direction matters: the two directions have different root causes and different
178
+ # correct fixes. "Fix the table" is the WRONG advice for the over-count direction
179
+ # — there the entry was never written, and the fix is to write it.
180
+ if ([int]$tTot -gt $tot) {
181
+ Write-Output (" → table EXCEEDS the section by {0}: the summary claims entries that do not exist." -f ([int]$tTot - $tot))
182
+ Write-Output ' Signature of a SKIPPED WRITE — most often an idempotency guard that searched'
183
+ Write-Output ' the whole file for the ID and matched a mention (the statistics Pending-Items'
184
+ Write-Output ' column, the Last Updated line, a cross-reference) instead of the entry itself.'
185
+ Write-Output " Fix: write the missing entry body (and confirm the guard matches '^- \[[x ]\] ISS-<N>:' )."
186
+ } elseif ([int]$tTot -lt $tot) {
187
+ Write-Output (" → section EXCEEDS the table by {0}: the derived summary was not updated." -f ($tot - [int]$tTot))
188
+ Write-Output ' Fix: update the table (the section entries are authoritative).'
189
+ } else {
190
+ Write-Output ' → counts agree but the resolved/pending split differs; reconcile against the section.'
191
+ }
192
+ $script:FAIL = 1
193
+ }
194
+ }
195
+
196
+ # --- 5. Pending-Items column names only entries that exist ----------------------------
197
+ Write-Output ''
198
+ foreach ($s in $SECTIONS) {
199
+ $h = Get-SectionHeadingLine $s
200
+ if ($h -lt 0) { continue }
201
+ $body = Get-SectionBody $h
202
+ $row = $lines | Where-Object { $_ -match "^\| \[$s\]" } | Select-Object -First 1
203
+ $cells = $row -split '\|'
204
+ $column = if ($cells.Count -gt 5) { $cells[5] } else { '' }
205
+ $named = @([regex]::Matches($column, 'ISS-[0-9]+') | ForEach-Object { $_.Value } | Sort-Object -Unique)
206
+
207
+ if ($named.Count -eq 0) {
208
+ Write-Output (" ✓ [{0,-9}] Pending-Items column names no IDs" -f $s)
209
+ continue
210
+ }
211
+ # ⚠️ `$miss` is built with a LEADING space per item, matching the Bash side's
212
+ # `miss="$miss $id"`. The result renders as `names ISS-99` — two spaces — and that
213
+ # is the output this script has to reproduce byte-for-byte. Tidying it to a single
214
+ # space here would be an improvement that breaks the contract.
215
+ $miss = ''
216
+ foreach ($id in $named) {
217
+ $pattern = "^- \[ \] $id`:"
218
+ if (-not ($body | Where-Object { $_ -match $pattern })) { $miss += " $id" }
219
+ }
220
+ if ($miss -eq '') {
221
+ Write-Output (" ✓ [{0,-9}] every named pending ID exists as an entry" -f $s)
222
+ } else {
223
+ Write-Output (" ✗ [{0,-9}] Pending-Items names {1} but no such pending entry exists in this section" -f $s, $miss)
224
+ Write-Output ' → the summary names an entry the file does not contain — a skipped or lost write,'
225
+ Write-Output ' not a formatting slip. Write the entry body, or correct the column if it was over-bumped.'
226
+ $script:FAIL = 1
227
+ }
228
+ }
229
+
230
+ # --- 4b. a completed hand-off names an entry that EXISTS in its target section --------
231
+ # ISS-110 / ISS-93 / ISS-90 ③: a decision whose closing line was "hand this to
232
+ # /specpro-tasks --review-issues" created no `[tasks]` entry — so that command read an
233
+ # EMPTY queue and exited having done nothing, while the decision read as complete. The
234
+ # channel was never the problem (登记即路由); what was missing was the OBLIGATION to write
235
+ # the entry, and a point where its absence stops something. This is that point.
236
+ #
237
+ # ⚠️ LINE-START anchor, never a full-text match (TOOL-009): the ledger is full of prose
238
+ # ABOUT hand-offs and a check that matched a mention would fire on the discussion.
239
+ # ⚠️ Only `[x]` entries — a `[ ]` entry has not handed anything off yet.
240
+ Write-Output ''
241
+ $handoffs = @()
242
+ $cur = ''
243
+ for ($i = 0; $i -lt $lines.Count; $i++) {
244
+ $ln = $lines[$i]
245
+ if ($ln -match '^- \[[ x]\] ISS-[0-9]+:') { $cur = $ln }
246
+ if ($ln -match '^ \*\*移交\*\*: \[[a-z-]+\] → ISS-[0-9]+') {
247
+ if ($cur -match '^- \[x\]') {
248
+ $handoffs += [pscustomobject]@{ LineNo = $i + 1; Entry = $cur; Handoff = $ln }
249
+ }
250
+ }
251
+ }
252
+ if ($handoffs.Count -eq 0) {
253
+ Write-Output ' ✓ no completed entry carries a hand-off line (nothing to resolve)'
254
+ } else {
255
+ $bad = 0
256
+ foreach ($h in $handoffs) {
257
+ $target = [regex]::Match($h.Handoff, '\[([a-z-]+)\]').Groups[1].Value
258
+ $hid = [regex]::Match($h.Handoff, 'ISS-[0-9]+').Value
259
+ $src = [regex]::Match($h.Entry, 'ISS-[0-9]+').Value
260
+ if (-not ($SECTIONS -contains $target)) {
261
+ Write-Output (" ✗ hand-off at line {0} targets [{1}], which the contract does not define" -f $h.LineNo, $target)
262
+ Write-Output ' → the named partition has no consumer; a hand-off there is a dead end.'
263
+ $bad = 1
264
+ continue
265
+ }
266
+ $hh = Get-SectionHeadingLine $target
267
+ $body = Get-SectionBody $hh
268
+ if ($body | Where-Object { $_ -match "^- \[[ x]\] $hid`:" }) {
269
+ Write-Output (" ✓ hand-off {0} → [{1}] {2} resolves" -f $src, $target, $hid)
270
+ } else {
271
+ Write-Output (" ✗ hand-off at line {0} names {1} in [{2}], but no such entry exists there" -f $h.LineNo, $hid, $target)
272
+ Write-Output ' → the decision says the work moved; the target section says nothing arrived.'
273
+ Write-Output ' Register the entry in that section (登记即路由), or drop the hand-off line.'
274
+ $bad = 1
275
+ }
276
+ }
277
+ if ($bad -ne 0) { $script:FAIL = 1 }
278
+ }
279
+
280
+ # --- 4. exactly one trailing newline --------------------------------------------------
281
+ Write-Output ''
282
+ $raw = [System.IO.File]::ReadAllText((Resolve-Path -LiteralPath $LEDGER).Path)
283
+ if ($raw.EndsWith("`n") -and -not $raw.EndsWith("`n`n")) {
284
+ Write-Output ' ✓ single trailing newline'
285
+ } else {
286
+ Write-Output ' ✗ file does not end with exactly one newline'
287
+ Write-Output ' → append-based writers will fuse the next entry onto the previous line'
288
+ $script:FAIL = 1
289
+ }
290
+
291
+ # --- 6. every entry block is bounded, and carries exactly one tail ----------------
292
+ # The ledger is position-structured at TWO levels: an entry is decided by position
293
+ # within a section, and a section by position within the file. The checks above see
294
+ # neither: a misplaced tail changes no count, and check 2 looks only at entry HEADERS.
295
+ # Observed three times — twice a `**Resolution**` landed past the end-of-file sentinel,
296
+ # once past a SECTION boundary. Same writer bug, different boundary; each time every
297
+ # check above reported OK.
298
+ #
299
+ # ⚠️ The block-boundary flush is written out twice rather than factored into a helper:
300
+ # a PowerShell scriptblock invoked with `&` runs in a CHILD scope, so `$bounds += …`
301
+ # inside it would be discarded on return — the check would collect nothing and report
302
+ # a clean ledger. Duplication is the cheaper mistake here.
303
+ Write-Output ""
304
+ $bounds = @()
305
+ $entry = $null; $rel = 0; $res = 0; $eid = ""
306
+
307
+ for ($n = 0; $n -lt $lines.Count; $n++) {
308
+ $l = $lines[$n]; $lineNo = $n + 1
309
+
310
+ if ($l -match '^- \[[ x]\] ISS-') {
311
+ if ($null -ne $entry -and ($rel -ne 1 -or $res -gt 1)) { $bounds += "BLOCK|$eid|$rel|$res" }
312
+ $entry = $l; $rel = 0; $res = 0
313
+ $eid = if ($l -match 'ISS-[0-9]+') { $Matches[0] } else { '?' }
314
+ continue
315
+ }
316
+ if ($l -match '^## \[' -or $l -match '^-->') {
317
+ if ($null -ne $entry -and ($rel -ne 1 -or $res -gt 1)) { $bounds += "BLOCK|$eid|$rel|$res" }
318
+ $entry = $null
319
+ continue
320
+ }
321
+ if ($l -match '^ \*\*Related\*\*:') {
322
+ if ($null -eq $entry) { $bounds += "STRAY|$lineNo|Related" } else { $rel++ }
323
+ }
324
+ if ($l -match '^ \*\*Resolution\*\*') {
325
+ if ($null -eq $entry) { $bounds += "STRAY|$lineNo|Resolution" } else { $res++ }
326
+ }
327
+ }
328
+ if ($null -ne $entry -and ($rel -ne 1 -or $res -gt 1)) { $bounds += "BLOCK|$eid|$rel|$res" }
329
+
330
+ if ($bounds.Count -eq 0) {
331
+ Write-Output " ✓ every entry block is bounded and carries exactly one Related line"
332
+ } else {
333
+ foreach ($b in $bounds) {
334
+ $p = $b -split '\|'
335
+ if ($p[0] -eq 'BLOCK') {
336
+ Write-Output (" ✗ {0}: block carries Related={1} Resolution={2} (want 1 and <=1)" -f $p[1], $p[2], $p[3])
337
+ Write-Output " → the block does not end where it should: a tail line was split off,"
338
+ Write-Output " or a foreign line was absorbed."
339
+ } else {
340
+ Write-Output (" ✗ line {0} carries a {1} OUTSIDE any entry block" -f $p[1], $p[2])
341
+ Write-Output " → a field line was written past a boundary; it belongs to no entry,"
342
+ Write-Output " so no reader will ever attribute it correctly."
343
+ }
344
+ }
345
+ $script:FAIL = 1
346
+ }
347
+
348
+ # --- 7. entry IDs are globally unique ---------------------------------------------------
349
+ # Why this exists (T206 / ISS-166): every consumer ADDRESSES an entry by its ID — the
350
+ # documented marking form is a line-start match on `ISS-<N>`. Two entries sharing one make
351
+ # that address name NEITHER of them, and nothing else reports it: the statistics table
352
+ # still reconciles (bump the count and it agrees), the section checks still pass, the
353
+ # sentinel still holds. The collision is silent until someone reads the whole file — the
354
+ # failure TOOL-011 recorded for its own numbering, in a file since retired while this one
355
+ # inherited none of it.
356
+ #
357
+ # ⚠️ Numeric sort, never a plain lexicographic one: the IDs are variable width, so
358
+ # `ISS-100` sorts BEFORE `ISS-97` character-wise. Same reason "take the next number" must
359
+ # use a numeric sort — see the note in commands/specpro.implement.md.
360
+ Write-Output ''
361
+ $ids = [System.IO.File]::ReadAllLines((Resolve-Path -LiteralPath $LEDGER).Path) |
362
+ Where-Object { $_ -match '^- \[[x ]\] ISS-[0-9]+:' } |
363
+ ForEach-Object { [int]([regex]::Match($_, 'ISS-([0-9]+):').Groups[1].Value) }
364
+ $dups = $ids | Group-Object | Where-Object { $_.Count -gt 1 } | ForEach-Object { $_.Name }
365
+ if (-not $dups) {
366
+ Write-Output ' ✓ entry IDs are unique'
367
+ } else {
368
+ Write-Output (" ✗ these entry IDs are used more than once:" + (($dups | Sort-Object { [int]$_ }) -join ' ' | ForEach-Object { " $_" }))
369
+ Write-Output ' → an ID is how a --review-issues run ADDRESSES an entry; two entries'
370
+ Write-Output ' sharing one mean the address names neither of them, and the counts'
371
+ Write-Output ' still reconcile, so nothing else would have reported this.'
372
+ $script:FAIL = 1
373
+ }
374
+
375
+ # --- verdict ---------------------------------------------------------------------------
376
+ Write-Output ''
377
+ if ($script:FAIL -eq 0) {
378
+ Write-Output '✓ ledger integrity: all invariants hold'
379
+ exit 0
380
+ } else {
381
+ Write-Output '✗ ledger integrity: VIOLATIONS FOUND (see above)'
382
+ exit 1
383
+ }