@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
@@ -0,0 +1,19 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { workspaceFingerprint } from '../workspace/fingerprint.js';
3
+ export function gateIdentity(directory, story) {
4
+ try {
5
+ return createHash('sha256').update(workspaceFingerprint(directory)).update(JSON.stringify(story)).digest('hex');
6
+ }
7
+ catch {
8
+ // Unavailable identity disables reuse; it never turns an unchecked state green.
9
+ return undefined;
10
+ }
11
+ }
12
+ export function snapshotGates(directory, story, before, evidence) {
13
+ if (!before || before !== gateIdentity(directory, story))
14
+ return undefined;
15
+ return { identity: before, evidence };
16
+ }
17
+ export function reuseGates(directory, story, snapshot) {
18
+ return snapshot && snapshot.identity === gateIdentity(directory, story) ? snapshot.evidence : undefined;
19
+ }
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/supervision", ".yoke/provider-processes", '.yoke/claims', '.yoke/worktrees', '.yoke/artifacts', '.yoke/events', '.yoke/history', '.yoke/routing', '.yoke/checks', '.yoke/integration-recovery', '.yoke/run-state.json', '.yoke/run-state.*.tmp', '.yoke/goal.json', '.yoke/goal.pause', '.yoke/loop.pause', '.yoke/loop-status.json', '.yoke/loop.lock', '.yoke/loop.lock.takeover', '.yoke/loop.lock.takeover.recovery', '.yoke/runner.pid', '.yoke/story-durations.json'];
3
+ export const RUNTIME_PATHS = [".yoke/supervision", ".yoke/provider-processes", '.yoke/claims', '.yoke/worktrees', '.yoke/artifacts', '.yoke/events', '.yoke/history', '.yoke/routing', '.yoke/routing-attempts', '.yoke/checks', '.yoke/integration-recovery', '.yoke/failure-progress', '.yoke/run-state.json', '.yoke/run-state.*.tmp', '.yoke/goal.json', '.yoke/goal.pause', '.yoke/loop.pause', '.yoke/loop-status.json', '.yoke/loop.lock', '.yoke/loop.lock.takeover', '.yoke/loop.lock.takeover.recovery', '.yoke/runner.pid', '.yoke/story-durations.json'];
4
4
  // Directory pathspecs include descendants and also exclude the directory entry.
5
5
  export const RUNTIME_EXCLUDES = RUNTIME_PATHS.map(path => `:(exclude)${path}`);
6
6
  export function stageImplementation(dir) {
package/dist/loop/loop.js CHANGED
@@ -4,6 +4,8 @@ import { acceptanceProtectionProblem } from '../check/command.js';
4
4
  import { join, relative } from 'node:path';
5
5
  import { isAcceptanceCriterion, loadPrd, savePrd, selectNextStory, allPass, progress, storyPathSegment } from './prd.js';
6
6
  import { stopTheLineGate, preDispatchGate } from './gates.js';
7
+ import { observeFailure, clearFailureProgress } from './failure.js';
8
+ import { gateIdentity, snapshotGates, reuseGates } from './gate-snapshot.js';
7
9
  import { appendDecision, contextDir } from '../context/context.js';
8
10
  import { noopReporter } from './reporter.js';
9
11
  import { consumeDecisionRequest } from './decision.js';
@@ -68,7 +70,8 @@ function runQualityReview(opts, executionDir, story, reporter) {
68
70
  }
69
71
  return { kind: 'passed' };
70
72
  };
