@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.
Files changed (34) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +74 -0
  4. package/README.md +10 -8
  5. package/SKILL.md +3 -0
  6. package/agents/README.md +29 -0
  7. package/package.json +2 -2
  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/install.cjs +100 -1
  13. package/scripts/lib/bandit-router.cjs +214 -7
  14. package/scripts/lib/budget-enforcer.cjs +69 -1
  15. package/scripts/lib/event-stream/index.ts +14 -1
  16. package/scripts/lib/event-stream/types.ts +125 -1
  17. package/scripts/lib/install/runtimes.cjs +58 -0
  18. package/scripts/lib/peer-cli/acp-client.cjs +375 -0
  19. package/scripts/lib/peer-cli/adapters/codex.cjs +101 -0
  20. package/scripts/lib/peer-cli/adapters/copilot.cjs +79 -0
  21. package/scripts/lib/peer-cli/adapters/cursor.cjs +78 -0
  22. package/scripts/lib/peer-cli/adapters/gemini.cjs +81 -0
  23. package/scripts/lib/peer-cli/adapters/qwen.cjs +72 -0
  24. package/scripts/lib/peer-cli/asp-client.cjs +587 -0
  25. package/scripts/lib/peer-cli/broker-lifecycle.cjs +406 -0
  26. package/scripts/lib/peer-cli/registry.cjs +434 -0
  27. package/scripts/lib/peer-cli/spawn-cmd.cjs +149 -0
  28. package/scripts/lib/runtime-detect.cjs +1 -1
  29. package/scripts/lib/session-runner/index.ts +362 -0
  30. package/scripts/lib/session-runner/types.ts +60 -0
  31. package/scripts/validate-frontmatter.ts +159 -1
  32. package/skills/peer-cli-add/SKILL.md +170 -0
  33. package/skills/peer-cli-customize/SKILL.md +110 -0
  34. package/skills/peers/SKILL.md +101 -0
