@event4u/agent-config 14.6.0 → 14.8.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 (258) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +255 -145
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze/inbox.md +264 -10
  5. package/dist/agent-src/commands/analyze.md +1 -1
  6. package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
  7. package/dist/agent-src/commands/evals/roadmap.json +17 -1
  8. package/dist/agent-src/commands/pr/create.md +14 -2
  9. package/dist/agent-src/commands/pr/merge.md +379 -0
  10. package/dist/agent-src/commands/roadmap/process-full.md +272 -32
  11. package/dist/agent-src/commands/roadmap.md +1 -1
  12. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
  13. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
  14. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +95 -2
  15. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +289 -106
  16. package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
  17. package/dist/agent-src/rules/context-hygiene.md +2 -0
  18. package/dist/agent-src/rules/decision-revisit-gate.md +67 -32
  19. package/dist/agent-src/rules/fix-what-you-see.md +108 -0
  20. package/dist/agent-src/rules/no-roadmap-references.md +0 -5
  21. package/dist/agent-src/rules/recurring-criticism.md +110 -0
  22. package/dist/agent-src/rules/roadmap-progress-sync.md +20 -1
  23. package/dist/agent-src/rules/rule-type-governance.md +0 -2
  24. package/dist/agent-src/rules/self-repair-loop.md +17 -0
  25. package/dist/agent-src/rules/skill-quality.md +0 -2
  26. package/dist/agent-src/rules/source-confidentiality.md +37 -7
  27. package/dist/agent-src/scripts/archival_sweep.ts +117 -0
  28. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
  29. package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
  30. package/dist/agent-src/scripts/gate_budget.ts +282 -0
  31. package/dist/agent-src/scripts/gate_execute.ts +72 -31
  32. package/dist/agent-src/scripts/roadmap_gates.ts +74 -3
  33. package/dist/agent-src/scripts/update_roadmap_progress.ts +296 -53
  34. package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
  35. package/dist/agent-src/skills/ai-council/SKILL.md +48 -807
  36. package/dist/agent-src/skills/ai-council/references/advanced-modes.md +320 -0
  37. package/dist/agent-src/skills/ai-council/references/cost-and-redaction.md +99 -0
  38. package/dist/agent-src/skills/ai-council/references/execution-modes.md +162 -0
  39. package/dist/agent-src/skills/ai-council/references/output-and-synthesis.md +125 -0
  40. package/dist/agent-src/skills/ai-council/references/procedure.md +143 -0
  41. package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
  42. package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
  43. package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
  44. package/dist/agent-src/skills/quality-tools/SKILL.md +34 -326
  45. package/dist/agent-src/skills/quality-tools/references/js-ts-tools.md +113 -0
  46. package/dist/agent-src/skills/quality-tools/references/php-tools.md +222 -0
  47. package/dist/agent-src/skills/roadmap-management/SKILL.md +15 -409
  48. package/dist/agent-src/skills/roadmap-management/references/archival.md +226 -0
  49. package/dist/agent-src/skills/roadmap-management/references/authoring.md +107 -0
  50. package/dist/agent-src/skills/roadmap-management/references/dashboard.md +47 -0
  51. package/dist/agent-src/skills/roadmap-management/references/lifecycle.md +58 -0
  52. package/dist/agent-src/skills/security/SKILL.md +8 -0
  53. package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
  54. package/dist/agent-src/skills/skill-writing/SKILL.md +34 -570
  55. package/dist/agent-src/skills/skill-writing/references/procedure.md +275 -0
  56. package/dist/agent-src/skills/skill-writing/references/read-only-scripts.md +18 -0
  57. package/dist/agent-src/skills/skill-writing/references/section-patterns.md +298 -0
  58. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  59. package/dist/agent-src/templates/roadmaps.md +65 -4
  60. package/dist/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  61. package/dist/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  62. package/dist/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  63. package/dist/cli/registry.js +2 -1
  64. package/dist/cli/registry.js.map +1 -1
  65. package/dist/cli-delegate/chunk-2GNWGMAL.js +172 -0
  66. package/dist/cli-delegate/{chunk-P6LQDIWK.js → chunk-7PRE6ZV2.js} +338 -0
  67. package/dist/cli-delegate/{chunk-BKO2AZ7Z.js → chunk-B2ERU2VA.js} +1 -1
  68. package/dist/cli-delegate/chunk-E4XIZDIB.js +402 -0
  69. package/dist/cli-delegate/{chunk-3UCJWMMP.js → chunk-EEJUMCDZ.js} +16 -16
  70. package/dist/cli-delegate/{chunk-NXFWFAYV.js → chunk-G23AT6GD.js} +1 -0
  71. package/dist/cli-delegate/{chunk-XURJ4AQC.js → chunk-H4VIVD4C.js} +385 -284
  72. package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
  73. package/dist/cli-delegate/{chunk-RLN73PR5.js → chunk-LPPWO5CE.js} +12 -8
  74. package/dist/cli-delegate/chunk-MWRD5YOH.js +100 -0
  75. package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-N7CDDGI3.js} +4 -23
  76. package/dist/cli-delegate/{chunk-63WSP6YZ.js → chunk-PMEKGDED.js} +74 -167
  77. package/dist/cli-delegate/{chunk-GRRPEPRN.js → chunk-T7L2QK3K.js} +24 -5
  78. package/dist/cli-delegate/{chunk-EVTY7ISR.js → chunk-YM4GZDJG.js} +9 -1
  79. package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
  80. package/dist/cli-delegate/cmd_conformance.js +466 -716
  81. package/dist/cli-delegate/cmd_converge.js +3 -3
  82. package/dist/cli-delegate/cmd_doctor.js +10 -10
  83. package/dist/cli-delegate/cmd_explain.js +1 -1
  84. package/dist/cli-delegate/cmd_export.js +10 -9
  85. package/dist/cli-delegate/cmd_handoff.js +10 -9
  86. package/dist/cli-delegate/cmd_memory_get.js +2 -1
  87. package/dist/cli-delegate/cmd_migrate.js +2 -2
  88. package/dist/cli-delegate/cmd_packs_active.js +1 -1
  89. package/dist/cli-delegate/cmd_preflight.js +5 -5
  90. package/dist/cli-delegate/cmd_prune.js +11 -10
  91. package/dist/cli-delegate/cmd_route_audit.js +6 -5
  92. package/dist/cli-delegate/cmd_route_explain.js +3 -3
  93. package/dist/cli-delegate/cmd_session_recycle.js +840 -30
  94. package/dist/cli-delegate/cmd_settings_get.js +3 -3
  95. package/dist/cli-delegate/cmd_settings_migrate.js +10 -9
  96. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  97. package/dist/cli-delegate/cmd_sync.js +11 -10
  98. package/dist/cli-delegate/cmd_uninstall.js +13 -11
  99. package/dist/cli-delegate/cmd_update.js +4 -4
  100. package/dist/cli-delegate/cmd_upgrade.js +4 -4
  101. package/dist/cli-delegate/cmd_validate.js +11 -10
  102. package/dist/cli-delegate/cmd_versions.js +3 -3
  103. package/dist/discovery/deprecation-report.md +1 -1
  104. package/dist/discovery/discovery-manifest.json +111 -37
  105. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  106. package/dist/discovery/discovery-manifest.summary.md +5 -5
  107. package/dist/discovery/dormancy-report.md +1 -1
  108. package/dist/discovery/orphan-report.md +1 -1
  109. package/dist/discovery/packs.json +15 -12
  110. package/dist/discovery/trust-report.md +3 -3
  111. package/dist/discovery/workspaces.json +11 -8
  112. package/dist/hooks/dispatch.js +3165 -2192
  113. package/dist/install/claudePathsPlan.js +187 -0
  114. package/dist/install/claudePathsPlan.js.map +1 -0
  115. package/dist/install/globalRuleLayers.js +169 -0
  116. package/dist/install/globalRuleLayers.js.map +1 -0
  117. package/dist/install/hostLayerFingerprint.js +136 -0
  118. package/dist/install/hostLayerFingerprint.js.map +1 -0
  119. package/dist/install/install.mjs +553 -425
  120. package/dist/install/partitionEligibility.js +432 -0
  121. package/dist/install/partitionEligibility.js.map +1 -0
  122. package/dist/install/ruleLayerPartition.js +157 -0
  123. package/dist/install/ruleLayerPartition.js.map +1 -0
  124. package/dist/mcp/registry-manifest.json +3 -3
  125. package/dist/mcp/server.json +2 -2
  126. package/dist/mcp/server.mjs +1863 -1234
  127. package/dist/router.json +1 -1
  128. package/dist/scripts/_lib/agent_src.js +41 -0
  129. package/dist/scripts/_lib/agent_src.js.map +1 -1
  130. package/dist/scripts/_lib/install_layout.js +83 -0
  131. package/dist/scripts/_lib/install_layout.js.map +1 -0
  132. package/dist/scripts/_lib/installed_lock.js +444 -0
  133. package/dist/scripts/_lib/installed_lock.js.map +1 -0
  134. package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
  135. package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
  136. package/dist/scripts/condense.js +63 -251
  137. package/dist/scripts/condense.js.map +1 -1
  138. package/dist/server/schemas/settings.js +4 -0
  139. package/dist/server/schemas/settings.js.map +1 -1
  140. package/docs/contracts/settings-classes.md +12 -6
  141. package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +8 -3
  142. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
  143. package/docs/guidelines/agent-infra/context-hygiene-mechanics.md +26 -0
  144. package/docs/guidelines/agent-infra/false-green.md +47 -1
  145. package/docs/guidelines/agent-infra/memory-access.md +17 -0
  146. package/docs/guidelines/agent-infra/recurring-criticism-mechanics.md +96 -0
  147. package/package.json +1 -1
  148. package/src/agent-src/scripts/archival_sweep.ts +117 -0
  149. package/src/agent-src/scripts/archive_completed_roadmaps.ts +26 -7
  150. package/src/agent-src/scripts/dashboard_mode.ts +212 -0
  151. package/src/agent-src/scripts/gate_budget.ts +282 -0
  152. package/src/agent-src/scripts/gate_execute.ts +72 -31
  153. package/src/agent-src/scripts/roadmap_gates.ts +74 -3
  154. package/src/agent-src/scripts/update_roadmap_progress.ts +296 -53
  155. package/src/agent-src/templates/scripts/telemetry/flush_sender.mjs +163 -0
  156. package/src/agent-src/templates/scripts/telemetry/remote.ts +160 -7
  157. package/src/agent-src/templates/scripts/telemetry/transport.ts +169 -0
  158. package/src/config/agent-settings.template.yml +27 -2
  159. package/src/config/ci-local-parity.yml +39 -0
  160. package/src/config/estate-count-budget.json +612 -3
  161. package/src/config/evaluator-budgets.json +3 -3
  162. package/src/config/gate-coverage.yml +220 -12
  163. package/src/config/gate-violation-baselines.json +73 -63
  164. package/src/config/gitignore-block.txt +15 -2
  165. package/src/config/hook-latency-budget.json +5 -0
  166. package/src/config/rule-activation-census.json +14 -6
  167. package/src/config/rule-stub-ceilings.json +34 -7
  168. package/src/install/claudePathsPlan.ts +196 -0
  169. package/src/install/globalRuleLayers.ts +197 -0
  170. package/src/install/hostLayerFingerprint.ts +145 -0
  171. package/src/install/partitionEligibility.ts +502 -0
  172. package/src/install/ruleLayerPartition.ts +212 -0
  173. package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
  174. package/src/scripts/_cli/cmd_session_recycle.ts +9 -1
  175. package/src/scripts/_dispatch.bash +17 -1
  176. package/src/scripts/_lib/ac_heading.ts +30 -0
  177. package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
  178. package/src/scripts/_lib/adr_frontmatter.ts +459 -0
  179. package/src/scripts/_lib/agent_src.ts +42 -0
  180. package/src/scripts/_lib/bench_ab_pinned_repo.ts +162 -0
  181. package/src/scripts/_lib/bench_ab_workspace.ts +10 -1
  182. package/src/scripts/_lib/envelope_grounding.ts +49 -9
  183. package/src/scripts/_lib/installed_lock.ts +50 -5
  184. package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
  185. package/src/scripts/_lib/md_section.ts +129 -0
  186. package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
  187. package/src/scripts/_lib/scope_guard.sh +26 -3
  188. package/src/scripts/_lib/self_repair.ts +19 -0
  189. package/src/scripts/_lib/self_repair_class_b.ts +171 -0
  190. package/src/scripts/adr/evidence_census.ts +899 -0
  191. package/src/scripts/adr/regenerate_index.ts +12 -32
  192. package/src/scripts/adr_cite_check.ts +540 -39
  193. package/src/scripts/ai_council/cli_help.ts +55 -0
  194. package/src/scripts/audit_adr_coverage.ts +103 -17
  195. package/src/scripts/before_complete_hook.ts +153 -28
  196. package/src/scripts/bench_ab_v2_complexity.ts +27 -7
  197. package/src/scripts/bench_hook_latency.ts +204 -0
  198. package/src/scripts/build_archive_index.ts +34 -0
  199. package/src/scripts/check_adr_frontmatter.ts +574 -39
  200. package/src/scripts/check_hook_bundle_content.ts +219 -0
  201. package/src/scripts/check_new_adr_evidence.ts +529 -0
  202. package/src/scripts/check_references.ts +78 -0
  203. package/src/scripts/check_release_published.ts +32 -11
  204. package/src/scripts/check_rule_layer_partition.ts +531 -0
  205. package/src/scripts/check_single_delivery.ts +194 -17
  206. package/src/scripts/check_standing_rule_delivery.ts +8 -2
  207. package/src/scripts/ci_settle.ts +170 -0
  208. package/src/scripts/condense.ts +79 -259
  209. package/src/scripts/council_cli.ts +3 -3
  210. package/src/scripts/dispatch_r2_reviewer.ts +6 -14
  211. package/src/scripts/external_sources_denylist.json +4 -1
  212. package/src/scripts/hook_manifest.json +1 -1
  213. package/src/scripts/hook_manifest.yaml +77 -14
  214. package/src/scripts/hooks/block_no_verify.ts +61 -10
  215. package/src/scripts/hooks/block_unauthorized_git.ts +21 -1
  216. package/src/scripts/hooks/concern_registry.ts +4 -0
  217. package/src/scripts/hooks/dispatch_hook.ts +154 -187
  218. package/src/scripts/hooks/dispatch_issues.ts +38 -19
  219. package/src/scripts/hooks/fallback_yaml.ts +66 -0
  220. package/src/scripts/hooks/host_semantics.ts +21 -0
  221. package/src/scripts/hooks/injection_budget.ts +13 -3
  222. package/src/scripts/hooks/py_json_dumps.ts +103 -0
  223. package/src/scripts/hooks/rtk_wrap_hook.ts +8 -3
  224. package/src/scripts/hooks/run_continuation_hook.ts +8 -5
  225. package/src/scripts/hooks/ship_diff_volume_hook.ts +81 -15
  226. package/src/scripts/hooks/source_first_gate_hook.ts +464 -0
  227. package/src/scripts/hooks/state_io.ts +870 -21
  228. package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
  229. package/src/scripts/hooks/subagent_ledger_hook.ts +44 -15
  230. package/src/scripts/hooks/telemetry_flush_hook.ts +139 -0
  231. package/src/scripts/hooks/telemetry_self_repair.ts +128 -0
  232. package/src/scripts/hooks/telemetry_usage_hook.ts +22 -4
  233. package/src/scripts/hooks/turn_end_gate_hook.ts +81 -15
  234. package/src/scripts/injection_scan_hook.ts +137 -5
  235. package/src/scripts/install.ts +16 -11
  236. package/src/scripts/language_mirror_hook.ts +561 -25
  237. package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
  238. package/src/scripts/lint_hook_manifest.ts +49 -0
  239. package/src/scripts/lint_plan_risk_register.ts +4 -3
  240. package/src/scripts/lint_provenance_vocabulary.ts +472 -4
  241. package/src/scripts/lint_skill_router_head.ts +4 -4
  242. package/src/scripts/mcp_server/tools.ts +2 -2
  243. package/src/scripts/new_roadmap.ts +136 -0
  244. package/src/scripts/pack_mcp_content.ts +20 -5
  245. package/src/scripts/release.ts +37 -38
  246. package/src/scripts/report_layer_overlap.ts +6 -0
  247. package/src/scripts/routing_doctor.ts +5 -1
  248. package/src/scripts/self_repair_hook.ts +37 -5
  249. package/src/scripts/session_register_hook.ts +11 -0
  250. package/src/scripts/skill_usage_collect.ts +11 -1
  251. package/src/scripts/skill_usage_report.ts +278 -8
  252. package/src/scripts/sync_pr_branch.ts +170 -20
  253. package/src/server/schemas/settings.ts +8 -0
  254. package/dist/cli-delegate/chunk-EJITJBJX.js +0 -172
  255. package/dist/cli-delegate/chunk-PZWRPRWP.js +0 -38
  256. package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
  257. package/dist/cli-delegate/{chunk-RYNADUFG.js → chunk-LWTYTX6F.js} +0 -0
  258. package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-MMSGVZKV.js} +3 -3
