@hecer/yoke 1.3.0 → 1.4.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 +22 -0
- package/README.md +67 -15
- package/TODOS.md +0 -3
- 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/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/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 +204 -51
- package/dist/loop/runner.js +67 -32
- 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/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 +4 -2
- package/dist/retrofit/gitignore.js +3 -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/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { appendDecision, contextDir } from '../context/context.js';
|
|
4
|
+
import { acquireClaim, heartbeatClaim, releaseClaim, requestClaimCancellation } from './claims.js';
|
|
5
|
+
import { coordinateCandidates } from './candidates.js';
|
|
6
|
+
import { MergeQueue } from './merge-queue.js';
|
|
7
|
+
import { isAcceptanceCriterion, loadPrd, progress, savePrd } from './prd.js';
|
|
8
|
+
import { readyStories } from './scheduler.js';
|
|
9
|
+
import { createWorkerCleanup } from './worker-cleanup.js';
|
|
10
|
+
function defaultClaims(targetDir, clock) {
|
|
11
|
+
return {
|
|
12
|
+
acquire: input => acquireClaim(targetDir, input.story.id, input.dispatcherId, {
|
|
13
|
+
dispatcherId: input.dispatcherId,
|
|
14
|
+
ownerToken: input.ownerToken,
|
|
15
|
+
...(!input.candidateRace ? {
|
|
16
|
+
baseCommit: input.worktree.baseCommit,
|
|
17
|
+
worktree: input.worktree.path,
|
|
18
|
+
provider: input.provider.provider,
|
|
19
|
+
model: input.provider.model,
|
|
20
|
+
} : {}),
|
|
21
|
+
role: 'implementation',
|
|
22
|
+
now: clock(),
|
|
23
|
+
}) !== null,
|
|
24
|
+
heartbeat: input => { heartbeatClaim(targetDir, input.story.id, input.ownerToken, { now: clock() }); },
|
|
25
|
+
release: input => releaseClaim(targetDir, input.story.id, input.ownerToken, { now: clock() }),
|
|
26
|
+
cancel: (input, reason) => { requestClaimCancellation(targetDir, input.story.id, input.ownerToken, reason, { now: clock() }); },
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
async function gateResult(gates, path, worker) {
|
|
30
|
+
const { story } = worker;
|
|
31
|
+
const criteria = story.acceptance.filter(isAcceptanceCriterion);
|
|
32
|
+
if (criteria.length === 0 && gates.requireCriterionEvidence)
|
|
33
|
+
return { passed: false, summary: 'missing criterion evidence' };
|
|
34
|
+
if (criteria.length > 0 && !gates.verifyCriterion)
|
|
35
|
+
return { passed: false, summary: 'criterion verifier is not configured' };
|
|
36
|
+
for (const criterion of criteria) {
|
|
37
|
+
const result = gates.verifyCriterion?.(path, story, criterion);
|
|
38
|
+
if (!result?.passed)
|
|
39
|
+
return { passed: false, summary: result?.summary ?? 'criterion verification failed' };
|
|
40
|
+
}
|
|
41
|
+
for (const gate of [gates.verify, gates.perf, gates.audit]) {
|
|
42
|
+
if (!gate)
|
|
43
|
+
continue;
|
|
44
|
+
const result = gate(path, story);
|
|
45
|
+
if (!result.passed)
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
return gates.qualityReview?.(path, story, worker) ?? { passed: true, summary: 'integrated gates passed' };
|
|
49
|
+
}
|
|
50
|
+
export function createDispatcher(options) {
|
|
51
|
+
const dispatcherId = options.dispatcherId ?? randomUUID();
|
|
52
|
+
const ids = options.id ?? randomUUID;
|
|
53
|
+
const clock = options.clock ?? (() => new Date());
|
|
54
|
+
const claims = options.claims ?? defaultClaims(options.targetDir, clock);
|
|
55
|
+
const queue = new MergeQueue();
|
|
56
|
+
const active = new Map();
|
|
57
|
+
const queued = new Map();
|
|
58
|
+
const areas = new Set();
|
|
59
|
+
const integrated = [];
|
|
60
|
+
const reopened = [];
|
|
61
|
+
const failed = [];
|
|
62
|
+
const failureReasons = new Map();
|
|
63
|
+
const integrationBlocks = [];
|
|
64
|
+
let iterations = 0;
|
|
65
|
+
let providerIndex = 0;
|
|
66
|
+
let paused = false;
|
|
67
|
+
let cancellationReason;
|
|
68
|
+
const reportProgress = () => options.onProgress?.({
|
|
69
|
+
dispatcherId,
|
|
70
|
+
maxConcurrency: options.maxConcurrency,
|
|
71
|
+
activeWorkers: active.size,
|
|
72
|
+
queuedCandidates: queued.size,
|
|
73
|
+
integrated: integrated.length,
|
|
74
|
+
reopened: reopened.length,
|
|
75
|
+
iteration: iterations,
|
|
76
|
+
progress: progress(options.stories),
|
|
77
|
+
workers: [...active.values()].map(worker => ({
|
|
78
|
+
story: worker.input.story.id,
|
|
79
|
+
storyTitle: worker.input.story.title,
|
|
80
|
+
provider: worker.input.provider.provider,
|
|
81
|
+
...(worker.input.provider.model ? { model: worker.input.provider.model } : {}),
|
|
82
|
+
phase: 'implementing',
|
|
83
|
+
})),
|
|
84
|
+
});
|
|
85
|
+
const providerFor = (story) => {
|
|
86
|
+
if (story.agent) {
|
|
87
|
+
const configured = options.affinityProviders?.find(provider => provider.provider === story.agent)
|
|
88
|
+
?? options.providers?.find(provider => provider.provider === story.agent);
|
|
89
|
+
return { ...(configured ?? { provider: story.agent }), provider: story.agent, role: 'implementation' };
|
|
90
|
+
}
|
|
91
|
+
const configured = options.providers?.[providerIndex++ % (options.providers?.length ?? 1)] ?? { provider: 'claude' };
|
|
92
|
+
return { ...configured, role: 'implementation' };
|
|
93
|
+
};
|
|
94
|
+
const cleanupWorker = createWorkerCleanup({
|
|
95
|
+
cleanupProcess: options.worktrees.cleanupProcess,
|
|
96
|
+
removeWorktree: input => options.worktrees.remove(input),
|
|
97
|
+
releaseClaim: input => {
|
|
98
|
+
if (claims.release(input) === false)
|
|
99
|
+
throw new Error('claim release failed');
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
const cleanup = (input) => {
|
|
103
|
+
if (input.story.area)
|
|
104
|
+
areas.delete(input.story.area);
|
|
105
|
+
cleanupWorker(input);
|
|
106
|
+
};
|
|
107
|
+
const releaseCandidateClaim = (input) => {
|
|
108
|
+
if (input.story.area)
|
|
109
|
+
areas.delete(input.story.area);
|
|
110
|
+
if (claims.release(input) === false)
|
|
111
|
+
throw new Error('claim release failed');
|
|
112
|
+
};
|
|
113
|
+
const persistPass = (input) => {
|
|
114
|
+
if (!options.prdPath)
|
|
115
|
+
return;
|
|
116
|
+
const path = join(input.worktree.path, relative(options.targetDir, options.prdPath));
|
|
117
|
+
const next = loadPrd(options.prdPath).map(story => story.id === input.story.id ? { ...story, passes: true } : story);
|
|
118
|
+
savePrd(path, next);
|
|
119
|
+
appendDecision(contextDir(input.worktree.path), { storyId: input.story.id, title: input.story.title, summary: `${input.story.id} integrated` });
|
|
120
|
+
};
|
|
121
|
+
const enqueue = (input, result) => {
|
|
122
|
+
let reason = 'integrated verification failed';
|
|
123
|
+
const task = queue.enqueue({
|
|
124
|
+
storyId: input.story.id,
|
|
125
|
+
rebase: async () => {
|
|
126
|
+
if (cancellationReason)
|
|
127
|
+
throw new Error(cancellationReason);
|
|
128
|
+
if (!options.git.isClean(options.targetDir))
|
|
129
|
+
throw new Error('target working tree is not clean');
|
|
130
|
+
const rebase = await options.git.rebase(input);
|
|
131
|
+
if (rebase.kind === 'reopen') {
|
|
132
|
+
reason = rebase.reason;
|
|
133
|
+
throw new Error(reason);
|
|
134
|
+
}
|
|
135
|
+
return rebase.expectedHead;
|
|
136
|
+
},
|
|
137
|
+
verify: async () => {
|
|
138
|
+
if (cancellationReason) {
|
|
139
|
+
reason = cancellationReason;
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
const gate = await gateResult(options.gates, input.worktree.path, input);
|
|
143
|
+
reason = gate.summary;
|
|
144
|
+
return gate.passed;
|
|
145
|
+
},
|
|
146
|
+
integrate: async (expectedHead) => {
|
|
147
|
+
if (cancellationReason)
|
|
148
|
+
throw new Error(cancellationReason);
|
|
149
|
+
options.gates.integrationPhase?.(input, 'committing');
|
|
150
|
+
persistPass(input);
|
|
151
|
+
await options.git.commit(input);
|
|
152
|
+
await options.git.integrate(input, expectedHead);
|
|
153
|
+
},
|
|
154
|
+
postIntegrateVerify: () => {
|
|
155
|
+
if (!options.git.isClean(options.targetDir))
|
|
156
|
+
throw new Error('target working tree is not clean after integration');
|
|
157
|
+
},
|
|
158
|
+
}).then(merge => {
|
|
159
|
+
switch (merge.status) {
|
|
160
|
+
case 'integrated':
|
|
161
|
+
input.story.passes = true;
|
|
162
|
+
integrated.push(input.story.id);
|
|
163
|
+
result.routing.recordOutcome?.(true);
|
|
164
|
+
return;
|
|
165
|
+
case 'integrated-but-blocked':
|
|
166
|
+
input.story.passes = true;
|
|
167
|
+
integrated.push(input.story.id);
|
|
168
|
+
integrationBlocks.push(merge.reason);
|
|
169
|
+
result.routing.recordOutcome?.(true);
|
|
170
|
+
return;
|
|
171
|
+
case 'reopened':
|
|
172
|
+
reopened.push(input.story.id);
|
|
173
|
+
result.routing.recordOutcome?.(false);
|
|
174
|
+
return;
|
|
175
|
+
default: {
|
|
176
|
+
const unexpected = merge;
|
|
177
|
+
throw new Error(`unexpected merge result: ${String(unexpected)}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}).finally(() => {
|
|
181
|
+
queued.delete(input.story.id);
|
|
182
|
+
options.gates.integrationPhase?.(input, undefined);
|
|
183
|
+
cleanup(input);
|
|
184
|
+
reportProgress();
|
|
185
|
+
});
|
|
186
|
+
queued.set(input.story.id, task);
|
|
187
|
+
reportProgress();
|
|
188
|
+
};
|
|
189
|
+
const launch = (story) => {
|
|
190
|
+
const provider = providerFor(story);
|
|
191
|
+
const ownerToken = ids();
|
|
192
|
+
const worktree = options.worktrees.create({ story, dispatcherId, ownerToken, provider });
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
const candidateRace = Boolean(options.candidateCount && options.candidateCount > 1 && options.candidateCoordinator);
|
|
195
|
+
const input = { story, worktree, provider, cancellation: { signal: controller.signal }, dispatcherId, ownerToken, ...(candidateRace ? { candidateRace: true } : {}) };
|
|
196
|
+
if (!claims.acquire(input)) {
|
|
197
|
+
options.worktrees.remove(input);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
iterations += 1;
|
|
201
|
+
if (story.area)
|
|
202
|
+
areas.add(story.area);
|
|
203
|
+
const candidateDispatch = candidateRace;
|
|
204
|
+
const task = (candidateDispatch && options.candidateCoordinator
|
|
205
|
+
? Promise.resolve(options.candidateCoordinator(input)).then(coordinateCandidates).then(result => ({ source: 'candidates', result }))
|
|
206
|
+
: options.worker(input).then(result => ({ source: 'worker', result }))).then(async (outcome) => {
|
|
207
|
+
active.delete(story.id);
|
|
208
|
+
claims.heartbeat(input);
|
|
209
|
+
if (outcome.source === 'candidates') {
|
|
210
|
+
switch (outcome.result.kind) {
|
|
211
|
+
case 'winner': {
|
|
212
|
+
const winnerInput = {
|
|
213
|
+
...input,
|
|
214
|
+
worktree: outcome.result.winner.worktree,
|
|
215
|
+
provider: outcome.result.winner.provider,
|
|
216
|
+
};
|
|
217
|
+
enqueue(winnerInput, outcome.result.winner.result);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
case 'paused':
|
|
221
|
+
paused = true;
|
|
222
|
+
releaseCandidateClaim(input);
|
|
223
|
+
reportProgress();
|
|
224
|
+
return;
|
|
225
|
+
case 'cancelled':
|
|
226
|
+
releaseCandidateClaim(input);
|
|
227
|
+
reportProgress();
|
|
228
|
+
return;
|
|
229
|
+
case 'blocked':
|
|
230
|
+
failed.push(story.id);
|
|
231
|
+
failureReasons.set(story.id, outcome.result.summary);
|
|
232
|
+
if (outcome.result.reason !== 'cleanup-error' && !outcome.result.recovery?.length)
|
|
233
|
+
releaseCandidateClaim(input);
|
|
234
|
+
reportProgress();
|
|
235
|
+
return;
|
|
236
|
+
default: {
|
|
237
|
+
const unexpected = outcome.result;
|
|
238
|
+
throw new Error(`unexpected candidate coordinator result: ${String(unexpected)}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const result = outcome.result;
|
|
243
|
+
if (result.kind === 'paused') {
|
|
244
|
+
if (result.reason === 'decision')
|
|
245
|
+
cancel(`critical decision required for story ${input.story.id}: ${result.summary}`);
|
|
246
|
+
else
|
|
247
|
+
paused = true;
|
|
248
|
+
cleanup(input);
|
|
249
|
+
reportProgress();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (result.kind !== 'candidate') {
|
|
253
|
+
failed.push(story.id);
|
|
254
|
+
failureReasons.set(story.id, result.summary);
|
|
255
|
+
cleanup(input);
|
|
256
|
+
reportProgress();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (cancellationReason || paused || options.pause?.()) {
|
|
260
|
+
paused = true;
|
|
261
|
+
result.routing.recordOutcome?.(false);
|
|
262
|
+
cleanup(input);
|
|
263
|
+
reportProgress();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
enqueue(input, result);
|
|
267
|
+
}, error => {
|
|
268
|
+
active.delete(story.id);
|
|
269
|
+
failed.push(story.id);
|
|
270
|
+
failureReasons.set(story.id, error instanceof Error ? error.message : String(error));
|
|
271
|
+
try {
|
|
272
|
+
cleanup(input);
|
|
273
|
+
}
|
|
274
|
+
catch (cleanupError) {
|
|
275
|
+
reportProgress();
|
|
276
|
+
throw new AggregateError([error, cleanupError], `worker ${story.id} and cleanup failed`);
|
|
277
|
+
}
|
|
278
|
+
reportProgress();
|
|
279
|
+
if (!(error instanceof Error))
|
|
280
|
+
throw error;
|
|
281
|
+
});
|
|
282
|
+
active.set(story.id, { input, controller, task });
|
|
283
|
+
reportProgress();
|
|
284
|
+
};
|
|
285
|
+
const cancel = (reason) => {
|
|
286
|
+
if (cancellationReason)
|
|
287
|
+
return;
|
|
288
|
+
cancellationReason = reason;
|
|
289
|
+
for (const worker of active.values()) {
|
|
290
|
+
claims.cancel?.(worker.input, reason);
|
|
291
|
+
worker.controller.abort(reason);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const run = async () => {
|
|
295
|
+
for (;;) {
|
|
296
|
+
if (options.pause?.())
|
|
297
|
+
paused = true;
|
|
298
|
+
if (!paused && !cancellationReason && integrationBlocks.length === 0 && iterations < options.maxIterations) {
|
|
299
|
+
const busy = new Set([...active.keys(), ...queued.keys(), ...failed]);
|
|
300
|
+
const slots = Math.max(0, options.maxConcurrency - active.size);
|
|
301
|
+
const ready = readyStories(options.stories, { activeAreas: areas }).filter(story => !busy.has(story.id)).slice(0, slots);
|
|
302
|
+
for (const story of ready) {
|
|
303
|
+
if (!story.area || !areas.has(story.area))
|
|
304
|
+
launch(story);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (active.size > 0) {
|
|
308
|
+
await Promise.race([...active.values()].map(worker => worker.task));
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (queued.size > 0) {
|
|
312
|
+
await Promise.race(queued.values());
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (cancellationReason)
|
|
316
|
+
return { status: 'cancelled', reason: cancellationReason, iterations, integrated, reopened, failed };
|
|
317
|
+
if (paused)
|
|
318
|
+
return { status: 'paused', iterations, integrated, reopened, failed };
|
|
319
|
+
if (integrationBlocks.length > 0)
|
|
320
|
+
return { status: 'blocked', reason: integrationBlocks[0], iterations, integrated, reopened, failed };
|
|
321
|
+
if (options.stories.every(story => story.passes))
|
|
322
|
+
return { status: 'complete', iterations, integrated, reopened, failed };
|
|
323
|
+
if (failed.length > 0) {
|
|
324
|
+
const storyId = failed[0];
|
|
325
|
+
const detail = storyId ? failureReasons.get(storyId) : undefined;
|
|
326
|
+
return { status: 'blocked', ...(storyId && detail ? { reason: `worker ${storyId} failed: ${detail}` } : {}), iterations, integrated, reopened, failed };
|
|
327
|
+
}
|
|
328
|
+
if (iterations >= options.maxIterations)
|
|
329
|
+
return { status: 'cap-reached', iterations, integrated, reopened, failed };
|
|
330
|
+
return { status: 'blocked', iterations, integrated, reopened, failed };
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
return { run, cancel };
|
|
334
|
+
}
|
package/dist/loop/loop.js
CHANGED
|
@@ -6,6 +6,7 @@ import { appendDecision, contextDir } from '../context/context.js';
|
|
|
6
6
|
import { noopReporter } from './reporter.js';
|
|
7
7
|
import { consumeDecisionRequest } from './decision.js';
|
|
8
8
|
import { writeCriterionEvidence } from './evidence.js';
|
|
9
|
+
import { runQualityRepairLoop } from '../quality/loop.js';
|
|
9
10
|
function blockReason(base, targetDir, git) {
|
|
10
11
|
let dirty = false;
|
|
11
12
|
try {
|
|
@@ -16,11 +17,93 @@ function blockReason(base, targetDir, git) {
|
|
|
16
17
|
? `${base} (working tree has uncommitted changes from the blocked story — review/clean before re-running)`
|
|
17
18
|
: base;
|
|
18
19
|
}
|
|
20
|
+
function reviewOutcome(result) {
|
|
21
|
+
if (result.reviewOutcome)
|
|
22
|
+
return result.reviewOutcome;
|
|
23
|
+
if (result.success) {
|
|
24
|
+
return { kind: 'approved', verdict: { approved: true, summary: result.summary, findings: [] } };
|
|
25
|
+
}
|
|
26
|
+
return { kind: 'malformed', summary: result.summary };
|
|
27
|
+
}
|
|
28
|
+
function repairBlockReason(outcome, story, targetDir, git) {
|
|
29
|
+
if (outcome.kind === 'approved' || outcome.kind === 'paused' || outcome.kind === 'cancelled')
|
|
30
|
+
return null;
|
|
31
|
+
const detail = outcome.summary ? `: ${outcome.summary}` : '';
|
|
32
|
+
const stage = outcome.reason === 'gate-failed' ? ` (${outcome.stage})` : '';
|
|
33
|
+
return blockReason(`story ${story.id} repair blocked${stage}: ${outcome.reason}${detail}`, targetDir, git);
|
|
34
|
+
}
|
|
35
|
+
function runQualityReview(opts, executionDir, story, reporter) {
|
|
36
|
+
const qualityAssessment = opts.qualityEnabled?.(story) === false ? undefined : opts.qualityStage;
|
|
37
|
+
const reviewAssessment = opts.review;
|
|
38
|
+
if (!qualityAssessment && !reviewAssessment)
|
|
39
|
+
return null;
|
|
40
|
+
const qualityMetadata = opts.qualityMetadata?.({ targetDir: executionDir, story });
|
|
41
|
+
const rerunGates = () => {
|
|
42
|
+
const criteria = runCriterionGates(opts, executionDir, story);
|
|
43
|
+
if (!criteria.passed)
|
|
44
|
+
return { kind: 'failed', stage: 'criterion', summary: criteria.summary };
|
|
45
|
+
reporter.phase('verifying');
|
|
46
|
+
const verify = runGate(opts.verify, executionDir, story.id);
|
|
47
|
+
if (!verify.passed)
|
|
48
|
+
return { kind: 'failed', stage: 'verify', summary: verify.summary };
|
|
49
|
+
if (opts.perf) {
|
|
50
|
+
reporter.phase('perf');
|
|
51
|
+
const perf = runGate(opts.perf, executionDir, story.id);
|
|
52
|
+
if (!perf.passed)
|
|
53
|
+
return { kind: 'failed', stage: 'perf', summary: perf.summary };
|
|
54
|
+
}
|
|
55
|
+
if (opts.audit) {
|
|
56
|
+
reporter.phase('audit');
|
|
57
|
+
const audit = runGate(opts.audit, executionDir, story.id);
|
|
58
|
+
if (!audit.passed)
|
|
59
|
+
return { kind: 'failed', stage: 'audit', summary: audit.summary };
|
|
60
|
+
}
|
|
61
|
+
return { kind: 'passed' };
|
|
62
|
+
};
|
|
63
|
+
return runQualityRepairLoop({
|
|
64
|
+
quality: qualityAssessment
|
|
65
|
+
? round => {
|
|
66
|
+
reporter.phase('comparing');
|
|
67
|
+
return qualityAssessment({ targetDir: executionDir, story }, round);
|
|
68
|
+
}
|
|
69
|
+
: undefined,
|
|
70
|
+
review: reviewAssessment
|
|
71
|
+
? () => {
|
|
72
|
+
reporter.phase('reviewing');
|
|
73
|
+
return reviewOutcome(reviewAssessment({ targetDir: executionDir, story }));
|
|
74
|
+
}
|
|
75
|
+
: undefined,
|
|
76
|
+
repair: request => {
|
|
77
|
+
reporter.phase('repairing');
|
|
78
|
+
if (!opts.repair)
|
|
79
|
+
return { kind: 'blocked', summary: 'repair callback is not configured' };
|
|
80
|
+
const result = opts.repair({ targetDir: executionDir, story }, request);
|
|
81
|
+
return result.success ? { kind: 'repaired' } : { kind: 'blocked', summary: result.summary };
|
|
82
|
+
},
|
|
83
|
+
rerunGates,
|
|
84
|
+
limits: opts.repairLimits,
|
|
85
|
+
pause: () => consumePause(opts.targetDir),
|
|
86
|
+
onStatus: status => {
|
|
87
|
+
if (qualityMetadata)
|
|
88
|
+
reporter.quality({ ...status, ...qualityMetadata });
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
19
92
|
// Control file a supervisor drops to pause the loop at the next story boundary.
|
|
20
93
|
// The loop consumes (deletes) it and stops with state 'paused' — never mid-story.
|
|
21
94
|
export function pauseFilePath(targetDir) {
|
|
22
95
|
return join(targetDir, '.yoke', 'loop.pause');
|
|
23
96
|
}
|
|
97
|
+
function consumePause(targetDir) {
|
|
98
|
+
const file = pauseFilePath(targetDir);
|
|
99
|
+
if (!existsSync(file))
|
|
100
|
+
return false;
|
|
101
|
+
try {
|
|
102
|
+
unlinkSync(file);
|
|
103
|
+
}
|
|
104
|
+
catch { /* consumed best-effort — pausing still wins */ }
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
24
107
|
// Abort channel for an agent that hits genuinely undecidable acceptance criteria
|
|
25
108
|
// (only instructed to use it under loop.onAmbiguity: abort). Honoured whenever
|
|
26
109
|
// present: without this check, an agent that stopped without changes would sail
|
|
@@ -125,12 +208,7 @@ export function runLoop(opts) {
|
|
|
125
208
|
}
|
|
126
209
|
// Intake is work at a story boundary. Cap, pause, and clean-tree safety must
|
|
127
210
|
// win before a planner is allowed to edit and commit the PRD.
|
|
128
|
-
|
|
129
|
-
if (existsSync(pauseFile)) {
|
|
130
|
-
try {
|
|
131
|
-
unlinkSync(pauseFile);
|
|
132
|
-
}
|
|
133
|
-
catch { /* consumed best-effort — pausing still wins */ }
|
|
211
|
+
if (consumePause(opts.targetDir)) {
|
|
134
212
|
reporter.paused(progress(stories));
|
|
135
213
|
return { status: 'paused', iterations, finalProgress: progress(stories) };
|
|
136
214
|
}
|
|
@@ -199,6 +277,15 @@ export function runLoop(opts) {
|
|
|
199
277
|
return { status: 'blocked', iterations, reason: stl.reason, finalProgress: progress(stories) };
|
|
200
278
|
}
|
|
201
279
|
reporter.storyStart({ id: story.id, title: story.title }, iterations + 1, progress(stories));
|
|
280
|
+
if (opts.qualityPreflight && opts.qualityEnabled?.(story) !== false) {
|
|
281
|
+
reporter.phase('quality-preflight');
|
|
282
|
+
const preflight = opts.qualityPreflight({ targetDir: opts.targetDir, story });
|
|
283
|
+
if (preflight.kind === 'blocked') {
|
|
284
|
+
const reason = blockReason(`story ${story.id} quality preflight: ${preflight.summary}`, opts.targetDir, opts.git);
|
|
285
|
+
reporter.blocked(reason);
|
|
286
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
202
289
|
if (opts.isolate) {
|
|
203
290
|
const wt = join(opts.targetDir, '.yoke', 'worktrees', storyPathSegment(story.id));
|
|
204
291
|
const wtPrd = join(wt, relative(opts.targetDir, opts.prdPath));
|
|
@@ -272,12 +359,15 @@ export function runLoop(opts) {
|
|
|
272
359
|
const summary = result.success
|
|
273
360
|
? result.summary
|
|
274
361
|
: `${result.summary} (runner exited non-zero but verify is green)`;
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
362
|
+
const repairOutcome = runQualityReview(opts, wt, story, reporter);
|
|
363
|
+
if (repairOutcome) {
|
|
364
|
+
if (repairOutcome.kind === 'paused') {
|
|
365
|
+
reporter.paused(progress(stories));
|
|
366
|
+
return { status: 'paused', iterations, finalProgress: progress(stories) };
|
|
367
|
+
}
|
|
368
|
+
const reason = repairBlockReason(repairOutcome, story, opts.targetDir, opts.git);
|
|
369
|
+
if (reason) {
|
|
279
370
|
result.routing?.recordOutcome(false);
|
|
280
|
-
const reason = blockReason(`story ${story.id} rejected in review: ${reviewResult.summary}`, opts.targetDir, opts.git);
|
|
281
371
|
reporter.blocked(reason);
|
|
282
372
|
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
283
373
|
}
|
|
@@ -385,12 +475,15 @@ export function runLoop(opts) {
|
|
|
385
475
|
const summary = result.success
|
|
386
476
|
? result.summary
|
|
387
477
|
: `${result.summary} (runner exited non-zero but verify is green)`;
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
478
|
+
const repairOutcome = runQualityReview(opts, opts.targetDir, story, reporter);
|
|
479
|
+
if (repairOutcome) {
|
|
480
|
+
if (repairOutcome.kind === 'paused') {
|
|
481
|
+
reporter.paused(progress(stories));
|
|
482
|
+
return { status: 'paused', iterations, finalProgress: progress(stories) };
|
|
483
|
+
}
|
|
484
|
+
const reason = repairBlockReason(repairOutcome, story, opts.targetDir, opts.git);
|
|
485
|
+
if (reason) {
|
|
392
486
|
result.routing?.recordOutcome(false);
|
|
393
|
-
const reason = blockReason(`story ${story.id} rejected in review: ${reviewResult.summary}`, opts.targetDir, opts.git);
|
|
394
487
|
reporter.blocked(reason);
|
|
395
488
|
return {
|
|
396
489
|
status: 'blocked',
|
package/dist/loop/merge-queue.js
CHANGED
|
@@ -3,14 +3,20 @@ export class MergeQueue {
|
|
|
3
3
|
enqueue(job) {
|
|
4
4
|
const run = async () => {
|
|
5
5
|
try {
|
|
6
|
-
await job.rebase();
|
|
7
|
-
if (!await job.verify())
|
|
8
|
-
return { storyId: job.storyId, integrated: false, reason: 'integrated-tree verification failed' };
|
|
9
|
-
await job.integrate();
|
|
10
|
-
|
|
6
|
+
const prepared = await job.rebase();
|
|
7
|
+
if (!await job.verify(prepared))
|
|
8
|
+
return { storyId: job.storyId, status: 'reopened', integrated: false, reason: 'integrated-tree verification failed' };
|
|
9
|
+
await job.integrate(prepared);
|
|
10
|
+
try {
|
|
11
|
+
await job.postIntegrateVerify?.(prepared);
|
|
12
|
+
return { storyId: job.storyId, status: 'integrated', integrated: true };
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
return { storyId: job.storyId, status: 'integrated-but-blocked', integrated: true, reason: error instanceof Error ? error.message : String(error) };
|
|
16
|
+
}
|
|
11
17
|
}
|
|
12
18
|
catch (error) {
|
|
13
|
-
return { storyId: job.storyId, integrated: false, reason: error.message };
|
|
19
|
+
return { storyId: job.storyId, status: 'reopened', integrated: false, reason: error instanceof Error ? error.message : String(error) };
|
|
14
20
|
}
|
|
15
21
|
};
|
|
16
22
|
const result = this.tail.then(run, run);
|