session-orchestrator 3.24.0 → 4.0.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 (350) hide show
  1. package/.agents/skills/architecture/SKILL.md +18 -0
  2. package/.agents/skills/autopilot/SKILL.md +17 -0
  3. package/.agents/skills/bootstrap/SKILL.md +20 -0
  4. package/.agents/skills/brainstorm/SKILL.md +22 -0
  5. package/.agents/skills/claude-md-drift-check/SKILL.md +15 -0
  6. package/.agents/skills/convergence-monitoring/SKILL.md +22 -0
  7. package/.agents/skills/debug/SKILL.md +22 -0
  8. package/.agents/skills/discovery/SKILL.md +20 -0
  9. package/.agents/skills/dispatcher/SKILL.md +15 -0
  10. package/.agents/skills/docs-orchestrator/SKILL.md +18 -0
  11. package/.agents/skills/ecosystem-health/SKILL.md +20 -0
  12. package/.agents/skills/eli5/SKILL.md +20 -0
  13. package/.agents/skills/eval/SKILL.md +21 -0
  14. package/.agents/skills/evolve/SKILL.md +21 -0
  15. package/.agents/skills/frontmatter-guard/SKILL.md +15 -0
  16. package/.agents/skills/gitlab-ops/SKILL.md +20 -0
  17. package/.agents/skills/gitlab-portfolio/SKILL.md +15 -0
  18. package/.agents/skills/grill/SKILL.md +22 -0
  19. package/.agents/skills/hook-development/SKILL.md +15 -0
  20. package/.agents/skills/mcp-builder/SKILL.md +15 -0
  21. package/.agents/skills/memory-cleanup/SKILL.md +21 -0
  22. package/.agents/skills/mode-selector/SKILL.md +17 -0
  23. package/.agents/skills/npm-publish/SKILL.md +16 -0
  24. package/.agents/skills/peekaboo-driver/SKILL.md +18 -0
  25. package/.agents/skills/persona-panel/SKILL.md +17 -0
  26. package/.agents/skills/plan/SKILL.md +20 -0
  27. package/.agents/skills/playwright-driver/SKILL.md +20 -0
  28. package/.agents/skills/quality-gates/SKILL.md +20 -0
  29. package/.agents/skills/reconcile/SKILL.md +21 -0
  30. package/.agents/skills/remote-offload/SKILL.md +20 -0
  31. package/.agents/skills/repo-audit/SKILL.md +16 -0
  32. package/.agents/skills/session-end/SKILL.md +20 -0
  33. package/.agents/skills/session-plan/SKILL.md +20 -0
  34. package/.agents/skills/session-start/SKILL.md +20 -0
  35. package/.agents/skills/spinout/SKILL.md +16 -0
  36. package/.agents/skills/sunset-review/SKILL.md +16 -0
  37. package/.agents/skills/test-runner/SKILL.md +20 -0
  38. package/.agents/skills/tmux-layout/SKILL.md +21 -0
  39. package/.agents/skills/using-orchestrator/SKILL.md +17 -0
  40. package/.agents/skills/vault-mirror/SKILL.md +15 -0
  41. package/.agents/skills/vault-sync/SKILL.md +15 -0
  42. package/.agents/skills/wave-executor/SKILL.md +20 -0
  43. package/.agents/skills/write-executable-plan/SKILL.md +22 -0
  44. package/.claude-plugin/marketplace.json +1 -1
  45. package/.claude-plugin/plugin.json +1 -1
  46. package/.codex-plugin/plugin.json +1 -1
  47. package/.cursor/commands/autopilot.md +2 -2
  48. package/.cursor/commands/bootstrap.md +1 -1
  49. package/.cursor/commands/brainstorm.md +1 -1
  50. package/.cursor/commands/debug.md +1 -1
  51. package/.cursor/commands/discovery.md +1 -1
  52. package/.cursor/commands/dispatcher.md +2 -2
  53. package/.cursor/commands/eli5.md +2 -2
  54. package/.cursor/commands/eval.md +2 -2
  55. package/.cursor/commands/evolve.md +1 -1
  56. package/.cursor/commands/go.md +1 -1
  57. package/.cursor/commands/grill.md +2 -2
  58. package/.cursor/commands/memory-cleanup.md +2 -2
  59. package/.cursor/commands/persona-panel.md +1 -1
  60. package/.cursor/commands/plan.md +1 -1
  61. package/.cursor/commands/portfolio.md +1 -1
  62. package/.cursor/commands/reconcile.md +2 -2
  63. package/.cursor/commands/release.md +2 -2
  64. package/.cursor/commands/session.md +2 -2
  65. package/.cursor/commands/spinout.md +2 -2
  66. package/.cursor/commands/sunset-review.md +2 -2
  67. package/.cursor/commands/templates-ack.md +2 -2
  68. package/.cursor/commands/test.md +2 -2
  69. package/.cursor/skills/brainstorm/SKILL.md +1 -1
  70. package/.cursor/skills/eval/SKILL.md +1 -1
  71. package/.cursor/skills/quality-gates/SKILL.md +1 -1
  72. package/.cursor/skills/remote-offload/SKILL.md +1 -1
  73. package/.orchestrator/policy/blocked-commands.json +121 -0
  74. package/.orchestrator/policy/ecosystem.schema.json +66 -0
  75. package/.orchestrator/policy/quality-gates.example.json +16 -0
  76. package/.orchestrator/policy/quality-gates.schema.json +38 -0
  77. package/.orchestrator/policy/templates-policy.json +27 -0
  78. package/.orchestrator/policy/test-profiles.json +47 -0
  79. package/AGENTS.md +225 -0
  80. package/CHANGELOG.md +1125 -2
  81. package/NOTICE +11 -6
  82. package/README.md +127 -94
  83. package/agents/eval-judge.md +1 -1
  84. package/agents/skill-applied-judge.md +1 -1
  85. package/assets/wave-lifecycle.svg +98 -0
  86. package/commands/release.md +6 -3
  87. package/commands/session.md +18 -3
  88. package/docs/README.md +4 -0
  89. package/{agents/AGENTS.md → docs/agent-authoring.md} +19 -26
  90. package/docs/baseline.md +67 -0
  91. package/docs/ci-setup.md +108 -62
  92. package/docs/codex-setup.md +65 -21
  93. package/docs/components.md +36 -15
  94. package/docs/cursor-setup.md +6 -2
  95. package/docs/events-schema.md +9 -6
  96. package/docs/instruction-delivery.md +62 -0
  97. package/{agents/memory-proposal-collector.md → docs/memory-proposal-flow.md} +1 -8
  98. package/docs/migration-v4.md +341 -0
  99. package/docs/pi-setup.md +6 -1
  100. package/docs/plugin-architecture-v3.md +1 -1
  101. package/docs/rule-authoring.md +85 -19
  102. package/docs/scope-collision-guard.md +5 -5
  103. package/docs/session-config-reference.md +57 -56
  104. package/docs/session-config-template.md +6 -29
  105. package/docs/telemetry.md +157 -3
  106. package/docs/vault-docs-architecture.md +50 -11
  107. package/hooks/_lib/hook-import-set.json +1487 -0
  108. package/hooks/_lib/subagent-transcript.mjs +562 -0
  109. package/hooks/config-protection.mjs +2 -2
  110. package/hooks/cwd-change-restore.mjs +2 -2
  111. package/hooks/enforce-commands.mjs +69 -0
  112. package/hooks/hooks-codex.json +1 -1
  113. package/hooks/hooks-cursor.json +10 -0
  114. package/hooks/hooks-pi.json +5 -0
  115. package/hooks/hooks.json +6 -1
  116. package/hooks/loop-guard.mjs +3 -3
  117. package/hooks/on-session-end.mjs +2 -2
  118. package/hooks/on-session-start.mjs +103 -2
  119. package/hooks/on-stop.mjs +36 -11
  120. package/hooks/operator-steer.mjs +2 -2
  121. package/hooks/post-bash-write-verify.mjs +85 -0
  122. package/hooks/post-edit-import-probe.mjs +344 -0
  123. package/hooks/post-subagent-discovery-validator.mjs +187 -431
  124. package/hooks/post-tool-batch-wave-signal.mjs +118 -4
  125. package/hooks/post-tool-failure-corrective-context.mjs +2 -2
  126. package/hooks/post-tooluse-frontend-slop.mjs +3 -3
  127. package/hooks/pre-bash-destructive-guard.mjs +39 -13
  128. package/hooks/skill-invocation-telemetry.mjs +17 -5
  129. package/hooks/subagent-telemetry.mjs +13 -4
  130. package/monitors/monitors.json +3 -3
  131. package/package.json +9 -1
  132. package/pi/prompts/session.md +2 -2
  133. package/plugin.json +27 -0
  134. package/scripts/backfill-abandoned-sessions.mjs +50 -4
  135. package/scripts/backfill-learnings-from-vault.mjs +9 -3
  136. package/scripts/dialectic-deriver.mjs +73 -8
  137. package/scripts/export-hw-learnings.mjs +113 -1
  138. package/scripts/generate-agents-skills.mjs +378 -0
  139. package/scripts/generate-cursor-adapter.mjs +45 -8
  140. package/scripts/generate-hook-import-set.mjs +249 -0
  141. package/scripts/lib/agent-status.mjs +13 -2
  142. package/scripts/lib/auto-dream.mjs +38 -36
  143. package/scripts/lib/autonomy/suitability.mjs +6 -0
  144. package/scripts/lib/autopilot/loop.mjs +2 -2
  145. package/scripts/lib/ci-status-banner.mjs +220 -75
  146. package/scripts/lib/codex/plugin-contract.mjs +82 -6
  147. package/scripts/lib/config/auto-dream.mjs +2 -1
  148. package/scripts/lib/config/block-header.mjs +8 -0
  149. package/scripts/lib/config/block-preprocess.mjs +177 -0
  150. package/scripts/lib/config/broken-window.mjs +2 -1
  151. package/scripts/lib/config/cold-start.mjs +2 -1
  152. package/scripts/lib/config/config-protection.mjs +22 -2
  153. package/scripts/lib/config/context-coverage.mjs +2 -1
  154. package/scripts/lib/config/cross-repo.mjs +2 -1
  155. package/scripts/lib/config/custom-phases.mjs +2 -1
  156. package/scripts/lib/config/dialectic.mjs +2 -1
  157. package/scripts/lib/config/discovery-validator.mjs +2 -1
  158. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +24 -1
  159. package/scripts/lib/config/dispatcher-autonomy.mjs +2 -1
  160. package/scripts/lib/config/docs-orchestrator.mjs +2 -1
  161. package/scripts/lib/config/docs-staleness.mjs +2 -1
  162. package/scripts/lib/config/drift-check.mjs +2 -1
  163. package/scripts/lib/config/eval.mjs +2 -1
  164. package/scripts/lib/config/events-rotation.mjs +2 -1
  165. package/scripts/lib/config/evolve.mjs +8 -2
  166. package/scripts/lib/config/frontend-slop-hook.mjs +7 -3
  167. package/scripts/lib/config/gitlab-portfolio.mjs +2 -1
  168. package/scripts/lib/config/handover-gate.mjs +2 -1
  169. package/scripts/lib/config/health-endpoints.mjs +7 -2
  170. package/scripts/lib/config/issue-budget.mjs +2 -1
  171. package/scripts/lib/config/loop-guard.mjs +2 -1
  172. package/scripts/lib/config/memory.mjs +2 -1
  173. package/scripts/lib/config/moc-staleness.mjs +2 -1
  174. package/scripts/lib/config/persona-gate-wave.mjs +2 -1
  175. package/scripts/lib/config/private-config-dir.mjs +67 -0
  176. package/scripts/lib/config/reconcile.mjs +2 -1
  177. package/scripts/lib/config/remote-hosts.mjs +2 -1
  178. package/scripts/lib/config/section-extractor.mjs +7 -1
  179. package/scripts/lib/config/skill-evolution.mjs +2 -1
  180. package/scripts/lib/config/slopcheck.mjs +2 -1
  181. package/scripts/lib/config/state-md-lock.mjs +2 -1
  182. package/scripts/lib/config/templates-first.mjs +2 -1
  183. package/scripts/lib/config/test.mjs +2 -1
  184. package/scripts/lib/config/vault-integration.mjs +7 -1
  185. package/scripts/lib/config/vault-mirror-quality.mjs +2 -1
  186. package/scripts/lib/config/vault-staleness.mjs +2 -1
  187. package/scripts/lib/config/vault-sync.mjs +2 -1
  188. package/scripts/lib/config/verification-auto-fix.mjs +2 -1
  189. package/scripts/lib/config/wave-reviewers.mjs +2 -1
  190. package/scripts/lib/config/worktree-orphans.mjs +2 -1
  191. package/scripts/lib/convergence-monitor.mjs +82 -16
  192. package/scripts/lib/dispatcher/rank.mjs +124 -48
  193. package/scripts/lib/ecosystem-health.mjs +16 -2
  194. package/scripts/lib/eval/engine.mjs +9 -1
  195. package/scripts/lib/eval/session-resolve.mjs +23 -4
  196. package/scripts/lib/events.mjs +22 -6
  197. package/scripts/lib/frontmatter-guard.mjs +131 -13
  198. package/scripts/lib/gates/gate-full.mjs +26 -0
  199. package/scripts/lib/gates/gate-helpers.mjs +76 -0
  200. package/scripts/lib/hardware-pattern-detector.mjs +18 -1
  201. package/scripts/lib/harness-audit/categories/category4.mjs +31 -11
  202. package/scripts/lib/host-identity.mjs +50 -11
  203. package/scripts/lib/instruction-budget-guard.mjs +171 -5
  204. package/scripts/lib/learnings/evolve-telemetry.mjs +178 -0
  205. package/scripts/lib/learnings/io.mjs +60 -6
  206. package/scripts/lib/memory-proposals/store.mjs +30 -22
  207. package/scripts/lib/owner-config-banner.mjs +43 -6
  208. package/scripts/lib/owner-config-loader.mjs +21 -10
  209. package/scripts/lib/owner-interview.mjs +3 -3
  210. package/scripts/lib/owner-yaml.mjs +207 -14
  211. package/scripts/lib/platform.mjs +108 -15
  212. package/scripts/lib/plugin-update-banner.mjs +406 -0
  213. package/scripts/lib/project-hygiene.mjs +38 -2
  214. package/scripts/lib/qg-command-drift-banner.mjs +50 -12
  215. package/scripts/lib/quality-gate.mjs +133 -44
  216. package/scripts/lib/reconcile/emitter.mjs +68 -6
  217. package/scripts/lib/reconcile/engine.mjs +13 -4
  218. package/scripts/lib/reconcile/idempotency.mjs +37 -4
  219. package/scripts/lib/reconcile/writer.mjs +40 -18
  220. package/scripts/lib/session-close-backfill.mjs +67 -9
  221. package/scripts/lib/session-id.mjs +12 -23
  222. package/scripts/lib/session-identity/own-session.mjs +125 -10
  223. package/scripts/lib/session-lock-shape.mjs +43 -0
  224. package/scripts/lib/session-lock.mjs +5 -10
  225. package/scripts/lib/session-registry.mjs +25 -9
  226. package/scripts/lib/session-schema/constants.mjs +36 -2
  227. package/scripts/lib/session-schema/validator.mjs +38 -4
  228. package/scripts/lib/session-start-probes.mjs +18 -1
  229. package/scripts/lib/sessions-staleness-banner.mjs +18 -11
  230. package/scripts/lib/skill-health/join.mjs +17 -4
  231. package/scripts/lib/state-md.mjs +78 -0
  232. package/scripts/lib/sunset/walker.mjs +6 -0
  233. package/scripts/lib/telemetry/schema.mjs +181 -9
  234. package/scripts/lib/telemetry/sync.mjs +368 -12
  235. package/scripts/lib/validate/check-agents-skills.mjs +327 -0
  236. package/scripts/lib/validate/check-agents.mjs +3 -3
  237. package/scripts/lib/validate/check-cursor-adapter.mjs +234 -72
  238. package/scripts/lib/validate/check-hooks-symmetry.mjs +45 -16
  239. package/scripts/lib/validate/check-owner-leakage.mjs +281 -20
  240. package/scripts/lib/validate/check-skill-links.mjs +163 -0
  241. package/scripts/lib/validate/check-skill-script-paths.mjs +47 -28
  242. package/scripts/lib/validate/check-unwired-features.mjs +0 -2
  243. package/scripts/lib/validate/check-validator-registration.mjs +10 -4
  244. package/scripts/lib/validate/enumerate-repo-files.mjs +317 -0
  245. package/scripts/lib/vault-backfill/template.mjs +63 -6
  246. package/scripts/lib/vault-mirror/process.mjs +165 -42
  247. package/scripts/lib/vault-mirror/telemetry.mjs +2 -2
  248. package/scripts/lib/vault-status/narrative-mirror.mjs +127 -18
  249. package/scripts/lib/wave-executor/dispatch-common.mjs +164 -0
  250. package/scripts/lib/wave-executor/foreign-dispatch.mjs +7 -142
  251. package/scripts/lib/wave-executor/remote-dispatch.mjs +5 -7
  252. package/scripts/lib/wave-resource-gate.mjs +8 -2
  253. package/scripts/lib/wave-sizing.mjs +4 -1
  254. package/scripts/lib/wave-transcript-tail.mjs +118 -4
  255. package/scripts/materialize-wave-scope.mjs +12 -5
  256. package/scripts/memory-propose.mjs +19 -5
  257. package/scripts/migrate-cold-start-seed.mjs +4 -1
  258. package/scripts/parse-config.mjs +60 -3
  259. package/scripts/release.mjs +337 -29
  260. package/scripts/repair-invalid-sessions.mjs +3 -3
  261. package/scripts/run-quality-gate.mjs +128 -11
  262. package/scripts/sweep-expired-learnings.mjs +90 -0
  263. package/scripts/sync-vault-schema.mjs +3 -1
  264. package/scripts/telemetry.mjs +2 -2
  265. package/scripts/validate-plugin.mjs +161 -0
  266. package/scripts/validate-wave-scope.mjs +28 -8
  267. package/scripts/wave-scope-binding.mjs +215 -0
  268. package/skills/_shared/instruction-file-resolution.md +10 -0
  269. package/skills/_shared/parallel-aware-preamble.md +1 -0
  270. package/skills/_shared/platform-tools.md +1 -1
  271. package/skills/_shared/state-ownership.md +1 -1
  272. package/skills/architecture/SKILL.md +7 -5
  273. package/skills/{domain-model/SKILL.md → architecture/references/domain-model.md} +9 -9
  274. package/skills/autopilot/SKILL.md +4 -18
  275. package/skills/claude-md-drift-check/SKILL.md +5 -1
  276. package/skills/claude-md-drift-check/checker.mjs +62 -2
  277. package/skills/convergence-monitoring/SIGNALS.md +55 -0
  278. package/skills/discovery/probes/vault-staleness.mjs +37 -13
  279. package/skills/discovery/probes-arch.md +20 -18
  280. package/skills/dispatcher/SKILL.md +3 -2
  281. package/skills/evolve/SKILL.md +65 -26
  282. package/skills/frontmatter-guard/SKILL.md +11 -5
  283. package/skills/npm-publish/SKILL.md +1 -1
  284. package/skills/reconcile/SKILL.md +33 -0
  285. package/skills/remote-offload/SKILL.md +1 -1
  286. package/skills/session-end/SKILL.md +18 -905
  287. package/skills/session-end/phase-3-6-tail.md +10 -3
  288. package/skills/session-end/plan-verification.md +221 -155
  289. package/skills/session-end/references/phase-2-quality-gate.md +93 -0
  290. package/skills/session-end/references/phase-3-documentation-updates.md +229 -0
  291. package/skills/session-end/references/phase-4a-worktree-cleanup.md +120 -0
  292. package/skills/session-end/references/phase-4b-worktree-orphan-sweep.md +58 -0
  293. package/skills/session-end/references/phase-5-issue-cleanup.md +104 -0
  294. package/skills/session-end/references/session-summary-template.md +62 -0
  295. package/skills/session-plan/SKILL.md +49 -0
  296. package/skills/session-start/SKILL.md +22 -904
  297. package/skills/session-start/phase-8-5-express-path.md +1 -1
  298. package/skills/session-start/references/phase-1-1-dispatcher-autonomy-capture.md +55 -0
  299. package/skills/session-start/references/phase-1-2-session-lock.md +140 -0
  300. package/skills/session-start/references/phase-1-5-session-continuity.md +254 -0
  301. package/skills/session-start/references/phase-1-7-vault-status-board.md +53 -0
  302. package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +75 -0
  303. package/skills/session-start/references/phase-4-ssot-environment-check.md +155 -0
  304. package/skills/session-start/references/phase-6-5-forced-reads.md +75 -0
  305. package/skills/session-start/references/phase-6-6-project-intelligence.md +81 -0
  306. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +103 -0
  307. package/skills/vault-sync/validator.mjs +21 -27
  308. package/skills/wave-executor/SKILL.md +15 -1
  309. package/skills/wave-executor/references/wave-loop-dispatch.md +612 -0
  310. package/skills/wave-executor/references/wave-loop-review.md +570 -0
  311. package/skills/wave-executor/references/wave-loop-scope-manifest.md +162 -0
  312. package/skills/wave-executor/wave-loop.md +14 -1309
  313. package/templates/_shared/journey-manifest.md +10 -6
  314. package/.cursor/commands/autopilot-multi.md +0 -14
  315. package/.cursor/commands/contract-version-bump.md +0 -14
  316. package/.cursor/commands/journey-audit.md +0 -14
  317. package/.cursor/skills/contract-version-bump/SKILL.md +0 -12
  318. package/.cursor/skills/daily/SKILL.md +0 -12
  319. package/.cursor/skills/domain-model/SKILL.md +0 -13
  320. package/.cursor/skills/journey-audit/SKILL.md +0 -13
  321. package/.cursor/skills/skill-creator/SKILL.md +0 -13
  322. package/.cursor/skills/ubiquitous-language/SKILL.md +0 -13
  323. package/commands/autopilot-multi.md +0 -74
  324. package/commands/contract-version-bump.md +0 -28
  325. package/commands/journey-audit.md +0 -43
  326. package/pi/prompts/autopilot-multi.md +0 -12
  327. package/pi/prompts/contract-version-bump.md +0 -12
  328. package/pi/prompts/journey-audit.md +0 -12
  329. package/scripts/autopilot-multi.mjs +0 -885
  330. package/scripts/backfill-learnings-expires.mjs +0 -196
  331. package/scripts/backfill-learnings.mjs +0 -203
  332. package/scripts/fleet-instruction-scan.mjs +0 -141
  333. package/scripts/lib/autopilot/dep-graph.mjs +0 -417
  334. package/scripts/lib/autopilot/multi-killswitch.mjs +0 -184
  335. package/scripts/lib/webhook-url.mjs +0 -105
  336. package/scripts/lifecycle-sim-v6.mjs +0 -347
  337. package/scripts/migrate-learnings-jsonl.mjs +0 -189
  338. package/scripts/migrate-subagents-jsonl.mjs +0 -196
  339. package/scripts/upload-social-preview.mjs +0 -316
  340. package/skills/_shared/model-selection.md +0 -64
  341. package/skills/contract-version-bump/SKILL.md +0 -219
  342. package/skills/daily/SKILL.md +0 -222
  343. package/skills/daily/generate.sh +0 -92
  344. package/skills/daily/templates/daily.md.tpl +0 -36
  345. package/skills/journey-audit/SKILL.md +0 -270
  346. package/skills/skill-creator/SKILL.md +0 -168
  347. package/skills/ubiquitous-language/SKILL.md +0 -97
  348. package/skills/vault-sync/package-lock.json +0 -40
  349. /package/skills/{domain-model → architecture/references}/ADR-FORMAT.md +0 -0
  350. /package/skills/{domain-model → architecture/references}/CONTEXT-FORMAT.md +0 -0
