@ornexus/neocortex 4.60.73 → 4.60.74

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 (241) hide show
  1. package/dist/release-source.json +1 -1
  2. package/dist/sbom.cdx.json +1216 -1210
  3. package/install.ps1 +1 -1
  4. package/install.sh +1 -1
  5. package/package.json +2 -2
  6. package/packages/client/dist/adapters/adapter-registry.js +1 -108
  7. package/packages/client/dist/adapters/antigravity-adapter.js +2 -77
  8. package/packages/client/dist/adapters/claude-code-adapter.js +3 -79
  9. package/packages/client/dist/adapters/codex-adapter.js +2 -80
  10. package/packages/client/dist/adapters/cursor-adapter.js +4 -115
  11. package/packages/client/dist/adapters/gemini-adapter.js +2 -71
  12. package/packages/client/dist/adapters/grok-adapter.js +2 -42
  13. package/packages/client/dist/adapters/index.js +1 -22
  14. package/packages/client/dist/adapters/platform-detector.js +1 -212
  15. package/packages/client/dist/adapters/target-adapter.js +0 -12
  16. package/packages/client/dist/adapters/vscode-adapter.js +2 -72
  17. package/packages/client/dist/agent/refresh-stubs.js +2 -417
  18. package/packages/client/dist/agent/update-agent-yaml.js +1 -102
  19. package/packages/client/dist/agent/update-description.js +1 -251
  20. package/packages/client/dist/cache/crypto-utils.js +1 -78
  21. package/packages/client/dist/cache/encrypted-cache.js +1 -108
  22. package/packages/client/dist/cache/in-memory-asset-cache.js +1 -73
  23. package/packages/client/dist/cache/index.js +1 -13
  24. package/packages/client/dist/cache/protected-pi-boundary.js +1 -44
  25. package/packages/client/dist/checkpoint/checkpoint-client-reader.js +2 -127
  26. package/packages/client/dist/checkpoint/index.js +1 -11
  27. package/packages/client/dist/checkpoint/shared-checkpoint-types.js +1 -32
  28. package/packages/client/dist/cli.js +5 -358
  29. package/packages/client/dist/commands/activate.js +8 -394
  30. package/packages/client/dist/commands/architecture-answers.js +7 -274
  31. package/packages/client/dist/commands/architecture-memory-terminal.js +4 -253
  32. package/packages/client/dist/commands/architecture-non-ui-continuation.js +1 -100
  33. package/packages/client/dist/commands/architecture-root-binding.js +1 -51
  34. package/packages/client/dist/commands/cache-status.js +2 -112
  35. package/packages/client/dist/commands/config.js +1 -88
  36. package/packages/client/dist/commands/doctor.js +2 -635
  37. package/packages/client/dist/commands/invoke.js +143 -7200
  38. package/packages/client/dist/commands/legacy-epic-terminal.js +1 -71
  39. package/packages/client/dist/commands/legacy-story-branch.js +1 -96
  40. package/packages/client/dist/commands/legacy-story-pr.js +1 -276
  41. package/packages/client/dist/commands/mutation-qa-checkpoint.js +5 -285
  42. package/packages/client/dist/commands/mutation-qa-gate.js +1 -54
  43. package/packages/client/dist/commands/mutation-qa-git.js +1 -41
  44. package/packages/client/dist/commands/mutation-qa-policy.js +1 -102
  45. package/packages/client/dist/commands/mutation-qa-run.js +3 -219
  46. package/packages/client/dist/commands/mutation-qa-verdict.js +1 -149
  47. package/packages/client/dist/commands/mutation-qa.js +1 -220
  48. package/packages/client/dist/commands/native-agent.js +2 -136
  49. package/packages/client/dist/commands/planning-diagnosis.js +1 -43
  50. package/packages/client/dist/commands/planning-generation.js +1 -53
  51. package/packages/client/dist/commands/planning-memory.js +1 -40
  52. package/packages/client/dist/commands/planning-research.js +1 -42
  53. package/packages/client/dist/commands/private-session.js +1 -75
  54. package/packages/client/dist/commands/property-qa.js +1 -139
  55. package/packages/client/dist/commands/qa-control.js +1 -16
  56. package/packages/client/dist/commands/qa-scan-browser-launch.js +1 -55
  57. package/packages/client/dist/commands/qa-scan-browser-route.js +1 -28
  58. package/packages/client/dist/commands/qa-scan-capture.js +5 -663
  59. package/packages/client/dist/commands/qa-scan-discovery.js +3 -376
  60. package/packages/client/dist/commands/qa-scan-review.js +5 -826
  61. package/packages/client/dist/commands/qa-synthetic-backend-capture.js +1 -266
  62. package/packages/client/dist/commands/qa-synthetic-backend-review.js +1 -297
  63. package/packages/client/dist/commands/qa-synthetic-backend.js +2 -340
  64. package/packages/client/dist/commands/qa-synthetic-broker-port.js +1 -85
  65. package/packages/client/dist/commands/qa-synthetic-browser.js +1 -39
  66. package/packages/client/dist/commands/refactor-characterization.js +21 -671
  67. package/packages/client/dist/commands/refactor-commit.js +13 -399
  68. package/packages/client/dist/commands/refactor-control.js +3 -142
  69. package/packages/client/dist/commands/refactor-cpp-assertions.js +3 -93
  70. package/packages/client/dist/commands/refactor-review.js +5 -283
  71. package/packages/client/dist/commands/refactor-root.js +7 -588
  72. package/packages/client/dist/commands/refresh-memory.js +3 -156
  73. package/packages/client/dist/commands/shared-architecture-non-ui.js +1 -24
  74. package/packages/client/dist/commands/story-checkpoint.js +1 -171
  75. package/packages/client/dist/config/profile-directory.js +1 -13
  76. package/packages/client/dist/config/resolver-selection.js +1 -278
  77. package/packages/client/dist/config/secure-config.js +12 -281
  78. package/packages/client/dist/config.js +2 -223
  79. package/packages/client/dist/constants.js +1 -25
  80. package/packages/client/dist/context/context-collector.js +2 -222
  81. package/packages/client/dist/context/context-sanitizer.js +1 -145
  82. package/packages/client/dist/continuity/autonomous-governance.js +1 -285
  83. package/packages/client/dist/continuity/continuity-client-state-store.js +1 -1587
  84. package/packages/client/dist/continuity/invoke-hooks.js +1 -129
  85. package/packages/client/dist/continuity/migrations/001-initial-schema.js +2 -12
  86. package/packages/client/dist/continuity/migrations/002-processing-output-summary-indexes.js +2 -12
  87. package/packages/client/dist/continuity/migrations/003-headless-execution-attempts.js +2 -12
  88. package/packages/client/dist/continuity/migrations/004-outbox-delivery-state.js +2 -12
  89. package/packages/client/dist/continuity/migrations/005-bounded-headless-queue.js +2 -12
  90. package/packages/client/dist/continuity/migrations/006-native-agent-dispatches.js +2 -10
  91. package/packages/client/dist/continuity/native-agent-store.js +1 -69
  92. package/packages/client/dist/continuity/native-agent-types.js +1 -90
  93. package/packages/client/dist/continuity/outbox.js +1 -117
  94. package/packages/client/dist/continuity/runner-bootstrap-policy.js +1 -172
  95. package/packages/client/dist/continuity/sqlite-store.js +81 -2612
  96. package/packages/client/dist/effects/invoke-effect-gateways.js +1 -62
  97. package/packages/client/dist/errors/error-messages.js +2 -228
  98. package/packages/client/dist/errors/frontmatter-diagnostics.js +1 -125
  99. package/packages/client/dist/evidence/collector.js +1 -347
  100. package/packages/client/dist/evidence/index.js +1 -1
  101. package/packages/client/dist/evidence/types.js +0 -2
  102. package/packages/client/dist/fs/atomic-state-replace.js +1 -152
  103. package/packages/client/dist/generated/command-registry.generated.js +1 -4076
  104. package/packages/client/dist/generated/public-projections.generated.js +1 -149
  105. package/packages/client/dist/git/direct-yolo-c01-retry.js +4 -138
  106. package/packages/client/dist/git/direct-yolo-checkpoint.js +4 -1234
  107. package/packages/client/dist/git/epic-operational-state.js +2 -672
  108. package/packages/client/dist/git/git-lifecycle-step-ids.js +1 -14
  109. package/packages/client/dist/git/git-lifecycle-store.d.ts +23 -0
  110. package/packages/client/dist/git/git-lifecycle-store.js +10 -1981
  111. package/packages/client/dist/git/legacy-story-branch-checkpoint.js +9 -259
  112. package/packages/client/dist/git/legacy-story-branch-setup.js +4 -348
  113. package/packages/client/dist/git/legacy-story-pr.js +70 -3538
  114. package/packages/client/dist/git/secure-filesystem-boundary.js +5 -182
  115. package/packages/client/dist/graph-retrieval/pre-command-hook.js +1 -284
  116. package/packages/client/dist/graph-retrieval/shared-graph-retrieval-contract.js +1 -237
  117. package/packages/client/dist/i18n/first-run.js +2 -60
  118. package/packages/client/dist/index.js +1 -47
  119. package/packages/client/dist/invocation/invoke-input.js +1 -24
  120. package/packages/client/dist/license/index.js +1 -5
  121. package/packages/client/dist/license/license-client.js +1 -304
  122. package/packages/client/dist/machine/fingerprint.js +2 -163
  123. package/packages/client/dist/machine/index.js +1 -5
  124. package/packages/client/dist/memory/project-memory-path-scope.js +1 -62
  125. package/packages/client/dist/memory/project-memory-writer.js +40 -1007
  126. package/packages/client/dist/memory/shared-project-memory-types.js +2 -253
  127. package/packages/client/dist/migrations/epic-lifecycle-backfill.js +3 -1060
  128. package/packages/client/dist/migrations/project-identity-capability.js +1 -50
  129. package/packages/client/dist/migrations/project-identity.js +7 -493
  130. package/packages/client/dist/migrations/state-json.js +4 -393
  131. package/packages/client/dist/policy/architecture-policy.js +2 -184
  132. package/packages/client/dist/policy/index.js +1 -7
  133. package/packages/client/dist/policy/shared-policy-types.js +0 -38
  134. package/packages/client/dist/policy/subagent-approval-policy.js +1 -55
  135. package/packages/client/dist/private-execution/clean-baseline.js +2 -279
  136. package/packages/client/dist/private-execution/codex-executor.js +4 -574
  137. package/packages/client/dist/private-execution/codex-host-runtime.js +1 -137
  138. package/packages/client/dist/private-execution/codex-lifecycle-guard.js +1 -97
  139. package/packages/client/dist/private-execution/codex-scoped-permissions.js +1 -128
  140. package/packages/client/dist/private-execution/delivery.js +13 -410
  141. package/packages/client/dist/private-execution/diagnosis-root-review.js +2 -294
  142. package/packages/client/dist/private-execution/frozen-handoff.js +6 -19
  143. package/packages/client/dist/private-execution/implementation-validation.js +2 -106
  144. package/packages/client/dist/private-execution/isolated-admission.js +23 -1432
  145. package/packages/client/dist/private-execution/isolated-session.js +5 -725
  146. package/packages/client/dist/private-execution/isolated-workspace.js +1 -122
  147. package/packages/client/dist/private-execution/pact-consumer-runtime.js +3 -409
  148. package/packages/client/dist/private-execution/pact-test-source.js +1 -258
  149. package/packages/client/dist/private-execution/pact-toolchain.js +3 -290
  150. package/packages/client/dist/private-execution/pact-write-set.js +1 -172
  151. package/packages/client/dist/private-execution/planning-diagnosis-checkpoint.js +5 -406
  152. package/packages/client/dist/private-execution/planning-generation-checkpoint.js +5 -462
  153. package/packages/client/dist/private-execution/planning-memory-checkpoint.js +3 -139
  154. package/packages/client/dist/private-execution/planning-research-checkpoint.js +4 -325
  155. package/packages/client/dist/private-execution/private-report-store.js +1 -42
  156. package/packages/client/dist/private-execution/property-test-candidate.js +1 -103
  157. package/packages/client/dist/private-execution/property-test-choices.js +3 -147
  158. package/packages/client/dist/private-execution/property-test-plan.js +7 -271
  159. package/packages/client/dist/private-execution/property-test-review.js +4 -258
  160. package/packages/client/dist/private-execution/property-test-runtime.js +9 -372
  161. package/packages/client/dist/private-execution/property-test-source.js +1 -139
  162. package/packages/client/dist/private-execution/root-checkpoint.js +1 -80
  163. package/packages/client/dist/private-execution/root-memory-git-evidence.js +3 -135
  164. package/packages/client/dist/private-execution/root-memory-recovery.js +1 -93
  165. package/packages/client/dist/private-execution/root-memory-request.js +1 -29
  166. package/packages/client/dist/private-execution/root-memory-scope.js +1 -178
  167. package/packages/client/dist/private-execution/root-memory-writer.js +1 -102
  168. package/packages/client/dist/private-execution/session-lease.js +1 -63
  169. package/packages/client/dist/private-execution/shared-private-admission-types.js +1 -50
  170. package/packages/client/dist/private-execution/shared-project-validation.js +1 -66
  171. package/packages/client/dist/private-execution/shared-root-memory-types.js +1 -61
  172. package/packages/client/dist/private-execution/ux-source-validation.js +1 -372
  173. package/packages/client/dist/private-execution/ux-write-set.js +2 -191
  174. package/packages/client/dist/registry/registry-reconciliation.js +1 -234
  175. package/packages/client/dist/resilience/circuit-breaker.js +1 -170
  176. package/packages/client/dist/resilience/degradation-manager.js +1 -164
  177. package/packages/client/dist/resilience/freshness-indicator.js +1 -100
  178. package/packages/client/dist/resilience/index.js +1 -8
  179. package/packages/client/dist/resilience/recovery-detector.js +1 -74
  180. package/packages/client/dist/resolvers/asset-resolver.js +0 -13
  181. package/packages/client/dist/resolvers/local-resolver.js +8 -255
  182. package/packages/client/dist/resolvers/remote-resolver.js +1 -233
  183. package/packages/client/dist/runner/binary.js +1 -404
  184. package/packages/client/dist/runner/cli.js +17 -1571
  185. package/packages/client/dist/runner/headless/adapters.js +1 -49
  186. package/packages/client/dist/runner/headless/claude-code-adapter.js +1 -79
  187. package/packages/client/dist/runner/headless/continuous-story-workspace.js +11 -1628
  188. package/packages/client/dist/runner/headless/epic-git-reconciliation-inventory.js +1 -69
  189. package/packages/client/dist/runner/headless/epic-merge-candidate-qa.d.ts +1 -1
  190. package/packages/client/dist/runner/headless/epic-merge-candidate-qa.js +2 -110
  191. package/packages/client/dist/runner/headless/epic-terminal-git-cli-adapter.d.ts +2 -0
  192. package/packages/client/dist/runner/headless/epic-terminal-git-cli-adapter.js +10 -1236
  193. package/packages/client/dist/runner/headless/epic-terminal-git-runner.d.ts +18 -2
  194. package/packages/client/dist/runner/headless/epic-terminal-git-runner.js +2 -318
  195. package/packages/client/dist/runner/headless/fake-adapter.js +1 -62
  196. package/packages/client/dist/runner/headless/managed-profile.js +1 -132
  197. package/packages/client/dist/runner/headless/opencode-adapter.js +2 -145
  198. package/packages/client/dist/runner/headless/prepared-story-bundle.js +1 -43
  199. package/packages/client/dist/runner/headless/resolver.js +1 -231
  200. package/packages/client/dist/runner/headless/run-yolo.js +1 -885
  201. package/packages/client/dist/runner/headless/run-yoloop.d.ts +1 -0
  202. package/packages/client/dist/runner/headless/run-yoloop.js +2 -2574
  203. package/packages/client/dist/runner/headless/types.js +2 -574
  204. package/packages/client/dist/runner/local-console.js +13 -396
  205. package/packages/client/dist/runner/portal-sync-worker.js +1 -1487
  206. package/packages/client/dist/runner/project-identity.js +1 -106
  207. package/packages/client/dist/runner/scheduler.js +7 -995
  208. package/packages/client/dist/runner-cli.js +2 -18
  209. package/packages/client/dist/state/invocation-shape.js +1 -13
  210. package/packages/client/dist/state/project-state-snapshot.js +1 -1731
  211. package/packages/client/dist/state/state-json-repair.js +3 -785
  212. package/packages/client/dist/state/structural-dag-projection.js +1 -118
  213. package/packages/client/dist/telemetry/index.js +1 -5
  214. package/packages/client/dist/telemetry/offline-queue.js +1 -131
  215. package/packages/client/dist/tier/index.js +1 -5
  216. package/packages/client/dist/tier/tier-aware-client.js +1 -405
  217. package/packages/client/dist/types/index.js +1 -38
  218. package/packages/client/dist/version.js +1 -27
  219. package/packages/client/dist/yolo/change-evidence-collector.js +1 -126
  220. package/packages/client/dist/yolo/invoke-envelope-capability.js +1 -62
  221. package/packages/client/dist/yolo/spec-envelope-collector.js +5 -184
  222. package/packages/client/dist/yoloop/discovery-hook.js +2 -300
  223. package/packages/client/dist/yoloop/index.js +1 -8
  224. package/packages/client/dist/yoloop/invoke-hooks.js +12 -669
  225. package/packages/client/dist/yoloop/shared-discover-epics.js +1 -1007
  226. package/packages/client/dist/yoloop/shared-yoloop-types.js +1 -33
  227. package/packages/client/dist/yoloop/target-normalization.js +1 -180
  228. package/packages/client/dist/yoloop/yoloop-client-state-store.js +1 -1920
  229. package/packages/shared/dist/yolo/change-evidence.js +1 -1
  230. package/packages/shared/dist/yoloop/epic-terminal-git.js +144 -2
  231. package/targets-stubs/antigravity/gemini.md +1 -1
  232. package/targets-stubs/antigravity/skill/SKILL.md +1 -1
  233. package/targets-stubs/claude-code/neocortex-root.agent.yaml +1 -1
  234. package/targets-stubs/claude-code/neocortex-root.md +2 -2
  235. package/targets-stubs/claude-code/neocortex.agent.yaml +1 -1
  236. package/targets-stubs/claude-code/neocortex.md +2 -2
  237. package/targets-stubs/codex/AGENTS.md +1 -1
  238. package/targets-stubs/cursor/agent.md +2 -2
  239. package/targets-stubs/gemini-cli/agent.md +2 -2
  240. package/targets-stubs/opencode/neocortex-root.md +1 -1
  241. package/targets-stubs/vscode/neocortex.agent.md +2 -2
