session-orchestrator 5.1.0 → 5.3.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 (484) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -1
  2. package/.agents/skills/autopilot/SKILL.md +6 -1
  3. package/.agents/skills/autopilot/agents/openai.yaml +5 -0
  4. package/.agents/skills/bootstrap/SKILL.md +7 -1
  5. package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
  6. package/.agents/skills/brainstorm/SKILL.md +8 -1
  7. package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
  8. package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
  9. package/.agents/skills/close/SKILL.md +21 -0
  10. package/.agents/skills/close/agents/openai.yaml +5 -0
  11. package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
  12. package/.agents/skills/debug/SKILL.md +7 -1
  13. package/.agents/skills/debug/agents/openai.yaml +5 -0
  14. package/.agents/skills/discovery/SKILL.md +7 -2
  15. package/.agents/skills/discovery/agents/openai.yaml +5 -0
  16. package/.agents/skills/dispatcher/SKILL.md +7 -1
  17. package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
  18. package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
  19. package/.agents/skills/ecosystem-health/SKILL.md +3 -1
  20. package/.agents/skills/eli5/SKILL.md +7 -1
  21. package/.agents/skills/eli5/agents/openai.yaml +5 -0
  22. package/.agents/skills/eval/SKILL.md +7 -2
  23. package/.agents/skills/eval/agents/openai.yaml +5 -0
  24. package/.agents/skills/evolve/SKILL.md +8 -3
  25. package/.agents/skills/evolve/agents/openai.yaml +5 -0
  26. package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
  27. package/.agents/skills/gitlab-ops/SKILL.md +3 -1
  28. package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
  29. package/.agents/skills/go/SKILL.md +22 -0
  30. package/.agents/skills/go/agents/openai.yaml +5 -0
  31. package/.agents/skills/grill/SKILL.md +7 -1
  32. package/.agents/skills/grill/agents/openai.yaml +5 -0
  33. package/.agents/skills/harness-audit/SKILL.md +20 -0
  34. package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
  35. package/.agents/skills/hook-development/SKILL.md +3 -1
  36. package/.agents/skills/mcp-builder/SKILL.md +3 -1
  37. package/.agents/skills/memory-cleanup/SKILL.md +6 -1
  38. package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
  39. package/.agents/skills/mode-selector/SKILL.md +3 -1
  40. package/.agents/skills/npm-publish/SKILL.md +4 -2
  41. package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
  42. package/.agents/skills/persona-panel/SKILL.md +6 -1
  43. package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
  44. package/.agents/skills/plan/SKILL.md +8 -2
  45. package/.agents/skills/plan/agents/openai.yaml +5 -0
  46. package/.agents/skills/playwright-driver/SKILL.md +3 -1
  47. package/.agents/skills/portfolio/SKILL.md +21 -0
  48. package/.agents/skills/portfolio/agents/openai.yaml +5 -0
  49. package/.agents/skills/quality-gates/SKILL.md +3 -1
  50. package/.agents/skills/reconcile/SKILL.md +6 -1
  51. package/.agents/skills/reconcile/agents/openai.yaml +5 -0
  52. package/.agents/skills/release/SKILL.md +22 -0
  53. package/.agents/skills/release/agents/openai.yaml +5 -0
  54. package/.agents/skills/remote-offload/SKILL.md +3 -1
  55. package/.agents/skills/repo-audit/SKILL.md +6 -1
  56. package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
  57. package/.agents/skills/session/SKILL.md +21 -0
  58. package/.agents/skills/session/agents/openai.yaml +5 -0
  59. package/.agents/skills/session-end/SKILL.md +3 -1
  60. package/.agents/skills/session-plan/SKILL.md +3 -1
  61. package/.agents/skills/session-start/SKILL.md +3 -1
  62. package/.agents/skills/spinout/SKILL.md +6 -1
  63. package/.agents/skills/spinout/agents/openai.yaml +5 -0
  64. package/.agents/skills/sunset-review/SKILL.md +7 -1
  65. package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
  66. package/.agents/skills/templates-ack/SKILL.md +21 -0
  67. package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
  68. package/.agents/skills/test/SKILL.md +21 -0
  69. package/.agents/skills/test/agents/openai.yaml +5 -0
  70. package/.agents/skills/test-runner/SKILL.md +3 -1
  71. package/.agents/skills/tmux-layout/SKILL.md +3 -1
  72. package/.agents/skills/using-orchestrator/SKILL.md +3 -1
  73. package/.agents/skills/ux-grill/SKILL.md +7 -1
  74. package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
  75. package/.agents/skills/vault-mirror/SKILL.md +3 -1
  76. package/.agents/skills/vault-sync/SKILL.md +3 -1
  77. package/.agents/skills/wave-executor/SKILL.md +3 -1
  78. package/.agents/skills/write-executable-plan/SKILL.md +3 -1
  79. package/.claude-plugin/marketplace.json +1 -1
  80. package/.claude-plugin/plugin.json +1 -1
  81. package/.codex-plugin/plugin.json +4 -4
  82. package/.codex-plugin/skills/autopilot/SKILL.md +5 -4
  83. package/.codex-plugin/skills/bootstrap/SKILL.md +8 -4
  84. package/.codex-plugin/skills/brainstorm/SKILL.md +11 -4
  85. package/.codex-plugin/skills/close/SKILL.md +3 -3
  86. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -1
  87. package/.codex-plugin/skills/debug/SKILL.md +11 -4
  88. package/.codex-plugin/skills/discovery/SKILL.md +8 -4
  89. package/.codex-plugin/skills/dispatcher/SKILL.md +4 -4
  90. package/.codex-plugin/skills/eli5/SKILL.md +9 -4
  91. package/.codex-plugin/skills/eval/SKILL.md +9 -4
  92. package/.codex-plugin/skills/evolve/SKILL.md +9 -4
  93. package/.codex-plugin/skills/go/SKILL.md +3 -3
  94. package/.codex-plugin/skills/grill/SKILL.md +11 -4
  95. package/.codex-plugin/skills/harness-audit/SKILL.md +4 -3
  96. package/.codex-plugin/skills/memory-cleanup/SKILL.md +9 -4
  97. package/.codex-plugin/skills/npm-publish/SKILL.md +1 -1
  98. package/.codex-plugin/skills/persona-panel/SKILL.md +5 -5
  99. package/.codex-plugin/skills/plan/SKILL.md +8 -4
  100. package/.codex-plugin/skills/portfolio/SKILL.md +3 -3
  101. package/.codex-plugin/skills/reconcile/SKILL.md +9 -4
  102. package/.codex-plugin/skills/release/SKILL.md +3 -3
  103. package/.codex-plugin/skills/repo-audit/SKILL.md +6 -4
  104. package/.codex-plugin/skills/session/SKILL.md +1 -1
  105. package/.codex-plugin/skills/spinout/SKILL.md +4 -4
  106. package/.codex-plugin/skills/sunset-review/SKILL.md +5 -4
  107. package/.codex-plugin/skills/test/SKILL.md +3 -3
  108. package/.codex-plugin/skills/ux-grill/SKILL.md +11 -4
  109. package/.cursor/commands/autopilot.md +4 -4
  110. package/.cursor/commands/bootstrap.md +5 -4
  111. package/.cursor/commands/brainstorm.md +5 -4
  112. package/.cursor/commands/close.md +4 -3
  113. package/.cursor/commands/debug.md +4 -4
  114. package/.cursor/commands/discovery.md +4 -4
  115. package/.cursor/commands/dispatcher.md +4 -4
  116. package/.cursor/commands/eli5.md +4 -4
  117. package/.cursor/commands/eval.md +4 -4
  118. package/.cursor/commands/evolve.md +4 -4
  119. package/.cursor/commands/go.md +4 -3
  120. package/.cursor/commands/grill.md +4 -4
  121. package/.cursor/commands/harness-audit.md +3 -3
  122. package/.cursor/commands/memory-cleanup.md +4 -4
  123. package/.cursor/commands/persona-panel.md +4 -4
  124. package/.cursor/commands/plan.md +5 -4
  125. package/.cursor/commands/portfolio.md +3 -3
  126. package/.cursor/commands/reconcile.md +4 -4
  127. package/.cursor/commands/release.md +4 -3
  128. package/.cursor/commands/repo-audit.md +4 -4
  129. package/.cursor/commands/session.md +1 -1
  130. package/.cursor/commands/spinout.md +4 -4
  131. package/.cursor/commands/sunset-review.md +4 -4
  132. package/.cursor/commands/test.md +3 -3
  133. package/.cursor/commands/ux-grill.md +4 -4
  134. package/.cursor/rules/000-session-orchestrator.mdc +0 -2
  135. package/.cursor/rules/010-session-workflow.mdc +2 -2
  136. package/.cursor/rules/050-plan.mdc +1 -1
  137. package/.cursor/skills/bootstrap/SKILL.md +1 -0
  138. package/.cursor/skills/close/SKILL.md +13 -0
  139. package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
  140. package/.cursor/skills/debug/SKILL.md +0 -1
  141. package/.cursor/skills/discovery/SKILL.md +0 -1
  142. package/.cursor/skills/dispatcher/SKILL.md +0 -1
  143. package/.cursor/skills/eli5/SKILL.md +0 -1
  144. package/.cursor/skills/eval/SKILL.md +1 -1
  145. package/.cursor/skills/evolve/SKILL.md +0 -1
  146. package/.cursor/skills/go/SKILL.md +13 -0
  147. package/.cursor/skills/grill/SKILL.md +0 -1
  148. package/.cursor/skills/harness-audit/SKILL.md +12 -0
  149. package/.cursor/skills/npm-publish/SKILL.md +1 -0
  150. package/.cursor/skills/portfolio/SKILL.md +12 -0
  151. package/.cursor/skills/release/SKILL.md +13 -0
  152. package/.cursor/skills/repo-audit/SKILL.md +0 -1
  153. package/.cursor/skills/sunset-review/SKILL.md +0 -1
  154. package/.cursor/skills/test/SKILL.md +12 -0
  155. package/.cursor/skills/ux-grill/SKILL.md +0 -1
  156. package/.cursor-plugin/plugin.json +1 -1
  157. package/.orchestrator/policy/blocked-commands.json +13 -4
  158. package/AGENTS.md +3 -2
  159. package/CHANGELOG.md +197 -0
  160. package/README.md +11 -9
  161. package/SECURITY.md +12 -0
  162. package/agents/dialectic-deriver.md +13 -10
  163. package/agents/eval-judge.md +67 -45
  164. package/agents/skill-applied-judge.md +34 -19
  165. package/commands/session.md +17 -3
  166. package/docs/baseline.md +12 -6
  167. package/docs/ci-setup.md +53 -0
  168. package/docs/codex-setup.md +15 -3
  169. package/docs/components.md +13 -6
  170. package/docs/events-schema.md +59 -9
  171. package/docs/install.md +16 -0
  172. package/docs/persona-panel.md +1 -1
  173. package/docs/pi-setup.md +1 -1
  174. package/docs/rule-authoring.md +135 -14
  175. package/docs/scope-collision-guard.md +2 -0
  176. package/docs/session-config-reference.md +106 -11
  177. package/docs/session-config-template.md +31 -2
  178. package/docs/telemetry.md +2 -0
  179. package/hooks/_lib/hook-import-set.json +125 -8
  180. package/hooks/_lib/subagent-paths.mjs +15 -0
  181. package/hooks/_lib/subagent-transcript.mjs +582 -31
  182. package/hooks/_lib/vcs-create-matcher.mjs +217 -62
  183. package/hooks/config-protection.mjs +11 -3
  184. package/hooks/cwd-change-restore.mjs +11 -3
  185. package/hooks/enforce-commands.mjs +70 -23
  186. package/hooks/enforce-scope.mjs +143 -33
  187. package/hooks/hooks-codex.json +1 -1
  188. package/hooks/hooks.json +1 -1
  189. package/hooks/loop-guard.mjs +11 -3
  190. package/hooks/on-session-end.mjs +72 -25
  191. package/hooks/on-session-start.mjs +48 -11
  192. package/hooks/on-stop.mjs +211 -23
  193. package/hooks/operator-steer.mjs +11 -3
  194. package/hooks/post-bash-issue-budget-refund.mjs +18 -8
  195. package/hooks/post-bash-write-verify.mjs +6 -2
  196. package/hooks/post-edit-import-probe.mjs +17 -9
  197. package/hooks/post-edit-validate.mjs +13 -5
  198. package/hooks/post-subagent-discovery-validator.mjs +98 -13
  199. package/hooks/post-tool-batch-wave-signal.mjs +200 -38
  200. package/hooks/post-tool-failure-corrective-context.mjs +11 -5
  201. package/hooks/post-tooluse-frontend-slop.mjs +10 -4
  202. package/hooks/pre-auq-clarity.mjs +18 -2
  203. package/hooks/pre-bash-destructive-guard.mjs +80 -9
  204. package/hooks/pre-bash-issue-budget.mjs +119 -28
  205. package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
  206. package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
  207. package/hooks/pre-bash-staging-fence.mjs +335 -31
  208. package/hooks/pre-bash-templates-first.mjs +19 -14
  209. package/hooks/pre-task-scope-disjoint.mjs +385 -5
  210. package/hooks/skill-invocation-telemetry.mjs +2 -1
  211. package/hooks/subagent-telemetry.mjs +15 -19
  212. package/hooks/wave-scope-commit-guard.mjs +197 -100
  213. package/monitors/monitors.json +1 -1
  214. package/output-styles/wave-summary.md +1 -1
  215. package/package.json +2 -1
  216. package/pi/prompts/autopilot.md +3 -3
  217. package/pi/prompts/bootstrap.md +3 -3
  218. package/pi/prompts/brainstorm.md +3 -3
  219. package/pi/prompts/close.md +2 -2
  220. package/pi/prompts/debug.md +3 -3
  221. package/pi/prompts/discovery.md +3 -3
  222. package/pi/prompts/dispatcher.md +3 -3
  223. package/pi/prompts/eli5.md +3 -3
  224. package/pi/prompts/eval.md +3 -3
  225. package/pi/prompts/evolve.md +3 -3
  226. package/pi/prompts/go.md +2 -2
  227. package/pi/prompts/grill.md +3 -3
  228. package/pi/prompts/harness-audit.md +2 -3
  229. package/pi/prompts/memory-cleanup.md +3 -3
  230. package/pi/prompts/persona-panel.md +3 -3
  231. package/pi/prompts/plan.md +3 -3
  232. package/pi/prompts/portfolio.md +2 -2
  233. package/pi/prompts/reconcile.md +3 -3
  234. package/pi/prompts/release.md +3 -3
  235. package/pi/prompts/repo-audit.md +3 -4
  236. package/pi/prompts/session.md +2 -2
  237. package/pi/prompts/spinout.md +3 -3
  238. package/pi/prompts/sunset-review.md +3 -3
  239. package/pi/prompts/templates-ack.md +1 -1
  240. package/pi/prompts/test.md +3 -3
  241. package/pi/prompts/ux-grill.md +3 -3
  242. package/rules/README.md +1 -1
  243. package/rules/opt-in-domain/prompt-caching.md +1 -1
  244. package/rules/opt-in-stack/backend-data.md +1 -1
  245. package/rules/opt-in-stack/backend.md +3 -3
  246. package/rules/opt-in-stack/frontend.md +1 -1
  247. package/rules/opt-in-stack/security-web.md +3 -3
  248. package/rules/opt-in-stack/swift.md +1 -1
  249. package/scripts/archive-closed-prds.mjs +2 -2
  250. package/scripts/auq-audit.mjs +2 -3
  251. package/scripts/autopilot.mjs +23 -2
  252. package/scripts/backfill-abandoned-sessions.mjs +171 -15
  253. package/scripts/backfill-evidence-digest.mjs +2 -1
  254. package/scripts/backfill-learnings-from-vault.mjs +2 -2
  255. package/scripts/check-package-manager.mjs +2 -2
  256. package/scripts/check-sessions-integrity.mjs +300 -0
  257. package/scripts/ci/assert-vitest-green.mjs +2 -1
  258. package/scripts/dialectic-deriver.mjs +50 -13
  259. package/scripts/emit-session.mjs +77 -32
  260. package/scripts/eval-session.mjs +65 -3
  261. package/scripts/export-hw-learnings.mjs +2 -1
  262. package/scripts/express-path.mjs +1 -1
  263. package/scripts/gc-stale-worktrees.mjs +2 -1
  264. package/scripts/generate-agents-skills.mjs +102 -29
  265. package/scripts/generate-codex-skills.mjs +48 -4
  266. package/scripts/generate-cursor-adapter.mjs +220 -11
  267. package/scripts/generate-hook-import-set.mjs +12 -27
  268. package/scripts/generate-pi-prompts.mjs +183 -13
  269. package/scripts/github-protection-audit.mjs +2 -3
  270. package/scripts/lib/agent-frontmatter.mjs +23 -1
  271. package/scripts/lib/agent-status.mjs +2 -31
  272. package/scripts/lib/auq/clarity.mjs +10 -2
  273. package/scripts/lib/auq/parse.mjs +12 -31
  274. package/scripts/lib/auq/schema.mjs +56 -41
  275. package/scripts/lib/auto-dialectic.mjs +304 -15
  276. package/scripts/lib/autopilot/flags.mjs +12 -1
  277. package/scripts/lib/autopilot/kill-switches.mjs +6 -3
  278. package/scripts/lib/autopilot/loop.mjs +14 -1
  279. package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
  280. package/scripts/lib/ci-status-banner.mjs +376 -16
  281. package/scripts/lib/claude-md-budget-lint.mjs +2 -5
  282. package/scripts/lib/command-blocker.mjs +408 -33
  283. package/scripts/lib/config/dialectic.mjs +12 -3
  284. package/scripts/lib/config/drift-check.mjs +19 -0
  285. package/scripts/lib/config/gate.mjs +74 -0
  286. package/scripts/lib/config/reaper.mjs +162 -0
  287. package/scripts/lib/config.mjs +14 -0
  288. package/scripts/lib/convergence-monitor.mjs +76 -13
  289. package/scripts/lib/cursor-hook-bridge.mjs +2 -2
  290. package/scripts/lib/description-surface.mjs +2 -5
  291. package/scripts/lib/dispatcher/cli.mjs +2 -1
  292. package/scripts/lib/ecosystem-health.mjs +11 -0
  293. package/scripts/lib/ecosystem-wizard.mjs +2 -1
  294. package/scripts/lib/eval/engine.mjs +421 -53
  295. package/scripts/lib/eval/judge.mjs +463 -40
  296. package/scripts/lib/eval/schema.mjs +10 -1
  297. package/scripts/lib/events-rotation.mjs +221 -25
  298. package/scripts/lib/events-schema.mjs +114 -0
  299. package/scripts/lib/events.mjs +524 -5
  300. package/scripts/lib/fetch-baseline.mjs +3 -8
  301. package/scripts/lib/frontmatter-guard.mjs +21 -10
  302. package/scripts/lib/gates/gate-baseline.mjs +27 -2
  303. package/scripts/lib/gates/gate-full.mjs +28 -3
  304. package/scripts/lib/gates/gate-helpers.mjs +243 -21
  305. package/scripts/lib/gates/gate-incremental.mjs +28 -3
  306. package/scripts/lib/gates/gate-per-file.mjs +27 -2
  307. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +2 -1
  308. package/scripts/lib/gitlab-portfolio/cli.mjs +2 -1
  309. package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
  310. package/scripts/lib/instruction-budget-guard.mjs +332 -50
  311. package/scripts/lib/io.mjs +42 -8
  312. package/scripts/lib/is-main-module.mjs +82 -0
  313. package/scripts/lib/issue-close-strip-labels.mjs +207 -49
  314. package/scripts/lib/js-mask.mjs +197 -0
  315. package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
  316. package/scripts/lib/locks/index.mjs +32 -25
  317. package/scripts/lib/maintenance-due-banner.mjs +122 -91
  318. package/scripts/lib/orphan-reaper.mjs +1588 -0
  319. package/scripts/lib/peer-cards/merger.mjs +48 -10
  320. package/scripts/lib/peer-cards/reader.mjs +78 -2
  321. package/scripts/lib/peer-discovery.mjs +2 -5
  322. package/scripts/lib/playwright-driver/runner.mjs +2 -1
  323. package/scripts/lib/process-group.mjs +899 -0
  324. package/scripts/lib/quality-gate.mjs +107 -28
  325. package/scripts/lib/reconcile/backlog.mjs +368 -0
  326. package/scripts/lib/reconcile/engine.mjs +55 -188
  327. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +884 -0
  328. package/scripts/lib/reconcile/sanitize.mjs +69 -3
  329. package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
  330. package/scripts/lib/resource-probe/parsers.mjs +31 -0
  331. package/scripts/lib/rule-loader.mjs +41 -12
  332. package/scripts/lib/rules-sync.mjs +2 -5
  333. package/scripts/lib/scope-echo.mjs +429 -7
  334. package/scripts/lib/scope-gate.mjs +605 -1
  335. package/scripts/lib/session-close-backfill.mjs +91 -12
  336. package/scripts/lib/session-id.mjs +9 -20
  337. package/scripts/lib/session-invocation.mjs +20 -0
  338. package/scripts/lib/session-schema/constants.mjs +30 -2
  339. package/scripts/lib/session-schema/normalizer.mjs +56 -4
  340. package/scripts/lib/session-schema.mjs +8 -3
  341. package/scripts/lib/session-start-probes.mjs +95 -10
  342. package/scripts/lib/sessions-canonical.mjs +23 -0
  343. package/scripts/lib/sessions-integrity-banner.mjs +7 -1
  344. package/scripts/lib/sessions-staleness-banner.mjs +193 -51
  345. package/scripts/lib/skill-evidence-window.mjs +891 -0
  346. package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
  347. package/scripts/lib/skill-evolution/engine.mjs +18 -9
  348. package/scripts/lib/skill-judge.mjs +45 -3
  349. package/scripts/lib/state-md.mjs +84 -3
  350. package/scripts/lib/sunset/walker.mjs +31 -4
  351. package/scripts/lib/tail-window.mjs +56 -0
  352. package/scripts/lib/telemetry/schema.mjs +30 -0
  353. package/scripts/lib/telemetry/sync.mjs +61 -6
  354. package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
  355. package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
  356. package/scripts/lib/tests-src-ratio.mjs +2 -6
  357. package/scripts/lib/tmux-layout/telemetry-stats.mjs +74 -14
  358. package/scripts/lib/user-invocable-skills.mjs +205 -0
  359. package/scripts/lib/ux-grill/reconcile.mjs +48 -22
  360. package/scripts/lib/validate/check-agents-skills.mjs +26 -15
  361. package/scripts/lib/validate/check-banner-parity.mjs +2 -2
  362. package/scripts/lib/validate/check-cursor-adapter.mjs +3 -2
  363. package/scripts/lib/validate/check-dead-bridge.mjs +2 -2
  364. package/scripts/lib/validate/check-doc-cli-commands.mjs +2 -2
  365. package/scripts/lib/validate/check-entry-guard.mjs +329 -0
  366. package/scripts/lib/validate/check-guard-requires-parity.mjs +2 -2
  367. package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
  368. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +2 -2
  369. package/scripts/lib/validate/check-learning-provenance.mjs +2 -2
  370. package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
  371. package/scripts/lib/validate/check-rules.mjs +7 -5
  372. package/scripts/lib/validate/check-skill-links.mjs +35 -6
  373. package/scripts/lib/validate/check-skill-script-paths.mjs +241 -29
  374. package/scripts/lib/validate/check-test-git-config-target.mjs +26 -36
  375. package/scripts/lib/validate/check-unicode-safety.mjs +2 -2
  376. package/scripts/lib/validate/check-untracked-test-deps.mjs +9 -104
  377. package/scripts/lib/validate/check-unwired-features.mjs +220 -33
  378. package/scripts/lib/validate/check-validator-registration.mjs +36 -12
  379. package/scripts/lib/validate/check-vcs-repo-flag.mjs +2 -2
  380. package/scripts/lib/validate/confidential-names.mjs +10 -0
  381. package/scripts/lib/validate-vendored-rules.mjs +39 -12
  382. package/scripts/lib/vault-mirror/namespace.mjs +46 -8
  383. package/scripts/lib/vault-mirror/process.mjs +10 -3
  384. package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
  385. package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
  386. package/scripts/lib/vault-yaml.mjs +118 -0
  387. package/scripts/lib/wave-transcript-tail.mjs +2 -2
  388. package/scripts/lib/worktree/lifecycle.mjs +153 -1
  389. package/scripts/lock-reaper.mjs +2 -1
  390. package/scripts/materialize-wave-scope.mjs +87 -4
  391. package/scripts/migrate-sessions-jsonl.mjs +2 -1
  392. package/scripts/migrate-vault-paths.mjs +2 -3
  393. package/scripts/release-session-lock.mjs +305 -0
  394. package/scripts/release.mjs +109 -39
  395. package/scripts/relocate-vault-corpus.mjs +2 -3
  396. package/scripts/repair-invalid-sessions.mjs +2 -2
  397. package/scripts/resolve-session-invocation.mjs +59 -0
  398. package/scripts/run-quality-gate.mjs +156 -17
  399. package/scripts/session-shape.mjs +2 -2
  400. package/scripts/site-numbers.mjs +35 -11
  401. package/scripts/sweep-expired-rules.mjs +227 -0
  402. package/scripts/validate-plugin.mjs +21 -0
  403. package/scripts/validate-wave-scope.mjs +32 -105
  404. package/scripts/vault-consolidate.mjs +2 -2
  405. package/scripts/vault-mirror.mjs +11 -4
  406. package/scripts/wave-scope-binding.mjs +2 -3
  407. package/skills/_shared/bootstrap-gate.md +1 -1
  408. package/skills/_shared/monitor-patterns.md +1 -1
  409. package/skills/_shared/platform-tools.md +23 -11
  410. package/skills/_shared/research-evidence.md +53 -0
  411. package/skills/_shared/state-ownership.md +3 -0
  412. package/skills/autopilot/SKILL.md +80 -11
  413. package/skills/bootstrap/SKILL.md +51 -1
  414. package/skills/brainstorm/SKILL.md +16 -0
  415. package/skills/claude-md-drift-check/SKILL.md +1 -1
  416. package/skills/claude-md-drift-check/checker.mjs +49 -11
  417. package/{commands/close.md → skills/close/SKILL.md} +9 -3
  418. package/skills/convergence-monitoring/README.md +8 -1
  419. package/skills/convergence-monitoring/SIGNALS.md +50 -6
  420. package/skills/convergence-monitoring/SKILL.md +15 -6
  421. package/skills/debug/SKILL.md +10 -0
  422. package/skills/discovery/SKILL.md +24 -1
  423. package/skills/discovery/probes-session.md +2 -2
  424. package/skills/dispatcher/SKILL.md +38 -7
  425. package/skills/eli5/SKILL.md +11 -0
  426. package/skills/eval/SKILL.md +52 -23
  427. package/skills/eval/rubric-v1.md +1 -0
  428. package/skills/eval/rubric-v2.md +457 -0
  429. package/skills/evolve/SKILL.md +9 -2
  430. package/skills/evolve/references/evolve-dialectic-mode.md +46 -25
  431. package/skills/gitlab-ops/SKILL.md +3 -2
  432. package/{commands/go.md → skills/go/SKILL.md} +9 -1
  433. package/skills/grill/SKILL.md +19 -0
  434. package/{commands/harness-audit.md → skills/harness-audit/SKILL.md} +7 -2
  435. package/skills/hook-development/SKILL.md +46 -41
  436. package/skills/memory-cleanup/SKILL.md +7 -0
  437. package/skills/npm-publish/SKILL.md +2 -2
  438. package/skills/persona-panel/SKILL.md +56 -1
  439. package/skills/persona-panel/persona-format.md +1 -1
  440. package/skills/plan/SKILL.md +28 -1
  441. package/{commands/portfolio.md → skills/portfolio/SKILL.md} +8 -2
  442. package/skills/reconcile/SKILL.md +21 -0
  443. package/{commands/release.md → skills/release/SKILL.md} +16 -2
  444. package/skills/repo-audit/SKILL.md +7 -0
  445. package/skills/session-end/SKILL.md +13 -16
  446. package/skills/session-end/discovery-scan.md +1 -1
  447. package/skills/session-end/phase-3-6-tail.md +55 -9
  448. package/skills/session-end/plan-verification.md +2 -2
  449. package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
  450. package/skills/session-end/session-metrics-write.md +10 -0
  451. package/skills/session-plan/SKILL.md +18 -6
  452. package/skills/session-plan/references/session-plan-task-classification.md +2 -2
  453. package/skills/session-start/SKILL.md +5 -4
  454. package/skills/session-start/phase-8-5-express-path.md +6 -6
  455. package/skills/session-start/references/phase-1-5-session-continuity.md +1 -1
  456. package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +1 -1
  457. package/skills/session-start/references/phase-4-ssot-environment-check.md +6 -4
  458. package/skills/spinout/SKILL.md +12 -1
  459. package/skills/sunset-review/SKILL.md +13 -0
  460. package/{commands/test.md → skills/test/SKILL.md} +10 -4
  461. package/skills/ux-grill/SKILL.md +20 -2
  462. package/skills/wave-executor/SKILL.md +14 -7
  463. package/skills/wave-executor/circuit-breaker.md +2 -0
  464. package/skills/wave-executor/references/wave-executor-state-init.md +18 -4
  465. package/skills/wave-executor/references/wave-loop-dispatch.md +5 -2
  466. package/skills/wave-executor/references/wave-loop-review.md +17 -1
  467. package/commands/autopilot.md +0 -80
  468. package/commands/bootstrap.md +0 -56
  469. package/commands/brainstorm.md +0 -48
  470. package/commands/debug.md +0 -36
  471. package/commands/discovery.md +0 -32
  472. package/commands/dispatcher.md +0 -59
  473. package/commands/eli5.md +0 -33
  474. package/commands/eval.md +0 -28
  475. package/commands/evolve.md +0 -10
  476. package/commands/grill.md +0 -45
  477. package/commands/memory-cleanup.md +0 -26
  478. package/commands/persona-panel.md +0 -121
  479. package/commands/plan.md +0 -15
  480. package/commands/reconcile.md +0 -23
  481. package/commands/repo-audit.md +0 -24
  482. package/commands/spinout.md +0 -15
  483. package/commands/sunset-review.md +0 -27
  484. package/commands/ux-grill.md +0 -51
