@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
@@ -7,8 +7,32 @@
7
7
  import { readFileSync, writeFileSync, existsSync } from 'fs';
8
8
  import { join } from 'path';
9
9
  import { listFeatures } from './feature-json.js';
10
+ import { loadFeaturesDir } from './project-paths.js';
11
+ import {
12
+ readPhaseOverrides,
13
+ readAnonymousRows,
14
+ readPreservedSections,
15
+ readPreservedSectionAnchors,
16
+ readPhaseOrder,
17
+ readPhaseBlocks,
18
+ } from './roadmap-preservers.js';
19
+ import { emitDrift } from './roadmap-drift.js';
10
20
 
11
21
  const STATUS_ORDER = ['IN_PROGRESS', 'PARTIAL', 'PLANNED', 'COMPLETE', 'SUPERSEDED', 'PARKED'];
22
+ const STATUS_TOKENS = ['COMPLETE', 'IN_PROGRESS', 'PARTIAL', 'PLANNED', 'SUPERSEDED', 'PARKED', 'BLOCKED', 'KILLED'];
23
+
24
+ /**
25
+ * Extract the leading status token from override text like
26
+ * `PARTIAL (1a–1d COMPLETE, 2 PLANNED)` → `PARTIAL`.
27
+ * Returns null if no token recognized.
28
+ */
29
+ function parseStatusToken(override) {
30
+ for (const t of STATUS_TOKENS) {
31
+ if (override === t) return t;
32
+ if (override.startsWith(t + ' ') || override.startsWith(t + '(')) return t;
33
+ }
34
+ return null;
35
+ }
12
36
 
13
37
  /**
14
38
  * Compute the aggregate status for a phase based on its features.
@@ -32,11 +56,27 @@ function phaseStatus(features) {
32
56
  * @returns {string} - Generated ROADMAP.md content
33
57
  */
