@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +74 -0
- package/README.md +10 -8
- package/SKILL.md +3 -0
- package/agents/README.md +29 -0
- package/package.json +2 -2
- package/reference/peer-cli-capabilities.md +151 -0
- package/reference/peer-protocols.md +266 -0
- package/reference/registry.json +14 -0
- package/reference/runtime-models.md +3 -3
- package/scripts/install.cjs +100 -1
- package/scripts/lib/bandit-router.cjs +214 -7
- package/scripts/lib/budget-enforcer.cjs +69 -1
- package/scripts/lib/event-stream/index.ts +14 -1
- package/scripts/lib/event-stream/types.ts +125 -1
- package/scripts/lib/install/runtimes.cjs +58 -0
- package/scripts/lib/peer-cli/acp-client.cjs +375 -0
- package/scripts/lib/peer-cli/adapters/codex.cjs +101 -0
- package/scripts/lib/peer-cli/adapters/copilot.cjs +79 -0
- package/scripts/lib/peer-cli/adapters/cursor.cjs +78 -0
- package/scripts/lib/peer-cli/adapters/gemini.cjs +81 -0
- package/scripts/lib/peer-cli/adapters/qwen.cjs +72 -0
- package/scripts/lib/peer-cli/asp-client.cjs +587 -0
- package/scripts/lib/peer-cli/broker-lifecycle.cjs +406 -0
- package/scripts/lib/peer-cli/registry.cjs +434 -0
- package/scripts/lib/peer-cli/spawn-cmd.cjs +149 -0
- package/scripts/lib/runtime-detect.cjs +1 -1
- package/scripts/lib/session-runner/index.ts +362 -0
- package/scripts/lib/session-runner/types.ts +60 -0
- package/scripts/validate-frontmatter.ts +159 -1
- package/skills/peer-cli-add/SKILL.md +170 -0
- package/skills/peer-cli-customize/SKILL.md +110 -0
- 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)`);
|