@event4u/agent-config 14.12.0 → 14.14.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 (521) 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 +118 -155
  5. package/CONTRIBUTING.md +38 -0
  6. package/CREDITS.md +135 -0
  7. package/NOTICE +13 -0
  8. package/README.md +8 -6
  9. package/dist/agent-src/commands/analyze/inbox.md +145 -2
  10. package/dist/agent-src/commands/feature/dev.md +7 -2
  11. package/dist/agent-src/commands/ghostwriter/write.md +5 -1
  12. package/dist/agent-src/commands/implement-ticket.md +1 -1
  13. package/dist/agent-src/commands/mission/upgrade.md +1 -1
  14. package/dist/agent-src/commands/orchestrate.md +2 -2
  15. package/dist/agent-src/commands/post-as/me.md +5 -1
  16. package/dist/agent-src/commands/pr/merge.md +19 -6
  17. package/dist/agent-src/commands/roadmap/process-full.md +19 -5
  18. package/dist/agent-src/commands/tdd/red.md +12 -2
  19. package/dist/agent-src/commands/work.md +1 -1
  20. package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +40 -0
  21. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +5 -2
  22. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +21 -0
  23. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +1 -0
  24. package/dist/agent-src/contexts/execution/subagent-response-contract.md +40 -0
  25. package/dist/agent-src/contexts/execution/subagent-steering.md +13 -1
  26. package/dist/agent-src/contexts/execution/terminal-states.md +96 -0
  27. package/dist/agent-src/rules/artifact-drafting-protocol.md +1 -1
  28. package/dist/agent-src/rules/broken-access-control.md +4 -0
  29. package/dist/agent-src/rules/code-comment-discipline.md +6 -0
  30. package/dist/agent-src/rules/code-provenance.md +4 -0
  31. package/dist/agent-src/rules/design-fidelity.md +1 -1
  32. package/dist/agent-src/rules/domain-safety-pii.md +4 -0
  33. package/dist/agent-src/rules/domain-safety-retention.md +4 -0
  34. package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
  35. package/dist/agent-src/rules/external-code-graph-interop.md +14 -12
  36. package/dist/agent-src/rules/improve-before-implement.md +6 -8
  37. package/dist/agent-src/rules/laravel-routing.md +1 -1
  38. package/dist/agent-src/rules/lethal-trifecta-guard.md +4 -0
  39. package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
  40. package/dist/agent-src/rules/security-sensitive-stop.md +2 -0
  41. package/dist/agent-src/rules/source-confidentiality.md +12 -34
  42. package/dist/agent-src/rules/symfony-routing.md +1 -1
  43. package/dist/agent-src/rules/token-efficiency.md +14 -0
  44. package/dist/agent-src/rules/tool-safety.md +4 -0
  45. package/dist/agent-src/rules/untrusted-input-defense.md +4 -0
  46. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +252 -8
  47. package/dist/agent-src/scripts/guarded_baseline.ts +343 -0
  48. package/dist/agent-src/scripts/roadmap_set_step.ts +253 -0
  49. package/dist/agent-src/scripts/stub_queue.ts +82 -0
  50. package/dist/agent-src/scripts/stubs_due.ts +516 -0
  51. package/dist/agent-src/scripts/ticket_bundles.ts +84 -0
  52. package/dist/agent-src/scripts/update_roadmap_progress.ts +16 -12
  53. package/dist/agent-src/skills/brand-asset-generation/SKILL.md +17 -4
  54. package/dist/agent-src/skills/brand-audit/SKILL.md +33 -0
  55. package/dist/agent-src/skills/code-intelligence/SKILL.md +57 -0
  56. package/dist/agent-src/skills/database/SKILL.md +8 -0
  57. package/dist/agent-src/skills/dependency-upgrade/SKILL.md +27 -0
  58. package/dist/agent-src/skills/design-review/SKILL.md +1 -1
  59. package/dist/agent-src/skills/doc-coauthoring/SKILL.md +1 -1
  60. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +17 -1
  61. package/dist/agent-src/skills/feature-planning/SKILL.md +9 -2
  62. package/dist/agent-src/skills/frontend-render-security/SKILL.md +44 -0
  63. package/dist/agent-src/skills/git-workflow/SKILL.md +1 -1
  64. package/dist/agent-src/skills/js-library-packaging/scripts/check_package_surface.ts +38 -4
  65. package/dist/agent-src/skills/judge-artifact-completeness/rubrics/ticket-quality-score.json +2 -2
  66. package/dist/agent-src/skills/laravel/SKILL.md +35 -0
  67. package/dist/agent-src/skills/laravel-migration/SKILL.md +57 -3
  68. package/dist/agent-src/skills/monorepo-workspace/SKILL.md +26 -0
  69. package/dist/agent-src/skills/playbook-authoring/SKILL.md +28 -1
  70. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
  71. package/dist/agent-src/skills/skill-writing/references/procedure.md +5 -5
  72. package/dist/agent-src/skills/sql-writing/SKILL.md +7 -1
  73. package/dist/agent-src/skills/standards-from-config/SKILL.md +27 -4
  74. package/dist/agent-src/skills/supply-chain-intake/SKILL.md +11 -4
  75. package/dist/agent-src/skills/symfony-workflow/SKILL.md +35 -0
  76. package/dist/agent-src/skills/test-case-discovery/SKILL.md +85 -0
  77. package/dist/agent-src/skills/test-driven-development/SKILL.md +33 -9
  78. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +1 -1
  79. package/dist/agent-src/skills/testing-anti-patterns/process-anti-patterns.md +37 -0
  80. package/dist/agent-src/skills/ui-component-architect/SKILL.md +45 -2
  81. package/dist/agent-src/skills/upstream-contribute/SKILL.md +38 -1
  82. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +9 -4
  83. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  84. package/dist/agent-src/templates/roadmaps.md +97 -3
  85. package/dist/agent-src/templates/scripts/check_memory.ts +258 -17
  86. package/dist/agent-src/templates/scripts/check_memory_proposal.ts +9 -3
  87. package/dist/agent-src/templates/scripts/memory_lookup.ts +86 -0
  88. package/dist/agent-src/templates/scripts/memory_signal.ts +44 -0
  89. package/dist/agent-src/templates/scripts/work_engine/directives/backend/implement.ts +90 -0
  90. package/dist/agent-src/templates/scripts/work_engine/directives/backend/index.ts +11 -0
  91. package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +23 -1
  92. package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +10 -1
  93. package/dist/agent-src/templates/scripts/work_engine/directives/mixed/stitch.ts +5 -1
  94. package/dist/agent-src/templates/scripts/work_engine/stack/runner.ts +102 -14
  95. package/dist/cli/commands/uiAudit.js +124 -5
  96. package/dist/cli/registry.js +2 -0
  97. package/dist/cli-delegate/{chunk-HYWSRNUX.js → chunk-2DK2VJWG.js} +3 -3
  98. package/dist/cli-delegate/{chunk-JCW7ZLHK.js → chunk-4FS4WRXP.js} +5 -0
  99. package/dist/cli-delegate/{chunk-OMIVVCG2.js → chunk-A4QJ6ENT.js} +24 -0
  100. package/dist/cli-delegate/{chunk-JJA2QEWA.js → chunk-AKJGUQNO.js} +466 -285
  101. package/dist/cli-delegate/{chunk-E46KPN6K.js → chunk-BTWUYDGN.js} +424 -363
  102. package/dist/cli-delegate/{chunk-K4FFODTS.js → chunk-ILMUPLGW.js} +1227 -316
  103. package/dist/cli-delegate/{chunk-FF7WAFMR.js → chunk-KPBWFOPZ.js} +5 -1
  104. package/dist/cli-delegate/cmd_adr_effective.js +102 -0
  105. package/dist/cli-delegate/cmd_conformance.js +80 -11
  106. package/dist/cli-delegate/cmd_doctor.js +3 -3
  107. package/dist/cli-delegate/cmd_export.js +3 -2
  108. package/dist/cli-delegate/cmd_fleet.js +2 -2
  109. package/dist/cli-delegate/cmd_handoff.js +1 -1
  110. package/dist/cli-delegate/cmd_preflight.js +2 -2
  111. package/dist/cli-delegate/cmd_prune.js +3 -2
  112. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  113. package/dist/cli-delegate/cmd_settings_migrate.js +3 -2
  114. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  115. package/dist/cli-delegate/cmd_sync.js +3 -2
  116. package/dist/cli-delegate/cmd_uninstall.js +3 -2
  117. package/dist/cli-delegate/cmd_validate.js +3 -2
  118. package/dist/discovery/deprecation-report.md +1 -1
  119. package/dist/discovery/discovery-manifest.json +58 -58
  120. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  121. package/dist/discovery/discovery-manifest.summary.md +1 -1
  122. package/dist/discovery/dormancy-report.md +1 -1
  123. package/dist/discovery/orphan-report.md +1 -1
  124. package/dist/discovery/packs.json +2 -2
  125. package/dist/discovery/trust-report.md +1 -1
  126. package/dist/discovery/workspaces.json +2 -2
  127. package/dist/hooks/dispatch.js +2900 -1049
  128. package/dist/install/claudeRuleRewrite.js +254 -0
  129. package/dist/install/detect.js +29 -16
  130. package/dist/install/detect_php_shape.js +159 -0
  131. package/dist/install/install.mjs +634 -428
  132. package/dist/mcp/awesome-mcp-servers.row.md +1 -1
  133. package/dist/mcp/mcp-cloudflare-catalogue.json +1 -1
  134. package/dist/mcp/registry-manifest.json +3 -3
  135. package/dist/mcp/server.json +3 -3
  136. package/dist/mcp/server.mjs +4895 -2627
  137. package/dist/server/schemas/settings.js +3 -0
  138. package/dist/ui/assets/index-D0jaCRPM.js +29 -0
  139. package/dist/ui/index.html +1 -1
  140. package/docs/MIGRATION.md +1 -1
  141. package/docs/THIRD-PARTY-NOTICES.md +25 -0
  142. package/docs/contracts/provider-lifecycle.md +1 -1
  143. package/docs/contracts/settings-classes.md +10 -4
  144. package/docs/guidelines/agent-infra/5w2h-analysis.md +3 -3
  145. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +1 -0
  146. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +58 -3
  147. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +2 -2
  148. package/docs/guidelines/agent-infra/asking-and-brevity-examples.md +3 -3
  149. package/docs/guidelines/agent-infra/break-glass-usage.md +7 -7
  150. package/docs/guidelines/agent-infra/carve-out-predicates.md +3 -3
  151. package/docs/guidelines/agent-infra/comparison-matrix.md +1 -1
  152. package/docs/guidelines/agent-infra/critical-thinking.md +4 -4
  153. package/docs/guidelines/agent-infra/direct-answers-demos.md +2 -2
  154. package/docs/guidelines/agent-infra/domain-eval-anti-pattern.md +1 -1
  155. package/docs/guidelines/agent-infra/engineering-memory-data-format.md +3 -3
  156. package/docs/guidelines/agent-infra/existence-question-verdicts.md +80 -0
  157. package/docs/guidelines/agent-infra/first-principles.md +2 -2
  158. package/docs/guidelines/agent-infra/framework-neutrality-patterns.md +2 -2
  159. package/docs/guidelines/agent-infra/guarded-baseline.md +39 -0
  160. package/docs/guidelines/agent-infra/inversion-thinking.md +5 -5
  161. package/docs/guidelines/agent-infra/language-and-tone-examples.md +1 -1
  162. package/docs/guidelines/agent-infra/layered-settings.md +10 -10
  163. package/docs/guidelines/agent-infra/memory-access.md +1 -1
  164. package/docs/guidelines/agent-infra/mental-models.md +3 -3
  165. package/docs/guidelines/agent-infra/reuse-verdict-mechanics.md +71 -0
  166. package/docs/guidelines/agent-infra/review-routing-data-format.md +7 -7
  167. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +13 -7
  168. package/docs/guidelines/agent-infra/role-contracts.md +2 -2
  169. package/docs/guidelines/agent-infra/role-mode-router.md +3 -3
  170. package/docs/guidelines/agent-infra/rule-type-governance.md +2 -2
  171. package/docs/guidelines/agent-infra/scqa-framework.md +5 -5
  172. package/docs/guidelines/agent-infra/self-improvement-pipeline.md +6 -6
  173. package/docs/guidelines/agent-infra/simplicity-and-goal-demos.md +2 -2
  174. package/docs/guidelines/agent-infra/six-hats.md +3 -3
  175. package/docs/guidelines/agent-infra/skill-quality-checklist.md +6 -6
  176. package/docs/guidelines/agent-infra/source-confidentiality-mechanics.md +109 -0
  177. package/docs/guidelines/agent-infra/systems-thinking.md +1 -1
  178. package/docs/guidelines/agent-infra/verify-before-complete-demos.md +2 -2
  179. package/docs/guidelines/augment-portability-patterns.md +6 -4
  180. package/docs/guidelines/code-clarity.md +18 -0
  181. package/docs/guidelines/component-oriented-and-oop-development.md +3 -2
  182. package/docs/guidelines/cross-role-handoff.md +2 -2
  183. package/docs/guidelines/design-fidelity-mechanics.md +1 -1
  184. package/docs/guidelines/docs/readme-size-and-splitting.md +3 -3
  185. package/docs/guidelines/php/database.md +90 -7
  186. package/docs/guidelines/prompt-templates.md +7 -7
  187. package/llms.txt +2 -2
  188. package/package.json +5 -2
  189. package/src/agent-src/scripts/archive_completed_roadmaps.ts +252 -8
  190. package/src/agent-src/scripts/guarded_baseline.ts +343 -0
  191. package/src/agent-src/scripts/roadmap_set_step.ts +253 -0
  192. package/src/agent-src/scripts/stub_queue.ts +82 -0
  193. package/src/agent-src/scripts/stubs_due.ts +516 -0
  194. package/src/agent-src/scripts/ticket_bundles.ts +84 -0
  195. package/src/agent-src/scripts/update_roadmap_progress.ts +16 -12
  196. package/src/agent-src/templates/scripts/check_memory.ts +258 -17
  197. package/src/agent-src/templates/scripts/check_memory_proposal.ts +9 -3
  198. package/src/agent-src/templates/scripts/memory_lookup.ts +86 -0
  199. package/src/agent-src/templates/scripts/memory_signal.ts +44 -0
  200. package/src/agent-src/templates/scripts/work_engine/directives/backend/implement.ts +90 -0
  201. package/src/agent-src/templates/scripts/work_engine/directives/backend/index.ts +11 -0
  202. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +23 -1
  203. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +10 -1
  204. package/src/agent-src/templates/scripts/work_engine/directives/mixed/stitch.ts +5 -1
  205. package/src/agent-src/templates/scripts/work_engine/stack/runner.ts +102 -14
  206. package/src/config/agent-settings.template.yml +32 -3
  207. package/src/config/assurance-capability-registry.json +21 -9
  208. package/src/config/canonical-terms.yml +119 -0
  209. package/src/config/capability-native-outcome-bars.json +81 -0
  210. package/src/config/ci-local-parity.yml +80 -0
  211. package/src/config/drafting-channels.yml +43 -0
  212. package/src/config/estate-count-budget.json +57 -36
  213. package/src/config/evaluator-budgets.json +14 -14
  214. package/src/config/gate-coverage.yml +729 -63
  215. package/src/config/gate-reachability-exemptions.json +21 -0
  216. package/src/config/gate-violation-baselines.json +42 -19
  217. package/src/config/harness-evolution-budget.json +25 -0
  218. package/src/config/lapsed-beta-baseline.json +96 -0
  219. package/src/config/memory-twin-verdicts.yml +129 -0
  220. package/src/config/metric-registry.yml +124 -0
  221. package/src/config/pack-size-budget.json +16 -15
  222. package/src/config/preamble-payload-budget.json +12 -4
  223. package/src/config/profiles/balanced.ini +27 -0
  224. package/src/config/profiles/full.ini +27 -0
  225. package/src/config/profiles/minimal.ini +27 -0
  226. package/src/config/publish-surface.json +9 -2
  227. package/src/config/routing-coverage-seed.json +16 -2
  228. package/src/config/rule-stub-ceilings.json +133 -112
  229. package/src/config/turnaround-budget.json +66 -0
  230. package/src/install/claudeRuleRewrite.ts +288 -0
  231. package/src/install/detect.ts +60 -17
  232. package/src/install/detect_php_shape.ts +191 -0
  233. package/src/scripts/_cli/cmd_doctor.ts +27 -112
  234. package/src/scripts/_dispatch.bash +23 -0
  235. package/src/scripts/_lib/activation_ladder.ts +255 -0
  236. package/src/scripts/_lib/activation_receipt_producer.ts +341 -0
  237. package/src/scripts/_lib/activation_states.ts +88 -0
  238. package/src/scripts/_lib/adherence_detectors.ts +143 -0
  239. package/src/scripts/_lib/adr_frontmatter.ts +26 -0
  240. package/src/scripts/_lib/arith_claims.ts +181 -0
  241. package/src/scripts/_lib/arm_ranking.ts +62 -0
  242. package/src/scripts/_lib/asset_delivery_ledger.ts +303 -0
  243. package/src/scripts/_lib/audit_field_provenance.ts +54 -0
  244. package/src/scripts/_lib/auto_dispatch.ts +32 -3
  245. package/src/scripts/_lib/bench_ab_size_claim.ts +98 -4
  246. package/src/scripts/_lib/benchmark_cache_fields.ts +158 -0
  247. package/src/scripts/_lib/candidate_pair_delta.ts +349 -0
  248. package/src/scripts/_lib/candidate_proposer.ts +402 -0
  249. package/src/scripts/_lib/candidate_record.ts +601 -0
  250. package/src/scripts/_lib/capture_rate.ts +319 -0
  251. package/src/scripts/_lib/carrier_status.ts +118 -0
  252. package/src/scripts/_lib/cascade_stage_enumeration.ts +92 -0
  253. package/src/scripts/_lib/collector_denominator.ts +771 -0
  254. package/src/scripts/_lib/collector_record.ts +340 -0
  255. package/src/scripts/_lib/collector_store.ts +664 -0
  256. package/src/scripts/_lib/collector_supervision.ts +827 -0
  257. package/src/scripts/_lib/concern_estate.ts +77 -0
  258. package/src/scripts/_lib/config_chain.ts +477 -0
  259. package/src/scripts/_lib/config_cost.ts +209 -0
  260. package/src/scripts/_lib/corpus_manifest.ts +726 -0
  261. package/src/scripts/_lib/council_settings_block.ts +6 -0
  262. package/src/scripts/_lib/curator_ops.ts +172 -0
  263. package/src/scripts/_lib/delivery_arm_experiment.ts +282 -0
  264. package/src/scripts/_lib/delivery_criticality.ts +126 -0
  265. package/src/scripts/_lib/doctor_runtime_checks.ts +70 -0
  266. package/src/scripts/_lib/empty_cycles.ts +88 -0
  267. package/src/scripts/_lib/estate_carrier_cases.ts +72 -0
  268. package/src/scripts/_lib/eval_publication.ts +354 -0
  269. package/src/scripts/_lib/evaluation_cascade.ts +452 -0
  270. package/src/scripts/_lib/evaluation_vector.ts +283 -0
  271. package/src/scripts/_lib/evaluator_promotion.ts +225 -0
  272. package/src/scripts/_lib/evidence_basis.ts +118 -0
  273. package/src/scripts/_lib/evolution_roi.ts +562 -0
  274. package/src/scripts/_lib/exec_evidence.ts +8 -0
  275. package/src/scripts/_lib/experience_card.ts +272 -0
  276. package/src/scripts/_lib/experience_report.ts +151 -0
  277. package/src/scripts/_lib/experiment_freeze.ts +112 -0
  278. package/src/scripts/_lib/generated_by.ts +66 -0
  279. package/src/scripts/_lib/harness_evolution_guards.ts +239 -0
  280. package/src/scripts/_lib/hook_effect_probe.ts +141 -0
  281. package/src/scripts/_lib/host_denominator.ts +687 -0
  282. package/src/scripts/_lib/host_projection_reach.ts +157 -0
  283. package/src/scripts/_lib/ignored_blocker.ts +148 -0
  284. package/src/scripts/_lib/install_reach_checks.ts +495 -0
  285. package/src/scripts/_lib/json_duplicate_keys.ts +150 -0
  286. package/src/scripts/_lib/judge_hygiene.ts +228 -0
  287. package/src/scripts/_lib/legacy_boundary_map.ts +228 -0
  288. package/src/scripts/_lib/lexical_shortlist.ts +215 -0
  289. package/src/scripts/_lib/llm_candidate_proposer.ts +461 -0
  290. package/src/scripts/_lib/llm_proposer_transport.ts +153 -0
  291. package/src/scripts/_lib/loop_guards.ts +299 -0
  292. package/src/scripts/_lib/loss_class.ts +129 -0
  293. package/src/scripts/_lib/machine_wake.ts +51 -0
  294. package/src/scripts/_lib/md_prose_lines.ts +177 -0
  295. package/src/scripts/_lib/minimality_tiebreak.ts +168 -0
  296. package/src/scripts/_lib/one_resolver_invariant.ts +449 -0
  297. package/src/scripts/_lib/orchestration_record.ts +253 -5
  298. package/src/scripts/_lib/outcome_envelope.ts +227 -0
  299. package/src/scripts/_lib/outcome_vocabularies.ts +125 -0
  300. package/src/scripts/_lib/paired_stats.ts +245 -0
  301. package/src/scripts/_lib/paired_verdict.ts +226 -0
  302. package/src/scripts/_lib/pathology_archive.ts +393 -0
  303. package/src/scripts/_lib/prefix_stable_surfaces.ts +123 -0
  304. package/src/scripts/_lib/privacy_class.ts +47 -0
  305. package/src/scripts/_lib/promotion_capability.ts +315 -0
  306. package/src/scripts/_lib/promotion_evidence.ts +414 -0
  307. package/src/scripts/_lib/promotion_review.ts +294 -0
  308. package/src/scripts/_lib/prompt_shape.ts +92 -0
  309. package/src/scripts/_lib/regression_neighbourhood.ts +259 -0
  310. package/src/scripts/_lib/release_highlights.ts +70 -1
  311. package/src/scripts/_lib/release_material.ts +125 -2
  312. package/src/scripts/_lib/repeated_failure.ts +105 -0
  313. package/src/scripts/_lib/repo_root.ts +111 -0
  314. package/src/scripts/_lib/review_skipped_record.ts +37 -0
  315. package/src/scripts/_lib/role_split.ts +338 -0
  316. package/src/scripts/_lib/routing_corpus.ts +307 -0
  317. package/src/scripts/_lib/routing_index_input.ts +104 -0
  318. package/src/scripts/_lib/rule_injection.ts +22 -1
  319. package/src/scripts/_lib/runtime_journal.ts +1499 -0
  320. package/src/scripts/_lib/runtime_wiring_checks.ts +430 -0
  321. package/src/scripts/_lib/semantic_noop.ts +167 -0
  322. package/src/scripts/_lib/skill_catalogue.ts +11 -1
  323. package/src/scripts/_lib/source_digest.ts +200 -0
  324. package/src/scripts/_lib/source_redact.ts +260 -0
  325. package/src/scripts/_lib/source_shape.ts +502 -0
  326. package/src/scripts/_lib/source_snapshot_dedup.ts +199 -0
  327. package/src/scripts/_lib/strip_comments.d.mts +8 -0
  328. package/src/scripts/_lib/strip_comments.mjs +65 -0
  329. package/src/scripts/_lib/test_red_state.ts +154 -0
  330. package/src/scripts/_lib/trigger_shift.ts +144 -0
  331. package/src/scripts/_lib/untrusted_content.ts +82 -7
  332. package/src/scripts/activation_receipt.ts +258 -0
  333. package/src/scripts/adoption_report.ts +1 -1
  334. package/src/scripts/adr/evidence_census.ts +1 -1
  335. package/src/scripts/adr/regenerate_index.ts +26 -2
  336. package/src/scripts/adr_cite_check.ts +164 -1
  337. package/src/scripts/ai_council/blind_review.ts +11 -1
  338. package/src/scripts/ai_council/clients.ts +4 -0
  339. package/src/scripts/ai_council/config.ts +3 -0
  340. package/src/scripts/ai_council/consensus.ts +28 -8
  341. package/src/scripts/ai_council/consensus_round.ts +226 -0
  342. package/src/scripts/ai_council/cross_exam.ts +128 -0
  343. package/src/scripts/ai_council/disagreement_signal.ts +338 -0
  344. package/src/scripts/ai_council/explain_route.ts +157 -0
  345. package/src/scripts/ai_council/information_gain.ts +293 -0
  346. package/src/scripts/ai_council/inline_findings.ts +340 -0
  347. package/src/scripts/ai_council/judge_position_bias.ts +197 -0
  348. package/src/scripts/ai_council/leakage_corpus.ts +385 -0
  349. package/src/scripts/ai_council/leakage_patterns.ts +453 -0
  350. package/src/scripts/ai_council/minority_retention.ts +171 -0
  351. package/src/scripts/ai_council/orchestrator.ts +16 -203
  352. package/src/scripts/ai_council/orchestrator_results.ts +23 -3
  353. package/src/scripts/ai_council/pricing.ts +106 -0
  354. package/src/scripts/ai_council/prompts.ts +74 -7
  355. package/src/scripts/ai_council/provider_leakage_bench.ts +327 -0
  356. package/src/scripts/ai_council/py_format.ts +14 -3
  357. package/src/scripts/ai_council/recouncil_guard.ts +293 -0
  358. package/src/scripts/ai_council/recouncil_savings.ts +299 -0
  359. package/src/scripts/ai_council/replay_route.ts +223 -0
  360. package/src/scripts/ai_council/response_record.ts +50 -0
  361. package/src/scripts/ai_council/reviewer_assignment.ts +279 -0
  362. package/src/scripts/ai_council/routing_training_row.ts +197 -0
  363. package/src/scripts/ai_council/synthesis_strategy.ts +159 -0
  364. package/src/scripts/ai_council/topology_bench_manifest.ts +860 -0
  365. package/src/scripts/ai_council/topology_vocabulary.ts +175 -0
  366. package/src/scripts/asset_delivery_ledger.ts +157 -0
  367. package/src/scripts/attest_artifact.ts +193 -0
  368. package/src/scripts/audit_auto_rules.ts +2 -1
  369. package/src/scripts/audit_command_surface.ts +1 -1
  370. package/src/scripts/audit_mcp_tools.ts +2 -2
  371. package/src/scripts/audit_skill_overlap_allowlist.json +6 -1
  372. package/src/scripts/audit_user_type_axis.ts +2 -1
  373. package/src/scripts/bench_ab_clone.ts +215 -10
  374. package/src/scripts/bench_ab_integrity.ts +119 -5
  375. package/src/scripts/bench_ab_v2_stats.ts +40 -213
  376. package/src/scripts/build_archive_index.ts +1 -1
  377. package/src/scripts/build_linear_digest.ts +1 -1
  378. package/src/scripts/build_proof.ts +11 -4
  379. package/src/scripts/build_rule_trigger_matrix.ts +1 -1
  380. package/src/scripts/build_source_digests.ts +157 -0
  381. package/src/scripts/cache_realization_report.ts +103 -0
  382. package/src/scripts/check_adr_frontmatter.ts +131 -10
  383. package/src/scripts/check_benchmark_report_fields.ts +207 -0
  384. package/src/scripts/check_beta_review_markers.ts +235 -6
  385. package/src/scripts/check_bundle_path_leakage.ts +34 -1
  386. package/src/scripts/check_claims.ts +576 -6
  387. package/src/scripts/check_composite_arming.ts +35 -0
  388. package/src/scripts/check_concern_admissions.ts +365 -0
  389. package/src/scripts/check_enforcement_coverage.ts +28 -0
  390. package/src/scripts/check_estate_count.ts +347 -25
  391. package/src/scripts/check_gate_reachability.ts +383 -0
  392. package/src/scripts/check_host_loadability.ts +104 -0
  393. package/src/scripts/check_loss_class_declared.ts +308 -0
  394. package/src/scripts/check_md_language.ts +15 -98
  395. package/src/scripts/check_memory_proposal.ts +18 -11
  396. package/src/scripts/check_no_currency_in_cost_surfaces.ts +225 -0
  397. package/src/scripts/check_no_external_sources.ts +281 -6
  398. package/src/scripts/check_pack_conformance_fixture.ts +11 -1
  399. package/src/scripts/check_pack_size.ts +59 -2
  400. package/src/scripts/check_preamble_payload_budget.ts +114 -3
  401. package/src/scripts/check_prefix_stable_mutation.ts +372 -0
  402. package/src/scripts/check_publish_surface.ts +10 -1
  403. package/src/scripts/check_references.ts +24 -1
  404. package/src/scripts/check_release_highlights.ts +96 -13
  405. package/src/scripts/check_roadmap_trackable.ts +17 -2
  406. package/src/scripts/check_rule_evidence_declaration.ts +316 -0
  407. package/src/scripts/check_rule_projection_integrity.ts +14 -4
  408. package/src/scripts/check_score_contract.ts +26 -3
  409. package/src/scripts/check_static_parity.ts +285 -0
  410. package/src/scripts/check_supervision_claim_atomicity.ts +498 -0
  411. package/src/scripts/check_suppression_hygiene.ts +15 -0
  412. package/src/scripts/ci_settle.ts +39 -2
  413. package/src/scripts/code_graph/build.ts +60 -3
  414. package/src/scripts/code_graph/extract.ts +44 -0
  415. package/src/scripts/code_graph/types.ts +24 -0
  416. package/src/scripts/collector_daemon.ts +714 -0
  417. package/src/scripts/config_cost_report.ts +302 -0
  418. package/src/scripts/consumer_matrix.ts +74 -1
  419. package/src/scripts/corpus_manifest.ts +235 -0
  420. package/src/scripts/cost/budget.mjs +10 -3
  421. package/src/scripts/cost/track.mjs +10 -3
  422. package/src/scripts/council_cli.ts +37 -54
  423. package/src/scripts/dispatch_r2_reviewer.ts +46 -5
  424. package/src/scripts/evolution_lab.ts +1153 -0
  425. package/src/scripts/external_sources_denylist.json +5 -18
  426. package/src/scripts/extract_audit_patterns.ts +21 -0
  427. package/src/scripts/fold_intake.ts +12 -1
  428. package/src/scripts/gen_discovery_baseline.ts +1 -1
  429. package/src/scripts/generate_capabilities_index.ts +12 -5
  430. package/src/scripts/generate_capability_matrix.ts +3 -3
  431. package/src/scripts/generate_command_flows.ts +2 -2
  432. package/src/scripts/generate_cookbook.ts +2 -2
  433. package/src/scripts/generate_index.ts +8 -1
  434. package/src/scripts/generate_knowledge_index.ts +2 -1
  435. package/src/scripts/generate_ownership_matrix.ts +2 -1
  436. package/src/scripts/generate_pack_manifests.ts +10 -2
  437. package/src/scripts/generate_role_experiences_catalog.ts +2 -2
  438. package/src/scripts/generate_settings_reference.ts +1 -1
  439. package/src/scripts/git_authorization_hook.ts +22 -1
  440. package/src/scripts/grade_target_readiness.ts +48 -5
  441. package/src/scripts/hook_effect_doctor.ts +261 -0
  442. package/src/scripts/hook_manifest.json +1 -1
  443. package/src/scripts/hook_manifest.yaml +125 -16
  444. package/src/scripts/hooks/block_speaking_inbox_dir.ts +271 -0
  445. package/src/scripts/hooks/block_unauthorized_git.ts +18 -0
  446. package/src/scripts/hooks/comment_discipline_hook.ts +146 -0
  447. package/src/scripts/hooks/concern_registry.ts +6 -0
  448. package/src/scripts/hooks/dispatch_hook.ts +32 -0
  449. package/src/scripts/hooks/journal_record_hook.ts +345 -0
  450. package/src/scripts/hooks/prepush_metadata_sources.sh +110 -0
  451. package/src/scripts/hooks/run_continuation_hook.ts +44 -9
  452. package/src/scripts/hooks/shims/install.sh +61 -0
  453. package/src/scripts/hooks/shims/php +101 -0
  454. package/src/scripts/hooks/suggestion_capture_hook.ts +10 -0
  455. package/src/scripts/hooks/turn_end_gate_hook.ts +9 -1
  456. package/src/scripts/hot_context_hook.ts +16 -0
  457. package/src/scripts/install-hooks.sh +20 -5
  458. package/src/scripts/install.ts +17 -35
  459. package/src/scripts/inventory_frontmatter.ts +2 -1
  460. package/src/scripts/language_mirror_hook.ts +14 -2
  461. package/src/scripts/lint_adr_sweep_routing.ts +12 -0
  462. package/src/scripts/lint_canonical_terms.ts +604 -0
  463. package/src/scripts/lint_carrier_integrity.ts +820 -0
  464. package/src/scripts/lint_code_comments.ts +643 -0
  465. package/src/scripts/lint_composition_review.ts +619 -0
  466. package/src/scripts/lint_consolidation_lineage.ts +666 -0
  467. package/src/scripts/lint_eval_specs.ts +593 -0
  468. package/src/scripts/lint_hook_manifest.ts +19 -0
  469. package/src/scripts/lint_host_portability.ts +373 -0
  470. package/src/scripts/lint_judge_prompt_expectation.ts +27 -1
  471. package/src/scripts/lint_memory_twin_parity.ts +335 -0
  472. package/src/scripts/lint_metric_consumers.ts +355 -0
  473. package/src/scripts/lint_plan_risk_register.ts +39 -19
  474. package/src/scripts/lint_promotion_paths.ts +779 -0
  475. package/src/scripts/lint_skill_descriptions.ts +18 -1
  476. package/src/scripts/lint_skill_frontmatter_safety.ts +58 -0
  477. package/src/scripts/lint_skill_link_reach.ts +252 -0
  478. package/src/scripts/lint_skill_trigger_corpus.ts +426 -23
  479. package/src/scripts/lint_ticket_buildable.ts +17 -1
  480. package/src/scripts/llm_propose.ts +256 -0
  481. package/src/scripts/mcp_server/consumer_tool_catalog.json +1 -1
  482. package/src/scripts/mcp_server/tool_catalog_source.ts +1 -1
  483. package/src/scripts/measure_delivery_sets.ts +361 -0
  484. package/src/scripts/measure_docs_dead_links.ts +348 -0
  485. package/src/scripts/measure_host_capture.ts +461 -0
  486. package/src/scripts/measure_routing_signal.ts +307 -0
  487. package/src/scripts/migration_status.ts +3 -3
  488. package/src/scripts/model_rule_injection.ts +76 -0
  489. package/src/scripts/orchestration_record.ts +14 -0
  490. package/src/scripts/preamble_byte_census.ts +139 -10
  491. package/src/scripts/prepack-check.mjs +4 -1
  492. package/src/scripts/probe_council_retention.ts +495 -0
  493. package/src/scripts/probe_turnaround.ts +384 -0
  494. package/src/scripts/probe_unblocked_ask.ts +279 -0
  495. package/src/scripts/release.ts +21 -37
  496. package/src/scripts/release_drill.ts +143 -0
  497. package/src/scripts/release_env.ts +35 -0
  498. package/src/scripts/release_publication.ts +84 -0
  499. package/src/scripts/render_benchmark_md.ts +1 -1
  500. package/src/scripts/rule_activation_census.ts +70 -9
  501. package/src/scripts/rule_backlinks.ts +1 -1
  502. package/src/scripts/run_lifecycle_suite.ts +208 -0
  503. package/src/scripts/run_skill_evals.ts +40 -0
  504. package/src/scripts/schemas/delivery-manifest.schema.json +43 -0
  505. package/src/scripts/schemas/drafting-channels.schema.json +43 -0
  506. package/src/scripts/schemas/evals.schema.json +24 -1
  507. package/src/scripts/schemas/rule.schema.json +52 -1
  508. package/src/scripts/schemas/skill.schema.json +17 -2
  509. package/src/scripts/skill_overlap.ts +7 -1
  510. package/src/scripts/skill_tools/run_block_d_eval.ts +8 -1
  511. package/src/scripts/skill_tools/score_skill_relevance.ts +84 -3
  512. package/src/scripts/skill_tools/suggest_skill_for_task.ts +8 -1
  513. package/src/scripts/skill_usage_report.ts +2 -1
  514. package/src/scripts/stubs_due.ts +211 -0
  515. package/src/scripts/sweep_source_surfaces.ts +678 -0
  516. package/src/scripts/sync_pr_branch.ts +3 -3
  517. package/src/scripts/trigger_eval_grandfather.json +202 -222
  518. package/src/scripts/utilization_report.ts +22 -2
  519. package/src/server/schemas/settings.ts +5 -0
  520. package/src/templates/marketing-copy.yml +1 -1
  521. package/dist/ui/assets/index-Cxgy63kz.js +0 -26
