@smartmemory/compose 0.2.49-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 (89) hide show
  1. package/bin/compose.js +30 -18
  2. package/dist/assets/{App-DOerhoDp.js → App-Bxyif1yv.js} +53 -53
  3. package/dist/assets/{arc-B1TqAC_R.js → arc-DeHak63Z.js} +1 -1
  4. package/dist/assets/{architectureDiagram-3BPJPVTR-C4hfxNOw.js → architectureDiagram-3BPJPVTR-C5Nkyl9y.js} +1 -1
  5. package/dist/assets/{blockDiagram-GPEHLZMM-DRqkGncy.js → blockDiagram-GPEHLZMM-BilnUBG2.js} +1 -1
  6. package/dist/assets/{c4Diagram-AAUBKEIU-Dc6swoQo.js → c4Diagram-AAUBKEIU-BsFJmPuS.js} +1 -1
  7. package/dist/assets/channel-ZgbQ1k0u.js +1 -0
  8. package/dist/assets/{chunk-2J33WTMH-6HKDSnOw.js → chunk-2J33WTMH-AEu6HIoY.js} +1 -1
  9. package/dist/assets/{chunk-4BX2VUAB-CdqXgd68.js → chunk-4BX2VUAB-XO4S2_89.js} +1 -1
  10. package/dist/assets/{chunk-55IACEB6-D7VauOmU.js → chunk-55IACEB6-CtFSLInj.js} +1 -1
  11. package/dist/assets/{chunk-727SXJPM-CeKpXDif.js → chunk-727SXJPM-CZeBRpM9.js} +1 -1
  12. package/dist/assets/{chunk-AQP2D5EJ-y2XL-YHV.js → chunk-AQP2D5EJ-_vmxcBc4.js} +1 -1
  13. package/dist/assets/{chunk-FMBD7UC4-C1Z5m8DP.js → chunk-FMBD7UC4-BMGd_Y9a.js} +1 -1
  14. package/dist/assets/{chunk-ND2GUHAM-DmqZMEQe.js → chunk-ND2GUHAM-BlrOECEr.js} +1 -1
  15. package/dist/assets/{chunk-QZHKN3VN-DY9GU2uD.js → chunk-QZHKN3VN-DHhUJ8RK.js} +1 -1
  16. package/dist/assets/classDiagram-4FO5ZUOK-KXxOorUx.js +1 -0
  17. package/dist/assets/classDiagram-v2-Q7XG4LA2-KXxOorUx.js +1 -0
  18. package/dist/assets/{cose-bilkent-S5V4N54A-B34g9xzV.js → cose-bilkent-S5V4N54A-C3M0mZqk.js} +1 -1
  19. package/dist/assets/{dagre-BM42HDAG-klZ_GOcw.js → dagre-BM42HDAG-DJlm4fEk.js} +1 -1
  20. package/dist/assets/{diagram-2AECGRRQ-D0FmM6Wf.js → diagram-2AECGRRQ-BmcONQG5.js} +1 -1
  21. package/dist/assets/{diagram-5GNKFQAL-DYuhby6q.js → diagram-5GNKFQAL-5JbWGIwd.js} +1 -1
  22. package/dist/assets/{diagram-KO2AKTUF-CCtQZgal.js → diagram-KO2AKTUF-DAnIvyI0.js} +1 -1
  23. package/dist/assets/{diagram-LMA3HP47-Cd1OqEln.js → diagram-LMA3HP47-Bmh7tvmI.js} +1 -1
  24. package/dist/assets/{diagram-OG6HWLK6-CiJrhoxo.js → diagram-OG6HWLK6-hnHVMNnk.js} +1 -1
  25. package/dist/assets/{erDiagram-TEJ5UH35-C05_Iqmb.js → erDiagram-TEJ5UH35-AMDitV0b.js} +1 -1
  26. package/dist/assets/{flowDiagram-I6XJVG4X-lXpnb-Ch.js → flowDiagram-I6XJVG4X-C0t2ZGQv.js} +1 -1
  27. package/dist/assets/{ganttDiagram-6RSMTGT7-EPbL_2Hr.js → ganttDiagram-6RSMTGT7-BPlM-BEN.js} +1 -1
  28. package/dist/assets/{gitGraphDiagram-PVQCEYII-COCoF_vW.js → gitGraphDiagram-PVQCEYII-Lv_YTxnb.js} +1 -1
  29. package/dist/assets/index-COq21Zym.js +119 -0
  30. package/dist/assets/{infoDiagram-5YYISTIA-B1sJw6j5.js → infoDiagram-5YYISTIA-Dul1vdUm.js} +1 -1
  31. package/dist/assets/{ishikawaDiagram-YF4QCWOH-CgdsWdgu.js → ishikawaDiagram-YF4QCWOH-CCBwgjFs.js} +1 -1
  32. package/dist/assets/{journeyDiagram-JHISSGLW-CyvHmE3z.js → journeyDiagram-JHISSGLW-zUJN38EU.js} +1 -1
  33. package/dist/assets/{kanban-definition-UN3LZRKU-Bqjdyv0T.js → kanban-definition-UN3LZRKU-BNxWi-8J.js} +1 -1
  34. package/dist/assets/{linear-DecnRXf0.js → linear-CRH9b4g7.js} +1 -1
  35. package/dist/assets/{mindmap-definition-RKZ34NQL-DM1x9vNY.js → mindmap-definition-RKZ34NQL-DolYwq7Y.js} +1 -1
  36. package/dist/assets/{pieDiagram-4H26LBE5-CRtmCvwV.js → pieDiagram-4H26LBE5-oxNfr2TX.js} +1 -1
  37. package/dist/assets/{quadrantDiagram-W4KKPZXB-CsBLWg27.js → quadrantDiagram-W4KKPZXB-BmZCvD-z.js} +1 -1
  38. package/dist/assets/{requirementDiagram-4Y6WPE33-DD-Gg_y5.js → requirementDiagram-4Y6WPE33-DsXZ05jo.js} +1 -1
  39. package/dist/assets/{sankeyDiagram-5OEKKPKP-BPdMN8UU.js → sankeyDiagram-5OEKKPKP-DsnYaawP.js} +1 -1
  40. package/dist/assets/{sequenceDiagram-3UESZ5HK-D4AENdze.js → sequenceDiagram-3UESZ5HK-Dt5mu3g8.js} +1 -1
  41. package/dist/assets/{stateDiagram-AJRCARHV-DDplpCf_.js → stateDiagram-AJRCARHV-BFR5ZINQ.js} +1 -1
  42. package/dist/assets/stateDiagram-v2-BHNVJYJU-BDrQD8fR.js +1 -0
  43. package/dist/assets/{timeline-definition-PNZ67QCA-BPm2AEQj.js → timeline-definition-PNZ67QCA-Bvnhw58e.js} +1 -1
  44. package/dist/assets/{vennDiagram-CIIHVFJN-DQCDWwlj.js → vennDiagram-CIIHVFJN-5WnyjZqf.js} +1 -1
  45. package/dist/assets/{wardley-L42UT6IY-WERqOmJt.js → wardley-L42UT6IY-CTxW4xow.js} +1 -1
  46. package/dist/assets/{wardleyDiagram-YWT4CUSO-5teUSOiI.js → wardleyDiagram-YWT4CUSO-K8Y1EOvb.js} +1 -1
  47. package/dist/assets/{xychartDiagram-2RQKCTM6-Ba05SXXF.js → xychartDiagram-2RQKCTM6-BCShes6B.js} +1 -1
  48. package/dist/index.html +1 -1
  49. package/lib/build-all.js +2 -1
  50. package/lib/build.js +91 -5
  51. package/lib/checkpoint/checkpoint-writer.js +3 -2
  52. package/lib/completion-writer.js +38 -14
  53. package/lib/feature-json.js +14 -3
  54. package/lib/feature-validator.js +22 -9
  55. package/lib/feature-write-guard.js +5 -7
  56. package/lib/feature-writer.js +3 -3
  57. package/lib/followup-writer.js +2 -2
  58. package/lib/get-roadmap.js +2 -2
  59. package/lib/gsd.js +2 -1
  60. package/lib/ideabox.js +3 -2
  61. package/lib/journal-writer.js +3 -3
  62. package/lib/migrate-roadmap.js +2 -3
  63. package/lib/paths-core.js +47 -0
  64. package/lib/project-paths.js +46 -37
  65. package/lib/roadmap-gen.js +3 -4
  66. package/lib/roadmap-graph/collect.js +5 -5
  67. package/lib/roadmap-graph/index.js +2 -2
  68. package/lib/state-migrations.js +3 -3
  69. package/lib/tracker/local-provider.js +2 -2
  70. package/lib/triage.js +7 -5
  71. package/lib/xref-push.js +4 -4
  72. package/lib/xref-sync.js +4 -4
  73. package/package.json +1 -1
  74. package/server/design-routes.js +9 -4
  75. package/server/drift-axes.js +5 -3
  76. package/server/feature-scan.js +8 -2
  77. package/server/file-watcher.js +13 -7
  78. package/server/ideabox-routes.js +12 -11
  79. package/server/project-root.js +7 -4
  80. package/server/session-routes.js +2 -2
  81. package/server/stratum-sync.js +85 -9
  82. package/server/vision-routes.js +17 -2
  83. package/server/vision-server.js +4 -3
  84. package/server/vision-utils.js +23 -4
  85. package/dist/assets/channel-KsY1x-7s.js +0 -1
  86. package/dist/assets/classDiagram-4FO5ZUOK-zDEDoVPk.js +0 -1
  87. package/dist/assets/classDiagram-v2-Q7XG4LA2-zDEDoVPk.js +0 -1
  88. package/dist/assets/index-DlgxA8dX.js +0 -123
  89. package/dist/assets/stateDiagram-v2-BHNVJYJU-J4t9AVzF.js +0 -1
