session-orchestrator 5.2.0 → 5.3.0

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 (295) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -1
  2. package/.agents/skills/autopilot/SKILL.md +5 -1
  3. package/.agents/skills/autopilot/agents/openai.yaml +5 -0
  4. package/.agents/skills/bootstrap/SKILL.md +5 -1
  5. package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
  6. package/.agents/skills/brainstorm/SKILL.md +5 -1
  7. package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
  8. package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
  9. package/.agents/skills/close/SKILL.md +5 -1
  10. package/.agents/skills/close/agents/openai.yaml +5 -0
  11. package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
  12. package/.agents/skills/debug/SKILL.md +5 -1
  13. package/.agents/skills/debug/agents/openai.yaml +5 -0
  14. package/.agents/skills/discovery/SKILL.md +5 -1
  15. package/.agents/skills/discovery/agents/openai.yaml +5 -0
  16. package/.agents/skills/dispatcher/SKILL.md +5 -1
  17. package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
  18. package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
  19. package/.agents/skills/ecosystem-health/SKILL.md +3 -1
  20. package/.agents/skills/eli5/SKILL.md +5 -1
  21. package/.agents/skills/eli5/agents/openai.yaml +5 -0
  22. package/.agents/skills/eval/SKILL.md +6 -2
  23. package/.agents/skills/eval/agents/openai.yaml +5 -0
  24. package/.agents/skills/evolve/SKILL.md +6 -2
  25. package/.agents/skills/evolve/agents/openai.yaml +5 -0
  26. package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
  27. package/.agents/skills/gitlab-ops/SKILL.md +3 -1
  28. package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
  29. package/.agents/skills/go/SKILL.md +5 -1
  30. package/.agents/skills/go/agents/openai.yaml +5 -0
  31. package/.agents/skills/grill/SKILL.md +5 -1
  32. package/.agents/skills/grill/agents/openai.yaml +5 -0
  33. package/.agents/skills/harness-audit/SKILL.md +5 -1
  34. package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
  35. package/.agents/skills/hook-development/SKILL.md +3 -1
  36. package/.agents/skills/mcp-builder/SKILL.md +3 -1
  37. package/.agents/skills/memory-cleanup/SKILL.md +5 -1
  38. package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
  39. package/.agents/skills/mode-selector/SKILL.md +3 -1
  40. package/.agents/skills/npm-publish/SKILL.md +4 -2
  41. package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
  42. package/.agents/skills/persona-panel/SKILL.md +5 -1
  43. package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
  44. package/.agents/skills/plan/SKILL.md +5 -1
  45. package/.agents/skills/plan/agents/openai.yaml +5 -0
  46. package/.agents/skills/playwright-driver/SKILL.md +3 -1
  47. package/.agents/skills/portfolio/SKILL.md +5 -1
  48. package/.agents/skills/portfolio/agents/openai.yaml +5 -0
  49. package/.agents/skills/quality-gates/SKILL.md +3 -1
  50. package/.agents/skills/reconcile/SKILL.md +5 -1
  51. package/.agents/skills/reconcile/agents/openai.yaml +5 -0
  52. package/.agents/skills/release/SKILL.md +5 -1
  53. package/.agents/skills/release/agents/openai.yaml +5 -0
  54. package/.agents/skills/remote-offload/SKILL.md +3 -1
  55. package/.agents/skills/repo-audit/SKILL.md +5 -1
  56. package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
  57. package/.agents/skills/session/SKILL.md +21 -0
  58. package/.agents/skills/session/agents/openai.yaml +5 -0
  59. package/.agents/skills/session-end/SKILL.md +3 -1
  60. package/.agents/skills/session-plan/SKILL.md +3 -1
  61. package/.agents/skills/session-start/SKILL.md +3 -1
  62. package/.agents/skills/spinout/SKILL.md +5 -1
  63. package/.agents/skills/spinout/agents/openai.yaml +5 -0
  64. package/.agents/skills/sunset-review/SKILL.md +5 -1
  65. package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
  66. package/.agents/skills/templates-ack/SKILL.md +21 -0
  67. package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
  68. package/.agents/skills/test/SKILL.md +5 -1
  69. package/.agents/skills/test/agents/openai.yaml +5 -0
  70. package/.agents/skills/test-runner/SKILL.md +3 -1
  71. package/.agents/skills/tmux-layout/SKILL.md +3 -1
  72. package/.agents/skills/using-orchestrator/SKILL.md +3 -1
  73. package/.agents/skills/ux-grill/SKILL.md +5 -1
  74. package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
  75. package/.agents/skills/vault-mirror/SKILL.md +3 -1
  76. package/.agents/skills/vault-sync/SKILL.md +3 -1
  77. package/.agents/skills/wave-executor/SKILL.md +3 -1
  78. package/.agents/skills/write-executable-plan/SKILL.md +3 -1
  79. package/.claude-plugin/marketplace.json +1 -1
  80. package/.claude-plugin/plugin.json +1 -1
  81. package/.codex-plugin/plugin.json +4 -4
  82. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
  83. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  84. package/.codex-plugin/skills/evolve/SKILL.md +1 -1
  85. package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
  86. package/.codex-plugin/skills/session/SKILL.md +1 -1
  87. package/.cursor/commands/eval.md +1 -1
  88. package/.cursor/commands/session.md +1 -1
  89. package/.cursor/rules/000-session-orchestrator.mdc +0 -2
  90. package/.cursor/rules/050-plan.mdc +1 -1
  91. package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
  92. package/.cursor/skills/eval/SKILL.md +1 -1
  93. package/.cursor/skills/npm-publish/SKILL.md +1 -0
  94. package/.cursor-plugin/plugin.json +1 -1
  95. package/.orchestrator/policy/blocked-commands.json +12 -3
  96. package/AGENTS.md +3 -2
  97. package/CHANGELOG.md +136 -0
  98. package/README.md +9 -9
  99. package/SECURITY.md +12 -0
  100. package/agents/dialectic-deriver.md +13 -10
  101. package/agents/eval-judge.md +67 -45
  102. package/agents/skill-applied-judge.md +34 -19
  103. package/commands/session.md +7 -3
  104. package/docs/baseline.md +12 -6
  105. package/docs/codex-setup.md +14 -2
  106. package/docs/components.md +7 -5
  107. package/docs/events-schema.md +56 -9
  108. package/docs/rule-authoring.md +58 -6
  109. package/docs/session-config-reference.md +100 -7
  110. package/docs/session-config-template.md +31 -2
  111. package/docs/telemetry.md +2 -0
  112. package/hooks/_lib/hook-import-set.json +85 -8
  113. package/hooks/_lib/subagent-transcript.mjs +582 -31
  114. package/hooks/config-protection.mjs +11 -3
  115. package/hooks/cwd-change-restore.mjs +11 -3
  116. package/hooks/enforce-commands.mjs +70 -23
  117. package/hooks/enforce-scope.mjs +143 -33
  118. package/hooks/hooks-codex.json +1 -1
  119. package/hooks/hooks.json +1 -1
  120. package/hooks/loop-guard.mjs +11 -3
  121. package/hooks/on-session-end.mjs +58 -23
  122. package/hooks/on-session-start.mjs +48 -11
  123. package/hooks/on-stop.mjs +168 -22
  124. package/hooks/operator-steer.mjs +11 -3
  125. package/hooks/post-bash-issue-budget-refund.mjs +18 -8
  126. package/hooks/post-bash-write-verify.mjs +3 -2
  127. package/hooks/post-edit-import-probe.mjs +17 -9
  128. package/hooks/post-edit-validate.mjs +13 -5
  129. package/hooks/post-subagent-discovery-validator.mjs +98 -13
  130. package/hooks/post-tool-batch-wave-signal.mjs +200 -38
  131. package/hooks/post-tool-failure-corrective-context.mjs +11 -5
  132. package/hooks/post-tooluse-frontend-slop.mjs +10 -4
  133. package/hooks/pre-auq-clarity.mjs +15 -2
  134. package/hooks/pre-bash-destructive-guard.mjs +80 -9
  135. package/hooks/pre-bash-issue-budget.mjs +16 -11
  136. package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
  138. package/hooks/pre-bash-staging-fence.mjs +335 -31
  139. package/hooks/pre-bash-templates-first.mjs +19 -14
  140. package/hooks/pre-task-scope-disjoint.mjs +233 -2
  141. package/hooks/subagent-telemetry.mjs +15 -19
  142. package/hooks/wave-scope-commit-guard.mjs +197 -100
  143. package/monitors/monitors.json +1 -1
  144. package/output-styles/wave-summary.md +1 -1
  145. package/package.json +1 -1
  146. package/pi/prompts/eval.md +1 -1
  147. package/pi/prompts/session.md +1 -1
  148. package/rules/README.md +1 -1
  149. package/rules/opt-in-domain/prompt-caching.md +1 -1
  150. package/rules/opt-in-stack/backend-data.md +1 -1
  151. package/rules/opt-in-stack/backend.md +3 -3
  152. package/rules/opt-in-stack/frontend.md +1 -1
  153. package/rules/opt-in-stack/security-web.md +3 -3
  154. package/rules/opt-in-stack/swift.md +1 -1
  155. package/scripts/autopilot.mjs +23 -2
  156. package/scripts/backfill-abandoned-sessions.mjs +117 -15
  157. package/scripts/check-sessions-integrity.mjs +300 -0
  158. package/scripts/dialectic-deriver.mjs +50 -13
  159. package/scripts/emit-session.mjs +75 -29
  160. package/scripts/eval-session.mjs +65 -3
  161. package/scripts/generate-agents-skills.mjs +102 -29
  162. package/scripts/generate-cursor-adapter.mjs +61 -16
  163. package/scripts/lib/agent-status.mjs +2 -31
  164. package/scripts/lib/auq/clarity.mjs +10 -2
  165. package/scripts/lib/auq/parse.mjs +12 -31
  166. package/scripts/lib/auq/schema.mjs +56 -41
  167. package/scripts/lib/auto-dialectic.mjs +304 -15
  168. package/scripts/lib/autopilot/flags.mjs +12 -1
  169. package/scripts/lib/autopilot/kill-switches.mjs +6 -3
  170. package/scripts/lib/autopilot/loop.mjs +14 -1
  171. package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
  172. package/scripts/lib/ci-status-banner.mjs +376 -16
  173. package/scripts/lib/command-blocker.mjs +275 -28
  174. package/scripts/lib/config/dialectic.mjs +12 -3
  175. package/scripts/lib/config/gate.mjs +74 -0
  176. package/scripts/lib/config/reaper.mjs +162 -0
  177. package/scripts/lib/config.mjs +14 -0
  178. package/scripts/lib/convergence-monitor.mjs +74 -11
  179. package/scripts/lib/ecosystem-health.mjs +11 -0
  180. package/scripts/lib/eval/engine.mjs +421 -53
  181. package/scripts/lib/eval/judge.mjs +463 -40
  182. package/scripts/lib/eval/schema.mjs +10 -1
  183. package/scripts/lib/events-rotation.mjs +221 -25
  184. package/scripts/lib/events-schema.mjs +114 -0
  185. package/scripts/lib/events.mjs +524 -5
  186. package/scripts/lib/frontmatter-guard.mjs +21 -10
  187. package/scripts/lib/gates/gate-baseline.mjs +27 -2
  188. package/scripts/lib/gates/gate-full.mjs +28 -3
  189. package/scripts/lib/gates/gate-helpers.mjs +243 -21
  190. package/scripts/lib/gates/gate-incremental.mjs +28 -3
  191. package/scripts/lib/gates/gate-per-file.mjs +27 -2
  192. package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
  193. package/scripts/lib/instruction-budget-guard.mjs +146 -4
  194. package/scripts/lib/io.mjs +42 -8
  195. package/scripts/lib/issue-close-strip-labels.mjs +207 -49
  196. package/scripts/lib/js-mask.mjs +197 -0
  197. package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
  198. package/scripts/lib/maintenance-due-banner.mjs +53 -88
  199. package/scripts/lib/orphan-reaper.mjs +1588 -0
  200. package/scripts/lib/peer-cards/merger.mjs +48 -10
  201. package/scripts/lib/peer-cards/reader.mjs +78 -2
  202. package/scripts/lib/process-group.mjs +899 -0
  203. package/scripts/lib/quality-gate.mjs +107 -28
  204. package/scripts/lib/reconcile/backlog.mjs +368 -0
  205. package/scripts/lib/reconcile/engine.mjs +55 -188
  206. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
  207. package/scripts/lib/reconcile/sanitize.mjs +69 -3
  208. package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
  209. package/scripts/lib/resource-probe/parsers.mjs +31 -0
  210. package/scripts/lib/rule-loader.mjs +41 -12
  211. package/scripts/lib/scope-echo.mjs +39 -2
  212. package/scripts/lib/scope-gate.mjs +605 -1
  213. package/scripts/lib/session-close-backfill.mjs +33 -6
  214. package/scripts/lib/session-id.mjs +9 -20
  215. package/scripts/lib/session-invocation.mjs +20 -0
  216. package/scripts/lib/session-schema/constants.mjs +30 -2
  217. package/scripts/lib/session-schema/normalizer.mjs +56 -4
  218. package/scripts/lib/session-schema.mjs +8 -3
  219. package/scripts/lib/session-start-probes.mjs +95 -10
  220. package/scripts/lib/sessions-canonical.mjs +23 -0
  221. package/scripts/lib/sessions-integrity-banner.mjs +7 -1
  222. package/scripts/lib/sessions-staleness-banner.mjs +193 -51
  223. package/scripts/lib/skill-evidence-window.mjs +891 -0
  224. package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
  225. package/scripts/lib/skill-evolution/engine.mjs +18 -9
  226. package/scripts/lib/skill-judge.mjs +45 -3
  227. package/scripts/lib/tail-window.mjs +56 -0
  228. package/scripts/lib/telemetry/schema.mjs +30 -0
  229. package/scripts/lib/telemetry/sync.mjs +61 -6
  230. package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
  231. package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
  232. package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
  233. package/scripts/lib/user-invocable-skills.mjs +23 -3
  234. package/scripts/lib/ux-grill/reconcile.mjs +48 -22
  235. package/scripts/lib/validate/check-agents-skills.mjs +26 -15
  236. package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
  237. package/scripts/lib/validate/check-entry-guard.mjs +13 -50
  238. package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
  239. package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
  240. package/scripts/lib/validate/check-rules.mjs +7 -5
  241. package/scripts/lib/validate/check-skill-links.mjs +9 -1
  242. package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
  243. package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
  244. package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
  245. package/scripts/lib/validate/check-unwired-features.mjs +130 -27
  246. package/scripts/lib/validate/check-validator-registration.mjs +34 -10
  247. package/scripts/lib/validate/confidential-names.mjs +10 -0
  248. package/scripts/lib/validate-vendored-rules.mjs +4 -3
  249. package/scripts/lib/vault-mirror/namespace.mjs +46 -8
  250. package/scripts/lib/vault-mirror/process.mjs +10 -3
  251. package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
  252. package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
  253. package/scripts/lib/vault-yaml.mjs +118 -0
  254. package/scripts/lib/worktree/lifecycle.mjs +153 -1
  255. package/scripts/release-session-lock.mjs +305 -0
  256. package/scripts/release.mjs +30 -5
  257. package/scripts/resolve-session-invocation.mjs +59 -0
  258. package/scripts/run-quality-gate.mjs +156 -17
  259. package/scripts/sweep-expired-rules.mjs +14 -3
  260. package/scripts/validate-plugin.mjs +12 -0
  261. package/scripts/validate-wave-scope.mjs +32 -105
  262. package/scripts/vault-mirror.mjs +9 -1
  263. package/skills/_shared/platform-tools.md +23 -11
  264. package/skills/autopilot/SKILL.md +22 -7
  265. package/skills/claude-md-drift-check/SKILL.md +1 -1
  266. package/skills/convergence-monitoring/README.md +8 -1
  267. package/skills/convergence-monitoring/SIGNALS.md +50 -6
  268. package/skills/convergence-monitoring/SKILL.md +15 -6
  269. package/skills/eval/SKILL.md +39 -24
  270. package/skills/eval/rubric-v1.md +1 -0
  271. package/skills/eval/rubric-v2.md +457 -0
  272. package/skills/evolve/SKILL.md +1 -1
  273. package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
  274. package/skills/gitlab-ops/SKILL.md +3 -2
  275. package/skills/npm-publish/SKILL.md +1 -1
  276. package/skills/reconcile/SKILL.md +11 -0
  277. package/skills/session-end/SKILL.md +13 -16
  278. package/skills/session-end/discovery-scan.md +1 -1
  279. package/skills/session-end/phase-3-6-tail.md +55 -9
  280. package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
  281. package/skills/session-end/session-metrics-write.md +10 -0
  282. package/skills/session-plan/SKILL.md +17 -5
  283. package/skills/session-plan/references/session-plan-task-classification.md +2 -2
  284. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -1
  285. package/skills/ux-grill/SKILL.md +1 -1
  286. package/skills/wave-executor/SKILL.md +8 -4
  287. package/skills/wave-executor/circuit-breaker.md +2 -0
  288. package/skills/wave-executor/references/wave-executor-state-init.md +5 -3
  289. package/skills/wave-executor/references/wave-loop-dispatch.md +2 -1
  290. package/.codex-plugin/skills/convergence-monitoring/agents/openai.yaml +0 -5
  291. package/.codex-plugin/skills/npm-publish/agents/openai.yaml +0 -5
  292. package/.cursor/commands/convergence-monitoring.md +0 -13
  293. package/.cursor/commands/npm-publish.md +0 -13
  294. package/pi/prompts/convergence-monitoring.md +0 -11
  295. package/pi/prompts/npm-publish.md +0 -11
