session-orchestrator 5.2.0 → 5.4.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 (427) hide show
  1. package/.agents/skills/architecture/SKILL.md +3 -1
  2. package/.agents/skills/autopilot/SKILL.md +5 -1
  3. package/.agents/skills/autopilot/agents/openai.yaml +5 -0
  4. package/.agents/skills/bootstrap/SKILL.md +5 -1
  5. package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
  6. package/.agents/skills/brainstorm/SKILL.md +5 -1
  7. package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
  8. package/.agents/skills/claude-md-drift-check/SKILL.md +4 -2
  9. package/.agents/skills/close/SKILL.md +5 -1
  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 +5 -1
  13. package/.agents/skills/debug/agents/openai.yaml +5 -0
  14. package/.agents/skills/discovery/SKILL.md +5 -1
  15. package/.agents/skills/discovery/agents/openai.yaml +5 -0
  16. package/.agents/skills/dispatcher/SKILL.md +5 -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 +5 -1
  21. package/.agents/skills/eli5/agents/openai.yaml +5 -0
  22. package/.agents/skills/eval/SKILL.md +6 -2
  23. package/.agents/skills/eval/agents/openai.yaml +5 -0
  24. package/.agents/skills/evolve/SKILL.md +6 -2
  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 +5 -1
  30. package/.agents/skills/go/agents/openai.yaml +5 -0
  31. package/.agents/skills/grill/SKILL.md +5 -1
  32. package/.agents/skills/grill/agents/openai.yaml +5 -0
  33. package/.agents/skills/harness-audit/SKILL.md +5 -1
  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 +5 -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/navigator/SKILL.md +22 -0
  41. package/.agents/skills/navigator/agents/openai.yaml +5 -0
  42. package/.agents/skills/npm-publish/SKILL.md +4 -2
  43. package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
  44. package/.agents/skills/persona-panel/SKILL.md +5 -1
  45. package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
  46. package/.agents/skills/plan/SKILL.md +5 -1
  47. package/.agents/skills/plan/agents/openai.yaml +5 -0
  48. package/.agents/skills/playwright-driver/SKILL.md +3 -1
  49. package/.agents/skills/portfolio/SKILL.md +5 -1
  50. package/.agents/skills/portfolio/agents/openai.yaml +5 -0
  51. package/.agents/skills/quality-gates/SKILL.md +3 -1
  52. package/.agents/skills/reconcile/SKILL.md +5 -1
  53. package/.agents/skills/reconcile/agents/openai.yaml +5 -0
  54. package/.agents/skills/release/SKILL.md +5 -1
  55. package/.agents/skills/release/agents/openai.yaml +5 -0
  56. package/.agents/skills/remote-offload/SKILL.md +3 -1
  57. package/.agents/skills/repo-audit/SKILL.md +5 -1
  58. package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
  59. package/.agents/skills/session/SKILL.md +21 -0
  60. package/.agents/skills/session/agents/openai.yaml +5 -0
  61. package/.agents/skills/session-end/SKILL.md +3 -1
  62. package/.agents/skills/session-plan/SKILL.md +3 -1
  63. package/.agents/skills/session-start/SKILL.md +3 -1
  64. package/.agents/skills/spinout/SKILL.md +5 -1
  65. package/.agents/skills/spinout/agents/openai.yaml +5 -0
  66. package/.agents/skills/sunset-review/SKILL.md +5 -1
  67. package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
  68. package/.agents/skills/templates-ack/SKILL.md +21 -0
  69. package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
  70. package/.agents/skills/test/SKILL.md +5 -1
  71. package/.agents/skills/test/agents/openai.yaml +5 -0
  72. package/.agents/skills/test-audit/SKILL.md +21 -0
  73. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  74. package/.agents/skills/test-runner/SKILL.md +3 -1
  75. package/.agents/skills/tmux-layout/SKILL.md +3 -1
  76. package/.agents/skills/using-orchestrator/SKILL.md +3 -1
  77. package/.agents/skills/ux-grill/SKILL.md +5 -1
  78. package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
  79. package/.agents/skills/vault-mirror/SKILL.md +3 -1
  80. package/.agents/skills/vault-sync/SKILL.md +3 -1
  81. package/.agents/skills/wave-executor/SKILL.md +3 -1
  82. package/.agents/skills/write-executable-plan/SKILL.md +3 -1
  83. package/.claude-plugin/marketplace.json +1 -1
  84. package/.claude-plugin/plugin.json +1 -1
  85. package/.codex-plugin/plugin.json +4 -4
  86. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  87. package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -3
  88. package/.codex-plugin/skills/eval/SKILL.md +1 -1
  89. package/.codex-plugin/skills/evolve/SKILL.md +1 -1
  90. package/.codex-plugin/skills/navigator/SKILL.md +22 -0
  91. package/.codex-plugin/skills/{convergence-monitoring → navigator}/agents/openai.yaml +2 -2
  92. package/.codex-plugin/skills/npm-publish/SKILL.md +1 -3
  93. package/.codex-plugin/skills/session/SKILL.md +1 -1
  94. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  95. package/.codex-plugin/skills/{npm-publish → test-audit}/agents/openai.yaml +1 -1
  96. package/.cursor/commands/eval.md +1 -1
  97. package/.cursor/commands/navigator.md +15 -0
  98. package/.cursor/commands/session.md +1 -1
  99. package/.cursor/commands/test-audit.md +14 -0
  100. package/.cursor/rules/000-session-orchestrator.mdc +0 -2
  101. package/.cursor/rules/050-plan.mdc +1 -1
  102. package/.cursor/rules/060-evolve.mdc +27 -12
  103. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  104. package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
  105. package/.cursor/skills/eval/SKILL.md +1 -1
  106. package/.cursor/skills/navigator/SKILL.md +13 -0
  107. package/.cursor/skills/npm-publish/SKILL.md +1 -0
  108. package/.cursor/skills/test-audit/SKILL.md +12 -0
  109. package/.cursor-plugin/plugin.json +1 -1
  110. package/.orchestrator/policy/blocked-commands.json +12 -3
  111. package/AGENTS.md +9 -30
  112. package/CHANGELOG.md +206 -0
  113. package/README.md +13 -9
  114. package/SECURITY.md +12 -0
  115. package/agents/dialectic-deriver.md +13 -10
  116. package/agents/eval-judge.md +67 -45
  117. package/agents/skill-applied-judge.md +34 -19
  118. package/commands/session.md +7 -3
  119. package/docs/agent-authoring.md +2 -2
  120. package/docs/baseline.md +22 -7
  121. package/docs/ci-setup.md +57 -4
  122. package/docs/codex-setup.md +14 -2
  123. package/docs/components.md +12 -10
  124. package/docs/events-schema.md +65 -15
  125. package/docs/persona-panel.md +5 -4
  126. package/docs/rule-authoring.md +84 -17
  127. package/docs/scope-collision-guard.md +26 -4
  128. package/docs/session-config-reference.md +154 -12
  129. package/docs/session-config-template.md +41 -3
  130. package/docs/telemetry.md +2 -0
  131. package/hooks/_lib/hook-import-set.json +87 -6
  132. package/hooks/_lib/subagent-transcript.mjs +582 -31
  133. package/hooks/config-protection.mjs +11 -3
  134. package/hooks/cwd-change-restore.mjs +11 -3
  135. package/hooks/enforce-commands.mjs +70 -23
  136. package/hooks/enforce-scope.mjs +143 -33
  137. package/hooks/hooks-codex.json +1 -1
  138. package/hooks/hooks-cursor.json +5 -0
  139. package/hooks/hooks-pi.json +5 -0
  140. package/hooks/hooks.json +6 -1
  141. package/hooks/loop-guard.mjs +11 -3
  142. package/hooks/on-session-end.mjs +58 -23
  143. package/hooks/on-session-start.mjs +101 -11
  144. package/hooks/on-stop.mjs +170 -22
  145. package/hooks/operator-steer.mjs +11 -3
  146. package/hooks/post-bash-issue-budget-refund.mjs +18 -8
  147. package/hooks/post-bash-write-verify.mjs +3 -2
  148. package/hooks/post-edit-import-probe.mjs +17 -9
  149. package/hooks/post-edit-validate.mjs +13 -5
  150. package/hooks/post-subagent-discovery-validator.mjs +98 -13
  151. package/hooks/post-tool-batch-wave-signal.mjs +202 -38
  152. package/hooks/post-tool-failure-corrective-context.mjs +11 -5
  153. package/hooks/post-tooluse-frontend-slop.mjs +10 -4
  154. package/hooks/pre-auq-clarity.mjs +15 -2
  155. package/hooks/pre-bash-destructive-guard.mjs +80 -9
  156. package/hooks/pre-bash-issue-budget.mjs +16 -11
  157. package/hooks/pre-bash-memory-propose-audit.mjs +137 -56
  158. package/hooks/pre-bash-sessions-ledger-guard.mjs +419 -22
  159. package/hooks/pre-bash-staging-fence.mjs +335 -31
  160. package/hooks/pre-bash-templates-first.mjs +19 -14
  161. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  162. package/hooks/pre-task-scope-disjoint.mjs +792 -74
  163. package/hooks/subagent-telemetry.mjs +111 -38
  164. package/hooks/wave-scope-commit-guard.mjs +197 -100
  165. package/monitors/monitors.json +1 -1
  166. package/output-styles/wave-summary.md +1 -1
  167. package/package.json +3 -3
  168. package/pi/prompts/eval.md +1 -1
  169. package/pi/prompts/navigator.md +12 -0
  170. package/pi/prompts/session.md +1 -1
  171. package/pi/prompts/test-audit.md +12 -0
  172. package/rules/README.md +7 -2
  173. package/rules/_index.md +3 -3
  174. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  175. package/rules/opt-in-domain/prompt-caching.md +6 -2
  176. package/rules/opt-in-stack/backend-data.md +10 -2
  177. package/rules/opt-in-stack/backend.md +10 -4
  178. package/rules/opt-in-stack/frontend.md +7 -2
  179. package/rules/opt-in-stack/security-web.md +10 -4
  180. package/rules/opt-in-stack/swift.md +6 -2
  181. package/scripts/apply-session-learnings.mjs +492 -0
  182. package/scripts/archive-closed-prds.mjs +2 -1
  183. package/scripts/autopilot.mjs +70 -53
  184. package/scripts/backfill-abandoned-sessions.mjs +118 -16
  185. package/scripts/baseline-archetypes.mjs +1 -1
  186. package/scripts/check-package-manager.mjs +62 -5
  187. package/scripts/check-sessions-integrity.mjs +300 -0
  188. package/scripts/cursor-install.mjs +1 -4
  189. package/scripts/dialectic-deriver.mjs +50 -13
  190. package/scripts/emit-session.mjs +371 -31
  191. package/scripts/eval-session.mjs +65 -3
  192. package/scripts/export-hw-learnings.mjs +22 -3
  193. package/scripts/fleet-checkin.mjs +147 -0
  194. package/scripts/generate-agents-skills.mjs +102 -29
  195. package/scripts/generate-cursor-adapter.mjs +61 -16
  196. package/scripts/lib/agent-frontmatter.mjs +3 -3
  197. package/scripts/lib/agent-status.mjs +2 -31
  198. package/scripts/lib/auq/clarity.mjs +10 -2
  199. package/scripts/lib/auq/parse.mjs +12 -31
  200. package/scripts/lib/auq/schema.mjs +56 -41
  201. package/scripts/lib/auto-dialectic.mjs +304 -15
  202. package/scripts/lib/auto-dream.mjs +7 -6
  203. package/scripts/lib/autopilot/flags.mjs +18 -2
  204. package/scripts/lib/autopilot/kill-switches.mjs +6 -3
  205. package/scripts/lib/autopilot/loop.mjs +19 -2
  206. package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
  207. package/scripts/lib/baseline-archetypes.mjs +41 -22
  208. package/scripts/lib/ci-status-banner.mjs +376 -16
  209. package/scripts/lib/command-blocker.mjs +275 -28
  210. package/scripts/lib/config/dialectic.mjs +12 -3
  211. package/scripts/lib/config/gate.mjs +74 -0
  212. package/scripts/lib/config/host-paths.mjs +79 -0
  213. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  214. package/scripts/lib/config/reaper.mjs +163 -0
  215. package/scripts/lib/config-schema.mjs +13 -0
  216. package/scripts/lib/config.mjs +85 -10
  217. package/scripts/lib/convergence-monitor.mjs +74 -11
  218. package/scripts/lib/ecosystem-health.mjs +11 -0
  219. package/scripts/lib/eval/engine.mjs +421 -53
  220. package/scripts/lib/eval/judge.mjs +463 -40
  221. package/scripts/lib/eval/schema.mjs +10 -1
  222. package/scripts/lib/events-rotation.mjs +221 -25
  223. package/scripts/lib/events-schema.mjs +114 -0
  224. package/scripts/lib/events.mjs +545 -7
  225. package/scripts/lib/fleet-protocol.mjs +270 -0
  226. package/scripts/lib/frontmatter-guard.mjs +21 -10
  227. package/scripts/lib/gates/gate-baseline.mjs +40 -5
  228. package/scripts/lib/gates/gate-full.mjs +35 -5
  229. package/scripts/lib/gates/gate-helpers.mjs +336 -23
  230. package/scripts/lib/gates/gate-incremental.mjs +49 -23
  231. package/scripts/lib/gates/gate-per-file.mjs +42 -8
  232. package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
  233. package/scripts/lib/instruction-budget-guard.mjs +208 -8
  234. package/scripts/lib/io.mjs +42 -8
  235. package/scripts/lib/issue-close-strip-labels.mjs +207 -49
  236. package/scripts/lib/js-mask.mjs +197 -0
  237. package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
  238. package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
  239. package/scripts/lib/learnings/io.mjs +154 -26
  240. package/scripts/lib/learnings/schema.mjs +163 -15
  241. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  242. package/scripts/lib/maintenance-due-banner.mjs +53 -88
  243. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  244. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  245. package/scripts/lib/named-vault-resolver.mjs +104 -5
  246. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  247. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  248. package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
  249. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  250. package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
  251. package/scripts/lib/orphan-reaper/scan.mjs +506 -0
  252. package/scripts/lib/orphan-reaper.mjs +316 -0
  253. package/scripts/lib/owner-yaml.mjs +104 -6
  254. package/scripts/lib/peer-cards/merger.mjs +48 -10
  255. package/scripts/lib/peer-cards/reader.mjs +78 -2
  256. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  257. package/scripts/lib/process-group.mjs +950 -0
  258. package/scripts/lib/project-hygiene.mjs +21 -1
  259. package/scripts/lib/quality-gate.mjs +152 -33
  260. package/scripts/lib/reconcile/backlog.mjs +368 -0
  261. package/scripts/lib/reconcile/engine.mjs +55 -188
  262. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +302 -60
  263. package/scripts/lib/reconcile/sanitize.mjs +69 -3
  264. package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
  265. package/scripts/lib/resource-probe/parsers.mjs +31 -0
  266. package/scripts/lib/resource-probe.mjs +1 -1
  267. package/scripts/lib/rule-loader.mjs +141 -74
  268. package/scripts/lib/rules-sync.mjs +27 -12
  269. package/scripts/lib/scope-baseline.mjs +3 -4
  270. package/scripts/lib/scope-echo.mjs +39 -2
  271. package/scripts/lib/scope-gate.mjs +605 -1
  272. package/scripts/lib/session-close-backfill.mjs +33 -6
  273. package/scripts/lib/session-id.mjs +9 -20
  274. package/scripts/lib/session-invocation.mjs +20 -0
  275. package/scripts/lib/session-record-repair.mjs +183 -26
  276. package/scripts/lib/session-registry.mjs +27 -2
  277. package/scripts/lib/session-schema/constants.mjs +56 -2
  278. package/scripts/lib/session-schema/normalizer.mjs +56 -4
  279. package/scripts/lib/session-schema/validator.mjs +28 -1
  280. package/scripts/lib/session-schema.mjs +8 -3
  281. package/scripts/lib/session-start-probes.mjs +95 -10
  282. package/scripts/lib/session-token-rollup.mjs +245 -52
  283. package/scripts/lib/sessions-canonical.mjs +118 -4
  284. package/scripts/lib/sessions-integrity-banner.mjs +7 -1
  285. package/scripts/lib/sessions-staleness-banner.mjs +193 -51
  286. package/scripts/lib/sh-quote.mjs +29 -0
  287. package/scripts/lib/skill-evidence-window.mjs +891 -0
  288. package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
  289. package/scripts/lib/skill-evolution/engine.mjs +18 -9
  290. package/scripts/lib/skill-judge.mjs +45 -3
  291. package/scripts/lib/subagents-schema.mjs +30 -3
  292. package/scripts/lib/tail-window.mjs +56 -0
  293. package/scripts/lib/telemetry/pricing.mjs +118 -71
  294. package/scripts/lib/telemetry/schema.mjs +30 -0
  295. package/scripts/lib/telemetry/sync.mjs +61 -6
  296. package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
  297. package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
  298. package/scripts/lib/tests-src-ratio.mjs +140 -23
  299. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  300. package/scripts/lib/tmux-layout/telemetry-stats.mjs +72 -13
  301. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  302. package/scripts/lib/user-invocable-skills.mjs +23 -3
  303. package/scripts/lib/ux-grill/reconcile.mjs +48 -22
  304. package/scripts/lib/validate/check-agents-skills.mjs +26 -15
  305. package/scripts/lib/validate/check-agents.mjs +1 -1
  306. package/scripts/lib/validate/check-cursor-adapter.mjs +1 -0
  307. package/scripts/lib/validate/check-entry-guard.mjs +13 -50
  308. package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
  309. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  310. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  311. package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
  312. package/scripts/lib/validate/check-rules.mjs +7 -5
  313. package/scripts/lib/validate/check-skill-links.mjs +9 -1
  314. package/scripts/lib/validate/check-skill-script-paths.mjs +239 -27
  315. package/scripts/lib/validate/check-test-git-config-target.mjs +24 -34
  316. package/scripts/lib/validate/check-test-value-bans.mjs +87 -14
  317. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  318. package/scripts/lib/validate/check-untracked-test-deps.mjs +7 -102
  319. package/scripts/lib/validate/check-unwired-features.mjs +130 -27
  320. package/scripts/lib/validate/check-validator-registration.mjs +34 -10
  321. package/scripts/lib/validate/confidential-names.mjs +10 -0
  322. package/scripts/lib/validate-vendored-rules.mjs +210 -84
  323. package/scripts/lib/vault-mirror/namespace.mjs +46 -8
  324. package/scripts/lib/vault-mirror/process.mjs +10 -3
  325. package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
  326. package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
  327. package/scripts/lib/vault-status/board-writer.mjs +48 -11
  328. package/scripts/lib/vault-status/narrative-mirror.mjs +71 -16
  329. package/scripts/lib/vault-yaml.mjs +118 -0
  330. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  331. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  332. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  333. package/scripts/lib/wave-resource-gate.mjs +34 -8
  334. package/scripts/lib/worktree/lifecycle.mjs +153 -1
  335. package/scripts/memory-propose.mjs +18 -5
  336. package/scripts/release-session-lock.mjs +306 -0
  337. package/scripts/release.mjs +40 -8
  338. package/scripts/repair-invalid-sessions.mjs +37 -4
  339. package/scripts/resolve-session-invocation.mjs +59 -0
  340. package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
  341. package/scripts/run-quality-gate.mjs +357 -197
  342. package/scripts/site-numbers.mjs +163 -5
  343. package/scripts/sweep-expired-learnings.mjs +56 -5
  344. package/scripts/sweep-expired-rules.mjs +16 -4
  345. package/scripts/validate-plugin.mjs +19 -0
  346. package/scripts/validate-wave-scope.mjs +32 -105
  347. package/scripts/vault-integration-watcher.mjs +2 -1
  348. package/scripts/vault-mirror.mjs +81 -47
  349. package/scripts/wave-scope-binding.mjs +2 -1
  350. package/skills/_shared/config-reading.md +3 -3
  351. package/skills/_shared/fleet-protocol.md +131 -0
  352. package/skills/_shared/platform-tools.md +23 -11
  353. package/skills/_shared/state-ownership.md +5 -0
  354. package/skills/autopilot/SKILL.md +47 -18
  355. package/skills/bootstrap/SKILL.md +1 -1
  356. package/skills/bootstrap/_shared-template.md +1 -1
  357. package/skills/bootstrap/fast-template.md +1 -1
  358. package/skills/bootstrap/private-contract.md +2 -1
  359. package/skills/bootstrap/public-fallback.md +6 -3
  360. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  361. package/skills/claude-md-drift-check/SKILL.md +10 -10
  362. package/skills/claude-md-drift-check/checker.mjs +26 -18
  363. package/skills/convergence-monitoring/README.md +8 -1
  364. package/skills/convergence-monitoring/SIGNALS.md +50 -6
  365. package/skills/convergence-monitoring/SKILL.md +15 -6
  366. package/skills/eval/SKILL.md +39 -24
  367. package/skills/eval/rubric-v1.md +1 -0
  368. package/skills/eval/rubric-v2.md +457 -0
  369. package/skills/evolve/SKILL.md +3 -3
  370. package/skills/evolve/references/evolve-analyze-mode.md +10 -6
  371. package/skills/evolve/references/evolve-dialectic-mode.md +42 -25
  372. package/skills/gitlab-ops/SKILL.md +3 -2
  373. package/skills/memory-cleanup/SKILL.md +1 -1
  374. package/skills/navigator/SKILL.md +193 -0
  375. package/skills/navigator/references/account-check.sh +87 -0
  376. package/skills/navigator/references/fleet-decisions.mjs +231 -0
  377. package/skills/navigator/references/slot-check.sh +114 -0
  378. package/skills/npm-publish/SKILL.md +1 -1
  379. package/skills/persona-panel/SKILL.md +2 -2
  380. package/skills/persona-panel/persona-format.md +2 -2
  381. package/skills/persona-panel/presets/designer-lens.md +1 -1
  382. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  383. package/skills/persona-panel/presets/pm-lens.md +1 -1
  384. package/skills/plan/mode-retro.md +19 -4
  385. package/skills/quality-gates/SKILL.md +6 -4
  386. package/skills/reconcile/SKILL.md +11 -0
  387. package/skills/remote-offload/SKILL.md +4 -2
  388. package/skills/session-end/SKILL.md +13 -16
  389. package/skills/session-end/discovery-scan.md +1 -1
  390. package/skills/session-end/learning-patterns.md +22 -12
  391. package/skills/session-end/metrics-collection.md +5 -6
  392. package/skills/session-end/phase-3-6-tail.md +55 -9
  393. package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
  394. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  395. package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
  396. package/skills/session-end/references/session-summary-template.md +1 -1
  397. package/skills/session-end/session-metrics-write.md +32 -60
  398. package/skills/session-plan/SKILL.md +17 -8
  399. package/skills/session-plan/references/session-plan-task-classification.md +2 -2
  400. package/skills/session-start/SKILL.md +6 -0
  401. package/skills/session-start/references/operations-contract.md +4 -0
  402. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  403. package/skills/session-start/references/phase-4-ssot-environment-check.md +4 -3
  404. package/skills/test-audit/SKILL.md +152 -0
  405. package/skills/test-audit/references/campaign.md +111 -0
  406. package/skills/test-audit/references/mutate.sh +50 -0
  407. package/skills/ux-grill/SKILL.md +1 -1
  408. package/skills/wave-executor/SKILL.md +9 -5
  409. package/skills/wave-executor/circuit-breaker.md +2 -0
  410. package/skills/wave-executor/references/wave-executor-state-init.md +8 -3
  411. package/skills/wave-executor/references/wave-loop-dispatch.md +5 -2
  412. package/skills/wave-executor/references/wave-loop-review.md +3 -5
  413. package/templates/personas/README.md +5 -5
  414. package/templates/personas/accounting-compliance.v1.md +1 -1
  415. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  416. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  417. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  418. package/templates/personas/buyer-p3-build.v1.md +1 -1
  419. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  420. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  421. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  422. package/templates/personas/klima-ai-expert.v1.md +1 -1
  423. package/templates/personas/klima-physicist.v1.md +1 -1
  424. package/.cursor/commands/convergence-monitoring.md +0 -13
  425. package/.cursor/commands/npm-publish.md +0 -13
  426. package/pi/prompts/convergence-monitoring.md +0 -11
  427. package/pi/prompts/npm-publish.md +0 -11
