@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
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * bandit-router.cjs — contextual Thompson-sampling bandit over
3
- * (agent_type, touches_size_bin) → {haiku, sonnet, opus} (Plan 23.5-01).
3
+ * (agent_type, touches_size_bin[, delegate]) → {haiku, sonnet, opus}
4
+ * (Plan 23.5-01 + Plan 27-07 delegate dimension).
4
5
  *
5
6
  * Replaces Phase 10.1's static tier_overrides map when the user opts
6
7
  * into adaptive_mode = "full". The static map continues to apply when
@@ -10,12 +11,25 @@
10
11
  * .design/telemetry/posterior.json
11
12
  * { schema_version: '1.0.0',
12
13
  * generated_at: ISO,
13
- * arms: [{agent, bin, tier, alpha, beta, last_used, count}] }
14
+ * arms: [{agent, bin, tier, delegate?, alpha, beta, last_used, count}] }
15
+ *
16
+ * The `delegate` field on an arm is OPTIONAL (Plan 27-07 / D-08). Existing
17
+ * callers that pass only `(agent, bin)` continue to read/write arms with
18
+ * `delegate === undefined`, which behaves identically to delegate='none'
19
+ * (i.e., the local-call slice). New callers can opt into the delegate
20
+ * dimension via `pullWithDelegate()` / `updateWithDelegate()` which
21
+ * persist `delegate ∈ {none, gemini, codex, cursor, copilot, qwen}`.
22
+ *
23
+ * Bootstrap discipline (D-08):
24
+ * - delegate='none' arms inherit Phase 23.5's TIER_PRIOR (informed).
25
+ * - delegate ∈ {gemini, codex, cursor, copilot, qwen} arms start
26
+ * neutral — the same TIER_PRIOR shape, on the assumption that we
27
+ * have no prior to favour any delegate over local; data drives.
14
28
  *
15
29
  * Atomic .tmp + rename. Discounted Thompson via per-arm time-decay
16
30
  * factor `rho^days_since_last_use` applied at sample time, not stored.
17
31
  *
18
- * Reward computation (D-06): two-stage lexicographic
32
+ * Reward computation (D-06): two-stage lexicographic — UNCHANGED.
19
33
  * if !solidify_pass: reward = 0
20
34
  * elif user_undo_in_session: reward = 0
21
35
  * else: reward = 1 - lambda * normalize(cost + epsilon * wall_time)
