@hecer/yoke 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +22 -0
  4. package/README.md +67 -15
  5. package/TODOS.md +0 -3
  6. package/canon/loop/loop-spec.md +22 -8
  7. package/canon/manifest.yaml +1 -1
  8. package/dist/agents/contracts.js +50 -0
  9. package/dist/agents/process-incarnation.js +15 -0
  10. package/dist/agents/process-record.js +65 -0
  11. package/dist/agents/process-streams.js +40 -0
  12. package/dist/agents/process.js +177 -0
  13. package/dist/agents/providers.js +10 -7
  14. package/dist/agents/telemetry.js +62 -0
  15. package/dist/cli.js +55 -3
  16. package/dist/loop/candidate-boundaries.js +43 -0
  17. package/dist/loop/candidate-cleanup.js +98 -0
  18. package/dist/loop/candidate-contracts.js +1 -0
  19. package/dist/loop/candidate-selection.js +84 -0
  20. package/dist/loop/candidates.js +228 -0
  21. package/dist/loop/claim-lease.js +131 -0
  22. package/dist/loop/claims.js +177 -40
  23. package/dist/loop/cleanup.js +117 -15
  24. package/dist/loop/decision.js +31 -0
  25. package/dist/loop/dispatcher.js +334 -0
  26. package/dist/loop/loop.js +109 -16
  27. package/dist/loop/merge-queue.js +12 -6
  28. package/dist/loop/parallel-adapters.js +185 -0
  29. package/dist/loop/parallel-command.js +287 -0
  30. package/dist/loop/parallel.js +2 -4
  31. package/dist/loop/prd.js +4 -1
  32. package/dist/loop/reporter.js +86 -5
  33. package/dist/loop/run-command.js +204 -51
  34. package/dist/loop/runner.js +67 -32
  35. package/dist/loop/watchdog.js +67 -8
  36. package/dist/loop/worker-cancellation.js +17 -0
  37. package/dist/loop/worker-cleanup.js +23 -0
  38. package/dist/loop/worker-contracts.js +1 -0
  39. package/dist/loop/worker.js +254 -0
  40. package/dist/quality/artifacts.js +59 -0
  41. package/dist/quality/candidate-comparison.js +130 -0
  42. package/dist/quality/command.js +316 -0
  43. package/dist/quality/loop.js +86 -0
  44. package/dist/quality/process-command.js +57 -0
  45. package/dist/quality/reference.js +187 -0
  46. package/dist/quality/repair.js +11 -0
  47. package/dist/quality/runner.js +66 -0
  48. package/dist/quality/types.js +60 -0
  49. package/dist/quality/verdict.js +142 -0
  50. package/dist/retrofit/config.js +4 -2
  51. package/dist/retrofit/gitignore.js +3 -0
  52. package/dist/review/command.js +27 -38
  53. package/dist/review/verdict.js +38 -7
  54. package/docs/MIGRATING-TO-1.4.md +70 -0
  55. package/docs/PUBLISHING.md +16 -2
  56. package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
  57. package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
  58. package/gemini-extension.json +1 -1
  59. package/package.json +1 -1
@@ -12,9 +12,12 @@ import { maybeAutoUpgrade } from '../update/upgrade.js';
12
12
  import { resolveCommitIdentity } from './identity.js';
13
13
  import { runAudit } from '../audit/command.js';
14
14
  import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
15
- import { clearDecisionResume, decisionProcessingExists, decisionRequestId, formatPendingDecision, readPendingDecision, writeDecisionResume, } from './decision.js';
15
+ import { buildTrustedDecisionResumeState, clearDecisionResume, decisionProcessingExists, decisionRequestId, formatPendingDecision, readPendingDecision, writeDecisionResume, } from './decision.js';
16
16
  import { makeAdaptiveRunner } from '../routing/router.js';
17
17
  import { runChangeApply } from '../change/inbox.js';
18
+ import { createQualityCommandHooks } from '../quality/command.js';
19
+ import { resolveQualityPolicy } from '../quality/types.js';
20
+ import { runParallelLoopCommand } from './parallel-command.js';
18
21
  export const DEFAULT_IDLE_MINUTES = 20;
19
22
  const STALE_MINUTES = 20; // a running status older than this likely means the loop died
