session-orchestrator 5.4.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 (150) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/060-evolve.mdc +5 -5
  5. package/.cursor-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +95 -3
  7. package/README.md +11 -11
  8. package/docs/ci-setup.md +120 -23
  9. package/docs/components.md +7 -7
  10. package/docs/events-schema.md +17 -14
  11. package/docs/install.md +14 -2
  12. package/docs/migration-v4.md +24 -17
  13. package/docs/plugin-architecture-v3.md +1 -1
  14. package/docs/scope-collision-guard.md +15 -13
  15. package/docs/session-config-reference.md +7 -7
  16. package/docs/vault-docs-architecture.md +8 -4
  17. package/hooks/_lib/hook-import-set.json +17 -8
  18. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  19. package/hooks/config-protection.mjs +1 -1
  20. package/hooks/enforce-commands.mjs +28 -13
  21. package/hooks/enforce-scope.mjs +201 -23
  22. package/hooks/hooks-codex.json +1 -1
  23. package/hooks/hooks.json +1 -1
  24. package/hooks/on-session-start.mjs +4 -2
  25. package/hooks/on-stop.mjs +70 -170
  26. package/hooks/post-bash-write-verify.mjs +98 -17
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +32 -154
  29. package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +584 -207
  32. package/hooks/wave-scope-commit-guard.mjs +168 -72
  33. package/package.json +2 -6
  34. package/scripts/apply-session-learnings.mjs +35 -14
  35. package/scripts/archive-closed-prds.mjs +38 -11
  36. package/scripts/autopilot.mjs +8 -1
  37. package/scripts/backfill-abandoned-sessions.mjs +80 -5
  38. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  39. package/scripts/codex-install.mjs +3 -0
  40. package/scripts/cursor-install.mjs +46 -2
  41. package/scripts/emit-session.mjs +5 -3
  42. package/scripts/export-hw-learnings.mjs +18 -7
  43. package/scripts/fleet-checkin.mjs +26 -3
  44. package/scripts/lib/autopilot/loop.mjs +25 -10
  45. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  46. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  47. package/scripts/lib/common.mjs +41 -0
  48. package/scripts/lib/config/vault-integration.mjs +151 -16
  49. package/scripts/lib/config.mjs +5 -1
  50. package/scripts/lib/eval/engine.mjs +13 -0
  51. package/scripts/lib/events-retention-banner.mjs +393 -0
  52. package/scripts/lib/events-rotation.mjs +21 -6
  53. package/scripts/lib/events.mjs +51 -3
  54. package/scripts/lib/fleet-protocol.mjs +20 -2
  55. package/scripts/lib/hardening.mjs +1 -0
  56. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  57. package/scripts/lib/instruction-budget-guard.mjs +8 -0
  58. package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
  59. package/scripts/lib/learnings/io.mjs +125 -16
  60. package/scripts/lib/lock-reaper.mjs +38 -14
  61. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  62. package/scripts/lib/named-vault-resolver.mjs +48 -5
  63. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  64. package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
  65. package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
  66. package/scripts/lib/orphan-reaper/scan.mjs +4 -0
  67. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  68. package/scripts/lib/orphan-reaper.mjs +5 -1
  69. package/scripts/lib/platform.mjs +206 -2
  70. package/scripts/lib/plugin-update-banner.mjs +3 -1
  71. package/scripts/lib/process-group.mjs +83 -22
  72. package/scripts/lib/scope-baseline.mjs +116 -25
  73. package/scripts/lib/scope-echo.mjs +7 -0
  74. package/scripts/lib/scope-gate.mjs +194 -27
  75. package/scripts/lib/session-close-backfill.mjs +129 -4
  76. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  77. package/scripts/lib/session-lock-shape.mjs +6 -3
  78. package/scripts/lib/session-lock.mjs +320 -67
  79. package/scripts/lib/session-registry.mjs +41 -16
  80. package/scripts/lib/session-start-probes.mjs +242 -68
  81. package/scripts/lib/session-token-rollup.mjs +39 -34
  82. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  83. package/scripts/lib/tail-window.mjs +27 -3
  84. package/scripts/lib/telemetry/pricing.mjs +5 -2
  85. package/scripts/lib/telemetry/sync.mjs +17 -0
  86. package/scripts/lib/tests-src-ratio.mjs +77 -2
  87. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  88. package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
  89. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  90. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  91. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  92. package/scripts/lib/vault-mirror/process.mjs +350 -62
  93. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  94. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  95. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  96. package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
  97. package/scripts/lib/vault-status/board-writer.mjs +5 -1
  98. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  99. package/scripts/materialize-wave-scope.mjs +7 -4
  100. package/scripts/release.mjs +1 -0
  101. package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
  102. package/scripts/run-quality-gate.mjs +8 -5
  103. package/scripts/self-update.mjs +229 -0
  104. package/scripts/sweep-expired-learnings.mjs +560 -28
  105. package/scripts/validate-wave-scope.mjs +21 -2
  106. package/scripts/vault-backfill.mjs +50 -37
  107. package/scripts/vault-mirror.mjs +170 -33
  108. package/skills/_shared/fleet-protocol.md +74 -71
  109. package/skills/_shared/state-ownership.md +38 -14
  110. package/skills/autopilot/SKILL.md +1 -1
  111. package/skills/bootstrap/SKILL.md +5 -3
  112. package/skills/bootstrap/_shared-template.md +96 -1
  113. package/skills/bootstrap/fast-template.md +3 -0
  114. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  115. package/skills/bootstrap/standard-template.md +2 -0
  116. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  117. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  118. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  119. package/skills/docs-orchestrator/SKILL.md +12 -5
  120. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  121. package/skills/evolve/SKILL.md +11 -2
  122. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  123. package/skills/session-end/learning-patterns.md +3 -3
  124. package/skills/session-end/metrics-collection.md +1 -1
  125. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  126. package/skills/session-end/plan-verification.md +25 -9
  127. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  128. package/skills/session-end/session-metrics-write.md +45 -10
  129. package/skills/session-start/SKILL.md +3 -3
  130. package/skills/session-start/references/operations-contract.md +5 -3
  131. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  132. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  133. package/skills/vault-mirror/SKILL.md +21 -11
  134. package/skills/wave-executor/SKILL.md +1 -1
  135. package/skills/wave-executor/circuit-breaker.md +1 -1
  136. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  137. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  138. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  139. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  140. package/.agents/skills/navigator/SKILL.md +0 -22
  141. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  142. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  143. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  144. package/.cursor/commands/navigator.md +0 -15
  145. package/.cursor/skills/navigator/SKILL.md +0 -13
  146. package/pi/prompts/navigator.md +0 -12
  147. package/skills/navigator/SKILL.md +0 -193
  148. package/skills/navigator/references/account-check.sh +0 -87
  149. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  150. package/skills/navigator/references/slot-check.sh +0 -114
