session-orchestrator 3.22.0 → 3.24.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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +446 -0
- package/README.md +22 -17
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -2
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +194 -25
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +120 -10
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +89 -9
- package/docs/session-config-template.md +38 -7
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +52 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +93 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +486 -19
- package/hooks/on-session-start.mjs +263 -12
- package/hooks/on-stop.mjs +392 -24
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +182 -21
- package/hooks/post-tool-batch-wave-signal.mjs +165 -42
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/hooks/subagent-telemetry.mjs +11 -26
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +31 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/emit-event.mjs +10 -2
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
- package/scripts/lib/build-live-signals.mjs +49 -27
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +87 -8
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +25 -10
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +517 -18
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +126 -0
- package/scripts/lib/session-close-backfill.mjs +427 -37
- package/scripts/lib/session-discovery.mjs +69 -5
- package/scripts/lib/session-end/phase-skip.mjs +38 -5
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +220 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +91 -16
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +333 -32
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +381 -141
- package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +889 -0
- package/scripts/materialize-wave-scope.mjs +228 -15
- package/scripts/mcp-server.sh +11 -2
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/parse-config.mjs +65 -0
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +29 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +305 -51
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +19 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +270 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +46 -3
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +20 -9
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +54 -17
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +5 -3
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +143 -22
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* own-session.mjs — "which session am I, and does this shared artefact belong to me?"
|
|
3
|
+
*
|
|
4
|
+
* A working copy is shared; a session is not. Every `.orchestrator/` and
|
|
5
|
+
* `<state-dir>/` artefact in this repo is written into the WORKING COPY, so a
|
|
6
|
+
* second live session reads the first one's files as if they were its own. The
|
|
7
|
+
* damage is always invisible to the writer: a peer's corrective hints briefing
|
|
8
|
+
* this session's fixer (#1058), a peer's `allowedPaths: []` locking this
|
|
9
|
+
* session out of every write (#1082/#1123).
|
|
10
|
+
*
|
|
11
|
+
* This module is the reusable half of that check, split into two pure-ish
|
|
12
|
+
* functions so a caller can resolve identity once and classify many artefacts:
|
|
13
|
+
*
|
|
14
|
+
* - {@link readOwnSessionIds} — every id that provably names THIS session.
|
|
15
|
+
* - {@link classifyManifestSession} — own / foreign / unknown for a manifest.
|
|
16
|
+
*
|
|
17
|
+
* Semantics were lifted from the `current-session.json` ownership check in
|
|
18
|
+
* `scripts/lib/quality-gate.mjs` (#1058), which is module-private there and sits
|
|
19
|
+
* behind a module this repo's hook guard-source-loader cannot bind. This is a
|
|
20
|
+
* deliberate re-implementation of the SEMANTICS, not a re-export.
|
|
21
|
+
*
|
|
22
|
+
* **Only what is PROVABLY foreign is foreign.** Every unprovable case returns
|
|
23
|
+
* `'unknown'`, and every caller is expected to treat `'unknown'` exactly as it
|
|
24
|
+
* behaved before this check existed. An ownership check that guesses turns
|
|
25
|
+
* "cannot tell" into a silent feature-off on every harness that exports no
|
|
26
|
+
* session id.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readLock } from '../session-lock.mjs';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The set of session ids that provably name THIS session — the UNION of every
|
|
33
|
+
* tier, never the first one that answers.
|
|
34
|
+
*
|
|
35
|
+
* Three sources, all read, all merged:
|
|
36
|
+
*
|
|
37
|
+
* 1. `hookInput` — the harness's own statement about the invocation being
|
|
38
|
+
* handled right now (`session_id` / `sessionId`, plus `parent_session_id`
|
|
39
|
+
* for a sub-agent invocation, whose coordinator is equally us). The only
|
|
40
|
+
* tier that is per-INVOCATION rather than per-working-copy.
|
|
41
|
+
* 2. `CLAUDE_CODE_SESSION_ID` — process-scoped, absent on harnesses that
|
|
42
|
+
* export no session env var.
|
|
43
|
+
* 3. `session.lock` `session_id` / `semantic_session_id` — repo-GLOBAL, and
|
|
44
|
+
* the identity the WRITER of a manifest uses: `wave-scope.json`'s
|
|
45
|
+
* `session` field comes from `sessionAttribution()`, which reads this same
|
|
46
|
+
* lock (`skills/wave-executor/wave-loop.md` § Scope Manifest 1).
|
|
47
|
+
*
|
|
48
|
+
* **Why the union, and not first-tier-wins.** Any id the process can
|
|
49
|
+
* legitimately claim — its own invocation, its harness env, the repo's live
|
|
50
|
+
* lock — names this session; only an id in NONE of them is somebody else's.
|
|
51
|
+
* Gating the tiers made the READER's identity a strict subset of the WRITER's,
|
|
52
|
+
* and three distinct ways of diverging all landed on the same silent failure —
|
|
53
|
+
* the OWN manifest classified `foreign`, so the write gate switched itself off
|
|
54
|
+
* for the whole wave, with an event that reads exactly like correct behaviour:
|
|
55
|
+
*
|
|
56
|
+
* - **Nested-harness divergence.** The payload `session_id` and
|
|
57
|
+
* `CLAUDE_CODE_SESSION_ID` disagree in a nested harness — already measured
|
|
58
|
+
* and documented in `resolveSessionId()` of
|
|
59
|
+
* `hooks/pre-bash-issue-budget.mjs`: *"stdin still wins: it is the id of
|
|
60
|
+
* THIS tool call, whereas the env var is the id of the process tree, and
|
|
61
|
+
* the two differ in a nested harness"*, alongside the measurement that the
|
|
62
|
+
* env var equals the `session.lock` `session_id` and survives into
|
|
63
|
+
* subagents. Under tier-gating, the payload alone decided.
|
|
64
|
+
* - **Sub-agent invocation.** A dispatched agent's own set was
|
|
65
|
+
* `{subagent-uuid}` while the manifest names the coordinator.
|
|
66
|
+
* `parent_session_id` is in tier 1 too, but a payload that carries only
|
|
67
|
+
* `session_id` still hid the coordinator's env/lock ids behind the gate.
|
|
68
|
+
* - **Peer-owned lock.** A second session that failed to acquire the lock
|
|
69
|
+
* (`bootstrapLock()` reason `active`, the lock keeps the PEER's id) writes
|
|
70
|
+
* that peer id into its OWN manifest via `sessionAttribution()`. Its own
|
|
71
|
+
* hook then read the payload tier, never reached the lock, and disarmed
|
|
72
|
+
* itself against the manifest it had just written.
|
|
73
|
+
*
|
|
74
|
+
* **The security direction is unchanged: the union only ADDS ids this process
|
|
75
|
+
* actually carries.** A manifest whose id appears in NO tier — not the
|
|
76
|
+
* invocation, not the env, not the lock — still classifies `foreign`, exactly
|
|
77
|
+
* as before; nothing here invents an id or widens what counts as a match.
|
|
78
|
+
*
|
|
79
|
+
* The cost is named rather than hidden, and it points the fail-CLOSED way: when
|
|
80
|
+
* the lock names a peer, that peer's manifest now reads `own`, so we ENFORCE a
|
|
81
|
+
* wave plan that is not ours. That is a visible, actionable deny — the inverse
|
|
82
|
+
* of tier-gating's failure, which was a silent enforcement-off. `unknown` still
|
|
83
|
+
* means unknown: an empty set can only produce `unknown`, never a mismatch.
|
|
84
|
+
*
|
|
85
|
+
* Every value is `.trim()`ed before it enters the set: a whitespace-only env
|
|
86
|
+
* var is truthy and would otherwise enter as a PHANTOM id that matches nothing
|
|
87
|
+
* — which would make every manifest read `foreign` and switch enforcement off
|
|
88
|
+
* (`.claude/rules/development.md` § env-var whitespace trap).
|
|
89
|
+
*
|
|
90
|
+
* Never throws.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} repoRoot — working copy root, for the `session.lock` tier.
|
|
93
|
+
* @param {{ hookInput?: object|null }} [opts]
|
|
94
|
+
* @returns {Set<string>} possibly EMPTY — an empty set means "identity
|
|
95
|
+
* unresolvable", which {@link classifyManifestSession} treats as `unknown`,
|
|
96
|
+
* never as a mismatch.
|
|
97
|
+
*/
|
|
98
|
+
export function readOwnSessionIds(repoRoot, { hookInput = null } = {}) {
|
|
99
|
+
const ids = new Set();
|
|
100
|
+
const add = (value) => {
|
|
101
|
+
const trimmed = typeof value === 'string' ? value.trim() : '';
|
|
102
|
+
if (trimmed) ids.add(trimmed);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Source 1 — the harness's statement about THIS invocation.
|
|
106
|
+
if (hookInput && typeof hookInput === 'object') {
|
|
107
|
+
for (const key of ['session_id', 'sessionId', 'parent_session_id']) add(hookInput[key]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Source 2 — process-scoped env var.
|
|
111
|
+
add(process.env.CLAUDE_CODE_SESSION_ID);
|
|
112
|
+
|
|
113
|
+
// Source 3 — repo-global lock file (the manifest writer's own identity).
|
|
114
|
+
try {
|
|
115
|
+
const lock = readLock({ repoRoot });
|
|
116
|
+
for (const key of ['session_id', 'semantic_session_id']) add(lock?.[key]);
|
|
117
|
+
} catch {
|
|
118
|
+
/* readLock never throws by contract, but that contract is not ours to trust */
|
|
119
|
+
}
|
|
120
|
+
return ids;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The ids that name this session and are PROCESS-LOCAL — tiers 1 and 2 only,
|
|
125
|
+
* never the lock and never STATE.md.
|
|
126
|
+
*
|
|
127
|
+
* A sibling of {@link readOwnSessionIds}, not a replacement: the two answer
|
|
128
|
+
* different questions and the difference is the whole point.
|
|
129
|
+
*
|
|
130
|
+
* - `readOwnSessionIds()` answers *"could this id name me?"* and unions three
|
|
131
|
+
* tiers, the third of which IS `session.lock`. That union is correct when
|
|
132
|
+
* the thing being classified was written by some OTHER process (a wave-scope
|
|
133
|
+
* manifest), because every id this process can legitimately claim counts.
|
|
134
|
+
* - This function answers *"which process is emitting right now?"*, and for
|
|
135
|
+
* that question the lock is **vacuous**: when the candidate ids under
|
|
136
|
+
* judgement are the lock's OWN values, a membership test against a set that
|
|
137
|
+
* contains the lock matches by construction — a peer-owned lock would
|
|
138
|
+
* classify as `own` 100% of the time.
|
|
139
|
+
*
|
|
140
|
+
* **STATE.md is excluded for the same reason, and this is the #1177-FX1 fix.**
|
|
141
|
+
* `.claude/STATE.md` is a SHARED working-copy artefact written by whichever
|
|
142
|
+
* session owns the working copy — normally the lock holder. So when a peer holds
|
|
143
|
+
* the lock, the peer also wrote STATE.md, and the two "independent" witnesses
|
|
144
|
+
* agree with each other about the PEER. Unioning a shared-file witness with a
|
|
145
|
+
* process-local one lets the weaker witness carry the verdict while a
|
|
146
|
+
* disagreeing process-local id cannot veto it (measured: lock=peer +
|
|
147
|
+
* STATE.md=peer + `CLAUDE_CODE_SESSION_ID`=me stamped the PEER's ids). Tiering
|
|
148
|
+
* rather than unioning is the fix — a better signal REPLACES a worse one
|
|
149
|
+
* (`.claude/rules/host-resources.md` § HR-102).
|
|
150
|
+
*
|
|
151
|
+
* Never throws.
|
|
152
|
+
*
|
|
153
|
+
* @param {{ env?: object, hookInput?: object|null }} [opts]
|
|
154
|
+
* @param {object} [opts.env=process.env] — injectable for tests.
|
|
155
|
+
* @param {object|null} [opts.hookInput=null] — the harness's statement about
|
|
156
|
+
* THIS invocation, when the caller is a hook.
|
|
157
|
+
* @returns {string[]} possibly EMPTY — an empty result means "this process
|
|
158
|
+
* cannot prove who it is", which callers must treat as unprovable rather
|
|
159
|
+
* than as a match.
|
|
160
|
+
*/
|
|
161
|
+
export function readProcessLocalSessionIds({ env = process.env, hookInput = null } = {}) {
|
|
162
|
+
const ids = [];
|
|
163
|
+
const add = (value) => {
|
|
164
|
+
const trimmed = typeof value === 'string' ? value.trim() : '';
|
|
165
|
+
if (trimmed && !ids.includes(trimmed)) ids.push(trimmed);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Tier 1 — the harness's statement about THIS invocation.
|
|
169
|
+
if (hookInput && typeof hookInput === 'object') {
|
|
170
|
+
for (const key of ['session_id', 'sessionId', 'parent_session_id']) add(hookInput[key]);
|
|
171
|
+
}
|
|
172
|
+
// Tier 2 — process-scoped env var.
|
|
173
|
+
add(env?.CLAUDE_CODE_SESSION_ID);
|
|
174
|
+
|
|
175
|
+
return ids;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Decide whether a wave-scope manifest belongs to THIS session.
|
|
180
|
+
*
|
|
181
|
+
* Three outcomes, and the middle one is load-bearing:
|
|
182
|
+
*
|
|
183
|
+
* - `'foreign'` — the manifest names at least one session id, we know at
|
|
184
|
+
* least one of our own, and NONE of them match. The only verdict that
|
|
185
|
+
* changes behaviour.
|
|
186
|
+
* - `'unknown'` — the manifest names no id (a legacy manifest written before
|
|
187
|
+
* the `session` field existed), or we could not resolve our own. Ownership
|
|
188
|
+
* is unproven in BOTH directions, so the caller must keep doing exactly
|
|
189
|
+
* what it did before.
|
|
190
|
+
* - `'own'` — an id matched.
|
|
191
|
+
*
|
|
192
|
+
* Both id fields are consulted because they address the same session under two
|
|
193
|
+
* naming schemes: `session` is the raw harness session id (a UUID on Claude
|
|
194
|
+
* Code), `semantic_session` the `<branch>-<date>-<mode>-<n>` form. A harness
|
|
195
|
+
* that resolves only the semantic one must still recognise its own manifest.
|
|
196
|
+
*
|
|
197
|
+
* @param {unknown} scope — parsed wave-scope manifest (any shape; a non-object
|
|
198
|
+
* simply yields no ids, hence `'unknown'`).
|
|
199
|
+
* @param {Set<string>} ownIds — from EITHER producer, depending on what is
|
|
200
|
+
* being judged: {@link readOwnSessionIds} when every id this process could
|
|
201
|
+
* legitimately claim counts, or `new Set(`{@link readProcessLocalSessionIds}
|
|
202
|
+
* `(...))` when the lock tier would match vacuously — which is the case for a
|
|
203
|
+
* wave-scope manifest in a checkout shared by two sessions (#1194). Note the
|
|
204
|
+
* latter returns a `string[]`: a bare array is NOT a Set and folds to the
|
|
205
|
+
* empty set below, yielding `'unknown'` for every manifest.
|
|
206
|
+
* @returns {{ verdict: 'own'|'foreign'|'unknown', manifestIds: string[] }}
|
|
207
|
+
*/
|
|
208
|
+
export function classifyManifestSession(scope, ownIds) {
|
|
209
|
+
const manifestIds = [];
|
|
210
|
+
if (scope && typeof scope === 'object' && !Array.isArray(scope)) {
|
|
211
|
+
for (const key of ['session', 'semantic_session']) {
|
|
212
|
+
const value = typeof scope[key] === 'string' ? scope[key].trim() : '';
|
|
213
|
+
if (value) manifestIds.push(value);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const own = ownIds instanceof Set ? ownIds : new Set();
|
|
217
|
+
if (manifestIds.length === 0 || own.size === 0) return { verdict: 'unknown', manifestIds };
|
|
218
|
+
const matched = manifestIds.some((id) => own.has(id));
|
|
219
|
+
return { verdict: matched ? 'own' : 'foreign', manifestIds };
|
|
220
|
+
}
|
|
@@ -45,6 +45,7 @@ import crypto from 'node:crypto';
|
|
|
45
45
|
import { classifyMode } from './exclusivity-matrix.mjs';
|
|
46
46
|
import { isPidAliveOnHost } from './file-lock.mjs';
|
|
47
47
|
import { writeJsonAtomicSync } from './io.mjs';
|
|
48
|
+
import { hostnamesMatch, lockHostCandidate, recordHostAlias, stableHostname } from './host-identity.mjs';
|
|
48
49
|
|
|
49
50
|
// isPidAliveOnHost moved into file-lock.mjs in #630 (the file-lock primitive
|
|
50
51
|
// owns it so the dependency edge points file-lock → io, never the reverse).
|
|
@@ -101,11 +102,12 @@ export const OWNER_PROOF_RELPATH = '.orchestrator/runtime/lock-owner-proof.json'
|
|
|
101
102
|
// NOT the discovery-path liveness check — since Epic #583 the discovery
|
|
102
103
|
// decision tree uses heartbeat-age via {@link isLockLive} instead, because the
|
|
103
104
|
// `pid` recorded on a session.lock is the *ephemeral hook subprocess* PID.
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
105
|
+
// NOTHING IN THIS MODULE CALLS IT any more: `acquire()` stopped consulting the
|
|
106
|
+
// pid in #744/#1137 and `checkStale()` in #1151 — the re-export is a
|
|
107
|
+
// compatibility surface for external importers only. The remaining production
|
|
108
|
+
// callers are file-lock.mjs's own stale-override path and lock-reaper.mjs,
|
|
109
|
+
// where the recorded PID IS the process being asked about. See file-lock.mjs
|
|
110
|
+
// for the full @forensic + PID-recycle trade-off note.
|
|
109
111
|
|
|
110
112
|
/**
|
|
111
113
|
* Resolve the absolute path to the lock file.
|
|
@@ -146,6 +148,28 @@ function lockAgeHours(lock) {
|
|
|
146
148
|
return (Date.now() - ts) / (3600 * 1000);
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Compute the age of a lock's heartbeat in fractional minutes.
|
|
153
|
+
*
|
|
154
|
+
* This is the diagnostic counterpart to `isLockLive()` — the SAME quantity the
|
|
155
|
+
* liveness rule thresholds against, surfaced as a number so callers (the
|
|
156
|
+
* Phase-1.2 stale-lock AUQ, recovery diagnostics) can report WHY a lock was
|
|
157
|
+
* classified stale instead of asserting a PID verdict the lock cannot support
|
|
158
|
+
* (#1137). Mirrors `isLockLive()`'s `last_heartbeat` → `started_at` fallback.
|
|
159
|
+
*
|
|
160
|
+
* @param {{ last_heartbeat?: string, started_at?: string }} lock
|
|
161
|
+
* @returns {number|null} minutes since the last heartbeat, or null if unparseable.
|
|
162
|
+
*/
|
|
163
|
+
function heartbeatAgeMinutes(lock) {
|
|
164
|
+
if (!lock || typeof lock !== 'object') return null;
|
|
165
|
+
const hbStr = (typeof lock.last_heartbeat === 'string' && lock.last_heartbeat.length > 0)
|
|
166
|
+
? lock.last_heartbeat
|
|
167
|
+
: lock.started_at;
|
|
168
|
+
const ts = Date.parse(hbStr);
|
|
169
|
+
if (Number.isNaN(ts)) return null;
|
|
170
|
+
return (Date.now() - ts) / (60 * 1000);
|
|
171
|
+
}
|
|
172
|
+
|
|
149
173
|
/**
|
|
150
174
|
* Parse lock file contents into an object. Returns null on any parse error.
|
|
151
175
|
*
|
|
@@ -213,13 +237,22 @@ function parseLock(raw) {
|
|
|
213
237
|
*/
|
|
214
238
|
function buildLock({ sessionId, mode, ttlHours, semanticSessionId }) {
|
|
215
239
|
const startedAt = nowIso();
|
|
240
|
+
// Writing a session lock is the one moment we KNOW the current os.hostname()
|
|
241
|
+
// belongs to this machine — record it so a later reading under a different
|
|
242
|
+
// spelling can still be recognised as the same host (#1072). Best-effort:
|
|
243
|
+
// recordHostAlias never throws, and a failed write only costs the alias.
|
|
244
|
+
recordHostAlias();
|
|
216
245
|
const lock = {
|
|
217
246
|
session_id: sessionId,
|
|
218
247
|
started_at: startedAt,
|
|
219
248
|
last_heartbeat: startedAt,
|
|
220
249
|
mode,
|
|
221
250
|
pid: process.pid,
|
|
251
|
+
// `host` stays the RAW hostname — it is an on-the-wire event field
|
|
252
|
+
// (orchestrator.session.lock.acquired) and feeds the privacy-hash contract.
|
|
253
|
+
// `host_id` is the additive normalised twin every comparison reads (#1072).
|
|
222
254
|
host: os.hostname(),
|
|
255
|
+
host_id: stableHostname(),
|
|
223
256
|
ttl_hours: ttlHours,
|
|
224
257
|
};
|
|
225
258
|
if (typeof semanticSessionId === 'string' && semanticSessionId.length > 0) {
|
|
@@ -455,10 +488,15 @@ export function readLockDetailed(opts = {}) {
|
|
|
455
488
|
* — lock created
|
|
456
489
|
* { ok: false, reason: 'active', existingLock, exclusivityClass? }
|
|
457
490
|
* — local lock held (live TTL, live PID)
|
|
458
|
-
* { ok: false, reason: 'stale-
|
|
459
|
-
* — local lock stale
|
|
460
|
-
*
|
|
461
|
-
*
|
|
491
|
+
* { ok: false, reason: 'stale-heartbeat', existingLock, ageHours, heartbeatAgeMinutes, exclusivityClass? }
|
|
492
|
+
* — local lock stale: its last_heartbeat is older than ttl_hours. This is
|
|
493
|
+
* the ONLY stale reason (#1137). It replaced the `stale-pid-dead` /
|
|
494
|
+
* `stale-pid-alive` pair, which claimed a PID verdict the lock cannot
|
|
495
|
+
* support: the recorded `pid` is the ephemeral hook / `node -e`
|
|
496
|
+
* subprocess, dead within ~1s of genesis (measured 2026-08-23: 7 of 7
|
|
497
|
+
* recorded pids dead, INCLUDING the currently heartbeating session's
|
|
498
|
+
* own lock), so `stale-pid-alive` was unreachable same-host and every
|
|
499
|
+
* stale lock rendered as "confirmed dead" in the recovery AUQ.
|
|
462
500
|
* { ok: false, reason: 'fs-error', error, exclusivityClass? }
|
|
463
501
|
* — filesystem failure
|
|
464
502
|
* { ok: false, reason: 'active-incompatible-exclusive', allActiveSessions, blockingSession, exclusivityClass }
|
|
@@ -563,12 +601,8 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
|
|
|
563
601
|
try {
|
|
564
602
|
// Classify an existing lock into the correct failure result. Shared by the
|
|
565
603
|
// up-front readLock() check AND the create-race EEXIST-loser path below so
|
|
566
|
-
// both report identical active / stale-
|
|
604
|
+
// both report identical active / stale-heartbeat reasons.
|
|
567
605
|
const classifyExisting = (existing) => {
|
|
568
|
-
const sameHost = existing.host === os.hostname();
|
|
569
|
-
// PID liveness is only meaningful on the same host.
|
|
570
|
-
const pidAlive = sameHost ? isPidAliveOnHost(existing.pid) : null;
|
|
571
|
-
|
|
572
606
|
// Heartbeat-first liveness (#744): isLockLive is the SOLE active gate.
|
|
573
607
|
// A dead recorded PID must NOT veto a fresh last_heartbeat — the pid on
|
|
574
608
|
// a session.lock is the ephemeral hook subprocess PID, not the semantic
|
|
@@ -581,11 +615,24 @@ export function acquire({ sessionId, mode, ttlHours = DEFAULT_TTL_HOURS, repoRoo
|
|
|
581
615
|
return { ok: false, reason: 'active', existingLock: existing, exclusivityClass: callerClass };
|
|
582
616
|
}
|
|
583
617
|
|
|
584
|
-
// Heartbeat expired —
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
|
|
618
|
+
// Heartbeat expired — ONE stale reason, derived from the same signal the
|
|
619
|
+
// active gate above used (#1137). The former two-way split asked
|
|
620
|
+
// isPidAliveOnHost(existing.pid) and reported 'stale-pid-dead' /
|
|
621
|
+
// 'stale-pid-alive'; that question has no answer the lock can give,
|
|
622
|
+
// because `pid` is the short-lived writer subprocess, not the session.
|
|
623
|
+
// Same-host it was therefore ~always 'dead' (7/7 measured, live sessions
|
|
624
|
+
// included) and 'stale-pid-alive' was structurally unreachable. The
|
|
625
|
+
// ageHours + heartbeatAgeMinutes fields carry the evidence instead, so a
|
|
626
|
+
// recovery prompt can state the measured heartbeat age rather than a
|
|
627
|
+
// liveness verdict.
|
|
628
|
+
return {
|
|
629
|
+
ok: false,
|
|
630
|
+
reason: 'stale-heartbeat',
|
|
631
|
+
existingLock: existing,
|
|
632
|
+
ageHours: lockAgeHours(existing),
|
|
633
|
+
heartbeatAgeMinutes: heartbeatAgeMinutes(existing),
|
|
634
|
+
exclusivityClass: callerClass,
|
|
635
|
+
};
|
|
589
636
|
};
|
|
590
637
|
|
|
591
638
|
const existing = readLock({ repoRoot });
|
|
@@ -978,7 +1025,7 @@ export function updateHeartbeat({ repoRoot, sessionId } = {}) {
|
|
|
978
1025
|
* lock: object|null,
|
|
979
1026
|
* ageHours: number|null,
|
|
980
1027
|
* ttlExpired: boolean,
|
|
981
|
-
*
|
|
1028
|
+
* heartbeatAgeMinutes: number|null,
|
|
982
1029
|
* host: string|null,
|
|
983
1030
|
* sameHost: boolean,
|
|
984
1031
|
* isLive: boolean
|
|
@@ -993,7 +1040,7 @@ export function checkStale({ repoRoot } = {}) {
|
|
|
993
1040
|
lock: null,
|
|
994
1041
|
ageHours: null,
|
|
995
1042
|
ttlExpired: false,
|
|
996
|
-
|
|
1043
|
+
heartbeatAgeMinutes: null,
|
|
997
1044
|
host: null,
|
|
998
1045
|
sameHost: false,
|
|
999
1046
|
isLive: false,
|
|
@@ -1002,14 +1049,22 @@ export function checkStale({ repoRoot } = {}) {
|
|
|
1002
1049
|
|
|
1003
1050
|
const ageHours = lockAgeHours(lock);
|
|
1004
1051
|
const ttlExpired = isTtlExpired(lock);
|
|
1005
|
-
|
|
1006
|
-
//
|
|
1007
|
-
const
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1052
|
+
// #1072: alias-aware, not a raw os.hostname() comparison — this machine's
|
|
1053
|
+
// hostname flips spelling, which made `sameHost` false for its own lock.
|
|
1054
|
+
const sameHost = hostnamesMatch(lockHostCandidate(lock), os.hostname());
|
|
1055
|
+
// NO `pidAlive` FIELD (#1151). #1137 kept it as an always-null shape stub;
|
|
1056
|
+
// nothing ever read it — measured @ f0766e1, zero production readers
|
|
1057
|
+
// repo-wide. Probing isPidAliveOnHost(lock.pid) answered a question about the
|
|
1058
|
+
// ephemeral writer subprocess, not the session: 2026-08-23, 7 of 7 recorded
|
|
1059
|
+
// pids were dead, including the lock of the session that was heartbeating at
|
|
1060
|
+
// that very moment, so a `false` here read as "the session is dead" and was
|
|
1061
|
+
// wrong every time. `isLive` is the verdict and `heartbeatAgeMinutes` the
|
|
1062
|
+
// magnitude behind it. `isPidAliveOnHost` itself stays exported —
|
|
1063
|
+
// file-lock.mjs and lock-reaper.mjs are legitimate callers, where the pid IS
|
|
1064
|
+
// the process being asked about.
|
|
1065
|
+
// Heartbeat-based liveness (#744) — the SAME check acquire()'s
|
|
1066
|
+
// classifyExisting uses as its sole active gate, surfaced here so callers of
|
|
1067
|
+
// checkStale() (recovery-flow diagnostics) can observe it directly.
|
|
1013
1068
|
const isLive = isLockLive(lock);
|
|
1014
1069
|
|
|
1015
1070
|
return {
|
|
@@ -1017,7 +1072,7 @@ export function checkStale({ repoRoot } = {}) {
|
|
|
1017
1072
|
lock,
|
|
1018
1073
|
ageHours,
|
|
1019
1074
|
ttlExpired,
|
|
1020
|
-
|
|
1075
|
+
heartbeatAgeMinutes: heartbeatAgeMinutes(lock),
|
|
1021
1076
|
host: lock.host,
|
|
1022
1077
|
sameHost,
|
|
1023
1078
|
isLive,
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* aliases) or parent barrel.
|
|
7
7
|
*
|
|
8
8
|
* Exports: normalizeSession
|
|
9
|
-
* Module-private: _warnedMissingSchemaVersion (Set, per-process dedupe)
|
|
9
|
+
* Module-private: _warnedMissingSchemaVersion (Set, per-process dedupe),
|
|
10
|
+
* isPlainObject, _canonicalizeExpressPath
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { SESSION_KEY_ALIASES } from './constants.mjs';
|
|
@@ -19,19 +20,82 @@ import { SESSION_KEY_ALIASES } from './constants.mjs';
|
|
|
19
20
|
// per process, preventing log-spam on large sessions.jsonl files.
|
|
20
21
|
const _warnedMissingSchemaVersion = new Set();
|
|
21
22
|
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Internal helper (intentional duplication — Option 1 submodule isolation,
|
|
25
|
+
// mirroring aliases.mjs / validator.mjs rather than adding a cross-import)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function isPlainObject(v) {
|
|
29
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Collapse the legacy OBJECT form of `express_path` onto the canonical
|
|
34
|
+
* BOOLEAN form, in place on the caller's already-copied `next` object.
|
|
35
|
+
*
|
|
36
|
+
* WHY BOOLEAN IS CANONICAL. The ledger carried the field in two forms with no
|
|
37
|
+
* arbiter: 20 boolean records (14 `false`, 6 `true`) and exactly ONE object,
|
|
38
|
+
* `{activated, tasks, notes}` on `main-2026-05-01-housekeeping-2` (measured
|
|
39
|
+
* 2026-08-23 over 271 records). That single record is a hand-composition
|
|
40
|
+
* artefact, not a richer designed variant: it is the only one of the 21 with
|
|
41
|
+
* no top-level `notes`, and its `express_path.notes` holds session-level
|
|
42
|
+
* narrative that every other record puts in the canonical top-level `notes`.
|
|
43
|
+
* It also carries the same era's ad-hoc vocabulary elsewhere
|
|
44
|
+
* (`waves_completed`, `issues_new`, `cross_repo_writes`), so the object form
|
|
45
|
+
* is that composition freedom, not a schema. The question the field exists to
|
|
46
|
+
* answer — "greift der Express Path?" (docs/prd/2026-08-22-framework-
|
|
47
|
+
* verschlankung.md § VS-1) — is a boolean question whose denominator is the
|
|
48
|
+
* 14 `false` records.
|
|
49
|
+
*
|
|
50
|
+
* NOTHING IS DISCARDED. The original object is preserved verbatim under
|
|
51
|
+
* `_express_path_detail`, following the module's own "original keys are
|
|
52
|
+
* preserved alongside their canonical alias" convention and the
|
|
53
|
+
* `_completed_at_conflict` forensics tag in aliases.mjs. The conversion is
|
|
54
|
+
* therefore deterministic AND reversible.
|
|
55
|
+
*
|
|
56
|
+
* A shape this function cannot recognise (string, number, array, or an object
|
|
57
|
+
* without a boolean `activated`) is passed through UNTOUCHED — normalizeSession
|
|
58
|
+
* never throws. Refusing such a shape is the write path's job; validator.mjs
|
|
59
|
+
* rejects it there.
|
|
60
|
+
*
|
|
61
|
+
* BOUNDED TOLERANCE — REVISIT TRIGGER: the legacy object form is accepted only
|
|
62
|
+
* because one historical record uses it. When that record leaves the ledger (or
|
|
63
|
+
* is rewritten by a migration), delete this function and the matching
|
|
64
|
+
* `express_path` object clause in validator.mjs — canonical-boolean-only.
|
|
65
|
+
*
|
|
66
|
+
* @param {Record<string, any>} next — mutable copy of the entry being normalized
|
|
67
|
+
*/
|
|
68
|
+
function _canonicalizeExpressPath(next) {
|
|
69
|
+
if (!('express_path' in next)) return;
|
|
70
|
+
const raw = next.express_path;
|
|
71
|
+
if (!isPlainObject(raw)) return; // boolean (canonical), null, or unrecognised
|
|
72
|
+
if (typeof raw.activated !== 'boolean') return; // not the legacy shape
|
|
73
|
+
|
|
74
|
+
// Non-clobber, matching the alias rule above: never overwrite a sidecar that
|
|
75
|
+
// a previous normalization pass (or a producer) already wrote.
|
|
76
|
+
if (!('_express_path_detail' in next)) {
|
|
77
|
+
next._express_path_detail = raw;
|
|
78
|
+
}
|
|
79
|
+
next.express_path = raw.activated;
|
|
80
|
+
}
|
|
81
|
+
|
|
22
82
|
// ---------------------------------------------------------------------------
|
|
23
83
|
// Public API
|
|
24
84
|
// ---------------------------------------------------------------------------
|
|
25
85
|
|
|
26
86
|
/**
|
|
27
|
-
* Normalize a session entry read from disk. Applies SAFE key aliases,
|
|
87
|
+
* Normalize a session entry read from disk. Applies SAFE key aliases, collapses
|
|
88
|
+
* the legacy object form of `express_path` onto its canonical boolean, and tags
|
|
28
89
|
* legacy entries without `schema_version` as 0 (distinct from
|
|
29
90
|
* CURRENT_SESSION_SCHEMA_VERSION=2 which is stamped on new writes; bumped
|
|
30
91
|
* 1 -> 2 via #372).
|
|
31
92
|
*
|
|
32
93
|
* Never throws. Malformed input (null, non-object, array) is passed through
|
|
33
94
|
* unchanged. Original keys are preserved alongside their canonical alias for
|
|
34
|
-
* debugging
|
|
95
|
+
* debugging; the pre-collapse `express_path` object is preserved under
|
|
96
|
+
* `_express_path_detail` for the same reason.
|
|
97
|
+
*
|
|
98
|
+
* Idempotent: normalize(normalize(x)) deep-equals normalize(x).
|
|
35
99
|
*
|
|
36
100
|
* @param {any} entry
|
|
37
101
|
* @returns {any} normalized entry (or original if non-object)
|
|
@@ -48,6 +112,9 @@ export function normalizeSession(entry) {
|
|
|
48
112
|
}
|
|
49
113
|
}
|
|
50
114
|
|
|
115
|
+
// express_path — same key, two shapes. Collapse onto the canonical boolean.
|
|
116
|
+
_canonicalizeExpressPath(next);
|
|
117
|
+
|
|
51
118
|
// schema_version — legacy entries tagged as 0 (NOT CURRENT_SESSION_SCHEMA_VERSION).
|
|
52
119
|
if ('schema_version' in next && next.schema_version !== undefined) {
|
|
53
120
|
// Preserve existing version.
|
|
@@ -431,6 +431,46 @@ function _validateOptionalFields(entry) {
|
|
|
431
431
|
}
|
|
432
432
|
}
|
|
433
433
|
}
|
|
434
|
+
|
|
435
|
+
// `express_path` — CANONICAL FORM IS BOOLEAN (`true` = the express path was
|
|
436
|
+
// taken, `false` = it was offered and declined). Until now the field had no
|
|
437
|
+
// arbiter at all: the ledger carried 20 booleans and 1 object
|
|
438
|
+
// (`{activated, tasks, notes}`) and nothing in the schema knew the key, so
|
|
439
|
+
// neither shape could be called wrong. See the rationale + the 2026-08-23
|
|
440
|
+
// census in normalizer.mjs `_canonicalizeExpressPath`.
|
|
441
|
+
//
|
|
442
|
+
// The legacy `{activated: boolean, ...}` object is a BOUNDED read tolerance,
|
|
443
|
+
// in the same spirit as `_validateSchemaVersion` accepting [0,1,2,3] while
|
|
444
|
+
// only CURRENT_SESSION_SCHEMA_VERSION is ever written. It is bounded in two
|
|
445
|
+
// ways: an object WITHOUT a boolean `activated` is refused (the tolerance
|
|
446
|
+
// cannot grow into "any object"), and `normalizeSession` collapses the
|
|
447
|
+
// legacy object to the canonical boolean on every read, so no consumer
|
|
448
|
+
// downstream of the read path ever observes two shapes. Rejecting the legacy
|
|
449
|
+
// object outright here would have cost exactly one historical record its
|
|
450
|
+
// clean bill of health in `checkSessionsIntegrity` for zero benefit —
|
|
451
|
+
// measured 2026-08-23: `validateSession` fails 0 of 271 records today.
|
|
452
|
+
//
|
|
453
|
+
// REVISIT TRIGGER: when no ledger record uses the object form any more, drop
|
|
454
|
+
// the `isPlainObject` branch below and require a boolean outright.
|
|
455
|
+
if (entry.express_path !== undefined && entry.express_path !== null) {
|
|
456
|
+
const ep = entry.express_path;
|
|
457
|
+
if (typeof ep !== 'boolean' && !(isPlainObject(ep) && typeof ep.activated === 'boolean')) {
|
|
458
|
+
const shape = Array.isArray(ep) ? 'array' : typeof ep;
|
|
459
|
+
throw new ValidationError(
|
|
460
|
+
`express_path must be a boolean (canonical), null, or the legacy {activated: boolean} object, got: ${shape}`
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// `_express_path_detail` — forensic sidecar written by `normalizeSession`
|
|
466
|
+
// when it collapses a legacy object `express_path` onto its boolean. Holds
|
|
467
|
+
// the pre-collapse object verbatim so the conversion stays reversible.
|
|
468
|
+
// Same optional-object contract as `effectiveness` / `discovery_stats`.
|
|
469
|
+
if (entry._express_path_detail !== undefined && entry._express_path_detail !== null) {
|
|
470
|
+
if (!isPlainObject(entry._express_path_detail)) {
|
|
471
|
+
throw new ValidationError('_express_path_detail must be an object or null');
|
|
472
|
+
}
|
|
473
|
+
}
|
|
434
474
|
}
|
|
435
475
|
|
|
436
476
|
// ---------------------------------------------------------------------------
|