@smartmemory/compose 0.1.8-beta → 0.1.9-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.
package/bin/compose.js CHANGED
@@ -15,10 +15,62 @@ import { homedir } from 'os'
15
15
  import { spawn, spawnSync } from 'child_process'
16
16
  import { fileURLToPath } from 'url'
17
17
  import { findProjectRoot } from '../server/find-root.js'
18
+ import { resolveWorkspace, getWorkspaceFlag } from '../lib/resolve-workspace.js'
18
19
 
19
20
  const __dirname = dirname(fileURLToPath(import.meta.url))
20
21
  const PACKAGE_ROOT = resolve(__dirname, '..')
21
22
 
23
+ function dieOnWorkspaceError(err) {
24
+ switch (err.code) {
25
+ case 'WorkspaceAmbiguous':
26
+ console.error('Multiple workspaces match cwd. Add --workspace=<id> or set COMPOSE_TARGET:')
27
+ for (const c of err.candidates) console.error(` --workspace=${c.id} (${c.root})`)
28
+ process.exit(1)
29
+ case 'WorkspaceIdCollision':
30
+ console.error(`workspaceId "${err.id}" is used by multiple roots:`)
31
+ for (const r of err.roots) console.error(` ${r}`)
32
+ console.error('Set an explicit workspaceId in each .compose/compose.json.')
33
+ process.exit(1)
34
+ case 'WorkspaceUnknown':
35
+ // err.message may be the path-doesn't-exist form; prefer it when richer.
36
+ console.error(err.message.includes('does not exist') ? err.message : `Unknown workspace: ${err.id}. Run \`compose doctor\` to list candidates.`)
37
+ process.exit(1)
38
+ case 'WorkspaceUnset':
39
+ console.error('No compose workspace found from the current directory.')
40
+ console.error('Run `compose init` to scaffold one, or cd into a project that has a .compose/ directory.')
41
+ process.exit(1)
42
+ case 'WorkspaceDiscoveryTooBroad':
43
+ console.error('Workspace discovery exceeded its bound from anchor.')
44
+ console.error('Set COMPOSE_TARGET=/absolute/path/to/workspace to bypass discovery.')
45
+ process.exit(1)
46
+ default:
47
+ throw err
48
+ }
49
+ }
50
+
51
+ // Cache the resolved cwd for the lifetime of this CLI process. resolveCwdWithWorkspace
52
+ // strips --workspace from args on first call (via getWorkspaceFlag splice), so a second
53
+ // call would re-resolve without the hint. Cache prevents this; ensures auto-init paths
54
+ // (runInit re-entry from build/fix/import/new) see the same workspace.
55
+ let _resolvedCwdCache = null
56
+
57
+ function resolveCwdWithWorkspace(args) {
58
+ if (_resolvedCwdCache !== null) return _resolvedCwdCache
59
+ let wsId = getWorkspaceFlag(args)
60
+ // Legacy hooks may pass the unsubstituted token literally — treat as absent.
61
+ if (wsId === '__COMPOSE_WORKSPACE_ID__') {
62
+ console.warn('[compose] hook predates workspace-aware install — re-run `compose hooks install`')
63
+ wsId = null
64
+ }
65
+ try {
66
+ const ws = resolveWorkspace({ workspaceId: wsId })
67
+ _resolvedCwdCache = ws.root
68
+ return ws.root
69
+ } catch (err) {
70
+ dieOnWorkspaceError(err)
71
+ }
72
+ }
73
+
22
74
  // ---------------------------------------------------------------------------
23
75
  // --team flag (COMP-TEAMS)
24
76
  // ---------------------------------------------------------------------------
