session-orchestrator 5.12.0 → 5.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +4 -1
- package/.codex-plugin/agents/explorer.toml +1 -1
- package/.codex-plugin/agents/session-reviewer.toml +2 -2
- package/.codex-plugin/agents/wave-worker.toml +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/.orchestrator/policy/blocked-commands.json +8 -0
- package/.orchestrator/policy/public-export.json +30 -0
- package/CHANGELOG.md +162 -4
- package/README.md +126 -127
- package/agents/analyst.md +7 -2
- package/agents/architect-reviewer.md +7 -2
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +9 -2
- package/agents/dialectic-deriver.md +9 -3
- package/agents/docs-writer.md +7 -2
- package/agents/eval-judge.md +7 -1
- package/agents/qa-strategist.md +7 -2
- package/agents/security-reviewer.md +9 -2
- package/agents/session-reviewer.md +18 -2
- package/agents/skill-applied-judge.md +7 -1
- package/agents/test-writer.md +9 -2
- package/agents/ui-developer.md +9 -2
- package/agents/ux-evaluator.md +6 -1
- package/commands/session.md +1 -1
- package/commands/templates-ack.md +89 -18
- package/docs/agent-authoring.md +30 -11
- package/docs/ci-setup.md +38 -2
- package/docs/codex-setup.md +33 -9
- package/docs/components.md +13 -2
- package/docs/cursor-setup.md +3 -3
- package/docs/events-schema.md +5 -4
- package/docs/evidence-candidates.md +63 -0
- package/docs/fleet-checkin-audit.md +7 -4
- package/docs/github-mirror-protection.md +150 -3
- package/docs/install.md +90 -17
- package/docs/instruction-delivery.md +1 -1
- package/docs/migration-v4.md +2 -2
- package/docs/mods.md +118 -0
- package/docs/owner-config-schema.md +1 -0
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/pm-skills-marketplace.md +4 -3
- package/docs/rule-authoring.md +24 -13
- package/docs/scope-collision-guard.md +2 -1
- package/docs/session-config-reference.md +49 -15
- package/docs/session-config-template.md +30 -22
- package/docs/sessions-ledger-repair.md +137 -0
- package/docs/vault-docs-architecture.md +1 -1
- package/hooks/_lib/hook-import-set.json +406 -73
- package/hooks/_lib/hook-root.mjs +301 -0
- package/hooks/_lib/lock-bootstrap.mjs +434 -4
- package/hooks/_lib/orchestrator-ignore.mjs +3 -0
- package/hooks/_lib/subagent-transcript.mjs +2 -2
- package/hooks/_lib/vcs-create-matcher.mjs +13 -17
- package/hooks/cwd-change-restore.mjs +5 -1
- package/hooks/enforce-commands.mjs +4 -3
- package/hooks/enforce-scope.mjs +11 -1
- package/hooks/hooks-codex.json +48 -2
- package/hooks/hooks.json +31 -31
- package/hooks/on-session-end.mjs +38 -15
- package/hooks/on-session-start.mjs +139 -49
- package/hooks/on-stop.mjs +11 -6
- package/hooks/post-bash-issue-budget-refund.mjs +6 -1
- package/hooks/post-bash-write-verify.mjs +8 -4
- package/hooks/post-edit-validate.mjs +45 -52
- package/hooks/post-subagent-discovery-validator.mjs +2 -2
- package/hooks/post-tool-batch-wave-signal.mjs +8 -4
- package/hooks/post-tool-failure-corrective-context.mjs +5 -1
- package/hooks/pre-bash-destructive-guard.mjs +220 -8
- package/hooks/pre-bash-issue-budget.mjs +6 -1
- package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
- package/hooks/pre-bash-staging-fence.mjs +258 -14
- package/hooks/pre-bash-templates-first.mjs +28 -27
- package/hooks/run-node.sh +1 -1
- package/hooks/skill-invocation-telemetry.mjs +15 -8
- package/hooks/subagent-telemetry.mjs +77 -14
- package/hooks/wave-scope-commit-guard.mjs +143 -14
- package/mods/so-hud/.claude-plugin/plugin.json +11 -0
- package/mods/so-hud/README.md +57 -0
- package/mods/so-hud/hooks/beacon-io.mjs +101 -0
- package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
- package/mods/so-hud/hooks/hooks.json +1 -0
- package/mods/so-hud/hooks/logic.ts +182 -0
- package/mods/so-hud/hooks/register.tsx +670 -0
- package/mods/so-hud/hooks/rules.ts +2998 -0
- package/mods/so-hud/tsconfig.json +8 -0
- package/mods/so-hud/types/index.d.ts +36 -0
- package/package.json +4 -2
- package/rules/_index.md +1 -1
- package/rules/always-on/ask-via-tool.md +1 -1
- package/rules/always-on/loop-and-monitor.md +17 -194
- package/rules/always-on/parallel-sessions.md +3 -1
- package/rules/opt-in-stack/backend-data.md +1 -1
- package/rules/opt-in-stack/backend.md +1 -1
- package/rules/opt-in-stack/security-web.md +5 -1
- package/scripts/agent-usage-census.mjs +79 -0
- package/scripts/apply-session-learnings.mjs +34 -6
- package/scripts/archive-closed-prds.mjs +21 -5
- package/scripts/autopilot.mjs +11 -5
- package/scripts/backfill-abandoned-sessions.mjs +13 -3
- package/scripts/backfill-evidence-digest.mjs +2 -1
- package/scripts/backfill-learnings-from-vault.mjs +61 -6
- package/scripts/backfill-sessions.mjs +16 -2
- package/scripts/check-package-manager.mjs +26 -8
- package/scripts/check-sessions-integrity.mjs +4 -1
- package/scripts/ci/assert-vitest-green.mjs +3 -0
- package/scripts/ci/windows-smoke.mjs +201 -0
- package/scripts/close-precheck.mjs +55 -0
- package/scripts/dialectic-deriver.mjs +20 -12
- package/scripts/emit-event.mjs +2 -2
- package/scripts/emit-session.mjs +239 -20
- package/scripts/eval-session.mjs +17 -3
- package/scripts/export-hw-learnings.mjs +29 -2
- package/scripts/fleet-checkin-audit.mjs +8 -4
- package/scripts/fleet-checkin.mjs +39 -27
- package/scripts/gc-stale-worktrees.mjs +6 -1
- package/scripts/github-export.mjs +128 -0
- package/scripts/github-protection-audit.mjs +3 -1
- package/scripts/handover-queue-check.mjs +209 -0
- package/scripts/harness-audit.mjs +6 -4
- package/scripts/lib/agent-frontmatter.mjs +57 -10
- package/scripts/lib/agent-status.mjs +125 -7
- package/scripts/lib/agent-usage-census.mjs +394 -0
- package/scripts/lib/auto-dialectic.mjs +17 -10
- package/scripts/lib/auto-dream.mjs +22 -14
- package/scripts/lib/autopilot/loop.mjs +3 -1
- package/scripts/lib/autopilot/recent-runs.mjs +4 -2
- package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
- package/scripts/lib/autopilot/telemetry.mjs +5 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/baseline-archetypes.mjs +10 -2
- package/scripts/lib/blocked-commands-policy.mjs +4 -3
- package/scripts/lib/build-live-signals.mjs +13 -9
- package/scripts/lib/ci-status-banner.mjs +329 -24
- package/scripts/lib/codex/plugin-contract.mjs +8 -0
- package/scripts/lib/cold-start-detector.mjs +5 -2
- package/scripts/lib/command-blocker.mjs +603 -43
- package/scripts/lib/common.mjs +3 -2
- package/scripts/lib/config/cross-repo.mjs +74 -7
- package/scripts/lib/config/host-paths.mjs +6 -2
- package/scripts/lib/config/section-extractor.mjs +65 -11
- package/scripts/lib/config/template-keys.mjs +153 -0
- package/scripts/lib/config/vault-integration.mjs +1 -0
- package/scripts/lib/config.mjs +2 -2
- package/scripts/lib/coordinator-snapshot.mjs +1 -1
- package/scripts/lib/discovery/triage-state.mjs +2 -1
- package/scripts/lib/dispatcher/rank.mjs +5 -7
- package/scripts/lib/ecosystem-health.mjs +2 -2
- package/scripts/lib/eval/engine.mjs +3 -2
- package/scripts/lib/eval/session-resolve.mjs +6 -2
- package/scripts/lib/eval/sink.mjs +4 -3
- package/scripts/lib/events-retention-banner.mjs +3 -2
- package/scripts/lib/events-schema.mjs +10 -0
- package/scripts/lib/events.mjs +23 -12
- package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
- package/scripts/lib/fleet-protocol.mjs +4 -0
- package/scripts/lib/gates/gate-full.mjs +172 -11
- package/scripts/lib/gates/gate-helpers.mjs +236 -23
- package/scripts/lib/git-config-drift.mjs +141 -7
- package/scripts/lib/github-export.mjs +447 -0
- package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
- package/scripts/lib/handover-queue-marks.mjs +159 -0
- package/scripts/lib/hardware-pattern-detector.mjs +103 -13
- package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
- package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
- package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
- package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
- package/scripts/lib/host-identity.mjs +9 -5
- package/scripts/lib/http-request.mjs +64 -0
- package/scripts/lib/instruction-budget-guard.mjs +30 -22
- package/scripts/lib/io.mjs +6 -3
- package/scripts/lib/issue-budget-reconcile.mjs +3 -2
- package/scripts/lib/issue-budget.mjs +36 -7
- package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
- package/scripts/lib/learnings/io.mjs +289 -15
- package/scripts/lib/learnings/select.mjs +53 -4
- package/scripts/lib/learnings/store-role.mjs +209 -0
- package/scripts/lib/learnings/surface.mjs +46 -7
- package/scripts/lib/ledger-path.mjs +113 -0
- package/scripts/lib/maintenance-due-banner.mjs +63 -12
- package/scripts/lib/memory-banner.mjs +5 -6
- package/scripts/lib/memory-proposals/collector.mjs +5 -2
- package/scripts/lib/memory-proposals/sink.mjs +7 -4
- package/scripts/lib/memory-proposals/store.mjs +22 -5
- package/scripts/lib/mode-selector-accuracy.mjs +2 -1
- package/scripts/lib/no-follow-open.mjs +57 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
- package/scripts/lib/own-lock-root.mjs +95 -0
- package/scripts/lib/owner-interview.mjs +2 -1
- package/scripts/lib/owner-yaml.mjs +70 -7
- package/scripts/lib/platform.mjs +161 -9
- package/scripts/lib/plugin-update-banner.mjs +15 -11
- package/scripts/lib/pre-push-local-partial.mjs +117 -0
- package/scripts/lib/pre-push-remote-gate.mjs +151 -25
- package/scripts/lib/process-group.mjs +270 -34
- package/scripts/lib/project-hygiene.mjs +28 -0
- package/scripts/lib/quality-gate.mjs +33 -9
- package/scripts/lib/reconcile/backlog.mjs +29 -15
- package/scripts/lib/reconcile/engine.mjs +26 -2
- package/scripts/lib/reconcile/idempotency.mjs +65 -3
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
- package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
- package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
- package/scripts/lib/resource-probe/evaluate.mjs +32 -9
- package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
- package/scripts/lib/rule-loader.mjs +20 -10
- package/scripts/lib/scope-baseline.mjs +123 -8
- package/scripts/lib/scope-echo.mjs +5 -2
- package/scripts/lib/scope-gate.mjs +55 -20
- package/scripts/lib/session-close-backfill.mjs +75 -7
- package/scripts/lib/session-end/close-precheck.mjs +151 -0
- package/scripts/lib/session-end/phase-skip.mjs +8 -7
- package/scripts/lib/session-end/tail-runner.mjs +7 -2
- package/scripts/lib/session-id.mjs +9 -1
- package/scripts/lib/session-identity/own-session.mjs +63 -0
- package/scripts/lib/session-lock.mjs +114 -0
- package/scripts/lib/session-record-repair.mjs +245 -35
- package/scripts/lib/session-registry.mjs +72 -9
- package/scripts/lib/session-schema/constants.mjs +9 -0
- package/scripts/lib/session-schema/filters.mjs +56 -1
- package/scripts/lib/session-schema/validator.mjs +15 -5
- package/scripts/lib/session-start-probes.mjs +149 -22
- package/scripts/lib/session-token-rollup.mjs +2 -1
- package/scripts/lib/session-work-trees.mjs +166 -0
- package/scripts/lib/sessions-canonical.mjs +61 -3
- package/scripts/lib/sessions-integrity-banner.mjs +17 -39
- package/scripts/lib/sessions-staleness-banner.mjs +8 -5
- package/scripts/lib/sh-quote.mjs +1 -0
- package/scripts/lib/skill-evidence-window.mjs +5 -1
- package/scripts/lib/skill-health/join.mjs +10 -9
- package/scripts/lib/skill-invocations-schema.mjs +13 -0
- package/scripts/lib/skill-judgments-schema.mjs +6 -2
- package/scripts/lib/snapshot-probe.mjs +414 -0
- package/scripts/lib/so-hud/collect.mjs +49 -5
- package/scripts/lib/so-hud/runtime.mjs +15 -4
- package/scripts/lib/spiral-carryover.mjs +8 -1
- package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
- package/scripts/lib/state-md/idle-reset.mjs +14 -5
- package/scripts/lib/state-md/mission-status.mjs +5 -1
- package/scripts/lib/state-md-peer-guard.mjs +2 -0
- package/scripts/lib/state-md.mjs +31 -8
- package/scripts/lib/sunset/walker.mjs +4 -3
- package/scripts/lib/telemetry/consent.mjs +4 -2
- package/scripts/lib/telemetry/queue.mjs +3 -3
- package/scripts/lib/telemetry/sync.mjs +45 -9
- package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
- package/scripts/lib/tmux-layout/layouts.mjs +20 -3
- package/scripts/lib/ux-grill/paths.mjs +5 -1
- package/scripts/lib/validate/check-agents.mjs +37 -6
- package/scripts/lib/validate/check-component-paths.mjs +25 -1
- package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
- package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
- package/scripts/lib/validate/check-json-files.mjs +3 -1
- package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
- package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
- package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
- package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
- package/scripts/lib/validate/check-unwired-features.mjs +4 -2
- package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
- package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
- package/scripts/lib/validate/confidential-names.mjs +3 -1
- package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
- package/scripts/lib/vault-mirror/namespace.mjs +19 -7
- package/scripts/lib/vault-mirror/process.mjs +56 -27
- package/scripts/lib/vault-mirror/prune.mjs +5 -2
- package/scripts/lib/vault-mirror/render-learnings.mjs +3 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
- package/scripts/lib/vault-mirror/retention.mjs +2 -2
- package/scripts/lib/vault-mirror/utils.mjs +79 -0
- package/scripts/lib/vault-staleness-banner.mjs +4 -7
- package/scripts/lib/vault-status/board-writer.mjs +2 -1
- package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
- package/scripts/lib/win-process-tree.mjs +137 -0
- package/scripts/lib/workspace.mjs +1 -1
- package/scripts/lib/worktree/constants.mjs +1 -1
- package/scripts/lib/worktree/lifecycle.mjs +1 -1
- package/scripts/lib/worktree/listing.mjs +1 -1
- package/scripts/lib/zx.mjs +50 -0
- package/scripts/mcp-server.mjs +16 -7
- package/scripts/mcp-server.sh +13 -83
- package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
- package/scripts/memory-propose.mjs +10 -3
- package/scripts/migrate-cold-start-seed.mjs +7 -0
- package/scripts/migrate-legacy-learnings.sh +22 -0
- package/scripts/migrate-sessions-jsonl.mjs +16 -2
- package/scripts/migrate-vault-paths.mjs +29 -13
- package/scripts/parse-config.mjs +71 -14
- package/scripts/print-learnings-index.mjs +25 -2
- package/scripts/release-session-lock.mjs +94 -7
- package/scripts/release.mjs +451 -24
- package/scripts/relocate-vault-corpus.mjs +11 -7
- package/scripts/repair-invalid-sessions.mjs +68 -3
- package/scripts/resolve-session-invocation.mjs +10 -17
- package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
- package/scripts/run-quality-gate.mjs +33 -7
- package/scripts/self-update.mjs +56 -7
- package/scripts/session-commit-files.mjs +133 -0
- package/scripts/site-numbers.mjs +52 -9
- package/scripts/snapshot-probe.mjs +156 -0
- package/scripts/so-hud.mjs +29 -6
- package/scripts/state-md-idle-reset.mjs +2 -1
- package/scripts/sweep-expired-learnings.mjs +54 -6
- package/scripts/sweep-expired-rules.mjs +4 -1
- package/scripts/validate-plugin.mjs +22 -0
- package/scripts/vault-integration-watcher.mjs +54 -6
- package/scripts/vault-mirror.mjs +9 -0
- package/skills/_shared/fleet-protocol.md +142 -1
- package/skills/_shared/instruction-file-resolution.md +1 -1
- package/skills/_shared/monitor-patterns.md +44 -25
- package/skills/_shared/platform-tools.md +7 -0
- package/skills/_shared/state-ownership.md +3 -2
- package/skills/architecture/references/domain-model.md +1 -1
- package/skills/bootstrap/SKILL.md +3 -1
- package/skills/bootstrap/_shared-template.md +4 -4
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/bootstrap/private-contract.md +1 -1
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
- package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
- package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
- package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
- package/skills/claude-md-drift-check/SKILL.md +3 -3
- package/skills/claude-md-drift-check/checker.mjs +61 -101
- package/skills/close/SKILL.md +20 -7
- package/skills/discovery/probes/docs-staleness.mjs +7 -5
- package/skills/discovery/probes/frontend-slop.mjs +7 -3
- package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
- package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
- package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
- package/skills/discovery/probes/vault-staleness.mjs +5 -3
- package/skills/discovery/probes-session.md +3 -0
- package/skills/discovery/probes-vault.md +2 -0
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/eval/SKILL.md +3 -1
- package/skills/evolve/SKILL.md +2 -0
- package/skills/evolve/references/evolve-analyze-mode.md +3 -3
- package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
- package/skills/gitlab-ops/SKILL.md +7 -6
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/go/SKILL.md +13 -1
- package/skills/npm-publish/SKILL.md +5 -2
- package/skills/plan/SKILL.md +23 -10
- package/skills/plan/mode-feature.md +13 -8
- package/skills/plan/mode-new.md +6 -14
- package/skills/plan/prd-feature-template.md +19 -0
- package/skills/plan/prd-full-template.md +12 -5
- package/skills/plan/prd-premortem-panel.md +48 -0
- package/skills/plan/prd-reviewer-prompt.md +11 -3
- package/skills/plan/soul.md +1 -1
- package/skills/portfolio/SKILL.md +5 -6
- package/skills/quality-gates/SKILL.md +2 -4
- package/skills/reconcile/SKILL.md +27 -4
- package/skills/release/SKILL.md +16 -8
- package/skills/session-end/SKILL.md +94 -11
- package/skills/session-end/phase-3-2-docs-verification.md +1 -0
- package/skills/session-end/phase-3-6-tail.md +11 -7
- package/skills/session-end/phase-3-7a-recommendations.md +9 -2
- package/skills/session-end/plan-verification.md +16 -1
- package/skills/session-end/references/phase-3-documentation-updates.md +14 -12
- package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
- package/skills/session-end/references/session-summary-template.md +4 -0
- package/skills/session-end/session-metrics-write.md +7 -4
- package/skills/session-plan/SKILL.md +5 -3
- package/skills/session-start/SKILL.md +9 -3
- package/skills/session-start/phase-4-5-resource-health.md +11 -5
- package/skills/session-start/phase-8-5-express-path.md +4 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -2
- package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
- package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
- package/skills/tmux-layout/SKILL.md +2 -2
- package/skills/vault-sync/validator.mjs +3 -2
- package/skills/wave-executor/SKILL.md +5 -4
- package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
- package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
- package/skills/wave-executor/references/wave-loop-review.md +13 -5
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
- package/assets/og-card.png +0 -0
- package/assets/og-card.svg +0 -47
- package/assets/wave-lifecycle.svg +0 -98
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* close-precheck.mjs — the `/close` pre-check as code (#1661).
|
|
3
|
+
*
|
|
4
|
+
* The pre-check used to be prose that read `<state-dir>/STATE.md` and acted on
|
|
5
|
+
* its `status` alone. It never asked whose STATE.md that is. Two failures
|
|
6
|
+
* followed (2026-10-17, three sessions in one afternoon):
|
|
7
|
+
* - After `/clear` the tab runs under a NEW native id while STATE.md still
|
|
8
|
+
* carries the previous one. A completed foreign STATE.md read as "already
|
|
9
|
+
* finalized", the session answered with the wait sentence although its own
|
|
10
|
+
* handover was written, and the inbox kept nudging.
|
|
11
|
+
* - A foreign ACTIVE STATE.md (a peer working in the same checkout) passed
|
|
12
|
+
* condition 3, so session-end would have closed the peer's session.
|
|
13
|
+
*
|
|
14
|
+
* Ownership is decided on the native id only: STATE.md `session-id` (#1368)
|
|
15
|
+
* against `readProcessLocalSessionIds()`. The session lock is no proof here —
|
|
16
|
+
* it records the pid of the short-lived hook process, and in a shared checkout
|
|
17
|
+
* it may name a peer.
|
|
18
|
+
*
|
|
19
|
+
* Unproven ownership (#1718): a STATE.md without `session-id` (written before
|
|
20
|
+
* #1368, in practice a foreign old file) or a process that cannot name its own
|
|
21
|
+
* id never runs session-end on status alone. A would-be session-end verdict
|
|
22
|
+
* becomes `ownership-unproven` with a `confirmToken` bound to the STATE.md path
|
|
23
|
+
* and its raw bytes; only that token (`--confirm-own`) turns it back, and only while the
|
|
24
|
+
* file is unchanged. Field incident 2026-10-21: a legacy STATE.md of an older,
|
|
25
|
+
* already closed plan read `complete-missing-record`.
|
|
26
|
+
*
|
|
27
|
+
* Ceiling (BV-004): on platforms without a native id (Cursor, Pi, Codex
|
|
28
|
+
* without a thread id) that is one operator confirmation per `/close`.
|
|
29
|
+
* Revisit when those platforms expose a native session id.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHash } from 'node:crypto';
|
|
33
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
|
|
36
|
+
import { parseStateMd } from '../state-md.mjs';
|
|
37
|
+
import { findRecordedSession } from '../session-close-backfill.mjs';
|
|
38
|
+
import { readLock } from '../session-lock.mjs';
|
|
39
|
+
import { readProcessLocalSessionIds } from '../session-identity/own-session.mjs';
|
|
40
|
+
import { readJsonlFile } from '../io.mjs';
|
|
41
|
+
import { resolveEventsLedgerRoot, resolveStateDir } from '../platform.mjs';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Confirmation token for an `ownership-unproven` STATE.md (#1718): bound to the
|
|
45
|
+
* STATE.md path and its raw bytes, so a byte-identical file in another checkout
|
|
46
|
+
* yields a different token.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} statePath path of the STATE.md (resolved to absolute)
|
|
49
|
+
* @param {Buffer|string} raw the raw STATE.md bytes
|
|
50
|
+
* @returns {string} 16 lowercase hex characters
|
|
51
|
+
*/
|
|
52
|
+
export function computeConfirmToken(statePath, raw) {
|
|
53
|
+
return createHash('sha256').update(path.resolve(statePath)).update('\0').update(raw).digest('hex').slice(0, 16);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Verdicts that hand off to session-end; every other verdict stops `/close`. */
|
|
57
|
+
export const RUNS_SESSION_END = new Set(['run-session-end', 'complete-missing-record']);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Classify `<state-dir>/STATE.md` for `/close`.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} opts
|
|
63
|
+
* @param {string} opts.repoRoot project root (the working tree `/close` runs in)
|
|
64
|
+
* @param {string} [opts.stateDir] `.claude` | `.codex` | `.cursor` | `.pi`; default from the platform
|
|
65
|
+
* @param {object} [opts.env=process.env] injectable for tests
|
|
66
|
+
* @param {string} [opts.confirmToken] token from an earlier `ownership-unproven`
|
|
67
|
+
* result; bound to the STATE.md path and its raw bytes, confirms a legacy/unprovable
|
|
68
|
+
* STATE.md while that file is unchanged (#1718)
|
|
69
|
+
* @returns {{
|
|
70
|
+
* verdict: 'no-state'|'foreign'|'run-session-end'|'complete-missing-record'|'already-finalized'|'unexpected-status'|'inconclusive'|'ownership-unproven',
|
|
71
|
+
* ownership: 'own'|'foreign'|'legacy'|'unprovable'|'confirmed'|null,
|
|
72
|
+
* status: string|null, stateSessionId: string|null, ownSessionIds: string[],
|
|
73
|
+
* statePath: string, reason?: string,
|
|
74
|
+
* wouldRun?: 'run-session-end'|'complete-missing-record', confirmToken?: string,
|
|
75
|
+
* session?: string|null, startedAt?: string|null
|
|
76
|
+
* }} the last four only with `ownership-unproven`
|
|
77
|
+
*/
|
|
78
|
+
export function classifyCloseState({ repoRoot, stateDir, env = process.env, confirmToken } = {}) {
|
|
79
|
+
const dir = stateDir ?? resolveStateDir();
|
|
80
|
+
const statePath = path.join(repoRoot, dir, 'STATE.md');
|
|
81
|
+
const ownSessionIds = readProcessLocalSessionIds({ env });
|
|
82
|
+
const base = { ownership: null, status: null, stateSessionId: null, ownSessionIds, statePath };
|
|
83
|
+
|
|
84
|
+
if (!existsSync(statePath)) return { ...base, verdict: 'no-state' };
|
|
85
|
+
|
|
86
|
+
const raw = readFileSync(statePath);
|
|
87
|
+
const parsed = parseStateMd(raw.toString('utf8'));
|
|
88
|
+
if (!parsed) return { ...base, verdict: 'inconclusive', reason: 'STATE.md has no readable frontmatter' };
|
|
89
|
+
const fm = parsed.frontmatter;
|
|
90
|
+
const status = typeof fm.status === 'string' ? fm.status : null;
|
|
91
|
+
const rawId = typeof fm['session-id'] === 'string' ? fm['session-id'].trim() : '';
|
|
92
|
+
const stateSessionId = rawId || null;
|
|
93
|
+
|
|
94
|
+
const ownership = !stateSessionId ? 'legacy'
|
|
95
|
+
: ownSessionIds.length === 0 ? 'unprovable'
|
|
96
|
+
: ownSessionIds.includes(stateSessionId) ? 'own' : 'foreign';
|
|
97
|
+
const known = { ...base, ownership, status, stateSessionId };
|
|
98
|
+
|
|
99
|
+
// A foreign STATE.md is never this session's to close, whatever its status:
|
|
100
|
+
// completed belongs to an earlier tab incarnation or a peer, active/paused to a
|
|
101
|
+
// peer (or to this tab before a /clear, which this process cannot prove).
|
|
102
|
+
if (ownership === 'foreign') return { ...known, verdict: 'foreign' };
|
|
103
|
+
|
|
104
|
+
// #1718: legacy/unprovable never reaches session-end without a token bound
|
|
105
|
+
// to this path and the bytes read here; every non-session-end verdict passes unchanged.
|
|
106
|
+
const gate = (wouldRun) => {
|
|
107
|
+
if (ownership === 'own') return { ...known, verdict: wouldRun };
|
|
108
|
+
const token = computeConfirmToken(statePath, raw);
|
|
109
|
+
if (confirmToken === token) return { ...known, ownership: 'confirmed', verdict: wouldRun };
|
|
110
|
+
const unproven = {
|
|
111
|
+
...known, verdict: 'ownership-unproven', wouldRun, confirmToken: token,
|
|
112
|
+
session: typeof fm.session === 'string' ? fm.session : null,
|
|
113
|
+
startedAt: typeof fm.started_at === 'string' ? fm.started_at : null,
|
|
114
|
+
};
|
|
115
|
+
if (confirmToken === undefined) return unproven;
|
|
116
|
+
return { ...unproven, reason: 'confirm token does not match STATE.md (changed since the pre-check, or the token belongs to another file)' };
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (status === 'active' || status === 'paused') return gate('run-session-end');
|
|
120
|
+
if (status !== 'completed') return { ...known, verdict: 'unexpected-status' };
|
|
121
|
+
|
|
122
|
+
// #429: completed alone does not prove the Phase 3.7 writer ran.
|
|
123
|
+
let records;
|
|
124
|
+
try {
|
|
125
|
+
records = readJsonlFile(path.join(resolveEventsLedgerRoot(repoRoot), '.orchestrator', 'metrics', 'sessions.jsonl'));
|
|
126
|
+
} catch (err) {
|
|
127
|
+
return { ...known, verdict: 'inconclusive', reason: `sessions.jsonl unreadable: ${err.message}` };
|
|
128
|
+
}
|
|
129
|
+
// The lock fallback only for STATE.md without session-id (pre-#1368), and
|
|
130
|
+
// only when the lock names THIS process (#1663). A peer's lock UUID would
|
|
131
|
+
// become the native key in findRecordedSession, skip this session's own
|
|
132
|
+
// record (different UUID) before the label match is reached, and send
|
|
133
|
+
// session-end a second time. Foreign or unprovable lock → no fallback, the
|
|
134
|
+
// label + started_at join decides.
|
|
135
|
+
let lockId = null;
|
|
136
|
+
if (!stateSessionId) {
|
|
137
|
+
let lockRaw;
|
|
138
|
+
try { lockRaw = readLock({ repoRoot })?.session_id ?? null; } catch { lockRaw = null; }
|
|
139
|
+
const trimmed = typeof lockRaw === 'string' ? lockRaw.trim() : '';
|
|
140
|
+
if (trimmed && ownSessionIds.includes(trimmed)) lockId = trimmed;
|
|
141
|
+
}
|
|
142
|
+
const record = findRecordedSession(records, {
|
|
143
|
+
sessionId: stateSessionId ?? lockId,
|
|
144
|
+
semanticSessionId: fm.session ?? null,
|
|
145
|
+
startedAt: fm.started_at ?? null,
|
|
146
|
+
});
|
|
147
|
+
// An abandoned backfill stub is no completion; a legacy record without
|
|
148
|
+
// `status` (written before the field existed) is authoritative.
|
|
149
|
+
const finalized = record && (record.status === undefined || record.status === 'completed');
|
|
150
|
+
return finalized ? { ...known, verdict: 'already-finalized' } : gate('complete-missing-record');
|
|
151
|
+
}
|
|
@@ -61,11 +61,11 @@
|
|
|
61
61
|
*/
|
|
62
62
|
|
|
63
63
|
import { existsSync } from 'node:fs';
|
|
64
|
-
import path from 'node:path';
|
|
65
64
|
|
|
66
65
|
import { collectProposals } from '../memory-proposals/collector.mjs';
|
|
67
66
|
import { sweepExpiredLearnings } from '../learnings/expiry-sweep.mjs';
|
|
68
|
-
import { readSkillInvocations } from '../skill-invocations-schema.mjs';
|
|
67
|
+
import { readSkillInvocations, defaultSkillInvocationsPath } from '../skill-invocations-schema.mjs';
|
|
68
|
+
import { resolveLedgerPath } from '../ledger-path.mjs';
|
|
69
69
|
import { runReconcileFromPhaseSkip, resolveEffectiveTargets } from '../reconcile/engine.mjs';
|
|
70
70
|
|
|
71
71
|
// NOTE (2026-09-09): `shouldDispatchAutoDream` / `shouldDispatchAutoDialectic`
|
|
@@ -91,10 +91,11 @@ import { runReconcileFromPhaseSkip, resolveEffectiveTargets } from '../reconcile
|
|
|
91
91
|
* @returns {{filePath: string, archivePath: string}}
|
|
92
92
|
*/
|
|
93
93
|
export function resolveLearningsPaths(repoRoot) {
|
|
94
|
-
|
|
94
|
+
// #1722: each store resolves on its own — a linked worktree of a repo that
|
|
95
|
+
// ignores them maps to the main checkout, a versioned one stays local.
|
|
95
96
|
return {
|
|
96
|
-
filePath:
|
|
97
|
-
archivePath:
|
|
97
|
+
filePath: resolveLedgerPath('.orchestrator/metrics/learnings.jsonl', repoRoot),
|
|
98
|
+
archivePath: resolveLedgerPath('.orchestrator/metrics/learnings-archive.jsonl', repoRoot),
|
|
98
99
|
};
|
|
99
100
|
}
|
|
100
101
|
|
|
@@ -232,7 +233,7 @@ async function decideSkillJudge({ repoRoot, cfg, sessionId }) {
|
|
|
232
233
|
return mkSkip(phase, 'disabled (skill-evolution.judge=false)', 'config-gate');
|
|
233
234
|
}
|
|
234
235
|
if (isPersistenceOff(cfg)) return mkSkip(phase, 'persistence=false', 'config-gate');
|
|
235
|
-
const invPath =
|
|
236
|
+
const invPath = defaultSkillInvocationsPath(repoRoot);
|
|
236
237
|
const entries = await readSkillInvocations(invPath);
|
|
237
238
|
const noSessionFilter = sessionId === null || sessionId === undefined;
|
|
238
239
|
const judged = new Set(
|
|
@@ -272,7 +273,7 @@ async function decideReconcile({ repoRoot, cfg }) {
|
|
|
272
273
|
if (cfg?.reconcile?.enabled !== true) {
|
|
273
274
|
return mkSkip(phase, 'disabled (reconcile.enabled=false)', 'config-gate');
|
|
274
275
|
}
|
|
275
|
-
const learningsPath =
|
|
276
|
+
const learningsPath = resolveLearningsPaths(repoRoot).filePath;
|
|
276
277
|
if (!existsSync(learningsPath)) {
|
|
277
278
|
return mkSkip(phase, 'learnings.jsonl absent', 'learnings.jsonl');
|
|
278
279
|
}
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
*
|
|
42
42
|
* @typedef {Object} SweepResult
|
|
43
43
|
* @property {boolean} ran - true only when the store was actually rewritten.
|
|
44
|
-
* @property {string} [reason] - why it did not run ('plan-skip' | 'no-plan' |
|
|
44
|
+
* @property {string} [reason] - why it did not run ('plan-skip' | 'no-plan' |
|
|
45
|
+
* 'store-role-inbox' | 'error').
|
|
45
46
|
* @property {string} [error] - error message, `reason === 'error'` only.
|
|
46
47
|
* @property {number} [scanned] - entries read from the active store (ran only).
|
|
47
48
|
* @property {number} [archived] - entries moved to the archive sidecar (ran only).
|
|
@@ -50,7 +51,7 @@
|
|
|
50
51
|
* @property {string} [archivePath] - the archive sidecar written to (ran only).
|
|
51
52
|
*/
|
|
52
53
|
|
|
53
|
-
import { sweepExpiredLearnings } from '../learnings/expiry-sweep.mjs';
|
|
54
|
+
import { SKIPPED_STORE_ROLE_INBOX, sweepExpiredLearnings } from '../learnings/expiry-sweep.mjs';
|
|
54
55
|
import { resolveLearningsPaths } from './phase-skip.mjs';
|
|
55
56
|
|
|
56
57
|
/** Event name — a plain string literal so the events-schema census can see it. */
|
|
@@ -151,6 +152,10 @@ export async function runExpiredSweep({ repoRoot, plan, now, graceDays, emit = t
|
|
|
151
152
|
...(graceDays === undefined ? {} : { graceDays }),
|
|
152
153
|
});
|
|
153
154
|
|
|
155
|
+
// Inbox role (#1516): the sweep wrote nothing, so this is not a run and
|
|
156
|
+
// emits no sweep_applied — that record's presence claims a rewrite.
|
|
157
|
+
if (res?.skipped === SKIPPED_STORE_ROLE_INBOX) return { ran: false, reason: SKIPPED_STORE_ROLE_INBOX };
|
|
158
|
+
|
|
154
159
|
const scanned = res?.scanned ?? 0;
|
|
155
160
|
const archived = res?.archived ?? 0;
|
|
156
161
|
// The sweep result omits `malformed` when the read found none, so absent IS
|
|
@@ -43,6 +43,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
43
43
|
import path from 'node:path';
|
|
44
44
|
|
|
45
45
|
import { eventsFilePath } from './events.mjs';
|
|
46
|
+
import { resolveEventsLedgerRoot } from './platform.mjs';
|
|
46
47
|
import { readCanonicalSessions } from './sessions-canonical.mjs';
|
|
47
48
|
import { readTailWindow } from './tail-window.mjs';
|
|
48
49
|
import { withStateMdLock } from './session-lock.mjs';
|
|
@@ -144,7 +145,11 @@ function isValidMode(mode) {
|
|
|
144
145
|
* only across DIFFERENT ids — never the same id twice, see above).
|
|
145
146
|
*/
|
|
146
147
|
async function readSessionIdsFromHistory(repoRoot) {
|
|
147
|
-
|
|
148
|
+
// #1592: the repo's ONE sessions ledger (main checkout of a linked worktree,
|
|
149
|
+
// where every close writes since #1584). Aggregation is a monotone max, so the
|
|
150
|
+
// wider ledger can only raise n — the events source does not always cover a
|
|
151
|
+
// closed session (rotated ledger, no lock event), the worktree read was empty.
|
|
152
|
+
const filePath = path.join(resolveEventsLedgerRoot(repoRoot), '.orchestrator', 'metrics', 'sessions.jsonl');
|
|
148
153
|
return readCanonicalSessions({ filePath }).map((rec) => rec.session_id);
|
|
149
154
|
}
|
|
150
155
|
|
|
@@ -266,6 +271,9 @@ async function readSessionIdsFromEvents(repoRoot) {
|
|
|
266
271
|
* @returns {Promise<string|null>} The session_id string, or null when absent.
|
|
267
272
|
*/
|
|
268
273
|
async function readSessionIdFromStateMd(repoRoot) {
|
|
274
|
+
// #1636 decision: deliberately the READ resolver, foreign STATE.md included —
|
|
275
|
+
// this source only collects ids already TAKEN, so a foreign one can only raise
|
|
276
|
+
// n (max over a union) and ignoring it could re-mint a peer's id.
|
|
269
277
|
const filePath = resolveStateMdPath(repoRoot);
|
|
270
278
|
let raw;
|
|
271
279
|
try {
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { readFileSync } from 'node:fs';
|
|
30
|
+
import { execFileSync } from 'node:child_process';
|
|
31
|
+
import { homedir } from 'node:os';
|
|
30
32
|
import path from 'node:path';
|
|
31
33
|
import { isLockShape } from '../session-lock-shape.mjs';
|
|
32
34
|
|
|
@@ -228,6 +230,67 @@ export function readProcessLocalSessionIds({ env = process.env, hookInput = null
|
|
|
228
230
|
return ids;
|
|
229
231
|
}
|
|
230
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Read only process parent/name metadata, never command arguments or env.
|
|
235
|
+
* @param {number} pid
|
|
236
|
+
* @returns {{ ppid: number, comm: string } | null}
|
|
237
|
+
*/
|
|
238
|
+
function readProcessParent(pid) {
|
|
239
|
+
const output = execFileSync('ps', ['-p', String(pid), '-o', 'ppid=,comm='], {
|
|
240
|
+
encoding: 'utf8', timeout: 1000, maxBuffer: 2048,
|
|
241
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
242
|
+
});
|
|
243
|
+
const match = /^\s*(\d+)\s+(\S+)\s*$/.exec(output);
|
|
244
|
+
return match ? { ppid: Number(match[1]), comm: match[2] } : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Prove an acknowledgement's own invocation using the nearest Claude process.
|
|
249
|
+
* Unlike the general identity union, inherited env and shared repo files are
|
|
250
|
+
* never proof here. Stop at the nearest Claude even when its session is unreadable.
|
|
251
|
+
* Missing, contradictory or unmeasurable evidence returns null; never throws.
|
|
252
|
+
* Reader injection permits deterministic fixtures when native ps is unavailable.
|
|
253
|
+
*
|
|
254
|
+
* @param {{ env?: object, pid?: number, homeDir?: string,
|
|
255
|
+
* readProcess?: (pid: number) => { ppid: number, comm: string } | null,
|
|
256
|
+
* readSession?: (pid: number) => unknown }} [opts]
|
|
257
|
+
* @returns {string|null}
|
|
258
|
+
*/
|
|
259
|
+
export function readInvocationSessionId({
|
|
260
|
+
env = process.env, pid = process.pid, homeDir = homedir(),
|
|
261
|
+
readProcess = readProcessParent,
|
|
262
|
+
readSession = (ancestorPid) => JSON.parse(readFileSync(
|
|
263
|
+
path.join(homeDir, '.claude', 'sessions', `${ancestorPid}.json`), 'utf8',
|
|
264
|
+
)),
|
|
265
|
+
} = {}) {
|
|
266
|
+
const seen = new Set();
|
|
267
|
+
try {
|
|
268
|
+
for (let depth = 0; depth < 64; depth++) {
|
|
269
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || seen.has(pid)) return null;
|
|
270
|
+
seen.add(pid);
|
|
271
|
+
const processInfo = readProcess(pid);
|
|
272
|
+
if (!processInfo || !Number.isSafeInteger(processInfo.ppid) || processInfo.ppid < 0 ||
|
|
273
|
+
seen.has(processInfo.ppid) || typeof processInfo.comm !== 'string' || !processInfo.comm.trim()) return null;
|
|
274
|
+
// macOS reports comm as argv[0]; a Claude started by absolute path must still count.
|
|
275
|
+
if (path.basename(processInfo.comm) === 'claude') {
|
|
276
|
+
const session = readSession(pid);
|
|
277
|
+
const sessionId = typeof session?.sessionId === 'string' ? session.sessionId.trim() : '';
|
|
278
|
+
if (!sessionId) return null;
|
|
279
|
+
// Every present native ID must agree; platform selection cannot hide
|
|
280
|
+
// a contradictory inherited ID from a nested harness.
|
|
281
|
+
for (const key of ['CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID']) {
|
|
282
|
+
const value = env?.[key];
|
|
283
|
+
if (value !== undefined && value !== null &&
|
|
284
|
+
(typeof value !== 'string' || (value.trim() && value.trim() !== sessionId))) return null;
|
|
285
|
+
}
|
|
286
|
+
return sessionId;
|
|
287
|
+
}
|
|
288
|
+
pid = processInfo.ppid;
|
|
289
|
+
}
|
|
290
|
+
} catch { /* Missing process/session evidence must fail closed. */ }
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
231
294
|
/**
|
|
232
295
|
* Decide whether a wave-scope manifest belongs to THIS session.
|
|
233
296
|
*
|
|
@@ -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.
|