@smartmemory/compose 0.1.44-beta → 0.2.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 (76) hide show
  1. package/bin/compose.js +71 -35
  2. package/dist/assets/App-VU2lfA8m.js +770 -0
  3. package/dist/assets/{arc-N74_SuiS.js → arc-CIeqpX37.js} +1 -1
  4. package/dist/assets/{architectureDiagram-3BPJPVTR-DHOb5xas.js → architectureDiagram-3BPJPVTR-itmOSZLE.js} +1 -1
  5. package/dist/assets/{blockDiagram-GPEHLZMM-DR9b_xXC.js → blockDiagram-GPEHLZMM-N7MotI_5.js} +8 -8
  6. package/dist/assets/{c4Diagram-AAUBKEIU-EXIx4J1v.js → c4Diagram-AAUBKEIU-DRKW39LH.js} +1 -1
  7. package/dist/assets/channel-DugSMLKi.js +1 -0
  8. package/dist/assets/{chunk-2J33WTMH-CyZqa6ub.js → chunk-2J33WTMH-CF6iSwEb.js} +1 -1
  9. package/dist/assets/{chunk-4BX2VUAB-SjPmvNaj.js → chunk-4BX2VUAB-BTe-QE0R.js} +1 -1
  10. package/dist/assets/{chunk-55IACEB6-Cnu3mdms.js → chunk-55IACEB6-E2hHEsl9.js} +1 -1
  11. package/dist/assets/{chunk-727SXJPM-DNj5i6fj.js → chunk-727SXJPM-CBRmkSvh.js} +1 -1
  12. package/dist/assets/{chunk-AQP2D5EJ-BIVskOlI.js → chunk-AQP2D5EJ-BdtQ63fN.js} +1 -1
  13. package/dist/assets/{chunk-FMBD7UC4-BWlLU-hh.js → chunk-FMBD7UC4-DfYQ2YmB.js} +1 -1
  14. package/dist/assets/{chunk-ND2GUHAM-DQEknadH.js → chunk-ND2GUHAM-CDrOVOW5.js} +1 -1
  15. package/dist/assets/{chunk-QZHKN3VN-CUYnhnAB.js → chunk-QZHKN3VN-DwjqJ9xB.js} +1 -1
  16. package/dist/assets/classDiagram-4FO5ZUOK-D2RRwp7J.js +1 -0
  17. package/dist/assets/classDiagram-v2-Q7XG4LA2-D2RRwp7J.js +1 -0
  18. package/dist/assets/{cose-bilkent-S5V4N54A-X3n23b12.js → cose-bilkent-S5V4N54A-MHpsrtBZ.js} +1 -1
  19. package/dist/assets/{dagre-BM42HDAG-C0SrhQ_X.js → dagre-BM42HDAG-DaPz_mPt.js} +1 -1
  20. package/dist/assets/{diagram-2AECGRRQ-Bc3qx6pJ.js → diagram-2AECGRRQ-DIdstuOm.js} +1 -1
  21. package/dist/assets/{diagram-5GNKFQAL-UiCrD06F.js → diagram-5GNKFQAL-DbkTGVES.js} +1 -1
  22. package/dist/assets/{diagram-KO2AKTUF-B9Vn5KyO.js → diagram-KO2AKTUF-BPalYJed.js} +1 -1
  23. package/dist/assets/{diagram-LMA3HP47-DLOYeLM3.js → diagram-LMA3HP47-vnySSoyd.js} +1 -1
  24. package/dist/assets/{diagram-OG6HWLK6-CXjh2miZ.js → diagram-OG6HWLK6-Dv3BUJft.js} +1 -1
  25. package/dist/assets/{erDiagram-TEJ5UH35-EmDzXNsM.js → erDiagram-TEJ5UH35-B3OLgtKK.js} +1 -1
  26. package/dist/assets/{flowDiagram-I6XJVG4X-vk6E_ebo.js → flowDiagram-I6XJVG4X-DdpxVf-5.js} +1 -1
  27. package/dist/assets/{ganttDiagram-6RSMTGT7-DYYSAjNx.js → ganttDiagram-6RSMTGT7-QALT_Lj9.js} +4 -4
  28. package/dist/assets/{gitGraphDiagram-PVQCEYII-CWPZVbhV.js → gitGraphDiagram-PVQCEYII-nITcPPED.js} +1 -1
  29. package/dist/assets/{graph-uO5hwVZK.js → graph-DnLKqSPg.js} +2 -2
  30. package/dist/assets/{index-BYYTTzUT.js → index-CLb8RFcn.js} +3 -3
  31. package/dist/assets/index-jqUffYBL.css +1 -0
  32. package/dist/assets/{infoDiagram-5YYISTIA-Dsu-eeJm.js → infoDiagram-5YYISTIA-CjlRce3x.js} +1 -1
  33. package/dist/assets/{ishikawaDiagram-YF4QCWOH-BP1SP8WA.js → ishikawaDiagram-YF4QCWOH-OyKVgxOz.js} +1 -1
  34. package/dist/assets/{journeyDiagram-JHISSGLW-DkE5By_R.js → journeyDiagram-JHISSGLW-3FaFyfLR.js} +1 -1
  35. package/dist/assets/{kanban-definition-UN3LZRKU-Cf_230xs.js → kanban-definition-UN3LZRKU-DUPnRo3q.js} +1 -1
  36. package/dist/assets/{linear-B-paxRBQ.js → linear-BeL8i3rv.js} +1 -1
  37. package/dist/assets/{mindmap-definition-RKZ34NQL-DAp6uJ_b.js → mindmap-definition-RKZ34NQL-C0CwWNdR.js} +1 -1
  38. package/dist/assets/mobile-qvdJ5p0m.js +17 -0
  39. package/dist/assets/{pieDiagram-4H26LBE5-CbYY5KL0.js → pieDiagram-4H26LBE5-DaU2jPjX.js} +1 -1
  40. package/dist/assets/{quadrantDiagram-W4KKPZXB-D5S4_ac5.js → quadrantDiagram-W4KKPZXB-HFtjZSAT.js} +1 -1
  41. package/dist/assets/{requirementDiagram-4Y6WPE33-BrPWCnHz.js → requirementDiagram-4Y6WPE33-CX_Mz3gv.js} +1 -1
  42. package/dist/assets/{sankeyDiagram-5OEKKPKP-CP8j1mcl.js → sankeyDiagram-5OEKKPKP-BR2_eTy9.js} +1 -1
  43. package/dist/assets/{sequenceDiagram-3UESZ5HK-c8DuhvUj.js → sequenceDiagram-3UESZ5HK-CtHp0Qnp.js} +1 -1
  44. package/dist/assets/{stateDiagram-AJRCARHV-KO9G1Jrm.js → stateDiagram-AJRCARHV-DmiEmD6G.js} +1 -1
  45. package/dist/assets/stateDiagram-v2-BHNVJYJU-7rdO1Tgp.js +1 -0
  46. package/dist/assets/{timeline-definition-PNZ67QCA-Cs2HLlbG.js → timeline-definition-PNZ67QCA-GSHqrJ3A.js} +1 -1
  47. package/dist/assets/{vennDiagram-CIIHVFJN-rcSRidqI.js → vennDiagram-CIIHVFJN-CNxhQnCU.js} +1 -1
  48. package/dist/assets/{wardley-L42UT6IY-BsajGfii.js → wardley-L42UT6IY-Bf-gQIFY.js} +1 -1
  49. package/dist/assets/{wardleyDiagram-YWT4CUSO-CSWALc_m.js → wardleyDiagram-YWT4CUSO-RGxoapr7.js} +1 -1
  50. package/dist/assets/{xychartDiagram-2RQKCTM6-jC4Q0GvG.js → xychartDiagram-2RQKCTM6-1_H1qVde.js} +1 -1
  51. package/dist/index.html +3 -3
  52. package/lib/build.js +3 -2
  53. package/lib/feature-code.js +14 -4
  54. package/lib/feature-json.js +33 -2
  55. package/lib/feature-validator.js +135 -11
  56. package/lib/feature-writer.js +83 -3
  57. package/lib/migrate-roadmap.js +16 -2
  58. package/lib/project-paths.js +16 -0
  59. package/lib/roadmap-config.js +50 -0
  60. package/lib/roadmap-gen.js +46 -31
  61. package/lib/roadmap-heading.js +85 -0
  62. package/lib/roadmap-parser.js +69 -18
  63. package/lib/roadmap-preservers.js +60 -19
  64. package/lib/roadmap-roundtrip.js +137 -0
  65. package/lib/vision-writer.js +42 -14
  66. package/lib/xref-sync.js +160 -0
  67. package/package.json +1 -1
  68. package/server/compose-mcp.js +2 -1
  69. package/server/vision-store.js +1 -1
  70. package/dist/assets/App-CdP799CF.js +0 -768
  71. package/dist/assets/channel-yPY0IE15.js +0 -1
  72. package/dist/assets/classDiagram-4FO5ZUOK-SGKYXTP4.js +0 -1
  73. package/dist/assets/classDiagram-v2-Q7XG4LA2-SGKYXTP4.js +0 -1
  74. package/dist/assets/index-Dh2rRpBR.css +0 -1
  75. package/dist/assets/mobile-BwduHUEq.js +0 -17
  76. package/dist/assets/stateDiagram-v2-BHNVJYJU-eVyb8_R4.js +0 -1