@@ -1,52 +1,61 @@
1
1
  /**
2
- * project-paths.js — read .compose/compose.json `paths.features` override
3
- * for lib-side writers.
2
+ * project-paths.js — read .compose/compose.json and resolve artifact paths
3
+ * for lib-side code. All path math is delegated to lib/paths-core.js so the
4
+ * server and lib resolvers can never diverge.
4
5
  *
5
- * Server-side code uses `server/project-root.js`'s cached `loadProjectConfig`,
6
- * but lib code may run outside the server process (CLI, tests, MCP stdio)
7
- * and shouldn't share that cache. This is a tiny per-call read; the file is
8
- * a few hundred bytes.
9
- *
10
- * Introduced by COMP-MCP-MIGRATION-2.
6
+ * Introduced by COMP-MCP-MIGRATION-2; extended to relocatable artifact paths
7
+ * by COMP-PATHS-EXTERNAL (absolute readers + *FromConfig alternate-root form).
11
8
  */
12
9
  import { existsSync, readFileSync } from 'fs';
13
10
  import { join } from 'path';
11
+ import { DEFAULT_PATHS, resolvePathValue, relForDisplay } from './paths-core.js';
12
+
13
+ function readConfig(cwd) {
14
+ const cfgPath = join(cwd, '.compose', 'compose.json');
15
+ if (!existsSync(cfgPath)) return {};
16
+ try { return JSON.parse(readFileSync(cfgPath, 'utf-8')); }
17
+ catch { return {}; }
18
+ }
19
+
20
+ /** Resolve one artifact key against the config on disk at `cwd`. Absolute. */
21
+ function resolveKey(cwd, key) {
22
+ return resolvePathValue(cwd, readConfig(cwd)?.paths?.[key], key);
23
+ }
24
+ /** Resolve one artifact key against an already-loaded config + arbitrary root. Absolute. */
25
+ function resolveKeyFromConfig(root, config, key) {
26
+ return resolvePathValue(root, config?.paths?.[key], key);
27
+ }
14
28
 
