@smartmemory/compose 0.2.48-beta → 0.2.50-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.
Files changed (96) hide show
  1. package/.claude/skills/compose/SKILL.md +2 -0
  2. package/.compose-deps.json +13 -0
  3. package/bin/compose.js +35 -20
  4. package/dist/assets/{App-D7E7S49Q.js → App-Bxyif1yv.js} +160 -160
  5. package/dist/assets/{arc-LOW60tiI.js → arc-DeHak63Z.js} +1 -1
  6. package/dist/assets/{architectureDiagram-3BPJPVTR-BDY00hRy.js → architectureDiagram-3BPJPVTR-C5Nkyl9y.js} +1 -1
  7. package/dist/assets/{blockDiagram-GPEHLZMM-DiC_6ktq.js → blockDiagram-GPEHLZMM-BilnUBG2.js} +1 -1
  8. package/dist/assets/{c4Diagram-AAUBKEIU-CXZC8woU.js → c4Diagram-AAUBKEIU-BsFJmPuS.js} +1 -1
  9. package/dist/assets/channel-ZgbQ1k0u.js +1 -0
  10. package/dist/assets/{chunk-2J33WTMH-b8Rdk4S-.js → chunk-2J33WTMH-AEu6HIoY.js} +1 -1
  11. package/dist/assets/{chunk-4BX2VUAB-BSxn03f-.js → chunk-4BX2VUAB-XO4S2_89.js} +1 -1
  12. package/dist/assets/{chunk-55IACEB6-uUk1WzZ1.js → chunk-55IACEB6-CtFSLInj.js} +1 -1
  13. package/dist/assets/{chunk-727SXJPM-zvXhT6iZ.js → chunk-727SXJPM-CZeBRpM9.js} +1 -1
  14. package/dist/assets/{chunk-AQP2D5EJ-B6GCyrIp.js → chunk-AQP2D5EJ-_vmxcBc4.js} +1 -1
  15. package/dist/assets/{chunk-FMBD7UC4-DlhrqkBp.js → chunk-FMBD7UC4-BMGd_Y9a.js} +1 -1
  16. package/dist/assets/{chunk-ND2GUHAM-lo_8kNzw.js → chunk-ND2GUHAM-BlrOECEr.js} +1 -1
  17. package/dist/assets/{chunk-QZHKN3VN-on-yD-C0.js → chunk-QZHKN3VN-DHhUJ8RK.js} +1 -1
  18. package/dist/assets/classDiagram-4FO5ZUOK-KXxOorUx.js +1 -0
  19. package/dist/assets/classDiagram-v2-Q7XG4LA2-KXxOorUx.js +1 -0
  20. package/dist/assets/{cose-bilkent-S5V4N54A-DxV7Y2bU.js → cose-bilkent-S5V4N54A-C3M0mZqk.js} +1 -1
  21. package/dist/assets/{dagre-BM42HDAG-BHf9sURy.js → dagre-BM42HDAG-DJlm4fEk.js} +1 -1
  22. package/dist/assets/{diagram-2AECGRRQ-R9Puxzlh.js → diagram-2AECGRRQ-BmcONQG5.js} +1 -1
  23. package/dist/assets/{diagram-5GNKFQAL-BbjlClKx.js → diagram-5GNKFQAL-5JbWGIwd.js} +1 -1
  24. package/dist/assets/{diagram-KO2AKTUF-r89Mx4m1.js → diagram-KO2AKTUF-DAnIvyI0.js} +1 -1
  25. package/dist/assets/{diagram-LMA3HP47-CstVDJZ3.js → diagram-LMA3HP47-Bmh7tvmI.js} +1 -1
  26. package/dist/assets/{diagram-OG6HWLK6-xmX57FKh.js → diagram-OG6HWLK6-hnHVMNnk.js} +1 -1
  27. package/dist/assets/{erDiagram-TEJ5UH35-DhLPxvrl.js → erDiagram-TEJ5UH35-AMDitV0b.js} +1 -1
  28. package/dist/assets/{flowDiagram-I6XJVG4X-BExoUaNm.js → flowDiagram-I6XJVG4X-C0t2ZGQv.js} +1 -1
  29. package/dist/assets/{ganttDiagram-6RSMTGT7-BEXa5a22.js → ganttDiagram-6RSMTGT7-BPlM-BEN.js} +1 -1
  30. package/dist/assets/{gitGraphDiagram-PVQCEYII-Cbes9api.js → gitGraphDiagram-PVQCEYII-Lv_YTxnb.js} +1 -1
  31. package/dist/assets/index-COq21Zym.js +119 -0
  32. package/dist/assets/{infoDiagram-5YYISTIA-UBm4ssfm.js → infoDiagram-5YYISTIA-Dul1vdUm.js} +1 -1
  33. package/dist/assets/{ishikawaDiagram-YF4QCWOH-CksBYHCF.js → ishikawaDiagram-YF4QCWOH-CCBwgjFs.js} +1 -1
  34. package/dist/assets/{journeyDiagram-JHISSGLW-CKHkIHky.js → journeyDiagram-JHISSGLW-zUJN38EU.js} +1 -1
  35. package/dist/assets/{kanban-definition-UN3LZRKU-CEahMFzE.js → kanban-definition-UN3LZRKU-BNxWi-8J.js} +1 -1
  36. package/dist/assets/{linear-CqLVtYRk.js → linear-CRH9b4g7.js} +1 -1
  37. package/dist/assets/{mindmap-definition-RKZ34NQL-C61zWt4M.js → mindmap-definition-RKZ34NQL-DolYwq7Y.js} +1 -1
  38. package/dist/assets/{pieDiagram-4H26LBE5-BIpZHyJW.js → pieDiagram-4H26LBE5-oxNfr2TX.js} +1 -1
  39. package/dist/assets/{quadrantDiagram-W4KKPZXB-Db7nRbZm.js → quadrantDiagram-W4KKPZXB-BmZCvD-z.js} +1 -1
  40. package/dist/assets/{requirementDiagram-4Y6WPE33-BQqFCAr0.js → requirementDiagram-4Y6WPE33-DsXZ05jo.js} +1 -1
  41. package/dist/assets/{sankeyDiagram-5OEKKPKP-D-oAAMci.js → sankeyDiagram-5OEKKPKP-DsnYaawP.js} +1 -1
  42. package/dist/assets/{sequenceDiagram-3UESZ5HK-Dc4Cp0Om.js → sequenceDiagram-3UESZ5HK-Dt5mu3g8.js} +1 -1
  43. package/dist/assets/{stateDiagram-AJRCARHV-B5DkQ8Pr.js → stateDiagram-AJRCARHV-BFR5ZINQ.js} +1 -1
  44. package/dist/assets/stateDiagram-v2-BHNVJYJU-BDrQD8fR.js +1 -0
  45. package/dist/assets/{timeline-definition-PNZ67QCA-DU9Fl-FW.js → timeline-definition-PNZ67QCA-Bvnhw58e.js} +1 -1
  46. package/dist/assets/{vennDiagram-CIIHVFJN-Bthz7siw.js → vennDiagram-CIIHVFJN-5WnyjZqf.js} +1 -1
  47. package/dist/assets/{wardley-L42UT6IY-nqdlFSIA.js → wardley-L42UT6IY-CTxW4xow.js} +1 -1
  48. package/dist/assets/{wardleyDiagram-YWT4CUSO-BJ1BC5zc.js → wardleyDiagram-YWT4CUSO-K8Y1EOvb.js} +1 -1
  49. package/dist/assets/{xychartDiagram-2RQKCTM6-Bib5JrbR.js → xychartDiagram-2RQKCTM6-BCShes6B.js} +1 -1
  50. package/dist/index.html +1 -1
  51. package/lib/build-all.js +2 -1
  52. package/lib/build.js +96 -6
  53. package/lib/checkpoint/checkpoint-writer.js +3 -2
  54. package/lib/completion-writer.js +38 -14
  55. package/lib/deps.js +98 -1
  56. package/lib/feature-json.js +14 -3
  57. package/lib/feature-validator.js +22 -9
  58. package/lib/feature-write-guard.js +5 -7
  59. package/lib/feature-writer.js +3 -3
  60. package/lib/followup-writer.js +2 -2
  61. package/lib/get-roadmap.js +2 -2
  62. package/lib/gsd.js +2 -1
  63. package/lib/ideabox.js +3 -2
  64. package/lib/journal-writer.js +3 -3
  65. package/lib/migrate-roadmap.js +2 -3
  66. package/lib/paths-core.js +47 -0
  67. package/lib/project-paths.js +46 -37
  68. package/lib/roadmap-gen.js +3 -4
  69. package/lib/roadmap-graph/collect.js +5 -5
  70. package/lib/roadmap-graph/index.js +2 -2
  71. package/lib/rtk.js +66 -0
  72. package/lib/state-migrations.js +211 -42
  73. package/lib/tracker/local-provider.js +2 -2
  74. package/lib/triage.js +7 -5
  75. package/lib/vision-writer.js +6 -13
  76. package/lib/xref-push.js +4 -4
  77. package/lib/xref-sync.js +4 -4
  78. package/package.json +1 -1
  79. package/server/compose-mcp-tools.js +9 -1
  80. package/server/design-routes.js +9 -4
  81. package/server/drift-axes.js +5 -3
  82. package/server/feature-scan.js +8 -2
  83. package/server/file-watcher.js +13 -7
  84. package/server/ideabox-routes.js +12 -11
  85. package/server/project-root.js +21 -4
  86. package/server/session-routes.js +2 -2
  87. package/server/stratum-sync.js +85 -9
  88. package/server/vision-routes.js +17 -2
  89. package/server/vision-server.js +4 -3
  90. package/server/vision-store.js +6 -11
  91. package/server/vision-utils.js +23 -4
  92. package/dist/assets/channel-Dh96pu1k.js +0 -1
  93. package/dist/assets/classDiagram-4FO5ZUOK-BCqpDcaS.js +0 -1
  94. package/dist/assets/classDiagram-v2-Q7XG4LA2-BCqpDcaS.js +0 -1
  95. package/dist/assets/index-DNuHtZwR.js +0 -123
  96. package/dist/assets/stateDiagram-v2-BHNVJYJU-BM1Uf6a_.js +0 -1