@@ -6,7 +6,8 @@
6
6
 
7
7
  import { readFileSync, writeFileSync, existsSync } from 'fs';
8
8
  import { join } from 'path';
9
- import { listFeatures } from './feature-json.js';
9
+ import { listFeatures, positionSortKey } from './feature-json.js';
10
+ import { parseStatusToken } from './roadmap-parser.js';
10
11
  import { loadFeaturesDir } from './project-paths.js';
11
12
  import {
12
13
  readPhaseOverrides,
@@ -17,22 +18,15 @@ import {
17
18
  readPhaseBlocks,
18
19
  } from './roadmap-preservers.js';
19
20
  import { emitDrift } from './roadmap-drift.js';
21
+ import { isNarrativeOwned, narrativeOwnedMessage } from './roadmap-config.js';
20
22
 
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
+ // Escape literal pipes in free-text table cells so they don't break markdown
24
+ // column splitting. Symmetric with the unescape in roadmap-parser.js /
25
+ // roadmap-preservers.js (split on unescaped `|`, then restore `\|` → `|`).
26
+ // Only applied to description/item cells — #/code/status never contain pipes.
27
+ const escCell = (s) => String(s ?? '').replace(/\|/g, '\\|');
23
28
 
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
- }
29
+ const STATUS_ORDER = ['IN_PROGRESS', 'PARTIAL', 'PLANNED', 'COMPLETE', 'SUPERSEDED', 'PARKED'];
36
30
 
