@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
package/dist/loop/loop.js CHANGED
@@ -1,9 +1,13 @@
1
+ import { cacheIsolationProblem } from './cache-isolation.js';
2
+ import { retainRuntimeProof } from './proof-retention.js';
1
3
  import { knownInfrastructureFailure } from "../routing/capability.js";
2
4
  import { existsSync, unlinkSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
3
5
  import { acceptanceProtectionProblem } from '../check/command.js';
4
6
  import { join, relative } from 'node:path';
5
7
  import { isAcceptanceCriterion, loadPrd, savePrd, selectNextStory, allPass, progress, storyPathSegment } from './prd.js';
6
8
  import { stopTheLineGate, preDispatchGate } from './gates.js';
9
+ import { observeFailure, clearFailureProgress } from './failure.js';
10
+ import { gateIdentity, snapshotGates, reuseGates } from './gate-snapshot.js';
7
11
  import { appendDecision, contextDir } from '../context/context.js';
8
12
  import { noopReporter } from './reporter.js';
9
13
  import { consumeDecisionRequest } from './decision.js';
@@ -68,7 +72,8 @@ function runQualityReview(opts, executionDir, story, reporter) {
68
72
  }
69
73
  return { kind: 'passed' };
70
74
  };
71
- return runQualityRepairLoop({
75
+ let failure;
76
+ const outcome = runQualityRepairLoop({
72
77
  quality: qualityAssessment
73
78
  ? round => {
74
79
  reporter.phase('comparing');
@@ -85,7 +90,13 @@ function runQualityReview(opts, executionDir, story, reporter) {
85
90
  reporter.phase('repairing');
86
91
  if (!opts.repair)
87
92
  return { kind: 'blocked', summary: 'repair callback is not configured' };
88
- const result = opts.repair({ targetDir: executionDir, story }, request);
93
+ const observed = observeFailure({ root: opts.targetDir, directory: executionDir, story, stage: 'quality', summary: JSON.stringify(request.finding) });
94
+ if (observed.action === 'blocked') {
95
+ failure = observed.failure;
96
+ return { kind: 'blocked', summary: observed.feedback };
97
+ }
98
+ const targetedRequest = observed.action === 'diagnose' ? { ...request, finding: { ...request.finding, message: observed.feedback } } : request;
99
+ const result = opts.repair({ targetDir: executionDir, story }, targetedRequest);
89
100
  return result.success ? { kind: 'repaired' } : { kind: 'blocked', summary: result.summary };
90
101
  },
91
102
  rerunGates,
@@ -96,6 +107,7 @@ function runQualityReview(opts, executionDir, story, reporter) {
96
107
  reporter.quality({ ...status, ...qualityMetadata });
97
108
  },
98
109
  });
110
+ return { ...outcome, ...(failure ? { failure } : {}) };
99
111
  }
100
112
  // Control file a supervisor drops to pause the loop at the next story boundary.
101
113
  // The loop consumes (deletes) it and stops with state 'paused' — never mid-story.
@@ -146,7 +158,7 @@ function runGate(gate, dir, storyId) {
146
158
  process.env.YOKE_STORY = prev;
147
159
  }
148
160
  }
149
- function consumeAmbiguity(dir) {
161
+ export function consumeAmbiguity(dir) {
150
162
  const file = ambiguityFilePath(dir);
151
163
  if (!existsSync(file))
152
164
  return null;
@@ -167,14 +179,16 @@ function runCompletionGate(opts, stories) {
167
179
  return null;
168
180
  const previous = process.env.YOKE_PHASE;
169
181
  process.env.YOKE_PHASE = 'completion';
170
- let verdict;
182
+ let reason;
171
183
  try {
172
- verdict = opts.completion(opts.targetDir);
184
+ const verdict = opts.completion(opts.targetDir);
185
+ if (!verdict.passed)
186
+ reason = `integrated system did not verify: ${verdict.summary}`;
187
+ else if (!opts.git.isClean(opts.targetDir))
188
+ reason = 'completion command left source or final assets dirty; preserve changes and move rerun proofs to ignored runtime paths before resuming';
173
189
  }
174
190
  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) };
191
+ reason = `integrated completion gate failed: ${error.message}`;
178
192
  }
179
193
  finally {
180
194
  if (previous === undefined)
@@ -182,13 +196,20 @@ function runCompletionGate(opts, stories) {
182
196
  else
183
197
  process.env.YOKE_PHASE = previous;
184
198
  }
185
- if (verdict.passed)
199
+ if (!reason) {
200
+ clearFailureProgress(opts.targetDir);
186
201
  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) };
202
+ }
203
+ const observed = observeFailure({ root: opts.targetDir, directory: opts.targetDir, stage: 'completion', summary: reason });
204
+ if (observed.action !== 'retry')
205
+ reason = observed.feedback;
206
+ (opts.reporter ?? noopReporter).blocked(reason, observed.failure);
207
+ return { status: 'blocked', iterations: 0, reason, failure: observed.failure, finalProgress: progress(stories) };
190
208
  }
