@smartmemory/compose 0.2.48-beta → 0.2.50-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.claude/skills/compose/SKILL.md +2 -0
  2. package/.compose-deps.json +13 -0
  3. package/bin/compose.js +35 -20
  4. package/dist/assets/{App-D7E7S49Q.js → App-Bxyif1yv.js} +160 -160
  5. package/dist/assets/{arc-LOW60tiI.js → arc-DeHak63Z.js} +1 -1
  6. package/dist/assets/{architectureDiagram-3BPJPVTR-BDY00hRy.js → architectureDiagram-3BPJPVTR-C5Nkyl9y.js} +1 -1
  7. package/dist/assets/{blockDiagram-GPEHLZMM-DiC_6ktq.js → blockDiagram-GPEHLZMM-BilnUBG2.js} +1 -1
  8. package/dist/assets/{c4Diagram-AAUBKEIU-CXZC8woU.js → c4Diagram-AAUBKEIU-BsFJmPuS.js} +1 -1
  9. package/dist/assets/channel-ZgbQ1k0u.js +1 -0
  10. package/dist/assets/{chunk-2J33WTMH-b8Rdk4S-.js → chunk-2J33WTMH-AEu6HIoY.js} +1 -1
  11. package/dist/assets/{chunk-4BX2VUAB-BSxn03f-.js → chunk-4BX2VUAB-XO4S2_89.js} +1 -1
  12. package/dist/assets/{chunk-55IACEB6-uUk1WzZ1.js → chunk-55IACEB6-CtFSLInj.js} +1 -1
  13. package/dist/assets/{chunk-727SXJPM-zvXhT6iZ.js → chunk-727SXJPM-CZeBRpM9.js} +1 -1
  14. package/dist/assets/{chunk-AQP2D5EJ-B6GCyrIp.js → chunk-AQP2D5EJ-_vmxcBc4.js} +1 -1
  15. package/dist/assets/{chunk-FMBD7UC4-DlhrqkBp.js → chunk-FMBD7UC4-BMGd_Y9a.js} +1 -1
  16. package/dist/assets/{chunk-ND2GUHAM-lo_8kNzw.js → chunk-ND2GUHAM-BlrOECEr.js} +1 -1
  17. package/dist/assets/{chunk-QZHKN3VN-on-yD-C0.js → chunk-QZHKN3VN-DHhUJ8RK.js} +1 -1
  18. package/dist/assets/classDiagram-4FO5ZUOK-KXxOorUx.js +1 -0
  19. package/dist/assets/classDiagram-v2-Q7XG4LA2-KXxOorUx.js +1 -0
  20. package/dist/assets/{cose-bilkent-S5V4N54A-DxV7Y2bU.js → cose-bilkent-S5V4N54A-C3M0mZqk.js} +1 -1
  21. package/dist/assets/{dagre-BM42HDAG-BHf9sURy.js → dagre-BM42HDAG-DJlm4fEk.js} +1 -1
  22. package/dist/assets/{diagram-2AECGRRQ-R9Puxzlh.js → diagram-2AECGRRQ-BmcONQG5.js} +1 -1
  23. package/dist/assets/{diagram-5GNKFQAL-BbjlClKx.js → diagram-5GNKFQAL-5JbWGIwd.js} +1 -1
  24. package/dist/assets/{diagram-KO2AKTUF-r89Mx4m1.js → diagram-KO2AKTUF-DAnIvyI0.js} +1 -1
  25. package/dist/assets/{diagram-LMA3HP47-CstVDJZ3.js → diagram-LMA3HP47-Bmh7tvmI.js} +1 -1
  26. package/dist/assets/{diagram-OG6HWLK6-xmX57FKh.js → diagram-OG6HWLK6-hnHVMNnk.js} +1 -1
  27. package/dist/assets/{erDiagram-TEJ5UH35-DhLPxvrl.js → erDiagram-TEJ5UH35-AMDitV0b.js} +1 -1
  28. package/dist/assets/{flowDiagram-I6XJVG4X-BExoUaNm.js → flowDiagram-I6XJVG4X-C0t2ZGQv.js} +1 -1
  29. package/dist/assets/{ganttDiagram-6RSMTGT7-BEXa5a22.js → ganttDiagram-6RSMTGT7-BPlM-BEN.js} +1 -1
  30. package/dist/assets/{gitGraphDiagram-PVQCEYII-Cbes9api.js → gitGraphDiagram-PVQCEYII-Lv_YTxnb.js} +1 -1
  31. package/dist/assets/index-COq21Zym.js +119 -0
  32. package/dist/assets/{infoDiagram-5YYISTIA-UBm4ssfm.js → infoDiagram-5YYISTIA-Dul1vdUm.js} +1 -1
  33. package/dist/assets/{ishikawaDiagram-YF4QCWOH-CksBYHCF.js → ishikawaDiagram-YF4QCWOH-CCBwgjFs.js} +1 -1
  34. package/dist/assets/{journeyDiagram-JHISSGLW-CKHkIHky.js → journeyDiagram-JHISSGLW-zUJN38EU.js} +1 -1
  35. package/dist/assets/{kanban-definition-UN3LZRKU-CEahMFzE.js → kanban-definition-UN3LZRKU-BNxWi-8J.js} +1 -1
  36. package/dist/assets/{linear-CqLVtYRk.js → linear-CRH9b4g7.js} +1 -1
  37. package/dist/assets/{mindmap-definition-RKZ34NQL-C61zWt4M.js → mindmap-definition-RKZ34NQL-DolYwq7Y.js} +1 -1
  38. package/dist/assets/{pieDiagram-4H26LBE5-BIpZHyJW.js → pieDiagram-4H26LBE5-oxNfr2TX.js} +1 -1
  39. package/dist/assets/{quadrantDiagram-W4KKPZXB-Db7nRbZm.js → quadrantDiagram-W4KKPZXB-BmZCvD-z.js} +1 -1
  40. package/dist/assets/{requirementDiagram-4Y6WPE33-BQqFCAr0.js → requirementDiagram-4Y6WPE33-DsXZ05jo.js} +1 -1
  41. package/dist/assets/{sankeyDiagram-5OEKKPKP-D-oAAMci.js → sankeyDiagram-5OEKKPKP-DsnYaawP.js} +1 -1
  42. package/dist/assets/{sequenceDiagram-3UESZ5HK-Dc4Cp0Om.js → sequenceDiagram-3UESZ5HK-Dt5mu3g8.js} +1 -1
  43. package/dist/assets/{stateDiagram-AJRCARHV-B5DkQ8Pr.js → stateDiagram-AJRCARHV-BFR5ZINQ.js} +1 -1
  44. package/dist/assets/stateDiagram-v2-BHNVJYJU-BDrQD8fR.js +1 -0
  45. package/dist/assets/{timeline-definition-PNZ67QCA-DU9Fl-FW.js → timeline-definition-PNZ67QCA-Bvnhw58e.js} +1 -1
  46. package/dist/assets/{vennDiagram-CIIHVFJN-Bthz7siw.js → vennDiagram-CIIHVFJN-5WnyjZqf.js} +1 -1
  47. package/dist/assets/{wardley-L42UT6IY-nqdlFSIA.js → wardley-L42UT6IY-CTxW4xow.js} +1 -1
  48. package/dist/assets/{wardleyDiagram-YWT4CUSO-BJ1BC5zc.js → wardleyDiagram-YWT4CUSO-K8Y1EOvb.js} +1 -1
  49. package/dist/assets/{xychartDiagram-2RQKCTM6-Bib5JrbR.js → xychartDiagram-2RQKCTM6-BCShes6B.js} +1 -1
  50. package/dist/index.html +1 -1
  51. package/lib/build-all.js +2 -1
  52. package/lib/build.js +96 -6
  53. package/lib/checkpoint/checkpoint-writer.js +3 -2
  54. package/lib/completion-writer.js +38 -14
  55. package/lib/deps.js +98 -1
  56. package/lib/feature-json.js +14 -3
  57. package/lib/feature-validator.js +22 -9
  58. package/lib/feature-write-guard.js +5 -7
  59. package/lib/feature-writer.js +3 -3
  60. package/lib/followup-writer.js +2 -2
  61. package/lib/get-roadmap.js +2 -2
  62. package/lib/gsd.js +2 -1
  63. package/lib/ideabox.js +3 -2
  64. package/lib/journal-writer.js +3 -3
  65. package/lib/migrate-roadmap.js +2 -3
  66. package/lib/paths-core.js +47 -0
  67. package/lib/project-paths.js +46 -37
  68. package/lib/roadmap-gen.js +3 -4
  69. package/lib/roadmap-graph/collect.js +5 -5
  70. package/lib/roadmap-graph/index.js +2 -2
  71. package/lib/rtk.js +66 -0
  72. package/lib/state-migrations.js +211 -42
  73. package/lib/tracker/local-provider.js +2 -2
  74. package/lib/triage.js +7 -5
  75. package/lib/vision-writer.js +6 -13
  76. package/lib/xref-push.js +4 -4
  77. package/lib/xref-sync.js +4 -4
  78. package/package.json +1 -1
  79. package/server/compose-mcp-tools.js +9 -1
  80. package/server/design-routes.js +9 -4
  81. package/server/drift-axes.js +5 -3
  82. package/server/feature-scan.js +8 -2
  83. package/server/file-watcher.js +13 -7
  84. package/server/ideabox-routes.js +12 -11
  85. package/server/project-root.js +21 -4
  86. package/server/session-routes.js +2 -2
  87. package/server/stratum-sync.js +85 -9
  88. package/server/vision-routes.js +17 -2
  89. package/server/vision-server.js +4 -3
  90. package/server/vision-store.js +6 -11
  91. package/server/vision-utils.js +23 -4
  92. package/dist/assets/channel-Dh96pu1k.js +0 -1
  93. package/dist/assets/classDiagram-4FO5ZUOK-BCqpDcaS.js +0 -1
  94. package/dist/assets/classDiagram-v2-Q7XG4LA2-BCqpDcaS.js +0 -1
  95. package/dist/assets/index-DNuHtZwR.js +0 -123
  96. package/dist/assets/stateDiagram-v2-BHNVJYJU-BM1Uf6a_.js +0 -1
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';
@@ -31,10 +31,11 @@ import { resolveAgentConfig } from './agent-string.js';
31
31
  import { installFactoryShim } from './connector-factory-shim.js';
