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
@@ -13,6 +13,17 @@
13
13
  * from `hooks/on-session-end.mjs` and by the one-time historical migration CLI
14
14
  * `scripts/backfill-abandoned-sessions.mjs`.
15
15
  *
16
+ * A second, sibling export — `backfillCompletedFromStateMd` — closes a
17
+ * different gap (#429): `commands/close.md`'s Pre-Check treats STATE.md
18
+ * `status: completed` as proof the session-end writer already ran and refuses
19
+ * to invoke it again. When that status was set by hand (or by any path that
20
+ * never reached Phase 3.7), no sessions.jsonl record is EVER written — the
21
+ * Pre-Check keeps blocking the only thing that would normally create one.
22
+ * `backfillCompletedFromStateMd` reads STATE.md directly, and when its status
23
+ * is `completed` with no matching sessions.jsonl record, backfills one tagged
24
+ * `status: 'completed'` + `_backfill_source: 'state-md-completed'` (never
25
+ * `'abandoned'` — the session itself claims to have finished normally).
26
+ *
16
27
  * ── ID BRIDGE ────────────────────────────────────────────────────────────────
17
28
  * sessions.jsonl records are keyed by SEMANTIC ids (`main-2026-05-27-session-1`).
18
29
  * events.jsonl carries the harness UUID on `session.started` / `stop` / `ended`.
@@ -25,6 +36,11 @@
25
36
  * - No-throw: every path returns a structured `{ action, ... }` result; the
26
37
  * hook must never be pushed past its teardown timeout by an exception.
27
38
  * - Dedupe: never double-write a session already present in sessions.jsonl.
39
+ * - Supersede (#1068 AC3/AC4): the ONE exception to that dedupe — an
40
+ * authoritative `state-md-completed` record may be appended for an identity
41
+ * whose only entry is a backfilled `abandoned` STUB, carrying
42
+ * `supersedes: <stub id>`. Append-only: the stub is kept verbatim and
43
+ * readers take the NEWEST record for an id as the canonical one.
28
44
  * - Liveness guard: never backfill over a FOREIGN live session.lock (PSA).
29
45
  * - TOCTOU marker: an atomic `openSync(..., 'wx')` claim file keyed by the
30
46
  * final id serialises concurrent backfill attempts (mirrors the
@@ -40,16 +56,17 @@ import path from 'node:path';
40
56
  import crypto from 'node:crypto';
41
57
 
42
58
  import { appendJsonl as defaultAppendJsonl } from './common.mjs';
59
+ import { parseSessionId } from './session-id.mjs';
43
60
  import { readLock as defaultReadLock, isLockLive as defaultIsLockLive, DEFAULT_TTL_HOURS } from './session-lock.mjs';
44
61
  import { validateSession as defaultValidateSession } from './session-schema/validator.mjs';
45
62
  import { serializeSessionLineChecked as defaultSerialize } from './session-schema.mjs';
63
+ import { resolveStateMdPath as defaultResolveStateMdPath } from './state-md/frontmatter-mutators.mjs';
64
+ import { parseStateMd as defaultParseStateMd } from './state-md/yaml-parser.mjs';
46
65
 
47
66
  // ---------------------------------------------------------------------------
48
67
  // Constants
49
68
  // ---------------------------------------------------------------------------
50
69
 
51
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
52
-
53
70
  /** session_type enum accepted by the schema — lock.mode is coerced against it. */
54
71
  const VALID_SESSION_TYPES = new Set(['feature', 'deep', 'housekeeping']);
55
72
 
@@ -66,9 +83,22 @@ const BACKFILL_LOG_REL = ['.orchestrator', 'metrics', 'session-close-backfill.lo
66
83
  // Helpers
67
84
  // ---------------------------------------------------------------------------
68
85
 
69
- /** True when `s` is a canonical UUID (v4-shaped). */
86
+ /**
87
+ * True when `s` is an RFC 9562 UUID session id (any version 1-8, variant `10xx`).
88
+ *
89
+ * Delegates to `parseSessionId()` so the repo has exactly ONE UUID contract
90
+ * (`UUID_RE` in `scripts/lib/session-id.mjs`). This module previously carried a
91
+ * private, LOOSER copy that constrained neither the version nor the variant
92
+ * nibble, so a 36-char lookalike such as
93
+ * `xxxxxxxx-xxxx-0xxx-cxxx-xxxxxxxxxxxx` was classified as a harness UUID here
94
+ * while `hooks/on-session-start.mjs` — which gates on `parseSessionId()` —
95
+ * rejected it. The disagreement is exactly the ID-bridge this module depends
96
+ * on: a value the writer refused to use as a raw id was still treated here as
97
+ * one, sending the record down the UUID branch (lock-bridge + synthetic-id
98
+ * mint) instead of the semantic branch.
99
+ */
70
100
  export function isUuid(s) {
71
- return typeof s === 'string' && UUID_RE.test(s);
101
+ return parseSessionId(s)?.format === 'uuid';
72
102
  }
73
103
 
74
104
  /** Filesystem-safe marker filename for an arbitrary session id. */
@@ -123,18 +153,33 @@ function collectSessionEvents(events, { sessionId, semanticSessionId }) {
123
153
 
124
154
  let mode = null;
125
155
  let semanticFromLock = null;
126
-
127
- // First pass lock.acquired bridges the UUID set + carries mode + semantic.
156
+ // #1167 — the SECOND semantic bridge. `orchestrator.session.ended` carries
157
+ // `semantic_session_id` alongside the raw UUID since #1068 AC1, but nothing
158
+ // read it: a session that LOST the lock-acquire race emits no lock.acquired,
159
+ // so the lock bridge above resolved null and the caller fell through to the
160
+ // synthetic-id mint — writing a SECOND `abandoned` stub for a session the
161
+ // SessionEnd hook had already recorded under its semantic id. Measured
162
+ // 2026-09-02 @ c3ab480: 8 such duplicate pairs in sessions.jsonl.
163
+ let semanticFromEvents = null;
164
+
165
+ // First pass — bridge the UUID set + carry mode + semantic id. lock.acquired
166
+ // is the original bridge; session.ended is the #1167 addition.
128
167
  for (const ev of events) {
129
- if (ev.event !== EVENT_LOCK_ACQUIRED) continue;
168
+ const isLock = ev.event === EVENT_LOCK_ACQUIRED;
169
+ const isEnded = ev.event === EVENT_ENDED && typeof ev.semantic_session_id === 'string';
170
+ if (!isLock && !isEnded) continue;
130
171
  const matchesUuid = isUuid(sessionId) && ev.session_id === sessionId;
131
172
  const matchesSemantic =
132
173
  (semanticSessionId && ev.semantic_session_id === semanticSessionId) ||
133
174
  (!isUuid(sessionId) && sessionId && ev.semantic_session_id === sessionId);
134
175
  if (!matchesUuid && !matchesSemantic) continue;
135
176
  if (typeof ev.session_id === 'string') uuids.add(ev.session_id);
136
- if (typeof ev.mode === 'string') mode = ev.mode;
137
- if (typeof ev.semantic_session_id === 'string') semanticFromLock = ev.semantic_session_id;
177
+ if (isLock) {
178
+ if (typeof ev.mode === 'string') mode = ev.mode;
179
+ if (typeof ev.semantic_session_id === 'string') semanticFromLock = ev.semantic_session_id;
180
+ } else {
181
+ semanticFromEvents = ev.semantic_session_id;
182
+ }
138
183
  }
139
184
 
140
185
  // Second pass — started + terminal timestamps from every matched UUID.
@@ -170,7 +215,18 @@ function collectSessionEvents(events, { sessionId, semanticSessionId }) {
170
215
  }
171
216
  }
172
217
 
173
- return { uuids, mode, semanticFromLock, startedAt, branch, project, lastTerminalMs, earliestMs, lastEventMs };
218
+ return {
219
+ uuids,
220
+ mode,
221
+ semanticFromLock,
222
+ semanticFromEvents,
223
+ startedAt,
224
+ branch,
225
+ project,
226
+ lastTerminalMs,
227
+ earliestMs,
228
+ lastEventMs,
229
+ };
174
230
  }
175
231
 
176
232
  /**
@@ -213,12 +269,20 @@ function isCandidateDeadByAge({ relaxDeadByAge, assumeDeadBeforeMs, lastEventMs,
213
269
  }
214
270
 
215
271
  /**
216
- * Build the abandoned-session stub record. Required fields with no events
217
- * source are defaulted to empty/zero and enumerated in
218
- * `_backfill_incomplete_fields`; the mode → session_type coercion sets
219
- * `_session_type_inferred`.
272
+ * Build a backfilled stub record. Required fields with no events source are
273
+ * defaulted to empty/zero and enumerated in `_backfill_incomplete_fields`;
274
+ * the mode → session_type coercion sets `_session_type_inferred`.
275
+ *
276
+ * `status` / `backfillSource` are parameterized (#429 — the STATE.md
277
+ * `status: completed` backfill below reuses this exact synthesis, just with
278
+ * a different terminal status and provenance tag). Defaults reproduce the
279
+ * original `backfillAbandonedSession` behaviour exactly for existing callers.
280
+ *
281
+ * `supersedes` (#1068 AC3, default `null`) stamps the id of the backfill STUB
282
+ * this record replaces. It is emitted only when non-null, so every existing
283
+ * record shape is byte-identical to before.
220
284
  */
221
- function synthesizeRecord({ recordId, synthetic, gathered, nowMs }) {
285
+ function synthesizeRecord({ recordId, synthetic, gathered, nowMs, status = 'abandoned', backfillSource = 'events-jsonl', supersedes = null, rawSessionId = null }) {
222
286
  const startedIso = canonicalIso(gathered.startedAt, gathered.earliestMs ?? nowMs);
223
287
  const startedMs = Date.parse(startedIso);
224
288
  // completed_at is events-attested, never the backfill-run wall-clock (#914 R1).
@@ -271,40 +335,120 @@ function synthesizeRecord({ recordId, synthetic, gathered, nowMs }) {
271
335
  agent_summary: { complete: 0, partial: 0, failed: 0, spiral: 0 },
272
336
  total_agents: 0,
273
337
  total_files_changed: 0,
274
- status: 'abandoned',
275
- // Issue #773 — an abandoned session never ran Phase 1.65, so its carryover
276
- // is genuinely UNKNOWN. Emit `null` (not 0) so downstream effectiveness
277
- // consumers can tell "not measured" apart from "measured zero" — 0 here
278
- // would resurrect the very carryover=0 blind spot #773 exists to close.
338
+ status,
339
+ // Issue #773 — a backfilled stub never ran (or cannot be proven to have
340
+ // run) Phase 1.65, so its carryover is genuinely UNKNOWN. Emit `null` (not
341
+ // 0) so downstream effectiveness consumers can tell "not measured" apart
342
+ // from "measured zero" — 0 here would resurrect the very carryover=0
343
+ // blind spot #773 exists to close.
279
344
  effectiveness: { carryover: null },
280
- _backfill_source: 'events-jsonl',
345
+ _backfill_source: backfillSource,
281
346
  _backfill_incomplete_fields: incomplete,
282
347
  };
283
348
  if (branchFound) record.branch = gathered.branch;
284
349
  if (inferred) record._session_type_inferred = true;
285
350
  if (synthetic) record._synthetic_session_id = true;
286
351
  if (completedEstimated) record._completed_at_estimated = true;
352
+ // #1068 AC3/AC4 — forensic supersede marker. sessions.jsonl is append-only,
353
+ // so the stub itself cannot be stamped `superseded_by`; the FORWARD pointer
354
+ // lives on the newer record instead, and the stub survives verbatim (AC4:
355
+ // "historische Stub-Provenance bleibt erhalten"). Readers resolve one
356
+ // canonical state per id by taking the NEWEST record for that id.
357
+ if (typeof supersedes === 'string' && supersedes.length > 0) record.supersedes = supersedes;
358
+ // #1167 — the harness UUID this record was reconstructed from, when known.
359
+ // Additive and optional (the schema validates unknown keys pass-through, see
360
+ // session-schema/validator.mjs `_validateOptionalFields`): it is the ONLY key
361
+ // that lets a reader join a semantic record back to its raw uuid. Measured
362
+ // 2026-09-02 @ c3ab480: 0 of 286 existing records carry it, which is exactly
363
+ // why the two backfill writers could not see each other's work.
364
+ if (typeof rawSessionId === 'string' && rawSessionId.length > 0) record.raw_session_id = rawSessionId;
287
365
  return record;
288
366
  }
289
367
 
368
+ /**
369
+ * Terminal statuses that mark a ledger record as a BACKFILLED STUB rather than
370
+ * an authoritative close (#1068 AC3).
371
+ *
372
+ * `abandoned` is the only member today: it is what `backfillAbandonedSession`
373
+ * writes when a session never reached `/close`, i.e. a reconstruction, never a
374
+ * self-reported outcome. `completed` is deliberately NOT a member — including
375
+ * the `state-md-completed` backfill, whose status IS the session's own truth
376
+ * claim. That exclusion is also what makes supersede idempotent: the record
377
+ * appended by a supersede is `completed`, so a second run classifies it as
378
+ * canonical and skips instead of superseding its own predecessor forever.
379
+ */
380
+ const BACKFILL_STUB_STATUSES = new Set(['abandoned']);
381
+
382
+ /**
383
+ * True when a sessions.jsonl record is a backfilled STUB — reconstructed
384
+ * provenance (`_backfill_source`) AND a stub status. Both are required: a
385
+ * hand-written `abandoned` record with no backfill provenance is somebody's
386
+ * deliberate statement and is never superseded on our own initiative.
387
+ *
388
+ * @param {unknown} record
389
+ * @returns {boolean}
390
+ */
391
+ function isBackfillStub(record) {
392
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return false;
393
+ if (typeof record._backfill_source !== 'string') return false;
394
+ return BACKFILL_STUB_STATUSES.has(record.status);
395
+ }
396
+
397
+ /**
398
+ * Classify what sessions.jsonl already holds for this identity (#1068 AC3/AC4).
399
+ *
400
+ * Reads the (small) sessions.jsonl exactly once and returns one of:
401
+ * { kind: 'absent' } — nothing recorded yet
402
+ * { kind: 'canonical', matchedId } — an authoritative record exists
403
+ * { kind: 'stub', matchedId, stubId } — only a backfilled stub exists
404
+ *
405
+ * "Newest wins" is the reading rule: sessions.jsonl is APPEND-ONLY, so a
406
+ * superseding record can never rewrite the stub in place — it is appended
407
+ * after it, and the LAST record for an id is therefore the current one. This
408
+ * function reads the same way (last match, not first), so a stub that has
409
+ * already been superseded classifies as `canonical` and is never superseded
410
+ * twice.
411
+ *
412
+ * @param {Function} readFileSync
413
+ * @param {string} sessionsPath
414
+ * @param {{recordId: string, sessionId: string|null}} ids
415
+ */
416
+ function classifyExisting(readFileSync, sessionsPath, { recordId, sessionId }) {
417
+ const sessionRecords = readJsonlSafe(readFileSync, sessionsPath);
418
+ // Both keys count: the semantic record id, and — defensively — a prior record
419
+ // keyed directly by the UUID.
420
+ const uuidKey = isUuid(sessionId) ? sessionId : null;
421
+ const byId = (id) =>
422
+ id === null ? [] : sessionRecords.filter((r) => r && r.session_id === id);
423
+ // Key preference is UNCHANGED from the pre-#1068 dedupe: the semantic
424
+ // recordId wins whenever any record carries it, and the UUID key is only the
425
+ // defensive fallback.
426
+ const semanticMatches = byId(recordId);
427
+ const matches = semanticMatches.length > 0 ? semanticMatches : byId(uuidKey);
428
+ if (matches.length === 0) return { kind: 'absent' };
429
+
430
+ const newest = matches[matches.length - 1];
431
+ if (isBackfillStub(newest)) {
432
+ return { kind: 'stub', matchedId: newest.session_id, stubId: newest.session_id };
433
+ }
434
+ return { kind: 'canonical', matchedId: newest.session_id };
435
+ }
436
+
290
437
  /**
291
438
  * Dedupe against sessions.jsonl. Returns a `skipped-already-recorded` result
292
439
  * when `recordId` (or a UUID `sessionId` written directly as a key) is already
293
- * present, else `null`. Reads the (small) sessions.jsonl exactly once.
440
+ * present, else `null`.
441
+ *
442
+ * HARD dedupe by design — it is the guard for `backfillAbandonedSession`, whose
443
+ * output is itself a stub: replacing one stub with another buys nothing and
444
+ * would re-append on every SessionEnd. The supersede path (#1068 AC3) belongs
445
+ * to the AUTHORITATIVE writer only; see `classifyExisting` + its use in
446
+ * `backfillCompletedFromStateMd`.
294
447
  */
295
448
  function checkAlreadyRecorded(readFileSync, sessionsPath, { recordId, sessionId }) {
296
- const sessionRecords = readJsonlSafe(readFileSync, sessionsPath);
297
- const existingIds = new Set(
298
- sessionRecords.map((r) => (r && typeof r.session_id === 'string' ? r.session_id : null)).filter(Boolean)
299
- );
300
- if (existingIds.has(recordId)) {
301
- return { action: 'skipped-already-recorded', sessionId: recordId };
302
- }
303
- // Defensive: a prior record keyed directly by the UUID also counts.
304
- if (isUuid(sessionId) && existingIds.has(sessionId)) {
305
- return { action: 'skipped-already-recorded', sessionId };
306
- }
307
- return null;
449
+ const existing = classifyExisting(readFileSync, sessionsPath, { recordId, sessionId });
450
+ if (existing.kind === 'absent') return null;
451
+ return { action: 'skipped-already-recorded', sessionId: existing.matchedId };
308
452
  }
309
453
 
310
454
  // ---------------------------------------------------------------------------
@@ -424,8 +568,11 @@ export async function backfillAbandonedSession({
424
568
 
425
569
  // -- Resolve a deferred id from the lock bridge or a synthetic mint ------
426
570
  if (recordId === null) {
427
- if (gathered.semanticFromLock) {
428
- recordId = gathered.semanticFromLock;
571
+ // Prefer the lock bridge (it also carries `mode`), then the #1167
572
+ // session.ended bridge. Only when NEITHER attests a semantic id do we
573
+ // mint a synthetic one — that fallback was the duplicate-stub source.
574
+ if (gathered.semanticFromLock || gathered.semanticFromEvents) {
575
+ recordId = gathered.semanticFromLock || gathered.semanticFromEvents;
429
576
  } else {
430
577
  // No semantic bridge — mint a synthetic id. Both components are STABLE
431
578
  // across re-runs so dedupe/marker suppress a double write (idempotency
@@ -508,7 +655,13 @@ export async function backfillAbandonedSession({
508
655
  }
509
656
 
510
657
  // -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ---
511
- const record = synthesizeRecord({ recordId, synthetic, gathered, nowMs });
658
+ const record = synthesizeRecord({
659
+ recordId,
660
+ synthetic,
661
+ gathered,
662
+ nowMs,
663
+ rawSessionId: isUuid(sessionId) ? sessionId : null,
664
+ });
512
665
  let validated;
513
666
  try {
514
667
  validated = validateSession(record);
@@ -583,6 +736,243 @@ export async function backfillAbandonedSession({
583
736
  }
584
737
  }
585
738
 
739
+ /**
740
+ * Backfill a `status: 'completed'` session record from STATE.md when STATE.md
741
+ * itself already carries `status: completed` but sessions.jsonl has no
742
+ * matching record (#429).
743
+ *
744
+ * ── THE GAP THIS CLOSES ──────────────────────────────────────────────────
745
+ * `commands/close.md`'s Pre-Check treats `STATE.md status: completed` as
746
+ * PROOF that the session-end skill's Phase 3.7 writer already ran, and
747
+ * stops before invoking it — including when `status: completed` was set by
748
+ * hand (or by any path that never reached Phase 3.7). The session then has
749
+ * no record in sessions.jsonl, permanently: nothing else ever re-drives the
750
+ * write, because the Pre-Check keeps refusing to invoke session-end for as
751
+ * long as STATE.md says `completed`. This function is the mechanical
752
+ * self-heal — every SessionEnd hook firing checks the invariant
753
+ * "STATE.md completed ⇒ a record exists" and repairs it once, regardless of
754
+ * which session's hook happens to run next.
755
+ *
756
+ * ── WHY `status: 'completed'`, NOT `'abandoned'` ─────────────────────────
757
+ * `backfillAbandonedSession` (above) marks its stub `abandoned` because a
758
+ * session that never reached `/close` is, by definition, unfinished. This
759
+ * case is the opposite: STATE.md's own `status: completed` is a truth claim
760
+ * the session made about itself — only the LEDGER write failed to run. The
761
+ * record is tagged `_backfill_source: 'state-md-completed'` (never
762
+ * `'events-jsonl'`) so a later reader can tell the two backfill classes
763
+ * apart at a glance.
764
+ *
765
+ * ── DATA SOURCE ──────────────────────────────────────────────────────────
766
+ * Required numeric counters (`total_waves`, `total_agents`,
767
+ * `total_files_changed`, `agent_summary`) are derived ONLY from
768
+ * events.jsonl via the same `collectSessionEvents` + `synthesizeRecord`
769
+ * machinery `backfillAbandonedSession` uses — never from STATE.md's own
770
+ * body sections (Wave History, etc.), which this module deliberately never
771
+ * parses. Schema `REQUIRED_FIELDS` forbids `null` on these (non-negative
772
+ * number, `session-schema/validator.mjs`), so "otherwise null" is realized
773
+ * as "otherwise 0, flagged in `_backfill_incomplete_fields`" — the same
774
+ * contract the abandoned path already carries and the same reason it exists.
775
+ *
776
+ * CONSTRAINT specific to this path: STATE.md's `session` field is already
777
+ * the SEMANTIC id, never the raw harness UUID — unlike
778
+ * `backfillAbandonedSession` (which usually receives the UUID directly from
779
+ * SessionEnd stdin), this function has no UUID to seed `collectSessionEvents`'s
780
+ * `uuids` set with. Events therefore only surface here when a
781
+ * `lock.acquired` breadcrumb bridges the UUID to this exact semantic id
782
+ * (`ev.semantic_session_id === recordId`) — the SAME bridge condition
783
+ * `backfillAbandonedSession`'s synthetic-id fallback exists to handle when
784
+ * ABSENT. Without that bridge, `gathered` stays empty and the record still
785
+ * validates (started_at/completed_at both fall back to `now`, flagged
786
+ * incomplete) — degraded but never blocked.
787
+ *
788
+ * Never throws. Returns one of:
789
+ * { action: 'backfilled', sessionId, record } — written to disk
790
+ * { action: 'superseded', sessionId, record, supersedes } — written, replacing a stub (#1068 AC3)
791
+ * { action: 'would-backfill', sessionId, record } — dryRun only
792
+ * { action: 'would-supersede', sessionId, record, supersedes } — dryRun only
793
+ * { action: 'skipped-no-state-md' } — no STATE.md at any candidate path
794
+ * { action: 'skipped-unparseable-state-md' } — frontmatter did not parse
795
+ * { action: 'skipped-not-completed', status } — STATE.md status isn't 'completed'
796
+ * { action: 'skipped-no-session-id' } — completed but no `session:` field
797
+ * { action: 'skipped-already-recorded', sessionId } — sessions.jsonl already has it
798
+ * { action: 'skipped-marker-exists', sessionId } — lost the TOCTOU claim
799
+ * { action: 'error', error, sessionId? } — any failure, swallowed
800
+ *
801
+ * @param {object} args
802
+ * @param {string} args.repoRoot absolute project root
803
+ * @param {number|string} [args.now] ms-since-epoch (test seam) or ISO string
804
+ * @param {boolean} [args.dryRun=false] compute + validate only, no marker/write
805
+ * @param {object} [args.deps] DI overrides (fs, appendJsonl, resolveStateMdPath, …)
806
+ * @returns {Promise<object>}
807
+ */
808
+ export async function backfillCompletedFromStateMd({
809
+ repoRoot,
810
+ now = Date.now(),
811
+ dryRun = false,
812
+ deps = {},
813
+ } = {}) {
814
+ const {
815
+ readFileSync = fs.readFileSync,
816
+ appendJsonl = defaultAppendJsonl,
817
+ openSync = fs.openSync,
818
+ closeSync = fs.closeSync,
819
+ validateSession = defaultValidateSession,
820
+ serializeSessionLineChecked = defaultSerialize,
821
+ resolveStateMdPath = defaultResolveStateMdPath,
822
+ parseStateMd = defaultParseStateMd,
823
+ log = null,
824
+ } = deps;
825
+
826
+ const nowMs = resolveNowMs(now);
827
+ const result = await run();
828
+ logBreadcrumb(result);
829
+ return result;
830
+
831
+ async function run() {
832
+ try {
833
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
834
+ return { action: 'error', error: 'repoRoot must be a non-empty string' };
835
+ }
836
+
837
+ // -- Read + parse STATE.md ------------------------------------------------
838
+ const stateMdPath = resolveStateMdPath(repoRoot);
839
+ let raw;
840
+ try {
841
+ raw = readFileSync(stateMdPath, 'utf8');
842
+ } catch {
843
+ return { action: 'skipped-no-state-md' };
844
+ }
845
+ const parsed = parseStateMd(raw);
846
+ if (parsed === null) {
847
+ return { action: 'skipped-unparseable-state-md' };
848
+ }
849
+
850
+ const stateStatus = parsed.frontmatter?.status;
851
+ if (stateStatus !== 'completed') {
852
+ return { action: 'skipped-not-completed', status: stateStatus ?? null };
853
+ }
854
+
855
+ const recordId = parsed.frontmatter?.session;
856
+ if (typeof recordId !== 'string' || recordId.length === 0) {
857
+ return { action: 'skipped-no-session-id' };
858
+ }
859
+
860
+ // -- Dedupe, or SUPERSEDE a backfill stub (#1068 AC3) ---------------------
861
+ // This is the authoritative writer of the pair: STATE.md's own
862
+ // `status: completed` is the session's truth claim about itself, and it
863
+ // arrives with an identity-complete key (the semantic id). When the only
864
+ // thing on file for that identity is a reconstructed `abandoned` stub,
865
+ // the stub is a measurement this record refutes — so we append the fuller
866
+ // record (carrying `supersedes: <stub id>`) instead of skipping. An
867
+ // authoritative record already on file still short-circuits exactly as
868
+ // before.
869
+ const sessionsPath = path.join(repoRoot, ...SESSIONS_REL);
870
+ const existing = classifyExisting(readFileSync, sessionsPath, { recordId, sessionId: recordId });
871
+ if (existing.kind === 'canonical') {
872
+ return { action: 'skipped-already-recorded', sessionId: existing.matchedId };
873
+ }
874
+ const supersedes = existing.kind === 'stub' ? existing.stubId : null;
875
+
876
+ // -- Derive whatever is derivable from events.jsonl (never STATE.md body) -
877
+ const eventsPath = path.join(repoRoot, ...EVENTS_REL);
878
+ const events = readJsonlSafe(readFileSync, eventsPath);
879
+ const gathered = collectSessionEvents(events, { sessionId: null, semanticSessionId: recordId });
880
+
881
+ // -- Synthesize + validate (round-trip gate) BEFORE any disk mutation ----
882
+ const record = synthesizeRecord({
883
+ recordId,
884
+ synthetic: false,
885
+ gathered,
886
+ nowMs,
887
+ status: 'completed',
888
+ backfillSource: 'state-md-completed',
889
+ supersedes,
890
+ // #1167 — the abandoned path stamps this; so must the authoritative
891
+ // one, or the join key exists on exactly the weaker half of the pair.
892
+ // `gathered.uuids` is bridged from lock.acquired / session.ended, so it
893
+ // normally holds EXACTLY the one uuid this semantic id ran under. Two
894
+ // (or zero) means the bridge is ambiguous — omit rather than guess, the
895
+ // same fail-quiet posture as `isUuid(sessionId) ? sessionId : null`.
896
+ rawSessionId: gathered.uuids?.size === 1 ? [...gathered.uuids][0] : null,
897
+ });
898
+ let validated;
899
+ try {
900
+ validated = validateSession(record);
901
+ serializeSessionLineChecked(record);
902
+ } catch (err) {
903
+ return { action: 'error', error: `validation: ${err?.message ?? String(err)}`, sessionId: recordId };
904
+ }
905
+
906
+ if (dryRun) {
907
+ return {
908
+ action: supersedes ? 'would-supersede' : 'would-backfill',
909
+ sessionId: recordId,
910
+ record: validated,
911
+ ...(supersedes ? { supersedes } : {}),
912
+ };
913
+ }
914
+
915
+ // -- TOCTOU marker — atomic create-or-fail, own namespace so a concurrent
916
+ // abandoned-path claim for the same id can never collide with this one. --
917
+ const markerPath = path.join(repoRoot, '.orchestrator', 'metrics', markerName(`completed-${recordId}`));
918
+ try {
919
+ const fd = openSync(markerPath, 'wx');
920
+ closeSync(fd);
921
+ } catch (err) {
922
+ if (err && err.code === 'EEXIST') {
923
+ return { action: 'skipped-marker-exists', sessionId: recordId };
924
+ }
925
+ return { action: 'error', error: `marker: ${err?.message ?? String(err)}`, sessionId: recordId };
926
+ }
927
+
928
+ // -- Write via the shared append path -------------------------------------
929
+ try {
930
+ await appendJsonl(sessionsPath, validated);
931
+ } catch (err) {
932
+ return { action: 'error', error: `append: ${err?.message ?? String(err)}`, sessionId: recordId };
933
+ }
934
+ return {
935
+ action: supersedes ? 'superseded' : 'backfilled',
936
+ sessionId: recordId,
937
+ record: validated,
938
+ ...(supersedes ? { supersedes } : {}),
939
+ };
940
+ } catch (err) {
941
+ // Absolute backstop — the hook must never see an exception from here.
942
+ return { action: 'error', error: err?.message ?? String(err) };
943
+ }
944
+ }
945
+
946
+ /** Best-effort JSONL breadcrumb (project-local; never cascades). Shares the
947
+ * same log file as backfillAbandonedSession — the two are distinguishable
948
+ * by `_backfill_source` on the eventual sessions.jsonl record, and by the
949
+ * distinct action vocabulary above (`skipped-not-completed`,
950
+ * `skipped-no-state-md`, …) in the breadcrumb itself. */
951
+ function logBreadcrumb(res) {
952
+ try {
953
+ if (typeof log === 'function') {
954
+ log(res);
955
+ return;
956
+ }
957
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) return;
958
+ const logPath = path.join(repoRoot, ...BACKFILL_LOG_REL);
959
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
960
+ fs.appendFileSync(
961
+ logPath,
962
+ JSON.stringify({
963
+ timestamp: new Date(nowMs).toISOString(),
964
+ action: res.action,
965
+ session_id: res.sessionId ?? null,
966
+ ...(res.error ? { error: res.error } : {}),
967
+ }) + '\n',
968
+ 'utf8'
969
+ );
970
+ } catch {
971
+ /* never let logging cascade into the caller */
972
+ }
973
+ }
974
+ }
975
+
586
976
  /** Normalise the `now` arg (number ms | ISO string | undefined) → ms. */
587
977
  function resolveNowMs(now) {
588
978
  if (typeof now === 'number' && Number.isFinite(now)) return now;