@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/feature-validator.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/feature-writer.js
CHANGED
|
@@ -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
|
|
package/lib/migrate-roadmap.js
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
/**
|
package/lib/project-paths.js
CHANGED
|
@@ -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
|
+
}
|