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.
Files changed (316) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +446 -0
  91. package/README.md +22 -17
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -2
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/session-reviewer.md +7 -1
  98. package/agents/test-writer.md +2 -1
  99. package/agents/ui-developer.md +2 -1
  100. package/commands/bootstrap.md +2 -2
  101. package/commands/close.md +3 -1
  102. package/commands/go.md +1 -1
  103. package/commands/journey-audit.md +43 -0
  104. package/docs/USER-GUIDE.md +2 -2
  105. package/docs/ci-setup.md +194 -25
  106. package/docs/codex-setup.md +64 -0
  107. package/docs/components.md +7 -7
  108. package/docs/cursor-setup.md +26 -47
  109. package/docs/events-schema.md +120 -10
  110. package/docs/github-mirror-protection.md +197 -0
  111. package/docs/pi-setup.md +2 -0
  112. package/docs/rule-authoring.md +3 -1
  113. package/docs/scope-collision-guard.md +49 -2
  114. package/docs/session-config-reference.md +89 -9
  115. package/docs/session-config-template.md +38 -7
  116. package/docs/telemetry/telemetry-claims.md +11 -10
  117. package/docs/telemetry.md +52 -1
  118. package/hooks/_lib/atomic-json.mjs +111 -0
  119. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  120. package/hooks/_lib/subagent-paths.mjs +143 -0
  121. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  122. package/hooks/cwd-change-restore.mjs +9 -29
  123. package/hooks/enforce-scope.mjs +93 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +2 -2
  128. package/hooks/on-session-end.mjs +486 -19
  129. package/hooks/on-session-start.mjs +263 -12
  130. package/hooks/on-stop.mjs +392 -24
  131. package/hooks/post-bash-write-verify.mjs +104 -4
  132. package/hooks/post-subagent-discovery-validator.mjs +182 -21
  133. package/hooks/post-tool-batch-wave-signal.mjs +165 -42
  134. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  135. package/hooks/pre-bash-issue-budget.mjs +117 -4
  136. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  138. package/hooks/pre-bash-staging-fence.mjs +4 -0
  139. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  140. package/hooks/skill-invocation-telemetry.mjs +21 -10
  141. package/hooks/subagent-telemetry.mjs +11 -26
  142. package/monitors/monitors.json +6 -0
  143. package/package.json +1 -1
  144. package/pi/prompts/journey-audit.md +12 -0
  145. package/rules/_index.md +9 -1
  146. package/rules/always-on/ask-via-tool.md +62 -0
  147. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  148. package/rules/always-on/build-value.md +47 -0
  149. package/rules/always-on/cross-session-messaging.md +59 -0
  150. package/rules/always-on/loop-and-monitor.md +221 -0
  151. package/rules/always-on/parallel-sessions.md +142 -12
  152. package/rules/always-on/receiving-review.md +108 -0
  153. package/rules/always-on/test-value.md +40 -0
  154. package/rules/always-on/verification-before-completion.md +77 -0
  155. package/scripts/archive-closed-prds.mjs +258 -18
  156. package/scripts/autopilot.mjs +31 -12
  157. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  158. package/scripts/backfill-evidence-digest.mjs +376 -0
  159. package/scripts/cursor-install.mjs +89 -48
  160. package/scripts/emit-event.mjs +10 -2
  161. package/scripts/export-hw-learnings.mjs +143 -2
  162. package/scripts/express-path.mjs +299 -0
  163. package/scripts/generate-cursor-adapter.mjs +253 -0
  164. package/scripts/github-protection-audit.mjs +358 -0
  165. package/scripts/lib/auq/parse.mjs +5 -29
  166. package/scripts/lib/auto-dialectic.mjs +68 -0
  167. package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
  168. package/scripts/lib/build-live-signals.mjs +49 -27
  169. package/scripts/lib/ci-status-banner.mjs +158 -11
  170. package/scripts/lib/cold-start-detector.mjs +23 -14
  171. package/scripts/lib/command-blocker.mjs +70 -0
  172. package/scripts/lib/config/block-header.mjs +55 -0
  173. package/scripts/lib/config/discovery-validator.mjs +7 -2
  174. package/scripts/lib/config/health-endpoints.mjs +383 -0
  175. package/scripts/lib/config/reconcile.mjs +79 -4
  176. package/scripts/lib/config/remote-hosts.mjs +233 -0
  177. package/scripts/lib/config/section-extractor.mjs +235 -36
  178. package/scripts/lib/config-schema.mjs +9 -1
  179. package/scripts/lib/config.mjs +87 -8
  180. package/scripts/lib/convergence-monitor.mjs +13 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  182. package/scripts/lib/dispatcher/cli.mjs +2 -2
  183. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  184. package/scripts/lib/events-schema.mjs +48 -0
  185. package/scripts/lib/events.mjs +238 -5
  186. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  187. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  188. package/scripts/lib/express-path.mjs +327 -0
  189. package/scripts/lib/file-lock.mjs +22 -4
  190. package/scripts/lib/gates/gate-full.mjs +81 -8
  191. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  192. package/scripts/lib/git-config-drift.mjs +134 -5
  193. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  194. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  195. package/scripts/lib/host-identity.mjs +247 -2
  196. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  197. package/scripts/lib/issue-budget.mjs +229 -30
  198. package/scripts/lib/learnings/io.mjs +55 -10
  199. package/scripts/lib/learnings/schema.mjs +95 -28
  200. package/scripts/lib/lock-reaper.mjs +7 -1
  201. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  202. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  203. package/scripts/lib/memory-banner.mjs +25 -10
  204. package/scripts/lib/memory-paths.mjs +15 -6
  205. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  206. package/scripts/lib/peer-discovery.mjs +20 -2
  207. package/scripts/lib/platform.mjs +72 -9
  208. package/scripts/lib/plugin-root.mjs +143 -19
  209. package/scripts/lib/project-hygiene.mjs +43 -3
  210. package/scripts/lib/quality-gate.mjs +271 -13
  211. package/scripts/lib/reconcile/emitter.mjs +87 -19
  212. package/scripts/lib/reconcile/engine.mjs +517 -18
  213. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  214. package/scripts/lib/reconcile/renderer.mjs +148 -3
  215. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  216. package/scripts/lib/reconcile/writer.mjs +415 -84
  217. package/scripts/lib/rule-loader.mjs +37 -2
  218. package/scripts/lib/rules-sync.mjs +51 -8
  219. package/scripts/lib/scope-gate.mjs +126 -0
  220. package/scripts/lib/session-close-backfill.mjs +427 -37
  221. package/scripts/lib/session-discovery.mjs +69 -5
  222. package/scripts/lib/session-end/phase-skip.mjs +38 -5
  223. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  224. package/scripts/lib/session-id.mjs +30 -14
  225. package/scripts/lib/session-identity/own-session.mjs +220 -0
  226. package/scripts/lib/session-lock.mjs +85 -30
  227. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  228. package/scripts/lib/session-schema/validator.mjs +40 -0
  229. package/scripts/lib/session-start-probes.mjs +608 -0
  230. package/scripts/lib/session-transition.mjs +277 -0
  231. package/scripts/lib/sessions-canonical.mjs +446 -0
  232. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  233. package/scripts/lib/spiral-carryover.mjs +90 -9
  234. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  235. package/scripts/lib/state-md/mission-status.mjs +350 -52
  236. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  237. package/scripts/lib/state-md.mjs +12 -2
  238. package/scripts/lib/telemetry/schema.mjs +74 -8
  239. package/scripts/lib/telemetry/sync.mjs +91 -16
  240. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  241. package/scripts/lib/validate/check-agents.mjs +66 -0
  242. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  243. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  244. package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
  245. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  246. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  247. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  248. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  249. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  250. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  251. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  252. package/scripts/lib/validate/check-unwired-features.mjs +333 -32
  253. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  254. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  255. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  256. package/scripts/lib/validate/repo-files.mjs +275 -0
  257. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  258. package/scripts/lib/vault-mirror/process.mjs +99 -43
  259. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  260. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  261. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  262. package/scripts/lib/vault-status/board-writer.mjs +381 -141
  263. package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
  264. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  265. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  266. package/scripts/lib/wave-resource-gate.mjs +127 -7
  267. package/scripts/lib/wave-transcript-tail.mjs +889 -0
  268. package/scripts/materialize-wave-scope.mjs +228 -15
  269. package/scripts/mcp-server.sh +11 -2
  270. package/scripts/memory-propose.mjs +132 -8
  271. package/scripts/parse-config.mjs +65 -0
  272. package/scripts/promote-vault-strict.mjs +4 -15
  273. package/scripts/site-numbers.mjs +36 -4
  274. package/scripts/token-audit.sh +9 -2
  275. package/scripts/validate-plugin.mjs +29 -0
  276. package/scripts/validate-wave-scope.mjs +67 -0
  277. package/scripts/vault-consolidate.mjs +3 -11
  278. package/scripts/vault-integration-watcher.mjs +2 -4
  279. package/scripts/vault-mirror.mjs +305 -51
  280. package/skills/_shared/monitor-patterns.md +31 -5
  281. package/skills/_shared/parallel-aware-auq.md +31 -2
  282. package/skills/_shared/parallel-aware-preamble.md +19 -4
  283. package/skills/_shared/platform-tools.md +11 -5
  284. package/skills/_shared/state-ownership.md +29 -2
  285. package/skills/autopilot/SKILL.md +5 -1
  286. package/skills/bootstrap/SKILL.md +3 -3
  287. package/skills/bootstrap/_shared-template.md +18 -10
  288. package/skills/bootstrap/deep-template.md +10 -6
  289. package/skills/bootstrap/fast-template.md +15 -8
  290. package/skills/bootstrap/standard-template.md +10 -6
  291. package/skills/claude-md-drift-check/checker.mjs +39 -11
  292. package/skills/contract-version-bump/SKILL.md +1 -1
  293. package/skills/dispatcher/SKILL.md +1 -1
  294. package/skills/ecosystem-health/SKILL.md +4 -1
  295. package/skills/ecosystem-health/wizard.md +5 -0
  296. package/skills/evolve/SKILL.md +38 -1
  297. package/skills/journey-audit/SKILL.md +270 -0
  298. package/skills/peekaboo-driver/SKILL.md +15 -3
  299. package/skills/persona-panel/SKILL.md +1 -1
  300. package/skills/reconcile/SKILL.md +46 -3
  301. package/skills/remote-offload/SKILL.md +89 -0
  302. package/skills/session-end/SKILL.md +17 -4
  303. package/skills/session-end/metrics-collection.md +7 -4
  304. package/skills/session-end/phase-3-6-tail.md +20 -9
  305. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  306. package/skills/session-plan/SKILL.md +6 -1
  307. package/skills/session-plan/wave-template.md +1 -0
  308. package/skills/session-start/SKILL.md +54 -17
  309. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  310. package/skills/session-start/phase-8-5-express-path.md +77 -12
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +5 -3
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +143 -22
  315. package/templates/_shared/journey-manifest.md +110 -0
  316. 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
