@event4u/agent-config 16.1.0 → 16.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 (467) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +98 -257
  5. package/README.md +14 -12
  6. package/dist/agent-src/commands/analyze/inbox.md +1 -1
  7. package/dist/agent-src/commands/analyze/roadmap-repos.md +1 -1
  8. package/dist/agent-src/commands/bug/fix.md +17 -2
  9. package/dist/agent-src/commands/feature/plan.md +1 -1
  10. package/dist/agent-src/commands/feature/roadmap.md +1 -1
  11. package/dist/agent-src/commands/fix/ci.md +1 -1
  12. package/dist/agent-src/commands/implement-ticket.md +1 -1
  13. package/dist/agent-src/commands/jira-ticket.md +1 -1
  14. package/dist/agent-src/commands/roadmap/create.md +1 -1
  15. package/dist/agent-src/commands/roadmap/materialize.md +1 -1
  16. package/dist/agent-src/commands/roadmap/process-full.md +5 -5
  17. package/dist/agent-src/commands/roadmap/resolve-blockers.md +361 -0
  18. package/dist/agent-src/commands/roadmap/triage-parked.md +325 -0
  19. package/dist/agent-src/commands/roadmap.md +18 -4
  20. package/dist/agent-src/commands/tests/create.md +17 -3
  21. package/dist/agent-src/commands/tests/execute.md +22 -5
  22. package/dist/agent-src/commands/work.md +4 -0
  23. package/dist/agent-src/contexts/authority/decision-revisit-gate-enforcement.md +43 -0
  24. package/dist/agent-src/contexts/communication/rules-auto/design-fidelity-enforcement.md +24 -0
  25. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +6 -6
  26. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +4 -8
  27. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +32 -2
  28. package/dist/agent-src/contexts/execution/session-canary-enforcement-history.md +67 -0
  29. package/dist/agent-src/contexts/execution/toolchain-resolver.md +101 -7
  30. package/dist/agent-src/rules/active-remediation.md +1 -1
  31. package/dist/agent-src/rules/autonomous-execution.md +1 -0
  32. package/dist/agent-src/rules/broken-access-control.md +1 -0
  33. package/dist/agent-src/rules/command-suggestion-policy.md +1 -0
  34. package/dist/agent-src/rules/context-hygiene.md +1 -1
  35. package/dist/agent-src/rules/decision-revisit-gate.md +16 -33
  36. package/dist/agent-src/rules/design-fidelity.md +13 -16
  37. package/dist/agent-src/rules/doc-screenshot-hygiene.md +1 -0
  38. package/dist/agent-src/rules/domain-safety-disclaimer.md +1 -0
  39. package/dist/agent-src/rules/domain-safety-pii.md +1 -0
  40. package/dist/agent-src/rules/domain-safety-retention.md +1 -0
  41. package/dist/agent-src/rules/engineering-safety-floor.md +1 -0
  42. package/dist/agent-src/rules/evaluator-independence.md +3 -2
  43. package/dist/agent-src/rules/fast-path-marker-visibility.md +1 -0
  44. package/dist/agent-src/rules/finance-safety-floor.md +1 -0
  45. package/dist/agent-src/rules/git-history-discipline.md +1 -0
  46. package/dist/agent-src/rules/image-likeness-and-rights.md +1 -0
  47. package/dist/agent-src/rules/legal-safety-floor.md +3 -2
  48. package/dist/agent-src/rules/lethal-trifecta-guard.md +1 -0
  49. package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
  50. package/dist/agent-src/rules/media-governance-routing.md +14 -17
  51. package/dist/agent-src/rules/persona-governance.md +2 -2
  52. package/dist/agent-src/rules/provider-lifecycle-discipline.md +1 -1
  53. package/dist/agent-src/rules/question-not-instruction.md +1 -0
  54. package/dist/agent-src/rules/roadmap-progress-sync.md +1 -1
  55. package/dist/agent-src/rules/runtime-safety.md +1 -0
  56. package/dist/agent-src/rules/secret-vcs-guard.md +1 -0
  57. package/dist/agent-src/rules/security-sensitive-stop.md +3 -2
  58. package/dist/agent-src/rules/session-canary.md +8 -62
  59. package/dist/agent-src/rules/settings-ask-protocol.md +1 -0
  60. package/dist/agent-src/rules/source-confidentiality.md +1 -0
  61. package/dist/agent-src/rules/strategy-safety-floor.md +1 -0
  62. package/dist/agent-src/rules/tool-safety.md +1 -0
  63. package/dist/agent-src/rules/ui-audit-gate.md +1 -2
  64. package/dist/agent-src/rules/untrusted-input-defense.md +1 -0
  65. package/dist/agent-src/rules/user-interrupt-priority.md +1 -0
  66. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +115 -20
  67. package/dist/agent-src/scripts/deferral_carry.ts +339 -0
  68. package/dist/agent-src/scripts/update_roadmap_progress.ts +4 -1
  69. package/dist/agent-src/skills/accessibility-auditor/data/aria-patterns.csv +1 -1
  70. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +2 -2
  71. package/dist/agent-src/skills/api-design/data/manifest.json +2 -2
  72. package/dist/agent-src/skills/api-testing/SKILL.md +17 -0
  73. package/dist/agent-src/skills/code-intelligence/SKILL.md +14 -2
  74. package/dist/agent-src/skills/design-review/SKILL.md +30 -4
  75. package/dist/agent-src/skills/design-review/references/verification-automation.md +32 -0
  76. package/dist/agent-src/skills/design-system-capture/references/import-procedure.md +1 -1
  77. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +18 -3
  78. package/dist/agent-src/skills/existing-ui-audit/references/component-taxonomy-detection.md +56 -0
  79. package/dist/agent-src/skills/fe-design/SKILL.md +1 -1
  80. package/dist/agent-src/skills/iconography/SKILL.md +1 -1
  81. package/dist/agent-src/skills/js-library-packaging/SKILL.md +2 -2
  82. package/dist/agent-src/skills/markitdown/SKILL.md +4 -0
  83. package/dist/agent-src/skills/monorepo-workspace/SKILL.md +1 -1
  84. package/dist/agent-src/skills/playwright-testing/SKILL.md +30 -6
  85. package/dist/agent-src/skills/quality-tools/SKILL.md +11 -1
  86. package/dist/agent-src/skills/quality-tools/references/go-tools.md +76 -0
  87. package/dist/agent-src/skills/quality-tools/references/python-tools.md +104 -0
  88. package/dist/agent-src/skills/review-routing/SKILL.md +2 -2
  89. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
  90. package/dist/agent-src/skills/storybook-workshop/SKILL.md +3 -3
  91. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +1 -1
  92. package/dist/agent-src/skills/test-case-discovery/SKILL.md +7 -0
  93. package/dist/agent-src/skills/test-driven-development/SKILL.md +17 -0
  94. package/dist/agent-src/skills/test-performance/SKILL.md +19 -1
  95. package/dist/agent-src/skills/ui-component-architect/SKILL.md +25 -2
  96. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  97. package/dist/agent-src/templates/roadmaps.md +30 -0
  98. package/dist/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
  99. package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +302 -21
  100. package/dist/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
  101. package/dist/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
  102. package/dist/agent-src/templates/scripts/work_engine/stack/runner.ts +774 -109
  103. package/dist/agent-src/templates/scripts/work_engine/state.ts +57 -7
  104. package/dist/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
  105. package/dist/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
  106. package/dist/cli/python/knowledge_ingest.js +1 -1
  107. package/dist/cli/python/workspace_drive.js +1 -1
  108. package/dist/cli/registry.js +1 -1
  109. package/dist/cli-delegate/archive_completed_roadmaps.js +92 -12
  110. package/dist/cli-delegate/check_update_banner.js +12 -1
  111. package/dist/cli-delegate/chunk-523HCFYO.js +90 -0
  112. package/dist/cli-delegate/{chunk-M2DPGFZ4.js → chunk-6V6TA25H.js} +22 -152
  113. package/dist/cli-delegate/chunk-7IQE3D5S.js +191 -0
  114. package/dist/cli-delegate/{chunk-6KFEC3G7.js → chunk-CQWX336L.js} +1 -1
  115. package/dist/cli-delegate/chunk-DEIV7XP7.js +108 -0
  116. package/dist/cli-delegate/chunk-EEHDXSYD.js +2040 -0
  117. package/dist/cli-delegate/{chunk-P6ZHP5W2.js → chunk-GKE7ZVFA.js} +4 -4
  118. package/dist/cli-delegate/chunk-JQOM7TTM.js +1201 -0
  119. package/dist/cli-delegate/{chunk-UNCRJBT3.js → chunk-JQVYKIZA.js} +41 -0
  120. package/dist/cli-delegate/{chunk-FC75PGJE.js → chunk-JY2I2DXV.js} +12 -1
  121. package/dist/cli-delegate/chunk-KB5FZ655.js +208 -0
  122. package/dist/cli-delegate/{chunk-YUM5KURG.js → chunk-KR6EM23A.js} +235 -1455
  123. package/dist/cli-delegate/{chunk-LTYVIY76.js → chunk-LIYTLH6O.js} +1 -1
  124. package/dist/cli-delegate/chunk-NQ7WT43P.js +2729 -0
  125. package/dist/cli-delegate/chunk-NXEILF2K.js +2160 -0
  126. package/dist/cli-delegate/{chunk-5R7HOLAF.js → chunk-NYX3L2PZ.js} +1 -1
  127. package/dist/cli-delegate/{chunk-4E2W63DA.js → chunk-OKJUIAPS.js} +62 -1
  128. package/dist/cli-delegate/{chunk-QAJS5YWY.js → chunk-PHD7OOLE.js} +2 -1
  129. package/dist/cli-delegate/{chunk-RGNIWTEA.js → chunk-QLDHIPWR.js} +1 -1
  130. package/dist/cli-delegate/{chunk-HDVVGPQW.js → chunk-RQNNW7IF.js} +1405 -567
  131. package/dist/cli-delegate/{chunk-S3VFXMTM.js → chunk-S4UMZNES.js} +1 -1
  132. package/dist/cli-delegate/{chunk-6KM6ORRY.js → chunk-S4YWNRKO.js} +24 -8
  133. package/dist/cli-delegate/{chunk-GJCBB2UG.js → chunk-TRI6DQZ6.js} +3054 -325
  134. package/dist/cli-delegate/chunk-ULQRUR23.js +208 -0
  135. package/dist/cli-delegate/{chunk-NUHLI2RX.js → chunk-YCU3TIXH.js} +2 -0
  136. package/dist/cli-delegate/chunk-ZBZV2G5R.js +210 -0
  137. package/dist/cli-delegate/{chunk-WGXTVKCQ.js → chunk-ZUZKZYCN.js} +5 -3
  138. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  139. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  140. package/dist/cli-delegate/cmd_conformance.js +1862 -809
  141. package/dist/cli-delegate/cmd_doctor.js +13 -6
  142. package/dist/cli-delegate/cmd_doctor_neighbours.js +23 -0
  143. package/dist/cli-delegate/cmd_explain.js +5 -5
  144. package/dist/cli-delegate/cmd_export.js +11 -8
  145. package/dist/cli-delegate/cmd_fleet.js +3 -3
  146. package/dist/cli-delegate/cmd_handoff.js +10 -7
  147. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  148. package/dist/cli-delegate/cmd_memory_get.js +3 -1
  149. package/dist/cli-delegate/cmd_migrate.js +2 -2
  150. package/dist/cli-delegate/cmd_packs_active.js +3 -3
  151. package/dist/cli-delegate/cmd_preflight.js +6 -6
  152. package/dist/cli-delegate/cmd_prune.js +11 -8
  153. package/dist/cli-delegate/cmd_refresh.js +2 -1
  154. package/dist/cli-delegate/cmd_route_audit.js +8 -6
  155. package/dist/cli-delegate/cmd_route_explain.js +4 -4
  156. package/dist/cli-delegate/cmd_session_recycle.js +4 -3
  157. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  158. package/dist/cli-delegate/cmd_settings_migrate.js +11 -8
  159. package/dist/cli-delegate/cmd_settings_set.js +4 -4
  160. package/dist/cli-delegate/cmd_sync.js +11 -8
  161. package/dist/cli-delegate/cmd_uninstall.js +11 -8
  162. package/dist/cli-delegate/cmd_update.js +1 -1
  163. package/dist/cli-delegate/cmd_upgrade.js +3 -2
  164. package/dist/cli-delegate/cmd_validate.js +11 -8
  165. package/dist/cli-delegate/cmd_versions.js +1 -1
  166. package/dist/cli-delegate/deferral_carry.js +16 -0
  167. package/dist/cli-delegate/gate_execute.js +2 -2
  168. package/dist/cli-delegate/roadmap_gates.js +5 -5
  169. package/dist/cli-delegate/update_roadmap_progress.js +1 -1
  170. package/dist/discovery/deprecation-report.md +1 -1
  171. package/dist/discovery/discovery-manifest.json +132 -80
  172. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  173. package/dist/discovery/discovery-manifest.summary.md +3 -3
  174. package/dist/discovery/dormancy-report.md +1 -1
  175. package/dist/discovery/orphan-report.md +1 -1
  176. package/dist/discovery/packs.json +7 -5
  177. package/dist/discovery/trust-report.md +2 -2
  178. package/dist/discovery/workspaces.json +6 -4
  179. package/dist/hooks/dispatch.js +7972 -5186
  180. package/dist/hooks/dispatch.sha256 +1 -0
  181. package/dist/install/claudePathsPlan.js +47 -6
  182. package/dist/install/claudeRuleRewrite.js +7 -0
  183. package/dist/install/emit_host_rules_cli.js +39 -36
  184. package/dist/install/globalRuleScope.js +87 -0
  185. package/dist/install/host_rule_ownership.js +160 -0
  186. package/dist/install/install.mjs +2226 -1055
  187. package/dist/install/installThinLayer.js +261 -0
  188. package/dist/install/installedRuleLinks.js +176 -0
  189. package/dist/install/wizard-plan.js +24 -0
  190. package/dist/mcp/registry-manifest.json +3 -3
  191. package/dist/mcp/server.json +2 -2
  192. package/dist/mcp/server.mjs +40913 -34865
  193. package/dist/scripts/_lib/agent_settings.js +11 -0
  194. package/dist/scripts/_lib/claude_settings_hooks.js +10 -8
  195. package/dist/scripts/_lib/installed_layer.js +378 -0
  196. package/dist/scripts/_lib/lean_projection_mode.js +218 -15
  197. package/dist/scripts/_lib/rule_consequence_class.js +45 -0
  198. package/dist/scripts/_lib/rule_injection.js +67 -10
  199. package/dist/scripts/_lib/rule_law_section.js +122 -0
  200. package/dist/scripts/_lib/thin_rules.js +458 -0
  201. package/dist/scripts/_lib/windsurf_render.js +60 -0
  202. package/dist/scripts/condense.js +14 -26
  203. package/dist/scripts/project_thin_rules.js +14 -294
  204. package/dist/server/schemas/settings.js +29 -0
  205. package/dist/shared/skillRanking.js +74 -13
  206. package/dist/ui/assets/index-Cwq4d0hX.js +23 -0
  207. package/dist/ui/assets/index-Cyjy8bWO.css +1 -0
  208. package/dist/ui/index.html +2 -2
  209. package/docs/contracts/settings-classes.md +10 -6
  210. package/package.json +4 -4
  211. package/src/agent-src/scripts/archive_completed_roadmaps.ts +115 -20
  212. package/src/agent-src/scripts/deferral_carry.ts +339 -0
  213. package/src/agent-src/scripts/update_roadmap_progress.ts +4 -1
  214. package/src/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +10 -4
  215. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +302 -21
  216. package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +22 -3
  217. package/src/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +6 -1
  218. package/src/agent-src/templates/scripts/work_engine/stack/runner.ts +774 -109
  219. package/src/agent-src/templates/scripts/work_engine/state.ts +57 -7
  220. package/src/agent-src/templates/scripts/work_engine/taxonomy/detect.ts +500 -0
  221. package/src/agent-src/templates/scripts/work_engine/taxonomy/place.ts +183 -0
  222. package/src/config/agent-settings.template.yml +91 -22
  223. package/src/config/ci-local-parity.yml +18 -0
  224. package/src/config/continuity-surface.json +9 -2
  225. package/src/config/evaluator-budgets.json +4 -4
  226. package/src/config/gate-coverage.yml +327 -3
  227. package/src/config/gate-violation-baselines.json +21 -9
  228. package/src/config/hook-bundle-budget.json +25 -0
  229. package/src/config/hook-latency-budget.json +21 -0
  230. package/src/config/hook-token-budget.json +8 -3
  231. package/src/config/host-instruction-limits.json +33 -0
  232. package/src/config/metric-registry.yml +114 -0
  233. package/src/config/placeholder-drift-budget.json +10 -0
  234. package/src/config/routing-coverage-seed.json +38 -3
  235. package/src/config/rule-activation-census.json +7 -3
  236. package/src/config/rule-consequence-class.json +161 -0
  237. package/src/config/rule-law-ceilings.json +99 -0
  238. package/src/install/claudePathsPlan.ts +46 -4
  239. package/src/install/claudeRuleRewrite.ts +10 -1
  240. package/src/install/emit_host_rules_cli.ts +43 -34
  241. package/src/install/globalRuleScope.ts +109 -0
  242. package/src/install/host_rule_ownership.ts +170 -0
  243. package/src/install/installThinLayer.ts +312 -0
  244. package/src/install/installedRuleLinks.ts +220 -0
  245. package/src/install/wizard-plan.ts +24 -0
  246. package/src/scripts/_cli/cmd_doctor.ts +28 -90
  247. package/src/scripts/_cli/cmd_doctor_neighbours.ts +293 -0
  248. package/src/scripts/_cli/doctor_execution.ts +100 -17
  249. package/src/scripts/_cli/doctor_network_posture.ts +226 -0
  250. package/src/scripts/_dispatch.bash +16 -2
  251. package/src/scripts/_lib/agent_settings.ts +11 -0
  252. package/src/scripts/_lib/body_portable.ts +102 -0
  253. package/src/scripts/_lib/cc_transcript.ts +132 -1
  254. package/src/scripts/_lib/claude_settings_hooks.ts +10 -8
  255. package/src/scripts/_lib/concern_sla_window.ts +200 -0
  256. package/src/scripts/_lib/estate_offsets.ts +241 -0
  257. package/src/scripts/_lib/exemption_shape.ts +143 -0
  258. package/src/scripts/_lib/fnv.ts +23 -0
  259. package/src/scripts/_lib/foreign_scan.ts +106 -0
  260. package/src/scripts/_lib/forge_protection.ts +114 -0
  261. package/src/scripts/_lib/forge_reader.ts +558 -0
  262. package/src/scripts/_lib/gitignore_block.ts +314 -0
  263. package/src/scripts/_lib/global_deploy_inventory.ts +32 -0
  264. package/src/scripts/_lib/graph_feeder_record.ts +214 -0
  265. package/src/scripts/_lib/hook_settings.ts +73 -0
  266. package/src/scripts/_lib/host_capability.ts +40 -19
  267. package/src/scripts/_lib/host_env_write.ts +111 -0
  268. package/src/scripts/_lib/host_hook_merge.ts +165 -0
  269. package/src/scripts/_lib/installed_layer.ts +538 -0
  270. package/src/scripts/_lib/json_merge.ts +82 -0
  271. package/src/scripts/_lib/json_pointers.ts +93 -11
  272. package/src/scripts/_lib/lean_projection_mode.ts +275 -15
  273. package/src/scripts/_lib/llm_proposer_transport.ts +14 -4
  274. package/src/scripts/_lib/machine_wake.ts +16 -3
  275. package/src/scripts/_lib/neighbour_census.ts +760 -0
  276. package/src/scripts/_lib/neighbour_scan.ts +122 -0
  277. package/src/scripts/_lib/neighbour_tool_use.ts +189 -0
  278. package/src/scripts/_lib/obligations.ts +119 -8
  279. package/src/scripts/_lib/probe_inputs.ts +271 -0
  280. package/src/scripts/_lib/prompt_shape.ts +46 -0
  281. package/src/scripts/_lib/read_surface_scan.ts +322 -0
  282. package/src/scripts/_lib/release_highlights.ts +2 -1
  283. package/src/scripts/_lib/release_migration_gate.ts +19 -3
  284. package/src/scripts/_lib/reserved_name_sweep.ts +73 -0
  285. package/src/scripts/_lib/retrieval_sanitize.ts +77 -16
  286. package/src/scripts/_lib/review_coverage.ts +134 -0
  287. package/src/scripts/_lib/rule_consequence_class.ts +71 -0
  288. package/src/scripts/_lib/rule_injection.ts +130 -10
  289. package/src/scripts/_lib/rule_law_section.ts +147 -0
  290. package/src/scripts/_lib/rule_layer_overlap.ts +58 -0
  291. package/src/scripts/_lib/shell_write_shapes.ts +108 -0
  292. package/src/scripts/_lib/skill_origin.ts +254 -0
  293. package/src/scripts/_lib/structural_hiding.ts +622 -0
  294. package/src/scripts/_lib/structured_ask.ts +64 -21
  295. package/src/scripts/_lib/thin_rules.ts +540 -0
  296. package/src/scripts/_lib/touched_file_quality.ts +434 -0
  297. package/src/scripts/_lib/turn_end_refusals.ts +600 -4
  298. package/src/scripts/_lib/turn_end_transcript.ts +216 -0
  299. package/src/scripts/_lib/verification_command.ts +324 -0
  300. package/src/scripts/_lib/verification_evidence.ts +647 -0
  301. package/src/scripts/_lib/verify_clause.ts +280 -0
  302. package/src/scripts/_lib/windsurf_render.ts +63 -0
  303. package/src/scripts/ai_council/cli_call_budget.ts +13 -5
  304. package/src/scripts/ai_council/clients.ts +26 -57
  305. package/src/scripts/ai_council/config.ts +1 -1
  306. package/src/scripts/ai_council/vendor_defaults.ts +45 -0
  307. package/src/scripts/ai_team/team_dispatch.ts +4 -0
  308. package/src/scripts/audit_skill_overlap.ts +261 -0
  309. package/src/scripts/before_complete_hook.ts +573 -25
  310. package/src/scripts/bench_hook_latency.ts +412 -5
  311. package/src/scripts/bench_touched_file_quality.ts +238 -0
  312. package/src/scripts/cache_realization_report.ts +61 -1
  313. package/src/scripts/check_argument_hint.ts +287 -0
  314. package/src/scripts/check_claims.ts +125 -1
  315. package/src/scripts/check_enforcement_coverage.ts +38 -9
  316. package/src/scripts/check_enforcement_matrix.ts +621 -22
  317. package/src/scripts/check_estate_count.ts +130 -153
  318. package/src/scripts/check_finding_dispositions.ts +138 -7
  319. package/src/scripts/check_generator_sync.ts +71 -0
  320. package/src/scripts/check_hook_bundle_composition.ts +370 -0
  321. package/src/scripts/check_host_docs_digest.ts +786 -0
  322. package/src/scripts/check_host_format_column.ts +559 -0
  323. package/src/scripts/check_input_references.ts +228 -0
  324. package/src/scripts/check_inputs_parity.ts +235 -0
  325. package/src/scripts/check_kernel_edit_ratified.ts +89 -9
  326. package/src/scripts/check_kill_switch_table.ts +294 -0
  327. package/src/scripts/check_memory.ts +7 -1
  328. package/src/scripts/check_placeholder_drift.ts +221 -0
  329. package/src/scripts/check_read_surface_coverage.ts +515 -0
  330. package/src/scripts/check_release_highlights.ts +10 -0
  331. package/src/scripts/check_release_holds.ts +2 -1
  332. package/src/scripts/check_release_pr_shape.ts +38 -15
  333. package/src/scripts/check_routing_coverage.ts +378 -11
  334. package/src/scripts/check_standing_rule_delivery.ts +33 -13
  335. package/src/scripts/check_static_parity.ts +17 -0
  336. package/src/scripts/check_verify_expectation_delta.ts +521 -0
  337. package/src/scripts/ci_settle.ts +145 -19
  338. package/src/scripts/closure_scan.ts +115 -1
  339. package/src/scripts/code_graph/detect.ts +127 -7
  340. package/src/scripts/code_graph/foreign.ts +194 -0
  341. package/src/scripts/code_graph/query.ts +217 -4
  342. package/src/scripts/code_graph/types.ts +26 -2
  343. package/src/scripts/code_graph/validate.ts +3 -1
  344. package/src/scripts/code_graph/verbs.ts +103 -0
  345. package/src/scripts/compile_hook_manifest.ts +90 -14
  346. package/src/scripts/condense.ts +14 -27
  347. package/src/scripts/estate_exemption_shape.ts +202 -0
  348. package/src/scripts/external_sources_denylist.json +29 -4
  349. package/src/scripts/generate_host_cost_table.ts +1 -1
  350. package/src/scripts/hook_manifest.json +1 -1
  351. package/src/scripts/hook_manifest.yaml +192 -14
  352. package/src/scripts/hooks/block_config_weakening.ts +294 -4
  353. package/src/scripts/hooks/block_kernel_rule_writes.ts +9 -78
  354. package/src/scripts/hooks/block_plumbing_writes.ts +342 -0
  355. package/src/scripts/hooks/bundle_integrity.ts +291 -0
  356. package/src/scripts/hooks/chain_nudge_hook.ts +1 -2
  357. package/src/scripts/hooks/code_graph_context_hook.ts +253 -25
  358. package/src/scripts/hooks/comment_discipline_hook.ts +1 -3
  359. package/src/scripts/hooks/concern_failure_policy.ts +280 -0
  360. package/src/scripts/hooks/concern_registry.ts +2 -0
  361. package/src/scripts/hooks/concern_timings.ts +94 -0
  362. package/src/scripts/hooks/delegation_nudge_hook.ts +1 -2
  363. package/src/scripts/hooks/design_pass_hook.ts +100 -9
  364. package/src/scripts/hooks/design_slop_hook.ts +1 -2
  365. package/src/scripts/hooks/dispatch_hook.ts +77 -71
  366. package/src/scripts/hooks/edit_shape_hook.ts +1 -2
  367. package/src/scripts/hooks/end_review_nudge_hook.ts +1 -1
  368. package/src/scripts/hooks/evidence_independence.ts +1 -2
  369. package/src/scripts/hooks/exit_codes.ts +129 -0
  370. package/src/scripts/hooks/host_lowering.json +1 -0
  371. package/src/scripts/hooks/host_lowering.ts +146 -3
  372. package/src/scripts/hooks/host_lowering.yaml +262 -46
  373. package/src/scripts/hooks/host_semantics.ts +30 -2
  374. package/src/scripts/hooks/injection_budget.ts +9 -2
  375. package/src/scripts/hooks/interruption_ledger_hook.ts +1 -2
  376. package/src/scripts/hooks/journal_record_hook.ts +1 -2
  377. package/src/scripts/hooks/memory_recall_hook.ts +1 -2
  378. package/src/scripts/hooks/obligation_settle_hook.ts +151 -5
  379. package/src/scripts/hooks/one_question_per_ask_hook.ts +43 -6
  380. package/src/scripts/hooks/orchestration_record_hook.ts +1 -2
  381. package/src/scripts/hooks/payload_stub.ts +10 -1
  382. package/src/scripts/hooks/permission_gate.ts +1 -2
  383. package/src/scripts/hooks/push_settle_hook.ts +1 -3
  384. package/src/scripts/hooks/replay_hook.ts +2 -1
  385. package/src/scripts/hooks/reread_guard_hook.ts +1 -2
  386. package/src/scripts/hooks/review_baseline_hook.ts +1 -2
  387. package/src/scripts/hooks/rtk_wrap_hook.ts +1 -2
  388. package/src/scripts/hooks/rule_inject_hook.ts +765 -68
  389. package/src/scripts/hooks/run_continuation_hook.ts +13 -15
  390. package/src/scripts/hooks/skill_route_hook.ts +1 -2
  391. package/src/scripts/hooks/source_first_gate_hook.ts +1 -2
  392. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +1 -2
  393. package/src/scripts/hooks/subagent_ledger_hook.ts +1 -2
  394. package/src/scripts/hooks/suggestion_capture_hook.ts +1 -1
  395. package/src/scripts/hooks/table_fingerprint.ts +70 -0
  396. package/src/scripts/hooks/telemetry_flush_hook.ts +1 -2
  397. package/src/scripts/hooks/telemetry_usage_hook.ts +65 -10
  398. package/src/scripts/hooks/tool_result_bytes_hook.ts +93 -9
  399. package/src/scripts/hooks/turn_end_gate_hook.ts +421 -324
  400. package/src/scripts/hooks/ui_route_nudge_hook.ts +1 -2
  401. package/src/scripts/hooks_status.ts +112 -0
  402. package/src/scripts/injection_scan_hook.ts +7 -0
  403. package/src/scripts/install.ts +75 -189
  404. package/src/scripts/installed_layer_report.ts +103 -0
  405. package/src/scripts/language_mirror_hook.ts +15 -6
  406. package/src/scripts/learning_sidecar.ts +119 -8
  407. package/src/scripts/lint_deny_text.ts +419 -0
  408. package/src/scripts/lint_exit_codes.ts +262 -0
  409. package/src/scripts/lint_hook_manifest.ts +108 -0
  410. package/src/scripts/lint_major_migration_sections.ts +156 -26
  411. package/src/scripts/lint_pack_boundaries.ts +63 -5
  412. package/src/scripts/lint_roadmap_blockers.ts +78 -12
  413. package/src/scripts/lint_rule_law_section.ts +517 -0
  414. package/src/scripts/lint_skill_descriptions.ts +1 -1
  415. package/src/scripts/lint_skill_scripts_readonly.ts +1 -1
  416. package/src/scripts/lint_workflow_security.ts +161 -5
  417. package/src/scripts/mcp_server/consumer_tool_catalog.json +53 -0
  418. package/src/scripts/mcp_server/graph_tools.ts +72 -3
  419. package/src/scripts/mcp_server/prompts.ts +142 -1
  420. package/src/scripts/measure_routing_signal.ts +54 -16
  421. package/src/scripts/measure_skill_ranker_baseline.ts +515 -53
  422. package/src/scripts/measure_turn_end_gate.ts +193 -0
  423. package/src/scripts/memory_learn_hook.ts +91 -6
  424. package/src/scripts/memory_lookup.ts +1 -1
  425. package/src/scripts/model_rule_injection.ts +52 -5
  426. package/src/scripts/prepack-check.mjs +53 -0
  427. package/src/scripts/project_thin_rules.ts +34 -327
  428. package/src/scripts/release_publication.ts +13 -1
  429. package/src/scripts/report_evidence_temperature.ts +657 -0
  430. package/src/scripts/report_installed_rule_links.ts +157 -0
  431. package/src/scripts/report_invocation_surface.ts +383 -0
  432. package/src/scripts/report_label_agreement.ts +233 -0
  433. package/src/scripts/report_obligation_carriers.ts +699 -0
  434. package/src/scripts/report_obligation_mechanism.ts +230 -0
  435. package/src/scripts/report_obligation_writer_split.ts +236 -0
  436. package/src/scripts/report_single_token_triggers.ts +277 -0
  437. package/src/scripts/report_skill_menu_census.ts +208 -0
  438. package/src/scripts/report_skill_ranker_confusion.ts +442 -0
  439. package/src/scripts/report_standing_payload_by_host.ts +188 -15
  440. package/src/scripts/report_verification_record_coverage.ts +306 -0
  441. package/src/scripts/roadmap_verify_share.ts +213 -0
  442. package/src/scripts/rule_activation_census.ts +5 -2
  443. package/src/scripts/rule_trigger_eval.ts +9 -9
  444. package/src/scripts/schemas/command.schema.json +44 -0
  445. package/src/scripts/schemas/review-findings.schema.json +7 -2
  446. package/src/scripts/schemas/rule.schema.json +5 -0
  447. package/src/scripts/schemas/skill.schema.json +44 -0
  448. package/src/scripts/self_review_gate.ts +421 -17
  449. package/src/scripts/semantic_parity_census.ts +314 -0
  450. package/src/scripts/session_eol_report.ts +54 -1
  451. package/src/scripts/skill_tools/score_skill_relevance.ts +290 -25
  452. package/src/scripts/sweep_dead_scan_roots.ts +1 -1
  453. package/src/scripts/sweep_skill_ranker_signals.ts +129 -0
  454. package/src/scripts/sync_gitignore.ts +29 -235
  455. package/src/scripts/trigger_eval_grandfather.json +0 -7
  456. package/src/scripts/trigger_eval_rotation.ts +122 -27
  457. package/src/scripts/ui_conformance_probe.ts +156 -4
  458. package/src/scripts/update_prices.ts +85 -3
  459. package/src/scripts/write_bundle_digest.mjs +74 -0
  460. package/src/server/schemas/settings.ts +41 -0
  461. package/src/shared/skillRanking.ts +117 -11
  462. package/src/templates/consumer-settings/ONBOARDING.md +1 -0
  463. package/dist/cli-delegate/chunk-FXYUYU4N.js +0 -4416
  464. package/dist/cli-delegate/chunk-QJWAUMJW.js +0 -1072
  465. package/dist/cli-delegate/chunk-YDPILCOL.js +0 -152
  466. package/dist/ui/assets/index-DH1xc62v.js +0 -29
  467. package/dist/ui/assets/index-jSfMW8_t.css +0 -1