@@ -114,10 +114,14 @@
114
114
  *
115
115
  * A deny-capable hook on the DISPATCH path has an asymmetric blast radius: a
116
116
  * false positive blocks every agent of the session (the guard becomes a session
117
- * outage), while a false negative is a double-assignment that three later gates
118
- * still catch (`validate-wave-scope.mjs`, `enforce-scope.mjs` at write time, and
119
- * the W5 verification pass). Fail-closed is right for a WRITE guard; it is wrong
120
- * 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:
121
125
  *
122
126
  * | # | Condition | Decision | Why |
123
127
  * |---|----------------------------------------|---------------------|-----|
@@ -128,18 +132,21 @@
128
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" |
129
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 |
130
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` |
131
- * | 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 |
132
136
  * | 9 | `findScopeCollisions` → not evaluable | WARN + ALLOW | the library says "not evaluable". Denying on a verdict with no witness is an assertion without evidence |
133
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 |
134
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 |
135
- * |11 | collision with a prior agent still IN FLIGHT | **DENY** | the one case this hook exists for |
139
+ * |11 | collision with a prior agent still IN FLIGHT | **DENY** | the case this hook was built for (#1020) |
136
140
  * |12 | unexpected throw | ALLOW + stderr | as row 2 |
137
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 |
138
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) |
139
144
  *
140
145
  * Every row that reaches a verdict from `decide()` — 5–11 and 13–14 — also
141
146
  * leaves an event record (§ Observability), so "which row fired" is answerable
142
- * 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
143
150
  * was made), and neither do the two crash rows 2 and 12: a hook that fell over
144
151
  * cannot describe itself, which is precisely why the GUARD INACTIVE banner on
145
152
  * stderr is the signal there.
@@ -165,13 +172,21 @@
165
172
  * ## Observability — the ledger half of #1092
166
173
  *
167
174
  * Every dispatch DECISION also appends one `orchestrator.wave_dispatch.scope_checked`
168
- * 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
169
181
  * exists is matrix rows 5/6: the no-signal ALLOW used to be byte-identical to
170
182
  * "the guard never ran", and the in-ledger counter added first is a WAVE tally —
171
183
  * it cannot say WHICH dispatch carried a scope. Payload: `wave` (omitted, never
172
184
  * `0`, when unknown), `agent_id`, `declared_path_count`, `injected`, `shape`,
173
- * `signal`, `ledger_result`, `collision_count`, `hook`, plus `session_id` /
174
- * `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.
175
190
  *
