@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
@@ -86,6 +86,336 @@ const RATE_GUARD_PROVIDER = 'anthropic';
86
86
  /** Default retries (first attempt + 1 retry). */
87
87
  const DEFAULT_MAX_RETRIES = 2;
88
88
 
89
+ // ── Plan 27-06 — Peer-CLI delegation primitives ─────────────────────────────
90
+ //
91
+ // Lazy registry loader: the registry is a .cjs module under scripts/lib/peer-cli
92
+ // landed by Plan 27-05. Tests inject a stub via SessionRunnerOptions.registryOverride;
93
+ // real callers fall through to the live module. Resolution is anchored to the
94
+ // repo root via the same `_nodeRequire` we use for jittered-backoff/rate-guard
95
+ // so the runner survives test sandboxes that chdir.
96
+ //
97
+ // We swallow load errors and return null → caller treats as "no peer available"
98
+ // → falls back to local SDK. This keeps the session-runner functional even on
99
+ // fresh checkouts where Plan 27-05 hasn't landed yet.
100
+
101
+ interface PeerRegistry {
102
+ dispatch: (
103
+ role: string,
104
+ tier: string | null,
105
+ text: string,
106
+ opts: { cwd?: string; [k: string]: unknown },
107
+ ) => Promise<{ result: unknown; peer: string; protocol: 'acp' | 'asp' } | null>;
108
+ }
109
+
110
+ let _peerRegistryCache: PeerRegistry | null | undefined;
111
+
112
+ /**
113
+ * Resolve the peer-CLI registry. Memoized; returns null if the module
114
+ * isn't installable (missing file, require throws, shape mismatch).
115
+ * Tests bypass this entirely by passing `registryOverride` on the
116
+ * SessionRunnerOptions.
117
+ */
118
+ function loadPeerRegistry(): PeerRegistry | null {
119
+ if (_peerRegistryCache !== undefined) return _peerRegistryCache;
120
+ try {
121
+ const mod = _nodeRequire(
122
+ _resolve(_REPO_ROOT, 'scripts/lib/peer-cli/registry.cjs'),
123
+ );
124
+ if (mod && typeof mod === 'object' && typeof (mod as { dispatch?: unknown }).dispatch === 'function') {
125
+ _peerRegistryCache = mod as PeerRegistry;
126
+ return _peerRegistryCache;
127
+ }
128
+ } catch {
129
+ // registry.cjs missing or threw on require — treat as "no peers available"
130
+ }
131
+ _peerRegistryCache = null;
132
+ return _peerRegistryCache;
133
+ }
134
+
135
+ /**
136
+ * Visible-for-testing reset of the peer-registry cache. The session-runner
137
+ * caches the registry module after first resolve so production runs don't
138
+ * re-require it per call; tests that swap process state (chdir into a
139
+ * sandbox, write a different registry.cjs, etc.) can call this between
140
+ * tests to force reload. Production code never calls this.
141
+ */
142
+ export function _resetPeerRegistryCache(): void {
143
+ _peerRegistryCache = undefined;
144
+ }
145
+
146
+ /**
147
+ * Parse a `delegate_to` value into (peer, role). Returns null when the
148
+ * value is missing, the literal "none" opt-out, or doesn't match the
149
+ * `<peer>-<role>` shape. The session-runner uses this to figure out
150
+ * which role to ask the registry for.
151
+ *
152
+ * Note: validate-frontmatter.ts already enforces the value shape at lint
153
+ * time — by the time a `delegate_to` reaches session-runner it's been
154
+ * validated against the capability matrix. We re-parse here defensively
155
+ * because the runner is consumed by tests that may pass arbitrary
156
+ * strings, and the cost of an extra split is trivial.
157
+ */
158
+ function parseDelegateTo(value: string | undefined): { peer: string; role: string } | null {
159
+ if (typeof value !== 'string' || value.length === 0) return null;
160
+ if (value === 'none') return null;
161
+ const dashIdx = value.indexOf('-');
162
+ if (dashIdx <= 0 || dashIdx >= value.length - 1) return null;
163
+ return {
164
+ peer: value.slice(0, dashIdx),
165
+ role: value.slice(dashIdx + 1),
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Try to dispatch a session via peer-CLI before falling back to the
171
+ * Anthropic SDK. Returns either a fully-built SessionResult on peer
172
+ * success, or null when the caller should continue to the local path.
173
+ *
174
+ * Per CONTEXT D-07 (transparent fallback): every failure path inside
175
+ * this helper returns null — peer-absent, registry-load-failure,
176
+ * adapter-error, dispatch-throw, anything. The local SDK path then
177
+ * runs as if the delegation never happened. Failure is observable
178
+ * only as a placeholder log call (and, once Plan 27-08 wires real
179
+ * events, as a `peer_call_failed` chain entry).
180
+ */
181
+ async function tryDelegate(args: {
182
+ opts: SessionRunnerOptions;
183
+ sanitizedPrompt: string;
184
+ transcriptPath: string;
185
+ sessionId: string;
186
+ sanitizer: { sanitized: string; applied: readonly string[]; removedSections: readonly string[] };
187
+ }): Promise<SessionResult | null> {
188
+ const { opts, sanitizedPrompt, transcriptPath, sanitizer } = args;
189
+ const parsed = parseDelegateTo(opts.delegateTo);
190
+ if (parsed === null) return null; // not configured / explicit opt-out
191
+
192
+ const role = typeof opts.delegateRole === 'string' && opts.delegateRole.length > 0
193
+ ? opts.delegateRole
194
+ : parsed.role;
195
+ const tier = opts.delegateTier === undefined ? null : opts.delegateTier;
196
+
197
+ const dispatcher: PeerRegistry['dispatch'] | null = (() => {
198
+ if (typeof opts.registryOverride === 'function') return opts.registryOverride;
199
+ const reg = loadPeerRegistry();
200
+ return reg !== null ? reg.dispatch : null;
201
+ })();
202
+ if (dispatcher === null) {
203
+ // No registry available at all — fall through to local.
204
+ _logPeerCallFailed({
205
+ peer: parsed.peer, role, errorClass: 'registry_missing',
206
+ sessionId: args.sessionId, stage: opts.stage,
207
+ });
208
+ return null;
209
+ }
210
+
211
+ // v1.27.1 — emit peer_call_started before dispatcher invocation so the
212
+ // events.jsonl trail captures the attempt even if the dispatcher hangs.
213
+ _logPeerCallStarted({
214
+ peer: parsed.peer, role,
215
+ sessionId: args.sessionId, stage: opts.stage,
216
+ });
217
+ const dispatchStartedAt = Date.now();
218
+
219
+ let dispatchResult: { result: unknown; peer: string; protocol: 'acp' | 'asp' } | null = null;
220
+ try {
221
+ dispatchResult = await dispatcher(role, tier, sanitizedPrompt, { cwd: process.cwd() });
222
+ } catch (err) {
223
+ _logPeerCallFailed({
224
+ peer: parsed.peer,
225
+ role,
226
+ errorClass: 'dispatch_threw',
227
+ message: err instanceof Error ? err.message : String(err),
228
+ sessionId: args.sessionId,
229
+ stage: opts.stage,
230
+ });
231
+ return null; // transparent fallback
232
+ }
233
+
234
+ if (dispatchResult === null) {
235
+ // Registry returned null — peer absent, capability mismatch, or
236
+ // adapter-side error. Per D-07 we fall back silently.
237
+ _logPeerCallFailed({
238
+ peer: parsed.peer, role, errorClass: 'registry_returned_null',
239
+ sessionId: args.sessionId, stage: opts.stage,
240
+ });
241
+ return null;
242
+ }
243
+
244
+ // v1.27.1 — peer round-trip succeeded. Emit peer_call_complete with the
245
+ // measured latency. Token counts + cost are 0 / null because adapters
246
+ // don't surface usage in v1.27 (Plan 27-04 spec deferred it); reflector
247
+ // tolerates null cost (Plan 26-06 cost-arbitrage analysis).
248
+ _logPeerCallComplete({
249
+ peer: dispatchResult.peer,
250
+ role,
251
+ latencyMs: Date.now() - dispatchStartedAt,
252
+ tokensIn: 0,
253
+ tokensOut: 0,
254
+ costUsd: null,
255
+ sessionId: args.sessionId,
256
+ stage: opts.stage,
257
+ });
258
+
259
+ // Peer succeeded. Build a SessionResult that mirrors the local path's
260
+ // shape so downstream consumers (stage-handlers, transcript readers,
261
+ // tests) treat both paths uniformly. We do NOT write a transcript file
262
+ // for delegated calls in v1.27.0 — the peer broker (Plan 27-03) keeps
263
+ // its own logs and Plan 27-08 wires the events that observers need.
264
+ // The transcript_path field still points at the would-be path so any
265
+ // consumer that probes it sees a stable string (existsSync will be
266
+ // false, which is correct: the file isn't ours to write).
267
+ const finalText = _coerceFinalText(dispatchResult.result);
268
+ return {
269
+ status: 'completed',
270
+ transcript_path: transcriptPath,
271
+ turns: 1,
272
+ usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
273
+ ...(finalText !== undefined ? { final_text: finalText } : {}),
274
+ tool_calls: [],
275
+ sanitizer: {
276
+ applied: [...sanitizer.applied],
277
+ removedSections: [...sanitizer.removedSections],
278
+ },
279
+ };
280
+ }
281
+
282
+ /** Best-effort extract a final text string from the adapter's free-form result. */
283
+ function _coerceFinalText(result: unknown): string | undefined {
284
+ if (typeof result === 'string' && result.length > 0) return result;
285
+ if (result !== null && typeof result === 'object') {
286
+ const obj = result as Record<string, unknown>;
287
+ if (typeof obj['final_text'] === 'string' && obj['final_text'].length > 0) {
288
+ return obj['final_text'] as string;
289
+ }
290
+ if (typeof obj['text'] === 'string' && obj['text'].length > 0) {
291
+ return obj['text'] as string;
292
+ }
293
+ if (typeof obj['output'] === 'string' && obj['output'].length > 0) {
294
+ return obj['output'] as string;
295
+ }
296
+ }
297
+ return undefined;
298
+ }
299
+
300
+ /**
301
+ * v1.27.1 — wires Plan 27-08's `peer_call_failed` event for real.
302
+ * Phase 22 `appendEvent` accepts the new event type (registered in
303
+ * KNOWN_EVENT_TYPES via Plan 27-08), so the reflector and downstream
304
+ * telemetry consumers see delegation drops as a measurement signal.
305
+ *
306
+ * Errors from `appendEvent` (e.g., events.jsonl unwritable) are
307
+ * swallowed — peer-call telemetry is observability, not critical
308
+ * path. STATE.md remains the durable record of session outcomes.
309
+ *
310
+ * Operators can additionally set `GDD_PEER_DEBUG=1` to emit a
311
+ * one-line stderr breadcrumb mirroring the event for live tailing.
312
+ */
313
+ function _logPeerCallFailed(args: {
314
+ peer: string;
315
+ role: string;
316
+ errorClass: string;
317
+ message?: string;
318
+ sessionId?: string;
319
+ stage?: SessionRunnerOptions['stage'];
320
+ }): void {
321
+ try {
322
+ appendEvent({
323
+ type: 'peer_call_failed',
324
+ timestamp: new Date().toISOString(),
325
+ sessionId: args.sessionId ?? 'unknown',
326
+ ...(args.stage !== undefined && args.stage !== 'init' && args.stage !== 'custom' ? { stage: args.stage } : {}),
327
+ payload: {
328
+ runtime_role: 'peer',
329
+ peer_id: args.peer,
330
+ role: args.role,
331
+ error_class: args.errorClass,
332
+ ...(args.message !== undefined ? { message: args.message } : {}),
333
+ },
334
+ });
335
+ } catch {
336
+ // Telemetry is best-effort — never let an event-stream failure
337
+ // break the actual session flow.
338
+ }
339
+ if (process.env['GDD_PEER_DEBUG'] === '1') {
340
+ const payload = JSON.stringify({
341
+ type: 'peer_call_failed',
342
+ peer_id: args.peer,
343
+ role: args.role,
344
+ error_class: args.errorClass,
345
+ ...(args.message !== undefined ? { message: args.message } : {}),
346
+ ts: new Date().toISOString(),
347
+ });
348
+ // eslint-disable-next-line no-console
349
+ console.error(`[peer-cli] ${payload}`);
350
+ }
351
+ }
352
+
353
+ /**
354
+ * v1.27.1 — emit `peer_call_started` event. Fired once at the beginning
355
+ * of a delegation attempt, before the dispatcher is invoked. Pairs with
356
+ * `peer_call_complete` (success path) or `peer_call_failed` (any failure
357
+ * path, transparent to caller per D-07).
358
+ */
359
+ function _logPeerCallStarted(args: {
360
+ peer: string;
361
+ role: string;
362
+ sessionId?: string;
363
+ stage?: SessionRunnerOptions['stage'];
364
+ }): void {
365
+ try {
366
+ appendEvent({
367
+ type: 'peer_call_started',
368
+ timestamp: new Date().toISOString(),
369
+ sessionId: args.sessionId ?? 'unknown',
370
+ ...(args.stage !== undefined && args.stage !== 'init' && args.stage !== 'custom' ? { stage: args.stage } : {}),
371
+ payload: {
372
+ runtime_role: 'peer',
373
+ peer_id: args.peer,
374
+ role: args.role,
375
+ },
376
+ });
377
+ } catch {
378
+ // best-effort
379
+ }
380
+ }
381
+
382
+ /**
383
+ * v1.27.1 — emit `peer_call_complete` event. Fired after a successful
384
+ * dispatcher round-trip. Cost is null when the adapter doesn't return
385
+ * usage data (some peers don't surface token counts); the reflector
386
+ * tolerates null cost for arbitrage analysis (Plan 26-06).
387
+ */
388
+ function _logPeerCallComplete(args: {
389
+ peer: string;
390
+ role: string;
391
+ latencyMs: number;
392
+ tokensIn: number;
393
+ tokensOut: number;
394
+ costUsd: number | null;
395
+ sessionId?: string;
396
+ stage?: SessionRunnerOptions['stage'];
397
+ }): void {
398
+ try {
399
+ appendEvent({
400
+ type: 'peer_call_complete',
401
+ timestamp: new Date().toISOString(),
402
+ sessionId: args.sessionId ?? 'unknown',
403
+ ...(args.stage !== undefined && args.stage !== 'init' && args.stage !== 'custom' ? { stage: args.stage } : {}),
404
+ payload: {
405
+ runtime_role: 'peer',
406
+ peer_id: args.peer,
407
+ role: args.role,
408
+ latency_ms: args.latencyMs,
409
+ tokens_in: args.tokensIn,
410
+ tokens_out: args.tokensOut,
411
+ cost_usd: args.costUsd,
412
+ },
413
+ });
414
+ } catch {
415
+ // best-effort
416
+ }
417
+ }
418
+
89
419
  /** Baseline retry backoff parameters (matches jittered-backoff defaults for
90
420
  * the SDK-retry case; 1s base → 30s cap). */
91
421
  const RETRY_BACKOFF = { baseMs: 1000, maxMs: 30_000 } as const;
@@ -408,6 +738,38 @@ export async function run(opts: SessionRunnerOptions): Promise<SessionResult> {
408
738
  }
409
739
  }
410
740
 
741
+ // -- 6.5. Peer-CLI delegation try (Plan 27-06 wiring, v1.27.1). ---------
742
+ // If the agent's frontmatter declares `delegate_to: <peer>-<role>` AND the
743
+ // peer is allowlisted AND the registry can route, run the prompt on the
744
+ // peer-CLI and return early. On peer-absent / peer-error / null result,
745
+ // fall through transparently to the local SDK loop (D-07).
746
+ //
747
+ // tryDelegate is a no-op when opts.delegateTo is undefined / 'none', when
748
+ // the registry can't load, when the peer isn't allowlisted, when the
749
+ // dispatcher returns null, or when the dispatcher throws. In all those
750
+ // cases tryDelegate returns null and we proceed to the local SDK path.
751
+ const peerResult = await tryDelegate({
752
+ opts,
753
+ sanitizedPrompt,
754
+ transcriptPath,
755
+ sessionId,
756
+ sanitizer: sanResult,
757
+ });
758
+ if (peerResult !== null) {
759
+ emit('session.completed', opts.stage, sessionId, {
760
+ stage: opts.stage,
761
+ sessionId,
762
+ status: peerResult.status,
763
+ turns: peerResult.turns,
764
+ usage: peerResult.usage,
765
+ transcript_path: transcriptPath,
766
+ sanitizer: { applied: [...peerResult.sanitizer.applied], removedSections: [...peerResult.sanitizer.removedSections] },
767
+ });
768
+ transcript.close();
769
+ if (opts.signal !== undefined) opts.signal.removeEventListener('abort', onExternalAbort);
770
+ return peerResult;
771
+ }
772
+
411
773
  // -- 7. Retry-once loop. ------------------------------------------------
412
774
  const maxAttempts = opts.maxRetries !== undefined && opts.maxRetries > 0
413
775
  ? opts.maxRetries
@@ -118,6 +118,66 @@ export interface SessionRunnerOptions {
118
118
  applied: readonly string[];
119
119
  removedSections: readonly string[];
120
120
  };
121
+
122
+ /**
123
+ * Phase 27 (Plan 27-06) — peer-CLI delegation.
124
+ *
125
+ * Optional. When set to `<peer>-<role>` (e.g. `gemini-research`), the
126
+ * session-runner attempts to dispatch the call to the named peer-CLI
127
+ * via `scripts/lib/peer-cli/registry.cjs#dispatch` BEFORE invoking the
128
+ * local Anthropic SDK. The peer's response, when successful, becomes
129
+ * the SessionResult — no SDK call is made.
130
+ *
131
+ * Fallback (CONTEXT D-07): if the registry returns `null` (peer
132
+ * absent / opt-out / adapter error / dispatch error) OR throws, the
133
+ * session-runner silently retries with the local Anthropic SDK. The
134
+ * caller never sees the peer failure — failure is a measurement
135
+ * signal, not a cycle-breaker.
136
+ *
137
+ * Special values:
138
+ * - `none` → explicit opt-out; never delegate. Same as omitting the field.
139
+ * - undefined → default behavior; never delegate.
140
+ *
141
+ * The session-runner never reads agent frontmatter on its own. Callers
142
+ * (pipeline-runner, explore, discuss, etc.) are responsible for
143
+ * resolving the agent's `delegate_to:` frontmatter and passing it
144
+ * through this option.
145
+ */
146
+ delegateTo?: string;
147
+
148
+ /**
149
+ * Phase 27 (Plan 27-06) — role hint for peer-CLI dispatch.
150
+ *
151
+ * Used only when `delegateTo` is set. Defaults to the role parsed out
152
+ * of `delegateTo` (e.g. `delegateTo: "gemini-research"` → role
153
+ * `"research"`). Provide explicitly when the caller wants to override
154
+ * the parsed value (rare).
155
+ */
156
+ delegateRole?: string;
157
+
158
+ /**
159
+ * Phase 27 (Plan 27-06) — tier hint for peer-CLI dispatch.
160
+ *
161
+ * Currently advisory; the registry's capability matrix doesn't gate
162
+ * on tier. Used by adapters for telemetry and by Plan 27-08 events.
163
+ * Defaults to null (let the adapter pick).
164
+ */
165
+ delegateTier?: string | null;
166
+
167
+ /**
168
+ * Phase 27 (Plan 27-06) — registry override for tests.
169
+ *
170
+ * Default loads `scripts/lib/peer-cli/registry.cjs` lazily on first
171
+ * delegation attempt. Tests inject a stub `dispatch()` to avoid
172
+ * spawning real peers. The override mirrors the registry's `dispatch`
173
+ * signature: `(role, tier, text, opts) => Promise<{result,peer,protocol} | null>`.
174
+ */
175
+ registryOverride?: (
176
+ role: string,
177
+ tier: string | null,
178
+ text: string,
179
+ opts: { cwd?: string; [k: string]: unknown },
180
+ ) => Promise<{ result: unknown; peer: string; protocol: 'acp' | 'asp' } | null>;
121
181
  }