71
- return runQualityRepairLoop({
73
+ let failure;
74
+ const outcome = runQualityRepairLoop({
72
75
  quality: qualityAssessment
73
76
  ? round => {
74
77
  reporter.phase('comparing');
@@ -85,7 +88,13 @@ function runQualityReview(opts, executionDir, story, reporter) {
85
88
  reporter.phase('repairing');
86
89
  if (!opts.repair)
87
90
  return { kind: 'blocked', summary: 'repair callback is not configured' };
88
- const result = opts.repair({ targetDir: executionDir, story }, request);
91
+ const observed = observeFailure({ root: opts.targetDir, directory: executionDir, story, stage: 'quality', summary: JSON.stringify(request.finding) });
92
+ if (observed.action === 'blocked') {
93
+ failure = observed.failure;
94
+ return { kind: 'blocked', summary: observed.feedback };
95
+ }
96
+ const targetedRequest = observed.action === 'diagnose' ? { ...request, finding: { ...request.finding, message: observed.feedback } } : request;
97
+ const result = opts.repair({ targetDir: executionDir, story }, targetedRequest);
89
98
  return result.success ? { kind: 'repaired' } : { kind: 'blocked', summary: result.summary };
90
99
  },
91
100
  rerunGates,
@@ -96,6 +105,7 @@ function runQualityReview(opts, executionDir, story, reporter) {
96
105
  reporter.quality({ ...status, ...qualityMetadata });
97
106
  },
98
107
  });
108
+ return { ...outcome, ...(failure ? { failure } : {}) };
99
109
  }
100
110
  // Control file a supervisor drops to pause the loop at the next story boundary.
101
111
  // The loop consumes (deletes) it and stops with state 'paused' — never mid-story.
@@ -146,7 +156,7 @@ function runGate(gate, dir, storyId) {
146
156
  process.env.YOKE_STORY = prev;
147
157
  }
148
158
  }
149
- function consumeAmbiguity(dir) {
159
+ export function consumeAmbiguity(dir) {
150
160
  const file = ambiguityFilePath(dir);
151
161
  if (!existsSync(file))
152
162
  return null;
@@ -167,14 +177,14 @@ function runCompletionGate(opts, stories) {
167
177
  return null;
168
178
  const previous = process.env.YOKE_PHASE;
169
179
  process.env.YOKE_PHASE = 'completion';
170
- let verdict;
180
+ let reason;
171
181
  try {
172
- verdict = opts.completion(opts.targetDir);
182
+ const verdict = opts.completion(opts.targetDir);
183
+ if (!verdict.passed)
184
+ reason = `integrated system did not verify: ${verdict.summary}`;
173
185
  }
174
186
  catch (error) {
175
- const reason = `integrated completion gate failed: ${error.message}`;
176
- (opts.reporter ?? noopReporter).blocked(reason);
177
- return { status: 'blocked', iterations: 0, reason, finalProgress: progress(stories) };
187
+ reason = `integrated completion gate failed: ${error.message}`;
178
188
  }
179
189
  finally {
180
190
  if (previous === undefined)
@@ -182,11 +192,15 @@ function runCompletionGate(opts, stories) {
182
192
  else
183
193
  process.env.YOKE_PHASE = previous;
184
194
  }
185
- if (verdict.passed)
195
+ if (!reason) {
196
+ clearFailureProgress(opts.targetDir);
186
197
  return null;
187
- const reason = `integrated system did not verify: ${verdict.summary}`;
188
- (opts.reporter ?? noopReporter).blocked(reason);
189
- return { status: 'blocked', iterations: 0, reason, finalProgress: progress(stories) };
198
+ }
199
+ const observed = observeFailure({ root: opts.targetDir, directory: opts.targetDir, stage: 'completion', summary: reason });
200
+ if (observed.action !== 'retry')
201
+ reason = observed.feedback;
202
+ (opts.reporter ?? noopReporter).blocked(reason, observed.failure);
203
+ return { status: 'blocked', iterations: 0, reason, failure: observed.failure, finalProgress: progress(stories) };
190
204
  }
191
205
  function runCriterionGates(opts, executionDir, story) {
192
206
  const protectionProblem = acceptanceProtectionProblem(executionDir, opts.targetDir);
@@ -313,86 +327,88 @@ export function runLoop(opts) {
313
327
  const wt = join(opts.targetDir, '.yoke', 'worktrees', storyPathSegment(story.id));
314
328
  const wtPrd = join(wt, relative(opts.targetDir, opts.prdPath));
315
329
  let landed = null;
330
+ let activeRouting;
316
331
  try {
317
332
  opts.git.addWorktree(opts.targetDir, wt);
318
- const result = runImplementation(opts, wt, story, reporter);
333
+ const implementation = runImplementation(opts, wt, story, reporter);
334
+ const result = implementation.result;
335
+ activeRouting = result.routing;
319
336
  iterations++;
320
337
  if (result.tokens)
321
338
  reporter.addTokens(result.tokens);
322
339
  if (result.infrastructureFailure || result.routing?.blocked) {
323
340
  if (result.infrastructureFailure)
324
341
  result.routing?.recordOutcome(false, "infrastructure");
325
- reporter.blocked(result.summary);
326
- return { status: "blocked", iterations, reason: result.summary, finalProgress: progress(stories) };
342
+ reporter.blocked(result.summary, implementation.failure);
343
+ return { status: "blocked", iterations, reason: result.summary, ...(implementation.failure ? { failure: implementation.failure } : {}), finalProgress: progress(stories) };
327
344
  }
328
345
  let decision;
329
346
  try {
330
347
  decision = consumeDecisionRequest(wt, opts.targetDir, story.id);
331
348
  }
332
349
  catch (error) {
350
+ result.routing?.recordOutcome(false, 'infrastructure');
333
351
  const reason = `invalid critical decision request for story ${story.id}: ${error.message}`;
334
352
  reporter.blocked(reason);
335
353
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
336
354
  }
337
355
  if (decision) {
356
+ result.routing?.recordOutcome(false, 'infrastructure');
338
357
  const reason = `critical decision required for story ${story.id}: ${decision.question}`;
339
358
  reporter.blocked(reason);
340
359
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
341
360
  }
342
361
  const ambiguity = consumeAmbiguity(wt);
343
362
  if (ambiguity) {
363
+ result.routing?.recordOutcome(false, 'infrastructure');
344
364
  const reason = `story ${story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`;
345
365
  reporter.blocked(reason);
346
366
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
347
367
  }
348
- const criteriaVerdict = runCriterionGates(opts, wt, story);
368
+ const verified = reuseGates(wt, story, implementation.gates);
369
+ const criteriaVerdict = verified?.criteria ?? runCriterionGates(opts, wt, story);
349
370
  if (!criteriaVerdict.passed) {
350
371
  result.routing?.recordOutcome(false);
351
372
  const reason = blockReason(`story ${story.id} lacks acceptance evidence: ${criteriaVerdict.summary}`, opts.targetDir, opts.git);
352
- reporter.blocked(reason);
353
- return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
373
+ return blockedMechanicalGate(opts, wt, story, 'criterion', criteriaVerdict.summary, reason, iterations, stories);
354
374
  }
355
375
  // Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
356
376
  // exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
357
377
  reporter.phase('verifying');
358
- const verdict = runGate(opts.verify, wt, story.id);
378
+ const verdict = verified?.verify ?? runGate(opts.verify, wt, story.id);
359
379
  if (!verdict.passed) {
360
380
  result.routing?.recordOutcome(false);
361
381
  const base = result.success
362
382
  ? `story ${story.id} did not verify: ${verdict.summary}`
363
383
  : `story ${story.id} runner failed (${result.summary}) and verify is red: ${verdict.summary}`;
364
384
  const reason = blockReason(base, opts.targetDir, opts.git);
365
- reporter.blocked(reason);
366
- return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
385
+ return blockedMechanicalGate(opts, wt, story, 'verify', verdict.summary, reason, iterations, stories);
367
386
  }
368
387
  if (opts.design) {
369
388
  reporter.phase('design');
370
- const designVerdict = runGate(opts.design, wt, story.id);
389
+ const designVerdict = verified?.design ?? runGate(opts.design, wt, story.id);
371
390
  if (!designVerdict.passed) {
372
391
  result.routing?.recordOutcome(false);
373
392
  const reason = blockReason(`story ${story.id} failed its design gate: ${designVerdict.summary}`, opts.targetDir, opts.git);
374
- reporter.blocked(reason);
375
- return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
393
+ return blockedMechanicalGate(opts, wt, story, 'design', designVerdict.summary, reason, iterations, stories);
376
394
  }
377
395
  }
378
396
  if (opts.perf) {
379
397
  reporter.phase('perf');
380
- const perfVerdict = runGate(opts.perf, wt, story.id);
398
+ const perfVerdict = verified?.perf ?? runGate(opts.perf, wt, story.id);
381
399
  if (!perfVerdict.passed) {
382
400
  result.routing?.recordOutcome(false);
383
401
  const reason = blockReason(`story ${story.id} exceeded its performance budget: ${perfVerdict.summary}`, opts.targetDir, opts.git);
384
- reporter.blocked(reason);
385
- return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
402
+ return blockedMechanicalGate(opts, wt, story, 'perf', perfVerdict.summary, reason, iterations, stories);
386
403
  }
387
404
  }
388
405
  if (opts.audit) {
389
406
  reporter.phase('audit');
390
- const auditVerdict = runGate(opts.audit, wt, story.id);
407
+ const auditVerdict = verified?.audit ?? runGate(opts.audit, wt, story.id);
391
408
  if (!auditVerdict.passed) {
392
409
  result.routing?.recordOutcome(false);
393
410
  const reason = blockReason(`story ${story.id} failed security audit: ${auditVerdict.summary}`, opts.targetDir, opts.git);
394
- reporter.blocked(reason);
395
- return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
411
+ return blockedMechanicalGate(opts, wt, story, 'audit', auditVerdict.summary, reason, iterations, stories);
396
412
  }
397
413
  }
398
414
  const summary = result.success
@@ -401,14 +417,15 @@ export function runLoop(opts) {
401
417
  const repairOutcome = runQualityReview(opts, wt, story, reporter);
402
418
  if (repairOutcome) {
403
419
  if (repairOutcome.kind === 'paused') {
420
+ result.routing?.recordOutcome(false, 'infrastructure');
404
421
  reporter.paused(progress(stories));
405
422
  return { status: 'paused', iterations, finalProgress: progress(stories) };
406
423
  }
407
424
  const reason = repairBlockReason(repairOutcome, story, opts.targetDir, opts.git);
408
425
  if (reason) {
409
426
  result.routing?.recordOutcome(false);
410
- reporter.blocked(reason);
411
- return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
427
+ reporter.blocked(reason, repairOutcome.failure);
428
+ return { status: 'blocked', iterations, reason, ...(repairOutcome.failure ? { failure: repairOutcome.failure } : {}), finalProgress: progress(stories) };
412
429
  }
413
430
  }
414
431
  const protection = acceptanceProtectionProblem(wt, opts.targetDir);
@@ -431,9 +448,11 @@ export function runLoop(opts) {
431
448
  opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`, opts.commitIdentity);
432
449
  opts.git.integrate(opts.targetDir, wt);
433
450
  result.routing?.recordOutcome(true);
451
+ clearFailureProgress(opts.targetDir, story.id);
434
452
  landed = progress(updated);
435
453
  }
436
454
  catch (e) {
455
+ activeRouting?.recordOutcome(false, 'infrastructure');
437
456
  const reason = blockReason(`isolated iteration failed for ${story.id}: ${e.message}`, opts.targetDir, opts.git);
438
457
  reporter.blocked(reason);
439
458
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
@@ -452,89 +471,84 @@ export function runLoop(opts) {
452
471
  reporter.storyDone({ id: story.id, title: story.title }, landed);
453
472
  continue;
454
473
  }
455
- const result = runImplementation(opts, opts.targetDir, story, reporter);
474
+ const implementation = runImplementation(opts, opts.targetDir, story, reporter);
475
+ const result = implementation.result;
456
476
  iterations++;
457
477
  if (result.tokens)
458
478
  reporter.addTokens(result.tokens);
459
479
  if (result.infrastructureFailure || result.routing?.blocked) {
460
480
  if (result.infrastructureFailure)
461
481
  result.routing?.recordOutcome(false, "infrastructure");
462
- reporter.blocked(result.summary);
463
- return { status: "blocked", iterations, reason: result.summary, finalProgress: progress(stories) };
482
+ reporter.blocked(result.summary, implementation.failure);
483
+ return { status: "blocked", iterations, reason: result.summary, ...(implementation.failure ? { failure: implementation.failure } : {}), finalProgress: progress(stories) };
464
484
  }
465
485
  let decision;
466
486
  try {
467
487
  decision = consumeDecisionRequest(opts.targetDir, opts.targetDir, story.id);
468
488
  }
469
489
  catch (error) {
490
+ result.routing?.recordOutcome(false, 'infrastructure');
470
491
  const reason = `invalid critical decision request for story ${story.id}: ${error.message}`;
471
492
  reporter.blocked(reason);
472
493
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
473
494
  }
474
495
  if (decision) {
496
+ result.routing?.recordOutcome(false, 'infrastructure');
475
497
  const reason = `critical decision required for story ${story.id}: ${decision.question}`;
476
498
  reporter.blocked(reason);
477
499
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
478
500
  }
479
501
  const ambiguity = consumeAmbiguity(opts.targetDir);
480
502
  if (ambiguity) {
503
+ result.routing?.recordOutcome(false, 'infrastructure');
481
504
  const reason = `story ${story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`;
482
505
  reporter.blocked(reason);
483
506
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
484
507
  }
485
- const criteriaVerdict = runCriterionGates(opts, opts.targetDir, story);
508
+ const verified = reuseGates(opts.targetDir, story, implementation.gates);
509
+ const criteriaVerdict = verified?.criteria ?? runCriterionGates(opts, opts.targetDir, story);
486
510
  if (!criteriaVerdict.passed) {
487
511
  result.routing?.recordOutcome(false);
488
512
  const reason = blockReason(`story ${story.id} lacks acceptance evidence: ${criteriaVerdict.summary}`, opts.targetDir, opts.git);
489
- reporter.blocked(reason);
490
- return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
513
+ return blockedMechanicalGate(opts, opts.targetDir, story, 'criterion', criteriaVerdict.summary, reason, iterations, stories);
491
514
  }
492
515
  // Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
493
516
  // exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
494
517
  reporter.phase('verifying');
495
- const verdict = runGate(opts.verify, opts.targetDir, story.id);
518
+ const verdict = verified?.verify ?? runGate(opts.verify, opts.targetDir, story.id);
496
519
  if (!verdict.passed) {
497
520
  result.routing?.recordOutcome(false);
498
521
  const base = result.success
499
522
  ? `story ${story.id} did not verify: ${verdict.summary}`
500
523
  : `story ${story.id} runner failed (${result.summary}) and verify is red: ${verdict.summary}`;
501
524
  const reason = blockReason(base, opts.targetDir, opts.git);
502
- reporter.blocked(reason);
503
- return {
504
- status: 'blocked',
505
- iterations,
506
- reason,
507
- finalProgress: progress(stories),
508
- };
525
+ return blockedMechanicalGate(opts, opts.targetDir, story, 'verify', verdict.summary, reason, iterations, stories);
509
526
  }
510
527
  if (opts.design) {
511
528
  reporter.phase('design');
512
- const designVerdict = runGate(opts.design, opts.targetDir, story.id);
529
+ const designVerdict = verified?.design ?? runGate(opts.design, opts.targetDir, story.id);
513
530
  if (!designVerdict.passed) {
514
531
  result.routing?.recordOutcome(false);
515
532
  const reason = blockReason(`story ${story.id} failed its design gate: ${designVerdict.summary}`, opts.targetDir, opts.git);
516
- reporter.blocked(reason);
517
- return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
533
+ return blockedMechanicalGate(opts, opts.targetDir, story, 'design', designVerdict.summary, reason, iterations, stories);
518
534
  }
519
535
  }
520
536
  if (opts.perf) {
521
537
  reporter.phase('perf');
522
- const perfVerdict = runGate(opts.perf, opts.targetDir, story.id);
538
+ const perfVerdict = verified?.perf ?? runGate(opts.perf, opts.targetDir, story.id);
523
539
  if (!perfVerdict.passed) {
524
540
  result.routing?.recordOutcome(false);
525
541
  const reason = blockReason(`story ${story.id} exceeded its performance budget: ${perfVerdict.summary}`, opts.targetDir, opts.git);
526
- reporter.blocked(reason);
527
- return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
542
+ return blockedMechanicalGate(opts, opts.targetDir, story, 'perf', perfVerdict.summary, reason, iterations, stories);
528
543
  }
529
544
  }
530
545
  if (opts.audit) {
531
546
  reporter.phase('audit');
532
- const auditVerdict = runGate(opts.audit, opts.targetDir, story.id);
547
+ const auditVerdict = verified?.audit ?? runGate(opts.audit, opts.targetDir, story.id);
533
548
  if (!auditVerdict.passed) {
534
549
  result.routing?.recordOutcome(false);
535
550
  const reason = blockReason(`story ${story.id} failed security audit: ${auditVerdict.summary}`, opts.targetDir, opts.git);
536
- reporter.blocked(reason);
537
- return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
551
+ return blockedMechanicalGate(opts, opts.targetDir, story, 'audit', auditVerdict.summary, reason, iterations, stories);
538
552
  }
539
553
  }
540
554
  const summary = result.success
@@ -543,17 +557,19 @@ export function runLoop(opts) {
543
557
  const repairOutcome = runQualityReview(opts, opts.targetDir, story, reporter);
544
558
  if (repairOutcome) {
545
559
  if (repairOutcome.kind === 'paused') {
560
+ result.routing?.recordOutcome(false, 'infrastructure');
546
561
  reporter.paused(progress(stories));
547
562
  return { status: 'paused', iterations, finalProgress: progress(stories) };
548
563
  }
549
564
  const reason = repairBlockReason(repairOutcome, story, opts.targetDir, opts.git);
550
565
  if (reason) {
551
566
  result.routing?.recordOutcome(false);
552
- reporter.blocked(reason);
567
+ reporter.blocked(reason, repairOutcome.failure);
553
568
  return {
554
569
  status: 'blocked',
555
570
  iterations,
556
571
  reason,
572
+ ...(repairOutcome.failure ? { failure: repairOutcome.failure } : {}),
557
573
  finalProgress: progress(stories),
558
574
  };
559
575
  }
@@ -564,7 +580,6 @@ export function runLoop(opts) {
564
580
  reporter.blocked(protection);
565
581
  return { status: 'blocked', iterations, reason: protection, finalProgress: progress(stories) };
566
582
  }
567
- result.routing?.recordOutcome(true);
568
583
  reporter.phase('committing');
569
584
  const dec = appendDecision(contextDir(opts.targetDir), {
570
585
  storyId: story.id,
@@ -581,6 +596,7 @@ export function runLoop(opts) {
581
596
  opts.git.commitAll(opts.targetDir, `yoke: complete ${story.id} ${story.title}`, opts.commitIdentity);
582
597
  }
583
598
  catch (e) {
599
+ result.routing?.recordOutcome(false, 'infrastructure');
584
600
  savePrd(opts.prdPath, onDisk); // revert — never persist passes:true without a commit
585
601
  dec.rollback(); // and never leave an orphan decision
586
602
  const reason = blockReason(`commit failed for ${story.id}: ${e.message}`, opts.targetDir, opts.git);
@@ -592,38 +608,56 @@ export function runLoop(opts) {
592
608
  finalProgress: progress(stories),
593
609
  };
594
610
  }
611
+ result.routing?.recordOutcome(true);
612
+ clearFailureProgress(opts.targetDir, story.id);
595
613
  reporter.storyDone({ id: story.id, title: story.title }, progress(updated));
596
614
  }
597
615
  }
616
+ function blockedMechanicalGate(opts, directory, story, stage, summary, reason, iterations, stories) {
617
+ const observed = observeFailure({ root: opts.targetDir, directory, story, stage, summary });
618
+ const detail = observed.action === 'retry' ? reason : `${reason}\n${observed.feedback}`;
619
+ (opts.reporter ?? noopReporter).blocked(detail, observed.failure);
620
+ return { status: 'blocked', iterations, reason: detail, failure: observed.failure, finalProgress: progress(stories) };
621
+ }
598
622
  function runImplementation(opts, dir, story, reporter) {
599
- let feedback;
623
+ let feedback = opts.feedback;
600
624
  for (let attempt = 0;; attempt++) {
601
625
  const result = opts.runner({ targetDir: dir, story, feedback });
602
626
  if (!result.routing?.canRetry || result.routing.blocked || attempt >= 7)
603
- return result;
627
+ return { result };
604
628
  if (["decision-request.yaml", "ambiguity.md", "loop.pause"].some(name => existsSync(join(dir, ".yoke", name))) || existsSync(pauseFilePath(opts.targetDir)))
605
- return result;
629
+ return { result };
606
630
  const protection = acceptanceProtectionProblem(dir, opts.targetDir);
607
631
  if (protection)
608
- return result;
632
+ return { result };
633
+ const before = gateIdentity(dir, story);
609
634
  const criteria = runCriterionGates(opts, dir, story);
610
- const gates = [opts.verify, opts.design, opts.perf, opts.audit].filter((gate) => Boolean(gate));
635
+ const evidence = { criteria };
611
636
  let verdict = criteria;
637
+ let failedStage = 'criterion';
612
638
  if (verdict.passed)
613
- for (const gate of gates) {
639
+ for (const name of ['verify', 'design', 'perf', 'audit']) {
640
+ const gate = opts[name];
641
+ if (!gate)
642
+ continue;
614
643
  verdict = runGate(gate, dir, story.id);
644
+ failedStage = name;
645
+ evidence[name] = verdict;
615
646
  if (!verdict.passed)
616
647
  break;
617
648
  }
618
649
  if (verdict.passed)
619
- return result;
650
+ return { result, gates: snapshotGates(dir, story, before, evidence) };
620
651
  if (knownInfrastructureFailure(verdict.summary)) {
621
652
  result.routing.recordOutcome(false, 'infrastructure');
622
- return { ...result, success: false, infrastructureFailure: true, summary: verdict.summary, routing: { ...result.routing, blocked: true, canRetry: false } };
653
+ return { result: { ...result, success: false, infrastructureFailure: true, summary: verdict.summary, routing: { ...result.routing, blocked: true, canRetry: false } } };
623
654
  }
624
655
  result.routing.recordOutcome(false);
656
+ const observed = observeFailure({ root: opts.targetDir, directory: dir, story, stage: failedStage, summary: verdict.summary });
657
+ if (observed.action === 'blocked')
658
+ return { result: { ...result, success: false, summary: observed.feedback, routing: { ...result.routing, blocked: true, canRetry: false } }, failure: observed.failure };
625
659
  if (result.tokens)
626
660
  reporter.addTokens(result.tokens);
627
- feedback = verdict.summary;
661
+ feedback = observed.feedback;
628
662
  }
629
663
  }
@@ -62,11 +62,11 @@ function productionAdapters(targetDir, identity) {
62
62
  return { path, baseCommit };
63
63
  },
64
64
  cleanupProcess: cleanupProviderProcesses,
65
- retain: (input, reason) => {
65
+ retain: (input, reason, phase = 'integration') => {
66
66
  const file = recoveryPath(targetDir, input.story.id);
67
67
  // Preserve the path's original ownership identity when a retry is rejected again.
68
68
  const ownerToken = ownershipTokens.get(input.worktree.path) ?? input.ownerToken;
69
- retainParallelWorktree(targetDir, file, { storyId: input.story.id, worktree: input.worktree.path, baseCommit: input.worktree.baseCommit, prdHash: acceptanceDigests.get(input.story.id), ownerToken, reason });
69
+ retainParallelWorktree(targetDir, file, { storyId: input.story.id, worktree: input.worktree.path, baseCommit: input.worktree.baseCommit, prdHash: acceptanceDigests.get(input.story.id), ownerToken, reason, phase });
70
70
  },
71
71
  remove: input => {
72
72
  removeOwnedWorktree(targetDir, input, owned, removed, realGitOps);
@@ -81,13 +81,14 @@ function productionAdapters(targetDir, identity) {
81
81
  commit: input => realGitOps.commitAll(input.worktree.path, `yoke: complete ${input.story.id} ${input.story.title}`, identity),
82
82
  integrate: (input, expectedHead) => integrateCandidate(targetDir, input, expectedHead),
83
83
  },
84
- candidates: primary => makeCandidateLifecycle(targetDir, primary, owned, removed, realGitOps, ownershipTokens),
84
+ candidates: primary => makeCandidateLifecycle(targetDir, primary, owned, removed, realGitOps, ownershipTokens, acceptanceDigests.get(primary.story.id)),
85
85
  };
86
86
  }
87
87
  function cleanupProviderProcesses(input) {
88
88
  reapProviderProcesses(input.worktree.path, isPidAlive, isProviderTreeAlive, killProcessTreeForCleanup);
89
89
  }
90
- function makeCandidateLifecycle(targetDir, primary, owned, removed, git, ownershipTokens) {
90
+ function makeCandidateLifecycle(targetDir, primary, owned, removed, git, ownershipTokens, acceptanceDigest) {
91
+ const retainedRecords = new Map();
91
92
  return {
92
93
  reserve: input => {
93
94
  const worktree = input.candidateId === 'candidate-1'
@@ -114,6 +115,23 @@ function makeCandidateLifecycle(targetDir, primary, owned, removed, git, ownersh
114
115
  reap: ownership => {
115
116
  reapProviderProcesses(ownership.worktree.path, isPidAlive, isProviderTreeAlive, killProcessTreeForCleanup);
116
117
  },
118
+ ...(ownershipTokens && acceptanceDigest ? {
119
+ retain: (ownership, reason, phase) => {
120
+ // The first retained candidate resumes through the existing dispatcher recovery path.
121
+ // Additional alternatives remain inspectable without creating a second scheduler.
122
+ const ownerToken = ownershipTokens.get(ownership.worktree.path) ?? ownership.ownerToken;
123
+ const suffix = createHash('sha256').update(ownerToken).digest('hex').slice(0, 24);
124
+ const file = retainedRecords.get(ownership.worktree.path) ?? (retainedRecords.size === 0
125
+ ? recoveryPath(targetDir, ownership.storyId)
126
+ : statePath(targetDir, 'integration-recovery', `${storyPathSegment(ownership.storyId)}-candidate-${suffix}.json`));
127
+ retainedRecords.set(ownership.worktree.path, file);
128
+ retainParallelWorktree(targetDir, file, {
129
+ storyId: ownership.storyId, worktree: ownership.worktree.path, baseCommit: ownership.worktree.baseCommit,
130
+ prdHash: acceptanceDigest, ownerToken, reason, phase,
131
+ });
132
+ writeCandidateStatus(targetDir, ownership, ownership.worktree.path, 'retained', reason);
133
+ },
134
+ } : {}),
117
135
  remove: ownership => {
118
136
  removeOwnedWorktree(targetDir, ownership, owned, removed, git);
119
137
  writeCandidateStatus(targetDir, ownership, ownership.worktree.path, 'removed');
@@ -1,4 +1,5 @@
1
1
  import { existsSync, rmSync } from 'node:fs';
2
+ import { observeFailure, clearFailureProgress } from './failure.js';
2
3
  import { acceptanceProtectionProblem } from '../check/command.js';
3
4
  import { makeAsyncAdaptiveRunner } from '../routing/router.js';
4
5
  import { resolvePlanner } from '../routing/planning.js';
@@ -12,6 +13,7 @@ import { loadPrd, progress } from './prd.js';
12
13
  import { makeAsyncRunner } from './runner.js';
13
14
  import { runStoryWorker } from './worker.js';
14
15
  import { acquireSharedWorker, sharedPoolStatus } from './resource-pool.js';
16
+ import { providerTelemetryUsage } from '../observability/usage.js';
15
17
  export async function runParallelLoopCommand(input) {
16
18
  const originalVerify = input.verify;
17
19
  input = { ...input, verify: path => {
@@ -96,6 +98,8 @@ export async function runParallelLoopCommand(input) {
96
98
  baseCommit: workerInput.worktree.baseCommit,
97
99
  provider: workerInput.provider,
98
100
  runner,
101
+ feedback: workerInput.worktree.recovery?.feedback,
102
+ failureRoot: input.targetDir,
99
103
  verify: input.verify,
100
104
  design: input.design,
101
105
  verifyCriterion: input.verifyCriterion,
@@ -126,11 +130,29 @@ export async function runParallelLoopCommand(input) {
126
130
  const result = await dispatcher.run();
127
131
  const finalProgress = progress(loadPrd(input.prdPath));
128
132
  if (result.status === 'complete' && input.completion) {
129
- const gate = input.completion(input.targetDir);
130
- if (!gate.passed) {
131
- input.reporter.blocked(gate.summary);
133
+ const previous = process.env.YOKE_PHASE;
134
+ process.env.YOKE_PHASE = 'completion';
135
+ let reason;
136
+ try {
137
+ const gate = input.completion(input.targetDir);
138
+ if (!gate.passed)
139
+ reason = `integrated system did not verify: ${gate.summary}`;
140
+ }
141
+ catch (error) {
142
+ reason = `integrated completion gate failed: ${error instanceof Error ? error.message : String(error)}`;
143
+ }
144
+ finally {
145
+ if (previous === undefined)
146
+ delete process.env.YOKE_PHASE;
147
+ else
148
+ process.env.YOKE_PHASE = previous;
149
+ }
150
+ if (reason) {
151
+ const observed = observeFailure({ root: input.targetDir, directory: input.targetDir, stage: 'completion', summary: reason });
152
+ input.reporter.blocked(observed.action === 'retry' ? reason : observed.feedback, observed.failure);
132
153
  return 1;
133
154
  }
155
+ clearFailureProgress(input.targetDir);
134
156
  }
135
157
  if (result.status === 'complete')
136
158
  input.reporter.complete(finalProgress);
@@ -139,7 +161,7 @@ export async function runParallelLoopCommand(input) {
139
161
  else if (result.status === 'cap-reached')
140
162
  input.reporter.capReached(finalProgress);
141
163
  else
142
- input.reporter.blocked(result.reason ?? `parallel dispatcher ${result.status}`);
164
+ input.reporter.blocked(result.reason ?? `parallel dispatcher ${result.status}`, result.failure);
143
165
  return result.status === 'complete' ? 0 : result.status === 'paused' ? 3 : 1;
144
166
  }
145
167
  function candidateCoordinatorInput(input, adapters, worker, pause) {
@@ -182,6 +204,8 @@ function candidateDefinitions(input, worker, candidateCount, pause) {
182
204
  worker: {
183
205
  provider: worker.provider,
184
206
  runner,
207
+ failureRoot: input.targetDir,
208
+ failureScope: candidateId,
185
209
  verify: input.verify,
186
210
  design: input.design,
187
211
  verifyCriterion: input.verifyCriterion,
@@ -290,6 +314,9 @@ function asyncRunner(input, provider, signal, workerId) {
290
314
  strategy: input.routing.strategy,
291
315
  maxCandidates: input.routing.maxCandidates,
292
316
  maxAttempts: input.routing.maxAttempts,
317
+ optimization: input.routing.optimization,
318
+ accountingScope: input.accountingScope,
319
+ executionPolicyKey: input.executionPolicyKey,
293
320
  planner: resolvePlanner({ planning: input.planning }, input.runnerAgent, input.selection),
294
321
  assessmentPolicy: input.routing.assessmentPolicy,
295
322
  fallback: input.routing.fallback,
@@ -338,7 +365,7 @@ function asyncRunner(input, provider, signal, workerId) {
338
365
  };
339
366
  }
340
367
  export function providerProcessResultToAgentResult(agent, storyId, result) {
341
- const tokens = result.telemetry.tokens;
368
+ const tokens = providerTelemetryUsage(result.telemetry);
342
369
  const telemetry = tokens ? { tokens } : {};
343
370
  switch (result.kind) {
344
371
  case 'succeeded': return { success: true, summary: `${agent} implemented ${storyId}`, ...telemetry };