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
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,15 +369,47 @@ 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) {
403
+ // Two roots, by writer (#1511 point b). `current-session.json` and
404
+ // `session.lock` (session id, lock heartbeat, duration) stay at the LAUNCH
405
+ // dir: on-session-start.mjs writes both at `resolveProjectDir()`. The wave
406
+ // and the commit/branch describe the tree the session WORKS in — after
407
+ // EnterWorktree that is the session root, not the launch checkout.
371
408
  const projectRoot = getProjectDir();
409
+ const sessionRoot = resolveSessionRoot(input?.cwd);
372
410
 
373
- const wave = await readWaveNumber(projectRoot);
374
- const { commit, branch } = await gitInfo(projectRoot);
411
+ const wave = await readWaveNumber(sessionRoot, input);
412
+ const { commit, branch } = await gitInfo(sessionRoot);
375
413
 
376
414
  const { sessionId, semanticSessionId } = await resolveSessionId(input, projectRoot);
377
415
 
@@ -483,6 +521,7 @@ async function handleStop(input) {
483
521
  ...(branch !== null ? { branch } : {}),
484
522
  ...(commit !== null ? { commit } : {}),
485
523
  ...duration,
524
+ ...reaperTriggerField(reaperTrigger),
486
525
  };
487
526
 
488
527
  try {
@@ -500,9 +539,10 @@ async function handleStop(input) {
500
539
  * coordinator turn. Currently always returns null (no inline warning from this
501
540
  * handler) — the slot is reserved for future SubagentStop feedback.
502
541
  * @param {object|null} input
542
+ * @param {{reason?: string}|null} [reaperTrigger] `maybeTriggerOrphanScan()` result.
503
543
  * @returns {Promise<string|null>} additionalContext or null
504
544
  */
505
- async function handleSubagentStop(input) {
545
+ async function handleSubagentStop(input, reaperTrigger = null) {
506
546
  /** @type {Record<string, unknown>} */
507
547
  const payload = {};
508
548
 
@@ -633,8 +673,12 @@ async function handleSubagentStop(input) {
633
673
  // #1183 — a malformed record throws EventValidationError BEFORE any side
634
674
  // effect (scripts/lib/events.mjs); this hook must never abort on that, so
635
675
  // the emit is wrapped rather than left to propagate.
676
+ // A fresh object via spread (like the Stop record above), never
677
+ // Object.assign onto a parsed payload: spread defines keys, so no `__proto__`
678
+ // key can reach the prototype setter (CWE-1321, semgrep
679
+ // prototype-pollution-object-assign).
636
680
  try {
637
- await emitEvent('orchestrator.agent.stopped', payload);
681
+ await emitEvent('orchestrator.agent.stopped', { ...payload, ...reaperTriggerField(reaperTrigger) });
638
682
  } catch { /* telemetry never blocks the hook (#1183) */ }
639
683
  return null;
640
684
  }
@@ -775,147 +819,6 @@ function readStatusFromTranscriptTail(transcriptPath) {
775
819
  // main
776
820
  // ---------------------------------------------------------------------------
777
821
 
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
822
  async function main() {
920
823
  const input = await readStdinJson();
921
824
  const eventType = discriminate(input);
@@ -926,11 +829,14 @@ async function main() {
926
829
  // hook, which triggers last) because both branches below may throw — a
927
830
  // `handleStop` failure must not silently disarm the watchdog. The cost is one
928
831
  // 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();
832
+ // and the scan itself runs in a detached child. Its outcome rides on the
833
+ // record either branch emits anyway (`reaper_trigger`, see reaperTriggerField).
834
+ // Rooted at the SESSION root (#1492 point 2): the gate ledger the scan reads
835
+ // lives in the tree the gate ran in — an entered worktree, not the launch dir.
836
+ const reaperTrigger = await maybeTriggerOrphanScan({ projectDir: resolveSessionRoot(input?.cwd) });
931
837
 
932
838
  if (eventType === 'subagent_stop') {
933
- const additionalContext = await handleSubagentStop(input);
839
+ const additionalContext = await handleSubagentStop(input, reaperTrigger);
934
840
  // v2.1.163+: emit hookSpecificOutput for SubagentStop path.
935
841
  // If handleSubagentStop returns a non-empty context string, feed it back
936
842
  // to the coordinator turn. Currently returns null (no inline warning from
@@ -951,7 +857,7 @@ async function main() {
951
857
  // SubagentStop is intentionally outside this block — it must never emit
952
858
  // terminalSequence.
953
859
  try {
954
- await handleStop(input);
860
+ await handleStop(input, reaperTrigger);
955
861
  } finally {
956
862
  // terminalSequence is only meaningful for Stop (session-level) events.
957
863
  process.stdout.write(buildTerminalSequenceJson(detectPlatform()));