@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/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);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
2
|
+
import { createDispatcher } from './dispatcher.js';
|
|
3
|
+
import { consumeDecisionRequest } from './decision.js';
|
|
4
|
+
import { pauseFilePath } from './loop.js';
|
|
5
|
+
import { makeParallelAdapters } from './parallel-adapters.js';
|
|
6
|
+
import { loadPrd, progress } from './prd.js';
|
|
7
|
+
import { makeAsyncRunner } from './runner.js';
|
|
8
|
+
import { runStoryWorker } from './worker.js';
|
|
9
|
+
export async function runParallelLoopCommand(input) {
|
|
10
|
+
const adapters = makeParallelAdapters(input.targetDir, input.identity, input.git);
|
|
11
|
+
if (!adapters.git.isClean(input.targetDir)) {
|
|
12
|
+
input.reporter.blocked('target working tree is not clean');
|
|
13
|
+
return 1;
|
|
14
|
+
}
|
|
15
|
+
let pauseConsumed = false;
|
|
16
|
+
const pause = () => {
|
|
17
|
+
if (pauseConsumed)
|
|
18
|
+
return true;
|
|
19
|
+
const path = pauseFilePath(input.targetDir);
|
|
20
|
+
if (!existsSync(path))
|
|
21
|
+
return false;
|
|
22
|
+
rmSync(path, { force: true });
|
|
23
|
+
pauseConsumed = true;
|
|
24
|
+
return true;
|
|
25
|
+
};
|
|
26
|
+
const dispatcher = createDispatcher({
|
|
27
|
+
targetDir: input.targetDir,
|
|
28
|
+
prdPath: input.prdPath,
|
|
29
|
+
stories: loadPrd(input.prdPath),
|
|
30
|
+
maxConcurrency: input.maxConcurrency,
|
|
31
|
+
maxIterations: input.maxIterations,
|
|
32
|
+
providers: input.providers,
|
|
33
|
+
affinityProviders: input.affinityProviders,
|
|
34
|
+
worktrees: adapters.worktrees,
|
|
35
|
+
git: adapters.git,
|
|
36
|
+
...(input.candidateCount && input.candidateCount > 1 ? {
|
|
37
|
+
candidateCount: input.candidateCount,
|
|
38
|
+
candidateCoordinator: workerInput => candidateCoordinatorInput(input, adapters, workerInput, pause),
|
|
39
|
+
} : {}),
|
|
40
|
+
pause,
|
|
41
|
+
onProgress: status => input.reporter.parallel?.(status),
|
|
42
|
+
gates: {
|
|
43
|
+
verify: input.verify,
|
|
44
|
+
verifyCriterion: input.verifyCriterion,
|
|
45
|
+
requireCriterionEvidence: input.requireCriterionEvidence,
|
|
46
|
+
perf: input.perf,
|
|
47
|
+
audit: input.audit,
|
|
48
|
+
qualityReview: (path, story, worker) => integrationGate(input, path, story, integratorReporter(input.reporter, worker)),
|
|
49
|
+
integrationPhase: (worker, phase) => reportIntegrator(input.reporter, worker, phase),
|
|
50
|
+
},
|
|
51
|
+
worker: workerInput => {
|
|
52
|
+
const runner = input.runner ?? asyncRunner(input, workerInput.provider, workerInput.cancellation.signal, workerInput.ownerToken);
|
|
53
|
+
const result = runStoryWorker({
|
|
54
|
+
story: workerInput.story,
|
|
55
|
+
worktree: workerInput.worktree.path,
|
|
56
|
+
baseCommit: workerInput.worktree.baseCommit,
|
|
57
|
+
provider: workerInput.provider,
|
|
58
|
+
runner,
|
|
59
|
+
verify: input.verify,
|
|
60
|
+
verifyCriterion: input.verifyCriterion,
|
|
61
|
+
requireCriterionEvidence: input.requireCriterionEvidence,
|
|
62
|
+
perf: input.perf,
|
|
63
|
+
audit: input.audit,
|
|
64
|
+
qualityPreflight: input.quality?.qualityPreflight,
|
|
65
|
+
qualityStage: input.quality?.qualityStage,
|
|
66
|
+
repair: input.quality?.repair,
|
|
67
|
+
repairLimits: input.quality?.repairLimits,
|
|
68
|
+
qualityMetadata: input.quality?.qualityMetadata,
|
|
69
|
+
qualityEnabled: input.quality?.qualityEnabled,
|
|
70
|
+
review: input.review,
|
|
71
|
+
cancellation: workerInput.cancellation,
|
|
72
|
+
beforeGates: context => {
|
|
73
|
+
const decision = consumeDecisionRequest(context.targetDir, input.targetDir, context.story.id);
|
|
74
|
+
if (!decision)
|
|
75
|
+
return null;
|
|
76
|
+
input.onCriticalDecision?.(decision);
|
|
77
|
+
return decision.question;
|
|
78
|
+
},
|
|
79
|
+
pause,
|
|
80
|
+
reporter: workerReporter(input.reporter, workerInput),
|
|
81
|
+
});
|
|
82
|
+
return Promise.resolve(result);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const result = await dispatcher.run();
|
|
86
|
+
const finalProgress = progress(loadPrd(input.prdPath));
|
|
87
|
+
if (result.status === 'complete' && input.completion) {
|
|
88
|
+
const gate = input.completion(input.targetDir);
|
|
89
|
+
if (!gate.passed) {
|
|
90
|
+
input.reporter.blocked(gate.summary);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (result.status === 'complete')
|
|
95
|
+
input.reporter.complete(finalProgress);
|
|
96
|
+
else if (result.status === 'paused')
|
|
97
|
+
input.reporter.paused(finalProgress);
|
|
98
|
+
else if (result.status === 'cap-reached')
|
|
99
|
+
input.reporter.capReached(finalProgress);
|
|
100
|
+
else
|
|
101
|
+
input.reporter.blocked(result.reason ?? `parallel dispatcher ${result.status}`);
|
|
102
|
+
return result.status === 'complete' ? 0 : result.status === 'paused' ? 3 : 1;
|
|
103
|
+
}
|
|
104
|
+
function candidateCoordinatorInput(input, adapters, worker, pause) {
|
|
105
|
+
const quality = input.quality;
|
|
106
|
+
const candidateCount = input.candidateCount;
|
|
107
|
+
if (!quality || !candidateCount)
|
|
108
|
+
throw new Error('candidate dispatch requires quality hooks and a candidate count');
|
|
109
|
+
const candidateIds = Array.from({ length: candidateCount }, (_, index) => `candidate-${index + 1}`);
|
|
110
|
+
const comparison = quality.candidateComparison(worker.story, candidateIds);
|
|
111
|
+
const reporter = workerReporter(input.reporter, worker);
|
|
112
|
+
return {
|
|
113
|
+
story: worker.story,
|
|
114
|
+
baseCommit: worker.worktree.baseCommit,
|
|
115
|
+
maxCandidates: candidateCount,
|
|
116
|
+
candidates: candidateDefinitions(input, worker, candidateCount, pause),
|
|
117
|
+
coordinatorId: `${worker.dispatcherId}:${worker.story.id}:${worker.ownerToken}`,
|
|
118
|
+
ownerToken: candidateId => `${worker.ownerToken}-${candidateId}`,
|
|
119
|
+
lifecycle: adapters.candidates(worker),
|
|
120
|
+
evidence: comparison.evidence,
|
|
121
|
+
stageEvidence: comparison.stageEvidence,
|
|
122
|
+
judgeProvenance: comparison.judgeProvenance,
|
|
123
|
+
onSelecting: () => reporter.phase('selecting-candidate'),
|
|
124
|
+
onLifecycle: (candidate, state, reason) => candidateReporter(input.reporter, worker, candidate, state, reason),
|
|
125
|
+
judge: request => comparison.judge(request),
|
|
126
|
+
recordResult: comparison.recordResult,
|
|
127
|
+
recordElimination: comparison.recordElimination,
|
|
128
|
+
recordSelection: comparison.recordSelection,
|
|
129
|
+
signal: worker.cancellation.signal,
|
|
130
|
+
pause,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function candidateDefinitions(input, worker, candidateCount, pause) {
|
|
134
|
+
const quality = input.quality;
|
|
135
|
+
return Array.from({ length: candidateCount }, (_, index) => {
|
|
136
|
+
const candidateId = `candidate-${index + 1}`;
|
|
137
|
+
const qualityRoundBase = (index + 1) * 10_000;
|
|
138
|
+
const runner = input.runner ?? asyncRunner(input, worker.provider, undefined, `${worker.ownerToken}-${candidateId}`);
|
|
139
|
+
return {
|
|
140
|
+
id: candidateId,
|
|
141
|
+
worker: {
|
|
142
|
+
provider: worker.provider,
|
|
143
|
+
runner,
|
|
144
|
+
verify: input.verify,
|
|
145
|
+
verifyCriterion: input.verifyCriterion,
|
|
146
|
+
requireCriterionEvidence: input.requireCriterionEvidence,
|
|
147
|
+
perf: input.perf,
|
|
148
|
+
audit: input.audit,
|
|
149
|
+
...(quality ? {
|
|
150
|
+
qualityPreflight: quality.qualityPreflight,
|
|
151
|
+
qualityStage: (context, round, attempt) => quality.qualityStage(context, qualityRoundBase + round, attempt),
|
|
152
|
+
repair: quality.repair,
|
|
153
|
+
repairLimits: quality.repairLimits,
|
|
154
|
+
qualityMetadata: quality.qualityMetadata,
|
|
155
|
+
qualityEnabled: quality.qualityEnabled,
|
|
156
|
+
} : {}),
|
|
157
|
+
review: input.review,
|
|
158
|
+
beforeGates: context => {
|
|
159
|
+
if (pause())
|
|
160
|
+
return 'candidate coordination paused';
|
|
161
|
+
const decision = consumeDecisionRequest(context.targetDir, input.targetDir, context.story.id);
|
|
162
|
+
if (!decision)
|
|
163
|
+
return null;
|
|
164
|
+
input.onCriticalDecision?.(decision);
|
|
165
|
+
return decision.question;
|
|
166
|
+
},
|
|
167
|
+
pause,
|
|
168
|
+
reporter: workerReporter(input.reporter, worker),
|
|
169
|
+
reporterFor: (candidate) => workerReporter(input.reporter, worker, candidate),
|
|
170
|
+
},
|
|
171
|
+
runnerFor: cancellation => input.runner ?? asyncRunner(input, worker.provider, cancellation.signal, `${worker.ownerToken}-${candidateId}`),
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function workerReporter(reporter, worker, candidate) {
|
|
176
|
+
const attribution = {
|
|
177
|
+
story: worker.story.id,
|
|
178
|
+
storyTitle: worker.story.title,
|
|
179
|
+
provider: worker.provider.provider,
|
|
180
|
+
...(worker.provider.model ? { model: worker.provider.model } : {}),
|
|
181
|
+
...(candidate ? { candidateId: candidate.candidateId, worktree: candidate.worktree.path } : {}),
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
phase: phase => {
|
|
185
|
+
if (reporter.parallelWorker)
|
|
186
|
+
reporter.parallelWorker({ ...attribution, phase });
|
|
187
|
+
else
|
|
188
|
+
reporter.phase(phase);
|
|
189
|
+
},
|
|
190
|
+
quality: quality => {
|
|
191
|
+
if (reporter.parallelWorker)
|
|
192
|
+
reporter.parallelWorker({ ...attribution, quality });
|
|
193
|
+
else
|
|
194
|
+
reporter.quality(quality);
|
|
195
|
+
},
|
|
196
|
+
addTokens: usage => reporter.addTokens(usage),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function candidateReporter(reporter, worker, candidate, state, reason) {
|
|
200
|
+
if (!reporter.parallelWorker)
|
|
201
|
+
return;
|
|
202
|
+
reporter.parallelWorker({
|
|
203
|
+
story: worker.story.id,
|
|
204
|
+
storyTitle: worker.story.title,
|
|
205
|
+
provider: candidate.provider.provider,
|
|
206
|
+
...(candidate.provider.model ? { model: candidate.provider.model } : {}),
|
|
207
|
+
candidateId: candidate.candidateId,
|
|
208
|
+
worktree: candidate.worktree.path,
|
|
209
|
+
lifecycle: state,
|
|
210
|
+
...(reason ? { reason } : {}),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
function integratorReporter(reporter, worker) {
|
|
214
|
+
const attribution = {
|
|
215
|
+
story: worker.story.id,
|
|
216
|
+
storyTitle: worker.story.title,
|
|
217
|
+
provider: worker.provider.provider,
|
|
218
|
+
...(worker.provider.model ? { model: worker.provider.model } : {}),
|
|
219
|
+
};
|
|
220
|
+
return {
|
|
221
|
+
phase: phase => reportIntegrator(reporter, worker, phase),
|
|
222
|
+
quality: quality => {
|
|
223
|
+
if (reporter.parallelIntegrator)
|
|
224
|
+
reporter.parallelIntegrator({ ...attribution, quality });
|
|
225
|
+
else
|
|
226
|
+
reporter.quality(quality);
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function reportIntegrator(reporter, worker, phase) {
|
|
231
|
+
if (reporter.parallelIntegrator) {
|
|
232
|
+
reporter.parallelIntegrator(phase
|
|
233
|
+
? { story: worker.story.id, storyTitle: worker.story.title, provider: worker.provider.provider, ...(worker.provider.model ? { model: worker.provider.model } : {}), phase }
|
|
234
|
+
: null);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (phase)
|
|
238
|
+
reporter.phase(phase);
|
|
239
|
+
}
|
|
240
|
+
function asyncRunner(input, provider, signal, workerId) {
|
|
241
|
+
const { model: _globalModel, reasoningEffort: _globalReasoningEffort, ...selection } = input.selection;
|
|
242
|
+
const runner = makeAsyncRunner(provider.provider, {
|
|
243
|
+
onAmbiguity: input.onAmbiguity,
|
|
244
|
+
permissions: input.permissions,
|
|
245
|
+
selection: {
|
|
246
|
+
...selection,
|
|
247
|
+
...(provider.model ? { model: provider.model } : {}),
|
|
248
|
+
...(provider.reasoningEffort ? { reasoningEffort: provider.reasoningEffort } : {}),
|
|
249
|
+
},
|
|
250
|
+
process: { idleTimeoutMs: input.idleMs, signal, workerId },
|
|
251
|
+
});
|
|
252
|
+
return async (context) => providerProcessResultToAgentResult(provider.provider, context.story.id, await runner(context).completion);
|
|
253
|
+
}
|
|
254
|
+
export function providerProcessResultToAgentResult(agent, storyId, result) {
|
|
255
|
+
const tokens = result.telemetry.tokens;
|
|
256
|
+
const telemetry = tokens ? { tokens } : {};
|
|
257
|
+
switch (result.kind) {
|
|
258
|
+
case 'succeeded': return { success: true, summary: `${agent} implemented ${storyId}`, ...telemetry };
|
|
259
|
+
case 'cancelled': return { success: false, summary: result.reason, ...telemetry };
|
|
260
|
+
case 'timed-out': return { success: false, summary: result.reason, ...telemetry };
|
|
261
|
+
case 'spawn-failed': return { success: false, summary: result.error, ...telemetry };
|
|
262
|
+
case 'failed': return { success: false, summary: `${agent} exited ${result.exitCode ?? 'without a code'}`, ...telemetry };
|
|
263
|
+
default: return assertNever(result);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function reviewGate(review, path, story) {
|
|
267
|
+
if (!review)
|
|
268
|
+
return { passed: true, summary: 'integrated gates passed' };
|
|
269
|
+
const result = review({ targetDir: path, story });
|
|
270
|
+
return { passed: result.success && result.reviewOutcome?.kind !== 'rejected', summary: result.summary };
|
|
271
|
+
}
|
|
272
|
+
function assertNever(value) { throw new Error(`unexpected provider result: ${String(value)}`); }
|
|
273
|
+
function integrationGate(input, path, story, reporter) {
|
|
274
|
+
const context = { targetDir: path, story };
|
|
275
|
+
const qualityEnabled = input.quality?.qualityEnabled(story) ?? false;
|
|
276
|
+
if (qualityEnabled)
|
|
277
|
+
reporter.phase('comparing');
|
|
278
|
+
const preflight = qualityEnabled ? input.quality?.qualityPreflight(context) : undefined;
|
|
279
|
+
if (preflight?.kind === 'blocked')
|
|
280
|
+
return { passed: false, summary: preflight.summary };
|
|
281
|
+
const quality = qualityEnabled ? input.quality?.qualityStage(context, 10_000, 'integration') : undefined;
|
|
282
|
+
if (quality && quality.kind !== 'pass' && quality.kind !== 'skipped')
|
|
283
|
+
return { passed: false, summary: quality.summary };
|
|
284
|
+
if (input.review)
|
|
285
|
+
reporter.phase('reviewing');
|
|
286
|
+
return reviewGate(input.review, path, story);
|
|
287
|
+
}
|