session-orchestrator 3.22.0 → 3.23.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 (268) 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/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -0,0 +1,277 @@
1
+ /**
2
+ * session-transition.mjs — the PROCESS-BOUNDARY teardown of a source repo root.
3
+ *
4
+ * Issue #1069 ("EnterWorktree/CwdChanged als eindeutigen Ein-Root-Übergang
5
+ * modellieren"), operator decision 2026-08-28: **Prozessgrenze**, not live
6
+ * migration. The source session ENDS regularly in the old root; a NEW session
7
+ * with its own identity starts in the destination worktree. Nothing is
8
+ * transferred — no lock hand-off, no registry rewrite, no proof migration.
9
+ *
10
+ * The gap this closes: session-start Phase 0.5 Worktree-Promotion calls
11
+ * `enterWorktree()` and then "exits Phase 0 immediately". It released nothing
12
+ * on the OLD root (measured 2026-08-28: zero `deregisterSelf` / `release(`
13
+ * call sites across `skills/session-start/SKILL.md` and
14
+ * `skills/_shared/parallel-aware-auq.md`). The abandoned registry entry keeps
15
+ * reading FRESH to `detectPeers()` for `freshnessMin=15` minutes and is only
16
+ * removed by `sweepZombies()` after `thresholdMin=60` — so the promoted-away
17
+ * root advertises a PHANTOM peer for up to an hour, and the abandoned
18
+ * `session.lock` blocks/skews every exclusivity decision in that root until
19
+ * its TTL lapses.
20
+ *
21
+ * Ownership rules are NOT re-derived here. The lock half is the same shape
22
+ * `hooks/on-session-end.mjs` uses: `readLockDetailed()` to distinguish an
23
+ * absent lock from an unparseable one, an exact raw `lock.session_id ===
24
+ * sessionId` compare BEFORE any proof is loaded, then `loadOwnerProof()`
25
+ * handed to `release()` as the second identity factor (#987/#989).
26
+ *
27
+ * ## Deliberate deltas vs `hooks/on-session-end.mjs` (its release block)
28
+ *
29
+ * The two teardowns share the ownership gate and now the
30
+ * `orchestrator.session.lock.released` breadcrumb, and diverge in four places
31
+ * ON PURPOSE. Each divergence is a narrower authority, never an oversight:
32
+ *
33
+ * 1. **No reconciliation on a raw-ID mismatch.** SessionEnd calls
34
+ * `attemptLockReconciliation()`, which may reap a provably-dead foreign
35
+ * lease. A worktree promotion is not a lifecycle end for the foreign
36
+ * session and carries no mandate over its lease: this function returns
37
+ * `lock-session-mismatch:<owner>` and touches nothing.
38
+ * 2. **No owner-proof unlink after a successful release.** SessionEnd removes
39
+ * the genesis proof as hygiene because the session is ending. Here the
40
+ * session continues in the DESTINATION root; the source proof is
41
+ * self-invalidating (its millisecond `started_at` can never match a future
42
+ * lock) and deleting it would be a write outside the teardown's remit.
43
+ * 3. **No `orchestrator.session.lock.read_anomaly` breadcrumb.** An
44
+ * unparseable lock aborts this teardown outright and is reported in the
45
+ * RETURN value (`ok: false`, `reason: 'lock-unreadable'|'lock-corrupt'`),
46
+ * which the caller must surface as a WARN. SessionEnd has no return channel
47
+ * to its caller, so there the event is the only place the anomaly can land.
48
+ * 4. **No `orchestrator.session.lock.release_failed` breadcrumb.** Same
49
+ * reason as (3): a failed release surfaces as `ok: false` plus
50
+ * `reason: 'lock-<release-reason>'` to a caller that is required to WARN on
51
+ * it, so the stream is not the only witness.
52
+ *
53
+ * @module scripts/lib/session-transition
54
+ */
55
+
56
+ import path from 'node:path';
57
+
58
+ import { deregisterSelf, readRegistry, repoPathHash } from './session-registry.mjs';
59
+ import { readLockDetailed, loadOwnerProof, release } from './session-lock.mjs';
60
+ import { emitEvent } from './events.mjs';
61
+
62
+ /** Event name emitted once per completed source-root departure. */
63
+ export const ROOT_LEFT_EVENT = 'orchestrator.session.root_left';
64
+
65
+ /** Shared lock-release breadcrumb — same event the other two `release()` call sites emit. */
66
+ export const LOCK_RELEASED_EVENT = 'orchestrator.session.lock.released';
67
+
68
+ /**
69
+ * Release this session's claim on `repoRoot` because the session is leaving it
70
+ * for good. Ordering is load-bearing and fixed:
71
+ *
72
+ * 1. `readLockDetailed()` — OWNERSHIP IS ESTABLISHED BEFORE ANYTHING IS
73
+ * REMOVED. A lock this session does not own, or one that cannot be parsed,
74
+ * aborts the teardown with `ok: false` and zero side effects. (This used to
75
+ * run second, after the registry entry had already been deleted: a caller
76
+ * passing the wrong `sessionId` destroyed a live registry entry and was
77
+ * told `ok: true` — the registry half of the very phantom-peer state the
78
+ * function exists to prevent.)
79
+ * 2. `deregisterSelf(sessionId)` — the host-wide registry entry. The registry
80
+ * is one file per harness session id, so this removes the session's ONLY
81
+ * entry; the destination worktree's own SessionStart registers itself
82
+ * afresh under its own identity (that is what "process boundary" means).
83
+ * Gated a second time on the entry's `repo_path_hash`: an entry that
84
+ * describes a DIFFERENT root is not this root's claim, and deleting it
85
+ * would silently unregister a session living somewhere else.
86
+ * 3. `release()` of the old root's `session.lock`, gated exactly as
87
+ * `hooks/on-session-end.mjs` gates it, followed by the shared
88
+ * `orchestrator.session.lock.released` breadcrumb (`caller:
89
+ * 'session-transition'`) so this third `release()` call site is visible on
90
+ * the same stream as the other two.
91
+ * 4. `emitEvent(ROOT_LEFT_EVENT)` into the OLD root's events.jsonl — the
92
+ * breadcrumb that says which root was left and why. Emitted even when a
93
+ * step above found nothing: "nothing to release" is itself a fact worth
94
+ * recording, and a departure that left no trace is exactly the
95
+ * indistinguishable-from-a-crash state #1069 was filed about. NOT emitted
96
+ * on an aborted teardown (foreign lock, unparseable lock, invalid args):
97
+ * the event asserts that a root was LEFT, and on those branches nothing
98
+ * was — writing it would put a fiction in a stream that, unlike the
99
+ * registry, may belong to the session that actually owns this root.
100
+ *
101
+ * The caller must invoke this BEFORE the destination worktree's own Phase 1.2
102
+ * `acquire()` runs — never both roots owning a live lock/registry entry at
103
+ * once, which is the double-live-UUID state the issue's acceptance criteria
104
+ * forbid.
105
+ *
106
+ * NEVER THROWS. Every failure is reported in the returned structure, because
107
+ * the sole caller is a promotion path that has already created the destination
108
+ * worktree: a thrown error there would abort the transition halfway, leaving
109
+ * precisely the two-live-roots state this function exists to prevent.
110
+ *
111
+ * @param {object} args
112
+ * @param {string} args.repoRoot — absolute path of the root being LEFT.
113
+ * @param {string} args.sessionId — the raw (physical) session id that owns the
114
+ * registry entry and the lock in `repoRoot`. NOT the semantic id.
115
+ * @param {string|null} [args.semanticSessionId] — semantic id, when known.
116
+ * Omitted from the event payload when null/absent rather than emitted as
117
+ * `null` (same honest-encoding rule as `sessionAttribution()` in events.mjs).
118
+ * @param {string} [args.reason='unspecified'] — why the root is being left,
119
+ * e.g. `'worktree-promotion'`. Recorded verbatim in the event payload.
120
+ * Defaults to `'unspecified'` rather than guessing `'worktree-promotion'`:
121
+ * a mislabelled reason is worse than an admittedly unknown one.
122
+ * @param {object} [deps] — test seams (same shape as `foreign-dispatch.mjs`).
123
+ * @param {Function} [deps.emitFn] — event-emitter seam; defaults to `emitEvent`.
124
+ * @returns {Promise<{
125
+ * ok: boolean,
126
+ * steps: { deregistered: boolean, released: boolean, emitted: boolean },
127
+ * reason?: string
128
+ * }>}
129
+ * `steps` are literal outcomes: `deregistered` = a registry file was removed,
130
+ * `released` = the lock file was deleted, `emitted` = the `root_left` event
131
+ * was appended.
132
+ *
133
+ * `ok: false` means the departure is NOT provably complete — invalid input, a
134
+ * lock owned by someone else, an unparseable lock, a registry entry pinned to
135
+ * a different root, a registry unlink that failed, a contradictory owner
136
+ * proof, or a filesystem error. Callers WARN on it and continue; see the
137
+ * `parallel-aware: leaveSourceRoot: <reason>` contract in
138
+ * `skills/_shared/parallel-aware-auq.md`.
139
+ *
140
+ * `reason` is present whenever something other than a full clean teardown
141
+ * happened; `'already-gone'` is the idempotent-rerun case (nothing left to
142
+ * remove), which is `ok: true`.
143
+ */
144
+ export async function leaveSourceRoot({
145
+ repoRoot,
146
+ sessionId,
147
+ semanticSessionId = null,
148
+ reason = 'unspecified',
149
+ } = {}, deps = {}) {
150
+ const emit = deps.emitFn ?? emitEvent;
151
+ const steps = { deregistered: false, released: false, emitted: false };
152
+
153
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0
154
+ || typeof sessionId !== 'string' || sessionId.length === 0) {
155
+ // Nothing is attempted on unusable input — in particular no event, since
156
+ // an event without a resolvable root has nowhere honest to be written.
157
+ return { ok: false, steps, reason: 'invalid-args' };
158
+ }
159
+
160
+ // -- Step 1: ownership, BEFORE any removal --------------------------------
161
+ // Mirrors hooks/on-session-end.mjs's release block: discriminate the read
162
+ // first, compare the RAW session_id before touching the proof, and only then
163
+ // hand the proof to release() as a second factor. An 'absent' lock is the
164
+ // idempotent-rerun / never-acquired case and does NOT block the teardown.
165
+ const lockDetail = readLockDetailed({ repoRoot });
166
+
167
+ if (lockDetail.status === 'unreadable' || lockDetail.status === 'corrupt') {
168
+ // Fail-closed and VISIBLE (issue #1069 AC: "fehlende oder widersprüchliche
169
+ // Proofs degradieren sichtbar und fail-closed"). A lock we cannot parse is
170
+ // neither released nor proven foreign — ownership stays indeterminate, so
171
+ // nothing is removed and nothing is claimed.
172
+ return { ok: false, steps, reason: `lock-${lockDetail.status}` };
173
+ }
174
+
175
+ if (lockDetail.status === 'ok' && lockDetail.lock.session_id !== sessionId) {
176
+ // A different session owns this root's lock, so this root is not ours to
177
+ // tear down — not the lock, and not the registry entry either. Aborting
178
+ // with `ok: false` (rather than the pre-#1069-review `ok: true`) is the
179
+ // point: a caller that passed the wrong id has NOT departed anything, and
180
+ // reporting success let that mistake through silently.
181
+ return { ok: false, steps, reason: `lock-session-mismatch:${lockDetail.lock.session_id}` };
182
+ }
183
+
184
+ let ok = true;
185
+ let outcome = null;
186
+
187
+ // -- Step 2: registry (host-wide) -----------------------------------------
188
+ // Second ownership factor, on the registry's own terms: the entry records the
189
+ // root it belongs to as a hash. When it names a DIFFERENT root, the entry is
190
+ // some other checkout's live claim and unlinking it would unregister a
191
+ // session that is still running there. Legacy v1 entries carry no hash — the
192
+ // gate is skipped rather than fail-closed, because rejecting them would make
193
+ // this teardown a no-op against exactly the old entries most likely to be
194
+ // stale (same back-compat rule as `_validEntry`'s optional `mode`).
195
+ let registryGateOk = true;
196
+ try {
197
+ const entry = (await readRegistry()).find((e) => e.session_id === sessionId);
198
+ if (entry && typeof entry.repo_path_hash === 'string' && entry.repo_path_hash.length > 0
199
+ && entry.repo_path_hash !== repoPathHash(repoRoot)) {
200
+ registryGateOk = false;
201
+ ok = false;
202
+ outcome ??= 'registry-root-mismatch';
203
+ }
204
+ } catch { /* an unreadable registry is handled by deregisterSelf below */ }
205
+
206
+ if (registryGateOk) {
207
+ try {
208
+ steps.deregistered = await deregisterSelf(sessionId);
209
+ } catch (err) {
210
+ ok = false;
211
+ outcome ??= `deregister-failed:${err?.message ?? 'unknown'}`;
212
+ }
213
+ }
214
+
215
+ // -- Step 3: lock (this root only) ----------------------------------------
216
+ if (lockDetail.status === 'ok') {
217
+ const proof = loadOwnerProof({ repoRoot });
218
+ // `proof` is null whenever ownership cannot be proven; release() treats
219
+ // null as absent (#989) and degrades to the session_id-only path, so it
220
+ // is passed through unguarded — the spread-guard call sites needed
221
+ // before that fix are exactly what the API contract removed.
222
+ const result = release({ sessionId, repoRoot, proof });
223
+ steps.released = result.ok === true && result.deleted === true;
224
+ // 'no-lock' = the lock vanished between the read above and the release —
225
+ // benign (idempotent rerun / concurrent SessionEnd), and forensically the
226
+ // interesting half: someone else removed a lock we still held.
227
+ const alreadyGone = result.ok === true && result.reason === 'no-lock';
228
+ if (!steps.released) {
229
+ if (!alreadyGone) ok = false;
230
+ outcome ??= `lock-${result.reason ?? 'not-deleted'}`;
231
+ }
232
+ if (steps.released || alreadyGone) {
233
+ // ARCH-MED-1 — this is the THIRD `release()` call site in the repo and
234
+ // was the only one silent on `orchestrator.session.lock.*`. Emitted at
235
+ // the call site, never inside release(): session-lock.mjs carries no
236
+ // dependency on events.mjs, and release() is synchronous.
237
+ try {
238
+ await emit(LOCK_RELEASED_EVENT, {
239
+ session_id: sessionId,
240
+ ...(semanticSessionId ? { semantic_session_id: semanticSessionId } : {}),
241
+ caller: 'session-transition',
242
+ outcome: steps.released ? 'deleted' : 'already-gone',
243
+ verified: result.verified === true,
244
+ }, { repoRoot });
245
+ } catch { /* observability is best-effort — the return value still tells */ }
246
+ }
247
+ }
248
+
249
+ if (outcome === null && !steps.deregistered && !steps.released) {
250
+ outcome = 'already-gone';
251
+ }
252
+
253
+ // -- Step 4: breadcrumb ----------------------------------------------------
254
+ try {
255
+ await emit(ROOT_LEFT_EVENT, {
256
+ session_id: sessionId,
257
+ ...(semanticSessionId ? { semantic_session_id: semanticSessionId } : {}),
258
+ // NEVER the absolute path. emitEvent forwards the whole payload to the
259
+ // optional Clank webhook with no redaction, and an absolute repo root on
260
+ // this host is `/Users/<operator>/…`. The hash is the SAME function the
261
+ // session registry keys its entries by (`repo_path_hash`), so a consumer
262
+ // can still join a departure to the registry entry it removed; the
263
+ // basename matches the registry's `repo_name`. Same rule as the
264
+ // `board_written` / `mirror_completed` payloads and
265
+ // `relativeWorktreePath` in worktree-pipeline.mjs.
266
+ from_root_hash: repoPathHash(repoRoot),
267
+ from_root_basename: path.basename(path.resolve(repoRoot)),
268
+ reason,
269
+ }, { repoRoot });
270
+ steps.emitted = true;
271
+ } catch (err) {
272
+ ok = false;
273
+ outcome ??= `emit-failed:${err?.message ?? 'unknown'}`;
274
+ }
275
+
276
+ return outcome === null ? { ok, steps } : { ok, steps, reason: outcome };
277
+ }
@@ -20,11 +20,15 @@
20
20
  * by the time this probe fires. Gating on "no live lock" would make the