15
- const DEFAULT_FEATURES_DIR = 'docs/features';
29
+ export const resolveDocsPath = (cwd) => resolveKey(cwd, 'docs');
30
+ export const resolveRoadmapPath = (cwd) => resolveKey(cwd, 'roadmap');
31
+ export const resolveFeaturesPath = (cwd) => resolveKey(cwd, 'features');
32
+ export const resolveJournalPath = (cwd) => resolveKey(cwd, 'journal');
33
+ export const resolveContextPath = (cwd) => resolveKey(cwd, 'context');
34
+ export const resolveIdeaboxPath = (cwd) => resolveKey(cwd, 'ideabox');
35
+
36
+ export const resolveDocsPathFromConfig = (root, config) => resolveKeyFromConfig(root, config, 'docs');
37
+ export const resolveRoadmapPathFromConfig = (root, config) => resolveKeyFromConfig(root, config, 'roadmap');
38
+ export const resolveFeaturesPathFromConfig = (root, config) => resolveKeyFromConfig(root, config, 'features');
39
+ export const resolveJournalPathFromConfig = (root, config) => resolveKeyFromConfig(root, config, 'journal');
40
+ export const resolveContextPathFromConfig = (root, config) => resolveKeyFromConfig(root, config, 'context');
41
+ export const resolveIdeaboxPathFromConfig = (root, config) => resolveKeyFromConfig(root, config, 'ideabox');
42
+
43
+ export { relForDisplay };
16
44
 