37
31
  /**
38
32
  * Compute the aggregate status for a phase based on its features.
@@ -56,6 +50,8 @@ function phaseStatus(features) {
56
50
  * @param {string} [opts.projectDescription] - Project description for default preamble
57
51
  * @param {string} [opts.cwd] - Used only for drift emission (optional; defaults to '')
58
52
  * @param {string} [opts.featuresDir] - Passed through to buildKeyDocs (optional)
53
+ * @param {string} [opts.now] - ISO date (YYYY-MM-DD) for the 'Last updated' line; defaults to today. Inject for deterministic output.
54
+ * @param {boolean} [opts.suppressDrift] - When true, skip emitDrift side effects (used by the pure roundtrip checker).
59
55
  * @returns {string} - Merged ROADMAP.md content
60
56
  */
61
57
  export function generateRoadmapFromBase(baseText, features, opts = {}) {
@@ -115,9 +111,12 @@ export function generateRoadmapFromBase(baseText, features, opts = {}) {
115
111
  const orderedPhaseIds = [...new Set(sourcePhaseOrder)];
116
112
  const seenInSource = new Set(sourcePhaseOrder);
117
113
  const newPhases = [...phases.keys()].filter(p => !seenInSource.has(p));
114
+ // Range-tolerant numeric key (same as listFeatures' sort) so a new phase whose
115
+ // features carry ranged-string positions ("92–95") still orders numerically
116
+ // instead of collapsing to a NaN comparator.
118
117
  newPhases.sort((a, b) => {
119
- const minA = Math.min(...phases.get(a).map(f => f.position ?? 999));
120
- const minB = Math.min(...phases.get(b).map(f => f.position ?? 999));
118
+ const minA = Math.min(...phases.get(a).map(f => positionSortKey(f.position)));
119
+ const minB = Math.min(...phases.get(b).map(f => positionSortKey(f.position)));
121
120
  return minA - minB;
122
121
  });
123
122
  orderedPhaseIds.push(...newPhases);
@@ -151,7 +150,7 @@ export function generateRoadmapFromBase(baseText, features, opts = {}) {
151
150
  if (override) {
152
151
  const overrideToken = parseStatusToken(override);
153
152
  if (overrideToken && overrideToken !== rollupStatus) {
154
- if (cwd) emitDrift(cwd, { phaseId: phase, override, computed: rollupStatus });
153
+ if (cwd && !opts.suppressDrift) emitDrift(cwd, { phaseId: phase, override, computed: rollupStatus });
155
154
  }
156
155
  // Override always wins. We can't reliably distinguish curated overrides
157
156
  // from previously-auto-generated rollups without explicit marking, so
@@ -207,17 +206,25 @@ export function generateRoadmapFromBase(baseText, features, opts = {}) {
207
206
  * @param {string} [opts.featuresDir] - Relative path to features dir
208
207
  * @param {string} [opts.projectName] - Project name for header
209
208
  * @param {string} [opts.projectDescription] - Project description for header
209
+ * @param {string} [opts.now] - ISO date (YYYY-MM-DD) for the 'Last updated' line; defaults to today. Inject for deterministic output.
210
210
  * @returns {string} - Generated ROADMAP.md content
211
211
  */
212
212
  export function generateRoadmap(cwd, opts = {}) {
213
- const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
214
- const features = listFeatures(cwd, featuresDir);
215
-
216
- // Read existing ROADMAP.md once: preamble + curated content for splice-back.
217
213
  const roadmapPath = join(cwd, 'ROADMAP.md');
218
214
  const existingText = existsSync(roadmapPath) ? readFileSync(roadmapPath, 'utf-8') : '';
219
215
 
220
- return generateRoadmapFromBase(existingText, features, { ...opts, cwd, featuresDir });
216
+ // Narrative-owned workspace: ROADMAP.md is hand-authored. Return it verbatim
217
+ // rather than regenerating from feature.json (#39).
218
+ if (isNarrativeOwned(cwd)) {
219
+ console.warn(narrativeOwnedMessage(cwd));
220
+ return existingText;
221
+ }
222
+
223
+ const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
224
+ const features = listFeatures(cwd, featuresDir);
225
+
226
+ const now = opts.now ?? new Date().toISOString().slice(0, 10);
227
+ return generateRoadmapFromBase(existingText, features, { ...opts, cwd, featuresDir, now });
221
228
  }
222
229
 
223
230
  /**
@@ -256,7 +263,7 @@ function readPreamble(cwd, opts, existingText) {
256
263
  // Default preamble
257
264
  const name = opts.projectName ?? 'Project';
258
265
  const desc = opts.projectDescription ?? '';
259
- const today = new Date().toISOString().slice(0, 10);
266
+ const today = opts.now ?? new Date().toISOString().slice(0, 10);
260
267
  return `# ${name} Roadmap
261
268
 
262
269
  ${desc ? desc + '\n\n' : ''}<!-- Generated from feature.json — do not edit manually -->
@@ -370,13 +377,13 @@ function renderTableLines(features, anonRows) {
370
377
  if (f.items && f.items.length > 0) {
371
378
  for (const item of f.items) {
372
379
  const num = item.position ?? '—';
373
- const desc = item.description ?? '';
380
+ const desc = escCell(item.description ?? '');
374
381
  const st = item.status ?? f.status;
375
382
  lines.push(`| ${num} | ${f.code} | ${desc} | ${st} |`);
376
383
  }
377
384
  } else {
378
385
  const num = f.position ?? '—';
379
- lines.push(`| ${num} | ${f.code} | ${f.description} | ${f.status} |`);
386
+ lines.push(`| ${num} | ${f.code} | ${escCell(f.description)} | ${f.status} |`);
380
387
  }
381
388
  emitAnonAfter(f.code);
382
389
  }
@@ -386,7 +393,7 @@ function renderTableLines(features, anonRows) {
386
393
  emitAnonAfter(null);
387
394
  for (const f of features) {
388
395
  const num = f.position ?? '—';
389
- const desc = f.description ?? '';
396
+ const desc = escCell(f.description ?? '');
390
397
  lines.push(`| ${num} | ${f.code} | ${desc} | ${f.status} |`);
391
398
  emitAnonAfter(f.code);
392
399
  }
@@ -434,13 +441,13 @@ function renderPhase(phaseName, status, features, anonRows = []) {
434
441
  if (f.items && f.items.length > 0) {
435
442
  for (const item of f.items) {
436
443
  const num = item.position ?? '—';
437
- const desc = item.description ?? '';
444
+ const desc = escCell(item.description ?? '');
438
445
  const st = item.status ?? f.status;
439
446
  lines.push(`| ${num} | ${f.code} | ${desc} | ${st} |`);
440
447
  }
441
448
  } else {
442
449
  const num = f.position ?? '—';
443
- lines.push(`| ${num} | ${f.code} | ${f.description} | ${f.status} |`);
450
+ lines.push(`| ${num} | ${f.code} | ${escCell(f.description)} | ${f.status} |`);
444
451
  }
445
452
  emitAnonAfter(f.code);
446
453
  }
@@ -454,7 +461,7 @@ function renderPhase(phaseName, status, features, anonRows = []) {
454
461
  emitAnonAfter(null); // any head-of-table anon rows
455
462
  for (const f of features) {
456
463
  const num = f.position ?? '—';
457
- const desc = f.description ?? '';
464
+ const desc = escCell(f.description ?? '');
458
465
  lines.push(`| ${num} | ${f.code} | ${desc} | ${f.status} |`);
459
466
  emitAnonAfter(f.code);
460
467
  }
@@ -509,8 +516,16 @@ function buildKeyDocs(features, featuresDir) {
509
516
  * @param {object} [opts]
510
517
  */
511
518
  export function writeRoadmap(cwd, opts = {}) {
512
- const content = generateRoadmap(cwd, opts);
513
519
  const roadmapPath = join(cwd, 'ROADMAP.md');
520
+
521
+ // Narrative-owned workspace: never overwrite hand-authored ROADMAP.md (#39).
522
+ // No-op + warn; the existing file on disk is the source of truth.
523
+ if (isNarrativeOwned(cwd)) {
524
+ console.warn(narrativeOwnedMessage(cwd));
525
+ return roadmapPath;
526
+ }
527
+
528
+ const content = generateRoadmap(cwd, opts);
514
529
  writeFileSync(roadmapPath, content);
515
530
  return roadmapPath;
516
531
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * roadmap-heading.js — Shared phase-heading parsing for ROADMAP.md.
3
+ *
4
+ * A phase heading is `## <title> — <status>`, where <title> may itself contain
5
+ * em-dashes (e.g. `## Wave 6 — Situational Awareness — COMPLETE`). Splitting on
6
+ * the FIRST ` — ` truncated such titles to their leading fragment and mis-read
7
+ * the status — see issue #38. The status is instead the trailing segment that
8
+ * begins, at an em-dash boundary, with a recognized status token.
9
+ *
10
+ * Single source of truth for the parser (roadmap-parser.js) and the typed-writer
11
+ * preservers (roadmap-preservers.js), so they never disagree on a phaseId.
12
+ */
13
+
14
+ // Canonical status enum. None is a prefix of another, so leading-token matching
15
+ // is unambiguous regardless of order.
16
+ export const STATUS_TOKENS = ['COMPLETE', 'IN_PROGRESS', 'PARTIAL', 'PLANNED', 'SUPERSEDED', 'PARKED', 'BLOCKED', 'KILLED'];
17
+
18
+ /**
19
+ * Extract the leading status token from cell/override text. The token must be
20
+ * the whole string OR be followed by whitespace or `(` — the only separators a
21
+ * real status cell uses before commentary: `PARKED — needs X` → `PARKED`,
22
+ * `PARTIAL (1a COMPLETE)` → `PARTIAL`, `COMPLETE` → `COMPLETE`. Deliberately
23
+ * conservative: glued forms like `PLANNED-ish` or `PARKED/blocked` return null
24
+ * (left for the validator to flag) rather than being coerced to a valid enum.
25
+ * Case-insensitive; returns the canonical UPPERCASE token, or null if none.
26
+ *
27
+ * @param {string} text
28
+ * @returns {string|null}
29
+ */
30
+ export function parseStatusToken(text) {
31
+ const up = String(text ?? '').trim().toUpperCase();
32
+ for (const t of STATUS_TOKENS) {
33
+ if (up === t) return t;
34
+ if (up.startsWith(t) && /[\s(]/.test(up[t.length])) return t;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ // Matches a level-2 heading line and captures its content (everything after
40
+ // `## `). Excludes `###` milestones: the required `\s+` after `##` fails on a
41
+ // third `#`. Requires at least one content char, so a bare `## ` is not a phase.
42
+ export const PHASE_HEADING_TEXT_RE = /^##\s+(.+)$/;
43
+
44
+ /**
45
+ * Split phase-heading content into { title, status }.
46
+ *
47
+ * The status is the run after the RIGHTMOST ` — ` boundary whose tail begins
48
+ * with a recognized status token; the title is everything before it. Choosing
49
+ * the rightmost (not the first) qualifying boundary is what lets a title
50
+ * fragment that itself starts with a status word stay in the title
51
+ * (`Phase 9 — BLOCKED API Cleanup — COMPLETE` → title `Phase 9 — BLOCKED API
52
+ * Cleanup`, status `COMPLETE`) while still keeping status commentary that
53
+ * contains an em-dash attached to the status (`A — PARKED — needs X` → status
54
+ * `PARKED — needs X`, since `needs X` is not a status token so the PARKED
55
+ * boundary stays the rightmost qualifying one). If no segment after a ` — `
56
+ * begins with a status token, the whole string is the title.
57
+ *
58
+ * Limitation (inherent ambiguity): a segment of the form `TOKEN words` is
59
+ * structurally identical whether it is a title fragment (`BLOCKED API Cleanup`)
60
+ * or a status with trailing commentary (`SUPERSEDED by STRAT-1`). The rightmost
61
+ * rule treats the LAST such segment as the status, so the rare heading whose
62
+ * status commentary itself begins with another bare status token
63
+ * (`X — PARKED — BLOCKED by upstream`) mis-splits. No real roadmap heading uses
64
+ * that form (write `PARKED (blocked by upstream)` instead); the rightmost rule
65
+ * is correct for every heading in the corpus and the common commentary forms.
66
+ *
67
+ * 'Wave 6 — Situational Awareness — COMPLETE' → { title: 'Wave 6 — Situational Awareness', status: 'COMPLETE' }
68
+ * 'Phase 9 — BLOCKED API Cleanup — COMPLETE' → { title: 'Phase 9 — BLOCKED API Cleanup', status: 'COMPLETE' }
69
+ * 'A — PARKED — needs X' → { title: 'A', status: 'PARKED — needs X' }
70
+ * 'Phase 0: Bootstrap — COMPLETE' → { title: 'Phase 0: Bootstrap', status: 'COMPLETE' }
71
+ * 'Wave 6 — Situational Awareness' → { title: 'Wave 6 — Situational Awareness', status: '' }
72
+ *
73
+ * @param {string} text heading content (without the leading `## `)
74
+ * @returns {{ title: string, status: string }}
75
+ */
76
+ export function splitPhaseHeading(text) {
77
+ const s = String(text ?? '').trim();
78
+ let best = null;
79
+ for (const m of s.matchAll(/\s+—\s+/g)) {
80
+ const tail = s.slice(m.index + m[0].length);
81
+ if (parseStatusToken(tail)) best = { index: m.index, tail };
82
+ }
83
+ if (best) return { title: s.slice(0, best.index).trim(), status: best.tail.trim() };
84
+ return { title: s, status: '' };
85
+ }
@@ -5,14 +5,21 @@
5
5
  * from the markdown table format used by Compose roadmaps.
6
6
  */
7
7
 
8
+ import { isFeatureCode } from './feature-code.js';
9
+ import { PRESERVED_OPEN_RE, PRESERVED_CLOSE_RE } from './roadmap-preservers.js';
10
+ import { parseStatusToken, splitPhaseHeading, PHASE_HEADING_TEXT_RE } from './roadmap-heading.js';
11
+
12
+ // Re-exported for backward compatibility — these now live in roadmap-heading.js,
13
+ // the shared source of truth for heading/status parsing (issue #38).
14
+ export { parseStatusToken, STATUS_TOKENS } from './roadmap-heading.js';
15
+
8
16
  // Statuses that exclude a feature from the buildable list. KILLED is
9
17
  // terminal; BLOCKED isn't buildable until unblocked.
10
18
  const SKIP_STATUSES = new Set(['COMPLETE', 'SUPERSEDED', 'PARKED', 'KILLED', 'BLOCKED']);
11
19
 
12
- const PHASE_HEADING_RE = /^##\s+(.+?)(?:\s+—\s+(.+))?$/;
13
20
  const MILESTONE_HEADING_RE = /^###\s+(.+?)(?:\s*:\s*(.+))?$/;
14
21
  const TABLE_ROW_RE = /^\|(.+)\|$/;
15
- const FEATURE_CODE_RE = /^[A-Z][\w-]*-\d+/;
22
+ const FENCE_RE = /^```/;
16
23
 
17
24
  /**
18
25
  * @typedef {{ code: string, description: string, status: string, phaseId: string, position: number }} FeatureEntry
@@ -30,19 +37,52 @@ export function parseRoadmap(text) {
30
37
  const lines = text.split('\n');
31
38
  const entries = [];
32
39
  let currentPhaseId = '';
40
+ let currentParentPhaseId = '';
33
41
  let currentPhaseStatus = '';
34
42
  let position = 0;
35
43
  let inTable = false;
44
+ let inFence = false;
45
+ let inPreserved = false;
36
46
  let columnLayout = null; // { codeCol, descCol, statusCol }
37
47
 
38
48
  for (const line of lines) {
49
+ // Fence + preserved-section detection run on the RAW line (never the
50
+ // trimmed one) so the parser agrees with the preservers — which use raw
51
+ // lines — on what counts as a marker. Markers/fences are column-0 by
52
+ // convention; an indented `<!-- preserved-section -->` is NOT a marker.
53
+ if (FENCE_RE.test(line)) {
54
+ inFence = !inFence;
55
+ inTable = false;
56
+ columnLayout = null;
57
+ continue;
58
+ }
59
+ if (inFence) continue;
60
+
61
+ // Content inside a preserved-section is curated narrative emitted verbatim
62
+ // by the writer (readPreservedSections). It must not be parsed as feature
63
+ // rows — otherwise migrate mints phantom features from planning tables with
64
+ // non-standard schemas, and the roundtrip never reaches a fixed point.
65
+ if (PRESERVED_OPEN_RE.test(line)) {
66
+ inPreserved = true;
67
+ inTable = false;
68
+ columnLayout = null;
69
+ continue;
70
+ }
71
+ if (PRESERVED_CLOSE_RE.test(line)) {
72
+ inPreserved = false;
73
+ continue;
74
+ }
75
+ if (inPreserved) continue;
76
+
39
77
  const trimmed = line.trim();
40
78
 
41
- // Phase heading: ## Phase 0: Bootstrap — COMPLETE
42
- const phaseMatch = trimmed.match(PHASE_HEADING_RE);
79
+ // Phase heading: ## Phase 0: Bootstrap — COMPLETE (title may contain em-dashes)
80
+ const phaseMatch = trimmed.match(PHASE_HEADING_TEXT_RE);
43
81
  if (phaseMatch) {
44
- currentPhaseId = phaseMatch[1].trim();
45
- currentPhaseStatus = phaseMatch[2]?.trim() ?? '';
82
+ const { title, status } = splitPhaseHeading(phaseMatch[1]);
83
+ currentPhaseId = title;
84
+ currentParentPhaseId = title;
85
+ currentPhaseStatus = status;
46
86
  inTable = false;
47
87
  columnLayout = null;
48
88
  continue;
@@ -51,13 +91,13 @@ export function parseRoadmap(text) {
51
91
  // Milestone heading: ### Milestone 1: Stratum Engine Complete
52
92
  const milestoneMatch = trimmed.match(MILESTONE_HEADING_RE);
53
93
  if (milestoneMatch) {
54
- // Nest under parent phase
94
+ // Nest under the PARENT phase, resetting on each milestone. Without
95
+ // resetting off the parent, consecutive ### headings would accumulate
96
+ // ("Phase > M1 > M2") instead of yielding sibling milestones.
55
97
  const milestoneLabel = milestoneMatch[1].trim();
56
- if (currentPhaseId) {
57
- currentPhaseId = `${currentPhaseId} > ${milestoneLabel}`;
58
- } else {
59
- currentPhaseId = milestoneLabel;
60
- }
98
+ currentPhaseId = currentParentPhaseId
99
+ ? `${currentParentPhaseId} > ${milestoneLabel}`
100
+ : milestoneLabel;
61
101
  inTable = false;
62
102
  columnLayout = null;
63
103
  continue;
@@ -72,7 +112,10 @@ export function parseRoadmap(text) {
72
112
  continue;
73
113
  }
74
114
 
75
- const cells = trimmed.split('|').slice(1, -1).map(c => c.trim());
115
+ // Split on UNESCAPED pipes only, then unescape `\|` → `|` in each cell.
116
+ // Symmetric with escCell() in roadmap-gen.js. For pipe-free rows this is
117
+ // identical to split('|') (no backslashes, no lookbehind matches).
118
+ const cells = trimmed.split(/(?<!\\)\|/).slice(1, -1).map(c => c.trim().replace(/\\\|/g, '|'));
76
119
 
77
120
  // Skip separator rows (|---|---|---|)
78
121
  if (cells.every(c => /^[-:]+$/.test(c))) {
@@ -93,15 +136,23 @@ export function parseRoadmap(text) {
93
136
  const desc = cells[columnLayout.descCol] ?? '';
94
137
  let status = cells[columnLayout.statusCol] ?? '';
95
138
 
96
- // Clean up status (strip bold markers, etc.)
139
+ // Clean up status (strip bold markers, etc.) then reduce to a bare enum
140
+ // token so a cell like "PARKED — needs Claude Code adoption" yields PARKED
141
+ // (the inline rationale would otherwise produce a schema-invalid status).
142
+ // Cells with no recognized token are left as-is (the validator flags them).
97
143
  status = status.replace(/\*\*/g, '').trim();
98
-
99
- // If the entire phase is COMPLETE, override individual statuses
100
- if (SKIP_STATUSES.has(currentPhaseStatus)) {
144
+ const statusToken = parseStatusToken(status);
145
+ if (statusToken) status = statusToken;
146
+
147
+ // If the entire phase is a SKIP_STATUS, only fill in rows that have NO
148
+ // explicit status. An explicit per-row status (e.g. a PLANNED item under a
149
+ // rolled-up COMPLETE phase) must win over the phase-level override.
150
+ const explicitStatus = status;
151
+ if (SKIP_STATUSES.has(currentPhaseStatus) && !explicitStatus) {
101
152
  status = currentPhaseStatus;
102
153
  }
103
154
 
104
- const isAnonymous = code === '—' || code === '-' || !FEATURE_CODE_RE.test(code);
155
+ const isAnonymous = code === '—' || code === '-' || !isFeatureCode(code);
105
156
 
106
157
  entries.push({
107
158
  code: isAnonymous ? `_anon_${position}` : code,
@@ -12,15 +12,18 @@
12
12
  * - non-feature sections wrapped in `<!-- preserved-section: <id> -->`
13
13
  */
14
14
 
15
- const PHASE_HEADING_RE = /^##\s+(.+?)\s+—\s+(.+?)\s*$/;
15
+ import { FEATURE_CODE_RE_STRICT } from './feature-code.js';
16
+ import { splitPhaseHeading, PHASE_HEADING_TEXT_RE } from './roadmap-heading.js';
17
+
16
18
  const FENCE_RE = /^```/;
17
19
  const TABLE_HEADER_RE = /^\|.*\|$/;
18
20
  const TABLE_DIVIDER_RE = /^\|[\s|:-]+\|$/;
19
21
  const TABLE_ROW_RE = /^\|.+\|$/;
20
- const FEATURE_CODE_RE = /^[A-Z][A-Z0-9-]*[A-Z0-9]$/;
21
22
 
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*$/;
23
+ // Exported so roadmap-parser.js can apply the SAME preserved-section skipping
24
+ // (single source of truth — parser and preservers must agree on what's preserved).
25
+ export const PRESERVED_OPEN_RE = /^<!--\s*preserved-section:\s*([a-z][a-z0-9-]*)\s*-->\s*$/;
26
+ export const PRESERVED_CLOSE_RE = /^<!--\s*\/preserved-section\s*-->\s*$/;
24
27
 
25
28
  /**
26
29
  * Scan ROADMAP.md text and return a Map of phaseId → override text.
@@ -35,14 +38,26 @@ const PRESERVED_CLOSE_RE = /^<!--\s*\/preserved-section\s*-->\s*$/;
35
38
  export function readPhaseOverrides(text) {
36
39
  const out = new Map();
37
40
  let inFence = false;
41
+ let inPreserved = false;
38
42
  for (const line of text.split('\n')) {
39
43
  if (FENCE_RE.test(line)) {
40
44
  inFence = !inFence;
41
45
  continue;
42
46
  }
43
47
  if (inFence) continue;
44
- const m = line.match(PHASE_HEADING_RE);
45
- if (m) out.set(m[1].trim(), m[2].trim());
48
+ // Headings inside a preserved-section are part of curated content emitted
49
+ // verbatim by readPreservedSections — they are not phase overrides.
50
+ if (PRESERVED_OPEN_RE.test(line)) { inPreserved = true; continue; }
51
+ if (PRESERVED_CLOSE_RE.test(line)) { inPreserved = false; continue; }
52
+ if (inPreserved) continue;
53
+ const m = line.match(PHASE_HEADING_TEXT_RE);
54
+ if (m) {
55
+ // Only a recognized trailing status token counts as an override; an
56
+ // em-dash that is part of the title (no status token after it) yields no
57
+ // override. See splitPhaseHeading / issue #38.
58
+ const { title, status } = splitPhaseHeading(m[1]);
59
+ if (status) out.set(title, status);
60
+ }
46
61
  }
47
62
  return out;
48
63
  }
@@ -56,7 +71,7 @@ export function readPhaseOverrides(text) {
56
71
  * table, or null if this anon row was at the table head (no typed predecessor).
57
72
  *
58
73
  * 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
74
+ * doesn't match FEATURE_CODE_RE_STRICT. The current parser regex (looser, requires
60
75
  * trailing -<digits>) is NOT used here; we use the strict regex for accurate
61
76
  * classification (anon means truly no feature code, not the parser's regex bug).
62
77
  * For the 3-col anonymous form (`# | Item | Status`), all rows are anon.
@@ -67,6 +82,7 @@ export function readPhaseOverrides(text) {
67
82
  export function readAnonymousRows(text) {
68
83
  const out = new Map();
69
84
  let inFence = false;
85
+ let inPreserved = false;
70
86
  let currentPhaseId = null;
71
87
  let inTable = false;
72
88
  let codeColIdx = -1; // -1 means anonymous-form (3-col) table
@@ -81,10 +97,23 @@ export function readAnonymousRows(text) {
81
97
  }
82
98
  if (inFence) continue;
83
99
 
100
+ // Rows inside a preserved-section are owned verbatim by
101
+ // readPreservedSections — never capture them as anon (else generate emits
102
+ // them twice and the roundtrip accumulates duplicates every pass). Keep
103
+ // currentPhaseId across the block (the phase is unchanged by it); only the
104
+ // table state resets, so anon rows AFTER the block still anchor correctly.
105
+ if (PRESERVED_OPEN_RE.test(line)) {
106
+ inPreserved = true;
107
+ inTable = false; codeColIdx = -1; lastTypedCode = null;
108
+ continue;
109
+ }
110
+ if (PRESERVED_CLOSE_RE.test(line)) { inPreserved = false; continue; }
111
+ if (inPreserved) continue;
112
+
84
113
  // Phase heading resets table state.
85
- const phaseMatch = line.match(/^##\s+(.+?)(?:\s+—\s+.+)?\s*$/);
114
+ const phaseMatch = line.match(PHASE_HEADING_TEXT_RE);
86
115
  if (phaseMatch && line.startsWith('## ')) {
87
- currentPhaseId = phaseMatch[1].trim();
116
+ currentPhaseId = splitPhaseHeading(phaseMatch[1]).title;
88
117
  inTable = false;
89
118
  codeColIdx = -1;
90
119
  lastTypedCode = null;
@@ -99,7 +128,11 @@ export function readAnonymousRows(text) {
99
128
  continue;
100
129
  }
101
130
 
102
- const cells = line.split('|').slice(1, -1).map(c => c.trim());
131
+ // Split on UNESCAPED pipes only (lockstep with roadmap-parser.js), then
132
+ // unescape `\|` → `|`. This affects column detection / code extraction only;
133
+ // anon rows are stored as `rawLine: line` verbatim below, so the raw source
134
+ // line round-trips unchanged regardless of escaping.
135
+ const cells = line.split(/(?<!\\)\|/).slice(1, -1).map(c => c.trim().replace(/\\\|/g, '|'));
103
136
 
104
137
  // Header row — detect column layout.
105
138
  if (!inTable) {
@@ -120,14 +153,22 @@ export function readAnonymousRows(text) {
120
153
  // Skip divider rows.
121
154
  if (TABLE_DIVIDER_RE.test(line.trim())) continue;
122
155
 
123
- // Data row.
156
+ // Data row. A code that matches the strict contract case-INSENSITIVELY is
157
+ // a typed row (the same feature as feature.json's uppercase code); anchor by
158
+ // the uppercased canonical form so emitAnonAfter() re-places following anon
159
+ // rows during regen. Classifying a lowercase code as anon would preserve a
160
+ // phantom duplicate alongside the regenerated typed row (GENFIX T5).
124
161
  let isAnon = false;
162
+ let typedCode = null;
125
163
  if (codeColIdx === -1) {
126
164
  isAnon = true;
127
165
  } else {
128
166
  const codeCell = cells[codeColIdx] ?? '';
129
- if (codeCell === '—' || codeCell === '' || !FEATURE_CODE_RE.test(codeCell)) {
167
+ const upper = codeCell.toUpperCase();
168
+ if (codeCell === '—' || codeCell === '' || !FEATURE_CODE_RE_STRICT.test(upper)) {
130
169
  isAnon = true;
170
+ } else {
171
+ typedCode = upper;
131
172
  }
132
173
  }
133
174
 
@@ -136,7 +177,7 @@ export function readAnonymousRows(text) {
136
177
  arr.push({ rawLine: line, predecessorCode: lastTypedCode });
137
178
  out.set(currentPhaseId, arr);
138
179
  } else {
139
- lastTypedCode = cells[codeColIdx];
180
+ lastTypedCode = typedCode;
140
181
  }
141
182
  }
142
183
  return out;
@@ -248,10 +289,10 @@ export function readPhaseBlocks(text) {
248
289
  }
249
290
  if (inPreserved) continue;
250
291
 
251
- const phaseMatch = line.match(/^##\s+(.+?)(?:\s+—\s+.+)?\s*$/);
292
+ const phaseMatch = line.match(PHASE_HEADING_TEXT_RE);
252
293
  if (phaseMatch && line.startsWith('## ')) {
253
294
  finalize(i);
254
- currentPhaseId = phaseMatch[1].trim();
295
+ currentPhaseId = splitPhaseHeading(phaseMatch[1]).title;
255
296
  currentStartLineIdx = i;
256
297
  }
257
298
  }
@@ -291,9 +332,9 @@ export function readPhaseOrder(text) {
291
332
  }
292
333
  if (inPreserved) continue;
293
334
 
294
- const phaseMatch = line.match(/^##\s+(.+?)(?:\s+—\s+.+)?\s*$/);
335
+ const phaseMatch = line.match(PHASE_HEADING_TEXT_RE);
295
336
  if (phaseMatch && line.startsWith('## ')) {
296
- out.push(phaseMatch[1].trim());
337
+ out.push(splitPhaseHeading(phaseMatch[1]).title);
297
338
  }
298
339
  }
299
340
  return out;
@@ -330,9 +371,9 @@ export function readPreservedSectionAnchors(text) {
330
371
 
331
372
  // Phase headings inside an open preserved-section do NOT advance the anchor.
332
373
  if (openId === null) {
333
- const phaseMatch = line.match(/^##\s+(.+?)(?:\s+—\s+.+)?\s*$/);
374
+ const phaseMatch = line.match(PHASE_HEADING_TEXT_RE);
334
375
  if (phaseMatch && line.startsWith('## ') && !PRESERVED_OPEN_RE.test(line) && !PRESERVED_CLOSE_RE.test(line)) {
335
- currentPhaseId = phaseMatch[1].trim();
376
+ currentPhaseId = splitPhaseHeading(phaseMatch[1]).title;
336
377
  continue;
337
378
  }
338
379
  }