session-orchestrator 5.12.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 +2 -2
- 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 +162 -4
- package/README.md +126 -127
- 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 +1 -1
- 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 +90 -17
- 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 +49 -15
- package/docs/session-config-template.md +30 -22
- 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 +139 -49
- 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 -2
- 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 +39 -27
- 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/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 +2 -2
- 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 +4 -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-id.mjs +9 -1
- package/scripts/lib/session-identity/own-session.mjs +63 -0
- package/scripts/lib/session-lock.mjs +114 -0
- package/scripts/lib/session-record-repair.mjs +245 -35
- package/scripts/lib/session-registry.mjs +72 -9
- 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 +49 -5
- package/scripts/lib/so-hud/runtime.mjs +15 -4
- 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 +14 -5
- package/scripts/lib/state-md/mission-status.mjs +5 -1
- 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 +19 -7
- package/scripts/lib/vault-mirror/process.mjs +56 -27
- package/scripts/lib/vault-mirror/prune.mjs +5 -2
- package/scripts/lib/vault-mirror/render-learnings.mjs +3 -3
- 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 +79 -0
- package/scripts/lib/vault-staleness-banner.mjs +4 -7
- package/scripts/lib/vault-status/board-writer.mjs +2 -1
- 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 +10 -17
- 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 +29 -6
- package/scripts/state-md-idle-reset.mjs +2 -1
- 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/skills/_shared/fleet-protocol.md +142 -1
- 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 +3 -2
- package/skills/architecture/references/domain-model.md +1 -1
- 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 +94 -11
- 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-documentation-updates.md +14 -12
- package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
- package/skills/session-end/references/session-summary-template.md +4 -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 +9 -3
- package/skills/session-start/phase-4-5-resource-health.md +11 -5
- 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 +2 -2
- 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/assets/og-card.png +0 -0
- package/assets/og-card.svg +0 -47
- package/assets/wave-lifecycle.svg +0 -98
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* snapshot-probe.mjs — run the suite on the CURATED public snapshot tree (#1712).
|
|
3
|
+
*
|
|
4
|
+
* The curated GitHub export drops the paths in `.orchestrator/policy/public-export.json`.
|
|
5
|
+
* A test that depends on one of those paths is green in the private repo and red on the
|
|
6
|
+
* public mirror — release 5.13.0 found that out on its first curated push (test B5 in
|
|
7
|
+
* `tests/lib/vault-mirror/namespace.test.mjs` needed `.vault.yaml`). This probe builds the
|
|
8
|
+
* snapshot tree through the SAME helper the export uses (`writeSnapshotTree`),
|
|
9
|
+
* materializes it OUTSIDE the repo as its own one-commit git repo without any remote,
|
|
10
|
+
* and runs validate-plugin and the test command there.
|
|
11
|
+
*
|
|
12
|
+
* Only runs with a suite step write a receipt. Receipts are keyed by the snapshot TREE: a source commit that changes only excluded
|
|
13
|
+
* files maps to the same tree and reuses the receipt; any change to a public file or to
|
|
14
|
+
* the policy yields a new tree and invalidates it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { createGitRunner, readSourcePolicy, writeSnapshotTree } from './github-export.mjs';
|
|
23
|
+
import { isPublicExportExcluded } from './validate/check-owner-leakage.mjs';
|
|
24
|
+
import { writeJsonAtomicSync } from './io.mjs';
|
|
25
|
+
import { runRemoteGate } from './pre-push-remote-gate.mjs';
|
|
26
|
+
import { registerExitCleanup, spawnInGroup } from './process-group.mjs';
|
|
27
|
+
|
|
28
|
+
export const RECEIPT_SCHEMA = 1;
|
|
29
|
+
export const RECEIPT_DIR_REL = '.orchestrator/tmp/snapshot-probe';
|
|
30
|
+
/** Basename of the materialized tree — offload derives the repo name from it. */
|
|
31
|
+
export const SNAPSHOT_DIRNAME = 'session-orchestrator';
|
|
32
|
+
export const KNOWN_STEPS = ['validate-plugin', 'test'];
|
|
33
|
+
/** Name of the step that replaces `test` when the suite runs on a remote host. */
|
|
34
|
+
export const REMOTE_STEP = 'remote-gate';
|
|
35
|
+
const HOST_ALIAS_SHAPE = /^[A-Za-z0-9._-]+$/;
|
|
36
|
+
// SHA-1 (40) or SHA-256 (64) object id — the receipt file name is built from it.
|
|
37
|
+
const TREE_ID_SHAPE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
38
|
+
|
|
39
|
+
const TAIL_LINES = 40;
|
|
40
|
+
const STEP_SPECS = {
|
|
41
|
+
'validate-plugin': { cmd: process.execPath, args: ['scripts/validate-plugin.mjs'], timeoutMs: 600_000 },
|
|
42
|
+
// The repo's Session Config test-command (`npm test`).
|
|
43
|
+
test: { cmd: 'npm', args: ['test'], timeoutMs: 1_800_000 },
|
|
44
|
+
};
|
|
45
|
+
const PROBE_IDENTITY = ['-c', 'user.name=snapshot-probe', '-c', 'user.email=snapshot-probe@example.invalid'];
|
|
46
|
+
const PROBE_GIT_CONFIG = ['-c', 'core.hooksPath=/dev/null', '-c', 'core.autocrlf=false', '-c', 'commit.gpgsign=false'];
|
|
47
|
+
// Git location variables would point every git call in the snapshot back at the source repo.
|
|
48
|
+
const GIT_LOCATION_VARS = new Set([
|
|
49
|
+
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
50
|
+
'GIT_COMMON_DIR', 'GIT_NAMESPACE', 'GIT_PREFIX', 'GIT_CEILING_DIRECTORIES', 'GIT_DISCOVERY_ACROSS_FILESYSTEM',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** Thrown for git/tar/mkdtemp failures — the CLI maps it to exit 2. */
|
|
54
|
+
export class SnapshotProbeSystemError extends Error {
|
|
55
|
+
constructor(message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = 'SnapshotProbeSystemError';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Environment for everything run inside the snapshot: drops the platform root variables
|
|
63
|
+
* (`*PROJECT_DIR`, `*PLUGIN_ROOT`, `*RULES_DIR` — they win over cwd in path resolution,
|
|
64
|
+
* same sweep as `.husky/pre-push`) and the git location variables.
|
|
65
|
+
* @param {NodeJS.ProcessEnv} [base]
|
|
66
|
+
* @returns {Record<string, string>}
|
|
67
|
+
*/
|
|
68
|
+
export function buildProbeEnv(base = process.env) {
|
|
69
|
+
/** @type {Record<string, string>} */
|
|
70
|
+
const env = {};
|
|
71
|
+
for (const [key, value] of Object.entries(base)) {
|
|
72
|
+
if (value === undefined) continue;
|
|
73
|
+
if (/(PROJECT_DIR|PLUGIN_ROOT|RULES_DIR)$/.test(key) || GIT_LOCATION_VARS.has(key)) continue;
|
|
74
|
+
env[key] = value;
|
|
75
|
+
}
|
|
76
|
+
return env;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Default step runner: own process group, so a timeout kills npm AND its vitest workers.
|
|
81
|
+
* @param {string} cmd @param {string[]} args
|
|
82
|
+
* @param {{ cwd: string, env: Record<string,string>, timeoutMs: number }} opts
|
|
83
|
+
* @returns {Promise<{ status: number|null, stdout: string, stderr: string, durationMs: number, timedOut: boolean }>}
|
|
84
|
+
*/
|
|
85
|
+
export async function defaultRunner(cmd, args, { cwd, env, timeoutMs }) {
|
|
86
|
+
const res = await spawnInGroup([cmd, ...args], { cwd, env, timeoutMs, maxOutputBytes: 64 * 1024 * 1024 });
|
|
87
|
+
return { status: res.exitCode, stdout: res.fullOutput, stderr: '', durationMs: res.durationMs, timedOut: res.timedOut };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Best-effort list of failed test files from vitest output (`FAIL <file> > …`).
|
|
92
|
+
* @param {string} output
|
|
93
|
+
* @returns {string[]}
|
|
94
|
+
*/
|
|
95
|
+
export function parseFailedFiles(output) {
|
|
96
|
+
// eslint-disable-next-line no-control-regex
|
|
97
|
+
const plain = output.replace(/\u001b\[[0-9;]*m/g, '');
|
|
98
|
+
const files = new Set();
|
|
99
|
+
for (const m of plain.matchAll(/^\s*(?:[×❯]\s*)?FAIL\s+(\S+\.(?:test|spec)\.[cm]?[jt]sx?)\b/gm)) files.add(m[1]);
|
|
100
|
+
return [...files];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function tailOf(text, lines = TAIL_LINES) {
|
|
104
|
+
return text.split('\n').slice(-lines - 1).join('\n').trimEnd();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Resolve source and write the snapshot tree. @param {import('./github-export.mjs').GitRunner} git */
|
|
108
|
+
function snapshotTreeOf(git, source) {
|
|
109
|
+
const rev = git(['rev-parse', '--verify', '--quiet', `${source}^{commit}`]);
|
|
110
|
+
if (rev.status !== 0) return { ok: false, reason: `source '${source}' does not resolve to a commit` };
|
|
111
|
+
const sourceSha = rev.stdout.trim();
|
|
112
|
+
const policy = readSourcePolicy(git, sourceSha);
|
|
113
|
+
if (!policy.ok) return { ok: false, reason: policy.reason, sourceSha };
|
|
114
|
+
const snap = writeSnapshotTree({ git, sourceSha, excludes: policy.excludes });
|
|
115
|
+
if (!snap.ok) return { ok: false, reason: snap.reason, sourceSha };
|
|
116
|
+
return { ok: true, sourceSha, excludes: policy.excludes, ...snap };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Snapshot tree of a source commit, without materializing it or running anything.
|
|
121
|
+
* Stable shape — the release preflight keys its receipt lookup on it.
|
|
122
|
+
* @param {{ repoRoot: string, source?: string, git?: import('./github-export.mjs').GitRunner }} opts
|
|
123
|
+
* @returns {{ ok: true, sourceSha: string, snapshotTree: string } | { ok: false, reason: string, sourceSha?: string }}
|
|
124
|
+
*/
|
|
125
|
+
export function computeSnapshotTree({ repoRoot, source = 'HEAD', git = createGitRunner(repoRoot) }) {
|
|
126
|
+
const snap = snapshotTreeOf(git, source);
|
|
127
|
+
if (!snap.ok) return snap;
|
|
128
|
+
return { ok: true, sourceSha: snap.sourceSha, snapshotTree: snap.tree };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {string} repoRoot @param {string} snapshotTree
|
|
133
|
+
* @throws {TypeError} when `snapshotTree` is not a git object id (it becomes a path segment)
|
|
134
|
+
*/
|
|
135
|
+
export function receiptPath(repoRoot, snapshotTree) {
|
|
136
|
+
if (typeof snapshotTree !== 'string' || !TREE_ID_SHAPE.test(snapshotTree)) {
|
|
137
|
+
throw new TypeError(`snapshot tree '${snapshotTree}' is not a git object id`);
|
|
138
|
+
}
|
|
139
|
+
return join(repoRoot, RECEIPT_DIR_REL, `${snapshotTree}.json`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read the probe receipt for a snapshot tree. Absent, unreadable and schema-mismatched
|
|
144
|
+
* receipts are distinguished in `reason`; all three mean "not proven".
|
|
145
|
+
* @param {{ repoRoot: string, snapshotTree: string }} opts
|
|
146
|
+
* @returns {{ ok: true, receipt: object } | { ok: false, receipt: null, reason: 'invalid-tree'|'absent'|'unreadable'|'schema-mismatch' }}
|
|
147
|
+
*/
|
|
148
|
+
export function readSnapshotProbeReceipt({ repoRoot, snapshotTree }) {
|
|
149
|
+
if (typeof snapshotTree !== 'string' || !TREE_ID_SHAPE.test(snapshotTree)) return { ok: false, receipt: null, reason: 'invalid-tree' };
|
|
150
|
+
const file = receiptPath(repoRoot, snapshotTree);
|
|
151
|
+
if (!existsSync(file)) return { ok: false, receipt: null, reason: 'absent' };
|
|
152
|
+
let parsed;
|
|
153
|
+
try {
|
|
154
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
155
|
+
} catch {
|
|
156
|
+
return { ok: false, receipt: null, reason: 'unreadable' };
|
|
157
|
+
}
|
|
158
|
+
if (!parsed || typeof parsed !== 'object' || parsed.schema !== RECEIPT_SCHEMA || parsed.snapshotTree !== snapshotTree) {
|
|
159
|
+
return { ok: false, receipt: null, reason: 'schema-mismatch' };
|
|
160
|
+
}
|
|
161
|
+
return { ok: true, receipt: parsed };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Count non-directory entries below `dir`. */
|
|
165
|
+
function countFiles(dir) {
|
|
166
|
+
let n = 0;
|
|
167
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
168
|
+
if (entry.isDirectory()) n += countFiles(join(dir, entry.name));
|
|
169
|
+
else n += 1;
|
|
170
|
+
}
|
|
171
|
+
return n;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Excluded paths grouped by the policy entry that matched them. */
|
|
175
|
+
function groupByPrefix(removed, excludes) {
|
|
176
|
+
/** @type {Record<string, number>} */
|
|
177
|
+
const out = {};
|
|
178
|
+
for (const p of removed) {
|
|
179
|
+
const key = excludes.find((e) => isPublicExportExcluded(p, [e])) ?? p;
|
|
180
|
+
out[key] = (out[key] ?? 0) + 1;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* git inside the materialized snapshot, with the probe env (no location vars).
|
|
187
|
+
* @param {string} cwd @param {string[]} args @param {Record<string,string>} env
|
|
188
|
+
*/
|
|
189
|
+
function snapshotGit(cwd, args, env) {
|
|
190
|
+
const res = spawnSync('git', args, { cwd, env, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
191
|
+
if (res.status !== 0) throw new SnapshotProbeSystemError(`git ${args.filter((a) => !a.includes('=')).join(' ')} failed in the snapshot: ${(res.stderr || String(res.error?.message ?? '')).trim()}`);
|
|
192
|
+
return res.stdout;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Materialize `tree` into `<dir>/session-orchestrator` as a one-commit repo with no remote.
|
|
197
|
+
* @returns {{ root: string, commit: string, exported: number }}
|
|
198
|
+
*/
|
|
199
|
+
function materialize({ git, tree, dir, nodeModules, env }) {
|
|
200
|
+
const root = join(dir, SNAPSHOT_DIRNAME);
|
|
201
|
+
mkdirSync(root);
|
|
202
|
+
const tarFile = join(dir, 'snapshot.tar');
|
|
203
|
+
const arch = git(['archive', '--format=tar', '-o', tarFile, tree]);
|
|
204
|
+
if (arch.status !== 0) throw new SnapshotProbeSystemError(`git archive ${tree} failed: ${arch.stderr.trim()}`);
|
|
205
|
+
const tar = spawnSync('tar', ['-x', '-f', tarFile, '-C', root], { encoding: 'utf8' });
|
|
206
|
+
if (tar.status !== 0) throw new SnapshotProbeSystemError(`tar -x failed: ${(tar.stderr || String(tar.error?.message ?? '')).trim()}`);
|
|
207
|
+
rmSync(tarFile, { force: true });
|
|
208
|
+
const exported = countFiles(root);
|
|
209
|
+
|
|
210
|
+
snapshotGit(root, [...PROBE_GIT_CONFIG, 'init', '-q', '-b', 'main'], env);
|
|
211
|
+
// A symlink is a FILE to git, so `.gitignore`'s `node_modules/` does not cover it.
|
|
212
|
+
writeFileSync(join(root, '.git', 'info', 'exclude'), '/node_modules\n', { flag: 'a' });
|
|
213
|
+
if (existsSync(nodeModules)) symlinkSync(nodeModules, join(root, 'node_modules'), 'dir');
|
|
214
|
+
snapshotGit(root, [...PROBE_GIT_CONFIG, 'add', '-A'], env);
|
|
215
|
+
snapshotGit(root, [...PROBE_IDENTITY, ...PROBE_GIT_CONFIG, 'commit', '-q', '--no-verify', '-m', `snapshot ${tree}`], env);
|
|
216
|
+
const commit = snapshotGit(root, ['rev-parse', 'HEAD'], env).trim();
|
|
217
|
+
// The committed tree must BE the snapshot tree: catches a lossy extract or a stray file.
|
|
218
|
+
const committedTree = snapshotGit(root, ['rev-parse', 'HEAD^{tree}'], env).trim();
|
|
219
|
+
if (committedTree !== tree) throw new SnapshotProbeSystemError(`materialized tree ${committedTree} differs from snapshot tree ${tree}`);
|
|
220
|
+
return { root, commit, exported };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The suite step on a remote host: the pre-push router's gate against the snapshot commit.
|
|
225
|
+
* Code 0 passes and code 1 with a finished suite is red. Every other code (2: host unknown
|
|
226
|
+
* or not ready, none ran; 10: fallback), and code 1 from a gate that did not finish, means
|
|
227
|
+
* no verdict on the snapshot, so it is thrown as a system error before any receipt is written.
|
|
228
|
+
*/
|
|
229
|
+
async function runRemoteStep({ remoteGate, root, commit, repoRoot, host }) {
|
|
230
|
+
const started = Date.now();
|
|
231
|
+
const r = await remoteGate({
|
|
232
|
+
tree: root, sha: commit, ledgerRoot: repoRoot,
|
|
233
|
+
// Telemetry off: a snapshot commit is not a pushed sha.
|
|
234
|
+
env: { ...process.env, SO_GATE_HOSTS: host }, emit: false, jobPrefix: 'snapshot-probe',
|
|
235
|
+
// The probe's stdout carries its own result (one JSON object with --json); the remote
|
|
236
|
+
// gate's output is diagnostics here.
|
|
237
|
+
stdout: process.stderr,
|
|
238
|
+
});
|
|
239
|
+
if (r.code !== 0 && r.code !== 1) {
|
|
240
|
+
const why = r.code === 2 ? `host '${host}' unknown or not ready`
|
|
241
|
+
: r.code === 10 ? 'fallback, the gate did not run remotely' : 'unexpected exit code';
|
|
242
|
+
throw new SnapshotProbeSystemError(`remote gate code ${r.code}: ${why} — the suite did not run, no receipt written`);
|
|
243
|
+
}
|
|
244
|
+
// Code 1 from a gate that never finished is a missing measurement, not a red snapshot:
|
|
245
|
+
// the gate hit its own budget (gate-timeout), a check timed out, or no test result exists
|
|
246
|
+
// at all. A dying suite (`suite_died`) stays red — the snapshot itself can cause it.
|
|
247
|
+
const report = r.report;
|
|
248
|
+
if (r.code === 1 && (report?.error === 'gate-timeout' || !report?.test ||
|
|
249
|
+
[report.test, report.typecheck, report.lint].some((check) => check?.timed_out === true))) {
|
|
250
|
+
const alias = r.alias ?? host;
|
|
251
|
+
const detail = report?.error === 'gate-timeout'
|
|
252
|
+
? `gate-timeout after ${report.duration_ms ?? report.timeout_ms ?? '?'} ms`
|
|
253
|
+
: !report?.test ? 'no test result in the remote receipt' : 'a check timed out';
|
|
254
|
+
throw new SnapshotProbeSystemError(`remote gate did not finish on ${alias} (${detail}) — the suite did not run to completion, no receipt written; retry on a less loaded host, e.g. --host win-wsl`);
|
|
255
|
+
}
|
|
256
|
+
/** @type {Record<string, unknown>} */
|
|
257
|
+
const step = { name: REMOTE_STEP, status: r.code === 0 ? 'pass' : 'fail', exitCode: r.code, durationMs: Date.now() - started };
|
|
258
|
+
if (r.code === 1) {
|
|
259
|
+
step.reason = `remote gate failed on ${r.alias ?? host}`;
|
|
260
|
+
const failedFiles = r.report?.test?.failed_files;
|
|
261
|
+
if (Array.isArray(failedFiles) && failedFiles.length > 0) step.failedFiles = failedFiles;
|
|
262
|
+
}
|
|
263
|
+
return step;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Errors a killed child can still cause while its tree is removed (#1723). */
|
|
267
|
+
const RM_RETRY_CODES = new Set(['ENOTEMPTY', 'EBUSY', 'EPERM']);
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Remove `dir` on the exit path, retrying up to 3 times with a real synchronous pause of
|
|
271
|
+
* 50, 100 and 150 ms. The exit handler sends SIGKILL to the gate's group without awaiting
|
|
272
|
+
* it, so a vitest worker can still write here for a few milliseconds (#1723). `rmSync`'s
|
|
273
|
+
* own `retryDelay` does not help: Node 24 truncates it to whole seconds (measured on
|
|
274
|
+
* v24.22.0: 3 retries at 50 ms waited 0 ms, at 400 ms 2004 ms over two failing operations).
|
|
275
|
+
* Ceiling: a child still writing after ~300 ms leaves its rest in TMPDIR; the last error
|
|
276
|
+
* reaches the exit path, which swallows it.
|
|
277
|
+
*
|
|
278
|
+
* @param {string} dir
|
|
279
|
+
* @returns {void}
|
|
280
|
+
*/
|
|
281
|
+
function removeTreeOnExit(dir) {
|
|
282
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
283
|
+
try {
|
|
284
|
+
rmSync(dir, { recursive: true, force: true });
|
|
285
|
+
return;
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (attempt > 3 || !RM_RETRY_CODES.has(err?.code)) throw err;
|
|
288
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50 * attempt);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Build the curated snapshot of `source`, materialize it outside the repo and run the
|
|
295
|
+
* requested steps there.
|
|
296
|
+
* @param {object} opts
|
|
297
|
+
* @param {string} opts.repoRoot
|
|
298
|
+
* @param {string} [opts.source='HEAD']
|
|
299
|
+
* @param {boolean} [opts.keep=false] keep the materialized directory (returned as `dir`)
|
|
300
|
+
* @param {string} [opts.tmpRoot]
|
|
301
|
+
* @param {string[]} [opts.steps]
|
|
302
|
+
* @param {import('./github-export.mjs').GitRunner} [opts.git]
|
|
303
|
+
* @param {typeof defaultRunner} [opts.runner]
|
|
304
|
+
* @param {string} [opts.nodeModules]
|
|
305
|
+
* @param {boolean} [opts.writeReceipt=true] write the tree-keyed receipt — only when a suite
|
|
306
|
+
* step (`test` or `remote-gate`) ran; a validate-plugin-only run leaves any receipt untouched
|
|
307
|
+
* @param {() => Date} [opts.now]
|
|
308
|
+
* @param {string|null} [opts.host=null] offload host alias: the `test` step then runs as one
|
|
309
|
+
* `remote-gate` step there (validate-plugin stays local)
|
|
310
|
+
* @param {typeof runRemoteGate} [opts.remoteGate]
|
|
311
|
+
* @returns {Promise<object>} probe result; throws SnapshotProbeSystemError on git/tar/fs failure
|
|
312
|
+
*/
|
|
313
|
+
export async function runSnapshotProbe({
|
|
314
|
+
repoRoot,
|
|
315
|
+
source = 'HEAD',
|
|
316
|
+
keep = false,
|
|
317
|
+
tmpRoot = tmpdir(),
|
|
318
|
+
steps = ['validate-plugin', 'test'],
|
|
319
|
+
git = createGitRunner(repoRoot),
|
|
320
|
+
runner = defaultRunner,
|
|
321
|
+
nodeModules = join(repoRoot, 'node_modules'),
|
|
322
|
+
writeReceipt = true,
|
|
323
|
+
now = () => new Date(),
|
|
324
|
+
host = null,
|
|
325
|
+
remoteGate = runRemoteGate,
|
|
326
|
+
}) {
|
|
327
|
+
for (const s of steps) if (!KNOWN_STEPS.includes(s)) throw new TypeError(`unknown step '${s}' (known: ${KNOWN_STEPS.join(', ')})`);
|
|
328
|
+
if (host !== null && (typeof host !== 'string' || !HOST_ALIAS_SHAPE.test(host) || host === '.' || host === '..')) {
|
|
329
|
+
throw new TypeError(`invalid host alias '${host}'`);
|
|
330
|
+
}
|
|
331
|
+
const route = host === null ? 'local' : `remote:${host}`;
|
|
332
|
+
const startedAt = now().toISOString();
|
|
333
|
+
const snap = snapshotTreeOf(git, source);
|
|
334
|
+
if (!snap.ok) throw new SnapshotProbeSystemError(snap.reason);
|
|
335
|
+
|
|
336
|
+
let dir;
|
|
337
|
+
try {
|
|
338
|
+
dir = mkdtempSync(join(tmpRoot, 'so-snapshot-probe-'));
|
|
339
|
+
} catch (err) {
|
|
340
|
+
throw new SnapshotProbeSystemError(`mkdtemp in ${tmpRoot} failed: ${err.message}`);
|
|
341
|
+
}
|
|
342
|
+
// The finally below never runs on SIGINT/SIGTERM/SIGHUP (#1715): register exactly this dir.
|
|
343
|
+
const unregisterCleanup = keep ? () => {} : registerExitCleanup(() => removeTreeOnExit(dir));
|
|
344
|
+
const env = buildProbeEnv();
|
|
345
|
+
try {
|
|
346
|
+
const { root, commit, exported } = materialize({ git, tree: snap.tree, dir, nodeModules, env });
|
|
347
|
+
const expected = snap.sourceFiles - snap.removed.length;
|
|
348
|
+
if (exported !== expected) throw new SnapshotProbeSystemError(`materialized ${exported} files, expected ${expected}`);
|
|
349
|
+
// No `origin`: a test that reaches the source repo through a remote must fail here.
|
|
350
|
+
if (snapshotGit(root, ['remote'], env).trim() !== '') {
|
|
351
|
+
throw new SnapshotProbeSystemError('the snapshot repo has a remote');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const stepResults = [];
|
|
355
|
+
for (const name of steps) {
|
|
356
|
+
if (name === 'test' && host !== null) {
|
|
357
|
+
stepResults.push(await runRemoteStep({ remoteGate, root, commit, repoRoot, host }));
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
const spec = STEP_SPECS[name];
|
|
361
|
+
const res = await runner(spec.cmd, spec.args, { cwd: root, env, timeoutMs: spec.timeoutMs });
|
|
362
|
+
const status = res.timedOut ? 'timeout' : res.status === 0 ? 'pass' : 'fail';
|
|
363
|
+
/** @type {Record<string, unknown>} */
|
|
364
|
+
const step = { name, status, exitCode: res.status, durationMs: res.durationMs };
|
|
365
|
+
if (status !== 'pass') {
|
|
366
|
+
const output = `${res.stdout ?? ''}${res.stderr ? `\n${res.stderr}` : ''}`;
|
|
367
|
+
const failedFiles = parseFailedFiles(output);
|
|
368
|
+
if (failedFiles.length > 0) step.failedFiles = failedFiles;
|
|
369
|
+
step.tail = tailOf(output);
|
|
370
|
+
}
|
|
371
|
+
stepResults.push(step);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const ok = stepResults.every((s) => s.status === 'pass');
|
|
375
|
+
// Receipts are keyed by tree only: a run without a suite step must not replace a fuller one.
|
|
376
|
+
const receiptWritten = writeReceipt && stepResults.some((s) => s.name === 'test' || s.name === REMOTE_STEP);
|
|
377
|
+
const finishedAt = now().toISOString();
|
|
378
|
+
const result = {
|
|
379
|
+
ok,
|
|
380
|
+
sourceSha: snap.sourceSha,
|
|
381
|
+
snapshotTree: snap.tree,
|
|
382
|
+
snapshotCommit: commit,
|
|
383
|
+
route,
|
|
384
|
+
counts: { source: snap.sourceFiles, excluded: snap.removed.length, exported },
|
|
385
|
+
excludedByPrefix: groupByPrefix(snap.removed, snap.excludes),
|
|
386
|
+
hasOrigin: false,
|
|
387
|
+
...(keep ? { dir: root } : {}),
|
|
388
|
+
steps: stepResults,
|
|
389
|
+
receiptWritten,
|
|
390
|
+
startedAt,
|
|
391
|
+
finishedAt,
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
if (receiptWritten) {
|
|
395
|
+
const receipt = {
|
|
396
|
+
schema: RECEIPT_SCHEMA,
|
|
397
|
+
sourceSha: snap.sourceSha,
|
|
398
|
+
snapshotTree: snap.tree,
|
|
399
|
+
ok,
|
|
400
|
+
route,
|
|
401
|
+
steps: stepResults.map(({ name, status, exitCode, durationMs }) => ({ name, status, exitCode, durationMs })),
|
|
402
|
+
finishedAt,
|
|
403
|
+
};
|
|
404
|
+
const file = receiptPath(repoRoot, snap.tree);
|
|
405
|
+
mkdirSync(join(repoRoot, RECEIPT_DIR_REL), { recursive: true });
|
|
406
|
+
const written = writeJsonAtomicSync(file, receipt, { tmpPrefix: '.snapshot-probe' });
|
|
407
|
+
if (!written.ok) throw new SnapshotProbeSystemError(`writing the receipt failed: ${written.error}`);
|
|
408
|
+
}
|
|
409
|
+
return result;
|
|
410
|
+
} finally {
|
|
411
|
+
if (!keep) rmSync(dir, { recursive: true, force: true });
|
|
412
|
+
unregisterCleanup();
|
|
413
|
+
}
|
|
414
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { parseStateMd } from '../state-md/yaml-parser.mjs';
|
|
4
|
+
import { isLifecycleDecayed } from '../agent-status.mjs';
|
|
3
5
|
|
|
4
6
|
export const EVENT_NAMES = new Set([
|
|
5
7
|
'orchestrator.quality_gate.passed', 'orchestrator.quality_gate.failed',
|
|
@@ -38,6 +40,24 @@ export function scopePaths(aggregate, dispatch) {
|
|
|
38
40
|
return [...new Set(rows.flatMap(r => r.files))];
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Why the agent live-map cannot answer "how many agents run in this session" (#1580), or null when it can.
|
|
45
|
+
* A map whose newest record predates the session's lock was never written by this session: its silence is
|
|
46
|
+
* not "0 agents". Own-session records prove the map current whatever their age (a resume keeps the lock).
|
|
47
|
+
*/
|
|
48
|
+
export function agentsStaleReason(agentStatus, ownStatus, lock, sid) {
|
|
49
|
+
if (!sid) return 'no-session';
|
|
50
|
+
if (ownStatus > 0) return null;
|
|
51
|
+
if (agentStatus.source === 'stale-cache') return 'cache-unverifiable';
|
|
52
|
+
// A lifecycle record proves the SubagentStart/Stop writer is wired here: no own record then means 0 agents,
|
|
53
|
+
// not "unknown" — otherwise every fresh session would show "?" (HR-101: a signal that nearly always fires).
|
|
54
|
+
if (Object.values(agentStatus.entries ?? {}).some(a => a?.kind === 'lifecycle')) return null;
|
|
55
|
+
const started = Date.parse(lock?.session_id === sid ? lock.started_at : '');
|
|
56
|
+
if (!Number.isFinite(started)) return 'no-session-start';
|
|
57
|
+
const at = Date.parse(agentStatus.at ?? '');
|
|
58
|
+
return Number.isFinite(at) && at >= started ? null : 'older-than-session';
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
/** All I/O and time are supplied. No writes, host discovery or defaults hidden in this fold. */
|
|
42
62
|
export async function collectSnapshot(repoRoot, readers) {
|
|
43
63
|
const degraded = [];
|
|
@@ -78,15 +98,33 @@ export async function collectSnapshot(repoRoot, readers) {
|
|
|
78
98
|
if (!text(a?.id)) continue;
|
|
79
99
|
entries.set(a.id, { id: a.id, typ: text(a.typ ?? a.agent_type ?? a.type), modellfamilie: text(a.modellfamilie ?? a.model), status: null, tokens: null, letzter_request_at: null, quelle: 'dispatch' });
|
|
80
100
|
}
|
|
101
|
+
let ownStatus = 0;
|
|
102
|
+
let decayed = 0;
|
|
81
103
|
for (const [id, a] of Object.entries(agentStatus.entries ?? {})) {
|
|
82
104
|
// Unbound/foreign status cannot make the current wave look alive.
|
|
83
105
|
if (!sid || (a.session_id ?? a.sessionId) !== sid) continue;
|
|
84
|
-
|
|
106
|
+
// Lifecycle records from the SubagentStart/Stop hooks (#1580) carry no wave: they belong to the session.
|
|
107
|
+
const recordWave = a.wave ?? a.wave_number;
|
|
108
|
+
if (waveNumber && recordWave !== undefined && recordWave !== waveNumber) continue;
|
|
109
|
+
ownStatus++;
|
|
85
110
|
const existing = entries.get(id);
|
|
111
|
+
// A `running` without SubagentStop for longer than LIFECYCLE_DECAY_MS is not counted as running, and not
|
|
112
|
+
// dropped either: it is reported as `decayed`, so a lower running count never reads as fully measured (#1633).
|
|
113
|
+
const isDecayed = isLifecycleDecayed(a, now);
|
|
114
|
+
if (isDecayed) decayed++;
|
|
86
115
|
entries.set(id, { id, typ: text(a.agent_type) ?? existing?.typ ?? null, modellfamilie: text(a.model) ?? existing?.modellfamilie ?? null,
|
|
87
|
-
status: text(a.status ?? a.text), tokens: null, letzter_request_at: null, quelle: existing ? 'dispatch+agent-status' : 'agent-status' });
|
|
116
|
+
status: isDecayed ? 'decayed' : text(a.status ?? a.text), tokens: null, letzter_request_at: null, quelle: existing ? 'dispatch+agent-status' : 'agent-status' });
|
|
88
117
|
}
|
|
89
|
-
|
|
118
|
+
// A ledger longer than the rebuild window is always tail-truncated; with the map verified as live it is still
|
|
119
|
+
// authoritative, so that alone is not a degraded measurement (every subagent appends two ledger lines, #1580).
|
|
120
|
+
const reasons = agentStatus.degraded?.reasons;
|
|
121
|
+
const onlyTruncated = agentStatus.source === 'live-map' && Array.isArray(reasons) && reasons.length === 1 && reasons[0] === 'tail-truncated';
|
|
122
|
+
if (agentStatus.degraded && !onlyTruncated) degraded.push('agents:degraded');
|
|
123
|
+
// Reported in agents.stale_reason, not in `degraded`: a fresh session without subagents is not a partial measurement.
|
|
124
|
+
// The map is dated against the lock beside it (`readers.agentLock`, #1730), not against `repoRoot`'s lock, which
|
|
125
|
+
// may belong to another session when the agents were read at the session's own lock root.
|
|
126
|
+
const agentLock = readers.agentLock ? ((await readers.agentLock()).lock ?? null) : lock;
|
|
127
|
+
const staleReason = agentsStaleReason(agentStatus, ownStatus, agentLock, sid);
|
|
90
128
|
const events = parseEvents(eventsRead.text ?? '');
|
|
91
129
|
const gates = events.filter(e => (e.event ?? e.event_type).startsWith('orchestrator.quality_gate.'));
|
|
92
130
|
const lastGate = gates.reduce((last, event) => {
|
|
@@ -98,6 +136,9 @@ export async function collectSnapshot(repoRoot, readers) {
|
|
|
98
136
|
const latest = name => events.filter(e => (e.event ?? e.event_type) === name && e.dry_run !== true).at(-1);
|
|
99
137
|
const mirror = latest('orchestrator.vault.mirror_run_completed');
|
|
100
138
|
const board = latest('orchestrator.vault.board_written');
|
|
139
|
+
// One spelling per directory: a symlinked worktree path is the same working copy. Unresolvable → the plain path.
|
|
140
|
+
const real = p => fs.realpath(p).catch(() => path.resolve(p));
|
|
141
|
+
const ownReal = await real(repoRoot);
|
|
101
142
|
const peers = await Promise.all((discovery.sessions ?? []).filter(s => s.sessionId !== sid).map(async s => {
|
|
102
143
|
const root = s.worktreePath;
|
|
103
144
|
const peerRead = async file => { try { return JSON.parse(await readers.readText(path.join(root, file))); } catch { return null; } };
|
|
@@ -112,7 +153,7 @@ export async function collectSnapshot(repoRoot, readers) {
|
|
|
112
153
|
const aggregate = currentWave ? await peerRead(`.claude/filescopes/wave-${currentWave}.scopes.json`) : null;
|
|
113
154
|
return { session_id: s.sessionId, branch: text(s.branch), status: bound ? text(peerFm.status) : null,
|
|
114
155
|
current_wave: currentWave, last_heartbeat: peerLock?.session_id === s.sessionId ? text(peerLock.last_heartbeat) : text(s.last_heartbeat),
|
|
115
|
-
gleiches_working_copy:
|
|
156
|
+
gleiches_working_copy: await real(root) === ownReal, scope_paths: dispatchNumber !== null && dispatchNumber !== currentWave ? null : scopePaths(aggregate, pd) };
|
|
116
157
|
}));
|
|
117
158
|
return {
|
|
118
159
|
schema_version: 1, measured_at: new Date(now).toISOString(), repo: { root: repoRoot, name: path.basename(repoRoot) },
|
|
@@ -120,7 +161,10 @@ export async function collectSnapshot(repoRoot, readers) {
|
|
|
120
161
|
session: sid ? { session_id: sid, semantic_session_id: text(lock?.semantic_session_id) } : null,
|
|
121
162
|
wave: { running: waveRead.absent ? false : ownWave ? sessionLockLive : null, number: waveNumber, role: text(ownWave?.role),
|
|
122
163
|
agents_total: Array.isArray(currentDispatch?.agents) ? currentDispatch.agents.length : null, source: ownWave ? '.claude/wave-scope.json' : null },
|
|
123
|
-
agents: { entries: [...entries.values()], source: agentStatus.source ?? (currentDispatch ? 'dispatch' : null), at: agentStatus.at ?? text(currentDispatch?.updated), degraded: agentStatus.degraded ?? null
|
|
164
|
+
agents: { entries: [...entries.values()], source: staleReason === 'older-than-session' && agentStatus.source && agentStatus.source !== 'absent' ? 'stale-cache' : agentStatus.source ?? (currentDispatch ? 'dispatch' : null), at: agentStatus.at ?? text(currentDispatch?.updated), degraded: agentStatus.degraded ?? null,
|
|
165
|
+
// null = unknown ("Agents ?"), never 0: a map that predates the session says nothing about its agents (#1580).
|
|
166
|
+
running: staleReason ? null : [...entries.values()].filter(e => e.status === 'running').length, stale_reason: staleReason,
|
|
167
|
+
decayed: staleReason ? null : decayed },
|
|
124
168
|
state: { status: text(state.status), session_type: text(state['session-type'] ?? state.type ?? state.mode), current_wave: positive(state['current-wave']), total_waves: positive(state['total-waves'] ?? state.waves) },
|
|
125
169
|
gate: { last: lastGate, last_green_sha: typeof greenRead.value === 'string' && /^[a-f0-9]{7,64}$/i.test(greenRead.value.trim()) ? greenRead.value.trim() : null },
|
|
126
170
|
peers, vault: { enabled: config.enabled ?? null, mode: config.mode ?? null,
|
|
@@ -9,13 +9,15 @@ import { readLockDetailed, isLockLive } from '../session-lock.mjs';
|
|
|
9
9
|
import { readTailWindow } from '../tail-window.mjs';
|
|
10
10
|
import { parseSessionConfig } from '../config.mjs';
|
|
11
11
|
import { resolveStateMdPath } from '../state-md.mjs';
|
|
12
|
+
import { resolveOwnLockRootFor } from '../own-lock-root.mjs';
|
|
12
13
|
import { collectSnapshot } from './collect.mjs';
|
|
14
|
+
import { resolveEventsLedgerRoot } from '../platform.mjs';
|
|
13
15
|
const exec = promisify(execFile);
|
|
14
16
|
export const MAX_EVENT_BYTES = 512 * 1024;
|
|
15
17
|
|
|
16
18
|
export function readEventTail(repoRoot) {
|
|
17
19
|
try {
|
|
18
|
-
const tail = readTailWindow(path.join(repoRoot, '.orchestrator/metrics/events.jsonl'), MAX_EVENT_BYTES);
|
|
20
|
+
const tail = readTailWindow(path.join(resolveEventsLedgerRoot(repoRoot), '.orchestrator/metrics/events.jsonl'), MAX_EVENT_BYTES);
|
|
19
21
|
return { text: tail.cut ? tail.text.slice(tail.text.indexOf('\n') + 1) : tail.text, degraded: tail.cut ? ['events:tail-only'] : [] };
|
|
20
22
|
} catch (e) { return { text: '', degraded: [e.code === 'ENOENT' ? 'events:absent' : 'events:unreadable'] }; }
|
|
21
23
|
}
|
|
@@ -66,11 +68,20 @@ async function config(repoRoot) {
|
|
|
66
68
|
}
|
|
67
69
|
return { enabled: null, mode: null };
|
|
68
70
|
}
|
|
69
|
-
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} repoRoot
|
|
73
|
+
* @param {{ sessionId?: string|null }} [opts] `sessionId` = the mod's raw session id (#1730). The agent-status
|
|
74
|
+
* writer (`hooks/subagent-telemetry.mjs`) writes beside the session's own lock, which is not always `repoRoot`:
|
|
75
|
+
* a session working in a worktree by `cd` keeps its lock in the main checkout and the other way round. With an
|
|
76
|
+
* id, agents and the lock that dates them are read there. Without one, everything is read at `repoRoot`.
|
|
77
|
+
*/
|
|
78
|
+
export async function measure(repoRoot, { sessionId = null } = {}) {
|
|
79
|
+
const ownRoot = sessionId ? resolveOwnLockRootFor({ sessionId, cwd: repoRoot }).root : null;
|
|
70
80
|
return collectSnapshot(repoRoot, {
|
|
71
81
|
now: Date.now, readText: file => fs.readFile(file, 'utf8'), lock: root => readLockDetailed({ repoRoot: root }),
|
|
72
|
-
isLockLive, agents: root => readCurrentStatus({ repoRoot: root }), discover, events: readEventTail, config,
|
|
82
|
+
isLockLive, agents: root => readCurrentStatus({ repoRoot: ownRoot ?? root }), discover, events: readEventTail, config,
|
|
83
|
+
...(ownRoot ? { agentLock: () => readLockDetailed({ repoRoot: ownRoot }) } : {}),
|
|
73
84
|
statePath: path.relative(repoRoot, resolveStateMdPath(repoRoot)),
|
|
74
|
-
sessionId: process.env.CLAUDE_SESSION_ID ?? process.env.SO_SESSION_ID ?? null,
|
|
85
|
+
sessionId: sessionId ?? process.env.CLAUDE_SESSION_ID ?? process.env.SO_SESSION_ID ?? null,
|
|
75
86
|
});
|
|
76
87
|
}
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
resolveIssueBudgetSessionId,
|
|
45
45
|
} from './issue-budget.mjs';
|
|
46
46
|
import { resolveProjectDir } from './platform.mjs';
|
|
47
|
+
import { resolveOwnLockRoot } from '../../hooks/_lib/hook-root.mjs';
|
|
47
48
|
import { resolveRepoSpec } from './vcs-repo-spec.mjs';
|
|
48
49
|
|
|
49
50
|
/**
|
|
@@ -167,8 +168,14 @@ function runCli(cmd, args, repoRoot) {
|
|
|
167
168
|
let currentSession = null;
|
|
168
169
|
if (typeof nativeRawId === 'string' && nativeRawId.length > 0) {
|
|
169
170
|
try {
|
|
171
|
+
// #1605: after /clear in an entered worktree the session's lock and
|
|
172
|
+
// current-session.json live in that worktree, not at the launch root.
|
|
173
|
+
// Read the record beside the own lock — the same lookup
|
|
174
|
+
// `hooks/pre-bash-issue-budget.mjs` uses — so both producers charge
|
|
175
|
+
// the ledger (which stays at `budgetRoot`) under the same session key.
|
|
176
|
+
const ownRoot = resolveOwnLockRoot({ session_id: nativeRawId, cwd: process.cwd() }, budgetRoot);
|
|
170
177
|
const parsed = JSON.parse(
|
|
171
|
-
readFileSync(path.join(
|
|
178
|
+
readFileSync(path.join(ownRoot, '.orchestrator', 'current-session.json'), 'utf8'),
|
|
172
179
|
);
|
|
173
180
|
// SESSION BINDING (#1058). `current-session.json` is repo-global —
|
|
174
181
|
// every session sharing this working copy writes the same path — so
|