@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.
Files changed (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +42 -0
  4. package/README.md +61 -22
  5. package/canon/manifest.yaml +1 -1
  6. package/canon/tools/gemini-rtk-hook.mjs +25 -0
  7. package/dist/agents/contracts.js +2 -0
  8. package/dist/agents/process-streams.js +18 -4
  9. package/dist/agents/providers.js +32 -4
  10. package/dist/agents/telemetry.js +58 -10
  11. package/dist/check/command.js +114 -0
  12. package/dist/cli.js +124 -5
  13. package/dist/context/packet.js +32 -0
  14. package/dist/dashboard/analytics.js +123 -0
  15. package/dist/dashboard/page.js +34 -0
  16. package/dist/dashboard/panels.js +19 -0
  17. package/dist/dashboard/registry.js +68 -0
  18. package/dist/dashboard/server.js +177 -0
  19. package/dist/estimation/durations.js +20 -0
  20. package/dist/estimation/schedule.js +41 -0
  21. package/dist/execution/actions.js +24 -0
  22. package/dist/goals/command.js +191 -0
  23. package/dist/loop/dispatcher.js +19 -5
  24. package/dist/loop/git.js +5 -4
  25. package/dist/loop/loop.js +23 -3
  26. package/dist/loop/parallel-adapters.js +3 -2
  27. package/dist/loop/parallel-command.js +52 -4
  28. package/dist/loop/prd.js +3 -0
  29. package/dist/loop/recovery.js +51 -0
  30. package/dist/loop/reporter.js +105 -8
  31. package/dist/loop/run-command.js +62 -13
  32. package/dist/loop/runner.js +20 -16
  33. package/dist/loop/scheduler.js +38 -1
  34. package/dist/observability/events.js +72 -0
  35. package/dist/observability/history.js +80 -0
  36. package/dist/quality/candidate-comparison.js +1 -1
  37. package/dist/quality/command.js +16 -3
  38. package/dist/retrofit/config.js +11 -0
  39. package/dist/retrofit/gitignore.js +6 -0
  40. package/dist/retrofit/planners/gemini.js +11 -3
  41. package/dist/routing/router.js +50 -10
  42. package/dist/setup/command.js +3 -2
  43. package/dist/workspace/fingerprint.js +66 -0
  44. package/dist/workspace/state.js +20 -0
  45. package/docs/PRODUCT-DIRECTION-2026-09-05.md +199 -0
  46. package/docs/VERIFIED-PROJECTS-VALIDATION.md +29 -0
  47. package/docs/VERIFIED-PROJECTS.md +167 -0
  48. package/docs/superpowers/plans/2026-09-05-verified-projects.md +83 -0
  49. package/gemini-extension.json +1 -1
  50. package/hooks/bounded-gemini.mjs +70 -0
  51. 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
- const { model: _globalModel, reasoningEffort: _globalReasoningEffort, ...selection } = input.selection;
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) => providerProcessResultToAgentResult(provider.provider, context.story.id, await runner(context).completion);
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
- return reviewGate(input.review, path, story);
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
+ }
@@ -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?.ms === 'number' && d.ms > 0);
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 pool = runDurations.length > 0 ? runDurations : history;
97
- if (pool.length === 0)
138
+ const estimate = estimateDurations(history, runDurations);
139
+ if (!estimate)
98
140
  return undefined;
99
- const avg = pool.reduce((a, b) => a + b, 0) / pool.length;
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 next = tokens ? { ...withPercent, tokens: { ...tokens } } : withPercent;
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 !== undefined) {
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 } : {}),
@@ -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
- const parallel = opts.parallel ?? 1;
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 ?? realGitOps;
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 routingEnabled = opts.routing ?? config.routing?.enabled ?? false;
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
- ...((routingEnabled || opts.routing === false) ? { nativeMultiAgent: false } : {}),
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 (routingEnabled && (!config.routing || config.routing.workers.length === 0)) {
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 || (opts.isolate ?? false),
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: (opts.parallel ?? 1) > 1 ? true : (opts.isolate ?? false),
508
+ isolate,
460
509
  review,
461
510
  reporter,
462
511
  ...(quality ?? {}),