@hecer/yoke 1.16.0 → 1.18.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.
@@ -1,9 +1,9 @@
1
1
  import { roleSelection } from "../routing/capability.js";
2
2
  import { join } from 'node:path';
3
- import { existsSync } from 'node:fs';
3
+ import { existsSync, unlinkSync } from 'node:fs';
4
4
  import { loadConfig, saveConfig, defaultConfig, resolveOutputPolicy, resolveVerifyCommand } from '../retrofit/config.js';
5
- import { loadPrd, progress } from './prd.js';
6
- import { runLoop } from './loop.js';
5
+ import { allPass, loadPrd, progress, selectNextStory } from './prd.js';
6
+ import { pauseFilePath, requestLoopPause, runLoop } from './loop.js';
7
7
  import { commitPaths, realGitOps } from './git.js';
8
8
  import { makeRunner, makeReviewRunner, isAgentAvailable } from './runner.js';
9
9
  import { commandVerifier, commandsVerifier, retryingVerifier } from './verify.js';
@@ -25,6 +25,8 @@ import { detectUiProject } from '../retrofit/ui-detect.js';
25
25
  import { designVerifier } from '../scan/gate.js';
26
26
  import { prepareIsolatedWorktree } from './recovery.js';
27
27
  import { AGENT_LIST, SUPPORTED_AGENTS } from '../agents/catalog.js';
28
+ import { MAX_PROJECT_WORKERS, sharedPoolStatus, withSharedWorkerSync } from './resource-pool.js';
29
+ import { runPrdExplore } from '../prd/explore.js';
28
30
  export const DEFAULT_IDLE_MINUTES = 20;
29
31
  const STALE_MINUTES = 20; // a running status older than this likely means the loop died