20
23
  export function relativeTime(fromIso, now) {
@@ -61,6 +64,24 @@ export function loopStatus(targetDir, now = () => new Date()) {
61
64
  }
62
65
  if (st.reason)
63
66
  lines.push(` reason: ${st.reason}`);
67
+ if (st.quality)
68
+ lines.push(` quality: round ${st.quality.currentRound} · ${st.quality.usedRepairs}${st.quality.unbounded ? ' unbounded repairs' : `/${st.quality.maxRepairs ?? 0} repairs`} · ${st.quality.policy}`);
69
+ if (st.parallel)
70
+ 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`);
71
+ const integrator = st.parallel?.integrator;
72
+ if (integrator) {
73
+ const quality = integrator.quality
74
+ ? ` · quality round ${integrator.quality.currentRound} · ${integrator.quality.usedRepairs}${integrator.quality.unbounded ? ' unbounded repairs' : `/${integrator.quality.maxRepairs ?? 0} repairs`}`
75
+ : '';
76
+ lines.push(` integrator ${integrator.story} "${integrator.storyTitle}" (${integrator.provider}${integrator.model ? `/${integrator.model}` : ''}) · ${integrator.phase ?? 'working'}${quality}`);
77
+ }
78
+ for (const worker of st.parallel?.workers ?? []) {
79
+ const quality = worker.quality
80
+ ? ` · quality round ${worker.quality.currentRound} · ${worker.quality.usedRepairs}${worker.quality.unbounded ? ' unbounded repairs' : `/${worker.quality.maxRepairs ?? 0} repairs`}`
81
+ : '';
82
+ const candidate = worker.candidateId ? ` candidate ${worker.candidateId} · ${worker.worktree ?? 'worktree unknown'} · ${worker.lifecycle ?? 'working'}` : '';
83
+ lines.push(` worker ${worker.story} "${worker.storyTitle}" (${worker.provider}${worker.model ? `/${worker.model}` : ''})${candidate} · ${worker.phase ?? 'working'}${quality}`);
84
+ }
64
85
  const ageMs = now().getTime() - Date.parse(st.updatedAt);
65
86
  if (st.state === 'running' && ageMs > STALE_MINUTES * 60_000) {
66
87
  lines.push(` ⚠ possibly stuck — no update in ${relativeTime(st.updatedAt, now())}`);
@@ -72,11 +93,27 @@ export function resolveIdleMs(flagMinutes, configMinutes) {
72
93
  return minutes > 0 ? minutes * 60_000 : 0;
73
94
  }
74
95
  export function runLoopCommand(targetDir, opts) {
75
- if ((opts.parallel ?? 1) > 1) {
76
- console.error('Parallel CLI workers are not enabled yet. The dependency-aware dispatcher and merge queue are available as APIs; use --parallel=1 for the synchronous provider runner.');
96
+ const parallel = opts.parallel ?? 1;
97
+ const candidates = opts.candidates ?? 1;
98
+ if (!Number.isInteger(parallel) || parallel < 1) {
99
+ console.error('--parallel must be a positive integer');
100
+ return 2;
101
+ }
102
+ if (!Number.isInteger(candidates) || candidates < 1 || candidates > 5) {
103
+ console.error('--candidates must be an integer from 1 to 5');
77
104
  return 2;
78
105
  }
79
106
  const config = loadConfig(targetDir);
107
+ const maxParallelCandidates = config?.quality?.maxParallelCandidates ?? 1;
108
+ if (candidates > maxParallelCandidates) {
109
+ console.error(`--candidates=${candidates} exceeds quality.maxParallelCandidates=${maxParallelCandidates}`);
110
+ return 2;
111
+ }
112
+ const qualityDisabled = opts.quality === false || (!config?.quality?.enabled && opts.quality !== true && opts.qualityUnbounded !== true);
113
+ if (candidates > 1 && qualityDisabled) {
114
+ console.error(`--candidates=${candidates} requires quality; quality cannot be disabled for candidate dispatch.`);
115
+ return 2;
116
+ }
80
117
  if (!config?.loop.enabled) {
81
118
  console.error('Loop is disabled. Enable it with: yoke loop on');
82
119
  return 2;
@@ -100,6 +137,13 @@ export function runLoopCommand(targetDir, opts) {
100
137
  console.error(`No PRD found at ${path}. Create one (see canon loop/prd.schema.md).`);
101
138
  return 2;
102
139
  }
140
+ if (candidates > 1) {
141
+ const missingQuality = loadPrd(path).find(story => !story.passes && !story.quality);
142
+ if (missingQuality) {
143
+ console.error(`Story ${missingQuality.id} needs a quality declaration before --candidates=${candidates} can dispatch.`);
144
+ return 2;
145
+ }
146
+ }
103
147
  let verify = opts.verify;
104
148
  if (!verify) {
105
149
  const command = resolveVerifyCommand(targetDir, config);
@@ -146,8 +190,84 @@ export function runLoopCommand(targetDir, opts) {
146
190
  announce(`Commits: ${commitIdentity.authorName} <${commitIdentity.authorEmail}> · co-authors: ${commitIdentity.allowCoAuthors ? 'allowed' : 'disabled'}`);
147
191
  }
148
192
  const idleMs = resolveIdleMs(opts.timeoutMinutes, config.loop.timeoutMinutes);
193
+ const qualityOverrides = {
194
+ ...(opts.qualityUnbounded ? { quality: true, qualityUnbounded: true } : opts.quality !== undefined ? { quality: opts.quality } : {}),
195
+ ...(opts.qualityRounds !== undefined ? { qualityRounds: opts.qualityRounds } : {}),
196
+ ...(opts.qualityMinutes !== undefined ? { qualityMinutes: opts.qualityMinutes } : {}),
197
+ ...(opts.qualityPolicy ? { qualityPolicy: opts.qualityPolicy } : {}),
198
+ ...(opts.candidates !== undefined ? { candidates: opts.candidates } : {}),
199
+ };
200
+ if (qualityOverrides.qualityUnbounded) {
201
+ console.error('WARNING: Quality repair limits are unbounded for this invocation. Mechanical gates, watchdog, isolation, and commit safety remain active.');
202
+ }
203
+ const configuredCriticAgent = config.quality?.critic?.agent ?? config.quality?.criticAgent ?? config.agents.find(agent => agent !== runnerAgent) ?? runnerAgent;
204
+ const configuredCriticModel = config.quality?.critic?.model ?? config.quality?.criticModel ?? (configuredCriticAgent === runnerAgent ? config.runner?.model : undefined);
205
+ if (candidates > 1 && !configuredCriticModel) {
206
+ console.error('Quality candidate selection requires quality.critic.model (or legacy quality.criticModel) before any runner or worktree is started.');
207
+ return 2;
208
+ }
209
+ const quality = createQualityCommandHooks({
210
+ targetDir,
211
+ config,
212
+ runnerAgent,
213
+ idleMs,
214
+ policy: qualityOverrides,
215
+ ...(opts.qualityRuntime ? { runtime: opts.qualityRuntime } : {}),
216
+ });
217
+ if (quality) {
218
+ const resolved = resolveQualityPolicy({ defaults: config.quality, overrides: qualityOverrides });
219
+ const criticAgent = configuredCriticAgent;
220
+ const repairAgent = config.quality?.repair?.agent ?? config.quality?.repairAgent ?? runnerAgent;
221
+ const limit = resolved.limits.unbounded ? 'unbounded' : `${resolved.limits.maxRounds ?? 3} rounds/${resolved.limits.maxMinutes ?? 60} minutes`;
222
+ const announce = opts.json ? console.error : console.log;
223
+ 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}`);
224
+ }
149
225
  const permissions = opts.permissions ?? config.runner?.permissions ?? 'safe';