@@ -45,11 +59,25 @@ const TIER_PRIOR = Object.freeze({
45
59
  const PRIOR_STRENGTH = 10;
46
60
  const DEFAULT_TIERS = Object.freeze(['haiku', 'sonnet', 'opus']);
47
61
 
62
+ // Plan 27-07 / D-08. Delegate context dimension. 'none' = local Anthropic
63
+ // call; the other 5 are peer-CLI delegations via ACP/ASP. Adding this as
64
+ // a third context dimension expands the arm space 6× (78 → ~468 contexts).
65
+ const DELEGATE_NONE = 'none';
66
+ const DEFAULT_DELEGATES = Object.freeze([
67
+ DELEGATE_NONE,
68
+ 'gemini',
69
+ 'codex',
70
+ 'cursor',
71
+ 'copilot',
72
+ 'qwen',
73
+ ]);
74
+
48
75
  const DEFAULT_PRIORS = Object.freeze({
49
76
  decay: DEFAULT_DECAY,
50
77
  strength: PRIOR_STRENGTH,
51
78
  tiers: DEFAULT_TIERS,
52
79
  perTier: TIER_PRIOR,
80
+ delegates: DEFAULT_DELEGATES,
53
81
  });
54
82
 
55
83
  const TOUCHES_BINS = Object.freeze([
@@ -141,12 +169,47 @@ function priorFor(tier, strength) {
141
169
  };
142
170
  }
143
171
 
144
- function findArm(arms, agent, bin, tier) {
145
- return arms.find((a) => a.agent === agent && a.bin === bin && a.tier === tier);
172
+ /**
173
+ * @param {object[]} arms
174
+ * @param {string} agent
175
+ * @param {string} bin
176
+ * @param {string} tier
177
+ * @param {string} [delegate] — when provided, match arms with that
178
+ * delegate label. When omitted, match arms with no delegate field
179
+ * (legacy Phase 23.5 slice — equivalent to delegate='none' for
180
+ * bootstrap purposes but persisted distinctly to preserve
181
+ * round-trippability of existing posterior files).
182
+ */
183
+ function findArm(arms, agent, bin, tier, delegate) {
184
+ if (delegate === undefined) {
185
+ return arms.find(
186
+ (a) =>
187
+ a.agent === agent &&
188
+ a.bin === bin &&
189
+ a.tier === tier &&
190
+ a.delegate === undefined,
191
+ );
192
+ }
193
+ return arms.find(
194
+ (a) =>
195
+ a.agent === agent &&
196
+ a.bin === bin &&
197
+ a.tier === tier &&
198
+ a.delegate === delegate,
199
+ );
146
200
  }
147
201
 
148
- function ensureArm(posterior, agent, bin, tier, strength) {
149
- let arm = findArm(posterior.arms, agent, bin, tier);
202
+ /**
203
+ * Ensure an arm exists, creating it with the informed prior when missing.
204
+ *
205
+ * For Plan 27-07: when `delegate` is provided, the arm is persisted with
206
+ * that label. Bootstrap is identical for delegate='none' (inherits Phase
207
+ * 23.5 prior — no migration needed because the legacy slice and the
208
+ * 'none' slice are independent contexts) and for the 5 peer delegates
209
+ * (each starts neutral with the same TIER_PRIOR shape; data drives).
210
+ */
211
+ function ensureArm(posterior, agent, bin, tier, strength, delegate) {
212
+ let arm = findArm(posterior.arms, agent, bin, tier, delegate);
150
213
  if (arm) return arm;
151
214
  const { alpha, beta } = priorFor(tier, strength);
152
215
  arm = {
@@ -158,6 +221,9 @@ function ensureArm(posterior, agent, bin, tier, strength) {
158
221
  last_used: null,
159
222
  count: 0,
160
223
  };
224
+ if (delegate !== undefined) {
225
+ arm.delegate = delegate;
226
+ }
161
227
  posterior.arms.push(arm);
162
228
  return arm;
163
229
  }
@@ -310,6 +376,143 @@ function update(input) {
310
376
  return { alpha: arm.alpha, beta: arm.beta, posteriorPath: p };
311
377
  }
312
378
 
379
+ /**
380
+ * Pull an arm with the delegate context dimension (Plan 27-07 / D-08).
381
+ *
382
+ * Joint sample over `tiers × delegates` — i.e., 3 × 6 = 18 arms in the
383
+ * default case. Returns the (tier, delegate) pair with the highest
384
+ * sampled posterior. Bumps the chosen arm's last_used + count.
385
+ *
386
+ * Caller-restricted delegate set:
387
+ * - For `delegate_to: none` agents (Plan 27-06 frontmatter), the caller
388
+ * should pass `delegates: ['none']` to constrain sampling to the
389
+ * local-call slice — the bandit will not explore peer delegations.
390
+ * - For agents without `delegate_to` (default), the caller may either
391
+ * omit delegates (legacy `pull()` behaviour) or pass DEFAULT_DELEGATES
392
+ * to enable adaptive routing across the full 18-arm space.
393
+ *
394
+ * @param {{
395
+ * agent: string,
396
+ * bin: string,
397
+ * tiers?: string[],
398
+ * delegates?: string[],
399
+ * baseDir?: string,
400
+ * posteriorPath?: string,
401
+ * decay?: number,
402
+ * strength?: number,
403
+ * now?: Date,
404
+ * }} input
405
+ * @returns {{
406
+ * tier: string,
407
+ * delegate: string,
408
+ * samples: Record<string, Record<string, number>>,
409
+ * posteriorPath: string,
410
+ * }}
411
+ */
412
+ function pullWithDelegate(input) {
413
+ if (!input || typeof input.agent !== 'string' || input.agent.length === 0) {
414
+ throw new TypeError('bandit-router.pullWithDelegate: agent (string) required');
415
+ }
416
+ if (typeof input.bin !== 'string' || input.bin.length === 0) {
417
+ throw new TypeError('bandit-router.pullWithDelegate: bin (string) required');
418
+ }
419
+ const tiers = input.tiers ?? DEFAULT_TIERS;
420
+ const delegates = input.delegates ?? DEFAULT_DELEGATES;
421
+ if (!Array.isArray(delegates) || delegates.length === 0) {
422
+ throw new TypeError(
423
+ 'bandit-router.pullWithDelegate: delegates must be a non-empty array',
424
+ );
425
+ }
426
+ const strength = input.strength ?? PRIOR_STRENGTH;
427
+ const now = input.now ?? new Date();
428
+
429
+ const posterior = loadPosterior(input);
430
+ /** @type {Record<string, Record<string, number>>} */
431
+ const samples = {};
432
+ let bestTier = tiers[0];
433
+ let bestDelegate = delegates[0];
434
+ let bestSample = -1;
435
+ for (const delegate of delegates) {
436
+ samples[delegate] = {};
437
+ for (const tier of tiers) {
438
+ const arm = ensureArm(posterior, input.agent, input.bin, tier, strength, delegate);
439
+ const decayed = decayArm(arm, { decay: input.decay, now, strength });
440
+ const s = sampleBeta(decayed.alpha, decayed.beta);
441
+ samples[delegate][tier] = s;
442
+ if (s > bestSample) {
443
+ bestSample = s;
444
+ bestTier = tier;
445
+ bestDelegate = delegate;
446
+ }
447
+ }
448
+ }
449
+ const chosen = ensureArm(
450
+ posterior,
451
+ input.agent,
452
+ input.bin,
453
+ bestTier,
454
+ strength,
455
+ bestDelegate,
456
+ );
457
+ chosen.last_used = now.toISOString();
458
+ chosen.count += 1;
459
+ const written = savePosterior(posterior, input);
460
+ return {
461
+ tier: bestTier,
462
+ delegate: bestDelegate,
463
+ samples,
464
+ posteriorPath: written,
465
+ };
466
+ }
467
+
468
+ /**
469
+ * Update the posterior with a reward signal — delegate-aware variant.
470
+ *
471
+ * Reward signal is UNCHANGED from Phase 23.5 (D-08): two-stage
472
+ * lexicographic via `computeReward()` — correctness first, cost as
473
+ * tiebreaker. The delegate dimension is applied at the arm-locator
474
+ * level, not the reward computation.
475
+ *
476
+ * @param {{
477
+ * agent: string,
478
+ * bin: string,
479
+ * tier: string,
480
+ * delegate: string,
481
+ * reward: number,
482
+ * baseDir?: string,
483
+ * posteriorPath?: string,
484
+ * strength?: number,
485
+ * }} input
486
+ * @returns {{alpha: number, beta: number, posteriorPath: string}}
487
+ */
488
+ function updateWithDelegate(input) {
489
+ if (!input) throw new TypeError('bandit-router.updateWithDelegate: input required');
490
+ for (const k of ['agent', 'bin', 'tier', 'delegate']) {
491
+ if (typeof input[k] !== 'string' || input[k].length === 0) {
492
+ throw new TypeError(
493
+ `bandit-router.updateWithDelegate: ${k} (string) required`,
494
+ );
495
+ }
496
+ }
497
+ if (typeof input.reward !== 'number' || Number.isNaN(input.reward)) {
498
+ throw new TypeError('bandit-router.updateWithDelegate: reward (number) required');
499
+ }
500
+ const r = Math.min(1, Math.max(0, input.reward));
501
+ const posterior = loadPosterior(input);
502
+ const arm = ensureArm(
503
+ posterior,
504
+ input.agent,
505
+ input.bin,
506
+ input.tier,
507
+ input.strength ?? PRIOR_STRENGTH,
508
+ input.delegate,
509
+ );
510
+ arm.alpha += r;
511
+ arm.beta += 1 - r;
512
+ const p = savePosterior(posterior, input);
513
+ return { alpha: arm.alpha, beta: arm.beta, posteriorPath: p };
514
+ }
515
+
313
516
  /**
314
517
  * Two-stage lexicographic reward (D-06).
315
518
  *
@@ -350,6 +553,8 @@ function computeReward(input) {
350
553
  module.exports = {
351
554
  pull,
352
555
  update,
556
+ pullWithDelegate,
557
+ updateWithDelegate,
353
558
  reset,
354
559
  loadPosterior,
355
560
  savePosterior,
@@ -360,6 +565,8 @@ module.exports = {
360
565
  priorFor,
361
566
  DEFAULT_PRIORS,
362
567
  DEFAULT_TIERS,
568
+ DEFAULT_DELEGATES,
569
+ DELEGATE_NONE,
363
570
  TIER_PRIOR,
364
571
  PRIOR_STRENGTH,
365
572
  TOUCHES_BINS,
@@ -384,6 +384,15 @@ function computeCost(args, opts) {
384
384
  * event-stream import); .cjs callers (non-CC mirrors) compose this with
385
385
  * the JSONL line shape used in tier-resolver.cjs's `emitEvent()`.
386
386
  *
387
+ * Phase 27 / Plan 27-08 (D-09) extension — additive: cost rows now
388
+ * optionally carry `runtime_role` ("host" | "peer", default "host" when
389
+ * absent for back-compat) and `peer_id` (set only when
390
+ * `runtime_role === "peer"`). Pre-Phase-27 callers that don't pass these
391
+ * fields get the legacy shape with `runtime_role: "host"` stamped — so
392
+ * the cost-aggregator + reflector cross-runtime arbitrage
393
+ * (`scripts/lib/cost-arbitrage.cjs`) never sees an absent role tag and
394
+ * mixed-role cycle history rolls up correctly without crashing.
395
+ *
387
396
  * @param {object} args
388
397
  * @param {string} args.runtime
389
398
  * @param {string} args.agent
@@ -392,10 +401,18 @@ function computeCost(args, opts) {
392
401
  * @param {number} args.tokens_in
393
402
  * @param {number} args.tokens_out
394
403
  * @param {number|null} args.cost_usd
404
+ * @param {('host'|'peer')} [args.runtime_role]
405
+ * Phase 27. Defaults to `"host"` when absent.
406
+ * @param {string|null} [args.peer_id]
407
+ * Phase 27. The peer-CLI ID when `runtime_role === "peer"` (e.g.
408
+ * `"gemini"`, `"codex"`). Omitted from output when absent or when
409
+ * `runtime_role === "host"`.
395
410
  * @returns {object}
396
411
  */
397
412
  function buildCostEventPayload(args) {
398
- return {
413
+ const role = args && args.runtime_role === 'peer' ? 'peer' : 'host';
414
+ /** @type {Record<string, unknown>} */
415
+ const out = {
399
416
  runtime: args.runtime,
400
417
  agent: args.agent,
401
418
  model_id: args.model_id,
@@ -405,7 +422,54 @@ function buildCostEventPayload(args) {
405
422
  cost_usd: typeof args.cost_usd === 'number' && Number.isFinite(args.cost_usd)
406
423
  ? args.cost_usd
407
424
  : null,
425
+ runtime_role: role,
408
426
  };
427
+ // peer_id is only meaningful when role === "peer"; we omit it for
428
+ // host rows to keep the legacy on-disk shape stable for cost-aggregator
429
+ // tests that snapshot the line content.
430
+ if (role === 'peer') {
431
+ const pid = args && typeof args.peer_id === 'string' && args.peer_id.length > 0
432
+ ? args.peer_id
433
+ : null;
434
+ out.peer_id = pid;
435
+ }
436
+ return out;
437
+ }
438
+
439
+ /**
440
+ * Phase 27 / Plan 27-08 helper — derive `(runtime_role, peer_id)` from a
441
+ * router decision shape, mirroring `modelFromResolved`'s contract:
442
+ *
443
+ * - When the router decision carries `runtime_role: "peer"` AND
444
+ * `peer_id: "<non-empty-string>"`, return `{ role: 'peer', peer_id }`.
445
+ * - When the decision is absent, malformed, or not flagged as peer-mode,
446
+ * return `{ role: 'host', peer_id: null }`.
447
+ *
448
+ * Plan 27-06's session-runner sets `runtime_role` + `peer_id` on its
449
+ * router-decision payload before invoking the budget-enforcer hook; this
450
+ * helper is the pure backend lookup that the hook (and any non-CC
451
+ * cost-recorder mirror) calls to thread those values into the cost row.
452
+ *
453
+ * Defensive on every shape — never throws on null / wrong type. Returning
454
+ * `host` on every error path keeps the legacy back-compat default
455
+ * everywhere.
456
+ *
457
+ * @param {unknown} routerDecision
458
+ * @returns {{ role: 'host' | 'peer', peer_id: string | null }}
459
+ */
460
+ function roleFromRouterDecision(routerDecision) {
461
+ if (!routerDecision || typeof routerDecision !== 'object') {
462
+ return { role: 'host', peer_id: null };
463
+ }
464
+ const role = routerDecision.runtime_role;
465
+ if (role !== 'peer') return { role: 'host', peer_id: null };
466
+ const pid = routerDecision.peer_id;
467
+ if (typeof pid !== 'string' || pid.length === 0) {
468
+ // peer-flagged but no peer_id is malformed — degrade to host to
469
+ // avoid emitting a half-tagged cost row.
470
+ return { role: 'host', peer_id: null };
471
+ }
472
+ return { role: 'peer', peer_id: pid };
409
473
  }
410
474
 
411
475
  /**
@@ -435,6 +499,10 @@ module.exports = {
435
499
  computeCost,
436
500
  buildCostEventPayload,
437
501
  modelFromResolved,
502
+ // Plan 27-08 (D-09): runtime-role + peer-id derivation from router
503
+ // decision. Used by the .ts hook and any non-CC cost-recorder mirror to
504
+ // thread peer-delegation tags into cost.jsonl rows.
505
+ roleFromRouterDecision,
438
506
  parsePriceTable,
439
507
  loadPriceTable,
440
508
  priceTablePath,
@@ -50,8 +50,21 @@ export type {
50
50
  ToolCallCompletedEvent,
51
51
  AgentSpawnEvent,
52
52
  AgentOutcomeEvent,
53
+ // Phase 27 / Plan 27-08 — peer-CLI delegation events (D-09).
54
+ RuntimeRole,
55
+ PeerCallStartedEvent,
56
+ PeerCallCompleteEvent,
57
+ PeerCallFailedEvent,
58
+ } from './types.ts';
59
+ export {
60
+ KNOWN_EVENT_TYPES,
61
+ // Phase 27 / Plan 27-08 — symbolic constants for peer-CLI event names.
62
+ PEER_CALL_STARTED,
63
+ PEER_CALL_COMPLETE,
64
+ PEER_CALL_FAILED,
65
+ PEER_CALL_EVENT_TYPES,
66
+ DEFAULT_RUNTIME_ROLE,
53
67
  } from './types.ts';
54
- export { KNOWN_EVENT_TYPES } from './types.ts';
55
68
  export { EventBus } from './emitter.ts';
56
69
  export type { EventHandler, Unsubscribe } from './emitter.ts';
57
70
  export { EventWriter, DEFAULT_EVENTS_PATH, DEFAULT_MAX_LINE_BYTES } from './writer.ts';
@@ -218,6 +218,87 @@ export type AgentOutcomeEvent = BaseEvent & {
218
218
  };
219
219
  };
220
220
 
221
+ // ---------------------------------------------------------------------------
222
+ // Phase 27 — peer-CLI delegation lifecycle (Plan 27-08, D-09)
223
+ // ---------------------------------------------------------------------------
224
+ //
225
+ // Additive extension. Every event keeps existing fields. Peer-call events
226
+ // gain two payload tags:
227
+ //
228
+ // * `runtime_role: "host" | "peer"` — defaults to `"host"` if absent at
229
+ // read time (so all pre-Phase-27 events continue to read as host-mode).
230
+ // Only the three `peer_call_*` events below MUST carry it as `"peer"`.
231
+ // * `peer_id` — the peer-CLI ID (`"gemini"`, `"codex"`, `"cursor"`,
232
+ // `"copilot"`, `"qwen"`, …) — set when `runtime_role === "peer"`.
233
+ //
234
+ // `costs.jsonl` cost rows (`cost_recorded` / `cost.update`) gain the same
235
+ // two tags so Phase 26's reflector cross-runtime arbitrage continues to
236
+ // roll up correctly with mixed-role data. See
237
+ // `scripts/lib/budget-enforcer.cjs#buildCostEventPayload` for the cost-row
238
+ // extension.
239
+ //
240
+ // Plan 27-06 owns the actual emission call sites in session-runner; this
241
+ // file provides the shape + symbolic constants so 27-06 can import the
242
+ // type names without redefining them.
243
+
244
+ /**
245
+ * Narrow union for the runtime-role tag. Pre-Phase-27 events do not carry
246
+ * this field; readers MUST default to `"host"` when absent.
247
+ */
248
+ export type RuntimeRole = 'host' | 'peer';
249
+
250
+ /**
251
+ * Emitted by session-runner (Plan 27-06) when a peer-CLI delegation is
252
+ * about to start. `latency_ms` is captured on the corresponding
253
+ * `peer_call_complete` / `peer_call_failed` event; this event marks the
254
+ * boundary so chain-walkers can pair started/complete via shared
255
+ * sessionId + peer_id + role.
256
+ */
257
+ export type PeerCallStartedEvent = BaseEvent & {
258
+ type: 'peer_call_started';
259
+ payload: {
260
+ runtime_role: 'peer';
261
+ peer_id: string;
262
+ role: string;
263
+ };
264
+ };
265
+
266
+ /**
267
+ * Emitted by session-runner on successful peer-CLI delegation.
268
+ * `cost_usd` is computed via the shared cost backend (Plan 26-05)
269
+ * extended with `runtime_role` + `peer_id` tags so the cost-aggregator
270
+ * rolls up peer spend correctly.
271
+ */
272
+ export type PeerCallCompleteEvent = BaseEvent & {
273
+ type: 'peer_call_complete';
274
+ payload: {
275
+ runtime_role: 'peer';
276
+ peer_id: string;
277
+ role: string;
278
+ latency_ms: number;
279
+ tokens_in: number;
280
+ tokens_out: number;
281
+ cost_usd: number | null;
282
+ };
283
+ };
284
+
285
+ /**
286
+ * Emitted by session-runner when peer-CLI delegation fails (peer-absent,
287
+ * peer-error, timeout). D-07: failure is transparent — session-runner
288
+ * falls back to the local Anthropic call — so this event is purely a
289
+ * measurement signal for the reflector. `error_class` mirrors
290
+ * Plan 20-04's `classify(err).kind`.
291
+ */
292
+ export type PeerCallFailedEvent = BaseEvent & {
293
+ type: 'peer_call_failed';
294
+ payload: {
295
+ runtime_role: 'peer';
296
+ peer_id: string;
297
+ role: string;
298
+ error_class: string;
299
+ };
300
+ };
301
+
221
302
  /**
222
303
  * Union of all pre-registered event types. Not a closed enum at the
223
304
  * envelope level — callers can emit unknown types — but downstream
@@ -247,7 +328,10 @@ export type KnownEvent =
247
328
  | ToolCallStartedEvent
248
329
  | ToolCallCompletedEvent
249
330
  | AgentSpawnEvent
250
- | AgentOutcomeEvent;
331
+ | AgentOutcomeEvent
332
+ | PeerCallStartedEvent
333
+ | PeerCallCompleteEvent
334
+ | PeerCallFailedEvent;
251
335
 
252
336
  /**
253
337
  * Runtime list of all pre-registered event `type` strings. Used by the
@@ -278,4 +362,44 @@ export const KNOWN_EVENT_TYPES: readonly string[] = [
278
362
  'tool_call.completed',
279
363
  'agent.spawn',
280
364
  'agent.outcome',
365
+ // Phase 27 / Plan 27-08 — peer-CLI delegation lifecycle (D-09).
366
+ 'peer_call_started',
367
+ 'peer_call_complete',
368
+ 'peer_call_failed',
369
+ ] as const;
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Phase 27 / Plan 27-08 — symbolic constants for peer-CLI event names.
373
+ // ---------------------------------------------------------------------------
374
+ //
375
+ // Plan 27-06 (session-runner) imports these by name rather than copying
376
+ // string literals so a downstream rename is a single-source-of-truth edit.
377
+ // All three are also present in `KNOWN_EVENT_TYPES` above for the registry
378
+ // test in `tests/event-types-registry.test.ts`.
379
+
380
+ /** Event type emitted when a peer-CLI delegation starts. See `PeerCallStartedEvent`. */
381
+ export const PEER_CALL_STARTED = 'peer_call_started' as const;
382
+ /** Event type emitted when a peer-CLI delegation succeeds. See `PeerCallCompleteEvent`. */
383
+ export const PEER_CALL_COMPLETE = 'peer_call_complete' as const;
384
+ /** Event type emitted when a peer-CLI delegation fails (D-07: transparent fallback). See `PeerCallFailedEvent`. */
385
+ export const PEER_CALL_FAILED = 'peer_call_failed' as const;
386
+
387
+ /**
388
+ * Frozen set of all three peer-call event names. Convenient for
389
+ * downstream code that wants to gate "is this a peer-call event?" checks
390
+ * (e.g. the cost-aggregator's mixed-role roll-up).
391
+ */
392
+ export const PEER_CALL_EVENT_TYPES: readonly string[] = [
393
+ PEER_CALL_STARTED,
394
+ PEER_CALL_COMPLETE,
395
+ PEER_CALL_FAILED,
281
396
  ] as const;
397
+
398
+ /**
399
+ * Default runtime-role tag for events that pre-date Phase 27. Readers
400
+ * SHOULD substitute this when `payload.runtime_role` is absent so legacy
401
+ * event-stream consumers continue to read uniformly. Stamping at write
402
+ * time on every emission would be a much larger surface change — see
403
+ * Plan 27-08 deviation notes for rationale.
404
+ */
405
+ export const DEFAULT_RUNTIME_ROLE: RuntimeRole = 'host';
@@ -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
  };