session-orchestrator 5.4.0 → 5.6.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 (151) 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 +115 -3
  7. package/README.md +30 -15
  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 +19 -15
  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 +77 -171
  26. package/hooks/post-bash-write-verify.mjs +219 -24
  27. package/hooks/post-edit-validate.mjs +15 -5
  28. package/hooks/post-tool-batch-wave-signal.mjs +41 -156
  29. package/hooks/pre-bash-memory-propose-audit.mjs +28 -11
  30. package/hooks/pre-bash-staging-fence.mjs +29 -8
  31. package/hooks/pre-task-scope-disjoint.mjs +603 -241
  32. package/hooks/wave-scope-commit-guard.mjs +235 -73
  33. package/package.json +9 -8
  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 +201 -27
  75. package/scripts/lib/session-close-backfill.mjs +202 -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 +60 -6
  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/parallel-aware-auq.md +1 -1
  110. package/skills/_shared/state-ownership.md +38 -14
  111. package/skills/autopilot/SKILL.md +1 -1
  112. package/skills/bootstrap/SKILL.md +5 -3
  113. package/skills/bootstrap/_shared-template.md +96 -1
  114. package/skills/bootstrap/fast-template.md +3 -0
  115. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  116. package/skills/bootstrap/standard-template.md +2 -0
  117. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  118. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  119. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  120. package/skills/docs-orchestrator/SKILL.md +12 -5
  121. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  122. package/skills/evolve/SKILL.md +11 -2
  123. package/skills/evolve/references/evolve-analyze-mode.md +37 -17
  124. package/skills/session-end/learning-patterns.md +3 -3
  125. package/skills/session-end/metrics-collection.md +1 -1
  126. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  127. package/skills/session-end/plan-verification.md +25 -9
  128. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  129. package/skills/session-end/session-metrics-write.md +45 -10
  130. package/skills/session-start/SKILL.md +3 -3
  131. package/skills/session-start/references/operations-contract.md +5 -3
  132. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  133. package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
  134. package/skills/vault-mirror/SKILL.md +21 -11
  135. package/skills/wave-executor/SKILL.md +1 -1
  136. package/skills/wave-executor/circuit-breaker.md +1 -1
  137. package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
  138. package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
  139. package/skills/wave-executor/references/wave-loop-review.md +2 -2
  140. package/skills/wave-executor/references/wave-loop-scope-manifest.md +14 -6
  141. package/.agents/skills/navigator/SKILL.md +0 -22
  142. package/.agents/skills/navigator/agents/openai.yaml +0 -5
  143. package/.codex-plugin/skills/navigator/SKILL.md +0 -22
  144. package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
  145. package/.cursor/commands/navigator.md +0 -15
  146. package/.cursor/skills/navigator/SKILL.md +0 -13
  147. package/pi/prompts/navigator.md +0 -12
  148. package/skills/navigator/SKILL.md +0 -193
  149. package/skills/navigator/references/account-check.sh +0 -87
  150. package/skills/navigator/references/fleet-decisions.mjs +0 -231
  151. 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,12 @@ 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
+ /** @type {typeof import('../scripts/lib/scope-gate.mjs').scopeEntrySpelling} */ let scopeEntrySpelling;
271
+ let findOwnScopeFile;
272
+ let readProcessLocalSessionIds;
273
+ let classifyManifestSession;
253
274
 
254
275
  const PLUGIN_ROOT = path.resolve(import.meta.dirname, '..');
255
276
 
@@ -263,11 +284,43 @@ const HOOK_NAME = 'pre-task-scope-disjoint';
263
284
  */
264
285
  const DISPATCH_TOOL = 'Agent';
265
286
 
266
- /** Ledger location, relative to the project dir. */
267
- const LEDGER_REL = path.join('.orchestrator', 'wave-dispatch-scopes.json');
287
+ /**
288
+ * Ledger location (#1493.3): ONE FILE PER SESSION,
289
+ * `.orchestrator/wave-dispatch-scopes.<session>.json`, each with its own lock
290
+ * `.orchestrator/wave-dispatch-scopes.<session>.lock` (§ Ledger concurrency).
291
+ *
292
+ * Until #1493 every session of the working copy shared ONE file holding ONE
293
+ * `waveKey`, and `decide()` keeps prior agents only under the same key — whose
294
+ * first segment IS the session id. So cross-session claims were never compared
295
+ * (nothing is lost by splitting), but a peer session's dispatch reset the file
296
+ * and erased this session's live claims, and the deny's "delete the ledger"
297
+ * remedy erased the peer's. One file per session removes both.
298
+ *
299
+ * A flat sibling, not a directory, on purpose: bootstrap's `store-lock-ignore`
300
+ * step (#1495) ignores `.orchestrator/wave-dispatch-scopes.*` in consumer repos,
301
+ * which covers this name; a directory would surface untracked in each of them.
302
+ */
303
+ const LEDGER_DIR_REL = '.orchestrator';
304
+ const LEDGER_STEM = 'wave-dispatch-scopes';
305
+
306
+ /**
307
+ * The pre-#1493 single shared ledger. READ only, for migration: adopted when
308
+ * this session has no file yet AND its `waveKey` names this session; never
309
+ * written and never deleted — it may still be a peer's live state.
310
+ * Revisit-Trigger: remove this read one minor release after the split ships,
311
+ * when no session that recorded claims in the old file can still be running.
312
+ */
313
+ const LEGACY_LEDGER_REL = path.join(LEDGER_DIR_REL, `${LEDGER_STEM}.json`);
268
314
 
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');
315
+ /**
316
+ * Longest session token a ledger file name carries. Harness session ids are
317
+ * UUIDs (36 chars) and pass through unchanged; the cap only keeps a pathological
318
+ * id below the 255-byte file-name limit. Two ids that sanitise to ONE token
319
+ * share a file again — degraded to the pre-#1493 behaviour, never worse, since
320
+ * `waveKey` still carries the raw id. Revisit if a harness ships ids that are
321
+ * not path-safe.
322
+ */
323
+ const MAX_SESSION_TOKEN_CHARS = 128;
271
324
 
272
325
  /**
273
326
  * Ledger-lock budget. Short on purpose: the whole locked region is a read, a
@@ -278,6 +331,28 @@ const LEDGER_LOCK_REL = path.join('.orchestrator', 'wave-dispatch-scopes.lock');
278
331
  const LEDGER_LOCK_TIMEOUT_MS = 2000;
279
332
  const LEDGER_LOCK_POLL_MS = 25;
280
333
 
334
+ /**
335
+ * ONE deadline for ALL git work of one hook fire (#1489 Pkt 7). Every spawn
336
+ * gets at most what is LEFT of it, so the git work as a whole — never a single
337
+ * call — is what is bounded.
338
+ *
339
+ * The number is arithmetic, not taste: `hooks/hooks.json` gives this hook
340
+ * `"timeout": 5` (seconds) for the WHOLE process. The git work runs first; after
341
+ * it the ledger lock may wait up to `LEDGER_LOCK_TIMEOUT_MS` (2000). 5000 − 2000
342
+ * − 1500 leaves 1500 ms for node start, the late-bound imports, the transcript
343
+ * scan (≤ ~210 ms at the largest measured 70 MB) and the two awaited event
344
+ * appends — ~20× the 69 ms full path measured idle (§ Measured cost). The git
345
+ * work itself measured 27.8 ms together with lock, ledger and compare, so 1500
346
+ * binds only a git that hangs (a stuck lock file, a network-backed `HEAD`, a
347
+ * frozen filesystem). Before, each worktree call had its own 5 s and
348
+ * `listTrackedFiles` none: one hanging git outlived the harness, which killed
349
+ * the hook — a silent fail-open with no record of the lost verdict.
350
+ *
351
+ * Revisit-Trigger: a change of this hook's `timeout` in `hooks/hooks.json` or of
352
+ * `LEDGER_LOCK_TIMEOUT_MS` — the three numbers must keep summing below 5000.
353
+ */
354
+ const GIT_BUDGET_MS = 1500;
355
+
281
356
  /** Payload bounds — see § stdout discipline. */