@@ -33,6 +33,7 @@
33
33
  * locks, writes, releases, closes. No long-lived file handles.
34
34
  */
35
35
 
36
+ import { createHash } from "node:crypto";
36
37
  import fs from "node:fs";
37
38
  import path from "node:path";
38
39
 
@@ -42,6 +43,14 @@ export const REPLAY_ENV_VAR = "AGENT_CONFIG_REPLAY";
42
43
 
43
44
  export const FEEDBACK_DIRNAME = ".dispatcher";
44
45
 
46
+ /**
47
+ * Outcome of one `update_json_under_lock` call.
48
+ *
49
+ * A string union rather than an enum so it survives the JSON/CLI boundaries this
50
+ * tree crosses, and so a `switch` over it is exhaustively checked.
51
+ */
52
+ export type LockedUpdateResult = "written" | "skipped" | "failed";
53
+
45
54
  /**
46
55
  * True when the caller signalled read-only fixture replay.
47
56
  *
@@ -57,6 +66,62 @@ function _lock_path(state_dir: string): string {
57
66
  return path.join(state_dir, LOCK_BASENAME);
58
67
  }
59
68
 
69
+ /**
70
+ * Lock path for ONE state file, used only by `update_json_under_lock`.
71
+ *
72
+ * ## Why this exists, and why it is narrow
73
+ *
74
+ * `_lock_path` above is DIRECTORY-keyed and always was — that part is not new,
75
+ * and `docs/contracts/hook-architecture-v1.md` § Concurrency mandates it with a
76
+ * stated rationale: "serialising state writes across concerns is cheaper than
77
+ * per-file locks, and concerns already run sequentially within one dispatcher
78
+ * invocation." That rationale is about CONCERNS inside one invocation, and it
79
+ * still holds for them.
80
+ *
81
+ * What changed is what the directory holds. Before the per-session split it
82
+ * held one file, so a directory lock WAS a file lock. After it, the directory
83
+ * holds N per-session files — and concurrent SESSIONS are not "concerns within
84
+ * one dispatcher invocation", so the contract's rationale does not reach them.
85
+ * A directory lock there re-serialises exactly the sessions the split exists to
86
+ * decouple.
87
+ *
88
+ * ## Measured, not assumed
89
+ *
90
+ * The prior basis was "probably unmeasurable at millisecond writes", which the
91
+ * roadmap named as a guess and required to be measured before any choice. It
92
+ * was measured (4 and 8 concurrent processes, 60 read-modify-writes each, each
93
+ * to its OWN per-session file, macOS/APFS):
94
+ *
95
+ * - 4 workers: slowest worker 68 ms with the shared directory lock vs 27 ms
96
+ * with no shared lock — ~1.1 ms vs ~0.45 ms per write.
97
+ * - 8 workers: slowest worker 138–267 ms vs 83–95 ms across runs.
98
+ *
99
+ * So the guess was wrong in direction (it IS measurable, and it grows with the
100
+ * number of concurrent sessions) and roughly right in magnitude (sub-millisecond
101
+ * to a few milliseconds per write). The decisive reading is not the absolute
102
+ * number: writes to DISTINCT files under the shared directory lock came out at
103
+ * or above writes to the SAME file, i.e. the directory lock was paying the full
104
+ * cost of mutual exclusion for writes that require none.
105
+ *
106
+ * ## Scope of the change
107
+ *
108
+ * Only `update_json_under_lock` uses this. `_atomic_write_text` — the path the
109
+ * contract describes, the one concerns share — keeps the directory lock
110
+ * untouched. Two sessions writing different per-session files now take
111
+ * different locks and do not block each other; two writers to the SAME file
112
+ * still take the same lock, so the four-process mutual-exclusion test is
113
+ * unaffected.
114
+ *
115
+ * The sentinel and its companion sit next to the state file as
116
+ * `<digest>.json.lock` / `.lock.held`. Neither ends in `.json`, so
117
+ * `prune_stale_session_states` skips both by its existing filter; that pruner
118
+ * now removes them alongside the state file it prunes, so this does not trade a
119
+ * lock for unbounded sentinel growth.
120
+ */
121
+ function _target_lock_path(target_path: string): string {
122
+ return `${target_path}.lock`;
123
+ }
124
+
60
125
  // Python json.dumps(payload, indent=2) byte-for-byte: indent of 2 spaces,
