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