@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 +128 -30
- package/bin/git-hooks/post-commit.template +2 -1
- package/bin/git-hooks/pre-push.template +2 -1
- package/lib/discover-workspaces.js +109 -0
- package/lib/resolve-workspace.js +166 -0
- package/package.json +1 -2
- package/server/compose-mcp-tools.js +28 -6
- package/server/compose-mcp.js +42 -0
- package/server/project-root.js +4 -0
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1529
|
-
: await validateProject(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
|
15
|
-
export
|
|
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(
|
|
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(
|
|
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
|
+
|
package/server/compose-mcp.js
CHANGED
|
@@ -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}`
|
package/server/project-root.js
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|