@@ -267,6 +319,10 @@ async function runDoctor(flags = []) {
267
319
  async function runInit(flags) {
268
320
  const noStratum = flags.includes('--no-stratum')
269
321
  const noLifecycle = flags.includes('--no-lifecycle')
322
+ // init creates the workspace — never go through resolveCwdWithWorkspace (which
323
+ // requires one to exist). Strip --workspace if present to avoid leaving it in
324
+ // the shared args array for downstream subcommands.
325
+ getWorkspaceFlag(args)
270
326
  const cwd = process.cwd()
271
327
 
272
328
  // 1. Create .compose/ directory
@@ -516,7 +572,20 @@ function getGitSha(repoPath) {
516
572
 
517
573
  async function runUpdate(flags) {
518
574
  const force = flags.includes('--force')
519
- const cwd = process.cwd()
575
+ // update is user-global — workspace is optional. Try to resolve; if no
576
+ // workspace exists, just use process.cwd() (we may still operate on
577
+ // user-global state below).
578
+ const wsId = getWorkspaceFlag(args)
579
+ let cwd
580
+ try {
581
+ cwd = resolveWorkspace({ workspaceId: wsId }).root
582
+ _resolvedCwdCache = cwd
583
+ } catch (err) {
584
+ // Only WorkspaceUnset is benign for `update` (user-global). Any explicit
585
+ // mistake (bad --workspace, collision, ambiguity, too-broad) should still die.
586
+ if (err.code !== 'WorkspaceUnset') dieOnWorkspaceError(err)
587
+ cwd = process.cwd()
588
+ }
520
589
  const { style, root } = detectInstallStyle()
521
590
 
522
591
  console.log(`compose update — install style: ${style}`)
@@ -623,7 +692,7 @@ if (cmd === 'install') {
623
692
  }
624
693
 
625
694
  if (cmd === 'import') {
626
- const cwd = process.cwd()
695
+ const cwd = resolveCwdWithWorkspace(args)
627
696
 
628
697
  // Auto-init if needed
629
698
  if (!existsSync(join(cwd, '.compose', 'compose.json'))) {
@@ -664,7 +733,7 @@ if (cmd === 'new') {
664
733
  process.exit(1)
665
734
  }
666
735
 
667
- const cwd = process.cwd()
736
+ const cwd = resolveCwdWithWorkspace(args)
668
737
  const name = basename(cwd)
669
738
 
670
739
  // --from-idea <ID>: pre-populate intent from a promoted ideabox entry (Item 184)
@@ -792,7 +861,7 @@ if (cmd === 'feature') {
792
861
  process.exit(1)
793
862
  }
794
863
 
795
- const cwd = process.cwd()
864
+ const cwd = resolveCwdWithWorkspace(args)
796
865
  const configPath = join(cwd, '.compose', 'compose.json')
797
866
  if (!existsSync(configPath)) {
798
867
  console.error("No .compose/compose.json found. Run 'compose init' first.")
@@ -958,7 +1027,7 @@ if (cmd === 'roadmap') {
958
1027
  // compose roadmap generate — regenerate ROADMAP.md from feature.json files
959
1028
  if (subcmd === 'generate' || subcmd === 'gen') {
960
1029
  const { writeRoadmap } = await import('../lib/roadmap-gen.js')
961
- const cwd = process.cwd()
1030
+ const cwd = resolveCwdWithWorkspace(args)
962
1031
  const path = writeRoadmap(cwd)
963
1032
  console.log(`Generated ${path} from feature.json files`)
964
1033
  process.exit(0)
@@ -967,7 +1036,7 @@ if (cmd === 'roadmap') {
967
1036
  // compose roadmap migrate — extract ROADMAP.md entries into feature.json files
968
1037
  if (subcmd === 'migrate') {
969
1038
  const { migrateRoadmap } = await import('../lib/migrate-roadmap.js')
970
- const cwd = process.cwd()
1039
+ const cwd = resolveCwdWithWorkspace(args)
971
1040
  const dryRun = args.includes('--dry-run')
972
1041
  const overwrite = args.includes('--overwrite')
973
1042
  const result = migrateRoadmap(cwd, { dryRun, overwrite })
@@ -986,7 +1055,7 @@ if (cmd === 'roadmap') {
986
1055
  if (subcmd === 'check') {
987
1056
  const { listFeatures } = await import('../lib/feature-json.js')
988
1057
  const { parseRoadmap } = await import('../lib/roadmap-parser.js')
989
- const cwd = process.cwd()
1058
+ const cwd = resolveCwdWithWorkspace(args)
990
1059
  const roadmapPath = join(cwd, 'ROADMAP.md')
991
1060
  if (!existsSync(roadmapPath)) {
992
1061
  console.error('No ROADMAP.md found. Run: compose roadmap generate')
@@ -1107,7 +1176,7 @@ if (cmd === 'roadmap') {
1107
1176
  }
1108
1177
  }
1109
1178
 
1110
- const cwd = process.cwd()
1179
+ const cwd = resolveCwdWithWorkspace(args)
1111
1180
  const roadmapPath = join(cwd, 'ROADMAP.md')
1112
1181
 
1113
1182
  if (existsSync(roadmapPath)) {
@@ -1264,7 +1333,7 @@ if (cmd === 'record-completion') {
1264
1333
  if (flags['force'] === true) completionArgs.force = true
1265
1334
  if (flags['idempotency-key']) completionArgs.idempotency_key = flags['idempotency-key']
1266
1335
 
1267
- const cwd = findProjectRoot(process.cwd())
1336
+ const cwd = resolveCwdWithWorkspace(args)
1268
1337
  const { recordCompletion } = await import('../lib/completion-writer.js')
1269
1338
  try {
1270
1339
  const result = await recordCompletion(cwd, completionArgs)
@@ -1332,7 +1401,7 @@ if (cmd === 'hooks') {
1332
1401
  const { join: pjoin, resolve: presolve } = await import('path')
1333
1402
  const { fileURLToPath: futp } = await import('url')
1334
1403
 
1335
- const projectRoot = findProjectRoot(process.cwd())
1404
+ const projectRoot = resolveCwdWithWorkspace(args)
1336
1405
  const gitDir = pjoin(projectRoot, '.git')
1337
1406
  if (!exSync(gitDir)) {
1338
1407
  console.error('Error: not a git repository (no .git directory found)')
@@ -1387,14 +1456,24 @@ if (cmd === 'hooks') {
1387
1456
  return 1
1388
1457
  }
1389
1458
  }
1459
+ // Hook install must pick exactly one workspace. Repo-level hooks bake one ID.
1460
+ const wsHint = hookFlags['workspace']
1461
+ let wsId
1462
+ try {
1463
+ wsId = resolveWorkspace({ cwd: projectRoot, workspaceId: wsHint }).id
1464
+ } catch (err) {
1465
+ dieOnWorkspaceError(err)
1466
+ }
1390
1467
  const substituted = template
1391
1468
  .replace(/__COMPOSE_NODE__/g, composeNode)
1392
1469
  .replace(/__COMPOSE_BIN__/g, composeBin)
1470
+ .replace(/__COMPOSE_WORKSPACE_ID__/g, wsId)
1393
1471
  wfSync(dest, substituted)
1394
1472
  chmodSync(dest, 0o755)
1395
1473
  console.log(`Installed ${type} hook at ${dest}`)
1396
1474
  console.log(` COMPOSE_NODE=${composeNode}`)
1397
1475
  console.log(` COMPOSE_BIN=${composeBin}`)
1476
+ console.log(` COMPOSE_WORKSPACE_ID=${wsId}`)
1398
1477
  return 0
1399
1478
  }
1400
1479
 
@@ -1415,6 +1494,11 @@ if (cmd === 'hooks') {
1415
1494
  return 0
1416
1495
  }
1417
1496
 
1497
+ function extractBakedWorkspaceId(content) {
1498
+ const m = content.match(/^COMPOSE_WORKSPACE_ID="([^"]*)"$/m)
1499
+ return m ? m[1] : null
1500
+ }
1501
+
1418
1502
  function statusOne(type) {
1419
1503
  const { marker, dest } = HOOK_TYPES[type]
1420
1504
  if (!exSync(dest)) {
@@ -1426,12 +1510,29 @@ if (cmd === 'hooks') {
1426
1510
  console.log(`${type}: foreign — hook exists but is not a Compose hook`)
1427
1511
  return
1428
1512
  }
1513
+ const wsHint = hookFlags['workspace']
1514
+ let expectedWsId = null
1515
+ if (wsHint) {
1516
+ try { expectedWsId = resolveWorkspace({ cwd: projectRoot, workspaceId: wsHint }).id } catch { /* ignore for status */ }
1517
+ } else {
1518
+ try { expectedWsId = resolveWorkspace({ cwd: projectRoot }).id } catch { /* ignore for status */ }
1519
+ }
1429
1520
  const nodeMatch = content.includes(`COMPOSE_NODE="${composeNode}"`)
1430
1521
  const binMatch = content.includes(`COMPOSE_BIN="${composeBin}"`)
1431
- if (nodeMatch && binMatch) {
1522
+ const hasRawToken = content.includes('__COMPOSE_WORKSPACE_ID__')
1523
+ const wsMatch = hasRawToken ? false
1524
+ : expectedWsId ? content.includes(`COMPOSE_WORKSPACE_ID="${expectedWsId}"`)
1525
+ : true
1526
+ if (nodeMatch && binMatch && wsMatch && !hasRawToken) {
1432
1527
  console.log(`${type}: installed (current)`)
1528
+ const baked = extractBakedWorkspaceId(content)
1529
+ if (baked) console.log(` workspace: ${baked}`)
1433
1530
  } else {
1434
- console.log(`${type}: installed (stale paths re-run install)`)
1531
+ const reason = hasRawToken ? 'MISSING_WORKSPACE_ID'
1532
+ : (expectedWsId && !wsMatch) ? 'STALE_WORKSPACE_ID'
1533
+ : 'stale paths'
1534
+ console.log(`${type}: installed (${reason} — re-run install)`)
1535
+ if (expectedWsId && !wsMatch && !hasRawToken) console.log(` expected COMPOSE_WORKSPACE_ID="${expectedWsId}"`)
1435
1536
  if (!nodeMatch) console.log(` expected COMPOSE_NODE="${composeNode}"`)
1436
1537
  if (!binMatch) console.log(` expected COMPOSE_BIN="${composeBin}"`)
1437
1538
  }
@@ -1522,11 +1623,12 @@ Exit codes:
1522
1623
  }
1523
1624
 
1524
1625
  const { validateFeature, validateProject } = await import('../lib/feature-validator.js')
1626
+ const valCwd = resolveCwdWithWorkspace(args)
1525
1627
  let result
1526
1628
  try {
1527
1629
  result = scope === 'feature'
1528
- ? await validateFeature(process.cwd(), code)
1529
- : await validateProject(process.cwd())
1630
+ ? await validateFeature(valCwd, code)
1631
+ : await validateProject(valCwd)
1530
1632
  } catch (err) {
1531
1633
  if (err.code === 'INVALID_INPUT') {
1532
1634
  console.error(`Error [INVALID_INPUT]: ${err.message}`)
@@ -1568,8 +1670,9 @@ Exit codes:
1568
1670
 
1569
1671
  if (cmd === 'pipeline') {
1570
1672
  const { runPipelineCli } = await import('../lib/pipeline-cli.js')
1673
+ const pipeCwd = resolveCwdWithWorkspace(args)
1571
1674
  try {
1572
- runPipelineCli(process.cwd(), args)
1675
+ runPipelineCli(pipeCwd, args)
1573
1676
  } catch (err) {
1574
1677
  console.error(`Error: ${err.message}`)
1575
1678
  process.exit(1)
@@ -1650,7 +1753,7 @@ if (cmd === 'build') {
1650
1753
  }
1651
1754
 
1652
1755
  // Auto-init if needed
1653
- const buildCwd = process.cwd()
1756
+ const buildCwd = resolveCwdWithWorkspace(args)
1654
1757
  if (!existsSync(join(buildCwd, '.compose', 'compose.json')) || !existsSync(join(buildCwd, 'pipelines', 'build.stratum.yaml'))) {
1655
1758
  console.log('Running compose init...\n')
1656
1759
  await runInit(args.filter(a => a.startsWith('--')))
@@ -1725,7 +1828,7 @@ if (cmd === 'build') {
1725
1828
  process.exit(1)
1726
1829
  }
1727
1830
 
1728
- const fixCwd = process.cwd()
1831
+ const fixCwd = resolveCwdWithWorkspace(args)
1729
1832
  if (!existsSync(join(fixCwd, '.compose', 'compose.json')) || !existsSync(join(fixCwd, 'pipelines', 'bug-fix.stratum.yaml'))) {
1730
1833
  console.log('Running compose init...\n')
1731
1834
  await runInit(args.filter(a => a.startsWith('--')))
@@ -1812,7 +1915,7 @@ if (cmd === 'build') {
1812
1915
  }
1813
1916
  import('../lib/triage.js').then(({ runTriage }) => {
1814
1917
  import('../lib/feature-json.js').then(({ readFeature, writeFeature, updateFeature }) => {
1815
- const trCwd = process.cwd()
1918
+ const trCwd = resolveCwdWithWorkspace(args)
1816
1919
  runTriage(triageCode, { cwd: trCwd }).then((result) => {
1817
1920
  console.log(`\nFeature: ${triageCode}`)
1818
1921
  console.log(`Tier: ${result.tier}`)
@@ -1856,16 +1959,11 @@ if (cmd === 'build') {
1856
1959
  })
1857
1960
  })
1858
1961
  } else if (cmd === 'start') {
1859
- // Resolve target root BEFORE spawning supervisor
1860
- const explicitTarget = process.env.COMPOSE_TARGET
1861
- const targetRoot = explicitTarget
1862
- ? resolve(explicitTarget)
1863
- : findProjectRoot(process.cwd())
1864
-
1865
- if (explicitTarget && !existsSync(resolve(explicitTarget))) {
1866
- console.error(`[compose] COMPOSE_TARGET=${explicitTarget} does not exist.`)
1867
- process.exit(1)
1868
- }
1962
+ // Resolve target root BEFORE spawning supervisor.
1963
+ // Use the unified resolver — it handles COMPOSE_TARGET as either ID or absolute path,
1964
+ // --workspace=<id>, and discovery. No need for the legacy explicitTarget short-circuit.
1965
+ const targetRoot = resolveCwdWithWorkspace(args)
1966
+
1869
1967
  if (!targetRoot || !existsSync(join(targetRoot, '.compose', 'compose.json'))) {
1870
1968
  console.error('[compose] No .compose/ found (searched from cwd upward).')
1871
1969
  console.error("[compose] Run 'compose init' first, or set COMPOSE_TARGET.")
@@ -1887,7 +1985,7 @@ if (cmd === 'build') {
1887
1985
  // compose ideabox — idea management CLI
1888
1986
  // ---------------------------------------------------------------------------
1889
1987
  const ibSubcmd = args[0]
1890
- const ibCwd = process.cwd()
1988
+ const ibCwd = resolveCwdWithWorkspace(args)
1891
1989
 
1892
1990
  // Resolve compose config (paths, etc.)
1893
1991
  function loadComposeConfig(cwd) {
@@ -2186,7 +2284,7 @@ if (cmd === 'build') {
2186
2284
  process.exit(1)
2187
2285
  }
2188
2286
 
2189
- const qsCwd = process.cwd()
2287
+ const qsCwd = resolveCwdWithWorkspace(args)
2190
2288
 
2191
2289
  import('../lib/feature-json.js').then(({ readFeature }) => {
2192
2290
  import('../lib/qa-scoping.js').then(({ mapFilesToRoutes, classifyRoutes }) => {
@@ -6,6 +6,7 @@
6
6
  set -u
7
7
  COMPOSE_NODE="__COMPOSE_NODE__"
8
8
  COMPOSE_BIN="__COMPOSE_BIN__"
9
+ COMPOSE_WORKSPACE_ID="__COMPOSE_WORKSPACE_ID__"
9
10
  LOG="${COMPOSE_HOOK_LOG:-.compose/data/post-commit.log}"
10
11
  mkdir -p "$(dirname "$LOG")"
11
12
 
@@ -52,7 +53,7 @@ echo "$trailers" | while IFS= read -r line; do
52
53
  done
53
54
 
54
55
  if ! echo "$files" | "$COMPOSE_NODE" "$COMPOSE_BIN" record-completion "$code" \
55
- --commit-sha="$sha" --tests-pass="$tp" --notes="$notes" \
56
+ --commit-sha="$sha" --tests-pass="$tp" --notes="$notes" --workspace="$COMPOSE_WORKSPACE_ID" \
56
57
  --files-changed-from-stdin >> "$LOG" 2>&1; then
57
58
  echo "[$(date -Iseconds)] hook: record_completion failed for $code (sha=$sha) — see above" >> "$LOG"
58
59
  fi
@@ -6,10 +6,11 @@
6
6
  set -u
7
7
  COMPOSE_NODE="__COMPOSE_NODE__"
8
8
  COMPOSE_BIN="__COMPOSE_BIN__"
9
+ COMPOSE_WORKSPACE_ID="__COMPOSE_WORKSPACE_ID__"
9
10
  LOG="${COMPOSE_HOOK_LOG:-.compose/data/pre-push.log}"
10
11
  mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
11
12
 
12
- OUTPUT=$("$COMPOSE_NODE" "$COMPOSE_BIN" validate --scope=project --block-on=error 2>&1)
13
+ OUTPUT=$("$COMPOSE_NODE" "$COMPOSE_BIN" validate --scope=project --block-on=error --workspace="$COMPOSE_WORKSPACE_ID" 2>&1)
13
14
  EXIT_CODE=$?
14
15
 
15
16
  if [ "$EXIT_CODE" -ne 0 ]; then
@@ -0,0 +1,109 @@
1
+ /**
2
+ * discover-workspaces.js — bounded bidirectional discovery of compose workspaces.
3
+ *
4
+ * Walks upward to find an "anchor" (any of ANCHOR_MARKERS), then scans the anchor
5
+ * subtree to MAX_DEPTH for `.compose/` markers. Hard-capped at MAX_VISITED dirs;
6
+ * over-cap throws an Error with code='WorkspaceDiscoveryTooBroad'. Permission
7
+ * errors during readdir are skipped silently — discovery is best-effort, not
8
+ * authoritative for individual subtrees.
9
+ *
10
+ * Exports:
11
+ * - findAnchor(startDir) → string|null
12
+ * - discoverWorkspaces(startDir) → { anchor, candidates: [{id, root, configPath}] }
13
+ * - deriveId({root}) → {id, root, configPath}
14
+ */
15
+ import path from 'node:path';
16
+ import fs from 'node:fs';
17
+
18
+ export const ANCHOR_MARKERS = ['.compose', '.stratum.yaml', '.git'];
19
+ export const WORKSPACE_MARKER = '.compose';
20
+ export const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.turbo']);
21
+ export const MAX_DEPTH = 3;
22
+ export const MAX_VISITED = 500;
23
+
24
+ /**
25
+ * Walk upward from startDir; return the first directory containing any
26
+ * ANCHOR_MARKER, or null if none found before filesystem root.
27
+ */
28
+ export function findAnchor(startDir) {
29
+ let dir = path.resolve(startDir);
30
+ const { root } = path.parse(dir);
31
+ while (true) {
32
+ for (const marker of ANCHOR_MARKERS) {
33
+ if (fs.existsSync(path.join(dir, marker))) return dir;
34
+ }
35
+ if (dir === root) return null;
36
+ const parent = path.dirname(dir);
37
+ if (parent === dir) return null;
38
+ dir = parent;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Discover candidate workspaces under the anchor for startDir.
44
+ * If no anchor exists upward, anchors at startDir itself.
45
+ */
46
+ export function discoverWorkspaces(startDir) {
47
+ const anchor = findAnchor(startDir) ?? path.resolve(startDir);
48
+ const visited = { count: 0 };
49
+ const candidates = [];
50
+ walkDescendants(anchor, 0, candidates, visited);
51
+ if (fs.existsSync(path.join(anchor, WORKSPACE_MARKER))) {
52
+ if (!candidates.find((c) => c.root === anchor)) {
53
+ candidates.unshift({ root: anchor });
54
+ }
55
+ }
56
+ return { anchor, candidates: candidates.map(deriveId) };
57
+ }
58
+
59
+ function walkDescendants(dir, depth, out, visited) {
60
+ if (depth > MAX_DEPTH) return;
61
+ if (++visited.count > MAX_VISITED) {
62
+ const e = new Error(
63
+ `Workspace discovery exceeded ${MAX_VISITED} directories from anchor. ` +
64
+ 'Set COMPOSE_TARGET=/absolute/path to bypass discovery.',
65
+ );
66
+ e.code = 'WorkspaceDiscoveryTooBroad';
67
+ throw e;
68
+ }
69
+ let entries;
70
+ try {
71
+ entries = fs.readdirSync(dir, { withFileTypes: true });
72
+ } catch (err) {
73
+ // EACCES, EPERM, ENOENT (race with rm), ENOTDIR (symlink target gone) —
74
+ // skip silently. Discovery is best-effort; missing perms aren't fatal.
75
+ return;
76
+ }
77
+ for (const entry of entries) {
78
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
79
+ const child = path.join(dir, entry.name);
80
+ if (fs.existsSync(path.join(child, WORKSPACE_MARKER))) {
81
+ out.push({ root: child });
82
+ }
83
+ walkDescendants(child, depth + 1, out, visited);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Resolve {id, root, configPath} for a candidate workspace root.
89
+ * Honors `.compose/compose.json#workspaceId` if it matches the canonical regex;
90
+ * otherwise falls back to path.basename(root).
91
+ *
92
+ * Exported so resolve-workspace.js can derive ids without re-running discovery.
93
+ */
94
+ export function deriveId({ root }) {
95
+ const configPath = path.join(root, '.compose', 'compose.json');
96
+ let id = path.basename(root);
97
+ try {
98
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
99
+ if (
100
+ typeof cfg.workspaceId === 'string' &&
101
+ /^[a-z][a-z0-9-]{1,63}$/.test(cfg.workspaceId)
102
+ ) {
103
+ id = cfg.workspaceId;
104
+ }
105
+ } catch {
106
+ // missing/unreadable/malformed → basename is fine
107
+ }
108
+ return { id, root, configPath };
109
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * resolve-workspace.js — single resolver chain for compose workspaces.
3
+ *
4
+ * Precedence:
5
+ * 1. explicit hint.workspaceId (cheap upward walk first; falls back to discovery)
6
+ * 2. COMPOSE_TARGET env (absolute path bypasses discovery; id routes through it)
7
+ * 3. hint.getBinding() (MCP binding)
8
+ * 4. discovery (auto-pick when exactly one candidate; throws otherwise)
9
+ *
10
+ * Throws structured errors with `.code`: WorkspaceUnknown, WorkspaceAmbiguous,
11
+ * WorkspaceIdCollision, WorkspaceUnset. The CLI's dieOnWorkspaceError consumes them.
12
+ *
13
+ * Design intent: explicit-flag path uses findWorkspaceById (cheap upward walk)
14
+ * BEFORE invoking discoverWorkspaces — this lets users escape WorkspaceDiscoveryTooBroad
15
+ * by passing --workspace=<ancestor-id>. A descendant id still routes through discovery.
16
+ */
17
+ import path from 'node:path';
18
+ import fs from 'node:fs';
19
+ import { discoverWorkspaces, deriveId } from './discover-workspaces.js';
20
+
21
+ export class WorkspaceUnknown extends Error {
22
+ constructor(id) {
23
+ super(`Unknown workspaceId: ${id}`);
24
+ this.code = 'WorkspaceUnknown';
25
+ this.id = id;
26
+ }
27
+ }
28
+
29
+ export class WorkspaceAmbiguous extends Error {
30
+ constructor(candidates) {
31
+ super('Multiple workspaces match cwd');
32
+ this.code = 'WorkspaceAmbiguous';
33
+ this.candidates = candidates;
34
+ }
35
+ }
36
+
37
+ export class WorkspaceIdCollision extends Error {
38
+ constructor(id, roots) {
39
+ super(`workspaceId "${id}" used by multiple roots`);
40
+ this.code = 'WorkspaceIdCollision';
41
+ this.id = id;
42
+ this.roots = roots;
43
+ }
44
+ }
45
+
46
+ export class WorkspaceUnset extends Error {
47
+ constructor() {
48
+ super('No workspace resolved');
49
+ this.code = 'WorkspaceUnset';
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Resolve a workspace from hints + env + cwd.
55
+ *
56
+ * @param {object} hint
57
+ * @param {string} [hint.cwd] — defaults to process.cwd()
58
+ * @param {string} [hint.workspaceId] — explicit --workspace=<id>
59
+ * @param {() => string|null} [hint.getBinding] — MCP binding accessor
60
+ * @returns {{id: string, root: string, configPath: string, source: string}}
61
+ */
62
+ export function resolveWorkspace(hint = {}) {
63
+ const cwd = hint.cwd ?? process.cwd();
64
+
65
+ // 1. Explicit flag — authoritative. Cheap upward walk first; fall back to
66
+ // discovery (which may throw TooBroad for pathological trees).
67
+ if (hint.workspaceId) {
68
+ const found = findWorkspaceById(cwd, hint.workspaceId);
69
+ if (found) return { ...found, source: 'explicit-flag' };
70
+ const { candidates } = discoverWorkspaces(cwd);
71
+ return resolveByIdScopedCollisionCheck(hint.workspaceId, candidates, 'explicit-flag');
72
+ }
73
+
74
+ // 2. COMPOSE_TARGET — absolute path is authoritative without discovery.
75
+ if (process.env.COMPOSE_TARGET) {
76
+ const t = process.env.COMPOSE_TARGET;
77
+ if (path.isAbsolute(t)) {
78
+ if (!fs.existsSync(t)) {
79
+ const e = new Error(`COMPOSE_TARGET=${t} does not exist`);
80
+ e.code = 'WorkspaceUnknown';
81
+ e.id = t;
82
+ throw e;
83
+ }
84
+ return { ...deriveId({ root: t }), source: 'env' };
85
+ }
86
+ const { candidates } = discoverWorkspaces(cwd);
87
+ return resolveByIdScopedCollisionCheck(t, candidates, 'env');
88
+ }
89
+
90
+ // 3. MCP binding — scoped collision check on the bound id.
91
+ if (hint.getBinding) {
92
+ const id = hint.getBinding();
93
+ if (id) {
94
+ const { candidates } = discoverWorkspaces(cwd);
95
+ return resolveByIdScopedCollisionCheck(id, candidates, 'mcp-binding');
96
+ }
97
+ }
98
+
99
+ // 4. Discovery — collisions matter because we're auto-picking.
100
+ const { candidates } = discoverWorkspaces(cwd);
101
+ detectCollisions(candidates);
102
+ if (candidates.length === 0) throw new WorkspaceUnset();
103
+ if (candidates.length === 1) return { ...candidates[0], source: 'discovery' };
104
+ throw new WorkspaceAmbiguous(candidates.map(({ id, root }) => ({ id, root })));
105
+ }
106
+
107
+ /**
108
+ * Cheap upward-only lookup: walk ancestors from startDir, return the first
109
+ * `.compose/` directory whose derived id matches targetId. Lets users bypass
110
+ * descendant-cap entirely via `--workspace=<ancestor-id>`.
111
+ */
112
+ function findWorkspaceById(startDir, targetId) {
113
+ let dir = path.resolve(startDir);
114
+ const { root } = path.parse(dir);
115
+ while (true) {
116
+ if (fs.existsSync(path.join(dir, '.compose'))) {
117
+ const candidate = deriveId({ root: dir });
118
+ if (candidate.id === targetId) return candidate;
119
+ }
120
+ if (dir === root) return null;
121
+ const parent = path.dirname(dir);
122
+ if (parent === dir) return null;
123
+ dir = parent;
124
+ }
125
+ }
126
+
127
+ function resolveByIdScopedCollisionCheck(id, candidates, source) {
128
+ const matching = candidates.filter((c) => c.id === id);
129
+ if (matching.length === 0) throw new WorkspaceUnknown(id);
130
+ if (matching.length > 1) {
131
+ throw new WorkspaceIdCollision(id, matching.map((m) => m.root));
132
+ }
133
+ return { ...matching[0], source };
134
+ }
135
+
136
+ function detectCollisions(candidates) {
137
+ const byId = new Map();
138
+ for (const c of candidates) {
139
+ if (!byId.has(c.id)) byId.set(c.id, []);
140
+ byId.get(c.id).push(c.root);
141
+ }
142
+ for (const [id, roots] of byId) {
143
+ if (roots.length > 1) throw new WorkspaceIdCollision(id, roots);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Pull --workspace=<id> or --workspace <id> out of args, mutating in place.
149
+ * Returns the id, or null if absent.
150
+ */
151
+ export function getWorkspaceFlag(args) {
152
+ for (let i = 0; i < args.length; i++) {
153
+ const a = args[i];
154
+ if (a === '--workspace' && i + 1 < args.length) {
155
+ const id = args[i + 1];
156
+ args.splice(i, 2);
157
+ return id;
158
+ }
159
+ if (typeof a === 'string' && a.startsWith('--workspace=')) {
160
+ const id = a.slice('--workspace='.length);
161
+ args.splice(i, 1);
162
+ return id;
163
+ }
164
+ }
165
+ return null;
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.1.8-beta",
3
+ "version": "0.1.9-beta",
4
4
  "description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
5
5
  "author": "SmartMemory",
6
6
  "license": "MIT",
@@ -95,7 +95,6 @@
95
95
  "date-fns": "^3.6.0",
96
96
  "diff": "^8.0.4",
97
97
  "express": "^4.21.0",
98
- "ink": "^5.2.1",
99
98
  "lucide-react": "^0.563.0",
100
99
  "mermaid": "^11.13.0",
101
100
  "react": "^19.2.5",
@@ -9,11 +9,12 @@ import fs from 'node:fs';
9
9
  import http from 'node:http';
10
10
  import path from 'node:path';
11
11
  import { ArtifactManager, ARTIFACT_SCHEMAS } from './artifact-manager.js';
12
- import { getTargetRoot, getDataDir, resolveProjectPath } from './project-root.js';
12
+ import { getTargetRoot, getDataDir, resolveProjectPath, switchProject, setCurrentWorkspaceId } from './project-root.js';
13
+ import { resolveWorkspace } from '../lib/resolve-workspace.js';
14
+ import { discoverWorkspaces } from '../lib/discover-workspaces.js';
13
15
 
14
- export const PROJECT_ROOT = getTargetRoot();
15
- export const VISION_FILE = path.join(getDataDir(), 'vision-state.json');
16
- export const SESSIONS_FILE = path.join(getDataDir(), 'sessions.json');
16
+ export function getVisionFile() { return path.join(getDataDir(), 'vision-state.json'); }
17
+ export function getSessionsFile() { return path.join(getDataDir(), 'sessions.json'); }
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // Data access
@@ -21,7 +22,7 @@ export const SESSIONS_FILE = path.join(getDataDir(), 'sessions.json');
21
22
 
22
23
  export function loadVisionState() {
23
24
  try {
24
- const raw = fs.readFileSync(VISION_FILE, 'utf-8');
25
+ const raw = fs.readFileSync(getVisionFile(), 'utf-8');
25
26
  const state = JSON.parse(raw);
26
27
  if (Array.isArray(state.gates)) {
27
28
  const seen = new Map();
@@ -36,7 +37,7 @@ export function loadVisionState() {
36
37
 
37
38
  export function loadSessions() {
38
39
  try {
39
- const raw = fs.readFileSync(SESSIONS_FILE, 'utf-8');
40
+ const raw = fs.readFileSync(getSessionsFile(), 'utf-8');
40
41
  const sessions = JSON.parse(raw);
41
42
  return Array.isArray(sessions) ? sessions : [];
42
43
  } catch {
@@ -466,3 +467,24 @@ export function toolGetPendingGates({ itemId }) {
466
467
  return { count: pending.length, gates: pending };
467
468
  }
468
469
 
470
+ // ---------------------------------------------------------------------------
471
+ // Workspace binding (MCP session-scoped)
472
+ // ---------------------------------------------------------------------------
473
+
474
+ let _binding = null;
475
+
476
+ export function toolSetWorkspace({ workspaceId }) {
477
+ const resolved = resolveWorkspace({ workspaceId });
478
+ switchProject(resolved.root);
479
+ setCurrentWorkspaceId(resolved.id);
480
+ _binding = resolved;
481
+ return { id: resolved.id, root: resolved.root, source: 'mcp-binding' };
482
+ }
483
+
484
+ export function toolGetWorkspace() {
485
+ const { candidates } = discoverWorkspaces(process.cwd());
486
+ return { current: _binding, candidates };
487
+ }
488
+
489
+ export function _getBinding() { return _binding?.id ?? null; }
490
+
@@ -59,7 +59,12 @@ import {
59
59
  toolGetCompletions,
60
60
  toolValidateFeature,
61
61
  toolValidateProject,
62
+ toolSetWorkspace,
63
+ toolGetWorkspace,
64
+ _getBinding,
62
65
  } from './compose-mcp-tools.js';
66
+ import { switchProject, getTargetRoot } from './project-root.js';
67
+ import { resolveWorkspace } from '../lib/resolve-workspace.js';
63
68
 
64
69
  // ---------------------------------------------------------------------------
65
70
  // Tool definitions
@@ -151,6 +156,20 @@ const TOOLS = [
151
156
  required: ['featureCode'],
152
157
  },
153
158
  },
159
+ {
160
+ name: 'set_workspace',
161
+ description: 'Bind this MCP session to a workspace. Required when cwd contains multiple workspaces. Lives in process memory; lost on MCP restart.',
162
+ inputSchema: {
163
+ type: 'object',
164
+ required: ['workspaceId'],
165
+ properties: { workspaceId: { type: 'string', description: 'Workspace ID (kebab-case)' } },
166
+ },
167
+ },
168
+ {
169
+ name: 'get_workspace',
170
+ description: 'Get the current MCP workspace binding plus all candidates discovered from cwd.',
171
+ inputSchema: { type: 'object', properties: {} },
172
+ },
154
173
  {
155
174
  name: 'get_feature_lifecycle',
156
175
  description: 'Get the lifecycle state of a feature: current phase, phase history, artifacts, warnings.',
@@ -570,7 +589,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
570
589
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
571
590
  const { name, arguments: args = {} } = request.params;
572
591
 
592
+ const WORKSPACE_EXEMPT = new Set(['set_workspace', 'get_workspace']);
593
+
573
594
  try {
595
+ if (!WORKSPACE_EXEMPT.has(name)) {
596
+ const ws = resolveWorkspace({ getBinding: _getBinding });
597
+ if (ws.root !== getTargetRoot()) switchProject(ws.root);
598
+ }
574
599
  let result;
575
600
  switch (name) {
576
601
  case 'get_vision_items': result = toolGetVisionItems(args); break;
@@ -579,6 +604,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
579
604
  case 'get_blocked_items': result = toolGetBlockedItems(); break;
580
605
  case 'get_current_session': result = await toolGetCurrentSession(args); break;
581
606
  case 'bind_session': result = await toolBindSession(args); break;
607
+ case 'set_workspace': result = toolSetWorkspace(args); break;
608
+ case 'get_workspace': result = toolGetWorkspace(); break;
582
609
  case 'get_feature_lifecycle': result = toolGetFeatureLifecycle(args); break;
583
610
  case 'kill_feature': result = await toolKillFeature(args); break;
584
611
  case 'complete_feature': result = await toolCompleteFeature(args); break;
@@ -624,6 +651,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
624
651
  let text = err && err.code
625
652
  ? `Error [${err.code}]: ${err.message}`
626
653
  : `Error: ${err.message}`;
654
+ // For workspace ambiguity, include the candidate list and the call to fix it
655
+ // so Claude can prompt the user without a follow-up tool call.
656
+ if (err && err.code === 'WorkspaceAmbiguous' && Array.isArray(err.candidates)) {
657
+ text += '\n\nCandidates:';
658
+ for (const c of err.candidates) text += `\n - ${c.id} (${c.root})`;
659
+ text += '\n\nNext step: call set_workspace({"workspaceId": "<id>"}) and retry.';
660
+ }
661
+ if (err && err.code === 'WorkspaceIdCollision' && Array.isArray(err.roots)) {
662
+ text += `\n\nworkspaceId "${err.id}" is used by multiple roots:`;
663
+ for (const r of err.roots) text += `\n - ${r}`;
664
+ text += '\n\nFix: set an explicit workspaceId in each .compose/compose.json.';
665
+ }
666
+ if (err && err.code === 'WorkspaceUnset') {
667
+ text += '\n\nNo .compose/ workspace was found. Run `compose init` to scaffold one.';
668
+ }
627
669
  if (err && err.cause && typeof err.cause.message === 'string') {
628
670
  text += err.cause.code
629
671
  ? `\n Caused by [${err.cause.code}]: ${err.cause.message}`
@@ -48,6 +48,10 @@ export function getTargetRoot() { return _targetRoot; }
48
48
  /** Data directory for Compose state. Lives in the target project. */
49
49
  export function getDataDir() { return _dataDir; }
50
50
 
51
+ let _currentWorkspaceId = null;
52
+ export function getCurrentWorkspaceId() { return _currentWorkspaceId; }
53
+ export function setCurrentWorkspaceId(id) { _currentWorkspaceId = id; }
54
+
51
55
  // ---------------------------------------------------------------------------
52
56
  // Switch project at runtime
53
57
  // ---------------------------------------------------------------------------