150
226
  const routingEnabled = opts.routing ?? config.routing?.enabled ?? false;
227
+ const runnerSelection = {
228
+ model: config.runner?.model,
229
+ reasoningEffort: config.runner?.reasoningEffort,
230
+ bare: config.runner?.bare,
231
+ ...((routingEnabled || opts.routing === false) ? { nativeMultiAgent: false } : {}),
232
+ };
233
+ if ((parallel > 1 || candidates > 1) && routingEnabled) {
234
+ console.error('Adaptive routing is not available with parallel workers or quality candidates. Run with --parallel=1 --candidates=1 or disable routing.');
235
+ return 2;
236
+ }
237
+ const parallelProviders = [{
238
+ provider: runnerAgent,
239
+ ...(runnerSelection.model ? { model: runnerSelection.model } : {}),
240
+ ...(runnerSelection.reasoningEffort ? { reasoningEffort: runnerSelection.reasoningEffort } : {}),
241
+ }];
242
+ const parallelAffinityProviders = (config.routing?.workers ?? []).map(worker => ({
243
+ provider: worker.agent,
244
+ ...(worker.model ? { model: worker.model } : {}),
245
+ ...(worker.reasoningEffort ? { reasoningEffort: worker.reasoningEffort } : {}),
246
+ }));
247
+ const parallelStories = parallel > 1 || candidates > 1 ? loadPrd(path).filter(story => !story.passes) : [];
248
+ const ambiguousAffinityProvider = [...new Set(parallelStories.flatMap(story => story.agent ? [story.agent] : []))]
249
+ .find(agent => parallelAffinityProviders.filter(provider => provider.provider === agent).length > 1);
250
+ if (ambiguousAffinityProvider) {
251
+ console.error(`Parallel affinity provider "${ambiguousAffinityProvider}" has multiple profiles. Configure exactly one profile for each story agent.`);
252
+ return 2;
253
+ }
254
+ if (opts.runner) {
255
+ const mismatchedAffinity = parallelStories.find(story => {
256
+ if (!story.agent)
257
+ return false;
258
+ const provider = parallelAffinityProviders.find(candidate => candidate.provider === story.agent)
259
+ ?? parallelProviders.find(candidate => candidate.provider === story.agent);
260
+ return !provider
261
+ || provider.provider !== runnerAgent
262
+ || provider.model !== runnerSelection.model
263
+ || provider.reasoningEffort !== runnerSelection.reasoningEffort;
264
+ });
265
+ if (mismatchedAffinity) {
266
+ console.error(`Injected runner cannot truthfully execute affinity provider for story ${mismatchedAffinity.id}. Remove the affinity or use the configured provider runner.`);
267
+ return 2;
268
+ }
269
+ }
270
+ const ambiguityPolicy = opts.decisionPolicy ?? opts.onAmbiguity ?? config.loop.decisionPolicy ?? config.loop.onAmbiguity ?? 'auto';
151
271
  if (routingEnabled && (!config.routing || config.routing.workers.length === 0)) {
152
272
  console.error('Adaptive routing was requested, but no worker profiles are configured. Run yoke setup . --routing or add routing.workers to .yoke/config.yaml.');
153
273
  return 2;
@@ -167,23 +287,22 @@ export function runLoopCommand(targetDir, opts) {
167
287
  }));
168
288
  let runner = opts.runner;