34
58
  export function generateRoadmap(cwd, opts = {}) {
35
- const featuresDir = opts.featuresDir ?? 'docs/features';
59
+ const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
36
60
  const features = listFeatures(cwd, featuresDir);
37
61
 
38
- // Read existing ROADMAP.md to preserve header/preamble
39
- const preamble = readPreamble(cwd, opts);
62
+ // Read existing ROADMAP.md once: preamble + curated content for splice-back.
63
+ const roadmapPath = join(cwd, 'ROADMAP.md');
64
+ const existingText = existsSync(roadmapPath) ? readFileSync(roadmapPath, 'utf-8') : '';
65
+ const preamble = readPreamble(cwd, opts, existingText);
66
+ const overrides = readPhaseOverrides(existingText);
67
+ const anonRows = readAnonymousRows(existingText);
68
+ const preserved = readPreservedSections(existingText);
69
+ const anchors = readPreservedSectionAnchors(existingText);
70
+ const sourcePhaseOrder = readPhaseOrder(existingText);
71
+ const phaseBlocks = readPhaseBlocks(existingText);
72
+
73
+ // Build anchor → preservedIds Map for splice-back during phase emission.
74
+ const anchorToPreserved = new Map();
75
+ for (const [id, anchor] of anchors) {
76
+ const arr = anchorToPreserved.get(anchor) ?? [];
77
+ arr.push(id);
78
+ anchorToPreserved.set(anchor, arr);
79
+ }
40
80
 
41
81
  // Group by phase
42
82
  const phases = new Map();
@@ -54,27 +94,97 @@ export function generateRoadmap(cwd, opts = {}) {
54
94
 
55
95
  const sections = [preamble.trimEnd()];
56
96
 
57
- // Sort phases by the minimum position of their features (preserves ROADMAP order)
58
- const sortedPhases = [...phases.entries()].sort((a, b) => {
59
- const minA = Math.min(...a[1].map(f => f.position ?? 999));
60
- const minB = Math.min(...b[1].map(f => f.position ?? 999));
97
+ // Splice in any preserved sections anchored to null (top-of-file, before first phase).
98
+ for (const id of anchorToPreserved.get(null) ?? []) {
99
+ sections.push(preserved.get(id));
100
+ }
101
+
102
+ // Build merged phase order: source order first (preserves curated sequencing
103
+ // for empty/legacy phases that have no feature.json), then any feature.json
104
+ // phases not yet in source (truly new phases) sorted by feature position.
105
+ const orderedPhaseIds = [...sourcePhaseOrder];
106
+ const seenInSource = new Set(sourcePhaseOrder);
107
+ const newPhases = [...phases.keys()].filter(p => !seenInSource.has(p));
108
+ newPhases.sort((a, b) => {
109
+ const minA = Math.min(...phases.get(a).map(f => f.position ?? 999));
110
+ const minB = Math.min(...phases.get(b).map(f => f.position ?? 999));
61
111
  return minA - minB;
62
112
  });
113
+ orderedPhaseIds.push(...newPhases);
114
+
115
+ // Render each phase. Phases with feature.json features render their tables;
116
+ // phases that exist only in source (override + anon rows) render heading + anon rows.
117
+ const emittedPreservedIds = new Set();
118
+ for (const phase of orderedPhaseIds) {
119
+ const phaseFeatures = phases.get(phase) ?? [];
120
+ const override = overrides.get(phase);
63
121
 
64
- // Render each phase
65
- for (const [phase, phaseFeatures] of sortedPhases) {
66
- const status = phaseStatus(phaseFeatures);
67
- sections.push(renderPhase(phase, status, phaseFeatures));
122
+ if (phaseFeatures.length === 0) {
123
+ // No feature.json features for this phase. Splice the original phase
124
+ // block verbatim — preserves heading override + intro prose + table
125
+ // (anon rows) + exit text. If the source has no block (truly new phase
126
+ // with no source counterpart and no features), skip.
127
+ const block = phaseBlocks.get(phase);
128
+ if (block && block.trim().length > 0) {
129
+ sections.push(block.trimEnd());
130
+ for (const id of anchorToPreserved.get(phase) ?? []) {
131
+ sections.push(preserved.get(id));
132
+ emittedPreservedIds.add(id);
133
+ }
134
+ }
135
+ continue;
136
+ }
137
+
138
+ // Typed phase: render from feature.json with override + anon-row interleave.
139
+ const rollupStatus = phaseStatus(phaseFeatures);
140
+ let headingStatus = rollupStatus;
141
+ if (override) {
142
+ const overrideToken = parseStatusToken(override);
143
+ if (overrideToken && overrideToken !== rollupStatus) {
144
+ emitDrift(cwd, { phaseId: phase, override, computed: rollupStatus });
145
+ }
146
+ // Override always wins. We can't reliably distinguish curated overrides
147
+ // from previously-auto-generated rollups without explicit marking, so
148
+ // we preserve all overrides and let drift detection surface divergence.
149
+ headingStatus = override;
150
+ }
151
+ const phaseAnonRows = anonRows.get(phase) ?? [];
152
+ const sourceBlock = phaseBlocks.get(phase);
153
+ if (sourceBlock) {
154
+ // Splice regenerated table into the source block so curated prose
155
+ // (intro paragraph, exit text, links) survives.
156
+ sections.push(spliceTableIntoBlock(sourceBlock, phase, headingStatus, phaseFeatures, phaseAnonRows));
157
+ } else {
158
+ // Truly new phase (no source counterpart): synthesize from scratch.
159
+ sections.push(renderPhase(phase, headingStatus, phaseFeatures, phaseAnonRows));
160
+ }
161
+
162
+ for (const id of anchorToPreserved.get(phase) ?? []) {
163
+ sections.push(preserved.get(id));
164
+ emittedPreservedIds.add(id);
165
+ }
68
166
  }
69
167
 
70
168
  // Render ungrouped features
71
169
  if (ungrouped.length > 0) {
72
- sections.push(renderPhase('Features', phaseStatus(ungrouped), ungrouped));
170
+ sections.push(renderPhase('Features', phaseStatus(ungrouped), ungrouped, []));
171
+ }
172
+
173
+ // Splice any preserved sections whose anchor phase didn't survive — append at end.
174
+ for (const [id, anchor] of anchors) {
175
+ if (anchor === null) continue;
176
+ if (emittedPreservedIds.has(id)) continue;
177
+ if (!phases.has(anchor)) {
178
+ sections.push(preserved.get(id));
179
+ }
73
180
  }
74
181
 
75
- // Key documents section
76
- const keyDocs = buildKeyDocs(features, featuresDir);
77
- if (keyDocs) sections.push(keyDocs);
182
+ // Key documents auto-gen is suppressed when a `key-documents` preserved-section exists.
183
+ // The preserved section already carries the curated content (including external links).
184
+ if (!preserved.has('key-documents')) {
185
+ const keyDocs = buildKeyDocs(features, featuresDir);
186
+ if (keyDocs) sections.push(keyDocs);
187
+ }
78
188
 
79
189
  return sections.join('\n\n---\n\n') + '\n';
80
190
  }
@@ -83,16 +193,27 @@ export function generateRoadmap(cwd, opts = {}) {
83
193
  * Read the preamble (everything before the first ## Phase/Feature section)
84
194
  * from an existing ROADMAP.md, or generate a default one.
85
195
  */
86
- function readPreamble(cwd, opts) {
87
- const roadmapPath = join(cwd, 'ROADMAP.md');
88
- if (existsSync(roadmapPath)) {
89
- const text = readFileSync(roadmapPath, 'utf-8');
90
- // Find the first ## heading that looks like a phase/feature section
91
- const match = text.match(/^(---\s*\n\s*)?(?=## )/m);
92
- if (match) {
93
- const idx = text.indexOf(match[0]);
94
- const pre = text.slice(0, idx).trimEnd();
95
- if (pre.length > 0) return pre;
196
+ function readPreamble(cwd, opts, existingText) {
197
+ if (existingText && existingText.length > 0) {
198
+ // Find the first `## ` or `<!-- preserved-section: ... -->` marker.
199
+ // Preserved-section markers belong to the preservation flow, not the preamble,
200
+ // so they bound the preamble the same way phase headings do.
201
+ let firstHeadingIdx = -1;
202
+ for (const re of [/^## /m, /^<!--\s*preserved-section:/m]) {
203
+ const m = existingText.match(re);
204
+ if (m) {
205
+ const idx = existingText.indexOf(m[0]);
206
+ if (firstHeadingIdx === -1 || idx < firstHeadingIdx) firstHeadingIdx = idx;
207
+ }
208
+ }
209
+ if (firstHeadingIdx > 0) {
210
+ // Walk back over a possible `---\n\n` separator immediately before the heading
211
+ // so it doesn't get duplicated against the join("\n\n---\n\n") below.
212
+ let cutIdx = firstHeadingIdx;
213
+ const tail = existingText.slice(0, cutIdx).trimEnd();
214
+ // If preamble ends with `---`, strip it.
215
+ const stripped = tail.replace(/\n---\s*$/, '').trimEnd();
216
+ if (stripped.length > 0) return stripped;
96
217
  }
97
218
  }
98
219
 
@@ -119,7 +240,131 @@ ${desc ? desc + '\n\n' : ''}<!-- Generated from feature.json — do not edit man
119
240
  /**
120
241
  * Render a phase section with its feature table.
121
242
  */
122
- function renderPhase(phaseName, status, features) {
243
+ /**
244
+ * Splice a regenerated table into a source phase block so curated prose
245
+ * (intro paragraph, exit text, doc links) survives a no-op regen.
246
+ *
247
+ * Strategy: the source block is `<heading>\n\n<prose>\n\n<table>\n\n<trailing>`.
248
+ * Find the table boundaries (header row + divider through the last contiguous
249
+ * `|...|` row), replace heading line with the override/rollup-aware version,
250
+ * replace the table portion with a freshly rendered one, keep everything else.
251
+ */
252
+ function spliceTableIntoBlock(sourceBlock, phaseName, headingStatus, features, anonRows) {
253
+ const lines = sourceBlock.split('\n');
254
+ const isTableLine = (s) => /^\s*\|.*\|\s*$/.test(s);
255
+
256
+ // Locate the first table row (header) — first `|...|` line.
257
+ let tableStart = -1;
258
+ for (let i = 0; i < lines.length; i++) {
259
+ if (isTableLine(lines[i])) {
260
+ tableStart = i;
261
+ break;
262
+ }
263
+ }
264
+
265
+ // Locate the last contiguous table row — walk forward from tableStart over
266
+ // any `|...|` lines. Allow blank lines inside if the next non-blank is a
267
+ // table line (rare but possible). Simpler: stop at first non-table-non-blank.
268
+ let tableEnd = tableStart;
269
+ if (tableStart !== -1) {
270
+ let i = tableStart;
271
+ while (i < lines.length) {
272
+ if (isTableLine(lines[i])) {
273
+ tableEnd = i;
274
+ i++;
275
+ } else if (lines[i].trim() === '' && i + 1 < lines.length && isTableLine(lines[i + 1])) {
276
+ i++; // skip blank between rows
277
+ } else {
278
+ break;
279
+ }
280
+ }
281
+ }
282
+
283
+ // Build the replacement table (header + divider + rows with anon interleave).
284
+ const newTable = renderTableLines(features, anonRows);
285
+
286
+ // Replace heading line (always lines[0] for a phase block).
287
+ const headingLine = `## ${phaseName} — ${headingStatus}`;
288
+ const out = [headingLine];
289
+
290
+ if (tableStart === -1) {
291
+ // No existing table — append the new one after the existing prose.
292
+ for (let i = 1; i < lines.length; i++) out.push(lines[i]);
293
+ if (out.length > 0 && out[out.length - 1].trim() !== '') out.push('');
294
+ out.push(...newTable);
295
+ return out.join('\n');
296
+ }
297
+
298
+ // Emit prose between heading and table.
299
+ for (let i = 1; i < tableStart; i++) out.push(lines[i]);
300
+
301
+ // Emit the regenerated table.
302
+ out.push(...newTable);
303
+
304
+ // Emit trailing content after the original table.
305
+ for (let i = tableEnd + 1; i < lines.length; i++) out.push(lines[i]);
306
+
307
+ return out.join('\n');
308
+ }
309
+
310
+ /**
311
+ * Render the table portion (header + divider + rows) for a phase. Anon rows
312
+ * interleave by predecessorCode rules.
313
+ */
314
+ function renderTableLines(features, anonRows) {
315
+ const hasSubItems = features.some(f => f.items && f.items.length > 0);
316
+ const lines = [];
317
+
318
+ const anonByPredecessor = new Map();
319
+ for (const row of anonRows) {
320
+ const arr = anonByPredecessor.get(row.predecessorCode) ?? [];
321
+ arr.push(row.rawLine);
322
+ anonByPredecessor.set(row.predecessorCode, arr);
323
+ }
324
+ const emitAnonAfter = (code) => {
325
+ for (const raw of anonByPredecessor.get(code) ?? []) lines.push(raw);
326
+ anonByPredecessor.delete(code);
327
+ };
328
+
329
+ if (hasSubItems) {
330
+ lines.push('| # | Feature | Item | Status |');
331
+ lines.push('|---|---------|------|--------|');
332
+ emitAnonAfter(null);
333
+ for (const f of features) {
334
+ if (f.items && f.items.length > 0) {
335
+ for (const item of f.items) {
336
+ const num = item.position ?? '—';
337
+ const desc = item.description ?? '';
338
+ const st = item.status ?? f.status;
339
+ lines.push(`| ${num} | ${f.code} | ${desc} | ${st} |`);
340
+ }
341
+ } else {
342
+ const num = f.position ?? '—';
343
+ lines.push(`| ${num} | ${f.code} | ${f.description} | ${f.status} |`);
344
+ }
345
+ emitAnonAfter(f.code);
346
+ }
347
+ } else {
348
+ lines.push('| # | Feature | Description | Status |');
349
+ lines.push('|---|---------|-------------|--------|');
350
+ emitAnonAfter(null);
351
+ for (const f of features) {
352
+ const num = f.position ?? '—';
353
+ const desc = f.description ?? '';
354
+ lines.push(`| ${num} | ${f.code} | ${desc} | ${f.status} |`);
355
+ emitAnonAfter(f.code);
356
+ }
357
+ }
358
+
359
+ // Leftovers (predecessor deleted) — append at end of table.
360
+ for (const [, leftover] of anonByPredecessor) {
361
+ for (const raw of leftover) lines.push(raw);
362
+ }
363
+
364
+ return lines;
365
+ }
366
+
367
+ function renderPhase(phaseName, status, features, anonRows = []) {
123
368
  const lines = [`## ${phaseName} — ${status}`, ''];
124
369
 
125
370
  // Phase description from the first feature's phaseDescription if available
@@ -131,10 +376,24 @@ function renderPhase(phaseName, status, features) {
131
376
  // Determine table columns based on whether features have sub-items
132
377
  const hasSubItems = features.some(f => f.items && f.items.length > 0);
133
378
 
379
+ // Index anon rows by predecessorCode for interleave.
380
+ const anonByPredecessor = new Map();
381
+ for (const row of anonRows) {
382
+ const key = row.predecessorCode; // null = head-of-table
383
+ const arr = anonByPredecessor.get(key) ?? [];
384
+ arr.push(row.rawLine);
385
+ anonByPredecessor.set(key, arr);
386
+ }
387
+ const emitAnonAfter = (code) => {
388
+ for (const raw of anonByPredecessor.get(code) ?? []) lines.push(raw);
389
+ anonByPredecessor.delete(code);
390
+ };
391
+
134
392
  if (hasSubItems) {
135
393
  // Expanded: one row per sub-item
136
394
  lines.push('| # | Feature | Item | Status |');
137
395
  lines.push('|---|---------|------|--------|');
396
+ emitAnonAfter(null); // any head-of-table anon rows
138
397
  for (const f of features) {
139
398
  if (f.items && f.items.length > 0) {
140
399
  for (const item of f.items) {
@@ -147,18 +406,29 @@ function renderPhase(phaseName, status, features) {
147
406
  const num = f.position ?? '—';
148
407
  lines.push(`| ${num} | ${f.code} | ${f.description} | ${f.status} |`);
149
408
  }
409
+ emitAnonAfter(f.code);
150
410
  }
151
411
  } else {
152
412
  // Simple: one row per feature
153
413
  lines.push('| # | Feature | Description | Status |');
154
414
  lines.push('|---|---------|-------------|--------|');
415
+ // COMP-MCP-MIGRATION-2-1: no truncation. Curated descriptions are
416
+ // often multi-sentence; truncating them at 80 chars makes regen lossy.
417
+ // Markdown tables tolerate long cells fine.
418
+ emitAnonAfter(null); // any head-of-table anon rows
155
419
  for (const f of features) {
156
420
  const num = f.position ?? '—';
157
- const desc = f.description.length > 80 ? f.description.slice(0, 77) + '...' : f.description;
421
+ const desc = f.description ?? '';
158
422
  lines.push(`| ${num} | ${f.code} | ${desc} | ${f.status} |`);
423
+ emitAnonAfter(f.code);
159
424
  }
160
425
  }
161
426
 
427
+ // Any anon rows whose predecessor was deleted: append at the end of the table.
428
+ for (const [, leftover] of anonByPredecessor) {
429
+ for (const raw of leftover) lines.push(raw);
430
+ }
431
+
162
432
  // Exit criteria
163
433
  const exit = features[0]?.phaseExit;
164
434
  if (exit) {