@smartmemory/compose 0.1.7-beta → 0.1.8-beta

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 (79) hide show
  1. package/README.md +32 -5
  2. package/bin/compose.js +167 -5
  3. package/dist/assets/{_baseUniq-D-avYfn5.js → _baseUniq-3jW4HAOf.js} +1 -1
  4. package/dist/assets/{arc-BC4dfQ-X.js → arc-DzzDimyd.js} +1 -1
  5. package/dist/assets/{architectureDiagram-Q4EWVU46-BZmFXnGI.js → architectureDiagram-Q4EWVU46-CtAgwORz.js} +1 -1
  6. package/dist/assets/{blockDiagram-DXYQGD6D-DlfWSuux.js → blockDiagram-DXYQGD6D-Bryby0c_.js} +1 -1
  7. package/dist/assets/{c4Diagram-AHTNJAMY-Y__uJrRx.js → c4Diagram-AHTNJAMY-C7N9RTJ8.js} +1 -1
  8. package/dist/assets/channel-DDkv7DUd.js +1 -0
  9. package/dist/assets/{chunk-4BX2VUAB-BfMePfTp.js → chunk-4BX2VUAB-wijkFgZY.js} +1 -1
  10. package/dist/assets/{chunk-4TB4RGXK-BdlMSdEA.js → chunk-4TB4RGXK-zdSZGRS2.js} +1 -1
  11. package/dist/assets/{chunk-55IACEB6-vrQHZTdv.js → chunk-55IACEB6-6zqzTZQQ.js} +1 -1
  12. package/dist/assets/{chunk-EDXVE4YY-B8wioVlW.js → chunk-EDXVE4YY-frd1Vwf-.js} +1 -1
  13. package/dist/assets/{chunk-FMBD7UC4-Cd6Hrux2.js → chunk-FMBD7UC4-CdkRK5Hx.js} +1 -1
  14. package/dist/assets/{chunk-OYMX7WX6-CfrhdQXY.js → chunk-OYMX7WX6-C6bMB0cf.js} +1 -1
  15. package/dist/assets/{chunk-QZHKN3VN-B9JQerOU.js → chunk-QZHKN3VN-4vsxN3jq.js} +1 -1
  16. package/dist/assets/{chunk-YZCP3GAM-DFN9X99H.js → chunk-YZCP3GAM-DbNARKip.js} +1 -1
  17. package/dist/assets/classDiagram-6PBFFD2Q-J6ZTeCbW.js +1 -0
  18. package/dist/assets/classDiagram-v2-HSJHXN6E-J6ZTeCbW.js +1 -0
  19. package/dist/assets/clone-5MVZ89iV.js +1 -0
  20. package/dist/assets/{cose-bilkent-S5V4N54A-BAn0ap_E.js → cose-bilkent-S5V4N54A-BpXeV7Vj.js} +1 -1
  21. package/dist/assets/{dagre-KV5264BT-DyxnVq1g.js → dagre-KV5264BT-DQLu_W8r.js} +1 -1
  22. package/dist/assets/{diagram-5BDNPKRD-XCrzqski.js → diagram-5BDNPKRD-skaOoe5A.js} +1 -1
  23. package/dist/assets/{diagram-G4DWMVQ6-MBCAXft_.js → diagram-G4DWMVQ6-DezlfFH4.js} +1 -1
  24. package/dist/assets/{diagram-MMDJMWI5-DbtB2yS6.js → diagram-MMDJMWI5-BUu-v-wT.js} +1 -1
  25. package/dist/assets/{diagram-TYMM5635-Bb5NzX61.js → diagram-TYMM5635-CziQ6LPs.js} +1 -1
  26. package/dist/assets/{erDiagram-SMLLAGMA-CpIeCOh2.js → erDiagram-SMLLAGMA-BsAyOVTI.js} +1 -1
  27. package/dist/assets/{flowDiagram-DWJPFMVM-CHyoKnhW.js → flowDiagram-DWJPFMVM-CbYWJOLq.js} +1 -1
  28. package/dist/assets/{ganttDiagram-T4ZO3ILL-DErKteO_.js → ganttDiagram-T4ZO3ILL-CAwgDkLl.js} +1 -1
  29. package/dist/assets/{gitGraphDiagram-UUTBAWPF-KFVAtj2F.js → gitGraphDiagram-UUTBAWPF-DK4RlkjO.js} +1 -1
  30. package/dist/assets/{graph-CRnO_ifT.js → graph-orv1XHGx.js} +1 -1
  31. package/dist/assets/{index-DkRKLuNr.js → index-Ceywghsu.js} +143 -143
  32. package/dist/assets/{infoDiagram-42DDH7IO-BZFnuSp5.js → infoDiagram-42DDH7IO-DQyA75sK.js} +1 -1
  33. package/dist/assets/{ishikawaDiagram-UXIWVN3A-4Xe2Szde.js → ishikawaDiagram-UXIWVN3A-C-F_5q4k.js} +1 -1
  34. package/dist/assets/{journeyDiagram-VCZTEJTY-CZRByfS-.js → journeyDiagram-VCZTEJTY-Bj8UIvK-.js} +1 -1
  35. package/dist/assets/{kanban-definition-6JOO6SKY-B95sk6Fk.js → kanban-definition-6JOO6SKY-DZYr8Dp1.js} +1 -1
  36. package/dist/assets/{layout-BqNQzxWT.js → layout-CBaTKjpX.js} +1 -1
  37. package/dist/assets/{linear-CUh7qb64.js → linear-j1sI_SiN.js} +1 -1
  38. package/dist/assets/{min-wXgOS3ig.js → min-DtJISjld.js} +1 -1
  39. package/dist/assets/{mindmap-definition-QFDTVHPH-DB6iaAbO.js → mindmap-definition-QFDTVHPH-Bulb64RS.js} +1 -1
  40. package/dist/assets/{pieDiagram-DEJITSTG-CHkZHrTW.js → pieDiagram-DEJITSTG-D11keQxr.js} +1 -1
  41. package/dist/assets/{quadrantDiagram-34T5L4WZ-DoTEO8e3.js → quadrantDiagram-34T5L4WZ-BEcWQiEG.js} +1 -1
  42. package/dist/assets/{requirementDiagram-MS252O5E-Dn8peXYp.js → requirementDiagram-MS252O5E-Cbp23uDf.js} +1 -1
  43. package/dist/assets/{sankeyDiagram-XADWPNL6-DRXs6Ipb.js → sankeyDiagram-XADWPNL6-Dae1hMc5.js} +1 -1
  44. package/dist/assets/{sequenceDiagram-FGHM5R23-wBBYZ0aq.js → sequenceDiagram-FGHM5R23-C16abORi.js} +1 -1
  45. package/dist/assets/{stateDiagram-FHFEXIEX-DPlBNGmf.js → stateDiagram-FHFEXIEX-CbEtfhbx.js} +1 -1
  46. package/dist/assets/stateDiagram-v2-QKLJ7IA2-CyY84hEA.js +1 -0
  47. package/dist/assets/{timeline-definition-GMOUNBTQ-CbbyTlHk.js → timeline-definition-GMOUNBTQ-BV7JTNMI.js} +1 -1
  48. package/dist/assets/{vennDiagram-DHZGUBPP-Bj4GaFfj.js → vennDiagram-DHZGUBPP-DBZiT48j.js} +1 -1
  49. package/dist/assets/{wardley-RL74JXVD-RtNzq8KU.js → wardley-RL74JXVD-Cc8uoiL3.js} +37 -37
  50. package/dist/assets/{wardleyDiagram-NUSXRM2D-CDfE3zSj.js → wardleyDiagram-NUSXRM2D-DEYcWGo5.js} +1 -1
  51. package/dist/assets/{xychartDiagram-5P7HB3ND-CZXHHYD5.js → xychartDiagram-5P7HB3ND-bFhLXv2b.js} +1 -1
  52. package/dist/index.html +1 -1
  53. package/lib/build.js +193 -19
  54. package/lib/completion-writer.js +7 -4
  55. package/lib/deps.js +17 -6
  56. package/lib/feature-events.js +3 -0
  57. package/lib/feature-writer.js +34 -22
  58. package/lib/followup-writer.js +556 -0
  59. package/lib/mcp-enforcement.js +173 -0
  60. package/lib/migrate-roadmap.js +4 -1
  61. package/lib/project-paths.js +36 -0
  62. package/lib/review-lenses.js +23 -8
  63. package/lib/review-normalize.js +42 -3
  64. package/lib/roadmap-drift.js +54 -0
  65. package/lib/roadmap-gen.js +297 -27
  66. package/lib/roadmap-preservers.js +353 -0
  67. package/lib/step-prompt.js +15 -0
  68. package/lib/triage.js +2 -1
  69. package/lib/version-check.js +110 -0
  70. package/package.json +1 -1
  71. package/server/compose-mcp-tools.js +16 -2
  72. package/server/compose-mcp.js +24 -1
  73. package/server/vision-routes.js +51 -2
  74. package/templates/ROADMAP.md +6 -0
  75. package/dist/assets/channel-LRG9kHqJ.js +0 -1
  76. package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +0 -1
  77. package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +0 -1
  78. package/dist/assets/clone-dRxgFrBv.js +0 -1
  79. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BW0ezXb4.js +0 -1
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Preservation helpers for typed-writer regen of ROADMAP.md.
3
+ *
4
+ * COMP-MCP-MIGRATION-2-1-1 (Option A — hand-rolled augmentation).
5
+ *
6
+ * Three pure functions with no I/O. Each scans an existing ROADMAP.md
7
+ * source string and returns a Map of curated content for the writer to
8
+ * splice back during regen, so typed-writer flips don't destroy:
9
+ *
10
+ * - phase-status overrides like `PARKED (Claude Code dependency)`
11
+ * - anonymous historical rows with `—` in the Feature column
12
+ * - non-feature sections wrapped in `<!-- preserved-section: <id> -->`
13
+ */
14
+
15
+ const PHASE_HEADING_RE = /^##\s+(.+?)\s+—\s+(.+?)\s*$/;
16
+ const FENCE_RE = /^```/;
17
+ const TABLE_HEADER_RE = /^\|.*\|$/;
18
+ const TABLE_DIVIDER_RE = /^\|[\s|:-]+\|$/;
19
+ const TABLE_ROW_RE = /^\|.+\|$/;
20
+ const FEATURE_CODE_RE = /^[A-Z][A-Z0-9-]*[A-Z0-9]$/;
21
+
22
+ const PRESERVED_OPEN_RE = /^<!--\s*preserved-section:\s*([a-z][a-z0-9-]*)\s*-->\s*$/;
23
+ const PRESERVED_CLOSE_RE = /^<!--\s*\/preserved-section\s*-->\s*$/;
24
+
25
+ /**
26
+ * Scan ROADMAP.md text and return a Map of phaseId → override text.
27
+ *
28
+ * Override is the substring after `— ` in any `## ...` heading line.
29
+ * Headings without an em-dash override are not included.
30
+ * Headings inside fenced code blocks are ignored.
31
+ *
32
+ * @param {string} text
33
+ * @returns {Map<string, string>}
34
+ */
35
+ export function readPhaseOverrides(text) {
36
+ const out = new Map();
37
+ let inFence = false;
38
+ for (const line of text.split('\n')) {
39
+ if (FENCE_RE.test(line)) {
40
+ inFence = !inFence;
41
+ continue;
42
+ }
43
+ if (inFence) continue;
44
+ const m = line.match(PHASE_HEADING_RE);
45
+ if (m) out.set(m[1].trim(), m[2].trim());
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * Scan ROADMAP.md text and return a Map of phaseId → AnonRow[].
52
+ *
53
+ * AnonRow shape: { rawLine, predecessorCode }
54
+ * - rawLine: the full table-row line as it appears in source.
55
+ * - predecessorCode: feature code of the prior typed row in the same phase
56
+ * table, or null if this anon row was at the table head (no typed predecessor).
57
+ *
58
+ * A row is "anonymous" if its Feature column (detected by header) is `—` or
59
+ * doesn't match FEATURE_CODE_RE. The current parser regex (looser, requires
60
+ * trailing -<digits>) is NOT used here; we use the strict regex for accurate
61
+ * classification (anon means truly no feature code, not the parser's regex bug).
62
+ * For the 3-col anonymous form (`# | Item | Status`), all rows are anon.
63
+ *
64
+ * @param {string} text
65
+ * @returns {Map<string, Array<{rawLine: string, predecessorCode: string|null}>>}
66
+ */
67
+ export function readAnonymousRows(text) {
68
+ const out = new Map();
69
+ let inFence = false;
70
+ let currentPhaseId = null;
71
+ let inTable = false;
72
+ let codeColIdx = -1; // -1 means anonymous-form (3-col) table
73
+ let lastTypedCode = null;
74
+
75
+ const lines = text.split('\n');
76
+ for (let i = 0; i < lines.length; i++) {
77
+ const line = lines[i];
78
+ if (FENCE_RE.test(line)) {
79
+ inFence = !inFence;
80
+ continue;
81
+ }
82
+ if (inFence) continue;
83
+
84
+ // Phase heading resets table state.
85
+ const phaseMatch = line.match(/^##\s+(.+?)(?:\s+—\s+.+)?\s*$/);
86
+ if (phaseMatch && line.startsWith('## ')) {
87
+ currentPhaseId = phaseMatch[1].trim();
88
+ inTable = false;
89
+ codeColIdx = -1;
90
+ lastTypedCode = null;
91
+ continue;
92
+ }
93
+
94
+ if (!currentPhaseId) continue;
95
+ if (!TABLE_ROW_RE.test(line.trim())) {
96
+ inTable = false;
97
+ codeColIdx = -1;
98
+ lastTypedCode = null;
99
+ continue;
100
+ }
101
+
102
+ const cells = line.split('|').slice(1, -1).map(c => c.trim());
103
+
104
+ // Header row — detect column layout.
105
+ if (!inTable) {
106
+ const lower = cells.map(c => c.toLowerCase());
107
+ const featureIdx = lower.findIndex(c => c === 'feature');
108
+ if (featureIdx !== -1) {
109
+ codeColIdx = featureIdx;
110
+ } else if (lower[0] === 'id') {
111
+ codeColIdx = 0;
112
+ } else {
113
+ codeColIdx = -1; // anonymous form
114
+ }
115
+ inTable = true;
116
+ lastTypedCode = null;
117
+ continue;
118
+ }
119
+
120
+ // Skip divider rows.
121
+ if (TABLE_DIVIDER_RE.test(line.trim())) continue;
122
+
123
+ // Data row.
124
+ let isAnon = false;
125
+ if (codeColIdx === -1) {
126
+ isAnon = true;
127
+ } else {
128
+ const codeCell = cells[codeColIdx] ?? '';
129
+ if (codeCell === '—' || codeCell === '' || !FEATURE_CODE_RE.test(codeCell)) {
130
+ isAnon = true;
131
+ }
132
+ }
133
+
134
+ if (isAnon) {
135
+ const arr = out.get(currentPhaseId) ?? [];
136
+ arr.push({ rawLine: line, predecessorCode: lastTypedCode });
137
+ out.set(currentPhaseId, arr);
138
+ } else {
139
+ lastTypedCode = cells[codeColIdx];
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /**
146
+ * Scan ROADMAP.md text and return a Map of preserved-section id → rawSource.
147
+ *
148
+ * rawSource includes both open and close markers and everything between.
149
+ * Markers inside fenced code blocks are ignored. Unbalanced markers (open
150
+ * without matching close) are excluded.
151
+ *
152
+ * @param {string} text
153
+ * @returns {Map<string, string>}
154
+ */
155
+ export function readPreservedSections(text) {
156
+ const out = new Map();
157
+ let inFence = false;
158
+ let openId = null;
159
+ let openLineIdx = -1;
160
+ const lines = text.split('\n');
161
+
162
+ // Track byte offsets to slice rawSource.
163
+ // Compute cumulative offsets per line (start-of-line offsets).
164
+ const lineOffsets = new Array(lines.length + 1);
165
+ lineOffsets[0] = 0;
166
+ for (let i = 0; i < lines.length; i++) {
167
+ lineOffsets[i + 1] = lineOffsets[i] + lines[i].length + 1; // +1 for newline
168
+ }
169
+
170
+ for (let i = 0; i < lines.length; i++) {
171
+ const line = lines[i];
172
+ if (FENCE_RE.test(line)) {
173
+ inFence = !inFence;
174
+ continue;
175
+ }
176
+ if (inFence) continue;
177
+
178
+ const openMatch = line.match(PRESERVED_OPEN_RE);
179
+ if (openMatch) {
180
+ openId = openMatch[1];
181
+ openLineIdx = i;
182
+ continue;
183
+ }
184
+ if (openId !== null && PRESERVED_CLOSE_RE.test(line)) {
185
+ const startOffset = lineOffsets[openLineIdx];
186
+ const endOffset = lineOffsets[i + 1] - 1; // exclude trailing newline of close marker line
187
+ out.set(openId, text.slice(startOffset, endOffset));
188
+ openId = null;
189
+ openLineIdx = -1;
190
+ }
191
+ }
192
+ // Unbalanced open is silently dropped (could log, but tests expect empty/missing).
193
+ return out;
194
+ }
195
+
196
+ /**
197
+ * For each `## ` phase heading in source, capture the entire phase block —
198
+ * heading line + everything up to (but not including) the next `## ` heading,
199
+ * `<!-- preserved-section: ... -->` open marker, or EOF. Trailing `---`
200
+ * separator and surrounding blank lines before the next boundary are excluded.
201
+ *
202
+ * Used by the writer as a fallback for phases that have no feature.json
203
+ * features — emit the raw block verbatim so curated prose, exit text, and
204
+ * legacy table formatting all survive regen.
205
+ *
206
+ * Headings inside fenced code blocks are ignored.
207
+ *
208
+ * @param {string} text
209
+ * @returns {Map<string, string>}
210
+ */
211
+ export function readPhaseBlocks(text) {
212
+ const out = new Map();
213
+ let inFence = false;
214
+ let inPreserved = false;
215
+ let currentPhaseId = null;
216
+ let currentStartLineIdx = -1;
217
+ const lines = text.split('\n');
218
+
219
+ const finalize = (endLineIdx) => {
220
+ if (currentPhaseId === null || currentStartLineIdx < 0) return;
221
+ // Walk back over trailing blank lines and a single `---` separator.
222
+ let endIdx = endLineIdx;
223
+ while (endIdx > currentStartLineIdx && lines[endIdx - 1].trim() === '') endIdx--;
224
+ if (endIdx > currentStartLineIdx && lines[endIdx - 1].trim() === '---') endIdx--;
225
+ while (endIdx > currentStartLineIdx && lines[endIdx - 1].trim() === '') endIdx--;
226
+ const block = lines.slice(currentStartLineIdx, endIdx).join('\n');
227
+ out.set(currentPhaseId, block);
228
+ };
229
+
230
+ for (let i = 0; i < lines.length; i++) {
231
+ const line = lines[i];
232
+ if (FENCE_RE.test(line)) {
233
+ inFence = !inFence;
234
+ continue;
235
+ }
236
+ if (inFence) continue;
237
+
238
+ if (PRESERVED_OPEN_RE.test(line)) {
239
+ finalize(i);
240
+ currentPhaseId = null;
241
+ currentStartLineIdx = -1;
242
+ inPreserved = true;
243
+ continue;
244
+ }
245
+ if (PRESERVED_CLOSE_RE.test(line)) {
246
+ inPreserved = false;
247
+ continue;
248
+ }
249
+ if (inPreserved) continue;
250
+
251
+ const phaseMatch = line.match(/^##\s+(.+?)(?:\s+—\s+.+)?\s*$/);
252
+ if (phaseMatch && line.startsWith('## ')) {
253
+ finalize(i);
254
+ currentPhaseId = phaseMatch[1].trim();
255
+ currentStartLineIdx = i;
256
+ }
257
+ }
258
+ finalize(lines.length);
259
+
260
+ return out;
261
+ }
262
+
263
+ /**
264
+ * Return the array of phaseIds in their order of appearance in source.
265
+ *
266
+ * Used by the writer to preserve original phase order when emitting phases
267
+ * that exist only in the source (no feature.json features). Headings inside
268
+ * fenced code blocks or inside open preserved-section markers are ignored.
269
+ *
270
+ * @param {string} text
271
+ * @returns {string[]}
272
+ */
273
+ export function readPhaseOrder(text) {
274
+ const out = [];
275
+ let inFence = false;
276
+ let inPreserved = false;
277
+ for (const line of text.split('\n')) {
278
+ if (FENCE_RE.test(line)) {
279
+ inFence = !inFence;
280
+ continue;
281
+ }
282
+ if (inFence) continue;
283
+
284
+ if (PRESERVED_OPEN_RE.test(line)) {
285
+ inPreserved = true;
286
+ continue;
287
+ }
288
+ if (PRESERVED_CLOSE_RE.test(line)) {
289
+ inPreserved = false;
290
+ continue;
291
+ }
292
+ if (inPreserved) continue;
293
+
294
+ const phaseMatch = line.match(/^##\s+(.+?)(?:\s+—\s+.+)?\s*$/);
295
+ if (phaseMatch && line.startsWith('## ')) {
296
+ out.push(phaseMatch[1].trim());
297
+ }
298
+ }
299
+ return out;
300
+ }
301
+
302
+ /**
303
+ * For each preserved section, find the phaseId of the most recent `## ` heading
304
+ * before its open marker. Returns Map<id, phaseId|null>.
305
+ *
306
+ * Used by the writer to splice preserved sections back into the regenerated
307
+ * output at the correct sequential position relative to phases.
308
+ *
309
+ * - `null` anchor means the preserved section appeared at the top of the
310
+ * file before any phase heading (e.g. Roadmap Conventions).
311
+ * - Markers inside fenced code blocks are ignored (false-positive guard).
312
+ * - Open markers without a matching close are excluded.
313
+ *
314
+ * @param {string} text
315
+ * @returns {Map<string, string|null>}
316
+ */
317
+ export function readPreservedSectionAnchors(text) {
318
+ const out = new Map();
319
+ let inFence = false;
320
+ let currentPhaseId = null;
321
+ let openId = null;
322
+ let openAnchor = null;
323
+
324
+ for (const line of text.split('\n')) {
325
+ if (FENCE_RE.test(line)) {
326
+ inFence = !inFence;
327
+ continue;
328
+ }
329
+ if (inFence) continue;
330
+
331
+ // Phase headings inside an open preserved-section do NOT advance the anchor.
332
+ if (openId === null) {
333
+ const phaseMatch = line.match(/^##\s+(.+?)(?:\s+—\s+.+)?\s*$/);
334
+ if (phaseMatch && line.startsWith('## ') && !PRESERVED_OPEN_RE.test(line) && !PRESERVED_CLOSE_RE.test(line)) {
335
+ currentPhaseId = phaseMatch[1].trim();
336
+ continue;
337
+ }
338
+ }
339
+
340
+ const openMatch = line.match(PRESERVED_OPEN_RE);
341
+ if (openMatch) {
342
+ openId = openMatch[1];
343
+ openAnchor = currentPhaseId;
344
+ continue;
345
+ }
346
+ if (openId !== null && PRESERVED_CLOSE_RE.test(line)) {
347
+ out.set(openId, openAnchor);
348
+ openId = null;
349
+ openAnchor = null;
350
+ }
351
+ }
352
+ return out;
353
+ }
@@ -93,6 +93,21 @@ export function buildStepPrompt(stepDispatch, context) {
93
93
  }
94
94
  }
95
95
 
96
+ // COMP-MCP-MIGRATION: typed-tool enforcement (prompt-only in v1).
97
+ // When `enforcement.mcpForFeatureMgmt` is true in .compose/compose.json,
98
+ // inject a hard instruction telling the agent to use typed MCP tools
99
+ // instead of free-text Edit/Write for ROADMAP/CHANGELOG/feature.json.
100
+ if (context.enforceMcpForFeatureMgmt) {
101
+ sections.push(
102
+ '## Enforcement\n' +
103
+ 'Do NOT use Edit, Write, or any shell write that targets `ROADMAP.md`, ' +
104
+ '`CHANGELOG.md`, or any `feature.json` under `docs/features/`. Use the ' +
105
+ 'typed MCP tools instead: `add_roadmap_entry`, `set_feature_status`, ' +
106
+ '`add_changelog_entry`, `record_completion`, `propose_followup`, ' +
107
+ '`link_features`, `link_artifact`, `write_journal_entry`.'
108
+ );
109
+ }
110
+
96
111
  const ctxLines = [
97
112
  `Working directory: ${context.cwd}`,
98
113
  `Feature: ${context.featureCode}`,
package/lib/triage.js CHANGED
@@ -185,7 +185,8 @@ function deriveProfile(signals) {
185
185
  */
186
186
  export async function runTriage(featureCode, opts = {}) {
187
187
  const cwd = opts.cwd ?? process.cwd();
188
- const featureDir = join(cwd, 'docs', 'features', featureCode);
188
+ const featuresDir = opts.featuresDir ?? 'docs/features';
189
+ const featureDir = join(cwd, featuresDir, featureCode);
189
190
 
190
191
  // Collect content from key files
191
192
  const candidateFiles = ['plan.md', 'blueprint.md', 'design.md', 'prd.md', 'architecture.md'];
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Version drift detection — fetch latest @smartmemory/compose from npm
3
+ * and compare to the locally-installed version.
4
+ *
5
+ * Cached 24h at ~/.compose/version-cache.json. Never throws; returns null
6
+ * on any failure (network, parse, etc.) so it never breaks `compose doctor`.
7
+ */
8
+
9
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
10
+ import { join, dirname } from 'path'
11
+ import { homedir } from 'os'
12
+
13
+ const PACKAGE_NAME = '@smartmemory/compose'
14
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24h
15
+ const FETCH_TIMEOUT_MS = 3000
16
+
17
+ function cachePath() {
18
+ return join(homedir(), '.compose', 'version-cache.json')
19
+ }
20
+
21
+ function readCache() {
22
+ try {
23
+ const raw = readFileSync(cachePath(), 'utf-8')
24
+ const parsed = JSON.parse(raw)
25
+ if (typeof parsed?.fetchedAt !== 'number' || typeof parsed?.latest !== 'string') return null
26
+ if (Date.now() - parsed.fetchedAt > CACHE_TTL_MS) return null
27
+ return parsed
28
+ } catch {
29
+ return null
30
+ }
31
+ }
32
+
33
+ function writeCache(latest) {
34
+ try {
35
+ const path = cachePath()
36
+ mkdirSync(dirname(path), { recursive: true })
37
+ writeFileSync(path, JSON.stringify({ fetchedAt: Date.now(), latest }, null, 2))
38
+ } catch {
39
+ // best-effort cache; ignore failures
40
+ }
41
+ }
42
+
43
+ async function fetchLatest() {
44
+ if (typeof fetch !== 'function') return null
45
+ const controller = new AbortController()
46
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
47
+ try {
48
+ const url = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`
49
+ const res = await fetch(url, { signal: controller.signal, headers: { accept: 'application/json' } })
50
+ if (!res.ok) return null
51
+ const data = await res.json()
52
+ return typeof data?.version === 'string' ? data.version : null
53
+ } catch {
54
+ return null
55
+ } finally {
56
+ clearTimeout(timer)
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Compare semver-ish strings. Returns:
62
+ * -1 if a < b, 0 if equal, 1 if a > b, null if either unparseable.
63
+ * Treats prerelease tags conservatively: "0.1.7-beta" < "0.1.7".
64
+ */
65
+ export function compareVersions(a, b) {
66
+ if (typeof a !== 'string' || typeof b !== 'string') return null
67
+ const parse = (s) => {
68
+ const [core, pre] = s.split('-')
69
+ const parts = core.split('.').map(n => Number.parseInt(n, 10))
70
+ if (parts.length !== 3 || parts.some(n => Number.isNaN(n))) return null
71
+ return { parts, pre: pre ?? null }
72
+ }
73
+ const pa = parse(a)
74
+ const pb = parse(b)
75
+ if (!pa || !pb) return null
76
+ for (let i = 0; i < 3; i++) {
77
+ if (pa.parts[i] < pb.parts[i]) return -1
78
+ if (pa.parts[i] > pb.parts[i]) return 1
79
+ }
80
+ // cores equal — prerelease < release
81
+ if (pa.pre && !pb.pre) return -1
82
+ if (!pa.pre && pb.pre) return 1
83
+ if (pa.pre === pb.pre) return 0
84
+ return pa.pre < pb.pre ? -1 : 1
85
+ }
86
+
87
+ /**
88
+ * Returns { current, latest, behind, source } or null on failure.
89
+ * behind: true if current < latest, false otherwise.
90
+ * source: 'cache' | 'network'
91
+ */
92
+ export async function checkLatestVersion(currentVersion, { force = false } = {}) {
93
+ if (!currentVersion) return null
94
+
95
+ if (!force) {
96
+ const cached = readCache()
97
+ if (cached) {
98
+ const cmp = compareVersions(currentVersion, cached.latest)
99
+ if (cmp === null) return null
100
+ return { current: currentVersion, latest: cached.latest, behind: cmp < 0, source: 'cache' }
101
+ }
102
+ }
103
+
104
+ const latest = await fetchLatest()
105
+ if (!latest) return null
106
+ writeCache(latest)
107
+ const cmp = compareVersions(currentVersion, latest)
108
+ if (cmp === null) return null
109
+ return { current: currentVersion, latest, behind: cmp < 0, source: 'network' }
110
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.1.7-beta",
3
+ "version": "0.1.8-beta",
4
4
  "description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
5
5
  "author": "SmartMemory",
6
6
  "license": "MIT",
@@ -236,6 +236,15 @@ export async function toolGetFeatureLinks(args) {
236
236
  return getFeatureLinks(getTargetRoot(), args);
237
237
  }
238
238
 
239
+ // ---------------------------------------------------------------------------
240
+ // Follow-up filing — COMP-MCP-FOLLOWUP
241
+ // ---------------------------------------------------------------------------
242
+
243
+ export async function toolProposeFollowup(args) {
244
+ const { proposeFollowup } = await import('../lib/followup-writer.js');
245
+ return proposeFollowup(getTargetRoot(), args);
246
+ }
247
+
239
248
  // ---------------------------------------------------------------------------
240
249
  // Changelog writer — COMP-MCP-CHANGELOG-WRITER
241
250
  // ---------------------------------------------------------------------------
@@ -397,8 +406,13 @@ export async function toolKillFeature({ id, reason }) {
397
406
  return _postLifecycle(id, 'kill', { reason });
398
407
  }
399
408
 
400
- export async function toolCompleteFeature({ id }) {
401
- return _postLifecycle(id, 'complete', {});
409
+ export async function toolCompleteFeature({ id, commit_sha, tests_pass, files_changed, notes }) {
410
+ const body = {};
411
+ if (commit_sha !== undefined) body.commit_sha = commit_sha;
412
+ if (tests_pass !== undefined) body.tests_pass = tests_pass;
413
+ if (files_changed !== undefined) body.files_changed = files_changed;
414
+ if (notes !== undefined) body.notes = notes;
415
+ return _postLifecycle(id, 'complete', body);
402
416
  }
403
417
 
404
418
  export async function toolIterationStart({ id, loopType, maxIterations }) {
@@ -50,6 +50,7 @@ import {
50
50
  toolLinkFeatures,
51
51
  toolGetFeatureArtifacts,
52
52
  toolGetFeatureLinks,
53
+ toolProposeFollowup,
53
54
  toolAddChangelogEntry,
54
55
  toolGetChangelogEntries,
55
56
  toolWriteJournalEntry,
@@ -175,11 +176,15 @@ const TOOLS = [
175
176
  },
176
177
  {
177
178
  name: 'complete_feature',
178
- description: 'Mark a feature as complete. Only callable from the ship phase.',
179
+ description: 'Mark a feature as complete. Only callable from the ship phase. When commit_sha is provided, the lifecycle endpoint also writes a commit-bound completion record via record_completion (which atomically flips feature.status to COMPLETE and regenerates ROADMAP.md). Without commit_sha, the lifecycle transitions but no completion record is written; a `cockpit_completion_skipped` decision event explains the skip.',
179
180
  inputSchema: {
180
181
  type: 'object',
181
182
  properties: {
182
183
  id: { type: 'string', description: 'Item ID' },
184
+ commit_sha: { type: 'string', description: 'Full 40-char commit SHA. Required to write a completion record.' },
185
+ tests_pass: { type: 'boolean', description: 'Defaults to true when commit_sha is provided.' },
186
+ files_changed: { type: 'array', items: { type: 'string' }, description: 'Repo-relative paths committed in the SHA.' },
187
+ notes: { type: 'string', description: 'One-line note for the completion record.' },
183
188
  },
184
189
  required: ['id'],
185
190
  },
@@ -348,6 +353,23 @@ const TOOLS = [
348
353
  },
349
354
  },
350
355
  },
356
+ {
357
+ name: 'propose_followup',
358
+ description: 'File a follow-up feature against a parent. Auto-numbers the next code in the parent\'s namespace (parent_code-N), adds the ROADMAP row, links surfaced_by from new → parent, and scaffolds design.md with a "## Why" rationale block. Idempotent on (parent_code, idempotency_key); resumes across partial failures via an inflight ledger.',
359
+ inputSchema: {
360
+ type: 'object',
361
+ required: ['parent_code', 'description', 'rationale'],
362
+ properties: {
363
+ parent_code: { type: 'string', description: 'Parent feature code (e.g. "COMP-MCP-MIGRATION"). Must exist; must not be KILLED/SUPERSEDED.' },
364
+ description: { type: 'string', description: 'One-line description for the ROADMAP cell.' },
365
+ rationale: { type: 'string', description: 'Why this follow-up exists. Persisted as a "## Why" block in the new design.md and in the audit event.' },
366
+ complexity: { type: 'string', enum: ['S', 'M', 'L', 'XL'] },
367
+ phase: { type: 'string', description: 'Phase heading. Defaults to the parent\'s phase if omitted.' },
368
+ status: { type: 'string', enum: ['PLANNED', 'IN_PROGRESS', 'PARTIAL', 'COMPLETE', 'BLOCKED', 'KILLED', 'PARKED', 'SUPERSEDED'] },
369
+ idempotency_key: { type: 'string', description: 'Optional retry-safety key. Without it, repeated calls allocate new codes.' },
370
+ },
371
+ },
372
+ },
351
373
 
352
374
  // -------------------------------------------------------------------------
353
375
  // Linker — COMP-MCP-ARTIFACT-LINKER
@@ -582,6 +604,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
582
604
  case 'get_completions': result = await toolGetCompletions(args); break;
583
605
  case 'validate_feature': result = await toolValidateFeature(args); break;
584
606
  case 'validate_project': result = await toolValidateProject(args); break;
607
+ case 'propose_followup': result = await toolProposeFollowup(args); break;
585
608
  // agent_run removed — STRAT-DEDUP-AGENTRUN v1. Use mcp__stratum__stratum_agent_run.
586
609
  default:
587
610
  return {
@@ -338,7 +338,7 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
338
338
  }
339
339
  });
340
340
 
341
- app.post('/api/vision/items/:id/lifecycle/complete', (req, res) => {
341
+ app.post('/api/vision/items/:id/lifecycle/complete', async (req, res) => {
342
342
  try {
343
343
  const item = store.items.get(req.params.id);
344
344
  if (!item?.lifecycle) return res.status(404).json({ error: 'No lifecycle on this item' });
@@ -360,7 +360,56 @@ export function attachVisionRoutes(app, { store, scheduleBroadcast, broadcastMes
360
360
  emitDriftAxes(broadcastMessage, store, item, projectRoot, now);
361
361
  // COMP-OBS-STATUS: emit status snapshot after lifecycle transition (complete)
362
362
  emitStatusSnapshot(broadcastMessage, store, item.lifecycle.featureCode, now);
363
- res.json({ completedAt: now });
363
+
364
+ // COMP-MCP-MIGRATION: reconcile cockpit complete with record_completion
365
+ // (commit-bound completion writer). Best-effort: failures emit a decision
366
+ // event but never roll back the lifecycle transition.
367
+ const featureCode = item.lifecycle.featureCode;
368
+ const completionResult = { partial: false };
369
+ if (featureCode) {
370
+ const { commit_sha, tests_pass, files_changed, notes } = req.body || {};
371
+ if (commit_sha) {
372
+ try {
373
+ const { recordCompletion } = await import('../lib/completion-writer.js');
374
+ await recordCompletion(projectRoot, {
375
+ feature_code: featureCode,
376
+ commit_sha,
377
+ tests_pass: tests_pass ?? true,
378
+ files_changed: files_changed ?? [],
379
+ notes: notes ?? `cockpit lifecycle: ${featureCode} complete`,
380
+ });
381
+ } catch (err) {
382
+ completionResult.partial = true;
383
+ const eventType = err.code === 'STATUS_FLIP_AFTER_COMPLETION_RECORDED'
384
+ ? 'cockpit_completion_partial_status_flip'
385
+ : 'cockpit_completion_failed';
386
+ completionResult.completion_failed = err.code || 'UNKNOWN';
387
+ completionResult.message = err.message;
388
+ try {
389
+ emitDecisionEvent(broadcastMessage, {
390
+ type: eventType,
391
+ featureCode,
392
+ timestamp: now,
393
+ error: { code: err.code, message: err.message },
394
+ });
395
+ } catch { /* decision event emit best-effort */ }
396
+ // eslint-disable-next-line no-console
397
+ console.warn(`[lifecycle/complete] record_completion ${eventType} for ${featureCode}: ${err.message}`);
398
+ }
399
+ } else {
400
+ // No SHA — emit a skip event so validate_feature can flag drift later
401
+ try {
402
+ emitDecisionEvent(broadcastMessage, {
403
+ type: 'cockpit_completion_skipped',
404
+ featureCode,
405
+ timestamp: now,
406
+ reason: 'no_commit_sha',
407
+ });
408
+ } catch { /* decision event emit best-effort */ }
409
+ }
410
+ }
411
+
412
+ res.json({ completedAt: now, ...completionResult });
364
413
  } catch (err) {
365
414
  const status = err.message.includes('not found') ? 404 : 400;
366
415
  res.status(status).json({ error: err.message });
@@ -5,6 +5,7 @@
5
5
 
6
6
  ---
7
7
 
8
+ <!-- preserved-section: roadmap-conventions -->
8
9
  ## Roadmap Conventions
9
10
 
10
11
  - **Status:** `PLANNED` | `IN_PROGRESS` | `PARTIAL` | `COMPLETE` | `SUPERSEDED` | `PARKED`
@@ -12,6 +13,8 @@
12
13
  - Items are numbered sequentially across all phases — never reuse a number.
13
14
  - Cross-reference stable IDs (e.g. `FEAT-1`, `Phase 2`) not section headings.
14
15
 
16
+ <!-- /preserved-section -->
17
+
15
18
  ---
16
19
 
17
20
  ## Phase 1: Foundation — PLANNED
@@ -37,6 +40,7 @@ Bootstrap: establish the core structure and first working milestone.
37
40
 
38
41
  ---
39
42
 
43
+ <!-- preserved-section: dogfooding-milestones -->
40
44
  ## Dogfooding Milestones
41
45
 
42
46
  | Milestone | Description | Status |
@@ -44,3 +48,5 @@ Bootstrap: establish the core structure and first working milestone.
44
48
  | D0: Bootstrap | Manual, out-of-band. | PLANNED |
45
49
  | D1: [First self-use milestone] | [Description] | PLANNED |
46
50
  | D2: [Second self-use milestone] | [Description] | PLANNED |
51
+
52
+ <!-- /preserved-section -->