@hegemonart/get-design-done 1.26.0 → 1.27.0

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.
Files changed (33) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +50 -0
  4. package/README.md +10 -8
  5. package/SKILL.md +3 -0
  6. package/agents/README.md +29 -0
  7. package/package.json +1 -1
  8. package/reference/peer-cli-capabilities.md +151 -0
  9. package/reference/peer-protocols.md +266 -0
  10. package/reference/registry.json +14 -0
  11. package/reference/runtime-models.md +3 -3
  12. package/scripts/lib/bandit-router.cjs +214 -7
  13. package/scripts/lib/budget-enforcer.cjs +69 -1
  14. package/scripts/lib/event-stream/index.ts +14 -1
  15. package/scripts/lib/event-stream/types.ts +125 -1
  16. package/scripts/lib/install/runtimes.cjs +58 -0
  17. package/scripts/lib/peer-cli/acp-client.cjs +375 -0
  18. package/scripts/lib/peer-cli/adapters/codex.cjs +101 -0
  19. package/scripts/lib/peer-cli/adapters/copilot.cjs +79 -0
  20. package/scripts/lib/peer-cli/adapters/cursor.cjs +78 -0
  21. package/scripts/lib/peer-cli/adapters/gemini.cjs +81 -0
  22. package/scripts/lib/peer-cli/adapters/qwen.cjs +72 -0
  23. package/scripts/lib/peer-cli/asp-client.cjs +587 -0
  24. package/scripts/lib/peer-cli/broker-lifecycle.cjs +406 -0
  25. package/scripts/lib/peer-cli/registry.cjs +434 -0
  26. package/scripts/lib/peer-cli/spawn-cmd.cjs +149 -0
  27. package/scripts/lib/runtime-detect.cjs +1 -1
  28. package/scripts/lib/session-runner/index.ts +215 -0
  29. package/scripts/lib/session-runner/types.ts +60 -0
  30. package/scripts/validate-frontmatter.ts +159 -1
  31. package/skills/peer-cli-add/SKILL.md +170 -0
  32. package/skills/peer-cli-customize/SKILL.md +110 -0
  33. 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:
25
+ // spawn('C:\\Users\\me\\AppData\\Local\\codex.cmd', ['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 = '/Users/me/.claude';
61
+ * process.env.CLAUDE_CONFIG_DIR = process.env.HOME + '/.claude';
62
62
  * detect(); // → 'claude'
63
63
  *
64
64
  * @example