@dzhechkov/harness-core 0.8.11 → 0.8.21

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 (205) hide show
  1. package/.dz-manifest.json +399 -139
  2. package/LICENSE +13 -0
  3. package/README.md +420 -6
  4. package/dist/agents-policy.d.ts +15 -1
  5. package/dist/agents-policy.d.ts.map +1 -1
  6. package/dist/agents-policy.js +27 -1
  7. package/dist/agents-policy.js.map +1 -1
  8. package/dist/amendment-trace.d.ts +72 -0
  9. package/dist/amendment-trace.d.ts.map +1 -1
  10. package/dist/amendment-trace.js +385 -17
  11. package/dist/amendment-trace.js.map +1 -1
  12. package/dist/backlog-public.d.ts +153 -0
  13. package/dist/backlog-public.d.ts.map +1 -0
  14. package/dist/backlog-public.js +415 -0
  15. package/dist/backlog-public.js.map +1 -0
  16. package/dist/backlog-transitions.d.ts +48 -0
  17. package/dist/backlog-transitions.d.ts.map +1 -0
  18. package/dist/backlog-transitions.js +64 -0
  19. package/dist/backlog-transitions.js.map +1 -0
  20. package/dist/backlog.d.ts.map +1 -1
  21. package/dist/backlog.js +13 -0
  22. package/dist/backlog.js.map +1 -1
  23. package/dist/claim-check.d.ts.map +1 -1
  24. package/dist/claim-check.js +24 -2
  25. package/dist/claim-check.js.map +1 -1
  26. package/dist/claude-hooks-assets.d.ts +93 -0
  27. package/dist/claude-hooks-assets.d.ts.map +1 -0
  28. package/dist/claude-hooks-assets.js +208 -0
  29. package/dist/claude-hooks-assets.js.map +1 -0
  30. package/dist/clean-room-smoke.d.ts +83 -0
  31. package/dist/clean-room-smoke.d.ts.map +1 -0
  32. package/dist/clean-room-smoke.js +138 -0
  33. package/dist/clean-room-smoke.js.map +1 -0
  34. package/dist/cmd-usage.d.ts.map +1 -1
  35. package/dist/cmd-usage.js +36 -6
  36. package/dist/cmd-usage.js.map +1 -1
  37. package/dist/codex-hooks-assets.d.ts +25 -7
  38. package/dist/codex-hooks-assets.d.ts.map +1 -1
  39. package/dist/codex-hooks-assets.js +138 -30
  40. package/dist/codex-hooks-assets.js.map +1 -1
  41. package/dist/codex-hooks.d.ts +21 -1
  42. package/dist/codex-hooks.d.ts.map +1 -1
  43. package/dist/codex-hooks.js +21 -1
  44. package/dist/codex-hooks.js.map +1 -1
  45. package/dist/course-staleness.d.ts +19 -0
  46. package/dist/course-staleness.d.ts.map +1 -0
  47. package/dist/course-staleness.js +95 -0
  48. package/dist/course-staleness.js.map +1 -0
  49. package/dist/destructive-guard-hook.d.ts +40 -0
  50. package/dist/destructive-guard-hook.d.ts.map +1 -0
  51. package/dist/destructive-guard-hook.js +109 -0
  52. package/dist/destructive-guard-hook.js.map +1 -0
  53. package/dist/destructive-guard.d.ts +27 -0
  54. package/dist/destructive-guard.d.ts.map +1 -0
  55. package/dist/destructive-guard.js +2808 -0
  56. package/dist/destructive-guard.js.map +1 -0
  57. package/dist/discrimination-gate.d.ts +28 -3
  58. package/dist/discrimination-gate.d.ts.map +1 -1
  59. package/dist/discrimination-gate.js +76 -16
  60. package/dist/discrimination-gate.js.map +1 -1
  61. package/dist/feature-adr-routing.d.ts +95 -1
  62. package/dist/feature-adr-routing.d.ts.map +1 -1
  63. package/dist/feature-adr-routing.js +193 -27
  64. package/dist/feature-adr-routing.js.map +1 -1
  65. package/dist/guard.d.ts +90 -0
  66. package/dist/guard.d.ts.map +1 -1
  67. package/dist/guard.js +271 -0
  68. package/dist/guard.js.map +1 -1
  69. package/dist/harness-core-location.d.ts +18 -0
  70. package/dist/harness-core-location.d.ts.map +1 -0
  71. package/dist/harness-core-location.js +42 -0
  72. package/dist/harness-core-location.js.map +1 -0
  73. package/dist/index.d.ts +24 -6
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +24 -3
  76. package/dist/index.js.map +1 -1
  77. package/dist/lead-shift-gate.d.ts +95 -0
  78. package/dist/lead-shift-gate.d.ts.map +1 -0
  79. package/dist/lead-shift-gate.js +100 -0
  80. package/dist/lead-shift-gate.js.map +1 -0
  81. package/dist/ledger-backfill.d.ts +11 -1
  82. package/dist/ledger-backfill.d.ts.map +1 -1
  83. package/dist/ledger-backfill.js +19 -0
  84. package/dist/ledger-backfill.js.map +1 -1
  85. package/dist/loop-blobs.generated.d.ts +1 -1
  86. package/dist/loop-blobs.generated.d.ts.map +1 -1
  87. package/dist/loop-blobs.generated.js +12 -3
  88. package/dist/loop-blobs.generated.js.map +1 -1
  89. package/dist/loop-lint.d.ts.map +1 -1
  90. package/dist/loop-lint.js +56 -7
  91. package/dist/loop-lint.js.map +1 -1
  92. package/dist/loop-plan-graph.d.ts +1 -3
  93. package/dist/loop-plan-graph.d.ts.map +1 -1
  94. package/dist/loop-plan-graph.js +70 -1
  95. package/dist/loop-plan-graph.js.map +1 -1
  96. package/dist/loop-trace.d.ts.map +1 -1
  97. package/dist/loop-trace.js +16 -2
  98. package/dist/loop-trace.js.map +1 -1
  99. package/dist/managed-hooks.d.ts +5 -6
  100. package/dist/managed-hooks.d.ts.map +1 -1
  101. package/dist/managed-hooks.js +2 -2
  102. package/dist/managed-hooks.js.map +1 -1
  103. package/dist/model-recommender.d.ts.map +1 -1
  104. package/dist/model-recommender.js +14 -3
  105. package/dist/model-recommender.js.map +1 -1
  106. package/dist/operations.d.ts.map +1 -1
  107. package/dist/operations.js +82 -0
  108. package/dist/operations.js.map +1 -1
  109. package/dist/patterns.d.ts +23 -0
  110. package/dist/patterns.d.ts.map +1 -1
  111. package/dist/patterns.js +10 -0
  112. package/dist/patterns.js.map +1 -1
  113. package/dist/publish.d.ts +11 -0
  114. package/dist/publish.d.ts.map +1 -1
  115. package/dist/publish.js +16 -2
  116. package/dist/publish.js.map +1 -1
  117. package/dist/registry.d.ts.map +1 -1
  118. package/dist/registry.js +3 -2
  119. package/dist/registry.js.map +1 -1
  120. package/dist/score.d.ts.map +1 -1
  121. package/dist/score.js +15 -4
  122. package/dist/score.js.map +1 -1
  123. package/dist/session-retro.d.ts +121 -2
  124. package/dist/session-retro.d.ts.map +1 -1
  125. package/dist/session-retro.js +454 -11
  126. package/dist/session-retro.js.map +1 -1
  127. package/dist/setup.d.ts +28 -0
  128. package/dist/setup.d.ts.map +1 -1
  129. package/dist/setup.js +223 -6
  130. package/dist/setup.js.map +1 -1
  131. package/dist/sign.d.ts.map +1 -1
  132. package/dist/sign.js +18 -1
  133. package/dist/sign.js.map +1 -1
  134. package/dist/skills-verify.d.ts +34 -1
  135. package/dist/skills-verify.d.ts.map +1 -1
  136. package/dist/skills-verify.js +82 -0
  137. package/dist/skills-verify.js.map +1 -1
  138. package/dist/stage-line.d.ts +68 -0
  139. package/dist/stage-line.d.ts.map +1 -0
  140. package/dist/stage-line.js +129 -0
  141. package/dist/stage-line.js.map +1 -0
  142. package/dist/statusline.d.ts +99 -0
  143. package/dist/statusline.d.ts.map +1 -1
  144. package/dist/statusline.js +310 -44
  145. package/dist/statusline.js.map +1 -1
  146. package/dist/store-counts.d.ts +26 -0
  147. package/dist/store-counts.d.ts.map +1 -0
  148. package/dist/store-counts.js +125 -0
  149. package/dist/store-counts.js.map +1 -0
  150. package/dist/store-guard.d.ts +106 -0
  151. package/dist/store-guard.d.ts.map +1 -0
  152. package/dist/store-guard.js +294 -0
  153. package/dist/store-guard.js.map +1 -0
  154. package/dist/swarm-brief.d.ts +95 -0
  155. package/dist/swarm-brief.d.ts.map +1 -0
  156. package/dist/swarm-brief.js +660 -0
  157. package/dist/swarm-brief.js.map +1 -0
  158. package/dist/trace-bundle.d.ts +8 -0
  159. package/dist/trace-bundle.d.ts.map +1 -1
  160. package/dist/trace-bundle.js +11 -0
  161. package/dist/trace-bundle.js.map +1 -1
  162. package/package.json +12 -11
  163. package/sbom.json +804 -154
  164. package/src/agents-policy.ts +46 -2
  165. package/src/amendment-trace.ts +441 -19
  166. package/src/backlog-public.ts +503 -0
  167. package/src/backlog-transitions.ts +77 -0
  168. package/src/backlog.ts +12 -0
  169. package/src/claim-check.ts +25 -2
  170. package/src/claude-hooks-assets.ts +227 -0
  171. package/src/clean-room-smoke.ts +195 -0
  172. package/src/cmd-usage.ts +29 -5
  173. package/src/codex-hooks-assets.ts +140 -30
  174. package/src/codex-hooks.ts +21 -1
  175. package/src/course-staleness.ts +125 -0
  176. package/src/destructive-guard-hook.ts +151 -0
  177. package/src/destructive-guard.ts +3027 -0
  178. package/src/discrimination-gate.ts +98 -19
  179. package/src/feature-adr-routing.ts +220 -22
  180. package/src/guard.ts +318 -0
  181. package/src/harness-core-location.ts +44 -0
  182. package/src/index.ts +111 -3
  183. package/src/lead-shift-gate.ts +145 -0
  184. package/src/ledger-backfill.ts +20 -1
  185. package/src/loop-blobs.generated.ts +12 -3
  186. package/src/loop-lint.ts +52 -7
  187. package/src/loop-plan-graph.ts +66 -1
  188. package/src/loop-trace.ts +13 -1
  189. package/src/managed-hooks.ts +5 -6
  190. package/src/model-recommender.ts +14 -3
  191. package/src/operations.ts +75 -0
  192. package/src/patterns.ts +33 -0
  193. package/src/publish.ts +27 -2
  194. package/src/registry.ts +3 -2
  195. package/src/score.ts +16 -4
  196. package/src/session-retro.ts +466 -11
  197. package/src/setup.ts +246 -9
  198. package/src/sign.ts +18 -1
  199. package/src/skills-verify.ts +99 -1
  200. package/src/stage-line.ts +151 -0
  201. package/src/statusline.ts +396 -47
  202. package/src/store-counts.ts +154 -0
  203. package/src/store-guard.ts +388 -0
  204. package/src/swarm-brief.ts +661 -0
  205. package/src/trace-bundle.ts +10 -0