@@ -0,0 +1,1588 @@
1
+ /**
2
+ * orphan-reaper.mjs — Teil B of Epic #1425: the NET under the process-group kill.
3
+ *
4
+ * Teil A (`process-group.mjs`) prevents orphans at the source. This module finds
5
+ * the ones that happen anyway — a session that crashed, a harness SIGKILL, a
6
+ * leak from a foreign repo on the same host — and reaps them.
7
+ *
8
+ * The 2026-09-20 incident this exists for: four orphaned `tsgo --noEmit`
9
+ * processes, two at PPID 1, 86-588% CPU and up to 8.0 GB RSS each; the Mac at
10
+ * 13% free memory, load 67.4. The existing detector
11
+ * (`countZombieProcesses`, `resource-probe/parsers.mjs`) would have counted ZERO:
12
+ * its `ps` carries no `ppid` and no `rss`, its name filter knows only
13
+ * `claude`/`node`, and it defines a zombie as IDLE (`cpu <= 1.0`) — the exact
14
+ * opposite of a runaway.
15
+ *
16
+ * ## Shape: pure decision, impure execution
17
+ *
18
+ * Strictly the `lock-reaper.mjs` split — `evaluateRepo()` decides, `archiveLock()`
19
+ * destroys, with a TOCTOU re-check between. Here:
20
+ *
21
+ * ps text → {@link parsePsSnapshot} → {@link decideReapCandidates} (PURE)
22
+ * → targeted ps + identity re-check BEFORE EVERY SIGNAL
23
+ * → `deps.killProcessGroup` → wait → re-measure (IMPURE)
24
+ *
25
+ * {@link decideReapCandidates} does no I/O, sends no signal, and is fully
26
+ * testable from `ps` text fixtures. Everything impure reaches it through
27
+ * `deps` ({@link resolveDeps}).
28
+ *
29
+ * ## Why PPID 1 is necessary and never sufficient
30
+ *
31
+ * Measured on this host 2026-09-21: 538 of 784 processes (68.6%) have PPID 1 —
32
+ * on macOS launchd is the parent of nearly everything. A reaper keyed on PPID 1
33
+ * alone would be a weapon pointed at the operating system. So a candidate is a
34
+ * CONJUNCTION: in our own gate-process ledger, PPID 1, old enough, a read-only
35
+ * gate command, and an identity that still verifies against the ledger record.
36
+ *
37
+ * ## Coverage: the ledger is joined by PID **and** by PGID
38
+ *
39
+ * The binding `ps` format ({@link PS_ARGS}) publishes `pgid`, so a row joins the
40
+ * ledger either as the recorded LEADER (`row.pid === record.pid`) or as any
41
+ * member of the recorded GROUP (`row.pgid === record.pgid`). The group join is
42
+ * what covers the measured grandchild case: `npm run typecheck` (82507) spawns
43
+ * `node scripts/typecheck.mjs` (82591) which spawns `tsgo`, all three in pgid
44
+ * 82507 — when only the leader dies, 82591 sits at PPID 1 and the ledger knows
45
+ * its group but not its pid. Named ceiling (BV-004): the join FINDS every
46
+ * member, but a member is only reaped when its own command passes the read-only
47
+ * allowlist — `tsgo`/`vitest` descendants are, a repo-local `node scripts/…`
48
+ * runner is NOT and is reported as `not-read-only` (measured 2026-09-22); the
49
+ * group still dies through its allowlisted sibling. Revisit trigger: an orphaned
50
+ * node runner that outlives every allowlisted sibling.
51
+ *
52
+ * A non-leader cannot be identified the leader's way: its start time is its own
53
+ * and its command line is not the recorded one. Its identity is therefore
54
+ * {@link verifyGroupMemberIdentity} — born no earlier than its group leader
55
+ * (a descendant is never older than the process that forked it), plus either
56
+ * the record's signature token or a command name the read-only allowlist knows.
57
+ *
58
+ * The KILL TARGET is the group in both cases (`-pgid`), so one record still
59
+ * covers its whole group with one ladder.
60
+ *
61
+ * Named ceiling (BV-004): a process that `setsid`-ed out of its group carries no
62
+ * ledger identity in this format and is not a candidate BY DESIGN (PRD
63
+ * § Umfangsgrenze: orphan-confidence via PPID history is Stufe 2 / C4). Revisit
64
+ * if such a case is ever observed for a process the ledger DID record.
65
+ */
66
+
67
+ import { spawn } from 'node:child_process';
68
+ import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
69
+ import path from 'node:path';
70
+
71
+ import { isMainModule } from './is-main-module.mjs';
72
+ import {
73
+ killProcessGroup,
74
+ pruneGateProcessLedger,
75
+ readGateProcessLedger,
76
+ verifyProcessIdentity,
77
+ } from './process-group.mjs';
78
+ import { parseEtimeToSeconds } from './resource-probe/parsers.mjs';
79
+
80
+ /**
81
+ * Stufe-1 parameters. Every number carries its provenance from the PRD
82
+ * parameter table (`docs/prd/2026-09-20-prozessgruppen-kill-und-waisen-waechter.md`
83
+ * § 4) — NOT calibrated, so the provenance is what makes a later re-measurement
84
+ * possible ("keine Zahl ohne ihre Population").
85
+ */
86
+ export const REAPER_DEFAULTS = Object.freeze({
87
+ /** `reaper.min-age-seconds`: DevWatchdogs' hard limit for `tsgo`; the
88
+ * 2026-09-20 orphans were 7-17 min old, comfortably above it. */
89
+ minAgeSeconds: 300,
90
+ /** `reaper.min-scan-interval-seconds`: DevWatchdogs' normal scan cadence —
91
+ * keeps a `PostToolBatch` storm from taxing every tool call. */
92
+ minScanIntervalSeconds: 30,
93
+ /** `reaper.kill-grace-ms`: `DEFAULT_KILL_GRACE_MS` from
94
+ * `wave-executor/dispatch-common.mjs` — a repo convention, not a new number. */
95
+ killGraceMs: 10_000,
96
+ /** `reaper.verify-wait-ms`: without it the 2026-09-20 hand-run cleanup
97
+ * reported "still alive" for processes that were already gone. */
98
+ verifyWaitMs: 500,
99
+ /** `reaper.max-hook-latency-ms`: ceiling a scan may delay a hook by. */
100
+ maxHookLatencyMs: 50,
101
+ /** `reaper.false-alarm-window`: last N audit decisions — a ROLLING window
102
+ * rather than calendar time, so a quiet host still has a population (HR-101). */
103
+ falseAlarmWindow: 50,
104
+ });
105
+
106
+ /**
107
+ * The read-only gate command class — an ALLOWLIST, which is why no denylist is
108
+ * needed: a dev server or MCP server matches nothing here and is therefore never
109
+ * a candidate, orphaned or not (PRD § Out-of-Scope: two orphaned `browser-kit`
110
+ * servers were reported, not killed).
111
+ *
112
+ * IN — commands that only read the working copy and can be re-run at will:
113
+ * `tsgo`, `tsc`, `vitest`, `eslint`, `node …vitest…`,
114
+ * `npm test` / `npm run typecheck` / `npm run lint`.
115
+ * DELIBERATELY OUT — anything that holds state or serves a port: dev servers,
116
+ * MCP servers, `npm run build`, `git` (writes the index), database processes.
117
+ *
118
+ * Matched per STATEMENT (see {@link isReadOnlyCommand}), with a `(^|[\s/])`
119
+ * boundary so both `tsgo --noEmit` and `/opt/homebrew/bin/tsgo --noEmit` and
120
+ * `npx tsgo` hit.
121
+ *
122
+ * `eslint` carries a negative lookahead for its WRITING flags: `eslint . --fix`
123
+ * is this repo's own `lint:fix` script and rewrites the working copy, so it is
124
+ * not re-runnable at will and has no business on a read-only allowlist.
125
+ */
126
+ export const READ_ONLY_COMMAND_PATTERNS = Object.freeze([
127
+ /(^|[\s/])tsgo(\s|$)/,
128
+ /(^|[\s/])tsc(\s|$)/,
129
+ /(^|[\s/])vitest(\s|$)/,
130
+ /(^|[\s/])eslint(?!\S)(?!.*\s--(fix|fix-dry-run|output-file)(\s|=|$))/,
131
+ /(^|[\s/])node\s+\S*vitest/,
132
+ /(^|[\s/])npm\s+(run\s+)?(test|typecheck|lint)(\s|$)/,
133
+ ]);
134
+
135
+ /**
136
+ * Shell operators that separate one STATEMENT from the next in a `ps` `args`
137
+ * line. `ps` prints argv joined by spaces with the quoting already stripped, so
138
+ * a quote-aware lexer (`command-blocker.mjs` `splitChainSegments`) would have
139
+ * nothing left to be aware of here — and it would put 2.190 lines into the
140
+ * static import closure of a module that is destined for a hot-path hook
141
+ * (#1432) and lazily imports even `events.mjs` for that reason.
142
+ */
143
+ const STATEMENT_SEPARATOR_RE = /\s*(?:&&|\|\||[;|&])\s*/;
144
+
145
+ /**
146
+ * The binding `ps` invocation (Discovery d-2, 2026-09-21, Darwin 25.6.0).
147
+ *
148
+ * Headerless (`=` per field) and six NUMERIC fields before `args`, because on
149
+ * macOS `comm` is a 16-character-truncated PATH that may contain spaces (68 of
150
+ * 784 processes carried a space, 13 of them survived the truncation) — appending
151
+ * `args` after `comm` breaks whitespace splitting outright. `-ww` disables column
152
+ * truncation; `ps` escapes control characters, so one process is one line.
153
+ * `rss` is in KiB. Roundtrip measured at ~47 ms for 287 KB / 784 processes.
154
+ *
155
+ * `pgid=` is the sixth column and is what lets a row join the ledger as a GROUP
156
+ * MEMBER rather than only as the recorded leader (see the module header).
157
+ * Availability measured 2026-09-22 on Darwin 25.6.0 — `ps -Aww -o
158
+ * pid=,ppid=,pgid=,rss=,etime=,%cpu=,args=` exits 0 and prints the column; it is
159
+ * POSIX (`pgid` is a standard `-o` keyword) and present on Linux `procps` too.
160
+ *
161
+ * A targeted call, NOT the full `probe()` — that one spawns up to five
162
+ * subprocesses and has no caching (PRD § B4).
163
+ */
164
+ export const PS_ARGS = Object.freeze(['-Aww', '-o', 'pid=,ppid=,pgid=,rss=,etime=,%cpu=,args=']);
165
+
166
+ /**
167
+ * The TARGETED variant of {@link PS_ARGS}: one pid, same seven columns.
168
+ *
169
+ * Used immediately before every signal and once after the ladder (B3/B6). It is
170
+ * a separate call rather than a re-run of the full `-A` scan because the whole
171
+ * point is freshness at the moment of signalling — a 784-row snapshot taken for
172
+ * the population is already stale by the time the ladder's 10 s grace elapses,
173
+ * and re-taking it per signal would cost 287 KB to learn one row.
174
+ *
175
+ * @param {number} pid
176
+ * @returns {string[]}
177
+ */
178
+ export function psPidArgs(pid) {
179
+ return ['-ww', '-p', String(pid), '-o', 'pid=,ppid=,pgid=,rss=,etime=,%cpu=,args='];
180
+ }
181
+
182
+ /**
183
+ * HR-101 ceiling: above this false-alarm rate the INSTRUMENT is suspect, and
184
+ * the answer is to re-aim it — never to raise the threshold it fires on. Surfaced
185
+ * as `instrumentSuspect` in the scan result; nothing in this module acts on it,
186
+ * because acting on a broken instrument is the failure it names.
187
+ */
188
+ export const FALSE_ALARM_SUSPECT_RATE = 0.1;
189
+
190
+ /** Name const for the one event this module emits. @see docs/events-schema.md */
191
+ export const REAPER_SCAN_EVENT = 'orchestrator.reaper.scan_completed';
192
+
193
+ /** Relative path of the JSONL kill audit (B5). Gitignored via `.gitignore:55`
194
+ * (`.orchestrator/metrics/*.jsonl`), verified with `git check-ignore --no-index -v`.
195
+ * Under the `ledger-delete-protected` policy rule: in-process fs only, never a
196
+ * shell `rm`/`mv`. */
197
+ export const REAPER_AUDIT_RELPATH = '.orchestrator/metrics/reaper-audit.jsonl';
198
+
199
+ /** Relative path of the scan-throttle marker (B4). Gitignored via
200
+ * `.gitignore:141` (`.orchestrator/tmp/`). */
201
+ export const SCAN_MARKER_RELPATH = '.orchestrator/tmp/reaper-last-scan';
202
+
203
+ /** Characters of `args` copied into an audit record — and ONLY for a command
204
+ * that already matched the read-only allowlist, so a foreign process's command
205
+ * line (which may carry paths or tokens) never reaches the audit. */
206
+ const ARGS_HEAD_CHARS = 80;
207
+
208
+ /** Wall-clock ceiling for one `ps` call. Same 2 s as `runPsDetailed()` in
209
+ * `resource-probe/probe-platform.mjs`, whose spawn/timeout shape this mirrors. */
210
+ const PS_TIMEOUT_MS = 2000;
211
+
212
+ /**
213
+ * One parsed `ps` row.
214
+ * @typedef {object} PsRow
215
+ * @property {number} pid
216
+ * @property {number} ppid
217
+ * @property {number} pgid Process-group id — the ledger's group join key.
218
+ * @property {number} rssKb Resident set size in KiB.
219
+ * @property {number} etimeSeconds Elapsed seconds since exec.
220
+ * @property {number} cpuPct
221
+ * @property {string} args Full command line.
222
+ */
223
+
224
+ /** @param {string} repoRoot @param {string} relpath @returns {string} */
225
+ function underRepo(repoRoot, relpath) {
226
+ return path.join(repoRoot, ...relpath.split('/'));
227
+ }
228
+
229
+ /**
230
+ * Parse `ps` output in the {@link PS_ARGS} format, tolerantly — and COUNT what
231
+ * it drops.
232
+ *
233
+ * A silently skipping parser turns a partial read into a clean verdict, which is
234
+ * the exact failure mode a reaper must not have; hence the `malformed` count
235
+ * ({@link parsePsSnapshotDetailed}). A row whose `etime` does not parse is
236
+ * treated as malformed rather than returned with a null age: every returned row
237
+ * must carry a usable age, because age is a load-bearing gate here.
238
+ *
239
+ * @param {string|null|undefined} text
240
+ * @returns {{rows: PsRow[], malformed: number}}
241
+ */
242
+ export function parsePsSnapshotDetailed(text) {
243
+ if (text === null || text === undefined) return { rows: [], malformed: 0 };
244
+ /** @type {PsRow[]} */
245
+ const rows = [];
246
+ let malformed = 0;
247
+ // Six whitespace-free fields, then `args` as the ENTIRE rest of the line —
248
+ // args legitimately contains spaces, so it must never be split.
249
+ const rowRe = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)(?:\s+(.*))?$/;
250
+ for (const raw of String(text).split(/\r?\n/)) {
251
+ if (raw.trim().length === 0) continue;
252
+ const m = rowRe.exec(raw);
253
+ if (!m) { malformed += 1; continue; }
254
+ const etimeSeconds = parseEtimeToSeconds(m[5]);
255
+ const cpuPct = parseFloat(m[6]);
256
+ if (etimeSeconds === null || Number.isNaN(cpuPct)) { malformed += 1; continue; }
257
+ rows.push({
258
+ pid: parseInt(m[1], 10),
259
+ ppid: parseInt(m[2], 10),
260
+ pgid: parseInt(m[3], 10),
261
+ rssKb: parseInt(m[4], 10),
262
+ etimeSeconds,
263
+ cpuPct,
264
+ args: m[7] ?? '',
265
+ });
266
+ }
267
+ return { rows, malformed };
268
+ }
269
+
270
+ /**
271
+ * {@link parsePsSnapshotDetailed} without the drop count.
272
+ * @param {string|null|undefined} text
273
+ * @returns {PsRow[]}
274
+ */
275
+ export function parsePsSnapshot(text) {
276
+ return parsePsSnapshotDetailed(text).rows;
277
+ }
278
+
279
+ /**
280
+ * True when EVERY statement of `args` is a read-only gate command per
281
+ * {@link READ_ONLY_COMMAND_PATTERNS}.
282
+ *
283
+ * Per-statement and ALL, not the whole string and ANY: judged over the whole
284
+ * line, `sh -c npm run build && npm test` matches the `npm test` pattern as a
285
+ * SUBSTRING and a build — which writes — is allowlisted by the read-only half
286
+ * of its own command. This is the destructive-guard's per-statement rule
287
+ * (`.claude/rules/guard-design.md` § "Widening a matcher without narrowing its
288
+ * bypass"), applied in the other direction: there one appended statement lifts
289
+ * a block, here one appended statement must be able to REVOKE a permission.
290
+ *
291
+ * An empty statement (a trailing `&&`, a doubled separator) is skipped rather
292
+ * than counted as a failure; a line with no statement at all is not read-only.
293
+ *
294
+ * @param {string} args
295
+ * @param {readonly RegExp[]} [patterns]
296
+ * @returns {boolean}
297
+ */
298
+ export function isReadOnlyCommand(args, patterns = READ_ONLY_COMMAND_PATTERNS) {
299
+ const s = typeof args === 'string' ? args : '';
300
+ if (s.trim().length === 0) return false;
301
+ const statements = s.split(STATEMENT_SEPARATOR_RE).filter((part) => part.trim().length > 0);
302
+ if (statements.length === 0) return false;
303
+ return statements.every((statement) => patterns.some((re) => re.test(statement)));
304
+ }
305
+
306
+ /**
307
+ * Identity check for a NON-LEADER member of a recorded process group — PURE.
308
+ *
309
+ * {@link verifyProcessIdentity} cannot serve here: it compares the row's start
310
+ * time against the LEADER's and the row's first token against the LEADER's
311
+ * signature, and a descendant matches neither (`node scripts/typecheck.mjs` was
312
+ * forked by `npm run typecheck` seconds after it).
313
+ *
314
+ * Both checks must hold, and both are one-sided on purpose:
315
+ * 1. NOT OLDER than the group leader, within `toleranceMs`. A process that
316
+ * existed BEFORE the leader cannot be its descendant, so an older row in the
317
+ * same pgid is a recycled group id, never a grandchild.
318
+ * 2. Either the leader's signature token prefixes the row's command (the
319
+ * descendant re-execs the same binary — `npm` → `npm`), or the row's command
320
+ * is itself on the read-only allowlist (`tsgo`, `node …vitest` — NOT a
321
+ * repo-local `node scripts/typecheck.mjs`, see the module head).
322
+ *
323
+ * @param {PsRow|null} row
324
+ * @param {import('./process-group.mjs').GateProcessRecord} record
325
+ * @param {object} [opts]
326
+ * @param {number} [opts.nowMs]
327
+ * @param {number} [opts.toleranceMs]
328
+ * @param {readonly RegExp[]} [opts.readOnlyPatterns]
329
+ * @returns {{match: boolean, reason: 'ok'|'gone'|'start-time-mismatch'|'signature-mismatch'}}
330
+ */
331
+ export function verifyGroupMemberIdentity(row, record, {
332
+ nowMs = Date.now(),
333
+ toleranceMs = 2000,
334
+ readOnlyPatterns = READ_ONLY_COMMAND_PATTERNS,
335
+ } = {}) {
336
+ if (!row || typeof row !== 'object') return { match: false, reason: 'gone' };
337
+ const etimeSeconds = typeof row.etimeSeconds === 'number' && Number.isFinite(row.etimeSeconds)
338
+ ? row.etimeSeconds
339
+ : null;
340
+ // An unmeasurable age is a refusal, never a pass — the same fail-closed
341
+ // direction `verifyProcessIdentity` takes for the leader.
342
+ if (etimeSeconds === null) return { match: false, reason: 'start-time-mismatch' };
343
+ const observedStart = nowMs - etimeSeconds * 1000;
344
+ const leaderStart = Number(record?.startTime);
345
+ if (!Number.isFinite(leaderStart)) return { match: false, reason: 'start-time-mismatch' };
346
+ if (observedStart < leaderStart - toleranceMs) {
347
+ return { match: false, reason: 'start-time-mismatch' };
348
+ }
349
+
350
+ const args = typeof row.args === 'string' ? row.args : '';
351
+ const token = signatureTokenOf(record?.commandSignature);
352
+ const firstToken = args.trim().split(/\s+/)[0] ?? '';
353
+ const tokenOk = token.length > 0 && firstToken === token;
354
+ if (!tokenOk && !isReadOnlyCommand(args, readOnlyPatterns)) {
355
+ return { match: false, reason: 'signature-mismatch' };
356
+ }
357
+ return { match: true, reason: 'ok' };
358
+ }
359
+
360
+ /**
361
+ * The command-name half of a command signature (`npm:6f1c…` → `npm`).
362
+ * A local four-liner rather than an import: `process-group.mjs` keeps its own
363
+ * copy private, and re-exporting it for one caller would widen that module's
364
+ * interface for no second consumer.
365
+ *
366
+ * @param {unknown} signature
367
+ * @returns {string}
368
+ */
369
+ function signatureTokenOf(signature) {
370
+ const s = typeof signature === 'string' ? signature : '';
371
+ const i = s.lastIndexOf(':');
372
+ return i === -1 ? s : s.slice(0, i);
373
+ }
374
+
375
+ /**
376
+ * Decide which processes are reapable orphans — PURE.
377
+ *
378
+ * No I/O, no signal, no clock of its own: `nowMs` is an argument. The only
379
+ * imports it reaches are {@link verifyProcessIdentity} and
380
+ * {@link verifyGroupMemberIdentity}, both themselves pure.
381
+ *
382
+ * Exactly ONE verdict per examined row, at a FIXED priority, so a row can never
383
+ * appear twice and a trigger is never ambiguous:
384
+ *
385
+ * 1. not in the ledger → `rejected: not-in-ledger`
386
+ * 2. record with `pgid !== pid` → `rejected: pgid-mismatch`
387
+ * 3. PPID !== 1 → `rejected: has-parent`
388
+ * 4. younger than `minAgeSeconds` → `rejected: too-young`
389
+ * 5. no `sessionId` on the record → `reported: unattributed` (never killed)
390
+ * 6. foreign session, live or of unmeasurable liveness
391
+ * → `reported: foreign-live-session`
392
+ * / `foreign-session-liveness-unknown` (never killed)
393
+ * 7. not a read-only command → `reported: not-read-only` (never killed)
394
+ * 8. identity does not verify → `rejected: identity-mismatch | signature-mismatch`
395
+ * 9. otherwise → `candidates` with `trigger: 'orphan-ppid1'`
396
+ *
397
+ * Step 2 is a LEDGER-INTEGRITY rejection, not a property of the row: under
398
+ * `detached: true` the leader IS its own group, so `pgid === pid` is the
399
+ * documented invariant (`process-group.mjs` — "the child calls setsid, so it IS
400
+ * its own group leader"). A record violating it describes a group this module
401
+ * did not create, and its `pgid` is the value that gets negated and signalled.
402
+ *
403
+ * Step 5 is fail-closed and was inert until 2026-09-22: `sessionId` was `null`
404
+ * in 377 of 377 live records because the gate runner passed none, and a null
405
+ * owner was silently read as "nobody's, therefore mine". An unattributed process
406
+ * is REPORTED — it is not evidence of ownership in either direction.
407
+ *
408
+ * Step 6 precedes step 7 on purpose: a foreign live session's process is
409
+ * reported for its OWNER, whether or not it also happens to be read-only
410
+ * (PRD FA3: "gemeldet, aber nicht automatisch getötet").
411
+ *
412
+ * Named ceiling (BV-004) on the `rejected` volume: every PPID-1 row that is not
413
+ * in the ledger is materialised as `not-in-ledger` — ~538 entries on this
414
+ * 784-process host. That is deliberate (the AC requires the rejection reason to
415
+ * be traceable in the RESULT) and bounded, because only candidates and reports
416
+ * are ever persisted to the audit; `rejected` stays in memory for one scan.
417
+ * Rows with a live parent that are ALSO not in the ledger are skipped entirely —
418
+ * they are neither orphan-shaped nor ours, and materialising them would double
419
+ * the array for no signal. Revisit if a consumer ever persists `rejected`.
420
+ *
421
+ * @param {PsRow[]} snapshot
422
+ * @param {import('./process-group.mjs').GateProcessRecord[]} ledgerRecords
423
+ * @param {number} nowMs
424
+ * @param {object} [opts]
425
+ * @param {string|null} [opts.ownSessionId]
426
+ * @param {string[]|null} [opts.livePeerSessionIds] Session ids `detectPeers()`
427
+ * reports LIVE — or `null` when the probe could not measure at all.
428
+ * @param {number} [opts.minAgeSeconds]
429
+ * @param {readonly RegExp[]} [opts.readOnlyPatterns]
430
+ * @param {number} [opts.identityToleranceMs]
431
+ * @returns {{candidates: object[], reported: object[], rejected: object[]}}
432
+ */
433
+ export function decideReapCandidates(snapshot, ledgerRecords, nowMs, {
434
+ ownSessionId = null,
435
+ livePeerSessionIds = [],
436
+ minAgeSeconds = REAPER_DEFAULTS.minAgeSeconds,
437
+ readOnlyPatterns = READ_ONLY_COMMAND_PATTERNS,
438
+ identityToleranceMs = 2000,
439
+ } = {}) {
440
+ const rows = Array.isArray(snapshot) ? snapshot : [];
441
+ const records = Array.isArray(ledgerRecords) ? ledgerRecords : [];
442
+ // THREE states, not two: a measured list, an empty measured list, or `null`
443
+ // for "liveness could not be measured". A failed peer probe must not read as
444
+ // "no peers are alive" — that would turn an unmeasured foreign session into a
445
+ // kill target (a missing measurement must never look like a zero).
446
+ const peers = livePeerSessionIds === null || livePeerSessionIds === undefined
447
+ ? null
448
+ : new Set((Array.isArray(livePeerSessionIds) ? livePeerSessionIds : [])
449
+ .filter((id) => typeof id === 'string' && id.length > 0));
450
+
451
+ /** Leader index: the pid the ledger recorded. @type {Map<number, object>} */
452
+ const byPid = new Map();
453
+ /** Group index: every member of a recorded group joins through this.
454
+ * @type {Map<number, object>} */
455
+ const byPgid = new Map();
456
+ for (const rec of records) {
457
+ if (!rec || typeof rec.pid !== 'number') continue;
458
+ byPid.set(rec.pid, rec);
459
+ // First record wins for a pgid: a recycled group id would otherwise let a
460
+ // newer record claim an older group's members.
461
+ if (typeof rec.pgid === 'number' && !byPgid.has(rec.pgid)) byPgid.set(rec.pgid, rec);
462
+ }
463
+
464
+ const candidates = [];
465
+ const reported = [];
466
+ const rejected = [];
467
+
468
+ for (const row of rows) {
469
+ // A `pid` hit is the LEADER; a `pgid` hit is any other member of its group
470
+ // (the measured grandchild case — see the module header). The leader wins,
471
+ // so a row is never judged by the weaker of the two identities.
472
+ const leaderRecord = byPid.get(row.pid) ?? null;
473
+ const record = leaderRecord
474
+ ?? (typeof row.pgid === 'number' ? byPgid.get(row.pgid) ?? null : null);
475
+ const isLeader = leaderRecord !== null;
476
+ const orphanShaped = row.ppid === 1;
477
+
478
+ if (!record) {
479
+ if (orphanShaped) {
480
+ rejected.push({
481
+ pid: row.pid, ppid: row.ppid, ageSeconds: row.etimeSeconds, reason: 'not-in-ledger',
482
+ });
483
+ }
484
+ continue;
485
+ }
486
+
487
+ const recordSessionId = typeof record.sessionId === 'string' && record.sessionId.length > 0
488
+ ? record.sessionId
489
+ : null;
490
+ const base = {
491
+ pid: row.pid,
492
+ pgid: record.pgid,
493
+ ppid: row.ppid,
494
+ isLeader,
495
+ ageSeconds: row.etimeSeconds,
496
+ rssKb: row.rssKb,
497
+ cpuPct: row.cpuPct,
498
+ commandSignature: record.commandSignature ?? null,
499
+ // Normalised to `null`: an empty-string owner is an ABSENT owner, and
500
+ // leaving `''` here would let a downstream truthiness check read it as one.
501
+ sessionId: recordSessionId,
502
+ };
503
+
504
+ // Ledger integrity before anything else: `record.pgid` is the value that
505
+ // gets NEGATED and signalled, and `pgid === pid` is the invariant every
506
+ // record this module writes satisfies.
507
+ if (record.pgid !== record.pid) {
508
+ rejected.push({ ...base, reason: 'pgid-mismatch' });
509
+ continue;
510
+ }
511
+
512
+ if (!orphanShaped) {
513
+ rejected.push({ ...base, reason: 'has-parent' });
514
+ continue;
515
+ }
516
+ if (row.etimeSeconds < minAgeSeconds) {
517
+ rejected.push({ ...base, reason: 'too-young', threshold: { minAgeSeconds } });
518
+ continue;
519
+ }
520
+
521
+ if (recordSessionId === null) {
522
+ // Fail-closed: an unowned record is not an unowned PROCESS. Report it and
523
+ // let the operator (or a fixed producer) decide.
524
+ reported.push({ ...base, reason: 'unattributed' });
525
+ continue;
526
+ }
527
+ const foreignSessionId = recordSessionId !== ownSessionId ? recordSessionId : null;
528
+ if (foreignSessionId !== null) {
529
+ if (peers === null) {
530
+ // Unmeasurable liveness → report, never reap. The only foreign process
531
+ // this function reaps is one whose session a SUCCESSFUL probe proved dead.
532
+ reported.push({ ...base, reason: 'foreign-session-liveness-unknown' });
533
+ continue;
534
+ }
535
+ if (peers.has(foreignSessionId)) {
536
+ // `args` ONLY for a command the allowlist already cleared — the same
537
+ // rule `ARGS_HEAD_CHARS` states, applied at the source rather than at
538
+ // the audit writer, so a foreign dev server's command line (paths,
539
+ // tokens) never enters the result in the first place.
540
+ reported.push({
541
+ ...base,
542
+ ...(isReadOnlyCommand(row.args, readOnlyPatterns) ? { args: row.args } : {}),
543
+ reason: 'foreign-live-session',
544
+ });
545
+ continue;
546
+ }
547
+ }
548
+
549
+ if (!isReadOnlyCommand(row.args, readOnlyPatterns)) {
550
+ // No `args` here: a command the allowlist did not recognise is not ours to
551
+ // copy around (dev servers, MCP servers, anything with a command line we
552
+ // have no reason to retain).
553
+ reported.push({ ...base, reason: 'not-read-only' });
554
+ continue;
555
+ }
556
+
557
+ const identity = isLeader
558
+ ? verifyProcessIdentity(
559
+ row.pid,
560
+ { startTime: record.startTime, commandSignature: record.commandSignature },
561
+ { snapshotLine: row, nowMs, toleranceMs: identityToleranceMs },
562
+ )
563
+ : verifyGroupMemberIdentity(row, record, {
564
+ nowMs, toleranceMs: identityToleranceMs, readOnlyPatterns,
565
+ });
566
+ if (!identity.match) {
567
+ rejected.push({
568
+ ...base,
569
+ reason: identity.reason === 'signature-mismatch' ? 'signature-mismatch' : 'identity-mismatch',
570
+ identity: { match: identity.match, reason: identity.reason },
571
+ });
572
+ continue;
573
+ }
574
+
575
+ candidates.push({
576
+ ...base,
577
+ args: row.args,
578
+ ledgerRecord: record,
579
+ identity: { match: identity.match, reason: identity.reason },
580
+ trigger: 'orphan-ppid1',
581
+ threshold: { minAgeSeconds },
582
+ actual: { ageSeconds: row.etimeSeconds },
583
+ });
584
+ }
585
+
586
+ return { candidates, reported, rejected };
587
+ }
588
+
589
+ /**
590
+ * Default `ps` runner: the {@link PS_ARGS} call with a hard 2 s deadline,
591
+ * SIGKILL on overrun, and `null` on any failure. Never throws.
592
+ *
593
+ * Mirrors `runPsDetailed()` in `resource-probe/probe-platform.mjs` — the same
594
+ * spawn/settle/timeout shape, a different column set.
595
+ *
596
+ * @param {number} [timeoutMs]
597
+ * @returns {Promise<string|null>}
598
+ */
599
+ function defaultRunPs(timeoutMs = PS_TIMEOUT_MS) {
600
+ return runPsArgs([...PS_ARGS], timeoutMs);
601
+ }
602
+
603
+ /**
604
+ * Default TARGETED `ps` runner (B3/B6): {@link psPidArgs} with the same hard
605
+ * deadline and the same `null`-on-any-failure contract as {@link defaultRunPs}.
606
+ *
607
+ * `ps -p <gone-pid>` exits NON-ZERO with empty stdout on macOS, which
608
+ * {@link runPsArgs} maps to `null` — so "gone" and "could not measure" arrive
609
+ * here as the same value. That is why the caller treats a `null` as UNMEASURED
610
+ * and refuses the signal, rather than reading it as proof of death: refusing on
611
+ * an absent measurement costs one skipped reap, believing it costs a bystander.
612
+ *
613
+ * @param {number} pid
614
+ * @param {number} [timeoutMs]
615
+ * @returns {Promise<string|null>}
616
+ */
617
+ function defaultRunPsPid(pid, timeoutMs = PS_TIMEOUT_MS) {
618
+ if (!Number.isInteger(pid) || pid <= 0) return Promise.resolve(null);
619
+ return runPsArgs(psPidArgs(pid), timeoutMs);
620
+ }
621
+
622
+ /**
623
+ * Spawn `ps` with `args`, capped at `timeoutMs`, `null` on any failure.
624
+ * Never throws. Mirrors `runPsDetailed()` in `resource-probe/probe-platform.mjs`.
625
+ *
626
+ * @param {string[]} args
627
+ * @param {number} timeoutMs
628
+ * @returns {Promise<string|null>}
629
+ */
630
+ function runPsArgs(args, timeoutMs) {
631
+ return new Promise((resolve) => {
632
+ if (process.platform === 'win32') { resolve(null); return; }
633
+ let settled = false;
634
+ const finish = (value) => {
635
+ if (settled) return;
636
+ settled = true;
637
+ resolve(value);
638
+ };
639
+ try {
640
+ const child = spawn('ps', args, { stdio: ['ignore', 'pipe', 'ignore'] });
641
+ const chunks = [];
642
+ child.stdout.on('data', (c) => chunks.push(c));
643
+ child.on('error', () => finish(null));
644
+ child.on('close', (code) => {
645
+ if (code !== 0) return finish(null);
646
+ finish(Buffer.concat(chunks).toString('utf8'));
647
+ });
648
+ const timer = setTimeout(() => {
649
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
650
+ finish(null);
651
+ }, timeoutMs);
652
+ timer.unref?.();
653
+ } catch {
654
+ finish(null);
655
+ }
656
+ });
657
+ }
658
+
659
+ /**
660
+ * Default audit sink: append one JSONL line to {@link REAPER_AUDIT_RELPATH}.
661
+ * In-process fs only (the path sits under the `ledger-delete-protected` policy
662
+ * rule). Best-effort — an audit write must never fail a scan — but a failure
663
+ * prints one WARN line rather than vanishing.
664
+ *
665
+ * @param {string} repoRoot
666
+ * @param {object} record
667
+ * @returns {void}
668
+ */
669
+ function defaultAppendAudit(repoRoot, record) {
670
+ const target = underRepo(repoRoot, REAPER_AUDIT_RELPATH);
671
+ try {
672
+ mkdirSync(path.dirname(target), { recursive: true });
673
+ appendFileSync(target, `${JSON.stringify(record)}\n`, 'utf8');
674
+ } catch (err) {
675
+ process.stderr.write(
676
+ `orphan-reaper: could not append to ${REAPER_AUDIT_RELPATH}: ${err?.message ?? String(err)}\n`,
677
+ );
678
+ }
679
+ }
680
+
681
+ /** Default own-session-id reader. Lazily imported so the static import closure
682
+ * of this module stays small — it is destined for a hot-path hook (#1432).
683
+ * @param {string} repoRoot @returns {Promise<string|null>} */
684
+ async function defaultReadOwnSessionId(repoRoot) {
685
+ try {
686
+ const mod = await import('./session-identity/own-session.mjs');
687
+ const ids = mod.readOwnSessionIds(repoRoot);
688
+ for (const id of ids) return id;
689
+ return null;
690
+ } catch {
691
+ return null;
692
+ }
693
+ }
694
+
695
+ /**
696
+ * Default live-peer probe. Lazily imported for the same reason as
697
+ * {@link defaultReadOwnSessionId}.
698
+ *
699
+ * Returns `null` — never `[]` — when the registry cannot be read. The two mean
700
+ * opposite things to {@link decideReapCandidates}: `[]` is a MEASUREMENT ("no
701
+ * peer is alive", so a foreign session's leftovers are reapable), `null` is the
702
+ * absence of one ("report it, do not touch it").
703
+ *
704
+ * @param {string|null} sessionId
705
+ * @returns {Promise<string[]|null>}
706
+ */
707
+ async function defaultDetectPeers(sessionId) {
708
+ try {
709
+ const mod = await import('./session-registry.mjs');
710
+ const peers = await mod.detectPeers({ sessionId: sessionId ?? undefined });
711
+ if (!Array.isArray(peers)) return null;
712
+ return peers.map((p) => p?.session_id).filter((v) => typeof v === 'string' && v.length > 0);
713
+ } catch {
714
+ return null;
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Default audit READER — the other half of {@link defaultAppendAudit}, and the
720
+ * data source {@link falseAlarmRate} needs (B5: "Dieselbe Datei ist die
721
+ * Datenquelle für `reaper.false-alarm-window`").
722
+ *
723
+ * Tolerant and COUNTING is not needed here for once: a malformed audit line is
724
+ * dropped from the rate's population rather than counted, because an unreadable
725
+ * record carries no decision to classify — and the rate reports its own `n`, so
726
+ * a shrinking population is visible in the number's denominator.
727
+ *
728
+ * Named ceiling (BV-004): reads the whole file and parses only its last `limit`
729
+ * lines. Audit lines are written only for candidates and reports — zero on a
730
+ * healthy host — so the file grows in the tens per week. Revisit (tail-seek, or
731
+ * rotation like `events-rotation.mjs`) if it ever passes ~1 MB.
732
+ *
733
+ * @param {string} repoRoot
734
+ * @param {number} [limit]
735
+ * @returns {object[]} parsed records in chronological order; `[]` on any failure
736
+ */
737
+ function defaultReadAuditRecords(repoRoot, limit = REAPER_DEFAULTS.falseAlarmWindow) {
738
+ const target = underRepo(repoRoot, REAPER_AUDIT_RELPATH);
739
+ let raw;
740
+ try {
741
+ raw = readFileSync(target, 'utf8');
742
+ } catch {
743
+ return [];
744
+ }
745
+ const lines = String(raw).split('\n').filter((l) => l.trim().length > 0);
746
+ const records = [];
747
+ for (const line of lines.slice(-Math.max(1, limit))) {
748
+ try {
749
+ const parsed = JSON.parse(line);
750
+ if (parsed && typeof parsed === 'object') records.push(parsed);
751
+ } catch {
752
+ /* an unreadable line carries no decision to classify */
753
+ }
754
+ }
755
+ return records;
756
+ }
757
+
758
+ /**
759
+ * Default event emitter. Lazily imported for the same reason as
760
+ * {@link defaultReadOwnSessionId}: `events.mjs` pulls in the schema validator,
761
+ * the attribution chain and the webhook client, and this module is destined for
762
+ * a hot-path hook (#1432) whose static import closure is measured.
763
+ *
764
+ * Never throws and never rejects — telemetry that can fail a scan would fail the
765
+ * hook the scan runs in.
766
+ *
767
+ * @param {string} type
768
+ * @param {object} payload
769
+ * @param {{repoRoot?: string}} [opts]
770
+ * @returns {Promise<boolean>} whether the record was written
771
+ */
772
+ async function defaultEmitEvent(type, payload, opts = {}) {
773
+ try {
774
+ const mod = await import('./events.mjs');
775
+ await mod.emitEvent(type, payload, opts);
776
+ return true;
777
+ } catch {
778
+ return false;
779
+ }
780
+ }
781
+
782
+ /** @param {number} ms @returns {Promise<void>} */
783
+ function defaultSleep(ms) {
784
+ return new Promise((resolve) => { setTimeout(resolve, ms); });
785
+ }
786
+
787
+ /**
788
+ * Resolve the adapter, defaulting every seam to the real implementation — the
789
+ * `resolveDeps()` pattern from `lock-reaper.mjs:77-100`.
790
+ *
791
+ * `killProcessGroup` HAS a real default, and that is safe only because
792
+ * {@link runOrphanScan} defaults `dryRun: true`. Tests ALWAYS inject it
793
+ * (`.claude/rules/testing.md`: never a real process as a kill target).
794
+ *
795
+ * @param {object} [deps]
796
+ * @returns {object}
797
+ */
798
+ export function resolveDeps(deps = {}) {
799
+ const d = deps ?? {};
800
+ return {
801
+ runPs: d.runPs ?? defaultRunPs,
802
+ runPsPid: d.runPsPid ?? defaultRunPsPid,
803
+ readLedger: d.readLedger ?? readGateProcessLedger,
804
+ verifyIdentity: d.verifyIdentity ?? verifyProcessIdentity,
805
+ killProcessGroup: d.killProcessGroup ?? killProcessGroup,
806
+ detectPeers: d.detectPeers ?? defaultDetectPeers,
807
+ readOwnSessionId: d.readOwnSessionId ?? defaultReadOwnSessionId,
808
+ appendAudit: d.appendAudit ?? defaultAppendAudit,
809
+ readAuditRecords: d.readAuditRecords ?? defaultReadAuditRecords,
810
+ emitEvent: d.emitEvent ?? defaultEmitEvent,
811
+ now: d.now ?? Date.now,
812
+ sleep: d.sleep ?? defaultSleep,
813
+ };
814
+ }
815
+
816
+ /**
817
+ * Map an identity verdict's reason to the audit's `reason` vocabulary.
818
+ *
819
+ * Three outcomes, never two: `gone` (measured, the process is not there),
820
+ * `signature-mismatch` / `identity-mismatch` (measured, it is a DIFFERENT
821
+ * process) and `unmeasured` (the probe itself could not answer). The third is
822
+ * the one a two-state mapping loses, and losing it is how an absent measurement
823
+ * starts reading like a clean verdict.
824
+ *
825
+ * @param {string|null|undefined} identityReason
826
+ * @returns {'gone'|'signature-mismatch'|'identity-mismatch'|'unmeasured'}
827
+ */
828
+ function rejectReasonFor(identityReason) {
829
+ if (identityReason === 'gone') return 'gone';
830
+ if (identityReason === 'unmeasured') return 'unmeasured';
831
+ if (identityReason === 'signature-mismatch') return 'signature-mismatch';
832
+ return 'identity-mismatch';
833
+ }
834
+
835
+ /**
836
+ * Build one B5 audit record.
837
+ *
838
+ * Exactly ONE `trigger` per record, taken from the single verdict
839
+ * {@link decideReapCandidates} assigned at its fixed priority — a kill carries
840
+ * `orphan-ppid1`, a report carries the reason it was reported for. That is what
841
+ * makes "why exactly was this killed" answerable afterwards; a record listing
842
+ * two triggers would answer it with a shrug.
843
+ *
844
+ * `reason` is separate from `trigger` and present on every WITHDRAWAL: the
845
+ * trigger says what made this a candidate, the reason says what took it back.
846
+ * `args_head` is present ONLY for a command that cleared the read-only
847
+ * allowlist — see {@link ARGS_HEAD_CHARS}.
848
+ *
849
+ * @param {object} entry A candidate or a reported/rejected entry.
850
+ * @param {'kill'|'report'|'reject'|'dry-run'} decision
851
+ * @param {object} [extra]
852
+ * @param {string} extra.timestamp
853
+ * @param {string|null} [extra.sessionId]
854
+ * @param {object} [extra.result]
855
+ * @param {string} [extra.reason]
856
+ * @returns {object}
857
+ */
858
+ function auditRecord(entry, decision, {
859
+ timestamp, sessionId = null, result, reason,
860
+ } = {}) {
861
+ /** @type {Record<string, unknown>} */
862
+ const record = {
863
+ timestamp,
864
+ session_id: sessionId,
865
+ pid: entry.pid,
866
+ pgid: entry.pgid ?? null,
867
+ trigger: entry.trigger ?? entry.reason ?? null,
868
+ threshold: entry.threshold ?? null,
869
+ actual: entry.actual ?? (typeof entry.ageSeconds === 'number' ? { ageSeconds: entry.ageSeconds } : null),
870
+ unit: 'seconds',
871
+ command_signature: entry.commandSignature ?? null,
872
+ decision,
873
+ };
874
+ if (typeof entry.args === 'string' && entry.args.length > 0) {
875
+ record.args_head = entry.args.slice(0, ARGS_HEAD_CHARS);
876
+ }
877
+ if (result !== undefined) record.result = result;
878
+ const effectiveReason = reason ?? (decision === 'report' ? entry.reason : undefined);
879
+ if (effectiveReason !== undefined && effectiveReason !== null) record.reason = effectiveReason;
880
+ return record;
881
+ }
882
+
883
+ /**
884
+ * Run one orphan scan: `ps` → decide → (unless `dryRun`) re-verify identity
885
+ * against a FRESH snapshot → kill the group → audit.
886
+ *
887
+ * NO-THROW contract: every failure degrades into `skipped: '<reason>'` or a
888
+ * `rejected` entry, because this runs from a hook and must never fail it
889
+ * (PRD FA4: "bei jedem Fehler degradiert er lautlos").
890
+ *
891
+ * The pre-signal re-check is the TOCTOU defence and the whole point of FA3: a
892
+ * PID recycled between decision and signal must NOT be signalled. It runs a
893
+ * TARGETED `ps` ({@link psPidArgs}) through `deps.runPsPid` and feeds the row to
894
+ * {@link verifyProcessIdentity} — immediately before SIGTERM **and** immediately
895
+ * before SIGKILL, via `killProcessGroup`'s `beforeSignal` gate, because the
896
+ * ladder's 10 s grace is itself a recycling window. On any mismatch no further
897
+ * signal is sent and the candidate is recorded as `decision: 'reject'` with the
898
+ * reason that withdrew it. Such a candidate stays in `candidates` (that WAS the
899
+ * decision) and additionally appears in `rejected` (that is the withdrawal) —
900
+ * the two arrays answer different questions and collapsing them would lose the
901
+ * TOCTOU event.
902
+ *
903
+ * Success is proven the same way, never from an exit code: after the ladder the
904
+ * scan waits `verifyWaitMs` and re-measures (B6). `ok` is true only for a
905
+ * process that is GONE; one that outlived SIGKILL is booked
906
+ * `survivedSigkill: true`, and one the probe could not measure is `verified:
907
+ * 'unmeasured'` — neither is ever a success.
908
+ *
909
+ * `dryRun` defaults to TRUE: arming happens at the CALL SITE (#1432), so a
910
+ * caller that forgets the flag scans and reports instead of killing.
911
+ *
912
+ * @param {object} opts
913
+ * @param {string} opts.repoRoot
914
+ * @param {number} [opts.now]
915
+ * @param {boolean} [opts.dryRun]
916
+ * @param {object} [opts.deps]
917
+ * @param {number} [opts.minAgeSeconds]
918
+ * @param {number} [opts.killGraceMs]
919
+ * @param {number} [opts.verifyWaitMs]
920
+ * @param {number} [opts.falseAlarmWindow] Rolling window of audit DECISIONS the
921
+ * HR-101 rate is judged over (`reaper.false-alarm-window`).
922
+ * @returns {Promise<{scanned: number, candidates: object[], reported: object[],
923
+ * rejected: object[], killed: object[], unattributed: number,
924
+ * peerLiveness: 'measured'|'unmeasured'|null, skipped?: string, malformed: number,
925
+ * durationMs: number, instrumentSuspect: boolean|null, falseAlarmRate: number|null,
926
+ * falseAlarmWindowN: number}>}
927
+ * `instrumentSuspect`/`falseAlarmRate` are `null` on a degraded (`skipped`) scan
928
+ * and `falseAlarmRate` is `null` below the 10-decision floor — in both cases a
929
+ * measurement that does not exist, never a measured zero. `peerLiveness` is the
930
+ * same distinction for the peer probe, and `null` only on a degraded scan.
931
+ */
932
+ export async function runOrphanScan({
933
+ repoRoot,
934
+ now,
935
+ dryRun = true,
936
+ deps,
937
+ minAgeSeconds = REAPER_DEFAULTS.minAgeSeconds,
938
+ killGraceMs = REAPER_DEFAULTS.killGraceMs,
939
+ verifyWaitMs = REAPER_DEFAULTS.verifyWaitMs,
940
+ falseAlarmWindow = REAPER_DEFAULTS.falseAlarmWindow,
941
+ } = {}) {
942
+ const d = resolveDeps(deps);
943
+ const startedAt = typeof now === 'number' ? now : d.now();
944
+ // A degraded scan measured NOTHING — including the instrument's own health.
945
+ // `null` rather than `false`/`0` for both instrument fields: a scan that never
946
+ // ran must not report a healthy instrument it never looked at.
947
+ const empty = (skipped, malformed = 0) => ({
948
+ scanned: 0,
949
+ candidates: [],
950
+ reported: [],
951
+ rejected: [],
952
+ killed: [],
953
+ unattributed: 0,
954
+ // Nothing was measured, so peer liveness was not measured either — `null`
955
+ // rather than the string, because "unmeasured" is a MEASUREMENT OUTCOME and
956
+ // a degraded scan never got as far as the probe.
957
+ peerLiveness: null,
958
+ skipped,
959
+ malformed,
960
+ durationMs: Math.max(0, d.now() - startedAt),
961
+ instrumentSuspect: null,
962
+ falseAlarmRate: null,
963
+ falseAlarmWindowN: 0,
964
+ });
965
+
966
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) return empty('no-repo-root');
967
+
968
+ let text;
969
+ try {
970
+ text = await d.runPs();
971
+ } catch {
972
+ return empty('ps-failed');
973
+ }
974
+ if (text === null || text === undefined) return empty('ps-failed');
975
+
976
+ const { rows, malformed } = parsePsSnapshotDetailed(text);
977
+
978
+ let ledger;
979
+ try {
980
+ ledger = d.readLedger(repoRoot, { nowMs: startedAt });
981
+ } catch {
982
+ return empty('ledger-unreadable', malformed);
983
+ }
984
+
985
+ let ownSessionId = null;
986
+ try {
987
+ ownSessionId = await d.readOwnSessionId(repoRoot);
988
+ } catch {
989
+ ownSessionId = null;
990
+ }
991
+
992
+ /** @type {string[]|null} */
993
+ let livePeerSessionIds;
994
+ try {
995
+ livePeerSessionIds = await d.detectPeers(ownSessionId);
996
+ } catch {
997
+ // null = unmeasured, NOT "no peers" — see decideReapCandidates.
998
+ livePeerSessionIds = null;
999
+ }
1000
+
1001
+ const { candidates, reported, rejected } = decideReapCandidates(
1002
+ rows,
1003
+ ledger?.records ?? [],
1004
+ startedAt,
1005
+ { ownSessionId, livePeerSessionIds, minAgeSeconds },
1006
+ );
1007
+
1008
+ const timestamp = new Date(startedAt).toISOString();
1009
+ const audit = (entry, decision, extra = {}) => {
1010
+ try {
1011
+ d.appendAudit(repoRoot, auditRecord(entry, decision, {
1012
+ timestamp, sessionId: ownSessionId, ...extra,
1013
+ }));
1014
+ } catch {
1015
+ /* the audit is an aid, never a precondition */
1016
+ }
1017
+ };
1018
+
1019
+ for (const entry of reported) audit(entry, 'report');
1020
+
1021
+ /** @type {object[]} */
1022
+ const killed = [];
1023
+
1024
+ /**
1025
+ * Close the scan: measure the instrument's own health (HR-101), emit at most
1026
+ * one event, return the result. Both real exits go through here so the rate
1027
+ * and the event can never be computed twice or forgotten once.
1028
+ * @returns {Promise<object>}
1029
+ */
1030
+ const complete = async () => {
1031
+ let auditRecords;
1032
+ try {
1033
+ auditRecords = (await d.readAuditRecords(repoRoot, falseAlarmWindow)) ?? [];
1034
+ } catch {
1035
+ auditRecords = [];
1036
+ }
1037
+ const fa = falseAlarmRate(auditRecords, falseAlarmWindow);
1038
+ // HR-101: the rate re-aims the instrument, it never re-thresholds it — so
1039
+ // this flag is REPORTED and nothing here branches on it.
1040
+ const instrumentSuspect = typeof fa.rate === 'number' && fa.rate > FALSE_ALARM_SUSPECT_RATE;
1041
+ const survivedSigkill = killed.filter((k) => k.survivedSigkill === true).length;
1042
+ const durationMs = Math.max(0, d.now() - startedAt);
1043
+ // Two counts that must not hide inside `reported`: an unattributed record is
1044
+ // a PRODUCER defect (the gate runner passed no session id), and an
1045
+ // unmeasured peer probe is an INSTRUMENT gap. Folded into the generic
1046
+ // `reported` number, both are invisible — which is how the foreign-session
1047
+ // guard stayed inert across 377 of 377 records without anything saying so.
1048
+ const unattributed = reported.filter((r) => r.reason === 'unattributed').length;
1049
+ const peerLiveness = livePeerSessionIds === null ? 'unmeasured' : 'measured';
1050
+
1051
+ // HR-101 again, in the other direction: a signal that fires on every hook
1052
+ // is noise nobody reads. A scan that found nothing emits nothing — the
1053
+ // absence of a record IS the healthy state, and `instrumentSuspect` is the
1054
+ // one finding that must surface even from an empty scan.
1055
+ if (candidates.length + reported.length + killed.length > 0 || instrumentSuspect) {
1056
+ try {
1057
+ await d.emitEvent(REAPER_SCAN_EVENT, {
1058
+ scanned: rows.length,
1059
+ candidates: candidates.length,
1060
+ reported: reported.length,
1061
+ rejected: rejected.length,
1062
+ killed: killed.length,
1063
+ unattributed,
1064
+ peer_liveness: peerLiveness,
1065
+ survived_sigkill: survivedSigkill,
1066
+ dry_run: dryRun === true,
1067
+ duration_ms: durationMs,
1068
+ instrument_suspect: instrumentSuspect,
1069
+ // OMITTED below the 10-decision floor, never 0: absence means "no
1070
+ // population yet", and a fabricated zero would read as a clean
1071
+ // instrument (`falseAlarmRate` returns `{rate: null}` there).
1072
+ ...(typeof fa.rate === 'number' ? { false_alarm_rate: fa.rate } : {}),
1073
+ }, { repoRoot });
1074
+ } catch {
1075
+ /* telemetry that can fail a scan would fail the hook the scan runs in */
1076
+ }
1077
+ }
1078
+
1079
+ // Housekeeping, last and best-effort: the ledger is append-only and grew
1080
+ // monotonically (measured 2026-09-22: 369 lines/day) because nothing in
1081
+ // production ever called the pruner.
1082
+ try {
1083
+ pruneGateProcessLedger(repoRoot);
1084
+ } catch {
1085
+ /* housekeeping, never a precondition of a scan */
1086
+ }
1087
+
1088
+ return {
1089
+ scanned: rows.length,
1090
+ candidates,
1091
+ reported,
1092
+ rejected,
1093
+ killed,
1094
+ unattributed,
1095
+ peerLiveness,
1096
+ malformed,
1097
+ durationMs,
1098
+ instrumentSuspect,
1099
+ falseAlarmRate: fa.rate,
1100
+ falseAlarmWindowN: fa.n,
1101
+ };
1102
+ };
1103
+
1104
+ if (dryRun) {
1105
+ for (const c of candidates) audit(c, 'dry-run');
1106
+ return complete();
1107
+ }
1108
+
1109
+ /**
1110
+ * ONE fresh, targeted identity measurement for one candidate (B3).
1111
+ *
1112
+ * There is deliberately no second, bulk `ps` pass any more: two TOCTOU
1113
+ * instruments measuring the same property at different freshness is how the
1114
+ * stale one silently wins. This is the only pre-signal measurement, and it is
1115
+ * taken immediately before each signal rather than once for the whole pass.
1116
+ *
1117
+ * @param {object} c
1118
+ * @returns {Promise<{match: boolean, reason: string, observed?: object}>}
1119
+ */
1120
+ const freshIdentity = async (c) => {
1121
+ let text;
1122
+ try {
1123
+ text = await d.runPsPid(c.pid);
1124
+ } catch {
1125
+ text = null;
1126
+ }
1127
+ // `ps -p` cannot distinguish "gone" from "could not run"; both must refuse
1128
+ // the signal, and only the second is an instrument gap worth its own reason.
1129
+ if (text === null || text === undefined) return { match: false, reason: 'unmeasured' };
1130
+ const row = parsePsSnapshot(text).find((r) => r.pid === c.pid) ?? null;
1131
+ try {
1132
+ // A group MEMBER is re-verified the way it was decided — by the leader's
1133
+ // seam it would fail every time (its own start time, its own command),
1134
+ // and a pre-signal check that always refuses is a disarmed feature, not a
1135
+ // strict one.
1136
+ if (c.isLeader === false) {
1137
+ return verifyGroupMemberIdentity(row, c.ledgerRecord, { nowMs: d.now() });
1138
+ }
1139
+ return d.verifyIdentity(
1140
+ c.pid,
1141
+ { startTime: c.ledgerRecord.startTime, commandSignature: c.ledgerRecord.commandSignature },
1142
+ { snapshotLine: row, nowMs: d.now() },
1143
+ );
1144
+ } catch {
1145
+ return { match: false, reason: 'gone' };
1146
+ }
1147
+ };
1148
+
1149
+ // ONE ladder per GROUP, not per row: with the pgid join a single group can
1150
+ // contribute several candidate rows (leader + descendants), and they all name
1151
+ // the same kill target. Leaders first, so the target of a group is the process
1152
+ // the ledger actually recorded whenever it is still alive.
1153
+ const killTargets = [];
1154
+ const coveredPgids = new Set();
1155
+ for (const c of [...candidates.filter((x) => x.isLeader), ...candidates.filter((x) => !x.isLeader)]) {
1156
+ if (coveredPgids.has(c.pgid)) continue;
1157
+ coveredPgids.add(c.pgid);
1158
+ killTargets.push(c);
1159
+ }
1160
+
1161
+ for (const c of killTargets) {
1162
+ /** Set by the gate below when it refuses; `null` means every signal was permitted. */
1163
+ let withdrawal = null;
1164
+ const beforeSignal = async (signal) => {
1165
+ const identity = await freshIdentity(c);
1166
+ if (identity?.match === true) return true;
1167
+ withdrawal = { signal, identity, reason: rejectReasonFor(identity?.reason) };
1168
+ return false;
1169
+ };
1170
+
1171
+ let result;
1172
+ try {
1173
+ result = await d.killProcessGroup(c.pgid, {
1174
+ killGraceMs, verifyWaitMs, sleepFn: d.sleep, beforeSignal,
1175
+ });
1176
+ } catch (err) {
1177
+ result = { ok: false, signalsSent: [], survivors: [c.pgid], error: err?.code ?? null, aborted: null };
1178
+ }
1179
+ const signalsSent = Array.isArray(result?.signalsSent) ? result.signalsSent : [];
1180
+
1181
+ if (withdrawal !== null) {
1182
+ // The candidate was withdrawn between decision and signal. It stays in
1183
+ // `candidates` (that WAS the decision) and appears in `rejected` (that is
1184
+ // the withdrawal) — collapsing the two would lose the TOCTOU event.
1185
+ // `signalsSent` is carried even here: a withdrawal before the ESCALATION
1186
+ // still means a SIGTERM went out, and an audit that hid it would
1187
+ // under-report what this reaper did to the host.
1188
+ rejected.push({
1189
+ ...c, reason: withdrawal.reason, identity: withdrawal.identity, signalsSent,
1190
+ });
1191
+ audit(c, 'reject', {
1192
+ reason: withdrawal.reason,
1193
+ result: {
1194
+ ok: false,
1195
+ signalsSent,
1196
+ survivors: [],
1197
+ survivedSigkill: false,
1198
+ verifiedAfterMs: 0,
1199
+ verified: 'withdrawn',
1200
+ },
1201
+ });
1202
+ continue;
1203
+ }
1204
+
1205
+ // B6: prove the EFFECT, after a wait, from a fresh measurement of the
1206
+ // process table. An exit code and a sent signal prove nothing — on
1207
+ // 2026-09-20 a probe with no wait reported "still alive" for dead processes.
1208
+ let verified = 'unverified';
1209
+ if (signalsSent.length > 0) {
1210
+ await d.sleep(verifyWaitMs);
1211
+ const after = await freshIdentity(c);
1212
+ if (after?.match === true) verified = 'alive';
1213
+ else if (after?.reason === 'unmeasured') verified = 'unmeasured';
1214
+ else verified = 'gone';
1215
+ }
1216
+ const ok = verified === 'gone';
1217
+ const survivedSigkill = verified === 'alive' && signalsSent.includes('SIGKILL');
1218
+ const survivors = ok ? [] : [c.pgid];
1219
+ const verifiedAfterMs = signalsSent.length > 0 ? verifyWaitMs : 0;
1220
+
1221
+ killed.push({
1222
+ pid: c.pid,
1223
+ pgid: c.pgid,
1224
+ // Every candidate row this one ladder covers — the group join means one
1225
+ // signal can end several candidates, and a `killed` list that named only
1226
+ // the target would under-report what the reaper did to the host.
1227
+ groupMemberPids: candidates.filter((x) => x.pgid === c.pgid).map((x) => x.pid),
1228
+ ok,
1229
+ signalsSent,
1230
+ survivors,
1231
+ survivedSigkill,
1232
+ verified,
1233
+ verifiedAfterMs,
1234
+ });
1235
+ audit(c, 'kill', {
1236
+ result: { ok, signalsSent, survivors, survivedSigkill, verifiedAfterMs, verified },
1237
+ });
1238
+ }
1239
+
1240
+ return complete();
1241
+ }
1242
+
1243
+ /**
1244
+ * Throttle gate (B4): may a scan run now?
1245
+ *
1246
+ * Reads the marker's mtime; a missing or unreadable marker means "yes" — the
1247
+ * first scan of a host must not be blocked by the absence of its own throttle
1248
+ * file. Writing the marker is the CALLER's job ({@link touchScanMarker}), so
1249
+ * this stays a read-only predicate a hook can call cheaply.
1250
+ *
1251
+ * @param {string} markerPath Absolute path — build it with {@link scanMarkerPath}.
1252
+ * @param {number} nowMs
1253
+ * @param {number} [minIntervalSeconds]
1254
+ * @param {object} [opts]
1255
+ * @param {(p: string) => {mtimeMs: number}} [opts.statFn]
1256
+ * @returns {boolean}
1257
+ */
1258
+ export function shouldScanNow(markerPath, nowMs, minIntervalSeconds = REAPER_DEFAULTS.minScanIntervalSeconds, {
1259
+ statFn = statSync,
1260
+ } = {}) {
1261
+ if (typeof markerPath !== 'string' || markerPath.length === 0) return false;
1262
+ let mtimeMs;
1263
+ try {
1264
+ mtimeMs = statFn(markerPath)?.mtimeMs;
1265
+ } catch {
1266
+ return true; // no marker yet → first scan
1267
+ }
1268
+ if (typeof mtimeMs !== 'number' || Number.isNaN(mtimeMs)) return true;
1269
+ return (nowMs - mtimeMs) >= minIntervalSeconds * 1000;
1270
+ }
1271
+
1272
+ /**
1273
+ * Stamp the throttle marker. Best-effort and never throws — a marker that could
1274
+ * not be written means the next scan runs, which is the safe direction for a
1275
+ * read-only probe.
1276
+ *
1277
+ * @param {string} markerPath
1278
+ * @param {object} [opts]
1279
+ * @param {(p: string, data: string) => void} [opts.writeFn]
1280
+ * @returns {boolean} whether the marker was written
1281
+ */
1282
+ export function touchScanMarker(markerPath, { writeFn } = {}) {
1283
+ try {
1284
+ if (writeFn) {
1285
+ writeFn(markerPath, `${new Date().toISOString()}\n`);
1286
+ return true;
1287
+ }
1288
+ mkdirSync(path.dirname(markerPath), { recursive: true });
1289
+ writeFileSync(markerPath, `${new Date().toISOString()}\n`, 'utf8');
1290
+ return true;
1291
+ } catch {
1292
+ return false;
1293
+ }
1294
+ }
1295
+
1296
+ /** Absolute path of the throttle marker for a repo. One constant per path.
1297
+ * @param {string} repoRoot @returns {string} */
1298
+ export function scanMarkerPath(repoRoot) {
1299
+ return underRepo(repoRoot, SCAN_MARKER_RELPATH);
1300
+ }
1301
+
1302
+ /** Absolute path of the kill audit for a repo. One constant per path.
1303
+ * @param {string} repoRoot @returns {string} */
1304
+ export function auditPath(repoRoot) {
1305
+ return underRepo(repoRoot, REAPER_AUDIT_RELPATH);
1306
+ }
1307
+
1308
+ /**
1309
+ * False-alarm rate over the last `windowSize` audit decisions — the HR-101
1310
+ * instrument-health check ("a signal may only warn if it is rare"; above ~10%
1311
+ * the instrument is broken and gets re-aimed, never re-thresholded).
1312
+ *
1313
+ * POPULATION (the number's denominator, stated because a rate without one is a
1314
+ * claim): audit records where the reaper judged a process a reapable orphan —
1315
+ * `decision` in `kill` | `dry-run` | `reject`. `report` records are excluded:
1316
+ * reporting a foreign or non-read-only process is the correct outcome, not a
1317
+ * firing of the kill signal.
1318
+ *
1319
+ * FALSE ALARM: a record where that judgement was refuted afterwards —
1320
+ * `decision: 'reject'` (the identity re-check withdrew the candidate) or a kill
1321
+ * whose `result.ok` is false (the signal did not take effect).
1322
+ *
1323
+ * Returns `{rate: null}` below 10 records: a rate over a handful of decisions
1324
+ * says nothing, and `null` is distinguishable from a measured 0 (a missing
1325
+ * measurement must never look like a zero).
1326
+ *
1327
+ * @param {object[]} auditRecords In chronological order; the LAST `windowSize` are used.
1328
+ * @param {number} [windowSize]
1329
+ * @returns {{rate: number|null, n: number}}
1330
+ */
1331
+ export function falseAlarmRate(auditRecords, windowSize = REAPER_DEFAULTS.falseAlarmWindow) {
1332
+ const all = Array.isArray(auditRecords) ? auditRecords : [];
1333
+ const firings = all.filter((r) => r && (r.decision === 'kill' || r.decision === 'dry-run' || r.decision === 'reject'));
1334
+ const window = windowSize > 0 ? firings.slice(-windowSize) : firings;
1335
+ const n = window.length;
1336
+ if (n < 10) return { rate: null, n };
1337
+ const falseAlarms = window.filter((r) => r.decision === 'reject' || r?.result?.ok === false).length;
1338
+ return { rate: falseAlarms / n, n };
1339
+ }
1340
+
1341
+ // ---------------------------------------------------------------------------
1342
+ // CLI (#1432 B4 follow-up)
1343
+ // ---------------------------------------------------------------------------
1344
+ //
1345
+ // The two trigger hooks (`hooks/on-stop.mjs`,
1346
+ // `hooks/post-tool-batch-wave-signal.mjs`) spawn the scan as a DETACHED child.
1347
+ // Before this tail existed they had to hand `node` an `--input-type=module -e
1348
+ // <program>` string that dynamically imported this module — a program the
1349
+ // hooks carried as source text, in two byte-identical copies. This entry point
1350
+ // replaces it with a plain argv call, so the hooks spawn `node
1351
+ // scripts/lib/orphan-reaper.mjs --repo-root <p> --mode <m> …` and the child's
1352
+ // contract lives HERE, in one place, next to the function it drives.
1353
+
1354
+ /**
1355
+ * Parse the CLI argv — PURE, and exported so the flag contract is testable
1356
+ * without starting a process (the argv form is what the two hooks build, so a
1357
+ * typo in it fails silently as a dead detached child).
1358
+ *
1359
+ * Never throws: an unknown flag, a missing value and a non-numeric value all
1360
+ * land in `errors`, which the tail maps to exit 2. Returning the errors rather
1361
+ * than throwing keeps this usable from a test and from the tail alike.
1362
+ *
1363
+ * @param {string[]} [argv] `process.argv.slice(2)`
1364
+ * @returns {{help: boolean, json: boolean, repoRoot: string|null, mode: 'report'|'kill',
1365
+ * dryRun: boolean, minAgeSeconds: number, killGraceMs: number, verifyWaitMs: number,
1366
+ * falseAlarmWindow: number, errors: string[]}}
1367
+ */
1368
+ export function parseReaperCliArgs(argv = []) {
1369
+ const args = Array.isArray(argv) ? argv : [];
1370
+ /** @type {string[]} */
1371
+ const errors = [];
1372
+ const out = {
1373
+ help: false,
1374
+ json: false,
1375
+ repoRoot: null,
1376
+ mode: /** @type {'report'|'kill'} */ ('report'),
1377
+ dryRun: true,
1378
+ minAgeSeconds: REAPER_DEFAULTS.minAgeSeconds,
1379
+ killGraceMs: REAPER_DEFAULTS.killGraceMs,
1380
+ verifyWaitMs: REAPER_DEFAULTS.verifyWaitMs,
1381
+ falseAlarmWindow: REAPER_DEFAULTS.falseAlarmWindow,
1382
+ errors,
1383
+ };
1384
+
1385
+ /**
1386
+ * Consume the value of `--flag <value>`; records an error when it is absent.
1387
+ * @param {string} flag @param {number} i @returns {string|null}
1388
+ */
1389
+ const valueAt = (flag, i) => {
1390
+ const value = args[i + 1];
1391
+ if (value === undefined || value.startsWith('--')) {
1392
+ errors.push(`missing value for ${flag}`);
1393
+ return null;
1394
+ }
1395
+ return value;
1396
+ };
1397
+
1398
+ /**
1399
+ * A count of seconds/milliseconds: finite and non-negative. A `NaN` here
1400
+ * would reach `runOrphanScan` as a threshold that compares false against
1401
+ * everything — a silently disarmed gate, which is why it is an error and
1402
+ * never a fallback to the default.
1403
+ * @param {string} flag @param {string} raw @returns {number|null}
1404
+ */
1405
+ const numberFrom = (flag, raw) => {
1406
+ const n = Number(raw);
1407
+ if (!Number.isFinite(n) || n < 0) {
1408
+ errors.push(`${flag} expects a non-negative number, got ${JSON.stringify(raw)}`);
1409
+ return null;
1410
+ }
1411
+ return n;
1412
+ };
1413
+
1414
+ for (let i = 0; i < args.length; i += 1) {
1415
+ const arg = args[i];
1416
+ switch (arg) {
1417
+ case '--help':
1418
+ case '-h':
1419
+ out.help = true;
1420
+ break;
1421
+ case '--json':
1422
+ out.json = true;
1423
+ break;
1424
+ case '--repo-root': {
1425
+ const value = valueAt(arg, i);
1426
+ if (value !== null) { out.repoRoot = value; i += 1; }
1427
+ break;
1428
+ }
1429
+ case '--mode': {
1430
+ const value = valueAt(arg, i);
1431
+ if (value === null) break;
1432
+ i += 1;
1433
+ if (value !== 'report' && value !== 'kill') {
1434
+ errors.push(`--mode expects report|kill, got ${JSON.stringify(value)}`);
1435
+ break;
1436
+ }
1437
+ out.mode = value;
1438
+ break;
1439
+ }
1440
+ case '--min-age-seconds':
1441
+ case '--kill-grace-ms':
1442
+ case '--verify-wait-ms': {
1443
+ const value = valueAt(arg, i);
1444
+ if (value === null) break;
1445
+ i += 1;
1446
+ const n = numberFrom(arg, value);
1447
+ if (n === null) break;
1448
+ if (arg === '--min-age-seconds') out.minAgeSeconds = n;
1449
+ else if (arg === '--kill-grace-ms') out.killGraceMs = n;
1450
+ else out.verifyWaitMs = n;
1451
+ break;
1452
+ }
1453
+ case '--false-alarm-window': {
1454
+ const value = valueAt(arg, i);
1455
+ if (value === null) break;
1456
+ i += 1;
1457
+ const n = numberFrom(arg, value);
1458
+ if (n === null) break;
1459
+ // A window of 0 is not "no window" — `falseAlarmRate` reads it as "every
1460
+ // firing ever recorded", which is the opposite of the rolling window the
1461
+ // key names. The config parser clamps at 1; the CLI refuses instead, so
1462
+ // a hand-run diagnosis never measures a different population than the
1463
+ // hook does.
1464
+ if (n < 1) {
1465
+ errors.push(`${arg} expects a number >= 1, got ${JSON.stringify(value)}`);
1466
+ break;
1467
+ }
1468
+ out.falseAlarmWindow = n;
1469
+ break;
1470
+ }
1471
+ default:
1472
+ errors.push(`unknown argument: ${arg}`);
1473
+ }
1474
+ }
1475
+
1476
+ // `kill` is the ONLY value that disarms the dry run — same direction as
1477
+ // `runOrphanScan`'s own `dryRun = true` default: a caller that mistypes the
1478
+ // mode scans and reports, it does not signal. (A rejected `--mode` value
1479
+ // already left `mode` at `report`, so this stays `true` there too.)
1480
+ out.dryRun = out.mode !== 'kill';
1481
+ return out;
1482
+ }
1483
+
1484
+ /** `--help` text. Exit codes documented here are the ones the tail returns. */
1485
+ const REAPER_HELP_TEXT = `orphan-reaper.mjs — scan for orphaned read-only gate processes (#1425 Teil B)
1486
+
1487
+ USAGE
1488
+ node scripts/lib/orphan-reaper.mjs [options]
1489
+
1490
+ DESCRIPTION
1491
+ Runs ONE orphan scan: ps -> decide -> (with --mode kill) re-verify identity
1492
+ against a fresh snapshot -> kill the process group -> audit. Candidates are a
1493
+ CONJUNCTION: recorded in this repo's gate-process ledger, PPID 1, older than
1494
+ --min-age-seconds, a read-only gate command, and an identity that still
1495
+ verifies. Spawned detached by hooks/on-stop.mjs and
1496
+ hooks/post-tool-batch-wave-signal.mjs; runnable by hand for diagnosis.
1497
+
1498
+ OPTIONS
1499
+ --repo-root <path> Repo whose ledger, audit and marker are used
1500
+ (default: cwd).
1501
+ --mode report|kill report = dry run, signals nothing (default);
1502
+ kill = send the SIGTERM/SIGKILL ladder.
1503
+ --min-age-seconds <n> Minimum elapsed time before a process is reapable
1504
+ (default: ${REAPER_DEFAULTS.minAgeSeconds}).
1505
+ --kill-grace-ms <n> Grace between SIGTERM and SIGKILL
1506
+ (default: ${REAPER_DEFAULTS.killGraceMs}).
1507
+ --verify-wait-ms <n> Wait before re-measuring the effect
1508
+ (default: ${REAPER_DEFAULTS.verifyWaitMs}).
1509
+ --false-alarm-window <n> Rolling window of audit DECISIONS the HR-101
1510
+ false-alarm rate is judged over; >= 1
1511
+ (default: ${REAPER_DEFAULTS.falseAlarmWindow}).
1512
+ --json Emit the full scan result as one JSON object.
1513
+ --help, -h Show this help and exit.
1514
+
1515
+ EXIT CODES
1516
+ 0 the scan ran (0 candidates is a normal, successful scan)
1517
+ 2 usage error, or a degraded scan that measured nothing (\`skipped\`)
1518
+ `;
1519
+
1520
+ /**
1521
+ * CLI body: one scan, one line (or one JSON object) of output.
1522
+ *
1523
+ * A degraded scan — `skipped: 'ps-failed' | 'ledger-unreadable' | …` — exits 2,
1524
+ * not 0: it measured NOTHING, and an exit 0 there is exactly the "missing
1525
+ * measurement looks like a zero" shape this module refuses everywhere else.
1526
+ * The detached hook child ignores the code; a human or a CI caller does not.
1527
+ *
1528
+ * @param {string[]} argv `process.argv.slice(2)`
1529
+ * @returns {Promise<number>} process exit code
1530
+ */
1531
+ async function mainCli(argv) {
1532
+ const cli = parseReaperCliArgs(argv);
1533
+
1534
+ if (cli.help) {
1535
+ process.stdout.write(REAPER_HELP_TEXT);
1536
+ return 0;
1537
+ }
1538
+ if (cli.errors.length > 0) {
1539
+ for (const message of cli.errors) process.stderr.write(`orphan-reaper: ${message}\n`);
1540
+ process.stderr.write('Run with --help for usage.\n');
1541
+ return 2;
1542
+ }
1543
+
1544
+ const repoRoot = cli.repoRoot ?? process.cwd();
1545
+ let result;
1546
+ try {
1547
+ result = await runOrphanScan({
1548
+ repoRoot,
1549
+ dryRun: cli.dryRun,
1550
+ minAgeSeconds: cli.minAgeSeconds,
1551
+ killGraceMs: cli.killGraceMs,
1552
+ verifyWaitMs: cli.verifyWaitMs,
1553
+ falseAlarmWindow: cli.falseAlarmWindow,
1554
+ });
1555
+ } catch (err) {
1556
+ // runOrphanScan carries a NO-THROW contract; this catch exists so a broken
1557
+ // contract surfaces as a tool error instead of an unhandled rejection.
1558
+ process.stderr.write(`orphan-reaper: scan failed — ${err?.message ?? String(err)}\n`);
1559
+ return 2;
1560
+ }
1561
+
1562
+ if (cli.json) {
1563
+ process.stdout.write(`${JSON.stringify({ repoRoot, mode: cli.mode, ...result })}\n`);
1564
+ } else {
1565
+ process.stdout.write(
1566
+ `orphan-reaper: scanned=${result.scanned} candidates=${result.candidates.length} `
1567
+ + `reported=${result.reported.length} unattributed=${result.unattributed} `
1568
+ + `peer_liveness=${result.peerLiveness ?? 'none'} killed=${result.killed.length} `
1569
+ + `malformed=${result.malformed} mode=${cli.mode}`
1570
+ + `${result.skipped ? ` skipped=${result.skipped}` : ''}\n`,
1571
+ );
1572
+ }
1573
+ return result.skipped ? 2 : 0;
1574
+ }
1575
+
1576
+ // Entry guard: a bare `import()` of this module must do NOTHING — both trigger
1577
+ // hooks import it for `scanMarkerPath`/`shouldScanNow` on a hot path. Written
1578
+ // in the one symlink-safe form `scripts/lib/validate/check-entry-guard.mjs`
1579
+ // and `check-hook-entry-guards.mjs` accept.
1580
+ //
1581
+ // `process.exitCode` rather than `process.exit()`: `--json` carries the whole
1582
+ // result, whose `rejected` array materialises every PPID-1 row not in the
1583
+ // ledger — measured 2026-09-22 on this host, 40.224 bytes for 834 processes,
1584
+ // the same order as the 64 KiB pipe buffer above which `process.exit()`
1585
+ // DISCARDS the pending write and turns a full result into a truncated one.
1586
+ if (isMainModule(import.meta.url)) {
1587
+ process.exitCode = await mainCli(process.argv.slice(2));
1588
+ }