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
@@ -66,21 +66,22 @@
66
66
  * hooks.json wiring is managed separately (W3-C4 scope).
67
67
  */
68
68
 
69
- import { execFileSync, spawn } from 'node:child_process';
70
- import { readFileSync } from 'node:fs';
69
+ import { execFileSync } from 'node:child_process';
71
70
  import { readFile } from 'node:fs/promises';
72
71
  import path from 'node:path';
73
- import { performance } from 'node:perf_hooks';
74
- import { fileURLToPath } from 'node:url';
75
72
 
76
73
  import { shouldRunHook } from './_lib/profile-gate.mjs';
77
74
  import { isMainModule } from '../scripts/lib/is-main-module.mjs';
78
75
 
79
- import { getProjectDir } from '../scripts/lib/platform.mjs';
76
+ import { getProjectDir, resolveSessionRoot } from '../scripts/lib/platform.mjs';
80
77
  import { emitEvent } from '../scripts/lib/events.mjs';
81
- import { findScopeFile } from '../scripts/lib/scope-gate.mjs';
78
+ import { findOwnScopeFile } from '../scripts/lib/scope-gate.mjs';
79
+ import {
80
+ classifyManifestSession,
81
+ readProcessLocalSessionIds,
82
+ } from '../scripts/lib/session-identity/own-session.mjs';
82
83
  import { atomicMutateJson } from './_lib/atomic-json.mjs';
83
- import { _parseReaper } from '../scripts/lib/config/reaper.mjs';
84
+ import { maybeTriggerOrphanScan } from '../scripts/lib/orphan-reaper/trigger.mjs';
84
85
 
85
86
  // ---------------------------------------------------------------------------
86
87
  // Helpers
