@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
@@ -36,41 +36,90 @@
36
36
  * does. On any other host, and on a `lean_projection.mode: eager-all` rollback,
37
37
  * it emits nothing.
38
38
  *
39
+ * THE INSTALL DECLARES THE SCOPE, THE PACKAGE SUPPLIES THE TEXT (step 1.3).
40
+ * A rule is delivered only when the host's own rule directories carry a file
41
+ * for it — `~/.claude/rules/` and `<project>/.claude/rules/`, as a union,
42
+ * because the host loads both. The router is the PACKAGE's list of what could
43
+ * route and is a larger set: 13 router tier rules are maintainer-only by
44
+ * `workspaces`, and before this filter a consumer could receive a body for a
45
+ * rule their install never carried. The two halves cannot be one read — under
46
+ * `delivery` the installed files are thin stubs by construction, so they can say
47
+ * WHICH rules are in scope and never WHAT they say. Where no host rule directory
48
+ * exists at all, nothing declares a scope and no filter applies; see
49
+ * `rule_layer_overlap.hostRuleLayerIds` for why that is not the empty set.
50
+ *
51
+ * THE HOST FORM, BY THE PROJECTOR'S OWN PARSER (step 1.4). Delivered text is
52
+ * `rule_law_section.ruleBody` of the file: frontmatter and HTML comments
53
+ * removed. Both are bytes the model cannot act on — the frontmatter is the
54
+ * routing surface the router has already read, and the comments are authoring
55
+ * scaffolding no host renders. `project_thin_rules.ts` calls the same function
56
+ * for the same reason, so "what a stub carries" and "what a delivery carries"
57
+ * cannot drift into two spellings. A file with nothing left after the strip is
58
+ * not delivered: an empty `<rule>` element is framing with no content in it.
59
+ *
60
+ * ONE STRING THE HOST DOES NOT REPLACE, AND A MANIFEST (step 1.5). Claude Code
61
+ * substitutes an `additionalContext` over 10,000 CHARACTERS with a path and a
62
+ * 2,000-character preview, so an oversized delivery does not arrive truncated —
63
+ * it does not arrive. {@link COMPOSED_CHARS} bounds the whole rule-produced
64
+ * string at 8,000, and `compose` fills it in the order D1 fixes: the law of
65
+ * every matched high-consequence rule, then the highest-priority full body that
66
+ * fits, then further bodies, then the law of everything still unsent, then the
67
+ * manifest. Nothing is ever cut mid-obligation — a rule whose law alone does not
68
+ * fit is REPORTED, because half a law reads like a whole one.
69
+ *
70
+ * AND EVERY MATCH IS IN THE MANIFEST, labelled `full`, `law`, `omitted_budget`
71
+ * or `source_unavailable`. A pointer is never labelled delivered. This is where
72
+ * `selectForInjection`'s `dropped` list is finally read: before it, a body the
73
+ * byte cap discarded left no trace, so a consumer could not tell a rule that did
74
+ * not match from one that matched and was thrown away.
75
+ *
39
76
  * ONE MATCHER, SHARED WITH THE OFFLINE MODEL. Everything about selection,
40
77
  * ordering, capping and body loading comes from `_lib/rule_injection.ts`, which
41
78
  * `model_rule_injection.ts` also imports. Step 0.5 states the reason in as many
42
79
  * words: an experiment whose offline pricing and runtime delivery use different
43
80
  * matchers measures nothing.
44
81
  *
45
- * ONCE PER SESSION PER RULE, RE-ARMED ON COMPACTION. A rule's body is injected
46
- * the first time one of its triggers fires and not again, because the model
47
- * already has it. Compaction is exactly the event that makes that false, so
48
- * `pre_compact` clears the seen-set — the same pin-lost shape `language-mirror`
49
- * uses. State lives under `agents/runtime/state/`, the class
50
- * `context-hygiene.json` already occupies; no new state convention is created.
82
+ * ONCE PER SESSION PER RULE, RE-ARMED AND RESTORED ON COMPACTION. A rule's body
83
+ * is injected the first time one of its triggers fires and not again, because
84
+ * the model already has it. Compaction is exactly the event that makes that
85
+ * false, so `pre_compact` empties the seen-set — the same pin-lost shape
86
+ * `language-mirror` uses.
87
+ *
88
+ * STATE LIVES UNDER THE USER-GLOBAL ROOT, KEYED BY PROJECT (step 1.7). It used
89
+ * to be `<workspace>/agents/runtime/state/rule-inject/`, so every consumer
90
+ * session left dispatcher bookkeeping as untracked files inside someone else's
91
+ * repository. The seen-set is state about a SESSION, not about the project.
92
+ * The delivered-row ledger beside it is deliberately NOT moved — see
93
+ * {@link statePath} for the join that depends on where it is.
51
94
  *
52
95
  * WHAT IS RE-DELIVERED AFTER A COMPACTION, EXACTLY
53
- * (road-to-delivery-for-every-host 2.4 — stated because a rule lost at a
54
- * compaction boundary is lost for the rest of the session, and "re-armed" alone
55
- * does not say what a reader may rely on):
96
+ * (road-to-delivery-for-every-host 2.4, amended by step 1.6 — stated because a
97
+ * rule lost at a compaction boundary used to be lost for the rest of the
98
+ * session, and "re-armed" alone does not say what a reader may rely on):
56
99
  *
57
- * · `pre_compact` clears the WHOLE seen-set for that session, not the rules
100
+ * · `pre_compact` empties the WHOLE seen-set for that session, not the rules
58
101
  * matched on the compacted turn. There is no per-rule bookkeeping to be
59
- * partially wrong about.
102
+ * partially wrong about. Since 1.6 it also hands those ids to `pending`
103
+ * rather than deleting them — see {@link armRestore}.
60
104
  * · Nothing is delivered BY the compaction itself. The slot emits zero bytes
