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
|
@@ -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
|
+
}
|
|
@@ -19,9 +19,18 @@
|
|
|
19
19
|
* started_at, last_heartbeat, status ('active'|'wave'|'idle'),
|
|
20
20
|
* current_wave, host_class, mode (Epic #583 W2-I3),
|
|
21
21
|
* role ('navigator'|null, optional, #1462 — display only, never ownership;
|
|
22
|
-
* set by heartbeat(patch.role), NOT by registerSelf)
|
|
22
|
+
* set by heartbeat(patch.role), NOT by registerSelf),
|
|
23
|
+
* session_root (absolute realpath|null, optional, #1605 — where THIS
|
|
24
|
+
* session's `.orchestrator/session.lock` and `current-session.json`
|
|
25
|
+
* live; set by registerSelf),
|
|
26
|
+
* work_root (absolute realpath|null, optional, #1605 — the working copy the
|
|
27
|
+
* session works in; set by registerSelf, refreshed by
|
|
28
|
+
* heartbeat(patch.workRoot) at every turn end)
|
|
23
29
|
* }
|
|
24
30
|
*
|
|
31
|
+
* `session_root` / `work_root` are additive: readers that predate #1605 ignore
|
|
32
|
+
* them, and `_validEntry` does not require them.
|
|
33
|
+
*
|
|
25
34
|
* Schema v2 (Epic #583, W2-I3): `mode` field added so the exclusivity-matrix
|
|
26
35
|
* can classify cross-repo registry entries correctly. Without it, every
|
|
27
36
|
* registry-sourced peer was bucketed as `mode='session'` → classifyMode threw
|
|
@@ -38,7 +47,7 @@ import crypto from 'node:crypto';
|
|
|
38
47
|
import { promises as fs } from 'node:fs';
|
|
39
48
|
import { digestSha256 } from './crypto-digest-utils.mjs';
|
|
40
49
|
|
|
41
|
-
import { appendFileSync, mkdirSync, realpathSync } from 'node:fs';
|
|
50
|
+
import { appendFileSync, chmodSync, mkdirSync, realpathSync } from 'node:fs';
|
|
42
51
|
|
|
43
52
|
import { utcTimestamp, appendJsonl } from './common.mjs';
|
|
44
53
|
import { resolvePrivateConfigDir } from './config/private-config-dir.mjs';
|
|
@@ -109,6 +118,23 @@ function resolveRepoPath(repoPath) {
|
|
|
109
118
|
}
|
|
110
119
|
}
|
|
111
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Canonical spelling of an optional absolute root for `session_root` /
|
|
123
|
+
* `work_root` (#1605): `null` when absent, the realpath otherwise.
|
|
124
|
+
*
|
|
125
|
+
* @param {unknown} value
|
|
126
|
+
* @param {string} name option name for the error message
|
|
127
|
+
* @returns {string|null}
|
|
128
|
+
* @throws {TypeError} when `value` is neither nullish nor an absolute path string
|
|
129
|
+
*/
|
|
130
|
+
function _optionalRoot(value, name) {
|
|
131
|
+
if (value === undefined || value === null) return null;
|
|
132
|
+
if (typeof value !== 'string' || !path.isAbsolute(value)) {
|
|
133
|
+
throw new TypeError(`${name} must be an absolute path when provided`);
|
|
134
|
+
}
|
|
135
|
+
return resolveRepoPath(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
112
138
|
function _assertSessionId(sessionId) {
|
|
113
139
|
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
114
140
|
throw new TypeError('session_id must be a non-empty string');
|
|
@@ -120,9 +146,11 @@ function _assertSessionId(sessionId) {
|
|
|
120
146
|
}
|
|
121
147
|
|
|
122
148
|
async function _writeJsonAtomic(filePath, data, { mode = 0o600 } = {}) {
|
|
123
|
-
|
|
149
|
+
// 0o700: the registry sits in the private config dir; a bare mkdir leaves it
|
|
150
|
+
// 0777 under umask 000 and breaks the owner-policy check beside it (#1629).
|
|
151
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
124
152
|
const tmp = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
125
|
-
await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
153
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode }); // no 0666 window (#1632)
|
|
126
154
|
await fs.rename(tmp, filePath);
|
|
127
155
|
try { await fs.chmod(filePath, mode); } catch { /* best effort */ }
|
|
128
156
|
}
|
|
@@ -218,8 +246,10 @@ export function logSweepEvent({ event, session_id, error }) {
|
|
|
218
246
|
}) + '\n';
|
|
219
247
|
try {
|
|
220
248
|
const logPath = sweepLogPath();
|
|
221
|
-
try { mkdirSync(path.dirname(logPath), { recursive: true }); } catch { /* best effort */ }
|
|
222
|
-
appendFileSync(logPath, line, 'utf8');
|
|
249
|
+
try { mkdirSync(path.dirname(logPath), { recursive: true, mode: 0o700 }); } catch { /* best effort */ } // #1629
|
|
250
|
+
appendFileSync(logPath, line, { encoding: 'utf8', mode: 0o600 }); // #1632
|
|
251
|
+
// `mode` applies on creation only: tighten a loose pre-existing log too (#1636).
|
|
252
|
+
try { chmodSync(logPath, 0o600); } catch { /* best effort */ }
|
|
223
253
|
} catch { /* last-resort silent no-op — never let log-write cascade */ }
|
|
224
254
|
}
|
|
225
255
|
|
|
@@ -235,6 +265,10 @@ export function logSweepEvent({ event, session_id, error }) {
|
|
|
235
265
|
* @param {number} [opts.pid] — defaults to process.pid
|
|
236
266
|
* @param {string} [opts.status] — 'active' | 'wave' | 'idle' (default 'active')
|
|
237
267
|
* @param {number} [opts.currentWave] — default 0
|
|
268
|
+
* @param {string} [opts.sessionRoot] — absolute root holding this session's
|
|
269
|
+
* `session.lock` / `current-session.json` (#1605); stored as `session_root`.
|
|
270
|
+
* @param {string} [opts.workRoot] — absolute working copy the session works in
|
|
271
|
+
* (#1605); stored as `work_root`.
|
|
238
272
|
* @param {string} [opts.mode] — session mode (e.g., 'deep', 'feature', 'housekeeping').
|
|
239
273
|
* Schema v2 (Epic #583, W2-I3): when present, propagated into the entry so
|
|
240
274
|
* discovery + exclusivity-matrix classification work for cross-repo peers.
|
|
@@ -258,6 +292,8 @@ export async function registerSelf(opts) {
|
|
|
258
292
|
if (opts.semanticSessionId !== undefined && opts.semanticSessionId !== null && typeof opts.semanticSessionId !== 'string') {
|
|
259
293
|
throw new TypeError('registerSelf: semanticSessionId must be a string when provided');
|
|
260
294
|
}
|
|
295
|
+
const sessionRoot = _optionalRoot(opts.sessionRoot, 'registerSelf: sessionRoot');
|
|
296
|
+
const workRoot = _optionalRoot(opts.workRoot, 'registerSelf: workRoot');
|
|
261
297
|
const now = utcTimestamp();
|
|
262
298
|
const entry = {
|
|
263
299
|
session_id: sessionId,
|
|
@@ -273,6 +309,8 @@ export async function registerSelf(opts) {
|
|
|
273
309
|
current_wave: opts.currentWave ?? 0,
|
|
274
310
|
host_class: opts.hostClass ?? null,
|
|
275
311
|
mode: opts.mode ?? null,
|
|
312
|
+
session_root: sessionRoot,
|
|
313
|
+
work_root: workRoot,
|
|
276
314
|
};
|
|
277
315
|
await _writeJsonAtomic(entryPath(sessionId), entry);
|
|
278
316
|
return entry;
|
|
@@ -287,16 +325,20 @@ export const REGISTRY_ROLES = Object.freeze(['navigator']);
|
|
|
287
325
|
*
|
|
288
326
|
* `patch.role`: `undefined` keeps the stored role, `null` clears it, a member
|
|
289
327
|
* of `REGISTRY_ROLES` sets it. Anything else throws before the file is touched.
|
|
328
|
+
* `patch.workRoot` (#1605): `undefined` keeps the stored `work_root`, an
|
|
329
|
+
* absolute path replaces it (stored as its realpath).
|
|
290
330
|
*
|
|
291
331
|
* @param {string} sessionId
|
|
292
|
-
* @param {{ status?: string, currentWave?: number, role?: string|null }} [patch]
|
|
332
|
+
* @param {{ status?: string, currentWave?: number, role?: string|null, workRoot?: string }} [patch]
|
|
293
333
|
* @returns {Promise<object|null>} the updated entry, or null if none exists.
|
|
294
|
-
* @throws {TypeError} when `patch.role` is neither undefined, null, nor a REGISTRY_ROLES
|
|
334
|
+
* @throws {TypeError} when `patch.role` is neither undefined, null, nor a REGISTRY_ROLES
|
|
335
|
+
* member, or `patch.workRoot` is neither undefined nor an absolute path.
|
|
295
336
|
*/
|
|
296
337
|
export async function heartbeat(sessionId, patch = {}) {
|
|
297
338
|
if (patch.role !== undefined && patch.role !== null && !REGISTRY_ROLES.includes(patch.role)) {
|
|
298
339
|
throw new TypeError('heartbeat: role must be "navigator" or null');
|
|
299
340
|
}
|
|
341
|
+
const workRoot = patch.workRoot === undefined ? undefined : _optionalRoot(patch.workRoot, 'heartbeat: workRoot');
|
|
300
342
|
const file = entryPath(sessionId);
|
|
301
343
|
const existing = await _readJsonSafe(file);
|
|
302
344
|
if (!_validEntry(existing)) return null;
|
|
@@ -306,6 +348,7 @@ export async function heartbeat(sessionId, patch = {}) {
|
|
|
306
348
|
...(patch.status !== undefined ? { status: patch.status } : {}),
|
|
307
349
|
...(patch.currentWave !== undefined ? { current_wave: patch.currentWave } : {}),
|
|
308
350
|
...(patch.role !== undefined ? { role: patch.role } : {}),
|
|
351
|
+
...(workRoot !== undefined && workRoot !== null ? { work_root: workRoot } : {}),
|
|
309
352
|
};
|
|
310
353
|
await _writeJsonAtomic(file, updated);
|
|
311
354
|
return updated;
|
|
@@ -363,6 +406,26 @@ export async function detectPeers({ sessionId, freshnessMin = 15 } = {}) {
|
|
|
363
406
|
});
|
|
364
407
|
}
|
|
365
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Count registry entries whose `started_at` lies within the last `windowMin`
|
|
411
|
+
* minutes — the start-wave signal (#1650). Pure: pass the entries you already
|
|
412
|
+
* hold. Passing detectPeers() output is complete as long as `windowMin` is at
|
|
413
|
+
* most detectPeers' `freshnessMin` (15): registerSelf() writes `started_at`
|
|
414
|
+
* and `last_heartbeat` with the same stamp, so every entry started inside the
|
|
415
|
+
* window is still heartbeat-fresh. Self must already be excluded by the caller.
|
|
416
|
+
* Unparseable or future `started_at` never counts (`_ageMinutes` → Infinity).
|
|
417
|
+
*
|
|
418
|
+
* @param {object[]} entries Registry entries (e.g. detectPeers() result)
|
|
419
|
+
* @param {object} [opts]
|
|
420
|
+
* @param {number} [opts.windowMin=5]
|
|
421
|
+
* @param {number} [opts.now] ms-since-epoch (test seam)
|
|
422
|
+
* @returns {number}
|
|
423
|
+
*/
|
|
424
|
+
export function countRecentStarts(entries, { windowMin = 5, now = Date.now() } = {}) {
|
|
425
|
+
if (!Array.isArray(entries)) return 0;
|
|
426
|
+
return entries.filter((e) => e && _ageMinutes(e.started_at, now) <= windowMin).length;
|
|
427
|
+
}
|
|
428
|
+
|
|
366
429
|
/**
|
|
367
430
|
* Remove heartbeat entries older than `thresholdMin` minutes. Appends one
|
|
368
431
|
* JSONL line per removal to sweepLogPath() for observability.
|
|
@@ -412,7 +475,7 @@ export async function sweepZombies({ thresholdMin = 60, now = Date.now() } = {})
|
|
|
412
475
|
file: name,
|
|
413
476
|
age_minutes: Number.isFinite(age) ? Math.round(age) : null,
|
|
414
477
|
reason: valid ? 'stale-heartbeat' : 'malformed-entry',
|
|
415
|
-
});
|
|
478
|
+
}, { mode: 0o600 }); // #1632
|
|
416
479
|
logged += 1;
|
|
417
480
|
} catch { /* log best-effort */ }
|
|
418
481
|
} catch { /* file might have been removed by another sweep */ }
|
|
@@ -45,6 +45,8 @@ export const SESSION_KEY_ALIASES = Object.freeze({
|
|
|
45
45
|
issues_filed: 'issues_created',
|
|
46
46
|
issues_planned: 'planned_issues',
|
|
47
47
|
files_changed: 'total_files_changed',
|
|
48
|
+
// No `agents_total` alias (#1699): session-record-repair.mjs lets the agent_summary sum win over it
|
|
49
|
+
// and flags a disagreement; an alias here would make normalize/migrate prefer agents_total instead.
|
|
48
50
|
snapshots: 'snapshots_created',
|
|
49
51
|
learnings: 'learnings_added',
|
|
50
52
|
waves_total: 'total_waves',
|
|
@@ -252,6 +254,13 @@ export const OPTIONAL_FIELDS = Object.freeze([
|
|
|
252
254
|
// 2026-10-01), so the counters say which of the two it is.
|
|
253
255
|
'cost_records_priced',
|
|
254
256
|
'cost_records_total',
|
|
257
|
+
// #1659 — `[{ tree, start_ref }]`: the work trees this session committed in,
|
|
258
|
+
// from its own `orchestrator.session.work_tree` events. Written by
|
|
259
|
+
// scripts/emit-session.mjs only when it adds something beyond
|
|
260
|
+
// `session_start_ref` (≥2 trees, or one tree that is not the own checkout).
|
|
261
|
+
// Shape (absolute `tree`, full hex `start_ref`) is enforced at that writer —
|
|
262
|
+
// malformed entries are dropped with a WARN — not in validateSession().
|
|
263
|
+
'session_work_trees',
|
|
255
264
|
// #1470 Pkt 4/6 — what `total_cost_usd` is and which price table produced
|
|
256
265
|
// the cost fields. Merged by scripts/emit-session.mjs from
|
|
257
266
|
// scripts/lib/telemetry/pricing.mjs (`COST_BASIS`, `PRICING_TABLE_DATE`):
|