@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.
Files changed (69) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +33 -0
  4. package/README.md +108 -16
  5. package/TODOS.md +0 -3
  6. package/bench/README.md +55 -46
  7. package/bench/output-compaction.mjs +65 -0
  8. package/canon/loop/loop-spec.md +22 -8
  9. package/canon/manifest.yaml +1 -1
  10. package/dist/agents/contracts.js +50 -0
  11. package/dist/agents/process-incarnation.js +15 -0
  12. package/dist/agents/process-record.js +65 -0
  13. package/dist/agents/process-streams.js +40 -0
  14. package/dist/agents/process.js +177 -0
  15. package/dist/agents/providers.js +10 -7
  16. package/dist/agents/telemetry.js +62 -0
  17. package/dist/audit/command.js +13 -5
  18. package/dist/cli.js +55 -3
  19. package/dist/loop/candidate-boundaries.js +43 -0
  20. package/dist/loop/candidate-cleanup.js +98 -0
  21. package/dist/loop/candidate-contracts.js +1 -0
  22. package/dist/loop/candidate-selection.js +84 -0
  23. package/dist/loop/candidates.js +228 -0
  24. package/dist/loop/claim-lease.js +131 -0
  25. package/dist/loop/claims.js +177 -40
  26. package/dist/loop/cleanup.js +117 -15
  27. package/dist/loop/decision.js +31 -0
  28. package/dist/loop/dispatcher.js +334 -0
  29. package/dist/loop/git.js +6 -4
  30. package/dist/loop/loop.js +109 -16
  31. package/dist/loop/merge-queue.js +12 -6
  32. package/dist/loop/parallel-adapters.js +185 -0
  33. package/dist/loop/parallel-command.js +287 -0
  34. package/dist/loop/parallel.js +2 -4
  35. package/dist/loop/prd.js +4 -1
  36. package/dist/loop/reporter.js +86 -5
  37. package/dist/loop/run-command.js +216 -58
  38. package/dist/loop/runner.js +67 -32
  39. package/dist/loop/verify.js +65 -11
  40. package/dist/loop/watchdog.js +67 -8
  41. package/dist/loop/worker-cancellation.js +17 -0
  42. package/dist/loop/worker-cleanup.js +23 -0
  43. package/dist/loop/worker-contracts.js +1 -0
  44. package/dist/loop/worker.js +254 -0
  45. package/dist/output/artifact.js +63 -0
  46. package/dist/output/compact.js +192 -0
  47. package/dist/output/types.js +4 -0
  48. package/dist/quality/artifacts.js +59 -0
  49. package/dist/quality/candidate-comparison.js +130 -0
  50. package/dist/quality/command.js +316 -0
  51. package/dist/quality/loop.js +86 -0
  52. package/dist/quality/process-command.js +57 -0
  53. package/dist/quality/reference.js +187 -0
  54. package/dist/quality/repair.js +11 -0
  55. package/dist/quality/runner.js +66 -0
  56. package/dist/quality/types.js +60 -0
  57. package/dist/quality/verdict.js +142 -0
  58. package/dist/retrofit/config.js +26 -2
  59. package/dist/retrofit/gitignore.js +4 -0
  60. package/dist/review/command.js +27 -38
  61. package/dist/review/verdict.js +38 -7
  62. package/docs/MIGRATING-TO-1.4.md +70 -0
  63. package/docs/PUBLISHING.md +16 -2
  64. package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
  65. package/docs/superpowers/plans/2026-08-16-artifact-backed-output-compaction.md +329 -0
  66. package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
  67. package/docs/superpowers/specs/2026-08-16-artifact-backed-output-compaction-design.md +181 -0
  68. package/gemini-extension.json +1 -1
  69. package/package.json +4 -3
@@ -1,14 +1,17 @@
1
- import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
+ import { parseProviderProcessRecord } from '../agents/process-record.js';
5
+ import { processIncarnation } from '../agents/process-incarnation.js';
4
6
  import { acquireTakeoverLease, acquireTakeoverRecoveryLease, lockPath, takeoverLockPath, readLock, isPidAlive, releaseTakeoverLease, releaseTakeoverRecoveryLease, takeoverRecoveryPath, } from './lock.js';
5
- import { killProcessTree } from './watchdog.js';
7
+ import { killProcessForCleanup, killProcessTreeForCleanup } from './watchdog.js';
8
+ import { cleanupClaims } from './claims.js';
6
9
  // Reap orphaned runners PROJECT-SCOPED: kill only pids recorded in this project's
7
10
  // .yoke/runner.pid files (main dir + each worktree). Never by process-name or
