@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
@@ -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
+ }
@@ -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
- const items = state.items || [];
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 but no lifecycle start lifecycle
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.find(item => item.lifecycle?.featureCode === featureCode) || null;
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) {
@@ -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.44-beta",
3
+ "version": "0.2.0",
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",
@@ -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
  },
@@ -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'];