@hecer/yoke 1.3.0 → 1.5.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 (69) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +33 -0
  4. package/README.md +108 -16
  5. package/TODOS.md +0 -3
  6. package/bench/README.md +55 -46
  7. package/bench/output-compaction.mjs +65 -0
  8. package/canon/loop/loop-spec.md +22 -8
  9. package/canon/manifest.yaml +1 -1
  10. package/dist/agents/contracts.js +50 -0
  11. package/dist/agents/process-incarnation.js +15 -0
  12. package/dist/agents/process-record.js +65 -0
  13. package/dist/agents/process-streams.js +40 -0
  14. package/dist/agents/process.js +177 -0
  15. package/dist/agents/providers.js +10 -7
  16. package/dist/agents/telemetry.js +62 -0
  17. package/dist/audit/command.js +13 -5
  18. package/dist/cli.js +55 -3
  19. package/dist/loop/candidate-boundaries.js +43 -0
  20. package/dist/loop/candidate-cleanup.js +98 -0
  21. package/dist/loop/candidate-contracts.js +1 -0
  22. package/dist/loop/candidate-selection.js +84 -0
  23. package/dist/loop/candidates.js +228 -0
  24. package/dist/loop/claim-lease.js +131 -0
  25. package/dist/loop/claims.js +177 -40
  26. package/dist/loop/cleanup.js +117 -15
  27. package/dist/loop/decision.js +31 -0
  28. package/dist/loop/dispatcher.js +334 -0
  29. package/dist/loop/git.js +6 -4
  30. package/dist/loop/loop.js +109 -16
  31. package/dist/loop/merge-queue.js +12 -6
  32. package/dist/loop/parallel-adapters.js +185 -0
  33. package/dist/loop/parallel-command.js +287 -0
  34. package/dist/loop/parallel.js +2 -4
  35. package/dist/loop/prd.js +4 -1
  36. package/dist/loop/reporter.js +86 -5
  37. package/dist/loop/run-command.js +216 -58
  38. package/dist/loop/runner.js +67 -32
  39. package/dist/loop/verify.js +65 -11
  40. package/dist/loop/watchdog.js +67 -8
  41. package/dist/loop/worker-cancellation.js +17 -0
  42. package/dist/loop/worker-cleanup.js +23 -0
  43. package/dist/loop/worker-contracts.js +1 -0
  44. package/dist/loop/worker.js +254 -0
  45. package/dist/output/artifact.js +63 -0
  46. package/dist/output/compact.js +192 -0
  47. package/dist/output/types.js +4 -0
  48. package/dist/quality/artifacts.js +59 -0
  49. package/dist/quality/candidate-comparison.js +130 -0
  50. package/dist/quality/command.js +316 -0
  51. package/dist/quality/loop.js +86 -0
  52. package/dist/quality/process-command.js +57 -0
  53. package/dist/quality/reference.js +187 -0
  54. package/dist/quality/repair.js +11 -0
  55. package/dist/quality/runner.js +66 -0
  56. package/dist/quality/types.js +60 -0
  57. package/dist/quality/verdict.js +142 -0
  58. package/dist/retrofit/config.js +26 -2
  59. package/dist/retrofit/gitignore.js +4 -0
  60. package/dist/review/command.js +27 -38
  61. package/dist/review/verdict.js +38 -7
  62. package/docs/MIGRATING-TO-1.4.md +70 -0
  63. package/docs/PUBLISHING.md +16 -2
  64. package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
  65. package/docs/superpowers/plans/2026-08-16-artifact-backed-output-compaction.md +329 -0
  66. package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
  67. package/docs/superpowers/specs/2026-08-16-artifact-backed-output-compaction-design.md +181 -0
  68. package/gemini-extension.json +1 -1
  69. package/package.json +4 -3