@@ -11,11 +11,18 @@
11
11
  * (string in, findings out); the hook keeps stdin, config gate, sidecar
12
12
  * resolution, sentinels and the three output channels.
13
13
  *
14
- * Three responsibilities, in the order the hook uses them:
14
+ * Four responsibilities, in the order the hook uses them:
15
15
  * 1. `readTranscriptTail()` — the last N assistant records of ONE transcript
16
- * JSONL, text blocks concatenated.
16
+ * JSONL, text blocks concatenated. This is the HOOK'S VIEW: what the
17
+ * agent SAID.
18
+ * 1b. `readTranscriptObservations()` — the vitest run summaries found in the
19
+ * `tool_result` blocks of the SAME byte window. This is what the agent
20
+ * actually RAN. A second reader rather than a second pattern, because
21
+ * `readTranscriptTail` collects assistant `text` blocks only and a
22
+ * `tool_result` lives in a `user` record.
17
23
  * 2. `findViolations()` — distributional/repo-state claims that carry no
18
- * adjacent measurement evidence.
24
+ * adjacent measurement evidence, gate verdicts that carry no run receipt,
25
+ * and (R1) a claimed test count that contradicts every run observed.
19
26
  * 3. `normalizeClaim()` / `dedupeViolations()` — one record per distinct
