@hecer/yoke 1.7.0 → 1.9.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.
@@ -1,3 +1,5 @@
1
+ import { loadConfig } from "../retrofit/config.js";
2
+ import { makeAsyncAdaptiveRunner } from "../routing/router.js";
1
3
  import { randomUUID } from 'node:crypto';
2
4
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync, readdirSync } from 'node:fs';
3
5
  import { join } from 'node:path';
@@ -97,7 +99,7 @@ export function goalHandoff(root) {
97
99
  async function executeAgent(input) {
98
100
  const handle = startProviderProcess(input.provider, buildProviderInvocation(input.provider, input.prompt, input.root, 'safe', input.selection), { signal: input.signal, idleTimeoutMs: 20 * 60_000 });
99
101
  const result = await handle.completion;
100
- return { success: result.kind === 'succeeded', summary: result.kind === 'succeeded' ? 'Agent finished; independently checked below' : `${result.kind}: ${result.stderr.slice(-3000)}`, ...result.telemetry.tokens };
102
+ return { success: result.kind === 'succeeded', summary: result.kind === 'succeeded' ? 'Agent finished; independently checked below' : `${result.kind}: ${result.stderr.slice(-3000)}`, ...result.telemetry.tokens, tokens: result.telemetry.tokens };
101
103
  }
102
104
  export async function runProjectGoal(root, options = {}) {
103
105
  const lock = acquireLock(root);
@@ -130,7 +132,24 @@ export async function runProjectGoal(root, options = {}) {
130
132
  throw error;
131
133
  }
132
134
  const provider = options.provider ?? 'codex';
133
- const execute = options.execute ?? executeAgent;
135
+ const rawExecute = options.execute ?? executeAgent;
136
+ const config = loadConfig(root);
137
+ const execute = async (input) => {
138
+ if (!config?.routing?.enabled || config.routing.strategy !== "capability")
139
+ return rawExecute(input);
140
+ const routed = makeAsyncAdaptiveRunner({ parent: provider, parentSelection: input.selection, projectRoot: root, workers: config.routing.workers, strategy: "capability", maxCandidates: config.routing.maxCandidates, maxAttempts: Math.min(goal.maxAttempts, config.routing.maxAttempts ?? 5),
141
+ captureRoute: async (agent, context, prompt, selection) => {
142
+ const run = await startProviderProcess(agent, buildProviderInvocation(agent, prompt, root, "read-only", selection), { signal: input.signal, idleTimeoutMs: 20 * 60_000 }).completion;
143
+ return { success: run.kind === "succeeded", summary: run.kind, output: run.stdout, tokens: run.telemetry.tokens };
144
+ },
145
+ makeWorker: (agent, selection) => async (context) => {
146
+ const result = await rawExecute({ ...input, provider: agent, selection, prompt: input.prompt + "\nPlanner approach:\n" + (context.story.assessment?.approach ?? "") });
147
+ return { ...result, infrastructureFailure: !result.success, tokens: result.tokens ?? (result.inputTokens !== undefined && result.outputTokens !== undefined ? { inputTokens: result.inputTokens, outputTokens: result.outputTokens, model: result.model } : undefined) };
148
+ }, });
149
+ const result = await routed({ targetDir: root, story: { id: goal.id, title: goal.objective, priority: 1, passes: false, agent: provider, acceptance: manifest.criteria.map(c => c.text) } });
150
+ const last = result.tokens?.calls?.at(-1);
151
+ return { ...result, provider: last?.provider ?? provider, model: last?.actualModel, inputTokens: result.tokens?.measurementComplete ? result.tokens.inputTokens : undefined, outputTokens: result.tokens?.measurementComplete ? result.tokens.outputTokens : undefined };
152
+ };
134
153
  let report = checkProject(root);
135
154
  while (report.status !== 'passed') {
136
155
  const protectionProblem = acceptanceProtectionProblem(root);
@@ -167,10 +186,18 @@ export async function runProjectGoal(root, options = {}) {
167
186
  const agentDurationMs = Date.now() - started;
168
187
  const checkStarted = Date.now();
169
188
  report = checkProject(root);
189
+ if (!result.routing?.blocked)
190
+ result.routing?.recordOutcome(report.status === 'passed');
170
191
  appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'phase-ended', phase: 'verify', durationMs: Date.now() - checkStarted, outcome: report.status });
171
- goal.attempts.push({ provider, model: result.model ?? options.selection?.model, startedAt: new Date(started).toISOString(), durationMs: agentDurationMs, success: result.success, summary: result.summary.slice(0, 8000), checkId: report.id, inputTokens: result.inputTokens, outputTokens: result.outputTokens });
172
- appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'attempt-ended', attemptId: `${goal.id}:${goal.attempts.length}`, durationMs: Date.now() - started, outcome: report.status, data: { provider, model: result.model, inputTokens: result.inputTokens, outputTokens: result.outputTokens } });
192
+ goal.attempts.push({ provider: result.provider ?? provider, model: result.model ?? options.selection?.model, startedAt: new Date(started).toISOString(), durationMs: agentDurationMs, success: result.success, summary: result.summary.slice(0, 8000), checkId: report.id, inputTokens: result.inputTokens, outputTokens: result.outputTokens });
193
+ const usageAvailable = result.inputTokens !== undefined && result.outputTokens !== undefined;
194
+ appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'tokens', attemptId: `${goal.id}:${goal.attempts.length}`, durationMs: agentDurationMs, data: { ...result.tokens, provider: result.provider ?? provider, role: 'parent', model: result.model, inputTokens: result.inputTokens, outputTokens: result.outputTokens, usageAvailable } });
195
+ appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'attempt-ended', attemptId: `${goal.id}:${goal.attempts.length}`, durationMs: Date.now() - started, outcome: report.status, data: { provider, model: result.model, usageAvailable } });
196
+ if (report.status === 'passed')
197
+ appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'accepted', attemptId: `${goal.id}:${goal.attempts.length}` });
173
198
  goal = save(root, { ...goal, lastCheck: report.id, pendingAttempt: undefined });
