@ktpartners/dgs-platform 2.6.2

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 (256) hide show
  1. package/LICENSE +38 -0
  2. package/README.md +851 -0
  3. package/agents/dgs-codebase-cross-analyzer.md +183 -0
  4. package/agents/dgs-codebase-mapper.md +782 -0
  5. package/agents/dgs-codebase-synthesizer.md +156 -0
  6. package/agents/dgs-debugger.md +1256 -0
  7. package/agents/dgs-executor.md +550 -0
  8. package/agents/dgs-integration-checker.md +481 -0
  9. package/agents/dgs-nyquist-auditor.md +178 -0
  10. package/agents/dgs-phase-researcher.md +563 -0
  11. package/agents/dgs-phase-verifier.md +450 -0
  12. package/agents/dgs-plan-checker.md +708 -0
  13. package/agents/dgs-planner.md +1324 -0
  14. package/agents/dgs-project-researcher.md +631 -0
  15. package/agents/dgs-research-synthesizer.md +249 -0
  16. package/agents/dgs-roadmapper.md +652 -0
  17. package/agents/dgs-verifier.md +607 -0
  18. package/bin/install.js +2073 -0
  19. package/commands/dgs/add-doc.md +45 -0
  20. package/commands/dgs/add-idea.md +38 -0
  21. package/commands/dgs/add-phase.md +43 -0
  22. package/commands/dgs/add-repo.md +54 -0
  23. package/commands/dgs/add-tests.md +41 -0
  24. package/commands/dgs/add-todo.md +47 -0
  25. package/commands/dgs/approve-spec.md +38 -0
  26. package/commands/dgs/audit-milestone.md +36 -0
  27. package/commands/dgs/audit-phase.md +37 -0
  28. package/commands/dgs/cancel-job.md +23 -0
  29. package/commands/dgs/capture-principle.md +143 -0
  30. package/commands/dgs/check-todos.md +45 -0
  31. package/commands/dgs/cleanup.md +18 -0
  32. package/commands/dgs/complete-milestone.md +136 -0
  33. package/commands/dgs/complete-project.md +70 -0
  34. package/commands/dgs/consolidate-ideas.md +50 -0
  35. package/commands/dgs/create-milestone-job.md +37 -0
  36. package/commands/dgs/debug.md +164 -0
  37. package/commands/dgs/develop-idea.md +53 -0
  38. package/commands/dgs/discuss-idea.md +41 -0
  39. package/commands/dgs/discuss-phase.md +83 -0
  40. package/commands/dgs/execute-phase.md +41 -0
  41. package/commands/dgs/fast.md +38 -0
  42. package/commands/dgs/find-related-ideas.md +43 -0
  43. package/commands/dgs/health.md +28 -0
  44. package/commands/dgs/help.md +22 -0
  45. package/commands/dgs/import-spec.md +36 -0
  46. package/commands/dgs/init-product.md +28 -0
  47. package/commands/dgs/insert-phase.md +32 -0
  48. package/commands/dgs/join-discord.md +18 -0
  49. package/commands/dgs/list-docs.md +40 -0
  50. package/commands/dgs/list-ideas.md +42 -0
  51. package/commands/dgs/list-jobs.md +22 -0
  52. package/commands/dgs/list-phase-assumptions.md +46 -0
  53. package/commands/dgs/list-projects.md +57 -0
  54. package/commands/dgs/list-specs.md +40 -0
  55. package/commands/dgs/map-codebase.md +92 -0
  56. package/commands/dgs/new-milestone.md +44 -0
  57. package/commands/dgs/new-project.md +42 -0
  58. package/commands/dgs/node-repair.md +26 -0
  59. package/commands/dgs/overlap-check.md +20 -0
  60. package/commands/dgs/pause-work.md +38 -0
  61. package/commands/dgs/plan-milestone-gaps.md +34 -0
  62. package/commands/dgs/plan-phase.md +44 -0
  63. package/commands/dgs/progress.md +24 -0
  64. package/commands/dgs/quick.md +41 -0
  65. package/commands/dgs/reactivate-project.md +70 -0
  66. package/commands/dgs/reapply-patches.md +110 -0
  67. package/commands/dgs/refine-spec.md +38 -0
  68. package/commands/dgs/reject-idea.md +43 -0
  69. package/commands/dgs/remove-doc.md +44 -0
  70. package/commands/dgs/remove-phase.md +31 -0
  71. package/commands/dgs/remove-repo.md +69 -0
  72. package/commands/dgs/research-idea.md +43 -0
  73. package/commands/dgs/research-phase.md +189 -0
  74. package/commands/dgs/restore-idea.md +45 -0
  75. package/commands/dgs/resume-work.md +40 -0
  76. package/commands/dgs/rollback-job.md +24 -0
  77. package/commands/dgs/run-job.md +35 -0
  78. package/commands/dgs/search.md +40 -0
  79. package/commands/dgs/set-profile.md +34 -0
  80. package/commands/dgs/settings.md +38 -0
  81. package/commands/dgs/switch-project.md +58 -0
  82. package/commands/dgs/undo-consolidation.md +42 -0
  83. package/commands/dgs/update-idea.md +44 -0
  84. package/commands/dgs/update.md +37 -0
  85. package/commands/dgs/validate-phase.md +35 -0
  86. package/commands/dgs/verify-work.md +39 -0
  87. package/commands/dgs/write-spec.md +49 -0
  88. package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-01-SUMMARY.md +84 -0
  89. package/deliver-great-systems/.planning/phases/09-backend-wiring-and-error-handling/09-02-SUMMARY.md +86 -0
  90. package/deliver-great-systems/.planning/phases/10-v1-to-v2-migration-flow/10-01-SUMMARY.md +85 -0
  91. package/deliver-great-systems/bin/dgs-tools.cjs +1444 -0
  92. package/deliver-great-systems/bin/lib/auto-test.cjs +1365 -0
  93. package/deliver-great-systems/bin/lib/commands.cjs +570 -0
  94. package/deliver-great-systems/bin/lib/config.cjs +417 -0
  95. package/deliver-great-systems/bin/lib/conflict-agent.cjs +1063 -0
  96. package/deliver-great-systems/bin/lib/conflict-agent.test.cjs +554 -0
  97. package/deliver-great-systems/bin/lib/context.cjs +929 -0
  98. package/deliver-great-systems/bin/lib/context.test.cjs +693 -0
  99. package/deliver-great-systems/bin/lib/core.cjs +744 -0
  100. package/deliver-great-systems/bin/lib/core.test.cjs +822 -0
  101. package/deliver-great-systems/bin/lib/docs.cjs +919 -0
  102. package/deliver-great-systems/bin/lib/docs.test.cjs +211 -0
  103. package/deliver-great-systems/bin/lib/execution.cjs +705 -0
  104. package/deliver-great-systems/bin/lib/execution.test.cjs +1472 -0
  105. package/deliver-great-systems/bin/lib/frontmatter.cjs +324 -0
  106. package/deliver-great-systems/bin/lib/ideas.cjs +1406 -0
  107. package/deliver-great-systems/bin/lib/ideas.test.cjs +1417 -0
  108. package/deliver-great-systems/bin/lib/identity.cjs +125 -0
  109. package/deliver-great-systems/bin/lib/init.cjs +1114 -0
  110. package/deliver-great-systems/bin/lib/init.test.cjs +1271 -0
  111. package/deliver-great-systems/bin/lib/jobs.cjs +2015 -0
  112. package/deliver-great-systems/bin/lib/jobs.test.cjs +2619 -0
  113. package/deliver-great-systems/bin/lib/merge-conflicts.cjs +654 -0
  114. package/deliver-great-systems/bin/lib/merge-conflicts.test.cjs +370 -0
  115. package/deliver-great-systems/bin/lib/migration.cjs +352 -0
  116. package/deliver-great-systems/bin/lib/migration.test.cjs +582 -0
  117. package/deliver-great-systems/bin/lib/milestone.cjs +243 -0
  118. package/deliver-great-systems/bin/lib/overlap.cjs +437 -0
  119. package/deliver-great-systems/bin/lib/overlap.test.cjs +747 -0
  120. package/deliver-great-systems/bin/lib/path-audit.test.cjs +384 -0
  121. package/deliver-great-systems/bin/lib/paths.cjs +144 -0
  122. package/deliver-great-systems/bin/lib/paths.test.cjs +486 -0
  123. package/deliver-great-systems/bin/lib/phase.cjs +910 -0
  124. package/deliver-great-systems/bin/lib/projects.cjs +691 -0
  125. package/deliver-great-systems/bin/lib/projects.test.cjs +871 -0
  126. package/deliver-great-systems/bin/lib/repos.cjs +1432 -0
  127. package/deliver-great-systems/bin/lib/repos.test.cjs +1882 -0
  128. package/deliver-great-systems/bin/lib/roadmap.cjs +305 -0
  129. package/deliver-great-systems/bin/lib/search.cjs +570 -0
  130. package/deliver-great-systems/bin/lib/specs.cjs +1303 -0
  131. package/deliver-great-systems/bin/lib/state.cjs +893 -0
  132. package/deliver-great-systems/bin/lib/template.cjs +228 -0
  133. package/deliver-great-systems/bin/lib/test-helpers.cjs +291 -0
  134. package/deliver-great-systems/bin/lib/verify.cjs +796 -0
  135. package/deliver-great-systems/references/checkpoints.md +776 -0
  136. package/deliver-great-systems/references/conflict-resolution.md +66 -0
  137. package/deliver-great-systems/references/context-tiers.md +166 -0
  138. package/deliver-great-systems/references/continuation-format.md +249 -0
  139. package/deliver-great-systems/references/decimal-phase-calculation.md +67 -0
  140. package/deliver-great-systems/references/git-integration.md +250 -0
  141. package/deliver-great-systems/references/git-planning-commit.md +40 -0
  142. package/deliver-great-systems/references/model-profile-resolution.md +36 -0
  143. package/deliver-great-systems/references/model-profiles.md +95 -0
  144. package/deliver-great-systems/references/phase-argument-parsing.md +61 -0
  145. package/deliver-great-systems/references/planning-config.md +224 -0
  146. package/deliver-great-systems/references/questioning.md +162 -0
  147. package/deliver-great-systems/references/spec-review-loop.md +177 -0
  148. package/deliver-great-systems/references/tdd.md +265 -0
  149. package/deliver-great-systems/references/ui-brand.md +160 -0
  150. package/deliver-great-systems/references/verification-patterns.md +612 -0
  151. package/deliver-great-systems/templates/DEBUG.md +166 -0
  152. package/deliver-great-systems/templates/UAT.md +251 -0
  153. package/deliver-great-systems/templates/VALIDATION.md +95 -0
  154. package/deliver-great-systems/templates/claude-md.md +74 -0
  155. package/deliver-great-systems/templates/codebase/architecture.md +257 -0
  156. package/deliver-great-systems/templates/codebase/concerns.md +312 -0
  157. package/deliver-great-systems/templates/codebase/conventions.md +309 -0
  158. package/deliver-great-systems/templates/codebase/integrations.md +282 -0
  159. package/deliver-great-systems/templates/codebase/stack.md +188 -0
  160. package/deliver-great-systems/templates/codebase/structure.md +287 -0
  161. package/deliver-great-systems/templates/codebase/testing.md +482 -0
  162. package/deliver-great-systems/templates/config.json +38 -0
  163. package/deliver-great-systems/templates/context.md +354 -0
  164. package/deliver-great-systems/templates/continue-here.md +80 -0
  165. package/deliver-great-systems/templates/debug-subagent-prompt.md +93 -0
  166. package/deliver-great-systems/templates/discovery.md +148 -0
  167. package/deliver-great-systems/templates/milestone-archive.md +125 -0
  168. package/deliver-great-systems/templates/milestone.md +117 -0
  169. package/deliver-great-systems/templates/phase-prompt.md +615 -0
  170. package/deliver-great-systems/templates/planner-subagent-prompt.md +119 -0
  171. package/deliver-great-systems/templates/project.md +186 -0
  172. package/deliver-great-systems/templates/requirements.md +233 -0
  173. package/deliver-great-systems/templates/research-project/ARCHITECTURE.md +206 -0
  174. package/deliver-great-systems/templates/research-project/FEATURES.md +149 -0
  175. package/deliver-great-systems/templates/research-project/PITFALLS.md +202 -0
  176. package/deliver-great-systems/templates/research-project/STACK.md +122 -0
  177. package/deliver-great-systems/templates/research-project/SUMMARY.md +172 -0
  178. package/deliver-great-systems/templates/research.md +554 -0
  179. package/deliver-great-systems/templates/retrospective.md +54 -0
  180. package/deliver-great-systems/templates/roadmap.md +204 -0
  181. package/deliver-great-systems/templates/state.md +178 -0
  182. package/deliver-great-systems/templates/summary-complex.md +59 -0
  183. package/deliver-great-systems/templates/summary-minimal.md +41 -0
  184. package/deliver-great-systems/templates/summary-standard.md +48 -0
  185. package/deliver-great-systems/templates/summary.md +253 -0
  186. package/deliver-great-systems/templates/user-setup.md +313 -0
  187. package/deliver-great-systems/templates/verification-report.md +324 -0
  188. package/deliver-great-systems/workflows/add-doc.md +151 -0
  189. package/deliver-great-systems/workflows/add-idea.md +96 -0
  190. package/deliver-great-systems/workflows/add-phase.md +120 -0
  191. package/deliver-great-systems/workflows/add-tests.md +359 -0
  192. package/deliver-great-systems/workflows/add-todo.md +162 -0
  193. package/deliver-great-systems/workflows/approve-spec.md +194 -0
  194. package/deliver-great-systems/workflows/audit-milestone.md +364 -0
  195. package/deliver-great-systems/workflows/audit-phase.md +462 -0
  196. package/deliver-great-systems/workflows/cancel-job.md +108 -0
  197. package/deliver-great-systems/workflows/check-todos.md +181 -0
  198. package/deliver-great-systems/workflows/cleanup.md +247 -0
  199. package/deliver-great-systems/workflows/codereview.md +526 -0
  200. package/deliver-great-systems/workflows/complete-milestone.md +1298 -0
  201. package/deliver-great-systems/workflows/consolidate-ideas.md +365 -0
  202. package/deliver-great-systems/workflows/create-milestone-job.md +177 -0
  203. package/deliver-great-systems/workflows/develop-idea.md +544 -0
  204. package/deliver-great-systems/workflows/diagnose-issues.md +231 -0
  205. package/deliver-great-systems/workflows/discovery-phase.md +301 -0
  206. package/deliver-great-systems/workflows/discuss-idea.md +263 -0
  207. package/deliver-great-systems/workflows/discuss-phase.md +733 -0
  208. package/deliver-great-systems/workflows/execute-phase.md +571 -0
  209. package/deliver-great-systems/workflows/execute-plan.md +592 -0
  210. package/deliver-great-systems/workflows/find-related-ideas.md +271 -0
  211. package/deliver-great-systems/workflows/health.md +173 -0
  212. package/deliver-great-systems/workflows/help.md +997 -0
  213. package/deliver-great-systems/workflows/import-spec.md +381 -0
  214. package/deliver-great-systems/workflows/init-product.md +767 -0
  215. package/deliver-great-systems/workflows/insert-phase.md +138 -0
  216. package/deliver-great-systems/workflows/list-docs.md +119 -0
  217. package/deliver-great-systems/workflows/list-ideas.md +154 -0
  218. package/deliver-great-systems/workflows/list-jobs.md +89 -0
  219. package/deliver-great-systems/workflows/list-phase-assumptions.md +192 -0
  220. package/deliver-great-systems/workflows/list-specs.md +101 -0
  221. package/deliver-great-systems/workflows/map-codebase.md +621 -0
  222. package/deliver-great-systems/workflows/new-milestone.md +591 -0
  223. package/deliver-great-systems/workflows/new-project.md +1113 -0
  224. package/deliver-great-systems/workflows/node-repair.md +94 -0
  225. package/deliver-great-systems/workflows/overlap-check.md +86 -0
  226. package/deliver-great-systems/workflows/pause-work.md +134 -0
  227. package/deliver-great-systems/workflows/plan-milestone-gaps.md +306 -0
  228. package/deliver-great-systems/workflows/plan-phase.md +698 -0
  229. package/deliver-great-systems/workflows/progress.md +386 -0
  230. package/deliver-great-systems/workflows/quick.md +845 -0
  231. package/deliver-great-systems/workflows/refine-spec.md +275 -0
  232. package/deliver-great-systems/workflows/reject-idea.md +109 -0
  233. package/deliver-great-systems/workflows/remove-doc.md +117 -0
  234. package/deliver-great-systems/workflows/remove-phase.md +163 -0
  235. package/deliver-great-systems/workflows/research-idea.md +325 -0
  236. package/deliver-great-systems/workflows/research-phase.md +81 -0
  237. package/deliver-great-systems/workflows/restore-idea.md +101 -0
  238. package/deliver-great-systems/workflows/resume-project.md +311 -0
  239. package/deliver-great-systems/workflows/rollback-job.md +130 -0
  240. package/deliver-great-systems/workflows/run-job.md +498 -0
  241. package/deliver-great-systems/workflows/search.md +130 -0
  242. package/deliver-great-systems/workflows/set-profile.md +83 -0
  243. package/deliver-great-systems/workflows/settings.md +470 -0
  244. package/deliver-great-systems/workflows/transition.md +563 -0
  245. package/deliver-great-systems/workflows/undo-consolidation.md +155 -0
  246. package/deliver-great-systems/workflows/update-idea.md +157 -0
  247. package/deliver-great-systems/workflows/update.md +242 -0
  248. package/deliver-great-systems/workflows/validate-phase.md +177 -0
  249. package/deliver-great-systems/workflows/verify-phase.md +253 -0
  250. package/deliver-great-systems/workflows/verify-work.md +671 -0
  251. package/deliver-great-systems/workflows/write-spec.md +450 -0
  252. package/hooks/dist/dgs-check-update.js +62 -0
  253. package/hooks/dist/dgs-context-monitor.js +141 -0
  254. package/hooks/dist/dgs-statusline.js +115 -0
  255. package/package.json +60 -0
  256. package/scripts/build-hooks.js +43 -0