32
32
  import { emitSections as emitPlanSections, appendTrailers as appendSectionTrailers, analyzeRollup, writeRollup } from './sections.js';
33
33
  import { SECTIONS_DIR } from './constants.js';
34
+ import { rtkPrefix } from './rtk.js';
34
35
 
35
36
  import YAML from 'yaml';
36
37
  // feature-json direct imports removed — mutations now go through TrackerProvider (T9)
37
- import { loadFeaturesDir } from './project-paths.js';
38
+ import { loadFeaturesDir, resolveContextPath, resolveRoadmapPath, resolveFeaturesPath } from './project-paths.js';
38
39
 
39
40
  // Lazy provider accessor — avoids circular import risk (factory → local-provider
40
41
  // does NOT import build.js, so a static import is safe, but lazy is used for
@@ -214,7 +215,10 @@ export async function maybeRunEscalation(stratum, context, progress, streamWrite
214
215
  }
215
216
  let currentDiff = '';
216
217
  try {
217
- currentDiff = execSync('git diff --no-color HEAD', {
218
+ // COMP-RTK-INTEROP: this diff is fed to Codex (LLM) for the tier-1 review, so
219
+ // route it through RTK when available to compress before the 8000-char cap.
220
+ // rtkPrefix is a no-op (byte-identical) when rtk is absent.
221
+ currentDiff = execSync(rtkPrefix('git diff --no-color HEAD'), {
218
222
  cwd: context.cwd, encoding: 'utf-8', timeout: 10_000,
219
223
  }).slice(0, 8000);
220
224
  } catch { /* not a git repo or no diff */ }
@@ -601,10 +605,19 @@ export async function runBuild(featureCode, opts = {}) {
601
605
  // `docs/features/<featureCode>/`. Callers must use this (not inline
602
606
  // string concatenation) so the bug-mode path stays in sync.
603
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).
604
617
  const featuresDir = loadFeaturesDir(cwd);
605
618
  const resolveItemDir = (code) => isBugMode
606
619
  ? join(cwd, 'docs', 'bugs', code)
607
- : join(cwd, featuresDir, code);
620
+ : join(resolveFeaturesPath(cwd), code);
608
621
 
609
622
  // COMP-MCP-MIGRATION-1: per-build correlation ID stamped onto every audit
610
623
  // row written during this run, so `executeShipStep`'s pre-stage scan can
@@ -659,7 +672,7 @@ export async function runBuild(featureCode, opts = {}) {
659
672
  }
660
673
  let composeConfig = {};
661
674
  try { composeConfig = JSON.parse(readFileSync(configPath, 'utf-8')); } catch { /* use defaults */ }
662
- const contextDirPath = join(cwd, composeConfig.paths?.context ?? 'docs/context');
675
+ const contextDirPath = resolveContextPath(cwd);
663
676
 
664
677
  // COMP-PAR-MERGE-QUEUE-CONSUMER-RETRY (D5): per-task pre-merge gate is opt-in.
665
678
  // Resolve ONCE and thread into startFresh's planInputs only when the capability
@@ -2215,6 +2228,43 @@ export function maybeEmitSectionsAfterPlanGate(stepId, featureDir, opts = {}) {
2215
2228
  // Ship step — runs git commit in-process (not via agent)
2216
2229
  // ---------------------------------------------------------------------------
2217
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
+
2218
2268
  /**
2219
2269
  * Execute the ship step: run tests, stage feature files, commit.
2220
2270
  * Returns a PhaseResult-shaped object.
@@ -2236,11 +2286,34 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2236
2286
  } catch { /* not a git repo */ }
2237
2287
 
2238
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
+ }
2239
2310
  return {
2240
2311
  phase: 'ship',
2241
2312
  artifact: 'no-git',
2242
2313
  outcome: 'complete',
2243
- 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 } : {}),
2244
2317
  };
