@smartmemory/compose 0.2.49-beta → 0.2.51-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/.claude/skills/compose/SKILL.md +24 -1
  2. package/.claude/skills/compose/references/hermes-tools.md +80 -0
  3. package/README.md +1 -1
  4. package/bin/compose.js +340 -70
  5. package/dist/assets/App-CT0vXPgd.js +724 -0
  6. package/dist/assets/_baseUniq-CsOHc_iS.js +1 -0
  7. package/dist/assets/{arc-B1TqAC_R.js → arc-BXht3LyE.js} +1 -1
  8. package/dist/assets/architectureDiagram-Q4EWVU46-BV1r86-w.js +36 -0
  9. package/dist/assets/blockDiagram-DXYQGD6D-BgcMJjAR.js +132 -0
  10. package/dist/assets/{browser-DW2JlJCC.js → browser-CnKiSnlr.js} +6 -6
  11. package/dist/assets/{c4Diagram-AAUBKEIU-Dc6swoQo.js → c4Diagram-AHTNJAMY-nBIRLJTv.js} +1 -1
  12. package/dist/assets/channel-Dd4XaiYv.js +1 -0
  13. package/dist/assets/{chunk-4BX2VUAB-CdqXgd68.js → chunk-4BX2VUAB-CNmGyhrp.js} +1 -1
  14. package/dist/assets/chunk-4TB4RGXK-MLT7I7w4.js +206 -0
  15. package/dist/assets/{chunk-55IACEB6-D7VauOmU.js → chunk-55IACEB6-D8KuKVLa.js} +1 -1
  16. package/dist/assets/{chunk-2J33WTMH-6HKDSnOw.js → chunk-EDXVE4YY-TPlt1bS2.js} +1 -1
  17. package/dist/assets/{chunk-FMBD7UC4-C1Z5m8DP.js → chunk-FMBD7UC4-tYY8xcjr.js} +1 -1
  18. package/dist/assets/chunk-OYMX7WX6-yBPwT1AT.js +231 -0
  19. package/dist/assets/{chunk-QZHKN3VN-DY9GU2uD.js → chunk-QZHKN3VN-B4Wfybww.js} +1 -1
  20. package/dist/assets/{chunk-ND2GUHAM-DmqZMEQe.js → chunk-YZCP3GAM-DE6_5aqh.js} +1 -1
  21. package/dist/assets/classDiagram-6PBFFD2Q-DshSQDF9.js +1 -0
  22. package/dist/assets/classDiagram-v2-HSJHXN6E-DshSQDF9.js +1 -0
  23. package/dist/assets/clone-CBsbNGAa.js +1 -0
  24. package/dist/assets/{cose-bilkent-S5V4N54A-B34g9xzV.js → cose-bilkent-S5V4N54A-DzzRyJtB.js} +1 -1
  25. package/dist/assets/dagre-KV5264BT-CTcTVGdU.js +4 -0
  26. package/dist/assets/diagram-5BDNPKRD-qR6mc8kJ.js +10 -0
  27. package/dist/assets/diagram-G4DWMVQ6-CUcGXyNb.js +24 -0
  28. package/dist/assets/diagram-MMDJMWI5-B68cPonH.js +43 -0
  29. package/dist/assets/diagram-TYMM5635-q8Id8eFG.js +24 -0
  30. package/dist/assets/{erDiagram-TEJ5UH35-C05_Iqmb.js → erDiagram-SMLLAGMA-CtxJi57K.js} +1 -1
  31. package/dist/assets/{flowDiagram-I6XJVG4X-lXpnb-Ch.js → flowDiagram-DWJPFMVM-BzzO4JYU.js} +1 -1
  32. package/dist/assets/ganttDiagram-T4ZO3ILL-Dn0wIyhR.js +292 -0
  33. package/dist/assets/gitGraphDiagram-UUTBAWPF-tnNFXfMM.js +106 -0
  34. package/dist/assets/graph-DZe55uk8.js +331 -0
  35. package/dist/assets/graph-Tq_bs_r0.js +1 -0
  36. package/dist/assets/index-8UhRLbGq.js +123 -0
  37. package/dist/assets/index-CRqB9els.css +1 -0
  38. package/dist/assets/infoDiagram-42DDH7IO-BXLvLeS0.js +2 -0
  39. package/dist/assets/{ishikawaDiagram-YF4QCWOH-CgdsWdgu.js → ishikawaDiagram-UXIWVN3A-DWx-7BBy.js} +5 -5
  40. package/dist/assets/{journeyDiagram-JHISSGLW-CyvHmE3z.js → journeyDiagram-VCZTEJTY-C42hXNha.js} +1 -1
  41. package/dist/assets/{kanban-definition-UN3LZRKU-Bqjdyv0T.js → kanban-definition-6JOO6SKY-CEq930ew.js} +1 -1
  42. package/dist/assets/katex-DkKDou_j.js +257 -0
  43. package/dist/assets/layout-qtUgN9BC.js +1 -0
  44. package/dist/assets/{linear-DecnRXf0.js → linear-D158F7OT.js} +1 -1
  45. package/dist/assets/min-hESxN-c0.js +1 -0
  46. package/dist/assets/{mindmap-definition-RKZ34NQL-DM1x9vNY.js → mindmap-definition-QFDTVHPH-d9SSh2nG.js} +7 -7
  47. package/dist/assets/mobile-1gVCT0OK.css +1 -0
  48. package/dist/assets/mobile-Chw8RWyH.js +17 -0
  49. package/dist/assets/pieDiagram-DEJITSTG-gglmeMav.js +30 -0
  50. package/dist/assets/quadrantDiagram-34T5L4WZ-CqFC_htU.js +7 -0
  51. package/dist/assets/{requirementDiagram-4Y6WPE33-DD-Gg_y5.js → requirementDiagram-MS252O5E-B1ZFIotK.js} +1 -1
  52. package/dist/assets/sankeyDiagram-XADWPNL6-BvItvZt5.js +10 -0
  53. package/dist/assets/sequenceDiagram-FGHM5R23-CAIn5Z3U.js +157 -0
  54. package/dist/assets/stateDiagram-FHFEXIEX-CyJrGzib.js +1 -0
  55. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DprhF_Ho.js +1 -0
  56. package/dist/assets/{timeline-definition-PNZ67QCA-BPm2AEQj.js → timeline-definition-GMOUNBTQ-k_0vJQAK.js} +1 -1
  57. package/dist/assets/{vennDiagram-CIIHVFJN-DQCDWwlj.js → vennDiagram-DHZGUBPP-TZAW60b0.js} +4 -4
  58. package/dist/assets/wardley-RL74JXVD-DzQEoG6X.js +162 -0
  59. package/dist/assets/wardleyDiagram-NUSXRM2D-BAoVVIjZ.js +20 -0
  60. package/dist/assets/{xychartDiagram-2RQKCTM6-Ba05SXXF.js → xychartDiagram-5P7HB3ND-CynkZcUb.js} +1 -1
  61. package/dist/index.html +4 -4
  62. package/lib/build-all.js +2 -1
  63. package/lib/build.js +363 -38
  64. package/lib/checkpoint/checkpoint-writer.js +3 -2
  65. package/lib/codex-preflight.js +151 -0
  66. package/lib/completion-writer.js +59 -15
  67. package/lib/feature-json.js +14 -3
  68. package/lib/feature-validator.js +27 -10
  69. package/lib/feature-write-guard.js +5 -7
  70. package/lib/feature-writer.js +3 -3
  71. package/lib/followup-writer.js +2 -2
  72. package/lib/get-roadmap.js +2 -2
  73. package/lib/gsd.js +2 -1
  74. package/lib/hooks-status.js +135 -0
  75. package/lib/ideabox.js +3 -2
  76. package/lib/install-agent-defs.js +22 -0
  77. package/lib/journal-writer.js +3 -3
  78. package/lib/migrate-anon.js +277 -0
  79. package/lib/migrate-roadmap.js +2 -3
  80. package/lib/paths-core.js +47 -0
  81. package/lib/project-paths.js +46 -37
  82. package/lib/roadmap-gen.js +3 -4
  83. package/lib/roadmap-graph/collect.js +5 -5
  84. package/lib/roadmap-graph/index.js +2 -2
  85. package/lib/roadmap-parser.js +1 -1
  86. package/lib/state-migrations.js +3 -3
  87. package/lib/test-bootstrap.js +165 -2
  88. package/lib/tracker/local-provider.js +2 -2
  89. package/lib/triage.js +7 -5
  90. package/lib/vocabulary-inject.js +103 -0
  91. package/lib/xref-push.js +4 -4
  92. package/lib/xref-sync.js +4 -4
  93. package/package.json +2 -1
  94. package/pipelines/build-quick.stratum.yaml +433 -0
  95. package/pipelines/build.stratum.yaml +60 -3
  96. package/scripts/watch-server.sh +32 -0
  97. package/server/build-routes.js +89 -9
  98. package/server/build-stream-bridge.js +12 -0
  99. package/server/compose-mcp-tools.js +73 -1
  100. package/server/compose-mcp.js +27 -0
  101. package/server/design-routes.js +9 -4
  102. package/server/drift-axes.js +5 -3
  103. package/server/feature-scaffold-routes.js +82 -0
  104. package/server/feature-scan.js +8 -2
  105. package/server/file-watcher.js +13 -7
  106. package/server/health-routes.js +261 -0
  107. package/server/ideabox-routes.js +12 -11
  108. package/server/project-root.js +7 -4
  109. package/server/qa-scope-routes.js +96 -0
  110. package/server/session-routes.js +2 -2
  111. package/server/stratum-sync.js +85 -9
  112. package/server/validate-routes.js +70 -0
  113. package/server/vision-routes.js +46 -2
  114. package/server/vision-server.js +25 -3
  115. package/server/vision-utils.js +23 -4
  116. package/dist/assets/App-DOerhoDp.js +0 -706
  117. package/dist/assets/architectureDiagram-3BPJPVTR-C4hfxNOw.js +0 -36
  118. package/dist/assets/blockDiagram-GPEHLZMM-DRqkGncy.js +0 -132
  119. package/dist/assets/channel-KsY1x-7s.js +0 -1
  120. package/dist/assets/chunk-727SXJPM-CeKpXDif.js +0 -206
  121. package/dist/assets/chunk-AQP2D5EJ-y2XL-YHV.js +0 -231
  122. package/dist/assets/classDiagram-4FO5ZUOK-zDEDoVPk.js +0 -1
  123. package/dist/assets/classDiagram-v2-Q7XG4LA2-zDEDoVPk.js +0 -1
  124. package/dist/assets/dagre-BM42HDAG-klZ_GOcw.js +0 -4
  125. package/dist/assets/diagram-2AECGRRQ-D0FmM6Wf.js +0 -43
  126. package/dist/assets/diagram-5GNKFQAL-DYuhby6q.js +0 -10
  127. package/dist/assets/diagram-KO2AKTUF-CCtQZgal.js +0 -3
  128. package/dist/assets/diagram-LMA3HP47-Cd1OqEln.js +0 -24
  129. package/dist/assets/diagram-OG6HWLK6-CiJrhoxo.js +0 -24
  130. package/dist/assets/ganttDiagram-6RSMTGT7-EPbL_2Hr.js +0 -292
  131. package/dist/assets/gitGraphDiagram-PVQCEYII-COCoF_vW.js +0 -106
  132. package/dist/assets/graph-BBXaecIU.js +0 -331
  133. package/dist/assets/graph-CAnANduQ.js +0 -1
  134. package/dist/assets/index-BwLfbbOu.css +0 -1
  135. package/dist/assets/index-DlgxA8dX.js +0 -123
  136. package/dist/assets/infoDiagram-5YYISTIA-B1sJw6j5.js +0 -2
  137. package/dist/assets/katex-C5jXJg4s.js +0 -257
  138. package/dist/assets/layout-DGIYPm2g.js +0 -1
  139. package/dist/assets/mobile-Cag5dHlF.css +0 -1
  140. package/dist/assets/mobile-DwmxS_O4.js +0 -17
  141. package/dist/assets/pieDiagram-4H26LBE5-CRtmCvwV.js +0 -30
  142. package/dist/assets/quadrantDiagram-W4KKPZXB-CsBLWg27.js +0 -7
  143. package/dist/assets/sankeyDiagram-5OEKKPKP-BPdMN8UU.js +0 -40
  144. package/dist/assets/sequenceDiagram-3UESZ5HK-D4AENdze.js +0 -162
  145. package/dist/assets/stateDiagram-AJRCARHV-DDplpCf_.js +0 -1
  146. package/dist/assets/stateDiagram-v2-BHNVJYJU-J4t9AVzF.js +0 -1
  147. package/dist/assets/wardley-L42UT6IY-WERqOmJt.js +0 -173
  148. package/dist/assets/wardleyDiagram-YWT4CUSO-5teUSOiI.js +0 -78
