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
@@ -23,6 +23,7 @@ import { randomUUID } from 'node:crypto';
23
23
  import path from 'node:path';
24
24
 
25
25
  import { filterRealSessions } from './session-schema.mjs';
26
+ import { emitEvent, sessionAttribution } from './events.mjs';
26
27
 
27
28
  // ---------------------------------------------------------------------------
28
29
  // Constants
@@ -245,6 +246,73 @@ export async function shouldDispatchAutoDialectic({
245
246
  };
246
247
  }
247
248
 
249
+ // ---------------------------------------------------------------------------
250
+ // Decision + mechanical telemetry (#1200 part c)
251
+ // ---------------------------------------------------------------------------
252
+
253
+ /**
254
+ * `shouldDispatchAutoDialectic()` plus a MECHANICAL `orchestrator.dialectic.nudge_decided`
255
+ * record, so the nudge decision is observable without depending on the
256
+ * session-end skill prose actually reaching the emit step (#1200: 0 records of
257
+ * this class across 164k fleet events despite the nudge firing every close).
258
+ *
259
+ * Contract-preserving wrapper: calls `shouldDispatchAutoDialectic()` unchanged
260
+ * and returns its decision object verbatim — the emit is a side effect bolted
261
+ * on, never a change to the decision logic or its return shape.
262
+ *
263
+ * Emits on ALL FOUR return paths (kill-switch, no-new-input,
264
+ * cadence-threshold-met, under-threshold) — best-effort, try/catch-wrapped,
265
+ * because `emitEvent()` throws `EventValidationError` on a malformed record
266
+ * and a telemetry failure must never change what the caller decides to do
267
+ * (same posture as `scripts/lib/reconcile/engine.mjs`'s `emitReconcileCompleted`
268
+ * wrapper).
269
+ *
270
+ * @param {object} args
271
+ * @param {string} args.repoRoot
272
+ * @param {number} [args.cadence=DEFAULT_CADENCE] `dialectic.cadence` from config.
273
+ * @param {object} [args.signals] Pre-computed signals (skips disk reads) — forwarded verbatim.
274
+ * @param {Function|null} [args.emitFn=emitEvent] DI hook for testing / disabling
275
+ * emission; defaults to `emitEvent` from `./events.mjs`. Any error it throws
276
+ * is swallowed — it never changes the returned decision.
277
+ * @param {boolean} [args.record=true] When `false`, no event is emitted at all —
278
+ * for read-only PROBE callers (e.g. the session-end Phase 3.6.x tail-skip
279
+ * aggregator, `scripts/lib/session-end/phase-skip.mjs`, whose documented
280
+ * contract is side-effect-free) where the decision is computed for internal
281
+ * branching only and must never itself be recorded as a nudge decision.
282
+ * @returns {Promise<{trigger:boolean, reason:string, signals:object}>}
283
+ */
284
+ export async function decideAndRecordAutoDialectic({
285
+ repoRoot,
286
+ cadence = DEFAULT_CADENCE,
287
+ signals,
288
+ emitFn = emitEvent,
289
+ record = true,
290
+ } = {}) {
291
+ const decision = await shouldDispatchAutoDialectic({ repoRoot, cadence, signals });
292
+
293
+ if (record && typeof emitFn === 'function') {
294
+ try {
295
+ await emitFn(
296
+ 'orchestrator.dialectic.nudge_decided',
297
+ {
298
+ ...sessionAttribution(repoRoot),
299
+ decided: decision.trigger,
300
+ reason: decision.reason,
301
+ cadence,
302
+ sessions_since: decision.signals?.sessionsSinceLast,
303
+ learnings_since: decision.signals?.learningsSinceLast,
304
+ },
305
+ { repoRoot },
306
+ );
307
+ } catch {
308
+ // best-effort — a telemetry failure must never block or change the
309
+ // decision the caller already has in hand.
310
+ }
311
+ }
312
+
313
+ return decision;
314
+ }
315
+
248
316
  // ---------------------------------------------------------------------------
249
317
  // last-run — atomic write
250
318
  // ---------------------------------------------------------------------------
@@ -36,6 +36,12 @@ import { acquire, release, buildLockOwnerProof } from '../session-lock.mjs';
36
36
  import { emitEvent } from '../events.mjs';
37
37
  import { main as gcMain } from '../../gc-stale-worktrees.mjs';