21
21
  * banner structurally silent forever. Instead:
22
22
  *
23
- * - `lastLedgerAt` = `completed_at` of the last PARSEABLE, GENUINE
24
- * (non-backfill-stub) `sessions.jsonl` record
25
- * (scanned from EOF backward, skipping malformed
26
- * lines) see "Backfill-stub self-erasure fix"
27
- * below for the stub-skip logic and its fallback.
23
+ * - `lastLedgerAt` = the NEWEST instant any PARSEABLE `sessions.jsonl`
24
+ * record proves the ledger was alive at — a genuine
25
+ * record contributes its `completed_at` (or, absent
26
+ * one, its `started_at`), a backfill stub
27
+ * contributes ONLY its `started_at`. See
28
+ * "Backfill-stub self-erasure fix" below for why the
29
+ * stub's `completed_at` is excluded, and "#1125 —
30
+ * newest-across-all, not genuine-first" for why the
31
+ * two kinds are maxed rather than ranked.
28
32
  * - `cutoff` = the CURRENT session's `session.lock`
29
33
  * `started_at` (via `readLock()`); when no lock is
30
34
  * readable, `cutoff = now` (all events count).
@@ -58,24 +62,50 @@
58
62
  * inflate `deltaHours` for perfectly healthy, promptly-closed sessions
