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
@@ -32,6 +32,28 @@
32
32
  * - Lock takes precedence over registry (more detail per session); registry
33
33
  * supplements missing per-worktree entries (e.g., when the hook ran but
34
34
  * the prose Phase 1.2 acquire was skipped).
35
+ * - A live local session.lock does NOT supersede same-repo registry entries
36
+ * with a different raw session_id (GH#67 candidate fix, measured 2026-09-02
37
+ * and NOT adopted): the session lock is advisory, so a second session can
38
+ * and demonstrably does run in the same working copy without holding it.
39
+ * Filtering those entries out hides genuine same-working-copy peers — the
40
+ * #1085 semantic-alias boundary (tests/integration/session-identity-
41
+ * boundaries.test.mjs) and 7 peer-discovery cases pin exactly that
42
+ * visibility. Any GH#67 fix must discriminate finished-but-fresh entries
43
+ * some other way; do not re-attempt lock-ownership supersession here.
44
+ * See ADR-0014 (docs/adr/0014-peer-visibility-under-advisory-lock.md) for
45
+ * the annotate-never-filter decision and the 9-test refutation of the
46
+ * filtering alternative.
47
+ * - The adopted GH#67 shape is therefore ADDITIVE ANNOTATION, never a filter.
48
+ * Registry-sourced sessions carry `registryOnly: true` plus
49
+ * `lockSuperseded` / `lockOwnerId`. `lockSuperseded: true` means: a LIVE
50
+ * lock at this repoRoot is owned by a different raw session_id than this
51
+ * registry entry. It is a HINT, not a verdict — the lock is advisory, so
52
+ * the entry may still be a live session that lost the acquire race (#1085
53
+ * contract). Consumers deciding a worktree-PROMOTION_OFFER downgrade such
54
+ * a peer to an advisory line (GH#67); consumers counting or displaying
55
+ * peers keep it. Lock-sourced sessions carry none of the three fields, so
56
+ * their objects stay byte-identical to the pre-GH#67 shape.
35
57
  *
36
58
  * Timeout + A1 fallback:
37
59
  * - listWorktrees() is raced against DEFAULT_DISCOVERY_TIMEOUT_MS (2 s).
@@ -46,6 +68,7 @@ import os from 'node:os';
46
68
  import { listWorktrees } from './worktree/listing.mjs';
47
69
  import { readLock, isLockLive } from './session-lock.mjs';
48
70
  import { readRegistry, repoPathHash, isRegistryEntryFresh } from './session-registry.mjs';
71
+ import { stableHostname } from './host-identity.mjs';
49
72
 
50
73
  // ---------------------------------------------------------------------------
51
74
  // Constants
@@ -66,7 +89,7 @@ export const DEFAULT_DISCOVERY_TIMEOUT_MS = 2000;
66
89
  * @param {object} lock Parsed lock body (schema v2).
67
90
  * @param {string} worktreePath Absolute worktree path.
68
91
  * @param {string} [branch] Branch from worktree object (falsy → '').
69
- * @returns {{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,branch:string}}
92
+ * @returns {{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,host_id:string,branch:string}}
70
93
  */
71
94
  function sessionFromLock(lock, worktreePath, branch = '') {
72
95
  return {
@@ -76,6 +99,12 @@ function sessionFromLock(lock, worktreePath, branch = '') {
76
99
  startedAt: lock.started_at,
77
100
  pid: lock.pid,
78
101
  host: lock.host,
102
+ // Additive normalised twin (#1072), same field the registry path emits —
103
+ // without it the two discovery sources returned DIFFERENT shapes, and a
104
+ // consumer comparing hosts silently fell back to the raw `host` for
105
+ // lock-sourced sessions only. Locks written before #1072 carry no
106
+ // `host_id`, so derive it from the raw name rather than emitting undefined.
107
+ host_id: lock.host_id ?? stableHostname(lock.host),
79
108
  branch: typeof branch === 'string' ? branch : '',
80
109
  };
81
110
  }
@@ -86,10 +115,24 @@ function sessionFromLock(lock, worktreePath, branch = '') {
86
115
  * does not record per-worktree paths — only repo-path-hashes. Branch is
87
116
  * passed through from the registry entry when available.
88
117
  *
118
+ * GH#67 annotation (additive, never a filter — see the module header): every
119
+ * registry-sourced session carries `registryOnly: true` and the pair
120
+ * `lockSuperseded` / `lockOwnerId`. `lockSuperseded: true` says a LIVE lock at
121
+ * this repoRoot is owned by a DIFFERENT raw session_id than this entry — a
122
+ * HINT that the entry may be a finished-but-still-fresh task (the GH#67 case:
123
+ * Codex emits no SessionEnd, so the registry entry outlives the task by up to
124
+ * `freshnessMin`). It is not a verdict: the lock is advisory, so the entry may
125
+ * equally be a live session that never acquired it (#1085). Consumers deciding
126
+ * a worktree PROMOTION_OFFER downgrade such a peer to an advisory line;
127
+ * consumers counting or displaying peers keep it unchanged.
128
+ *
89
129
  * @param {object} entry Registry entry.
90
130
  * @param {string} repoRoot Discovery repoRoot (used as worktreePath fallback).
131
+ * @param {object} [ctx]
132
+ * @param {string|null} [ctx.lockOwnerId] Raw session_id owning a LIVE lock at
133
+ * repoRoot, or null when there is no live local lock.
91
134
  */
92
- function sessionFromRegistryEntry(entry, repoRoot) {
135
+ function sessionFromRegistryEntry(entry, repoRoot, { lockOwnerId = null } = {}) {
93
136
  return {
94
137
  worktreePath: repoRoot,
95
138
  sessionId: entry.session_id,
@@ -100,7 +143,15 @@ function sessionFromRegistryEntry(entry, repoRoot) {
100
143
  // local hostname as the default since registry entries are host-scoped
101
144
  // by design (see ~/.config/session-orchestrator/sessions/).
102
145
  host: os.hostname(),
146
+ // Additive normalised twin (#1072) — `os.hostname()` is not stable on a
147
+ // single machine, so consumers comparing hosts need the normalised form.
148
+ host_id: stableHostname(),
103
149
  branch: typeof entry.branch === 'string' ? entry.branch : '',
150
+ // GH#67 additive annotation (see the JSDoc above). Lock-sourced sessions
151
+ // deliberately carry none of these three fields.
152
+ registryOnly: true,
153
+ lockSuperseded: lockOwnerId !== null && entry.session_id !== lockOwnerId,
154
+ lockOwnerId,
104
155
  };
105
156
  }
106
157
 
@@ -111,7 +162,7 @@ function sessionFromRegistryEntry(entry, repoRoot) {
111
162
  * Liveness rule: heartbeat freshness via isLockLive (Epic #583, W2-I3).
112
163
  *
113
164
  * @param {string} repoRoot
114
- * @returns {Array<{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,branch:string}>}
165
+ * @returns {Array<{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,host_id:string,branch:string}>}
115
166
  */
116
167
  function readLocalSession(repoRoot) {
117
168
  const lock = readLock({ repoRoot });
@@ -167,7 +218,10 @@ function dedupeBySessionId(sessions) {
167
218
  * @param {Function} [opts.registryReader] DI hook replacing readRegistry() for tests.
168
219
  * @param {number} [opts.freshnessMin=15] Registry-entry freshness threshold in minutes.
169
220
  * @param {number} [opts.now] ms-since-epoch (test seam for heartbeat freshness).
170
- * @returns {Promise<Array<{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,branch:string}>>}
221
+ * @returns {Promise<Array<{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,host_id:string,branch:string,registryOnly?:boolean,lockSuperseded?:boolean,lockOwnerId?:string|null}>>}
222
+ * The last three fields appear ONLY on registry-sourced sessions (GH#67
223
+ * annotation — see `sessionFromRegistryEntry`); lock-sourced sessions omit
224
+ * them entirely.
171
225
  */
172
226
  export async function discoverActiveSessions(repoRoot, opts = {}) {
173
227
  const listWorktreesFn = opts.listWorktreesImpl ?? listWorktrees;
@@ -222,10 +276,20 @@ export async function discoverActiveSessions(repoRoot, opts = {}) {
222
276
  try {
223
277
  const allEntries = await registryReaderFn();
224
278
  const myRepoHash = repoPathHash(repoRoot);
279
+ // GH#67: who (if anyone) holds a LIVE lock at this repoRoot right now.
280
+ // Read once, outside the map — it is a property of the repo, not of an
281
+ // entry. A stale lock proves nothing, so it yields null.
282
+ const localLock = readLock({ repoRoot });
283
+ const lockOwnerId = (
284
+ localLock
285
+ && isLockLive(localLock, nowMs)
286
+ && typeof localLock.session_id === 'string'
287
+ && localLock.session_id
288
+ ) ? localLock.session_id : null;
225
289
  registrySessions = allEntries
226
290
  .filter((e) => e && e.repo_path_hash === myRepoHash)
227
291
  .filter((e) => isRegistryEntryFresh(e, { freshnessMin, now: nowMs }))
228
- .map((e) => sessionFromRegistryEntry(e, repoRoot));
292
+ .map((e) => sessionFromRegistryEntry(e, repoRoot, { lockOwnerId }));
229
293
  } catch {
230
294
  // Registry read failure → keep going with lock-only results.
231
295
  registrySessions = [];
@@ -37,6 +37,10 @@
37
37
  * ('config-gate' | 'proposals.jsonl' | 'sweep-dry-run' | 'auto-dream-signal' |
38
38
  * 'skill-invocations.jsonl' | 'auto-dialectic-signal' | 'reconcile-dry-run' |
39
39
  * 'learnings.jsonl' | 'probe-error').
40
+ * @property {string[]} [targets] - 3.6.8 only, RUN decisions only: the effective
41
+ * reconcile write-targets (issue #1099). Additive — absent on every other phase.
42
+ * @property {string|null} [baselineRoot] - 3.6.8 only, RUN decisions only: the
43
+ * resolved projects-baseline root, or null when `baseline` is not in effect.
40
44
  *
41
45
  * @typedef {Object} TailPlan
42
46
  * @property {PhaseDecision[]} plan
@@ -51,7 +55,7 @@ import { sweepExpiredLearnings } from '../learnings/expiry-sweep.mjs';
51
55
  import { shouldDispatchAutoDream } from '../auto-dream.mjs';
52
56
  import { shouldDispatchAutoDialectic } from '../auto-dialectic.mjs';
53
57
  import { readSkillInvocations } from '../skill-invocations-schema.mjs';
54
- import { runReconcile } from '../reconcile/engine.mjs';
58
+ import { runReconcileFromPhaseSkip, resolveEffectiveTargets } from '../reconcile/engine.mjs';
55
59
  import { resolveMemoryDir } from '../memory-paths.mjs';
56
60
 
57
61
  // ---------------------------------------------------------------------------
@@ -178,7 +182,9 @@ async function decideAutoDream({ repoRoot, cfg, platform, memoryDir }) {
178
182
  if (!isClaudePlatform(platform)) {
179
183
  return mkSkip(phase, 'non-Claude-Code platform (memory dir unavailable)', 'config-gate');
180
184
  }
181
- const dir = memoryDir ?? resolveMemoryDir();
185
+ // #1071: same root as the `repoRoot` handed to shouldDispatchAutoDream below —
186
+ // a cwd-derived memory dir made the two halves of this decision disagree.
187
+ const dir = memoryDir ?? resolveMemoryDir(repoRoot);
182
188
  const dec = await shouldDispatchAutoDream({
183
189
  repoRoot,
184
190
  memoryDir: dir,
@@ -259,6 +265,14 @@ async function decideAutoDialectic({ repoRoot, cfg }) {
259
265
  * with `dryRun: true` (SKIPs the candidate-sidecar merge — no write) and the
260
266
  * operator confidence-floor delivery gate. 0 proposals above floor → skip.
261
267
  * runReconcile never throws; an engine `error` is treated fail-open (run).
268
+ *
269
+ * Target resolution (#1099) happens HERE — the layer where config → host-path
270
+ * resolution already lives — and deliberately only on the RUN branch: a WARN
271
+ * about an unusable baseline root in a session that had nothing to propose
272
+ * anyway would fire on almost every session and teach the operator to ignore it
273
+ * (`.claude/rules/host-resources.md` § HR-101). A `baseline` target that cannot
274
+ * be written is dropped BEFORE the coordinator surfaces the approval AUQ, so the
275
+ * operator is never asked to approve a write to a destination that cannot exist.
262
276
  */
263
277
  async function decideReconcile({ repoRoot, cfg }) {
264
278
  const phase = '3.6.8';
@@ -271,7 +285,7 @@ async function decideReconcile({ repoRoot, cfg }) {
271
285
  if (!existsSync(learningsPath)) {
272
286
  return mkSkip(phase, 'learnings.jsonl absent', 'learnings.jsonl');
273
287
  }
274
- const { proposals, summary, error } = await runReconcile({
288
+ const { proposals, summary, error } = await runReconcileFromPhaseSkip({
275
289
  repoRoot,
276
290
  ruleExpiryDays: cfg?.reconcile?.['rule-expiry-days'] ?? undefined,
277
291
  minRuleDays: cfg?.reconcile?.['min-rule-days'] ?? undefined,
@@ -292,7 +306,26 @@ async function decideReconcile({ repoRoot, cfg }) {
292
306
  'reconcile-dry-run',
293
307
  );
294
308
  }
295
- return mkRun(phase, `${surfaced.length} proposal(s) above confidence floor`, 'reconcile-dry-run');
309
+ const { targets, baselineRoot } = resolveEffectiveTargets({
310
+ targets: cfg?.reconcile?.targets,
311
+ baselineRoot: cfg?.['plan-baseline-path'],
312
+ });
313
+ if (targets.length === 0) {
314
+ return mkSkip(
315
+ phase,
316
+ `${surfaced.length} proposal(s) above floor but no writable target remains`,
317
+ 'config-gate',
318
+ );
319
+ }
320
+ return {
321
+ ...mkRun(
322
+ phase,
323
+ `${surfaced.length} proposal(s) above confidence floor → targets: ${targets.join(', ')}`,
324
+ 'reconcile-dry-run',
325
+ ),
326
+ targets,
327
+ baselineRoot,
328
+ };
296
329
  } catch (err) {
297
330
  return mkProbeError(phase, err);
298
331
  }
@@ -332,7 +365,7 @@ export function buildSkippedReport(plan) {
332
365
  * @param {object} args.config Parsed Session Config object (from parse-config.mjs).
333
366
  * @param {string|null} [args.sessionId] Current session id (for the 3.6.6 judged-set filter).
334
367
  * @param {string} [args.platform] Platform id ('claude' | 'codex' | 'cursor' | …).
335
- * @param {string} [args.memoryDir] Optional Auto-Dream memory dir override (default resolveMemoryDir()).
368
+ * @param {string} [args.memoryDir] Optional Auto-Dream memory dir override (default resolveMemoryDir(repoRoot)).
336
369
  * @returns {Promise<TailPlan>}
337
370
  */
338
371
  export async function planTailPhases({ repoRoot, config, sessionId, platform, memoryDir } = {}) {
@@ -2,9 +2,12 @@
2
2
  * worktree-cleanup.mjs — Phase 4a Auto-Promoted Worktree Cleanup helpers (#575 P3.2).
3
3
  *
4
4
  * Public API:
5
- * - detectAutoPromotedWorktree(repoRoot, sessionId, opts): { wtPath, sessionId, branch } | null
5
+ * - detectAutoPromotedWorktree(repoRoot, sessionId, opts): { wtPath, sessionId, branch, source } | null
6
6
  * - isWorktreeClean(wtPath, opts): boolean
7
7
  * (opts.execFileFn — injectable execFileSync seam for tests; #577 HARDEN-001)
8
+ * - PROMOTION_MARKER_RELPATH — repo-relative path of the promotion marker
9
+ * written by `enterWorktree()` (SSOT for the file location; the WRITER
10
+ * imports this constant from here, so writer and reader can never drift).
8
11
  *
9
12
  * Closes #575 — Epic #568 Phase 3.2 (Parallel-Aware Sessions Auto-Promoted Worktree Cleanup)
10
13
  * PRD: "Parallel-aware sessions" (#568; archived in the private Meta-Vault) §3 P3 Gherkin rows 2-3
@@ -21,27 +24,162 @@
21
24
  * kept divergent on purpose — unifying them would break the sync/async boundary.
22
25
  */
23
26
  import path from 'node:path';
27
+ import { readFileSync } from 'node:fs';
24
28
  import { execFileSync } from 'node:child_process';
25
29
  import { parseSessionId } from '../session-id.mjs';
26
30
 
31
+ /**
32
+ * Repo-relative location of the promotion marker `enterWorktree()` drops into
33
+ * every worktree it creates. Deliberately inside `.orchestrator/` (the session
34
+ * state dir) and deliberately WITHOUT any absolute path in its payload — the
35
+ * source checkout is recorded as `repoPathHash()` so the file can be committed
36
+ * or shipped without leaking the operator's filesystem layout.
37
+ *
38
+ * @type {string}
39
+ */
40
+ export const PROMOTION_MARKER_RELPATH = path.join('.orchestrator', 'promoted-from.json');
41
+
42
+ /**
43
+ * Read + shape-validate the promotion marker of a candidate worktree.
44
+ *
45
+ * Never throws: a missing file, a directory, unreadable permissions, invalid
46
+ * JSON, or a payload of the wrong shape all mean "no marker" (→ legacy path).
47
+ *
48
+ * @param {string} repoRoot
49
+ * @returns {{branch: string, source_session_id: string} & Record<string, unknown> | null}
50
+ */
51
+ function readPromotionMarker(repoRoot) {
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(readFileSync(path.join(repoRoot, PROMOTION_MARKER_RELPATH), 'utf8'));
55
+ } catch {
56
+ return null; // absent / unreadable / corrupt JSON — fall back to legacy detection
57
+ }
58
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
59
+ if (typeof parsed.branch !== 'string' || parsed.branch.length === 0) return null;
60
+ if (typeof parsed.source_session_id !== 'string' || parsed.source_session_id.length === 0) {
61
+ return null;
62
+ }
63
+ return parsed;
64
+ }
65
+
66
+ /**
67
+ * The marker path as git reports it in `status --porcelain` — always
68
+ * forward-slashed, regardless of the host separator.
69
+ * @type {string}
70
+ */
71
+ const PROMOTION_MARKER_GIT_PATH = PROMOTION_MARKER_RELPATH.split(path.sep).join('/');
72
+
73
+ /**
74
+ * True for the ONE porcelain line the promotion marker itself produces:
75
+ * `?? .orchestrator/promoted-from.json`.
76
+ *
77
+ * Why this exists: in THIS repo `.gitignore` already lists
78
+ * `.orchestrator/promoted-from.json` explicitly (`git check-ignore
79
+ * .orchestrator/promoted-from.json` exits 0), so the marker never reaches
80
+ * `git status --porcelain` here at all — this exemption is redundant
81
+ * belt-and-braces for the repo that ships it. It earns its keep in CONSUMER
82
+ * repos: `.orchestrator/` is commonly only PARTLY gitignored there
83
+ * (`.orchestrator/metrics/*.jsonl`, `session.lock`, … but not the bare
84
+ * directory, and not necessarily this file), and on a worktree whose branch
85
+ * predates that ignore line being added, the marker would show up as an
86
+ * untracked file and make every promoted worktree read "dirty" — turning the
87
+ * Phase 4a clean path (auto-remove) into a permanent operator AUQ. Repos that
88
+ * gitignore `.orchestrator/` wholesale never see the line at all. A third shape exists —
89
+ * the directory untracked AND un-ignored, where git collapses everything into
90
+ * one `?? .orchestrator/` line — and is deliberately NOT matched here:
91
+ * discounting a whole directory could hide operator work, and such a worktree
92
+ * is already dirty from the session's own `session.lock` / `STATE.md` writes
93
+ * regardless of this marker.
94
+ *
95
+ * Deliberately narrow: ONLY the untracked (`??`) status of exactly this path is
96
+ * ignored. A modified, staged, renamed or conflicted marker still counts as
97
+ * dirty, and no other file under `.orchestrator/` is affected — this is our own
98
+ * bookkeeping artefact, not operator work (PSA-003: we created it, so it is
99
+ * ours to discount).
100
+ *
101
+ * @param {string} line - One `git status --porcelain` line.
102
+ * @returns {boolean}
103
+ */
104
+ function isUntrackedPromotionMarker(line) {
105
+ if (!line.startsWith('?? ')) return false;
106
+ const filePath = line.slice(3).trim().replace(/^"(.*)"$/, '$1');
107
+ return filePath === PROMOTION_MARKER_GIT_PATH;
108
+ }
109
+
110
+ /**
111
+ * Current branch of a worktree, or `null` when it cannot be determined
112
+ * (git error, or a detached HEAD — `git branch --show-current` prints nothing).
113
+ *
114
+ * @param {Function} execFileFn
115
+ * @param {string} repoRoot
116
+ * @returns {string|null}
117
+ */
118
+ function currentBranchOf(execFileFn, repoRoot) {
119
+ try {
120
+ const out = execFileFn('git', ['-C', repoRoot, 'branch', '--show-current'], {
121
+ encoding: 'utf8',
122
+ });
123
+ const branch = String(out ?? '').trim();
124
+ return branch.length > 0 ? branch : null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
27
130
  /**
28
131
  * Detect whether the given repoRoot is an auto-promoted sibling worktree
29
132
  * created by `enterWorktree()` during the Phase 0.5 PROMOTION_OFFER path.
30
133
  *
31
- * Auto-promoted layout: <basePath>/<repo-name>-<sessionId>/
134
+ * Two keys, tried in this order:
135
+ *
136
+ * 1. **Marker (primary).** `<repoRoot>/.orchestrator/promoted-from.json`,
137
+ * written by `enterWorktree()` at creation time. Accepted when the file
138
+ * parses, carries `branch` + `source_session_id`, and the worktree's
139
+ * current branch either MATCHES the recorded one or cannot be read at all.
140
+ * This is the only key that survives the #1069 process boundary: since
141
+ * #1069 the session that RUNS in the promoted worktree is a NEW session
142
+ * with its OWN id, and since #1067 the worktree sits on `so/<sourceId>` —
143
+ * so the current session's id appears in neither the directory name nor
144
+ * the branch, and key 2 below can never match. Recording the fact at
145
+ * creation time is what makes it re-derivable later.
146
+ * 2. **Basename (legacy fallback).** `<basePath>/<main-repo-name>-<sessionId>/`
147
+ * against the CURRENT session id — still correct for worktrees created
148
+ * before the marker existed, and for the same-session case.
32
149
  *
33
150
  * Returns:
34
- * { wtPath, sessionId, branch } on match
151
+ * { wtPath, sessionId, branch, source: 'marker'|'basename' } on match
35
152
  * null on non-match (UUID session, non-promoted path, or git error)
36
153
  *
37
154
  * @param {string} repoRoot - Absolute path to the candidate worktree
38
155
  * @param {string} sessionId - Session ID (semantic or UUID)
39
- * @returns {{wtPath: string, sessionId: string, branch: string} | null}
156
+ * @returns {{wtPath: string, sessionId: string, branch: string, source: 'marker'|'basename'} | null}
40
157
  */
41
158
  export function detectAutoPromotedWorktree(repoRoot, sessionId, opts = {}) {
42
159
  // #577 HARDEN-001: execFileSync + args ARRAY (no shell) is structurally
43
160
  // injection-proof — repoRoot can never be interpreted as shell metacharacters.
44
161
  const execFileFn = opts.execFileFn ?? execFileSync;
162
+
163
+ // --- Key 1: the marker written at creation time (session-id independent) ---
164
+ const marker = readPromotionMarker(repoRoot);
165
+ if (marker) {
166
+ const current = currentBranchOf(execFileFn, repoRoot);
167
+ // `current === null` (git unavailable / detached HEAD) is accepted: the
168
+ // marker is written by exactly one code path, and the destructive step in
169
+ // Phase 4a is gated separately by `isWorktreeClean()`, which fails CLOSED
170
+ // on any git error. So an unverifiable branch can only ever route the
171
+ // operator into the AUQ, never into an automatic removal.
172
+ if (current === null || current === marker.branch) {
173
+ return {
174
+ wtPath: repoRoot,
175
+ sessionId: marker.source_session_id,
176
+ branch: marker.branch,
177
+ source: 'marker',
178
+ };
179
+ }
180
+ }
181
+
182
+ // --- Key 2: legacy basename match against the CURRENT session id ---
45
183
  const parsed = parseSessionId(sessionId);
46
184
  if (!parsed || parsed.format !== 'semantic') return null; // UUID-format sessions are never auto-promoted
47
185
 
@@ -72,7 +210,7 @@ export function detectAutoPromotedWorktree(repoRoot, sessionId, opts = {}) {
72
210
  const isPromotedPath = path.basename(repoRoot) === expectedBasename;
73
211
 
74
212
  if (isPromotedPath) {
75
- return { wtPath: repoRoot, sessionId, branch: parsed.branch };
213
+ return { wtPath: repoRoot, sessionId, branch: parsed.branch, source: 'basename' };
76
214
  }
77
215
  return null;
78
216
  }
@@ -82,7 +220,9 @@ export function detectAutoPromotedWorktree(repoRoot, sessionId, opts = {}) {
82
220
  *
83
221
  * A worktree is clean iff ALL three conditions hold:
84
222
  * 1. No uncommitted changes (`git status --porcelain` is empty)
85
- * 2. No untracked files (implicit in #1 — porcelain includes `??` entries)
223
+ * 2. No untracked files (implicit in #1 — porcelain includes `??` entries),
224
+ * with ONE exception: the untracked promotion marker this module's own
225
+ * writer drops into the worktree (see isUntrackedPromotionMarker)
86
226
  * 3. No unpushed commits (`git status --short --branch` lacks `ahead`)
87
227
  *
88
228
  * On any git error, returns `false` (safer per PSA-003 — conservative default
@@ -98,7 +238,14 @@ export function isWorktreeClean(wtPath, opts = {}) {
98
238
  const status = execFileFn('git', ['-C', wtPath, 'status', '--porcelain'], {
99
239
  encoding: 'utf8',
100
240
  });
101
- if (status.trim().length > 0) return false; // dirty (modified, untracked, or staged)
241
+ const significant = String(status ?? '')
242
+ .split('\n')
243
+ .map((l) => l.trimEnd())
244
+ .filter((l) => l.length > 0)
245
+ // Our own promotion marker is not operator work — see
246
+ // isUntrackedPromotionMarker() for why it must not count as dirty.
247
+ .filter((l) => !isUntrackedPromotionMarker(l));
248
+ if (significant.length > 0) return false; // dirty (modified, untracked, or staged)
102
249
 
103
250
  const branchStatus = execFileFn('git', ['-C', wtPath, 'status', '--short', '--branch'], {
104
251
  encoding: 'utf8',
@@ -6,7 +6,7 @@
6
6
  * - parseSessionId(id): { format: 'semantic'|'uuid', ...fields, raw } | null
7
7
  * - DEFAULT_SESSION_ID_SOURCES — the default `sources` array (see below)
8
8
  * - SEMANTIC_ID_RE — source-of-truth regex for semantic session IDs
9
- * - UUID_V4_RE — regex for UUID-v4 format session IDs
9
+ * - UUID_RE — regex for RFC 9562 UUID session IDs (any version 1–8)
10
10
  *
11
11
  * Closes #572 — Epic #568 Phase 2.1 (Parallel-Aware Sessions Semantic ID)
12
12
  * Closes #585 — Epic #583 W2-I2 (history-aware n-increment) per audit
@@ -67,14 +67,25 @@ import { parseStateMd } from './state-md/yaml-parser.mjs';
67
67
  export const SEMANTIC_ID_RE = /^([a-z0-9._/-]+)-(\d{4}-\d{2}-\d{2})-([a-z-]+)-(\d+)$/;
68
68
 
69
69
  /**
70
- * Regex for UUID-v4 session IDs.
70
+ * Regex for RFC 9562 UUID session IDs — ANY version 1–8, variant `10xx`.
71
71
  *
72
- * Matches: 8-4-4-4-12 hex digits, version nibble = '4', variant nibble in {8,9,a,b}.
73
- * Case-insensitive to accept both uppercase and lowercase hex.
72
+ * Matches: 8-4-4-4-12 hex digits, version nibble in [1-8], variant nibble in
73
+ * {8,9,a,b}. Case-insensitive to accept both uppercase and lowercase hex.
74
+ *
75
+ * Why the version nibble is a RANGE and not the literal `4` (Kanevry#66 / #1091):
76
+ * Claude Code mints UUIDv4 session ids, but Codex CLI mints UUIDv7. Pinning `4`
77
+ * made `parseSessionId()` return `null` for every Codex session, so
78
+ * `hooks/on-session-start.mjs` fell through to a freshly generated
79
+ * `randomUUID()` and every later hook in that session missed the lock.
80
+ *
81
+ * The structure stays strict on purpose: the dash/length layout and the
82
+ * variant nibble are what discriminate a real UUID from a 36-char lookalike,
83
+ * so only the version nibble is widened. Version `0` (nil UUID) and `9`..`f`
84
+ * (unassigned / max UUID) remain rejected — RFC 9562 defines 1–8.
74
85
  *
75
86
  * @type {RegExp}
76
87
  */
77
- export const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
88
+ export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
78
89
 
79
90
  // ---------------------------------------------------------------------------
80
91
  // Internal helpers
@@ -344,8 +355,12 @@ export const DEFAULT_SESSION_ID_SOURCES = Object.freeze([
344
355
  * 1. Semantic: `<branch>-<YYYY-MM-DD>-<mode>-<n>`
345
356
  * Returns `{ format: 'semantic', branch, date, mode, n, raw }`.
346
357
  *
347
- * 2. UUID-v4: `xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx`
348
- * Returns `{ format: 'uuid', uuid, raw }`.
358
+ * 2. RFC 9562 UUID, any version 1–8, variant `10xx`:
359
+ * `xxxxxxxx-xxxx-[1-8]xxx-[89ab]xxx-xxxxxxxxxxxx`
360
+ * Returns `{ format: 'uuid', uuid, version, raw }`, where `version` is the
361
+ * version nibble as an integer (4 for Claude Code's v4 ids, 7 for Codex
362
+ * CLI's v7 ids). Callers that only branch on `format` are unaffected —
363
+ * `version` is additive (Kanevry#66 / #1091).
349
364
  *
350
365
  * Returns `null` for any input that is not a non-empty string or does not
351
366
  * match either known format. Never throws.
@@ -356,7 +371,7 @@ export const DEFAULT_SESSION_ID_SOURCES = Object.freeze([
356
371
  *
357
372
  * @param {unknown} id - The session ID to parse.
358
373
  * @returns {{ format: 'semantic', branch: string, date: string, mode: string, n: number, raw: string }
359
- * | { format: 'uuid', uuid: string, raw: string }
374
+ * | { format: 'uuid', uuid: string, version: number, raw: string }
360
375
  * | null}
361
376
  */
362
377
  export function parseSessionId(id) {
@@ -375,9 +390,10 @@ export function parseSessionId(id) {
375
390
  };
376
391
  }
377
392
 
378
- // Try UUID-v4.
379
- if (UUID_V4_RE.test(id)) {
380
- return { format: 'uuid', uuid: id, raw: id };
393
+ // Try RFC 9562 UUID (any version 1–8). Index 14 is the version nibble, and
394
+ // UUID_RE has already constrained it to [1-8], so Number() cannot be NaN.
395
+ if (UUID_RE.test(id)) {
396
+ return { format: 'uuid', uuid: id, version: Number(id[14]), raw: id };
381
397
  }
382
398
 
383
399
  return null;
@@ -418,9 +434,9 @@ export function parseSessionId(id) {
418
434
  * cannot assign the same n. The #952 collision was NOT a concurrency defect —
419
435
  * the lock held; the candidate set was incomplete.
420
436
  *
421
- * UUID-v4 entries (in any source) are silently dropped (parseSessionId returns
422
- * format:'uuid' which the filter excludes). Malformed semantic-looking IDs are
423
- * also dropped (SEMANTIC_ID_RE rejects them).
437
+ * UUID entries of ANY version (in any source) are silently dropped
438
+ * (parseSessionId returns format:'uuid' which the filter excludes). Malformed
439
+ * semantic-looking IDs are also dropped (SEMANTIC_ID_RE rejects them).
424
440
  *
425
441
  * @param {object} opts
426
442
  * @param {string} opts.branch - Current git branch (e.g. "main", "feature/foo").