@smartmemory/compose 0.2.49-beta → 0.2.51-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 (148) hide show
  1. package/.claude/skills/compose/SKILL.md +24 -1
  2. package/.claude/skills/compose/references/hermes-tools.md +80 -0
  3. package/README.md +1 -1
  4. package/bin/compose.js +340 -70
  5. package/dist/assets/App-CT0vXPgd.js +724 -0
  6. package/dist/assets/_baseUniq-CsOHc_iS.js +1 -0
  7. package/dist/assets/{arc-B1TqAC_R.js → arc-BXht3LyE.js} +1 -1
  8. package/dist/assets/architectureDiagram-Q4EWVU46-BV1r86-w.js +36 -0
  9. package/dist/assets/blockDiagram-DXYQGD6D-BgcMJjAR.js +132 -0
  10. package/dist/assets/{browser-DW2JlJCC.js → browser-CnKiSnlr.js} +6 -6
  11. package/dist/assets/{c4Diagram-AAUBKEIU-Dc6swoQo.js → c4Diagram-AHTNJAMY-nBIRLJTv.js} +1 -1
  12. package/dist/assets/channel-Dd4XaiYv.js +1 -0
  13. package/dist/assets/{chunk-4BX2VUAB-CdqXgd68.js → chunk-4BX2VUAB-CNmGyhrp.js} +1 -1
  14. package/dist/assets/chunk-4TB4RGXK-MLT7I7w4.js +206 -0
  15. package/dist/assets/{chunk-55IACEB6-D7VauOmU.js → chunk-55IACEB6-D8KuKVLa.js} +1 -1
  16. package/dist/assets/{chunk-2J33WTMH-6HKDSnOw.js → chunk-EDXVE4YY-TPlt1bS2.js} +1 -1
  17. package/dist/assets/{chunk-FMBD7UC4-C1Z5m8DP.js → chunk-FMBD7UC4-tYY8xcjr.js} +1 -1
  18. package/dist/assets/chunk-OYMX7WX6-yBPwT1AT.js +231 -0
  19. package/dist/assets/{chunk-QZHKN3VN-DY9GU2uD.js → chunk-QZHKN3VN-B4Wfybww.js} +1 -1
  20. package/dist/assets/{chunk-ND2GUHAM-DmqZMEQe.js → chunk-YZCP3GAM-DE6_5aqh.js} +1 -1
  21. package/dist/assets/classDiagram-6PBFFD2Q-DshSQDF9.js +1 -0
  22. package/dist/assets/classDiagram-v2-HSJHXN6E-DshSQDF9.js +1 -0
  23. package/dist/assets/clone-CBsbNGAa.js +1 -0
  24. package/dist/assets/{cose-bilkent-S5V4N54A-B34g9xzV.js → cose-bilkent-S5V4N54A-DzzRyJtB.js} +1 -1
  25. package/dist/assets/dagre-KV5264BT-CTcTVGdU.js +4 -0
  26. package/dist/assets/diagram-5BDNPKRD-qR6mc8kJ.js +10 -0
  27. package/dist/assets/diagram-G4DWMVQ6-CUcGXyNb.js +24 -0
  28. package/dist/assets/diagram-MMDJMWI5-B68cPonH.js +43 -0
  29. package/dist/assets/diagram-TYMM5635-q8Id8eFG.js +24 -0
  30. package/dist/assets/{erDiagram-TEJ5UH35-C05_Iqmb.js → erDiagram-SMLLAGMA-CtxJi57K.js} +1 -1
  31. package/dist/assets/{flowDiagram-I6XJVG4X-lXpnb-Ch.js → flowDiagram-DWJPFMVM-BzzO4JYU.js} +1 -1
  32. package/dist/assets/ganttDiagram-T4ZO3ILL-Dn0wIyhR.js +292 -0
  33. package/dist/assets/gitGraphDiagram-UUTBAWPF-tnNFXfMM.js +106 -0
  34. package/dist/assets/graph-DZe55uk8.js +331 -0
  35. package/dist/assets/graph-Tq_bs_r0.js +1 -0
  36. package/dist/assets/index-8UhRLbGq.js +123 -0
  37. package/dist/assets/index-CRqB9els.css +1 -0
  38. package/dist/assets/infoDiagram-42DDH7IO-BXLvLeS0.js +2 -0
  39. package/dist/assets/{ishikawaDiagram-YF4QCWOH-CgdsWdgu.js → ishikawaDiagram-UXIWVN3A-DWx-7BBy.js} +5 -5
  40. package/dist/assets/{journeyDiagram-JHISSGLW-CyvHmE3z.js → journeyDiagram-VCZTEJTY-C42hXNha.js} +1 -1
  41. package/dist/assets/{kanban-definition-UN3LZRKU-Bqjdyv0T.js → kanban-definition-6JOO6SKY-CEq930ew.js} +1 -1
  42. package/dist/assets/katex-DkKDou_j.js +257 -0
  43. package/dist/assets/layout-qtUgN9BC.js +1 -0
  44. package/dist/assets/{linear-DecnRXf0.js → linear-D158F7OT.js} +1 -1
  45. package/dist/assets/min-hESxN-c0.js +1 -0
  46. package/dist/assets/{mindmap-definition-RKZ34NQL-DM1x9vNY.js → mindmap-definition-QFDTVHPH-d9SSh2nG.js} +7 -7
  47. package/dist/assets/mobile-1gVCT0OK.css +1 -0
  48. package/dist/assets/mobile-Chw8RWyH.js +17 -0
  49. package/dist/assets/pieDiagram-DEJITSTG-gglmeMav.js +30 -0
  50. package/dist/assets/quadrantDiagram-34T5L4WZ-CqFC_htU.js +7 -0
  51. package/dist/assets/{requirementDiagram-4Y6WPE33-DD-Gg_y5.js → requirementDiagram-MS252O5E-B1ZFIotK.js} +1 -1
  52. package/dist/assets/sankeyDiagram-XADWPNL6-BvItvZt5.js +10 -0
  53. package/dist/assets/sequenceDiagram-FGHM5R23-CAIn5Z3U.js +157 -0
  54. package/dist/assets/stateDiagram-FHFEXIEX-CyJrGzib.js +1 -0
  55. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DprhF_Ho.js +1 -0
  56. package/dist/assets/{timeline-definition-PNZ67QCA-BPm2AEQj.js → timeline-definition-GMOUNBTQ-k_0vJQAK.js} +1 -1
  57. package/dist/assets/{vennDiagram-CIIHVFJN-DQCDWwlj.js → vennDiagram-DHZGUBPP-TZAW60b0.js} +4 -4
  58. package/dist/assets/wardley-RL74JXVD-DzQEoG6X.js +162 -0
  59. package/dist/assets/wardleyDiagram-NUSXRM2D-BAoVVIjZ.js +20 -0
  60. package/dist/assets/{xychartDiagram-2RQKCTM6-Ba05SXXF.js → xychartDiagram-5P7HB3ND-CynkZcUb.js} +1 -1
  61. package/dist/index.html +4 -4
  62. package/lib/build-all.js +2 -1
  63. package/lib/build.js +363 -38
  64. package/lib/checkpoint/checkpoint-writer.js +3 -2
  65. package/lib/codex-preflight.js +151 -0
  66. package/lib/completion-writer.js +59 -15
  67. package/lib/feature-json.js +14 -3
  68. package/lib/feature-validator.js +27 -10
  69. package/lib/feature-write-guard.js +5 -7
  70. package/lib/feature-writer.js +3 -3
  71. package/lib/followup-writer.js +2 -2
  72. package/lib/get-roadmap.js +2 -2
  73. package/lib/gsd.js +2 -1
  74. package/lib/hooks-status.js +135 -0
  75. package/lib/ideabox.js +3 -2
  76. package/lib/install-agent-defs.js +22 -0
  77. package/lib/journal-writer.js +3 -3
  78. package/lib/migrate-anon.js +277 -0
  79. package/lib/migrate-roadmap.js +2 -3
  80. package/lib/paths-core.js +47 -0
  81. package/lib/project-paths.js +46 -37
  82. package/lib/roadmap-gen.js +3 -4
  83. package/lib/roadmap-graph/collect.js +5 -5
  84. package/lib/roadmap-graph/index.js +2 -2
  85. package/lib/roadmap-parser.js +1 -1
  86. package/lib/state-migrations.js +3 -3
  87. package/lib/test-bootstrap.js +165 -2
  88. package/lib/tracker/local-provider.js +2 -2
  89. package/lib/triage.js +7 -5
  90. package/lib/vocabulary-inject.js +103 -0
  91. package/lib/xref-push.js +4 -4
  92. package/lib/xref-sync.js +4 -4
  93. package/package.json +2 -1
  94. package/pipelines/build-quick.stratum.yaml +433 -0
  95. package/pipelines/build.stratum.yaml +60 -3
  96. package/scripts/watch-server.sh +32 -0
  97. package/server/build-routes.js +89 -9
  98. package/server/build-stream-bridge.js +12 -0
  99. package/server/compose-mcp-tools.js +73 -1
  100. package/server/compose-mcp.js +27 -0
  101. package/server/design-routes.js +9 -4
  102. package/server/drift-axes.js +5 -3
  103. package/server/feature-scaffold-routes.js +82 -0
  104. package/server/feature-scan.js +8 -2
  105. package/server/file-watcher.js +13 -7
  106. package/server/health-routes.js +261 -0
  107. package/server/ideabox-routes.js +12 -11
  108. package/server/project-root.js +7 -4
  109. package/server/qa-scope-routes.js +96 -0
  110. package/server/session-routes.js +2 -2
  111. package/server/stratum-sync.js +85 -9
  112. package/server/validate-routes.js +70 -0
  113. package/server/vision-routes.js +46 -2
  114. package/server/vision-server.js +25 -3
  115. package/server/vision-utils.js +23 -4
  116. package/dist/assets/App-DOerhoDp.js +0 -706
  117. package/dist/assets/architectureDiagram-3BPJPVTR-C4hfxNOw.js +0 -36
  118. package/dist/assets/blockDiagram-GPEHLZMM-DRqkGncy.js +0 -132
  119. package/dist/assets/channel-KsY1x-7s.js +0 -1
  120. package/dist/assets/chunk-727SXJPM-CeKpXDif.js +0 -206
  121. package/dist/assets/chunk-AQP2D5EJ-y2XL-YHV.js +0 -231
  122. package/dist/assets/classDiagram-4FO5ZUOK-zDEDoVPk.js +0 -1
  123. package/dist/assets/classDiagram-v2-Q7XG4LA2-zDEDoVPk.js +0 -1
  124. package/dist/assets/dagre-BM42HDAG-klZ_GOcw.js +0 -4
  125. package/dist/assets/diagram-2AECGRRQ-D0FmM6Wf.js +0 -43
  126. package/dist/assets/diagram-5GNKFQAL-DYuhby6q.js +0 -10
  127. package/dist/assets/diagram-KO2AKTUF-CCtQZgal.js +0 -3
  128. package/dist/assets/diagram-LMA3HP47-Cd1OqEln.js +0 -24
  129. package/dist/assets/diagram-OG6HWLK6-CiJrhoxo.js +0 -24
  130. package/dist/assets/ganttDiagram-6RSMTGT7-EPbL_2Hr.js +0 -292
  131. package/dist/assets/gitGraphDiagram-PVQCEYII-COCoF_vW.js +0 -106
  132. package/dist/assets/graph-BBXaecIU.js +0 -331
  133. package/dist/assets/graph-CAnANduQ.js +0 -1
  134. package/dist/assets/index-BwLfbbOu.css +0 -1
  135. package/dist/assets/index-DlgxA8dX.js +0 -123
  136. package/dist/assets/infoDiagram-5YYISTIA-B1sJw6j5.js +0 -2
  137. package/dist/assets/katex-C5jXJg4s.js +0 -257
  138. package/dist/assets/layout-DGIYPm2g.js +0 -1
  139. package/dist/assets/mobile-Cag5dHlF.css +0 -1
  140. package/dist/assets/mobile-DwmxS_O4.js +0 -17
  141. package/dist/assets/pieDiagram-4H26LBE5-CRtmCvwV.js +0 -30
  142. package/dist/assets/quadrantDiagram-W4KKPZXB-CsBLWg27.js +0 -7
  143. package/dist/assets/sankeyDiagram-5OEKKPKP-BPdMN8UU.js +0 -40
  144. package/dist/assets/sequenceDiagram-3UESZ5HK-D4AENdze.js +0 -162
  145. package/dist/assets/stateDiagram-AJRCARHV-DDplpCf_.js +0 -1
  146. package/dist/assets/stateDiagram-v2-BHNVJYJU-J4t9AVzF.js +0 -1
  147. package/dist/assets/wardley-L42UT6IY-WERqOmJt.js +0 -173
  148. package/dist/assets/wardleyDiagram-YWT4CUSO-5teUSOiI.js +0 -78
