@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 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 tracker(s):`)
1171
- for (const s of res.pushed) console.log(` ${s.code} github ${s.target}: ${s.from} ${s.to} (${verb})`)
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} github ${s.target}: ${s.reason}`)
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 to GitHub.`)
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 (default http://localhost:3000)
2776
- const baseUrl = process.env.COMPOSE_URL || 'http://localhost:3000'
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
  }
@@ -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;
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * Canonical port resolution for the Compose server.
3
- * Single source of truth: COMPOSE_PORT > PORT > 3001.
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) || 3001;
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
- return { state };
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
- * Write a github link's issue to `toState`. Uses updateIssueResult (status-
89
- * returning) so a non-2xx response degrades to a skip rather than a false
90
- * success. Returns { ok: true } or { skipped, reason }.
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, toState, opts = {}) {
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, { state: toState });
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
- * @param {(link: object, toState: string) => Promise<{ok?: boolean, skipped?: boolean, reason?: string}>} [opts.write]
116
- * @param {object} [opts.githubTransport] injectable transport for the default resolve/write (tests)
117
- * @param {object} [opts.githubAuth] injectable auth for the default resolve/write (tests)
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 resolve = opts.resolve ?? ((link) => defaultResolve(link, clientOpts));
124
- const write = opts.write ?? ((link, toState) => defaultWrite(link, toState, clientOpts));
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
- // v1: github only, opt-in only, valid target state only.
146
- if (link.provider !== 'github' || link.push !== true) continue;
147
- scanned++;
148
- if (!isGithubState(link.expect)) {
149
- skipped.push({ code: fj.code, target: targetLabel(link), reason: `malformed expect "${link.expect}" (want open|closed)` });
150
- continue;
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
- const r = await resolve(link);
154
- if (r.skipped) {
155
- skipped.push({ code: fj.code, target: targetLabel(link), reason: r.reason });
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
- if (!apply) {
162
- pushed.push({ code: fj.code, target: targetLabel(link), from: verdict.from, to: verdict.to });
163
- continue;
164
- }
165
- const w = await write(link, verdict.to);
166
- if (w.skipped) {
167
- skipped.push({ code: fj.code, target: targetLabel(link), reason: w.reason });
168
- continue;
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
- pushed.push({ code: fj.code, target: targetLabel(link), from: verdict.from, to: verdict.to });
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, realpathSync } from 'fs';
17
- import { join, resolve as resolvePath, dirname } from 'path';
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 (parity with feature-validator resolveLocalRef): the
75
- // repo token must resolve to a DIRECT sibling of cwd lexical check first,
76
- // then realpath to defeat a valid-named sibling symlinked outside the parent.
77
- const parentDir = resolvePath(cwd, '..');
78
- const citedRoot = resolvePath(parentDir, String(link.repo));
79
- if (/[\\/]/.test(link.repo) || link.repo === '.' || link.repo === '..'
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.32-beta",
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",
@@ -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 3001), which
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
- const API_SERVER = `http://127.0.0.1:${process.env.PORT || 3001}`;
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 = {
@@ -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 (3001) via SDK hooks.
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:${process.env.COMPOSE_PORT || process.env.PORT || 3001}`;
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 = process.env.COMPOSE_PORT || process.env.PORT || 3001;
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;
@@ -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;
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Process supervisor for Compose.
3
3
  * Manages three independent processes:
4
- * 1. API server (port 3001) — Express + file-watcher + vision
5
- * 2. Agent server (port 3002) — SDK streaming, structured messages (Tier 1, immortal)
6
- * 3. Vite dev server (port 5173) — Frontend HMR
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.