2245
2318
  }
2246
2319
 
@@ -2312,6 +2385,18 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2312
2385
  const dataDir = join(cwd, '.compose', 'data');
2313
2386
  const mode = readEnforcementMode(dataDir);
2314
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
+ }
2315
2400
  const events = readEvents(cwd, { since: context.buildStartedAt });
2316
2401
  const { violations } = scanGuarded({
2317
2402
  dirtyFiles: featureFiles,
@@ -2437,6 +2522,11 @@ export async function executeShipStep(featureCode, agentCwd, cwd, context, descr
2437
2522
  }
2438
2523
  }
2439
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
+
2440
2530
  return {
2441
2531
  phase: 'ship',
2442
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 () => {
package/lib/deps.js CHANGED
@@ -13,6 +13,7 @@
13
13
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
14
14
  import { join } from 'node:path'
15
15
  import { homedir } from 'node:os'
16
+ import { spawnSync } from 'node:child_process'
16
17
 
17
18
  /**
18
19
  * Load .compose-deps.json from `packageRoot`. Returns the parsed manifest with
@@ -50,7 +51,34 @@ export function loadDeps(packageRoot) {
50
51
  }
51
52
  valid.push(dep)
52
53
  }
53
- return { ...raw, external_skills: valid }
54
+
55
+ // COMP-RTK-INTEROP: external_binaries is optional and additive. A missing or
56
+ // malformed array degrades to [] — it never nulls the whole manifest (the
57
+ // required external_skills check above remains the only fatal gate).
58
+ const validBinaries = []
59
+ if (raw.external_binaries !== undefined) {
60
+ if (!Array.isArray(raw.external_binaries)) {
61
+ console.warn('Warning: .compose-deps.json external_binaries must be an array — ignoring')
62
+ } else {
63
+ for (const bin of raw.external_binaries) {
64
+ const idOk = typeof bin?.id === 'string'
65
+ const detectOk = typeof bin?.detect === 'string'
66
+ const installOk = typeof bin?.install === 'string'
67
+ const optOk = typeof bin?.optional === 'boolean'
68
+ // required_for / recommend are optional; validate only when present.
69
+ const reqOk = bin?.required_for === undefined
70
+ || (Array.isArray(bin.required_for) && bin.required_for.every(v => typeof v === 'string'))
71
+ const recOk = bin?.recommend === undefined || bin?.recommend === null || typeof bin?.recommend === 'string'
72
+ if (!(idOk && detectOk && installOk && optOk && reqOk && recOk)) {
73
+ console.warn(`Warning: skipping invalid binary entry in .compose-deps.json: ${JSON.stringify(bin)}`)
74
+ continue
75
+ }
76
+ validBinaries.push(bin)
77
+ }
78
+ }
79
+ }
80
+
81
+ return { ...raw, external_skills: valid, external_binaries: validBinaries }
54
82
  }
55
83
 
56
84
  /**
@@ -188,6 +216,75 @@ export function buildDepReport(result) {
188
216
  }
189
217
  }
190
218
 
219
+ /**
220
+ * COMP-RTK-INTEROP — probe each declared external *binary* (e.g. rtk) on PATH.
221
+ *
222
+ * Returns { present: [...], missing: [...] }. Optional binaries never make the
223
+ * doctor fail; this only reports availability + install/recommend hints.
224
+ *
225
+ * @param {object} deps - from loadDeps (uses deps.external_binaries)
226
+ * @param {object} [opts]
227
+ * @param {(detect:string)=>boolean} [opts.probe] - injectable for tests; default
228
+ * splits the `detect` string on whitespace and runs it, treating exit 0 as present.
229
+ */
230
+ export function checkExternalBinaries(deps, opts = {}) {
231
+ const binaries = deps?.external_binaries ?? []
232
+ const probe = opts.probe ?? ((detect) => {
233
+ const parts = detect.trim().split(/\s+/)
234
+ const [bin, ...binArgs] = parts
235
+ try {
236
+ return spawnSync(bin, binArgs, { encoding: 'utf-8', timeout: 3000, stdio: 'pipe' }).status === 0
237
+ } catch {
238
+ return false
239
+ }
240
+ })
241
+ const present = []
242
+ const missing = []
243
+ for (const bin of binaries) {
244
+ if (probe(bin.detect)) present.push(bin); else missing.push(bin)
245
+ }
246
+ return { present, missing }
247
+ }
248
+
249
+ /**
250
+ * Build a JSON-serializable binary report (mirrors buildDepReport for binaries).
251
+ */
252
+ export function buildBinaryReport(result) {
253
+ const projectBin = (b) => ({
254
+ id: b.id,
255
+ detect: b.detect,
256
+ install: b.install,
257
+ recommend: b.recommend ?? null,
258
+ optional: b.optional,
259
+ })
260
+ return {
261
+ present: result.present.map(projectBin),
262
+ missing: result.missing.map(projectBin),
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Print a human-readable external-binary report. Returns true if all required
268
+ * (non-optional) binaries are present. Prints nothing when no binaries are declared.
269
+ */
270
+ export function printBinaryReport(result) {
271
+ const total = result.present.length + result.missing.length
272
+ if (total === 0) return true
273
+
274
+ console.log('\nExternal binaries:')
275
+ for (const bin of result.present) {
276
+ const rec = bin.recommend ? ` — recommend: ${bin.recommend}` : ''
277
+ console.log(` ✓ ${bin.id}${rec}`)
278
+ }
279
+ for (const bin of result.missing) {
280
+ const mark = bin.optional ? '○' : '✗'
281
+ const tag = bin.optional ? ' (optional)' : ''
282
+ const rec = bin.recommend ? ` — recommend: ${bin.recommend}` : ''
283
+ console.log(` ${mark} ${bin.id}${tag} — install: ${bin.install}${rec}`)
284
+ }
285
+ return result.missing.every(b => b.optional)
286
+ }
287
+
191
288
  export function printDepReport(result, opts = {}) {
192
289
  if (opts.json) {
193
290
  console.log(JSON.stringify(buildDepReport(result), null, 2))
@@ -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
  }