122
182
 
123
183
  /**
@@ -14,10 +14,100 @@
14
14
  */
15
15
 
16
16
  import { existsSync, statSync, readdirSync } from 'node:fs';
17
- import { join, basename } from 'node:path';
17
+ import { join, basename, dirname, resolve } from 'node:path';
18
+ import { createRequire } from 'node:module';
18
19
 
19
20
  import { readFrontmatter } from '../tests/helpers.ts';
20
21
 
22
+ // ── delegate_to capability matrix loader (Plan 27-06) ──────────────────────
23
+ //
24
+ // The `delegate_to: <peer>-<role> | none` field is validated against the
25
+ // capability matrix exported by scripts/lib/peer-cli/registry.cjs (Plan
26
+ // 27-05). Loading the .cjs from a .ts module under the strip-types loader
27
+ // requires createRequire — and we anchor it to the repo root so the
28
+ // validator survives being invoked from any cwd.
29
+ //
30
+ // Loading is lazy + defensive: if the registry module isn't on disk yet
31
+ // (e.g. during a fresh clone before Plan 27-05 lands, or in a partial
32
+ // checkout), we fall back to an inline literal mirror of the locked D-05
33
+ // capability matrix so this validator never crashes a CI run on a
34
+ // missing dependency. The literal mirror MUST stay in sync with
35
+ // registry.cjs's CAPABILITY_MATRIX — tests assert equivalence.
36
+ function _findRepoRootFromHere(): string {
37
+ // process.argv[1] is the validator script path under strip-types; walk
38
+ // up from its directory looking for package.json. Fall back to cwd.
39
+ const start: string = (() => {
40
+ const argv1 = process.argv[1];
41
+ if (typeof argv1 === 'string' && argv1.length > 0) return dirname(argv1);
42
+ return process.cwd();
43
+ })();
44
+ let dir = resolve(start);
45
+ for (let i = 0; i < 8; i++) {
46
+ if (existsSync(join(dir, 'package.json'))) return dir;
47
+ const parent = dirname(dir);
48
+ if (parent === dir) break;
49
+ dir = parent;
50
+ }
51
+ return process.cwd();
52
+ }
53
+
54
+ /** Locked D-05 capability matrix mirror — fallback when registry.cjs unloadable. */
55
+ const _DELEGATE_MATRIX_FALLBACK: Readonly<Record<string, readonly string[]>> = Object.freeze({
56
+ codex: Object.freeze(['execute']),
57
+ copilot: Object.freeze(['review', 'research']),
58
+ cursor: Object.freeze(['debug', 'plan']),
59
+ gemini: Object.freeze(['research', 'exploration']),
60
+ qwen: Object.freeze(['write']),
61
+ });
62
+
63
+ let _delegateMatrixCache: Readonly<Record<string, readonly string[]>> | null = null;
64
+
65
+ /**
66
+ * Return the live capability matrix as a `peer -> roles[]` map. Cached
67
+ * after first call. Uses registry.cjs as the source of truth; falls back
68
+ * to the inline mirror only if the registry module fails to load.
69
+ */
70
+ export function loadDelegateMatrix(): Readonly<Record<string, readonly string[]>> {
71
+ if (_delegateMatrixCache !== null) return _delegateMatrixCache;
72
+ try {
73
+ const root = _findRepoRootFromHere();
74
+ const req = createRequire(join(root, 'package.json'));
75
+ const reg = req(resolve(root, 'scripts/lib/peer-cli/registry.cjs')) as {
76
+ CAPABILITY_MATRIX?: Record<string, { roles: readonly string[] }>;
77
+ };
78
+ if (reg && typeof reg.CAPABILITY_MATRIX === 'object' && reg.CAPABILITY_MATRIX !== null) {
79
+ const out: Record<string, readonly string[]> = {};
80
+ for (const [peer, cap] of Object.entries(reg.CAPABILITY_MATRIX)) {
81
+ if (cap && Array.isArray(cap.roles)) {
82
+ out[peer] = Object.freeze([...cap.roles]);
83
+ }
84
+ }
85
+ _delegateMatrixCache = Object.freeze(out);
86
+ return _delegateMatrixCache;
87
+ }
88
+ } catch {
89
+ // fall through to fallback
90
+ }
91
+ _delegateMatrixCache = _DELEGATE_MATRIX_FALLBACK;
92
+ return _delegateMatrixCache;
93
+ }
94
+
95
+ /**
96
+ * Build the flat set of valid `<peer>-<role>` IDs from the capability
97
+ * matrix. e.g. `gemini-research`, `codex-execute`, etc. The literal
98
+ * string `"none"` is the explicit opt-out and is accepted separately.
99
+ */
100
+ export function validDelegateIds(): readonly string[] {
101
+ const matrix = loadDelegateMatrix();
102
+ const out: string[] = [];
103
+ for (const [peer, roles] of Object.entries(matrix)) {
104
+ for (const role of roles) {
105
+ out.push(`${peer}-${role}`);
106
+ }
107
+ }
108
+ return Object.freeze(out.sort());
109
+ }
110
+
21
111
  // Importing a type from generated.d.ts satisfies the Plan 20-00 rule that