@@ -32,11 +32,11 @@
32
32
  *
33
33
  * TWO of the five are CONDITIONAL, and saying "unconditional" here would be the
34
34
  * same stale-header defect this block corrects below. A and D are the
35
- * completion-adjacent pair an open subagent dispatch excuses, so `main()` runs
36
- * them only when `dispatchOpen` is false; B, C and E run on every turn-end. That
37
- * narrowing is deliberate (Phase 3 Step 2, narrowed again by R2 round 2) and is
38
- * the third allow path, alongside the two re-entrancy layers — see the
39
- * per-detector list in `main()`.
35
+ * completion-adjacent pair an open subagent dispatch excuses, so `runDetectors()`
36
+ * runs them only when `dispatchOpen` is false; B, C and E run on every turn-end.
37
+ * That narrowing is deliberate (Phase 3 Step 2, narrowed again by R2 round 2) and
38
+ * is the third allow path, alongside the two re-entrancy layers — see the
39
+ * per-detector list in `runDetectors()`.
40
40
  *
41
41
  * This block said "Three" and listed A/B/C until 2026-08-18, while `DetectorId`
42
42
  * below has carried four since round 7. `DETECTOR_IDS` in
@@ -154,7 +154,6 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
154
154
  import {
155
155
  classify,
156
156
  hasStableSessionId,
157
- isSyntheticPrompt,
158
157
  statePathFor as languageStatePathFor,
159
158
  type Verdict,
160
159
  } from '../language_mirror_hook.js';
