@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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +6 -1
- package/bench/analyze-codex-comparison.mjs +90 -17
- package/bench/compare-codex.mjs +159 -36
- package/bench/result-schema.mjs +132 -0
- package/canon/manifest.yaml +1 -1
- package/dist/agents/pi-telemetry.js +2 -1
- package/dist/agents/process-streams.js +12 -64
- package/dist/agents/provider-selection.js +12 -0
- package/dist/agents/telemetry.js +52 -52
- package/dist/change/inbox.js +8 -3
- package/dist/check/command.js +69 -17
- package/dist/check/delivery.js +121 -0
- package/dist/cli.js +86 -8
- package/dist/code-intelligence/budgets.js +138 -0
- package/dist/code-intelligence/contracts.js +2 -0
- package/dist/code-intelligence/coordinator.js +156 -84
- package/dist/code-intelligence/evidence.js +87 -34
- package/dist/code-intelligence/mcp-client.js +10 -2
- package/dist/code-intelligence/mcp-server.js +10 -10
- package/dist/dashboard/analytics.js +5 -3
- package/dist/goals/command.js +183 -53
- package/dist/goals/usage.js +87 -0
- package/dist/loop/candidate-cleanup.js +47 -17
- package/dist/loop/candidates.js +17 -11
- package/dist/loop/cleanup.js +100 -0
- package/dist/loop/dispatcher.js +89 -26
- package/dist/loop/failure.js +104 -0
- package/dist/loop/gate-snapshot.js +19 -0
- package/dist/loop/git.js +1 -1
- package/dist/loop/loop.js +100 -66
- package/dist/loop/parallel-adapters.js +22 -4
- package/dist/loop/parallel-command.js +32 -5
- package/dist/loop/recovery.js +23 -5
- package/dist/loop/reporter.js +21 -4
- package/dist/loop/run-command.js +102 -48
- package/dist/loop/runner.js +5 -4
- package/dist/loop/worker.js +145 -91
- package/dist/observability/history.js +1 -1
- package/dist/observability/invocation.js +42 -0
- package/dist/observability/usage.js +17 -0
- package/dist/prd/command.js +20 -7
- package/dist/prd/decompose.js +5 -2
- package/dist/retrofit/command.js +22 -0
- package/dist/retrofit/config.js +26 -1
- package/dist/retrofit/gitignore.js +2 -0
- package/dist/retrofit/planners/claude.js +4 -4
- package/dist/retrofit/wsl.js +23 -3
- package/dist/routing/attempts.js +241 -0
- package/dist/routing/capability.js +13 -9
- package/dist/routing/optimization.js +73 -0
- package/dist/routing/registry.js +7 -1
- package/dist/routing/router.js +280 -127
- package/dist/setup/command.js +54 -6
- package/dist/smoke/command.js +302 -74
- package/docs/BENCHMARK-MANIFEST.md +131 -0
- package/docs/CODE-INTELLIGENCE.md +43 -1
- package/docs/CODEX-COMPARISON-2026-09-29.md +15 -0
- package/docs/DELIVERY-JOURNEYS.md +199 -0
- package/docs/ECONOMIC-ROUTING.md +180 -0
- package/docs/GOALS.md +61 -4
- package/docs/RELEASE-VALIDATION-1.22.0.md +115 -0
- package/docs/parallel-execution.md +37 -9
- package/gemini-extension.json +1 -1
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
195
|
+
if (!reason) {
|
|
196
|
+
clearFailureProgress(opts.targetDir);
|
|
186
197
|
return null;
|
|
187
|
-
|
|
188
|
-
(opts.
|
|
189
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
635
|
+
const evidence = { criteria };
|
|
611
636
|
let verdict = criteria;
|
|
637
|
+
let failedStage = 'criterion';
|
|
612
638
|
if (verdict.passed)
|
|
613
|
-
for (const
|
|
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 =
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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 };
|