191
209
  function runCriterionGates(opts, executionDir, story) {
210
+ const cacheProblem = opts.isolate && executionDir !== opts.targetDir ? cacheIsolationProblem(executionDir) : undefined;
211
+ if (cacheProblem)
212
+ return { passed: false, summary: cacheProblem };
192
213
  const protectionProblem = acceptanceProtectionProblem(executionDir, opts.targetDir);
193
214
  if (protectionProblem)
194
215
  return { passed: false, summary: protectionProblem };
@@ -200,10 +221,10 @@ function runCriterionGates(opts, executionDir, story) {
200
221
  }
201
222
  if (!opts.verifyCriterion)
202
223
  return { passed: false, summary: `story ${story.id} has criteria but no criterion verifier` };
203
- const evidence = criteria.map(criterion => ({
204
- criterion,
205
- result: opts.verifyCriterion(executionDir, story, criterion),
206
- }));
224
+ const evidence = criteria.map(criterion => {
225
+ const problem = opts.isolate && executionDir !== opts.targetDir ? cacheIsolationProblem(executionDir) : undefined;
226
+ return { criterion, result: problem ? { passed: false, summary: problem } : opts.verifyCriterion(executionDir, story, criterion) };
227
+ });
207
228
  try {
208
229
  writeCriterionEvidence(opts.targetDir, story, evidence);
209
230
  }
@@ -216,6 +237,15 @@ function runCriterionGates(opts, executionDir, story) {
216
237
  : { passed: true, summary: `${evidence.length} acceptance criteria verified` };
217
238
  }
218
239
  export function runLoop(opts) {
240
+ if (opts.isolate)
241
+ for (const name of ['verify', 'design', 'perf', 'audit']) {
242
+ const gate = opts[name];
243
+ if (gate)
244
+ opts = { ...opts, [name]: (dir) => {
245
+ const problem = dir !== opts.targetDir ? cacheIsolationProblem(dir) : undefined;
246
+ return problem ? { passed: false, summary: problem } : gate(dir);
247
+ } };
248
+ }
219
249
  let iterations = 0;
220
250
  const reporter = opts.reporter ?? noopReporter;
221
251
  for (;;) {
@@ -313,86 +343,88 @@ export function runLoop(opts) {
313
343
  const wt = join(opts.targetDir, '.yoke', 'worktrees', storyPathSegment(story.id));
314
344
  const wtPrd = join(wt, relative(opts.targetDir, opts.prdPath));
315
345
  let landed = null;
346
+ let activeRouting;
316
347
  try {
317
348
  opts.git.addWorktree(opts.targetDir, wt);
318
- const result = runImplementation(opts, wt, story, reporter);
349
+ const implementation = runImplementation(opts, wt, story, reporter);
350
+ const result = implementation.result;
351
+ activeRouting = result.routing;
319
352
  iterations++;
320
353
  if (result.tokens)
321
354
  reporter.addTokens(result.tokens);
322
355
  if (result.infrastructureFailure || result.routing?.blocked) {
323
356
  if (result.infrastructureFailure)
324
357
  result.routing?.recordOutcome(false, "infrastructure");
325
- reporter.blocked(result.summary);
326
- return { status: "blocked", iterations, reason: result.summary, finalProgress: progress(stories) };
358
+ reporter.blocked(result.summary, implementation.failure);
359
+ return { status: "blocked", iterations, reason: result.summary, ...(implementation.failure ? { failure: implementation.failure } : {}), finalProgress: progress(stories) };
327
360
  }
328
361
  let decision;
329
362
  try {
330
363
  decision = consumeDecisionRequest(wt, opts.targetDir, story.id);
331
364
  }
332
365
  catch (error) {
366
+ result.routing?.recordOutcome(false, 'infrastructure');
333
367
  const reason = `invalid critical decision request for story ${story.id}: ${error.message}`;
334
368
  reporter.blocked(reason);
335
369
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
336
370
  }
337
371
  if (decision) {
372
+ result.routing?.recordOutcome(false, 'infrastructure');
338
373
  const reason = `critical decision required for story ${story.id}: ${decision.question}`;
339
374
  reporter.blocked(reason);
340
375
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
341
376
  }
342
377
  const ambiguity = consumeAmbiguity(wt);
343
378
  if (ambiguity) {
379
+ result.routing?.recordOutcome(false, 'infrastructure');
344
380
  const reason = `story ${story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`;
345
381
  reporter.blocked(reason);
346
382
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
347
383
  }
348
- const criteriaVerdict = runCriterionGates(opts, wt, story);
384
+ const verified = reuseGates(wt, story, implementation.gates);
385
+ const criteriaVerdict = verified?.criteria ?? runCriterionGates(opts, wt, story);
349
386
  if (!criteriaVerdict.passed) {
350
387
  result.routing?.recordOutcome(false);
351
388
  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) };
389
+ return blockedMechanicalGate(opts, wt, story, 'criterion', criteriaVerdict.summary, reason, iterations, stories);
354
390
  }
355
391
  // Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
356
392
  // exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
357
393
  reporter.phase('verifying');
358
- const verdict = runGate(opts.verify, wt, story.id);
394
+ const verdict = verified?.verify ?? runGate(opts.verify, wt, story.id);
359
395
  if (!verdict.passed) {
360
396
  result.routing?.recordOutcome(false);
361
397
  const base = result.success
362
398
  ? `story ${story.id} did not verify: ${verdict.summary}`
363
399
  : `story ${story.id} runner failed (${result.summary}) and verify is red: ${verdict.summary}`;
364
400
  const reason = blockReason(base, opts.targetDir, opts.git);
365
- reporter.blocked(reason);
366
- return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
401
+ return blockedMechanicalGate(opts, wt, story, 'verify', verdict.summary, reason, iterations, stories);
367
402
  }
368
403
  if (opts.design) {
369
404
  reporter.phase('design');
370
- const designVerdict = runGate(opts.design, wt, story.id);
405
+ const designVerdict = verified?.design ?? runGate(opts.design, wt, story.id);
371
406
  if (!designVerdict.passed) {
372
407
  result.routing?.recordOutcome(false);
373
408
  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) };
409
+ return blockedMechanicalGate(opts, wt, story, 'design', designVerdict.summary, reason, iterations, stories);
376
410
  }
377
411
  }
378
412
  if (opts.perf) {
379
413
  reporter.phase('perf');
380
- const perfVerdict = runGate(opts.perf, wt, story.id);
414
+ const perfVerdict = verified?.perf ?? runGate(opts.perf, wt, story.id);
381
415
  if (!perfVerdict.passed) {
382
416
  result.routing?.recordOutcome(false);
383
417
  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) };
418
+ return blockedMechanicalGate(opts, wt, story, 'perf', perfVerdict.summary, reason, iterations, stories);
386
419
  }