59
63
  * (a session's own mid-session events would newly count as "after" the
60
64
  * anchor), reintroducing false positives on the opposite side. (a) is
61
- * chosen: `lastLedgerEntry()` skips any record `isBackfillStub()` flags and
62
- * keeps searching backward for a GENUINE `completed_at`. Stub recognition
63
- * uses EITHER marker (OR, not AND) deliberately both are set by the same
64
- * producer today, but requiring both would silently stop matching the day a
65
- * future backfill variant drops one of them while keeping the other; OR
65
+ * chosen: a stub's `completed_at` is NEVER an anchor candidate. Stub
66
+ * recognition uses EITHER marker (OR, not AND) deliberately — both are set by
67
+ * the same producer today, but requiring both would silently stop matching the
68
+ * day a future backfill variant drops one of them while keeping the other; OR
66
69
  * degrades gracefully (still catches it), AND does not.
67
70
  *
68
- * All-stub fallback (deliberately NOT null): when NO genuine record exists
69
- * anywhere in the file every record is a backfill stub — this is a
70
- * STRONGER signal of the close-through gap than an ordinary stale ledger,
71
- * not a weaker one: no session has EVER genuinely closed. The module's
72
- * usual fail-quiet convention (null on missing/empty/ambiguous input) does
73
- * not extend to "we have data but all of it is synthetic" that state IS
74
- * the failure this banner exists to catch, so `lastLedgerEntry()` instead
75
- * anchors on the newest stub's `started_at` (grounded in the real
76
- * `orchestrator.session.started` event in the common casesee
77
- * `synthesizeRecord()`unlike that same stub's fabricated `completed_at`)
78
- * and flags the result `stubFallback: true` for callers that want to say so.
71
+ * #1125 — newest-across-ALL records, not genuine-first: excluding the stub's
72
+ * `completed_at` (above) is correct; excluding the whole STUB from the anchor
73
+ * search was not. The original implementation scanned from EOF backward and
74
+ * returned the first GENUINE record it met, reaching the stub `started_at`
75
+ * fallback only when no genuine record existed ANYWHERE. That is a PRIORITY
76
+ * ORDER, and it lets an OLD genuine record outrank a NEWER stub: whenever the
77
+ * most recent ledger activity is a stub, the anchor fell back to a stale
78
+ * genuine `completed_at` and the reported gap ballooned by the difference.
79
+ * Measured in the vault repo 2026-08-22 (issue #1125): a 173h `alert` the
80
+ * probe's TOP severity against a ledger whose newest records were from the
81
+ * same day, contradicted by three independent counter-measurements (231 of 233
82
+ * records carrying `completed_at`, newest `2026-08-22T16:23Z`;
83
+ * `backfill-abandoned-sessions.mjs --dry-run` finding 0 to backfill; all 15
84
+ * sessions since 2026-08-15 closed). A permanent top-severity false alarm
85
+ * trains exactly the looking-away this probe exists to prevent, so it is a
86
+ * defect of the same class as a missed gap, not a cosmetic one.
87
+ *
88
+ * The anchor is therefore the MAXIMUM over every record's own contribution:
89
+ * - GENUINE record → its `completed_at`; or, when that field is absent or
90
+ * unparseable, its `started_at` as a floor.
91
+ * - BACKFILL STUB → its `started_at` ONLY (never the fabricated
92
+ * `completed_at` — that is the axis-(a) rule above).
93
+ * A stub written for an abandoned session still PROVES the ledger was alive at
94
+ * that instant: that session IS recorded (as abandoned), so activity around it
95
+ * is not evidence of an unrecorded close-through. Taking a MAX can only move
96
+ * the anchor FORWARD relative to the genuine-only anchor, so it never
97
+ * re-introduces axis (b)'s inflation of `deltaHours`.
98
+ *
99
+ * Stub-anchored reporting (deliberately NOT null): when the winning anchor is a
100
+ * stub's `started_at` — either because every record is a stub (no session has
101
+ * EVER genuinely closed, a STRONGER close-through signal than an ordinary stale
102
+ * ledger) or merely because the newest ledger activity happens to be a stub —
103
+ * the result carries `stubFallback: true` so the message can say the anchor is
104
+ * a stub `started_at` (grounded in the real `orchestrator.session.started`
105
+ * event in the common case — see `synthesizeRecord()`) rather than imply a
106
+ * measured close. The module's usual fail-quiet convention (null on
107
+ * missing/empty/ambiguous input) does not extend to "we have data but all of it
108
+ * is synthetic" — that state IS the failure this banner exists to catch.
79
109
  *
