session-orchestrator 5.3.0 → 5.5.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -76,7 +76,24 @@
76
76
  * real background-batch collision through. Completion arrives later as a
77
77
  * `<task-notification>` record carrying `<tool-use-id>toolu_…</tool-use-id>`
78
78
  * and `<status>completed</status>` (measured: launch 14:14:26.768 →
79
- * notification 14:24:39.360, ten minutes later).
79
+ * notification 14:24:39.360, ten minutes later). Any TERMINAL status
80
+ * finishes the id, not only `completed` — see {@link TERMINAL_STATUS_RE}.
81
+ * #1455: a 429'd agent notified `failed`, was resumed via SendMessage
82
+ * (new tool-use-id), and its original id never finished → permanent deny.
83
+ * Only a HARNESS-WRITTEN carrier record counts (#1459 Pkt 2 Rest,
84
+ * {@link notificationCarrierText}): a complete block inside a `tool_result`
85
+ * (a `Read` of a fixture), assistant text or a typed prompt is a forgery
86
+ * that would false-ALLOW an overlap with a still-running agent.
87
+ * c) EXACT ID, POSITIVE PROOF, RESUME (#1480, #1459 P1). A ledger entry keeps
88
+ * its dispatch's PreToolUse `tool_use_id` as `useId`, and liveness is
89
+ * looked up by THAT id; the description is only the fallback for entries
90
+ * without one, the ledger `id` never (B3). A dispatch finishes only on
91
+ * positive evidence — a terminal carrier, `is_error`, or a result in
92
+ * {@link RESULT_COMPLETION_FORMS}; any other result form is "unknown",
93
+ * i.e. no evidence (B2, row 13). A `SendMessage` to the dispatch's task id
94
+ * re-opens it until a terminal carrier for the dispatch, that SendMessage or
95
+ * the task id follows — a resumed agent is running, not done (#1459 P1;
96
+ * {@link buildLivenessIndex}).
80
97
  *
81
98
  * COST CONTAINMENT: the transcript is read ONLY when a collision has already
82
99
  * been found — i.e. on the path that is about to deny. The 99 % no-collision
@@ -84,8 +101,9 @@
84
101
  * costs 78 ms to read + 129 ms to scan; a typical one is 1–5 MB.
85
102
  *
86
103
  * BLIND FALLBACK + ITS CEILING (BV-004): when the transcript is unavailable or
87
- * carries no record of that agent at all, liveness falls back to the ledger
88
- * entry's own age, with `IN_FLIGHT_TTL_MS` = 30 min. Named ceiling: the largest
104
+ * carries no positive evidence for that agent (no record, or only an unknown
105
+ * result form), liveness falls back to the ledger entry's own age, with
106
+ * `IN_FLIGHT_TTL_MS` = 30 min. Named ceiling: the largest
89
107
  * MEASURED same-batch dispatch spread is 95.7 s, so 30 min is ~19× headroom
90
108
  * against the false-ALLOW direction, while both measured sequential repair gaps
91
109
  * (36 min, 49 min) sit above it. Revisit trigger: a same-batch spread above
@@ -96,10 +114,14 @@
96
114
  *
97
115
  * A deny-capable hook on the DISPATCH path has an asymmetric blast radius: a
98
116
  * false positive blocks every agent of the session (the guard becomes a session
99
- * outage), while a false negative is a double-assignment that three later gates
100
- * still catch (`validate-wave-scope.mjs`, `enforce-scope.mjs` at write time, and
101
- * the W5 verification pass). Fail-closed is right for a WRITE guard; it is wrong
102
- * here. Each row below is a deliberate choice, not an oversight:
117
+ * outage), while a false negative of the COLLISION check is a double-assignment
118
+ * that three later gates still catch (`validate-wave-scope.mjs`,
119
+ * `enforce-scope.mjs` at write time, and the W5 verification pass). The #1485
120
+ * stale-base check (row 15) has no such later gate — a missed stale base is an
121
+ * agent editing old code — so it is kept fail-open by a different rule: it
122
+ * denies only on a MEASUREMENT, and every unknown is ALLOW plus a record.
123
+ * Fail-closed is right for a WRITE guard; it is wrong here. Each row below is a
124
+ * deliberate choice, not an oversight:
103
125
  *
104
126
  * | # | Condition | Decision | Why |
105
127
  * |---|----------------------------------------|---------------------|-----|
@@ -110,18 +132,21 @@
110
132
  * | 5 | prompt carries no scope marker | ALLOW + COUNT | 105 of 147 real prompts (71.4 %) have none. Non-extractable ≠ violation; denying these would deny 7 dispatches in 10. #1092: the allow now carries a counter-only record, so it is no longer byte-identical to "the guard never ran" |
111
133
  * | 6 | scope block present but unparseable | ALLOW + COUNT | same reason as 5 — the parser is the fragile part, so its failures must resolve to the harmless side. Counted under a DISTINCT class from row 5 |
112
134
  * | 7 | ledger unreadable / corrupt | WARN + ALLOW + SELF-HEAL | loss of state is not evidence of a violation; loud so it gets noticed. The verdict now CARRIES a fresh ledger, so the corruption is repaired on the spot — without it the guard stayed OFF for the whole remaining wave, visible only in one `systemMessage` |
113
- * | 8 | `git ls-files` failed | ALLOW (degraded) | glob-vs-glob expansion degrades, concrete collisions are still found. A git outage is not a scope violation |
135
+ * | 8 | `git ls-files` failed, or the shared git budget (`GIT_BUDGET_MS`) ran out | ALLOW (degraded) + `known_files_skipped` in the record | glob-vs-glob expansion degrades, concrete collisions are still found. A git outage is not a scope violation. #1489 Pkt 7: a HANGING git used to outlive the 5 s harness timeout, which killed the hook — the verdict lost with no record |
114
136
  * | 9 | `findScopeCollisions` → not evaluable | WARN + ALLOW | the library says "not evaluable". Denying on a verdict with no witness is an assertion without evidence |
