@smartmemory/compose 0.2.32-beta → 0.2.34-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 +9 -6
- package/contracts/feature-json.schema.json +5 -2
- package/lib/feature-writer.js +12 -0
- package/lib/resolve-port.js +8 -2
- package/lib/xref-local.js +42 -0
- package/lib/xref-push.js +132 -36
- package/lib/xref-sync.js +9 -16
- package/package.json +1 -1
- package/server/agent-hooks.js +4 -2
- package/server/agent-server.js +1 -1
- package/server/compose-mcp-tools.js +13 -2
- package/server/compose-mcp.js +13 -0
- package/server/supervisor.js +3 -3
package/bin/compose.js
CHANGED
|
@@ -16,6 +16,7 @@ import { spawn, spawnSync } from 'child_process'
|
|
|
16
16
|
import { fileURLToPath } from 'url'
|
|
17
17
|
import { findProjectRoot } from '../server/find-root.js'
|
|
18
18
|
import { resolveWorkspace, getWorkspaceFlag } from '../lib/resolve-workspace.js'
|
|
19
|
+
import { resolvePort } from '../lib/resolve-port.js'
|
|
19
20
|
|
|
20
21
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
21
22
|
const PACKAGE_ROOT = resolve(__dirname, '..')
|
|
@@ -1167,15 +1168,15 @@ if (cmd === 'roadmap') {
|
|
|
1167
1168
|
if (res.pushed.length === 0) {
|
|
1168
1169
|
console.log(`No external trackers to push (${res.scanned} push-opted link(s) checked, ${res.unchanged} already in sync).`)
|
|
1169
1170
|
} else {
|
|
1170
|
-
console.log(`${apply ? 'Pushed' : 'Would push'} ${res.pushed.length} external
|
|
1171
|
-
for (const s of res.pushed) console.log(` ${s.code}
|
|
1171
|
+
console.log(`${apply ? 'Pushed' : 'Would push'} ${res.pushed.length} external target(s):`)
|
|
1172
|
+
for (const s of res.pushed) console.log(` ${s.code} ${s.provider} ${s.target}: ${s.summary} (${verb})`)
|
|
1172
1173
|
}
|
|
1173
1174
|
if (res.skipped.length > 0) {
|
|
1174
1175
|
console.log(`\nSkipped ${res.skipped.length} link(s):`)
|
|
1175
|
-
for (const s of res.skipped) console.log(` ${s.code}
|
|
1176
|
+
for (const s of res.skipped) console.log(` ${s.code} ${s.provider} ${s.target}: ${s.reason}`)
|
|
1176
1177
|
}
|
|
1177
1178
|
if (!apply && res.pushed.length > 0) {
|
|
1178
|
-
console.log(`\nDry-run — pass --apply to write these changes
|
|
1179
|
+
console.log(`\nDry-run — pass --apply to write these changes.`)
|
|
1179
1180
|
}
|
|
1180
1181
|
process.exit(0)
|
|
1181
1182
|
}
|
|
@@ -2772,8 +2773,10 @@ if (cmd === 'build') {
|
|
|
2772
2773
|
process.exit(1)
|
|
2773
2774
|
}
|
|
2774
2775
|
|
|
2775
|
-
// Resolve compose server URL
|
|
2776
|
-
|
|
2776
|
+
// Resolve compose server URL. Default must match the API server port
|
|
2777
|
+
// (resolvePort: COMPOSE_PORT > PORT > 4001) — a stale :3000 here made every
|
|
2778
|
+
// `compose loops` call fail with ECONNREFUSED in a default install.
|
|
2779
|
+
const baseUrl = process.env.COMPOSE_URL || `http://127.0.0.1:${resolvePort()}`
|
|
2777
2780
|
|
|
2778
2781
|
// COMP-WORKSPACE-HTTP T7: resolve workspace tolerantly so we can attach
|
|
2779
2782
|
// X-Compose-Workspace-Id to the HTTP calls below. Loops CLI did not previously
|
|
@@ -95,7 +95,8 @@
|
|
|
95
95
|
"required": ["repo", "issue"],
|
|
96
96
|
"properties": {
|
|
97
97
|
"repo": { "pattern": "^[^\\s/#]+/[^\\s/#]+$" },
|
|
98
|
-
"expect": { "enum": ["open", "closed"] }
|
|
98
|
+
"expect": { "enum": ["open", "closed"] },
|
|
99
|
+
"expect_labels": { "type": "array", "items": { "type": "string", "minLength": 1 } }
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
},
|
|
@@ -104,6 +105,7 @@
|
|
|
104
105
|
"then": {
|
|
105
106
|
"required": ["repo", "to_code"],
|
|
106
107
|
"properties": {
|
|
108
|
+
"expect_labels": false,
|
|
107
109
|
"repo": {
|
|
108
110
|
"pattern": "^[A-Za-z0-9._-]+$",
|
|
109
111
|
"not": { "enum": [".", ".."] }
|
|
@@ -117,7 +119,8 @@
|
|
|
117
119
|
"then": {
|
|
118
120
|
"required": ["url"],
|
|
119
121
|
"properties": {
|
|
120
|
-
"url": { "type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9+\\-.]*://" }
|
|
122
|
+
"url": { "type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9+\\-.]*://" },
|
|
123
|
+
"expect_labels": false
|
|
121
124
|
}
|
|
122
125
|
}
|
|
123
126
|
}
|
package/lib/feature-writer.js
CHANGED
|
@@ -844,7 +844,15 @@ function validateExternalArgs(args) {
|
|
|
844
844
|
if (args.push != null && typeof args.push !== 'boolean') {
|
|
845
845
|
throw new Error(`feature-writer: external github push must be boolean, got "${args.push}"`);
|
|
846
846
|
}
|
|
847
|
+
if (args.expect_labels != null
|
|
848
|
+
&& (!Array.isArray(args.expect_labels)
|
|
849
|
+
|| !args.expect_labels.every((l) => typeof l === 'string' && l.length > 0))) {
|
|
850
|
+
throw new Error('feature-writer: external github expect_labels must be an array of non-empty strings');
|
|
851
|
+
}
|
|
847
852
|
} else if (p === 'local') {
|
|
853
|
+
if (args.expect_labels != null) {
|
|
854
|
+
throw new Error('feature-writer: expect_labels is github-only (no labels on a local ref)');
|
|
855
|
+
}
|
|
848
856
|
if (!args.repo || !args.to_code) {
|
|
849
857
|
throw new Error('feature-writer: external local link requires repo + to_code');
|
|
850
858
|
}
|
|
@@ -865,6 +873,9 @@ function validateExternalArgs(args) {
|
|
|
865
873
|
);
|
|
866
874
|
}
|
|
867
875
|
} else if (XREF_URL_CLASS.has(p)) {
|
|
876
|
+
if (args.expect_labels != null) {
|
|
877
|
+
throw new Error('feature-writer: expect_labels is github-only (no labels on a url-class ref)');
|
|
878
|
+
}
|
|
868
879
|
if (!args.url) {
|
|
869
880
|
throw new Error(`feature-writer: external ${p} link (url-class) requires url`);
|
|
870
881
|
}
|
|
@@ -923,6 +934,7 @@ async function linkFeatureExternal(cwd, args) {
|
|
|
923
934
|
if (args.url != null) entry.url = args.url;
|
|
924
935
|
if (args.expect != null) entry.expect = args.expect;
|
|
925
936
|
if (args.push != null) entry.push = args.push;
|
|
937
|
+
if (args.expect_labels != null) entry.expect_labels = args.expect_labels;
|
|
926
938
|
if (args.note) entry.note = args.note;
|
|
927
939
|
|
|
928
940
|
if (matchIdx !== -1) links[matchIdx] = entry;
|
package/lib/resolve-port.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Canonical port resolution for the Compose server.
|
|
3
|
-
* Single source of truth: COMPOSE_PORT > PORT >
|
|
3
|
+
* Single source of truth: COMPOSE_PORT > PORT > 4001.
|
|
4
|
+
*
|
|
5
|
+
* The default MUST match server/index.js (`PORT || 4001`) and the supervisor,
|
|
6
|
+
* which start the API server on 4001. A stale 3001 default here silently sent
|
|
7
|
+
* every CLI probe / MCP lifecycle call / agent-hook to a dead port while the
|
|
8
|
+
* real server was alive on 4001 — gates fell back to readline, completions and
|
|
9
|
+
* loops failed with ECONNREFUSED.
|
|
4
10
|
*/
|
|
5
11
|
export function resolvePort() {
|
|
6
|
-
return Number(process.env.COMPOSE_PORT) || Number(process.env.PORT) ||
|
|
12
|
+
return Number(process.env.COMPOSE_PORT) || Number(process.env.PORT) || 4001;
|
|
7
13
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* xref-local.js — shared containment guard for `local`-provider external refs.
|
|
3
|
+
*
|
|
4
|
+
* A `local` external link names a sibling repo by a bare directory token
|
|
5
|
+
* (`repo`). Both the Pull resolver (xref-sync) and the Push writer (xref-push)
|
|
6
|
+
* must vet that token resolves to a DIRECT sibling of cwd before touching it —
|
|
7
|
+
* lexically first (no path separators, not `.`/`..`, parent must be cwd's
|
|
8
|
+
* parent), then by realpath to defeat a valid-named sibling symlinked outside
|
|
9
|
+
* the workspace parent. This is the security boundary for cross-repo operations.
|
|
10
|
+
*
|
|
11
|
+
* Scope: this helper covers ONLY the repo-token → siblingRoot containment. The
|
|
12
|
+
* caller still owns `to_code` presence checks and reading the sibling's own
|
|
13
|
+
* feature.json (the sibling may declare its own paths.features).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { resolve, dirname } from 'path';
|
|
17
|
+
import { realpathSync } from 'fs';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} cwd
|
|
21
|
+
* @param {string} repo bare sibling directory token from the link
|
|
22
|
+
* @returns {{root: string} | {skipped: true, reason: string}}
|
|
23
|
+
*/
|
|
24
|
+
export function resolveSiblingRoot(cwd, repo) {
|
|
25
|
+
if (!repo) return { skipped: true, reason: 'incomplete local ref' };
|
|
26
|
+
const parentDir = resolve(cwd, '..');
|
|
27
|
+
const citedRoot = resolve(parentDir, String(repo));
|
|
28
|
+
// Lexical guard: a path separator, `.`/`..`, or a token whose parent isn't
|
|
29
|
+
// the workspace parent is not a direct sibling.
|
|
30
|
+
if (/[\\/]/.test(repo) || repo === '.' || repo === '..' || dirname(citedRoot) !== parentDir) {
|
|
31
|
+
return { skipped: true, reason: `local repo token "${repo}" is not a valid sibling` };
|
|
32
|
+
}
|
|
33
|
+
// Realpath guard: defeat a valid-named sibling symlinked outside the parent.
|
|
34
|
+
try {
|
|
35
|
+
if (dirname(realpathSync(citedRoot)) !== realpathSync(parentDir)) {
|
|
36
|
+
return { skipped: true, reason: `local repo "${repo}" escapes the workspace parent` };
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
return { skipped: true, reason: `local target ${repo} not found` };
|
|
40
|
+
}
|
|
41
|
+
return { root: citedRoot };
|
|
42
|
+
}
|
package/lib/xref-push.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { readdirSync, existsSync, readFileSync } from 'fs';
|
|
19
19
|
import { join } from 'path';
|
|
20
20
|
import { loadFeaturesDir } from './project-paths.js';
|
|
21
|
+
import { resolveSiblingRoot } from './xref-local.js';
|
|
21
22
|
|
|
22
23
|
const GITHUB_STATES = new Set(['open', 'closed']);
|
|
23
24
|
|
|
@@ -78,23 +79,28 @@ export async function defaultResolve(link, opts = {}) {
|
|
|
78
79
|
}
|
|
79
80
|
const state = r.body && r.body.state;
|
|
80
81
|
if (!isGithubState(state)) return { skipped: true, reason: 'no parseable issue state' };
|
|
81
|
-
|
|
82
|
+
// Normalize labels to a string[] of names — GitHub returns label OBJECTS
|
|
83
|
+
// ({name}); a labels-only link needs the names to compute the additive union.
|
|
84
|
+
const labels = (r.body && r.body.labels ? r.body.labels : []).map((l) => (l && l.name != null ? l.name : l));
|
|
85
|
+
return { state, labels };
|
|
82
86
|
} catch (e) {
|
|
83
87
|
return { skipped: true, reason: e && e.rateLimit ? 'rate limit' : (e && e.message) || 'resolution error' };
|
|
84
88
|
}
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
/**
|
|
88
|
-
*
|
|
89
|
-
* returning) so a non-2xx response degrades to a skip rather than a false
|
|
90
|
-
* success.
|
|
92
|
+
* Apply a `patch` (`{state?, labels?}`) to a github issue. Uses updateIssueResult
|
|
93
|
+
* (status-returning) so a non-2xx response degrades to a skip rather than a false
|
|
94
|
+
* success. A github PATCH replaces the whole label set, so `patch.labels` MUST be
|
|
95
|
+
* the full intended set (the additive union), never just the missing subset.
|
|
96
|
+
* Returns { ok: true } or { skipped, reason }.
|
|
91
97
|
*/
|
|
92
|
-
export async function defaultWrite(link,
|
|
98
|
+
export async function defaultWrite(link, patch, opts = {}) {
|
|
93
99
|
if (!link.repo || link.issue == null) return { skipped: true, reason: 'incomplete github ref' };
|
|
94
100
|
const c = await makeClient(link, opts);
|
|
95
101
|
if (c.skipped) return c;
|
|
96
102
|
try {
|
|
97
|
-
const r = await c.gh.updateIssueResult(link.issue,
|
|
103
|
+
const r = await c.gh.updateIssueResult(link.issue, patch);
|
|
98
104
|
if (r.status < 200 || r.status >= 300) return { skipped: true, reason: `write HTTP ${r.status}` };
|
|
99
105
|
return { ok: true };
|
|
100
106
|
} catch (e) {
|
|
@@ -102,6 +108,49 @@ export async function defaultWrite(link, toState, opts = {}) {
|
|
|
102
108
|
}
|
|
103
109
|
}
|
|
104
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Pure additive label plan: which of `expectLabels` are missing from
|
|
113
|
+
* `currentNames`, and the full union to PATCH. Case-sensitive (GitHub label
|
|
114
|
+
* names are case-sensitive); never removes a label not in `expectLabels`.
|
|
115
|
+
*
|
|
116
|
+
* @param {string[]} currentNames
|
|
117
|
+
* @param {string[]} expectLabels
|
|
118
|
+
* @returns {{action: 'none'} | {action: 'add', add: string[], to: string[]}}
|
|
119
|
+
*/
|
|
120
|
+
export function planLabels(currentNames, expectLabels) {
|
|
121
|
+
if (!Array.isArray(expectLabels) || expectLabels.length === 0) return { action: 'none' };
|
|
122
|
+
const have = new Set(currentNames);
|
|
123
|
+
const missing = [...new Set(expectLabels)].filter((l) => !have.has(l));
|
|
124
|
+
if (missing.length === 0) return { action: 'none' };
|
|
125
|
+
return { action: 'add', add: missing, to: [...new Set([...currentNames, ...expectLabels])] };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Default local-status writer: delegate to the SIBLING repo's own
|
|
130
|
+
* setFeatureStatus so its transition policy + ROADMAP roundtrip apply. Never
|
|
131
|
+
* force/derived (the sibling's TRANSITIONS table governs). Throws on rejection;
|
|
132
|
+
* the caller degrade-skips.
|
|
133
|
+
*/
|
|
134
|
+
async function defaultSetStatus(siblingRoot, code, status) {
|
|
135
|
+
const { setFeatureStatus } = await import('./feature-writer.js');
|
|
136
|
+
return setFeatureStatus(siblingRoot, { code, status });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve a local link's sibling current status, via the shared containment
|
|
141
|
+
* guard. Returns { state, root } or { skipped, reason }.
|
|
142
|
+
*/
|
|
143
|
+
function localResolve(link, cwd) {
|
|
144
|
+
if (!link.to_code) return { skipped: true, reason: 'incomplete local ref (no to_code)' };
|
|
145
|
+
const sib = resolveSiblingRoot(cwd, link.repo);
|
|
146
|
+
if (sib.skipped) return sib;
|
|
147
|
+
try {
|
|
148
|
+
const fjPath = join(sib.root, loadFeaturesDir(sib.root), link.to_code, 'feature.json');
|
|
149
|
+
if (!existsSync(fjPath)) return { skipped: true, reason: `local target ${link.repo}/${link.to_code} not found` };
|
|
150
|
+
return { state: JSON.parse(readFileSync(fjPath, 'utf8')).status || null, root: sib.root };
|
|
151
|
+
} catch (e) { return { skipped: true, reason: `unreadable local target: ${e.message}` }; }
|
|
152
|
+
}
|
|
153
|
+
|
|
105
154
|
/**
|
|
106
155
|
* Push-write every eligible feature.json external link's target to match its
|
|
107
156
|
* declared `expect`. Eligibility: github provider, `push === true`, and a valid
|
|
@@ -111,17 +160,22 @@ export async function defaultWrite(link, toState, opts = {}) {
|
|
|
111
160
|
* @param {object} [opts]
|
|
112
161
|
* @param {boolean} [opts.apply] perform writes (default false = dry-run)
|
|
113
162
|
* @param {string} [opts.featuresDir]
|
|
114
|
-
* @param {(link: object) => Promise<{state?: string, skipped?: boolean, reason?: string}>} [opts.resolve]
|
|
115
|
-
*
|
|
116
|
-
* @param {object
|
|
117
|
-
*
|
|
163
|
+
* @param {(link: object) => Promise<{state?: string, labels?: string[], skipped?: boolean, reason?: string}>} [opts.resolve]
|
|
164
|
+
* github resolve override (tests)
|
|
165
|
+
* @param {(link: object, patch: object) => Promise<{ok?: boolean, skipped?: boolean, reason?: string}>} [opts.write]
|
|
166
|
+
* github write override (tests); patch is `{state?, labels?}`
|
|
167
|
+
* @param {object} [opts.githubTransport] injectable transport for the default github resolve/write (tests)
|
|
168
|
+
* @param {object} [opts.githubAuth] injectable auth for the default github resolve/write (tests)
|
|
169
|
+
* @param {(siblingRoot: string, code: string, status: string) => Promise<any>} [opts.setStatus]
|
|
170
|
+
* local-status writer override (tests); default delegates to the sibling's setFeatureStatus
|
|
118
171
|
* @returns {Promise<{pushed: Array, skipped: Array, unchanged: number, scanned: number}>}
|
|
119
172
|
*/
|
|
120
173
|
export async function pushExternalRefs(cwd, opts = {}) {
|
|
121
174
|
const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
|
|
122
175
|
const clientOpts = { transport: opts.githubTransport ?? null, auth: opts.githubAuth };
|
|
123
|
-
const
|
|
124
|
-
const
|
|
176
|
+
const ghResolve = opts.resolve ?? ((link) => defaultResolve(link, clientOpts));
|
|
177
|
+
const ghWrite = opts.write ?? ((link, patch) => defaultWrite(link, patch, clientOpts));
|
|
178
|
+
const setStatus = opts.setStatus ?? defaultSetStatus;
|
|
125
179
|
const apply = opts.apply === true;
|
|
126
180
|
const dir = join(cwd, featuresDir);
|
|
127
181
|
|
|
@@ -129,6 +183,7 @@ export async function pushExternalRefs(cwd, opts = {}) {
|
|
|
129
183
|
const skipped = [];
|
|
130
184
|
let unchanged = 0;
|
|
131
185
|
let scanned = 0;
|
|
186
|
+
const skip = (fj, link, reason) => skipped.push({ code: fj.code, provider: link.provider, target: targetLabel(link), reason });
|
|
132
187
|
|
|
133
188
|
if (!existsSync(dir)) return { pushed, skipped, unchanged, scanned };
|
|
134
189
|
|
|
@@ -141,39 +196,80 @@ export async function pushExternalRefs(cwd, opts = {}) {
|
|
|
141
196
|
if (!Array.isArray(fj.links)) continue;
|
|
142
197
|
|
|
143
198
|
for (const link of fj.links) {
|
|
144
|
-
if (!link || link.kind !== 'external') continue;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
199
|
+
if (!link || link.kind !== 'external' || link.push !== true) continue;
|
|
200
|
+
if (link.provider === 'github') {
|
|
201
|
+
// Eligible if it declares ANY intent (state and/or labels).
|
|
202
|
+
if (link.expect == null && link.expect_labels == null) continue;
|
|
203
|
+
scanned++;
|
|
204
|
+
// Malformed-state skip fires ONLY when a state intent is present —
|
|
205
|
+
// a labels-only link is still eligible.
|
|
206
|
+
if (link.expect != null && !isGithubState(link.expect)) {
|
|
207
|
+
skip(fj, link, `malformed expect "${link.expect}" (want open|closed)`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const r = await ghResolve(link);
|
|
211
|
+
if (r.skipped) { skip(fj, link, r.reason); continue; }
|
|
152
212
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
const verdict = planPush(link, r.state ?? null);
|
|
159
|
-
if (verdict.action !== 'write') { unchanged++; continue; }
|
|
213
|
+
const statePlan = link.expect != null ? planPush(link, r.state ?? null) : { action: 'none' };
|
|
214
|
+
const labelsPlan = link.expect_labels != null ? planLabels(r.labels ?? [], link.expect_labels) : { action: 'none' };
|
|
215
|
+
if (statePlan.action !== 'write' && labelsPlan.action !== 'add') { unchanged++; continue; }
|
|
160
216
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
217
|
+
const patch = {};
|
|
218
|
+
if (statePlan.action === 'write') patch.state = statePlan.to;
|
|
219
|
+
if (labelsPlan.action === 'add') patch.labels = labelsPlan.to; // FULL union, not the subset
|
|
220
|
+
|
|
221
|
+
if (apply) {
|
|
222
|
+
const w = await ghWrite(link, patch);
|
|
223
|
+
if (w.skipped) { skip(fj, link, w.reason); continue; }
|
|
224
|
+
}
|
|
225
|
+
pushed.push(githubRow(fj, link, statePlan, labelsPlan));
|
|
226
|
+
} else if (link.provider === 'local') {
|
|
227
|
+
if (link.expect == null) continue; // local has only the state aspect
|
|
228
|
+
scanned++;
|
|
229
|
+
const r = localResolve(link, cwd);
|
|
230
|
+
if (r.skipped) { skip(fj, link, r.reason); continue; }
|
|
231
|
+
|
|
232
|
+
const verdict = planPush(link, r.state ?? null);
|
|
233
|
+
if (verdict.action !== 'write') { unchanged++; continue; }
|
|
234
|
+
|
|
235
|
+
if (apply) {
|
|
236
|
+
try { await setStatus(r.root, link.to_code, link.expect); }
|
|
237
|
+
catch (e) { skip(fj, link, (e && e.message) || 'sibling write rejected'); continue; }
|
|
238
|
+
}
|
|
239
|
+
pushed.push({
|
|
240
|
+
code: fj.code, provider: 'local', target: targetLabel(link),
|
|
241
|
+
from: verdict.from, to: verdict.to,
|
|
242
|
+
state: { from: verdict.from, to: verdict.to },
|
|
243
|
+
summary: `status ${verdict.from} → ${verdict.to}`,
|
|
244
|
+
});
|
|
169
245
|
}
|
|
170
|
-
|
|
246
|
+
// other providers (url/reserved): not pushable, silently ignored.
|
|
171
247
|
}
|
|
172
248
|
}
|
|
173
249
|
|
|
174
250
|
return { pushed, skipped, unchanged, scanned };
|
|
175
251
|
}
|
|
176
252
|
|
|
253
|
+
// Build a github result row. Keeps flat from/to (back-compat with -PUSH) for a
|
|
254
|
+
// state change, plus structured state/labels aspects and a display summary.
|
|
255
|
+
function githubRow(fj, link, statePlan, labelsPlan) {
|
|
256
|
+
const row = { code: fj.code, provider: 'github', target: targetLabel(link) };
|
|
257
|
+
const parts = [];
|
|
258
|
+
if (statePlan.action === 'write') {
|
|
259
|
+
row.from = statePlan.from;
|
|
260
|
+
row.to = statePlan.to;
|
|
261
|
+
row.state = { from: statePlan.from, to: statePlan.to };
|
|
262
|
+
parts.push(`state ${statePlan.from} → ${statePlan.to}`);
|
|
263
|
+
}
|
|
264
|
+
if (labelsPlan.action === 'add') {
|
|
265
|
+
row.labels = { added: labelsPlan.add };
|
|
266
|
+
parts.push(`labels +${labelsPlan.add.join(',')}`);
|
|
267
|
+
}
|
|
268
|
+
row.summary = parts.join('; ');
|
|
269
|
+
return row;
|
|
270
|
+
}
|
|
271
|
+
|
|
177
272
|
function targetLabel(link) {
|
|
273
|
+
if (link.provider === 'local') return `${link.repo}/${link.to_code}`;
|
|
178
274
|
return `${link.repo}#${link.issue}`;
|
|
179
275
|
}
|
package/lib/xref-sync.js
CHANGED
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
* ROADMAP roundtrip fixed point. Resolution is injectable for testability.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { readdirSync, existsSync, readFileSync
|
|
17
|
-
import { join
|
|
16
|
+
import { readdirSync, existsSync, readFileSync } from 'fs';
|
|
17
|
+
import { join } from 'path';
|
|
18
18
|
import { writeFeature } from './feature-json.js';
|
|
19
19
|
import { loadFeaturesDir } from './project-paths.js';
|
|
20
|
+
import { resolveSiblingRoot } from './xref-local.js';
|
|
20
21
|
|
|
21
22
|
const RESOLVABLE = new Set(['github', 'local']);
|
|
22
23
|
|
|
@@ -71,20 +72,12 @@ async function defaultResolve(link, cwd, featuresDir) {
|
|
|
71
72
|
if (link.provider === 'local') {
|
|
72
73
|
// The target feature lives in a sibling repo; its status is the live state.
|
|
73
74
|
if (!link.repo || !link.to_code) return { skipped: true, reason: 'incomplete local ref' };
|
|
74
|
-
// Containment guard (
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|| dirname(citedRoot) !== parentDir) {
|
|
81
|
-
return { skipped: true, reason: `local repo token "${link.repo}" is not a valid sibling` };
|
|
82
|
-
}
|
|
83
|
-
try {
|
|
84
|
-
if (dirname(realpathSync(citedRoot)) !== realpathSync(parentDir)) {
|
|
85
|
-
return { skipped: true, reason: `local repo "${link.repo}" escapes the workspace parent` };
|
|
86
|
-
}
|
|
87
|
-
} catch { return { skipped: true, reason: `local target ${link.repo} not found` }; }
|
|
75
|
+
// Containment guard (shared with xref-push): repo token must resolve to a
|
|
76
|
+
// DIRECT sibling of cwd. resolveSiblingRoot covers the repo-token guard ONLY;
|
|
77
|
+
// the to_code presence check above and the feature read below stay here.
|
|
78
|
+
const sib = resolveSiblingRoot(cwd, link.repo);
|
|
79
|
+
if (sib.skipped) return sib;
|
|
80
|
+
const citedRoot = sib.root;
|
|
88
81
|
// Resolve the SIBLING's own features dir (it may have its own paths.features).
|
|
89
82
|
try {
|
|
90
83
|
const fjPath = join(citedRoot, loadFeaturesDir(citedRoot), link.to_code, 'feature.json');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smartmemory/compose",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.34-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",
|
package/server/agent-hooks.js
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
* SDK hook callbacks for the Agent Server.
|
|
3
3
|
*
|
|
4
4
|
* These are in-process JavaScript functions passed to query() — not shell
|
|
5
|
-
* scripts. They POST structured events to the api-server (port
|
|
5
|
+
* scripts. They POST structured events to the api-server (port 4001), which
|
|
6
6
|
* feeds SessionManager and VisionServer, preserving all Phase 3 monitoring.
|
|
7
7
|
*
|
|
8
8
|
* Single source of truth for TOOL_CATEGORIES (previously duplicated in
|
|
9
9
|
* Terminal.jsx and vision-server.js).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
import { resolvePort } from '../lib/resolve-port.js';
|
|
13
|
+
|
|
14
|
+
const API_SERVER = `http://127.0.0.1:${resolvePort()}`;
|
|
13
15
|
|
|
14
16
|
/** Semantic category for each Claude Code tool */
|
|
15
17
|
export const TOOL_CATEGORIES = {
|
package/server/agent-server.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* User input arrives via POST — SSE is the right transport for a unidirectional
|
|
7
7
|
* async iterator.
|
|
8
8
|
*
|
|
9
|
-
* Process isolation: this server only talks to api-server (
|
|
9
|
+
* Process isolation: this server only talks to api-server (4001) via SDK hooks.
|
|
10
10
|
* SessionManager and VisionServer require zero changes.
|
|
11
11
|
*/
|
|
12
12
|
|
|
@@ -114,6 +114,7 @@ export function assertTerminalStatusAuthorized(args, toolName, capsOverride) {
|
|
|
114
114
|
}
|
|
115
115
|
import { resolveWorkspace } from '../lib/resolve-workspace.js';
|
|
116
116
|
import { discoverWorkspaces } from '../lib/discover-workspaces.js';
|
|
117
|
+
import { resolvePort } from '../lib/resolve-port.js';
|
|
117
118
|
|
|
118
119
|
export function getVisionFile() { return path.join(getDataDir(), 'vision-state.json'); }
|
|
119
120
|
export function getSessionsFile() { return path.join(getDataDir(), 'sessions.json'); }
|
|
@@ -483,6 +484,16 @@ export async function toolRoadmapGraph({ project, out } = {}) {
|
|
|
483
484
|
}
|
|
484
485
|
}
|
|
485
486
|
|
|
487
|
+
// COMP-ROADMAP-XREF-PUSH-2: programmatic push surface. Dry-run by default;
|
|
488
|
+
// only links with push:true are eligible. Returns the small summary, never a
|
|
489
|
+
// large body. apply:true performs the writes (github issue state/labels, local
|
|
490
|
+
// sibling status via the sibling's own setFeatureStatus).
|
|
491
|
+
export async function toolRoadmapXrefPush({ project, apply } = {}) {
|
|
492
|
+
const { pushExternalRefs } = await import('../lib/xref-push.js');
|
|
493
|
+
const cwd = project || getTargetRoot();
|
|
494
|
+
return pushExternalRefs(cwd, { apply: apply === true });
|
|
495
|
+
}
|
|
496
|
+
|
|
486
497
|
export async function toolRoadmapGraphCheck({ project, out } = {}) {
|
|
487
498
|
const { checkRoadmapGraph } = await import('../lib/roadmap-graph/index.js');
|
|
488
499
|
const cwd = project || getTargetRoot();
|
|
@@ -539,7 +550,7 @@ export async function toolBindSession({ featureCode, profile } = {}) {
|
|
|
539
550
|
// ---------------------------------------------------------------------------
|
|
540
551
|
|
|
541
552
|
function _getComposeApi() {
|
|
542
|
-
return `http://127.0.0.1:${
|
|
553
|
+
return `http://127.0.0.1:${resolvePort()}`;
|
|
543
554
|
}
|
|
544
555
|
|
|
545
556
|
async function _postLifecycle(itemId, action, body) {
|
|
@@ -582,7 +593,7 @@ async function _postGate(gateId, action, body) {
|
|
|
582
593
|
* COMP-WORKSPACE-HTTP T5.
|
|
583
594
|
*/
|
|
584
595
|
async function _httpRequest(method, urlPath, body = null) {
|
|
585
|
-
const port =
|
|
596
|
+
const port = resolvePort();
|
|
586
597
|
const headers = { 'Content-Type': 'application/json' };
|
|
587
598
|
if (_binding?.id) headers['X-Compose-Workspace-Id'] = _binding.id;
|
|
588
599
|
let payload = null;
|
package/server/compose-mcp.js
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
toolValidateProject,
|
|
62
62
|
toolRoadmapGraph,
|
|
63
63
|
toolRoadmapGraphCheck,
|
|
64
|
+
toolRoadmapXrefPush,
|
|
64
65
|
toolSetWorkspace,
|
|
65
66
|
toolGetWorkspace,
|
|
66
67
|
toolWriteCheckpoint,
|
|
@@ -407,6 +408,17 @@ const TOOLS = [
|
|
|
407
408
|
},
|
|
408
409
|
},
|
|
409
410
|
},
|
|
411
|
+
{
|
|
412
|
+
name: 'roadmap_xref_push',
|
|
413
|
+
description: 'COMP-ROADMAP-XREF-PUSH: write external trackers to match feature.json `expect=`/`expect_labels` declared intent (the write-side counterpart to roadmap xref-sync Pull). DRY-RUN by default — returns {pushed, skipped, unchanged, scanned} describing what WOULD change. Only links with `push: true` are eligible. apply:true performs the writes: github issue open/closed + additive labels (never removes; PR-backed refs skipped), and local refs via the sibling repo\'s own setFeatureStatus. Degrades (skips, never guesses) on offline/no-token/404/non-2xx/disallowed-transition. Returns a small summary, never a large body.',
|
|
414
|
+
inputSchema: {
|
|
415
|
+
type: 'object',
|
|
416
|
+
properties: {
|
|
417
|
+
project: { type: 'string', description: 'Project root path. Default: current workspace.' },
|
|
418
|
+
apply: { type: 'boolean', description: 'Perform the writes. Default false (dry-run — report only).' },
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
},
|
|
410
422
|
{
|
|
411
423
|
name: 'propose_followup',
|
|
412
424
|
description: 'File a follow-up feature against a parent. Auto-numbers the next code in the parent\'s namespace (parent_code-N), adds the ROADMAP row, links surfaced_by from new → parent, and scaffolds design.md with a "## Why" rationale block. Idempotent on (parent_code, idempotency_key); resumes across partial failures via an inflight ledger.',
|
|
@@ -735,6 +747,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
735
747
|
case 'validate_project': result = await toolValidateProject(args); break;
|
|
736
748
|
case 'roadmap_graph': result = await toolRoadmapGraph(args); break;
|
|
737
749
|
case 'roadmap_graph_check': result = await toolRoadmapGraphCheck(args); break;
|
|
750
|
+
case 'roadmap_xref_push': result = await toolRoadmapXrefPush(args); break;
|
|
738
751
|
case 'propose_followup': result = await toolProposeFollowup(args); break;
|
|
739
752
|
case 'write_checkpoint': result = await toolWriteCheckpoint(args); break;
|
|
740
753
|
case 'compose_resume': result = await toolComposeResume(args); break;
|
package/server/supervisor.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Process supervisor for Compose.
|
|
3
3
|
* Manages three independent processes:
|
|
4
|
-
* 1. API server (port
|
|
5
|
-
* 2. Agent server (port
|
|
6
|
-
* 3. Vite dev server (port
|
|
4
|
+
* 1. API server (port 4001) — Express + file-watcher + vision
|
|
5
|
+
* 2. Agent server (port 4002) — SDK streaming, structured messages (Tier 1, immortal)
|
|
6
|
+
* 3. Vite dev server (port 5195) — Frontend HMR
|
|
7
7
|
*
|
|
8
8
|
* Each process gets independent restart with exponential backoff.
|
|
9
9
|
* If a process keeps crashing for > 1 minute, the supervisor gives up on it.
|