17
45
  /**
18
- * Resolve the project's features directory, respecting `.compose/compose.json`'s
19
- * `paths.features` override. Returns the relative path (joined onto cwd by callers).
20
- *
21
- * @param {string} cwd
22
- * @returns {string} Relative features dir, e.g. 'docs/features' or 'specs/features'.
46
+ * @deprecated relative form prefer resolveFeaturesPath (absolute). Kept for
47
+ * any caller that genuinely needs the configured RELATIVE string. Callers that
48
+ * pass the result into feature-json.js should migrate to resolveFeaturesPath,
49
+ * which is absolute-safe (COMP-PATHS-EXTERNAL S2).
23
50
  */
24
51
  export function loadFeaturesDir(cwd) {
25
- const cfgPath = join(cwd, '.compose', 'compose.json');
26
- if (!existsSync(cfgPath)) return DEFAULT_FEATURES_DIR;
27
- try {
28
- const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
29
- const rel = cfg?.paths?.features;
30
- return (typeof rel === 'string' && rel.length > 0) ? rel : DEFAULT_FEATURES_DIR;
31
- } catch {
32
- return DEFAULT_FEATURES_DIR;
33
- }
52
+ const rel = readConfig(cwd)?.paths?.features;
53
+ return (typeof rel === 'string' && rel.length > 0) ? rel : DEFAULT_PATHS.features;
34
54
  }
35
55
 
36
- /**
37
- * Read `externalPrefixes` from .compose/compose.json — code prefixes (e.g.
38
- * ["STRAT-"]) for features owned by OTHER projects, present in this roadmap
39
- * only as cross-project references. Returns [] if absent/unreadable.
40
- * @param {string} cwd
41
- * @returns {string[]}
42
- */
43
56
  export function loadExternalPrefixes(cwd) {
44
- try {
45
- const cfgPath = join(cwd, '.compose', 'compose.json');
46
- if (!existsSync(cfgPath)) return [];
47
- const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
48
- return Array.isArray(cfg.externalPrefixes) ? cfg.externalPrefixes : [];
49
- } catch { return []; }
57
+ const arr = readConfig(cwd)?.externalPrefixes;
58
+ return Array.isArray(arr) ? arr : [];
50
59
  }
51
60
 
52
- export const _internals = { DEFAULT_FEATURES_DIR };
61
+ export const _internals = { DEFAULT_FEATURES_DIR: DEFAULT_PATHS.features };
@@ -5,10 +5,9 @@
5
5
  */
6
6
 
7
7
  import { readFileSync, writeFileSync, existsSync } from 'fs';
8
- import { join } from 'path';
9
8
  import { listFeatures, positionSortKey } from './feature-json.js';
10
9
  import { parseStatusToken } from './roadmap-parser.js';
