@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
@@ -86,6 +86,221 @@ 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. Phase 22
204
+ // event emission (Plan 27-08) hooks here as `peer_call_failed`
205
+ // with reason="registry_missing". For now, a placeholder stderr
206
+ // breadcrumb so operators can grep for delegation drops without
207
+ // CI-failing on stdout pollution.
208
+ _logPeerCallFailed({ peer: parsed.peer, role, errorClass: 'registry_missing' });
209
+ return null;
210
+ }
211
+
212
+ let dispatchResult: { result: unknown; peer: string; protocol: 'acp' | 'asp' } | null = null;
213
+ try {
214
+ dispatchResult = await dispatcher(role, tier, sanitizedPrompt, { cwd: process.cwd() });
215
+ } catch (err) {
216
+ _logPeerCallFailed({
217
+ peer: parsed.peer,
218
+ role,
219
+ errorClass: 'dispatch_threw',
220
+ message: err instanceof Error ? err.message : String(err),
221
+ });
222
+ return null; // transparent fallback
223
+ }
224
+
225
+ if (dispatchResult === null) {
226
+ // Registry returned null — peer absent, capability mismatch, or
227
+ // adapter-side error. Per D-07 we fall back silently.
228
+ _logPeerCallFailed({ peer: parsed.peer, role, errorClass: 'registry_returned_null' });
229
+ return null;
230
+ }
231
+
232
+ // Peer succeeded. Build a SessionResult that mirrors the local path's
233
+ // shape so downstream consumers (stage-handlers, transcript readers,
234
+ // tests) treat both paths uniformly. We do NOT write a transcript file
235
+ // for delegated calls in v1.27.0 — the peer broker (Plan 27-03) keeps
236
+ // its own logs and Plan 27-08 wires the events that observers need.
237
+ // The transcript_path field still points at the would-be path so any
238
+ // consumer that probes it sees a stable string (existsSync will be
239
+ // false, which is correct: the file isn't ours to write).
240
+ const finalText = _coerceFinalText(dispatchResult.result);
241
+ return {
242
+ status: 'completed',
243
+ transcript_path: transcriptPath,
244
+ turns: 1,
245
+ usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
246
+ ...(finalText !== undefined ? { final_text: finalText } : {}),
247
+ tool_calls: [],
248
+ sanitizer: {
249
+ applied: [...sanitizer.applied],
250
+ removedSections: [...sanitizer.removedSections],
251
+ },
252
+ };
253
+ }
254
+
255
+ /** Best-effort extract a final text string from the adapter's free-form result. */
256
+ function _coerceFinalText(result: unknown): string | undefined {
257
+ if (typeof result === 'string' && result.length > 0) return result;
258
+ if (result !== null && typeof result === 'object') {
259
+ const obj = result as Record<string, unknown>;
260
+ if (typeof obj['final_text'] === 'string' && obj['final_text'].length > 0) {
261
+ return obj['final_text'] as string;
262
+ }
263
+ if (typeof obj['text'] === 'string' && obj['text'].length > 0) {
264
+ return obj['text'] as string;
265
+ }
266
+ if (typeof obj['output'] === 'string' && obj['output'].length > 0) {
267
+ return obj['output'] as string;
268
+ }
269
+ }
270
+ return undefined;
271
+ }
272
+
273
+ /**
274
+ * Placeholder for Plan 27-08's `peer_call_failed` event. Until 27-08
275
+ * wires real `appendEvent('peer_call_failed', ...)`, we write a single
276
+ * stderr line so operators can grep for silent delegation drops. We
277
+ * deliberately don't go through `appendEvent` here because the Phase 22
278
+ * event-stream hasn't gained a `peer_call_failed` type yet (that's
279
+ * 27-08's job) and pushing an unknown event type today would create a
280
+ * migration mess for the reflector.
281
+ */
282
+ function _logPeerCallFailed(args: {
283
+ peer: string;
284
+ role: string;
285
+ errorClass: string;
286
+ message?: string;
287
+ }): void {
288
+ // One-line, machine-greppable. Quiet by default in test runs (NODE_ENV)
289
+ // so the test output stays clean. Operators set GDD_PEER_DEBUG=1 to see
290
+ // the breadcrumb in production logs.
291
+ if (process.env['GDD_PEER_DEBUG'] !== '1') return;
292
+ const payload = JSON.stringify({
293
+ type: 'peer_call_failed',
294
+ peer_id: args.peer,
295
+ role: args.role,
296
+ error_class: args.errorClass,
297
+ ...(args.message !== undefined ? { message: args.message } : {}),
298
+ ts: new Date().toISOString(),
299
+ });
300
+ // eslint-disable-next-line no-console
301
+ console.error(`[peer-cli] ${payload}`);
302
+ }
303
+
89
304
  /** Baseline retry backoff parameters (matches jittered-backoff defaults for
90
305
  * the SDK-retry case; 1s base → 30s cap). */