@@ -27,7 +27,7 @@ import {
27
27
  renameSync, unlinkSync, readdirSync,
28
28
  } from 'fs';
29
29
  import { join } from 'path';
30
- import { loadFeaturesDir } from './project-paths.js';
30
+ import { resolveFeaturesPath } from './project-paths.js';
31
31
  import { writeFeature } from './feature-json.js';
32
32
 
33
33
  /**
@@ -48,6 +48,7 @@ export const MIGRATIONS = [
48
48
  {
49
49
  version: 1,
50
50
  id: 'normalize-complexity',
51
+ target: 'feature',
51
52
  describe: 'Normalize legacy free-text complexity to the S/M/L/XL enum; drop null/unmappable (fails the string|number oneOf)',
52
53
  migrateFeature(f) {
53
54
  // Total on any parseable JSON value: a non-object (scalar/array/null)
@@ -68,8 +69,100 @@ export const MIGRATIONS = [
68
69
  return { changed: true, feature: rest };
69
70
  },
70
71
  },
72
+ {
73
+ version: 2,
74
+ id: 'normalize-vision-legacy',
75
+ target: 'vision',
76
+ describe: 'Vision-state: legacy `featureCode: "feature:X"` → `lifecycle.featureCode` + normalize legacy gate outcomes to the imperative enum',
77
+ migrateState(state) { return migrateVisionState(state); },
78
+ },
71
79
  ];
72
80
 
81
+ // ---------------------------------------------------------------------------
82
+ // COMP-MIGRATE-UNIFY-VISION — shared vision-state transforms.
83
+ //
84
+ // These two transforms were historically inlined AND duplicated in
85
+ // server/vision-store.js and lib/vision-writer.js, run lazily on every
86
+ // vision-state load. They are folded here as the single implementation, reused
87
+ // by both:
88
+ // - the load-time paths (vision-store._load / vision-writer._load), and
89
+ // - the eager runner below — so cold vision-state the server never loads
90
+ // (e.g. a frozen forge-top store) is migrated by `compose migrate-state`.
91
+ //
92
+ // Scope is EXACTLY these two transforms. Each file's other load-time work —
93
+ // gate/pending dedup, slug/files/group derivation — is deliberately NOT folded
94
+ // here (it differs per call site and is out of this consolidation's scope).
95
+ //
96
+ // They MUTATE their argument in place (and report `changed`) by design: the
97
+ // on-disk byte image must stay identical to the previous inline behavior, which
98
+ // mutated in place, so key-insertion order is preserved exactly. They are
99
+ // otherwise pure (no I/O) and idempotent.
100
+ // ---------------------------------------------------------------------------
101
+
102
+ const GATE_OUTCOME_MAP = { approved: 'approve', killed: 'kill', revised: 'revise' };
103
+
104
+ /** Normalize a legacy past-tense gate outcome to the imperative enum. Pure/total/idempotent. */
105
+ export function normalizeGateOutcome(outcome) {
106
+ return GATE_OUTCOME_MAP[outcome] || outcome;
107
+ }
108
+
109
+ /**
110
+ * Migrate one vision item's legacy `featureCode: "feature:X"` to
111
+ * `lifecycle.featureCode: "X"`. Mutates `item` in place (for byte-identity with
112
+ * the prior inline code) and reports whether anything changed. No-op when the
113
+ * binding is already in lifecycle form or the field is absent.
114
+ *
115
+ * PURE + TOTAL (the registry invariant): never throws on parseable JSON. The
116
+ * `typeof === 'string'` guard is deliberate — the prior inline loaders called
117
+ * `.startsWith` unguarded and would THROW on a malformed truthy non-string
118
+ * `featureCode` (a load-path crash → fresh-state fallback). On real corpora every
119
+ * `featureCode` is a string-or-absent, so the on-disk output is byte-identical;
120
+ * the only divergence is that pathological non-string values now no-op instead of
121
+ * crashing, which is required so the eager runner (no per-item try/catch) stays
122
+ * total. Out of scope to "fix" such malformed data here — this only consolidates.
123
+ * @param {object} item
124
+ * @returns {{changed: boolean}}
125
+ */
126
+ export function migrateVisionItemFeatureCode(item) {
127
+ if (!item || typeof item !== 'object') return { changed: false };
128
+ if (item.featureCode && typeof item.featureCode === 'string'
129
+ && item.featureCode.startsWith('feature:') && !item.lifecycle?.featureCode) {
130
+ const bare = item.featureCode.replace(/^feature:/, '');
131
+ item.lifecycle = item.lifecycle || {};
132
+ item.lifecycle.featureCode = bare;
133
+ delete item.featureCode;
134
+ return { changed: true };
135
+ }
136
+ return { changed: false };
137
+ }
138
+
139
+ /**
140
+ * Apply the legacy vision-state transforms across a whole parsed vision-state
141
+ * object: `featureCode→lifecycle.featureCode` over `items[]` and gate-outcome
142
+ * normalization over `gates[]`. Mutates in place and also returns the object so
143
+ * either the return value or the original reference may be used. Total on any
144
+ * parseable shape (missing/non-array items/gates are skipped).
145
+ * @param {object} state
146
+ * @returns {{changed: boolean, state: object}}
147
+ */
148
+ export function migrateVisionState(state) {
149
+ let changed = false;
150
+ if (state && Array.isArray(state.items)) {
151
+ for (const item of state.items) {
152
+ if (migrateVisionItemFeatureCode(item).changed) changed = true;
153
+ }
154
+ }
155
+ if (state && Array.isArray(state.gates)) {
156
+ for (const gate of state.gates) {
157
+ if (gate && gate.outcome) {
158
+ const normalized = normalizeGateOutcome(gate.outcome);
159
+ if (normalized !== gate.outcome) { gate.outcome = normalized; changed = true; }
160
+ }
161
+ }
162
+ }
163
+ return { changed, state };
164
+ }
165
+
73
166
  function stateFilePath(cwd) {
74
167
  return join(cwd, '.compose', 'data', 'migration-state.json');
75
168
  }