20
27
  * claim, with an `occurrences` count (#1198: the fleet's worst repo held
21
28
  * 3,360 records over 205 distinct `claim_text` values = duplication
@@ -25,7 +32,7 @@
25
32
  * @module hooks/_lib/subagent-transcript
26
33
  */
27
34
 
28
- import { promises as fs } from 'node:fs';
35
+ import { readTailWindow } from '../../scripts/lib/tail-window.mjs';
29
36
 
30
37
  // ---------------------------------------------------------------------------
31
38
  // Constants
@@ -33,6 +40,20 @@ import { promises as fs } from 'node:fs';
33
40
 
34
41
  /** Number of trailing assistant records to scan. */
35
42
  export const TAIL_RECORDS = 8;
43
+ /**
44
+ * Byte window `readTranscriptTail()` reads from the END of a transcript (#1388
45
+ * P4) — the ceiling on this deliberate simplification, per BV-004.
46
+ *
47
+ * Measured 2026-09-18 over 1016 real subagent transcripts: the byte span of the
48
+ * last TAIL_RECORDS assistant records was median 41 KB, p99 322 KB, max 1.25 MB
49
+ * (file size median 676 KB, max 8.2 MB). 2 MiB loses zero records in that
50
+ * sample; 1 MiB would already lose one.
51
+ *
52
+ * REVISIT TRIGGER: if TAIL_RECORDS grows, or a re-measurement puts the max span
53
+ * above ~1.5 MB, raise this window — a too-small window silently drops the
54
+ * OLDEST of the eight records rather than failing.
55
+ */
56
+ export const TAIL_WINDOW_BYTES = 2 * 1024 * 1024;
36
57
  /** Proximity window (in lines) for an adjacent grep transcript. */
37
58
  export const GREP_PROXIMITY_LINES = 5;
38
59
  /** Max characters of claim text persisted to the event record. */
@@ -198,6 +219,276 @@ const INLINE_CODE_RE = /`[^`\n]*`/g;
198
219
  const GATE_SUMMARY_LINE_RE =
199
220
  /\b\d+\s+passed\s*\/\s*\d+\s+failed\b|^\s*STATUS:\s*(?:done|partial|failed)\b|\bFull Gate\b|\bGate:\s*(?:typecheck|grün|gruen|rot)\b|\bGate \d[\d.]*\/\d+\b|\b\d+\s+Wellen?,\s*\d+\s+Agents?\b|\bArbeitsbaum leer\b|\bmit Nachweis geschlossen\b/i;
200
221
 
222
+ /**
223
+ * GATE/DONE-VERDICT claim (#1397 w4-1) — the second claim class, and the one
224
+ * the exemption above structurally hid.
225
+ *
226
+ * `GATE_SUMMARY_LINE_RE` is CORRECT for what it was built for: it keeps the
227
+ * DISTRIBUTIONAL patterns off the harness's own completion output, where a
228
+ * number is a field value rather than an assertion (186/400 = 46.5% of sampled
229
+ * violations, #1198 D8). What it did NOT do is look at those lines for a claim
230
+ * of a DIFFERENT kind — so `STATUS: done` and "alles grün" were the two line
231
+ * shapes the scanner could never flag, which is precisely where "done" and
232
+ * "green" get claimed.
233
+ *
234
+ * Measured 2026-09-19 over a 130-case labelled done-claim corpus
235
+ * (`~/.cache/jev-eval/so/tasks/s2-done-claim.json`, gold `qid: "support"`):
236
+ * `findViolations()` answered `supported` on 128 of 130 cases — accuracy 30.2%,
237
+ * Cohen κ 0.007, statistically indistinguishable from guessing. Independent
238
+ * evidence from the same build: of 50 done/partial reports carrying neither a
239
+ * test count nor `exit 0`, 3 carry a bare gate-verdict claim with no command
240
+ * anywhere in the report.
241
+ *
242
+ * TWO separate triggers, both deliberately gap-free (no `[^\n]{0,N}` bridge) so
243
+ * a number or a verb between the noun and the verdict word breaks the match —
244
+ * "The gate ran green on 2026-07-29" and "CI-Pipeline #6995 grün" are prose
245
+ * about a past run, not a verdict being asserted now.
246
+ */
247
+ const DONE_STATUS_RE =
248
+ /^\s*(?:[-*+>]\s+|#{1,6}\s+)*\**\s*STATUS\**\s*[:=]\s*\**\s*(?:done|partial|complete|abgeschlossen|fertig)\b/i;
249
+
250
+ const GATE_GREEN_RE = new RegExp(
251
+ [
252
+ // English verdicts
253
+ '\\b(?:all\\s+)?tests?\\s+(?:now\\s+)?pass(?:es|ed|ing)?\\b',
254
+ '\\b(?:suite|gate|build|pipeline|ci)\\s+(?:is\\s+|are\\s+)?green\\b',
255
+ '\\bgreen\\s+(?:gate|suite|build|pipeline)\\b',
256
+ '\\b(?:typecheck|lint|build|gate|suite)\\s+(?:is\\s+)?(?:clean|passes|passing)\\b',
257
+ '\\beverything\\s+(?:passes|is\\s+green)\\b',
258
+ // "<gate>: PASS|clean|green|OK" — the shape the code-implementer report
259
+ // template itself prescribes ("Verification — Typecheck: pass").
260
+ '\\b(?:tests?|typecheck|lint|gate|build|suite)\\s*:\\s*\\**\\s*(?:pass|passed|clean|green|ok)\\b',
261
+ // German verdicts
262
+ '\\balles\\s+gr(?:ü|ue)n\\b',
263
+ '\\balle\\s+Tests?\\s+gr(?:ü|ue)n\\b',
264
+ '\\bgr(?:ü|ue)ne(?:[rsn]|nes)?\\s+(?:Gate|Suite|Lauf)\\b',
265
+ '\\b(?:Full\\s+Gate|Gate|Suite)\\s*:?\\s*\\**\\s*(?:ist\\s+)?gr(?:ü|ue)n',
266
+ ].join('|'),
267
+ 'i'
268
+ );
269
+
270
+ /**
271
+ * A RUN RECEIPT — the counted result PSA-006 item 1-3 asks a gate claim to
272
+ * carry: a pass/fail count, a typecheck file count, an exit code, or a
273
+ * numerator/denominator green ratio. German forms alongside the English ones.
274
+ *
275
+ * Scope is the WHOLE scanned text, not the ±GREP_PROXIMITY_LINES window the
276
+ * distributional class uses, and that asymmetry is the point: a distributional
277
+ * claim is about ONE measurement and needs its transcript adjacent, while a
278
+ * done/gate verdict is about the whole report's work — a report that quotes
279
+ * `npm test` at the top and writes `STATUS: done` forty lines later IS
280
+ * evidenced. The window still applies to the command half via
281
+ * `nearIndex(measurementLines, …)`; this is the counted-result half.
282
+ *
283
+ * Deliberately LENIENT in the false-positive-safe direction: any exit code (not
284
+ * only `0`) counts, because the question this class asks is "was anything run
285
+ * at all?", never "did it pass?". A quoted `exit 1` means a run happened; the
286
+ * verdict's truth is the coordinator's judgement, not a regex's.
287
+ *
288
+ * `(?<![A-Za-z])exit` rather than `\bexit`: the shells here emit `LINT_EXIT=0`
289
+ * / `TYPECHECK_EXIT=0` / `VITEST_EXIT=0`, where `_` is a word character and
290
+ * `\b` would therefore NOT match.
291
+ *
292
+ * All quantifiers bounded ({0,20}) — linear-time, ReDoS-safe.
293
+ */
294
+ const RUN_RECEIPT_RE = new RegExp(
295
+ [
296
+ '\\b\\d+\\s+(?:passed|failed|bestanden|fehlgeschlagen|skipped)\\b',
297
+ '\\b\\d+\\s+file(?:\\(s\\)|s)?\\s+OK\\b',
298
+ '(?<![A-Za-z])exit(?:\\s*code)?\\s*[=:]?\\s*\\d+',
299
+ '\\b\\d+\\s*/\\s*\\d+\\b[^\\n]{0,20}?\\b(?:passed|green|gr(?:ü|ue)n|tests?)\\b',
300
+ '\\b(?:passed|failed)\\s*[=:]\\s*\\d+',
301
+ ].join('|'),
302
+ 'i'
303
+ );
304
+
305
+ /** Claim-class discriminators carried on every violation record. */
306
+ export const KIND_DISTRIBUTIONAL = 'distributional';
307
+ export const KIND_GATE_VERDICT = 'gate-verdict';
308
+ /**
309
+ * CLAIM-MISMATCH (#1385 R1) — the third class, and the one that closes an
310
+ * INVERSION the other two leave standing.
311
+ *
312
+ * `RUN_RECEIPT_RE` is report-wide and knows `\d+\s+passed`, so the number an
313
+ * agent asserts is accepted as its own receipt. Measured on this module at
314
+ * HEAD before this change:
315
+ *
316
+ * 'STATUS: done\nTests pass: 5129 passed / 0 failed.' -> []
317
+ * 'STATUS: done\nAlles grün.' -> ['gate-verdict','gate-verdict']
318
+ *
319
+ * An invented number therefore DISARMS the guard, while an honest report with
320
+ * no number is flagged. This class reads the `tool_result` side of the same
321
+ * window and compares: the claim is only a violation when runs WERE observed
322
+ * and NONE of them carries the asserted pass count.
323
+ *
324
+ * Deliberately NOT built (BV-001.1): the SHA-claim check. Measured 2026-09-19
325
+ * by w1-3 over 1037 real subagent transcripts — 244 carried a SHA claim, 103
326
+ * distinct tokens, 5 unresolvable in-repo, and all 5 were cross-repo
327
+ * artefacts, 0 hallucinations. A check there produces only false alarms.
328
+ */
329
+ export const KIND_CLAIM_MISMATCH = 'claim-mismatch';
330
+ /** `mismatch` sub-kind: a claimed test COUNT no observed run carries. */
331
+ export const MISMATCH_COUNT = 'count';
332
+
333
+ /**
334
+ * Commands whose `tool_result` may carry a vitest run summary. Restricted to
335
+ * vitest ON PURPOSE: `validate-plugin`'s own tally ("229 passed, 0 failed")
336
+ * supplied 5 of the 12 false-alarm candidates in w1-3's pre-measurement, and
337
+ * admitting a second counter means two number vocabularies in one comparison.
338
+ *
339
+ * Structurally reinforced by OBSERVED_SUMMARY_RE below, which demands vitest's
340
+ * `Tests N passed (M)` shape — validate-plugin's comma form cannot match it
341
+ * even when it prints inside a `npx vitest` invocation (it is the suite's
342
+ * globalSetup).
343
+ */
344
+ const VITEST_CMD_RE = /\b(?:npm\s+(?:run\s+)?test|pnpm\s+(?:run\s+)?test|npx\s+vitest|vitest\s+run)\b/;
345
+
346
+ /**
347
+ * Tools whose `tool_result` may carry a run summary even though the tool is
348
+ * not the runner. Measured 2026-09-19 over 1044 real subagent transcripts:
349
+ * the dominant local idiom is `npm test > run.log 2>&1` followed by a SEPARATE
350
+ * step that surfaces the log — `Read` on the log file (2 of 9 residual
351
+ * firings) or `BashOutput` on a backgrounded run (1 of 9). Keyed on the TOOL
352
+ * NAME because neither carries the runner command in its own input.
353
+ *
354
+ * Admitting these can only ever CLEAR a claim, never raise one — an extra
355
+ * observation adds a number the claim may match. NAMED CEILING (BV-004): an
356
+ * agent that WRITES a file containing a fabricated `Tests N passed (N)` line
357
+ * and then `Read`s it back manufactures its own observation. Accepted: that
358
+ * is a deliberate two-step forgery, where the class's target is the ordinary
359
+ * one-step invented number. REVISIT TRIGGER: one observed case of a claim
360
+ * cleared by a self-authored file.
361
+ */
362
+ const RECEIPT_BEARING_TOOLS = new Set(['BashOutput', 'Read']);
363
+
364
+ /**
365
+ * A vitest run summary line: `Tests 15419 passed | 11 skipped (15430)`.
366
+ *
367
+ * Anchored at line start so `Test Files 612 passed (612)` (a FILE count, not
368
+ * a test count) and vitest's `⎯⎯ Failed Tests 1 ⎯⎯` banner cannot match — the
369
+ * former because `Tests` is not followed by whitespace there, the latter
370
+ * because the line does not begin with it.
371
+ *
372
+ * The optional `30:` / ` 30→` prefix is NOT cosmetic: the log-reading idiom
373
+ * above surfaces the summary through `grep -n`, `cat -n` or `Read`, all of
374
+ * which prepend a line number. Without it the anchor missed a real receipt
375
+ * that sat in the window — 2 of the 9 residual firings measured 2026-09-19
376
+ * read `"30: Tests 1 failed | 1338 passed (1339)"` verbatim.
377
+ *
378
+ * All quantifiers bounded — linear-time, ReDoS-safe.
379
+ */
380
+ const OBSERVED_SUMMARY_RE =
381
+ /^[^\S\n]{0,16}(?:\d{1,7}[:|→\t][^\S\n]{0,8})?Tests[^\S\n]{1,8}([^\n(]{0,120}?)[^\S\n]{0,8}\((\d{1,9})\)[^\S\n]{0,8}$/gm;
382
+ const PASSED_COUNT_RE = /(\d{1,9})\s+passed\b/i;
383
+ const FAILED_COUNT_RE = /(\d{1,9})\s+failed\b/i;
384
+
385
+ /** ANSI SGR sequences, stripped before a summary line is matched. */
386
+ // eslint-disable-next-line no-control-regex
387
+ const ANSI_SGR_RE = /\u001b\[[0-9;]{0,16}m/g;
388
+
389
+ /**
390
+ * Digit-group separators admitted in a claimed count and stripped before it is
391
+ * parsed: ASCII dot/comma plus NO-BREAK SPACE (U+00A0) and NARROW NO-BREAK
392
+ * SPACE (U+202F), the two `toLocaleString` emits for de-AT / fr grouping.
393
+ *
394
+ * Written as ESCAPES inside a plain string, never as the literal characters.
395
+ * Both forms behave identically at runtime and only one survives review: the
396
+ * literal pair tripped `no-irregular-whitespace` AND is invisible on the page,
397
+ * the same property that makes `validate-plugin`'s dangerous-invisible check
398
+ * reject a U+200B. Defined ABOVE its first use — `CLAIMED_COUNT_RE` reads it
399
+ * at module-evaluation time, and a `const` referenced from above is a TDZ
400
+ * ReferenceError that `node --check` does not catch (only an import probe
401
+ * does; see `.claude/rules/toolchain-and-build.md`).
402
+ */
403
+ const THOUSANDS_SEP_CLASS = '[.,\\u00a0\\u202f]';
404
+
405
+ /**
406
+ * A CLAIMED pass count in the agent's own prose: `Tests pass: 5129 passed`,
407
+ * `51 tests passed`, `14,340 passed`. Global — a line routinely names SEVERAL
408
+ * (`2 passed files; 70 tests passed`), and treating only the first as "the"
409
+ * claim is what produced most of the measured false alarms (below).
410
+ *
411
+ * Three shapes the corpus forced, each measured 2026-09-19 over 1044 real
412
+ * subagent transcripts (`~/.claude/projects/<slug>/<session>/subagents/`):
413
+ *
414
+ * 1. THOUSANDS SEPARATORS. `14,340 passed` read as `340` under a bare
415
+ * `\d{1,9}`, inventing a mismatch out of a parse error — 3 of the first
416
+ * 12 samples. The group admits `1.091` / `14,340` / `14 340` and the
417
+ * separators are stripped before `Number()`.
418
+ * 2. AN INTERVENING NOUN. `51 tests passed` hid the only number on the line
419
+ * that WAS observed.
420
+ * 3. `passed` AS AN ORDINARY VERB. "the 8 test call sites at lines 715 …
421
+ * and 1080 passed bodies" is not a test result at all — `1080` is a line
422
+ * number and `passed` takes an object. The trailing lookahead therefore
423
+ * requires a RESULT context after the word: end of line, punctuation, a
424
+ * digit, or a short connector. The same lookahead subsumes the file
425
+ * tally (`2 passed files`), whose column-header form `Test Files` is
426
+ * caught by COUNT_CLAIM_EXCLUSION_RES.
427
+ *
428
+ * All quantifiers bounded — linear-time, ReDoS-safe.
429
+ */
430
+ const CLAIMED_COUNT_RE = new RegExp(
431
+ String.raw`(\d{1,3}(?:${THOUSANDS_SEP_CLASS}\d{3})+|\d{1,9})\s{1,4}(?:tests?\s{1,4})?passed\b` +
432
+ String.raw`(?=\s{0,4}(?:$|[,.;:/|)*\]!—-]|\d|(?:and|und|in|with|on|at|after|before|for|across|under|exit)\b))`,
433
+ 'gi'
434
+ );
435
+ const THOUSANDS_SEP_RE = new RegExp(THOUSANDS_SEP_CLASS, 'g');
436
+
437
+ /**
438
+ * Every pass count asserted on one line, in encounter order.
439
+ *
440
+ * @param {string} line
441
+ * @returns {number[]}
442
+ */
443
+ function extractClaimedCounts(line) {
444
+ const out = [];
445
+ CLAIMED_COUNT_RE.lastIndex = 0;
446
+ for (const m of line.matchAll(CLAIMED_COUNT_RE)) {
447
+ const n = Number(m[1].replace(THOUSANDS_SEP_RE, ''));
448
+ if (Number.isFinite(n)) out.push(n);
449
+ }
450
+ return out;
451
+ }
452
+
453
+ /**
454
+ * Lines that carry `N passed` WITHOUT asserting this run's test count. Each
455
+ * entry is one of the four false-alarm forms w1-3 measured; a fifth would go
456
+ * here rather than into a widened threshold (HR-101 / development.md §
457
+ * Guard & Threshold Design — category separation, never a looser number).
458
+ *
459
+ * 1. A DIFFERENT instrument with the same word shape — vitest's own file
460
+ * tally, `validate-plugin`, the `check-rules`/`check-skills`/`check-docs`
461
+ * validators, a `Results:` summary line.
462
+ * 2. A QUOTED foreign assertion — "the reviewer claimed 5129 passed".
463
+ * 3. A RED-BEFORE-FIX number — the fake-regression proof a bugfix owes
464
+ * (`.claude/rules/testing.md` § Negative-Assertion Fake-Regression
465
+ * Check) names a count that deliberately does NOT match the green run.
466
+ * The marker list is wider than the briefed four because the corpus said
467
+ * so: the first real hit read "RED proof: mutated peer-discovery loader
468
+ * — 2 failed, 0 passed", which `mutation` and `red before` both miss.
469
+ * 4. A JSON key line — a field value, not an assertion (same reasoning as
470
+ * CONFIG_KEY_LINE_RE above, narrowed to the quoted-key form).
471
+ */
472
+ const COUNT_CLAIM_EXCLUSION_RES = [
473
+ /Test\s+Files/i,
474
+ /validate[-_]plugin/i,
475
+ /check[-_](?:rules|skills|docs)/i,
476
+ /Results:/i,
477
+ /^\s*"[^"\n]{1,64}"\s*:/,
478
+ /\b(?:red before|red proof|rot vor|vor dem Fix|before the fix|mutat(?:e|ed|es|ing|ion)|Fake-Regression|baseline|Rot-Lauf|Rot-Beweis)\b/i,
479
+ /\b(?:claimed|stated|behauptet|laut)\b/i,
480
+ ];
481
+
482
+ /**
483
+ * Cap on observations carried into the pair-sum search below.
484
+ *
485
+ * NAMED CEILING (BV-004): the partial-run match is O(n²) over this list. 50
486
+ * keeps the worst case at 1,225 additions — free beside the regex passes this
487
+ * module already makes. REVISIT TRIGGER: a transcript whose window holds more
488
+ * than 50 vitest summaries AND a claim that only the dropped ones explain.
489
+ */
490
+ const MAX_OBSERVATIONS = 50;
491
+
201
492
  /**
202
493
  * NON-PROSE structural lines (#1218 negative-context guard). A PSA-006 claim is
203
494
  * an ASSERTION in prose; these four shapes are not prose at all, and every one
@@ -239,6 +530,20 @@ const NON_PROSE_LINE_RE = new RegExp(
239
530
  'i'
240
531
  );
241
532
 
533
+ /**
534
+ * The same guard MINUS the heading rule, for the gate/done-verdict class. The
535
+ * #1218 heading exemption was measured on DISTRIBUTIONAL claims, where a
536
+ * heading labels a section and the claim is restated in the body below. A gate
537
+ * verdict is the opposite: `### Tests: PASS` and `## Wave 3 Complete — Gate: …`
538
+ * ARE the verdict, and nothing restates them. Table rows, separators and
539
+ * plan/intent items stay excluded for both classes — a task-list `- [ ] make
540
+ * the suite green` is intent, and a status-matrix cell is structured data.
541
+ */
542
+ const NON_PROSE_NO_HEADING_RE = new RegExp(
543
+ `(${TABLE_ROW_RE.source})|(${TABLE_SEPARATOR_RE.source})|(${PLAN_INTENT_RE.source})`,
544
+ 'i'
545
+ );
546
+
242
547
  /**
243
548
  * CONFIG / SCORE / VERSION literal line (#1218). A `key: <scalar>` line is a
244
549
  * configuration or JSON fragment, not an assertion — the number is a SETTING.
@@ -317,24 +622,52 @@ const LEADING_MARKER_RE = /^(?:\s*(?:[-*+•]|\d{1,3}[.)]|#{1,6}|>)\s+)+/;
317
622
  * @param {string} transcriptPath
318
623
  * @returns {Promise<string>}
319
624
  */
