@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.
- package/bin/compose.js +71 -35
- package/dist/assets/App-VU2lfA8m.js +770 -0
- package/dist/assets/{arc-N74_SuiS.js → arc-CIeqpX37.js} +1 -1
- package/dist/assets/{architectureDiagram-3BPJPVTR-DHOb5xas.js → architectureDiagram-3BPJPVTR-itmOSZLE.js} +1 -1
- package/dist/assets/{blockDiagram-GPEHLZMM-DR9b_xXC.js → blockDiagram-GPEHLZMM-N7MotI_5.js} +8 -8
- package/dist/assets/{c4Diagram-AAUBKEIU-EXIx4J1v.js → c4Diagram-AAUBKEIU-DRKW39LH.js} +1 -1
- package/dist/assets/channel-DugSMLKi.js +1 -0
- package/dist/assets/{chunk-2J33WTMH-CyZqa6ub.js → chunk-2J33WTMH-CF6iSwEb.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-SjPmvNaj.js → chunk-4BX2VUAB-BTe-QE0R.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-Cnu3mdms.js → chunk-55IACEB6-E2hHEsl9.js} +1 -1
- package/dist/assets/{chunk-727SXJPM-DNj5i6fj.js → chunk-727SXJPM-CBRmkSvh.js} +1 -1
- package/dist/assets/{chunk-AQP2D5EJ-BIVskOlI.js → chunk-AQP2D5EJ-BdtQ63fN.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BWlLU-hh.js → chunk-FMBD7UC4-DfYQ2YmB.js} +1 -1
- package/dist/assets/{chunk-ND2GUHAM-DQEknadH.js → chunk-ND2GUHAM-CDrOVOW5.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-CUYnhnAB.js → chunk-QZHKN3VN-DwjqJ9xB.js} +1 -1
- package/dist/assets/classDiagram-4FO5ZUOK-D2RRwp7J.js +1 -0
- package/dist/assets/classDiagram-v2-Q7XG4LA2-D2RRwp7J.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-X3n23b12.js → cose-bilkent-S5V4N54A-MHpsrtBZ.js} +1 -1
- package/dist/assets/{dagre-BM42HDAG-C0SrhQ_X.js → dagre-BM42HDAG-DaPz_mPt.js} +1 -1
- package/dist/assets/{diagram-2AECGRRQ-Bc3qx6pJ.js → diagram-2AECGRRQ-DIdstuOm.js} +1 -1
- package/dist/assets/{diagram-5GNKFQAL-UiCrD06F.js → diagram-5GNKFQAL-DbkTGVES.js} +1 -1
- package/dist/assets/{diagram-KO2AKTUF-B9Vn5KyO.js → diagram-KO2AKTUF-BPalYJed.js} +1 -1
- package/dist/assets/{diagram-LMA3HP47-DLOYeLM3.js → diagram-LMA3HP47-vnySSoyd.js} +1 -1
- package/dist/assets/{diagram-OG6HWLK6-CXjh2miZ.js → diagram-OG6HWLK6-Dv3BUJft.js} +1 -1
- package/dist/assets/{erDiagram-TEJ5UH35-EmDzXNsM.js → erDiagram-TEJ5UH35-B3OLgtKK.js} +1 -1
- package/dist/assets/{flowDiagram-I6XJVG4X-vk6E_ebo.js → flowDiagram-I6XJVG4X-DdpxVf-5.js} +1 -1
- package/dist/assets/{ganttDiagram-6RSMTGT7-DYYSAjNx.js → ganttDiagram-6RSMTGT7-QALT_Lj9.js} +4 -4
- package/dist/assets/{gitGraphDiagram-PVQCEYII-CWPZVbhV.js → gitGraphDiagram-PVQCEYII-nITcPPED.js} +1 -1
- package/dist/assets/{graph-uO5hwVZK.js → graph-DnLKqSPg.js} +2 -2
- package/dist/assets/{index-BYYTTzUT.js → index-CLb8RFcn.js} +3 -3
- package/dist/assets/index-jqUffYBL.css +1 -0
- package/dist/assets/{infoDiagram-5YYISTIA-Dsu-eeJm.js → infoDiagram-5YYISTIA-CjlRce3x.js} +1 -1
- package/dist/assets/{ishikawaDiagram-YF4QCWOH-BP1SP8WA.js → ishikawaDiagram-YF4QCWOH-OyKVgxOz.js} +1 -1
- package/dist/assets/{journeyDiagram-JHISSGLW-DkE5By_R.js → journeyDiagram-JHISSGLW-3FaFyfLR.js} +1 -1
- package/dist/assets/{kanban-definition-UN3LZRKU-Cf_230xs.js → kanban-definition-UN3LZRKU-DUPnRo3q.js} +1 -1
- package/dist/assets/{linear-B-paxRBQ.js → linear-BeL8i3rv.js} +1 -1
- package/dist/assets/{mindmap-definition-RKZ34NQL-DAp6uJ_b.js → mindmap-definition-RKZ34NQL-C0CwWNdR.js} +1 -1
- package/dist/assets/mobile-qvdJ5p0m.js +17 -0
- package/dist/assets/{pieDiagram-4H26LBE5-CbYY5KL0.js → pieDiagram-4H26LBE5-DaU2jPjX.js} +1 -1
- package/dist/assets/{quadrantDiagram-W4KKPZXB-D5S4_ac5.js → quadrantDiagram-W4KKPZXB-HFtjZSAT.js} +1 -1
- package/dist/assets/{requirementDiagram-4Y6WPE33-BrPWCnHz.js → requirementDiagram-4Y6WPE33-CX_Mz3gv.js} +1 -1
- package/dist/assets/{sankeyDiagram-5OEKKPKP-CP8j1mcl.js → sankeyDiagram-5OEKKPKP-BR2_eTy9.js} +1 -1
- package/dist/assets/{sequenceDiagram-3UESZ5HK-c8DuhvUj.js → sequenceDiagram-3UESZ5HK-CtHp0Qnp.js} +1 -1
- package/dist/assets/{stateDiagram-AJRCARHV-KO9G1Jrm.js → stateDiagram-AJRCARHV-DmiEmD6G.js} +1 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-7rdO1Tgp.js +1 -0
- package/dist/assets/{timeline-definition-PNZ67QCA-Cs2HLlbG.js → timeline-definition-PNZ67QCA-GSHqrJ3A.js} +1 -1
- package/dist/assets/{vennDiagram-CIIHVFJN-rcSRidqI.js → vennDiagram-CIIHVFJN-CNxhQnCU.js} +1 -1
- package/dist/assets/{wardley-L42UT6IY-BsajGfii.js → wardley-L42UT6IY-Bf-gQIFY.js} +1 -1
- package/dist/assets/{wardleyDiagram-YWT4CUSO-CSWALc_m.js → wardleyDiagram-YWT4CUSO-RGxoapr7.js} +1 -1
- package/dist/assets/{xychartDiagram-2RQKCTM6-jC4Q0GvG.js → xychartDiagram-2RQKCTM6-1_H1qVde.js} +1 -1
- package/dist/index.html +3 -3
- package/lib/build.js +3 -2
- package/lib/feature-code.js +14 -4
- package/lib/feature-json.js +33 -2
- package/lib/feature-validator.js +135 -11
- package/lib/feature-writer.js +83 -3
- package/lib/migrate-roadmap.js +16 -2
- package/lib/project-paths.js +16 -0
- package/lib/roadmap-config.js +50 -0
- package/lib/roadmap-gen.js +46 -31
- package/lib/roadmap-heading.js +85 -0
- package/lib/roadmap-parser.js +69 -18
- package/lib/roadmap-preservers.js +60 -19
- package/lib/roadmap-roundtrip.js +137 -0
- package/lib/vision-writer.js +42 -14
- package/lib/xref-sync.js +160 -0
- package/package.json +1 -1
- package/server/compose-mcp.js +2 -1
- package/server/vision-store.js +1 -1
- package/dist/assets/App-CdP799CF.js +0 -768
- package/dist/assets/channel-yPY0IE15.js +0 -1
- package/dist/assets/classDiagram-4FO5ZUOK-SGKYXTP4.js +0 -1
- package/dist/assets/classDiagram-v2-Q7XG4LA2-SGKYXTP4.js +0 -1
- package/dist/assets/index-Dh2rRpBR.css +0 -1
- package/dist/assets/mobile-BwduHUEq.js +0 -17
- package/dist/assets/stateDiagram-v2-BHNVJYJU-eVyb8_R4.js +0 -1
package/lib/roadmap-gen.js
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
|
120
|
-
const minB = Math.min(...phases.get(b).map(f => f.position
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/roadmap-parser.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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 === '-' || !
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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(
|
|
114
|
+
const phaseMatch = line.match(PHASE_HEADING_TEXT_RE);
|
|
86
115
|
if (phaseMatch && line.startsWith('## ')) {
|
|
87
|
-
currentPhaseId = phaseMatch[1].
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
292
|
+
const phaseMatch = line.match(PHASE_HEADING_TEXT_RE);
|
|
252
293
|
if (phaseMatch && line.startsWith('## ')) {
|
|
253
294
|
finalize(i);
|
|
254
|
-
currentPhaseId = phaseMatch[1].
|
|
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(
|
|
335
|
+
const phaseMatch = line.match(PHASE_HEADING_TEXT_RE);
|
|
295
336
|
if (phaseMatch && line.startsWith('## ')) {
|
|
296
|
-
out.push(phaseMatch[1].
|
|
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(
|
|
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].
|
|
376
|
+
currentPhaseId = splitPhaseHeading(phaseMatch[1]).title;
|
|
336
377
|
continue;
|
|
337
378
|
}
|
|
338
379
|
}
|