@hecer/yoke 1.21.1 → 1.23.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 (103) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +48 -0
  4. package/README.md +8 -1
  5. package/TODOS.md +6 -0
  6. package/bench/analyze-codex-comparison.mjs +90 -17
  7. package/bench/compare-codex.mjs +159 -36
  8. package/bench/result-schema.mjs +132 -0
  9. package/canon/manifest.yaml +1 -1
  10. package/canon/skills/visual-verification/SKILL.md +25 -2
  11. package/canon/tools/codex-rtk-hook.mjs +6 -16
  12. package/dist/agents/pi-telemetry.js +2 -1
  13. package/dist/agents/process-streams.js +12 -64
  14. package/dist/agents/provider-selection.js +12 -0
  15. package/dist/agents/telemetry.js +52 -52
  16. package/dist/change/inbox.js +8 -3
  17. package/dist/check/command.js +69 -17
  18. package/dist/check/delivery.js +121 -0
  19. package/dist/cli.js +91 -3
  20. package/dist/code-intelligence/adapters/mcp.js +1 -0
  21. package/dist/code-intelligence/budgets.js +138 -0
  22. package/dist/code-intelligence/contracts.js +2 -0
  23. package/dist/code-intelligence/coordinator.js +159 -85
  24. package/dist/code-intelligence/evidence.js +87 -34
  25. package/dist/code-intelligence/index.js +1 -0
  26. package/dist/code-intelligence/mcp-client.js +25 -6
  27. package/dist/code-intelligence/mcp-server.js +14 -11
  28. package/dist/code-intelligence/preflight.js +71 -0
  29. package/dist/dashboard/analytics.js +5 -3
  30. package/dist/goals/command.js +183 -53
  31. package/dist/goals/usage.js +87 -0
  32. package/dist/loop/cache-isolation.js +36 -0
  33. package/dist/loop/candidate-cleanup.js +47 -17
  34. package/dist/loop/candidates.js +17 -11
  35. package/dist/loop/dispatcher.js +89 -26
  36. package/dist/loop/failure.js +104 -0
  37. package/dist/loop/gate-snapshot.js +19 -0
  38. package/dist/loop/git.js +1 -1
  39. package/dist/loop/loop.js +124 -70
  40. package/dist/loop/parallel-adapters.js +57 -6
  41. package/dist/loop/parallel-command.js +49 -7
  42. package/dist/loop/proof-retention.js +70 -0
  43. package/dist/loop/recovery.js +23 -5
  44. package/dist/loop/reporter.js +22 -5
  45. package/dist/loop/run-command.js +101 -47
  46. package/dist/loop/runner.js +6 -5
  47. package/dist/loop/worker.js +152 -91
  48. package/dist/observability/history.js +2 -1
  49. package/dist/observability/invocation.js +42 -0
  50. package/dist/observability/local-report.js +120 -0
  51. package/dist/observability/usage.js +19 -0
  52. package/dist/prd/command.js +20 -7
  53. package/dist/prd/decompose.js +5 -2
  54. package/dist/retrofit/config.js +29 -2
  55. package/dist/retrofit/gitignore.js +12 -0
  56. package/dist/retrofit/planners/codex.js +20 -20
  57. package/dist/routing/attempts.js +241 -0
  58. package/dist/routing/capability.js +13 -9
  59. package/dist/routing/optimization.js +73 -0
  60. package/dist/routing/registry.js +7 -1
  61. package/dist/routing/router.js +282 -127
  62. package/dist/setup/command.js +8 -2
  63. package/dist/smoke/command.js +387 -85
  64. package/dist/update/check.js +1 -1
  65. package/docs/BENCHMARK-MANIFEST.md +131 -0
  66. package/docs/CODE-INTELLIGENCE.md +43 -1
  67. package/docs/CODEX-COMPARISON-2026-09-29.md +15 -0
  68. package/docs/DELIVERY-JOURNEYS.md +206 -0
  69. package/docs/ECONOMIC-ROUTING.md +180 -0
  70. package/docs/GOALS.md +61 -4
  71. package/docs/RELEASE-VALIDATION-1.22.0.md +115 -0
  72. package/docs/RELEASE-VALIDATION-1.23.0.md +39 -0
  73. package/docs/benchmarks/2026-10-04-efficiency/ANALYSE.md +182 -0
  74. package/docs/benchmarks/2026-10-04-efficiency/compare-help.py +55 -0
  75. package/docs/benchmarks/2026-10-04-efficiency/manifest.json +125 -0
  76. package/docs/benchmarks/2026-10-04-efficiency/provenance-analysis.json +90 -0
  77. package/docs/benchmarks/2026-10-04-efficiency/provenance-design.json +90 -0
  78. package/docs/benchmarks/2026-10-04-efficiency/provenance-original-report.json +90 -0
  79. package/docs/benchmarks/2026-10-04-efficiency/raw/DEVELOPMENT_ANALYSIS.md +142 -0
  80. package/docs/benchmarks/2026-10-04-efficiency/raw/RESULT.md +21 -0
  81. package/docs/benchmarks/2026-10-04-efficiency/raw/commands.jsonl +26 -0
  82. package/docs/benchmarks/2026-10-04-efficiency/raw/environment.json +31 -0
  83. package/docs/benchmarks/2026-10-04-efficiency/raw/final-yoke-smoke.json +40 -0
  84. package/docs/benchmarks/2026-10-04-efficiency/raw/model-purpose-hints.csv +19 -0
  85. package/docs/benchmarks/2026-10-04-efficiency/raw/observations.jsonl +21 -0
  86. package/docs/benchmarks/2026-10-04-efficiency/raw/observer-command-phases.csv +12 -0
  87. package/docs/benchmarks/2026-10-04-efficiency/raw/roles.csv +5 -0
  88. package/docs/benchmarks/2026-10-04-efficiency/raw/shell-categories.csv +8 -0
  89. package/docs/benchmarks/2026-10-04-efficiency/raw/stories.csv +8 -0
  90. package/docs/benchmarks/2026-10-04-efficiency/raw/summary.json +469 -0
  91. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-history.jsonl +104 -0
  92. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-1.log +58 -0
  93. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-2.log +29 -0
  94. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-3.log +5 -0
  95. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-loop-4.log +12 -0
  96. package/docs/benchmarks/2026-10-04-efficiency/raw/yoke-phases.csv +10 -0
  97. package/docs/benchmarks/2026-10-04-efficiency/regression-comparison.json +104 -0
  98. package/docs/parallel-execution.md +37 -9
  99. package/docs/superpowers/plans/2026-10-04-yoke-1.23-efficiency-prd.json +11 -0
  100. package/docs/superpowers/plans/2026-10-04-yoke-1.23-efficiency.md +83 -0
  101. package/docs/superpowers/specs/2026-10-04-yoke-1.23-efficiency-design.md +120 -0
  102. package/gemini-extension.json +1 -1
  103. package/package.json +1 -1