@@ -0,0 +1,1303 @@
1
+ /**
2
+ * Specs — Structured PRD spec CRUD operations and lifecycle management
3
+ *
4
+ * Provides the data model and operations for the spec lifecycle:
5
+ * create, list, update-status, append-review-history, and finalize.
6
+ * Specs are stored as markdown files with YAML frontmatter in
7
+ * .planning/specs/ (flat directory, no subdirectories).
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { safeReadFile, execGit, generateSlugInternal, output, error } = require('./core.cjs');
13
+ const { getPlanningRoot } = require('./paths.cjs');
14
+ const { extractNameFromAuthor } = require('./identity.cjs');
15
+
16
+ // ─── Directory Helpers ────────────────────────────────────────────────────────
17
+
18
+ const SPEC_STATUSES = ['draft', 'review', 'final'];
19
+
20
+ /**
21
+ * Normalize spec status to two-state machine (draft/final).
22
+ * Maps deprecated 'review' and 'in-review' to 'draft'.
23
+ *
24
+ * @param {string} status - Raw status value
25
+ * @returns {string} Normalized status ('draft' or 'final')
26
+ */
27
+ function normalizeStatus(status) {
28
+ if (status === 'final') return 'final';
29
+ if (status === 'draft') return 'draft';
30
+ if (status === 'review' || status === 'in-review') return 'draft';
31
+ return 'draft';
32
+ }
33
+
34
+ /**
35
+ * Ensure .planning/specs/ directory exists.
36
+ *
37
+ * @param {string} cwd - Working directory
38
+ */
39
+ function ensureSpecsDir(cwd) {
40
+ fs.mkdirSync(path.join(getPlanningRoot(cwd), 'specs'), { recursive: true });
41
+ }
42
+
43
+ // ─── Milestone State ─────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Get the set of shipped milestone versions from MILESTONES.md.
47
+ * Parses headings of the form: ## vX.Y Name (Shipped: DATE)
48
+ *
49
+ * @param {string} cwd - Working directory
50
+ * @returns {Set<string>} Set of shipped milestone version strings (e.g., "v12.0")
51
+ */
52
+ function getShippedMilestones(cwd) {
53
+ const milestonesPath = path.join(getPlanningRoot(cwd), 'MILESTONES.md');
54
+ const content = safeReadFile(milestonesPath);
55
+ if (!content) return new Set();
56
+
57
+ const shipped = new Set();
58
+ const pattern = /^## (v\d+\.\d+)\s+.*\(Shipped:/gm;
59
+ let match;
60
+ while ((match = pattern.exec(content)) !== null) {
61
+ shipped.add(match[1]);
62
+ }
63
+ return shipped;
64
+ }
65
+
66
+ // ─── Frontmatter Parsing ──────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Parse YAML frontmatter from spec file content.
70
+ * Uses simple regex parsing (no YAML library -- zero-dependency project).
71
+ *
72
+ * @param {string} content - File content
73
+ * @returns {{ frontmatter: object, body: string }}
74
+ */
75
+ function parseSpecFrontmatter(content) {
76
+ const match = content.match(/^---\n([\s\S]+?)\n---\n?([\s\S]*)/);
77
+ if (!match) return { frontmatter: {}, body: content };
78
+
79
+ const yaml = match[1];
80
+ const body = match[2] || '';
81
+ const frontmatter = {};
82
+
83
+ const lines = yaml.split('\n');
84
+ let currentArrayKey = null;
85
+ let currentArray = [];
86
+
87
+ for (const line of lines) {
88
+ if (line.trim() === '') continue;
89
+
90
+ // Handle multi-line array items (indented with -)
91
+ if (currentArrayKey && /^\s+-\s+/.test(line)) {
92
+ const value = line.replace(/^\s+-\s+/, '').replace(/^["']|["']$/g, '');
93
+ currentArray.push(value);
94
+ continue;
95
+ } else if (currentArrayKey) {
96
+ // End of multi-line array
97
+ frontmatter[currentArrayKey] = currentArray;
98
+ currentArrayKey = null;
99
+ currentArray = [];
100
+ }
101
+
102
+ // Handle inline array values (e.g., source_ideas: ["a", "b"])
103
+ const arrayMatch = line.match(/^([\w_]+):\s*\[([^\]]*)\]/);
104
+ if (arrayMatch) {
105
+ const key = arrayMatch[1];
106
+ const values = arrayMatch[2]
107
+ .split(',')
108
+ .map(v => v.trim().replace(/^["']|["']$/g, ''))
109
+ .filter(v => v !== '');
110
+ frontmatter[key] = values;
111
+ continue;
112
+ }
113
+
114
+ // Handle array start (key followed by nothing or empty)
115
+ const arrayStartMatch = line.match(/^([\w_]+):\s*$/);
116
+ if (arrayStartMatch) {
117
+ currentArrayKey = arrayStartMatch[1];
118
+ currentArray = [];
119
+ continue;
120
+ }
121
+
122
+ // Handle simple key: value
123
+ const kvMatch = line.match(/^([\w_]+):\s*(.*)/);
124
+ if (kvMatch) {
125
+ const key = kvMatch[1];
126
+ let value = kvMatch[2].trim();
127
+ // Remove quotes
128
+ value = value.replace(/^["']|["']$/g, '');
129
+ frontmatter[key] = value;
130
+ }
131
+ }
132
+
133
+ // Flush any remaining array
134
+ if (currentArrayKey) {
135
+ frontmatter[currentArrayKey] = currentArray;
136
+ }
137
+
138
+ return { frontmatter, body };
139
+ }
140
+
141
+ // ─── Dual-Format Support ─────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Detect the frontmatter format of spec file content.
145
+ *
146
+ * @param {string} content - File content
147
+ * @returns {'yaml' | 'bold-key' | 'none'}
148
+ */
149
+ function detectSpecFormat(content) {
150
+ if (!content) return 'none';
151
+
152
+ // YAML format: starts with ---\n
153
+ if (/^---\n/.test(content)) return 'yaml';
154
+
155
+ // Bold-key format: has **Key:** pattern before any --- delimiter or ## section heading
156
+ const lines = content.split('\n');
157
+ for (const line of lines) {
158
+ // Stop at --- horizontal rule
159
+ if (/^---\s*$/.test(line)) break;
160
+ // Stop at ## section heading
161
+ if (/^## /.test(line)) break;
162
+ // Found a bold-key line
163
+ if (/^\*\*[^*]+:\*\*/.test(line)) return 'bold-key';
164
+ }
165
+
166
+ return 'none';
167
+ }
168
+
169
+ /**
170
+ * Parse bold-key frontmatter from spec file content.
171
+ * Extracts **Key:** Value pairs from the top of the file.
172
+ *
173
+ * @param {string} content - File content
174
+ * @returns {{ frontmatter: object, body: string }}
175
+ */
176
+ function parseBoldKeyFrontmatter(content) {
177
+ if (!content) return { frontmatter: {}, body: content || '' };
178
+
179
+ const lines = content.split('\n');
180
+ const frontmatter = {};
181
+ let bodyStartIndex = 0;
182
+
183
+ // Bold-key to frontmatter key mapping
184
+ const keyMap = {
185
+ 'Status': 'status',
186
+ 'Author': 'author',
187
+ 'Date': 'created',
188
+ 'Updated': 'updated',
189
+ 'Version': 'version',
190
+ 'Approved Date': 'approved_date',
191
+ 'Milestones': 'milestones',
192
+ 'Source Ideas': 'source_ideas',
193
+ };
194
+
195
+ for (let i = 0; i < lines.length; i++) {
196
+ const line = lines[i];
197
+
198
+ // Extract title from # Title line
199
+ const titleMatch = line.match(/^# (.+)$/);
200
+ if (titleMatch && !frontmatter.title) {
201
+ frontmatter.title = titleMatch[1].trim();
202
+ bodyStartIndex = i + 1;
203
+ continue;
204
+ }
205
+
206
+ // Stop at --- horizontal rule (body starts after it)
207
+ if (/^---\s*$/.test(line)) {
208
+ bodyStartIndex = i + 1;
209
+ break;
210
+ }
211
+
212
+ // Stop at ## section heading (body starts here)
213
+ if (/^## /.test(line)) {
214
+ bodyStartIndex = i;
215
+ break;
216
+ }
217
+
218
+ // Extract bold-key values
219
+ const boldKeyMatch = line.match(/^\*\*([^*]+):\*\*\s*(.*)/);
220
+ if (boldKeyMatch) {
221
+ const rawKey = boldKeyMatch[1].trim();
222
+ const rawValue = boldKeyMatch[2].trim();
223
+ const mappedKey = keyMap[rawKey];
224
+
225
+ if (mappedKey) {
226
+ if (mappedKey === 'status') {
227
+ // Lowercase the value: Draft -> draft, Final -> final
228
+ frontmatter[mappedKey] = rawValue.toLowerCase();
229
+ } else if (mappedKey === 'milestones' || mappedKey === 'source_ideas') {
230
+ // Split comma-separated values into array
231
+ frontmatter[mappedKey] = rawValue
232
+ .split(',')
233
+ .map(v => v.trim())
234
+ .filter(v => v !== '');
235
+ } else {
236
+ frontmatter[mappedKey] = rawValue;
237
+ }
238
+ }
239
+
240
+ bodyStartIndex = i + 1;
241
+ continue;
242
+ }
243
+
244
+ // Skip empty lines in the frontmatter area
245
+ if (line.trim() === '') {
246
+ bodyStartIndex = i + 1;
247
+ continue;
248
+ }
249
+
250
+ // Non-matching non-empty line that isn't a title or bold-key -- stop
251
+ bodyStartIndex = i;
252
+ break;
253
+ }
254
+
255
+ // Generate id from title if not present
256
+ if (frontmatter.title && !frontmatter.id) {
257
+ const slug = generateSlugInternal(frontmatter.title);
258
+ frontmatter.id = `spec-${slug}`;
259
+ }
260
+
261
+ const body = lines.slice(bodyStartIndex).join('\n');
262
+ return { frontmatter, body };
263
+ }
264
+
265
+ /**
266
+ * Build spec file content in bold-key format from frontmatter object and body.
267
+ *
268
+ * @param {object} frontmatter - Frontmatter fields
269
+ * @param {string} body - Body content
270
+ * @returns {string} Full file content
271
+ */
272
+ function buildBoldKeyContent(frontmatter, body) {
273
+ let content = '';
274
+
275
+ // Title first
276
+ if (frontmatter.title) {
277
+ content += `# ${frontmatter.title}\n\n`;
278
+ }
279
+
280
+ // Status (title-case: draft -> Draft, final -> Final)
281
+ if (frontmatter.status) {
282
+ const titleCased = frontmatter.status.charAt(0).toUpperCase() + frontmatter.status.slice(1);
283
+ content += `**Status:** ${titleCased}\n`;
284
+ }
285
+
286
+ // Author
287
+ if (frontmatter.author) {
288
+ content += `**Author:** ${frontmatter.author}\n`;
289
+ }
290
+
291
+ // Date (maps from 'created')
292
+ if (frontmatter.created) {
293
+ content += `**Date:** ${frontmatter.created}\n`;
294
+ }
295
+
296
+ // Updated
297
+ if (frontmatter.updated) {
298
+ content += `**Updated:** ${frontmatter.updated}\n`;
299
+ }
300
+
301
+ // Version
302
+ if (frontmatter.version) {
303
+ content += `**Version:** ${frontmatter.version}\n`;
304
+ }
305
+
306
+ // Approved Date
307
+ if (frontmatter.approved_date) {
308
+ content += `**Approved Date:** ${frontmatter.approved_date}\n`;
309
+ }
310
+
311
+ // Milestones (comma-separated)
312
+ if (frontmatter.milestones && frontmatter.milestones.length > 0) {
313
+ content += `**Milestones:** ${frontmatter.milestones.join(', ')}\n`;
314
+ }
315
+
316
+ // Source Ideas (comma-separated)
317
+ if (frontmatter.source_ideas && frontmatter.source_ideas.length > 0) {
318
+ content += `**Source Ideas:** ${frontmatter.source_ideas.join(', ')}\n`;
319
+ }
320
+
321
+ // Separator and body
322
+ content += '\n---\n';
323
+
324
+ if (body) {
325
+ const trimmedBody = body.replace(/^\n+/, '');
326
+ content += '\n' + trimmedBody;
327
+ }
328
+
329
+ return content;
330
+ }
331
+
332
+ /**
333
+ * Build spec file content from frontmatter object and body string.
334
+ *
335
+ * @param {object} frontmatter - Frontmatter fields
336
+ * @param {string} body - Body content
337
+ * @returns {string} Full file content
338
+ */
339
+ function buildSpecContent(frontmatter, body) {
340
+ let yaml = '---\n';
341
+ yaml += `id: ${frontmatter.id}\n`;
342
+ yaml += `title: "${frontmatter.title}"\n`;
343
+ yaml += `status: ${frontmatter.status}\n`;
344
+
345
+ if (frontmatter.source_ideas && frontmatter.source_ideas.length > 0) {
346
+ yaml += 'source_ideas:\n';
347
+ for (const idea of frontmatter.source_ideas) {
348
+ yaml += ` - "${idea}"\n`;
349
+ }
350
+ } else {
351
+ yaml += 'source_ideas: []\n';
352
+ }
353
+
354
+ if (frontmatter.source_document) {
355
+ yaml += `source_document: "${frontmatter.source_document}"\n`;
356
+ }
357
+
358
+ yaml += `created: ${frontmatter.created}\n`;
359
+ yaml += `updated: ${frontmatter.updated}\n`;
360
+ if (frontmatter.author) {
361
+ yaml += `author: "${frontmatter.author}"\n`;
362
+ }
363
+ if (frontmatter.updated_by) {
364
+ yaml += `updated_by: "${frontmatter.updated_by}"\n`;
365
+ }
366
+ if (frontmatter.version) {
367
+ yaml += `version: ${frontmatter.version}\n`;
368
+ }
369
+ if (frontmatter.approved_date) {
370
+ yaml += `approved_date: ${frontmatter.approved_date}\n`;
371
+ }
372
+ if (frontmatter.milestones && frontmatter.milestones.length > 0) {
373
+ yaml += 'milestones:\n';
374
+ for (const m of frontmatter.milestones) {
375
+ yaml += ` - "${m}"\n`;
376
+ }
377
+ }
378
+ yaml += '---\n';
379
+
380
+ if (body) {
381
+ // Ensure body starts with newline for clean separation
382
+ const trimmedBody = body.replace(/^\n+/, '');
383
+ yaml += '\n' + trimmedBody;
384
+ }
385
+
386
+ return yaml;
387
+ }
388
+
389
+ // ─── Find Spec ────────────────────────────────────────────────────────────────
390
+
391
+ /**
392
+ * Find a spec file by id or filename.
393
+ *
394
+ * @param {string} cwd - Working directory
395
+ * @param {string} idOrFilename - Spec id (e.g., "spec-review-config") or filename
396
+ * @returns {{ filename: string, path: string, frontmatter: object, body: string } | null}
397
+ */
398
+ function findSpecFile(cwd, idOrFilename) {
399
+ if (!idOrFilename) return null;
400
+
401
+ const specsDir = path.join(getPlanningRoot(cwd), 'specs');
402
+ let files;
403
+ try {
404
+ files = fs.readdirSync(specsDir).filter(f => f.endsWith('.md'));
405
+ } catch {
406
+ return null;
407
+ }
408
+
409
+ // Helper to normalize a parsed result before returning
410
+ function normalizeResult(file, frontmatter, body) {
411
+ frontmatter.status = normalizeStatus(frontmatter.status || 'draft');
412
+ if (!frontmatter.version) {
413
+ frontmatter.version = '0.1';
414
+ }
415
+ return { filename: file, path: path.join(specsDir, file), frontmatter, body };
416
+ }
417
+
418
+ // First pass: exact matches
419
+ const parsed = [];
420
+ for (const file of files) {
421
+ const content = safeReadFile(path.join(specsDir, file));
422
+ if (!content) continue;
423
+
424
+ // Dual-format support: detect format and use appropriate parser
425
+ const format = detectSpecFormat(content);
426
+ const { frontmatter, body } = format === 'bold-key'
427
+ ? parseBoldKeyFrontmatter(content)
428
+ : parseSpecFrontmatter(content);
429
+
430
+ // Match by id
431
+ if (frontmatter.id === idOrFilename) {
432
+ return normalizeResult(file, frontmatter, body);
433
+ }
434
+
435
+ // Match by exact filename
436
+ if (file === idOrFilename) {
437
+ return normalizeResult(file, frontmatter, body);
438
+ }
439
+
440
+ // Match by filename without .md extension
441
+ if (file === idOrFilename + '.md') {
442
+ return normalizeResult(file, frontmatter, body);
443
+ }
444
+
445
+ parsed.push({ file, frontmatter, body });
446
+ }
447
+
448
+ // Second pass: substring matching on id and title (case-insensitive)
449
+ const searchLower = idOrFilename.toLowerCase();
450
+ for (const entry of parsed) {
451
+ const idLower = (entry.frontmatter.id || '').toLowerCase();
452
+ const titleLower = (entry.frontmatter.title || '').toLowerCase();
453
+
454
+ if (idLower.includes(searchLower) || titleLower.includes(searchLower)) {
455
+ return normalizeResult(entry.file, entry.frontmatter, entry.body);
456
+ }
457
+ }
458
+
459
+ return null;
460
+ }
461
+
462
+ // ─── CRUD Operations ──────────────────────────────────────────────────────────
463
+
464
+ /**
465
+ * Create a new spec file.
466
+ *
467
+ * @param {string} cwd - Working directory
468
+ * @param {object} options - { title, source_ideas: ["001-slug.md", ...], body }
469
+ * @param {boolean} raw - Raw output mode
470
+ */
471
+ function cmdSpecsCreate(cwd, options, raw) {
472
+ if (!options || !options.title) {
473
+ error('title required for specs create');
474
+ }
475
+
476
+ ensureSpecsDir(cwd);
477
+
478
+ const slug = generateSlugInternal(options.title);
479
+ const id = `spec-${slug}`;
480
+ const filename = `${id}.md`;
481
+ const planRoot = getPlanningRoot(cwd);
482
+ const planRootRel = path.relative(cwd, planRoot) || '.';
483
+ const filePath = path.join(planRoot, 'specs', filename);
484
+ const now = new Date().toISOString();
485
+
486
+ const sourceIdeas = options.source_ideas || [];
487
+ const sourceDocument = options.source_document || null;
488
+
489
+ const frontmatter = {
490
+ id,
491
+ title: options.title,
492
+ status: 'draft',
493
+ source_ideas: sourceIdeas,
494
+ created: now,
495
+ updated: now,
496
+ version: '1.0',
497
+ };
498
+
499
+ if (sourceDocument) {
500
+ frontmatter.source_document = sourceDocument;
501
+ }
502
+
503
+ if (options.author) {
504
+ frontmatter.author = options.author;
505
+ frontmatter.updated_by = options.author;
506
+ }
507
+
508
+ const body = options.body || '';
509
+ const content = buildSpecContent(frontmatter, body);
510
+ fs.writeFileSync(filePath, content, 'utf-8');
511
+
512
+ const result = {
513
+ id,
514
+ filename,
515
+ path: path.join(planRootRel, 'specs', filename),
516
+ title: options.title,
517
+ status: 'draft',
518
+ source_ideas: sourceIdeas,
519
+ source_document: sourceDocument,
520
+ };
521
+ output(result, raw);
522
+ }
523
+
524
+ /**
525
+ * Derive aggregate implementation status from linked milestones.
526
+ * @param {string[]|undefined} milestones - Array of milestone version strings
527
+ * @param {Set<string>} shippedMilestones - Set of shipped milestone versions
528
+ * @returns {'none'|'in-progress'|'completed'}
529
+ */
530
+ function deriveImplementationStatus(milestones, shippedMilestones) {
531
+ if (!milestones || milestones.length === 0) return 'none';
532
+ const allShipped = milestones.every(m => shippedMilestones.has(m));
533
+ return allShipped ? 'completed' : 'in-progress';
534
+ }
535
+
536
+ /**
537
+ * Derive per-milestone state details.
538
+ * @param {string[]|undefined} milestones - Array of milestone version strings
539
+ * @param {Set<string>} shippedMilestones - Set of shipped milestone versions
540
+ * @returns {Array<{version: string, state: string}>}
541
+ */
542
+ function deriveMilestoneDetails(milestones, shippedMilestones) {
543
+ if (!milestones || milestones.length === 0) return [];
544
+ return milestones.map(m => ({
545
+ version: m,
546
+ state: shippedMilestones.has(m) ? 'completed' : 'in-progress',
547
+ }));
548
+ }
549
+
550
+ /**
551
+ * List all specs with optional status filter.
552
+ *
553
+ * @param {string} cwd - Working directory
554
+ * @param {object} options - { status: null|"draft"|"review"|"final" }
555
+ * @param {boolean} raw - Raw output mode
556
+ */
557
+ function cmdSpecsList(cwd, options, raw) {
558
+ const specs = [];
559
+ const counts = { draft: 0, final: 0, total: 0 };
560
+
561
+ const specsDir = path.join(getPlanningRoot(cwd), 'specs');
562
+ let files;
563
+ try {
564
+ files = fs.readdirSync(specsDir).filter(f => f.endsWith('.md'));
565
+ } catch {
566
+ // No specs directory
567
+ output({ specs, counts }, raw);
568
+ return;
569
+ }
570
+
571
+ const shippedMilestones = getShippedMilestones(cwd);
572
+
573
+ for (const file of files) {
574
+ const content = safeReadFile(path.join(specsDir, file));
575
+ if (!content) continue;
576
+
577
+ const format = detectSpecFormat(content);
578
+ const { frontmatter } = format === 'bold-key'
579
+ ? parseBoldKeyFrontmatter(content)
580
+ : parseSpecFrontmatter(content);
581
+ const status = normalizeStatus(frontmatter.status || 'draft');
582
+
583
+ // Status filter
584
+ if (options && options.status && status !== options.status) continue;
585
+
586
+ // Author filter (case-insensitive substring match on name-only)
587
+ const authorName = extractNameFromAuthor(frontmatter.author || '');
588
+ if (options && options.author) {
589
+ const filterAuthor = options.author.toLowerCase();
590
+ if (!authorName.toLowerCase().includes(filterAuthor)) continue;
591
+ }
592
+
593
+ specs.push({
594
+ id: frontmatter.id || '',
595
+ title: frontmatter.title || '',
596
+ status,
597
+ version: frontmatter.version || '',
598
+ milestones: frontmatter.milestones || [],
599
+ implementation_status: deriveImplementationStatus(frontmatter.milestones, shippedMilestones),
600
+ milestone_details: deriveMilestoneDetails(frontmatter.milestones, shippedMilestones),
601
+ source_ideas: frontmatter.source_ideas || [],
602
+ source_document: frontmatter.source_document || '',
603
+ created: frontmatter.created || '',
604
+ updated: frontmatter.updated || '',
605
+ author: authorName,
606
+ filename: file,
607
+ });
608
+
609
+ if (counts[status] !== undefined) {
610
+ counts[status]++;
611
+ }
612
+ counts.total++;
613
+ }
614
+
615
+ output({ specs, counts }, raw);
616
+ }
617
+
618
+ /**
619
+ * Update spec status field.
620
+ *
621
+ * @param {string} cwd - Working directory
622
+ * @param {string} idOrFilename - Spec id or filename
623
+ * @param {string} newStatus - New status (draft, review, final)
624
+ * @param {boolean} raw - Raw output mode
625
+ */
626
+ function cmdSpecsUpdateStatus(cwd, idOrFilename, newStatus, raw, author) {
627
+ if (!idOrFilename) {
628
+ error('id required for specs update-status');
629
+ }
630
+ if (!newStatus) {
631
+ error('status required for specs update-status');
632
+ }
633
+ if (!SPEC_STATUSES.includes(newStatus)) {
634
+ error(`invalid status: ${newStatus}. Use draft, review, or final`);
635
+ }
636
+
637
+ const spec = findSpecFile(cwd, idOrFilename);
638
+ if (!spec) {
639
+ error(`spec not found: ${idOrFilename}`);
640
+ }
641
+
642
+ const previousStatus = spec.frontmatter.status;
643
+ spec.frontmatter.status = newStatus;
644
+ spec.frontmatter.updated = new Date().toISOString();
645
+ if (author) {
646
+ spec.frontmatter.updated_by = author;
647
+ }
648
+
649
+ const content = buildSpecContent(spec.frontmatter, spec.body);
650
+ fs.writeFileSync(spec.path, content, 'utf-8');
651
+
652
+ const result = {
653
+ id: spec.frontmatter.id,
654
+ filename: spec.filename,
655
+ status: newStatus,
656
+ previous_status: previousStatus,
657
+ };
658
+ output(result, raw);
659
+ }
660
+
661
+ /**
662
+ * Append a review round to the spec's Review History section.
663
+ *
664
+ * @param {string} cwd - Working directory
665
+ * @param {string} idOrFilename - Spec id or filename
666
+ * @param {string} historyEntry - Formatted markdown for one review round
667
+ * @param {boolean} raw - Raw output mode
668
+ */
669
+ function cmdSpecsAppendReviewHistory(cwd, idOrFilename, historyEntry, raw, author) {
670
+ if (!idOrFilename) {
671
+ error('id required for specs append-review');
672
+ }
673
+ if (!historyEntry) {
674
+ error('entry required for specs append-review');
675
+ }
676
+
677
+ const spec = findSpecFile(cwd, idOrFilename);
678
+ if (!spec) {
679
+ error(`spec not found: ${idOrFilename}`);
680
+ }
681
+
682
+ spec.frontmatter.updated = new Date().toISOString();
683
+ if (author) {
684
+ spec.frontmatter.updated_by = author;
685
+ }
686
+
687
+ // Normalize literal \n sequences to real newlines (CLI passes escaped newlines)
688
+ historyEntry = historyEntry.replace(/\\n/g, '\n');
689
+
690
+ // Inject "Applied by" attribution into the review history entry
691
+ if (author) {
692
+ const appliedByName = extractNameFromAuthor(author);
693
+ // Insert "**Applied by:** Name" after the first line (### Round N heading)
694
+ const firstNewline = historyEntry.indexOf('\n');
695
+ if (firstNewline !== -1) {
696
+ historyEntry = historyEntry.slice(0, firstNewline + 1) +
697
+ '**Applied by:** ' + appliedByName + '\n' +
698
+ historyEntry.slice(firstNewline + 1);
699
+ } else {
700
+ // Entry is a single line — append attribution after it
701
+ historyEntry = historyEntry + '\n**Applied by:** ' + appliedByName;
702
+ }
703
+ }
704
+
705
+ let body = spec.body;
706
+
707
+ // Find the ## Review History section
708
+ const reviewHistoryPattern = /## Review History\n/;
709
+ const reviewMatch = body.match(reviewHistoryPattern);
710
+
711
+ if (reviewMatch) {
712
+ // Find the position after "## Review History\n"
713
+ const insertPos = reviewMatch.index + reviewMatch[0].length;
714
+ // Find existing content after the header (before next ## section or end)
715
+ const afterHeader = body.slice(insertPos);
716
+ const nextSectionMatch = afterHeader.match(/\n## [^#]/);
717
+ const existingContent = nextSectionMatch
718
+ ? afterHeader.slice(0, nextSectionMatch.index)
719
+ : afterHeader;
720
+
721
+ // Append the new entry after existing review history content
722
+ const newContent = existingContent.trimEnd() + '\n\n' + historyEntry + '\n';
723
+
724
+ if (nextSectionMatch) {
725
+ body = body.slice(0, insertPos) + newContent + afterHeader.slice(nextSectionMatch.index);
726
+ } else {
727
+ body = body.slice(0, insertPos) + newContent;
728
+ }
729
+ } else {
730
+ // No Review History section exists -- append it
731
+ body = body.trimEnd() + '\n\n## Review History\n\n' + historyEntry + '\n';
732
+ }
733
+
734
+ const content = buildSpecContent(spec.frontmatter, body);
735
+ fs.writeFileSync(spec.path, content, 'utf-8');
736
+
737
+ const result = {
738
+ id: spec.frontmatter.id,
739
+ filename: spec.filename,
740
+ appended: true,
741
+ };
742
+ output(result, raw);
743
+ }
744
+
745
+ /**
746
+ * Finalize a spec: set status to final, move source ideas to done.
747
+ *
748
+ * @param {string} cwd - Working directory
749
+ * @param {string} idOrFilename - Spec id or filename
750
+ * @param {boolean} raw - Raw output mode
751
+ */
752
+ function cmdSpecsFinalize(cwd, idOrFilename, raw, author) {
753
+ if (!idOrFilename) {
754
+ error('id required for specs finalize');
755
+ }
756
+
757
+ const spec = findSpecFile(cwd, idOrFilename);
758
+ if (!spec) {
759
+ error(`spec not found: ${idOrFilename}`);
760
+ }
761
+
762
+ // Guard: already finalized
763
+ if (spec.frontmatter.status === 'final') {
764
+ error(`spec already final: ${spec.frontmatter.id}. Cannot re-finalize.`);
765
+ }
766
+
767
+ // Set status to final
768
+ spec.frontmatter.status = 'final';
769
+ spec.frontmatter.updated = new Date().toISOString();
770
+ if (author) {
771
+ spec.frontmatter.updated_by = author;
772
+ }
773
+
774
+ const content = buildSpecContent(spec.frontmatter, spec.body);
775
+ fs.writeFileSync(spec.path, content, 'utf-8');
776
+
777
+ // Move source ideas to done
778
+ const ideasMoved = [];
779
+ const ideasFailed = [];
780
+ const sourceIdeas = spec.frontmatter.source_ideas || [];
781
+
782
+ for (const ideaFilename of sourceIdeas) {
783
+ // Extract idea ID from filename (e.g., "001-slug.md" -> "001")
784
+ const idMatch = ideaFilename.match(/^(\d+)/);
785
+ if (!idMatch) {
786
+ ideasFailed.push({ idea: ideaFilename, reason: 'could not parse ID' });
787
+ continue;
788
+ }
789
+ const ideaId = idMatch[1];
790
+
791
+ try {
792
+ // Move ideas to done manually (ideas.cjs cmd functions call process.exit,
793
+ // so we use the internal helpers directly instead)
794
+ // Instead, do the move manually following the same pattern
795
+ const { findIdeaFile, ensureIdeasDirs } = require('./ideas.cjs');
796
+ const idea = findIdeaFile(cwd, ideaId);
797
+
798
+ if (!idea) {
799
+ ideasFailed.push({ idea: ideaFilename, reason: 'idea not found' });
800
+ continue;
801
+ }
802
+
803
+ if (idea.state === 'done') {
804
+ // Already done, skip
805
+ ideasMoved.push(ideaFilename);
806
+ continue;
807
+ }
808
+
809
+ if (idea.state === 'consolidated') {
810
+ // Consolidated ideas cannot be used as spec sources
811
+ ideasFailed.push({ idea: ideaFilename, reason: 'idea is consolidated' });
812
+ continue;
813
+ }
814
+
815
+ ensureIdeasDirs(cwd);
816
+
817
+ const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
818
+ const fromRel = path.join(planRootRel, 'ideas', idea.state, idea.filename);
819
+ const toRel = path.join(planRootRel, 'ideas', 'done', idea.filename);
820
+ const mvResult = execGit(cwd, ['mv', fromRel, toRel]);
821
+
822
+ if (mvResult.exitCode !== 0) {
823
+ ideasFailed.push({ idea: ideaFilename, reason: mvResult.stderr || 'git mv failed' });
824
+ continue;
825
+ }
826
+
827
+ ideasMoved.push(ideaFilename);
828
+ } catch (err) {
829
+ ideasFailed.push({ idea: ideaFilename, reason: err.message || 'unknown error' });
830
+ }
831
+ }
832
+
833
+ // Move associated research documents and update links in idea files
834
+ const researchDocsMoved = [];
835
+
836
+ for (const ideaFilename of ideasMoved) {
837
+ try {
838
+ // Extract slug from idea filename: "001-my-idea.md" -> "my-idea"
839
+ const slug = ideaFilename.replace(/^\d+-/, '').replace(/\.md$/, '');
840
+ const researchDocName = `${slug}-research.md`;
841
+ const specPlanRoot = getPlanningRoot(cwd);
842
+ const specPlanRootRel = path.relative(cwd, specPlanRoot) || '.';
843
+ const sourcePath = path.join(specPlanRoot, 'docs', 'ideas', 'pending', researchDocName);
844
+
845
+ // Skip if no research doc exists (graceful handling)
846
+ if (!fs.existsSync(sourcePath)) continue;
847
+
848
+ // Ensure done directory exists
849
+ fs.mkdirSync(path.join(specPlanRoot, 'docs', 'ideas', 'done'), { recursive: true });
850
+
851
+ const sourceRel = path.join(specPlanRootRel, 'docs', 'ideas', 'pending', researchDocName);
852
+ const targetRel = path.join(specPlanRootRel, 'docs', 'ideas', 'done', researchDocName);
853
+ const mvResult = execGit(cwd, ['mv', sourceRel, targetRel]);
854
+
855
+ if (mvResult.exitCode !== 0) continue;
856
+
857
+ researchDocsMoved.push(researchDocName);
858
+
859
+ // Update documentLink paths in the idea file (now in done/)
860
+ const ideaDonePath = path.join(getPlanningRoot(cwd), 'ideas', 'done', ideaFilename);
861
+ if (fs.existsSync(ideaDonePath)) {
862
+ let ideaContent = fs.readFileSync(ideaDonePath, 'utf-8');
863
+ // Replace pending/ paths with done/ for this research doc
864
+ const pendingPattern = `docs/ideas/pending/${researchDocName}`;
865
+ const doneReplacement = `docs/ideas/done/${researchDocName}`;
866
+ if (ideaContent.includes(pendingPattern)) {
867
+ ideaContent = ideaContent.split(pendingPattern).join(doneReplacement);
868
+ fs.writeFileSync(ideaDonePath, ideaContent, 'utf-8');
869
+ }
870
+ }
871
+ } catch {
872
+ // Graceful handling: skip on any error
873
+ }
874
+ }
875
+
876
+ // Auto-commit the spec status change and idea moves
877
+ execGit(cwd, ['add', '-A']);
878
+ const title = spec.frontmatter.title || spec.frontmatter.id;
879
+ execGit(cwd, ['commit', '-m', `specs: finalize ${spec.frontmatter.id} - ${title}`]);
880
+
881
+ const result = {
882
+ id: spec.frontmatter.id,
883
+ filename: spec.filename,
884
+ status: 'final',
885
+ ideas_moved: ideasMoved,
886
+ ideas_failed: ideasFailed,
887
+ research_docs_moved: researchDocsMoved,
888
+ };
889
+ output(result, raw);
890
+ }
891
+
892
+ // ─── Set Status (two-state machine) ──────────────────────────────────────────
893
+
894
+ /**
895
+ * Set spec status, enforcing the two-state machine (draft/final only).
896
+ * Rejects deprecated 'review' and 'in-review' with clear message.
897
+ *
898
+ * @param {string} cwd - Working directory
899
+ * @param {string} idOrFilename - Spec id or filename
900
+ * @param {string} newStatus - New status (draft or final)
901
+ * @param {boolean} raw - Raw output mode
902
+ * @param {string} [author] - Author string for updated_by
903
+ */
904
+ function cmdSpecsSetStatus(cwd, idOrFilename, newStatus, raw, author) {
905
+ if (!idOrFilename) {
906
+ error('id required for specs set-status');
907
+ }
908
+ if (!newStatus) {
909
+ error('status required for specs set-status');
910
+ }
911
+
912
+ // Reject deprecated statuses with specific message
913
+ if (newStatus === 'review' || newStatus === 'in-review') {
914
+ error(`Status '${newStatus}' is deprecated. Valid statuses: draft, final`);
915
+ }
916
+
917
+ // Validate against two-state machine (hardcoded, NOT using SPEC_STATUSES)
918
+ const validStatuses = ['draft', 'final'];
919
+ if (!validStatuses.includes(newStatus)) {
920
+ error(`Invalid status '${newStatus}'. Valid statuses: draft, final`);
921
+ }
922
+
923
+ const spec = findSpecFile(cwd, idOrFilename);
924
+ if (!spec) {
925
+ // Build fuzzy suggestions
926
+ const specsDir = path.join(getPlanningRoot(cwd), 'specs');
927
+ const suggestions = [];
928
+ try {
929
+ const files = fs.readdirSync(specsDir).filter(f => f.endsWith('.md'));
930
+ const searchLower = idOrFilename.toLowerCase();
931
+ for (const file of files) {
932
+ const content = safeReadFile(path.join(specsDir, file));
933
+ if (!content) continue;
934
+ const format = detectSpecFormat(content);
935
+ const { frontmatter } = format === 'bold-key'
936
+ ? parseBoldKeyFrontmatter(content)
937
+ : parseSpecFrontmatter(content);
938
+ const idLower = (frontmatter.id || '').toLowerCase();
939
+ const titleLower = (frontmatter.title || '').toLowerCase();
940
+ if (idLower.includes(searchLower) || titleLower.includes(searchLower)) {
941
+ suggestions.push(frontmatter.id || file);
942
+ }
943
+ if (suggestions.length >= 3) break;
944
+ }
945
+ } catch { /* ignore */ }
946
+ const suggestionText = suggestions.length > 0
947
+ ? `. Did you mean: ${suggestions.join(', ')}?`
948
+ : '';
949
+ error(`spec not found: ${idOrFilename}${suggestionText}`);
950
+ }
951
+
952
+ // Read the raw file content to detect format
953
+ const fileContent = fs.readFileSync(spec.path, 'utf-8');
954
+ const format = detectSpecFormat(fileContent);
955
+
956
+ // Track the previous status (normalized from what was stored)
957
+ const previousStatus = spec.frontmatter.status;
958
+
959
+ // Update frontmatter
960
+ spec.frontmatter.status = newStatus;
961
+ spec.frontmatter.updated = new Date().toISOString();
962
+ if (newStatus === 'final') {
963
+ spec.frontmatter.approved_date = new Date().toISOString().split('T')[0];
964
+ }
965
+ if (author) {
966
+ spec.frontmatter.updated_by = author;
967
+ }
968
+
969
+ // Write back using the correct builder
970
+ const content = format === 'bold-key'
971
+ ? buildBoldKeyContent(spec.frontmatter, spec.body)
972
+ : buildSpecContent(spec.frontmatter, spec.body);
973
+ fs.writeFileSync(spec.path, content, 'utf-8');
974
+
975
+ const result = {
976
+ id: spec.frontmatter.id,
977
+ filename: spec.filename,
978
+ previous_status: previousStatus,
979
+ status: newStatus,
980
+ message: `\u2713 ${spec.filename}: ${previousStatus} \u2192 ${newStatus}`,
981
+ };
982
+ output(result, raw);
983
+ }
984
+
985
+ // ─── Validate ────────────────────────────────────────────────────────────────
986
+
987
+ /**
988
+ * Validate a spec for completeness, producing tiered error/warning/info output.
989
+ *
990
+ * @param {string} cwd - Working directory
991
+ * @param {string} idOrFilename - Spec id or filename
992
+ * @param {boolean} raw - Raw output mode
993
+ */
994
+ function cmdSpecsValidate(cwd, idOrFilename, raw) {
995
+ if (!idOrFilename) {
996
+ error('id required for specs validate');
997
+ }
998
+
999
+ const spec = findSpecFile(cwd, idOrFilename);
1000
+ if (!spec) {
1001
+ // Build fuzzy suggestions
1002
+ const specsDir = path.join(getPlanningRoot(cwd), 'specs');
1003
+ const suggestions = [];
1004
+ try {
1005
+ const files = fs.readdirSync(specsDir).filter(f => f.endsWith('.md'));
1006
+ const searchLower = idOrFilename.toLowerCase();
1007
+ for (const file of files) {
1008
+ const content = safeReadFile(path.join(specsDir, file));
1009
+ if (!content) continue;
1010
+ const fmt = detectSpecFormat(content);
1011
+ const { frontmatter } = fmt === 'bold-key'
1012
+ ? parseBoldKeyFrontmatter(content)
1013
+ : parseSpecFrontmatter(content);
1014
+ const idLower = (frontmatter.id || '').toLowerCase();
1015
+ const titleLower = (frontmatter.title || '').toLowerCase();
1016
+ if (idLower.includes(searchLower) || titleLower.includes(searchLower)) {
1017
+ suggestions.push(frontmatter.id || file);
1018
+ }
1019
+ if (suggestions.length >= 3) break;
1020
+ }
1021
+ } catch { /* ignore */ }
1022
+ const suggestionText = suggestions.length > 0
1023
+ ? `. Did you mean: ${suggestions.join(', ')}?`
1024
+ : '';
1025
+ error(`spec not found: ${idOrFilename}${suggestionText}`);
1026
+ }
1027
+
1028
+ // Read the full body content (use the spec.body from findSpecFile)
1029
+ const fullContent = fs.readFileSync(spec.path, 'utf-8');
1030
+
1031
+ // Parse section headings from the full content
1032
+ const lines = fullContent.split('\n');
1033
+ const sectionsFound = [];
1034
+ for (const line of lines) {
1035
+ const sectionMatch = line.match(/^##\s+(?:\d+\.\s+)?(.*)/);
1036
+ if (sectionMatch) {
1037
+ sectionsFound.push(sectionMatch[1].trim());
1038
+ }
1039
+ }
1040
+
1041
+ const errors = [];
1042
+ const warnings = [];
1043
+ const info = [];
1044
+
1045
+ // P0 (errors): Problem/Problem Statement, Requirements, Non-Goals/Scope
1046
+ const problemPattern = /^##\s+(?:\d+\.\s+)?(?:Problem(?:\s+Statement)?)/im;
1047
+ const requirementsPattern = /^##\s+(?:\d+\.\s+)?Requirements/im;
1048
+ const nonGoalsPattern = /^##\s+(?:\d+\.\s+)?(?:Non[- ]Goals|Scope)/im;
1049
+
1050
+ if (!problemPattern.test(fullContent)) {
1051
+ errors.push({ code: 'MISSING_SECTION', section: 'Problem', tier: 'P0', message: 'Missing required section: Problem' });
1052
+ }
1053
+ if (!requirementsPattern.test(fullContent)) {
1054
+ errors.push({ code: 'MISSING_SECTION', section: 'Requirements', tier: 'P0', message: 'Missing required section: Requirements' });
1055
+ }
1056
+ if (!nonGoalsPattern.test(fullContent)) {
1057
+ errors.push({ code: 'MISSING_SECTION', section: 'Non-Goals', tier: 'P0', message: 'Missing required section: Non-Goals' });
1058
+ }
1059
+
1060
+ // Check for P0 requirement subsection under Requirements
1061
+ const p0Pattern = /^###\s+P0/im;
1062
+ if (requirementsPattern.test(fullContent) && !p0Pattern.test(fullContent)) {
1063
+ errors.push({ code: 'NO_P0_REQUIREMENT', message: 'No P0 requirement found in Requirements section' });
1064
+ }
1065
+
1066
+ // P1 (warnings): User Stories, Success Metrics
1067
+ const userStoriesPattern = /^##\s+(?:\d+\.\s+)?User\s+Stories/im;
1068
+ const successMetricsPattern = /^##\s+(?:\d+\.\s+)?Success\s+Metrics/im;
1069
+
1070
+ if (!userStoriesPattern.test(fullContent)) {
1071
+ warnings.push({ code: 'MISSING_SECTION', section: 'User Stories', tier: 'P1', message: 'Missing recommended section: User Stories' });
1072
+ }
1073
+ if (!successMetricsPattern.test(fullContent)) {
1074
+ warnings.push({ code: 'MISSING_SECTION', section: 'Success Metrics', tier: 'P1', message: 'Missing recommended section: Success Metrics' });
1075
+ }
1076
+
1077
+ const implNotesPattern = /^##\s+(?:\d+\.\s+)?Implementation\s+Notes/im;
1078
+ if (!implNotesPattern.test(fullContent)) {
1079
+ warnings.push({ code: 'MISSING_SECTION', section: 'Implementation Notes', tier: 'P1', message: 'Missing recommended section: Implementation Notes' });
1080
+ }
1081
+
1082
+ // P2 (info): other sections that are present (metadata sections excluded)
1083
+ const metadataSections = ['Refinement Log', 'Review History'];
1084
+ for (const section of sectionsFound) {
1085
+ // Skip P0 and P1 sections (already classified)
1086
+ if (/^(?:Problem(?:\s+Statement)?|Requirements|Non[- ]Goals|Scope|User\s+Stories|Success\s+Metrics|Implementation\s+Notes)$/i.test(section)) continue;
1087
+ // Skip metadata sections
1088
+ if (metadataSections.some(m => section.toLowerCase() === m.toLowerCase())) continue;
1089
+ info.push({ section, tier: 'P2', message: `Additional section found: ${section}` });
1090
+ }
1091
+
1092
+ const result = {
1093
+ id: spec.frontmatter.id,
1094
+ valid: errors.length === 0,
1095
+ errors,
1096
+ warnings,
1097
+ info,
1098
+ sections_found: sectionsFound,
1099
+ error_count: errors.length,
1100
+ warning_count: warnings.length,
1101
+ };
1102
+
1103
+ if (raw) {
1104
+ // Format as grouped list for raw mode
1105
+ let text = '';
1106
+ if (errors.length > 0) {
1107
+ text += 'Errors:\n';
1108
+ for (const e of errors) text += ` - [${e.code}] ${e.message}\n`;
1109
+ }
1110
+ if (warnings.length > 0) {
1111
+ text += 'Warnings:\n';
1112
+ for (const w of warnings) text += ` - [${w.code}] ${w.message}\n`;
1113
+ }
1114
+ text += `\nErrors: ${errors.length}, Warnings: ${warnings.length}\n`;
1115
+ text += errors.length === 0 ? 'PASS' : 'FAIL';
1116
+ process.stdout.write(text + '\n');
1117
+ } else {
1118
+ output(result);
1119
+ }
1120
+ }
1121
+
1122
+ // ─── Add Log Entry ───────────────────────────────────────────────────────────
1123
+
1124
+ /**
1125
+ * Add a log entry to the spec's Refinement Log table.
1126
+ * Creates the section if it does not exist.
1127
+ *
1128
+ * @param {string} cwd - Working directory
1129
+ * @param {string} idOrFilename - Spec id or filename
1130
+ * @param {string} date - Entry date
1131
+ * @param {string} version - Version string
1132
+ * @param {string} action - Action type (Created, Refined, Approved, Imported)
1133
+ * @param {string} summary - Entry summary
1134
+ * @param {boolean} raw - Raw output mode
1135
+ * @param {string} [author] - Author string for attribution
1136
+ */
1137
+ function cmdSpecsAddLogEntry(cwd, idOrFilename, date, version, action, summary, raw, author) {
1138
+ if (!idOrFilename) {
1139
+ error('id required for specs add-log-entry');
1140
+ }
1141
+ if (!date) {
1142
+ error('date required for specs add-log-entry');
1143
+ }
1144
+ if (!version) {
1145
+ error('version required for specs add-log-entry');
1146
+ }
1147
+ if (!action) {
1148
+ error('action required for specs add-log-entry');
1149
+ }
1150
+ if (!summary) {
1151
+ error('summary required for specs add-log-entry');
1152
+ }
1153
+
1154
+ // Validate action
1155
+ const validActions = ['Created', 'Refined', 'Approved', 'Imported'];
1156
+ if (!validActions.includes(action)) {
1157
+ error(`Invalid action '${action}'. Valid actions: ${validActions.join(', ')}`);
1158
+ }
1159
+
1160
+ const spec = findSpecFile(cwd, idOrFilename);
1161
+ if (!spec) {
1162
+ error(`spec not found: ${idOrFilename}`);
1163
+ }
1164
+
1165
+ // Read the full file content from disk to preserve exact formatting
1166
+ let fileContent = fs.readFileSync(spec.path, 'utf-8');
1167
+
1168
+ // Look for existing Refinement Log section
1169
+ const logSectionPattern = /^## Refinement Log/m;
1170
+ const logMatch = fileContent.match(logSectionPattern);
1171
+
1172
+ if (logMatch) {
1173
+ // Find the last table row after the section heading
1174
+ const afterSection = fileContent.slice(logMatch.index);
1175
+ const afterLines = afterSection.split('\n');
1176
+ let lastTableRowIndex = -1;
1177
+
1178
+ for (let i = 0; i < afterLines.length; i++) {
1179
+ if (/^\|/.test(afterLines[i])) {
1180
+ lastTableRowIndex = i;
1181
+ }
1182
+ }
1183
+
1184
+ if (lastTableRowIndex !== -1) {
1185
+ // Insert new row after the last table row
1186
+ afterLines.splice(lastTableRowIndex + 1, 0, `| ${date} | ${version} | ${action} | ${summary} |`);
1187
+ fileContent = fileContent.slice(0, logMatch.index) + afterLines.join('\n');
1188
+ } else {
1189
+ // Section exists but no table — add table after heading
1190
+ const insertPos = logMatch.index + logMatch[0].length;
1191
+ const tableContent = `\n\n| Date | Version | Action | Summary |\n|------|---------|--------|--------|\n| ${date} | ${version} | ${action} | ${summary} |`;
1192
+ fileContent = fileContent.slice(0, insertPos) + tableContent + fileContent.slice(insertPos);
1193
+ }
1194
+ } else {
1195
+ // Section does not exist — append to end of file
1196
+ const tableContent = `\n## Refinement Log\n\n| Date | Version | Action | Summary |\n|------|---------|--------|--------|\n| ${date} | ${version} | ${action} | ${summary} |\n`;
1197
+ fileContent = fileContent.trimEnd() + '\n' + tableContent;
1198
+ }
1199
+
1200
+ fs.writeFileSync(spec.path, fileContent, 'utf-8');
1201
+
1202
+ const result = {
1203
+ id: spec.frontmatter.id,
1204
+ filename: spec.filename,
1205
+ action,
1206
+ version,
1207
+ summary,
1208
+ message: `\u2713 ${spec.filename}: ${action} log entry added (v${version})`,
1209
+ };
1210
+ output(result, raw);
1211
+ }
1212
+
1213
+ // ─── Link Milestone ──────────────────────────────────────────────────────────
1214
+
1215
+ /**
1216
+ * Link a milestone to a spec by adding it to the milestones array.
1217
+ * No duplicates allowed.
1218
+ *
1219
+ * @param {string} cwd - Working directory
1220
+ * @param {string} idOrFilename - Spec id or filename
1221
+ * @param {string} milestone - Milestone version string (e.g., "v13.0")
1222
+ * @param {boolean} raw - Raw output mode
1223
+ * @param {string} [author] - Author string for updated_by
1224
+ */
1225
+ function cmdSpecsLinkMilestone(cwd, idOrFilename, milestone, raw, author) {
1226
+ if (!idOrFilename) {
1227
+ error('id required for specs link-milestone');
1228
+ }
1229
+ if (!milestone) {
1230
+ error('milestone required for specs link-milestone');
1231
+ }
1232
+
1233
+ const spec = findSpecFile(cwd, idOrFilename);
1234
+ if (!spec) {
1235
+ error(`spec not found: ${idOrFilename}`);
1236
+ }
1237
+
1238
+ // Read file content and detect format
1239
+ const fileContent = fs.readFileSync(spec.path, 'utf-8');
1240
+ const format = detectSpecFormat(fileContent);
1241
+
1242
+ // Check for duplicate
1243
+ const milestones = spec.frontmatter.milestones || [];
1244
+ if (milestones.includes(milestone)) {
1245
+ const result = {
1246
+ id: spec.frontmatter.id,
1247
+ filename: spec.filename,
1248
+ milestone,
1249
+ milestones,
1250
+ message: `${spec.filename}: already linked to ${milestone}`,
1251
+ };
1252
+ output(result, raw);
1253
+ return;
1254
+ }
1255
+
1256
+ // Add milestone
1257
+ milestones.push(milestone);
1258
+ spec.frontmatter.milestones = milestones;
1259
+
1260
+ // Update timestamp and author
1261
+ spec.frontmatter.updated = new Date().toISOString();
1262
+ if (author) {
1263
+ spec.frontmatter.updated_by = author;
1264
+ }
1265
+
1266
+ // Write back using correct format builder
1267
+ const content = format === 'bold-key'
1268
+ ? buildBoldKeyContent(spec.frontmatter, spec.body)
1269
+ : buildSpecContent(spec.frontmatter, spec.body);
1270
+ fs.writeFileSync(spec.path, content, 'utf-8');
1271
+
1272
+ const result = {
1273
+ id: spec.frontmatter.id,
1274
+ filename: spec.filename,
1275
+ milestone,
1276
+ milestones: spec.frontmatter.milestones,
1277
+ message: `\u2713 ${spec.filename}: linked to ${milestone}`,
1278
+ };
1279
+ output(result, raw);
1280
+ }
1281
+
1282
+ // ─── Exports ──────────────────────────────────────────────────────────────────
1283
+
1284
+ module.exports = {
1285
+ ensureSpecsDir,
1286
+ normalizeStatus,
1287
+ getShippedMilestones,
1288
+ parseSpecFrontmatter,
1289
+ detectSpecFormat,
1290
+ parseBoldKeyFrontmatter,
1291
+ buildBoldKeyContent,
1292
+ buildSpecContent,
1293
+ findSpecFile,
1294
+ cmdSpecsCreate,
1295
+ cmdSpecsList,
1296
+ cmdSpecsUpdateStatus,
1297
+ cmdSpecsAppendReviewHistory,
1298
+ cmdSpecsFinalize,
1299
+ cmdSpecsSetStatus,
1300
+ cmdSpecsValidate,
1301
+ cmdSpecsAddLogEntry,
1302
+ cmdSpecsLinkMilestone,
1303
+ };