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
@@ -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
  // ---------------------------------------------------------------------------
@@ -489,6 +354,13 @@ async function main() {
489
354
  ...(waveSignal !== null ? { wave_signal: waveSignal } : {}),
490
355
  };
491
356
 
357
+ // Two roots, by writer (#1511 point a). `current-session.json` and
358
+ // `session.lock` stay at the LAUNCH dir: on-session-start.mjs writes both at
359
+ // `resolveProjectDir()`, and a reader elsewhere finds no record. Everything
360
+ // about the WAVE — its manifest, its start sha, the files it changed — is read
361
+ // at the SESSION root: after EnterWorktree the launch dir is a different
362
+ // checkout, whose HEAD and working tree say nothing about this wave.
363
+ const sessionRoot = resolveSessionRoot(input?.cwd);
492
364
  const sessionFile = path.join(getProjectDir(), '.orchestrator', 'current-session.json');
493
365
 
494
366
  const lastBatchResult = await atomicMutateJson(sessionFile, {}, (current) => ({
@@ -596,7 +468,10 @@ async function main() {
596
468
  // when an N+1 transition already closed wave N, SessionEnd sees
597
469
  // last_wave_completed === last_wave and stays silent.
598
470
  try {
599
- const wave = await resolveWaveNumber(getProjectDir());
471
+ const wave = await resolveWaveNumber(
472
+ sessionRoot,
473
+ new Set(readProcessLocalSessionIds({ hookInput: input })),
474
+ );
600
475
  if (wave > 0) {
601
476
  // Read last_wave from the just-written session file (after the
602
477
  // last_batch RMW above, so we observe the latest persisted value).
@@ -627,7 +502,7 @@ async function main() {
627
502
  if (lastWave > lastWaveCompleted) {
628
503
  // #980 — measure the closing wave's diff size BEFORE the new wave's
629
504
  // start sha is persisted below. Unmeasurable → both keys omitted.
630
- const filesChanged = countFilesChangedSince(getProjectDir(), waveStartSha);
505
+ const filesChanged = countFilesChangedSince(sessionRoot, waveStartSha);
631
506
  await emitEvent('orchestrator.wave.completed', {
632
507
  wave_number: lastWave,
633
508
  ...(filesChanged !== null
@@ -669,7 +544,7 @@ async function main() {
669
544
  // #980 — the OPEN half: stamp the sha this new wave starts from.
670
545
  // Written as null (not omitted) when git is unreadable, so the
671
546
  // PREVIOUS wave's sha can never linger and inflate the next count.
672
- const startSha = readHeadSha(getProjectDir());
547
+ const startSha = readHeadSha(sessionRoot);
673
548
  const markResult = await atomicMutateJson(sessionFile, {}, (current) => ({
674
549
  ...current,
675
550
  wave_start_sha: startSha,
@@ -715,7 +590,17 @@ async function main() {
715
590
  // `process.exit(0)` in a `finally`: an un-awaited spawn would race the exit.
716
591
  // The await is bounded by design — one config read, one stat, one detached
717
592
  // spawn, all measured under `reaper.max-hook-latency-ms` (50 ms).
718
- await maybeTriggerOrphanScan();
593
+ //
594
+ // The returned reason is deliberately NOT recorded here (#1489): this hook
595
+ // emits no record per batch, and adding one is the per-fire telemetry HR-101
596
+ // rejects. The throttle marker is one file per repo, so a defeated marker
597
+ // (`spawned-unthrottled`) shows on the next Stop/SubagentStop record, whose
598
+ // `reaper_trigger` key hooks/on-stop.mjs fills from the same trigger.
599
+ //
600
+ // Rooted at the SESSION root (#1492 point 2, #1504 point 4): the quality gate
601
+ // registers its children in the ledger of the tree it runs in, which for an
602
+ // entered worktree is the worktree — the launch dir holds none of them.
603
+ await maybeTriggerOrphanScan({ projectDir: sessionRoot });
719
604
  }
720
605
 
721
606
  // 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 { getProjectDir, 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');
@@ -239,15 +246,25 @@ async function main() {
239
246
  if (!MEMORY_PROPOSE_REGEX.test(command)) return emitAllow();
240
247
 
241
248
  // Matched — proceed with audit logging (G4-G7)
242
- const projectDir = process.env.CLAUDE_PROJECT_DIR
243
- ?? process.env.CODEX_PROJECT_DIR
244
- ?? process.cwd();
249
+ // Two roots, by writer (#1511 point c). The session-id fallback reads
250
+ // `current-session.json` where on-session-start.mjs WRITES it — the launch dir,
251
+ // through the same resolver (`resolveProjectDir`; the hand-rolled
252
+ // `CLAUDE_PROJECT_DIR ?? CODEX_PROJECT_DIR ?? process.cwd()` kept a set-but-
253
+ // empty var, skipped the Cursor/Pi vars and never walked up). The wave and the
254
+ // recorded `cwd` describe the tree the session WORKS in: the SESSION root the
255
+ // scope guards use (#1504 point 4), which after EnterWorktree is not the
256
+ // launch dir.
257
+ const projectDir = getProjectDir();
258
+ const sessionRoot = resolveSessionRoot(input.cwd);
245
259
 
246
260
  // G4 — resolve session_id
247
261
  const sessionId = await resolveSessionId(input, projectDir);
248
262
 
249
263
  // G5 — resolve wave
250
- const wave = await resolveWave(projectDir);
264
+ const wave = await resolveWave(
265
+ sessionRoot,
266
+ new Set(readProcessLocalSessionIds({ hookInput: input })),
267
+ );
251
268
 
252
269
  // G6 — derive the non-reversible argv summary (#1415). Nothing downstream
253
270
  // ever sees the command text: hash for grouping, known flag NAMES for
@@ -268,7 +285,7 @@ async function main() {
268
285
  command_hash: commandHash,
269
286
  flags_present: flags,
270
287
  argv_length: command.length,
271
- cwd: relativeCwd(process.cwd(), projectDir),
288
+ cwd: relativeCwd(process.cwd(), sessionRoot),
272
289
  exit_code: null,
273
290
  });
274
291
  } catch { /* telemetry never blocks the hook (#1183) */ }
@@ -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,