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
package/hooks/on-stop.mjs CHANGED
@@ -27,11 +27,12 @@
27
27
  * "duration_ms":<int>,"duration_source":"meta-birthtime","status":"done"}
28
28
  * (#1190 — every key after `event` is OPTIONAL and OMITTED when the
29
29
  * measurement could not be made; `agent` too. See docs/events-schema.md.)
30
+ * Both records also carry `"reaper_trigger":"<reason>"` whenever `reaper.enabled`
31
+ * is true — the orphan-reaper trigger outcome of this fire (#1489, see
32
+ * `reaperTriggerField`); omitted when the reaper is disabled.
30
33
  */
31
34
 
32
35
  import path from 'node:path';
33
- import { spawn } from 'node:child_process';
34
- import { performance } from 'node:perf_hooks';
35
36
  import {
36
37
  promises as fs,
37
38
  existsSync,
@@ -39,19 +40,23 @@ import {
39
40
  statSync,
40
41
  writeFileSync,
41
42
  } from 'node:fs';
42
- import { fileURLToPath } from 'node:url';
43
43
 
44
44
  import { shouldRunHook } from './_lib/profile-gate.mjs';
45
45
  import { readTailWindow } from '../scripts/lib/tail-window.mjs';
46
46
  import { AGENT_ID_RE, resolveSubagentSidecar } from './_lib/subagent-paths.mjs';
47
47
 
48
48
  import { emitEvent } from '../scripts/lib/events.mjs';
49
- import { detectPlatform, getProjectDir } from '../scripts/lib/platform.mjs';
49
+ import { detectPlatform, getProjectDir, resolveSessionRoot } from '../scripts/lib/platform.mjs';
50
+ import { findOwnScopeFile } from '../scripts/lib/scope-gate.mjs';
51
+ import {
52
+ classifyManifestSession,
53
+ readProcessLocalSessionIds,
54
+ } from '../scripts/lib/session-identity/own-session.mjs';
50
55
  import { parseSessionId } from '../scripts/lib/session-id.mjs';
51
56
  import { isMainModule } from '../scripts/lib/is-main-module.mjs';
52
57
  import { heartbeat, logSweepEvent } from '../scripts/lib/session-registry.mjs';
53
58
  import { readLock, updateHeartbeat } from '../scripts/lib/session-lock.mjs';
54
- import { _parseReaper } from '../scripts/lib/config/reaper.mjs';
59
+ import { maybeTriggerOrphanScan } from '../scripts/lib/orphan-reaper/trigger.mjs';
55
60
 
56
61
  // ---------------------------------------------------------------------------
57
62
  // stdin reading (inline — no io.mjs because Stop hooks exit 0 always, never deny)
@@ -211,25 +216,26 @@ async function gitInfo(projectRoot) {
211
216
  // ---------------------------------------------------------------------------
212
217
 
213
218
  /**
214
- * Try to read the wave number from .claude/wave-scope.json (or .codex / .cursor / .pi).
215
- * Returns 0 if no scope file is found or the file cannot be parsed.
216
- * @param {string} projectRoot
219
+ * Read the wave number from THIS session's wave-scope manifest at the session
220
+ * root — the same root and the same `.pi` > `.cursor` > `.codex` > `.claude`
221
+ * precedence the scope guards use (#1504 point 4). A peer's manifest in the
222
+ * same working copy is skipped; a peer-only result is "no own wave" → 0.
223
+ * Returns 0 too when the manifest is absent or cannot be parsed.
224
+ *
225
+ * @param {string} sessionRoot `resolveSessionRoot(input.cwd)`
226
+ * @param {object|null} input the hook payload (its `session_id` is ours)
217
227
  * @returns {Promise<number>}
218
228
  */
219
- async function readWaveNumber(projectRoot) {
220
- const dirs = ['.pi', '.claude', '.codex', '.cursor'];
221
- for (const dir of dirs) {
222
- const scopePath = path.join(projectRoot, dir, 'wave-scope.json');
223
- try {
224
- const raw = await fs.readFile(scopePath, 'utf8');
225
- const obj = JSON.parse(raw);
226
- const wave = typeof obj.wave === 'number' ? obj.wave : 0;
227
- return wave;
228
- } catch {
229
- // file missing or unparseable — try next
230
- }
229
+ async function readWaveNumber(sessionRoot, input) {
230
+ const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
231
+ const { path: scopePath } = findOwnScopeFile(sessionRoot, ownIds, classifyManifestSession);
232
+ if (scopePath === null) return 0;
233
+ try {
234
+ const obj = JSON.parse(await fs.readFile(scopePath, 'utf8'));
235
+ return typeof obj?.wave === 'number' ? obj.wave : 0;
236
+ } catch {
237
+ return 0; // unreadable or unparseable — no wave known
231
238
  }
232
- return 0;
233
239
  }
234
240
 
235
241
  // ---------------------------------------------------------------------------
@@ -363,14 +369,40 @@ function resolveStopDuration(input, projectRoot) {
363
369
  }
364
370
  }
365
371
 
372
+ /**
373
+ * The orphan-reaper trigger outcome as an optional record key (#1489).
374
+ *
375
+ * `reaper_trigger` carries the reason `maybeTriggerOrphanScan()` returned for
376
+ * THIS hook fire — `throttled` | `spawned` | `spawned-unthrottled` | `error` —
377
+ * on the record this hook emits anyway, so no new record type and no per-fire
378
+ * event exist (HR-101). `spawned-unthrottled` is the case it is here for: a
379
+ * throttle marker the trigger cannot stamp (a planted link or directory, a
380
+ * read-only tmp dir) makes every fire spawn a scan, and until now that outcome
381
+ * was discarded in `main()` — a defeated throttle nothing recorded (HR-105).
382
+ * Recording the healthy reasons too gives that count its denominator: the
383
+ * share of armed fires that ran unthrottled, from events.jsonl alone (HR-106).
384
+ *
385
+ * OMITTED when the reaper is disabled (the default): that is configuration,
386
+ * not a measurement, and stamping it would touch every record on every host.
387
+ *
388
+ * @param {{reason?: unknown}|null|undefined} trigger
389
+ * @returns {{reaper_trigger?: string}}
390
+ */
391
+ function reaperTriggerField(trigger) {
392
+ const reason = trigger?.reason;
393
+ if (typeof reason !== 'string' || reason === 'disabled') return {};
394
+ return { reaper_trigger: reason };
395
+ }
396
+
366
397
  /**
367
398
  * Handle a Stop event. Reads wave from scope file + git info, appends JSONL.
368
399
  * @param {object|null} input
400
+ * @param {{reason?: string}|null} [reaperTrigger] `maybeTriggerOrphanScan()` result.
369
401
  */
370
- async function handleStop(input) {
402
+ async function handleStop(input, reaperTrigger = null) {
371
403
  const projectRoot = getProjectDir();
372
404
 
373
- const wave = await readWaveNumber(projectRoot);
405
+ const wave = await readWaveNumber(resolveSessionRoot(input?.cwd), input);
374
406
  const { commit, branch } = await gitInfo(projectRoot);
375
407
 
376
408
  const { sessionId, semanticSessionId } = await resolveSessionId(input, projectRoot);
@@ -483,6 +515,7 @@ async function handleStop(input) {
483
515
  ...(branch !== null ? { branch } : {}),
484
516
  ...(commit !== null ? { commit } : {}),
485
517
  ...duration,
518
+ ...reaperTriggerField(reaperTrigger),
486
519
  };
487
520
 
488
521
  try {
@@ -500,9 +533,10 @@ async function handleStop(input) {
500
533
  * coordinator turn. Currently always returns null (no inline warning from this
501
534
  * handler) — the slot is reserved for future SubagentStop feedback.
502
535
  * @param {object|null} input
536
+ * @param {{reason?: string}|null} [reaperTrigger] `maybeTriggerOrphanScan()` result.
503
537
  * @returns {Promise<string|null>} additionalContext or null
504
538
  */
505
- async function handleSubagentStop(input) {
539
+ async function handleSubagentStop(input, reaperTrigger = null) {
506
540
  /** @type {Record<string, unknown>} */
507
541
  const payload = {};
508
542
 
@@ -633,8 +667,12 @@ async function handleSubagentStop(input) {
633
667
  // #1183 — a malformed record throws EventValidationError BEFORE any side
634
668
  // effect (scripts/lib/events.mjs); this hook must never abort on that, so
635
669
  // the emit is wrapped rather than left to propagate.
670
+ // A fresh object via spread (like the Stop record above), never
671
+ // Object.assign onto a parsed payload: spread defines keys, so no `__proto__`
672
+ // key can reach the prototype setter (CWE-1321, semgrep
673
+ // prototype-pollution-object-assign).
636
674
  try {
637
- await emitEvent('orchestrator.agent.stopped', payload);
675
+ await emitEvent('orchestrator.agent.stopped', { ...payload, ...reaperTriggerField(reaperTrigger) });
638
676
  } catch { /* telemetry never blocks the hook (#1183) */ }
639
677
  return null;
640
678
  }
@@ -775,147 +813,6 @@ function readStatusFromTranscriptTail(transcriptPath) {
775
813
  // main
776
814
  // ---------------------------------------------------------------------------
777
815
 
778
- // ---------------------------------------------------------------------------
779
- // orphan-reaper trigger (#1432 B4)
780
- // ---------------------------------------------------------------------------
781
- //
782
- // DUPLICATED VERBATIM between the two trigger hooks —
783
- // hooks/post-tool-batch-wave-signal.mjs <-> hooks/on-stop.mjs. Deliberate, with
784
- // a named revisit trigger (BV-004): they are the only two trigger points the
785
- // PRD declares, and a shared `hooks/_lib/reaper-trigger.mjs` would be a third
786
- // file in the hook import graph. SIZE, measured 2026-09-22 by diffing the two
787
- // blocks: 138 identical lines — this note said "~40 lines of glue" until then,
788
- // which is what made the duplicate look cheaper than it is. Extract it the
789
- // moment a THIRD hook needs the trigger, or the moment the copies must differ.
790
-
791
- /**
792
- * Filesystem path of the scan CLI, spawned as a PLAIN argv call.
793
- *
794
- * It used to be a `file://` URL handed to `node --input-type=module -e
795
- * <program>`, because `scripts/lib/orphan-reaper.mjs` had no entry guard. It
796
- * has one now (`parseReaperCliArgs` + the `isMainModule` tail), so the child's
797
- * contract lives in that module instead of as source text duplicated here —
798
- * and nothing this hook builds is a program any more: every variable part is
799
- * an argv value, which cannot become code whatever the checkout path contains.
800
- *
801
- * `fileURLToPath`, not `new URL(...).pathname`: the latter leaves a
802
- * percent-encoded path for any checkout directory containing a space.
803
- */
804
- const ORPHAN_REAPER_SCRIPT = fileURLToPath(
805
- new URL('../scripts/lib/orphan-reaper.mjs', import.meta.url),
806
- );
807
-
808
- /**
809
- * Read the `reaper:` block from the project's Session Config host file.
810
- * Sync + inline, mirroring `hooks/loop-guard.mjs` `loadConfig()` — a hot hook
811
- * path must not import the full config orchestrator. A missing or unreadable
812
- * file yields the parser defaults, i.e. DISABLED.
813
- *
814
- * @param {string} projectDir
815
- * @returns {ReturnType<typeof _parseReaper>}
816
- */
817
- function loadReaperConfig(projectDir) {
818
- for (const name of ['CLAUDE.md', 'AGENTS.md']) {
819
- try {
820
- return _parseReaper(readFileSync(path.join(projectDir, name), 'utf8'));
821
- } catch {
822
- // missing or unreadable — try the next candidate
823
- }
824
- }
825
- return _parseReaper('');
826
- }
827
-
828
- /**
829
- * Trigger the orphan scan, throttled and NON-BLOCKING (PRD FA4).
830
- *
831
- * The hook itself does exactly two pieces of I/O — one config read and one
832
- * `stat` of the throttle marker — and then hands the work to a DETACHED,
833
- * unref'd child. The scan is never run inline: one `ps` round-trip out of Node
834
- * was measured at ~47 ms over 287 KB of output, which alone would blow the
835
- * 50 ms `reaper.max-hook-latency-ms` budget this hook has to stay inside.
836
- *
837
- * The marker is stamped BEFORE the spawn, so a spawn that fails still consumes
838
- * the throttle window — otherwise a broken spawn would be retried on every
839
- * single tool batch.
840
- *
841
- * `reaper.max-hook-latency-ms` is the ceiling on the work above — measured here
842
- * with `performance.now()` and reported as ONE stderr WARN line when exceeded.
843
- * A WARN and not an event: this fires from a `PostToolBatch`-class hook, and a
844
- * per-fire telemetry record is exactly the always-on signal
845
- * `.claude/rules/host-resources.md` HR-101 calls a broken instrument. The
846
- * measurement covers preparation only — the scan itself runs detached, which is
847
- * the whole reason the budget can be held.
848
- *
849
- * Never throws: any failure degrades silently (PRD FA4 "lautlos degradieren").
850
- *
851
- * @param {object} [opts]
852
- * @param {string} [opts.projectDir] Repo root; defaults to `getProjectDir()`.
853
- * @param {number} [opts.now] Injected clock (ms).
854
- * @param {Function} [opts.spawnFn] Injected `spawn` (tests).
855
- * @param {Function} [opts.statFn] Injected `statSync` (tests).
856
- * @param {Function} [opts.writeFn] Injected marker writer (tests).
857
- * @param {() => number} [opts.clockFn] Injected monotonic clock for the latency
858
- * budget (tests); defaults to `performance.now`.
859
- * @returns {Promise<{spawned: boolean, reason: string}>}
860
- */
861
- export async function maybeTriggerOrphanScan({
862
- projectDir,
863
- now,
864
- spawnFn = spawn,
865
- statFn,
866
- writeFn,
867
- clockFn = () => performance.now(),
868
- } = {}) {
869
- const startedAt = clockFn();
870
- /** One WARN line when the preparation overran `reaper.max-hook-latency-ms`. */
871
- const checkLatency = (budgetMs) => {
872
- const elapsed = clockFn() - startedAt;
873
- if (Number.isFinite(budgetMs) && budgetMs > 0 && elapsed > budgetMs) {
874
- process.stderr.write(
875
- `orphan-reaper trigger: hook latency ${elapsed.toFixed(1)} ms exceeded `
876
- + `reaper.max-hook-latency-ms (${budgetMs} ms)\n`,
877
- );
878
- }
879
- };
880
- try {
881
- const root = typeof projectDir === 'string' && projectDir ? projectDir : getProjectDir();
882
- const cfg = loadReaperConfig(root);
883
- // Cheapest gate first: disabled means no stat, no spawn, no module load.
884
- if (cfg.enabled !== true) return { spawned: false, reason: 'disabled' };
885
-
886
- // The throttle module only — the scan's ledger, `ps` and kill-ladder code
887
- // load in the detached child, never on this hook path (#1437).
888
- const reaper = await import('../scripts/lib/orphan-reaper/scan-throttle.mjs');
889
- const markerPath = reaper.scanMarkerPath(root);
890
- const nowMs = typeof now === 'number' ? now : Date.now();
891
-
892
- if (!reaper.shouldScanNow(markerPath, nowMs, cfg['min-scan-interval-seconds'], { statFn })) {
893
- checkLatency(cfg['max-hook-latency-ms']);
894
- return { spawned: false, reason: 'throttled' };
895
- }
896
- reaper.touchScanMarker(markerPath, { writeFn });
897
-
898
- const child = spawnFn(
899
- process.execPath,
900
- [
901
- ORPHAN_REAPER_SCRIPT,
902
- '--repo-root', root,
903
- '--mode', cfg.mode,
904
- '--min-age-seconds', String(cfg['min-age-seconds']),
905
- '--kill-grace-ms', String(cfg['kill-grace-ms']),
906
- '--verify-wait-ms', String(cfg['verify-wait-ms']),
907
- '--false-alarm-window', String(cfg['false-alarm-window']),
908
- ],
909
- { detached: true, stdio: 'ignore' },
910
- );
911
- if (child && typeof child.unref === 'function') child.unref();
912
- checkLatency(cfg['max-hook-latency-ms']);
913
- return { spawned: true, reason: 'spawned' };
914
- } catch {
915
- return { spawned: false, reason: 'error' };
916
- }
917
- }
918
-
919
816
  async function main() {
920
817
  const input = await readStdinJson();
921
818
  const eventType = discriminate(input);
@@ -926,11 +823,14 @@ async function main() {
926
823
  // hook, which triggers last) because both branches below may throw — a
927
824
  // `handleStop` failure must not silently disarm the watchdog. The cost is one
928
825
  // config read plus one stat, bounded by `reaper.max-hook-latency-ms` (50 ms),
929
- // and the scan itself runs in a detached child.
930
- await maybeTriggerOrphanScan();
826
+ // and the scan itself runs in a detached child. Its outcome rides on the
827
+ // record either branch emits anyway (`reaper_trigger`, see reaperTriggerField).
828
+ // Rooted at the SESSION root (#1492 point 2): the gate ledger the scan reads
829
+ // lives in the tree the gate ran in — an entered worktree, not the launch dir.
830
+ const reaperTrigger = await maybeTriggerOrphanScan({ projectDir: resolveSessionRoot(input?.cwd) });
931
831
 
932
832
  if (eventType === 'subagent_stop') {
933
- const additionalContext = await handleSubagentStop(input);
833
+ const additionalContext = await handleSubagentStop(input, reaperTrigger);
934
834
  // v2.1.163+: emit hookSpecificOutput for SubagentStop path.
935
835
  // If handleSubagentStop returns a non-empty context string, feed it back
936
836
  // to the coordinator turn. Currently returns null (no inline warning from
@@ -951,7 +851,7 @@ async function main() {
951
851
  // SubagentStop is intentionally outside this block — it must never emit
952
852
  // terminalSequence.
953
853
  try {
954
- await handleStop(input);
854
+ await handleStop(input, reaperTrigger);
955
855
  } finally {
956
856
  // terminalSequence is only meaningful for Stop (session-level) events.
957
857
  process.stdout.write(buildTerminalSequenceJson(detectPlatform()));
@@ -109,6 +109,10 @@
109
109
  * observed actor could rewrite the gate unreported. Because the file is
110
110
  * typically gitignored, `git status` cannot see it anyway — change detection
111
111
  * for it is content-hash-based against the snapshot (`formatControlNotice`).
112
+ * Its sibling control state `<state-dir>/filescopes/**` (the per-agent scope
113
+ * declarations, #1020) is likewise not on the list: `computeReport` drops it
114
+ * from the violation set beside the manifest itself, keyed on the ACTIVE
115
+ * manifest's state dir (`isScopeDeclarationPath`, #1027 Pkt 5).
112
116
  *
113
117
  * The hook's OWN snapshot lives in `$TMPDIR`, never inside the repo, so it is
114
118
  * structurally incapable of reporting its own bookkeeping.
@@ -149,18 +153,24 @@ import os from 'node:os';
149
153
  import { fileURLToPath } from 'node:url';
150
154
  import { createHash } from 'node:crypto';
151
155
  import { execFileSync } from 'node:child_process';
152
- import { readFileSync, mkdirSync, writeFileSync, renameSync, realpathSync, statSync } from 'node:fs';
156
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, realpathSync, statSync } from 'node:fs';
153
157
 
154
158
  import { readStdin, writeStdoutLineSync } from '../scripts/lib/io.mjs';
155
- import { resolveProjectDir } from '../scripts/lib/platform.mjs';
156
- import { findScopeFile, pathMatchesPattern } from '../scripts/lib/hardening.mjs';
159
+ import { resolveSessionRoot } from '../scripts/lib/platform.mjs';
160
+ import { pathMatchesPattern } from '../scripts/lib/hardening.mjs';
157
161
  // #1057 — `sessionAgeMs` and its private `clockAgeMs` helper MOVED to the lib so
158
162
  // hooks/enforce-scope.mjs can read the same session clock without a hook->hook
159
163
  // import. Re-exported below, so this hook's public surface — and
160
164
  // tests/hooks/post-bash-write-verify.test.mjs, which imports the named export —
161
165
  // is unchanged. Two byte-identical copies of a clock is exactly the one-fact-two-
162
166
  // copies class this repo keeps paying for.
163
- import { sessionAgeMs, PEER_RECORD_PREFIX, isPeerRecordId } from '../scripts/lib/scope-gate.mjs';
167
+ import {
168
+ sessionAgeMs,
169
+ PEER_RECORD_PREFIX,
170
+ isPeerRecordId,
171
+ isScopeDeclarationPath,
172
+ findOwnScopeFile,
173
+ } from '../scripts/lib/scope-gate.mjs';
164
174
  // #1153 P1 — the same process-local ownership check hooks/enforce-scope.mjs
165
175
  // applies at Gate 3b. This hook reads the SAME working-copy `wave-scope.json`,
166
176
  // so without it a peer session's manifest drives this session's advisories.
@@ -313,6 +323,45 @@ export function parsePorcelainZ(raw) {
313
323
  return out;
314
324
  }
315
325
 
326
+ /**
327
+ * Rebase toplevel-relative porcelain paths onto the SESSION ROOT.
328
+ *
329
+ * `git status --porcelain` names paths relative to the git TOPLEVEL, while
330
+ * `allowedPaths`, the ignore list and the manifest path are relative to the
331
+ * session root — since #1492 the LAUNCH DIR, which in a session started from a
332
+ * repo subdirectory (`<mono>/packages/foo`) sits below the toplevel. Judged
333
+ * unrebased, an in-scope write to `packages/foo/src/new.mjs` failed `src/**`
334
+ * (false positive) and a write to `<mono>/src/evil.mjs` matched it (false
335
+ * negative). A path under the prefix loses it; every other path is outside the
336
+ * project root and is spelled `../…` from the session root — the same category
337
+ * enforce-scope Gate 6 denies, and a spelling no allowedPaths entry can grant
338
+ * (see {@link isOutsideSessionRoot}).
339
+ *
340
+ * @param {string[]} paths toplevel-relative, forward slashes
341
+ * @param {string} prefix session root relative to the toplevel ('' = same dir)
342
+ * @returns {string[]}
343
+ */
344
+ export function rebaseToSessionRoot(paths, prefix) {
345
+ const clean = typeof prefix === 'string' ? prefix.replace(/\/+$/, '') : '';
346
+ // '' = the session root IS the toplevel; '..'/absolute cannot come from
347
+ // `--show-prefix` and would make the arithmetic meaningless — leave as is.
348
+ if (clean === '' || clean.startsWith('..') || clean.startsWith('/')) return paths;
349
+ const head = `${clean}/`;
350
+ const ups = clean.split('/').map(() => '..').join('/');
351
+ return paths.map((p) => (p.startsWith(head) ? p.slice(head.length) : `${ups}/${p}`));
352
+ }
353
+
354
+ /**
355
+ * Is this (session-root-relative) path outside the project root — a `../…`
356
+ * spelling produced by {@link rebaseToSessionRoot}? Never in scope.
357
+ *
358
+ * @param {string} relPath
359
+ * @returns {boolean}
360
+ */
361
+ export function isOutsideSessionRoot(relPath) {
362
+ return relPath === '..' || relPath.startsWith('../');
363
+ }
364
+
316
365
  /**
317
366
  * Is a repo-relative path covered by the wave's `allowedPaths`?
318
367
  *
@@ -384,14 +433,17 @@ export function readPeerScopeRecords(stateDir, wave) {
384
433
  * Which peer record — if any — declared this path?
385
434
  *
386
435
  * Same matcher as `isInScope`, so a peer's declaration is read exactly the way
387
- * the wave's own `allowedPaths` are.
436
+ * the wave's own `allowedPaths` are — and the same outside-root guard as
437
+ * `computeReport`: a `../…` path is outside the project root, which no peer
438
+ * record can grant any more than `allowedPaths` can (a peer glob over every
439
+ * `.md` file would otherwise file `<mono>/NOTES.md` as an agreed peer write).
388
440
  *
389
441
  * @param {string} relPath
390
442
  * @param {Array<{ id: string, files: string[] }>} peerRecords
391
443
  * @returns {string|null} the peer record id, or null
392
444
  */
393
445
  export function peerRecordFor(relPath, peerRecords) {
394
- if (!Array.isArray(peerRecords)) return null;
446
+ if (!Array.isArray(peerRecords) || isOutsideSessionRoot(relPath)) return null;
395
447
  for (const record of peerRecords) {
396
448
  if (record.files.some((p) => pathMatchesPattern(relPath, p))) return record.id;
397
449
  }
@@ -466,14 +518,22 @@ export function snapshotPathFor(repoRoot) {
466
518
  export function computeReport({ dirtyPaths, allowedPaths, snapshot, signature, scopeMtimeMs = null, mtimeMs = null, scopeRelPath = null }) {
467
519
  const outOfScope = dirtyPaths
468
520
  .filter((p) => !isIgnoredPath(p))
469
- .filter((p) => !isInScope(p, allowedPaths))
521
+ // A `../…` path (outside the project root, see rebaseToSessionRoot) is never
522
+ // in scope — not even under a broad glob that would happen to match it.
523
+ .filter((p) => isOutsideSessionRoot(p) || !isInScope(p, allowedPaths))
470
524
  // The wave-scope.json control file is out-of-scope by construction (it is
471
525
  // never under allowedPaths) but its CHANGES are reported via the content-hash
472
526
  // control-notice path, not here. Excluding it keeps the mtime re-baseline
473
527
  // filter from reporting the scope file against ITSELF — its mtime always
474
528
  // equals scopeMtimeMs, so the MED-3 `>=` boundary would otherwise flag it on
475
529
  // every first run (#938 MED-3 follow-through).
476
- .filter((p) => p !== scopeRelPath);
530
+ .filter((p) => p !== scopeRelPath)
531
+ // Its sibling control state, `<state-dir>/filescopes/` (the per-agent scope
532
+ // declarations `materialize-wave-scope.mjs` writes once per wave), is no
533
+ // wave territory either — excluded the same way, not via
534
+ // IGNORED_PATH_PATTERNS, so the exemption follows THIS manifest's state dir
535
+ // instead of granting all four harness dirs (#1027 Pkt 5).
536
+ .filter((p) => !isScopeDeclarationPath(p, scopeRelPath));
477
537
 
478
538
  const rebaselined = !snapshot || snapshot.signature !== signature;
479
539
  const seen = rebaselined || !Array.isArray(snapshot?.paths) ? new Set() : new Set(snapshot.paths);
@@ -515,7 +575,9 @@ export function computeReport({ dirtyPaths, allowedPaths, snapshot, signature, s
515
575
  * @returns {string}
516
576
  */
517
577
  export function formatMessage(report, allowedCount) {
518
- const shown = report.slice(0, MAX_REPORTED_PATHS);
578
+ const shown = report
579
+ .slice(0, MAX_REPORTED_PATHS)
580
+ .map((p) => (isOutsideSessionRoot(p) ? `${p} (outside project root)` : p));
519
581
  const more = report.length - shown.length;
520
582
  const tail = more > 0 ? ` (+${more} more)` : '';
521
583
  const msg =
@@ -608,12 +670,13 @@ export function formatSnapshotMissingNotice({ sessionAge = null, scopeAge = null
608
670
  // ---------------------------------------------------------------------------
609
671
 
610
672
  /**
611
- * Collect the working-tree dirty set. Returns null when git is unavailable, the
673
+ * Collect the working-tree dirty set, rebased onto the session root
674
+ * ({@link rebaseToSessionRoot}). Returns null when git is unavailable, the
612
675
  * directory is not a repo, or the call times out — all of which mean "no signal
613
676
  * to report", never a warning.
614
677
  *
615
- * @param {string} repoRoot
616
- * @returns {string[]|null}
678
+ * @param {string} repoRoot the session root
679
+ * @returns {string[]|null} session-root-relative paths (`../…` = outside it)
617
680
  */
618
681
  function readDirtyPaths(repoRoot) {
619
682
  try {
@@ -630,7 +693,16 @@ function readDirtyPaths(repoRoot) {
630
693
  ['--no-optional-locks', 'status', '--porcelain', '-z', '--no-renames', '--untracked-files=all'],
631
694
  { cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: GIT_MAX_BUFFER, stdio: ['ignore', 'pipe', 'ignore'] },
632
695
  );
633
- return parsePorcelainZ(raw);
696
+ // Porcelain paths are toplevel-relative; everything they are judged against
697
+ // is session-root-relative. A `.git` AT the session root makes it the
698
+ // toplevel (prefix ''), so the extra spawn is paid only by a session
699
+ // launched from a repo subdirectory.
700
+ const prefix = existsSync(path.join(repoRoot, '.git'))
701
+ ? ''
702
+ : execFileSync('git', ['rev-parse', '--show-prefix'], {
703
+ cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'],
704
+ }).trim();
705
+ return rebaseToSessionRoot(parsePorcelainZ(raw), prefix);
634
706
  } catch {
635
707
  return null;
636
708
  }
@@ -807,7 +879,10 @@ async function main() {
807
879
  // G2 — only Bash calls carry the bypass risk this hook watches.
808
880
  if (input.tool_name !== 'Bash') return;
809
881
 
810
- const repoRootRaw = resolveProjectDir();
882
+ // #1492 — the SESSION's working copy, not the launch dir `$CLAUDE_PROJECT_DIR`
883
+ // keeps after `EnterWorktree` (the manifest lives in the worktree). See
884
+ // `resolveSessionRoot` in scripts/lib/platform.mjs.
885
+ const repoRootRaw = resolveSessionRoot(input.cwd);
811
886
  let repoRoot;
812
887
  try {
813
888
  repoRoot = realpathSync(repoRootRaw);
@@ -833,7 +908,14 @@ async function main() {
833
908
  // G3 — no wave scope → nothing defines "outside". #938: if a previous call
834
909
  // RECORDED a scope state, the control file's disappearance is itself a
835
910
  // control-file change and gets one visible (non-alarming) notice.
836
- const scopePath = findScopeFile(repoRoot);
911
+ //
912
+ // #1504 point 6 — the first manifest that is not provably a peer's, so a
913
+ // peer's higher-precedence manifest no longer hides this session's own one;
914
+ // only when every candidate is foreign does the first come back, and G3b
915
+ // below keeps its stand-down and its rebind notice unchanged.
916
+ const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
917
+ const located = findOwnScopeFile(repoRoot, ownIds, classifyManifestSession);
918
+ const scopePath = located.path ?? located.foreignPath;
837
919
  if (!scopePath) {
838
920
  if (prevScopeState && prevScopeState.hash !== 'absent') {
839
921
  writeSnapshot(snapFile, {
@@ -857,7 +939,7 @@ async function main() {
857
939
  } catch {
858
940
  /* raw stays null (unreadable) or scope stays null (unparseable) */
859
941
  }
860
- if (raw === null) return; // file vanished between findScopeFile and read
942
+ if (raw === null) return; // file vanished between findOwnScopeFile and read
861
943
 
862
944
  // The control-file identity of THIS call, computed BEFORE the G3b
863
945
  // stand-down (W4/F7). Ordering is the whole fix: G3b used to `return` above
@@ -898,7 +980,6 @@ async function main() {
898
980
  // CLI, Cursor today) the own-id set is empty, so this gate is permanently
899
981
  // `'unknown'` = full pre-#1153 behaviour there.
900
982
  {
901
- const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
902
983
  const { verdict, manifestIds } = classifyManifestSession(scope ?? {}, ownIds);
903
984
  if (verdict === 'foreign') {
904
985
  // Observability only — one event per stand-down decision, awaited so the
@@ -35,8 +35,13 @@ import { shouldRunHook } from './_lib/profile-gate.mjs';
35
35
  import { isMainModule } from '../scripts/lib/is-main-module.mjs';
36
36
 
37
37
  import { readStdin } from '../scripts/lib/io.mjs';
38
- import { resolveProjectDir } from '../scripts/lib/platform.mjs';
39
- import { findScopeFile, gateEnabled } from '../scripts/lib/hardening.mjs';
38
+ import { resolveSessionRoot } from '../scripts/lib/platform.mjs';
39
+ import { gateEnabled } from '../scripts/lib/hardening.mjs';
40
+ import { findOwnScopeFile } from '../scripts/lib/scope-gate.mjs';
41
+ import {
42
+ classifyManifestSession,
43
+ readProcessLocalSessionIds,
44
+ } from '../scripts/lib/session-identity/own-session.mjs';
40
45
  import { readConfigFile, parseSessionConfig } from '../scripts/lib/config.mjs';
41
46
 
42
47
  // ---------------------------------------------------------------------------
@@ -184,7 +189,9 @@ async function main() {
184
189
  const ext = path.extname(filePath).toLowerCase();
185
190
  if (!TS_EXTS.has(ext)) process.exit(0);
186
191
 
187
- const projectRoot = resolveProjectDir();
192
+ // The SESSION root the scope guards use (#1492, #1504 point 4) — not the
193
+ // launch dir, which stays put while a session works in an entered worktree.
194
+ const projectRoot = resolveSessionRoot(input?.cwd);
188
195
 
189
196
  // Compute relative path (REQ-03)
190
197
  const absFilePath = path.isAbsolute(filePath)
@@ -192,8 +199,11 @@ async function main() {
192
199
  : path.resolve(projectRoot, filePath);
193
200
  const relPath = path.relative(projectRoot, absFilePath);
194
201
 
195
- // G5: per-gate toggle — skip if post-edit-validate gate is disabled
196
- const scopeFile = findScopeFile(projectRoot);
202
+ // G5: per-gate toggle — skip if post-edit-validate gate is disabled. Only
203
+ // THIS session's manifest may switch it off: a peer's manifest in the same
204
+ // working copy is skipped, and a peer-only result means no toggle (#1504).
205
+ const ownIds = new Set(readProcessLocalSessionIds({ hookInput: input }));
206
+ const { path: scopeFile } = findOwnScopeFile(projectRoot, ownIds, classifyManifestSession);
197
207
  if (scopeFile && !gateEnabled(scopeFile, 'post-edit-validate')) {
198
208
  process.exit(0);
199
209
  }