169
289
  if (!runner) {
170
- if (!available(runnerAgent)) {
171
- console.error(`Agent CLI "${runnerAgent}" was not found on PATH. Install it, or pick another with --runner=<claude|codex|gemini>.`);
290
+ const requiredProviders = parallel > 1 || candidates > 1
291
+ ? [...new Set(loadPrd(path).filter(story => !story.passes).map(story => story.agent ?? runnerAgent))]
292
+ : [runnerAgent];
293
+ const unavailableProvider = requiredProviders.find(agent => !available(agent));
294
+ if (unavailableProvider) {
295
+ console.error(`Agent CLI "${unavailableProvider}" was not found on PATH. Install it, or pick another with --runner=<claude|codex|gemini>.`);
172
296
  return 2;
173
297
  }
174
298
  // Token reporting is part of the machine interface: in --json mode a claude
175
299
  // runner switches to stream-json so cumulative usage rides on every status.
176
300
  const runnerOpts = {
177
301
  tokenReport: opts.json === true,
178
- onAmbiguity: opts.decisionPolicy ?? opts.onAmbiguity ?? config.loop.decisionPolicy ?? config.loop.onAmbiguity ?? 'auto',
302
+ onAmbiguity: ambiguityPolicy,
179
303
  perfCommand: config.perf?.command,
180
304
  permissions,
181
- selection: {
182
- model: config.runner?.model,
183
- reasoningEffort: config.runner?.reasoningEffort,
184
- bare: config.runner?.bare,
185
- ...((routingEnabled || opts.routing === false) ? { nativeMultiAgent: false } : {}),
186
- },
305
+ selection: runnerSelection,
187
306
  };
188
307
  runner = routingEnabled && config.routing
189
308
  ? makeAdaptiveRunner({
@@ -237,8 +356,75 @@ export function runLoopCommand(targetDir, opts) {
237
356
  if (lock.stalePid !== undefined) {
238
357
  console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
239
358
  }
359
+ const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json });
360
+ const buildResume = (storyId, requestId) => buildTrustedDecisionResumeState({
361
+ storyId,
362
+ requestId,
363
+ ...(opts.maxIterations !== undefined ? { maxIterations: opts.maxIterations } : {}),
364
+ agent: runnerAgent,
365
+ isolate: parallel > 1 || candidates > 1 || (opts.isolate ?? false),
366
+ reviewer: opts.reviewer,
367
+ review: opts.review === true || opts.reviewRunner !== undefined,
368
+ allowSelfReview: opts.allowSelfReview ?? false,
369
+ timeoutMinutes: opts.timeoutMinutes ?? config.loop.timeoutMinutes,
370
+ json: opts.json ?? false,
371
+ onAmbiguity: opts.decisionPolicy ? undefined : opts.onAmbiguity === 'resolve' || opts.onAmbiguity === 'abort' ? opts.onAmbiguity : (config.loop.decisionPolicy ? undefined : config.loop.onAmbiguity),
372
+ decisionPolicy: opts.decisionPolicy ?? (opts.onAmbiguity ? (opts.onAmbiguity === 'auto' || opts.onAmbiguity === 'critical' ? opts.onAmbiguity : undefined) : config.loop.decisionPolicy),
373
+ permissions,
374
+ parallel,
375
+ routing: routingEnabled,
376
+ ...(qualityOverrides.quality !== undefined ? { quality: qualityOverrides.quality } : {}),
377
+ ...(opts.qualityRounds !== undefined ? { qualityRounds: opts.qualityRounds } : {}),
378
+ ...(opts.qualityMinutes !== undefined ? { qualityMinutes: opts.qualityMinutes } : {}),
379
+ ...(opts.qualityPolicy ? { qualityPolicy: opts.qualityPolicy } : {}),
380
+ ...(opts.candidates !== undefined ? { candidates: opts.candidates } : {}),
381
+ });
382
+ const reconcileDecisionResume = () => {
383
+ try {
384
+ const pendingDecision = readPendingDecision(targetDir);
385
+ if (pendingDecision) {
386
+ writeDecisionResume(targetDir, buildResume(pendingDecision.storyId, decisionRequestId(pendingDecision)));
387
+ }
388
+ else {
389
+ clearDecisionResume(targetDir);
390
+ }
391
+ return undefined;
392
+ }
393
+ catch (error) {
394
+ reporter.blocked(`could not persist trusted decision resume state: ${error instanceof Error ? error.message : String(error)}`);
395
+ return 1;
396
+ }
397
+ };
398
+ if (parallel > 1 || candidates > 1) {
399
+ return runParallelLoopCommand({
400
+ targetDir,
401
+ prdPath: path,
402
+ maxConcurrency: parallel,
403
+ candidateCount: candidates,
404
+ maxIterations: opts.maxIterations ?? Number.POSITIVE_INFINITY,
405
+ runner: opts.runner,
406
+ runnerAgent,
407
+ idleMs,
408
+ permissions,
409
+ selection: runnerSelection,
410
+ providers: parallelProviders,
411
+ affinityProviders: parallelAffinityProviders,
412
+ onAmbiguity: ambiguityPolicy,
413
+ git: opts.git,
414
+ identity: commitIdentity,
415
+ verify,
416
+ verifyCriterion: (dir, _story, criterion) => commandsVerifier(criterion.verify)(dir),
417
+ requireCriterionEvidence: config.verify?.requireCriteria ?? false,
418
+ perf,
419
+ audit,
420
+ review,
421
+ reporter,
422
+ completion,
423
+ quality,
424
+ onCriticalDecision: decision => writeDecisionResume(targetDir, buildResume(decision.storyId, decisionRequestId(decision))),
425
+ }).then(code => reconcileDecisionResume() ?? code).finally(() => releaseLock(targetDir, lock.ownerToken));
426
+ }
240
427
  try {
241
- const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json });
242
428
  const maxIterations = opts.maxIterations ?? Number.POSITIVE_INFINITY;
243
429
  const result = runLoop({
244
430
  prdPath: path,
@@ -257,45 +443,12 @@ export function runLoopCommand(targetDir, opts) {
257
443
  isolate: (opts.parallel ?? 1) > 1 ? true : (opts.isolate ?? false),
258
444
  review,
259
445
  reporter,
446
+ ...(quality ?? {}),
447
+ ...(quality ? { qualityEnabled: quality.qualityEnabled, qualityMetadata: quality.qualityMetadata } : {}),
260
448
  });
261
- try {
262
- const pendingDecision = readPendingDecision(targetDir);
263
- if (pendingDecision) {
264
- writeDecisionResume(targetDir, {
265
- version: 1,
266
- storyId: pendingDecision.storyId,
267
- requestId: decisionRequestId(pendingDecision),
268
- answered: false,
269
- ...(opts.maxIterations !== undefined ? { maxIterations: opts.maxIterations } : {}),
270
- agent: runnerAgent,
271
- isolate: opts.isolate ?? false,
272
- reviewer: opts.reviewer,
273
- review: opts.review === true || opts.reviewRunner !== undefined,
274
- allowSelfReview: opts.allowSelfReview ?? false,
275
- timeoutMinutes: opts.timeoutMinutes ?? config.loop.timeoutMinutes,
276
- json: opts.json ?? false,
277
- onAmbiguity: opts.decisionPolicy
278
- ? undefined
279
- : opts.onAmbiguity === 'resolve' || opts.onAmbiguity === 'abort'
280
- ? opts.onAmbiguity
281
- : (config.loop.decisionPolicy ? undefined : config.loop.onAmbiguity),
282
- decisionPolicy: opts.decisionPolicy
283
- ?? (opts.onAmbiguity
284
- ? (opts.onAmbiguity === 'auto' || opts.onAmbiguity === 'critical' ? opts.onAmbiguity : undefined)
285
- : config.loop.decisionPolicy),
286
- permissions,
287
- parallel: opts.parallel ?? 1,
288
- routing: routingEnabled,
289
- });
290
- }
291
- else
292
- clearDecisionResume(targetDir);
293
- }
294
- catch (error) {
295
- const reason = `could not persist trusted decision resume state: ${error.message}`;
296
- reporter.blocked(reason);
297
- return 1;
298
- }
449
+ const resumeCode = reconcileDecisionResume();
450
+ if (resumeCode !== undefined)
451
+ return resumeCode;
299
452
  // In json mode stdout belongs to the NDJSON stream — route the narrative summary to stderr.
300
453
  const say = opts.json ? (line) => console.error(line) : (line) => console.log(line);
301
454
  say(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`);
@@ -1,12 +1,13 @@
1
1
  import { isAcceptanceCriterion } from './prd.js';
2
2
  import { execFileSync, execSync } from 'node:child_process';
3
- import { existsSync, mkdirSync, rmSync } from 'node:fs';
3
+ import { existsSync } from 'node:fs';
4
+ import { createRequire } from 'node:module';
4
5
  import { join } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
7
  import { loadContext, formatForPrompt, contextDir } from '../context/context.js';
7
- import { buildProviderInvocation } from '../agents/providers.js';
8
- import { parseProviderTelemetry } from '../agents/telemetry.js';
9
- import { formatReviewContract, readReviewVerdict, reviewVerdictPath } from '../review/verdict.js';
8
+ import { buildProviderInvocation, startProviderProcess } from '../agents/providers.js';
9
+ import { parseProviderResult, parseProviderTelemetry } from '../agents/telemetry.js';
10
+ import { formatReviewContract, formatReviewStdoutContract, parseReviewVerdict } from '../review/verdict.js';
10
11
  export function contextBlockFor(targetDir) {
11
12
  return formatForPrompt(loadContext(contextDir(targetDir)));
12
13
  }
@@ -40,7 +41,7 @@ export function buildClaudePrompt(story, context, onAmbiguity = 'resolve', perfC
40
41
  lines.push('- Keep your final message to a few short sentences: what changed and what you verified.');
41
42
  return lines.join('\n');
42
43
  }
43
- export function buildReviewPrompt(story, context, verdictPath) {
44
+ export function buildReviewPrompt(story, context, verdictPath, provider) {
44
45
  const criteria = formatAcceptance(story);
45
46
  const lines = [
46
47
  'You are an independent reviewer inside the Yoke loop. You did NOT implement this change.',
@@ -51,11 +52,10 @@ export function buildReviewPrompt(story, context, verdictPath) {
51
52
  lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria:', criteria, '', 'Approve ONLY if every acceptance criterion is met and the change is sound.', 'If you find ANY blocking issue (an unmet criterion, a bug, a missing test), reject.', 'Base your verdict only on what the diff and test runs actually show — never assume unverified behavior.', verdictPath
52
53
  ? 'Do not modify project source, tests, configuration, or generated artifacts. The verdict file named below is the only permitted write. Do not commit.'
53
54
  : 'Do not modify files. Do not commit.', 'Keep your verdict to a few short sentences.');
54
- if (verdictPath)
55
- lines.push('', formatReviewContract(verdictPath));
55
+ lines.push('', verdictPath ? formatReviewContract(verdictPath, provider) : formatReviewStdoutContract(provider ?? 'claude'));
56
56
  return lines.join('\n');
57
57
  }
58
- export function buildStandaloneReviewPrompt(scope, focus, verdictPath) {
58
+ export function buildStandaloneReviewPrompt(scope, focus, verdictPath, provider) {
59
59
  const lines = [
60
60
  'You are an independent reviewer. You did NOT write this change.',
61
61
  `Review ${scope}. Run git yourself to see the diff (e.g. \`git diff\`, or \`git diff <base>..HEAD\`).`,
@@ -66,8 +66,7 @@ export function buildStandaloneReviewPrompt(scope, focus, verdictPath) {
66
66
  lines.push('', 'Approve by exiting 0 ONLY if the change is sound and complete.', 'If you find ANY blocking issue, exit non-zero to reject and explain what is wrong.', 'Base your verdict only on what the diff and test runs actually show — never assume unverified behavior.', verdictPath
67
67
  ? 'Do not modify project source, tests, configuration, or generated artifacts. The verdict file named below is the only permitted write. Do not commit.'
68
68
  : 'Do not modify files. Do not commit.', 'Keep your verdict to a few short sentences.');
69
- if (verdictPath)
70
- lines.push('', formatReviewContract(verdictPath));
69
+ lines.push('', verdictPath ? formatReviewContract(verdictPath, provider) : formatReviewStdoutContract(provider ?? 'claude'));
71
70
  return lines.join('\n');
72
71
  }
73
72
  // Headless agents must run non-interactively: with plain `-p` the CLI denies
@@ -150,9 +149,13 @@ export function parseClaudeStreamUsage(lines) {
150
149
  const usage = result ?? { inputTokens: assistantIn, outputTokens: assistantOut };
151
150
  return model ? { ...usage, model } : usage;
152
151
  }
153
- function watchdogPath() {
154
- // runner.js and watchdog.js sit side by side (dist/loop/ at runtime, src/loop/ under tsx)
155
- return fileURLToPath(new URL('./watchdog.js', import.meta.url));
152
+ function watchdogArgs() {
153
+ const compiled = fileURLToPath(new URL('./watchdog.js', import.meta.url));
154
+ if (existsSync(compiled))
155
+ return [compiled];
156
+ const source = fileURLToPath(new URL('./watchdog.ts', import.meta.url));
157
+ const tsxLoader = pathToFileURL(createRequire(import.meta.url).resolve('tsx')).href;
158
+ return ['--import', tsxLoader, source];
156
159
  }
157
160
  // When idleTimeoutMs > 0, run the agent THROUGH the watchdog so a silent hang is
158
161
  // killed after idleTimeoutMs of no output. The prompt still flows via stdin.
@@ -161,14 +164,14 @@ function watchdogPath() {
161
164
  // killing by process-name/command-line pattern takes down other projects'
162
165
  // runners too. (Plain repos, e.g. `yoke review` outside a yoke project, get
163
166
  // no pid file rather than a littered .yoke dir.)
164
- export function buildWatchdogInvocation(inv, idleTimeoutMs) {
167
+ export function buildWatchdogInvocation(inv, idleTimeoutMs, ownershipRoot = inv.cwd) {
165
168
  if (idleTimeoutMs <= 0)
166
169
  return inv;
167
- const yokeDir = join(inv.cwd, '.yoke');
170
+ const yokeDir = join(ownershipRoot, '.yoke');
168
171
  const pidArgs = existsSync(yokeDir) ? [`--pid-file=${join(yokeDir, 'runner.pid')}`] : [];
169
172
  return {
170
173
  command: 'node',
171
- args: [watchdogPath(), `--idle-ms=${idleTimeoutMs}`, ...pidArgs, '--', inv.command, ...inv.args],
174
+ args: [...watchdogArgs(), `--idle-ms=${idleTimeoutMs}`, ...pidArgs, '--', inv.command, ...inv.args],
172
175
  input: inv.input,
173
176
  cwd: inv.cwd,
174
177
  };
@@ -187,7 +190,7 @@ export function win32CommandString(command, args) {
187
190
  return [command, ...args].map(q).join(' ');
188
191
  }
189
192
  function runCli(inv) {
190
- if (process.platform === 'win32') {
193
+ if (process.platform === 'win32' && !/\.(?:exe|com)$/iu.test(inv.command) && inv.command !== process.execPath && inv.command !== 'node') {
191
194
  execSync(win32CommandString(inv.command, inv.args), {
192
195
  cwd: inv.cwd,
193
196
  input: inv.input,
@@ -208,7 +211,7 @@ function runCli(inv) {
208
211
  // through it. Throws on a non-zero exit; the error carries the partial stdout.
209
212
  function runCliCapture(inv) {
210
213
  const opts = { cwd: inv.cwd, input: inv.input, stdio: ['pipe', 'pipe', 'inherit'], encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 };
211
- return process.platform === 'win32'
214
+ return process.platform === 'win32' && !/\.(?:exe|com)$/iu.test(inv.command) && inv.command !== process.execPath && inv.command !== 'node'
212
215
  ? execSync(win32CommandString(inv.command, inv.args), opts)
213
216
  : execFileSync(inv.command, inv.args, opts);
214
217
  }
@@ -225,7 +228,7 @@ function runReviewCli(inv) {
225
228
  encoding: 'utf8',
226
229
  maxBuffer: 64 * 1024 * 1024,
227
230
  };
228
- if (process.platform === 'win32')
231
+ if (process.platform === 'win32' && !/\.(?:exe|com)$/iu.test(inv.command) && inv.command !== process.execPath && inv.command !== 'node')
229
232
  execSync(win32CommandString(inv.command, inv.args), opts);
230
233
  else
231
234
  execFileSync(inv.command, inv.args, opts);
@@ -296,6 +299,9 @@ export function runReviewAgent(inv) {
296
299
  return { success: false, summary: processFailureSummary(error) };
297
300
  }
298
301
  }
302
+ export function makeAsyncRunner(agent, opts = {}) {
303
+ return (ctx) => startProviderProcess(agent, runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, true, opts.permissions ?? 'safe', opts.selection), opts.process);
304
+ }
299
305
  export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
300
306
  // Claude always streams (see runnerInvocation) — capture the stream so tokens are
301
307
  // always reported; other agents keep inherit stdio. opts.tokenReport is now
@@ -331,33 +337,62 @@ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
331
337
  };
332
338
  }
333
339
  export const claudeRunner = makeRunner('claude');
334
- export function makeReviewRunner(agent, idleTimeoutMs = 0, exec = runReviewCli) {
340
+ export function makeReviewRunner(agent, idleTimeoutMs = 0, exec) {
335
341
  return (ctx) => {
336
- const verdictPath = reviewVerdictPath(ctx.targetDir);
337
- mkdirSync(join(ctx.targetDir, '.yoke'), { recursive: true });
338
- rmSync(verdictPath, { force: true });
339
- const base = agentInvocation(agent, buildReviewPrompt(ctx.story, contextBlockFor(ctx.targetDir), verdictPath), ctx.targetDir, 'safe');
342
+ const before = repositoryFingerprint(ctx.targetDir);
343
+ const base = agentInvocation(agent, buildReviewPrompt(ctx.story, contextBlockFor(ctx.targetDir), undefined, agent), ctx.targetDir, 'read-only');
340
344
  const inv = buildWatchdogInvocation(base, idleTimeoutMs);
341
345
  let processFailure;
346
+ let actualModel;
347
+ let output = '';
342
348
  try {
343
- exec(inv);
349
+ const result = exec?.(inv) ?? runCapturedAgent(agent, inv);
350
+ if (!result.success)
351
+ processFailure = result.summary;
352
+ actualModel = result.tokens?.model;
353
+ output = result.output;
354
+ if (!exec && !actualModel && !processFailure)
355
+ processFailure = 'review provider did not report its model';
344
356
  }
345
357
  catch (e) {
346
358
  processFailure = processFailureSummary(e);
347
359
  }
348
360
  try {
349
- const verdict = readReviewVerdict(verdictPath);
350
- if (processFailure)
351
- return { success: false, summary: `review process failed: ${processFailure}; verdict: ${verdict.summary}` };
361
+ if (repositoryFingerprint(ctx.targetDir) !== before)
362
+ throw new Error('reviewer modified the repository during a read-only review');
363
+ const expected = { provider: agent, ...(actualModel ? { model: actualModel } : {}) };
364
+ const verdict = parseReviewVerdict(parseProviderResult(agent, output), expected);
365
+ if (processFailure) {
366
+ return {
367
+ success: false,
368
+ summary: `review process failed: ${processFailure}; verdict: ${verdict.summary}`,
369
+ reviewOutcome: { kind: 'infrastructure', summary: processFailure },
370
+ };
371
+ }
352
372
  return verdict.approved
353
- ? { success: true, summary: `${agent} approved ${ctx.story.id}: ${verdict.summary}` }
354
- : { success: false, summary: `${agent} rejected ${ctx.story.id}: ${verdict.summary}` };
373
+ ? reviewResult(agent, ctx.story.id, verdict, { kind: 'approved', verdict })
374
+ : reviewResult(agent, ctx.story.id, verdict, { kind: 'rejected', verdict });
355
375
  }
356
376
  catch (e) {
357
- return { success: false, summary: `${processFailure ? `review process failed: ${processFailure}; ` : ''}${e.message}` };
377
+ const summary = `${processFailure ? `review process failed: ${processFailure}; ` : ''}${e.message}`;
378
+ return { success: false, summary, reviewOutcome: processFailure ? { kind: 'infrastructure', summary } : { kind: 'malformed', summary } };
358
379
  }
359
380
  };
360
381
  }
382
+ export function repositoryFingerprint(targetDir) {
383
+ try {
384
+ return execFileSync('git', ['diff', '--binary', 'HEAD'], { cwd: targetDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
385
+ + execFileSync('git', ['status', '--porcelain=v1', '-z'], { cwd: targetDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
386
+ }
387
+ catch {
388
+ return '';
389
+ }
390
+ }
391
+ function reviewResult(agent, storyId, verdict, reviewOutcome) {
392
+ return verdict.approved
393
+ ? { success: true, summary: `${agent} approved ${storyId}: ${verdict.summary}`, reviewOutcome }
394
+ : { success: false, summary: `${agent} rejected ${storyId}: ${verdict.summary}`, reviewOutcome };
395
+ }
361
396
  // Probe whether the agent's CLI is on PATH (so the loop can refuse upfront with a
362
397
  // clear message instead of failing mid-run with spawn ENOENT). Never throws.
363
398
  export function isAgentAvailable(agent) {
@@ -1,9 +1,11 @@
1
- import { spawn } from 'node:child_process';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { constants } from 'node:os';
2
3
  import { writeFileSync, rmSync } from 'node:fs';
3
4
  import { pathToFileURL } from 'node:url';
5
+ import { processIncarnation } from '../agents/process-incarnation.js';
4
6
  // Kill one recorded process tree, platform-appropriately. Exported for
5
7
  // `yoke loop cleanup` (scoped reaping of recorded runner pids).
6
- export function killProcessTree(pid) {
8
+ export function killProcessTree(pid, force = true) {
7
9
  if (process.platform === 'win32') {
8
10
  try {
9
11
  spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
@@ -12,11 +14,51 @@ export function killProcessTree(pid) {
12
14
  }
13
15
  else {
14
16
  try {
15
- process.kill(pid, 'SIGKILL');
17
+ process.kill(pid, force ? 'SIGKILL' : 'SIGTERM');
16
18
  }
17
19
  catch { /* already gone */ }
18
20
  }
19
21
  }
22
+ function waitForCleanupRetry() {
23
+ spawnSync(process.execPath, ['-e', 'Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25)'], { stdio: 'ignore' });
24
+ }
25
+ export function killProcessForCleanup(pid, platform = process.platform, runTaskkill = (command, args) => spawnSync(command, args, { stdio: 'ignore' }).status, sendSignal = (target, signal) => { process.kill(target, signal); }, isProcessAlive = (target) => {
26
+ try {
27
+ process.kill(target, 0);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }) {
34
+ if (platform === 'win32')
35
+ return runTaskkill('taskkill', ['/PID', String(pid), '/T', '/F']) === 0;
36
+ try {
37
+ sendSignal(pid, 'SIGKILL');
38
+ }
39
+ catch (error) {
40
+ return error.code === 'ESRCH';
41
+ }
42
+ for (let attempt = 0; attempt < 3; attempt++) {
43
+ if (!isProcessAlive(pid))
44
+ return true;
45
+ waitForCleanupRetry();
46
+ }
47
+ return false;
48
+ }
49
+ export function killProcessTreeForCleanup(pid, platform = process.platform, runTaskkill = (command, args) => spawnSync(command, args, { stdio: 'ignore' }).status, sendSignal = (target, signal) => { process.kill(target, signal); }) {
50
+ if (platform === 'win32')
51
+ return runTaskkill('taskkill', ['/PID', String(pid), '/T', '/F']) === 0;
52
+ try {
53
+ // Provider processes run detached on POSIX, so their PID is also the
54
+ // process-group leader. Signal the group to reap descendants as well.
55
+ sendSignal(-pid, 'SIGKILL');
56
+ }
57
+ catch (error) {
58
+ return error.code === 'ESRCH';
59
+ }
60
+ return true;
61
+ }
20
62
  // win32 default: kill the whole tree. Console apps have no reliable soft-close
21
63
  // on Windows, so both phases force (/F) — the grace pass is a retry, not an
22
64
  // escalation. Best-effort by design: taskkill is a standard Windows component.
@@ -30,7 +72,7 @@ export function runWatchdog(opts) {
30
72
  const spawnFn = opts.spawnFn ?? spawn;
31
73
  const out = opts.out ?? ((d) => process.stdout.write(d));
32
74
  const err = opts.err ?? ((d) => process.stderr.write(d));
33
- const child = spawnFn(opts.command, opts.args, { shell: process.platform === 'win32' });
75
+ const child = spawnFn(opts.command, opts.args, { shell: process.platform === 'win32', detached: process.platform !== 'win32' });
34
76
  if (opts.stdin && child.stdin) {
35
77
  try {
36
78
  opts.stdin.pipe(child.stdin);
@@ -39,7 +81,7 @@ export function runWatchdog(opts) {
39
81
  }
40
82
  if (opts.pidFile && child.pid !== undefined) {
41
83
  try {
42
- writeFileSync(opts.pidFile, JSON.stringify({ watchdogPid: process.pid, childPid: child.pid, startedAt: new Date().toISOString() }));
84
+ writeFileSync(opts.pidFile, JSON.stringify({ watchdogPid: process.pid, watchdogIncarnation: processIncarnation(process.pid), childPid: child.pid, childIncarnation: processIncarnation(child.pid), startedAt: new Date().toISOString() }));
43
85
  }
44
86
  catch { /* best-effort — cleanup falls back to worktree/lock handling */ }
45
87
  }
@@ -54,12 +96,15 @@ export function runWatchdog(opts) {
54
96
  const graceMs = opts.graceMs ?? 5000;
55
97
  // Explicitly-passed killTree wins (including an explicit undefined, which pins
56
98
  // the per-process signal path — tests use this to be platform-independent).
57
- const killTree = 'killTree' in opts ? opts.killTree : (process.platform === 'win32' ? taskkillTree : undefined);
99
+ const killTree = 'killTree' in opts ? opts.killTree : (pid, _force) => killProcessTreeForCleanup(pid);
58
100
  // Terminate the child — via the tree-killer when we have one and a pid,
59
101
  // otherwise per-process signals (POSIX default; SIGKILL is uncatchable).
102
+ let terminationRequested = false;
103
+ let terminationConfirmed = false;
60
104
  const terminate = (child, force) => {
105
+ terminationRequested = true;
61
106
  if (killTree && child.pid !== undefined) {
62
- killTree(child.pid, force);
107
+ terminationConfirmed = killTree(child.pid, force) === true || terminationConfirmed;
63
108
  return;
64
109
  }
65
110
  try {
@@ -111,7 +156,21 @@ export function runWatchdog(opts) {
111
156
  child.stdout.on('data', (d) => { out(d); arm(); });
112
157
  child.stderr.on('data', (d) => { err(d); arm(); });
113
158
  child.on('error', () => { clear(); removePidFile(); resolve(127); });
114
- child.on('close', (code) => { clear(); removePidFile(); resolve(killedForIdle ? 124 : (code ?? 0)); });
159
+ child.on('close', (code, signal) => {
160
+ clear();
161
+ if (!terminationRequested || terminationConfirmed)
162
+ removePidFile();
163
+ if (killedForIdle) {
164
+ resolve(124);
165
+ return;
166
+ }
167
+ if (code !== null) {
168
+ resolve(code);
169
+ return;
170
+ }
171
+ const signalNumber = signal ? constants.signals[signal] : undefined;
172
+ resolve(signalNumber === undefined ? 1 : 128 + signalNumber);
173
+ });
115
174
  arm();
116
175
  });
117
176
  }