@smartmemory/compose 0.1.44-beta → 0.2.1-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- 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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* roadmap-roundtrip.js — prove ROADMAP.md is a deterministic fixed point of
|
|
3
|
+
* feature.json. Pure: no filesystem, no event/stderr side effects.
|
|
4
|
+
*
|
|
5
|
+
* COMP-ROADMAP-RT.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { generateRoadmapFromBase } from './roadmap-gen.js';
|
|
9
|
+
import { parseRoadmap } from './roadmap-parser.js';
|
|
10
|
+
import { isFeatureCode } from './feature-code.js';
|
|
11
|
+
|
|
12
|
+
export const MAX_REGEN_PASSES = 3;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Human-readable labels for the lossless (LOSSLESS_*) diff kinds. Shared by the
|
|
16
|
+
* CLI (`roadmap check`) and the project validator (ROADMAP_LOSSY findings) so the
|
|
17
|
+
* two surfaces can't drift in their wording.
|
|
18
|
+
*/
|
|
19
|
+
export const LOSSY_LABELS = {
|
|
20
|
+
LOSSLESS_MISSING: 'roadmap missing a row for feature.json entry',
|
|
21
|
+
LOSSLESS_EXTRA: 'roadmap row not backed by feature.json',
|
|
22
|
+
LOSSLESS_CHANGED: 'roadmap row disagrees with feature.json',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Human-readable one-line description of a lossless diff. */
|
|
26
|
+
export function describeLossyDiff(d) {
|
|
27
|
+
const label = LOSSY_LABELS[d.kind] ?? d.kind;
|
|
28
|
+
const code = d.code ? ` ${d.code}` : '';
|
|
29
|
+
const detail = d.detail ? `: ${d.detail}` : '';
|
|
30
|
+
return `${label}${code}${detail}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {{ kind: string, phaseId?: string, code?: string, detail?: string }} Diff
|
|
35
|
+
* @typedef {{ fixedPoint: boolean, lossless: boolean, canonical: string, passes: number, diffs: Diff[] }} RoundtripResult
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} baseText Existing ROADMAP.md content ('' for a fresh file)
|
|
40
|
+
* @param {Array} features feature.json feature objects
|
|
41
|
+
* @param {object} [opts] { now, maxPasses, projectName, projectDescription, externalPrefixes }
|
|
42
|
+
* @param {string[]} [opts.externalPrefixes] Code prefixes (e.g. ['STRAT-']) for
|
|
43
|
+
* features owned by OTHER projects, present in this roadmap only as
|
|
44
|
+
* cross-project references. A parsed code matching any prefix is NOT flagged
|
|
45
|
+
* LOSSLESS_EXTRA. Defaults to [].
|
|
46
|
+
* @returns {RoundtripResult}
|
|
47
|
+
*/
|
|
48
|
+
export function checkRoundtrip(baseText, features, opts = {}) {
|
|
49
|
+
const maxPasses = opts.maxPasses ?? MAX_REGEN_PASSES;
|
|
50
|
+
const externalPrefixes = opts.externalPrefixes ?? [];
|
|
51
|
+
const isExternal = (code) => externalPrefixes.some(p => code.startsWith(p));
|
|
52
|
+
// Pure: never pass cwd (so no drift I/O); suppressDrift belt-and-suspenders.
|
|
53
|
+
const genOpts = { ...opts, cwd: undefined, suppressDrift: true };
|
|
54
|
+
const diffs = [];
|
|
55
|
+
|
|
56
|
+
// --- Fixed point: iterate gen until output stabilizes. ---
|
|
57
|
+
// Each pass regenerates from the previous output; convergence = next === canonical.
|
|
58
|
+
// On non-convergence within maxPasses, emit exactly one FIXED_POINT_DIVERGENCE
|
|
59
|
+
// diff comparing the last two distinct passes. canonical is always the last pass.
|
|
60
|
+
let canonical = generateRoadmapFromBase(baseText, features, genOpts);
|
|
61
|
+
let passes = 1;
|
|
62
|
+
let fixedPoint = false;
|
|
63
|
+
while (passes < maxPasses) {
|
|
64
|
+
const next = generateRoadmapFromBase(canonical, features, genOpts);
|
|
65
|
+
passes++;
|
|
66
|
+
if (next === canonical) { fixedPoint = true; break; }
|
|
67
|
+
const prev = canonical;
|
|
68
|
+
canonical = next;
|
|
69
|
+
if (passes === maxPasses) {
|
|
70
|
+
diffs.push({ kind: 'FIXED_POINT_DIVERGENCE', detail: firstDiffLine(prev, canonical) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- Losslessness: parse canonical, aggregate by code, exclude anon. ---
|
|
75
|
+
const parsed = parseRoadmap(canonical);
|
|
76
|
+
const byCode = new Map();
|
|
77
|
+
for (const e of parsed) {
|
|
78
|
+
if (e.code.startsWith('_anon_') || !isFeatureCode(e.code)) continue;
|
|
79
|
+
const arr = byCode.get(e.code) ?? [];
|
|
80
|
+
arr.push(e);
|
|
81
|
+
byCode.set(e.code, arr);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const featureCodes = new Set();
|
|
85
|
+
for (const f of features) {
|
|
86
|
+
featureCodes.add(f.code);
|
|
87
|
+
const group = byCode.get(f.code);
|
|
88
|
+
if (!group || group.length === 0) {
|
|
89
|
+
diffs.push({ kind: 'LOSSLESS_MISSING', code: f.code, phaseId: f.phase });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const hasItems = Array.isArray(f.items) && f.items.length > 0;
|
|
93
|
+
if (hasItems) {
|
|
94
|
+
const want = f.items.map(i => up(i.status ?? f.status)).sort();
|
|
95
|
+
const got = group.map(e => up(e.status)).sort();
|
|
96
|
+
if (want.length !== got.length || want.some((s, i) => s !== got[i])) {
|
|
97
|
+
diffs.push({ kind: 'LOSSLESS_CHANGED', code: f.code, phaseId: f.phase,
|
|
98
|
+
detail: `items: want [${want}] got [${got}]` });
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
const e = group[0];
|
|
102
|
+
if (up(e.status) !== up(f.status)) {
|
|
103
|
+
diffs.push({ kind: 'LOSSLESS_CHANGED', code: f.code, phaseId: f.phase,
|
|
104
|
+
detail: `status: want ${up(f.status)} got ${up(e.status)}` });
|
|
105
|
+
}
|
|
106
|
+
// feature.json stores a FLAT phase, but the parser yields a full
|
|
107
|
+
// milestone path ("Phase > Milestone") for ### sub-headings. Compare on
|
|
108
|
+
// the top-level phase only; surface the full parsed phaseId in detail so
|
|
109
|
+
// genuine drift stays legible.
|
|
110
|
+
const topPhase = e.phaseId ? e.phaseId.split(' > ')[0].trim() : e.phaseId;
|
|
111
|
+
if (f.phase && topPhase && topPhase !== f.phase) {
|
|
112
|
+
diffs.push({ kind: 'LOSSLESS_CHANGED', code: f.code, phaseId: f.phase,
|
|
113
|
+
detail: `phase: want ${f.phase} got ${e.phaseId}` });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for (const code of byCode.keys()) {
|
|
118
|
+
if (!featureCodes.has(code) && !isExternal(code)) diffs.push({ kind: 'LOSSLESS_EXTRA', code });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const lossless = !diffs.some(d => d.kind.startsWith('LOSSLESS_'));
|
|
122
|
+
return { fixedPoint, lossless, canonical, passes, diffs };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function up(s) { return String(s ?? '').toUpperCase().trim(); }
|
|
126
|
+
|
|
127
|
+
/** First differing line between two texts, for FIXED_POINT_DIVERGENCE.detail. */
|
|
128
|
+
function firstDiffLine(a, b) {
|
|
129
|
+
const al = a.split('\n'), bl = b.split('\n');
|
|
130
|
+
const n = Math.max(al.length, bl.length);
|
|
131
|
+
for (let i = 0; i < n; i++) {
|
|
132
|
+
// The ?? '' padding means any length difference surfaces as a line mismatch,
|
|
133
|
+
// so the loop always returns when a !== b (the only caller's precondition).
|
|
134
|
+
if (al[i] !== bl[i]) return `line ${i + 1}: "${al[i] ?? ''}" → "${bl[i] ?? ''}"`;
|
|
135
|
+
}
|
|
136
|
+
return '';
|
|
137
|
+
}
|
package/lib/vision-writer.js
CHANGED
|
@@ -16,6 +16,24 @@ import { probeServer } from './server-probe.js';
|
|
|
16
16
|
|
|
17
17
|
const EMPTY_STATE = () => ({ items: [], connections: [], gates: [] });
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Locate the vision item a featureCode refers to, tolerant of UI-created items
|
|
21
|
+
* that have no lifecycle yet (#31). Priority order: lifecycle.featureCode (the
|
|
22
|
+
* canonical binding), then the raw item id, then a top-level item.featureCode.
|
|
23
|
+
* A lifecycle match anywhere in the list beats an id/featureCode match, so the
|
|
24
|
+
* three passes are evaluated in order rather than first-item-wins.
|
|
25
|
+
*
|
|
26
|
+
* @param {Array<object>} items
|
|
27
|
+
* @param {string} featureCode
|
|
28
|
+
* @returns {object|null}
|
|
29
|
+
*/
|
|
30
|
+
function matchFeatureItem(items, featureCode) {
|
|
31
|
+
return items.find(item => item.lifecycle?.featureCode === featureCode)
|
|
32
|
+
|| items.find(item => item.id === featureCode)
|
|
33
|
+
|| items.find(item => item.featureCode === featureCode)
|
|
34
|
+
|| null;
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
/** Canonical outcome normalization — maps legacy past-tense to imperative */
|
|
20
38
|
function normalizeOutcome(outcome) {
|
|
21
39
|
const map = { approved: 'approve', killed: 'kill', revised: 'revise' };
|
|
@@ -150,14 +168,15 @@ export class VisionWriter {
|
|
|
150
168
|
|
|
151
169
|
async _restFindFeatureItem(featureCode) {
|
|
152
170
|
const state = await this._fetch('/api/vision/items');
|
|
153
|
-
|
|
154
|
-
return items.find(item => item.lifecycle?.featureCode === featureCode) || null;
|
|
171
|
+
return matchFeatureItem(state.items || [], featureCode);
|
|
155
172
|
}
|
|
156
173
|
|
|
157
|
-
async _restEnsureFeatureItem(featureCode, title) {
|
|
174
|
+
async _restEnsureFeatureItem(featureCode, title, mode = 'feature') {
|
|
158
175
|
const existing = await this._restFindFeatureItem(featureCode);
|
|
159
176
|
if (existing) {
|
|
160
|
-
// Partial repair: item exists
|
|
177
|
+
// Partial repair: item exists (e.g. UI-created, matched by id or
|
|
178
|
+
// featureCode) but no lifecycle — start lifecycle so the next CLI run
|
|
179
|
+
// finds it by lifecycle.featureCode (#31).
|
|
161
180
|
if (!existing.lifecycle?.featureCode) {
|
|
162
181
|
await this._fetch(`/api/vision/items/${existing.id}/lifecycle/start`, {
|
|
163
182
|
method: 'POST',
|
|
@@ -169,7 +188,7 @@ export class VisionWriter {
|
|
|
169
188
|
const item = await this._fetch('/api/vision/items', {
|
|
170
189
|
method: 'POST',
|
|
171
190
|
body: JSON.stringify({
|
|
172
|
-
type: 'feature',
|
|
191
|
+
type: mode === 'bug' ? 'bug' : 'feature',
|
|
173
192
|
title: title || featureCode,
|
|
174
193
|
description: '',
|
|
175
194
|
status: 'planned',
|
|
@@ -238,20 +257,29 @@ export class VisionWriter {
|
|
|
238
257
|
|
|
239
258
|
_directFindFeatureItem(featureCode) {
|
|
240
259
|
const state = this._load();
|
|
241
|
-
return state.items
|
|
260
|
+
return matchFeatureItem(state.items, featureCode);
|
|
242
261
|
}
|
|
243
262
|
|
|
244
|
-
_directEnsureFeatureItem(featureCode, title) {
|
|
245
|
-
const existing = this._directFindFeatureItem(featureCode);
|
|
246
|
-
if (existing) return existing.id;
|
|
247
|
-
|
|
263
|
+
_directEnsureFeatureItem(featureCode, title, mode = 'feature') {
|
|
248
264
|
const state = this._load();
|
|
265
|
+
const existing = matchFeatureItem(state.items, featureCode);
|
|
266
|
+
if (existing) {
|
|
267
|
+
// Partial repair: a UI-created item matched by id/featureCode but has no
|
|
268
|
+
// lifecycle yet — seed lifecycle.featureCode so the next CLI run binds by
|
|
269
|
+
// the canonical key (#31).
|
|
270
|
+
if (!existing.lifecycle?.featureCode) {
|
|
271
|
+
existing.lifecycle = { ...(existing.lifecycle || {}), featureCode, currentPhase: existing.lifecycle?.currentPhase || 'explore_design' };
|
|
272
|
+
this._atomicWrite(state);
|
|
273
|
+
}
|
|
274
|
+
return existing.id;
|
|
275
|
+
}
|
|
276
|
+
|
|
249
277
|
// Derive group from featureCode (same logic as vision-store.js deriveGroup)
|
|
250
278
|
const groupMatch = (title || featureCode).match(/^([A-Z]+-[A-Z]+|[A-Z]+)(?=-\d)/);
|
|
251
279
|
const group = groupMatch ? groupMatch[1] : featureCode;
|
|
252
280
|
const item = {
|
|
253
281
|
id: crypto.randomUUID(),
|
|
254
|
-
type: 'feature',
|
|
282
|
+
type: mode === 'bug' ? 'bug' : 'feature',
|
|
255
283
|
title: title || featureCode,
|
|
256
284
|
description: '',
|
|
257
285
|
status: 'planned',
|
|
@@ -348,11 +376,11 @@ export class VisionWriter {
|
|
|
348
376
|
return this._directFindFeatureItem(featureCode);
|
|
349
377
|
}
|
|
350
378
|
|
|
351
|
-
async ensureFeatureItem(featureCode, title) {
|
|
379
|
+
async ensureFeatureItem(featureCode, title, mode = 'feature') {
|
|
352
380
|
if (await this._serverAvailable()) {
|
|
353
|
-
return this._restEnsureFeatureItem(featureCode, title);
|
|
381
|
+
return this._restEnsureFeatureItem(featureCode, title, mode);
|
|
354
382
|
}
|
|
355
|
-
return this._directEnsureFeatureItem(featureCode, title);
|
|
383
|
+
return this._directEnsureFeatureItem(featureCode, title, mode);
|
|
356
384
|
}
|
|
357
385
|
|
|
358
386
|
async updateItemStatus(itemId, status) {
|
package/lib/xref-sync.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xref-sync.js — COMP-ROADMAP-XREF-SYNC v1 (PULL reconciliation).
|
|
3
|
+
*
|
|
4
|
+
* Turns the read-only XREF_DRIFT warning (COMP-MCP-XREF-VALIDATE #16) into an
|
|
5
|
+
* applied fix: for every feature.json external link that carries an `expect=`,
|
|
6
|
+
* resolve the live target and rewrite `expect` to match reality. This is a
|
|
7
|
+
* PULL — it reconciles the LOCAL citation to the EXTERNAL truth and NEVER writes
|
|
8
|
+
* to an external system (closing a GitHub issue etc. is a separate, deliberate
|
|
9
|
+
* capability — see docs/features/COMP-ROADMAP-XREF-SYNC/design.md).
|
|
10
|
+
*
|
|
11
|
+
* Operates on the structured `links[].kind === 'external'` carrier (the
|
|
12
|
+
* post-migration source of truth), so it never rewrites markdown or perturbs the
|
|
13
|
+
* ROADMAP roundtrip fixed point. Resolution is injectable for testability.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readdirSync, existsSync, readFileSync, realpathSync } from 'fs';
|
|
17
|
+
import { join, resolve as resolvePath, dirname } from 'path';
|
|
18
|
+
import { writeFeature } from './feature-json.js';
|
|
19
|
+
import { loadFeaturesDir } from './project-paths.js';
|
|
20
|
+
|
|
21
|
+
const RESOLVABLE = new Set(['github', 'local']);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Pure reconciliation: should `ref.expect` be rewritten to `liveState`?
|
|
25
|
+
*
|
|
26
|
+
* @param {{expect: string|null}} ref
|
|
27
|
+
* @param {string|null} liveState resolved live state, or null if unresolved
|
|
28
|
+
* @returns {{changed: boolean, from?: string, to?: string}}
|
|
29
|
+
*/
|
|
30
|
+
export function reconcileExpect(ref, liveState) {
|
|
31
|
+
if (!ref.expect) return { changed: false }; // nothing to pull
|
|
32
|
+
if (liveState == null) return { changed: false }; // unresolved → leave as-is
|
|
33
|
+
if (ref.expect === liveState) return { changed: false };
|
|
34
|
+
return { changed: true, from: ref.expect, to: liveState };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a single external link to its live state using the same primitives as
|
|
39
|
+
* the validator. Returns { state } on success, { skipped, reason } on a degrade
|
|
40
|
+
* (offline / no-token / rate-limit / missing target), mirroring the read-only
|
|
41
|
+
* checker's per-ref degrade semantics — never guesses a state.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} link feature.json external link
|
|
44
|
+
* @param {string} cwd
|
|
45
|
+
* @param {string} featuresDir
|
|
46
|
+
*/
|
|
47
|
+
async function defaultResolve(link, cwd, featuresDir) {
|
|
48
|
+
if (link.provider === 'github') {
|
|
49
|
+
if (!link.repo || link.issue == null) return { skipped: true, reason: 'incomplete github ref' };
|
|
50
|
+
let GitHubApi;
|
|
51
|
+
try { ({ GitHubApi } = await import('./tracker/github-api.js')); }
|
|
52
|
+
catch (e) { return { skipped: true, reason: `github client unavailable: ${e.message}` }; }
|
|
53
|
+
let gh;
|
|
54
|
+
try {
|
|
55
|
+
// auth.token from env if present, else the client falls back to `gh auth token`.
|
|
56
|
+
gh = new GitHubApi({ repo: link.repo, auth: { token: process.env.GITHUB_TOKEN || process.env.GH_TOKEN } });
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return { skipped: true, reason: (e && e.message) || 'no GitHub auth' };
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const r = await gh.getIssueResult(link.issue);
|
|
62
|
+
if (r.status === 404) return { skipped: true, reason: `target ${link.repo}#${link.issue} missing (404)` };
|
|
63
|
+
if (r.status < 200 || r.status >= 300) return { skipped: true, reason: `HTTP ${r.status}` };
|
|
64
|
+
const state = r.body && r.body.state;
|
|
65
|
+
if (state !== 'open' && state !== 'closed') return { skipped: true, reason: 'no parseable issue state' };
|
|
66
|
+
return { state };
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return { skipped: true, reason: e && e.rateLimit ? 'rate limit' : (e && e.message) || 'resolution error' };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (link.provider === 'local') {
|
|
72
|
+
// The target feature lives in a sibling repo; its status is the live state.
|
|
73
|
+
if (!link.repo || !link.to_code) return { skipped: true, reason: 'incomplete local ref' };
|
|
74
|
+
// Containment guard (parity with feature-validator resolveLocalRef): the
|
|
75
|
+
// repo token must resolve to a DIRECT sibling of cwd — lexical check first,
|
|
76
|
+
// then realpath to defeat a valid-named sibling symlinked outside the parent.
|
|
77
|
+
const parentDir = resolvePath(cwd, '..');
|
|
78
|
+
const citedRoot = resolvePath(parentDir, String(link.repo));
|
|
79
|
+
if (/[\\/]/.test(link.repo) || link.repo === '.' || link.repo === '..'
|
|
80
|
+
|| dirname(citedRoot) !== parentDir) {
|
|
81
|
+
return { skipped: true, reason: `local repo token "${link.repo}" is not a valid sibling` };
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
if (dirname(realpathSync(citedRoot)) !== realpathSync(parentDir)) {
|
|
85
|
+
return { skipped: true, reason: `local repo "${link.repo}" escapes the workspace parent` };
|
|
86
|
+
}
|
|
87
|
+
} catch { return { skipped: true, reason: `local target ${link.repo} not found` }; }
|
|
88
|
+
// Resolve the SIBLING's own features dir (it may have its own paths.features).
|
|
89
|
+
try {
|
|
90
|
+
const fjPath = join(citedRoot, loadFeaturesDir(citedRoot), link.to_code, 'feature.json');
|
|
91
|
+
if (!existsSync(fjPath)) return { skipped: true, reason: `local target ${link.repo}/${link.to_code} not found` };
|
|
92
|
+
return { state: JSON.parse(readFileSync(fjPath, 'utf8')).status || null };
|
|
93
|
+
} catch (e) { return { skipped: true, reason: `unreadable local target: ${e.message}` }; }
|
|
94
|
+
}
|
|
95
|
+
return { skipped: true, reason: `unresolvable provider: ${link.provider}` };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pull-reconcile every feature.json external link's `expect` to live target state.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} cwd
|
|
102
|
+
* @param {object} [opts]
|
|
103
|
+
* @param {boolean} [opts.dryRun] report changes without writing
|
|
104
|
+
* @param {string} [opts.featuresDir]
|
|
105
|
+
* @param {(link: object, cwd: string, featuresDir: string) => Promise<{state?: string|null, skipped?: boolean, reason?: string}>} [opts.resolve]
|
|
106
|
+
* injectable resolver (defaults to github-api + local feature.json)
|
|
107
|
+
* @returns {Promise<{synced: Array, skipped: Array, unchanged: number, scanned: number}>}
|
|
108
|
+
*/
|
|
109
|
+
export async function syncExternalRefs(cwd, opts = {}) {
|
|
110
|
+
const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
|
|
111
|
+
const resolve = opts.resolve ?? defaultResolve;
|
|
112
|
+
const dir = join(cwd, featuresDir);
|
|
113
|
+
|
|
114
|
+
const synced = [];
|
|
115
|
+
const skipped = [];
|
|
116
|
+
let unchanged = 0;
|
|
117
|
+
let scanned = 0;
|
|
118
|
+
|
|
119
|
+
if (!existsSync(dir)) return { synced, skipped, unchanged, scanned };
|
|
120
|
+
|
|
121
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
122
|
+
if (!entry.isDirectory()) continue;
|
|
123
|
+
const fjPath = join(dir, entry.name, 'feature.json');
|
|
124
|
+
if (!existsSync(fjPath)) continue;
|
|
125
|
+
let fj;
|
|
126
|
+
try { fj = JSON.parse(readFileSync(fjPath, 'utf8')); } catch { continue; }
|
|
127
|
+
if (!Array.isArray(fj.links)) continue;
|
|
128
|
+
|
|
129
|
+
let mutated = false;
|
|
130
|
+
for (const link of fj.links) {
|
|
131
|
+
if (!link || link.kind !== 'external') continue;
|
|
132
|
+
// Only resolvable providers that carry an explicit expectation can drift.
|
|
133
|
+
if (!RESOLVABLE.has(link.provider) || !link.expect) continue;
|
|
134
|
+
scanned++;
|
|
135
|
+
|
|
136
|
+
const r = await resolve(link, cwd, featuresDir);
|
|
137
|
+
if (r.skipped) {
|
|
138
|
+
skipped.push({ code: fj.code, provider: link.provider, target: targetLabel(link), reason: r.reason });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const verdict = reconcileExpect(link, r.state ?? null);
|
|
142
|
+
if (!verdict.changed) { unchanged++; continue; }
|
|
143
|
+
|
|
144
|
+
synced.push({ code: fj.code, provider: link.provider, target: targetLabel(link), from: verdict.from, to: verdict.to });
|
|
145
|
+
if (!dryRun(opts)) { link.expect = verdict.to; mutated = true; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (mutated && !dryRun(opts)) writeFeature(cwd, fj, featuresDir);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { synced, skipped, unchanged, scanned };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function dryRun(opts) { return opts.dryRun === true; }
|
|
155
|
+
|
|
156
|
+
function targetLabel(link) {
|
|
157
|
+
if (link.provider === 'github') return `${link.repo}#${link.issue}`;
|
|
158
|
+
if (link.provider === 'local') return `${link.repo}/${link.to_code}`;
|
|
159
|
+
return link.url || '';
|
|
160
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/compose",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1-beta",
|
|
4
4
|
"description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
|
|
5
5
|
"author": "SmartMemory",
|
|
6
6
|
"license": "MIT",
|
package/server/compose-mcp.js
CHANGED
|
@@ -316,6 +316,7 @@ const TOOLS = [
|
|
|
316
316
|
position: { type: 'number', description: 'Sort order within phase' },
|
|
317
317
|
parent: { type: 'string', description: 'Parent feature code, for cross-references' },
|
|
318
318
|
tags: { type: 'array', items: { type: 'string' } },
|
|
319
|
+
force: { type: 'boolean', description: 'Bypass the pre-commit roundtrip guard (commit even if ROADMAP.md would not be a generation fixed point).' },
|
|
319
320
|
idempotency_key: { type: 'string', description: 'Optional caller-provided key. Same key replays return the cached result without re-mutating.' },
|
|
320
321
|
},
|
|
321
322
|
},
|
|
@@ -331,7 +332,7 @@ const TOOLS = [
|
|
|
331
332
|
status: { type: 'string', enum: ['PLANNED', 'IN_PROGRESS', 'PARTIAL', 'COMPLETE', 'BLOCKED', 'KILLED', 'PARKED', 'SUPERSEDED'] },
|
|
332
333
|
reason: { type: 'string', description: 'Free-form reason persisted in the audit event' },
|
|
333
334
|
commit_sha: { type: 'string', description: 'Optional commit binding' },
|
|
334
|
-
force: { type: 'boolean', description: 'Bypass the transition policy. Recorded in audit.' },
|
|
335
|
+
force: { type: 'boolean', description: 'Bypass the transition policy AND the roundtrip fixed-point guard (commits even if ROADMAP.md would not be a generation fixed point). Recorded in audit.' },
|
|
335
336
|
idempotency_key: { type: 'string' },
|
|
336
337
|
},
|
|
337
338
|
},
|
package/server/vision-store.js
CHANGED
|
@@ -7,7 +7,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
|
|
10
|
-
export const VALID_TYPES = ['feature', 'track', 'idea', 'decision', 'question', 'thread', 'artifact', 'task', 'spec', 'evaluation'];
|
|
10
|
+
export const VALID_TYPES = ['feature', 'bug', 'track', 'idea', 'decision', 'question', 'thread', 'artifact', 'task', 'spec', 'evaluation'];
|
|
11
11
|
export const VALID_STATUSES = ['planned', 'ready', 'in_progress', 'review', 'complete', 'blocked', 'parked', 'killed'];
|
|
12
12
|
export const VALID_CONNECTION_TYPES = ['informs', 'blocks', 'supports', 'contradicts', 'implements'];
|
|
13
13
|
export const VALID_PHASES = ['vision', 'specification', 'planning', 'implementation', 'verification', 'release'];
|