session-orchestrator 5.11.0 → 5.13.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/.claude-plugin/marketplace.json +12 -1
- package/.claude-plugin/plugin.json +4 -1
- package/.codex-plugin/agents/explorer.toml +1 -1
- package/.codex-plugin/agents/session-reviewer.toml +2 -2
- package/.codex-plugin/agents/wave-worker.toml +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +8 -0
- package/.orchestrator/policy/public-export.json +30 -0
- package/CHANGELOG.md +180 -3
- package/README.md +126 -117
- package/agents/analyst.md +7 -2
- package/agents/architect-reviewer.md +7 -2
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +9 -2
- package/agents/dialectic-deriver.md +9 -3
- package/agents/docs-writer.md +7 -2
- package/agents/eval-judge.md +7 -1
- package/agents/qa-strategist.md +7 -2
- package/agents/security-reviewer.md +9 -2
- package/agents/session-reviewer.md +18 -2
- package/agents/skill-applied-judge.md +7 -1
- package/agents/test-writer.md +9 -2
- package/agents/ui-developer.md +9 -2
- package/agents/ux-evaluator.md +6 -1
- package/commands/session.md +4 -2
- package/commands/templates-ack.md +89 -18
- package/docs/agent-authoring.md +30 -11
- package/docs/ci-setup.md +38 -2
- package/docs/codex-setup.md +33 -9
- package/docs/components.md +13 -2
- package/docs/cursor-setup.md +3 -3
- package/docs/events-schema.md +5 -4
- package/docs/evidence-candidates.md +63 -0
- package/docs/fleet-checkin-audit.md +7 -4
- package/docs/github-mirror-protection.md +150 -3
- package/docs/install.md +102 -25
- package/docs/instruction-delivery.md +1 -1
- package/docs/migration-v4.md +2 -2
- package/docs/mods.md +118 -0
- package/docs/owner-config-schema.md +1 -0
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/pm-skills-marketplace.md +4 -3
- package/docs/rule-authoring.md +24 -13
- package/docs/scope-collision-guard.md +2 -1
- package/docs/session-config-reference.md +55 -21
- package/docs/session-config-template.md +32 -24
- package/docs/sessions-ledger-repair.md +137 -0
- package/docs/vault-docs-architecture.md +1 -1
- package/hooks/_lib/hook-import-set.json +406 -73
- package/hooks/_lib/hook-root.mjs +301 -0
- package/hooks/_lib/lock-bootstrap.mjs +434 -4
- package/hooks/_lib/orchestrator-ignore.mjs +3 -0
- package/hooks/_lib/subagent-transcript.mjs +2 -2
- package/hooks/_lib/vcs-create-matcher.mjs +13 -17
- package/hooks/cwd-change-restore.mjs +5 -1
- package/hooks/enforce-commands.mjs +4 -3
- package/hooks/enforce-scope.mjs +11 -1
- package/hooks/hooks-codex.json +48 -2
- package/hooks/hooks.json +31 -31
- package/hooks/on-session-end.mjs +38 -15
- package/hooks/on-session-start.mjs +158 -59
- package/hooks/on-stop.mjs +11 -6
- package/hooks/post-bash-issue-budget-refund.mjs +6 -1
- package/hooks/post-bash-write-verify.mjs +8 -4
- package/hooks/post-edit-validate.mjs +45 -52
- package/hooks/post-subagent-discovery-validator.mjs +2 -2
- package/hooks/post-tool-batch-wave-signal.mjs +8 -4
- package/hooks/post-tool-failure-corrective-context.mjs +5 -1
- package/hooks/pre-bash-destructive-guard.mjs +220 -8
- package/hooks/pre-bash-issue-budget.mjs +6 -1
- package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
- package/hooks/pre-bash-staging-fence.mjs +258 -14
- package/hooks/pre-bash-templates-first.mjs +28 -27
- package/hooks/run-node.sh +1 -1
- package/hooks/skill-invocation-telemetry.mjs +15 -8
- package/hooks/subagent-telemetry.mjs +77 -14
- package/hooks/wave-scope-commit-guard.mjs +143 -14
- package/mods/so-hud/.claude-plugin/plugin.json +11 -0
- package/mods/so-hud/README.md +57 -0
- package/mods/so-hud/hooks/beacon-io.mjs +101 -0
- package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
- package/mods/so-hud/hooks/hooks.json +1 -0
- package/mods/so-hud/hooks/logic.ts +182 -0
- package/mods/so-hud/hooks/register.tsx +670 -0
- package/mods/so-hud/hooks/rules.ts +2998 -0
- package/mods/so-hud/tsconfig.json +8 -0
- package/mods/so-hud/types/index.d.ts +36 -0
- package/package.json +4 -3
- package/rules/_index.md +1 -1
- package/rules/always-on/ask-via-tool.md +1 -1
- package/rules/always-on/loop-and-monitor.md +17 -194
- package/rules/always-on/parallel-sessions.md +3 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +1 -1
- package/rules/opt-in-stack/security-web.md +5 -1
- package/scripts/agent-usage-census.mjs +79 -0
- package/scripts/apply-session-learnings.mjs +34 -6
- package/scripts/archive-closed-prds.mjs +21 -5
- package/scripts/autopilot.mjs +11 -5
- package/scripts/backfill-abandoned-sessions.mjs +13 -3
- package/scripts/backfill-evidence-digest.mjs +2 -1
- package/scripts/backfill-learnings-from-vault.mjs +61 -6
- package/scripts/backfill-sessions.mjs +16 -2
- package/scripts/check-package-manager.mjs +26 -8
- package/scripts/check-sessions-integrity.mjs +4 -1
- package/scripts/ci/assert-vitest-green.mjs +3 -0
- package/scripts/ci/windows-smoke.mjs +201 -0
- package/scripts/close-precheck.mjs +55 -0
- package/scripts/dialectic-deriver.mjs +20 -12
- package/scripts/emit-event.mjs +2 -2
- package/scripts/emit-session.mjs +239 -20
- package/scripts/eval-session.mjs +17 -3
- package/scripts/export-hw-learnings.mjs +29 -2
- package/scripts/fleet-checkin-audit.mjs +8 -4
- package/scripts/fleet-checkin.mjs +73 -3
- package/scripts/gc-stale-worktrees.mjs +6 -1
- package/scripts/github-export.mjs +128 -0
- package/scripts/github-protection-audit.mjs +3 -1
- package/scripts/handover-queue-check.mjs +209 -0
- package/scripts/harness-audit.mjs +6 -4
- package/scripts/lib/agent-frontmatter.mjs +57 -10
- package/scripts/lib/agent-status.mjs +125 -7
- package/scripts/lib/agent-usage-census.mjs +394 -0
- package/scripts/lib/auto-dialectic.mjs +17 -10
- package/scripts/lib/auto-dream.mjs +22 -14
- package/scripts/lib/autopilot/loop.mjs +3 -1
- package/scripts/lib/autopilot/recent-runs.mjs +4 -2
- package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
- package/scripts/lib/autopilot/telemetry.mjs +5 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/baseline-archetypes.mjs +10 -2
- package/scripts/lib/blocked-commands-policy.mjs +4 -3
- package/scripts/lib/build-live-signals.mjs +13 -9
- package/scripts/lib/ci-status-banner.mjs +329 -24
- package/scripts/lib/codex/plugin-contract.mjs +8 -0
- package/scripts/lib/cold-start-detector.mjs +5 -2
- package/scripts/lib/command-blocker.mjs +603 -43
- package/scripts/lib/common.mjs +3 -2
- package/scripts/lib/config/cross-repo.mjs +74 -7
- package/scripts/lib/config/host-paths.mjs +6 -2
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config/section-extractor.mjs +65 -11
- package/scripts/lib/config/template-keys.mjs +153 -0
- package/scripts/lib/config/vault-integration.mjs +1 -0
- package/scripts/lib/config.mjs +3 -3
- package/scripts/lib/coordinator-snapshot.mjs +1 -1
- package/scripts/lib/discovery/triage-state.mjs +2 -1
- package/scripts/lib/dispatcher/rank.mjs +5 -7
- package/scripts/lib/ecosystem-health.mjs +2 -2
- package/scripts/lib/eval/engine.mjs +3 -2
- package/scripts/lib/eval/session-resolve.mjs +6 -2
- package/scripts/lib/eval/sink.mjs +4 -3
- package/scripts/lib/events-retention-banner.mjs +3 -2
- package/scripts/lib/events-schema.mjs +10 -0
- package/scripts/lib/events.mjs +23 -12
- package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
- package/scripts/lib/fleet-protocol.mjs +21 -0
- package/scripts/lib/gates/gate-full.mjs +172 -11
- package/scripts/lib/gates/gate-helpers.mjs +236 -23
- package/scripts/lib/git-config-drift.mjs +141 -7
- package/scripts/lib/github-export.mjs +447 -0
- package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
- package/scripts/lib/handover-queue-marks.mjs +159 -0
- package/scripts/lib/hardware-pattern-detector.mjs +103 -13
- package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
- package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
- package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
- package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
- package/scripts/lib/host-identity.mjs +9 -5
- package/scripts/lib/http-request.mjs +64 -0
- package/scripts/lib/instruction-budget-guard.mjs +30 -22
- package/scripts/lib/io.mjs +6 -3
- package/scripts/lib/issue-budget-reconcile.mjs +3 -2
- package/scripts/lib/issue-budget.mjs +36 -7
- package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
- package/scripts/lib/learnings/io.mjs +289 -15
- package/scripts/lib/learnings/select.mjs +53 -4
- package/scripts/lib/learnings/store-role.mjs +209 -0
- package/scripts/lib/learnings/surface.mjs +46 -7
- package/scripts/lib/ledger-path.mjs +113 -0
- package/scripts/lib/maintenance-due-banner.mjs +63 -12
- package/scripts/lib/memory-banner.mjs +5 -6
- package/scripts/lib/memory-proposals/collector.mjs +5 -2
- package/scripts/lib/memory-proposals/sink.mjs +7 -4
- package/scripts/lib/memory-proposals/store.mjs +22 -5
- package/scripts/lib/mode-selector-accuracy.mjs +2 -1
- package/scripts/lib/no-follow-open.mjs +57 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
- package/scripts/lib/own-lock-root.mjs +95 -0
- package/scripts/lib/owner-interview.mjs +2 -1
- package/scripts/lib/owner-yaml.mjs +70 -7
- package/scripts/lib/platform.mjs +161 -9
- package/scripts/lib/plugin-update-banner.mjs +15 -11
- package/scripts/lib/pre-push-local-partial.mjs +117 -0
- package/scripts/lib/pre-push-remote-gate.mjs +151 -25
- package/scripts/lib/process-group.mjs +270 -34
- package/scripts/lib/project-hygiene.mjs +28 -0
- package/scripts/lib/quality-gate.mjs +33 -9
- package/scripts/lib/reconcile/backlog.mjs +29 -15
- package/scripts/lib/reconcile/engine.mjs +26 -2
- package/scripts/lib/reconcile/idempotency.mjs +65 -3
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
- package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
- package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
- package/scripts/lib/resource-probe/evaluate.mjs +32 -9
- package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
- package/scripts/lib/rule-loader.mjs +20 -10
- package/scripts/lib/scope-baseline.mjs +123 -8
- package/scripts/lib/scope-echo.mjs +5 -2
- package/scripts/lib/scope-gate.mjs +55 -20
- package/scripts/lib/session-close-backfill.mjs +75 -7
- package/scripts/lib/session-end/close-precheck.mjs +151 -0
- package/scripts/lib/session-end/phase-skip.mjs +8 -7
- package/scripts/lib/session-end/tail-runner.mjs +7 -2
- package/scripts/lib/session-end/vault-orphans.mjs +205 -0
- package/scripts/lib/session-id.mjs +9 -1
- package/scripts/lib/session-identity/own-session.mjs +63 -0
- package/scripts/lib/session-invocation.mjs +14 -3
- package/scripts/lib/session-lock.mjs +114 -0
- package/scripts/lib/session-record-repair.mjs +245 -35
- package/scripts/lib/session-registry.mjs +85 -12
- package/scripts/lib/session-schema/constants.mjs +9 -0
- package/scripts/lib/session-schema/filters.mjs +56 -1
- package/scripts/lib/session-schema/validator.mjs +15 -5
- package/scripts/lib/session-start-probes.mjs +149 -22
- package/scripts/lib/session-token-rollup.mjs +2 -1
- package/scripts/lib/session-work-trees.mjs +166 -0
- package/scripts/lib/sessions-canonical.mjs +61 -3
- package/scripts/lib/sessions-integrity-banner.mjs +17 -39
- package/scripts/lib/sessions-staleness-banner.mjs +8 -5
- package/scripts/lib/sh-quote.mjs +1 -0
- package/scripts/lib/skill-evidence-window.mjs +5 -1
- package/scripts/lib/skill-health/join.mjs +10 -9
- package/scripts/lib/skill-invocations-schema.mjs +13 -0
- package/scripts/lib/skill-judgments-schema.mjs +6 -2
- package/scripts/lib/snapshot-probe.mjs +414 -0
- package/scripts/lib/so-hud/collect.mjs +174 -0
- package/scripts/lib/so-hud/runtime.mjs +87 -0
- package/scripts/lib/spiral-carryover.mjs +8 -1
- package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
- package/scripts/lib/state-md/idle-reset.mjs +248 -0
- package/scripts/lib/state-md/mission-status.mjs +5 -1
- package/scripts/lib/state-md/recommendations.mjs +26 -4
- package/scripts/lib/state-md-peer-guard.mjs +2 -0
- package/scripts/lib/state-md.mjs +31 -8
- package/scripts/lib/sunset/walker.mjs +4 -3
- package/scripts/lib/telemetry/consent.mjs +4 -2
- package/scripts/lib/telemetry/queue.mjs +3 -3
- package/scripts/lib/telemetry/sync.mjs +45 -9
- package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
- package/scripts/lib/tmux-layout/layouts.mjs +20 -3
- package/scripts/lib/ux-grill/paths.mjs +5 -1
- package/scripts/lib/validate/check-agents.mjs +37 -6
- package/scripts/lib/validate/check-component-paths.mjs +25 -1
- package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
- package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
- package/scripts/lib/validate/check-json-files.mjs +3 -1
- package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
- package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
- package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
- package/scripts/lib/validate/check-unwired-features.mjs +4 -2
- package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
- package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
- package/scripts/lib/validate/confidential-names.mjs +3 -1
- package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
- package/scripts/lib/vault-mirror/namespace.mjs +22 -9
- package/scripts/lib/vault-mirror/process.mjs +197 -65
- package/scripts/lib/vault-mirror/prune.mjs +5 -2
- package/scripts/lib/vault-mirror/render-learnings.mjs +8 -7
- package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
- package/scripts/lib/vault-mirror/retention.mjs +2 -2
- package/scripts/lib/vault-mirror/utils.mjs +106 -13
- package/scripts/lib/vault-staleness-banner.mjs +4 -7
- package/scripts/lib/vault-status/board-writer.mjs +34 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
- package/scripts/lib/win-process-tree.mjs +137 -0
- package/scripts/lib/workspace.mjs +1 -1
- package/scripts/lib/worktree/constants.mjs +1 -1
- package/scripts/lib/worktree/lifecycle.mjs +1 -1
- package/scripts/lib/worktree/listing.mjs +1 -1
- package/scripts/lib/zx.mjs +50 -0
- package/scripts/mcp-server.mjs +16 -7
- package/scripts/mcp-server.sh +13 -83
- package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
- package/scripts/memory-propose.mjs +10 -3
- package/scripts/migrate-cold-start-seed.mjs +7 -0
- package/scripts/migrate-legacy-learnings.sh +22 -0
- package/scripts/migrate-sessions-jsonl.mjs +16 -2
- package/scripts/migrate-vault-paths.mjs +29 -13
- package/scripts/parse-config.mjs +71 -14
- package/scripts/print-learnings-index.mjs +25 -2
- package/scripts/release-session-lock.mjs +94 -7
- package/scripts/release.mjs +451 -24
- package/scripts/relocate-vault-corpus.mjs +11 -7
- package/scripts/repair-invalid-sessions.mjs +68 -3
- package/scripts/resolve-session-invocation.mjs +57 -8
- package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
- package/scripts/run-quality-gate.mjs +33 -7
- package/scripts/self-update.mjs +56 -7
- package/scripts/session-commit-files.mjs +133 -0
- package/scripts/site-numbers.mjs +52 -9
- package/scripts/snapshot-probe.mjs +156 -0
- package/scripts/so-hud.mjs +68 -0
- package/scripts/state-md-idle-reset.mjs +82 -0
- package/scripts/sweep-expired-learnings.mjs +54 -6
- package/scripts/sweep-expired-rules.mjs +4 -1
- package/scripts/validate-plugin.mjs +22 -0
- package/scripts/vault-integration-watcher.mjs +54 -6
- package/scripts/vault-mirror.mjs +9 -0
- package/scripts/vault-orphans.mjs +171 -0
- package/skills/_shared/fleet-protocol.md +166 -3
- package/skills/_shared/instruction-file-resolution.md +1 -1
- package/skills/_shared/monitor-patterns.md +44 -25
- package/skills/_shared/platform-tools.md +7 -0
- package/skills/_shared/state-ownership.md +5 -4
- package/skills/architecture/references/domain-model.md +1 -1
- package/skills/autopilot/SKILL.md +3 -2
- package/skills/bootstrap/SKILL.md +3 -1
- package/skills/bootstrap/_shared-template.md +4 -4
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/bootstrap/private-contract.md +1 -1
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
- package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
- package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
- package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
- package/skills/claude-md-drift-check/SKILL.md +3 -3
- package/skills/claude-md-drift-check/checker.mjs +61 -101
- package/skills/close/SKILL.md +20 -7
- package/skills/discovery/probes/docs-staleness.mjs +7 -5
- package/skills/discovery/probes/frontend-slop.mjs +7 -3
- package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
- package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
- package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
- package/skills/discovery/probes/vault-staleness.mjs +5 -3
- package/skills/discovery/probes-session.md +3 -0
- package/skills/discovery/probes-vault.md +2 -0
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/eval/SKILL.md +3 -1
- package/skills/evolve/SKILL.md +2 -0
- package/skills/evolve/references/evolve-analyze-mode.md +3 -3
- package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
- package/skills/gitlab-ops/SKILL.md +7 -6
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/go/SKILL.md +13 -1
- package/skills/npm-publish/SKILL.md +5 -2
- package/skills/plan/SKILL.md +23 -10
- package/skills/plan/mode-feature.md +13 -8
- package/skills/plan/mode-new.md +6 -14
- package/skills/plan/prd-feature-template.md +19 -0
- package/skills/plan/prd-full-template.md +12 -5
- package/skills/plan/prd-premortem-panel.md +48 -0
- package/skills/plan/prd-reviewer-prompt.md +11 -3
- package/skills/plan/soul.md +1 -1
- package/skills/portfolio/SKILL.md +5 -6
- package/skills/quality-gates/SKILL.md +2 -4
- package/skills/reconcile/SKILL.md +27 -4
- package/skills/release/SKILL.md +16 -8
- package/skills/session-end/SKILL.md +97 -13
- package/skills/session-end/phase-3-2-docs-verification.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +11 -7
- package/skills/session-end/phase-3-7a-recommendations.md +9 -2
- package/skills/session-end/plan-verification.md +16 -1
- package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
- package/skills/session-end/references/phase-3-documentation-updates.md +25 -13
- package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
- package/skills/session-end/references/session-summary-template.md +8 -0
- package/skills/session-end/session-metrics-write.md +7 -4
- package/skills/session-plan/SKILL.md +5 -3
- package/skills/session-start/SKILL.md +10 -4
- package/skills/session-start/phase-4-5-resource-health.md +11 -5
- package/skills/session-start/phase-7-5-mode-selector.md +3 -1
- package/skills/session-start/phase-8-5-express-path.md +4 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
- package/skills/session-start/references/phase-1-5-session-continuity.md +13 -5
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
- package/skills/tmux-layout/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +3 -2
- package/skills/wave-executor/SKILL.md +5 -4
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
- package/skills/wave-executor/references/wave-loop-review.md +13 -5
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
- package/AGENTS.md +0 -207
- package/assets/og-card.png +0 -0
- package/assets/og-card.svg +0 -47
- package/assets/wave-lifecycle.svg +0 -98
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* makes the shell a process-group LEADER (`child.pid === pgid`, setsid), so
|
|
11
11
|
* `process.kill(-pgid, sig)` reaches every descendant that did not setsid away.
|
|
12
12
|
*
|
|
13
|
+
* Windows uses a non-detached hidden child and taskkill /T with CIM readback.
|
|
14
|
+
*
|
|
13
15
|
* Measured on this host (Darwin 25.6.0, 2026-09-21) and load-bearing here:
|
|
14
16
|
* - `process.kill(-pgid, 'SIGTERM')` does NOT terminate a grandchild that
|
|
15
17
|
* installs `trap "" TERM`; SIGKILL to the group does. Hence the ladder.
|
|
@@ -23,6 +25,7 @@
|
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
27
|
import { spawn } from 'node:child_process';
|
|
28
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
26
29
|
import { createHash } from 'node:crypto';
|
|
27
30
|
import {
|
|
28
31
|
existsSync,
|
|
@@ -36,6 +39,7 @@ import path from 'node:path';
|
|
|
36
39
|
|
|
37
40
|
import { NOT_A_REGULAR_FILE, appendNoFollow } from './orphan-reaper/no-follow-append.mjs';
|
|
38
41
|
import { readTailWindow } from './tail-window.mjs';
|
|
42
|
+
import { killWindowsProcessTree } from './win-process-tree.mjs';
|
|
39
43
|
|
|
40
44
|
/**
|
|
41
45
|
* Grace period between SIGTERM and SIGKILL, in ms — the repo's one definition,
|
|
@@ -106,12 +110,20 @@ const EXIT_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
|
106
110
|
* one the caller passed, which is why the exit handler never reaches
|
|
107
111
|
* `process.kill` for a group a test spawned through an injected seam.
|
|
108
112
|
*
|
|
109
|
-
* @type {Map<number, {killFn: (target: number, signal: string) => unknown}>}
|
|
113
|
+
* @type {Map<number, {killFn: (target: number, signal: string) => unknown, platform: string, spawnSyncFn?: Function, snapshotFn?: Function, child: object, spawnedAtMs: number, verifyTree: boolean}>}
|
|
110
114
|
*/
|
|
111
115
|
const LIVE_GROUPS = new Map();
|
|
112
116
|
|
|
113
117
|
let exitHandlerInstalled = false;
|
|
114
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Synchronous cleanup callbacks registered via `registerExitCleanup()`, run on
|
|
121
|
+
* the exit path after the live groups are killed.
|
|
122
|
+
*
|
|
123
|
+
* @type {Set<() => void>}
|
|
124
|
+
*/
|
|
125
|
+
const EXIT_CLEANUPS = new Set();
|
|
126
|
+
|
|
115
127
|
/**
|
|
116
128
|
* Default liveness probe: signal 0 tells us whether a PID exists without
|
|
117
129
|
* touching it. `ESRCH` = gone. `EPERM` = alive but not ours — reported as ALIVE
|
|
@@ -142,6 +154,28 @@ function defaultKill(target, signal) {
|
|
|
142
154
|
return process.kill(target, signal);
|
|
143
155
|
}
|
|
144
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Default GROUP liveness probe: signal 0 to `-pgid` asks whether ANY member of
|
|
159
|
+
* the group still exists — unlike {@link defaultIsAlive}, which probes the
|
|
160
|
+
* leader pid and so reads a group whose leader already exited as dead.
|
|
161
|
+
* `ESRCH` = no member left. `EPERM` = a member exists that we may not signal:
|
|
162
|
+
* reported as ALIVE, for the same reason as {@link defaultIsAlive} — the ladder
|
|
163
|
+
* that follows then stops at EPERM and reports the group as a survivor instead
|
|
164
|
+
* of the probe booking it as gone.
|
|
165
|
+
*
|
|
166
|
+
* @param {number} pgid
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
function defaultGroupAlive(pgid) {
|
|
170
|
+
if (!isSignalablePgid(pgid)) return false;
|
|
171
|
+
try {
|
|
172
|
+
process.kill(-pgid, 0);
|
|
173
|
+
return true;
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return err?.code === 'EPERM';
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
145
179
|
/**
|
|
146
180
|
* Is `pgid` a value that may be NEGATED and handed to `kill(2)`?
|
|
147
181
|
*
|
|
@@ -197,12 +231,12 @@ function isValidLedgerRecord(parsed) {
|
|
|
197
231
|
* same binary with different arguments without copying a command line — which
|
|
198
232
|
* may carry paths or tokens — into a ledger.
|
|
199
233
|
*
|
|
200
|
-
* @param {string} cmd Full command line.
|
|
234
|
+
* @param {string|string[]} cmd Full command line or argv.
|
|
201
235
|
* @returns {string} e.g. `npm:6f1c4e0a9b2d7e35`. Empty input yields `:<hash of "">`.
|
|
202
236
|
*/
|
|
203
237
|
export function buildCommandSignature(cmd) {
|
|
204
|
-
const full = String(cmd ?? '');
|
|
205
|
-
const firstToken = full.trim().split(/\s+/)[0] ?? '';
|
|
238
|
+
const full = Array.isArray(cmd) ? JSON.stringify(cmd) : String(cmd ?? '');
|
|
239
|
+
const firstToken = Array.isArray(cmd) ? (cmd[0] ?? '') : (full.trim().split(/\s+/)[0] ?? '');
|
|
206
240
|
const hash = createHash('sha256').update(full, 'utf8').digest('hex').slice(0, 16);
|
|
207
241
|
return `${firstToken}:${hash}`;
|
|
208
242
|
}
|
|
@@ -242,12 +276,19 @@ export function installExitHandler() {
|
|
|
242
276
|
|
|
243
277
|
process.once('exit', () => {
|
|
244
278
|
killAllLiveGroupsSync();
|
|
279
|
+
runExitCleanupsSync();
|
|
245
280
|
});
|
|
246
281
|
|
|
247
282
|
for (const signal of EXIT_SIGNALS) {
|
|
248
283
|
const handler = () => {
|
|
249
284
|
killAllLiveGroupsSync();
|
|
250
285
|
if (process.listenerCount(signal) === 0) {
|
|
286
|
+
// SIGKILL is sent, not awaited (#1723): a child can still write for a
|
|
287
|
+
// few milliseconds while the callbacks remove its files, so a callback
|
|
288
|
+
// that deletes a tree retries (snapshot-probe: removeTreeOnExit).
|
|
289
|
+
// Only on this branch: with a foreign listener the process may live on,
|
|
290
|
+
// and its own `exit` (if any) still runs the callbacks then.
|
|
291
|
+
runExitCleanupsSync();
|
|
251
292
|
// We were the only listener: restore the default disposition so the
|
|
252
293
|
// process still dies from the signal it was sent.
|
|
253
294
|
process.kill(process.pid, signal);
|
|
@@ -257,6 +298,52 @@ export function installExitHandler() {
|
|
|
257
298
|
}
|
|
258
299
|
}
|
|
259
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Register a synchronous callback that runs once when the process leaves via
|
|
303
|
+
* `exit` or via a SIGINT/SIGTERM/SIGHUP this module handles — after SIGKILL was
|
|
304
|
+
* sent to every live group (sent, not awaited: a child may still be writing
|
|
305
|
+
* for a few milliseconds, #1723), before the signal is re-raised. Installs the exit handler
|
|
306
|
+
* itself, so a process that never spawned a group is covered too.
|
|
307
|
+
*
|
|
308
|
+
* Use it for resources the caller created and would otherwise remove only in a
|
|
309
|
+
* `finally` that a signal skips (e.g. its own `mkdtemp` directory, #1715).
|
|
310
|
+
* Errors a callback throws are swallowed: the exit path must not throw.
|
|
311
|
+
*
|
|
312
|
+
* Named ceiling (BV-004): SIGKILL or any other uncatchable death runs no
|
|
313
|
+
* callback, and an async callback's continuation never runs (the process is
|
|
314
|
+
* already exiting). Revisit if a caller needs async teardown.
|
|
315
|
+
*
|
|
316
|
+
* @param {() => void} fn synchronous cleanup
|
|
317
|
+
* @returns {() => void} unregister — call it once the caller cleaned up itself
|
|
318
|
+
*/
|
|
319
|
+
export function registerExitCleanup(fn) {
|
|
320
|
+
if (typeof fn !== 'function') throw new TypeError('registerExitCleanup: fn must be a function');
|
|
321
|
+
installExitHandler();
|
|
322
|
+
// A wrapper per registration: the same fn registered twice stays two entries.
|
|
323
|
+
const entry = () => fn();
|
|
324
|
+
EXIT_CLEANUPS.add(entry);
|
|
325
|
+
return () => {
|
|
326
|
+
EXIT_CLEANUPS.delete(entry);
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Run and drop every registered cleanup, each at most once, swallowing errors.
|
|
332
|
+
*
|
|
333
|
+
* @returns {void}
|
|
334
|
+
*/
|
|
335
|
+
function runExitCleanupsSync() {
|
|
336
|
+
const entries = [...EXIT_CLEANUPS];
|
|
337
|
+
EXIT_CLEANUPS.clear();
|
|
338
|
+
for (const entry of entries) {
|
|
339
|
+
try {
|
|
340
|
+
entry();
|
|
341
|
+
} catch {
|
|
342
|
+
/* the exit path must never throw */
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
260
347
|
/**
|
|
261
348
|
* SIGKILL every registered group, synchronously, swallowing every error.
|
|
262
349
|
* Deliberately not exported: it is the exit path, not part of the interface.
|
|
@@ -267,7 +354,10 @@ function killAllLiveGroupsSync() {
|
|
|
267
354
|
for (const [pgid, entry] of LIVE_GROUPS) {
|
|
268
355
|
if (!isSignalablePgid(pgid)) continue;
|
|
269
356
|
try {
|
|
270
|
-
entry.
|
|
357
|
+
if (entry.platform === 'win32') {
|
|
358
|
+
const result = killWindowsProcessTree(pgid, entry);
|
|
359
|
+
if (!result.ok) process.stderr.write(`process-group: Windows exit cleanup: ${result.error ?? `survivors ${result.survivors}`}\n`);
|
|
360
|
+
} else entry.killFn(-pgid, 'SIGKILL');
|
|
271
361
|
} catch {
|
|
272
362
|
/* already gone, or not ours — nothing to do at exit time */
|
|
273
363
|
}
|
|
@@ -316,14 +406,20 @@ function killAllLiveGroupsSync() {
|
|
|
316
406
|
* @param {number} pgid Process-group id (positive; the negation happens here).
|
|
317
407
|
* @param {object} [opts]
|
|
318
408
|
* @param {(target: number, signal: string) => unknown} [opts.killFn] Signal seam. Tests ALWAYS inject.
|
|
409
|
+
* @param {string} [opts.platform]
|
|
410
|
+
* @param {Function} [opts.spawnSyncFn]
|
|
411
|
+
* @param {Function} [opts.snapshotFn]
|
|
412
|
+
* @param {boolean} [opts.verifyTree] - Skip Windows CIM measurement for short-budget hooks.
|
|
413
|
+
* @param {object} [opts.child] - Owned ChildProcess (Windows identity).
|
|
414
|
+
* @param {number} [opts.spawnedAtMs] - Wall-clock timestamp immediately before spawn.
|
|
319
415
|
* @param {number} [opts.killGraceMs]
|
|
320
416
|
* @param {number} [opts.verifyWaitMs]
|
|
321
417
|
* @param {(pid: number) => boolean} [opts.isAliveFn] Liveness probe on the group leader.
|
|
322
418
|
* @param {(ms: number) => Promise<void>} [opts.sleepFn]
|
|
323
419
|
* @param {((signal: string) => boolean|Promise<boolean>)|null} [opts.beforeSignal] Consulted
|
|
324
420
|
* immediately before EVERY signal. Anything but `true` aborts the rest of the ladder.
|
|
325
|
-
* @returns {Promise<{ok: boolean, signalsSent: string[], survivors: number[],
|
|
326
|
-
* error:
|
|
421
|
+
* @returns {Promise<{ok: boolean, signalsSent: string[], survivors: number[]|null,
|
|
422
|
+
* error: string|null, aborted: string|null}>}
|
|
327
423
|
* `signalsSent` records ATTEMPTS in order, including one that threw.
|
|
328
424
|
*/
|
|
329
425
|
export async function killProcessGroup(pgid, {
|
|
@@ -333,6 +429,12 @@ export async function killProcessGroup(pgid, {
|
|
|
333
429
|
isAliveFn = defaultIsAlive,
|
|
334
430
|
sleepFn = defaultSleep,
|
|
335
431
|
beforeSignal = null,
|
|
432
|
+
platform = process.platform,
|
|
433
|
+
spawnSyncFn,
|
|
434
|
+
snapshotFn,
|
|
435
|
+
verifyTree = true,
|
|
436
|
+
child,
|
|
437
|
+
spawnedAtMs,
|
|
336
438
|
} = {}) {
|
|
337
439
|
/** @type {string[]} */
|
|
338
440
|
const signalsSent = [];
|
|
@@ -359,6 +461,11 @@ export async function killProcessGroup(pgid, {
|
|
|
359
461
|
ok: false, signalsSent, survivors: [], error: null, aborted: signal,
|
|
360
462
|
});
|
|
361
463
|
|
|
464
|
+
if (platform === 'win32') {
|
|
465
|
+
if (!(await permitted('taskkill'))) return aborted('taskkill');
|
|
466
|
+
return killWindowsProcessTree(pgid, { spawnSyncFn, snapshotFn, child, spawnedAtMs, verifyTree });
|
|
467
|
+
}
|
|
468
|
+
|
|
362
469
|
if (!(await permitted('SIGTERM'))) return aborted('SIGTERM');
|
|
363
470
|
|
|
364
471
|
signalsSent.push('SIGTERM');
|
|
@@ -438,14 +545,17 @@ export async function killProcessGroup(pgid, {
|
|
|
438
545
|
* @param {object} [opts]
|
|
439
546
|
* @param {{pid?: number, ppid?: number, rssKb?: number, etimeSeconds?: number|null, cpuPct?: number, args?: string|null}|null} [opts.snapshotLine]
|
|
440
547
|
* @param {number} [opts.nowMs]
|
|
548
|
+
* @param {string} [opts.platform]
|
|
441
549
|
* @param {number} [opts.toleranceMs=2000]
|
|
442
|
-
* @returns {{match: boolean, reason:
|
|
550
|
+
* @returns {{match: boolean, reason: string, observed: {etimeSeconds: number|null, args: string|null}}}
|
|
443
551
|
*/
|
|
444
552
|
export function verifyProcessIdentity(pid, expected, {
|
|
445
553
|
snapshotLine = null,
|
|
446
554
|
nowMs = Date.now(),
|
|
447
555
|
toleranceMs = 2000,
|
|
556
|
+
platform = process.platform,
|
|
448
557
|
} = {}) {
|
|
558
|
+
if (platform === 'win32') return { match: false, reason: 'nicht messbar auf win32', observed: { etimeSeconds: null, args: null } };
|
|
449
559
|
const gone = { match: false, reason: /** @type {const} */ ('gone'), observed: { etimeSeconds: null, args: null } };
|
|
450
560
|
if (!snapshotLine || typeof snapshotLine !== 'object') return gone;
|
|
451
561
|
// A row for a different PID describes a different process, not this one.
|
|
@@ -529,12 +639,20 @@ function readLedgerNoFollow(target) {
|
|
|
529
639
|
* @param {string} repoRoot
|
|
530
640
|
* @param {GateProcessRecord} record
|
|
531
641
|
* @param {object} [opts]
|
|
642
|
+
* @param {string} [opts.platform]
|
|
532
643
|
* @param {(filePath: string, line: string) => void} [opts.appendFn] Sink seam. When
|
|
533
644
|
* supplied, the parent directory is NOT created — the caller owns its sink, and
|
|
534
645
|
* mkdir on a synthetic repoRoot would materialise a directory in a test.
|
|
535
646
|
* @returns {void}
|
|
536
647
|
*/
|
|
537
|
-
export function recordGateProcess(repoRoot, record, { appendFn } = {}) {
|
|
648
|
+
export function recordGateProcess(repoRoot, record, { appendFn, platform = process.platform } = {}) {
|
|
649
|
+
// win32: no ledger. Its only reader is the orphan reaper, which is POSIX-only
|
|
650
|
+
// (identity checks need `ps`), and the no-follow append has no Windows
|
|
651
|
+
// equivalent (O_NOFOLLOW is undefined there). A warning here fired on EVERY
|
|
652
|
+
// gate run on Windows (measured 2026-10-14, #1612) — noise, not a signal.
|
|
653
|
+
// Readers still report `state: 'unreadable'` on win32, so nothing reads this
|
|
654
|
+
// absence as an empty ledger. Revisit when a Windows reaper exists.
|
|
655
|
+
if (platform === 'win32') return;
|
|
538
656
|
const target = ledgerPathFor(repoRoot);
|
|
539
657
|
try {
|
|
540
658
|
if (!appendFn) mkdirSync(path.dirname(target), { recursive: true });
|
|
@@ -572,9 +690,10 @@ export function recordGateProcess(repoRoot, record, { appendFn } = {}) {
|
|
|
572
690
|
* @param {(filePath: string) => string} [opts.readFn] Reader seam; must throw or
|
|
573
691
|
* return '' for a missing file.
|
|
574
692
|
* @param {number} [opts.nowMs]
|
|
693
|
+
* @param {string} [opts.platform]
|
|
575
694
|
* @param {number} [opts.maxAgeMs]
|
|
576
695
|
* @returns {{records: GateProcessRecord[], malformedLines: number, expired: number,
|
|
577
|
-
* state: 'read'|'absent'|'unreadable'}}
|
|
696
|
+
* state: 'read'|'absent'|'unreadable', error?: string}}
|
|
578
697
|
* `records` are the fresh, parseable entries in source order; empty unless
|
|
579
698
|
* `state` is `read`.
|
|
580
699
|
*/
|
|
@@ -582,7 +701,9 @@ export function readGateProcessLedger(repoRoot, {
|
|
|
582
701
|
readFn,
|
|
583
702
|
nowMs = Date.now(),
|
|
584
703
|
maxAgeMs = DEFAULT_LEDGER_MAX_AGE_MS,
|
|
704
|
+
platform = process.platform,
|
|
585
705
|
} = {}) {
|
|
706
|
+
if (platform === 'win32') return { records: [], malformedLines: 0, expired: 0, state: 'unreadable', error: 'nicht messbar auf win32' };
|
|
586
707
|
const target = ledgerPathFor(repoRoot);
|
|
587
708
|
let raw;
|
|
588
709
|
try {
|
|
@@ -691,6 +812,7 @@ export function replaceRegularFile(target, body, io = { lstatSync, writeFileSync
|
|
|
691
812
|
* @param {string} repoRoot
|
|
692
813
|
* @param {object} [opts]
|
|
693
814
|
* @param {number} [opts.nowMs]
|
|
815
|
+
* @param {string} [opts.platform]
|
|
694
816
|
* @param {number} [opts.maxAgeMs]
|
|
695
817
|
* @param {{existsSync: Function, readFileSync: Function, writeFileSync: Function,
|
|
696
818
|
* lstatSync: Function, renameSync: Function, unlinkSync: Function}} [opts.fs] fs seam;
|
|
@@ -701,8 +823,11 @@ export function replaceRegularFile(target, body, io = { lstatSync, writeFileSync
|
|
|
701
823
|
export function pruneGateProcessLedger(repoRoot, {
|
|
702
824
|
nowMs = Date.now(),
|
|
703
825
|
maxAgeMs = DEFAULT_LEDGER_MAX_AGE_MS,
|
|
826
|
+
platform = process.platform,
|
|
704
827
|
fs: fsSeam,
|
|
705
828
|
} = {}) {
|
|
829
|
+
// win32 keeps no ledger (see recordGateProcess), so there is nothing to prune.
|
|
830
|
+
if (platform === 'win32') return 0;
|
|
706
831
|
const io = fsSeam ?? {
|
|
707
832
|
existsSync,
|
|
708
833
|
readFileSync: readLedgerNoFollow,
|
|
@@ -778,12 +903,28 @@ export function pruneGateProcessLedger(repoRoot, {
|
|
|
778
903
|
* later, at a pgid `finish()` already deregistered — the exact late signal at a
|
|
779
904
|
* possibly-recycled group id this module exists to prevent.
|
|
780
905
|
*
|
|
781
|
-
*
|
|
906
|
+
* Normal-exit path (POSIX, #1704): `close` proves only that every member closed
|
|
907
|
+
* the PIPE — a member that redirected its stdio away (`sleep 30 >/dev/null 2>&1 &`)
|
|
908
|
+
* outlives a command that exited 0. On a normal close the GROUP is probed once
|
|
909
|
+
* (`kill(-pgid, 0)`); gone → resolve at once, no added latency. Still alive →
|
|
910
|
+
* the same ladder runs against it and the promise resolves only after it, so the
|
|
911
|
+
* report is accurate: added latency at most `killGraceMs + verifyWaitMs + 1000`
|
|
912
|
+
* (the same hard deadline). The finding goes into the additive `leftoverGroup`
|
|
913
|
+
* field — never into `survivors`/`killSignals`, whose consumers read them as
|
|
914
|
+
* "the run's own ladder fired / something outlived SIGKILL" (pre-push
|
|
915
|
+
* `cleanRerun`, the gate envelopes) — and pass/fail is NOT changed:
|
|
916
|
+
* measure first (HR-105), making it red is a later decision.
|
|
917
|
+
*
|
|
918
|
+
* @param {string|string[]} cmd Shell command or [executable, ...args]. Executable configuration, not data —
|
|
782
919
|
* see `.claude/rules/security.md` § Session Config Command Trust.
|
|
783
920
|
* @param {object} [opts]
|
|
784
921
|
* @param {string} [opts.cwd]
|
|
785
922
|
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
786
923
|
* @param {number|null} [opts.timeoutMs] null disables the clock (the byte cap still applies).
|
|
924
|
+
* @param {string} [opts.platform]
|
|
925
|
+
* @param {Function} [opts.spawnSyncFn]
|
|
926
|
+
* @param {Function} [opts.snapshotFn]
|
|
927
|
+
* @param {boolean} [opts.verifyTree] - Skip Windows CIM measurement for short-budget hooks.
|
|
787
928
|
* @param {number} [opts.killGraceMs]
|
|
788
929
|
* @param {number} [opts.verifyWaitMs]
|
|
789
930
|
* @param {number} [opts.maxOutputBytes]
|
|
@@ -791,6 +932,10 @@ export function pruneGateProcessLedger(repoRoot, {
|
|
|
791
932
|
* @param {(target: number, signal: string) => unknown} [opts.killFn] Tests ALWAYS inject.
|
|
792
933
|
* @param {() => number} [opts.now]
|
|
793
934
|
* @param {(pid: number) => boolean} [opts.isAliveFn]
|
|
935
|
+
* @param {((pgid: number) => boolean)|null} [opts.groupAliveFn] Group probe for the
|
|
936
|
+
* normal-exit path. Defaults to {@link defaultGroupAlive} ONLY while `killFn` is
|
|
937
|
+
* the real one; with an injected `killFn` and no injected probe the normal-exit
|
|
938
|
+
* probe is skipped, so a test's fake pid never reaches the real kernel.
|
|
794
939
|
* @param {(ms: number) => Promise<void>} [opts.sleepFn]
|
|
795
940
|
* @param {(record: GateProcessRecord) => void} [opts.onRegister] Defaults to
|
|
796
941
|
* {@link recordGateProcess} when — and only when — `repoRoot` is given.
|
|
@@ -798,11 +943,14 @@ export function pruneGateProcessLedger(repoRoot, {
|
|
|
798
943
|
* @param {string} [opts.commandSignature] Defaults to {@link buildCommandSignature}.
|
|
799
944
|
* @param {string|null} [opts.sessionId]
|
|
800
945
|
* @returns {Promise<{exitCode: number, output: string, fullOutput: string, timedOut: boolean,
|
|
801
|
-
* overflow: boolean, killSignals: string[], survivors: number[], pid: number, pgid: number,
|
|
802
|
-
* durationMs: number
|
|
946
|
+
* overflow: boolean, killSignals: string[], survivors: number[]|null, pid: number, pgid: number,
|
|
947
|
+
* durationMs: number, ok?: boolean, error?: string|null,
|
|
948
|
+
* leftoverGroup: {pgid: number, signals: string[], survivors: number[]}|null}>}
|
|
803
949
|
* `exitCode` is 124 on timeout, 1 on overflow or spawn failure, else the child's own code.
|
|
804
950
|
* `output` is the last ~50 lines of `fullOutput`. `survivors` are PIDs still alive after
|
|
805
951
|
* SIGKILL + `verifyWaitMs` — a non-empty list is a REPORTED failure, never a silent one.
|
|
952
|
+
* `leftoverGroup` is null unless the group was still alive after a NORMAL close; then it
|
|
953
|
+
* carries the signals sent to it and what survived them.
|
|
806
954
|
*/
|
|
807
955
|
export function spawnInGroup(cmd, {
|
|
808
956
|
cwd,
|
|
@@ -815,13 +963,19 @@ export function spawnInGroup(cmd, {
|
|
|
815
963
|
killFn = defaultKill,
|
|
816
964
|
now = Date.now,
|
|
817
965
|
isAliveFn = defaultIsAlive,
|
|
966
|
+
groupAliveFn,
|
|
818
967
|
sleepFn = defaultSleep,
|
|
819
968
|
onRegister,
|
|
820
969
|
repoRoot,
|
|
821
970
|
commandSignature,
|
|
822
971
|
sessionId = null,
|
|
972
|
+
platform = process.platform,
|
|
973
|
+
spawnSyncFn,
|
|
974
|
+
snapshotFn,
|
|
975
|
+
verifyTree = true,
|
|
823
976
|
} = {}) {
|
|
824
977
|
installExitHandler();
|
|
978
|
+
const groupAlive = groupAliveFn ?? (killFn === defaultKill ? defaultGroupAlive : null);
|
|
825
979
|
|
|
826
980
|
return new Promise((resolve) => {
|
|
827
981
|
const startedAt = now();
|
|
@@ -832,13 +986,22 @@ export function spawnInGroup(cmd, {
|
|
|
832
986
|
/** @type {string[]} */
|
|
833
987
|
const killSignals = [];
|
|
834
988
|
let fullOutput = '';
|
|
989
|
+
const stdoutDecoder = new StringDecoder('utf8');
|
|
990
|
+
const stderrDecoder = new StringDecoder('utf8');
|
|
991
|
+
let cleanupError = null;
|
|
835
992
|
let capturedBytes = 0;
|
|
836
993
|
let timedOut = false;
|
|
837
994
|
let overflow = false;
|
|
838
995
|
let settled = false;
|
|
839
996
|
let ladderStarted = false;
|
|
840
|
-
|
|
997
|
+
// The signal that ended the child, from `close` (#1673): `exitCode` maps a
|
|
998
|
+
// signal end to 1, so without this field it reads like a normal failure.
|
|
999
|
+
/** @type {string|null} */
|
|
1000
|
+
let exitSignal = null;
|
|
1001
|
+
/** @type {number[]|null} */
|
|
841
1002
|
let survivors = [];
|
|
1003
|
+
/** @type {{pgid: number, signals: string[], survivors: number[]}|null} */
|
|
1004
|
+
let leftoverGroup = null;
|
|
842
1005
|
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
843
1006
|
let timeoutTimer = null;
|
|
844
1007
|
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
@@ -847,6 +1010,7 @@ export function spawnInGroup(cmd, {
|
|
|
847
1010
|
const finish = (childExitCode) => {
|
|
848
1011
|
if (settled) return;
|
|
849
1012
|
settled = true;
|
|
1013
|
+
fullOutput += stdoutDecoder.end() + stderrDecoder.end();
|
|
850
1014
|
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
851
1015
|
if (deadlineTimer) clearTimeout(deadlineTimer);
|
|
852
1016
|
if (pgid !== null) LIVE_GROUPS.delete(pgid);
|
|
@@ -862,25 +1026,28 @@ export function spawnInGroup(cmd, {
|
|
|
862
1026
|
timedOut,
|
|
863
1027
|
overflow,
|
|
864
1028
|
killSignals,
|
|
1029
|
+
exitSignal,
|
|
865
1030
|
survivors,
|
|
1031
|
+
leftoverGroup,
|
|
866
1032
|
pid: pid ?? -1,
|
|
867
1033
|
pgid: pgid ?? -1,
|
|
868
1034
|
durationMs: now() - startedAt,
|
|
1035
|
+
...(platform === 'win32' && ladderStarted ? { ok: cleanupError === null && survivors?.length === 0, error: cleanupError } : {}),
|
|
869
1036
|
});
|
|
870
1037
|
};
|
|
871
1038
|
|
|
872
1039
|
let child;
|
|
1040
|
+
let spawnedAtMs;
|
|
873
1041
|
try {
|
|
874
|
-
//
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
cwd,
|
|
879
|
-
env,
|
|
880
|
-
shell: true,
|
|
881
|
-
detached: true,
|
|
1042
|
+
// Configured strings keep shell semantics; internal argv bypasses quoting.
|
|
1043
|
+
const options = {
|
|
1044
|
+
cwd, env, shell: !Array.isArray(cmd),
|
|
1045
|
+
...(platform === 'win32' ? { windowsHide: true } : { detached: true }),
|
|
882
1046
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
883
|
-
}
|
|
1047
|
+
};
|
|
1048
|
+
spawnedAtMs = Date.now();
|
|
1049
|
+
// nosemgrep: unsafe-shell-spawn
|
|
1050
|
+
child = Array.isArray(cmd) ? spawnFn(cmd[0], cmd.slice(1), options) : spawnFn(cmd, options);
|
|
884
1051
|
} catch (err) {
|
|
885
1052
|
fullOutput = `process-group: failed to spawn "${cmd}": ${err?.message ?? String(err)}`;
|
|
886
1053
|
finish(1);
|
|
@@ -893,7 +1060,7 @@ export function spawnInGroup(cmd, {
|
|
|
893
1060
|
pgid = pid;
|
|
894
1061
|
|
|
895
1062
|
if (pgid !== null) {
|
|
896
|
-
LIVE_GROUPS.set(pgid, { killFn });
|
|
1063
|
+
LIVE_GROUPS.set(pgid, { killFn, platform, spawnSyncFn, snapshotFn, child, spawnedAtMs, verifyTree });
|
|
897
1064
|
/** @type {GateProcessRecord} */
|
|
898
1065
|
const record = {
|
|
899
1066
|
pid,
|
|
@@ -903,7 +1070,7 @@ export function spawnInGroup(cmd, {
|
|
|
903
1070
|
sessionId: sessionId ?? null,
|
|
904
1071
|
recordedAt: new Date(startedAt).toISOString(),
|
|
905
1072
|
};
|
|
906
|
-
const register = onRegister ?? (repoRoot ? (r) => recordGateProcess(repoRoot, r) : null);
|
|
1073
|
+
const register = onRegister ?? (repoRoot ? (r) => recordGateProcess(repoRoot, r, { platform }) : null);
|
|
907
1074
|
if (register) {
|
|
908
1075
|
try {
|
|
909
1076
|
register(record);
|
|
@@ -921,7 +1088,7 @@ export function spawnInGroup(cmd, {
|
|
|
921
1088
|
// for.
|
|
922
1089
|
if (!onRegister && repoRoot) {
|
|
923
1090
|
try {
|
|
924
|
-
pruneGateProcessLedger(repoRoot);
|
|
1091
|
+
pruneGateProcessLedger(repoRoot, { platform });
|
|
925
1092
|
} catch {
|
|
926
1093
|
/* housekeeping, never a gate precondition */
|
|
927
1094
|
}
|
|
@@ -943,13 +1110,14 @@ export function spawnInGroup(cmd, {
|
|
|
943
1110
|
// Hard deadline: `close` waits for EVERY group member to close the shared
|
|
944
1111
|
// pipe, so one survivor would hang this promise forever.
|
|
945
1112
|
deadlineTimer = setTimeout(() => {
|
|
946
|
-
if (survivors
|
|
1113
|
+
if (survivors?.length === 0 && pgid !== null) survivors = [pgid];
|
|
947
1114
|
finish(null);
|
|
948
1115
|
}, killGraceMs + verifyWaitMs + 1000);
|
|
949
1116
|
deadlineTimer.unref?.();
|
|
950
1117
|
|
|
951
1118
|
killProcessGroup(pgid, {
|
|
952
1119
|
killFn: trackingKill,
|
|
1120
|
+
platform, spawnSyncFn, snapshotFn, child, spawnedAtMs, verifyTree,
|
|
953
1121
|
killGraceMs,
|
|
954
1122
|
verifyWaitMs,
|
|
955
1123
|
isAliveFn,
|
|
@@ -962,17 +1130,67 @@ export function spawnInGroup(cmd, {
|
|
|
962
1130
|
})
|
|
963
1131
|
.then((res) => {
|
|
964
1132
|
survivors = res.survivors;
|
|
1133
|
+
if (platform === 'win32') {
|
|
1134
|
+
killSignals.push(...res.signalsSent);
|
|
1135
|
+
cleanupError = res.error ?? (res.aborted ? `aborted: ${res.aborted}` : null);
|
|
1136
|
+
if (cleanupError) fullOutput += `\nprocess-group: ${cleanupError}\n`;
|
|
1137
|
+
finish(null);
|
|
1138
|
+
}
|
|
965
1139
|
if (!res.ok) finish(null);
|
|
966
1140
|
})
|
|
967
|
-
.catch(() => {
|
|
1141
|
+
.catch((err) => {
|
|
1142
|
+
if (platform === 'win32') {
|
|
1143
|
+
survivors = null;
|
|
1144
|
+
cleanupError = `nicht messbar auf win32: ${err}`;
|
|
1145
|
+
fullOutput += `\nprocess-group: ${cleanupError}\n`;
|
|
1146
|
+
}
|
|
968
1147
|
finish(null);
|
|
969
1148
|
});
|
|
970
1149
|
};
|
|
971
1150
|
|
|
972
|
-
|
|
1151
|
+
// Normal-exit leftover (#1704): the leader closed, but the group still has a
|
|
1152
|
+
// member. Same ladder, same `beforeSignal` gate and hard deadline as
|
|
1153
|
+
// `runLadder`, but its own record: the run itself stays a normal exit.
|
|
1154
|
+
const reapLeftoverGroup = (childExitCode) => {
|
|
1155
|
+
// Marks the group as being reaped, so a timeout firing during the grace
|
|
1156
|
+
// window cannot start a second ladder (or flip the run to 124).
|
|
1157
|
+
ladderStarted = true;
|
|
1158
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
1159
|
+
const record = { pgid, signals: [], survivors: [] };
|
|
1160
|
+
leftoverGroup = record;
|
|
1161
|
+
deadlineTimer = setTimeout(() => {
|
|
1162
|
+
if (record.survivors.length === 0) record.survivors = [pgid];
|
|
1163
|
+
finish(childExitCode);
|
|
1164
|
+
}, killGraceMs + verifyWaitMs + 1000);
|
|
1165
|
+
deadlineTimer.unref?.();
|
|
1166
|
+
|
|
1167
|
+
killProcessGroup(pgid, {
|
|
1168
|
+
killFn: (target, signal) => {
|
|
1169
|
+
record.signals.push(signal);
|
|
1170
|
+
return killFn(target, signal);
|
|
1171
|
+
},
|
|
1172
|
+
platform, killGraceMs, verifyWaitMs, sleepFn,
|
|
1173
|
+
// The leader is already reaped, so the leader-pid probe would read the
|
|
1174
|
+
// group as dead; verify on the GROUP instead.
|
|
1175
|
+
isAliveFn: groupAlive,
|
|
1176
|
+
// Settled only via the hard deadline here; after that the pgid is
|
|
1177
|
+
// deregistered and may be recycled, so no further signal (see runLadder).
|
|
1178
|
+
beforeSignal: () => !settled,
|
|
1179
|
+
})
|
|
1180
|
+
.then((res) => {
|
|
1181
|
+
if (!settled) record.survivors = Array.isArray(res.survivors) ? [...res.survivors] : [pgid];
|
|
1182
|
+
})
|
|
1183
|
+
.catch(() => {
|
|
1184
|
+
if (!settled) record.survivors = [pgid];
|
|
1185
|
+
})
|
|
1186
|
+
.finally(() => finish(childExitCode));
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
const onChunk = (decoder, chunk) => {
|
|
973
1190
|
if (overflow) return;
|
|
974
|
-
const
|
|
975
|
-
|
|
1191
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1192
|
+
const text = decoder.write(bytes);
|
|
1193
|
+
capturedBytes += bytes.length;
|
|
976
1194
|
fullOutput += text;
|
|
977
1195
|
if (capturedBytes > maxOutputBytes) {
|
|
978
1196
|
overflow = true;
|
|
@@ -980,8 +1198,8 @@ export function spawnInGroup(cmd, {
|
|
|
980
1198
|
runLadder();
|
|
981
1199
|
}
|
|
982
1200
|
};
|
|
983
|
-
child.stdout?.on('data', onChunk);
|
|
984
|
-
child.stderr?.on('data', onChunk);
|
|
1201
|
+
child.stdout?.on('data', (chunk) => onChunk(stdoutDecoder, chunk));
|
|
1202
|
+
child.stderr?.on('data', (chunk) => onChunk(stderrDecoder, chunk));
|
|
985
1203
|
|
|
986
1204
|
if (typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
987
1205
|
timeoutTimer = setTimeout(() => {
|
|
@@ -995,7 +1213,25 @@ export function spawnInGroup(cmd, {
|
|
|
995
1213
|
fullOutput += `\nprocess-group: child error: ${err?.message ?? String(err)}`;
|
|
996
1214
|
finish(1);
|
|
997
1215
|
});
|
|
998
|
-
child.on('close', (code) =>
|
|
1216
|
+
child.on('close', (code, signal) => {
|
|
1217
|
+
exitSignal = typeof signal === 'string' ? signal : null;
|
|
1218
|
+
// Windows cleanup must finish readback even if taskkill already closed pipes.
|
|
1219
|
+
if (platform === 'win32' && ladderStarted) return;
|
|
1220
|
+
const childExitCode = typeof code === 'number' ? code : null;
|
|
1221
|
+
// POSIX only: Windows has no process group to probe (its tree cleanup is
|
|
1222
|
+
// a separate path) and keeps its behaviour. One synchronous probe — when
|
|
1223
|
+
// the group is gone, the caller pays nothing extra.
|
|
1224
|
+
// Named ceiling (BV-004): the probe runs right after the leader was
|
|
1225
|
+
// reaped; a pgid recycled onto a foreign session leader inside that gap
|
|
1226
|
+
// would be probed and laddered. Needs the pid space to wrap within
|
|
1227
|
+
// milliseconds; revisit with an identity check (verifyProcessIdentity on
|
|
1228
|
+
// a group member) if a leftover is ever reported for a foreign command.
|
|
1229
|
+
if (!settled && !ladderStarted && platform !== 'win32' && pgid !== null && groupAlive?.(pgid) === true) {
|
|
1230
|
+
reapLeftoverGroup(childExitCode);
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
finish(childExitCode);
|
|
1234
|
+
});
|
|
999
1235
|
});
|
|
1000
1236
|
}
|
|
1001
1237
|
|
|
@@ -796,6 +796,31 @@ export function checkEnvDocumentation(repoRoot) {
|
|
|
796
796
|
};
|
|
797
797
|
}
|
|
798
798
|
|
|
799
|
+
/** The session ledger the orchestrator appends to on every close. */
|
|
800
|
+
const SESSIONS_LEDGER_REL = '.orchestrator/metrics/sessions.jsonl';
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* H6 — Tracked session ledger (#1666).
|
|
804
|
+
*
|
|
805
|
+
* A repo that TRACKS `.orchestrator/metrics/sessions.jsonl` gets one commit-worthy
|
|
806
|
+
* diff per session close, and every linked worktree checks out its own copy —
|
|
807
|
+
* where foreign records landed in #1660. The hint is text only; nothing here
|
|
808
|
+
* runs it. `fixable: false`: untracking changes what the repo shares with
|
|
809
|
+
* every clone, an owner decision, not batch work.
|
|
810
|
+
*
|
|
811
|
+
* @param {string} repoRoot
|
|
812
|
+
* @returns {object|null}
|
|
813
|
+
*/
|
|
814
|
+
export function checkTrackedSessionsLedger(repoRoot) {
|
|
815
|
+
// `--error-unmatch` exits non-zero (→ null) when the path is not tracked.
|
|
816
|
+
if (git(['ls-files', '--error-unmatch', '--', SESSIONS_LEDGER_REL], repoRoot) === null) return null;
|
|
817
|
+
return {
|
|
818
|
+
check: 'tracked-sessions-ledger',
|
|
819
|
+
fixable: false,
|
|
820
|
+
message: `${SESSIONS_LEDGER_REL} is tracked in git — every session close dirties the tree and each worktree carries its own copy; untrack it: \`git rm --cached ${SESSIONS_LEDGER_REL}\` and add it to .gitignore`,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
799
824
|
/**
|
|
800
825
|
* Aggregate the project-hygiene family into one banner result.
|
|
801
826
|
*
|
|
@@ -831,6 +856,9 @@ export function checkProjectHygiene({ repoRoot, now = Date.now(), thresholds = {
|
|
|
831
856
|
|
|
832
857
|
const env = checkEnvDocumentation(repoRoot);
|
|
833
858
|
if (env) findings.push(env);
|
|
859
|
+
|
|
860
|
+
const ledger = checkTrackedSessionsLedger(repoRoot);
|
|
861
|
+
if (ledger) findings.push(ledger);
|
|
834
862
|
} catch {
|
|
835
863
|
// Probe family is advisory — a partial result beats a crashed session-start.
|
|
836
864
|
if (findings.length === 0) return null;
|