@@ -0,0 +1,406 @@
1
+ // scripts/lib/peer-cli/broker-lifecycle.cjs
2
+ //
3
+ // Plan 27-03 — long-lived broker session per (peer, workspace).
4
+ //
5
+ // ============================================================================
6
+ // WHY A BROKER, NOT PER-CALL SPAWN — Phase 27 D-03
7
+ // ============================================================================
8
+ //
9
+ // Cold-spawning an ACP/ASP peer-CLI session for every delegated agent call
10
+ // re-runs the JSON-RPC `initialize` handshake every time. On a real cycle
11
+ // with N delegated calls (gemini-research, codex-execute, cursor-debug, ...),
12
+ // that's N handshakes × ~200-800ms each = a measurable latency tax that
13
+ // erases most of the cost arbitrage win.
14
+ //
15
+ // Instead, we keep ONE long-lived peer-CLI process per `(peer, workspace)`
16
+ // tuple — the "broker" — and route delegated calls to it via:
17
+ //
18
+ // - POSIX: Unix domain socket at
19
+ // `~/.gdd/peer-brokers/<peer>-<workspace-hash>.sock`
20
+ // - Windows: named pipe at
21
+ // `\\.\pipe\gdd-peer-broker-<peer>-<workspace-hash>`
22
+ //
23
+ // Brokers live BETWEEN gdd cycles. Closing this client's connection does
24
+ // NOT shut down the broker — multiple cycles re-attach to the same broker
25
+ // process. The broker is reaped by an external lifecycle manager (TBD in
26
+ // Plan 27-06 integration) or by an idle-timeout the broker itself enforces.
27
+ //
28
+ // This module is the CLIENT-SIDE surface only. It connects, sends JSON-RPC
29
+ // frames, awaits replies, and disconnects. It does NOT implement the broker
30
+ // server itself — that's a separate concern (likely a small persistent
31
+ // process started by the registry on first dispatch). For tests we mock
32
+ // the underlying transport entirely; real broker spawn/reap is exercised
33
+ // in Plan 27-06's integration harness.
34
+ //
35
+ // ============================================================================
36
+ // CONTRACT
37
+ // ============================================================================
38
+ //
39
+ // const broker = createBroker({
40
+ // peer: 'gemini', // peer ID; matches adapters/<peer>.cjs
41
+ // workspace: '/repo', // absolute repo path
42
+ // transport: 'acp', // 'acp' or 'asp'
43
+ // });
44
+ // await broker.connect();
45
+ // broker.send({ id: 1, method: 'initialize', params: {...} });
46
+ // const reply = await broker.receive(5000); // ms timeout
47
+ // await broker.close();
48
+ //
49
+ // Properties:
50
+ // - send() is non-blocking; replies arrive via receive()
51
+ // - receive() resolves with the next pending reply OR rejects on timeout
52
+ // - send() throws BrokerBusyError when the in-flight queue exceeds
53
+ // MAX_PENDING (D-03 backpressure: 100)
54
+ // - close() releases this client's transport handle but does NOT signal
55
+ // the broker process to terminate
56
+ //
57
+ // ============================================================================
58
+ // BACKPRESSURE
59
+ // ============================================================================
60
+ //
61
+ // Each client tracks its own pending-request queue. If the broker is slow
62
+ // (e.g., a long-running peer LLM call) and the caller fires sends faster
63
+ // than the broker drains, send() throws `BrokerBusyError` once the queue
64
+ // hits 100 in-flight. This is intentional: a stuck broker should surface
65
+ // as "your peer is wedged" rather than silently inflating memory until the
66
+ // process OOMs. The session-runner (Plan 27-06) catches BrokerBusyError
67
+ // and falls back to local Anthropic SDK on this dispatch — same fallback
68
+ // path as peer-absent / peer-error per D-07.
69
+
70
+ 'use strict';
71
+
72
+ const crypto = require('node:crypto');
73
+ const net = require('node:net');
74
+ const path = require('node:path');
75
+ const os = require('node:os');
76
+
77
+ const MAX_PENDING = 100;
78
+ const DEFAULT_RECEIVE_TIMEOUT_MS = 30_000;
79
+
80
+ /**
81
+ * Thrown by `send()` when this client's pending-request queue is at the
82
+ * `MAX_PENDING` ceiling. Session-runner catches this and falls back.
83
+ */
84
+ class BrokerBusyError extends Error {
85
+ constructor(message) {
86
+ super(message);
87
+ this.name = 'BrokerBusyError';
88
+ this.code = 'EBROKERBUSY';
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Thrown by `receive()` when no reply arrives within the timeout window.
94
+ */
95
+ class BrokerTimeoutError extends Error {
96
+ constructor(message) {
97
+ super(message);
98
+ this.name = 'BrokerTimeoutError';
99
+ this.code = 'EBROKERTIMEOUT';
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Compute the platform-appropriate broker endpoint for a (peer, workspace).
105
+ *
106
+ * POSIX → `~/.gdd/peer-brokers/<peer>-<hash>.sock`
107
+ * Windows → `\\.\pipe\gdd-peer-broker-<peer>-<hash>`
108
+ *
109
+ * The workspace hash is a short SHA-256 prefix of the absolute workspace
110
+ * path; we don't include the literal path in the socket name because:
111
+ * - sockets have a length limit (~104 bytes on macOS) that long repo
112
+ * paths blow through, and
113
+ * - paths contain `/` and other chars that make filenames awkward.
114
+ *
115
+ * @param {object} args
116
+ * @param {string} args.peer e.g. 'gemini'
117
+ * @param {string} args.workspace absolute path to the workspace root
118
+ * @param {NodeJS.Platform} [args.platform] override; tests inject 'win32'
119
+ * @returns {string} endpoint path or named-pipe address
120
+ */
121
+ function brokerEndpoint({ peer, workspace, platform } = {}) {
122
+ if (typeof peer !== 'string' || peer.length === 0) {
123
+ throw new TypeError('brokerEndpoint: peer must be a non-empty string');
124
+ }
125
+ if (typeof workspace !== 'string' || workspace.length === 0) {
126
+ throw new TypeError('brokerEndpoint: workspace must be a non-empty string');
127
+ }
128
+ const plat = platform || process.platform;
129
+ const hash = crypto
130
+ .createHash('sha256')
131
+ .update(workspace)
132
+ .digest('hex')
133
+ .slice(0, 12);
134
+
135
+ if (plat === 'win32') {
136
+ return `\\\\.\\pipe\\gdd-peer-broker-${peer}-${hash}`;
137
+ }
138
+ return path.join(os.homedir(), '.gdd', 'peer-brokers', `${peer}-${hash}.sock`);
139
+ }
140
+
141
+ /**
142
+ * @typedef {object} BrokerOptions
143
+ * @property {string} peer peer-CLI ID (e.g. 'gemini', 'codex')
144
+ * @property {string} workspace absolute workspace path
145
+ * @property {'acp'|'asp'} transport
146
+ * @property {string} [endpoint] override the computed endpoint (test/escape hatch)
147
+ * @property {(endpoint: string) => any} [connectFn] test injection: must return
148
+ * an object with `.write(line: string) → boolean`, `.end()`, and EventEmitter
149
+ * semantics for `data` (Buffer chunks) + `error` + `close`. Default: `net.createConnection`.
150
+ * @property {NodeJS.Platform} [platform] override for endpoint computation
151
+ * @property {number} [maxPending] override MAX_PENDING (test only)
152
+ */
153
+
154
+ /**
155
+ * @typedef {object} BrokerHandle
156
+ * @property {() => Promise<void>} connect
157
+ * @property {(message: object) => void} send
158
+ * @property {(timeoutMs?: number) => Promise<object>} receive
159
+ * @property {() => Promise<void>} close
160
+ * @property {() => number} pendingCount current in-flight send count
161
+ * @property {string} endpoint
162
+ */
163
+
164
+ /**
165
+ * Create a client handle for a peer broker. Idempotent connect — calling
166
+ * `.connect()` twice on the same handle is a no-op after the first.
167
+ *
168
+ * @param {BrokerOptions} opts
169
+ * @returns {BrokerHandle}
170
+ */
171
+ function createBroker(opts) {
172
+ if (!opts || typeof opts !== 'object') {
173
+ throw new TypeError('createBroker: opts object required');
174
+ }
175
+ const { peer, workspace, transport } = opts;
176
+ if (transport !== 'acp' && transport !== 'asp') {
177
+ throw new TypeError(
178
+ `createBroker: transport must be 'acp' or 'asp', got ${JSON.stringify(transport)}`,
179
+ );
180
+ }
181
+
182
+ const endpoint =
183
+ opts.endpoint ||
184
+ brokerEndpoint({ peer, workspace, platform: opts.platform });
185
+ const connectFn = opts.connectFn || ((ep) => net.createConnection(ep));
186
+ const maxPending =
187
+ Number.isFinite(opts.maxPending) && opts.maxPending > 0
188
+ ? opts.maxPending
189
+ : MAX_PENDING;
190
+
191
+ /** @type {any} the underlying socket / fake transport */
192
+ let socket = null;
193
+ let connected = false;
194
+ let connecting = null;
195
+ let closed = false;
196
+
197
+ // Inbound replies that have been parsed but not yet handed to a receive() caller.
198
+ /** @type {object[]} */
199
+ const inbox = [];
200
+ // receive() callers waiting for the next reply. FIFO.
201
+ /** @type {Array<{resolve: (m: object) => void, reject: (e: Error) => void, timer: NodeJS.Timeout | null}>} */
202
+ const waiters = [];
203
+ // Send-side accounting: count of outgoing requests that have not yet been
204
+ // matched by a corresponding inbound reply. We use this for backpressure
205
+ // — when this hits maxPending, send() throws BrokerBusyError.
206
+ let pending = 0;
207
+
208
+ // Line-buffer for newline-delimited JSON. ACP and ASP both frame messages
209
+ // with a single `\n` separator; partial chunks are common because TCP /
210
+ // domain-socket reads can split frames anywhere.
211
+ let lineBuf = '';
212
+
213
+ function deliver(message) {
214
+ // Decrement pending: every inbound message that matches a sent request
215
+ // frees one slot. We don't try to correlate request IDs here — that's
216
+ // the protocol layer's job (acp-client / asp-client). At the broker
217
+ // level we just count round-trips so backpressure is meaningful.
218
+ if (pending > 0) pending -= 1;
219
+
220
+ const next = waiters.shift();
221
+ if (next) {
222
+ if (next.timer) clearTimeout(next.timer);
223
+ next.resolve(message);
224
+ } else {
225
+ inbox.push(message);
226
+ }
227
+ }
228
+
229
+ function fail(err) {
230
+ // Reject all pending receivers; future send() calls fail because the
231
+ // socket is gone.
232
+ while (waiters.length > 0) {
233
+ const w = waiters.shift();
234
+ if (w.timer) clearTimeout(w.timer);
235
+ w.reject(err);
236
+ }
237
+ connected = false;
238
+ }
239
+
240
+ function onData(chunk) {
241
+ lineBuf += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
242
+ let nl;
243
+ while ((nl = lineBuf.indexOf('\n')) >= 0) {
244
+ const line = lineBuf.slice(0, nl).trim();
245
+ lineBuf = lineBuf.slice(nl + 1);
246
+ if (line.length === 0) continue;
247
+ let parsed;
248
+ try {
249
+ parsed = JSON.parse(line);
250
+ } catch (e) {
251
+ // Malformed frame from the broker — surface as an error to anyone
252
+ // waiting; flush the rest of the buffer to avoid wedging on a
253
+ // poison frame.
254
+ fail(new Error(`broker: malformed JSON frame: ${e.message}`));
255
+ return;
256
+ }
257
+ deliver(parsed);
258
+ }
259
+ }
260
+
261
+ function attachSocket(sock) {
262
+ socket = sock;
263
+ sock.on('data', onData);
264
+ sock.on('error', (e) => fail(e));
265
+ sock.on('close', () => {
266
+ // Treat unexpected close as an error for any in-flight callers, but
267
+ // don't synthesize an error if the consumer initiated close().
268
+ if (!closed) {
269
+ fail(new Error('broker: connection closed unexpectedly'));
270
+ }
271
+ connected = false;
272
+ });
273
+ }
274
+
275
+ async function connect() {
276
+ if (connected) return;
277
+ if (connecting) return connecting;
278
+ if (closed) {
279
+ throw new Error('broker: cannot reconnect a closed handle');
280
+ }
281
+ connecting = new Promise((resolve, reject) => {
282
+ let sock;
283
+ try {
284
+ sock = connectFn(endpoint);
285
+ } catch (e) {
286
+ reject(e);
287
+ return;
288
+ }
289
+ // If the underlying transport supports a 'connect' / 'ready' event,
290
+ // wait for it. Otherwise (e.g., test fakes that are synchronously
291
+ // ready) fall through to immediate resolve.
292
+ let settled = false;
293
+ const finish = (err) => {
294
+ if (settled) return;
295
+ settled = true;
296
+ if (err) {
297
+ reject(err);
298
+ } else {
299
+ attachSocket(sock);
300
+ connected = true;
301
+ resolve();
302
+ }
303
+ };
304
+ // Most net.Socket / mock transports support .once('connect').
305
+ if (typeof sock.once === 'function') {
306
+ sock.once('connect', () => finish(null));
307
+ sock.once('error', (e) => finish(e));
308
+ } else {
309
+ // Synchronous fake: assume already connected.
310
+ queueMicrotask(() => finish(null));
311
+ }
312
+ });
313
+ try {
314
+ await connecting;
315
+ } finally {
316
+ connecting = null;
317
+ }
318
+ }
319
+
320
+ function send(message) {
321
+ if (closed) {
322
+ throw new Error('broker: send on closed handle');
323
+ }
324
+ if (!connected) {
325
+ throw new Error('broker: send before connect');
326
+ }
327
+ if (pending >= maxPending) {
328
+ throw new BrokerBusyError(
329
+ `broker: ${pending} pending requests at MAX_PENDING=${maxPending}`,
330
+ );
331
+ }
332
+ const line = JSON.stringify(message) + '\n';
333
+ pending += 1;
334
+ // We deliberately do not await drain() here — line-delimited JSON-RPC
335
+ // is small (typically < 4KB per frame); the kernel buffer absorbs it.
336
+ // If a future workload changes that, switch to a write-with-drain queue.
337
+ socket.write(line);
338
+ }
339
+
340
+ function receive(timeoutMs) {
341
+ if (closed) {
342
+ return Promise.reject(new Error('broker: receive on closed handle'));
343
+ }
344
+ // Fast path: a reply arrived before anyone was waiting.
345
+ if (inbox.length > 0) {
346
+ return Promise.resolve(inbox.shift());
347
+ }
348
+ const ms = Number.isFinite(timeoutMs) && timeoutMs > 0
349
+ ? timeoutMs
350
+ : DEFAULT_RECEIVE_TIMEOUT_MS;
351
+ return new Promise((resolve, reject) => {
352
+ const waiter = { resolve, reject, timer: null };
353
+ waiter.timer = setTimeout(() => {
354
+ // Remove from queue so a late-arriving reply doesn't resolve a
355
+ // caller that already gave up.
356
+ const idx = waiters.indexOf(waiter);
357
+ if (idx >= 0) waiters.splice(idx, 1);
358
+ reject(
359
+ new BrokerTimeoutError(`broker: receive timeout after ${ms}ms`),
360
+ );
361
+ }, ms);
362
+ // Allow the process to exit even if a receive() is outstanding —
363
+ // matches the behavior of net.Socket which is also unref-friendly.
364
+ if (waiter.timer && typeof waiter.timer.unref === 'function') {
365
+ waiter.timer.unref();
366
+ }
367
+ waiters.push(waiter);
368
+ });
369
+ }
370
+
371
+ async function close() {
372
+ if (closed) return;
373
+ closed = true;
374
+ // Reject any outstanding waiters — the consumer is going away.
375
+ while (waiters.length > 0) {
376
+ const w = waiters.shift();
377
+ if (w.timer) clearTimeout(w.timer);
378
+ w.reject(new Error('broker: handle closed'));
379
+ }
380
+ if (socket && typeof socket.end === 'function') {
381
+ try {
382
+ socket.end();
383
+ } catch {
384
+ // Best-effort close — broker may already be gone.
385
+ }
386
+ }
387
+ connected = false;
388
+ }
389
+
390
+ return {
391
+ connect,
392
+ send,
393
+ receive,
394
+ close,
395
+ pendingCount: () => pending,
396
+ endpoint,
397
+ };
398
+ }
399
+
400
+ module.exports = {
401
+ createBroker,
402
+ brokerEndpoint,
403
+ BrokerBusyError,
404
+ BrokerTimeoutError,
405
+ MAX_PENDING,
406
+ };