@@ -0,0 +1,950 @@
1
+ /**
2
+ * process-group.mjs — process-GROUP spawn/kill, identity re-verification and the
3
+ * gate-process ledger (Epic #1425: A4, plus the primitives A1/A2 and B3 build on).
4
+ *
5
+ * Why a group and not a child: `spawn(cmd, { shell: true })` makes the SHELL the
6
+ * child. `child.kill()` signals that shell only, and its own children —
7
+ * `tsgo --noEmit`, vitest workers — are reparented to PID 1 and keep running.
8
+ * That is the 2026-09-20 incident: four orphaned `tsgo` processes, two at
9
+ * PPID 1, up to 8.0 GB RSS each, host at 13% free memory. `detached: true`
10
+ * makes the shell a process-group LEADER (`child.pid === pgid`, setsid), so
11
+ * `process.kill(-pgid, sig)` reaches every descendant that did not setsid away.
12
+ *
13
+ * Measured on this host (Darwin 25.6.0, 2026-09-21) and load-bearing here:
14
+ * - `process.kill(-pgid, 'SIGTERM')` does NOT terminate a grandchild that
15
+ * installs `trap "" TERM`; SIGKILL to the group does. Hence the ladder.
16
+ * - `close` fires only once EVERY group member has closed the shared pipe, so
17
+ * a surviving grandchild hangs the promise. Hence the hard deadline.
18
+ * - `maxBuffer` does not exist on async `spawn` (22 MB ran through unbounded).
19
+ * Hence the hand-rolled byte cap, which reproduces the spawnSync/ENOBUFS
20
+ * contract the existing gate tests pin ("21 MiB → exitCode 1").
21
+ * - A detached child SURVIVES its parent's exit with PPID 1 unless someone
22
+ * kills the group. Hence {@link installExitHandler}.
23
+ */
24
+
25
+ import { spawn } from 'node:child_process';
26
+ import { createHash } from 'node:crypto';
27
+ import {
28
+ appendFileSync,
29
+ existsSync,
30
+ lstatSync,
31
+ mkdirSync,
32
+ readFileSync,
33
+ renameSync,
34
+ unlinkSync,
35
+ writeFileSync,
36
+ } from 'node:fs';
37
+ import path from 'node:path';
38
+
39
+ /**
40
+ * Grace period between SIGTERM and SIGKILL, in ms — the repo's one definition,
41
+ * used by the gate's group ladder here and by `wave-executor/foreign-dispatch.mjs`
42
+ * and `remote-dispatch.mjs` through `runChild` (`dispatch-common.mjs`).
43
+ *
44
+ * `child.kill('SIGTERM')` is a REQUEST: a child that installs a handler and
45
+ * ignores it never emits `close`, and `runChild`'s only resolve paths are
46
+ * `close` and `error` — so without escalation a wedged `cursor-agent` hangs
47
+ * `dispatchForeign` forever and takes the whole wave with it. SIGKILL is not
48
+ * catchable, so the escalation always terminates.
49
+ *
50
+ * Named ceiling (BV-004): 10 s is enough for a cooperative child to flush its
51
+ * stream-json tail and exit. Revisit if a well-behaved `cursor-agent` is ever
52
+ * measured needing longer than that to shut down — raise the constant, never
53
+ * drop the escalation.
54
+ */
55
+ export const DEFAULT_KILL_GRACE_MS = 10_000;
56
+
57
+ /** Append-only ledger of every gate process THIS repo's orchestrator started.
58
+ * Gitignored via `.gitignore:125` (`.orchestrator/runtime/`), verified with
59
+ * `git check-ignore --no-index -v`. */
60
+ export const GATE_PROCESS_LEDGER_RELPATH = '.orchestrator/runtime/gate-processes.jsonl';
61
+
62
+ /**
63
+ * How long to wait after SIGKILL before reading liveness back.
64
+ *
65
+ * Named ceiling (BV-004): 500 ms. On 2026-09-20 the hand-run cleanup routine
66
+ * measured immediately after `kill -9` and reported "still alive" for processes
67
+ * that were already gone — a signal is asynchronous, the reap is not instant.
68
+ * Revisit if a survivor is ever observed clearing later than this; raising it
69
+ * costs only the timeout path.
70
+ */
71
+ export const DEFAULT_VERIFY_WAIT_MS = 500;
72
+
73
+ /** Byte cap on captured stdout+stderr. Mirrors the 16 MiB `maxBuffer` the
74
+ * synchronous gate used, because async `spawn` has no `maxBuffer` at all. */
75
+ export const DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
76
+
77
+ /** Ledger entries older than this are not candidates for anything. */
78
+ export const DEFAULT_LEDGER_MAX_AGE_MS = 24 * 3600 * 1000;
79
+
80
+ /** Wall-clock ceiling for one gate command. Same 15 min as the synchronous
81
+ * path's `GATE_TIMEOUT_MS`, per the PRD parameter table (`gate.timeout-path-b-ms`),
82
+ * so both gate paths are allowed exactly as long. */
83
+ export const DEFAULT_GATE_TIMEOUT_MS = 900_000;
84
+
85
+ /** Lines of the tail returned as `output` (the full capture is `fullOutput`). */
86
+ const OUTPUT_TAIL_LINES = 50;
87
+
88
+ /** Signals whose default disposition terminates the process; on these the exit
89
+ * handler must kill our groups before we go away. */
90
+ const EXIT_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
91
+
92
+ /**
93
+ * @typedef {object} GateProcessRecord
94
+ * @property {number} pid PID of the group leader (the shell).
95
+ * @property {number} pgid Process-group id — equals `pid` under `detached: true`.
96
+ * @property {number} startTime `Date.now()` at spawn. The identity anchor against PID recycling.
97
+ * @property {string} commandSignature Stable short form of the command — see {@link buildCommandSignature}.
98
+ * @property {string|null} sessionId Owning session, or null when unknown.
99
+ * @property {string} recordedAt ISO-8601 of `startTime`.
100
+ */
101
+
102
+ /**
103
+ * Groups this process started and has not yet reaped: pgid → its `killFn`.
104
+ * Keyed by pgid so a double-register is idempotent. The stored `killFn` is the
105
+ * one the caller passed, which is why the exit handler never reaches
106
+ * `process.kill` for a group a test spawned through an injected seam.
107
+ *
108
+ * @type {Map<number, {killFn: (target: number, signal: string) => unknown}>}
109
+ */
110
+ const LIVE_GROUPS = new Map();
111
+
112
+ let exitHandlerInstalled = false;
113
+
114
+ /**
115
+ * Default liveness probe: signal 0 tells us whether a PID exists without
116
+ * touching it. `ESRCH` = gone. `EPERM` = alive but not ours — reported as ALIVE
117
+ * on purpose, because "I may not signal it" is not "it is dead", and a survivor
118
+ * misreported as dead is exactly the false green this module exists to prevent.
119
+ *
120
+ * @param {number} pid
121
+ * @returns {boolean}
122
+ */
123
+ function defaultIsAlive(pid) {
124
+ try {
125
+ process.kill(pid, 0);
126
+ return true;
127
+ } catch (err) {
128
+ return err?.code === 'EPERM';
129
+ }
130
+ }
131
+
132
+ /** @param {number} ms @returns {Promise<void>} */
133
+ function defaultSleep(ms) {
134
+ return new Promise((resolve) => {
135
+ setTimeout(resolve, ms);
136
+ });
137
+ }
138
+
139
+ /** @param {number} target @param {string} signal @returns {unknown} */
140
+ function defaultKill(target, signal) {
141
+ return process.kill(target, signal);
142
+ }
143
+
144
+ /**
145
+ * Is `pgid` a value that may be NEGATED and handed to `kill(2)`?
146
+ *
147
+ * The only guard between a malformed ledger line and a POSIX broadcast. Three
148
+ * values are catastrophic rather than merely wrong, and all three are ordinary
149
+ * JSON numbers that reach here from a file on disk:
150
+ * - `1` → `kill(-1, sig)` signals EVERY process the user may signal.
151
+ * - `0` / `-0` → `kill(-0, sig)` signals the CALLER's own group (the session).
152
+ * - a negative pgid → the negation turns it POSITIVE, so the group-wide kill
153
+ * silently degrades into a single-PID kill of a stranger.
154
+ * `1` is excluded and not merely `<= 0` because pgid 1 is launchd's group on
155
+ * Darwin and init's on Linux — a real id, and never one of ours.
156
+ *
157
+ * @param {unknown} pgid
158
+ * @returns {boolean}
159
+ */
160
+ function isSignalablePgid(pgid) {
161
+ return Number.isInteger(pgid) && /** @type {number} */ (pgid) > 1;
162
+ }
163
+
164
+ /**
165
+ * Is a parsed ledger line a usable {@link GateProcessRecord}?
166
+ *
167
+ * Shared by {@link readGateProcessLedger} and {@link pruneGateProcessLedger} so
168
+ * the reader and the pruner cannot disagree about what "usable" means — a line
169
+ * the reader rejects but the pruner keeps would sit in the ledger forever.
170
+ *
171
+ * `typeof === 'number'` is not enough for a value that gets NEGATED and handed
172
+ * to `kill(2)`: `1`, `0`, `-0`, `NaN` and a negative pgid are all numbers, and
173
+ * the first two are a POSIX broadcast and a self-kill respectively
174
+ * ({@link isSignalablePgid}). `commandSignature` is required for the same class
175
+ * of reason: without it the reaper's identity check has nothing to compare a
176
+ * `ps` row against but the row itself.
177
+ *
178
+ * @param {unknown} parsed
179
+ * @returns {boolean}
180
+ */
181
+ function isValidLedgerRecord(parsed) {
182
+ if (!parsed || typeof parsed !== 'object') return false;
183
+ const r = /** @type {Record<string, unknown>} */ (parsed);
184
+ return isSignalablePgid(r.pid)
185
+ && isSignalablePgid(r.pgid)
186
+ && typeof r.commandSignature === 'string' && r.commandSignature.length > 0
187
+ && typeof r.startTime === 'number' && Number.isFinite(r.startTime);
188
+ }
189
+
190
+ /**
191
+ * Stable, short, non-reversible signature of a command line.
192
+ *
193
+ * Shape: `<first token>:<16 hex of sha256(full command)>`. The leading token
194
+ * stays readable so a `ps` row can be matched against it by name
195
+ * ({@link verifyProcessIdentity}); the hash discriminates two invocations of the
196
+ * same binary with different arguments without copying a command line — which
197
+ * may carry paths or tokens — into a ledger.
198
+ *
199
+ * @param {string} cmd Full command line.
200
+ * @returns {string} e.g. `npm:6f1c4e0a9b2d7e35`. Empty input yields `:<hash of "">`.
201
+ */
202
+ export function buildCommandSignature(cmd) {
203
+ const full = String(cmd ?? '');
204
+ const firstToken = full.trim().split(/\s+/)[0] ?? '';
205
+ const hash = createHash('sha256').update(full, 'utf8').digest('hex').slice(0, 16);
206
+ return `${firstToken}:${hash}`;
207
+ }
208
+
209
+ /**
210
+ * The command-name half of a signature (everything before the final `:`).
211
+ *
212
+ * @param {string} signature
213
+ * @returns {string}
214
+ */
215
+ function signatureToken(signature) {
216
+ const s = String(signature ?? '');
217
+ const i = s.lastIndexOf(':');
218
+ return i === -1 ? s : s.slice(0, i);
219
+ }
220
+
221
+ /**
222
+ * Install the process-wide exit handler that kills every still-registered group
223
+ * with SIGKILL. Idempotent — repeated calls install nothing further.
224
+ *
225
+ * `detached: true` buys a process group at the price of survival: measured
226
+ * 2026-09-21, a detached child outlives its parent's exit with PPID 1. This
227
+ * handler is what pays that price back.
228
+ *
229
+ * On `exit` the cleanup is synchronous (no async work is possible there).
230
+ * On a terminating SIGNAL the cleanup runs, and the signal's default effect is
231
+ * restored ONLY when this handler is the sole listener for it — named ceiling
232
+ * (BV-004): with another listener present we clean up and leave the exit
233
+ * decision to it, rather than exiting out from under it. Revisit if a host ever
234
+ * needs us to terminate despite a foreign listener.
235
+ *
236
+ * @returns {void}
237
+ */
238
+ export function installExitHandler() {
239
+ if (exitHandlerInstalled) return;
240
+ exitHandlerInstalled = true;
241
+
242
+ process.once('exit', () => {
243
+ killAllLiveGroupsSync();
244
+ });
245
+
246
+ for (const signal of EXIT_SIGNALS) {
247
+ const handler = () => {
248
+ killAllLiveGroupsSync();
249
+ if (process.listenerCount(signal) === 0) {
250
+ // We were the only listener: restore the default disposition so the
251
+ // process still dies from the signal it was sent.
252
+ process.kill(process.pid, signal);
253
+ }
254
+ };
255
+ process.once(signal, handler);
256
+ }
257
+ }
258
+
259
+ /**
260
+ * SIGKILL every registered group, synchronously, swallowing every error.
261
+ * Deliberately not exported: it is the exit path, not part of the interface.
262
+ *
263
+ * @returns {void}
264
+ */
265
+ function killAllLiveGroupsSync() {
266
+ for (const [pgid, entry] of LIVE_GROUPS) {
267
+ if (!isSignalablePgid(pgid)) continue;
268
+ try {
269
+ entry.killFn(-pgid, 'SIGKILL');
270
+ } catch {
271
+ /* already gone, or not ours — nothing to do at exit time */
272
+ }
273
+ }
274
+ LIVE_GROUPS.clear();
275
+ }
276
+
277
+ /**
278
+ * Send SIGTERM → grace → SIGKILL to a whole process GROUP, then VERIFY.
279
+ *
280
+ * The verification is the point: `ok` is true only when nothing answers a
281
+ * liveness probe after `verifyWaitMs`. An exit code or a sent signal proves
282
+ * nothing (PRD B6), and a process that survives SIGKILL — e.g. one that
283
+ * `setsid`-ed out of the group — is reported as a survivor, never booked as
284
+ * success.
285
+ *
286
+ * Error semantics:
287
+ * - `invalid-pgid` — the id is not a signalable process group
288
+ * ({@link isSignalablePgid}). Checked FIRST, before `beforeSignal` and before
289
+ * any signal: `ok: false`, `signalsSent: []`, nothing is sent. The ledger is
290
+ * a file on disk, and `pgid: 1` in one line means `kill(-1, …)` — a POSIX
291
+ * broadcast to every process this user may signal.
292
+ * - `ESRCH` on the FIRST signal — the group was already gone. `ok: true`,
293
+ * `error: 'ESRCH'`, no escalation.
294
+ * - `ESRCH` on the escalation — proof of death, not a failure: `error` stays
295
+ * null and the ladder completes normally.
296
+ * - `EPERM` — a foreign process sits in the group and refused our signal. Stop
297
+ * signal, never a retry: `ok: false`, `error: 'EPERM'`, `survivors: [pgid]`.
298
+ *
299
+ * ## `beforeSignal` — the ladder is ABORTABLE, and that is load-bearing
300
+ *
301
+ * The ladder sleeps `killGraceMs` (10 s by default) between the two signals, and
302
+ * a promise cannot be un-awaited. Without a gate consulted IMMEDIATELY BEFORE
303
+ * each signal, the escalation fires ~10 s after the caller has moved on — at a
304
+ * pgid the caller has already deregistered and the kernel may have recycled onto
305
+ * a stranger. Two callers need exactly that gate, for the same reason:
306
+ * - {@link spawnInGroup} passes `() => !settled`, so a child that closed during
307
+ * the grace window never draws a late group-wide SIGKILL.
308
+ * - the orphan-reaper passes a fresh identity re-check (PRD B3), so a PID
309
+ * recycled between SIGTERM and SIGKILL is never escalated against.
310
+ *
311
+ * It may be async; a throw counts as REFUSAL (fail-closed — an unmeasurable gate
312
+ * must never read as permission). An abort returns `aborted: '<signal>'`,
313
+ * `ok: false`, and `signalsSent` carries only the signals actually attempted.
314
+ *
315
+ * @param {number} pgid Process-group id (positive; the negation happens here).
316
+ * @param {object} [opts]
317
+ * @param {(target: number, signal: string) => unknown} [opts.killFn] Signal seam. Tests ALWAYS inject.
318
+ * @param {number} [opts.killGraceMs]
319
+ * @param {number} [opts.verifyWaitMs]
320
+ * @param {(pid: number) => boolean} [opts.isAliveFn] Liveness probe on the group leader.
321
+ * @param {(ms: number) => Promise<void>} [opts.sleepFn]
322
+ * @param {((signal: string) => boolean|Promise<boolean>)|null} [opts.beforeSignal] Consulted
323
+ * immediately before EVERY signal. Anything but `true` aborts the rest of the ladder.
324
+ * @returns {Promise<{ok: boolean, signalsSent: string[], survivors: number[],
325
+ * error: 'ESRCH'|'EPERM'|'invalid-pgid'|null, aborted: string|null}>}
326
+ * `signalsSent` records ATTEMPTS in order, including one that threw.
327
+ */
328
+ export async function killProcessGroup(pgid, {
329
+ killFn = defaultKill,
330
+ killGraceMs = DEFAULT_KILL_GRACE_MS,
331
+ verifyWaitMs = DEFAULT_VERIFY_WAIT_MS,
332
+ isAliveFn = defaultIsAlive,
333
+ sleepFn = defaultSleep,
334
+ beforeSignal = null,
335
+ } = {}) {
336
+ /** @type {string[]} */
337
+ const signalsSent = [];
338
+
339
+ // Fail-closed BEFORE anything else: an unsignalable id never reaches `killFn`,
340
+ // not even through an injected seam, and `beforeSignal` is not consulted —
341
+ // there is nothing to permit.
342
+ if (!isSignalablePgid(pgid)) {
343
+ return {
344
+ ok: false, signalsSent, survivors: [], error: 'invalid-pgid', aborted: null,
345
+ };
346
+ }
347
+
348
+ /** Fail-closed gate: only an explicit `true` permits the signal. */
349
+ const permitted = async (signal) => {
350
+ if (typeof beforeSignal !== 'function') return true;
351
+ try {
352
+ return (await beforeSignal(signal)) === true;
353
+ } catch {
354
+ return false;
355
+ }
356
+ };
357
+ const aborted = (signal) => ({
358
+ ok: false, signalsSent, survivors: [], error: null, aborted: signal,
359
+ });
360
+
361
+ if (!(await permitted('SIGTERM'))) return aborted('SIGTERM');
362
+
363
+ signalsSent.push('SIGTERM');
364
+ try {
365
+ killFn(-pgid, 'SIGTERM');
366
+ } catch (err) {
367
+ const code = err?.code ?? null;
368
+ if (code === 'ESRCH') {
369
+ return { ok: true, signalsSent, survivors: [], error: 'ESRCH', aborted: null };
370
+ }
371
+ return {
372
+ ok: false, signalsSent, survivors: [pgid], error: code === 'EPERM' ? 'EPERM' : null, aborted: null,
373
+ };
374
+ }
375
+
376
+ await sleepFn(killGraceMs);
377
+
378
+ // Escalate unconditionally: SIGTERM is a REQUEST and a grandchild with a
379
+ // TERM trap ignores it (measured). SIGKILL on an already-dead group throws
380
+ // ESRCH, which is the cheapest possible proof that the ladder worked.
381
+ // "Unconditionally" means "regardless of what SIGTERM appeared to achieve" —
382
+ // never "regardless of whether this pgid is still the process we targeted",
383
+ // which is what `beforeSignal` re-decides here.
384
+ if (!(await permitted('SIGKILL'))) return aborted('SIGKILL');
385
+
386
+ signalsSent.push('SIGKILL');
387
+ let escalationEsrch = false;
388
+ try {
389
+ killFn(-pgid, 'SIGKILL');
390
+ } catch (err) {
391
+ const code = err?.code ?? null;
392
+ if (code === 'ESRCH') {
393
+ escalationEsrch = true;
394
+ } else {
395
+ return {
396
+ ok: false, signalsSent, survivors: [pgid], error: code === 'EPERM' ? 'EPERM' : null, aborted: null,
397
+ };
398
+ }
399
+ }
400
+
401
+ if (escalationEsrch) {
402
+ return { ok: true, signalsSent, survivors: [], error: null, aborted: null };
403
+ }
404
+
405
+ await sleepFn(verifyWaitMs);
406
+ const alive = isAliveFn(pgid) === true;
407
+ return {
408
+ ok: !alive,
409
+ signalsSent,
410
+ survivors: alive ? [pgid] : [],
411
+ error: null,
412
+ aborted: null,
413
+ };
414
+ }
415
+
416
+ /**
417
+ * Re-verify, immediately before signalling, that PID `pid` is still the process
418
+ * the ledger recorded — the guard against PID recycling (PRD B3, Feature Area 3).
419
+ *
420
+ * PURE: `snapshotLine` is an ALREADY-PARSED `ps` row, so this function does no
421
+ * I/O and is fully testable from text fixtures. `null` means the process is gone.
422
+ *
423
+ * Both checks must hold:
424
+ * 1. Start time. macOS has no `etimes` and its `lstart` is locale-dependent, so
425
+ * the comparison runs over ELAPSED seconds: `nowMs - etimeSeconds*1000`
426
+ * against the recorded `startTime`, within `toleranceMs`.
427
+ * 2. Command signature. The leading token of `expected.commandSignature` must
428
+ * prefix the observed `args` (an exact full-signature match also passes).
429
+ *
430
+ * Fail-closed: a row with no measurable `etimeSeconds` yields
431
+ * `start-time-mismatch` with `observed.etimeSeconds === null`, so a caller can
432
+ * tell "measured, and it differs" from "could not be measured" — both refuse
433
+ * the signal, and only the second is an instrument gap.
434
+ *
435
+ * @param {number} pid
436
+ * @param {{startTime: number, commandSignature: string}} expected
437
+ * @param {object} [opts]
438
+ * @param {{pid?: number, ppid?: number, rssKb?: number, etimeSeconds?: number|null, cpuPct?: number, args?: string|null}|null} [opts.snapshotLine]
439
+ * @param {number} [opts.nowMs]
440
+ * @param {number} [opts.toleranceMs=2000]
441
+ * @returns {{match: boolean, reason: 'ok'|'gone'|'start-time-mismatch'|'signature-mismatch', observed: {etimeSeconds: number|null, args: string|null}}}
442
+ */
443
+ export function verifyProcessIdentity(pid, expected, {
444
+ snapshotLine = null,
445
+ nowMs = Date.now(),
446
+ toleranceMs = 2000,
447
+ } = {}) {
448
+ const gone = { match: false, reason: /** @type {const} */ ('gone'), observed: { etimeSeconds: null, args: null } };
449
+ if (!snapshotLine || typeof snapshotLine !== 'object') return gone;
450
+ // A row for a different PID describes a different process, not this one.
451
+ if (snapshotLine.pid !== undefined && snapshotLine.pid !== null
452
+ && Number(snapshotLine.pid) !== Number(pid)) return gone;
453
+
454
+ const etimeSeconds = typeof snapshotLine.etimeSeconds === 'number' && Number.isFinite(snapshotLine.etimeSeconds)
455
+ ? snapshotLine.etimeSeconds
456
+ : null;
457
+ const args = typeof snapshotLine.args === 'string' ? snapshotLine.args : null;
458
+ const observed = { etimeSeconds, args };
459
+
460
+ if (etimeSeconds === null) {
461
+ return { match: false, reason: 'start-time-mismatch', observed };
462
+ }
463
+ const observedStart = nowMs - etimeSeconds * 1000;
464
+ if (Math.abs(observedStart - Number(expected?.startTime)) > toleranceMs) {
465
+ return { match: false, reason: 'start-time-mismatch', observed };
466
+ }
467
+
468
+ const wanted = String(expected?.commandSignature ?? '');
469
+ const token = signatureToken(wanted);
470
+ const argsFirstToken = (args ?? '').trim().split(/\s+/)[0] ?? '';
471
+ const signatureOk = args !== null
472
+ && token.length > 0
473
+ && (argsFirstToken === token || buildCommandSignature(args) === wanted);
474
+ if (!signatureOk) {
475
+ return { match: false, reason: 'signature-mismatch', observed };
476
+ }
477
+
478
+ return { match: true, reason: 'ok', observed };
479
+ }
480
+
481
+ /**
482
+ * Absolute path of the gate-process ledger for a repo.
483
+ * The relpath lives in exactly one constant so no second site spells
484
+ * `.orchestrator` by hand.
485
+ *
486
+ * @param {string} repoRoot
487
+ * @returns {string}
488
+ */
489
+ function ledgerPathFor(repoRoot) {
490
+ return path.join(repoRoot, ...GATE_PROCESS_LEDGER_RELPATH.split('/'));
491
+ }
492
+
493
+ /**
494
+ * Append one {@link GateProcessRecord} to the gate-process ledger.
495
+ *
496
+ * Append-only JSONL: concurrent gate runs from parallel sessions each add their
497
+ * own line and never rewrite a foreign one. Best-effort — a ledger write must
498
+ * never fail a gate — but a failure prints one WARN line to stderr rather than
499
+ * vanishing (a ledger that silently stops being written is indistinguishable
500
+ * from a host with no gate processes).
501
+ *
502
+ * @param {string} repoRoot
503
+ * @param {GateProcessRecord} record
504
+ * @param {object} [opts]
505
+ * @param {(filePath: string, line: string) => void} [opts.appendFn] Sink seam. When
506
+ * supplied, the parent directory is NOT created — the caller owns its sink, and
507
+ * mkdir on a synthetic repoRoot would materialise a directory in a test.
508
+ * @returns {void}
509
+ */
510
+ export function recordGateProcess(repoRoot, record, { appendFn } = {}) {
511
+ const target = ledgerPathFor(repoRoot);
512
+ try {
513
+ if (!appendFn) mkdirSync(path.dirname(target), { recursive: true });
514
+ const line = `${JSON.stringify(record)}\n`;
515
+ if (appendFn) appendFn(target, line);
516
+ else appendFileSync(target, line, 'utf8');
517
+ } catch (err) {
518
+ process.stderr.write(
519
+ `process-group: could not record gate process in ${GATE_PROCESS_LEDGER_RELPATH}: ${err?.message ?? String(err)}\n`,
520
+ );
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Read the gate-process ledger, tolerantly.
526
+ *
527
+ * Malformed lines are skipped AND COUNTED: a silently skipping JSONL parser
528
+ * turns a partial read into a clean verdict, which is the exact failure mode a
529
+ * reaper must not have. "Malformed" includes a line whose `pid`/`pgid` is not a
530
+ * SIGNALABLE process-group id and one carrying no `commandSignature` — see the
531
+ * inline note at the check. Entries older than `maxAgeMs` are filtered out and
532
+ * counted separately — they are not candidates for anything, and their PIDs are
533
+ * the most likely to have been recycled.
534
+ *
535
+ * @param {string} repoRoot
536
+ * @param {object} [opts]
537
+ * @param {(filePath: string) => string} [opts.readFn] Reader seam; must throw or
538
+ * return '' for a missing file.
539
+ * @param {number} [opts.nowMs]
540
+ * @param {number} [opts.maxAgeMs]
541
+ * @returns {{records: GateProcessRecord[], malformedLines: number, expired: number}}
542
+ * `records` are the fresh, parseable entries in source order.
543
+ */
544
+ export function readGateProcessLedger(repoRoot, {
545
+ readFn,
546
+ nowMs = Date.now(),
547
+ maxAgeMs = DEFAULT_LEDGER_MAX_AGE_MS,
548
+ } = {}) {
549
+ const target = ledgerPathFor(repoRoot);
550
+ let raw = '';
551
+ try {
552
+ if (readFn) raw = readFn(target) ?? '';
553
+ else if (existsSync(target)) raw = readFileSync(target, 'utf8');
554
+ } catch {
555
+ return { records: [], malformedLines: 0, expired: 0 };
556
+ }
557
+
558
+ /** @type {GateProcessRecord[]} */
559
+ const records = [];
560
+ let malformedLines = 0;
561
+ let expired = 0;
562
+
563
+ for (const line of String(raw).split('\n')) {
564
+ if (line.trim().length === 0) continue;
565
+ let parsed;
566
+ try {
567
+ parsed = JSON.parse(line);
568
+ } catch {
569
+ malformedLines += 1;
570
+ continue;
571
+ }
572
+ if (!isValidLedgerRecord(parsed)) {
573
+ malformedLines += 1;
574
+ continue;
575
+ }
576
+ if (nowMs - parsed.startTime > maxAgeMs) {
577
+ expired += 1;
578
+ continue;
579
+ }
580
+ records.push(parsed);
581
+ }
582
+
583
+ return { records, malformedLines, expired };
584
+ }
585
+
586
+ /**
587
+ * Replace a regular file's contents atomically: write `<target>.tmp-<pid>` in
588
+ * the same directory, then rename it over the target. Throws instead of
589
+ * writing when the target is a symlink or not a regular file — the caller turns
590
+ * that into its one WARN line.
591
+ *
592
+ * Why not `writeFileSync(target)`: that truncates first, so a kill between the
593
+ * truncate and the write leaves the file empty, and it FOLLOWS a symlink — a
594
+ * ledger linked to `events.jsonl` emptied the link's target (CWE-59, reproduced
595
+ * 2026-10-01). `rename` replaces the directory entry itself, never a link's
596
+ * target, and the `wx` flag refuses a pre-planted file or link at the tmp name.
597
+ * Kept local, not imported, because this module deliberately has no local
598
+ * imports; `orphan-reaper/reaper-audit.mjs` carries the same few lines.
599
+ *
600
+ * @param {string} target
601
+ * @param {string} body
602
+ * @param {{lstatSync: Function, writeFileSync: Function, renameSync: Function, unlinkSync: Function}} io
603
+ */
604
+ function replaceRegularFile(target, body, io) {
605
+ if (!io.lstatSync(target).isFile()) {
606
+ throw new Error('not a regular file (a symlink is never written through) — left untouched');
607
+ }
608
+ const tmp = `${target}.tmp-${process.pid}`;
609
+ try {
610
+ io.writeFileSync(tmp, body, { encoding: 'utf8', flag: 'wx' });
611
+ io.renameSync(tmp, target);
612
+ } catch (err) {
613
+ // EEXIST: the tmp name belongs to someone else — never remove it.
614
+ if (err?.code !== 'EEXIST') {
615
+ try { io.unlinkSync(tmp); } catch { /* nothing was created */ }
616
+ }
617
+ throw err;
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Drop expired and unusable lines from the ledger, rewriting it atomically.
623
+ * "Unusable" is {@link isValidLedgerRecord}'s verdict — the same one
624
+ * {@link readGateProcessLedger} applies, so nothing the reader skips survives
625
+ * the pruner.
626
+ *
627
+ * Called once per gate command from {@link spawnInGroup}'s register path and
628
+ * once per scan from the orphan reaper: an append-only ledger with no pruner
629
+ * grows monotonically, and it had none until 2026-09-22.
630
+ *
631
+ * In-process fs only — never a shell `rm`/`mv` (PSA-003, and
632
+ * `.orchestrator/metrics/**` deletions are a blocked-command rule for a reason).
633
+ * The rewrite goes through {@link replaceRegularFile}: a symlinked or
634
+ * non-regular ledger is refused with one WARN and left untouched.
635
+ *
636
+ * Named ceiling (BV-004): read-filter-write is not atomic against a concurrent
637
+ * append, so a line appended between the read and the write is lost. Acceptable
638
+ * while the ledger is one line per gate command (tens per session) and its only
639
+ * consumer is a best-effort reaper. Revisit — with an exclusive lock or an
640
+ * append-only compaction sidecar — if the ledger ever gains a consumer that
641
+ * must not miss an entry.
642
+ *
643
+ * @param {string} repoRoot
644
+ * @param {object} [opts]
645
+ * @param {number} [opts.nowMs]
646
+ * @param {number} [opts.maxAgeMs]
647
+ * @param {{existsSync: Function, readFileSync: Function, writeFileSync: Function,
648
+ * lstatSync: Function, renameSync: Function, unlinkSync: Function}} [opts.fs] fs seam;
649
+ * replaces `node:fs` wholesale, so a partial seam fails closed (WARN, 0).
650
+ * @returns {number} Number of lines removed (expired + malformed). 0 when the
651
+ * ledger is absent, empty, unreadable, or not a regular file.
652
+ */
653
+ export function pruneGateProcessLedger(repoRoot, {
654
+ nowMs = Date.now(),
655
+ maxAgeMs = DEFAULT_LEDGER_MAX_AGE_MS,
656
+ fs: fsSeam,
657
+ } = {}) {
658
+ const io = fsSeam ?? { existsSync, readFileSync, writeFileSync, lstatSync, renameSync, unlinkSync };
659
+ const target = ledgerPathFor(repoRoot);
660
+ let raw;
661
+ try {
662
+ if (!io.existsSync(target)) return 0;
663
+ raw = io.readFileSync(target, 'utf8');
664
+ } catch {
665
+ return 0;
666
+ }
667
+
668
+ const kept = [];
669
+ let removed = 0;
670
+ for (const line of String(raw).split('\n')) {
671
+ if (line.trim().length === 0) continue;
672
+ let parsed;
673
+ try {
674
+ parsed = JSON.parse(line);
675
+ } catch {
676
+ removed += 1;
677
+ continue;
678
+ }
679
+ if (!isValidLedgerRecord(parsed) || nowMs - parsed.startTime > maxAgeMs) {
680
+ removed += 1;
681
+ continue;
682
+ }
683
+ kept.push(line);
684
+ }
685
+
686
+ if (removed === 0) return 0;
687
+ try {
688
+ replaceRegularFile(target, kept.length > 0 ? `${kept.join('\n')}\n` : '', io);
689
+ } catch (err) {
690
+ process.stderr.write(
691
+ `process-group: could not prune ${GATE_PROCESS_LEDGER_RELPATH}: ${err?.message ?? String(err)}\n`,
692
+ );
693
+ return 0;
694
+ }
695
+ return removed;
696
+ }
697
+
698
+ /**
699
+ * Spawn a command as the leader of its OWN process group and guarantee that the
700
+ * whole group is gone when this promise settles.
701
+ *
702
+ * Timeout path: the group gets SIGTERM → `killGraceMs` → SIGKILL → verify
703
+ * (`timedOut: true`, `exitCode: 124`).
704
+ * Overflow path: the same ladder, triggered by the byte cap instead of the
705
+ * clock (`overflow: true`, `exitCode: 1` — the contract the synchronous gate's
706
+ * ENOBUFS behaviour already has).
707
+ *
708
+ * The promise resolves on `close` — which fires only when EVERY group member
709
+ * has closed the shared pipe — or, if a member survives SIGKILL, on a hard
710
+ * deadline of `killGraceMs + verifyWaitMs + 1000` measured from the start of the
711
+ * kill ladder. Without that deadline one `setsid`-escaped grandchild hangs the
712
+ * gate forever, which is the failure the whole module exists to prevent.
713
+ *
714
+ * The converse holds too: once this promise HAS settled, the ladder is
715
+ * cancelled (`beforeSignal` in {@link killProcessGroup}). A cooperative child
716
+ * that closes on SIGTERM would otherwise still draw a SIGKILL `killGraceMs`
717
+ * later, at a pgid `finish()` already deregistered — the exact late signal at a
718
+ * possibly-recycled group id this module exists to prevent.
719
+ *
720
+ * @param {string} cmd Full shell command. Executable configuration, not data —
721
+ * see `.claude/rules/security.md` § Session Config Command Trust.
722
+ * @param {object} [opts]
723
+ * @param {string} [opts.cwd]
724
+ * @param {NodeJS.ProcessEnv} [opts.env]
725
+ * @param {number|null} [opts.timeoutMs] null disables the clock (the byte cap still applies).
726
+ * @param {number} [opts.killGraceMs]
727
+ * @param {number} [opts.verifyWaitMs]
728
+ * @param {number} [opts.maxOutputBytes]
729
+ * @param {Function} [opts.spawnFn] `(cmd, options) => ChildProcess`. Tests inject a fake.
730
+ * @param {(target: number, signal: string) => unknown} [opts.killFn] Tests ALWAYS inject.
731
+ * @param {() => number} [opts.now]
732
+ * @param {(pid: number) => boolean} [opts.isAliveFn]
733
+ * @param {(ms: number) => Promise<void>} [opts.sleepFn]
734
+ * @param {(record: GateProcessRecord) => void} [opts.onRegister] Defaults to
735
+ * {@link recordGateProcess} when — and only when — `repoRoot` is given.
736
+ * @param {string} [opts.repoRoot]
737
+ * @param {string} [opts.commandSignature] Defaults to {@link buildCommandSignature}.
738
+ * @param {string|null} [opts.sessionId]
739
+ * @returns {Promise<{exitCode: number, output: string, fullOutput: string, timedOut: boolean,
740
+ * overflow: boolean, killSignals: string[], survivors: number[], pid: number, pgid: number,
741
+ * durationMs: number}>}
742
+ * `exitCode` is 124 on timeout, 1 on overflow or spawn failure, else the child's own code.
743
+ * `output` is the last ~50 lines of `fullOutput`. `survivors` are PIDs still alive after
744
+ * SIGKILL + `verifyWaitMs` — a non-empty list is a REPORTED failure, never a silent one.
745
+ */
746
+ export function spawnInGroup(cmd, {
747
+ cwd,
748
+ env,
749
+ timeoutMs = DEFAULT_GATE_TIMEOUT_MS,
750
+ killGraceMs = DEFAULT_KILL_GRACE_MS,
751
+ verifyWaitMs = DEFAULT_VERIFY_WAIT_MS,
752
+ maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES,
753
+ spawnFn = spawn,
754
+ killFn = defaultKill,
755
+ now = Date.now,
756
+ isAliveFn = defaultIsAlive,
757
+ sleepFn = defaultSleep,
758
+ onRegister,
759
+ repoRoot,
760
+ commandSignature,
761
+ sessionId = null,
762
+ } = {}) {
763
+ installExitHandler();
764
+
765
+ return new Promise((resolve) => {
766
+ const startedAt = now();
767
+ /** @type {number|null} */
768
+ let pid = null;
769
+ /** @type {number|null} */
770
+ let pgid = null;
771
+ /** @type {string[]} */
772
+ const killSignals = [];
773
+ let fullOutput = '';
774
+ let capturedBytes = 0;
775
+ let timedOut = false;
776
+ let overflow = false;
777
+ let settled = false;
778
+ let ladderStarted = false;
779
+ /** @type {number[]} */
780
+ let survivors = [];
781
+ /** @type {ReturnType<typeof setTimeout>|null} */
782
+ let timeoutTimer = null;
783
+ /** @type {ReturnType<typeof setTimeout>|null} */
784
+ let deadlineTimer = null;
785
+
786
+ const finish = (childExitCode) => {
787
+ if (settled) return;
788
+ settled = true;
789
+ if (timeoutTimer) clearTimeout(timeoutTimer);
790
+ if (deadlineTimer) clearTimeout(deadlineTimer);
791
+ if (pgid !== null) LIVE_GROUPS.delete(pgid);
792
+ let exitCode;
793
+ if (timedOut) exitCode = 124;
794
+ else if (overflow) exitCode = 1;
795
+ else if (typeof childExitCode === 'number') exitCode = childExitCode;
796
+ else exitCode = 1;
797
+ resolve({
798
+ exitCode,
799
+ output: fullOutput.split('\n').slice(-OUTPUT_TAIL_LINES).join('\n').trim(),
800
+ fullOutput,
801
+ timedOut,
802
+ overflow,
803
+ killSignals,
804
+ survivors,
805
+ pid: pid ?? -1,
806
+ pgid: pgid ?? -1,
807
+ durationMs: now() - startedAt,
808
+ });
809
+ };
810
+
811
+ let child;
812
+ try {
813
+ // The command is trusted configuration; `shell: true` is intentional.
814
+ // `detached: true` is what makes this a GROUP leader — the whole point.
815
+ // nosemgrep: unsafe-shell-spawn
816
+ child = spawnFn(cmd, {
817
+ cwd,
818
+ env,
819
+ shell: true,
820
+ detached: true,
821
+ stdio: ['ignore', 'pipe', 'pipe'],
822
+ });
823
+ } catch (err) {
824
+ fullOutput = `process-group: failed to spawn "${cmd}": ${err?.message ?? String(err)}`;
825
+ finish(1);
826
+ return;
827
+ }
828
+
829
+ pid = typeof child?.pid === 'number' ? child.pid : null;
830
+ // Under `detached: true` the child calls setsid, so it IS its own group
831
+ // leader and pgid === pid (measured 2026-09-21, Darwin 25.6.0).
832
+ pgid = pid;
833
+
834
+ if (pgid !== null) {
835
+ LIVE_GROUPS.set(pgid, { killFn });
836
+ /** @type {GateProcessRecord} */
837
+ const record = {
838
+ pid,
839
+ pgid,
840
+ startTime: startedAt,
841
+ commandSignature: commandSignature ?? buildCommandSignature(cmd),
842
+ sessionId: sessionId ?? null,
843
+ recordedAt: new Date(startedAt).toISOString(),
844
+ };
845
+ const register = onRegister ?? (repoRoot ? (r) => recordGateProcess(repoRoot, r) : null);
846
+ if (register) {
847
+ try {
848
+ register(record);
849
+ } catch {
850
+ /* the ledger is an aid to the reaper, never a gate precondition */
851
+ }
852
+ }
853
+ // Prune AFTER the append, once per gate command — the ledger is
854
+ // append-only and had NO production pruner, so it grew monotonically
855
+ // (measured 2026-09-22: 369 lines/day on this host). Only on the real
856
+ // sink: with an injected `onRegister` the caller owns its storage and a
857
+ // prune here would write a file the test never asked for. Cheap by
858
+ // construction (the file holds one line per gate command) and wrapped,
859
+ // because a pruner that throws must not fail the gate it is housekeeping
860
+ // for.
861
+ if (!onRegister && repoRoot) {
862
+ try {
863
+ pruneGateProcessLedger(repoRoot);
864
+ } catch {
865
+ /* housekeeping, never a gate precondition */
866
+ }
867
+ }
868
+ }
869
+
870
+ // Signals are recorded AT SEND TIME, not merged from the ladder's return
871
+ // value: a cooperative child closes before `killProcessGroup` resolves, and
872
+ // a `killSignals` filled after `resolve()` reports an empty ladder for the
873
+ // very case the ladder worked.
874
+ const trackingKill = (target, signal) => {
875
+ killSignals.push(signal);
876
+ return killFn(target, signal);
877
+ };
878
+
879
+ const runLadder = () => {
880
+ if (ladderStarted || pgid === null) return;
881
+ ladderStarted = true;
882
+ // Hard deadline: `close` waits for EVERY group member to close the shared
883
+ // pipe, so one survivor would hang this promise forever.
884
+ deadlineTimer = setTimeout(() => {
885
+ if (survivors.length === 0 && pgid !== null) survivors = [pgid];
886
+ finish(null);
887
+ }, killGraceMs + verifyWaitMs + 1000);
888
+ deadlineTimer.unref?.();
889
+
890
+ killProcessGroup(pgid, {
891
+ killFn: trackingKill,
892
+ killGraceMs,
893
+ verifyWaitMs,
894
+ isAliveFn,
895
+ sleepFn,
896
+ // Cancel the ladder the moment this promise settles. `finish()` has by
897
+ // then deleted the pgid from LIVE_GROUPS, so a SIGKILL arriving
898
+ // `killGraceMs` later would be aimed at an id nobody here owns any more
899
+ // — and the kernel may have recycled it onto a foreign group.
900
+ beforeSignal: () => !settled,
901
+ })
902
+ .then((res) => {
903
+ survivors = res.survivors;
904
+ if (!res.ok) finish(null);
905
+ })
906
+ .catch(() => {
907
+ finish(null);
908
+ });
909
+ };
910
+
911
+ const onChunk = (chunk) => {
912
+ if (overflow) return;
913
+ const text = chunk.toString();
914
+ capturedBytes += Buffer.byteLength(text, 'utf8');
915
+ fullOutput += text;
916
+ if (capturedBytes > maxOutputBytes) {
917
+ overflow = true;
918
+ fullOutput += `\nprocess-group: output exceeded ${maxOutputBytes} bytes — killing process group.\n`;
919
+ runLadder();
920
+ }
921
+ };
922
+ child.stdout?.on('data', onChunk);
923
+ child.stderr?.on('data', onChunk);
924
+
925
+ if (typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0) {
926
+ timeoutTimer = setTimeout(() => {
927
+ timedOut = true;
928
+ runLadder();
929
+ }, timeoutMs);
930
+ timeoutTimer.unref?.();
931
+ }
932
+
933
+ child.on('error', (err) => {
934
+ fullOutput += `\nprocess-group: child error: ${err?.message ?? String(err)}`;
935
+ finish(1);
936
+ });
937
+ child.on('close', (code) => finish(typeof code === 'number' ? code : null));
938
+ });
939
+ }
940
+
941
+ /**
942
+ * Test-only: the pgids this process currently has registered for exit-time
943
+ * cleanup. Exported so a test can prove a group is deregistered after `close`
944
+ * instead of asserting on module internals.
945
+ *
946
+ * @returns {number[]}
947
+ */
948
+ export function _liveGroupPgids() {
949
+ return [...LIVE_GROUPS.keys()];
950
+ }