@@ -13,15 +13,36 @@
13
13
  * tests; it lets a caller inject mock `runBuild` / `abortBuild` / `getDataDir`.
14
14
  */
15
15
 
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
16
18
  import { runBuild as defaultRunBuild, abortBuild as defaultAbortBuild } from '../lib/build.js';
19
+ import { runNew as defaultRunNew } from '../lib/new.js';
20
+ import { runBuildAll as defaultRunBuildAll } from '../lib/build-all.js';
21
+ import { runGsd as defaultRunGsd } from '../lib/gsd.js';
17
22
  import { requireSensitiveOrPaired as requireSensitiveToken } from './security.js';
18
- import { getDataDir as defaultGetDataDir } from './project-root.js';
23
+ import { getDataDir as defaultGetDataDir, getTargetRoot as defaultGetTargetRoot } from './project-root.js';
19
24
  import { readBuildHistory } from '../lib/build-history.js';
20
25
 
26
+ // A launcher conflict (another build/GSD run already owns the feature) is a
27
+ // user-facing 409, not a 500. runBuild surfaces "already active"; runGsd
28
+ // refuses a concurrent run ("Refusing to start a concurrent run").
29
+ function launcherErrorStatus(err) {
30
+ const msg = err && err.message ? err.message : '';
31
+ return /already active|already running|concurrent run|refusing to start/i.test(msg) ? 409 : 500;
32
+ }
33
+
21
34
  export function attachBuildRoutes(app, deps = {}) {
22
35
  const runBuild = deps.runBuild || defaultRunBuild;
23
36
  const abortBuild = deps.abortBuild || defaultAbortBuild;
37
+ const runNew = deps.runNew || defaultRunNew;
38
+ const runBuildAll = deps.runBuildAll || defaultRunBuildAll;
39
+ const runGsd = deps.runGsd || defaultRunGsd;
24
40
  const getDataDir = deps.getDataDir || defaultGetDataDir;
41
+ // Bind build dispatch to the active project root (the same global target the
42
+ // vision store + getDataDir() use). Without this the runners default to
43
+ // process.cwd(), which switchProject() does not chdir — so after a project
44
+ // switch the cockpit and a launched build could target different workspaces.
45
+ const getTargetRoot = deps.getTargetRoot || defaultGetTargetRoot;
25
46
 
26
47
  // COMP-COCKPIT-3: read-only past-builds history. No sensitive token —
27
48
  // mirrors GET /api/build/state (read-only, no secrets in the records).
@@ -38,22 +59,81 @@ export function attachBuildRoutes(app, deps = {}) {
38
59
 
39
60
  app.post('/api/build/start', requireSensitiveToken, async (req, res) => {
40
61
  const body = req.body || {};
41
- const { featureCode, mode = 'feature', description = '' } = body;
42
- if (!featureCode) {
62
+ const { featureCode, mode = 'feature', description = '', resume = false } = body;
63
+
64
+ // featureCode is required for feature/bug/gsd; 'new' takes a free-text
65
+ // intent (no code yet) and 'all' sweeps every PLANNED feature.
66
+ if (!featureCode && mode !== 'all' && mode !== 'new') {
43
67
  return res.status(400).json({ error: 'featureCode required' });
44
68
  }
45
- if (mode !== 'feature' && mode !== 'bug') {
46
- return res.status(400).json({ error: "mode must be 'feature' or 'bug'" });
69
+ if (!['feature', 'bug', 'new', 'all', 'gsd'].includes(mode)) {
70
+ return res.status(400).json({ error: "mode must be 'feature', 'bug', 'new', 'all', or 'gsd'" });
47
71
  }
72
+
73
+ // PARITY-2: launch the `new` lifecycle from a description (intent).
74
+ if (mode === 'new') {
75
+ const intent = (description || '').trim();
76
+ if (!intent) {
77
+ return res.status(400).json({ error: 'intent (description) required for mode=new' });
78
+ }
79
+ try {
80
+ const result = await runNew(intent, { cwd: getTargetRoot() });
81
+ return res.json(result ?? { ok: true });
82
+ } catch (err) {
83
+ return res.status(500).json({ error: err.message || String(err) });
84
+ }
85
+ }
86
+
87
+ // PARITY-8: build-all sweep.
88
+ if (mode === 'all') {
89
+ try {
90
+ const result = await runBuildAll({ cwd: getTargetRoot() });
91
+ return res.json(result ?? { ok: true });
92
+ } catch (err) {
93
+ return res.status(launcherErrorStatus(err)).json({ error: err.message || String(err) });
94
+ }
95
+ }
96
+
97
+ // PARITY-8: GSD autonomous run for a single feature.
98
+ if (mode === 'gsd') {
99
+ try {
100
+ const result = await runGsd(featureCode, { cwd: getTargetRoot() });
101
+ return res.json(result ?? { ok: true });
102
+ } catch (err) {
103
+ return res.status(launcherErrorStatus(err)).json({ error: err.message || String(err) });
104
+ }
105
+ }
106
+
107
+ // PARITY-2: resume an interrupted bug-fix flow from active-build.json.
108
+ let resumeFlowId = null;
109
+ if (resume === true) {
110
+ if (mode !== 'bug') {
111
+ return res.status(400).json({ error: 'resume is only supported for mode=bug' });
112
+ }
113
+ let active = null;
114
+ try {
115
+ const activePath = path.join(getDataDir(), 'active-build.json');
116
+ active = JSON.parse(fs.readFileSync(activePath, 'utf8'));
117
+ } catch {
118
+ active = null;
119
+ }
120
+ if (!active || active.featureCode !== featureCode || !active.flowId) {
121
+ return res.status(409).json({ error: 'no resumable build for featureCode' });
122
+ }
123
+ if (active.mode && active.mode !== 'bug') {
124
+ return res.status(409).json({ error: `active build is mode=${active.mode}, not resumable as bug` });
125
+ }
126
+ resumeFlowId = active.flowId;
127
+ }
128
+
48
129
  try {
49
130
  const opts = mode === 'bug'
50
- ? { mode, template: 'bug-fix', description }
51
- : { mode, description };
131
+ ? { mode, template: 'bug-fix', description, cwd: getTargetRoot(), ...(resumeFlowId ? { resumeFlowId } : {}) }
132
+ : { mode, description, cwd: getTargetRoot() };
52
133
  const result = await runBuild(featureCode, opts);
53
134
  res.json(result ?? { ok: true });
54
135
  } catch (err) {
55
- const code = /already active/i.test(err && err.message ? err.message : '') ? 409 : 500;
56
- res.status(code).json({ error: err.message || String(err) });
136
+ res.status(launcherErrorStatus(err)).json({ error: err.message || String(err) });
57
137
  }
58
138
  });
59
139
 
@@ -471,6 +471,18 @@ export class BuildStreamBridge {
471
471
  };
472
472
  }
473
473
 
474
+ // COMP-TEST-BOOTSTRAP-4-1: advisory review of the tests the coverage step
475
+ // generated this build. Forwarded so the cockpit can surface the findings
476
+ // for human verification (the step never blocks ship).
477
+ case 'test_review':
478
+ return {
479
+ type: 'system', subtype: 'test_review',
480
+ clean: event.clean ?? true,
481
+ summary: event.summary ?? '',
482
+ findings: event.findings ?? [],
483
+ _source: 'build',
484
+ };
485
+
474
486
  // COMP-HEALTH item 118: health score after build completion
475
487
  case 'health_score':
476
488
  return {
@@ -359,10 +359,82 @@ export async function toolProposeFollowup(args) {
359
359
  // propose_followup also accepts a caller-supplied `status` and routes to
360
360
  // addRoadmapEntry — gate lifecycle-owned terminal statuses the same way.
361
361
  assertTerminalStatusAuthorized(args, 'propose_followup');
362
- const { proposeFollowup } = await import('../lib/followup-writer.js');
362
+ let proposeFollowup;
363
+ try {
364
+ ({ proposeFollowup } = await import('../lib/followup-writer.js'));
365
+ } catch (err) {
366
+ // COMP-MCP-FOLLOWUP-1: a long-running stdio MCP server caches ESM module
367
+ // records. If lib/ changed under it (e.g. a later commit added an export
368
+ // this lazily-imported module now depends on), linking fails with a cryptic
369
+ // "does not provide an export named ..." even though the on-disk code is
370
+ // correct — the running server just needs to reload. Rethrow with an
371
+ // actionable hint instead of the raw ESM error.
372
+ if (/does not provide an export named|Cannot find module/i.test(err?.message || '')) {
373
+ const hint = new Error(
374
+ `propose_followup could not load lib/followup-writer.js: ${err.message}. ` +
375
+ `This usually means the compose MCP server is running stale code (module-cache skew after a ` +
376
+ `code change) — reconnect the MCP server (/mcp) to reload it. If it persists after reconnecting, ` +
377
+ `an export is genuinely missing on disk.`,
378
+ );
379
+ hint.cause = err;
380
+ throw hint;
381
+ }
382
+ throw err;
383
+ }
363
384
  return proposeFollowup(getTargetRoot(), args);
364
385
  }
365
386
 
387
+ // ---------------------------------------------------------------------------
388
+ // Eager boot-time module preload — COMP-MCP-FOLLOWUP-1-1
389
+ // ---------------------------------------------------------------------------
390
+
391
+ /**
392
+ * Modules reachable only via a lazy `import()` on a hot tool path, which
393
+ * therefore escape boot-time ESM linking. Eager-importing them at server start
394
+ * makes a genuine on-disk break (missing module OR missing export) fail fast at
395
+ * boot instead of surfacing on first tool invocation — complementing the
396
+ * COMP-MCP-FOLLOWUP-1 CI import-graph test.
397
+ *
398
+ * The default set is exactly one module: `lib/followup-writer.js`, lazily
399
+ * imported by `toolProposeFollowup`. Its own dynamic import
400
+ * (`server/artifact-manager.js`) is already statically imported at the top of
401
+ * this file, so it links at boot regardless — no need to list it here.
402
+ */
403
+ export const EAGER_PRELOAD_MODULES = [
404
+ { specifier: '../lib/followup-writer.js', expect: 'proposeFollowup' },
405
+ ];
406
+
407
+ /**
408
+ * Eagerly import each spec and assert its expected export is a function.
409
+ * Throws (naming the offending module) on a missing module or missing export.
410
+ *
411
+ * `await import()` does NOT throw when a module loads but no longer provides the
412
+ * expected named export — it resolves with that binding `undefined`. So the
413
+ * binding check, not the import alone, is what turns a missing export into a
414
+ * boot failure (the exact COMP-MCP-FOLLOWUP-1 failure class).
415
+ *
416
+ * `specs` is injectable for testing and for the `COMPOSE_PRELOAD_PROBE` boot
417
+ * seam; entries without an `expect` are import-only checks.
418
+ */
419
+ export async function preloadEagerModules(specs = EAGER_PRELOAD_MODULES) {
420
+ const loaded = {};
421
+ for (const { specifier, expect } of specs) {
422
+ let mod;
423
+ try {
424
+ mod = await import(specifier);
425
+ } catch (err) {
426
+ throw new Error(`eager preload failed for ${specifier}: ${err.message}`, { cause: err });
427
+ }
428
+ if (expect && typeof mod[expect] !== 'function') {
429
+ throw new Error(
430
+ `eager preload failed for ${specifier}: expected export '${expect}' to be a function, got ${typeof mod[expect]}`,
431
+ );
432
+ }
433
+ loaded[specifier] = mod;
434
+ }
435
+ return loaded;
436
+ }
437
+
366
438
  // ---------------------------------------------------------------------------
367
439
  // Checkpoints / resume — COMP-RESUME
368
440
  // ---------------------------------------------------------------------------
@@ -26,6 +26,7 @@
26
26
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
27
27
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
28
28
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
29
+ import { writeSync } from 'node:fs';
29
30
  import {
30
31
  toolGetVisionItems,
31
32
  toolGetRoadmap,
@@ -71,6 +72,8 @@ import {
71
72
  assertToolPhaseAllowed,
72
73
  _getSessionProfile,
73
74
  resolveBoundPhase,
75
+ preloadEagerModules,
76
+ EAGER_PRELOAD_MODULES,
74
77
  } from './compose-mcp-tools.js';
75
78
  import { isToolAllowed } from './mcp-tool-policy.js';
76
79
  import { switchProject, getTargetRoot, loadProjectConfig } from './project-root.js';
@@ -817,6 +820,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
817
820
  // Start
818
821
  // ---------------------------------------------------------------------------
819
822
 
823
+ // COMP-MCP-FOLLOWUP-1-1: eager-preload lazily-imported hot-path modules so a
824
+ // genuine on-disk break (missing module/export) fails fast at boot instead of
825
+ // on first tool call. COMPOSE_PRELOAD_PROBE is an ops/test seam: comma-separated
826
+ // entries appended to the default set, each `specifier` or `specifier#export`
827
+ // (the `#export` form also exercises the export assertion). Absent → default.
828
+ const probeExtra = (process.env.COMPOSE_PRELOAD_PROBE || '')
829
+ .split(',')
830
+ .map((s) => s.trim())
831
+ .filter(Boolean)
832
+ .map((entry) => {
833
+ const hash = entry.indexOf('#');
834
+ return hash === -1
835
+ ? { specifier: entry }
836
+ : { specifier: entry.slice(0, hash), expect: entry.slice(hash + 1) };
837
+ });
838
+ try {
839
+ await preloadEagerModules([...EAGER_PRELOAD_MODULES, ...probeExtra]);
840
+ } catch (err) {
841
+ // writeSync (not stderr.write) so the line is guaranteed flushed before
842
+ // process.exit — an async pipe write can otherwise be truncated/dropped.
843
+ writeSync(2, `[compose-mcp] boot aborted: ${err.message}\n`);
844
+ process.exit(1);
845
+ }
846
+
820
847
  const transport = new StdioServerTransport();
821
848
  await server.connect(transport);
822
849
  // Server runs until stdin closes — no explicit exit needed
@@ -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);
@@ -0,0 +1,82 @@
1
+ /**
2
+ * feature-scaffold-routes.js — POST /api/features/scaffold.
3
+ *
4
+ * Cockpit equivalent of `compose feature <CODE>`: scaffolds docs/features/<CODE>/
5
+ * (feature.json + seed design.md) and the ROADMAP.md row. Reuses the typed writer
6
+ * addRoadmapEntry (lib/feature-writer.js) — never reimplements scaffolding and
7
+ * never echoes the regenerated roadmap back (the lib return is already compact).
8
+ *
9
+ * Sensitive (mutating). attachFeatureScaffoldRoutes(app, deps?) — deps is for tests.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { requireSensitiveOrPaired as requireSensitiveToken } from './security.js';
14
+ import { addRoadmapEntry as defaultAddRoadmapEntry } from '../lib/feature-writer.js';
15
+ import { isFeatureCode } from '../lib/feature-code.js';
16
+ import { getTargetRoot as defaultGetProjectRoot } from './project-root.js';
17
+ import { resolveFeaturesPathFromConfig, relForDisplay } from '../lib/project-paths.js';
18
+
19
+ const DEFAULT_PHASE = 'Backlog';
20
+
21
+ export function attachFeatureScaffoldRoutes(app, deps = {}) {
22
+ const addRoadmapEntry = deps.addRoadmapEntry || defaultAddRoadmapEntry;
23
+ const getProjectRoot = deps.getProjectRoot || defaultGetProjectRoot;
24
+
25
+ app.post('/api/features/scaffold', requireSensitiveToken, async (req, res) => {
26
+ const body = req.body || {};
27
+ const code = typeof body.code === 'string' ? body.code.trim().toUpperCase() : '';
28
+ const description = typeof body.description === 'string' ? body.description.trim() : '';
29
+ const phase = (typeof body.phase === 'string' && body.phase.trim())
30
+ || (typeof body.group === 'string' && body.group.trim())
31
+ || DEFAULT_PHASE;
32
+
33
+ if (!isFeatureCode(code)) {
34
+ return res.status(400).json({ error: 'Invalid feature code (e.g. COMP-FOO-1)' });
35
+ }
36
+ if (!description) {
37
+ return res.status(400).json({ error: 'description is required' });
38
+ }
39
+
40
+ const projectRoot = getProjectRoot();
41
+ try {
42
+ // 1. feature.json + ROADMAP.md row (compact return — no roadmap echo).
43
+ const result = await addRoadmapEntry(projectRoot, { code, description, phase });
44
+
45
+ // 2. Seed design.md stub (addRoadmapEntry does not write it). Idempotent.
46
+ // Resolve the ABSOLUTE features dir (may be relocated) — mirror ideabox.
47
+ let featurePath = null;
48
+ try {
49
+ const composeJsonPath = path.join(projectRoot, '.compose', 'compose.json');
50
+ let cfg = {};
51
+ if (fs.existsSync(composeJsonPath)) {
52
+ try { cfg = JSON.parse(fs.readFileSync(composeJsonPath, 'utf-8')); } catch { /* default cfg */ }
53
+ }
54
+ const featuresBase = resolveFeaturesPathFromConfig(projectRoot, cfg);
55
+ const featureDir = path.join(featuresBase, code);
56
+ const designPath = path.join(featureDir, 'design.md');
57
+ if (!fs.existsSync(designPath)) {
58
+ const today = new Date().toISOString().slice(0, 10);
59
+ fs.mkdirSync(featureDir, { recursive: true });
60
+ fs.writeFileSync(designPath,
61
+ `# ${code}: ${description}\n\n**Status:** PLANNED\n**Created:** ${today}\n\n---\n\n## Intent\n\n${description}\n\n---\n\n## Notes\n\n_Seed design doc created by the New Feature dialog. \`compose build\` will expand it._\n`);
62
+ }
63
+ featurePath = relForDisplay(projectRoot, featureDir);
64
+ } catch { /* design seed is best-effort; feature.json + roadmap already written */ }
65
+
66
+ // COMPACT result — never the full roadmap (memory: MCP add_roadmap_entry echo
67
+ // blows the token cap; the lib return is small, keep it small here too).
68
+ return res.json({
69
+ ok: true,
70
+ code: result.code,
71
+ phase: result.phase,
72
+ position: result.position,
73
+ roadmap_path: result.roadmap_path,
74
+ featurePath,
75
+ });
76
+ } catch (err) {
77
+ const msg = err && err.message ? err.message : String(err);
78
+ const code409 = /already exists/i.test(msg);
79
+ return res.status(code409 ? 409 : 400).json({ error: msg });
80
+ }
81
+ });
82
+ }
@@ -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
  }