@@ -12,10 +12,8 @@ export async function runParallelLoop(stories, opts) {
12
12
  activeAreas.add(story.area);
13
13
  const affinity = story.agent ?? (opts.agents?.length ? opts.agents[agentIndex++ % opts.agents.length] : undefined);
14
14
  const promise = opts.worker(story, affinity).then(result => {
15
- if (result.success) {
16
- story.passes = true;
15
+ if (result.success)
17
16
  completed.push(story.id);
18
- }
19
17
  else
20
18
  failed.push(story.id);
21
19
  }).catch(() => { failed.push(story.id); }).finally(() => {
@@ -27,7 +25,7 @@ export async function runParallelLoop(stories, opts) {
27
25
  };
28
26
  while (iterations < opts.maxIterations && !opts.paused?.()) {
29
27
  const slots = Math.max(0, opts.maxConcurrency - active.size);
30
- const ready = readyStories(stories, { activeAreas }).filter(s => !active.has(s.id) && !failed.includes(s.id)).slice(0, slots);
28
+ const ready = readyStories(stories, { activeAreas }).filter(s => !active.has(s.id) && !completed.includes(s.id) && !failed.includes(s.id)).slice(0, slots);
31
29
  for (const story of ready)
32
30
  launch(story);
33
31
  if (active.size === 0)
package/dist/loop/prd.js CHANGED
@@ -1,6 +1,8 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { readFileSync, writeFileSync } from 'node:fs';
2
3
  import { parse, stringify } from 'yaml';
3
4
  import { z } from 'zod';
5
+ import { StoryQualityDeclarationSchema } from '../quality/types.js';
4
6
  export const AcceptanceCriterionSchema = z.object({
5
7
  id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
6
8
  text: z.string().min(1),
@@ -39,6 +41,7 @@ export const StorySchema = z.object({
39
41
  agent: z.enum(['claude', 'codex', 'gemini']).optional(),
40
42
  /** Inbox request that created this story. Used for idempotent append-only intake. */
41
43
  sourceChange: z.string().min(1).optional(),
44
+ quality: StoryQualityDeclarationSchema.optional(),
42
45
  }).superRefine((story, ctx) => {
43
46
  const structured = story.acceptance.filter(isAcceptanceCriterion);
44
47
  const ids = structured.map(criterion => criterion.id);
@@ -58,7 +61,7 @@ export const StorySchema = z.object({
58
61
  }
59
62
  });
60
63
  export function storyPathSegment(id) {
61
- return `story-${Buffer.from(id, 'utf8').toString('base64url')}`;
64
+ return `story-${createHash('sha256').update(id, 'utf8').digest('hex')}`;
62
65
  }
63
66
  export function isAcceptanceCriterion(value) {
64
67
  return typeof value !== 'string';
@@ -143,20 +143,86 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
143
143
  },
144
144
  blocked(reason) {
145
145
  const base = current ?? emptyStatus(now().toISOString());
146
- persist({ ...base, state: 'blocked', reason, updatedAt: now().toISOString() }, 'blocked', `■ blocked on ${base.story ?? '?'}: ${reason}`);
146
+ persist({ ...withoutParallel(base), state: 'blocked', reason, updatedAt: now().toISOString() }, 'blocked', `■ blocked on ${base.story ?? '?'}: ${reason}`);
147
147
  },
148
148
  complete(progress) {
149
- persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'complete', phase: undefined,
149
+ persist({ ...withoutParallel(current ?? emptyStatus(now().toISOString())), state: 'complete', phase: undefined,
150
150
  progress, reason: undefined, updatedAt: now().toISOString() }, 'complete', `✔ loop complete — ${progress.passed}/${progress.total}`);
151
151
  },
152
152
  capReached(progress) {
153
- persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'cap-reached', phase: undefined,
153
+ persist({ ...withoutParallel(current ?? emptyStatus(now().toISOString())), state: 'cap-reached', phase: undefined,
154
154
  progress, updatedAt: now().toISOString() }, 'cap-reached', `◾ iteration cap reached — ${progress.passed}/${progress.total}`);
155
155
  },
156
156
  paused(progress) {
157
- persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'paused', phase: undefined,
157
+ persist({ ...withoutParallel(current ?? emptyStatus(now().toISOString())), state: 'paused', phase: undefined,
158
158
  progress, updatedAt: now().toISOString() }, 'paused', `⏸ loop paused — ${progress.passed}/${progress.total}`);
159
159
  },
160
+ quality(status) {
161
+ const base = current ?? emptyStatus(now().toISOString());
162
+ persist({ ...base, quality: status, updatedAt: now().toISOString() }, 'quality', ` · quality round ${status.currentRound}…`);
163
+ },
164
+ parallel(status) {
165
+ const base = current ?? emptyStatus(now().toISOString());
166
+ const previousWorkers = base.parallel?.workers ?? [];
167
+ const previousIntegrator = base.parallel?.integrator;
168
+ const workers = status.workers?.map(worker => {
169
+ const previous = previousWorkers.find(candidate => sameWorker(candidate, worker));
170
+ return previous
171
+ ? {
172
+ ...worker,
173
+ ...(previous.phase ? { phase: previous.phase } : {}),
174
+ ...(previous.quality ? { quality: previous.quality } : {}),
175
+ }
176
+ : worker;
177
+ });
178
+ const reportedCandidates = previousWorkers.filter(worker => worker.candidateId && !(workers ?? []).some(candidate => sameWorker(candidate, worker)));
179
+ const parallel = {
180
+ ...status,
181
+ ...(workers !== undefined
182
+ ? { workers: [...workers, ...reportedCandidates] }
183
+ : previousWorkers.length > 0 ? { workers: previousWorkers } : {}),
184
+ ...(status.integrator !== undefined
185
+ ? { integrator: status.integrator }
186
+ : previousIntegrator ? { integrator: previousIntegrator } : {}),
187
+ };
188
+ persist({
189
+ ...base,
190
+ iteration: status.iteration ?? base.iteration,
191
+ progress: status.progress ?? base.progress,
192
+ parallel,
193
+ updatedAt: now().toISOString(),
194
+ }, 'parallel', ` · parallel ${status.activeWorkers}/${status.maxConcurrency}`);
195
+ },
196
+ parallelWorker(status) {
197
+ const base = current;
198
+ const parallel = base?.parallel;
199
+ if (!base || !parallel?.workers)
200
+ return;
201
+ persist({
202
+ ...base,
203
+ parallel: {
204
+ ...parallel,
205
+ workers: upsertWorker(parallel.workers, status),
206
+ },
207
+ updatedAt: now().toISOString(),
208
+ }, 'parallel-worker', ` · ${status.story} ${status.phase ?? 'quality'}…`);
209
+ },
210
+ parallelIntegrator(status) {
211
+ const base = current;
212
+ const parallel = base?.parallel;
213
+ if (!base || !parallel)
214
+ return;
215
+ if (status) {
216
+ persist({
217
+ ...base,
218
+ parallel: { ...parallel, integrator: status },
219
+ updatedAt: now().toISOString(),
220
+ }, 'parallel-integrator', ` · integrating ${status.story} ${status.phase ?? 'quality'}…`);
221
+ return;
222
+ }
223
+ const { integrator: _integrator, ...withoutIntegrator } = parallel;
224
+ persist({ ...base, parallel: withoutIntegrator, updatedAt: now().toISOString() }, 'parallel-integrator', ' · integration complete');
225
+ },
160
226
  addTokens(usage) {
161
227
  const model = usage.model ?? tokens?.model;
162
228
  const cachedInputTokens = (tokens?.cachedInputTokens ?? 0) + (usage.cachedInputTokens ?? 0);
@@ -179,6 +245,21 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
179
245
  function emptyStatus(ts) {
180
246
  return { state: 'running', iteration: 0, progress: { passed: 0, total: 0 }, startedAt: ts, updatedAt: ts };
181
247
  }
248
+ function sameWorker(left, right) {
249
+ return left.candidateId || right.candidateId
250
+ ? left.story === right.story && left.candidateId === right.candidateId && left.worktree === right.worktree
251
+ : left.story === right.story;
252
+ }
253
+ function upsertWorker(workers, status) {
254
+ const index = workers.findIndex(worker => sameWorker(worker, status));
255
+ return index < 0
256
+ ? [...workers, status]
257
+ : workers.map((worker, workerIndex) => workerIndex === index ? { ...worker, ...status } : worker);
258
+ }
259
+ function withoutParallel(status) {
260
+ const { parallel: _parallel, ...withoutParallel } = status;
261
+ return withoutParallel;
262
+ }
182
263
  export const noopReporter = {
183
- storyStart() { }, storyDone() { }, phase() { }, blocked() { }, complete() { }, capReached() { }, paused() { }, addTokens() { },
264
+ storyStart() { }, storyDone() { }, phase() { }, blocked() { }, complete() { }, capReached() { }, paused() { }, quality() { }, addTokens() { },
184
265
  };
@@ -1,6 +1,6 @@
1
1
  import { join } from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
- import { loadConfig, saveConfig, defaultConfig, resolveVerifyCommand } from '../retrofit/config.js';
3
+ import { loadConfig, saveConfig, defaultConfig, resolveOutputPolicy, resolveVerifyCommand } from '../retrofit/config.js';
4
4
  import { loadPrd, progress } from './prd.js';
5
5
  import { runLoop } from './loop.js';
6
6
  import { commitPaths, realGitOps } from './git.js';
@@ -12,9 +12,12 @@ import { maybeAutoUpgrade } from '../update/upgrade.js';
12
12
  import { resolveCommitIdentity } from './identity.js';
13
13
  import { runAudit } from '../audit/command.js';
14
14
  import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
15
- import { clearDecisionResume, decisionProcessingExists, decisionRequestId, formatPendingDecision, readPendingDecision, writeDecisionResume, } from './decision.js';
15
+ import { buildTrustedDecisionResumeState, clearDecisionResume, decisionProcessingExists, decisionRequestId, formatPendingDecision, readPendingDecision, writeDecisionResume, } from './decision.js';
16
16
  import { makeAdaptiveRunner } from '../routing/router.js';
17
17
  import { runChangeApply } from '../change/inbox.js';
18
+ import { createQualityCommandHooks } from '../quality/command.js';
19
+ import { resolveQualityPolicy } from '../quality/types.js';
20
+ import { runParallelLoopCommand } from './parallel-command.js';
18
21
  export const DEFAULT_IDLE_MINUTES = 20;
19
22
  const STALE_MINUTES = 20; // a running status older than this likely means the loop died
20
23
  export function relativeTime(fromIso, now) {
@@ -61,6 +64,24 @@ export function loopStatus(targetDir, now = () => new Date()) {
61
64
  }
62
65
  if (st.reason)
63
66
  lines.push(` reason: ${st.reason}`);
67
+ if (st.quality)
68
+ lines.push(` quality: round ${st.quality.currentRound} · ${st.quality.usedRepairs}${st.quality.unbounded ? ' unbounded repairs' : `/${st.quality.maxRepairs ?? 0} repairs`} · ${st.quality.policy}`);
69
+ if (st.parallel)
70
+ lines.push(` parallel ${st.parallel.dispatcherId}: ${st.parallel.activeWorkers}/${st.parallel.maxConcurrency} workers · ${st.parallel.queuedCandidates} queued · ${st.parallel.integrated} integrated · ${st.parallel.reopened} reopened`);
71
+ const integrator = st.parallel?.integrator;
72
+ if (integrator) {
73
+ const quality = integrator.quality
74
+ ? ` · quality round ${integrator.quality.currentRound} · ${integrator.quality.usedRepairs}${integrator.quality.unbounded ? ' unbounded repairs' : `/${integrator.quality.maxRepairs ?? 0} repairs`}`
75
+ : '';
76
+ lines.push(` integrator ${integrator.story} "${integrator.storyTitle}" (${integrator.provider}${integrator.model ? `/${integrator.model}` : ''}) · ${integrator.phase ?? 'working'}${quality}`);
77
+ }
78
+ for (const worker of st.parallel?.workers ?? []) {
79
+ const quality = worker.quality
80
+ ? ` · quality round ${worker.quality.currentRound} · ${worker.quality.usedRepairs}${worker.quality.unbounded ? ' unbounded repairs' : `/${worker.quality.maxRepairs ?? 0} repairs`}`
81
+ : '';
82
+ const candidate = worker.candidateId ? ` candidate ${worker.candidateId} · ${worker.worktree ?? 'worktree unknown'} · ${worker.lifecycle ?? 'working'}` : '';
83
+ lines.push(` worker ${worker.story} "${worker.storyTitle}" (${worker.provider}${worker.model ? `/${worker.model}` : ''})${candidate} · ${worker.phase ?? 'working'}${quality}`);
84
+ }
64
85
  const ageMs = now().getTime() - Date.parse(st.updatedAt);
65
86
  if (st.state === 'running' && ageMs > STALE_MINUTES * 60_000) {
66
87
  lines.push(` ⚠ possibly stuck — no update in ${relativeTime(st.updatedAt, now())}`);
@@ -72,11 +93,27 @@ export function resolveIdleMs(flagMinutes, configMinutes) {
72
93
  return minutes > 0 ? minutes * 60_000 : 0;
73
94
  }
74
95
  export function runLoopCommand(targetDir, opts) {
75
- if ((opts.parallel ?? 1) > 1) {
76
- console.error('Parallel CLI workers are not enabled yet. The dependency-aware dispatcher and merge queue are available as APIs; use --parallel=1 for the synchronous provider runner.');
96
+ const parallel = opts.parallel ?? 1;
97
+ const candidates = opts.candidates ?? 1;
98
+ if (!Number.isInteger(parallel) || parallel < 1) {
99
+ console.error('--parallel must be a positive integer');
100
+ return 2;
101
+ }
102
+ if (!Number.isInteger(candidates) || candidates < 1 || candidates > 5) {
103
+ console.error('--candidates must be an integer from 1 to 5');
77
104
  return 2;
78
105
  }
79
106
  const config = loadConfig(targetDir);
107
+ const maxParallelCandidates = config?.quality?.maxParallelCandidates ?? 1;
108
+ if (candidates > maxParallelCandidates) {
109
+ console.error(`--candidates=${candidates} exceeds quality.maxParallelCandidates=${maxParallelCandidates}`);
110
+ return 2;
111
+ }
112
+ const qualityDisabled = opts.quality === false || (!config?.quality?.enabled && opts.quality !== true && opts.qualityUnbounded !== true);
113
+ if (candidates > 1 && qualityDisabled) {
114
+ console.error(`--candidates=${candidates} requires quality; quality cannot be disabled for candidate dispatch.`);
115
+ return 2;
116
+ }
80
117
  if (!config?.loop.enabled) {
81
118
  console.error('Loop is disabled. Enable it with: yoke loop on');
82
119
  return 2;
@@ -100,6 +137,14 @@ export function runLoopCommand(targetDir, opts) {
100
137
  console.error(`No PRD found at ${path}. Create one (see canon loop/prd.schema.md).`);
101
138
  return 2;
102
139
  }
140
+ if (candidates > 1) {
141
+ const missingQuality = loadPrd(path).find(story => !story.passes && !story.quality);
142
+ if (missingQuality) {
143
+ console.error(`Story ${missingQuality.id} needs a quality declaration before --candidates=${candidates} can dispatch.`);
144
+ return 2;
145
+ }
146
+ }
147
+ const outputPolicy = resolveOutputPolicy(config);
103
148
  let verify = opts.verify;
104
149
  if (!verify) {
105
150
  const command = resolveVerifyCommand(targetDir, config);
@@ -107,16 +152,16 @@ export function runLoopCommand(targetDir, opts) {
107
152
  console.error('No verify command configured. Set verify.command in .yoke/config.yaml (e.g. "npm test") so the loop can confirm tests pass before marking work done.');
108
153
  return 2;
109
154
  }
110
- verify = retryingVerifier(commandVerifier(command), config.verify?.retries ?? 1);
155
+ verify = retryingVerifier(commandVerifier(command, { phase: 'verify', policy: outputPolicy }), config.verify?.retries ?? 1);
111
156
  }
112
157
  // Optional performance budget gate: same contract as verify (exit 0 = within
113
158
  // budget), same flake tolerance (benchmarks are noisy).
114
159
  let perf = opts.perf;
115
160
  if (!perf && config.perf?.command) {
116
- perf = retryingVerifier(commandVerifier(config.perf.command), config.perf.retries ?? 1);
161
+ perf = retryingVerifier(commandVerifier(config.perf.command, { phase: 'perf', policy: outputPolicy }), config.perf.retries ?? 1);
117
162
  }
118
163
  const completion = config.completion?.command
119
- ? retryingVerifier(commandVerifier(config.completion.command), config.completion.retries ?? 1)
164
+ ? retryingVerifier(commandVerifier(config.completion.command, { phase: 'completion', policy: outputPolicy }), config.completion.retries ?? 1)
120
165
  : undefined;
121
166
  // Opt-in self-update, loop START only — this run keeps executing the version
122
167
  // it started with; a fetched upgrade applies from the next invocation.
@@ -137,8 +182,12 @@ export function runLoopCommand(targetDir, opts) {
137
182
  let audit = opts.audit;
138
183
  if (!audit && config.audit?.enabled) {
139
184
  audit = (dir) => {
140
- const result = runAudit(dir, { command: config.audit?.command, suppressions: config.audit?.suppressions });
141
- return { passed: result.code === 0, summary: result.error ?? (result.findings.map(f => `${f.ruleId} ${f.file}${f.line ? `:${f.line}` : ''}`).join(', ') || 'audit passed') };
185
+ const result = runAudit(dir, {
186
+ command: config.audit?.command,
187
+ suppressions: config.audit?.suppressions,
188
+ commandRunner: (command, commandDir) => commandVerifier(command, { phase: 'audit', policy: outputPolicy })(commandDir),
189
+ });
190
+ return { passed: result.code === 0, summary: result.error ?? (result.findings.map(f => `${f.ruleId} ${f.file}${f.line ? `:${f.line}` : ''}: ${f.message}`).join('\n') || 'audit passed') };
142
191
  };
143
192
  }
144
193
  if (commitIdentity) {
@@ -146,8 +195,84 @@ export function runLoopCommand(targetDir, opts) {
146
195
  announce(`Commits: ${commitIdentity.authorName} <${commitIdentity.authorEmail}> · co-authors: ${commitIdentity.allowCoAuthors ? 'allowed' : 'disabled'}`);
147
196
  }
148
197
  const idleMs = resolveIdleMs(opts.timeoutMinutes, config.loop.timeoutMinutes);
198
+ const qualityOverrides = {
199
+ ...(opts.qualityUnbounded ? { quality: true, qualityUnbounded: true } : opts.quality !== undefined ? { quality: opts.quality } : {}),
200
+ ...(opts.qualityRounds !== undefined ? { qualityRounds: opts.qualityRounds } : {}),
201
+ ...(opts.qualityMinutes !== undefined ? { qualityMinutes: opts.qualityMinutes } : {}),
202
+ ...(opts.qualityPolicy ? { qualityPolicy: opts.qualityPolicy } : {}),
203
+ ...(opts.candidates !== undefined ? { candidates: opts.candidates } : {}),
204
+ };
205
+ if (qualityOverrides.qualityUnbounded) {
206
+ console.error('WARNING: Quality repair limits are unbounded for this invocation. Mechanical gates, watchdog, isolation, and commit safety remain active.');
207
+ }
208
+ const configuredCriticAgent = config.quality?.critic?.agent ?? config.quality?.criticAgent ?? config.agents.find(agent => agent !== runnerAgent) ?? runnerAgent;
209
+ const configuredCriticModel = config.quality?.critic?.model ?? config.quality?.criticModel ?? (configuredCriticAgent === runnerAgent ? config.runner?.model : undefined);
210
+ if (candidates > 1 && !configuredCriticModel) {
211
+ console.error('Quality candidate selection requires quality.critic.model (or legacy quality.criticModel) before any runner or worktree is started.');
212
+ return 2;
213
+ }
214
+ const quality = createQualityCommandHooks({
215
+ targetDir,
216
+ config,
217
+ runnerAgent,
218
+ idleMs,
219
+ policy: qualityOverrides,
220
+ ...(opts.qualityRuntime ? { runtime: opts.qualityRuntime } : {}),
221
+ });
222
+ if (quality) {
223
+ const resolved = resolveQualityPolicy({ defaults: config.quality, overrides: qualityOverrides });
224
+ const criticAgent = configuredCriticAgent;
225
+ const repairAgent = config.quality?.repair?.agent ?? config.quality?.repairAgent ?? runnerAgent;
226
+ const limit = resolved.limits.unbounded ? 'unbounded' : `${resolved.limits.maxRounds ?? 3} rounds/${resolved.limits.maxMinutes ?? 60} minutes`;
227
+ const announce = opts.json ? console.error : console.log;
228
+ 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}`);
229
+ }
149
230
  const permissions = opts.permissions ?? config.runner?.permissions ?? 'safe';
150
231
  const routingEnabled = opts.routing ?? config.routing?.enabled ?? false;
232
+ const runnerSelection = {
233
+ model: config.runner?.model,
234
+ reasoningEffort: config.runner?.reasoningEffort,
235
+ bare: config.runner?.bare,
236
+ ...((routingEnabled || opts.routing === false) ? { nativeMultiAgent: false } : {}),
237
+ };
238
+ if ((parallel > 1 || candidates > 1) && routingEnabled) {
239
+ console.error('Adaptive routing is not available with parallel workers or quality candidates. Run with --parallel=1 --candidates=1 or disable routing.');
240
+ return 2;
241
+ }
242
+ const parallelProviders = [{
243
+ provider: runnerAgent,
244
+ ...(runnerSelection.model ? { model: runnerSelection.model } : {}),
245
+ ...(runnerSelection.reasoningEffort ? { reasoningEffort: runnerSelection.reasoningEffort } : {}),
246
+ }];
247
+ const parallelAffinityProviders = (config.routing?.workers ?? []).map(worker => ({
248
+ provider: worker.agent,
249
+ ...(worker.model ? { model: worker.model } : {}),
250
+ ...(worker.reasoningEffort ? { reasoningEffort: worker.reasoningEffort } : {}),
251
+ }));
252
+ const parallelStories = parallel > 1 || candidates > 1 ? loadPrd(path).filter(story => !story.passes) : [];
253
+ const ambiguousAffinityProvider = [...new Set(parallelStories.flatMap(story => story.agent ? [story.agent] : []))]
254
+ .find(agent => parallelAffinityProviders.filter(provider => provider.provider === agent).length > 1);
255
+ if (ambiguousAffinityProvider) {
256
+ console.error(`Parallel affinity provider "${ambiguousAffinityProvider}" has multiple profiles. Configure exactly one profile for each story agent.`);
257
+ return 2;
258
+ }
259
+ if (opts.runner) {
260
+ const mismatchedAffinity = parallelStories.find(story => {
261
+ if (!story.agent)
262
+ return false;
263
+ const provider = parallelAffinityProviders.find(candidate => candidate.provider === story.agent)
264
+ ?? parallelProviders.find(candidate => candidate.provider === story.agent);
265
+ return !provider
266
+ || provider.provider !== runnerAgent
267
+ || provider.model !== runnerSelection.model
268
+ || provider.reasoningEffort !== runnerSelection.reasoningEffort;
269
+ });
270
+ if (mismatchedAffinity) {
271
+ console.error(`Injected runner cannot truthfully execute affinity provider for story ${mismatchedAffinity.id}. Remove the affinity or use the configured provider runner.`);
272
+ return 2;
273
+ }
274
+ }
275
+ const ambiguityPolicy = opts.decisionPolicy ?? opts.onAmbiguity ?? config.loop.decisionPolicy ?? config.loop.onAmbiguity ?? 'auto';
151
276
  if (routingEnabled && (!config.routing || config.routing.workers.length === 0)) {
152
277
  console.error('Adaptive routing was requested, but no worker profiles are configured. Run yoke setup . --routing or add routing.workers to .yoke/config.yaml.');
153
278
  return 2;
@@ -167,23 +292,22 @@ export function runLoopCommand(targetDir, opts) {
167
292
  }));
168
293
  let runner = opts.runner;
169
294
  if (!runner) {
170
- if (!available(runnerAgent)) {
171
- console.error(`Agent CLI "${runnerAgent}" was not found on PATH. Install it, or pick another with --runner=<claude|codex|gemini>.`);
295
+ const requiredProviders = parallel > 1 || candidates > 1
296
+ ? [...new Set(loadPrd(path).filter(story => !story.passes).map(story => story.agent ?? runnerAgent))]
297
+ : [runnerAgent];
298
+ const unavailableProvider = requiredProviders.find(agent => !available(agent));
299
+ if (unavailableProvider) {
300
+ console.error(`Agent CLI "${unavailableProvider}" was not found on PATH. Install it, or pick another with --runner=<claude|codex|gemini>.`);
172
301
  return 2;
173
302
  }
174
303
  // Token reporting is part of the machine interface: in --json mode a claude
175
304
  // runner switches to stream-json so cumulative usage rides on every status.
176
305
  const runnerOpts = {
177
306
  tokenReport: opts.json === true,
178
- onAmbiguity: opts.decisionPolicy ?? opts.onAmbiguity ?? config.loop.decisionPolicy ?? config.loop.onAmbiguity ?? 'auto',
307
+ onAmbiguity: ambiguityPolicy,
179
308
  perfCommand: config.perf?.command,
180
309
  permissions,
181
- selection: {
182
- model: config.runner?.model,
183
- reasoningEffort: config.runner?.reasoningEffort,
184
- bare: config.runner?.bare,
185
- ...((routingEnabled || opts.routing === false) ? { nativeMultiAgent: false } : {}),
186
- },
310
+ selection: runnerSelection,
187
311
  };
188
312
  runner = routingEnabled && config.routing
189
313
  ? makeAdaptiveRunner({
@@ -237,8 +361,75 @@ export function runLoopCommand(targetDir, opts) {
237
361
  if (lock.stalePid !== undefined) {
238
362
  console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
239
363
  }
364
+ const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json });
365
+ const buildResume = (storyId, requestId) => buildTrustedDecisionResumeState({
366
+ storyId,
367
+ requestId,
368
+ ...(opts.maxIterations !== undefined ? { maxIterations: opts.maxIterations } : {}),
369
+ agent: runnerAgent,
370
+ isolate: parallel > 1 || candidates > 1 || (opts.isolate ?? false),
371
+ reviewer: opts.reviewer,
372
+ review: opts.review === true || opts.reviewRunner !== undefined,
373
+ allowSelfReview: opts.allowSelfReview ?? false,
374
+ timeoutMinutes: opts.timeoutMinutes ?? config.loop.timeoutMinutes,
375
+ json: opts.json ?? false,
376
+ onAmbiguity: opts.decisionPolicy ? undefined : opts.onAmbiguity === 'resolve' || opts.onAmbiguity === 'abort' ? opts.onAmbiguity : (config.loop.decisionPolicy ? undefined : config.loop.onAmbiguity),
377
+ decisionPolicy: opts.decisionPolicy ?? (opts.onAmbiguity ? (opts.onAmbiguity === 'auto' || opts.onAmbiguity === 'critical' ? opts.onAmbiguity : undefined) : config.loop.decisionPolicy),
378
+ permissions,
379
+ parallel,
380
+ routing: routingEnabled,
381
+ ...(qualityOverrides.quality !== undefined ? { quality: qualityOverrides.quality } : {}),
382
+ ...(opts.qualityRounds !== undefined ? { qualityRounds: opts.qualityRounds } : {}),
383
+ ...(opts.qualityMinutes !== undefined ? { qualityMinutes: opts.qualityMinutes } : {}),
384
+ ...(opts.qualityPolicy ? { qualityPolicy: opts.qualityPolicy } : {}),
385
+ ...(opts.candidates !== undefined ? { candidates: opts.candidates } : {}),
386
+ });
387
+ const reconcileDecisionResume = () => {
388
+ try {
389
+ const pendingDecision = readPendingDecision(targetDir);
390
+ if (pendingDecision) {
391
+ writeDecisionResume(targetDir, buildResume(pendingDecision.storyId, decisionRequestId(pendingDecision)));
392
+ }
393
+ else {
394
+ clearDecisionResume(targetDir);
395
+ }
396
+ return undefined;
397
+ }
398
+ catch (error) {
399
+ reporter.blocked(`could not persist trusted decision resume state: ${error instanceof Error ? error.message : String(error)}`);
400
+ return 1;
401
+ }
402
+ };
403
+ if (parallel > 1 || candidates > 1) {
404
+ return runParallelLoopCommand({
405
+ targetDir,
406
+ prdPath: path,
407
+ maxConcurrency: parallel,
408
+ candidateCount: candidates,
409
+ maxIterations: opts.maxIterations ?? Number.POSITIVE_INFINITY,
410
+ runner: opts.runner,
411
+ runnerAgent,
412
+ idleMs,
413
+ permissions,
414
+ selection: runnerSelection,
415
+ providers: parallelProviders,
416
+ affinityProviders: parallelAffinityProviders,
417
+ onAmbiguity: ambiguityPolicy,
418
+ git: opts.git,
419
+ identity: commitIdentity,
420
+ verify,
421
+ verifyCriterion: (dir, _story, criterion) => commandsVerifier(criterion.verify, { phase: 'criterion', policy: outputPolicy })(dir),
422
+ requireCriterionEvidence: config.verify?.requireCriteria ?? false,
423
+ perf,
424
+ audit,
425
+ review,
426
+ reporter,
427
+ completion,
428
+ quality,
429
+ onCriticalDecision: decision => writeDecisionResume(targetDir, buildResume(decision.storyId, decisionRequestId(decision))),
430
+ }).then(code => reconcileDecisionResume() ?? code).finally(() => releaseLock(targetDir, lock.ownerToken));
431
+ }
240
432
  try {
241
- const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json });
242
433
  const maxIterations = opts.maxIterations ?? Number.POSITIVE_INFINITY;
243
434
  const result = runLoop({
244
435
  prdPath: path,
@@ -247,7 +438,7 @@ export function runLoopCommand(targetDir, opts) {
247
438
  git,
248
439
  commitIdentity,
249
440
  verify,
250
- verifyCriterion: (dir, _story, criterion) => commandsVerifier(criterion.verify)(dir),
441
+ verifyCriterion: (dir, _story, criterion) => commandsVerifier(criterion.verify, { phase: 'criterion', policy: outputPolicy })(dir),
251
442
  requireCriterionEvidence: config.verify?.requireCriteria ?? false,
252
443
  completion,
253
444
  intake,
@@ -257,45 +448,12 @@ export function runLoopCommand(targetDir, opts) {
257
448
  isolate: (opts.parallel ?? 1) > 1 ? true : (opts.isolate ?? false),
258
449
  review,
259
450
  reporter,
451
+ ...(quality ?? {}),
452
+ ...(quality ? { qualityEnabled: quality.qualityEnabled, qualityMetadata: quality.qualityMetadata } : {}),
260
453
  });
261
- try {
262
- const pendingDecision = readPendingDecision(targetDir);
263
- if (pendingDecision) {
264
- writeDecisionResume(targetDir, {
265
- version: 1,
266
- storyId: pendingDecision.storyId,
267
- requestId: decisionRequestId(pendingDecision),
268
- answered: false,
269
- ...(opts.maxIterations !== undefined ? { maxIterations: opts.maxIterations } : {}),
270
- agent: runnerAgent,
271
- isolate: opts.isolate ?? false,
272
- reviewer: opts.reviewer,
273
- review: opts.review === true || opts.reviewRunner !== undefined,
274
- allowSelfReview: opts.allowSelfReview ?? false,
275
- timeoutMinutes: opts.timeoutMinutes ?? config.loop.timeoutMinutes,
276
- json: opts.json ?? false,
277
- onAmbiguity: opts.decisionPolicy
278
- ? undefined
279
- : opts.onAmbiguity === 'resolve' || opts.onAmbiguity === 'abort'
280
- ? opts.onAmbiguity
281
- : (config.loop.decisionPolicy ? undefined : config.loop.onAmbiguity),
282
- decisionPolicy: opts.decisionPolicy
283
- ?? (opts.onAmbiguity
284
- ? (opts.onAmbiguity === 'auto' || opts.onAmbiguity === 'critical' ? opts.onAmbiguity : undefined)
285
- : config.loop.decisionPolicy),
286
- permissions,
287
- parallel: opts.parallel ?? 1,
288
- routing: routingEnabled,
289
- });
290
- }
291
- else
292
- clearDecisionResume(targetDir);
293
- }
294
- catch (error) {
295
- const reason = `could not persist trusted decision resume state: ${error.message}`;
296
- reporter.blocked(reason);
297
- return 1;
298
- }
454
+ const resumeCode = reconcileDecisionResume();
455
+ if (resumeCode !== undefined)
456
+ return resumeCode;
299
457
  // In json mode stdout belongs to the NDJSON stream — route the narrative summary to stderr.
300
458
  const say = opts.json ? (line) => console.error(line) : (line) => console.log(line);
301
459
  say(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`);