@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
@@ -52,10 +52,12 @@ export function prepareIsolatedWorktree(directory, worktree, resume) {
52
52
  // Record is outside the worker checkout and binds reuse to its source state.
53
53
  writeFileSync(record, JSON.stringify({ version: 1, root, worktree: wt, base, prdHash }), { mode: 0o600 });
54
54
  }
55
- const ParallelRecovery = z.object({
55
+ const LegacyParallelRecovery = z.object({
56
56
  version: z.literal(1), root: z.string().max(4096), storyId: z.string().max(1024), worktree: z.string().max(4096), baseCommit: z.string().max(128),
57
57
  prdHash: z.string().length(64), ownerToken: z.string().min(1).max(256), reason: z.string().max(16384), state: z.literal('retained'), recordedAt: z.string().datetime(),
58
58
  }).strict();
59
+ const CurrentParallelRecovery = LegacyParallelRecovery.extend({ version: z.literal(2), phase: z.enum(['implementation', 'integration']) });
60
+ const ParallelRecovery = z.discriminatedUnion('version', [LegacyParallelRecovery, CurrentParallelRecovery]);
59
61
  export function parallelAcceptanceDigest(directory) {
60
62
  // Accepted sibling stories change only passes. Their acceptance contracts remain protected.
61
63
  const contract = loadPrd(join(directory, '.yoke', 'prd.yaml')).map(({ passes: _passes, ...story }) => story);
@@ -84,7 +86,7 @@ export function discardParallelRecoveryRecords(directory) {
84
86
  export function retainParallelWorktree(directory, file, input) {
85
87
  const root = realpathSync(directory);
86
88
  const worktree = realpathSync(input.worktree);
87
- const record = ParallelRecovery.parse({ version: 1, root, ...input, reason: input.reason.slice(0, 16384), worktree, state: 'retained', recordedAt: new Date().toISOString() });
89
+ const record = CurrentParallelRecovery.parse({ version: 2, root, ...input, phase: input.phase ?? 'integration', reason: input.reason.slice(0, 16384), worktree, state: 'retained', recordedAt: new Date().toISOString() });
88
90
  const safe = parallelRecordPath(directory, file);
89
91
  mkdirSync(dirname(safe), { recursive: true });
90
92
  const temp = statePath(directory, 'integration-recovery', `${randomUUID()}.tmp`);
@@ -107,6 +109,8 @@ export function recoverParallelWorktree(directory, file, storyId) {
107
109
  if (stat.size > 65536)
108
110
  throw new Error('Parallel recovery record is too large');
109
111
  const saved = ParallelRecovery.parse(JSON.parse(readFileSync(safe, 'utf8')));
112
+ // Existing records describe independently checked candidates awaiting integration.
113
+ const phase = saved.version === 1 ? 'integration' : saved.phase;
110
114
  if (!existsSync(saved.worktree))
111
115
  throw new Error(`Retained candidate is missing: ${saved.worktree}; resolve its recovery record before retrying`);
112
116
  const root = realpathSync(directory);
@@ -117,8 +121,22 @@ export function recoverParallelWorktree(directory, file, storyId) {
117
121
  if (saved.storyId !== storyId || pathIdentity(saved.root) !== pathIdentity(root) || pathIdentity(actual) !== pathIdentity(saved.worktree) || isAbsolute(rel) || rel !== expectedName)
118
122
  throw new Error('Retained candidate ownership or path binding is invalid');
119
123
  const git = (args, cwd = root) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe' }).trim();
120
- if (saved.baseCommit !== git(['rev-parse', 'HEAD']) || saved.prdHash !== parallelAcceptanceDigest(root))
121
- throw new Error(`Retained candidate is stale against target or PRD: ${actual}; reconcile it before retrying`);
124
+ const stale = () => new Error(`Retained candidate is stale against target or PRD: ${actual}; reconcile it before retrying`);
125
+ if (saved.prdHash !== parallelAcceptanceDigest(root))
126
+ throw stale();
127
+ if (saved.baseCommit !== git(['rev-parse', 'HEAD'])) {
128
+ if (phase === 'integration')
129
+ throw stale();
130
+ // Siblings may have integrated while this worker was still incomplete. Reuse
131
+ // its original checkout only for a forward target; integration still rebases
132
+ // and independently verifies the combined tree before accepting the story.
133
+ try {
134
+ git(['merge-base', '--is-ancestor', saved.baseCommit, 'HEAD']);
135
+ }
136
+ catch {
137
+ throw stale();
138
+ }
139
+ }
122
140
  const common = realpathSync(resolve(root, git(['rev-parse', '--git-common-dir'])));
123
141
  if (pathIdentity(realpathSync(resolve(actual, git(['rev-parse', '--git-common-dir'], actual)))) !== pathIdentity(common))
124
142
  throw new Error('Retained candidate belongs to another repository');
@@ -126,5 +144,5 @@ export function recoverParallelWorktree(directory, file, storyId) {
126
144
  if (!registered.some(path => pathIdentity(path) === pathIdentity(actual)))
127
145
  throw new Error('Retained candidate is not a registered worktree');
128
146
  git(['merge-base', '--is-ancestor', saved.baseCommit, 'HEAD'], actual);
129
- return { path: actual, baseCommit: saved.baseCommit, recovered: true, ownerToken: saved.ownerToken };
147
+ return { path: actual, baseCommit: saved.baseCommit, recovered: true, ownerToken: saved.ownerToken, recovery: { phase, feedback: saved.reason } };
130
148
  }
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { appendEvent } from '../observability/events.js';
6
6
  import { estimateDurations, validDuration } from '../estimation/durations.js';
7
+ import { recordActiveRoutingUsage, markActiveRoutingUsageIncomplete } from '../routing/attempts.js';
7
8
  export const LOG_CAP_BYTES = 256 * 1024;
8
9
  // Append a line to .yoke/loop.log, keeping the file bounded: once it exceeds
9
10
  // capBytes, truncate to the recent tail (starting at a line boundary) so the log
@@ -225,15 +226,15 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
225
226
  const base = phase === 'exploring' || phase === 'waiting-exploration' || phase === 'waiting-recovery'
226
227
  ? (({ story: _story, storyTitle: _storyTitle, ...withoutStory }) => withoutStory)(status)
227
228
  : status;
228
- persist({ ...base, state: 'running', phase, ...(progress ? { progress } : {}), ...(reason ? { reason } : { reason: undefined }), updatedAt: now().toISOString() }, phase, ` · ${phase}…`);
229
+ persist({ ...base, state: 'running', phase, failure: undefined, ...(progress ? { progress } : {}), ...(reason ? { reason } : { reason: undefined }), updatedAt: now().toISOString() }, phase, ` · ${phase}…`);
229
230
  },
230
- blocked(reason) {
231
+ blocked(reason, failure) {
231
232
  const base = current ?? emptyStatus(now().toISOString());
232
- persist({ ...withoutParallel(base), state: 'blocked', reason, updatedAt: now().toISOString() }, 'blocked', `■ blocked on ${base.story ?? '?'}: ${reason}`);
233
+ persist({ ...withoutParallel(base), state: 'blocked', reason, failure, updatedAt: now().toISOString() }, 'blocked', `■ blocked on ${base.story ?? '?'}: ${reason}`);
233
234
  },
234
235
  complete(progress) {
235
236
  persist({ ...withoutParallel(current ?? emptyStatus(now().toISOString())), state: 'complete', phase: undefined,
236
- progress, reason: undefined, updatedAt: now().toISOString() }, 'complete', `✔ loop complete — ${progress.passed}/${progress.total}`);
237
+ progress, reason: undefined, failure: undefined, updatedAt: now().toISOString() }, 'complete', `✔ loop complete — ${progress.passed}/${progress.total}`);
237
238
  },
238
239
  capReached(progress) {
239
240
  persist({ ...withoutParallel(current ?? emptyStatus(now().toISOString())), state: 'cap-reached', phase: undefined,
@@ -345,6 +346,22 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
345
346
  if (value !== undefined && (!Number.isFinite(value) || value < 0))
346
347
  delete usage[key];
347
348
  }
349
+ if (!usage.calls?.length && !usage.callId)
350
+ usage.callId = randomUUID();
351
+ const costRole = usage.role && ['reviewer', 'critic', 'repair', 'quality-critic', 'quality-repair', 'candidate-selection'].includes(usage.role);
352
+ if (usage.storyId && (usage.routingAttemptId || (costRole && !usage.calls?.length))) {
353
+ try {
354
+ recordActiveRoutingUsage(dir, usage.storyId, usage);
355
+ }
356
+ catch {
357
+ // Accounting failures must disqualify economic evidence without losing
358
+ // the independent event below or interrupting a useful implementation.
359
+ try {
360
+ markActiveRoutingUsageIncomplete(dir, usage.storyId);
361
+ }
362
+ catch { /* Router finalization also fails closed on corrupt accounting. */ }
363
+ }
364
+ }
348
365
  const calls = usage.calls?.length ? usage.calls : [{ usageAvailable: usage.measurementComplete !== false, totalCostUsd: usage.totalCostUsd }];
349
366
  measuredCalls += calls.filter(call => call.usageAvailable !== false).length;
350
367
  unknownCalls += calls.filter(call => call.usageAvailable === false).length;
@@ -1,4 +1,5 @@
1
1
  import { roleSelection } from "../routing/capability.js";
2
+ import { createHash } from 'node:crypto';
2
3
  import { join } from 'node:path';
3
4
  import { existsSync, unlinkSync } from 'node:fs';
4
5
  import { loadConfig, saveConfig, defaultConfig, resolveOutputPolicy, resolveVerifyCommand } from '../retrofit/config.js';
@@ -31,6 +32,15 @@ import { MAX_PROJECT_WORKERS, sharedPoolStatus, withSharedWorkerSync } from './r
31
32
  import { runPrdExplore } from '../prd/explore.js';
32
33
  export const DEFAULT_IDLE_MINUTES = 20;
33
34
  const STALE_MINUTES = 20; // a running status older than this likely means the loop died
35
+ const nativeReporters = new WeakSet();
36
+ function executionPolicyFingerprint(policy) {
37
+ const canonical = JSON.stringify(policy, (_key, value) => {
38
+ if (!value || typeof value !== 'object' || Array.isArray(value))
39
+ return value;
40
+ return Object.fromEntries(Object.keys(value).sort().map(key => [key, value[key]]));
41
+ });
42
+ return createHash('sha256').update(canonical).digest('hex');
43
+ }
34
44
  export function relativeTime(fromIso, now) {
35
45
  const ms = Math.max(0, now.getTime() - Date.parse(fromIso));
36
46
  const s = Math.floor(ms / 1000);
@@ -53,7 +63,7 @@ export function setLoopEnabled(targetDir, enabled) {
53
63
  config.loop = { ...config.loop, enabled };
54
64
  saveConfig(targetDir, config);
55
65
  }
56
- export function loopStatus(targetDir, now = () => new Date()) {
66
+ export function loopStatus(targetDir, now = () => new Date(), opts) {
57
67
  const config = loadConfig(targetDir);
58
68
  const enabled = config?.loop.enabled ?? false;
59
69
  const path = prdPath(targetDir);
@@ -62,6 +72,12 @@ export function loopStatus(targetDir, now = () => new Date()) {
62
72
  const p = progress(loadPrd(path));
63
73
  prog = `${p.passed}/${p.total} stories pass`;
64
74
  }
75
+ const st = readStatus(targetDir);
76
+ if (opts?.compact) {
77
+ if (!st)
78
+ return `state=${enabled ? 'enabled' : 'disabled'} prd="${prog}"`;
79
+ return `state=${st.state} story=${st.story ?? 'none'} progress=${st.progress.passed}/${st.progress.total} phase=${st.phase} updated=${relativeTime(st.updatedAt, now())}`;
80
+ }
65
81
  const sharedPoolLine = () => {
66
82
  try {
67
83
  const pool = sharedPoolStatus();
@@ -71,7 +87,6 @@ export function loopStatus(targetDir, now = () => new Date()) {
71
87
  return `Shared pool unavailable: ${error.message}`;
72
88
  }
73
89
  };
74
- const st = readStatus(targetDir);
75
90
  if (!st)
76
91
  return `Loop: ${enabled ? 'enabled' : 'disabled'}\nPRD: ${prog}\n${sharedPoolLine()}`;
77
92
  const head = `Loop: ${st.state.toUpperCase()}${st.story ? ` on ${st.story}${st.storyTitle ? ` "${st.storyTitle}"` : ''}` : ''}`;
@@ -236,6 +251,8 @@ async function runContinuousExploration(targetDir, options) {
236
251
  }
237
252
  const limitDeadline = options.savedRun?.exploreDeadline ?? (options.exploreLimitMs === undefined ? undefined : Date.now() + options.exploreLimitMs);
238
253
  const reporter = options.reporter ?? makeReporter(targetDir, { json: options.json, quiet: true });
254
+ if (!options.reporter)
255
+ nativeReporters.add(reporter);
239
256
  let config;
240
257
  try {
241
258
  config = loadConfig(targetDir);
@@ -256,6 +273,7 @@ async function runContinuousExploration(targetDir, options) {
256
273
  let reviewerAgent = options.reviewer ?? (reviewRequested ? SUPPORTED_AGENTS.find(agent => agent !== defaultImplementationAgent && available(agent)) : undefined);
257
274
  let explorationAgent = defaultExplorer;
258
275
  let retryWorktree = false;
276
+ let retryFeedback;
259
277
  const safeBatchSize = () => {
260
278
  if (retryWorktree)
261
279
  return 1;
@@ -360,6 +378,7 @@ async function runContinuousExploration(targetDir, options) {
360
378
  resultCode = await Promise.resolve(runLoopCommand(targetDir, {
361
379
  ...innerOptions,
362
380
  agent: implementationAgent,
381
+ recoveryFeedback: retryFeedback,
363
382
  ...(reviewRequested && reviewerAgent ? { reviewer: reviewerAgent } : {}),
364
383
  ...(batchLimit !== undefined ? { maxIterations: batchLimit } : {}),
365
384
  ...(retryWorktree ? { resumeWorktree: true, parallel: 1, candidates: 1 } : {}),
@@ -450,7 +469,11 @@ async function runContinuousExploration(targetDir, options) {
450
469
  }
451
470
  continue;
452
471
  }
453
- if (afterStatus?.reason?.startsWith('integrated completion gate failed') && allCurrentStoriesPass(targetDir)) {
472
+ if (afterStatus?.failure?.kind === 'no-progress') {
473
+ reporter.blocked(afterStatus.reason ?? 'Automatic continuation stopped after unchanged failures', afterStatus.failure);
474
+ return 1;
475
+ }
476
+ if (afterStatus?.failure?.kind === 'completion-failed' && allCurrentStoriesPass(targetDir)) {
454
477
  retryWorktree = false;
455
478
  reporter.phase('exploring', `all planned tasks pass, but ${afterStatus.reason}; looking for work that can resolve the completion gate`, currentProgress(targetDir));
456
479
  const scan = await scanForWork(`The integrated completion gate is still failing: ${afterStatus.reason}`);
@@ -473,6 +496,7 @@ async function runContinuousExploration(targetDir, options) {
473
496
  continue;
474
497
  }
475
498
  retryCount++;
499
+ retryFeedback = afterStatus?.reason;
476
500
  advanceRecoveryProviders();
477
501
  retryWorktree = (afterStatus?.parallel?.reopened ?? 0) === 0;
478
502
  const wait = retryDelay(retryCount);
@@ -574,9 +598,10 @@ export function runLoopCommand(targetDir, opts) {
574
598
  }
575
599
  }
576
600
  const outputPolicy = resolveOutputPolicy(config);
601
+ const configuredVerifyCommand = opts.verify ? undefined : resolveVerifyCommand(targetDir, config);
577
602
  let verify = opts.verify;
578
603
  if (!verify) {
579
- const command = resolveVerifyCommand(targetDir, config);
604
+ const command = configuredVerifyCommand;
580
605
  if (!command) {
581
606
  console.error('No verify command configured. Set verify.command in .yoke/config.yaml (e.g. "npm test") so the loop can confirm tests pass before marking work done.');
582
607
  return 2;
@@ -747,6 +772,73 @@ export function runLoopCommand(targetDir, opts) {
747
772
  selection: runnerSelection,
748
773
  commit: (_path, request) => commitPaths(targetDir, ['.yoke/prd.yaml'], `yoke: plan change ${request.id}`, commitIdentity),
749
774
  }));
775
+ const reviewerProviders = !opts.reviewRunner && (opts.review || opts.reviewer) ? SUPPORTED_AGENTS.filter(available) : [];
776
+ let review = opts.reviewRunner;
777
+ let reviewProvider = 'unknown';
778
+ if (!review && (opts.review || opts.reviewer)) {
779
+ const reviewerAgent = opts.reviewer ?? reviewerProviders.find(agent => agent !== runnerAgent);
780
+ if (!reviewerAgent) {
781
+ if (!opts.allowSelfReview) {
782
+ console.error('No independent reviewer CLI is available. Install or select a second agent, or pass --allow-self-review explicitly.');
783
+ return 2;
784
+ }
785
+ }
786
+ const resolvedReviewer = reviewerAgent ?? runnerAgent;
787
+ reviewProvider = resolvedReviewer;
788
+ if (resolvedReviewer === runnerAgent && !opts.allowSelfReview) {
789
+ console.error(`Reviewer "${resolvedReviewer}" is also the implementer. Pick another agent or pass --allow-self-review explicitly.`);
790
+ return 2;
791
+ }
792
+ if (!available(resolvedReviewer)) {
793
+ console.error(`Reviewer agent CLI "${resolvedReviewer}" was not found on PATH. Install it, or pick another with --reviewer=<${AGENT_LIST}>.`);
794
+ return 2;
795
+ }
796
+ review = context => {
797
+ const implementer = readStatus(targetDir)?.routingDecisions?.[context.story.id]?.provider ?? runnerAgent;
798
+ const selectedReviewer = !opts.reviewer && resolvedReviewer === implementer
799
+ ? reviewerProviders.find(agent => agent !== implementer) ?? resolvedReviewer : resolvedReviewer;
800
+ if (selectedReviewer === implementer && !opts.allowSelfReview)
801
+ 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" } };
802
+ reviewProvider = selectedReviewer;
803
+ return makeReviewRunner(selectedReviewer, idleMs, undefined, routingEnabled ? roleSelection(targetDir, config, context.story, selectedReviewer, "reviewer") : undefined)(context);
804
+ };
805
+ }
806
+ if (review) {
807
+ const reviewRunner = review;
808
+ review = context => {
809
+ const started = Date.now();
810
+ let result;
811
+ try {
812
+ result = reviewRunner(context);
813
+ return result;
814
+ }
815
+ finally {
816
+ executionReporter?.addTokens({ inputTokens: 0, outputTokens: 0, measurementComplete: result?.tokens !== undefined, ...result?.tokens, provider: reviewProvider, role: 'reviewer', storyId: context.story.id, durationMs: Date.now() - started });
817
+ }
818
+ };
819
+ }
820
+ // Only native, attributable roles justify comparisons of complete attempts.
821
+ // Concurrent candidates have ambiguous story-wide role attribution and remain
822
+ // conservative until their costs can be joined to an individual alternative.
823
+ const completeAccounting = candidates === 1 && !opts.runner && !opts.verify && !opts.design && !opts.perf && !opts.audit
824
+ && !opts.reviewRunner && !opts.qualityRuntime && !opts.git && !opts.intake
825
+ && (!opts.reporter || nativeReporters.has(opts.reporter));
826
+ const accountingScope = completeAccounting ? 'execution-attempt' : undefined;
827
+ const executionPolicyKey = completeAccounting ? executionPolicyFingerprint({
828
+ version: 1,
829
+ mode: useParallelDispatcher ? candidates > 1 ? 'candidates' : 'parallel' : 'serial',
830
+ parallel, candidates, isolate: useParallelDispatcher || isolate,
831
+ verify: { command: configuredVerifyCommand, retries: config.verify?.retries ?? 1, requireCriteria: config.verify?.requireCriteria ?? false },
832
+ design: design ? { max: config.design?.max } : false,
833
+ perf: perf ? { command: config.perf?.command, retries: config.perf?.retries ?? 1 } : false,
834
+ audit: audit ? config.audit : false,
835
+ completion: completion ? { command: config.completion?.command, retries: config.completion?.retries ?? 1 } : false,
836
+ quality: quality ? { defaults: config.quality, overrides: qualityOverrides, limits: quality.repairLimits, runnerAgent, runner: config.runner, agents: config.agents } : false,
837
+ review: review ? { provider: reviewProvider, explicitProvider: opts.reviewer, eligibleProviders: reviewerProviders, allowSelfReview: opts.allowSelfReview ?? false } : false,
838
+ roleRouting: routingEnabled && (quality || review) ? config.routing : false,
839
+ permissions: { implementation: permissions, orchestrator: 'read-only', reviewer: 'read-only', critic: 'read-only', repair: 'safe' },
840
+ idleMs, ambiguityPolicy,
841
+ }) : undefined;
750
842
  let runner = opts.runner;
751
843
  if (!runner) {
752
844
  const requiredProviders = useParallelDispatcher
@@ -778,6 +870,9 @@ export function runLoopCommand(targetDir, opts) {
778
870
  strategy: config.routing.strategy,
779
871
  maxCandidates: config.routing.maxCandidates,
780
872
  maxAttempts: config.routing.maxAttempts,
873
+ optimization: config.routing.optimization,
874
+ accountingScope,
875
+ executionPolicyKey,
781
876
  planner: resolvePlanner(config, runnerAgent, runnerSelection),
782
877
  assessmentPolicy: config.routing.assessmentPolicy,
783
878
  fallback: config.routing.fallback,
@@ -800,50 +895,6 @@ export function runLoopCommand(targetDir, opts) {
800
895
  }
801
896
  runner = makeActionRunner(config.actions, runner);
802
897
  }
803
- let review = opts.reviewRunner;
804
- let reviewProvider = 'unknown';
805
- if (!review && (opts.review || opts.reviewer)) {
806
- const reviewerAgent = opts.reviewer ?? SUPPORTED_AGENTS.find(agent => agent !== runnerAgent && available(agent));
807
- if (!reviewerAgent) {
808
- if (!opts.allowSelfReview) {
809
- console.error('No independent reviewer CLI is available. Install or select a second agent, or pass --allow-self-review explicitly.');
810
- return 2;
811
- }
812
- }
813
- const resolvedReviewer = reviewerAgent ?? runnerAgent;
814
- reviewProvider = resolvedReviewer;
815
- if (resolvedReviewer === runnerAgent && !opts.allowSelfReview) {
816
- console.error(`Reviewer "${resolvedReviewer}" is also the implementer. Pick another agent or pass --allow-self-review explicitly.`);
817
- return 2;
818
- }
819
- if (!available(resolvedReviewer)) {
820
- console.error(`Reviewer agent CLI "${resolvedReviewer}" was not found on PATH. Install it, or pick another with --reviewer=<${AGENT_LIST}>.`);
821
- return 2;
822
- }
823
- review = context => {
824
- const implementer = readStatus(targetDir)?.routingDecisions?.[context.story.id]?.provider ?? runnerAgent;
825
- const selectedReviewer = !opts.reviewer && resolvedReviewer === implementer
826
- ? SUPPORTED_AGENTS.find(agent => agent !== implementer && available(agent)) ?? resolvedReviewer : resolvedReviewer;
827
- if (selectedReviewer === implementer && !opts.allowSelfReview)
828
- 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" } };
829
- reviewProvider = selectedReviewer;
830
- return makeReviewRunner(selectedReviewer, idleMs, undefined, routingEnabled ? roleSelection(targetDir, config, context.story, selectedReviewer, "reviewer") : undefined)(context);
831
- };
832
- }
833
- if (review) {
834
- const reviewRunner = review;
835
- review = context => {
836
- const started = Date.now();
837
- let result;
838
- try {
839
- result = reviewRunner(context);
840
- return result;
841
- }
842
- finally {
843
- executionReporter?.addTokens({ inputTokens: 0, outputTokens: 0, measurementComplete: result?.tokens !== undefined, ...result?.tokens, provider: reviewProvider, role: 'reviewer', storyId: context.story.id, durationMs: Date.now() - started });
844
- }
845
- };
846
- }
847
898
  if (!useParallelDispatcher) {
848
899
  if (runner) {
849
900
  const unpooled = runner;
@@ -946,6 +997,8 @@ export function runLoopCommand(targetDir, opts) {
946
997
  providers: parallelProviders,
947
998
  affinityProviders: parallelAffinityProviders,
948
999
  routing: routingEnabled ? config.routing : undefined,
1000
+ accountingScope,
1001
+ executionPolicyKey,
949
1002
  planning: config.planning,
950
1003
  isAvailable: available,
951
1004
  onAmbiguity: ambiguityPolicy,
@@ -971,6 +1024,7 @@ export function runLoopCommand(targetDir, opts) {
971
1024
  prdPath: path,
972
1025
  targetDir,
973
1026
  runner,
1027
+ feedback: opts.recoveryFeedback,
974
1028
  git,
975
1029
  commitIdentity,
976
1030
  verify,
@@ -9,6 +9,7 @@ import { loadContext, formatForPrompt, contextDir } from '../context/context.js'
9
9
  import { contextPacket } from '../context/packet.js';
10
10
  import { buildProviderInvocation, startProviderProcess } from '../agents/providers.js';
11
11
  import { parseProviderResult, parseProviderTelemetry } from '../agents/telemetry.js';
12
+ import { providerTelemetryUsage } from '../observability/usage.js';
12
13
  import { formatReviewContract, formatReviewStdoutContract, parseReviewVerdict } from '../review/verdict.js';
13
14
  import { prepareWindowsInvocation } from '../agents/windows-launch.js';
14
15
  import { readSupervision } from '../agents/supervision.js';
@@ -243,7 +244,7 @@ function processFailureSummary(error) {
243
244
  export function runCapturedAgent(agent, inv) {
244
245
  try {
245
246
  const output = runCliCapture(inv);
246
- return { success: true, output, summary: 'exited 0', tokens: parseProviderTelemetry(agent, output.split(/\r?\n/)).tokens };
247
+ return { success: true, output, summary: 'exited 0', tokens: providerTelemetryUsage(parseProviderTelemetry(agent, output.split(/\r?\n/))) };
247
248
  }
248
249
  catch (error) {
249
250
  const partial = error.stdout;
@@ -252,7 +253,7 @@ export function runCapturedAgent(agent, inv) {
252
253
  success: false,
253
254
  output,
254
255
  summary: error.message,
255
- tokens: output ? parseProviderTelemetry(agent, output.split(/\r?\n/)).tokens : undefined,
256
+ tokens: output ? providerTelemetryUsage(parseProviderTelemetry(agent, output.split(/\r?\n/))) : undefined,
256
257
  };
257
258
  }
258
259
  }
@@ -316,12 +317,12 @@ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
316
317
  try {
317
318
  const out = capture(inv);
318
319
  const telemetry = parseProviderTelemetry(agent, out.split(/\r?\n/));
319
- return { success: true, summary: `${agent} implemented ${ctx.story.id}`, tokens: attributed(telemetry.tokens) };
320
+ return { success: true, summary: `${agent} implemented ${ctx.story.id}`, tokens: attributed(providerTelemetryUsage(telemetry)) };
320
321
  }
321
322
  catch (e) {
322
323
  // Salvage usage from whatever the agent streamed before dying — those tokens were spent.
323
324
  const partial = e.stdout;
324
- const tokens = partial == null ? undefined : parseProviderTelemetry(agent, String(partial).split(/\r?\n/)).tokens;
325
+ const tokens = partial == null ? undefined : providerTelemetryUsage(parseProviderTelemetry(agent, String(partial).split(/\r?\n/)));
325
326
  const reason = readSupervision(ctx.targetDir, new Date(started).toISOString())[0]?.reason;
326
327
  return { success: false, infrastructureFailure: true, summary: `${agent} failed on ${ctx.story.id}: ${reason ?? e.message}`, tokens: attributed(tokens) };
327
328
  }