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,899 @@
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
+ mkdirSync,
31
+ readFileSync,
32
+ writeFileSync,
33
+ } from 'node:fs';
34
+ import path from 'node:path';
35
+
36
+ import { DEFAULT_KILL_GRACE_MS } from './wave-executor/dispatch-common.mjs';
37
+
38
+ /**
39
+ * Re-exported, never re-defined: the SIGTERM→SIGKILL grace is a repo
40
+ * convention with its own named ceiling in `dispatch-common.mjs`. A second
41
+ * literal here would drift from it silently.
42
+ */
43
+ export { DEFAULT_KILL_GRACE_MS };
44
+
45
+ /** Append-only ledger of every gate process THIS repo's orchestrator started.
46
+ * Gitignored via `.gitignore:125` (`.orchestrator/runtime/`), verified with
47
+ * `git check-ignore --no-index -v`. */
48
+ export const GATE_PROCESS_LEDGER_RELPATH = '.orchestrator/runtime/gate-processes.jsonl';
49
+
50
+ /**
51
+ * How long to wait after SIGKILL before reading liveness back.
52
+ *
53
+ * Named ceiling (BV-004): 500 ms. On 2026-09-20 the hand-run cleanup routine
54
+ * measured immediately after `kill -9` and reported "still alive" for processes
55
+ * that were already gone — a signal is asynchronous, the reap is not instant.
56
+ * Revisit if a survivor is ever observed clearing later than this; raising it
57
+ * costs only the timeout path.
58
+ */
59
+ export const DEFAULT_VERIFY_WAIT_MS = 500;
60
+
61
+ /** Byte cap on captured stdout+stderr. Mirrors the 16 MiB `maxBuffer` the
62
+ * synchronous gate used, because async `spawn` has no `maxBuffer` at all. */
63
+ export const DEFAULT_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
64
+
65
+ /** Ledger entries older than this are not candidates for anything. */
66
+ export const DEFAULT_LEDGER_MAX_AGE_MS = 24 * 3600 * 1000;
67
+
68
+ /** Wall-clock ceiling for one gate command. Same 15 min as the synchronous
69
+ * path's `GATE_TIMEOUT_MS`, per the PRD parameter table (`gate.timeout-path-b-ms`),
70
+ * so both gate paths are allowed exactly as long. */
71
+ export const DEFAULT_GATE_TIMEOUT_MS = 900_000;
72
+
73
+ /** Lines of the tail returned as `output` (the full capture is `fullOutput`). */
74
+ const OUTPUT_TAIL_LINES = 50;
75
+
76
+ /** Signals whose default disposition terminates the process; on these the exit
77
+ * handler must kill our groups before we go away. */
78
+ const EXIT_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
79
+
80
+ /**
81
+ * @typedef {object} GateProcessRecord
82
+ * @property {number} pid PID of the group leader (the shell).
83
+ * @property {number} pgid Process-group id — equals `pid` under `detached: true`.
84
+ * @property {number} startTime `Date.now()` at spawn. The identity anchor against PID recycling.
85
+ * @property {string} commandSignature Stable short form of the command — see {@link buildCommandSignature}.
86
+ * @property {string|null} sessionId Owning session, or null when unknown.
87
+ * @property {string} recordedAt ISO-8601 of `startTime`.
88
+ */
89
+
90
+ /**
91
+ * Groups this process started and has not yet reaped: pgid → its `killFn`.
92
+ * Keyed by pgid so a double-register is idempotent. The stored `killFn` is the
93
+ * one the caller passed, which is why the exit handler never reaches
94
+ * `process.kill` for a group a test spawned through an injected seam.
95
+ *
96
+ * @type {Map<number, {killFn: (target: number, signal: string) => unknown}>}
97
+ */
98
+ const LIVE_GROUPS = new Map();
99
+
100
+ let exitHandlerInstalled = false;
101
+
102
+ /**
103
+ * Default liveness probe: signal 0 tells us whether a PID exists without
104
+ * touching it. `ESRCH` = gone. `EPERM` = alive but not ours — reported as ALIVE
105
+ * on purpose, because "I may not signal it" is not "it is dead", and a survivor
106
+ * misreported as dead is exactly the false green this module exists to prevent.
107
+ *
108
+ * @param {number} pid
109
+ * @returns {boolean}
110
+ */
111
+ function defaultIsAlive(pid) {
112
+ try {
113
+ process.kill(pid, 0);
114
+ return true;
115
+ } catch (err) {
116
+ return err?.code === 'EPERM';
117
+ }
118
+ }
119
+
120
+ /** @param {number} ms @returns {Promise<void>} */
121
+ function defaultSleep(ms) {
122
+ return new Promise((resolve) => {
123
+ setTimeout(resolve, ms);
124
+ });
125
+ }
126
+
127
+ /** @param {number} target @param {string} signal @returns {unknown} */
128
+ function defaultKill(target, signal) {
129
+ return process.kill(target, signal);
130
+ }
131
+
132
+ /**
133
+ * Is `pgid` a value that may be NEGATED and handed to `kill(2)`?
134
+ *
135
+ * The only guard between a malformed ledger line and a POSIX broadcast. Three
136
+ * values are catastrophic rather than merely wrong, and all three are ordinary
137
+ * JSON numbers that reach here from a file on disk:
138
+ * - `1` → `kill(-1, sig)` signals EVERY process the user may signal.
139
+ * - `0` / `-0` → `kill(-0, sig)` signals the CALLER's own group (the session).
140
+ * - a negative pgid → the negation turns it POSITIVE, so the group-wide kill
141
+ * silently degrades into a single-PID kill of a stranger.
142
+ * `1` is excluded and not merely `<= 0` because pgid 1 is launchd's group on
143
+ * Darwin and init's on Linux — a real id, and never one of ours.
144
+ *
145
+ * @param {unknown} pgid
146
+ * @returns {boolean}
147
+ */
148
+ function isSignalablePgid(pgid) {
149
+ return Number.isInteger(pgid) && /** @type {number} */ (pgid) > 1;
150
+ }
151
+
152
+ /**
153
+ * Is a parsed ledger line a usable {@link GateProcessRecord}?
154
+ *
155
+ * Shared by {@link readGateProcessLedger} and {@link pruneGateProcessLedger} so
156
+ * the reader and the pruner cannot disagree about what "usable" means — a line
157
+ * the reader rejects but the pruner keeps would sit in the ledger forever.
158
+ *
159
+ * `typeof === 'number'` is not enough for a value that gets NEGATED and handed
160
+ * to `kill(2)`: `1`, `0`, `-0`, `NaN` and a negative pgid are all numbers, and
161
+ * the first two are a POSIX broadcast and a self-kill respectively
162
+ * ({@link isSignalablePgid}). `commandSignature` is required for the same class
163
+ * of reason: without it the reaper's identity check has nothing to compare a
164
+ * `ps` row against but the row itself.
165
+ *
166
+ * @param {unknown} parsed
167
+ * @returns {boolean}
168
+ */
169
+ function isValidLedgerRecord(parsed) {
170
+ if (!parsed || typeof parsed !== 'object') return false;
171
+ const r = /** @type {Record<string, unknown>} */ (parsed);
172
+ return isSignalablePgid(r.pid)
173
+ && isSignalablePgid(r.pgid)
174
+ && typeof r.commandSignature === 'string' && r.commandSignature.length > 0
175
+ && typeof r.startTime === 'number' && Number.isFinite(r.startTime);
176
+ }
177
+
178
+ /**
179
+ * Stable, short, non-reversible signature of a command line.
180
+ *
181
+ * Shape: `<first token>:<16 hex of sha256(full command)>`. The leading token
182
+ * stays readable so a `ps` row can be matched against it by name
183
+ * ({@link verifyProcessIdentity}); the hash discriminates two invocations of the
184
+ * same binary with different arguments without copying a command line — which
185
+ * may carry paths or tokens — into a ledger.
186
+ *
187
+ * @param {string} cmd Full command line.
188
+ * @returns {string} e.g. `npm:6f1c4e0a9b2d7e35`. Empty input yields `:<hash of "">`.
189
+ */
190
+ export function buildCommandSignature(cmd) {
191
+ const full = String(cmd ?? '');
192
+ const firstToken = full.trim().split(/\s+/)[0] ?? '';
193
+ const hash = createHash('sha256').update(full, 'utf8').digest('hex').slice(0, 16);
194
+ return `${firstToken}:${hash}`;
195
+ }
196
+
197
+ /**
198
+ * The command-name half of a signature (everything before the final `:`).
199
+ *
200
+ * @param {string} signature
201
+ * @returns {string}
202
+ */
203
+ function signatureToken(signature) {
204
+ const s = String(signature ?? '');
205
+ const i = s.lastIndexOf(':');
206
+ return i === -1 ? s : s.slice(0, i);
207
+ }
208
+
209
+ /**
210
+ * Install the process-wide exit handler that kills every still-registered group
211
+ * with SIGKILL. Idempotent — repeated calls install nothing further.
212
+ *
213
+ * `detached: true` buys a process group at the price of survival: measured
214
+ * 2026-09-21, a detached child outlives its parent's exit with PPID 1. This
215
+ * handler is what pays that price back.
216
+ *
217
+ * On `exit` the cleanup is synchronous (no async work is possible there).
218
+ * On a terminating SIGNAL the cleanup runs, and the signal's default effect is
219
+ * restored ONLY when this handler is the sole listener for it — named ceiling
220
+ * (BV-004): with another listener present we clean up and leave the exit
221
+ * decision to it, rather than exiting out from under it. Revisit if a host ever
222
+ * needs us to terminate despite a foreign listener.
223
+ *
224
+ * @returns {void}
225
+ */
226
+ export function installExitHandler() {
227
+ if (exitHandlerInstalled) return;
228
+ exitHandlerInstalled = true;
229
+
230
+ process.once('exit', () => {
231
+ killAllLiveGroupsSync();
232
+ });
233
+
234
+ for (const signal of EXIT_SIGNALS) {
235
+ const handler = () => {
236
+ killAllLiveGroupsSync();
237
+ if (process.listenerCount(signal) === 0) {
238
+ // We were the only listener: restore the default disposition so the
239
+ // process still dies from the signal it was sent.
240
+ process.kill(process.pid, signal);
241
+ }
242
+ };
243
+ process.once(signal, handler);
244
+ }
245
+ }
246
+
247
+ /**
248
+ * SIGKILL every registered group, synchronously, swallowing every error.
249
+ * Deliberately not exported: it is the exit path, not part of the interface.
250
+ *
251
+ * @returns {void}
252
+ */
253
+ function killAllLiveGroupsSync() {
254
+ for (const [pgid, entry] of LIVE_GROUPS) {
255
+ if (!isSignalablePgid(pgid)) continue;
256
+ try {
257
+ entry.killFn(-pgid, 'SIGKILL');
258
+ } catch {
259
+ /* already gone, or not ours — nothing to do at exit time */
260
+ }
261
+ }
262
+ LIVE_GROUPS.clear();
263
+ }
264
+
265
+ /**
266
+ * Send SIGTERM → grace → SIGKILL to a whole process GROUP, then VERIFY.
267
+ *
268
+ * The verification is the point: `ok` is true only when nothing answers a
269
+ * liveness probe after `verifyWaitMs`. An exit code or a sent signal proves
270
+ * nothing (PRD B6), and a process that survives SIGKILL — e.g. one that
271
+ * `setsid`-ed out of the group — is reported as a survivor, never booked as
272
+ * success.
273
+ *
274
+ * Error semantics:
275
+ * - `invalid-pgid` — the id is not a signalable process group
276
+ * ({@link isSignalablePgid}). Checked FIRST, before `beforeSignal` and before
277
+ * any signal: `ok: false`, `signalsSent: []`, nothing is sent. The ledger is
278
+ * a file on disk, and `pgid: 1` in one line means `kill(-1, …)` — a POSIX
279
+ * broadcast to every process this user may signal.
280
+ * - `ESRCH` on the FIRST signal — the group was already gone. `ok: true`,
281
+ * `error: 'ESRCH'`, no escalation.
282
+ * - `ESRCH` on the escalation — proof of death, not a failure: `error` stays
283
+ * null and the ladder completes normally.
284
+ * - `EPERM` — a foreign process sits in the group and refused our signal. Stop
285
+ * signal, never a retry: `ok: false`, `error: 'EPERM'`, `survivors: [pgid]`.
286
+ *
287
+ * ## `beforeSignal` — the ladder is ABORTABLE, and that is load-bearing
288
+ *
289
+ * The ladder sleeps `killGraceMs` (10 s by default) between the two signals, and
290
+ * a promise cannot be un-awaited. Without a gate consulted IMMEDIATELY BEFORE
291
+ * each signal, the escalation fires ~10 s after the caller has moved on — at a
292
+ * pgid the caller has already deregistered and the kernel may have recycled onto
293
+ * a stranger. Two callers need exactly that gate, for the same reason:
294
+ * - {@link spawnInGroup} passes `() => !settled`, so a child that closed during
295
+ * the grace window never draws a late group-wide SIGKILL.
296
+ * - the orphan-reaper passes a fresh identity re-check (PRD B3), so a PID
297
+ * recycled between SIGTERM and SIGKILL is never escalated against.
298
+ *
299
+ * It may be async; a throw counts as REFUSAL (fail-closed — an unmeasurable gate
300
+ * must never read as permission). An abort returns `aborted: '<signal>'`,
301
+ * `ok: false`, and `signalsSent` carries only the signals actually attempted.
302
+ *
303
+ * @param {number} pgid Process-group id (positive; the negation happens here).
304
+ * @param {object} [opts]
305
+ * @param {(target: number, signal: string) => unknown} [opts.killFn] Signal seam. Tests ALWAYS inject.
306
+ * @param {number} [opts.killGraceMs]
307
+ * @param {number} [opts.verifyWaitMs]
308
+ * @param {(pid: number) => boolean} [opts.isAliveFn] Liveness probe on the group leader.
309
+ * @param {(ms: number) => Promise<void>} [opts.sleepFn]
310
+ * @param {((signal: string) => boolean|Promise<boolean>)|null} [opts.beforeSignal] Consulted
311
+ * immediately before EVERY signal. Anything but `true` aborts the rest of the ladder.
312
+ * @returns {Promise<{ok: boolean, signalsSent: string[], survivors: number[],
313
+ * error: 'ESRCH'|'EPERM'|'invalid-pgid'|null, aborted: string|null}>}
314
+ * `signalsSent` records ATTEMPTS in order, including one that threw.
315
+ */
316
+ export async function killProcessGroup(pgid, {
317
+ killFn = defaultKill,
318
+ killGraceMs = DEFAULT_KILL_GRACE_MS,
319
+ verifyWaitMs = DEFAULT_VERIFY_WAIT_MS,
320
+ isAliveFn = defaultIsAlive,
321
+ sleepFn = defaultSleep,
322
+ beforeSignal = null,
323
+ } = {}) {
324
+ /** @type {string[]} */
325
+ const signalsSent = [];
326
+
327
+ // Fail-closed BEFORE anything else: an unsignalable id never reaches `killFn`,
328
+ // not even through an injected seam, and `beforeSignal` is not consulted —
329
+ // there is nothing to permit.
330
+ if (!isSignalablePgid(pgid)) {
331
+ return {
332
+ ok: false, signalsSent, survivors: [], error: 'invalid-pgid', aborted: null,
333
+ };
334
+ }
335
+
336
+ /** Fail-closed gate: only an explicit `true` permits the signal. */
337
+ const permitted = async (signal) => {
338
+ if (typeof beforeSignal !== 'function') return true;
339
+ try {
340
+ return (await beforeSignal(signal)) === true;
341
+ } catch {
342
+ return false;
343
+ }
344
+ };
345
+ const aborted = (signal) => ({
346
+ ok: false, signalsSent, survivors: [], error: null, aborted: signal,
347
+ });
348
+
349
+ if (!(await permitted('SIGTERM'))) return aborted('SIGTERM');
350
+
351
+ signalsSent.push('SIGTERM');
352
+ try {
353
+ killFn(-pgid, 'SIGTERM');
354
+ } catch (err) {
355
+ const code = err?.code ?? null;
356
+ if (code === 'ESRCH') {
357
+ return { ok: true, signalsSent, survivors: [], error: 'ESRCH', aborted: null };
358
+ }
359
+ return {
360
+ ok: false, signalsSent, survivors: [pgid], error: code === 'EPERM' ? 'EPERM' : null, aborted: null,
361
+ };
362
+ }
363
+
364
+ await sleepFn(killGraceMs);
365
+
366
+ // Escalate unconditionally: SIGTERM is a REQUEST and a grandchild with a
367
+ // TERM trap ignores it (measured). SIGKILL on an already-dead group throws
368
+ // ESRCH, which is the cheapest possible proof that the ladder worked.
369
+ // "Unconditionally" means "regardless of what SIGTERM appeared to achieve" —
370
+ // never "regardless of whether this pgid is still the process we targeted",
371
+ // which is what `beforeSignal` re-decides here.
372
+ if (!(await permitted('SIGKILL'))) return aborted('SIGKILL');
373
+
374
+ signalsSent.push('SIGKILL');
375
+ let escalationEsrch = false;
376
+ try {
377
+ killFn(-pgid, 'SIGKILL');
378
+ } catch (err) {
379
+ const code = err?.code ?? null;
380
+ if (code === 'ESRCH') {
381
+ escalationEsrch = true;
382
+ } else {
383
+ return {
384
+ ok: false, signalsSent, survivors: [pgid], error: code === 'EPERM' ? 'EPERM' : null, aborted: null,
385
+ };
386
+ }
387
+ }
388
+
389
+ if (escalationEsrch) {
390
+ return { ok: true, signalsSent, survivors: [], error: null, aborted: null };
391
+ }
392
+
393
+ await sleepFn(verifyWaitMs);
394
+ const alive = isAliveFn(pgid) === true;
395
+ return {
396
+ ok: !alive,
397
+ signalsSent,
398
+ survivors: alive ? [pgid] : [],
399
+ error: null,
400
+ aborted: null,
401
+ };
402
+ }
403
+
404
+ /**
405
+ * Re-verify, immediately before signalling, that PID `pid` is still the process
406
+ * the ledger recorded — the guard against PID recycling (PRD B3, Feature Area 3).
407
+ *
408
+ * PURE: `snapshotLine` is an ALREADY-PARSED `ps` row, so this function does no
409
+ * I/O and is fully testable from text fixtures. `null` means the process is gone.
410
+ *
411
+ * Both checks must hold:
412
+ * 1. Start time. macOS has no `etimes` and its `lstart` is locale-dependent, so
413
+ * the comparison runs over ELAPSED seconds: `nowMs - etimeSeconds*1000`
414
+ * against the recorded `startTime`, within `toleranceMs`.
415
+ * 2. Command signature. The leading token of `expected.commandSignature` must
416
+ * prefix the observed `args` (an exact full-signature match also passes).
417
+ *
418
+ * Fail-closed: a row with no measurable `etimeSeconds` yields
419
+ * `start-time-mismatch` with `observed.etimeSeconds === null`, so a caller can
420
+ * tell "measured, and it differs" from "could not be measured" — both refuse
421
+ * the signal, and only the second is an instrument gap.
422
+ *
423
+ * @param {number} pid
424
+ * @param {{startTime: number, commandSignature: string}} expected
425
+ * @param {object} [opts]
426
+ * @param {{pid?: number, ppid?: number, rssKb?: number, etimeSeconds?: number|null, cpuPct?: number, args?: string|null}|null} [opts.snapshotLine]
427
+ * @param {number} [opts.nowMs]
428
+ * @param {number} [opts.toleranceMs=2000]
429
+ * @returns {{match: boolean, reason: 'ok'|'gone'|'start-time-mismatch'|'signature-mismatch', observed: {etimeSeconds: number|null, args: string|null}}}
430
+ */
431
+ export function verifyProcessIdentity(pid, expected, {
432
+ snapshotLine = null,
433
+ nowMs = Date.now(),
434
+ toleranceMs = 2000,
435
+ } = {}) {
436
+ const gone = { match: false, reason: /** @type {const} */ ('gone'), observed: { etimeSeconds: null, args: null } };
437
+ if (!snapshotLine || typeof snapshotLine !== 'object') return gone;
438
+ // A row for a different PID describes a different process, not this one.
439
+ if (snapshotLine.pid !== undefined && snapshotLine.pid !== null
440
+ && Number(snapshotLine.pid) !== Number(pid)) return gone;
441
+
442
+ const etimeSeconds = typeof snapshotLine.etimeSeconds === 'number' && Number.isFinite(snapshotLine.etimeSeconds)
443
+ ? snapshotLine.etimeSeconds
444
+ : null;
445
+ const args = typeof snapshotLine.args === 'string' ? snapshotLine.args : null;
446
+ const observed = { etimeSeconds, args };
447
+
448
+ if (etimeSeconds === null) {
449
+ return { match: false, reason: 'start-time-mismatch', observed };
450
+ }
451
+ const observedStart = nowMs - etimeSeconds * 1000;
452
+ if (Math.abs(observedStart - Number(expected?.startTime)) > toleranceMs) {
453
+ return { match: false, reason: 'start-time-mismatch', observed };
454
+ }
455
+
456
+ const wanted = String(expected?.commandSignature ?? '');
457
+ const token = signatureToken(wanted);
458
+ const argsFirstToken = (args ?? '').trim().split(/\s+/)[0] ?? '';
459
+ const signatureOk = args !== null
460
+ && token.length > 0
461
+ && (argsFirstToken === token || buildCommandSignature(args) === wanted);
462
+ if (!signatureOk) {
463
+ return { match: false, reason: 'signature-mismatch', observed };
464
+ }
465
+
466
+ return { match: true, reason: 'ok', observed };
467
+ }
468
+
469
+ /**
470
+ * Absolute path of the gate-process ledger for a repo.
471
+ * The relpath lives in exactly one constant so no second site spells
472
+ * `.orchestrator` by hand.
473
+ *
474
+ * @param {string} repoRoot
475
+ * @returns {string}
476
+ */
477
+ function ledgerPathFor(repoRoot) {
478
+ return path.join(repoRoot, ...GATE_PROCESS_LEDGER_RELPATH.split('/'));
479
+ }
480
+
481
+ /**
482
+ * Append one {@link GateProcessRecord} to the gate-process ledger.
483
+ *
484
+ * Append-only JSONL: concurrent gate runs from parallel sessions each add their
485
+ * own line and never rewrite a foreign one. Best-effort — a ledger write must
486
+ * never fail a gate — but a failure prints one WARN line to stderr rather than
487
+ * vanishing (a ledger that silently stops being written is indistinguishable
488
+ * from a host with no gate processes).
489
+ *
490
+ * @param {string} repoRoot
491
+ * @param {GateProcessRecord} record
492
+ * @param {object} [opts]
493
+ * @param {(filePath: string, line: string) => void} [opts.appendFn] Sink seam. When
494
+ * supplied, the parent directory is NOT created — the caller owns its sink, and
495
+ * mkdir on a synthetic repoRoot would materialise a directory in a test.
496
+ * @returns {void}
497
+ */
498
+ export function recordGateProcess(repoRoot, record, { appendFn } = {}) {
499
+ const target = ledgerPathFor(repoRoot);
500
+ try {
501
+ if (!appendFn) mkdirSync(path.dirname(target), { recursive: true });
502
+ const line = `${JSON.stringify(record)}\n`;
503
+ if (appendFn) appendFn(target, line);
504
+ else appendFileSync(target, line, 'utf8');
505
+ } catch (err) {
506
+ process.stderr.write(
507
+ `process-group: could not record gate process in ${GATE_PROCESS_LEDGER_RELPATH}: ${err?.message ?? String(err)}\n`,
508
+ );
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Read the gate-process ledger, tolerantly.
514
+ *
515
+ * Malformed lines are skipped AND COUNTED: a silently skipping JSONL parser
516
+ * turns a partial read into a clean verdict, which is the exact failure mode a
517
+ * reaper must not have. "Malformed" includes a line whose `pid`/`pgid` is not a
518
+ * SIGNALABLE process-group id and one carrying no `commandSignature` — see the
519
+ * inline note at the check. Entries older than `maxAgeMs` are filtered out and
520
+ * counted separately — they are not candidates for anything, and their PIDs are
521
+ * the most likely to have been recycled.
522
+ *
523
+ * @param {string} repoRoot
524
+ * @param {object} [opts]
525
+ * @param {(filePath: string) => string} [opts.readFn] Reader seam; must throw or
526
+ * return '' for a missing file.
527
+ * @param {number} [opts.nowMs]
528
+ * @param {number} [opts.maxAgeMs]
529
+ * @returns {{records: GateProcessRecord[], malformedLines: number, expired: number}}
530
+ * `records` are the fresh, parseable entries in source order.
531
+ */
532
+ export function readGateProcessLedger(repoRoot, {
533
+ readFn,
534
+ nowMs = Date.now(),
535
+ maxAgeMs = DEFAULT_LEDGER_MAX_AGE_MS,
536
+ } = {}) {
537
+ const target = ledgerPathFor(repoRoot);
538
+ let raw = '';
539
+ try {
540
+ if (readFn) raw = readFn(target) ?? '';
541
+ else if (existsSync(target)) raw = readFileSync(target, 'utf8');
542
+ } catch {
543
+ return { records: [], malformedLines: 0, expired: 0 };
544
+ }
545
+
546
+ /** @type {GateProcessRecord[]} */
547
+ const records = [];
548
+ let malformedLines = 0;
549
+ let expired = 0;
550
+
551
+ for (const line of String(raw).split('\n')) {
552
+ if (line.trim().length === 0) continue;
553
+ let parsed;
554
+ try {
555
+ parsed = JSON.parse(line);
556
+ } catch {
557
+ malformedLines += 1;
558
+ continue;
559
+ }
560
+ if (!isValidLedgerRecord(parsed)) {
561
+ malformedLines += 1;
562
+ continue;
563
+ }
564
+ if (nowMs - parsed.startTime > maxAgeMs) {
565
+ expired += 1;
566
+ continue;
567
+ }
568
+ records.push(parsed);
569
+ }
570
+
571
+ return { records, malformedLines, expired };
572
+ }
573
+
574
+ /**
575
+ * Drop expired and unusable lines from the ledger, rewriting it in place.
576
+ * "Unusable" is {@link isValidLedgerRecord}'s verdict — the same one
577
+ * {@link readGateProcessLedger} applies, so nothing the reader skips survives
578
+ * the pruner.
579
+ *
580
+ * Called once per gate command from {@link spawnInGroup}'s register path and
581
+ * once per scan from the orphan reaper: an append-only ledger with no pruner
582
+ * grows monotonically, and it had none until 2026-09-22.
583
+ *
584
+ * In-process fs only — never a shell `rm`/`mv` (PSA-003, and
585
+ * `.orchestrator/metrics/**` deletions are a blocked-command rule for a reason).
586
+ *
587
+ * Named ceiling (BV-004): read-filter-write is not atomic against a concurrent
588
+ * append, so a line appended between the read and the write is lost. Acceptable
589
+ * while the ledger is one line per gate command (tens per session) and its only
590
+ * consumer is a best-effort reaper. Revisit — with an exclusive lock or an
591
+ * append-only compaction sidecar — if the ledger ever gains a consumer that
592
+ * must not miss an entry.
593
+ *
594
+ * @param {string} repoRoot
595
+ * @param {object} [opts]
596
+ * @param {number} [opts.nowMs]
597
+ * @param {number} [opts.maxAgeMs]
598
+ * @param {{existsSync: Function, readFileSync: Function, writeFileSync: Function}} [opts.fs] fs seam.
599
+ * @returns {number} Number of lines removed (expired + malformed). 0 when the
600
+ * ledger is absent, empty, or unreadable.
601
+ */
602
+ export function pruneGateProcessLedger(repoRoot, {
603
+ nowMs = Date.now(),
604
+ maxAgeMs = DEFAULT_LEDGER_MAX_AGE_MS,
605
+ fs: fsSeam,
606
+ } = {}) {
607
+ const io = fsSeam ?? { existsSync, readFileSync, writeFileSync };
608
+ const target = ledgerPathFor(repoRoot);
609
+ let raw;
610
+ try {
611
+ if (!io.existsSync(target)) return 0;
612
+ raw = io.readFileSync(target, 'utf8');
613
+ } catch {
614
+ return 0;
615
+ }
616
+
617
+ const kept = [];
618
+ let removed = 0;
619
+ for (const line of String(raw).split('\n')) {
620
+ if (line.trim().length === 0) continue;
621
+ let parsed;
622
+ try {
623
+ parsed = JSON.parse(line);
624
+ } catch {
625
+ removed += 1;
626
+ continue;
627
+ }
628
+ if (!isValidLedgerRecord(parsed) || nowMs - parsed.startTime > maxAgeMs) {
629
+ removed += 1;
630
+ continue;
631
+ }
632
+ kept.push(line);
633
+ }
634
+
635
+ if (removed === 0) return 0;
636
+ try {
637
+ io.writeFileSync(target, kept.length > 0 ? `${kept.join('\n')}\n` : '', 'utf8');
638
+ } catch (err) {
639
+ process.stderr.write(
640
+ `process-group: could not prune ${GATE_PROCESS_LEDGER_RELPATH}: ${err?.message ?? String(err)}\n`,
641
+ );
642
+ return 0;
643
+ }
644
+ return removed;
645
+ }
646
+
647
+ /**
648
+ * Spawn a command as the leader of its OWN process group and guarantee that the
649
+ * whole group is gone when this promise settles.
650
+ *
651
+ * Timeout path: the group gets SIGTERM → `killGraceMs` → SIGKILL → verify
652
+ * (`timedOut: true`, `exitCode: 124`).
653
+ * Overflow path: the same ladder, triggered by the byte cap instead of the
654
+ * clock (`overflow: true`, `exitCode: 1` — the contract the synchronous gate's
655
+ * ENOBUFS behaviour already has).
656
+ *
657
+ * The promise resolves on `close` — which fires only when EVERY group member
658
+ * has closed the shared pipe — or, if a member survives SIGKILL, on a hard
659
+ * deadline of `killGraceMs + verifyWaitMs + 1000` measured from the start of the
660
+ * kill ladder. Without that deadline one `setsid`-escaped grandchild hangs the
661
+ * gate forever, which is the failure the whole module exists to prevent.
662
+ *
663
+ * The converse holds too: once this promise HAS settled, the ladder is
664
+ * cancelled (`beforeSignal` in {@link killProcessGroup}). A cooperative child
665
+ * that closes on SIGTERM would otherwise still draw a SIGKILL `killGraceMs`
666
+ * later, at a pgid `finish()` already deregistered — the exact late signal at a
667
+ * possibly-recycled group id this module exists to prevent.
668
+ *
669
+ * @param {string} cmd Full shell command. Executable configuration, not data —
670
+ * see `.claude/rules/security.md` § Session Config Command Trust.
671
+ * @param {object} [opts]
672
+ * @param {string} [opts.cwd]
673
+ * @param {NodeJS.ProcessEnv} [opts.env]
674
+ * @param {number|null} [opts.timeoutMs] null disables the clock (the byte cap still applies).
675
+ * @param {number} [opts.killGraceMs]
676
+ * @param {number} [opts.verifyWaitMs]
677
+ * @param {number} [opts.maxOutputBytes]
678
+ * @param {Function} [opts.spawnFn] `(cmd, options) => ChildProcess`. Tests inject a fake.
679
+ * @param {(target: number, signal: string) => unknown} [opts.killFn] Tests ALWAYS inject.
680
+ * @param {() => number} [opts.now]
681
+ * @param {(pid: number) => boolean} [opts.isAliveFn]
682
+ * @param {(ms: number) => Promise<void>} [opts.sleepFn]
683
+ * @param {(record: GateProcessRecord) => void} [opts.onRegister] Defaults to
684
+ * {@link recordGateProcess} when — and only when — `repoRoot` is given.
685
+ * @param {string} [opts.repoRoot]
686
+ * @param {string} [opts.commandSignature] Defaults to {@link buildCommandSignature}.
687
+ * @param {string|null} [opts.sessionId]
688
+ * @returns {Promise<{exitCode: number, output: string, fullOutput: string, timedOut: boolean,
689
+ * overflow: boolean, killSignals: string[], survivors: number[], pid: number, pgid: number,
690
+ * durationMs: number}>}
691
+ * `exitCode` is 124 on timeout, 1 on overflow or spawn failure, else the child's own code.
692
+ * `output` is the last ~50 lines of `fullOutput`. `survivors` are PIDs still alive after
693
+ * SIGKILL + `verifyWaitMs` — a non-empty list is a REPORTED failure, never a silent one.
694
+ */
695
+ export function spawnInGroup(cmd, {
696
+ cwd,
697
+ env,
698
+ timeoutMs = DEFAULT_GATE_TIMEOUT_MS,
699
+ killGraceMs = DEFAULT_KILL_GRACE_MS,
700
+ verifyWaitMs = DEFAULT_VERIFY_WAIT_MS,
701
+ maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES,
702
+ spawnFn = spawn,
703
+ killFn = defaultKill,
704
+ now = Date.now,
705
+ isAliveFn = defaultIsAlive,
706
+ sleepFn = defaultSleep,
707
+ onRegister,
708
+ repoRoot,
709
+ commandSignature,
710
+ sessionId = null,
711
+ } = {}) {
712
+ installExitHandler();
713
+
714
+ return new Promise((resolve) => {
715
+ const startedAt = now();
716
+ /** @type {number|null} */
717
+ let pid = null;
718
+ /** @type {number|null} */
719
+ let pgid = null;
720
+ /** @type {string[]} */
721
+ const killSignals = [];
722
+ let fullOutput = '';
723
+ let capturedBytes = 0;
724
+ let timedOut = false;
725
+ let overflow = false;
726
+ let settled = false;
727
+ let ladderStarted = false;
728
+ /** @type {number[]} */
729
+ let survivors = [];
730
+ /** @type {ReturnType<typeof setTimeout>|null} */
731
+ let timeoutTimer = null;
732
+ /** @type {ReturnType<typeof setTimeout>|null} */
733
+ let deadlineTimer = null;
734
+
735
+ const finish = (childExitCode) => {
736
+ if (settled) return;
737
+ settled = true;
738
+ if (timeoutTimer) clearTimeout(timeoutTimer);
739
+ if (deadlineTimer) clearTimeout(deadlineTimer);
740
+ if (pgid !== null) LIVE_GROUPS.delete(pgid);
741
+ let exitCode;
742
+ if (timedOut) exitCode = 124;
743
+ else if (overflow) exitCode = 1;
744
+ else if (typeof childExitCode === 'number') exitCode = childExitCode;
745
+ else exitCode = 1;
746
+ resolve({
747
+ exitCode,
748
+ output: fullOutput.split('\n').slice(-OUTPUT_TAIL_LINES).join('\n').trim(),
749
+ fullOutput,
750
+ timedOut,
751
+ overflow,
752
+ killSignals,
753
+ survivors,
754
+ pid: pid ?? -1,
755
+ pgid: pgid ?? -1,
756
+ durationMs: now() - startedAt,
757
+ });
758
+ };
759
+
760
+ let child;
761
+ try {
762
+ // The command is trusted configuration; `shell: true` is intentional.
763
+ // `detached: true` is what makes this a GROUP leader — the whole point.
764
+ // nosemgrep: unsafe-shell-spawn
765
+ child = spawnFn(cmd, {
766
+ cwd,
767
+ env,
768
+ shell: true,
769
+ detached: true,
770
+ stdio: ['ignore', 'pipe', 'pipe'],
771
+ });
772
+ } catch (err) {
773
+ fullOutput = `process-group: failed to spawn "${cmd}": ${err?.message ?? String(err)}`;
774
+ finish(1);
775
+ return;
776
+ }
777
+
778
+ pid = typeof child?.pid === 'number' ? child.pid : null;
779
+ // Under `detached: true` the child calls setsid, so it IS its own group
780
+ // leader and pgid === pid (measured 2026-09-21, Darwin 25.6.0).
781
+ pgid = pid;
782
+
783
+ if (pgid !== null) {
784
+ LIVE_GROUPS.set(pgid, { killFn });
785
+ /** @type {GateProcessRecord} */
786
+ const record = {
787
+ pid,
788
+ pgid,
789
+ startTime: startedAt,
790
+ commandSignature: commandSignature ?? buildCommandSignature(cmd),
791
+ sessionId: sessionId ?? null,
792
+ recordedAt: new Date(startedAt).toISOString(),
793
+ };
794
+ const register = onRegister ?? (repoRoot ? (r) => recordGateProcess(repoRoot, r) : null);
795
+ if (register) {
796
+ try {
797
+ register(record);
798
+ } catch {
799
+ /* the ledger is an aid to the reaper, never a gate precondition */
800
+ }
801
+ }
802
+ // Prune AFTER the append, once per gate command — the ledger is
803
+ // append-only and had NO production pruner, so it grew monotonically
804
+ // (measured 2026-09-22: 369 lines/day on this host). Only on the real
805
+ // sink: with an injected `onRegister` the caller owns its storage and a
806
+ // prune here would write a file the test never asked for. Cheap by
807
+ // construction (the file holds one line per gate command) and wrapped,
808
+ // because a pruner that throws must not fail the gate it is housekeeping
809
+ // for.
810
+ if (!onRegister && repoRoot) {
811
+ try {
812
+ pruneGateProcessLedger(repoRoot);
813
+ } catch {
814
+ /* housekeeping, never a gate precondition */
815
+ }
816
+ }
817
+ }
818
+
819
+ // Signals are recorded AT SEND TIME, not merged from the ladder's return
820
+ // value: a cooperative child closes before `killProcessGroup` resolves, and
821
+ // a `killSignals` filled after `resolve()` reports an empty ladder for the
822
+ // very case the ladder worked.
823
+ const trackingKill = (target, signal) => {
824
+ killSignals.push(signal);
825
+ return killFn(target, signal);
826
+ };
827
+
828
+ const runLadder = () => {
829
+ if (ladderStarted || pgid === null) return;
830
+ ladderStarted = true;
831
+ // Hard deadline: `close` waits for EVERY group member to close the shared
832
+ // pipe, so one survivor would hang this promise forever.
833
+ deadlineTimer = setTimeout(() => {
834
+ if (survivors.length === 0 && pgid !== null) survivors = [pgid];
835
+ finish(null);
836
+ }, killGraceMs + verifyWaitMs + 1000);
837
+ deadlineTimer.unref?.();
838
+
839
+ killProcessGroup(pgid, {
840
+ killFn: trackingKill,
841
+ killGraceMs,
842
+ verifyWaitMs,
843
+ isAliveFn,
844
+ sleepFn,
845
+ // Cancel the ladder the moment this promise settles. `finish()` has by
846
+ // then deleted the pgid from LIVE_GROUPS, so a SIGKILL arriving
847
+ // `killGraceMs` later would be aimed at an id nobody here owns any more
848
+ // — and the kernel may have recycled it onto a foreign group.
849
+ beforeSignal: () => !settled,
850
+ })
851
+ .then((res) => {
852
+ survivors = res.survivors;
853
+ if (!res.ok) finish(null);
854
+ })
855
+ .catch(() => {
856
+ finish(null);
857
+ });
858
+ };
859
+
860
+ const onChunk = (chunk) => {
861
+ if (overflow) return;
862
+ const text = chunk.toString();
863
+ capturedBytes += Buffer.byteLength(text, 'utf8');
864
+ fullOutput += text;
865
+ if (capturedBytes > maxOutputBytes) {
866
+ overflow = true;
867
+ fullOutput += `\nprocess-group: output exceeded ${maxOutputBytes} bytes — killing process group.\n`;
868
+ runLadder();
869
+ }
870
+ };
871
+ child.stdout?.on('data', onChunk);
872
+ child.stderr?.on('data', onChunk);
873
+
874
+ if (typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0) {
875
+ timeoutTimer = setTimeout(() => {
876
+ timedOut = true;
877
+ runLadder();
878
+ }, timeoutMs);
879
+ timeoutTimer.unref?.();
880
+ }
881
+
882
+ child.on('error', (err) => {
883
+ fullOutput += `\nprocess-group: child error: ${err?.message ?? String(err)}`;
884
+ finish(1);
885
+ });
886
+ child.on('close', (code) => finish(typeof code === 'number' ? code : null));
887
+ });
888
+ }
889
+
890
+ /**
891
+ * Test-only: the pgids this process currently has registered for exit-time
892
+ * cleanup. Exported so a test can prove a group is deregistered after `close`
893
+ * instead of asserting on module internals.
894
+ *
895
+ * @returns {number[]}
896
+ */
897
+ export function _liveGroupPgids() {
898
+ return [...LIVE_GROUPS.keys()];
899
+ }