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
|
@@ -47,6 +47,7 @@ import { isLockShape } from './session-lock-shape.mjs';
|
|
|
47
47
|
import { isPidAliveOnHost } from './file-lock.mjs';
|
|
48
48
|
import { writeJsonAtomicSync } from './io.mjs';
|
|
49
49
|
import { hostnamesMatch, lockHostCandidate, recordHostAlias, stableHostname } from './host-identity.mjs';
|
|
50
|
+
import { isSafeSessionId } from './fleet-protocol.mjs';
|
|
50
51
|
|
|
51
52
|
// isPidAliveOnHost moved into file-lock.mjs in #630 (the file-lock primitive
|
|
52
53
|
// owns it so the dependency edge points file-lock → io, never the reverse).
|
|
@@ -133,6 +134,13 @@ export const LOCK_LEFTOVER_MIN_AGE_MS = 60 * 60 * 1000;
|
|
|
133
134
|
*/
|
|
134
135
|
export const OWNER_PROOF_RELPATH = '.orchestrator/runtime/lock-owner-proof.json';
|
|
135
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Directory of the "released on purpose" markers (#1651), relative to the repo
|
|
139
|
+
* root — one `<raw session_id>.json` per session that gave its lock back via
|
|
140
|
+
* `scripts/release-session-lock.mjs`. Gitignored like the owner proof beside it.
|
|
141
|
+
*/
|
|
142
|
+
export const RELEASE_MARKER_RELDIR = '.orchestrator/runtime/lock-released';
|
|
143
|
+
|
|
136
144
|
// ---------------------------------------------------------------------------
|
|
137
145
|
// Internal helpers
|
|
138
146
|
// ---------------------------------------------------------------------------
|
|
@@ -875,6 +883,10 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
|
|
|
875
883
|
function withOwnerProof(result, repoRoot) {
|
|
876
884
|
const w = writeOwnerProof({ repoRoot, lock: result.lock });
|
|
877
885
|
result.ownerProof = w.ok ? { ok: true } : { ok: false, reason: w.reason };
|
|
886
|
+
// #1651: the session holds a lock again, so its earlier "released on
|
|
887
|
+
// purpose" marker no longer describes it. This is the one junction every
|
|
888
|
+
// ok:true write of acquire()/forceAcquire() passes. Never throws.
|
|
889
|
+
clearReleaseMarker({ repoRoot, sessionId: result.lock?.session_id });
|
|
878
890
|
return result;
|
|
879
891
|
}
|
|
880
892
|
|
|
@@ -1253,6 +1265,108 @@ export function loadOwnerProof({ repoRoot } = {}) {
|
|
|
1253
1265
|
}
|
|
1254
1266
|
}
|
|
1255
1267
|
|
|
1268
|
+
/**
|
|
1269
|
+
* Absolute path of the release marker for `sessionId`, or null when the id is
|
|
1270
|
+
* not a safe single file-name component (#1651). The id comes from a lock file
|
|
1271
|
+
* or a CLI flag, so it is validated before it reaches a path: no separator, no
|
|
1272
|
+
* `.`/`..`, at most 128 chars of `[A-Za-z0-9._-]`.
|
|
1273
|
+
*
|
|
1274
|
+
* @param {string|undefined} repoRoot
|
|
1275
|
+
* @param {unknown} sessionId
|
|
1276
|
+
* @returns {string|null}
|
|
1277
|
+
*/
|
|
1278
|
+
function releaseMarkerPath(repoRoot, sessionId) {
|
|
1279
|
+
if (!isSafeSessionId(sessionId)) return null;
|
|
1280
|
+
return path.join(repoRoot ?? process.cwd(), RELEASE_MARKER_RELDIR, `${sessionId}.json`);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Record that `sessionId` deliberately released its own lock (#1651), so the
|
|
1285
|
+
* SessionStart bootstrap does not re-create it on a `compact` or `resume` of
|
|
1286
|
+
* that same raw session — a headless run may be using the slot. Atomic
|
|
1287
|
+
* (tmp+rename). Never throws.
|
|
1288
|
+
*
|
|
1289
|
+
* Ceiling (BV-004): markers are never pruned — one small file per released
|
|
1290
|
+
* session id. Revisit if `.orchestrator/runtime/lock-released/` grows past a
|
|
1291
|
+
* few hundred entries.
|
|
1292
|
+
*
|
|
1293
|
+
* `reason` (optional, #1747) records why the lock was given back; the
|
|
1294
|
+
* SessionStart bootstrap writes `'lead-yield'` and honours such a marker only
|
|
1295
|
+
* while a fleet lead's lease is active. Absent = an explicit release.
|
|
1296
|
+
*
|
|
1297
|
+
* @param {{ repoRoot?: string, sessionId: string, reason?: string }} args
|
|
1298
|
+
* @returns {{ ok: true, path: string } | { ok: false, reason: string, error?: string }}
|
|
1299
|
+
*/
|
|
1300
|
+
export function writeReleaseMarker({ repoRoot, sessionId, reason } = {}) {
|
|
1301
|
+
try {
|
|
1302
|
+
const file = releaseMarkerPath(repoRoot, sessionId);
|
|
1303
|
+
if (file === null) return { ok: false, reason: 'unsafe-session-id' };
|
|
1304
|
+
const w = writeJsonAtomicSync(
|
|
1305
|
+
file,
|
|
1306
|
+
{
|
|
1307
|
+
schema_version: 1,
|
|
1308
|
+
session_id: sessionId,
|
|
1309
|
+
released_at: nowIso(),
|
|
1310
|
+
host: stableHostname(),
|
|
1311
|
+
...(typeof reason === 'string' ? { reason } : {}),
|
|
1312
|
+
},
|
|
1313
|
+
{ tmpPrefix: '.lock-released.tmp' },
|
|
1314
|
+
);
|
|
1315
|
+
if (!w.ok) return { ok: false, reason: 'fs-error', error: w.error };
|
|
1316
|
+
return { ok: true, path: file };
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* True when a release marker for `sessionId` exists (#1651). Never throws; an
|
|
1324
|
+
* unsafe id or any fs error reads as "no marker", i.e. the pre-#1651 behaviour.
|
|
1325
|
+
*
|
|
1326
|
+
* @param {{ repoRoot?: string, sessionId: string }} args
|
|
1327
|
+
* @returns {boolean}
|
|
1328
|
+
*/
|
|
1329
|
+
export function hasReleaseMarker({ repoRoot, sessionId } = {}) {
|
|
1330
|
+
try {
|
|
1331
|
+
const file = releaseMarkerPath(repoRoot, sessionId);
|
|
1332
|
+
return file !== null && fs.lstatSync(file).isFile();
|
|
1333
|
+
} catch {
|
|
1334
|
+
return false;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* The release marker body for `sessionId`, or null when it is absent, unsafe,
|
|
1340
|
+
* not a regular file or not a JSON object (#1747). Never throws. Existence
|
|
1341
|
+
* alone is {@link hasReleaseMarker}; this reader only adds the `reason`.
|
|
1342
|
+
*
|
|
1343
|
+
* @param {{ repoRoot?: string, sessionId: string }} args
|
|
1344
|
+
* @returns {{ reason?: unknown } & Record<string, unknown> | null}
|
|
1345
|
+
*/
|
|
1346
|
+
export function readReleaseMarker({ repoRoot, sessionId } = {}) {
|
|
1347
|
+
try {
|
|
1348
|
+
const file = releaseMarkerPath(repoRoot, sessionId);
|
|
1349
|
+
if (file === null || !fs.lstatSync(file).isFile()) return null;
|
|
1350
|
+
const body = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1351
|
+
return body !== null && typeof body === 'object' && !Array.isArray(body) ? body : null;
|
|
1352
|
+
} catch {
|
|
1353
|
+
return null;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Remove the release marker for `sessionId` (#1651). Best-effort, never throws.
|
|
1359
|
+
*
|
|
1360
|
+
* @param {{ repoRoot?: string, sessionId: string }} args
|
|
1361
|
+
* @returns {void}
|
|
1362
|
+
*/
|
|
1363
|
+
export function clearReleaseMarker({ repoRoot, sessionId } = {}) {
|
|
1364
|
+
try {
|
|
1365
|
+
const file = releaseMarkerPath(repoRoot, sessionId);
|
|
1366
|
+
if (file !== null) fs.rmSync(file, { force: true });
|
|
1367
|
+
} catch { /* best-effort */ }
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1256
1370
|
/**
|
|
1257
1371
|
* Release the lock IFF it belongs to the given session_id.
|
|
1258
1372
|
* Silent no-op when the lock belongs to a different session or does not exist.
|
|
@@ -72,6 +72,7 @@ import { atomicWriteWithBackup, envelopeToError } from './io.mjs';
|
|
|
72
72
|
import { validateSession as defaultValidateSession } from './session-schema/validator.mjs';
|
|
73
73
|
import { serializeSessionLineChecked as defaultSerialize } from './session-schema.mjs';
|
|
74
74
|
import { checkSessionsIntegrity as defaultCheckIntegrity } from './sessions-integrity-banner.mjs';
|
|
75
|
+
import { resolveEventsLedgerRoot } from './platform.mjs';
|
|
75
76
|
|
|
76
77
|
// ---------------------------------------------------------------------------
|
|
77
78
|
// Constants
|
|
@@ -263,6 +264,34 @@ function preserveRaw(out, key, rawValue, rescued) {
|
|
|
263
264
|
rescued.add(sidecar);
|
|
264
265
|
}
|
|
265
266
|
|
|
267
|
+
/**
|
|
268
|
+
* True when `flags` lists `completed_at` as incomplete although `rec.completed_at`
|
|
269
|
+
* IS a measured end: the record's own valid, monotonic `ended_at` (verbatim or
|
|
270
|
+
* its millisecond copy). Records repaired before #1699 item 12 carry exactly
|
|
271
|
+
* this stale flag (#1705).
|
|
272
|
+
*
|
|
273
|
+
* The ONE predicate behind both the heal in {@link repairRecord} and the
|
|
274
|
+
* routing of an otherwise schema-valid line in {@link repairLine} — two copies
|
|
275
|
+
* would drift, and a routing that is wider than the heal re-serializes a line
|
|
276
|
+
* for nothing, one that is narrower leaves the heal unreachable.
|
|
277
|
+
*
|
|
278
|
+
* @param {unknown} flags — the record's `_backfill_incomplete_fields`
|
|
279
|
+
* @param {object} rec — the record whose timestamps are judged
|
|
280
|
+
* @returns {boolean}
|
|
281
|
+
*/
|
|
282
|
+
function hasStaleCompletedAtFlag(flags, rec) {
|
|
283
|
+
if (!Array.isArray(flags) || !flags.includes('completed_at')) return false;
|
|
284
|
+
if (typeof rec.completed_at !== 'string' || typeof rec.ended_at !== 'string' ||
|
|
285
|
+
typeof rec.started_at !== 'string') return false;
|
|
286
|
+
const endedNorm = normalizeTimestamp(rec.ended_at);
|
|
287
|
+
const endedMs = Date.parse(rec.ended_at);
|
|
288
|
+
const startedMs = Date.parse(rec.started_at);
|
|
289
|
+
const endedValid = Number.isFinite(endedMs) && !(endedNorm !== null && 'invalid' in endedNorm);
|
|
290
|
+
const isCopy = rec.completed_at === rec.ended_at ||
|
|
291
|
+
(endedNorm !== null && rec.completed_at === endedNorm.value);
|
|
292
|
+
return endedValid && Number.isFinite(startedMs) && endedMs >= startedMs && isCopy;
|
|
293
|
+
}
|
|
294
|
+
|
|
266
295
|
/** Compact ISO stamp for backup filenames: `20260805T091500Z`. */
|
|
267
296
|
export function backupStamp(date = new Date()) {
|
|
268
297
|
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
@@ -342,9 +371,17 @@ export function repairRecord(record) {
|
|
|
342
371
|
// -- total_waves ----------------------------------------------------------
|
|
343
372
|
if (!isCount(out.total_waves)) {
|
|
344
373
|
preserveRaw(out, 'total_waves', record.total_waves, rescued);
|
|
345
|
-
out.total_waves = wavesNumber !== null ? wavesNumber : out.waves.length;
|
|
346
374
|
defects.push('total_waves_missing');
|
|
347
|
-
|
|
375
|
+
// A numeric `waves` IS the wave count (see above): measured, not
|
|
376
|
+
// defaulted, so NOT listed — the same rule as the `agents_total` /
|
|
377
|
+
// `files_changed` aliases below (#1699 item 12). Only the fallback to the
|
|
378
|
+
// (possibly just-defaulted) array length is an unverified default.
|
|
379
|
+
if (wavesNumber !== null) {
|
|
380
|
+
out.total_waves = wavesNumber;
|
|
381
|
+
} else {
|
|
382
|
+
out.total_waves = out.waves.length;
|
|
383
|
+
incomplete.add('total_waves');
|
|
384
|
+
}
|
|
348
385
|
}
|
|
349
386
|
|
|
350
387
|
// -- agent_summary --------------------------------------------------------
|
|
@@ -377,27 +414,58 @@ export function repairRecord(record) {
|
|
|
377
414
|
// -- total_agents ---------------------------------------------------------
|
|
378
415
|
if (!isCount(out.total_agents)) {
|
|
379
416
|
preserveRaw(out, 'total_agents', record.total_agents, rescued);
|
|
380
|
-
//
|
|
417
|
+
// Only the record's own evidence counts: an agent_summary PRESENT in the
|
|
381
418
|
// original sums to the real agent count (live line 71 sums to 30 where
|
|
382
|
-
// waves.length is 5 — W2/A4 review finding)
|
|
383
|
-
//
|
|
419
|
+
// waves.length is 5 — W2/A4 review finding); else the legacy alias
|
|
420
|
+
// `agents_total` carries the same fact under an older name (#1698). Never
|
|
421
|
+
// waves.length: a wave is not an agent (a record with `agents_total: 19`
|
|
422
|
+
// and five waves was repaired to 5). No evidence → transparently 0.
|
|
384
423
|
const summarySum = isPlainObject(record.agent_summary)
|
|
385
424
|
? AGENT_SUMMARY_FIELDS.reduce(
|
|
386
425
|
(n, f) => n + (isCount(record.agent_summary[f]) ? record.agent_summary[f] : 0),
|
|
387
426
|
0
|
|
388
427
|
)
|
|
389
428
|
: 0;
|
|
390
|
-
out.total_agents = summarySum > 0 ? summarySum : out.waves.length;
|
|
391
429
|
defects.push('total_agents_missing');
|
|
392
|
-
|
|
430
|
+
if (summarySum > 0) {
|
|
431
|
+
// A sum over counters that may themselves be partial: an inference, so
|
|
432
|
+
// it stays listed as incomplete (unchanged since W2/A4).
|
|
433
|
+
out.total_agents = summarySum;
|
|
434
|
+
incomplete.add('total_agents');
|
|
435
|
+
// A measured `agents_total` that disagrees with the sum is a conflict
|
|
436
|
+
// between two of the record's own sources; the sum still wins, but the
|
|
437
|
+
// record must say so (#1699 item 10). Same forensics tag shape as
|
|
438
|
+
// `_completed_at_conflict` (session-schema/aliases.mjs); the alias key
|
|
439
|
+
// stays on the record, so both values remain readable.
|
|
440
|
+
if (isCount(record.agents_total) && record.agents_total !== summarySum) {
|
|
441
|
+
out._total_agents_conflict = true;
|
|
442
|
+
defects.push('total_agents_conflict');
|
|
443
|
+
}
|
|
444
|
+
} else if (isCount(record.agents_total)) {
|
|
445
|
+
// The same fact under its old name: measured, not defaulted — so NOT
|
|
446
|
+
// listed as incomplete (the list is the set of defaulted fields, see the
|
|
447
|
+
// module docblock). The alias key itself stays on the record untouched,
|
|
448
|
+
// like legacy `ended_at` below.
|
|
449
|
+
out.total_agents = record.agents_total;
|
|
450
|
+
} else {
|
|
451
|
+
out.total_agents = 0;
|
|
452
|
+
incomplete.add('total_agents');
|
|
453
|
+
}
|
|
393
454
|
}
|
|
394
455
|
|
|
395
456
|
// -- total_files_changed --------------------------------------------------
|
|
396
457
|
if (!isCount(out.total_files_changed)) {
|
|
397
458
|
preserveRaw(out, 'total_files_changed', record.total_files_changed, rescued);
|
|
398
|
-
out.total_files_changed = 0;
|
|
399
459
|
defects.push('total_files_changed_missing');
|
|
400
|
-
|
|
460
|
+
// Legacy alias `files_changed` is the same count under an older name
|
|
461
|
+
// (#1698: records with `files_changed: 44` were repaired to 0). A non-count
|
|
462
|
+
// alias (e.g. a path list) is no proof → 0, flagged as defaulted.
|
|
463
|
+
if (isCount(record.files_changed)) {
|
|
464
|
+
out.total_files_changed = record.files_changed;
|
|
465
|
+
} else {
|
|
466
|
+
out.total_files_changed = 0;
|
|
467
|
+
incomplete.add('total_files_changed');
|
|
468
|
+
}
|
|
401
469
|
}
|
|
402
470
|
|
|
403
471
|
// -- timestamp precision (#1442) -------------------------------------------
|
|
@@ -424,6 +492,7 @@ export function repairRecord(record) {
|
|
|
424
492
|
// the ledger is not chronologically ordered (see the module docblock).
|
|
425
493
|
if (typeof out.completed_at !== 'string' && typeof out.started_at === 'string') {
|
|
426
494
|
preserveRaw(out, 'completed_at', record.completed_at, rescued);
|
|
495
|
+
let measuredEnd = false;
|
|
427
496
|
const endedMs = typeof out.ended_at === 'string' ? Date.parse(out.ended_at) : NaN;
|
|
428
497
|
const startedMs = Date.parse(out.started_at);
|
|
429
498
|
const endedNorm = normalizeTimestamp(out.ended_at);
|
|
@@ -440,11 +509,36 @@ export function repairRecord(record) {
|
|
|
440
509
|
offsetNormalized ||= endedNorm.offset;
|
|
441
510
|
}
|
|
442
511
|
out.completed_at = endedNorm?.value ?? out.ended_at;
|
|
512
|
+
// The same measurement under its legacy name (aliasLegacyEndedAt flags
|
|
513
|
+
// nothing either): NOT listed, or `sessions-staleness-banner.mjs` would
|
|
514
|
+
// discard a real end time as an anchor (#1699 item 12).
|
|
515
|
+
measuredEnd = true;
|
|
443
516
|
} else {
|
|
444
517
|
out.completed_at = out.started_at;
|
|
445
518
|
}
|
|
446
519
|
defects.push('completed_at_missing');
|
|
447
|
-
incomplete.add('completed_at');
|
|
520
|
+
if (!measuredEnd) incomplete.add('completed_at');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// -- stale completed_at flag (#1705) ----------------------------------------
|
|
524
|
+
// Records repaired before #1699 item 12 copied a valid, monotonic `ended_at`
|
|
525
|
+
// into `completed_at` AND listed it as incomplete. The branch above only runs
|
|
526
|
+
// when `completed_at` is missing, so those records never healed and the
|
|
527
|
+
// staleness banner kept discarding a real end time. Same predicate as the
|
|
528
|
+
// `measuredEnd` branch: `completed_at` IS that ended_at (verbatim or its
|
|
529
|
+
// millisecond copy), parseable, not before `started_at`.
|
|
530
|
+
// Measured 2026-10-21: 0 ledger records carry the sibling `total_waves`
|
|
531
|
+
// flag beside a numeric `waves`, so that path needs no heal.
|
|
532
|
+
const priorFlags = Array.isArray(record._backfill_incomplete_fields)
|
|
533
|
+
? record._backfill_incomplete_fields
|
|
534
|
+
: [];
|
|
535
|
+
let healedFlags = null;
|
|
536
|
+
if (hasStaleCompletedAtFlag(priorFlags, out)) {
|
|
537
|
+
// The flag list is the record's own provenance; keep the prior list
|
|
538
|
+
// exactly once in its sidecar before rewriting it (process-contracts.md).
|
|
539
|
+
preserveRaw(out, 'backfill_incomplete_fields', record._backfill_incomplete_fields, rescued);
|
|
540
|
+
healedFlags = priorFlags.filter((f) => f !== 'completed_at');
|
|
541
|
+
defects.push('completed_at_flag_stale');
|
|
448
542
|
}
|
|
449
543
|
if (precisionCut) defects.push('timestamp_precision');
|
|
450
544
|
if (offsetNormalized) defects.push('timestamp_offset');
|
|
@@ -454,9 +548,7 @@ export function repairRecord(record) {
|
|
|
454
548
|
}
|
|
455
549
|
|
|
456
550
|
// -- provenance (additive, already schema-accepted) ------------------------
|
|
457
|
-
const prior =
|
|
458
|
-
? record._backfill_incomplete_fields.filter((f) => typeof f === 'string')
|
|
459
|
-
: [];
|
|
551
|
+
const prior = (healedFlags ?? priorFlags).filter((f) => typeof f === 'string');
|
|
460
552
|
const merged = orderIncompleteFields(incomplete);
|
|
461
553
|
// The repair is recorded in its own field; `_backfill_source` keeps the
|
|
462
554
|
// record's ORIGIN. Overwriting a synthesised origin ('state-md-completed',
|
|
@@ -483,17 +575,25 @@ export function repairRecord(record) {
|
|
|
483
575
|
* - `unparseable` — `JSON.parse` threw; emitted unchanged. File corruption is
|
|
484
576
|
* a different problem and the integrity banner skips these
|
|
485
577
|
* too; silently "fixing" bytes we cannot read is worse.
|
|
486
|
-
* - `valid` — passed `validateSession`; emitted BYTE-FOR-BYTE.
|
|
487
|
-
*
|
|
578
|
+
* - `valid` — passed `validateSession`; emitted BYTE-FOR-BYTE. The ONE
|
|
579
|
+
* exception is below.
|
|
580
|
+
* - `repaired` — defects fixed, gated, re-serialized. `healedValid` is
|
|
581
|
+
* true when the line was ALREADY schema-valid and only
|
|
582
|
+
* carried a stale `completed_at` flag (#1705, see
|
|
583
|
+
* `hasStaleCompletedAtFlag`): such a line is routed through
|
|
584
|
+
* `repairRecord` too, otherwise that heal is unreachable from
|
|
585
|
+
* the ledger path. Callers counting INVALID lines must not
|
|
586
|
+
* count these. Should the healed record fail the gate, the
|
|
587
|
+
* line stays `valid` and byte-identical — it was valid before.
|
|
488
588
|
* - `error` — still invalid after repair; ORIGINAL emitted unchanged.
|
|
489
589
|
*
|
|
490
590
|
* @param {string} line
|
|
491
591
|
* @param {{validateSession?: Function, serialize?: Function}} [deps]
|
|
492
|
-
* @returns {{line: string, status: string, sessionId: string|null, defects: string[], incompleteFields: string[], error: string|null, record: object|null}}
|
|
592
|
+
* @returns {{line: string, status: string, sessionId: string|null, defects: string[], incompleteFields: string[], error: string|null, record: object|null, healedValid: boolean}}
|
|
493
593
|
*/
|
|
494
594
|
export function repairLine(line, deps = {}) {
|
|
495
595
|
const { validateSession = defaultValidateSession, serialize = defaultSerialize } = deps;
|
|
496
|
-
const base = { line, status: 'valid', sessionId: null, defects: [], incompleteFields: [], error: null, record: null };
|
|
596
|
+
const base = { line, status: 'valid', sessionId: null, defects: [], incompleteFields: [], error: null, record: null, healedValid: false };
|
|
497
597
|
|
|
498
598
|
if (line.trim().length === 0) return { ...base, status: 'blank' };
|
|
499
599
|
|
|
@@ -509,16 +609,22 @@ export function repairLine(line, deps = {}) {
|
|
|
509
609
|
|
|
510
610
|
const sessionId = typeof record.session_id === 'string' ? record.session_id : null;
|
|
511
611
|
|
|
612
|
+
let validBefore = false;
|
|
512
613
|
try {
|
|
513
614
|
validateSession(record);
|
|
514
|
-
|
|
515
|
-
// would rewrite key order for no benefit and bury the real repairs in noise.
|
|
516
|
-
return { ...base, status: 'valid', sessionId, record };
|
|
615
|
+
validBefore = true;
|
|
517
616
|
} catch {
|
|
518
617
|
/* fall through to repair */
|
|
519
618
|
}
|
|
619
|
+
// Already valid — emit the ORIGINAL bytes. Re-serializing a passing record
|
|
620
|
+
// would rewrite key order for no benefit and bury the real repairs in noise.
|
|
621
|
+
// Sole exception: the stale completed_at flag no schema check sees (#1705).
|
|
622
|
+
if (validBefore && !hasStaleCompletedAtFlag(record._backfill_incomplete_fields, record)) {
|
|
623
|
+
return { ...base, status: 'valid', sessionId, record };
|
|
624
|
+
}
|
|
520
625
|
|
|
521
|
-
const { record: repaired, defects, incompleteFields } = repairRecord(record);
|
|
626
|
+
const { record: repaired, defects, incompleteFields, changed } = repairRecord(record);
|
|
627
|
+
if (validBefore && !changed) return { ...base, status: 'valid', sessionId, record };
|
|
522
628
|
try {
|
|
523
629
|
// GATE ONLY — the return value is deliberately discarded. `validateSession`
|
|
524
630
|
// stamps `schema_version: 2` on its output, and live line 152 has no
|
|
@@ -534,8 +640,10 @@ export function repairLine(line, deps = {}) {
|
|
|
534
640
|
incompleteFields,
|
|
535
641
|
error: null,
|
|
536
642
|
record: repaired,
|
|
643
|
+
healedValid: validBefore,
|
|
537
644
|
};
|
|
538
645
|
} catch (err) {
|
|
646
|
+
if (validBefore) return { ...base, status: 'valid', sessionId, record };
|
|
539
647
|
return {
|
|
540
648
|
...base,
|
|
541
649
|
status: 'error',
|
|
@@ -568,6 +676,9 @@ export function repairText(raw, deps = {}) {
|
|
|
568
676
|
let unparseable = 0;
|
|
569
677
|
let invalidBefore = 0;
|
|
570
678
|
let repaired = 0;
|
|
679
|
+
// Schema-valid lines whose stale completed_at flag was healed (#1705). They
|
|
680
|
+
// count under `repaired` (the line IS rewritten) but never as invalid.
|
|
681
|
+
let healedValid = 0;
|
|
571
682
|
|
|
572
683
|
for (let i = 0; i < results.length; i++) {
|
|
573
684
|
const r = results[i];
|
|
@@ -579,6 +690,12 @@ export function repairText(raw, deps = {}) {
|
|
|
579
690
|
continue;
|
|
580
691
|
}
|
|
581
692
|
if (r.status === 'valid') continue;
|
|
693
|
+
if (r.healedValid) {
|
|
694
|
+
healedValid += 1;
|
|
695
|
+
repaired += 1;
|
|
696
|
+
for (const d of r.defects) defectsByClass[d] = (defectsByClass[d] ?? 0) + 1;
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
582
699
|
invalidBefore += 1;
|
|
583
700
|
for (const d of r.defects) defectsByClass[d] = (defectsByClass[d] ?? 0) + 1;
|
|
584
701
|
if (r.status === 'repaired') {
|
|
@@ -601,8 +718,10 @@ export function repairText(raw, deps = {}) {
|
|
|
601
718
|
unparseable,
|
|
602
719
|
invalid_before: invalidBefore,
|
|
603
720
|
repaired,
|
|
721
|
+
// Subset of `repaired`: lines that were already schema-valid (#1705).
|
|
722
|
+
healed_valid: healedValid,
|
|
604
723
|
// Projected: every line that was invalid and did NOT get repaired.
|
|
605
|
-
invalid_after: invalidBefore - repaired,
|
|
724
|
+
invalid_after: invalidBefore - (repaired - healedValid),
|
|
606
725
|
// Lines with no defensible repair (`repairLine` status `error`). They are
|
|
607
726
|
// emitted byte-identically and are EXPECTED to stay invalid after a
|
|
608
727
|
// write — `verifyWritten` tolerates exactly these line numbers (#1442).
|
|
@@ -623,8 +742,8 @@ export function repairText(raw, deps = {}) {
|
|
|
623
742
|
* `checkSessionsIntegrity` additionally exercises the REAL vault-mirror render
|
|
624
743
|
* path — a record can pass the first and still be dropped by the second.
|
|
625
744
|
*
|
|
626
|
-
* The integrity probe reads `<repoRoot>/.orchestrator/metrics/sessions.jsonl`
|
|
627
|
-
* by construction, so it is only meaningful when `file` IS that path; against
|
|
745
|
+
* The integrity probe reads `<ledger root of repoRoot>/.orchestrator/metrics/sessions.jsonl`
|
|
746
|
+
* (the main checkout's for a linked worktree, #1592) by construction, so it is only meaningful when `file` IS that path; against
|
|
628
747
|
* any other target it is reported as skipped rather than quietly measuring the
|
|
629
748
|
* wrong file.
|
|
630
749
|
*
|
|
@@ -706,10 +825,14 @@ export function verifyWritten({ file, repoRoot, deps = {}, expected = {} }) {
|
|
|
706
825
|
};
|
|
707
826
|
}
|
|
708
827
|
|
|
709
|
-
/**
|
|
828
|
+
/**
|
|
829
|
+
* True when `file` IS the ledger `checkIntegrity` reads for `repoRoot` —
|
|
830
|
+
* resolved through the same main-checkout mapping (#1592), so a stale
|
|
831
|
+
* worktree-local ledger is never judged by the main ledger's integrity.
|
|
832
|
+
*/
|
|
710
833
|
function isCanonicalLedger(file, repoRoot) {
|
|
711
834
|
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return false;
|
|
712
|
-
return path.resolve(file) === path.resolve(path.join(repoRoot, CANONICAL_LEDGER_REL));
|
|
835
|
+
return path.resolve(file) === path.resolve(path.join(resolveEventsLedgerRoot(repoRoot), CANONICAL_LEDGER_REL));
|
|
713
836
|
}
|
|
714
837
|
|
|
715
838
|
/**
|
|
@@ -736,14 +859,17 @@ function integrityWorsened(before, after) {
|
|
|
736
859
|
* write. Apply copies the original to `<file>.bak-<stamp>` FIRST, writes
|
|
737
860
|
* `<file>.tmp-<pid>` in the same directory and renames it over the target
|
|
738
861
|
* (atomic within a filesystem), then re-verifies the written file. A failed
|
|
739
|
-
* verification restores the backup byte-identically
|
|
862
|
+
* verification restores the backup byte-identically (plus any line appended
|
|
863
|
+
* after the rename, #1670) and reports `ok: false` —
|
|
740
864
|
* the caller maps that to exit 3. Lines with no defensible repair are kept
|
|
741
865
|
* byte-identical and counted in `summary.unrepairable`; they are NOT a
|
|
742
866
|
* verification failure (see `verifyWritten` § WHAT "OK" MEANS) — the caller
|
|
743
867
|
* maps an ok run with an unrepairable remainder to exit 4.
|
|
744
868
|
*
|
|
745
869
|
* Never throws for a defective RECORD (those land in `summary.errors`); a
|
|
746
|
-
* genuine I/O failure DOES throw and is the caller's exit-2 case
|
|
870
|
+
* genuine I/O failure DOES throw and is the caller's exit-2 case — as does a
|
|
871
|
+
* ledger that changed between read and rename (`err.code === 'ELEDGERCHANGED'`,
|
|
872
|
+
* #1665): the tmp is removed and the original, with its new line, stays as-is.
|
|
747
873
|
*
|
|
748
874
|
* @param {object} args
|
|
749
875
|
* @param {string} args.file ledger path
|
|
@@ -761,6 +887,7 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
|
|
|
761
887
|
renameSync = fs.renameSync,
|
|
762
888
|
copyFileSync = fs.copyFileSync,
|
|
763
889
|
unlinkSync = fs.unlinkSync,
|
|
890
|
+
appendFileSync = fs.appendFileSync,
|
|
764
891
|
} = deps;
|
|
765
892
|
|
|
766
893
|
const { checkIntegrity = defaultCheckIntegrity } = deps;
|
|
@@ -798,9 +925,34 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
|
|
|
798
925
|
// The helper unlinks its tmp on failure; the backup above keeps its own
|
|
799
926
|
// `.bak-<compact stamp>` schema, so the helper takes none. The injected fs
|
|
800
927
|
// seams (`deps`) are threaded through so a test fake still sees the write.
|
|
928
|
+
//
|
|
929
|
+
// Lost-update guard (#1665): appenders (`emit-session.mjs` → `appendJsonl`)
|
|
930
|
+
// take NO lock — they rely on O_APPEND — so a repair-side lock would exclude
|
|
931
|
+
// nobody. Instead the ledger is re-read right before the rename and the swap
|
|
932
|
+
// is refused when its bytes differ from what was repaired: an append between
|
|
933
|
+
// read and rename would otherwise be overwritten silently. Throwing inside the
|
|
934
|
+
// rename seam makes the helper unlink its tmp and leave the target untouched;
|
|
935
|
+
// the caller re-raises it (CLI exit 2) and a re-run picks up the new line.
|
|
936
|
+
// CEILING: the window between this check and the rename itself stays open
|
|
937
|
+
// (microseconds). Revisit trigger: if appenders ever take a shared lock,
|
|
938
|
+
// replace this check with that lock held across read→rename.
|
|
939
|
+
// The exact bytes renamed into place — the restore below carries over only
|
|
940
|
+
// what lies BEYOND them (#1670).
|
|
941
|
+
let renamedBytes = null;
|
|
942
|
+
const guardedRename = (from, to) => {
|
|
943
|
+
if (readFileSync(file, 'utf8') !== raw) {
|
|
944
|
+
const err = new Error(
|
|
945
|
+
`${file} changed during the repair (concurrent append?) — nothing was replaced; re-run the repair`
|
|
946
|
+
);
|
|
947
|
+
err.code = 'ELEDGERCHANGED';
|
|
948
|
+
throw err;
|
|
949
|
+
}
|
|
950
|
+
renamedBytes = readFileSync(from);
|
|
951
|
+
renameSync(from, to);
|
|
952
|
+
};
|
|
801
953
|
const res = atomicWriteWithBackup(file, text, {
|
|
802
954
|
tmpPrefix: `.${path.basename(file)}.tmp`,
|
|
803
|
-
fs: { writeFileSync, renameSync, unlinkSync },
|
|
955
|
+
fs: { writeFileSync, renameSync: guardedRename, unlinkSync },
|
|
804
956
|
});
|
|
805
957
|
if (!res.ok) throw envelopeToError(res);
|
|
806
958
|
|
|
@@ -825,15 +977,73 @@ export function repairLedger({ file, repoRoot = null, apply = false, backup = tr
|
|
|
825
977
|
// emitted byte-identically and keep the ledger exactly as broken as it was.
|
|
826
978
|
// The backup is preferred (it is the on-disk artefact an operator can
|
|
827
979
|
// inspect); `raw` is the in-memory fallback for `--no-backup`, and both are
|
|
828
|
-
// the same bytes.
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
}
|
|
980
|
+
// the same bytes. Lines appended AFTER the rename are carried over (#1670).
|
|
981
|
+
summary.restore_appended_bytes = restoreOriginal({
|
|
982
|
+
file, backupPath, raw, renamed: renamedBytes,
|
|
983
|
+
fs: { readFileSync, writeFileSync, renameSync, copyFileSync, unlinkSync, appendFileSync },
|
|
984
|
+
});
|
|
834
985
|
summary.ok = false;
|
|
835
986
|
summary.restored = true;
|
|
836
987
|
}
|
|
837
988
|
|
|
838
989
|
return summary;
|
|
839
990
|
}
|
|
991
|
+
|
|
992
|
+
/** How often the restore re-reads a ledger that keeps moving before it gives up. */
|
|
993
|
+
const RESTORE_ATTEMPTS = 3;
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Put the original bytes back after a failed post-verification WITHOUT losing a
|
|
997
|
+
* line another process appended after the repair's rename (#1670).
|
|
998
|
+
*
|
|
999
|
+
* Appenders take no lock (O_APPEND, see the lost-update guard in
|
|
1000
|
+
* {@link repairLedger}), so between the rename and this restore a session
|
|
1001
|
+
* record can land at the end of the repaired file. A plain copy of the backup
|
|
1002
|
+
* over the target dropped it. The restore therefore re-reads the target: the
|
|
1003
|
+
* bytes beyond the renamed content are appended to the original, and the result
|
|
1004
|
+
* is swapped in through a tmp file + rename that is refused when the target
|
|
1005
|
+
* moved again since that read (bounded retry). When the target no longer starts
|
|
1006
|
+
* with the renamed bytes, someone rewrote it — restoring would destroy that
|
|
1007
|
+
* write, so nothing is touched and ELEDGERCHANGED is raised (the backup stays).
|
|
1008
|
+
*
|
|
1009
|
+
* CEILING: as in the repair swap, the window between the last re-read and the
|
|
1010
|
+
* rename itself stays open (microseconds). Revisit trigger: appenders taking a
|
|
1011
|
+
* shared lock — hold it across read→rename instead.
|
|
1012
|
+
*
|
|
1013
|
+
* @returns {number} bytes carried over from concurrent appends
|
|
1014
|
+
*/
|
|
1015
|
+
function restoreOriginal({ file, backupPath, raw, renamed, fs: io }) {
|
|
1016
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.restore-${process.pid}`);
|
|
1017
|
+
for (let attempt = 0; attempt < RESTORE_ATTEMPTS; attempt++) {
|
|
1018
|
+
const current = io.readFileSync(file);
|
|
1019
|
+
if (current.length < renamed.length || !current.subarray(0, renamed.length).equals(renamed)) {
|
|
1020
|
+
const err = new Error(
|
|
1021
|
+
`${file} was rewritten after the repair's rename — post-verification failed, but restoring ` +
|
|
1022
|
+
`would overwrite that write; nothing was restored. Original bytes: ${backupPath ?? '(no backup taken)'}`
|
|
1023
|
+
);
|
|
1024
|
+
err.code = 'ELEDGERCHANGED';
|
|
1025
|
+
throw err;
|
|
1026
|
+
}
|
|
1027
|
+
const tail = current.subarray(renamed.length);
|
|
1028
|
+
try {
|
|
1029
|
+
if (backupPath) io.copyFileSync(backupPath, tmp);
|
|
1030
|
+
else io.writeFileSync(tmp, raw, 'utf8');
|
|
1031
|
+
if (tail.length > 0) io.appendFileSync(tmp, tail);
|
|
1032
|
+
if (!io.readFileSync(file).equals(current)) {
|
|
1033
|
+
io.unlinkSync(tmp);
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
io.renameSync(tmp, file);
|
|
1037
|
+
return tail.length;
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
try { io.unlinkSync(tmp); } catch { /* already gone */ }
|
|
1040
|
+
throw err;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
const err = new Error(
|
|
1044
|
+
`${file} kept changing during the restore (concurrent appends?) — post-verification failed and ` +
|
|
1045
|
+
`nothing was restored. Original bytes: ${backupPath ?? '(no backup taken)'}`
|
|
1046
|
+
);
|
|
1047
|
+
err.code = 'ELEDGERCHANGED';
|
|
1048
|
+
throw err;
|
|
1049
|
+
}
|