176
191
  * WHAT IT PROVES: this hook SAW (or did not see) a `FILE-SCOPE` declaration in
177
192
  * the prompt the coordinator handed to the dispatch tool, and what the guard
@@ -229,6 +244,7 @@ import path from 'node:path';
229
244
  import { pathToFileURL, fileURLToPath } from 'node:url';
230
245
  import { execFileSync } from 'node:child_process';
231
246
  import { mkdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
247
+ import { homedir } from 'node:os';
232
248
 
233
249
  import { shouldRunHook } from './_lib/profile-gate.mjs';
234
250
 
@@ -249,7 +265,11 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
249
265
  /** @type {typeof import('../scripts/lib/io.mjs').writeJsonAtomicSync} */ let writeJsonAtomicSync;
250
266
  /** @type {typeof import('../scripts/lib/file-lock.mjs').withFileLock} */ let withFileLock;
251
267
  /** @type {typeof import('../scripts/lib/scope-echo.mjs').scopeDigest} */ let scopeDigest;
268
+ /** @type {typeof import('../scripts/lib/platform.mjs').resolveSessionRoot} */ let resolveSessionRoot;
252
269
  let findScopeCollisions;
270
+ let findOwnScopeFile;
271
+ let readProcessLocalSessionIds;
272
+ let classifyManifestSession;
253
273
 
254
274
  const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
255
275
 
@@ -263,11 +283,43 @@ const HOOK_NAME = 'pre-task-scope-disjoint';
263
283
  */
264
284
  const DISPATCH_TOOL = 'Agent';
265
285
 
266
- /** Ledger location, relative to the project dir. */
267
- 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';
304
+
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`);
268
313
 
269
- /** Mutex for the ledger's read-modify-write cycle — see § Ledger concurrency. */
270
- const LEDGER_LOCK_REL = path.join('.orchestrator', 'wave-dispatch-scopes.lock');
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;
271
323
 
272
324
  /**
273
325
  * Ledger-lock budget. Short on purpose: the whole locked region is a read, a
@@ -278,6 +330,28 @@ const LEDGER_LOCK_REL = path.join('.orchestrator', 'wave-dispatch-scopes.lock');
278
330
  const LEDGER_LOCK_TIMEOUT_MS = 2000;
279
331
  const LEDGER_LOCK_POLL_MS = 25;
280
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
+
281
355
  /** Payload bounds — see § stdout discipline. */
282
356
  const MAX_REPORTED_COLLISIONS = 5;
283
357
  const MAX_EVIDENCE_PER_COLLISION = 4;
@@ -307,14 +381,14 @@ const SHAPE_NONE = 'none';
307
381
  const SCOPE_EVENT = 'orchestrator.wave_dispatch.scope_checked';
308
382
 
309
383
  /**
310
- * The worktree-base observability record (#1413).
384
+ * The worktree-base observability record (#1413; carries the #1485 `decision`).
311
385
  *
312
386
  * Same `wave_dispatch` domain and same `_checked` verb as `SCOPE_EVENT`, and
313
387
  * written for EVERY `isolation: "worktree"` dispatch rather than only the
314
388
  * alarming one. That is HR-105 applied at the source: a numerator-only stream
315
- * cannot tell "genuinely rare" from "silently broken", and the firing rate this
316
- * warning must stay under is then unfalsifiable. With every outcome in one
317
- * 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`.
318
392
  *
319
393
  * #1424 widened "every outcome" from two to three: the `stale: true|false`
320
394
  * split was already covered, but each NON-measurement (peer STATE.md, no
@@ -326,9 +400,6 @@ const SCOPE_EVENT = 'orchestrator.wave_dispatch.scope_checked';
326
400
  */
327
401
  const WORKTREE_BASE_EVENT = 'orchestrator.wave_dispatch.worktree_base_checked';
328
402
 
329
- /** State-dir candidates, in the same order `waveKeyOf()` probes them. */
330
- const STATE_DIR_CANDIDATES = ['.pi', '.cursor', '.codex', '.claude'];
331
-
332
403
  /**
333
404
  * The receive-side instruction line `renderScopeEchoInstruction()` renders and
334
405
  * the coordinator appends immediately after the fenced block
@@ -486,6 +557,7 @@ async function bootstrap() {
486
557
  {
487
558
  io: { specifier: lib('io.mjs') },
488
559
  scopeGate: { specifier: lib('scope-gate.mjs') },
560
+ sessionIdentity: { specifier: lib('session-identity', 'own-session.mjs') },
489
561
  fileLock: { specifier: lib('file-lock.mjs') },
490
562
  // ONE normalization for the digest, shared with the receive side (#1092):
491
563
  // `scope-echo.mjs` is pure (stdlib + `crypto-digest-utils.mjs`) and its
@@ -495,6 +567,10 @@ async function bootstrap() {
495
567
  // names. Late-bound like every other repo module so a load failure
496
568
  // banners instead of disarming the guard silently (#993).
497
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') },
498
574
  },
499
575
  {
500
576
  hookName: HOOK_NAME,
@@ -505,9 +581,11 @@ async function bootstrap() {
505
581
  );
506
582
 
507
583
  ({ readStdin, emitAllow, emitDeny, emitWarn, writeJsonAtomicSync } = modules.io);
508
- ({ findScopeCollisions } = modules.scopeGate);
584
+ ({ findScopeCollisions, findOwnScopeFile } = modules.scopeGate);
585
+ ({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
509
586
  ({ withFileLock } = modules.fileLock);
510
587
  ({ scopeDigest } = modules.scopeEcho);
588
+ ({ resolveSessionRoot } = modules.platform);
511
589
  }
512
590
 
513
591
  // ---------------------------------------------------------------------------
@@ -1355,7 +1433,8 @@ export function makeFinishedProbe({
1355
1433
  /**
1356
1434
  * Identify the wave this dispatch belongs to. Derived from the coordinator's
1357
1435
  * own scope file so a wave transition resets the ledger without anyone having to
1358
- * 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.
1359
1438
  *
1360
1439
  * FALLBACK, stated honestly (review MED): with no readable `wave-scope.json` the
1361
1440
  * key degrades to `<session>|w?|?`, so the ledger spans the whole SESSION and a
@@ -1370,22 +1449,75 @@ export function makeFinishedProbe({
1370
1449
  * @param {string} projectDir
1371
1450
  * @param {string} sessionId
1372
1451
  * @param {(p: string, enc: string) => string} readFn — injected `readFileSync`
1373
- * (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.
1374
1454
  * @returns {string}
1375
1455
  */
1376
- export function waveKeyOf(projectDir, sessionId, readFn) {
1377
- 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) {
1378
1460
  try {
1379
- const raw = readFn(path.join(projectDir, dir, 'wave-scope.json'), 'utf8');
1461
+ const raw = readFn(scopeFile, 'utf8');
1380
1462
  const data = JSON.parse(raw);
1381
1463
  const wave = data?.wave ?? '?';
1382
1464
  const role = data?.role ?? '?';
1383
1465
  return `${sessionId}|w${wave}|${role}`;
1384
- } catch { /* try next location */ }
1466
+ } catch { /* corrupt / unreadable own-or-unknown manifest → no wave */ }
1385
1467
  }
1386
1468
  return `${sessionId}|w?|?`;
1387
1469
  }
