@hegemonart/get-design-done 1.26.0 → 1.27.1
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +74 -0
- package/README.md +10 -8
- package/SKILL.md +3 -0
- package/agents/README.md +29 -0
- package/package.json +2 -2
- package/reference/peer-cli-capabilities.md +151 -0
- package/reference/peer-protocols.md +266 -0
- package/reference/registry.json +14 -0
- package/reference/runtime-models.md +3 -3
- package/scripts/install.cjs +100 -1
- package/scripts/lib/bandit-router.cjs +214 -7
- package/scripts/lib/budget-enforcer.cjs +69 -1
- package/scripts/lib/event-stream/index.ts +14 -1
- package/scripts/lib/event-stream/types.ts +125 -1
- package/scripts/lib/install/runtimes.cjs +58 -0
- package/scripts/lib/peer-cli/acp-client.cjs +375 -0
- package/scripts/lib/peer-cli/adapters/codex.cjs +101 -0
- package/scripts/lib/peer-cli/adapters/copilot.cjs +79 -0
- package/scripts/lib/peer-cli/adapters/cursor.cjs +78 -0
- package/scripts/lib/peer-cli/adapters/gemini.cjs +81 -0
- package/scripts/lib/peer-cli/adapters/qwen.cjs +72 -0
- package/scripts/lib/peer-cli/asp-client.cjs +587 -0
- package/scripts/lib/peer-cli/broker-lifecycle.cjs +406 -0
- package/scripts/lib/peer-cli/registry.cjs +434 -0
- package/scripts/lib/peer-cli/spawn-cmd.cjs +149 -0
- package/scripts/lib/runtime-detect.cjs +1 -1
- package/scripts/lib/session-runner/index.ts +362 -0
- package/scripts/lib/session-runner/types.ts +60 -0
- package/scripts/validate-frontmatter.ts +159 -1
- package/skills/peer-cli-add/SKILL.md +170 -0
- package/skills/peer-cli-customize/SKILL.md +110 -0
- package/skills/peers/SKILL.md +101 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// scripts/lib/peer-cli/registry.cjs
|
|
2
|
+
//
|
|
3
|
+
// Plan 27-05 — central peer-CLI dispatch + per-peer health probe.
|
|
4
|
+
//
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// WHAT THIS DOES
|
|
7
|
+
// ============================================================================
|
|
8
|
+
//
|
|
9
|
+
// The registry is the single entry-point the session-runner (Plan 27-06) calls
|
|
10
|
+
// when an agent's frontmatter says `delegate_to: <peer-role-id>`. It answers
|
|
11
|
+
// three questions in order:
|
|
12
|
+
//
|
|
13
|
+
// 1. Which peer-CLI claims this `(role, tier)`? (capability matrix)
|
|
14
|
+
// 2. Is that peer actually usable on this host right now? (health probe)
|
|
15
|
+
// 3. Can we run the call against it? (dispatch)
|
|
16
|
+
//
|
|
17
|
+
// On any "no" along that chain, the registry returns `null` rather than
|
|
18
|
+
// throwing — the caller (session-runner) treats null as "fall back to local
|
|
19
|
+
// Anthropic SDK". This is the **transparent-fallback** contract from CONTEXT
|
|
20
|
+
// D-07: peers are an optimization, never a requirement, and a missing or
|
|
21
|
+
// broken peer must never break the cycle.
|
|
22
|
+
//
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// CAPABILITY MATRIX (CONTEXT D-05, locked)
|
|
25
|
+
// ============================================================================
|
|
26
|
+
//
|
|
27
|
+
// codex → execute (ASP)
|
|
28
|
+
// gemini → research, exploration (ACP)
|
|
29
|
+
// cursor → debug, plan (ACP)
|
|
30
|
+
// copilot → review, research (ACP)
|
|
31
|
+
// qwen → write (ACP)
|
|
32
|
+
//
|
|
33
|
+
// When two peers claim the same role (e.g. `research` is claimed by both
|
|
34
|
+
// gemini and copilot), `findPeerFor` picks the FIRST in alphabetical peer-name
|
|
35
|
+
// order — deterministic so reflectors can attribute regressions to a specific
|
|
36
|
+
// peer. Users override the order via `.design/config.json#peer_cli.enabled_peers`
|
|
37
|
+
// (a peer not in the allowlist is treated as absent).
|
|
38
|
+
//
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// OPT-IN GATING (CONTEXT D-11)
|
|
41
|
+
// ============================================================================
|
|
42
|
+
//
|
|
43
|
+
// Even if a peer's binary is on disk, the registry refuses to dispatch unless
|
|
44
|
+
// the peer ID appears in `.design/config.json#peer_cli.enabled_peers` (an
|
|
45
|
+
// allowlist array). Default config: `enabled_peers: []` — empty, opt-in
|
|
46
|
+
// required. The install-time nudge (Plan 27-11) populates this on user
|
|
47
|
+
// confirmation.
|
|
48
|
+
//
|
|
49
|
+
// This protects the trust contract: the user pays for their peer-CLI
|
|
50
|
+
// subscriptions; gdd auto-routing to them without consent would be a privacy
|
|
51
|
+
// + cost surprise.
|
|
52
|
+
//
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// DEFENSIVE ADAPTER LOADING
|
|
55
|
+
// ============================================================================
|
|
56
|
+
//
|
|
57
|
+
// Per-peer adapters live at `scripts/lib/peer-cli/adapters/<peer>.cjs` and
|
|
58
|
+
// are landed by Plan 27-04 (parallel with this plan). We load them via
|
|
59
|
+
// `try { require(...) } catch { return null }` so the registry remains
|
|
60
|
+
// functional even if an adapter goes missing or hasn't shipped yet — the
|
|
61
|
+
// peer is simply treated as absent. The same defensive load makes it safe
|
|
62
|
+
// for users to remove an adapter file to force-disable a single peer
|
|
63
|
+
// without editing the registry.
|
|
64
|
+
//
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// HEALTH PROBE CONTRACT
|
|
67
|
+
// ============================================================================
|
|
68
|
+
//
|
|
69
|
+
// `healthProbe(peer)` returns `{ available: bool, reason?: string }`.
|
|
70
|
+
// It checks, in order:
|
|
71
|
+
//
|
|
72
|
+
// (a) Is `peer` in the `enabled_peers` allowlist? (D-11 opt-in)
|
|
73
|
+
// (b) Does the adapter module load? (defensive require)
|
|
74
|
+
// (c) Does the adapter expose a `peerBinary` resolver, and does that path
|
|
75
|
+
// exist on the filesystem? (basic install check)
|
|
76
|
+
//
|
|
77
|
+
// We do NOT spawn the binary with `--version` here — that adds 100-500ms
|
|
78
|
+
// latency per dispatch and is brittle on cold-start (Codex' app-server takes
|
|
79
|
+
// >1s to print its version on macOS). The broker layer (Plan 27-03) handles
|
|
80
|
+
// liveness via the long-lived session itself; if a binary is corrupt the
|
|
81
|
+
// broker connect fails and that surfaces as a peer-error which `dispatch`
|
|
82
|
+
// converts to null. If a future plan needs a deeper probe, it goes here.
|
|
83
|
+
//
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// FALLBACK SEMANTICS
|
|
86
|
+
// ============================================================================
|
|
87
|
+
//
|
|
88
|
+
// `dispatch(role, tier, text, opts)` returns one of:
|
|
89
|
+
//
|
|
90
|
+
// - null → no peer claims this role / opt-out / health-probe
|
|
91
|
+
// failed / adapter threw / peer returned an error.
|
|
92
|
+
// Caller falls back to local.
|
|
93
|
+
// - {result, peer} → peer succeeded; result is whatever the adapter
|
|
94
|
+
// returned (Plan 27-04 enforces the structured shape).
|
|
95
|
+
//
|
|
96
|
+
// The registry never throws on peer-side breakage. It DOES throw on
|
|
97
|
+
// programmer error (bad arg types) — those are bugs in the caller, not the
|
|
98
|
+
// peer ecosystem.
|
|
99
|
+
//
|
|
100
|
+
// Phase 22 event emission (`peer_call_started` / `peer_call_complete` /
|
|
101
|
+
// `peer_call_failed`) is Plan 27-08's job; v1.27.0 registry just returns
|
|
102
|
+
// null and lets 27-08 wrap dispatch with the event tags.
|
|
103
|
+
|
|
104
|
+
'use strict';
|
|
105
|
+
|
|
106
|
+
const fs = require('node:fs');
|
|
107
|
+
const path = require('node:path');
|
|
108
|
+
|
|
109
|
+
// ── Capability matrix (D-05, locked) ────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Per-peer claimed roles. Frozen so consumers can't mutate at runtime.
|
|
113
|
+
* Adding a new peer: extend this map AND drop a `adapters/<peer>.cjs`
|
|
114
|
+
* (Plan 27-10's `peer-cli-add` skill walks users through both steps).
|
|
115
|
+
*
|
|
116
|
+
* Each entry also carries the protocol the peer speaks; the adapter layer
|
|
117
|
+
* uses that to pick acp-client vs asp-client. The registry surfaces it for
|
|
118
|
+
* `peer-cli-capabilities.md` rendering and the `/gdd:peers` command.
|
|
119
|
+
*
|
|
120
|
+
* @type {Readonly<Record<string, {roles: readonly string[], protocol: 'acp'|'asp'}>>}
|
|
121
|
+
*/
|
|
122
|
+
const CAPABILITY_MATRIX = Object.freeze({
|
|
123
|
+
codex: Object.freeze({ roles: Object.freeze(['execute']), protocol: 'asp' }),
|
|
124
|
+
copilot: Object.freeze({ roles: Object.freeze(['review', 'research']), protocol: 'acp' }),
|
|
125
|
+
cursor: Object.freeze({ roles: Object.freeze(['debug', 'plan']), protocol: 'acp' }),
|
|
126
|
+
gemini: Object.freeze({ roles: Object.freeze(['research', 'exploration']), protocol: 'acp' }),
|
|
127
|
+
qwen: Object.freeze({ roles: Object.freeze(['write']), protocol: 'acp' }),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* All known peer IDs in deterministic alphabetical order. When two peers
|
|
132
|
+
* claim the same role, this order decides which one `findPeerFor` returns
|
|
133
|
+
* — alphabetical because it's stable across releases and gives reflectors
|
|
134
|
+
* an attribution anchor that doesn't shift if we add/remove peers.
|
|
135
|
+
*/
|
|
136
|
+
const KNOWN_PEERS = Object.freeze(Object.keys(CAPABILITY_MATRIX).sort());
|
|
137
|
+
|
|
138
|
+
// ── Config loading (D-11 opt-in gating) ─────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read `<cwd>/.design/config.json` and extract the
|
|
142
|
+
* `peer_cli.enabled_peers` allowlist. Returns an empty array on any
|
|
143
|
+
* failure path (file missing, unparsable, wrong shape) — opt-in by default
|
|
144
|
+
* means "absence == empty allowlist", not "absence == error".
|
|
145
|
+
*
|
|
146
|
+
* Test injection: pass `cwd` so unit tests can point at a fixture dir
|
|
147
|
+
* without `process.chdir`.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} [cwd] defaults to `process.cwd()`
|
|
150
|
+
* @returns {string[]} allowlisted peer IDs (lowercased, deduped); empty by default
|
|
151
|
+
*/
|
|
152
|
+
function readEnabledPeers(cwd) {
|
|
153
|
+
const root = typeof cwd === 'string' && cwd.length > 0 ? cwd : process.cwd();
|
|
154
|
+
const cfgPath = path.join(root, '.design', 'config.json');
|
|
155
|
+
let raw;
|
|
156
|
+
try {
|
|
157
|
+
raw = fs.readFileSync(cfgPath, 'utf8');
|
|
158
|
+
} catch {
|
|
159
|
+
return []; // no config → no peers enabled
|
|
160
|
+
}
|
|
161
|
+
let parsed;
|
|
162
|
+
try {
|
|
163
|
+
parsed = JSON.parse(raw);
|
|
164
|
+
} catch {
|
|
165
|
+
return []; // malformed config is a user-fixable error, but registry must not crash
|
|
166
|
+
}
|
|
167
|
+
const peerCli = parsed && typeof parsed === 'object' ? parsed.peer_cli : null;
|
|
168
|
+
const list = peerCli && Array.isArray(peerCli.enabled_peers)
|
|
169
|
+
? peerCli.enabled_peers
|
|
170
|
+
: [];
|
|
171
|
+
// Lowercase + dedupe + filter to known peers; an unknown ID in the
|
|
172
|
+
// allowlist is silently dropped (a user typo shouldn't crash dispatch).
|
|
173
|
+
const out = [];
|
|
174
|
+
const seen = new Set();
|
|
175
|
+
for (const item of list) {
|
|
176
|
+
if (typeof item !== 'string') continue;
|
|
177
|
+
const norm = item.toLowerCase();
|
|
178
|
+
if (seen.has(norm)) continue;
|
|
179
|
+
if (!Object.prototype.hasOwnProperty.call(CAPABILITY_MATRIX, norm)) continue;
|
|
180
|
+
seen.add(norm);
|
|
181
|
+
out.push(norm);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Defensive adapter loading ───────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Try to require the per-peer adapter module. Returns null on any failure
|
|
190
|
+
* (module missing / require threw / module shape unrecognized). Per the
|
|
191
|
+
* coordination note in the plan brief: this lets the registry function
|
|
192
|
+
* even when Plan 27-04 hasn't landed yet, and lets users force-disable a
|
|
193
|
+
* peer by deleting its adapter file.
|
|
194
|
+
*
|
|
195
|
+
* Test injection: `loadAdapterFn` lets unit tests stub the require with a
|
|
196
|
+
* mock-adapter factory keyed by peer ID. Real callers omit it.
|
|
197
|
+
*
|
|
198
|
+
* @param {string} peer
|
|
199
|
+
* @param {(peer: string) => unknown} [loadAdapterFn]
|
|
200
|
+
* @returns {object | null}
|
|
201
|
+
*/
|
|
202
|
+
function loadAdapter(peer, loadAdapterFn) {
|
|
203
|
+
if (typeof loadAdapterFn === 'function') {
|
|
204
|
+
try {
|
|
205
|
+
const mod = loadAdapterFn(peer);
|
|
206
|
+
return mod && typeof mod === 'object' ? mod : null;
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
// eslint-disable-next-line global-require, import/no-dynamic-require
|
|
213
|
+
const mod = require(`./adapters/${peer}.cjs`);
|
|
214
|
+
return mod && typeof mod === 'object' ? mod : null;
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Health probe ────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Determine whether the given peer is usable on this host.
|
|
224
|
+
*
|
|
225
|
+
* Checks (in order, short-circuiting on the first failure):
|
|
226
|
+
* 1. peer ID is in the capability matrix (rejects typos)
|
|
227
|
+
* 2. peer ID appears in `enabled_peers` allowlist (D-11 opt-in)
|
|
228
|
+
* 3. adapter module loads
|
|
229
|
+
* 4. adapter exposes `peerBinary()` (a function) AND that path exists
|
|
230
|
+
* on the filesystem
|
|
231
|
+
*
|
|
232
|
+
* We deliberately skip a `--version`-style spawn probe here. Cost: ~100-500ms
|
|
233
|
+
* per dispatch on cold-start, brittleness on slow file-systems / corrupt
|
|
234
|
+
* binaries. The broker layer (Plan 27-03) catches a corrupt binary as a
|
|
235
|
+
* connect failure and surfaces it as a peer-error — which `dispatch`
|
|
236
|
+
* converts to null. Documented choice: less probing here = faster dispatch
|
|
237
|
+
* on the happy path, with the broker as the actual liveness gate.
|
|
238
|
+
*
|
|
239
|
+
* Returns `{ available: false, reason: '...' }` on any negative; the reason
|
|
240
|
+
* is plain English suitable for `/gdd:peers` rendering or test assertions.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} peer
|
|
243
|
+
* @param {object} [opts]
|
|
244
|
+
* @param {string} [opts.cwd] override `process.cwd()` for config + path resolution
|
|
245
|
+
* @param {string[]} [opts.enabledPeers] override the config-derived allowlist
|
|
246
|
+
* @param {(peer: string) => unknown} [opts.loadAdapter] test injection
|
|
247
|
+
* @param {(p: string) => boolean} [opts.fileExists] test injection (defaults to fs.existsSync)
|
|
248
|
+
* @returns {{available: true} | {available: false, reason: string}}
|
|
249
|
+
*/
|
|
250
|
+
function healthProbe(peer, opts) {
|
|
251
|
+
const o = opts || {};
|
|
252
|
+
if (typeof peer !== 'string' || peer.length === 0) {
|
|
253
|
+
return { available: false, reason: 'invalid peer id (must be non-empty string)' };
|
|
254
|
+
}
|
|
255
|
+
const norm = peer.toLowerCase();
|
|
256
|
+
if (!Object.prototype.hasOwnProperty.call(CAPABILITY_MATRIX, norm)) {
|
|
257
|
+
return { available: false, reason: `unknown peer "${peer}" (not in capability matrix)` };
|
|
258
|
+
}
|
|
259
|
+
const enabled = Array.isArray(o.enabledPeers) ? o.enabledPeers : readEnabledPeers(o.cwd);
|
|
260
|
+
if (!enabled.includes(norm)) {
|
|
261
|
+
return { available: false, reason: `peer "${norm}" not in .design/config.json#peer_cli.enabled_peers (opt-in required)` };
|
|
262
|
+
}
|
|
263
|
+
const adapter = loadAdapter(norm, o.loadAdapter);
|
|
264
|
+
if (!adapter) {
|
|
265
|
+
return { available: false, reason: `adapter module scripts/lib/peer-cli/adapters/${norm}.cjs missing or failed to load` };
|
|
266
|
+
}
|
|
267
|
+
if (typeof adapter.peerBinary !== 'function') {
|
|
268
|
+
// Adapter is loaded but doesn't expose the resolver we need to verify
|
|
269
|
+
// installation. Treat as "available but un-probable" for v1.27 — the
|
|
270
|
+
// dispatch path will still try; if the binary is missing the protocol
|
|
271
|
+
// client errors out and dispatch returns null. We return available:true
|
|
272
|
+
// here so the user's `/gdd:peers` command shows the peer as usable
|
|
273
|
+
// pending a real call. (Once Plan 27-11 ships `peerBinary` on every
|
|
274
|
+
// adapter via runtimes.cjs, this branch becomes dead code.)
|
|
275
|
+
return { available: true };
|
|
276
|
+
}
|
|
277
|
+
let binPath;
|
|
278
|
+
try {
|
|
279
|
+
binPath = adapter.peerBinary();
|
|
280
|
+
} catch (err) {
|
|
281
|
+
return { available: false, reason: `adapter peerBinary() threw: ${err && err.message ? err.message : 'unknown error'}` };
|
|
282
|
+
}
|
|
283
|
+
if (typeof binPath !== 'string' || binPath.length === 0) {
|
|
284
|
+
// Adapter chose not to resolve a binary (e.g. peer not installed) —
|
|
285
|
+
// honor that without re-checking the filesystem.
|
|
286
|
+
return { available: false, reason: `peer "${norm}" binary not resolved by adapter (peer not installed?)` };
|
|
287
|
+
}
|
|
288
|
+
const exists = typeof o.fileExists === 'function'
|
|
289
|
+
? o.fileExists(binPath)
|
|
290
|
+
: fs.existsSync(binPath);
|
|
291
|
+
if (!exists) {
|
|
292
|
+
return { available: false, reason: `peer "${norm}" binary missing at ${binPath}` };
|
|
293
|
+
}
|
|
294
|
+
return { available: true };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── findPeerFor ─────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Resolve the best peer for a `(role, tier)` request.
|
|
301
|
+
*
|
|
302
|
+
* Tier is currently advisory — the capability matrix doesn't gate on tier
|
|
303
|
+
* because peer-CLI subscriptions don't expose tier-by-tier pricing the way
|
|
304
|
+
* Anthropic does. We accept the parameter so callers can pass through the
|
|
305
|
+
* tier for telemetry (Plan 27-08 logs it on `peer_call_*` events) and so
|
|
306
|
+
* future work can teach the matrix to reject e.g. opus-tier on a peer
|
|
307
|
+
* that only supports a sonnet-class model.
|
|
308
|
+
*
|
|
309
|
+
* Algorithm:
|
|
310
|
+
* 1. Filter peers whose capability matrix includes `role`.
|
|
311
|
+
* 2. Walk those in alphabetical peer-name order (KNOWN_PEERS is already
|
|
312
|
+
* sorted) — the first one whose health probe says "available" wins.
|
|
313
|
+
* 3. Return `{ peer, adapter, protocol, roles }` or null.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} role e.g. 'research', 'execute'
|
|
316
|
+
* @param {string|null} [tier] advisory; passed through opts to adapters
|
|
317
|
+
* @param {object} [opts] same shape as healthProbe opts
|
|
318
|
+
* @returns {{peer: string, adapter: object, protocol: 'acp'|'asp', roles: readonly string[]} | null}
|
|
319
|
+
*/
|
|
320
|
+
function findPeerFor(role, tier, opts) {
|
|
321
|
+
if (typeof role !== 'string' || role.length === 0) {
|
|
322
|
+
throw new TypeError('findPeerFor: role must be a non-empty string');
|
|
323
|
+
}
|
|
324
|
+
// tier is advisory; we tolerate undefined/null/string. Other types are
|
|
325
|
+
// a programmer error and worth surfacing.
|
|
326
|
+
if (tier !== undefined && tier !== null && typeof tier !== 'string') {
|
|
327
|
+
throw new TypeError('findPeerFor: tier must be a string, null, or undefined');
|
|
328
|
+
}
|
|
329
|
+
const o = opts || {};
|
|
330
|
+
// Pre-resolve the allowlist once for this call so we don't re-read
|
|
331
|
+
// .design/config.json per peer.
|
|
332
|
+
const enabledPeers = Array.isArray(o.enabledPeers)
|
|
333
|
+
? o.enabledPeers
|
|
334
|
+
: readEnabledPeers(o.cwd);
|
|
335
|
+
|
|
336
|
+
for (const peer of KNOWN_PEERS) {
|
|
337
|
+
const cap = CAPABILITY_MATRIX[peer];
|
|
338
|
+
if (!cap.roles.includes(role)) continue;
|
|
339
|
+
const probe = healthProbe(peer, {
|
|
340
|
+
cwd: o.cwd,
|
|
341
|
+
enabledPeers,
|
|
342
|
+
loadAdapter: o.loadAdapter,
|
|
343
|
+
fileExists: o.fileExists,
|
|
344
|
+
});
|
|
345
|
+
if (!probe.available) continue;
|
|
346
|
+
const adapter = loadAdapter(peer, o.loadAdapter);
|
|
347
|
+
if (!adapter) continue; // race: adapter vanished between probe and load
|
|
348
|
+
return {
|
|
349
|
+
peer,
|
|
350
|
+
adapter,
|
|
351
|
+
protocol: cap.protocol,
|
|
352
|
+
roles: cap.roles,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── dispatch ────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Run a delegated call. Returns:
|
|
362
|
+
*
|
|
363
|
+
* - null → no peer or peer-side failure (caller falls back)
|
|
364
|
+
* - { result, peer, protocol } → peer succeeded; result is the adapter's payload
|
|
365
|
+
*
|
|
366
|
+
* Adapter contract (Plan 27-04 enforces): each adapter exposes a `dispatch`
|
|
367
|
+
* function with signature `(role, tier, text, opts) -> Promise<result>`.
|
|
368
|
+
* The registry awaits that promise and converts thrown errors into the
|
|
369
|
+
* null-fallback path. Adapter is responsible for protocol framing,
|
|
370
|
+
* slash-command translation, and broker lifecycle — registry just routes.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} role
|
|
373
|
+
* @param {string|null} tier
|
|
374
|
+
* @param {string} text
|
|
375
|
+
* @param {object} [opts]
|
|
376
|
+
* @param {string} [opts.cwd]
|
|
377
|
+
* @param {string[]} [opts.enabledPeers]
|
|
378
|
+
* @param {(peer: string) => unknown} [opts.loadAdapter]
|
|
379
|
+
* @param {(p: string) => boolean} [opts.fileExists]
|
|
380
|
+
* @returns {Promise<{result: unknown, peer: string, protocol: 'acp'|'asp'} | null>}
|
|
381
|
+
*/
|
|
382
|
+
async function dispatch(role, tier, text, opts) {
|
|
383
|
+
if (typeof text !== 'string') {
|
|
384
|
+
throw new TypeError('dispatch: text must be a string');
|
|
385
|
+
}
|
|
386
|
+
const found = findPeerFor(role, tier === undefined ? null : tier, opts);
|
|
387
|
+
if (!found) return null;
|
|
388
|
+
const { peer, adapter, protocol } = found;
|
|
389
|
+
if (typeof adapter.dispatch !== 'function') {
|
|
390
|
+
// Adapter shape mismatch — treat as peer-side failure so the caller
|
|
391
|
+
// falls back. Phase 22 events (Plan 27-08) will tag this as
|
|
392
|
+
// `peer_call_failed` with reason="adapter_shape".
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
const result = await adapter.dispatch(role, tier === undefined ? null : tier, text, opts || {});
|
|
397
|
+
return { result, peer, protocol };
|
|
398
|
+
} catch {
|
|
399
|
+
// Per D-07: peer-error is a transparent fallback. We swallow the
|
|
400
|
+
// error here and return null. Plan 27-08 wraps this with event
|
|
401
|
+
// emission so the reflector still sees the failure signal.
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── Introspection helpers (used by /gdd:peers in Plan 27-09) ────────────────
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Return a snapshot of the capability matrix as a plain (non-frozen) object
|
|
410
|
+
* suitable for JSON serialization. Useful for `/gdd:peers` rendering and
|
|
411
|
+
* for tests that want to assert on the matrix without touching the frozen
|
|
412
|
+
* exports directly.
|
|
413
|
+
*
|
|
414
|
+
* @returns {Record<string, {roles: string[], protocol: 'acp'|'asp'}>}
|
|
415
|
+
*/
|
|
416
|
+
function describeCapabilities() {
|
|
417
|
+
const out = {};
|
|
418
|
+
for (const peer of KNOWN_PEERS) {
|
|
419
|
+
const cap = CAPABILITY_MATRIX[peer];
|
|
420
|
+
out[peer] = { roles: [...cap.roles], protocol: cap.protocol };
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
module.exports = {
|
|
426
|
+
CAPABILITY_MATRIX,
|
|
427
|
+
KNOWN_PEERS,
|
|
428
|
+
readEnabledPeers,
|
|
429
|
+
loadAdapter,
|
|
430
|
+
healthProbe,
|
|
431
|
+
findPeerFor,
|
|
432
|
+
dispatch,
|
|
433
|
+
describeCapabilities,
|
|
434
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// scripts/lib/peer-cli/spawn-cmd.cjs
|
|
2
|
+
//
|
|
3
|
+
// Plan 27-03 — cross-platform child-process spawn for peer-CLI binaries.
|
|
4
|
+
//
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// THE WINDOWS .cmd EINVAL PROBLEM — DO NOT "CLEAN UP" THIS WORKAROUND
|
|
7
|
+
// ============================================================================
|
|
8
|
+
//
|
|
9
|
+
// Node's `child_process.spawn(absolutePath, args)` on Windows fails with EINVAL
|
|
10
|
+
// when `absolutePath` ends in `.cmd` and `shell` is not set. This is a
|
|
11
|
+
// long-standing, well-documented Node behavior tied to how Windows resolves
|
|
12
|
+
// `.cmd` shim launchers (npm, yarn, claude, gemini, codex, cursor, copilot,
|
|
13
|
+
// qwen all ship `.cmd` shims on Windows). Without the `shell: true` form,
|
|
14
|
+
// every Windows user sees:
|
|
15
|
+
//
|
|
16
|
+
// Error: spawn EINVAL
|
|
17
|
+
// at ChildProcess.spawn (node:internal/child_process:421:11)
|
|
18
|
+
//
|
|
19
|
+
// The fix, per cc-multi-cli's `transport-decisions.md` (Apache-2.0), is to
|
|
20
|
+
// invoke the .cmd through `cmd.exe` by passing a single shell-quoted command
|
|
21
|
+
// string with `shell: true`. We forward-slash the path so Windows shell
|
|
22
|
+
// resolves it correctly even when the path contains backslashes:
|
|
23
|
+
//
|
|
24
|
+
// // BROKEN on Windows for .cmd shims (absPath ends in .cmd):
|
|
25
|
+
// spawn(absPath, ['app-server'])
|
|
26
|
+
//
|
|
27
|
+
// // WORKS everywhere (.cmd via cmd.exe; non-.cmd via direct exec):
|
|
28
|
+
// const fwd = absPath.replace(/\\/g, '/');
|
|
29
|
+
// spawn(`"${fwd}" ${args.join(' ')}`, [], { shell: true })
|
|
30
|
+
//
|
|
31
|
+
// Why we don't `shell: true` unconditionally: shell mode adds ~30ms per spawn
|
|
32
|
+
// on Linux/macOS, mangles argument quoting in surprising ways for binaries
|
|
33
|
+
// that DO support direct exec (the entire POSIX peer-CLI fleet), and exposes
|
|
34
|
+
// a shell-injection surface we don't need outside the .cmd workaround. So
|
|
35
|
+
// we shell ONLY when the workaround actually applies.
|
|
36
|
+
//
|
|
37
|
+
// References:
|
|
38
|
+
// - cc-multi-cli `transport-decisions.md` (Apache 2.0; ported with NOTICE
|
|
39
|
+
// attribution per Plan 27 D-02 / D-14).
|
|
40
|
+
// - Phase 27 CONTEXT.md decision D-04.
|
|
41
|
+
// - Node child_process EINVAL on Windows .cmd: see Node issue tracker for
|
|
42
|
+
// the long-standing CVE-2024-27980-driven hardening that made this
|
|
43
|
+
// fully unworkable without `shell: true` on modern Node.
|
|
44
|
+
//
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// CONTRACT
|
|
47
|
+
// ============================================================================
|
|
48
|
+
//
|
|
49
|
+
// const cp = spawnCmd('/path/to/gemini', ['acp'], { cwd: '/repo' });
|
|
50
|
+
// // cp is a normal ChildProcess; works the same on POSIX + Windows .cmd
|
|
51
|
+
//
|
|
52
|
+
// On non-.cmd paths or non-Windows platforms we delegate to the plain
|
|
53
|
+
// `spawn(path, args, opts)` form. On Windows .cmd we apply the fix above.
|
|
54
|
+
//
|
|
55
|
+
// This module is `.cjs` (matching the rest of `scripts/lib/peer-cli/`) so it
|
|
56
|
+
// can be `require()`d from both the broker subprocess host and the
|
|
57
|
+
// adapter/registry layer without `--experimental-strip-types`.
|
|
58
|
+
|
|
59
|
+
'use strict';
|
|
60
|
+
|
|
61
|
+
const child_process = require('node:child_process');
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Per-call platform/path overrides for testing. Most callers omit this
|
|
65
|
+
* entirely; tests inject a fake platform string + custom spawn function so
|
|
66
|
+
* we can exercise the Windows shell branch on macOS/Linux CI.
|
|
67
|
+
*
|
|
68
|
+
* @typedef {object} SpawnCmdInternals
|
|
69
|
+
* @property {NodeJS.Platform} [platform] override for `process.platform`
|
|
70
|
+
* @property {(cmd: string, args: string[], opts: object) => any} [spawn]
|
|
71
|
+
* override for the underlying spawn implementation; receives the EXACT
|
|
72
|
+
* arguments we would pass to `child_process.spawn`. Useful for asserting
|
|
73
|
+
* that the .cmd branch produced the right shell-mode invocation.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Spawn a child process for a peer-CLI binary, transparently applying the
|
|
78
|
+
* Windows `.cmd` EINVAL workaround when needed.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} command absolute path to the executable. May be `.cmd` /
|
|
81
|
+
* `.bat` on Windows. Do NOT pass a bare command name like `"gemini"`;
|
|
82
|
+
* resolve it to an absolute path first (the registry's job).
|
|
83
|
+
* @param {readonly string[]} [args] argv tail. Defaults to `[]`.
|
|
84
|
+
* @param {object} [options] forwarded to `child_process.spawn`. We add
|
|
85
|
+
* `shell: true` automatically on the Windows .cmd path; callers should NOT
|
|
86
|
+
* pre-set it unless they really know what they're doing.
|
|
87
|
+
* @param {SpawnCmdInternals} [internals] test-only injection point. Real
|
|
88
|
+
* callers omit this.
|
|
89
|
+
* @returns {import('node:child_process').ChildProcess}
|
|
90
|
+
*
|
|
91
|
+
* @throws {TypeError} if `command` is not a non-empty string.
|
|
92
|
+
*/
|
|
93
|
+
function spawnCmd(command, args, options, internals) {
|
|
94
|
+
if (typeof command !== 'string' || command.length === 0) {
|
|
95
|
+
throw new TypeError(
|
|
96
|
+
'spawnCmd: command must be a non-empty absolute path string',
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const safeArgs = Array.isArray(args) ? args : [];
|
|
100
|
+
const safeOpts = options && typeof options === 'object' ? options : {};
|
|
101
|
+
const platform = (internals && internals.platform) || process.platform;
|
|
102
|
+
const spawnImpl = (internals && internals.spawn) || child_process.spawn;
|
|
103
|
+
|
|
104
|
+
const isWindows = platform === 'win32';
|
|
105
|
+
const lower = command.toLowerCase();
|
|
106
|
+
const isCmdShim = lower.endsWith('.cmd') || lower.endsWith('.bat');
|
|
107
|
+
|
|
108
|
+
if (isWindows && isCmdShim) {
|
|
109
|
+
// Forward-slash the path so the shell resolves it consistently even when
|
|
110
|
+
// the absolute path contains backslashes. The double-quote wrapper handles
|
|
111
|
+
// paths with spaces ("C:/Program Files/...").
|
|
112
|
+
const fwd = command.replace(/\\/g, '/');
|
|
113
|
+
const quoted = safeArgs.map(quoteShellArg).join(' ');
|
|
114
|
+
const line = quoted.length > 0 ? `"${fwd}" ${quoted}` : `"${fwd}"`;
|
|
115
|
+
|
|
116
|
+
// shell: true is the whole point of this branch — DO NOT remove it.
|
|
117
|
+
return spawnImpl(line, [], { ...safeOpts, shell: true });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// POSIX / non-.cmd Windows: direct exec. Faster, cleaner, no shell quoting
|
|
121
|
+
// surprises.
|
|
122
|
+
return spawnImpl(command, safeArgs, safeOpts);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Conservative shell quoting for the Windows .cmd shell-mode branch. We're
|
|
127
|
+
* targeting cmd.exe (not bash), so the rules are different from POSIX:
|
|
128
|
+
*
|
|
129
|
+
* - Empty arg → `""`
|
|
130
|
+
* - Arg with no whitespace and no shell metachars → leave untouched
|
|
131
|
+
* - Otherwise → wrap in `"..."` with embedded `"` doubled to `""`
|
|
132
|
+
*
|
|
133
|
+
* cmd.exe does not interpret backslashes the way POSIX shells do, so we
|
|
134
|
+
* leave them alone. Forward slashes pass through unchanged.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} arg
|
|
137
|
+
* @returns {string}
|
|
138
|
+
*/
|
|
139
|
+
function quoteShellArg(arg) {
|
|
140
|
+
const s = String(arg);
|
|
141
|
+
if (s.length === 0) return '""';
|
|
142
|
+
// Bail to quoted form if anything that cmd.exe parses specially is present.
|
|
143
|
+
if (/[\s"&|<>^()%!]/.test(s)) {
|
|
144
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
145
|
+
}
|
|
146
|
+
return s;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { spawnCmd, quoteShellArg };
|
|
@@ -58,7 +58,7 @@ const ENV_TO_RUNTIME = Object.freeze(
|
|
|
58
58
|
* when no recognized env-var is set in the current environment.
|
|
59
59
|
*
|
|
60
60
|
* @example
|
|
61
|
-
* process.env.CLAUDE_CONFIG_DIR = '
|
|
61
|
+
* process.env.CLAUDE_CONFIG_DIR = process.env.HOME + '/.claude';
|
|
62
62
|
* detect(); // → 'claude'
|
|
63
63
|
*
|
|
64
64
|
* @example
|