@hecer/yoke 1.12.0 → 1.13.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 (53) hide show
  1. package/.claude-plugin/plugin.json +3 -3
  2. package/.codex-plugin/plugin.json +2 -2
  3. package/CHANGELOG.md +19 -0
  4. package/README.md +39 -27
  5. package/canon/loop/prd.schema.md +2 -2
  6. package/canon/manifest.yaml +2 -2
  7. package/canon/skills/authoring-prd/SKILL.md +1 -1
  8. package/canon/skills/yoke-retrofit/SKILL.md +2 -2
  9. package/canon/skills/yoke-workflow/SKILL.md +1 -1
  10. package/canon/tools/graphify.md +1 -1
  11. package/canon/tools/playwright-mcp.md +1 -1
  12. package/canon/tools/serena.md +1 -1
  13. package/dist/agents/catalog.js +7 -0
  14. package/dist/agents/contracts.js +3 -1
  15. package/dist/agents/host.js +4 -0
  16. package/dist/agents/process-streams.js +62 -0
  17. package/dist/agents/process.js +43 -3
  18. package/dist/agents/providers.js +45 -3
  19. package/dist/agents/telemetry.js +99 -2
  20. package/dist/canon/manifest.js +2 -1
  21. package/dist/change/inbox.js +1 -1
  22. package/dist/cli.js +22 -24
  23. package/dist/dashboard/panels.js +2 -2
  24. package/dist/goals/command.js +3 -2
  25. package/dist/loop/claims.js +2 -1
  26. package/dist/loop/decision.js +3 -2
  27. package/dist/loop/parallel-command.js +4 -2
  28. package/dist/loop/prd.js +2 -1
  29. package/dist/loop/reporter.js +1 -0
  30. package/dist/loop/run-command.js +31 -10
  31. package/dist/prd/command.js +3 -3
  32. package/dist/quality/candidate-comparison.js +6 -1
  33. package/dist/quality/command.js +17 -2
  34. package/dist/quality/types.js +6 -1
  35. package/dist/retrofit/apply.js +87 -1
  36. package/dist/retrofit/config.js +8 -0
  37. package/dist/retrofit/detect.js +6 -0
  38. package/dist/retrofit/plan.js +6 -0
  39. package/dist/retrofit/planners/kilo.js +44 -0
  40. package/dist/retrofit/planners/opencode.js +44 -0
  41. package/dist/retrofit/planners/pi.js +24 -0
  42. package/dist/retrofit/skill-actions.js +3 -0
  43. package/dist/retrofit/tools.js +8 -0
  44. package/dist/review/command.js +3 -2
  45. package/dist/review/verdict.js +1 -1
  46. package/dist/routing/capability.js +2 -2
  47. package/dist/routing/planning.js +2 -0
  48. package/dist/routing/registry.js +3 -1
  49. package/dist/routing/router.js +7 -3
  50. package/dist/setup/command.js +13 -3
  51. package/docs/HARNESSES.md +81 -0
  52. package/gemini-extension.json +2 -2
  53. package/package.json +6 -2
@@ -24,6 +24,20 @@ const argsFor = (agent, permissions) => {
24
24
  const approval = permissions === 'read-only' ? 'plan' : 'auto-edit';
25
25
  return ['--approval-mode', approval, '--sandbox', '--output-format', 'stream-json', ...(permissions === 'safe' ? ['--allowed-tools', 'run_shell_command'] : [])];
26
26
  }
27
+ if (agent === 'opencode' || agent === 'kilo') {
28
+ const args = ['run', '--format', 'json', '--auto'];
29
+ if (permissions === 'read-only')
30
+ args.push('--agent', 'plan');
31
+ if (permissions === 'unsafe')
32
+ args.push('--dangerously-skip-permissions');
33
+ return args;
34
+ }
35
+ if (agent === 'pi') {
36
+ const args = ['--mode', 'json', '--no-session'];
37
+ if (permissions !== 'unsafe')
38
+ args.push('--tools', permissions === 'read-only' ? 'read,grep,find,ls' : 'read,bash,edit,write');
39
+ return args;
40
+ }
27
41
  if (permissions === 'unsafe')
28
42
  return ['--yolo', '--output-format', 'stream-json'];
29
43
  const approval = permissions === 'read-only' ? 'plan' : 'auto_edit';
