session-orchestrator 3.22.0 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -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]);
@@ -230,7 +230,12 @@ async function modeSelector() {
230
230
  // `backlogLimit` is intentionally NOT passed: the window default lives once,
231
231
  // in backlog-scan.mjs (`DEFAULT_BACKLOG_LIMIT`). The hand-written 50 that
232
232
  // stood here under-read this repo's own backlog and hid its critical issues.
233
+ // `repoRoot` is passed EXPLICITLY (#1071) rather than left to the helper's
234
+ // cwd default: this driver already reads learnings.jsonl and sessions.jsonl
235
+ // relative to the invocation directory, and an implicit default is the seam
236
+ // where those two roots silently drift apart.
233
237
  const signals = await buildLiveSignals({
238
+ repoRoot: process.cwd(),
234
239
  learnings: surfacedLearnings,
235
240
  });
236
241
  return selectMode(signals);