115
- * |10 | same agent id re-dispatched, same scope| ALLOW (ledger replace) | a retry after a failed agent is legitimate; treating it as a duplicate would make the guard block every retry — a self-lock vector |
116
- * |10a| collision, but EVERY colliding prior agent has FINISHED | ALLOW (+ prune) | the sequential repair pass. Its ledger records are pruned, so the state cannot re-block the next one either |
117
- * |11 | collision with a prior agent still IN FLIGHT | **DENY** | the one case this hook exists for |
137
+ * |10 | same agent id re-dispatched | ALLOW; predecessor pruned only when FINISHED | a retry after a failed agent is legitimate (a failed agent is finished), so the guard must not self-lock. #1480 B4: an unfinished predecessor is KEPT as a claim — a third agent hitting only its scope still collides. With distinct `tool_use_id`s on both it is an ordinary partner (row 11); without them it never collides with its own retry. Replaced WITHOUT a probe only where no claim is lost: the same `tool_use_id` (the same dispatch evaluated again), or — neither carrying an id — a predecessor wholly covered by the retry's scope |
138
+ * |10a| collision, but EVERY colliding prior agent has FINISHED (positive evidence) | ALLOW (+ prune) | the sequential repair pass. Its ledger records are pruned, so the state cannot re-block the next one either |
139
+ * |11 | collision with a prior agent still IN FLIGHT | **DENY** | the case this hook was built for (#1020) |
118
140
  * |12 | unexpected throw | ALLOW + stderr | as row 2 |
119
- * |13 | liveness probe throws / no evidence at all | treat as IN FLIGHT | keeps row 11 biting; the blind case is bounded by `IN_FLIGHT_TTL_MS`, never unbounded |
141
+ * |13 | liveness probe throws / no positive evidence (no record, unknown result form) | treat as IN FLIGHT | keeps row 11 biting; the blind case is bounded by `IN_FLIGHT_TTL_MS`, never unbounded. A dispatch re-opened by a SendMessage with no completion after it is in flight outright, without TTL |
120
142
  * |14 | ledger lock not acquirable in `LEDGER_LOCK_TIMEOUT_MS` | run UNLOCKED (degraded) | the lock removes the read-modify-write race (below); failing to take it must not deny, so the cycle degrades to the pre-lock behaviour |
143
+ * |15 | `isolation: "worktree"`, `worktree.baseRef` not `"head"`, `merge-base --is-ancestor HEAD origin/HEAD` exits 1 in a full-history repo whose FETCH_HEAD is < 24 h old | **DENY**, no ledger write | #1485: the agent would branch from a base missing commits of HEAD and edit OLD code. Every non-measurement — no origin/HEAD, shallow, `stale-remote-ref`, git error, git budget spent (`git-budget`) — is ALLOW + a `skipped` record. No ledger write, so the in-place re-dispatch the deny advises meets no phantom claim. A collision DENY (row 11) wins the terminal emit; this one then goes to stderr. Otherwise the `scope_checked` record carries `ledger_result: 'deny-stale-base'`, never the collision verdict's allow (#1489 Pkt 8) |
121
144
  *
122
145
  * Every row that reaches a verdict from `decide()` — 5–11 and 13–14 — also
123
146
  * leaves an event record (§ Observability), so "which row fired" is answerable
124
- * after the fact and not only in the moment. Rows 1–4 emit nothing (no decision
147
+ * after the fact and not only in the moment. Row 15 is decided outside
148
+ * `decide()` and leaves its own `worktree_base_checked` record for EVERY
149
+ * worktree dispatch, allow and skip included. Rows 1–4 emit nothing (no decision
125
150
  * was made), and neither do the two crash rows 2 and 12: a hook that fell over
126
151
  * cannot describe itself, which is precisely why the GUARD INACTIVE banner on
127
152
  * stderr is the signal there.
@@ -147,13 +172,21 @@
147
172
  * ## Observability — the ledger half of #1092
148
173
  *
149
174
  * Every dispatch DECISION also appends one `orchestrator.wave_dispatch.scope_checked`
150
- * record to `<projectDir>/.orchestrator/metrics/events.jsonl`. The reason it
175
+ * record to `<session root>/.orchestrator/metrics/events.jsonl` — the root
176
+ * `resolveSessionRoot()` resolves (`scripts/lib/platform.mjs`: the git toplevel
177
+ * of the payload `cwd` — its nearest `.git` ancestor when git cannot answer —
178
+ * but never above a launch dir inside it; so neither the subdirectory a `cd`
179
+ * moved to, #1489 Pkt 6, nor the launch dir a worktree session left), which is
180
+ * also where the ledger and its lock live. The reason it
151
181
  * exists is matrix rows 5/6: the no-signal ALLOW used to be byte-identical to
152
182
  * "the guard never ran", and the in-ledger counter added first is a WAVE tally —
153
183
  * it cannot say WHICH dispatch carried a scope. Payload: `wave` (omitted, never
154
184
  * `0`, when unknown), `agent_id`, `declared_path_count`, `injected`, `shape`,
155
- * `signal`, `ledger_result`, `collision_count`, `hook`, plus `session_id` /
156
- * `semantic_session_id` when a session lock is readable.
185
+ * `signal`, `ledger_result`, `collision_count`, `hook`, `known_files_skipped`
186
+ * (only when the tracked-file listing degraded, matrix row 8), `collision_result`
187
+ * (only beside `ledger_result: 'deny-stale-base'` — the collision verdict it
188
+ * replaced), plus `session_id` / `semantic_session_id` when a session lock is
189
+ * readable.
157
190
  *
158
191
  * WHAT IT PROVES: this hook SAW (or did not see) a `FILE-SCOPE` declaration in
159
192
  * the prompt the coordinator handed to the dispatch tool, and what the guard
@@ -211,6 +244,7 @@ import path from 'node:path';
211
244
  import { pathToFileURL, fileURLToPath } from 'node:url';
212
245
  import { execFileSync } from 'node:child_process';
213
246
  import { mkdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
247
+ import { homedir } from 'node:os';
214
248
 
215
249
  import { shouldRunHook } from './_lib/profile-gate.mjs';
216
250
 
@@ -231,7 +265,11 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
231
265
  /** @type {typeof import('../scripts/lib/io.mjs').writeJsonAtomicSync} */ let writeJsonAtomicSync;
232
266
  /** @type {typeof import('../scripts/lib/file-lock.mjs').withFileLock} */ let withFileLock;
233
267
  /** @type {typeof import('../scripts/lib/scope-echo.mjs').scopeDigest} */ let scopeDigest;
268
+ /** @type {typeof import('../scripts/lib/platform.mjs').resolveSessionRoot} */ let resolveSessionRoot;
234
269
  let findScopeCollisions;
270
+ let findOwnScopeFile;
271
+ let readProcessLocalSessionIds;
272
+ let classifyManifestSession;
235
273
 
236
274
  const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
237
275
 
@@ -245,11 +283,43 @@ const HOOK_NAME = 'pre-task-scope-disjoint';
245
283
  */
246
284
  const DISPATCH_TOOL = 'Agent';
247
285
 
248
- /** Ledger location, relative to the project dir. */
249
- const LEDGER_REL = path.join('.orchestrator', 'wave-dispatch-scopes.json');
286
+ /**
287
+ * Ledger location (#1493.3): ONE FILE PER SESSION,
288
+ * `.orchestrator/wave-dispatch-scopes.<session>.json`, each with its own lock
289
+ * `.orchestrator/wave-dispatch-scopes.<session>.lock` (§ Ledger concurrency).
290
+ *
291
+ * Until #1493 every session of the working copy shared ONE file holding ONE
292
+ * `waveKey`, and `decide()` keeps prior agents only under the same key — whose
293
+ * first segment IS the session id. So cross-session claims were never compared
294
+ * (nothing is lost by splitting), but a peer session's dispatch reset the file
295
+ * and erased this session's live claims, and the deny's "delete the ledger"
296
+ * remedy erased the peer's. One file per session removes both.
297
+ *
298
+ * A flat sibling, not a directory, on purpose: bootstrap's `store-lock-ignore`
299
+ * step (#1495) ignores `.orchestrator/wave-dispatch-scopes.*` in consumer repos,
300
+ * which covers this name; a directory would surface untracked in each of them.
301
+ */
302
+ const LEDGER_DIR_REL = '.orchestrator';
303
+ const LEDGER_STEM = 'wave-dispatch-scopes';
250
304
 
251
- /** Mutex for the ledger's read-modify-write cycle — see § Ledger concurrency. */
252
- const LEDGER_LOCK_REL = path.join('.orchestrator', 'wave-dispatch-scopes.lock');
305
+ /**
306
+ * The pre-#1493 single shared ledger. READ only, for migration: adopted when
307
+ * this session has no file yet AND its `waveKey` names this session; never
308
+ * written and never deleted — it may still be a peer's live state.
309
+ * Revisit-Trigger: remove this read one minor release after the split ships,
310
+ * when no session that recorded claims in the old file can still be running.
311
+ */
312
+ const LEGACY_LEDGER_REL = path.join(LEDGER_DIR_REL, `${LEDGER_STEM}.json`);
313
+
314
+ /**
315
+ * Longest session token a ledger file name carries. Harness session ids are
316
+ * UUIDs (36 chars) and pass through unchanged; the cap only keeps a pathological
317
+ * id below the 255-byte file-name limit. Two ids that sanitise to ONE token
318
+ * share a file again — degraded to the pre-#1493 behaviour, never worse, since
319
+ * `waveKey` still carries the raw id. Revisit if a harness ships ids that are
320
+ * not path-safe.
321
+ */
322
+ const MAX_SESSION_TOKEN_CHARS = 128;
253
323
 
254
324
  /**
255
325
  * Ledger-lock budget. Short on purpose: the whole locked region is a read, a
@@ -260,6 +330,28 @@ const LEDGER_LOCK_REL = path.join('.orchestrator', 'wave-dispatch-scopes.lock');
260
330
  const LEDGER_LOCK_TIMEOUT_MS = 2000;
261
331
  const LEDGER_LOCK_POLL_MS = 25;
262
332
 
333
+ /**
334
+ * ONE deadline for ALL git work of one hook fire (#1489 Pkt 7). Every spawn
335
+ * gets at most what is LEFT of it, so the git work as a whole — never a single
336
+ * call — is what is bounded.
337
+ *
338
+ * The number is arithmetic, not taste: `hooks/hooks.json` gives this hook
339
+ * `"timeout": 5` (seconds) for the WHOLE process. The git work runs first; after
340
+ * it the ledger lock may wait up to `LEDGER_LOCK_TIMEOUT_MS` (2000). 5000 − 2000
341
+ * − 1500 leaves 1500 ms for node start, the late-bound imports, the transcript
342
+ * scan (≤ ~210 ms at the largest measured 70 MB) and the two awaited event
343
+ * appends — ~20× the 69 ms full path measured idle (§ Measured cost). The git
344
+ * work itself measured 27.8 ms together with lock, ledger and compare, so 1500
345
+ * binds only a git that hangs (a stuck lock file, a network-backed `HEAD`, a
346
+ * frozen filesystem). Before, each worktree call had its own 5 s and
347
+ * `listTrackedFiles` none: one hanging git outlived the harness, which killed
348
+ * the hook — a silent fail-open with no record of the lost verdict.
349
+ *
350
+ * Revisit-Trigger: a change of this hook's `timeout` in `hooks/hooks.json` or of
351
+ * `LEDGER_LOCK_TIMEOUT_MS` — the three numbers must keep summing below 5000.
352
+ */
353
+ const GIT_BUDGET_MS = 1500;
354
+
263
355
  /** Payload bounds — see § stdout discipline. */
264
356
  const MAX_REPORTED_COLLISIONS = 5;
265
357
  const MAX_EVIDENCE_PER_COLLISION = 4;
@@ -289,14 +381,14 @@ const SHAPE_NONE = 'none';
289
381
  const SCOPE_EVENT = 'orchestrator.wave_dispatch.scope_checked';
290
382
 
291
383
  /**
292
- * The worktree-base observability record (#1413).
384
+ * The worktree-base observability record (#1413; carries the #1485 `decision`).
293
385
  *
294
386
  * Same `wave_dispatch` domain and same `_checked` verb as `SCOPE_EVENT`, and
295
387
  * written for EVERY `isolation: "worktree"` dispatch rather than only the
296
388
  * alarming one. That is HR-105 applied at the source: a numerator-only stream
297
- * cannot tell "genuinely rare" from "silently broken", and the firing rate this
298
- * warning must stay under is then unfalsifiable. With every outcome in one
299
- * stream the rate is `stale:true / all records of this name`.
389
+ * cannot tell "genuinely rare" from "silently broken", and the firing rate of
390
+ * the #1485 deny is then unfalsifiable. With every outcome in one stream the
391
+ * rate is `decision:"deny" / all records of this name`.
300
392
  *
301
393
  * #1424 widened "every outcome" from two to three: the `stale: true|false`
302
394
  * split was already covered, but each NON-measurement (peer STATE.md, no
@@ -308,9 +400,6 @@ const SCOPE_EVENT = 'orchestrator.wave_dispatch.scope_checked';
308
400
  */
309
401
  const WORKTREE_BASE_EVENT = 'orchestrator.wave_dispatch.worktree_base_checked';
310
402
 
311
- /** State-dir candidates, in the same order `waveKeyOf()` probes them. */
312
- const STATE_DIR_CANDIDATES = ['.pi', '.cursor', '.codex', '.claude'];
313
-
314
403
  /**
315
404
  * The receive-side instruction line `renderScopeEchoInstruction()` renders and
316
405
  * the coordinator appends immediately after the fenced block
@@ -358,9 +447,77 @@ const MAX_TRANSCRIPT_BYTES = 256 * 1024 * 1024;
358
447
  * a completion — see § Liveness (b). Reading it as one would let every real
359
448
  * background-batch collision through, which is the one direction this repair
360
449
  * must not take.
450
+ *
451
+ * Recognised only as the PREFIX of the result text (after trimStart): a finished
452
+ * sync report that merely QUOTES this sentence is not a launch receipt, and read
453
+ * as one it stayed "running" without TTL (R1 LOW). Census 2026-09-30, 5,881
454
+ * local transcripts: 4,684 of 4,684 ACK results begin with it, also in their
455
+ * first content part.
361
456
  */
362
457
  const ASYNC_LAUNCH_ACK = 'Async agent launched successfully';
363
458
 
459
+ /**
460
+ * The `tool_result` forms that PROVE a dispatch finished (#1480 B2), one line
461
+ * per form. A result matching none of them is NOT a completion but "no
462
+ * evidence" — the entry falls back to the TTL rule (matrix row 13). `is_error`
463
+ * is handled beside this list (the dispatch never ran), and the ACK is tested
464
+ * FIRST, so a launch receipt can never read as a completion.
465
+ *
466
+ * Census 2026-09-30 (local transcripts, 14 days, counts only), Agent
467
+ * tool_results: ACK 4,730; sync completion carrying the `<usage>`/`total_tokens`
468
+ * trailer and `agentId:` 690 (690/690); `is_error` 37; neither 38. Of those 38,
469
+ * "This agent's report was delivered…" is a completion (35/35 without a
470
+ * terminal carrier before or after, 2026-09-30), "Fork started — processing in
471
+ * b…" is not (a start receipt without the ACK wording — before this list it was
472
+ * read as a completion: the B2 fail-open).
473
+ *
474
+ * Named ceiling (BV-004): any other wording reads as "unknown", so a fresh entry
475
+ * binds for up to IN_FLIGHT_TTL_MS although its agent may be done — a bounded
476
+ * false-DENY, never a false-ALLOW. Revisit-Trigger: a census re-run finds an
477
+ * Agent tool_result wording in neither this list nor ASYNC_LAUNCH_ACK — classify
478
+ * it, then add ONE line here.
479
+ */
480
+ const RESULT_COMPLETION_FORMS = Object.freeze([
481
+ Object.freeze({ at: 'anywhere', text: '<usage>' }), // sync trailer, 690/690
482
+ Object.freeze({ at: 'anywhere', text: 'total_tokens' }), // sync trailer, 690/690
483
+ Object.freeze({ at: 'start', text: "This agent's report was delivered" }), // 35/35 without carrier
484
+ ]);
485
+
486
+ /** The tool that addresses — and can RESUME — an already-dispatched agent (#1459 P1). */
487
+ const SEND_MESSAGE_TOOL = 'SendMessage';
488
+
489
+ /**
490
+ * A `SendMessage` result that reached nobody (`{"success":false,…"No agent
491
+ * named…"}`, census 2026-09-30): such a send activates nothing.
492
+ */
493
+ const SEND_NO_EFFECT = '"success":false';
494
+
495
+ /**
496
+ * The harness's `agentId:` line in an Agent tool_result (ACK or sync trailer) —
497
+ * the task id a `SendMessage` addresses. Only a line that BEGINS with it, id on
498
+ * the same line, read as the LAST match over the content parts joined with
499
+ * `\n`: a sync result embeds the agent's own report ahead of the harness
500
+ * trailer, and a report ending in `agentId:` must not swallow the trailer's
501
+ * line (R1 MEDIUM — a wrong task id let a resumed agent read as finished).
502
+ * Census 2026-09-30, 5,881 local transcripts: in 5,375 of 5,375 Agent results
503
+ * carrying `agentId:`, the last one starts a line and sits in the last part.
504
+ */
505
+ const AGENT_ID_LINE_RE = /^agentId:[ \t]*([A-Za-z0-9_-]+)/gm;
506
+
507
+ /** Per-dispatch liveness states — see {@link buildLivenessIndex}. */
508
+ const LIVE_FINISHED = 'finished';
509
+ const LIVE_RUNNING = 'running';
510
+ const LIVE_UNKNOWN = 'unknown';
511
+
512
+ /**
513
+ * `<task-notification>` statuses that mean the task is NO LONGER RUNNING (#1455).
514
+ * Enumerated from evidence, not guessed — census 2026-09-25 over the operator's
515
+ * local Claude Code transcripts (`rg "<tool-use-id>…<status>X</status>"` on
516
+ * `*.jsonl`): completed, failed 1430 lines, killed 609, stopped 28.
517
+ * `running` is deliberately absent — it is the one non-terminal spelling.
518
+ */
519
+ const TERMINAL_STATUS_RE = /<status>(?:completed|failed|killed|stopped)<\/status>/;
520
+
364
521
  /**
365
522
  * The consequence block spliced VERBATIM into the GUARD INACTIVE banner (#993),
366
523
  * naming the enforcement this hook's outage stops applying.
@@ -400,6 +557,7 @@ async function bootstrap() {
400
557
  {
401
558
  io: { specifier: lib('io.mjs') },
402
559
  scopeGate: { specifier: lib('scope-gate.mjs') },
560
+ sessionIdentity: { specifier: lib('session-identity', 'own-session.mjs') },
403
561
  fileLock: { specifier: lib('file-lock.mjs') },
404
562
  // ONE normalization for the digest, shared with the receive side (#1092):
405
563
  // `scope-echo.mjs` is pure (stdlib + `crypto-digest-utils.mjs`) and its
@@ -409,6 +567,10 @@ async function bootstrap() {
409
567
  // names. Late-bound like every other repo module so a load failure
410
568
  // banners instead of disarming the guard silently (#993).
411
569
  scopeEcho: { specifier: lib('scope-echo.mjs') },
570
+ // #1492: ONE session-root resolver for every hook that reads the
571
+ // session's control files — this ledger, and `wave-scope.json` in
572
+ // enforce-scope / enforce-commands / post-bash-write-verify.
573
+ platform: { specifier: lib('platform.mjs') },
412
574
  },
413
575
  {
414
576
  hookName: HOOK_NAME,
@@ -419,9 +581,11 @@ async function bootstrap() {
419
581
  );
420
582
 
421
583
  ({ readStdin, emitAllow, emitDeny, emitWarn, writeJsonAtomicSync } = modules.io);
422
- ({ findScopeCollisions } = modules.scopeGate);
584
+ ({ findScopeCollisions, findOwnScopeFile } = modules.scopeGate);
585
+ ({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
423
586
  ({ withFileLock } = modules.fileLock);
424
587
  ({ scopeDigest } = modules.scopeEcho);
588
+ ({ resolveSessionRoot } = modules.platform);
425
589
  }
426
590
 
427
591
  // ---------------------------------------------------------------------------
@@ -750,8 +914,8 @@ export function extractScopeFromPrompt(prompt) {
750
914
 
751
915
  /**
752
916
  * The dispatch's human description — the field the liveness probe matches
753
- * against the transcript's `tool_use` blocks (present in 147/147 measured
754
- * payloads).
917
+ * against the transcript's `tool_use` blocks when an entry carries no exact
918
+ * `useId` (present in 147/147 measured payloads).
755
919
  *
756
920
  * @param {{description?: unknown}} toolInput
757
921
  * @returns {string}
@@ -777,69 +941,401 @@ export function agentIdOf(toolInput) {
777
941
  return 'unnamed-agent';
778
942
  }
779
943
 
944
+ /**
945
+ * A dispatch's EXACT identity (#1480 A): the PreToolUse payload's `tool_use_id`,
946
+ * or a ledger entry's stored `useId`. On Claude Code it equals the transcript's
947
+ * `tool_use` id, so liveness becomes a lookup instead of a description match;
948
+ * other harnesses may omit it, and `''` then means "no exact id". Taken
949
+ * verbatim, never re-spelled — and dropped rather than clipped past
950
+ * MAX_AGENT_ID_CHARS: a clipped id could never match, two clipped ids could
951
+ * match wrongly.
952
+ *
953
+ * @param {unknown} value
954
+ * @returns {string}
955
+ */
956
+ function exactUseId(value) {
957
+ return typeof value === 'string' && value.trim() !== '' && value.length <= MAX_AGENT_ID_CHARS ? value : '';
958
+ }
959
+
780
960
  // ---------------------------------------------------------------------------
781
961
  // Liveness — has an already-recorded agent FINISHED? (§ Liveness)
782
962
  // ---------------------------------------------------------------------------
783
963
 
964
+ const NOTIFICATION_OPEN = '<task-notification>';
965
+ const NOTIFICATION_CLOSE = '</task-notification>';
966
+
967
+ /**
968
+ * A notification head in the harness's own tag order: `<task-id>`,
969
+ * `<tool-use-id>`, optional `<output-file>`, `<status>` — anchored at the opener.
970
+ * Group 1 is the task id (the resume key, #1459 P1), group 2 the tool-use id,
971
+ * group 3 the whole `<status>` element.
972
+ */
973
+ const NOTIFICATION_HEAD_RE = /^<task-notification>\s*<task-id>([^<]*)<\/task-id>\s*<tool-use-id>([^<]+)<\/tool-use-id>\s*(?:<output-file>[^<]*<\/output-file>\s*)?(<status>[^<]*<\/status>)/;
974
+
975
+ /** `origin.kind` / `commandMode` value the harness stamps on a notification carrier. */
976
+ const CARRIER_KIND = 'task-notification';
977
+
978
+ /** `queue-operation` operations measured to carry a notification (dequeue carries no content). */
979
+ const CARRIER_QUEUE_OPS = new Set(['enqueue', 'remove']);
980
+
981
+ /** First line of the harness preamble some `user` carriers put before the block. */
982
+ const SYSTEM_NOTIFICATION_MARK = '[SYSTEM NOTIFICATION - NOT USER INPUT]\n';
983
+
984
+ /**
985
+ * The notification text of a HARNESS-WRITTEN carrier record, or `null` for any
986
+ * other record (#1459 Pkt 2 Rest). Census 2026-09-28 over the operator's local
987
+ * transcripts (11,005 files, 2026-08-06 → 2026-09-28): every terminal id the
988
+ * pre-fix raw-line match accepted from a real carrier came from exactly one of
989
+ * three record forms, each of which carries ids the other two do not:
990
+ * - `user` with STRING `message.content` and `origin.kind: 'task-notification'`
991
+ * (a typed prompt carries `origin.kind: 'human'`). The text begins with the
992
+ * opener, or with the harness preamble ({@link SYSTEM_NOTIFICATION_MARK},
993
+ * tag-free, ending in a blank line) directly followed by the opener;
994
+ * - `queue-operation` with `operation` `enqueue`/`remove` and string `content`;
995
+ * - `attachment` of type `queued_command` with `commandMode: 'task-notification'`.
996
+ * Everything else — `tool_result` content, assistant text, tool inputs, typed
997
+ * prompts — is a quote or a forgery and yields `null`, i.e. no finished id.
998
+ *
999
+ * The guard on the `user` form is `origin.kind`, which only the harness sets; the
1000
+ * preamble rule merely strips a tag-free prefix and is no control on its own.
1001
+ *
1002
+ * Named residual (BV-004): a `queue-operation` record has no field that tells a
1003
+ * harness notification from an operator prompt typed while the agent was busy,
1004
+ * so an OPERATOR could forge through that one channel. An agent cannot through
1005
+ * the one producer censused: text a subagent or peer delivers via `SendMessage`
1006
+ * arrives wrapped by the harness, so it never begins with the opener. Measured
1007
+ * 2026-09-28 in a second run (count only, 11,008 transcript files, three more
1008
+ * than the census above): 7,131 records carry such a message, 1,801 of them as
1009
+ * `queue-operation`, 2,486 as `queued_command` with `commandMode: 'prompt'`;
1010
+ * 0 of the 7,131 begin with `<task-notification>`. Other queue producers
1011
+ * (scheduled wake-ups, a `/loop` body) were not censused. Revisit if the harness
1012
+ * adds a discriminator here (like `commandMode` on the attachment) or stops
1013
+ * wrapping.
1014
+ *
1015
+ * @param {any} rec — one parsed transcript record
1016
+ * @returns {string|null}
1017
+ */
1018
+ function notificationCarrierText(rec) {
1019
+ if (rec?.type === 'user' && rec?.origin?.kind === CARRIER_KIND && typeof rec?.message?.content === 'string') {
1020
+ const text = rec.message.content;
1021
+ if (!text.startsWith(SYSTEM_NOTIFICATION_MARK)) return text;
1022
+ const open = text.indexOf('<');
1023
+ return open !== -1 && text.startsWith(`\n\n${NOTIFICATION_OPEN}`, open - 2) ? text.slice(open) : null;
1024
+ }
1025
+ if (rec?.type === 'queue-operation' && CARRIER_QUEUE_OPS.has(rec?.operation) && typeof rec?.content === 'string') {
1026
+ return rec.content;
1027
+ }
1028
+ const att = rec?.type === 'attachment' ? rec?.attachment : undefined;
1029
+ if (att?.type === 'queued_command' && att?.commandMode === CARRIER_KIND && typeof att?.prompt === 'string') {
1030
+ return att.prompt;
1031
+ }
1032
+ return null;
1033
+ }
1034
+
784
1035
  /**
785
- * Index a session transcript by agent DESCRIPTION → completion state.
1036
+ * The HEAD of a carrier text's FIRST `<task-notification>` block (#1459 Pkt 2) —
1037
+ * its task id, its tool-use id and whether its status is TERMINAL; at most one
1038
+ * head per carrier. Only a terminal head finishes an id; a non-terminal one only
1039
+ * contributes the task id (#1459 P1), which can open a dispatch, never close it.
1040
+ * (Named `finishedNotificationIds` until #1480; the ceiling below is unchanged.)
1041
+ *
1042
+ * The text must BEGIN with the opener (a block further in is a quote) and its
1043
+ * head must follow the harness tag order ({@link NOTIFICATION_HEAD_RE}).
1044
+ * `<summary>` and `<result>` carry free text that can QUOTE another
1045
+ * notification's `<tool-use-id>` and `<status>`, or even forge a whole block
1046
+ * (R2 F1: a summary that closes itself and opens a complete fake block defeated
1047
+ * the earlier cut-then-split approach). So nothing past the block's HEAD is read:
1048
+ * the head runs from the opener to the earliest `<summary>`, `<result>` or
1049
+ * `</task-notification>`. Fail-closed — the deny stays — when the text is not a
1050
+ * carrier's, does not begin with the opener, has no closing tag (truncated
1051
+ * block), or its head is out of order or carries no terminal status.
1052
+ *
1053
+ * Named ceiling (BV-004, #1467): a batch of several DIFFERENT notifications in
1054
+ * one carrier reports only the first as finished (the rest stay running, the
1055
+ * safe direction but a false-DENY source). Census 2026-09-30 over the operator's
1056
+ * local transcripts of all repos (11,684 files, 2026-08-06 → 2026-09-30, at
1057
+ * f58480e5): 23 preamble-form `user` carriers hold 2+ distinct terminal ids; 108
1058
+ * non-first ids are finished by no other carrier, and 0 of them is an `Agent`
1059
+ * dispatch id (105 Bash, 3 Monitor) — the only ids this index resolves, so no
1060
+ * Agent liveness is lost today. The 2026-09-28 figures (31 / 155) were not
1061
+ * reproduced. No split is built: it would gain nothing, and its safety is
1062
+ * unproven. `origin` has no per-block discriminator, so a split is safe only if
1063
+ * the harness escapes `<` in `<summary>`/`<result>` — unescaped, a block forged
1064
+ * there is byte-identical to a genuine `\n\n`-joined batch. The census suggests
1065
+ * escaping (12,039 of 25,031 results carry `&lt;`, 3 a raw `<` + letter) but
1066
+ * does not establish it for every harness version. Revisit-Trigger: a re-run
1067
+ * census finds a non-first terminal id that is an `Agent` dispatch id finished
1068
+ * by no other carrier or `tool_result`; a split then needs escaping proven first.
1069
+ *
1070
+ * @param {string|null} text — a carrier's text from {@link notificationCarrierText}
1071
+ * @returns {{taskId: string, toolUseId: string, terminal: boolean}|null}
1072
+ */
1073
+ function notificationHead(text) {
1074
+ if (typeof text !== 'string' || !text.startsWith(NOTIFICATION_OPEN)) return null;
1075
+ const close = text.indexOf(NOTIFICATION_CLOSE);
1076
+ if (close === -1) return null;
1077
+ let headEnd = close;
1078
+ for (const tag of ['<summary>', '<result>']) {
1079
+ const at = text.indexOf(tag);
1080
+ if (at !== -1 && at < headEnd) headEnd = at;
1081
+ }
1082
+ const head = text.slice(0, headEnd).match(NOTIFICATION_HEAD_RE);
1083
+ if (head === null) return null;
1084
+ return { taskId: head[1].trim(), toolUseId: head[2], terminal: TERMINAL_STATUS_RE.test(head[3]) };
1085
+ }
1086
+
1087
+ /**
1088
+ * Classify an Agent dispatch's OWN `tool_result` (#1480 B2): `done` only on
1089
+ * positive evidence (`is_error`, or one of {@link RESULT_COMPLETION_FORMS}),
1090
+ * `ack` for the launch acknowledgement (a text PREFIX, see
1091
+ * {@link ASYNC_LAUNCH_ACK}), `unknown` for everything else. The ACK is tested
1092
+ * before the forms, so no ACK wording can ever read as a completion.
1093
+ *
1094
+ * @param {{is_error?: unknown}} block
1095
+ * @param {string} text — {@link resultTextOf}(block)
1096
+ * @returns {'done'|'ack'|'unknown'}
1097
+ */
1098
+ function dispatchResultForm(block, text) {
1099
+ if (block?.is_error === true) return 'done';
1100
+ const lead = text.trimStart();
1101
+ if (lead.startsWith(ASYNC_LAUNCH_ACK)) return 'ack';
1102
+ for (const form of RESULT_COMPLETION_FORMS) {
1103
+ if (form.at === 'start' ? lead.startsWith(form.text) : text.includes(form.text)) return 'done';
1104
+ }
1105
+ return 'unknown';
1106
+ }
1107
+
1108
+ /**
1109
+ * The LAST line-leading `agentId:` a harness-written dispatch result names, or
1110
+ * `''` — see {@link AGENT_ID_LINE_RE}. Parts are joined with `\n` HERE (unlike
1111
+ * {@link resultTextOf}), so a part boundary is a line boundary and report text
1112
+ * cannot run into the trailer's line.
786
1113
  *
787
- * Three record shapes are read, all measured in this repo's own transcripts:
788
- * - `tool_use` `{name:'Agent', id, input.description}` — the dispatch.
789
- * - `tool_result` `{tool_use_id, content}` — a completion for the SYNCHRONOUS
790
- * shape, but only when its text is not the {@link ASYNC_LAUNCH_ACK}.
791
- * - a `<task-notification>` record carrying `<tool-use-id>` and
792
- * `<status>completed</status>` — the ASYNC shape's completion.
1114
+ * @param {{content?: unknown}} block
1115
+ * @returns {string}
1116
+ */
1117
+ function lastAgentIdIn(block) {
1118
+ const c = block?.content;
1119
+ const text = typeof c === 'string'
1120
+ ? c
1121
+ : Array.isArray(c) ? c.map((part) => (typeof part?.text === 'string' ? part.text : '')).join('\n') : '';
1122
+ let last = '';
1123
+ for (const m of text.matchAll(AGENT_ID_LINE_RE)) last = m[1];
1124
+ return last;
1125
+ }
1126
+
1127
+ /**
1128
+ * Append `value` to the array stored under `key`.
793
1129
  *
794
- * A description dispatched N times counts as finished only when EVERY one of its
795
- * tool_use ids is finished. Conservative on purpose: one outstanding run of the
796
- * same agent keeps the deny alive.
1130
+ * @template T
1131
+ * @param {Map<string, T[]>} map
1132
+ * @param {string} key
1133
+ * @param {T} value
1134
+ */
1135
+ function pushTo(map, key, value) {
1136
+ const list = map.get(key);
1137
+ if (list === undefined) map.set(key, [value]);
1138
+ else list.push(value);
1139
+ }
1140
+
1141
+ /**
1142
+ * Index a session transcript by DISPATCH tool-use id → liveness state, plus
1143
+ * description → dispatch ids for ledger entries that carry no exact id.
1144
+ *
1145
+ * Record shapes read, all measured (census 2026-09-30 for the SendMessage half):
1146
+ * - `tool_use` `{name:'Agent', id, input.description}` — the dispatch U.
1147
+ * - `tool_use` `{name:'SendMessage', id, input.to}` — addresses a dispatch by
1148
+ * its task id T(U) (= agentId); 1,055 via a notification task id, 33 via the
1149
+ * ACK's agentId, 0 via the description.
1150
+ * - `tool_result` for U — classified by {@link dispatchResultForm}; for a
1151
+ * SendMessage only whether it reached nobody ({@link SEND_NO_EFFECT} or
1152
+ * `is_error`).
1153
+ * - a `<task-notification>` CARRIER record — only a harness-written carrier
1154
+ * ({@link notificationCarrierText}), only its first block's head
1155
+ * ({@link notificationHead}); a terminal `<status>` finishes (#1455: `failed`
1156
+ * too).
1157
+ *
1158
+ * T(U) is the last line-leading `agentId:` ({@link lastAgentIdIn}) of U's own
1159
+ * ACK or positive result, else the task
1160
+ * id of the first carrier head carrying U's tool-use id.
1161
+ *
1162
+ * RESUME (#1459 P1). U is ACTIVATED by its dispatch and by every SendMessage
1163
+ * with `to === T(U)` whose result is not a no-effect (a send without a result
1164
+ * yet counts — the safe direction). U is FINISHED when a completion lies AFTER
1165
+ * its last activation, by transcript line position: a terminal carrier with
1166
+ * tool-use id U, with the id of an activating SendMessage, or with task id T(U);
1167
+ * when the dispatch itself is the last activation, also U's own positive
1168
+ * result. Measured 2026-09-30: a resumed agent completes under the
1169
+ * SendMessage's id and the same task id, never again under U (455 cases); a
1170
+ * message queued to a running agent completes under U with the same task id
1171
+ * (566). Activations only OPEN; closing still needs a carrier or U's own
1172
+ * harness-written result.
1173
+ *
1174
+ * States: `finished`; `running` — no result, an ACK, or a SendMessage as last
1175
+ * activation with no completion after it (no TTL); `unknown` — only a result
1176
+ * form without positive evidence, which the probe treats as no evidence (TTL).
1177
+ * A repeated `tool_use` record (same Agent or SendMessage id) is the SAME event,
1178
+ * not a new activation: its position is the FIRST occurrence, so a copy after
1179
+ * the completion cannot re-open a finished agent (R1 D4, a false-DENY). A
1180
+ * repeated carrier still closes. Hardening without a measured case: census
1181
+ * 2026-09-30 (5,881 local transcripts) found 0 repeated Agent and 0 repeated
1182
+ * SendMessage `tool_use` records.
1183
+ *
1184
+ * Named ceiling (BV-004): `to` is matched against T(U) only — a SendMessage that
1185
+ * addresses the agent by name or description re-opens nothing (0 of ~6,200
1186
+ * measured 2026-09-30). Revisit-Trigger: a census re-run finds a SendMessage to
1187
+ * a dispatch whose `to` is not its task id.
797
1188
  *
798
1189
  * Pure and total — a malformed line is skipped, never thrown on.
799
1190
  *
800
1191
  * @param {string} raw — the transcript's JSONL text
801
- * @returns {Map<string, boolean>} description → finished?
1192
+ * @returns {{states: Map<string, 'finished'|'running'|'unknown'>,
1193
+ * idsByDesc: Map<string, string[]>}}
802
1194
  */
803
- export function buildTranscriptIndex(raw) {
804
- const out = new Map();
805
- if (typeof raw !== 'string' || raw.length === 0) return out;
806
-
807
- const dispatched = new Map(); // description → tool_use ids
808
- const finishedIds = new Set();
809
-
810
- for (const line of raw.split('\n')) {
1195
+ function buildLivenessIndex(raw) {
1196
+ const states = new Map();
1197
+ const idsByDesc = new Map();
1198
+ if (typeof raw !== 'string' || raw.length === 0) return { states, idsByDesc };
1199
+
1200
+ const dispatchPos = new Map(); // U → FIRST line position of its tool_use
1201
+ const results = new Map(); // U → [{pos, form, agentId}]
1202
+ const sendsByTo = new Map(); // SendMessage `to` → [{id, pos}]
1203
+ const sendIds = new Set();
1204
+ const sendNoEffect = new Set(); // SendMessage ids whose result reached nobody
1205
+ const headsByUse = new Map(); // carrier tool-use id → [{pos, taskId, terminal}]
1206
+ const terminalByTask = new Map(); // carrier task id → [pos] of terminal heads
1207
+
1208
+ const lines = raw.split('\n');
1209
+ for (let pos = 0; pos < lines.length; pos++) {
1210
+ const line = lines[pos];
811
1211
  if (line.length < 24) continue;
1212
+ // Substring prefilter only — the verdict needs the PARSED record: a raw-line
1213
+ // match let a forged block in any record finish an id (#1459 Pkt 2 Rest).
1214
+ const hasNotification = line.includes('task-notification');
1215
+ const hasToolRow = line.includes('"tool_use"') || line.includes('tool_use_id');
1216
+ if (!hasNotification && !hasToolRow) continue;
1217
+ let rec;
1218
+ try { rec = JSON.parse(line); } catch { continue; }
812
1219
 
813
- // ASYNC completion — matched on the RAW line: the tags are plain text inside
814
- // a JSON string, so no parse is needed and the `queue-operation` carrier
815
- // record (which has no `message.content`) is covered too.
816
- if (line.includes('task-notification') && line.includes('<status>completed</status>')) {
817
- for (const m of line.matchAll(/<tool-use-id>([^<]+)<\/tool-use-id>/g)) finishedIds.add(m[1]);
1220
+ // Completion (and the task id) — only from a harness-written carrier record.
1221
+ if (hasNotification) {
1222
+ const head = notificationHead(notificationCarrierText(rec));
1223
+ if (head !== null) {
1224
+ pushTo(headsByUse, head.toolUseId, { pos, taskId: head.taskId, terminal: head.terminal });
1225
+ if (head.terminal && head.taskId !== '') pushTo(terminalByTask, head.taskId, pos);
1226
+ }
818
1227
  }
819
1228
 
820
- if (!line.includes('"tool_use"') && !line.includes('tool_use_id')) continue;
821
- let rec;
822
- try { rec = JSON.parse(line); } catch { continue; }
1229
+ if (!hasToolRow) continue;
823
1230
  const content = rec?.message?.content;
824
1231
  if (!Array.isArray(content)) continue;
825
1232
 
826
1233
  for (const block of content) {
827
- if (block?.type === 'tool_use' && block?.name === DISPATCH_TOOL && typeof block?.id === 'string') {
828
- const desc = typeof block?.input?.description === 'string' ? block.input.description.trim() : '';
829
- if (desc === '') continue;
830
- const ids = dispatched.get(desc) ?? [];
831
- ids.push(block.id);
832
- dispatched.set(desc, ids);
1234
+ if (block?.type === 'tool_use' && typeof block?.id === 'string') {
1235
+ if (block.name === DISPATCH_TOOL) {
1236
+ if (!results.has(block.id)) { // first occurrence only — see D4 above
1237
+ results.set(block.id, []);
1238
+ dispatchPos.set(block.id, pos);
1239
+ const desc = typeof block?.input?.description === 'string' ? block.input.description.trim() : '';
1240
+ if (desc !== '') pushTo(idsByDesc, desc, block.id);
1241
+ }
1242
+ } else if (block.name === SEND_MESSAGE_TOOL && typeof block?.input?.to === 'string' && block.input.to !== ''
1243
+ && !sendIds.has(block.id)) {
1244
+ sendIds.add(block.id);
1245
+ pushTo(sendsByTo, block.input.to, { id: block.id, pos });
1246
+ }
833
1247
  continue;
834
1248
  }
835
1249
  if (block?.type === 'tool_result' && typeof block?.tool_use_id === 'string') {
836
- // The launch ACK is not a completion — see § Liveness (b).
837
- if (!resultTextOf(block).includes(ASYNC_LAUNCH_ACK)) finishedIds.add(block.tool_use_id);
1250
+ const own = results.get(block.tool_use_id);
1251
+ if (own !== undefined) {
1252
+ const text = resultTextOf(block);
1253
+ const form = dispatchResultForm(block, text);
1254
+ // An error text is not a harness trailer — learn T(U) from ACK/completion only.
1255
+ const agentId = form === 'ack' || (form === 'done' && block.is_error !== true) ? lastAgentIdIn(block) : '';
1256
+ own.push({ pos, form, agentId });
1257
+ } else if (sendIds.has(block.tool_use_id)
1258
+ && (block.is_error === true || resultTextOf(block).includes(SEND_NO_EFFECT))) {
1259
+ sendNoEffect.add(block.tool_use_id);
1260
+ }
1261
+ }
1262
+ }
1263
+ }
1264
+
1265
+ for (const [useId, pos] of dispatchPos) {
1266
+ const own = results.get(useId) ?? [];
1267
+ const taskId = own.find((r) => r.agentId !== '')?.agentId
1268
+ ?? (headsByUse.get(useId) ?? []).find((h) => h.taskId !== '')?.taskId
1269
+ ?? '';
1270
+
1271
+ let lastPos = pos;
1272
+ let lastIsSend = false;
1273
+ const closers = [useId];
1274
+ if (taskId !== '') {
1275
+ for (const send of sendsByTo.get(taskId) ?? []) {
1276
+ if (sendNoEffect.has(send.id)) continue;
1277
+ closers.push(send.id);
1278
+ if (send.pos > lastPos) { lastPos = send.pos; lastIsSend = true; }
838
1279
  }
839
1280
  }
1281
+
1282
+ const closedAfter = closers.some((id) => (headsByUse.get(id) ?? []).some((h) => h.terminal && h.pos > lastPos))
1283
+ || (taskId !== '' && (terminalByTask.get(taskId) ?? []).some((p) => p > lastPos))
1284
+ || (!lastIsSend && own.some((r) => r.form === 'done' && r.pos > lastPos));
1285
+
1286
+ let state;
1287
+ if (closedAfter) state = LIVE_FINISHED;
1288
+ else if (lastIsSend || own.length === 0 || own.some((r) => r.form === 'ack')) state = LIVE_RUNNING;
1289
+ else state = LIVE_UNKNOWN;
1290
+ states.set(useId, state);
840
1291
  }
1292
+ return { states, idsByDesc };
1293
+ }
1294
+
1295
+ /**
1296
+ * Description-level verdict for a ledger entry WITHOUT an exact id: `false` if
1297
+ * any of the description's dispatches is running, `true` only if every one is
1298
+ * finished, `undefined` (no evidence → TTL) otherwise. `excludeUseId` drops the
1299
+ * CURRENT dispatch, whose `tool_use` may already sit in the transcript at
1300
+ * PreToolUse time and would make a finished same-named predecessor look alive.
1301
+ *
1302
+ * @param {{states: Map<string, string>, idsByDesc: Map<string, string[]>}} index
1303
+ * @param {string} desc
1304
+ * @param {string} excludeUseId
1305
+ * @returns {boolean|undefined}
1306
+ */
1307
+ function descFinished(index, desc, excludeUseId) {
1308
+ let seen = false;
1309
+ let unknown = false;
1310
+ for (const useId of index.idsByDesc.get(desc) ?? []) {
1311
+ if (useId === excludeUseId) continue;
1312
+ seen = true;
1313
+ const state = index.states.get(useId);
1314
+ if (state === LIVE_RUNNING) return false;
1315
+ if (state !== LIVE_FINISHED) unknown = true;
1316
+ }
1317
+ return seen && !unknown ? true : undefined;
1318
+ }
841
1319
 
842
- for (const [desc, ids] of dispatched) out.set(desc, ids.every((id) => finishedIds.has(id)));
1320
+ /**
1321
+ * Index a session transcript by agent DESCRIPTION → completion state — the
1322
+ * description view of {@link buildLivenessIndex}.
1323
+ *
1324
+ * A description dispatched N times counts as finished only when EVERY one of its
1325
+ * tool_use ids is finished, and as running as soon as one is. Conservative on
1326
+ * purpose: one outstanding run of the same agent keeps the deny alive. A
1327
+ * description with no running run but an `unknown` one is absent (no evidence).
1328
+ *
1329
+ * @param {string} raw — the transcript's JSONL text
1330
+ * @returns {Map<string, boolean>} description → finished? (absent = no evidence)
1331
+ */
1332
+ export function buildTranscriptIndex(raw) {
1333
+ const index = buildLivenessIndex(raw);
1334
+ const out = new Map();
1335
+ for (const desc of index.idsByDesc.keys()) {
1336
+ const finished = descFinished(index, desc, '');
1337
+ if (finished !== undefined) out.set(desc, finished);
1338
+ }
843
1339
  return out;
844
1340
  }
845
1341
 
@@ -866,10 +1362,16 @@ function resultTextOf(block) {
866
1362
  * been found. The no-collision path — the overwhelming majority — never touches
867
1363
  * the file (§ Liveness, cost containment).
868
1364
  *
869
- * Resolution order per ledger entry:
870
- * 1. transcript evidence for its description → definitive;
871
- * 2. no evidence → the entry's own age against `IN_FLIGHT_TTL_MS`;
872
- * 3. no usable timestamp either → NOT finished (matrix row 13 — keeps the
1365
+ * Resolution order per ledger entry (#1480 A):
1366
+ * 1. an entry WITH `useId` → that exact dispatch's state: finished / running
1367
+ * are definitive, `unknown` or no record is no evidence;
1368
+ * 2. an entry WITHOUT `useId` (legacy, or a harness sending no `tool_use_id`)
1369
+ * → its non-empty description via {@link descFinished}, the current
1370
+ * dispatch (`selfUseId`) excluded. Never the ledger `id` — a
1371
+ * `desc (type)` string or a bare subagent type, which matched unrelated
1372
+ * dispatches (B3);
1373
+ * 3. no evidence → the entry's own age against `IN_FLIGHT_TTL_MS`;
1374
+ * 4. no usable timestamp either → NOT finished (matrix row 13 — keeps the
873
1375
  * deny biting rather than inventing a completion).
874
1376
  *
875
1377
  * @param {object} params
@@ -877,9 +1379,13 @@ function resultTextOf(block) {
877
1379
  * @param {number} [params.now]
878
1380
  * @param {number} [params.ttlMs]
879
1381
  * @param {(p: string, enc: string) => string} [params.readFn]
880
- * @returns {(entry: {id: string, desc?: string, at?: string}) => boolean}
1382
+ * @param {string} [params.selfUseId] the CURRENT dispatch's `tool_use_id`
1383
+ * @returns {(entry: {id: string, desc?: string, at?: string, useId?: string}) => boolean}
881
1384
  */
882
- export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN_FLIGHT_TTL_MS, readFn = readFileSync } = {}) {
1385
+ export function makeFinishedProbe({
1386
+ transcriptPath, now = Date.now(), ttlMs = IN_FLIGHT_TTL_MS, readFn = readFileSync, selfUseId,
1387
+ } = {}) {
1388
+ const excludeUseId = exactUseId(selfUseId);
883
1389
  let index; // undefined = not loaded yet, null = unavailable
884
1390
  const load = () => {
885
1391
  if (index !== undefined) return index;
@@ -887,7 +1393,7 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
887
1393
  try {
888
1394
  if (typeof transcriptPath === 'string' && transcriptPath.length > 0) {
889
1395
  if (statSync(transcriptPath).size <= MAX_TRANSCRIPT_BYTES) {
890
- index = buildTranscriptIndex(readFn(transcriptPath, 'utf8'));
1396
+ index = buildLivenessIndex(readFn(transcriptPath, 'utf8'));
891
1397
  }
892
1398
  }
893
1399
  } catch {
@@ -899,10 +1405,17 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
899
1405
  return (entry) => {
900
1406
  try {
901
1407
  const idx = load();
902
- const desc = typeof entry?.desc === 'string' && entry.desc !== '' ? entry.desc : entry?.id;
903
- if (idx !== null && typeof desc === 'string') {
904
- const finished = idx.get(desc);
905
- if (finished !== undefined) return finished;
1408
+ if (idx !== null) {
1409
+ const useId = exactUseId(entry?.useId);
1410
+ if (useId !== '') {
1411
+ const state = idx.states.get(useId);
1412
+ if (state === LIVE_FINISHED) return true;
1413
+ if (state === LIVE_RUNNING) return false;
1414
+ } else {
1415
+ const desc = typeof entry?.desc === 'string' ? entry.desc.trim() : '';
1416
+ const finished = desc === '' ? undefined : descFinished(idx, desc, excludeUseId);
1417
+ if (finished !== undefined) return finished;
1418
+ }
906
1419
  }
907
1420
  const at = Date.parse(entry?.at ?? '');
908
1421
  if (Number.isFinite(at)) return now - at > ttlMs;
@@ -920,7 +1433,8 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
920
1433
  /**
921
1434
  * Identify the wave this dispatch belongs to. Derived from the coordinator's
922
1435
  * own scope file so a wave transition resets the ledger without anyone having to
923
- * remember to clear it.
1436
+ * remember to clear it. `findOwnScopeFile` skips provably foreign manifests;
1437
+ * unbound or corrupt manifests still decide under the ENFORCE contract.
924
1438
  *
925
1439
  * FALLBACK, stated honestly (review MED): with no readable `wave-scope.json` the
926
1440
  * key degrades to `<session>|w?|?`, so the ledger spans the whole SESSION and a
@@ -935,22 +1449,75 @@ export function makeFinishedProbe({ transcriptPath, now = Date.now(), ttlMs = IN
935
1449
  * @param {string} projectDir
936
1450
  * @param {string} sessionId
937
1451
  * @param {(p: string, enc: string) => string} readFn — injected `readFileSync`
938
- * (the module is late-bound, so it cannot be imported at the top level here)
1452
+ * @param {Set<string>} [ownIds] — process-local ids; repo helpers are late-bound
1453
+ * by bootstrap, as for the other guard dependencies.
939
1454
  * @returns {string}
940
1455
  */
941
- export function waveKeyOf(projectDir, sessionId, readFn) {
942
- for (const dir of ['.pi', '.cursor', '.codex', '.claude']) {
1456
+ export function waveKeyOf(projectDir, sessionId, readFn,
1457
+ ownIds = new Set(readProcessLocalSessionIds({ hookInput: { session_id: sessionId } }))) {
1458
+ const { path: scopeFile } = findOwnScopeFile(projectDir, ownIds, classifyManifestSession);
1459
+ if (scopeFile) {
943
1460
  try {
944
- const raw = readFn(path.join(projectDir, dir, 'wave-scope.json'), 'utf8');
1461
+ const raw = readFn(scopeFile, 'utf8');
945
1462
  const data = JSON.parse(raw);
946
1463
  const wave = data?.wave ?? '?';
947
1464
  const role = data?.role ?? '?';
948
1465
  return `${sessionId}|w${wave}|${role}`;
949
- } catch { /* try next location */ }
1466
+ } catch { /* corrupt / unreadable own-or-unknown manifest → no wave */ }
950
1467
  }
951
1468
  return `${sessionId}|w?|?`;
952
1469
  }
953
1470
 
1471
+ /**
1472
+ * The session id this dispatch is keyed under — the payload's own, never a
1473
+ * lock or env read (§ 6 limit 10 of `docs/scope-collision-guard.md`).
1474
+ *
1475
+ * @param {object} input parsed PreToolUse payload
1476
+ * @returns {string}
1477
+ */
1478
+ function sessionIdOf(input) {
1479
+ return typeof input?.session_id === 'string' ? input.session_id : 'no-session';
1480
+ }
1481
+
1482
+ /**
1483
+ * The repo-relative paths of THIS session's ledger and its lock (#1493.3).
1484
+ * The session id is reduced to a path-safe token (`[A-Za-z0-9._-]`, capped at
1485
+ * `MAX_SESSION_TOKEN_CHARS`), so no id can name a file outside `.orchestrator/`;
1486
+ * an id with no usable character falls back to `no-session`.
1487
+ *
1488
+ * @param {string} sessionId
1489
+ * @returns {{ ledgerRel: string, lockRel: string }}
1490
+ */
1491
+ export function ledgerPathsFor(sessionId) {
1492
+ const token = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, MAX_SESSION_TOKEN_CHARS);
1493
+ const safe = /[A-Za-z0-9]/.test(token) ? token : 'no-session';
1494
+ const base = path.join(LEDGER_DIR_REL, `${LEDGER_STEM}.${safe}`);
1495
+ return { ledgerRel: `${base}.json`, lockRel: `${base}.lock` };
1496
+ }
1497
+
1498
+ /**
1499
+ * Migration read of the pre-#1493 shared ledger: the parsed legacy ledger when
1500
+ * its `waveKey` belongs to THIS session (first `|` segment equals the id), else
1501
+ * `null`. An unparseable or foreign legacy file is not this session's state —
1502
+ * it is neither adopted nor reported as corruption (row 7 is about OUR file).
1503
+ *
1504
+ * @param {string|null} raw legacy file contents (`null` = absent)
1505
+ * @param {string} sessionId
1506
+ * @returns {object|null}
1507
+ */
1508
+ function adoptLegacyLedger(raw, sessionId) {
1509
+ if (typeof raw !== 'string') return null;
1510
+ try {
1511
+ const legacy = JSON.parse(raw);
1512
+ return legacy !== null && typeof legacy === 'object' && typeof legacy.waveKey === 'string'
1513
+ && legacy.waveKey.startsWith(`${sessionId}|`)
1514
+ ? legacy
1515
+ : null;
1516
+ } catch {
1517
+ return null;
1518
+ }
1519
+ }
1520
+
954
1521
  /**
955
1522
  * The wave NUMBER out of a `waveKeyOf()` key, for the telemetry record (#1092).
956
1523
  *
@@ -971,6 +1538,88 @@ function waveNumberOf(waveKey) {
971
1538
  return Number.isSafeInteger(n) && n > 0 ? n : null;
972
1539
  }
973
1540
 
1541
+ /** Thrown instead of spawning once the shared git deadline has passed. */
1542
+ class GitBudgetExhausted extends Error {
1543
+ /** @param {string} subcommand */
1544
+ constructor(subcommand) {
1545
+ super(`git ${subcommand}: shared git budget (${GIT_BUDGET_MS} ms) exhausted`);
1546
+ this.name = 'GitBudgetExhausted';
1547
+ }
1548
+ }
1549
+
1550
+ /**
1551
+ * A git runner bound to ONE deadline shared by every call of this hook fire
1552
+ * (see `GIT_BUDGET_MS`). Each call gets `timeout` = what is left; once nothing
1553
+ * is left it throws without spawning. `exhausted()` turns sticky the moment a
1554
+ * call is refused or timed out, so a caller can tell "git said no" from "git
1555
+ * never got to answer" — the second is a skip, never evidence (§ matrix rows 8,
1556
+ * 15). `SIGKILL`, not the default SIGTERM: a child that ignores SIGTERM keeps
1557
+ * `execFileSync` blocked past its timeout. Safe here — every call is read-only
1558
+ * plumbing that takes no index lock (§ PSA).
1559
+ *
1560
+ * @param {number} deadline — epoch ms
1561
+ * @returns {{run: (args: string[], cwd: string, opts?: {maxBuffer?: number}) => string,
1562
+ * exhausted: () => boolean}}
1563
+ */
1564
+ function makeGitRunner(deadline) {
1565
+ let exhausted = false;
1566
+ const run = (args, cwd, { maxBuffer } = {}) => {
1567
+ const remaining = Math.floor(deadline - Date.now());
1568
+ if (remaining <= 0) {
1569
+ exhausted = true;
1570
+ throw new GitBudgetExhausted(args[0]);
1571
+ }
1572
+ try {
1573
+ return execFileSync('git', args, {
1574
+ cwd,
1575
+ encoding: 'utf8',
1576
+ stdio: ['ignore', 'pipe', 'ignore'],
1577
+ timeout: remaining,
1578
+ killSignal: 'SIGKILL',
1579
+ ...(maxBuffer === undefined ? {} : { maxBuffer }),
1580
+ });
1581
+ } catch (e) {
1582
+ if (e?.code === 'ETIMEDOUT') exhausted = true;
1583
+ throw e;
1584
+ }
1585
+ };
1586
+ return { run, exhausted: () => exhausted };
1587
+ }
1588
+
1589
+ /**
1590
+ * `git rev-parse --show-toplevel` of `cwd`, or `''` when git cannot say.
1591
+ *
1592
+ * @param {string} cwd
1593
+ * @param {ReturnType<typeof makeGitRunner>} git
1594
+ * @returns {string}
1595
+ */
1596
+ function gitToplevel(cwd, git) {
1597
+ try {
1598
+ return git.run(['rev-parse', '--show-toplevel'], cwd).trim();
1599
+ } catch {
1600
+ return '';
1601
+ }
1602
+ }
1603
+
1604
+ /**
1605
+ * Tracked files of the repo rooted at `root`, plus WHY the list is empty when
1606
+ * it had to be (matrix row 8 — degrade, never deny, but leave a reason).
1607
+ *
1608
+ * @param {string} root — a git toplevel, or `''` when none could be resolved
1609
+ * @param {ReturnType<typeof makeGitRunner>} git
1610
+ * @returns {{files: string[], skipped?: 'budget-exhausted'|'git-error'}}
1611
+ */
1612
+ function trackedFilesIn(root, git) {
1613
+ const failed = () => ({ files: [], skipped: git.exhausted() ? 'budget-exhausted' : 'git-error' });
1614
+ if (root === '') return failed();
1615
+ try {
1616
+ const stdout = git.run(['ls-files', '-z'], root, { maxBuffer: 32 * 1024 * 1024 });
1617
+ return { files: stdout.split('\0').filter((f) => f.length > 0) };
1618
+ } catch {
1619
+ return failed();
1620
+ }
1621
+ }
1622
+
974
1623
  /**
975
1624
  * Tracked files, for glob expansion inside `findScopeCollisions`. The library is
976
1625
  * pure and must not spawn — supplying this is precisely the hook's job.
@@ -984,82 +1633,181 @@ function waveNumberOf(waveKey) {
984
1633
  * That is the dangerous direction, because the hook is the last gate before the
985
1634
  * write.
986
1635
  *
1636
+ * Standalone entry with its own `GIT_BUDGET_MS`; `main()` composes the same two
1637
+ * steps under the deadline it shares with the worktree-base check.
1638
+ *
987
1639
  * @param {string} cwd
988
1640
  * @returns {string[]}
989
1641
  */
990
1642
  export function listTrackedFiles(cwd) {
991
- const opts = {
992
- cwd,
993
- encoding: 'utf8',
994
- maxBuffer: 32 * 1024 * 1024,
995
- stdio: ['ignore', 'pipe', 'ignore'],
996
- };
997
- try {
998
- const root = execFileSync('git', ['rev-parse', '--show-toplevel'], opts).trim();
999
- if (!root) return [];
1000
- const stdout = execFileSync('git', ['ls-files', '-z'], { ...opts, cwd: root });
1001
- return stdout.split('\0').filter((f) => f.length > 0);
1002
- } catch {
1003
- return [];
1004
- }
1643
+ const git = makeGitRunner(Date.now() + GIT_BUDGET_MS);
1644
+ return trackedFilesIn(gitToplevel(cwd, git), git).files;
1645
+ }
1646
+
1647
+ /**
1648
+ * The SETTINGS ROOT (#1485) — where `harnessBaseRef()` reads the project's
1649
+ * `.claude/settings{,.local}.json`: `$CLAUDE_PROJECT_DIR`, else the git toplevel
1650
+ * of `cwd`, else `cwd`. The launch dir FIRST, unlike `resolveSessionRoot()`: it is the
1651
+ * root the harness applies project settings from, and after entering a worktree
1652
+ * the gitignored `settings.local.json` exists only there.
1653
+ *
1654
+ * @param {string} cwd — the payload `cwd`
1655
+ * @param {string} cwdToplevel — `gitToplevel(cwd)`, `''` when git could not say
1656
+ * @returns {string}
1657
+ */
1658
+ function settingsRootOf(cwd, cwdToplevel) {
1659
+ return (process.env.CLAUDE_PROJECT_DIR || '').trim() || cwdToplevel || cwd;
1005
1660
  }
1006
1661
 
1007
1662
  // ---------------------------------------------------------------------------
1008
- // Stale worktree base (#1413) — a WARNING, never a decision
1663
+ // Stale worktree base (#1413 → #1485) — a MEASURED mismatch DENIES the dispatch
1009
1664
  //
1010
1665
  // `isolation: "worktree"` makes the HARNESS create `<repo>/.claude/worktrees/
1011
1666
  // agent-<hex>`; no code in this repo creates it and the `Agent` payload carries
1012
- // no base-ref field (the 147-dispatch key census in the module docblock). The
1013
- // base is the SESSION-START commit, and it does not follow a mid-session commit:
1014
- // measured 2026-09-19 (session s18), worktrees created 25 minutes AFTER commit
1015
- // `240efda6` still stood on its parent `8f15f77b`. A fix agent then silently
1016
- // edits the OLD code, and its test run is structurally red on top — `validate-
1017
- // plugin` is vitest's globalSetup and `check-guard-requires-parity.mjs` compares
1018
- // against `git show HEAD:` inside the stale worktree.
1667
+ // no base-ref field (the 147-dispatch key census in the module docblock). Which
1668
+ // commit it branches from is documented harness behaviour (code.claude.com/docs
1669
+ // /en/worktrees § "Choose the base branch", read 2026-10-02): "Subagent worktrees
1670
+ // use the same base branch as `--worktree`, so they branch from your repository's
1671
+ // default branch unless `worktree.baseRef` is set to `"head"`" — `"fresh"` (the
1672
+ // default) is `origin/HEAD`, `"head"` is the current local HEAD.
1673
+ //
1674
+ // That REPLACES the #1413 model ("the base is the session-start commit"): it fit
1675
+ // s18 only because that session ran on `main`, where origin/main WAS the start
1676
+ // commit. #1485 measured the general case on a feature branch — agent worktrees
1677
+ // stood on `main` (`0efb3e97`), six commits behind the branch HEAD (`47c49652`),
1678
+ // so the agents silently edited old code. Owner decision 2026-10-02, option (a):
1679
+ // check with `git merge-base --is-ancestor` before dispatch and stop on mismatch.
1019
1680
  //
1020
- // We have no lever on the base, so the only available fix is to be LOUD. This
1021
- // never touches the verdict: the two functions below are TOTAL (every failure
1022
- // resolves to silence), and the caller ignores their result for the decision.
1681
+ // ONLY A MEASURED MISMATCH DENIES (`is-ancestor` exit 1 in a full-history repo,
1682
+ // against a cached origin ref the harness will branch from as-is). Every
1683
+ // non-measurement — no origin/HEAD, a shallow clone, a cached ref the harness
1684
+ // refetches first (`stale-remote-ref`), a git failure, an exit code other than
1685
+ // 0/1, a throw — is `stale: null` + `skipped`, recorded and ALLOWED: unknown is
1686
+ // not mismatch, and this hook guards the dispatch path, where a wrong deny is a
1687
+ // session outage (§ the fail-open note at the bottom of this file).
1688
+ //
1689
+ // Where the hook's base and the harness's can still DIVERGE (Claude Code
1690
+ // 2.1.287 bundle, read 2026-10-02):
1691
+ // - a settings layer this hook cannot read (`harnessBaseRef()`) — either way,
1692
+ // wrong deny or missed mismatch, from any `base_ref_source`;
1693
+ // - no `refs/remotes/origin/HEAD` symref: the harness falls back to
1694
+ // `origin/main|master`, the hook skips `no-origin-head` — a missed mismatch
1695
+ // only, never a wrong deny (measured 2026-10-02: 0 of 28 local repos lack
1696
+ // the symref);
1697
+ // - a stale FETCH_HEAD whose refetch then FAILS (offline): the harness keeps
1698
+ // the cached ref, so the skipped deny would have been right — the price of
1699
+ // not knowing in advance whether a fetch will succeed.
1023
1700
  // ---------------------------------------------------------------------------
1024
1701
 
1702
+ /** `worktree.baseRef` values the docs define; anything else sets nothing here. */
1703
+ const BASE_REF_VALUES = new Set(['head', 'fresh']);
1704
+
1705
+ /**
1706
+ * The harness refetches origin before branching a `"fresh"` worktree when
1707
+ * FETCH_HEAD is older than this (Claude Code 2.1.287 bundle, read 2026-10-02:
1708
+ * `Date.now() - mtimeMs > 86400000`, then `git fetch origin <default>` with a
1709
+ * 5 s timeout). Revisit when the harness changes that constant — a mismatch
1710
+ * either skips a real deny or lets a refetched base be denied again.
1711
+ */
1712
+ const FETCH_REFRESH_MS = 24 * 60 * 60 * 1000;
1713
+
1714
+ /**
1715
+ * The harness's effective `worktree.baseRef`, as far as this hook can see it,
1716
+ * and WHICH layer decided it — read in the documented precedence order (code.
1717
+ * claude.com/docs/en/settings § "Settings precedence", read 2026-10-02): local
1718
+ * → project → user, the FIRST file that sets the key to a defined value wins.
1719
+ * No file sets it → `'fresh'` from `'default'`.
1720
+ *
1721
+ * `settingsRoot` is the SESSION ROOT, not the hook payload's `cwd`: that `cwd`
1722
+ * follows the session's `cd` (docs/en/hooks § "cwd follows Claude"), while the
1723
+ * project settings the harness applies belong to the root it started in.
1724
+ *
1725
+ * Read only on the rare `worktree` branch (14 of 147 measured dispatches carried
1726
+ * `isolation` at all), so the hot dispatch path never pays these reads.
1727
+ *
1728
+ * NAMED CEILING (BV-004): blind to the two layers ABOVE local — managed settings
1729
+ * and the `--settings` CLI flag. The error points BOTH ways: a `"head"` there
1730
+ * with no visible file setting the key reads as `default` → `fresh` → a WRONG
1731
+ * DENY when HEAD is ahead of origin/HEAD; a `"fresh"` there shadowing a visible
1732
+ * `"head"` → a MISSED mismatch. Not read on purpose: `--settings` reaches no
1733
+ * hook, and managed settings arrive through several delivery mechanisms (a
1734
+ * `managed-settings.json` in a system dir, MDM/OS policy, server-managed settings
1735
+ * from the claude.ai console, an embedding host's SDK option — docs settings
1736
+ * § "Managed settings"), combined by their own intra-tier rules; reading the one
1737
+ * file among them would turn an honest "invisible" into a partial view that
1738
+ * still decides. The wrong-deny population is countable instead (HR-105): a
1739
+ * hidden `"head"` with no visible file setting the key yields `decision:"deny"`
1740
+ * records with `base_ref_source:"default"`; a hidden `"head"` shadowed by a
1741
+ * visible `"fresh"` yields them under that file's source instead. Revisit if
1742
+ * such a deny is reported in a session whose `/status` names a managed or
1743
+ * `--settings` source carrying `worktree.baseRef`.
1744
+ *
1745
+ * @param {string} settingsRoot
1746
+ * @returns {{baseRef: 'head'|'fresh', source: 'local'|'project'|'user'|'default'}}
1747
+ */
1748
+ function harnessBaseRef(settingsRoot) {
1749
+ const userDir = (process.env.CLAUDE_CONFIG_DIR || '').trim() || path.join(homedir(), '.claude');
1750
+ for (const [source, file] of [
1751
+ ['local', path.join(settingsRoot, '.claude', 'settings.local.json')],
1752
+ ['project', path.join(settingsRoot, '.claude', 'settings.json')],
1753
+ ['user', path.join(userDir, 'settings.json')],
1754
+ ]) {
1755
+ try {
1756
+ const value = JSON.parse(readFileSync(file, 'utf8'))?.worktree?.baseRef;
1757
+ if (BASE_REF_VALUES.has(value)) return { baseRef: value, source };
1758
+ } catch { /* absent or unparseable — the next layer decides */ }
1759
+ }
1760
+ return { baseRef: 'fresh', source: 'default' };
1761
+ }
1762
+
1763
+ /** Where each `base_ref_source` lives, for the operator-facing deny text. */
1764
+ const BASE_REF_SOURCE_LABEL = {
1765
+ local: '.claude/settings.local.json',
1766
+ project: '.claude/settings.json',
1767
+ user: 'user settings.json',
1768
+ default: 'the default — no settings file this hook can read sets it',
1769
+ };
1770
+
1025
1771
  /**
1026
1772
  * Facts for the worktree-base check, as a DISCRIMINATED record with three
1027
1773
  * outcomes rather than two (#1424):
1028
1774
  *
1029
- * - `{stale: true|false, head, session_start_ref, subagent_type?}` — measured.
1775
+ * - `{stale: true|false, head, base, base_ref, base_ref_source, missing_commits?,
1776
+ * subagent_type?}` — measured. `stale` means "the worktree would MISS commits
1777
+ * of HEAD": `git merge-base --is-ancestor HEAD <base>` exited 1 in a repo
1778
+ * with full history and a FETCH_HEAD younger than `FETCH_REFRESH_MS` (so
1779
+ * the harness branches from that cached ref without refetching it first).
1780
+ * A base AHEAD of HEAD (origin moved on, HEAD contained)
1781
+ * is not stale — nothing of HEAD is lost. `base_ref_source` names the
1782
+ * settings layer that decided `base_ref` (`harnessBaseRef()`), so a wrong
1783
+ * deny from a layer this hook cannot read stays countable (HR-105).
1030
1784
  * - `{stale: null, skipped: <reason>}` — the dispatch WAS on the `worktree`
1031
1785
  * branch, but the question could not be answered honestly. Still emitted,
1032
- * because HR-105 needs the denominator: while every such case returned bare
1033
- * `null` and the caller emitted nothing, "the check never had anything to
1034
- * say" and "the check was silently disarmed" both read as zero records —
1035
- * precisely the shape this event exists to close.
1036
- * - `null` — `isolation !== 'worktree'`. SILENT by construction, and the one
1037
- * outcome that must stay so: `isolation` appeared in 14 of 147 measured
1038
- * dispatches, so recording the other ~90% would put the denominator on the
1039
- * whole hot dispatch path (HR-101).
1040
- *
1041
- * IDENTITY GATE (`.claude/rules/identity-and-locks.md`): `session-start-ref`
1042
- * lives in STATE.md, a SHARED working-copy artefact that routinely belongs to a
1043
- * peer session. It is read only when STATE.md's own `session-id` equals this
1044
- * process's raw id (hook payload `session_id`, else `CLAUDE_CODE_SESSION_ID`) —
1045
- * a process-local witness REPLACES the shared one, never unions with it. On
1046
- * mismatch, absence or any read failure this reports `stale: null`: a confident
1047
- * warning about somebody else's session is worse than no warning at all. The
1048
- * test runs PER CANDIDATE (`continue`, not `break`) — see the loop below.
1049
- *
1050
- * `parseStateMd` is imported DYNAMICALLY and only on the rare `worktree` branch
1051
- * (14 of 147 measured dispatches carried `isolation` at all): it is a leaf module
1052
- * with zero imports and already inside the committed hook-import closure, so
1053
- * reuse costs no new edge, and binding it late keeps a load failure catchable
1054
- * instead of disarming the guard at ESM link time (§ #993 late-bound deps).
1786
+ * because HR-105 needs the denominator, and never denied (unknown ≠ mismatch).
1787
+ * - `null` — `isolation !== 'worktree'`. SILENT by construction: recording the
1788
+ * other ~90% of dispatches would put the denominator on the hot path (HR-101).
1789
+ *
1790
+ * TWO DIRECTORIES, deliberately different: git measures in `cwd` (the payload's,
1791
+ * which follows the session's `cd` — git finds the enclosing repo from any
1792
+ * subdirectory, and inside an entered worktree `"head"` means THAT worktree's
1793
+ * HEAD); settings are read at `settingsRoot` (`settingsRootOf()` — the root the
1794
+ * harness applies project settings from, which is NOT where the record lands:
1795
+ * that is `resolveSessionRoot()`).
1796
+ *
1797
+ * Every git call draws on the hook fire's ONE shared deadline (`GIT_BUDGET_MS`).
1798
+ * A call the budget cut short is no measurement: each skip then reads
1799
+ * `git-budget`, whichever reason the cut call would otherwise have produced — a
1800
+ * timed-out `origin/HEAD` lookup is not evidence that there is none.
1055
1801
  *
1056
1802
  * @param {{tool_input?: unknown}} input — the raw hook payload
1057
- * @param {string} projectDir
1058
- * @param {string} sessionId — `input.session_id`, or `'no-session'`
1059
- * @returns {Promise<{head: string, session_start_ref: string, stale: boolean,
1060
- * subagent_type?: string}|{stale: null, skipped: string}|null>}
1803
+ * @param {string} cwd — the hook payload's `cwd`
1804
+ * @param {string} settingsRoot — `settingsRootOf()`
1805
+ * @param {ReturnType<typeof makeGitRunner>} gitRunner — the fire's shared runner
1806
+ * @returns {{head: string, base: string, base_ref: 'head'|'fresh',
1807
+ * base_ref_source: 'local'|'project'|'user'|'default', stale: boolean,
1808
+ * missing_commits?: number, subagent_type?: string}|{stale: null, skipped: string}|null}
1061
1809
  */
1062
- async function worktreeBaseFacts(input, projectDir, sessionId) {
1810
+ function worktreeBaseFacts(input, cwd, settingsRoot, gitRunner) {
1063
1811
  // The applicability gate sits OUTSIDE the try on purpose: everything below it
1064
1812
  // resolves to a `skipped` RECORD, so a throw here must not be able to mint one
1065
1813
  // for a dispatch that was never on the `worktree` branch at all.
@@ -1067,98 +1815,106 @@ async function worktreeBaseFacts(input, projectDir, sessionId) {
1067
1815
  if (toolInput === null || typeof toolInput !== 'object') return null;
1068
1816
  if (toolInput.isolation !== 'worktree') return null;
1069
1817
 
1818
+ /** @param {string} reason */
1819
+ const skip = (reason) => ({ stale: null, skipped: gitRunner.exhausted() ? 'git-budget' : reason });
1070
1820
  try {
1071
- const ownId = sessionId && sessionId !== 'no-session'
1072
- ? sessionId
1073
- : (process.env.CLAUDE_CODE_SESSION_ID || '');
1074
- if (!ownId) return { stale: null, skipped: 'no-own-id' };
1075
-
1076
- const { parseStateMd } = await import(
1077
- pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'state-md', 'yaml-parser.mjs')).href
1078
- );
1079
-
1080
- // #1424: the identity test lives INSIDE the loop. Breaking at the first
1081
- // PARSEABLE STATE.md and testing `session-id` afterwards meant a single
1082
- // left-over `.pi/STATE.md` — `.pi` sorts before `.claude` in
1083
- // STATE_DIR_CANDIDATES — disarmed the check for a session whose own
1084
- // `.claude/STATE.md` sat right there, readable and matching. `sawStateMd`
1085
- // keeps the two silences apart afterwards: "nothing to read" is a different
1086
- // fact from "read, and none of it was mine".
1087
- let frontmatter = null;
1088
- let sawStateMd = false;
1089
- for (const dir of STATE_DIR_CANDIDATES) {
1090
- try {
1091
- const parsed = parseStateMd(readFileSync(path.join(projectDir, dir, 'STATE.md'), 'utf8'));
1092
- if (!parsed?.frontmatter) continue;
1093
- sawStateMd = true;
1094
- if (parsed.frontmatter['session-id'] !== ownId) continue;
1095
- frontmatter = parsed.frontmatter;
1096
- break;
1097
- } catch { /* try the next state dir */ }
1098
- }
1099
- if (frontmatter === null) {
1100
- return { stale: null, skipped: sawStateMd ? 'identity-mismatch' : 'no-state-md' };
1101
- }
1102
-
1103
- const startRef = typeof frontmatter['session-start-ref'] === 'string'
1104
- ? frontmatter['session-start-ref'].trim()
1105
- : '';
1106
- if (startRef === '') return { stale: null, skipped: 'no-start-ref' };
1821
+ /** @param {string[]} args */
1822
+ const git = (args) => gitRunner.run(args, cwd).trim();
1107
1823
 
1108
1824
  let head = '';
1109
- try {
1110
- head = execFileSync('git', ['rev-parse', 'HEAD'], {
1111
- cwd: projectDir,
1112
- encoding: 'utf8',
1113
- stdio: ['ignore', 'pipe', 'ignore'],
1114
- }).trim();
1115
- } catch {
1116
- return { stale: null, skipped: 'git-error' };
1117
- }
1118
- if (head === '') return { stale: null, skipped: 'git-error' };
1825
+ try { head = git(['rev-parse', '--verify', 'HEAD^{commit}']); } catch { /* below */ }
1826
+ if (head === '') return skip('git-error');
1119
1827
 
1120
- const facts = { head, session_start_ref: startRef, stale: head !== startRef };
1828
+ const { baseRef, source } = harnessBaseRef(settingsRoot);
1829
+ const facts = { head, base_ref: baseRef, base_ref_source: source };
1121
1830
  if (typeof toolInput.subagent_type === 'string' && toolInput.subagent_type !== '') {
1122
1831
  facts.subagent_type = toolInput.subagent_type;
1123
1832
  }
1124
- return facts;
1833
+ // `"head"` inside a worktree resolves to THAT worktree's HEAD (docs, same
1834
+ // section) — the very `git rev-parse HEAD` above, run in `cwd`.
1835
+ if (baseRef === 'head') return { ...facts, base: head, stale: false };
1836
+
1837
+ // `"fresh"`: origin/HEAD. Not cached locally → the harness fetches, or
1838
+ // falls back to HEAD; neither outcome is knowable here → skip, not deny.
1839
+ let base = '';
1840
+ try { base = git(['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/HEAD^{commit}']); } catch { /* below */ }
1841
+ if (base === '') return skip('no-origin-head');
1842
+ if (base === head) return { ...facts, base, stale: false };
1843
+
1844
+ try {
1845
+ git(['merge-base', '--is-ancestor', head, base]);
1846
+ return { ...facts, base, stale: false };
1847
+ } catch (e) {
1848
+ // Exit 1 is the ONE candidate "no"; 128 (bad object), a timeout or a
1849
+ // signal is a non-measurement and must not accuse anybody.
1850
+ if (e?.status !== 1) return skip('git-error');
1851
+ }
1852
+ // ...and exit 1 is a MEASUREMENT only over full history. A shallow clone's
1853
+ // cut-off graph answers "no" for a HEAD that IS an ancestor (measured
1854
+ // 2026-10-02: `clone --depth 1` + `fetch --depth 1` → exit 1, the full source
1855
+ // → exit 0). Probed only here, on the rare "no": an exit 0, which no missing
1856
+ // history can fake, never pays for it. A failed probe cannot rule shallow
1857
+ // out, so it skips as well. ONE spawn answers both questions below (two
1858
+ // lines, in argument order) — the deny path is the one that must finish
1859
+ // inside the shared budget.
1860
+ let shallow = '';
1861
+ let fetchHeadPath = '';
1862
+ try {
1863
+ [shallow = '', fetchHeadPath = ''] = git(['rev-parse', '--is-shallow-repository', '--git-path', 'FETCH_HEAD'])
1864
+ .split('\n')
1865
+ .map((line) => line.trim());
1866
+ } catch { /* below */ }
1867
+ if (shallow !== 'false') return skip(shallow === 'true' ? 'shallow' : 'git-error');
1868
+
1869
+ // ...and only over a ref the harness will actually branch from. With
1870
+ // FETCH_HEAD older than `FETCH_REFRESH_MS` (or absent — the harness reads
1871
+ // that as mtime 0) it first fetches origin and branches from the FETCHED
1872
+ // tip, which may well contain HEAD by now; this hook never fetches (network
1873
+ // inside a 5 s PreToolUse budget), so the cached ref decides nothing here.
1874
+ let fetchedMs = 0;
1875
+ try { if (fetchHeadPath !== '') fetchedMs = statSync(path.resolve(cwd, fetchHeadPath)).mtimeMs; } catch { /* absent → stale */ }
1876
+ if (Date.now() - fetchedMs > FETCH_REFRESH_MS) return { stale: null, skipped: 'stale-remote-ref' };
1877
+
1878
+ let missing = Number.NaN;
1879
+ try { missing = Number.parseInt(git(['rev-list', '--count', `${base}..${head}`]), 10); } catch { /* optional */ }
1880
+ return { ...facts, base, stale: true, ...(Number.isInteger(missing) ? { missing_commits: missing } : {}) };
1125
1881
  } catch {
1126
- // Total by construction: no parser, an unreadable state dir, anything else.
1127
- // Still a RECORD rather than silence — the dispatch WAS on the `worktree`
1128
- // branch, and a broken probe that emits nothing is indistinguishable from a
1129
- // probe that had nothing to report (#1424 / HR-105).
1882
+ // Total by construction. Still a RECORD rather than silence (#1424 / HR-105).
1130
1883
  return { stale: null, skipped: 'probe-error' };
1131
1884
  }
1132
1885
  }
1133
1886
 
1134
1887
  /**
1135
- * The operator-facing warning. It names the ACTION, not just the condition —
1136
- * a warning the coordinator cannot act on is noise (HR-106).
1137
- *
1138
- * NO `⚠ ` PREFIX HERE: this text is handed to `emitWarn`, which prefixes it on
1139
- * both channels it writes (stderr and the `systemMessage` payload). Prefixing
1140
- * here too produced `⚠ ⚠ …`.
1141
- *
1142
- * @param {{head: string, session_start_ref: string}} facts
1143
- * @returns {string}
1888
+ * The deny reason + suggestion for a measured stale base. Names the ACTION, not
1889
+ * just the condition (HR-106), and the settings layer that chose `"fresh"`: an
1890
+ * operator whose `"head"` lives where this hook cannot read (managed settings,
1891
+ * `--settings`) then reads "the default" and knows the deny rests on that blind
1892
+ * spot. The reason stays ONE line: `emitDeny` lifts the first line into the
1893
+ * operator-visible `systemMessage` headline.
1894
+ *
1895
+ * @param {{head: string, base: string, base_ref_source?: string, missing_commits?: number}} facts
1896
+ * @returns {{reason: string, suggestion: string}}
1144
1897
  */
1145
- function staleWorktreeWarning(facts) {
1146
- const head = facts.head.slice(0, 12);
1147
- const base = facts.session_start_ref.slice(0, 12);
1148
- return [
1149
- `${HOOK_NAME}: STALE WORKTREE BASE (#1413) — this dispatch uses `
1150
- + `isolation: "worktree", but HEAD (${head}) has moved past this session's `
1151
- + `session-start-ref (${base}).`,
1152
- ' The harness bases a new agent worktree on the SESSION-START commit and offers',
1153
- ' no base-ref field, so the agent will silently get the OLDER code — and its test',
1154
- ' run is structurally red on top (check-guard-requires-parity compares against',
1155
- ' `git show HEAD:`, and validate-plugin is vitest\'s globalSetup).',
1156
- ' DO ONE OF: dispatch this wave IN-PLACE (omit `isolation`) — or verify the base',
1157
- ' before trusting the agent\'s result:',
1158
- ' git worktree list --porcelain | awk -v h="$(git rev-parse HEAD)" '
1159
- + '\'/^worktree /{w=$2} /^HEAD /{if (w ~ /\\.claude\\/worktrees\\/agent-/ && $2 != h) '
1160
- + 'print "STALE " substr($2,1,12) " " w}\'',
1161
- ].join('\n');
1898
+ function staleWorktreeDeny(facts) {
1899
+ const missing = Number.isInteger(facts.missing_commits)
1900
+ ? `${facts.missing_commits} commit(s)`
1901
+ : 'commits';
1902
+ const from = BASE_REF_SOURCE_LABEL[facts.base_ref_source] ?? BASE_REF_SOURCE_LABEL.default;
1903
+ return {
1904
+ reason:
1905
+ `${HOOK_NAME}: STALE WORKTREE BASE (#1485) — isolation: "worktree" would branch this `
1906
+ + `agent from origin/HEAD ${facts.base.slice(0, 12)} (worktree.baseRef "fresh" from ${from}), `
1907
+ + `which is missing ${missing} of HEAD ${facts.head.slice(0, 12)}; the agent would silently edit OLD code.`,
1908
+ suggestion: [
1909
+ 'DO ONE OF:',
1910
+ ' 1. re-dispatch IN-PLACE — omit `isolation` (isolation "none");',
1911
+ ' 2. set `"worktree": {"baseRef": "head"}` in .claude/settings.json so agent',
1912
+ ' worktrees branch from your local HEAD (code.claude.com/docs/en/worktrees) —',
1913
+ ' a higher layer setting "fresh" (local settings, --settings, managed) overrides it;',
1914
+ ' 3. get HEAD into the default branch on origin and fetch, then re-dispatch.',
1915
+ 'Uncommitted changes reach no worktree under any base — commit first or go in-place.',
1916
+ ].join('\n'),
1917
+ };
1162
1918
  }
1163
1919
 
1164
1920
  /**
@@ -1378,7 +2134,10 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1378
2134
  }
1379
2135
 
1380
2136
  const desc = agentDescOf(toolInput);
1381
- const self = { id, desc, files, at };
2137
+ // #1480 A: the exact dispatch id travels with the entry, so liveness is never
2138
+ // guessed from a description when the harness names the dispatch itself.
2139
+ const useId = exactUseId(input?.tool_use_id);
2140
+ const self = useId === '' ? { id, desc, files, at } : { id, desc, files, at, useId };
1382
2141
 
1383
2142
  // Row 7: ledger existed but was unparseable. Terminal warn — decided here and
1384
2143
  // returned, never emitted mid-flow. SELF-HEALING since the review: the verdict
@@ -1399,13 +2158,51 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1399
2158
  ? ledger.agents.filter((a) => a !== null && typeof a === 'object' && typeof a.id === 'string')
1400
2159
  : [];
1401
2160
 
1402
- // Row 10: same agent re-dispatched (a retry after a failed agent is legitimate).
1403
- // Replace its record instead of letting it collide with its own earlier self.
1404
- const others = prior.filter((a) => a.id !== id);
2161
+ // Row 10 (#1480 B4): a predecessor with the SAME agent id is no longer dropped
2162
+ // before the check. Dropping it unconditionally erased the claim of a still
2163
+ // RUNNING agent: [a,b] running, re-dispatched as [b,c], and a third agent on
2164
+ // `a` was allowed while the first run still wrote it. Now:
2165
+ // - the same `tool_use_id` → the same dispatch evaluated again → replaced;
2166
+ // - FINISHED (probe true) → pruned below, so a retry after a failed agent
2167
+ // stays allowed — a failed agent finishes via its `failed` carrier,
2168
+ // `is_error` or its report;
2169
+ // - both dispatches carry distinct exact ids and the predecessor is NOT
2170
+ // finished → an ordinary collision partner (row 11);
2171
+ // - otherwise no exact statement is possible → it stays as a CLAIM (a third
2172
+ // agent hitting only its scope still collides) but never collides with
2173
+ // this retry.
2174
+ // Named ceiling (BV-004): without exact ids a same-id retry cannot tell "my
2175
+ // earlier run is still going" from "it failed", so the retry is allowed while
2176
+ // the earlier claim is kept — the pre-#1480 behaviour for the retry itself,
2177
+ // and a claim a third agent can hit until the earlier run finishes or its TTL
2178
+ // lapses. Revisit-Trigger: a harness without `tool_use_id` in the PreToolUse
2179
+ // payload dispatches a measurable share of this repo's waves.
2180
+ // A predecessor wholly covered by this retry's scope, neither carrying an
2181
+ // exact id, adds nothing but ledger growth — both are resolved through the
2182
+ // same description and this entry is the younger one — so it is superseded.
2183
+ const superseded = (a) => a.id === id && (
2184
+ (useId !== '' && exactUseId(a.useId) === useId)
2185
+ || (useId === '' && exactUseId(a.useId) === '' && desc !== '' && a.desc === desc
2186
+ && Array.isArray(a.files) && a.files.every((f) => files.includes(f)))
2187
+ );
2188
+ const others = prior.filter((a) => !superseded(a));
2189
+
2190
+ // One comparison key per record, unique by construction: several records may
2191
+ // share an agent id now, and `findScopeCollisions` reports equal ids as
2192
+ // duplicates rather than comparing them. The readable id stays the key where
2193
+ // it is unique, so the deny text and every single-record wave are unchanged.
2194
+ const used = new Set([id]);
2195
+ const partners = others.map((entry) => {
2196
+ let key = entry.id;
2197
+ for (let n = 2; used.has(key); n++) key = `${entry.id} #${n}`;
2198
+ used.add(key);
2199
+ const selfClaim = entry.id === id && !(useId !== '' && exactUseId(entry.useId) !== '');
2200
+ return { key, entry, selfClaim };
2201
+ });
1405
2202
 
1406
2203
  const known = new Set(Array.isArray(knownFiles) ? knownFiles : []);
1407
2204
  const agentScopes = [
1408
- ...others.map((a) => ({ id: a.id, files: promoteDirEntries(a.files, known) })),
2205
+ ...partners.map((p) => ({ id: p.key, files: promoteDirEntries(p.entry.files, known) })),
1409
2206
  { id, files: promoteDirEntries(files, known) },
1410
2207
  ];
1411
2208
 
@@ -1453,28 +2250,35 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1453
2250
  // § Liveness — the review's HIGH finding. A collision with an agent that has
1454
2251
  // ALREADY FINISHED is a sequential repair pass, not a race. The probe is called
1455
2252
  // ONLY here, so the transcript is read only on the path that would deny.
1456
- const byId = new Map(others.map((a) => [a.id, a]));
2253
+ const byKey = new Map(partners.map((p) => [p.key, p]));
1457
2254
  const probe = typeof isFinished === 'function' ? isFinished : () => false;
1458
- const finishedIds = new Set();
2255
+ const finishedKeys = new Set();
1459
2256
  const live = [];
2257
+ let selfClaims = 0;
1460
2258
  for (const c of mine) {
1461
- const otherId = c.a === id ? c.b : c.a;
1462
- const entry = byId.get(otherId);
1463
- if (entry !== undefined && probe(entry)) {
1464
- finishedIds.add(otherId);
2259
+ const otherKey = c.a === id ? c.b : c.a;
2260
+ const partner = byKey.get(otherKey);
2261
+ if (partner !== undefined && probe(partner.entry)) {
2262
+ finishedKeys.add(otherKey);
2263
+ continue;
2264
+ }
2265
+ // Row 10: an unfinished same-id claim without exact ids is kept, not collided with.
2266
+ if (partner?.selfClaim === true) {
2267
+ selfClaims++;
1465
2268
  continue;
1466
2269
  }
1467
2270
  live.push(c);
1468
2271
  }
2272
+ const counted = mine.length - selfClaims;
1469
2273
 
1470
2274
  // Row 10a: every colliding prior agent has finished. Allow AND prune their
1471
2275
  // records — leaving them would make the NEXT repair pass pay the transcript
1472
2276
  // scan again for a question already answered.
1473
2277
  if (live.length === 0) {
1474
- const kept = others.filter((a) => !finishedIds.has(a.id));
2278
+ const kept = partners.filter((p) => !finishedKeys.has(p.key)).map((p) => p.entry);
1475
2279
  return {
1476
2280
  action: 'allow',
1477
- telemetry: telemetryFor('allow-finished', mine.length),
2281
+ telemetry: finishedKeys.size > 0 ? telemetryFor('allow-finished', counted) : telemetryFor('allow'),
1478
2282
  ledger: {
1479
2283
  waveKey,
1480
2284
  updated: at,
@@ -1487,13 +2291,15 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1487
2291
  // Row 11: the one case this hook exists for.
1488
2292
  const shown = live.slice(0, MAX_REPORTED_COLLISIONS);
1489
2293
  const lines = shown.map((c) => {
1490
- const other = c.a === id ? c.b : c.a;
2294
+ const otherKey = c.a === id ? c.b : c.a;
2295
+ const other = byKey.get(otherKey)?.entry.id ?? otherKey;
2296
+ const earlier = other === id ? ' (an earlier dispatch of the same agent)' : '';
1491
2297
  const ev = Array.isArray(c.evidence) ? c.evidence : [];
1492
2298
  const evShown = ev.slice(0, MAX_EVIDENCE_PER_COLLISION).map(clipPath).join(', ');
1493
2299
  const more = ev.length > MAX_EVIDENCE_PER_COLLISION
1494
2300
  ? ` (+${ev.length - MAX_EVIDENCE_PER_COLLISION} more)`
1495
2301
  : '';
1496
- return ` • "${id}" ↔ "${other}" [${c.kind}]: ${evShown}${more}`;
2302
+ return ` • "${id}" ↔ "${other}"${earlier} [${c.kind}]: ${evShown}${more}`;
1497
2303
  });
1498
2304
  const omitted = live.length > shown.length ? `\n (+${live.length - shown.length} further collisions)` : '';
1499
2305
 
@@ -1508,11 +2314,12 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1508
2314
  'Two agents editing one file at the same time race each other (PSA-002). ' +
1509
2315
  'Give the file exactly ONE owner in the wave plan, or wait for the named ' +
1510
2316
  `agent(s) to finish and re-dispatch — a finished agent no longer blocks. ` +
1511
- `If the ledger is stale, delete ${LEDGER_REL}.`;
2317
+ `If the ledger is stale, delete this session's ${ledgerPathsFor(sessionIdOf(input)).ledgerRel} ` +
2318
+ '— never another session\'s file beside it, which holds that session\'s live claims.';
1512
2319
 
1513
2320
  // Deliberately NOT persisting the ledger on deny: the dispatch did not happen,
1514
2321
  // so recording it would make the retry-after-fix look like a duplicate.
1515
- return { action: 'deny', telemetry: telemetryFor('deny', mine.length), reason, suggestion };
2322
+ return { action: 'deny', telemetry: telemetryFor('deny', counted), reason, suggestion };
1516
2323
  }
1517
2324
 
1518
2325
  // ---------------------------------------------------------------------------
@@ -1524,18 +2331,61 @@ async function main() {
1524
2331
  const input = await readStdin();
1525
2332
  if (!input) return emitAllow();
1526
2333
 
1527
- const projectDir = typeof input.cwd === 'string' && input.cwd !== ''
2334
+ // The payload `cwd` follows the session's `cd`; it names the repo git measures
2335
+ // in, and only through its git toplevel where this session's state lives
2336
+ // (`resolveSessionRoot()`) — a `cd sub` must not move the ledger.
2337
+ const cwd = typeof input.cwd === 'string' && input.cwd !== ''
1528
2338
  ? input.cwd
1529
2339
  : bannerProjectDir();
1530
- const sessionId = typeof input.session_id === 'string' ? input.session_id : 'no-session';
2340
+ const sessionId = sessionIdOf(input);
1531
2341
 
1532
2342
  // Cheap pre-check: skip all I/O for the overwhelmingly common non-dispatch call.
1533
2343
  if (input.tool_name !== DISPATCH_TOOL) return emitAllow();
1534
2344
 
1535
- const waveKey = waveKeyOf(projectDir, sessionId, readFileSync);
1536
- const knownFiles = listTrackedFiles(projectDir);
1537
- const isFinished = makeFinishedProbe({ transcriptPath: input.transcript_path });
1538
- const ledgerPath = path.join(projectDir, LEDGER_REL);
2345
+ // ONE git deadline for this whole fire (`GIT_BUDGET_MS`). Worst case 7 spawns
2346
+ // against it: the toplevel below, up to 5 in `worktreeBaseFacts` (HEAD,
2347
+ // origin/HEAD, is-ancestor, shallow+FETCH_HEAD, rev-list) and `ls-files`.
2348
+ const git = makeGitRunner(Date.now() + GIT_BUDGET_MS);
2349
+ const cwdToplevel = gitToplevel(cwd, git);
2350
+ const sessionRoot = resolveSessionRoot(cwd, cwdToplevel);
2351
+
2352
+ const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
2353
+ const waveKey = waveKeyOf(sessionRoot, sessionId, readFileSync, ownIds);
2354
+ // `selfUseId`: this dispatch's own tool_use may already stand in the transcript
2355
+ // and must not make a finished same-named predecessor look alive (#1480 A).
2356
+ const isFinished = makeFinishedProbe({ transcriptPath: input.transcript_path, selfUseId: input.tool_use_id });
2357
+ const { ledgerRel, lockRel } = ledgerPathsFor(sessionId);
2358
+ const ledgerPath = path.join(sessionRoot, ledgerRel);
2359
+
2360
+ // #1485 — the stale-worktree-base verdict, computed BEFORE the ledger cycle
2361
+ // (and outside the lock: its git spawns hold nobody up there). It never
2362
+ // reads the collision verdict and a collision DENY always wins; on its own it
2363
+ // denies ONLY a measured mismatch (`stale === true`). Every skip is allowed.
2364
+ // It must be known before the cycle because a dispatch this check will deny
2365
+ // never happens, so the cycle must not persist it: a phantom claim would make
2366
+ // the deny's own remedy — the same agent re-dispatched in place under a new
2367
+ // tool_use_id — collide with it for up to `IN_FLIGHT_TTL_MS`.
2368
+ //
2369
+ // It draws on the shared budget BEFORE the tracked-file listing: a missed
2370
+ // stale base has no later gate (matrix row 15), a missed listing only
2371
+ // degrades glob expansion, and three later gates still see a collision.
2372
+ const worktreeBase = worktreeBaseFacts(input, cwd, settingsRootOf(cwd, cwdToplevel), git);
2373
+ const staleDeny = worktreeBase?.stale === true ? staleWorktreeDeny(worktreeBase) : null;
2374
+
2375
+ // Listed at `cwd`'s toplevel ONLY: the declared paths are repo-relative to the
2376
+ // repo the agents edit, which is the one the session works in (after entering
2377
+ // a worktree: that worktree). Whenever git resolved it, it IS `sessionRoot` —
2378
+ // except inside a harness agent worktree, which `sessionRoot` lifts to its
2379
+ // parent (#1492) and which lists the same repo's files from its own HEAD, and
2380
+ // in a session launched in a repo SUBDIRECTORY, where `sessionRoot` is that
2381
+ // launch dir and the listing stays at the toplevel above it;
2382
+ // when git could not, `sessionRoot` falls back to a `.git` ancestor (git just
2383
+ // failed there), `$CLAUDE_PROJECT_DIR` or a bare `cwd` — another repo, or a
2384
+ // subdirectory from which `ls-files` answers subdir-relative (the review-MED
2385
+ // class above) — so the listing degrades (matrix row 8) instead of following
2386
+ // it there.
2387
+ const known = trackedFilesIn(cwdToplevel, git);
2388
+ const knownFiles = known.files;
1539
2389
 
1540
2390
  // The read-modify-write CYCLE, run under the ledger lock below. Everything
1541
2391
  // inside is synchronous and emits NOTHING — an emit here would `process.exit()`
@@ -1549,7 +2399,15 @@ async function main() {
1549
2399
  } catch (err) {
1550
2400
  // Absent ledger is the normal first-dispatch case, NOT corruption (row 7
1551
2401
  // must not fire on every wave's first agent).
1552
- if (err?.code !== 'ENOENT') ledgerCorrupt = true;
2402
+ if (err?.code !== 'ENOENT') {
2403
+ ledgerCorrupt = true;
2404
+ } else {
2405
+ // #1493.3 migration: claims this session recorded in the shared
2406
+ // pre-#1493 file before the split still bind. Read, never touched.
2407
+ let legacyRaw = null;
2408
+ try { legacyRaw = readFileSync(path.join(sessionRoot, LEGACY_LEDGER_REL), 'utf8'); } catch { /* absent */ }
2409
+ ledger = adoptLegacyLedger(legacyRaw, sessionId);
2410
+ }
1553
2411
  }
1554
2412
 
1555
2413
  const verdict = decide({
@@ -1562,7 +2420,9 @@ async function main() {
1562
2420
  isFinished,
1563
2421
  });
1564
2422
 
1565
- if (verdict.ledger) {
2423
+ // Not on a stale-base deny — the same rule as the collision deny path
2424
+ // (`decide()`, "Deliberately NOT persisting the ledger on deny").
2425
+ if (verdict.ledger && staleDeny === null) {
1566
2426
  try {
1567
2427
  writeJsonAtomicSync(ledgerPath, verdict.ledger);
1568
2428
  } catch {
@@ -1574,7 +2434,7 @@ async function main() {
1574
2434
  };
1575
2435
 
1576
2436
  let verdict;
1577
- const lockPath = path.join(projectDir, LEDGER_LOCK_REL);
2437
+ const lockPath = path.join(sessionRoot, lockRel);
1578
2438
  try {
1579
2439
  mkdirSync(path.dirname(lockPath), { recursive: true });
1580
2440
  } catch { /* the unlocked fallback below still works */ }
@@ -1607,72 +2467,75 @@ async function main() {
1607
2467
  // import is dynamic so a checkout without `scripts/lib/events.mjs` degrades to
1608
2468
  // silence instead of crashing (§ Import safety), and the catch guarantees a
1609
2469
  // failed write cannot change the verdict or the exit code.
2470
+ //
2471
+ // `decide()` never sees the stale-base check, so its `ledger_result` describes
2472
+ // the COLLISION verdict alone. When the stale base is what actually blocks the
2473
+ // dispatch (no collision deny beside it — that one keeps `deny`), the record
2474
+ // says so (#1489 Pkt 8): recorded as `allow`, a refused dispatch counted as a
2475
+ // dispatched one in every later query. The replaced value survives as
2476
+ // `collision_result`, so a `warn-ledger-corrupt` / `warn-not-evaluable` behind
2477
+ // a stale-base deny is not reported on stderr alone.
1610
2478
  if (verdict.telemetry) {
2479
+ const telemetry = {
2480
+ ...verdict.telemetry,
2481
+ ...(staleDeny !== null && verdict.action !== 'deny'
2482
+ ? { ledger_result: 'deny-stale-base', collision_result: verdict.telemetry.ledger_result }
2483
+ : {}),
2484
+ ...(known.skipped === undefined ? {} : { known_files_skipped: known.skipped }),
2485
+ };
1611
2486
  try {
1612
2487
  const { emitEvent, sessionAttribution } = await import(
1613
2488
  pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'events.mjs')).href
1614
2489
  );
1615
2490
  await emitEvent(
1616
2491
  SCOPE_EVENT,
1617
- { hook: HOOK_NAME, ...verdict.telemetry, ...sessionAttribution(projectDir) },
1618
- { repoRoot: projectDir }
2492
+ { hook: HOOK_NAME, ...telemetry, ...sessionAttribution(sessionRoot) },
2493
+ { repoRoot: sessionRoot }
1619
2494
  );
1620
2495
  } catch { /* observability is best-effort — it never blocks the decision */ }
1621
2496
  }
1622
2497
 
1623
- // #1413 — the stale-worktree-base warning, also awaited BEFORE the terminal
1624
- // emit for the same process.exit() reason as the block above. It reads
1625
- // `verdict` not at all: it can never turn an allow into a deny, and a git or
1626
- // STATE.md failure resolves to silence rather than to a wrong accusation.
1627
- //
1628
- // ROUTED TO THE OPERATOR, not to stderr alone (w3-5): under the exit-0
1629
- // PreToolUse protocol stderr goes nowhere — `io.mjs:545` calls it the "Debug
1630
- // channel … Invisible under exit 0" — so the first cut of this check announced
1631
- // the s18 incident class to a log and to nobody. The visible channel is the
1632
- // top-level `systemMessage`, i.e. `emitWarn`, which carries NO
1633
- // `permissionDecision` and therefore still means ALLOW (io.mjs § "which is
1634
- // precisely what keeps warn non-blocking").
1635
- //
1636
- // The note is CARRIED to the single terminal emit below rather than emitted
1637
- // here, because `emitWarn` calls `process.exit(0)` and never returns — warning
1638
- // inline would terminate the process before a collision DENY could be emitted,
1639
- // flipping a block into an allow (§ stdout discipline, the same reason
1640
- // `decide()` is pure).
2498
+ // #1485 — the stale-worktree-base record (verdict computed above, before the
2499
+ // cycle), awaited BEFORE the terminal emit for the same process.exit() reason
2500
+ // as the block above.
1641
2501
  //
1642
2502
  // `null` here means "not a worktree dispatch" and stays silent; ANY other
1643
2503
  // shape is emitted, including the `{stale: null, skipped: …}` non-measurements
1644
- // (#1424). The warning still fires on `stale === true` alone — a skipped probe
1645
- // accuses nobody.
1646
- const worktreeBase = await worktreeBaseFacts(input, projectDir, sessionId);
1647
- let staleNote = null;
2504
+ // (#1424), with this check's own `decision` folded into the SAME record
2505
+ // rather than a second event.
1648
2506
  if (worktreeBase !== null) {
1649
- if (worktreeBase.stale === true) staleNote = staleWorktreeWarning(worktreeBase);
1650
2507
  try {
1651
2508
  const { emitEvent, sessionAttribution } = await import(
1652
2509
  pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'events.mjs')).href
1653
2510
  );
1654
2511
  await emitEvent(
1655
2512
  WORKTREE_BASE_EVENT,
1656
- { hook: HOOK_NAME, ...worktreeBase, ...sessionAttribution(projectDir) },
1657
- { repoRoot: projectDir }
2513
+ {
2514
+ hook: HOOK_NAME,
2515
+ ...worktreeBase,
2516
+ decision: staleDeny === null ? 'allow' : 'deny',
2517
+ ...sessionAttribution(sessionRoot),
2518
+ },
2519
+ { repoRoot: sessionRoot }
1658
2520
  );
1659
2521
  } catch { /* observability is best-effort — it never blocks the decision */ }
1660
2522
  }
1661
2523
 
1662
- // ONE terminal emit, with the #1413 note folded in where a visible channel is
1663
- // free. On DENY the dispatch does not happen and `systemMessage` already
1664
- // carries the ⛔ headline, so the note stays on the debug channel — a stale
1665
- // base is moot for a dispatch that was just blocked.
2524
+ // ONE terminal emit. A collision DENY keeps its own reason — the dispatch is
2525
+ // blocked either way — and the stale base goes to the debug channel beside it.
1666
2526
  if (verdict.action === 'deny') {
1667
- if (staleNote !== null) {
1668
- try { console.error(`⚠ ${staleNote}`); } catch { /* stderr may be closed */ }
2527
+ if (staleDeny !== null) {
2528
+ try { console.error(`⚠ ${staleDeny.reason}`); } catch { /* stderr may be closed */ }
1669
2529
  }
1670
2530
  return emitDeny(verdict.reason, verdict.suggestion);
1671
2531
  }
1672
- if (verdict.action === 'warn') {
1673
- return emitWarn(staleNote === null ? verdict.note : `${verdict.note}\n\n${staleNote}`);
2532
+ if (staleDeny !== null) {
2533
+ if (verdict.action === 'warn') {
2534
+ try { console.error(`⚠ ${verdict.note}`); } catch { /* stderr may be closed */ }
2535
+ }
2536
+ return emitDeny(staleDeny.reason, staleDeny.suggestion);
1674
2537
  }
1675
- if (staleNote !== null) return emitWarn(staleNote);
2538
+ if (verdict.action === 'warn') return emitWarn(verdict.note);
1676
2539
  return emitAllow();
1677
2540
  }
1678
2541
 
@@ -1720,9 +2583,10 @@ if (invokedAsScript()) {
1720
2583
  // `enforce-scope`'s fail-closed handler — and the reason is the asymmetry
1721
2584
  // named in the matrix header: enforce-scope guards a WRITE (denying one
1722
2585
  // write is cheap), this guards the DISPATCH path (denying every dispatch
1723
- // is a session outage). The bug this could hide is caught downstream by
2586
+ // is a session outage). A hidden COLLISION is caught downstream by
1724
2587
  // `validate-wave-scope.mjs` and by `enforce-scope` at write time; a
1725
- // wrongly-denied dispatch is caught by nothing.
2588
+ // hidden #1485 stale base is not (matrix row 15) — the accepted cost of
2589
+ // the same asymmetry; a wrongly-denied dispatch is caught by nothing.
1726
2590
  // -------------------------------------------------------------------------
1727
2591
  try {
1728
2592
  await bootstrap();