@@ -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) {
@@ -0,0 +1,151 @@
1
+ /**
2
+ * COMP-CODEX-IMPL — preflight probe for the Codex-as-implementer path.
3
+ *
4
+ * Codex self-applies a Seatbelt sandbox ([[project_codex_seatbelt_nonnesting]]).
5
+ * Compose's `execute` step runs each task agent inside a *detached git worktree*
6
+ * (`git worktree add --detach`), whose `.git` is a file pointer, not a directory.
7
+ * Whether Codex's Seatbelt tolerates writing inside such a worktree is UNVERIFIED —
8
+ * no Codex+worktree path exists in the repo today (Codex only ever ran read-only
9
+ * reviews with cwd at the repo root). If it cannot, a `--codex` build would
10
+ * hard-fail at `execute`. A runtime warning would only explain that after the fact.
11
+ *
12
+ * So before any real `--codex` work, we probe the exact primitive: create a throwaway
13
+ * detached worktree, have Codex write a sentinel file in it, and verify the write
14
+ * landed. On failure the build aborts fast with an actionable message rather than
15
+ * failing mid-execute. The result is cached per-repo (the primitive doesn't change
16
+ * between builds) and can be skipped via COMPOSE_SKIP_CODEX_PROBE for CI/tests.
17
+ *
18
+ * The proper fix for a probe-fails environment (a Codex `isolation: none` execution
19
+ * path) is deferred to COMP-CODEX-IMPL-SPIKE.
20
+ */
21
+ import { execSync } from 'node:child_process';
22
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { homedir } from 'node:os';
25
+
26
+ export const PROBE_SENTINEL = 'COMPOSE_CODEX_PROBE_OK';
27
+ const WORKTREE_BASE = join(homedir(), '.stratum', 'worktrees');
28
+ const PROBE_CACHE = 'codex-worktree-probe.json';
29
+ // Bound the probe's agent run so a wedged Codex CLI can't hang the whole build
30
+ // before the flow even starts. On timeout we reject → the finally cleanup runs.
31
+ const PROBE_AGENT_TIMEOUT_MS = 180_000;
32
+
33
+ function cachePath(dataDir) {
34
+ return join(dataDir, PROBE_CACHE);
35
+ }
36
+
37
+ export function readProbeCache(dataDir) {
38
+ try {
39
+ return JSON.parse(readFileSync(cachePath(dataDir), 'utf-8'));
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export function writeProbeCache(dataDir, result) {
46
+ try {
47
+ mkdirSync(dataDir, { recursive: true });
48
+ writeFileSync(cachePath(dataDir), JSON.stringify(result, null, 2));
49
+ } catch { /* best-effort cache; a miss just re-probes */ }
50
+ }
51
+
52
+ function isGitRepo(cwd) {
53
+ try {
54
+ execSync('git rev-parse --is-inside-work-tree', { cwd, encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Verify Codex can write inside a detached git worktree under its sandbox.
63
+ *
64
+ * @param {object} args
65
+ * @param {string} args.cwd repo working directory
66
+ * @param {object} args.stratum stratum client (uses runAgentText('codex', ...))
67
+ * @param {string} args.dataDir .compose/data dir for the per-repo cache
68
+ * @param {string} args.ts caller-supplied timestamp string (unique worktree name)
69
+ * @param {boolean} [args.force] ignore the cache and re-probe
70
+ * @returns {Promise<{ok: boolean, reason: string, cached?: boolean, skipped?: boolean}>}
71
+ */
72
+ export async function preflightCodexWorktreeProbe({ cwd, stratum, dataDir, ts, force = false }) {
73
+ if (process.env.COMPOSE_SKIP_CODEX_PROBE) {
74
+ return { ok: true, skipped: true, reason: 'COMPOSE_SKIP_CODEX_PROBE set — probe skipped' };
75
+ }
76
+ if (!force) {
77
+ const cached = readProbeCache(dataDir);
78
+ if (cached && cached.ok === true) return { ...cached, cached: true };
79
+ }
80
+ // Not a git repo → the execute step won't create worktrees (useWorktrees gates on
81
+ // isGitRepo), so there is no worktree primitive to probe. Treat as a pass.
82
+ if (!isGitRepo(cwd)) {
83
+ return { ok: true, reason: 'not a git repo — worktree isolation is not used, nothing to probe' };
84
+ }
85
+
86
+ try { mkdirSync(WORKTREE_BASE, { recursive: true }); } catch { /* best-effort */ }
87
+ const wtPath = join(WORKTREE_BASE, `codex-probe-${ts}`);
88
+
89
+ // Create the detached worktree (the exact primitive the execute step uses).
90
+ try {
91
+ execSync(`git worktree add "${wtPath}" --detach HEAD`, {
92
+ cwd, encoding: 'utf-8', timeout: 30_000, stdio: 'pipe',
93
+ });
94
+ } catch (err) {
95
+ try { execSync(`rm -rf "${wtPath}"`, { encoding: 'utf-8', timeout: 10_000 }); } catch { /* best-effort */ }
96
+ const result = { ok: false, reason: `could not create a probe worktree: ${err?.message ?? err}` };
97
+ writeProbeCache(dataDir, result);
98
+ return result;
99
+ }
100
+
101
+ // Unique per-run filename so a file committed in the repo (carried into the
102
+ // detached worktree from HEAD) can never make the probe false-pass.
103
+ const probeName = `codex-probe-${ts}.txt`;
104
+ try {
105
+ const prompt =
106
+ `Write a file named ${probeName} in the current working directory whose exact ` +
107
+ `contents are the single line: ${PROBE_SENTINEL}\n` +
108
+ `Do nothing else. This is an environment write-probe.`;
109
+ try {
110
+ // Bound the agent run — a hang here would otherwise stall the build and skip
111
+ // the finally cleanup. On timeout the race rejects and we fall to the catch.
112
+ await Promise.race([
113
+ stratum.runAgentText('codex', prompt, { cwd: wtPath }),
114
+ new Promise((_, reject) =>
115
+ setTimeout(() => reject(new Error(`probe timed out after ${PROBE_AGENT_TIMEOUT_MS}ms`)), PROBE_AGENT_TIMEOUT_MS).unref?.()
116
+ ),
117
+ ]);
118
+ } catch (err) {
119
+ const result = { ok: false, reason: `Codex agent run failed/timed out inside the worktree: ${err?.message ?? err}` };
120
+ writeProbeCache(dataDir, result);
121
+ return result;
122
+ }
123
+ const probeFile = join(wtPath, probeName);
124
+ let landed = false;
125
+ try {
126
+ landed = existsSync(probeFile) && readFileSync(probeFile, 'utf-8').includes(PROBE_SENTINEL);
127
+ } catch { landed = false; }
128
+ const result = landed
129
+ ? { ok: true, reason: 'Codex wrote the sentinel inside a detached worktree' }
130
+ : { ok: false, reason: 'Codex did not write the sentinel file inside the detached worktree (likely a Seatbelt/worktree write restriction)' };
131
+ writeProbeCache(dataDir, result);
132
+ return result;
133
+ } finally {
134
+ try {
135
+ execSync(`git worktree remove "${wtPath}" --force`, { cwd, encoding: 'utf-8', timeout: 30_000, stdio: 'pipe' });
136
+ } catch {
137
+ try { execSync(`rm -rf "${wtPath}"`, { encoding: 'utf-8', timeout: 10_000 }); } catch { /* give up */ }
138
+ }
139
+ }
140
+ }
141
+
142
+ /** The actionable abort message when the probe fails. */
143
+ export function codexProbeAbortMessage(reason) {
144
+ return (
145
+ `--codex aborted: Codex cannot write inside a detached git worktree in this environment.\n` +
146
+ ` Reason: ${reason}\n` +
147
+ ` This is the known COMP-CODEX-IMPL-SPIKE limitation (Codex Seatbelt vs worktree isolation).\n` +
148
+ ` Re-run without --codex (Claude implements), or set COMPOSE_SKIP_CODEX_PROBE=1 to bypass the\n` +
149
+ ` probe at your own risk. A Codex isolation:none execution path is tracked as the follow-up.`
150
+ );
151
+ }
@@ -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
@@ -193,6 +209,18 @@ function validate(args) {
193
209
  throw inputError('completion-writer: set_status must be a boolean');
194
210
  }
195
211
 
212
+ // built_via — OPTIONAL marker recording the pipeline template a feature was
213
+ // built with (e.g. 'build-quick'). Persisted on feature.json so downstream
214
+ // consumers (e.g. the validator's report-completion check) can tell which
215
+ // lifecycle produced the feature. Must be a lowercase template slug.
216
+ if (args.built_via !== undefined && args.built_via !== null) {
217
+ if (typeof args.built_via !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(args.built_via)) {
218
+ throw inputError(
219
+ `completion-writer: built_via must be a lowercase template slug (e.g. "build-quick"), got "${args.built_via}"`
220
+ );
221
+ }
222
+ }
223
+
196
224
  // force — boolean if present
197
225
  if (args.force !== undefined && typeof args.force !== 'boolean') {
198
226
  throw inputError('completion-writer: force must be a boolean');
@@ -248,6 +276,8 @@ async function safeAppendEvent(cwd, event) {
248
276
  * @param {string} [args.notes]
249
277
  * @param {boolean} [args.set_status] Default true — flip status to COMPLETE
250
278
  * @param {boolean} [args.force] Replace existing same-(code,sha) record
279
+ * @param {string} [args.built_via] Optional template slug (e.g. 'build-quick')
280
+ * stamped onto feature.json (COMP-BUILD-QUICK-1)
251
281
  * @param {string} [args.idempotency_key]
252
282
  *
253
283
  * @returns {{ feature_code, completion_id, commit_sha, commit_sha_short,
@@ -257,13 +287,21 @@ export async function recordCompletion(cwd, args) {
257
287
  // 1. Validate
258
288
  validate(args);
259
289
 
260
- // 2. Normalize SHA
261
- const commit_sha = args.commit_sha.trim().toLowerCase();
290
+ // 2. Normalize SHA (omitted/null ⇒ NULL_SHA sentinel — commit-less completion)
291
+ const commit_sha = (args.commit_sha === undefined || args.commit_sha === null)
292
+ ? NULL_SHA
293
+ : args.commit_sha.trim().toLowerCase();
262
294
  const commit_sha_short = commit_sha.slice(0, SHORT_LEN);
263
295
  const feature_code = args.feature_code;
264
296
 
265
- // 3. Compute completion_id
266
- const completion_id = `${feature_code}:${commit_sha}`;
297
+ // 3. Compute completion_id. For a real commit the SHA makes it stable and
298
+ // dedup-able. For a commit-less completion (NULL_SHA) the SHA is constant,
299
+ // so id off the sha would alias every no-commit completion of a feature
300
+ // onto one record (a re-completion would idempotent-no-op). Derive a
301
+ // distinct id from a timestamp instead. (COMP-PATHS-EXTERNAL D6b)
302
+ const completion_id = (commit_sha === NULL_SHA)
303
+ ? `${feature_code}:nocommit:${Date.now().toString(36)}-${(_nocommitSeq++).toString(36)}`
304
+ : `${feature_code}:${commit_sha}`;
267
305
 
268
306
  // 4. Wrap in maybeIdempotent for caller-key path
269
307
  return maybeIdempotent({ ...args, cwd }, async () => {
@@ -320,7 +358,13 @@ export async function recordCompletion(cwd, args) {
320
358
  // 5h. Persist completion record BEFORE status flip (so flip failure doesn't lose the record)
321
359
  // Use persistFeatureRaw (policy-free, no status-delta guard) — completion-writer
322
360
  // owns its own ordering/locking and only mutates the completions array, not status.
323
- await provider.persistFeatureRaw(feature_code, { ...feature, completions });
361
+ await provider.persistFeatureRaw(feature_code, {
362
+ ...feature,
363
+ completions,
364
+ // COMP-BUILD-QUICK-1: stamp the build template (e.g. 'build-quick') so the
365
+ // validator can exempt quick-built features from MISSING_COMPLETION_REPORT.
366
+ ...(args.built_via ? { built_via: args.built_via } : {}),
367
+ });
324
368
 
325
369
  // 5i. Status flip (default on)
326
370
  const set_status = args.set_status !== false;
@@ -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
  }
@@ -570,7 +573,11 @@ function runArtifactLinkChecks(fctx, ctx, findings) {
570
573
  // PLANNED / SUPERSEDED / KILLED / PARKED — no finding (expected baseline)
571
574
  }
572
575
  // MISSING_COMPLETION_REPORT (warning, COMPLETE)
573
- if (status === 'COMPLETE' && !folder.files.has('report.md')) {
576
+ // COMP-BUILD-QUICK-1: the quick lifecycle (compose build --quick) omits the
577
+ // report phase by design and stamps built_via:'build-quick' on feature.json at
578
+ // ship. Exempt those features — a missing report.md is expected, not debt.
579
+ if (status === 'COMPLETE' && !folder.files.has('report.md') &&
580
+ featureJson?.built_via !== 'build-quick') {
574
581
  findings.push(finding('warning', 'MISSING_COMPLETION_REPORT', code,
575
582
  `Feature is COMPLETE but report.md is missing (current writers do not enforce this; warning only)`));
576
583
  }
@@ -1125,6 +1132,18 @@ export async function validateProject(cwd, options = {}) {
1125
1132
  const ctx = loadValidationContext(cwd, options);
1126
1133
  const findings = [];
1127
1134
 
1135
+ // COMP-PATHS-EXTERNAL: a relocated artifact path may resolve into a parent
1136
+ // that does not exist (e.g. the external docs repo isn't checked out). A
1137
+ // not-yet-created leaf dir is fine — writers mkdir it — but an unreachable
1138
+ // PARENT is a configuration error worth surfacing with the resolved path.
1139
+ for (const [key, p] of [['features', ctx.paths.features], ['roadmap', ctx.paths.roadmap]]) {
1140
+ const isExternal = path.relative(cwd, p).startsWith('..');
1141
+ if (isExternal && !fs.existsSync(path.dirname(p))) {
1142
+ findings.push(finding('error', 'CONFIGURED_PATH_UNREACHABLE', null,
1143
+ `configured ${key} path parent does not exist: ${path.dirname(p)} (resolved ${key} = ${p})`));
1144
+ }
1145
+ }
1146
+
1128
1147
  const allCodes = new Set([
1129
1148
  ...ctx.roadmapByCode.keys(),
1130
1149
  ...ctx.visionByCode.keys(),
@@ -1146,9 +1165,7 @@ export async function validateProject(cwd, options = {}) {
1146
1165
 
1147
1166
  // --- COMP-ROADMAP-RT: roundtrip + hierarchy ---
1148
1167
  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);
1168
+ const features = listFeatures(cwd, ctx.paths.features);
1152
1169
  const roadmapText = fs.existsSync(ctx.paths.roadmap)
1153
1170
  ? fs.readFileSync(ctx.paths.roadmap, 'utf8')
1154
1171
  : '';
@@ -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}. ` +