199
+ if (result.routing?.blocked)
200
+ return save(root, { ...goal, status: "blocked", reason: result.summary });
174
201
  if (controller.signal.aborted)
175
202
  return save(root, { ...goal, status: 'blocked', reason: 'Time budget exceeded; work retained' });
176
203
  }
@@ -163,11 +163,13 @@ export function createDispatcher(options) {
163
163
  case 'integrated':
164
164
  input.story.passes = true;
165
165
  integrated.push(input.story.id);
166
+ options.onAccepted?.(input.story);
166
167
  result.routing.recordOutcome?.(true);
167
168
  return;
168
169
  case 'integrated-but-blocked':
169
170
  input.story.passes = true;
170
171
  integrated.push(input.story.id);
172
+ options.onAccepted?.(input.story);
171
173
  integrationBlocks.push(merge.reason);
172
174
  result.routing.recordOutcome?.(true);
173
175
  return;
@@ -301,7 +303,8 @@ export function createDispatcher(options) {
301
303
  paused = true;
302
304
  if (!paused && !cancellationReason && integrationBlocks.length === 0 && iterations < options.maxIterations) {
303
305
  const busy = new Set([...active.keys(), ...queued.keys(), ...failed]);
304
- const slots = Math.max(0, options.maxConcurrency - active.size);
306
+ // Integration/review retains its execution slot until the candidate lands.
307
+ const slots = Math.max(0, options.maxConcurrency - active.size - queued.size);
305
308
  const ready = readyStories(options.stories, { activeAreas: areas, activeWrites: [...reservedWrites.values()] }).filter(story => !busy.has(story.id));
306
309
  let launched = 0;
307
310
  for (const story of ready) {
@@ -316,7 +319,7 @@ export function createDispatcher(options) {
316
319
  }
317
320
  }
318
321
  if (active.size > 0) {
319
- await Promise.race([...active.values()].map(worker => worker.task));
322
+ await Promise.race([...active.values()].map(worker => worker.task).concat([...queued.values()]));
320
323
  continue;
321
324
  }
322
325
  if (queued.size > 0) {
package/dist/loop/git.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { sanitizeCommitMessage } from './identity.js';
3
- export const RUNTIME_PATHS = ['.yoke/artifacts', '.yoke/events', '.yoke/checks', '.yoke/goal.json', '.yoke/goal.pause'];
3
+ export const RUNTIME_PATHS = ['.yoke/artifacts', '.yoke/events', '.yoke/history', '.yoke/routing', '.yoke/checks', '.yoke/goal.json', '.yoke/goal.pause'];
4
4
  export const RUNTIME_EXCLUDES = RUNTIME_PATHS.map(path => `:(exclude)${path}${path.endsWith('.json') || path.endsWith('.pause') ? '' : '/**'}`);
5
5
  export const realGitOps = {
6
6
  isClean(dir) {
package/dist/loop/loop.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { knownInfrastructureFailure } from "../routing/capability.js";
1
2
  import { existsSync, unlinkSync, readFileSync } from 'node:fs';
2
3
  import { acceptanceProtectionProblem } from '../check/command.js';
3
4
  import { join, relative } from 'node:path';
@@ -302,10 +303,14 @@ export function runLoop(opts) {
302
303
  let landed = null;
303
304
  try {
304
305
  opts.git.addWorktree(opts.targetDir, wt);
305
- const result = opts.runner({ targetDir: wt, story });
306
+ const result = runImplementation(opts, wt, story, reporter);
306
307
  iterations++;
307
308
  if (result.tokens)
308
309
  reporter.addTokens(result.tokens);
310
+ if (result.routing?.blocked) {
311
+ reporter.blocked(result.summary);
312
+ return { status: "blocked", iterations, reason: result.summary, finalProgress: progress(stories) };
313
+ }
309
314
  let decision;
310
315
  try {
311
316
  decision = consumeDecisionRequest(wt, opts.targetDir, story.id);
@@ -398,7 +403,6 @@ export function runLoop(opts) {
398
403
  reporter.blocked(protection);
399
404
  return { status: 'blocked', iterations, reason: protection, finalProgress: progress(stories) };
400
405
  }
401
- result.routing?.recordOutcome(true);
402
406
  // The worktree is a checkout of committed HEAD, so the agent above reads
403
407
  // context from HEAD's .yoke/context — commit context changes for --isolate
404
408
  // to honour them. We write the decision here so `integrate` carries it back.
@@ -412,6 +416,7 @@ export function runLoop(opts) {
412
416
  savePrd(wtPrd, updated);
413
417
  opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`, opts.commitIdentity);
414
418
  opts.git.integrate(opts.targetDir, wt);
419
+ result.routing?.recordOutcome(true);
415
420
  landed = progress(updated);
416
421
  }
417
422
  catch (e) {
@@ -433,10 +438,14 @@ export function runLoop(opts) {
433
438
  reporter.storyDone({ id: story.id, title: story.title }, landed);
434
439
  continue;
435
440
  }
436
- const result = opts.runner({ targetDir: opts.targetDir, story });
441
+ const result = runImplementation(opts, opts.targetDir, story, reporter);
437
442
  iterations++;
438
443
  if (result.tokens)
439
444
  reporter.addTokens(result.tokens);
445
+ if (result.routing?.blocked) {
446
+ reporter.blocked(result.summary);
447
+ return { status: "blocked", iterations, reason: result.summary, finalProgress: progress(stories) };
448
+ }
440
449
  let decision;
441
450
  try {
442
451
  decision = consumeDecisionRequest(opts.targetDir, opts.targetDir, story.id);
@@ -570,3 +579,35 @@ export function runLoop(opts) {
570
579
  reporter.storyDone({ id: story.id, title: story.title }, progress(updated));
571
580
  }
572
581
  }
582
+ function runImplementation(opts, dir, story, reporter) {
583
+ let feedback;
584
+ for (let attempt = 0;; attempt++) {
585
+ const result = opts.runner({ targetDir: dir, story, feedback });
586
+ if (!result.routing?.canRetry || result.routing.blocked || attempt >= 7)
587
+ return result;
588
+ if (["decision-request.yaml", "ambiguity.md", "loop.pause"].some(name => existsSync(join(dir, ".yoke", name))) || existsSync(pauseFilePath(opts.targetDir)))
589
+ return result;
590
+ const protection = acceptanceProtectionProblem(dir, opts.targetDir);
591
+ if (protection)
592
+ return result;
593
+ const criteria = runCriterionGates(opts, dir, story);
594
+ const gates = [opts.verify, opts.design, opts.perf, opts.audit].filter((gate) => Boolean(gate));
595
+ let verdict = criteria;
596
+ if (verdict.passed)
597
+ for (const gate of gates) {
598
+ verdict = runGate(gate, dir, story.id);
599
+ if (!verdict.passed)
600
+ break;
601
+ }
602
+ if (verdict.passed)
603
+ return result;
604
+ if (knownInfrastructureFailure(verdict.summary)) {
605
+ result.routing.recordOutcome(false, "infrastructure");
606
+ return result;
607
+ }
608
+ result.routing.recordOutcome(false);
609
+ if (result.tokens)
610
+ reporter.addTokens(result.tokens);
611
+ feedback = verdict.summary;
612
+ }
613
+ }
@@ -1,5 +1,8 @@
1
1
  import { existsSync, rmSync } from 'node:fs';
2
2
  import { acceptanceProtectionProblem } from '../check/command.js';
3
+ import { makeAsyncAdaptiveRunner } from '../routing/router.js';
4
+ import { startProviderProcess } from '../agents/process.js';
5
+ import { runnerInvocation, isAgentAvailable } from './runner.js';
3
6
  import { createDispatcher } from './dispatcher.js';
4
7
  import { consumeDecisionRequest } from './decision.js';
5
8
  import { pauseFilePath } from './loop.js';
@@ -45,6 +48,7 @@ export async function runParallelLoopCommand(input) {
45
48
  } : {}),
46
49
  pause,
47
50
  onProgress: status => input.reporter.parallel?.(status),
51
+ onAccepted: story => input.reporter.accepted?.(story),
48
52
  gates: {
49
53
  verify: input.verify,
50
54
  design: input.design,
@@ -202,7 +206,7 @@ function workerReporter(reporter, worker, candidate) {
202
206
  else
203
207
  reporter.quality(quality);
204
208
  },
205
- addTokens: usage => reporter.addTokens(usage),
209
+ addTokens: usage => reporter.addTokens({ ...usage, storyId: worker.story.id }),
206
210
  };
207
211
  }
208
212
  function candidateReporter(reporter, worker, candidate, state, reason) {
@@ -247,29 +251,66 @@ function reportIntegrator(reporter, worker, phase) {
247
251
  reporter.phase(phase);
248
252
  }
249
253
  function asyncRunner(input, provider, signal, workerId) {
250
- const { model: _globalModel, reasoningEffort: _globalReasoningEffort, ...selection } = input.selection;
254
+ if (input.routing) {
255
+ const routed = makeAsyncAdaptiveRunner({
256
+ parent: input.routing.strategy === 'capability' ? input.runnerAgent : provider.provider,
257
+ parentSelection: input.routing.strategy === 'capability' ? input.selection : { ...input.selection, model: provider.model, reasoningEffort: provider.reasoningEffort, nativeMultiAgent: false },
258
+ projectRoot: input.targetDir,
259
+ workers: input.routing.workers,
260
+ rules: input.routing.rules,
261
+ strategy: input.routing.strategy,
262
+ maxCandidates: input.routing.maxCandidates,
263
+ maxAttempts: input.routing.maxAttempts,
264
+ onDecision: (id, decision) => input.reporter.routingDecision?.(id, decision),
265
+ orchestratorSelection: input.routing.orchestrator,
266
+ isAvailable: input.isAvailable ?? isAgentAvailable,
267
+ captureRoute: async (agent, context, prompt, selection) => {
268
+ const result = await startProviderProcess(agent, runnerInvocation(agent, prompt, context.targetDir, true, 'read-only', selection), { idleTimeoutMs: input.idleMs, signal, workerId }).completion;
269
+ return { ...providerProcessResultToAgentResult(agent, context.story.id, result), output: result.stdout };
270
+ },
271
+ makeWorker: (agent, selection) => async (context) => {
272
+ if (signal?.aborted)
273
+ return { success: false, summary: 'Routing cancelled before worker start' };
274
+ input.reporter.parallelWorker?.({ story: context.story.id, storyTitle: context.story.title, provider: agent, selectedProvider: agent, selectedModel: selection.model, phase: 'implementing' });
275
+ const run = makeAsyncRunner(agent, { onAmbiguity: input.onAmbiguity, permissions: input.permissions, selection,
276
+ process: { idleTimeoutMs: input.idleMs, signal, workerId } });
277
+ return providerProcessResultToAgentResult(agent, context.story.id, await run(context).completion);
278
+ },
279
+ });
280
+ return context => context.story.agent && input.routing?.strategy !== 'capability'
281
+ ? asyncRunner({ ...input, routing: undefined }, provider, signal, workerId)(context)
282
+ : routed(context);
283
+ }
284
+ const { model: _globalModel, reasoningEffort: _globalReasoningEffort, nativeMultiAgent: _globalNative, bare: globalBare, ...selection } = input.selection;
251
285
  const runner = makeAsyncRunner(provider.provider, {
252
286
  onAmbiguity: input.onAmbiguity,
253
287
  permissions: input.permissions,
254
288
  selection: {
255
289
  ...selection,
256
- ...(provider.provider === 'codex' ? { nativeMultiAgent: false } : {}),
290
+ ...(provider.provider !== 'gemini' && globalBare !== undefined ? { bare: globalBare } : {}),
291
+ nativeMultiAgent: false,
257
292
  ...(provider.model ? { model: provider.model } : {}),
258
293
  ...(provider.reasoningEffort ? { reasoningEffort: provider.reasoningEffort } : {}),
259
294
  },
260
295
  process: { idleTimeoutMs: input.idleMs, signal, workerId },
261
296
  });
262
- return async (context) => providerProcessResultToAgentResult(provider.provider, context.story.id, await runner(context).completion);
297
+ return async (context) => {
298
+ const started = Date.now();
299
+ const result = providerProcessResultToAgentResult(provider.provider, context.story.id, await runner(context).completion);
300
+ if (result.tokens)
301
+ result.tokens = { ...result.tokens, provider: provider.provider, role: 'worker', storyId: context.story.id, durationMs: Date.now() - started };
302
+ return result;
303
+ };
263
304
  }
264
305
  export function providerProcessResultToAgentResult(agent, storyId, result) {
265
306
  const tokens = result.telemetry.tokens;
266
307
  const telemetry = tokens ? { tokens } : {};
267
308
  switch (result.kind) {
268
309
  case 'succeeded': return { success: true, summary: `${agent} implemented ${storyId}`, ...telemetry };
269
- case 'cancelled': return { success: false, summary: result.reason, ...telemetry };
270
- case 'timed-out': return { success: false, summary: result.reason, ...telemetry };
271
- case 'spawn-failed': return { success: false, summary: result.error, ...telemetry };
272
- case 'failed': return { success: false, summary: `${agent} exited ${result.exitCode ?? 'without a code'}`, ...telemetry };
310
+ case 'cancelled': return { success: false, infrastructureFailure: true, summary: result.reason, ...telemetry };
311
+ case 'timed-out': return { success: false, infrastructureFailure: true, summary: result.reason, ...telemetry };
312
+ case 'spawn-failed': return { success: false, infrastructureFailure: true, summary: result.error, ...telemetry };
313
+ case 'failed': return { success: false, infrastructureFailure: true, summary: `${agent} exited ${result.exitCode ?? 'without a code'}`, ...telemetry };
273
314
  default: return assertNever(result);
274
315
  }
275
316
  }
package/dist/loop/prd.js CHANGED
@@ -4,6 +4,7 @@ import { parse, stringify } from 'yaml';
4
4
  import { z } from 'zod';
5
5
  import { StoryQualityDeclarationSchema } from '../quality/types.js';
6
6
  import { validWriteScope } from './scheduler.js';
7
+ import { AssessmentSchema } from '../routing/assessment.js';
7
8
  export const AcceptanceCriterionSchema = z.object({
8
9
  id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
9
10
  text: z.string().min(1),
@@ -45,6 +46,7 @@ export const StorySchema = z.object({
45
46
  /** Inbox request that created this story. Used for idempotent append-only intake. */
46
47
  sourceChange: z.string().min(1).optional(),
47
48
  quality: StoryQualityDeclarationSchema.optional(),
49
+ assessment: AssessmentSchema.optional(),
48
50
  }).superRefine((story, ctx) => {
49
51
  const structured = story.acceptance.filter(isAcceptanceCriterion);
50
52
  const ids = structured.map(criterion => criterion.id);
@@ -93,6 +93,7 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
93
93
  let tokens;
94
94
  const runId = randomUUID();
95
95
  let measuredCalls = 0;
96
+ let unknownCalls = 0;
96
97
  let measuredCosts = 0;
97
98
  let unmeasuredAttempts = 0;
98
99
  const attempts = new Map();
@@ -143,8 +144,10 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
143
144
  };
144
145
  const persist = (status, logLabel, consoleLine) => {
145
146
  const time = Date.parse(status.updatedAt);
146
- if (logLabel === 'story-done')
147
+ if (logLabel === 'story-done') {
147
148
  finishAttempt('serial', 'completed', time);
149
+ appendEvent(dir, { runId, timestamp: status.updatedAt, type: 'accepted', storyId: status.story });
150
+ }
148
151
  else if (status.state !== 'running')
149
152
  for (const key of [...attempts.keys()])
150
153
  finishAttempt(key, status.state, time);
@@ -163,7 +166,7 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
163
166
  if (key.startsWith('parallel:') && !workerKeys.has(key))
164
167
  finishAttempt(key, 'worker-ended', time);
165
168
  const withPercent = { ...status, percent: percentOf(status.progress) };
166
- const measurement = { measuredCalls, unmeasuredAttempts, usageAvailable: measuredCalls > 0, costAvailable: measuredCosts === 0 ? 'unknown' : measuredCosts === measuredCalls && unmeasuredAttempts === 0 && tokens?.measurementComplete !== false && tokens?.costMeasurementComplete !== false ? 'measured' : 'partial' };
169
+ const measurement = { measuredCalls, unknownCalls, unmeasuredAttempts, usageAvailable: measuredCalls > 0, costAvailable: measuredCosts === 0 ? 'unknown' : measuredCosts === measuredCalls && unknownCalls === 0 && unmeasuredAttempts === 0 && tokens?.measurementComplete !== false && tokens?.costMeasurementComplete !== false ? 'measured' : 'partial' };
167
170
  const next = { ...withPercent, ...(tokens ? { tokens: { ...tokens } } : {}), measurement };
168
171
  current = next;
169
172
  appendEvent(dir, { runId, timestamp: status.updatedAt, type: 'status', ...(status.story ? { storyId: status.story } : {}), data: { ...next } });
@@ -179,6 +182,17 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
179
182
  emitConsole(consoleLine);
180
183
  };
181
184
  return {
185
+ routingDecision(storyId, decision) {
186
+ if (current)
187
+ persist({ ...current, routingDecisions: { ...current.routingDecisions, [storyId]: decision }, updatedAt: now().toISOString() }, "routing", ` · route ${storyId}: ${decision.profile} (${decision.reason})`);
188
+ },
189
+ execution(provider, requestedModel) {
190
+ if (current)
191
+ persist({ ...current, execution: { provider, requestedModel, startedAt: now().toISOString() }, updatedAt: now().toISOString() }, 'execution', ` · ${provider}/${requestedModel ?? 'provider-default'}`);
192
+ },
193
+ accepted(story) {
194
+ appendEvent(dir, { runId, timestamp: now().toISOString(), type: 'accepted', storyId: story.id });
195
+ },
182
196
  storyStart(story, iteration, progress) {
183
197
  const ts = now().toISOString();
184
198
  finishAttempt('serial', 'superseded', Date.parse(ts));
@@ -237,10 +251,15 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
237
251
  return previous
238
252
  ? {
239
253
  ...worker,
254
+ startedAt: previous.startedAt,
255
+ selectedProvider: previous.selectedProvider,
256
+ selectedModel: previous.selectedModel,
257
+ ...(previous.model ? { model: previous.model } : {}),
258
+ provider: previous.provider,
240
259
  ...(previous.phase ? { phase: previous.phase } : {}),
241
260
  ...(previous.quality ? { quality: previous.quality } : {}),
242
261
  }
243
- : worker;
262
+ : { ...worker, startedAt: now().toISOString() };
244
263
  });
245
264
  const reportedCandidates = previousWorkers.filter(worker => worker.candidateId && !(workers ?? []).some(candidate => sameWorker(candidate, worker)));
246
265
  const parallel = {
@@ -299,13 +318,14 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
299
318
  if (value !== undefined && (!Number.isFinite(value) || value < 0))
300
319
  delete usage[key];
301
320
  }
302
- measuredCalls++;
303
- if (usage.totalCostUsd !== undefined && Number.isFinite(usage.totalCostUsd) && usage.totalCostUsd >= 0)
304
- measuredCosts++;
305
- const attempt = attempts.get('serial');
321
+ const calls = usage.calls?.length ? usage.calls : [{ usageAvailable: usage.measurementComplete !== false, totalCostUsd: usage.totalCostUsd }];
322
+ measuredCalls += calls.filter(call => call.usageAvailable !== false).length;
323
+ unknownCalls += calls.filter(call => call.usageAvailable === false).length;
324
+ measuredCosts += calls.filter(call => call.totalCostUsd !== undefined && Number.isFinite(call.totalCostUsd) && call.totalCostUsd >= 0).length;
325
+ const attempt = usage.storyId ? [...attempts.values()].find(attempt => attempt.storyId === usage.storyId) : attempts.get('serial');
306
326
  if (attempt)
307
- attempt.usageAvailable = true;
308
- appendEvent(dir, { runId, timestamp: now().toISOString(), type: 'tokens', ...(attempt ? { storyId: attempt.storyId, attemptId: attempt.id } : {}), data: { usageAvailable: true, ...usage } });
327
+ attempt.usageAvailable = usage.measurementComplete !== false;
328
+ appendEvent(dir, { runId, timestamp: now().toISOString(), type: 'tokens', ...(usage.storyId ? { storyId: usage.storyId } : {}), ...(usage.durationMs !== undefined ? { durationMs: usage.durationMs } : {}), ...(attempt ? { storyId: attempt.storyId, attemptId: attempt.id } : {}), data: { usageAvailable: usage.measurementComplete !== false, ...usage } });
309
329
  const model = usage.model ?? tokens?.model;
310
330
  const cachedInputTokens = (tokens?.cachedInputTokens ?? 0) + (usage.cachedInputTokens ?? 0);
311
331
  const cacheWriteInputTokens = (tokens?.cacheWriteInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0);
@@ -1,3 +1,4 @@
1
+ import { roleSelection } from "../routing/capability.js";
1
2
  import { join } from 'node:path';
2
3
  import { existsSync } from 'node:fs';
3
4
  import { loadConfig, saveConfig, defaultConfig, resolveOutputPolicy, resolveVerifyCommand } from '../retrofit/config.js';
@@ -97,9 +98,9 @@ export function resolveIdleMs(flagMinutes, configMinutes) {
97
98
  return minutes > 0 ? minutes * 60_000 : 0;
98
99
  }
99
100
  export function runLoopCommand(targetDir, opts) {
100
- const parallel = opts.parallel ?? 1;
101
+ let parallel = opts.parallel ?? 1;
101
102
  const candidates = opts.candidates ?? 1;
102
- if (opts.resumeWorktree && (!opts.isolate || parallel !== 1 || candidates !== 1)) {
103
+ if (opts.resumeWorktree && (opts.isolate === false || parallel !== 1 || candidates !== 1)) {
103
104
  console.error('--resume-worktree requires --isolate --parallel=1 --candidates=1');
104
105
  return 2;
105
106
  }
@@ -145,6 +146,18 @@ export function runLoopCommand(targetDir, opts) {
145
146
  console.error(`No PRD found at ${path}. Create one (see canon loop/prd.schema.md).`);
146
147
  return 2;
147
148
  }
149
+ const parallelSetting = opts.parallelAuto ? 'auto' : opts.parallel ?? config.loop.parallel ?? 'auto';
150
+ if (parallelSetting === 'auto') {
151
+ const pending = loadPrd(path).filter(story => !story.passes);
152
+ parallel = !opts.resumeWorktree && !config.actions?.length && pending.length > 1 && pending.every(story => story.writes?.length) ? 3 : 1;
153
+ }
154
+ else
155
+ parallel = parallelSetting;
156
+ const isolate = opts.isolate ?? config.loop.isolate ?? true;
157
+ if (opts.resumeWorktree && (!isolate || parallel !== 1)) {
158
+ console.error('--resume-worktree requires isolation and one worker');
159
+ return 2;
160
+ }
148
161
  if (candidates > 1) {
149
162
  const missingQuality = loadPrd(path).find(story => !story.passes && !story.quality);
150
163
  if (missingQuality) {
@@ -229,11 +242,13 @@ export function runLoopCommand(targetDir, opts) {
229
242
  console.error('Quality candidate selection requires quality.critic.model (or legacy quality.criticModel) before any runner or worktree is started.');
230
243
  return 2;
231
244
  }
245
+ let executionReporter;
232
246
  const quality = createQualityCommandHooks({
233
247
  targetDir,
234
- config,
248
+ config: opts.routing === false ? { ...config, routing: undefined } : config,
235
249
  runnerAgent,
236
250
  idleMs,
251
+ onUsage: usage => executionReporter?.addTokens(usage),
237
252
  policy: qualityOverrides,
238
253
  ...(opts.qualityRuntime ? { runtime: opts.qualityRuntime } : {}),
239
254
  });
@@ -246,23 +261,20 @@ export function runLoopCommand(targetDir, opts) {
246
261
  announce(`Quality: ${resolved.policy} · critic: ${criticAgent}${configuredCriticModel ? `/${configuredCriticModel}` : '/provider-default'} · repair: ${repairAgent}${config.quality?.repair?.model ?? config.quality?.repairModel ? `/${config.quality?.repair?.model ?? config.quality?.repairModel}` : '/provider-default'} · permissions: read-only critic/safe repair · budget: ${limit}`);
247
262
  }
248
263
  const permissions = opts.permissions ?? config.runner?.permissions ?? 'safe';
249
- const routingEnabled = opts.routing ?? config.routing?.enabled ?? false;
264
+ const routingRequested = opts.routing ?? config.routing?.enabled ?? true;
265
+ const routingEnabled = routingRequested && Boolean(config.routing?.workers.length);
250
266
  const runnerSelection = {
251
267
  model: config.runner?.model,
252
268
  reasoningEffort: config.runner?.reasoningEffort,
253
269
  bare: config.runner?.bare,
254
- ...((runnerAgent === 'codex' && (routingEnabled || opts.routing === false)) ? { nativeMultiAgent: false } : {}),
270
+ nativeMultiAgent: false,
255
271
  };
256
- if ((parallel > 1 || candidates > 1) && routingEnabled) {
257
- console.error('Adaptive routing is not available with parallel workers or quality candidates. Run with --parallel=1 --candidates=1 or disable routing.');
258
- return 2;
259
- }
260
272
  const parallelProviders = [{
261
273
  provider: runnerAgent,
262
274
  ...(runnerSelection.model ? { model: runnerSelection.model } : {}),
263
275
  ...(runnerSelection.reasoningEffort ? { reasoningEffort: runnerSelection.reasoningEffort } : {}),
264
276
  }];
265
- const parallelAffinityProviders = (config.routing?.workers ?? []).map(worker => ({
277
+ const parallelAffinityProviders = config.routing?.strategy === 'capability' ? config.agents.map(agent => ({ provider: agent, ...(agent === runnerAgent ? runnerSelection : {}) })) : (config.routing?.workers ?? []).map(worker => ({
266
278
  provider: worker.agent,
267
279
  ...(worker.model ? { model: worker.model } : {}),
268
280
  ...(worker.reasoningEffort ? { reasoningEffort: worker.reasoningEffort } : {}),
@@ -291,7 +303,7 @@ export function runLoopCommand(targetDir, opts) {
291
303
  }
292
304
  }
293
305
  const ambiguityPolicy = opts.decisionPolicy ?? opts.onAmbiguity ?? config.loop.decisionPolicy ?? config.loop.onAmbiguity ?? 'auto';
294
- if (routingEnabled && (!config.routing || config.routing.workers.length === 0)) {
306
+ if (opts.routing === true && (!config.routing || config.routing.workers.length === 0)) {
295
307
  console.error('Adaptive routing was requested, but no worker profiles are configured. Run yoke setup . --routing or add routing.workers to .yoke/config.yaml.');
296
308
  return 2;
297
309
  }
@@ -321,6 +333,7 @@ export function runLoopCommand(targetDir, opts) {
321
333
  // Token reporting is part of the machine interface: in --json mode a claude
322
334
  // runner switches to stream-json so cumulative usage rides on every status.
323
335
  const runnerOpts = {
336
+ onStart: (agent, selection) => executionReporter?.execution?.(agent, selection.model),
324
337
  tokenReport: opts.json === true,
325
338
  onAmbiguity: ambiguityPolicy,
326
339
  perfCommand: config.perf?.command,
@@ -330,12 +343,15 @@ export function runLoopCommand(targetDir, opts) {
330
343
  runner = routingEnabled && config.routing
331
344
  ? makeAdaptiveRunner({
332
345
  parent: runnerAgent,
346
+ projectRoot: targetDir,
333
347
  parentSelection: runnerOpts.selection,
334
348
  orchestratorSelection: config.routing.orchestrator ?? runnerOpts.selection,
335
349
  workers: config.routing.workers,
336
350
  rules: config.routing.rules,
337
351
  strategy: config.routing.strategy,
338
352
  maxCandidates: config.routing.maxCandidates,
353
+ maxAttempts: config.routing.maxAttempts,
354
+ onDecision: (id, decision) => executionReporter?.routingDecision?.(id, decision),
339
355
  idleTimeoutMs: idleMs,
340
356
  permissions,
341
357
  runnerOpts,
@@ -343,7 +359,7 @@ export function runLoopCommand(targetDir, opts) {
343
359
  })
344
360
  : makeRunner(runnerAgent, idleMs, runnerOpts);
345
361
  const announce = opts.json ? console.error : console.log;
346
- announce(`Runner: ${runnerAgent} · permissions: ${permissions} · routing: ${routingEnabled ? 'on' : 'off'} · cwd: ${targetDir}`);
362
+ announce(`Runner: ${runnerAgent} · permissions: ${permissions} · routing: ${routingEnabled ? 'on' : routingRequested ? 'auto (parent; no worker profiles)' : 'off'} · workers: ${parallel} · isolation: ${isolate || parallel > 1 ? 'on' : 'off'} · cwd: ${targetDir}`);
347
363
  }
348
364
  if (config.actions?.length) {
349
365
  if (parallel > 1 || candidates > 1) {
@@ -353,6 +369,7 @@ export function runLoopCommand(targetDir, opts) {
353
369
  runner = makeActionRunner(config.actions, runner);
354
370
  }
355
371
  let review = opts.reviewRunner;
372
+ let reviewProvider = 'unknown';
356
373
  if (!review && (opts.review || opts.reviewer)) {
357
374
  const reviewerAgent = opts.reviewer ?? ['codex', 'gemini', 'claude'].find(agent => agent !== runnerAgent && available(agent));
358
375
  if (!reviewerAgent) {
@@ -362,6 +379,7 @@ export function runLoopCommand(targetDir, opts) {
362
379
  }
363
380
  }
364
381
  const resolvedReviewer = reviewerAgent ?? runnerAgent;
382
+ reviewProvider = resolvedReviewer;
365
383
  if (resolvedReviewer === runnerAgent && !opts.allowSelfReview) {
366
384
  console.error(`Reviewer "${resolvedReviewer}" is also the implementer. Pick another agent or pass --allow-self-review explicitly.`);
367
385
  return 2;
@@ -370,7 +388,29 @@ export function runLoopCommand(targetDir, opts) {
370
388
  console.error(`Reviewer agent CLI "${resolvedReviewer}" was not found on PATH. Install it, or pick another with --reviewer=<claude|codex|gemini>.`);
371
389
  return 2;
372
390
  }
373
- review = makeReviewRunner(resolvedReviewer, idleMs);
391
+ review = context => {
392
+ const implementer = readStatus(targetDir)?.routingDecisions?.[context.story.id]?.provider ?? runnerAgent;
393
+ const selectedReviewer = !opts.reviewer && resolvedReviewer === implementer
394
+ ? ["codex", "claude", "gemini"].find(agent => agent !== implementer && available(agent)) ?? resolvedReviewer : resolvedReviewer;
395
+ if (selectedReviewer === implementer && !opts.allowSelfReview)
396
+ return { success: false, summary: "Independent review requires a provider distinct from the routed implementer", reviewOutcome: { kind: "infrastructure", summary: "Routed implementation and reviewer share a provider" } };
397
+ reviewProvider = selectedReviewer;
398
+ return makeReviewRunner(selectedReviewer, idleMs, undefined, routingEnabled ? roleSelection(targetDir, config, context.story, selectedReviewer, "reviewer") : undefined)(context);
399
+ };
400
+ }
401
+ if (review) {
402
+ const reviewRunner = review;
403
+ review = context => {
404
+ const started = Date.now();
405
+ let result;
406
+ try {
407
+ result = reviewRunner(context);
408
+ return result;
409
+ }
410
+ finally {
411
+ executionReporter?.addTokens({ inputTokens: 0, outputTokens: 0, measurementComplete: result?.tokens !== undefined, ...result?.tokens, provider: reviewProvider, role: 'reviewer', storyId: context.story.id, durationMs: Date.now() - started });
412
+ }
413
+ };
374
414
  }
375
415
  let lock;
376
416
  try {
@@ -388,12 +428,13 @@ export function runLoopCommand(targetDir, opts) {
388
428
  console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
389
429
  }
390
430
  const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json });
431
+ executionReporter = reporter;
391
432
  const buildResume = (storyId, requestId) => buildTrustedDecisionResumeState({
392
433
  storyId,
393
434
  requestId,
394
435
  ...(opts.maxIterations !== undefined ? { maxIterations: opts.maxIterations } : {}),
395
436
  agent: runnerAgent,
396
- isolate: parallel > 1 || candidates > 1 || (opts.isolate ?? false),
437
+ isolate: parallel > 1 || candidates > 1 || isolate,
397
438
  reviewer: opts.reviewer,
398
439
  review: opts.review === true || opts.reviewRunner !== undefined,
399
440
  allowSelfReview: opts.allowSelfReview ?? false,
@@ -440,6 +481,8 @@ export function runLoopCommand(targetDir, opts) {
440
481
  selection: runnerSelection,
441
482
  providers: parallelProviders,
442
483
  affinityProviders: parallelAffinityProviders,
484
+ routing: routingEnabled ? config.routing : undefined,
485
+ isAvailable: available,
443
486
  onAmbiguity: ambiguityPolicy,
444
487
  git: opts.git,
445
488
  identity: commitIdentity,
@@ -473,7 +516,7 @@ export function runLoopCommand(targetDir, opts) {
473
516
  perf,
474
517
  audit,
475
518
  maxIterations,
476
- isolate: (opts.parallel ?? 1) > 1 ? true : (opts.isolate ?? false),
519
+ isolate,
477
520
  review,
478
521
  reporter,
479
522
  ...(quality ?? {}),