@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
@@ -50,6 +50,8 @@ const RUNTIMES = Object.freeze([
50
50
  configDirFallback: '.gemini',
51
51
  kind: 'agents-md',
52
52
  files: ['GEMINI.md'],
53
+ // Phase 27 (Plan 27-11): peer-CLI delegation binary, ACP protocol.
54
+ peerBinary: process.platform === 'win32' ? 'gemini.cmd' : 'gemini',
53
55
  },
54
56
  {
55
57
  id: 'kilo',
@@ -66,6 +68,8 @@ const RUNTIMES = Object.freeze([
66
68
  configDirFallback: '.codex',
67
69
  kind: 'agents-md',
68
70
  files: ['AGENTS.md'],
71
+ // Phase 27 (Plan 27-11): peer-CLI delegation binary, ASP protocol.
72
+ peerBinary: process.platform === 'win32' ? 'codex.cmd' : 'codex',
69
73
  },
70
74
  {
71
75
  id: 'copilot',
@@ -74,6 +78,8 @@ const RUNTIMES = Object.freeze([
74
78
  configDirFallback: '.copilot',
75
79
  kind: 'agents-md',
76
80
  files: ['AGENTS.md'],
81
+ // Phase 27 (Plan 27-11): peer-CLI delegation binary, ACP protocol.
82
+ peerBinary: process.platform === 'win32' ? 'copilot.cmd' : 'copilot',
77
83
  },
78
84
  {
79
85
  id: 'cursor',
@@ -82,6 +88,8 @@ const RUNTIMES = Object.freeze([
82
88
  configDirFallback: '.cursor',
83
89
  kind: 'agents-md',
84
90
  files: ['AGENTS.md'],
91
+ // Phase 27 (Plan 27-11): peer-CLI delegation binary, ACP protocol.
92
+ peerBinary: process.platform === 'win32' ? 'cursor-agent.cmd' : 'cursor-agent',
85
93
  },
86
94
  {
87
95
  id: 'windsurf',
@@ -122,6 +130,8 @@ const RUNTIMES = Object.freeze([
122
130
  configDirFallback: '.qwen',
123
131
  kind: 'agents-md',
124
132
  files: ['AGENTS.md'],
133
+ // Phase 27 (Plan 27-11): peer-CLI delegation binary, ACP protocol.
134
+ peerBinary: process.platform === 'win32' ? 'qwen.cmd' : 'qwen',
125
135
  },
126
136
  {
127
137
  id: 'codebuddy',
@@ -202,6 +212,52 @@ function _resetRuntimeModelsCache() {
202
212
  _modelsCache.clear();
203
213
  }
204
214
 
215
+ // Phase 27 (Plan 27-11) — peer-CLI detection helpers.
216
+ //
217
+ // `listPeerCapableRuntimes()` returns the entries that carry a `peerBinary`
218
+ // field — the 5 runtimes that gdd can DELEGATE to (codex, gemini, cursor,
219
+ // copilot, qwen). The other 9 runtimes (claude, opencode, kilo, windsurf,
220
+ // antigravity, augment, trae, codebuddy, cline) are install targets only.
221
+ //
222
+ // `detectInstalledPeers({ which? })` checks each peer-capable runtime's
223
+ // `peerBinary` against the system PATH and returns the IDs of the peers
224
+ // that are installed locally. The `which` parameter is injectable for
225
+ // tests — the production caller passes a real `which`/`where` shim.
226
+
227
+ function listPeerCapableRuntimes() {
228
+ return RUNTIMES.filter((r) => typeof r.peerBinary === 'string');
229
+ }
230
+
231
+ function detectInstalledPeers(opts) {
232
+ const opts2 = opts || {};
233
+ const whichFn = opts2.which || _defaultWhich;
234
+ const detected = [];
235
+ for (const r of listPeerCapableRuntimes()) {
236
+ try {
237
+ if (whichFn(r.peerBinary)) {
238
+ detected.push(r.id);
239
+ }
240
+ } catch (_e) {
241
+ // ENOENT / non-zero exit = not installed; never throw.
242
+ }
243
+ }
244
+ return detected;
245
+ }
246
+
247
+ function _defaultWhich(binary) {
248
+ const { execSync } = require('node:child_process');
249
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
250
+ try {
251
+ const out = execSync(`${cmd} ${binary}`, {
252
+ stdio: ['ignore', 'pipe', 'ignore'],
253
+ encoding: 'utf8',
254
+ }).trim();
255
+ return out.length > 0 ? out.split(/\r?\n/)[0] : null;
256
+ } catch (_e) {
257
+ return null;
258
+ }
259
+ }
260
+
205
261
  module.exports = {
206
262
  RUNTIMES,
207
263
  REPO,
@@ -211,5 +267,7 @@ module.exports = {
211
267
  listRuntimes,
212
268
  listRuntimeIds,
213
269
  getRuntimeModels,
270
+ listPeerCapableRuntimes,
271
+ detectInstalledPeers,
214
272
  _resetRuntimeModelsCache,
215
273
  };
@@ -0,0 +1,375 @@
1
+ // scripts/lib/peer-cli/acp-client.cjs
2
+ //
3
+ // Plan 27-01 — Agent Client Protocol (ACP) client.
4
+ //
5
+ // Protocol shape adapted from greenpolo/cc-multi-cli `acp-client.mjs`
6
+ // (Apache 2.0). See NOTICE for full attribution (added by Plan 27-12).
7
+ //
8
+ // ACP is the line-delimited JSON-RPC 2.0 transport spoken by the Gemini,
9
+ // Cursor, Copilot, and Qwen CLIs when launched in their `acp` mode. We
10
+ // drive a child process over stdio, send framed JSON requests on stdin,
11
+ // and read framed JSON responses + notifications off stdout. Every
12
+ // outbound request gets a numeric `id`; the server's reply is correlated
13
+ // back via that id. Server-pushed notifications (`agent_message_chunk`,
14
+ // `tool_call`, `file_change`, etc.) lack an id and are surfaced through
15
+ // the `onNotification` callback supplied to `prompt()`.
16
+ //
17
+ // Wire framing:
18
+ // send: `<json>\n` over stdin.
19
+ // recv: `<json>\n` over stdout, line-delimited.
20
+ //
21
+ // The framing layer must handle three real-world conditions:
22
+ // (a) multiple complete JSON messages arriving in one stdout chunk,
23
+ // (b) a single JSON message split across multiple stdout chunks, and
24
+ // (c) misbehaved peer never emitting a newline — we cap the buffered
25
+ // line length at 16 MiB and reject the active prompt rather than
26
+ // grow memory unbounded.
27
+ //
28
+ // Module exports:
29
+ // createAcpClient({command, args, cwd, env}) -> AcpClient
30
+ //
31
+ // AcpClient:
32
+ // initialize(params) -> Promise<result>
33
+ // First call after spawn. Sends the JSON-RPC `initialize` request
34
+ // with the negotiated `protocolVersion` + `clientCapabilities` and
35
+ // resolves with the server's capability reply.
36
+ //
37
+ // prompt(text, opts) -> Promise<result>
38
+ // Sends a `prompt` request. Notifications received between request
39
+ // send and response receive are forwarded to opts.onNotification(n).
40
+ // Resolves with the final `result` payload tied to the request id.
41
+ //
42
+ // close() -> Promise<void>
43
+ // Sends SIGTERM to the child, waits for exit, drains any in-flight
44
+ // promises with a "client closed" rejection.
45
+ //
46
+ // This module has no external dependencies — only Node built-ins
47
+ // (`child_process`, `events`).
48
+
49
+ 'use strict';
50
+
51
+ const { spawn } = require('child_process');
52
+ const { EventEmitter } = require('events');
53
+
54
+ /**
55
+ * Hard cap on the size of a single un-terminated line read from the
56
+ * peer's stdout. If the peer streams more than this without a `\n`, we
57
+ * treat it as a protocol violation and reject all pending requests.
58
+ * 16 MiB is well above any legitimate ACP payload (largest observed in
59
+ * cc-multi-cli traces is ~2 MiB for tool-call results with embedded
60
+ * file diffs) but small enough that a runaway peer can't OOM the host.
61
+ */
62
+ const MAX_LINE_BYTES = 16 * 1024 * 1024;
63
+
64
+ /**
65
+ * Default ACP protocol version we negotiate with. Callers can override
66
+ * via `initialize({ protocolVersion: '...' })`. The current Gemini /
67
+ * Cursor / Copilot / Qwen CLIs all advertise `2025-06-18` as of writing.
68
+ */
69
+ const DEFAULT_PROTOCOL_VERSION = '2025-06-18';
70
+
71
+ /**
72
+ * Create an ACP client wrapping a freshly spawned peer-CLI process.
73
+ *
74
+ * @param {object} opts
75
+ * @param {string} opts.command Absolute path (or PATH-resolvable name)
76
+ * to the peer-CLI binary. The caller is responsible for resolving
77
+ * peer-binary location (Plan 27-11 ships `peerBinary` on runtimes.cjs
78
+ * for that). On Windows, `.cmd` shims need the spawn-cmd.cjs
79
+ * workaround (Plan 27-03) — this module does NOT apply that shim
80
+ * itself, callers must wrap.
81
+ * @param {string[]} [opts.args=[]] Extra args to the peer binary;
82
+ * typical value is `['acp']` to launch the peer in ACP mode.
83
+ * @param {string} [opts.cwd=process.cwd()] Working directory for the
84
+ * child process.
85
+ * @param {Record<string,string>} [opts.env] Environment overrides.
86
+ * Defaults to inheriting from process.env.
87
+ * @returns {{
88
+ * initialize: (params: object) => Promise<unknown>,
89
+ * prompt: (text: string, opts?: object) => Promise<unknown>,
90
+ * close: () => Promise<void>,
91
+ * on: (event: string, listener: Function) => void,
92
+ * pid: number | undefined,
93
+ * }}
94
+ */
95
+ function createAcpClient(opts) {
96
+ if (!opts || typeof opts.command !== 'string' || opts.command.length === 0) {
97
+ throw new TypeError('createAcpClient: opts.command (string) is required');
98
+ }
99
+ const command = opts.command;
100
+ const args = Array.isArray(opts.args) ? opts.args : [];
101
+ const cwd = typeof opts.cwd === 'string' ? opts.cwd : process.cwd();
102
+ const env = opts.env && typeof opts.env === 'object' ? opts.env : process.env;
103
+
104
+ const events = new EventEmitter();
105
+
106
+ // Spawn the child. We use plain `spawn` (no shell) — Windows `.cmd`
107
+ // dispatch is the caller's responsibility (Plan 27-03 spawn-cmd.cjs).
108
+ const child = spawn(command, args, {
109
+ cwd,
110
+ env,
111
+ stdio: ['pipe', 'pipe', 'pipe'],
112
+ windowsHide: true,
113
+ });
114
+
115
+ // Per-request correlation. Server replies carry the id we sent; we
116
+ // resolve/reject the matching pending entry. Notifications (no id)
117
+ // bypass this map and route to the active prompt's onNotification.
118
+ let nextRequestId = 1;
119
+ /** @type {Map<number, {resolve: Function, reject: Function, onNotification?: Function}>} */
120
+ const pending = new Map();
121
+
122
+ // The id of the request whose notifications we currently surface.
123
+ // ACP is half-duplex per stream — only one prompt is in flight at
124
+ // once from the host's perspective — so we track it as a single ref.
125
+ let activeNotificationTargetId = null;
126
+
127
+ // Closure / error state.
128
+ let closed = false;
129
+ /** @type {Error | null} */
130
+ let fatalError = null;
131
+
132
+ // Line-buffer state. Stdout chunks accumulate here; each `\n` flushes
133
+ // a complete JSON message into handleMessage().
134
+ let lineBuffer = '';
135
+
136
+ child.stdout.setEncoding('utf8');
137
+ child.stdout.on('data', onStdoutData);
138
+ child.stderr.setEncoding('utf8');
139
+ child.stderr.on('data', (chunk) => {
140
+ // Surface stderr verbatim so callers can wire it into Phase 22
141
+ // event chain or simple debug logging without us imposing a
142
+ // structure on it.
143
+ events.emit('stderr', chunk);
144
+ });
145
+ child.on('error', (err) => {
146
+ fail(err);
147
+ });
148
+ child.on('exit', (code, signal) => {
149
+ events.emit('exit', { code, signal });
150
+ if (!closed) {
151
+ // Unexpected exit → fail every in-flight request.
152
+ fail(new Error(`ACP peer exited unexpectedly (code=${code}, signal=${signal})`));
153
+ }
154
+ });
155
+
156
+ /**
157
+ * Append a stdout chunk to the line buffer and flush every complete
158
+ * line. A "line" is everything up to (but not including) a `\n`.
159
+ * Lines longer than MAX_LINE_BYTES indicate a malformed peer stream
160
+ * — we tear the client down with an error so callers don't OOM.
161
+ */
162
+ function onStdoutData(chunk) {
163
+ if (fatalError) return;
164
+ lineBuffer += chunk;
165
+
166
+ if (lineBuffer.length > MAX_LINE_BYTES) {
167
+ // We measured length in code units (UTF-16) but the cap is in
168
+ // bytes; this is an over-eager check (UTF-16 length <= UTF-8
169
+ // byte count is not always true, but for our payloads of mostly
170
+ // ASCII JSON the two are within a small constant). The intent
171
+ // is "no peer should ever emit this much without a newline" —
172
+ // exact byte accounting isn't worth the Buffer churn here.
173
+ fail(new Error(
174
+ `ACP peer emitted ${lineBuffer.length} bytes without a newline ` +
175
+ `(cap: ${MAX_LINE_BYTES} bytes) — protocol violation`,
176
+ ));
177
+ return;
178
+ }
179
+
180
+ let newlineIdx;
181
+ while ((newlineIdx = lineBuffer.indexOf('\n')) !== -1) {
182
+ const line = lineBuffer.slice(0, newlineIdx);
183
+ lineBuffer = lineBuffer.slice(newlineIdx + 1);
184
+ if (line.length === 0) continue; // tolerate keep-alive blank lines
185
+ handleLine(line);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Parse one JSON-RPC frame and dispatch it to either the response
191
+ * correlation table (if it has an `id` matching a pending request)
192
+ * or the active notification sink.
193
+ */
194
+ function handleLine(line) {
195
+ let msg;
196
+ try {
197
+ msg = JSON.parse(line);
198
+ } catch (err) {
199
+ // Malformed JSON from the peer is a protocol violation but not
200
+ // necessarily fatal — the peer may recover on the next line.
201
+ // Surface it so callers can log, but keep going.
202
+ events.emit('parse_error', { line, error: err });
203
+ return;
204
+ }
205
+
206
+ // Standard JSON-RPC 2.0 dispatch:
207
+ // - response : has `id` AND (`result` OR `error`)
208
+ // - notification : has `method` AND no `id`
209
+ // - request from server (e.g. tool_call back-channel) : has `id`
210
+ // AND `method`. ACP peers can issue these; for v1 we treat
211
+ // them as notifications (callers handle via onNotification).
212
+ //
213
+ // We dispatch in priority: response first (id present + no method),
214
+ // then notifications (method present).
215
+ const hasId = Object.prototype.hasOwnProperty.call(msg, 'id') && msg.id !== null;
216
+ const hasMethod = typeof msg.method === 'string';
217
+
218
+ if (hasId && !hasMethod) {
219
+ const entry = pending.get(msg.id);
220
+ if (!entry) {
221
+ // Unknown id — peer replied to a request we didn't send (or
222
+ // we already timed it out). Surface for diagnostic but ignore.
223
+ events.emit('orphan_response', msg);
224
+ return;
225
+ }
226
+ pending.delete(msg.id);
227
+ if (msg.id === activeNotificationTargetId) {
228
+ activeNotificationTargetId = null;
229
+ }
230
+ if (msg.error) {
231
+ const err = new Error(
232
+ (msg.error && msg.error.message) || 'ACP peer returned error',
233
+ );
234
+ // Preserve the JSON-RPC error envelope so callers can inspect
235
+ // .code and .data without losing typing.
236
+ err.code = msg.error.code;
237
+ err.data = msg.error.data;
238
+ entry.reject(err);
239
+ } else {
240
+ entry.resolve(msg.result);
241
+ }
242
+ return;
243
+ }
244
+
245
+ if (hasMethod) {
246
+ // Notification (or server-issued request — same handling for v1).
247
+ if (activeNotificationTargetId !== null) {
248
+ const entry = pending.get(activeNotificationTargetId);
249
+ if (entry && typeof entry.onNotification === 'function') {
250
+ try {
251
+ entry.onNotification(msg);
252
+ } catch (err) {
253
+ // Caller's notification handler threw — surface but don't
254
+ // tear down the protocol stream.
255
+ events.emit('notification_handler_error', { error: err, notification: msg });
256
+ }
257
+ }
258
+ }
259
+ // Always emit on the EventEmitter too, so callers without an
260
+ // active prompt (e.g. health monitor) can still observe.
261
+ events.emit('notification', msg);
262
+ return;
263
+ }
264
+
265
+ // Neither id+result nor method — malformed. Surface for diagnostic.
266
+ events.emit('protocol_violation', msg);
267
+ }
268
+
269
+ /**
270
+ * Send a JSON-RPC request and return a Promise resolving to its
271
+ * `result`. Caller-supplied `onNotification` is fired for every
272
+ * notification received between send and reply.
273
+ */
274
+ function sendRequest(method, params, onNotification) {
275
+ if (closed) {
276
+ return Promise.reject(new Error('ACP client is closed'));
277
+ }
278
+ if (fatalError) {
279
+ return Promise.reject(fatalError);
280
+ }
281
+ const id = nextRequestId++;
282
+ const frame = JSON.stringify({ jsonrpc: '2.0', id, method, params });
283
+ return new Promise((resolve, reject) => {
284
+ pending.set(id, { resolve, reject, onNotification });
285
+ activeNotificationTargetId = id;
286
+ const ok = child.stdin.write(frame + '\n', 'utf8', (err) => {
287
+ if (err) {
288
+ pending.delete(id);
289
+ if (activeNotificationTargetId === id) activeNotificationTargetId = null;
290
+ reject(err);
291
+ }
292
+ });
293
+ // Backpressure: `write` returning false signals the kernel buffer
294
+ // is full. For a JSON-RPC line of typical size (<10 KiB) this is
295
+ // exceedingly rare; we don't block on `drain` but trust Node's
296
+ // internal queue. If a pathological peer stalls reads, the
297
+ // kernel buffer fills and write throws via the callback above.
298
+ void ok;
299
+ });
300
+ }
301
+
302
+ /**
303
+ * Tear down all in-flight promises with the given error. Safe to
304
+ * call multiple times — subsequent calls are no-ops.
305
+ */
306
+ function fail(err) {
307
+ if (fatalError) return;
308
+ fatalError = err;
309
+ for (const [, entry] of pending) {
310
+ try { entry.reject(err); } catch { /* ignore */ }
311
+ }
312
+ pending.clear();
313
+ activeNotificationTargetId = null;
314
+ }
315
+
316
+ /** ACP `initialize` — first call after spawn. */
317
+ function initialize(params) {
318
+ const merged = Object.assign(
319
+ { protocolVersion: DEFAULT_PROTOCOL_VERSION, clientCapabilities: {} },
320
+ params || {},
321
+ );
322
+ return sendRequest('initialize', merged);
323
+ }
324
+
325
+ /** ACP `prompt` — primary turn-driver. */
326
+ function prompt(text, promptOpts) {
327
+ const params = Object.assign({ text }, (promptOpts && promptOpts.params) || {});
328
+ const onNotification = promptOpts && typeof promptOpts.onNotification === 'function'
329
+ ? promptOpts.onNotification
330
+ : undefined;
331
+ return sendRequest('prompt', params, onNotification);
332
+ }
333
+
334
+ /** Gracefully terminate the peer; resolves once the child exits. */
335
+ function close() {
336
+ if (closed) return Promise.resolve();
337
+ closed = true;
338
+ return new Promise((resolve) => {
339
+ const onExit = () => {
340
+ fail(new Error('ACP client is closed'));
341
+ resolve();
342
+ };
343
+ if (child.exitCode !== null || child.signalCode !== null) {
344
+ // Already exited.
345
+ onExit();
346
+ return;
347
+ }
348
+ child.once('exit', onExit);
349
+ try {
350
+ child.stdin.end();
351
+ } catch { /* ignore */ }
352
+ // Give the peer a moment to exit on its own after stdin EOF.
353
+ // 500ms is well above ACP cleanup time observed in cc-multi-cli.
354
+ setTimeout(() => {
355
+ if (child.exitCode === null && child.signalCode === null) {
356
+ try { child.kill('SIGTERM'); } catch { /* ignore */ }
357
+ }
358
+ }, 500);
359
+ });
360
+ }
361
+
362
+ return {
363
+ initialize,
364
+ prompt,
365
+ close,
366
+ on: events.on.bind(events),
367
+ get pid() { return child.pid; },
368
+ };
369
+ }
370
+
371
+ module.exports = {
372
+ createAcpClient,
373
+ MAX_LINE_BYTES,
374
+ DEFAULT_PROTOCOL_VERSION,
375
+ };
@@ -0,0 +1,101 @@
1
+ // scripts/lib/peer-cli/adapters/codex.cjs
2
+ //
3
+ // Plan 27-04 — Per-peer adapter for the Codex CLI.
4
+ //
5
+ // Codex is the only peer in our matrix that speaks ASP (App Server
6
+ // Protocol) rather than ACP. The ASP transport is thread-oriented:
7
+ // every turn lives inside a `threadId` that we obtain via `threadStart`
8
+ // before driving the conversation with `turn(threadId, text)`. See
9
+ // scripts/lib/peer-cli/asp-client.cjs for the protocol details.
10
+ //
11
+ // Capability matrix (CONTEXT.md D-05):
12
+ // * Codex claims the `execute` role only.
13
+ //
14
+ // The slash-command translation layer is wafer-thin: when the registry
15
+ // dispatches role=`execute`, we prepend `/execute ` to the user's text
16
+ // so Codex routes the prompt through its execute pipeline. Roles the
17
+ // peer doesn't claim are rejected at `dispatch()` time — the registry
18
+ // (Plan 27-05) uses `claims(role)` to filter before reaching us, so
19
+ // hitting this branch indicates a registry bug or a misconfigured
20
+ // `delegate_to:` frontmatter.
21
+
22
+ 'use strict';
23
+
24
+ const { createAspClient } = require('../asp-client.cjs');
25
+
26
+ /** Roles this peer claims. Roles outside this set are refused. */
27
+ const ROLES_CLAIMED = Object.freeze(['execute']);
28
+
29
+ /**
30
+ * Per-role prompt prefix. Codex understands `/execute` natively, so the
31
+ * prefix doubles as both a role marker and a Codex slash command.
32
+ */
33
+ const ROLE_PREFIX = Object.freeze({
34
+ execute: '/execute ',
35
+ });
36
+
37
+ /**
38
+ * Cheap predicate the registry consults to decide whether to dispatch
39
+ * a role to this peer. Pure / synchronous on purpose.
40
+ */
41
+ function claims(role) {
42
+ return ROLES_CLAIMED.includes(role);
43
+ }
44
+
45
+ /**
46
+ * Drive a single Codex turn for the supplied role + text.
47
+ *
48
+ * @param {{command: string, args?: string[], cwd?: string, env?: object}} peer
49
+ * Spawn descriptor for the Codex binary. The registry resolves these
50
+ * from runtimes.cjs (Plan 27-11 ships `peerBinary`).
51
+ * @param {string} role Must satisfy `claims(role)`.
52
+ * @param {string} text User-supplied prompt; we prepend ROLE_PREFIX.
53
+ * @param {{onNotification?: (n: object) => void, threadId?: string}} [opts]
54
+ * `onNotification` is forwarded to the ASP turn for streaming
55
+ * visibility. `threadId` lets the broker (Plan 27-03) resume a thread
56
+ * instead of starting a fresh one — v1.27.0 always passes undefined.
57
+ * @returns {Promise<object>} The ASP `turn()` result envelope —
58
+ * `{status, content, usage, threadId, turnId, notifications}` — passed
59
+ * through unchanged so the caller can branch on `status === 'error'`.
60
+ */
61
+ async function dispatch(peer, role, text, opts) {
62
+ if (!claims(role)) {
63
+ throw new Error(`codex adapter does not claim role: ${role}`);
64
+ }
65
+ if (typeof text !== 'string') {
66
+ throw new TypeError('codex adapter: text must be a string');
67
+ }
68
+ const onNotification = opts && typeof opts.onNotification === 'function'
69
+ ? opts.onNotification
70
+ : undefined;
71
+ const explicitThreadId = opts && typeof opts.threadId === 'string' && opts.threadId.length > 0
72
+ ? opts.threadId
73
+ : null;
74
+
75
+ const client = createAspClient({
76
+ command: peer.command,
77
+ args: peer.args,
78
+ cwd: peer.cwd,
79
+ env: peer.env,
80
+ });
81
+ try {
82
+ let threadId = explicitThreadId;
83
+ if (threadId === null) {
84
+ const started = await client.threadStart({ service_name: 'gdd_peer_delegation' });
85
+ threadId = started.threadId;
86
+ }
87
+ const prompt = ROLE_PREFIX[role] + text;
88
+ return await client.turn(threadId, prompt, { onNotification });
89
+ } finally {
90
+ await client.close();
91
+ }
92
+ }
93
+
94
+ module.exports = {
95
+ name: 'codex',
96
+ protocol: 'asp',
97
+ ROLES_CLAIMED,
98
+ ROLE_PREFIX,
99
+ claims,
100
+ dispatch,
101
+ };
@@ -0,0 +1,79 @@
1
+ // scripts/lib/peer-cli/adapters/copilot.cjs
2
+ //
3
+ // Plan 27-04 — Per-peer adapter for the GitHub Copilot CLI.
4
+ //
5
+ // Copilot speaks ACP (line-delimited JSON-RPC over stdio); see
6
+ // scripts/lib/peer-cli/acp-client.cjs.
7
+ //
8
+ // Capability matrix (CONTEXT.md D-05):
9
+ // * Copilot claims the `review` and `research` roles.
10
+ //
11
+ // Note: `research` overlaps with Gemini. The registry (Plan 27-05)
12
+ // arbitrates by per-peer health + posterior win-rate; this adapter
13
+ // just declares membership in both role pools.
14
+ //
15
+ // Copilot exposes `/review` and `/research` as slash commands.
16
+
17
+ 'use strict';
18
+
19
+ const { createAcpClient } = require('../acp-client.cjs');
20
+
21
+ /** Roles this peer claims. */
22
+ const ROLES_CLAIMED = Object.freeze(['review', 'research']);
23
+
24
+ /**
25
+ * Per-role slash-command prefix. Copilot recognizes `/review` and
26
+ * `/research` natively at the start of a prompt.
27
+ */
28
+ const ROLE_PREFIX = Object.freeze({
29
+ review: '/review ',
30
+ research: '/research ',
31
+ });
32
+
33
+ function claims(role) {
34
+ return ROLES_CLAIMED.includes(role);
35
+ }
36
+
37
+ /**
38
+ * Drive one Copilot ACP `prompt` for the supplied role + text.
39
+ *
40
+ * @param {{command: string, args?: string[], cwd?: string, env?: object}} peer
41
+ * @param {string} role
42
+ * @param {string} text
43
+ * @param {{onNotification?: (n: object) => void}} [opts]
44
+ * @returns {Promise<object>}
45
+ */
46
+ async function dispatch(peer, role, text, opts) {
47
+ if (!claims(role)) {
48
+ throw new Error(`copilot adapter does not claim role: ${role}`);
49
+ }
50
+ if (typeof text !== 'string') {
51
+ throw new TypeError('copilot adapter: text must be a string');
52
+ }
53
+ const onNotification = opts && typeof opts.onNotification === 'function'
54
+ ? opts.onNotification
55
+ : undefined;
56
+
57
+ const client = createAcpClient({
58
+ command: peer.command,
59
+ args: peer.args,
60
+ cwd: peer.cwd,
61
+ env: peer.env,
62
+ });
63
+ try {
64
+ await client.initialize({ protocolVersion: '2025-06-18' });
65
+ const prompt = ROLE_PREFIX[role] + text;
66
+ return await client.prompt(prompt, { onNotification });
67
+ } finally {
68
+ await client.close();
69
+ }
70
+ }
71
+
72
+ module.exports = {
73
+ name: 'copilot',
74
+ protocol: 'acp',
75
+ ROLES_CLAIMED,
76
+ ROLE_PREFIX,
77
+ claims,
78
+ dispatch,
79
+ };