80
110
  * Severity: warn above `2 × DEFAULT_TTL_HOURS` (8h, imported from
81
111
  * `session-lock.mjs` rather than duplicated), alert above 24h.
@@ -158,57 +188,92 @@ function isBackfillStub(record) {
158
188
  }
159
189
 
160
190
  /**
161
- * Scan `sessions.jsonl` lines from EOF backward and return the anchor
162
- * instant to measure ledger staleness against. Malformed or non-conforming
163
- * lines (bad JSON, non-object) are skipped, not treated as fatal.
191
+ * Parse one record field into an anchor candidate. Returns `null` when the
192
+ * field is absent, not a string, or not a parseable timestamp.
164
193
  *
165
- * Two passes, in priority order:
166
- * 1. GENUINE the last (by position) record that is NOT `isBackfillStub()`
167
- * and carries a valid `completed_at`. This is the trustworthy case:
168
- * `completed_at` was written by the real session-end path.
169
- * 2. STUB-FALLBACK only reached when the loop above finds no genuine
170
- * record at all (every record is a stub, or the file has none). Anchors
171
- * on the newest-by-position stub's `started_at` instead of its
172
- * `completed_at` — see the module-header design note for why. Flags
173
- * `stubFallback: true` on the returned object; omitted (`undefined`) on
174
- * the genuine path so existing callers checking `ledger.ms`/`ledger.iso`
175
- * see no behavioural change.
194
+ * @param {unknown} value
195
+ * @returns {{iso: string, ms: number}|null}
196
+ */
197
+ function tsCandidate(value) {
198
+ if (typeof value !== 'string') return null;
199
+ const ms = Date.parse(value);
200
+ return Number.isFinite(ms) ? { iso: value, ms } : null;
201
+ }
202
+
203
+ /**
204
+ * Keep whichever of the two candidates is newer. A `null` candidate never
205
+ * displaces a real one. On an exact tie the LATER-seen candidate wins, which
206
+ * (with the forward scan in `lastLedgerEntry()`) preserves the previous
207
+ * "nearest-EOF record wins" tie-break.
208
+ *
209
+ * @param {{iso: string, ms: number}|null} current
210
+ * @param {{iso: string, ms: number}|null} candidate
211
+ * @returns {{iso: string, ms: number}|null}
212
+ */
213
+ function keepNewer(current, candidate) {
214
+ if (candidate === null) return current;
215
+ if (current === null) return candidate;
216
+ return candidate.ms >= current.ms ? candidate : current;
217
+ }
218
+
219
+ /**
220
+ * Scan all `sessions.jsonl` lines and return the anchor instant to measure
221
+ * ledger staleness against: the NEWEST instant any record proves the ledger
222
+ * was alive at. Malformed or non-conforming lines (bad JSON, non-object) are
223
+ * skipped, not treated as fatal.
224
+ *
225
+ * Which timestamp each record kind contributes, and why (full reasoning in the
226
+ * module header, "#1125 — newest-across-ALL records"):
227
+ * - GENUINE (`!isBackfillStub()`) → `completed_at`, written by the real
228
+ * session-end path; falling back to `started_at` when `completed_at` is
229
+ * absent or unparseable, so a truncated/in-flight record still contributes
230
+ * the coverage it does prove. Taking the newer of the two can only move the
231
+ * anchor forward, never backward, so it does not inflate `deltaHours`.
232
+ * - BACKFILL STUB → `started_at` ONLY. Its `completed_at` may be the backfill
233
+ * RUN's own wall-clock rather than a measurement of when the session ended
234
+ * (see `synthesizeRecord()`), and anchoring on that would let a backfill run
235
+ * retroactively erase a real multi-day gap.
236
+ *
237
+ * The two kinds are MAXED, not ranked: an older genuine record must not outrank
238
+ * a newer stub (that priority order was the #1125 false-alert defect). When the
239
+ * winning candidate is a stub's `started_at`, `stubFallback: true` is set on the
240
+ * result so the caller can say the anchor is a stub start, not a measured close;
241
+ * the key is omitted (`undefined`) on the genuine path.
176
242
  *
177
243
  * @param {string[]} lines
178
244
  * @returns {{iso: string, ms: number, stubFallback?: true}|null}
179
245
  */