22
112
  // every Tier-1 TS file participates in the codegen graph. We don't use
23
113
  // IntelSchema at runtime; the re-export keeps it visible for static checks.
@@ -42,6 +132,22 @@ export interface AgentFrontmatter {
42
132
  'default-tier'?: 'haiku' | 'sonnet' | 'opus';
43
133
  'reasoning-class'?: 'high' | 'medium' | 'low';
44
134
  'size_budget'?: 'S' | 'M' | 'L' | 'XL';
135
+ /**
136
+ * Phase 27 (Plan 27-06) — peer-CLI delegation hint.
137
+ *
138
+ * Optional. Default unset = use local Anthropic call. Setting
139
+ * `delegate_to: gemini-research` tells session-runner "try delegate
140
+ * first, fall back to local on peer-absent / peer-error". Setting
141
+ * `delegate_to: none` explicitly opts out (e.g. security-sensitive
142
+ * agents). See agents/README.md "Peer-CLI delegation (delegate_to)"
143
+ * for the additive-superset rationale (CONTEXT D-06).
144
+ *
145
+ * Valid values are `<peer>-<role>` IDs that the peer-CLI registry
146
+ * capability matrix knows (e.g. `gemini-research`, `codex-execute`,
147
+ * `cursor-debug`, `cursor-plan`, `copilot-review`, `copilot-research`,
148
+ * `qwen-write`) plus the literal string `"none"`.
149
+ */
150
+ 'delegate_to'?: string;
45
151
  }