1388
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
+
1389
1521
  /**
1390
1522
  * The wave NUMBER out of a `waveKeyOf()` key, for the telemetry record (#1092).
1391
1523
  *
@@ -1406,6 +1538,88 @@ function waveNumberOf(waveKey) {
1406
1538
  return Number.isSafeInteger(n) && n > 0 ? n : null;
1407
1539
  }
1408
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
+
1409
1623
  /**
1410
1624
  * Tracked files, for glob expansion inside `findScopeCollisions`. The library is
1411
1625
  * pure and must not spawn — supplying this is precisely the hook's job.
@@ -1419,82 +1633,181 @@ function waveNumberOf(waveKey) {
1419
1633
  * That is the dangerous direction, because the hook is the last gate before the
1420
1634
  * write.
1421
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
+ *
1422
1639
  * @param {string} cwd
1423
1640
  * @returns {string[]}
1424
1641
  */
1425
1642
  export function listTrackedFiles(cwd) {
1426
- const opts = {
1427
- cwd,
1428
- encoding: 'utf8',
1429
- maxBuffer: 32 * 1024 * 1024,
1430
- stdio: ['ignore', 'pipe', 'ignore'],
1431
- };
1432
- try {
1433
- const root = execFileSync('git', ['rev-parse', '--show-toplevel'], opts).trim();
1434
- if (!root) return [];
1435
- const stdout = execFileSync('git', ['ls-files', '-z'], { ...opts, cwd: root });
1436
- return stdout.split('\0').filter((f) => f.length > 0);
1437
- } catch {
1438
- return [];
1439
- }
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;
1440
1660
  }
1441
1661
 
1442
1662
  // ---------------------------------------------------------------------------