@@ -43,6 +57,10 @@ export function buildProviderInvocation(agent, prompt, cwd, permissions = 'safe'
43
57
  throw new Error('Qwen does not support the reasoningEffort selection');
44
58
  if (agent === 'qwen' && parsedSelection.nativeMultiAgent === true)
45
59
  throw new Error('Qwen does not support enabling the nativeMultiAgent selection');
60
+ if (parsedSelection.provider && !['opencode', 'kilo', 'pi'].includes(agent))
61
+ throw new Error(`${agent} does not support an explicit provider selection`);
62
+ if (parsedSelection.variant && !['opencode', 'kilo', 'pi'].includes(agent))
63
+ throw new Error(`${agent} does not support a model variant selection`);
46
64
  const args = argsFor(agent, permissions);
47
65
  if (output.schemaFile !== undefined || output.jsonSchema !== undefined) {
48
66
  if (agent === 'codex' && output.schemaFile && output.jsonSchema === undefined) {
@@ -56,9 +74,11 @@ export function buildProviderInvocation(agent, prompt, cwd, permissions = 'safe'
56
74
  throw new Error('Inline structured output is unsupported by the Windows provider shell shim');
57
75
  args.push('--json-schema', schema);
58
76
  }
59
- else
77
+ else if (!['opencode', 'kilo', 'pi'].includes(agent))
60
78
  throw new Error(`${agent} structured output schema requires ${agent === 'codex' ? 'schemaFile' : agent === 'claude' ? 'jsonSchema' : 'a supported native schema option (unavailable)'}`);
61
79
  }
80
+ if (parsedSelection.provider && agent === 'pi')
81
+ args.push('--provider', parsedSelection.provider);
62
82
  if (parsedSelection.model) {
63
83
  const qualified = agent === 'qwen' && parsedSelection.model.includes('::')
64
84
  ? parsedSelection.model.match(/^(openai|anthropic|gemini|vertex-ai|qwen-oauth)::(.+)$/u) : undefined;
@@ -68,14 +88,32 @@ export function buildProviderInvocation(agent, prompt, cwd, permissions = 'safe'
68
88
  const model = ModelSelectionSchema.shape.model.parse(qualified[2]);
69
89
  args.push('--auth-type', qualified[1], '--model', model);
70
90
  }
71
- else
72
- args.push('--model', parsedSelection.model);
91
+ else {
92
+ const qualified = parsedSelection.provider && (agent === 'opencode' || agent === 'kilo')
93
+ ? `${parsedSelection.provider}/${parsedSelection.model}`
94
+ : parsedSelection.model;
95
+ args.push('--model', qualified);
96
+ }
73
97
  }
74
98
  if (parsedSelection.reasoningEffort) {
75
99
  if (agent === 'claude')
76
100
  args.push('--effort', parsedSelection.reasoningEffort);
77
101
  else if (agent === 'codex')
78
102
  args.push('--config', `model_reasoning_effort=${parsedSelection.reasoningEffort}`);
103
+ else if (agent === 'opencode' || agent === 'kilo')
104
+ args.push('--variant', parsedSelection.reasoningEffort);
105
+ else if (agent === 'pi')
106
+ args.push('--thinking', parsedSelection.reasoningEffort);
107
+ }
108
+ if (parsedSelection.variant) {
109
+ if (agent === 'opencode' || agent === 'kilo')
110
+ args.push('--variant', parsedSelection.variant);
111
+ else if (agent === 'pi') {
112
+ if (parsedSelection.reasoningEffort && parsedSelection.reasoningEffort !== parsedSelection.variant)
113
+ throw new Error('Pi reasoningEffort and variant selections must match');
114
+ if (!parsedSelection.reasoningEffort)
115
+ args.push('--thinking', parsedSelection.variant);
116
+ }
79
117
  }
80
118
  if (agent === 'codex' && parsedSelection.nativeMultiAgent === false)
81
119
  args.push('--disable', 'multi_agent');
@@ -89,6 +127,10 @@ export function buildProviderInvocation(agent, prompt, cwd, permissions = 'safe'
89
127
  args.push('--ignore-user-config');
90
128
  else if (agent === 'claude')
91
129
  args.push('--bare');
130
+ else if (agent === 'opencode' || agent === 'kilo')
131
+ args.push('--pure');
132
+ else if (agent === 'pi')
133
+ throw new Error('Pi does not support the bare startup selection');
92
134
  }
93
135
  if (agent === 'gemini' && parsedSelection.nativeMultiAgent === false) {
94
136
  return { command: process.execPath, args: [fileURLToPath(new URL('../../hooks/bounded-gemini.mjs', import.meta.url)), ...args], input: prompt, cwd };
@@ -12,6 +12,13 @@ function parseJson(value) {
12
12
  function isRecord(value) {
13
13
  return typeof value === 'object' && value !== null && !Array.isArray(value);
14
14
  }
15
+ function textContent(value) {
16
+ if (typeof value === 'string')
17
+ return value;
18
+ if (!Array.isArray(value))
19
+ return '';
20
+ return value.filter(isRecord).filter(part => part.type === 'text' && typeof part.text === 'string').map(part => part.text).join('');
21
+ }
15
22
  function directMachineResult(value) {
16
23
  return isRecord(value) && value.schemaVersion === 1 ? value : undefined;
17
24
  }
@@ -47,6 +54,20 @@ export function parseProviderResult(agent, output) {
47
54
  if (event.type === 'message' && event.role === 'assistant' && typeof event.content === 'string')
48
55
  fragments.push(event.content);
49
56
  break;
57
+ case 'opencode':
58
+ case 'kilo': {
59
+ const part = isRecord(event.part) ? event.part : undefined;
60
+ if (event.type === 'text' && typeof part?.text === 'string')
61
+ fragments.push(part.text);
62
+ break;
63
+ }
64
+ case 'pi':
65
+ if (event.type === 'message_end' && isRecord(event.message) && event.message.role === 'assistant') {
66
+ const text = textContent(event.message.content);
67
+ if (text)
68
+ fragments.push(text);
69
+ }
70
+ break;
50
71
  }
51
72
  }
52
73
  const joined = parseJson(fragments.join(''));
@@ -102,6 +123,10 @@ export function parseProviderTelemetry(agent, lines) {
102
123
  let totalCostUsd;
103
124
  let model;
104
125
  let reportedModels = [];
126
+ const harnessTotals = agent === 'opencode' || agent === 'kilo'
127
+ ? { input: 0, output: 0, cached: 0, cacheWrite: 0, reasoning: 0, cost: 0, hasInput: false, hasOutput: false, hasCached: false, hasCacheWrite: false, hasReasoning: false, hasCost: false }
128
+ : undefined;
129
+ let piUsage;
105
130
  for (const line of lines) {
106
131
  let parsed;
107
132
  try {
@@ -117,6 +142,43 @@ export function parseProviderTelemetry(agent, lines) {
117
142
  continue;
118
143
  const message = event.message && typeof event.message === 'object' ? event.message : undefined;
119
144
  const stats = event.stats && typeof event.stats === 'object' ? event.stats : undefined;
145
+ const part = event.part && typeof event.part === 'object' ? event.part : undefined;
146
+ const partTokens = part?.tokens && typeof part.tokens === 'object' ? part.tokens : undefined;
147
+ if (harnessTotals && (event.type === 'step_finish' || part?.type === 'step-finish') && partTokens) {
148
+ const cache = partTokens.cache && typeof partTokens.cache === 'object' ? partTokens.cache : undefined;
149
+ const stepInput = finite(partTokens.input);
150
+ const stepOutput = finite(partTokens.output);
151
+ const stepCached = finite(partTokens.cached ?? partTokens.cacheRead ?? cache?.read);
152
+ const stepCacheWrite = finite(partTokens.cacheWrite ?? cache?.write);
153
+ const stepReasoning = finite(partTokens.reasoning);
154
+ const stepCost = finite(part?.cost ?? event.cost);
155
+ if (stepInput !== undefined) {
156
+ harnessTotals.input += stepInput;
157
+ harnessTotals.hasInput = true;
158
+ }
159
+ if (stepOutput !== undefined) {
160
+ harnessTotals.output += stepOutput;
161
+ harnessTotals.hasOutput = true;
162
+ }
163
+ if (stepCached !== undefined) {
164
+ harnessTotals.cached += stepCached;
165
+ harnessTotals.hasCached = true;
166
+ }
167
+ if (stepCacheWrite !== undefined) {
168
+ harnessTotals.cacheWrite += stepCacheWrite;
169
+ harnessTotals.hasCacheWrite = true;
170
+ }
171
+ if (stepReasoning !== undefined) {
172
+ harnessTotals.reasoning += stepReasoning;
173
+ harnessTotals.hasReasoning = true;
174
+ }
175
+ if (stepCost !== undefined) {
176
+ harnessTotals.cost += stepCost;
177
+ harnessTotals.hasCost = true;
178
+ }
179
+ }
180
+ if (agent === 'pi' && event.type === 'message_update' && event.usage && typeof event.usage === 'object')
181
+ piUsage = event.usage;
120
182
  const usage = (event.usage && typeof event.usage === 'object'
121
183
  ? event.usage
122
184
  : message?.usage && typeof message.usage === 'object'
@@ -163,7 +225,7 @@ export function parseProviderTelemetry(agent, lines) {
163
225
  source.cached_input_tokens = aggregateCached;
164
226
  }
165
227
  const inValue = finite(source?.input_tokens ?? source?.inputTokens ?? source?.prompt_tokens ?? source?.promptTokenCount ?? source?.input);
166
- const cachedValue = finite(source?.cached_input_tokens ?? source?.cache_read_input_tokens ?? source?.cachedInputTokens ?? source?.cachedContentTokenCount ?? source?.cached);
228
+ const cachedValue = finite(source?.cached_input_tokens ?? source?.cache_read_input_tokens ?? source?.cachedInputTokens ?? source?.cacheRead ?? source?.cachedContentTokenCount ?? source?.cached);
167
229
  const cacheWriteValue = finite(source?.cache_write_input_tokens ?? source?.cache_creation_input_tokens ?? source?.cacheWriteInputTokens ?? source?.cacheWrite);
168
230
  const outValue = finite(source?.output_tokens ?? source?.outputTokens ?? source?.completion_tokens ?? source?.candidatesTokenCount ?? source?.output);
169
231
  const reasoningValue = finite(source?.reasoning_output_tokens ?? source?.reasoningOutputTokens ?? source?.thoughtsTokenCount ?? source?.thoughts);
@@ -177,13 +239,48 @@ export function parseProviderTelemetry(agent, lines) {
177
239
  outputTokens = outValue;
178
240
  if (reasoningValue !== undefined)
179
241
  reasoningOutputTokens = reasoningValue;
180
- const costValue = finite(event.total_cost_usd ?? event.totalCostUsd ?? stats?.total_cost_usd);
242
+ const usageCost = isRecord(usage?.cost) ? finite(usage.cost.total) : undefined;
243
+ const costValue = finite(event.total_cost_usd ?? event.totalCostUsd ?? stats?.total_cost_usd ?? usageCost);
181
244
  if (costValue !== undefined)
182
245
  totalCostUsd = costValue;
183
246
  const eventModel = event.model ?? message?.model ?? firstModel?.[0];
184
247
  if (typeof eventModel === 'string' && eventModel && reportedModels.length <= 1)
185
248
  model = eventModel;
186
249
  }
250
+ if (piUsage) {
251
+ const piInput = finite(piUsage.input);
252
+ const piOutput = finite(piUsage.output);
253
+ const piCached = finite(piUsage.cacheRead);
254
+ const piCacheWrite = finite(piUsage.cacheWrite);
255
+ const piReasoning = finite(piUsage.reasoning);
256
+ const piCost = isRecord(piUsage.cost) ? finite(piUsage.cost.total) : undefined;
257
+ if (piInput !== undefined)
258
+ inputTokens = piInput;
259
+ if (piOutput !== undefined)
260
+ outputTokens = piOutput;
261
+ if (piCached !== undefined)
262
+ cachedInputTokens = piCached;
263
+ if (piCacheWrite !== undefined)
264
+ cacheWriteInputTokens = piCacheWrite;
265
+ if (piReasoning !== undefined)
266
+ reasoningOutputTokens = piReasoning;
267
+ if (piCost !== undefined)
268
+ totalCostUsd = piCost;
269
+ }
270
+ if (harnessTotals) {
271
+ if (harnessTotals.hasInput)
272
+ inputTokens = harnessTotals.input;
273
+ if (harnessTotals.hasOutput)
274
+ outputTokens = harnessTotals.output;
275
+ if (harnessTotals.hasCached)
276
+ cachedInputTokens = harnessTotals.cached;
277
+ if (harnessTotals.hasCacheWrite)
278
+ cacheWriteInputTokens = harnessTotals.cacheWrite;
279
+ if (harnessTotals.hasReasoning)
280
+ reasoningOutputTokens = harnessTotals.reasoning;
281
+ if (harnessTotals.hasCost)
282
+ totalCostUsd = harnessTotals.cost;
283
+ }
187
284
  if (inputTokens === undefined || outputTokens === undefined) {
188
285
  const partialUsage = {
189
286
  ...(inputTokens !== undefined ? { inputTokens } : {}),
@@ -1,7 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import { parse } from 'yaml';
3
3
  import { readFileSync } from 'node:fs';
4
- export const AgentSchema = z.enum(['claude', 'codex', 'gemini', 'qwen']);
4
+ import { AgentSchema } from '../agents/contracts.js';
5
+ export { AgentSchema };
5
6
  export const InvocationSchema = z.enum(['auto', 'manual']);
6
7
  export const SkillEntrySchema = z.object({
7
8
  id: z.string().min(1),
@@ -104,7 +104,7 @@ export function buildChangePrompt(request, proposalPath, stories, brief = '') {
104
104
  'Every proposed story must have passes: false and 2-5 structured acceptance criteria.',
105
105
  'Every criterion must have a stable id, behavioral text, and one or more executable verify commands.',
106
106
  'Each criterion id must appear in every verify command; each entry must be one approved test command without shell control operators.',
107
- 'Use only claude, codex, or gemini if an optional agent affinity is useful.',
107
+ 'Use only a configured Yoke harness if an optional agent affinity is useful (claude, codex, gemini, qwen, opencode, kilo, or pi).',
108
108
  '',
109
109
  `Write ONLY a YAML array of the NEW stories to this exact file: ${proposalPath}`,
110
110
  'Do not edit .yoke/prd.yaml or any source file. Do not commit.',
package/dist/cli.js CHANGED
@@ -19,6 +19,7 @@ import { runLoopCleanup } from './loop/cleanup.js';
19
19
  import { runFlowSmoke } from './smoke/command.js';
20
20
  import { maybeNotifyUpdate, currentYokeVersion } from './update/check.js';
21
21
  import { runUpgrade } from './update/upgrade.js';
22
+ import { AGENT_LIST, isSupportedAgent, SUPPORTED_AGENTS } from './agents/catalog.js';
22
23
  import { printAudit, runAudit } from './audit/command.js';
23
24
  import { runSetup } from './setup/command.js';
24
25
  import { pendingChanges, queueChange } from './change/inbox.js';
@@ -105,7 +106,7 @@ export function main(argv) {
105
106
  switch (cmd) {
106
107
  case 'setup': {
107
108
  const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
108
- const valid = ['claude', 'codex', 'gemini', 'qwen'];
109
+ const valid = [...SUPPORTED_AGENTS];
109
110
  const hostArg = rest.find(a => a.startsWith('--host='))?.slice('--host='.length);
110
111
  if (hostArg && !valid.includes(hostArg)) {
111
112
  console.error(`Invalid --host value: ${hostArg}`);
@@ -115,7 +116,7 @@ export function main(argv) {
115
116
  const agentTokens = agentArg === 'all' ? valid : agentArg?.split(',').map(a => a.trim());
116
117
  const invalidAgents = agentTokens?.filter(a => !valid.includes(a)) ?? [];
117
118
  if (agentArg && (invalidAgents.length > 0 || agentTokens?.length === 0)) {
118
- console.error(`Invalid --agent value: ${agentArg} (expected claude,codex,gemini,qwen|all)`);
119
+ console.error(`Invalid --agent value: ${agentArg} (expected ${AGENT_LIST}|all)`);
119
120
  return 1;
120
121
  }
121
122
  const agents = agentTokens ? [...new Set(agentTokens)] : undefined;
@@ -234,7 +235,7 @@ export function main(argv) {
234
235
  }
235
236
  if (sub === 'run' || sub === 'resume') {
236
237
  const provider = value('runner') ?? 'codex';
237
- if (!['codex', 'claude', 'gemini', 'qwen'].includes(provider))
238
+ if (!SUPPORTED_AGENTS.includes(provider))
238
239
  throw new Error('Unknown runner');
239
240
  return runProjectGoal(targetDir, { provider: provider, selection: { model: value('model') } }).then(goal => {
240
241
  console.log(JSON.stringify(goal));
@@ -277,7 +278,7 @@ export function main(argv) {
277
278
  const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
278
279
  const loop = rest.includes('--loop');
279
280
  const agentArg = rest.find(a => a.startsWith('--agent='))?.slice('--agent='.length);
280
- const all = ['claude', 'codex', 'gemini', 'qwen'];
281
+ const all = [...SUPPORTED_AGENTS];
281
282
  const agents = !agentArg || agentArg === 'all'
282
283
  ? (agentArg === 'all' ? all : undefined)
283
284
  : agentArg.split(',').filter((a) => all.includes(a));
@@ -476,18 +477,17 @@ export function main(argv) {
476
477
  return 1;
477
478
  }
478
479
  const runnerArg = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
479
- const valid = ['claude', 'codex', 'gemini', 'qwen'];
480
- const agent = runnerArg && valid.includes(runnerArg) ? runnerArg : undefined;
480
+ const agent = runnerArg && isSupportedAgent(runnerArg) ? runnerArg : undefined;
481
481
  if (runnerArg && !agent) {
482
- console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini|qwen)`);
482
+ console.error(`Invalid --runner value: ${runnerArg} (expected ${AGENT_LIST})`);
483
483
  return 1;
484
484
  }
485
485
  const isolate = rest.includes('--isolate') ? true : rest.includes('--no-isolate') ? false : undefined;
486
486
  const reviewerArg = rest.find(a => a.startsWith('--reviewer='))?.slice('--reviewer='.length);
487
487
  let reviewer;
488
488
  if (reviewerArg) {
489
- if (!valid.includes(reviewerArg)) {
490
- console.error(`Invalid --reviewer value: ${reviewerArg} (expected claude|codex|gemini|qwen)`);
489
+ if (!isSupportedAgent(reviewerArg)) {
490
+ console.error(`Invalid --reviewer value: ${reviewerArg} (expected ${AGENT_LIST})`);
491
491
  return 1;
492
492
  }
493
493
  reviewer = reviewerArg;
@@ -530,19 +530,19 @@ export function main(argv) {
530
530
  }
531
531
  return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, resumeWorktree: rest.includes('--resume-worktree'), parallel, parallelAuto: parallelArg === '--parallel=auto', reviewer, review, allowSelfReview, timeoutMinutes, json, routing, onAmbiguity: oaArg, decisionPolicy: dpArg, permissions, ...qualityFlags.options });
532
532
  }
533
- console.log('usage: yoke loop <on|off|status|decision|answer|resume [--discard] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N]|cleanup [--remove-worktrees] [--discard-stale-recovery]|run [--max=N] [--parallel=<auto|N>] [--runner=<claude|codex|gemini|qwen>] [--reviewer=<claude|codex|gemini|qwen>] [--review] [--allow-self-review] [--routing|--no-routing] [--isolate|--no-isolate] [--unsafe] [--timeout=<minutes>] [--decision-policy=<auto|critical>] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N] [--json]> [targetDir]');
533
+ console.log(`usage: yoke loop <on|off|status|decision|answer|resume [--discard] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N]|cleanup [--remove-worktrees] [--discard-stale-recovery]|run [--max=N] [--parallel=<auto|N>] [--runner=<${AGENT_LIST}>] [--reviewer=<${AGENT_LIST}>] [--review] [--allow-self-review] [--routing|--no-routing] [--isolate|--no-isolate] [--unsafe] [--timeout=<minutes>] [--decision-policy=<auto|critical>] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N] [--json]> [targetDir]`);
534
534
  return 1;
535
535
  }
536
536
  case 'new': {
537
537
  const dir = rest.find(a => !a.startsWith('-'));
538
538
  if (!dir) {
539
- console.error('usage: yoke new <dir> [--idea="..."] [--agent=claude,codex,gemini,qwen|all] [--runner=<claude|codex|gemini|qwen>] [--loop]');
539
+ console.error(`usage: yoke new <dir> [--idea="..."] [--agent=${AGENT_LIST}|all] [--runner=<${AGENT_LIST}>] [--loop]`);
540
540
  return 1;
541
541
  }
542
542
  const idea = rest.find(a => a.startsWith('--idea='))?.slice('--idea='.length);
543
543
  const loop = rest.includes('--loop');
544
544
  const agentArg = rest.find(a => a.startsWith('--agent='))?.slice('--agent='.length);
545
- const all = ['claude', 'codex', 'gemini', 'qwen'];
545
+ const all = [...SUPPORTED_AGENTS];
546
546
  const agents = !agentArg || agentArg === 'all'
547
547
  ? (agentArg === 'all' ? all : undefined)
548
548
  : agentArg.split(',').filter((a) => all.includes(a));
@@ -550,8 +550,8 @@ export function main(argv) {
550
550
  console.warn('Unknown agent(s) in --agent; falling back to detection');
551
551
  }
552
552
  const runnerArg = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
553
- if (runnerArg && !all.includes(runnerArg)) {
554
- console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini|qwen)`);
553
+ if (runnerArg && !isSupportedAgent(runnerArg)) {
554
+ console.error(`Invalid --runner value: ${runnerArg} (expected ${AGENT_LIST})`);
555
555
  return 1;
556
556
  }
557
557
  return runNew(dir, { idea, agents, runner: runnerArg, loop });
@@ -561,7 +561,7 @@ export function main(argv) {
561
561
  const targetDir = rest.slice(1).find(a => !a.startsWith('-')) ?? '.';
562
562
  if (sub === 'assess') {
563
563
  const runner = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
564
- if (runner && !['codex', 'claude', 'gemini', 'qwen'].includes(runner)) {
564
+ if (runner && !SUPPORTED_AGENTS.includes(runner)) {
565
565
  console.error('Invalid planning runner');
566
566
  return 1;
567
567
  }
@@ -570,13 +570,12 @@ export function main(argv) {
570
570
  if (sub === 'draft') {
571
571
  const idea = rest.find(a => a.startsWith('--idea='))?.slice('--idea='.length);
572
572
  if (!idea) {
573
- console.error('usage: yoke prd draft [dir] --idea="..." [--runner=<claude|codex|gemini|qwen>] [--force] [--timeout=<minutes>]');
573
+ console.error(`usage: yoke prd draft [dir] --idea="..." [--runner=<${AGENT_LIST}>] [--force] [--timeout=<minutes>]`);
574
574
  return 1;
575
575
  }
576
- const valid = ['claude', 'codex', 'gemini', 'qwen'];
577
576
  const runnerArg = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
578
- if (runnerArg && !valid.includes(runnerArg)) {
579
- console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini|qwen)`);
577
+ if (runnerArg && !isSupportedAgent(runnerArg)) {
578
+ console.error(`Invalid --runner value: ${runnerArg} (expected ${AGENT_LIST})`);
580
579
  return 1;
581
580
  }
582
581
  const force = rest.includes('--force');
@@ -594,7 +593,7 @@ export function main(argv) {
594
593
  }
595
594
  if (sub === 'check')
596
595
  return runPrdCheck(targetDir);
597
- console.log('usage: yoke prd <draft|check|assess> [dir] [--idea="..."] [--runner=<claude|codex|gemini|qwen>] [--story=<id>] [--reassess] [--force] [--timeout=<minutes>]');
596
+ console.log(`usage: yoke prd <draft|check|assess> [dir] [--idea="..."] [--runner=<${AGENT_LIST}>] [--story=<id>] [--reassess] [--force] [--timeout=<minutes>]`);
598
597
  return 1;
599
598
  }
600
599
  case 'context': {
@@ -609,10 +608,9 @@ export function main(argv) {
609
608
  }
610
609
  case 'review': {
611
610
  const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
612
- const valid = ['claude', 'codex', 'gemini', 'qwen'];
613
611
  const reviewerArg = rest.find(a => a.startsWith('--reviewer='))?.slice('--reviewer='.length);
614
- if (reviewerArg && !valid.includes(reviewerArg)) {
615
- console.error(`Invalid --reviewer value: ${reviewerArg} (expected claude|codex|gemini|qwen)`);
612
+ if (reviewerArg && !isSupportedAgent(reviewerArg)) {
613
+ console.error(`Invalid --reviewer value: ${reviewerArg} (expected ${AGENT_LIST})`);
616
614
  return 1;
617
615
  }
618
616
  const base = rest.find(a => a.startsWith('--base='))?.slice('--base='.length);
@@ -659,7 +657,7 @@ export function main(argv) {
659
657
  return runUpgrade();
660
658
  default:
661
659
  console.log('Project workflows: yoke check [dir] [--json|--protect] | goal set|run|resume|pause|status|handoff|budget [dir] | projects add|list|remove | dashboard [dir] [--port=N]');
662
- console.log('usage: yoke <setup [dir] | new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=claude,codex,gemini,qwen|all] [--code-graph=graphify|serena] [--loop] | change <add|status> [dir] | prd <draft|check|assess> [dir] | loop <on|off|status|decision|answer|resume|run|cleanup> | context <init|status> | review [dir] [--reviewer=<claude|codex|gemini|qwen>] [--base=<ref>] [--focus="..."] | design-scan [dir] [--max=N] [--report] | flow-smoke [dir] [--url=<baseUrl>] [--label=<name>] | upgrade>');
660
+ console.log(`usage: yoke <setup [dir] | new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=${AGENT_LIST}|all] [--code-graph=graphify|serena] [--loop] | change <add|status> [dir] | prd <draft|check|assess> [dir] | loop <on|off|status|decision|answer|resume|run|cleanup> | context <init|status> | review [dir] [--reviewer=<${AGENT_LIST}>] [--base=<ref>] [--focus="..."] | design-scan [dir] [--max=N] [--report] | flow-smoke [dir] [--url=<baseUrl>] [--label=<name>] | upgrade>`);
663
661
  return cmd ? 1 : 0;
664
662
  }
665
663
  }
@@ -45,12 +45,12 @@ function renderNow(p){
45
45
  if(goal.pendingAttempt)summary.append(row('Goal attempt',goal.pendingAttempt.provider+' · requested model '+(goal.pendingAttempt.model||'provider default')+' · started '+goal.pendingAttempt.startedAt,state.stale?'unconfirmed':'running'));
46
46
  if(status.execution&&status.state==='running')summary.append(el('p',state.stale?'Execution was reported but is now unconfirmed.':status.execution.provider+' · requested model '+(status.execution.requestedModel||'provider default')+' · elapsed '+duration(Date.now()-Date.parse(status.execution.startedAt))));
47
47
  if(status.story&&!workers.length)summary.append(row(status.storyTitle||status.story,status.phase||'Phase unknown',reportedState));
48
- for(const worker of workers)summary.append(row(worker.storyTitle||worker.story,[worker.selectedProvider||worker.provider,'requested model '+(worker.selectedModel||worker.model||'provider default'),worker.phase||worker.lifecycle||'working',worker.startedAt?(state.stale?'last started '+worker.startedAt:'elapsed '+duration(Date.now()-Date.parse(worker.startedAt))):null,worker.quality?'repair '+worker.quality.usedRepairs:null].filter(Boolean).join(' · '),state.stale?'unconfirmed':worker.lifecycle||'running'));
48
+ for(const worker of workers)summary.append(row(worker.storyTitle||worker.story,[worker.provider+(worker.selectedProvider&&worker.selectedProvider!==worker.provider?' ('+worker.selectedProvider+')':''),'requested model '+(worker.selectedModel||worker.model||'provider default'),worker.selectedVariant?'variant '+worker.selectedVariant:null,worker.phase||worker.lifecycle||'working',worker.startedAt?(state.stale?'last started '+worker.startedAt:'elapsed '+duration(Date.now()-Date.parse(worker.startedAt))):null,worker.quality?'repair '+worker.quality.usedRepairs:null].filter(Boolean).join(' · '),state.stale?'unconfirmed':worker.lifecycle||'running'));
49
49
  if(status.parallel?.integrator){const worker=status.parallel.integrator;summary.append(row('Integration: '+(worker.storyTitle||worker.story),worker.phase||'checking',state.stale?'unconfirmed':'integrating'))}
50
50
  if(!workers.length&&!status.story&&!goal.pendingAttempt)summary.append(el('p','No task currently reported.','empty'));
51
51
  summary.append(el('p','Status refreshes every 5 seconds. Requested models are not proof of the model actually used; reported model identities appear under Usage & time.','muted'));content.append(summary);
52
52
  const tasks=panel('Tasks');for(const task of p.stories||[])tasks.append(row(task.title,task.id+(task.needs?.length?' · after '+task.needs.join(', '):'')+' · '+taskTiming(task,p.estimate),task.passes?'passed':workers.some(worker=>worker.story===task.id)?state.stale?'unconfirmed':'running':'open'));content.append(tasks);
53
- const routing=panel('Model selection');for(const [id,decision] of Object.entries(status.routingDecisions||{})){routing.append(row(id,decision.provider+' / '+(decision.model||'provider default')+(decision.reasoningEffort?' · '+decision.reasoningEffort:''),decision.profile));routing.append(el('p',decision.reason));routing.append(el('p','Next escalation: '+decision.next,'muted'))}if(!Object.keys(status.routingDecisions||{}).length)routing.append(el('p','No recorded model selection for this run.','empty'));content.append(routing);
53
+ const routing=panel('Model selection');for(const [id,decision] of Object.entries(status.routingDecisions||{})){routing.append(row(id,decision.provider+(decision.providerModel?' ('+decision.providerModel+')':'')+' / '+(decision.model||'provider default')+(decision.reasoningEffort?' · '+decision.reasoningEffort:'')+(decision.variant?' · variant '+decision.variant:''),decision.profile));routing.append(el('p',decision.reason));routing.append(el('p','Next escalation: '+decision.next,'muted'))}if(!Object.keys(status.routingDecisions||{}).length)routing.append(el('p','No recorded model selection for this run.','empty'));content.append(routing);
54
54
  const activity=panel('Recent activity');for(const event of (p.events||[]).slice(-16).reverse())activity.append(row(event.storyId||event.type,event.timestamp+(event.phase?' · '+event.phase:'')+(Number.isFinite(event.durationMs)?' · '+duration(event.durationMs):''),event.outcome||event.type));content.append(activity);
55
55
  }
56
56
  function renderUsageHistory(current,previous){
@@ -10,10 +10,11 @@ import { acquireLock, releaseLock, isPidAlive } from '../loop/lock.js';
10
10
  import { reapProviderProcesses, isProviderTreeAlive } from '../loop/cleanup.js';
11
11
  import { killProcessTreeForCleanup } from '../loop/watchdog.js';
12
12
  import { buildProviderInvocation, startProviderProcess } from '../agents/providers.js';
13
+ import { AgentSchema } from '../agents/contracts.js';
13
14
  import { appendEvent } from '../observability/events.js';
14
15
  import { statePath } from '../workspace/state.js';
15
- const Attempt = z.object({ provider: z.enum(['codex', 'claude', 'gemini', 'qwen']), model: z.string().optional(), startedAt: z.string(), durationMs: z.number().nonnegative(), success: z.boolean(), summary: z.string(), checkId: z.string(), inputTokens: z.number().nonnegative().optional(), outputTokens: z.number().nonnegative().optional() });
16
- const Goal = z.object({ version: z.literal(1), id: z.string().uuid(), objective: z.string().trim().min(1).max(16000), status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), createdAt: z.string(), updatedAt: z.string(), maxAttempts: z.number().int().min(1).max(20), maxMinutes: z.number().positive().max(1440), tokenBudget: z.number().int().positive().optional(), attempts: z.array(Attempt), reason: z.string().optional(), lastCheck: z.string().optional(), pendingAttempt: z.object({ provider: z.enum(['codex', 'claude', 'gemini', 'qwen']), model: z.string().optional(), startedAt: z.string().datetime() }).optional() }).strict();
16
+ const Attempt = z.object({ provider: AgentSchema, model: z.string().optional(), startedAt: z.string(), durationMs: z.number().nonnegative(), success: z.boolean(), summary: z.string(), checkId: z.string(), inputTokens: z.number().nonnegative().optional(), outputTokens: z.number().nonnegative().optional() });
17
+ const Goal = z.object({ version: z.literal(1), id: z.string().uuid(), objective: z.string().trim().min(1).max(16000), status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), createdAt: z.string(), updatedAt: z.string(), maxAttempts: z.number().int().min(1).max(20), maxMinutes: z.number().positive().max(1440), tokenBudget: z.number().int().positive().optional(), attempts: z.array(Attempt), reason: z.string().optional(), lastCheck: z.string().optional(), pendingAttempt: z.object({ provider: AgentSchema, model: z.string().optional(), startedAt: z.string().datetime() }).optional() }).strict();
17
18
  const goalPath = (root) => statePath(root, 'goal.json');
18
19
  function save(root, goal) {
19
20
  const parsed = Goal.parse({ ...goal, updatedAt: new Date().toISOString() });
@@ -4,6 +4,7 @@ import { z } from 'zod';
4
4
  import { isPidAlive } from './lock.js';
5
5
  import { publishClaimFile, replaceClaimFile, withClaimOperations } from './claim-lease.js';
6
6
  import { storyPathSegment } from './prd.js';
7
+ import { AgentSchema } from '../agents/contracts.js';
7
8
  export const CLAIM_SCHEMA_VERSION = 2;
8
9
  const LegacyClaimSchema = z.object({
9
10
  storyId: z.string().min(1),
@@ -18,7 +19,7 @@ const RichClaimSchema = LegacyClaimSchema.extend({
18
19
  heartbeatAt: z.string().min(1),
19
20
  baseCommit: z.string().min(1).optional(),
20
21
  worktree: z.string().min(1).optional(),
21
- provider: z.enum(['claude', 'codex', 'gemini', 'qwen']).optional(),
22
+ provider: AgentSchema.optional(),
22
23
  model: z.string().min(1).optional(),
23
24
  role: z.enum(['implementation', 'quality', 'repair', 'review']).optional(),
24
25
  cancellation: z.object({
@@ -10,6 +10,7 @@ import { resolveCommitIdentity } from './identity.js';
10
10
  import { commitPaths, realGitOps } from './git.js';
11
11
  import { acquireLock, releaseLock } from './lock.js';
12
12
  import { loadPrd, selectNextStory } from './prd.js';
13
+ import { AgentSchema } from '../agents/contracts.js';
13
14
  const singleLine = (max) => z.string().trim().min(1).max(max).refine(value => !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value), 'must be a single line without control or bidi-formatting characters');
14
15
  const MAX_STATE_BYTES = 256 * 1024;
15
16
  const DecisionOptionSchema = z.object({
@@ -60,9 +61,9 @@ export const DecisionResumeSchema = z.object({
60
61
  answered: z.boolean().optional(),
61
62
  /** Absent preserves the default unlimited run across a critical-decision resume. */
62
63
  maxIterations: z.number().int().positive().optional(),
63
- agent: z.enum(['claude', 'codex', 'gemini', 'qwen']).optional(),
64
+ agent: AgentSchema.optional(),
64
65
  isolate: z.boolean().optional(),
65
- reviewer: z.enum(['claude', 'codex', 'gemini', 'qwen']).optional(),
66
+ reviewer: AgentSchema.optional(),
66
67
  review: z.boolean().optional(),
67
68
  allowSelfReview: z.boolean().optional(),
68
69
  timeoutMinutes: z.number().nonnegative().optional(),
@@ -255,7 +255,7 @@ function asyncRunner(input, provider, signal, workerId) {
255
255
  if (input.routing) {
256
256
  const routed = makeAsyncAdaptiveRunner({
257
257
  parent: input.routing.strategy === 'capability' ? input.runnerAgent : provider.provider,
258
- parentSelection: input.routing.strategy === 'capability' ? input.selection : { ...input.selection, model: provider.model, reasoningEffort: provider.reasoningEffort, nativeMultiAgent: false },
258
+ parentSelection: input.routing.strategy === 'capability' ? input.selection : { ...input.selection, provider: provider.providerModel, model: provider.model, reasoningEffort: provider.reasoningEffort, variant: provider.variant, nativeMultiAgent: false },
259
259
  projectRoot: input.targetDir,
260
260
  workers: input.routing.workers,
261
261
  rules: input.routing.rules,
@@ -276,7 +276,7 @@ function asyncRunner(input, provider, signal, workerId) {
276
276
  makeWorker: (agent, selection) => async (context) => {
277
277
  if (signal?.aborted)
278
278
  return { success: false, summary: 'Routing cancelled before worker start' };
279
- input.reporter.parallelWorker?.({ story: context.story.id, storyTitle: context.story.title, provider: agent, selectedProvider: agent, selectedModel: selection.model, phase: 'implementing' });
279
+ input.reporter.parallelWorker?.({ story: context.story.id, storyTitle: context.story.title, provider: agent, selectedProvider: selection.provider ?? agent, selectedModel: selection.model, selectedVariant: selection.variant, phase: 'implementing' });
280
280
  const run = makeAsyncRunner(agent, { onAmbiguity: input.onAmbiguity, permissions: input.permissions, selection,
281
281
  process: { idleTimeoutMs: input.idleMs, signal, workerId } });
282
282
  return providerProcessResultToAgentResult(agent, context.story.id, await run(context).completion);
@@ -292,10 +292,12 @@ function asyncRunner(input, provider, signal, workerId) {
292
292
  permissions: input.permissions,
293
293
  selection: {
294
294
  ...selection,
295
+ ...(provider.providerModel ? { provider: provider.providerModel } : {}),
295
296
  ...(provider.provider !== 'gemini' && globalBare !== undefined ? { bare: globalBare } : {}),
296
297
  nativeMultiAgent: false,
297
298
  ...(provider.model ? { model: provider.model } : {}),
298
299
  ...(provider.reasoningEffort ? { reasoningEffort: provider.reasoningEffort } : {}),
300
+ ...(provider.variant ? { variant: provider.variant } : {}),
299
301
  },
300
302
  process: { idleTimeoutMs: input.idleMs, signal, workerId },
301
303
  });
package/dist/loop/prd.js CHANGED
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { readFileSync, writeFileSync } from 'node:fs';
3
3
  import { parse, stringify } from 'yaml';
4
4
  import { z } from 'zod';
5
+ import { AgentSchema } from '../agents/contracts.js';
5
6
  import { StoryQualityDeclarationSchema } from '../quality/types.js';
6
7
  import { validWriteScope } from './scheduler.js';
7
8
  import { AssessmentSchema } from '../routing/assessment.js';
@@ -42,7 +43,7 @@ export const StorySchema = z.object({
42
43
  area: z.string().min(1).optional(),
43
44
  /** Advisory relative file/directory scopes; not a filesystem permission boundary. */
44
45
  writes: z.array(z.string().min(1).max(500).refine(validWriteScope, 'Write scopes must be relative non-glob paths without traversal')).max(100).optional(),
45
- agent: z.enum(['claude', 'codex', 'gemini', 'qwen']).optional(),
46
+ agent: AgentSchema.optional(),
46
47
  /** Inbox request that created this story. Used for idempotent append-only intake. */
47
48
  sourceChange: z.string().min(1).optional(),
48
49
  quality: StoryQualityDeclarationSchema.optional(),
@@ -257,6 +257,7 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
257
257
  startedAt: previous.startedAt,
258
258
  selectedProvider: previous.selectedProvider,
259
259
  selectedModel: previous.selectedModel,
260
+ selectedVariant: previous.selectedVariant,
260
261
  ...(previous.model ? { model: previous.model } : {}),
261
262
  provider: previous.provider,
262
263
  ...(previous.phase ? { phase: previous.phase } : {}),