387
420
  }
388
421
  if (opts.audit) {
389
422
  reporter.phase('audit');
390
- const auditVerdict = runGate(opts.audit, wt, story.id);
423
+ const auditVerdict = verified?.audit ?? runGate(opts.audit, wt, story.id);
391
424
  if (!auditVerdict.passed) {
392
425
  result.routing?.recordOutcome(false);
393
426
  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) };
427
+ return blockedMechanicalGate(opts, wt, story, 'audit', auditVerdict.summary, reason, iterations, stories);
396
428
  }
397
429
  }
398
430
  const summary = result.success
@@ -401,14 +433,15 @@ export function runLoop(opts) {
401
433
  const repairOutcome = runQualityReview(opts, wt, story, reporter);
402
434
  if (repairOutcome) {
403
435
  if (repairOutcome.kind === 'paused') {
436
+ result.routing?.recordOutcome(false, 'infrastructure');
404
437
  reporter.paused(progress(stories));
405
438
  return { status: 'paused', iterations, finalProgress: progress(stories) };
406
439
  }
407
440
  const reason = repairBlockReason(repairOutcome, story, opts.targetDir, opts.git);
408
441
  if (reason) {
409
442
  result.routing?.recordOutcome(false);
410
- reporter.blocked(reason);
411
- return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
443
+ reporter.blocked(reason, repairOutcome.failure);
444
+ return { status: 'blocked', iterations, reason, ...(repairOutcome.failure ? { failure: repairOutcome.failure } : {}), finalProgress: progress(stories) };
412
445
  }
413
446
  }
414
447
  const protection = acceptanceProtectionProblem(wt, opts.targetDir);
@@ -428,12 +461,15 @@ export function runLoop(opts) {
428
461
  });