@@ -40,7 +40,16 @@ export const POLICY_SOURCES: readonly PolicySource[] = [
40
40
  file: 'CLAUDE.md',
41
41
  heading: 'Test execution: never watch',
42
42
  why: 'A watch-mode test run hangs an unattended coding session.',
43
- operativeClause: 'npm test -- --run',
43
+ // Была `npm test -- --run` — эта команда в корне НЕ СУЩЕСТВУЕТ (Missing script: test), и
44
+ // 2026-09-03 канон исправлен. Несущей стала команда, которая есть.
45
+ operativeClause: 'npm run test:all',
46
+ },
47
+ {
48
+ id: 'backlog-coverage',
49
+ file: 'CLAUDE.md',
50
+ heading: 'Backlog coverage',
51
+ why: 'Работа, не закрытая в этом же ходу, иначе не оставляет следа нигде, кроме переписки, которая прокручивается.',
52
+ operativeClause: 'ALWAYS say out loud which records you filed',
44
53
  },
45
54
  {
46
55
  id: 'data-protection',
@@ -101,7 +110,26 @@ export interface ExtractPolicyBlocksResult {
101
110
  readonly missing: readonly string[];
102
111
  }
103
112
 
104
- export type PolicyDriftStatus = 'ok' | 'stale' | 'missing-stamp' | 'missing-anchor' | 'orphan-stamp';
113
+ export type PolicyDriftStatus =
114
+ | 'ok'
115
+ | 'stale'
116
+ | 'missing-stamp'
117
+ | 'missing-anchor'
118
+ | 'orphan-stamp'
119
+ /**
120
+ * Секция `<!-- dz:policy id=X -->` найдена в ФАЙЛЕ-ИСТОЧНИКЕ, но не объявлена в `POLICY_SOURCES`.
121
+ *
122
+ * ЗАЧЕМ ОТДЕЛЬНЫЙ СТАТУС. `orphan-stamp` смотрит в другую сторону — штамп в проекции без
123
+ * источника. Обратный случай не покрывался ничем, и это давало ЛОЖНОЕ ЗЕЛЁНОЕ: реестр
124
+ * `POLICY_SOURCES` ведётся руками, поэтому секция, дописанная в CLAUDE.md и не вписанная в него,
125
+ * молча не попадала в AGENTS.md, а `dz agents-sync --check` отвечал «in sync».
126
+ *
127
+ * ИЗМЕРЕНО 2026-09-03 на живом случае: добавил в канон секцию `backlog-coverage`, прогнал
128
+ * `agents-sync` — «in sync — 9 policy section(s), 9250 bytes», ровно те же число секций и байт,
129
+ * что до правки, ни на единицу не изменившиеся. Правило, ради которого всё делалось, до агентов
130
+ * не доехало, а прибор доложил успех.
131
+ */
132
+ | 'unregistered-section';
105
133
 
106
134
  export interface PolicyDriftFinding {
107
135
  readonly id: string;
@@ -320,6 +348,22 @@ export function detectPolicyDrift(
320
348
  findings.push({ id: stamp.id, file: stamp.file, status: 'orphan-stamp', expectedSha: null, actualSha: stamp.sha });
321
349
  }
322
350
 
351
+ // Секции канона, о которых реестр не знает. Ищем во ВСЕХ переданных файлах-источниках, а не
352
+ // только в тех, что уже объявлены: файл может целиком отсутствовать в реестре.
353
+ {
354
+ const declared = new Set(sources.map((source) => source.id));
355
+ const seen = new Set<string>();
356
+ for (const [file, text] of sourceFiles) {
357
+ if (typeof text !== 'string') continue;
358
+ for (const m of text.matchAll(/<!-- dz:policy id=([a-z0-9-]+) -->/g)) {
359
+ const id = m[1]!;
360
+ if (declared.has(id) || seen.has(id)) continue;
361
+ seen.add(id);
362
+ findings.push({ id, file, status: 'unregistered-section', expectedSha: null, actualSha: null });
363
+ }
364
+ }
365
+ }
366
+
323
367
  if (typeof agentsMdText === 'string') {
324
368
  const beginCount = markerCount(agentsMdText, POLICY_BLOCK_BEGIN);
325
369
  const endCount = markerCount(agentsMdText, POLICY_BLOCK_END);
@@ -20,7 +20,38 @@ export interface AmendmentRow {
20
20
  readonly id: string;
21
21
  readonly testIds: readonly string[];
22
22
  readonly file: string | null;
23
+ /** The row's literal text, fenced examples included — what the author wrote. */
23
24
  readonly raw: string;
25
+ /**
26
+ * The same slice with non-rendered regions (fenced blocks, HTML comments) blanked out, same
27
+ * length, so an index means the same byte in both. EVERY semantic read goes through this one —
28
+ * pointers, retraction, subject — because an EXAMPLE inside a row's block is not that row's
29
+ * answer.
30
+ *
31
+ * OPTIONAL, and that is a compatibility promise rather than sloppiness: round 4 made it REQUIRED
32
+ * and thereby broke every downstream constructor of a row the previous release accepted — a
33
+ * TypeScript consumer stopped compiling, and a legacy or deserialised row reaching
34
+ * `amendmentsMissingFromPlan` threw `TypeError: Cannot read properties of undefined (reading
35
+ * 'split')` (MEASURED 2026-09-06, Codex cross-family review round 5, P1). Every read goes through
36
+ * `rowScan()`, which falls back to `raw` — the honest degradation for a row that was built
37
+ * without a mask and therefore has no masked text to offer.
38
+ *
39
+ * MEASURED 2026-09-06 (Codex cross-family review round 4, P2): row starts were found in the mask
40
+ * while the text was sliced from the original, so a testless `AM-1` followed by a fenced
41
+ * `AM-2 … → test \`x\` in \`x.ts\`` example borrowed the example's pointer, resolved, and the
42
+ * gate answered `pass` with exit 0. Finding in the mask and reading past it is not masking.
43
+ */
44
+ readonly scan?: string;
45
+ }
46
+
47
+ /**
48
+ * The text a row's SEMANTICS are read from: its masked slice when it has one, its raw text when it
49
+ * does not. A row built by hand or revived from JSON has no mask to speak of, so `raw` is the only
50
+ * honest answer for it — and a fallback that returns text is the difference between a legacy row
51
+ * being read slightly too generously and the whole call throwing.
52
+ */
53
+ function rowScan(row: AmendmentRow): string {
54
+ return row.scan ?? row.raw;
24
55
  }
25
56
 
26
57
  export type AmendmentVerdict =
@@ -109,39 +140,364 @@ export function normalizeTestId(s: string): string {
109
140
  // bullet drops 104 of the corpus's 347 real amendment rows out of the check entirely, and a
110
141
  // bullet-less row made this tool return `not-established` — which the pipeline's own gate text calls
111
142
  // "NEVER a pass". C6 has always treated the bullet as optional; this is that half of the contract.
112
- const ROW_START = /^(?:[-*|]\s*)?\*{0,2}AM-(?:CP-)?(\d+)/gm;
143
+ //
144
+ // The `CP-` prefix is CAPTURED, because it is IDENTITY: `AM-CP-N` is a row like any other AND a
145
+ // DIFFERENT id from `AM-N`. It was a non-capturing group until 2026-09-05 and the id was rebuilt
146
+ // from the number alone, so a challenge-panel row collided with the ideation's `AM-1` and the
147
+ // subject guard failed an honest plan as `subject-changed`. MEASURED TWICE that day (backlog
148
+ // a7d0aece023774a0): `run-registry-liveness` re-numbered 7 panel rows to AM-18…AM-31 to escape it,
149
+ // `core-boundary-guard` refused the `AM-CP-N` form outright. K2 (check-plan-completeness.mjs:361,
150
+ // `(AM-(?:CP-)?\d+)`) has always kept the whole token — this ends that disagreement, and the
151
+ // agreement is pinned by test/amendment-grammar-agreement.test.ts, not by this comment.
152
+ const ROW_START = /^(?:[-*|]\s*)?\*{0,2}AM-(CP-)?(\d+)/gm;
153
+
154
+ /**
155
+ * The same token, read out of ORDINARY PROSE rather than at the start of a row — `dz score` scans a
156
+ * whole plan and a whole QE report for the amendments it must account for, and a scan is not a row
157
+ * grammar (a row is anchored to the line start; a mention is not).
158
+ *
159
+ * It lives HERE, next to `ROW_START`, because the defect it closes is the same class the row
160
+ * grammar just closed (doc-26: two tools, one text, two contracts) — measured one reader later.
161
+ * MEASURED 2026-09-06: `score.ts` scanned with its own `/AM-\d+/g`, which does not match `AM-CP-1`
162
+ * at all (`node -e "console.log('AM-CP-1'.match(/AM-\d+/g))"` → `null`), so a plan whose amendments
163
+ * were all challenge-panel rows produced an EMPTY planned set and the amendment-confirmation
164
+ * discipline was skipped with no trace — a check that silently checked nothing, the very class this
165
+ * module exists to remove. There were never two readers of `AM-*`; there were three.
166
+ *
167
+ * The trailing `\b` is identity too: without it a planned `AM-1` is "covered" by a report that only
168
+ * ever mentions `AM-10` (MEASURED — `'AM-10'.includes('AM-1')` → `true`). A substring is not an id.
169
+ */
170
+ const AMENDMENT_ID_TOKEN = /\bAM-(?:CP-)?\d+\b/g;
171
+
172
+ /** Every distinct amendment id mentioned in `text`, in first-seen order. */
173
+ export function amendmentIdsIn(text: string): string[] {
174
+ return [...new Set(text.match(AMENDMENT_ID_TOKEN) ?? [])];
175
+ }
176
+
177
+ /**
178
+ * Does `text` mention `id` AS AN ID? `AM-10` is not a mention of `AM-1`, and `AM-CP-1` is not a
179
+ * mention of `AM-1` — both are what a `String.includes` check answers wrongly.
180
+ */
181
+ export function mentionsAmendmentId(text: string, id: string): boolean {
182
+ return amendmentIdsIn(text).includes(id);
183
+ }
184
+
185
+ /**
186
+ * The `## Amendments` heading line — ONE definition, because two readers of the same line is the
187
+ * defect class this module keeps closing. Group 1 is the heading SUFFIX: everything after the word
188
+ * `Amendments` on that line, which is where the inline declaration `## Amendments: None` lives.
189
+ * The final newline is optional so a document that ENDS on its heading is still a section that
190
+ * exists (an empty one) rather than no section at all.
191
+ */
192
+ const AMENDMENT_HEADING = /^ {0,3}(#{2,4})\s+Amendments\b([^\n]*)(?:\n|$)/m;
193
+
194
+ /**
195
+ * The CLOSED set of explicit-none forms, matched against a WHOLE trimmed heading suffix or a WHOLE
196
+ * trimmed first paragraph. The word alone, optionally with a full stop — nothing else.
197
+ *
198
+ * The previous trailing-guard form only checked the character AFTER the word, so ordinary prose such
199
+ * as `None of the required rows has been written yet.` passed as a DECLARATION because the next
200
+ * character was a space, and with zero parsed rows the gate answered skip/exit 0 (MEASURED
201
+ * 2026-09-06, Codex cross-family review round 7, P1). A declaration is a whole utterance, not a
202
+ * prefix: "none" is an answer, "none of X yet" is a description of work outstanding.
203
+ */
204
+ const NONE_DECLARATION_EXACT = /^(?:none|n\/a|нет)\.?$/iu;
205
+
206
+ /** Anything that LOOKS like amendment content in rendered text: a row id, a table cell, a child section. */
207
+ const AMENDMENT_LIKE = /\bAM-(?:CP-)?\d+\b|^\s*\|\s*\*{0,2}AM-|^ {0,3}#{3,6}\s+Amendments\b/;
208
+
209
+ /** A separator that turns a heading suffix into a DECLARATION rather than a qualifier. */
210
+ const HEADING_DECLARATION_SEPARATOR = /^\s*[:\u2014\u2013-]\s*/;
211
+
212
+ /**
213
+ * The document with every FENCED block blanked out — same length, same line count, so every index
214
+ * computed on the mask addresses the identical byte of the original.
215
+ *
216
+ * A plan is allowed to SHOW the form it may write, and this module must not read the example as the
217
+ * document's own answer. MEASURED 2026-09-06 (Codex round 3, P2): a fenced `## Amendments: None`
218
+ * placed above the real section won the raw heading search, the fence's own body parsed as zero
219
+ * rows, and the explicit-none branch exited 0 WITHOUT EVER LOOKING at the real section, which
220
+ * carried an unresolvable `AM-1`. That is the new false pass NFR-3 forbids. K2 has refused fenced
221
+ * examples from the start (`check-plan-completeness.mjs`, its fence-blindness fix); this is that
222
+ * half of the contract, arriving here.
223
+ *
224
+ * An UNCLOSED fence is reverted rather than trusted — masking may only lose EXAMPLES, never hide a
225
+ * real section, because a hidden section is a skip and a skip is exit 0. The safe direction is
226
+ * chosen explicitly and pinned by a test.
227
+ */
228
+ function blankLine(line: string): string {
229
+ return ' '.repeat(line.length);
230
+ }
231
+
232
+ /**
233
+ * The document with every NON-RENDERED block blanked out — same length, same line count, so every
234
+ * index computed on the mask addresses the identical byte of the original.
235
+ *
236
+ * ONE BLOCK SCAN, not a stack of regexes. Five rounds of cross-family review found the same class
237
+ * five times — a fenced heading, a fenced row, a fenced pointer, a fence closed by a shorter run,
238
+ * an HTML-commented template — and round 6 found the sixth: `<!--` and `-->` written inside INLINE
239
+ * CODE SPANS in ordinary prose, which a `<!--[\s\S]*?-->` mask happily read as a comment spanning
240
+ * the real section (MEASURED 2026-09-06: `section: NULL`, `skip`, exit 0, the whole section
241
+ * swallowed). Each mask was born to close the previous one's hole, which is the signature of the
242
+ * wrong level: markdown decides what a document CONTAINS at BLOCK level, so that is where this
243
+ * reads. An inline construct cannot open a block, so the code-span case is closed BY CONSTRUCTION
244
+ * rather than by a longer pattern.
245
+ *
246
+ * WHAT THIS IMPLEMENTS, named so the gap is auditable:
247
+ * • CommonMark §4.5 fenced code blocks — opener ``` or ~~~ (three or more, indented ≤3), closed
248
+ * only by a run of the SAME character at least as long, with nothing but whitespace after it.
249
+ * • CommonMark §4.6 HTML blocks, TYPE 2 ONLY (`<!-- … -->`) — start condition `<!--` at the start
250
+ * of a line after at most three spaces; end condition `-->` anywhere on a line, the start line
251
+ * included. This is the rule that makes a code-spanned delimiter a non-event.
252
+ * • CommonMark §4.2 ATX headings are then matched only in what survives (the heading pattern
253
+ * itself is unchanged and allows at most three spaces of indent).
254
+ * • CommonMark §6.1 code spans are deliberately NOT parsed: no inline construct can open a block,
255
+ * so there is nothing left for them to forge.
256
+ *
257
+ * WHAT IT DOES NOT IMPLEMENT (honest, and the reason a fixture outside this list is not covered):
258
+ * indented code blocks (§4.4) — a four-space-indented row still parses as a row, though a
259
+ * four-space-indented heading is not a heading; HTML block types 1 and 3–7 (`<script>`, `<?`,
260
+ * `<!DOCTYPE`, `<![CDATA[`, block tags, generic tags); block quotes and list containers, so a fence
261
+ * nested inside them is matched by its own indentation rather than relative to its container; tabs
262
+ * as indentation; link reference definitions.
263
+ *
264
+ * DELIBERATE DEVIATION: CommonMark runs an unclosed fence (or HTML block) to the end of the
265
+ * document. Here an unclosed block is REVERTED — its lines come back. The asymmetry is the point:
266
+ * masking may lose an EXAMPLE, but hiding a real section turns the gate into `skip`, and a skip is
267
+ * exit 0. Pinned by its own tests, for both fences and comments.
268
+ */
269
+ function maskNonRendered(md: string): string {
270
+ const lines = md.split('\n');
271
+ const out = lines.slice();
272
+ let state: 'text' | 'fence' | 'html' = 'text';
273
+ let marker = '';
274
+ let markerLength = 0;
275
+ let openedAt = -1;
276
+ for (let i = 0; i < lines.length; i++) {
277
+ const line = lines[i] as string;
278
+ if (state === 'text') {
279
+ const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)$/.exec(line);
280
+ // CommonMark §4.5: the info string of a BACKTICK fence may not contain a backtick (a tilde
281
+ // fence may). An invalid opener is not an opener — accepting one let a later run close a
282
+ // fence that markdown never opened, exposing a `## Amendments: None` that is really inside an
283
+ // unclosed fence and hiding a real section above it (Codex round 7, P2).
284
+ if (fence && (fence[1] as string)[0] === '`' && (fence[2] as string).includes('`')) {
285
+ continue;
286
+ }
287
+ if (fence) {
288
+ const run = fence[1] as string;
289
+ state = 'fence';
290
+ marker = run[0] as string;
291
+ markerLength = run.length;
292
+ openedAt = i;
293
+ out[i] = blankLine(line);
294
+ continue;
295
+ }
296
+ // §4.6 type 2 START CONDITION: line start, at most three spaces. NOT anywhere in the line —
297
+ // that spelling is what let a code-spanned `<!--` in prose open a comment (round 6, P1).
298
+ const htmlStart = /^ {0,3}<!--/.exec(line);
299
+ if (htmlStart) {
300
+ state = 'html';
301
+ openedAt = i;
302
+ out[i] = blankLine(line);
303
+ // The end condition may be satisfied on the start line itself.
304
+ if (/-->/.test(line)) {
305
+ state = 'text';
306
+ openedAt = -1;
307
+ }
308
+ }
309
+ continue;
310
+ }
311
+ out[i] = blankLine(line);
312
+ if (state === 'fence') {
313
+ const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line);
314
+ if (close) {
315
+ const run = close[1] as string;
316
+ if ((run[0] as string) === marker && run.length >= markerLength) {
317
+ state = 'text';
318
+ openedAt = -1;
319
+ }
320
+ }
321
+ } else if (/-->/.test(line)) {
322
+ state = 'text';
323
+ openedAt = -1;
324
+ }
325
+ }
326
+ // An unclosed block is not trusted: put its lines back (see DELIBERATE DEVIATION above).
327
+ if (openedAt >= 0) for (let i = openedAt; i < lines.length; i++) out[i] = lines[i] as string;
328
+ return out.join('\n');
329
+ }
330
+
331
+ /**
332
+ * How many RENDERED `## Amendments` sections the document opens. More than one is a document that
333
+ * contradicts itself, and until 2026-09-06 the FIRST one answered for all of them: a stale
334
+ * `## Amendments: None` left above the real section made the explicit-none branch exit 0 while the
335
+ * real section's unresolved rows were never parsed (Codex round 6, P2 — MEASURED `skip`, exit 0).
336
+ */
337
+ /** Where the document contradicts its own "no amendments" declaration. */
338
+ export interface AmendmentAmbiguity {
339
+ /** 1-based line in the ORIGINAL document. */
340
+ readonly line: number;
341
+ /** the offending line, trimmed and capped — evidence, not a hint. */
342
+ readonly text: string;
343
+ }
344
+
345
+ /**
346
+ * The FAIL-CLOSED half of the explicit-none contract (Codex cross-family review round 7).
347
+ *
348
+ * Seven rounds produced one outcome again and again: an ambiguous document answering `skip`/exit 0.
349
+ * Each round closed the parser hole that round's fixture used — and the next fixture used the next
350
+ * hole. This function stops paying that toll by inverting the burden: a declaration of "no
351
+ * amendments" is honoured ONLY IF the rendered document carries no amendment-shaped content below
352
+ * it. Anything AM-like with zero parsed rows means the PARSER and the DOCUMENT disagree, and the
353
+ * honest verdict for a disagreement is NOT-ESTABLISHED, never a pass and never a skip.
354
+ *
355
+ * Scanned in the block-scanned text, so fenced examples and HTML comments are already out; scanned
356
+ * from the section heading DOWN, which is deliberately more than "below the declaration" — the
357
+ * conservative direction here is to see more, because every miss is an exit 0.
358
+ */
359
+ export function amendmentDeclarationAmbiguity(md: string): AmendmentAmbiguity | null {
360
+ const mask = maskNonRendered(md);
361
+ const m = AMENDMENT_HEADING.exec(mask);
362
+ if (m === null) return null;
363
+ const maskLines = mask.split('\n');
364
+ const rawLines = md.split('\n');
365
+ const headingIndex = mask.slice(0, m.index).split('\n').length - 1;
366
+ for (let i = headingIndex + 1; i < maskLines.length; i++) {
367
+ const line = maskLines[i] as string;
368
+ if (AMENDMENT_LIKE.test(line)) {
369
+ return { line: i + 1, text: (rawLines[i] ?? '').trim().slice(0, 160) };
370
+ }
371
+ }
372
+ return null;
373
+ }
374
+
375
+ export function amendmentSectionCount(md: string): number {
376
+ const mask = maskNonRendered(md);
377
+ // DEPTH MATTERS, and the corpus is why (MEASURED 2026-09-06 on the 363-feature census while this
378
+ // refusal was being added): `features/p16-non-js-portability/06_implementation_plan.md` carries
379
+ // `## Amendments` at line 780 and `### Amendments folded in from the challenge panel …` at 845.
380
+ // The second is a SUBSECTION of the first, not a rival for it, and counting it turned a feature
381
+ // whose rows are genuinely broken from `fail` into `not-established` — a gate that answers "I
382
+ // cannot tell" where it used to name six bad rows is a REGRESSION dressed as caution. So a
383
+ // duplicate is a later heading at the SAME depth or shallower; anything deeper is a child.
384
+ const re = /^ {0,3}(#{2,4})\s+Amendments\b/gm;
385
+ let first: number | null = null;
386
+ let count = 0;
387
+ for (let m = re.exec(mask); m !== null; m = re.exec(mask)) {
388
+ const depth = (m[1] as string).length;
389
+ if (first === null) {
390
+ first = depth;
391
+ count = 1;
392
+ continue;
393
+ }
394
+ if (depth <= first) count++;
395
+ }
396
+ return count;
397
+ }
398
+
399
+ /**
400
+ * Does the heading itself declare "none"? `## Amendments: None` is the form the pipeline's own
401
+ * Step-8 module documents (`.claude/skills/feature-adr/modules/08-qe.md:166`), and until 2026-09-06
402
+ * `amendmentSection` ate `: None` as part of the heading and returned an EMPTY body, so the
403
+ * explicit-none branch never ran and the gate answered NOT-ESTABLISHED (exit 3) on a plan that had
404
+ * said its piece (Codex cross-family review round 2, P2).
405
+ *
406
+ * A DECLARATION, not a qualifier: a separator (`:` or a dash) must precede the word, and the word
407
+ * must end there. That rule was chosen from the corpus rather than invented — the 363 features
408
+ * carry `## Amendments (carried verbatim into the plan)` (8×), `## Amendments — conditions before
409
+ * plan/code`, `## Amendments applied before Step 6` and `## Amendments and confirmation
410
+ * obligations`, and none of them may read as "none". Whole-utterance matching refuses
411
+ * `none-blocking`; an optional whitespace-separated closing `#` sequence is heading furniture.
412
+ */
413
+ function headingSaysNone(md: string): boolean {
414
+ const m = AMENDMENT_HEADING.exec(maskNonRendered(md));
415
+ if (!m) return false;
416
+ const suffix = m[1 + 1] ?? '';
417
+ const sep = HEADING_DECLARATION_SEPARATOR.exec(suffix);
418
+ if (!sep) return false;
419
+ // CommonMark §4.2 permits an optional closing `#` sequence when whitespace separates it from
420
+ // the heading text. `None##` deliberately stays text because it has no separating whitespace.
421
+ const declaration = suffix.slice((sep[0] as string).length).replace(/[ \t]+#+[ \t]*$/, '').trim();
422
+ // The WHOLE remainder must be the declaration (round 7, P1): `## Amendments: None` is an answer,
423
+ // `## Amendments: None of the panel rows landed` is a sentence about outstanding work.
424
+ return NONE_DECLARATION_EXACT.test(declaration);
425
+ }
426
+
427
+ /**
428
+ * The `## Amendments` section body AND the same body with fenced examples blanked out. Both are
429
+ * returned because they answer different questions: the RAW body is what a row's text is, and the
430
+ * MASKED body is what counts as a row or as a heading at all. Two callers, one traversal, no way
431
+ * for them to disagree about where the section is.
432
+ */
433
+ function amendmentSectionPair(md: string): { raw: string; masked: string } | null {
434
+ const mask = maskNonRendered(md);
435
+ const m = AMENDMENT_HEADING.exec(mask);
436
+ if (!m) return null;
437
+ const start = m.index + (m[0] as string).length;
438
+ const depth = (m[1] as string).length;
439
+ // The boundary is searched in the MASK for the same reason the heading is: a `## Something`
440
+ // inside a fenced example does not end the section, it illustrates one.
441
+ //
442
+ // And it ends only at a heading of the SAME depth or SHALLOWER. A `### Amendments folded in from
443
+ // the challenge panel` under a `## Amendments` is a CHILD, and round 6 made it a non-duplicate
444
+ // for the ambiguity count while parsing still stopped dead at it — so `## Amendments: None` plus
445
+ // a deeper subsection full of rows read as zero rows and answered skip/exit 0 (MEASURED
446
+ // 2026-09-06, Codex round 7, P1). One rule for both questions: deeper is inside.
447
+ const rest = mask.slice(start);
448
+ const boundary = /^ {0,3}(#{1,6})\s+\S/gm;
449
+ let end = md.length;
450
+ for (let b = boundary.exec(rest); b !== null; b = boundary.exec(rest)) {
451
+ if ((b[1] as string).length <= depth) {
452
+ end = start + b.index;
453
+ break;
454
+ }
455
+ }
456
+ return { raw: md.slice(start, end), masked: mask.slice(start, end) };
457
+ }
113
458
 
114
459
  /** The `## Amendments` section body, or null when the document has none (acid case A5). */
115
460
  export function amendmentSection(md: string): string | null {
116
- const m = /^ {0,3}#{2,4}\s+Amendments\b[^\n]*\n/m.exec(md);
117
- if (!m) return null;
118
- const start = m.index + m[0].length;
119
- const rest = md.slice(start);
120
- const next = /^ {0,3}#{2,4}\s+\S/m.exec(rest);
121
- return next ? rest.slice(0, next.index) : rest;
461
+ return amendmentSectionPair(md)?.raw ?? null;
122
462
  }
123
463
 
124
464
  /** `## Amendments` present but recording nothing to check — distinct from the section being absent. */
125
465
  export function planSaysNoAmendments(planMd: string): boolean {
126
466
  const sec = amendmentSection(planMd);
127
467
  if (sec === null) return false;
128
- return /^\s*(none|n\/a|нет)\b/i.test(sec.trim());
468
+ // BOTH homes of the same declaration: the section body, and the heading suffix. A reader that
469
+ // knows only one of them tells a plan that answered honestly that its grammar matched nothing.
470
+ // The body form is the FIRST PARAGRAPH, whole: `None.` is a declaration, `None of the rows has
471
+ // been written yet.` is prose about outstanding work and used to pass as one (round 7, P1).
472
+ const firstParagraph = (sec.trim().split(/\n\s*\n/)[0] ?? '').trim();
473
+ return headingSaysNone(planMd) || NONE_DECLARATION_EXACT.test(firstParagraph);
129
474
  }
130
475
 
131
476
  export function parseAmendments(md: string): AmendmentRow[] {
132
- const section = amendmentSection(md);
133
- if (section === null) return [];
134
- const starts: { index: number; num: string }[] = [];
477
+ const pair = amendmentSectionPair(md);
478
+ if (pair === null) return [];
479
+ const section = pair.raw;
480
+ // Rows are FOUND in the masked body (a fenced `- AM-9 …` is an example of the form, not an
481
+ // amendment) and SLICED from the raw one, which is the text the row actually carries. The mask is
482
+ // length-preserving, so an index means the same byte in both.
483
+ const scan = pair.masked;
484
+ const starts: { index: number; prefix: string; num: string }[] = [];
135
485
  ROW_START.lastIndex = 0;
136
- for (let m = ROW_START.exec(section); m !== null; m = ROW_START.exec(section)) {
137
- starts.push({ index: m.index, num: (m[1] ?? m[2]) as string });
486
+ for (let m = ROW_START.exec(scan); m !== null; m = ROW_START.exec(scan)) {
487
+ // group 1 is the optional `CP-` prefix, group 2 the number. The prefix is kept because it is
488
+ // part of the id (backlog a7d0aece) — dropping it merges two provenances into one identity.
489
+ starts.push({ index: m.index, prefix: m[1] ?? '', num: m[2] as string });
138
490
  }
139
491
  const rows: AmendmentRow[] = [];
140
492
  for (let i = 0; i < starts.length; i++) {
141
- const s = starts[i] as { index: number; num: string };
493
+ const s = starts[i] as { index: number; prefix: string; num: string };
142
494
  const end = i + 1 < starts.length ? (starts[i + 1] as { index: number }).index : section.length;
143
495
  const raw = section.slice(s.index, end);
144
- rows.push({ id: `AM-${s.num}`, testIds: extractTestIds(raw), file: extractFile(raw), raw });
496
+ // Pointers are read from the MASKED slice: a fenced example inside this row's block illustrates
497
+ // the form, it does not answer for the row (Codex round 4, P2 — it used to, and that resolved a
498
+ // testless amendment).
499
+ const rowScan = scan.slice(s.index, end);
500
+ rows.push({ id: `AM-${s.prefix}${s.num}`, testIds: extractTestIds(rowScan), file: extractFile(rowScan), raw, scan: rowScan });
145
501
  }
146
502
  return rows;
147
503
  }
@@ -180,7 +536,9 @@ export function resolveAmendments(
180
536
  for (const row of rows) {
181
537
  // A retraction is checked BEFORE the missing-pointer branch: a row that says it was superseded
182
538
  // is not a row that forgot its test.
183
- if (SUPERSEDED.test(row.raw)) {
539
+ // The masked slice, for the same reason the pointer is: a retraction shown INSIDE a fenced
540
+ // example is an illustration of the form, not this row's retraction.
541
+ if (SUPERSEDED.test(rowScan(row))) {
184
542
  out.push({ id: row.id, testId: null, file: row.file, verdict: 'superseded', detail: 'the plan retracted this amendment and named its successor' });
185
543
  continue;
186
544
  }
@@ -260,6 +618,18 @@ export function decideAmendmentOutcome(input: {
260
618
  readError?: string | null;
261
619
  /** Ideation amendments the plan fails to carry — absent, or reworded under the same id. */
262
620
  missingFromPlan?: readonly { id: string; kind: 'dropped' | 'subject-changed' }[];
621
+ /**
622
+ * How many RENDERED `## Amendments` sections the inputs open (`amendmentSectionCount`). Optional
623
+ * for backward compatibility — absent means "not measured", never "exactly one".
624
+ */
625
+ sectionCount?: number;
626
+ /**
627
+ * Amendment-shaped content found below the section heading in RENDERED text
628
+ * (`amendmentDeclarationAmbiguity`). Optional for backward compatibility — absent means "not
629
+ * measured". Present WITH zero parsed rows is the fail-closed trigger: parser and document
630
+ * disagree, so the run is inconclusive rather than skipped.
631
+ */
632
+ ambiguity?: { readonly line: number; readonly text: string } | null;
263
633
  }): AmendmentDecision {
264
634
  const counts: Record<AmendmentVerdict, number> = { ...ZERO_COUNTS };
265
635
  for (const r of input.resolutions) counts[r.verdict]++;
@@ -278,6 +648,55 @@ export function decideAmendmentOutcome(input: {
278
648
  counts,
279
649
  };
280
650
  }
651
+ // A document that opens the section TWICE contradicts itself, and the first heading used to answer
652
+ // for both — a stale `## Amendments: None` above the real section bought a skip while the real
653
+ // section's rows were never parsed (Codex round 6, P2; MEASURED skip/exit 0). Refused BEFORE the
654
+ // explicit-none branch, and refused as INCONCLUSIVE rather than as a failure: which section is
655
+ // authoritative is not something this checker can decide, and guessing would be the same class of
656
+ // lie in a new place.
657
+ if ((input.sectionCount ?? 0) > 1) {
658
+ return {
659
+ outcome: 'not-established',
660
+ exit: 3,
661
+ reasons: [
662
+ `the document opens more than one rendered \`## Amendments\` section (${input.sectionCount}) — which one is authoritative is not decidable here, and the first one must never answer for the rest`,
663
+ ],
664
+ counts,
665
+ };
666
+ }
667
+ // An explicit "None" is an ANSWER; zero rows from a section that says nothing is a BLIND SPOT.
668
+ // Until 2026-09-06 the blind-spot branch below ran first, so a plan that honestly declared it had
669
+ // no amendments got NOT-ESTABLISHED (exit 3) — an inconclusive verdict on a document that was
670
+ // complete. MEASURED on this feature's own plan (backlog ce2da797e17a7a7f):
671
+ // `dz amendment-check --feature-dir features/amendment-trace-cp-prefix --json` → exit 3 with
672
+ // "the grammar matched nothing". The two facts are opposites and must not share a verdict.
673
+ // GUARDED: only when nothing is OWED. A gap carried in from the ideation side is owed, so an
674
+ // explicit "None" may never be the reason a dropped amendment exits 0 (that case falls through
675
+ // to the blind-spot branch, which is inconclusive — never a pass).
676
+ if (input.rows.length === 0 && input.planSaysNone && (input.missingFromPlan ?? []).length === 0) {
677
+ // FAIL-CLOSED (round 7): a declaration only answers for a document that carries nothing
678
+ // amendment-shaped below it. If it does and NOTHING parsed, the parser and the document
679
+ // disagree — which is exactly the shape every one of the seven review rounds arrived in — and a
680
+ // disagreement is NOT-ESTABLISHED. Absence of a receipt is not success.
681
+ if (input.ambiguity) {
682
+ return {
683
+ outcome: 'not-established',
684
+ exit: 3,
685
+ reasons: [
686
+ `the \`## Amendments\` section declares no amendments but AM-like content exists at line ${input.ambiguity.line} ("${input.ambiguity.text}") while ZERO rows parsed — the declaration and the document disagree, and a disagreement is never a pass`,
687
+ ],
688
+ counts,
689
+ };
690
+ }
691
+ return {
692
+ outcome: 'skip',
693
+ exit: 0,
694
+ reasons: [
695
+ 'the `## Amendments` section explicitly declares no amendments ("None"/"нет"/"n/a") and zero rows parsed — an explicit absence, which is an answer and not a grammar failure (an absence is a skip, never a pass)',
696
+ ],
697
+ counts,
698
+ };
699
+ }
281
700
  // The whole class this feature removes: a check that silently checked nothing (AM-1, acid case A7).
282
701
  if (input.rows.length === 0) {
283
702
  return {
@@ -355,7 +774,10 @@ export function amendmentSubject(raw: string): string {
355
774
  // a colon. An earlier version consumed up to 80 characters after the id, which ate the SUBJECT
356
775
  // itself whenever a row carried no `(source):` tag — the checker then compared two truncations
357
776
  // and called honest rows a mismatch.
358
- const stripped = withoutPointer.replace(/^[\s|*\-]*\**AM-\d+\**\s*(?:\([^)]{0,80}\))?\s*:?\s*/, '');
777
+ // `AM-CP-N` is furniture too. Until 2026-09-05 this pattern demanded a digit straight after
778
+ // `AM-`, so a panel row's own id stayed inside its "subject" and every CP row read as a different
779
+ // change from every other one (backlog a7d0aece).
780
+ const stripped = withoutPointer.replace(/^[\s|*\-]*\**AM-(?:CP-)?\d+\**\s*(?:\([^)]{0,80}\))?\s*:?\s*/, '');
359
781
  return normalizeTestId(stripped);
360
782
  }
361
783
 
@@ -382,8 +804,8 @@ export function amendmentsMissingFromPlan(
382
804
  gaps.push({ id: row.id, kind: 'dropped' });
383
805
  continue;
384
806
  }
385
- const want = amendmentSubject(row.raw);
386
- const got = amendmentSubject(planRow.raw);
807
+ const want = amendmentSubject(rowScan(row));
808
+ const got = amendmentSubject(rowScan(planRow));
387
809
  // Containment either way: a plan may append a note ("closes HIGH-2"), and ideation may be the
388
810
  // longer prose. What it may not do is describe a different change.
389
811
  if (want.length >= MIN_MATCHABLE_ID_LENGTH && !got.includes(want) && !want.includes(got)) {