session-orchestrator 5.3.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -0,0 +1,510 @@
1
+ /**
2
+ * orphan-reaper/scan.mjs — one orphan scan end to end: `ps` → decide → (unless
3
+ * `dryRun`) re-verify identity before every signal → kill the group → audit.
4
+ *
5
+ * The IMPURE half of the reaper. Everything impure reaches the pure decision
6
+ * (`reaper-decide.mjs`) through `deps` ({@link resolveDeps}), the
7
+ * `lock-reaper.mjs` split of `evaluateRepo()` / `archiveLock()`.
8
+ */
9
+
10
+ import {
11
+ killProcessGroup,
12
+ pruneGateProcessLedger,
13
+ readGateProcessLedger,
14
+ verifyProcessIdentity,
15
+ } from '../process-group.mjs';
16
+ import { REAPER_DEFAULTS } from './defaults.mjs';
17
+ import { parsePsSnapshot, parsePsSnapshotDetailed, runPs, runPsPid } from './ps-snapshot.mjs';
18
+ import {
19
+ FALSE_ALARM_SUSPECT_RATE,
20
+ appendAuditRecord,
21
+ buildAuditRecord,
22
+ falseAlarmRate,
23
+ pruneReaperAudit,
24
+ readAuditRecords,
25
+ } from './reaper-audit.mjs';
26
+ import { decideReapCandidates, verifyGroupMemberIdentity } from './reaper-decide.mjs';
27
+
28
+ /** Name const for the one event this module emits. @see docs/events-schema.md */
29
+ export const REAPER_SCAN_EVENT = 'orchestrator.reaper.scan_completed';
30
+
31
+ /** Default own-session-id reader. Lazily imported so the static import closure
32
+ * of this module stays small — it is destined for a hot-path hook (#1432).
33
+ * @param {string} repoRoot @returns {Promise<string|null>} */
34
+ async function defaultReadOwnSessionId(repoRoot) {
35
+ try {
36
+ const mod = await import('../session-identity/own-session.mjs');
37
+ const ids = mod.readOwnSessionIds(repoRoot);
38
+ for (const id of ids) return id;
39
+ return null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Default live-peer probe. Lazily imported for the same reason as
47
+ * {@link defaultReadOwnSessionId}.
48
+ *
49
+ * Returns `null` — never `[]` — when the registry cannot be read. The two mean
50
+ * opposite things to `decideReapCandidates`: `[]` is a MEASUREMENT ("no
51
+ * peer is alive", so a foreign session's leftovers are reapable), `null` is the
52
+ * absence of one ("report it, do not touch it").
53
+ *
54
+ * @param {string|null} sessionId
55
+ * @returns {Promise<string[]|null>}
56
+ */
57
+ async function defaultDetectPeers(sessionId) {
58
+ try {
59
+ const mod = await import('../session-registry.mjs');
60
+ const peers = await mod.detectPeers({ sessionId: sessionId ?? undefined });
61
+ if (!Array.isArray(peers)) return null;
62
+ return peers.map((p) => p?.session_id).filter((v) => typeof v === 'string' && v.length > 0);
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Default event emitter. Lazily imported for the same reason as
70
+ * {@link defaultReadOwnSessionId}: `events.mjs` pulls in the schema validator,
71
+ * the attribution chain and the webhook client, and this module is destined for
72
+ * a hot-path hook (#1432) whose static import closure is measured.
73
+ *
74
+ * Never throws and never rejects — telemetry that can fail a scan would fail the
75
+ * hook the scan runs in.
76
+ *
77
+ * @param {string} type
78
+ * @param {object} payload
79
+ * @param {{repoRoot?: string}} [opts]
80
+ * @returns {Promise<boolean>} whether the record was written
81
+ */
82
+ async function defaultEmitEvent(type, payload, opts = {}) {
83
+ try {
84
+ const mod = await import('../events.mjs');
85
+ await mod.emitEvent(type, payload, opts);
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ /** @param {number} ms @returns {Promise<void>} */
93
+ function defaultSleep(ms) {
94
+ return new Promise((resolve) => { setTimeout(resolve, ms); });
95
+ }
96
+
97
+ /**
98
+ * Resolve the adapter, defaulting every seam to the real implementation — the
99
+ * `resolveDeps()` pattern from `lock-reaper.mjs:77-100`.
100
+ *
101
+ * `killProcessGroup` HAS a real default, and that is safe only because
102
+ * {@link runOrphanScan} defaults `dryRun: true`. Tests ALWAYS inject it
103
+ * (`.claude/rules/testing.md`: never a real process as a kill target).
104
+ *
105
+ * @param {object} [deps]
106
+ * @returns {object}
107
+ */
108
+ export function resolveDeps(deps = {}) {
109
+ const d = deps ?? {};
110
+ return {
111
+ runPs: d.runPs ?? runPs,
112
+ runPsPid: d.runPsPid ?? runPsPid,
113
+ readLedger: d.readLedger ?? readGateProcessLedger,
114
+ verifyIdentity: d.verifyIdentity ?? verifyProcessIdentity,
115
+ killProcessGroup: d.killProcessGroup ?? killProcessGroup,
116
+ detectPeers: d.detectPeers ?? defaultDetectPeers,
117
+ readOwnSessionId: d.readOwnSessionId ?? defaultReadOwnSessionId,
118
+ appendAudit: d.appendAudit ?? appendAuditRecord,
119
+ readAuditRecords: d.readAuditRecords ?? readAuditRecords,
120
+ emitEvent: d.emitEvent ?? defaultEmitEvent,
121
+ now: d.now ?? Date.now,
122
+ sleep: d.sleep ?? defaultSleep,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Map an identity verdict's reason to the audit's `reason` vocabulary.
128
+ *
129
+ * Three outcomes, never two: `gone` (measured, the process is not there),
130
+ * `signature-mismatch` / `identity-mismatch` (measured, it is a DIFFERENT
131
+ * process) and `unmeasured` (the probe itself could not answer). The third is
132
+ * the one a two-state mapping loses, and losing it is how an absent measurement
133
+ * starts reading like a clean verdict.
134
+ *
135
+ * @param {string|null|undefined} identityReason
136
+ * @returns {'gone'|'signature-mismatch'|'identity-mismatch'|'unmeasured'}
137
+ */
138
+ function rejectReasonFor(identityReason) {
139
+ if (identityReason === 'gone') return 'gone';
140
+ if (identityReason === 'unmeasured') return 'unmeasured';
141
+ if (identityReason === 'signature-mismatch') return 'signature-mismatch';
142
+ return 'identity-mismatch';
143
+ }
144
+
145
+ /**
146
+ * Run one orphan scan: `ps` → decide → (unless `dryRun`) re-verify identity
147
+ * against a FRESH snapshot → kill the group → audit.
148
+ *
149
+ * NO-THROW contract: every failure degrades into `skipped: '<reason>'` or a
150
+ * `rejected` entry, because this runs from a hook and must never fail it
151
+ * (PRD FA4: "bei jedem Fehler degradiert er lautlos").
152
+ *
153
+ * The pre-signal re-check is the TOCTOU defence and the whole point of FA3: a
154
+ * PID recycled between decision and signal must NOT be signalled. It runs a
155
+ * TARGETED `ps` (`psPidArgs`, `ps-snapshot.mjs`) through `deps.runPsPid` and feeds the row to
156
+ * {@link verifyProcessIdentity} — immediately before SIGTERM **and** immediately
157
+ * before SIGKILL, via `killProcessGroup`'s `beforeSignal` gate, because the
158
+ * ladder's 10 s grace is itself a recycling window. On any mismatch no further
159
+ * signal is sent and the candidate is recorded as `decision: 'reject'` with the
160
+ * reason that withdrew it. Such a candidate stays in `candidates` (that WAS the
161
+ * decision) and additionally appears in `rejected` (that is the withdrawal) —
162
+ * the two arrays answer different questions and collapsing them would lose the
163
+ * TOCTOU event.
164
+ *
165
+ * Success is proven the same way, never from an exit code: after the ladder the
166
+ * scan waits `verifyWaitMs` and re-measures (B6). `ok` is true only for a
167
+ * process that is GONE; one that outlived SIGKILL is booked
168
+ * `survivedSigkill: true`, and one the probe could not measure is `verified:
169
+ * 'unmeasured'` — neither is ever a success.
170
+ *
171
+ * `dryRun` defaults to TRUE: arming happens at the CALL SITE (#1432), so a
172
+ * caller that forgets the flag scans and reports instead of killing.
173
+ *
174
+ * @param {object} opts
175
+ * @param {string} opts.repoRoot
176
+ * @param {number} [opts.now]
177
+ * @param {boolean} [opts.dryRun]
178
+ * @param {object} [opts.deps]
179
+ * @param {number} [opts.minAgeSeconds]
180
+ * @param {number} [opts.killGraceMs]
181
+ * @param {number} [opts.verifyWaitMs]
182
+ * @param {number} [opts.falseAlarmWindow] Rolling window of audit DECISIONS the
183
+ * HR-101 rate is judged over (`reaper.false-alarm-window`).
184
+ * @returns {Promise<{scanned: number, candidates: object[], reported: object[],
185
+ * rejected: object[], killed: object[], unattributed: number,
186
+ * peerLiveness: 'measured'|'unmeasured'|null, skipped?: string, malformed: number,
187
+ * durationMs: number, instrumentSuspect: boolean|null, falseAlarmRate: number|null,
188
+ * falseAlarmWindowN: number}>}
189
+ * `instrumentSuspect`/`falseAlarmRate` are `null` on a degraded (`skipped`) scan
190
+ * and `falseAlarmRate` is `null` below the 10-decision floor — in both cases a
191
+ * measurement that does not exist, never a measured zero. `peerLiveness` is the
192
+ * same distinction for the peer probe, and `null` only on a degraded scan.
193
+ */
194
+ export async function runOrphanScan({
195
+ repoRoot,
196
+ now,
197
+ dryRun = true,
198
+ deps,
199
+ minAgeSeconds = REAPER_DEFAULTS.minAgeSeconds,
200
+ killGraceMs = REAPER_DEFAULTS.killGraceMs,
201
+ verifyWaitMs = REAPER_DEFAULTS.verifyWaitMs,
202
+ falseAlarmWindow = REAPER_DEFAULTS.falseAlarmWindow,
203
+ } = {}) {
204
+ const d = resolveDeps(deps);
205
+ const startedAt = typeof now === 'number' ? now : d.now();
206
+ // A degraded scan measured NOTHING — including the instrument's own health.
207
+ // `null` rather than `false`/`0` for both instrument fields: a scan that never
208
+ // ran must not report a healthy instrument it never looked at.
209
+ const empty = (skipped, malformed = 0) => ({
210
+ scanned: 0,
211
+ candidates: [],
212
+ reported: [],
213
+ rejected: [],
214
+ killed: [],
215
+ unattributed: 0,
216
+ // Nothing was measured, so peer liveness was not measured either — `null`
217
+ // rather than the string, because "unmeasured" is a MEASUREMENT OUTCOME and
218
+ // a degraded scan never got as far as the probe.
219
+ peerLiveness: null,
220
+ skipped,
221
+ malformed,
222
+ durationMs: Math.max(0, d.now() - startedAt),
223
+ instrumentSuspect: null,
224
+ falseAlarmRate: null,
225
+ falseAlarmWindowN: 0,
226
+ });
227
+
228
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) return empty('no-repo-root');
229
+
230
+ let text;
231
+ try {
232
+ text = await d.runPs();
233
+ } catch {
234
+ return empty('ps-failed');
235
+ }
236
+ if (text === null || text === undefined) return empty('ps-failed');
237
+
238
+ const { rows, malformed } = parsePsSnapshotDetailed(text);
239
+
240
+ let ledger;
241
+ try {
242
+ ledger = d.readLedger(repoRoot, { nowMs: startedAt });
243
+ } catch {
244
+ return empty('ledger-unreadable', malformed);
245
+ }
246
+ // The default reader never throws: a refused or unreadable ledger comes back
247
+ // as no records with `state: 'unreadable'` (#1498). Read as "no register
248
+ // hits" it ran a clean-looking scan that measured nothing.
249
+ if (ledger?.state === 'unreadable') return empty('ledger-unreadable', malformed);
250
+
251
+ let ownSessionId = null;
252
+ try {
253
+ ownSessionId = await d.readOwnSessionId(repoRoot);
254
+ } catch {
255
+ ownSessionId = null;
256
+ }
257
+
258
+ /** @type {string[]|null} */
259
+ let livePeerSessionIds;
260
+ try {
261
+ livePeerSessionIds = await d.detectPeers(ownSessionId);
262
+ } catch {
263
+ // null = unmeasured, NOT "no peers" — see decideReapCandidates.
264
+ livePeerSessionIds = null;
265
+ }
266
+
267
+ const { candidates, reported, rejected } = decideReapCandidates(
268
+ rows,
269
+ ledger?.records ?? [],
270
+ startedAt,
271
+ { ownSessionId, livePeerSessionIds, minAgeSeconds },
272
+ );
273
+
274
+ const timestamp = new Date(startedAt).toISOString();
275
+ const audit = (entry, decision, extra = {}) => {
276
+ try {
277
+ d.appendAudit(repoRoot, buildAuditRecord(entry, decision, {
278
+ timestamp, sessionId: ownSessionId, ...extra,
279
+ }));
280
+ } catch {
281
+ /* the audit is an aid, never a precondition */
282
+ }
283
+ };
284
+
285
+ for (const entry of reported) audit(entry, 'report');
286
+
287
+ /** @type {object[]} */
288
+ const killed = [];
289
+
290
+ /**
291
+ * Close the scan: measure the instrument's own health (HR-101), emit at most
292
+ * one event, return the result. Both real exits go through here so the rate
293
+ * and the event can never be computed twice or forgotten once.
294
+ * @returns {Promise<object>}
295
+ */
296
+ const complete = async () => {
297
+ let auditRecords;
298
+ try {
299
+ auditRecords = (await d.readAuditRecords(repoRoot, falseAlarmWindow)) ?? [];
300
+ } catch {
301
+ auditRecords = [];
302
+ }
303
+ const fa = falseAlarmRate(auditRecords, falseAlarmWindow);
304
+ // HR-101: the rate re-aims the instrument, it never re-thresholds it — so
305
+ // this flag is REPORTED and nothing here branches on it.
306
+ const instrumentSuspect = typeof fa.rate === 'number' && fa.rate > FALSE_ALARM_SUSPECT_RATE;
307
+ const survivedSigkill = killed.filter((k) => k.survivedSigkill === true).length;
308
+ const durationMs = Math.max(0, d.now() - startedAt);
309
+ // Two counts that must not hide inside `reported`: an unattributed record is
310
+ // a PRODUCER defect (the gate runner passed no session id), and an
311
+ // unmeasured peer probe is an INSTRUMENT gap. Folded into the generic
312
+ // `reported` number, both are invisible — which is how the foreign-session
313
+ // guard stayed inert across 377 of 377 records without anything saying so.
314
+ const unattributed = reported.filter((r) => r.reason === 'unattributed').length;
315
+ const peerLiveness = livePeerSessionIds === null ? 'unmeasured' : 'measured';
316
+
317
+ // HR-101 again, in the other direction: a signal that fires on every hook
318
+ // is noise nobody reads. A scan that found nothing emits nothing — the
319
+ // absence of a record IS the healthy state, and `instrumentSuspect` is the
320
+ // one finding that must surface even from an empty scan.
321
+ if (candidates.length + reported.length + killed.length > 0 || instrumentSuspect) {
322
+ try {
323
+ await d.emitEvent(REAPER_SCAN_EVENT, {
324
+ scanned: rows.length,
325
+ candidates: candidates.length,
326
+ reported: reported.length,
327
+ rejected: rejected.length,
328
+ killed: killed.length,
329
+ unattributed,
330
+ peer_liveness: peerLiveness,
331
+ survived_sigkill: survivedSigkill,
332
+ dry_run: dryRun === true,
333
+ duration_ms: durationMs,
334
+ instrument_suspect: instrumentSuspect,
335
+ // OMITTED below the 10-decision floor, never 0: absence means "no
336
+ // population yet", and a fabricated zero would read as a clean
337
+ // instrument (`falseAlarmRate` returns `{rate: null}` there).
338
+ ...(typeof fa.rate === 'number' ? { false_alarm_rate: fa.rate } : {}),
339
+ }, { repoRoot });
340
+ } catch {
341
+ /* telemetry that can fail a scan would fail the hook the scan runs in */
342
+ }
343
+ }
344
+
345
+ // Housekeeping, last and best-effort: the ledger is append-only and grew
346
+ // monotonically (measured 2026-09-22: 369 lines/day) because nothing in
347
+ // production ever called the pruner.
348
+ try {
349
+ pruneGateProcessLedger(repoRoot);
350
+ // The audit is pruned AFTER it was read for the rate above, so a prune
351
+ // can never shrink the population this scan judged itself on.
352
+ pruneReaperAudit(repoRoot);
353
+ } catch {
354
+ /* housekeeping, never a precondition of a scan */
355
+ }
356
+
357
+ return {
358
+ scanned: rows.length,
359
+ candidates,
360
+ reported,
361
+ rejected,
362
+ killed,
363
+ unattributed,
364
+ peerLiveness,
365
+ malformed,
366
+ durationMs,
367
+ instrumentSuspect,
368
+ falseAlarmRate: fa.rate,
369
+ falseAlarmWindowN: fa.n,
370
+ };
371
+ };
372
+
373
+ if (dryRun) {
374
+ for (const c of candidates) audit(c, 'dry-run');
375
+ return complete();
376
+ }
377
+
378
+ /**
379
+ * ONE fresh, targeted identity measurement for one candidate (B3).
380
+ *
381
+ * There is deliberately no second, bulk `ps` pass any more: two TOCTOU
382
+ * instruments measuring the same property at different freshness is how the
383
+ * stale one silently wins. This is the only pre-signal measurement, and it is
384
+ * taken immediately before each signal rather than once for the whole pass.
385
+ *
386
+ * @param {object} c
387
+ * @returns {Promise<{match: boolean, reason: string, observed?: object}>}
388
+ */
389
+ const freshIdentity = async (c) => {
390
+ let text;
391
+ try {
392
+ text = await d.runPsPid(c.pid);
393
+ } catch {
394
+ text = null;
395
+ }
396
+ // `ps -p` cannot distinguish "gone" from "could not run"; both must refuse
397
+ // the signal, and only the second is an instrument gap worth its own reason.
398
+ if (text === null || text === undefined) return { match: false, reason: 'unmeasured' };
399
+ const row = parsePsSnapshot(text).find((r) => r.pid === c.pid) ?? null;
400
+ try {
401
+ // A group MEMBER is re-verified the way it was decided — by the leader's
402
+ // seam it would fail every time (its own start time, its own command),
403
+ // and a pre-signal check that always refuses is a disarmed feature, not a
404
+ // strict one.
405
+ if (c.isLeader === false) {
406
+ return verifyGroupMemberIdentity(row, c.ledgerRecord, { nowMs: d.now() });
407
+ }
408
+ return d.verifyIdentity(
409
+ c.pid,
410
+ { startTime: c.ledgerRecord.startTime, commandSignature: c.ledgerRecord.commandSignature },
411
+ { snapshotLine: row, nowMs: d.now() },
412
+ );
413
+ } catch {
414
+ return { match: false, reason: 'gone' };
415
+ }
416
+ };
417
+
418
+ // ONE ladder per GROUP, not per row: with the pgid join a single group can
419
+ // contribute several candidate rows (leader + descendants), and they all name
420
+ // the same kill target. Leaders first, so the target of a group is the process
421
+ // the ledger actually recorded whenever it is still alive.
422
+ const killTargets = [];
423
+ const coveredPgids = new Set();
424
+ for (const c of [...candidates.filter((x) => x.isLeader), ...candidates.filter((x) => !x.isLeader)]) {
425
+ if (coveredPgids.has(c.pgid)) continue;
426
+ coveredPgids.add(c.pgid);
427
+ killTargets.push(c);
428
+ }
429
+
430
+ for (const c of killTargets) {
431
+ /** Set by the gate below when it refuses; `null` means every signal was permitted. */
432
+ let withdrawal = null;
433
+ const beforeSignal = async (signal) => {
434
+ const identity = await freshIdentity(c);
435
+ if (identity?.match === true) return true;
436
+ withdrawal = { signal, identity, reason: rejectReasonFor(identity?.reason) };
437
+ return false;
438
+ };
439
+
440
+ let result;
441
+ try {
442
+ result = await d.killProcessGroup(c.pgid, {
443
+ killGraceMs, verifyWaitMs, sleepFn: d.sleep, beforeSignal,
444
+ });
445
+ } catch (err) {
446
+ result = { ok: false, signalsSent: [], survivors: [c.pgid], error: err?.code ?? null, aborted: null };
447
+ }
448
+ const signalsSent = Array.isArray(result?.signalsSent) ? result.signalsSent : [];
449
+
450
+ if (withdrawal !== null) {
451
+ // The candidate was withdrawn between decision and signal. It stays in
452
+ // `candidates` (that WAS the decision) and appears in `rejected` (that is
453
+ // the withdrawal) — collapsing the two would lose the TOCTOU event.
454
+ // `signalsSent` is carried even here: a withdrawal before the ESCALATION
455
+ // still means a SIGTERM went out, and an audit that hid it would
456
+ // under-report what this reaper did to the host.
457
+ rejected.push({
458
+ ...c, reason: withdrawal.reason, identity: withdrawal.identity, signalsSent,
459
+ });
460
+ audit(c, 'reject', {
461
+ reason: withdrawal.reason,
462
+ result: {
463
+ ok: false,
464
+ signalsSent,
465
+ survivors: [],
466
+ survivedSigkill: false,
467
+ verifiedAfterMs: 0,
468
+ verified: 'withdrawn',
469
+ },
470
+ });
471
+ continue;
472
+ }
473
+
474
+ // B6: prove the EFFECT, after a wait, from a fresh measurement of the
475
+ // process table. An exit code and a sent signal prove nothing — on
476
+ // 2026-09-20 a probe with no wait reported "still alive" for dead processes.
477
+ let verified = 'unverified';
478
+ if (signalsSent.length > 0) {
479
+ await d.sleep(verifyWaitMs);
480
+ const after = await freshIdentity(c);
481
+ if (after?.match === true) verified = 'alive';
482
+ else if (after?.reason === 'unmeasured') verified = 'unmeasured';
483
+ else verified = 'gone';
484
+ }
485
+ const ok = verified === 'gone';
486
+ const survivedSigkill = verified === 'alive' && signalsSent.includes('SIGKILL');
487
+ const survivors = ok ? [] : [c.pgid];
488
+ const verifiedAfterMs = signalsSent.length > 0 ? verifyWaitMs : 0;
489
+
490
+ killed.push({
491
+ pid: c.pid,
492
+ pgid: c.pgid,
493
+ // Every candidate row this one ladder covers — the group join means one
494
+ // signal can end several candidates, and a `killed` list that named only
495
+ // the target would under-report what the reaper did to the host.
496
+ groupMemberPids: candidates.filter((x) => x.pgid === c.pgid).map((x) => x.pid),
497
+ ok,
498
+ signalsSent,
499
+ survivors,
500
+ survivedSigkill,
501
+ verified,
502
+ verifiedAfterMs,
503
+ });
504
+ audit(c, 'kill', {
505
+ result: { ok, signalsSent, survivors, survivedSigkill, verifiedAfterMs, verified },
506
+ });
507
+ }
508
+
509
+ return complete();
510
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * orphan-reaper/trigger.mjs — the B4 trigger both hooks call (#1432 B4).
3
+ *
4
+ * `hooks/on-stop.mjs` (Stop + SubagentStop) and
5
+ * `hooks/post-tool-batch-wave-signal.mjs` (PostToolBatch) are the two trigger
6
+ * points the PRD declares. Until 2026-10-02 each carried its own verbatim copy
7
+ * of this code (138 identical lines, one comment apart, measured by diffing the
8
+ * blocks); the copies are now this one module (#1489).
9
+ *
10
+ * Its own file rather than part of `scan-throttle.mjs`, for two closures:
11
+ * - `scan-throttle.mjs` is re-exported by the detached scan CLI
12
+ * (`scripts/lib/orphan-reaper.mjs`); putting the trigger there would load the
13
+ * config parser and `platform.mjs` into every scan child for nothing.
14
+ * - The throttle stays a LAZY import below, so a host with the reaper disabled
15
+ * (the default) pays for one config read and no throttle module at all.
16
+ */
17
+
18
+ import { spawn } from 'node:child_process';
19
+ import { readFileSync } from 'node:fs';
20
+ import path from 'node:path';
21
+ import { performance } from 'node:perf_hooks';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ import { _parseReaper } from '../config/reaper.mjs';
25
+ import { getProjectDir } from '../platform.mjs';
26
+
27
+ /**
28
+ * Filesystem path of the scan CLI, spawned as a PLAIN argv call.
29
+ *
30
+ * It used to be a `file://` URL handed to `node --input-type=module -e
31
+ * <program>`, because `scripts/lib/orphan-reaper.mjs` had no entry guard. It
32
+ * has one now (`parseReaperCliArgs` + the `isMainModule` tail), so the child's
33
+ * contract lives in that module instead of as source text duplicated here —
34
+ * and nothing this trigger builds is a program any more: every variable part is
35
+ * an argv value, which cannot become code whatever the checkout path contains.
36
+ *
37
+ * `fileURLToPath`, not `new URL(...).pathname`: the latter leaves a
38
+ * percent-encoded path for any checkout directory containing a space.
39
+ */
40
+ const ORPHAN_REAPER_SCRIPT = fileURLToPath(new URL('../orphan-reaper.mjs', import.meta.url));
41
+
42
+ /**
43
+ * Read the `reaper:` block from the project's Session Config host file.
44
+ * Sync + inline, mirroring `hooks/loop-guard.mjs` `loadConfig()` — a hot hook
45
+ * path must not import the full config orchestrator. A missing or unreadable
46
+ * file yields the parser defaults, i.e. DISABLED.
47
+ *
48
+ * @param {string} projectDir
49
+ * @returns {ReturnType<typeof _parseReaper>}
50
+ */
51
+ function loadReaperConfig(projectDir) {
52
+ for (const name of ['CLAUDE.md', 'AGENTS.md']) {
53
+ try {
54
+ return _parseReaper(readFileSync(path.join(projectDir, name), 'utf8'));
55
+ } catch {
56
+ // missing or unreadable — try the next candidate
57
+ }
58
+ }
59
+ return _parseReaper('');
60
+ }
61
+
62
+ /**
63
+ * Trigger the orphan scan, throttled and NON-BLOCKING (PRD FA4).
64
+ *
65
+ * The hook itself does exactly two pieces of I/O — one config read and one
66
+ * `stat` of the throttle marker — and then hands the work to a DETACHED,
67
+ * unref'd child. The scan is never run inline: one `ps` round-trip out of Node
68
+ * was measured at ~47 ms over 287 KB of output, which alone would blow the
69
+ * 50 ms `reaper.max-hook-latency-ms` budget this hook has to stay inside.
70
+ *
71
+ * The marker is stamped BEFORE the spawn, so a spawn that fails still consumes
72
+ * the throttle window — otherwise a broken spawn would be retried on every
73
+ * single tool batch.
74
+ *
75
+ * `reaper.max-hook-latency-ms` is the ceiling on the work above — measured here
76
+ * with `performance.now()` and reported as ONE stderr WARN line when exceeded.
77
+ * A WARN and not an event: this fires from a `PostToolBatch`-class hook, and a
78
+ * per-fire telemetry record is exactly the always-on signal
79
+ * `.claude/rules/host-resources.md` HR-101 calls a broken instrument. The
80
+ * measurement covers preparation only — the scan itself runs detached, which is
81
+ * the whole reason the budget can be held.
82
+ *
83
+ * Never throws: any failure degrades silently (PRD FA4 "lautlos degradieren").
84
+ *
85
+ * @param {object} [opts]
86
+ * @param {string} [opts.projectDir] Repo root; defaults to `getProjectDir()`.
87
+ * @param {number} [opts.now] Injected clock (ms).
88
+ * @param {Function} [opts.spawnFn] Injected `spawn` (tests).
89
+ * @param {Function} [opts.statFn] Injected marker `lstatSync` (tests).
90
+ * @param {Function} [opts.writeFn] Injected marker writer (tests).
91
+ * @param {() => number} [opts.clockFn] Injected monotonic clock for the latency
92
+ * budget (tests); defaults to `performance.now`.
93
+ * @returns {Promise<{spawned: boolean, reason: string}>} `reason` is one of
94
+ * `'disabled' | 'throttled' | 'spawned' | 'spawned-unthrottled' | 'error'`.
95
+ * `'spawned-unthrottled'` means the scan ran but the marker could not be
96
+ * stamped — the next fire will scan again, and the result must not read like
97
+ * a throttled spawn. `hooks/on-stop.mjs` records `reason` as `reaper_trigger`
98
+ * on its per-turn and per-agent records (HR-105: a signal nothing records is
99
+ * unfalsifiable); the PostToolBatch hook does not, because the marker is one
100
+ * file per repo — a defeated marker shows on the very next Stop record, and a
101
+ * per-batch record is the HR-101 shape the WARN above already refuses.
102
+ */
103
+ export async function maybeTriggerOrphanScan({
104
+ projectDir,
105
+ now,
106
+ spawnFn = spawn,
107
+ statFn,
108
+ writeFn,
109
+ clockFn = () => performance.now(),
110
+ } = {}) {
111
+ const startedAt = clockFn();
112
+ /** One WARN line when the preparation overran `reaper.max-hook-latency-ms`. */
113
+ const checkLatency = (budgetMs) => {
114
+ const elapsed = clockFn() - startedAt;
115
+ if (Number.isFinite(budgetMs) && budgetMs > 0 && elapsed > budgetMs) {
116
+ process.stderr.write(
117
+ `orphan-reaper trigger: hook latency ${elapsed.toFixed(1)} ms exceeded `
118
+ + `reaper.max-hook-latency-ms (${budgetMs} ms)\n`,
119
+ );
120
+ }
121
+ };
122
+ let root;
123
+ try {
124
+ root = typeof projectDir === 'string' && projectDir ? projectDir : getProjectDir();
125
+ } catch {
126
+ // No project dir, no config to read (#1498): `process.cwd()` throws ENOENT
127
+ // when the hook starts in a deleted directory with no `*_PROJECT_DIR` set.
128
+ // That is an unreadable config — the parser defaults, DISABLED — and not a
129
+ // failure of an armed reaper, so it must not surface as `error`: on-stop
130
+ // stamps `reaper_trigger` only for a reaper that is enabled.
131
+ return { spawned: false, reason: 'disabled' };
132
+ }
133
+ try {
134
+ const cfg = loadReaperConfig(root);
135
+ // Cheapest gate first: disabled means no stat, no spawn, no module load.
136
+ if (cfg.enabled !== true) return { spawned: false, reason: 'disabled' };
137
+
138
+ // The throttle module only — the scan's ledger, `ps` and kill-ladder code
139
+ // load in the detached child, never on this hook path (#1437).
140
+ const reaper = await import('./scan-throttle.mjs');
141
+ const markerPath = reaper.scanMarkerPath(root);
142
+ const nowMs = typeof now === 'number' ? now : Date.now();
143
+
144
+ if (!reaper.shouldScanNow(markerPath, nowMs, cfg['min-scan-interval-seconds'], { statFn })) {
145
+ checkLatency(cfg['max-hook-latency-ms']);
146
+ return { spawned: false, reason: 'throttled' };
147
+ }
148
+ // An unwritable marker does not cancel the scan — the throttle fails toward
149
+ // scanning (see shouldScanNow) — but the result says the throttle is off.
150
+ // No output: this runs on every hook fire (each tool batch, each Stop) in
151
+ // every session on the host.
152
+ const stamped = reaper.touchScanMarker(markerPath, { writeFn });
153
+
154
+ const child = spawnFn(
155
+ process.execPath,
156
+ [
157
+ ORPHAN_REAPER_SCRIPT,
158
+ '--repo-root', root,
159
+ '--mode', cfg.mode,
160
+ '--min-age-seconds', String(cfg['min-age-seconds']),
161
+ '--kill-grace-ms', String(cfg['kill-grace-ms']),
162
+ '--verify-wait-ms', String(cfg['verify-wait-ms']),
163
+ '--false-alarm-window', String(cfg['false-alarm-window']),
164
+ ],
165
+ { detached: true, stdio: 'ignore' },
166
+ );
167
+ if (child && typeof child.unref === 'function') child.unref();
168
+ checkLatency(cfg['max-hook-latency-ms']);
169
+ return { spawned: true, reason: stamped ? 'spawned' : 'spawned-unthrottled' };
170
+ } catch {
171
+ return { spawned: false, reason: 'error' };
172
+ }
173
+ }