46
152
 
47
153
  const REQUIRED_FIELDS: readonly (keyof AgentFrontmatter)[] = [
@@ -169,6 +275,48 @@ export function validateReasoningClass(
169
275
  return violations;
170
276
  }
171
277
 
278
+ /**
279
+ * Validate the optional Phase 27 (Plan 27-06) `delegate_to` field. Returns
280
+ * an array of violation messages; empty means the agent passes.
281
+ *
282
+ * Rules (CONTEXT D-06):
283
+ * 1. `delegate_to` is OPTIONAL. Absence = use local Anthropic call.
284
+ * 2. If present, value MUST be a string.
285
+ * 3. The literal value `"none"` is accepted as the explicit opt-out.
286
+ * 4. Any other value MUST match a `<peer>-<role>` ID drawn from the
287
+ * peer-CLI capability matrix (Plan 27-05's registry.cjs is the
288
+ * source of truth; loadDelegateMatrix() resolves it lazily with
289
+ * a literal fallback if the registry is unloadable).
290
+ *
291
+ * The 26 v1.26 baseline agents do not carry this field; this helper
292
+ * returns `[]` for them. The validator runs trivially clean on them.
293
+ */
294
+ export function validateDelegateTo(
295
+ fm: Record<string, unknown>,
296
+ agentName: string,
297
+ ): string[] {
298
+ const violations: string[] = [];
299
+ const has = 'delegate_to' in fm && !isMissing(fm['delegate_to']);
300
+ if (!has) return violations;
301
+
302
+ const raw = fm['delegate_to'];
303
+ if (typeof raw !== 'string') {
304
+ violations.push(
305
+ `delegate_to: invalid value "${String(raw)}" for agent "${agentName}" — must be a string ("none" or "<peer>-<role>" e.g. "gemini-research")`,
306
+ );
307
+ return violations;
308
+ }
309
+ if (raw === 'none') return violations; // explicit opt-out
310
+
311
+ const valid = validDelegateIds();
312
+ if (!valid.includes(raw)) {
313
+ violations.push(
314
+ `delegate_to: invalid value "${raw}" for agent "${agentName}" — must be "none" or one of: ${valid.join(', ')} (peer-CLI capability matrix; see scripts/lib/peer-cli/registry.cjs)`,
315
+ );
316
+ }
317
+ return violations;
318
+ }
319
+
172
320
  function walkMd(dir: string): string[] {
173
321
  const out: string[] = [];
174
322
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -234,6 +382,16 @@ function main(): void {
234
382
  console.log(`${f}:${msg}`);
235
383
  violations++;
236
384
  }
385
+
386
+ // Plan 27-06 — peer-CLI delegate_to validation (additive optional field).
387
+ const delegateViolations = validateDelegateTo(
388
+ fm as Record<string, unknown>,
389
+ agentName,
390
+ );
391
+ for (const msg of delegateViolations) {
392
+ console.log(`${f}:${msg}`);
393
+ violations++;
394
+ }
237
395
  }
238
396
 
239
397
  console.log(`summary: ${files.length} file(s) checked, ${violations} violation(s)`);