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
@@ -16,11 +16,16 @@
16
16
  * 2. Enumerate tracked .md files under --prd-dir (git ls-files), excluding
17
17
  * *.original-uncommitted.md. A missing/empty --prd-dir yields [] → clean
18
18
  * report, exit 0 (no crash) — see listTrackedPrds.
19
- * 3. For each doc: parse the FIRST `#NNN` Epic/Issue reference in the header
20
- * region (first ~20 lines, up to the first `## ` section). No ref skip (WARN).
21
- * 4. `glab issue view <iid> --output json` state. Only `closed` refs archive;
19
+ * 3. For each doc: parse an Epic/Issue DECLARATION out of the header region
20
+ * (first ~20 lines, up to the first `## ` section) frontmatter key, label
21
+ * line, or H1 title suffix. A bare `#NNN` in prose is a CITATION and never
22
+ * counts (`citation-only`); no `#NNN` at all → `no-epic-ref`. Both skip.
23
+ * 4. Session ownership: a doc with uncommitted changes (`uncommitted`) or one
24
+ * committed at/after this session's `started_at` (`foreign-session`) is
25
+ * skipped — it belongs to a parallel session, not to us (#1123).
26
+ * 5. `glab issue view <iid> --output json` → state. Only `closed` refs archive;
22
27
  * `opened` and unknown/error states skip (never guess).
23
- * 5. Closed → archiveFileToVault(...) into <vault>/<--vault-subdir>/. Under
28
+ * 6. Closed → archiveFileToVault(...) into <vault>/<--vault-subdir>/. Under
24
29
  * --apply the source doc is removed with `git rm`; --dry-run (default) writes
25
30
  * NOTHING (not even to the vault).
26
31
  *
@@ -29,8 +34,9 @@
29
34
  *
30
35
  * Exit codes: 0 success · 1 input/config error · 2 system error.
31
36
  *
32
- * Exports (for tests): main, parseEpicRef, readHeaderRegion, listTrackedPrds,
33
- * epicState, defaultGlabRepo.
37
+ * Exports (for tests): main, parseEpicDeclaration, parseEpicRef, readHeaderRegion,
38
+ * listTrackedPrds, epicState, classifyOwnership, readSessionStartedAt,
39
+ * defaultGlabRepo.
34
40
  */
35
41
 
36
42
  import { readFileSync } from 'node:fs';
@@ -43,6 +49,9 @@ import { parseSessionConfig } from './lib/config.mjs';
43
49
  import { glabRun as defaultGlabRun } from './lib/vault-backfill/glab.mjs';
44
50
  import { archiveFileToVault, titleFromMarkdown } from './lib/vault-archive.mjs';
45
51
  import { defaultGlabRepo } from './lib/vcs-repo-spec.mjs';
52
+ import { readLock } from './lib/session-lock.mjs';
53
+ import { resolveStateMdPath } from './lib/state-md/frontmatter-mutators.mjs';
54
+ import { parseStateMd } from './lib/state-md/yaml-parser.mjs';
46
55
 
47
56
  const DEFAULT_PRD_DIR = 'docs/prd';
48
57
  const DEFAULT_VAULT_SUBDIR = '01-projects/session-orchestrator/prd';
@@ -74,14 +83,184 @@ export { defaultGlabRepo };
74
83
  // Pure helpers
75
84
  // ---------------------------------------------------------------------------
76
85
 
86
+ // A DECLARATION line names the doc's own tracking Epic/Issue. Three accepted
87
+ // spellings, all anchored at line start so prose can never reach them:
88
+ // (a) `**Epic:** #1113` — colon INSIDE the bold (the shape 2 of 4 live PRDs use)
89
+ // (b) `**Epic**: #1113` — colon outside the bold
90
+ // (c) `Source: docs/prd/x.md (#786)` — unbolded (the plan-header shape,
91
+ // skills/write-executable-plan/SKILL.md:193)
92
+ // The optional leading `-`/`*` covers list items (`- **Issue:** #366`).
93
+ const LABEL_WORD = String.raw`(?:parent[ \t]+)?(?:epic|issues?|source)`;
94
+ const LABEL_LINE_RE = new RegExp(
95
+ String.raw`^[-*]?[ \t]*(?:` +
96
+ String.raw`\*\*[ \t]*${LABEL_WORD}[ \t]*:[ \t]*\*\*` +
97
+ String.raw`|\*\*[ \t]*${LABEL_WORD}[ \t]*\*\*[ \t]*:` +
98
+ String.raw`|${LABEL_WORD}[ \t]*:` +
99
+ String.raw`)[ \t]*(\S.*)$`,
100
+ 'i',
101
+ );
102
+ const FRONTMATTER_LINE_RE = /^(?:epic|issues?)[ \t]*:[ \t]*\[?[ \t]*['"]?#?(\d+)/i;
103
+ const TITLE_SUFFIX_RE = /^#[ \t]+.*\(#(\d+)\)[ \t]*$/;
104
+ const ANY_IID_RE = /#(\d+)/;
105
+
106
+ /**
107
+ * Parse the doc's DECLARED Epic/Issue out of its header region.
108
+ *
109
+ * Precedence (first hit wins): frontmatter key → label line → H1 title suffix.
110
+ * A bare `#NNN` in prose is a CITATION and NEVER counts — that conflation is
111
+ * what deleted a live PRD on 2026-08-22 (#1112): a quoted, long-closed `#214`
112
+ * inside a blockquote read as the doc's own Epic, so the doc looked finished.
113
+ * The rule therefore prefers, by construction, documents that carry LESS
114
+ * context — hence the anchored line shapes below.
115
+ *
116
+ * @param {string} headerText — output of readHeaderRegion().
117
+ * @returns {{ iid: string, via: 'frontmatter'|'label'|'title' }|null}
118
+ */
119
+ export function parseEpicDeclaration(headerText) {
120
+ const lines = String(headerText).split(/\r?\n/);
121
+
122
+ // (1) Frontmatter — only inside the LEADING `---` block, never a stray
123
+ // `epic:` line further down the header.
124
+ //
125
+ // A leading `---` alone does NOT make a frontmatter block: in Markdown it is
126
+ // also a thematic break, and a doc that opens with one would otherwise have
127
+ // its BODY read as frontmatter (a body line `epic: 1113` — or any `issue:`
128
+ // note — would then win over the real declaration below). Require a CLOSING
129
+ // `---` inside the header region; without it, fall through to label/title.
130
+ if (lines[0]?.trim() === '---') {
131
+ const close = lines.findIndex((line, i) => i > 0 && line.trim() === '---');
132
+ if (close !== -1) {
133
+ for (let i = 1; i < close; i++) {
134
+ const m = FRONTMATTER_LINE_RE.exec(lines[i]);
135
+ if (m) return { iid: m[1], via: 'frontmatter' };
136
+ }
137
+ }
138
+ }
139
+
140
+ // (2) Label line — the first `#NNN` on a recognised label line. Taking the
141
+ // first is correct for `**Epic:** #1048 · **Sub-Issues:** #1049 …`: the
142
+ // primary declaration leads, the sub-issues trail.
143
+ for (const line of lines) {
144
+ const label = LABEL_LINE_RE.exec(line);
145
+ if (!label) continue;
146
+ const iid = ANY_IID_RE.exec(label[1]);
147
+ if (iid) return { iid: iid[1], via: 'label' };
148
+ }
149
+
150
+ // (3) H1 title suffix — `# Feature: Foo (#1113)`.
151
+ for (const line of lines) {
152
+ const m = TITLE_SUFFIX_RE.exec(line);
153
+ if (m) return { iid: m[1], via: 'title' };
154
+ }
155
+
156
+ return null;
157
+ }
158
+
77
159
  /**
78
- * Parse the FIRST `#NNN` issue reference out of a PRD header region.
160
+ * @deprecated Use {@link parseEpicDeclaration} it also reports HOW the iid was
161
+ * declared, which the caller needs to tell `citation-only` from `no-epic-ref`.
162
+ * Retained as a thin shim for existing importers.
79
163
  * @param {string} headerText
80
- * @returns {string|null} the numeric iid as a string, or null when absent.
164
+ * @returns {string|null} the declared iid as a string, or null when absent.
81
165
  */
82
166
  export function parseEpicRef(headerText) {
83
- const m = String(headerText).match(/#(\d+)/);
84
- return m ? m[1] : null;
167
+ return parseEpicDeclaration(headerText)?.iid ?? null;
168
+ }
169
+
170
+ /**
171
+ * Decide whether a doc belongs to THIS session or to a parallel one (#1123).
172
+ *
173
+ * Author IDENTITY carries no signal here — every session commits as the same
174
+ * human — so the discriminator is commit TIME against this session's
175
+ * `started_at`, taken as MAX(author date, committer date) so a rebased or
176
+ * cherry-picked peer commit cannot read as old.
177
+ * Fail-closed in every ambiguous direction: a failed git probe,
178
+ * an unparseable commit timestamp, or a missing `sessionStartedAt` all yield
179
+ * `'foreign'`, because 'foreign' is the verdict that does NOT delete.
180
+ *
181
+ * @param {object} args
182
+ * @param {string} args.repoRoot
183
+ * @param {string} args.rel — repo-relative doc path.
184
+ * @param {string|null|undefined} args.sessionStartedAt — ISO-8601.
185
+ * @param {(args: string[]) => { ok: boolean, stdout: string, stderr: string }} args.gitRunFn
186
+ * @returns {'mine'|'foreign'|'uncommitted'} never throws.
187
+ */
188
+ export function classifyOwnership({ repoRoot, rel, sessionStartedAt, gitRunFn }) {
189
+ let status;
190
+ try {
191
+ status = gitRunFn(['-C', repoRoot, 'status', '--porcelain', '--', rel]);
192
+ } catch {
193
+ return 'foreign';
194
+ }
195
+ if (!status?.ok) return 'foreign';
196
+ // Non-empty porcelain output = staged, unstaged or untracked (`??`) — either
197
+ // way there is work in the tree that a `git rm` would destroy.
198
+ if (String(status.stdout).trim() !== '') return 'uncommitted';
199
+
200
+ const startedMs = Date.parse(String(sessionStartedAt ?? ''));
201
+ if (Number.isNaN(startedMs)) return 'foreign';
202
+
203
+ let log;
204
+ try {
205
+ // BOTH timestamps, newest wins. `%aI` alone is the AUTHOR date, which
206
+ // survives rebase, cherry-pick and `--amend` UNCHANGED — a peer session's
207
+ // doc rebased onto this branch keeps its old author date and would read as
208
+ // 'mine', i.e. as ours to `git rm`. `%cI` (committer date) moves with every
209
+ // rewrite, so MAX(author, committer) is the moment the object entered THIS
210
+ // history — the quantity the session-ownership guard actually needs.
211
+ log = gitRunFn(['-C', repoRoot, 'log', '-1', '--format=%aI%n%cI', '--', rel]);
212
+ } catch {
213
+ return 'foreign';
214
+ }
215
+ if (!log?.ok) return 'foreign';
216
+ const stamps = String(log.stdout)
217
+ .split(/\r?\n/)
218
+ .map((s) => Date.parse(s.trim()))
219
+ .filter((n) => !Number.isNaN(n));
220
+ if (stamps.length === 0) return 'foreign';
221
+ const committedMs = Math.max(...stamps);
222
+
223
+ return committedMs >= startedMs ? 'foreign' : 'mine';
224
+ }
225
+
226
+ /**
227
+ * Resolve THIS session's `started_at` (ISO-8601): the session lock first, the
228
+ * STATE.md frontmatter as fallback.
229
+ *
230
+ * @param {string} repoRoot
231
+ * @returns {string|null} null when neither source yields a parseable timestamp.
232
+ */
233
+ export function readSessionStartedAt(repoRoot) {
234
+ try {
235
+ const startedAt = readLock({ repoRoot })?.started_at;
236
+ if (typeof startedAt === 'string' && !Number.isNaN(Date.parse(startedAt))) return startedAt;
237
+ } catch {
238
+ // fall through to STATE.md
239
+ }
240
+ try {
241
+ const parsed = parseStateMd(readFileSync(resolveStateMdPath(repoRoot), 'utf8'));
242
+ const startedAt = parsed?.frontmatter?.started_at;
243
+ if (typeof startedAt === 'string' && !Number.isNaN(Date.parse(startedAt))) return startedAt;
244
+ } catch {
245
+ // no STATE.md, or unreadable → null (fail-closed at the call site)
246
+ }
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * Read the issue iids this session declared in STATE.md frontmatter (`issues:`).
252
+ * @param {string} repoRoot
253
+ * @returns {Set<string>} empty when STATE.md is absent or carries no list.
254
+ */
255
+ function readSessionIssues(repoRoot) {
256
+ try {
257
+ const parsed = parseStateMd(readFileSync(resolveStateMdPath(repoRoot), 'utf8'));
258
+ const issues = parsed?.frontmatter?.issues;
259
+ if (Array.isArray(issues)) return new Set(issues.map((n) => String(n)));
260
+ } catch {
261
+ // absent/unreadable → empty set
262
+ }
263
+ return new Set();
85
264
  }
86
265
 
87
266
  /**
@@ -167,7 +346,8 @@ function printHelp() {
167
346
 
168
347
  USAGE
169
348
  node scripts/archive-closed-prds.mjs [--dry-run|--apply] [--json]
170
- [--prd-dir DIR] [--vault-subdir DIR] [--help]
349
+ [--prd-dir DIR] [--vault-subdir DIR] [--glab-repo SPEC]
350
+ [--ignore-session-guard] [--owned-issues-only] [--help]
171
351
 
172
352
  FLAGS
173
353
  --dry-run (default) Plan the archive; write NOTHING (not even the vault).
@@ -180,6 +360,16 @@ FLAGS
180
360
  shell wrapper and uses the ambient GITLAB_HOST). When
181
361
  omitted, the spec is auto-detected from the local git
182
362
  remote (prefers 'gitlab', else 'origin').
363
+ --ignore-session-guard
364
+ Escape hatch for a CATCH-UP SWEEP: also consider docs last
365
+ committed at/after this session's start (normally skipped
366
+ as 'foreign-session', because they belong to a parallel
367
+ session — #1123). Docs with uncommitted changes are STILL
368
+ skipped; this flag never widens that guard.
369
+ --owned-issues-only
370
+ Opt-in: archive only docs whose declared iid appears in
371
+ this session's STATE.md 'issues:' list. Everything else
372
+ skips as 'epic-#NNN-not-owned'.
183
373
  -h, --help Show this help and exit.
184
374
 
185
375
  EXIT CODES
@@ -229,6 +419,8 @@ function printHuman(archived, skipped, isDryRun, vaultDir, vaultSubdir) {
229
419
  * @param {{ env?: Record<string, string|undefined>, ownerConfig?: object }} [opts.hostPaths]
230
420
  * — forwarded to parseSessionConfig. Tests pass `{ env: {}, ownerConfig: undefined }`
231
421
  * for hermetic, owner.yaml-free vault-dir resolution (issue #653 bleed guard).
422
+ * @param {string|null} [opts.sessionStartedAt] — ISO-8601 override for the
423
+ * session-ownership guard (#1123). Omitted → readSessionStartedAt(root).
232
424
  * @returns {{ code: 0|1|2, archived: object[], skipped: object[], vaultDir?: string, dryRun?: boolean }}
233
425
  */
234
426
  export function main({
@@ -238,6 +430,7 @@ export function main({
238
430
  gitRunFn = defaultGitRun,
239
431
  now,
240
432
  hostPaths,
433
+ sessionStartedAt,
241
434
  } = {}) {
242
435
  // ── Parse flags ──────────────────────────────────────────────────────────
243
436
  let apply = false;
@@ -247,6 +440,8 @@ export function main({
247
440
  let prdDir = DEFAULT_PRD_DIR;
248
441
  let vaultSubdir = DEFAULT_VAULT_SUBDIR;
249
442
  let glabRepo;
443
+ let ignoreSessionGuard = false;
444
+ let ownedIssuesOnly = false;
250
445
 
251
446
  for (let i = 0; i < argv.length; i++) {
252
447
  const a = argv[i];
@@ -260,6 +455,8 @@ export function main({
260
455
  else if (a.startsWith('--vault-subdir=')) vaultSubdir = a.slice('--vault-subdir='.length);
261
456
  else if (a === '--glab-repo') glabRepo = argv[++i];
262
457
  else if (a.startsWith('--glab-repo=')) glabRepo = a.slice('--glab-repo='.length);
458
+ else if (a === '--ignore-session-guard') ignoreSessionGuard = true;
459
+ else if (a === '--owned-issues-only') ownedIssuesOnly = true;
263
460
  else {
264
461
  process.stderr.write(`archive-closed-prds: unknown argument: ${a}\n`);
265
462
  process.stderr.write('Run with --help for usage.\n');
@@ -314,6 +511,16 @@ export function main({
314
511
  // the local git remote (host-agnostic; nothing host-specific committed).
315
512
  const effectiveGlabRepo = glabRepo ?? defaultGlabRepo(root, gitRunFn);
316
513
 
514
+ // ── Session identity (ownership guard, #1123) ────────────────────────────
515
+ const startedAt = sessionStartedAt !== undefined ? sessionStartedAt : readSessionStartedAt(root);
516
+ if (!startedAt && !ignoreSessionGuard) {
517
+ warn(
518
+ 'archive-closed-prds: no session started_at (session.lock/STATE.md) — every doc is treated ' +
519
+ 'as foreign-session. Use --ignore-session-guard for a deliberate catch-up sweep.',
520
+ );
521
+ }
522
+ const ownedIssues = ownedIssuesOnly ? readSessionIssues(root) : null;
523
+
317
524
  // ── Enumerate + classify PRDs ────────────────────────────────────────────
318
525
  const prds = listTrackedPrds(root, prdDir, gitRunFn);
319
526
  const archived = [];
@@ -331,20 +538,51 @@ export function main({
331
538
  continue;
332
539
  }
333
540
 
334
- const iid = parseEpicRef(header);
335
- if (!iid) {
336
- skipped.push({ source: rel, reason: 'no-epic-ref' });
337
- warn(`archive-closed-prds: no Epic reference in header of ${rel} skipped (never guess).`);
541
+ const decl = parseEpicDeclaration(header);
542
+ if (!decl) {
543
+ // Distinguish "nothing to go on" from "only a CITATION to go on" — the
544
+ // latter is the #1112 shape and the more dangerous of the two, because a
545
+ // quoted closed issue reads exactly like a declared one.
546
+ const citationOnly = ANY_IID_RE.test(header);
547
+ skipped.push({ source: rel, reason: citationOnly ? 'citation-only' : 'no-epic-ref' });
548
+ warn(
549
+ citationOnly
550
+ ? `archive-closed-prds: ${rel} header has #NNN only as a citation, not a declaration — skipped (#1112).`
551
+ : `archive-closed-prds: no Epic reference in header of ${rel} — skipped (never guess).`,
552
+ );
553
+ continue;
554
+ }
555
+ const { iid, via } = decl;
556
+
557
+ // Session ownership BEFORE any glab call: a doc a parallel session just
558
+ // committed is not ours to archive, whatever its Epic's state (#1123).
559
+ const ownership = classifyOwnership({ repoRoot: root, rel, sessionStartedAt: startedAt, gitRunFn });
560
+ if (ownership === 'uncommitted') {
561
+ skipped.push({ source: rel, reason: 'uncommitted', iid, via, ownership });
562
+ warn(`archive-closed-prds: ${rel} has uncommitted changes — skipped (never 'git rm' live work).`);
563
+ continue;
564
+ }
565
+ if (ownership === 'foreign' && !ignoreSessionGuard) {
566
+ skipped.push({ source: rel, reason: 'foreign-session', iid, via, ownership });
567
+ warn(
568
+ `archive-closed-prds: ${rel} was last committed at/after this session's start — skipped ` +
569
+ '(belongs to a parallel session; --ignore-session-guard overrides).',
570
+ );
571
+ continue;
572
+ }
573
+
574
+ if (ownedIssues && !ownedIssues.has(String(iid))) {
575
+ skipped.push({ source: rel, reason: `epic-#${iid}-not-owned`, iid, via, ownership });
338
576
  continue;
339
577
  }
340
578
 
341
579
  const state = epicState(iid, glabRunFn, effectiveGlabRepo);
342
580
  if (state === 'opened') {
343
- skipped.push({ source: rel, reason: `epic-#${iid}-open`, iid });
581
+ skipped.push({ source: rel, reason: `epic-#${iid}-open`, iid, via, ownership });
344
582
  continue;
345
583
  }
346
584
  if (state === 'unknown') {
347
- skipped.push({ source: rel, reason: `epic-#${iid}-state-unknown`, iid });
585
+ skipped.push({ source: rel, reason: `epic-#${iid}-state-unknown`, iid, via, ownership });
348
586
  warn(`archive-closed-prds: could not resolve state of Epic #${iid} for ${rel} — skipped.`);
349
587
  continue;
350
588
  }
@@ -364,11 +602,13 @@ export function main({
364
602
  title: titleFromMarkdown(header),
365
603
  });
366
604
  } catch (err) {
367
- skipped.push({ source: rel, reason: `archive-failed: ${err.message}`, iid });
605
+ skipped.push({ source: rel, reason: `archive-failed: ${err.message}`, iid, via, ownership });
368
606
  warn(`archive-closed-prds: failed to archive ${rel}: ${err.message}`);
369
607
  continue;
370
608
  }
371
609
  entry.iid = iid;
610
+ entry.via = via;
611
+ entry.ownership = ownership;
372
612
 
373
613
  if (!isDryRun) {
374
614
  const rm = gitRunFn(['-C', root, 'rm', '--', rel]);
@@ -47,6 +47,7 @@ import { selectMode } from './lib/mode-selector.mjs';
47
47
  import { probe, evaluate, DEFAULT_RESOURCE_THRESHOLDS } from './lib/resource-probe.mjs';
48
48
  import { detectPeers } from './lib/session-registry.mjs';
49
49
  import { normalizeSession } from './lib/session-schema.mjs';
50
+ import { readCanonicalSessions } from './lib/sessions-canonical.mjs';
50
51
 
51
52
  // ---------------------------------------------------------------------------
52
53
  // CLI-level flag extraction
@@ -174,17 +175,29 @@ const branch = detectBranch();
174
175
  const SESSIONS_JSONL_PATH = resolve('.orchestrator/metrics/sessions.jsonl');
175
176
 
176
177
  /**
177
- * Count the number of non-empty lines in sessions.jsonl. Returns 0 if missing.
178
- * @returns {number}
178
+ * Set of the DISTINCT session identities currently recorded in sessions.jsonl.
179
+ * Empty set when the file is missing or unreadable.
180
+ *
181
+ * Identities, not lines (#1167): the ledger is append-only, so one physical
182
+ * session can occupy two lines (an abandoned stub plus the authoritative
183
+ * record that supersedes it, or the systemic double-stub pair). The
184
+ * pre/post comparison below asks "did the session I just spawned record
185
+ * itself?", and a raw line count answers that question wrong in BOTH
186
+ * directions — a duplicate pair looks like two sessions, while a supersede
187
+ * append (+1 line, −1 stub) leaves a canonical COUNT unchanged. Comparing the
188
+ * id SETS is immune to both.
189
+ *
190
+ * @returns {Set<string>}
179
191
  */
180
- function countSessionLines() {
181
- if (!existsSync(SESSIONS_JSONL_PATH)) return 0;
182
- try {
183
- const raw = readFileSync(SESSIONS_JSONL_PATH, 'utf8');
184
- return raw.split('\n').filter((l) => l.trim().length > 0).length;
185
- } catch {
186
- return 0;
192
+ function readSessionIds() {
193
+ if (!existsSync(SESSIONS_JSONL_PATH)) return new Set();
194
+ const ids = new Set();
195
+ for (const rec of readCanonicalSessions({ filePath: SESSIONS_JSONL_PATH })) {
196
+ if (typeof rec.session_id === 'string' && rec.session_id.length > 0) {
197
+ ids.add(rec.session_id);
198
+ }
187
199
  }
200
+ return ids;
188
201
  }
189
202
 
190
203
  /**
@@ -230,7 +243,12 @@ async function modeSelector() {
230
243
  // `backlogLimit` is intentionally NOT passed: the window default lives once,
231
244
  // in backlog-scan.mjs (`DEFAULT_BACKLOG_LIMIT`). The hand-written 50 that
232
245
  // stood here under-read this repo's own backlog and hid its critical issues.
246
+ // `repoRoot` is passed EXPLICITLY (#1071) rather than left to the helper's
247
+ // cwd default: this driver already reads learnings.jsonl and sessions.jsonl
248
+ // relative to the invocation directory, and an implicit default is the seam
249
+ // where those two roots silently drift apart.
233
250
  const signals = await buildLiveSignals({
251
+ repoRoot: process.cwd(),
234
252
  learnings: surfacedLearnings,
235
253
  });
236
254
  return selectMode(signals);
@@ -244,7 +262,7 @@ async function modeSelector() {
244
262
  * @returns {Promise<{session_id: string, agent_summary?: object, effectiveness?: object}>}
245
263
  */
246
264
  async function sessionRunner({ mode, autopilotRunId }) {
247
- const preCount = countSessionLines();
265
+ const preIds = readSessionIds();
248
266
 
249
267
  await new Promise((res, rej) => {
250
268
  const childStdio = hasVerbose
@@ -276,8 +294,9 @@ async function sessionRunner({ mode, autopilotRunId }) {
276
294
  });
277
295
  });
278
296
 
279
- const postCount = countSessionLines();
280
- if (postCount === preCount) {
297
+ const postIds = readSessionIds();
298
+ const appeared = [...postIds].some((id) => !preIds.has(id));
299
+ if (!appeared) {
281
300
  throw new Error('no session record appended');
282
301
  }
283
302
 
@@ -42,10 +42,12 @@ import { parseArgs } from 'node:util';
42
42
  import { fileURLToPath } from 'node:url';
43
43
 
44
44
  import { backfillAbandonedSession, isUuid } from './lib/session-close-backfill.mjs';
45
+ import { emitEvent } from './lib/events.mjs';
45
46
  import { SO_PROJECT_DIR } from './lib/platform.mjs';
46
47
 
47
48
  const LOCK_ACQUIRED = 'orchestrator.session.lock.acquired';
48
49
  const SESSION_STARTED = 'orchestrator.session.started';
50
+ const SESSION_ENDED = 'orchestrator.session.ended';
49
51
 
50
52
  /**
51
53
  * Default cap on how many candidates may reach the (expensive) shared core in a
@@ -54,12 +56,31 @@ const SESSION_STARTED = 'orchestrator.session.started';
54
56
  */
55
57
  export const SESSION_START_LIMIT = 25;
56
58
 
57
- /** Read a JSONL file into parsed objects; missing → []; malformed lines skipped. */
59
+ /**
60
+ * Read a JSONL file into parsed objects; MISSING (ENOENT) → `[]` silently,
61
+ * UNREADABLE (EACCES/EISDIR/…) → `[]` with a stderr WARN (#1188); malformed
62
+ * lines skipped.
63
+ */
58
64
  function readJsonl(filePath) {
59
65
  let raw;
60
66
  try {
61
67
  raw = fs.readFileSync(filePath, 'utf8');
62
- } catch {
68
+ } catch (err) {
69
+ // #1188 — ENOENT and EACCES/EISDIR are different facts: a missing ledger is
70
+ // the ordinary fresh-repo case; an UNREADABLE one previously read as "no
71
+ // records" and made every downstream count silently wrong. Same split as
72
+ // readLockDetailed (session-lock.mjs § absent vs unreadable).
73
+ if (!err || err.code !== 'ENOENT') {
74
+ process.stderr.write(
75
+ `⚠ backfill-abandoned-sessions: cannot read ${filePath} ` +
76
+ `(${err?.code ?? '?'}: ${err?.message ?? String(err)}) — ` +
77
+ 'treating as EMPTY, counts below are floors\n',
78
+ );
79
+ }
80
+ // CEILING (BV-004): still [] rather than throw — this runs on the
81
+ // SessionStart path (SESSION_START_LIMIT), where a permissions fault must
82
+ // not crash the session start. REVISIT if the warn rate in events.jsonl
83
+ // shows masked corruption.
63
84
  return [];
64
85
  }
65
86
  const out = [];
@@ -86,16 +107,22 @@ function readJsonl(filePath) {
86
107
  export function planSessions({ repoRoot }) {
87
108
  const events = readJsonl(path.join(repoRoot, '.orchestrator', 'metrics', 'events.jsonl'));
88
109
 
89
- const semanticByUuid = new Map();
110
+ // Two independent UUID -> semantic bridges (#1167). `lock.acquired` is the
111
+ // original one, but a session that LOST the lock-acquire race never emits it;
112
+ // its `session.ended` event carries `semantic_session_id` all the same (since
113
+ // #1068 AC1) and was never read here. Without that second bridge the
114
+ // candidate resolved semantic=null and the shared core minted a SYNTHETIC id,
115
+ // writing a duplicate stub beside the record the SessionEnd hook had already
116
+ // written under the real semantic id (measured 2026-09-02 @ c3ab480: 8 pairs).
117
+ // lock.acquired keeps precedence — it is the older, mode-carrying attestation.
118
+ const semanticFromLock = new Map();
119
+ const semanticFromEnded = new Map();
90
120
  for (const ev of events) {
91
- if (
92
- ev.event === LOCK_ACQUIRED &&
93
- typeof ev.session_id === 'string' &&
94
- typeof ev.semantic_session_id === 'string'
95
- ) {
96
- semanticByUuid.set(ev.session_id, ev.semantic_session_id);
97
- }
121
+ if (typeof ev.session_id !== 'string' || typeof ev.semantic_session_id !== 'string') continue;
122
+ if (ev.event === LOCK_ACQUIRED) semanticFromLock.set(ev.session_id, ev.semantic_session_id);
123
+ else if (ev.event === SESSION_ENDED) semanticFromEnded.set(ev.session_id, ev.semantic_session_id);
98
124
  }
125
+ const semanticByUuid = new Map([...semanticFromEnded, ...semanticFromLock]);
99
126
 
100
127
  const seen = new Set();
101
128
  const plan = [];
@@ -133,7 +160,14 @@ function readRecordedIds(repoRoot) {
133
160
  const records = readJsonl(path.join(repoRoot, '.orchestrator', 'metrics', 'sessions.jsonl'));
134
161
  const ids = new Set();
135
162
  for (const r of records) {
136
- if (r && typeof r.session_id === 'string') ids.add(r.session_id);
163
+ if (!r) continue;
164
+ if (typeof r.session_id === 'string') ids.add(r.session_id);
165
+ // #1167 — a backfilled record now stamps the harness UUID it was
166
+ // reconstructed from. Indexing it lets a bare-UUID candidate be recognised
167
+ // as already-recorded even though the record itself is keyed semantically —
168
+ // the join that was structurally impossible while the field was absent
169
+ // (0 of 286 records carried it, measured 2026-09-02 @ c3ab480).
170
+ if (typeof r.raw_session_id === 'string') ids.add(r.raw_session_id);
137
171
  }
138
172
  return ids;
139
173
  }
@@ -238,6 +272,7 @@ export async function runMigration({
238
272
  case 'backfilled':
239
273
  summary.backfilled += 1;
240
274
  if (res.deadByAge) summary.dead_by_age += 1;
275
+ await emitBackfillCompleted(repoRoot, item, res);
241
276
  // Keep the pre-filter snapshot in step with what we just wrote, so a
242
277
  // second candidate bridging to the SAME semantic id is skipped cheaply
243
278
  // instead of re-entering the core (which would reach the same verdict).
@@ -263,6 +298,40 @@ export async function runMigration({
263
298
  return summary;
264
299
  }
265
300
 
301
+ /**
302
+ * Emit `orchestrator.session.backfill_completed` for a record THIS path wrote
303
+ * (#1167). Mirrors the payload of `hooks/on-session-end.mjs::emitBackfillOutcome`
304
+ * so both writers are queryable with one filter.
305
+ *
306
+ * Until now the startup/CLI path wrote records SILENTLY: only the SessionEnd
307
+ * hook emitted the event, so nothing in the event stream distinguished "the
308
+ * backfill never ran" from "the backfill ran here". Best-effort by contract —
309
+ * observability must never fail a migration run.
310
+ *
311
+ * @param {string} repoRoot
312
+ * @param {{sessionId: string, semanticSessionId: string|null}} item
313
+ * @param {{action: string, sessionId?: string, supersedes?: string, error?: string}} res
314
+ */
315
+ async function emitBackfillCompleted(repoRoot, item, res) {
316
+ try {
317
+ await emitEvent(
318
+ 'orchestrator.session.backfill_completed',
319
+ {
320
+ kind: 'abandoned',
321
+ action: typeof res?.action === 'string' ? res.action : 'unknown',
322
+ ...(typeof item?.sessionId === 'string' ? { session_id: item.sessionId } : {}),
323
+ ...(typeof item?.semanticSessionId === 'string'
324
+ ? { semantic_session_id: item.semanticSessionId }
325
+ : {}),
326
+ ...(typeof res?.sessionId === 'string' ? { record_id: res.sessionId } : {}),
327
+ ...(typeof res?.supersedes === 'string' ? { supersedes: res.supersedes } : {}),
328
+ ...(typeof res?.error === 'string' ? { reason: res.error } : {}),
329
+ },
330
+ { repoRoot },
331
+ );
332
+ } catch { /* observability is best-effort */ }
333
+ }
334
+
266
335
  /**
267
336
  * SessionStart entry point (#926) — decouple the backfill from the /close path.
268
337
  *