@@ -1,1007 +1 @@
1
- /**
2
- * @license FSL-1.1
3
- * Copyright (c) 2026 OrNexus AI
4
- *
5
- * This file is part of Neocortex CLI, licensed under the
6
- * Functional Source License, Version 1.1 (FSL-1.1).
7
- *
8
- * Change Date: February 20, 2029
9
- * Change License: MIT
10
- *
11
- * See the LICENSE file in the project root for full license text.
12
- */
13
- // =====================================================================
14
- // Pattern arrays (2 readonly arrays per AC3)
15
- // =====================================================================
16
- /**
17
- * Canonical naming contract for files generated by `*create-epic`.
18
- *
19
- * Discovery keeps accepting older forms for compatibility, but new generated
20
- * files must use only the canonical values below.
21
- */
22
- export const GENERATED_NAMING_CONTRACT = Object.freeze({
23
- epicId: 'P{number}',
24
- epicFilename: 'docs/epics/epic-P{number}.md',
25
- storyId: 'P{number}.NN',
26
- storyFilename: 'docs/stories/P{number}.NN.story.md',
27
- acceptedOnly: Object.freeze({
28
- epicFilenames: Object.freeze([
29
- 'docs/epics/epic-{number}.md',
30
- 'docs/epics/EPIC-{number}-{slug}.md',
31
- 'docs/epics/EPIC-{number}_{slug}.md',
32
- 'docs/epics/epic-{slug}.md',
33
- ]),
34
- storyFilenames: Object.freeze([
35
- 'docs/stories/{number}.NN.story.md',
36
- 'docs/stories/story-P{number}.NN.md',
37
- 'docs/stories/story-{number}.NN.md',
38
- 'docs/stories/EPIC-{number}.STORY-{number}.md',
39
- ]),
40
- }),
41
- });
42
- /**
43
- * Three accepted epic filename conventions.
44
- * Anchored regex (`^...$`) so partial matches are rejected.
45
- *
46
- * - `[0]` generated canonical: `epic-P119.md`
47
- * accepted legacy: `epic-92.5.md`, `epic-277.md`
48
- * - `[1]` accepted legacy: `EPIC-01-foundation.md`, `EPIC-12_setup.md`
49
- * - `[2]` accepted legacy: `epic-customer-portal.md`, `epic-feature-x.md`
50
- *
51
- * Group 1 is the canonical ID (or slug) extracted from the filename.
52
- */
53
- export const EPIC_FILE_PATTERNS = Object.freeze([
54
- /^epic-(P?\d+(?:\.\d+)?)\.md$/i,
55
- /^EPIC-(\d+)(?:[-_].*)?\.md$/,
56
- /^epic-([a-z0-9][a-z0-9_-]*)\.md$/i,
57
- ]);
58
- /**
59
- * Three accepted story filename conventions.
60
- *
61
- * - `[0]` generated canonical: `P119.01.story.md`
62
- * accepted legacy: `01.02.story.md`
63
- * - `[1]` accepted legacy: `story-P104.04.md`
64
- * - `[2]` accepted legacy: `EPIC-01.STORY-02.md`
65
- *
66
- * Group 1 = story ID for `[0]` and `[1]`. For `[2]`, group 1 = epic id,
67
- * group 2 = story local id (composite).
68
- *
69
- * Pattern[0] accepts both `P119.01.story.md` (P-prefix) and `01.02.story.md`
70
- * (numeric only) -- the `P?` prefix is optional.
71
- */
72
- export const STORY_FILE_PATTERNS = Object.freeze([
73
- /^(P?\d+\.\d+(?:\.\d+)?)\.story\.md$/i,
74
- /^story-(P?\d+\.\d+)\.md$/i,
75
- /^EPIC-(\d+)\.STORY-(\d+)\.md$/,
76
- ]);
77
- /**
78
- * Subdirectory names that are skipped (not recursed) during scan.
79
- * Operators use these conventions for archival/draft separation.
80
- */
81
- const IGNORED_SUBDIRS = Object.freeze([
82
- '.archive',
83
- '_archive',
84
- '_archives',
85
- '_drafts',
86
- 'archive',
87
- 'archives',
88
- 'archived',
89
- 'drafts',
90
- ]);
91
- // =====================================================================
92
- // Helpers (internal, not exported)
93
- // =====================================================================
94
- /** Pre-compiled YAML frontmatter regex (top-of-file `---\n...\n---`). */
95
- const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---/;
96
- /** Field extractors -- trim + strip surrounding quotes if any. */
97
- function parseField(block, key) {
98
- const re = new RegExp(`^${key}:\\s*(.+?)\\s*$`, 'mi');
99
- const match = block.match(re);
100
- if (!match)
101
- return undefined;
102
- let value = match[1].trim();
103
- // Strip wrapping quotes (single or double)
104
- if ((value.startsWith('"') && value.endsWith('"')) ||
105
- (value.startsWith("'") && value.endsWith("'"))) {
106
- value = value.slice(1, -1);
107
- }
108
- return value || undefined;
109
- }
110
- /**
111
- * Lightweight regex-only frontmatter parser.
112
- * No js-yaml dependency: only the bounded discovery fields are extracted.
113
- * Malformed frontmatter (no closing `---`, extra `:`) returns empty object.
114
- */
115
- function parseFrontmatter(content) {
116
- if (typeof content !== 'string' || content.length === 0)
117
- return {};
118
- const match = content.match(FRONTMATTER_RE);
119
- if (!match)
120
- return {};
121
- const block = match[1];
122
- return {
123
- id: parseField(block, 'id'),
124
- title: parseField(block, 'title'),
125
- status: parseField(block, 'status'),
126
- epic_id: parseField(block, 'epic_id'),
127
- depends_on: parseDependencyField(block),
128
- files_to_modify: parseStringListField(block, 'files_to_modify'),
129
- total_stories: parseNonNegativeInteger(parseField(block, 'total_stories')),
130
- };
131
- }
132
- const LEGACY_HEADER_MAX_LINES = 32;
133
- const LEGACY_HEADER_MAX_CHARS = 4_096;
134
- const LEGACY_FIELD_RE = /^\s*\*\*([^*\r\n]+?):\*\*\s*(.*?)\s*$/;
135
- const LEGACY_KNOWN_STATUSES = new Set([
136
- 'backlog', 'pending', 'ready', 'planning', 'in-progress',
137
- 'done', 'completed', 'merged', 'cancelled', 'canceled', 'abandoned',
138
- 'draft', 'template', 'archived', 'branch-ready', 'diagnose', 'research',
139
- 'write-spec', 'create-tasks', 'implemented', 'refactor-suggest-done',
140
- 'property-tests-emitted', 'tests-validated', 'qa-gate-passed',
141
- 'memory-updated', 'committed', 'synced', 'pr-created', 'pr-reviewed', 'partial',
142
- ]);
143
- function stripLegacyMarkup(raw) {
144
- let value = raw.trim();
145
- if (value.startsWith('`') && value.endsWith('`') && value.length >= 2) {
146
- value = value.slice(1, -1).trim();
147
- }
148
- if ((value.startsWith('"') && value.endsWith('"')) ||
149
- (value.startsWith("'") && value.endsWith("'"))) {
150
- value = value.slice(1, -1).trim();
151
- }
152
- return value;
153
- }
154
- function parseNonNegativeInteger(raw) {
155
- if (raw === undefined || !/^\d{1,5}$/.test(raw.trim()))
156
- return undefined;
157
- const parsed = Number(raw);
158
- return Number.isSafeInteger(parsed) ? parsed : undefined;
159
- }
160
- function normalizeLegacyStatus(raw) {
161
- const normalized = stripLegacyMarkup(raw).toLowerCase().replace(/[_\s]+/g, '-');
162
- const aliases = {
163
- planned: 'planning',
164
- complete: 'completed',
165
- closed: 'done',
166
- finished: 'done',
167
- };
168
- const canonical = aliases[normalized] ?? normalized;
169
- return LEGACY_KNOWN_STATUSES.has(canonical) ? canonical : undefined;
170
- }
171
- function normalizeLegacyLabel(raw) {
172
- const label = raw.trim().toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ');
173
- if (label === 'id' || label === 'epic id')
174
- return 'id';
175
- if (label === 'title' || label === 'titulo')
176
- return 'title';
177
- if (label === 'status')
178
- return 'status';
179
- if (label === 'total stories' || label === 'total de stories')
180
- return 'total_stories';
181
- return undefined;
182
- }
183
- function legacyHeadingTitle(lines) {
184
- const heading = lines.find((line) => /^#\s+\S/.test(line));
185
- if (!heading)
186
- return undefined;
187
- const value = heading.replace(/^#\s+/, '').trim();
188
- const withoutPrefix = value.replace(/^(?:Epic|Story)\s+\S+\s*(?:(?:--?|:)|[\u2013\u2014])\s*/i, '');
189
- return (withoutPrefix || value).slice(0, 200);
190
- }
191
- function selectLegacyValue(values, field, filename, warnings) {
192
- const normalized = [...new Set(values.map(stripLegacyMarkup).filter(Boolean))];
193
- if (normalized.length > 1) {
194
- warnings.push(`legacy metadata ambiguous for '${field}' in '${filename}': conflicting values ignored`);
195
- return undefined;
196
- }
197
- return normalized[0];
198
- }
199
- function parseLegacyMetadata(content, filename) {
200
- const warnings = [];
201
- const lines = [];
202
- const boundedLines = content.slice(0, LEGACY_HEADER_MAX_CHARS).split(/\r?\n/, LEGACY_HEADER_MAX_LINES);
203
- for (const line of boundedLines) {
204
- if (lines.length > 0 && (/^\s*---\s*$/.test(line) || /^##\s+/.test(line)))
205
- break;
206
- lines.push(line);
207
- }
208
- const values = new Map();
209
- for (const line of lines) {
210
- const match = line.match(LEGACY_FIELD_RE);
211
- if (!match)
212
- continue;
213
- const field = normalizeLegacyLabel(match[1]);
214
- if (!field)
215
- continue;
216
- const current = values.get(field) ?? [];
217
- current.push(match[2]);
218
- values.set(field, current);
219
- }
220
- const id = selectLegacyValue(values.get('id') ?? [], 'id', filename, warnings);
221
- const explicitTitle = selectLegacyValue(values.get('title') ?? [], 'title', filename, warnings);
222
- const rawStatus = selectLegacyValue(values.get('status') ?? [], 'status', filename, warnings);
223
- const rawTotalStories = selectLegacyValue(values.get('total_stories') ?? [], 'total_stories', filename, warnings);
224
- const status = rawStatus === undefined ? undefined : normalizeLegacyStatus(rawStatus);
225
- if (rawStatus !== undefined && status === undefined) {
226
- warnings.push(`legacy metadata status unrecognized in '${filename}': value ignored`);
227
- }
228
- const totalStories = parseNonNegativeInteger(rawTotalStories);
229
- if (rawTotalStories !== undefined && totalStories === undefined) {
230
- warnings.push(`legacy metadata total_stories invalid in '${filename}': value ignored`);
231
- }
232
- const safeId = id && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id) ? id : undefined;
233
- if (id !== undefined && safeId === undefined) {
234
- warnings.push(`legacy metadata id invalid in '${filename}': value ignored`);
235
- }
236
- const headingTitle = legacyHeadingTitle(lines);
237
- const title = explicitTitle ?? headingTitle;
238
- const metadata = {
239
- ...(safeId ? { id: safeId } : {}),
240
- ...(title ? { title: title.slice(0, 200) } : {}),
241
- ...(status ? { status } : {}),
242
- ...(totalStories !== undefined ? { total_stories: totalStories } : {}),
243
- };
244
- return { metadata, source: 'legacy-metadata', warnings: Object.freeze(warnings) };
245
- }
246
- function safeDiagnosticFilename(filename) {
247
- return /^[A-Za-z0-9._-]{1,160}$/.test(filename) ? filename : 'markdown-target.md';
248
- }
249
- function utf8ByteLength(value) {
250
- return new TextEncoder().encode(value).length;
251
- }
252
- function hasUnsupportedFrontmatterShape(block) {
253
- let listAllowed = false;
254
- for (const rawLine of block.split(/\r?\n/)) {
255
- if (!rawLine.trim() || rawLine.trimStart().startsWith('#'))
256
- continue;
257
- const listItem = rawLine.match(/^\s+-\s+(.+?)\s*$/);
258
- if (listItem) {
259
- if (!listAllowed)
260
- return true;
261
- const value = listItem[1].trim();
262
- if ((value.startsWith('"') && !value.endsWith('"')) ||
263
- (value.startsWith("'") && !value.endsWith("'")))
264
- return true;
265
- continue;
266
- }
267
- if (/^\s+/.test(rawLine))
268
- return true;
269
- const field = rawLine.match(/^[A-Za-z0-9_-]+:\s*(.*?)\s*$/);
270
- if (!field)
271
- return true;
272
- const value = field[1].trim();
273
- listAllowed = value.length === 0;
274
- if ((value.startsWith('"') && !value.endsWith('"')) ||
275
- (value.startsWith("'") && !value.endsWith("'")))
276
- return true;
277
- }
278
- return false;
279
- }
280
- function frontmatterDiagnosticWarnings(content, filename, metadata) {
281
- const safeFilename = safeDiagnosticFilename(filename);
282
- const match = content.match(FRONTMATTER_RE);
283
- if (!match) {
284
- return Object.freeze([
285
- `metadata-frontmatter reason=frontmatter-malformed file='${safeFilename}': repair the bounded leading YAML envelope; source content omitted`,
286
- ]);
287
- }
288
- const block = match[1];
289
- if (utf8ByteLength(block) > 8 * 1024 || block.split(/\r?\n/).length > 128) {
290
- return Object.freeze([
291
- `metadata-frontmatter reason=frontmatter-oversized file='${safeFilename}': reduce the leading YAML envelope; source content omitted`,
292
- ]);
293
- }
294
- if (hasUnsupportedFrontmatterShape(block) ||
295
- (block.trim().length > 0 && Object.keys(metadata).length === 0)) {
296
- return Object.freeze([
297
- `metadata-frontmatter reason=frontmatter-unsupported-value file='${safeFilename}': use bounded scalar fields or string lists; source content omitted`,
298
- ]);
299
- }
300
- return Object.freeze([]);
301
- }
302
- function parseDescriptorMetadata(content, filename) {
303
- if (/^---(?:\s*\r?\n|\s*$)/.test(content)) {
304
- const metadata = parseFrontmatter(content);
305
- return {
306
- metadata,
307
- source: 'frontmatter',
308
- warnings: frontmatterDiagnosticWarnings(content, filename, metadata),
309
- };
310
- }
311
- if (typeof content !== 'string' || content.length === 0) {
312
- return { metadata: {}, source: 'none', warnings: Object.freeze([]) };
313
- }
314
- return parseLegacyMetadata(content, filename);
315
- }
316
- function normalizeDependencyToken(raw) {
317
- let value = raw.trim();
318
- if ((value.startsWith('"') && value.endsWith('"')) ||
319
- (value.startsWith("'") && value.endsWith("'"))) {
320
- value = value.slice(1, -1).trim();
321
- }
322
- if (!value)
323
- return undefined;
324
- if (/^(null|undefined|true|false)$/i.test(value))
325
- return undefined;
326
- return value;
327
- }
328
- function parseDependencyField(block) {
329
- return parseStringListField(block, 'depends_on');
330
- }
331
- function parseStringListField(block, fieldName) {
332
- const escapedField = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
333
- const inlineMatch = block.match(new RegExp(`^${escapedField}:[ \\t]*(.+?)[ \\t]*$`, 'mi'));
334
- const inline = inlineMatch?.[1]?.trim();
335
- if (inline !== undefined) {
336
- const rawItems = inline.startsWith('[') && inline.endsWith(']')
337
- ? inline.slice(1, -1).split(',')
338
- : [inline];
339
- const items = rawItems
340
- .map(normalizeDependencyToken)
341
- .filter((d) => typeof d === 'string' && d.length > 0);
342
- return items.length > 0 ? Object.freeze(items) : undefined;
343
- }
344
- const lines = block.split(/\r?\n/);
345
- const items = [];
346
- let inListBlock = false;
347
- for (const line of lines) {
348
- if (!inListBlock) {
349
- if (new RegExp(`^${escapedField}:\\s*$`, 'i').test(line.trim())) {
350
- inListBlock = true;
351
- }
352
- continue;
353
- }
354
- if (/^[A-Za-z0-9_-]+:\s*/.test(line))
355
- break;
356
- const match = line.match(/^\s*-\s*(.+?)\s*$/);
357
- if (!match)
358
- continue;
359
- const value = normalizeDependencyToken(match[1]);
360
- if (value)
361
- items.push(value);
362
- }
363
- return items.length > 0 ? Object.freeze(items) : undefined;
364
- }
365
- /**
366
- * Test if a basename should be ignored (drafts/hidden).
367
- * Anchored on prefix only (`_*`, `.*`).
368
- */
369
- function isIgnoredFile(basename) {
370
- return basename.startsWith('_') || basename.startsWith('.');
371
- }
372
- /**
373
- * Test if a subdirectory name should be skipped.
374
- */
375
- function isIgnoredSubdir(name) {
376
- return IGNORED_SUBDIRS.includes(name);
377
- }
378
- /**
379
- * Default filesystem adapter wrapping `node:fs` synchronous APIs.
380
- * Lazy-loaded via dynamic require so this module is safe to import in
381
- * non-Node environments (browser bundles tree-shake unused calls).
382
- *
383
- * @example
384
- * ```ts
385
- * const fs = await buildDefaultDiscoveryFs();
386
- * const result = discoverEpicsFromDir(dir, fs);
387
- * ```
388
- */
389
- export async function buildDefaultDiscoveryFs() {
390
- const fsModule = await import('node:fs');
391
- const pathModule = await import('node:path');
392
- return {
393
- isDirectory(p) {
394
- try {
395
- return fsModule.statSync(p).isDirectory();
396
- }
397
- catch {
398
- return false;
399
- }
400
- },
401
- readdir(p) {
402
- try {
403
- return fsModule.readdirSync(p);
404
- }
405
- catch {
406
- return [];
407
- }
408
- },
409
- readFile(p) {
410
- try {
411
- return fsModule.readFileSync(p, 'utf8');
412
- }
413
- catch {
414
- return '';
415
- }
416
- },
417
- join(...parts) {
418
- try {
419
- return pathModule.join(...parts);
420
- }
421
- catch {
422
- return parts.join('/');
423
- }
424
- },
425
- };
426
- }
427
- // =====================================================================
428
- // Public API
429
- // =====================================================================
430
- /**
431
- * Resolve canonical epic ID from filename + frontmatter.
432
- *
433
- * Priority (highest to lowest):
434
- * 1. Frontmatter `id:` field (operator-authoritative)
435
- * 2. `EPIC_FILE_PATTERNS[0]` canonical (numeric or P-prefix)
436
- * 3. `EPIC_FILE_PATTERNS[1]` uppercase numeric
437
- * 4. `EPIC_FILE_PATTERNS[2]` alpha-slug fallback
438
- *
439
- * Returns `null` if no pattern matches AND no frontmatter id.
440
- *
441
- * @param filename basename only (e.g., `epic-P119.md`)
442
- * @param frontmatter parsed frontmatter (may have `id?: string`)
443
- */
444
- export function getEpicIdFromDescriptor(filename, frontmatter) {
445
- // Priority 1: frontmatter id (only if pattern at least matches the
446
- // filename shape -- prevents arbitrary files claiming to be epics)
447
- let patternMatched = false;
448
- let patternId = null;
449
- let patternSource = null;
450
- for (let i = 0; i < EPIC_FILE_PATTERNS.length; i++) {
451
- const re = EPIC_FILE_PATTERNS[i];
452
- const match = filename.match(re);
453
- if (match) {
454
- patternMatched = true;
455
- patternId = match[1];
456
- patternSource = i === 2 ? 'filename-slug' : 'filename-canonical';
457
- break;
458
- }
459
- }
460
- if (!patternMatched) {
461
- // Filename did NOT match any epic pattern -- not an epic at all.
462
- return null;
463
- }
464
- // Frontmatter id wins if present
465
- if (frontmatter.id && frontmatter.id.length > 0) {
466
- return { id: frontmatter.id, source: 'frontmatter' };
467
- }
468
- return { id: patternId, source: patternSource };
469
- }
470
- function getEpicIdFromFilename(filename) {
471
- for (const re of EPIC_FILE_PATTERNS) {
472
- const match = filename.match(re);
473
- if (match)
474
- return match[1];
475
- }
476
- return null;
477
- }
478
- function normalizeGeneratedComparableId(id) {
479
- const trimmed = id.trim();
480
- if (/^\d+(?:\.\d+)*$/i.test(trimmed))
481
- return `P${trimmed}`.toUpperCase();
482
- return trimmed.toUpperCase();
483
- }
484
- function canonicalEpicFilenameFor(id) {
485
- const normalized = normalizeGeneratedComparableId(id);
486
- return /^P\d+(?:\.\d+)?$/i.test(normalized)
487
- ? `epic-${normalized}.md`
488
- : `epic-${id}.md`;
489
- }
490
- function canonicalStoryFilenameFor(id) {
491
- const normalized = normalizeGeneratedComparableId(id);
492
- return /^P\d+\.\d+(?:\.\d+)?$/i.test(normalized)
493
- ? `${normalized}.story.md`
494
- : `${id}.story.md`;
495
- }
496
- function idsDiffer(frontmatterId, filenameId) {
497
- if (!frontmatterId || !filenameId)
498
- return false;
499
- return normalizeGeneratedComparableId(frontmatterId) !== normalizeGeneratedComparableId(filenameId);
500
- }
501
- function epicMismatchWarning(filename, frontmatterId, filenameId) {
502
- return `frontmatter/filename mismatch: epic '${filename}' has frontmatter id '${frontmatterId}' but filename implies '${filenameId}'. Repair by using generated canonical '${canonicalEpicFilenameFor(frontmatterId)}' or changing frontmatter id to '${filenameId}'.`;
503
- }
504
- function storyMismatchWarning(filename, frontmatterId, filenameId) {
505
- return `frontmatter/filename mismatch: story '${filename}' has frontmatter id '${frontmatterId}' but filename implies '${filenameId}'. Repair by using generated canonical '${canonicalStoryFilenameFor(frontmatterId)}' or changing frontmatter id to '${filenameId}'.`;
506
- }
507
- /**
508
- * Match a story filename and extract its canonical ID.
509
- * Returns null if no story pattern matches.
510
- */
511
- function matchStoryFilename(filename) {
512
- // Pattern [0] canonical: P119.01.story.md
513
- let m = filename.match(STORY_FILE_PATTERNS[0]);
514
- if (m) {
515
- const id = m[1];
516
- // Hint: epic_id is the prefix before the dot (P119.01 -> P119)
517
- const dotIdx = id.indexOf('.');
518
- return {
519
- id,
520
- epic_id_hint: dotIdx > 0 ? id.slice(0, dotIdx) : null,
521
- };
522
- }
523
- // Pattern [1] legacy: story-P104.04.md
524
- m = filename.match(STORY_FILE_PATTERNS[1]);
525
- if (m) {
526
- const id = m[1];
527
- const dotIdx = id.indexOf('.');
528
- return {
529
- id,
530
- epic_id_hint: dotIdx > 0 ? id.slice(0, dotIdx) : null,
531
- };
532
- }
533
- // Pattern [2] uppercase: EPIC-01.STORY-02.md
534
- m = filename.match(STORY_FILE_PATTERNS[2]);
535
- if (m) {
536
- const epicNum = m[1];
537
- const storyNum = m[2];
538
- return {
539
- id: `${epicNum}.${storyNum}`,
540
- epic_id_hint: epicNum,
541
- };
542
- }
543
- return null;
544
- }
545
- /**
546
- * Locate the stories directory adjacent to the epics directory.
547
- * Convention: `<dir>/../stories/`. Returns the path string regardless of
548
- * existence (caller checks via fs adapter).
549
- */
550
- function inferStoriesDir(epicsDir, fs) {
551
- return fs.join(epicsDir, '..', 'stories');
552
- }
553
- /**
554
- * Scan `dir` for epic markdown files and return discovered descriptors.
555
- *
556
- * Behavior:
557
- * - Non-recursive scan of `dir/*.md`
558
- * - Sibling stories directory at `<dir>/../stories/*.story.md` (if exists)
559
- * - Files with prefix `_` or `.` are skipped (drafts, hidden)
560
- * - Subdirectories listed in `IGNORED_SUBDIRS` are not recursed
561
- * - Frontmatter parser extracts `id`, `title`, `status`, `epic_id`
562
- * - ID derivation: frontmatter > canonical regex > slug fallback
563
- * - Slug collision: 2nd file with same canonical ID gets `-2`, `-3` suffix + warning
564
- * - Story-to-epic linking: prefer frontmatter `epic_id` field, fallback filename pattern
565
- * - Default fs adapter wraps `node:fs` synchronous methods (lazy-loaded)
566
- *
567
- * @param dir absolute or relative path to the epics directory
568
- * @param fs optional fs adapter; defaults to `node:fs` wrapper (lazy)
569
- * @returns DiscoveryResult with epics, warnings, skipped arrays
570
- */
571
- export function discoverEpicsFromDir(dir, fs) {
572
- const warnings = [];
573
- const skipped = [];
574
- const epics = [];
575
- const discoveredAt = new Date().toISOString();
576
- // Defensive guard: dir must be a non-empty string
577
- if (typeof dir !== 'string' || dir.length === 0) {
578
- return Object.freeze({
579
- epics: Object.freeze([]),
580
- warnings: Object.freeze([]),
581
- skipped: Object.freeze([]),
582
- });
583
- }
584
- // Skip discovery if dir doesn't exist (fail-open)
585
- if (!fs.isDirectory(dir)) {
586
- return Object.freeze({
587
- epics: Object.freeze([]),
588
- warnings: Object.freeze([]),
589
- skipped: Object.freeze([]),
590
- });
591
- }
592
- const entries = [...fs.readdir(dir)].sort();
593
- const candidates = [];
594
- for (const entry of entries) {
595
- const fullPath = fs.join(dir, entry);
596
- if (isIgnoredFile(entry)) {
597
- skipped.push(`${entry}: ignored (draft or hidden prefix)`);
598
- continue;
599
- }
600
- if (fs.isDirectory(fullPath)) {
601
- if (isIgnoredSubdir(entry)) {
602
- skipped.push(`${entry}: ignored subdirectory`);
603
- continue;
604
- }
605
- // Other subdirs: not recursed (P120 is non-recursive)
606
- skipped.push(`${entry}: subdirectory not recursed`);
607
- continue;
608
- }
609
- if (!entry.endsWith('.md')) {
610
- // Not markdown -- silently skip
611
- continue;
612
- }
613
- // Quick filename match check before parsing frontmatter
614
- const matchesEpic = EPIC_FILE_PATTERNS.some((re) => re.test(entry));
615
- if (!matchesEpic) {
616
- skipped.push(`${entry}: did not match any EPIC_FILE_PATTERNS`);
617
- continue;
618
- }
619
- const content = fs.readFile(fullPath);
620
- const parsed = parseDescriptorMetadata(content, entry);
621
- warnings.push(...parsed.warnings);
622
- const metadata = parsed.metadata;
623
- const filenameId = getEpicIdFromFilename(entry);
624
- if (metadata.id && idsDiffer(metadata.id, filenameId)) {
625
- warnings.push(parsed.source === 'frontmatter'
626
- ? epicMismatchWarning(entry, metadata.id, filenameId)
627
- : `legacy metadata/filename mismatch: epic '${entry}' id does not match its filename`);
628
- }
629
- candidates.push({ filename: entry, fullPath, metadata, metadataSource: parsed.source });
630
- }
631
- // ID resolution + collision tracking
632
- const idCount = new Map();
633
- const resolved = [];
634
- for (const candidate of candidates) {
635
- const idResult = getEpicIdFromDescriptor(candidate.filename, candidate.metadata);
636
- if (idResult === null) {
637
- // Should not happen since we pre-filtered, but defensive
638
- skipped.push(`${candidate.filename}: id resolution returned null`);
639
- continue;
640
- }
641
- let finalId = idResult.id;
642
- // Collision suffix only for filename-slug source (frontmatter / canonical
643
- // are already operator-deterministic; collisions there are user error)
644
- const baseId = finalId;
645
- const prevCount = idCount.get(baseId) ?? 0;
646
- if (prevCount > 0) {
647
- finalId = `${baseId}-${prevCount + 1}`;
648
- warnings.push(`epic ID collision: '${baseId}' resolved to '${finalId}' for ${candidate.filename}`);
649
- }
650
- idCount.set(baseId, prevCount + 1);
651
- resolved.push({
652
- candidate,
653
- finalId,
654
- idSource: candidate.metadataSource === 'legacy-metadata' && candidate.metadata.id
655
- ? 'legacy-metadata'
656
- : idResult.source,
657
- });
658
- }
659
- // Discover stories from sibling stories/ dir
660
- const storiesByEpic = new Map();
661
- const storiesDir = inferStoriesDir(dir, fs);
662
- if (fs.isDirectory(storiesDir)) {
663
- const storyEntries = [...fs.readdir(storiesDir)].sort();
664
- for (const entry of storyEntries) {
665
- if (isIgnoredFile(entry))
666
- continue;
667
- if (!entry.endsWith('.md'))
668
- continue;
669
- const fullPath = fs.join(storiesDir, entry);
670
- if (fs.isDirectory(fullPath))
671
- continue;
672
- const storyMatch = matchStoryFilename(entry);
673
- if (!storyMatch)
674
- continue;
675
- const content = fs.readFile(fullPath);
676
- const parsed = parseDescriptorMetadata(content, entry);
677
- warnings.push(...parsed.warnings);
678
- const fm = parsed.metadata;
679
- if (fm.id && idsDiffer(fm.id, storyMatch.id)) {
680
- warnings.push(parsed.source === 'frontmatter'
681
- ? storyMismatchWarning(entry, fm.id, storyMatch.id)
682
- : `legacy metadata/filename mismatch: story '${entry}' id does not match its filename`);
683
- }
684
- // Link priority: frontmatter epic_id > filename pattern hint
685
- const epicId = fm.epic_id ?? storyMatch.epic_id_hint;
686
- if (!epicId) {
687
- warnings.push(`story '${entry}' has no epic_id (frontmatter or filename pattern)`);
688
- continue;
689
- }
690
- const story = Object.freeze({
691
- id: storyMatch.id,
692
- epic_id: epicId,
693
- filename: entry,
694
- path: fullPath,
695
- ...(fm.title ? { title: fm.title } : {}),
696
- ...(fm.status ? { status: fm.status } : {}),
697
- ...(fm.depends_on ? { depends_on: fm.depends_on } : {}),
698
- ...(fm.files_to_modify ? { files_to_modify: fm.files_to_modify } : {}),
699
- });
700
- if (!storiesByEpic.has(epicId)) {
701
- storiesByEpic.set(epicId, []);
702
- }
703
- storiesByEpic.get(epicId).push(story);
704
- }
705
- }
706
- // Build final EpicDescriptor list
707
- for (const r of resolved) {
708
- const stories = storiesByEpic.get(r.finalId) ?? [];
709
- const desc = Object.freeze({
710
- id: r.finalId,
711
- filename: r.candidate.filename,
712
- path: r.candidate.fullPath,
713
- ...(r.candidate.metadata.title ? { title: r.candidate.metadata.title } : {}),
714
- ...(r.candidate.metadata.status ? { status: r.candidate.metadata.status } : {}),
715
- ...(r.candidate.metadata.total_stories !== undefined ? { total_stories: r.candidate.metadata.total_stories } : {}),
716
- ...(r.candidate.metadata.depends_on ? { depends_on: r.candidate.metadata.depends_on } : {}),
717
- source: 'discovered',
718
- discovered_at: discoveredAt,
719
- stories: Object.freeze([...stories]),
720
- _idSource: r.idSource,
721
- _metadataSource: r.candidate.metadataSource,
722
- });
723
- epics.push(desc);
724
- }
725
- // Identify orphan stories (epic_id points to non-existent epic)
726
- const knownEpicIds = new Set(epics.map((e) => e.id));
727
- for (const [epicId, stories] of storiesByEpic.entries()) {
728
- if (!knownEpicIds.has(epicId)) {
729
- for (const s of stories) {
730
- warnings.push(`orphan story '${s.filename}' references unknown epic '${epicId}'`);
731
- }
732
- }
733
- }
734
- return Object.freeze({
735
- epics: Object.freeze(epics),
736
- warnings: Object.freeze(warnings),
737
- skipped: Object.freeze(skipped),
738
- });
739
- }
740
- function isDoneLikeStoryStatus(status) {
741
- const normalized = normalizePublicStatus(status);
742
- return normalized === 'done' || normalized === 'completed' || normalized === 'merged';
743
- }
744
- function isTerminalEpicStatus(status) {
745
- return isDoneLikeStoryStatus(status);
746
- }
747
- function normalizePublicStatus(status) {
748
- return typeof status === 'string' ? status.trim().toLowerCase().replace(/[_\s]+/g, '-') : '';
749
- }
750
- function isRecord(value) {
751
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
752
- }
753
- function aggregateHardStop(reasonCode) {
754
- throw new Error(`epic-aggregate-writer:${reasonCode}`);
755
- }
756
- function validateAggregateStoryRefs(storyRefs) {
757
- const storyIds = [];
758
- const seen = new Set();
759
- for (const storyRef of storyRefs) {
760
- if (typeof storyRef !== 'string' || storyRef.length === 0) {
761
- aggregateHardStop('epic_aggregate_story_ref_invalid');
762
- }
763
- if (seen.has(storyRef))
764
- aggregateHardStop('epic_aggregate_story_ref_duplicate');
765
- seen.add(storyRef);
766
- storyIds.push(storyRef);
767
- }
768
- return storyIds;
769
- }
770
- function validateAggregateListedStory(epicId, storyId, storiesMap) {
771
- const story = storiesMap[storyId];
772
- if (story === undefined)
773
- aggregateHardStop('epic_aggregate_story_missing');
774
- if (!isRecord(story))
775
- aggregateHardStop('epic_aggregate_story_not_object');
776
- if (story.id !== storyId)
777
- aggregateHardStop('epic_aggregate_story_id_conflict');
778
- if (story.epic_id !== epicId)
779
- aggregateHardStop('epic_aggregate_story_epic_conflict');
780
- return story;
781
- }
782
- function recomputeDiscoveredEpicAggregate(epicId, epicsMap, storiesMap, nowIso, descriptor) {
783
- const epic = epicsMap[epicId];
784
- if (!isRecord(epic))
785
- aggregateHardStop('epic_aggregate_epic_not_object');
786
- if (epic.id !== epicId)
787
- aggregateHardStop('epic_aggregate_epic_id_conflict');
788
- if (!Array.isArray(epic.stories))
789
- aggregateHardStop('epic_aggregate_story_list_not_array');
790
- const storyIds = validateAggregateStoryRefs(epic.stories);
791
- const linkedStories = storyIds.length;
792
- const linkedCompletedStories = storyIds.filter((storyId) => isDoneLikeStoryStatus(validateAggregateListedStory(epicId, storyId, storiesMap).status)).length;
793
- const declaredTotalStories = descriptor?.total_stories;
794
- const legacyTerminalMetadata = descriptor?._metadataSource === 'legacy-metadata' &&
795
- isTerminalEpicStatus(epic.status);
796
- const totalStories = legacyTerminalMetadata
797
- ? declaredTotalStories ?? linkedStories
798
- : linkedStories > 0
799
- ? linkedStories
800
- : declaredTotalStories ?? 0;
801
- const completedStories = legacyTerminalMetadata ||
802
- (linkedStories === 0 && totalStories > 0 && isTerminalEpicStatus(epic.status))
803
- ? totalStories
804
- : linkedCompletedStories;
805
- const allListedStoriesDoneLike = linkedStories > 0 && linkedCompletedStories === linkedStories;
806
- if (isTerminalEpicStatus(epic.status) && !legacyTerminalMetadata &&
807
- !allListedStoriesDoneLike && linkedStories > 0) {
808
- aggregateHardStop('epic_aggregate_terminal_incomplete_conflict');
809
- }
810
- epic.total_stories = totalStories;
811
- epic.completed_stories = completedStories;
812
- if (!isTerminalEpicStatus(epic.status) && allListedStoriesDoneLike) {
813
- epic.status = 'done';
814
- if (epic.completed_at === undefined || epic.completed_at === null) {
815
- epic.completed_at = nowIso;
816
- }
817
- }
818
- }
819
- /**
820
- * Merge `DiscoveryResult` (from `discoverEpicsFromDir`) into a `state` object,
821
- * preserving canonical `*create-epic`-registered entries unless `forceOverwrite`.
822
- *
823
- * **Critical defense**: the non-overwrite guard prevents discovered entries
824
- * from clobbering valuable canonical registrations. Even with `forceOverwrite:
825
- * true`, the function preserves the existing `source` field for audit trail.
826
- *
827
- * **Idempotency**: re-running merge with the same `discovered` + same `state`
828
- * produces identical output (no duplicate stories, timestamps from first run
829
- * preserved). Critical for `/loop` iteration which may re-invoke discovery
830
- * between stories.
831
- *
832
- * **Mutation semantics**: merge work is staged on cloned maps and committed to
833
- * the caller-owned container only after every identity and aggregate invariant
834
- * passes. Validation failures therefore provide strong exception safety.
835
- *
836
- * @param state target state object (`null`/`undefined` ok -- treated as empty)
837
- * @param discovered discovery result (epics + warnings + skipped)
838
- * @param options optional behavior modifiers
839
- * @returns `MergeResult` with mutated state + telemetry counters
840
- *
841
- * @example
842
- * ```ts
843
- * const result = mergeDiscoveredIntoState(state, discovered);
844
- * if (result.skipped.length > 0) {
845
- * console.warn('skipped:', result.skipped);
846
- * }
847
- * console.info(`merged ${result.added.length} epics, ${result.storiesAdded} stories`);
848
- * ```
849
- */
850
- export function mergeDiscoveredIntoState(state, discovered, options) {
851
- // AC4: defensive null guard -- treat null/undefined state as empty containers.
852
- // Existing maps remain untouched until the final two-field commit.
853
- const safeState = state ?? {};
854
- const currentEpics = isRecord(safeState.epics) ? safeState.epics : {};
855
- const currentStories = isRecord(safeState.stories) ? safeState.stories : {};
856
- const epicsMap = structuredClone(currentEpics);
857
- const storiesMap = structuredClone(currentStories);
858
- const added = [];
859
- const skipped = [];
860
- const aggregateEpicIds = new Set();
861
- const aggregateDescriptors = new Map();
862
- let storiesAdded = 0;
863
- const forceOverwrite = options?.forceOverwrite === true;
864
- const nowIso = options?.nowIso ?? new Date().toISOString();
865
- // Track which epic IDs are part of THIS discovery batch (for orphan story detection)
866
- const discoveredEpicIds = new Set();
867
- const discoveredStoryOwners = new Map();
868
- for (const epic of discovered.epics) {
869
- if (!epic.id || discoveredEpicIds.has(epic.id)) {
870
- throw new Error('discovery_epic_identity_conflict');
871
- }
872
- discoveredEpicIds.add(epic.id);
873
- aggregateDescriptors.set(epic.id, epic);
874
- const existingEpic = epicsMap[epic.id];
875
- if (existingEpic !== undefined && (!isRecord(existingEpic) ||
876
- existingEpic.id !== undefined && existingEpic.id !== epic.id)) {
877
- throw new Error('discovery_epic_identity_conflict');
878
- }
879
- for (const story of epic.stories) {
880
- if (!story.id || story.epic_id !== epic.id || discoveredStoryOwners.has(story.id)) {
881
- throw new Error('discovery_story_identity_conflict');
882
- }
883
- discoveredStoryOwners.set(story.id, epic.id);
884
- const existingStory = storiesMap[story.id];
885
- if (existingStory !== undefined && (!isRecord(existingStory) ||
886
- existingStory.id !== undefined && existingStory.id !== story.id ||
887
- existingStory.epic_id !== undefined && existingStory.epic_id !== epic.id)) {
888
- throw new Error('discovery_story_identity_conflict');
889
- }
890
- }
891
- }
892
- // -------------------------------------------------------------------
893
- // Pass 1: merge epics
894
- // -------------------------------------------------------------------
895
- for (const epic of discovered.epics) {
896
- const existing = epicsMap[epic.id];
897
- // Filesystem discovery supplies descriptors, never physical Git authority.
898
- // Preserve lifecycle state even when legacy records have no source marker.
899
- const lifecycle = existing;
900
- if (lifecycle && (lifecycle.branch_mode === 'epic' || typeof lifecycle.execution_branch === 'string')) {
901
- skipped.push({ id: epic.id, reason: 'epic Git lifecycle preserved' });
902
- continue;
903
- }
904
- // AC2: non-overwrite guard (default behavior)
905
- if (existing && existing.source === 'create-epic' && !forceOverwrite) {
906
- skipped.push({
907
- id: epic.id,
908
- reason: 'create-epic source preserved (defense)',
909
- });
910
- continue;
911
- }
912
- // Existing state owns lifecycle and unknown fields regardless of provenance.
913
- // Discovery may only enrich bounded specification metadata.
914
- if (existing) {
915
- const existingRecord = existing;
916
- existingRecord.depends_on = epic.depends_on ?? [];
917
- const existingStoryIds = Array.isArray(existingRecord.stories)
918
- ? existingRecord.stories.filter((id) => typeof id === 'string')
919
- : [];
920
- existingRecord.stories = [...new Set([
921
- ...existingStoryIds,
922
- ...epic.stories.map((story) => story.id),
923
- ])];
924
- if (epic.title !== undefined)
925
- existingRecord.title = epic.title;
926
- if (existing.source !== 'create-epic')
927
- aggregateEpicIds.add(epic.id);
928
- // Already merged previously -- skip without churning timestamps
929
- // (NOT in skipped[] because this is normal idempotent flow, not a
930
- // defense rejection)
931
- continue;
932
- }
933
- // Build new entry (or overwrite when forceOverwrite is set)
934
- const newEntry = {
935
- id: epic.id,
936
- title: epic.title ?? '',
937
- status: epic.status ?? 'planning',
938
- source: 'discovered',
939
- discovered_at: nowIso,
940
- merged_at: nowIso,
941
- stories: epic.stories.map((s) => s.id),
942
- depends_on: epic.depends_on ?? [],
943
- };
944
- epicsMap[epic.id] = newEntry;
945
- aggregateEpicIds.add(epic.id);
946
- added.push(epic.id);
947
- }
948
- // -------------------------------------------------------------------
949
- // Pass 2: merge stories
950
- // -------------------------------------------------------------------
951
- for (const epic of discovered.epics) {
952
- for (const story of epic.stories) {
953
- const existing = storiesMap[story.id];
954
- const owner = epicsMap[epic.id];
955
- if (existing && owner && (owner.branch_mode === 'epic' || typeof owner.execution_branch === 'string')) {
956
- skipped.push({ id: story.id, reason: 'story Git lifecycle preserved' });
957
- continue;
958
- }
959
- // AC6: story-level non-overwrite guard
960
- if (existing && existing.source === 'create-epic' && !forceOverwrite) {
961
- skipped.push({
962
- id: story.id,
963
- reason: 'create-epic source preserved (defense, story)',
964
- });
965
- continue;
966
- }
967
- // Existing state owns workflow lifecycle even when provenance is absent
968
- // or forceOverwrite is requested. Overlay specification metadata only.
969
- if (existing) {
970
- const existingRecord = existing;
971
- if (story.title !== undefined)
972
- existingRecord.title = story.title;
973
- existingRecord.depends_on = story.depends_on ?? [];
974
- existingRecord.files_to_modify = story.files_to_modify ?? [];
975
- continue;
976
- }
977
- const newStory = {
978
- id: story.id,
979
- epic_id: story.epic_id,
980
- title: story.title ?? '',
981
- // Filesystem frontmatter is specification, never workflow authority.
982
- status: 'backlog',
983
- source: 'discovered',
984
- discovered_at: nowIso,
985
- depends_on: story.depends_on ?? [],
986
- files_to_modify: story.files_to_modify ?? [],
987
- };
988
- storiesMap[story.id] = newStory;
989
- storiesAdded++;
990
- }
991
- }
992
- // -------------------------------------------------------------------
993
- // Pass 3: P189.04 aggregate writer invariants
994
- // -------------------------------------------------------------------
995
- for (const epicId of aggregateEpicIds) {
996
- recomputeDiscoveredEpicAggregate(epicId, epicsMap, storiesMap, nowIso, aggregateDescriptors.get(epicId));
997
- }
998
- // Publish only after the full batch and all aggregate invariants pass.
999
- safeState.epics = epicsMap;
1000
- safeState.stories = storiesMap;
1001
- return {
1002
- state: safeState,
1003
- added: Object.freeze([...added]),
1004
- skipped: Object.freeze([...skipped]),
1005
- storiesAdded,
1006
- };
1007
- }
1
+ const ge=Object.freeze({epicId:"P{number}",epicFilename:"docs/epics/epic-P{number}.md",storyId:"P{number}.NN",storyFilename:"docs/stories/P{number}.NN.story.md",acceptedOnly:Object.freeze({epicFilenames:Object.freeze(["docs/epics/epic-{number}.md","docs/epics/EPIC-{number}-{slug}.md","docs/epics/EPIC-{number}_{slug}.md","docs/epics/epic-{slug}.md"]),storyFilenames:Object.freeze(["docs/stories/{number}.NN.story.md","docs/stories/story-P{number}.NN.md","docs/stories/story-{number}.NN.md","docs/stories/EPIC-{number}.STORY-{number}.md"])})}),O=Object.freeze([/^epic-(P?\d+(?:\.\d+)?)\.md$/i,/^EPIC-(\d+)(?:[-_].*)?\.md$/,/^epic-([a-z0-9][a-z0-9_-]*)\.md$/i]),D=Object.freeze([/^(P?\d+\.\d+(?:\.\d+)?)\.story\.md$/i,/^story-(P?\d+\.\d+)\.md$/i,/^EPIC-(\d+)\.STORY-(\d+)\.md$/]),k=Object.freeze([".archive","_archive","_archives","_drafts","archive","archives","archived","drafts"]),F=/^---\s*\n([\s\S]*?)\n---/;function z(t,e){const i=new RegExp(`^${e}:\\s*(.+?)\\s*$`,"mi"),n=t.match(i);if(!n)return;let c=n[1].trim();return(c.startsWith('"')&&c.endsWith('"')||c.startsWith("'")&&c.endsWith("'"))&&(c=c.slice(1,-1)),c||void 0}function G(t){if(typeof t!="string"||t.length===0)return{};const e=t.match(F);if(!e)return{};const i=e[1];return{id:z(i,"id"),title:z(i,"title"),status:z(i,"status"),epic_id:z(i,"epic_id"),depends_on:te(i),files_to_modify:P(i,"files_to_modify"),total_stories:x(z(i,"total_stories"))}}const Y=32,B=4096,H=/^\s*\*\*([^*\r\n]+?):\*\*\s*(.*?)\s*$/,U=new Set(["backlog","pending","ready","planning","in-progress","done","completed","merged","cancelled","canceled","abandoned","draft","template","archived","branch-ready","diagnose","research","write-spec","create-tasks","implemented","refactor-suggest-done","property-tests-emitted","tests-validated","qa-gate-passed","memory-updated","committed","synced","pr-created","pr-reviewed","partial"]);function L(t){let e=t.trim();return e.startsWith("`")&&e.endsWith("`")&&e.length>=2&&(e=e.slice(1,-1).trim()),(e.startsWith('"')&&e.endsWith('"')||e.startsWith("'")&&e.endsWith("'"))&&(e=e.slice(1,-1).trim()),e}function x(t){if(t===void 0||!/^\d{1,5}$/.test(t.trim()))return;const e=Number(t);return Number.isSafeInteger(e)?e:void 0}function Z(t){const e=L(t).toLowerCase().replace(/[_\s]+/g,"-"),n={planned:"planning",complete:"completed",closed:"done",finished:"done"}[e]??e;return U.has(n)?n:void 0}function X(t){const e=t.trim().toLowerCase().replace(/[_-]+/g," ").replace(/\s+/g," ");if(e==="id"||e==="epic id")return"id";if(e==="title"||e==="titulo")return"title";if(e==="status")return"status";if(e==="total stories"||e==="total de stories")return"total_stories"}function q(t){const e=t.find(c=>/^#\s+\S/.test(c));if(!e)return;const i=e.replace(/^#\s+/,"").trim();return(i.replace(/^(?:Epic|Story)\s+\S+\s*(?:(?:--?|:)|[\u2013\u2014])\s*/i,"")||i).slice(0,200)}function j(t,e,i,n){const c=[...new Set(t.map(L).filter(Boolean))];if(c.length>1){n.push(`legacy metadata ambiguous for '${e}' in '${i}': conflicting values ignored`);return}return c[0]}function K(t,e){const i=[],n=[],c=t.slice(0,B).split(/\r?\n/,Y);for(const m of c){if(n.length>0&&(/^\s*---\s*$/.test(m)||/^##\s+/.test(m)))break;n.push(m)}const o=new Map;for(const m of n){const r=m.match(H);if(!r)continue;const a=X(r[1]);if(!a)continue;const d=o.get(a)??[];d.push(r[2]),o.set(a,d)}const f=j(o.get("id")??[],"id",e,i),l=j(o.get("title")??[],"title",e,i),_=j(o.get("status")??[],"status",e,i),g=j(o.get("total_stories")??[],"total_stories",e,i),p=_===void 0?void 0:Z(_);_!==void 0&&p===void 0&&i.push(`legacy metadata status unrecognized in '${e}': value ignored`);const b=x(g);g!==void 0&&b===void 0&&i.push(`legacy metadata total_stories invalid in '${e}': value ignored`);const $=f&&/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(f)?f:void 0;f!==void 0&&$===void 0&&i.push(`legacy metadata id invalid in '${e}': value ignored`);const s=q(n),u=l??s;return{metadata:{...$?{id:$}:{},...u?{title:u.slice(0,200)}:{},...p?{status:p}:{},...b!==void 0?{total_stories:b}:{}},source:"legacy-metadata",warnings:Object.freeze(i)}}function V(t){return/^[A-Za-z0-9._-]{1,160}$/.test(t)?t:"markdown-target.md"}function J(t){return new TextEncoder().encode(t).length}function Q(t){let e=!1;for(const i of t.split(/\r?\n/)){if(!i.trim()||i.trimStart().startsWith("#"))continue;const n=i.match(/^\s+-\s+(.+?)\s*$/);if(n){if(!e)return!0;const f=n[1].trim();if(f.startsWith('"')&&!f.endsWith('"')||f.startsWith("'")&&!f.endsWith("'"))return!0;continue}if(/^\s+/.test(i))return!0;const c=i.match(/^[A-Za-z0-9_-]+:\s*(.*?)\s*$/);if(!c)return!0;const o=c[1].trim();if(e=o.length===0,o.startsWith('"')&&!o.endsWith('"')||o.startsWith("'")&&!o.endsWith("'"))return!0}return!1}function ee(t,e,i){const n=V(e),c=t.match(F);if(!c)return Object.freeze([`metadata-frontmatter reason=frontmatter-malformed file='${n}': repair the bounded leading YAML envelope; source content omitted`]);const o=c[1];return J(o)>8*1024||o.split(/\r?\n/).length>128?Object.freeze([`metadata-frontmatter reason=frontmatter-oversized file='${n}': reduce the leading YAML envelope; source content omitted`]):Q(o)||o.trim().length>0&&Object.keys(i).length===0?Object.freeze([`metadata-frontmatter reason=frontmatter-unsupported-value file='${n}': use bounded scalar fields or string lists; source content omitted`]):Object.freeze([])}function N(t,e){if(/^---(?:\s*\r?\n|\s*$)/.test(t)){const i=G(t);return{metadata:i,source:"frontmatter",warnings:ee(t,e,i)}}return typeof t!="string"||t.length===0?{metadata:{},source:"none",warnings:Object.freeze([])}:K(t,e)}function R(t){let e=t.trim();if((e.startsWith('"')&&e.endsWith('"')||e.startsWith("'")&&e.endsWith("'"))&&(e=e.slice(1,-1).trim()),!!e&&!/^(null|undefined|true|false)$/i.test(e))return e}function te(t){return P(t,"depends_on")}function P(t,e){const i=e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),c=t.match(new RegExp(`^${i}:[ \\t]*(.+?)[ \\t]*$`,"mi"))?.[1]?.trim();if(c!==void 0){const g=(c.startsWith("[")&&c.endsWith("]")?c.slice(1,-1).split(","):[c]).map(R).filter(p=>typeof p=="string"&&p.length>0);return g.length>0?Object.freeze(g):void 0}const o=t.split(/\r?\n/),f=[];let l=!1;for(const _ of o){if(!l){new RegExp(`^${i}:\\s*$`,"i").test(_.trim())&&(l=!0);continue}if(/^[A-Za-z0-9_-]+:\s*/.test(_))break;const g=_.match(/^\s*-\s*(.+?)\s*$/);if(!g)continue;const p=R(g[1]);p&&f.push(p)}return f.length>0?Object.freeze(f):void 0}function T(t){return t.startsWith("_")||t.startsWith(".")}function ie(t){return k.includes(t)}async function he(){const t=await import("node:fs"),e=await import("node:path");return{isDirectory(i){try{return t.statSync(i).isDirectory()}catch{return!1}},readdir(i){try{return t.readdirSync(i)}catch{return[]}},readFile(i){try{return t.readFileSync(i,"utf8")}catch{return""}},join(...i){try{return e.join(...i)}catch{return i.join("/")}}}}function ne(t,e){let i=!1,n=null,c=null;for(let o=0;o<O.length;o++){const f=O[o],l=t.match(f);if(l){i=!0,n=l[1],c=o===2?"filename-slug":"filename-canonical";break}}return i?e.id&&e.id.length>0?{id:e.id,source:"frontmatter"}:{id:n,source:c}:null}function se(t){for(const e of O){const i=t.match(e);if(i)return i[1]}return null}function I(t){const e=t.trim();return/^\d+(?:\.\d+)*$/i.test(e)?`P${e}`.toUpperCase():e.toUpperCase()}function re(t){const e=I(t);return/^P\d+(?:\.\d+)?$/i.test(e)?`epic-${e}.md`:`epic-${t}.md`}function oe(t){const e=I(t);return/^P\d+\.\d+(?:\.\d+)?$/i.test(e)?`${e}.story.md`:`${t}.story.md`}function C(t,e){return!t||!e?!1:I(t)!==I(e)}function ce(t,e,i){return`frontmatter/filename mismatch: epic '${t}' has frontmatter id '${e}' but filename implies '${i}'. Repair by using generated canonical '${re(e)}' or changing frontmatter id to '${i}'.`}function ae(t,e,i){return`frontmatter/filename mismatch: story '${t}' has frontmatter id '${e}' but filename implies '${i}'. Repair by using generated canonical '${oe(e)}' or changing frontmatter id to '${i}'.`}function de(t){let e=t.match(D[0]);if(e){const i=e[1],n=i.indexOf(".");return{id:i,epic_id_hint:n>0?i.slice(0,n):null}}if(e=t.match(D[1]),e){const i=e[1],n=i.indexOf(".");return{id:i,epic_id_hint:n>0?i.slice(0,n):null}}if(e=t.match(D[2]),e){const i=e[1],n=e[2];return{id:`${i}.${n}`,epic_id_hint:i}}return null}function ue(t,e){return e.join(t,"..","stories")}function _e(t,e){const i=[],n=[],c=[],o=new Date().toISOString();if(typeof t!="string"||t.length===0)return Object.freeze({epics:Object.freeze([]),warnings:Object.freeze([]),skipped:Object.freeze([])});if(!e.isDirectory(t))return Object.freeze({epics:Object.freeze([]),warnings:Object.freeze([]),skipped:Object.freeze([])});const f=[...e.readdir(t)].sort(),l=[];for(const s of f){const u=e.join(t,s);if(T(s)){n.push(`${s}: ignored (draft or hidden prefix)`);continue}if(e.isDirectory(u)){if(ie(s)){n.push(`${s}: ignored subdirectory`);continue}n.push(`${s}: subdirectory not recursed`);continue}if(!s.endsWith(".md"))continue;if(!O.some(h=>h.test(s))){n.push(`${s}: did not match any EPIC_FILE_PATTERNS`);continue}const m=e.readFile(u),r=N(m,s);i.push(...r.warnings);const a=r.metadata,d=se(s);a.id&&C(a.id,d)&&i.push(r.source==="frontmatter"?ce(s,a.id,d):`legacy metadata/filename mismatch: epic '${s}' id does not match its filename`),l.push({filename:s,fullPath:u,metadata:a,metadataSource:r.source})}const _=new Map,g=[];for(const s of l){const u=ne(s.filename,s.metadata);if(u===null){n.push(`${s.filename}: id resolution returned null`);continue}let y=u.id;const m=y,r=_.get(m)??0;r>0&&(y=`${m}-${r+1}`,i.push(`epic ID collision: '${m}' resolved to '${y}' for ${s.filename}`)),_.set(m,r+1),g.push({candidate:s,finalId:y,idSource:s.metadataSource==="legacy-metadata"&&s.metadata.id?"legacy-metadata":u.source})}const p=new Map,b=ue(t,e);if(e.isDirectory(b)){const s=[...e.readdir(b)].sort();for(const u of s){if(T(u)||!u.endsWith(".md"))continue;const y=e.join(b,u);if(e.isDirectory(y))continue;const m=de(u);if(!m)continue;const r=e.readFile(y),a=N(r,u);i.push(...a.warnings);const d=a.metadata;d.id&&C(d.id,m.id)&&i.push(a.source==="frontmatter"?ae(u,d.id,m.id):`legacy metadata/filename mismatch: story '${u}' id does not match its filename`);const h=d.epic_id??m.epic_id_hint;if(!h){i.push(`story '${u}' has no epic_id (frontmatter or filename pattern)`);continue}const S=Object.freeze({id:m.id,epic_id:h,filename:u,path:y,...d.title?{title:d.title}:{},...d.status?{status:d.status}:{},...d.depends_on?{depends_on:d.depends_on}:{},...d.files_to_modify?{files_to_modify:d.files_to_modify}:{}});p.has(h)||p.set(h,[]),p.get(h).push(S)}}for(const s of g){const u=p.get(s.finalId)??[],y=Object.freeze({id:s.finalId,filename:s.candidate.filename,path:s.candidate.fullPath,...s.candidate.metadata.title?{title:s.candidate.metadata.title}:{},...s.candidate.metadata.status?{status:s.candidate.metadata.status}:{},...s.candidate.metadata.total_stories!==void 0?{total_stories:s.candidate.metadata.total_stories}:{},...s.candidate.metadata.depends_on?{depends_on:s.candidate.metadata.depends_on}:{},source:"discovered",discovered_at:o,stories:Object.freeze([...u]),t:s.idSource,e:s.candidate.metadataSource});c.push(y)}const $=new Set(c.map(s=>s.id));for(const[s,u]of p.entries())if(!$.has(s))for(const y of u)i.push(`orphan story '${y.filename}' references unknown epic '${s}'`);return Object.freeze({epics:Object.freeze(c),warnings:Object.freeze(i),skipped:Object.freeze(n)})}function M(t){const e=fe(t);return e==="done"||e==="completed"||e==="merged"}function A(t){return M(t)}function fe(t){return typeof t=="string"?t.trim().toLowerCase().replace(/[_\s]+/g,"-"):""}function E(t){return!!t&&typeof t=="object"&&!Array.isArray(t)}function w(t){throw new Error(`epic-aggregate-writer:${t}`)}function le(t){const e=[],i=new Set;for(const n of t)(typeof n!="string"||n.length===0)&&w("epic_aggregate_story_ref_invalid"),i.has(n)&&w("epic_aggregate_story_ref_duplicate"),i.add(n),e.push(n);return e}function pe(t,e,i){const n=i[e];return n===void 0&&w("epic_aggregate_story_missing"),E(n)||w("epic_aggregate_story_not_object"),n.id!==e&&w("epic_aggregate_story_id_conflict"),n.epic_id!==t&&w("epic_aggregate_story_epic_conflict"),n}function me(t,e,i,n,c){const o=e[t];E(o)||w("epic_aggregate_epic_not_object"),o.id!==t&&w("epic_aggregate_epic_id_conflict"),Array.isArray(o.stories)||w("epic_aggregate_story_list_not_array");const f=le(o.stories),l=f.length,_=f.filter(u=>M(pe(t,u,i).status)).length,g=c?.total_stories,p=c?.e==="legacy-metadata"&&A(o.status),b=p?g??l:l>0?l:g??0,$=p||l===0&&b>0&&A(o.status)?b:_,s=l>0&&_===l;A(o.status)&&!p&&!s&&l>0&&w("epic_aggregate_terminal_incomplete_conflict"),o.total_stories=b,o.completed_stories=$,!A(o.status)&&s&&(o.status="done",(o.completed_at===void 0||o.completed_at===null)&&(o.completed_at=n))}function ye(t,e,i){const n=t??{},c=E(n.epics)?n.epics:{},o=E(n.stories)?n.stories:{},f=structuredClone(c),l=structuredClone(o),_=[],g=[],p=new Set,b=new Map;let $=0;const s=i?.forceOverwrite===!0,u=i?.nowIso??new Date().toISOString(),y=new Set,m=new Map;for(const r of e.epics){if(!r.id||y.has(r.id))throw new Error("discovery_epic_identity_conflict");y.add(r.id),b.set(r.id,r);const a=f[r.id];if(a!==void 0&&(!E(a)||a.id!==void 0&&a.id!==r.id))throw new Error("discovery_epic_identity_conflict");for(const d of r.stories){if(!d.id||d.epic_id!==r.id||m.has(d.id))throw new Error("discovery_story_identity_conflict");m.set(d.id,r.id);const h=l[d.id];if(h!==void 0&&(!E(h)||h.id!==void 0&&h.id!==d.id||h.epic_id!==void 0&&h.epic_id!==r.id))throw new Error("discovery_story_identity_conflict")}}for(const r of e.epics){const a=f[r.id],d=a;if(d&&(d.branch_mode==="epic"||typeof d.execution_branch=="string")){g.push({id:r.id,reason:"epic Git lifecycle preserved"});continue}if(a&&a.source==="create-epic"&&!s){g.push({id:r.id,reason:"create-epic source preserved (defense)"});continue}if(a){const S=a;S.depends_on=r.depends_on??[];const v=Array.isArray(S.stories)?S.stories.filter(W=>typeof W=="string"):[];S.stories=[...new Set([...v,...r.stories.map(W=>W.id)])],r.title!==void 0&&(S.title=r.title),a.source!=="create-epic"&&p.add(r.id);continue}const h={id:r.id,title:r.title??"",status:r.status??"planning",source:"discovered",discovered_at:u,merged_at:u,stories:r.stories.map(S=>S.id),depends_on:r.depends_on??[]};f[r.id]=h,p.add(r.id),_.push(r.id)}for(const r of e.epics)for(const a of r.stories){const d=l[a.id],h=f[r.id];if(d&&h&&(h.branch_mode==="epic"||typeof h.execution_branch=="string")){g.push({id:a.id,reason:"story Git lifecycle preserved"});continue}if(d&&d.source==="create-epic"&&!s){g.push({id:a.id,reason:"create-epic source preserved (defense, story)"});continue}if(d){const v=d;a.title!==void 0&&(v.title=a.title),v.depends_on=a.depends_on??[],v.files_to_modify=a.files_to_modify??[];continue}const S={id:a.id,epic_id:a.epic_id,title:a.title??"",status:"backlog",source:"discovered",discovered_at:u,depends_on:a.depends_on??[],files_to_modify:a.files_to_modify??[]};l[a.id]=S,$++}for(const r of p)me(r,f,l,u,b.get(r));return n.epics=f,n.stories=l,{state:n,added:Object.freeze([..._]),skipped:Object.freeze([...g]),storiesAdded:$}}export{O as EPIC_FILE_PATTERNS,ge as GENERATED_NAMING_CONTRACT,D as STORY_FILE_PATTERNS,he as buildDefaultDiscoveryFs,_e as discoverEpicsFromDir,ne as getEpicIdFromDescriptor,ye as mergeDiscoveredIntoState};