8
11
  // command-line pattern — that takes down runners belonging to OTHER projects,
9
12
  // which then stall mid-story. Skipped entirely while the loop lock holder is
10
13
  // alive: a live loop's runner is healthy, not an orphan.
11
- function reapRecordedRunners(targetDir, wtDir, isAlive, killTree) {
14
+ export function reapRecordedRunners(targetDir, wtDir, isAlive, isTreeAlive, killChildTree, killWatchdog, currentIncarnation) {
12
15
  const pidFiles = [join(targetDir, '.yoke', 'runner.pid')];
13
16
  if (existsSync(wtDir)) {
14
17
  for (const name of readdirSync(wtDir))
@@ -18,28 +21,85 @@ function reapRecordedRunners(targetDir, wtDir, isAlive, killTree) {
18
21
  for (const file of pidFiles) {
19
22
  if (!existsSync(file))
20
23
  continue;
24
+ let rec;
21
25
  try {
22
- const rec = JSON.parse(readFileSync(file, 'utf8'));
23
- // Child (agent tree) first, then the watchdog wrapper.
24
- for (const pid of [rec.childPid, rec.watchdogPid]) {
25
- if (typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && isAlive(pid)) {
26
- killTree(pid);
27
- killed++;
26
+ rec = JSON.parse(readFileSync(file, 'utf8'));
27
+ }
28
+ catch { /* malformed record — still consume the file below */ }
29
+ if (!rec) {
30
+ rmSync(file, { force: true });
31
+ continue;
32
+ }
33
+ // Child (agent tree) first, then the watchdog wrapper.
34
+ for (const [pid, incarnation, treeOwned] of [[rec.childPid, rec.childIncarnation, true], [rec.watchdogPid, rec.watchdogIncarnation, false]]) {
35
+ const alive = typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && (isAlive(pid) || (treeOwned && isTreeAlive(pid)));
36
+ if (alive && typeof pid === 'number') {
37
+ if (isAlive(pid) && (typeof incarnation !== 'string' || currentIncarnation(pid) !== incarnation)) {
38
+ throw new Error(`could not confirm legacy runner process incarnation for pid ${pid}`);
39
+ }
40
+ const terminated = treeOwned ? killChildTree(pid) : killWatchdog(pid);
41
+ if (!terminated && (isAlive(pid) || (treeOwned && isTreeAlive(pid)))) {
42
+ throw new Error(`could not confirm legacy runner process tree termination for pid ${pid}`);
28
43
  }
44
+ if (terminated)
45
+ killed++;
29
46
  }
30
47
  }
31
- catch { /* malformed record — still consume the file below */ }
32
48
  rmSync(file, { force: true });
33
49
  }
34
50
  return killed;
35
51
  }
52
+ export function reapProviderProcesses(targetDir, isAlive, isTreeAlive, killTree, currentIncarnation = processIncarnation) {
53
+ const recordsDir = join(targetDir, '.yoke', 'provider-processes');
54
+ if (!existsSync(recordsDir))
55
+ return 0;
56
+ const expectedTarget = resolve(targetDir);
57
+ let killed = 0;
58
+ for (const name of readdirSync(recordsDir)) {
59
+ const recordPath = join(recordsDir, name);
60
+ let parsed = null;
61
+ try {
62
+ parsed = parseProviderProcessRecord(JSON.parse(readFileSync(recordPath, 'utf8')));
63
+ }
64
+ catch { /* malformed records are not trusted or consumed */ }
65
+ if (!parsed || parsed.targetDir !== expectedTarget)
66
+ continue;
67
+ if (isAlive(parsed.childPid) || isTreeAlive(parsed.childPid)) {
68
+ if (isAlive(parsed.childPid) && currentIncarnation(parsed.childPid) !== parsed.startedAt) {
69
+ throw new Error(`could not confirm provider process incarnation for pid ${parsed.childPid}`);
70
+ }
71
+ const terminated = killTree(parsed.childPid);
72
+ if (!terminated && (isAlive(parsed.childPid) || isTreeAlive(parsed.childPid))) {
73
+ throw new Error(`could not confirm provider process tree termination for pid ${parsed.childPid}`);
74
+ }
75
+ if (terminated)
76
+ killed++;
77
+ }
78
+ rmSync(recordPath, { force: true });
79
+ }
80
+ return killed;
81
+ }
82
+ export function isProviderTreeAlive(pid) {
83
+ if (process.platform === 'win32')
84
+ return isPidAlive(pid);
85
+ try {
86
+ process.kill(-pid, 0);
87
+ return true;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
36
93
  // Cleans ONLY yoke-created runtime artifacts: recorded orphan runners,
37
94
  // .yoke/worktrees/*, and a stale loop.lock. Never touches user-created
38
95
  // worktrees, a lock whose holder is alive, or any process it did not record.
39
96
  export function runLoopCleanup(targetDir, opts = {}) {
40
97
  const git = opts.git ?? ((args, cwd) => { execFileSync('git', args, { cwd, stdio: 'pipe' }); });
41
98
  const isAlive = opts.isAlive ?? isPidAlive;
42
- const killTree = opts.killTree ?? killProcessTree;
99
+ const killWatchdog = opts.killTree ?? killProcessForCleanup;
100
+ const killProviderTree = opts.killTree ?? killProcessTreeForCleanup;
101
+ const providerTreeAlive = process.platform === 'win32' ? isAlive : isProviderTreeAlive;
102
+ const currentIncarnation = opts.processIncarnation ?? processIncarnation;
43
103
  const wtDir = join(targetDir, '.yoke', 'worktrees');
44
104
  const recoveryFile = takeoverRecoveryPath(targetDir);
45
105
  if (opts.discardStaleRecovery && existsSync(recoveryFile)) {
@@ -112,20 +172,34 @@ export function runLoopCleanup(targetDir, opts = {}) {
112
172
  console.log(`Loop lock held by a live process (pid ${holder.pid}) — no cleanup performed.`);
113
173
  return 0;
114
174
  }
115
- const killed = reapRecordedRunners(targetDir, wtDir, isAlive, killTree);
116
- if (killed > 0)
117
- console.log(`Killed ${killed} orphaned runner process tree(s) recorded in runner.pid files.`);
175
+ let killed;
176
+ try {
177
+ killed = reapRecordedRunners(targetDir, wtDir, isAlive, providerTreeAlive, killProviderTree, killWatchdog, currentIncarnation) + reapProviderProcesses(targetDir, isAlive, providerTreeAlive, killProviderTree, currentIncarnation);
178
+ }
179
+ catch (error) {
180
+ console.error(`Failed to reap recorded process trees: ${error.message}`);
181
+ return 1;
182
+ }
118
183
  let removed = 0;
119
184
  let failed = 0;
120
185
  if (existsSync(wtDir)) {
121
186
  for (const name of readdirSync(wtDir)) {
122
187
  const path = join(wtDir, name);
188
+ try {
189
+ killed += reapProviderProcesses(path, isAlive, providerTreeAlive, killProviderTree, currentIncarnation);
190
+ }
191
+ catch (error) {
192
+ console.error(`Failed to reap provider processes for worktree ${path}: ${error.message}`);
193
+ failed++;
194
+ continue;
195
+ }
123
196
  if (!opts.removeWorktrees) {
124
197
  console.log(`Yoke worktree retained: ${path} (pass --remove-worktrees to remove it)`);
125
198
  continue;
126
199
  }
127
200
  try {
128
201
  git(['worktree', 'remove', '--force', path], targetDir);
202
+ markCandidateWorktreeRemoved(targetDir, path);
129
203
  removed++;
130
204
  }
131
205
  catch (e) {
@@ -140,6 +214,10 @@ export function runLoopCleanup(targetDir, opts = {}) {
140
214
  catch { /* best-effort */ }
141
215
  }
142
216
  }
217
+ if (killed > 0)
218
+ console.log(`Killed ${killed} orphaned process tree(s) from project-scoped Yoke records.`);
219
+ if (opts.removeWorktrees && failed === 0)
220
+ cleanupClaims(targetDir);
143
221
  const lockFile = lockPath(targetDir);
144
222
  if (existsSync(lockFile)) {
145
223
  rmSync(lockFile, { force: true });
@@ -152,3 +230,27 @@ export function runLoopCleanup(targetDir, opts = {}) {
152
230
  releaseTakeoverLease(targetDir, cleanupLease.ownerToken);
153
231
  }
154
232
  }
233
+ function markCandidateWorktreeRemoved(targetDir, worktree) {
234
+ const proofRoot = join(targetDir, '.yoke', 'proof');
235
+ if (!existsSync(proofRoot))
236
+ return;
237
+ for (const storyId of readdirSync(proofRoot)) {
238
+ const candidates = join(proofRoot, storyId, 'candidates');
239
+ if (!existsSync(candidates))
240
+ continue;
241
+ for (const candidateId of readdirSync(candidates)) {
242
+ const status = join(candidates, candidateId, 'status.json');
243
+ if (!existsSync(status))
244
+ continue;
245
+ try {
246
+ const parsed = JSON.parse(readFileSync(status, 'utf8'));
247
+ if (parsed.worktree !== worktree)
248
+ continue;
249
+ writeFileSync(status, JSON.stringify({ ...parsed, state: 'removed', reason: 'external cleanup recovered candidate worktree' }));
250
+ }
251
+ catch {
252
+ continue;
253
+ }
254
+ }
255
+ }
256
+ }
@@ -72,12 +72,43 @@ export const DecisionResumeSchema = z.object({
72
72
  permissions: z.enum(['safe', 'unsafe', 'read-only']).optional(),
73
73
  parallel: z.number().int().positive().optional(),
74
74
  routing: z.boolean().optional(),
75
+ quality: z.boolean().optional(),
76
+ qualityRounds: z.number().int().positive().optional(),
77
+ qualityMinutes: z.number().int().positive().optional(),
78
+ qualityPolicy: z.enum(['blocking', 'advisory']).optional(),
79
+ candidates: z.number().int().positive().optional(),
75
80
  projectId: z.string().regex(/^[a-f0-9]{16}$/u).optional(),
76
81
  baseCommit: z.string().regex(/^[a-f0-9]{40,64}$/u).optional(),
77
82
  prdHash: z.string().regex(/^[a-f0-9]{64}$/u).optional(),
78
83
  decisionCommit: z.string().regex(/^[a-f0-9]{40,64}$/u).optional(),
79
84
  answerId: z.string().regex(/^[a-f0-9]{16}$/u).optional(),
80
85
  });
86
+ export function buildTrustedDecisionResumeState(input) {
87
+ return DecisionResumeSchema.parse({
88
+ version: 1,
89
+ storyId: input.storyId,
90
+ requestId: input.requestId,
91
+ answered: false,
92
+ ...(input.maxIterations !== undefined ? { maxIterations: input.maxIterations } : {}),
93
+ ...(input.agent ? { agent: input.agent } : {}),
94
+ isolate: input.isolate,
95
+ ...(input.reviewer ? { reviewer: input.reviewer } : {}),
96
+ review: input.review,
97
+ allowSelfReview: input.allowSelfReview,
98
+ ...(input.timeoutMinutes !== undefined ? { timeoutMinutes: input.timeoutMinutes } : {}),
99
+ json: input.json,
100
+ ...(input.onAmbiguity ? { onAmbiguity: input.onAmbiguity } : {}),
101
+ ...(input.decisionPolicy ? { decisionPolicy: input.decisionPolicy } : {}),
102
+ permissions: input.permissions,
103
+ parallel: input.parallel,
104
+ routing: input.routing,
105
+ ...(input.quality !== undefined ? { quality: input.quality } : {}),
106
+ ...(input.qualityRounds !== undefined ? { qualityRounds: input.qualityRounds } : {}),
107
+ ...(input.qualityMinutes !== undefined ? { qualityMinutes: input.qualityMinutes } : {}),
108
+ ...(input.qualityPolicy ? { qualityPolicy: input.qualityPolicy } : {}),
109
+ ...(input.candidates !== undefined ? { candidates: input.candidates } : {}),
110
+ });
111
+ }
81
112
  export function decisionRequestPath(targetDir) {
82
113
  return join(targetDir, '.yoke', 'decision-request.yaml');
83
114
  }
@@ -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/git.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { sanitizeCommitMessage } from './identity.js';
3
+ const OUTPUT_ARTIFACT_EXCLUDE = ':(exclude).yoke/artifacts/**';
3
4
  export const realGitOps = {
4
5
  isClean(dir) {
5
- const out = execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString();
6
+ const out = execFileSync('git', ['status', '--porcelain', '--untracked-files=all', '--', '.', OUTPUT_ARTIFACT_EXCLUDE], { cwd: dir }).toString();
6
7
  return out.trim() === '';
7
8
  },
8
9
  commitAll(dir, message, identity) {
9
- execFileSync('git', ['add', '-A'], { cwd: dir, stdio: 'pipe' });
10
- const status = execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString().trim();
11
- if (status === '') {
10
+ execFileSync('git', ['reset', '--quiet', '--', '.yoke/artifacts'], { cwd: dir, stdio: 'pipe' });
11
+ execFileSync('git', ['add', '-A', '--', '.', OUTPUT_ARTIFACT_EXCLUDE], { cwd: dir, stdio: 'pipe' });
12
+ const staged = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: dir }).toString().trim();
13
+ if (staged === '') {
12
14
  throw new Error('nothing to commit after agent run');
13
15
  }
14
16
  const identityArgs = identity