@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.
- package/LICENSE +21 -0
- package/README.md +1014 -0
- package/bin/compose.js +1515 -0
- package/dist/assets/_baseUniq-CQwX6VLz.js +1 -0
- package/dist/assets/arc-SxJ2J1sh.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-BykunY1F.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-ohAKBOUw.js +132 -0
- package/dist/assets/c4Diagram-AHTNJAMY-DBDC3ENB.js +10 -0
- package/dist/assets/channel-DGElom1e.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-Cv93Z7uM.js +1 -0
- package/dist/assets/chunk-4TB4RGXK-DE0WBDkj.js +206 -0
- package/dist/assets/chunk-55IACEB6-CE1EXenG.js +1 -0
- package/dist/assets/chunk-EDXVE4YY-DA7Ana6H.js +1 -0
- package/dist/assets/chunk-FMBD7UC4-CTDIPA3p.js +15 -0
- package/dist/assets/chunk-OYMX7WX6-uGBaPaTX.js +231 -0
- package/dist/assets/chunk-QZHKN3VN-CYlnXuUO.js +1 -0
- package/dist/assets/chunk-YZCP3GAM-ojGkzcZK.js +1 -0
- package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +1 -0
- package/dist/assets/clone-DUJKJXd7.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-Bktn9hL-.js +1 -0
- package/dist/assets/dagre-KV5264BT-DFaSzuRF.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-5BDNPKRD-DnfmDzEm.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-Bm8W9YnG.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-B5-TSKvp.js +43 -0
- package/dist/assets/diagram-TYMM5635-ls4rqlky.js +24 -0
- package/dist/assets/erDiagram-SMLLAGMA-giG6WO-r.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-XvlUuz-7.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-hLBV57oV.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js +106 -0
- package/dist/assets/graph-D0Cfv00Y.js +1 -0
- package/dist/assets/index-CUd6pFGF.css +1 -0
- package/dist/assets/index-DReRlzZI.js +1144 -0
- package/dist/assets/infoDiagram-42DDH7IO-DbqRsOo3.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/ishikawaDiagram-UXIWVN3A-DnCdx7zb.js +70 -0
- package/dist/assets/journeyDiagram-VCZTEJTY-CfD7eNcP.js +139 -0
- package/dist/assets/kanban-definition-6JOO6SKY-BYaO9-mK.js +89 -0
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-Bj72wOEB.js +1 -0
- package/dist/assets/linear-BRFo114D.js +1 -0
- package/dist/assets/min-GCHnKlJS.js +1 -0
- package/dist/assets/mindmap-definition-QFDTVHPH-n0PMebY4.js +96 -0
- package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/assets/pieDiagram-DEJITSTG-pN4CljHF.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-DNoAy8-D.js +7 -0
- package/dist/assets/requirementDiagram-MS252O5E-BhtY05PT.js +84 -0
- package/dist/assets/sankeyDiagram-XADWPNL6-B6AD-16A.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-DShHM-uk.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-DMxn7HTo.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +1 -0
- package/dist/assets/timeline-definition-GMOUNBTQ-Cdu6uq52.js +120 -0
- package/dist/assets/vennDiagram-DHZGUBPP-CpK29iRe.js +34 -0
- package/dist/assets/wardley-RL74JXVD-BQgSkdcO.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-DJHYev6O.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-1d75pbaO.js +7 -0
- package/dist/index.html +30 -0
- package/lib/agent-chains.js +65 -0
- package/lib/agent-string.js +86 -0
- package/lib/budget-ledger.js +86 -0
- package/lib/build-all.js +162 -0
- package/lib/build-dag.js +120 -0
- package/lib/build-stream-writer.js +190 -0
- package/lib/build.js +2997 -0
- package/lib/capability-checker.js +53 -0
- package/lib/cert-inject.js +38 -0
- package/lib/cli-progress.js +483 -0
- package/lib/constants.js +69 -0
- package/lib/cross-layer-audit.js +84 -0
- package/lib/debug-discipline.js +173 -0
- package/lib/feature-json.js +106 -0
- package/lib/gate-prompt.js +291 -0
- package/lib/gate-tiers.js +194 -0
- package/lib/health-history.js +119 -0
- package/lib/health-score.js +227 -0
- package/lib/ideabox.js +570 -0
- package/lib/import.js +244 -0
- package/lib/migrate-roadmap.js +94 -0
- package/lib/model-pricing.js +67 -0
- package/lib/new.js +413 -0
- package/lib/pipeline-cli.js +489 -0
- package/lib/plan-parser.js +103 -0
- package/lib/qa-scoping.js +474 -0
- package/lib/questionnaire.js +200 -0
- package/lib/resolve-port.js +7 -0
- package/lib/result-normalizer.js +349 -0
- package/lib/review-lenses.js +166 -0
- package/lib/roadmap-gen.js +210 -0
- package/lib/roadmap-parser.js +176 -0
- package/lib/server-probe.js +23 -0
- package/lib/staleness.js +87 -0
- package/lib/step-prompt.js +260 -0
- package/lib/step-validator.js +49 -0
- package/lib/stratum-mcp-client.js +365 -0
- package/lib/team-flag.js +46 -0
- package/lib/test-bootstrap.js +401 -0
- package/lib/triage.js +274 -0
- package/lib/vision-writer.js +391 -0
- package/package.json +111 -0
- package/pipelines/bug-fix.stratum.yaml +230 -0
- package/pipelines/build.stratum.yaml +498 -0
- package/pipelines/content.stratum.yaml +112 -0
- package/pipelines/coverage-sweep.stratum.yaml +52 -0
- package/pipelines/refactor.stratum.yaml +169 -0
- package/pipelines/research.stratum.yaml +88 -0
- package/pipelines/review-fix.stratum.yaml +109 -0
- package/presets/team-feature.stratum.yaml +105 -0
- package/presets/team-research.stratum.yaml +108 -0
- package/presets/team-review.stratum.yaml +106 -0
- package/scripts/agent-activity-hook.sh +31 -0
- package/scripts/agent-error-hook.sh +28 -0
- package/scripts/analyze-orphans.mjs +50 -0
- package/scripts/find-orphans.mjs +26 -0
- package/scripts/fix-phases.mjs +49 -0
- package/scripts/generate-stratum-spec.mjs +137 -0
- package/scripts/import-roadmap.mjs +116 -0
- package/scripts/phase-audit.mjs +33 -0
- package/scripts/run-pipeline.mjs +314 -0
- package/scripts/session-end-hook.sh +18 -0
- package/scripts/session-start-hook.sh +38 -0
- package/scripts/vision-hook.sh +104 -0
- package/scripts/vision-track.mjs +554 -0
- package/scripts/wire-all-orphans.mjs +108 -0
- package/scripts/wire-orphans.mjs +164 -0
- package/server/activity-routes.js +123 -0
- package/server/agent-health.js +197 -0
- package/server/agent-hooks.js +102 -0
- package/server/agent-mcp.js +10 -0
- package/server/agent-registry.js +95 -0
- package/server/agent-server.js +290 -0
- package/server/agent-spawn.js +251 -0
- package/server/agent-templates.js +77 -0
- package/server/artifact-manager.js +247 -0
- package/server/artifact-templates/architecture.md +28 -0
- package/server/artifact-templates/blueprint.md +21 -0
- package/server/artifact-templates/design.md +36 -0
- package/server/artifact-templates/plan.md +25 -0
- package/server/artifact-templates/prd.md +43 -0
- package/server/artifact-templates/report.md +40 -0
- package/server/block-tracker.js +90 -0
- package/server/build-stream-bridge.js +502 -0
- package/server/coalescing-buffer.js +46 -0
- package/server/compose-mcp-tools.js +479 -0
- package/server/compose-mcp.js +324 -0
- package/server/connectors/agent-connector.js +78 -0
- package/server/connectors/claude-sdk-connector.js +198 -0
- package/server/connectors/codex-connector.js +240 -0
- package/server/connectors/connector-discovery.js +18 -0
- package/server/connectors/connector-runtime.js +13 -0
- package/server/connectors/opencode-connector.js +200 -0
- package/server/design-routes.js +540 -0
- package/server/design-session.js +161 -0
- package/server/feature-scan.js +593 -0
- package/server/file-watcher.js +284 -0
- package/server/find-root.js +29 -0
- package/server/graph-export.js +343 -0
- package/server/ideabox-cache.js +77 -0
- package/server/ideabox-routes.js +294 -0
- package/server/index.js +156 -0
- package/server/model-tiers.js +49 -0
- package/server/pipeline-routes.js +288 -0
- package/server/policy-evaluator.js +36 -0
- package/server/project-root.js +122 -0
- package/server/security.js +23 -0
- package/server/session-manager.js +403 -0
- package/server/session-routes.js +190 -0
- package/server/session-store.js +107 -0
- package/server/settings-routes.js +35 -0
- package/server/settings-store.js +234 -0
- package/server/stratum-api.js +102 -0
- package/server/stratum-client.js +192 -0
- package/server/stratum-sync.js +193 -0
- package/server/summarizer.js +139 -0
- package/server/supervisor.js +196 -0
- package/server/vision-routes.js +668 -0
- package/server/vision-server.js +393 -0
- package/server/vision-store.js +360 -0
- package/server/vision-utils.js +179 -0
- package/server/worktree-gc.js +137 -0
- 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
|
+
}
|