@hecer/yoke 1.6.2 → 1.8.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 +42 -0
- package/README.md +61 -22
- package/canon/manifest.yaml +1 -1
- package/canon/tools/gemini-rtk-hook.mjs +25 -0
- package/dist/agents/contracts.js +2 -0
- package/dist/agents/process-streams.js +18 -4
- package/dist/agents/providers.js +32 -4
- package/dist/agents/telemetry.js +58 -10
- package/dist/check/command.js +114 -0
- package/dist/cli.js +124 -5
- package/dist/context/packet.js +32 -0
- package/dist/dashboard/analytics.js +123 -0
- package/dist/dashboard/page.js +34 -0
- package/dist/dashboard/panels.js +19 -0
- package/dist/dashboard/registry.js +68 -0
- package/dist/dashboard/server.js +177 -0
- package/dist/estimation/durations.js +20 -0
- package/dist/estimation/schedule.js +41 -0
- package/dist/execution/actions.js +24 -0
- package/dist/goals/command.js +191 -0
- package/dist/loop/dispatcher.js +19 -5
- package/dist/loop/git.js +5 -4
- package/dist/loop/loop.js +23 -3
- package/dist/loop/parallel-adapters.js +3 -2
- package/dist/loop/parallel-command.js +52 -4
- package/dist/loop/prd.js +3 -0
- package/dist/loop/recovery.js +51 -0
- package/dist/loop/reporter.js +105 -8
- package/dist/loop/run-command.js +62 -13
- package/dist/loop/runner.js +20 -16
- package/dist/loop/scheduler.js +38 -1
- package/dist/observability/events.js +72 -0
- package/dist/observability/history.js +80 -0
- package/dist/quality/candidate-comparison.js +1 -1
- package/dist/quality/command.js +16 -3
- package/dist/retrofit/config.js +11 -0
- package/dist/retrofit/gitignore.js +6 -0
- package/dist/retrofit/planners/gemini.js +11 -3
- package/dist/routing/router.js +50 -10
- package/dist/setup/command.js +3 -2
- package/dist/workspace/fingerprint.js +66 -0
- package/dist/workspace/state.js +20 -0
- package/docs/PRODUCT-DIRECTION-2026-09-05.md +199 -0
- package/docs/VERIFIED-PROJECTS-VALIDATION.md +29 -0
- package/docs/VERIFIED-PROJECTS.md +167 -0
- package/docs/superpowers/plans/2026-09-05-verified-projects.md +83 -0
- package/gemini-extension.json +1 -1
- package/hooks/bounded-gemini.mjs +70 -0
- package/package.json +1 -1
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { existsSync, rmSync } from 'node:fs';
|
|
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';
|
|
2
6
|
import { createDispatcher } from './dispatcher.js';
|
|
3
7
|
import { consumeDecisionRequest } from './decision.js';
|
|
4
8
|
import { pauseFilePath } from './loop.js';
|
|
@@ -7,6 +11,11 @@ import { loadPrd, progress } from './prd.js';
|
|
|
7
11
|
import { makeAsyncRunner } from './runner.js';
|
|
8
12
|
import { runStoryWorker } from './worker.js';
|
|
9
13
|
export async function runParallelLoopCommand(input) {
|
|
14
|
+
const originalVerify = input.verify;
|
|
15
|
+
input = { ...input, verify: path => {
|
|
16
|
+
const problem = acceptanceProtectionProblem(path, input.targetDir);
|
|
17
|
+
return problem ? { passed: false, summary: problem } : originalVerify(path);
|
|
18
|
+
} };
|
|
10
19
|
const adapters = makeParallelAdapters(input.targetDir, input.identity, input.git);
|
|
11
20
|
if (!adapters.git.isClean(input.targetDir)) {
|
|
12
21
|
input.reporter.blocked('target working tree is not clean');
|
|
@@ -39,6 +48,7 @@ export async function runParallelLoopCommand(input) {
|
|
|
39
48
|
} : {}),
|
|
40
49
|
pause,
|
|
41
50
|
onProgress: status => input.reporter.parallel?.(status),
|
|
51
|
+
onAccepted: story => input.reporter.accepted?.(story),
|
|
42
52
|
gates: {
|
|
43
53
|
verify: input.verify,
|
|
44
54
|
design: input.design,
|
|
@@ -196,7 +206,7 @@ function workerReporter(reporter, worker, candidate) {
|
|
|
196
206
|
else
|
|
197
207
|
reporter.quality(quality);
|
|
198
208
|
},
|
|
199
|
-
addTokens: usage => reporter.addTokens(usage),
|
|
209
|
+
addTokens: usage => reporter.addTokens({ ...usage, storyId: worker.story.id }),
|
|
200
210
|
};
|
|
201
211
|
}
|
|
202
212
|
function candidateReporter(reporter, worker, candidate, state, reason) {
|
|
@@ -241,18 +251,54 @@ function reportIntegrator(reporter, worker, phase) {
|
|
|
241
251
|
reporter.phase(phase);
|
|
242
252
|
}
|
|
243
253
|
function asyncRunner(input, provider, signal, workerId) {
|
|
244
|
-
|
|
254
|
+
if (input.routing) {
|
|
255
|
+
const routed = makeAsyncAdaptiveRunner({
|
|
256
|
+
parent: provider.provider,
|
|
257
|
+
parentSelection: { ...input.selection, model: provider.model, reasoningEffort: provider.reasoningEffort, ...(provider.provider === 'codex' ? { 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
|
+
orchestratorSelection: input.routing.orchestrator,
|
|
264
|
+
isAvailable: input.isAvailable ?? isAgentAvailable,
|
|
265
|
+
captureRoute: async (agent, context, prompt, selection) => {
|
|
266
|
+
const result = await startProviderProcess(agent, runnerInvocation(agent, prompt, context.targetDir, true, 'read-only', selection), { idleTimeoutMs: input.idleMs, signal, workerId }).completion;
|
|
267
|
+
return { ...providerProcessResultToAgentResult(agent, context.story.id, result), output: result.stdout };
|
|
268
|
+
},
|
|
269
|
+
makeWorker: (agent, selection) => async (context) => {
|
|
270
|
+
if (signal?.aborted)
|
|
271
|
+
return { success: false, summary: 'Routing cancelled before worker start' };
|
|
272
|
+
input.reporter.parallelWorker?.({ story: context.story.id, storyTitle: context.story.title, provider: agent, selectedProvider: agent, selectedModel: selection.model, phase: 'implementing' });
|
|
273
|
+
const run = makeAsyncRunner(agent, { onAmbiguity: input.onAmbiguity, permissions: input.permissions, selection,
|
|
274
|
+
process: { idleTimeoutMs: input.idleMs, signal, workerId } });
|
|
275
|
+
return providerProcessResultToAgentResult(agent, context.story.id, await run(context).completion);
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
return context => context.story.agent
|
|
279
|
+
? asyncRunner({ ...input, routing: undefined }, provider, signal, workerId)(context)
|
|
280
|
+
: routed(context);
|
|
281
|
+
}
|
|
282
|
+
const { model: _globalModel, reasoningEffort: _globalReasoningEffort, nativeMultiAgent: _globalNative, bare: globalBare, ...selection } = input.selection;
|
|
245
283
|
const runner = makeAsyncRunner(provider.provider, {
|
|
246
284
|
onAmbiguity: input.onAmbiguity,
|
|
247
285
|
permissions: input.permissions,
|
|
248
286
|
selection: {
|
|
249
287
|
...selection,
|
|
288
|
+
...(provider.provider !== 'gemini' && globalBare !== undefined ? { bare: globalBare } : {}),
|
|
289
|
+
nativeMultiAgent: false,
|
|
250
290
|
...(provider.model ? { model: provider.model } : {}),
|
|
251
291
|
...(provider.reasoningEffort ? { reasoningEffort: provider.reasoningEffort } : {}),
|
|
252
292
|
},
|
|
253
293
|
process: { idleTimeoutMs: input.idleMs, signal, workerId },
|
|
254
294
|
});
|
|
255
|
-
return async (context) =>
|
|
295
|
+
return async (context) => {
|
|
296
|
+
const started = Date.now();
|
|
297
|
+
const result = providerProcessResultToAgentResult(provider.provider, context.story.id, await runner(context).completion);
|
|
298
|
+
if (result.tokens)
|
|
299
|
+
result.tokens = { ...result.tokens, provider: provider.provider, role: 'worker', storyId: context.story.id, durationMs: Date.now() - started };
|
|
300
|
+
return result;
|
|
301
|
+
};
|
|
256
302
|
}
|
|
257
303
|
export function providerProcessResultToAgentResult(agent, storyId, result) {
|
|
258
304
|
const tokens = result.telemetry.tokens;
|
|
@@ -286,5 +332,7 @@ function integrationGate(input, path, story, reporter) {
|
|
|
286
332
|
return { passed: false, summary: quality.summary };
|
|
287
333
|
if (input.review)
|
|
288
334
|
reporter.phase('reviewing');
|
|
289
|
-
|
|
335
|
+
const verdict = reviewGate(input.review, path, story);
|
|
336
|
+
const protection = acceptanceProtectionProblem(path, input.targetDir);
|
|
337
|
+
return protection ? { passed: false, summary: protection } : verdict;
|
|
290
338
|
}
|
package/dist/loop/prd.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
3
3
|
import { parse, stringify } from 'yaml';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { StoryQualityDeclarationSchema } from '../quality/types.js';
|
|
6
|
+
import { validWriteScope } from './scheduler.js';
|
|
6
7
|
export const AcceptanceCriterionSchema = z.object({
|
|
7
8
|
id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
8
9
|
text: z.string().min(1),
|
|
@@ -38,6 +39,8 @@ export const StorySchema = z.object({
|
|
|
38
39
|
passes: z.boolean(),
|
|
39
40
|
needs: z.array(z.string().min(1)).optional(),
|
|
40
41
|
area: z.string().min(1).optional(),
|
|
42
|
+
/** Advisory relative file/directory scopes; not a filesystem permission boundary. */
|
|
43
|
+
writes: z.array(z.string().min(1).max(500).refine(validWriteScope, 'Write scopes must be relative non-glob paths without traversal')).max(100).optional(),
|
|
41
44
|
agent: z.enum(['claude', 'codex', 'gemini']).optional(),
|
|
42
45
|
/** Inbox request that created this story. Used for idempotent append-only intake. */
|
|
43
46
|
sourceChange: z.string().min(1).optional(),
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync as filesystemRealpathSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, relative, resolve, isAbsolute } from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
const Recovery = z.object({ version: z.literal(1), root: z.string(), worktree: z.string(), base: z.string(), prdHash: z.string() }).strict();
|
|
7
|
+
const digest = (file) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
8
|
+
const pathIdentity = (path) => process.platform === 'win32' ? path.toLowerCase() : path;
|
|
9
|
+
// Native handle-based resolution expands Windows 8.3 names. The JS realpath
|
|
10
|
+
// implementation can retain RUNNER~1 while Git reports runneradmin.
|
|
11
|
+
const realpathSync = filesystemRealpathSync.native;
|
|
12
|
+
/** Explicit recovery is valid only for the unchanged original target and PRD. */
|
|
13
|
+
export function prepareIsolatedWorktree(directory, worktree, resume) {
|
|
14
|
+
const root = realpathSync(directory);
|
|
15
|
+
// Resolve caller aliases (including Windows 8.3 temp paths) against the same
|
|
16
|
+
// canonical root before comparing them with Git's registered path spellings.
|
|
17
|
+
const wt = resolve(root, relative(resolve(directory), resolve(worktree)));
|
|
18
|
+
const expectedParent = join(root, '.yoke', 'worktrees');
|
|
19
|
+
if (pathIdentity(dirname(wt)) !== pathIdentity(expectedParent))
|
|
20
|
+
throw new Error('Recovery worktree path must be a direct project worktree');
|
|
21
|
+
const git = (args, cwd = root) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
22
|
+
const common = realpathSync(resolve(root, git(['rev-parse', '--git-common-dir'])));
|
|
23
|
+
const name = createHash('sha256').update(wt).digest('hex');
|
|
24
|
+
const record = join(common, 'yoke-recovery', `${name}.json`);
|
|
25
|
+
const base = git(['rev-parse', 'HEAD']);
|
|
26
|
+
const prdHash = digest(join(root, '.yoke', 'prd.yaml'));
|
|
27
|
+
if (existsSync(wt)) {
|
|
28
|
+
if (!resume)
|
|
29
|
+
throw new Error(`Retained worktree: ${wt}. Use --resume-worktree to continue or explicit loop cleanup to discard.`);
|
|
30
|
+
if (!existsSync(record))
|
|
31
|
+
throw new Error('No trusted worktree recovery record exists');
|
|
32
|
+
const saved = Recovery.parse(JSON.parse(readFileSync(record, 'utf8')));
|
|
33
|
+
if (pathIdentity(saved.root) !== pathIdentity(root) || pathIdentity(saved.worktree) !== pathIdentity(wt) || saved.base !== base || saved.prdHash !== prdHash)
|
|
34
|
+
throw new Error('Target or PRD changed; recovery is stale');
|
|
35
|
+
const actual = realpathSync(wt);
|
|
36
|
+
const rel = relative(realpathSync(expectedParent), actual);
|
|
37
|
+
if (isAbsolute(rel) || rel.startsWith('..') || rel.includes('/') || rel.includes('\\'))
|
|
38
|
+
throw new Error('Recovery worktree path escaped');
|
|
39
|
+
const registered = git(['worktree', 'list', '--porcelain']).split(/\r?\n/).filter(line => line.startsWith('worktree ')).map(line => realpathSync(resolve(line.slice(9))));
|
|
40
|
+
if (!registered.some(path => pathIdentity(path) === pathIdentity(actual)))
|
|
41
|
+
throw new Error(`Recovery directory is not a registered worktree: ${actual}; registered: ${registered.join(', ')}`);
|
|
42
|
+
if (pathIdentity(realpathSync(resolve(actual, git(['rev-parse', '--git-common-dir'], actual)))) !== pathIdentity(common))
|
|
43
|
+
throw new Error('Recovery belongs to a different repository');
|
|
44
|
+
git(['merge-base', '--is-ancestor', base, 'HEAD'], actual);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
mkdirSync(dirname(record), { recursive: true });
|
|
48
|
+
git(['worktree', 'add', '--detach', wt, base]);
|
|
49
|
+
// Record is outside the worker checkout and binds reuse to its source state.
|
|
50
|
+
writeFileSync(record, JSON.stringify({ version: 1, root, worktree: wt, base, prdHash }), { mode: 0o600 });
|
|
51
|
+
}
|
package/dist/loop/reporter.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, appendFileSync, statSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { appendEvent } from '../observability/events.js';
|
|
5
|
+
import { estimateDurations, validDuration } from '../estimation/durations.js';
|
|
3
6
|
export const LOG_CAP_BYTES = 256 * 1024;
|
|
4
7
|
// Append a line to .yoke/loop.log, keeping the file bounded: once it exceeds
|
|
5
8
|
// capBytes, truncate to the recent tail (starting at a line boundary) so the log
|
|
@@ -39,10 +42,12 @@ function durationsPath(dir) {
|
|
|
39
42
|
}
|
|
40
43
|
export function readDurations(dir) {
|
|
41
44
|
try {
|
|
45
|
+
if (statSync(durationsPath(dir)).size > 64 * 1024)
|
|
46
|
+
return [];
|
|
42
47
|
const arr = JSON.parse(readFileSync(durationsPath(dir), 'utf8'));
|
|
43
48
|
if (!Array.isArray(arr))
|
|
44
49
|
return [];
|
|
45
|
-
return arr.filter((d) => typeof d?.
|
|
50
|
+
return arr.filter((d) => typeof d?.storyId === 'string' && validDuration(d?.ms)).slice(-DURATION_HISTORY_CAP);
|
|
46
51
|
}
|
|
47
52
|
catch {
|
|
48
53
|
return [];
|
|
@@ -86,6 +91,43 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
86
91
|
sink(line); };
|
|
87
92
|
let current = null;
|
|
88
93
|
let tokens;
|
|
94
|
+
const runId = randomUUID();
|
|
95
|
+
let measuredCalls = 0;
|
|
96
|
+
let unknownCalls = 0;
|
|
97
|
+
let measuredCosts = 0;
|
|
98
|
+
let unmeasuredAttempts = 0;
|
|
99
|
+
const attempts = new Map();
|
|
100
|
+
const finishPhase = (attempt, time) => {
|
|
101
|
+
if (attempt.phase)
|
|
102
|
+
appendEvent(dir, { runId, timestamp: new Date(time).toISOString(), type: 'phase-ended', storyId: attempt.storyId, attemptId: attempt.id, phase: attempt.phase, durationMs: Math.max(0, time - attempt.phaseStarted) });
|
|
103
|
+
};
|
|
104
|
+
const finishAttempt = (key, outcome, time) => {
|
|
105
|
+
const attempt = attempts.get(key);
|
|
106
|
+
if (!attempt)
|
|
107
|
+
return;
|
|
108
|
+
if (!attempt.usageAvailable)
|
|
109
|
+
unmeasuredAttempts++;
|
|
110
|
+
finishPhase(attempt, time);
|
|
111
|
+
const durationMs = Math.max(0, time - attempt.started);
|
|
112
|
+
appendEvent(dir, { runId, timestamp: new Date(time).toISOString(), type: 'attempt-ended', storyId: attempt.storyId, attemptId: attempt.id, durationMs, outcome, data: { usageAvailable: attempt.usageAvailable,
|
|
113
|
+
...(attempt.prediction ? { prediction: attempt.prediction, ...(outcome === 'completed' ? { errorMs: durationMs - attempt.prediction.expectedMs, withinObservedRange: durationMs >= attempt.prediction.lowerMs && durationMs <= attempt.prediction.upperMs } : {}) } : {}),
|
|
114
|
+
} });
|
|
115
|
+
attempts.delete(key);
|
|
116
|
+
};
|
|
117
|
+
const track = (key, storyId, phase, time) => {
|
|
118
|
+
const attempt = attempts.get(key);
|
|
119
|
+
if (!attempt) {
|
|
120
|
+
const estimate = estimateDurations(history, runDurations);
|
|
121
|
+
attempts.set(key, { id: randomUUID(), storyId, started: time, phase, phaseStarted: time, usageAvailable: false,
|
|
122
|
+
...(estimate ? { prediction: { expectedMs: estimate.typicalMs, lowerMs: estimate.lowerMs, upperMs: estimate.upperMs, sampleCount: estimate.sampleCount } } : {}),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
else if (attempt.phase !== phase) {
|
|
126
|
+
finishPhase(attempt, time);
|
|
127
|
+
attempt.phase = phase;
|
|
128
|
+
attempt.phaseStarted = time;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
89
131
|
// ETA source: durations of stories completed in THIS run beat the persisted
|
|
90
132
|
// history of earlier runs (current velocity over old experience).
|
|
91
133
|
const history = readDurations(dir).map(h => h.ms);
|
|
@@ -93,17 +135,41 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
93
135
|
let storyStartedAt = null;
|
|
94
136
|
const percentOf = (p) => (p.total > 0 ? Math.round((p.passed / p.total) * 100) : 0);
|
|
95
137
|
const etaFor = (p) => {
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
138
|
+
const estimate = estimateDurations(history, runDurations);
|
|
139
|
+
if (!estimate)
|
|
98
140
|
return undefined;
|
|
99
|
-
const avg =
|
|
141
|
+
const avg = estimate.typicalMs;
|
|
100
142
|
const remainingStories = Math.max(0, p.total - p.passed);
|
|
101
|
-
return { avgStoryMs: Math.round(avg), remainingStories, etaMs: Math.round(avg * remainingStories) };
|
|
143
|
+
return { avgStoryMs: Math.round(avg), remainingStories, etaMs: Math.round(avg * remainingStories), lowerMs: Math.round(estimate.lowerMs * remainingStories), upperMs: Math.round(estimate.upperMs * remainingStories), sampleCount: estimate.sampleCount, confidence: estimate.confidence };
|
|
102
144
|
};
|
|
103
145
|
const persist = (status, logLabel, consoleLine) => {
|
|
146
|
+
const time = Date.parse(status.updatedAt);
|
|
147
|
+
if (logLabel === 'story-done') {
|
|
148
|
+
finishAttempt('serial', 'completed', time);
|
|
149
|
+
appendEvent(dir, { runId, timestamp: status.updatedAt, type: 'accepted', storyId: status.story });
|
|
150
|
+
}
|
|
151
|
+
else if (status.state !== 'running')
|
|
152
|
+
for (const key of [...attempts.keys()])
|
|
153
|
+
finishAttempt(key, status.state, time);
|
|
154
|
+
else if (status.story && status.phase)
|
|
155
|
+
track('serial', status.story, status.phase, time);
|
|
156
|
+
const workerKeys = new Set();
|
|
157
|
+
for (const worker of [...(status.parallel?.workers ?? []), ...(status.parallel?.integrator ? [status.parallel.integrator] : [])]) {
|
|
158
|
+
const key = `parallel:${worker.story}:${worker.candidateId ?? ''}:${worker.worktree ?? ''}`;
|
|
159
|
+
workerKeys.add(key);
|
|
160
|
+
if (worker.lifecycle === 'removed')
|
|
161
|
+
finishAttempt(key, 'removed', time);
|
|
162
|
+
else
|
|
163
|
+
track(key, worker.story, worker.phase ?? worker.lifecycle, time);
|
|
164
|
+
}
|
|
165
|
+
for (const key of [...attempts.keys()])
|
|
166
|
+
if (key.startsWith('parallel:') && !workerKeys.has(key))
|
|
167
|
+
finishAttempt(key, 'worker-ended', time);
|
|
104
168
|
const withPercent = { ...status, percent: percentOf(status.progress) };
|
|
105
|
-
const
|
|
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' };
|
|
170
|
+
const next = { ...withPercent, ...(tokens ? { tokens: { ...tokens } } : {}), measurement };
|
|
106
171
|
current = next;
|
|
172
|
+
appendEvent(dir, { runId, timestamp: status.updatedAt, type: 'status', ...(status.story ? { storyId: status.story } : {}), data: { ...next } });
|
|
107
173
|
try {
|
|
108
174
|
writeStatus(dir, next);
|
|
109
175
|
appendLog(dir, `${next.updatedAt} ${logLabel} ${next.story ?? '-'} ${next.reason ?? ''}`.trimEnd());
|
|
@@ -116,8 +182,16 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
116
182
|
emitConsole(consoleLine);
|
|
117
183
|
};
|
|
118
184
|
return {
|
|
185
|
+
execution(provider, requestedModel) {
|
|
186
|
+
if (current)
|
|
187
|
+
persist({ ...current, execution: { provider, requestedModel, startedAt: now().toISOString() }, updatedAt: now().toISOString() }, 'execution', ` · ${provider}/${requestedModel ?? 'provider-default'}`);
|
|
188
|
+
},
|
|
189
|
+
accepted(story) {
|
|
190
|
+
appendEvent(dir, { runId, timestamp: now().toISOString(), type: 'accepted', storyId: story.id });
|
|
191
|
+
},
|
|
119
192
|
storyStart(story, iteration, progress) {
|
|
120
193
|
const ts = now().toISOString();
|
|
194
|
+
finishAttempt('serial', 'superseded', Date.parse(ts));
|
|
121
195
|
storyStartedAt = now().getTime();
|
|
122
196
|
const eta = etaFor(progress);
|
|
123
197
|
const hint = eta ? ` · ~${fmtDuration(eta.etaMs)} left (Ø ${fmtDuration(eta.avgStoryMs)}/story)` : '';
|
|
@@ -128,7 +202,7 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
128
202
|
const t = now().getTime();
|
|
129
203
|
const ms = storyStartedAt !== null ? Math.max(0, t - storyStartedAt) : undefined;
|
|
130
204
|
storyStartedAt = null;
|
|
131
|
-
if (ms
|
|
205
|
+
if (validDuration(ms)) {
|
|
132
206
|
runDurations.push(ms);
|
|
133
207
|
appendDuration(dir, { storyId: story.id, ms });
|
|
134
208
|
}
|
|
@@ -173,10 +247,15 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
173
247
|
return previous
|
|
174
248
|
? {
|
|
175
249
|
...worker,
|
|
250
|
+
startedAt: previous.startedAt,
|
|
251
|
+
selectedProvider: previous.selectedProvider,
|
|
252
|
+
selectedModel: previous.selectedModel,
|
|
253
|
+
...(previous.model ? { model: previous.model } : {}),
|
|
254
|
+
provider: previous.provider,
|
|
176
255
|
...(previous.phase ? { phase: previous.phase } : {}),
|
|
177
256
|
...(previous.quality ? { quality: previous.quality } : {}),
|
|
178
257
|
}
|
|
179
|
-
: worker;
|
|
258
|
+
: { ...worker, startedAt: now().toISOString() };
|
|
180
259
|
});
|
|
181
260
|
const reportedCandidates = previousWorkers.filter(worker => worker.candidateId && !(workers ?? []).some(candidate => sameWorker(candidate, worker)));
|
|
182
261
|
const parallel = {
|
|
@@ -227,12 +306,30 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
227
306
|
persist({ ...base, parallel: withoutIntegrator, updatedAt: now().toISOString() }, 'parallel-integrator', ' · integration complete');
|
|
228
307
|
},
|
|
229
308
|
addTokens(usage) {
|
|
309
|
+
if (![usage.inputTokens, usage.outputTokens].every(value => Number.isFinite(value) && value >= 0))
|
|
310
|
+
return;
|
|
311
|
+
usage = { ...usage };
|
|
312
|
+
for (const key of ['cachedInputTokens', 'cacheWriteInputTokens', 'reasoningOutputTokens', 'totalCostUsd']) {
|
|
313
|
+
const value = usage[key];
|
|
314
|
+
if (value !== undefined && (!Number.isFinite(value) || value < 0))
|
|
315
|
+
delete usage[key];
|
|
316
|
+
}
|
|
317
|
+
const calls = usage.calls?.length ? usage.calls : [{ usageAvailable: usage.measurementComplete !== false, totalCostUsd: usage.totalCostUsd }];
|
|
318
|
+
measuredCalls += calls.filter(call => call.usageAvailable !== false).length;
|
|
319
|
+
unknownCalls += calls.filter(call => call.usageAvailable === false).length;
|
|
320
|
+
measuredCosts += calls.filter(call => call.totalCostUsd !== undefined && Number.isFinite(call.totalCostUsd) && call.totalCostUsd >= 0).length;
|
|
321
|
+
const attempt = usage.storyId ? [...attempts.values()].find(attempt => attempt.storyId === usage.storyId) : attempts.get('serial');
|
|
322
|
+
if (attempt)
|
|
323
|
+
attempt.usageAvailable = usage.measurementComplete !== false;
|
|
324
|
+
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 } });
|
|
230
325
|
const model = usage.model ?? tokens?.model;
|
|
231
326
|
const cachedInputTokens = (tokens?.cachedInputTokens ?? 0) + (usage.cachedInputTokens ?? 0);
|
|
232
327
|
const cacheWriteInputTokens = (tokens?.cacheWriteInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0);
|
|
233
328
|
const reasoningOutputTokens = (tokens?.reasoningOutputTokens ?? 0) + (usage.reasoningOutputTokens ?? 0);
|
|
234
329
|
const totalCostUsd = (tokens?.totalCostUsd ?? 0) + (usage.totalCostUsd ?? 0);
|
|
235
330
|
tokens = {
|
|
331
|
+
...((tokens?.measurementComplete !== undefined || usage.measurementComplete !== undefined) ? { measurementComplete: tokens?.measurementComplete !== false && usage.measurementComplete !== false } : {}),
|
|
332
|
+
...((tokens?.costMeasurementComplete !== undefined || usage.costMeasurementComplete !== undefined) ? { costMeasurementComplete: tokens?.costMeasurementComplete !== false && usage.costMeasurementComplete !== false } : {}),
|
|
236
333
|
inputTokens: (tokens?.inputTokens ?? 0) + usage.inputTokens,
|
|
237
334
|
...((tokens?.cachedInputTokens !== undefined || usage.cachedInputTokens !== undefined) ? { cachedInputTokens } : {}),
|
|
238
335
|
...((tokens?.cacheWriteInputTokens !== undefined || usage.cacheWriteInputTokens !== undefined) ? { cacheWriteInputTokens } : {}),
|
package/dist/loop/run-command.js
CHANGED
|
@@ -14,12 +14,14 @@ import { runAudit } from '../audit/command.js';
|
|
|
14
14
|
import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
|
|
15
15
|
import { buildTrustedDecisionResumeState, clearDecisionResume, decisionProcessingExists, decisionRequestId, formatPendingDecision, readPendingDecision, writeDecisionResume, } from './decision.js';
|
|
16
16
|
import { makeAdaptiveRunner } from '../routing/router.js';
|
|
17
|
+
import { makeActionRunner } from '../execution/actions.js';
|
|
17
18
|
import { runChangeApply } from '../change/inbox.js';
|
|
18
19
|
import { createQualityCommandHooks } from '../quality/command.js';
|
|
19
20
|
import { resolveQualityPolicy } from '../quality/types.js';
|
|
20
21
|
import { runParallelLoopCommand } from './parallel-command.js';
|
|
21
22
|
import { detectUiProject } from '../retrofit/ui-detect.js';
|
|
22
23
|
import { designVerifier } from '../scan/gate.js';
|
|
24
|
+
import { prepareIsolatedWorktree } from './recovery.js';
|
|
23
25
|
export const DEFAULT_IDLE_MINUTES = 20;
|
|
24
26
|
const STALE_MINUTES = 20; // a running status older than this likely means the loop died
|
|
25
27
|
export function relativeTime(fromIso, now) {
|
|
@@ -95,8 +97,12 @@ export function resolveIdleMs(flagMinutes, configMinutes) {
|
|
|
95
97
|
return minutes > 0 ? minutes * 60_000 : 0;
|
|
96
98
|
}
|
|
97
99
|
export function runLoopCommand(targetDir, opts) {
|
|
98
|
-
|
|
100
|
+
let parallel = opts.parallel ?? 1;
|
|
99
101
|
const candidates = opts.candidates ?? 1;
|
|
102
|
+
if (opts.resumeWorktree && (opts.isolate === false || parallel !== 1 || candidates !== 1)) {
|
|
103
|
+
console.error('--resume-worktree requires --isolate --parallel=1 --candidates=1');
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
100
106
|
if (!Number.isInteger(parallel) || parallel < 1) {
|
|
101
107
|
console.error('--parallel must be a positive integer');
|
|
102
108
|
return 2;
|
|
@@ -139,6 +145,18 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
139
145
|
console.error(`No PRD found at ${path}. Create one (see canon loop/prd.schema.md).`);
|
|
140
146
|
return 2;
|
|
141
147
|
}
|
|
148
|
+
const parallelSetting = opts.parallelAuto ? 'auto' : opts.parallel ?? config.loop.parallel ?? 'auto';
|
|
149
|
+
if (parallelSetting === 'auto') {
|
|
150
|
+
const pending = loadPrd(path).filter(story => !story.passes);
|
|
151
|
+
parallel = !opts.resumeWorktree && !config.actions?.length && pending.length > 1 && pending.every(story => story.writes?.length) ? 3 : 1;
|
|
152
|
+
}
|
|
153
|
+
else
|
|
154
|
+
parallel = parallelSetting;
|
|
155
|
+
const isolate = opts.isolate ?? config.loop.isolate ?? true;
|
|
156
|
+
if (opts.resumeWorktree && (!isolate || parallel !== 1)) {
|
|
157
|
+
console.error('--resume-worktree requires isolation and one worker');
|
|
158
|
+
return 2;
|
|
159
|
+
}
|
|
142
160
|
if (candidates > 1) {
|
|
143
161
|
const missingQuality = loadPrd(path).find(story => !story.passes && !story.quality);
|
|
144
162
|
if (missingQuality) {
|
|
@@ -177,7 +195,10 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
177
195
|
maybeAutoUpgrade(config.update?.auto);
|
|
178
196
|
const available = opts.isAvailable ?? isAgentAvailable;
|
|
179
197
|
const runnerAgent = resolveRunnerAgent(config, opts.agent, detectHostAgent());
|
|
180
|
-
const git = opts.git ??
|
|
198
|
+
const git = opts.git ?? {
|
|
199
|
+
...realGitOps,
|
|
200
|
+
addWorktree: (repo, worktree) => prepareIsolatedWorktree(repo, worktree, opts.resumeWorktree === true),
|
|
201
|
+
};
|
|
181
202
|
let commitIdentity = opts.commitIdentity;
|
|
182
203
|
if (!commitIdentity && !opts.git) {
|
|
183
204
|
try {
|
|
@@ -220,11 +241,13 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
220
241
|
console.error('Quality candidate selection requires quality.critic.model (or legacy quality.criticModel) before any runner or worktree is started.');
|
|
221
242
|
return 2;
|
|
222
243
|
}
|
|
244
|
+
let executionReporter;
|
|
223
245
|
const quality = createQualityCommandHooks({
|
|
224
246
|
targetDir,
|
|
225
247
|
config,
|
|
226
248
|
runnerAgent,
|
|
227
249
|
idleMs,
|
|
250
|
+
onUsage: usage => executionReporter?.addTokens(usage),
|
|
228
251
|
policy: qualityOverrides,
|
|
229
252
|
...(opts.qualityRuntime ? { runtime: opts.qualityRuntime } : {}),
|
|
230
253
|
});
|
|
@@ -237,17 +260,14 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
237
260
|
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}`);
|
|
238
261
|
}
|
|
239
262
|
const permissions = opts.permissions ?? config.runner?.permissions ?? 'safe';
|
|
240
|
-
const
|
|
263
|
+
const routingRequested = opts.routing ?? config.routing?.enabled ?? true;
|
|
264
|
+
const routingEnabled = routingRequested && Boolean(config.routing?.workers.length);
|
|
241
265
|
const runnerSelection = {
|
|
242
266
|
model: config.runner?.model,
|
|
243
267
|
reasoningEffort: config.runner?.reasoningEffort,
|
|
244
268
|
bare: config.runner?.bare,
|
|
245
|
-
|
|
269
|
+
nativeMultiAgent: false,
|
|
246
270
|
};
|
|
247
|
-
if ((parallel > 1 || candidates > 1) && routingEnabled) {
|
|
248
|
-
console.error('Adaptive routing is not available with parallel workers or quality candidates. Run with --parallel=1 --candidates=1 or disable routing.');
|
|
249
|
-
return 2;
|
|
250
|
-
}
|
|
251
271
|
const parallelProviders = [{
|
|
252
272
|
provider: runnerAgent,
|
|
253
273
|
...(runnerSelection.model ? { model: runnerSelection.model } : {}),
|
|
@@ -282,7 +302,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
282
302
|
}
|
|
283
303
|
}
|
|
284
304
|
const ambiguityPolicy = opts.decisionPolicy ?? opts.onAmbiguity ?? config.loop.decisionPolicy ?? config.loop.onAmbiguity ?? 'auto';
|
|
285
|
-
if (
|
|
305
|
+
if (opts.routing === true && (!config.routing || config.routing.workers.length === 0)) {
|
|
286
306
|
console.error('Adaptive routing was requested, but no worker profiles are configured. Run yoke setup . --routing or add routing.workers to .yoke/config.yaml.');
|
|
287
307
|
return 2;
|
|
288
308
|
}
|
|
@@ -303,7 +323,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
303
323
|
if (!runner) {
|
|
304
324
|
const requiredProviders = parallel > 1 || candidates > 1
|
|
305
325
|
? [...new Set(loadPrd(path).filter(story => !story.passes).map(story => story.agent ?? runnerAgent))]
|
|
306
|
-
: [runnerAgent];
|
|
326
|
+
: loadPrd(path).filter(story => !story.passes).every(story => config.actions?.some(action => action.storyId === story.id)) ? [] : [runnerAgent];
|
|
307
327
|
const unavailableProvider = requiredProviders.find(agent => !available(agent));
|
|
308
328
|
if (unavailableProvider) {
|
|
309
329
|
console.error(`Agent CLI "${unavailableProvider}" was not found on PATH. Install it, or pick another with --runner=<claude|codex|gemini>.`);
|
|
@@ -312,6 +332,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
312
332
|
// Token reporting is part of the machine interface: in --json mode a claude
|
|
313
333
|
// runner switches to stream-json so cumulative usage rides on every status.
|
|
314
334
|
const runnerOpts = {
|
|
335
|
+
onStart: (agent, selection) => executionReporter?.execution?.(agent, selection.model),
|
|
315
336
|
tokenReport: opts.json === true,
|
|
316
337
|
onAmbiguity: ambiguityPolicy,
|
|
317
338
|
perfCommand: config.perf?.command,
|
|
@@ -321,9 +342,11 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
321
342
|
runner = routingEnabled && config.routing
|
|
322
343
|
? makeAdaptiveRunner({
|
|
323
344
|
parent: runnerAgent,
|
|
345
|
+
projectRoot: targetDir,
|
|
324
346
|
parentSelection: runnerOpts.selection,
|
|
325
347
|
orchestratorSelection: config.routing.orchestrator ?? runnerOpts.selection,
|
|
326
348
|
workers: config.routing.workers,
|
|
349
|
+
rules: config.routing.rules,
|
|
327
350
|
strategy: config.routing.strategy,
|
|
328
351
|
maxCandidates: config.routing.maxCandidates,
|
|
329
352
|
idleTimeoutMs: idleMs,
|
|
@@ -333,9 +356,17 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
333
356
|
})
|
|
334
357
|
: makeRunner(runnerAgent, idleMs, runnerOpts);
|
|
335
358
|
const announce = opts.json ? console.error : console.log;
|
|
336
|
-
announce(`Runner: ${runnerAgent} · permissions: ${permissions} · routing: ${routingEnabled ? 'on' : 'off'} · cwd: ${targetDir}`);
|
|
359
|
+
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}`);
|
|
360
|
+
}
|
|
361
|
+
if (config.actions?.length) {
|
|
362
|
+
if (parallel > 1 || candidates > 1) {
|
|
363
|
+
console.error('Configured tool actions currently require --parallel=1 --candidates=1');
|
|
364
|
+
return 2;
|
|
365
|
+
}
|
|
366
|
+
runner = makeActionRunner(config.actions, runner);
|
|
337
367
|
}
|
|
338
368
|
let review = opts.reviewRunner;
|
|
369
|
+
let reviewProvider = 'unknown';
|
|
339
370
|
if (!review && (opts.review || opts.reviewer)) {
|
|
340
371
|
const reviewerAgent = opts.reviewer ?? ['codex', 'gemini', 'claude'].find(agent => agent !== runnerAgent && available(agent));
|
|
341
372
|
if (!reviewerAgent) {
|
|
@@ -345,6 +376,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
345
376
|
}
|
|
346
377
|
}
|
|
347
378
|
const resolvedReviewer = reviewerAgent ?? runnerAgent;
|
|
379
|
+
reviewProvider = resolvedReviewer;
|
|
348
380
|
if (resolvedReviewer === runnerAgent && !opts.allowSelfReview) {
|
|
349
381
|
console.error(`Reviewer "${resolvedReviewer}" is also the implementer. Pick another agent or pass --allow-self-review explicitly.`);
|
|
350
382
|
return 2;
|
|
@@ -355,6 +387,20 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
355
387
|
}
|
|
356
388
|
review = makeReviewRunner(resolvedReviewer, idleMs);
|
|
357
389
|
}
|
|
390
|
+
if (review) {
|
|
391
|
+
const reviewRunner = review;
|
|
392
|
+
review = context => {
|
|
393
|
+
const started = Date.now();
|
|
394
|
+
let result;
|
|
395
|
+
try {
|
|
396
|
+
result = reviewRunner(context);
|
|
397
|
+
return result;
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
executionReporter?.addTokens({ inputTokens: 0, outputTokens: 0, measurementComplete: result?.tokens !== undefined, ...result?.tokens, provider: reviewProvider, role: 'reviewer', storyId: context.story.id, durationMs: Date.now() - started });
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
}
|
|
358
404
|
let lock;
|
|
359
405
|
try {
|
|
360
406
|
lock = acquireLock(targetDir);
|
|
@@ -371,12 +417,13 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
371
417
|
console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
|
|
372
418
|
}
|
|
373
419
|
const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json });
|
|
420
|
+
executionReporter = reporter;
|
|
374
421
|
const buildResume = (storyId, requestId) => buildTrustedDecisionResumeState({
|
|
375
422
|
storyId,
|
|
376
423
|
requestId,
|
|
377
424
|
...(opts.maxIterations !== undefined ? { maxIterations: opts.maxIterations } : {}),
|
|
378
425
|
agent: runnerAgent,
|
|
379
|
-
isolate: parallel > 1 || candidates > 1 ||
|
|
426
|
+
isolate: parallel > 1 || candidates > 1 || isolate,
|
|
380
427
|
reviewer: opts.reviewer,
|
|
381
428
|
review: opts.review === true || opts.reviewRunner !== undefined,
|
|
382
429
|
allowSelfReview: opts.allowSelfReview ?? false,
|
|
@@ -423,6 +470,8 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
423
470
|
selection: runnerSelection,
|
|
424
471
|
providers: parallelProviders,
|
|
425
472
|
affinityProviders: parallelAffinityProviders,
|
|
473
|
+
routing: routingEnabled ? config.routing : undefined,
|
|
474
|
+
isAvailable: available,
|
|
426
475
|
onAmbiguity: ambiguityPolicy,
|
|
427
476
|
git: opts.git,
|
|
428
477
|
identity: commitIdentity,
|
|
@@ -456,7 +505,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
456
505
|
perf,
|
|
457
506
|
audit,
|
|
458
507
|
maxIterations,
|
|
459
|
-
isolate
|
|
508
|
+
isolate,
|
|
460
509
|
review,
|
|
461
510
|
reporter,
|
|
462
511
|
...(quality ?? {}),
|