38
38
  import { SEMANTIC_ID_RE } from '../session-id.mjs';
39
+ import { repoPathHash } from '../session-registry.mjs';
40
+ import { leaveSourceRoot } from '../session-transition.mjs';
41
+ // Marker location is owned by its READER (session-end Phase 4a) — importing the
42
+ // constant from there keeps writer and reader on one string. The dependency runs
43
+ // heavy→lean (this pipeline → the dependency-free cleanup helper), never back.
44
+ import { PROMOTION_MARKER_RELPATH } from '../session-end/worktree-cleanup.mjs';
39
45
 
40
46
  // ---------------------------------------------------------------------------
41
47
  // Type definitions
@@ -547,6 +553,134 @@ export async function runStoryPipeline(context, opts = {}) {
547
553
  /** Valid git branch character set — mirrors isValidBranch from session-id.mjs. */
548
554
  const ENTER_WORKTREE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
549
555
 
556
+ /**
557
+ * Raised when the promotion branch `so/<sessionId>` is already checked out by
558
+ * another worktree. Refusing is the point: adopting a foreign target worktree
559
+ * would put two sessions on one branch (PSA-002 territory), so the caller gets
560
+ * the conflicting path by name instead of a silent takeover.
561
+ */
562
+ export class WorktreePromotionBranchError extends Error {
563
+ /**
564
+ * @param {string} message
565
+ * @param {{branch: string, checkedOutAt: string}} detail
566
+ */
567
+ constructor(message, { branch, checkedOutAt } = {}) {
568
+ super(message);
569
+ this.name = 'WorktreePromotionBranchError';
570
+ this.branch = branch;
571
+ this.checkedOutAt = checkedOutAt;
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Map of branch name → worktree path for every branch currently checked out by
577
+ * ANY worktree of `repoRoot` (#1067).
578
+ *
579
+ * Deliberately NOT `listWorktrees()` from `../worktree/listing.mjs`: that helper
580
+ * runs `git worktree list --porcelain` in `process.cwd()` (it has no repo
581
+ * parameter), and `enterWorktree` takes `repoRoot` explicitly precisely to avoid
582
+ * CWD drift (#219) — so reusing it would query whichever repo the process
583
+ * happens to sit in. Its `$` seam also has a different shape (`$({cwd})` returns
584
+ * the tag) than `enterWorktree`'s (`exec` IS the tag), so the two cannot share
585
+ * one injected executor. Only the `branch` lines are parsed here; `HEAD`,
586
+ * `detached`, `bare` and `locked` records carry nothing this decision needs.
587
+ *
588
+ * @param {Function} exec zx-like template-tag executor (the DI seam).
589
+ * @param {string} repoRoot Absolute path to the source git repository.
590
+ * @returns {Promise<Map<string, string>>} Branch names (without the
591
+ * `refs/heads/` prefix) → the worktree path that has them checked out.
592
+ * Empty on git failure — the caller then behaves exactly as it did before this
593
+ * check existed, so a broken listing degrades to git's own loud
594
+ * `already used by worktree` error rather than to a silent wrong branch.
595
+ */
596
+ async function listCheckedOutBranches(exec, repoRoot) {
597
+ let stdout;
598
+ try {
599
+ const result = await exec`git -C ${repoRoot} worktree list --porcelain`;
600
+ stdout = String(result?.stdout ?? '');
601
+ } catch {
602
+ return new Map();
603
+ }
604
+
605
+ const branches = new Map();
606
+ let currentPath = null;
607
+ for (const rawLine of stdout.split('\n')) {
608
+ const line = rawLine.trimEnd();
609
+ const wtMatch = /^worktree (.+)$/.exec(line);
610
+ if (wtMatch) {
611
+ currentPath = wtMatch[1];
612
+ continue;
613
+ }
614
+ const branchMatch = /^branch refs\/heads\/(.+)$/.exec(line);
615
+ if (branchMatch) branches.set(branchMatch[1], currentPath ?? '');
616
+ }
617
+ return branches;
618
+ }
619
+
620
+ /**
621
+ * Does `<ref>` resolve in `repoRoot`? Wraps `git rev-parse --verify --quiet`.
622
+ *
623
+ * @param {Function} exec
624
+ * @param {string} repoRoot
625
+ * @param {string} ref
626
+ * @returns {Promise<boolean>} false on any git failure (the ref is absent, or
627
+ * git could not be asked — both lead to the create-new path, whose failure
628
+ * mode is git's own loud error rather than a silent wrong checkout).
629
+ */
630
+ async function refExists(exec, repoRoot, ref) {
631
+ try {
632
+ await exec`git -C ${repoRoot} rev-parse --verify --quiet ${ref}`;
633
+ return true;
634
+ } catch {
635
+ return false;
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Record, inside the freshly created worktree, that it was auto-promoted —
641
+ * and from where. Best-effort: a failure to write the marker degrades Phase 4a
642
+ * detection to the legacy basename key, it never fails the promotion itself.
643
+ *
644
+ * No absolute source path is stored: `source_root_hash` is `repoPathHash()`, the
645
+ * same stable SHA-256 the session registry uses to correlate repos without
646
+ * exposing the operator's filesystem layout.
647
+ *
648
+ * @param {object} params
649
+ * @param {string} params.wtPath Freshly created worktree.
650
+ * @param {string} params.sourceRoot Resolved source checkout.
651
+ * @param {string} params.sessionId Session label the worktree was created for.
652
+ * @param {string} params.branch Branch the worktree actually landed on.
653
+ * @returns {boolean} true when the marker was written.
654
+ */
655
+ function writePromotionMarker({ wtPath, sourceRoot, sessionId, branch }) {
656
+ try {
657
+ const markerPath = path.join(wtPath, PROMOTION_MARKER_RELPATH);
658
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true });
659
+ fs.writeFileSync(
660
+ markerPath,
661
+ JSON.stringify(
662
+ {
663
+ source_root_hash: repoPathHash(sourceRoot),
664
+ source_root_basename: path.basename(sourceRoot),
665
+ source_session_id: sessionId,
666
+ branch,
667
+ promoted_at: new Date().toISOString(),
668
+ },
669
+ null,
670
+ 2,
671
+ ) + '\n',
672
+ 'utf8',
673
+ );
674
+ return true;
675
+ } catch (err) {
676
+ console.warn(
677
+ `enterWorktree: could not write promotion marker in ${wtPath} (${err.message}) — ` +
678
+ 'session-end Phase 4a falls back to basename detection',
679
+ );
680
+ return false;
681
+ }
682
+ }
683
+
550
684
  /**
551
685
  * Create a sibling git worktree for Worktree-Auto-Promotion (#574, Epic #568 P3.1).
552
686
  *
@@ -573,23 +707,75 @@ const ENTER_WORKTREE_BRANCH_RE = /^[a-zA-Z0-9._/-]+$/;
573
707
  * check as `setupWorktree` (CWE-23 / SEC-013 defence-in-depth). Throws
574
708
  * `WorktreeBoundaryError` if the computed path escapes `basePath`.
575
709
  *
576
- * Branch handling: if the branch already exists (verified via
577
- * `git rev-parse --verify <branch>`), use `git worktree add <wtPath> <branch>`
578
- * (reuse). Otherwise use `git worktree add -b <branch> <wtPath>` (create new).
579
- * This differs from `setupWorktree`, which always passes `-b`.
710
+ * Branch handling three cases, decided in this order (#1067):
711
+ * 1. `<branch>` is already checked out by ANY worktree of `repoRoot` (the
712
+ * normal session-start Phase 0.5 case, where `branch` is the CURRENT HEAD):
713
+ * git refuses a second checkout of the same branch
714
+ * (`fatal: '<branch>' is already used by worktree at '<repoRoot>'`). Treat
715
+ * `<branch>` as a START POINT only and create a fresh promotion branch
716
+ * `so/<sessionId>` at it: `git worktree add -b so/<sessionId> <wtPath> <branch>`.
717
+ * The source branch and the source worktree are left untouched.
718
+ * If `so/<sessionId>` ALREADY exists (a worktree directory was removed but
719
+ * its branch survived), it is REUSED — `git worktree add <wtPath>
720
+ * so/<sessionId>`, `reusedBranch: true` in the return — because `-b` would
721
+ * abort with `fatal: a branch named 'so/<sessionId>' already exists`.
722
+ * If it exists AND is checked out by some worktree, this throws
723
+ * `WorktreePromotionBranchError` naming that path: adopting a foreign
724
+ * target worktree is refused, never silently taken over.
725
+ * 2. `<branch>` exists but is not checked out (verified via
726
+ * `git rev-parse --verify <branch>`): `git worktree add <wtPath> <branch>`.
727
+ * 3. `<branch>` does not exist: `git worktree add -b <branch> <wtPath>`.
728
+ * Cases 2/3 differ from `setupWorktree`, which always passes `-b`.
580
729
  *
581
730
  * @param {object} params
582
731
  * @param {string} params.basePath - Parent directory where the new worktree goes (absolute).
583
732
  * @param {string} params.sessionId - Semantic session-ID matching SEMANTIC_ID_RE.
584
733
  * @param {string} params.branch - Branch name (existing or new) matching ENTER_WORKTREE_BRANCH_RE.
585
734
  * @param {string} params.repoRoot - Path to the source git repository (passed explicitly to avoid CWD drift per #219).
735
+ * @param {string} [params.rawSessionId] - The RAW (physical) session id that owns
736
+ * the SOURCE root's `session.lock` and registry entry. Optional. When it is a
737
+ * non-empty string, the source root is LEFT mechanically (#1170): after the
738
+ * destination worktree provably exists, `leaveSourceRoot()` runs and its
739
+ * result is reported as `left` in the return value. OMITTING it (strictly
740
+ * `undefined`) preserves the pre-#1170 behaviour exactly — no teardown, no
741
+ * `left` key, no WARN. Passing something UNUSABLE (`null`, `''`, a non-string —
742
+ * `readLock({ repoRoot }).session_id` returns `null` on a missing/unreadable
743
+ * lock, which is how this happens in practice) is NOT the same case: it WARNs
744
+ * and reports `left: { ok: false, reason: 'raw-session-id-unusable' }`, because
745
+ * a silent skip there is the phantom-owner state of #1069. NOT the semantic
746
+ * `sessionId` above, which is a naming key, never an ownership key.
747
+ * @param {string} [params.reason='worktree-promotion'] - Departure reason
748
+ * recorded verbatim in the `orchestrator.session.root_left` payload. Only used
749
+ * when `rawSessionId` is given.
586
750
  * @param {object} [opts]
587
751
  * @param {Function} [opts.$] - zx-like template-tag executor (DI seam); falls back to lazy `await import('zx')`.
588
- * @returns {Promise<{ wtPath: string, reused: boolean }>}
752
+ * Every freshly created worktree also gets a `.orchestrator/promoted-from.json`
753
+ * marker (see {@link PROMOTION_MARKER_RELPATH}) so session-end Phase 4a can
754
+ * still recognise it after the #1069 process boundary hands the worktree to a
755
+ * session with a different id. Marker writing is best-effort and never fails
756
+ * the promotion.
757
+ *
758
+ * @returns {Promise<{ wtPath: string, reused: boolean, branch?: string, promotedFrom?: string, reusedBranch?: true, left?: { ok: boolean, steps: object, reason?: string } }>}
759
+ * `branch` is the branch the new worktree actually landed on — equal to the
760
+ * `branch` param except in case 1 above, where it is `so/<sessionId>` and
761
+ * `promotedFrom` carries the requested source branch. `reusedBranch` is
762
+ * present (and `true`) only when an existing `so/<sessionId>` was checked out
763
+ * rather than created. All three are absent on the `reused: true` path (no
764
+ * branch was chosen — the worktree pre-existed). `left` is present on BOTH
765
+ * success exits — and ONLY — when `rawSessionId` was supplied (including when
766
+ * it was supplied unusable, where it reports the refusal).
589
767
  * @throws {TypeError} when any required param is missing or fails validation.
590
768
  * @throws {WorktreeBoundaryError} when the computed worktree path escapes `basePath`.
769
+ * @throws {WorktreePromotionBranchError} when `so/<sessionId>` is checked out elsewhere.
591
770
  */
592
- export async function enterWorktree({ basePath, sessionId, branch, repoRoot } = {}, opts = {}) {
771
+ export async function enterWorktree({
772
+ basePath,
773
+ sessionId,
774
+ branch,
775
+ repoRoot,
776
+ rawSessionId,
777
+ reason = 'worktree-promotion',
778
+ } = {}, opts = {}) {
593
779
  // -------------------------------------------------------------------------
594
780
  // Step 1: Input validation (TypeError on any malformed param).
595
781
  // -------------------------------------------------------------------------
@@ -678,36 +864,150 @@ export async function enterWorktree({ basePath, sessionId, branch, repoRoot } =
678
864
  );
679
865
  }
680
866
 
867
+ // -------------------------------------------------------------------------
868
+ // Step 3b: The source-root departure (#1170), as a MECHANICAL step.
869
+ //
870
+ // Before #1170 this was skill PROSE at four sites: `enterWorktree()` created
871
+ // the destination and the coordinator was trusted to call `leaveSourceRoot()`
872
+ // afterwards. A step that only exists in prose is a step that is sometimes
873
+ // skipped — and skipping it is exactly the phantom-peer / double-live-lock
874
+ // state #1069 was filed about.
875
+ //
876
+ // Ordering is load-bearing and only runs on a proven-existing destination:
877
+ // every throw above leaves the source root untouched, because a departure
878
+ // from a root whose successor does not exist strands the session with no
879
+ // live root at all. `leaveSourceRoot()` never throws, so a failed teardown
880
+ // WARNs and is reported in `left` — it never fails the promotion.
881
+ // -------------------------------------------------------------------------
882
+ const departSourceRoot = async (result) => {
883
+ // NOT PASSED and PASSED-BUT-UNUSABLE are different callers with different
884
+ // bugs, and collapsing them re-opened #1069 silently. `undefined` is the
885
+ // documented opt-out (the autopilot callers, which own no source session):
886
+ // byte-identical pre-#1170 behaviour, no `left`, no WARN.
887
+ if (rawSessionId === undefined) return result;
888
+ // Anything else falsy or non-string means a caller INTENDED to depart and
889
+ // handed us something unusable. The documented derivation is
890
+ // `readLock({ repoRoot }).session_id`, which is `null` whenever the lock is
891
+ // missing or unreadable — so the realistic failure lands here, and before
892
+ // this branch it returned early with nothing on stderr: promotion succeeds,
893
+ // source root never deregistered, phantom owner (#1069).
894
+ if (typeof rawSessionId !== 'string' || rawSessionId.length === 0) {
895
+ const shown = rawSessionId === null
896
+ ? 'null'
897
+ : typeof rawSessionId === 'string' ? 'empty string' : typeof rawSessionId;
898
+ console.warn(
899
+ `enterWorktree: rawSessionId unusable (${shown}) — source root NOT departed; ` +
900
+ 'read it via readLock({ repoRoot }).session_id',
901
+ );
902
+ result.left = { ok: false, reason: 'raw-session-id-unusable' };
903
+ return result;
904
+ }
905
+ const left = await leaveSourceRoot({
906
+ repoRoot,
907
+ sessionId: rawSessionId,
908
+ semanticSessionId: sessionId,
909
+ reason,
910
+ });
911
+ if (left.ok !== true) {
912
+ console.warn(`enterWorktree: leaveSourceRoot: ${left.reason ?? 'unknown'}`);
913
+ }
914
+ result.left = left;
915
+ return result;
916
+ };
917
+
681
918
  // -------------------------------------------------------------------------
682
919
  // Step 4: Idempotency — reuse if worktree already exists with .git.
683
920
  // -------------------------------------------------------------------------
684
921
  if (fs.existsSync(wtPath) && fs.existsSync(path.join(wtPath, '.git'))) {
685
- return { wtPath, reused: true };
922
+ return await departSourceRoot({ wtPath, reused: true });
686
923
  }
687
924
 
688
925
  // -------------------------------------------------------------------------
689
- // Step 5: Detect whether branch already exists, then `git worktree add`.
926
+ // Step 5: Is `branch` already checked out elsewhere? (#1067)
927
+ //
928
+ // Phase 0.5 passes the CURRENT HEAD as `branch`, so in the normal promotion
929
+ // case it is checked out by `repoRoot` itself and git would refuse a second
930
+ // checkout. Then `branch` is only a start point and the worktree lands on a
931
+ // fresh `so/<sessionId>`; the source branch/worktree stay untouched.
690
932
  // -------------------------------------------------------------------------
691
- let branchExists = false;
692
- try {
693
- await exec`git -C ${repoRoot} rev-parse --verify ${branch}`;
694
- branchExists = true;
695
- } catch {
696
- // Branch does not exist fall through to create-new path with `-b`.
933
+ const checkedOutBranches = await listCheckedOutBranches(exec, repoRoot);
934
+ const promoted = checkedOutBranches.has(branch);
935
+ const targetBranch = promoted ? `so/${sessionId}` : branch;
936
+
937
+ // -------------------------------------------------------------------------
938
+ // Step 6: Decide the `git worktree add` argv.
939
+ //
940
+ // On the promotion path the target is `so/<sessionId>`, which can be in any
941
+ // of THREE states — and only the first was handled before:
942
+ // (a) absent → create it at `<branch>`: `add -b so/<id> <wt> <branch>`
943
+ // (b) exists, free → REUSE it: `add <wt> so/<id>`. This is the survivor
944
+ // of a worktree whose directory was removed while its branch stayed
945
+ // behind (`git worktree remove` does not delete the branch); `-b` would
946
+ // abort Phase 0.5 with `fatal: a branch named 'so/<id>' already exists`.
947
+ // (c) exists, checked out elsewhere → REFUSE. Adopting a foreign target
948
+ // worktree is exactly what Phase 0.5 must not do.
949
+ // -------------------------------------------------------------------------
950
+ let reusedBranch = false;
951
+ if (promoted) {
952
+ const checkedOutAt = checkedOutBranches.get(targetBranch);
953
+ if (checkedOutAt !== undefined) {
954
+ throw new WorktreePromotionBranchError(
955
+ `enterWorktree: promotion branch '${targetBranch}' is already checked out at '${checkedOutAt}' — refusing to adopt a foreign worktree`,
956
+ { branch: targetBranch, checkedOutAt },
957
+ );
958
+ }
959
+ reusedBranch = await refExists(exec, repoRoot, `refs/heads/${targetBranch}`);
697
960
  }
698
961
 
699
- if (branchExists) {
962
+ // A branch that a worktree has checked out exists by construction, so the
963
+ // source-branch rev-parse probe is skipped on the promotion path.
964
+ let branchExists = promoted;
965
+ if (!promoted) {
966
+ try {
967
+ await exec`git -C ${repoRoot} rev-parse --verify ${branch}`;
968
+ branchExists = true;
969
+ } catch {
970
+ // Branch does not exist — fall through to create-new path with `-b`.
971
+ }
972
+ }
973
+
974
+ if (promoted && reusedBranch) {
975
+ await exec`git -C ${repoRoot} worktree add ${wtPath} ${targetBranch}`;
976
+ } else if (promoted) {
977
+ await exec`git -C ${repoRoot} worktree add -b ${targetBranch} ${wtPath} ${branch}`;
978
+ } else if (branchExists) {
700
979
  await exec`git -C ${repoRoot} worktree add ${wtPath} ${branch}`;
701
980
  } else {
702
981
  await exec`git -C ${repoRoot} worktree add -b ${branch} ${wtPath}`;
703
982
  }
704
983
 
705
984
  // -------------------------------------------------------------------------
706
- // Step 6: WARN to stderr (PRD §3 P3 Gherkin row-1 + #574 DoD).
985
+ // Step 6b: Record the promotion FACT inside the new worktree (#1069 boundary).
986
+ // Session-end Phase 4a runs in a session whose id is NOT this one, so nothing
987
+ // in the path or the branch identifies the worktree to it — the marker does.
988
+ // -------------------------------------------------------------------------
989
+ writePromotionMarker({
990
+ wtPath,
991
+ sourceRoot: resolvedRepoRoot,
992
+ sessionId,
993
+ branch: targetBranch,
994
+ });
995
+
996
+ // -------------------------------------------------------------------------
997
+ // Step 7: WARN to stderr (PRD §3 P3 Gherkin row-1 + #574 DoD).
707
998
  // -------------------------------------------------------------------------
708
999
  console.warn(
709
- `enterWorktree: created sibling worktree at ${wtPath} (branch=${branch}, sessionId=${sessionId})`,
1000
+ promoted
1001
+ ? `enterWorktree: created sibling worktree at ${wtPath} (branch=${targetBranch}, promoted from ${branch}, sessionId=${sessionId})`
1002
+ : `enterWorktree: created sibling worktree at ${wtPath} (branch=${branch}, sessionId=${sessionId})`,
710
1003
  );
711
1004
 
712
- return { wtPath, reused: false };
1005
+ if (promoted) {
1006
+ const result = { wtPath, reused: false, branch: targetBranch, promotedFrom: branch };
1007
+ // Present only when it happened, so the common shape stays byte-identical
1008
+ // for every existing consumer and strict-equality pin.
1009
+ if (reusedBranch) result.reusedBranch = true;
1010
+ return await departSourceRoot(result);
1011
+ }
1012
+ return await departSourceRoot({ wtPath, reused: false, branch });
713
1013
  }
@@ -10,7 +10,8 @@
10
10
  * - Synchronous file I/O for STATE.md / sessions.jsonl / bootstrap.lock.
11
11
  * The async wrapper exists only because `scanBacklog` is async.
12
12
  * - Logging is NOT this helper's job — silent graceful-null on every error.
13
- * - Paths are resolved absolutely from process.cwd() if relative.
13
+ * - Relative paths resolve against `opts.repoRoot` (which itself defaults to
14
+ * process.cwd()); an explicit ABSOLUTE path always wins over the root.
14
15
  */
15
16
 
16
17
  import { existsSync, readFileSync } from 'node:fs';
@@ -19,6 +20,7 @@ import { parseStateMd, parseRecommendations } from './state-md.mjs';
19
20
  import { normalizeSession, tailRealSessions } from './session-schema.mjs';
20
21
  import { parseBootstrapLock } from './bootstrap-lock-freshness.mjs';
21
22
  import { scanBacklog, DEFAULT_BACKLOG_LIMIT } from './backlog-scan.mjs';
23
+ import { readCanonicalSessions } from './sessions-canonical.mjs';
22
24
 
23
25
  // ---------------------------------------------------------------------------
24
26
  // Public API
@@ -32,9 +34,16 @@ import { scanBacklog, DEFAULT_BACKLOG_LIMIT } from './backlog-scan.mjs';
32
34
  * null/[] value to the Signals object.
33
35
  *
34
36
  * @param {object} [opts]
35
- * @param {string} [opts.statePath] defaults to '.claude/STATE.md'
36
- * @param {string} [opts.sessionsPath] — defaults to '.orchestrator/metrics/sessions.jsonl'
37
- * @param {string} [opts.lockPath] — defaults to '.orchestrator/bootstrap.lock'
37
+ * @param {string} [opts.repoRoot] absolute project root every relative
38
+ * path below resolves against, and the root forwarded to `scanBacklog`.
39
+ * Defaults to `process.cwd()`. Without it (#1071) a caller running from a
40
+ * worktree or a subdirectory silently read a DIFFERENT repo's STATE.md and
41
+ * sessions.jsonl than the one it was reporting on — measured as
42
+ * `recentSessions: []` against a checkout holding 245 session records.
43
+ * An explicit absolute `statePath`/`sessionsPath`/`lockPath` still wins.
44
+ * @param {string} [opts.statePath] — defaults to '<repoRoot>/.claude/STATE.md'
45
+ * @param {string} [opts.sessionsPath] — defaults to '<repoRoot>/.orchestrator/metrics/sessions.jsonl'
46
+ * @param {string} [opts.lockPath] — defaults to '<repoRoot>/.orchestrator/bootstrap.lock'
38
47
  * @param {Array} [opts.learnings] — pre-surfaced top-N learnings; defaults to []
39
48
  * @param {number} [opts.backlogLimit] — passed to scanBacklog; defaults to
40
49
  * `DEFAULT_BACKLOG_LIMIT` from backlog-scan.mjs (never a local copy of that
@@ -46,15 +55,24 @@ import { scanBacklog, DEFAULT_BACKLOG_LIMIT } from './backlog-scan.mjs';
46
55
  * @returns {Promise<import('./mode-selector.mjs').Signals>}
47
56
  */
48
57
  export async function buildLiveSignals(opts = {}) {
58
+ const repoRoot =
59
+ typeof opts.repoRoot === 'string' && opts.repoRoot.length > 0
60
+ ? opts.repoRoot
61
+ : process.cwd();
62
+ // `resolve(root, p)` returns `p` unchanged when `p` is absolute — explicit
63
+ // per-file overrides therefore keep precedence over repoRoot.
49
64
  const statePath = resolve(
65
+ repoRoot,
50
66
  typeof opts.statePath === 'string' ? opts.statePath : '.claude/STATE.md'
51
67
  );
52
68
  const sessionsPath = resolve(
69
+ repoRoot,
53
70
  typeof opts.sessionsPath === 'string'
54
71
  ? opts.sessionsPath
55
72
  : '.orchestrator/metrics/sessions.jsonl'
56
73
  );
57
74
  const lockPath = resolve(
75
+ repoRoot,
58
76
  typeof opts.lockPath === 'string' ? opts.lockPath : '.orchestrator/bootstrap.lock'
59
77
  );
60
78
  const learnings = Array.isArray(opts.learnings) ? opts.learnings : [];
@@ -96,29 +114,31 @@ export async function buildLiveSignals(opts = {}) {
96
114
  let recentSessions = [];
97
115
 
98
116
  try {
99
- if (existsSync(sessionsPath)) {
100
- const raw = readFileSync(sessionsPath, 'utf8');
101
- const lines = raw
102
- .split('\n')
103
- .map((l) => l.trim())
104
- .filter((l) => l.length > 0);
105
- // #834: parse ALL lines (not just the naive last-N) before windowing —
106
- // `status: 'abandoned'` phantom stubs must be filtered out BEFORE the
107
- // tail is taken, or sessionTailN silently means "last N LINES" instead
108
- // of "last N REAL sessions". tailRealSessions() does the filter + tail.
109
- const parsed = [];
110
- for (const line of lines) {
111
- try {
112
- const obj = JSON.parse(line);
113
- parsed.push(normalizeSession(obj));
114
- } catch {
115
- // Branch 4: skip malformed lines silently
116
- }
117
- }
118
- recentSessions = tailRealSessions(parsed, sessionTailN);
119
- }
117
+ // #1186: readCanonicalSessions applies the #1167 newest-wins-per-`session_id`
118
+ // / attestable-`supersedes` collapse BEFORE the tail is taken. The raw read
119
+ // this replaced parsed every line unconditionally (malformed lines skipped
120
+ // on JSON.parse failure only), so a duplicated `session_id` — a
121
+ // crash-recovery re-append, or the #1068 abandoned-stub/supersede pair —
122
+ // counted as TWO entries toward `sessionTailN`, silently narrowing the
123
+ // REAL window by however many duplicates sat in the file's tail.
124
+ //
125
+ // Behavioural change: a well-formed record with NO `session_id` (never
126
+ // legitimate per REQUIRED_FIELDS, but possible on a hand-edited or
127
+ // pre-schema legacy line) is now DROPPED rather than counted — it cannot
128
+ // be deduplicated by identity, so `canonicalizeSessions` excludes it (see
129
+ // sessions-canonical.mjs's own contract). Missing-file / unreadable-file
130
+ // handling (ENOENT vs EACCES/EISDIR, #1188) is delegated to the shared
131
+ // reader; both still degrade to `[]` here, matching Branch 3's contract.
132
+ const canonical = readCanonicalSessions({ filePath: sessionsPath });
133
+ const parsed = canonical.map((obj) => normalizeSession(obj));
134
+ // #834: `status: 'abandoned'` phantom stubs must still be filtered out
135
+ // BEFORE the tail is taken, or sessionTailN silently means "last N LINES"
136
+ // instead of "last N REAL sessions". tailRealSessions() does the filter +
137
+ // tail.
138
+ recentSessions = tailRealSessions(parsed, sessionTailN);
120
139
  } catch {
121
- // Branch 3: file unreadable — recentSessions stays []
140
+ // Branch 3: file unreadable — recentSessions stays [] (readCanonicalSessions
141
+ // itself never throws, but the graceful-null contract is kept as a backstop).
122
142
  }
123
143
 
124
144
  // --- Branch 5: bootstrap.lock ---
@@ -137,7 +157,9 @@ export async function buildLiveSignals(opts = {}) {
137
157
  let backlog = null;
138
158
 
139
159
  try {
140
- backlog = await _scan({ limit: backlogLimit });
160
+ // `repoRoot` is forwarded so VCS detection and the `-R` host-pinning spec
161
+ // inside scanBacklog answer about the SAME repo the signals describe.
162
+ backlog = await _scan({ limit: backlogLimit, repoRoot });
141
163
  } catch {
142
164
  // Branch 6: scanBacklog threw — backlog stays null
143
165
  }