@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
@@ -34,6 +34,10 @@ import { fileURLToPath } from 'node:url';
34
34
  import { FEATURE_CODE_RE_STRICT, validateCode } from './feature-code.js';
35
35
  import { parseRoadmap } from './roadmap-parser.js';
36
36
  import { listFeatures, readFeature } from './feature-json.js';
37
+ import { loadExternalPrefixes } from './project-paths.js';
38
+ import { checkRoundtrip, LOSSY_LABELS } from './roadmap-roundtrip.js';
39
+ import { isNarrativeOwned } from './roadmap-config.js';
40
+ import { readPhaseOrder, readPhaseBlocks, readPreservedSectionAnchors, readPhaseOverrides } from './roadmap-preservers.js';
37
41
  import { parseCitations } from './xref-citation.js';
38
42
  import { GitHubApi } from './tracker/github-api.js';
39
43
  import { ArtifactManager } from '../server/artifact-manager.js';
@@ -51,6 +55,45 @@ const DEFAULT_PATHS = { docs: 'docs', features: 'docs/features', journal: 'docs/
51
55
  const TERMINAL_STATUSES = new Set(['KILLED', 'SUPERSEDED']);
52
56
  const VALID_STATUSES = new Set(['PLANNED', 'IN_PROGRESS', 'PARTIAL', 'COMPLETE', 'SUPERSEDED', 'PARKED', 'BLOCKED', 'KILLED']);
53
57
 
58
+ // Finding kinds that treat ROADMAP.md as a canonical, machine-comparable source
59
+ // — roundtrip/structure derived from feature.json, folder↔row linkage, and any
60
+ // drift check that compares a parsed ROADMAP row against feature.json OR
61
+ // vision-state. All are false positives in a narrative-owned workspace (#39),
62
+ // where ROADMAP.md is hand-authored prose, not a data source. Findings that do
63
+ // NOT involve the roadmap (e.g. STATUS_MISMATCH_FEATUREJSON_VS_VISION_STATE,
64
+ // CONTRADICTORY_PHASE_CLAIM) are real drift and intentionally NOT listed here.
65
+ const NARRATIVE_SUPPRESSED_KINDS = new Set([
66
+ 'ROUNDTRIP_NOT_FIXED_POINT', 'ROADMAP_LOSSY', 'HIERARCHY_DEPTH_INVALID', 'ORPHAN_PHASE',
67
+ 'ROADMAP_ROW_WITHOUT_FOLDER', 'FOLDER_WITHOUT_ROADMAP_ROW', 'ORPHAN_FOLDER',
68
+ 'STATUS_MISMATCH_ROADMAP_VS_FEATUREJSON', 'STATUS_MISMATCH_ROADMAP_VS_VISION_STATE',
69
+ 'COMPLEXITY_OR_DESCRIPTION_DRIFT',
70
+ // Hand-authored rows are not typed rows — don't validate them against the
71
+ // ROADMAP row schema in a narrative-owned workspace (#39).
72
+ 'ROADMAP_ROW_SCHEMA_VIOLATION',
73
+ ]);
74
+
75
+ // Drop roadmap-derived findings on a narrative-owned workspace and record the
76
+ // skip as a single info finding (#39). Applied at every public entry point
77
+ // (validateProject and direct validateFeature) so the CLI/MCP feature scope is
78
+ // as clean as the project scope. No-op unless narrative-owned; returns the
79
+ // findings unchanged when nothing was suppressed so a clean feature stays quiet.
80
+ function applyNarrativeSuppression(findings, ctx) {
81
+ // Deliberately gated on featureJsonMode: "narrative-owned" means feature.json
82
+ // is canonical and ROADMAP.md is a hand-authored sidecar. In legacy
83
+ // roadmap-as-source mode (featureJsonMode=false) feature.json isn't even
84
+ // loaded and the project roundtrip checks don't run, so the roadmap IS a
85
+ // primary source — its row-schema/drift findings are real signal there and
86
+ // must NOT be suppressed. narrative-owned + featureJsonMode=false is a
87
+ // contradictory config; we honor featureJsonMode.
88
+ if (!(ctx.featureJsonMode && ctx.narrativeOwned)) return findings;
89
+ const dropped = findings.filter((f) => NARRATIVE_SUPPRESSED_KINDS.has(f.kind)).length;
90
+ if (dropped === 0) return findings;
91
+ const kept = findings.filter((f) => !NARRATIVE_SUPPRESSED_KINDS.has(f.kind));
92
+ kept.push(finding('info', 'ROADMAP_NARRATIVE_OWNED', undefined,
93
+ `roadmap.narrative=true — ROADMAP.md is hand-authored; ${dropped} roadmap↔feature.json correspondence finding(s) suppressed`));
94
+ return kept;
95
+ }
96
+
54
97
  const _validatorCache = {};
55
98
  function getValidator(schemaPath) {
56
99
  if (!_validatorCache[schemaPath]) _validatorCache[schemaPath] = new SchemaValidator(schemaPath);
@@ -208,9 +251,21 @@ function loadValidationContext(cwd, options = {}) {
208
251
  citationRows,
209
252
  externalPrefixes: options.externalPrefixes || [],
210
253
  featureJsonMode: options.featureJsonMode !== false,
254
+ // Narrative-owned (#39): ROADMAP.md is hand-authored, so a parsed roadmap row
255
+ // is NOT a canonical data source. Computed once here and consulted by checks
256
+ // that would otherwise treat a row as authoritative.
257
+ narrativeOwned: isNarrativeOwned(cwd),
211
258
  };
212
259
  }
213
260
 
261
+ // Effective status for per-feature checks. feature.json is canonical; the parsed
262
+ // ROADMAP row is only a fallback — and NOT even that on a narrative-owned
263
+ // workspace, where the row is hand-authored prose (#39).
264
+ function effectiveStatus(fctx, ctx) {
265
+ return normalizeStatus(fctx.featureJson?.status)
266
+ || (ctx?.narrativeOwned ? null : normalizeStatus(fctx.roadmap?.status));
267
+ }
268
+
214
269
  function loadFeatureContext(cwd, code, ctx) {
215
270
  const folder = ctx.foldersByCode.get(code);
216
271
  let featureJson = null;
@@ -244,17 +299,20 @@ function finding(severity, kind, code, detail, source) {
244
299
  // Per-feature checks
245
300
  // ---------------------------------------------------------------------------
246
301
 
247
- function runKilledModeChecks(fctx, findings) {
302
+ function runKilledModeChecks(fctx, findings, narrativeOwned = false) {
248
303
  const { code, folder, roadmap, vision, featureJson } = fctx;
249
304
  // KILLED_STATUS_NOT_TERMINAL
250
305
  const statuses = [];
251
- if (roadmap?.status) statuses.push({ src: 'roadmap', val: String(roadmap.status).toUpperCase() });
306
+ // On a narrative-owned workspace the ROADMAP row is hand-authored, not a
307
+ // canonical data source, so a non-terminal roadmap status is not real drift
308
+ // (#39). feature.json / vision-state are still checked.
309
+ if (!narrativeOwned && roadmap?.status) statuses.push({ src: 'roadmap', val: String(roadmap.status).toUpperCase() });
252
310
  if (featureJson?.status) statuses.push({ src: 'feature.json', val: String(featureJson.status).toUpperCase() });
253
311
  if (vision?.status) statuses.push({ src: 'vision-state', val: String(vision.status).toUpperCase() });
254
312
  for (const s of statuses) {
255
313
  if (!TERMINAL_STATUSES.has(s.val)) {
256
314
  findings.push(finding('error', 'KILLED_STATUS_NOT_TERMINAL', code,
257
- `${s.src} status is ${s.val} but killed.md is present; expected KILLED or SUPERSEDED`));
315
+ `${s.src} status is ${s.val} but killed.md is present; expected KILLED or SUPERSEDED`, s.src));
258
316
  }
259
317
  }
260
318
  // KILLED_SUCCESSOR_NOT_LINKED
@@ -422,7 +480,7 @@ function runArtifactLinkChecks(fctx, ctx, findings) {
422
480
  try { assessment = am.assess(code); } catch { /* fall through */ }
423
481
 
424
482
  // MISSING_DESIGN_ARTIFACT — error for active work, warning for COMPLETE (legacy may not have design.md).
425
- const status = normalizeStatus(featureJson?.status) || normalizeStatus(fctx.roadmap?.status);
483
+ const status = effectiveStatus(fctx, ctx);
426
484
  const ACTIVE_STATUSES = new Set(['IN_PROGRESS', 'PARTIAL', 'BLOCKED']);
427
485
  if (status && !folder.files.has('design.md')) {
428
486
  if (ACTIVE_STATUSES.has(status)) {
@@ -491,7 +549,7 @@ function runCrossFeatureRefChecks(fctx, ctx, findings) {
491
549
  }
492
550
  // SUPERSEDED_WITHOUT_LINK — non-killed feature in SUPERSEDED status without a
493
551
  // typed `supersedes` link to the successor.
494
- const status = normalizeStatus(featureJson?.status) || normalizeStatus(fctx.roadmap?.status);
552
+ const status = effectiveStatus(fctx, ctx);
495
553
  if (status === 'SUPERSEDED' && !fctx.killed) {
496
554
  const links = featureJson?.links || [];
497
555
  const hasSupersedes = links.some((l) => l.kind === 'supersedes' && l.to_code);
@@ -505,7 +563,7 @@ function runCrossFeatureRefChecks(fctx, ctx, findings) {
505
563
  function runCoherenceChecks(fctx, ctx, findings) {
506
564
  const { code, featureJson } = fctx;
507
565
  // COMPLETION_WITHOUT_CHANGELOG — check at project level, but per-feature too
508
- const status = normalizeStatus(featureJson?.status) || normalizeStatus(fctx.roadmap?.status);
566
+ const status = effectiveStatus(fctx, ctx);
509
567
  if (status === 'COMPLETE' || status === 'PARTIAL') {
510
568
  let changelog = '';
511
569
  try { changelog = fs.readFileSync(ctx.paths.changelog, 'utf8'); } catch {}
@@ -620,8 +678,9 @@ export async function validateFeature(cwd, code, options = {}) {
620
678
  const fctx = loadFeatureContext(cwd, code, ctx);
621
679
 
622
680
  if (fctx.killed) {
623
- runKilledModeChecks(fctx, findings);
624
- return { scope: 'feature', feature_code: code, validated_at: nowIso(), findings };
681
+ runKilledModeChecks(fctx, findings, ctx.narrativeOwned);
682
+ const kf = options._deferNarrative ? findings : applyNarrativeSuppression(findings, ctx);
683
+ return { scope: 'feature', feature_code: code, validated_at: nowIso(), findings: kf };
625
684
  }
626
685
 
627
686
  runSchemaChecks(fctx, ctx, findings);
@@ -631,7 +690,11 @@ export async function validateFeature(cwd, code, options = {}) {
631
690
  runCrossFeatureRefChecks(fctx, ctx, findings);
632
691
  runCoherenceChecks(fctx, ctx, findings);
633
692
 
634
- return { scope: 'feature', feature_code: code, validated_at: nowIso(), findings };
693
+ // Direct feature-scope calls suppress roadmap-derived findings here; when
694
+ // invoked by validateProject (_deferNarrative) the project does it once over
695
+ // the aggregate so the info finding isn't multiplied per feature (#39).
696
+ const kept = options._deferNarrative ? findings : applyNarrativeSuppression(findings, ctx);
697
+ return { scope: 'feature', feature_code: code, validated_at: nowIso(), findings: kept };
635
698
  }
636
699
 
637
700
  // ---------------------------------------------------------------------------
@@ -975,13 +1038,69 @@ export async function validateProject(cwd, options = {}) {
975
1038
 
976
1039
  for (const code of allCodes) {
977
1040
  if (!FEATURE_CODE_RE_STRICT.test(code)) continue;
978
- const result = await validateFeature(cwd, code, options);
1041
+ // Defer narrative suppression to the single project-level pass below, so the
1042
+ // ROADMAP_NARRATIVE_OWNED info finding isn't emitted once per feature (#39).
1043
+ const result = await validateFeature(cwd, code, { ...options, _deferNarrative: true });
979
1044
  findings.push(...result.findings);
980
1045
  }
981
1046
 
982
1047
  runOrphanFolderCheck(ctx, findings);
983
1048
  runChangelogReferenceCheck(ctx, findings);
984
1049
  runJournalIndexDriftCheck(ctx, findings);
1050
+
1051
+ // --- COMP-ROADMAP-RT: roundtrip + hierarchy ---
1052
+ if (ctx.featureJsonMode) {
1053
+ const cfg = readProjectConfig(cwd);
1054
+ const featuresDir = (cfg && cfg.paths && cfg.paths.features) || DEFAULT_PATHS.features;
1055
+ const features = listFeatures(cwd, featuresDir);
1056
+ const roadmapText = fs.existsSync(ctx.paths.roadmap)
1057
+ ? fs.readFileSync(ctx.paths.roadmap, 'utf8')
1058
+ : '';
1059
+
1060
+ for (const f of features) {
1061
+ if (!f.phase) {
1062
+ findings.push(finding('warning', 'HIERARCHY_DEPTH_INVALID', f.code,
1063
+ 'feature has no phase — renders ungrouped (depth < 2)'));
1064
+ }
1065
+ }
1066
+
1067
+ const externalPrefixes = (ctx.externalPrefixes && ctx.externalPrefixes.length)
1068
+ ? ctx.externalPrefixes
1069
+ : loadExternalPrefixes(cwd);
1070
+ const rt = checkRoundtrip(roadmapText, features, { now: '0000-00-00', externalPrefixes });
1071
+ if (!rt.fixedPoint) {
1072
+ const d = rt.diffs.find((x) => x.kind === 'FIXED_POINT_DIVERGENCE');
1073
+ findings.push(finding('error', 'ROUNDTRIP_NOT_FIXED_POINT', undefined,
1074
+ `ROADMAP.md is not a generation fixed point: ${d?.detail ?? 'diverges on regen'}`));
1075
+ }
1076
+ for (const d of rt.diffs.filter((x) => x.kind.startsWith('LOSSLESS_'))) {
1077
+ const label = LOSSY_LABELS[d.kind] ?? d.kind;
1078
+ findings.push(finding('warning', 'ROADMAP_LOSSY', d.code ?? undefined,
1079
+ `${label}${d.detail ? ': ' + d.detail : ''}`));
1080
+ }
1081
+
1082
+ const phasesWithFeatures = new Set(features.map((f) => f.phase).filter(Boolean));
1083
+ const phaseBlocks = readPhaseBlocks(roadmapText);
1084
+ const phaseOverrides = readPhaseOverrides(roadmapText);
1085
+ const anchoredPhases = new Set(
1086
+ [...readPreservedSectionAnchors(roadmapText).values()].filter(Boolean));
1087
+ for (const phaseId of readPhaseOrder(roadmapText)) {
1088
+ if (phasesWithFeatures.has(phaseId)) continue;
1089
+ const block = phaseBlocks.get(phaseId);
1090
+ const hasBody = block && block.split('\n').slice(1).some((l) => l.trim().length > 0);
1091
+ if (!hasBody && !anchoredPhases.has(phaseId)) {
1092
+ // A dead heading (no rows, no body) is a warning — but if the heading
1093
+ // itself carries an active status, it is holding live work in a
1094
+ // place that renders nothing: escalate to error.
1095
+ const ov = (phaseOverrides.get(phaseId) ?? '').toUpperCase();
1096
+ const active = ov.startsWith('IN_PROGRESS') || ov.startsWith('PARTIAL');
1097
+ findings.push(finding(active ? 'error' : 'warning', 'ORPHAN_PHASE', undefined,
1098
+ `phase "${phaseId}" has no feature.json features and no preserved content`
1099
+ + (active ? ' (active status — dead heading holds live work)' : '')));
1100
+ }
1101
+ }
1102
+ }
1103
+
985
1104
  try {
986
1105
  await runExternalRefChecks(ctx, findings, options);
987
1106
  } catch (e) {
@@ -995,5 +1114,10 @@ export async function validateProject(cwd, options = {}) {
995
1114
  ));
996
1115
  }
997
1116
 
998
- return { scope: 'project', validated_at: nowIso(), findings };
1117
+ // Narrative-owned workspaces (#39): ROADMAP.md is hand-authored, not driven by
1118
+ // feature.json, so every finding that treats a roadmap row as canonical is a
1119
+ // false positive. Strip them in one place (robust against new such checks) and
1120
+ // record the skip as a single info finding. feature.json↔vision drift is left
1121
+ // intact — it doesn't involve the roadmap.
1122
+ return { scope: 'project', validated_at: nowIso(), findings: applyNarrativeSuppression(findings, ctx) };
999
1123
  }
@@ -16,12 +16,14 @@
16
16
  * be called from MCP tools, the CLI, or future REST routes.
17
17
  */
18
18
 
19
- import { existsSync, realpathSync, statSync } from 'fs';
20
- import { resolve, normalize, sep, basename, dirname } from 'path';
19
+ import { existsSync, realpathSync, statSync, readFileSync } from 'fs';
20
+ import { resolve, normalize, sep, basename, dirname, join } from 'path';
21
21
 
22
22
  import { readEvents } from './feature-events.js';
23
23
  import { checkOrInsert } from './idempotency.js';
24
24
  import { loadFeaturesDir } from './project-paths.js';
25
+ import { checkRoundtrip } from './roadmap-roundtrip.js';
26
+ import { isNarrativeOwned, narrativeOwnedMessage } from './roadmap-config.js';
25
27
 
26
28
  // providerFor is imported lazily (inside each function) to break the
27
29
  // module-load-time cycle: factory.js → local-provider.js → feature-writer.js.
@@ -90,9 +92,15 @@ function maybeIdempotent(args, fn) {
90
92
  * @param {number} [args.position]
91
93
  * @param {string} [args.parent]
92
94
  * @param {string[]} [args.tags]
95
+ * @param {boolean} [args.force]
93
96
  * @param {string} [args.idempotency_key]
94
97
  */
95
98
  export async function addRoadmapEntry(cwd, args) {
99
+ // Narrative-owned workspaces have a hand-authored ROADMAP.md and no typed
100
+ // tracker provider — refuse before writing any feature.json (#39).
101
+ if (isNarrativeOwned(cwd)) {
102
+ throw new Error(narrativeOwnedMessage(cwd));
103
+ }
96
104
  validateCode(args.code);
97
105
  if (!args.description) throw new Error('feature-writer: description is required');
98
106
  if (!args.phase) throw new Error('feature-writer: phase is required');
@@ -128,6 +136,13 @@ export async function addRoadmapEntry(cwd, args) {
128
136
  if (args.parent) feature.parent = args.parent;
129
137
  if (args.tags && args.tags.length) feature.tags = args.tags;
130
138
 
139
+ let roundtrip = null;
140
+ if (isLocalProvider(provider)) {
141
+ roundtrip = await roundtripGuard(cwd, provider,
142
+ (feats) => [...feats, feature],
143
+ { force: args.force, label: 'add_roadmap_entry' });
144
+ }
145
+
131
146
  // Use createFeature (not putFeature) for the initial write of a brand-new
132
147
  // feature: the not-found check above has already confirmed it doesn't exist,
133
148
  // and createFeature carries the correct semantics for remote providers
@@ -157,6 +172,7 @@ export async function addRoadmapEntry(cwd, args) {
157
172
  phase: args.phase,
158
173
  position: feature.position,
159
174
  roadmap_path: roadmapPath,
175
+ roundtrip,
160
176
  };
161
177
  });
162
178
  }
@@ -186,6 +202,62 @@ function partialWriteError(message, cause) {
186
202
  return err;
187
203
  }
188
204
 
205
+ // Local-provider discriminator. LocalFileProvider.name() returns 'local';
206
+ // GitHubProvider.name() returns 'github' (see lib/tracker/*-provider.js). The
207
+ // factory returns the bare local instance for the local case and a Proxy
208
+ // wrapping GitHubProvider otherwise — name() resolves correctly through both.
209
+ // Test mock providers that don't model the local file tracker won't report
210
+ // 'local', so the guard is correctly skipped for them.
211
+ function isLocalProvider(provider) {
212
+ return typeof provider?.name === 'function' && provider.name() === 'local';
213
+ }
214
+
215
+ // Read ROADMAP.md as a regen base only if it's a readable regular file.
216
+ // Anything else (absent, directory, unreadable) yields '' so the guard stays a
217
+ // pure pre-check and leaves any real write-path I/O fault to renderRoadmap.
218
+ function readRoadmapBase(roadmapPath) {
219
+ try {
220
+ if (!existsSync(roadmapPath) || !statSync(roadmapPath).isFile()) return '';
221
+ return readFileSync(roadmapPath, 'utf-8');
222
+ } catch {
223
+ return '';
224
+ }
225
+ }
226
+
227
+ // Pre-commit roundtrip guard (LOCAL providers only — remote providers render
228
+ // server-side and are out of scope for COMP-ROADMAP-RT). Runs checkRoundtrip on
229
+ // the prospective feature set BEFORE persistence; throws (unless force) when the
230
+ // render won't stabilize, so canonical feature.json is never written ahead of a
231
+ // broken view. Returns the RoundtripResult on success.
232
+ //
233
+ // Blocks only on fixed-point divergence (a visibly churning rendered view);
234
+ // losslessness is surfaced by the validator (Task 6 / validate_project), not
235
+ // blocked here. The full RoundtripResult — including lossless + diffs — is still
236
+ // returned for callers to inspect.
237
+ async function roundtripGuard(cwd, provider, mutate, { force, label }) {
238
+ const current = await provider.listFeatures();
239
+ const projected = mutate(current.map(f => ({ ...f })));
240
+ const roadmapPath = join(cwd, 'ROADMAP.md');
241
+ // Read the existing ROADMAP as the regen base, but only when it's a readable
242
+ // regular file. If the path is missing, a directory, or otherwise unreadable,
243
+ // treat the base as empty: the guard must never mask the downstream
244
+ // renderRoadmap partial-write error (which surfaces that I/O fault with the
245
+ // correct ROADMAP_PARTIAL_WRITE envelope after feature.json is committed).
246
+ const baseText = readRoadmapBase(roadmapPath);
247
+ const rt = checkRoundtrip(baseText, projected, { now: '0000-00-00' });
248
+ if (!rt.fixedPoint && !force) {
249
+ const d = rt.diffs.find(x => x.kind === 'FIXED_POINT_DIVERGENCE');
250
+ const err = new Error(
251
+ `${label}: aborted — ROADMAP.md would not be a generation fixed point ` +
252
+ `(${d?.detail ?? 'diverges on regen'}). No changes were written. ` +
253
+ `Pass force: true to commit anyway.`
254
+ );
255
+ err.code = 'ROUNDTRIP_NOT_FIXED_POINT';
256
+ throw err;
257
+ }
258
+ return rt;
259
+ }
260
+
189
261
  // Audit-log writes are best-effort: a failed append must NOT roll back a
190
262
  // committed mutation (per design Decision 2 and docs/mcp.md). Log a warning
191
263
  // and continue.
@@ -252,6 +324,14 @@ export async function setFeatureStatus(cwd, args) {
252
324
  // enforcement has already happened above — this is the raw persistence step.
253
325
  const updated = { ...feature, status: to };
254
326
  if (args.commit_sha) updated.commit_sha = args.commit_sha;
327
+
328
+ let roundtrip = null;
329
+ if (isLocalProvider(provider)) {
330
+ roundtrip = await roundtripGuard(cwd, provider,
331
+ (feats) => feats.map(f => f.code === args.code ? updated : f),
332
+ { force: args.force, label: 'set_feature_status' });
333
+ }
334
+
255
335
  await provider.persistFeatureRaw(args.code, updated);
256
336
  try {
257
337
  await provider.renderRoadmap();
@@ -275,7 +355,7 @@ export async function setFeatureStatus(cwd, args) {
275
355
  if (args.force && !allowed.includes(to)) event.forced = true;
276
356
  await safeAppendEvent(cwd, event);
277
357
 
278
- return { code: args.code, from, to, ts: new Date().toISOString() };
358
+ return { code: args.code, from, to, ts: new Date().toISOString(), roundtrip };
279
359
  });
280
360
  }
281
361
 
@@ -19,12 +19,18 @@ import { loadFeaturesDir } from './project-paths.js';
19
19
  * @param {string} [opts.featuresDir] - Relative features path
20
20
  * @param {boolean} [opts.dryRun] - Print what would be created without writing
21
21
  * @param {boolean} [opts.overwrite] - Overwrite existing feature.json files
22
- * @returns {{ created: string[], skipped: string[], updated: string[] }}
22
+ * @param {string[]} [opts.externalPrefixes] - Code prefixes for features owned by
23
+ * OTHER projects (cross-project references). Entries whose code matches any
24
+ * prefix are skipped entirely — no feature.json is created — and recorded in
25
+ * the returned `skippedExternal` array.
26
+ * @returns {{ created: string[], skipped: string[], updated: string[], skippedExternal: string[] }}
23
27
  */
24
28
  export function migrateRoadmap(cwd, opts = {}) {
25
29
  // COMP-MCP-MIGRATION-2-1: honor `paths.features` override so backfill
26
30
  // writes under the configured root rather than the hardcoded default.
27
31
  const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
32
+ const externalPrefixes = opts.externalPrefixes ?? [];
33
+ const isExternal = (code) => externalPrefixes.some((p) => code.startsWith(p));
28
34
  const roadmapPath = join(cwd, 'ROADMAP.md');
29
35
 
30
36
  if (!existsSync(roadmapPath)) {
@@ -37,11 +43,19 @@ export function migrateRoadmap(cwd, opts = {}) {
37
43
  const created = [];
38
44
  const skipped = [];
39
45
  const updated = [];
46
+ const skippedExternal = [];
40
47
 
41
48
  for (const entry of entries) {
42
49
  // Skip anonymous entries
43
50
  if (entry.code.startsWith('_anon_')) continue;
44
51
 
52
+ // Skip cross-project references — owned by another project, present here
53
+ // only as a roadmap reference. Never create a feature.json for these.
54
+ if (isExternal(entry.code)) {
55
+ skippedExternal.push(entry.code);
56
+ continue;
57
+ }
58
+
45
59
  const existing = readFeature(cwd, entry.code, featuresDir);
46
60
 
47
61
  if (existing && !opts.overwrite) {
@@ -81,7 +95,7 @@ export function migrateRoadmap(cwd, opts = {}) {
81
95
  }
82
96
  }
83
97
 
84
- return { created, skipped, updated };
98
+ return { created, skipped, updated, skippedExternal };
85
99
  }
86
100
 
87
101
  /**
@@ -33,4 +33,20 @@ export function loadFeaturesDir(cwd) {
33
33
  }
34
34
  }
35
35
 
36
+ /**
37
+ * Read `externalPrefixes` from .compose/compose.json — code prefixes (e.g.
38
+ * ["STRAT-"]) for features owned by OTHER projects, present in this roadmap
39
+ * only as cross-project references. Returns [] if absent/unreadable.
40
+ * @param {string} cwd
41
+ * @returns {string[]}
42
+ */
43
+ export function loadExternalPrefixes(cwd) {
44
+ try {
45
+ const cfgPath = join(cwd, '.compose', 'compose.json');
46
+ if (!existsSync(cfgPath)) return [];
47
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
48
+ return Array.isArray(cfg.externalPrefixes) ? cfg.externalPrefixes : [];
49
+ } catch { return []; }
50
+ }
51
+
36
52
  export const _internals = { DEFAULT_FEATURES_DIR };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * roadmap-config.js — Workspace-level roadmap policy (issue #39).
3
+ *
4
+ * A workspace is "narrative-owned" when its ROADMAP.md is hand-authored and
5
+ * must NOT be machine-regenerated from feature.json. It is signalled by
6
+ * `roadmap.narrative: true` in `.compose/compose.json`. The typed writer
7
+ * (generateRoadmap / writeRoadmap / add_roadmap_entry) refuses to engage such a
8
+ * workspace — otherwise regen flattens curated reconciliation prose into
9
+ * rendered tables (the forge-top "Wave 6" duplication, root cause of #39).
10
+ *
11
+ * feature.json files may still exist in a narrative-owned workspace — they are
12
+ * structured link carriers (xref-sync) and cross-references; they simply do not
13
+ * DRIVE ROADMAP.md. The guard stops the writer, it does not delete data.
14
+ */
15
+
16
+ import { readFileSync, existsSync } from 'fs';
17
+ import { join } from 'path';
18
+
19
+ /**
20
+ * @param {string} cwd - Workspace root
21
+ * @returns {boolean} true if `.compose/compose.json` declares roadmap.narrative
22
+ */
23
+ export function isNarrativeOwned(cwd) {
24
+ const p = join(cwd, '.compose/compose.json');
25
+ if (!existsSync(p)) return false;
26
+ try {
27
+ const parsed = JSON.parse(readFileSync(p, 'utf8'));
28
+ return parsed?.roadmap?.narrative === true;
29
+ } catch {
30
+ // Malformed config: don't claim narrative-owned. The tracker factory
31
+ // (loadTrackerConfig) is the loud validator for malformed compose.json;
32
+ // this gate stays quiet so a parse error there doesn't double-report here.
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Actionable message explaining why a typed-writer operation was refused.
39
+ * @param {string} cwd
40
+ * @returns {string}
41
+ */
42
+ export function narrativeOwnedMessage(cwd) {
43
+ return (
44
+ `compose: ${cwd} is narrative-owned (roadmap.narrative=true in ` +
45
+ `.compose/compose.json) — its ROADMAP.md is hand-authored and is not ` +
46
+ `regenerated from feature.json. Edit ROADMAP.md directly. To re-enable ` +
47
+ `typed-roadmap generation, remove "roadmap": { "narrative": true } from ` +
48
+ `.compose/compose.json.`
49
+ );
50
+ }