@@ -10,6 +10,7 @@ import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
 
12
12
  import { getTargetRoot, loadProjectConfig, ensureDataDir } from './project-root.js';
13
+ import { resolveDocsPathFromConfig, resolveFeaturesPathFromConfig } from '../lib/project-paths.js';
13
14
 
14
15
  const PROJECT_ROOT = getTargetRoot();
15
16
 
@@ -69,7 +70,8 @@ export class FileWatcherServer {
69
70
  app.get('/api/files', (_req, res) => {
70
71
  const config = loadProjectConfig();
71
72
  const docsPrefix = config.paths?.docs || 'docs';
72
- const docsDir = path.join(PROJECT_ROOT, docsPrefix);
73
+ // COMP-PATHS-EXTERNAL: list the RESOLVED docs dir (may be relocated).
74
+ const docsDir = resolveDocsPathFromConfig(PROJECT_ROOT, config);
73
75
  try {
74
76
  const files = this.listMarkdownFiles(docsDir, docsPrefix);
75
77
  res.json({ files });
@@ -164,7 +166,10 @@ export class FileWatcherServer {
164
166
  if (!filename || !fileFilter(filename)) return;
165
167
 
166
168
  const relativePath = path.join(prefix, filename);
167
- const fullPath = path.join(PROJECT_ROOT, relativePath);
169
+ // COMP-PATHS-EXTERNAL: derive the real path from the WATCHED dir, not
170
+ // by re-rooting under PROJECT_ROOT — the dir may be relocated outside
171
+ // the workspace. Byte-identical to the old form for an in-root dir.
172
+ const fullPath = path.join(dir, filename);
168
173
 
169
174
  // Debounce: ignore events within 100ms of each other for the same file
170
175
  const now = Date.now();
@@ -180,10 +185,11 @@ export class FileWatcherServer {
180
185
  }
181
186
  };
182
187
 
183
- // Watch docs/ — broadcast fileChanged events
188
+ // Watch docs/ — broadcast fileChanged events. COMP-PATHS-EXTERNAL: watch
189
+ // the RESOLVED absolute dir (may be relocated outside PROJECT_ROOT).
184
190
  const config = loadProjectConfig();
185
191
  const docsPrefix = config.paths?.docs || 'docs';
186
- watchDir(path.join(PROJECT_ROOT, docsPrefix), docsPrefix, (relativePath, fullPath) => {
192
+ watchDir(resolveDocsPathFromConfig(PROJECT_ROOT, config), docsPrefix, (relativePath, fullPath) => {
187
193
  try {
188
194
  if (!fs.existsSync(fullPath)) return;
189
195
  const content = fs.readFileSync(fullPath, 'utf-8');
@@ -195,9 +201,9 @@ export class FileWatcherServer {
195
201
 
196
202
  // Watch features/ — notify for auto-reseed into vision store
197
203
  const featuresPrefix = config.paths?.features || 'docs/features';
198
- watchDir(path.join(PROJECT_ROOT, featuresPrefix), featuresPrefix, (relativePath) => {
199
- // Also broadcast as fileChanged (features are docs)
200
- const fullPath = path.join(PROJECT_ROOT, relativePath);
204
+ watchDir(resolveFeaturesPathFromConfig(PROJECT_ROOT, config), featuresPrefix, (relativePath, fullPath) => {
205
+ // Also broadcast as fileChanged (features are docs). fullPath comes from
206
+ // the watched dir (COMP-PATHS-EXTERNAL) — do not re-root under PROJECT_ROOT.
201
207
  try {
202
208
  if (fs.existsSync(fullPath)) {
203
209
  const content = fs.readFileSync(fullPath, 'utf-8');