30
32
  export function relativeTime(fromIso, now) {
@@ -58,14 +60,24 @@ export function loopStatus(targetDir, now = () => new Date()) {
58
60
  const p = progress(loadPrd(path));
59
61
  prog = `${p.passed}/${p.total} stories pass`;
60
62
  }
63
+ const sharedPoolLine = () => {
64
+ try {
65
+ const pool = sharedPoolStatus();
66
+ return `Shared pool: ${pool.activeUnits}/${pool.limit} units · ${pool.activeByRole.implementation} implementation · ${pool.activeByRole.integration} integration · ${pool.waitingWorkers} waiting${pool.oldestWaitMs ? ` · oldest wait ${fmtDuration(pool.oldestWaitMs)}` : ''}`;
67
+ }
68
+ catch (error) {
69
+ return `Shared pool unavailable: ${error.message}`;
70
+ }
71
+ };
61
72
  const st = readStatus(targetDir);
62
73
  if (!st)
63
- return `Loop: ${enabled ? 'enabled' : 'disabled'}\nPRD: ${prog}`;
74
+ return `Loop: ${enabled ? 'enabled' : 'disabled'}\nPRD: ${prog}\n${sharedPoolLine()}`;
64
75
  const head = `Loop: ${st.state.toUpperCase()}${st.story ? ` on ${st.story}${st.storyTitle ? ` "${st.storyTitle}"` : ''}` : ''}`;
65
76
  const pct = st.percent !== undefined ? ` (${st.percent}%)` : '';
66
77
  const meta = [st.phase, `iteration ${st.iteration}`, `backlog ${st.progress.passed}/${st.progress.total}${pct}`, `updated ${relativeTime(st.updatedAt, now())}`]
67
78
  .filter(Boolean).join(' · ');
68
79
  const lines = [head, ` ${meta}`];
80
+ lines.push(` ${sharedPoolLine()}`);
69
81
  for (const process of st.supervision ?? []) {
70
82
  lines.push(` provider PID ${process.childPid ?? 'not started'}: ${process.state} · attempt ${process.retry + 1} · identity/liveness ${process.liveness ?? 'unknown'} · supervisor heartbeat ${relativeTime(process.heartbeatAt, now())} · last output ${process.lastOutputAt ? relativeTime(process.lastOutputAt, now()) : 'none'} · last successful tool/edit ${process.lastProgressAt ? relativeTime(process.lastProgressAt, now()) : 'none'}${process.reason ? ' · ' + process.reason : ''}`);
71
83
  }
@@ -77,7 +89,7 @@ export function loopStatus(targetDir, now = () => new Date()) {
77
89
  if (st.quality)
78
90
  lines.push(` quality: round ${st.quality.currentRound} · ${st.quality.usedRepairs}${st.quality.unbounded ? ' unbounded repairs' : `/${st.quality.maxRepairs ?? 0} repairs`} · ${st.quality.policy}`);
79
91
  if (st.parallel)
80
- lines.push(` parallel ${st.parallel.dispatcherId}: ${st.parallel.activeWorkers}/${st.parallel.maxConcurrency} workers · ${st.parallel.queuedCandidates} queued · ${st.parallel.integrated} integrated · ${st.parallel.reopened} reopened`);
92
+ lines.push(` parallel ${st.parallel.dispatcherId}: ${st.parallel.activeWorkers}/${st.parallel.maxConcurrency} workers (local; ${st.parallel.waitingWorkers ?? 0} waiting) · ${st.parallel.queuedIntegrations ?? st.parallel.queuedCandidates} queued for integration · ${st.parallel.integrated} integrated · ${st.parallel.reopened} reopened`);
81
93
  const integrator = st.parallel?.integrator;
82
94
  if (integrator) {
83
95
  const quality = integrator.quality
@@ -92,8 +104,13 @@ export function loopStatus(targetDir, now = () => new Date()) {
92
104
  const candidate = worker.candidateId ? ` candidate ${worker.candidateId} · ${worker.worktree ?? 'worktree unknown'} · ${worker.lifecycle ?? 'working'}` : '';
93
105
  lines.push(` worker ${worker.story} "${worker.storyTitle}" (${worker.provider}${worker.model ? `/${worker.model}` : ''})${candidate} · ${worker.phase ?? 'working'}${quality}`);
94
106
  }
95
- const ageMs = now().getTime() - Date.parse(st.updatedAt);
96
- if (st.state === 'running' && ageMs > STALE_MINUTES * 60_000) {
107
+ const nowMs = now().getTime();
108
+ const ageMs = nowMs - Date.parse(st.updatedAt);
109
+ const providerProgressMs = Math.max(0, ...((st.supervision ?? [])
110
+ .filter(process => process.liveness === 'alive' && process.lastProgressAt)
111
+ .map(process => Date.parse(process.lastProgressAt))
112
+ .filter(Number.isFinite)));
113
+ if (st.state === 'running' && ageMs > STALE_MINUTES * 60_000 && nowMs - providerProgressMs > STALE_MINUTES * 60_000) {
97
114
  lines.push(` ⚠ possibly stuck — no update in ${relativeTime(st.updatedAt, now())}`);
98
115
  }
99
116
  return lines.join('\n');
@@ -102,15 +119,376 @@ export function resolveIdleMs(flagMinutes, configMinutes) {
102
119
  const minutes = flagMinutes ?? configMinutes ?? DEFAULT_IDLE_MINUTES;
103
120
  return minutes > 0 ? minutes * 60_000 : 0;
104
121
  }
122
+ const DEFAULT_EXPLORE_INTERVAL_MINUTES = 30;
123
+ const EXPLORE_RETRY_BASE_MS = 30_000;
124
+ const EXPLORE_RETRY_MAX_MS = 15 * 60_000;
125
+ function consumeExplorePause(targetDir) {
126
+ const path = pauseFilePath(targetDir);
127
+ if (!existsSync(path))
128
+ return false;
129
+ try {
130
+ unlinkSync(path);
131
+ }
132
+ catch { /* a pause request still wins if cleanup fails */ }
133
+ return true;
134
+ }
135
+ function currentProgress(targetDir) {
136
+ try {
137
+ return progress(loadPrd(prdPath(targetDir)));
138
+ }
139
+ catch {
140
+ return { passed: 0, total: 0 };
141
+ }
142
+ }
143
+ function allCurrentStoriesPass(targetDir) {
144
+ try {
145
+ return allPass(loadPrd(prdPath(targetDir)));
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ }
151
+ async function waitForExplorePause(targetDir, durationMs, heartbeat, limitDeadline) {
152
+ const waitDeadline = Date.now() + durationMs;
153
+ let nextHeartbeat = Date.now() + 5 * 60_000;
154
+ while (Date.now() < waitDeadline && (limitDeadline === undefined || Date.now() < limitDeadline)) {
155
+ if (consumeExplorePause(targetDir))
156
+ return true;
157
+ if (Date.now() >= nextHeartbeat) {
158
+ heartbeat?.();
159
+ nextHeartbeat = Date.now() + 5 * 60_000;
160
+ }
161
+ const untilWaitEnds = waitDeadline - Date.now();
162
+ const untilLimit = limitDeadline === undefined ? untilWaitEnds : limitDeadline - Date.now();
163
+ await new Promise(resolve => setTimeout(resolve, Math.max(1, Math.min(1_000, untilWaitEnds, untilLimit))));
164
+ }
165
+ return consumeExplorePause(targetDir);
166
+ }
167
+ function retryProviders(targetDir, preferred, available = isAgentAvailable) {
168
+ let config;
169
+ try {
170
+ config = loadConfig(targetDir);
171
+ }
172
+ catch {
173
+ config = null;
174
+ }
175
+ const primary = resolveRunnerAgent(config, preferred, detectHostAgent());
176
+ return [...new Set([primary, ...(config?.agents ?? [])])].filter(agent => available(agent));
177
+ }
178
+ function retryReviewers(preferred, implementer, available) {
179
+ return [...new Set([...(preferred ? [preferred] : []), ...SUPPORTED_AGENTS])].filter(agent => agent !== implementer && available(agent));
180
+ }
181
+ function nextProvider(providers, current) {
182
+ if (!providers.length)
183
+ return current;
184
+ const index = current ? providers.indexOf(current) : -1;
185
+ return providers[(index + 1 + providers.length) % providers.length];
186
+ }
187
+ function retryDelay(failures) {
188
+ return Math.min(EXPLORE_RETRY_MAX_MS, EXPLORE_RETRY_BASE_MS * 2 ** Math.min(10, Math.max(0, failures - 1)));
189
+ }
190
+ function scheduleExploreLimitPause(targetDir, deadline) {
191
+ if (deadline === undefined)
192
+ return () => { };
193
+ let timer;
194
+ const check = () => {
195
+ const remaining = deadline - Date.now();
196
+ if (remaining <= 0) {
197
+ requestLoopPause(targetDir);
198
+ return;
199
+ }
200
+ timer = setTimeout(check, Math.min(remaining, 2_147_483_647));
201
+ timer.unref?.();
202
+ };
203
+ check();
204
+ return () => { if (timer)
205
+ clearTimeout(timer); };
206
+ }
207
+ function reportExploration(targetDir, summary, json, nextStep) {
208
+ const say = json ? (line) => console.error(line) : (line) => console.log(line);
209
+ let next = nextStep;
210
+ try {
211
+ if (!next) {
212
+ const story = selectNextStory(loadPrd(prdPath(targetDir)));
213
+ if (story)
214
+ next = `${story.id}: ${story.title}`;
215
+ }
216
+ }
217
+ catch { /* the persistent loop status keeps the parse failure visible */ }
218
+ say(`Exploration: ${summary}`);
219
+ say(`Next: ${next ?? 'scan the project again for evidenced improvements'}`);
220
+ }
221
+ async function runContinuousExploration(targetDir, options) {
222
+ if (options.isolate === false) {
223
+ console.error('--explore requires isolated story worktrees so failed work can be resumed safely.');
224
+ return 2;
225
+ }
226
+ const intervalMinutes = options.exploreIntervalMinutes ?? DEFAULT_EXPLORE_INTERVAL_MINUTES;
227
+ if (!Number.isInteger(intervalMinutes) || intervalMinutes < 1 || intervalMinutes > 1_440) {
228
+ console.error('--explore-interval must be an integer from 1 to 1440 minutes');
229
+ return 2;
230
+ }
231
+ if (options.exploreLimitMs !== undefined && (!Number.isSafeInteger(options.exploreLimitMs) || options.exploreLimitMs <= 0 || Date.now() + options.exploreLimitMs > 8_640_000_000_000_000)) {
232
+ console.error('--explore-limit must be a positive, supported duration');
233
+ return 2;
234
+ }
235
+ const limitDeadline = options.exploreLimitMs === undefined ? undefined : Date.now() + options.exploreLimitMs;
236
+ const reporter = options.reporter ?? makeReporter(targetDir, { json: options.json, quiet: true });
237
+ let config;
238
+ try {
239
+ config = loadConfig(targetDir);
240
+ }
241
+ catch {
242
+ config = null;
243
+ }
244
+ const defaultImplementationAgent = options.agent ?? resolveRunnerAgent(config, undefined, detectHostAgent());
245
+ const defaultExplorer = options.agent ?? config?.planning?.agent ?? defaultImplementationAgent;
246
+ const available = options.isAvailable ?? isAgentAvailable;
247
+ const reviewRequested = !options.reviewRunner && Boolean(options.review || options.reviewer);
248
+ const innerOptions = { ...options, isolate: true, explore: true, explorationSupervisor: true, reporter, quiet: true };
249
+ let usedIterations = 0;
250
+ let retryCount = 0;
251
+ let explorationFailures = 0;
252
+ let noActionScans = 0;
253
+ let implementationAgent = defaultImplementationAgent;
254
+ let reviewerAgent = options.reviewer ?? (reviewRequested ? SUPPORTED_AGENTS.find(agent => agent !== defaultImplementationAgent && available(agent)) : undefined);
255
+ let explorationAgent = defaultExplorer;
256
+ let retryWorktree = false;
257
+ const safeBatchSize = () => {
258
+ if (retryWorktree)
259
+ return 1;
260
+ const parallelSetting = options.parallelAuto ? 'auto' : options.parallel ?? config?.loop.parallel ?? 'auto';
261
+ if (parallelSetting !== 'auto')
262
+ return Math.max(1, parallelSetting);
263
+ try {
264
+ const pending = loadPrd(prdPath(targetDir)).filter(story => !story.passes);
265
+ if (!config?.actions?.length && pending.length > 1 && pending.every(story => story.writes?.length)) {
266
+ return Math.min(sharedPoolStatus().limit, pending.length);
267
+ }
268
+ }
269
+ catch { /* the inner command reports the underlying configuration or PRD error */ }
270
+ return 1;
271
+ };
272
+ const say = options.json ? (line) => console.error(line) : (line) => console.log(line);
273
+ reporter.phase('exploring', 'continuous exploration supervisor started', currentProgress(targetDir));
274
+ const limitReached = () => limitDeadline !== undefined && Date.now() >= limitDeadline;
275
+ const stopForTimeLimit = () => {
276
+ consumeExplorePause(targetDir);
277
+ const reason = 'the configured exploration time limit elapsed; stopped at a safe boundary';
278
+ reporter.paused(currentProgress(targetDir), reason);
279
+ say('Exploration time limit elapsed; the loop stopped at a safe boundary. Next: yoke loop run --explore .');
280
+ return 3;
281
+ };
282
+ const scanForWork = async (focus) => {
283
+ if (limitReached())
284
+ return 'limit';
285
+ const explorer = retryProviders(targetDir, explorationAgent, available);
286
+ const result = runPrdExplore(targetDir, {
287
+ runner: explorationAgent,
288
+ timeoutMinutes: options.timeoutMinutes,
289
+ isAvailable: options.isAvailable ?? isAgentAvailable,
290
+ onUsage: usage => reporter.addTokens(usage),
291
+ pause: () => consumeExplorePause(targetDir) || limitReached(),
292
+ ...(focus ? { focus } : {}),
293
+ });
294
+ if (result.kind === 'paused') {
295
+ if (limitReached())
296
+ return 'limit';
297
+ reporter.paused(currentProgress(targetDir));
298
+ say('Exploration paused at a safe boundary.');
299
+ return 'paused';
300
+ }
301
+ if (result.kind === 'added') {
302
+ explorationFailures = 0;
303
+ noActionScans = 0;
304
+ explorationAgent = defaultExplorer;
305
+ const titles = result.tasks.map(task => `${task.id}: ${task.title}`).join('; ');
306
+ reporter.phase('exploring', `accepted tasks are committed; implementation is next: ${titles}`, currentProgress(targetDir));
307
+ reportExploration(targetDir, `added ${titles}`, options.json ?? false);
308
+ return 'continue';
309
+ }
310
+ if (result.kind === 'none') {
311
+ explorationFailures = 0;
312
+ noActionScans++;
313
+ if (noActionScans % 3 === 0)
314
+ explorationAgent = nextProvider(explorer, explorationAgent) ?? defaultExplorer;
315
+ const wait = intervalMinutes * 60_000;
316
+ const waitingReason = `${focus ? `${focus}; ` : ''}${result.summary}; scanning again in ${intervalMinutes} minute(s)`;
317
+ reporter.phase('waiting-exploration', waitingReason, currentProgress(targetDir));
318
+ reportExploration(targetDir, 'no new task met the evidence and quality gates', options.json ?? false, `scan again in ${intervalMinutes} minute(s)`);
319
+ return await waitForExplorePause(targetDir, wait, () => reporter.phase('waiting-exploration', waitingReason, currentProgress(targetDir)), limitDeadline) ? 'paused' : 'continue';
320
+ }
321
+ explorationFailures++;
322
+ explorationAgent = nextProvider(explorer, result.provider ?? explorationAgent) ?? defaultExplorer;
323
+ const wait = retryDelay(explorationFailures);
324
+ const waitingReason = `${focus ? `${focus}; ` : ''}${result.summary}; retrying exploration in ${Math.ceil(wait / 1_000)} seconds`;
325
+ reporter.phase('waiting-recovery', waitingReason, currentProgress(targetDir));
326
+ reportExploration(targetDir, 'the explorer could not produce an admissible task', options.json ?? false, `retry in ${Math.ceil(wait / 1_000)} seconds`);
327
+ return await waitForExplorePause(targetDir, wait, () => reporter.phase('waiting-recovery', waitingReason, currentProgress(targetDir)), limitDeadline) ? 'paused' : 'continue';
328
+ };
329
+ const advanceRecoveryProviders = () => {
330
+ implementationAgent = nextProvider(retryProviders(targetDir, implementationAgent, available), implementationAgent) ?? implementationAgent;
331
+ if (reviewRequested)
332
+ reviewerAgent = nextProvider(retryReviewers(reviewerAgent, implementationAgent, available), reviewerAgent) ?? reviewerAgent;
333
+ };
334
+ for (;;) {
335
+ if (consumeExplorePause(targetDir)) {
336
+ reporter.paused(currentProgress(targetDir));
337
+ say(`Exploration paused. Next: yoke loop run --explore .`);
338
+ return 3;
339
+ }
340
+ if (limitReached())
341
+ return stopForTimeLimit();
342
+ const beforeStatus = readStatus(targetDir);
343
+ const priorProgress = currentProgress(targetDir);
344
+ const remaining = options.maxIterations === undefined ? undefined : Math.max(0, options.maxIterations - usedIterations);
345
+ if (remaining === 0) {
346
+ reporter.capReached(currentProgress(targetDir));
347
+ say(`Exploration reached --max=${options.maxIterations}. Next: yoke loop run --explore .`);
348
+ return 1;
349
+ }
350
+ let resultCode;
351
+ let unexpectedFailure;
352
+ const batchSize = limitDeadline === undefined ? undefined : safeBatchSize();
353
+ const batchLimit = batchSize === undefined ? remaining : Math.min(remaining ?? batchSize, batchSize);
354
+ const cancelTimePause = scheduleExploreLimitPause(targetDir, limitDeadline);
355
+ try {
356
+ resultCode = await Promise.resolve(runLoopCommand(targetDir, {
357
+ ...innerOptions,
358
+ agent: implementationAgent,
359
+ ...(reviewRequested && reviewerAgent ? { reviewer: reviewerAgent } : {}),
360
+ ...(batchLimit !== undefined ? { maxIterations: batchLimit } : {}),
361
+ ...(retryWorktree ? { resumeWorktree: true, parallel: 1, candidates: 1 } : {}),
362
+ }));
363
+ }
364
+ catch (error) {
365
+ resultCode = 1;
366
+ unexpectedFailure = error instanceof Error ? error.message : String(error);
367
+ }
368
+ finally {
369
+ cancelTimePause();
370
+ }
371
+ const afterStatus = readStatus(targetDir);
372
+ const changedStatus = afterStatus?.updatedAt !== beforeStatus?.updatedAt;
373
+ if (changedStatus && priorProgress.passed < priorProgress.total)
374
+ usedIterations += Math.max(0, afterStatus?.iteration ?? 0);
375
+ if (unexpectedFailure) {
376
+ retryCount++;
377
+ advanceRecoveryProviders();
378
+ const wait = retryDelay(retryCount);
379
+ const reason = `the loop stopped unexpectedly: ${unexpectedFailure}; retrying with ${implementationAgent} in ${Math.ceil(wait / 1_000)} seconds`;
380
+ reporter.phase('waiting-recovery', reason, currentProgress(targetDir));
381
+ say(`The loop stopped unexpectedly. Next: retry with ${implementationAgent} in ${Math.ceil(wait / 1_000)} seconds or pause with yoke loop pause .`);
382
+ if (await waitForExplorePause(targetDir, wait, () => reporter.phase('waiting-recovery', reason, currentProgress(targetDir)), limitDeadline)) {
383
+ reporter.paused(currentProgress(targetDir));
384
+ return 3;
385
+ }
386
+ continue;
387
+ }
388
+ if (resultCode === 3) {
389
+ if (limitReached())
390
+ return stopForTimeLimit();
391
+ say('Exploration paused. Next: yoke loop run --explore .');
392
+ return 3;
393
+ }
394
+ if (limitReached())
395
+ return stopForTimeLimit();
396
+ if (options.maxIterations !== undefined && usedIterations >= options.maxIterations) {
397
+ if (resultCode === 0) {
398
+ reporter.complete(currentProgress(targetDir));
399
+ say(`Current backlog complete; --max=${options.maxIterations} stopped further exploration. Next: yoke loop run --explore .`);
400
+ return 0;
401
+ }
402
+ reporter.capReached(currentProgress(targetDir));
403
+ say(`Exploration reached --max=${options.maxIterations} with work remaining. Next: yoke loop run --explore .`);
404
+ return 1;
405
+ }
406
+ if (resultCode === 0) {
407
+ retryCount = 0;
408
+ retryWorktree = false;
409
+ const availableProviders = retryProviders(targetDir, defaultImplementationAgent, options.isAvailable ?? isAgentAvailable);
410
+ implementationAgent = availableProviders.includes(defaultImplementationAgent) ? defaultImplementationAgent : availableProviders[0] ?? defaultImplementationAgent;
411
+ reporter.phase('exploring', 'the current accepted backlog is complete; scanning for the next verified improvement', currentProgress(targetDir));
412
+ const scan = await scanForWork();
413
+ if (scan === 'paused')
414
+ return 3;
415
+ if (scan === 'limit')
416
+ return stopForTimeLimit();
417
+ continue;
418
+ }
419
+ if (afterStatus?.state === 'cap-reached') {
420
+ if (limitDeadline !== undefined) {
421
+ retryCount = 0;
422
+ retryWorktree = false;
423
+ reporter.phase('exploring', 'the current safe task batch is complete; continuing the accepted backlog', currentProgress(targetDir));
424
+ continue;
425
+ }
426
+ say(`The current backlog is incomplete at the requested iteration cap. Next: yoke loop run --explore .`);
427
+ return 1;
428
+ }
429
+ let pendingDecision = false;
430
+ try {
431
+ pendingDecision = Boolean(readPendingDecision(targetDir));
432
+ }
433
+ catch {
434
+ pendingDecision = true;
435
+ }
436
+ if (pendingDecision || afterStatus?.reason?.includes('ambiguous acceptance criteria')) {
437
+ retryWorktree = false;
438
+ const wait = Math.min(intervalMinutes * 60_000, 60_000);
439
+ reporter.phase('waiting-recovery', `story ${afterStatus?.story ?? 'unknown'} needs a human decision before it can continue`, currentProgress(targetDir));
440
+ say(`Story ${afterStatus?.story ?? 'unknown'} is waiting for a human decision. Next: answer it or run yoke loop pause .`);
441
+ if (await waitForExplorePause(targetDir, wait, undefined, limitDeadline)) {
442
+ reporter.paused(currentProgress(targetDir));
443
+ return 3;
444
+ }
445
+ continue;
446
+ }
447
+ if (afterStatus?.reason?.startsWith('integrated completion gate failed') && allCurrentStoriesPass(targetDir)) {
448
+ retryWorktree = false;
449
+ reporter.phase('exploring', `all planned tasks pass, but ${afterStatus.reason}; looking for work that can resolve the completion gate`, currentProgress(targetDir));
450
+ const scan = await scanForWork(`The integrated completion gate is still failing: ${afterStatus.reason}`);
451
+ if (scan === 'paused')
452
+ return 3;
453
+ if (scan === 'limit')
454
+ return stopForTimeLimit();
455
+ continue;
456
+ }
457
+ if (resultCode === 2) {
458
+ retryCount++;
459
+ advanceRecoveryProviders();
460
+ const wait = retryDelay(retryCount);
461
+ reporter.phase('waiting-recovery', `the loop could not start; retrying with ${implementationAgent}${reviewerAgent ? ` and reviewer ${reviewerAgent}` : ''} in ${Math.ceil(wait / 1_000)} seconds`, currentProgress(targetDir));
462
+ say(`The unfinished loop could not start. Next: retry with ${implementationAgent}${reviewerAgent ? ` and reviewer ${reviewerAgent}` : ''} in ${Math.ceil(wait / 1_000)} seconds or pause with yoke loop pause .`);
463
+ if (await waitForExplorePause(targetDir, wait, undefined, limitDeadline)) {
464
+ reporter.paused(currentProgress(targetDir));
465
+ return 3;
466
+ }
467
+ continue;
468
+ }
469
+ retryCount++;
470
+ advanceRecoveryProviders();
471
+ retryWorktree = true;
472
+ const wait = retryDelay(retryCount);
473
+ reporter.phase('waiting-recovery', `story ${afterStatus?.story ?? 'unknown'} remains incomplete: ${afterStatus?.reason ?? 'no passing completion status'}; retrying with ${implementationAgent ?? 'the configured runner'} in ${Math.ceil(wait / 1_000)} seconds`, currentProgress(targetDir));
474
+ say(`The current task remains unfinished; its isolated work is retained. Next: retry with ${implementationAgent ?? 'the configured runner'} in ${Math.ceil(wait / 1_000)} seconds.`);
475
+ if (await waitForExplorePause(targetDir, wait, undefined, limitDeadline)) {
476
+ reporter.paused(currentProgress(targetDir));
477
+ return 3;
478
+ }
479
+ }
480
+ }
105
481
  export function runLoopCommand(targetDir, opts) {
482
+ if (opts.explore && !opts.explorationSupervisor)
483
+ return runContinuousExploration(targetDir, opts);
106
484
  let parallel = opts.parallel ?? 1;
107
485
  const candidates = opts.candidates ?? 1;
108
486
  if (opts.resumeWorktree && (opts.isolate === false || parallel !== 1 || candidates !== 1)) {
109
487
  console.error('--resume-worktree requires --isolate --parallel=1 --candidates=1');
110
488
  return 2;
111
489
  }
112
- if (!Number.isInteger(parallel) || parallel < 1) {
113
- console.error('--parallel must be a positive integer');
490
+ if (!Number.isInteger(parallel) || parallel < 1 || parallel > MAX_PROJECT_WORKERS) {
491
+ console.error(`--parallel must be an integer from 1 to ${MAX_PROJECT_WORKERS}`);
114
492
  return 2;
115
493
  }
116
494
  if (!Number.isInteger(candidates) || candidates < 1 || candidates > 5) {
@@ -151,14 +529,32 @@ export function runLoopCommand(targetDir, opts) {
151
529
  console.error(`No PRD found at ${path}. Create one (see canon loop/prd.schema.md).`);
152
530
  return 2;
153
531
  }
532
+ let sharedLimit;
533
+ try {
534
+ sharedLimit = sharedPoolStatus().limit;
535
+ }
536
+ catch (error) {
537
+ console.error(`Cannot read the shared Yoke worker pool: ${error.message}`);
538
+ return 2;
539
+ }
154
540
  const parallelSetting = opts.parallelAuto ? 'auto' : opts.parallel ?? config.loop.parallel ?? 'auto';
155
541
  if (parallelSetting === 'auto') {
156
- const pending = loadPrd(path).filter(story => !story.passes);
157
- parallel = !opts.resumeWorktree && !config.actions?.length && pending.length > 1 && pending.every(story => story.writes?.length) ? 3 : 1;
542
+ const stories = loadPrd(path);
543
+ const pending = stories.filter(story => !story.passes);
544
+ if (!opts.resumeWorktree && !config.actions?.length && pending.length > 1 && pending.every(story => story.writes?.length)) {
545
+ parallel = Math.min(sharedLimit, pending.length);
546
+ }
547
+ else
548
+ parallel = 1;
158
549
  }
159
550
  else
160
551
  parallel = parallelSetting;
552
+ if (candidates > sharedLimit) {
553
+ console.error(`--candidates=${candidates} exceeds the shared worker-pool limit (${sharedLimit}); raise YOKE_MAX_PARALLEL_WORKERS (maximum ${MAX_PROJECT_WORKERS}) or lower --candidates`);
554
+ return 2;
555
+ }
161
556
  const isolate = opts.isolate ?? config.loop.isolate ?? true;
557
+ const useParallelDispatcher = parallel > 1 || candidates > 1;
162
558
  if (opts.resumeWorktree && (!isolate || parallel !== 1)) {
163
559
  console.error('--resume-worktree requires isolation and one worker');
164
560
  return 2;
@@ -228,7 +624,8 @@ export function runLoopCommand(targetDir, opts) {
228
624
  }
229
625
  if (commitIdentity) {
230
626
  const announce = opts.json ? console.error : console.log;
231
- announce(`Commits: ${commitIdentity.authorName} <${commitIdentity.authorEmail}> · co-authors: ${commitIdentity.allowCoAuthors ? 'allowed' : 'disabled'}`);
627
+ if (!opts.quiet)
628
+ announce(`Commits: ${commitIdentity.authorName} <${commitIdentity.authorEmail}> · co-authors: ${commitIdentity.allowCoAuthors ? 'allowed' : 'disabled'}`);
232
629
  }
233
630
  const idleMs = resolveIdleMs(opts.timeoutMinutes, config.loop.timeoutMinutes);
234
631
  const qualityOverrides = {
@@ -239,7 +636,8 @@ export function runLoopCommand(targetDir, opts) {
239
636
  ...(opts.candidates !== undefined ? { candidates: opts.candidates } : {}),
240
637
  };
241
638
  if (qualityOverrides.qualityUnbounded) {
242
- console.error('WARNING: Quality repair limits are unbounded for this invocation. Mechanical gates, watchdog, isolation, and commit safety remain active.');
639
+ if (!opts.quiet)
640
+ console.error('WARNING: Quality repair limits are unbounded for this invocation. Mechanical gates, watchdog, isolation, and commit safety remain active.');
243
641
  }
244
642
  const configuredCriticAgent = config.quality?.critic?.agent ?? config.quality?.criticAgent ?? config.agents.find(agent => agent !== runnerAgent) ?? runnerAgent;
245
643
  const configuredCriticModel = config.quality?.critic?.model ?? config.quality?.criticModel ?? (configuredCriticAgent === runnerAgent ? config.runner?.model : undefined);
@@ -263,7 +661,8 @@ export function runLoopCommand(targetDir, opts) {
263
661
  const repairAgent = config.quality?.repair?.agent ?? config.quality?.repairAgent ?? runnerAgent;
264
662
  const limit = resolved.limits.unbounded ? 'unbounded' : `${resolved.limits.maxRounds ?? 3} rounds/${resolved.limits.maxMinutes ?? 60} minutes`;
265
663
  const announce = opts.json ? console.error : console.log;
266
- announce(`Quality: ${resolved.policy} · critic: ${criticAgent}${configuredCriticModel ? `/${configuredCriticModel}` : '/provider-default'} · repair: ${repairAgent}${config.quality?.repair?.model ?? config.quality?.repairModel ? `/${config.quality?.repair?.model ?? config.quality?.repairModel}` : '/provider-default'} · permissions: read-only critic/safe repair · budget: ${limit}`);
664
+ if (!opts.quiet)
665
+ announce(`Quality: ${resolved.policy} · critic: ${criticAgent}${configuredCriticModel ? `/${configuredCriticModel}` : '/provider-default'} · repair: ${repairAgent}${config.quality?.repair?.model ?? config.quality?.repairModel ? `/${config.quality?.repair?.model ?? config.quality?.repairModel}` : '/provider-default'} · permissions: read-only critic/safe repair · budget: ${limit}`);
267
666
  }
268
667
  const permissions = opts.permissions ?? config.runner?.permissions ?? 'safe';
269
668
  const routingRequested = opts.routing ?? config.routing?.enabled ?? true;
@@ -347,7 +746,7 @@ export function runLoopCommand(targetDir, opts) {
347
746
  }));
348
747
  let runner = opts.runner;
349
748
  if (!runner) {
350
- const requiredProviders = parallel > 1 || candidates > 1
749
+ const requiredProviders = useParallelDispatcher
351
750
  ? [...new Set(loadPrd(path).filter(story => !story.passes).map(story => story.agent ?? runnerAgent))]
352
751
  : loadPrd(path).filter(story => !story.passes).every(story => config.actions?.some(action => action.storyId === story.id)) ? [] : [runnerAgent];
353
752
  const unavailableProvider = requiredProviders.find(agent => !available(agent));
@@ -388,7 +787,8 @@ export function runLoopCommand(targetDir, opts) {
388
787
  })
389
788
  : makeRunner(runnerAgent, idleMs, runnerOpts);
390
789
  const announce = opts.json ? console.error : console.log;
391
- announce(`Runner: ${runnerAgent} · permissions: ${permissions} · routing: ${routingEnabled ? 'on' : routingRequested ? 'auto (parent; no worker profiles)' : 'off'} · workers: ${parallel} · isolation: ${isolate || parallel > 1 ? 'on' : 'off'} · cwd: ${targetDir}`);
790
+ if (!opts.quiet)
791
+ announce(`Runner: ${runnerAgent} · permissions: ${permissions} · routing: ${routingEnabled ? 'on' : routingRequested ? 'auto (parent; no worker profiles)' : 'off'} · workers: ${parallel} local / ${sharedLimit} shared maximum · isolation: ${isolate || parallel > 1 ? 'on' : 'off'} · cwd: ${targetDir}`);
392
792
  }
393
793
  if (config.actions?.length) {
394
794
  if (parallel > 1 || candidates > 1) {
@@ -441,6 +841,17 @@ export function runLoopCommand(targetDir, opts) {
441
841
  }
442
842
  };
443
843
  }
844
+ if (!useParallelDispatcher) {
845
+ if (runner) {
846
+ const unpooled = runner;
847
+ runner = context => withSharedWorkerSync({ targetDir, storyId: context.story.id, provider: runnerAgent, role: 'implementation', onWait: waitMs => executionReporter?.resourceWait?.({ id: context.story.id, title: context.story.title }, 'implementation', 1, waitMs, runnerAgent) }, () => unpooled(context));
848
+ }
849
+ if (review) {
850
+ const unpooledReview = review;
851
+ const provider = SUPPORTED_AGENTS.includes(reviewProvider) ? reviewProvider : runnerAgent;
852
+ review = context => withSharedWorkerSync({ targetDir, storyId: context.story.id, provider, role: 'integration', onWait: waitMs => executionReporter?.resourceWait?.({ id: context.story.id, title: context.story.title }, 'integration', 1, waitMs, provider) }, () => unpooledReview(context));
853
+ }
854
+ }
444
855
  let lock;
445
856
  try {
446
857
  lock = acquireLock(targetDir);
@@ -456,7 +867,7 @@ export function runLoopCommand(targetDir, opts) {
456
867
  if (lock.stalePid !== undefined) {
457
868
  console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
458
869
  }
459
- const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json });
870
+ const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json, quiet: opts.quiet });
460
871
  executionReporter = reporter;
461
872
  const buildResume = (storyId, requestId) => buildTrustedDecisionResumeState({
462
873
  storyId,
@@ -496,7 +907,7 @@ export function runLoopCommand(targetDir, opts) {
496
907
  return 1;
497
908
  }
498
909
  };
499
- if (parallel > 1 || candidates > 1) {
910
+ if (useParallelDispatcher) {
500
911
  return runParallelLoopCommand({
501
912
  targetDir,
502
913
  prdPath: path,
@@ -557,11 +968,13 @@ export function runLoopCommand(targetDir, opts) {
557
968
  return resumeCode;
558
969
  // In json mode stdout belongs to the NDJSON stream — route the narrative summary to stderr.
559
970
  const say = opts.json ? (line) => console.error(line) : (line) => console.log(line);
560
- say(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`);
561
- if (result.reason)
562
- say(`Reason: ${result.reason}`);
563
- if (result.reason && /api key|please run \/login|not logged in|auth/i.test(result.reason)) {
564
- say('Hint: the agent CLI has no credentials in this environment. Set ANTHROPIC_API_KEY, GEMINI_API_KEY, or OPENAI_API_KEY, or log the agent in for headless use.');
971
+ if (!opts.quiet) {
972
+ say(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`);
973
+ if (result.reason)
974
+ say(`Reason: ${result.reason}`);
975
+ if (result.reason && /api key|please run \/login|not logged in|auth/i.test(result.reason)) {
976
+ say('Hint: the agent CLI has no credentials in this environment. Set ANTHROPIC_API_KEY, GEMINI_API_KEY, or OPENAI_API_KEY, or log the agent in for headless use.');
977
+ }
565
978
  }
566
979
  // Exit codes: 0 complete · 1 blocked/cap-reached · 2 config error (handled above) · 3 paused (loop.pause consumed at a story boundary)
567
980
  if (result.status === 'complete')