@@ -18,18 +18,18 @@
18
18
  * TAIL (last ~8 `type:"assistant"` records), concat text blocks. When no
19
19
  * `agent_id` is derivable (or the file is absent) the hook exits 0 and
20
20
  * records NOTHING — see the scope note below.
21
- * 5. Regex-scan the concatenated text for 8 claim patterns — 6 quantifier-
22
- * triggered distributional claims, the #908 bare-cardinal repo-state
23
- * fact ("14 commits", "92 learnings", "5 dirty files", "412 lines"), and
24
- * the #918 numerator/denominator slash form ("12/14 files", "4/4 callers").
25
- * 6. For each match, check whether a fenced ```bash block containing a
26
- * MEASUREMENT command (grep/rg/find/git/wc/jq/ls/node/npm) appears within
27
- * ±5 lines. If a claim has NO adjacent measurement block → record a
28
- * `discovery_validator_violation` event in events.jsonl + a stderr WARN.
29
- * 7. ADVISORY (#908 Baustein 2 input): for claims that ARE verified, check
30
- * whether the adjacent block also carries a measurement TIMESTAMP (ISO
31
- * date, `HEAD`, "as of", "measured at"). Undated-but-verified claims are
32
- * counted and reported in the warn text — they are NOT violations in v1.
21
+ * 5. Hand the concatenated text to `hooks/_lib/subagent-transcript.mjs`
22
+ * `findViolations()` the whole matcher (claim patterns, negative-context
23
+ * guards, evidence proximity, normalisation, dedup) lives there so it can
24
+ * be measured against a claim corpus without spawning this hook. It
25
+ * returns DEDUPLICATED `{claim, normalized, occurrences}` records.
26
+ * 6. Attribute the claim: `agent` + `agent_source` (`payload`|`meta`|`none`)
27
+ * + `agent_description`, and on `none` the sorted stdin `payload_keys`
28
+ * the harness DID send, so a gap is diagnosable from the ledger.
29
+ * 7. Write ONE record per distinct claim per session (session-scoped tmp
30
+ * sentinel), plus a stderr WARN. ADVISORY (#908 Baustein 2 input): claims
31
+ * that ARE verified but carry no measurement TIMESTAMP are counted and
32
+ * reported in the warn text — never recorded as violations in v1.
33
33
  *
34
34
  * Why read a transcript at all: the SubagentStop stdin payload has NO
35
35
  * output_text field — the agent's text only exists on disk.
@@ -71,215 +71,12 @@ import { tmpdir } from 'node:os';
71
71
  import path from 'node:path';
72
72
 
73
73
  import { resolveSubagentSidecar } from './_lib/subagent-paths.mjs';
74
+ import { findViolations, readTranscriptTail } from './_lib/subagent-transcript.mjs';
74
75
  import { appendJsonl } from '../scripts/lib/common.mjs';
75
76
  import { eventsFilePath } from '../scripts/lib/events.mjs';
76
- import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
77
+ import { getProjectDir } from '../scripts/lib/platform.mjs';
77
78
  import { _parseDiscoveryValidator } from '../scripts/lib/config/discovery-validator.mjs';
78
79
 
79
- // ---------------------------------------------------------------------------
80
- // Constants
81
- // ---------------------------------------------------------------------------
82
-
83
- /** Number of trailing assistant records to scan. */
84
- const TAIL_RECORDS = 8;
85
- /** Proximity window (in lines) for an adjacent grep transcript. */
86
- const GREP_PROXIMITY_LINES = 5;
87
- /** Max characters of claim text persisted to the event record. */
88
- const CLAIM_TEXT_MAX = 200;
89
-
90
- /**
91
- * Code-distribution context nouns (singular or plural). A distributional claim
92
- * is only a PSA-006 *code* claim when one of these appears near the trigger —
93
- * this is what separates "4 of 4 callers" (a real claim) from "Turn 3 of 25"
94
- * (a turn counter) and "every caller imports X" from "every developer should
95
- * test" (generic advice). Tightening per W2-review LOW finding (#567).
96
- *
97
- * Single optional `\s?` only (call site / callsite) — no nested quantifiers,
98
- * so the alternation stays linear-time / ReDoS-safe.
99
- */
100
- const CTX = '(?:call\\s?sites?|callers?|sites?|references?|instances?|files?|consumers?|imports?|matches|match|occurrences?|usages?|modules?|tests?|places?|functions?|dependenc(?:y|ies)|endpoints?|hooks?)';
101
-
102
- /**
103
- * Repo-STATE nouns (#908). The four documented #908 drift cases were counts of
104
- * repository state, not of code locations: "14 commits", "92 learnings",
105
- * "5 dirty files", "412 lines". None of them contains a CTX noun, so the
106
- * original six patterns could not see them.
107
- *
108
- * Vocabulary taken from this repo's own artefacts (`.orchestrator/metrics/*.jsonl`
109
- * record kinds, `.claude/rules/`, `skills/`, `agents/`, `hooks/`) rather than a
110
- * generic English list — a noun that never names a countable repo artefact here
111
- * only buys false positives.
112
- */
113
- const STATE = '(?:commits?|learnings?|issues?|branches?|lines?|entries|records?|sessions?|rules?|skills?|probes?|waves?|proposals?|worktrees?)';
114
-
115
- /**
116
- * The noun class the BARE-CARDINAL pattern may use — deliberately a strict
117
- * subset of STATE ∪ CTX, restricted to the artefact kinds the #908 drift was
118
- * actually measured in (commit counts, learnings counts, open-issue counts,
119
- * branch counts, line counts, dirty-file counts).
120
- *
121
- * Measured, not guessed: over 32 real agent-stop windows from this repo's own
122
- * transcripts, admitting the full CTX ∪ STATE set fired 93 times (2.9 per
123
- * stop — the "validator gets switched off" zone). Every noun below earns its
124
- * place by naming one of the documented #908 facts; the ones that only cost
125
- * false positives (`tests`, `references`, `matches`, `agents`, `files` without
126
- * a state adjective) are excluded here and remain reachable through the six
127
- * quantifier-triggered patterns above, which have a lexical anchor.
128
- *
129
- * `callers` (#918): PSA-006's own canonical noun — the rule text's worked
130
- * examples ("4 of 4 callers", "100% of callers opt-in") all count callers, yet
131
- * the cardinal pattern could not see a bare "14 callers". Re-measured with it
132
- * admitted: 490 real SubagentStop transcripts (2026-07-31), +0 additional
133
- * firings from the bare form — the noun is free on this corpus (all +3 delta
134
- * firings came from the #918 slash pattern below).
135
- */
136
- const CARDINAL_NOUN = '(?:commits?|learnings?|issues?|branches?|lines?|files?|callers?)';
137
-
138
- /**
139
- * Wide noun class = code-distribution nouns ∪ repo-state nouns. Used by the six
140
- * QUANTIFIER-triggered patterns ("N of M", "100% of", "all N", "no remaining",
141
- * "none of") — each of those carries a strong lexical trigger, so widening the
142
- * noun set there is low-risk.
143
- *
144
- * Deliberately NOT used by the `every <noun>` pattern: `every` has no numeric
145
- * anchor, so `every commit must be signed` / `every rule is always-on` are
146
- * ordinary prose, not measured claims. That pattern keeps the narrow CTX.
147
- */
148
- const WIDE = `(?:${CTX}|${STATE})`;
149
-
150
- /** Bounded same-line gap between a trigger and its context noun. */
151
- const CTX_GAP = '[^\\n]{0,40}?';
152
-
153
- /**
154
- * Distributional-claim patterns (case-insensitive). A match is a PSA-006 claim
155
- * that requires an adjacent measurement transcript.
156
- *
157
- * Each pattern requires a context noun within a small, bounded same-line window
158
- * of the trigger. The `[^\n]{0,40}?` gap is a bounded lazy character class
159
- * (linear-time — the ReDoS-safety the W2 reviewer verified is preserved). True
160
- * claims ("4 of 4 callers opt-in", "every caller imports X", "no remaining
161
- * references to Y") still flag; benign strings ("Turn 3 of 25 complete",
162
- * "every developer should test", "100% of users love it") do not.
163
- */
164
- const CLAIM_PATTERNS = [
165
- new RegExp(`\\b\\d+ of \\d+\\b${CTX_GAP}\\b${WIDE}\\b`, 'i'),
166
- new RegExp(`100% of\\b${CTX_GAP}\\b${WIDE}\\b`, 'i'),
167
- new RegExp(`\\ball \\d+\\b${CTX_GAP}\\b${WIDE}\\b`, 'i'),
168
- new RegExp(`no remaining\\b${CTX_GAP}\\b${WIDE}\\b`, 'i'),
169
- new RegExp(`every ${CTX}\\b`, 'i'),
170
- new RegExp(`none of\\b${CTX_GAP}\\b${WIDE}\\b`, 'i'),
171
- ];
172
-
173
- /**
174
- * Pattern 7 (#908) — the BARE CARDINAL repo-state fact. `14 commits` has no
175
- * quantifier trigger at all, which is exactly why the #908 drift went unseen.
176
- *
177
- * A naive `\d+` would fire on every issue reference, version literal, date,
178
- * line number and percentage in a normal report — and a validator that fires on
179
- * every report gets switched off, which is strictly worse than no validator.
180
- * Precision is therefore bought three ways:
181
- *
182
- * 1. TRIGGER: a digit run that is not glued to identifier punctuation.
183
- * Lookbehind rejects `#906`, `v3`, `PSA-006`, `W2`, `foo.mjs:123`;
184
- * lookahead rejects `3.17`, `70%`, `2026-07-29`, `12615/0`.
185
- * 2. GAP: at most two intervening ADJECTIVE-like words, and never a
186
- * preposition/article/copula. "5 dirty files" matches; "3 of 5 stars" and
187
- * "2 sections below the rules" do not.
188
- * 3. SCOPE: evaluated only on prose lines with inline-code spans masked out —
189
- * fenced blocks are skipped entirely (see `findViolations`), because a
190
- * number inside a fence is tool OUTPUT (the evidence itself), not an
191
- * unverified assertion about it.
192
- *
193
- * All quantifiers are bounded ({1,9}, {0,2}) — linear-time, ReDoS-safe.
194
- */
195
- const CARDINAL_TRIGGER = '(?<![\\w#$:/.-])\\d{1,9}(?![\\d.%:/-])';
196
- const CARDINAL_STOPWORDS =
197
- 'of|in|on|at|for|to|the|a|an|and|or|is|are|was|were|from|with|by|that|than|per|out|over|into|onto|via|but|as';
198
- const CARDINAL_GAP = `(?:\\s+(?!(?:${CARDINAL_STOPWORDS})\\b)[A-Za-z][\\w-]*){0,2}`;
199
- const CARDINAL_PATTERN = new RegExp(`${CARDINAL_TRIGGER}${CARDINAL_GAP}\\s+${CARDINAL_NOUN}\\b`, 'i');
200
-
201
- /**
202
- * Pattern 8 (#918) — the PSA-006 CANONICAL numerator/denominator slash form:
203
- * "12/14 files", "4/4 callers". PSA-006 literally demands "Quote the numerator
204
- * AND denominator", and `N/M` is the notation that demand produces — yet the
205
- * cardinal trigger's lookahead `(?![\d.%:/-])` excludes the slash, making the
206
- * rule's own canon form structurally invisible (#918).
207
- *
208
- * Admitting the slash re-opens the `12615/0/11` gate-summary class the
209
- * lookahead exists to suppress, so the slash form gets its OWN pattern with a
210
- * STRICTER contract than the bare cardinal:
211
- *
212
- * 1. RATIO: exactly two slash-joined numbers. Both boundary guards stay:
213
- * the lookbehind rejects a numerator glued to a path/id (`hooks/12/14`),
214
- * the lookahead after the DENOMINATOR rejects a third slash-segment —
215
- * `12615/0/11` fails twice over (denominator `0` is followed by `/`, and
216
- * the trailing pair `0/11` has a `/`-glued numerator).
217
- * 2. NOUN IMMEDIATELY AFTER — no CARDINAL_GAP. The noun-after-denominator is
218
- * the discriminator that separates a measured ratio ("12/14 files") from
219
- * a bare slash pair that is a date or score ("on 12/14 we shipped",
220
- * "rated 3/5 overall"): those are followed by anything BUT an artefact
221
- * noun. Widening to the gapped form would admit US-date + adjective +
222
- * noun collisions with no PSA-006 payoff.
223
- *
224
- * FP re-measured on the CORRECT text sort (#918 requirement — the prior 1.07
225
- * rate was measured on coordinator narration, not on what this hook reads):
226
- * 490 real SubagentStop subagent transcripts (`~/.claude/projects/<slug>/<session>/
227
- * subagents/*.jsonl`), spawning THIS hook per transcript, 2026-07-31.
228
- * Baseline (pre-#918): 345 firings / 0.7041 per stop. With slash form +
229
- * `callers` admitted: 348 / 0.7102 — +3 firings, of which 1 is a true positive
230
- * ("0/49 Learnings mit Allow-List-Typ", an unverified canon-form claim), 1
231
- * quotes the #918 example sentence itself (mention-not-use), and 1 is a
232
- * before/after line-count pair ("858/857 lines", class-consistent with the
233
- * bare "412 lines" behaviour of pattern 7).
234
- *
235
- * Bounded quantifiers only ({1,9}) — linear-time, ReDoS-safe.
236
- */
237
- const CARDINAL_RATIO_PATTERN = new RegExp(
238
- `(?<![\\w#$:/.-])\\d{1,9}/\\d{1,9}(?![\\d.%:/-])\\s+${CARDINAL_NOUN}\\b`,
239
- 'i'
240
- );
241
-
242
- /** Inline-code spans are masked before the cardinal pattern runs. */
243
- const INLINE_CODE_RE = /`[^`\n]*`/g;
244
-
245
- /**
246
- * Gate-summary / STATUS-report lines are the HARNESS's own completion output —
247
- * tool evidence, not an unverified assertion about the codebase — yet carried
248
- * no exemption of their own: they were caught only incidentally, by whichever
249
- * individual claim pattern happened not to fire on that exact phrasing. GitLab
250
- * #1198 (Discovery D8, 2026-09-02): a 400-event sample of
251
- * `discovery_validator_violation` records showed 186/400 (46.5%) firing on
252
- * this exact class — the single largest false-positive source measured to
253
- * date. Skipped at the LINE level, before ANY pattern runs, rather than by
254
- * narrowing `CARDINAL_NOUN`: narrowing the noun set would ALSO silence a true
255
- * "N files" claim appearing outside a gate-summary line, which is not this
256
- * bug's fix.
257
- *
258
- * Four alternatives cover the measured shapes: an N-passed/M-failed count
259
- * ("14904 passed / 0 failed"), a `STATUS:` report line (`skills/wave-executor`
260
- * agent-report convention), a "Full Gate" heading, and a `Gate: <verdict>`
261
- * summary line (English "typecheck" or German "grün"/"rot").
262
- */
263
- const GATE_SUMMARY_LINE_RE =
264
- /\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|rot)\b/i;
265
-
266
- /**
267
- * Commands that count as a MEASUREMENT inside a fenced block. `grep|rg|find`
268
- * (the #567 set) only covers text search; the #908 facts are measured with
269
- * `git log --oneline | wc -l`, `jq` over a JSONL metrics file, `ls | wc -l`,
270
- * or a `node`/`npm` script. Refusing to recognise those made the honest,
271
- * evidence-quoting path fail verification.
272
- */
273
- const MEASUREMENT_CMD_RE = /\b(grep|rg|find|git|wc|jq|ls|node|npm)\b/;
274
-
275
- /**
276
- * Markers that date a measurement (#908 Baustein 2 input). ADVISORY in v1:
277
- * an undated-but-verified claim is counted and reported, never a violation —
278
- * a hard contract without an established authoring habit buys friction, not
279
- * accuracy. Baustein 2 can escalate this to a violation once the habit exists.
280
- */
281
- const TIMESTAMP_MARKER_RE = /\b\d{4}-\d{2}-\d{2}\b|\bHEAD\b|\bas of\b|\bmeasured (?:at|on)\b|\brev-parse\b/i;
282
-
283
80
  // ---------------------------------------------------------------------------
284
81
  // stdin reading (inline — Stop-family hooks exit 0 always, never deny)
285
82
  // ---------------------------------------------------------------------------
@@ -324,8 +121,8 @@ function readStdinJson() {
324
121
  */
325
122
  async function isEnabled() {
326
123
  const candidates = [
327
- path.join(SO_PROJECT_DIR, 'CLAUDE.md'),
328
- path.join(SO_PROJECT_DIR, 'AGENTS.md'),
124
+ path.join(getProjectDir(), 'CLAUDE.md'),
125
+ path.join(getProjectDir(), 'AGENTS.md'),
329
126
  ];
330
127
  for (const file of candidates) {
331
128
  try {
@@ -338,194 +135,6 @@ async function isEnabled() {
338
135
  return false;
339
136
  }
340
137
 
341
- // ---------------------------------------------------------------------------
342
- // transcript reading
343
- // ---------------------------------------------------------------------------
344
-
345
- /**
346
- * Read the transcript JSONL at `transcriptPath`, take the last TAIL_RECORDS
347
- * `type:"assistant"` records, and concatenate their text content blocks.
348
- * Returns '' on any failure (missing file, /dev/null, malformed lines).
349
- *
350
- * @param {string} transcriptPath
351
- * @returns {Promise<string>}
352
- */
353
- async function readTranscriptTail(transcriptPath) {
354
- if (typeof transcriptPath !== 'string' || !transcriptPath) return '';
355
- let raw;
356
- try {
357
- raw = await fs.readFile(transcriptPath, 'utf8');
358
- } catch {
359
- return '';
360
- }
361
- if (!raw.trim()) return '';
362
-
363
- const assistantRecords = [];
364
- for (const line of raw.split(/\r?\n/)) {
365
- const trimmed = line.trim();
366
- if (!trimmed) continue;
367
- let rec;
368
- try { rec = JSON.parse(trimmed); } catch { continue; }
369
- if (rec && rec.type === 'assistant') assistantRecords.push(rec);
370
- }
371
-
372
- const tail = assistantRecords.slice(-TAIL_RECORDS);
373
- const textBlocks = [];
374
- for (const rec of tail) {
375
- const content = rec?.message?.content;
376
- if (!Array.isArray(content)) continue;
377
- for (const block of content) {
378
- if (block && block.type === 'text' && typeof block.text === 'string') {
379
- textBlocks.push(block.text);
380
- }
381
- }
382
- }
383
- return textBlocks.join('\n');
384
- }
385
-
386
- // ---------------------------------------------------------------------------
387
- // claim scanning
388
- // ---------------------------------------------------------------------------
389
-
390
- /**
391
- * Single fence walk. Returns two disjoint-purpose index sets:
392
- * - `measurementLines`: lines belonging to a fenced block whose body contains
393
- * a MEASUREMENT_CMD_RE invocation (the evidence a claim can lean on).
394
- * - `fencedLines`: lines belonging to ANY fenced block (evidence or not).
395
- * Used only to keep the greedy #908 cardinal pattern out of tool output;
396
- * the six quantifier patterns are unchanged and still scan fenced lines.
397
- *
398
- * An unterminated trailing fence is treated as fenced-to-EOF (conservative for
399
- * false-positive suppression) but never as a measurement block (its body was
400
- * never closed, so we cannot claim it verified anything).
401
- *
402
- * @param {string[]} lines
403
- * @returns {{ measurementLines: Set<number>, fencedLines: Set<number> }}
404
- */
405
- function scanFences(lines) {
406
- const measurementLines = new Set();
407
- const fencedLines = new Set();
408
- let fenceStart = -1;
409
- let buffer = [];
410
- let inFence = false;
411
-
412
- for (let i = 0; i < lines.length; i++) {
413
- const isFence = /^\s*```/.test(lines[i]);
414
- if (!inFence) {
415
- if (isFence) { inFence = true; fenceStart = i; buffer = []; }
416
- continue;
417
- }
418
- // inside a fence
419
- if (isFence) {
420
- // closing fence — evaluate the buffered body
421
- const body = buffer.join('\n');
422
- const isMeasurement = MEASUREMENT_CMD_RE.test(body);
423
- for (let j = fenceStart; j <= i; j++) {
424
- fencedLines.add(j);
425
- if (isMeasurement) measurementLines.add(j);
426
- }
427
- inFence = false;
428
- fenceStart = -1;
429
- buffer = [];
430
- } else {
431
- buffer.push(lines[i]);
432
- }
433
- }
434
-
435
- // Unterminated trailing fence — suppress cardinal matches, grant no evidence.
436
- if (inFence) {
437
- for (let j = fenceStart; j < lines.length; j++) fencedLines.add(j);
438
- }
439
-
440
- // INLINE-code evidence: PSA-006 asks for "the exact pattern executed" to be
441
- // quoted — an inline `git log --oneline | wc -l` satisfies that exactly as
442
- // well as a fenced block, and one-line findings are commonly written that
443
- // way. Refusing to count it would penalise the honest path.
444
- for (let i = 0; i < lines.length; i++) {
445
- if (fencedLines.has(i)) continue;
446
- for (const span of lines[i].match(INLINE_CODE_RE) ?? []) {
447
- if (MEASUREMENT_CMD_RE.test(span)) { measurementLines.add(i); break; }
448
- }
449
- }
450
-
451
- return { measurementLines, fencedLines };
452
- }
453
-
454
- /**
455
- * True when any line in `indices` sits within ±GREP_PROXIMITY_LINES of `i`.
456
- *
457
- * @param {Set<number>} indices
458
- * @param {number} i
459
- * @returns {boolean}
460
- */
461
- function nearIndex(indices, i) {
462
- for (let j = i - GREP_PROXIMITY_LINES; j <= i + GREP_PROXIMITY_LINES; j++) {
463
- if (indices.has(j)) return true;
464
- }
465
- return false;
466
- }
467
-
468
- /**
469
- * True when a measurement TIMESTAMP marker appears within the same proximity
470
- * window used for the measurement block itself.
471
- *
472
- * @param {string[]} lines
473
- * @param {number} i
474
- * @returns {boolean}
475
- */
476
- function hasMeasurementTimestamp(lines, i) {
477
- const from = Math.max(0, i - GREP_PROXIMITY_LINES);
478
- const to = Math.min(lines.length - 1, i + GREP_PROXIMITY_LINES);
479
- for (let j = from; j <= to; j++) {
480
- if (TIMESTAMP_MARKER_RE.test(lines[j])) return true;
481
- }
482
- return false;
483
- }
484
-
485
- /**
486
- * Scan concatenated transcript text for claims lacking an adjacent measurement
487
- * block (within ±GREP_PROXIMITY_LINES).
488
- *
489
- * @param {string} text
490
- * @returns {{ violations: string[], undatedVerified: number }}
491
- * `violations` — truncated claim-text snippets; `undatedVerified` — count of
492
- * claims that ARE verified but carry no measurement timestamp (advisory).
493
- */
494
- function findViolations(text) {
495
- if (!text) return { violations: [], undatedVerified: 0 };
496
- const lines = text.split(/\r?\n/);
497
- const { measurementLines, fencedLines } = scanFences(lines);
498
- const violations = [];
499
- let undatedVerified = 0;
500
-
501
- for (let i = 0; i < lines.length; i++) {
502
- const line = lines[i];
503
- // #1198 FIX 2: gate-summary/STATUS lines are tool OUTPUT, not a claim —
504
- // skipped before any pattern runs (see GATE_SUMMARY_LINE_RE header).
505
- if (GATE_SUMMARY_LINE_RE.test(line)) continue;
506
-
507
- // #1198 FIX 3 (masking-order bug): mask inline-code spans ONCE, then test
508
- // BOTH the six CLAIM_PATTERNS and the cardinal/ratio patterns against the
509
- // masked text. Previously only the cardinal branch masked — a claim
510
- // quoted entirely inside backticks (evidence/example text, not an
511
- // assertion) still tripped CLAIM_PATTERNS via the raw, unmasked line.
512
- const masked = line.replace(INLINE_CODE_RE, ' ');
513
- let matched = CLAIM_PATTERNS.some((re) => re.test(masked));
514
- if (!matched && !fencedLines.has(i)) {
515
- matched = CARDINAL_PATTERN.test(masked) || CARDINAL_RATIO_PATTERN.test(masked);
516
- }
517
- if (!matched) continue;
518
-
519
- if (nearIndex(measurementLines, i)) {
520
- if (!hasMeasurementTimestamp(lines, i)) undatedVerified++;
521
- continue;
522
- }
523
-
524
- violations.push(line.trim().slice(0, CLAIM_TEXT_MAX));
525
- }
526
- return { violations, undatedVerified };
527
- }
528
-
529
138
  // ---------------------------------------------------------------------------
