@smartmemory/compose 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1014 -0
  3. package/bin/compose.js +1515 -0
  4. package/dist/assets/_baseUniq-CQwX6VLz.js +1 -0
  5. package/dist/assets/arc-SxJ2J1sh.js +1 -0
  6. package/dist/assets/architectureDiagram-Q4EWVU46-BykunY1F.js +36 -0
  7. package/dist/assets/blockDiagram-DXYQGD6D-ohAKBOUw.js +132 -0
  8. package/dist/assets/c4Diagram-AHTNJAMY-DBDC3ENB.js +10 -0
  9. package/dist/assets/channel-DGElom1e.js +1 -0
  10. package/dist/assets/chunk-4BX2VUAB-Cv93Z7uM.js +1 -0
  11. package/dist/assets/chunk-4TB4RGXK-DE0WBDkj.js +206 -0
  12. package/dist/assets/chunk-55IACEB6-CE1EXenG.js +1 -0
  13. package/dist/assets/chunk-EDXVE4YY-DA7Ana6H.js +1 -0
  14. package/dist/assets/chunk-FMBD7UC4-CTDIPA3p.js +15 -0
  15. package/dist/assets/chunk-OYMX7WX6-uGBaPaTX.js +231 -0
  16. package/dist/assets/chunk-QZHKN3VN-CYlnXuUO.js +1 -0
  17. package/dist/assets/chunk-YZCP3GAM-ojGkzcZK.js +1 -0
  18. package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +1 -0
  19. package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +1 -0
  20. package/dist/assets/clone-DUJKJXd7.js +1 -0
  21. package/dist/assets/cose-bilkent-S5V4N54A-Bktn9hL-.js +1 -0
  22. package/dist/assets/dagre-KV5264BT-DFaSzuRF.js +4 -0
  23. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  24. package/dist/assets/diagram-5BDNPKRD-DnfmDzEm.js +10 -0
  25. package/dist/assets/diagram-G4DWMVQ6-Bm8W9YnG.js +24 -0
  26. package/dist/assets/diagram-MMDJMWI5-B5-TSKvp.js +43 -0
  27. package/dist/assets/diagram-TYMM5635-ls4rqlky.js +24 -0
  28. package/dist/assets/erDiagram-SMLLAGMA-giG6WO-r.js +85 -0
  29. package/dist/assets/flowDiagram-DWJPFMVM-XvlUuz-7.js +162 -0
  30. package/dist/assets/ganttDiagram-T4ZO3ILL-hLBV57oV.js +292 -0
  31. package/dist/assets/gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js +106 -0
  32. package/dist/assets/graph-D0Cfv00Y.js +1 -0
  33. package/dist/assets/index-CUd6pFGF.css +1 -0
  34. package/dist/assets/index-DReRlzZI.js +1144 -0
  35. package/dist/assets/infoDiagram-42DDH7IO-DbqRsOo3.js +2 -0
  36. package/dist/assets/init-Gi6I4Gst.js +1 -0
  37. package/dist/assets/ishikawaDiagram-UXIWVN3A-DnCdx7zb.js +70 -0
  38. package/dist/assets/journeyDiagram-VCZTEJTY-CfD7eNcP.js +139 -0
  39. package/dist/assets/kanban-definition-6JOO6SKY-BYaO9-mK.js +89 -0
  40. package/dist/assets/katex-DkKDou_j.js +257 -0
  41. package/dist/assets/layout-Bj72wOEB.js +1 -0
  42. package/dist/assets/linear-BRFo114D.js +1 -0
  43. package/dist/assets/min-GCHnKlJS.js +1 -0
  44. package/dist/assets/mindmap-definition-QFDTVHPH-n0PMebY4.js +96 -0
  45. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  46. package/dist/assets/pieDiagram-DEJITSTG-pN4CljHF.js +30 -0
  47. package/dist/assets/quadrantDiagram-34T5L4WZ-DNoAy8-D.js +7 -0
  48. package/dist/assets/requirementDiagram-MS252O5E-BhtY05PT.js +84 -0
  49. package/dist/assets/sankeyDiagram-XADWPNL6-B6AD-16A.js +10 -0
  50. package/dist/assets/sequenceDiagram-FGHM5R23-DShHM-uk.js +157 -0
  51. package/dist/assets/stateDiagram-FHFEXIEX-DMxn7HTo.js +1 -0
  52. package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +1 -0
  53. package/dist/assets/timeline-definition-GMOUNBTQ-Cdu6uq52.js +120 -0
  54. package/dist/assets/vennDiagram-DHZGUBPP-CpK29iRe.js +34 -0
  55. package/dist/assets/wardley-RL74JXVD-BQgSkdcO.js +162 -0
  56. package/dist/assets/wardleyDiagram-NUSXRM2D-DJHYev6O.js +20 -0
  57. package/dist/assets/xychartDiagram-5P7HB3ND-1d75pbaO.js +7 -0
  58. package/dist/index.html +30 -0
  59. package/lib/agent-chains.js +65 -0
  60. package/lib/agent-string.js +86 -0
  61. package/lib/budget-ledger.js +86 -0
  62. package/lib/build-all.js +162 -0
  63. package/lib/build-dag.js +120 -0
  64. package/lib/build-stream-writer.js +190 -0
  65. package/lib/build.js +2997 -0
  66. package/lib/capability-checker.js +53 -0
  67. package/lib/cert-inject.js +38 -0
  68. package/lib/cli-progress.js +483 -0
  69. package/lib/constants.js +69 -0
  70. package/lib/cross-layer-audit.js +84 -0
  71. package/lib/debug-discipline.js +173 -0
  72. package/lib/feature-json.js +106 -0
  73. package/lib/gate-prompt.js +291 -0
  74. package/lib/gate-tiers.js +194 -0
  75. package/lib/health-history.js +119 -0
  76. package/lib/health-score.js +227 -0
  77. package/lib/ideabox.js +570 -0
  78. package/lib/import.js +244 -0
  79. package/lib/migrate-roadmap.js +94 -0
  80. package/lib/model-pricing.js +67 -0
  81. package/lib/new.js +413 -0
  82. package/lib/pipeline-cli.js +489 -0
  83. package/lib/plan-parser.js +103 -0
  84. package/lib/qa-scoping.js +474 -0
  85. package/lib/questionnaire.js +200 -0
  86. package/lib/resolve-port.js +7 -0
  87. package/lib/result-normalizer.js +349 -0
  88. package/lib/review-lenses.js +166 -0
  89. package/lib/roadmap-gen.js +210 -0
  90. package/lib/roadmap-parser.js +176 -0
  91. package/lib/server-probe.js +23 -0
  92. package/lib/staleness.js +87 -0
  93. package/lib/step-prompt.js +260 -0
  94. package/lib/step-validator.js +49 -0
  95. package/lib/stratum-mcp-client.js +365 -0
  96. package/lib/team-flag.js +46 -0
  97. package/lib/test-bootstrap.js +401 -0
  98. package/lib/triage.js +274 -0
  99. package/lib/vision-writer.js +391 -0
  100. package/package.json +111 -0
  101. package/pipelines/bug-fix.stratum.yaml +230 -0
  102. package/pipelines/build.stratum.yaml +498 -0
  103. package/pipelines/content.stratum.yaml +112 -0
  104. package/pipelines/coverage-sweep.stratum.yaml +52 -0
  105. package/pipelines/refactor.stratum.yaml +169 -0
  106. package/pipelines/research.stratum.yaml +88 -0
  107. package/pipelines/review-fix.stratum.yaml +109 -0
  108. package/presets/team-feature.stratum.yaml +105 -0
  109. package/presets/team-research.stratum.yaml +108 -0
  110. package/presets/team-review.stratum.yaml +106 -0
  111. package/scripts/agent-activity-hook.sh +31 -0
  112. package/scripts/agent-error-hook.sh +28 -0
  113. package/scripts/analyze-orphans.mjs +50 -0
  114. package/scripts/find-orphans.mjs +26 -0
  115. package/scripts/fix-phases.mjs +49 -0
  116. package/scripts/generate-stratum-spec.mjs +137 -0
  117. package/scripts/import-roadmap.mjs +116 -0
  118. package/scripts/phase-audit.mjs +33 -0
  119. package/scripts/run-pipeline.mjs +314 -0
  120. package/scripts/session-end-hook.sh +18 -0
  121. package/scripts/session-start-hook.sh +38 -0
  122. package/scripts/vision-hook.sh +104 -0
  123. package/scripts/vision-track.mjs +554 -0
  124. package/scripts/wire-all-orphans.mjs +108 -0
  125. package/scripts/wire-orphans.mjs +164 -0
  126. package/server/activity-routes.js +123 -0
  127. package/server/agent-health.js +197 -0
  128. package/server/agent-hooks.js +102 -0
  129. package/server/agent-mcp.js +10 -0
  130. package/server/agent-registry.js +95 -0
  131. package/server/agent-server.js +290 -0
  132. package/server/agent-spawn.js +251 -0
  133. package/server/agent-templates.js +77 -0
  134. package/server/artifact-manager.js +247 -0
  135. package/server/artifact-templates/architecture.md +28 -0
  136. package/server/artifact-templates/blueprint.md +21 -0
  137. package/server/artifact-templates/design.md +36 -0
  138. package/server/artifact-templates/plan.md +25 -0
  139. package/server/artifact-templates/prd.md +43 -0
  140. package/server/artifact-templates/report.md +40 -0
  141. package/server/block-tracker.js +90 -0
  142. package/server/build-stream-bridge.js +502 -0
  143. package/server/coalescing-buffer.js +46 -0
  144. package/server/compose-mcp-tools.js +479 -0
  145. package/server/compose-mcp.js +324 -0
  146. package/server/connectors/agent-connector.js +78 -0
  147. package/server/connectors/claude-sdk-connector.js +198 -0
  148. package/server/connectors/codex-connector.js +240 -0
  149. package/server/connectors/connector-discovery.js +18 -0
  150. package/server/connectors/connector-runtime.js +13 -0
  151. package/server/connectors/opencode-connector.js +200 -0
  152. package/server/design-routes.js +540 -0
  153. package/server/design-session.js +161 -0
  154. package/server/feature-scan.js +593 -0
  155. package/server/file-watcher.js +284 -0
  156. package/server/find-root.js +29 -0
  157. package/server/graph-export.js +343 -0
  158. package/server/ideabox-cache.js +77 -0
  159. package/server/ideabox-routes.js +294 -0
  160. package/server/index.js +156 -0
  161. package/server/model-tiers.js +49 -0
  162. package/server/pipeline-routes.js +288 -0
  163. package/server/policy-evaluator.js +36 -0
  164. package/server/project-root.js +122 -0
  165. package/server/security.js +23 -0
  166. package/server/session-manager.js +403 -0
  167. package/server/session-routes.js +190 -0
  168. package/server/session-store.js +107 -0
  169. package/server/settings-routes.js +35 -0
  170. package/server/settings-store.js +234 -0
  171. package/server/stratum-api.js +102 -0
  172. package/server/stratum-client.js +192 -0
  173. package/server/stratum-sync.js +193 -0
  174. package/server/summarizer.js +139 -0
  175. package/server/supervisor.js +196 -0
  176. package/server/vision-routes.js +668 -0
  177. package/server/vision-server.js +393 -0
  178. package/server/vision-store.js +360 -0
  179. package/server/vision-utils.js +179 -0
  180. package/server/worktree-gc.js +137 -0
  181. package/templates/ROADMAP.md +46 -0