@@ -203,18 +204,23 @@ async function ownsSessionFile(input, sessionFile) {
203
204
  * wave-scope.json absent"). The file is deleted mid-session at Quality phase
204
205
  * transitions and final cleanup, so absence is an expected, non-error state.
205
206
  *
206
- * The manifest is located via `findScopeFile()` (#1082), the same precedence
207
+ * The manifest is located via `findOwnScopeFile()` (#1504), the same precedence
207
208
  * every other consumer uses — `.pi` > `.cursor` > `.codex` > `.claude`. The
208
209
  * previous hard-coded `.claude/` path made this hook's whole wave-lifecycle
209
210
  * fallback structurally dead on Codex CLI, Cursor and pi: the manifest exists,
210
211
  * it just is not under `.claude/`, so every batch read 0 and no
211
212
  * wave-lifecycle event was ever emitted there.
212
213
  *
213
- * @param {string} projectDir
214
+ * Only THIS session's manifest counts (#1504): `findOwnScopeFile` skips a
215
+ * peer's manifest in the same working copy, and a peer-only result is "no own
216
+ * wave" → 0, never the peer's number recorded as ours.
217
+ *
218
+ * @param {string} projectDir the session root (`resolveSessionRoot`)
219
+ * @param {Set<string>} ownIds this process's own session ids
214
220
  * @returns {Promise<number>}
215
221
  */
216
- async function resolveWaveNumber(projectDir) {
217
- const waveFile = findScopeFile(projectDir);
222
+ async function resolveWaveNumber(projectDir, ownIds) {
223
+ const { path: waveFile } = findOwnScopeFile(projectDir, ownIds, classifyManifestSession);
218
224
  if (waveFile === null) return 0;
219
225
  try {
220
226
  const raw = await readFile(waveFile, 'utf8');
@@ -308,147 +314,6 @@ function countFilesChangedSince(projectDir, sha) {
308
314
  }
309
315
  }
310
316
 
311
- // ---------------------------------------------------------------------------
312
- // orphan-reaper trigger (#1432 B4)
313
- // ---------------------------------------------------------------------------
314
- //
315
- // DUPLICATED VERBATIM between the two trigger hooks —
316
- // hooks/post-tool-batch-wave-signal.mjs <-> hooks/on-stop.mjs. Deliberate, with
317
- // a named revisit trigger (BV-004): they are the only two trigger points the
318
- // PRD declares, and a shared `hooks/_lib/reaper-trigger.mjs` would be a third
319
- // file in the hook import graph. SIZE, measured 2026-09-22 by diffing the two
320
- // blocks: 138 identical lines — this note said "~40 lines of glue" until then,
321
- // which is what made the duplicate look cheaper than it is. Extract it the
322
- // moment a THIRD hook needs the trigger, or the moment the copies must differ.
323
-
324
- /**
325
- * Filesystem path of the scan CLI, spawned as a PLAIN argv call.
326
- *
327
- * It used to be a `file://` URL handed to `node --input-type=module -e
328
- * <program>`, because `scripts/lib/orphan-reaper.mjs` had no entry guard. It
329
- * has one now (`parseReaperCliArgs` + the `isMainModule` tail), so the child's
330
- * contract lives in that module instead of as source text duplicated here —
331
- * and nothing this hook builds is a program any more: every variable part is
332
- * an argv value, which cannot become code whatever the checkout path contains.
333
- *
334
- * `fileURLToPath`, not `new URL(...).pathname`: the latter leaves a
335
- * percent-encoded path for any checkout directory containing a space.
336
- */
337
- const ORPHAN_REAPER_SCRIPT = fileURLToPath(
338
- new URL('../scripts/lib/orphan-reaper.mjs', import.meta.url),
339
- );
340
-
341
- /**
342
- * Read the `reaper:` block from the project's Session Config host file.
343
- * Sync + inline, mirroring `hooks/loop-guard.mjs` `loadConfig()` — a hot hook
344
- * path must not import the full config orchestrator. A missing or unreadable
345
- * file yields the parser defaults, i.e. DISABLED.
346
- *
347
- * @param {string} projectDir
348
- * @returns {ReturnType<typeof _parseReaper>}
349
- */
350
- function loadReaperConfig(projectDir) {
351
- for (const name of ['CLAUDE.md', 'AGENTS.md']) {
352
- try {
353
- return _parseReaper(readFileSync(path.join(projectDir, name), 'utf8'));
354
- } catch {
355
- // missing or unreadable — try the next candidate
356
- }
357
- }
358
- return _parseReaper('');
359
- }
360
-
361
- /**
362
- * Trigger the orphan scan, throttled and NON-BLOCKING (PRD FA4).
363
- *
364
- * The hook itself does exactly two pieces of I/O — one config read and one
365
- * `stat` of the throttle marker — and then hands the work to a DETACHED,
366
- * unref'd child. The scan is never run inline: one `ps` round-trip out of Node
367
- * was measured at ~47 ms over 287 KB of output, which alone would blow the
368
- * 50 ms `reaper.max-hook-latency-ms` budget this hook has to stay inside.
369
- *
370
- * The marker is stamped BEFORE the spawn, so a spawn that fails still consumes
371
- * the throttle window — otherwise a broken spawn would be retried on every
372
- * single tool batch.
373
- *
374
- * `reaper.max-hook-latency-ms` is the ceiling on the work above — measured here
375
- * with `performance.now()` and reported as ONE stderr WARN line when exceeded.
376
- * A WARN and not an event: this fires from a `PostToolBatch`-class hook, and a
377
- * per-fire telemetry record is exactly the always-on signal
378
- * `.claude/rules/host-resources.md` HR-101 calls a broken instrument. The
379
- * measurement covers preparation only — the scan itself runs detached, which is
380
- * the whole reason the budget can be held.
381
- *
382
- * Never throws: any failure degrades silently (PRD FA4 "lautlos degradieren").
383
- *
384
- * @param {object} [opts]
385
- * @param {string} [opts.projectDir] Repo root; defaults to `getProjectDir()`.
386
- * @param {number} [opts.now] Injected clock (ms).
387
- * @param {Function} [opts.spawnFn] Injected `spawn` (tests).
388
- * @param {Function} [opts.statFn] Injected `statSync` (tests).
389
- * @param {Function} [opts.writeFn] Injected marker writer (tests).
390
- * @param {() => number} [opts.clockFn] Injected monotonic clock for the latency
391
- * budget (tests); defaults to `performance.now`.
392
- * @returns {Promise<{spawned: boolean, reason: string}>}
393
- */
394
- export async function maybeTriggerOrphanScan({
395
- projectDir,
396
- now,
397
- spawnFn = spawn,
398
- statFn,
399
- writeFn,
400
- clockFn = () => performance.now(),
401
- } = {}) {
402
- const startedAt = clockFn();
403
- /** One WARN line when the preparation overran `reaper.max-hook-latency-ms`. */
404
- const checkLatency = (budgetMs) => {
405
- const elapsed = clockFn() - startedAt;
406
- if (Number.isFinite(budgetMs) && budgetMs > 0 && elapsed > budgetMs) {
407
- process.stderr.write(
408
- `orphan-reaper trigger: hook latency ${elapsed.toFixed(1)} ms exceeded `
409
- + `reaper.max-hook-latency-ms (${budgetMs} ms)\n`,
410
- );
411
- }
412
- };
413
- try {
414
- const root = typeof projectDir === 'string' && projectDir ? projectDir : getProjectDir();
415
- const cfg = loadReaperConfig(root);
416
- // Cheapest gate first: disabled means no stat, no spawn, no module load.
417
- if (cfg.enabled !== true) return { spawned: false, reason: 'disabled' };
418
-
419
- // The throttle module only — the scan's ledger, `ps` and kill-ladder code
420
- // load in the detached child, never on this hook path (#1437).
421
- const reaper = await import('../scripts/lib/orphan-reaper/scan-throttle.mjs');
422
- const markerPath = reaper.scanMarkerPath(root);
423
- const nowMs = typeof now === 'number' ? now : Date.now();
424
-
425
- if (!reaper.shouldScanNow(markerPath, nowMs, cfg['min-scan-interval-seconds'], { statFn })) {
426
- checkLatency(cfg['max-hook-latency-ms']);
427
- return { spawned: false, reason: 'throttled' };
428
- }
429
- reaper.touchScanMarker(markerPath, { writeFn });
430
-
431
- const child = spawnFn(
432
- process.execPath,
433
- [
434
- ORPHAN_REAPER_SCRIPT,
435
- '--repo-root', root,
436
- '--mode', cfg.mode,
437
- '--min-age-seconds', String(cfg['min-age-seconds']),
438
- '--kill-grace-ms', String(cfg['kill-grace-ms']),
439
- '--verify-wait-ms', String(cfg['verify-wait-ms']),
440
- '--false-alarm-window', String(cfg['false-alarm-window']),
441
- ],
442
- { detached: true, stdio: 'ignore' },
443
- );
444
- if (child && typeof child.unref === 'function') child.unref();
445
- checkLatency(cfg['max-hook-latency-ms']);
446
- return { spawned: true, reason: 'spawned' };
447
- } catch {
448
- return { spawned: false, reason: 'error' };
449
- }
450
- }
451
-
452
317
  // ---------------------------------------------------------------------------
453
318
  // Main
454
319
  // ---------------------------------------------------------------------------
@@ -596,7 +461,10 @@ async function main() {
596
461
  // when an N+1 transition already closed wave N, SessionEnd sees
597
462
  // last_wave_completed === last_wave and stays silent.
598
463
  try {
599
- const wave = await resolveWaveNumber(getProjectDir());
464
+ const wave = await resolveWaveNumber(
465
+ resolveSessionRoot(input?.cwd),
466
+ new Set(readProcessLocalSessionIds({ hookInput: input })),
467
+ );
600
468
  if (wave > 0) {
601
469
  // Read last_wave from the just-written session file (after the
602
470
  // last_batch RMW above, so we observe the latest persisted value).
@@ -715,7 +583,17 @@ async function main() {
715
583
  // `process.exit(0)` in a `finally`: an un-awaited spawn would race the exit.
716
584
  // The await is bounded by design — one config read, one stat, one detached
717
585
  // spawn, all measured under `reaper.max-hook-latency-ms` (50 ms).
718
- await maybeTriggerOrphanScan();
586
+ //
587
+ // The returned reason is deliberately NOT recorded here (#1489): this hook
588
+ // emits no record per batch, and adding one is the per-fire telemetry HR-101
589
+ // rejects. The throttle marker is one file per repo, so a defeated marker
590
+ // (`spawned-unthrottled`) shows on the next Stop/SubagentStop record, whose
591
+ // `reaper_trigger` key hooks/on-stop.mjs fills from the same trigger.
592
+ //
593
+ // Rooted at the SESSION root (#1492 point 2, #1504 point 4): the quality gate
594
+ // registers its children in the ledger of the tree it runs in, which for an
595
+ // entered worktree is the worktree — the launch dir holds none of them.
596
+ await maybeTriggerOrphanScan({ projectDir: resolveSessionRoot(input?.cwd) });
719
597
  }
720
598
 
721
599
  // Entry guard (#1393): run only as the node script the harness execs — a bare
@@ -29,7 +29,12 @@ import { readFile } from 'node:fs/promises';
29
29
  import os from 'node:os';
30
30
  import path from 'node:path';
31
31
  import { emitEvent } from '../scripts/lib/events.mjs';
32
- import { findScopeFile } from '../scripts/lib/hardening.mjs';
32
+ import { resolveSessionRoot } from '../scripts/lib/platform.mjs';
33
+ import { findOwnScopeFile } from '../scripts/lib/scope-gate.mjs';
34
+ import {
35
+ classifyManifestSession,
36
+ readProcessLocalSessionIds,
37
+ } from '../scripts/lib/session-identity/own-session.mjs';
33
38
 
34
39
  import { shouldRunHook } from './_lib/profile-gate.mjs';
35
40
  import { isMainModule } from '../scripts/lib/is-main-module.mjs';
@@ -201,14 +206,16 @@ async function resolveSessionId(input, projectDir) {
201
206
  }
202
207
 
203
208
  /**
204
- * Resolve the current wave number from the active wave-scope.json.
205
- * Returns 0 when the file is absent or unparseable.
209
+ * Resolve the current wave number from THIS session's wave-scope.json.
210
+ * Returns 0 when the file is absent or unparseable — and when the only
211
+ * manifest is a peer's (#1504): its number is not this session's wave.
206
212
  *
207
- * @param {string} projectDir
213
+ * @param {string} projectDir the session root (`resolveSessionRoot`)
214
+ * @param {Set<string>} ownIds this process's own session ids
208
215
  * @returns {Promise<number>}
209
216
  */
210
- async function resolveWave(projectDir) {
211
- const waveFile = findScopeFile(projectDir);
217
+ async function resolveWave(projectDir, ownIds) {
218
+ const { path: waveFile } = findOwnScopeFile(projectDir, ownIds, classifyManifestSession);
212
219
  if (!waveFile || !existsSync(waveFile)) return 0;
213
220
  try {
214
221
  const raw = await readFile(waveFile, 'utf8');
@@ -247,7 +254,12 @@ async function main() {
247
254
  const sessionId = await resolveSessionId(input, projectDir);
248
255
 
249
256
  // G5 — resolve wave
250
- const wave = await resolveWave(projectDir);
257
+ // Read at the SESSION root the scope guards use (#1504 point 4), not at the
258
+ // launch dir above, which stays put while the session works in a worktree.
259
+ const wave = await resolveWave(
260
+ resolveSessionRoot(input.cwd),
261
+ new Set(readProcessLocalSessionIds({ hookInput: input })),
262
+ );
251
263
 
252
264
  // G6 — derive the non-reversible argv summary (#1415). Nothing downstream
253
265
  // ever sees the command text: hash for grouping, known flag NAMES for
@@ -75,6 +75,7 @@ import crypto from 'node:crypto';
75
75
  import { shouldRunHook } from './_lib/profile-gate.mjs';
76
76
  import { isMainModule } from '../scripts/lib/is-main-module.mjs';
77
77
  import { stableHostname } from '../scripts/lib/host-identity.mjs';
78
+ import { dotGitAncestor, launchDirFromEnv } from '../scripts/lib/platform.mjs';
78
79
 
79
80
  // ---------------------------------------------------------------------------
80
81
  // Constants
@@ -400,16 +401,36 @@ function deriveAgentId() {
400
401
  }
401
402
 
402
403
  /**
403
- * Resolve the project directory the hook should operate against. Mirrors
404
- * pre-bash-memory-propose-audit.mjs resolution: prefer CLAUDE_PROJECT_DIR /
405
- * CODEX_PROJECT_DIR env-vars, fall back to cwd.
404
+ * The repo whose fence directory this `git add` is recorded in — the SAME root
405
+ * the reader uses (#1504 point 4). The only reader, `hooks/wave-scope-commit-guard.mjs`,
406
+ * runs as git's pre-commit hook and walks `<git rev-parse --show-toplevel>/.orchestrator/staging-fence/`,
407
+ * i.e. the work-tree root the commit is made in. `dotGitAncestor(cwd)` is that
408
+ * root without a spawn: the nearest `.git` entry at or above the payload `cwd` —
409
+ * the worktree root for a session that entered a worktree (measured 2026-10-03:
410
+ * launch dir `<main>`, cwd `<worktree>/sub` → `<worktree>`, git toplevel
411
+ * `<worktree>`), the repo root for a launch in a repo SUBDIRECTORY. The launch
412
+ * dir (`$CLAUDE_PROJECT_DIR`) was wrong in both: the fence landed where no
413
+ * commit guard looked, and every cross-agent overlap passed.
406
414
  *
415
+ * Deliberately NOT `resolveSessionRoot()`: its clamp answers the launch
416
+ * SUBDIRECTORY of a monorepo package, while git's toplevel is the repo root.
417
+ *
418
+ * Fallback when the payload carries no `cwd`, or `cwd` is in no repo (where no
419
+ * commit can read a fence anyway): the launch dir from env, then `process.cwd()`.
420
+ *
421
+ * NAMED CEILING (BV-004): the root follows the payload `cwd`, not the command —
422
+ * `git -C <other-tree> add` or `cd <other-tree> && git add` records the intent
423
+ * in the CURRENT tree's fence dir while the commit in `<other-tree>` reads its
424
+ * own, so that overlap stays unseen (as before). Revisit if a fence miss is
425
+ * ever traced to a redirected `git add`; the fix then is to resolve the root
426
+ * from the tokenized command's `-C` / leading `cd`.
427
+ *
428
+ * @param {object|null} input the hook payload
407
429
  * @returns {string}
408
430
  */
409
- function resolveProjectDir() {
410
- return process.env.CLAUDE_PROJECT_DIR
411
- ?? process.env.CODEX_PROJECT_DIR
412
- ?? process.cwd();
431
+ function resolveFenceRoot(input) {
432
+ const cwd = typeof input?.cwd === 'string' ? input.cwd.trim() : '';
433
+ return (cwd !== '' && dotGitAncestor(cwd)) || launchDirFromEnv() || process.cwd();
413
434
  }
414
435
 
415
436
  /**
@@ -494,7 +515,7 @@ async function main() {
494
515
  if (!recognised) return emitAllow();
495
516
 
496
517
  // G5 — derive paths.
497
- const projectDir = resolveProjectDir();
518
+ const projectDir = resolveFenceRoot(input);
498
519
  const agentId = deriveAgentId();
499
520
  const fenceFile = path.join(
500
521
  projectDir,