@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.
Files changed (59) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +22 -0
  4. package/README.md +67 -15
  5. package/TODOS.md +0 -3
  6. package/canon/loop/loop-spec.md +22 -8
  7. package/canon/manifest.yaml +1 -1
  8. package/dist/agents/contracts.js +50 -0
  9. package/dist/agents/process-incarnation.js +15 -0
  10. package/dist/agents/process-record.js +65 -0
  11. package/dist/agents/process-streams.js +40 -0
  12. package/dist/agents/process.js +177 -0
  13. package/dist/agents/providers.js +10 -7
  14. package/dist/agents/telemetry.js +62 -0
  15. package/dist/cli.js +55 -3
  16. package/dist/loop/candidate-boundaries.js +43 -0
  17. package/dist/loop/candidate-cleanup.js +98 -0
  18. package/dist/loop/candidate-contracts.js +1 -0
  19. package/dist/loop/candidate-selection.js +84 -0
  20. package/dist/loop/candidates.js +228 -0
  21. package/dist/loop/claim-lease.js +131 -0
  22. package/dist/loop/claims.js +177 -40
  23. package/dist/loop/cleanup.js +117 -15
  24. package/dist/loop/decision.js +31 -0
  25. package/dist/loop/dispatcher.js +334 -0
  26. package/dist/loop/loop.js +109 -16
  27. package/dist/loop/merge-queue.js +12 -6
  28. package/dist/loop/parallel-adapters.js +185 -0
  29. package/dist/loop/parallel-command.js +287 -0
  30. package/dist/loop/parallel.js +2 -4
  31. package/dist/loop/prd.js +4 -1
  32. package/dist/loop/reporter.js +86 -5
  33. package/dist/loop/run-command.js +204 -51
  34. package/dist/loop/runner.js +67 -32
  35. package/dist/loop/watchdog.js +67 -8
  36. package/dist/loop/worker-cancellation.js +17 -0
  37. package/dist/loop/worker-cleanup.js +23 -0
  38. package/dist/loop/worker-contracts.js +1 -0
  39. package/dist/loop/worker.js +254 -0
  40. package/dist/quality/artifacts.js +59 -0
  41. package/dist/quality/candidate-comparison.js +130 -0
  42. package/dist/quality/command.js +316 -0
  43. package/dist/quality/loop.js +86 -0
  44. package/dist/quality/process-command.js +57 -0
  45. package/dist/quality/reference.js +187 -0
  46. package/dist/quality/repair.js +11 -0
  47. package/dist/quality/runner.js +66 -0
  48. package/dist/quality/types.js +60 -0
  49. package/dist/quality/verdict.js +142 -0
  50. package/dist/retrofit/config.js +4 -2
  51. package/dist/retrofit/gitignore.js +3 -0
  52. package/dist/review/command.js +27 -38
  53. package/dist/review/verdict.js +38 -7
  54. package/docs/MIGRATING-TO-1.4.md +70 -0
  55. package/docs/PUBLISHING.md +16 -2
  56. package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
  57. package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
  58. package/gemini-extension.json +1 -1
  59. 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
- const pauseFile = pauseFilePath(opts.targetDir);
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
- if (opts.review) {
276
- reporter.phase('reviewing');
277
- const reviewResult = opts.review({ targetDir: wt, story });
278
- if (!reviewResult.success) {
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
- if (opts.review) {
389
- reporter.phase('reviewing');
390
- const reviewResult = opts.review({ targetDir: opts.targetDir, story });
391
- if (!reviewResult.success) {
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',
@@ -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
- return { storyId: job.storyId, integrated: true };
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);