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
|
@@ -15,6 +15,9 @@ import { dirname, resolve as resolvePath } from 'node:path';
|
|
|
15
15
|
import { parseStateMd, serializeStateMd } from './yaml-parser.mjs';
|
|
16
16
|
import { withStateMdLock } from '../session-lock.mjs';
|
|
17
17
|
import { resolveStateDir } from '../platform.mjs';
|
|
18
|
+
// Hook-closure note: own-session.mjs imports only node:* and the import-free
|
|
19
|
+
// session-lock-shape.mjs, both already in the hook import set.
|
|
20
|
+
import { readProcessLocalSessionIds } from '../session-identity/own-session.mjs';
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* Sets frontmatter.updated to the given ISO 8601 timestamp and returns the
|
|
@@ -85,20 +88,84 @@ const STATE_DIR_CANDIDATES = ['.claude', '.codex', '.cursor', '.pi'];
|
|
|
85
88
|
* @returns {string} Absolute artifact path.
|
|
86
89
|
*/
|
|
87
90
|
export function resolveStateArtifactPath(repoRoot, filename) {
|
|
91
|
+
const { candidates } = stateArtifactCandidates(repoRoot, filename);
|
|
92
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Shared candidate list: SO_STATE_DIR override (if set), the active harness's
|
|
97
|
+
* state dir, then the legacy harness dirs. `preferredCount` is how many of the
|
|
98
|
+
* leading entries are override/active (as opposed to legacy).
|
|
99
|
+
*
|
|
100
|
+
* @param {string|undefined} repoRoot
|
|
101
|
+
* @param {string} filename
|
|
102
|
+
* @returns {{ candidates: string[], preferredCount: number }}
|
|
103
|
+
*/
|
|
104
|
+
function stateArtifactCandidates(repoRoot, filename) {
|
|
88
105
|
const root = repoRoot ?? process.cwd();
|
|
89
106
|
const override = (process.env.SO_STATE_DIR ?? '').trim();
|
|
90
107
|
const active = resolveStateDir();
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
return
|
|
108
|
+
const preferred = [...new Set([override || active, active])];
|
|
109
|
+
const directories = [...new Set([...preferred, ...STATE_DIR_CANDIDATES])];
|
|
110
|
+
return {
|
|
111
|
+
candidates: directories.map((directory) => resolvePath(root, directory, filename)),
|
|
112
|
+
preferredCount: preferred.length,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* May THIS process write into the existing STATE.md at `candidate`?
|
|
118
|
+
* Always yes for a placeholder (no frontmatter or no `session-id`), a
|
|
119
|
+
* `status: idle` record, or a `session-id` naming this process
|
|
120
|
+
* (`readProcessLocalSessionIds()`). An unreadable file is never a target.
|
|
121
|
+
*
|
|
122
|
+
* Otherwise the answer depends on where the candidate sits:
|
|
123
|
+
* - Legacy (other-platform) dir: no — #1618, unchanged.
|
|
124
|
+
* - SO_STATE_DIR / active dir (`preferred`): refused ONLY when it is
|
|
125
|
+
* `status: completed` AND this process has a measurable own id (#1640 —
|
|
126
|
+
* the measured damage: session-end Phase 3.7a wrote into a FINISHED
|
|
127
|
+
* foreign session's record). A non-completed record keeps the pre-#1640
|
|
128
|
+
* behaviour: one checkout has one session.lock owner, and after `/clear`
|
|
129
|
+
* the process carries a NEW raw id while its own active STATE.md still
|
|
130
|
+
* names the old one — refusing that file made `/close` unable to finish.
|
|
131
|
+
* `completedHandoff` (Idle Reset) may take a completed record too: that
|
|
132
|
+
* rewrite IS the hand-over to the next session.
|
|
133
|
+
*
|
|
134
|
+
* CEILING (BV-004): a `/clear`ed session's active file is still identified by
|
|
135
|
+
* its OLD id until close, where {@link markStateMdCompletedOnDisk} re-stamps
|
|
136
|
+
* it; a live parallel session in the same checkout is PSA territory, not
|
|
137
|
+
* this guard's. Revisit when the raw id survives `/clear` (resume linkage).
|
|
138
|
+
*
|
|
139
|
+
* @param {string} candidate
|
|
140
|
+
* @param {string[]} ownIds
|
|
141
|
+
* @param {{ preferred: boolean, completedHandoff?: boolean }} opts
|
|
142
|
+
* @returns {boolean}
|
|
143
|
+
*/
|
|
144
|
+
function isWritableStateMd(candidate, ownIds, { preferred, completedHandoff = false }) {
|
|
145
|
+
let frontmatter;
|
|
146
|
+
try {
|
|
147
|
+
frontmatter = parseStateMd(readFileSync(candidate, 'utf8'))?.frontmatter ?? null;
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
const raw = frontmatter?.['session-id'];
|
|
152
|
+
const sessionId = typeof raw === 'string' ? raw.trim() : '';
|
|
153
|
+
if (!sessionId) return true;
|
|
154
|
+
const status = frontmatter.status;
|
|
155
|
+
if (status === 'idle') return true;
|
|
156
|
+
if (ownIds.includes(sessionId)) return true;
|
|
157
|
+
if (!preferred) return false;
|
|
158
|
+
return status !== 'completed' || completedHandoff || ownIds.length === 0;
|
|
94
159
|
}
|
|
95
160
|
|
|
96
161
|
/**
|
|
97
|
-
* Resolve the active STATE.md path under a given repo root.
|
|
162
|
+
* Resolve the active STATE.md path under a given repo root — READ side.
|
|
98
163
|
*
|
|
99
164
|
* Returns the first existing candidate, checking the active platform first.
|
|
100
165
|
* Falls back to the active platform's state dir when none exist (matches the
|
|
101
|
-
* create-on-first-write semantics expected by callers).
|
|
166
|
+
* create-on-first-write semantics expected by callers). The legacy fallback to
|
|
167
|
+
* another platform's STATE.md is kept for readers (#890); writers must use
|
|
168
|
+
* {@link resolveStateMdWritePath}.
|
|
102
169
|
*
|
|
103
170
|
* @param {string|undefined} repoRoot
|
|
104
171
|
* @returns {string} Absolute path to STATE.md.
|
|
@@ -107,6 +174,83 @@ export function resolveStateMdPath(repoRoot) {
|
|
|
107
174
|
return resolveStateArtifactPath(repoRoot, 'STATE.md');
|
|
108
175
|
}
|
|
109
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Resolve the STATE.md path a WRITE may target (#1618, #1640).
|
|
179
|
+
*
|
|
180
|
+
* Every existing candidate — SO_STATE_DIR, the active platform's dir, the
|
|
181
|
+
* legacy dirs — must pass the ownership rule ({@link isWritableStateMd}); the
|
|
182
|
+
* first one that does wins. Before #1640 the override/active candidates were
|
|
183
|
+
* taken unchecked, so session-end Phase 3.7a wrote its recommendation fields
|
|
184
|
+
* into a FOREIGN completed `.claude/STATE.md` while this session's own record
|
|
185
|
+
* lived elsewhere. Without a qualifying candidate the preferred path is
|
|
186
|
+
* returned for create-on-first-write — unless that path itself holds a foreign
|
|
187
|
+
* file, in which case `blocked` is true and writers refuse.
|
|
188
|
+
*
|
|
189
|
+
* @param {string|undefined} repoRoot
|
|
190
|
+
* @returns {string} Absolute path to STATE.md.
|
|
191
|
+
*/
|
|
192
|
+
export function resolveStateMdWritePath(repoRoot) {
|
|
193
|
+
return resolveStateMdWriteTarget(repoRoot).path;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Like {@link resolveStateMdWritePath}, but also names the first STATE.md
|
|
198
|
+
* that was REJECTED on the way (#1618 review): `rejected` is the absolute path
|
|
199
|
+
* of an existing STATE.md not proven to be this process's, set only when the
|
|
200
|
+
* returned `path` is NOT a qualifying existing file (absent → create, or
|
|
201
|
+
* `blocked`). Callers use it to say why nothing was written instead of
|
|
202
|
+
* returning a silent no-op. `blocked` means `path` exists and is foreign:
|
|
203
|
+
* nothing may be written there (#1640).
|
|
204
|
+
*
|
|
205
|
+
* `opts.statePath` (#1640) pins the target explicitly — session-end passes the
|
|
206
|
+
* same path to Phase 3.7a and Phase 3.4 so both land in one file — and is
|
|
207
|
+
* subject to the same ownership rule (treated as a preferred candidate).
|
|
208
|
+
* `opts.completedHandoff` lets the Idle Reset take a preferred
|
|
209
|
+
* `status: completed` record (see {@link isWritableStateMd}).
|
|
210
|
+
*
|
|
211
|
+
* @param {string|undefined} repoRoot
|
|
212
|
+
* @param {{ statePath?: string, completedHandoff?: boolean }} [opts]
|
|
213
|
+
* @returns {{ path: string, rejected: string|null, blocked: boolean }}
|
|
214
|
+
*/
|
|
215
|
+
export function resolveStateMdWriteTarget(repoRoot, opts = {}) {
|
|
216
|
+
const { statePath, completedHandoff = false } = opts;
|
|
217
|
+
const ownIds = readProcessLocalSessionIds();
|
|
218
|
+
const explicit = typeof statePath === 'string' && statePath.trim() !== '';
|
|
219
|
+
const { candidates, preferredCount } = explicit
|
|
220
|
+
? { candidates: [resolvePath(repoRoot ?? process.cwd(), statePath)], preferredCount: 1 }
|
|
221
|
+
: stateArtifactCandidates(repoRoot, 'STATE.md');
|
|
222
|
+
let rejected = null;
|
|
223
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
224
|
+
if (!existsSync(candidate)) continue;
|
|
225
|
+
if (isWritableStateMd(candidate, ownIds, { preferred: index < preferredCount, completedHandoff })) {
|
|
226
|
+
return { path: candidate, rejected: null, blocked: false };
|
|
227
|
+
}
|
|
228
|
+
rejected ??= candidate;
|
|
229
|
+
}
|
|
230
|
+
const path = candidates[0];
|
|
231
|
+
return { path, rejected, blocked: existsSync(path) };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* One stderr line naming a rejected foreign STATE.md, so a fail-closed
|
|
236
|
+
* write target never degrades into a silent no-op (#1618 review). When the
|
|
237
|
+
* rejected file IS the target (#1640 `blocked`), the line says nothing is
|
|
238
|
+
* written instead of naming a different target.
|
|
239
|
+
*
|
|
240
|
+
* @param {string} caller
|
|
241
|
+
* @param {string} rejected
|
|
242
|
+
* @param {string} target
|
|
243
|
+
*/
|
|
244
|
+
export function warnForeignStateMd(caller, rejected, target) {
|
|
245
|
+
const outcome = rejected === target
|
|
246
|
+
? 'nothing written — it is no session-own write target'
|
|
247
|
+
: `write target is ${target}`;
|
|
248
|
+
process.stderr.write(
|
|
249
|
+
`⚠ ${caller}: ignoring ${rejected} — its session-id is not this process's ` +
|
|
250
|
+
`(no matching process-local session id); ${outcome} (#1618/#1640)\n`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
110
254
|
// ---------------------------------------------------------------------------
|
|
111
255
|
// Size-ceiling guard (issue #739)
|
|
112
256
|
// ---------------------------------------------------------------------------
|
|
@@ -351,27 +495,40 @@ function writeFileAtomic(filePath, contents) {
|
|
|
351
495
|
* frontmatter-unsafe breach throws an `Error`
|
|
352
496
|
* (`.code === 'STATE_MD_FRONTMATTER_UNSAFE'`) instead of returning a no-op
|
|
353
497
|
* result. Default `false`.
|
|
498
|
+
* @param {string} [opts.statePath] — explicit STATE.md target (#1640), absolute
|
|
499
|
+
* or repo-relative; still ownership-checked. A foreign existing target is
|
|
500
|
+
* refused with `reason: 'foreign-state-md'`, never overwritten.
|
|
501
|
+
* @param {boolean} [opts.completedHandoff] — Idle Reset only: a preferred
|
|
502
|
+
* `status: completed` record qualifies as write target (#1640).
|
|
354
503
|
* @returns {Promise<{ written: boolean, path: string, contents: string|null, reason?: string }>}
|
|
355
504
|
*/
|
|
356
505
|
export async function writeStateMd(repoRoot, transformer, opts = {}) {
|
|
357
506
|
if (typeof transformer !== 'function') {
|
|
358
507
|
throw new TypeError('writeStateMd: transformer must be a function');
|
|
359
508
|
}
|
|
360
|
-
const statePath =
|
|
509
|
+
const { path: statePath, rejected, blocked } = resolveStateMdWriteTarget(repoRoot, {
|
|
510
|
+
statePath: opts.statePath,
|
|
511
|
+
completedHandoff: opts.completedHandoff,
|
|
512
|
+
});
|
|
513
|
+
if (rejected) warnForeignStateMd('writeStateMd', rejected, statePath);
|
|
514
|
+
const foreign = rejected ? { reason: 'foreign-state-md' } : {};
|
|
515
|
+
// #1640: the only existing file at the target belongs to another session —
|
|
516
|
+
// never overwrite it, never call the transformer on its contents.
|
|
517
|
+
if (blocked) return { written: false, path: statePath, contents: null, ...foreign };
|
|
361
518
|
return withStateMdLock(
|
|
362
519
|
repoRoot,
|
|
363
520
|
async () => {
|
|
364
521
|
const before = existsSync(statePath) ? readFileSync(statePath, 'utf8') : '';
|
|
365
522
|
const after = await transformer(before);
|
|
366
523
|
if (after === null || after === undefined) {
|
|
367
|
-
return { written: false, path: statePath, contents: null };
|
|
524
|
+
return { written: false, path: statePath, contents: null, ...foreign };
|
|
368
525
|
}
|
|
369
526
|
if (typeof after !== 'string') {
|
|
370
527
|
throw new TypeError('writeStateMd: transformer must return a string or null');
|
|
371
528
|
}
|
|
372
529
|
if (after === before) {
|
|
373
530
|
// No textual change — skip the write to keep mtime stable.
|
|
374
|
-
return { written: false, path: statePath, contents: before };
|
|
531
|
+
return { written: false, path: statePath, contents: before, ...foreign };
|
|
375
532
|
}
|
|
376
533
|
|
|
377
534
|
const ceilingBytes = typeof opts._ceilingBytes === 'number' && opts._ceilingBytes > 0
|
|
@@ -456,6 +613,107 @@ export async function updateFrontmatterFieldsOnDisk(repoRoot, fields, opts = {})
|
|
|
456
613
|
return writeStateMd(repoRoot, (contents) => updateFrontmatterFields(contents, fields), opts);
|
|
457
614
|
}
|
|
458
615
|
|
|
616
|
+
/**
|
|
617
|
+
* Why the STATE.md `session-id` `sessionId` may NOT be taken over by this
|
|
618
|
+
* process (#1640 review MED-1), or `null` when it may. A non-completed record
|
|
619
|
+
* passes the write rule even with a foreign id (a `/clear`ed session's own
|
|
620
|
+
* file still names its pre-`/clear` id), so before re-stamping the id and
|
|
621
|
+
* completing the file we ask the host session registry whether that id is
|
|
622
|
+
* still REGISTERED at all (`readRegistry()`; the caller already excludes our
|
|
623
|
+
* own id). A registered session owns the file: completing it would lock that
|
|
624
|
+
* session out of its own record. No freshness window: the heartbeat is only
|
|
625
|
+
* written at turn end (hooks/on-stop.mjs), and 92 of 954 consecutive
|
|
626
|
+
* `orchestrator.turn.stopped` gaps (48 of 64 sessions) exceeded 15 min —
|
|
627
|
+
* an idle-but-live peer would look dead. Measured 2026-10-15: 13 of 13
|
|
628
|
+
* sessions that ended with SessionEnd `reason: clear` were already
|
|
629
|
+
* deregistered (on-session-end.mjs deregisters on every reason), so a
|
|
630
|
+
* `/clear` predecessor does not block its successor's close.
|
|
631
|
+
*
|
|
632
|
+
* Fail-closed: a registry directory that exists but cannot be read refuses.
|
|
633
|
+
* An absent registry (ENOENT) means no live peers.
|
|
634
|
+
*
|
|
635
|
+
* CEILING (BV-004): the registry is host-wide, not per checkout, and the
|
|
636
|
+
* check runs inside the STATE.md lock but not under the registry's. A session
|
|
637
|
+
* that crashed without SessionEnd (or whose deregistration failed) stays
|
|
638
|
+
* registered until `sweepZombies()` removes it after 60 min of inactivity at
|
|
639
|
+
* the next SessionStart of ANY session — until then no other session can
|
|
640
|
+
* complete its STATE.md (fail-closed direction, refused loudly). Revisit if
|
|
641
|
+
* that refusal shows up in close transcripts.
|
|
642
|
+
*
|
|
643
|
+
* Lazy import: this module is in the hook import closure; the registry is
|
|
644
|
+
* only needed on this async session-end path.
|
|
645
|
+
*
|
|
646
|
+
* @param {string} sessionId
|
|
647
|
+
* @param {string|undefined} ownId
|
|
648
|
+
* @returns {Promise<string|null>}
|
|
649
|
+
*/
|
|
650
|
+
async function liveForeignOwner(sessionId) {
|
|
651
|
+
try {
|
|
652
|
+
const { activeDir, readRegistry } = await import('../session-registry.mjs');
|
|
653
|
+
const { readdir } = await import('node:fs/promises');
|
|
654
|
+
try {
|
|
655
|
+
await readdir(activeDir());
|
|
656
|
+
} catch (err) {
|
|
657
|
+
if (err?.code === 'ENOENT') return null;
|
|
658
|
+
return `session registry unreadable (${err?.code ?? 'error'})`;
|
|
659
|
+
}
|
|
660
|
+
const entries = await readRegistry();
|
|
661
|
+
return entries.some((entry) => entry.session_id === sessionId)
|
|
662
|
+
? 'its session-id belongs to a session still registered on this host'
|
|
663
|
+
: null;
|
|
664
|
+
} catch (err) {
|
|
665
|
+
return `session registry unreadable (${err?.code ?? err?.message ?? 'error'})`;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* session-end Phase 3.4 (#1640): finalize THIS session's STATE.md in one
|
|
671
|
+
* locked write — `status: completed`, `updated`, optionally `current-wave`,
|
|
672
|
+
* and `session-id` re-stamped to the closing process's own raw id when one is
|
|
673
|
+
* measurable. The re-stamp keeps a `/clear`ed session's record its own after
|
|
674
|
+
* 3.4: the writers that run LATER in the close — Phase 5's
|
|
675
|
+
* `markOpenQuestionAnsweredOnDisk` — would otherwise find a completed record
|
|
676
|
+
* naming the pre-`/clear` id and be refused as foreign. Without a measurable
|
|
677
|
+
* id the existing `session-id` is left as is.
|
|
678
|
+
*
|
|
679
|
+
* Refused (`reason: 'foreign-state-md'`, one stderr line, file byte-identical)
|
|
680
|
+
* when the record's `session-id` is neither this process's nor free to take
|
|
681
|
+
* over — see {@link liveForeignOwner}. Placeholder (no `session-id`), idle and
|
|
682
|
+
* own records are never checked against the registry.
|
|
683
|
+
*
|
|
684
|
+
* @param {string} repoRoot — absolute path to the repository root (required)
|
|
685
|
+
* @param {object} [opts] — `writeStateMd` options; pass the SAME `statePath` as Phase 3.7a.
|
|
686
|
+
* @param {number} [opts.currentWave] — final wave count, written as `current-wave`.
|
|
687
|
+
* @returns {Promise<{ written: boolean, path: string, contents: string|null, reason?: string }>}
|
|
688
|
+
* @throws {Error} when repoRoot is undefined, null, or empty
|
|
689
|
+
*/
|
|
690
|
+
export async function markStateMdCompletedOnDisk(repoRoot, opts = {}) {
|
|
691
|
+
requireRepoRoot(repoRoot, 'markStateMdCompletedOnDisk');
|
|
692
|
+
const { currentWave, ...writeOpts } = opts;
|
|
693
|
+
const fields = { status: 'completed', updated: new Date().toISOString() };
|
|
694
|
+
if (Number.isInteger(currentWave)) fields['current-wave'] = currentWave;
|
|
695
|
+
const [ownId] = readProcessLocalSessionIds();
|
|
696
|
+
if (ownId) fields['session-id'] = ownId;
|
|
697
|
+
let refusal = null;
|
|
698
|
+
const result = await writeStateMd(
|
|
699
|
+
repoRoot,
|
|
700
|
+
async (contents) => {
|
|
701
|
+
const frontmatter = parseStateMd(contents)?.frontmatter;
|
|
702
|
+
const raw = frontmatter?.['session-id'];
|
|
703
|
+
const sessionId = typeof raw === 'string' ? raw.trim() : '';
|
|
704
|
+
if (sessionId && sessionId !== ownId && frontmatter.status !== 'idle') {
|
|
705
|
+
refusal = await liveForeignOwner(sessionId);
|
|
706
|
+
if (refusal) return null;
|
|
707
|
+
}
|
|
708
|
+
return updateFrontmatterFields(contents, fields);
|
|
709
|
+
},
|
|
710
|
+
writeOpts,
|
|
711
|
+
);
|
|
712
|
+
if (!refusal) return result;
|
|
713
|
+
process.stderr.write(`⚠ markStateMdCompletedOnDisk: refusing ${result.path} — ${refusal}; nothing written (#1640)\n`);
|
|
714
|
+
return { ...result, written: false, reason: 'foreign-state-md' };
|
|
715
|
+
}
|
|
716
|
+
|
|
459
717
|
/**
|
|
460
718
|
* Convenience wrapper: apply `touchUpdatedField` under the state-lock.
|
|
461
719
|
*
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { parseStateMd, serializeStateMd } from './yaml-parser.mjs';
|
|
20
20
|
import { existsSync } from 'node:fs';
|
|
21
|
-
import {
|
|
21
|
+
import { resolveStateMdWriteTarget, updateFrontmatterFields, warnForeignStateMd, writeStateMd } from './frontmatter-mutators.mjs';
|
|
22
22
|
import { parseRecommendations } from './recommendations.mjs';
|
|
23
23
|
|
|
24
24
|
/** Rule 7: the drift-baseline keys that must not survive into session N+1. */
|
|
@@ -201,7 +201,9 @@ export function resetStateMdToIdle(contents) {
|
|
|
201
201
|
|
|
202
202
|
/**
|
|
203
203
|
* Applies `resetStateMdToIdle` to the repo's STATE.md under the STATE.md lock
|
|
204
|
-
* (`writeStateMd`). A missing or empty STATE.md is a no-op with reason `missing
|
|
204
|
+
* (`writeStateMd`). A missing or empty STATE.md is a no-op with reason `missing`;
|
|
205
|
+
* when the only STATE.md belongs to another platform's foreign session it is
|
|
206
|
+
* `foreign-state-md` (#1618), with one stderr WARN naming that file.
|
|
205
207
|
* A write the size-ceiling / frontmatter-safe guards refuse comes back as
|
|
206
208
|
* `changed: false` with that guard's reason.
|
|
207
209
|
*
|
|
@@ -216,8 +218,15 @@ export async function resetStateMdToIdleOnDisk(repoRoot, opts = {}) {
|
|
|
216
218
|
}
|
|
217
219
|
// No STATE.md: answer before the lock, which would otherwise create
|
|
218
220
|
// <repoRoot>/.orchestrator/ in whatever directory a mistyped root names.
|
|
219
|
-
|
|
220
|
-
|
|
221
|
+
// Probe the same path writeStateMd will target (#1618), never a foreign one.
|
|
222
|
+
// The reset is the hand-over that turns the PREVIOUS session's completed
|
|
223
|
+
// record into idle, so a preferred `status: completed` file qualifies (#1640).
|
|
224
|
+
const writeOpts = { ...opts, completedHandoff: true };
|
|
225
|
+
const { path: statePath, rejected } = resolveStateMdWriteTarget(repoRoot, writeOpts);
|
|
226
|
+
if (!existsSync(statePath)) {
|
|
227
|
+
if (rejected) warnForeignStateMd('resetStateMdToIdleOnDisk', rejected, statePath);
|
|
228
|
+
return { changed: false, reason: rejected ? 'foreign-state-md' : 'missing', path: statePath };
|
|
229
|
+
}
|
|
221
230
|
let reason;
|
|
222
231
|
const result = await writeStateMd(
|
|
223
232
|
repoRoot,
|
|
@@ -230,7 +239,7 @@ export async function resetStateMdToIdleOnDisk(repoRoot, opts = {}) {
|
|
|
230
239
|
reason = reset.reason;
|
|
231
240
|
return reset.changed ? reset.contents : null;
|
|
232
241
|
},
|
|
233
|
-
|
|
242
|
+
writeOpts,
|
|
234
243
|
);
|
|
235
244
|
const out = { changed: result.written, path: result.path };
|
|
236
245
|
const why = result.reason ?? reason;
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import { parseStateMd, serializeStateMd } from './yaml-parser.mjs';
|
|
26
|
-
import { updateFrontmatterFields, writeStateMd } from './frontmatter-mutators.mjs';
|
|
26
|
+
import { resolveStateMdWriteTarget, updateFrontmatterFields, writeStateMd } from './frontmatter-mutators.mjs';
|
|
27
27
|
|
|
28
28
|
const MISSION_STATUS_HEADING_RE = /^##\s+Mission Status\s*$/;
|
|
29
29
|
const WRITER_TIMESTAMP_SOURCE = '\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z';
|
|
@@ -677,9 +677,13 @@ const REFUSAL_REASONS = Object.freeze({
|
|
|
677
677
|
*/
|
|
678
678
|
export async function setMissionStatusOnDisk(repoRoot, taskId, status, opts = {}) {
|
|
679
679
|
let refused;
|
|
680
|
+
// A fail-closed write target (#1618) hands an empty buffer; writeStateMd
|
|
681
|
+
// already names the rejected foreign STATE.md, so do not blame its content.
|
|
682
|
+
const foreign = resolveStateMdWriteTarget(repoRoot, opts).rejected !== null;
|
|
680
683
|
const result = await writeStateMd(
|
|
681
684
|
repoRoot,
|
|
682
685
|
(contents) => {
|
|
686
|
+
if (foreign && contents === '') return contents;
|
|
683
687
|
const outcome = setMissionStatusDetailed(contents, taskId, status);
|
|
684
688
|
refused = outcome.refused;
|
|
685
689
|
if (outcome.refused) {
|
|
@@ -55,6 +55,8 @@ function orderedStateMdCandidates() {
|
|
|
55
55
|
* @returns {string} Absolute path (may not exist on disk).
|
|
56
56
|
*/
|
|
57
57
|
function _resolveStateMdPath(repoRoot) {
|
|
58
|
+
// #1636 decision: no ownership filter here — this guard exists to SEE another
|
|
59
|
+
// session's STATE.md, so a foreign file is its input, not noise to drop.
|
|
58
60
|
const candidates = orderedStateMdCandidates();
|
|
59
61
|
for (const candidate of candidates) {
|
|
60
62
|
const abs = join(repoRoot, candidate);
|
package/scripts/lib/state-md.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { parseStateMd as _parseStateMd } from './state-md/yaml-parser.mjs';
|
|
20
20
|
import { updateFrontmatterFields as _updateFrontmatterFields } from './state-md/frontmatter-mutators.mjs';
|
|
21
21
|
import { readLock as _readLock } from './session-lock.mjs';
|
|
22
|
+
import { readProcessLocalSessionIds as _readProcessLocalSessionIds } from './session-identity/own-session.mjs';
|
|
22
23
|
|
|
23
24
|
export { parseStateMd, serializeStateMd } from './state-md/yaml-parser.mjs';
|
|
24
25
|
|
|
@@ -195,27 +196,49 @@ export function resolveSessionStartedAt({ repoRoot } = {}) {
|
|
|
195
196
|
/**
|
|
196
197
|
* The session ids a STATE.md writer may stamp into frontmatter: `session-id`
|
|
197
198
|
* (the RAW/native harness id) and `session` (the semantic attribution label),
|
|
198
|
-
* both read from the live lock
|
|
199
|
+
* both read from the live lock — and `session-id` only when the lock is
|
|
200
|
+
* provably THIS process's own (#1663).
|
|
199
201
|
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
202
|
+
* The lock is repo-global: a session that lost the acquire race, or a
|
|
203
|
+
* pre-#1641 lock without `claude_pid` that survives until its TTL, names a
|
|
204
|
+
* PEER. Since #1661 `/close` decides ownership by comparing STATE.md
|
|
205
|
+
* `session-id` against `readProcessLocalSessionIds()`, so stamping the peer's
|
|
206
|
+
* id would make this session's own STATE.md read `foreign` and stop its own
|
|
207
|
+
* close. Hence:
|
|
208
|
+
* - `session_id` is the lock's raw id only when it is contained in
|
|
209
|
+
* `readProcessLocalSessionIds({ env })`; otherwise `null`. A process that
|
|
210
|
+
* cannot name its own id (Cursor, Pi, Codex without a thread id) gets
|
|
211
|
+
* `null` too — unprovable is not own.
|
|
212
|
+
* - `semantic_session_id` is `null` when the lock is PROVABLY foreign (this
|
|
213
|
+
* process has an id and the lock names another). Ceiling (BV-004): when
|
|
214
|
+
* the process has no id at all, the label is still taken from the lock,
|
|
215
|
+
* as before #1663, so the #429 label join keeps working on those
|
|
216
|
+
* harnesses — it may then name a peer. Revisit when every harness
|
|
217
|
+
* exposes a native id.
|
|
218
|
+
*
|
|
219
|
+
* ABSENCE IS NEVER COERCED: a writer must OMIT a `null` key rather than write
|
|
220
|
+
* a placeholder. Neither id grants ownership of anything (see
|
|
203
221
|
* `skills/_shared/state-ownership.md`); `session-id` exists so `/close`'s #429
|
|
204
222
|
* pre-check can join STATE.md to sessions.jsonl on a native identity instead
|
|
205
223
|
* of falling through to the label + timestamp path.
|
|
206
224
|
*
|
|
207
225
|
* Never throws.
|
|
208
226
|
*
|
|
209
|
-
* @param {{ repoRoot?: string }} [opts]
|
|
227
|
+
* @param {{ repoRoot?: string, env?: object }} [opts] `env` defaults to
|
|
228
|
+
* `process.env`; injectable for tests.
|
|
210
229
|
* @returns {{ session_id: string|null, semantic_session_id: string|null }}
|
|
211
230
|
*/
|
|
212
|
-
export function resolveSessionIds({ repoRoot } = {}) {
|
|
231
|
+
export function resolveSessionIds({ repoRoot, env = process.env } = {}) {
|
|
213
232
|
const pick = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : null);
|
|
214
233
|
try {
|
|
215
234
|
const lock = _readLock({ repoRoot });
|
|
235
|
+
const lockId = pick(lock?.session_id);
|
|
236
|
+
const ownIds = _readProcessLocalSessionIds({ env });
|
|
237
|
+
const own = lockId !== null && ownIds.includes(lockId);
|
|
238
|
+
const provablyForeign = lockId !== null && ownIds.length > 0 && !own;
|
|
216
239
|
return {
|
|
217
|
-
session_id:
|
|
218
|
-
semantic_session_id: pick(lock?.semantic_session_id),
|
|
240
|
+
session_id: own ? lockId : null,
|
|
241
|
+
semantic_session_id: provablyForeign ? null : pick(lock?.semantic_session_id),
|
|
219
242
|
};
|
|
220
243
|
} catch {
|
|
221
244
|
return { session_id: null, semantic_session_id: null };
|
|
@@ -42,6 +42,7 @@ import path from 'node:path';
|
|
|
42
42
|
import { normalizeSkillInvocation } from '../skill-invocations-schema.mjs';
|
|
43
43
|
import { normalizeSkillJudgment } from '../skill-judgments-schema.mjs';
|
|
44
44
|
import { isMainModule } from '../is-main-module.mjs';
|
|
45
|
+
import { resolveLedgerPath } from '../ledger-path.mjs';
|
|
45
46
|
import { userInvocableSkills } from '../user-invocable-skills.mjs';
|
|
46
47
|
|
|
47
48
|
// ---------------------------------------------------------------------------
|
|
@@ -948,15 +949,15 @@ export function runSunsetWalk(repoRoot, opts = {}) {
|
|
|
948
949
|
|
|
949
950
|
const subagentsPath =
|
|
950
951
|
opts.subagentsPath ??
|
|
951
|
-
|
|
952
|
+
resolveLedgerPath('.orchestrator/metrics/subagents.jsonl', repoRoot);
|
|
952
953
|
|
|
953
954
|
const invocationsPath =
|
|
954
955
|
opts.invocationsPath ??
|
|
955
|
-
|
|
956
|
+
resolveLedgerPath('.orchestrator/metrics/skill-invocations.jsonl', repoRoot);
|
|
956
957
|
|
|
957
958
|
const judgmentsPath =
|
|
958
959
|
opts.judgmentsPath ??
|
|
959
|
-
|
|
960
|
+
resolveLedgerPath('.orchestrator/metrics/skill-judgments.jsonl', repoRoot);
|
|
960
961
|
|
|
961
962
|
const surface = enumerateSurface(repoRoot);
|
|
962
963
|
const dispatch = readDispatchCounts(subagentsPath, { windowDays, now: nowMs });
|
|
@@ -159,11 +159,13 @@ export function readTelemetryState({ path } = {}) {
|
|
|
159
159
|
export function writeTelemetryState(record, { path } = {}) {
|
|
160
160
|
const target = path || TELEMETRY_JSON_PATH;
|
|
161
161
|
try {
|
|
162
|
-
|
|
162
|
+
// 0o700: the default target is the private config dir, which must not come
|
|
163
|
+
// out 0777 under umask 000 (#1629).
|
|
164
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
163
165
|
} catch (err) {
|
|
164
166
|
return { ok: false, error: err?.message ?? String(err) };
|
|
165
167
|
}
|
|
166
|
-
const res = writeJsonAtomicSync(target, record);
|
|
168
|
+
const res = writeJsonAtomicSync(target, record, { mode: 0o600 }); // consent + anon_id are host-private (#1632)
|
|
167
169
|
return res.ok ? { ok: true } : { ok: false, error: res.error };
|
|
168
170
|
}
|
|
169
171
|
|
|
@@ -91,7 +91,7 @@ function _serializedByteLength(entries) {
|
|
|
91
91
|
* via tmp-file + `renameSync` in the SAME directory (same-filesystem rename
|
|
92
92
|
* is atomic on POSIX — mirrors `writeJsonAtomicSync` in `scripts/lib/io.mjs`,
|
|
93
93
|
* which has no NDJSON-array variant). Creates the parent directory with
|
|
94
|
-
* `mkdirSync(dir, { recursive: true })` first.
|
|
94
|
+
* `mkdirSync(dir, { recursive: true, mode: 0o700 })` first.
|
|
95
95
|
*
|
|
96
96
|
* @param {string} filePath
|
|
97
97
|
* @param {Array<object>} entries
|
|
@@ -101,10 +101,10 @@ function _serializedByteLength(entries) {
|
|
|
101
101
|
*/
|
|
102
102
|
function _writeEntriesAtomicSync(filePath, entries) {
|
|
103
103
|
const dir = dirname(filePath);
|
|
104
|
-
mkdirSync(dir, { recursive: true });
|
|
104
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 }); // private config dir; 0777 under umask 000 otherwise (#1629)
|
|
105
105
|
const tmpSuffix = randomBytes(6).toString('hex');
|
|
106
106
|
const tmpFile = join(dir, `.tmp.${tmpSuffix}`);
|
|
107
|
-
writeFileSync(tmpFile, _toNdjson(entries), 'utf8');
|
|
107
|
+
writeFileSync(tmpFile, _toNdjson(entries), { encoding: 'utf8', mode: 0o600 }); // rename keeps this mode (#1632)
|
|
108
108
|
renameSync(tmpFile, filePath);
|
|
109
109
|
}
|
|
110
110
|
|