@hecer/yoke 1.17.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';
@@ -26,6 +26,7 @@ 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
28
  import { MAX_PROJECT_WORKERS, sharedPoolStatus, withSharedWorkerSync } from './resource-pool.js';
29
+ import { runPrdExplore } from '../prd/explore.js';
29
30
  export const DEFAULT_IDLE_MINUTES = 20;
30
31
  const STALE_MINUTES = 20; // a running status older than this likely means the loop died
31
32
  export function relativeTime(fromIso, now) {
@@ -103,8 +104,13 @@ export function loopStatus(targetDir, now = () => new Date()) {
103
104
  const candidate = worker.candidateId ? ` candidate ${worker.candidateId} · ${worker.worktree ?? 'worktree unknown'} · ${worker.lifecycle ?? 'working'}` : '';
104
105
  lines.push(` worker ${worker.story} "${worker.storyTitle}" (${worker.provider}${worker.model ? `/${worker.model}` : ''})${candidate} · ${worker.phase ?? 'working'}${quality}`);
105
106
  }
106
- const ageMs = now().getTime() - Date.parse(st.updatedAt);
107
- 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) {
108
114
  lines.push(` ⚠ possibly stuck — no update in ${relativeTime(st.updatedAt, now())}`);
109
115
  }
110
116
  return lines.join('\n');
@@ -113,7 +119,368 @@ export function resolveIdleMs(flagMinutes, configMinutes) {
113
119
  const minutes = flagMinutes ?? configMinutes ?? DEFAULT_IDLE_MINUTES;
114
120
  return minutes > 0 ? minutes * 60_000 : 0;
115
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
+ }
116
481
  export function runLoopCommand(targetDir, opts) {
482
+ if (opts.explore && !opts.explorationSupervisor)
483
+ return runContinuousExploration(targetDir, opts);
117
484
  let parallel = opts.parallel ?? 1;
118
485
  const candidates = opts.candidates ?? 1;
119
486
  if (opts.resumeWorktree && (opts.isolate === false || parallel !== 1 || candidates !== 1)) {
@@ -257,7 +624,8 @@ export function runLoopCommand(targetDir, opts) {
257
624
  }
258
625
  if (commitIdentity) {
259
626
  const announce = opts.json ? console.error : console.log;
260
- 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'}`);
261
629
  }
262
630
  const idleMs = resolveIdleMs(opts.timeoutMinutes, config.loop.timeoutMinutes);
263
631
  const qualityOverrides = {
@@ -268,7 +636,8 @@ export function runLoopCommand(targetDir, opts) {
268
636
  ...(opts.candidates !== undefined ? { candidates: opts.candidates } : {}),
269
637
  };
270
638
  if (qualityOverrides.qualityUnbounded) {
271
- 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.');
272
641
  }
273
642
  const configuredCriticAgent = config.quality?.critic?.agent ?? config.quality?.criticAgent ?? config.agents.find(agent => agent !== runnerAgent) ?? runnerAgent;
274
643
  const configuredCriticModel = config.quality?.critic?.model ?? config.quality?.criticModel ?? (configuredCriticAgent === runnerAgent ? config.runner?.model : undefined);
@@ -292,7 +661,8 @@ export function runLoopCommand(targetDir, opts) {
292
661
  const repairAgent = config.quality?.repair?.agent ?? config.quality?.repairAgent ?? runnerAgent;
293
662
  const limit = resolved.limits.unbounded ? 'unbounded' : `${resolved.limits.maxRounds ?? 3} rounds/${resolved.limits.maxMinutes ?? 60} minutes`;
294
663
  const announce = opts.json ? console.error : console.log;
295
- 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}`);
296
666
  }
297
667
  const permissions = opts.permissions ?? config.runner?.permissions ?? 'safe';
298
668
  const routingRequested = opts.routing ?? config.routing?.enabled ?? true;
@@ -417,7 +787,8 @@ export function runLoopCommand(targetDir, opts) {
417
787
  })
418
788
  : makeRunner(runnerAgent, idleMs, runnerOpts);
419
789
  const announce = opts.json ? console.error : console.log;
420
- 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}`);
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}`);
421
792
  }
422
793
  if (config.actions?.length) {
423
794
  if (parallel > 1 || candidates > 1) {
@@ -496,7 +867,7 @@ export function runLoopCommand(targetDir, opts) {
496
867
  if (lock.stalePid !== undefined) {
497
868
  console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
498
869
  }
499
- const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json });
870
+ const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json, quiet: opts.quiet });
500
871
  executionReporter = reporter;
501
872
  const buildResume = (storyId, requestId) => buildTrustedDecisionResumeState({
502
873
  storyId,
@@ -597,11 +968,13 @@ export function runLoopCommand(targetDir, opts) {
597
968
  return resumeCode;
598
969
  // In json mode stdout belongs to the NDJSON stream — route the narrative summary to stderr.
599
970
  const say = opts.json ? (line) => console.error(line) : (line) => console.log(line);
600
- say(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`);
601
- if (result.reason)
602
- say(`Reason: ${result.reason}`);
603
- if (result.reason && /api key|please run \/login|not logged in|auth/i.test(result.reason)) {
604
- 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
+ }
605
978
  }
606
979
  // Exit codes: 0 complete · 1 blocked/cap-reached · 2 config error (handled above) · 3 paused (loop.pause consumed at a story boundary)
607
980
  if (result.status === 'complete')