282
357
  const MAX_REPORTED_COLLISIONS = 5;
283
358
  const MAX_EVIDENCE_PER_COLLISION = 4;
@@ -307,14 +382,14 @@ const SHAPE_NONE = 'none';
307
382
  const SCOPE_EVENT = 'orchestrator.wave_dispatch.scope_checked';
308
383
 
309
384
  /**
310
- * The worktree-base observability record (#1413).
385
+ * The worktree-base observability record (#1413; carries the #1485 `decision`).
311
386
  *
312
387
  * Same `wave_dispatch` domain and same `_checked` verb as `SCOPE_EVENT`, and
313
388
  * written for EVERY `isolation: "worktree"` dispatch rather than only the
314
389
  * 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`.
390
+ * cannot tell "genuinely rare" from "silently broken", and the firing rate of
391
+ * the #1485 deny is then unfalsifiable. With every outcome in one stream the
392
+ * rate is `decision:"deny" / all records of this name`.
318
393
  *
319
394
  * #1424 widened "every outcome" from two to three: the `stale: true|false`
320
395
  * split was already covered, but each NON-measurement (peer STATE.md, no
@@ -326,9 +401,6 @@ const SCOPE_EVENT = 'orchestrator.wave_dispatch.scope_checked';
326
401
  */
327
402
  const WORKTREE_BASE_EVENT = 'orchestrator.wave_dispatch.worktree_base_checked';
328
403
 