429
462
  const updated = stories.map(s => (s.id === story.id ? { ...s, passes: true } : s));
430
463
  savePrd(wtPrd, updated);
464
+ retainRuntimeProof(wt, story.id, opts.targetDir);
431
465
  opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`, opts.commitIdentity);
432
466
  opts.git.integrate(opts.targetDir, wt);
433
467
  result.routing?.recordOutcome(true);
468
+ clearFailureProgress(opts.targetDir, story.id);
434
469
  landed = progress(updated);
435
470
  }
436
471
  catch (e) {
472
+ activeRouting?.recordOutcome(false, 'infrastructure');
437
473
  const reason = blockReason(`isolated iteration failed for ${story.id}: ${e.message}`, opts.targetDir, opts.git);
438
474
  reporter.blocked(reason);
439
475
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
@@ -452,89 +488,84 @@ export function runLoop(opts) {
452
488
  reporter.storyDone({ id: story.id, title: story.title }, landed);
453
489
  continue;
454
490
  }
455
- const result = runImplementation(opts, opts.targetDir, story, reporter);
491
+ const implementation = runImplementation(opts, opts.targetDir, story, reporter);
492
+ const result = implementation.result;
456
493
  iterations++;
457
494
  if (result.tokens)
458
495
  reporter.addTokens(result.tokens);
459
496
  if (result.infrastructureFailure || result.routing?.blocked) {
460
497
  if (result.infrastructureFailure)
461
498
  result.routing?.recordOutcome(false, "infrastructure");
462
- reporter.blocked(result.summary);
463
- return { status: "blocked", iterations, reason: result.summary, finalProgress: progress(stories) };
499
+ reporter.blocked(result.summary, implementation.failure);
500
+ return { status: "blocked", iterations, reason: result.summary, ...(implementation.failure ? { failure: implementation.failure } : {}), finalProgress: progress(stories) };
464
501
  }
465
502
  let decision;
466
503
  try {
467
504
  decision = consumeDecisionRequest(opts.targetDir, opts.targetDir, story.id);
468
505
  }
469
506
  catch (error) {
507
+ result.routing?.recordOutcome(false, 'infrastructure');
470
508
  const reason = `invalid critical decision request for story ${story.id}: ${error.message}`;
471
509
  reporter.blocked(reason);
472
510
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
473
511
  }
474
512
  if (decision) {
513
+ result.routing?.recordOutcome(false, 'infrastructure');
475
514
  const reason = `critical decision required for story ${story.id}: ${decision.question}`;
476
515
  reporter.blocked(reason);
477
516
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
478
517
  }
479
518
  const ambiguity = consumeAmbiguity(opts.targetDir);
480
519
  if (ambiguity) {
520
+ result.routing?.recordOutcome(false, 'infrastructure');
481
521
  const reason = `story ${story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`;
482
522
  reporter.blocked(reason);
483
523
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
484
524
  }
485
- const criteriaVerdict = runCriterionGates(opts, opts.targetDir, story);
525
+ const verified = reuseGates(opts.targetDir, story, implementation.gates);
526
+ const criteriaVerdict = verified?.criteria ?? runCriterionGates(opts, opts.targetDir, story);
486
527
  if (!criteriaVerdict.passed) {
487
528
  result.routing?.recordOutcome(false);
488
529
  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) };
530
+ return blockedMechanicalGate(opts, opts.targetDir, story, 'criterion', criteriaVerdict.summary, reason, iterations, stories);
491
531
  }
492
532
  // Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
493
533
  // exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
494
534
  reporter.phase('verifying');
495
- const verdict = runGate(opts.verify, opts.targetDir, story.id);
535
+ const verdict = verified?.verify ?? runGate(opts.verify, opts.targetDir, story.id);
496
536
  if (!verdict.passed) {
497
537
  result.routing?.recordOutcome(false);
498
538
  const base = result.success
499
539
  ? `story ${story.id} did not verify: ${verdict.summary}`
500
540
  : `story ${story.id} runner failed (${result.summary}) and verify is red: ${verdict.summary}`;
501
541
  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
- };
542
+ return blockedMechanicalGate(opts, opts.targetDir, story, 'verify', verdict.summary, reason, iterations, stories);
509
543
  }
510
544
  if (opts.design) {
511
545
  reporter.phase('design');
512
- const designVerdict = runGate(opts.design, opts.targetDir, story.id);
546
+ const designVerdict = verified?.design ?? runGate(opts.design, opts.targetDir, story.id);
513
547
  if (!designVerdict.passed) {
514
548
  result.routing?.recordOutcome(false);
515
549
  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) };
550
+ return blockedMechanicalGate(opts, opts.targetDir, story, 'design', designVerdict.summary, reason, iterations, stories);
518
551
  }
519
552
  }
520
553
  if (opts.perf) {
521
554
  reporter.phase('perf');
522
- const perfVerdict = runGate(opts.perf, opts.targetDir, story.id);
555
+ const perfVerdict = verified?.perf ?? runGate(opts.perf, opts.targetDir, story.id);
523
556
  if (!perfVerdict.passed) {
524
557
  result.routing?.recordOutcome(false);
525
558
  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) };
559
+ return blockedMechanicalGate(opts, opts.targetDir, story, 'perf', perfVerdict.summary, reason, iterations, stories);
528
560
  }
529
561
  }
530
562
  if (opts.audit) {
531
563
  reporter.phase('audit');
532
- const auditVerdict = runGate(opts.audit, opts.targetDir, story.id);
564
+ const auditVerdict = verified?.audit ?? runGate(opts.audit, opts.targetDir, story.id);
533
565
  if (!auditVerdict.passed) {
534
566
  result.routing?.recordOutcome(false);
535
567
  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) };
568
+ return blockedMechanicalGate(opts, opts.targetDir, story, 'audit', auditVerdict.summary, reason, iterations, stories);
538
569
  }
539
570
  }
540
571
  const summary = result.success
@@ -543,17 +574,19 @@ export function runLoop(opts) {
543
574
  const repairOutcome = runQualityReview(opts, opts.targetDir, story, reporter);
544
575
  if (repairOutcome) {
545
576
  if (repairOutcome.kind === 'paused') {
577
+ result.routing?.recordOutcome(false, 'infrastructure');
546
578
  reporter.paused(progress(stories));
547
579
  return { status: 'paused', iterations, finalProgress: progress(stories) };
548
580
  }
549
581
  const reason = repairBlockReason(repairOutcome, story, opts.targetDir, opts.git);
550
582
  if (reason) {
551
583
  result.routing?.recordOutcome(false);
552
- reporter.blocked(reason);
584
+ reporter.blocked(reason, repairOutcome.failure);
553
585
  return {
554
586
  status: 'blocked',
555
587
  iterations,
556
588
  reason,
589
+ ...(repairOutcome.failure ? { failure: repairOutcome.failure } : {}),
557
590
  finalProgress: progress(stories),
558
591
  };
559
592
  }
@@ -564,7 +597,6 @@ export function runLoop(opts) {
564
597
  reporter.blocked(protection);
565
598
  return { status: 'blocked', iterations, reason: protection, finalProgress: progress(stories) };
566
599
  }
567
- result.routing?.recordOutcome(true);
568
600
  reporter.phase('committing');
569
601
  const dec = appendDecision(contextDir(opts.targetDir), {
570
602
  storyId: story.id,
@@ -581,6 +613,7 @@ export function runLoop(opts) {
581
613
  opts.git.commitAll(opts.targetDir, `yoke: complete ${story.id} ${story.title}`, opts.commitIdentity);
582
614
  }
583
615
  catch (e) {
616
+ result.routing?.recordOutcome(false, 'infrastructure');
584
617
  savePrd(opts.prdPath, onDisk); // revert — never persist passes:true without a commit
585
618
  dec.rollback(); // and never leave an orphan decision
586
619
  const reason = blockReason(`commit failed for ${story.id}: ${e.message}`, opts.targetDir, opts.git);
@@ -592,38 +625,59 @@ export function runLoop(opts) {
592
625
  finalProgress: progress(stories),
593
626
  };
594
627
  }
628
+ result.routing?.recordOutcome(true);
629
+ clearFailureProgress(opts.targetDir, story.id);
595
630
  reporter.storyDone({ id: story.id, title: story.title }, progress(updated));
596
631
  }
597
632
  }
633
+ function blockedMechanicalGate(opts, directory, story, stage, summary, reason, iterations, stories) {
634
+ const observed = observeFailure({ root: opts.targetDir, directory, story, stage, summary });
635
+ const detail = observed.action === 'retry' ? reason : `${reason}\n${observed.feedback}`;
636
+ (opts.reporter ?? noopReporter).blocked(detail, observed.failure);
637
+ return { status: 'blocked', iterations, reason: detail, failure: observed.failure, finalProgress: progress(stories) };
638
+ }
598
639
  function runImplementation(opts, dir, story, reporter) {
599
- let feedback;
640
+ let feedback = opts.feedback;
600
641
  for (let attempt = 0;; attempt++) {
601
642
  const result = opts.runner({ targetDir: dir, story, feedback });
643
+ const cacheProblem = opts.isolate && dir !== opts.targetDir ? cacheIsolationProblem(dir) : undefined;
644
+ if (cacheProblem)
645
+ return { result: { ...result, success: false, infrastructureFailure: true, summary: cacheProblem } };
602
646
  if (!result.routing?.canRetry || result.routing.blocked || attempt >= 7)
603
- return result;
647
+ return { result };
604
648
  if (["decision-request.yaml", "ambiguity.md", "loop.pause"].some(name => existsSync(join(dir, ".yoke", name))) || existsSync(pauseFilePath(opts.targetDir)))
605
- return result;
649
+ return { result };
606
650
  const protection = acceptanceProtectionProblem(dir, opts.targetDir);
607
651
  if (protection)
608
- return result;
652
+ return { result };
653
+ const before = gateIdentity(dir, story);
609
654
  const criteria = runCriterionGates(opts, dir, story);
610
- const gates = [opts.verify, opts.design, opts.perf, opts.audit].filter((gate) => Boolean(gate));
655
+ const evidence = { criteria };
611
656
  let verdict = criteria;
657
+ let failedStage = 'criterion';
612
658
  if (verdict.passed)
613
- for (const gate of gates) {
659
+ for (const name of ['verify', 'design', 'perf', 'audit']) {
660
+ const gate = opts[name];
661
+ if (!gate)
662
+ continue;
614
663
  verdict = runGate(gate, dir, story.id);
664
+ failedStage = name;
665
+ evidence[name] = verdict;
615
666
  if (!verdict.passed)
616
667
  break;
617
668
  }
618
669
  if (verdict.passed)
619
- return result;
670
+ return { result, gates: snapshotGates(dir, story, before, evidence) };
620
671
  if (knownInfrastructureFailure(verdict.summary)) {
621
672
  result.routing.recordOutcome(false, 'infrastructure');
622
- return { ...result, success: false, infrastructureFailure: true, summary: verdict.summary, routing: { ...result.routing, blocked: true, canRetry: false } };
673
+ return { result: { ...result, success: false, infrastructureFailure: true, summary: verdict.summary, routing: { ...result.routing, blocked: true, canRetry: false } } };
623
674
  }
624
675
  result.routing.recordOutcome(false);
676
+ const observed = observeFailure({ root: opts.targetDir, directory: dir, story, stage: failedStage, summary: verdict.summary });
677
+ if (observed.action === 'blocked')
678
+ return { result: { ...result, success: false, summary: observed.feedback, routing: { ...result.routing, blocked: true, canRetry: false } }, failure: observed.failure };
625
679
  if (result.tokens)
626
680
  reporter.addTokens(result.tokens);
627
- feedback = verdict.summary;
681
+ feedback = observed.feedback;
628
682
  }
629
683
  }
@@ -3,11 +3,14 @@ import { createHash } from 'node:crypto';
3
3
  import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
4
4
  import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { isProviderTreeAlive, reapProviderProcesses } from './cleanup.js';
6
- import { realGitOps, stageImplementation } from './git.js';
6
+ import { realGitOps, stageImplementation, RUNTIME_EXCLUDES } from './git.js';
7
7
  import { isPidAlive } from './lock.js';
8
8
  import { storyPathSegment } from './prd.js';
9
9
  import { killProcessTreeForCleanup } from './watchdog.js';
10
10
  import { parallelAcceptanceDigest, recoverParallelWorktree, retainParallelWorktree } from './recovery.js';
11
+ import { cacheIsolationProblem } from './cache-isolation.js';
12
+ import { retainRuntimeProof } from './proof-retention.js';
13
+ import { writeScopesOverlap } from './scheduler.js';
11
14
  import { statePath } from '../workspace/state.js';
12
15
  export function makeParallelAdapters(targetDir, identity, injectedGit) {
13
16
  return injectedGit
@@ -62,11 +65,11 @@ function productionAdapters(targetDir, identity) {
62
65
  return { path, baseCommit };
63
66
  },
64
67
  cleanupProcess: cleanupProviderProcesses,
65
- retain: (input, reason) => {
68
+ retain: (input, reason, phase = 'integration') => {
66
69
  const file = recoveryPath(targetDir, input.story.id);
67
70
  // Preserve the path's original ownership identity when a retry is rejected again.
68
71
  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 });
72
+ 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
73
  },
71
74
  remove: input => {
72
75
  removeOwnedWorktree(targetDir, input, owned, removed, realGitOps);
@@ -78,16 +81,23 @@ function productionAdapters(targetDir, identity) {
78
81
  git: {
79
82
  isClean: dir => realGitOps.isClean(dir),
80
83
  rebase: input => rebaseCandidate(targetDir, input),
81
- commit: input => realGitOps.commitAll(input.worktree.path, `yoke: complete ${input.story.id} ${input.story.title}`, identity),
84
+ commit: input => {
85
+ const unexpected = unexpectedWrites(input, changedPaths(input.worktree.path, gitText(input.worktree.path, ['rev-parse', 'HEAD'])));
86
+ if (unexpected.length)
87
+ throw new Error(`Candidate writes outside declared scopes after integrated gates: ${unexpected.join(', ')}`);
88
+ retainRuntimeProof(input.worktree.path, input.story.id, targetDir);
89
+ realGitOps.commitAll(input.worktree.path, `yoke: complete ${input.story.id} ${input.story.title}`, identity);
90
+ },
82
91
  integrate: (input, expectedHead) => integrateCandidate(targetDir, input, expectedHead),
83
92
  },
84
- candidates: primary => makeCandidateLifecycle(targetDir, primary, owned, removed, realGitOps, ownershipTokens),
93
+ candidates: primary => makeCandidateLifecycle(targetDir, primary, owned, removed, realGitOps, ownershipTokens, acceptanceDigests.get(primary.story.id)),
85
94
  };
86
95
  }
87
96
  function cleanupProviderProcesses(input) {
88
97
  reapProviderProcesses(input.worktree.path, isPidAlive, isProviderTreeAlive, killProcessTreeForCleanup);
89
98
  }
90
- function makeCandidateLifecycle(targetDir, primary, owned, removed, git, ownershipTokens) {
99
+ function makeCandidateLifecycle(targetDir, primary, owned, removed, git, ownershipTokens, acceptanceDigest) {
100
+ const retainedRecords = new Map();
91
101
  return {
92
102
  reserve: input => {
93
103
  const worktree = input.candidateId === 'candidate-1'
@@ -114,6 +124,23 @@ function makeCandidateLifecycle(targetDir, primary, owned, removed, git, ownersh
114
124
  reap: ownership => {
115
125
  reapProviderProcesses(ownership.worktree.path, isPidAlive, isProviderTreeAlive, killProcessTreeForCleanup);
116
126
  },
127
+ ...(ownershipTokens && acceptanceDigest ? {
128
+ retain: (ownership, reason, phase) => {
129
+ // The first retained candidate resumes through the existing dispatcher recovery path.
130
+ // Additional alternatives remain inspectable without creating a second scheduler.
131
+ const ownerToken = ownershipTokens.get(ownership.worktree.path) ?? ownership.ownerToken;
132
+ const suffix = createHash('sha256').update(ownerToken).digest('hex').slice(0, 24);
133
+ const file = retainedRecords.get(ownership.worktree.path) ?? (retainedRecords.size === 0
134
+ ? recoveryPath(targetDir, ownership.storyId)
135
+ : statePath(targetDir, 'integration-recovery', `${storyPathSegment(ownership.storyId)}-candidate-${suffix}.json`));
136
+ retainedRecords.set(ownership.worktree.path, file);
137
+ retainParallelWorktree(targetDir, file, {
138
+ storyId: ownership.storyId, worktree: ownership.worktree.path, baseCommit: ownership.worktree.baseCommit,
139
+ prdHash: acceptanceDigest, ownerToken, reason, phase,
140
+ });
141
+ writeCandidateStatus(targetDir, ownership, ownership.worktree.path, 'retained', reason);
142
+ },
143
+ } : {}),
117
144
  remove: ownership => {
118
145
  removeOwnedWorktree(targetDir, ownership, owned, removed, git);
119
146
  writeCandidateStatus(targetDir, ownership, ownership.worktree.path, 'removed');
@@ -159,7 +186,18 @@ function writeCandidateStatus(targetDir, input, worktree, state, reason) {
159
186
  writeFileSync(file, JSON.stringify({ storyId: input.storyId, candidateId: input.candidateId, worktree, state, ...(reason ? { reason } : {}) }));
160
187
  }
161
188
  function rebaseCandidate(targetDir, input) {
189
+ const cacheProblem = cacheIsolationProblem(input.worktree.path);
190
+ if (cacheProblem)
191
+ return { kind: 'reopen', reason: cacheProblem };
162
192
  const currentHead = gitText(targetDir, ['rev-parse', 'HEAD']);
193
+ const actual = changedPaths(input.worktree.path, input.worktree.baseCommit);
194
+ const unexpected = unexpectedWrites(input, actual);
195
+ const siblings = currentHead === input.worktree.baseCommit ? [] : changedPaths(targetDir, input.worktree.baseCommit);
196
+ const collisions = actual.filter(path => writeScopesOverlap([path], siblings));
197
+ if (unexpected.length)
198
+ return { kind: 'reopen', reason: `Candidate writes outside declared scopes: ${unexpected.join(', ')}${collisions.length ? `; integrated sibling collisions: ${collisions.join(', ')}` : ''}` };
199
+ if (collisions.length)
200
+ return { kind: 'reopen', reason: `Candidate writes collide with integrated sibling changes: ${collisions.join(', ')}` };
163
201
  const candidateHead = gitText(input.worktree.path, ['rev-parse', 'HEAD']);
164
202
  try {
165
203
  execFileSync('git', ['merge-base', '--is-ancestor', input.worktree.baseCommit, 'HEAD'], { cwd: input.worktree.path, stdio: 'pipe' });
@@ -229,3 +267,16 @@ function gitText(dir, args) {
229
267
  function errorMessage(error) {
230
268
  return error instanceof Error ? error.message : String(error);
231
269
  }
270
+ function changedPaths(directory, base) {
271
+ const read = (args) => execFileSync('git', args, { cwd: directory, stdio: 'pipe' }).toString().split('\0').filter(Boolean);
272
+ return [...new Set([...read(['diff', '--no-renames', '--name-only', '-z', base, '--', '.', ...RUNTIME_EXCLUDES]), ...read(['ls-files', '--others', '--exclude-standard', '-z', '--', '.', ...RUNTIME_EXCLUDES])])].filter(path => !/^(?:\.yoke\/(?:proof|context)\/|\.yoke\/prd\.(?:yaml|json)$)/u.test(path));
273
+ }
274
+ function unexpectedWrites(input, paths) {
275
+ if (input.story.writes === undefined)
276
+ return [];
277
+ const normalize = (value) => {
278
+ const path = value.replace(/\\/gu, '/').replace(/\/$/u, '');
279
+ return process.platform === 'win32' ? path.toLowerCase() : path;
280
+ };
281
+ return paths.filter(path => !input.story.writes.some(scope => normalize(path) === normalize(scope) || normalize(path).startsWith(`${normalize(scope)}/`)));
282
+ }