@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,1406 @@
1
+ /**
2
+ * Ideas — Idea capture, CRUD, manifest-based ID allocation, state transitions,
3
+ * and section-aware parsing/building with Discussion Log and Research Log support.
4
+ *
5
+ * Provides the data model and operations for the ideas lifecycle:
6
+ * create, update, append-note, list, reject, restore, move-state,
7
+ * discuss-save, research-save, appendDiscussionEntry, and appendResearchEntry.
8
+ *
9
+ * Ideas are stored as markdown files with YAML frontmatter in
10
+ * .planning/ideas/{pending,done,rejected}/ directories.
11
+ * A manifest.json tracks the next available sequential ID (never reused).
12
+ *
13
+ * Section order: Body > ## Notes > ## Discussion Log > ## Research Log
14
+ * Sections are optional; headings only appear when content exists.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { safeReadFile, execGit, generateSlugInternal, output, error } = require('./core.cjs');
20
+ const { getPlanningRoot } = require('./paths.cjs');
21
+ const { extractNameFromAuthor } = require('./identity.cjs');
22
+
23
+ // ─── Manifest Management ─────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Load the ideas manifest from .planning/ideas/manifest.json.
27
+ * Returns { next_id: N }. If missing, returns { next_id: 1 }.
28
+ *
29
+ * @param {string} cwd - Working directory
30
+ * @returns {{ next_id: number }}
31
+ */
32
+ function loadManifest(cwd) {
33
+ const manifestPath = path.join(getPlanningRoot(cwd), 'ideas', 'manifest.json');
34
+ const content = safeReadFile(manifestPath);
35
+ if (!content) return { next_id: 1 };
36
+ try {
37
+ const parsed = JSON.parse(content);
38
+ return { next_id: parsed.next_id || 1 };
39
+ } catch {
40
+ return { next_id: 1 };
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Save the ideas manifest to .planning/ideas/manifest.json.
46
+ *
47
+ * @param {string} cwd - Working directory
48
+ * @param {{ next_id: number }} manifest
49
+ */
50
+ function saveManifest(cwd, manifest) {
51
+ const manifestDir = path.join(getPlanningRoot(cwd), 'ideas');
52
+ fs.mkdirSync(manifestDir, { recursive: true });
53
+ const manifestPath = path.join(manifestDir, 'manifest.json');
54
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
55
+ }
56
+
57
+ /**
58
+ * Allocate a new unique ID from the manifest. Increments next_id and saves.
59
+ * Returns zero-padded 3-digit string (e.g., "001", "042").
60
+ * IDs are never reused -- next_id only goes up.
61
+ *
62
+ * @param {string} cwd - Working directory
63
+ * @returns {string} Zero-padded ID string
64
+ */
65
+ function allocateId(cwd) {
66
+ const manifest = loadManifest(cwd);
67
+ const id = manifest.next_id;
68
+ manifest.next_id = id + 1;
69
+ saveManifest(cwd, manifest);
70
+ return String(id).padStart(3, '0');
71
+ }
72
+
73
+ // ─── Directory & File Helpers ─────────────────────────────────────────────────
74
+
75
+ const IDEA_STATES = ['pending', 'done', 'rejected', 'consolidated'];
76
+
77
+ /**
78
+ * Ensure .planning/ideas/{pending,done,rejected}/ directories exist.
79
+ *
80
+ * @param {string} cwd - Working directory
81
+ */
82
+ function ensureIdeasDirs(cwd) {
83
+ for (const state of IDEA_STATES) {
84
+ fs.mkdirSync(path.join(getPlanningRoot(cwd), 'ideas', state), { recursive: true });
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Parse YAML frontmatter from idea file content.
90
+ * Uses simple regex parsing (no YAML library -- zero-dependency project).
91
+ *
92
+ * Section-aware: splits content after frontmatter into body, notes,
93
+ * discussionLog, and researchLog based on H2 markers.
94
+ * Section markers are case-sensitive: `## Notes`, `## Discussion Log`, `## Research Log`.
95
+ *
96
+ * @param {string} content - File content
97
+ * @returns {{ frontmatter: object, body: string, notes: string, discussionLog: string, researchLog: string }}
98
+ */
99
+ function parseIdeaFrontmatter(content) {
100
+ const match = content.match(/^---\n([\s\S]+?)\n---\n?([\s\S]*)/);
101
+ if (!match) return { frontmatter: {}, body: content, notes: '', discussionLog: '', researchLog: '' };
102
+
103
+ const yaml = match[1];
104
+ const remaining = match[2] || '';
105
+ const frontmatter = {};
106
+
107
+ const lines = yaml.split('\n');
108
+ for (const line of lines) {
109
+ if (line.trim() === '') continue;
110
+
111
+ // Handle array values (tags)
112
+ const arrayMatch = line.match(/^(\w+):\s*\[([^\]]*)\]/);
113
+ if (arrayMatch) {
114
+ const key = arrayMatch[1];
115
+ const values = arrayMatch[2]
116
+ .split(',')
117
+ .map(v => v.trim().replace(/^["']|["']$/g, ''))
118
+ .filter(v => v !== '');
119
+ frontmatter[key] = values;
120
+ continue;
121
+ }
122
+
123
+ // Handle simple key: value
124
+ const kvMatch = line.match(/^(\w+):\s*(.*)/);
125
+ if (kvMatch) {
126
+ const key = kvMatch[1];
127
+ let value = kvMatch[2].trim();
128
+ // Remove quotes
129
+ value = value.replace(/^["']|["']$/g, '');
130
+ // Parse numeric id
131
+ if (key === 'id') {
132
+ frontmatter[key] = parseInt(value, 10);
133
+ } else {
134
+ frontmatter[key] = value;
135
+ }
136
+ }
137
+ }
138
+
139
+ // Split remaining content into sections using H2 markers (case-sensitive)
140
+ const sectionMarkers = [
141
+ { key: 'notes', heading: '## Notes' },
142
+ { key: 'discussionLog', heading: '## Discussion Log' },
143
+ { key: 'researchLog', heading: '## Research Log' },
144
+ ];
145
+
146
+ // Find position of each marker in the remaining content
147
+ const positions = [];
148
+ for (const marker of sectionMarkers) {
149
+ // Match the heading at the start of a line (after newline or at start)
150
+ const pattern = new RegExp('(?:^|\\n)(' + marker.heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')\\n');
151
+ const m = remaining.match(pattern);
152
+ if (m) {
153
+ // Find the actual index of the heading in remaining
154
+ const idx = remaining.indexOf(m[1], m.index);
155
+ positions.push({ key: marker.key, heading: marker.heading, index: idx });
156
+ }
157
+ }
158
+
159
+ // Sort positions by index
160
+ positions.sort((a, b) => a.index - b.index);
161
+
162
+ // Extract body (everything before first section marker, or all content if no markers)
163
+ let body = '';
164
+ const sections = { notes: '', discussionLog: '', researchLog: '' };
165
+
166
+ if (positions.length === 0) {
167
+ body = remaining;
168
+ } else {
169
+ body = remaining.substring(0, positions[0].index);
170
+ // Extract each section's content
171
+ for (let i = 0; i < positions.length; i++) {
172
+ const pos = positions[i];
173
+ const headingEnd = pos.index + pos.heading.length + 1; // +1 for the newline after heading
174
+ const nextStart = (i + 1 < positions.length) ? positions[i + 1].index : remaining.length;
175
+ sections[pos.key] = remaining.substring(headingEnd, nextStart);
176
+ }
177
+ }
178
+
179
+ // Trim trailing whitespace from sections but preserve internal formatting
180
+ // Remove leading newline from section content (the one right after the heading)
181
+ for (const key of Object.keys(sections)) {
182
+ if (sections[key]) {
183
+ sections[key] = sections[key].replace(/^\n/, '');
184
+ }
185
+ }
186
+
187
+ return { frontmatter, body, notes: sections.notes, discussionLog: sections.discussionLog, researchLog: sections.researchLog };
188
+ }
189
+
190
+ /**
191
+ * Build idea file content from frontmatter and sections.
192
+ *
193
+ * Assembles sections in order: body, ## Notes, ## Discussion Log, ## Research Log.
194
+ * Section headings are only emitted when the section has non-empty content.
195
+ *
196
+ * @param {object} frontmatter - Frontmatter fields
197
+ * @param {string} body - Body content
198
+ * @param {string} [notes=''] - Notes section content (without ## Notes heading)
199
+ * @param {string} [discussionLog=''] - Discussion Log content (without ## Discussion Log heading)
200
+ * @param {string} [researchLog=''] - Research Log content (without ## Research Log heading)
201
+ * @returns {string} Full file content
202
+ */
203
+ function buildIdeaContent(frontmatter, body, notes, discussionLog, researchLog) {
204
+ notes = notes || '';
205
+ discussionLog = discussionLog || '';
206
+ researchLog = researchLog || '';
207
+
208
+ let yaml = '---\n';
209
+ yaml += `id: ${frontmatter.id}\n`;
210
+ yaml += `title: "${frontmatter.title}"\n`;
211
+ if (frontmatter.tags && frontmatter.tags.length > 0) {
212
+ yaml += `tags: [${frontmatter.tags.map(t => `"${t}"`).join(', ')}]\n`;
213
+ } else {
214
+ yaml += 'tags: []\n';
215
+ }
216
+ yaml += `created: ${frontmatter.created}\n`;
217
+ yaml += `updated: ${frontmatter.updated}\n`;
218
+ if (frontmatter.author) {
219
+ yaml += `author: "${frontmatter.author}"\n`;
220
+ }
221
+ if (frontmatter.updated_by) {
222
+ yaml += `updated_by: "${frontmatter.updated_by}"\n`;
223
+ }
224
+ if (frontmatter.consolidated_from && frontmatter.consolidated_from.length > 0) {
225
+ yaml += `consolidated_from: [${frontmatter.consolidated_from.map(id => `"${id}"`).join(', ')}]\n`;
226
+ }
227
+ if (frontmatter.consolidated_into) {
228
+ yaml += `consolidated_into: "${frontmatter.consolidated_into}"\n`;
229
+ }
230
+ yaml += '---\n';
231
+
232
+ // Body
233
+ if (body) {
234
+ yaml += '\n' + body;
235
+ }
236
+
237
+ // Ensure a blank line before sections
238
+ if (notes || discussionLog || researchLog) {
239
+ // Ensure the body ends with a newline for clean separation
240
+ if (!yaml.endsWith('\n')) {
241
+ yaml += '\n';
242
+ }
243
+ }
244
+
245
+ // Notes section
246
+ if (notes) {
247
+ yaml += '\n## Notes\n\n' + notes;
248
+ if (!notes.endsWith('\n')) {
249
+ yaml += '\n';
250
+ }
251
+ }
252
+
253
+ // Discussion Log section
254
+ if (discussionLog) {
255
+ yaml += '\n## Discussion Log\n\n' + discussionLog;
256
+ if (!discussionLog.endsWith('\n')) {
257
+ yaml += '\n';
258
+ }
259
+ }
260
+
261
+ // Research Log section
262
+ if (researchLog) {
263
+ yaml += '\n## Research Log\n\n' + researchLog;
264
+ if (!researchLog.endsWith('\n')) {
265
+ yaml += '\n';
266
+ }
267
+ }
268
+
269
+ return yaml;
270
+ }
271
+
272
+ /**
273
+ * Find an idea file by numeric ID or exact filename across all state directories.
274
+ *
275
+ * @param {string} cwd - Working directory
276
+ * @param {string} idOrFilename - Numeric ID (e.g., "42", "042") or filename
277
+ * @returns {{ path: string, state: string, filename: string, frontmatter: object, body: string, notes: string, discussionLog: string, researchLog: string } | null}
278
+ */
279
+ function findIdeaFile(cwd, idOrFilename) {
280
+ if (!idOrFilename) return null;
281
+
282
+ const idStr = idOrFilename.replace(/^0+/, '') || '0';
283
+ const paddedId = String(parseInt(idStr, 10)).padStart(3, '0');
284
+
285
+ for (const state of IDEA_STATES) {
286
+ const dir = path.join(getPlanningRoot(cwd), 'ideas', state);
287
+ let files;
288
+ try {
289
+ files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
290
+ } catch {
291
+ continue;
292
+ }
293
+
294
+ for (const file of files) {
295
+ // Match by ID prefix
296
+ if (file.startsWith(paddedId + '-')) {
297
+ const content = safeReadFile(path.join(dir, file));
298
+ if (!content) continue;
299
+ const { frontmatter, body, notes, discussionLog, researchLog } = parseIdeaFrontmatter(content);
300
+ return { path: path.join(dir, file), state, filename: file, frontmatter, body, notes, discussionLog, researchLog };
301
+ }
302
+ // Match by exact filename
303
+ if (file === idOrFilename) {
304
+ const content = safeReadFile(path.join(dir, file));
305
+ if (!content) continue;
306
+ const { frontmatter, body, notes, discussionLog, researchLog } = parseIdeaFrontmatter(content);
307
+ return { path: path.join(dir, file), state, filename: file, frontmatter, body, notes, discussionLog, researchLog };
308
+ }
309
+ }
310
+ }
311
+
312
+ return null;
313
+ }
314
+
315
+ // ─── CRUD Operations ──────────────────────────────────────────────────────────
316
+
317
+ /**
318
+ * Create a new idea with auto-assigned ID.
319
+ *
320
+ * @param {string} cwd - Working directory
321
+ * @param {string} title - Idea title
322
+ * @param {string} body - Freeform body text
323
+ * @param {string} tags - Comma-separated tags string (or null)
324
+ * @param {boolean} raw - Raw output mode
325
+ * @param {string} [author] - Author string (e.g., "Name <email>"). When provided, sets author and updated_by.
326
+ */
327
+ function cmdIdeasCreate(cwd, title, body, tags, raw, author) {
328
+ if (!title) {
329
+ error('title required for ideas create');
330
+ }
331
+
332
+ ensureIdeasDirs(cwd);
333
+
334
+ const id = allocateId(cwd);
335
+ const slug = generateSlugInternal(title);
336
+ const filename = `${id}-${slug}.md`;
337
+ const planRoot = getPlanningRoot(cwd);
338
+ const planRootRel = path.relative(cwd, planRoot) || '.';
339
+ const filePath = path.join(planRoot, 'ideas', 'pending', filename);
340
+ const now = new Date().toISOString();
341
+
342
+ const parsedTags = tags
343
+ ? tags.split(',').map(t => t.trim()).filter(t => t !== '')
344
+ : [];
345
+
346
+ const frontmatter = {
347
+ id: parseInt(id, 10),
348
+ title,
349
+ tags: parsedTags,
350
+ created: now,
351
+ updated: now,
352
+ };
353
+
354
+ if (author) {
355
+ frontmatter.author = author;
356
+ frontmatter.updated_by = author;
357
+ }
358
+
359
+ const content = buildIdeaContent(frontmatter, body || '');
360
+ fs.writeFileSync(filePath, content, 'utf-8');
361
+
362
+ const result = {
363
+ id: parseInt(id, 10),
364
+ filename,
365
+ path: path.join(planRootRel, 'ideas', 'pending', filename),
366
+ title,
367
+ };
368
+ output(result, raw);
369
+ }
370
+
371
+ /**
372
+ * Update a field on an existing idea.
373
+ *
374
+ * @param {string} cwd - Working directory
375
+ * @param {string} idOrFilename - Idea ID or filename
376
+ * @param {string} field - Field to update (title, body, tags)
377
+ * @param {string} value - New value
378
+ * @param {boolean} raw - Raw output mode
379
+ * @param {string} [author] - Author string. When provided, sets updated_by (never changes original author).
380
+ */
381
+ function cmdIdeasUpdate(cwd, idOrFilename, field, value, raw, author) {
382
+ if (!idOrFilename) {
383
+ error('id required for ideas update');
384
+ }
385
+ if (!field) {
386
+ error('field required for ideas update');
387
+ }
388
+ if (value === null || value === undefined) {
389
+ error('value required for ideas update');
390
+ }
391
+
392
+ const idea = findIdeaFile(cwd, idOrFilename);
393
+ if (!idea) {
394
+ error(`idea not found: ${idOrFilename}`);
395
+ }
396
+
397
+ const fm = idea.frontmatter;
398
+ let oldValue;
399
+ let body = idea.body;
400
+
401
+ if (field === 'title') {
402
+ oldValue = fm.title;
403
+ fm.title = value;
404
+ } else if (field === 'body') {
405
+ oldValue = body.trim();
406
+ body = value;
407
+ } else if (field === 'tags') {
408
+ oldValue = (fm.tags || []).join(', ');
409
+ fm.tags = value.split(',').map(t => t.trim()).filter(t => t !== '');
410
+ } else {
411
+ error(`unknown field: ${field}. Use title, body, or tags`);
412
+ }
413
+
414
+ fm.updated = new Date().toISOString();
415
+ if (author) {
416
+ fm.updated_by = author;
417
+ }
418
+
419
+ const content = buildIdeaContent(fm, body, idea.notes, idea.discussionLog, idea.researchLog);
420
+ fs.writeFileSync(idea.path, content, 'utf-8');
421
+
422
+ // If title changed, rename the file
423
+ let newFilename = idea.filename;
424
+ if (field === 'title') {
425
+ const idPadded = String(fm.id).padStart(3, '0');
426
+ const newSlug = generateSlugInternal(value);
427
+ newFilename = `${idPadded}-${newSlug}.md`;
428
+ if (newFilename !== idea.filename) {
429
+ const newPath = path.join(path.dirname(idea.path), newFilename);
430
+ fs.renameSync(idea.path, newPath);
431
+ }
432
+ }
433
+
434
+ const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
435
+ const result = {
436
+ id: fm.id,
437
+ filename: newFilename,
438
+ path: path.join(planRootRel, 'ideas', idea.state, newFilename),
439
+ field,
440
+ old_value: oldValue,
441
+ new_value: value,
442
+ };
443
+ output(result, raw);
444
+ }
445
+
446
+ /**
447
+ * Append a timestamped note to an idea.
448
+ *
449
+ * @param {string} cwd - Working directory
450
+ * @param {string} idOrFilename - Idea ID or filename
451
+ * @param {string} noteText - Note text
452
+ * @param {boolean} raw - Raw output mode
453
+ * @param {string} [author] - Author string. When provided, sets updated_by (never changes original author).
454
+ */
455
+ function cmdIdeasAppendNote(cwd, idOrFilename, noteText, raw, author) {
456
+ if (!idOrFilename) {
457
+ error('id required for ideas append-note');
458
+ }
459
+ if (!noteText) {
460
+ error('note required for ideas append-note');
461
+ }
462
+
463
+ const idea = findIdeaFile(cwd, idOrFilename);
464
+ if (!idea) {
465
+ error(`idea not found: ${idOrFilename}`);
466
+ }
467
+
468
+ const fm = idea.frontmatter;
469
+ fm.updated = new Date().toISOString();
470
+ if (author) {
471
+ fm.updated_by = author;
472
+ }
473
+
474
+ const now = new Date().toISOString().split('T')[0];
475
+ const noteEntry = `---\n**${now}:** ${noteText}\n`;
476
+
477
+ // Append new note to existing notes section
478
+ const updatedNotes = idea.notes
479
+ ? idea.notes + noteEntry
480
+ : noteEntry;
481
+
482
+ const content = buildIdeaContent(fm, idea.body, updatedNotes, idea.discussionLog, idea.researchLog);
483
+ fs.writeFileSync(idea.path, content, 'utf-8');
484
+
485
+ const result = {
486
+ id: fm.id,
487
+ filename: idea.filename,
488
+ note_added: true,
489
+ };
490
+ output(result, raw);
491
+ }
492
+
493
+ /**
494
+ * List ideas grouped by state with optional filtering.
495
+ *
496
+ * @param {string} cwd - Working directory
497
+ * @param {{ state: string|null, tag: string|null, include_orphan_check: boolean, include_consolidated: boolean, show_all: boolean }} options
498
+ * @param {boolean} raw - Raw output mode
499
+ */
500
+ function cmdIdeasList(cwd, options, raw) {
501
+ const ideas = [];
502
+ const counts = { pending: 0, done: 0, rejected: 0, consolidated: 0, total: 0 };
503
+ const orphanedIds = [];
504
+
505
+ // Determine which states to iterate
506
+ let statesToList;
507
+ if (options.state) {
508
+ statesToList = [options.state];
509
+ } else if (options.include_consolidated) {
510
+ statesToList = ['consolidated'];
511
+ } else if (options.show_all) {
512
+ statesToList = IDEA_STATES;
513
+ } else {
514
+ statesToList = ['pending', 'done', 'rejected']; // default: hide consolidated
515
+ }
516
+
517
+ for (const state of statesToList) {
518
+
519
+ const dir = path.join(getPlanningRoot(cwd), 'ideas', state);
520
+ let files;
521
+ try {
522
+ files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
523
+ } catch {
524
+ continue;
525
+ }
526
+
527
+ for (const file of files) {
528
+ const content = safeReadFile(path.join(dir, file));
529
+ if (!content) continue;
530
+
531
+ const { frontmatter, discussionLog, researchLog } = parseIdeaFrontmatter(content);
532
+
533
+ // Tag filter (case-insensitive)
534
+ if (options.tag) {
535
+ const tagLower = options.tag.toLowerCase();
536
+ const tags = (frontmatter.tags || []).map(t => t.toLowerCase());
537
+ if (!tags.includes(tagLower)) continue;
538
+ }
539
+
540
+ // Author filter (case-insensitive substring match on name-only)
541
+ const authorName = extractNameFromAuthor(frontmatter.author || '');
542
+ if (options.author) {
543
+ const filterAuthor = options.author.toLowerCase();
544
+ if (!authorName.toLowerCase().includes(filterAuthor)) continue;
545
+ }
546
+
547
+ const ideaObj = {
548
+ id: frontmatter.id,
549
+ title: frontmatter.title || '',
550
+ tags: frontmatter.tags || [],
551
+ created: frontmatter.created || '',
552
+ updated: frontmatter.updated || '',
553
+ author: authorName,
554
+ state,
555
+ filename: file,
556
+ discussed: !!(discussionLog && discussionLog.trim().length > 0),
557
+ researched: !!(researchLog && researchLog.trim().length > 0),
558
+ };
559
+
560
+ if (state === 'consolidated' && frontmatter.consolidated_into) {
561
+ ideaObj.consolidated_into = frontmatter.consolidated_into;
562
+ }
563
+
564
+ ideas.push(ideaObj);
565
+
566
+ counts[state]++;
567
+ counts.total++;
568
+ }
569
+ }
570
+
571
+ // Orphan check: scan specs for references to done idea IDs
572
+ if (options.include_orphan_check) {
573
+ const doneIdeas = ideas.filter(i => i.state === 'done');
574
+ const specsDir = path.join(getPlanningRoot(cwd), 'specs');
575
+
576
+ let specContent = '';
577
+ try {
578
+ const specFiles = fs.readdirSync(specsDir).filter(f => f.endsWith('.md'));
579
+ for (const sf of specFiles) {
580
+ specContent += safeReadFile(path.join(specsDir, sf)) || '';
581
+ }
582
+ } catch {
583
+ // No specs dir
584
+ }
585
+
586
+ for (const idea of doneIdeas) {
587
+ const idPadded = String(idea.id).padStart(3, '0');
588
+ // Check if any spec references this idea's ID
589
+ if (!specContent.includes(idPadded) && !specContent.includes(String(idea.id))) {
590
+ idea.orphaned = true;
591
+ orphanedIds.push(idea.id);
592
+ }
593
+ }
594
+ }
595
+
596
+ const result = {
597
+ ideas,
598
+ counts,
599
+ orphaned_ids: orphanedIds,
600
+ };
601
+ output(result, raw);
602
+ }
603
+
604
+ /**
605
+ * Reject an idea (move from pending to rejected).
606
+ *
607
+ * @param {string} cwd - Working directory
608
+ * @param {string} idOrFilename - Idea ID or filename
609
+ * @param {string} reason - Rejection reason (optional)
610
+ * @param {boolean} raw - Raw output mode
611
+ * @param {string} [author] - Author string. When provided, sets updated_by before move.
612
+ */
613
+ function cmdIdeasReject(cwd, idOrFilename, reason, raw, author) {
614
+ if (!idOrFilename) {
615
+ error('id required for ideas reject');
616
+ }
617
+
618
+ const idea = findIdeaFile(cwd, idOrFilename);
619
+ if (!idea) {
620
+ error(`idea not found: ${idOrFilename}`);
621
+ }
622
+
623
+ if (idea.state !== 'pending') {
624
+ if (idea.state === 'rejected') {
625
+ error(`idea ${idOrFilename} is already rejected`);
626
+ }
627
+ error(`idea ${idOrFilename} is in ${idea.state} state, only pending ideas can be rejected`);
628
+ }
629
+
630
+ // Re-parse file to get all sections
631
+ const currentContent = safeReadFile(idea.path) || '';
632
+ const { frontmatter: fm, body: bd, notes: nt, discussionLog: dl, researchLog: rl } = parseIdeaFrontmatter(currentContent);
633
+
634
+ // Append rejection reason to notes section if provided
635
+ let updatedNotes = nt;
636
+ if (reason) {
637
+ const now = new Date().toISOString().split('T')[0];
638
+ const reasonEntry = `---\n**Rejected (${now}):** ${reason}\n`;
639
+ updatedNotes = nt ? nt + reasonEntry : reasonEntry;
640
+ }
641
+
642
+ // Set updated_by if author provided
643
+ if (author) {
644
+ fm.updated_by = author;
645
+ fm.updated = new Date().toISOString();
646
+ }
647
+
648
+ // Rebuild with all sections preserved
649
+ const rebuilt = buildIdeaContent(fm, bd, updatedNotes, dl, rl);
650
+ fs.writeFileSync(idea.path, rebuilt, 'utf-8');
651
+
652
+ ensureIdeasDirs(cwd);
653
+
654
+ // Git mv from pending to rejected
655
+ const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
656
+ const fromRel = path.join(planRootRel, 'ideas', 'pending', idea.filename);
657
+ const toRel = path.join(planRootRel, 'ideas', 'rejected', idea.filename);
658
+ execGit(cwd, ['mv', fromRel, toRel]);
659
+
660
+ // Auto-commit
661
+ execGit(cwd, ['add', '-A']);
662
+ const title = idea.frontmatter.title || idea.filename;
663
+ const idNum = idea.frontmatter.id || idOrFilename;
664
+ execGit(cwd, ['commit', '-m', `ideas: reject #${idNum} - ${title}`]);
665
+
666
+ const result = {
667
+ id: idea.frontmatter.id,
668
+ filename: idea.filename,
669
+ from: idea.state,
670
+ to: 'rejected',
671
+ reason: reason || null,
672
+ };
673
+ output(result, raw);
674
+ }
675
+
676
+ /**
677
+ * Restore an idea to pending (from rejected or done).
678
+ *
679
+ * @param {string} cwd - Working directory
680
+ * @param {string} idOrFilename - Idea ID or filename
681
+ * @param {boolean} raw - Raw output mode
682
+ * @param {string} [author] - Author string. When provided, sets updated_by before move.
683
+ */
684
+ function cmdIdeasRestore(cwd, idOrFilename, raw, author) {
685
+ if (!idOrFilename) {
686
+ error('id required for ideas restore');
687
+ }
688
+
689
+ const idea = findIdeaFile(cwd, idOrFilename);
690
+ if (!idea) {
691
+ error(`idea not found: ${idOrFilename}`);
692
+ }
693
+
694
+ if (idea.state === 'pending') {
695
+ error(`idea ${idOrFilename} is already in pending`);
696
+ }
697
+
698
+ // Remove consolidated_into when restoring from consolidated state
699
+ if (idea.state === 'consolidated') {
700
+ const currentContent = safeReadFile(idea.path) || '';
701
+ const { frontmatter: fm, body: bd, notes: nt, discussionLog: dl, researchLog: rl } = parseIdeaFrontmatter(currentContent);
702
+ delete fm.consolidated_into;
703
+ fm.updated = new Date().toISOString();
704
+ if (author) {
705
+ fm.updated_by = author;
706
+ }
707
+ fs.writeFileSync(idea.path, buildIdeaContent(fm, bd, nt, dl, rl), 'utf-8');
708
+ } else if (author) {
709
+ // Set updated_by for non-consolidated restores
710
+ const currentContent = safeReadFile(idea.path) || '';
711
+ const { frontmatter: fm, body: bd, notes: nt, discussionLog: dl, researchLog: rl } = parseIdeaFrontmatter(currentContent);
712
+ fm.updated_by = author;
713
+ fm.updated = new Date().toISOString();
714
+ fs.writeFileSync(idea.path, buildIdeaContent(fm, bd, nt, dl, rl), 'utf-8');
715
+ }
716
+
717
+ ensureIdeasDirs(cwd);
718
+
719
+ // Git mv to pending (keep rejection reason in file as history)
720
+ const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
721
+ const fromRel = path.join(planRootRel, 'ideas', idea.state, idea.filename);
722
+ const toRel = path.join(planRootRel, 'ideas', 'pending', idea.filename);
723
+ execGit(cwd, ['mv', fromRel, toRel]);
724
+
725
+ // Auto-commit
726
+ execGit(cwd, ['add', '-A']);
727
+ const title = idea.frontmatter.title || idea.filename;
728
+ const idNum = idea.frontmatter.id || idOrFilename;
729
+ execGit(cwd, ['commit', '-m', `ideas: restore #${idNum} - ${title} to pending`]);
730
+
731
+ const result = {
732
+ id: idea.frontmatter.id,
733
+ filename: idea.filename,
734
+ from: idea.state,
735
+ to: 'pending',
736
+ };
737
+ output(result, raw);
738
+ }
739
+
740
+ /**
741
+ * Move an idea to a target state (general-purpose state transition).
742
+ *
743
+ * @param {string} cwd - Working directory
744
+ * @param {string} idOrFilename - Idea ID or filename
745
+ * @param {string} targetState - Target state (pending, done, rejected)
746
+ * @param {boolean} raw - Raw output mode
747
+ * @param {string} [author] - Author string. When provided, sets updated_by before move.
748
+ */
749
+ function cmdIdeasMoveState(cwd, idOrFilename, targetState, raw, author) {
750
+ if (!idOrFilename) {
751
+ error('id required for ideas move-state');
752
+ }
753
+ if (!targetState) {
754
+ error('target state required for ideas move-state');
755
+ }
756
+ if (!IDEA_STATES.includes(targetState)) {
757
+ error(`invalid state: ${targetState}. Use ${IDEA_STATES.join(', ')}`);
758
+ }
759
+
760
+ const idea = findIdeaFile(cwd, idOrFilename);
761
+ if (!idea) {
762
+ error(`idea not found: ${idOrFilename}`);
763
+ }
764
+
765
+ if (idea.state === targetState) {
766
+ error(`idea ${idOrFilename} is already in ${targetState}`);
767
+ }
768
+
769
+ // Set updated_by if author provided
770
+ if (author) {
771
+ const currentContent = safeReadFile(idea.path) || '';
772
+ const { frontmatter: fm, body: bd, notes: nt, discussionLog: dl, researchLog: rl } = parseIdeaFrontmatter(currentContent);
773
+ fm.updated_by = author;
774
+ fm.updated = new Date().toISOString();
775
+ fs.writeFileSync(idea.path, buildIdeaContent(fm, bd, nt, dl, rl), 'utf-8');
776
+ }
777
+
778
+ ensureIdeasDirs(cwd);
779
+
780
+ // Git mv
781
+ const planRootRel = path.relative(cwd, getPlanningRoot(cwd)) || '.';
782
+ const fromRel = path.join(planRootRel, 'ideas', idea.state, idea.filename);
783
+ const toRel = path.join(planRootRel, 'ideas', targetState, idea.filename);
784
+ execGit(cwd, ['mv', fromRel, toRel]);
785
+
786
+ // Auto-commit
787
+ execGit(cwd, ['add', '-A']);
788
+ const title = idea.frontmatter.title || idea.filename;
789
+ const idNum = idea.frontmatter.id || idOrFilename;
790
+ execGit(cwd, ['commit', '-m', `ideas: move #${idNum} - ${title} to ${targetState}`]);
791
+
792
+ const result = {
793
+ id: idea.frontmatter.id,
794
+ filename: idea.filename,
795
+ from: idea.state,
796
+ to: targetState,
797
+ };
798
+ output(result, raw);
799
+ }
800
+
801
+ // ─── Section Entry Helpers ────────────────────────────────────────────────────
802
+
803
+ /**
804
+ * Append a discussion entry to an idea's content string.
805
+ *
806
+ * Parses the content, formats a new H3 entry, prepends it to the existing
807
+ * discussionLog (newest first), rebuilds, and returns the full content.
808
+ *
809
+ * @param {string} content - Full idea file content string
810
+ * @param {{ date: string, keyInsights: string, refinedProblem: string, refinedApproach: string, openQuestions: string, decision: string }} entry
811
+ * @returns {string} Updated full idea content
812
+ */
813
+ function appendDiscussionEntry(content, entry) {
814
+ const parsed = parseIdeaFrontmatter(content);
815
+
816
+ const entryBlock = [
817
+ `### Discussion — ${entry.date}`,
818
+ '',
819
+ `**Key Insights:** ${entry.keyInsights}`,
820
+ `**Refined Problem:** ${entry.refinedProblem}`,
821
+ `**Refined Approach:** ${entry.refinedApproach}`,
822
+ `**Open Questions:** ${entry.openQuestions}`,
823
+ `**Decision:** ${entry.decision}`,
824
+ '',
825
+ ].join('\n');
826
+
827
+ // Prepend new entry to existing discussion log (newest first)
828
+ const updatedDiscussionLog = parsed.discussionLog
829
+ ? entryBlock + parsed.discussionLog
830
+ : entryBlock;
831
+
832
+ return buildIdeaContent(
833
+ parsed.frontmatter,
834
+ parsed.body,
835
+ parsed.notes,
836
+ updatedDiscussionLog,
837
+ parsed.researchLog
838
+ );
839
+ }
840
+
841
+ /**
842
+ * Append a research entry to an idea's content string.
843
+ *
844
+ * Parses the content, formats a new H3 entry, prepends it to the existing
845
+ * researchLog (newest first), rebuilds, and returns the full content.
846
+ *
847
+ * @param {string} content - Full idea file content string
848
+ * @param {{ date: string, summary: string, keyFindings: string, recommendation: string, documentLink: string, outcome: string }} entry
849
+ * @returns {string} Updated full idea content
850
+ */
851
+ function appendResearchEntry(content, entry) {
852
+ const parsed = parseIdeaFrontmatter(content);
853
+
854
+ const entryBlock = [
855
+ `### Research — ${entry.date}`,
856
+ '',
857
+ `**Summary:** ${entry.summary}`,
858
+ `**Key Findings:** ${entry.keyFindings}`,
859
+ `**Recommendation:** ${entry.recommendation}`,
860
+ `**Document:** ${entry.documentLink}`,
861
+ `**Outcome:** ${entry.outcome}`,
862
+ '',
863
+ ].join('\n');
864
+
865
+ // Prepend new entry to existing research log (newest first)
866
+ const updatedResearchLog = parsed.researchLog
867
+ ? entryBlock + parsed.researchLog
868
+ : entryBlock;
869
+
870
+ return buildIdeaContent(
871
+ parsed.frontmatter,
872
+ parsed.body,
873
+ parsed.notes,
874
+ parsed.discussionLog,
875
+ updatedResearchLog
876
+ );
877
+ }
878
+
879
+ // ─── Discussion Save Command ──────────────────────────────────────────────────
880
+
881
+ /**
882
+ * Save a structured discussion entry to an idea file.
883
+ *
884
+ * Reads the idea, calls appendDiscussionEntry, updates the Updated timestamp,
885
+ * optionally sets updated_by, rebuilds all sections, writes the file, and
886
+ * returns JSON confirmation.
887
+ *
888
+ * @param {string} cwd - Working directory
889
+ * @param {string} idOrFilename - Idea ID or filename
890
+ * @param {string} entryJson - JSON string with { date, keyInsights, refinedProblem, refinedApproach, openQuestions, decision }
891
+ * @param {boolean} raw - Raw output mode
892
+ * @param {string} [author] - Author string. When provided, sets updated_by.
893
+ */
894
+ function cmdIdeasDiscussSave(cwd, idOrFilename, entryJson, raw, author) {
895
+ if (!idOrFilename) {
896
+ error('id required for ideas discuss-save');
897
+ }
898
+ if (!entryJson) {
899
+ error('entry required for ideas discuss-save');
900
+ }
901
+
902
+ let entry;
903
+ try {
904
+ entry = JSON.parse(entryJson);
905
+ } catch (e) {
906
+ error('invalid JSON for --entry: ' + e.message);
907
+ }
908
+
909
+ const idea = findIdeaFile(cwd, idOrFilename);
910
+ if (!idea) {
911
+ error(`idea not found: ${idOrFilename}`);
912
+ }
913
+
914
+ // Read full file content
915
+ const content = fs.readFileSync(idea.path, 'utf-8');
916
+
917
+ // Append the discussion entry (handles prepend/newest-first)
918
+ const withEntry = appendDiscussionEntry(content, entry);
919
+
920
+ // Re-parse to get updated sections and frontmatter
921
+ const parsed = parseIdeaFrontmatter(withEntry);
922
+ const fm = parsed.frontmatter;
923
+
924
+ // Update timestamp
925
+ fm.updated = new Date().toISOString();
926
+ if (author) {
927
+ fm.updated_by = author;
928
+ }
929
+
930
+ // Rebuild with all sections preserved
931
+ const updatedContent = buildIdeaContent(fm, parsed.body, parsed.notes, parsed.discussionLog, parsed.researchLog);
932
+ fs.writeFileSync(idea.path, updatedContent, 'utf-8');
933
+
934
+ // Compute relative path for output
935
+ const relPath = path.relative(cwd, idea.path);
936
+
937
+ output({ id: fm.id, filename: idea.filename, path: relPath, state: idea.state }, raw);
938
+ }
939
+
940
+ // ─── Research Save Command ─────────────────────────────────────────────────────
941
+
942
+ /**
943
+ * Save a structured research entry to an idea file.
944
+ *
945
+ * Reads the idea, calls appendResearchEntry, updates the Updated timestamp,
946
+ * optionally sets updated_by, rebuilds all sections, writes the file, and
947
+ * returns JSON confirmation.
948
+ *
949
+ * @param {string} cwd - Working directory
950
+ * @param {string} idOrFilename - Idea ID or filename
951
+ * @param {string} entryJson - JSON string with { date, summary, keyFindings, recommendation, documentLink, outcome }
952
+ * @param {boolean} raw - Raw output mode
953
+ * @param {string} [author] - Author string. When provided, sets updated_by.
954
+ */
955
+ function cmdIdeasResearchSave(cwd, idOrFilename, entryJson, raw, author) {
956
+ if (!idOrFilename) {
957
+ error('id required for ideas research-save');
958
+ }
959
+ if (!entryJson) {
960
+ error('entry required for ideas research-save');
961
+ }
962
+
963
+ let entry;
964
+ try {
965
+ entry = JSON.parse(entryJson);
966
+ } catch (e) {
967
+ error('invalid JSON for --entry: ' + e.message);
968
+ }
969
+
970
+ const idea = findIdeaFile(cwd, idOrFilename);
971
+ if (!idea) {
972
+ error(`idea not found: ${idOrFilename}`);
973
+ }
974
+
975
+ // Read full file content
976
+ const content = fs.readFileSync(idea.path, 'utf-8');
977
+
978
+ // Append the research entry (handles prepend/newest-first)
979
+ const withEntry = appendResearchEntry(content, entry);
980
+
981
+ // Re-parse to get updated sections and frontmatter
982
+ const parsed = parseIdeaFrontmatter(withEntry);
983
+ const fm = parsed.frontmatter;
984
+
985
+ // Update timestamp
986
+ fm.updated = new Date().toISOString();
987
+ if (author) {
988
+ fm.updated_by = author;
989
+ }
990
+
991
+ // Rebuild with all sections preserved
992
+ const updatedContent = buildIdeaContent(fm, parsed.body, parsed.notes, parsed.discussionLog, parsed.researchLog);
993
+ fs.writeFileSync(idea.path, updatedContent, 'utf-8');
994
+
995
+ // Compute relative path for output
996
+ const relPath = path.relative(cwd, idea.path);
997
+
998
+ output({ id: fm.id, filename: idea.filename, path: relPath, state: idea.state }, raw);
999
+ }
1000
+
1001
+ // ─── Consolidation Note Helper ────────────────────────────────────────────────
1002
+
1003
+ /**
1004
+ * Append a consolidation note to a source idea file.
1005
+ *
1006
+ * Records that this idea was consolidated into a new combined idea,
1007
+ * with the new idea's ID, title, and date. Called before moving
1008
+ * source ideas to consolidated/.
1009
+ *
1010
+ * @param {string} ideaPath - Absolute path to the idea file
1011
+ * @param {string} newIdeaId - The ID of the consolidated idea (e.g., "042")
1012
+ * @param {string} newIdeaTitle - The title of the consolidated idea
1013
+ * @param {string} date - Date string (e.g., "2026-03-10")
1014
+ * @returns {string} Updated file content
1015
+ */
1016
+ function appendConsolidationNote(ideaPath, newIdeaId, newIdeaTitle, date) {
1017
+ const content = safeReadFile(ideaPath) || '';
1018
+ const { frontmatter, body, notes, discussionLog, researchLog } = parseIdeaFrontmatter(content);
1019
+
1020
+ const noteLine = `- Consolidated into #${newIdeaId} (${newIdeaTitle}) on ${date}\n`;
1021
+
1022
+ // Append to existing notes or start new notes section
1023
+ const updatedNotes = notes ? notes + noteLine : noteLine;
1024
+
1025
+ const updatedContent = buildIdeaContent(frontmatter, body, updatedNotes, discussionLog, researchLog);
1026
+ fs.writeFileSync(ideaPath, updatedContent, 'utf-8');
1027
+
1028
+ return updatedContent;
1029
+ }
1030
+
1031
+ // ─── Consolidate Command ──────────────────────────────────────────────────────
1032
+
1033
+ /**
1034
+ * Consolidate 2+ pending ideas into a single new idea.
1035
+ *
1036
+ * Validates all source IDs exist and are pending, creates a new idea with
1037
+ * consolidated_from, annotates each source with consolidated_into and a
1038
+ * consolidation note, moves sources to consolidated/, and commits atomically.
1039
+ *
1040
+ * @param {string} cwd - Working directory
1041
+ * @param {{ ids: string, title: string, body?: string, tags?: string, discussion?: string, research?: string }} options
1042
+ * @param {boolean} raw - Raw output mode
1043
+ * @param {string} [author] - Author string
1044
+ */
1045
+ function cmdIdeasConsolidate(cwd, options, raw, author) {
1046
+ const { ids, title, body, tags, discussion, research } = options;
1047
+
1048
+ // Validate required inputs
1049
+ if (!title) error('title required for ideas consolidate');
1050
+ if (!ids) error('ids required for ideas consolidate');
1051
+
1052
+ const idList = ids.split(',').map(s => s.trim()).filter(Boolean);
1053
+ if (idList.length < 2) error('at least 2 idea IDs required for consolidation');
1054
+
1055
+ // Resolve and validate all source ideas BEFORE making any changes
1056
+ const sourceIdeas = [];
1057
+ for (const id of idList) {
1058
+ const idea = findIdeaFile(cwd, id);
1059
+ if (!idea) error(`idea not found: ${id}`);
1060
+ if (idea.state !== 'pending') error(`idea ${id} is not in pending state (current: ${idea.state})`);
1061
+ sourceIdeas.push(idea);
1062
+ }
1063
+
1064
+ ensureIdeasDirs(cwd);
1065
+
1066
+ // Collect tags: union of all source tags + provided tags (deduplicated)
1067
+ const allTags = new Set();
1068
+ for (const idea of sourceIdeas) {
1069
+ if (idea.frontmatter.tags && Array.isArray(idea.frontmatter.tags)) {
1070
+ idea.frontmatter.tags.forEach(t => allTags.add(t));
1071
+ }
1072
+ }
1073
+ if (tags) {
1074
+ tags.split(',').map(t => t.trim()).filter(Boolean).forEach(t => allTags.add(t));
1075
+ }
1076
+
1077
+ // Allocate new ID and prepare file paths
1078
+ const newId = allocateId(cwd);
1079
+ const slug = generateSlugInternal(title);
1080
+ const filename = `${newId}-${slug}.md`;
1081
+ const planRoot = getPlanningRoot(cwd);
1082
+ const planRootRel = path.relative(cwd, planRoot) || '.';
1083
+ const filePath = path.join(planRoot, 'ideas', 'pending', filename);
1084
+ const now = new Date().toISOString();
1085
+ const dateStr = now.split('T')[0];
1086
+
1087
+ // Build new idea frontmatter
1088
+ const frontmatter = {
1089
+ id: parseInt(newId, 10),
1090
+ title,
1091
+ tags: [...allTags],
1092
+ created: now,
1093
+ updated: now,
1094
+ };
1095
+ if (author) {
1096
+ frontmatter.author = author;
1097
+ frontmatter.updated_by = author;
1098
+ }
1099
+ frontmatter.consolidated_from = sourceIdeas.map(i => String(i.frontmatter.id).padStart(3, '0'));
1100
+
1101
+ // Write new consolidated idea to pending/
1102
+ const content = buildIdeaContent(frontmatter, body || '', '', discussion || '', research || '');
1103
+ fs.writeFileSync(filePath, content, 'utf-8');
1104
+
1105
+ // Annotate and move each source idea
1106
+ const movedFiles = [];
1107
+ for (const idea of sourceIdeas) {
1108
+ // Set consolidated_into on source idea
1109
+ const srcContent = safeReadFile(idea.path) || '';
1110
+ const parsed = parseIdeaFrontmatter(srcContent);
1111
+ parsed.frontmatter.consolidated_into = newId;
1112
+ parsed.frontmatter.updated = now;
1113
+ if (author) parsed.frontmatter.updated_by = author;
1114
+ fs.writeFileSync(idea.path, buildIdeaContent(parsed.frontmatter, parsed.body, parsed.notes, parsed.discussionLog, parsed.researchLog), 'utf-8');
1115
+
1116
+ // Append consolidation note
1117
+ appendConsolidationNote(idea.path, newId, title, dateStr);
1118
+
1119
+ // Git mv source from pending/ to consolidated/
1120
+ const fromRel = path.join(planRootRel, 'ideas', 'pending', idea.filename);
1121
+ const toRel = path.join(planRootRel, 'ideas', 'consolidated', idea.filename);
1122
+ execGit(cwd, ['mv', fromRel, toRel]);
1123
+
1124
+ movedFiles.push({
1125
+ id: idea.frontmatter.id,
1126
+ filename: idea.filename,
1127
+ from: 'pending',
1128
+ to: 'consolidated',
1129
+ });
1130
+ }
1131
+
1132
+ // Atomic git commit
1133
+ execGit(cwd, ['add', '-A']);
1134
+ const sourceIdsStr = sourceIdeas.map(i => `#${i.frontmatter.id}`).join(', ');
1135
+ execGit(cwd, ['commit', '-m', `ideas: consolidate ${sourceIdsStr} -> #${parseInt(newId, 10)} - ${title}`]);
1136
+
1137
+ // Return structured JSON
1138
+ const result = {
1139
+ id: parseInt(newId, 10),
1140
+ filename,
1141
+ path: path.join(planRootRel, 'ideas', 'pending', filename),
1142
+ title,
1143
+ consolidated_from: frontmatter.consolidated_from,
1144
+ moved_files: movedFiles,
1145
+ };
1146
+ output(result, raw);
1147
+ }
1148
+
1149
+ // ─── Find Related Ideas ───────────────────────────────────────────────────────
1150
+
1151
+ /**
1152
+ * Find ideas related to an anchor idea by tag overlap (Jaccard similarity).
1153
+ *
1154
+ * Scores all other pending ideas against the anchor, groups by match level,
1155
+ * applies threshold filtering, and returns full content for downstream AI scoring.
1156
+ *
1157
+ * Match levels (Jaccard similarity ratio):
1158
+ * HIGH: ratio >= 0.5
1159
+ * MEDIUM: ratio >= 0.2
1160
+ * LOW: ratio > 0
1161
+ * NONE: ratio === 0 (excluded from results)
1162
+ *
1163
+ * @param {string} cwd - Working directory
1164
+ * @param {{ id: string, threshold: string }} options
1165
+ * @param {boolean} raw - Raw output mode
1166
+ */
1167
+ function cmdIdeasFindRelated(cwd, options, raw) {
1168
+ const { id, threshold } = options;
1169
+
1170
+ if (!id) {
1171
+ error('id required for ideas find-related');
1172
+ }
1173
+
1174
+ // 1. Find and validate anchor idea
1175
+ const anchor = findIdeaFile(cwd, id);
1176
+ if (!anchor) {
1177
+ error('Idea #' + id + ' not found');
1178
+ }
1179
+
1180
+ const anchorTags = anchor.frontmatter.tags || [];
1181
+
1182
+ // 2. Load all other pending ideas
1183
+ const pendingDir = path.join(getPlanningRoot(cwd), 'ideas', 'pending');
1184
+ let pendingFiles;
1185
+ try {
1186
+ pendingFiles = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
1187
+ } catch {
1188
+ pendingFiles = [];
1189
+ }
1190
+
1191
+ // 3. Score each candidate for tag overlap
1192
+ const matchLevelOrder = { HIGH: 0, MEDIUM: 1, LOW: 2, NONE: 3 };
1193
+ const candidates = [];
1194
+
1195
+ for (const file of pendingFiles) {
1196
+ const content = safeReadFile(path.join(pendingDir, file));
1197
+ if (!content) continue;
1198
+
1199
+ const parsed = parseIdeaFrontmatter(content);
1200
+ const candidateId = parsed.frontmatter.id;
1201
+
1202
+ // Skip the anchor idea itself
1203
+ if (candidateId === anchor.frontmatter.id) continue;
1204
+
1205
+ const candidateTags = parsed.frontmatter.tags || [];
1206
+
1207
+ // Jaccard similarity: intersection / union
1208
+ const sharedSet = new Set(anchorTags.filter(t => candidateTags.includes(t)));
1209
+ const unionSet = new Set([...anchorTags, ...candidateTags]);
1210
+ const shared = [...sharedSet];
1211
+ const ratio = unionSet.size === 0 ? 0 : shared.length / unionSet.size;
1212
+
1213
+ // Round ratio to avoid floating point display issues
1214
+ const roundedRatio = Math.round(ratio * 100) / 100;
1215
+
1216
+ // 4. Assign match levels
1217
+ let matchLevel;
1218
+ if (ratio >= 0.5) {
1219
+ matchLevel = 'HIGH';
1220
+ } else if (ratio >= 0.2) {
1221
+ matchLevel = 'MEDIUM';
1222
+ } else if (ratio > 0) {
1223
+ matchLevel = 'LOW';
1224
+ } else {
1225
+ matchLevel = 'NONE';
1226
+ }
1227
+
1228
+ // Exclude NONE (zero overlap) entirely
1229
+ if (matchLevel === 'NONE') continue;
1230
+
1231
+ candidates.push({
1232
+ id: candidateId,
1233
+ title: parsed.frontmatter.title || '',
1234
+ tags: candidateTags,
1235
+ body: parsed.body || '',
1236
+ notes: parsed.notes || '',
1237
+ discussion_log: parsed.discussionLog || '',
1238
+ research_log: parsed.researchLog || '',
1239
+ tag_overlap: {
1240
+ shared,
1241
+ ratio: roundedRatio,
1242
+ },
1243
+ match_level: matchLevel,
1244
+ _ratio: ratio, // internal for sorting
1245
+ });
1246
+ }
1247
+
1248
+ // 5. Apply threshold filter
1249
+ const thresholdLevel = (threshold || 'medium').toLowerCase();
1250
+ let allowedLevels;
1251
+ if (thresholdLevel === 'high') {
1252
+ allowedLevels = new Set(['HIGH']);
1253
+ } else if (thresholdLevel === 'medium') {
1254
+ allowedLevels = new Set(['HIGH', 'MEDIUM']);
1255
+ } else {
1256
+ // 'low' — include HIGH, MEDIUM, LOW (still excludes NONE)
1257
+ allowedLevels = new Set(['HIGH', 'MEDIUM', 'LOW']);
1258
+ }
1259
+
1260
+ const filtered = candidates.filter(c => allowedLevels.has(c.match_level));
1261
+
1262
+ // 6. Sort results: HIGH first, then MEDIUM, then LOW
1263
+ // Within same level, sort by ratio descending
1264
+ filtered.sort((a, b) => {
1265
+ const levelDiff = matchLevelOrder[a.match_level] - matchLevelOrder[b.match_level];
1266
+ if (levelDiff !== 0) return levelDiff;
1267
+ return b._ratio - a._ratio;
1268
+ });
1269
+
1270
+ // 7. Build result — remove internal _ratio field
1271
+ const results = filtered.map(c => {
1272
+ const { _ratio, ...rest } = c;
1273
+ return rest;
1274
+ });
1275
+
1276
+ // Counts
1277
+ const counts = { high: 0, medium: 0, low: 0, total: results.length };
1278
+ for (const r of results) {
1279
+ if (r.match_level === 'HIGH') counts.high++;
1280
+ else if (r.match_level === 'MEDIUM') counts.medium++;
1281
+ else if (r.match_level === 'LOW') counts.low++;
1282
+ }
1283
+
1284
+ // 8. Output JSON
1285
+ const result = {
1286
+ anchor: {
1287
+ id: anchor.frontmatter.id,
1288
+ title: anchor.frontmatter.title || '',
1289
+ tags: anchorTags,
1290
+ },
1291
+ results,
1292
+ counts,
1293
+ threshold: thresholdLevel,
1294
+ };
1295
+
1296
+ output(result, raw);
1297
+ }
1298
+
1299
+ // ─── Undo Consolidation Command ───────────────────────────────────────────────
1300
+
1301
+ /**
1302
+ * Undo a consolidation: restore all source ideas to pending and delete the consolidated idea.
1303
+ *
1304
+ * Given the ID of the consolidated result idea (the one with consolidated_from),
1305
+ * this function:
1306
+ * 1. Finds the consolidated idea and reads its consolidated_from list
1307
+ * 2. For each source ID in consolidated_from:
1308
+ * a. Finds the source idea in consolidated/ state
1309
+ * b. Removes consolidated_into from its frontmatter
1310
+ * c. Git mv from consolidated/ to pending/
1311
+ * 3. Deletes the consolidated idea file from pending/ (git rm)
1312
+ * 4. Creates a single atomic git commit
1313
+ *
1314
+ * @param {string} cwd - Working directory
1315
+ * @param {string} idOrFilename - ID of the consolidated result idea
1316
+ * @param {boolean} raw - Raw output mode
1317
+ * @param {string} [author] - Author string
1318
+ */
1319
+ function cmdIdeasUndoConsolidation(cwd, idOrFilename, raw, author) {
1320
+ if (!idOrFilename) error('id required for ideas undo-consolidation');
1321
+
1322
+ const idea = findIdeaFile(cwd, idOrFilename);
1323
+ if (!idea) error(`idea not found: ${idOrFilename}`);
1324
+
1325
+ // Validate that this is a consolidated idea (has consolidated_from)
1326
+ const consolidatedFrom = idea.frontmatter.consolidated_from;
1327
+ if (!consolidatedFrom || !Array.isArray(consolidatedFrom) || consolidatedFrom.length === 0) {
1328
+ error(`idea ${idOrFilename} is not a consolidated idea (no consolidated_from field)`);
1329
+ }
1330
+
1331
+ const planRoot = getPlanningRoot(cwd);
1332
+ const planRootRel = path.relative(cwd, planRoot) || '.';
1333
+ const restoredIds = [];
1334
+
1335
+ // Restore each source idea from consolidated/ to pending/
1336
+ for (const sourceId of consolidatedFrom) {
1337
+ const sourceIdea = findIdeaFile(cwd, sourceId);
1338
+ if (!sourceIdea) {
1339
+ error(`source idea #${sourceId} not found during undo`);
1340
+ }
1341
+ if (sourceIdea.state !== 'consolidated') {
1342
+ error(`source idea #${sourceId} is in ${sourceIdea.state} state, expected consolidated`);
1343
+ }
1344
+
1345
+ // Remove consolidated_into from frontmatter
1346
+ const srcContent = safeReadFile(sourceIdea.path) || '';
1347
+ const parsed = parseIdeaFrontmatter(srcContent);
1348
+ delete parsed.frontmatter.consolidated_into;
1349
+ parsed.frontmatter.updated = new Date().toISOString();
1350
+ if (author) parsed.frontmatter.updated_by = author;
1351
+ fs.writeFileSync(sourceIdea.path, buildIdeaContent(parsed.frontmatter, parsed.body, parsed.notes, parsed.discussionLog, parsed.researchLog), 'utf-8');
1352
+
1353
+ // Git mv from consolidated/ to pending/
1354
+ const fromRel = path.join(planRootRel, 'ideas', 'consolidated', sourceIdea.filename);
1355
+ const toRel = path.join(planRootRel, 'ideas', 'pending', sourceIdea.filename);
1356
+ execGit(cwd, ['mv', fromRel, toRel]);
1357
+
1358
+ restoredIds.push(parseInt(sourceId, 10));
1359
+ }
1360
+
1361
+ // Delete the consolidated result idea
1362
+ const ideaRel = path.join(planRootRel, 'ideas', idea.state, idea.filename);
1363
+ execGit(cwd, ['rm', ideaRel]);
1364
+
1365
+ // Atomic git commit
1366
+ execGit(cwd, ['add', '-A']);
1367
+ const title = idea.frontmatter.title || idea.filename;
1368
+ const idNum = idea.frontmatter.id || idOrFilename;
1369
+ execGit(cwd, ['commit', '-m', `ideas: undo consolidation #${idNum} - ${title}`]);
1370
+
1371
+ const result = {
1372
+ undone_idea_id: idea.frontmatter.id,
1373
+ title,
1374
+ restored_ids: restoredIds,
1375
+ deleted_file: idea.filename,
1376
+ };
1377
+ output(result, raw);
1378
+ }
1379
+
1380
+ // ─── Exports ──────────────────────────────────────────────────────────────────
1381
+
1382
+ module.exports = {
1383
+ IDEA_STATES,
1384
+ loadManifest,
1385
+ saveManifest,
1386
+ allocateId,
1387
+ ensureIdeasDirs,
1388
+ parseIdeaFrontmatter,
1389
+ buildIdeaContent,
1390
+ appendDiscussionEntry,
1391
+ appendResearchEntry,
1392
+ appendConsolidationNote,
1393
+ findIdeaFile,
1394
+ cmdIdeasCreate,
1395
+ cmdIdeasUpdate,
1396
+ cmdIdeasAppendNote,
1397
+ cmdIdeasList,
1398
+ cmdIdeasReject,
1399
+ cmdIdeasRestore,
1400
+ cmdIdeasMoveState,
1401
+ cmdIdeasDiscussSave,
1402
+ cmdIdeasResearchSave,
1403
+ cmdIdeasConsolidate,
1404
+ cmdIdeasFindRelated,
1405
+ cmdIdeasUndoConsolidation,
1406
+ };