@@ -0,0 +1,1499 @@
1
+ /**
2
+ * The runtime event journal — a durable, episode-keyed record of the dispatch
3
+ * stream, written by hook invocations that terminate.
4
+ *
5
+ * `road-to-runtime-event-journal` Phases 1 and 2. Every writer here starts,
6
+ * writes, and exits: this is Class A under ADR-124, the same shape as
7
+ * `_lib/test_red_state.ts`, and it opens no Class-B surface. The storage
8
+ * choice is precedented rather than novel — `code_graph/sqlite_store.ts` and
9
+ * `mcp_telemetry_store.ts` already run SQLite on the Class-A path through
10
+ * `_lib/sqlite_guard.ts`.
11
+ *
12
+ * ## Privacy is a property of the schema, not of a scrubber
13
+ *
14
+ * {@link JournalEvent} has **no field capable of holding free-form content**.
15
+ * That is enforced three ways, deliberately overlapping, because a scrubbing
16
+ * pass is exactly the mechanism that fails silently:
17
+ *
18
+ * 1. {@link JOURNAL_RECORD_KEYS} is the committed key set, bound to the record
19
+ * type in BOTH directions by a compile-time assertion below — adding a
20
+ * field without adding its key, or vice versa, does not compile.
21
+ * 2. {@link NoFreeForm} resolves to `never` if the record ever acquires a key
22
+ * from {@link FreeFormKey} (`prompt`, `body`, `path`, `payload`, ...), and
23
+ * it is applied to {@link JournalEvent} in an assertion that fails to
24
+ * type-check rather than warning.
25
+ * 3. Write time rejects absolute paths, over-long locators, and a `capability`
26
+ * that is not a bounded identifier. Belt and braces: (1) and (2) stop a
27
+ * *schema* mistake, (3) stops a *value* mistake in a field that is legal.
28
+ *
29
+ * ## Where the database lives — ACCEPTED, and the contract says so
30
+ *
31
+ * `<git-common-dir>/agent-journal/journal.sqlite`. This is the NORMATIVE path,
32
+ * not an implementation detail that drifted from a spec: the roadmap's step 1.1
33
+ * and `docs/contracts/runtime-persistence-tiers.md` were amended to state it,
34
+ * by AI council decision 2026-08-28 (anthropic + openai, 2/2 convergent).
35
+ *
36
+ * The roadmap originally specified `agents/runtime/state/`, which is
37
+ * **worktree-local**: `_lib/session_register.ts` documents the measurement
38
+ * (2026-08-07) that concerns run with `CWD = envelope.workspace_root`, so in a
39
+ * linked worktree that directory is the worktree's own and a fresh worktree's
40
+ * does not exist at all. Putting the journal there would give every checkout
41
+ * its own database and make AC-3 — "two concurrent writers **from two worktrees
42
+ * of one repository** both land" — **unfalsifiable by construction**: two
43
+ * writers to two different files always both land, and the test could never go
44
+ * red. Both council seats reached that conclusion independently, and the real
45
+ * contention the shared path creates then found three genuine durability bugs
46
+ * (see § Transient errors below, and the capture evidence page).
47
+ *
48
+ * `<git-common-dir>` is inside `.git/`, so the store is untracked for the same
49
+ * reason `agents/runtime/` is gitignored. The worktree-local path is kept as
50
+ * the documented **fallback** for a root that is not inside a git repository,
51
+ * where there is no common dir to use. {@link resolveJournal} reports which of
52
+ * the two it picked and why.
53
+ *
54
+ * ## Two identities, because one was underspecified
55
+ *
56
+ * Council 2026-08-28, decision 2. The module used to carry a single opaque
57
+ * `namespace` — the digest of the common git directory — and describe it as
58
+ * keeping records "attributable when read together". That description was
59
+ * **false in one direction**: the common git dir is the one directory every
60
+ * worktree of a repo shares BY DEFINITION, so the digest cannot attribute a
61
+ * record to a particular worktree, and no amount of reading it more carefully
62
+ * would make it do so. The concept is now split, and both halves are recorded:
63
+ *
64
+ * - **{@link JournalEvent.repository_id}** — digest of the common git
65
+ * directory. **Identical across every worktree of one repository**, distinct
66
+ * between repositories. This is what makes an episode opened in one checkout
67
+ * joinable from another, and what keeps two repositories' records
68
+ * attributable when their stores are read together.
69
+ * - **{@link JournalEvent.worktree_id}** — digest of the **per-worktree** git
70
+ * directory (`_lib/git_common_dir.ts::git_dir`). **Distinct for every
71
+ * checkout of one repository**, including the main one. This is the
72
+ * attribution the single field could not carry.
73
+ *
74
+ * Both are 12 hex characters from a domain-separated SHA-256, never a path.
75
+ * The domain separation is why `repository_id !== worktree_id` even in a main
76
+ * checkout, where the two inputs are the same directory: two ids that collide
77
+ * on the commonest layout would read as one concept wearing two names.
78
+ *
79
+ * The privacy property is unchanged and is enforced on the new field too:
80
+ * {@link JOURNAL_RECORD_KEYS} is bound to the record type in both directions,
81
+ * {@link NoFreeForm} makes a free-form key a compile error, and
82
+ * {@link requireDigest} refuses a value at write time that is not a bounded
83
+ * 12-hex identifier — so a future edit that assigned a raw absolute path to
84
+ * `worktree_id` fails at the write, not in a review.
85
+ *
86
+ * ## Cleanup when a worktree, or the repository, is deleted
87
+ *
88
+ * Stated because a storage contract that is silent about deletion is a contract
89
+ * with a hole, and because the honest answer is partly "nothing":
90
+ *
91
+ * - **A linked worktree is deleted (or `git worktree prune`d).** *Nothing
92
+ * happens.* Its records stay in the shared store, carrying a `worktree_id`
93
+ * that no longer resolves to a directory. They are not orphaned in any way a
94
+ * reader can trip over — `repository_id` still identifies the repository and
95
+ * the episode ids still join — and they expire on the ordinary 30-day TTL
96
+ * anchored at episode close ({@link episodeAnchor}, {@link pruneExpired}).
97
+ * There is **no worktree-liveness reaper**, deliberately: a record is
98
+ * evidence about a run that happened, and a checkout being removed afterwards
99
+ * does not make the run un-happen. `_lib/session_register.ts` reaps SESSION
100
+ * records because a stale session record makes a live-session claim that has
101
+ * gone false; a journal event claims only that an event occurred.
102
+ * - **The main repository is deleted.** The store goes with it. It lives inside
103
+ * `.git/`, so `rm -rf <repo>` removes `journal.sqlite` and its WAL sidecars in
104
+ * the same operation. There is nothing to clean up elsewhere, and nothing
105
+ * outside the repository is ever written — see {@link resolveJournal}, whose
106
+ * only two outputs are under `<git-common-dir>` or under the root itself.
107
+ * - **The fallback path** (no git repository) is under the root and disappears
108
+ * with it on the same terms.
109
+ *
110
+ * ## Transient errors never reach the corruption path
111
+ *
112
+ * Restated at the top of the file because it is the defect that cost the most
113
+ * to find and would be cheapest to reintroduce. {@link openJournal} probes the
114
+ * schema version of an existing database before opening it, and a probe failure
115
+ * used to set `drift = true`, which calls {@link discard} — which DELETES the
116
+ * database and its WAL sidecars.
117
+ *
118
+ * Under real two-process contention the losing process's probe threw
119
+ * `SQLITE_BUSY`, and this path then **deleted a healthy database holding
120
+ * another process's 120 committed records, while both processes exited 0.**
121
+ * Measured, not predicted.
122
+ *
123
+ * {@link isBusyError} is the separation: `SQLITE_BUSY` / `SQLITE_LOCKED` (and
124
+ * their message forms) mean *another process is holding the lock*, never *this
125
+ * file is unreadable*. Only a genuinely unreadable file is rebuilt. **A rebuild
126
+ * path that can fire on contention is a data-destruction path** — never widen
127
+ * that `catch` to treat an unclassified failure as drift.
128
+ *
129
+ * ## The episode boundary (blocker: what-counts-as-an-episode-boundary)
130
+ *
131
+ * Resolved by council 2026-08-28 as (c), one episode per task, with the
132
+ * roadmap's own opening rule REJECTED by both seats: the episode opens on the
133
+ * **first event carrying a `task_id`** — envelope correlation — and never on
134
+ * "the first mutating action", which would omit the reads and dispatch
135
+ * decisions that explain why the mutation happened. An event with no `task_id`
136
+ * stays session-scoped and is **marked** `session_fallback`, never silently
137
+ * dropped. Every record carries its boundary provenance and the version of the
138
+ * rule that derived it, so a later reconstruction can produce a corrected
139
+ * episode VIEW without rewriting a single record.
140
+ *
141
+ * ## Retention (blocker: journal-retention-and-size)
142
+ *
143
+ * Resolved as (c): a 30-day TTL anchored on **episode close**, plus an
144
+ * explicit, time-bounded, human-only hold. Holds are append-only
145
+ * `retention_hold` / `retention_release` records in their own table — never a
146
+ * mutable pin table — and they expire too (180 days, renewable), because a
147
+ * hold surface that never expires quietly recreates the unbounded store the
148
+ * TTL exists to prevent.
149
+ *
150
+ * **A derived or session-fallback boundary may not carry an episode-only
151
+ * hold.** Both council seats found the same combined failure: a hold makes a
152
+ * mis-derived boundary durable, so pinning an episode that was really three
153
+ * tasks retains two unrelated tasks forever. {@link createHold} widens such a
154
+ * hold to the containing session rather than refusing it.
155
+ *
156
+ * ## The honest limit on "human-only"
157
+ *
158
+ * {@link createHold} refuses a `created_by` that names an agent, and this
159
+ * module documents that the observed agent may never hold its own episodes.
160
+ * That is a **refusal at the API surface, not an authorization boundary**:
161
+ * nothing inside a single-process Class-A store can distinguish a human's
162
+ * keystroke from an agent that passed a human-looking string. The check stops
163
+ * the accident and the reflex; it does not stop a determined caller, and
164
+ * claiming otherwise would be the kind of coverage inflation this repository
165
+ * keeps removing. A real boundary needs a signer or an out-of-process gate,
166
+ * and neither is Class A.
167
+ */
168
+
169
+ import * as crypto from 'node:crypto';
170
+ import * as fs from 'node:fs';
171
+ import * as path from 'node:path';
172
+ import type { DatabaseSync } from 'node:sqlite';
173
+
174
+ import { current_branch, git_common_dir, git_dir } from './git_common_dir.js';
175
+ import type { TerminalState } from './outcome_envelope.js';
176
+ import { RUN_TERMINAL_STATES } from './outcome_vocabularies.js';
177
+ import {
178
+ isSqliteAvailableSync,
179
+ loadSqliteSync,
180
+ readUserVersion,
181
+ stampUserVersion,
182
+ } from './sqlite_guard.js';
183
+
184
+ /** Compile-time assertion helper: `Assert<false>` is a type error. */
185
+ type Assert<T extends true> = T;
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Versions and constants
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /**
192
+ * Bump on ANY table or column change. A mismatch discards and rebuilds.
193
+ *
194
+ * v2 (council 2026-08-28, decision 2): `namespace` became
195
+ * {@link JournalEvent.repository_id} and {@link JournalEvent.worktree_id} was
196
+ * added. A v1 database is discarded on open rather than migrated — the store is
197
+ * a gitignored, rebuildable record under `.git/`, and a migration path for a
198
+ * schema nothing has consumed yet would be code with no reader.
199
+ */
200
+ export const JOURNAL_SCHEMA_VERSION = 3;
201
+
202
+ /**
203
+ * The version of the boundary-derivation rule recorded on every event.
204
+ *
205
+ * v1 is the council's adopted rule: explicit `episode_id` wins; else derive
206
+ * from `task_id`; else fall back to the session and mark it. Bump this when
207
+ * the derivation changes, so a reconstruction can tell which rule produced a
208
+ * given record instead of assuming the current one.
209
+ */
210
+ export const BOUNDARY_RULE_VERSION = 1;
211
+
212
+ /** Default TTL, anchored on episode close. Council 2026-08-28, constraint 1. */
213
+ export const RETENTION_TTL_DAYS = 30;
214
+
215
+ /** Default hold window. Holds expire too. Council 2026-08-28, constraint 4. */
216
+ export const HOLD_DEFAULT_DAYS = 180;
217
+
218
+ /**
219
+ * How long an un-closed episode must be silent before its last event counts as
220
+ * its anchor. Below this it is treated as live and never expires — a TTL
221
+ * anchored on write rather than close would expire an episode still running.
222
+ */
223
+ export const INACTIVITY_ANCHOR_MS = 24 * 60 * 60 * 1000;
224
+
225
+ const DAY_MS = 24 * 60 * 60 * 1000;
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // The event vocabulary partition (AC-1)
229
+ // ---------------------------------------------------------------------------
230
+
231
+ /**
232
+ * Members of `EVENT_VOCABULARY` this module writes a record for.
233
+ *
234
+ * Deliberately not imported from `hooks/dispatch_hook.ts`: that module resolves
235
+ * the hook manifest from its own file location at import time, which makes it a
236
+ * poor dependency for a library used from arbitrary roots. The test enumerates
237
+ * the real `EVENT_VOCABULARY` and asserts this set plus {@link NOT_RECORDED}
238
+ * partitions it exactly, so a copy that drifts fails rather than passing.
239
+ */
240
+ export const RECORDED_EVENTS: ReadonlySet<string> = new Set([
241
+ 'session_start',
242
+ 'session_end',
243
+ 'user_prompt_submit',
244
+ 'pre_tool_use',
245
+ 'post_tool_use',
246
+ 'stop',
247
+ 'pre_compact',
248
+ 'agent_error',
249
+ 'subagent_start',
250
+ 'subagent_stop',
251
+ ]);
252
+
253
+ /**
254
+ * Members deliberately NOT recorded, each with the reason — silence about an
255
+ * event is not coverage.
256
+ *
257
+ * **Currently empty, and the emptiness is the finding.** All ten members of the
258
+ * vocabulary are recordable: the journal stores what the dispatcher hands it
259
+ * and has no per-event exclusion. The value of the partition is therefore not
260
+ * what it excludes today but that an eleventh member cannot arrive without a
261
+ * decision — the coverage test fails until it is placed in one set or the
262
+ * other, with a reason if it lands here.
263
+ */
264
+ export const NOT_RECORDED: ReadonlyMap<string, string> = new Map<string, string>();
265
+
266
+ /**
267
+ * Partition check, extracted so the test can exercise its failure mode against
268
+ * a synthetic vocabulary as well as the real one.
269
+ *
270
+ * Returns the members covered by neither set and the ones claimed by both — a
271
+ * member in both sets is as much a coverage defect as one in neither, because
272
+ * the reader cannot tell which claim is live.
273
+ */
274
+ export function partitionGaps(
275
+ vocabulary: Iterable<string>,
276
+ recorded: ReadonlySet<string> = RECORDED_EVENTS,
277
+ notRecorded: ReadonlyMap<string, string> = NOT_RECORDED,
278
+ ): { uncovered: string[]; doubleClaimed: string[]; unreasoned: string[] } {
279
+ const uncovered: string[] = [];
280
+ const doubleClaimed: string[] = [];
281
+ for (const member of vocabulary) {
282
+ const inRecorded = recorded.has(member);
283
+ const inNot = notRecorded.has(member);
284
+ if (!inRecorded && !inNot) uncovered.push(member);
285
+ if (inRecorded && inNot) doubleClaimed.push(member);
286
+ }
287
+ const unreasoned = [...notRecorded.entries()]
288
+ .filter(([, reason]) => reason.trim() === '')
289
+ .map(([member]) => member);
290
+ return { uncovered, doubleClaimed, unreasoned };
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // The record shape (1.1, 2.1, AC-2)
295
+ // ---------------------------------------------------------------------------
296
+
297
+ /** Boundary provenance. Recorded on every event, never inferred at read time. */
298
+ export const BOUNDARY_STATUSES = ['explicit', 'derived', 'session_fallback'] as const;
299
+ export type BoundaryStatus = (typeof BOUNDARY_STATUSES)[number];
300
+
301
+ /** What the orchestrator did with a return. Phase 3 fills it; Phase 1 stores it. */
302
+ export const CONSUMPTION_STATES = ['consumed', 'partially-consumed', 'rejected-with-reason'] as const;
303
+ export type Consumption = (typeof CONSUMPTION_STATES)[number];
304
+
305
+ /**
306
+ * The six terminal states as a runtime array.
307
+ *
308
+ * Re-export of `RUN_TERMINAL_STATES` from `outcome_vocabularies.ts`. It used to
309
+ * be a second literal list here, guarded by two type-level assertions that
310
+ * bound it to the imported TYPE in both directions. That guard worked — but a
311
+ * guarded duplicate is still a duplicate, and it was the only one the step-1.3
312
+ * anti-duplicate check found in this tree
313
+ * (`road-to-experience-loop-broadening` 1.3). Re-exporting removes the second
314
+ * list, which makes the assertions tautological, so they are gone too: there is
315
+ * now nothing to drift.
316
+ *
317
+ * The name is kept because it is exported and pinned by
318
+ * `tests/scripts/runtime_journal.test.ts:205,207`.
319
+ */
320
+ export const TERMINAL_STATES = RUN_TERMINAL_STATES;
321
+
322
+ /**
323
+ * Keys a record may never carry. Not an exhaustive list of every bad name — it
324
+ * is the set of names an author reaches for when they want to stash content,
325
+ * which is the failure mode this guard exists to make non-compiling.
326
+ */
327
+ export const FREE_FORM_KEYS = [
328
+ 'prompt',
329
+ 'prompt_text',
330
+ 'text',
331
+ 'content',
332
+ 'body',
333
+ 'file_body',
334
+ 'message',
335
+ 'output',
336
+ 'stdout',
337
+ 'stderr',
338
+ 'diff',
339
+ 'source',
340
+ 'snippet',
341
+ 'excerpt',
342
+ 'detail',
343
+ 'details',
344
+ 'note',
345
+ 'notes',
346
+ 'comment',
347
+ 'reason',
348
+ 'payload',
349
+ 'extra',
350
+ 'metadata',
351
+ 'data',
352
+ 'args',
353
+ 'env',
354
+ 'cwd',
355
+ 'path',
356
+ 'file_path',
357
+ 'abs_path',
358
+ 'absolute_path',
359
+ ] as const;
360
+
361
+ export type FreeFormKey = (typeof FREE_FORM_KEYS)[number];
362
+
363
+ /**
364
+ * `T` when `T` carries no {@link FreeFormKey}, and `never` when it does.
365
+ *
366
+ * Applied to {@link JournalEvent} in `_RecordCarriesNoFreeFormField` below: a
367
+ * record type that grows a `payload` or a `detail` makes that assertion
368
+ * `Assert<false>`, which is a compile error, not a lint warning.
369
+ */
370
+ export type NoFreeForm<T> = Extract<keyof T, FreeFormKey> extends never ? T : never;
371
+
372
+ /** One journal event. These fields and no others. */
373
+ export interface JournalEvent {
374
+ /** Monotonic within one database. Assigned by SQLite, never by a caller. */
375
+ seq: number;
376
+ /** A member of `EVENT_VOCABULARY`. */
377
+ event: string;
378
+ /** Opaque episode id. Derived per {@link BOUNDARY_RULE_VERSION}. */
379
+ episode_id: string;
380
+ /** Opaque host session id. */
381
+ session_id: string;
382
+ /** Opaque envelope task id, when the envelope assigned one. */
383
+ task_id: string | null;
384
+ /** Opaque prompt id, when the event has one. */
385
+ prompt_id: string | null;
386
+ /** How `episode_id` was arrived at. */
387
+ boundary_status: BoundaryStatus;
388
+ /** Which derivation rule produced `boundary_status`. */
389
+ boundary_rule_version: number;
390
+ /**
391
+ * Digest of the **common** git directory — the same value from every
392
+ * worktree of one repository. See the module docstring's § Two identities.
393
+ */
394
+ repository_id: string;
395
+ /**
396
+ * Digest of the **per-worktree** git directory — a distinct value for every
397
+ * checkout of one repository, the main one included. Bounded identifier,
398
+ * never a path: {@link requireDigest} refuses anything else at write time.
399
+ */
400
+ worktree_id: string;
401
+ /** ISO-8601 instant. */
402
+ at: string;
403
+ /** Bounded identifier: the hook or command name. Never free text. */
404
+ capability: string;
405
+ /** Imported from `outcome_envelope.ts`; null until an episode terminates. */
406
+ terminal_state: TerminalState | null;
407
+ /** Repo-relative locator for the return. Never an absolute path. */
408
+ return_ref: string | null;
409
+ /** Repo-relative locator for the verification. Never an absolute path. */
410
+ verification_ref: string | null;
411
+ /** What the orchestrator did with the return. Phase 3 fills it. */
412
+ consumption: Consumption | null;
413
+ /** Provisional per-record expiry; see {@link effectiveExpiry}. ISO-8601. */
414
+ retain_until: string;
415
+ /**
416
+ * The `seq` this event AMENDS, or `null` for an original observation.
417
+ *
418
+ * Outcomes arrive late — rework, a regression, a review landing after the
419
+ * episode closed — and this store is append-only by construction (`seq` is
420
+ * assigned by SQLite, never by a caller), so the original cannot be edited
421
+ * and must not be. An amendment is a NEW ROW pointing at the one it
422
+ * revises; the original stays byte-identical and {@link reconstructEpisode}
423
+ * folds the chain. That matters for the repeated-failure rate in
424
+ * particular: a repeat is exactly the signal that surfaces after the record
425
+ * is written, so a rate over unamended rows undercounts what it measures.
426
+ */
427
+ amends_seq: number | null;
428
+ }
429
+
430
+ /** The committed key set. AC-2 asserts a record's keys against exactly this. */
431
+ export const JOURNAL_RECORD_KEYS = Object.freeze([
432
+ 'seq',
433
+ 'event',
434
+ 'episode_id',
435
+ 'session_id',
436
+ 'task_id',
437
+ 'prompt_id',
438
+ 'boundary_status',
439
+ 'boundary_rule_version',
440
+ 'repository_id',
441
+ 'worktree_id',
442
+ 'at',
443
+ 'capability',
444
+ 'terminal_state',
445
+ 'return_ref',
446
+ 'verification_ref',
447
+ 'consumption',
448
+ 'retain_until',
449
+ 'amends_seq',
450
+ ] as const);
451
+
452
+ type _KeysCoverTheRecord = Assert<
453
+ Exclude<keyof JournalEvent, (typeof JOURNAL_RECORD_KEYS)[number]> extends never ? true : false
454
+ >;
455
+ type _KeysAddNothing = Assert<
456
+ Exclude<(typeof JOURNAL_RECORD_KEYS)[number], keyof JournalEvent> extends never ? true : false
457
+ >;
458
+ type _RecordCarriesNoFreeFormField = Assert<[NoFreeForm<JournalEvent>] extends [never] ? false : true>;
459
+
460
+ // ---------------------------------------------------------------------------
461
+ // Retention records
462
+ // ---------------------------------------------------------------------------
463
+
464
+ export const RETENTION_KINDS = ['retention_hold', 'retention_release'] as const;
465
+ export type RetentionKind = (typeof RETENTION_KINDS)[number];
466
+
467
+ export const HOLD_SCOPES = ['episode', 'session'] as const;
468
+ export type HoldScope = (typeof HOLD_SCOPES)[number];
469
+
470
+ /**
471
+ * One append-only retention record.
472
+ *
473
+ * `reason` is free text and is the one place in this module that holds any.
474
+ * That is deliberate and bounded: it is human-authored, capped at 500
475
+ * characters, lives in a table {@link NoFreeForm} does not govern, and exists
476
+ * because a hold whose reason nobody wrote down is unreviewable. The EVENT
477
+ * record — the one written on every hook invocation, at volume, by machinery —
478
+ * carries none.
479
+ */
480
+ export interface RetentionRecord {
481
+ seq: number;
482
+ kind: RetentionKind;
483
+ scope: HoldScope;
484
+ /** `episode_id` for an episode-scoped record, `session_id` for a session one. */
485
+ scope_id: string;
486
+ repository_id: string;
487
+ created_at: string;
488
+ /** Who created it. Must not name an agent — see the module docstring. */
489
+ created_by: string;
490
+ reason: string;
491
+ retain_until: string;
492
+ }
493
+
494
+ /** Raised when a caller asks the journal to violate its own contract. */
495
+ export class JournalContractError extends Error {
496
+ constructor(message: string) {
497
+ super(message);
498
+ this.name = 'JournalContractError';
499
+ }
500
+ }
501
+
502
+ // ---------------------------------------------------------------------------
503
+ // Value validation (the third privacy layer)
504
+ // ---------------------------------------------------------------------------
505
+
506
+ /** A bounded identifier: a hook or command name, never a sentence. */
507
+ const CAPABILITY_RE = /^[a-z0-9][a-z0-9._:-]{0,63}$/;
508
+ /** An opaque id: a uuid, a hash, a host token. Bounded and punctuation-poor. */
509
+ const OPAQUE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
510
+ /** Locators are repo-relative and short. 256 is generous for a path in-tree. */
511
+ const MAX_REF_LEN = 256;
512
+ /**
513
+ * The shape {@link digest} produces: exactly twelve lower-case hex characters.
514
+ *
515
+ * `repository_id` and `worktree_id` are checked against this at write time.
516
+ * They are derived internally today, so the check cannot fail from here — which
517
+ * is the point: it is the value-level half of the no-absolute-path property,
518
+ * and it fires the day someone hands the writer an id from somewhere else.
519
+ */
520
+ const DIGEST_RE = /^[0-9a-f]{12}$/;
521
+
522
+ /**
523
+ * Actor names that read as the agent rather than a human.
524
+ *
525
+ * Matched case-insensitively against the whole value and against a leading
526
+ * `<word>:` prefix. See the module docstring for what this does and does not
527
+ * guarantee.
528
+ */
529
+ const AGENT_ACTOR_WORDS: ReadonlySet<string> = new Set([
530
+ 'agent',
531
+ 'ai',
532
+ 'assistant',
533
+ 'auto',
534
+ 'automation',
535
+ 'bot',
536
+ 'claude',
537
+ 'codex',
538
+ 'copilot',
539
+ 'cursor',
540
+ 'gemini',
541
+ 'hook',
542
+ 'llm',
543
+ 'model',
544
+ 'orchestrator',
545
+ 'subagent',
546
+ 'system',
547
+ ]);
548
+
549
+ /** True when `actor` names an agent rather than a person. */
550
+ export function isAgentActor(actor: string): boolean {
551
+ const v = actor.trim().toLowerCase();
552
+ if (v === '') return true;
553
+ if (AGENT_ACTOR_WORDS.has(v)) return true;
554
+ const prefix = v.split(':', 1)[0] ?? '';
555
+ return AGENT_ACTOR_WORDS.has(prefix);
556
+ }
557
+
558
+ /** True when `ref` is a safe repo-relative locator. */
559
+ export function isRepoRelativeRef(ref: string): boolean {
560
+ if (ref === '' || ref.length > MAX_REF_LEN) return false;
561
+ if (ref.startsWith('/') || ref.startsWith('\\')) return false;
562
+ if (/^[A-Za-z]:[\\/]/.test(ref)) return false; // windows drive
563
+ if (/[\u0000-\u001f]/.test(ref)) return false;
564
+ if (ref.startsWith('~')) return false;
565
+ const parts = ref.split(/[\\/]/);
566
+ return !parts.includes('..');
567
+ }
568
+
569
+ function requireRef(name: string, ref: string | null | undefined): string | null {
570
+ if (ref === null || ref === undefined) return null;
571
+ if (!isRepoRelativeRef(ref)) {
572
+ throw new JournalContractError(
573
+ `${name} must be a repo-relative locator under ${MAX_REF_LEN} characters — ` +
574
+ `refused ${JSON.stringify(ref.slice(0, 60))}. An absolute path names the machine ` +
575
+ 'that wrote the record, which is the privacy failure the schema exists to prevent.',
576
+ );
577
+ }
578
+ return ref;
579
+ }
580
+
581
+ /** Refuse an identity value that is not a bounded digest. */
582
+ function requireDigest(name: string, value: string): string {
583
+ if (!DIGEST_RE.test(value)) {
584
+ throw new JournalContractError(
585
+ `${name} must be a 12-character hex digest matching ${String(DIGEST_RE)} — refused ` +
586
+ `${JSON.stringify(value.slice(0, 60))}. This field identifies a repository or a ` +
587
+ 'checkout; a path here would name the machine that wrote the record, which is the ' +
588
+ 'privacy failure the schema exists to prevent.',
589
+ );
590
+ }
591
+ return value;
592
+ }
593
+
594
+ function requireId(name: string, id: string | null | undefined, optional: boolean): string | null {
595
+ if (id === null || id === undefined || id === '') {
596
+ if (optional) return null;
597
+ throw new JournalContractError(`${name} is required and must be a non-empty opaque id.`);
598
+ }
599
+ if (!OPAQUE_ID_RE.test(id)) {
600
+ throw new JournalContractError(
601
+ `${name} must be a bounded opaque id matching ${String(OPAQUE_ID_RE)} — refused ` +
602
+ `${JSON.stringify(id.slice(0, 60))}. Free text in an id field is content in disguise.`,
603
+ );
604
+ }
605
+ return id;
606
+ }
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // Location and repository_id (2.3)
610
+ // ---------------------------------------------------------------------------
611
+
612
+ export const JOURNAL_DIR_NAME = 'agent-journal';
613
+ export const JOURNAL_FILE_NAME = 'journal.sqlite';
614
+ /** The documented fallback path when no common git dir resolves. */
615
+ export const JOURNAL_FALLBACK_REL = path.join('agents', 'runtime', 'state', JOURNAL_FILE_NAME);
616
+
617
+ export interface JournalLocation {
618
+ /** Absolute path to the database file. */
619
+ readonly path: string;
620
+ /**
621
+ * `repo-shared` — under the common git dir, so every worktree of the repo
622
+ * writes ONE database. `worktree-local` — the fallback, outside a repo.
623
+ */
624
+ readonly scope: 'repo-shared' | 'worktree-local';
625
+ /** Why that scope was chosen. Never a silent default. */
626
+ readonly reason: string;
627
+ /** Digest of the common git dir — one value per repository. */
628
+ readonly repository_id: string;
629
+ /** Digest of the per-worktree git dir — one value per checkout. */
630
+ readonly worktree_id: string;
631
+ }
632
+
633
+ /**
634
+ * Twelve hex characters — citable in prose, and it carries no path.
635
+ *
636
+ * `domain` is not decoration: `repository_id` and `worktree_id` are digests of
637
+ * the SAME directory in a main checkout, and two ids that collide on the
638
+ * commonest layout would read as one concept wearing two names. Separating the
639
+ * domains makes them differ everywhere, which is what lets a test assert the
640
+ * two are distinct concepts rather than assert a coincidence.
641
+ */
642
+ function digest(domain: string, input: string): string {
643
+ return crypto.createHash('sha256').update(`${domain}\u0000${input}`).digest('hex').slice(0, 12);
644
+ }
645
+
646
+ /** Domain tags for {@link digest}. Distinct so the two ids never collide. */
647
+ const REPO_DOMAIN = 'agent-journal/repository';
648
+ const WORKTREE_DOMAIN = 'agent-journal/worktree';
649
+
650
+ /**
651
+ * Where this root's journal lives, and the two identities its records carry.
652
+ *
653
+ * Both are resolved by `_lib/git_common_dir.ts` rather than by shelling out —
654
+ * that module's own header records why: an inherited `GIT_DIR` silently
655
+ * redirects `git` to the wrong repository inside a hook, and `rev-parse
656
+ * --git-common-dir` prints a relative path from a main checkout and an absolute
657
+ * one from a worktree, so the raw output is not comparable across checkouts.
658
+ * The helper realpaths both, which is what makes one stable digest per
659
+ * repository and one per checkout possible.
660
+ *
661
+ * - `repository_id` — digest of `git_common_dir(root)`, the directory every
662
+ * worktree of a repo shares. Same value from every checkout.
663
+ * - `worktree_id` — digest of `git_dir(root)`: `.git` in a main checkout, the
664
+ * linked gitdir (`<common>/worktrees/<name>`) in a worktree. Different value
665
+ * for every checkout.
666
+ *
667
+ * Outside a git repository neither is resolvable from git, so both fall back to
668
+ * the realpath of the root — which IS the checkout there — under their own
669
+ * domains. That is reported through `scope` and `reason`, never silently.
670
+ */
671
+ export function resolveJournal(root: string): JournalLocation {
672
+ const common = git_common_dir(root);
673
+ if (common !== null) {
674
+ // `git_dir` resolves for any root `git_common_dir` resolved for; the
675
+ // fallback keeps a degenerate layout writing a well-shaped id rather
676
+ // than throwing on the hook path.
677
+ const own = git_dir(root) ?? common;
678
+ return {
679
+ path: path.join(common, JOURNAL_DIR_NAME, JOURNAL_FILE_NAME),
680
+ scope: 'repo-shared',
681
+ reason: 'common git dir resolved — every worktree of this repo writes one database',
682
+ repository_id: digest(REPO_DOMAIN, common),
683
+ worktree_id: digest(WORKTREE_DOMAIN, own),
684
+ };
685
+ }
686
+ let real = path.resolve(root);
687
+ try {
688
+ real = fs.realpathSync(real);
689
+ } catch {
690
+ /* keep the resolved form */
691
+ }
692
+ return {
693
+ path: path.join(real, JOURNAL_FALLBACK_REL),
694
+ scope: 'worktree-local',
695
+ reason: 'no common git dir — not inside a git repository; falling back to the tree-local path',
696
+ repository_id: digest(REPO_DOMAIN, real),
697
+ worktree_id: digest(WORKTREE_DOMAIN, real),
698
+ };
699
+ }
700
+
701
+ /**
702
+ * A cache key for a derived episode PROJECTION — repository_id plus branch.
703
+ *
704
+ * Deliberately NOT a record field: the repository_id is branch-independent by
705
+ * construction, which is the property that lets two branches of one repo share
706
+ * a journal. A projection built while on one branch must not be silently reused
707
+ * on another, so the invalidation lives on the projection key rather than on
708
+ * the records it is derived from. Records stay append-only.
709
+ */
710
+ export function projectionKey(root: string): string {
711
+ const { repository_id } = resolveJournal(root);
712
+ const branch = current_branch(root);
713
+ return `${repository_id}:${branch ?? 'detached'}`;
714
+ }
715
+
716
+ // ---------------------------------------------------------------------------
717
+ // Storage
718
+ // ---------------------------------------------------------------------------
719
+
720
+ export interface JournalHandle {
721
+ readonly db: DatabaseSync;
722
+ readonly location: JournalLocation;
723
+ close(): void;
724
+ }
725
+
726
+ function createSchema(db: DatabaseSync): void {
727
+ // WAL is what lets a second process write while a first one is mid-write;
728
+ // busy_timeout is what turns the residual lock contention into a wait
729
+ // rather than an immediate SQLITE_BUSY. Both are load-bearing for 1.3, and
730
+ // neutralising either one is what makes that test go red.
731
+ // ORDER IS LOAD-BEARING, and the WAL transition needs more than ordering.
732
+ // `busy_timeout` comes FIRST so every statement after it waits instead of
733
+ // failing. It is not enough on its own: `PRAGMA journal_mode = WAL` takes
734
+ // an exclusive lock and does NOT invoke the busy handler, so it returns
735
+ // SQLITE_BUSY immediately when a second process is opening the same fresh
736
+ // database. Measured, not predicted — the two-process test failed on that
737
+ // exact line with the timeout already set. ensureWal() carries the bounded
738
+ // retry the PRAGMA will not do for itself.
739
+ db.exec('PRAGMA busy_timeout = 5000');
740
+ ensureWal(db);
741
+ db.exec('PRAGMA synchronous = NORMAL');
742
+ db.exec(
743
+ `CREATE TABLE IF NOT EXISTS journal_event (
744
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
745
+ event TEXT NOT NULL,
746
+ episode_id TEXT NOT NULL,
747
+ session_id TEXT NOT NULL,
748
+ task_id TEXT,
749
+ prompt_id TEXT,
750
+ boundary_status TEXT NOT NULL,
751
+ boundary_rule_version INTEGER NOT NULL,
752
+ repository_id TEXT NOT NULL,
753
+ worktree_id TEXT NOT NULL,
754
+ at TEXT NOT NULL,
755
+ capability TEXT NOT NULL,
756
+ terminal_state TEXT,
757
+ return_ref TEXT,
758
+ verification_ref TEXT,
759
+ consumption TEXT,
760
+ retain_until TEXT NOT NULL,
761
+ amends_seq INTEGER
762
+ )`,
763
+ );
764
+ db.exec('CREATE INDEX IF NOT EXISTS idx_event_episode ON journal_event(repository_id, episode_id, seq)');
765
+ db.exec('CREATE INDEX IF NOT EXISTS idx_event_session ON journal_event(repository_id, session_id, seq)');
766
+ db.exec('CREATE INDEX IF NOT EXISTS idx_event_task ON journal_event(repository_id, task_id)');
767
+ // The worktree attribution the single-id schema could not express. Indexed
768
+ // because "which checkout produced these records" is the question the field
769
+ // was added to answer, and an unindexed answer is a table scan.
770
+ db.exec('CREATE INDEX IF NOT EXISTS idx_event_worktree ON journal_event(repository_id, worktree_id, seq)');
771
+ db.exec(
772
+ `CREATE TABLE IF NOT EXISTS retention_record (
773
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
774
+ kind TEXT NOT NULL,
775
+ scope TEXT NOT NULL,
776
+ scope_id TEXT NOT NULL,
777
+ repository_id TEXT NOT NULL,
778
+ created_at TEXT NOT NULL,
779
+ created_by TEXT NOT NULL,
780
+ reason TEXT NOT NULL,
781
+ retain_until TEXT NOT NULL
782
+ )`,
783
+ );
784
+ db.exec('CREATE INDEX IF NOT EXISTS idx_hold_scope ON retention_record(repository_id, scope, scope_id, seq)');
785
+ }
786
+
787
+ /**
788
+ * Is this failure lock contention rather than corruption?
789
+ *
790
+ * `node:sqlite` surfaces `errcode` (5 = SQLITE_BUSY, 6 = SQLITE_LOCKED) on the
791
+ * thrown error; the message check is the fallback for a runtime that does not.
792
+ * The distinction is load-bearing: a rebuild path that fires on contention
793
+ * deletes healthy records belonging to another process.
794
+ */
795
+ function isBusyError(exc: unknown): boolean {
796
+ const e = exc as { errcode?: unknown; code?: unknown; message?: unknown };
797
+ if (e?.errcode === 5 || e?.errcode === 6) return true;
798
+ if (typeof e?.code === 'string' && /BUSY|LOCKED/i.test(e.code)) return true;
799
+ return typeof e?.message === 'string' && /database is locked|database table is locked|busy/i.test(e.message);
800
+ }
801
+
802
+ /** Synchronous sleep. The Class-A path is sync end to end; no event loop here. */
803
+ function sleepSync(ms: number): void {
804
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
805
+ }
806
+
807
+ /** The current persisted journal mode, lower-cased. A read; takes no lock. */
808
+ function journalMode(db: DatabaseSync): string {
809
+ const row = db.prepare('PRAGMA journal_mode').get() as { journal_mode?: unknown } | undefined;
810
+ return String(row?.journal_mode ?? '').toLowerCase();
811
+ }
812
+
813
+ /**
814
+ * Put the database in WAL, retrying while another process holds the lock.
815
+ *
816
+ * The transition is one-time — the mode is persisted in the file header — so
817
+ * after the first successful call every later opener reads `wal` and returns
818
+ * without touching a lock at all. Only the opening race pays the retry.
819
+ */
820
+ function ensureWal(db: DatabaseSync): void {
821
+ const deadline = Date.now() + 5000;
822
+ for (;;) {
823
+ if (journalMode(db) === 'wal') return;
824
+ try {
825
+ db.exec('PRAGMA journal_mode = WAL');
826
+ if (journalMode(db) === 'wal') return;
827
+ } catch (exc) {
828
+ if (!isBusyError(exc)) throw exc;
829
+ }
830
+ if (Date.now() >= deadline) {
831
+ throw new JournalContractError(
832
+ 'could not switch the journal into WAL mode within 5s — another process holds the ' +
833
+ 'database lock. The journal is unavailable; degrade rather than failing the hook.',
834
+ );
835
+ }
836
+ sleepSync(15);
837
+ }
838
+ }
839
+
840
+ function discard(dbPath: string): void {
841
+ for (const p of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
842
+ try {
843
+ fs.rmSync(p, { force: true });
844
+ } catch {
845
+ /* best-effort */
846
+ }
847
+ }
848
+ }
849
+
850
+ /**
851
+ * Open (creating on first write) the journal for `root`.
852
+ *
853
+ * Throws {@link JournalContractError} when `node:sqlite` is unavailable —
854
+ * callers on the hook path should probe {@link isJournalAvailable} first and
855
+ * degrade rather than failing the hook. A journal that cannot be written is a
856
+ * degraded state to report, never a reason to break the run that reports it.
857
+ */
858
+ export function openJournal(root: string): JournalHandle {
859
+ if (!isSqliteAvailableSync()) {
860
+ throw new JournalContractError(
861
+ 'node:sqlite is unavailable in this runtime — the journal cannot be opened. ' +
862
+ 'Probe isJournalAvailable() and degrade instead of failing the hook.',
863
+ );
864
+ }
865
+ const location = resolveJournal(root);
866
+ fs.mkdirSync(path.dirname(location.path), { recursive: true });
867
+ const { DatabaseSync } = loadSqliteSync('runtime_journal');
868
+
869
+ if (fs.existsSync(location.path)) {
870
+ let drift = false;
871
+ let probe: DatabaseSync | null = null;
872
+ try {
873
+ probe = new DatabaseSync(location.path);
874
+ probe.exec('PRAGMA busy_timeout = 5000');
875
+ const v = readUserVersion(probe);
876
+ drift = v !== 0 && v !== JOURNAL_SCHEMA_VERSION;
877
+ } catch (exc) {
878
+ // A LOCKED database is not a corrupt one. Treating contention as
879
+ // corruption here made the version probe destroy a concurrent
880
+ // writer's records — measured, not predicted: the two-process test
881
+ // reported both writers exiting 0 and only one writer's 120 events
882
+ // surviving, because the loser's probe threw SQLITE_BUSY and this
883
+ // branch then called discard() on a healthy database. Only a
884
+ // genuinely unreadable file is rebuilt; a busy one is left alone.
885
+ drift = !isBusyError(exc);
886
+ } finally {
887
+ try {
888
+ probe?.close();
889
+ } catch {
890
+ /* best-effort */
891
+ }
892
+ }
893
+ if (drift) discard(location.path);
894
+ }
895
+
896
+ const db = new DatabaseSync(location.path);
897
+ createSchema(db);
898
+ stampUserVersion(db, JOURNAL_SCHEMA_VERSION);
899
+ return {
900
+ db,
901
+ location,
902
+ close(): void {
903
+ try {
904
+ db.close();
905
+ } catch {
906
+ /* best-effort */
907
+ }
908
+ },
909
+ };
910
+ }
911
+
912
+ /** Non-throwing probe: can a journal be opened in this runtime at all? */
913
+ export function isJournalAvailable(): boolean {
914
+ return isSqliteAvailableSync();
915
+ }
916
+
917
+ /** Open, run, close. The Class-A shape: start, write, exit. */
918
+ export function withJournal<T>(root: string, fn: (h: JournalHandle) => T): T {
919
+ const h = openJournal(root);
920
+ try {
921
+ return fn(h);
922
+ } finally {
923
+ h.close();
924
+ }
925
+ }
926
+
927
+ // ---------------------------------------------------------------------------
928
+ // Boundary derivation (2.1) and writing (1.1, 1.2)
929
+ // ---------------------------------------------------------------------------
930
+
931
+ export interface BoundaryDecision {
932
+ readonly episode_id: string;
933
+ readonly boundary_status: BoundaryStatus;
934
+ readonly boundary_rule_version: number;
935
+ }
936
+
937
+ /**
938
+ * Rule v1, stated once so both the writer and any later reconstruction can cite
939
+ * the same version.
940
+ *
941
+ * - An explicit `episode_id` from the envelope wins -> `explicit`.
942
+ * - Else a `task_id` derives one -> `derived`. The FIRST event carrying that
943
+ * task id therefore opens the episode, which is the council's adopted rule.
944
+ * - Else the session is the fallback key -> `session_fallback`, marked.
945
+ */
946
+ export function deriveBoundary(input: {
947
+ repository_id: string;
948
+ session_id: string;
949
+ task_id?: string | null;
950
+ episode_id?: string | null;
951
+ }): BoundaryDecision {
952
+ if (input.episode_id !== null && input.episode_id !== undefined && input.episode_id !== '') {
953
+ return {
954
+ episode_id: input.episode_id,
955
+ boundary_status: 'explicit',
956
+ boundary_rule_version: BOUNDARY_RULE_VERSION,
957
+ };
958
+ }
959
+ if (input.task_id !== null && input.task_id !== undefined && input.task_id !== '') {
960
+ return {
961
+ episode_id: `ep-${digest('agent-journal/episode', `${input.repository_id} task ${input.task_id}`)}`,
962
+ boundary_status: 'derived',
963
+ boundary_rule_version: BOUNDARY_RULE_VERSION,
964
+ };
965
+ }
966
+ return {
967
+ episode_id: `sess-${digest('agent-journal/session', `${input.repository_id} session ${input.session_id}`)}`,
968
+ boundary_status: 'session_fallback',
969
+ boundary_rule_version: BOUNDARY_RULE_VERSION,
970
+ };
971
+ }
972
+
973
+ export interface RecordEventInput {
974
+ event: string;
975
+ session_id: string;
976
+ capability: string;
977
+ task_id?: string | null;
978
+ prompt_id?: string | null;
979
+ episode_id?: string | null;
980
+ terminal_state?: TerminalState | null;
981
+ return_ref?: string | null;
982
+ verification_ref?: string | null;
983
+ consumption?: Consumption | null;
984
+ /**
985
+ * The `seq` this event amends. Omit for an original observation.
986
+ * See {@link JournalEvent.amends_seq} for why an amendment is a new row.
987
+ */
988
+ amends_seq?: number | null;
989
+ /** ISO instant; defaults to now. Injectable so tests are deterministic. */
990
+ at?: string;
991
+ }
992
+
993
+ /** Append one event. Returns the record as written, `seq` included. */
994
+ export function recordEvent(h: JournalHandle, input: RecordEventInput): JournalEvent {
995
+ if (!RECORDED_EVENTS.has(input.event)) {
996
+ const why = NOT_RECORDED.get(input.event);
997
+ throw new JournalContractError(
998
+ why === undefined
999
+ ? `unknown event ${JSON.stringify(input.event)} — not a member of the recorded set. ` +
1000
+ 'Add it to RECORDED_EVENTS or to NOT_RECORDED with a reason; silence is not coverage.'
1001
+ : `event ${JSON.stringify(input.event)} is deliberately not recorded: ${why}`,
1002
+ );
1003
+ }
1004
+ if (!CAPABILITY_RE.test(input.capability)) {
1005
+ throw new JournalContractError(
1006
+ `capability must be a bounded identifier matching ${String(CAPABILITY_RE)} — refused ` +
1007
+ `${JSON.stringify(input.capability.slice(0, 60))}. This field names a hook or a ` +
1008
+ 'command; free text here is a content field wearing an identifier name.',
1009
+ );
1010
+ }
1011
+ const session_id = requireId('session_id', input.session_id, false) as string;
1012
+ const task_id = requireId('task_id', input.task_id, true);
1013
+ const prompt_id = requireId('prompt_id', input.prompt_id, true);
1014
+ const explicit = requireId('episode_id', input.episode_id, true);
1015
+
1016
+ if (
1017
+ input.terminal_state !== null &&
1018
+ input.terminal_state !== undefined &&
1019
+ !(TERMINAL_STATES as readonly string[]).includes(input.terminal_state)
1020
+ ) {
1021
+ throw new JournalContractError(
1022
+ `terminal_state ${JSON.stringify(input.terminal_state)} is not one of the six.`,
1023
+ );
1024
+ }
1025
+ if (
1026
+ input.consumption !== null &&
1027
+ input.consumption !== undefined &&
1028
+ !(CONSUMPTION_STATES as readonly string[]).includes(input.consumption)
1029
+ ) {
1030
+ throw new JournalContractError(`consumption ${JSON.stringify(input.consumption)} is not a known state.`);
1031
+ }
1032
+
1033
+ const at = input.at ?? new Date().toISOString();
1034
+ if (Number.isNaN(Date.parse(at))) {
1035
+ throw new JournalContractError(`at ${JSON.stringify(at)} is not an ISO-8601 instant.`);
1036
+ }
1037
+ const repository_id = requireDigest('repository_id', h.location.repository_id);
1038
+ const worktree_id = requireDigest('worktree_id', h.location.worktree_id);
1039
+ const boundary = deriveBoundary({ repository_id, session_id, task_id, episode_id: explicit });
1040
+ const retain_until = new Date(Date.parse(at) + RETENTION_TTL_DAYS * DAY_MS).toISOString();
1041
+
1042
+ const row: Omit<JournalEvent, 'seq'> = {
1043
+ event: input.event,
1044
+ episode_id: boundary.episode_id,
1045
+ session_id,
1046
+ task_id,
1047
+ prompt_id,
1048
+ boundary_status: boundary.boundary_status,
1049
+ boundary_rule_version: boundary.boundary_rule_version,
1050
+ repository_id,
1051
+ worktree_id,
1052
+ at,
1053
+ capability: input.capability,
1054
+ terminal_state: input.terminal_state ?? null,
1055
+ return_ref: requireRef('return_ref', input.return_ref),
1056
+ verification_ref: requireRef('verification_ref', input.verification_ref),
1057
+ consumption: input.consumption ?? null,
1058
+ retain_until,
1059
+ amends_seq: input.amends_seq ?? null,
1060
+ };
1061
+
1062
+ h.db
1063
+ .prepare(
1064
+ `INSERT INTO journal_event
1065
+ (event, episode_id, session_id, task_id, prompt_id, boundary_status,
1066
+ boundary_rule_version, repository_id, worktree_id, at, capability,
1067
+ terminal_state, return_ref, verification_ref, consumption, retain_until,
1068
+ amends_seq)
1069
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1070
+ )
1071
+ .run(
1072
+ row.event,
1073
+ row.episode_id,
1074
+ row.session_id,
1075
+ row.task_id,
1076
+ row.prompt_id,
1077
+ row.boundary_status,
1078
+ row.boundary_rule_version,
1079
+ row.repository_id,
1080
+ row.worktree_id,
1081
+ row.at,
1082
+ row.capability,
1083
+ row.terminal_state,
1084
+ row.return_ref,
1085
+ row.verification_ref,
1086
+ row.consumption,
1087
+ row.retain_until,
1088
+ row.amends_seq,
1089
+ );
1090
+ const seqRow = h.db.prepare('SELECT last_insert_rowid() AS seq').get() as { seq: number | bigint };
1091
+ return { seq: Number(seqRow.seq), ...row };
1092
+ }
1093
+
1094
+ function toEvent(r: Record<string, unknown>): JournalEvent {
1095
+ return {
1096
+ seq: Number(r['seq']),
1097
+ event: String(r['event']),
1098
+ episode_id: String(r['episode_id']),
1099
+ session_id: String(r['session_id']),
1100
+ task_id: (r['task_id'] as string | null) ?? null,
1101
+ prompt_id: (r['prompt_id'] as string | null) ?? null,
1102
+ boundary_status: String(r['boundary_status']) as BoundaryStatus,
1103
+ boundary_rule_version: Number(r['boundary_rule_version']),
1104
+ repository_id: String(r['repository_id']),
1105
+ worktree_id: String(r['worktree_id']),
1106
+ at: String(r['at']),
1107
+ capability: String(r['capability']),
1108
+ terminal_state: (r['terminal_state'] as TerminalState | null) ?? null,
1109
+ return_ref: (r['return_ref'] as string | null) ?? null,
1110
+ verification_ref: (r['verification_ref'] as string | null) ?? null,
1111
+ consumption: (r['consumption'] as Consumption | null) ?? null,
1112
+ retain_until: String(r['retain_until']),
1113
+ amends_seq: r['amends_seq'] === null || r['amends_seq'] === undefined ? null : Number(r['amends_seq']),
1114
+ };
1115
+ }
1116
+
1117
+ /** Every event of one episode, in write order. */
1118
+ export function readEpisodeEvents(h: JournalHandle, episode_id: string, repository_id?: string): JournalEvent[] {
1119
+ const ns = repository_id ?? h.location.repository_id;
1120
+ const rows = h.db
1121
+ .prepare('SELECT * FROM journal_event WHERE repository_id = ? AND episode_id = ? ORDER BY seq')
1122
+ .all(ns, episode_id) as Record<string, unknown>[];
1123
+ return rows.map(toEvent);
1124
+ }
1125
+
1126
+ /** Every event of one repository_id, in write order. */
1127
+ export function readAllEvents(h: JournalHandle, repository_id?: string): JournalEvent[] {
1128
+ const ns = repository_id ?? h.location.repository_id;
1129
+ const rows = h.db
1130
+ .prepare('SELECT * FROM journal_event WHERE repository_id = ? ORDER BY seq')
1131
+ .all(ns) as Record<string, unknown>[];
1132
+ return rows.map(toEvent);
1133
+ }
1134
+
1135
+ // ---------------------------------------------------------------------------
1136
+ // Episode reconstruction (2.2)
1137
+ // ---------------------------------------------------------------------------
1138
+
1139
+ /**
1140
+ * One episode, rebuilt from the journal alone.
1141
+ *
1142
+ * Every nullable field is either non-null or named in {@link
1143
+ * EpisodeReconstruction.absent}. A reader can therefore tell "not recorded"
1144
+ * from "recorded as nothing", which is the distinction a reconstruction with
1145
+ * in-memory help never has to make and one without it must.
1146
+ */
1147
+ export interface EpisodeReconstruction {
1148
+ repository_id: string;
1149
+ episode_id: string;
1150
+ boundary_status: BoundaryStatus;
1151
+ boundary_rule_version: number;
1152
+ session_ids: string[];
1153
+ /**
1154
+ * Every checkout that contributed an event, in first-seen order. An episode
1155
+ * spanning two worktrees lists both — the attribution a single repository
1156
+ * id cannot give, surfaced where a reconstruction reader needs it.
1157
+ */
1158
+ worktree_ids: string[];
1159
+ task_id: string | null;
1160
+ prompt_id: string | null;
1161
+ opened_at: string;
1162
+ opened_by: { seq: number; event: string; capability: string };
1163
+ closed_at: string | null;
1164
+ terminal_state: TerminalState | null;
1165
+ return_ref: string | null;
1166
+ verification_ref: string | null;
1167
+ consumption: Consumption | null;
1168
+ capabilities: string[];
1169
+ /**
1170
+ * How many rows in this episode were superseded by a later amendment.
1171
+ *
1172
+ * Reported rather than hidden: a projection that silently differs from the
1173
+ * raw rows is the shape a reader cannot audit, and this number is how they
1174
+ * tell "the store says X" from "the store's latest word is X".
1175
+ */
1176
+ amendment_count: number;
1177
+ events: JournalEvent[];
1178
+ retain_until: string;
1179
+ /** Names of the nullable fields that are genuinely absent. */
1180
+ absent: string[];
1181
+ }
1182
+
1183
+ /** The nullable fields of a reconstruction, so a test can enumerate them. */
1184
+ export const RECONSTRUCTION_NULLABLE_FIELDS = [
1185
+ 'task_id',
1186
+ 'prompt_id',
1187
+ 'closed_at',
1188
+ 'terminal_state',
1189
+ 'return_ref',
1190
+ 'verification_ref',
1191
+ 'consumption',
1192
+ ] as const;
1193
+
1194
+ /**
1195
+ * Rebuild one episode from stored records, with no in-memory state.
1196
+ *
1197
+ * Returns `null` when the episode has no records at all — an absent episode is
1198
+ * reported as absent, never as an empty success.
1199
+ */
1200
+ export function reconstructEpisode(
1201
+ h: JournalHandle,
1202
+ episode_id: string,
1203
+ repository_id?: string,
1204
+ ): EpisodeReconstruction | null {
1205
+ const events = readEpisodeEvents(h, episode_id, repository_id);
1206
+ if (events.length === 0) return null;
1207
+ const first = events[0]!;
1208
+
1209
+ // Amendment folding. `events` is returned WHOLE and untouched -- every
1210
+ // original row is still there, byte-identical, which is the property the
1211
+ // append-only store exists to guarantee. What the projection does is
1212
+ // decide which rows are still EFFECTIVE.
1213
+ const amended = new Set<number>();
1214
+ for (const e of events) {
1215
+ if (e.amends_seq !== null) amended.add(e.amends_seq);
1216
+ }
1217
+ const effective = events.filter((e) => !amended.has(e.seq));
1218
+
1219
+ // LAST terminal state, not the first. This is the line the amendment path
1220
+ // exists for: an outcome that arrives after the episode closed is the whole
1221
+ // point, and `find` would have kept returning the superseded verdict
1222
+ // forever while the amendment sat in the table unread.
1223
+ let closing: JournalEvent | null = null;
1224
+ for (let i = effective.length - 1; i >= 0; i -= 1) {
1225
+ if (effective[i]!.terminal_state !== null) {
1226
+ closing = effective[i]!;
1227
+ break;
1228
+ }
1229
+ }
1230
+
1231
+ const latest = <K extends keyof JournalEvent>(k: K): JournalEvent[K] | null => {
1232
+ for (let i = effective.length - 1; i >= 0; i -= 1) {
1233
+ const v = effective[i]![k];
1234
+ if (v !== null && v !== undefined) return v;
1235
+ }
1236
+ return null;
1237
+ };
1238
+
1239
+ const out: EpisodeReconstruction = {
1240
+ repository_id: first.repository_id,
1241
+ episode_id: first.episode_id,
1242
+ boundary_status: first.boundary_status,
1243
+ boundary_rule_version: first.boundary_rule_version,
1244
+ session_ids: [...new Set(events.map((e) => e.session_id))],
1245
+ worktree_ids: [...new Set(events.map((e) => e.worktree_id))],
1246
+ task_id: (latest('task_id') as string | null) ?? null,
1247
+ prompt_id: (latest('prompt_id') as string | null) ?? null,
1248
+ opened_at: first.at,
1249
+ opened_by: { seq: first.seq, event: first.event, capability: first.capability },
1250
+ closed_at: closing?.at ?? null,
1251
+ terminal_state: closing?.terminal_state ?? null,
1252
+ return_ref: (latest('return_ref') as string | null) ?? null,
1253
+ verification_ref: (latest('verification_ref') as string | null) ?? null,
1254
+ consumption: (latest('consumption') as Consumption | null) ?? null,
1255
+ capabilities: [...new Set(events.map((e) => e.capability))],
1256
+ /** How many rows in this episode were superseded by an amendment. */
1257
+ amendment_count: amended.size,
1258
+ events,
1259
+ retain_until: events[events.length - 1]!.retain_until,
1260
+ absent: [],
1261
+ };
1262
+ out.absent = RECONSTRUCTION_NULLABLE_FIELDS.filter((k) => out[k] === null);
1263
+ return out;
1264
+ }
1265
+
1266
+ // ---------------------------------------------------------------------------
1267
+ // Retention: holds, anchors, pruning
1268
+ // ---------------------------------------------------------------------------
1269
+
1270
+ export interface CreateHoldInput {
1271
+ scope: HoldScope;
1272
+ scope_id: string;
1273
+ /** MUST NOT name an agent. See the module docstring's honest limit. */
1274
+ created_by: string;
1275
+ reason: string;
1276
+ /** ISO instant; defaults to `at` + {@link HOLD_DEFAULT_DAYS}. */
1277
+ retain_until?: string;
1278
+ at?: string;
1279
+ }
1280
+
1281
+ function insertRetention(h: JournalHandle, rec: Omit<RetentionRecord, 'seq'>): RetentionRecord {
1282
+ h.db
1283
+ .prepare(
1284
+ `INSERT INTO retention_record
1285
+ (kind, scope, scope_id, repository_id, created_at, created_by, reason, retain_until)
1286
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1287
+ )
1288
+ .run(
1289
+ rec.kind,
1290
+ rec.scope,
1291
+ rec.scope_id,
1292
+ rec.repository_id,
1293
+ rec.created_at,
1294
+ rec.created_by,
1295
+ rec.reason,
1296
+ rec.retain_until,
1297
+ );
1298
+ const seqRow = h.db.prepare('SELECT last_insert_rowid() AS seq').get() as { seq: number | bigint };
1299
+ return { seq: Number(seqRow.seq), ...rec };
1300
+ }
1301
+
1302
+ /**
1303
+ * Create a hold. Human callers only, and a mis-derived boundary widens.
1304
+ *
1305
+ * Returns one record per hold written — an episode-only hold on a `derived` or
1306
+ * `session_fallback` episode widens to its containing session(s), so an episode
1307
+ * spanning two sessions produces two records rather than one hold that silently
1308
+ * covers the wrong set.
1309
+ */
1310
+ export function createHold(h: JournalHandle, input: CreateHoldInput): RetentionRecord[] {
1311
+ if (isAgentActor(input.created_by)) {
1312
+ throw new JournalContractError(
1313
+ `created_by ${JSON.stringify(input.created_by)} names an agent. Only a human may set a ` +
1314
+ 'retention hold: an agent that can hold its own episodes can defeat retention, and it ' +
1315
+ 'need not do so deliberately. (This is a refusal at the API surface, not an ' +
1316
+ 'authorization boundary — see the module docstring.)',
1317
+ );
1318
+ }
1319
+ const reason = input.reason.trim();
1320
+ if (reason === '') {
1321
+ throw new JournalContractError('a hold requires a reason — a hold nobody explained is unreviewable.');
1322
+ }
1323
+ if (!(HOLD_SCOPES as readonly string[]).includes(input.scope)) {
1324
+ throw new JournalContractError(`unknown hold scope ${JSON.stringify(input.scope)}.`);
1325
+ }
1326
+ const created_at = input.at ?? new Date().toISOString();
1327
+ const retain_until =
1328
+ input.retain_until ?? new Date(Date.parse(created_at) + HOLD_DEFAULT_DAYS * DAY_MS).toISOString();
1329
+ if (Date.parse(retain_until) <= Date.parse(created_at)) {
1330
+ throw new JournalContractError('retain_until must be after created_at — holds expire too.');
1331
+ }
1332
+ const ns = h.location.repository_id;
1333
+ const base = {
1334
+ kind: 'retention_hold' as const,
1335
+ repository_id: ns,
1336
+ created_at,
1337
+ created_by: input.created_by.trim(),
1338
+ reason: reason.slice(0, 500),
1339
+ retain_until,
1340
+ };
1341
+
1342
+ if (input.scope === 'session') {
1343
+ return [insertRetention(h, { ...base, scope: 'session', scope_id: input.scope_id })];
1344
+ }
1345
+
1346
+ const events = readEpisodeEvents(h, input.scope_id, ns);
1347
+ if (events.length === 0) {
1348
+ throw new JournalContractError(
1349
+ `no records for episode ${JSON.stringify(input.scope_id)} — refusing to hold an episode ` +
1350
+ 'that does not exist, because its boundary provenance cannot be checked.',
1351
+ );
1352
+ }
1353
+ if (events[0]!.boundary_status === 'explicit') {
1354
+ return [insertRetention(h, { ...base, scope: 'episode', scope_id: input.scope_id })];
1355
+ }
1356
+ // Widen. Council 2026-08-28: a hold makes a mis-derived boundary durable,
1357
+ // so a boundary the rule DERIVED never carries an episode-only hold.
1358
+ const sessions = [...new Set(events.map((e) => e.session_id))];
1359
+ const widened =
1360
+ `${base.reason} [widened from episode ${input.scope_id}: ` +
1361
+ `boundary_status=${events[0]!.boundary_status}]`;
1362
+ return sessions.map((s) =>
1363
+ insertRetention(h, { ...base, reason: widened.slice(0, 500), scope: 'session', scope_id: s }),
1364
+ );
1365
+ }
1366
+
1367
+ /** Release a hold — an append-only counter-record, never a delete. */
1368
+ export function releaseHold(
1369
+ h: JournalHandle,
1370
+ input: { scope: HoldScope; scope_id: string; created_by: string; reason: string; at?: string },
1371
+ ): RetentionRecord {
1372
+ if (isAgentActor(input.created_by)) {
1373
+ throw new JournalContractError(
1374
+ `created_by ${JSON.stringify(input.created_by)} names an agent — releases are human-only too.`,
1375
+ );
1376
+ }
1377
+ const created_at = input.at ?? new Date().toISOString();
1378
+ return insertRetention(h, {
1379
+ kind: 'retention_release',
1380
+ scope: input.scope,
1381
+ scope_id: input.scope_id,
1382
+ repository_id: h.location.repository_id,
1383
+ created_at,
1384
+ created_by: input.created_by.trim(),
1385
+ reason: input.reason.trim().slice(0, 500) || 'released',
1386
+ retain_until: created_at,
1387
+ });
1388
+ }
1389
+
1390
+ /** Every retention record for one scope id, in write order. */
1391
+ export function readRetention(h: JournalHandle, scope: HoldScope, scope_id: string): RetentionRecord[] {
1392
+ const rows = h.db
1393
+ .prepare('SELECT * FROM retention_record WHERE repository_id = ? AND scope = ? AND scope_id = ? ORDER BY seq')
1394
+ .all(h.location.repository_id, scope, scope_id) as Record<string, unknown>[];
1395
+ return rows.map((r) => ({
1396
+ seq: Number(r['seq']),
1397
+ kind: String(r['kind']) as RetentionKind,
1398
+ scope: String(r['scope']) as HoldScope,
1399
+ scope_id: String(r['scope_id']),
1400
+ repository_id: String(r['repository_id']),
1401
+ created_at: String(r['created_at']),
1402
+ created_by: String(r['created_by']),
1403
+ reason: String(r['reason']),
1404
+ retain_until: String(r['retain_until']),
1405
+ }));
1406
+ }
1407
+
1408
+ /** True when an unreleased, unexpired hold covers this scope id at `now`. */
1409
+ export function isHeld(h: JournalHandle, scope: HoldScope, scope_id: string, now: string): boolean {
1410
+ const t = Date.parse(now);
1411
+ let held = false;
1412
+ for (const r of readRetention(h, scope, scope_id)) {
1413
+ held = r.kind === 'retention_hold' ? Date.parse(r.retain_until) > t : false;
1414
+ }
1415
+ return held;
1416
+ }
1417
+
1418
+ export type AnchorVia = 'episode_closed_at' | 'session_end' | 'inactivity' | 'active';
1419
+
1420
+ export interface EpisodeAnchor {
1421
+ readonly via: AnchorVia;
1422
+ /** Null when the episode is still live and therefore has no expiry yet. */
1423
+ readonly anchor: string | null;
1424
+ }
1425
+
1426
+ /**
1427
+ * Resolve the retention anchor for one episode — council constraint 1's ladder.
1428
+ *
1429
+ * `episode_closed_at` (the first event carrying a terminal state) -> the
1430
+ * episode's `session_end` event -> the last event, but only once it is past
1431
+ * {@link INACTIVITY_ANCHOR_MS}. A live episode has no anchor, and therefore no
1432
+ * expiry: a TTL anchored on WRITE rather than close would expire an episode
1433
+ * that is still running, which is the failure the ladder exists to prevent.
1434
+ *
1435
+ * `retain_until` on the record is the provisional per-record floor written at
1436
+ * insert time, when the anchor is by definition not yet known. This function is
1437
+ * the authoritative expiry and {@link pruneExpired} uses it.
1438
+ */
1439
+ export function episodeAnchor(h: JournalHandle, episode_id: string, now: string): EpisodeAnchor {
1440
+ const events = readEpisodeEvents(h, episode_id);
1441
+ if (events.length === 0) return { via: 'active', anchor: null };
1442
+ const closed = events.find((e) => e.terminal_state !== null);
1443
+ if (closed !== undefined) return { via: 'episode_closed_at', anchor: closed.at };
1444
+ const ended = events.find((e) => e.event === 'session_end');
1445
+ if (ended !== undefined) return { via: 'session_end', anchor: ended.at };
1446
+ const last = events[events.length - 1]!;
1447
+ if (Date.parse(now) - Date.parse(last.at) >= INACTIVITY_ANCHOR_MS) {
1448
+ return { via: 'inactivity', anchor: last.at };
1449
+ }
1450
+ return { via: 'active', anchor: null };
1451
+ }
1452
+
1453
+ /** The effective expiry for one episode, or null while it is live. */
1454
+ export function effectiveExpiry(h: JournalHandle, episode_id: string, now: string): string | null {
1455
+ const { anchor } = episodeAnchor(h, episode_id, now);
1456
+ if (anchor === null) return null;
1457
+ return new Date(Date.parse(anchor) + RETENTION_TTL_DAYS * DAY_MS).toISOString();
1458
+ }
1459
+
1460
+ export interface PruneReport {
1461
+ readonly examined: number;
1462
+ readonly deleted_episodes: string[];
1463
+ readonly deleted_events: number;
1464
+ readonly held_episodes: string[];
1465
+ readonly live_episodes: string[];
1466
+ }
1467
+
1468
+ /**
1469
+ * Delete every episode past its effective expiry and not covered by a hold.
1470
+ *
1471
+ * Reports what it kept and why, so a reader can tell "retained on purpose" from
1472
+ * "not yet expired" without re-deriving the ladder.
1473
+ */
1474
+ export function pruneExpired(h: JournalHandle, now: string = new Date().toISOString()): PruneReport {
1475
+ const ns = h.location.repository_id;
1476
+ const rows = h.db
1477
+ .prepare('SELECT DISTINCT episode_id, session_id FROM journal_event WHERE repository_id = ?')
1478
+ .all(ns) as { episode_id: string; session_id: string }[];
1479
+ const deleted_episodes: string[] = [];
1480
+ const held_episodes: string[] = [];
1481
+ const live_episodes: string[] = [];
1482
+ let deleted_events = 0;
1483
+ for (const { episode_id, session_id } of rows) {
1484
+ const expiry = effectiveExpiry(h, episode_id, now);
1485
+ if (expiry === null || Date.parse(expiry) > Date.parse(now)) {
1486
+ live_episodes.push(episode_id);
1487
+ continue;
1488
+ }
1489
+ if (isHeld(h, 'episode', episode_id, now) || isHeld(h, 'session', session_id, now)) {
1490
+ held_episodes.push(episode_id);
1491
+ continue;
1492
+ }
1493
+ const before = readEpisodeEvents(h, episode_id).length;
1494
+ h.db.prepare('DELETE FROM journal_event WHERE repository_id = ? AND episode_id = ?').run(ns, episode_id);
1495
+ deleted_events += before;
1496
+ deleted_episodes.push(episode_id);
1497
+ }
1498
+ return { examined: rows.length, deleted_episodes, deleted_events, held_episodes, live_episodes };
1499
+ }