- // Same-host callers (`acquire`, `checkStale`, and the state-lock /
105
- // staging-fence stale-override paths, now via the file-lock primitive) use it
106
- // only for the short-lived stale-override path where the recorded PID IS the
107
- // live writer's PID. See file-lock.mjs for the full @forensic + PID-recycle
108
- // trade-off note.
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-pid-dead', existingLock, exclusivityClass? }
459
- * — local lock stale (dead PID)
460
- * { ok: false, reason: 'stale-pid-alive', existingLock, exclusivityClass? }
461
- * local lock stale (live PID, TTL expired)
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-pid-dead / stale-pid-alive reasons.
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 — classify the stale variant. Cross-host locks never
585
- // have a confirmable dead PID (pidAlive stays null), so they always land
586
- // on 'stale-pid-alive' rather than 'stale-pid-dead'.
587
- const reason = (pidAlive === false) ? 'stale-pid-dead' : 'stale-pid-alive';
588
- return { ok: false, reason, existingLock: existing, exclusivityClass: callerClass };
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
- * pidAlive: boolean|null,
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
- pidAlive: null,
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
- const sameHost = lock.host === os.hostname();
1006
- // Only attempt PID check when the lock was written on this machine.
1007
- const pidAlive = sameHost ? isPidAliveOnHost(lock.pid) : null;
1008
- // Heartbeat-based liveness (#744) additive field alongside the pre-existing
1009
- // ttlExpired/pidAlive/sameHost fields (back-compat). This is the SAME check
1010
- // acquire()'s classifyExisting now uses as its sole active gate, surfaced
1011
- // here so callers of checkStale() (recovery-flow diagnostics) can observe
1012
- // when isLive diverges from the legacy pidAlive/ttlExpired signals.
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
- pidAlive,
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, tags
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
  // ---------------------------------------------------------------------------