@hecer/yoke 1.7.0 → 1.9.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 +35 -0
- package/README.md +23 -18
- package/canon/skills/authoring-prd/SKILL.md +7 -0
- package/dist/agents/providers.js +10 -3
- package/dist/change/inbox.js +2 -0
- package/dist/cli.js +12 -5
- package/dist/dashboard/analytics.js +123 -0
- package/dist/dashboard/page.js +6 -4
- package/dist/dashboard/panels.js +19 -0
- package/dist/dashboard/server.js +20 -3
- package/dist/goals/command.js +31 -4
- package/dist/loop/dispatcher.js +5 -2
- package/dist/loop/git.js +1 -1
- package/dist/loop/loop.js +44 -3
- package/dist/loop/parallel-command.js +49 -8
- package/dist/loop/prd.js +2 -0
- package/dist/loop/reporter.js +29 -9
- package/dist/loop/run-command.js +58 -15
- package/dist/loop/runner.js +17 -10
- package/dist/loop/worker.js +28 -1
- package/dist/observability/events.js +6 -1
- package/dist/observability/history.js +80 -0
- package/dist/quality/candidate-comparison.js +1 -1
- package/dist/quality/command.js +27 -8
- package/dist/retrofit/config.js +6 -1
- package/dist/retrofit/gitignore.js +1 -0
- package/dist/routing/assessment.js +66 -0
- package/dist/routing/capability.js +79 -0
- package/dist/routing/router.js +80 -10
- package/dist/setup/command.js +24 -11
- package/docs/CAPABILITY-ROUTING.md +54 -0
- package/docs/PRODUCT-DIRECTION-2026-09-05.md +26 -0
- package/docs/VERIFIED-PROJECTS.md +20 -0
- package/gemini-extension.json +1 -1
- package/hooks/bounded-gemini.mjs +70 -0
- package/package.json +1 -1
package/dist/goals/command.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { loadConfig } from "../retrofit/config.js";
|
|
2
|
+
import { makeAsyncAdaptiveRunner } from "../routing/router.js";
|
|
1
3
|
import { randomUUID } from 'node:crypto';
|
|
2
4
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync, readdirSync } from 'node:fs';
|
|
3
5
|
import { join } from 'node:path';
|
|
@@ -97,7 +99,7 @@ export function goalHandoff(root) {
|
|
|
97
99
|
async function executeAgent(input) {
|
|
98
100
|
const handle = startProviderProcess(input.provider, buildProviderInvocation(input.provider, input.prompt, input.root, 'safe', input.selection), { signal: input.signal, idleTimeoutMs: 20 * 60_000 });
|
|
99
101
|
const result = await handle.completion;
|
|
100
|
-
return { success: result.kind === 'succeeded', summary: result.kind === 'succeeded' ? 'Agent finished; independently checked below' : `${result.kind}: ${result.stderr.slice(-3000)}`, ...result.telemetry.tokens };
|
|
102
|
+
return { success: result.kind === 'succeeded', summary: result.kind === 'succeeded' ? 'Agent finished; independently checked below' : `${result.kind}: ${result.stderr.slice(-3000)}`, ...result.telemetry.tokens, tokens: result.telemetry.tokens };
|
|
101
103
|
}
|
|
102
104
|
export async function runProjectGoal(root, options = {}) {
|
|
103
105
|
const lock = acquireLock(root);
|
|
@@ -130,7 +132,24 @@ export async function runProjectGoal(root, options = {}) {
|
|
|
130
132
|
throw error;
|
|
131
133
|
}
|
|
132
134
|
const provider = options.provider ?? 'codex';
|
|
133
|
-
const
|
|
135
|
+
const rawExecute = options.execute ?? executeAgent;
|
|
136
|
+
const config = loadConfig(root);
|
|
137
|
+
const execute = async (input) => {
|
|
138
|
+
if (!config?.routing?.enabled || config.routing.strategy !== "capability")
|
|
139
|
+
return rawExecute(input);
|
|
140
|
+
const routed = makeAsyncAdaptiveRunner({ parent: provider, parentSelection: input.selection, projectRoot: root, workers: config.routing.workers, strategy: "capability", maxCandidates: config.routing.maxCandidates, maxAttempts: Math.min(goal.maxAttempts, config.routing.maxAttempts ?? 5),
|
|
141
|
+
captureRoute: async (agent, context, prompt, selection) => {
|
|
142
|
+
const run = await startProviderProcess(agent, buildProviderInvocation(agent, prompt, root, "read-only", selection), { signal: input.signal, idleTimeoutMs: 20 * 60_000 }).completion;
|
|
143
|
+
return { success: run.kind === "succeeded", summary: run.kind, output: run.stdout, tokens: run.telemetry.tokens };
|
|
144
|
+
},
|
|
145
|
+
makeWorker: (agent, selection) => async (context) => {
|
|
146
|
+
const result = await rawExecute({ ...input, provider: agent, selection, prompt: input.prompt + "\nPlanner approach:\n" + (context.story.assessment?.approach ?? "") });
|
|
147
|
+
return { ...result, infrastructureFailure: !result.success, tokens: result.tokens ?? (result.inputTokens !== undefined && result.outputTokens !== undefined ? { inputTokens: result.inputTokens, outputTokens: result.outputTokens, model: result.model } : undefined) };
|
|
148
|
+
}, });
|
|
149
|
+
const result = await routed({ targetDir: root, story: { id: goal.id, title: goal.objective, priority: 1, passes: false, agent: provider, acceptance: manifest.criteria.map(c => c.text) } });
|
|
150
|
+
const last = result.tokens?.calls?.at(-1);
|
|
151
|
+
return { ...result, provider: last?.provider ?? provider, model: last?.actualModel, inputTokens: result.tokens?.measurementComplete ? result.tokens.inputTokens : undefined, outputTokens: result.tokens?.measurementComplete ? result.tokens.outputTokens : undefined };
|
|
152
|
+
};
|
|
134
153
|
let report = checkProject(root);
|
|
135
154
|
while (report.status !== 'passed') {
|
|
136
155
|
const protectionProblem = acceptanceProtectionProblem(root);
|
|
@@ -167,10 +186,18 @@ export async function runProjectGoal(root, options = {}) {
|
|
|
167
186
|
const agentDurationMs = Date.now() - started;
|
|
168
187
|
const checkStarted = Date.now();
|
|
169
188
|
report = checkProject(root);
|
|
189
|
+
if (!result.routing?.blocked)
|
|
190
|
+
result.routing?.recordOutcome(report.status === 'passed');
|
|
170
191
|
appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'phase-ended', phase: 'verify', durationMs: Date.now() - checkStarted, outcome: report.status });
|
|
171
|
-
goal.attempts.push({ provider, model: result.model ?? options.selection?.model, startedAt: new Date(started).toISOString(), durationMs: agentDurationMs, success: result.success, summary: result.summary.slice(0, 8000), checkId: report.id, inputTokens: result.inputTokens, outputTokens: result.outputTokens });
|
|
172
|
-
|
|
192
|
+
goal.attempts.push({ provider: result.provider ?? provider, model: result.model ?? options.selection?.model, startedAt: new Date(started).toISOString(), durationMs: agentDurationMs, success: result.success, summary: result.summary.slice(0, 8000), checkId: report.id, inputTokens: result.inputTokens, outputTokens: result.outputTokens });
|
|
193
|
+
const usageAvailable = result.inputTokens !== undefined && result.outputTokens !== undefined;
|
|
194
|
+
appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'tokens', attemptId: `${goal.id}:${goal.attempts.length}`, durationMs: agentDurationMs, data: { ...result.tokens, provider: result.provider ?? provider, role: 'parent', model: result.model, inputTokens: result.inputTokens, outputTokens: result.outputTokens, usageAvailable } });
|
|
195
|
+
appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'attempt-ended', attemptId: `${goal.id}:${goal.attempts.length}`, durationMs: Date.now() - started, outcome: report.status, data: { provider, model: result.model, usageAvailable } });
|
|
196
|
+
if (report.status === 'passed')
|
|
197
|
+
appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'accepted', attemptId: `${goal.id}:${goal.attempts.length}` });
|
|
173
198
|
goal = save(root, { ...goal, lastCheck: report.id, pendingAttempt: undefined });
|
|
199
|
+
if (result.routing?.blocked)
|
|
200
|
+
return save(root, { ...goal, status: "blocked", reason: result.summary });
|
|
174
201
|
if (controller.signal.aborted)
|
|
175
202
|
return save(root, { ...goal, status: 'blocked', reason: 'Time budget exceeded; work retained' });
|
|
176
203
|
}
|
package/dist/loop/dispatcher.js
CHANGED
|
@@ -163,11 +163,13 @@ export function createDispatcher(options) {
|
|
|
163
163
|
case 'integrated':
|
|
164
164
|
input.story.passes = true;
|
|
165
165
|
integrated.push(input.story.id);
|
|
166
|
+
options.onAccepted?.(input.story);
|
|
166
167
|
result.routing.recordOutcome?.(true);
|
|
167
168
|
return;
|
|
168
169
|
case 'integrated-but-blocked':
|
|
169
170
|
input.story.passes = true;
|
|
170
171
|
integrated.push(input.story.id);
|
|
172
|
+
options.onAccepted?.(input.story);
|
|
171
173
|
integrationBlocks.push(merge.reason);
|
|
172
174
|
result.routing.recordOutcome?.(true);
|
|
173
175
|
return;
|
|
@@ -301,7 +303,8 @@ export function createDispatcher(options) {
|
|
|
301
303
|
paused = true;
|
|
302
304
|
if (!paused && !cancellationReason && integrationBlocks.length === 0 && iterations < options.maxIterations) {
|
|
303
305
|
const busy = new Set([...active.keys(), ...queued.keys(), ...failed]);
|
|
304
|
-
|
|
306
|
+
// Integration/review retains its execution slot until the candidate lands.
|
|
307
|
+
const slots = Math.max(0, options.maxConcurrency - active.size - queued.size);
|
|
305
308
|
const ready = readyStories(options.stories, { activeAreas: areas, activeWrites: [...reservedWrites.values()] }).filter(story => !busy.has(story.id));
|
|
306
309
|
let launched = 0;
|
|
307
310
|
for (const story of ready) {
|
|
@@ -316,7 +319,7 @@ export function createDispatcher(options) {
|
|
|
316
319
|
}
|
|
317
320
|
}
|
|
318
321
|
if (active.size > 0) {
|
|
319
|
-
await Promise.race([...active.values()].map(worker => worker.task));
|
|
322
|
+
await Promise.race([...active.values()].map(worker => worker.task).concat([...queued.values()]));
|
|
320
323
|
continue;
|
|
321
324
|
}
|
|
322
325
|
if (queued.size > 0) {
|
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/artifacts', '.yoke/events', '.yoke/checks', '.yoke/goal.json', '.yoke/goal.pause'];
|
|
3
|
+
export const RUNTIME_PATHS = ['.yoke/artifacts', '.yoke/events', '.yoke/history', '.yoke/routing', '.yoke/checks', '.yoke/goal.json', '.yoke/goal.pause'];
|
|
4
4
|
export const RUNTIME_EXCLUDES = RUNTIME_PATHS.map(path => `:(exclude)${path}${path.endsWith('.json') || path.endsWith('.pause') ? '' : '/**'}`);
|
|
5
5
|
export const realGitOps = {
|
|
6
6
|
isClean(dir) {
|
package/dist/loop/loop.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { knownInfrastructureFailure } from "../routing/capability.js";
|
|
1
2
|
import { existsSync, unlinkSync, readFileSync } from 'node:fs';
|
|
2
3
|
import { acceptanceProtectionProblem } from '../check/command.js';
|
|
3
4
|
import { join, relative } from 'node:path';
|
|
@@ -302,10 +303,14 @@ export function runLoop(opts) {
|
|
|
302
303
|
let landed = null;
|
|
303
304
|
try {
|
|
304
305
|
opts.git.addWorktree(opts.targetDir, wt);
|
|
305
|
-
const result = opts
|
|
306
|
+
const result = runImplementation(opts, wt, story, reporter);
|
|
306
307
|
iterations++;
|
|
307
308
|
if (result.tokens)
|
|
308
309
|
reporter.addTokens(result.tokens);
|
|
310
|
+
if (result.routing?.blocked) {
|
|
311
|
+
reporter.blocked(result.summary);
|
|
312
|
+
return { status: "blocked", iterations, reason: result.summary, finalProgress: progress(stories) };
|
|
313
|
+
}
|
|
309
314
|
let decision;
|
|
310
315
|
try {
|
|
311
316
|
decision = consumeDecisionRequest(wt, opts.targetDir, story.id);
|
|
@@ -398,7 +403,6 @@ export function runLoop(opts) {
|
|
|
398
403
|
reporter.blocked(protection);
|
|
399
404
|
return { status: 'blocked', iterations, reason: protection, finalProgress: progress(stories) };
|
|
400
405
|
}
|
|
401
|
-
result.routing?.recordOutcome(true);
|
|
402
406
|
// The worktree is a checkout of committed HEAD, so the agent above reads
|
|
403
407
|
// context from HEAD's .yoke/context — commit context changes for --isolate
|
|
404
408
|
// to honour them. We write the decision here so `integrate` carries it back.
|
|
@@ -412,6 +416,7 @@ export function runLoop(opts) {
|
|
|
412
416
|
savePrd(wtPrd, updated);
|
|
413
417
|
opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`, opts.commitIdentity);
|
|
414
418
|
opts.git.integrate(opts.targetDir, wt);
|
|
419
|
+
result.routing?.recordOutcome(true);
|
|
415
420
|
landed = progress(updated);
|
|
416
421
|
}
|
|
417
422
|
catch (e) {
|
|
@@ -433,10 +438,14 @@ export function runLoop(opts) {
|
|
|
433
438
|
reporter.storyDone({ id: story.id, title: story.title }, landed);
|
|
434
439
|
continue;
|
|
435
440
|
}
|
|
436
|
-
const result = opts
|
|
441
|
+
const result = runImplementation(opts, opts.targetDir, story, reporter);
|
|
437
442
|
iterations++;
|
|
438
443
|
if (result.tokens)
|
|
439
444
|
reporter.addTokens(result.tokens);
|
|
445
|
+
if (result.routing?.blocked) {
|
|
446
|
+
reporter.blocked(result.summary);
|
|
447
|
+
return { status: "blocked", iterations, reason: result.summary, finalProgress: progress(stories) };
|
|
448
|
+
}
|
|
440
449
|
let decision;
|
|
441
450
|
try {
|
|
442
451
|
decision = consumeDecisionRequest(opts.targetDir, opts.targetDir, story.id);
|
|
@@ -570,3 +579,35 @@ export function runLoop(opts) {
|
|
|
570
579
|
reporter.storyDone({ id: story.id, title: story.title }, progress(updated));
|
|
571
580
|
}
|
|
572
581
|
}
|
|
582
|
+
function runImplementation(opts, dir, story, reporter) {
|
|
583
|
+
let feedback;
|
|
584
|
+
for (let attempt = 0;; attempt++) {
|
|
585
|
+
const result = opts.runner({ targetDir: dir, story, feedback });
|
|
586
|
+
if (!result.routing?.canRetry || result.routing.blocked || attempt >= 7)
|
|
587
|
+
return result;
|
|
588
|
+
if (["decision-request.yaml", "ambiguity.md", "loop.pause"].some(name => existsSync(join(dir, ".yoke", name))) || existsSync(pauseFilePath(opts.targetDir)))
|
|
589
|
+
return result;
|
|
590
|
+
const protection = acceptanceProtectionProblem(dir, opts.targetDir);
|
|
591
|
+
if (protection)
|
|
592
|
+
return result;
|
|
593
|
+
const criteria = runCriterionGates(opts, dir, story);
|
|
594
|
+
const gates = [opts.verify, opts.design, opts.perf, opts.audit].filter((gate) => Boolean(gate));
|
|
595
|
+
let verdict = criteria;
|
|
596
|
+
if (verdict.passed)
|
|
597
|
+
for (const gate of gates) {
|
|
598
|
+
verdict = runGate(gate, dir, story.id);
|
|
599
|
+
if (!verdict.passed)
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
if (verdict.passed)
|
|
603
|
+
return result;
|
|
604
|
+
if (knownInfrastructureFailure(verdict.summary)) {
|
|
605
|
+
result.routing.recordOutcome(false, "infrastructure");
|
|
606
|
+
return result;
|
|
607
|
+
}
|
|
608
|
+
result.routing.recordOutcome(false);
|
|
609
|
+
if (result.tokens)
|
|
610
|
+
reporter.addTokens(result.tokens);
|
|
611
|
+
feedback = verdict.summary;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { existsSync, rmSync } from 'node:fs';
|
|
2
2
|
import { acceptanceProtectionProblem } from '../check/command.js';
|
|
3
|
+
import { makeAsyncAdaptiveRunner } from '../routing/router.js';
|
|
4
|
+
import { startProviderProcess } from '../agents/process.js';
|
|
5
|
+
import { runnerInvocation, isAgentAvailable } from './runner.js';
|
|
3
6
|
import { createDispatcher } from './dispatcher.js';
|
|
4
7
|
import { consumeDecisionRequest } from './decision.js';
|
|
5
8
|
import { pauseFilePath } from './loop.js';
|
|
@@ -45,6 +48,7 @@ export async function runParallelLoopCommand(input) {
|
|
|
45
48
|
} : {}),
|
|
46
49
|
pause,
|
|
47
50
|
onProgress: status => input.reporter.parallel?.(status),
|
|
51
|
+
onAccepted: story => input.reporter.accepted?.(story),
|
|
48
52
|
gates: {
|
|
49
53
|
verify: input.verify,
|
|
50
54
|
design: input.design,
|
|
@@ -202,7 +206,7 @@ function workerReporter(reporter, worker, candidate) {
|
|
|
202
206
|
else
|
|
203
207
|
reporter.quality(quality);
|
|
204
208
|
},
|
|
205
|
-
addTokens: usage => reporter.addTokens(usage),
|
|
209
|
+
addTokens: usage => reporter.addTokens({ ...usage, storyId: worker.story.id }),
|
|
206
210
|
};
|
|
207
211
|
}
|
|
208
212
|
function candidateReporter(reporter, worker, candidate, state, reason) {
|
|
@@ -247,29 +251,66 @@ function reportIntegrator(reporter, worker, phase) {
|
|
|
247
251
|
reporter.phase(phase);
|
|
248
252
|
}
|
|
249
253
|
function asyncRunner(input, provider, signal, workerId) {
|
|
250
|
-
|
|
254
|
+
if (input.routing) {
|
|
255
|
+
const routed = makeAsyncAdaptiveRunner({
|
|
256
|
+
parent: input.routing.strategy === 'capability' ? input.runnerAgent : provider.provider,
|
|
257
|
+
parentSelection: input.routing.strategy === 'capability' ? input.selection : { ...input.selection, model: provider.model, reasoningEffort: provider.reasoningEffort, nativeMultiAgent: false },
|
|
258
|
+
projectRoot: input.targetDir,
|
|
259
|
+
workers: input.routing.workers,
|
|
260
|
+
rules: input.routing.rules,
|
|
261
|
+
strategy: input.routing.strategy,
|
|
262
|
+
maxCandidates: input.routing.maxCandidates,
|
|
263
|
+
maxAttempts: input.routing.maxAttempts,
|
|
264
|
+
onDecision: (id, decision) => input.reporter.routingDecision?.(id, decision),
|
|
265
|
+
orchestratorSelection: input.routing.orchestrator,
|
|
266
|
+
isAvailable: input.isAvailable ?? isAgentAvailable,
|
|
267
|
+
captureRoute: async (agent, context, prompt, selection) => {
|
|
268
|
+
const result = await startProviderProcess(agent, runnerInvocation(agent, prompt, context.targetDir, true, 'read-only', selection), { idleTimeoutMs: input.idleMs, signal, workerId }).completion;
|
|
269
|
+
return { ...providerProcessResultToAgentResult(agent, context.story.id, result), output: result.stdout };
|
|
270
|
+
},
|
|
271
|
+
makeWorker: (agent, selection) => async (context) => {
|
|
272
|
+
if (signal?.aborted)
|
|
273
|
+
return { success: false, summary: 'Routing cancelled before worker start' };
|
|
274
|
+
input.reporter.parallelWorker?.({ story: context.story.id, storyTitle: context.story.title, provider: agent, selectedProvider: agent, selectedModel: selection.model, phase: 'implementing' });
|
|
275
|
+
const run = makeAsyncRunner(agent, { onAmbiguity: input.onAmbiguity, permissions: input.permissions, selection,
|
|
276
|
+
process: { idleTimeoutMs: input.idleMs, signal, workerId } });
|
|
277
|
+
return providerProcessResultToAgentResult(agent, context.story.id, await run(context).completion);
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
return context => context.story.agent && input.routing?.strategy !== 'capability'
|
|
281
|
+
? asyncRunner({ ...input, routing: undefined }, provider, signal, workerId)(context)
|
|
282
|
+
: routed(context);
|
|
283
|
+
}
|
|
284
|
+
const { model: _globalModel, reasoningEffort: _globalReasoningEffort, nativeMultiAgent: _globalNative, bare: globalBare, ...selection } = input.selection;
|
|
251
285
|
const runner = makeAsyncRunner(provider.provider, {
|
|
252
286
|
onAmbiguity: input.onAmbiguity,
|
|
253
287
|
permissions: input.permissions,
|
|
254
288
|
selection: {
|
|
255
289
|
...selection,
|
|
256
|
-
...(provider.provider
|
|
290
|
+
...(provider.provider !== 'gemini' && globalBare !== undefined ? { bare: globalBare } : {}),
|
|
291
|
+
nativeMultiAgent: false,
|
|
257
292
|
...(provider.model ? { model: provider.model } : {}),
|
|
258
293
|
...(provider.reasoningEffort ? { reasoningEffort: provider.reasoningEffort } : {}),
|
|
259
294
|
},
|
|
260
295
|
process: { idleTimeoutMs: input.idleMs, signal, workerId },
|
|
261
296
|
});
|
|
262
|
-
return async (context) =>
|
|
297
|
+
return async (context) => {
|
|
298
|
+
const started = Date.now();
|
|
299
|
+
const result = providerProcessResultToAgentResult(provider.provider, context.story.id, await runner(context).completion);
|
|
300
|
+
if (result.tokens)
|
|
301
|
+
result.tokens = { ...result.tokens, provider: provider.provider, role: 'worker', storyId: context.story.id, durationMs: Date.now() - started };
|
|
302
|
+
return result;
|
|
303
|
+
};
|
|
263
304
|
}
|
|
264
305
|
export function providerProcessResultToAgentResult(agent, storyId, result) {
|
|
265
306
|
const tokens = result.telemetry.tokens;
|
|
266
307
|
const telemetry = tokens ? { tokens } : {};
|
|
267
308
|
switch (result.kind) {
|
|
268
309
|
case 'succeeded': return { success: true, summary: `${agent} implemented ${storyId}`, ...telemetry };
|
|
269
|
-
case 'cancelled': return { success: false, summary: result.reason, ...telemetry };
|
|
270
|
-
case 'timed-out': return { success: false, summary: result.reason, ...telemetry };
|
|
271
|
-
case 'spawn-failed': return { success: false, summary: result.error, ...telemetry };
|
|
272
|
-
case 'failed': return { success: false, summary: `${agent} exited ${result.exitCode ?? 'without a code'}`, ...telemetry };
|
|
310
|
+
case 'cancelled': return { success: false, infrastructureFailure: true, summary: result.reason, ...telemetry };
|
|
311
|
+
case 'timed-out': return { success: false, infrastructureFailure: true, summary: result.reason, ...telemetry };
|
|
312
|
+
case 'spawn-failed': return { success: false, infrastructureFailure: true, summary: result.error, ...telemetry };
|
|
313
|
+
case 'failed': return { success: false, infrastructureFailure: true, summary: `${agent} exited ${result.exitCode ?? 'without a code'}`, ...telemetry };
|
|
273
314
|
default: return assertNever(result);
|
|
274
315
|
}
|
|
275
316
|
}
|
package/dist/loop/prd.js
CHANGED
|
@@ -4,6 +4,7 @@ import { parse, stringify } from 'yaml';
|
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { StoryQualityDeclarationSchema } from '../quality/types.js';
|
|
6
6
|
import { validWriteScope } from './scheduler.js';
|
|
7
|
+
import { AssessmentSchema } from '../routing/assessment.js';
|
|
7
8
|
export const AcceptanceCriterionSchema = z.object({
|
|
8
9
|
id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
9
10
|
text: z.string().min(1),
|
|
@@ -45,6 +46,7 @@ export const StorySchema = z.object({
|
|
|
45
46
|
/** Inbox request that created this story. Used for idempotent append-only intake. */
|
|
46
47
|
sourceChange: z.string().min(1).optional(),
|
|
47
48
|
quality: StoryQualityDeclarationSchema.optional(),
|
|
49
|
+
assessment: AssessmentSchema.optional(),
|
|
48
50
|
}).superRefine((story, ctx) => {
|
|
49
51
|
const structured = story.acceptance.filter(isAcceptanceCriterion);
|
|
50
52
|
const ids = structured.map(criterion => criterion.id);
|
package/dist/loop/reporter.js
CHANGED
|
@@ -93,6 +93,7 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
93
93
|
let tokens;
|
|
94
94
|
const runId = randomUUID();
|
|
95
95
|
let measuredCalls = 0;
|
|
96
|
+
let unknownCalls = 0;
|
|
96
97
|
let measuredCosts = 0;
|
|
97
98
|
let unmeasuredAttempts = 0;
|
|
98
99
|
const attempts = new Map();
|
|
@@ -143,8 +144,10 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
143
144
|
};
|
|
144
145
|
const persist = (status, logLabel, consoleLine) => {
|
|
145
146
|
const time = Date.parse(status.updatedAt);
|
|
146
|
-
if (logLabel === 'story-done')
|
|
147
|
+
if (logLabel === 'story-done') {
|
|
147
148
|
finishAttempt('serial', 'completed', time);
|
|
149
|
+
appendEvent(dir, { runId, timestamp: status.updatedAt, type: 'accepted', storyId: status.story });
|
|
150
|
+
}
|
|
148
151
|
else if (status.state !== 'running')
|
|
149
152
|
for (const key of [...attempts.keys()])
|
|
150
153
|
finishAttempt(key, status.state, time);
|
|
@@ -163,7 +166,7 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
163
166
|
if (key.startsWith('parallel:') && !workerKeys.has(key))
|
|
164
167
|
finishAttempt(key, 'worker-ended', time);
|
|
165
168
|
const withPercent = { ...status, percent: percentOf(status.progress) };
|
|
166
|
-
const measurement = { measuredCalls, unmeasuredAttempts, usageAvailable: measuredCalls > 0, costAvailable: measuredCosts === 0 ? 'unknown' : measuredCosts === measuredCalls && unmeasuredAttempts === 0 && tokens?.measurementComplete !== false && tokens?.costMeasurementComplete !== false ? 'measured' : 'partial' };
|
|
169
|
+
const measurement = { measuredCalls, unknownCalls, unmeasuredAttempts, usageAvailable: measuredCalls > 0, costAvailable: measuredCosts === 0 ? 'unknown' : measuredCosts === measuredCalls && unknownCalls === 0 && unmeasuredAttempts === 0 && tokens?.measurementComplete !== false && tokens?.costMeasurementComplete !== false ? 'measured' : 'partial' };
|
|
167
170
|
const next = { ...withPercent, ...(tokens ? { tokens: { ...tokens } } : {}), measurement };
|
|
168
171
|
current = next;
|
|
169
172
|
appendEvent(dir, { runId, timestamp: status.updatedAt, type: 'status', ...(status.story ? { storyId: status.story } : {}), data: { ...next } });
|
|
@@ -179,6 +182,17 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
179
182
|
emitConsole(consoleLine);
|
|
180
183
|
};
|
|
181
184
|
return {
|
|
185
|
+
routingDecision(storyId, decision) {
|
|
186
|
+
if (current)
|
|
187
|
+
persist({ ...current, routingDecisions: { ...current.routingDecisions, [storyId]: decision }, updatedAt: now().toISOString() }, "routing", ` · route ${storyId}: ${decision.profile} (${decision.reason})`);
|
|
188
|
+
},
|
|
189
|
+
execution(provider, requestedModel) {
|
|
190
|
+
if (current)
|
|
191
|
+
persist({ ...current, execution: { provider, requestedModel, startedAt: now().toISOString() }, updatedAt: now().toISOString() }, 'execution', ` · ${provider}/${requestedModel ?? 'provider-default'}`);
|
|
192
|
+
},
|
|
193
|
+
accepted(story) {
|
|
194
|
+
appendEvent(dir, { runId, timestamp: now().toISOString(), type: 'accepted', storyId: story.id });
|
|
195
|
+
},
|
|
182
196
|
storyStart(story, iteration, progress) {
|
|
183
197
|
const ts = now().toISOString();
|
|
184
198
|
finishAttempt('serial', 'superseded', Date.parse(ts));
|
|
@@ -237,10 +251,15 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
237
251
|
return previous
|
|
238
252
|
? {
|
|
239
253
|
...worker,
|
|
254
|
+
startedAt: previous.startedAt,
|
|
255
|
+
selectedProvider: previous.selectedProvider,
|
|
256
|
+
selectedModel: previous.selectedModel,
|
|
257
|
+
...(previous.model ? { model: previous.model } : {}),
|
|
258
|
+
provider: previous.provider,
|
|
240
259
|
...(previous.phase ? { phase: previous.phase } : {}),
|
|
241
260
|
...(previous.quality ? { quality: previous.quality } : {}),
|
|
242
261
|
}
|
|
243
|
-
: worker;
|
|
262
|
+
: { ...worker, startedAt: now().toISOString() };
|
|
244
263
|
});
|
|
245
264
|
const reportedCandidates = previousWorkers.filter(worker => worker.candidateId && !(workers ?? []).some(candidate => sameWorker(candidate, worker)));
|
|
246
265
|
const parallel = {
|
|
@@ -299,13 +318,14 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
299
318
|
if (value !== undefined && (!Number.isFinite(value) || value < 0))
|
|
300
319
|
delete usage[key];
|
|
301
320
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
321
|
+
const calls = usage.calls?.length ? usage.calls : [{ usageAvailable: usage.measurementComplete !== false, totalCostUsd: usage.totalCostUsd }];
|
|
322
|
+
measuredCalls += calls.filter(call => call.usageAvailable !== false).length;
|
|
323
|
+
unknownCalls += calls.filter(call => call.usageAvailable === false).length;
|
|
324
|
+
measuredCosts += calls.filter(call => call.totalCostUsd !== undefined && Number.isFinite(call.totalCostUsd) && call.totalCostUsd >= 0).length;
|
|
325
|
+
const attempt = usage.storyId ? [...attempts.values()].find(attempt => attempt.storyId === usage.storyId) : attempts.get('serial');
|
|
306
326
|
if (attempt)
|
|
307
|
-
attempt.usageAvailable =
|
|
308
|
-
appendEvent(dir, { runId, timestamp: now().toISOString(), type: 'tokens', ...(attempt ? { storyId: attempt.storyId, attemptId: attempt.id } : {}), data: { usageAvailable:
|
|
327
|
+
attempt.usageAvailable = usage.measurementComplete !== false;
|
|
328
|
+
appendEvent(dir, { runId, timestamp: now().toISOString(), type: 'tokens', ...(usage.storyId ? { storyId: usage.storyId } : {}), ...(usage.durationMs !== undefined ? { durationMs: usage.durationMs } : {}), ...(attempt ? { storyId: attempt.storyId, attemptId: attempt.id } : {}), data: { usageAvailable: usage.measurementComplete !== false, ...usage } });
|
|
309
329
|
const model = usage.model ?? tokens?.model;
|
|
310
330
|
const cachedInputTokens = (tokens?.cachedInputTokens ?? 0) + (usage.cachedInputTokens ?? 0);
|
|
311
331
|
const cacheWriteInputTokens = (tokens?.cacheWriteInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0);
|
package/dist/loop/run-command.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { roleSelection } from "../routing/capability.js";
|
|
1
2
|
import { join } from 'node:path';
|
|
2
3
|
import { existsSync } from 'node:fs';
|
|
3
4
|
import { loadConfig, saveConfig, defaultConfig, resolveOutputPolicy, resolveVerifyCommand } from '../retrofit/config.js';
|
|
@@ -97,9 +98,9 @@ export function resolveIdleMs(flagMinutes, configMinutes) {
|
|
|
97
98
|
return minutes > 0 ? minutes * 60_000 : 0;
|
|
98
99
|
}
|
|
99
100
|
export function runLoopCommand(targetDir, opts) {
|
|
100
|
-
|
|
101
|
+
let parallel = opts.parallel ?? 1;
|
|
101
102
|
const candidates = opts.candidates ?? 1;
|
|
102
|
-
if (opts.resumeWorktree && (
|
|
103
|
+
if (opts.resumeWorktree && (opts.isolate === false || parallel !== 1 || candidates !== 1)) {
|
|
103
104
|
console.error('--resume-worktree requires --isolate --parallel=1 --candidates=1');
|
|
104
105
|
return 2;
|
|
105
106
|
}
|
|
@@ -145,6 +146,18 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
145
146
|
console.error(`No PRD found at ${path}. Create one (see canon loop/prd.schema.md).`);
|
|
146
147
|
return 2;
|
|
147
148
|
}
|
|
149
|
+
const parallelSetting = opts.parallelAuto ? 'auto' : opts.parallel ?? config.loop.parallel ?? 'auto';
|
|
150
|
+
if (parallelSetting === 'auto') {
|
|
151
|
+
const pending = loadPrd(path).filter(story => !story.passes);
|
|
152
|
+
parallel = !opts.resumeWorktree && !config.actions?.length && pending.length > 1 && pending.every(story => story.writes?.length) ? 3 : 1;
|
|
153
|
+
}
|
|
154
|
+
else
|
|
155
|
+
parallel = parallelSetting;
|
|
156
|
+
const isolate = opts.isolate ?? config.loop.isolate ?? true;
|
|
157
|
+
if (opts.resumeWorktree && (!isolate || parallel !== 1)) {
|
|
158
|
+
console.error('--resume-worktree requires isolation and one worker');
|
|
159
|
+
return 2;
|
|
160
|
+
}
|
|
148
161
|
if (candidates > 1) {
|
|
149
162
|
const missingQuality = loadPrd(path).find(story => !story.passes && !story.quality);
|
|
150
163
|
if (missingQuality) {
|
|
@@ -229,11 +242,13 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
229
242
|
console.error('Quality candidate selection requires quality.critic.model (or legacy quality.criticModel) before any runner or worktree is started.');
|
|
230
243
|
return 2;
|
|
231
244
|
}
|
|
245
|
+
let executionReporter;
|
|
232
246
|
const quality = createQualityCommandHooks({
|
|
233
247
|
targetDir,
|
|
234
|
-
config,
|
|
248
|
+
config: opts.routing === false ? { ...config, routing: undefined } : config,
|
|
235
249
|
runnerAgent,
|
|
236
250
|
idleMs,
|
|
251
|
+
onUsage: usage => executionReporter?.addTokens(usage),
|
|
237
252
|
policy: qualityOverrides,
|
|
238
253
|
...(opts.qualityRuntime ? { runtime: opts.qualityRuntime } : {}),
|
|
239
254
|
});
|
|
@@ -246,23 +261,20 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
246
261
|
announce(`Quality: ${resolved.policy} · critic: ${criticAgent}${configuredCriticModel ? `/${configuredCriticModel}` : '/provider-default'} · repair: ${repairAgent}${config.quality?.repair?.model ?? config.quality?.repairModel ? `/${config.quality?.repair?.model ?? config.quality?.repairModel}` : '/provider-default'} · permissions: read-only critic/safe repair · budget: ${limit}`);
|
|
247
262
|
}
|
|
248
263
|
const permissions = opts.permissions ?? config.runner?.permissions ?? 'safe';
|
|
249
|
-
const
|
|
264
|
+
const routingRequested = opts.routing ?? config.routing?.enabled ?? true;
|
|
265
|
+
const routingEnabled = routingRequested && Boolean(config.routing?.workers.length);
|
|
250
266
|
const runnerSelection = {
|
|
251
267
|
model: config.runner?.model,
|
|
252
268
|
reasoningEffort: config.runner?.reasoningEffort,
|
|
253
269
|
bare: config.runner?.bare,
|
|
254
|
-
|
|
270
|
+
nativeMultiAgent: false,
|
|
255
271
|
};
|
|
256
|
-
if ((parallel > 1 || candidates > 1) && routingEnabled) {
|
|
257
|
-
console.error('Adaptive routing is not available with parallel workers or quality candidates. Run with --parallel=1 --candidates=1 or disable routing.');
|
|
258
|
-
return 2;
|
|
259
|
-
}
|
|
260
272
|
const parallelProviders = [{
|
|
261
273
|
provider: runnerAgent,
|
|
262
274
|
...(runnerSelection.model ? { model: runnerSelection.model } : {}),
|
|
263
275
|
...(runnerSelection.reasoningEffort ? { reasoningEffort: runnerSelection.reasoningEffort } : {}),
|
|
264
276
|
}];
|
|
265
|
-
const parallelAffinityProviders = (config.routing?.workers ?? []).map(worker => ({
|
|
277
|
+
const parallelAffinityProviders = config.routing?.strategy === 'capability' ? config.agents.map(agent => ({ provider: agent, ...(agent === runnerAgent ? runnerSelection : {}) })) : (config.routing?.workers ?? []).map(worker => ({
|
|
266
278
|
provider: worker.agent,
|
|
267
279
|
...(worker.model ? { model: worker.model } : {}),
|
|
268
280
|
...(worker.reasoningEffort ? { reasoningEffort: worker.reasoningEffort } : {}),
|
|
@@ -291,7 +303,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
291
303
|
}
|
|
292
304
|
}
|
|
293
305
|
const ambiguityPolicy = opts.decisionPolicy ?? opts.onAmbiguity ?? config.loop.decisionPolicy ?? config.loop.onAmbiguity ?? 'auto';
|
|
294
|
-
if (
|
|
306
|
+
if (opts.routing === true && (!config.routing || config.routing.workers.length === 0)) {
|
|
295
307
|
console.error('Adaptive routing was requested, but no worker profiles are configured. Run yoke setup . --routing or add routing.workers to .yoke/config.yaml.');
|
|
296
308
|
return 2;
|
|
297
309
|
}
|
|
@@ -321,6 +333,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
321
333
|
// Token reporting is part of the machine interface: in --json mode a claude
|
|
322
334
|
// runner switches to stream-json so cumulative usage rides on every status.
|
|
323
335
|
const runnerOpts = {
|
|
336
|
+
onStart: (agent, selection) => executionReporter?.execution?.(agent, selection.model),
|
|
324
337
|
tokenReport: opts.json === true,
|
|
325
338
|
onAmbiguity: ambiguityPolicy,
|
|
326
339
|
perfCommand: config.perf?.command,
|
|
@@ -330,12 +343,15 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
330
343
|
runner = routingEnabled && config.routing
|
|
331
344
|
? makeAdaptiveRunner({
|
|
332
345
|
parent: runnerAgent,
|
|
346
|
+
projectRoot: targetDir,
|
|
333
347
|
parentSelection: runnerOpts.selection,
|
|
334
348
|
orchestratorSelection: config.routing.orchestrator ?? runnerOpts.selection,
|
|
335
349
|
workers: config.routing.workers,
|
|
336
350
|
rules: config.routing.rules,
|
|
337
351
|
strategy: config.routing.strategy,
|
|
338
352
|
maxCandidates: config.routing.maxCandidates,
|
|
353
|
+
maxAttempts: config.routing.maxAttempts,
|
|
354
|
+
onDecision: (id, decision) => executionReporter?.routingDecision?.(id, decision),
|
|
339
355
|
idleTimeoutMs: idleMs,
|
|
340
356
|
permissions,
|
|
341
357
|
runnerOpts,
|
|
@@ -343,7 +359,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
343
359
|
})
|
|
344
360
|
: makeRunner(runnerAgent, idleMs, runnerOpts);
|
|
345
361
|
const announce = opts.json ? console.error : console.log;
|
|
346
|
-
announce(`Runner: ${runnerAgent} · permissions: ${permissions} · routing: ${routingEnabled ? 'on' : 'off'} · cwd: ${targetDir}`);
|
|
362
|
+
announce(`Runner: ${runnerAgent} · permissions: ${permissions} · routing: ${routingEnabled ? 'on' : routingRequested ? 'auto (parent; no worker profiles)' : 'off'} · workers: ${parallel} · isolation: ${isolate || parallel > 1 ? 'on' : 'off'} · cwd: ${targetDir}`);
|
|
347
363
|
}
|
|
348
364
|
if (config.actions?.length) {
|
|
349
365
|
if (parallel > 1 || candidates > 1) {
|
|
@@ -353,6 +369,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
353
369
|
runner = makeActionRunner(config.actions, runner);
|
|
354
370
|
}
|
|
355
371
|
let review = opts.reviewRunner;
|
|
372
|
+
let reviewProvider = 'unknown';
|
|
356
373
|
if (!review && (opts.review || opts.reviewer)) {
|
|
357
374
|
const reviewerAgent = opts.reviewer ?? ['codex', 'gemini', 'claude'].find(agent => agent !== runnerAgent && available(agent));
|
|
358
375
|
if (!reviewerAgent) {
|
|
@@ -362,6 +379,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
362
379
|
}
|
|
363
380
|
}
|
|
364
381
|
const resolvedReviewer = reviewerAgent ?? runnerAgent;
|
|
382
|
+
reviewProvider = resolvedReviewer;
|
|
365
383
|
if (resolvedReviewer === runnerAgent && !opts.allowSelfReview) {
|
|
366
384
|
console.error(`Reviewer "${resolvedReviewer}" is also the implementer. Pick another agent or pass --allow-self-review explicitly.`);
|
|
367
385
|
return 2;
|
|
@@ -370,7 +388,29 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
370
388
|
console.error(`Reviewer agent CLI "${resolvedReviewer}" was not found on PATH. Install it, or pick another with --reviewer=<claude|codex|gemini>.`);
|
|
371
389
|
return 2;
|
|
372
390
|
}
|
|
373
|
-
review =
|
|
391
|
+
review = context => {
|
|
392
|
+
const implementer = readStatus(targetDir)?.routingDecisions?.[context.story.id]?.provider ?? runnerAgent;
|
|
393
|
+
const selectedReviewer = !opts.reviewer && resolvedReviewer === implementer
|
|
394
|
+
? ["codex", "claude", "gemini"].find(agent => agent !== implementer && available(agent)) ?? resolvedReviewer : resolvedReviewer;
|
|
395
|
+
if (selectedReviewer === implementer && !opts.allowSelfReview)
|
|
396
|
+
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" } };
|
|
397
|
+
reviewProvider = selectedReviewer;
|
|
398
|
+
return makeReviewRunner(selectedReviewer, idleMs, undefined, routingEnabled ? roleSelection(targetDir, config, context.story, selectedReviewer, "reviewer") : undefined)(context);
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
if (review) {
|
|
402
|
+
const reviewRunner = review;
|
|
403
|
+
review = context => {
|
|
404
|
+
const started = Date.now();
|
|
405
|
+
let result;
|
|
406
|
+
try {
|
|
407
|
+
result = reviewRunner(context);
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
finally {
|
|
411
|
+
executionReporter?.addTokens({ inputTokens: 0, outputTokens: 0, measurementComplete: result?.tokens !== undefined, ...result?.tokens, provider: reviewProvider, role: 'reviewer', storyId: context.story.id, durationMs: Date.now() - started });
|
|
412
|
+
}
|
|
413
|
+
};
|
|
374
414
|
}
|
|
375
415
|
let lock;
|
|
376
416
|
try {
|
|
@@ -388,12 +428,13 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
388
428
|
console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
|
|
389
429
|
}
|
|
390
430
|
const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json });
|
|
431
|
+
executionReporter = reporter;
|
|
391
432
|
const buildResume = (storyId, requestId) => buildTrustedDecisionResumeState({
|
|
392
433
|
storyId,
|
|
393
434
|
requestId,
|
|
394
435
|
...(opts.maxIterations !== undefined ? { maxIterations: opts.maxIterations } : {}),
|
|
395
436
|
agent: runnerAgent,
|
|
396
|
-
isolate: parallel > 1 || candidates > 1 ||
|
|
437
|
+
isolate: parallel > 1 || candidates > 1 || isolate,
|
|
397
438
|
reviewer: opts.reviewer,
|
|
398
439
|
review: opts.review === true || opts.reviewRunner !== undefined,
|
|
399
440
|
allowSelfReview: opts.allowSelfReview ?? false,
|
|
@@ -440,6 +481,8 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
440
481
|
selection: runnerSelection,
|
|
441
482
|
providers: parallelProviders,
|
|
442
483
|
affinityProviders: parallelAffinityProviders,
|
|
484
|
+
routing: routingEnabled ? config.routing : undefined,
|
|
485
|
+
isAvailable: available,
|
|
443
486
|
onAmbiguity: ambiguityPolicy,
|
|
444
487
|
git: opts.git,
|
|
445
488
|
identity: commitIdentity,
|
|
@@ -473,7 +516,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
473
516
|
perf,
|
|
474
517
|
audit,
|
|
475
518
|
maxIterations,
|
|
476
|
-
isolate
|
|
519
|
+
isolate,
|
|
477
520
|
review,
|
|
478
521
|
reporter,
|
|
479
522
|
...(quality ?? {}),
|