61
126
  // ", " collapses to ",\n<indent>" with separators (",", ": "), and
62
127
  // ensure_ascii=True (default) escapes every non-ASCII codepoint as \uXXXX.
@@ -203,16 +268,153 @@ export function atomic_write_text(target: string, text: string): void {
203
268
  _atomic_write_text(targetPath, text);
204
269
  }
205
270
 
206
- function _acquire_lock(lock_path: string): number {
271
+ /**
272
+ * ── The guarantee this lock rests on, and where it holds ─────────────────────
273
+ *
274
+ * PRIMITIVE: `fs.openSync(path, "wx")` — `O_CREAT | O_EXCL`. Create-or-fail is
275
+ * the exclusion; the loser gets `EEXIST`.
276
+ *
277
+ * PLATFORM AND VERSION the guarantee is claimed on: **local filesystems on the
278
+ * platforms this package supports** — macOS/APFS and Linux/ext4|btrfs|xfs, on
279
+ * Node >= 20.11.0 (`package.json` `engines`). Native Windows is explicitly not
280
+ * a first-class target (`docs/installation.md` § Windows names WSL2 or Git
281
+ * Bash), so no claim is made for it. Verified locally on Darwin 24.6.0 arm64.
282
+ *
283
+ * ## The NFS claim, and why it is not acted on as it was reported
284
+ *
285
+ * A peer reported `openSync(path, "wx")` as "broken on NFS per `open(2)`" and
286
+ * proposed `mkdirSync` instead. That report is not usable as stated, in both
287
+ * directions:
288
+ *
289
+ * - The LOCAL BSD `open(2)` says the opposite. Read on Darwin 24.6.0: "If
290
+ * O_EXCL is set with O_CREAT and the file already exists, open() returns an
291
+ * error. **This may be used to implement a simple exclusive-access locking
292
+ * mechanism.**" That is a recommendation of this exact use.
293
+ * - The warning is real but Linux-specific and VERSION-BOUND, not categorical.
294
+ * The Linux `open(2)` NOTES scope it to old NFS — NFSv2, which lacks an
295
+ * atomic create-exclusive operation, so `O_EXCL` there is emulated and
296
+ * racy. NFSv3 carries an atomic exclusive-create and NFSv4 likewise. So the
297
+ * honest form of the claim is "unreliable on NFSv2", not "broken on NFS".
298
+ *
299
+ * ## Is NFS even in scope? No — and that is why nothing is rewritten
300
+ *
301
+ * This state tree lives INSIDE the project directory
302
+ * (`<project>/agents/runtime/state/`, gitignored per
303
+ * `docs/contracts/agents-layout.md`). Its filesystem is whatever the developer's
304
+ * checkout sits on. A checkout on an NFSv2 mount is not a configuration this
305
+ * package supports, tests, or has ever observed; NFSv2 has been superseded for
306
+ * roughly three decades.
307
+ *
308
+ * So `mkdirSync` is NOT adopted. Both primitives are atomic create-or-fail on
309
+ * every filesystem in scope, so the swap would buy nothing here while trading a
310
+ * primitive the local manpage recommends for one chosen on a mis-stated premise.
311
+ * Note also that `mkdirSync` would not even be a clean win on the premise as
312
+ * given: the same Linux NOTES that qualify `O_EXCL` are about the absence of an
313
+ * atomic create-exclusive RPC, and directory creation over NFSv2 inherits the
314
+ * same retransmission ambiguity.
315
+ *
316
+ * WHAT WOULD CHANGE THIS: an actual report of this state tree on a network
317
+ * mount, or a supported-platform claim that includes one. At that point the
318
+ * fix is not a primitive swap by reputation — it is a measurement on that
319
+ * mount, because the staleness reclaim above (mtime-based) also depends on
320
+ * clock and attribute-cache behaviour that a network filesystem changes.
321
+ */
322
+
323
+ /**
324
+ * How long a companion must be untouched before it counts as ABANDONED.
325
+ *
326
+ * Matches `language_mirror_hook.LOCK_STALE_MS` — a convention match, not a
327
+ * measurement, and stated as such. What matters is that it is much larger than
328
+ * any real critical section here (a read, a mutate and a rename), so a live
329
+ * holder is never mistaken for a dead one.
330
+ */
331
+ export const LOCK_STALE_MS = 30_000;
332
+
333
+ /** How long a BLOCKING acquire waits before giving up. Unchanged value. */
334
+ export const LOCK_ACQUIRE_DEADLINE_MS = 5_000;
335
+
336
+ /** One spin's sleep. Small enough that the deadline is a real bound. */
337
+ const LOCK_SPIN_SLEEP_MS = 2;
338
+
339
+ /** Real sleep without async — `Atomics.wait` on a throwaway buffer. */
340
+ function _sleep(ms: number): void {
341
+ const sab = new Int32Array(new SharedArrayBuffer(4));
342
+ Atomics.wait(sab, 0, 0, ms);
343
+ }
344
+
345
+ /**
346
+ * Take the cross-process lock for `lock_path`, or report that it is held.
347
+ *
348
+ * Node has no `flock`, so exclusion comes from an `O_EXCL` companion
349
+ * (`<lock>.held`) removed on release; the visible sentinel is kept.
350
+ *
351
+ * ## What was wrong before, precisely
352
+ *
353
+ * The reclaim branch was commented `// Stale companion — reclaim it` and
354
+ * examined the companion in NO way: no mtime, no age, no owner. It fired on
355
+ * `Date.now() - start > deadlineMs` — a property of how long THIS caller had
356
+ * waited, not of the companion's health. So it was a patience check wearing the
357
+ * name of a staleness check, and a caller that merely waited long enough
358
+ * deleted a perfectly live peer's lock.
359
+ *
360
+ * Two consequences followed, and the second is the worse one:
361
+ *
362
+ * - `start` was never reset before `continue`, and the deadline test sat
363
+ * BEFORE the sleep. So the first timeout made the sleep permanently
364
+ * unreachable: every later iteration took the reclaim branch and looped
365
+ * with no pause at all. A pauseless spin was the end state of any call that
366
+ * touched the deadline once.
367
+ * - Two callers both past their deadline evicted each other's companion
368
+ * without bound — each one's "reclaim" destroyed the exclusion the other
369
+ * had just acquired, so neither held a lock while both believed they did.
370
+ *
371
+ * ## What holds now
372
+ *
373
+ * The companion's AGE decides reclamation, and nothing else. A fresh companion
374
+ * is never removed, by any caller, at any wait duration — a blocking caller
375
+ * that reaches its deadline reports failure instead of evicting. Reclamation
376
+ * requires `LOCK_STALE_MS` of inactivity, which only an abandoned lock reaches,
377
+ * so mutual eviction is gone rather than merely rarer.
378
+ *
379
+ * Every retry path either sleeps or has just made real progress (the companion
380
+ * vanished, or a genuinely abandoned one was removed), so no path reaches a
381
+ * sleepless retry loop.
382
+ *
383
+ * ## `blocking: false` — the hot-path acquire
384
+ *
385
+ * `post_tool_use` runs on every tool call and must never wait. A non-blocking
386
+ * caller returns on the first `EEXIST` with no spin at all, so it cannot cross
387
+ * a deadline — which makes the reclaim branch UNREACHABLE for it rather than
388
+ * merely rarer. That is why the roadmap treats the non-blocking acquire as
389
+ * subsuming the reclaim fix rather than complementing it; the reclaim fix is
390
+ * still needed for the blocking callers, which is why both landed.
391
+ *
392
+ * An abandoned companion is still reclaimed on the non-blocking path: skipping
393
+ * that would let one crashed process wedge the hot path for 30 seconds, which
394
+ * is the wedge the never-block contract exists to prevent.
395
+ *
396
+ * Returns the sentinel fd, or `null` when the lock is held by a live peer.
397
+ * `null` is NOT a failure to handle generically — the two callers below take
398
+ * deliberately opposite actions on it, for reasons stated at each.
399
+ */
400
+ function _acquire_lock(
401
+ lock_path: string,
402
+ options: { blocking?: boolean } = {},
403
+ ): number | null {
404
+ const blocking = options.blocking ?? true;
207
405
  // Open (and keep) the sentinel lock file with O_CREAT | O_RDWR — never
208
406
  // truncated, just an fd, exactly like the Python fcntl path. The file
209
407
  // stays on disk alongside the target after release.
210
408
  const fd = fs.openSync(lock_path, "a", 0o644);
211
- // Cross-process mutual exclusion: Node has no flock, so we spin on an
212
- // O_EXCL companion (`.held`) with a bounded deadline. The companion is
213
- // removed on release; the visible `.dispatcher.lock` sentinel is kept.
214
409
  const held = lock_path + ".held";
215
- const deadlineMs = 5000;
410
+ const give_up = (): null => {
411
+ try {
412
+ fs.closeSync(fd);
413
+ } catch {
414
+ /* ignore */
415
+ }
416
+ return null;
417
+ };
216
418
  const start = Date.now();
217
419
  for (;;) {
218
420
  try {
@@ -221,22 +423,44 @@ function _acquire_lock(lock_path: string): number {
221
423
  return fd;
222
424
  } catch (err) {
223
425
  if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
224
- // Unexpected error — proceed without the companion guard rather
225
- // than wedge the never-block hook contract.
426
+ // Unexpected error (a read-only mount, a vanished parent) — proceed
427
+ // without the companion guard rather than wedge the never-block hook
428
+ // contract. Pre-existing behaviour, deliberately retained: the failure
429
+ // mode here is a lost guard, and the alternative is a lost write.
226
430
  return fd;
227
431
  }
228
- if (Date.now() - start > deadlineMs) {
229
- // Stale companion — reclaim it. Never block the agent loop.
432
+ // EEXIST: somebody holds it. Decide from the COMPANION, never from how
433
+ // long we have waited.
434
+ let age_ms: number;
435
+ try {
436
+ age_ms = Date.now() - fs.statSync(held).mtimeMs;
437
+ } catch {
438
+ // Vanished between the open and the stat — the holder just released.
439
+ // Retry at once: this is real progress, not a spin.
440
+ continue;
441
+ }
442
+ if (age_ms >= LOCK_STALE_MS) {
443
+ // A genuine staleness check: the companion itself has been untouched
444
+ // for longer than any live critical section here can last. Removing it
445
+ // is progress, so the immediate retry is bounded.
230
446
  try {
231
447
  fs.rmSync(held, { force: true });
232
448
  } catch {
233
- /* ignore */
449
+ /* a peer reclaimed it first — the retry below sees the new one */
234
450
  }
235
451
  continue;
236
452
  }
237
- // Busy-wait briefly (Atomics gives a real sleep without async).
238
- const sab = new Int32Array(new SharedArrayBuffer(4));
239
- Atomics.wait(sab, 0, 0, 2);
453
+ // A LIVE peer holds it.
454
+ if (!blocking) {
455
+ return give_up();
456
+ }
457
+ if (Date.now() - start > LOCK_ACQUIRE_DEADLINE_MS) {
458
+ // Waited long enough. Report failure — never evict a fresh companion,
459
+ // which is exactly the eviction that made two impatient callers destroy
460
+ // each other's exclusion.
461
+ return give_up();
462
+ }
463
+ _sleep(LOCK_SPIN_SLEEP_MS);
240
464
  }
241
465
  }
242
466
  }
@@ -257,13 +481,54 @@ function _release_lock(fd: number, lock_path: string): void {
257
481
  }
258
482
  }
259
483
 
260
- function _atomic_write_text(target: string, text: string): void {
484
+ /**
485
+ * Publish `text` at `target` atomically. CALLER MUST HOLD THE LOCK.
486
+ *
487
+ * Split out of `_atomic_write_text` so a read-modify-write can hold ONE lock
488
+ * across all three steps. The `O_EXCL` companion in `_acquire_lock` is not
489
+ * reentrant: nesting an acquire inside a held lock spins to the 5-second
490
+ * deadline, reclaims its own companion as stale, and proceeds — so it would
491
+ * "work" while costing five seconds per call and silently dropping the mutual
492
+ * exclusion it was there for. That is why this is a separate function rather
493
+ * than a nested call.
494
+ */
495
+ function _publish_text_locked(target: string, text: string): void {
261
496
  const tmp = target + `.tmp.${process.pid}`;
497
+ fs.writeFileSync(tmp, text, { encoding: "utf-8" });
498
+ fs.renameSync(tmp, target);
499
+ }
500
+
501
+ /**
502
+ * A BLIND WRITE is the lesser evil here, and only here.
503
+ *
504
+ * `_acquire_lock` can now report that a live peer holds the lock. This writer
505
+ * publishes anyway, because its unit of work is a whole-file replacement: the
506
+ * publish is a `rename`, so the worst case under contention is last-writer-wins
507
+ * on a complete, well-formed file — never a torn one. Refusing instead would
508
+ * turn a contended write into a LOST write, and every caller here
509
+ * (`atomic_write_json`, the dispatcher's feedback files) treats a lost write as
510
+ * the more serious failure.
511
+ *
512
+ * `update_json_under_lock` takes the OPPOSITE decision on the same signal, and
513
+ * the asymmetry is the point: a read-modify-write without exclusion produces
514
+ * the lost UPDATE that function exists to prevent, so there it fails closed.
515
+ * Pairing the two decisions in one place rather than picking one globally is
516
+ * what keeps each caller's real failure mode addressed.
517
+ *
518
+ * This also preserves the pre-existing behaviour exactly. The old code reached
519
+ * the same outcome by DELETING the peer's companion and proceeding, which wrote
520
+ * the file and destroyed the peer's exclusion. This writes the file and leaves
521
+ * the peer's lock intact.
522
+ */
523
+ function _atomic_write_text(target: string, text: string): void {
262
524
  const lock_path = _lock_path(path.dirname(target));
263
525
  const fd = _acquire_lock(lock_path);
526
+ if (fd === null) {
527
+ _publish_text_locked(target, text);
528
+ return;
529
+ }
264
530
  try {
265
- fs.writeFileSync(tmp, text, { encoding: "utf-8" });
266
- fs.renameSync(tmp, target);
531
+ _publish_text_locked(target, text);
267
532
  } finally {
268
533
  _release_lock(fd, lock_path);
269
534
  }
@@ -275,19 +540,603 @@ function _atomic_write_text(target: string, text: string): void {
275
540
  * Layout:
276
541
  * <state_root>/.dispatcher/<session_id>/
277
542
  * <concern>.json — one per concern that ran
278
- * summary.json — rollup written by the dispatcher
543
+ * summary.json — capped list of per-invocation rollups
544
+ * (schema 2; see hook-architecture-v1 for why
545
+ * a single rollup object lost one per overlap)
279
546
  *
280
547
  * Per Council Round 2 (2026-05-04): exit-code reduction collapses
281
548
  * multiple concern signals into a single platform-native code; the
282
549
  * feedback dir surfaces the per-concern detail to humans and
283
550
  * `task hooks-status` without re-routing control flow.
551
+ *
552
+ * **Why a composite name and not `session_state_file`'s pure digest.** The two
553
+ * serve different readers. A state file is machine-only, so a bare digest costs
554
+ * nothing. This directory is the HUMAN surface named in the paragraph above,
555
+ * and `hooks_doctor` puts its path in the `last_feedback` column — a bare
556
+ * digest there would replace the one operator-legible token in the output with
557
+ * 12 hex characters. The composite keeps the label for the reader and takes the
558
+ * distinctness from the digest, which is where distinctness actually comes from.
559
+ *
560
+ * **No migration.** Nothing parses the directory name back into a session id
561
+ * and nothing prunes this tree: `hooks_doctor._latest_feedback` enumerates
562
+ * whatever directories exist and picks per concern by mtime. Pre-existing
563
+ * old-scheme directories therefore stay readable and simply stop being written
564
+ * to. Verified against that function rather than assumed.
565
+ *
566
+ * **The id-less bucket is retained, deliberately.** An empty id still maps to
567
+ * one shared `unknown-session.<digest of "">` directory, and
568
+ * `has_stable_session_id` is NOT applied here. That helper exists so a caller
569
+ * can decline to persist; this caller cannot — the dispatcher must put its
570
+ * per-concern detail somewhere or the feedback surface silently empties for any
571
+ * host that exports no id. What merges in that bucket is a per-concern VIEW,
572
+ * recoverable from the next run, not a pin whose loss is unrecoverable. That
573
+ * asymmetry is the whole reason the two functions differ, and it is stated
574
+ * rather than left as an apparent oversight.
284
575
  */
285
576
  export function feedback_dir(state_root: string, session_id: string): string {
286
- let safe_session = session_id || "unknown-session";
287
- // Defence-in-depth: refuse path traversal in session_id.
288
- safe_session = safe_session
577
+ // Defence-in-depth: refuse path traversal in session_id. This half is
578
+ // UNCHANGED — it is what keeps the result a single segment under
579
+ // `.dispatcher`, and `dispatcher_feedback_traversal.test.ts` pins that
580
+ // property directly rather than pinning the literal it produces.
581
+ const label = (session_id || "unknown-session")
289
582
  .replace(/\//g, "_")
290
583
  .replace(/\\/g, "_")
291
584
  .replace(/\.\./g, "_");
292
- return path.join(state_root, FEEDBACK_DIRNAME, safe_session);
585
+ // The DIGEST is what makes two ids two directories. The sanitiser alone
586
+ // could not: it maps `a/b` and `a_b` onto one name, which is the exact shape
587
+ // `session_state_file` replaced for the same reason — see § Per-session
588
+ // concern state, which named this function as a known instance and deferred
589
+ // it on scope grounds. This is that deferral being paid.
590
+ //
591
+ // Keyed on the FULL id, never on `label`, so the sanitiser's collisions
592
+ // cannot survive into the digest.
593
+ const digest = createHash("sha256").update(session_id, "utf8").digest("hex").slice(0, 12);
594
+ return path.join(state_root, FEEDBACK_DIRNAME, `${label}.${digest}`);
595
+ }
596
+
597
+ /**
598
+ * ── Per-session concern state ────────────────────────────────────────────────
599
+ *
600
+ * WHY THIS LIVES HERE, and why it is shared rather than copied.
601
+ *
602
+ * A concern that keeps ONE state file per project root shares that file with
603
+ * every concurrent session — and in this repo's worktree workflow
604
+ * `CLAUDE_PROJECT_DIR` resolves to the PARENT checkout, so "one per project
605
+ * root" means one across every worktree at once. Two failures follow, and they
606
+ * are opposites, which is why neither is obviously a bug from inside one
607
+ * session:
608
+ *
609
+ * - A WRONG READ. Session B reads a fact session A recorded. Measured
610
+ * 2026-08-20 (session 15b9ac52): a terse German "1" read a neighbouring
611
+ * English session's language pin and injected
612
+ * `Reply language for this turn: English.` into a German conversation.
613
+ *
614
+ * - A LOST WRITE. Both sessions write through `atomic_write_json`
615
+ * (write + rename), so the later rename discards the earlier state
616
+ * entirely.
617
+ *
618
+ * A "session boundary reset" — noticing a foreign `session_id` INSIDE the file
619
+ * and clearing the counters — is the guard that looks like a fix and is not.
620
+ * It is written for SEQUENTIAL sessions (A ends, B starts). Under CONCURRENT
621
+ * sessions it becomes the damage: each read sees the other's id, resets, and
622
+ * writes, so the two sessions erase each other's evidence in a loop. The AI
623
+ * council said the same thing about `_ownsPin` in the language hook (round 2,
624
+ * BLOCKER 1): a guard catches the collision only AFTER the fact, and cannot
625
+ * stop the other session's file from being replaced.
626
+ *
627
+ * The property that actually holds comes from the DIGEST, not from the
628
+ * directory layout — stating it the other way round is how that defect survived
629
+ * a round of review. Two distinct ids cannot address one file because
630
+ * sha256 does not collide in practice, and a sanitiser DOES: the first cut of
631
+ * this keyed on a character replacement, under which `a/b` and `a_b` were one
632
+ * file.
633
+ *
634
+ * `feedback_dir` above carried the same sanitiser shape (`a/b` and `a_b` → one
635
+ * directory) and this paragraph used to defer it, on the grounds that changing
636
+ * the dispatcher feedback layout needed its own change with its own
637
+ * `hooks-status` verification. That change has now happened: `feedback_dir`
638
+ * takes its distinctness from a digest of the full id and keeps a legible
639
+ * label as a prefix, for the reader-asymmetry reason stated at that function.
640
+ * The deferral text is replaced rather than left standing — a comment that
641
+ * describes a defect the code no longer has reads as a live known-issue, and
642
+ * the next reader spends the same investigation twice.
643
+ *
644
+ * One difference between the two remains, and it is intentional rather than
645
+ * residual: `feedback_dir` still gives every id-less invocation one shared
646
+ * bucket, because its caller cannot decline to write. The reason is at that
647
+ * function; the short version is that a merged VIEW is recoverable and a
648
+ * merged PIN is not.
649
+ */
650
+
651
+ /**
652
+ * Is there a stable identity to key state on at all?
653
+ *
654
+ * An empty or whitespace id is NOT a bucket to share. Sanitising it into a
655
+ * literal like `unknown-session` puts every id-less invocation in one file with
656
+ * no secondary defense left, which is the original defect restored in the one
657
+ * case that had no guard (language hook, council round 2, BLOCKER 2). There is
658
+ * no sound local way to tell two id-less sessions apart — a fresh UUID per
659
+ * invocation would give one session a new file per hook call, destroying
660
+ * continuity rather than providing it — so the honest answer is for callers to
661
+ * run stateless: derive what this turn needs from this turn's input, persist
662
+ * nothing.
663
+ */
664
+ export function has_stable_session_id(session_id: unknown): boolean {
665
+ return typeof session_id === "string" && session_id.trim() !== "";
666
+ }
667
+
668
+ /**
669
+ * Path of one session's state file inside `dir`, keyed on a digest of the FULL id.
670
+ *
671
+ * The digest also removes the filename-length failure mode for unusually long
672
+ * ids. Callers keep an in-file `session_id` as a cheap integrity check — it now
673
+ * guards against a stale or hand-copied file rather than against a collision.
674
+ */
675
+ export function session_state_file(dir: string, session_id: string): string {
676
+ const digest = createHash("sha256").update(session_id, "utf8").digest("hex").slice(0, 32);
677
+ return path.join(dir, `${digest}.json`);
678
+ }
679
+
680
+ /**
681
+ * Put a claimed state file back at its live path, without ever overwriting.
682
+ *
683
+ * THE CRITICAL SECTION of the pruner. Council round 3, both seats independently:
684
+ * the previous `existsSync(full)` then `renameSync(tomb, full)` was a TOCTOU
685
+ * window — the owner could create `full` between the two calls, and POSIX
686
+ * `rename` then replaced that fresh file with the pruner's older copy. Losing a
687
+ * live pin to the component whose whole job is to protect it.
688
+ *
689
+ * `link` closes it because it is atomic and refuses to clobber: it either
690
+ * creates the live name (nothing was there — restoring is right) or fails
691
+ * `EEXIST` (the owner won the race — its file is newer than ours by
692
+ * construction, and dropping our copy is right). There is no third outcome and
693
+ * no window between deciding and acting, which is the difference from the code
694
+ * this replaces.
695
+ *
696
+ * `rm` afterwards removes only the tombstone NAME; on the success path the
697
+ * content stays reachable through the live path it is now also linked to.
698
+ *
699
+ * Exported for its own tests. What those tests can and cannot establish is
700
+ * stated at them: the difference from the code this replaces is visible ONLY
701
+ * under real concurrency, so they pin the post-condition (a restore never
702
+ * changes an existing live file) rather than claiming to stage the race.
703
+ *
704
+ * FILESYSTEMS WITHOUT HARD LINKS fall back to the racy sequence, deliberately.
705
+ * The peer-review round asked for this before accepting `link` as the fix, and
706
+ * it was the one condition of the three not already met: `linkSync` is the
707
+ * tree's first, `engines` only pins Node >= 20.11, and `installation.md` names
708
+ * Windows — so a share, a FUSE mount, or a restricted container can answer
709
+ * `EPERM` / `ENOSYS` / `EXDEV` / `EOPNOTSUPP` / `EMLINK`. Throwing there would
710
+ * be caught by the pruner's outer handler and leave the tombstone behind, which
711
+ * loses a FRESH pin outright. The fallback narrows the window to the same one
712
+ * this fix removes elsewhere — strictly better than a guaranteed loss, and the
713
+ * only honest option where the atomic primitive is unavailable.
714
+ */
715
+ export function restore_claimed_state(
716
+ tomb: string,
717
+ live: string,
718
+ /** Injectable so the no-hard-link fallback is reachable without one. */
719
+ link: (from: string, to: string) => void = fs.linkSync,
720
+ ): void {
721
+ try {
722
+ link(tomb, live);
723
+ } catch (err) {
724
+ const code = (err as NodeJS.ErrnoException).code;
725
+ // EEXIST → the owner wrote a newer file; ENOENT → a peer pruner already
726
+ // resolved this tombstone. Both mean the live path is already correct.
727
+ if (code !== "EEXIST" && code !== "ENOENT") {
728
+ if (!NO_HARD_LINK_CODES.has(code ?? "")) throw err;
729
+ // Degraded path: check-then-act, the race this fix closes where `link`
730
+ // works. Kept narrow — nothing else is available here.
731
+ if (!fs.existsSync(live)) {
732
+ fs.renameSync(tomb, live);
733
+ return;
734
+ }
735
+ }
736
+ }
737
+ fs.rmSync(tomb, { force: true });
738
+ }
739
+
740
+ /** Errors that mean "this filesystem cannot hard-link", not "the link failed". */
741
+ const NO_HARD_LINK_CODES = new Set(["EPERM", "ENOSYS", "EXDEV", "EOPNOTSUPP", "EMLINK"]);
742
+
743
+ /**
744
+ * Drop session states in `dir` untouched for longer than `retention_days`.
745
+ *
746
+ * One file per session is unbounded growth otherwise. Call this on a
747
+ * once-per-turn path (a prompt or session event), never per tool call.
748
+ *
749
+ * `mtime_of` is injectable for the same reason the language hook injects it:
750
+ * the claim-then-revalidate branch below only runs when the candidate check and
751
+ * the post-claim check DISAGREE, and that disagreement is a race no
752
+ * cutoff-only test can stage. A test that cannot enter the branch would pass
753
+ * against an implementation that does not have it.
754
+ */
755
+ export function prune_stale_session_states(
756
+ dir: string,
757
+ now_ms: number,
758
+ retention_days: number,
759
+ mtime_of: (target: string) => number = (target) => fs.statSync(target).mtimeMs,
760
+ ): number {
761
+ if (is_replay_mode()) return 0;
762
+ const cutoff = now_ms - retention_days * 24 * 60 * 60 * 1000;
763
+ let entries: string[];
764
+ try {
765
+ entries = fs.readdirSync(dir);
766
+ } catch {
767
+ return 0; // no directory yet
768
+ }
769
+ let removed = 0;
770
+ let claim = 0;
771
+
772
+ // Tombstones a crashed pruner left behind. Council round 3, both seats: a
773
+ // crash between the claim rename and the restore stranded the file under a
774
+ // name nothing reads (it no longer ends in `.json`) and nothing prunes — and
775
+ // when the candidate had been refreshed under the pruner, that tombstone held
776
+ // the CURRENT state while the live path was gone for good.
777
+ //
778
+ // No grace period, deliberately: `rename` preserves mtime, so a tombstone
779
+ // carries the age of its CONTENT, not of the claim. That is the property this
780
+ // needs. A tombstone another pruner is holding right now is judged on exactly
781
+ // the same evidence its holder will use, and both orders end at the same
782
+ // place — the loser's `link` hits EEXIST or its `rm` is a no-op.
783
+ //
784
+ // WHY THIS IS NOT THE STALE-BREAKER DEFECT that a sibling lock primitive hit
785
+ // in round 4 (two breakers each judge one fixed-name lock stale; the second
786
+ // deletes the first one FRESH lock and both believe they hold it). The
787
+ // question is worth answering here rather than leaving to a reviewer, because
788
+ // the shapes look alike: both delete an object another live process is using.
789
+ //
790
+ // Two properties separate them, and neither is incidental. The tombstone name
791
+ // carries `pid` and a per-run counter, so no peer can ever create the name
792
+ // this run holds — there is no second claimant to confuse, which is exactly
793
+ // what a fixed name provides. And the delete/restore decision reads the
794
+ // CONTENT mtime, which `rename` does not touch, so a peer resolving this
795
+ // tombstone reaches the same verdict its holder would; the holder then finds
796
+ // ENOENT and skips. The lock case has neither: one name, and a decision made
797
+ // on how long THIS caller waited rather than on a property of the object.
798
+ //
799
+ // What this does NOT claim: that a peer never touches a live tombstone. It
800
+ // does, and that is intended — the alternative (waiting out a holder that may
801
+ // have crashed) is the leak this pass exists to close.
802
+ for (const name of entries) {
803
+ const orphan = /^(.+\.json)\.\d+\.\d+\.tomb$/.exec(name);
804
+ const live_name = orphan?.[1];
805
+ if (live_name === undefined) continue;
806
+ const tomb = path.join(dir, name);
807
+ const live = path.join(dir, live_name);
808
+ try {
809
+ if (mtime_of(tomb) >= cutoff) {
810
+ restore_claimed_state(tomb, live);
811
+ continue;
812
+ }
813
+ fs.rmSync(tomb, { force: true });
814
+ removed += 1;
815
+ } catch {
816
+ // Vanished under us, or a peer pruner got there first. Costs disk, never
817
+ // state.
818
+ }
819
+ }
820
+
821
+ for (const name of entries) {
822
+ if (!name.endsWith(".json")) continue;
823
+ const full = path.join(dir, name);
824
+ try {
825
+ if (mtime_of(full) >= cutoff) continue;
826
+
827
+ // `stat` then `rm` is a race that can delete a FRESH file: the pruner
828
+ // observes a stale mtime, the owning session resumes and atomically
829
+ // replaces that pathname, and the delete then removes the NEW state —
830
+ // losing the pin of an actively resumed session (language hook, council
831
+ // round 2, BLOCKER 3). Writing before pruning only stops the current
832
+ // invocation from eating its own file; it does nothing about a
833
+ // concurrent one.
834
+ //
835
+ // Claim-then-revalidate instead: rename the candidate OUT of the live
836
+ // path first, so any writer arriving afterwards simply creates the path
837
+ // anew and is unaffected. Then re-stat what was actually claimed — a
838
+ // writer that landed between the stat and the rename means the claimed
839
+ // file is fresh, and it goes back (or is dropped, if the writer has since
840
+ // written a newer file at the live path).
841
+ claim += 1;
842
+ const tomb = `${full}.${process.pid}.${claim}.tomb`;
843
+ fs.renameSync(full, tomb);
844
+ if (mtime_of(tomb) >= cutoff) {
845
+ restore_claimed_state(tomb, full);
846
+ continue;
847
+ }
848
+ fs.rmSync(tomb, { force: true });
849
+ // The per-file lock sentinel belongs to the state file, so it is pruned
850
+ // WITH it. Without this, `update_json_under_lock`'s file-keyed lock
851
+ // (see `_target_lock_path`) would leave one sentinel per retired session
852
+ // forever: neither name ends in `.json`, so the filter above skips both
853
+ // and nothing else walks this directory. Trading a serialised write path
854
+ // for an unbounded file count is not a fix.
855
+ //
856
+ // Safe to remove unconditionally at this point: the state file is gone,
857
+ // so no live writer can be holding a lock for it — and a writer that
858
+ // arrives afterwards creates both anew.
859
+ for (const suffix of [".lock", ".lock.held"]) {
860
+ try {
861
+ fs.rmSync(`${full}${suffix}`, { force: true });
862
+ } catch {
863
+ /* never a reason to fail a prune */
864
+ }
865
+ }
866
+ removed += 1;
867
+ } catch {
868
+ // A file that vanished under us, an unreadable stat, or a rename another
869
+ // pruner won — skip it. A failed prune costs disk, never a turn, and
870
+ // never another session's state.
871
+ }
872
+ }
873
+ return removed;
874
+ }
875
+
876
+ /**
877
+ * Remove a pre-split single state file once the per-session layout owns the tree.
878
+ *
879
+ * NOT a migration: a new session has no state to inherit anyway, so there is
880
+ * nothing in the old file worth carrying across — and carrying it across would
881
+ * reintroduce exactly the cross-session read the split closed, since that file
882
+ * is the shared one. Leaving it in place leaves dead state that looks live to
883
+ * anyone reading the directory.
884
+ *
885
+ * MIXED-VERSION EXPOSURE, measured rather than assumed. A cross-model review
886
+ * (2026-08-20) flagged this as destroying the live state of an older bundle that
887
+ * is still writing the single file — and the callers' own comments claimed
888
+ * exactly that scenario, which is what made the finding land. Both were written
889
+ * without checking, so here is the check: the shipped hook command resolves the
890
+ * dispatcher as `$CLAUDE_PROJECT_DIR/node_modules/@event4u/agent-config/dist/hooks/dispatch.js`,
891
+ * falling back to `$CLAUDE_PROJECT_DIR/dist/hooks/dispatch.js`. `CLAUDE_PROJECT_DIR`
892
+ * is the PARENT checkout even in a worktree session, so every concurrent session
893
+ * under one project — worktrees included — executes ONE bundle and writes ONE
894
+ * state tree. There is no steady state in which two versions run side by side,
895
+ * which is why the deletion is safe in the case the review had in mind.
896
+ *
897
+ * What DOES remain is a narrow window, and it is real: a hook process that
898
+ * already loaded the previous bundle and is still running when the file is
899
+ * replaced. It writes by the old layout, a concurrent new-bundle session deletes
900
+ * that file, and one turn's evidence is lost. Bounded to a turn, self-healing on
901
+ * the next write, and confined to the moment of a rebuild or `npm install`. It is
902
+ * NOT bounded by anything in this function — that is a property of the deployment
903
+ * shape, so if the shape changes (a per-worktree `node_modules`, a long-lived
904
+ * hook daemon) this deletion needs a version marker or a grace period.
905
+ */
906
+ export function prune_legacy_state_file(target: string): void {
907
+ if (is_replay_mode()) return;
908
+ try {
909
+ if (fs.statSync(target).isFile()) {
910
+ fs.rmSync(target, { force: true });
911
+ }
912
+ } catch {
913
+ // Absent or unreadable — nothing to prune.
914
+ }
915
+ }
916
+
917
+ /**
918
+ * Does a loaded session-state object belong to `session_id`?
919
+ *
920
+ * THE DIGEST PATH IS NOT THE WHOLE GUARANTEE, which is the correction this
921
+ * function exists to carry. `session_state_file` says callers "keep an in-file
922
+ * `session_id` as a cheap integrity check" — and a cross-model review
923
+ * (2026-08-20, both seats, blocking) found the two `turn-end-gate` readers
924
+ * consuming state without ever making that check, so the sentence described a
925
+ * discipline no consumer followed. A file that arrives at the wrong digest path
926
+ * by a copy, a restore, a hand-edit, or a buggy writer was accepted whole; the
927
+ * sharp case is a foreign `ci_last: {settled: true}`, which vouches for a CI run
928
+ * this session never made and lets a premature completion claim through a
929
+ * BLOCKING gate.
930
+ *
931
+ * EXACT match, and an absent owner is foreign. There is no compatibility window
932
+ * to honour: the digest layout has never shipped for either concern, so no
933
+ * deployed version ever wrote an ownerless hashed file — a permissive branch
934
+ * would be dead code that weakens the guarantee its own doc comment states.
935
+ * (The language hook's `_ownsPin` learned this in its round 3 and now delegates
936
+ * here.)
937
+ */
938
+ export function owns_session_state(state: unknown, session_id: string): boolean {
939
+ if (typeof state !== "object" || state === null || Array.isArray(state)) return false;
940
+ return (state as Record<string, unknown>)["session_id"] === session_id;
941
+ }
942
+
943
+ /**
944
+ * Read a JSON state file, mutate it, and write it back — the WHOLE sequence
945
+ * under one lock.
946
+ *
947
+ * WHY THIS EXISTS. `atomic_write_json` makes the *publish* atomic; it does not
948
+ * make load → update → publish atomic. A cross-model review (2026-08-20, both
949
+ * seats) named the consequence, and it is not hypothetical on a host that runs
950
+ * tool calls in parallel: two `post_tool_use` invocations for the SAME session
951
+ * both load counter 149, both compute 150, both publish 150, and one event is
952
+ * gone. Per-session filenames fix cross-session reads and do nothing for this —
953
+ * the two failures are independent, and fixing the first made the second easier
954
+ * to mistake for solved.
955
+ *
956
+ * The worse shape is a lost FIELD rather than a lost increment: a snapshot
957
+ * loaded at the start of a hook run, spread into a write at the end
958
+ * (`{...previous, one_field: x}`), silently republishes every OTHER field as it
959
+ * looked at load time — so a newer value written in between is reverted, not
960
+ * merely missed.
961
+ *
962
+ * `mutate` receives the state as loaded INSIDE the lock and returns what to
963
+ * write. Returning `null` writes nothing, so a caller that decides against the
964
+ * write after seeing fresh state does not have to publish a no-op.
965
+ *
966
+ * ## Three states, not two
967
+ *
968
+ * This returned a BOOLEAN and reported `true` for two different outcomes: the
969
+ * write landed, and the mutator deliberately declined. That collapse is exactly
970
+ * what a fail-closed caller must not have. The reset-then-emit pattern is the
971
+ * worked case: a reset that did not land leaves a counter at its threshold, so
972
+ * emitting anyway re-fires on every subsequent call — which is why such callers
973
+ * stay silent unless they know the write landed. Under the boolean they could
974
+ * not distinguish "declined, nothing needed to land" from "landed", and the
975
+ * only way to express a decline was a flag captured in the mutator's closure —
976
+ * state smuggled out of a callback, invisible in the signature, and unenforced.
977
+ *
978
+ * - `written` — the mutator returned a value and it is on disk.
979
+ * - `skipped` — the mutator returned `null`. Nothing was written and nothing
980
+ * went wrong. A caller whose emit depends on a write having landed treats
981
+ * this as "do not emit"; a caller that only needed the state examined
982
+ * treats it as success. Both readings are now expressible.
983
+ * - `failed` — replay mode, or the directory, the lock, the read or the
984
+ * publish went wrong. Never distinguishable from `skipped` before.
985
+ *
986
+ * `failed` is deliberately NOT split further (no `replay` state, no `busy`
987
+ * state): every caller that has one treats replay, contention and a genuine
988
+ * write failure identically — stay silent — and a state nobody branches on is a
989
+ * state that goes stale. Under `AGENT_CONFIG_REPLAY=1` this reports `failed`
990
+ * without touching the tree, the same no-op contract as the writers above.
991
+ *
992
+ * ## `blocking: false` — for hot paths that must never wait
993
+ *
994
+ * Default `true`, i.e. the pre-existing behaviour. Pass `false` from a caller
995
+ * that runs on every tool call: it declines on the first contended attempt with
996
+ * no spin, returning `failed`. The trade is explicit — a wait becomes a skip,
997
+ * and for a counter that is a missed increment rather than a late one. Take it
998
+ * only where the missed increment is recoverable by construction (a re-emit
999
+ * distance counter is; a verification witness may not be).
1000
+ *
1001
+ * **Migration hazard, stated because the compiler does not catch it.** The old
1002
+ * shape was tested with `if (!result)`. Every value of the new union is a
1003
+ * non-empty string and therefore truthy, so a missed call site does not fail to
1004
+ * compile — it silently stops detecting failure. There were two production call
1005
+ * sites at the time of the change and both were converted; a new caller must
1006
+ * compare against a member of the union, never coerce to a boolean.
1007
+ */
1008
+ /**
1009
+ * Read a TEXT file, transform it, and write it back — the WHOLE sequence under
1010
+ * one lock.
1011
+ *
1012
+ * The text sibling of {@link update_json_under_lock}, added for P3 of
1013
+ * `b-stop-async-split-prerequisites` (council 2026-08-20, option (a),
1014
+ * "P3 before anything else"). The concrete case is
1015
+ * `agents/runtime/state/dispatch-issues.jsonl`, which had NO lock and NO
1016
+ * tmp+rename: `log_dispatch_issue` read the whole capped log, appended a line,
1017
+ * and `writeFileSync`'d the target directly.
1018
+ *
1019
+ * That is corruption-capable rather than merely lossy, and the distinction is
1020
+ * why this needed its own primitive instead of a comment. `writeFileSync`
1021
+ * truncates and then writes, so a concurrent reader can observe a half-written
1022
+ * log and a concurrent writer can interleave into one — and the file is written
1023
+ * precisely when something has ALREADY gone wrong, so the evidence destroyed is
1024
+ * the evidence of the failure being reported. Two concurrent dispatchers in one
1025
+ * workspace is a supported configuration (two platforms installed side by side),
1026
+ * and a host that runs tool calls in parallel produces it without any second
1027
+ * platform at all.
1028
+ *
1029
+ * `transform` receives the file's content as read INSIDE the lock — `null` when
1030
+ * the file does not exist, so a caller can tell "absent" from "empty" — and
1031
+ * returns what to write. Returning `null` writes nothing and still counts as
1032
+ * success.
1033
+ *
1034
+ * Returns `written` / `skipped` / `failed`, the same three-state contract
1035
+ * `update_json_under_lock` carries since `bcbb0380b` — a boolean could not tell
1036
+ * a deliberate decline from a failure, and every member of the union is a truthy
1037
+ * string, so `if (!result)` is a silent no-op rather than a compile error. Under
1038
+ * `AGENT_CONFIG_REPLAY=1` this reports `failed` without touching the tree.
1039
+ */
1040
+ export function update_text_under_lock(
1041
+ target: string,
1042
+ transform: (loaded: string | null) => string | null,
1043
+ options: { blocking?: boolean } = {},
1044
+ ): LockedUpdateResult {
1045
+ if (is_replay_mode()) return "failed";
1046
+ const targetPath = path.resolve(target);
1047
+ const state_dir = path.dirname(targetPath);
1048
+ try {
1049
+ fs.mkdirSync(state_dir, { recursive: true });
1050
+ } catch {
1051
+ return "failed";
1052
+ }
1053
+ // FILE-keyed, matching `update_json_under_lock` rather than
1054
+ // `_atomic_write_text`. Converged onto the sibling deliberately after
1055
+ // `bcbb0380b` landed the measured argument: a read-modify-write needs mutual
1056
+ // exclusion against writers of THIS file and nothing else, and the shared
1057
+ // directory lock was measured paying the full cost of exclusion for writes
1058
+ // that require none (8 workers: slowest 138-267 ms shared vs 83-95 ms
1059
+ // unshared). This function was written against the directory-keyed primitive
1060
+ // one commit earlier; leaving it there would have made
1061
+ // `dispatch-issues.jsonl` serialise against every unrelated
1062
+ // `atomic_write_text` in the state dir.
1063
+ const lock_path = _target_lock_path(targetPath);
1064
+ let fd: number | null;
1065
+ try {
1066
+ fd = _acquire_lock(lock_path, { blocking: options.blocking ?? true });
1067
+ } catch {
1068
+ return "failed";
1069
+ }
1070
+ // FAIL CLOSED, for the same reason the JSON sibling does: an append without
1071
+ // exclusion is the truncating write this function exists to prevent.
1072
+ if (fd === null) return "failed";
1073
+ try {
1074
+ let loaded: string | null = null;
1075
+ try {
1076
+ loaded = fs.readFileSync(targetPath, "utf-8");
1077
+ } catch {
1078
+ // Absent or unreadable. `null` lets the transform distinguish the two
1079
+ // from an empty file, which matters for an append-only log: an
1080
+ // unreadable existing log must not be silently replaced by one line.
1081
+ }
1082
+ const next = transform(loaded);
1083
+ if (next === null) return "skipped"; // deliberate no-write, not a failure
1084
+ _publish_text_locked(targetPath, next);
1085
+ return "written";
1086
+ } catch {
1087
+ return "failed";
1088
+ } finally {
1089
+ _release_lock(fd, lock_path);
1090
+ }
1091
+ }
1092
+
1093
+ export function update_json_under_lock<T>(
1094
+ target: string,
1095
+ mutate: (loaded: Partial<T>) => T | null,
1096
+ options: { indent?: number; blocking?: boolean } = {},
1097
+ ): LockedUpdateResult {
1098
+ if (is_replay_mode()) return "failed";
1099
+ const targetPath = path.resolve(target);
1100
+ const state_dir = path.dirname(targetPath);
1101
+ try {
1102
+ fs.mkdirSync(state_dir, { recursive: true });
1103
+ } catch {
1104
+ return "failed";
1105
+ }
1106
+ // FILE-keyed, not directory-keyed — see `_target_lock_path` for the measured
1107
+ // reason and for why `_atomic_write_text` deliberately keeps the shared one.
1108
+ const lock_path = _target_lock_path(targetPath);
1109
+ let fd: number | null;
1110
+ try {
1111
+ fd = _acquire_lock(lock_path, { blocking: options.blocking ?? true });
1112
+ } catch {
1113
+ return "failed";
1114
+ }
1115
+ // FAIL CLOSED, unlike `_atomic_write_text`. A read-modify-write without
1116
+ // exclusion is the lost-update this function exists to prevent, so a held
1117
+ // lock is reported rather than worked around. Reported as `failed` and not as
1118
+ // a fourth `busy` state on purpose: every fail-closed caller takes the same
1119
+ // action on both (stay silent), and a state nobody branches on goes stale.
1120
+ if (fd === null) return "failed";
1121
+ try {
1122
+ let loaded: Partial<T> = {};
1123
+ try {
1124
+ const raw = fs.readFileSync(targetPath, "utf-8");
1125
+ const decoded: unknown = JSON.parse(raw);
1126
+ if (typeof decoded === "object" && decoded !== null && !Array.isArray(decoded)) {
1127
+ loaded = decoded as Partial<T>;
1128
+ }
1129
+ } catch {
1130
+ // Absent, unreadable, or malformed — the mutator decides what an empty
1131
+ // state means. Never a reason to abandon the write.
1132
+ }
1133
+ const next = mutate(loaded);
1134
+ if (next === null) return "skipped"; // deliberate no-write, not a failure
1135
+ _publish_text_locked(targetPath, _py_json_dumps(next, options.indent ?? 2) + "\n");
1136
+ return "written";
1137
+ } catch {
1138
+ return "failed";
1139
+ } finally {
1140
+ _release_lock(fd, lock_path);
1141
+ }
293
1142
  }