@ornexus/neocortex 4.60.68 → 4.60.70

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 (232) hide show
  1. package/dist/release-source.json +1 -1
  2. package/dist/sbom.cdx.json +1194 -1194
  3. package/install.ps1 +1 -1
  4. package/install.sh +1 -1
  5. package/package.json +3 -3
  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 +3 -290
  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.d.ts +6 -0
  38. package/packages/client/dist/commands/invoke.js +142 -7140
  39. package/packages/client/dist/commands/legacy-epic-terminal.js +1 -71
  40. package/packages/client/dist/commands/legacy-story-branch.js +1 -96
  41. package/packages/client/dist/commands/legacy-story-pr.js +1 -276
  42. package/packages/client/dist/commands/mutation-qa-checkpoint.js +5 -285
  43. package/packages/client/dist/commands/mutation-qa-gate.js +1 -54
  44. package/packages/client/dist/commands/mutation-qa-git.js +1 -41
  45. package/packages/client/dist/commands/mutation-qa-policy.js +1 -102
  46. package/packages/client/dist/commands/mutation-qa-run.js +3 -219
  47. package/packages/client/dist/commands/mutation-qa-verdict.js +1 -149
  48. package/packages/client/dist/commands/mutation-qa.js +1 -220
  49. package/packages/client/dist/commands/native-agent.js +2 -136
  50. package/packages/client/dist/commands/planning-diagnosis.js +1 -43
  51. package/packages/client/dist/commands/planning-generation.js +1 -53
  52. package/packages/client/dist/commands/planning-memory.js +1 -40
  53. package/packages/client/dist/commands/planning-research.js +1 -42
  54. package/packages/client/dist/commands/private-session.js +1 -75
  55. package/packages/client/dist/commands/property-qa.js +1 -139
  56. package/packages/client/dist/commands/qa-control.js +1 -16
  57. package/packages/client/dist/commands/qa-scan-browser-launch.js +1 -55
  58. package/packages/client/dist/commands/qa-scan-browser-route.js +1 -28
  59. package/packages/client/dist/commands/qa-scan-capture.js +5 -663
  60. package/packages/client/dist/commands/qa-scan-discovery.js +3 -376
  61. package/packages/client/dist/commands/qa-scan-review.js +5 -826
  62. package/packages/client/dist/commands/qa-synthetic-backend-capture.js +1 -266
  63. package/packages/client/dist/commands/qa-synthetic-backend-review.js +1 -297
  64. package/packages/client/dist/commands/qa-synthetic-backend.js +2 -340
  65. package/packages/client/dist/commands/qa-synthetic-broker-port.js +1 -85
  66. package/packages/client/dist/commands/qa-synthetic-browser.js +1 -39
  67. package/packages/client/dist/commands/refactor-characterization.js +21 -671
  68. package/packages/client/dist/commands/refactor-commit.js +13 -399
  69. package/packages/client/dist/commands/refactor-control.js +3 -142
  70. package/packages/client/dist/commands/refactor-cpp-assertions.js +3 -93
  71. package/packages/client/dist/commands/refactor-review.js +5 -283
  72. package/packages/client/dist/commands/refactor-root.js +7 -588
  73. package/packages/client/dist/commands/refresh-memory.js +3 -156
  74. package/packages/client/dist/commands/shared-architecture-non-ui.js +1 -24
  75. package/packages/client/dist/commands/story-checkpoint.js +1 -171
  76. package/packages/client/dist/config/profile-directory.js +1 -13
  77. package/packages/client/dist/config/resolver-selection.js +1 -278
  78. package/packages/client/dist/config/secure-config.js +12 -281
  79. package/packages/client/dist/config.js +2 -223
  80. package/packages/client/dist/constants.js +1 -25
  81. package/packages/client/dist/context/context-collector.js +2 -222
  82. package/packages/client/dist/context/context-sanitizer.js +1 -145
  83. package/packages/client/dist/continuity/autonomous-governance.js +1 -285
  84. package/packages/client/dist/continuity/continuity-client-state-store.js +1 -1587
  85. package/packages/client/dist/continuity/invoke-hooks.js +1 -129
  86. package/packages/client/dist/continuity/migrations/001-initial-schema.js +2 -12
  87. package/packages/client/dist/continuity/migrations/002-processing-output-summary-indexes.js +2 -12
  88. package/packages/client/dist/continuity/migrations/003-headless-execution-attempts.js +2 -12
  89. package/packages/client/dist/continuity/migrations/004-outbox-delivery-state.js +2 -12
  90. package/packages/client/dist/continuity/migrations/005-bounded-headless-queue.js +2 -12
  91. package/packages/client/dist/continuity/migrations/006-native-agent-dispatches.js +2 -10
  92. package/packages/client/dist/continuity/native-agent-store.js +1 -69
  93. package/packages/client/dist/continuity/native-agent-types.js +1 -90
  94. package/packages/client/dist/continuity/outbox.js +1 -117
  95. package/packages/client/dist/continuity/runner-bootstrap-policy.js +1 -172
  96. package/packages/client/dist/continuity/sqlite-store.js +81 -2536
  97. package/packages/client/dist/effects/invoke-effect-gateways.js +1 -62
  98. package/packages/client/dist/errors/error-messages.js +2 -228
  99. package/packages/client/dist/errors/frontmatter-diagnostics.js +1 -125
  100. package/packages/client/dist/evidence/collector.js +1 -347
  101. package/packages/client/dist/evidence/index.js +1 -1
  102. package/packages/client/dist/evidence/types.js +0 -2
  103. package/packages/client/dist/fs/atomic-state-replace.js +1 -152
  104. package/packages/client/dist/generated/command-registry.generated.js +1 -4076
  105. package/packages/client/dist/generated/public-projections.generated.js +1 -149
  106. package/packages/client/dist/git/direct-yolo-c01-retry.js +4 -138
  107. package/packages/client/dist/git/direct-yolo-checkpoint.js +4 -1234
  108. package/packages/client/dist/git/git-lifecycle-step-ids.js +1 -14
  109. package/packages/client/dist/git/git-lifecycle-store.js +9 -1938
  110. package/packages/client/dist/git/legacy-story-branch-checkpoint.js +9 -259
  111. package/packages/client/dist/git/legacy-story-branch-setup.js +4 -348
  112. package/packages/client/dist/git/legacy-story-pr.js +70 -3538
  113. package/packages/client/dist/git/secure-filesystem-boundary.js +5 -182
  114. package/packages/client/dist/graph-retrieval/pre-command-hook.js +1 -284
  115. package/packages/client/dist/graph-retrieval/shared-graph-retrieval-contract.js +1 -237
  116. package/packages/client/dist/i18n/first-run.js +2 -60
  117. package/packages/client/dist/index.js +1 -47
  118. package/packages/client/dist/invocation/invoke-input.js +1 -24
  119. package/packages/client/dist/license/index.js +1 -5
  120. package/packages/client/dist/license/license-client.js +1 -304
  121. package/packages/client/dist/machine/fingerprint.js +2 -163
  122. package/packages/client/dist/machine/index.js +1 -5
  123. package/packages/client/dist/memory/project-memory-path-scope.js +1 -62
  124. package/packages/client/dist/memory/project-memory-writer.js +40 -1007
  125. package/packages/client/dist/memory/shared-project-memory-types.js +2 -253
  126. package/packages/client/dist/migrations/project-identity-capability.js +1 -50
  127. package/packages/client/dist/migrations/project-identity.js +7 -493
  128. package/packages/client/dist/migrations/state-json.js +4 -344
  129. package/packages/client/dist/policy/architecture-policy.js +2 -184
  130. package/packages/client/dist/policy/index.js +1 -7
  131. package/packages/client/dist/policy/shared-policy-types.js +0 -38
  132. package/packages/client/dist/policy/subagent-approval-policy.js +1 -55
  133. package/packages/client/dist/private-execution/clean-baseline.js +2 -279
  134. package/packages/client/dist/private-execution/codex-executor.js +4 -574
  135. package/packages/client/dist/private-execution/codex-host-runtime.js +1 -137
  136. package/packages/client/dist/private-execution/codex-lifecycle-guard.js +1 -97
  137. package/packages/client/dist/private-execution/codex-scoped-permissions.js +1 -128
  138. package/packages/client/dist/private-execution/delivery.js +13 -410
  139. package/packages/client/dist/private-execution/diagnosis-root-review.js +2 -294
  140. package/packages/client/dist/private-execution/frozen-handoff.js +6 -19
  141. package/packages/client/dist/private-execution/implementation-validation.js +2 -106
  142. package/packages/client/dist/private-execution/isolated-admission.js +23 -1432
  143. package/packages/client/dist/private-execution/isolated-session.js +5 -725
  144. package/packages/client/dist/private-execution/isolated-workspace.js +1 -122
  145. package/packages/client/dist/private-execution/pact-consumer-runtime.js +3 -409
  146. package/packages/client/dist/private-execution/pact-test-source.js +1 -258
  147. package/packages/client/dist/private-execution/pact-toolchain.js +3 -290
  148. package/packages/client/dist/private-execution/pact-write-set.js +1 -172
  149. package/packages/client/dist/private-execution/planning-diagnosis-checkpoint.js +5 -406
  150. package/packages/client/dist/private-execution/planning-generation-checkpoint.js +5 -462
  151. package/packages/client/dist/private-execution/planning-memory-checkpoint.js +3 -139
  152. package/packages/client/dist/private-execution/planning-research-checkpoint.js +4 -325
  153. package/packages/client/dist/private-execution/private-report-store.js +1 -42
  154. package/packages/client/dist/private-execution/property-test-candidate.js +1 -103
  155. package/packages/client/dist/private-execution/property-test-choices.js +3 -147
  156. package/packages/client/dist/private-execution/property-test-plan.js +7 -271
  157. package/packages/client/dist/private-execution/property-test-review.js +4 -258
  158. package/packages/client/dist/private-execution/property-test-runtime.js +9 -372
  159. package/packages/client/dist/private-execution/property-test-source.js +1 -139
  160. package/packages/client/dist/private-execution/root-checkpoint.js +1 -80
  161. package/packages/client/dist/private-execution/root-memory-git-evidence.js +3 -135
  162. package/packages/client/dist/private-execution/root-memory-recovery.js +1 -93
  163. package/packages/client/dist/private-execution/root-memory-request.js +1 -29
  164. package/packages/client/dist/private-execution/root-memory-scope.js +1 -178
  165. package/packages/client/dist/private-execution/root-memory-writer.js +1 -102
  166. package/packages/client/dist/private-execution/session-lease.js +1 -63
  167. package/packages/client/dist/private-execution/shared-private-admission-types.js +1 -50
  168. package/packages/client/dist/private-execution/shared-project-validation.js +1 -66
  169. package/packages/client/dist/private-execution/shared-root-memory-types.js +1 -61
  170. package/packages/client/dist/private-execution/ux-source-validation.js +1 -372
  171. package/packages/client/dist/private-execution/ux-write-set.js +2 -191
  172. package/packages/client/dist/resilience/circuit-breaker.js +1 -170
  173. package/packages/client/dist/resilience/degradation-manager.js +1 -164
  174. package/packages/client/dist/resilience/freshness-indicator.js +1 -100
  175. package/packages/client/dist/resilience/index.js +1 -8
  176. package/packages/client/dist/resilience/recovery-detector.js +1 -74
  177. package/packages/client/dist/resolvers/asset-resolver.js +0 -13
  178. package/packages/client/dist/resolvers/local-resolver.js +8 -255
  179. package/packages/client/dist/resolvers/remote-resolver.js +1 -233
  180. package/packages/client/dist/runner/binary.js +1 -404
  181. package/packages/client/dist/runner/cli.js +17 -1571
  182. package/packages/client/dist/runner/headless/adapters.js +1 -49
  183. package/packages/client/dist/runner/headless/claude-code-adapter.js +1 -79
  184. package/packages/client/dist/runner/headless/continuous-story-workspace.js +11 -1628
  185. package/packages/client/dist/runner/headless/epic-git-reconciliation-inventory.js +1 -69
  186. package/packages/client/dist/runner/headless/epic-merge-candidate-qa.js +2 -110
  187. package/packages/client/dist/runner/headless/epic-terminal-git-cli-adapter.js +8 -1236
  188. package/packages/client/dist/runner/headless/epic-terminal-git-runner.js +2 -318
  189. package/packages/client/dist/runner/headless/fake-adapter.js +1 -62
  190. package/packages/client/dist/runner/headless/managed-profile.js +1 -132
  191. package/packages/client/dist/runner/headless/opencode-adapter.js +2 -145
  192. package/packages/client/dist/runner/headless/prepared-story-bundle.js +1 -43
  193. package/packages/client/dist/runner/headless/resolver.js +1 -231
  194. package/packages/client/dist/runner/headless/run-yolo.js +1 -885
  195. package/packages/client/dist/runner/headless/run-yoloop.js +2 -2574
  196. package/packages/client/dist/runner/headless/types.js +2 -574
  197. package/packages/client/dist/runner/local-console.js +13 -396
  198. package/packages/client/dist/runner/portal-sync-worker.js +1 -1487
  199. package/packages/client/dist/runner/project-identity.js +1 -106
  200. package/packages/client/dist/runner/scheduler.js +7 -995
  201. package/packages/client/dist/runner-cli.js +2 -18
  202. package/packages/client/dist/state/invocation-shape.js +1 -13
  203. package/packages/client/dist/state/project-state-snapshot.js +1 -1677
  204. package/packages/client/dist/state/state-json-repair.js +3 -785
  205. package/packages/client/dist/state/structural-dag-projection.js +1 -118
  206. package/packages/client/dist/telemetry/index.js +1 -5
  207. package/packages/client/dist/telemetry/offline-queue.js +1 -131
  208. package/packages/client/dist/tier/index.js +1 -5
  209. package/packages/client/dist/tier/tier-aware-client.js +1 -405
  210. package/packages/client/dist/types/index.js +1 -38
  211. package/packages/client/dist/version.js +1 -27
  212. package/packages/client/dist/yolo/change-evidence-collector.js +1 -126
  213. package/packages/client/dist/yolo/invoke-envelope-capability.js +1 -62
  214. package/packages/client/dist/yolo/spec-envelope-collector.js +5 -185
  215. package/packages/client/dist/yoloop/discovery-hook.js +2 -300
  216. package/packages/client/dist/yoloop/index.js +1 -8
  217. package/packages/client/dist/yoloop/invoke-hooks.js +12 -669
  218. package/packages/client/dist/yoloop/shared-discover-epics.js +1 -1007
  219. package/packages/client/dist/yoloop/shared-yoloop-types.js +1 -33
  220. package/packages/client/dist/yoloop/target-normalization.js +1 -180
  221. package/packages/client/dist/yoloop/yoloop-client-state-store.js +1 -1723
  222. package/targets-stubs/antigravity/gemini.md +1 -1
  223. package/targets-stubs/antigravity/skill/SKILL.md +1 -1
  224. package/targets-stubs/claude-code/neocortex-root.agent.yaml +1 -1
  225. package/targets-stubs/claude-code/neocortex-root.md +2 -2
  226. package/targets-stubs/claude-code/neocortex.agent.yaml +1 -1
  227. package/targets-stubs/claude-code/neocortex.md +2 -2
  228. package/targets-stubs/codex/AGENTS.md +1 -1
  229. package/targets-stubs/cursor/agent.md +2 -2
  230. package/targets-stubs/gemini-cli/agent.md +2 -2
  231. package/targets-stubs/opencode/neocortex-root.md +1 -1
  232. 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};