@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
package/lib/build.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs';
12
- import { join, resolve, dirname } from 'node:path';
12
+ import { join, resolve, dirname, relative } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { homedir, tmpdir } from 'node:os';
15
15
  import { execSync, execFileSync } from 'node:child_process';
@@ -35,7 +35,7 @@ import { rtkPrefix } from './rtk.js';
35
35
 
36
36
  import YAML from 'yaml';
37
37
  // feature-json direct imports removed — mutations now go through TrackerProvider (T9)
38
- import { loadFeaturesDir } from './project-paths.js';
38
+ import { loadFeaturesDir, resolveContextPath, resolveRoadmapPath, resolveFeaturesPath } from './project-paths.js';
39
39
 
40
40
  // Lazy provider accessor — avoids circular import risk (factory → local-provider
41
41
  // does NOT import build.js, so a static import is safe, but lazy is used for
@@ -605,10 +605,19 @@ export async function runBuild(featureCode, opts = {}) {
605
605
  // `docs/features/<featureCode>/`. Callers must use this (not inline
606
606
  // string concatenation) so the bug-mode path stays in sync.
607
607
  // COMP-MCP-MIGRATION-2: feature-mode honors paths.features override.
608
+ // COMP-PATHS-EXTERNAL D7: two representations on purpose.
609
+ // - `featuresDir` stays RELATIVE (loadFeaturesDir): it flows into
610
+ // context.featuresDir → the MCP-enforcement guard, which matches against
611
+ // repo-relative `git status` output. External artifacts live in another
612
+ // repo and are out of that guard's scope by construction; making this
613
+ // absolute would break the relative match. triage consumers are
614
+ // absolute-safe (resolvePathValue) so the relative value is fine there too.
615
+ // - `resolveItemDir` resolves the actual FILESYSTEM dir via
616
+ // resolveFeaturesPath (absolute — handles absolute/../-escaping config).
608
617
  const featuresDir = loadFeaturesDir(cwd);
609
618
  const resolveItemDir = (code) => isBugMode
610
619
  ? join(cwd, 'docs', 'bugs', code)
611
- : join(cwd, featuresDir, code);
620
+ : join(resolveFeaturesPath(cwd), code);
612
621
 
613
622
  // COMP-MCP-MIGRATION-1: per-build correlation ID stamped onto every audit
614
623
  // row written during this run, so `executeShipStep`'s pre-stage scan can
@@ -663,7 +672,7 @@ export async function runBuild(featureCode, opts = {}) {
663
672
  }
664
673
  let composeConfig = {};
665
674
  try { composeConfig = JSON.parse(readFileSync(configPath, 'utf-8')); } catch { /* use defaults */ }
666
- const contextDirPath = join(cwd, composeConfig.paths?.context ?? 'docs/context');
675
+ const contextDirPath = resolveContextPath(cwd);
667
676
 
668
677
  // COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (D5): per-task pre-merge gate is opt-in.
669
678
  // Resolve ONCE and thread into startFresh's planInputs only when the capability
@@ -2219,6 +2228,43 @@ export function maybeEmitSectionsAfterPlanGate(stepId, featureDir, opts = {}) {
2219
2228
  // Ship step — runs git commit in-process (not via agent)
2220
2229
  // ---------------------------------------------------------------------------
2221
2230
 
2231
+ /**
2232
+ * The git repository toplevel that contains `dir`, or null if `dir` is not
2233
+ * inside any git work tree. Used so ship decides commit ownership per-file by
2234
+ * containing repo, not by assuming the workspace root == the repo
2235
+ * (COMP-PATHS-EXTERNAL D6a).
2236
+ */
2237
+ function gitToplevel(dir) {
2238
+ try {
2239
+ return execSync('git rev-parse --show-toplevel', {
2240
+ cwd: dir, encoding: 'utf-8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
2241
+ }).trim() || null;
2242
+ } catch { return null; }
2243
+ }
2244
+
2245
+ /**
2246
+ * For each owned artifact (ROADMAP, the feature folder) that resolves into a
2247
+ * git repo OTHER than the build's repo, log a one-line "commit it there"
2248
+ * notice. v1 does not auto-commit other repos (that is COMP-PATHS-EXTERNAL-1).
2249
+ * Artifacts in the build's own repo, or in no repo at all, produce no notice.
2250
+ */
2251
+ function noticeExternalArtifacts(cwd, featureCode, buildToplevel) {
2252
+ try {
2253
+ const candidates = [
2254
+ ['ROADMAP.md', resolveRoadmapPath(cwd)],
2255
+ ['feature folder', join(resolveFeaturesPath(cwd), featureCode)],
2256
+ ];
2257
+ for (const [label, abs] of candidates) {
2258
+ if (!existsSync(abs)) continue;
2259
+ const top = gitToplevel(dirname(abs));
2260
+ if (top && top !== buildToplevel) {
2261
+ // eslint-disable-next-line no-console
2262
+ console.warn(`[build/ship] 📝 wrote ${label} in ${top} — commit it there (Compose does not auto-commit other repos in v1)`);
2263
+ }
2264
+ }
2265
+ } catch { /* notice is best-effort */ }
2266
+ }
2267
+
2222
2268
  /**
2223
2269
  * Execute the ship step: run tests, stage feature files, commit.
2224
2270
  * Returns a PhaseResult-shaped object.
@@ -2240,11 +2286,34 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2240
2286
  } catch { /* not a git repo */ }
2241
2287
 
2242
2288
  if (!isGitRepo) {
2289
+ // COMP-PATHS-EXTERNAL D6b: there is no repo to commit into (e.g. a
2290
+ // forge-top-shaped workspace), but the lifecycle must still advance —
2291
+ // record a commit-less completion (null-SHA) so status flips to COMPLETE.
2292
+ // Best-effort: a completion failure must not fail ship.
2293
+ let completionWarning = null;
2294
+ if (featureCode) {
2295
+ try {
2296
+ const { recordCompletion } = await import('./completion-writer.js');
2297
+ await recordCompletion(cwd, {
2298
+ feature_code: featureCode,
2299
+ // commit_sha omitted — non-git workspace, stamped with the null-SHA
2300
+ tests_pass: true,
2301
+ files_changed: [],
2302
+ notes: description.split('\n')[0].slice(0, 72),
2303
+ });
2304
+ } catch (err) {
2305
+ completionWarning = `completion record failed (${err.code || 'UNKNOWN'}): ${err.message}`;
2306
+ // eslint-disable-next-line no-console
2307
+ console.warn(`[build/ship] ${featureCode}: ${completionWarning}`);
2308
+ }
2309
+ }
2243
2310
  return {
2244
2311
  phase: 'ship',
2245
2312
  artifact: 'no-git',
2246
2313
  outcome: 'complete',
2247
- summary: 'No git repository — commit skipped (non-fatal)',
2314
+ summary: 'No git repository — wrote artifacts, recorded completion (commit skipped)',
2315
+ commit: null,
2316
+ ...(completionWarning ? { completionWarning } : {}),
2248
2317
  };
2249
2318
  }
2250
2319
 
@@ -2316,6 +2385,18 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2316
2385
  const dataDir = join(cwd, '.compose', 'data');
2317
2386
  const mode = readEnforcementMode(dataDir);
2318
2387
  if (mode !== 'off') {
2388
+ // COMP-PATHS-EXTERNAL: the pre-stage guard matches repo-relative
2389
+ // `git status`, so relocated canon (an external paths.roadmap /
2390
+ // paths.features) is OUT of its scope — unauthorized edits there are
2391
+ // NOT enforced. Surface that visibly instead of failing silently.
2392
+ // Full external-canon enforcement needs cross-repo dirty detection
2393
+ // (tracked with COMP-PATHS-EXTERNAL-1).
2394
+ for (const [label, abs] of [['paths.roadmap', resolveRoadmapPath(cwd)], ['paths.features', resolveFeaturesPath(cwd)]]) {
2395
+ if (relative(cwd, abs).startsWith('..')) {
2396
+ // eslint-disable-next-line no-console
2397
+ console.warn(`[ship] MCP enforcement (${mode}) does NOT cover external ${label} (${abs}) — edits there are unguarded in v1.`);
2398
+ }
2399
+ }
2319
2400
  const events = readEvents(cwd, { since: context.buildStartedAt });
2320
2401
  const { violations } = scanGuarded({
2321
2402
  dirtyFiles: featureFiles,
@@ -2441,6 +2522,11 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2441
2522
  }
2442
2523
  }
2443
2524
 
2525
+ // COMP-PATHS-EXTERNAL D6a: if ROADMAP / the feature folder resolved into a
2526
+ // DIFFERENT git repo, they were written but not committed here — tell the
2527
+ // user to commit them there (v1 does not auto-commit other repos).
2528
+ noticeExternalArtifacts(cwd, featureCode, gitToplevel(agentCwd));
2529
+
2444
2530
  return {
2445
2531
  phase: 'ship',
2446
2532
  artifact: sha ?? '',
@@ -16,6 +16,7 @@ import { captureFingerprint } from './fingerprint.js';
16
16
  import { captureAnchor } from './anchor.js';
17
17
  import { scribePrompt } from './prompts.js';
18
18
  import { createCheckpointStore } from './store/index.js';
19
+ import { resolveFeaturesPath } from '../project-paths.js';
19
20
 
20
21
  /**
21
22
  * Resolve the checkpoint config block from .compose/compose.json with defaults
@@ -75,7 +76,7 @@ export function writeCheckpoint(targetRoot, {
75
76
  const cfg = checkpointConfig(targetRoot);
76
77
  const dataDir = path.join(targetRoot, '.compose', 'data');
77
78
  const composeDir = path.join(targetRoot, '.compose');
78
- const featureDir = path.join(targetRoot, 'docs', 'features', featureCode);
79
+ const featureDir = path.join(resolveFeaturesPath(targetRoot), featureCode);
79
80
 
80
81
  const store = createCheckpointStore(cfg.backend, { dataDir });
81
82
  // The prior checkpoint (read before writing) seeds the scribe prompt's context.
@@ -121,7 +122,7 @@ export function anchorBoundary(targetRoot, { item, trigger, flowId = null } = {}
121
122
  if (!featureCode) return null;
122
123
  const dataDir = path.join(targetRoot, '.compose', 'data');
123
124
  const composeDir = path.join(targetRoot, '.compose');
124
- const featureDir = path.join(targetRoot, 'docs', 'features', featureCode);
125
+ const featureDir = path.join(resolveFeaturesPath(targetRoot), featureCode);
125
126
  const store = createCheckpointStore(cfg.backend, { dataDir });
126
127
  return captureAnchor({ item, trigger, cwd: targetRoot, featureDir, composeDir, dataDir, store, flowId });
127
128
  } catch (err) {
@@ -42,6 +42,13 @@ async function getProvider(cwd) {
42
42
  // ---------------------------------------------------------------------------
43
43
  const SHA_RE = /^[0-9a-f]{40}$/i; // FULL SHA only (Decision 9). Case-insensitive input; normalize to lowercase.
44
44
  const SHORT_LEN = 8; // Display only — never the dedup key.
45
+ // git's null-SHA. Stamped for a commit-less completion (non-git workspace —
46
+ // COMP-PATHS-EXTERNAL Decision 6b). Schema-valid (matches SHA_RE) and a
47
+ // well-known "no commit" marker, so no schema or downstream change is needed.
48
+ const NULL_SHA = '0'.repeat(40);
49
+ // Monotonic per-process counter so distinct commit-less completions get
50
+ // distinct completion_ids even within the same millisecond.
51
+ let _nocommitSeq = 0;
45
52
  const DEFAULT_LIMIT = 50;
46
53
  const MAX_LIMIT = 500;
47
54
 
@@ -124,6 +131,10 @@ function validateRepoRelativePath(p) {
124
131
  if (p.includes('\0')) {
125
132
  throw inputError(`files_changed: entry contains NUL byte: "${p}"`);
126
133
  }
134
+ // files_changed stays repo-relative on purpose — absolute paths are a
135
+ // path-safety hazard (e.g. "/etc/passwd") and no caller needs them: the
136
+ // commit-less (external) ship path passes []. (COMP-PATHS-EXTERNAL: kept the
137
+ // guard rather than widen it for an unused capability.)
127
138
  if (p.startsWith('/')) {
128
139
  throw inputError(`files_changed: absolute paths not allowed: "${p}"`);
129
140
  }
@@ -151,16 +162,21 @@ function validate(args) {
151
162
  );
152
163
  }
153
164
 
154
- // commit_sha — full 40-char hex required (Decision 9)
155
- if (typeof args.commit_sha !== 'string' || args.commit_sha.trim().length === 0) {
156
- throw inputError('completion-writer: commit_sha is required (non-empty string)');
157
- }
158
- const trimmedSha = args.commit_sha.trim();
159
- if (!SHA_RE.test(trimmedSha)) {
160
- throw inputError(
161
- `completion-writer: commit_sha must be a full 40-char hex SHA (Decision 9). ` +
162
- `Got "${trimmedSha}" (length ${trimmedSha.length}). Short prefixes are rejected on write.`
163
- );
165
+ // commit_sha — OPTIONAL (COMP-PATHS-EXTERNAL Decision 6b): omitted/null means
166
+ // a commit-less completion (non-git workspace) and is stamped with NULL_SHA.
167
+ // When PRESENT it must still be a full 40-char hex SHA (Decision 9) — a
168
+ // malformed value (short, non-hex, empty string, non-string) is rejected.
169
+ if (args.commit_sha !== undefined && args.commit_sha !== null) {
170
+ if (typeof args.commit_sha !== 'string' || args.commit_sha.trim().length === 0) {
171
+ throw inputError('completion-writer: commit_sha must be a non-empty 40-char hex SHA when provided');
172
+ }
173
+ const trimmedSha = args.commit_sha.trim();
174
+ if (!SHA_RE.test(trimmedSha)) {
175
+ throw inputError(
176
+ `completion-writer: commit_sha must be a full 40-char hex SHA (Decision 9). ` +
177
+ `Got "${trimmedSha}" (length ${trimmedSha.length}). Short prefixes are rejected on write.`
178
+ );
179
+ }
164
180
  }
165
181
 
166
182
  // tests_pass — strict boolean
@@ -257,13 +273,21 @@ export async function recordCompletion(cwd, args) {
257
273
  // 1. Validate
258
274
  validate(args);
259
275
 
260
- // 2. Normalize SHA
261
- const commit_sha = args.commit_sha.trim().toLowerCase();
276
+ // 2. Normalize SHA (omitted/null ⇒ NULL_SHA sentinel — commit-less completion)
277
+ const commit_sha = (args.commit_sha === undefined || args.commit_sha === null)
278
+ ? NULL_SHA
279
+ : args.commit_sha.trim().toLowerCase();
262
280
  const commit_sha_short = commit_sha.slice(0, SHORT_LEN);
263
281
  const feature_code = args.feature_code;
264
282
 
265
- // 3. Compute completion_id
266
- const completion_id = `${feature_code}:${commit_sha}`;
283
+ // 3. Compute completion_id. For a real commit the SHA makes it stable and
284
+ // dedup-able. For a commit-less completion (NULL_SHA) the SHA is constant,
285
+ // so id off the sha would alias every no-commit completion of a feature
286
+ // onto one record (a re-completion would idempotent-no-op). Derive a
287
+ // distinct id from a timestamp instead. (COMP-PATHS-EXTERNAL D6b)
288
+ const completion_id = (commit_sha === NULL_SHA)
289
+ ? `${feature_code}:nocommit:${Date.now().toString(36)}-${(_nocommitSeq++).toString(36)}`
290
+ : `${feature_code}:${commit_sha}`;
267
291
 
268
292
  // 4. Wrap in maybeIdempotent for caller-key path
269
293
  return maybeIdempotent({ ...args, cwd }, async () => {
@@ -10,6 +10,17 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkS
10
10
  import { join, basename } from 'path';
11
11
  import { readdirSync } from 'fs';
12
12
  import { assertValidLinkShape, assertLinkTargetsExist } from './feature-write-guard.js';
13
+ import { resolvePathValue } from './paths-core.js';
14
+
15
+ /**
16
+ * Resolve the features-dir base. `featuresDir` may be relative-to-cwd (legacy,
17
+ * incl. the default), ../-escaping, or absolute (COMP-PATHS-EXTERNAL D7).
18
+ * Identical to join(cwd, featuresDir) for plain relative dirs.
19
+ * @returns {string} absolute features dir
20
+ */
21
+ function featuresBase(cwd, featuresDir) {
22
+ return resolvePathValue(cwd, featuresDir, 'features');
23
+ }
13
24
 
14
25
  /**
15
26
  * @typedef {object} FeatureJson
@@ -34,7 +45,7 @@ import { assertValidLinkShape, assertLinkTargetsExist } from './feature-write-gu
34
45
  * @returns {FeatureJson|null}
35
46
  */
36
47
  export function readFeature(cwd, code, featuresDir = 'docs/features') {
37
- const path = join(cwd, featuresDir, code, 'feature.json');
48
+ const path = join(featuresBase(cwd, featuresDir), code, 'feature.json');
38
49
  if (!existsSync(path)) return null;
39
50
  try {
40
51
  return JSON.parse(readFileSync(path, 'utf-8'));
@@ -66,7 +77,7 @@ export function writeFeature(cwd, feature, featuresDir = 'docs/features', opts =
66
77
  const priorLinks = hasTargets ? (readFeature(cwd, feature.code, featuresDir)?.links) : undefined;
67
78
  assertLinkTargetsExist(cwd, feature, { allowForwardRefs: opts.allowForwardRefs, priorLinks });
68
79
  }
69
- const dir = join(cwd, featuresDir, feature.code);
80
+ const dir = join(featuresBase(cwd, featuresDir), feature.code);
70
81
  mkdirSync(dir, { recursive: true });
71
82
  const path = join(dir, 'feature.json');
72
83
  feature.updated = new Date().toISOString().slice(0, 10);
@@ -115,7 +126,7 @@ export function positionSortKey(position) {
115
126
  * @returns {FeatureJson[]}
116
127
  */
117
128
  export function listFeatures(cwd, featuresDir = 'docs/features') {
118
- const dir = join(cwd, featuresDir);
129
+ const dir = featuresBase(cwd, featuresDir);
119
130
  if (!existsSync(dir)) return [];
120
131
 
121
132
  const features = [];
@@ -43,6 +43,9 @@ import { featureStatusToVisionStatus } from './status-projection.js';
43
43
  import { GitHubApi } from './tracker/github-api.js';
44
44
  import { ArtifactManager } from '../server/artifact-manager.js';
45
45
  import { SchemaValidator } from '../server/schema-validator.js';
46
+ import {
47
+ resolveRoadmapPathFromConfig, resolveFeaturesPathFromConfig, resolveJournalPathFromConfig,
48
+ } from './project-paths.js';
46
49
 
47
50
  const __filename = fileURLToPath(import.meta.url);
48
51
  const __dirname = path.dirname(__filename);
@@ -51,7 +54,6 @@ const FEATURE_JSON_SCHEMA = path.resolve(__dirname, '../contracts/feature-json
51
54
  const VISION_STATE_SCHEMA = path.resolve(__dirname, '../contracts/vision-state.schema.json');
52
55
  const ROADMAP_ROW_SCHEMA = path.resolve(__dirname, '../contracts/roadmap-row.schema.json');
53
56
 
54
- const DEFAULT_PATHS = { docs: 'docs', features: 'docs/features', journal: 'docs/journal' };
55
57
 
56
58
  const TERMINAL_STATUSES = new Set(['KILLED', 'SUPERSEDED']);
57
59
  const VALID_STATUSES = new Set(['PLANNED', 'IN_PROGRESS', 'PARTIAL', 'COMPLETE', 'SUPERSEDED', 'PARKED', 'BLOCKED', 'KILLED']);
@@ -109,13 +111,14 @@ function readProjectConfig(cwd) {
109
111
  }
110
112
 
111
113
  function resolveProjectPaths(cwd) {
112
- const cfg = readProjectConfig(cwd);
113
- const paths = (cfg && cfg.paths) || DEFAULT_PATHS;
114
+ const cfg = readProjectConfig(cwd) || {};
115
+ // Artifact paths may be relocated outside cwd (COMP-PATHS-EXTERNAL); resolve
116
+ // through the shared resolver. vision-state + changelog stay at the root.
114
117
  return {
115
- roadmap: path.join(cwd, 'ROADMAP.md'),
118
+ roadmap: resolveRoadmapPathFromConfig(cwd, cfg),
116
119
  visionState: path.join(cwd, '.compose', 'data', 'vision-state.json'),
117
- features: path.join(cwd, paths.features || DEFAULT_PATHS.features),
118
- journal: path.join(cwd, paths.journal || DEFAULT_PATHS.journal),
120
+ features: resolveFeaturesPathFromConfig(cwd, cfg),
121
+ journal: resolveJournalPathFromConfig(cwd, cfg),
119
122
  changelog: path.join(cwd, 'CHANGELOG.md'),
120
123
  };
121
124
  }
@@ -1125,6 +1128,18 @@ export async function validateProject(cwd, options = {}) {
1125
1128
  const ctx = loadValidationContext(cwd, options);
1126
1129
  const findings = [];
1127
1130
 
1131
+ // COMP-PATHS-EXTERNAL: a relocated artifact path may resolve into a parent
1132
+ // that does not exist (e.g. the external docs repo isn't checked out). A
1133
+ // not-yet-created leaf dir is fine — writers mkdir it — but an unreachable
1134
+ // PARENT is a configuration error worth surfacing with the resolved path.
1135
+ for (const [key, p] of [['features', ctx.paths.features], ['roadmap', ctx.paths.roadmap]]) {
1136
+ const isExternal = path.relative(cwd, p).startsWith('..');
1137
+ if (isExternal && !fs.existsSync(path.dirname(p))) {
1138
+ findings.push(finding('error', 'CONFIGURED_PATH_UNREACHABLE', null,
1139
+ `configured ${key} path parent does not exist: ${path.dirname(p)} (resolved ${key} = ${p})`));
1140
+ }
1141
+ }
1142
+
1128
1143
  const allCodes = new Set([
1129
1144
  ...ctx.roadmapByCode.keys(),
1130
1145
  ...ctx.visionByCode.keys(),
@@ -1146,9 +1161,7 @@ export async function validateProject(cwd, options = {}) {
1146
1161
 
1147
1162
  // --- COMP-ROADMAP-RT: roundtrip + hierarchy ---
1148
1163
  if (ctx.featureJsonMode) {
1149
- const cfg = readProjectConfig(cwd);
1150
- const featuresDir = (cfg && cfg.paths && cfg.paths.features) || DEFAULT_PATHS.features;
1151
- const features = listFeatures(cwd, featuresDir);
1164
+ const features = listFeatures(cwd, ctx.paths.features);
1152
1165
  const roadmapText = fs.existsSync(ctx.paths.roadmap)
1153
1166
  ? fs.readFileSync(ctx.paths.roadmap, 'utf8')
1154
1167
  : '';
@@ -20,6 +20,7 @@ import { fileURLToPath } from 'url';
20
20
  import { SchemaValidator } from '../server/schema-validator.js';
21
21
  import { FEATURE_CODE_RE_STRICT } from './feature-code.js';
22
22
  import { splitRoadmapCells } from './roadmap-parser.js';
23
+ import { resolveFeaturesPath, resolveRoadmapPath } from './project-paths.js';
23
24
 
24
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
25
26
  const SCHEMA_PATH = resolve(__dirname, '../contracts/feature-json.schema.json');
@@ -77,14 +78,11 @@ export function assertValidLinkShape(feature) {
77
78
  }
78
79
 
79
80
  function resolvePaths(cwd) {
80
- let featuresRel = 'docs/features';
81
- try {
82
- const cfg = JSON.parse(readFileSync(join(cwd, '.compose', 'compose.json'), 'utf-8'));
83
- if (cfg?.paths?.features) featuresRel = cfg.paths.features;
84
- } catch { /* no config — use defaults (mirrors resolveProjectPaths) */ }
81
+ // Artifact paths may be relocated outside cwd (COMP-PATHS-EXTERNAL); resolve
82
+ // through the shared resolver. vision-state stays at the root (D4).
85
83
  return {
86
- features: join(cwd, featuresRel),
87
- roadmap: join(cwd, 'ROADMAP.md'),
84
+ features: resolveFeaturesPath(cwd),
85
+ roadmap: resolveRoadmapPath(cwd),
88
86
  visionState: join(cwd, '.compose', 'data', 'vision-state.json'),
89
87
  };
90
88
  }
@@ -21,7 +21,7 @@ import { resolve, normalize, sep, basename, dirname, join } from 'path';
21
21
 
22
22
  import { readEvents } from './feature-events.js';
23
23
  import { checkOrInsert } from './idempotency.js';
24
- import { loadFeaturesDir } from './project-paths.js';
24
+ import { loadFeaturesDir, resolveRoadmapPath } from './project-paths.js';
25
25
  import { checkRoundtrip } from './roadmap-roundtrip.js';
26
26
  import { isNarrativeOwned, narrativeOwnedMessage } from './roadmap-config.js';
27
27
  import { knownFeatureCodes, FeatureWriteValidationError } from './feature-write-guard.js';
@@ -238,7 +238,7 @@ function readRoadmapBase(roadmapPath) {
238
238
  async function roundtripGuard(cwd, provider, mutate, { force, label }) {
239
239
  const current = await provider.listFeatures();
240
240
  const projected = mutate(current.map(f => ({ ...f })));
241
- const roadmapPath = join(cwd, 'ROADMAP.md');
241
+ const roadmapPath = resolveRoadmapPath(cwd);
242
242
  // Read the existing ROADMAP as the regen base, but only when it's a readable
243
243
  // regular file. If the path is missing, a directory, or otherwise unreadable,
244
244
  // treat the base as empty: the guard must never mask the downstream
@@ -728,7 +728,7 @@ export async function setRoadmapRowStatus(cwd, args) {
728
728
  throw new Error(`feature-writer: invalid status "${args.status}"`);
729
729
  }
730
730
  return maybeIdempotent({ ...args, cwd }, async () => {
731
- const roadmapPath = join(cwd, 'ROADMAP.md');
731
+ const roadmapPath = resolveRoadmapPath(cwd);
732
732
  let text;
733
733
  try { text = readFileSync(roadmapPath, 'utf-8'); }
734
734
  catch { return { code: args.code, changed: false }; }
@@ -28,7 +28,7 @@ import { writeRoadmap } from './roadmap-gen.js';
28
28
  import { appendEvent } from './feature-events.js';
29
29
  import { checkOrInsert } from './idempotency.js';
30
30
  import { FEATURE_CODE_RE_STRICT } from './feature-code.js';
31
- import { loadFeaturesDir } from './project-paths.js';
31
+ import { loadFeaturesDir, resolveRoadmapPath } from './project-paths.js';
32
32
 
33
33
  const TERMINAL_STATUSES = new Set(['KILLED', 'SUPERSEDED']);
34
34
  const VALID_STATUSES = new Set([
@@ -509,7 +509,7 @@ async function orchestrate({ cwd, parent_code, parent, args, phase, status, requ
509
509
  parent_code,
510
510
  phase: created?.phase ?? phase,
511
511
  position: created?.position,
512
- roadmap_path: resolve(cwd, 'ROADMAP.md'),
512
+ roadmap_path: resolveRoadmapPath(cwd),
513
513
  scaffolded: scaffolded ?? { created: [], skipped: ['design.md'] },
514
514
  link: { kind: 'surfaced_by', from_code: allocated_code, to_code: parent_code },
515
515
  };
@@ -12,11 +12,11 @@
12
12
  * - Row parsing reuses parseRoadmap — no second hand-rolled regex.
13
13
  */
14
14
  import { existsSync, readFileSync } from 'node:fs';
15
- import { join } from 'node:path';
16
15
 
17
16
  import { generateRoadmap } from './roadmap-gen.js';
18
17
  import { isNarrativeOwned } from './roadmap-config.js';
19
18
  import { parseRoadmap, parseStatusToken } from './roadmap-parser.js';
19
+ import { resolveRoadmapPath } from './project-paths.js';
20
20
 
21
21
  // Whole `**Last updated:** <date>` line — date-only and per-day, so it is
22
22
  // stripped (not literal-matched) before drift comparison.
@@ -56,7 +56,7 @@ function stripVolatile(text) {
56
56
  export function getRoadmap(root, opts = {}) {
57
57
  const { status, phase, format = 'summary', check_drift = true, limit } = opts ?? {};
58
58
 
59
- const roadmapPath = join(root, 'ROADMAP.md');
59
+ const roadmapPath = resolveRoadmapPath(root);
60
60
  const onDisk = existsSync(roadmapPath) ? readFileSync(roadmapPath, 'utf-8') : '';
61
61
 
62
62
  // Branch on narrative ownership ourselves: read the file directly for narrative
package/lib/gsd.js CHANGED
@@ -33,6 +33,7 @@ import { writeGsdState, readGsdState, gsdStatePath, pidAlive, clearGsdHaltArtifa
33
33
  import { generateGsdMilestoneReport } from './gsd-milestone-report.js';
34
34
  import { readHeadlessConfig } from './gsd-headless-config.js';
35
35
  import { appendGsdEvent, clearGsdEvents } from './gsd-events.js';
36
+ import { resolveFeaturesPath } from './project-paths.js';
36
37
 
37
38
  const __dirname = dirname(fileURLToPath(import.meta.url));
38
39
  const PACKAGE_ROOT = resolve(__dirname, '..');
@@ -62,7 +63,7 @@ export async function runGsd(featureCode, opts = {}) {
62
63
  }
63
64
 
64
65
  // 1. Validate preconditions: blueprint exists + Boundary Map ok
65
- const blueprintPath = join(cwd, 'docs', 'features', featureCode, 'blueprint.md');
66
+ const blueprintPath = join(resolveFeaturesPath(cwd), featureCode, 'blueprint.md');
66
67
  if (!existsSync(blueprintPath)) {
67
68
  throw new Error(
68
69
  `runGsd: blueprint missing at ${blueprintPath}. ` +
package/lib/ideabox.js CHANGED
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
18
18
  import { join, dirname } from 'node:path'
19
+ import { resolvePathValue } from './paths-core.js'
19
20
 
20
21
  // ---------------------------------------------------------------------------
21
22
  // Default template
@@ -540,7 +541,7 @@ export function loadLens(cwd, lensName) {
540
541
  * Read and parse the ideabox file from the project.
541
542
  */
542
543
  export function readIdeabox(cwd, ideaboxPath) {
543
- const fullPath = join(cwd, ideaboxPath)
544
+ const fullPath = resolvePathValue(cwd, ideaboxPath, 'ideabox')
544
545
  if (!existsSync(fullPath)) {
545
546
  // Return empty state
546
547
  return { ideas: [], killed: [], nextId: 1 }
@@ -553,7 +554,7 @@ export function readIdeabox(cwd, ideaboxPath) {
553
554
  * Write serialized ideabox back to disk.
554
555
  */
555
556
  export function writeIdeabox(cwd, ideaboxPath, parsedData) {
556
- const fullPath = join(cwd, ideaboxPath)
557
+ const fullPath = resolvePathValue(cwd, ideaboxPath, 'ideabox')
557
558
  mkdirSync(dirname(fullPath), { recursive: true })
558
559
  writeFileSync(fullPath, serializeIdeabox(parsedData))
559
560
  }
@@ -29,12 +29,12 @@ import { join, dirname } from 'node:path';
29
29
 
30
30
  import { appendEvent, normalizeSince } from './feature-events.js';
31
31
  import { checkOrInsert } from './idempotency.js';
32
+ import { resolveJournalPath } from './project-paths.js';
32
33
 
33
34
  // ---------------------------------------------------------------------------
34
35
  // Constants & regexes
35
36
  // ---------------------------------------------------------------------------
36
37
 
37
- const JOURNAL_DIR = 'docs/journal';
38
38
  const INDEX_FILE = 'README.md';
39
39
  const ENTRIES_HEADING = '## Entries';
40
40
 
@@ -649,7 +649,7 @@ export async function writeJournalEntry(cwd, args) {
649
649
  validateArgs(args);
650
650
 
651
651
  return maybeIdempotent({ ...args, cwd }, async () => {
652
- const journalDir = join(cwd, JOURNAL_DIR);
652
+ const journalDir = resolveJournalPath(cwd);
653
653
  const indexPath = join(journalDir, INDEX_FILE);
654
654
 
655
655
  // Pre-flight: read + parse index before any disk mutation.
@@ -857,7 +857,7 @@ export async function writeJournalEntry(cwd, args) {
857
857
  * @returns {{ entries: Array, count: number }}
858
858
  */
859
859
  export function getJournalEntries(cwd, opts = {}) {
860
- const journalDir = join(cwd, JOURNAL_DIR);
860
+ const journalDir = resolveJournalPath(cwd);
861
861
  if (!existsSync(journalDir)) return { entries: [], count: 0 };
862
862
 
863
863
  const sinceMs = opts.since !== undefined ? normalizeSince(opts.since) : null;
@@ -6,10 +6,9 @@
6
6
  */
7
7
 
8
8
  import { readFileSync, existsSync } from 'fs';
9
- import { join } from 'path';
10
9
  import { parseRoadmap } from './roadmap-parser.js';
11
10
  import { readFeature, writeFeature } from './feature-json.js';
12
- import { loadFeaturesDir } from './project-paths.js';
11
+ import { loadFeaturesDir, resolveRoadmapPath } from './project-paths.js';
13
12
 
14
13
  /**
15
14
  * Migrate ROADMAP.md entries to feature.json files.
@@ -31,7 +30,7 @@ export function migrateRoadmap(cwd, opts = {}) {
31
30
  const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
32
31
  const externalPrefixes = opts.externalPrefixes ?? [];
33
32
  const isExternal = (code) => externalPrefixes.some((p) => code.startsWith(p));
34
- const roadmapPath = join(cwd, 'ROADMAP.md');
33
+ const roadmapPath = resolveRoadmapPath(cwd);
35
34
 
36
35
  if (!existsSync(roadmapPath)) {
37
36
  throw new Error(`No ROADMAP.md found at ${roadmapPath}`);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * paths-core.js — PURE artifact-path resolution. No fs, no config reading.
3
+ * Single source of truth for default artifact locations (COMP-PATHS-EXTERNAL).
4
+ *
5
+ * `resolvePathValue` uses path.resolve (NOT path.join) so an absolute or
6
+ * ../-escaping `paths.*` override resolves correctly instead of being
7
+ * silently re-rooted under cwd.
8
+ */
9
+ import path from 'node:path';
10
+
11
+ export const DEFAULT_PATHS = Object.freeze({
12
+ docs: 'docs',
13
+ roadmap: 'ROADMAP.md',
14
+ features: 'docs/features',
15
+ journal: 'docs/journal',
16
+ context: 'docs/context',
17
+ ideabox: 'docs/product/ideabox.md',
18
+ });
19
+
20
+ /**
21
+ * Resolve a configured `paths[key]` value to an absolute, normalized path.
22
+ *
23
+ * @param {string} root Absolute workspace root.
24
+ * @param {*} value The configured paths[key] value (any type).
25
+ * @param {string} key Fallback key into DEFAULT_PATHS when value is unusable.
26
+ * @returns {string} Absolute, normalized path.
27
+ */
28
+ export function resolvePathValue(root, value, key) {
29
+ const v = (typeof value === 'string' && value.trim().length > 0)
30
+ ? value
31
+ : DEFAULT_PATHS[key];
32
+ return path.isAbsolute(v) ? path.normalize(v) : path.resolve(root, v);
33
+ }
34
+
35
+ /**
36
+ * Display-safe relativization: a clean root-relative string when `abs` is
37
+ * inside `root`, else the absolute path (never a `../`-prefixed string).
38
+ *
39
+ * @param {string} root Absolute workspace root.
40
+ * @param {string} abs Absolute path to relativize for display.
41
+ * @returns {string}
42
+ */
43
+ export function relForDisplay(root, abs) {
44
+ const rel = path.relative(root, abs);
45
+ if (rel === '') return '.';
46
+ return rel.startsWith('..') || path.isAbsolute(rel) ? abs : rel;
47
+ }