@@ -1,4 +1,6 @@
1
+ import { cacheIsolationProblem } from './cache-isolation.js';
1
2
  import { existsSync, rmSync } from 'node:fs';
3
+ import { observeFailure, clearFailureProgress } from './failure.js';
2
4
  import { acceptanceProtectionProblem } from '../check/command.js';
3
5
  import { makeAsyncAdaptiveRunner } from '../routing/router.js';
4
6
  import { resolvePlanner } from '../routing/planning.js';
@@ -12,12 +14,25 @@ import { loadPrd, progress } from './prd.js';
12
14
  import { makeAsyncRunner } from './runner.js';
13
15
  import { runStoryWorker } from './worker.js';
14
16
  import { acquireSharedWorker, sharedPoolStatus } from './resource-pool.js';
17
+ import { providerTelemetryUsage } from '../observability/usage.js';
15
18
  export async function runParallelLoopCommand(input) {
16
- const originalVerify = input.verify;
19
+ const originalVerify = input.verify, originalCriterion = input.verifyCriterion;
20
+ const cacheProblem = (path) => !input.git && path !== input.targetDir ? cacheIsolationProblem(path) : undefined;
17
21
  input = { ...input, verify: path => {
18
- const problem = acceptanceProtectionProblem(path, input.targetDir);
22
+ const problem = cacheProblem(path) ?? acceptanceProtectionProblem(path, input.targetDir);
19
23
  return problem ? { passed: false, summary: problem } : originalVerify(path);
24
+ }, verifyCriterion: (path, story, criterion) => {
25
+ const problem = cacheProblem(path);
26
+ return problem ? { passed: false, summary: problem } : originalCriterion(path, story, criterion);
20
27
  } };
28
+ for (const name of ['design', 'perf', 'audit']) {
29
+ const gate = input[name];
30
+ if (gate)
31
+ input = { ...input, [name]: (path) => {
32
+ const problem = cacheProblem(path);
33
+ return problem ? { passed: false, summary: problem } : gate(path);
34
+ } };
35
+ }
21
36
  const adapters = makeParallelAdapters(input.targetDir, input.identity, input.git);
22
37
  if (!adapters.git.isClean(input.targetDir)) {
23
38
  input.reporter.blocked('target working tree is not clean');
@@ -96,6 +111,8 @@ export async function runParallelLoopCommand(input) {
96
111
  baseCommit: workerInput.worktree.baseCommit,
97
112
  provider: workerInput.provider,
98
113
  runner,
114
+ feedback: workerInput.worktree.recovery?.feedback,
115
+ failureRoot: input.targetDir,
99
116
  verify: input.verify,
100
117
  design: input.design,
101
118
  verifyCriterion: input.verifyCriterion,
@@ -126,11 +143,31 @@ export async function runParallelLoopCommand(input) {
126
143
  const result = await dispatcher.run();
127
144
  const finalProgress = progress(loadPrd(input.prdPath));
128
145
  if (result.status === 'complete' && input.completion) {
129
- const gate = input.completion(input.targetDir);
130
- if (!gate.passed) {
131
- input.reporter.blocked(gate.summary);
146
+ const previous = process.env.YOKE_PHASE;
147
+ process.env.YOKE_PHASE = 'completion';
148
+ let reason;
149
+ try {
150
+ const gate = input.completion(input.targetDir);
151
+ if (!gate.passed)
152
+ reason = `integrated system did not verify: ${gate.summary}`;
153
+ else if (!adapters.git.isClean(input.targetDir))
154
+ reason = 'completion command left source or final assets dirty; preserve changes and move rerun proofs to ignored runtime paths before resuming';
155
+ }
156
+ catch (error) {
157
+ reason = `integrated completion gate failed: ${error instanceof Error ? error.message : String(error)}`;
158
+ }
159
+ finally {
160
+ if (previous === undefined)
161
+ delete process.env.YOKE_PHASE;
162
+ else
163
+ process.env.YOKE_PHASE = previous;
164
+ }
165
+ if (reason) {
166
+ const observed = observeFailure({ root: input.targetDir, directory: input.targetDir, stage: 'completion', summary: reason });
167
+ input.reporter.blocked(observed.action === 'retry' ? reason : observed.feedback, observed.failure);
132
168
  return 1;
133
169
  }
170
+ clearFailureProgress(input.targetDir);
134
171
  }
135
172
  if (result.status === 'complete')
136
173
  input.reporter.complete(finalProgress);
@@ -139,7 +176,7 @@ export async function runParallelLoopCommand(input) {
139
176
  else if (result.status === 'cap-reached')
140
177
  input.reporter.capReached(finalProgress);
141
178
  else
142
- input.reporter.blocked(result.reason ?? `parallel dispatcher ${result.status}`);
179
+ input.reporter.blocked(result.reason ?? `parallel dispatcher ${result.status}`, result.failure);
143
180
  return result.status === 'complete' ? 0 : result.status === 'paused' ? 3 : 1;
144
181
  }
145
182
  function candidateCoordinatorInput(input, adapters, worker, pause) {
@@ -182,6 +219,8 @@ function candidateDefinitions(input, worker, candidateCount, pause) {
182
219
  worker: {
183
220
  provider: worker.provider,
184
221
  runner,
222
+ failureRoot: input.targetDir,
223
+ failureScope: candidateId,
185
224
  verify: input.verify,
186
225
  design: input.design,
187
226
  verifyCriterion: input.verifyCriterion,
@@ -290,6 +329,9 @@ function asyncRunner(input, provider, signal, workerId) {
290
329
  strategy: input.routing.strategy,
291
330
  maxCandidates: input.routing.maxCandidates,
292
331
  maxAttempts: input.routing.maxAttempts,
332
+ optimization: input.routing.optimization,
333
+ accountingScope: input.accountingScope,
334
+ executionPolicyKey: input.executionPolicyKey,
293
335
  planner: resolvePlanner({ planning: input.planning }, input.runnerAgent, input.selection),
294
336
  assessmentPolicy: input.routing.assessmentPolicy,
295
337
  fallback: input.routing.fallback,
@@ -338,7 +380,7 @@ function asyncRunner(input, provider, signal, workerId) {
338
380
  };
339
381
  }
340
382
  export function providerProcessResultToAgentResult(agent, storyId, result) {
341
- const tokens = result.telemetry.tokens;
383
+ const tokens = providerTelemetryUsage(result.telemetry);
342
384
  const telemetry = tokens ? { tokens } : {};
343
385
  switch (result.kind) {
344
386
  case 'succeeded': return { success: true, summary: `${agent} implemented ${storyId}`, ...telemetry };
@@ -0,0 +1,70 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, relative, resolve, isAbsolute } from 'node:path';
4
+ import { workspaceFingerprint } from '../workspace/fingerprint.js';
5
+ import { storyPathSegment } from './prd.js';
6
+ const hash = (data) => createHash('sha256').update(data).digest('hex');
7
+ function safePath(root, path) {
8
+ const rel = relative(resolve(root), resolve(path));
9
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('../') || rel.startsWith('..\\'))
10
+ throw new Error('Proof path escaped workspace');
11
+ let current = root;
12
+ for (const segment of rel.split(/[\\/]/u).filter(Boolean)) {
13
+ current = join(current, segment);
14
+ try {
15
+ if (lstatSync(current).isSymbolicLink())
16
+ throw new Error(`Proof path must not contain a link: ${current}`);
17
+ }
18
+ catch (error) {
19
+ if (error.code !== 'ENOENT')
20
+ throw error;
21
+ }
22
+ }
23
+ }
24
+ /** Copy selected proof into an immutable runtime snapshot; errors preserve the candidate. */
25
+ export function retainRuntimeProof(directory, storyId, targetDirectory) {
26
+ const entries = [];
27
+ const walk = (dir, base, prefix = '') => {
28
+ safePath(directory, dir);
29
+ if (!existsSync(dir))
30
+ return;
31
+ for (const name of readdirSync(dir).sort()) {
32
+ const file = join(dir, name);
33
+ safePath(directory, file);
34
+ const stat = lstatSync(file);
35
+ if (stat.isDirectory())
36
+ walk(file, base, prefix);
37
+ else if (stat.isFile()) {
38
+ const bytes = readFileSync(file);
39
+ entries.push({ path: (prefix + relative(base, file)).replace(/\\/gu, '/'), sha256: hash(bytes), bytes });
40
+ }
41
+ else
42
+ throw new Error(`Unsupported proof entry: ${file}`);
43
+ }
44
+ };
45
+ const artifacts = join(directory, '.yoke/artifacts'), proof = join(directory, '.yoke/proof');
46
+ walk(artifacts, artifacts);
47
+ walk(proof, proof, 'proof/');
48
+ if (!entries.length)
49
+ return;
50
+ const config = ['config.yaml', 'acceptance.yaml', 'prd.yaml'].map(name => {
51
+ const path = join(directory, '.yoke', name);
52
+ safePath(directory, path);
53
+ return [name, existsSync(path) ? hash(readFileSync(path)) : 'missing'];
54
+ });
55
+ const manifest = JSON.stringify({ version: 1, source: workspaceFingerprint(directory), config: hash(JSON.stringify(config)), environment: hash(JSON.stringify({ platform: process.platform, arch: process.arch, versions: process.versions })), files: entries.map(({ path, sha256 }) => ({ path, sha256 })) }, null, 2) + '\n';
56
+ const destination = join(targetDirectory, '.yoke/proof', storyPathSegment(storyId), 'runtime-artifacts', hash(manifest));
57
+ for (const { path, sha256, bytes } of [...entries, { path: 'manifest.json', sha256: hash(manifest), bytes: Buffer.from(manifest) }]) {
58
+ const copied = join(destination, path);
59
+ safePath(targetDirectory, copied);
60
+ mkdirSync(dirname(copied), { recursive: true });
61
+ if (existsSync(copied)) {
62
+ if (hash(readFileSync(copied)) !== sha256)
63
+ throw new Error(`Retained proof hash mismatch: ${path}`);
64
+ }
65
+ else
66
+ writeFileSync(copied, bytes, { mode: 0o600, flag: 'wx' });
67
+ if (hash(readFileSync(copied)) !== sha256)
68
+ throw new Error(`Proof copy hash mismatch: ${path}`);
69
+ }
70
+ }
@@ -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,
@@ -334,7 +335,7 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
334
335
  return;
335
336
  }
336
337
  const { integrator: _integrator, ...withoutIntegrator } = parallel;
337
- persist({ ...base, parallel: withoutIntegrator, updatedAt: now().toISOString() }, 'parallel-integrator', ' · integration complete');
338
+ persist({ ...base, parallel: withoutIntegrator, updatedAt: now().toISOString() }, 'parallel-integrator', ' · integrator idle');
338
339
  },
339
340
  addTokens(usage) {
340
341
  if (![usage.inputTokens, usage.outputTokens].every(value => Number.isFinite(value) && value >= 0))
@@ -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);
@@ -66,7 +76,12 @@ export function loopStatus(targetDir, now = () => new Date(), opts) {
66
76
  if (opts?.compact) {
67
77
  if (!st)
68
78
  return `state=${enabled ? 'enabled' : 'disabled'} prd="${prog}"`;
69
- return `state=${st.state} story=${st.story ?? 'none'} progress=${st.progress.passed}/${st.progress.total} phase=${st.phase} updated=${relativeTime(st.updatedAt, now())}`;
79
+ const workers = [...(st.parallel?.workers ?? [])].sort((a, b) => a.story.localeCompare(b.story));
80
+ const work = (worker) => `${encodeURIComponent(worker.story)}:${worker.phase ?? 'working'}`;
81
+ const integrator = st.parallel?.integrator;
82
+ const story = st.story ?? integrator?.story ?? workers[0]?.story ?? 'none';
83
+ const parallel = st.parallel ? ` workers=${workers.map(work).join(',') || 'none'} integrator=${integrator ? work(integrator) : 'none'} waiting=${st.parallel.waitingWorkers ?? 0} queued=${st.parallel.queuedIntegrations ?? st.parallel.queuedCandidates}` : '';
84
+ return `state=${st.state} story=${encodeURIComponent(story)} progress=${st.progress.passed}/${st.progress.total} phase=${st.phase ?? (st.parallel ? 'parallel' : st.state)}${parallel} updated=${relativeTime(st.updatedAt, now())}`;
70
85
  }
71
86
  const sharedPoolLine = () => {
72
87
  try {
@@ -241,6 +256,8 @@ async function runContinuousExploration(targetDir, options) {
241
256
  }
242
257
  const limitDeadline = options.savedRun?.exploreDeadline ?? (options.exploreLimitMs === undefined ? undefined : Date.now() + options.exploreLimitMs);
243
258
  const reporter = options.reporter ?? makeReporter(targetDir, { json: options.json, quiet: true });
259
+ if (!options.reporter)
260
+ nativeReporters.add(reporter);
244
261
  let config;
245
262
  try {
246
263
  config = loadConfig(targetDir);
@@ -261,6 +278,7 @@ async function runContinuousExploration(targetDir, options) {
261
278
  let reviewerAgent = options.reviewer ?? (reviewRequested ? SUPPORTED_AGENTS.find(agent => agent !== defaultImplementationAgent && available(agent)) : undefined);
262
279
  let explorationAgent = defaultExplorer;
263
280
  let retryWorktree = false;
281
+ let retryFeedback;
264
282
  const safeBatchSize = () => {
265
283
  if (retryWorktree)
266
284
  return 1;
@@ -365,6 +383,7 @@ async function runContinuousExploration(targetDir, options) {
365
383
  resultCode = await Promise.resolve(runLoopCommand(targetDir, {
366
384
  ...innerOptions,
367
385
  agent: implementationAgent,
386
+ recoveryFeedback: retryFeedback,
368
387
  ...(reviewRequested && reviewerAgent ? { reviewer: reviewerAgent } : {}),
369
388
  ...(batchLimit !== undefined ? { maxIterations: batchLimit } : {}),
370
389
  ...(retryWorktree ? { resumeWorktree: true, parallel: 1, candidates: 1 } : {}),
@@ -455,7 +474,11 @@ async function runContinuousExploration(targetDir, options) {
455
474
  }
456
475
  continue;
457
476
  }
458
- if (afterStatus?.reason?.startsWith('integrated completion gate failed') && allCurrentStoriesPass(targetDir)) {
477
+ if (afterStatus?.failure?.kind === 'no-progress') {
478
+ reporter.blocked(afterStatus.reason ?? 'Automatic continuation stopped after unchanged failures', afterStatus.failure);
479
+ return 1;
480
+ }
481
+ if (afterStatus?.failure?.kind === 'completion-failed' && allCurrentStoriesPass(targetDir)) {
459
482
  retryWorktree = false;
460
483
  reporter.phase('exploring', `all planned tasks pass, but ${afterStatus.reason}; looking for work that can resolve the completion gate`, currentProgress(targetDir));
461
484
  const scan = await scanForWork(`The integrated completion gate is still failing: ${afterStatus.reason}`);
@@ -478,6 +501,7 @@ async function runContinuousExploration(targetDir, options) {
478
501
  continue;
479
502
  }
480
503
  retryCount++;
504
+ retryFeedback = afterStatus?.reason;
481
505
  advanceRecoveryProviders();
482
506
  retryWorktree = (afterStatus?.parallel?.reopened ?? 0) === 0;
483
507
  const wait = retryDelay(retryCount);
@@ -579,9 +603,10 @@ export function runLoopCommand(targetDir, opts) {
579
603
  }
580
604
  }
581
605
  const outputPolicy = resolveOutputPolicy(config);
606
+ const configuredVerifyCommand = opts.verify ? undefined : resolveVerifyCommand(targetDir, config);
582
607
  let verify = opts.verify;
583
608
  if (!verify) {
584
- const command = resolveVerifyCommand(targetDir, config);
609
+ const command = configuredVerifyCommand;
585
610
  if (!command) {
586
611
  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.');
587
612
  return 2;
@@ -752,6 +777,73 @@ export function runLoopCommand(targetDir, opts) {
752
777
  selection: runnerSelection,
753
778
  commit: (_path, request) => commitPaths(targetDir, ['.yoke/prd.yaml'], `yoke: plan change ${request.id}`, commitIdentity),
754
779
  }));
780
+ const reviewerProviders = !opts.reviewRunner && (opts.review || opts.reviewer) ? SUPPORTED_AGENTS.filter(available) : [];
781
+ let review = opts.reviewRunner;
782
+ let reviewProvider = 'unknown';
783
+ if (!review && (opts.review || opts.reviewer)) {
784
+ const reviewerAgent = opts.reviewer ?? reviewerProviders.find(agent => agent !== runnerAgent);
785
+ if (!reviewerAgent) {
786
+ if (!opts.allowSelfReview) {
787
+ console.error('No independent reviewer CLI is available. Install or select a second agent, or pass --allow-self-review explicitly.');
788
+ return 2;
789
+ }
790
+ }
791
+ const resolvedReviewer = reviewerAgent ?? runnerAgent;
792
+ reviewProvider = resolvedReviewer;
793
+ if (resolvedReviewer === runnerAgent && !opts.allowSelfReview) {
794
+ console.error(`Reviewer "${resolvedReviewer}" is also the implementer. Pick another agent or pass --allow-self-review explicitly.`);
795
+ return 2;
796
+ }
797
+ if (!available(resolvedReviewer)) {
798
+ console.error(`Reviewer agent CLI "${resolvedReviewer}" was not found on PATH. Install it, or pick another with --reviewer=<${AGENT_LIST}>.`);
799
+ return 2;
800
+ }
801
+ review = context => {
802
+ const implementer = readStatus(targetDir)?.routingDecisions?.[context.story.id]?.provider ?? runnerAgent;
803
+ const selectedReviewer = !opts.reviewer && resolvedReviewer === implementer
804
+ ? reviewerProviders.find(agent => agent !== implementer) ?? resolvedReviewer : resolvedReviewer;
805
+ if (selectedReviewer === implementer && !opts.allowSelfReview)
806
+ 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" } };
807
+ reviewProvider = selectedReviewer;
808
+ return makeReviewRunner(selectedReviewer, idleMs, undefined, routingEnabled ? roleSelection(targetDir, config, context.story, selectedReviewer, "reviewer") : undefined)(context);
809
+ };
810
+ }
811
+ if (review) {
812
+ const reviewRunner = review;
813
+ review = context => {
814
+ const started = Date.now();
815
+ let result;
816
+ try {
817
+ result = reviewRunner(context);
818
+ return result;
819
+ }
820
+ finally {
821
+ executionReporter?.addTokens({ inputTokens: 0, outputTokens: 0, measurementComplete: result?.tokens !== undefined, ...result?.tokens, provider: reviewProvider, role: 'reviewer', storyId: context.story.id, durationMs: Date.now() - started });
822
+ }
823
+ };
824
+ }
825
+ // Only native, attributable roles justify comparisons of complete attempts.
826
+ // Concurrent candidates have ambiguous story-wide role attribution and remain
827
+ // conservative until their costs can be joined to an individual alternative.
828
+ const completeAccounting = candidates === 1 && !opts.runner && !opts.verify && !opts.design && !opts.perf && !opts.audit
829
+ && !opts.reviewRunner && !opts.qualityRuntime && !opts.git && !opts.intake
830
+ && (!opts.reporter || nativeReporters.has(opts.reporter));
831
+ const accountingScope = completeAccounting ? 'execution-attempt' : undefined;
832
+ const executionPolicyKey = completeAccounting ? executionPolicyFingerprint({
833
+ version: 1,
834
+ mode: useParallelDispatcher ? candidates > 1 ? 'candidates' : 'parallel' : 'serial',
835
+ parallel, candidates, isolate: useParallelDispatcher || isolate,
836
+ verify: { command: configuredVerifyCommand, retries: config.verify?.retries ?? 1, requireCriteria: config.verify?.requireCriteria ?? false },
837
+ design: design ? { max: config.design?.max } : false,
838
+ perf: perf ? { command: config.perf?.command, retries: config.perf?.retries ?? 1 } : false,
839
+ audit: audit ? config.audit : false,
840
+ completion: completion ? { command: config.completion?.command, retries: config.completion?.retries ?? 1 } : false,
841
+ quality: quality ? { defaults: config.quality, overrides: qualityOverrides, limits: quality.repairLimits, runnerAgent, runner: config.runner, agents: config.agents } : false,
842
+ review: review ? { provider: reviewProvider, explicitProvider: opts.reviewer, eligibleProviders: reviewerProviders, allowSelfReview: opts.allowSelfReview ?? false } : false,
843
+ roleRouting: routingEnabled && (quality || review) ? config.routing : false,
844
+ permissions: { implementation: permissions, orchestrator: 'read-only', reviewer: 'read-only', critic: 'read-only', repair: 'safe' },
845
+ idleMs, ambiguityPolicy,
846
+ }) : undefined;
755
847
  let runner = opts.runner;
756
848
  if (!runner) {
757
849
  const requiredProviders = useParallelDispatcher
@@ -783,6 +875,9 @@ export function runLoopCommand(targetDir, opts) {
783
875
  strategy: config.routing.strategy,
784
876
  maxCandidates: config.routing.maxCandidates,
785
877
  maxAttempts: config.routing.maxAttempts,
878
+ optimization: config.routing.optimization,
879
+ accountingScope,
880
+ executionPolicyKey,
786
881
  planner: resolvePlanner(config, runnerAgent, runnerSelection),
787
882
  assessmentPolicy: config.routing.assessmentPolicy,
788
883
  fallback: config.routing.fallback,
@@ -805,50 +900,6 @@ export function runLoopCommand(targetDir, opts) {
805
900
  }
806
901
  runner = makeActionRunner(config.actions, runner);
807
902
  }
808
- let review = opts.reviewRunner;
809
- let reviewProvider = 'unknown';
810
- if (!review && (opts.review || opts.reviewer)) {
811
- const reviewerAgent = opts.reviewer ?? SUPPORTED_AGENTS.find(agent => agent !== runnerAgent && available(agent));
812
- if (!reviewerAgent) {
813
- if (!opts.allowSelfReview) {
814
- console.error('No independent reviewer CLI is available. Install or select a second agent, or pass --allow-self-review explicitly.');
815
- return 2;
816
- }
817
- }
818
- const resolvedReviewer = reviewerAgent ?? runnerAgent;
819
- reviewProvider = resolvedReviewer;
820
- if (resolvedReviewer === runnerAgent && !opts.allowSelfReview) {
821
- console.error(`Reviewer "${resolvedReviewer}" is also the implementer. Pick another agent or pass --allow-self-review explicitly.`);
822
- return 2;
823
- }
824
- if (!available(resolvedReviewer)) {
825
- console.error(`Reviewer agent CLI "${resolvedReviewer}" was not found on PATH. Install it, or pick another with --reviewer=<${AGENT_LIST}>.`);
826
- return 2;
827
- }
828
- review = context => {
829
- const implementer = readStatus(targetDir)?.routingDecisions?.[context.story.id]?.provider ?? runnerAgent;
830
- const selectedReviewer = !opts.reviewer && resolvedReviewer === implementer
831
- ? SUPPORTED_AGENTS.find(agent => agent !== implementer && available(agent)) ?? resolvedReviewer : resolvedReviewer;
832
- if (selectedReviewer === implementer && !opts.allowSelfReview)
833
- 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" } };
834
- reviewProvider = selectedReviewer;
835
- return makeReviewRunner(selectedReviewer, idleMs, undefined, routingEnabled ? roleSelection(targetDir, config, context.story, selectedReviewer, "reviewer") : undefined)(context);
836
- };
837
- }
838
- if (review) {
839
- const reviewRunner = review;
840
- review = context => {
841
- const started = Date.now();
842
- let result;
843
- try {
844
- result = reviewRunner(context);
845
- return result;
846
- }
847
- finally {
848
- executionReporter?.addTokens({ inputTokens: 0, outputTokens: 0, measurementComplete: result?.tokens !== undefined, ...result?.tokens, provider: reviewProvider, role: 'reviewer', storyId: context.story.id, durationMs: Date.now() - started });
849
- }
850
- };
851
- }
852
903
  if (!useParallelDispatcher) {
853
904
  if (runner) {
854
905
  const unpooled = runner;
@@ -951,6 +1002,8 @@ export function runLoopCommand(targetDir, opts) {
951
1002
  providers: parallelProviders,
952
1003
  affinityProviders: parallelAffinityProviders,
953
1004
  routing: routingEnabled ? config.routing : undefined,
1005
+ accountingScope,
1006
+ executionPolicyKey,
954
1007
  planning: config.planning,
955
1008
  isAvailable: available,
956
1009
  onAmbiguity: ambiguityPolicy,
@@ -976,6 +1029,7 @@ export function runLoopCommand(targetDir, opts) {
976
1029
  prdPath: path,
977
1030
  targetDir,
978
1031
  runner,
1032
+ feedback: opts.recoveryFeedback,
979
1033
  git,
980
1034
  commitIdentity,
981
1035
  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';
@@ -31,7 +32,7 @@ export function buildClaudePrompt(story, context, onAmbiguity = 'resolve', perfC
31
32
  ];
32
33
  if (context)
33
34
  lines.push('', context);
34
- lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria (Definition of Done):', criteria, ...(story.assessment ? ['Planner approach:', story.assessment.approach] : []), '', "When done, ensure the project's full test suite passes.", 'Do NOT commit — the loop commits on your behalf after verifying.', '', 'Working rules:', '- Add nothing beyond what the story requires: no extra features, abstractions, comments, or defensive code for cases that cannot happen.', '- Do not create summary, plan, or analysis documents — only files the story itself needs.', '- If a check fails, fix the root cause; never bypass it (e.g. --no-verify) or pass by weakening tests.', '- Report the outcome faithfully: if a criterion is unmet or tests fail, say so plainly instead of claiming success.', '- Never ask questions or wait for input — you run unattended and nobody can answer.', onAmbiguity === 'abort'
35
+ lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria (Definition of Done):', criteria, ...(story.assessment ? ['Planner approach:', story.assessment.approach] : []), '', "When done, ensure the project's full test suite passes.", 'Do NOT commit — the loop commits on your behalf after verifying.', '', 'Working rules:', '- Use worktree-local writable dependency/runtime caches. Do not link node_modules to a target checkout when tools write .vite-temp or other caches there. Shared package download caches are allowed; preserve the configured sandbox. Report offline cache misses and network failures explicitly instead of blindly retrying.', '- Add nothing beyond what the story requires: no extra features, abstractions, comments, or defensive code for cases that cannot happen.', '- Do not create summary, plan, or analysis documents — only files the story itself needs.', '- If a check fails, fix the root cause; never bypass it (e.g. --no-verify) or pass by weakening tests.', '- Report the outcome faithfully: if a criterion is unmet or tests fail, say so plainly instead of claiming success.', '- Never ask questions or wait for input — you run unattended and nobody can answer.', onAmbiguity === 'abort'
35
36
  ? '- If an acceptance criterion is genuinely undecidable, do NOT guess: write the open question(s) to .yoke/ambiguity.md, change nothing else, and stop.'
36
37
  : onAmbiguity === 'critical'
37
38
  ? [
@@ -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
  }