1443
- // Stale worktree base (#1413) — a WARNING, never a decision
1663
+ // Stale worktree base (#1413 → #1485) — a MEASURED mismatch DENIES the dispatch
1444
1664
  //
1445
1665
  // `isolation: "worktree"` makes the HARNESS create `<repo>/.claude/worktrees/
1446
1666
  // agent-<hex>`; no code in this repo creates it and the `Agent` payload carries
1447
- // no base-ref field (the 147-dispatch key census in the module docblock). The
1448
- // base is the SESSION-START commit, and it does not follow a mid-session commit:
1449
- // measured 2026-09-19 (session s18), worktrees created 25 minutes AFTER commit
1450
- // `240efda6` still stood on its parent `8f15f77b`. A fix agent then silently
1451
- // edits the OLD code, and its test run is structurally red on top — `validate-
1452
- // plugin` is vitest's globalSetup and `check-guard-requires-parity.mjs` compares
1453
- // 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.
1454
1673
  //
1455
- // We have no lever on the base, so the only available fix is to be LOUD. This
1456
- // never touches the verdict: the two functions below are TOTAL (every failure
1457
- // resolves to silence), and the caller ignores their result for the decision.
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.
1680
+ //
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.
1458
1700
  // ---------------------------------------------------------------------------
1459
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
+
1460
1771
  /**
1461
1772
  * Facts for the worktree-base check, as a DISCRIMINATED record with three
1462
1773
  * outcomes rather than two (#1424):
1463
1774
  *
1464
- * - `{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).
1465
1784
  * - `{stale: null, skipped: <reason>}` — the dispatch WAS on the `worktree`
1466
1785
  * branch, but the question could not be answered honestly. Still emitted,
1467
- * because HR-105 needs the denominator: while every such case returned bare
1468
- * `null` and the caller emitted nothing, "the check never had anything to
1469
- * say" and "the check was silently disarmed" both read as zero records —
1470
- * precisely the shape this event exists to close.
1471
- * - `null` — `isolation !== 'worktree'`. SILENT by construction, and the one
1472
- * outcome that must stay so: `isolation` appeared in 14 of 147 measured
1473
- * dispatches, so recording the other ~90% would put the denominator on the
1474
- * whole hot dispatch path (HR-101).
1475
- *
1476
- * IDENTITY GATE (`.claude/rules/identity-and-locks.md`): `session-start-ref`
1477
- * lives in STATE.md, a SHARED working-copy artefact that routinely belongs to a
1478
- * peer session. It is read only when STATE.md's own `session-id` equals this
1479
- * process's raw id (hook payload `session_id`, else `CLAUDE_CODE_SESSION_ID`) —
1480
- * a process-local witness REPLACES the shared one, never unions with it. On
1481
- * mismatch, absence or any read failure this reports `stale: null`: a confident
1482
- * warning about somebody else's session is worse than no warning at all. The
1483
- * test runs PER CANDIDATE (`continue`, not `break`) — see the loop below.
1484
- *
1485
- * `parseStateMd` is imported DYNAMICALLY and only on the rare `worktree` branch
1486
- * (14 of 147 measured dispatches carried `isolation` at all): it is a leaf module
1487
- * with zero imports and already inside the committed hook-import closure, so
1488
- * reuse costs no new edge, and binding it late keeps a load failure catchable
1489
- * 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.
1490
1801
  *
1491
1802
  * @param {{tool_input?: unknown}} input — the raw hook payload
1492
- * @param {string} projectDir
1493
- * @param {string} sessionId — `input.session_id`, or `'no-session'`
1494
- * @returns {Promise<{head: string, session_start_ref: string, stale: boolean,
1495
- * 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}
1496
1809
  */