61
- * and exits allow; re-arming is silent.
62
- * · A rule's body returns on the NEXT turn whose trigger matches it — which
63
- * means a rule whose trigger does not fire again is NOT restored. Delivery
64
- * is trigger-driven on both sides of the boundary; compaction resets the
65
- * de-duplication, it does not replay a transcript.
66
- * · The seen-set is per session, so a compaction in one session re-arms only
67
- * that session.
68
- *
69
- * Held by three fixtures in `tests/scripts/rule_inject_hook.test.ts` under
70
- * "once per session per rule, re-armed on compaction": the dedup case, the
71
- * matched-rule → compaction → matching-turn → body-present case, and the
72
- * per-session case. All three predate this roadmap; 2.4 adds the contract
73
- * above, not the coverage, and says so rather than claiming new tests.
105
+ * and exits allow; re-arming is silent. It cannot deliver: the host is about
106
+ * to discard the context it would emit into.
107
+ * · `session_start` with `source: compact` sends the LAW section of every
108
+ * rule in that pending set, under the same 8,000-character budget, once.
109
+ * That is the half this paragraph used to end at: a rule whose trigger does
110
+ * not recur is no longer unrestored. A rule with no law section is reported
111
+ * in the manifest rather than having its whole body sent in its place.
112
+ * · A rule's BODY still returns on the next turn whose trigger matches it.
113
+ * The restore does not re-arm the de-duplication and does not replace
114
+ * trigger-driven delivery; it covers the gap between the two.
115
+ * · The seen-set is per session, so a compaction in one session re-arms and
116
+ * restores only that session.
117
+ *
118
+ * Held by fixtures in `tests/scripts/rule_inject_hook.test.ts` under "once per
119
+ * session per rule" and "re-deliver after a compact", and — for the BINDING
120
+ * rather than the function — by the dispatcher column in
121
+ * `rule_inject_foreign_matrix.test.ts`, because a suite that only calls `main()`
122
+ * stays green over a slot the dispatcher never routes to this concern.
74
123
  *
75
124
  * NEVER BLOCKS. Every failure path returns 0: unreadable stdin, malformed JSON,
76
125
  * missing router, unreadable body, unwritable state. The one non-zero exit is
@@ -90,29 +139,38 @@
90
139
  * claude 2.1.241, three verdicts). Delivery is therefore orchestrator-only, and
91
140
  * no third binding is added.
92
141
  */
142
+ import { createHash } from 'node:crypto';
93
143
  import * as fs from 'node:fs';
94
144
  import * as path from 'node:path';
95
145
  import process from 'node:process';
96
146
  import { fileURLToPath, pathToFileURL } from 'node:url';
97
147
 
98
- import { hookSectionEnabled, leanProjectionHostsRaw, leanProjectionModeRaw } from '../_lib/hook_settings.js';
99
- import {
100
- deliversBodies,
101
- normalizeLeanProjectionMode,
102
- resolveLeanProjectionHosts,
103
- } from '../_lib/lean_projection_mode.js';
148
+ import { hookSectionEnabled } from '../_lib/hook_settings.js';
149
+ import * as user_global_paths from '../_lib/user_global_paths.js';
150
+ import { deliversBodies, resolveLeanProjection } from '../_lib/lean_projection_mode.js';
104
151
  import { enforcement_class_from_frontmatter } from '../_lib/obligation_frequency.js';
105
- import { appendDelivered, stamp, type DeliveredRow } from '../_lib/obligations.js';
106
152
  import {
153
+ appendDelivered,
154
+ stamp,
155
+ type DeliveredRow,
156
+ type WriterInput,
157
+ } from '../_lib/obligations.js';
158
+ import {
159
+ allTierRules,
160
+ bytesOf,
107
161
  loadRuleBody,
108
162
  loadRouter,
109
163
  matchTierRules,
164
+ ruleSources,
110
165
  selectForInjection,
166
+ type SelectionResult,
167
+ type TierRuleMatch,
111
168
  } from '../_lib/rule_injection.js';
169
+ import { readConsequenceClass, stubLawIds } from '../_lib/rule_consequence_class.js';
170
+ import { lawText, ruleBody } from '../_lib/rule_law_section.js';
171
+ import { hostRuleLayerIds } from '../_lib/rule_layer_overlap.js';
112
172
  import { readHookStdin } from './hook_stdin.js';
113
-
114
- const EXIT_ALLOW = 0;
115
- const EXIT_WARN = 2;
173
+ import { EXIT_ALLOW, EXIT_WARN } from './exit_codes.js';
116
174
 
117
175
  /**
118
176
  * Per-prompt injection ceiling, in UTF-8 BYTES.
@@ -186,45 +244,142 @@ export function workspaceRoot(env: JsonObject): string {
186
244
  // ── seen-set state ───────────────────────────────────────────────────────
187
245
 
188
246
  export interface SeenState {
247
+ /** Ids already delivered in this session. */
189
248
  rules: string[];
249
+ /**
250
+ * Ids the compaction boundary took away, waiting for the restore slot.
251
+ *
252
+ * IDS, NEVER TEXT. The restore re-reads each rule's law from the corpus; a
253
+ * seen-set that cached bodies would be a second copy of the rule layer
254
+ * living in a consumer's state directory, going stale on every upgrade.
255
+ */
256
+ pending?: string[];
257
+ }
258
+
259
+ /**
260
+ * A stable, readable, collision-free directory name for one project root.
261
+ *
262
+ * The basename alone collides — a developer with `~/work/api` and
263
+ * `~/clients/api` would share a seen-set and silently lose deliveries in both.
264
+ * The digest alone is unreadable, and this directory is one a human opens when
265
+ * a delivery looks wrong. Both, so neither problem is the one you get.
266
+ */
267
+ function projectKey(root: string): string {
268
+ const abs = path.resolve(root);
269
+ const name = (path.basename(abs) || 'root').replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 40);
270
+ return `${name}-${createHash('sha256').update(abs, 'utf-8').digest('hex').slice(0, 12)}`;
190
271
  }
191
272
 
273
+ /**
274
+ * Where this session's seen-set lives (step 1.7).
275
+ *
276
+ * UNDER THE USER-GLOBAL ROOT, KEYED BY PROJECT. It used to be written to
277
+ * `<workspace>/agents/runtime/state/rule-inject/`, so every consumer session
278
+ * left dispatcher bookkeeping inside someone else's repository — untracked
279
+ * files in their `git status`, in a directory their `.gitignore` says nothing
280
+ * about unless this package's managed block was installed. The seen-set is
281
+ * state ABOUT a session, not about the project, and the project is the one
282
+ * place it has no business being.
283
+ *
284
+ * THE DELIVERED-ROW LEDGER IS DELIBERATELY NOT MOVED WITH IT.
285
+ * `recordDelivered` writes through `_lib/obligations.ts`, which resolves under
286
+ * the project root, and `road-to-a-stop-that-holds` Phase 3 reads those rows
287
+ * and joins them on the session id. Moving the ledger would be a second,
288
+ * larger change with a live consumer; moving only the seen-set leaves that join
289
+ * untouched by construction, because the two were never keyed on each other.
290
+ *
291
+ * `EVENT4U_CONFIG_HOME` and `$HOME` resolve the root, so a test points it
292
+ * somewhere disposable instead of writing into the developer's own state.
293
+ *
294
+ * ONE COST, STATED: an upgrade mid-session orphans the old file and the seen-set
295
+ * reads empty, which re-injects each matched rule once more. Session state is
296
+ * per session and cheap to rebuild; a migration step for it would be more code
297
+ * than the duplicate it prevents.
298
+ */
192
299
  export function statePath(root: string, session: string): string {
193
300
  const safe = session.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 80) || 'unknown';