package/lib/ideabox.js ADDED
@@ -0,0 +1,570 @@
1
+ /**
2
+ * lib/ideabox.js — Ideabox markdown parser, writer, and mutation helpers.
3
+ *
4
+ * Format reference: SmartMemory ideabox.md canonical format.
5
+ * Each idea is an H4 entry under an H3 cluster inside the ## Ideas section.
6
+ *
7
+ * #### IDEA-N — <title>
8
+ * **Status:** NEW | **Priority:** P1 | **Tags:** `#tag`
9
+ * **Source:** <source text>
10
+ * **Idea:** <description prose>
11
+ * **Maps to:** <optional cross-refs>
12
+ *
13
+ * KILLED ideas end up under ## Killed Ideas with:
14
+ * **Killed:** <date> — <reason>
15
+ */
16
+
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
18
+ import { join, dirname } from 'node:path'
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Default template
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export const IDEABOX_TEMPLATE = `# Ideabox
25
+
26
+ **Purpose:** Capture raw ideas before they're ready for the roadmap.
27
+
28
+ ## Conventions
29
+ - **ID:** \`IDEA-N\` (sequential, never reuse)
30
+ - **Status:** \`NEW\` | \`DISCUSSING\` | \`PROMOTED\` | \`KILLED\`
31
+ - **Priority:** \`P0\` (promote now) | \`P1\` (next up) | \`P2\` (backlog) | \`—\` (untriaged)
32
+ - **Source:** Where the idea came from
33
+ - **Tags:** \`#ux\` \`#core\` \`#distribution\` \`#integration\` \`#research\` \`#infra\`
34
+
35
+ ## Ideas
36
+
37
+ <!-- Ideas grouped by potential feature cluster -->
38
+
39
+ ## Killed Ideas
40
+ `
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Regex helpers
44
+ // ---------------------------------------------------------------------------
45
+
46
+ // Matches: #### IDEA-42 — Some Title (or "- " variant)
47
+ const IDEA_HEADING_RE = /^####\s+(IDEA-(\d+))\s+[—–-]+\s+(.+)$/
48
+
49
+ // Matches a field line: **FieldName:** value (colon inside bold markers)
50
+ const FIELD_RE = /^\*\*([^*:]+):\*\*\s*(.*)$/
51
+
52
+ // Matches a discussion entry: - [2026-04-10] author: text
53
+ const DISCUSSION_ENTRY_RE = /^-\s+\[(\d{4}-\d{2}-\d{2})\]\s+(\w+):\s+(.+)$/
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // parseIdeabox(markdown) → { ideas, killed, nextId }
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Parse ideabox markdown into structured data.
61
+ * @param {string} markdown
62
+ * @returns {{ ideas: IdeaEntry[], killed: IdeaEntry[], nextId: number }}
63
+ */
64
+ export function parseIdeabox(markdown) {
65
+ const lines = markdown.split('\n')
66
+
67
+ const ideas = []
68
+ const killed = []
69
+
70
+ let inIdeasSection = false
71
+ let inKilledSection = false
72
+ let currentCluster = null
73
+ let currentIdea = null
74
+
75
+ function flushCurrentIdea() {
76
+ if (!currentIdea) return
77
+ // Remove internal parsing state before pushing
78
+ delete currentIdea._inDiscussion
79
+ if (inKilledSection) {
80
+ killed.push(currentIdea)
81
+ } else {
82
+ ideas.push(currentIdea)
83
+ }
84
+ currentIdea = null
85
+ }
86
+
87
+ for (let i = 0; i < lines.length; i++) {
88
+ const line = lines[i]
89
+
90
+ // Detect section boundaries
91
+ if (/^##\s+Ideas/.test(line)) {
92
+ flushCurrentIdea()
93
+ inIdeasSection = true
94
+ inKilledSection = false
95
+ currentCluster = null
96
+ continue
97
+ }
98
+ if (/^##\s+Killed\s+Ideas/.test(line)) {
99
+ flushCurrentIdea()
100
+ inIdeasSection = false
101
+ inKilledSection = true
102
+ currentCluster = null
103
+ continue
104
+ }
105
+ // Other H2 sections end both
106
+ if (/^##\s/.test(line) && !(/^##\s+Ideas/.test(line)) && !(/^##\s+Killed\s+Ideas/.test(line))) {
107
+ flushCurrentIdea()
108
+ inIdeasSection = false
109
+ inKilledSection = false
110
+ continue
111
+ }
112
+
113
+ if (!inIdeasSection && !inKilledSection) continue
114
+
115
+ // H3 = cluster heading
116
+ if (/^###\s/.test(line)) {
117
+ flushCurrentIdea()
118
+ currentCluster = line.replace(/^###\s+/, '').trim()
119
+ continue
120
+ }
121
+
122
+ // H4 = idea heading
123
+ const headingMatch = line.match(IDEA_HEADING_RE)
124
+ if (headingMatch) {
125
+ flushCurrentIdea()
126
+ currentIdea = {
127
+ id: headingMatch[1], // "IDEA-42"
128
+ num: parseInt(headingMatch[2], 10),
129
+ title: headingMatch[3].trim(),
130
+ status: 'NEW',
131
+ priority: '—',
132
+ tags: [],
133
+ source: '',
134
+ description: '',
135
+ cluster: currentCluster || null,
136
+ mapsTo: '',
137
+ killedReason: '',
138
+ killedDate: '',
139
+ effort: null,
140
+ impact: null,
141
+ discussion: [],
142
+ // raw fields for round-trip fidelity
143
+ _extraLines: [],
144
+ _inDiscussion: false,
145
+ }
146
+ continue
147
+ }
148
+
149
+ if (!currentIdea) continue
150
+
151
+ // Discussion header line: **Discussion:** (must check before general FIELD_RE)
152
+ if (line.trim() === '**Discussion:**') {
153
+ currentIdea._inDiscussion = true
154
+ continue
155
+ }
156
+
157
+ // Field lines
158
+ const fieldMatch = line.match(FIELD_RE)
159
+ if (fieldMatch) {
160
+ const key = fieldMatch[1].trim()
161
+ const val = fieldMatch[2].trim()
162
+
163
+ if (key === 'Status') {
164
+ // Handle inline: "NEW | **Priority:** P1 | **Tags:** `#tag`"
165
+ // or just: "PROMOTED (→ FEAT-1)"
166
+ // Split on | to get multiple fields on one line
167
+ const parts = val.split('|').map(p => p.trim())
168
+ for (const part of parts) {
169
+ const inlineField = part.match(FIELD_RE)
170
+ if (inlineField) {
171
+ applyField(currentIdea, inlineField[1].trim(), inlineField[2].trim())
172
+ } else {
173
+ // The status itself
174
+ currentIdea.status = extractStatus(part)
175
+ }
176
+ }
177
+ } else {
178
+ applyField(currentIdea, key, val)
179
+ }
180
+ continue
181
+ }
182
+
183
+ // Discussion entry: - [date] author: text
184
+ if (currentIdea._inDiscussion) {
185
+ const discMatch = line.match(DISCUSSION_ENTRY_RE)
186
+ if (discMatch) {
187
+ currentIdea.discussion.push({
188
+ date: discMatch[1],
189
+ author: discMatch[2],
190
+ text: discMatch[3].trim(),
191
+ })
192
+ continue
193
+ }
194
+ // Empty line in discussion block — stay in discussion mode
195
+ if (!line.trim()) continue
196
+ // Non-matching non-empty line → exit discussion mode, fall through
197
+ currentIdea._inDiscussion = false
198
+ }
199
+
200
+ // Non-empty lines after the heading = extra content (description overflow, etc.)
201
+ if (line.trim()) {
202
+ currentIdea._extraLines.push(line)
203
+ }
204
+ }
205
+
206
+ flushCurrentIdea()
207
+
208
+ // Compute nextId
209
+ const allNums = [...ideas, ...killed].map(i => i.num).filter(n => !isNaN(n))
210
+ const maxNum = allNums.length ? Math.max(...allNums) : 0
211
+ const nextId = maxNum + 1
212
+
213
+ return { ideas, killed, nextId }
214
+ }
215
+
216
+ function extractStatus(raw) {
217
+ const val = raw.toUpperCase()
218
+ if (val.startsWith('NEW')) return 'NEW'
219
+ if (val.startsWith('DISCUSSING')) return 'DISCUSSING'
220
+ if (val.startsWith('PROMOTED')) return raw // preserve "(→ FEAT-1)" suffix
221
+ if (val.startsWith('KILLED')) return 'KILLED'
222
+ return raw.trim()
223
+ }
224
+
225
+ function applyField(idea, key, val) {
226
+ switch (key) {
227
+ case 'Status':
228
+ idea.status = extractStatus(val)
229
+ break
230
+ case 'Priority':
231
+ idea.priority = val.replace(/`/g, '').trim() || '—'
232
+ break
233
+ case 'Tags':
234
+ idea.tags = (val.match(/#\w+/g) || [])
235
+ break
236
+ case 'Source':
237
+ idea.source = val
238
+ break
239
+ case 'Idea':
240
+ idea.description = val
241
+ break
242
+ case 'Maps to':
243
+ case 'Maps To':
244
+ idea.mapsTo = val
245
+ break
246
+ case 'Effort':
247
+ // Validate: only S/M/L allowed, anything else becomes null
248
+ idea.effort = ['S', 'M', 'L'].includes(val) ? val : null
249
+ break
250
+ case 'Impact':
251
+ // Validate: only low/medium/high allowed
252
+ idea.impact = ['low', 'medium', 'high'].includes(val) ? val : null
253
+ break
254
+ case 'Killed':
255
+ // "2026-04-09 — reason text"
256
+ {
257
+ const m = val.match(/^(\S+)\s+[—–-]+\s+(.+)$/)
258
+ if (m) {
259
+ idea.killedDate = m[1]
260
+ idea.killedReason = m[2]
261
+ } else {
262
+ idea.killedReason = val
263
+ }
264
+ idea.status = 'KILLED'
265
+ }
266
+ break
267
+ default:
268
+ // Store unknown fields in extra lines for round-trip
269
+ idea._extraLines.push(`**${key}:** ${val}`)
270
+ }
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // serializeIdeabox(parsedData) → markdown string
275
+ // ---------------------------------------------------------------------------
276
+
277
+ /**
278
+ * Serialize parsed ideabox data back to markdown.
279
+ * @param {{ ideas: IdeaEntry[], killed: IdeaEntry[], nextId: number }} parsedData
280
+ * @returns {string}
281
+ */
282
+ export function serializeIdeabox({ ideas, killed }) {
283
+ const lines = []
284
+
285
+ lines.push('# Ideabox')
286
+ lines.push('')
287
+ lines.push('**Purpose:** Capture raw ideas before they\'re ready for the roadmap.')
288
+ lines.push('')
289
+ lines.push('## Conventions')
290
+ lines.push('- **ID:** `IDEA-N` (sequential, never reuse)')
291
+ lines.push('- **Status:** `NEW` | `DISCUSSING` | `PROMOTED` | `KILLED`')
292
+ lines.push('- **Priority:** `P0` (promote now) | `P1` (next up) | `P2` (backlog) | `—` (untriaged)')
293
+ lines.push('- **Source:** Where the idea came from')
294
+ lines.push('- **Tags:** `#ux` `#core` `#distribution` `#integration` `#research` `#infra`')
295
+ lines.push('')
296
+ lines.push('## Ideas')
297
+ lines.push('')
298
+ lines.push('<!-- Ideas grouped by potential feature cluster -->')
299
+ lines.push('')
300
+
301
+ // Group active ideas by cluster
302
+ const clusters = new Map()
303
+ const unclustered = []
304
+ for (const idea of ideas) {
305
+ if (idea.cluster) {
306
+ if (!clusters.has(idea.cluster)) clusters.set(idea.cluster, [])
307
+ clusters.get(idea.cluster).push(idea)
308
+ } else {
309
+ unclustered.push(idea)
310
+ }
311
+ }
312
+
313
+ for (const [cluster, clusterIdeas] of clusters) {
314
+ lines.push(`### ${cluster}`)
315
+ lines.push('')
316
+ for (const idea of clusterIdeas) {
317
+ lines.push(...serializeIdea(idea))
318
+ }
319
+ }
320
+
321
+ if (unclustered.length > 0) {
322
+ for (const idea of unclustered) {
323
+ lines.push(...serializeIdea(idea))
324
+ }
325
+ }
326
+
327
+ lines.push('## Killed Ideas')
328
+ lines.push('')
329
+
330
+ for (const idea of killed) {
331
+ lines.push(...serializeKilledIdea(idea))
332
+ }
333
+
334
+ return lines.join('\n')
335
+ }
336
+
337
+ function serializeIdea(idea) {
338
+ const out = []
339
+ out.push(`#### ${idea.id} — ${idea.title}`)
340
+
341
+ // Build status line
342
+ const tagStr = idea.tags.length ? ` | **Tags:** ${idea.tags.join(' ')}` : ''
343
+ const statusStr = idea.status.startsWith('PROMOTED')
344
+ ? idea.status
345
+ : idea.status
346
+ out.push(`**Status:** ${statusStr} | **Priority:** ${idea.priority}${tagStr}`)
347
+
348
+ if (idea.source) out.push(`**Source:** ${idea.source}`)
349
+ if (idea.description) out.push(`**Idea:** ${idea.description}`)
350
+ if (idea.mapsTo) out.push(`**Maps to:** ${idea.mapsTo}`)
351
+ if (idea.effort) out.push(`**Effort:** ${idea.effort}`)
352
+ if (idea.impact) out.push(`**Impact:** ${idea.impact}`)
353
+
354
+ for (const extra of (idea._extraLines || [])) {
355
+ out.push(extra)
356
+ }
357
+
358
+ // Discussion thread
359
+ if (idea.discussion && idea.discussion.length > 0) {
360
+ out.push('**Discussion:**')
361
+ for (const entry of idea.discussion) {
362
+ out.push(`- [${entry.date}] ${entry.author}: ${entry.text}`)
363
+ }
364
+ }
365
+
366
+ out.push('')
367
+ return out
368
+ }
369
+
370
+ function serializeKilledIdea(idea) {
371
+ const out = []
372
+ out.push(`#### ${idea.id} — ${idea.title}`)
373
+
374
+ const tagStr = idea.tags.length ? ` | **Tags:** ${idea.tags.join(' ')}` : ''
375
+ out.push(`**Status:** KILLED${tagStr}`)
376
+
377
+ if (idea.source) out.push(`**Source:** ${idea.source}`)
378
+ if (idea.description) out.push(`**Idea:** ${idea.description}`)
379
+ if (idea.mapsTo) out.push(`**Maps to:** ${idea.mapsTo}`)
380
+ if (idea.effort) out.push(`**Effort:** ${idea.effort}`)
381
+ if (idea.impact) out.push(`**Impact:** ${idea.impact}`)
382
+
383
+ const date = idea.killedDate || new Date().toISOString().slice(0, 10)
384
+ const reason = idea.killedReason || '(no reason given)'
385
+ out.push(`**Killed:** ${date} — ${reason}`)
386
+
387
+ for (const extra of (idea._extraLines || [])) {
388
+ out.push(extra)
389
+ }
390
+
391
+ // Discussion thread
392
+ if (idea.discussion && idea.discussion.length > 0) {
393
+ out.push('**Discussion:**')
394
+ for (const entry of idea.discussion) {
395
+ out.push(`- [${entry.date}] ${entry.author}: ${entry.text}`)
396
+ }
397
+ }
398
+
399
+ out.push('')
400
+ return out
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Mutation helpers
405
+ // ---------------------------------------------------------------------------
406
+
407
+ /**
408
+ * Add a new idea. Mutates parsedData in place and returns it.
409
+ */
410
+ export function addIdea(parsedData, { title, description = '', source = '', tags = [], cluster = null, effort = null, impact = null }) {
411
+ const id = `IDEA-${parsedData.nextId}`
412
+ const idea = {
413
+ id,
414
+ num: parsedData.nextId,
415
+ title,
416
+ status: 'NEW',
417
+ priority: '—',
418
+ tags: Array.isArray(tags) ? tags : [],
419
+ source,
420
+ description,
421
+ cluster: cluster || null,
422
+ mapsTo: '',
423
+ killedReason: '',
424
+ killedDate: '',
425
+ effort,
426
+ impact,
427
+ discussion: [],
428
+ _extraLines: [],
429
+ }
430
+ parsedData.ideas.push(idea)
431
+ parsedData.nextId += 1
432
+ return parsedData
433
+ }
434
+
435
+ /**
436
+ * Promote an idea: mark PROMOTED, optionally reference a feature code.
437
+ */
438
+ export function promoteIdea(parsedData, ideaId, featureCode = '') {
439
+ const idea = findIdea(parsedData, ideaId)
440
+ if (!idea) throw new Error(`Idea not found: ${ideaId}`)
441
+ const ref = featureCode ? ` (→ ${featureCode})` : ''
442
+ idea.status = `PROMOTED${ref}`
443
+ return parsedData
444
+ }
445
+
446
+ /**
447
+ * Kill an idea: move from ideas → killed with reason + date.
448
+ */
449
+ export function killIdea(parsedData, ideaId, reason = '') {
450
+ const idx = parsedData.ideas.findIndex(i => i.id.toUpperCase() === ideaId.toUpperCase())
451
+ if (idx === -1) {
452
+ // Already in killed? No-op.
453
+ const inKilled = parsedData.killed.find(i => i.id.toUpperCase() === ideaId.toUpperCase())
454
+ if (inKilled) return parsedData
455
+ throw new Error(`Idea not found: ${ideaId}`)
456
+ }
457
+ const [idea] = parsedData.ideas.splice(idx, 1)
458
+ idea.status = 'KILLED'
459
+ idea.killedReason = reason
460
+ idea.killedDate = new Date().toISOString().slice(0, 10)
461
+ parsedData.killed.push(idea)
462
+ return parsedData
463
+ }
464
+
465
+ /**
466
+ * Resurrect a killed idea: move from killed → ideas, reset status to NEW.
467
+ */
468
+ export function resurrectIdea(parsedData, ideaId) {
469
+ const idx = parsedData.killed.findIndex(i => i.id.toUpperCase() === ideaId.toUpperCase())
470
+ if (idx === -1) throw new Error(`Killed idea not found: ${ideaId}`)
471
+ const [idea] = parsedData.killed.splice(idx, 1)
472
+ idea.status = 'NEW'
473
+ delete idea.killedReason
474
+ delete idea.killedDate
475
+ parsedData.ideas.push(idea)
476
+ return parsedData
477
+ }
478
+
479
+ /**
480
+ * Set priority on an idea.
481
+ */
482
+ export function setPriority(parsedData, ideaId, priority) {
483
+ const valid = ['P0', 'P1', 'P2', '—']
484
+ if (!valid.includes(priority)) throw new Error(`Invalid priority: ${priority}. Must be P0, P1, P2, or —`)
485
+ const idea = findIdea(parsedData, ideaId)
486
+ if (!idea) throw new Error(`Idea not found: ${ideaId}`)
487
+ idea.priority = priority
488
+ return parsedData
489
+ }
490
+
491
+ /**
492
+ * Update arbitrary fields on an idea (status, source, description, tags, cluster).
493
+ */
494
+ export function updateIdea(parsedData, ideaId, fields) {
495
+ const idea = findIdea(parsedData, ideaId)
496
+ if (!idea) throw new Error(`Idea not found: ${ideaId}`)
497
+ Object.assign(idea, fields)
498
+ return parsedData
499
+ }
500
+
501
+ /**
502
+ * Append a discussion entry to an idea.
503
+ * @param {object} parsedData
504
+ * @param {string} ideaId e.g. "IDEA-3"
505
+ * @param {string} author e.g. "human" or "agent"
506
+ * @param {string} text Comment text
507
+ */
508
+ export function addDiscussion(parsedData, ideaId, author, text) {
509
+ const idea = findIdea(parsedData, ideaId)
510
+ if (!idea) throw new Error(`Idea not found: ${ideaId}`)
511
+ if (!idea.discussion) idea.discussion = []
512
+ idea.discussion.push({
513
+ date: new Date().toISOString().slice(0, 10),
514
+ author,
515
+ text,
516
+ })
517
+ return parsedData
518
+ }
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // Lens support (Item 180)
522
+ // ---------------------------------------------------------------------------
523
+
524
+ /**
525
+ * Load a priority lens from docs/product/ideabox-priority-<lensName>.md.
526
+ * Returns lens metadata or null if not found.
527
+ */
528
+ export function loadLens(cwd, lensName) {
529
+ const lensPath = join(cwd, 'docs', 'product', `ideabox-priority-${lensName}.md`)
530
+ if (!existsSync(lensPath)) return null
531
+ const content = readFileSync(lensPath, 'utf-8')
532
+ return { name: lensName, path: lensPath, content }
533
+ }
534
+
535
+ // ---------------------------------------------------------------------------
536
+ // File I/O helpers
537
+ // ---------------------------------------------------------------------------
538
+
539
+ /**
540
+ * Read and parse the ideabox file from the project.
541
+ */
542
+ export function readIdeabox(cwd, ideaboxPath) {
543
+ const fullPath = join(cwd, ideaboxPath)
544
+ if (!existsSync(fullPath)) {
545
+ // Return empty state
546
+ return { ideas: [], killed: [], nextId: 1 }
547
+ }
548
+ const markdown = readFileSync(fullPath, 'utf-8')
549
+ return parseIdeabox(markdown)
550
+ }
551
+
552
+ /**
553
+ * Write serialized ideabox back to disk.
554
+ */
555
+ export function writeIdeabox(cwd, ideaboxPath, parsedData) {
556
+ const fullPath = join(cwd, ideaboxPath)
557
+ mkdirSync(dirname(fullPath), { recursive: true })
558
+ writeFileSync(fullPath, serializeIdeabox(parsedData))
559
+ }
560
+
561
+ // ---------------------------------------------------------------------------
562
+ // Internal helpers
563
+ // ---------------------------------------------------------------------------
564
+
565
+ function findIdea(parsedData, ideaId) {
566
+ const upper = ideaId.toUpperCase()
567
+ return parsedData.ideas.find(i => i.id.toUpperCase() === upper)
568
+ || parsedData.killed.find(i => i.id.toUpperCase() === upper)
569
+ || null
570
+ }