@hecer/yoke 1.20.0 → 1.22.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 (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +46 -0
  4. package/README.md +6 -1
  5. package/bench/analyze-codex-comparison.mjs +90 -17
  6. package/bench/compare-codex.mjs +159 -36
  7. package/bench/result-schema.mjs +132 -0
  8. package/canon/manifest.yaml +1 -1
  9. package/dist/agents/pi-telemetry.js +2 -1
  10. package/dist/agents/process-streams.js +12 -64
  11. package/dist/agents/provider-selection.js +12 -0
  12. package/dist/agents/telemetry.js +52 -52
  13. package/dist/change/inbox.js +8 -3
  14. package/dist/check/command.js +69 -17
  15. package/dist/check/delivery.js +121 -0
  16. package/dist/cli.js +86 -8
  17. package/dist/code-intelligence/budgets.js +138 -0
  18. package/dist/code-intelligence/contracts.js +2 -0
  19. package/dist/code-intelligence/coordinator.js +156 -84
  20. package/dist/code-intelligence/evidence.js +87 -34
  21. package/dist/code-intelligence/mcp-client.js +10 -2
  22. package/dist/code-intelligence/mcp-server.js +10 -10
  23. package/dist/dashboard/analytics.js +5 -3
  24. package/dist/goals/command.js +183 -53
  25. package/dist/goals/usage.js +87 -0
  26. package/dist/loop/candidate-cleanup.js +47 -17
  27. package/dist/loop/candidates.js +17 -11
  28. package/dist/loop/cleanup.js +100 -0
  29. package/dist/loop/dispatcher.js +89 -26
  30. package/dist/loop/failure.js +104 -0
  31. package/dist/loop/gate-snapshot.js +19 -0
  32. package/dist/loop/git.js +1 -1
  33. package/dist/loop/loop.js +100 -66
  34. package/dist/loop/parallel-adapters.js +22 -4
  35. package/dist/loop/parallel-command.js +32 -5
  36. package/dist/loop/recovery.js +23 -5
  37. package/dist/loop/reporter.js +21 -4
  38. package/dist/loop/run-command.js +102 -48
  39. package/dist/loop/runner.js +5 -4
  40. package/dist/loop/worker.js +145 -91
  41. package/dist/observability/history.js +1 -1
  42. package/dist/observability/invocation.js +42 -0
  43. package/dist/observability/usage.js +17 -0
  44. package/dist/prd/command.js +20 -7
  45. package/dist/prd/decompose.js +5 -2
  46. package/dist/retrofit/command.js +22 -0
  47. package/dist/retrofit/config.js +26 -1
  48. package/dist/retrofit/gitignore.js +2 -0
  49. package/dist/retrofit/planners/claude.js +4 -4
  50. package/dist/retrofit/wsl.js +23 -3
  51. package/dist/routing/attempts.js +241 -0
  52. package/dist/routing/capability.js +13 -9
  53. package/dist/routing/optimization.js +73 -0
  54. package/dist/routing/registry.js +7 -1
  55. package/dist/routing/router.js +280 -127
  56. package/dist/setup/command.js +54 -6
  57. package/dist/smoke/command.js +302 -74
  58. package/docs/BENCHMARK-MANIFEST.md +131 -0
  59. package/docs/CODE-INTELLIGENCE.md +43 -1
  60. package/docs/CODEX-COMPARISON-2026-09-29.md +15 -0
  61. package/docs/DELIVERY-JOURNEYS.md +199 -0
  62. package/docs/ECONOMIC-ROUTING.md +180 -0
  63. package/docs/GOALS.md +61 -4
  64. package/docs/RELEASE-VALIDATION-1.22.0.md +115 -0
  65. package/docs/parallel-execution.md +37 -9
  66. package/gemini-extension.json +1 -1
  67. package/package.json +1 -1
@@ -1,9 +1,17 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { buildWatchdogInvocation, makeRunner, runCapturedAgent, runnerInvocation, contextBlockFor, } from '../loop/runner.js';
2
3
  import { isAcceptanceCriterion, criterionCommandProblem } from '../loop/prd.js';
3
4
  import { historyForWorkers, projectHash, readRoutingObservations, recordRoutingObservation, storyHash } from './registry.js';
4
5
  import { assessmentInstructions, parseAssessment, tiers } from './assessment.js';
5
6
  import { chooseCapability, readAssessment, saveAssessment, routingAssessmentKey, knownInfrastructureFailure } from './capability.js';
6
7
  import { readPlanningFile } from './contracts.js';
8
+ import { finishRoutingAttempt, markRoutingAttemptUsageIncomplete, readRoutingAttempts, recordRoutingAttemptUsage, reserveRoutingAttempt, routingEpisodeSummary } from './attempts.js';
9
+ import { assessmentSignature } from './optimization.js';
10
+ export function buildAssessmentPrompt(root, ctx) {
11
+ return [assessmentInstructions, 'Use the supplied task contract and project context to produce a bounded plan. Do not implement or change files.',
12
+ 'Approved planning brief:', readPlanningFile(root, '.yoke/plan.md', 80_000) ?? '',
13
+ contextBlockFor(ctx.targetDir, ctx.story), JSON.stringify(ctx.story), 'Return exactly one line: YOKE_ASSESS {"taskClass":"implementation","difficulty":"medium","uncertainty":"low","risk":"low","scope":"low","testability":"high","reason":"evidence","approach":"steps and tests"}'].join('\n');
14
+ }
7
15
  const costRank = { low: 0, medium: 1, high: 2 };
8
16
  export function rankWorkers(workers, strategy, maxCandidates) {
9
17
  const history = historyForWorkers(workers);
@@ -113,6 +121,7 @@ function callUsage(role, provider, selection, tokens, durationMs, profile) {
113
121
  usageAvailable: tokens !== undefined && tokens.measurementComplete !== false,
114
122
  ...(tokens?.reasoningOutputTokens !== undefined ? { reasoningOutputTokens: tokens.reasoningOutputTokens } : {}),
115
123
  ...(tokens?.totalCostUsd !== undefined ? { totalCostUsd: tokens.totalCostUsd } : {}),
124
+ costMeasurementComplete: tokens?.totalCostUsd !== undefined && tokens.costMeasurementComplete !== false,
116
125
  durationMs,
117
126
  };
118
127
  }
@@ -121,8 +130,17 @@ export function makeAdaptiveRunner(options) {
121
130
  return ctx => {
122
131
  const steps = run(ctx);
123
132
  let next = steps.next();
124
- while (!next.done)
125
- next = steps.next(next.value());
133
+ while (!next.done) {
134
+ let result;
135
+ try {
136
+ result = next.value();
137
+ }
138
+ catch (error) {
139
+ next = steps.throw(error);
140
+ continue;
141
+ }
142
+ next = steps.next(result);
143
+ }
126
144
  return next.value;
127
145
  };
128
146
  }
@@ -131,8 +149,17 @@ export function makeAsyncAdaptiveRunner(options) {
131
149
  return async (ctx) => {
132
150
  const steps = run(ctx);
133
151
  let next = steps.next();
134
- while (!next.done)
135
- next = steps.next(await next.value());
152
+ while (!next.done) {
153
+ let result;
154
+ try {
155
+ result = await next.value();
156
+ }
157
+ catch (error) {
158
+ next = steps.throw(error);
159
+ continue;
160
+ }
161
+ next = steps.next(result);
162
+ }
136
163
  return next.value;
137
164
  };
138
165
  }
@@ -142,26 +169,100 @@ function routingSteps(options) {
142
169
  const eligibleWorkers = options.workers.filter(worker => available(worker.agent));
143
170
  const failedStories = new Set();
144
171
  const makeWorker = options.makeWorker ?? ((agent, selection) => makeRunner(agent, options.idleTimeoutMs ?? 0, {
145
- ...options.runnerOpts,
146
- permissions: options.permissions ?? 'safe',
147
- selection,
172
+ ...options.runnerOpts, permissions: options.permissions ?? 'safe', selection,
148
173
  }));
149
174
  return function* (ctx) {
150
- const blocked = (summary) => ({ success: false, summary, routing: { blocked: true, recordOutcome: () => undefined } });
175
+ const root = options.projectRoot ?? ctx.targetDir;
176
+ const calls = [];
177
+ let reservation;
178
+ const routeStartedAt = new Date().toISOString();
179
+ const blocked = (summary) => ({ success: false, summary,
180
+ ...(calls.length ? { tokens: { ...aggregateCalls(calls), storyId: ctx.story.id, ...(reservation ? { routingAttemptId: reservation.id } : {}) } } : {}),
181
+ routing: { blocked: true, recordOutcome: () => undefined },
182
+ });
183
+ function* perform(role, provider, selection, work, profile, beforeInvoke) {
184
+ const request = { callId: randomUUID(), storyId: ctx.story.id, role, provider, selection: { ...selection } };
185
+ try {
186
+ if (!available(provider))
187
+ return { blocked: `Configured ${role} provider is unavailable` };
188
+ const refusal = options.admitCall?.(request);
189
+ if (refusal)
190
+ return { blocked: refusal };
191
+ beforeInvoke?.();
192
+ }
193
+ catch (error) {
194
+ return { blocked: `Call admission failed: ${error.message}` };
195
+ }
196
+ const started = now();
197
+ let result;
198
+ try {
199
+ result = yield work;
200
+ }
201
+ catch (error) {
202
+ const reported = error && typeof error === 'object' ? error.tokens : undefined;
203
+ result = { success: false, infrastructureFailure: true, summary: error?.message ?? String(error), output: '', ...(reported ? { tokens: reported } : {}) };
204
+ }
205
+ const durationMs = Math.max(0, now() - started);
206
+ const callId = result.tokens?.callId ?? (result.tokens?.calls?.length === 1 ? result.tokens.calls[0].callId : undefined) ?? request.callId;
207
+ const call = { ...callUsage(role, provider, selection, result.tokens, durationMs, profile), callId,
208
+ ...(reservation ? { routingAttemptId: reservation.id } : {}) };
209
+ calls.push(call);
210
+ const usage = { ...aggregateCalls([call]), callId, storyId: ctx.story.id,
211
+ ...(reservation ? { routingAttemptId: reservation.id } : {}) };
212
+ let accountingError;
213
+ try {
214
+ // Earlier planning calls acquire the same execution identity once a worker
215
+ // has actually been admitted. Stable call IDs make reporter joins idempotent.
216
+ if (reservation)
217
+ recordRoutingAttemptUsage(root, reservation.id, { ...aggregateCalls(calls), routingAttemptId: reservation.id });
218
+ }
219
+ catch (error) {
220
+ accountingError = error;
221
+ }
222
+ // A failed optional join must never suppress delivery of already paid
223
+ // usage to the caller's own durable budget account.
224
+ try {
225
+ options.onCallUsage?.({ ...request, callId, durationMs, usage });
226
+ }
227
+ catch (error) {
228
+ accountingError ??= error;
229
+ }
230
+ if (accountingError) {
231
+ if (reservation) {
232
+ try {
233
+ markRoutingAttemptUsageIncomplete(root, reservation.id);
234
+ }
235
+ catch { /* unreadable state cannot provide a complete sample */ }
236
+ try {
237
+ finishRoutingAttempt(root, reservation, { verificationSuccess: false, failureKind: 'infrastructure', actualModel: result.tokens?.model });
238
+ }
239
+ catch { /* reservation remains charged and unresolved */ }
240
+ }
241
+ return { blocked: `Call accounting failed: ${accountingError.message}` };
242
+ }
243
+ return { result };
244
+ }
245
+ const capture = (provider, prompt, selection) => () => options.captureRoute
246
+ ? options.captureRoute(provider, ctx, prompt, selection)
247
+ : runCapturedAgent(provider, buildWatchdogInvocation(runnerInvocation(provider, prompt, ctx.targetDir, true, 'read-only', selection), options.idleTimeoutMs ?? 0));
248
+ const reserveWorker = (provider, selection, profile, limit) => {
249
+ reservation = reserveRoutingAttempt({ root, storyId: ctx.story.id, contractKey: routingAssessmentKey(root, ctx.story), limit,
250
+ provider, profile, selection, startedAt: routeStartedAt, accountingScope: options.accountingScope,
251
+ executionPolicyKey: options.executionPolicyKey });
252
+ };
151
253
  if (options.strategy === 'capability' && options.assessmentPolicy === 'prepared') {
152
254
  try {
153
- const criteria = ctx.story.acceptance;
154
- if (criteria.length < 2 || criteria.length > 5 || criteria.some(c => !isAcceptanceCriterion(c) || criterionCommandProblem(c)))
155
- return blocked('Prepared routing requires 2-5 executable acceptance criteria');
156
- if (!readAssessment(options.projectRoot ?? ctx.targetDir, ctx.story, true))
157
- return blocked('Task assessment is missing or stale. Run yoke prd assess before execution.');
255
+ const criteria = ctx.story.acceptance, goal = options.contractKind === 'goal';
256
+ if (criteria.length < (goal ? 1 : 2) || (!goal && criteria.length > 5) || criteria.some(c => !isAcceptanceCriterion(c) || (!goal && criterionCommandProblem(c)) || (goal && (!c.verify.length || c.verify.some(command => !command.trim())))))
257
+ return blocked(goal ? 'Prepared goal routing requires bound executable acceptance criteria' : 'Prepared routing requires 2-5 executable acceptance criteria');
258
+ if (!readAssessment(root, ctx.story, true))
259
+ return blocked(`Task assessment is missing or stale. Run yoke ${goal ? 'goal' : 'prd'} assess before execution.`);
158
260
  }
159
261
  catch (error) {
160
262
  return blocked(`Cannot read prepared assessment: ${error.message}`);
161
263
  }
162
264
  }
163
265
  if (options.strategy === 'capability' && !options.rules?.some(rule => (!rule.area || rule.area === ctx.story.area) && (!rule.storyId || rule.storyId === ctx.story.id))) {
164
- const root = options.projectRoot ?? ctx.targetDir;
165
266
  let assessment;
166
267
  try {
167
268
  assessment = readAssessment(root, ctx.story, options.assessmentPolicy === 'prepared');
@@ -169,79 +270,151 @@ function routingSteps(options) {
169
270
  catch (error) {
170
271
  return blocked(`Cannot read assessment: ${error.message}`);
171
272
  }
172
- let planning;
173
- const calls = [];
174
273
  if (!assessment) {
175
- const inputKey = routingAssessmentKey(root, ctx.story);
176
- const prompt = [assessmentInstructions, 'Use the supplied task contract and project context to produce a bounded plan. Do not implement or change files.',
177
- 'Approved planning brief:', readPlanningFile(root, '.yoke/plan.md', 80_000) ?? '',
178
- contextBlockFor(ctx.targetDir, ctx.story), JSON.stringify(ctx.story), 'Return exactly one line: YOKE_ASSESS {"taskClass":"implementation","difficulty":"medium","uncertainty":"low","risk":"low","scope":"low","testability":"high","reason":"evidence","approach":"steps and tests"}'].join('\n');
179
274
  const planner = options.planner?.agent ?? options.parent;
180
- if (!available(planner))
181
- return blocked('Configured planning provider is unavailable');
182
275
  const selection = { ...(options.planner?.selection ?? options.parentSelection), nativeMultiAgent: false };
183
- const started = now();
184
- planning = yield () => options.captureRoute ? options.captureRoute(planner, ctx, prompt, selection)
185
- : runCapturedAgent(planner, buildWatchdogInvocation(runnerInvocation(planner, prompt, ctx.targetDir, true, 'read-only', selection), options.idleTimeoutMs ?? 0));
186
- calls.push(callUsage('orchestrator', planner, selection, planning.tokens, now() - started));
187
- if (routingAssessmentKey(root, ctx.story) !== inputKey)
188
- return { ...blocked('Planning inputs changed during assessment; retry planning with the current contract'), tokens: aggregateCalls(calls) };
189
- assessment = planning.success ? parseAssessment(planning.output) : undefined;
190
- if (assessment)
191
- saveAssessment(root, ctx.story, assessment, { provider: planner, model: planning.tokens?.model ?? selection.model });
276
+ let inputKey, prompt;
277
+ try {
278
+ inputKey = routingAssessmentKey(root, ctx.story);
279
+ prompt = buildAssessmentPrompt(root, ctx);
280
+ }
281
+ catch (error) {
282
+ return blocked(`Cannot prepare assessment: ${error.message}`);
283
+ }
284
+ const planningCall = yield* perform('orchestrator', planner, selection, capture(planner, prompt, selection));
285
+ if (planningCall.blocked)
286
+ return blocked(planningCall.blocked);
287
+ const planning = planningCall.result;
288
+ try {
289
+ if (routingAssessmentKey(root, ctx.story) !== inputKey)
290
+ return blocked('Planning inputs changed during assessment; retry planning with the current contract');
291
+ assessment = planning.success ? parseAssessment(planning.output) : undefined;
292
+ if (assessment)
293
+ saveAssessment(root, ctx.story, assessment, { provider: planner, model: planning.tokens?.model ?? selection.model });
294
+ }
295
+ catch (error) {
296
+ return blocked(`Cannot persist current assessment: ${error.message}`);
297
+ }
192
298
  }
193
299
  if (!assessment)
194
- return { success: false, summary: 'Routing assessment unavailable or invalid; implementation was not started', tokens: aggregateCalls(calls), routing: { recordOutcome: () => undefined, blocked: true } };
195
- const choice = chooseCapability({ root, story: ctx.story, assessment, workers: eligibleWorkers, parent: options.parent, parentSelection: options.parentSelection, maxAttempts: options.maxAttempts, fallback: options.fallback, maxTier: options.maxTier });
300
+ return blocked('Routing assessment unavailable or invalid; implementation was not started');
301
+ let choice;
302
+ try {
303
+ choice = chooseCapability({ root, story: ctx.story, assessment, workers: eligibleWorkers, parent: options.parent, parentSelection: options.parentSelection,
304
+ maxAttempts: options.maxAttempts, fallback: options.fallback, maxTier: options.maxTier,
305
+ optimization: options.optimization, executionPolicyKey: options.executionPolicyKey });
306
+ }
307
+ catch (error) {
308
+ return blocked(`Cannot admit routing attempt: ${error.message}`);
309
+ }
196
310
  options.onDecision?.(ctx.story.id, { profile: choice.worker?.id ?? 'SELF', provider: choice.provider, model: choice.selection.model, reasoningEffort: choice.selection.reasoningEffort, variant: choice.selection.variant, providerModel: choice.selection.provider, reason: choice.reason, next: choice.next, assessment });
197
311
  if (choice.blocked)
198
- return { ...blocked(choice.reason), tokens: aggregateCalls(calls) };
312
+ return blocked(choice.reason);
199
313
  if (choice.exhausted)
200
- return { success: false, summary: 'Routing attempt budget exhausted; replan this task before retrying', tokens: aggregateCalls(calls), routing: { recordOutcome: () => undefined, blocked: true } };
201
- const started = now();
202
- const result = yield () => makeWorker(choice.provider, choice.selection)({ ...ctx, attempt: choice.failures + 1, story: { ...ctx.story, assessment } });
203
- calls.push(callUsage(choice.worker ? 'worker' : 'parent', choice.provider, choice.selection, result.tokens, now() - started, choice.worker?.id ?? 'SELF'));
314
+ return blocked('Routing attempt budget exhausted; replan this task before retrying');
315
+ let runner;
316
+ const implementation = yield* perform(choice.worker ? 'worker' : 'parent', choice.provider, choice.selection, () => runner({ ...ctx, attempt: reservation.ordinal, story: { ...ctx.story, assessment } }), choice.worker?.id ?? 'SELF', () => { runner = makeWorker(choice.provider, choice.selection); reserveWorker(choice.provider, choice.selection, choice.worker?.id ?? 'SELF', choice.attemptLimit); });
317
+ if (implementation.blocked)
318
+ return blocked(implementation.blocked);
319
+ const result = implementation.result;
204
320
  let recorded = false;
205
321
  const infrastructureFailure = result.infrastructureFailure || (!result.success && knownInfrastructureFailure(result.summary));
322
+ const recordOutcome = (verified, failureKind) => {
323
+ if (recorded)
324
+ return;
325
+ const kind = infrastructureFailure ? 'infrastructure' : failureKind ?? 'implementation';
326
+ const measured = finishRoutingAttempt(root, reservation, { verificationSuccess: infrastructureFailure ? false : verified, failureKind: kind, actualModel: result.tokens?.model });
327
+ const episode = routingEpisodeSummary(root, reservation);
328
+ recorded = true;
329
+ recordRoutingObservation({ projectHash: projectHash(root), storyHash: storyHash(projectHash(root), ctx.story.id), assessmentKey: reservation.contractKey, taskClass: assessment.taskClass, requiredTier: choice.requiredTier,
330
+ role: 'implementation', strategy: 'capability', selected: choice.worker?.id ?? 'SELF', provider: choice.provider,
331
+ requestedProvider: choice.selection.provider, requestedModel: choice.selection.model, requestedReasoningEffort: choice.selection.reasoningEffort, requestedVariant: choice.selection.variant,
332
+ actualModel: result.tokens?.model, orchestratorProvider: options.planner?.agent ?? options.parent, orchestratorModel: (options.planner?.selection ?? options.parentSelection)?.model,
333
+ orchestratorDurationMs: calls.filter(c => c.role === 'orchestrator').reduce((sum, call) => sum + call.durationMs, 0), workerDurationMs: calls.at(-1).durationMs,
334
+ processSuccess: result.success, verificationSuccess: infrastructureFailure ? false : verified, failureKind: kind,
335
+ usageAvailable: measured.usageComplete, inputTokens: measured.inputTokens, outputTokens: measured.outputTokens,
336
+ totalCostUsd: measured.totalCostUsd, costMeasurementComplete: measured.costComplete, accountingScope: measured.accountingScope,
337
+ totalDurationMs: measured.durationMs, executionPolicyKey: options.executionPolicyKey,
338
+ economicEpisode: { ...episode, assessmentSignature: assessmentSignature(assessment) },
339
+ });
340
+ };
341
+ if (infrastructureFailure)
342
+ recordOutcome(false, 'infrastructure');
206
343
  return { ...result, summary: `route=${choice.worker?.id ?? 'SELF'} (${choice.reason}); ${result.summary}`,
207
344
  ...(infrastructureFailure ? { success: false, infrastructureFailure: true } : {}),
208
- tokens: { ...aggregateCalls(calls), storyId: ctx.story.id, escalated: choice.failures > 1 },
209
- routing: { blocked: infrastructureFailure || undefined, canRetry: !infrastructureFailure && choice.failures + 1 < (options.maxAttempts ?? 5), recordOutcome: (verified, failureKind) => {
210
- if (recorded)
211
- return;
212
- recorded = true;
213
- recordRoutingObservation({ projectHash: projectHash(root), storyHash: storyHash(projectHash(root), ctx.story.id), assessmentKey: routingAssessmentKey(root, ctx.story), taskClass: assessment.taskClass, requiredTier: choice.requiredTier,
214
- role: 'implementation', strategy: 'capability', selected: choice.worker?.id ?? 'SELF', provider: choice.provider, requestedProvider: choice.selection.provider, requestedModel: choice.selection.model, requestedReasoningEffort: choice.selection.reasoningEffort, requestedVariant: choice.selection.variant,
215
- actualModel: result.tokens?.model, orchestratorProvider: options.planner?.agent ?? options.parent, orchestratorModel: (options.planner?.selection ?? options.parentSelection)?.model, orchestratorDurationMs: calls.filter(c => c.role === 'orchestrator').reduce((s, c) => s + c.durationMs, 0), workerDurationMs: calls[calls.length - 1].durationMs,
216
- processSuccess: result.success, verificationSuccess: infrastructureFailure ? false : verified, failureKind: infrastructureFailure ? 'infrastructure' : failureKind ?? 'implementation', usageAvailable: result.tokens !== undefined && result.tokens.measurementComplete !== false,
217
- inputTokens: result.tokens?.inputTokens ?? 0, outputTokens: result.tokens?.outputTokens ?? 0, totalCostUsd: result.tokens?.totalCostUsd });
218
- } } };
345
+ tokens: { ...aggregateCalls(calls), storyId: ctx.story.id, routingAttemptId: reservation.id, escalated: choice.failures > 1 },
346
+ routing: { blocked: infrastructureFailure || undefined, canRetry: !infrastructureFailure && reservation.ordinal < choice.attemptLimit, recordOutcome } };
219
347
  }
220
- // Re-rank per story so a long-running loop can use gate outcomes learned by
221
- // earlier stories without rebuilding the runner.
222
348
  const rule = options.rules?.find(rule => (!rule.area || rule.area === ctx.story.area) && (!rule.storyId || rule.storyId === ctx.story.id) && (rule.area || rule.storyId));
349
+ const bounded = options.strategy === 'capability' || options.maxAttempts !== undefined;
350
+ let priorAttempts = [];
351
+ let failureKey = ctx.story.id;
352
+ if (bounded) {
353
+ try {
354
+ failureKey = routingAssessmentKey(root, ctx.story);
355
+ priorAttempts = readRoutingAttempts(root, ctx.story.id, failureKey);
356
+ if (priorAttempts.length >= (options.maxAttempts ?? 5))
357
+ return blocked('Routing attempt budget exhausted; replan this task before retrying');
358
+ }
359
+ catch (error) {
360
+ return blocked(`Cannot read routing attempt state: ${error.message}`);
361
+ }
362
+ }
223
363
  if (rule) {
224
- const project = projectHash(options.projectRoot ?? ctx.targetDir);
225
- const prior = readRoutingObservations().reverse().find(event => event.projectHash === project && event.storyHash === storyHash(project, ctx.story.id) && typeof event.verificationSuccess === 'boolean');
226
- if (prior?.verificationSuccess === false)
227
- failedStories.add(ctx.story.id);
364
+ const project = projectHash(root);
365
+ try {
366
+ const prior = bounded ? priorAttempts.filter(entry => entry.outcome?.failureKind !== 'infrastructure').at(-1)?.outcome
367
+ : readRoutingObservations().reverse().find(event => event.projectHash === project && event.storyHash === storyHash(project, ctx.story.id) && typeof event.verificationSuccess === 'boolean');
368
+ if (prior?.verificationSuccess === false)
369
+ failedStories.add(failureKey);
370
+ }
371
+ catch (error) {
372
+ return blocked(`Cannot read routing attempt state: ${error.message}`);
373
+ }
228
374
  }
229
- const ruleWorker = rule && failedStories.has(ctx.story.id) ? rule.escalateTo ?? 'SELF' : rule?.worker;
375
+ const ruleWorker = rule && failedStories.has(failureKey) ? rule.escalateTo ?? 'SELF' : rule?.worker;
230
376
  const candidates = rule ? eligibleWorkers : rankWorkers(eligibleWorkers, options.strategy, options.maxCandidates);
231
377
  if (candidates.length === 0) {
232
378
  if (options.fallback === 'block' || options.maxTier)
233
379
  return blocked('No eligible routing profiles; parent fallback is disabled');
234
- return yield () => makeWorker(options.parent, options.parentSelection ?? {})(ctx);
380
+ let runner;
381
+ const selection = options.parentSelection ?? {};
382
+ options.onDecision?.(ctx.story.id, { profile: 'SELF', provider: options.parent, model: selection.model, providerModel: selection.provider,
383
+ reasoningEffort: selection.reasoningEffort, variant: selection.variant, reason: 'No eligible profile; configured parent fallback', next: 'independent gate verification' });
384
+ const execution = yield* perform('parent', options.parent, selection, () => runner(ctx), 'SELF', () => {
385
+ runner = makeWorker(options.parent, selection);
386
+ if (bounded)
387
+ reserveWorker(options.parent, selection, 'SELF', options.maxAttempts ?? 5);
388
+ });
389
+ if (execution.blocked)
390
+ return blocked(execution.blocked);
391
+ const result = execution.result;
392
+ const infrastructureFailure = result.infrastructureFailure || (!result.success && knownInfrastructureFailure(result.summary));
393
+ let recorded = false;
394
+ const recordOutcome = (verified, failureKind) => {
395
+ if (recorded)
396
+ return;
397
+ const kind = infrastructureFailure ? 'infrastructure' : failureKind ?? 'implementation';
398
+ if (reservation)
399
+ finishRoutingAttempt(root, reservation, { verificationSuccess: infrastructureFailure ? false : verified, failureKind: kind, actualModel: result.tokens?.model });
400
+ recorded = true;
401
+ };
402
+ if (infrastructureFailure)
403
+ recordOutcome(false, 'infrastructure');
404
+ return { ...result, ...(infrastructureFailure ? { success: false, infrastructureFailure: true } : {}),
405
+ tokens: { ...aggregateCalls(calls), storyId: ctx.story.id, ...(reservation ? { routingAttemptId: reservation.id } : {}) },
406
+ routing: { recordOutcome, ...(infrastructureFailure ? { blocked: true } : {}), ...(bounded ? { canRetry: !infrastructureFailure && reservation.ordinal < (options.maxAttempts ?? 5) } : {}) } };
235
407
  }
236
- const prompt = buildRoutingPrompt(ctx, candidates, options.strategy);
237
408
  const orchestratorSelection = { ...(options.parentSelection ?? {}), ...(options.orchestratorSelection ?? {}), nativeMultiAgent: false };
238
- const orchestratorStarted = now();
239
- const routeRun = rule ? { success: true, summary: 'Explicit rule', output: '', tokens: { inputTokens: 0, outputTokens: 0 } } : yield () => options.captureRoute
240
- ? options.captureRoute(options.parent, ctx, prompt, orchestratorSelection)
241
- : runCapturedAgent(options.parent, buildWatchdogInvocation(runnerInvocation(options.parent, prompt, ctx.targetDir, true, 'read-only', orchestratorSelection), options.idleTimeoutMs ?? 0));
242
- const orchestratorDurationMs = Math.max(0, now() - orchestratorStarted);
409
+ let routeRun = { success: true, summary: 'Explicit rule', output: '' };
410
+ if (!rule) {
411
+ const routed = yield* perform('orchestrator', options.parent, orchestratorSelection, capture(options.parent, buildRoutingPrompt(ctx, candidates, options.strategy), orchestratorSelection));
412
+ if (routed.blocked)
413
+ return blocked(routed.blocked);
414
+ routeRun = routed.result;
415
+ }
243
416
  const decision = rule
244
- ? { worker: ruleWorker === 'SELF' || candidates.some(w => w.id === ruleWorker) ? ruleWorker : 'SELF', reason: failedStories.has(ctx.story.id) ? 'gate failure escalated by project rule' : 'explicit project routing rule' }
417
+ ? { worker: ruleWorker === 'SELF' || candidates.some(w => w.id === ruleWorker) ? ruleWorker : 'SELF', reason: failedStories.has(failureKey) ? 'gate failure escalated by project rule' : 'explicit project routing rule' }
245
418
  : routeRun.success ? parseRouteDecision(routeRun.output, candidates.map(worker => worker.id)) : null;
246
419
  const selected = decision?.worker ?? 'SELF';
247
420
  const worker = selected === 'SELF' ? undefined : candidates.find(candidate => candidate.id === selected);
@@ -251,75 +424,55 @@ function routingSteps(options) {
251
424
  const selection = worker
252
425
  ? { provider: worker.provider, model: worker.model, reasoningEffort: worker.reasoningEffort, variant: worker.variant, nativeMultiAgent: false, ...(provider !== 'gemini' && provider !== 'qwen' && provider !== 'pi' && provider !== 'hermes' ? { bare: options.parentSelection?.bare } : {}) }
253
426
  : { ...(options.parentSelection ?? {}), nativeMultiAgent: false };
254
- const workerStarted = now();
255
- const result = yield () => makeWorker(provider, selection)(ctx);
256
- const workerDurationMs = Math.max(0, now() - workerStarted);
257
- const calls = [
258
- ...(!rule ? [callUsage('orchestrator', options.parent, orchestratorSelection, routeRun.tokens, orchestratorDurationMs)] : []),
259
- callUsage(worker ? 'worker' : 'parent', provider, selection, result.tokens, workerDurationMs, selected),
260
- ];
261
- const tokens = {
262
- storyId: ctx.story.id,
263
- escalated: Boolean(rule && failedStories.has(ctx.story.id)),
264
- inputTokens: (routeRun.tokens?.inputTokens ?? 0) + (result.tokens?.inputTokens ?? 0),
265
- ...((routeRun.tokens?.cachedInputTokens !== undefined || result.tokens?.cachedInputTokens !== undefined)
266
- ? { cachedInputTokens: (routeRun.tokens?.cachedInputTokens ?? 0) + (result.tokens?.cachedInputTokens ?? 0) }
267
- : {}),
268
- ...((routeRun.tokens?.cacheWriteInputTokens !== undefined || result.tokens?.cacheWriteInputTokens !== undefined)
269
- ? { cacheWriteInputTokens: (routeRun.tokens?.cacheWriteInputTokens ?? 0) + (result.tokens?.cacheWriteInputTokens ?? 0) }
270
- : {}),
271
- outputTokens: (routeRun.tokens?.outputTokens ?? 0) + (result.tokens?.outputTokens ?? 0),
272
- ...((routeRun.tokens?.reasoningOutputTokens !== undefined || result.tokens?.reasoningOutputTokens !== undefined)
273
- ? { reasoningOutputTokens: (routeRun.tokens?.reasoningOutputTokens ?? 0) + (result.tokens?.reasoningOutputTokens ?? 0) }
274
- : {}),
275
- ...((routeRun.tokens?.totalCostUsd !== undefined || result.tokens?.totalCostUsd !== undefined)
276
- ? { totalCostUsd: (routeRun.tokens?.totalCostUsd ?? 0) + (result.tokens?.totalCostUsd ?? 0) }
277
- : {}),
278
- ...(result.tokens?.model ? { model: result.tokens.model } : routeRun.tokens?.model ? { model: routeRun.tokens.model } : {}),
279
- calls,
280
- measurementComplete: calls.every(call => call.usageAvailable),
281
- costMeasurementComplete: calls.every(call => call.totalCostUsd !== undefined),
282
- };
427
+ options.onDecision?.(ctx.story.id, { profile: selected, provider, model: selection.model, providerModel: selection.provider,
428
+ reasoningEffort: selection.reasoningEffort, variant: selection.variant, reason: decision?.reason ?? 'Invalid controller response; configured parent fallback',
429
+ next: rule?.escalateTo ?? 'independent gate verification', ...(ctx.story.assessment ? { assessment: ctx.story.assessment } : {}) });
430
+ let runner;
431
+ const execution = yield* perform(worker ? 'worker' : 'parent', provider, selection, () => runner(ctx), selected, () => {
432
+ runner = makeWorker(provider, selection);
433
+ if (bounded)
434
+ reserveWorker(provider, selection, selected, options.maxAttempts ?? 5);
435
+ });
436
+ if (execution.blocked)
437
+ return blocked(execution.blocked);
438
+ const result = execution.result;
439
+ const tokens = { ...aggregateCalls(calls), storyId: ctx.story.id, escalated: Boolean(rule && failedStories.has(failureKey)), ...(reservation ? { routingAttemptId: reservation.id } : {}) };
283
440
  let recorded = false;
284
- const recordOutcome = (verificationSuccess) => {
441
+ const recordOutcome = (verificationSuccess, failureKind) => {
285
442
  if (recorded)
286
443
  return;
444
+ if (reservation)
445
+ finishRoutingAttempt(root, reservation, { verificationSuccess, failureKind, actualModel: result.tokens?.model });
287
446
  recorded = true;
288
- if (!verificationSuccess)
289
- failedStories.add(ctx.story.id);
290
- else
291
- failedStories.delete(ctx.story.id);
292
- const project = projectHash(options.projectRoot ?? ctx.targetDir);
293
- recordRoutingObservation({
294
- projectHash: project,
295
- storyHash: storyHash(project, ctx.story.id),
296
- strategy: options.strategy,
297
- selected,
298
- provider,
299
- ...(selection.model ? { requestedModel: selection.model } : {}),
300
- ...(selection.reasoningEffort ? { requestedReasoningEffort: selection.reasoningEffort } : {}),
301
- ...(selection.provider ? { requestedProvider: selection.provider } : {}),
302
- ...(selection.variant ? { requestedVariant: selection.variant } : {}),
303
- ...(result.tokens?.model ? { actualModel: result.tokens.model } : {}),
304
- orchestratorProvider: options.parent,
305
- ...(orchestratorSelection.model ? { orchestratorModel: orchestratorSelection.model } : {}),
306
- orchestratorDurationMs,
307
- workerDurationMs,
308
- processSuccess: result.success,
309
- verificationSuccess,
310
- inputTokens: tokens.inputTokens,
311
- outputTokens: tokens.outputTokens,
312
- });
447
+ if (!verificationSuccess && failureKind !== 'infrastructure')
448
+ failedStories.add(failureKey);
449
+ else if (verificationSuccess)
450
+ failedStories.delete(failureKey);
451
+ const project = projectHash(root);
452
+ recordRoutingObservation({ projectHash: project, storyHash: storyHash(project, ctx.story.id), strategy: options.strategy, selected, provider,
453
+ requestedModel: selection.model, requestedReasoningEffort: selection.reasoningEffort, requestedProvider: selection.provider, requestedVariant: selection.variant,
454
+ actualModel: result.tokens?.model, orchestratorProvider: options.parent, orchestratorModel: orchestratorSelection.model,
455
+ orchestratorDurationMs: calls.filter(call => call.role === 'orchestrator').reduce((sum, call) => sum + call.durationMs, 0), workerDurationMs: calls.at(-1).durationMs,
456
+ processSuccess: result.success, verificationSuccess, failureKind,
457
+ inputTokens: tokens.inputTokens, outputTokens: tokens.outputTokens, usageAvailable: tokens.measurementComplete,
458
+ totalCostUsd: tokens.totalCostUsd, costMeasurementComplete: tokens.costMeasurementComplete, });
313
459
  };
314
- const routeSummary = decision
315
- ? `route=${selected} (${decision.reason})`
316
- : `route=SELF (${routeRun.success ? 'invalid routing response' : 'orchestrator failed'})`;
317
- return { ...result, summary: `${routeSummary}; ${result.summary}`, tokens, routing: { recordOutcome } };
460
+ if (result.infrastructureFailure)
461
+ recordOutcome(false, 'infrastructure');
462
+ const routeSummary = decision ? `route=${selected} (${decision.reason})` : `route=SELF (${routeRun.success ? 'invalid routing response' : 'orchestrator failed'})`;
463
+ return { ...result, summary: `${routeSummary}; ${result.summary}`, tokens, routing: { recordOutcome, ...(result.infrastructureFailure ? { blocked: true } : {}),
464
+ ...(bounded ? { canRetry: !result.infrastructureFailure && reservation.ordinal < (options.maxAttempts ?? 5) } : {}) } };
318
465
  };
319
466
  }
320
467
  function aggregateCalls(calls) {
321
- return { inputTokens: calls.reduce((n, c) => n + c.inputTokens, 0), outputTokens: calls.reduce((n, c) => n + c.outputTokens, 0),
322
- cachedInputTokens: calls.reduce((n, c) => n + (c.cachedInputTokens ?? 0), 0), cacheWriteInputTokens: calls.reduce((n, c) => n + (c.cacheWriteInputTokens ?? 0), 0),
323
- ...(calls.some(c => c.totalCostUsd !== undefined) ? { totalCostUsd: calls.reduce((n, c) => n + (c.totalCostUsd ?? 0), 0) } : {}),
324
- calls, measurementComplete: calls.every(c => c.usageAvailable), costMeasurementComplete: calls.every(c => c.totalCostUsd !== undefined) };
468
+ const optional = {};
469
+ for (const field of ['cachedInputTokens', 'cacheWriteInputTokens', 'reasoningOutputTokens', 'totalCostUsd']) {
470
+ if (calls.some(call => call[field] !== undefined))
471
+ optional[field] = calls.reduce((sum, call) => sum + (call[field] ?? 0), 0);
472
+ }
473
+ const model = calls.at(-1)?.actualModel;
474
+ return { inputTokens: calls.reduce((sum, call) => sum + call.inputTokens, 0), outputTokens: calls.reduce((sum, call) => sum + call.outputTokens, 0),
475
+ ...optional, ...(model ? { model } : {}), calls,
476
+ measurementComplete: calls.every(call => call.usageAvailable !== false),
477
+ costMeasurementComplete: calls.every(call => call.totalCostUsd !== undefined && call.costMeasurementComplete !== false) };
325
478
  }
@@ -46,7 +46,10 @@ export function defaultRoutingWorkers(agents) {
46
46
  { id: 'hermes-standard', agent: 'hermes', tier: 'standard', costTier: 'medium', capabilities: ['implementation'] },
47
47
  ],
48
48
  };
49
- return agents.flatMap(agent => workers[agent]);
49
+ return agents.flatMap(agent => workers[agent]).map(worker => ({
50
+ ...worker,
51
+ profileMetadata: { version: 1, catalog: 'yoke-1.22.0', source: 'yoke-default', basis: 'configured-prior' },
52
+ }));
50
53
  }
51
54
  function parseAgents(value, fallback) {
52
55
  if (value.trim().toLowerCase() === 'all')
@@ -55,6 +58,8 @@ function parseAgents(value, fallback) {
55
58
  return parsed.length > 0 ? [...new Set(parsed)] : fallback;
56
59
  }
57
60
  function yes(value, fallback) {
61
+ if (!value)
62
+ return fallback;
58
63
  const normalized = value.trim().toLowerCase();
59
64
  if (['y', 'yes', 'j', 'ja', 'true', '1'].includes(normalized))
60
65
  return true;
@@ -99,6 +104,10 @@ export async function runSetup(targetDir, opts = {}) {
99
104
  let runner = defaultRunner;
100
105
  let decisionPolicy = defaultPolicy;
101
106
  let routing = defaultRouting;
107
+ let runnerModel = opts.runnerModel ?? existing?.runner?.model;
108
+ let runnerReasoning = opts.runnerReasoning ?? existing?.runner?.reasoningEffort;
109
+ const agentModels = { ...(opts.agentModels ?? {}) };
110
+ const agentReasoning = { ...(opts.agentReasoning ?? {}) };
102
111
  if (interactive && ask) {
103
112
  agents = parseAgents(await ask(`Agents [${defaultAgents.join(',')}] (${SUPPORTED_AGENTS.join(',')}|all): `), defaultAgents);
104
113
  const graphAnswer = (await ask(`Code graph [${defaultGraph}] (graphify|serena): `)).trim().toLowerCase();
@@ -112,12 +121,39 @@ export async function runSetup(targetDir, opts = {}) {
112
121
  if (policyAnswer === 'auto' || policyAnswer === 'critical')
113
122
  decisionPolicy = policyAnswer;
114
123
  routing = yes(await ask(`Enable adaptive multi-model routing? [${defaultRouting ? 'yes' : 'no'}]: `), defaultRouting);
124
+ if (opts.configureModels) {
125
+ const configureModels = yes(await ask(`Configure model & reasoning effort per agent? [no]: `), false);
126
+ if (configureModels) {
127
+ const rm = (await ask(`Runner model [${runnerModel ?? 'default'}]: `)).trim();
128
+ if (rm)
129
+ runnerModel = rm;
130
+ const rr = (await ask(`Runner reasoning effort (none|low|medium|high|xhigh) [${runnerReasoning ?? 'default'}]: `)).trim();
131
+ if (rr)
132
+ runnerReasoning = rr;
133
+ for (const a of agents) {
134
+ const am = (await ask(`Model for agent "${a}" [${agentModels[a] ?? 'default'}]: `)).trim();
135
+ if (am)
136
+ agentModels[a] = am;
137
+ const ar = (await ask(`Reasoning effort for agent "${a}" (none|low|medium|high|xhigh) [${agentReasoning[a] ?? 'default'}]: `)).trim();
138
+ if (ar)
139
+ agentReasoning[a] = ar;
140
+ }
141
+ }
142
+ }
115
143
  }
116
144
  if (modelProviders.length && !agents.includes('qwen'))
117
145
  agents = [...agents, 'qwen'];
118
146
  if (!agents.includes(runner))
119
147
  agents = [...agents, runner];
120
- const code = runRetrofit(targetDir, { loop, agents, codeGraph, codeIntelligence, host });
148
+ const priorRunner = existing?.runner?.agent ?? existing?.agents[0];
149
+ const runnerChanged = priorRunner !== undefined && priorRunner !== runner;
150
+ if (runnerChanged) {
151
+ if (opts.runnerModel === undefined)
152
+ runnerModel = undefined;
153
+ if (opts.runnerReasoning === undefined)
154
+ runnerReasoning = undefined;
155
+ }
156
+ const code = runRetrofit(targetDir, { loop, agents, codeGraph, codeIntelligence, host, cleanWorktrees: opts.cleanWorktrees, runnerModel, runnerReasoning, agentModels, agentReasoning });
121
157
  if (code !== 0)
122
158
  return code;
123
159
  applyActions(presetActions, targetDir, { backupDir: join(targetDir, '.yoke', 'backups', `model-presets-${Date.now()}`) });
@@ -125,14 +161,25 @@ export async function runSetup(targetDir, opts = {}) {
125
161
  if (!config)
126
162
  return 1;
127
163
  config.loop = { parallel: 'auto', isolate: true, ...config.loop, enabled: loop, decisionPolicy };
128
- const priorRunner = existing?.runner?.agent ?? existing?.agents[0];
129
164
  const selectPresetModel = presetWorkers.length > 0 && runner === 'qwen' && (!existing || (priorRunner !== undefined && priorRunner !== runner));
130
165
  if (selectPresetModel && priorRunner !== 'qwen')
131
166
  config.runner = { permissions: config.runner?.permissions };
132
- config.runner = { ...config.runner, agent: runner, ...(selectPresetModel && !config.runner?.model ? { model: presetWorkers[0].model } : {}) };
167
+ config.runner = {
168
+ ...config.runner,
169
+ agent: runner,
170
+ ...(runnerModel ? { model: runnerModel } : selectPresetModel && !config.runner?.model ? { model: presetWorkers[0].model } : {}),
171
+ ...(runnerReasoning ? { reasoningEffort: runnerReasoning } : {}),
172
+ };
133
173
  const existingWorkers = config.routing?.workers ?? [];
134
174
  const baseWorkers = existingWorkers.length > 0 && !opts.routingPreset ? existingWorkers : defaultRoutingWorkers(agents).filter(worker => !modelProviders.length || worker.agent !== 'qwen');
135
- const workers = [...baseWorkers, ...presetWorkers.filter(worker => !baseWorkers.some(existing => existing.id === worker.id))];
175
+ const rawWorkers = [...baseWorkers, ...presetWorkers.filter(worker => !baseWorkers.some(existing => existing.id === worker.id))];
176
+ const workers = rawWorkers.map(w => {
177
+ const model = agentModels[w.agent] ?? w.model;
178
+ const reasoningEffort = agentReasoning[w.agent] ?? w.reasoningEffort;
179
+ return { ...w, ...(model ? { model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}),
180
+ ...((agentModels[w.agent] || agentReasoning[w.agent]) && w.profileMetadata ? { profileMetadata: { ...w.profileMetadata, source: 'operator' } } : {}),
181
+ };
182
+ });
136
183
  config.routing = {
137
184
  ...config.routing,
138
185
  enabled: routing,
@@ -140,12 +187,13 @@ export async function runSetup(targetDir, opts = {}) {
140
187
  maxCandidates: config.routing?.maxCandidates ?? 3,
141
188
  assessmentPolicy: existing?.routing?.assessmentPolicy ?? (existing ? 'on-demand' : 'prepared'),
142
189
  fallback: existing?.routing?.fallback ?? (existing ? 'parent' : 'block'),
190
+ ...(!existing && !config.routing?.optimization ? { optimization: { version: 1, objective: 'balanced', minSamples: 20 } } : {}),
143
191
  ...(config.routing?.orchestrator ? { orchestrator: config.routing.orchestrator } : {}),
144
192
  workers,
145
193
  };
146
194
  YokeConfigSchema.parse(config);
147
195
  saveConfig(targetDir, config);
148
- console.log(`Yoke setup complete: agents=${agents.join(',')} · runner=${runner} · loop=${loop ? 'on' : 'off'} · routing=${routing ? 'on' : 'off'} · decisions=${decisionPolicy}`);
196
+ console.log(`Yoke setup complete: agents=${agents.join(',')} · runner=${runner}${runnerModel ? ` (${runnerModel})` : ''}${runnerReasoning ? ` [${runnerReasoning}]` : ''} · loop=${loop ? 'on' : 'off'} · routing=${routing ? 'on' : 'off'} · decisions=${decisionPolicy}`);
149
197
  return 0;
150
198
  }
151
199
  finally {