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
|
@@ -80,15 +80,45 @@ function tailOf(text) {
|
|
|
80
80
|
* SIGKILL (PRD B6: a sent signal proves nothing).
|
|
81
81
|
*
|
|
82
82
|
* @param {number} timeoutMs
|
|
83
|
-
* @param {{pgid: number, killSignals: string[], survivors: number[]}} run
|
|
83
|
+
* @param {{pgid: number, killSignals: string[], survivors: number[]|null, error?: string}} run
|
|
84
84
|
* @returns {string}
|
|
85
85
|
*/
|
|
86
86
|
function timeoutLine(timeoutMs, run) {
|
|
87
87
|
const ladder = (run.killSignals ?? []).join('\u2192') || 'no signal sent';
|
|
88
88
|
return `gate: TIMEOUT after ${timeoutMs} ms \u2014 process group ${run.pgid} ${ladder}, `
|
|
89
|
-
+
|
|
89
|
+
+ (run.survivors === null
|
|
90
|
+
? `Überlebende nicht messbar: ${run.error}`
|
|
91
|
+
: `survivors: [${(run.survivors ?? []).join(', ')}]`);
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
const LEFTOVER_GROUP_PREFIX = 'gate: reaped process group ';
|
|
95
|
+
const LEFTOVER_GROUP_INFIX = ' left running after exit: ';
|
|
96
|
+
const LEFTOVER_NO_SIGNAL = 'no signal sent';
|
|
97
|
+
/**
|
|
98
|
+
* The line that names a process group still alive after a NORMAL exit and
|
|
99
|
+
* reaped by {@link spawnInGroup} (#1704, its `leftoverGroup` field). Reported,
|
|
100
|
+
* never a status change: pass/fail stays the command's own (HR-105).
|
|
101
|
+
*
|
|
102
|
+
* @param {{pgid: number, signals: string[], survivors: number[]}} leftover
|
|
103
|
+
* @returns {string}
|
|
104
|
+
*/
|
|
105
|
+
export function leftoverGroupLine(leftover) {
|
|
106
|
+
const ladder = (leftover.signals ?? []).join('→') || LEFTOVER_NO_SIGNAL;
|
|
107
|
+
const survivors = leftover.survivors ?? [];
|
|
108
|
+
return `${LEFTOVER_GROUP_PREFIX}${leftover.pgid}${LEFTOVER_GROUP_INFIX}${ladder}`
|
|
109
|
+
+ (survivors.length > 0 ? `, survivors: [${survivors.join(', ')}]` : '');
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Exactly the line {@link leftoverGroupLine} produces, anchored, built from the
|
|
113
|
+
* same constants so the two cannot drift. The timeout-only parser admits it
|
|
114
|
+
* after the FAIL blocks: it is the gate's own report, not a vitest error.
|
|
115
|
+
*/
|
|
116
|
+
const LEFTOVER_GROUP_LINE_RE = new RegExp(
|
|
117
|
+
`^${LEFTOVER_GROUP_PREFIX}\\d+${LEFTOVER_GROUP_INFIX}`
|
|
118
|
+
+ `(?:${LEFTOVER_NO_SIGNAL}|SIG[A-Z0-9]+(?:→SIG[A-Z0-9]+)*)`
|
|
119
|
+
+ String.raw`(?:, survivors: \[\d+(?:, \d+)*\])?$`,
|
|
120
|
+
);
|
|
121
|
+
|
|
92
122
|
/**
|
|
93
123
|
* The stdout envelope `scripts/run-quality-gate.mjs` publishes when the GATE
|
|
94
124
|
* SUB-SCRIPT itself was killed on the wall-clock ceiling.
|
|
@@ -106,10 +136,10 @@ function timeoutLine(timeoutMs, run) {
|
|
|
106
136
|
* @param {object} args
|
|
107
137
|
* @param {string} args.variant The `--variant` value the run was started with.
|
|
108
138
|
* @param {number} args.timeoutMs Ceiling that fired.
|
|
109
|
-
* @param {{pgid: number, durationMs: number, killSignals: string[], survivors: number[]}} args.run
|
|
139
|
+
* @param {{pgid: number, durationMs: number, killSignals: string[], survivors: number[]|null, error?: string}} args.run
|
|
110
140
|
* The {@link spawnInGroup} result.
|
|
111
141
|
* @returns {{variant: string, error: 'gate-timeout', timeout_ms: number, duration_ms: number,
|
|
112
|
-
* pgid: number, kill_signals: string[], survivors: number[]}}
|
|
142
|
+
* pgid: number, kill_signals: string[], survivors: number[]|null, cleanup_error?: string, ok?: boolean}}
|
|
113
143
|
*/
|
|
114
144
|
export function gateTimeoutEnvelope({ variant, timeoutMs, run }) {
|
|
115
145
|
return {
|
|
@@ -119,7 +149,8 @@ export function gateTimeoutEnvelope({ variant, timeoutMs, run }) {
|
|
|
119
149
|
duration_ms: run?.durationMs ?? 0,
|
|
120
150
|
pgid: run?.pgid ?? -1,
|
|
121
151
|
kill_signals: run?.killSignals ?? [],
|
|
122
|
-
survivors: run?.survivors ?? [],
|
|
152
|
+
survivors: run?.survivors === null ? null : (run?.survivors ?? []),
|
|
153
|
+
...(run?.survivors === null ? { cleanup_error: run.error, ok: false } : {}),
|
|
123
154
|
};
|
|
124
155
|
}
|
|
125
156
|
|
|
@@ -136,9 +167,10 @@ export function gateTimeoutEnvelope({ variant, timeoutMs, run }) {
|
|
|
136
167
|
* Absent is not zero: a result that spawned nothing (skip, stub) carries no
|
|
137
168
|
* `timedOut` boolean, and gets NO keys — never `false` / `[]`.
|
|
138
169
|
*
|
|
139
|
-
* @param {{timedOut?: boolean, killSignals?: string[], survivors?: number[]}} result
|
|
170
|
+
* @param {{timedOut?: boolean, killSignals?: string[], survivors?: number[]|null, error?: string}} result
|
|
140
171
|
* A {@link runCheck} result.
|
|
141
|
-
* @returns {{timed_out: boolean, kill_signals: string[], survivors: number[]
|
|
172
|
+
* @returns {{timed_out: boolean, kill_signals: string[], survivors: number[]|null, error?: string,
|
|
173
|
+
* leftover_group?: {pgid: number, signals: string[], survivors: number[]}}|{}}
|
|
142
174
|
* `survivors` are the pids, same as {@link gateTimeoutEnvelope}.
|
|
143
175
|
*/
|
|
144
176
|
export function commandKillFields(result) {
|
|
@@ -146,7 +178,14 @@ export function commandKillFields(result) {
|
|
|
146
178
|
return {
|
|
147
179
|
timed_out: result.timedOut,
|
|
148
180
|
kill_signals: Array.isArray(result.killSignals) ? [...result.killSignals] : [],
|
|
149
|
-
survivors: Array.isArray(result.survivors) ? [...result.survivors] : [],
|
|
181
|
+
survivors: result.survivors === null ? null : (Array.isArray(result.survivors) ? [...result.survivors] : []),
|
|
182
|
+
...(result.survivors === null ? { error: result.error, ok: false } : {}),
|
|
183
|
+
// #1704: additive, only when a group was reaped after a normal exit.
|
|
184
|
+
...(result.leftoverGroup ? { leftover_group: {
|
|
185
|
+
pgid: result.leftoverGroup.pgid,
|
|
186
|
+
signals: [...(result.leftoverGroup.signals ?? [])],
|
|
187
|
+
survivors: [...(result.leftoverGroup.survivors ?? [])],
|
|
188
|
+
} } : {}),
|
|
150
189
|
};
|
|
151
190
|
}
|
|
152
191
|
|
|
@@ -163,7 +202,12 @@ const KILL_FIELD_COMMANDS = ['typecheck', 'test', 'lint'];
|
|
|
163
202
|
* a boolean `timed_out` or provably spawned nothing — `status: 'skip'` or
|
|
164
203
|
* listed in `stubbed`): `timed_out` = outer OR any inner; `kill_signals` =
|
|
165
204
|
* outer followed by each command's, in execution order; `survivors` = outer
|
|
166
|
-
* count plus every inner pid count.
|
|
205
|
+
* count plus every inner pid count. Any explicit null makes the joined
|
|
206
|
+
* count null and retains the measurement error. A command whose group was
|
|
207
|
+
* reaped after a normal exit adds `{command, pgid, signals, survivors}` to
|
|
208
|
+
* `leftover_groups` (#1708); the key is absent when no command had one.
|
|
209
|
+
* The joined `survivors` count stays the kill-ladder count and does NOT
|
|
210
|
+
* include `leftover_groups[].survivors` (#1704 keeps the two apart).
|
|
167
211
|
* - inner NOT measured (a variant whose envelope does not publish it, an
|
|
168
212
|
* unparseable envelope, or the outer ceiling killed the sub-script before it
|
|
169
213
|
* wrote one): the outer ladder alone when it fired — a kill is a kill — and
|
|
@@ -173,10 +217,11 @@ const KILL_FIELD_COMMANDS = ['typecheck', 'test', 'lint'];
|
|
|
173
217
|
* Never throws.
|
|
174
218
|
*
|
|
175
219
|
* @param {object} args
|
|
176
|
-
* @param {{timed_out: boolean, survivors: number, kill_signals: string[]}} args.outer
|
|
220
|
+
* @param {{timed_out: boolean, survivors: number|null, error?: string, kill_signals: string[]}} args.outer
|
|
177
221
|
* `gateKillFields()` of the sub-script's `spawnInGroup` result.
|
|
178
222
|
* @param {string} args.gateStdout The sub-script's stdout envelope (`''` on an outer kill).
|
|
179
|
-
* @returns {{timed_out: boolean, survivors: number, kill_signals: string[]
|
|
223
|
+
* @returns {{timed_out: boolean, survivors: number|null, error?: string, kill_signals: string[],
|
|
224
|
+
* leftover_groups?: Array<{command: string, pgid: number, signals: string[], survivors: number[]}>}|{}}
|
|
180
225
|
*/
|
|
181
226
|
export function joinGateKillFields({ outer, gateStdout }) {
|
|
182
227
|
let parsed = null;
|
|
@@ -199,12 +244,22 @@ export function joinGateKillFields({ outer, gateStdout }) {
|
|
|
199
244
|
|| parsed[name].status === 'skip'
|
|
200
245
|
|| Boolean(stubbed[name]));
|
|
201
246
|
|
|
202
|
-
if (!innerKnown) return outer?.timed_out === true ? { ...outer } : {};
|
|
247
|
+
if (!innerKnown) return outer?.timed_out === true || outer?.survivors === null ? { ...outer } : {};
|
|
248
|
+
|
|
249
|
+
const unmeasurable = [outer, ...inner].filter((c) => c?.survivors === null);
|
|
250
|
+
// #1708 point 1: a group reaped after a NORMAL exit (#1704) reached the
|
|
251
|
+
// envelope via commandKillFields but was dropped here, so the event could
|
|
252
|
+
// not show how often it happens before one turns a gate red (HR-105).
|
|
253
|
+
const leftoverGroups = names
|
|
254
|
+
.filter((name) => isObj(parsed[name].leftover_group))
|
|
255
|
+
.map((name) => ({ command: name, ...parsed[name].leftover_group }));
|
|
203
256
|
|
|
204
257
|
return {
|
|
258
|
+
...(leftoverGroups.length > 0 ? { leftover_groups: leftoverGroups } : {}),
|
|
205
259
|
timed_out: outer?.timed_out === true || inner.some((c) => c.timed_out),
|
|
206
|
-
survivors: (outer?.survivors ?? 0)
|
|
260
|
+
survivors: unmeasurable.length > 0 ? null : (outer?.survivors ?? 0)
|
|
207
261
|
+ inner.reduce((n, c) => n + (Array.isArray(c.survivors) ? c.survivors.length : 0), 0),
|
|
262
|
+
...(unmeasurable.length > 0 ? { error: unmeasurable.map((c) => c.error).join('; '), ok: false } : {}),
|
|
208
263
|
kill_signals: [
|
|
209
264
|
...(outer?.kill_signals ?? []),
|
|
210
265
|
...inner.flatMap((c) => (Array.isArray(c.kill_signals) ? c.kill_signals : [])),
|
|
@@ -237,7 +292,7 @@ export function joinGateKillFields({ outer, gateStdout }) {
|
|
|
237
292
|
*
|
|
238
293
|
* @param {object} args
|
|
239
294
|
* @param {{fullOutput?: string, exitCode?: number, timedOut?: boolean, pgid?: number,
|
|
240
|
-
* durationMs?: number, killSignals?: string[], survivors?: number[]}} args.result
|
|
295
|
+
* durationMs?: number, killSignals?: string[], survivors?: number[]|null, error?: string}} args.result
|
|
241
296
|
* The {@link spawnInGroup} result for the gate sub-script.
|
|
242
297
|
* @param {string} args.variant The `--variant` value the run was started with.
|
|
243
298
|
* @param {number} args.timeoutMs The OUTER ceiling that applied to the sub-script.
|
|
@@ -254,14 +309,16 @@ export function publishGateOutcome({ result, variant, timeoutMs }) {
|
|
|
254
309
|
return { stdout: capture, stderr: '', exitCode, warnings: [] };
|
|
255
310
|
}
|
|
256
311
|
|
|
257
|
-
const survivors = result.survivors ?? [];
|
|
312
|
+
const survivors = result.survivors === null ? null : (result.survivors ?? []);
|
|
258
313
|
return {
|
|
259
314
|
stdout: `${JSON.stringify(gateTimeoutEnvelope({ variant, timeoutMs, run: result }))}\n`,
|
|
260
315
|
stderr: capture.trim()
|
|
261
316
|
? `\n\u2500\u2500\u2500\u2500 gate TIMED OUT \u2014 captured output before the kill \u2500\u2500\u2500\u2500\n${capture}\n\u2500\u2500\u2500\u2500 end \u2500\u2500\u2500\u2500\n`
|
|
262
317
|
: '',
|
|
263
318
|
exitCode,
|
|
264
|
-
warnings: survivors
|
|
319
|
+
warnings: survivors === null
|
|
320
|
+
? [`Überlebende nicht messbar: ${result.error}`]
|
|
321
|
+
: survivors.length > 0
|
|
265
322
|
? [`gate process group ${result.pgid} left survivors after SIGKILL: ${survivors.join(', ')}`]
|
|
266
323
|
: [],
|
|
267
324
|
};
|
|
@@ -342,10 +399,12 @@ function isTestFile(filePath) {
|
|
|
342
399
|
* @param {number|null} [opts.timeoutMs] - Wall-clock ceiling; defaults to
|
|
343
400
|
* {@link resolveGateTimeoutMs} (`SO_GATE_TIMEOUT_MS` or 900_000). `null`
|
|
344
401
|
* disables the clock — the byte cap still applies.
|
|
402
|
+
* @param {string} [opts.platform] - Host platform for missing-command classification.
|
|
345
403
|
* @param {string} [opts.repoRoot] - Root whose gate-process ledger the spawn is
|
|
346
404
|
* recorded in; defaults to `process.cwd()`, which is the tree under test.
|
|
347
405
|
* @returns {Promise<{ status: 'pass'|'fail'|'skip', output: string, fullOutput: string,
|
|
348
|
-
* exitCode: number, timedOut?: boolean, killSignals?: string[], survivors?: number[],
|
|
406
|
+
* exitCode: number, timedOut?: boolean, killSignals?: string[], survivors?: number[]|null, error?: string,
|
|
407
|
+
* leftoverGroup?: {pgid: number, signals: string[], survivors: number[]},
|
|
349
408
|
* stubbed?: { kind: 'echo'|'noop' } }>}
|
|
350
409
|
*/
|
|
351
410
|
export async function runCheck(cmd, opts = {}) {
|
|
@@ -364,12 +423,30 @@ export async function runCheck(cmd, opts = {}) {
|
|
|
364
423
|
repoRoot: process.cwd(),
|
|
365
424
|
commandSignature: buildCommandSignature(cmd),
|
|
366
425
|
...opts,
|
|
426
|
+
// #1675: the CLI gate path (`.husky/pre-push` → `npm run quality-gate` →
|
|
427
|
+
// gate-full → here) must bound Vitest's workers exactly like `runGate()` in
|
|
428
|
+
// scripts/lib/quality-gate.mjs; without it the pre-push gate ran 18 workers
|
|
429
|
+
// on an 18-core host and timed out under load (#1673). Applied AFTER the
|
|
430
|
+
// `opts` spread so a caller-supplied `env` keeps its keys but cannot drop
|
|
431
|
+
// the bound. typecheck/lint ignore the flag, so it is set unconditionally.
|
|
432
|
+
// NAMED CEILING (BV-004): the policy is duplicated in exactly two spawn
|
|
433
|
+
// sites (this one and `runGate`); revisit by extracting one shared helper
|
|
434
|
+
// if a third gate entrypoint appears or the bound needs a value other
|
|
435
|
+
// than the flag `vitest.config.mjs` reads.
|
|
436
|
+
env: { ...(opts.env ?? process.env), SO_BOUNDED_WORKERS: '1' },
|
|
367
437
|
timeoutMs,
|
|
368
438
|
});
|
|
369
439
|
|
|
370
440
|
// Reported in every returned shape below, so a consumer never has to ask a
|
|
371
441
|
// second question to learn whether the group is actually gone.
|
|
372
|
-
const groupFields = { killSignals: run.killSignals, survivors: run.survivors
|
|
442
|
+
const groupFields = { killSignals: run.killSignals, survivors: run.survivors,
|
|
443
|
+
...(run.survivors === null ? { error: run.error, ok: false } : {}),
|
|
444
|
+
...(run.leftoverGroup ? { leftoverGroup: run.leftoverGroup } : {}) };
|
|
445
|
+
// #1704: a group reaped after a NORMAL exit is named in the output of the
|
|
446
|
+
// non-timeout shapes (a timeout run never carries one); status is unchanged.
|
|
447
|
+
const runFullOutput = run.leftoverGroup
|
|
448
|
+
? `${run.fullOutput}\n${leftoverGroupLine(run.leftoverGroup)}\n`
|
|
449
|
+
: run.fullOutput;
|
|
373
450
|
|
|
374
451
|
if (run.timedOut) {
|
|
375
452
|
const fullOutput = `${run.fullOutput}\n${timeoutLine(timeoutMs, run)}\n`;
|
|
@@ -383,17 +460,25 @@ export async function runCheck(cmd, opts = {}) {
|
|
|
383
460
|
};
|
|
384
461
|
}
|
|
385
462
|
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
463
|
+
// A missing configured command is a failure, never an implicit opt-out.
|
|
464
|
+
// cmd.exe sets ERRORLEVEL 9009 for an unknown command, but `cmd /d /s /c`
|
|
465
|
+
// (Node's shell on win32) exits 1 for it (measured 2026-10-14, #1612), so a
|
|
466
|
+
// missing command there is an ordinary `fail` without this hint; 9009
|
|
467
|
+
// only surfaces when a batch file propagates it with `exit /b`.
|
|
468
|
+
if (run.exitCode === 127 || ((opts.platform ?? process.platform) === 'win32' && run.exitCode === 9009)) {
|
|
469
|
+
const message = `command not found (exit ${run.exitCode}); correct the command or explicitly set it to skip in the Session Config.`;
|
|
470
|
+
const fullOutput = `${runFullOutput}\n${message}`.trim();
|
|
471
|
+
return { status: 'fail', output: tailOf(fullOutput), fullOutput, exitCode: run.exitCode, timedOut: false, ...groupFields };
|
|
389
472
|
}
|
|
390
473
|
|
|
391
474
|
return {
|
|
392
475
|
status: run.exitCode === 0 ? 'pass' : 'fail',
|
|
393
|
-
output: tailOf(
|
|
394
|
-
fullOutput:
|
|
476
|
+
output: tailOf(runFullOutput),
|
|
477
|
+
fullOutput: runFullOutput,
|
|
395
478
|
exitCode: run.exitCode,
|
|
396
479
|
timedOut: false,
|
|
480
|
+
// #1673: an end by signal, which `exitCode` alone reports as 1.
|
|
481
|
+
exitSignal: run.exitSignal ?? null,
|
|
397
482
|
...groupFields,
|
|
398
483
|
};
|
|
399
484
|
}
|
|
@@ -657,6 +742,134 @@ export function extractFailedTestFiles(output, cwd = process.cwd()) {
|
|
|
657
742
|
return [...seen];
|
|
658
743
|
}
|
|
659
744
|
|
|
745
|
+
/**
|
|
746
|
+
* vitest's own wording for a test or hook that ran past its timeout (vitest
|
|
747
|
+
* 4.1.11, `makeTimeoutError` in @vitest/runner): `Test timed out in 5000ms.`,
|
|
748
|
+
* with the `If this is a long-running …` hint after a newline, so it is the
|
|
749
|
+
* NEXT line ({@link TIMEOUT_BLOCK_LINE}). Anchored at the end: any further text
|
|
750
|
+
* on the error line is not a plain timeout.
|
|
751
|
+
*/
|
|
752
|
+
const TIMEOUT_ERROR_LINE = /^(?:Error:\s+)?(?:Test|Hook) timed out in \d+ms\.?$/;
|
|
753
|
+
/**
|
|
754
|
+
* `⎯⎯⎯[k/N]⎯` — closes every numbered error block. N is the run's error total
|
|
755
|
+
* counted BEFORE vitest merges identical errors (`printErrorsSummary`, vitest
|
|
756
|
+
* 4.1.11), so it equals the number of FAIL headers, not of blocks.
|
|
757
|
+
*/
|
|
758
|
+
const ERROR_BLOCK_COUNTER = /^\s*⎯+\[(\d+)\/(\d+)\]⎯*\s*$/;
|
|
759
|
+
/**
|
|
760
|
+
* The only lines a timeout block may carry after its error line: vitest's
|
|
761
|
+
* hint, a stack frame (` ❯ …`) and the code frame (` 2| …`, ` | ^`).
|
|
762
|
+
* Anything else — `Caused by:`, a serialized error, a second error — makes the
|
|
763
|
+
* block something other than a plain timeout.
|
|
764
|
+
*/
|
|
765
|
+
const TIMEOUT_BLOCK_LINE = /^(?:If this is a long-running (?:test|hook),|\s*❯\s|\s+\d+\|(?: |$)|\s+\|\s*\^)/;
|
|
766
|
+
/**
|
|
767
|
+
* Once the first FAIL block opened, the only lines vitest prints OUTSIDE a
|
|
768
|
+
* block are the section rules and the closing summary, each in its exact
|
|
769
|
+
* numeric shape (vitest 4.1.11, measured). Any other line there — a package
|
|
770
|
+
* manager's own error line included — is an error the block list does not hold
|
|
771
|
+
* (Codex review round 3, H1: prefix-only matches admitted `npm error
|
|
772
|
+
* AssertionError …` and `⎯⎯ AssertionError … ⎯⎯`).
|
|
773
|
+
*/
|
|
774
|
+
const AFTER_FAILURES_LINE = new RegExp(
|
|
775
|
+
[
|
|
776
|
+
String.raw`^\s*⎯+ Failed (?:Tests|Suites) \d+ ⎯+\s*$`,
|
|
777
|
+
String.raw`^\s*Test Files\s+\d+ (?:failed|passed)(?: \| \d+ (?:passed|skipped|todo))* \(\d+\)\s*$`,
|
|
778
|
+
String.raw`^\s*Tests\s+\d+ (?:failed|passed)(?: \| \d+ (?:passed|skipped|todo))* \(\d+\)\s*$`,
|
|
779
|
+
String.raw`^\s*Start at\s+\d{2}:\d{2}:\d{2}\s*$`,
|
|
780
|
+
String.raw`^\s*Duration\s+\d+(?:\.\d+)?m?s(?: \([^()]*\))?\s*$`,
|
|
781
|
+
].join('|'),
|
|
782
|
+
);
|
|
783
|
+
/**
|
|
784
|
+
* Errors vitest reports OUTSIDE the numbered FAIL blocks: the `Unhandled
|
|
785
|
+
* Errors` section and its `Errors N error(s)` summary line. Either one means a
|
|
786
|
+
* failure the block list does not hold (measured, vitest 4.1.11).
|
|
787
|
+
*/
|
|
788
|
+
const UNCOUNTED_ERRORS_LINE = /^(?:⎯+\s*Unhandled\b|\s*Errors\s+\d+\s+errors?\b)/;
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Which failing files failed ONLY by timeout — the set the full gate may run a
|
|
792
|
+
* second time, serially (#1673).
|
|
793
|
+
*
|
|
794
|
+
* Under host load the parallel suite loses tests to their timeout while the
|
|
795
|
+
* code is fine; four pre-push runs on m5 blocked with four different red sets
|
|
796
|
+
* (11, 11, 2, 7 tests), none touching the pushed diff. A timeout is the one
|
|
797
|
+
* failure that load alone produces. An assertion failure is NOT eligible, even
|
|
798
|
+
* one that load provoked: re-running it would let a genuine flake through the
|
|
799
|
+
* gate (Lead 32, 2026-10-20).
|
|
800
|
+
*
|
|
801
|
+
* Shape (measured, vitest 4.1.11): every error block starts with one or more
|
|
802
|
+
* `FAIL <file> …` headers, then the error line, and ends in `⎯[k/N]⎯`.
|
|
803
|
+
* Several headers share one block when vitest's `printTaskErrors` merges their
|
|
804
|
+
* errors: same `stack` AND same `diff`, same project, and (for tests) deep-equal
|
|
805
|
+
* annotations. A timeout's stack is the place the test or hook was DECLARED, so
|
|
806
|
+
* one `beforeEach` that times out for every test of a file, or the cases of one
|
|
807
|
+
* `it.each`, print as one block under several headers. Each header is one
|
|
808
|
+
* (task, error) pair, and N counts those pairs before the merge, so the
|
|
809
|
+
* completeness check compares N with the HEADERS read, not with the blocks.
|
|
810
|
+
*
|
|
811
|
+
* Fail-closed: `eligible` is true only when EVERY counted error was parsed and
|
|
812
|
+
* EVERY block is a timeout. A header count that differs from N (an unhandled
|
|
813
|
+
* error, an unknown layout), no counter at all, or one non-timeout block makes
|
|
814
|
+
* the whole run ineligible.
|
|
815
|
+
*
|
|
816
|
+
* @param {string} output - Captured test-runner stdout/stderr.
|
|
817
|
+
* @param {string} [cwd=process.cwd()] - Root to relativise absolute paths against.
|
|
818
|
+
* @returns {{ eligible: boolean, files: string[], reason: string }}
|
|
819
|
+
*/
|
|
820
|
+
export function extractTimeoutOnlyFailedFiles(output, cwd = process.cwd()) {
|
|
821
|
+
if (!output || typeof output !== 'string') return { eligible: false, files: [], reason: 'no-output' };
|
|
822
|
+
|
|
823
|
+
const prefix = cwd.endsWith('/') ? cwd : `${cwd}/`;
|
|
824
|
+
const files = new Set();
|
|
825
|
+
let pending = [];
|
|
826
|
+
let inBlock = false;
|
|
827
|
+
let sawFail = false;
|
|
828
|
+
let blocks = 0;
|
|
829
|
+
let errors = 0;
|
|
830
|
+
let declaredTotal = null;
|
|
831
|
+
let allTimeouts = true;
|
|
832
|
+
|
|
833
|
+
for (const rawLine of output.split('\n')) {
|
|
834
|
+
const line = rawLine.replace(ANSI_ESCAPE, '');
|
|
835
|
+
if (UNCOUNTED_ERRORS_LINE.test(line)) return { eligible: false, files: [], reason: 'uncounted-errors' };
|
|
836
|
+
const counter = ERROR_BLOCK_COUNTER.exec(line);
|
|
837
|
+
if (counter) {
|
|
838
|
+
declaredTotal = Number(counter[2]);
|
|
839
|
+
inBlock = false;
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
const header = FAIL_LINE.exec(line);
|
|
843
|
+
if (header) {
|
|
844
|
+
pending.push(header[1].startsWith(prefix) ? header[1].slice(prefix.length) : header[1]);
|
|
845
|
+
inBlock = false;
|
|
846
|
+
sawFail = true;
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
if (line.trim() === '') continue;
|
|
850
|
+
if (inBlock) {
|
|
851
|
+
if (!TIMEOUT_BLOCK_LINE.test(line)) allTimeouts = false;
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (pending.length === 0) {
|
|
855
|
+
if (sawFail && !AFTER_FAILURES_LINE.test(line) && !LEFTOVER_GROUP_LINE_RE.test(line)) return { eligible: false, files: [], reason: 'line-outside-blocks' };
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
blocks += 1;
|
|
859
|
+
errors += pending.length;
|
|
860
|
+
if (!TIMEOUT_ERROR_LINE.test(line.trim())) allTimeouts = false;
|
|
861
|
+
for (const file of pending) files.add(file);
|
|
862
|
+
pending = [];
|
|
863
|
+
inBlock = true;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (pending.length > 0) return { eligible: false, files: [], reason: 'header-without-error' };
|
|
867
|
+
if (declaredTotal === null || blocks === 0) return { eligible: false, files: [], reason: 'no-error-blocks' };
|
|
868
|
+
if (errors !== declaredTotal) return { eligible: false, files: [], reason: `parsed-${errors}-of-${declaredTotal}` };
|
|
869
|
+
if (!allTimeouts) return { eligible: false, files: [], reason: 'non-timeout-failure' };
|
|
870
|
+
return { eligible: true, files: [...files], reason: 'timeouts-only' };
|
|
871
|
+
}
|
|
872
|
+
|
|
660
873
|
/**
|
|
661
874
|
* Admit a suite-count triple, or refuse to claim a measurement.
|
|
662
875
|
*
|
|
@@ -101,7 +101,9 @@
|
|
|
101
101
|
*/
|
|
102
102
|
|
|
103
103
|
import { spawnSync } from 'node:child_process';
|
|
104
|
-
import {
|
|
104
|
+
import { accessSync, constants, existsSync } from 'node:fs';
|
|
105
|
+
import { detectPackageManager } from './package-manager.mjs';
|
|
106
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
105
107
|
|
|
106
108
|
/** Default timeout in ms for each `git config` invocation. */
|
|
107
109
|
export const DEFAULT_TIMEOUT_MS = 5000;
|
|
@@ -140,8 +142,17 @@ export const DEGRADED_REASONS = Object.freeze([
|
|
|
140
142
|
* - `hooks-path` — a local `core.hooksPath` not pointing at `.husky/_`
|
|
141
143
|
* and not DECLARED by the repo — no tracked file directly under it is a
|
|
142
144
|
* real git hook name; see `hooksPathIsTracked` / `GIT_HOOK_NAMES`.
|
|
145
|
+
* - `hooks-path-missing` — `core.hooksPath` points at `.husky/_`, but the
|
|
146
|
+
* directory git resolves it to in THIS working tree lacks an executable
|
|
147
|
+
* `pre-push` or the `h` helper (#1688).
|
|
148
|
+
* `.husky/_` is gitignored and written by `husky`; under `ignore-scripts=true`
|
|
149
|
+
* a fresh linked worktree never gets it, so commit and push run ungated and
|
|
150
|
+
* silent. Repair with the canonical PM (SEC-020): `pnpm exec husky`
|
|
151
|
+
* (pnpm), `npm exec --no -- husky` (npm); never fetch a missing package.
|
|
152
|
+
* - `hooks-path-unset` — `core.hooksPath` is set in no scope although the repo
|
|
153
|
+
* tracks `.husky/<hook>` files, so git never runs them (#1692). Same repair.
|
|
143
154
|
*
|
|
144
|
-
* @type {readonly ['ambient-git-env','local-identity','local-gpgsign','fixture-remote','hooks-path']}
|
|
155
|
+
* @type {readonly ['ambient-git-env','local-identity','local-gpgsign','fixture-remote','hooks-path','hooks-path-missing','hooks-path-unset']}
|
|
145
156
|
*/
|
|
146
157
|
export const FINDING_KINDS = Object.freeze([
|
|
147
158
|
'ambient-git-env',
|
|
@@ -149,6 +160,8 @@ export const FINDING_KINDS = Object.freeze([
|
|
|
149
160
|
'local-gpgsign',
|
|
150
161
|
'fixture-remote',
|
|
151
162
|
'hooks-path',
|
|
163
|
+
'hooks-path-missing',
|
|
164
|
+
'hooks-path-unset',
|
|
152
165
|
]);
|
|
153
166
|
|
|
154
167
|
/**
|
|
@@ -267,7 +280,8 @@ function filteredGitEnv(sourceEnv) {
|
|
|
267
280
|
* Run one `git config` query.
|
|
268
281
|
*
|
|
269
282
|
* @param {string[]} args argv after the leading `git`
|
|
270
|
-
* @param {{repoRoot: string, env: Record<string,string|undefined>, timeoutMs: number, spawn: Function}} ctx
|
|
283
|
+
* @param {{repoRoot: string, env: Record<string,string|undefined>, timeoutMs: number, spawn: Function, repoConfirmed?: boolean}} ctx
|
|
284
|
+
* `repoConfirmed` is true once an earlier `--local` read of this probe succeeded
|
|
271
285
|
* @returns {{ok: true, stdout: string} | {ok: false, reason: 'git-unavailable'|'not-a-git-repo'|'config-unreadable'|'timeout'|'git-error', status: number|null}}
|
|
272
286
|
*/
|
|
273
287
|
function runGitConfig(args, ctx) {
|
|
@@ -297,7 +311,9 @@ function runGitConfig(args, ctx) {
|
|
|
297
311
|
if (stderr.includes('bad config') || stderr.includes('bad numeric config')) {
|
|
298
312
|
return { ok: false, reason: 'config-unreadable', status };
|
|
299
313
|
}
|
|
300
|
-
|
|
314
|
+
// Once the `--local` read has succeeded the repo demonstrably exists, so a
|
|
315
|
+
// later 128 is a config git can no longer read, not a missing repo (#1694).
|
|
316
|
+
return { ok: false, reason: ctx.repoConfirmed ? 'config-unreadable' : 'not-a-git-repo', status };
|
|
301
317
|
}
|
|
302
318
|
return { ok: false, reason: 'git-error', status };
|
|
303
319
|
}
|
|
@@ -437,6 +453,102 @@ function hooksPathIsTracked(value, ctx) {
|
|
|
437
453
|
return lines.some((line) => isDeclaredHookFile(pathspec, line));
|
|
438
454
|
}
|
|
439
455
|
|
|
456
|
+
/**
|
|
457
|
+
* @param {{repoRoot: string, env: Record<string,string|undefined>, timeoutMs: number, spawn: Function}} ctx
|
|
458
|
+
* @returns {string[]} each missing runtime requirement, empty when healthy.
|
|
459
|
+
* Uses git's own per-worktree path resolution. A failed query is reported,
|
|
460
|
+
* never read as present.
|
|
461
|
+
*/
|
|
462
|
+
function hooksDirMissingReasons(ctx) {
|
|
463
|
+
const res = runGitConfig(['rev-parse', '--path-format=absolute', '--git-path', 'hooks'], ctx);
|
|
464
|
+
if (!res.ok) return [`der Hook-Pfad ließ sich nicht auflösen (${res.reason})`];
|
|
465
|
+
const dir = res.stdout.trim();
|
|
466
|
+
if (dir === '') return ['der Hook-Pfad ließ sich nicht auflösen (leere Ausgabe)'];
|
|
467
|
+
const reasons = [];
|
|
468
|
+
const prePush = join(dir, 'pre-push');
|
|
469
|
+
if (!existsSync(prePush)) {
|
|
470
|
+
reasons.push(`${prePush} fehlt`);
|
|
471
|
+
} else {
|
|
472
|
+
try {
|
|
473
|
+
accessSync(prePush, constants.X_OK);
|
|
474
|
+
} catch {
|
|
475
|
+
reasons.push(`${prePush} ist nicht ausführbar`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (!existsSync(join(dir, 'h'))) reasons.push(`${dir}/h fehlt`);
|
|
479
|
+
return reasons;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* `core.hooksPath` unset in EVERY scope (local, global, system, includes) while
|
|
484
|
+
* the repo tracks `.husky/<hook>` files: git then falls back to
|
|
485
|
+
* `<common-dir>/hooks`, which husky never writes, so commit and push run
|
|
486
|
+
* ungated and silent (#1692 — measured on a host clone with three ungated
|
|
487
|
+
* MR pushes).
|
|
488
|
+
*
|
|
489
|
+
* Ceiling: a `core.hooksPath` set ONLY globally/system-wide is not judged
|
|
490
|
+
* here — the local loop covers local values; revisit if a fleet host sets it
|
|
491
|
+
* globally. Likewise a value git finds only through config-location env vars
|
|
492
|
+
* (`GIT_CONFIG_GLOBAL`, `GIT_CONFIG_COUNT`, a non-default `XDG_CONFIG_HOME`)
|
|
493
|
+
* is invisible behind {@link GIT_ENV_ALLOWLIST} and reads as unset; revisit
|
|
494
|
+
* if a host relies on that.
|
|
495
|
+
*
|
|
496
|
+
* @param {{repoRoot: string, env: Record<string,string|undefined>, timeoutMs: number, spawn: Function}} ctx
|
|
497
|
+
* @returns {{kind:'hooks-path-unset', key:string, value:string, detail:string} | null}
|
|
498
|
+
* null when a hooksPath is set somewhere or the repo tracks no husky hook.
|
|
499
|
+
* A failed query is a finding ("unknown"), never read as healthy.
|
|
500
|
+
*/
|
|
501
|
+
function hooksPathUnsetFinding(ctx) {
|
|
502
|
+
const unknown = (what, reason) => ({
|
|
503
|
+
kind: /** @type {const} */ ('hooks-path-unset'),
|
|
504
|
+
key: 'core.hooksPath',
|
|
505
|
+
value: '',
|
|
506
|
+
detail: `${what} (${reason}) — ob die Husky-Hooks laufen, ist unbekannt.`,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// `git config --get` exits 1 for "key not set in any scope".
|
|
510
|
+
const effective = runGitConfig(['config', '--get', 'core.hooksPath'], ctx);
|
|
511
|
+
if (effective.ok) return null;
|
|
512
|
+
if (effective.status !== 1) return unknown('core.hooksPath ließ sich nicht abfragen', effective.reason);
|
|
513
|
+
|
|
514
|
+
// `ls-files` also exits 128 for a corrupt index, which is no config fault:
|
|
515
|
+
// the repo-confirmed 128 is named for what it is, a git error (#1694 review).
|
|
516
|
+
const tracked = runGitConfig(['ls-files', '--', '.husky'], { ...ctx, repoConfirmed: false });
|
|
517
|
+
if (!tracked.ok) {
|
|
518
|
+
const reason = tracked.reason === 'not-a-git-repo' ? 'git-error' : tracked.reason;
|
|
519
|
+
return unknown('die Husky-Hooks ließen sich nicht auflisten', reason);
|
|
520
|
+
}
|
|
521
|
+
const hooks = tracked.stdout
|
|
522
|
+
.split('\n')
|
|
523
|
+
.filter((line) => isDeclaredHookFile('.husky', line));
|
|
524
|
+
if (hooks.length === 0) return null;
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
kind: 'hooks-path-unset',
|
|
528
|
+
key: 'core.hooksPath',
|
|
529
|
+
value: '',
|
|
530
|
+
detail:
|
|
531
|
+
`core.hooksPath ist nicht gesetzt, das Repo versioniert aber ${hooks.join(', ')} — ` +
|
|
532
|
+
`git führt diese Hooks nicht aus, Commits und Pushes laufen ungeprüft. ` +
|
|
533
|
+
huskyRepairHint(ctx.repoRoot),
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** @param {string} repoRoot */
|
|
538
|
+
function huskyRepairHint(repoRoot) {
|
|
539
|
+
// SEC-020: respect the TARGET repo's canonical PM, not the plugin's npm.
|
|
540
|
+
const commands = {
|
|
541
|
+
pnpm: 'pnpm exec husky',
|
|
542
|
+
npm: 'npm exec --no -- husky',
|
|
543
|
+
yarn: 'yarn exec husky',
|
|
544
|
+
bun: 'bun run husky',
|
|
545
|
+
};
|
|
546
|
+
const pm = detectPackageManager(repoRoot);
|
|
547
|
+
return pm
|
|
548
|
+
? `Reparatur: \`${commands[pm]}\`.`
|
|
549
|
+
: 'Reparatur: Husky mit dem canonical Package Manager des Repos ausführen (SEC-020).';
|
|
550
|
+
}
|
|
551
|
+
|
|
440
552
|
/**
|
|
441
553
|
* Build the degraded result. Distinct from `null` on purpose — see the header.
|
|
442
554
|
*
|
|
@@ -479,7 +591,7 @@ export function checkGitConfigDrift(opts = {}, deps = {}) {
|
|
|
479
591
|
|
|
480
592
|
const activeEnv = env && typeof env === 'object' ? env : process.env;
|
|
481
593
|
const spawn = deps?.spawn ?? spawnSync;
|
|
482
|
-
const ctx = { repoRoot, env: activeEnv, timeoutMs, spawn };
|
|
594
|
+
const ctx = { repoRoot, env: activeEnv, timeoutMs, spawn, repoConfirmed: false };
|
|
483
595
|
|
|
484
596
|
/** @type {Array<{kind:string, key:string, value:string, detail:string}>} */
|
|
485
597
|
const findings = [];
|
|
@@ -503,6 +615,7 @@ export function checkGitConfigDrift(opts = {}, deps = {}) {
|
|
|
503
615
|
|
|
504
616
|
const local = runGitConfig(['config', '--local', '--list', '-z'], ctx);
|
|
505
617
|
if (!local.ok) return degradedResult(local.reason);
|
|
618
|
+
ctx.repoConfirmed = true;
|
|
506
619
|
|
|
507
620
|
// Best-effort global read, for the identity/gpgsign comparison only. Its
|
|
508
621
|
// failure never degrades the result — the local query, which IS the
|
|
@@ -516,7 +629,8 @@ export function checkGitConfigDrift(opts = {}, deps = {}) {
|
|
|
516
629
|
}
|
|
517
630
|
}
|
|
518
631
|
|
|
519
|
-
|
|
632
|
+
const localEntries = parseNulConfigList(local.stdout);
|
|
633
|
+
for (const { key, value } of localEntries) {
|
|
520
634
|
const lower = key.toLowerCase();
|
|
521
635
|
|
|
522
636
|
if (lower === 'user.email' || lower === 'user.name') {
|
|
@@ -566,7 +680,22 @@ export function checkGitConfigDrift(opts = {}, deps = {}) {
|
|
|
566
680
|
continue;
|
|
567
681
|
}
|
|
568
682
|
|
|
569
|
-
if (lower === 'core.hookspath' &&
|
|
683
|
+
if (lower === 'core.hookspath' && hooksPathIsExpected(value)) {
|
|
684
|
+
const missing = hooksDirMissingReasons(ctx);
|
|
685
|
+
for (const reason of missing) {
|
|
686
|
+
findings.push({
|
|
687
|
+
kind: 'hooks-path-missing',
|
|
688
|
+
key,
|
|
689
|
+
value,
|
|
690
|
+
detail:
|
|
691
|
+
`core.hooksPath=${value}, aber ${reason} — die Husky-Installation in diesem ` +
|
|
692
|
+
`Arbeitsbaum ist unvollständig. ${huskyRepairHint(repoRoot)}`,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (lower === 'core.hookspath' && !hooksPathIsTracked(value, ctx)) {
|
|
570
699
|
findings.push({
|
|
571
700
|
kind: 'hooks-path',
|
|
572
701
|
key,
|
|
@@ -578,6 +707,11 @@ export function checkGitConfigDrift(opts = {}, deps = {}) {
|
|
|
578
707
|
}
|
|
579
708
|
}
|
|
580
709
|
|
|
710
|
+
if (!localEntries.some(({ key }) => key.toLowerCase() === 'core.hookspath')) {
|
|
711
|
+
const unset = hooksPathUnsetFinding(ctx);
|
|
712
|
+
if (unset) findings.push(unset);
|
|
713
|
+
}
|
|
714
|
+
|
|
581
715
|
if (findings.length === 0) return null;
|
|
582
716
|
|
|
583
717
|
const plural = findings.length === 1 ? 'Eintrag' : 'Einträge';
|