@hecer/yoke 1.2.1 → 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 +66 -24
- package/README.md +186 -104
- package/TODOS.md +0 -3
- package/canon/loop/loop-spec.md +53 -24
- package/canon/loop/prd.schema.md +30 -6
- package/canon/manifest.yaml +1 -1
- package/canon/skills/authoring-prd/SKILL.md +30 -31
- 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/change/inbox.js +279 -0
- package/dist/cli.js +90 -4
- 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/evidence.js +31 -0
- package/dist/loop/gates.js +10 -1
- package/dist/loop/loop.js +236 -33
- 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 +63 -2
- package/dist/loop/reporter.js +86 -5
- package/dist/loop/run-command.js +227 -53
- package/dist/loop/runner.js +77 -34
- package/dist/loop/verify.js +11 -0
- 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/prd/command.js +18 -5
- 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 +13 -4
- package/dist/retrofit/gitignore.js +4 -0
- package/dist/review/command.js +27 -38
- package/dist/review/verdict.js +38 -7
- package/dist/routing/router.js +4 -1
- 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 +6 -6
package/dist/loop/loop.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { existsSync, unlinkSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
|
-
import { loadPrd, savePrd, selectNextStory, allPass, progress } from './prd.js';
|
|
3
|
+
import { isAcceptanceCriterion, loadPrd, savePrd, selectNextStory, allPass, progress, storyPathSegment } from './prd.js';
|
|
4
4
|
import { stopTheLineGate, preDispatchGate } from './gates.js';
|
|
5
5
|
import { appendDecision, contextDir } from '../context/context.js';
|
|
6
6
|
import { noopReporter } from './reporter.js';
|
|
7
7
|
import { consumeDecisionRequest } from './decision.js';
|
|
8
|
+
import { writeCriterionEvidence } from './evidence.js';
|
|
9
|
+
import { runQualityRepairLoop } from '../quality/loop.js';
|
|
8
10
|
function blockReason(base, targetDir, git) {
|
|
9
11
|
let dirty = false;
|
|
10
12
|
try {
|
|
@@ -15,11 +17,93 @@ function blockReason(base, targetDir, git) {
|
|
|
15
17
|
? `${base} (working tree has uncommitted changes from the blocked story — review/clean before re-running)`
|
|
16
18
|
: base;
|
|
17
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
|
+
}
|
|
18
92
|
// Control file a supervisor drops to pause the loop at the next story boundary.
|
|
19
93
|
// The loop consumes (deletes) it and stops with state 'paused' — never mid-story.
|
|
20
94
|
export function pauseFilePath(targetDir) {
|
|
21
95
|
return join(targetDir, '.yoke', 'loop.pause');
|
|
22
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
|
+
}
|
|
23
107
|
// Abort channel for an agent that hits genuinely undecidable acceptance criteria
|
|
24
108
|
// (only instructed to use it under loop.onAmbiguity: abort). Honoured whenever
|
|
25
109
|
// present: without this check, an agent that stopped without changes would sail
|
|
@@ -58,53 +142,152 @@ function consumeAmbiguity(dir) {
|
|
|
58
142
|
const compact = content.replace(/\s+/g, ' ').trim().slice(0, 500);
|
|
59
143
|
return compact || 'agent reported ambiguous acceptance criteria without details';
|
|
60
144
|
}
|
|
145
|
+
function runCompletionGate(opts, stories) {
|
|
146
|
+
if (!opts.completion)
|
|
147
|
+
return null;
|
|
148
|
+
const previous = process.env.YOKE_PHASE;
|
|
149
|
+
process.env.YOKE_PHASE = 'completion';
|
|
150
|
+
let verdict;
|
|
151
|
+
try {
|
|
152
|
+
verdict = opts.completion(opts.targetDir);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
const reason = `integrated completion gate failed: ${error.message}`;
|
|
156
|
+
(opts.reporter ?? noopReporter).blocked(reason);
|
|
157
|
+
return { status: 'blocked', iterations: 0, reason, finalProgress: progress(stories) };
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
if (previous === undefined)
|
|
161
|
+
delete process.env.YOKE_PHASE;
|
|
162
|
+
else
|
|
163
|
+
process.env.YOKE_PHASE = previous;
|
|
164
|
+
}
|
|
165
|
+
if (verdict.passed)
|
|
166
|
+
return null;
|
|
167
|
+
const reason = `integrated system did not verify: ${verdict.summary}`;
|
|
168
|
+
(opts.reporter ?? noopReporter).blocked(reason);
|
|
169
|
+
return { status: 'blocked', iterations: 0, reason, finalProgress: progress(stories) };
|
|
170
|
+
}
|
|
171
|
+
function runCriterionGates(opts, executionDir, story) {
|
|
172
|
+
const criteria = story.acceptance.filter(isAcceptanceCriterion);
|
|
173
|
+
if (criteria.length === 0) {
|
|
174
|
+
return opts.requireCriterionEvidence
|
|
175
|
+
? { passed: false, summary: `story ${story.id} lacks executable criterion evidence` }
|
|
176
|
+
: { passed: true, summary: 'legacy acceptance criteria' };
|
|
177
|
+
}
|
|
178
|
+
if (!opts.verifyCriterion)
|
|
179
|
+
return { passed: false, summary: `story ${story.id} has criteria but no criterion verifier` };
|
|
180
|
+
const evidence = criteria.map(criterion => ({
|
|
181
|
+
criterion,
|
|
182
|
+
result: opts.verifyCriterion(executionDir, story, criterion),
|
|
183
|
+
}));
|
|
184
|
+
try {
|
|
185
|
+
writeCriterionEvidence(opts.targetDir, story, evidence);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
return { passed: false, summary: `could not persist criterion evidence: ${error.message}` };
|
|
189
|
+
}
|
|
190
|
+
const failed = evidence.find(item => !item.result.passed);
|
|
191
|
+
return failed
|
|
192
|
+
? { passed: false, summary: `${failed.criterion.id}: ${failed.result.summary}` }
|
|
193
|
+
: { passed: true, summary: `${evidence.length} acceptance criteria verified` };
|
|
194
|
+
}
|
|
61
195
|
export function runLoop(opts) {
|
|
62
196
|
let iterations = 0;
|
|
63
197
|
const reporter = opts.reporter ?? noopReporter;
|
|
64
|
-
const initial = loadPrd(opts.prdPath);
|
|
65
|
-
if (initial.length === 0) {
|
|
66
|
-
reporter.blocked('PRD has no stories');
|
|
67
|
-
return { status: 'blocked', iterations: 0, reason: 'PRD has no stories', finalProgress: { passed: 0, total: 0 } };
|
|
68
|
-
}
|
|
69
198
|
for (;;) {
|
|
70
|
-
|
|
71
|
-
if (
|
|
72
|
-
reporter.
|
|
73
|
-
return { status: '
|
|
199
|
+
let stories = loadPrd(opts.prdPath);
|
|
200
|
+
if (stories.length === 0) {
|
|
201
|
+
reporter.blocked('PRD has no stories');
|
|
202
|
+
return { status: 'blocked', iterations, reason: 'PRD has no stories', finalProgress: { passed: 0, total: 0 } };
|
|
74
203
|
}
|
|
75
|
-
|
|
204
|
+
const capReached = iterations >= opts.maxIterations;
|
|
205
|
+
if (capReached && !allPass(stories)) {
|
|
76
206
|
reporter.capReached(progress(stories));
|
|
77
207
|
return { status: 'cap-reached', iterations, finalProgress: progress(stories) };
|
|
78
208
|
}
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
if (existsSync(pauseFile)) {
|
|
83
|
-
try {
|
|
84
|
-
unlinkSync(pauseFile);
|
|
85
|
-
}
|
|
86
|
-
catch { /* consumed best-effort — pausing still wins */ }
|
|
209
|
+
// Intake is work at a story boundary. Cap, pause, and clean-tree safety must
|
|
210
|
+
// win before a planner is allowed to edit and commit the PRD.
|
|
211
|
+
if (consumePause(opts.targetDir)) {
|
|
87
212
|
reporter.paused(progress(stories));
|
|
88
213
|
return { status: 'paused', iterations, finalProgress: progress(stories) };
|
|
89
214
|
}
|
|
90
215
|
const pre = preDispatchGate(opts.targetDir, opts.git);
|
|
91
216
|
if (!pre.ok) {
|
|
92
|
-
|
|
93
|
-
|
|
217
|
+
const reason = opts.intake ? `change intake blocked by dirty worktree: ${pre.reason ?? 'pre-dispatch gate failed'}` : pre.reason;
|
|
218
|
+
reporter.blocked(reason ?? 'pre-dispatch gate failed');
|
|
219
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
220
|
+
}
|
|
221
|
+
// A bounded run that finished its final story may report completion, but it
|
|
222
|
+
// must not plan additional queued work after the requested cap.
|
|
223
|
+
if (capReached) {
|
|
224
|
+
const completionFailure = runCompletionGate(opts, stories);
|
|
225
|
+
if (completionFailure)
|
|
226
|
+
return { ...completionFailure, iterations };
|
|
227
|
+
reporter.complete(progress(stories));
|
|
228
|
+
return { status: 'complete', iterations, finalProgress: progress(stories) };
|
|
229
|
+
}
|
|
230
|
+
if (opts.intake) {
|
|
231
|
+
let intake;
|
|
232
|
+
try {
|
|
233
|
+
intake = opts.intake();
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
const stories = loadPrd(opts.prdPath);
|
|
237
|
+
const reason = `change intake failed: ${error.message}`;
|
|
238
|
+
reporter.blocked(reason);
|
|
239
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
240
|
+
}
|
|
241
|
+
if (!intake.ok) {
|
|
242
|
+
const stories = loadPrd(opts.prdPath);
|
|
243
|
+
const reason = `change intake failed: ${intake.summary}`;
|
|
244
|
+
reporter.blocked(reason);
|
|
245
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
246
|
+
}
|
|
247
|
+
if (intake.added > 0) {
|
|
248
|
+
const afterIntake = preDispatchGate(opts.targetDir, opts.git);
|
|
249
|
+
if (!afterIntake.ok) {
|
|
250
|
+
const stories = loadPrd(opts.prdPath);
|
|
251
|
+
const reason = `change intake left a dirty worktree: ${afterIntake.reason ?? 'pre-dispatch gate failed'}`;
|
|
252
|
+
reporter.blocked(reason);
|
|
253
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
stories = loadPrd(opts.prdPath);
|
|
258
|
+
if (stories.length === 0) {
|
|
259
|
+
reporter.blocked('PRD has no stories');
|
|
260
|
+
return { status: 'blocked', iterations, reason: 'PRD has no stories', finalProgress: { passed: 0, total: 0 } };
|
|
261
|
+
}
|
|
262
|
+
if (allPass(stories)) {
|
|
263
|
+
const completionFailure = runCompletionGate(opts, stories);
|
|
264
|
+
if (completionFailure)
|
|
265
|
+
return { ...completionFailure, iterations };
|
|
266
|
+
reporter.complete(progress(stories));
|
|
267
|
+
return { status: 'complete', iterations, finalProgress: progress(stories) };
|
|
94
268
|
}
|
|
95
269
|
const story = selectNextStory(stories);
|
|
96
270
|
if (!story) {
|
|
97
271
|
reporter.complete(progress(stories));
|
|
98
272
|
return { status: 'complete', iterations, finalProgress: progress(stories) };
|
|
99
273
|
}
|
|
100
|
-
const stl = stopTheLineGate(story);
|
|
274
|
+
const stl = stopTheLineGate(story, opts.requireCriterionEvidence);
|
|
101
275
|
if (!stl.ok) {
|
|
102
276
|
reporter.blocked(stl.reason ?? 'stop-the-line gate failed');
|
|
103
277
|
return { status: 'blocked', iterations, reason: stl.reason, finalProgress: progress(stories) };
|
|
104
278
|
}
|
|
105
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
|
+
}
|
|
106
289
|
if (opts.isolate) {
|
|
107
|
-
const wt = join(opts.targetDir, '.yoke', 'worktrees', story.id);
|
|
290
|
+
const wt = join(opts.targetDir, '.yoke', 'worktrees', storyPathSegment(story.id));
|
|
108
291
|
const wtPrd = join(wt, relative(opts.targetDir, opts.prdPath));
|
|
109
292
|
let landed = null;
|
|
110
293
|
try {
|
|
@@ -133,6 +316,13 @@ export function runLoop(opts) {
|
|
|
133
316
|
reporter.blocked(reason);
|
|
134
317
|
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
135
318
|
}
|
|
319
|
+
const criteriaVerdict = runCriterionGates(opts, wt, story);
|
|
320
|
+
if (!criteriaVerdict.passed) {
|
|
321
|
+
result.routing?.recordOutcome(false);
|
|
322
|
+
const reason = blockReason(`story ${story.id} lacks acceptance evidence: ${criteriaVerdict.summary}`, opts.targetDir, opts.git);
|
|
323
|
+
reporter.blocked(reason);
|
|
324
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
325
|
+
}
|
|
136
326
|
// Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
|
|
137
327
|
// exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
|
|
138
328
|
reporter.phase('verifying');
|
|
@@ -169,12 +359,15 @@ export function runLoop(opts) {
|
|
|
169
359
|
const summary = result.success
|
|
170
360
|
? result.summary
|
|
171
361
|
: `${result.summary} (runner exited non-zero but verify is green)`;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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) {
|
|
176
370
|
result.routing?.recordOutcome(false);
|
|
177
|
-
const reason = blockReason(`story ${story.id} rejected in review: ${reviewResult.summary}`, opts.targetDir, opts.git);
|
|
178
371
|
reporter.blocked(reason);
|
|
179
372
|
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
180
373
|
}
|
|
@@ -234,6 +427,13 @@ export function runLoop(opts) {
|
|
|
234
427
|
reporter.blocked(reason);
|
|
235
428
|
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
236
429
|
}
|
|
430
|
+
const criteriaVerdict = runCriterionGates(opts, opts.targetDir, story);
|
|
431
|
+
if (!criteriaVerdict.passed) {
|
|
432
|
+
result.routing?.recordOutcome(false);
|
|
433
|
+
const reason = blockReason(`story ${story.id} lacks acceptance evidence: ${criteriaVerdict.summary}`, opts.targetDir, opts.git);
|
|
434
|
+
reporter.blocked(reason);
|
|
435
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
436
|
+
}
|
|
237
437
|
// Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
|
|
238
438
|
// exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
|
|
239
439
|
reporter.phase('verifying');
|
|
@@ -275,12 +475,15 @@ export function runLoop(opts) {
|
|
|
275
475
|
const summary = result.success
|
|
276
476
|
? result.summary
|
|
277
477
|
: `${result.summary} (runner exited non-zero but verify is green)`;
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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) {
|
|
282
486
|
result.routing?.recordOutcome(false);
|
|
283
|
-
const reason = blockReason(`story ${story.id} rejected in review: ${reviewResult.summary}`, opts.targetDir, opts.git);
|
|
284
487
|
reporter.blocked(reason);
|
|
285
488
|
return {
|
|
286
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);
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { isProviderTreeAlive, reapProviderProcesses } from './cleanup.js';
|
|
5
|
+
import { realGitOps } from './git.js';
|
|
6
|
+
import { isPidAlive } from './lock.js';
|
|
7
|
+
import { storyPathSegment } from './prd.js';
|
|
8
|
+
import { killProcessTreeForCleanup } from './watchdog.js';
|
|
9
|
+
export function makeParallelAdapters(targetDir, identity, injectedGit) {
|
|
10
|
+
return injectedGit
|
|
11
|
+
? injectedAdapters(targetDir, identity, injectedGit)
|
|
12
|
+
: productionAdapters(targetDir, identity);
|
|
13
|
+
}
|
|
14
|
+
function injectedAdapters(targetDir, identity, git) {
|
|
15
|
+
const owned = new Set();
|
|
16
|
+
const removed = new Set();
|
|
17
|
+
return {
|
|
18
|
+
worktrees: {
|
|
19
|
+
create: input => {
|
|
20
|
+
const path = worktreePath(targetDir, input);
|
|
21
|
+
git.addWorktree(targetDir, path);
|
|
22
|
+
owned.add(path);
|
|
23
|
+
return { path, baseCommit: 'injected-base' };
|
|
24
|
+
},
|
|
25
|
+
cleanupProcess: cleanupProviderProcesses,
|
|
26
|
+
remove: input => removeOwnedWorktree(targetDir, input, owned, removed, git),
|
|
27
|
+
},
|
|
28
|
+
git: {
|
|
29
|
+
isClean: dir => git.isClean(dir),
|
|
30
|
+
rebase: input => ({ kind: 'rebased', expectedHead: input.worktree.baseCommit }),
|
|
31
|
+
commit: input => git.commitAll(input.worktree.path, `yoke: complete ${input.story.id} ${input.story.title}`, identity),
|
|
32
|
+
integrate: input => git.integrate(targetDir, input.worktree.path),
|
|
33
|
+
},
|
|
34
|
+
candidates: primary => makeCandidateLifecycle(targetDir, primary, owned, removed, git),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function productionAdapters(targetDir, identity) {
|
|
38
|
+
const owned = new Set();
|
|
39
|
+
const removed = new Set();
|
|
40
|
+
return {
|
|
41
|
+
worktrees: {
|
|
42
|
+
create: input => {
|
|
43
|
+
const path = worktreePath(targetDir, input);
|
|
44
|
+
const baseCommit = gitText(targetDir, ['rev-parse', 'HEAD']);
|
|
45
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
46
|
+
realGitOps.addWorktree(targetDir, path);
|
|
47
|
+
owned.add(path);
|
|
48
|
+
return { path, baseCommit };
|
|
49
|
+
},
|
|
50
|
+
cleanupProcess: cleanupProviderProcesses,
|
|
51
|
+
remove: input => removeOwnedWorktree(targetDir, input, owned, removed, realGitOps),
|
|
52
|
+
},
|
|
53
|
+
git: {
|
|
54
|
+
isClean: dir => realGitOps.isClean(dir),
|
|
55
|
+
rebase: input => rebaseCandidate(targetDir, input),
|
|
56
|
+
commit: input => realGitOps.commitAll(input.worktree.path, `yoke: complete ${input.story.id} ${input.story.title}`, identity),
|
|
57
|
+
integrate: (input, expectedHead) => integrateCandidate(targetDir, input, expectedHead),
|
|
58
|
+
},
|
|
59
|
+
candidates: primary => makeCandidateLifecycle(targetDir, primary, owned, removed, realGitOps),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function cleanupProviderProcesses(input) {
|
|
63
|
+
reapProviderProcesses(input.worktree.path, isPidAlive, isProviderTreeAlive, killProcessTreeForCleanup);
|
|
64
|
+
}
|
|
65
|
+
function makeCandidateLifecycle(targetDir, primary, owned, removed, git) {
|
|
66
|
+
return {
|
|
67
|
+
reserve: input => {
|
|
68
|
+
const worktree = input.candidateId === 'candidate-1'
|
|
69
|
+
? primary.worktree
|
|
70
|
+
: { path: candidateWorktreePath(targetDir, input), baseCommit: primary.worktree.baseCommit };
|
|
71
|
+
writeCandidateStatus(targetDir, input, worktree.path, 'reserved');
|
|
72
|
+
return worktree;
|
|
73
|
+
},
|
|
74
|
+
materialize: ownership => {
|
|
75
|
+
if (ownership.worktree.path !== primary.worktree.path) {
|
|
76
|
+
mkdirSync(dirname(ownership.worktree.path), { recursive: true });
|
|
77
|
+
git.addWorktree(targetDir, ownership.worktree.path);
|
|
78
|
+
owned.add(ownership.worktree.path);
|
|
79
|
+
}
|
|
80
|
+
writeCandidateStatus(targetDir, ownership, ownership.worktree.path, 'materialized');
|
|
81
|
+
},
|
|
82
|
+
cancel: (ownership, reason) => {
|
|
83
|
+
writeCandidateStatus(targetDir, ownership, ownership.worktree.path, 'cleaning', reason);
|
|
84
|
+
},
|
|
85
|
+
reap: ownership => {
|
|
86
|
+
reapProviderProcesses(ownership.worktree.path, isPidAlive, isProviderTreeAlive, killProcessTreeForCleanup);
|
|
87
|
+
},
|
|
88
|
+
remove: ownership => {
|
|
89
|
+
removeOwnedWorktree(targetDir, ownership, owned, removed, git);
|
|
90
|
+
writeCandidateStatus(targetDir, ownership, ownership.worktree.path, 'removed');
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function worktreePath(targetDir, input) {
|
|
95
|
+
return join(targetDir, '.yoke', 'worktrees', `${storyPathSegment(input.story.id)}-${input.ownerToken}`);
|
|
96
|
+
}
|
|
97
|
+
function removeOwnedWorktree(targetDir, input, owned, removed, git) {
|
|
98
|
+
if (removed.has(input.worktree.path))
|
|
99
|
+
return;
|
|
100
|
+
if (!owned.has(input.worktree.path)) {
|
|
101
|
+
throw new Error(`refusing to remove a worktree not created by this dispatcher: ${input.worktree.path}`);
|
|
102
|
+
}
|
|
103
|
+
git.removeWorktree(targetDir, input.worktree.path);
|
|
104
|
+
owned.delete(input.worktree.path);
|
|
105
|
+
removed.add(input.worktree.path);
|
|
106
|
+
}
|
|
107
|
+
function candidateWorktreePath(targetDir, input) {
|
|
108
|
+
return join(targetDir, '.yoke', 'worktrees', `${storyPathSegment(input.storyId)}-${input.ownerToken}`);
|
|
109
|
+
}
|
|
110
|
+
function writeCandidateStatus(targetDir, input, worktree, state, reason) {
|
|
111
|
+
const file = join(targetDir, '.yoke', 'proof', storyPathSegment(input.storyId), 'candidates', input.candidateId, 'status.json');
|
|
112
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
113
|
+
writeFileSync(file, JSON.stringify({ storyId: input.storyId, candidateId: input.candidateId, worktree, state, ...(reason ? { reason } : {}) }));
|
|
114
|
+
}
|
|
115
|
+
function rebaseCandidate(targetDir, input) {
|
|
116
|
+
const currentHead = gitText(targetDir, ['rev-parse', 'HEAD']);
|
|
117
|
+
const candidateHead = gitText(input.worktree.path, ['rev-parse', 'HEAD']);
|
|
118
|
+
try {
|
|
119
|
+
execFileSync('git', ['merge-base', '--is-ancestor', input.worktree.baseCommit, 'HEAD'], { cwd: input.worktree.path, stdio: 'pipe' });
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { kind: 'reopen', reason: `candidate history no longer descends from base ${input.worktree.baseCommit}` };
|
|
123
|
+
}
|
|
124
|
+
if (currentHead !== input.worktree.baseCommit) {
|
|
125
|
+
try {
|
|
126
|
+
execFileSync('git', ['merge-base', '--is-ancestor', input.worktree.baseCommit, currentHead], { cwd: targetDir, stdio: 'pipe' });
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return { kind: 'reopen', reason: `candidate base ${input.worktree.baseCommit} is stale against target ${currentHead}` };
|
|
130
|
+
}
|
|
131
|
+
let candidateTree;
|
|
132
|
+
try {
|
|
133
|
+
execFileSync('git', ['add', '-A'], { cwd: input.worktree.path, stdio: 'pipe' });
|
|
134
|
+
const unstaged = spawnSync('git', ['diff', '--quiet', '--ignore-submodules=none'], { cwd: input.worktree.path, stdio: 'pipe' });
|
|
135
|
+
if (unstaged.error || unstaged.status !== 0)
|
|
136
|
+
return { kind: 'reopen', reason: 'candidate contains changes Git cannot snapshot' };
|
|
137
|
+
candidateTree = gitText(input.worktree.path, ['write-tree']);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return { kind: 'reopen', reason: `candidate snapshot failed: ${errorMessage(error)}` };
|
|
141
|
+
}
|
|
142
|
+
const merge = spawnSync('git', ['merge-tree', '--write-tree', '--messages', '--name-only', `--merge-base=${input.worktree.baseCommit}`, currentHead, candidateTree], {
|
|
143
|
+
cwd: input.worktree.path,
|
|
144
|
+
encoding: 'utf8',
|
|
145
|
+
});
|
|
146
|
+
const mergedTree = merge.stdout.trim().split(/\r?\n/u)[0] ?? '';
|
|
147
|
+
if (merge.error || merge.status !== 0 || !/^[0-9a-f]{40,64}$/u.test(mergedTree)) {
|
|
148
|
+
const detail = [merge.stderr, merge.stdout].map(value => value.trim()).filter(Boolean).join(' · ');
|
|
149
|
+
return { kind: 'reopen', reason: `candidate tree merge failed${detail ? `: ${detail}` : ''}` };
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
execFileSync('git', ['reset', '--soft', currentHead], { cwd: input.worktree.path, stdio: 'pipe' });
|
|
153
|
+
execFileSync('git', ['read-tree', '--reset', '-u', mergedTree], { cwd: input.worktree.path, stdio: 'pipe' });
|
|
154
|
+
const unstaged = spawnSync('git', ['diff', '--quiet', '--ignore-submodules=none'], { cwd: input.worktree.path, stdio: 'pipe' });
|
|
155
|
+
if (unstaged.error || unstaged.status !== 0 || gitText(input.worktree.path, ['ls-files', '--unmerged']) || gitText(input.worktree.path, ['write-tree']) !== mergedTree) {
|
|
156
|
+
throw new Error('materialized candidate tree did not match the computed merge');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
try {
|
|
161
|
+
execFileSync('git', ['reset', '--soft', candidateHead], { cwd: input.worktree.path, stdio: 'pipe' });
|
|
162
|
+
execFileSync('git', ['read-tree', '--reset', '-u', candidateTree], { cwd: input.worktree.path, stdio: 'pipe' });
|
|
163
|
+
}
|
|
164
|
+
catch { }
|
|
165
|
+
return { kind: 'reopen', reason: `candidate tree materialization failed: ${errorMessage(error)}` };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Preserve the worker's candidate tree, but return commit authority to the dispatcher.
|
|
169
|
+
execFileSync('git', ['reset', '--soft', currentHead], { cwd: input.worktree.path, stdio: 'pipe' });
|
|
170
|
+
return { kind: 'rebased', expectedHead: currentHead };
|
|
171
|
+
}
|
|
172
|
+
function integrateCandidate(targetDir, input, expectedHead) {
|
|
173
|
+
if (!realGitOps.isClean(targetDir))
|
|
174
|
+
throw new Error('target working tree is not clean before integration');
|
|
175
|
+
const currentHead = gitText(targetDir, ['rev-parse', 'HEAD']);
|
|
176
|
+
if (currentHead !== expectedHead)
|
|
177
|
+
throw new Error(`target HEAD changed from ${expectedHead} to ${currentHead} during integrated gates`);
|
|
178
|
+
realGitOps.integrate(targetDir, input.worktree.path);
|
|
179
|
+
}
|
|
180
|
+
function gitText(dir, args) {
|
|
181
|
+
return execFileSync('git', args, { cwd: dir, stdio: 'pipe' }).toString().trim();
|
|
182
|
+
}
|
|
183
|
+
function errorMessage(error) {
|
|
184
|
+
return error instanceof Error ? error.message : String(error);
|
|
185
|
+
}
|