11
- import { loadFeaturesDir } from './project-paths.js';
10
+ import { loadFeaturesDir, resolveRoadmapPath } from './project-paths.js';
12
11
  import {
13
12
  readPhaseOverrides,
14
13
  readAnonymousRows,
@@ -207,7 +206,7 @@ export function generateRoadmapFromBase(baseText, features, opts = {}) {
207
206
  * @returns {string} - Generated ROADMAP.md content
208
207
  */
209
208
  export function generateRoadmap(cwd, opts = {}) {
210
- const roadmapPath = join(cwd, 'ROADMAP.md');
209
+ const roadmapPath = resolveRoadmapPath(cwd);
211
210
  const existingText = existsSync(roadmapPath) ? readFileSync(roadmapPath, 'utf-8') : '';
212
211
 
213
212
  // Narrative-owned workspace: ROADMAP.md is hand-authored. Return it verbatim
@@ -513,7 +512,7 @@ function buildKeyDocs(features, featuresDir) {
513
512
  * @param {object} [opts]
514
513
  */
515
514
  export function writeRoadmap(cwd, opts = {}) {
516
- const roadmapPath = join(cwd, 'ROADMAP.md');
515
+ const roadmapPath = resolveRoadmapPath(cwd);
517
516
 
518
517
  // Narrative-owned workspace: never overwrite hand-authored ROADMAP.md (#39).
519
518
  // No-op + warn; the existing file on disk is the source of truth.
@@ -12,7 +12,7 @@ import { join, dirname, resolve } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { parse as parseYaml } from 'yaml';
14
14
  import { listFeatures } from '../feature-json.js';
15
- import { loadFeaturesDir, loadExternalPrefixes } from '../project-paths.js';
15
+ import { resolveFeaturesPath, resolveRoadmapPath, loadExternalPrefixes } from '../project-paths.js';
16
16
  import { parseRoadmap } from '../roadmap-parser.js';
17
17
  import { SchemaValidator } from '../../server/schema-validator.js';
18
18
  import { depsToEdges } from './model.js';
@@ -28,7 +28,7 @@ const FM_KEYS = ['name', 'priority', 'track', 'desc'];
28
28
  * @param {string} [featuresDir] relative features dir (default from compose.json)
29
29
  * @returns {{ nodes: object[], rawEdges: object[], knownCodes: Set<string>, warnings: string[] }}
30
30
  */
31
- export function collectGraphInputs(cwd, featuresDir = loadFeaturesDir(cwd)) {
31
+ export function collectGraphInputs(cwd, featuresDir = resolveFeaturesPath(cwd)) {
32
32
  const warnings = [];
33
33
  const depsValidator = new SchemaValidator(DEPS_SCHEMA);
34
34
  const fmValidator = new SchemaValidator(FRONTMATTER_SCHEMA);
@@ -48,7 +48,7 @@ export function collectGraphInputs(cwd, featuresDir = loadFeaturesDir(cwd)) {
48
48
  // An external-prefixed folder is a cross-project reference living here —
49
49
  // known (so edges resolve) but never rendered as one of THIS project's nodes.
50
50
  if (isExternal(f.code)) { externalCodes.add(f.code); continue; }
51
- const folder = join(cwd, featuresDir, f.code);
51
+ const folder = join(featuresDir, f.code);
52
52
  const fm = readDisplayMetadata(folder, f, fmValidator, warnings);
53
53
  universe.set(f.code, {
54
54
  id: f.code,
@@ -62,7 +62,7 @@ export function collectGraphInputs(cwd, featuresDir = loadFeaturesDir(cwd)) {
62
62
  }
63
63
 
64
64
  // (b) ROADMAP.md rows — fallback for features not registered as folders.
65
- const roadmapPath = join(cwd, 'ROADMAP.md');
65
+ const roadmapPath = resolveRoadmapPath(cwd);
66
66
  if (existsSync(roadmapPath)) {
67
67
  let entries = [];
68
68
  try { entries = parseRoadmap(readFileSync(roadmapPath, 'utf-8')); } catch { /* ignore */ }
@@ -88,7 +88,7 @@ export function collectGraphInputs(cwd, featuresDir = loadFeaturesDir(cwd)) {
88
88
  for (const f of features) {
89
89
  if (!f || typeof f.code !== 'string') continue;
90
90
  if (isExternal(f.code)) continue;
91
- const depsPath = join(cwd, featuresDir, f.code, 'deps.yaml');
91
+ const depsPath = join(featuresDir, f.code, 'deps.yaml');
92
92
  if (!existsSync(depsPath)) continue;
93
93
  let deps;
94
94
  try {
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync } from 'node:fs';
12
12
  import { join, isAbsolute, dirname } from 'node:path';
13
- import { loadFeaturesDir } from '../project-paths.js';
13
+ import { resolveFeaturesPath } from '../project-paths.js';
14
14
  import { collectGraphInputs } from './collect.js';
15
15
  import { buildGraph } from './model.js';
16
16
  import { renderGraphHtml } from './render.js';
@@ -22,7 +22,7 @@ import { loadGraphConfig } from './config.js';
22
22
  * @returns {{ html: string, nodes: object[], edges: object[], dropped: string[], warnings: string[], config: object }}
23
23
  */
24
24
  export function buildRoadmapGraph(cwd) {
25
- const featuresDir = loadFeaturesDir(cwd);
25
+ const featuresDir = resolveFeaturesPath(cwd);
26
26
  const inputs = collectGraphInputs(cwd, featuresDir);
27
27
  const graph = buildGraph(inputs); // throws DanglingEdgeError
28
28
  const config = loadGraphConfig(cwd);
@@ -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
  /**
@@ -283,8 +283,8 @@ export function runStateMigrations(cwd, opts = {}) {
283
283
 
284
284
  // --- feature.json walk -----------------------------------------------------
285
285
  if (featurePending.length > 0) {
286
- const featuresDir = loadFeaturesDir(cwd); // honors paths.features override
287
- const featuresRoot = join(cwd, featuresDir);
286
+ const featuresDir = resolveFeaturesPath(cwd); // absolute; honors paths.features override
287
+ const featuresRoot = featuresDir;
288
288
  // Own directory walk — do NOT use listFeatures(): it silently skips unreadable
289
289
  // files, which would hide exactly the cold-data corruption we must surface.
290
290
  let dirs = [];
@@ -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;
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.49-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",
@@ -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
  }
@@ -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');
@@ -25,6 +25,7 @@ import {
25
25
  } from '../lib/ideabox.js'
26
26
  import { writeFeature } from '../lib/feature-json.js'
27
27
  import { IdeaboxCache } from './ideabox-cache.js'
28
+ import { resolveIdeaboxPathFromConfig, resolveFeaturesPathFromConfig, relForDisplay } from '../lib/project-paths.js'
28
29
 
29
30
  /**
30
31
  * @param {object} app — Express app
@@ -41,8 +42,7 @@ export function attachIdeaboxRoutes(app, { getProjectRoot, getDataDir, broadcast
41
42
  const dataDir = getDataDir()
42
43
  if (!_cache || _lastProjectRoot !== projectRoot || _lastDataDir !== dataDir) {
43
44
  const config = loadConfig(projectRoot)
44
- const ideaboxRel = config?.paths?.ideabox || 'docs/product/ideabox.md'
45
- const sourceFile = path.join(projectRoot, ideaboxRel)
45
+ const sourceFile = resolveIdeaboxPathFromConfig(projectRoot, config)
46
46
  _cache = new IdeaboxCache(dataDir, sourceFile)
47
47
  _lastProjectRoot = projectRoot
48
48
  _lastDataDir = dataDir
@@ -172,16 +172,17 @@ export function attachIdeaboxRoutes(app, { getProjectRoot, getDataDir, broadcast
172
172
  resolvedCode = `IDEA-${sourceIdea.num}-${slug}`.toUpperCase()
173
173
  }
174
174
 
175
- // Create feature folder + feature.json (same logic as CLI promote)
175
+ // Create feature folder + feature.json (same logic as CLI promote).
176
+ // COMP-PATHS-EXTERNAL: resolve the ABSOLUTE features base (may be
177
+ // relocated outside projectRoot). Re-rooting under projectRoot would make
178
+ // existsSync check the wrong path and risk overwriting external canon.
176
179
  const composeJsonPath = path.join(projectRoot, '.compose', 'compose.json')
177
- let featuresRel = 'docs/features'
180
+ let cfg = {}
178
181
  if (fs.existsSync(composeJsonPath)) {
179
- try {
180
- const cfg = JSON.parse(fs.readFileSync(composeJsonPath, 'utf-8'))
181
- featuresRel = cfg?.paths?.features || 'docs/features'
182
- } catch {}
182
+ try { cfg = JSON.parse(fs.readFileSync(composeJsonPath, 'utf-8')) } catch {}
183
183
  }
184
- const featuresDir = path.join(projectRoot, featuresRel, resolvedCode)
184
+ const featuresBase = resolveFeaturesPathFromConfig(projectRoot, cfg) // absolute
185
+ const featuresDir = path.join(featuresBase, resolvedCode)
185
186
  if (!fs.existsSync(featuresDir)) {
186
187
  // COMP-MCP-VALIDATE-1: route through the validated writer instead of a
187
188
  // raw fs.writeFileSync so the promoted feature.json is schema-guarded.
@@ -191,7 +192,7 @@ export function attachIdeaboxRoutes(app, { getProjectRoot, getDataDir, broadcast
191
192
  status: 'PLANNED',
192
193
  promotedFrom: sourceIdea.id,
193
194
  createdAt: new Date().toISOString(),
194
- }, featuresRel)
195
+ }, featuresBase)
195
196
  }
196
197
 
197
198
  promoteIdea(parsed, id, resolvedCode)
@@ -200,7 +201,7 @@ export function attachIdeaboxRoutes(app, { getProjectRoot, getDataDir, broadcast
200
201
  broadcastUpdate()
201
202
 
202
203
  const updated = parsed.ideas.find(i => i.id.toUpperCase() === upper)
203
- res.json({ ...(updated || { id }), featureCode: resolvedCode, featurePath: `${featuresRel}/${resolvedCode}` })
204
+ res.json({ ...(updated || { id }), featureCode: resolvedCode, featurePath: relForDisplay(projectRoot, featuresDir) })
204
205
  } catch (err) {
205
206
  const status = err.message.includes('not found') ? 404 : 500
206
207
  res.status(status).json({ error: err.message })
@@ -15,6 +15,7 @@ import path from 'node:path';
15
15
  import fs from 'node:fs';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { findProjectRoot } from './find-root.js';
18
+ import { DEFAULT_PATHS, resolvePathValue } from '../lib/paths-core.js';
18
19
 
19
20
  export { findProjectRoot } from './find-root.js';
20
21
 
@@ -99,7 +100,7 @@ export function ensureDataDir() {
99
100
  const DEFAULT_CONFIG = Object.freeze({
100
101
  version: 1,
101
102
  capabilities: Object.freeze({ stratum: true, lifecycle: true }),
102
- paths: Object.freeze({ docs: 'docs', features: 'docs/features', journal: 'docs/journal' }),
103
+ paths: DEFAULT_PATHS, // single source of truth (COMP-PATHS-EXTERNAL)
103
104
  });
104
105
 
105
106
  function cloneConfig(obj) {
@@ -120,9 +121,11 @@ export function loadProjectConfig() {
120
121
 
121
122
  export function resolveProjectPath(key) {
122
123
  const config = loadProjectConfig();
123
- const rel = config.paths?.[key];
124
- if (!rel) return path.join(getTargetRoot(), DEFAULT_CONFIG.paths[key] || key);
125
- return path.join(getTargetRoot(), rel);
124
+ // resolvePathValue handles in-root / ../-escaping / absolute overrides.
125
+ // Preserve the legacy `|| key` fallback for keys absent from DEFAULT_PATHS.
126
+ // (COMP-PATHS-EXTERNAL)
127
+ const value = config.paths?.[key] ?? DEFAULT_PATHS[key] ?? key;
128
+ return resolvePathValue(getTargetRoot(), value, key);
126
129
  }
127
130
 
128
131
  /**
@@ -11,7 +11,7 @@
11
11
 
12
12
  import path from 'node:path';
13
13
  import { readSessionsByFeature } from './session-store.js';
14
- import { getTargetRoot, getDataDir } from './project-root.js';
14
+ import { getTargetRoot, getDataDir, resolveProjectPath } from './project-root.js';
15
15
  import { appendPhaseHistory } from './lifecycle-phase-history.js';
16
16
  import { reconcile } from '../lib/checkpoint/reconciler.js';
17
17
  import { checkpointConfig } from '../lib/checkpoint/checkpoint-writer.js';
@@ -140,7 +140,7 @@ export function attachSessionRoutes(app, { sessionManager, scheduleBroadcast, br
140
140
  featureCode,
141
141
  item,
142
142
  cwd: targetRoot,
143
- featureDir: path.join(targetRoot, 'docs', 'features', featureCode),
143
+ featureDir: path.join(resolveProjectPath('features'), featureCode),
144
144
  composeDir: path.join(targetRoot, '.compose'),
145
145
  dataDir,
146
146
  store: cpStore,