194
- return path.join(root, 'agents', 'runtime', 'state', 'rule-inject', `${safe}.json`);
301
+ return path.join(
302
+ user_global_paths.write_target(path.join('state', 'rule-inject', projectKey(root))),
303
+ `${safe}.json`,
304
+ );
195
305
  }
196
306
 
197
- export function readSeen(root: string, session: string): Set<string> {
307
+ function readState(root: string, session: string): SeenState {
198
308
  try {
199
309
  const raw = fs.readFileSync(statePath(root, session), 'utf-8');
200
310
  const parsed = JSON.parse(raw) as SeenState;
201
- return new Set(Array.isArray(parsed.rules) ? parsed.rules.map(String) : []);
311
+ return {
312
+ rules: Array.isArray(parsed.rules) ? parsed.rules.map(String) : [],
313
+ pending: Array.isArray(parsed.pending) ? parsed.pending.map(String) : [],
314
+ };
202
315
  } catch {
203
- return new Set(); // fresh session, or a file nothing can parse
316
+ return { rules: [], pending: [] }; // fresh session, or a file nothing can parse
204
317
  }
205
318
  }
206
319
 
207
- export function writeSeen(root: string, session: string, seen: Set<string>): void {
320
+ export function readSeen(root: string, session: string): Set<string> {
321
+ return new Set(readState(root, session).rules);
322
+ }
323
+
324
+ function writeState(root: string, session: string, state: SeenState): void {
208
325
  const p = statePath(root, session);
209
326
  try {
210
327
  fs.mkdirSync(path.dirname(p), { recursive: true });
211
- const payload: SeenState = { rules: [...seen].sort() };
212
- fs.writeFileSync(`${p}.tmp`, `${JSON.stringify(payload)}\n`, 'utf-8');
328
+ fs.writeFileSync(`${p}.tmp`, `${JSON.stringify(state)}\n`, 'utf-8');
213
329
  fs.renameSync(`${p}.tmp`, p);
214
330
  } catch {
215
331
  /* unwritable state must never fail a turn — worst case a re-injection */
216
332
  }
217
333
  }
218
334
 
219
- export function clearSeen(root: string, session: string): void {
220
- try {
221
- fs.rmSync(statePath(root, session), { force: true });
222
- } catch {
223
- /* ignore */
335
+ export function writeSeen(root: string, session: string, seen: Set<string>): void {
336
+ const prev = readState(root, session);
337
+ writeState(root, session, { rules: [...seen].sort(), pending: prev.pending ?? [] });
338
+ }
339
+
340
+ /**
341
+ * Hand the seen-set to the restore slot instead of deleting it (step 1.6).
342
+ *
343
+ * `pre_compact` used to `rm` this file, which re-armed delivery correctly and
344
+ * threw away the one thing the restore needs. The de-duplication is still
345
+ * cleared — `rules` goes empty, so a rule whose trigger fires again gets its
346
+ * BODY back exactly as before — and the ids move to `pending`, where
347
+ * `session_start` with `source: compact` reads them and sends each rule's LAW.
348
+ *
349
+ * That is the half the header used to end at: a rule whose trigger does not
350
+ * recur was not restored at all, and a rule lost at a compaction boundary is
351
+ * lost for the rest of the session.
352
+ */
353
+ export function armRestore(root: string, session: string): void {
354
+ const prev = readState(root, session);
355
+ const pending = [...new Set([...prev.rules, ...(prev.pending ?? [])])].sort();
356
+ if (pending.length === 0) {
357
+ try {
358
+ fs.rmSync(statePath(root, session), { force: true });
359
+ } catch {
360
+ /* ignore */
361
+ }
362
+ return;
224
363
  }
364
+ writeState(root, session, { rules: [], pending });
365
+ }
366
+
367
+ /** The ids waiting for the restore slot. */
368
+ export function readPending(root: string, session: string): Set<string> {
369
+ return new Set(readState(root, session).pending ?? []);
225
370
  }
226
371
 
227
- // ── payload extraction ───────────────────────────────────────────────────
372
+ /**
373
+ * Drop the pending set, once the restore it fed has actually been built.
374
+ *
375
+ * SEPARATE FROM THE READ on purpose. A single take-and-clear loses the set when
376
+ * the build fails, on a slot that does not fire again for that boundary — so
377
+ * the clear is the caller's last step rather than its first.
378
+ */
379
+ export function clearPending(root: string, session: string): void {
380
+ const prev = readState(root, session);
381
+ if ((prev.pending ?? []).length > 0) writeState(root, session, { rules: prev.rules, pending: [] });
382
+ }
228
383
 
229
384
  /** The user's prompt, across the shapes the hosts use. */
230
385
  export function extractPrompt(payload: JsonObject): string {
@@ -238,12 +393,162 @@ export function extractFilePath(payload: JsonObject): string | null {
238
393
  return str(ti, 'file_path', 'path', 'filePath', 'notebook_path');
239
394
  }
240
395
 
241
- // ── decision ─────────────────────────────────────────────────────────────
396
+ /**
397
+ * Per-fire ceiling on the whole rule-produced string, in CHARACTERS (D2).
398
+ *
399
+ * CHARACTERS, because that is the unit the host's own threshold is in. Claude
400
+ * Code replaces an `additionalContext` longer than 10,000 CHARACTERS with a
401
+ * path and a 2,000-character preview, so a delivery over that line is not
402
+ * truncated — it is substituted, and the obligation text never arrives at all.
403
+ *
404
+ * WHICH STRING IT BOUNDS, stated because the two readings give different
405
+ * margins and D2's own wording did not separate them. This number bounds the
406
+ * RULE-PRODUCED string: the `<rule>` elements and the manifest, which is what
407
+ * this concern emits. The host's 10,000 applies to the `additionalContext` it
408
+ * assembles, and the other concerns on this slot measured 1,272 characters of
409
+ * non-rule text. So the margin is 2,000 if the host measures this concern's
410
+ * payload alone, and **728** if it measures the assembled string — the
411
+ * conservative reading, and still a margin. 8,000 is safe under both; which
412
+ * reading is right has not been established here and the smaller number is the
413
+ * one to plan against.
414
+ *
415
+ * IT BOUNDS THE WHOLE STRING, framing and manifest included, because that is
416
+ * what the host measures. {@link CAP_BYTES} above bounds something else — the
417
+ * SELECTION, shared byte-for-byte with the offline model — and both still
418
+ * apply: selection drops whole bodies to stay under the byte cap, then
419
+ * composition drops or demotes to stay under this one.
420
+ *
421
+ * THE TWO CANNOT BREACH EACH OTHER AT ANY RATIO THIS CORPUS PRODUCES, and the
422
+ * ratio is measured rather than assumed: over the 121 projected rule files the
423
+ * mean is 1.0088 bytes per character and the per-file maximum is 1.0346
424
+ * (`no-decorative-emojis-in-git-surfaces.md`), so 8,000 characters is at most
425
+ * 8,277 bytes against a registered 16,384. A composed string would have to
426
+ * average 2.05 bytes per character to reach the byte row, which no Markdown
427
+ * rule corpus produces. `per_concern_caps_chars` in
428
+ * `src/config/hook-token-budget.json` registers this number in its own unit
429
+ * rather than converting it into the bytes map, and a fixture pins both.
430
+ */
431
+ export const COMPOSED_CHARS = 8000;
432
+
433
+ /**
434
+ * What a matched rule actually got, as the manifest labels it.
435
+ *
436
+ * `full` and `law` mean text arrived. `omitted_budget` and `source_unavailable`
437
+ * mean it did not, and both are a POINTER — an id the model can ask for. A
438
+ * pointer is never labelled delivered; that distinction is the whole reason the
439
+ * manifest has four values instead of a present/absent flag.
440
+ */
441
+ export type DeliveryForm = 'full' | 'law' | 'omitted_budget' | 'source_unavailable';
442
+
443
+ /**
444
+ * The host's own threshold, in the host's own unit (step 0.3).
445
+ *
446
+ * Claude Code REPLACES an `additionalContext` longer than this with a path and
447
+ * a 2,000-character preview. A composed string over this line is therefore not
448
+ * truncated — it is substituted, and the obligation text never arrives. That is
449
+ * why this number is the one the arrival record checks against rather than
450
+ * {@link COMPOSED_CHARS}: 8,000 is the budget this concern holds itself to, and
451
+ * 10,000 is the line past which the host stops delivering what it was handed.
452
+ *
453
+ * WHICH STRING IS MEASURED, stated because risk 5 of this roadmap's register
454
+ * says the answer is not established. This records the RULE-PRODUCED string,
455
+ * which is the only one this concern can see. If the host measures the
456
+ * ASSEMBLED `additionalContext` instead, the real margin is 728 rather than
457
+ * 2,000 and a reading of 0 here is necessary but not sufficient. The record is
458
+ * honest about which of the two it is; it does not settle the question.
459
+ */
460
+ export const HOST_REPLACE_CHARS = 10000;
461
+
462
+ /**
463
+ * The three forms step 0.3 names, which is not {@link DeliveryForm}'s four.
464
+ *
465
+ * `omitted_budget` and `source_unavailable` are different REASONS for the same
466
+ * outcome — an id the model can ask for and no text. The arrival record asks
467
+ * what the model got, so the two collapse to `pointer`; the manifest keeps the
468
+ * distinction, because a consumer diagnosing a broken install needs the reason
469
+ * and a reader counting arrivals does not.
470
+ */
471
+ export type ArrivalForm = 'body' | 'law' | 'pointer';
472
+
473
+ /**
474
+ * How far a matched rule got, in the levels D4 fixed (step 0.3).
475
+ *
476
+ * - `A0` discoverable — a file or pointer exists.
477
+ * - `A1` routed — a trigger matched.
478
+ * - `A2` arrived — the obligation text is in context.
479
+ * - `A3` enforced — a gate blocks the forbidden outcome.
480
+ *
481
+ * THIS CONCERN NEVER WRITES A0 OR A3, and both omissions are deliberate rather
482
+ * than unimplemented. A0 is the level of a rule that never routed, and this
483
+ * concern only ever sees rules that did — writing an A0 row per fire for every
484
+ * rule in the corpus would be a per-turn sweep of the router to record the
485
+ * absence of an event. A3 belongs to whatever gate refuses the outcome; a
486
+ * carrier that recorded its own delivery as `enforced` would be the
487
+ * emitter-record-read-as-compliance mistake the delivered ledger's own header
488
+ * carries a council lock against.
489
+ */
490
+ export type ArrivalLevel = 'A0' | 'A1' | 'A2' | 'A3';
491
+
492
+ /** One matched rule, with what it got and how far that got it. */
493
+ export interface ArrivalRow {
494
+ readonly rule: string;
495
+ readonly form: ArrivalForm;
496
+ readonly arrival: ArrivalLevel;
497
+ }
498
+
499
+ /** One fire, measured on the composed string in the host's unit. */
500
+ export interface ArrivalRecord {
501
+ /** The composed string's length in CHARACTERS — what the host counts. */
502
+ readonly chars: number;
503
+ /** 1 when this fire was over {@link HOST_REPLACE_CHARS}, else 0. Summing the column gives the over-budget count. */
504
+ readonly over_budget: number;
505
+ /** Which slot the fire reached the host on. */
506
+ readonly slot: string;
507
+ readonly at: string;
508
+ readonly rows: readonly ArrivalRow[];
509
+ }
510
+
511
+ /** The manifest's four reasons, as the three forms a reader of arrivals wants. */
512
+ export function arrivalForm(f: DeliveryForm): ArrivalForm {
513
+ if (f === 'full') return 'body';
514
+ if (f === 'law') return 'law';
515
+ return 'pointer';
516
+ }
517
+
518
+ /**
519
+ * What this fire actually delivered, in the host's unit.
520
+ *
521
+ * TWO WAYS TO STAY AT A1, and they are different failures with the same
522
+ * reading. A pointer never carried text in the first place — step 0.3's "a
523
+ * pointer is never recorded above A1", taken literally. And a composed string
524
+ * the host REPLACES carried text that the host then did not deliver, so every
525
+ * row in that fire is A1 however good its form looked: the manifest says `body`
526
+ * and the model received a path. Recording those as A2 would be the instrument
527
+ * reporting its own intent instead of the outcome, which is the one thing a
528
+ * measurement of arrival must not do.
529
+ */
530
+ export function arrivalRecord(injection: Injection, slot: string, now = stamp()): ArrivalRecord {
531
+ const chars = injection.body.length;
532
+ const replaced = chars > HOST_REPLACE_CHARS;
533
+ return {
534
+ chars,
535
+ over_budget: replaced ? 1 : 0,
536
+ slot,
537
+ at: now,
538
+ rows: injection.manifest.map(([rule, f]) => {
539
+ const form = arrivalForm(f);
540
+ return { rule, form, arrival: form === 'pointer' || replaced ? 'A1' : 'A2' };
541
+ }),
542
+ };
543
+ }
242
544
 
243
545
  export interface Injection {
546
+ /** Ids whose TEXT was emitted — what the ledger records. */
244
547
  rules: string[];
245
548
  bytes: number;
246
549
  body: string;
550
+ /** Every matched rule with the form it got, in router order. */
551
+ manifest: Array<[string, DeliveryForm]>;
247
552
  }
248
553
 
249
554
  /**
@@ -268,7 +573,7 @@ export interface Injection {
268
573
  export function recordDelivered(root: string, session: string, ruleIds: string[]): number {
269
574
  try {
270
575
  const now = stamp();
271
- const rows: DeliveredRow[] = ruleIds.map((id) => {
576
+ const rows: WriterInput<DeliveredRow>[] = ruleIds.map((id) => {
272
577
  const body = loadRuleBody(root, id);
273
578
  return {
274
579
  rule: id,
@@ -284,6 +589,256 @@ export function recordDelivered(root: string, session: string, ruleIds: string[]
284
589
  }
285
590
  }
286
591
 
592
+ /**
593
+ * Ids whose LAW may stand on its own, empty when the config is unreachable.
594
+ *
595
+ * `stubLawIds` and not `Object.keys(members)`: the `no_stub` subset is exactly
596
+ * the members whose law is missing or too long to carry the obligation alone,
597
+ * which is why the projector ships those full-bodied. Serving one of them its
598
+ * law here would be the thin-stub defect `rule_consequence_class.ts` exists to
599
+ * prevent, reproduced at the delivery end — and `stubLawIds` is that module's
600
+ * own answer, so re-deriving the distinction here would be a second reader of a
601
+ * class that is supposed to have one.
602
+ *
603
+ * MEMOISED PER ROOT. The config is 12 kB of JSON and this runs on the
604
+ * `user_prompt_submit` hot path; a dispatch is one process and an install does
605
+ * not change under it.
606
+ */
607
+ const HI_CACHE = new Map<string, Set<string>>();
608
+
609
+ function highConsequenceIds(root: string): Set<string> {
610
+ const hit = HI_CACHE.get(root);
611
+ if (hit !== undefined) return hit;
612
+ let out = new Set<string>();
613
+ for (const r of [root, ruleSources(root).pkg]) {
614
+ if (r === null) continue;
615
+ try {
616
+ out = stubLawIds(readConsequenceClass(r));
617
+ break;
618
+ } catch {
619
+ continue;
620
+ }
621
+ }
622
+ HI_CACHE.set(root, out);
623
+ return out;
624
+ }
625
+
626
+ /** The manifest block, which is part of the budgeted string rather than beside it. */
627
+ function manifestText(rows: Array<[string, DeliveryForm]>): string {
628
+ return `<rule-manifest>\n${rows.map(([id, f]) => `${id}=${f}`).join('\n')}\n</rule-manifest>`;
629
+ }
630
+
631
+ /**
632
+ * Compose one fire under {@link COMPOSED_CHARS}, in the order D1 fixes.
633
+ *
634
+ * THE ORDER IS A DECISION, not an implementation detail. D1:
635
+ *
636
+ * 1. the law of every matched HIGH-CONSEQUENCE rule,
637
+ * 2. then the highest-priority full body that fits,
638
+ * 3. then further full bodies,
639
+ * 4. then the law of everything still unsent,
640
+ * 5. then the manifest.
641
+ *
642
+ * Phase 1 first because a high-consequence rule's law arriving is worth more
643
+ * than a lower-consequence rule's whole body, and a budget that filled in
644
+ * priority order alone would spend itself before reaching it.
645
+ *
646
+ * PHASE 1 IS A FLOOR AND NEVER A CEILING, which is the half the first
647
+ * implementation got backwards. A rule served its law in phase 1 can be
648
+ * UPGRADED to its whole body in phase 2 when the budget still has room for the
649
+ * difference — `put` replaces the part in place and charges only the delta.
650
+ * Without that, the 23 class members with a law section could never receive
651
+ * their body however empty the budget was: `security-sensitive-stop` would have
652
+ * dropped 5,687 characters to 429 on every fire, and its id still enters the
653
+ * seen-set, so the body would not have come back later either. D1's "then the
654
+ * highest-priority full body that fits" reads as a floor and has to behave like
655
+ * one.
656
+ *
657
+ * EVERY MATCHED CLASS MEMBER REACHES PHASE 1, including one the byte cap
658
+ * dropped. `selectForInjection` ranks on score alone and knows nothing about
659
+ * the class, so a high-consequence rule can lose that race; D1 says "every
660
+ * matched", so the text for a dropped class member is loaded here rather than
661
+ * left unreachable.
662
+ *
663
+ * NOTHING IS EVER TRUNCATED. A rule whose law alone does not fit is REPORTED —
664
+ * `omitted_budget` in the manifest — because half an obligation reads like a
665
+ * whole one and is the more dangerous output. The manifest's space is reserved
666
+ * BEFORE any text is admitted, using the longest label for every matched id, so
667
+ * the report can never be the thing that pushes the string over.
668
+ *
669
+ * `sel.dropped` is read here, and that is the point of the step as much as the
670
+ * budget is: before this, a body dropped by the byte cap left no trace at all,
671
+ * so a consumer could not tell a rule that did not match from one that matched
672
+ * and was silently discarded.
673
+ */
674
+ function compose(
675
+ root: string,
676
+ sel: SelectionResult,
677
+ hi: Set<string>,
678
+ lawOnly = false,
679
+ ): Injection | null {
680
+ const form = new Map<string, DeliveryForm>();
681
+ const full = new Map<string, string>();
682
+ const read = (id: string): void => {
683
+ const raw = loadRuleBody(root, id);
684
+ // STEP 1.4 — the host form, by the parser the thin projector uses.
685
+ // `ruleBody` strips frontmatter and HTML comments: the first is the
686
+ // routing surface the router already read, the second is authoring
687
+ // scaffolding no host renders to a model. A file with nothing left after
688
+ // the strip carries no obligation — an empty `<rule>` element is framing
689
+ // with no content inside it.
690
+ const body = raw === null ? '' : ruleBody(raw);
691
+ if (body === '') {
692
+ form.set(id, 'source_unavailable');
693
+ return;
694
+ }
695
+ form.set(id, 'omitted_budget');
696
+ full.set(id, body);
697
+ };
698
+ for (const m of sel.dropped) {
699
+ if (hi.has(m.id)) {
700
+ read(m.id);
701
+ continue;
702
+ }
703
+ form.set(m.id, (sel.bodyBytes.get(m.id) ?? 0) === 0 ? 'source_unavailable' : 'omitted_budget');
704
+ }
705
+ for (const m of sel.selected) read(m.id);
706
+
707
+ const all = [...sel.selected, ...sel.dropped].sort((a, b) => a.order - b.order);
708
+ const tiers = new Map(all.map((m) => [m.id, m.tier]));
709
+ const reserve = manifestText(all.map((m) => [m.id, 'source_unavailable']));
710
+ let left = COMPOSED_CHARS - reserve.length - 2;
711
+ // BOTH UNITS RESERVE THE MANIFEST. The byte row is the registered one, so a
712
+ // manifest charged against the character budget and not against this one
713
+ // would let the emission exceed the row it is registered at.
714
+ let bytesLeft = CAP_BYTES - bytesOf(reserve) - 2;
715
+ const parts: string[] = [];
716
+ const partOf = new Map<string, number>();
717
+ const put = (id: string, kind: 'full' | 'law', text: string): boolean => {
718
+ const part = `<rule id="${id}" tier="${tiers.get(id) ?? ''}" form="${kind}">\n${text}\n</rule>`;
719
+ const at = partOf.get(id);
720
+ const prev = at === undefined ? '' : (parts[at] as string);
721
+ const sep = at === undefined && parts.length > 0 ? 2 : 0;
722
+ // BOTH UNITS, INDEPENDENTLY. The measured 1.0346 bytes-per-character
723
+ // maximum over today's corpus says the character budget binds first, and
724
+ // a measurement over one corpus is evidence rather than an invariant: a
725
+ // 6,000-character payload of three-byte code points is 18,000 bytes.
726
+ // Safety that rests on a ratio stops being safety the day the corpus
727
+ // changes, so the registered byte row is enforced here too and the ratio
728
+ // is what makes the second check almost never bind rather than what
729
+ // makes it unnecessary.
730
+ const cost = part.length - prev.length + sep;
731
+ const costBytes = bytesOf(part) - bytesOf(prev) + sep;
732
+ if (cost > left || costBytes > bytesLeft) return false;
733
+ left -= cost;
734
+ bytesLeft -= costBytes;
735
+ if (at === undefined) {
736
+ partOf.set(id, parts.length);
737
+ parts.push(part);
738
+ } else {
739
+ parts[at] = part;
740
+ }
741
+ form.set(id, kind);
742
+ return true;
743
+ };
744
+
745
+ const ranked = [...all].sort((a, b) => b.score - a.score || a.order - b.order);
746
+ for (const m of ranked) {
747
+ const body = full.get(m.id);
748
+ if (body === undefined || !hi.has(m.id)) continue;
749
+ const law = lawText(body);
750
+ // A class member with no law section cannot be served by phase 1. It is
751
+ // left to compete for a full body below rather than reported, which is
752
+ // `no_stub`'s own answer to the same state.
753
+ if (law !== null) put(m.id, 'law', law);
754
+ }
755
+ for (const m of ranked) {
756
+ const body = full.get(m.id);
757
+ if (body === undefined || form.get(m.id) === 'full') continue;
758
+ // A RESTORE SENDS LAWS, with one exception: a rule with NO law section
759
+ // has no law to send, and reporting it `omitted_budget` would blame a
760
+ // budget decision nobody made. Those compete for their body instead, so
761
+ // every label on the restore is true.
762
+ if (lawOnly && lawText(body) !== null) continue;
763
+ put(m.id, 'full', body);
764
+ }
765
+ for (const m of ranked) {
766
+ const body = full.get(m.id);
767
+ if (body === undefined || form.get(m.id) !== 'omitted_budget') continue;
768
+ const law = lawText(body);
769
+ if (law !== null) put(m.id, 'law', law);
770
+ }
771
+
772
+ const rows: Array<[string, DeliveryForm]> = all.map((m) => [
773
+ m.id,
774
+ form.get(m.id) ?? 'source_unavailable',
775
+ ]);
776
+ const ids = rows.filter(([, f]) => f === 'full' || f === 'law').map(([id]) => id);
777
+ // THE MANIFEST IS BOUNDED TOO, and by construction rather than by how many
778
+ // rules this router happens to carry. Its space is reserved above at the
779
+ // longest label, so it always fits what was reserved — but if the RESERVE
780
+ // itself exceeded the budget (enough matches that the report alone is over
781
+ // 8,000 characters) the fill loop would admit nothing and the manifest
782
+ // would be the whole emission, over the cap. No router today comes close;
783
+ // "no corpus today comes close" is the shape of claim the byte check above
784
+ // exists because of, so this is a property instead.
785
+ //
786
+ // UNDELIVERED ROWS GO FIRST. A row for a rule whose text IS in this string
787
+ // must never be the one dropped — that would report a delivered rule as
788
+ // omitted, which is the one thing AC-2 forbids outright.
789
+ const order = [...rows.keys()].sort(
790
+ (a, b) =>
791
+ Number(['full', 'law'].includes((rows[a] as [string, DeliveryForm])[1])) -
792
+ Number(['full', 'law'].includes((rows[b] as [string, DeliveryForm])[1])),
793
+ );
794
+ const head = parts.join('\n\n') + (parts.length > 0 ? '\n\n' : '');
795
+ let drop = 0;
796
+ let body = head + manifestText(rows);
797
+ while (body.length > COMPOSED_CHARS && drop < rows.length) {
798
+ drop += 1;
799
+ const cut = new Set(order.slice(0, drop));
800
+ const kept = rows.filter((_, i) => !cut.has(i));
801
+ body = head + manifestText([...kept, [`+${drop}`, 'omitted_budget']]);
802
+ }
803
+ return { rules: ids, bytes: bytesOf(body), body, manifest: rows };
804
+ }
805
+
806
+ /**
807
+ * Build the post-compaction restore, or `null` for silence (step 1.6).
808
+ *
809
+ * LAWS, NOT BODIES, and the asymmetry with a normal fire is the point. A
810
+ * compaction removed text the model was already under; re-sending every body
811
+ * would re-spend the whole budget on rules whose obligations the session may
812
+ * never touch again. The law is the part that cannot be inferred from the rest,
813
+ * so it is what the budget buys back. Everything that does not fit is reported
814
+ * in the same manifest as any other fire.
815
+ *
816
+ * The router supplies each id's tier and declaration order, because the
817
+ * seen-set deliberately carries neither — see {@link SeenState.pending}.
818
+ */
819
+ function restore(root: string, ids: Set<string>): Injection | null {
820
+ if (ids.size === 0) return null;
821
+ let router;
822
+ try {
823
+ router = loadRouter(root);
824
+ } catch {
825
+ return null;
826
+ }
827
+ const selected: TierRuleMatch[] = [];
828
+ let order = 0;
829
+ for (const r of allTierRules(router)) {
830
+ if (ids.has(r.id)) selected.push({ id: r.id, tier: r.tier, score: 0, order });
831
+ order += 1;
832
+ }
833
+ if (selected.length === 0) return null;
834
+ return compose(
835
+ root,
836
+ { selected, dropped: [], bytes: 0, bodyBytes: new Map() },
837
+ new Set(selected.map((m) => m.id)),
838
+ true,
839
+ );
840
+ }
841
+
287
842
  /**
288
843
  * Build the injection for one event, or `null` for silence.
289
844
  *
@@ -305,21 +860,30 @@ export function buildInjection(
305
860
  } catch {
306
861
  return null; // no router — nothing to deliver, and never a failure
307
862
  }
863
+ // STEP 1.3 — the install declares the scope, the package supplies the text.
864
+ //
865
+ // The router is the PACKAGE's list of every rule that could route. What a
866
+ // given consumer is actually under is what their install wrote into the
867
+ // host's rule directories, and the two are not the same set: 13 router tier
868
+ // rules are maintainer-only by `workspaces`, and before this filter a
869
+ // consumer could receive a body for a rule their install never carried.
870
+ //
871
+ // The two halves cannot be one read, and that is the reason this is a
872
+ // filter rather than a different body source. In `delivery` mode the
873
+ // installed files are thin stubs by construction, so the installed layer can
874
+ // say WHICH rules are in scope and never WHAT they say; the body still comes
875
+ // from the package corpus `ruleSources` resolves.
876
+ //
877
+ // `null` means no host rule layer exists to declare a scope — see
878
+ // `hostRuleLayerIds`. No filter then, because scoping to an empty set would
879
+ // re-create the silence step 1.1 repaired.
880
+ const scope = hostRuleLayerIds(root);
308
881
  const matches = matchTierRules(router, prompt, openFiles, command).filter(
309
- (m) => !seen.has(m.id),
882
+ (m) => !seen.has(m.id) && (scope === null || scope.has(m.id)),
310
883
  );
311
884
  if (matches.length === 0) return null;
312
885
  const sel = selectForInjection(root, matches, CAP_BYTES);
313
- const parts: string[] = [];
314
- const ids: string[] = [];
315
- for (const m of sel.selected) {
316
- const body = loadRuleBody(root, m.id);
317
- if (body === null) continue;
318
- ids.push(m.id);
319
- parts.push(`<rule id="${m.id}" tier="${m.tier}">\n${body.trim()}\n</rule>`);
320
- }
321
- if (ids.length === 0) return null;
322
- return { rules: ids, bytes: sel.bytes, body: parts.join('\n\n') };
886
+ return compose(root, sel, highConsequenceIds(root));
323
887
  }
324
888
 
325
889
  // ── main ─────────────────────────────────────────────────────────────────
@@ -358,10 +922,16 @@ export const DELIVERY_HOST = 'claude-code';
358
922
  * to open. `AGENT_CONFIG_REPLAY` re-imposes it, which is what keeps
359
923
  * `bench_hook_injection` measuring the configured tree rather than the probe.
360
924
  */
361
- export function gateOpen(root: string, cliEntry: boolean): boolean {
362
- const mode = normalizeLeanProjectionMode(leanProjectionModeRaw(root));
363
- const hosts = resolveLeanProjectionHosts(leanProjectionHostsRaw(root)).hosts;
364
- if (deliversBodies(mode) && hosts.includes(DELIVERY_HOST)) return true;
925
+ export function gateOpen(root: string, cliEntry: boolean, packageRoot?: string | null): boolean {
926
+ // ONE resolver since step 1.2 — the full cascade (template base, canonical
927
+ // `agents/settings/`, project root, user-global), not the legacy root file
928
+ // this concern used to read alone. `packageRoot` is where the template is
929
+ // found; inside the bundle nothing else can locate it.
930
+ const { mode, hosts } = resolveLeanProjection({
931
+ projectRoot: root,
932
+ packageRoot: packageRoot ?? null,
933
+ });
934
+ if (deliversBodies(mode) && hosts.hosts.includes(DELIVERY_HOST)) return true;
365
935
  if (hookSectionEnabled(root, 'rule_inject')) return true;
366
936
  return cliEntry && process.env['AGENT_CONFIG_REPLAY'] !== '1';
367
937
  }
@@ -390,11 +960,38 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
390
960
  const session = str(env, 'session_id', 'sessionId') ?? str(payload, 'session_id', 'sessionId') ?? 'unknown';
391
961
 
392
962
  if (slot === 'pre_compact') {
393
- clearSeen(root, session);
394
- return EXIT_ALLOW; // re-arm is silent; the next turn re-injects
963
+ armRestore(root, session);
964
+ return EXIT_ALLOW; // re-arm is silent; the restore happens on the next slot
965
+ }
966
+ if (slot === 'session_start') {
967
+ // ONLY on `source: compact`. `startup`, `resume`, `clear` and `fork`
968
+ // either begin a session that never had a seen-set or hand back a
969
+ // transcript the host already restored; emitting there would be a
970
+ // duplicate, and on `fork` a duplicate belonging to a session that is
971
+ // still alive. An unknown source emits nothing rather than guessing —
972
+ // the same stance `handoff-context`'s `sourceGate` takes.
973
+ if ((str(payload, 'source') ?? str(env, 'source')) !== 'compact') return EXIT_ALLOW;
974
+ if (!gateOpen(root, _isCliEntry(), ruleSources(root).pkg)) return EXIT_ALLOW;
975
+ // READ, then BUILD, then clear — never clear first. A restore that
976
+ // fires twice on one boundary is a duplicate delivery, so the set has to
977
+ // be cleared; clearing it BEFORE the build means a build that fails
978
+ // (the package moved mid-session, or an upgrade renamed every pending
979
+ // rule out of the router) loses the set permanently and silently, on the
980
+ // one slot that does not come round again.
981
+ const restored = restore(root, readPending(root, session));
982
+ if (restored === null) return EXIT_ALLOW;
983
+ clearPending(root, session);
984
+ recordDelivered(root, session, restored.rules);
985
+ return emit(restored, slot, root, session);
395
986
  }
396
987
  if (slot !== 'user_prompt_submit' && slot !== 'pre_tool_use') return EXIT_ALLOW;
397
- if (!gateOpen(root, _isCliEntry())) return EXIT_ALLOW;
988
+
989
+ // Resolved ONCE, before the gate. The gate needs the package root to find
990
+ // the settings template and the delivery needs it to find the corpus;
991
+ // reading the environment and probing the filesystem twice for the same
992
+ // answer is a cost every hook dispatch on every slot would pay.
993
+ const src = ruleSources(root);
994
+ if (!gateOpen(root, _isCliEntry(), src.pkg)) return EXIT_ALLOW;
398
995
 
399
996
  let prompt = '';
400
997
  let openFiles: string[] | null = null;
@@ -412,6 +1009,39 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
412
1009
  openFiles = [fp];
413
1010
  }
414
1011
 
1012
+ // Fail CLOSED and SAY SO. An empty delivery is indistinguishable from "no
1013
+ // rule matched", which is the exact silence this concern shipped with; one
1014
+ // line on stderr — which the dispatcher captures — is what makes a broken
1015
+ // install diagnosable from outside this file. Still allow: a carrier that
1016
+ // cannot find its corpus must not fail a turn.
1017
+ if (src.gap !== null) {
1018
+ // Fail CLOSED and SAY SO, through the channel the dispatcher actually
1019
+ // surfaces. Writing to stderr looks right and is not: `_run_concern_inproc`
1020
+ // captures a concern's stderr and `dispatch_hook` re-emits it only at
1021
+ // rc >= 3 (a crash), so a concern exiting allow is silent by
1022
+ // construction — which is the exact failure this diagnostic exists to
1023
+ // end, reproduced one layer up. Found by the 1.8 matrix against the
1024
+ // built bundle; no in-process fixture could have seen it.
1025
+ //
1026
+ // WHERE IT ACTUALLY LANDS, measured against the built bundle rather
1027
+ // than assumed: on Claude Code `emitFor` translates an advisory warn on
1028
+ // this slot into `hookSpecificOutput.additionalContext` at exit 0, so
1029
+ // this line reaches the MODEL, not the operator's terminal. That is the
1030
+ // host's translation and not a choice available here — `reason` alone
1031
+ // has no terminal-facing path on this slot.
1032
+ //
1033
+ // It is the right place anyway: the agent is what the user is talking
1034
+ // to, so an agent told its rule corpus is missing can say so. The cost
1035
+ // is one short line per turn for as long as the install stays broken,
1036
+ // which is bounded and deliberate. Bounding it further — once per
1037
+ // session, via the seen-set — needs a state write this concern cannot
1038
+ // currently afford (`src/config/hook-bundle-budget.json` left 30 bytes
1039
+ // of headroom at this commit), and is recorded in the roadmap rather
1040
+ // than silently skipped.
1041
+ process.stdout.write(`${JSON.stringify({ decision: 'warn', reason: src.gap })}\n`);
1042
+ return EXIT_WARN;
1043
+ }
1044
+
415
1045
  const seen = readSeen(root, session);
416
1046
  const injection = buildInjection(root, prompt, openFiles, command, seen);
417
1047
  if (injection === null) return EXIT_ALLOW; // silence is the default
@@ -420,10 +1050,77 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
420
1050
  writeSeen(root, session, seen);
421
1051
  recordDelivered(root, session, injection.rules);
422
1052
 
1053
+ return emit(injection, slot, root, session);
1054
+ }
1055
+
1056
+ /**
1057
+ * Where this session's arrival record lives (step 0.3).
1058
+ *
1059
+ * BESIDE THE SEEN-SET, under the user-global root and keyed by project, for the
1060
+ * reason step 1.7 moved that one: a measurement about a session is not state
1061
+ * about the project, and a consumer's `git status` is the wrong place to learn
1062
+ * what this dispatcher counted. The delivered-row ledger stays where it is —
1063
+ * `road-to-a-stop-that-holds` Phase 3 joins on it — and this is a different
1064
+ * record with a different reader, so the two do not have to live together.
1065
+ *
1066
+ * JSONL rather than one rewritten object: a fire appends, and an append cannot
1067
+ * lose the rows already written by partially serialising the whole file. The
1068
+ * over-budget COUNT the step asks for is the sum of the `over_budget` column,
1069
+ * which a reader computes; storing a running total here would be a second
1070
+ * number that can disagree with the rows it summarises.
1071
+ */
1072
+ export function arrivalPath(root: string, session: string): string {
1073
+ const safe = session.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 80) || 'unknown';
1074
+ return path.join(
1075
+ user_global_paths.write_target(path.join('state', 'rule-inject', projectKey(root))),
1076
+ `${safe}.arrival.jsonl`,
1077
+ );
1078
+ }
1079
+
1080
+ /**
1081
+ * Append one fire's arrival record, best-effort.
1082
+ *
1083
+ * NEVER THROWS. This is an instrument, and a turn refused because an instrument
1084
+ * missed a reading is worse than the missing reading — the same stance
1085
+ * `appendDelivered` takes, for the same reason.
1086
+ */
1087
+ export function recordArrival(root: string, session: string, record: ArrivalRecord): void {
1088
+ try {
1089
+ const p = arrivalPath(root, session);
1090
+ fs.mkdirSync(path.dirname(p), { recursive: true });
1091
+ fs.appendFileSync(p, `${JSON.stringify(record)}\n`, 'utf-8');
1092
+ } catch {
1093
+ /* an unwritable instrument costs a reading, never a turn */
1094
+ }
1095
+ }
1096
+
1097
+ /** Read back what this session recorded, dropping any line nothing can parse. */
1098
+ export function readArrival(root: string, session: string): ArrivalRecord[] {
1099
+ let raw: string;
1100
+ try {
1101
+ raw = fs.readFileSync(arrivalPath(root, session), 'utf-8');
1102
+ } catch {
1103
+ return [];
1104
+ }
1105
+ const out: ArrivalRecord[] = [];
1106
+ for (const line of raw.split('\n')) {
1107
+ if (line.trim() === '') continue;
1108
+ try {
1109
+ out.push(JSON.parse(line) as ArrivalRecord);
1110
+ } catch {
1111
+ continue; // a torn last line must not cost the rows before it
1112
+ }
1113
+ }
1114
+ return out;
1115
+ }
1116
+
1117
+ /** The one place a delivery reaches the host, shared by the fire and the restore. */
1118
+ function emit(injection: Injection, slot: string, root: string, session: string): number {
1119
+ recordArrival(root, session, arrivalRecord(injection, slot));
423
1120
  process.stdout.write(
424
1121
  `${JSON.stringify({
425
1122
  decision: 'warn',
426
- reason: `rule-inject: ${injection.rules.length} rule body/bodies on ${slot} (${injection.bytes} B)`,
1123
+ reason: `rule-inject: ${injection.rules.length}/${injection.manifest.length} matched rule(s) sent on ${slot} (${injection.body.length} chars)`,
427
1124
  additional_context: injection.body,
428
1125
  })}\n`,
429
1126
  );