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.
- package/.agents/skills/architecture/SKILL.md +3 -1
- package/.agents/skills/autopilot/SKILL.md +6 -1
- package/.agents/skills/autopilot/agents/openai.yaml +5 -0
- package/.agents/skills/bootstrap/SKILL.md +7 -1
- package/.agents/skills/bootstrap/agents/openai.yaml +5 -0
- package/.agents/skills/brainstorm/SKILL.md +8 -1
- package/.agents/skills/brainstorm/agents/openai.yaml +5 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +3 -1
- package/.agents/skills/close/SKILL.md +21 -0
- package/.agents/skills/close/agents/openai.yaml +5 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +4 -2
- package/.agents/skills/debug/SKILL.md +7 -1
- package/.agents/skills/debug/agents/openai.yaml +5 -0
- package/.agents/skills/discovery/SKILL.md +7 -2
- package/.agents/skills/discovery/agents/openai.yaml +5 -0
- package/.agents/skills/dispatcher/SKILL.md +7 -1
- package/.agents/skills/dispatcher/agents/openai.yaml +5 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ecosystem-health/SKILL.md +3 -1
- package/.agents/skills/eli5/SKILL.md +7 -1
- package/.agents/skills/eli5/agents/openai.yaml +5 -0
- package/.agents/skills/eval/SKILL.md +7 -2
- package/.agents/skills/eval/agents/openai.yaml +5 -0
- package/.agents/skills/evolve/SKILL.md +8 -3
- package/.agents/skills/evolve/agents/openai.yaml +5 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +3 -1
- package/.agents/skills/gitlab-ops/SKILL.md +3 -1
- package/.agents/skills/gitlab-portfolio/SKILL.md +3 -1
- package/.agents/skills/go/SKILL.md +22 -0
- package/.agents/skills/go/agents/openai.yaml +5 -0
- package/.agents/skills/grill/SKILL.md +7 -1
- package/.agents/skills/grill/agents/openai.yaml +5 -0
- package/.agents/skills/harness-audit/SKILL.md +20 -0
- package/.agents/skills/harness-audit/agents/openai.yaml +5 -0
- package/.agents/skills/hook-development/SKILL.md +3 -1
- package/.agents/skills/mcp-builder/SKILL.md +3 -1
- package/.agents/skills/memory-cleanup/SKILL.md +6 -1
- package/.agents/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.agents/skills/mode-selector/SKILL.md +3 -1
- package/.agents/skills/npm-publish/SKILL.md +4 -2
- package/.agents/skills/peekaboo-driver/SKILL.md +3 -1
- package/.agents/skills/persona-panel/SKILL.md +6 -1
- package/.agents/skills/persona-panel/agents/openai.yaml +5 -0
- package/.agents/skills/plan/SKILL.md +8 -2
- package/.agents/skills/plan/agents/openai.yaml +5 -0
- package/.agents/skills/playwright-driver/SKILL.md +3 -1
- package/.agents/skills/portfolio/SKILL.md +21 -0
- package/.agents/skills/portfolio/agents/openai.yaml +5 -0
- package/.agents/skills/quality-gates/SKILL.md +3 -1
- package/.agents/skills/reconcile/SKILL.md +6 -1
- package/.agents/skills/reconcile/agents/openai.yaml +5 -0
- package/.agents/skills/release/SKILL.md +22 -0
- package/.agents/skills/release/agents/openai.yaml +5 -0
- package/.agents/skills/remote-offload/SKILL.md +3 -1
- package/.agents/skills/repo-audit/SKILL.md +6 -1
- package/.agents/skills/repo-audit/agents/openai.yaml +5 -0
- package/.agents/skills/session/SKILL.md +21 -0
- package/.agents/skills/session/agents/openai.yaml +5 -0
- package/.agents/skills/session-end/SKILL.md +3 -1
- package/.agents/skills/session-plan/SKILL.md +3 -1
- package/.agents/skills/session-start/SKILL.md +3 -1
- package/.agents/skills/spinout/SKILL.md +6 -1
- package/.agents/skills/spinout/agents/openai.yaml +5 -0
- package/.agents/skills/sunset-review/SKILL.md +7 -1
- package/.agents/skills/sunset-review/agents/openai.yaml +5 -0
- package/.agents/skills/templates-ack/SKILL.md +21 -0
- package/.agents/skills/templates-ack/agents/openai.yaml +5 -0
- package/.agents/skills/test/SKILL.md +21 -0
- package/.agents/skills/test/agents/openai.yaml +5 -0
- package/.agents/skills/test-runner/SKILL.md +3 -1
- package/.agents/skills/tmux-layout/SKILL.md +3 -1
- package/.agents/skills/using-orchestrator/SKILL.md +3 -1
- package/.agents/skills/ux-grill/SKILL.md +7 -1
- package/.agents/skills/ux-grill/agents/openai.yaml +5 -0
- package/.agents/skills/vault-mirror/SKILL.md +3 -1
- package/.agents/skills/vault-sync/SKILL.md +3 -1
- package/.agents/skills/wave-executor/SKILL.md +3 -1
- package/.agents/skills/write-executable-plan/SKILL.md +3 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/.codex-plugin/skills/autopilot/SKILL.md +5 -4
- package/.codex-plugin/skills/bootstrap/SKILL.md +8 -4
- package/.codex-plugin/skills/brainstorm/SKILL.md +11 -4
- package/.codex-plugin/skills/close/SKILL.md +3 -3
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +1 -1
- package/.codex-plugin/skills/debug/SKILL.md +11 -4
- package/.codex-plugin/skills/discovery/SKILL.md +8 -4
- package/.codex-plugin/skills/dispatcher/SKILL.md +4 -4
- package/.codex-plugin/skills/eli5/SKILL.md +9 -4
- package/.codex-plugin/skills/eval/SKILL.md +9 -4
- package/.codex-plugin/skills/evolve/SKILL.md +9 -4
- package/.codex-plugin/skills/go/SKILL.md +3 -3
- package/.codex-plugin/skills/grill/SKILL.md +11 -4
- package/.codex-plugin/skills/harness-audit/SKILL.md +4 -3
- package/.codex-plugin/skills/memory-cleanup/SKILL.md +9 -4
- package/.codex-plugin/skills/npm-publish/SKILL.md +1 -1
- package/.codex-plugin/skills/persona-panel/SKILL.md +5 -5
- package/.codex-plugin/skills/plan/SKILL.md +8 -4
- package/.codex-plugin/skills/portfolio/SKILL.md +3 -3
- package/.codex-plugin/skills/reconcile/SKILL.md +9 -4
- package/.codex-plugin/skills/release/SKILL.md +3 -3
- package/.codex-plugin/skills/repo-audit/SKILL.md +6 -4
- package/.codex-plugin/skills/session/SKILL.md +1 -1
- package/.codex-plugin/skills/spinout/SKILL.md +4 -4
- package/.codex-plugin/skills/sunset-review/SKILL.md +5 -4
- package/.codex-plugin/skills/test/SKILL.md +3 -3
- package/.codex-plugin/skills/ux-grill/SKILL.md +11 -4
- package/.cursor/commands/autopilot.md +4 -4
- package/.cursor/commands/bootstrap.md +5 -4
- package/.cursor/commands/brainstorm.md +5 -4
- package/.cursor/commands/close.md +4 -3
- package/.cursor/commands/debug.md +4 -4
- package/.cursor/commands/discovery.md +4 -4
- package/.cursor/commands/dispatcher.md +4 -4
- package/.cursor/commands/eli5.md +4 -4
- package/.cursor/commands/eval.md +4 -4
- package/.cursor/commands/evolve.md +4 -4
- package/.cursor/commands/go.md +4 -3
- package/.cursor/commands/grill.md +4 -4
- package/.cursor/commands/harness-audit.md +3 -3
- package/.cursor/commands/memory-cleanup.md +4 -4
- package/.cursor/commands/persona-panel.md +4 -4
- package/.cursor/commands/plan.md +5 -4
- package/.cursor/commands/portfolio.md +3 -3
- package/.cursor/commands/reconcile.md +4 -4
- package/.cursor/commands/release.md +4 -3
- package/.cursor/commands/repo-audit.md +4 -4
- package/.cursor/commands/session.md +1 -1
- package/.cursor/commands/spinout.md +4 -4
- package/.cursor/commands/sunset-review.md +4 -4
- package/.cursor/commands/test.md +3 -3
- package/.cursor/commands/ux-grill.md +4 -4
- package/.cursor/rules/000-session-orchestrator.mdc +0 -2
- package/.cursor/rules/010-session-workflow.mdc +2 -2
- package/.cursor/rules/050-plan.mdc +1 -1
- package/.cursor/skills/bootstrap/SKILL.md +1 -0
- package/.cursor/skills/close/SKILL.md +13 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +1 -0
- package/.cursor/skills/debug/SKILL.md +0 -1
- package/.cursor/skills/discovery/SKILL.md +0 -1
- package/.cursor/skills/dispatcher/SKILL.md +0 -1
- package/.cursor/skills/eli5/SKILL.md +0 -1
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/evolve/SKILL.md +0 -1
- package/.cursor/skills/go/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +0 -1
- package/.cursor/skills/harness-audit/SKILL.md +12 -0
- package/.cursor/skills/npm-publish/SKILL.md +1 -0
- package/.cursor/skills/portfolio/SKILL.md +12 -0
- package/.cursor/skills/release/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +0 -1
- package/.cursor/skills/sunset-review/SKILL.md +0 -1
- package/.cursor/skills/test/SKILL.md +12 -0
- package/.cursor/skills/ux-grill/SKILL.md +0 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +13 -4
- package/AGENTS.md +3 -2
- package/CHANGELOG.md +197 -0
- package/README.md +11 -9
- package/SECURITY.md +12 -0
- package/agents/dialectic-deriver.md +13 -10
- package/agents/eval-judge.md +67 -45
- package/agents/skill-applied-judge.md +34 -19
- package/commands/session.md +17 -3
- package/docs/baseline.md +12 -6
- package/docs/ci-setup.md +53 -0
- package/docs/codex-setup.md +15 -3
- package/docs/components.md +13 -6
- package/docs/events-schema.md +59 -9
- package/docs/install.md +16 -0
- package/docs/persona-panel.md +1 -1
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +135 -14
- package/docs/scope-collision-guard.md +2 -0
- package/docs/session-config-reference.md +106 -11
- package/docs/session-config-template.md +31 -2
- package/docs/telemetry.md +2 -0
- package/hooks/_lib/hook-import-set.json +125 -8
- package/hooks/_lib/subagent-paths.mjs +15 -0
- package/hooks/_lib/subagent-transcript.mjs +582 -31
- package/hooks/_lib/vcs-create-matcher.mjs +217 -62
- package/hooks/config-protection.mjs +11 -3
- package/hooks/cwd-change-restore.mjs +11 -3
- package/hooks/enforce-commands.mjs +70 -23
- package/hooks/enforce-scope.mjs +143 -33
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/loop-guard.mjs +11 -3
- package/hooks/on-session-end.mjs +72 -25
- package/hooks/on-session-start.mjs +48 -11
- package/hooks/on-stop.mjs +211 -23
- package/hooks/operator-steer.mjs +11 -3
- package/hooks/post-bash-issue-budget-refund.mjs +18 -8
- package/hooks/post-bash-write-verify.mjs +6 -2
- package/hooks/post-edit-import-probe.mjs +17 -9
- package/hooks/post-edit-validate.mjs +13 -5
- package/hooks/post-subagent-discovery-validator.mjs +98 -13
- package/hooks/post-tool-batch-wave-signal.mjs +200 -38
- package/hooks/post-tool-failure-corrective-context.mjs +11 -5
- package/hooks/post-tooluse-frontend-slop.mjs +10 -4
- package/hooks/pre-auq-clarity.mjs +18 -2
- package/hooks/pre-bash-destructive-guard.mjs +80 -9
- package/hooks/pre-bash-issue-budget.mjs +119 -28
- package/hooks/pre-bash-memory-propose-audit.mjs +86 -54
- package/hooks/pre-bash-sessions-ledger-guard.mjs +391 -20
- package/hooks/pre-bash-staging-fence.mjs +335 -31
- package/hooks/pre-bash-templates-first.mjs +19 -14
- package/hooks/pre-task-scope-disjoint.mjs +385 -5
- package/hooks/skill-invocation-telemetry.mjs +2 -1
- package/hooks/subagent-telemetry.mjs +15 -19
- package/hooks/wave-scope-commit-guard.mjs +197 -100
- package/monitors/monitors.json +1 -1
- package/output-styles/wave-summary.md +1 -1
- package/package.json +2 -1
- package/pi/prompts/autopilot.md +3 -3
- package/pi/prompts/bootstrap.md +3 -3
- package/pi/prompts/brainstorm.md +3 -3
- package/pi/prompts/close.md +2 -2
- package/pi/prompts/debug.md +3 -3
- package/pi/prompts/discovery.md +3 -3
- package/pi/prompts/dispatcher.md +3 -3
- package/pi/prompts/eli5.md +3 -3
- package/pi/prompts/eval.md +3 -3
- package/pi/prompts/evolve.md +3 -3
- package/pi/prompts/go.md +2 -2
- package/pi/prompts/grill.md +3 -3
- package/pi/prompts/harness-audit.md +2 -3
- package/pi/prompts/memory-cleanup.md +3 -3
- package/pi/prompts/persona-panel.md +3 -3
- package/pi/prompts/plan.md +3 -3
- package/pi/prompts/portfolio.md +2 -2
- package/pi/prompts/reconcile.md +3 -3
- package/pi/prompts/release.md +3 -3
- package/pi/prompts/repo-audit.md +3 -4
- package/pi/prompts/session.md +2 -2
- package/pi/prompts/spinout.md +3 -3
- package/pi/prompts/sunset-review.md +3 -3
- package/pi/prompts/templates-ack.md +1 -1
- package/pi/prompts/test.md +3 -3
- package/pi/prompts/ux-grill.md +3 -3
- package/rules/README.md +1 -1
- package/rules/opt-in-domain/prompt-caching.md +1 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +3 -3
- package/rules/opt-in-stack/frontend.md +1 -1
- package/rules/opt-in-stack/security-web.md +3 -3
- package/rules/opt-in-stack/swift.md +1 -1
- package/scripts/archive-closed-prds.mjs +2 -2
- package/scripts/auq-audit.mjs +2 -3
- package/scripts/autopilot.mjs +23 -2
- package/scripts/backfill-abandoned-sessions.mjs +171 -15
- package/scripts/backfill-evidence-digest.mjs +2 -1
- package/scripts/backfill-learnings-from-vault.mjs +2 -2
- package/scripts/check-package-manager.mjs +2 -2
- package/scripts/check-sessions-integrity.mjs +300 -0
- package/scripts/ci/assert-vitest-green.mjs +2 -1
- package/scripts/dialectic-deriver.mjs +50 -13
- package/scripts/emit-session.mjs +77 -32
- package/scripts/eval-session.mjs +65 -3
- package/scripts/export-hw-learnings.mjs +2 -1
- package/scripts/express-path.mjs +1 -1
- package/scripts/gc-stale-worktrees.mjs +2 -1
- package/scripts/generate-agents-skills.mjs +102 -29
- package/scripts/generate-codex-skills.mjs +48 -4
- package/scripts/generate-cursor-adapter.mjs +220 -11
- package/scripts/generate-hook-import-set.mjs +12 -27
- package/scripts/generate-pi-prompts.mjs +183 -13
- package/scripts/github-protection-audit.mjs +2 -3
- package/scripts/lib/agent-frontmatter.mjs +23 -1
- package/scripts/lib/agent-status.mjs +2 -31
- package/scripts/lib/auq/clarity.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +12 -31
- package/scripts/lib/auq/schema.mjs +56 -41
- package/scripts/lib/auto-dialectic.mjs +304 -15
- package/scripts/lib/autopilot/flags.mjs +12 -1
- package/scripts/lib/autopilot/kill-switches.mjs +6 -3
- package/scripts/lib/autopilot/loop.mjs +14 -1
- package/scripts/lib/autopilot/stall-sampler.mjs +80 -23
- package/scripts/lib/ci-status-banner.mjs +376 -16
- package/scripts/lib/claude-md-budget-lint.mjs +2 -5
- package/scripts/lib/command-blocker.mjs +408 -33
- package/scripts/lib/config/dialectic.mjs +12 -3
- package/scripts/lib/config/drift-check.mjs +19 -0
- package/scripts/lib/config/gate.mjs +74 -0
- package/scripts/lib/config/reaper.mjs +162 -0
- package/scripts/lib/config.mjs +14 -0
- package/scripts/lib/convergence-monitor.mjs +76 -13
- package/scripts/lib/cursor-hook-bridge.mjs +2 -2
- package/scripts/lib/description-surface.mjs +2 -5
- package/scripts/lib/dispatcher/cli.mjs +2 -1
- package/scripts/lib/ecosystem-health.mjs +11 -0
- package/scripts/lib/ecosystem-wizard.mjs +2 -1
- package/scripts/lib/eval/engine.mjs +421 -53
- package/scripts/lib/eval/judge.mjs +463 -40
- package/scripts/lib/eval/schema.mjs +10 -1
- package/scripts/lib/events-rotation.mjs +221 -25
- package/scripts/lib/events-schema.mjs +114 -0
- package/scripts/lib/events.mjs +524 -5
- package/scripts/lib/fetch-baseline.mjs +3 -8
- package/scripts/lib/frontmatter-guard.mjs +21 -10
- package/scripts/lib/gates/gate-baseline.mjs +27 -2
- package/scripts/lib/gates/gate-full.mjs +28 -3
- package/scripts/lib/gates/gate-helpers.mjs +243 -21
- package/scripts/lib/gates/gate-incremental.mjs +28 -3
- package/scripts/lib/gates/gate-per-file.mjs +27 -2
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +2 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +2 -1
- package/scripts/lib/gitlab-portfolio/markdown-writer.mjs +6 -1
- package/scripts/lib/instruction-budget-guard.mjs +332 -50
- package/scripts/lib/io.mjs +42 -8
- package/scripts/lib/is-main-module.mjs +82 -0
- package/scripts/lib/issue-close-strip-labels.mjs +207 -49
- package/scripts/lib/js-mask.mjs +197 -0
- package/scripts/lib/learnings/evolve-telemetry.mjs +11 -7
- package/scripts/lib/locks/index.mjs +32 -25
- package/scripts/lib/maintenance-due-banner.mjs +122 -91
- package/scripts/lib/orphan-reaper.mjs +1588 -0
- package/scripts/lib/peer-cards/merger.mjs +48 -10
- package/scripts/lib/peer-cards/reader.mjs +78 -2
- package/scripts/lib/peer-discovery.mjs +2 -5
- package/scripts/lib/playwright-driver/runner.mjs +2 -1
- package/scripts/lib/process-group.mjs +899 -0
- package/scripts/lib/quality-gate.mjs +107 -28
- package/scripts/lib/reconcile/backlog.mjs +368 -0
- package/scripts/lib/reconcile/engine.mjs +55 -188
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +884 -0
- package/scripts/lib/reconcile/sanitize.mjs +69 -3
- package/scripts/lib/reconcile-nudge-banner.mjs +138 -45
- package/scripts/lib/resource-probe/parsers.mjs +31 -0
- package/scripts/lib/rule-loader.mjs +41 -12
- package/scripts/lib/rules-sync.mjs +2 -5
- package/scripts/lib/scope-echo.mjs +429 -7
- package/scripts/lib/scope-gate.mjs +605 -1
- package/scripts/lib/session-close-backfill.mjs +91 -12
- package/scripts/lib/session-id.mjs +9 -20
- package/scripts/lib/session-invocation.mjs +20 -0
- package/scripts/lib/session-schema/constants.mjs +30 -2
- package/scripts/lib/session-schema/normalizer.mjs +56 -4
- package/scripts/lib/session-schema.mjs +8 -3
- package/scripts/lib/session-start-probes.mjs +95 -10
- package/scripts/lib/sessions-canonical.mjs +23 -0
- package/scripts/lib/sessions-integrity-banner.mjs +7 -1
- package/scripts/lib/sessions-staleness-banner.mjs +193 -51
- package/scripts/lib/skill-evidence-window.mjs +891 -0
- package/scripts/lib/skill-evolution/candidate-intake.mjs +133 -12
- package/scripts/lib/skill-evolution/engine.mjs +18 -9
- package/scripts/lib/skill-judge.mjs +45 -3
- package/scripts/lib/state-md.mjs +84 -3
- package/scripts/lib/sunset/walker.mjs +31 -4
- package/scripts/lib/tail-window.mjs +56 -0
- package/scripts/lib/telemetry/schema.mjs +30 -0
- package/scripts/lib/telemetry/sync.mjs +61 -6
- package/scripts/lib/telemetry-flush-health-banner.mjs +4 -22
- package/scripts/lib/test-runner/issue-reconcile.mjs +48 -16
- package/scripts/lib/tests-src-ratio.mjs +2 -6
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +74 -14
- package/scripts/lib/user-invocable-skills.mjs +205 -0
- package/scripts/lib/ux-grill/reconcile.mjs +48 -22
- package/scripts/lib/validate/check-agents-skills.mjs +26 -15
- package/scripts/lib/validate/check-banner-parity.mjs +2 -2
- package/scripts/lib/validate/check-cursor-adapter.mjs +3 -2
- package/scripts/lib/validate/check-dead-bridge.mjs +2 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +2 -2
- package/scripts/lib/validate/check-entry-guard.mjs +329 -0
- package/scripts/lib/validate/check-guard-requires-parity.mjs +2 -2
- package/scripts/lib/validate/check-hook-entry-guards.mjs +636 -0
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +2 -2
- package/scripts/lib/validate/check-learning-provenance.mjs +2 -2
- package/scripts/lib/validate/check-pi-prompts.mjs +1 -0
- package/scripts/lib/validate/check-rules.mjs +7 -5
- package/scripts/lib/validate/check-skill-links.mjs +35 -6
- package/scripts/lib/validate/check-skill-script-paths.mjs +241 -29
- package/scripts/lib/validate/check-test-git-config-target.mjs +26 -36
- package/scripts/lib/validate/check-unicode-safety.mjs +2 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +9 -104
- package/scripts/lib/validate/check-unwired-features.mjs +220 -33
- package/scripts/lib/validate/check-validator-registration.mjs +36 -12
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +2 -2
- package/scripts/lib/validate/confidential-names.mjs +10 -0
- package/scripts/lib/validate-vendored-rules.mjs +39 -12
- package/scripts/lib/vault-mirror/namespace.mjs +46 -8
- package/scripts/lib/vault-mirror/process.mjs +10 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +12 -2
- package/scripts/lib/vault-status/narrative-mirror.mjs +31 -7
- package/scripts/lib/vault-yaml.mjs +118 -0
- package/scripts/lib/wave-transcript-tail.mjs +2 -2
- package/scripts/lib/worktree/lifecycle.mjs +153 -1
- package/scripts/lock-reaper.mjs +2 -1
- package/scripts/materialize-wave-scope.mjs +87 -4
- package/scripts/migrate-sessions-jsonl.mjs +2 -1
- package/scripts/migrate-vault-paths.mjs +2 -3
- package/scripts/release-session-lock.mjs +305 -0
- package/scripts/release.mjs +109 -39
- package/scripts/relocate-vault-corpus.mjs +2 -3
- package/scripts/repair-invalid-sessions.mjs +2 -2
- package/scripts/resolve-session-invocation.mjs +59 -0
- package/scripts/run-quality-gate.mjs +156 -17
- package/scripts/session-shape.mjs +2 -2
- package/scripts/site-numbers.mjs +35 -11
- package/scripts/sweep-expired-rules.mjs +227 -0
- package/scripts/validate-plugin.mjs +21 -0
- package/scripts/validate-wave-scope.mjs +32 -105
- package/scripts/vault-consolidate.mjs +2 -2
- package/scripts/vault-mirror.mjs +11 -4
- package/scripts/wave-scope-binding.mjs +2 -3
- package/skills/_shared/bootstrap-gate.md +1 -1
- package/skills/_shared/monitor-patterns.md +1 -1
- package/skills/_shared/platform-tools.md +23 -11
- package/skills/_shared/research-evidence.md +53 -0
- package/skills/_shared/state-ownership.md +3 -0
- package/skills/autopilot/SKILL.md +80 -11
- package/skills/bootstrap/SKILL.md +51 -1
- package/skills/brainstorm/SKILL.md +16 -0
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +49 -11
- package/{commands/close.md → skills/close/SKILL.md} +9 -3
- package/skills/convergence-monitoring/README.md +8 -1
- package/skills/convergence-monitoring/SIGNALS.md +50 -6
- package/skills/convergence-monitoring/SKILL.md +15 -6
- package/skills/debug/SKILL.md +10 -0
- package/skills/discovery/SKILL.md +24 -1
- package/skills/discovery/probes-session.md +2 -2
- package/skills/dispatcher/SKILL.md +38 -7
- package/skills/eli5/SKILL.md +11 -0
- package/skills/eval/SKILL.md +52 -23
- package/skills/eval/rubric-v1.md +1 -0
- package/skills/eval/rubric-v2.md +457 -0
- package/skills/evolve/SKILL.md +9 -2
- package/skills/evolve/references/evolve-dialectic-mode.md +46 -25
- package/skills/gitlab-ops/SKILL.md +3 -2
- package/{commands/go.md → skills/go/SKILL.md} +9 -1
- package/skills/grill/SKILL.md +19 -0
- package/{commands/harness-audit.md → skills/harness-audit/SKILL.md} +7 -2
- package/skills/hook-development/SKILL.md +46 -41
- package/skills/memory-cleanup/SKILL.md +7 -0
- package/skills/npm-publish/SKILL.md +2 -2
- package/skills/persona-panel/SKILL.md +56 -1
- package/skills/persona-panel/persona-format.md +1 -1
- package/skills/plan/SKILL.md +28 -1
- package/{commands/portfolio.md → skills/portfolio/SKILL.md} +8 -2
- package/skills/reconcile/SKILL.md +21 -0
- package/{commands/release.md → skills/release/SKILL.md} +16 -2
- package/skills/repo-audit/SKILL.md +7 -0
- package/skills/session-end/SKILL.md +13 -16
- package/skills/session-end/discovery-scan.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +55 -9
- package/skills/session-end/plan-verification.md +2 -2
- package/skills/session-end/references/phase-5-issue-cleanup.md +9 -14
- package/skills/session-end/session-metrics-write.md +10 -0
- package/skills/session-plan/SKILL.md +18 -6
- package/skills/session-plan/references/session-plan-task-classification.md +2 -2
- package/skills/session-start/SKILL.md +5 -4
- package/skills/session-start/phase-8-5-express-path.md +6 -6
- package/skills/session-start/references/phase-1-5-session-continuity.md +1 -1
- package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +6 -4
- package/skills/spinout/SKILL.md +12 -1
- package/skills/sunset-review/SKILL.md +13 -0
- package/{commands/test.md → skills/test/SKILL.md} +10 -4
- package/skills/ux-grill/SKILL.md +20 -2
- package/skills/wave-executor/SKILL.md +14 -7
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +18 -4
- package/skills/wave-executor/references/wave-loop-dispatch.md +5 -2
- package/skills/wave-executor/references/wave-loop-review.md +17 -1
- package/commands/autopilot.md +0 -80
- package/commands/bootstrap.md +0 -56
- package/commands/brainstorm.md +0 -48
- package/commands/debug.md +0 -36
- package/commands/discovery.md +0 -32
- package/commands/dispatcher.md +0 -59
- package/commands/eli5.md +0 -33
- package/commands/eval.md +0 -28
- package/commands/evolve.md +0 -10
- package/commands/grill.md +0 -45
- package/commands/memory-cleanup.md +0 -26
- package/commands/persona-panel.md +0 -121
- package/commands/plan.md +0 -15
- package/commands/reconcile.md +0 -23
- package/commands/repo-audit.md +0 -24
- package/commands/spinout.md +0 -15
- package/commands/sunset-review.md +0 -27
- 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
|
+
}
|