@@ -163,7 +162,15 @@ import {
163
162
  // write" is only true while the two agree, and importing a constant made that
164
163
  // agreement invisible when the producer's layout moved. A builder makes the move
165
164
  // a type error.
166
- import { statePathFor as ciStatePathFor } from '../before_complete_hook.js';
165
+ import {
166
+ statePathFor as ciStatePathFor,
167
+ readTurnRunState,
168
+ type TurnRunState,
169
+ } from '../before_complete_hook.js';
170
+ // Re-exported: two test files address `readTurnRunState` at this path, and a
171
+ // reader arriving at detector C should find the reader where the detector uses
172
+ // it. The DEFINITION lives with the producer of the shape it reads.
173
+ export { readTurnRunState };
167
174
  // The spec-backed options-block reading, imported rather than re-derived:
168
175
  // `user-interaction` Iron Law 1's definition of an ask — a block PLUS its
169
176
  // recommendation line — lives in exactly one place and detector E reads it from
@@ -179,9 +186,29 @@ import { statePathFor as ciStatePathFor } from '../before_complete_hook.js';
179
186
  // Detector E, extracted to `_lib` where its prose costs no ratchet debt.
180
187
  // Re-exported so every existing importer of this module is unchanged.
181
188
  import { detectDroppedDecision } from '../_lib/dropped_decision.js';
189
+ // Step 3.2 of `road-to-a-graph-that-feeds-the-gate` — the shadow feeder. It
190
+ // lives in `_lib` for the reason every other extraction here does: this file
191
+ // sits against a 1,500-line shrink-only source budget, and the module it would
192
+ // otherwise grow into is one a recall reading has to be able to open on its own.
193
+ import {
194
+ appendFeederRow,
195
+ buildFeederRow,
196
+ type FeederLayer,
197
+ graphUntestedVerdict,
198
+ } from '../_lib/graph_feeder_record.js';
199
+ import { type GraphState, graphState } from '../code_graph/detect.js';
200
+ import { isVerificationCommand } from '../_lib/verification_command.js';
201
+ // Round 8 — the record path detector C prefers over its own regex. The regex
202
+ // reads a command's TEXT and `echo test` matches it; these read what the run
203
+ // exited with and what it printed.
204
+ import {
205
+ NO_RED_EVIDENCE_REASON,
206
+ describeRecordFinding as recordReason,
207
+ hasRedThenGreen,
208
+ readRunEvidence,
209
+ } from '../_lib/verification_evidence.js';
182
210
 
183
211
  export { detectDroppedDecision };
184
- import { isSafeTranscriptPath } from './end_review_nudge_hook.js';
185
212
  import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
186
213
  import { readHookStdin } from './hook_stdin.js';
187
214
  import {
@@ -193,18 +220,24 @@ import { openRecordStats } from './subagent_ledger_hook.js';
193
220
  import {
194
221
  deriveSessionKey,
195
222
  foldRefusal,
223
+ foldShadow,
196
224
  parseRecord,
225
+ parseShadowRecord,
197
226
  readInstallBoundary,
198
227
  sessionRefusalFile,
228
+ sessionShadowFile,
229
+ shadowSetBy,
199
230
  type RefusalRecord,
231
+ type ShadowLayer,
232
+ type ShadowRecord,
200
233
  } from '../_lib/turn_end_refusals.js';
234
+ import { EXIT_ALLOW, EXIT_BLOCK } from './exit_codes.js';
201
235
 
202
236
  /** Dispatcher-internal block code. Pinned to 1 by `concern_block_exit_parity`. */
203
- const EXIT_BLOCK = 1;
204
- const EXIT_ALLOW = 0;
205
237
 
206
238
  /**
207
- * Transcript-read ceiling for this hook, passed at the call site in `main()`.
239
+ * Transcript-read ceiling for this hook, passed at its one production call
240
+ * site, `assembleDetectorInputs()`.
208
241
  * Deliberately well under `isSafeTranscriptPath`'s own 50 MB refusal: the
209
242
  * ordinal is a count over every entry, so the whole file is walked once per
210
243
  * turn-end, and a session file grows for the life of the session. Past this the
@@ -227,6 +260,18 @@ export interface Finding {
227
260
  /** The span that triggered it — quoted back so the refusal is actionable. */
228
261
  evidence: string;
229
262
  reason: string;
263
+ /**
264
+ * Which evidence path produced this finding, where a detector has two.
265
+ *
266
+ * Detectors C and F set it — the sentence here read "Only detector C" while
267
+ * F set it two functions below, which an independent review caught.
268
+ * `record` means a persisted run record was read;
269
+ * `transcript` means the recorder left nothing for this turn and the
270
+ * command-text scan answered instead — the mode a replay through
271
+ * `measure_turn_end_gate` / `check_detector_corpus` always runs in, so a
272
+ * corpus reading stays reproducible after the record path landed.
273
+ */
274
+ mode?: 'record' | 'transcript';
230
275
  }
231
276
 
232
277
  // ---------------------------------------------------------------------------
@@ -487,6 +532,7 @@ export function readCiSettled(
487
532
  return { seen: false, settled: false };
488
533
  }
489
534
 
535
+
490
536
  /**
491
537
  * A completion claim in the delivered reply. Deliberately narrow: the German and
492
538
  * English closings the corpus actually produced, anchored to a line so a mid-reply
@@ -656,62 +702,14 @@ export function detectLanguage(reply: string, pinned: Verdict): Finding | null {
656
702
  const _EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
657
703
 
658
704
  /**
659
- * Shell commands that count as verification.
660
- *
661
- * A positive list, and deliberately narrow. The alternative — treating any
662
- * `Bash` call as verification — was rejected because `ls` would then clear an
663
- * unverified edit, which is the failure mode `verify-before-complete` names
664
- * ("relying on partial verification"). The cost of narrowness is a MISSED
665
- * detection when a project verifies by some command not listed here, and that is
666
- * the safe direction for a gate that can refuse a turn: a false negative costs
667
- * one unguarded turn, a false positive teaches the user to switch the gate off.
668
- *
669
- * ## Audited 2026-08-17 — `road-to-stop-gate-honesty` step 2.2
670
- *
671
- * 52 commands this project's own surface actually produces (its `package.json`
672
- * scripts, its `Taskfile` targets, `./scripts-run`, `./agent-config`, and the
673
- * PHP / Python / Go / Rust toolchains) were run through
674
- * `isVerificationCommand`. The table of every one, with its verdict, is
675
- * `turn_end_verify_allowlist.test.ts`; that file is the fixture step 2.2
676
- * requires, and every addition below has its own row in it.
677
- *
678
- * The draft that opened the roadmap expected to add `phpunit` and `pest`. Claim
679
- * 4 had already overtaken that — both matched before this audit, as did
680
- * `composer test` and `php artisan test` — so what the audit actually found was
681
- * two different gaps:
682
- *
683
- * · `phpstan` — a static analyser of exactly the class already listed
684
- * (`mypy`, `pyright`, `clippy`). Added.
685
- * · `lint` followed by a WORD character — `lint_persistence`,
686
- * `lint_provenance`, and every other `lint_*` script in `src/scripts/`
687
- * missed, because `\blint\b` needs a boundary and `_` is a word character.
688
- * `lint[-_:a-z]*` is the exact mirror of the `check[-_:a-z]*` this list
689
- * already carried, which is why it is the narrow fix rather than a new idea.
705
+ * Re-exported, not re-derived: the selector moved to `_lib/verification_command.ts`
706
+ * so the evidence classifier can read it without a hook<->lib import cycle.
690
707
  *
691
- * Four further misses were found and deliberately NOT added, because Risk 2 of
692
- * that roadmap is that every addition is a way to satisfy the gate without
693
- * verifying anything:
694
- *
695
- * · `npm run prepack` — a lifecycle hook whose content is per-project. Here it
696
- * validates; elsewhere it copies files. Recognising it would clear an
697
- * unverified edit in any repo whose prepack is a build step.
698
- * · `task sync` / `task generate-tools` / `agent-config roadmap:progress` —
699
- * GENERATORS. They rewrite the tree; they check nothing. A generator that
700
- * cleared the gate would be the rubber stamp in its purest form.
701
- * · `agent-config gates --all` — enumerates gates, runs none.
702
- * · `vendor/bin/rector process --dry-run` — a refactoring tool. Its dry run
703
- * prints a diff; it does not assert anything holds.
704
- *
705
- * `psalm` is the obvious sibling of `phpstan` and is also NOT added: nothing in
706
- * the audited surface runs it, and "the team actually runs" is the step's own
707
- * standard. It is named here so the next audit does not re-derive the question.
708
+ * The name stays exported HERE because two test files pin it at this path and
709
+ * because a reader arriving at detector C should find the selector where the
710
+ * detector uses it, not have to know it was relocated.
708
711
  */
709
- const _VERIFY_RE =
710
- /\b(test|tests|vitest|jest|pytest|phpunit|pest|tsc|eslint|ruff|mypy|pyright|clippy|phpstan|typecheck|lint[-_:a-z]*|build|ci|preflight|smoke[-_:a-z]*|check[-_:a-z]*|validate[-_:a-z]*)\b|\b(task|npm|pnpm|yarn|composer|cargo|go|make|php artisan|bun)\s+(run\s+)?\S*(test|check|lint|build|ci|typecheck)/i;
711
-
712
- export function isVerificationCommand(command: string): boolean {
713
- return _VERIFY_RE.test(command);
714
- }
712
+ export { isVerificationCommand };
715
713
 
716
714
  /**
717
715
  * Fire when the turn changed a file and then ran nothing that could have
@@ -728,28 +726,55 @@ export function isVerificationCommand(command: string): boolean {
728
726
  * no claim to verify, and refusing one would make the gate fire on the majority
729
727
  * of turns, which is how a guard gets disabled.
730
728
  */
731
- export function detectUnverifiedEdit(toolCalls: readonly ToolCall[]): Finding | null {
729
+ export function detectUnverifiedEdit(
730
+ toolCalls: readonly ToolCall[],
731
+ runState: TurnRunState | null = null,
732
+ ): Finding | null {
732
733
  let lastEdit = -1;
733
734
  for (let i = 0; i < toolCalls.length; i += 1) {
734
735
  if (_EDIT_TOOLS.has(toolCalls[i]!.name)) lastEdit = i;
735
736
  }
736
737
  if (lastEdit === -1) return null;
738
+ const edited = toolCalls[lastEdit]!.path ?? toolCalls[lastEdit]!.name;
739
+
740
+ // THE RECORD PATH. Preferred whenever the recorder demonstrably witnessed
741
+ // this turn — see `readTurnRunState`, which returns null otherwise. The
742
+ // regex path below stays reachable and is not deprecated: it is the only
743
+ // path a transcript replay has, and it is what a host that binds no
744
+ // `post_tool_use` slot keeps.
745
+ if (runState !== null) {
746
+ const reading = readRunEvidence(runState);
747
+ if (reading.passed) return null;
748
+ // An instrument gap is never refused on (Risk 1 of the plan): the host
749
+ // surfaced no exit code, or wrote a record nothing can place, and
750
+ // neither is a fact about the operator's work. The transcript answer
751
+ // stands in, which is exactly the behavior before this path existed.
752
+ if (!reading.instrumentGap) {
753
+ return {
754
+ detector: 'verification',
755
+ evidence: edited,
756
+ reason: recordReason(reading.failed, reading.reasons),
757
+ mode: 'record',
758
+ };
759
+ }
760
+ }
761
+
737
762
  for (let i = lastEdit + 1; i < toolCalls.length; i += 1) {
738
763
  const c = toolCalls[i]!;
739
764
  if (c.name === 'Bash' && c.command !== undefined && isVerificationCommand(c.command)) {
740
765
  return null;
741
766
  }
742
767
  }
743
- const edited = toolCalls[lastEdit]!.path;
744
768
  return {
745
769
  detector: 'verification',
746
770
  // The path, never the diff: the evidence span is quoted into a refusal,
747
771
  // and `ToolCall` is shaped so a file body cannot reach it.
748
- evidence: edited ?? toolCalls[lastEdit]!.name,
772
+ evidence: edited,
749
773
  reason:
750
774
  'this turn changed a file and then ran no verification command — ' +
751
775
  'no test, type-check, lint or build call follows the last edit ' +
752
776
  '(verify-before-complete: a claim without a fresh run is unverified)',
777
+ mode: 'transcript',
753
778
  };
754
779
  }
755
780
 
@@ -827,13 +852,55 @@ function _isProductionSource(p: string): boolean {
827
852
  * turn; drop the second and it fires on a turn that did its job; drop the third
828
853
  * and it fires mid-cycle on the red step of red-green-refactor.
829
854
  */
830
- export function detectUntestedChange(reply: string, toolCalls: readonly ToolCall[]): Finding | null {
855
+ export function detectUntestedChange(
856
+ reply: string,
857
+ toolCalls: readonly ToolCall[],
858
+ runState: TurnRunState | null = null,
859
+ ): Finding | null {
831
860
  const edited = toolCalls
832
861
  .filter((c) => _EDIT_TOOLS.has(c.name) && c.path !== undefined)
833
862
  .map((c) => c.path as string);
834
863
  const source = edited.filter(_isProductionSource);
835
864
  if (source.length === 0) return null;
836
- if (edited.some(_isTestPath)) return null;
865
+
866
+ // THE TEST-FILE ESCAPE, and why step 5.1 narrowed it rather than removed it.
867
+ //
868
+ // "A test file was touched somewhere in the turn" is satisfied by a file
869
+ // containing `it('works', () => expect(true).toBe(true))`. A test never seen
870
+ // red has unknown sensitivity — it may assert nothing the change could
871
+ // break — so the escape as written accepted the shape of evidence instead of
872
+ // evidence.
873
+ //
874
+ // With records available, the escape asks for the cheapest observable proof
875
+ // that the assertion discriminates: the same target red, then green after
876
+ // the last edit. Without records it stays exactly as it was, because the
877
+ // transcript cannot see an exit code and a detector that refused on a
878
+ // transcript-only host would refuse every honest turn there.
879
+ // A NEW test file, not any test edit. Step 5.1 says "accepts a NEW test file
880
+ // only with that pair present", and `edited.some(_isTestPath)` is any
881
+ // test-path edit — so adjusting an assertion in an existing test alongside a
882
+ // production change and running it green once was refused. New-vs-existing
883
+ // is not decidable from a transcript; `Write` on a test path is the usable
884
+ // proxy, because `Edit` and `MultiEdit` presuppose a file that already
885
+ // existed. Narrowing here can only ALLOW turns the previous line refused.
886
+ const wroteNewTest = toolCalls.some(
887
+ (c) => c.name === 'Write' && c.path !== undefined && _isTestPath(c.path),
888
+ );
889
+ let noRedEvidence = false;
890
+ if (edited.some(_isTestPath)) {
891
+ // An EXISTING test adjusted alongside a production change keeps the old
892
+ // escape untouched — `Edit` and `MultiEdit` presuppose a file that was
893
+ // already there, so nothing new was claimed and there is no new
894
+ // assertion whose sensitivity is unknown.
895
+ if (!wroteNewTest) return null;
896
+ if (runState === null) return null;
897
+ if (hasRedThenGreen(runState)) return null;
898
+ // An instrument gap is not a missing red. Same rule as detector C: the
899
+ // host surfaced no exit code, so nothing about the operator's work is
900
+ // known, and the pre-record behavior stands in.
901
+ if (readRunEvidence(runState).instrumentGap) return null;
902
+ noRedEvidence = true;
903
+ }
837
904
 
838
905
  // The claim gate, reusing detector D's pair rather than a second dialect of
839
906
  // "done" — two lists of completion phrasings would drift, and the negation
@@ -844,8 +911,21 @@ export function detectUntestedChange(reply: string, toolCalls: readonly ToolCall
844
911
  if (_NEGATED_CLAIM_RE.test(_lineAround(prose, m.index))) return null;
845
912
 
846
913
  const shown = source.slice(0, 3).join(', ');
914
+ if (noRedEvidence) {
915
+ return {
916
+ detector: 'untested',
917
+ evidence: shown + (source.length > 3 ? ` (+${String(source.length - 3)} more)` : ''),
918
+ mode: 'record',
919
+ reason: NO_RED_EVIDENCE_REASON,
920
+ };
921
+ }
922
+ // `transcript` unconditionally: no record contributed to THIS verdict — it
923
+ // is reached because the turn touched no test file at all, which is read off
924
+ // the tool calls. Labelling it `record` because a run state happened to be
925
+ // available would misattribute the evidence.
847
926
  return {
848
927
  detector: 'untested',
928
+ mode: 'transcript',
849
929
  evidence: shown + (source.length > 3 ? ` (+${String(source.length - 3)} more)` : ''),
850
930
  reason:
851
931
  'a completion claim over production code this turn changed, with NO test file ' +
@@ -984,199 +1064,20 @@ function markRefusedTurn(
984
1064
  // Transcript
985
1065
  // ---------------------------------------------------------------------------
986
1066
 
987
- /**
988
- * One tool call the assistant made, reduced to what a detector can reason
989
- * about: the tool's name, the shell command when it is a shell call, and the
990
- * target path when it is a file write.
991
- *
992
- * Nothing else is kept. A tool input can hold a whole file body, and this
993
- * struct is what a refusal quotes back — so it is shaped to be INCAPABLE of
994
- * carrying one, the same PII-exclusion-by-construction discipline
995
- * `domain-safety-pii` § Surface 2 asks for in a log line.
996
- */
997
- export interface ToolCall {
998
- name: string;
999
- /** `Bash` only — the command line, so "did anything verify" is answerable. */
1000
- command?: string;
1001
- /** Edit/Write only — the file the turn changed, used as the evidence span. */
1002
- path?: string;
1003
- }
1004
-
1005
- export interface TranscriptTail {
1006
- lastAssistant: string;
1007
- /**
1008
- * Every assistant text of the CURRENT turn, in order — reset at each genuine
1009
- * user prompt exactly like `toolCalls`, and for the same reason: a question
1010
- * asked three turns ago was answered, a question asked earlier in THIS turn
1011
- * was not. Detector E reads it; the other four read `lastAssistant`, which is
1012
- * always the last element when this is non-empty.
1013
- *
1014
- * A tool-only assistant entry contributes NOTHING here. `_messageText`
1015
- * returns null for it and the collector skips it, so the ordinary shape of a
1016
- * working turn — prose, tool call, prose — produces two elements and not
1017
- * three. That is the whole false-positive surface of detector E, and it is
1018
- * closed here rather than in the detector.
1019
- */
1020
- assistantTurnTexts: string[];
1021
- /**
1022
- * How many GENUINE user prompts the transcript carries — harness-injected
1023
- * user-role entries excluded via `isSyntheticPrompt`. This is the turn's
1024
- * identity; see `alreadyRefusedTurn` for why the prompt's text is not.
1025
- */
1026
- turnOrdinal: number;
1027
- /**
1028
- * The tool calls of the CURRENT turn, in order — reset at every genuine user
1029
- * prompt, so a verification from three turns ago cannot vouch for an edit
1030
- * made now. `_messageText` keeps only `type === 'text'` blocks, which is why
1031
- * this needed its own extraction rather than a reading of `lastAssistant`:
1032
- * tool activity is not in the prose at all.
1033
- */
1034
- toolCalls: ToolCall[];
1035
- }
1036
-
1037
- /**
1038
- * The last assistant text, plus the turn ordinal, from a Claude JSONL
1039
- * transcript. Mirrors `chat_history._extract_claude_transcript_response` for
1040
- * the assistant half.
1041
- *
1042
- * `isSyntheticPrompt` is applied to every user-role entry — the same filter
1043
- * `language_mirror_hook` uses, and for the same reason it was added there
1044
- * (round-5 § 6.5): a background-task notification and a `<system-reminder>`
1045
- * both occupy the user role without being chat messages. Counting them would
1046
- * move the turn ordinal mid-turn, which is R2 finding 3.
1047
- */
1048
- export function readTranscriptTail(
1049
- transcriptPath: string,
1050
- opts: { homeDir?: string; maxBytes?: number } = {},
1051
- ): TranscriptTail {
1052
- const empty: TranscriptTail = {
1053
- lastAssistant: '',
1054
- turnOrdinal: 0,
1055
- toolCalls: [],
1056
- assistantTurnTexts: [],
1057
- };
1058
- if (!transcriptPath || !isSafeTranscriptPath(transcriptPath, opts)) return empty;
1059
- let lines: string[];
1060
- try {
1061
- // R2 finding 12: `maxBytes` was accepted in the options type and never
1062
- // used, so the declared cap was decoration. It is enforced here as well
1063
- // as inside `isSafeTranscriptPath`, because this is the read it bounds.
1064
- // The whole file still has to be walked — the turn ordinal is a count
1065
- // over all entries, not something a tail can answer — so the cap is the
1066
- // guard, not an optimisation.
1067
- if (opts.maxBytes !== undefined && fs.statSync(transcriptPath).size > opts.maxBytes) {
1068
- return empty;
1069
- }
1070
- lines = fs.readFileSync(transcriptPath, 'utf-8').split('\n');
1071
- } catch {
1072
- return empty;
1073
- }
1074
- let lastAssistant = '';
1075
- let turnOrdinal = 0;
1076
- let toolCalls: ToolCall[] = [];
1077
- let assistantTurnTexts: string[] = [];
1078
- for (const rawLine of lines) {
1079
- const line = rawLine.trim();
1080
- if (!line) continue;
1081
- let obj: unknown;
1082
- try {
1083
- obj = JSON.parse(line);
1084
- } catch {
1085
- continue;
1086
- }
1087
- if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) continue;
1088
- const entry = obj as Record<string, unknown>;
1089
- // Sidechain entries are a SUBAGENT's conversation recorded in the same
1090
- // JSONL. A subagent prompt is a genuine-looking user-role text entry
1091
- // appended mid-turn, so counting it moves the ordinal within the turn —
1092
- // finding 3's failure class in a new shape (R2 round 2, finding 3).
1093
- if (entry['isSidechain'] === true) continue;
1094
- const role = entry['type'];
1095
- if (role !== 'assistant' && role !== 'user') continue;
1096
- const msg = entry['message'];
1097
- if (typeof msg !== 'object' || msg === null || Array.isArray(msg)) continue;
1098
- const content = (msg as Record<string, unknown>)['content'];
1099
- // Tool calls are read BEFORE the text guard: an assistant entry that is
1100
- // only a tool_use block has no text at all, so `continue`-ing on a null
1101
- // text would drop exactly the entries this detector exists to see.
1102
- if (role === 'assistant') {
1103
- toolCalls.push(...extractToolCalls(content));
1104
- }
1105
- const text = _messageText(content);
1106
- if (text === null) continue;
1107
- if (role === 'assistant') {
1108
- lastAssistant = text;
1109
- assistantTurnTexts.push(text);
1110
- } else if (!isSyntheticPrompt(text)) {
1111
- turnOrdinal += 1;
1112
- // A genuine user prompt starts a new turn, so the previous turn's
1113
- // tool activity stops counting. Without this reset, a verification
1114
- // run three turns ago would vouch for an edit made now — the
1115
- // "fresh" in edit-without-FRESH-verification is this line.
1116
- toolCalls = [];
1117
- // Same boundary, same reason, for detector E: an options block the
1118
- // user has since replied to is answered, not dropped. Resetting on
1119
- // the identical line is what makes "in the SAME user turn" true of
1120
- // the array rather than merely intended.
1121
- assistantTurnTexts = [];
1122
- }
1123
- }
1124
- return {
1125
- lastAssistant: lastAssistant.trim(),
1126
- turnOrdinal,
1127
- toolCalls,
1128
- assistantTurnTexts,
1129
- };
1130
- }
1131
-
1132
- /**
1133
- * Extract this entry's tool calls, keeping only name, shell command and target
1134
- * path. A tool input can hold a whole file body; nothing but those three fields
1135
- * is carried forward.
1136
- *
1137
- * Exported because `measure_turn_end_gate` scores detectors C and E over a real
1138
- * transcript corpus and therefore has to rebuild the same `ToolCall[]` the gate
1139
- * sees. A second extractor there would be a second dialect of "what the turn
1140
- * did", and the measurement would then be of that dialect rather than of the
1141
- * shipped gate — the exact population-parity defect that script's own header
1142
- * documents twice.
1143
- */
1144
- export function extractToolCalls(content: unknown): ToolCall[] {
1145
- if (!Array.isArray(content)) return [];
1146
- const out: ToolCall[] = [];
1147
- for (const blk of content) {
1148
- if (typeof blk !== 'object' || blk === null || Array.isArray(blk)) continue;
1149
- const b = blk as Record<string, unknown>;
1150
- if (b['type'] !== 'tool_use') continue;
1151
- const name = b['name'];
1152
- if (typeof name !== 'string') continue;
1153
- const input = b['input'];
1154
- const call: ToolCall = { name };
1155
- if (typeof input === 'object' && input !== null && !Array.isArray(input)) {
1156
- const inp = input as Record<string, unknown>;
1157
- const cmd = inp['command'];
1158
- if (typeof cmd === 'string') call.command = cmd;
1159
- const p = inp['file_path'] ?? inp['path'] ?? inp['notebook_path'];
1160
- if (typeof p === 'string') call.path = p;
1161
- }
1162
- out.push(call);
1163
- }
1164
- return out;
1165
- }
1166
-
1167
- function _messageText(content: unknown): string | null {
1168
- if (typeof content === 'string') return content;
1169
- if (!Array.isArray(content)) return null;
1170
- const parts: string[] = [];
1171
- for (const blk of content) {
1172
- if (typeof blk !== 'object' || blk === null || Array.isArray(blk)) continue;
1173
- const b = blk as Record<string, unknown>;
1174
- if (b['type'] !== 'text') continue;
1175
- const t = b['text'];
1176
- if (typeof t === 'string') parts.push(t);
1177
- }
1178
- return parts.length > 0 ? parts.join('\n') : null;
1179
- }
1067
+ // Extracted to `_lib/turn_end_transcript.ts` — same move, same reason, as
1068
+ // detector E above: `_lib` is where the prose costs no ratchet debt, and
1069
+ // this file hit its 1,500-line source budget when the shadow read landed.
1070
+ // Re-exported so every existing importer of this module is unchanged;
1071
+ // `tests/scripts/turn_end_gate_hook.test.ts` addresses all three names at
1072
+ // this path.
1073
+ import { readTranscriptTail, type ToolCall } from '../_lib/turn_end_transcript.js';
1074
+
1075
+ export {
1076
+ extractToolCalls,
1077
+ readTranscriptTail,
1078
+ type ToolCall,
1079
+ type TranscriptTail,
1080
+ } from '../_lib/turn_end_transcript.js';
1180
1081
 
1181
1082
  // ---------------------------------------------------------------------------
1182
1083
  // main
@@ -1186,18 +1087,41 @@ function str(v: JsonValue | undefined): string {
1186
1087
  return typeof v === 'string' ? v : '';
1187
1088
  }
1188
1089
 
1189
- export function main(): number {
1190
- let envelope: JsonObject;
1191
- let payload: JsonObject;
1192
- try {
1193
- [envelope, payload] = unwrap(readHookStdin(), 'claude');
1194
- } catch {
1195
- return EXIT_ALLOW;
1196
- }
1197
-
1198
- // Layer 1 — the host's own answer to "did I already refuse this turn?".
1199
- if (payload['stop_hook_active'] === true) return EXIT_ALLOW;
1090
+ /**
1091
+ * Everything the detectors read, assembled once per stop event.
1092
+ *
1093
+ * Extracted for step 2.1 of `road-to-a-stop-that-holds`, and the extraction —
1094
+ * not the shadow write — is the load-bearing half. The contract's Q1 asks how
1095
+ * often a turn the gate ALLOWS on a re-entrancy layer would have been refused
1096
+ * again, which needs the detectors to run on the allow path. There were two
1097
+ * ways to get them there: call them from a second assembly written beside the
1098
+ * first, or assemble once and call from both. `obligation_settle_hook.ts`'s
1099
+ * header already rules out the first — "a detector whose shadow measurement
1100
+ * and live behaviour come from different code measures nothing" — so this
1101
+ * function exists so that there is exactly one assembly and exactly one
1102
+ * detector list, read by the live verdict and by the shadow alike.
1103
+ *
1104
+ * `null` means the turn has no assistant text, which is `main()`'s existing
1105
+ * allow and is not a detector input the shadow could have measured either.
1106
+ */
1107
+ interface DetectorInputs {
1108
+ workspaceRoot: string;
1109
+ rawSessionId: string;
1110
+ sessionKey: string;
1111
+ turnOrdinal: number;
1112
+ promptId: string;
1113
+ lastAssistant: string;
1114
+ toolCalls: ToolCall[];
1115
+ turnTexts: string[];
1116
+ closingFromPayload: boolean;
1117
+ dispatchOpen: boolean;
1118
+ runState: TurnRunState | null;
1119
+ }
1200
1120
 
1121
+ function assembleDetectorInputs(
1122
+ envelope: JsonObject,
1123
+ payload: JsonObject,
1124
+ ): DetectorInputs | null {
1201
1125
  const workspaceRoot = str(envelope['workspace_root'] as JsonValue | undefined) || process.cwd();
1202
1126
 
1203
1127
  // Layer 1b — an open subagent dispatch is an EXPLICIT allow
@@ -1219,7 +1143,8 @@ export function main(): number {
1219
1143
  // suppressed ALL FOUR detectors. A pending dispatch explains a promissory
1220
1144
  // closing (A) and an unsettled completion claim (D); it explains nothing
1221
1145
  // about a language mismatch (B) or an unverified edit (C), and silencing
1222
- // those was scope Step 2 never asked for. Applied per detector below.
1146
+ // those was scope Step 2 never asked for. Applied per detector in
1147
+ // `runDetectors`.
1223
1148
  //
1224
1149
  // R2 round 2, finding 1: the count is TTL-filtered inside
1225
1150
  // `openRecordStats`. A leaked record from a dispatch that never returned
@@ -1252,7 +1177,7 @@ export function main(): number {
1252
1177
  transcriptPath,
1253
1178
  { maxBytes: TRANSCRIPT_READ_MAX_BYTES },
1254
1179
  );
1255
- if (!lastAssistant) return EXIT_ALLOW;
1180
+ if (!lastAssistant) return null;
1256
1181
 
1257
1182
  // The closing reply comes from the field the host hands us when it supplies
1258
1183
  // one, not only from a file the host writes asynchronously. The transcript
@@ -1272,42 +1197,57 @@ export function main(): number {
1272
1197
  ? [...assistantTurnTexts, payloadClosing]
1273
1198
  : assistantTurnTexts;
1274
1199
 
1275
- // Layer 2 — keyed on the turn's ORDINAL, never on the prompt's text.
1276
- // A host that sends no `session_id` shares one bucket AND one small-integer
1277
- // ordinal namespace, so an unrelated session whose ordinal matches a stored
1278
- // `refused_turn` reads as already-refused and goes unguarded. R2 round 2,
1279
- // finding 16: the sibling hook documents its own bucket as degrading rather
1280
- // than colliding, and this one collides. It is named here rather than
1281
- // papered over — the degradation is toward UNDER-refusing, which is the safe
1282
- // direction, and layer 1 still covers the host that sends the flag.
1200
+ // The host's own id for the prompt being processed. Recorded beside the
1201
+ // ordinal in the refusal marker — never used as the guard key. The
1202
+ // ordinal's drift is what both re-entrancy layers exist for, so a later
1203
+ // reading needs the pair to tell a drifted ordinal from a second prompt.
1204
+ const promptId = str((payload['prompt_id'] ?? payload['promptId']) as JsonValue | undefined);
1283
1205
  // RAW id, kept beside the derived key rather than replaced by it:
1284
1206
  // `readLanguagePin` addresses the producer's own per-session file, and the
1285
- // producer keys that on the raw `session_id`. Passing `sessionKey` here would
1286
- // read a path nothing writes — the same shape as the STATE_FILE break this
1287
- // parameter exists to close.
1288
- // The host's own id for the prompt being processed. Read here and recorded
1289
- // beside the ordinal in the refusal marker — never used as the guard key.
1290
- // The ordinal's drift is what both re-entrancy layers exist for, so a later
1291
- // reading needs the pair to tell a drifted ordinal from a second prompt.
1292
- const promptId = str(
1293
- (payload['prompt_id'] ?? payload['promptId']) as JsonValue | undefined,
1294
- );
1207
+ // producer keys that on the raw `session_id`. Passing `sessionKey` there
1208
+ // would read a path nothing writes — the same shape as the STATE_FILE break
1209
+ // this pairing exists to close.
1295
1210
  const rawSessionId = str(envelope['session_id'] as JsonValue | undefined) || '';
1296
1211
  const sessionKey = deriveSessionKey(rawSessionId || 'unknown-session');
1297
- if (alreadyRefusedTurn(workspaceRoot, sessionKey, turnOrdinal)) return EXIT_ALLOW;
1298
-
1299
- // B and C run on every turn-end; A and D run only when no dispatch is open
1300
- // (the ternaries below). For the detectors that DO run, the gating is INSIDE
1301
- // each one — no promise, no pin mismatch, no unverified edit, no unsettled
1302
- // completion claim ⇒ no finding ⇒ the turn ends. That is the whole of "fires
1303
- // when it is warranted"; there is no second, configurable notion of warranted
1304
- // layered on top of it.
1212
+
1213
+ // ONE read of the recorder's state, shared by detectors C and F. Two reads
1214
+ // would be two file opens on the stop slot for one answer, and — worse —
1215
+ // could disagree if a post-tool event landed between them.
1305
1216
  //
1306
- // This said "Every detector runs on every turn-end" and enumerated three of
1307
- // four, three lines above the two `dispatchOpen` ternaries that refute it.
1308
- // Corrected 2026-08-19 with the header block at the top of this file: a reader
1309
- // arriving here met the false claim first, and the D comment below deferred to
1310
- // "the note at the top of this loop" — which was this sentence.
1217
+ // It used to sit AFTER the Layer-2 allow, with a comment saying "a retry
1218
+ // pays no file read it immediately discards". Step 2.1 makes that false
1219
+ // rather than leaving the comment to rot: a retry now RUNS the detectors
1220
+ // to record whether it would have been refused again, so the read is
1221
+ // consumed on that path too. The cost is one `readFileSync` per retry,
1222
+ // which is the budget line 2.1 pre-registers.
1223
+ const runState = readTurnRunState(workspaceRoot, rawSessionId);
1224
+
1225
+ return {
1226
+ workspaceRoot,
1227
+ rawSessionId,
1228
+ sessionKey,
1229
+ turnOrdinal,
1230
+ promptId,
1231
+ lastAssistant,
1232
+ toolCalls,
1233
+ turnTexts,
1234
+ closingFromPayload,
1235
+ dispatchOpen,
1236
+ runState,
1237
+ };
1238
+ }
1239
+
1240
+ /**
1241
+ * Run every detector over one assembled input set.
1242
+ *
1243
+ * The one list, called by the live verdict and by the shadow read. Its
1244
+ * ordering and its `dispatchOpen` ternaries are the gate's behaviour; nothing
1245
+ * about the shadow may change them, which is why the shadow calls this rather
1246
+ * than carrying a copy.
1247
+ */
1248
+ function runDetectors(inputs: DetectorInputs): Finding[] {
1249
+ const { dispatchOpen, lastAssistant, workspaceRoot, rawSessionId, toolCalls, runState } =
1250
+ inputs;
1311
1251
  const findings: Finding[] = [];
1312
1252
  for (const f of [
1313
1253
  // A and D are the completion-adjacent pair a pending dispatch excuses
@@ -1315,17 +1255,14 @@ export function main(): number {
1315
1255
  // excused by anything about a dispatch and run unchanged.
1316
1256
  dispatchOpen ? null : detectPromissory(lastAssistant),
1317
1257
  detectLanguage(lastAssistant, readLanguagePin(workspaceRoot, rawSessionId)),
1318
- detectUnverifiedEdit(toolCalls),
1319
- // Round 7 § Phase 1 — detector D. It is NOT unconditional, and this
1320
- // comment said it was while sitting one line above the `dispatchOpen`
1321
- // ternary that conditions it: A and D are both excused by an open
1322
- // dispatch, per the note at the top of this loop. Corrected 2026-08-18.
1323
- // It shipped with its own settings flag one commit earlier
1324
- // and that flag is gone: `hooks.turn_end_gate.*` was deleted on
1325
- // 2026-08-12 because a default-off safety gate is an absent one. Its
1326
- // gating is where the comment above says gating belongs — inside the
1327
- // detector: no CI observed, or a settled read, or no completion claim
1328
- // ⇒ no finding.
1258
+ detectUnverifiedEdit(toolCalls, runState),
1259
+ // Round 7 § Phase 1 — detector D. It is NOT unconditional: A and D are
1260
+ // both excused by an open dispatch, per the note above. It shipped with
1261
+ // its own settings flag one commit earlier and that flag is gone:
1262
+ // `hooks.turn_end_gate.*` was deleted on 2026-08-12 because a
1263
+ // default-off safety gate is an absent one. Its gating is where the
1264
+ // note above says gating belongs — inside the detector: no CI observed,
1265
+ // or a settled read, or no completion claim ⇒ no finding.
1329
1266
  dispatchOpen
1330
1267
  ? null
1331
1268
  : detectCompletionClaim(lastAssistant, readCiSettled(workspaceRoot, rawSessionId)),
@@ -1337,25 +1274,185 @@ export function main(): number {
1337
1274
  // that produced the measured failure — IS the continuation that drops
1338
1275
  // the decision, so narrowing E the same way would silence it in exactly
1339
1276
  // the case it exists for.
1340
- detectDroppedDecision(turnTexts, closingFromPayload),
1277
+ detectDroppedDecision(inputs.turnTexts, inputs.closingFromPayload),
1341
1278
  // Detector F takes the OTHER side of that same question, and the two
1342
1279
  // sitting adjacent is the clearest place to say why. E fires on a
1343
1280
  // question already asked, which an open dispatch cannot excuse. F fires
1344
1281
  // on a completion CLAIM, and a turn waiting on a subagent has not
1345
1282
  // finished — so its closing is not the claim F is about. Same slot,
1346
1283
  // opposite trigger, opposite treatment of `dispatchOpen`.
1347
- dispatchOpen ? null : detectUntestedChange(lastAssistant, toolCalls),
1284
+ dispatchOpen ? null : detectUntestedChange(lastAssistant, toolCalls, runState),
1348
1285
  ]) {
1349
1286
  if (f) findings.push(f);
1350
1287
  }
1288
+ return findings;
1289
+ }
1290
+
1291
+ /**
1292
+ * Write the graph feeder row for ONE stop (step 3.2).
1293
+ *
1294
+ * Called from both stop paths with the findings that path already computed, so
1295
+ * the F verdict in the row is the SAME verdict the gate acted on — never a
1296
+ * second evaluation written beside it, which is the shape
1297
+ * `obligation_settle_hook.ts` names as measuring nothing.
1298
+ *
1299
+ * It changes no exit code, and the whole body is wrapped: a stop that crashed on
1300
+ * its own instrument would be a new failure mode on a path that is supposed to
1301
+ * cost nothing. The graph half is skipped outright unless the state is `fresh`
1302
+ * or `edited` — an answer from an index that predates the change is not evidence
1303
+ * about the change.
1304
+ */
1305
+ function recordGraphFeeder(
1306
+ inputs: DetectorInputs,
1307
+ findings: readonly Finding[],
1308
+ layer: FeederLayer,
1309
+ ): void {
1310
+ if (is_replay_mode()) return;
1311
+ try {
1312
+ let state: GraphState;
1313
+ try {
1314
+ state = graphState(inputs.workspaceRoot);
1315
+ } catch {
1316
+ return;
1317
+ }
1318
+ // No graph at all is the silent case, exactly as it is for the context
1319
+ // hook: a consumer who never builds one never grows this file.
1320
+ if (state === 'absent') return;
1321
+ // The SAME path set detector F reads, filtered by the SAME predicate.
1322
+ // A completion review found the two arms running over different inputs:
1323
+ // the feeder passed every edit path while F filters to production
1324
+ // source, so the graph arm would have fired on precisely the turns F is
1325
+ // silent for — a recall comparison between two detectors answering
1326
+ // different questions, which is worse than no comparison.
1327
+ const paths = inputs.toolCalls
1328
+ .filter((c) => _EDIT_TOOLS.has(c.name) && c.path !== undefined)
1329
+ .map((c) => c.path as string)
1330
+ .filter(_isProductionSource);
1331
+ const f = findings.find((x) => x.detector === 'untested');
1332
+ appendFeederRow(
1333
+ inputs.workspaceRoot,
1334
+ inputs.sessionKey,
1335
+ buildFeederRow({
1336
+ turn: inputs.turnOrdinal,
1337
+ layer,
1338
+ state,
1339
+ fFired: f !== undefined,
1340
+ fMode: f?.mode ?? null,
1341
+ paths,
1342
+ graph: graphUntestedVerdict(inputs.workspaceRoot, state, paths),
1343
+ }),
1344
+ );
1345
+ } catch {
1346
+ // An instrument is never a reason to change what the gate does.
1347
+ }
1348
+ }
1349
+
1350
+ /**
1351
+ * Record what this retry WOULD have been refused for, then let it end.
1352
+ *
1353
+ * Never changes a verdict: every caller returns `EXIT_ALLOW` whatever happens
1354
+ * here, and the whole body is wrapped because a retry that crashed on its own
1355
+ * instrument would be a new failure mode on a path that previously did no work
1356
+ * at all. A retry with no findings still lands, as `retries_observed` without a
1357
+ * row — that is the only way Q1 can read below 1.
1358
+ */
1359
+ function recordShadow(inputs: DetectorInputs, layer: ShadowLayer): void {
1360
+ if (is_replay_mode()) return;
1361
+ try {
1362
+ const findings = runDetectors(inputs);
1363
+ // On `refused_turn` the answer is `ours` by construction — that marker
1364
+ // is this gate's own and nothing else writes it. On `stop_hook_active`
1365
+ // the host sets the flag for ANY stop hook's block, so it has to be
1366
+ // read from our own refusal record rather than assumed.
1367
+ const setBy = layer === 'refused_turn'
1368
+ ? 'ours'
1369
+ : shadowSetBy(inputs.workspaceRoot, inputs.sessionKey);
1370
+ recordGraphFeeder(inputs, findings, layer);
1371
+ const file = sessionShadowFile(inputs.workspaceRoot, inputs.sessionKey);
1372
+ let prev: ShadowRecord | null = null;
1373
+ try {
1374
+ prev = parseShadowRecord(fs.readFileSync(file, 'utf-8'));
1375
+ } catch {
1376
+ // Absent or unreadable prior state starts a fresh record.
1377
+ }
1378
+ atomic_write_json(
1379
+ file,
1380
+ foldShadow(prev, {
1381
+ detectors: findings.map((f) => f.detector),
1382
+ turnOrdinal: inputs.turnOrdinal,
1383
+ at: new Date().toISOString(),
1384
+ layer,
1385
+ setBy,
1386
+ }) as unknown as Record<string, unknown>,
1387
+ );
1388
+ } catch {
1389
+ // An instrument is never a reason to change what the gate does.
1390
+ }
1391
+ }
1392
+
1393
+ export function main(): number {
1394
+ let envelope: JsonObject;
1395
+ let payload: JsonObject;
1396
+ try {
1397
+ [envelope, payload] = unwrap(readHookStdin(), 'claude');
1398
+ } catch {
1399
+ return EXIT_ALLOW;
1400
+ }
1401
+
1402
+ // Layer 1 — the host's own answer to "did I already refuse this turn?".
1403
+ //
1404
+ // Still an unconditional allow. What changed in step 2.1 is that the turn
1405
+ // is MEASURED on the way out: the detectors run once against the same
1406
+ // inputs the live path would have built, and a finding becomes a
1407
+ // `would_refuse_again` row. The verdict is byte-identical to the version
1408
+ // that returned here immediately.
1409
+ if (payload['stop_hook_active'] === true) {
1410
+ try {
1411
+ const retryInputs = assembleDetectorInputs(envelope, payload);
1412
+ if (retryInputs !== null) recordShadow(retryInputs, 'stop_hook_active');
1413
+ } catch {
1414
+ // Assembly is throw-safe by construction today; the guard is here
1415
+ // because this path did NO work before 2.1, so a regression in any
1416
+ // reader it now calls must not turn a clean retry into a crash.
1417
+ }
1418
+ return EXIT_ALLOW;
1419
+ }
1420
+
1421
+ const inputs = assembleDetectorInputs(envelope, payload);
1422
+ if (inputs === null) return EXIT_ALLOW;
1423
+
1424
+ // Layer 2 — keyed on the turn's ORDINAL, never on the prompt's text.
1425
+ // A host that sends no `session_id` shares one bucket AND one small-integer
1426
+ // ordinal namespace, so an unrelated session whose ordinal matches a stored
1427
+ // `refused_turn` reads as already-refused and goes unguarded. R2 round 2,
1428
+ // finding 16: the sibling hook documents its own bucket as degrading rather
1429
+ // than colliding, and this one collides. It is named here rather than
1430
+ // papered over — the degradation is toward UNDER-refusing, which is the safe
1431
+ // direction, and layer 1 still covers the host that sends the flag.
1432
+ if (alreadyRefusedTurn(inputs.workspaceRoot, inputs.sessionKey, inputs.turnOrdinal)) {
1433
+ recordShadow(inputs, 'refused_turn');
1434
+ return EXIT_ALLOW;
1435
+ }
1436
+
1437
+ // B and C run on every turn-end; A and D run only when no dispatch is open.
1438
+ // For the detectors that DO run, the gating is INSIDE each one — no promise,
1439
+ // no pin mismatch, no unverified edit, no unsettled completion claim ⇒ no
1440
+ // finding ⇒ the turn ends. That is the whole of "fires when it is
1441
+ // warranted"; there is no second, configurable notion of warranted layered
1442
+ // on top of it.
1443
+ const findings = runDetectors(inputs);
1444
+ // The live verdict's own row. It is written BEFORE the branch below, so a
1445
+ // turn the gate allows and a turn it refuses are both in the record — a
1446
+ // recall reading needs the allowed turns as its negative arm.
1447
+ recordGraphFeeder(inputs, findings, 'live');
1351
1448
  if (findings.length === 0) return EXIT_ALLOW;
1352
1449
 
1353
1450
  markRefusedTurn(
1354
- workspaceRoot,
1355
- sessionKey,
1356
- turnOrdinal,
1451
+ inputs.workspaceRoot,
1452
+ inputs.sessionKey,
1453
+ inputs.turnOrdinal,
1357
1454
  findings.map((f) => f.detector),
1358
- promptId,
1455
+ inputs.promptId,
1359
1456
  );
1360
1457
 
1361
1458
  const lines = findings.map(