1497
- async function worktreeBaseFacts(input, projectDir, sessionId) {
1810
+ function worktreeBaseFacts(input, cwd, settingsRoot, gitRunner) {
1498
1811
  // The applicability gate sits OUTSIDE the try on purpose: everything below it
1499
1812
  // resolves to a `skipped` RECORD, so a throw here must not be able to mint one
1500
1813
  // for a dispatch that was never on the `worktree` branch at all.
@@ -1502,98 +1815,106 @@ async function worktreeBaseFacts(input, projectDir, sessionId) {
1502
1815
  if (toolInput === null || typeof toolInput !== 'object') return null;
1503
1816
  if (toolInput.isolation !== 'worktree') return null;
1504
1817
 
1818
+ /** @param {string} reason */
1819
+ const skip = (reason) => ({ stale: null, skipped: gitRunner.exhausted() ? 'git-budget' : reason });
1505
1820
  try {
1506
- const ownId = sessionId && sessionId !== 'no-session'
1507
- ? sessionId
1508
- : (process.env.CLAUDE_CODE_SESSION_ID || '');
1509
- if (!ownId) return { stale: null, skipped: 'no-own-id' };
1510
-
1511
- const { parseStateMd } = await import(
1512
- pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'state-md', 'yaml-parser.mjs')).href
1513
- );
1514
-
1515
- // #1424: the identity test lives INSIDE the loop. Breaking at the first
1516
- // PARSEABLE STATE.md and testing `session-id` afterwards meant a single
1517
- // left-over `.pi/STATE.md` — `.pi` sorts before `.claude` in
1518
- // STATE_DIR_CANDIDATES — disarmed the check for a session whose own
1519
- // `.claude/STATE.md` sat right there, readable and matching. `sawStateMd`
1520
- // keeps the two silences apart afterwards: "nothing to read" is a different
1521
- // fact from "read, and none of it was mine".
1522
- let frontmatter = null;
1523
- let sawStateMd = false;
1524
- for (const dir of STATE_DIR_CANDIDATES) {
1525
- try {
1526
- const parsed = parseStateMd(readFileSync(path.join(projectDir, dir, 'STATE.md'), 'utf8'));
1527
- if (!parsed?.frontmatter) continue;
1528
- sawStateMd = true;
1529
- if (parsed.frontmatter['session-id'] !== ownId) continue;
1530
- frontmatter = parsed.frontmatter;
1531
- break;
1532
- } catch { /* try the next state dir */ }
1533
- }
1534
- if (frontmatter === null) {
1535
- return { stale: null, skipped: sawStateMd ? 'identity-mismatch' : 'no-state-md' };
1536
- }
1537
-
1538
- const startRef = typeof frontmatter['session-start-ref'] === 'string'
1539
- ? frontmatter['session-start-ref'].trim()
1540
- : '';
1541
- if (startRef === '') return { stale: null, skipped: 'no-start-ref' };
1821
+ /** @param {string[]} args */
1822
+ const git = (args) => gitRunner.run(args, cwd).trim();
1542
1823
 
1543
1824
  let head = '';
1544
- try {
1545
- head = execFileSync('git', ['rev-parse', 'HEAD'], {
1546
- cwd: projectDir,
1547
- encoding: 'utf8',
1548
- stdio: ['ignore', 'pipe', 'ignore'],
1549
- }).trim();
1550
- } catch {
1551
- return { stale: null, skipped: 'git-error' };
1552
- }
1553
- 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');
1554
1827
 
1555
- 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 };
1556
1830
  if (typeof toolInput.subagent_type === 'string' && toolInput.subagent_type !== '') {
1557
1831
  facts.subagent_type = toolInput.subagent_type;
1558
1832
  }
1559
- 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 } : {}) };
1560
1881
  } catch {
1561
- // Total by construction: no parser, an unreadable state dir, anything else.
1562
- // Still a RECORD rather than silence — the dispatch WAS on the `worktree`
1563
- // branch, and a broken probe that emits nothing is indistinguishable from a
1564
- // probe that had nothing to report (#1424 / HR-105).
1882
+ // Total by construction. Still a RECORD rather than silence (#1424 / HR-105).
1565
1883
  return { stale: null, skipped: 'probe-error' };
1566
1884
  }
1567
1885
  }
1568
1886
 
1569
1887
  /**
1570
- * The operator-facing warning. It names the ACTION, not just the condition —
1571
- * a warning the coordinator cannot act on is noise (HR-106).
1572
- *
1573
- * NO `⚠ ` PREFIX HERE: this text is handed to `emitWarn`, which prefixes it on
1574
- * both channels it writes (stderr and the `systemMessage` payload). Prefixing
1575
- * here too produced `⚠ ⚠ …`.
1576
- *
1577
- * @param {{head: string, session_start_ref: string}} facts
1578
- * @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}}
1579
1897
  */
1580
- function staleWorktreeWarning(facts) {
1581
- const head = facts.head.slice(0, 12);
1582
- const base = facts.session_start_ref.slice(0, 12);
1583
- return [
1584
- `${HOOK_NAME}: STALE WORKTREE BASE (#1413) — this dispatch uses `
1585
- + `isolation: "worktree", but HEAD (${head}) has moved past this session's `
1586
- + `session-start-ref (${base}).`,
1587
- ' The harness bases a new agent worktree on the SESSION-START commit and offers',
1588
- ' no base-ref field, so the agent will silently get the OLDER code — and its test',
1589
- ' run is structurally red on top (check-guard-requires-parity compares against',
1590
- ' `git show HEAD:`, and validate-plugin is vitest\'s globalSetup).',
1591
- ' DO ONE OF: dispatch this wave IN-PLACE (omit `isolation`) — or verify the base',
1592
- ' before trusting the agent\'s result:',
1593
- ' git worktree list --porcelain | awk -v h="$(git rev-parse HEAD)" '
1594
- + '\'/^worktree /{w=$2} /^HEAD /{if (w ~ /\\.claude\\/worktrees\\/agent-/ && $2 != h) '
1595
- + 'print "STALE " substr($2,1,12) " " w}\'',
1596
- ].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
+ };
1597
1918
  }