530
139
  // payload helpers
531
140
  // ---------------------------------------------------------------------------
@@ -578,27 +187,92 @@ function resolveAgentTranscriptPath(input, agentId) {
578
187
  const AGENT_TYPE_META_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
579
188
 
580
189
  /**
581
- * Read the agent TYPE from the sidecar `agent-<id>.meta.json` the harness writes
582
- * next to the subagent transcript. Used only when the stdin payload omits
583
- * `agent_type` the reason `agent` read `"unknown"` on ~91% of events.
190
+ * The sidecar `description` is free operator prose ("W1-d5 Scripts-Audit"), so
191
+ * it is clamped by LENGTH and stripped of control characters rather than
192
+ * charset-validated. 120 chars is the widest description measured on-disk
193
+ * (2026-09-06, 20 real `agent-*.meta.json` files under
194
+ * `~/.claude/projects/<slug>/<session>/subagents/`: max 34 chars).
195
+ *
196
+ * Exposure note: unlike `orchestrator.agent.stopped` — which deliberately
197
+ * omits `description` because that record travels the optional Clank webhook
198
+ * unredacted (see hooks/on-stop.mjs) — this record is appended straight to
199
+ * `.orchestrator/metrics/events.jsonl` via `appendJsonl`, never through
200
+ * `emitEvent`, and it ALREADY carries up to 200 characters of the agent's own
201
+ * prose in `claim_text`. Carrying the description adds no new exposure class.
202
+ */
203
+ const AGENT_DESCRIPTION_MAX = 120;
204
+
205
+ /**
206
+ * Resolve WHO made the claim, and say where the answer came from (#1218).
207
+ *
208
+ * Measured 2026-09-06 over the fleet's worst-affected repo
209
+ * (`extern/aiat-barrierefrei-engine`, 3,360 `discovery_validator_violation`
210
+ * records): `agent` was the literal string `"unknown"` on 3,026 of them
211
+ * (90.1%), and NOT ONE record carried an `agent_id`. Both the sidecar-meta
212
+ * fallback and the `agent_id` field landed in the same commit (936dae8a,
213
+ * 2026-09-02); the newest of those 3,360 records is 2026-08-25. The 90.1% is
214
+ * therefore a property of a PRE-FIX corpus, not of the code at HEAD — d3's
215
+ * open question 3 ("pre- or post-#1191?") resolves to *pre*.
216
+ *
217
+ * What the corpus does NOT excuse is the SHAPE of the answer. Two gaps remain
218
+ * at HEAD and this function closes them:
219
+ *
220
+ * 1. `"unknown"` was indistinguishable from a real agent type named
221
+ * "unknown", and carried no hint of WHY resolution failed. The record now
222
+ * always carries `agent_source` — `payload` | `meta` | `none` — and, on
223
+ * `none`, the sorted list of stdin keys that WERE present, so the next
224
+ * reader diagnoses the harness gap from the ledger instead of guessing.
225
+ * 2. `agentType` is frequently the useless class `general-purpose`: measured
226
+ * on the same day over 20 real sidecars in THIS repo's own session
227
+ * directory, 14 read `general-purpose` and only 6 a plugin-qualified type.
228
+ * The sidecar's `description` ("W1-d5 Scripts-Audit") is what actually
229
+ * identifies the agent, so it is carried alongside as
230
+ * `agent_description`.
584
231
  *
232
+ * @param {object} input — SubagentStop stdin payload
585
233
  * @param {string} metaPath — `resolveSubagentSidecar(...).meta`
586
- * @returns {Promise<string|null>}
234
+ * @returns {Promise<{agent: string, source: 'payload'|'meta'|'none', description: string|null, payloadKeys: string[]}>}
587
235
  */
588
- async function readAgentTypeFromMeta(metaPath) {
236
+ async function resolveAgentAttribution(input, metaPath) {
237
+ const fromPayload = firstNonEmptyString(input, ['agent_type', 'subagent_type'], null);
238
+ // Clamped with the same shape hooks/on-stop.mjs applies to `agentType`
239
+ // (colon included — `session-orchestrator:code-implementer` is the real
240
+ // shape). This value reaches BOTH the ledger event and the model-visible
241
+ // `additionalContext` string, so a mismatch is OMITTED rather than
242
+ // truncated: an unmeasured type stays visibly unmeasured.
243
+ const payloadAgent =
244
+ fromPayload !== null && AGENT_TYPE_META_RE.test(fromPayload.trim()) ? fromPayload.trim() : null;
245
+
246
+ let metaAgent = null;
247
+ let description = null;
589
248
  try {
590
249
  const meta = JSON.parse(await fs.readFile(metaPath, 'utf8'));
591
250
  const t = meta?.agentType;
592
- if (typeof t !== 'string' || !t.trim()) return null;
593
- // Clamped with the same shape hooks/on-stop.mjs applies to `agentType`
594
- // (colon included `session-orchestrator:code-implementer` is the real
595
- // shape). This value reaches BOTH the ledger event and the model-visible
596
- // `additionalContext` string, so a mismatch is OMITTED rather than
597
- // truncated: the caller then falls back to the honest `'unknown'`.
598
- return AGENT_TYPE_META_RE.test(t.trim()) ? t.trim() : null;
251
+ if (typeof t === 'string' && AGENT_TYPE_META_RE.test(t.trim())) metaAgent = t.trim();
252
+ const d = meta?.description;
253
+ if (typeof d === 'string' && d.trim()) {
254
+ // Control characters (a stray NUL above all: one NUL makes a text
255
+ // file invisible to every grep-based audit) are replaced, never
256
+ // carried. Filtered by code point rather than by a control-char
257
+ // regex so this file itself stays greppable.
258
+ const clean = [...d]
259
+ .map((ch) => { const c = ch.codePointAt(0); return c < 0x20 || c === 0x7f ? ' ' : ch; })
260
+ .join('')
261
+ .replace(/\s+/g, ' ')
262
+ .trim();
263
+ if (clean) description = clean.slice(0, AGENT_DESCRIPTION_MAX);
264
+ }
599
265
  } catch {
600
- return null;
266
+ // absent or corrupt sidecar meta — the payload branch still stands
601
267
  }
268
+
269
+ const agent = payloadAgent ?? metaAgent ?? 'unknown';
270
+ const source = payloadAgent !== null ? 'payload' : metaAgent !== null ? 'meta' : 'none';
271
+ // Keys only, never values: the diagnostic question is "what DID the harness
272
+ // send?", and a value could carry a path or prose that has no business in a
273
+ // record whose whole point is attribution.
274
+ const payloadKeys = input && typeof input === 'object' ? Object.keys(input).sort() : [];
275
+ return { agent, source, description, payloadKeys };
602
276
  }
603
277
 
604
278
  /**
@@ -657,6 +331,50 @@ function dedupSentinelPath({ projectRoot, sessionId, agentId, claimText }) {
657
331
  );
658
332
  }
659
333
 
334
+ /**
335
+ * Build the SESSION-scoped LEDGER sentinel for one normalized claim (#1198).
336
+ *
337
+ * Distinct from `dedupSentinelPath` above in BOTH key and effect, and the
338
+ * difference is the point:
339
+ * - `dedupSentinelPath` is keyed on the individual AGENT and suppresses only
340
+ * the `additionalContext` echo, so each real agent still receives its own
341
+ * copy of the feedback.
342
+ * - this one is keyed on the SESSION and the NORMALIZED claim, and suppresses
343
+ * the events.jsonl WRITE. The ledger is a record of distinct findings; the
344
+ * same sentence re-asserted by a second agent of the same session is the
345
+ * same finding, and writing it twice is what produced a duplication factor
346
+ * of 16.4 (3,360 records over 205 distinct `claim_text` values, measured
347
+ * 2026-09-06 in `extern/aiat-barrierefrei-engine`).
348
+ *
349
+ * Returns null without a session id — a claim that cannot be bound to a session
350
+ * must not collide with an unrelated one under a global "unknown" key, so it is
351
+ * always written (the pre-#1198 behaviour, preserved for that case only).
352
+ *
353
+ * NAMED CEILING (BV-004): one zero-byte tmp file per distinct claim per
354
+ * session, never cleaned up by this hook — the same shape (and the same
355
+ * unbounded growth) `dedupSentinelPath` above has carried since #567, relying
356
+ * on the OS's tmpdir reaping. Fine at the measured rate: 158 distinct claims
357
+ * across a repo's worst 90 days. REVISIT TRIGGER: a repo whose distinct-claim
358
+ * count per session reaches the hundreds — then this belongs in a single
359
+ * per-session state file rather than one inode per claim.
360
+ *
361
+ * @param {object} opts
362
+ * @param {string} opts.projectRoot
363
+ * @param {string|null} opts.sessionId
364
+ * @param {string} opts.normalizedClaim — `normalizeClaim()` output
365
+ * @returns {string|null}
366
+ */
367
+ function claimLedgerSentinelPath({ projectRoot, sessionId, normalizedClaim }) {
368
+ if (typeof sessionId !== 'string' || !sessionId.trim()) return null;
369
+ if (typeof normalizedClaim !== 'string' || !normalizedClaim) return null;
370
+
371
+ const claimHash = createHash('sha256').update(normalizedClaim).digest('hex').slice(0, 16);
372
+ return path.join(
373
+ tmpdir(),
374
+ `psa006-claim-${projectRootHash(projectRoot)}-${safeSentinelComponent(sessionId)}-${claimHash}.lock`
375
+ );
376
+ }
377
+
660
378
  // ---------------------------------------------------------------------------
661
379
  // main
662
380
  // ---------------------------------------------------------------------------
@@ -678,10 +396,8 @@ async function main() {
678
396
  const { violations, undatedVerified } = findViolations(text);
679
397
  if (violations.length === 0) return;
680
398
 
681
- const agentForDedup =
682
- firstNonEmptyString(input, ['agent_type', 'subagent_type'], null) ??
683
- (await readAgentTypeFromMeta(sidecar.meta));
684
- const agent = agentForDedup ?? 'unknown';
399
+ const attribution = await resolveAgentAttribution(input, sidecar.meta);
400
+ const agent = attribution.agent;
685
401
  // session_id precedence: parent_session_id first, mirroring the sibling hook
686
402
  // hooks/subagent-telemetry.mjs (firstNonEmptyString(['parent_session_id',
687
403
  // 'session_id'])). W2-review LOW finding (#567) — the prior `session_id ||
@@ -694,23 +410,55 @@ async function main() {
694
410
  // globally. Keyed on agentId (not agent TYPE, #1198 FIX 1) plus the first
695
411
  // violation's claim text so distinct real agents and distinct claims never
696
412
  // share a sentinel.
413
+ const projectRoot = getProjectDir();
697
414
  const sentinel = dedupSentinelPath({
698
- projectRoot: SO_PROJECT_DIR,
415
+ projectRoot,
699
416
  sessionId,
700
417
  agentId,
701
- claimText: violations[0],
418
+ claimText: violations[0].claim,
702
419
  });
703
420
 
421
+ // Ledger write, ONE record per distinct normalized claim (#1198). Two
422
+ // levels, because the duplication had two sources: `findViolations()` already
423
+ // collapsed the repeats INSIDE this transcript tail into an `occurrences`
424
+ // count, and the session sentinel below drops a claim this session has
425
+ // already recorded from an earlier SubagentStop.
704
426
  const filePath = eventsFilePath();
705
- for (const claim of violations) {
427
+ let written = 0;
428
+ for (const violation of violations) {
429
+ const claimSentinel = claimLedgerSentinelPath({
430
+ projectRoot,
431
+ sessionId,
432
+ normalizedClaim: violation.normalized,
433
+ });
434
+ if (claimSentinel !== null) {
435
+ let seen = false;
436
+ try {
437
+ await fs.writeFile(claimSentinel, '', { flag: 'wx' });
438
+ } catch (err) {
439
+ // EEXIST = this session already recorded this claim. Any OTHER
440
+ // filesystem error must NOT suppress the record: losing a finding to a
441
+ // full disk or a read-only tmpdir is the worse failure.
442
+ seen = err && err.code === 'EEXIST';
443
+ }
444
+ if (seen) continue;
445
+ }
706
446
  await appendJsonl(filePath, {
707
447
  event: 'discovery_validator_violation',
708
448
  timestamp: new Date().toISOString(),
709
449
  agent,
450
+ // Always present, even (especially) on 'none': a bare "unknown" is
451
+ // indistinguishable from a real agent type of that name, and says
452
+ // nothing about WHY resolution failed.
453
+ agent_source: attribution.source,
454
+ ...(attribution.description !== null ? { agent_description: attribution.description } : {}),
455
+ ...(attribution.source === 'none' ? { payload_keys: attribution.payloadKeys } : {}),
710
456
  ...(agentId !== null ? { agent_id: agentId } : {}),
711
457
  ...(sessionId !== null ? { session_id: sessionId } : {}),
712
- claim_text: claim,
458
+ claim_text: violation.claim,
459
+ occurrences: violation.occurrences,
713
460
  });
461
+ written++;
714
462
  }
715
463
 
716
464
  // Advisory only (#908 item 4) — never promoted to a violation in v1.
@@ -718,10 +466,18 @@ async function main() {
718
466
  ? ` ${undatedVerified} verified claim(s) carry no measurement timestamp (advisory).`
719
467
  : '';
720
468
 
469
+ // `written < violations.length` means this session had already recorded the
470
+ // difference from an earlier SubagentStop — say so rather than let the WARN
471
+ // count and the ledger count disagree with no explanation (#1198).
472
+ const suppressedNote = written < violations.length
473
+ ? ` ${violations.length - written} already recorded earlier in this session.`
474
+ : '';
475
+
721
476
  const warnText =
722
- `⚠ PSA-006: ${violations.length} repo-state/distributional claim(s) from agent "${agent}" ` +
723
- `lack an adjacent measurement transcript (grep/rg/find/git/wc/jq/ls/node/npm) (non-blocking).` +
724
- `${undatedNote} ` +
477
+ `⚠ PSA-006: ${violations.length} distinct repo-state/distributional claim(s) from agent ` +
478
+ `"${agent}" (source: ${attribution.source}) lack an adjacent measurement transcript ` +
479
+ `(grep/rg/find/git/wc/jq/ls/node/npm) (non-blocking).` +
480
+ `${suppressedNote}${undatedNote} ` +
725
481
  `See .claude/rules/parallel-sessions.md § PSA-006.`;
726
482
  process.stderr.write(warnText + '\n');
727
483