320
- export async function readTranscriptTail(transcriptPath) {
321
- if (typeof transcriptPath !== 'string' || !transcriptPath) return '';
625
+ /**
626
+ * Parse the bounded tail window of `transcriptPath` into JSONL records.
627
+ *
628
+ * Shared by both readers below; each still performs its own window read, so a
629
+ * hook that wants both pays two ≤2 MiB reads. Accepted (BV-004): the hook runs
630
+ * once per SubagentStop and the page cache serves the second read.
631
+ *
632
+ * @param {string} transcriptPath
633
+ * @returns {object[]} parsed records, oldest first; `[]` on any failure
634
+ */
635
+ function readTailRecords(transcriptPath) {
636
+ if (typeof transcriptPath !== 'string' || !transcriptPath) return [];
322
637
  let raw;
638
+ let cut;
323
639
  try {
324
- raw = await fs.readFile(transcriptPath, 'utf8');
640
+ // Bounded read (#1388 P4): the whole transcript used to be decoded and
641
+ // JSON-parsed line by line only to keep the last 8 assistant records.
642
+ ({ text: raw, cut } = readTailWindow(transcriptPath, TAIL_WINDOW_BYTES));
325
643
  } catch {
326
- return '';
644
+ // Every fs error (ENOENT, /dev/null EACCES, …) maps to empty —
645
+ // readTailWindow THROWS where the former fs.readFile catch swallowed, and
646
+ // the callers (post-subagent-discovery-validator) rely on that contract.
647
+ return [];
327
648
  }
328
- if (!raw.trim()) return '';
649
+ if (!raw.trim()) return [];
650
+
651
+ const lines = raw.split(/\r?\n/);
652
+ // `cut` means the window did not start at byte 0, so line 0 is (or may be) a
653
+ // record fragment, possibly severed mid-UTF-8. Drop it explicitly rather than
654
+ // leaning on the JSON.parse catch below: a truncated record can still parse.
655
+ if (cut) lines.shift();
329
656
 
330
- const assistantRecords = [];
331
- for (const line of raw.split(/\r?\n/)) {
657
+ const records = [];
658
+ for (const line of lines) {
332
659
  const trimmed = line.trim();
333
660
  if (!trimmed) continue;
334
661
  let rec;
335
662
  try { rec = JSON.parse(trimmed); } catch { continue; }
336
- if (rec && rec.type === 'assistant') assistantRecords.push(rec);
663
+ if (rec) records.push(rec);
337
664
  }
665
+ return records;
666
+ }
667
+
668
+ export async function readTranscriptTail(transcriptPath) {
669
+ const assistantRecords = readTailRecords(transcriptPath).filter((r) => r.type === 'assistant');
670
+ if (assistantRecords.length === 0) return '';
338
671
 
339
672
  const tail = assistantRecords.slice(-TAIL_RECORDS);
340
673
  const textBlocks = [];
@@ -350,6 +683,91 @@ export async function readTranscriptTail(transcriptPath) {
350
683
  return textBlocks.join('\n');
351
684
  }
352
685
 
686
+ /**
687
+ * Flatten a `tool_result` block's content to text. Measured on a real
688
+ * transcript (2026-09-19, `~/.claude/projects/<slug>/<session>/subagents/`):
689
+ * `content` is a plain STRING on the Bash results sampled, and the array-of-
690
+ * blocks form is the documented alternative — both are handled, because a
691
+ * reader that knows only one shape silently observes nothing and every claim
692
+ * then reads as unobserved (the fail-OPEN direction is correct here, but a
693
+ * shape gap would make this whole class inert without saying so).
694
+ *
695
+ * @param {*} content
696
+ * @returns {string}
697
+ */
698
+ function toolResultText(content) {
699
+ if (typeof content === 'string') return content;
700
+ if (!Array.isArray(content)) return '';
701
+ const parts = [];
702
+ for (const block of content) {
703
+ if (typeof block === 'string') parts.push(block);
704
+ else if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text);
705
+ }
706
+ return parts.join('\n');
707
+ }
708
+
709
+ /**
710
+ * Read the vitest run summaries the agent ACTUALLY produced inside the same
711
+ * bounded tail window `readTranscriptTail()` reads.
712
+ *
713
+ * A second reader rather than a second pattern: `readTranscriptTail()`
714
+ * collects assistant `text` blocks, and a `tool_result` lives in a `user`
715
+ * record — the two never meet. The `tool_use` → `tool_result` join is by
716
+ * `tool_use_id`, so only the results of a vitest Bash command (or of the
717
+ * receipt-bearing tools above) are read; a `git log` result that happens to
718
+ * contain the word "passed" is not an observation.
719
+ *
720
+ * Returns at most `MAX_OBSERVATIONS` entries, newest last.
721
+ *
722
+ * @param {string} transcriptPath
723
+ * @returns {Promise<{passed: number, failed: number, total: number}[]>}
724
+ */
725
+ export async function readTranscriptObservations(transcriptPath) {
726
+ const records = readTailRecords(transcriptPath);
727
+ if (records.length === 0) return [];
728
+
729
+ /** @type {Set<string>} tool_use ids whose result may carry a run summary */
730
+ const receiptToolUseIds = new Set();
731
+ for (const rec of records) {
732
+ const content = rec?.message?.content;
733
+ if (!Array.isArray(content)) continue;
734
+ for (const block of content) {
735
+ if (!block || block.type !== 'tool_use') continue;
736
+ if (typeof block.id !== 'string' || !block.id) continue;
737
+ if (RECEIPT_BEARING_TOOLS.has(block.name)) { receiptToolUseIds.add(block.id); continue; }
738
+ if (block.name !== 'Bash') continue;
739
+ const cmd = block?.input?.command;
740
+ if (typeof cmd === 'string' && VITEST_CMD_RE.test(cmd)) receiptToolUseIds.add(block.id);
741
+ }
742
+ }
743
+ if (receiptToolUseIds.size === 0) return [];
744
+
745
+ const observations = [];
746
+ for (const rec of records) {
747
+ const content = rec?.message?.content;
748
+ if (!Array.isArray(content)) continue;
749
+ for (const block of content) {
750
+ if (!block || block.type !== 'tool_result') continue;
751
+ if (!receiptToolUseIds.has(block.tool_use_id)) continue;
752
+ const text = toolResultText(block.content).replace(ANSI_SGR_RE, '');
753
+ if (!text) continue;
754
+ OBSERVED_SUMMARY_RE.lastIndex = 0;
755
+ for (const m of text.matchAll(OBSERVED_SUMMARY_RE)) {
756
+ const body = m[1] ?? '';
757
+ const passedMatch = PASSED_COUNT_RE.exec(body);
758
+ if (!passedMatch) continue; // `Tests no tests (0)` and friends
759
+ const failedMatch = FAILED_COUNT_RE.exec(body);
760
+ observations.push({
761
+ passed: Number(passedMatch[1]),
762
+ failed: failedMatch ? Number(failedMatch[1]) : 0,
763
+ total: Number(m[2]),
764
+ });
765
+ }
766
+ }
767
+ }
768
+ return observations.slice(-MAX_OBSERVATIONS);
769
+ }
770
+
353
771
  // ---------------------------------------------------------------------------
354
772
  // claim scanning
355
773
  // ---------------------------------------------------------------------------
@@ -494,57 +912,190 @@ export function normalizeClaim(text) {
494
912
  }
495
913
 
496
914
  /**
497
- * Collapse repeated claims into one entry per distinct normalized key,
915
+ * Collapse repeated claims into one entry per distinct (kind, normalized key),
498
916
  * preserving first-seen order and counting `occurrences` (#1198).
499
917
  *
500
- * @param {string[]} claims — raw claim strings in encounter order
501
- * @returns {{claim: string, normalized: string, occurrences: number}[]}
918
+ * Accepts a bare string (kind defaults to `distributional`) or a
919
+ * `{claim, kind, detail?}` record — the claim classes never merge into one
920
+ * entry even when they land on the identical line, because a coordinator
921
+ * triaging the ledger needs to know WHICH rule the line broke.
922
+ *
923
+ * `detail` (the `claim-mismatch` class's `{mismatch, claimed, observed,
924
+ * observed_n}` payload) rides along FIRST-SEEN-WINS: a repeat of the same
925
+ * normalized line was compared against the same observation set, so a second
926
+ * copy would carry identical numbers.
927
+ *
928
+ * @param {(string|{claim: string, kind?: string, detail?: object})[]} claims — in encounter order
929
+ * @returns {{claim: string, normalized: string, occurrences: number, kind: string, detail?: object}[]}
502
930
  */
503
931
  export function dedupeViolations(claims) {
504
- /** @type {Map<string, {claim: string, normalized: string, occurrences: number}>} */
932
+ /** @type {Map<string, {claim: string, normalized: string, occurrences: number, kind: string, detail?: object}>} */
505
933
  const byKey = new Map();
506
- for (const claim of claims) {
934
+ for (const entry of claims) {
935
+ const claim = typeof entry === 'string' ? entry : entry?.claim;
936
+ const kind = (typeof entry === 'string' ? undefined : entry?.kind) ?? KIND_DISTRIBUTIONAL;
937
+ const detail = typeof entry === 'string' ? undefined : entry?.detail;
938
+ if (typeof claim !== 'string') continue;
507
939
  const normalized = normalizeClaim(claim);
508
940
  if (!normalized) continue;
509
- const hit = byKey.get(normalized);
941
+ const key = `${kind}\u0000${normalized}`;
942
+ const hit = byKey.get(key);
510
943
  if (hit) { hit.occurrences += 1; continue; }
511
- byKey.set(normalized, { claim, normalized, occurrences: 1 });
944
+ byKey.set(key, {
945
+ claim,
946
+ normalized,
947
+ occurrences: 1,
948
+ kind,
949
+ ...(detail !== undefined ? { detail } : {}),
950
+ });
512
951
  }
513
952
  return [...byKey.values()];
514
953
  }
515
954
 
955
+ /**
956
+ * ANY-MATCH test for a claimed pass count against the observed runs (R1).
957
+ *
958
+ * "Any" is the whole design: a report legitimately quotes ONE of several runs
959
+ * it made, so a claim is evidenced the moment a single observation carries its
960
+ * number. Only a claim that matches NONE of them is a mismatch.
961
+ *
962
+ * PARTIAL RUNS: two observations may be summed, because splitting a suite over
963
+ * two `npx vitest run <files>` invocations and reporting the total is normal
964
+ * here. NAMED CEILING (BV-004): PAIRS ONLY — three-way sums are not searched,
965
+ * since admitting them makes almost any number reachable from a handful of
966
+ * runs and the class stops discriminating. REVISIT TRIGGER: one documented
967
+ * report whose honest total is the sum of three separate runs.
968
+ *
969
+ * @param {number} claimedPassed
970
+ * @param {{passed: number}[]} observations
971
+ * @returns {boolean} true when some observation (or observation PAIR) carries it
972
+ */
973
+ function observationsCarryCount(claimedPassed, observations) {
974
+ for (const o of observations) {
975
+ if (o.passed === claimedPassed) return true;
976
+ }
977
+ for (let i = 0; i < observations.length; i++) {
978
+ for (let j = i + 1; j < observations.length; j++) {
979
+ if (observations[i].passed + observations[j].passed === claimedPassed) return true;
980
+ }
981
+ }
982
+ return false;
983
+ }
984
+
516
985
  /**
517
986
  * Scan concatenated transcript text for claims lacking an adjacent measurement
518
987
  * block (within ±GREP_PROXIMITY_LINES).
519
988
  *
989
+ * THREE claim classes, reported through one list and told apart by `kind`:
990
+ * - `distributional` (#567/#908/#1211) — "4 of 4 callers", "14 commits".
991
+ * Evidence must be ADJACENT (±GREP_PROXIMITY_LINES).
992
+ * - `gate-verdict` (w4-1) — "STATUS: done", "alles grün", "Tests: PASS".
993
+ * Evidence is an adjacent measurement command OR a RUN RECEIPT anywhere in
994
+ * the report (see RUN_RECEIPT_RE for why the scopes differ).
995
+ * - `claim-mismatch` (#1385 R1) — "5129 passed" where every vitest run in
996
+ * the window reported a different count. Evidence is the `tool_result`
997
+ * side of the transcript, supplied by the caller as `observations`.
998
+ *
520
999
  * @param {string} text
521
- * @returns {{ violations: {claim: string, normalized: string, occurrences: number}[], undatedVerified: number }}
1000
+ * @param {object} [opts]
1001
+ * @param {{passed: number, failed: number, total: number}[]} [opts.observations]
1002
+ * vitest run summaries from `readTranscriptObservations()`. DEFAULT EMPTY,
1003
+ * and an empty list disables the `claim-mismatch` class entirely — absence
1004
+ * of evidence is `gate-verdict`'s job, never this one's. (Measured
1005
+ * 2026-09-19: 488 of 503 transcripts carrying a count claim had at least one
1006
+ * vitest summary in the window, so the disabled case is the rare one.)
1007
+ * @returns {{ violations: {claim: string, normalized: string, occurrences: number, kind: string, detail?: object}[], undatedVerified: number }}
522
1008
  * `violations` — deduplicated, truncated claim snippets with an occurrence
523
- * count; `undatedVerified` — count of claims that ARE verified but carry no
524
- * measurement timestamp (advisory).
1009
+ * count and a claim-class `kind`; `undatedVerified` — count of DISTRIBUTIONAL
1010
+ * claims that ARE verified but carry no measurement timestamp (advisory).
525
1011
  */
526
- export function findViolations(text) {
1012
+ export function findViolations(text, opts = {}) {
527
1013
  if (!text) return { violations: [], undatedVerified: 0 };
1014
+ const observations = Array.isArray(opts.observations) ? opts.observations : [];
528
1015
  const lines = text.split(/\r?\n/);
529
1016
  const { measurementLines, fencedLines } = scanFences(lines);
530
1017
  const configLines = scanConfigBlocks(lines);
1018
+ // Report-wide, computed ONCE: the counted-result half of the gate class's
1019
+ // evidence test. Costs one regex pass over the tail, not one per line.
1020
+ const hasRunReceipt = RUN_RECEIPT_RE.test(text);
531
1021
  const raw = [];
532
1022
  let undatedVerified = 0;
533
1023
 
534
1024
  for (let i = 0; i < lines.length; i++) {
535
1025
  const line = lines[i];
536
- // #1198 FIX 2: gate-summary/STATUS lines are tool OUTPUT, not a claim —
537
- // skipped before any pattern runs (see GATE_SUMMARY_LINE_RE header).
1026
+ // #1198 FIX 3 (masking-order bug): mask inline-code spans ONCE, then test
1027
+ // every pattern against the masked text, so a claim quoted entirely inside
1028
+ // backticks (evidence or example text, not an assertion) cannot trip one.
1029
+ const masked = line.replace(INLINE_CODE_RE, ' ');
1030
+
1031
+ // --- gate/done-verdict class -------------------------------------------
1032
+ // Runs BEFORE the GATE_SUMMARY_LINE_RE skip BY DESIGN: that skip exists to
1033
+ // keep the DISTRIBUTIONAL patterns off harness gate output, and the two
1034
+ // line shapes it exempts are exactly the ones this class must see.
1035
+ if (
1036
+ !fencedLines.has(i) &&
1037
+ !NON_PROSE_NO_HEADING_RE.test(line) &&
1038
+ (DONE_STATUS_RE.test(masked) || GATE_GREEN_RE.test(masked)) &&
1039
+ !hasRunReceipt &&
1040
+ !nearIndex(measurementLines, i)
1041
+ ) {
1042
+ raw.push({ claim: line.trim().slice(0, CLAIM_TEXT_MAX), kind: KIND_GATE_VERDICT });
1043
+ }
1044
+
1045
+ // --- claim-mismatch class (#1385 R1) -----------------------------------
1046
+ // Also BEFORE the GATE_SUMMARY_LINE_RE skip, and for the same reason the
1047
+ // gate class is: that skip exempts exactly the `N passed / M failed` line
1048
+ // shape this class must read. The distributional patterns keep the skip
1049
+ // untouched (#1198 — 46.5% of a 400-event false-positive sample).
1050
+ //
1051
+ // FENCED LINES ARE NOT CLAIMS: a pasted run summary inside ``` is quoted
1052
+ // tool output. Scanning it would flag the honest report that quotes a run
1053
+ // older than the window, which is the expensive direction.
1054
+ if (
1055
+ observations.length > 0 &&
1056
+ !fencedLines.has(i) &&
1057
+ !NON_PROSE_NO_HEADING_RE.test(line) &&
1058
+ !COUNT_CLAIM_EXCLUSION_RES.some((re) => re.test(line))
1059
+ ) {
1060
+ // ANY-MATCH ON BOTH SIDES. A line names several counts routinely
1061
+ // (`2 passed files; 70 tests passed`, `40 passed (40) / Tests 1091
1062
+ // passed (1091)`), and treating the FIRST as "the" claim flagged
1063
+ // reports whose observed number sat later on the same line — 5 of the
1064
+ // first 12 corpus hits. The line is a mismatch only when NOT ONE of the
1065
+ // counts it names is carried by any observed run.
1066
+ const claimedCounts = extractClaimedCounts(masked);
1067
+ if (claimedCounts.length > 0) {
1068
+ if (!claimedCounts.some((n) => observationsCarryCount(n, observations))) {
1069
+ const failedMatch = FAILED_COUNT_RE.exec(masked);
1070
+ raw.push({
1071
+ claim: line.trim().slice(0, CLAIM_TEXT_MAX),
1072
+ kind: KIND_CLAIM_MISMATCH,
1073
+ detail: {
1074
+ mismatch: MISMATCH_COUNT,
1075
+ claimed: {
1076
+ passed: claimedCounts[0],
1077
+ failed: failedMatch ? Number(failedMatch[1]) : null,
1078
+ },
1079
+ // The three most recent runs — enough for the coordinator to see
1080
+ // WHAT was actually measured without copying the whole window
1081
+ // into the ledger. NO raw command text: precedent is `8f15f77b`
1082
+ // (`command_hash` instead of the raw command).
1083
+ observed: observations.slice(-3),
1084
+ observed_n: observations.length,
1085
+ },
1086
+ });
1087
+ }
1088
+ }
1089
+ }
1090
+
1091
+ // --- distributional class (unchanged) ----------------------------------
1092
+ // #1198 FIX 2: gate-summary/STATUS lines are tool OUTPUT, not a
1093
+ // distributional claim — skipped before any pattern runs.
538
1094
  if (GATE_SUMMARY_LINE_RE.test(line)) continue;
539
1095
  // #1218: table rows, headings and plan/intent items are not prose
540
1096
  // assertions at all — skipped before any pattern runs.
541
1097
  if (NON_PROSE_LINE_RE.test(line)) continue;
542
1098
 
543
- // #1198 FIX 3 (masking-order bug): mask inline-code spans ONCE, then test
544
- // BOTH the CLAIM_PATTERNS and the cardinal/ratio patterns against the
545
- // masked text, so a claim quoted entirely inside backticks (evidence or
546
- // example text, not an assertion) cannot trip a pattern.
547
- const masked = line.replace(INLINE_CODE_RE, ' ');
548
1099
  let matched = CLAIM_PATTERNS.some((re) => re.test(masked));
549
1100
  if (!matched && !fencedLines.has(i) && !configLines.has(i)) {
550
1101
  matched = CARDINAL_PATTERN.test(masked) || CARDINAL_RATIO_PATTERN.test(masked);
@@ -556,7 +1107,7 @@ export function findViolations(text) {
556
1107
  continue;
557
1108
  }
558
1109
 
559
- raw.push(line.trim().slice(0, CLAIM_TEXT_MAX));
1110
+ raw.push({ claim: line.trim().slice(0, CLAIM_TEXT_MAX), kind: KIND_DISTRIBUTIONAL });
560
1111
  }
561
1112
  return { violations: dedupeViolations(raw), undatedVerified };
562
1113
  }