@@ -104,6 +197,24 @@ function writeMigrationStateAtomic(cwd, state) {
104
197
  }
105
198
  }
106
199
 
200
+ /**
201
+ * Atomically rewrite `.compose/data/vision-state.json`, byte-for-byte matching
202
+ * VisionWriter._atomicWrite (2-space JSON + trailing newline, temp + rename).
203
+ */
204
+ function writeVisionStateAtomic(cwd, state) {
205
+ const dir = join(cwd, '.compose', 'data');
206
+ mkdirSync(dir, { recursive: true });
207
+ const p = join(dir, 'vision-state.json');
208
+ const tmp = `${p}.tmp.${process.pid}`;
209
+ try {
210
+ writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n');
211
+ renameSync(tmp, p);
212
+ } catch (err) {
213
+ try { unlinkSync(tmp); } catch { /* tmp may not exist */ }
214
+ throw err;
215
+ }
216
+ }
217
+
107
218
  /**
108
219
  * Tracker classification from `.compose/compose.json`:
109
220
  * 'local' — tracker absent/null or provider==='local'
@@ -124,16 +235,20 @@ function trackerKind(cwd) {
124
235
  }
125
236
 
126
237
  /**
127
- * Run pending feature.json state migrations for the workspace at `cwd`.
238
+ * Run pending state migrations for the workspace at `cwd`. Feature-target
239
+ * migrations walk every feature.json; vision-target migrations walk the single
240
+ * `.compose/data/vision-state.json` (COMP-MIGRATE-UNIFY-VISION) using the same
241
+ * pure transforms the server's load-time path uses. One shared `stateVersion`
242
+ * stamp covers both.
128
243
  *
129
244
  * @param {string} cwd - workspace root
130
245
  * @param {{dryRun?: boolean}} [opts]
131
246
  * @returns {object} report — one of:
132
- * {skipped:'no-workspace'|'non-local-tracker'}
247
+ * {skipped:'no-workspace'|'non-local-tracker'|'unreadable-config'}
133
248
  * {from, to, dryRun, noop:true, perMigration:[], parseErrors:[]}
134
- * {from, to, dryRun, perMigration:[{id,version,touched:[]}], parseErrors:[{path,message}]}
135
- * @throws if a migrateFeature transform throws (migration-code bug) — aborts
136
- * WITHOUT advancing the stamp.
249
+ * {from, to, dryRun, perMigration:[{id,version,target,touched:[]}], parseErrors:[{path,message}]}
250
+ * @throws if a migrate transform throws (migration-code bug) — aborts WITHOUT
251
+ * advancing the stamp.
137
252
  */
