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