1598
1919
 
1599
1920
  /**
@@ -1993,7 +2314,8 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1993
2314
  'Two agents editing one file at the same time race each other (PSA-002). ' +
1994
2315
  'Give the file exactly ONE owner in the wave plan, or wait for the named ' +
1995
2316
  `agent(s) to finish and re-dispatch — a finished agent no longer blocks. ` +
1996
- `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.';
1997
2319
 
1998
2320
  // Deliberately NOT persisting the ledger on deny: the dispatch did not happen,
1999
2321
  // so recording it would make the retry-after-fix look like a duplicate.
@@ -2009,20 +2331,61 @@ async function main() {
2009
2331
  const input = await readStdin();
2010
2332
  if (!input) return emitAllow();
2011
2333
 
2012
- 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 !== ''
2013
2338
  ? input.cwd
2014
2339
  : bannerProjectDir();
2015
- const sessionId = typeof input.session_id === 'string' ? input.session_id : 'no-session';
2340
+ const sessionId = sessionIdOf(input);
2016
2341
 
2017
2342
  // Cheap pre-check: skip all I/O for the overwhelmingly common non-dispatch call.
2018
2343
  if (input.tool_name !== DISPATCH_TOOL) return emitAllow();
2019
2344
 
2020
- const waveKey = waveKeyOf(projectDir, sessionId, readFileSync);
2021
- const knownFiles = listTrackedFiles(projectDir);
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);
2022
2354
  // `selfUseId`: this dispatch's own tool_use may already stand in the transcript
2023
2355
  // and must not make a finished same-named predecessor look alive (#1480 A).
2024
2356
  const isFinished = makeFinishedProbe({ transcriptPath: input.transcript_path, selfUseId: input.tool_use_id });
2025
- const ledgerPath = path.join(projectDir, LEDGER_REL);
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;
2026
2389
 
2027
2390
  // The read-modify-write CYCLE, run under the ledger lock below. Everything
2028
2391
  // inside is synchronous and emits NOTHING — an emit here would `process.exit()`
@@ -2036,7 +2399,15 @@ async function main() {
2036
2399
  } catch (err) {
2037
2400
  // Absent ledger is the normal first-dispatch case, NOT corruption (row 7
2038
2401
  // must not fire on every wave's first agent).
2039
- 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
+ }
2040
2411
  }
2041
2412
 
2042
2413
  const verdict = decide({
@@ -2049,7 +2420,9 @@ async function main() {
2049
2420
  isFinished,
2050
2421
  });
2051
2422
 
2052
- 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) {
2053
2426
  try {
2054
2427
  writeJsonAtomicSync(ledgerPath, verdict.ledger);
2055
2428
  } catch {
@@ -2061,7 +2434,7 @@ async function main() {
2061
2434
  };
2062
2435
 
2063
2436
  let verdict;
2064
- const lockPath = path.join(projectDir, LEDGER_LOCK_REL);
2437
+ const lockPath = path.join(sessionRoot, lockRel);
2065
2438
  try {
2066
2439
  mkdirSync(path.dirname(lockPath), { recursive: true });
2067
2440
  } catch { /* the unlocked fallback below still works */ }
@@ -2094,72 +2467,75 @@ async function main() {
2094
2467
  // import is dynamic so a checkout without `scripts/lib/events.mjs` degrades to
2095
2468
  // silence instead of crashing (§ Import safety), and the catch guarantees a
2096
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.
2097
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
+ };
2098
2486
  try {
2099
2487
  const { emitEvent, sessionAttribution } = await import(
2100
2488
  pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'events.mjs')).href
2101
2489
  );
2102
2490
  await emitEvent(
2103
2491
  SCOPE_EVENT,
2104
- { hook: HOOK_NAME, ...verdict.telemetry, ...sessionAttribution(projectDir) },
2105
- { repoRoot: projectDir }
2492
+ { hook: HOOK_NAME, ...telemetry, ...sessionAttribution(sessionRoot) },
2493
+ { repoRoot: sessionRoot }
2106
2494
  );