91
306
  const RETRY_BACKOFF = { baseMs: 1000, maxMs: 30_000 } as const;
@@ -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)`);
@@ -0,0 +1,170 @@
1
+ ---
2
+ name: peer-cli-add
3
+ description: "Guided ladder for adding a brand-new peer (a peer not in the v1.27 capability matrix) to the gdd peer-CLI delegation layer. Verification ladder + adapter scaffolding + capability-matrix update + Windows quirks documented. Run when you discover a new peer CLI you want gdd to delegate to."
4
+ argument-hint: "<new-peer-id> <peer-binary> <protocol: acp|asp>"
5
+ tools: Read, Edit, Write, Bash, Grep
6
+ ---
7
+
8
+ <!-- Procedural pattern adapted from greenpolo/cc-multi-cli's `multi-cli-anything` skill (Apache 2.0). See NOTICE for full attribution. -->
9
+
10
+ # peer-cli-add
11
+
12
+ ## Role
13
+
14
+ You add a brand-new peer-CLI to gdd's delegation layer. v1.27.0 ships 5 peers (codex, gemini, cursor, copilot, qwen). When the user wants a 6th — a peer that exists in the wild but isn't in our capability matrix yet — they run this skill. It walks them through a verification ladder (does the peer actually speak ACP or ASP?) and produces the 3-file footprint that integrates the peer cleanly.
15
+
16
+ ## Invocation Contract
17
+
18
+ - **Required input**: `<new-peer-id>` (lowercase identifier, e.g., `aider`), `<peer-binary>` (the executable name, e.g., `aider` or `aider.cmd`), `<protocol>` (`acp` or `asp`).
19
+ - **Output**: a 3-file diff + a verification report.
20
+
21
+ ## Procedure
22
+
23
+ ### Step 1 — Verification ladder (no edits yet)
24
+
25
+ Before touching any code, confirm the peer actually speaks the protocol it claims.
26
+
27
+ #### 1a. Binary on PATH
28
+
29
+ `which <peer-binary>` (POSIX) or `where <peer-binary>` (Windows). If exit non-zero, stop and ask user to install the peer first.
30
+
31
+ #### 1b. Handshake test
32
+
33
+ Spawn the peer with the appropriate protocol entry point:
34
+ - ACP peers: `<peer-binary> acp` (or whatever the peer documents as its ACP entry — Gemini uses `gemini acp`; some peers use a flag).
35
+ - ASP peers: `<peer-binary> app-server` (Codex's convention; other ASP peers may differ).
36
+
37
+ Send an `initialize` JSON-RPC message over stdin with `protocolVersion: '2025-06-18'` (ACP) or `service_name: 'gdd_peer_delegation'` (ASP).
38
+
39
+ Capture the reply on stdout. If the reply is a valid JSON-RPC response with `result.protocolVersion` (ACP) or `result.threadId` (ASP), the peer speaks the protocol.
40
+
41
+ If no valid reply within 5 seconds, the peer either doesn't speak this protocol or uses a non-standard entry point. Stop and ask the user for the correct invocation.
42
+
43
+ #### 1c. Model-ID `-preview`-suffix trap
44
+
45
+ Many peers expose preview models with a `-preview` suffix (e.g., `gpt-5-preview` vs `gpt-5`). The suffix drifts: today's preview is tomorrow's GA. Capture the peer's current model list (most peers expose `<peer-binary> models` or similar). Note any model that has `-preview` in its name and document the parent name in the new entry's `provider_model_id` field — so the runtime-models.md entry can survive the suffix flipping.
46
+
47
+ #### 1d. Windows quirks
48
+
49
+ If the peer-binary ends in `.cmd` and the user is on Windows, confirm the spawn-cmd shell-escape logic from `scripts/lib/peer-cli/spawn-cmd.cjs` will pick it up (it should — that module already handles `.cmd` detection per Plan 27-03 / D-04). Document any other Windows-specific quirks in the new adapter's leading comment.
50
+
51
+ ### Step 2 — Generate the adapter scaffold
52
+
53
+ Use the existing 5 adapters at `scripts/lib/peer-cli/adapters/{codex,gemini,cursor,copilot,qwen}.cjs` as templates. Pick the closest match to your new peer's protocol (ASP if `<protocol> = asp`, otherwise ACP).
54
+
55
+ Use the `Write` tool to create `scripts/lib/peer-cli/adapters/<new-peer-id>.cjs`:
56
+
57
+ ```js
58
+ 'use strict';
59
+
60
+ const { createAcpClient } = require('../acp-client.cjs');
61
+ // OR for ASP peers: const { createAspClient } = require('../asp-client.cjs');
62
+
63
+ const ROLES_CLAIMED = ['<role-1>', '<role-2>']; // ASK USER which roles this peer claims
64
+ const ROLE_PREFIX = {
65
+ '<role-1>': '<prompt prefix or empty string>',
66
+ '<role-2>': '<prompt prefix or empty string>',
67
+ };
68
+
69
+ function claims(role) { return ROLES_CLAIMED.includes(role); }
70
+
71
+ async function dispatch({ command, args, cwd, env }, role, text, opts) {
72
+ if (!claims(role)) {
73
+ throw new Error(`<new-peer-id> does not claim role: ${role}`);
74
+ }
75
+ const client = createAcpClient({ command, args, cwd, env });
76
+ try {
77
+ await client.initialize({ protocolVersion: '2025-06-18' });
78
+ const prompt = (ROLE_PREFIX[role] || '') + text;
79
+ return await client.prompt(prompt, { onNotification: opts?.onNotification });
80
+ } finally {
81
+ await client.close();
82
+ }
83
+ }
84
+
85
+ module.exports = { claims, dispatch, ROLES_CLAIMED, ROLE_PREFIX, name: '<new-peer-id>', protocol: '<protocol>' };
86
+ ```
87
+
88
+ Replace placeholders with the user's input from Step 1's verification.
89
+
90
+ ### Step 3 — Add `peerBinary` to runtimes.cjs
91
+
92
+ Edit `scripts/lib/install/runtimes.cjs` to add an entry for the new peer. Mirror the shape of the 5 existing peer entries. Add the `peerBinary` field with platform-aware resolution:
93
+
94
+ ```js
95
+ {
96
+ id: '<new-peer-id>',
97
+ // ... existing fields per Phase 24 runtime matrix shape ...
98
+ peerBinary: process.platform === 'win32' ? '<peer-binary>.cmd' : '<peer-binary>',
99
+ }
100
+ ```
101
+
102
+ ### Step 4 — Add the capability-matrix entry
103
+
104
+ Edit `reference/peer-cli-capabilities.md`. Add a new row to the top capability matrix table AND a new per-peer section. Follow the existing format. Cite the verification evidence from Step 1.
105
+
106
+ ### Step 5 — Update the registry capability matrix
107
+
108
+ Edit `scripts/lib/peer-cli/registry.cjs`. Add the new peer to the `CAPABILITY_MATRIX` constant (and `KNOWN_PEERS` if that's a separate list). Mirror the shape of the 5 existing entries.
109
+
110
+ ### Step 6 — Verify the integration
111
+
112
+ Run, in this order, until each passes:
113
+
114
+ 1. `npx tsc --noEmit` — clean.
115
+ 2. `node --test tests/peer-cli-registry.test.cjs tests/peer-cli-adapters.test.cjs` — no regression on existing tests.
116
+ 3. `node --test tests/reference-registry.test.cjs` — capability-matrix doc is in registry.json (if you added it).
117
+ 4. `npm run validate:frontmatter` — no agent's `delegate_to:` field is broken by the new entry.
118
+
119
+ If any step fails, surface the error and offer to revert the changes.
120
+
121
+ ### Step 7 — Surface a 3-file footprint summary
122
+
123
+ ```
124
+ ## peer-cli-add summary
125
+
126
+ Added peer: <new-peer-id> (protocol: <protocol>)
127
+ Roles claimed: <role-1>, <role-2>
128
+
129
+ Files modified:
130
+ ✓ scripts/lib/peer-cli/adapters/<new-peer-id>.cjs (new)
131
+ ✓ scripts/lib/install/runtimes.cjs (added peerBinary entry)
132
+ ✓ reference/peer-cli-capabilities.md (added matrix row + per-peer section)
133
+ ✓ scripts/lib/peer-cli/registry.cjs (added to CAPABILITY_MATRIX)
134
+
135
+ Verification:
136
+ ✓ tsc clean
137
+ ✓ existing peer-cli tests pass
138
+ ✓ reference-registry round-trip valid
139
+ ✓ frontmatter validator: 0 violations
140
+
141
+ Next steps:
142
+ - Run /gdd:peers to confirm the new peer shows up in the capability matrix.
143
+ - Run skills/peer-cli-customize/SKILL.md to wire delegate_to: <new-peer-id>-<role> on specific agents.
144
+ - Phase 23.5 bandit will need ~5 cycles of data before the posterior surfaces a recommendation for this peer.
145
+ ```
146
+
147
+ ## Edge cases
148
+
149
+ - **Peer speaks neither ACP nor ASP** — gdd v1.27 ships only those two protocols. Stop and document the gap in `.design/RESEARCH.md` for a future phase to consider adding a new protocol layer.
150
+ - **Peer claims a role no existing peer claims** (e.g., `translate`) — fine, capability matrix is open. But document the role in `reference/peer-cli-capabilities.md` so future peers can compete on it.
151
+ - **Peer claims ALL roles** (e.g., a generalist peer) — accept, but flag in the per-peer section. Generalist peers are usually weaker than specialist peers; the bandit will sort it out via measurement.
152
+ - **Peer name conflicts with an existing peer-id** — fail. Peer-IDs must be globally unique. Suggest a disambiguating suffix.
153
+ - **User wants to add a peer for testing only** — same flow, but suggest committing under a separate branch and not adding to the install-time detection nudge until the peer is production-ready.
154
+
155
+ ## Cross-references
156
+
157
+ - `scripts/lib/peer-cli/registry.cjs` (Plan 27-05) — capability matrix data.
158
+ - `scripts/lib/peer-cli/adapters/*.cjs` (Plan 27-04) — adapter template.
159
+ - `scripts/lib/peer-cli/spawn-cmd.cjs` (Plan 27-03) — Windows .cmd handling.
160
+ - `reference/peer-cli-capabilities.md` (Plan 27-05) — capability-matrix doc.
161
+ - `skills/peer-cli-customize/SKILL.md` — once new peer is added, use customize to wire it on specific agents.
162
+ - `.planning/phases/27-peer-cli-delegation/CONTEXT.md` D-02, D-05 — decision lineage.
163
+
164
+ ## Record
165
+
166
+ After execution, append one JSONL line to `.design/skill-records.jsonl`:
167
+
168
+ ```json
169
+ {"skill": "peer-cli-add", "ts": "<ISO timestamp>", "new_peer": "<new-peer-id>", "protocol": "<protocol>", "roles_claimed": ["<role-1>"], "verification_passed": true}
170
+ ```