138
253
  export function runStateMigrations(cwd, opts = {}) {
139
254
  const dryRun = !!opts.dryRun;
@@ -155,43 +270,82 @@ export function runStateMigrations(cwd, opts = {}) {
155
270
  return { from, to: from, dryRun, noop: true, perMigration: [], parseErrors: [] };
156
271
  }
157
272
 
158
- const featuresDir = loadFeaturesDir(cwd); // honors paths.features override
159
- const featuresRoot = join(cwd, featuresDir);
160
- const perMigration = pending.map((m) => ({ id: m.id, version: m.version, touched: [] }));
161
- const parseErrors = [];
273
+ // Migrations dispatch by `target` (default 'feature'): feature-target ones
274
+ // walk every feature.json; vision-target ones walk the single vision-state.json.
275
+ const featurePending = pending.filter((m) => (m.target || 'feature') === 'feature');
276
+ const visionPending = pending.filter((m) => m.target === 'vision');
162
277
 
163
- // Own directory walk — do NOT use listFeatures(): it silently skips unreadable
164
- // files, which would hide exactly the cold-data corruption we must surface.
165
- let dirs = [];
166
- try {
167
- dirs = readdirSync(featuresRoot, { withFileTypes: true })
168
- .filter((e) => e.isDirectory())
169
- .map((e) => e.name);
170
- } catch {
171
- dirs = []; // features dir may not exist yet
172
- }
278
+ const perMigration = pending.map((m) => ({
279
+ id: m.id, version: m.version, target: m.target || 'feature', touched: [],
280
+ }));
281
+ const perMigrationByVersion = new Map(perMigration.map((p) => [p.version, p]));
282
+ const parseErrors = [];
173
283
 
174
- for (const code of dirs) {
175
- const fpath = join(featuresRoot, code, 'feature.json');
176
- if (!existsSync(fpath)) continue;
177
- let feature;
284
+ // --- feature.json walk -----------------------------------------------------
285
+ if (featurePending.length > 0) {
286
+ const featuresDir = resolveFeaturesPath(cwd); // absolute; honors paths.features override
287
+ const featuresRoot = featuresDir;
288
+ // Own directory walk — do NOT use listFeatures(): it silently skips unreadable
289
+ // files, which would hide exactly the cold-data corruption we must surface.
290
+ let dirs = [];
178
291
  try {
179
- feature = JSON.parse(readFileSync(fpath, 'utf-8'));
180
- } catch (err) {
181
- parseErrors.push({ path: fpath, message: err.message });
182
- continue;
292
+ dirs = readdirSync(featuresRoot, { withFileTypes: true })
293
+ .filter((e) => e.isDirectory())
294
+ .map((e) => e.name);
295
+ } catch {
296
+ dirs = []; // features dir may not exist yet
183
297
  }
184
- let changedAny = false;
185
- pending.forEach((m, i) => {
186
- const res = m.migrateFeature(feature); // pure/total; a throw = migration-code bug → fail-fast
187
- if (res.changed) {
188
- feature = res.feature;
189
- changedAny = true;
190
- perMigration[i].touched.push(feature.code || code);
298
+
299
+ for (const code of dirs) {
300
+ const fpath = join(featuresRoot, code, 'feature.json');
301
+ if (!existsSync(fpath)) continue;
302
+ let feature;
303
+ try {
304
+ feature = JSON.parse(readFileSync(fpath, 'utf-8'));
305
+ } catch (err) {
306
+ parseErrors.push({ path: fpath, message: err.message });
307
+ continue;
308
+ }
309
+ let changedAny = false;
310
+ for (const m of featurePending) {
311
+ const res = m.migrateFeature(feature); // pure/total; a throw = migration-code bug → fail-fast
312
+ if (res.changed) {
313
+ feature = res.feature;
314
+ changedAny = true;
315
+ perMigrationByVersion.get(m.version).touched.push(feature.code || code);
316
+ }
317
+ }
318
+ if (changedAny && !dryRun) {
319
+ writeFeature(cwd, feature, featuresDir, { validate: false });
320
+ }
321
+ }
322
+ }
323
+
324
+ // --- vision-state.json walk (COMP-MIGRATE-UNIFY-VISION) ---------------------
325
+ // Eagerly migrate cold vision-state the running server never loaded. Same pure
326
+ // transforms the load-time paths use, so the result is byte-identical.
327
+ if (visionPending.length > 0) {
328
+ const vpath = join(cwd, '.compose', 'data', 'vision-state.json');
329
+ if (existsSync(vpath)) {
330
+ let state = null;
331
+ try {
332
+ state = JSON.parse(readFileSync(vpath, 'utf-8'));
333
+ } catch (err) {
334
+ parseErrors.push({ path: vpath, message: err.message }); // reported, never blocks the stamp
335
+ }
336
+ if (state) {
337
+ let changedAny = false;
338
+ for (const m of visionPending) {
339
+ const res = m.migrateState(state); // mutates in place; throw = migration-code bug → fail-fast
340
+ if (res.changed) {
341
+ changedAny = true;
342
+ perMigrationByVersion.get(m.version).touched.push('vision-state.json');
343
+ }
344
+ }
345
+ if (changedAny && !dryRun) {
346
+ writeVisionStateAtomic(cwd, state);
347
+ }
191
348
  }
192
- });
193
- if (changedAny && !dryRun) {
194
- writeFeature(cwd, feature, featuresDir, { validate: false });
195
349
  }
196
350
  }
197
351
 
@@ -209,9 +363,24 @@ export function runStateMigrations(cwd, opts = {}) {
209
363
  export function summarizeMigrationReport(report) {
210
364
  if (!report || report.skipped) return null;
211
365
  if (report.noop) return `state up to date (v${report.to})`;
212
- const touched = report.perMigration.reduce((n, m) => n + m.touched.length, 0);
213
- const errs = report.parseErrors.length;
214
366
  const dry = report.dryRun ? ' (dry-run)' : '';
215
- return `migrated ${touched} feature.json across ${report.perMigration.length} migration(s) `
216
- + `→ stateVersion ${report.to}${errs ? `, ${errs} unparseable (reported)` : ''}${dry}`;
367
+ const errs = report.parseErrors.length;
368
+ const errClause = errs ? `, ${errs} unparseable (reported)` : '';
369
+
370
+ // Feature migrations report touched feature.json files; vision migrations
371
+ // touch the single vision-state.json. Absent `target` ⇒ 'feature' (back-compat
372
+ // with the COMP-MIGRATE-ON-UPGRADE report shape).
373
+ const feature = report.perMigration.filter((m) => (m.target || 'feature') === 'feature');
374
+ const vision = report.perMigration.filter((m) => m.target === 'vision');
375
+ const parts = [];
376
+ if (feature.length) {
377
+ const touched = feature.reduce((n, m) => n + m.touched.length, 0);
378
+ parts.push(`migrated ${touched} feature.json across ${feature.length} migration(s)`);
379
+ }
380
+ if (vision.length) {
381
+ const touched = vision.reduce((n, m) => n + m.touched.length, 0);
382
+ parts.push(`vision-state ${touched ? 'migrated' : 'up to date'} across ${vision.length} migration(s)`);
383
+ }
384
+ if (parts.length === 0) parts.push('no migrations applied');
385
+ return `${parts.join('; ')} → stateVersion ${report.to}${errClause}${dry}`;
217
386
  }
@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameS
2
2
  import { join, dirname } from 'path';
3
3
 
4
4
  import { readFeature, listFeatures as listFeaturesRaw, writeFeature } from '../feature-json.js';
5
- import { loadFeaturesDir } from '../project-paths.js';
5
+ import { resolveFeaturesPath } from '../project-paths.js';
6
6
  import { TrackerProvider, CAP } from './provider.js';
7
7
 
8
8
  import { setFeatureStatus, addRoadmapEntry as addRoadmapEntryRaw } from '../feature-writer.js';
@@ -38,7 +38,7 @@ export class LocalFileProvider extends TrackerProvider {
38
38
 
39
39
  async init(cwd) {
40
40
  this.cwd = cwd;
41
- this.featuresDir = loadFeaturesDir(cwd);
41
+ this.featuresDir = resolveFeaturesPath(cwd);
42
42
  return this;
43
43
  }
44
44
 
package/lib/triage.js CHANGED
@@ -11,6 +11,8 @@
11
11
 
12
12
  import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
+ import { resolveFeaturesPath } from './project-paths.js';
15
+ import { resolvePathValue } from './paths-core.js';
14
16
 
15
17
  // ---------------------------------------------------------------------------
16
18
  // Tier definitions
@@ -185,8 +187,8 @@ function deriveProfile(signals) {
185
187
  */
186
188
  export async function runTriage(featureCode, opts = {}) {
187
189
  const cwd = opts.cwd ?? process.cwd();
188
- const featuresDir = opts.featuresDir ?? 'docs/features';
189
- const featureDir = join(cwd, featuresDir, featureCode);
190
+ const featuresDir = opts.featuresDir ?? resolveFeaturesPath(cwd);
191
+ const featureDir = join(resolvePathValue(cwd, featuresDir, 'features'), featureCode);
190
192
 
191
193
  // Collect content from key files
192
194
  const candidateFiles = ['plan.md', 'blueprint.md', 'design.md', 'prd.md', 'architecture.md'];
@@ -234,11 +236,11 @@ export async function runTriage(featureCode, opts = {}) {
234
236
  *
235
237
  * @param {string} cwd - Project root
236
238
  * @param {string} featureCode - Feature code
237
- * @param {string} [featuresDir] - Relative path to features dir (default: docs/features)
239
+ * @param {string} [featuresDir] - Path to features dir (absolute or relative; default: resolved features path)
238
240
  * @returns {boolean}
239
241
  */
240
- export function isTriageStale(cwd, featureCode, featuresDir = 'docs/features') {
241
- const featureDir = join(cwd, featuresDir, featureCode);
242
+ export function isTriageStale(cwd, featureCode, featuresDir = resolveFeaturesPath(cwd)) {
243
+ const featureDir = join(resolvePathValue(cwd, featuresDir, 'features'), featureCode);
242
244
  const featureJsonPath = join(featureDir, 'feature.json');
243
245
 
244
246
  if (!existsSync(featureJsonPath)) return true;
@@ -13,6 +13,7 @@ import path from 'node:path';
13
13
  import crypto from 'node:crypto';
14
14
  import { resolvePort } from './resolve-port.js';
15
15
  import { probeServer } from './server-probe.js';
16
+ import { migrateVisionItemFeatureCode, normalizeGateOutcome } from './state-migrations.js';
16
17
 
17
18
  const EMPTY_STATE = () => ({ items: [], connections: [], gates: [] });
18
19
 
@@ -34,11 +35,9 @@ function matchFeatureItem(items, featureCode) {
34
35
  || null;
35
36
  }
36
37
 
37
- /** Canonical outcome normalization — maps legacy past-tense to imperative */
38
- function normalizeOutcome(outcome) {
39
- const map = { approved: 'approve', killed: 'kill', revised: 'revise' };
40
- return map[outcome] || outcome;
41
- }
38
+ /** Canonical outcome normalization — maps legacy past-tense to imperative.
39
+ * Shared single implementation lives in state-migrations.js (COMP-MIGRATE-UNIFY-VISION). */
40
+ const normalizeOutcome = normalizeGateOutcome;
42
41
 
43
42
  export class ServerUnreachableError extends Error {
44
43
  constructor(message = 'Server is unreachable') {
@@ -77,16 +76,10 @@ export class VisionWriter {
77
76
  parsed.connections = parsed.connections || [];
78
77
  parsed.gates = parsed.gates || [];
79
78
 
80
- // Migration: legacy featureCode → lifecycle.featureCode
79
+ // Migration: legacy featureCode → lifecycle.featureCode (shared impl)
81
80
  let migrated = false;
82
81
  for (const item of parsed.items) {
83
- if (item.featureCode && item.featureCode.startsWith('feature:') && !item.lifecycle?.featureCode) {
84
- const bare = item.featureCode.replace(/^feature:/, '');
85
- item.lifecycle = item.lifecycle || {};
86
- item.lifecycle.featureCode = bare;
87
- delete item.featureCode;
88
- migrated = true;
89
- }
82
+ if (migrateVisionItemFeatureCode(item).changed) migrated = true;
90
83
  }
91
84
 
92
85
  // Dedup gates by ID (keep latest)
package/lib/xref-push.js CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { readdirSync, existsSync, readFileSync } from 'fs';
19
19
  import { join } from 'path';
20
- import { loadFeaturesDir } from './project-paths.js';
20
+ import { resolveFeaturesPath } from './project-paths.js';
21
21
  import { resolveSiblingRoot } from './xref-local.js';
22
22
 
23
23
  const GITHUB_STATES = new Set(['open', 'closed']);
@@ -145,7 +145,7 @@ function localResolve(link, cwd) {
145
145
  const sib = resolveSiblingRoot(cwd, link.repo);
146
146
  if (sib.skipped) return sib;
147
147
  try {
148
- const fjPath = join(sib.root, loadFeaturesDir(sib.root), link.to_code, 'feature.json');
148
+ const fjPath = join(resolveFeaturesPath(sib.root), link.to_code, 'feature.json');
149
149
  if (!existsSync(fjPath)) return { skipped: true, reason: `local target ${link.repo}/${link.to_code} not found` };
150
150
  return { state: JSON.parse(readFileSync(fjPath, 'utf8')).status || null, root: sib.root };
151
151
  } catch (e) { return { skipped: true, reason: `unreadable local target: ${e.message}` }; }
@@ -171,13 +171,13 @@ function localResolve(link, cwd) {
171
171
  * @returns {Promise<{pushed: Array, skipped: Array, unchanged: number, scanned: number}>}
172
172
  */
173
173
  export async function pushExternalRefs(cwd, opts = {}) {
174
- const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
174
+ const featuresDir = opts.featuresDir ?? resolveFeaturesPath(cwd);
175
175
  const clientOpts = { transport: opts.githubTransport ?? null, auth: opts.githubAuth };
176
176
  const ghResolve = opts.resolve ?? ((link) => defaultResolve(link, clientOpts));
177
177
  const ghWrite = opts.write ?? ((link, patch) => defaultWrite(link, patch, clientOpts));
178
178
  const setStatus = opts.setStatus ?? defaultSetStatus;
179
179
  const apply = opts.apply === true;
180
- const dir = join(cwd, featuresDir);
180
+ const dir = featuresDir;
181
181
 
182
182
  const pushed = [];
183
183
  const skipped = [];
package/lib/xref-sync.js CHANGED
@@ -16,7 +16,7 @@
16
16
  import { readdirSync, existsSync, readFileSync } from 'fs';
17
17
  import { join } from 'path';
18
18
  import { writeFeature } from './feature-json.js';
19
- import { loadFeaturesDir } from './project-paths.js';
19
+ import { resolveFeaturesPath } from './project-paths.js';
20
20
  import { resolveSiblingRoot } from './xref-local.js';
21
21
 
22
22
  const RESOLVABLE = new Set(['github', 'local']);
@@ -80,7 +80,7 @@ async function defaultResolve(link, cwd, featuresDir) {
80
80
  const citedRoot = sib.root;
81
81
  // Resolve the SIBLING's own features dir (it may have its own paths.features).
82
82
  try {
83
- const fjPath = join(citedRoot, loadFeaturesDir(citedRoot), link.to_code, 'feature.json');
83
+ const fjPath = join(resolveFeaturesPath(citedRoot), link.to_code, 'feature.json');
84
84
  if (!existsSync(fjPath)) return { skipped: true, reason: `local target ${link.repo}/${link.to_code} not found` };
85
85
  return { state: JSON.parse(readFileSync(fjPath, 'utf8')).status || null };
86
86
  } catch (e) { return { skipped: true, reason: `unreadable local target: ${e.message}` }; }
@@ -100,9 +100,9 @@ async function defaultResolve(link, cwd, featuresDir) {
100
100
  * @returns {Promise<{synced: Array, skipped: Array, unchanged: number, scanned: number}>}
101
101
  */
102
102
  export async function syncExternalRefs(cwd, opts = {}) {
103
- const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
103
+ const featuresDir = opts.featuresDir ?? resolveFeaturesPath(cwd);
104
104
  const resolve = opts.resolve ?? defaultResolve;
105
- const dir = join(cwd, featuresDir);
105
+ const dir = featuresDir;
106
106
 
107
107
  const synced = [];
108
108
  const skipped = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.2.48-beta",
3
+ "version": "0.2.50-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",
@@ -9,7 +9,7 @@ import fs from 'node:fs';
9
9
  import http from 'node:http';
10
10
  import path from 'node:path';
11
11
  import { ArtifactManager, ARTIFACT_SCHEMAS } from './artifact-manager.js';
12
- import { getTargetRoot, getDataDir, resolveProjectPath, switchProject, setCurrentWorkspaceId, loadProjectConfig } from './project-root.js';
12
+ import { getTargetRoot, getDataDir, resolveProjectPath, switchProject, setCurrentWorkspaceId, loadProjectConfig, isLifecycleEnabled } from './project-root.js';
13
13
  import { resolveProfile, isToolAllowed } from './mcp-tool-policy.js';
14
14
  import { getRoadmap } from '../lib/get-roadmap.js';
15
15
 
@@ -125,6 +125,14 @@ export function getSessionsFile() { return path.join(getDataDir(), 'sessions.jso
125
125
  // ---------------------------------------------------------------------------
126
126
 
127
127
  export function loadVisionState() {
128
+ // FORGE-ROADMAP-RETIRE-STORE: when the bound workspace disables the lifecycle
129
+ // capability (capabilities.lifecycle:false — e.g. a narrative-owned forge-top),
130
+ // the vision store is RETIRED. Every MCP vision read funnels through here, so
131
+ // returning the empty shape guarantees get_vision_items (and every dependent:
132
+ // phase summary, item detail, pending gates, lifecycle status) can't surface a
133
+ // second, drift-prone answer beside the prose ROADMAP. Reads stay inert even if
134
+ // a stray write later recreates the file. get_roadmap (narrative) is unaffected.
135
+ if (!isLifecycleEnabled()) return { items: [], connections: [], gates: [] };
128
136
  try {
129
137
  const raw = fs.readFileSync(getVisionFile(), 'utf-8');
130
138
  const state = JSON.parse(raw);
@@ -16,6 +16,8 @@ import { randomUUID } from 'node:crypto';
16
16
  import { parseDecisionBlocks } from '../src/components/vision/designSessionState.js';
17
17
  import { StratumMcpClient } from '../lib/stratum-mcp-client.js';
18
18
  import { KNOWN_VERSIONS } from '../lib/build-stream-schema.js';
19
+ import { getTargetRoot, resolveProjectPath } from './project-root.js';
20
+ import { relForDisplay } from '../lib/project-paths.js';
19
21
 
20
22
  // Lazy singleton — design conversations share one stratum-mcp connection
21
23
  // across the server process lifetime. Concurrent runs are correlation-id scoped.
@@ -444,16 +446,19 @@ Output ONLY the Markdown content, no code fences.`;
444
446
  return;
445
447
  }
446
448
 
447
- // Determine the output path
448
- let designDocPath;
449
+ // Determine the output path. Resolve the absolute write target first
450
+ // (features may be relocated outside the root — COMP-PATHS-EXTERNAL),
451
+ // then derive the display-relative string from it.
452
+ let absPath, designDocPath;
449
453
  if (scope === 'feature' && featureCode) {
450
- designDocPath = path.join('docs', 'features', featureCode, 'design.md');
454
+ absPath = path.join(resolveProjectPath('features'), featureCode, 'design.md');
455
+ designDocPath = relForDisplay(getTargetRoot(), absPath);
451
456
  } else {
452
457
  designDocPath = path.join('docs', 'design.md');
458
+ absPath = path.join(projectRoot, designDocPath);
453
459
  }
454
460
 
455
461
  // Guard: never overwrite an existing doc with empty content
456
- const absPath = path.join(projectRoot, designDocPath);
457
462
  if (!docContent.trim()) {
458
463
  console.error('[design] Generated doc is empty — refusing to overwrite');
459
464
  res.status(500).json({ error: 'Doc generation produced empty content' });
@@ -19,6 +19,7 @@ import fs from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import { execSync } from 'node:child_process';
21
21
  import { diffContracts } from './contract-diff.js';
22
+ import { resolveFeaturesPath } from '../lib/project-paths.js';
22
23
 
23
24
  // ── Threshold constants (Decision 2) ─────────────────────────────────────────
24
25
 
@@ -353,9 +354,10 @@ export function computeDriftAxes(item, projectRoot, now) {
353
354
 
354
355
  const ts = now || new Date().toISOString();
355
356
 
356
- // Resolve the docs/features/<FC> directory
357
- // projectRoot/docs/features/<FC>
358
- const featurePath = path.join(projectRoot, 'docs', 'features', featureCode);
357
+ // Resolve the features/<FC> directory against projectRoot's own config
358
+ // (relocatable artifact paths, COMP-PATHS-EXTERNAL). Byte-identical to
359
+ // projectRoot/docs/features/<FC> for the in-root default.
360
+ const featurePath = path.join(resolveFeaturesPath(projectRoot), featureCode);
359
361
 
360
362
  const pathAxis = computePathDrift(item, projectRoot, featurePath, ts);
361
363
  const contractAxis = computeContractDrift(item, projectRoot, featurePath, ts);
@@ -16,6 +16,7 @@ import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
 
18
18
  import { getTargetRoot, resolveProjectPath } from './project-root.js';
19
+ import { relForDisplay } from '../lib/project-paths.js';
19
20
  import { assertValidLinkShape } from '../lib/feature-write-guard.js';
20
21
 
21
22
  // ---------------------------------------------------------------------------
@@ -608,6 +609,11 @@ export function seedFromRoadmapGraph(store) {
608
609
  export function seedFeatures(features, store) {
609
610
  const seeded = { features: 0, updated: 0, connections: 0 };
610
611
  const featureItemMap = new Map(); // featureCode → itemId
612
+ const root = getTargetRoot();
613
+ const featuresBase = resolveProjectPath('features');
614
+ // Root-relative for the in-root default (relForDisplay guarantees byte-identity
615
+ // there); absolute when the features dir is relocated outside the workspace root.
616
+ const artifactPath = (feature, a) => relForDisplay(root, path.join(featuresBase, feature.name, a));
611
617
 
612
618
  // First pass: create/update items
613
619
  for (const feature of features) {
@@ -623,7 +629,7 @@ export function seedFeatures(features, store) {
623
629
  status: feature.status || 'planned',
624
630
  phase: feature.phase || 'planning',
625
631
  confidence: feature.confidence,
626
- files: feature.artifacts.map(a => `docs/features/${feature.name}/${a}`),
632
+ files: feature.artifacts.map(a => artifactPath(feature, a)),
627
633
  ...(feature.group ? { group: feature.group } : {}),
628
634
  });
629
635
  try {
@@ -643,7 +649,7 @@ export function seedFeatures(features, store) {
643
649
  if (feature.confidence > (featureItem.confidence || 0)) {
644
650
  updates.confidence = feature.confidence;
645
651
  }
646
- const newFiles = feature.artifacts.map(a => `docs/features/${feature.name}/${a}`);
652
+ const newFiles = feature.artifacts.map(a => artifactPath(feature, a));
647
653
  if (JSON.stringify(newFiles) !== JSON.stringify(featureItem.files || [])) {
648
654
  updates.files = newFiles;
649
655
  }