180
246
  function lastLedgerEntry(lines) {
181
- let newestStub = null; // newest-by-position stub with a parseable started_at
247
+ let newestGenuine = null; // newest genuine completed_at (or started_at floor)
248
+ let newestStub = null; // newest stub started_at
182
249
 
183
- for (let i = lines.length - 1; i >= 0; i--) {
250
+ for (const line of lines) {
184
251
  let record;
185
252
  try {
186
- record = JSON.parse(lines[i]);
253
+ record = JSON.parse(line);
187
254
  } catch {
188
255
  continue;
189
256
  }
190
257
  if (!record || typeof record !== 'object') continue;
191
258
 
192
259
  if (isBackfillStub(record)) {
193
- // Never anchor on a stub's completed_at (it may be the backfill run's
194
- // own wall-clock) — remember it only as a fallback candidate, and only
195
- // the first (nearest-EOF, i.e. newest-by-position) one seen.
196
- if (newestStub === null && typeof record.started_at === 'string') {
197
- const startedMs = Date.parse(record.started_at);
198
- if (Number.isFinite(startedMs)) newestStub = { iso: record.started_at, ms: startedMs };
199
- }
260
+ newestStub = keepNewer(newestStub, tsCandidate(record.started_at));
200
261
  continue;
201
262
  }
202
263
 
203
- if (typeof record.completed_at !== 'string') continue;
204
- const ms = Date.parse(record.completed_at);
205
- if (!Number.isFinite(ms)) continue;
206
- return { iso: record.completed_at, ms };
264
+ let own = tsCandidate(record.completed_at);
265
+ const started = tsCandidate(record.started_at);
266
+ // `completed_at` is the normal anchor and wins ties; `started_at` only
267
+ // takes over when it is genuinely newer or `completed_at` is unusable.
268
+ if (started !== null && (own === null || started.ms > own.ms)) own = started;
269
+ newestGenuine = keepNewer(newestGenuine, own);
207
270
  }
208
271
 
209
- // No genuine record anywhere see module-header "All-stub fallback" note:
210
- // this is a stronger alarm signal than null, not a null-worthy absence.
211
- return newestStub ? { iso: newestStub.iso, ms: newestStub.ms, stubFallback: true } : null;
272
+ if (newestGenuine === null && newestStub === null) return null;
273
+
274
+ const stubWins = newestGenuine === null || (newestStub !== null && newestStub.ms > newestGenuine.ms);
275
+ if (stubWins) return { iso: newestStub.iso, ms: newestStub.ms, stubFallback: true };
276
+ return { iso: newestGenuine.iso, ms: newestGenuine.ms };
212
277
  }
213
278
 
214
279
  /**
@@ -282,7 +347,7 @@ function resolveCutoffMs(repoRoot, nowMs) {
282
347
  * event exists, the foreign event is not after the last ledger entry, or the
283
348
  * resulting gap is under the warn threshold. Never throws.
284
349
  *
285
- * When the anchor comes from the all-stub fallback (`ledger.stubFallback`),
350
+ * When the newest anchor is a backfill stub (`ledger.stubFallback`),
286
351
  * `lastLedgerAt` is a STUB's `started_at`, not a genuine `completed_at` — the
287
352
  * message says so explicitly rather than implying a real close was measured.
288
353
  *
@@ -327,11 +392,13 @@ export function checkSessionsStaleness({ repoRoot, now = Date.now() } = {}) {
327
392
 
328
393
  const severity = deltaHours > ALERT_THRESHOLD_HOURS ? 'alert' : 'warn';
329
394
 
330
- // stubFallback (see lastLedgerEntry()): every sessions.jsonl record is a
331
- // backfill stub — ledger.iso is a STUB's started_at, not a measured
332
- // completed_at. Say so explicitly rather than implying a real close.
395
+ // stubFallback (see lastLedgerEntry()): the newest anchor in sessions.jsonl
396
+ // is a backfill stub — ledger.iso is that STUB's started_at, not a measured
397
+ // completed_at. Say so explicitly rather than implying a real close. (The
398
+ // wording deliberately does NOT claim "stub-only": since #1125 a stub can
399
+ // win the anchor while older genuine records exist.)
333
400
  const ledgerDescription = ledger.stubFallback
334
- ? `last sessions.jsonl entry is backfill-stub-onlynewest stub started_at ${ledger.iso}`
401
+ ? `newest sessions.jsonl entry is a backfill stub — its started_at ${ledger.iso}`
335
402
  : `last sessions.jsonl entry ${ledger.iso}`;
336
403
 
337
404
  const base =