2107
2495
  } catch { /* observability is best-effort — it never blocks the decision */ }
2108
2496
  }
2109
2497
 
2110
- // #1413 — the stale-worktree-base warning, also awaited BEFORE the terminal
2111
- // emit for the same process.exit() reason as the block above. It reads
2112
- // `verdict` not at all: it can never turn an allow into a deny, and a git or
2113
- // STATE.md failure resolves to silence rather than to a wrong accusation.
2114
- //
2115
- // ROUTED TO THE OPERATOR, not to stderr alone (w3-5): under the exit-0
2116
- // PreToolUse protocol stderr goes nowhere — `io.mjs:545` calls it the "Debug
2117
- // channel … Invisible under exit 0" — so the first cut of this check announced
2118
- // the s18 incident class to a log and to nobody. The visible channel is the
2119
- // top-level `systemMessage`, i.e. `emitWarn`, which carries NO
2120
- // `permissionDecision` and therefore still means ALLOW (io.mjs § "which is
2121
- // precisely what keeps warn non-blocking").
2122
- //
2123
- // The note is CARRIED to the single terminal emit below rather than emitted
2124
- // here, because `emitWarn` calls `process.exit(0)` and never returns — warning
2125
- // inline would terminate the process before a collision DENY could be emitted,
2126
- // flipping a block into an allow (§ stdout discipline, the same reason
2127
- // `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.
2128
2501
  //
2129
2502
  // `null` here means "not a worktree dispatch" and stays silent; ANY other
2130
2503
  // shape is emitted, including the `{stale: null, skipped: …}` non-measurements
2131
- // (#1424). The warning still fires on `stale === true` alone — a skipped probe
2132
- // accuses nobody.
2133
- const worktreeBase = await worktreeBaseFacts(input, projectDir, sessionId);
2134
- let staleNote = null;
2504
+ // (#1424), with this check's own `decision` folded into the SAME record
2505
+ // rather than a second event.
2135
2506
  if (worktreeBase !== null) {
2136
- if (worktreeBase.stale === true) staleNote = staleWorktreeWarning(worktreeBase);
2137
2507
  try {
2138
2508
  const { emitEvent, sessionAttribution } = await import(
2139
2509
  pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'events.mjs')).href
2140
2510
  );
2141
2511
  await emitEvent(
2142
2512
  WORKTREE_BASE_EVENT,
2143
- { hook: HOOK_NAME, ...worktreeBase, ...sessionAttribution(projectDir) },
2144
- { repoRoot: projectDir }
2513
+ {
2514
+ hook: HOOK_NAME,
2515
+ ...worktreeBase,
2516
+ decision: staleDeny === null ? 'allow' : 'deny',
2517
+ ...sessionAttribution(sessionRoot),
2518
+ },
2519
+ { repoRoot: sessionRoot }
2145
2520
  );
2146
2521
  } catch { /* observability is best-effort — it never blocks the decision */ }
2147
2522
  }
2148
2523
 
2149
- // ONE terminal emit, with the #1413 note folded in where a visible channel is
2150
- // free. On DENY the dispatch does not happen and `systemMessage` already
2151
- // carries the ⛔ headline, so the note stays on the debug channel — a stale
2152
- // 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.
2153
2526
  if (verdict.action === 'deny') {
2154
- if (staleNote !== null) {
2155
- try { console.error(`⚠ ${staleNote}`); } catch { /* stderr may be closed */ }
2527
+ if (staleDeny !== null) {
2528
+ try { console.error(`⚠ ${staleDeny.reason}`); } catch { /* stderr may be closed */ }
2156
2529
  }
2157
2530
  return emitDeny(verdict.reason, verdict.suggestion);
2158
2531
  }
2159
- if (verdict.action === 'warn') {
2160
- 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);
2161
2537
  }
2162
- if (staleNote !== null) return emitWarn(staleNote);
2538
+ if (verdict.action === 'warn') return emitWarn(verdict.note);
2163
2539
  return emitAllow();
2164
2540
  }
2165
2541
 
@@ -2207,9 +2583,10 @@ if (invokedAsScript()) {
2207
2583
  // `enforce-scope`'s fail-closed handler — and the reason is the asymmetry
2208
2584
  // named in the matrix header: enforce-scope guards a WRITE (denying one
2209
2585
  // write is cheap), this guards the DISPATCH path (denying every dispatch
2210
- // 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
2211
2587
  // `validate-wave-scope.mjs` and by `enforce-scope` at write time; a
2212
- // 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.
2213
2590
  // -------------------------------------------------------------------------
2214
2591
  try {
2215
2592
  await bootstrap();