329
- /** State-dir candidates, in the same order `waveKeyOf()` probes them. */
330
- const STATE_DIR_CANDIDATES = ['.pi', '.cursor', '.codex', '.claude'];
331
-
332
404
  /**
333
405
  * The receive-side instruction line `renderScopeEchoInstruction()` renders and
334
406
  * the coordinator appends immediately after the fenced block
@@ -486,6 +558,7 @@ async function bootstrap() {
486
558
  {
487
559
  io: { specifier: lib('io.mjs') },
488
560
  scopeGate: { specifier: lib('scope-gate.mjs') },
561
+ sessionIdentity: { specifier: lib('session-identity', 'own-session.mjs') },
489
562
  fileLock: { specifier: lib('file-lock.mjs') },
490
563
  // ONE normalization for the digest, shared with the receive side (#1092):
491
564
  // `scope-echo.mjs` is pure (stdlib + `crypto-digest-utils.mjs`) and its
@@ -495,6 +568,10 @@ async function bootstrap() {
495
568
  // names. Late-bound like every other repo module so a load failure
496
569
  // banners instead of disarming the guard silently (#993).
497
570
  scopeEcho: { specifier: lib('scope-echo.mjs') },
571
+ // #1492: ONE session-root resolver for every hook that reads the
572
+ // session's control files — this ledger, and `wave-scope.json` in
573
+ // enforce-scope / enforce-commands / post-bash-write-verify.
574
+ platform: { specifier: lib('platform.mjs') },
498
575
  },
499
576
  {
500
577
  hookName: HOOK_NAME,
@@ -505,9 +582,11 @@ async function bootstrap() {
505
582
  );
506
583
 
507
584
  ({ readStdin, emitAllow, emitDeny, emitWarn, writeJsonAtomicSync } = modules.io);
508
- ({ findScopeCollisions } = modules.scopeGate);
585
+ ({ findScopeCollisions, findOwnScopeFile, scopeEntrySpelling } = modules.scopeGate);
586
+ ({ readProcessLocalSessionIds, classifyManifestSession } = modules.sessionIdentity);
509
587
  ({ withFileLock } = modules.fileLock);
510
588
  ({ scopeDigest } = modules.scopeEcho);
589
+ ({ resolveSessionRoot } = modules.platform);
511
590
  }
512
591
 
513
592
  // ---------------------------------------------------------------------------
@@ -661,36 +740,6 @@ function cleanScopeLine(line) {
661
740
  .trim();
662
741
  }
663
742
 
664
- /**
665
- * Canonicalise a scope entry's SPELLING so two agents writing the same file two
666
- * ways are not read as disjoint (review LOW). Measured before this existed:
667
- * `['./scripts/lib/foo.mjs']` vs `['scripts/lib/foo.mjs']` compared `ok: true`
668
- * — the hook extracts from PROSE, and `looksLikeRepoPath` admits a `./` prefix,
669
- * so both spellings reach the comparison verbatim.
670
- *
671
- * Purely syntactic and meaning-preserving: `./` prefixes, `/./` segments and
672
- * duplicated slashes are removed. A TRAILING slash is deliberately kept — it is
673
- * the directory-prefix operator of `pathMatchesPattern`, so stripping it would
674
- * silently narrow a scope. The `dir` ↔ `dir/` case is handled by
675
- * {@link promoteDirEntries}, which decides it on evidence rather than guessing.
676
- *
677
- * `scope-gate.mjs` is a hook-safe pure library and out of this change's scope,
678
- * so the normalisation lives on THIS side of the call, applied to both sides of
679
- * every comparison.
680
- *
681
- * @param {string} entry
682
- * @returns {string}
683
- */
684
- export function normalizeScopeEntry(entry) {
685
- if (typeof entry !== 'string') return '';
686
- let s = entry.trim();
687
- if (s === '') return '';
688
- s = s.replace(/\/{2,}/g, '/'); // `a//b` → `a/b`
689
- s = s.replace(/(?:^|\/)\.\//g, (m) => (m.startsWith('/') ? '/' : '')); // `./a`, `a/./b`
690
- while (s.startsWith('./')) s = s.slice(2);
691
- return s;
692
- }
693
-
694
743
  /**
695
744
  * Promote an entry that names a DIRECTORY to its `dir/` prefix form, on
696
745
  * evidence. `scripts/lib` and `scripts/lib/` are the same claim, but
@@ -737,7 +786,11 @@ function collectScopePaths(segments) {
737
786
  for (const raw of segments) {
738
787
  // A trailing sentence period is punctuation, never part of a path
739
788
  // (measured: "docs/events-schema.md." at the end of an inline declaration).
740
- const cleaned = normalizeScopeEntry(cleanScopeLine(raw).replace(/\.$/, ''));
789
+ // Lexical cleaning only (prose → path); the SPELLING rule is
790
+ // `scopeEntrySpelling` in scope-gate.mjs, applied in `decide()` (#1510 g).
791
+ // The second trim: a period stripped after `cleanScopeLine`'s trim can
792
+ // expose a space (`foo.mjs .`), which `looksLikeRepoPath` would reject.
793
+ const cleaned = cleanScopeLine(raw).replace(/\.$/, '').trim();
741
794
  if (!looksLikeRepoPath(cleaned)) continue;
742
795
  if (seen.has(cleaned)) continue;
743
796
  seen.add(cleaned);
@@ -828,7 +881,8 @@ export function extractScopeSignal(prompt) {
828
881
  * (and the tests that pin them) keep the original signature.
829
882
  *
830
883
  * @param {string} prompt
831
- * @returns {string[]} repo-relative paths/globs, normalised, deduped, order preserved
884
+ * @returns {string[]} repo-relative paths/globs as declared (lexically cleaned,
885
+ * deduped, order preserved) — the SPELLING rule runs in `decide()` (#1510 g)
832
886
  */
833
887
  export function extractScopeFromPrompt(prompt) {
834
888
  return extractScopeSignal(prompt).files;
@@ -1355,7 +1409,8 @@ export function makeFinishedProbe({
1355
1409
  /**
1356
1410
  * Identify the wave this dispatch belongs to. Derived from the coordinator's
1357
1411
  * own scope file so a wave transition resets the ledger without anyone having to
1358
- * remember to clear it.
1412
+ * remember to clear it. `findOwnScopeFile` skips provably foreign manifests;
1413
+ * unbound or corrupt manifests still decide under the ENFORCE contract.
1359
1414
  *
1360
1415
  * FALLBACK, stated honestly (review MED): with no readable `wave-scope.json` the
1361
1416
  * key degrades to `<session>|w?|?`, so the ledger spans the whole SESSION and a
@@ -1370,22 +1425,75 @@ export function makeFinishedProbe({
1370
1425
  * @param {string} projectDir
1371
1426
  * @param {string} sessionId
1372
1427
  * @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)
1428
+ * @param {Set<string>} [ownIds] — process-local ids; repo helpers are late-bound
1429
+ * by bootstrap, as for the other guard dependencies.
1374
1430
  * @returns {string}
1375
1431
  */
1376
- export function waveKeyOf(projectDir, sessionId, readFn) {
1377
- for (const dir of ['.pi', '.cursor', '.codex', '.claude']) {
1432
+ export function waveKeyOf(projectDir, sessionId, readFn,
1433
+ ownIds = new Set(readProcessLocalSessionIds({ hookInput: { session_id: sessionId } }))) {
1434
+ const { path: scopeFile } = findOwnScopeFile(projectDir, ownIds, classifyManifestSession);
1435
+ if (scopeFile) {
1378
1436
  try {
1379
- const raw = readFn(path.join(projectDir, dir, 'wave-scope.json'), 'utf8');
1437
+ const raw = readFn(scopeFile, 'utf8');
1380
1438
  const data = JSON.parse(raw);
1381
1439
  const wave = data?.wave ?? '?';
1382
1440
  const role = data?.role ?? '?';
1383
1441
  return `${sessionId}|w${wave}|${role}`;
1384
- } catch { /* try next location */ }
1442
+ } catch { /* corrupt / unreadable own-or-unknown manifest → no wave */ }
1385
1443
  }
1386
1444
  return `${sessionId}|w?|?`;
1387
1445
  }
1388
1446
 
1447
+ /**
1448
+ * The session id this dispatch is keyed under — the payload's own, never a
1449
+ * lock or env read (§ 6 limit 10 of `docs/scope-collision-guard.md`).
1450
+ *
1451
+ * @param {object} input parsed PreToolUse payload
1452
+ * @returns {string}
1453
+ */
1454
+ function sessionIdOf(input) {
1455
+ return typeof input?.session_id === 'string' ? input.session_id : 'no-session';
1456
+ }
1457
+
1458
+ /**
1459
+ * The repo-relative paths of THIS session's ledger and its lock (#1493.3).
1460
+ * The session id is reduced to a path-safe token (`[A-Za-z0-9._-]`, capped at
1461
+ * `MAX_SESSION_TOKEN_CHARS`), so no id can name a file outside `.orchestrator/`;
1462
+ * an id with no usable character falls back to `no-session`.
1463
+ *
1464
+ * @param {string} sessionId
1465
+ * @returns {{ ledgerRel: string, lockRel: string }}
1466
+ */
1467
+ export function ledgerPathsFor(sessionId) {
1468
+ const token = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, MAX_SESSION_TOKEN_CHARS);
1469
+ const safe = /[A-Za-z0-9]/.test(token) ? token : 'no-session';
1470
+ const base = path.join(LEDGER_DIR_REL, `${LEDGER_STEM}.${safe}`);
1471
+ return { ledgerRel: `${base}.json`, lockRel: `${base}.lock` };
1472
+ }
1473
+
1474
+ /**
1475
+ * Migration read of the pre-#1493 shared ledger: the parsed legacy ledger when
1476
+ * its `waveKey` belongs to THIS session (first `|` segment equals the id), else
1477
+ * `null`. An unparseable or foreign legacy file is not this session's state —
1478
+ * it is neither adopted nor reported as corruption (row 7 is about OUR file).
1479
+ *
1480
+ * @param {string|null} raw legacy file contents (`null` = absent)
1481
+ * @param {string} sessionId
1482
+ * @returns {object|null}
1483
+ */
1484
+ function adoptLegacyLedger(raw, sessionId) {
1485
+ if (typeof raw !== 'string') return null;
1486
+ try {
1487
+ const legacy = JSON.parse(raw);
1488
+ return legacy !== null && typeof legacy === 'object' && typeof legacy.waveKey === 'string'
1489
+ && legacy.waveKey.startsWith(`${sessionId}|`)
1490
+ ? legacy
1491
+ : null;
1492
+ } catch {
1493
+ return null;
1494
+ }
1495
+ }
1496
+
1389
1497
  /**
1390
1498
  * The wave NUMBER out of a `waveKeyOf()` key, for the telemetry record (#1092).
1391
1499
  *
@@ -1406,6 +1514,88 @@ function waveNumberOf(waveKey) {
1406
1514
  return Number.isSafeInteger(n) && n > 0 ? n : null;
1407
1515
  }
1408
1516
 
1517
+ /** Thrown instead of spawning once the shared git deadline has passed. */
1518
+ class GitBudgetExhausted extends Error {
1519
+ /** @param {string} subcommand */
1520
+ constructor(subcommand) {
1521
+ super(`git ${subcommand}: shared git budget (${GIT_BUDGET_MS} ms) exhausted`);
1522
+ this.name = 'GitBudgetExhausted';
1523
+ }
1524
+ }
1525
+
1526
+ /**
1527
+ * A git runner bound to ONE deadline shared by every call of this hook fire
1528
+ * (see `GIT_BUDGET_MS`). Each call gets `timeout` = what is left; once nothing
1529
+ * is left it throws without spawning. `exhausted()` turns sticky the moment a
1530
+ * call is refused or timed out, so a caller can tell "git said no" from "git
1531
+ * never got to answer" — the second is a skip, never evidence (§ matrix rows 8,
1532
+ * 15). `SIGKILL`, not the default SIGTERM: a child that ignores SIGTERM keeps
1533
+ * `execFileSync` blocked past its timeout. Safe here — every call is read-only
1534
+ * plumbing that takes no index lock (§ PSA).
1535
+ *
1536
+ * @param {number} deadline — epoch ms
1537
+ * @returns {{run: (args: string[], cwd: string, opts?: {maxBuffer?: number}) => string,
1538
+ * exhausted: () => boolean}}
1539
+ */
1540
+ function makeGitRunner(deadline) {
1541
+ let exhausted = false;
1542
+ const run = (args, cwd, { maxBuffer } = {}) => {
1543
+ const remaining = Math.floor(deadline - Date.now());
1544
+ if (remaining <= 0) {
1545
+ exhausted = true;
1546
+ throw new GitBudgetExhausted(args[0]);
1547
+ }
1548
+ try {
1549
+ return execFileSync('git', args, {
1550
+ cwd,
1551
+ encoding: 'utf8',
1552
+ stdio: ['ignore', 'pipe', 'ignore'],
1553
+ timeout: remaining,
1554
+ killSignal: 'SIGKILL',
1555
+ ...(maxBuffer === undefined ? {} : { maxBuffer }),
1556
+ });
1557
+ } catch (e) {
1558
+ if (e?.code === 'ETIMEDOUT') exhausted = true;
1559
+ throw e;
1560
+ }
1561
+ };
1562
+ return { run, exhausted: () => exhausted };
1563
+ }
1564
+
1565
+ /**
1566
+ * `git rev-parse --show-toplevel` of `cwd`, or `''` when git cannot say.
1567
+ *
1568
+ * @param {string} cwd
1569
+ * @param {ReturnType<typeof makeGitRunner>} git
1570
+ * @returns {string}
1571
+ */
1572
+ function gitToplevel(cwd, git) {
1573
+ try {
1574
+ return git.run(['rev-parse', '--show-toplevel'], cwd).trim();
1575
+ } catch {
1576
+ return '';
1577
+ }
1578
+ }
1579
+
1580
+ /**
1581
+ * Tracked files of the repo rooted at `root`, plus WHY the list is empty when
1582
+ * it had to be (matrix row 8 — degrade, never deny, but leave a reason).
1583
+ *
1584
+ * @param {string} root — a git toplevel, or `''` when none could be resolved
1585
+ * @param {ReturnType<typeof makeGitRunner>} git
1586
+ * @returns {{files: string[], skipped?: 'budget-exhausted'|'git-error'}}
1587
+ */
1588
+ function trackedFilesIn(root, git) {
1589
+ const failed = () => ({ files: [], skipped: git.exhausted() ? 'budget-exhausted' : 'git-error' });
1590
+ if (root === '') return failed();
1591
+ try {
1592
+ const stdout = git.run(['ls-files', '-z'], root, { maxBuffer: 32 * 1024 * 1024 });
1593
+ return { files: stdout.split('\0').filter((f) => f.length > 0) };
1594
+ } catch {
1595
+ return failed();
1596
+ }
1597
+ }
1598
+
1409
1599
  /**
1410
1600
  * Tracked files, for glob expansion inside `findScopeCollisions`. The library is
1411
1601
  * pure and must not spawn — supplying this is precisely the hook's job.
@@ -1419,82 +1609,181 @@ function waveNumberOf(waveKey) {
1419
1609
  * That is the dangerous direction, because the hook is the last gate before the
1420
1610
  * write.
1421
1611
  *
1612
+ * Standalone entry with its own `GIT_BUDGET_MS`; `main()` composes the same two
1613
+ * steps under the deadline it shares with the worktree-base check.
1614
+ *
1422
1615
  * @param {string} cwd
1423
1616
  * @returns {string[]}
1424
1617
  */
1425
1618
  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
- }
1619
+ const git = makeGitRunner(Date.now() + GIT_BUDGET_MS);
1620
+ return trackedFilesIn(gitToplevel(cwd, git), git).files;
1621
+ }
1622
+
1623
+ /**
1624
+ * The SETTINGS ROOT (#1485) — where `harnessBaseRef()` reads the project's
1625
+ * `.claude/settings{,.local}.json`: `$CLAUDE_PROJECT_DIR`, else the git toplevel
1626
+ * of `cwd`, else `cwd`. The launch dir FIRST, unlike `resolveSessionRoot()`: it is the
1627
+ * root the harness applies project settings from, and after entering a worktree
1628
+ * the gitignored `settings.local.json` exists only there.
1629
+ *
1630
+ * @param {string} cwd — the payload `cwd`
1631
+ * @param {string} cwdToplevel — `gitToplevel(cwd)`, `''` when git could not say
1632
+ * @returns {string}
1633
+ */
1634
+ function settingsRootOf(cwd, cwdToplevel) {
1635
+ return (process.env.CLAUDE_PROJECT_DIR || '').trim() || cwdToplevel || cwd;
1440
1636
  }
1441
1637
 
1442
1638
  // ---------------------------------------------------------------------------
1443
- // Stale worktree base (#1413) — a WARNING, never a decision
1639
+ // Stale worktree base (#1413 → #1485) — a MEASURED mismatch DENIES the dispatch
1444
1640
  //
1445
1641
  // `isolation: "worktree"` makes the HARNESS create `<repo>/.claude/worktrees/
1446
1642
  // 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.
1643
+ // no base-ref field (the 147-dispatch key census in the module docblock). Which
1644
+ // commit it branches from is documented harness behaviour (code.claude.com/docs
1645
+ // /en/worktrees § "Choose the base branch", read 2026-10-02): "Subagent worktrees
1646
+ // use the same base branch as `--worktree`, so they branch from your repository's
1647
+ // default branch unless `worktree.baseRef` is set to `"head"`" — `"fresh"` (the
1648
+ // default) is `origin/HEAD`, `"head"` is the current local HEAD.
1649
+ //
1650
+ // That REPLACES the #1413 model ("the base is the session-start commit"): it fit
1651
+ // s18 only because that session ran on `main`, where origin/main WAS the start
1652
+ // commit. #1485 measured the general case on a feature branch — agent worktrees
1653
+ // stood on `main` (`0efb3e97`), six commits behind the branch HEAD (`47c49652`),
1654
+ // so the agents silently edited old code. Owner decision 2026-10-02, option (a):
1655
+ // check with `git merge-base --is-ancestor` before dispatch and stop on mismatch.
1454
1656
  //
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.
1657
+ // ONLY A MEASURED MISMATCH DENIES (`is-ancestor` exit 1 in a full-history repo,
1658
+ // against a cached origin ref the harness will branch from as-is). Every
1659
+ // non-measurement — no origin/HEAD, a shallow clone, a cached ref the harness
1660
+ // refetches first (`stale-remote-ref`), a git failure, an exit code other than
1661
+ // 0/1, a throw — is `stale: null` + `skipped`, recorded and ALLOWED: unknown is
1662
+ // not mismatch, and this hook guards the dispatch path, where a wrong deny is a
1663
+ // session outage (§ the fail-open note at the bottom of this file).
1664
+ //
1665
+ // Where the hook's base and the harness's can still DIVERGE (Claude Code
1666
+ // 2.1.287 bundle, read 2026-10-02):
1667
+ // - a settings layer this hook cannot read (`harnessBaseRef()`) — either way,
1668
+ // wrong deny or missed mismatch, from any `base_ref_source`;
1669
+ // - no `refs/remotes/origin/HEAD` symref: the harness falls back to
1670
+ // `origin/main|master`, the hook skips `no-origin-head` — a missed mismatch
1671
+ // only, never a wrong deny (measured 2026-10-02: 0 of 28 local repos lack
1672
+ // the symref);
1673
+ // - a stale FETCH_HEAD whose refetch then FAILS (offline): the harness keeps
1674
+ // the cached ref, so the skipped deny would have been right — the price of
1675
+ // not knowing in advance whether a fetch will succeed.
1458
1676
  // ---------------------------------------------------------------------------
1459
1677
 
1678
+ /** `worktree.baseRef` values the docs define; anything else sets nothing here. */
1679
+ const BASE_REF_VALUES = new Set(['head', 'fresh']);
1680
+
1681
+ /**
1682
+ * The harness refetches origin before branching a `"fresh"` worktree when
1683
+ * FETCH_HEAD is older than this (Claude Code 2.1.287 bundle, read 2026-10-02:
1684
+ * `Date.now() - mtimeMs > 86400000`, then `git fetch origin <default>` with a
1685
+ * 5 s timeout). Revisit when the harness changes that constant — a mismatch
1686
+ * either skips a real deny or lets a refetched base be denied again.
1687
+ */
1688
+ const FETCH_REFRESH_MS = 24 * 60 * 60 * 1000;
1689
+
1690
+ /**
1691
+ * The harness's effective `worktree.baseRef`, as far as this hook can see it,
1692
+ * and WHICH layer decided it — read in the documented precedence order (code.
1693
+ * claude.com/docs/en/settings § "Settings precedence", read 2026-10-02): local
1694
+ * → project → user, the FIRST file that sets the key to a defined value wins.
1695
+ * No file sets it → `'fresh'` from `'default'`.
1696
+ *
1697
+ * `settingsRoot` is the SESSION ROOT, not the hook payload's `cwd`: that `cwd`
1698
+ * follows the session's `cd` (docs/en/hooks § "cwd follows Claude"), while the
1699
+ * project settings the harness applies belong to the root it started in.
1700
+ *
1701
+ * Read only on the rare `worktree` branch (14 of 147 measured dispatches carried
1702
+ * `isolation` at all), so the hot dispatch path never pays these reads.
1703
+ *
1704
+ * NAMED CEILING (BV-004): blind to the two layers ABOVE local — managed settings
1705
+ * and the `--settings` CLI flag. The error points BOTH ways: a `"head"` there
1706
+ * with no visible file setting the key reads as `default` → `fresh` → a WRONG
1707
+ * DENY when HEAD is ahead of origin/HEAD; a `"fresh"` there shadowing a visible
1708
+ * `"head"` → a MISSED mismatch. Not read on purpose: `--settings` reaches no
1709
+ * hook, and managed settings arrive through several delivery mechanisms (a
1710
+ * `managed-settings.json` in a system dir, MDM/OS policy, server-managed settings
1711
+ * from the claude.ai console, an embedding host's SDK option — docs settings
1712
+ * § "Managed settings"), combined by their own intra-tier rules; reading the one
1713
+ * file among them would turn an honest "invisible" into a partial view that
1714
+ * still decides. The wrong-deny population is countable instead (HR-105): a
1715
+ * hidden `"head"` with no visible file setting the key yields `decision:"deny"`
1716
+ * records with `base_ref_source:"default"`; a hidden `"head"` shadowed by a
1717
+ * visible `"fresh"` yields them under that file's source instead. Revisit if
1718
+ * such a deny is reported in a session whose `/status` names a managed or
1719
+ * `--settings` source carrying `worktree.baseRef`.
1720
+ *
1721
+ * @param {string} settingsRoot
1722
+ * @returns {{baseRef: 'head'|'fresh', source: 'local'|'project'|'user'|'default'}}
1723
+ */
1724
+ function harnessBaseRef(settingsRoot) {
1725
+ const userDir = (process.env.CLAUDE_CONFIG_DIR || '').trim() || path.join(homedir(), '.claude');
1726
+ for (const [source, file] of [
1727
+ ['local', path.join(settingsRoot, '.claude', 'settings.local.json')],
1728
+ ['project', path.join(settingsRoot, '.claude', 'settings.json')],
1729
+ ['user', path.join(userDir, 'settings.json')],
1730
+ ]) {
1731
+ try {
1732
+ const value = JSON.parse(readFileSync(file, 'utf8'))?.worktree?.baseRef;
1733
+ if (BASE_REF_VALUES.has(value)) return { baseRef: value, source };
1734
+ } catch { /* absent or unparseable — the next layer decides */ }
1735
+ }
1736
+ return { baseRef: 'fresh', source: 'default' };
1737
+ }
1738
+
1739
+ /** Where each `base_ref_source` lives, for the operator-facing deny text. */
1740
+ const BASE_REF_SOURCE_LABEL = {
1741
+ local: '.claude/settings.local.json',
1742
+ project: '.claude/settings.json',
1743
+ user: 'user settings.json',
1744
+ default: 'the default — no settings file this hook can read sets it',
1745
+ };
1746
+
1460
1747
  /**
1461
1748
  * Facts for the worktree-base check, as a DISCRIMINATED record with three
1462
1749
  * outcomes rather than two (#1424):
1463
1750
  *
1464
- * - `{stale: true|false, head, session_start_ref, subagent_type?}` — measured.
1751
+ * - `{stale: true|false, head, base, base_ref, base_ref_source, missing_commits?,
1752
+ * subagent_type?}` — measured. `stale` means "the worktree would MISS commits
1753
+ * of HEAD": `git merge-base --is-ancestor HEAD <base>` exited 1 in a repo
1754
+ * with full history and a FETCH_HEAD younger than `FETCH_REFRESH_MS` (so
1755
+ * the harness branches from that cached ref without refetching it first).
1756
+ * A base AHEAD of HEAD (origin moved on, HEAD contained)
1757
+ * is not stale — nothing of HEAD is lost. `base_ref_source` names the
1758
+ * settings layer that decided `base_ref` (`harnessBaseRef()`), so a wrong
1759
+ * deny from a layer this hook cannot read stays countable (HR-105).
1465
1760
  * - `{stale: null, skipped: <reason>}` — the dispatch WAS on the `worktree`
1466
1761
  * 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).
1762
+ * because HR-105 needs the denominator, and never denied (unknown ≠ mismatch).
1763
+ * - `null` — `isolation !== 'worktree'`. SILENT by construction: recording the
1764
+ * other ~90% of dispatches would put the denominator on the hot path (HR-101).
1765
+ *
1766
+ * TWO DIRECTORIES, deliberately different: git measures in `cwd` (the payload's,
1767
+ * which follows the session's `cd` — git finds the enclosing repo from any
1768
+ * subdirectory, and inside an entered worktree `"head"` means THAT worktree's
1769
+ * HEAD); settings are read at `settingsRoot` (`settingsRootOf()` — the root the
1770
+ * harness applies project settings from, which is NOT where the record lands:
1771
+ * that is `resolveSessionRoot()`).
1772
+ *
1773
+ * Every git call draws on the hook fire's ONE shared deadline (`GIT_BUDGET_MS`).
1774
+ * A call the budget cut short is no measurement: each skip then reads
1775
+ * `git-budget`, whichever reason the cut call would otherwise have produced — a
1776
+ * timed-out `origin/HEAD` lookup is not evidence that there is none.
1490
1777
  *
1491
1778
  * @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>}
1779
+ * @param {string} cwd — the hook payload's `cwd`
1780
+ * @param {string} settingsRoot — `settingsRootOf()`
1781
+ * @param {ReturnType<typeof makeGitRunner>} gitRunner — the fire's shared runner
1782
+ * @returns {{head: string, base: string, base_ref: 'head'|'fresh',
1783
+ * base_ref_source: 'local'|'project'|'user'|'default', stale: boolean,
1784
+ * missing_commits?: number, subagent_type?: string}|{stale: null, skipped: string}|null}
1496
1785
  */
1497
- async function worktreeBaseFacts(input, projectDir, sessionId) {
1786
+ function worktreeBaseFacts(input, cwd, settingsRoot, gitRunner) {
1498
1787
  // The applicability gate sits OUTSIDE the try on purpose: everything below it
1499
1788
  // resolves to a `skipped` RECORD, so a throw here must not be able to mint one
1500
1789
  // for a dispatch that was never on the `worktree` branch at all.
@@ -1502,98 +1791,106 @@ async function worktreeBaseFacts(input, projectDir, sessionId) {
1502
1791
  if (toolInput === null || typeof toolInput !== 'object') return null;
1503
1792
  if (toolInput.isolation !== 'worktree') return null;
1504
1793
 
1794
+ /** @param {string} reason */
1795
+ const skip = (reason) => ({ stale: null, skipped: gitRunner.exhausted() ? 'git-budget' : reason });
1505
1796
  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' };
1797
+ /** @param {string[]} args */
1798
+ const git = (args) => gitRunner.run(args, cwd).trim();
1542
1799
 
1543
1800
  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' };
1801
+ try { head = git(['rev-parse', '--verify', 'HEAD^{commit}']); } catch { /* below */ }
1802
+ if (head === '') return skip('git-error');
1554
1803
 
1555
- const facts = { head, session_start_ref: startRef, stale: head !== startRef };
1804
+ const { baseRef, source } = harnessBaseRef(settingsRoot);
1805
+ const facts = { head, base_ref: baseRef, base_ref_source: source };
1556
1806
  if (typeof toolInput.subagent_type === 'string' && toolInput.subagent_type !== '') {
1557
1807
  facts.subagent_type = toolInput.subagent_type;
1558
1808
  }
1559
- return facts;
1809
+ // `"head"` inside a worktree resolves to THAT worktree's HEAD (docs, same
1810
+ // section) — the very `git rev-parse HEAD` above, run in `cwd`.
1811
+ if (baseRef === 'head') return { ...facts, base: head, stale: false };
1812
+
1813
+ // `"fresh"`: origin/HEAD. Not cached locally → the harness fetches, or
1814
+ // falls back to HEAD; neither outcome is knowable here → skip, not deny.
1815
+ let base = '';
1816
+ try { base = git(['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/HEAD^{commit}']); } catch { /* below */ }
1817
+ if (base === '') return skip('no-origin-head');
1818
+ if (base === head) return { ...facts, base, stale: false };
1819
+
1820
+ try {
1821
+ git(['merge-base', '--is-ancestor', head, base]);
1822
+ return { ...facts, base, stale: false };
1823
+ } catch (e) {
1824
+ // Exit 1 is the ONE candidate "no"; 128 (bad object), a timeout or a
1825
+ // signal is a non-measurement and must not accuse anybody.
1826
+ if (e?.status !== 1) return skip('git-error');
1827
+ }
1828
+ // ...and exit 1 is a MEASUREMENT only over full history. A shallow clone's
1829
+ // cut-off graph answers "no" for a HEAD that IS an ancestor (measured
1830
+ // 2026-10-02: `clone --depth 1` + `fetch --depth 1` → exit 1, the full source
1831
+ // → exit 0). Probed only here, on the rare "no": an exit 0, which no missing
1832
+ // history can fake, never pays for it. A failed probe cannot rule shallow
1833
+ // out, so it skips as well. ONE spawn answers both questions below (two
1834
+ // lines, in argument order) — the deny path is the one that must finish
1835
+ // inside the shared budget.
1836
+ let shallow = '';
1837
+ let fetchHeadPath = '';
1838
+ try {
1839
+ [shallow = '', fetchHeadPath = ''] = git(['rev-parse', '--is-shallow-repository', '--git-path', 'FETCH_HEAD'])
1840
+ .split('\n')
1841
+ .map((line) => line.trim());
1842
+ } catch { /* below */ }
1843
+ if (shallow !== 'false') return skip(shallow === 'true' ? 'shallow' : 'git-error');
1844
+
1845
+ // ...and only over a ref the harness will actually branch from. With
1846
+ // FETCH_HEAD older than `FETCH_REFRESH_MS` (or absent — the harness reads
1847
+ // that as mtime 0) it first fetches origin and branches from the FETCHED
1848
+ // tip, which may well contain HEAD by now; this hook never fetches (network
1849
+ // inside a 5 s PreToolUse budget), so the cached ref decides nothing here.
1850
+ let fetchedMs = 0;
1851
+ try { if (fetchHeadPath !== '') fetchedMs = statSync(path.resolve(cwd, fetchHeadPath)).mtimeMs; } catch { /* absent → stale */ }
1852
+ if (Date.now() - fetchedMs > FETCH_REFRESH_MS) return { stale: null, skipped: 'stale-remote-ref' };
1853
+
1854
+ let missing = Number.NaN;
1855
+ try { missing = Number.parseInt(git(['rev-list', '--count', `${base}..${head}`]), 10); } catch { /* optional */ }
1856
+ return { ...facts, base, stale: true, ...(Number.isInteger(missing) ? { missing_commits: missing } : {}) };
1560
1857
  } 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).
1858
+ // Total by construction. Still a RECORD rather than silence (#1424 / HR-105).
1565
1859
  return { stale: null, skipped: 'probe-error' };
1566
1860
  }
1567
1861
  }
1568
1862
 
1569
1863
  /**
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}
1864
+ * The deny reason + suggestion for a measured stale base. Names the ACTION, not
1865
+ * just the condition (HR-106), and the settings layer that chose `"fresh"`: an
1866
+ * operator whose `"head"` lives where this hook cannot read (managed settings,
1867
+ * `--settings`) then reads "the default" and knows the deny rests on that blind
1868
+ * spot. The reason stays ONE line: `emitDeny` lifts the first line into the
1869
+ * operator-visible `systemMessage` headline.
1870
+ *
1871
+ * @param {{head: string, base: string, base_ref_source?: string, missing_commits?: number}} facts
1872
+ * @returns {{reason: string, suggestion: string}}
1579
1873
  */
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');
1874
+ function staleWorktreeDeny(facts) {
1875
+ const missing = Number.isInteger(facts.missing_commits)
1876
+ ? `${facts.missing_commits} commit(s)`
1877
+ : 'commits';
1878
+ const from = BASE_REF_SOURCE_LABEL[facts.base_ref_source] ?? BASE_REF_SOURCE_LABEL.default;
1879
+ return {
1880
+ reason:
1881
+ `${HOOK_NAME}: STALE WORKTREE BASE (#1485) — isolation: "worktree" would branch this `
1882
+ + `agent from origin/HEAD ${facts.base.slice(0, 12)} (worktree.baseRef "fresh" from ${from}), `
1883
+ + `which is missing ${missing} of HEAD ${facts.head.slice(0, 12)}; the agent would silently edit OLD code.`,
1884
+ suggestion: [
1885
+ 'DO ONE OF:',
1886
+ ' 1. re-dispatch IN-PLACE — omit `isolation` (isolation "none");',
1887
+ ' 2. set `"worktree": {"baseRef": "head"}` in .claude/settings.json so agent',
1888
+ ' worktrees branch from your local HEAD (code.claude.com/docs/en/worktrees) —',
1889
+ ' a higher layer setting "fresh" (local settings, --settings, managed) overrides it;',
1890
+ ' 3. get HEAD into the default branch on origin and fetch, then re-dispatch.',
1891
+ 'Uncommitted changes reach no worktree under any base — commit first or go in-place.',
1892
+ ].join('\n'),
1893
+ };
1597
1894
  }
1598
1895
 
1599
1896
  /**
@@ -1749,9 +2046,11 @@ export function scopeDigestFields(files, prompt, digestFn) {
1749
2046
  * @param {string} [params.nowIso] dispatch timestamp recorded on the entry
1750
2047
  * @param {(paths: string[]) => string} [params.digestFn] scope-digest function
1751
2048
  * (injected for testability; defaults to the late-bound `scopeDigest`)
2049
+ * @param {(entry: string) => string} [params.spellFn] scope-entry spelling rule
2050
+ * (injected for testability; defaults to the late-bound `scopeEntrySpelling`)
1752
2051
  * @returns {Verdict}
1753
2052
  */
1754
- export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, collide, isFinished, nowIso, digestFn }) {
2053
+ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, collide, isFinished, nowIso, digestFn, spellFn }) {
1755
2054
  const toolName = input?.tool_name;
1756
2055
  // Row 4: not our tool.
1757
2056
  if (toolName !== DISPATCH_TOOL) return { action: 'allow' };
@@ -1762,7 +2061,14 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1762
2061
  const at = typeof nowIso === 'string' ? nowIso : new Date().toISOString();
1763
2062
 
1764
2063
  const signal = extractScopeSignal(toolInput.prompt);
1765
- const files = signal.files;
2064
+ // #1510 g — ONE spelling rule, scope-gate's, applied before the ledger, the
2065
+ // digest and `promoteDirEntries` see an entry: `./scripts/lib` must reach the
2066
+ // dir-promotion as `scripts/lib`, or a bare-dir claim stays a literal and
2067
+ // compares disjoint with the files beneath it. Re-deduped on the spelling.
2068
+ // Unbound (a direct importer that never ran `bootstrap()`) ⇒ entries as
2069
+ // extracted; `findScopeCollisions` still spells both sides itself.
2070
+ const spell = typeof spellFn === 'function' ? spellFn : scopeEntrySpelling;
2071
+ const files = typeof spell === 'function' ? [...new Set(signal.files.map(spell))] : signal.files;
1766
2072
  const id = agentIdOf(toolInput);
1767
2073
  const priorAgents = (ledger !== null && ledger?.waveKey === waveKey && Array.isArray(ledger.agents))
1768
2074
  ? ledger.agents
@@ -1993,7 +2299,8 @@ export function decide({ input, ledger, ledgerCorrupt, waveKey, knownFiles, coll
1993
2299
  'Two agents editing one file at the same time race each other (PSA-002). ' +
1994
2300
  'Give the file exactly ONE owner in the wave plan, or wait for the named ' +
1995
2301
  `agent(s) to finish and re-dispatch — a finished agent no longer blocks. ` +
1996
- `If the ledger is stale, delete ${LEDGER_REL}.`;
2302
+ `If the ledger is stale, delete this session's ${ledgerPathsFor(sessionIdOf(input)).ledgerRel} ` +
2303
+ '— never another session\'s file beside it, which holds that session\'s live claims.';
1997
2304
 
1998
2305
  // Deliberately NOT persisting the ledger on deny: the dispatch did not happen,
1999
2306
  // so recording it would make the retry-after-fix look like a duplicate.
@@ -2009,20 +2316,61 @@ async function main() {
2009
2316
  const input = await readStdin();
2010
2317
  if (!input) return emitAllow();
2011
2318
 
2012
- const projectDir = typeof input.cwd === 'string' && input.cwd !== ''
2319
+ // The payload `cwd` follows the session's `cd`; it names the repo git measures
2320
+ // in, and only through its git toplevel where this session's state lives
2321
+ // (`resolveSessionRoot()`) — a `cd sub` must not move the ledger.
2322
+ const cwd = typeof input.cwd === 'string' && input.cwd !== ''
2013
2323
  ? input.cwd
2014
2324
  : bannerProjectDir();
2015
- const sessionId = typeof input.session_id === 'string' ? input.session_id : 'no-session';
2325
+ const sessionId = sessionIdOf(input);
2016
2326
 
2017
2327
  // Cheap pre-check: skip all I/O for the overwhelmingly common non-dispatch call.
2018
2328
  if (input.tool_name !== DISPATCH_TOOL) return emitAllow();
2019
2329
 
2020
- const waveKey = waveKeyOf(projectDir, sessionId, readFileSync);
2021
- const knownFiles = listTrackedFiles(projectDir);
2330
+ // ONE git deadline for this whole fire (`GIT_BUDGET_MS`). Worst case 7 spawns
2331
+ // against it: the toplevel below, up to 5 in `worktreeBaseFacts` (HEAD,
2332
+ // origin/HEAD, is-ancestor, shallow+FETCH_HEAD, rev-list) and `ls-files`.
2333
+ const git = makeGitRunner(Date.now() + GIT_BUDGET_MS);
2334
+ const cwdToplevel = gitToplevel(cwd, git);
2335
+ const sessionRoot = resolveSessionRoot(cwd, cwdToplevel);
2336
+
2337
+ const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
2338
+ const waveKey = waveKeyOf(sessionRoot, sessionId, readFileSync, ownIds);
2022
2339
  // `selfUseId`: this dispatch's own tool_use may already stand in the transcript
2023
2340
  // and must not make a finished same-named predecessor look alive (#1480 A).
2024
2341
  const isFinished = makeFinishedProbe({ transcriptPath: input.transcript_path, selfUseId: input.tool_use_id });
2025
- const ledgerPath = path.join(projectDir, LEDGER_REL);
2342
+ const { ledgerRel, lockRel } = ledgerPathsFor(sessionId);
2343
+ const ledgerPath = path.join(sessionRoot, ledgerRel);
2344
+
2345
+ // #1485 — the stale-worktree-base verdict, computed BEFORE the ledger cycle
2346
+ // (and outside the lock: its git spawns hold nobody up there). It never
2347
+ // reads the collision verdict and a collision DENY always wins; on its own it
2348
+ // denies ONLY a measured mismatch (`stale === true`). Every skip is allowed.
2349
+ // It must be known before the cycle because a dispatch this check will deny
2350
+ // never happens, so the cycle must not persist it: a phantom claim would make
2351
+ // the deny's own remedy — the same agent re-dispatched in place under a new
2352
+ // tool_use_id — collide with it for up to `IN_FLIGHT_TTL_MS`.
2353
+ //
2354
+ // It draws on the shared budget BEFORE the tracked-file listing: a missed
2355
+ // stale base has no later gate (matrix row 15), a missed listing only
2356
+ // degrades glob expansion, and three later gates still see a collision.
2357
+ const worktreeBase = worktreeBaseFacts(input, cwd, settingsRootOf(cwd, cwdToplevel), git);
2358
+ const staleDeny = worktreeBase?.stale === true ? staleWorktreeDeny(worktreeBase) : null;
2359
+
2360
+ // Listed at `cwd`'s toplevel ONLY: the declared paths are repo-relative to the
2361
+ // repo the agents edit, which is the one the session works in (after entering
2362
+ // a worktree: that worktree). Whenever git resolved it, it IS `sessionRoot` —
2363
+ // except inside a harness agent worktree, which `sessionRoot` lifts to its
2364
+ // parent (#1492) and which lists the same repo's files from its own HEAD, and
2365
+ // in a session launched in a repo SUBDIRECTORY, where `sessionRoot` is that
2366
+ // launch dir and the listing stays at the toplevel above it;
2367
+ // when git could not, `sessionRoot` falls back to a `.git` ancestor (git just
2368
+ // failed there), `$CLAUDE_PROJECT_DIR` or a bare `cwd` — another repo, or a
2369
+ // subdirectory from which `ls-files` answers subdir-relative (the review-MED
2370
+ // class above) — so the listing degrades (matrix row 8) instead of following
2371
+ // it there.
2372
+ const known = trackedFilesIn(cwdToplevel, git);
2373
+ const knownFiles = known.files;
2026
2374
 
2027
2375
  // The read-modify-write CYCLE, run under the ledger lock below. Everything
2028
2376
  // inside is synchronous and emits NOTHING — an emit here would `process.exit()`
@@ -2036,7 +2384,15 @@ async function main() {
2036
2384
  } catch (err) {
2037
2385
  // Absent ledger is the normal first-dispatch case, NOT corruption (row 7
2038
2386
  // must not fire on every wave's first agent).
2039
- if (err?.code !== 'ENOENT') ledgerCorrupt = true;
2387
+ if (err?.code !== 'ENOENT') {
2388
+ ledgerCorrupt = true;
2389
+ } else {
2390
+ // #1493.3 migration: claims this session recorded in the shared
2391
+ // pre-#1493 file before the split still bind. Read, never touched.
2392
+ let legacyRaw = null;
2393
+ try { legacyRaw = readFileSync(path.join(sessionRoot, LEGACY_LEDGER_REL), 'utf8'); } catch { /* absent */ }
2394
+ ledger = adoptLegacyLedger(legacyRaw, sessionId);
2395
+ }
2040
2396
  }
2041
2397
 
2042
2398
  const verdict = decide({
@@ -2049,7 +2405,9 @@ async function main() {
2049
2405
  isFinished,
2050
2406
  });
2051
2407
 
2052
- if (verdict.ledger) {
2408
+ // Not on a stale-base deny — the same rule as the collision deny path
2409
+ // (`decide()`, "Deliberately NOT persisting the ledger on deny").
2410
+ if (verdict.ledger && staleDeny === null) {
2053
2411
  try {
2054
2412
  writeJsonAtomicSync(ledgerPath, verdict.ledger);
2055
2413
  } catch {
@@ -2061,7 +2419,7 @@ async function main() {
2061
2419
  };
2062
2420
 
2063
2421
  let verdict;
2064
- const lockPath = path.join(projectDir, LEDGER_LOCK_REL);
2422
+ const lockPath = path.join(sessionRoot, lockRel);
2065
2423
  try {
2066
2424
  mkdirSync(path.dirname(lockPath), { recursive: true });
2067
2425
  } catch { /* the unlocked fallback below still works */ }
@@ -2094,72 +2452,75 @@ async function main() {
2094
2452
  // import is dynamic so a checkout without `scripts/lib/events.mjs` degrades to
2095
2453
  // silence instead of crashing (§ Import safety), and the catch guarantees a
2096
2454
  // failed write cannot change the verdict or the exit code.
2455
+ //
2456
+ // `decide()` never sees the stale-base check, so its `ledger_result` describes
2457
+ // the COLLISION verdict alone. When the stale base is what actually blocks the
2458
+ // dispatch (no collision deny beside it — that one keeps `deny`), the record
2459
+ // says so (#1489 Pkt 8): recorded as `allow`, a refused dispatch counted as a
2460
+ // dispatched one in every later query. The replaced value survives as
2461
+ // `collision_result`, so a `warn-ledger-corrupt` / `warn-not-evaluable` behind
2462
+ // a stale-base deny is not reported on stderr alone.
2097
2463
  if (verdict.telemetry) {
2464
+ const telemetry = {
2465
+ ...verdict.telemetry,
2466
+ ...(staleDeny !== null && verdict.action !== 'deny'
2467
+ ? { ledger_result: 'deny-stale-base', collision_result: verdict.telemetry.ledger_result }
2468
+ : {}),
2469
+ ...(known.skipped === undefined ? {} : { known_files_skipped: known.skipped }),
2470
+ };
2098
2471
  try {
2099
2472
  const { emitEvent, sessionAttribution } = await import(
2100
2473
  pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'events.mjs')).href
2101
2474
  );
2102
2475
  await emitEvent(
2103
2476
  SCOPE_EVENT,
2104
- { hook: HOOK_NAME, ...verdict.telemetry, ...sessionAttribution(projectDir) },
2105
- { repoRoot: projectDir }
2477
+ { hook: HOOK_NAME, ...telemetry, ...sessionAttribution(sessionRoot) },
2478
+ { repoRoot: sessionRoot }
2106
2479
  );
2107
2480
  } catch { /* observability is best-effort — it never blocks the decision */ }
2108
2481
  }
2109
2482
 
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).
2483
+ // #1485 — the stale-worktree-base record (verdict computed above, before the
2484
+ // cycle), awaited BEFORE the terminal emit for the same process.exit() reason
2485
+ // as the block above.
2128
2486
  //
2129
2487
  // `null` here means "not a worktree dispatch" and stays silent; ANY other
2130
2488
  // 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;
2489
+ // (#1424), with this check's own `decision` folded into the SAME record
2490
+ // rather than a second event.
2135
2491
  if (worktreeBase !== null) {
2136
- if (worktreeBase.stale === true) staleNote = staleWorktreeWarning(worktreeBase);
2137
2492
  try {
2138
2493
  const { emitEvent, sessionAttribution } = await import(
2139
2494
  pathToFileURL(path.join(PLUGIN_ROOT, 'scripts', 'lib', 'events.mjs')).href
2140
2495
  );
2141
2496
  await emitEvent(
2142
2497
  WORKTREE_BASE_EVENT,
2143
- { hook: HOOK_NAME, ...worktreeBase, ...sessionAttribution(projectDir) },
2144
- { repoRoot: projectDir }
2498
+ {
2499
+ hook: HOOK_NAME,
2500
+ ...worktreeBase,
2501
+ decision: staleDeny === null ? 'allow' : 'deny',
2502
+ ...sessionAttribution(sessionRoot),
2503
+ },
2504
+ { repoRoot: sessionRoot }
2145
2505
  );
2146
2506
  } catch { /* observability is best-effort — it never blocks the decision */ }
2147
2507
  }
2148
2508
 
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.
2509
+ // ONE terminal emit. A collision DENY keeps its own reason — the dispatch is
2510
+ // blocked either way — and the stale base goes to the debug channel beside it.
2153
2511
  if (verdict.action === 'deny') {
2154
- if (staleNote !== null) {
2155
- try { console.error(`⚠ ${staleNote}`); } catch { /* stderr may be closed */ }
2512
+ if (staleDeny !== null) {
2513
+ try { console.error(`⚠ ${staleDeny.reason}`); } catch { /* stderr may be closed */ }
2156
2514
  }
2157
2515
  return emitDeny(verdict.reason, verdict.suggestion);
2158
2516
  }
2159
- if (verdict.action === 'warn') {
2160
- return emitWarn(staleNote === null ? verdict.note : `${verdict.note}\n\n${staleNote}`);
2517
+ if (staleDeny !== null) {
2518
+ if (verdict.action === 'warn') {
2519
+ try { console.error(`⚠ ${verdict.note}`); } catch { /* stderr may be closed */ }
2520
+ }
2521
+ return emitDeny(staleDeny.reason, staleDeny.suggestion);
2161
2522
  }
2162
- if (staleNote !== null) return emitWarn(staleNote);
2523
+ if (verdict.action === 'warn') return emitWarn(verdict.note);
2163
2524
  return emitAllow();
2164
2525
  }
2165
2526
 
@@ -2207,9 +2568,10 @@ if (invokedAsScript()) {
2207
2568
  // `enforce-scope`'s fail-closed handler — and the reason is the asymmetry
2208
2569
  // named in the matrix header: enforce-scope guards a WRITE (denying one
2209
2570
  // 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
2571
+ // is a session outage). A hidden COLLISION is caught downstream by
2211
2572
  // `validate-wave-scope.mjs` and by `enforce-scope` at write time; a
2212
- // wrongly-denied dispatch is caught by nothing.
2573
+ // hidden #1485 stale base is not (matrix row 15) — the accepted cost of
2574
+ // the same asymmetry; a wrongly-denied dispatch is caught by nothing.
2213
2575
  // -------------------------------------------------------------------------
2214
2576
  try {
2215
2577
  await bootstrap();