@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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +108 -16
- package/TODOS.md +0 -3
- package/bench/README.md +55 -46
- package/bench/output-compaction.mjs +65 -0
- package/canon/loop/loop-spec.md +22 -8
- package/canon/manifest.yaml +1 -1
- package/dist/agents/contracts.js +50 -0
- package/dist/agents/process-incarnation.js +15 -0
- package/dist/agents/process-record.js +65 -0
- package/dist/agents/process-streams.js +40 -0
- package/dist/agents/process.js +177 -0
- package/dist/agents/providers.js +10 -7
- package/dist/agents/telemetry.js +62 -0
- package/dist/audit/command.js +13 -5
- package/dist/cli.js +55 -3
- package/dist/loop/candidate-boundaries.js +43 -0
- package/dist/loop/candidate-cleanup.js +98 -0
- package/dist/loop/candidate-contracts.js +1 -0
- package/dist/loop/candidate-selection.js +84 -0
- package/dist/loop/candidates.js +228 -0
- package/dist/loop/claim-lease.js +131 -0
- package/dist/loop/claims.js +177 -40
- package/dist/loop/cleanup.js +117 -15
- package/dist/loop/decision.js +31 -0
- package/dist/loop/dispatcher.js +334 -0
- package/dist/loop/git.js +6 -4
- package/dist/loop/loop.js +109 -16
- package/dist/loop/merge-queue.js +12 -6
- package/dist/loop/parallel-adapters.js +185 -0
- package/dist/loop/parallel-command.js +287 -0
- package/dist/loop/parallel.js +2 -4
- package/dist/loop/prd.js +4 -1
- package/dist/loop/reporter.js +86 -5
- package/dist/loop/run-command.js +216 -58
- package/dist/loop/runner.js +67 -32
- package/dist/loop/verify.js +65 -11
- package/dist/loop/watchdog.js +67 -8
- package/dist/loop/worker-cancellation.js +17 -0
- package/dist/loop/worker-cleanup.js +23 -0
- package/dist/loop/worker-contracts.js +1 -0
- package/dist/loop/worker.js +254 -0
- package/dist/output/artifact.js +63 -0
- package/dist/output/compact.js +192 -0
- package/dist/output/types.js +4 -0
- package/dist/quality/artifacts.js +59 -0
- package/dist/quality/candidate-comparison.js +130 -0
- package/dist/quality/command.js +316 -0
- package/dist/quality/loop.js +86 -0
- package/dist/quality/process-command.js +57 -0
- package/dist/quality/reference.js +187 -0
- package/dist/quality/repair.js +11 -0
- package/dist/quality/runner.js +66 -0
- package/dist/quality/types.js +60 -0
- package/dist/quality/verdict.js +142 -0
- package/dist/retrofit/config.js +26 -2
- package/dist/retrofit/gitignore.js +4 -0
- package/dist/review/command.js +27 -38
- package/dist/review/verdict.js +38 -7
- package/docs/MIGRATING-TO-1.4.md +70 -0
- package/docs/PUBLISHING.md +16 -2
- package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
- package/docs/superpowers/plans/2026-08-16-artifact-backed-output-compaction.md +329 -0
- package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
- package/docs/superpowers/specs/2026-08-16-artifact-backed-output-compaction-design.md +181 -0
- package/gemini-extension.json +1 -1
- package/package.json +4 -3
package/dist/loop/parallel.js
CHANGED
|
@@ -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-${
|
|
64
|
+
return `story-${createHash('sha256').update(id, 'utf8').digest('hex')}`;
|
|
62
65
|
}
|
|
63
66
|
export function isAcceptanceCriterion(value) {
|
|
64
67
|
return typeof value !== 'string';
|
package/dist/loop/reporter.js
CHANGED
|
@@ -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
|
};
|
package/dist/loop/run-command.js
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
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, {
|
|
141
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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:
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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`);
|