@hecer/yoke 1.8.0 → 1.10.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 (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +34 -1
  4. package/README.md +8 -6
  5. package/canon/loop/prd.schema.md +6 -0
  6. package/canon/skills/authoring-prd/SKILL.md +7 -0
  7. package/dist/agents/process-incarnation.js +1 -1
  8. package/dist/agents/process.js +74 -6
  9. package/dist/agents/supervision.js +153 -0
  10. package/dist/agents/windows-launch.js +80 -0
  11. package/dist/change/inbox.js +23 -5
  12. package/dist/cli.js +18 -2
  13. package/dist/dashboard/page.js +102 -8
  14. package/dist/dashboard/panels.js +84 -8
  15. package/dist/goals/command.js +29 -4
  16. package/dist/loop/git.js +12 -4
  17. package/dist/loop/loop.js +48 -3
  18. package/dist/loop/parallel-adapters.js +2 -3
  19. package/dist/loop/parallel-command.js +14 -7
  20. package/dist/loop/prd.js +4 -0
  21. package/dist/loop/reporter.js +8 -1
  22. package/dist/loop/run-command.js +25 -5
  23. package/dist/loop/runner.js +27 -31
  24. package/dist/loop/watchdog.js +87 -11
  25. package/dist/loop/worker.js +30 -1
  26. package/dist/prd/assess.js +145 -0
  27. package/dist/prd/command.js +59 -21
  28. package/dist/quality/command.js +11 -5
  29. package/dist/retrofit/config.js +15 -1
  30. package/dist/retrofit/gitignore.js +1 -1
  31. package/dist/routing/assessment.js +66 -0
  32. package/dist/routing/capability.js +91 -0
  33. package/dist/routing/contracts.js +60 -0
  34. package/dist/routing/planning.js +12 -0
  35. package/dist/routing/router.js +85 -2
  36. package/dist/setup/command.js +23 -9
  37. package/docs/BATCH-PLANNING-VALIDATION.md +67 -0
  38. package/docs/CAPABILITY-ROUTING.md +82 -0
  39. package/docs/DASHBOARD-EVOLUTION.md +33 -0
  40. package/docs/PRODUCT-DIRECTION-2026-09-05.md +28 -0
  41. package/docs/WINDOWS-RUNNER-VALIDATION.md +104 -0
  42. package/docs/assets/yoke-logo.png +0 -0
  43. package/gemini-extension.json +1 -1
  44. package/package.json +1 -1
@@ -2,7 +2,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
2
2
  import { mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { isProviderTreeAlive, reapProviderProcesses } from './cleanup.js';
5
- import { realGitOps, RUNTIME_EXCLUDES, RUNTIME_PATHS } from './git.js';
5
+ import { realGitOps, stageImplementation } from './git.js';
6
6
  import { isPidAlive } from './lock.js';
7
7
  import { storyPathSegment } from './prd.js';
8
8
  import { killProcessTreeForCleanup } from './watchdog.js';
@@ -130,8 +130,7 @@ function rebaseCandidate(targetDir, input) {
130
130
  }
131
131
  let candidateTree;
132
132
  try {
133
- execFileSync('git', ['reset', '--quiet', '--', ...RUNTIME_PATHS], { cwd: input.worktree.path, stdio: 'pipe' });
134
- execFileSync('git', ['add', '-A', '--', '.', ...RUNTIME_EXCLUDES], { cwd: input.worktree.path, stdio: 'pipe' });
133
+ stageImplementation(input.worktree.path);
135
134
  const unstaged = spawnSync('git', ['diff', '--quiet', '--ignore-submodules=none'], { cwd: input.worktree.path, stdio: 'pipe' });
136
135
  if (unstaged.error || unstaged.status !== 0)
137
136
  return { kind: 'reopen', reason: 'candidate contains changes Git cannot snapshot' };
@@ -1,6 +1,7 @@
1
1
  import { existsSync, rmSync } from 'node:fs';
2
2
  import { acceptanceProtectionProblem } from '../check/command.js';
3
3
  import { makeAsyncAdaptiveRunner } from '../routing/router.js';
4
+ import { resolvePlanner } from '../routing/planning.js';
4
5
  import { startProviderProcess } from '../agents/process.js';
5
6
  import { runnerInvocation, isAgentAvailable } from './runner.js';
6
7
  import { createDispatcher } from './dispatcher.js';
@@ -253,13 +254,19 @@ function reportIntegrator(reporter, worker, phase) {
253
254
  function asyncRunner(input, provider, signal, workerId) {
254
255
  if (input.routing) {
255
256
  const routed = makeAsyncAdaptiveRunner({
256
- parent: provider.provider,
257
- parentSelection: { ...input.selection, model: provider.model, reasoningEffort: provider.reasoningEffort, ...(provider.provider === 'codex' ? { nativeMultiAgent: false } : {}) },
257
+ parent: input.routing.strategy === 'capability' ? input.runnerAgent : provider.provider,
258
+ parentSelection: input.routing.strategy === 'capability' ? input.selection : { ...input.selection, model: provider.model, reasoningEffort: provider.reasoningEffort, nativeMultiAgent: false },
258
259
  projectRoot: input.targetDir,
259
260
  workers: input.routing.workers,
260
261
  rules: input.routing.rules,
261
262
  strategy: input.routing.strategy,
262
263
  maxCandidates: input.routing.maxCandidates,
264
+ maxAttempts: input.routing.maxAttempts,
265
+ planner: resolvePlanner({ planning: input.planning }, input.runnerAgent, input.selection),
266
+ assessmentPolicy: input.routing.assessmentPolicy,
267
+ fallback: input.routing.fallback,
268
+ maxTier: input.routing.maxTier,
269
+ onDecision: (id, decision) => input.reporter.routingDecision?.(id, decision),
263
270
  orchestratorSelection: input.routing.orchestrator,
264
271
  isAvailable: input.isAvailable ?? isAgentAvailable,
265
272
  captureRoute: async (agent, context, prompt, selection) => {
@@ -275,7 +282,7 @@ function asyncRunner(input, provider, signal, workerId) {
275
282
  return providerProcessResultToAgentResult(agent, context.story.id, await run(context).completion);
276
283
  },
277
284
  });
278
- return context => context.story.agent
285
+ return context => context.story.agent && input.routing?.strategy !== 'capability'
279
286
  ? asyncRunner({ ...input, routing: undefined }, provider, signal, workerId)(context)
280
287
  : routed(context);
281
288
  }
@@ -305,10 +312,10 @@ export function providerProcessResultToAgentResult(agent, storyId, result) {
305
312
  const telemetry = tokens ? { tokens } : {};
306
313
  switch (result.kind) {
307
314
  case 'succeeded': return { success: true, summary: `${agent} implemented ${storyId}`, ...telemetry };
308
- case 'cancelled': return { success: false, summary: result.reason, ...telemetry };
309
- case 'timed-out': return { success: false, summary: result.reason, ...telemetry };
310
- case 'spawn-failed': return { success: false, summary: result.error, ...telemetry };
311
- case 'failed': return { success: false, summary: `${agent} exited ${result.exitCode ?? 'without a code'}`, ...telemetry };
315
+ case 'cancelled': return { success: false, infrastructureFailure: true, summary: result.reason, ...telemetry };
316
+ case 'timed-out': return { success: false, infrastructureFailure: true, summary: result.reason, ...telemetry };
317
+ case 'spawn-failed': return { success: false, infrastructureFailure: true, summary: result.error, ...telemetry };
318
+ case 'failed': return { success: false, infrastructureFailure: true, summary: `${agent} exited ${result.exitCode ?? 'without a code'}`, ...telemetry };
312
319
  default: return assertNever(result);
313
320
  }
314
321
  }
package/dist/loop/prd.js CHANGED
@@ -4,6 +4,7 @@ import { parse, stringify } from 'yaml';
4
4
  import { z } from 'zod';
5
5
  import { StoryQualityDeclarationSchema } from '../quality/types.js';
6
6
  import { validWriteScope } from './scheduler.js';
7
+ import { AssessmentSchema } from '../routing/assessment.js';
7
8
  export const AcceptanceCriterionSchema = z.object({
8
9
  id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
9
10
  text: z.string().min(1),
@@ -45,6 +46,9 @@ export const StorySchema = z.object({
45
46
  /** Inbox request that created this story. Used for idempotent append-only intake. */
46
47
  sourceChange: z.string().min(1).optional(),
47
48
  quality: StoryQualityDeclarationSchema.optional(),
49
+ assessment: AssessmentSchema.optional(),
50
+ /** Binding to the task, upstream contracts and approved planning brief. */
51
+ assessmentFor: z.string().regex(/^[a-f0-9]{64}$/).optional(),
48
52
  }).superRefine((story, ctx) => {
49
53
  const structured = story.acceptance.filter(isAcceptanceCriterion);
50
54
  const ids = structured.map(criterion => criterion.id);
@@ -1,3 +1,4 @@
1
+ import { readSupervision } from "../agents/supervision.js";
1
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, appendFileSync, statSync, rmSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import { randomUUID } from 'node:crypto';
@@ -76,7 +77,9 @@ export function readStatus(dir) {
76
77
  if (!existsSync(file))
77
78
  return null;
78
79
  try {
79
- return JSON.parse(readFileSync(file, 'utf8'));
80
+ const status = JSON.parse(readFileSync(file, 'utf8'));
81
+ const supervision = readSupervision(dir, status.startedAt);
82
+ return supervision.length ? { ...status, supervision } : status;
80
83
  }
81
84
  catch {
82
85
  return null;
@@ -182,6 +185,10 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
182
185
  emitConsole(consoleLine);
183
186
  };
184
187
  return {
188
+ routingDecision(storyId, decision) {
189
+ if (current)
190
+ persist({ ...current, routingDecisions: { ...current.routingDecisions, [storyId]: decision }, updatedAt: now().toISOString() }, "routing", ` · route ${storyId}: ${decision.profile} (${decision.reason})`);
191
+ },
185
192
  execution(provider, requestedModel) {
186
193
  if (current)
187
194
  persist({ ...current, execution: { provider, requestedModel, startedAt: now().toISOString() }, updatedAt: now().toISOString() }, 'execution', ` · ${provider}/${requestedModel ?? 'provider-default'}`);
@@ -1,3 +1,4 @@
1
+ import { roleSelection } from "../routing/capability.js";
1
2
  import { join } from 'node:path';
2
3
  import { existsSync } from 'node:fs';
3
4
  import { loadConfig, saveConfig, defaultConfig, resolveOutputPolicy, resolveVerifyCommand } from '../retrofit/config.js';
@@ -16,6 +17,7 @@ import { buildTrustedDecisionResumeState, clearDecisionResume, decisionProcessin
16
17
  import { makeAdaptiveRunner } from '../routing/router.js';
17
18
  import { makeActionRunner } from '../execution/actions.js';
18
19
  import { runChangeApply } from '../change/inbox.js';
20
+ import { resolvePlanner } from '../routing/planning.js';
19
21
  import { createQualityCommandHooks } from '../quality/command.js';
20
22
  import { resolveQualityPolicy } from '../quality/types.js';
21
23
  import { runParallelLoopCommand } from './parallel-command.js';
@@ -60,9 +62,12 @@ export function loopStatus(targetDir, now = () => new Date()) {
60
62
  return `Loop: ${enabled ? 'enabled' : 'disabled'}\nPRD: ${prog}`;
61
63
  const head = `Loop: ${st.state.toUpperCase()}${st.story ? ` on ${st.story}${st.storyTitle ? ` "${st.storyTitle}"` : ''}` : ''}`;
62
64
  const pct = st.percent !== undefined ? ` (${st.percent}%)` : '';
63
- const meta = [st.phase, `iteration ${st.iteration}`, `${st.progress.passed}/${st.progress.total}${pct}`, `updated ${relativeTime(st.updatedAt, now())}`]
65
+ const meta = [st.phase, `iteration ${st.iteration}`, `backlog ${st.progress.passed}/${st.progress.total}${pct}`, `updated ${relativeTime(st.updatedAt, now())}`]
64
66
  .filter(Boolean).join(' · ');
65
67
  const lines = [head, ` ${meta}`];
68
+ for (const process of st.supervision ?? []) {
69
+ 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 : ''}`);
70
+ }
66
71
  if (st.state === 'running' && st.eta && st.eta.remainingStories > 0) {
67
72
  lines.push(` ~${fmtDuration(st.eta.etaMs)} remaining (Ø ${fmtDuration(st.eta.avgStoryMs)}/story)`);
68
73
  }
@@ -244,7 +249,7 @@ export function runLoopCommand(targetDir, opts) {
244
249
  let executionReporter;
245
250
  const quality = createQualityCommandHooks({
246
251
  targetDir,
247
- config,
252
+ config: opts.routing === false ? { ...config, routing: undefined } : config,
248
253
  runnerAgent,
249
254
  idleMs,
250
255
  onUsage: usage => executionReporter?.addTokens(usage),
@@ -261,7 +266,7 @@ export function runLoopCommand(targetDir, opts) {
261
266
  }
262
267
  const permissions = opts.permissions ?? config.runner?.permissions ?? 'safe';
263
268
  const routingRequested = opts.routing ?? config.routing?.enabled ?? true;
264
- const routingEnabled = routingRequested && Boolean(config.routing?.workers.length);
269
+ const routingEnabled = routingRequested && Boolean(config.routing && (config.routing.workers.length || config.routing.fallback === 'block' || config.routing.maxTier || config.routing.assessmentPolicy === 'prepared'));
265
270
  const runnerSelection = {
266
271
  model: config.runner?.model,
267
272
  reasoningEffort: config.runner?.reasoningEffort,
@@ -273,7 +278,7 @@ export function runLoopCommand(targetDir, opts) {
273
278
  ...(runnerSelection.model ? { model: runnerSelection.model } : {}),
274
279
  ...(runnerSelection.reasoningEffort ? { reasoningEffort: runnerSelection.reasoningEffort } : {}),
275
280
  }];
276
- const parallelAffinityProviders = (config.routing?.workers ?? []).map(worker => ({
281
+ const parallelAffinityProviders = config.routing?.strategy === 'capability' ? config.agents.map(agent => ({ provider: agent, ...(agent === runnerAgent ? runnerSelection : {}) })) : (config.routing?.workers ?? []).map(worker => ({
277
282
  provider: worker.agent,
278
283
  ...(worker.model ? { model: worker.model } : {}),
279
284
  ...(worker.reasoningEffort ? { reasoningEffort: worker.reasoningEffort } : {}),
@@ -349,6 +354,12 @@ export function runLoopCommand(targetDir, opts) {
349
354
  rules: config.routing.rules,
350
355
  strategy: config.routing.strategy,
351
356
  maxCandidates: config.routing.maxCandidates,
357
+ maxAttempts: config.routing.maxAttempts,
358
+ planner: resolvePlanner(config, runnerAgent, runnerSelection),
359
+ assessmentPolicy: config.routing.assessmentPolicy,
360
+ fallback: config.routing.fallback,
361
+ maxTier: config.routing.maxTier,
362
+ onDecision: (id, decision) => executionReporter?.routingDecision?.(id, decision),
352
363
  idleTimeoutMs: idleMs,
353
364
  permissions,
354
365
  runnerOpts,
@@ -385,7 +396,15 @@ export function runLoopCommand(targetDir, opts) {
385
396
  console.error(`Reviewer agent CLI "${resolvedReviewer}" was not found on PATH. Install it, or pick another with --reviewer=<claude|codex|gemini>.`);
386
397
  return 2;
387
398
  }
388
- review = makeReviewRunner(resolvedReviewer, idleMs);
399
+ review = context => {
400
+ const implementer = readStatus(targetDir)?.routingDecisions?.[context.story.id]?.provider ?? runnerAgent;
401
+ const selectedReviewer = !opts.reviewer && resolvedReviewer === implementer
402
+ ? ["codex", "claude", "gemini"].find(agent => agent !== implementer && available(agent)) ?? resolvedReviewer : resolvedReviewer;
403
+ if (selectedReviewer === implementer && !opts.allowSelfReview)
404
+ return { success: false, summary: "Independent review requires a provider distinct from the routed implementer", reviewOutcome: { kind: "infrastructure", summary: "Routed implementation and reviewer share a provider" } };
405
+ reviewProvider = selectedReviewer;
406
+ return makeReviewRunner(selectedReviewer, idleMs, undefined, routingEnabled ? roleSelection(targetDir, config, context.story, selectedReviewer, "reviewer") : undefined)(context);
407
+ };
389
408
  }
390
409
  if (review) {
391
410
  const reviewRunner = review;
@@ -471,6 +490,7 @@ export function runLoopCommand(targetDir, opts) {
471
490
  providers: parallelProviders,
472
491
  affinityProviders: parallelAffinityProviders,
473
492
  routing: routingEnabled ? config.routing : undefined,
493
+ planning: config.planning,
474
494
  isAvailable: available,
475
495
  onAmbiguity: ambiguityPolicy,
476
496
  git: opts.git,
@@ -10,6 +10,8 @@ import { contextPacket } from '../context/packet.js';
10
10
  import { buildProviderInvocation, startProviderProcess } from '../agents/providers.js';
11
11
  import { parseProviderResult, parseProviderTelemetry } from '../agents/telemetry.js';
12
12
  import { formatReviewContract, formatReviewStdoutContract, parseReviewVerdict } from '../review/verdict.js';
13
+ import { prepareWindowsInvocation } from '../agents/windows-launch.js';
14
+ import { readSupervision } from '../agents/supervision.js';
13
15
  export function contextBlockFor(targetDir, story) {
14
16
  const context = loadContext(contextDir(targetDir));
15
17
  return story ? contextPacket(context, `${story.title} ${story.area ?? ''} ${story.acceptance.map(c => typeof c === 'string' ? c : c.text).join(' ')}`) : formatForPrompt(context);
@@ -29,7 +31,7 @@ export function buildClaudePrompt(story, context, onAmbiguity = 'resolve', perfC
29
31
  ];
30
32
  if (context)
31
33
  lines.push('', context);
32
- lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria (Definition of Done):', criteria, '', "When done, ensure the project's full test suite passes.", 'Do NOT commit — the loop commits on your behalf after verifying.', '', 'Working rules:', '- Add nothing beyond what the story requires: no extra features, abstractions, comments, or defensive code for cases that cannot happen.', '- Do not create summary, plan, or analysis documents — only files the story itself needs.', '- If a check fails, fix the root cause; never bypass it (e.g. --no-verify) or pass by weakening tests.', '- Report the outcome faithfully: if a criterion is unmet or tests fail, say so plainly instead of claiming success.', '- Never ask questions or wait for input — you run unattended and nobody can answer.', onAmbiguity === 'abort'
34
+ lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria (Definition of Done):', criteria, ...(story.assessment ? ['Planner approach:', story.assessment.approach] : []), '', "When done, ensure the project's full test suite passes.", 'Do NOT commit — the loop commits on your behalf after verifying.', '', 'Working rules:', '- Add nothing beyond what the story requires: no extra features, abstractions, comments, or defensive code for cases that cannot happen.', '- Do not create summary, plan, or analysis documents — only files the story itself needs.', '- If a check fails, fix the root cause; never bypass it (e.g. --no-verify) or pass by weakening tests.', '- Report the outcome faithfully: if a criterion is unmet or tests fail, say so plainly instead of claiming success.', '- Never ask questions or wait for input — you run unattended and nobody can answer.', onAmbiguity === 'abort'
33
35
  ? '- If an acceptance criterion is genuinely undecidable, do NOT guess: write the open question(s) to .yoke/ambiguity.md, change nothing else, and stop.'
34
36
  : onAmbiguity === 'critical'
35
37
  ? [
@@ -167,8 +169,8 @@ function watchdogArgs() {
167
169
  // killing by process-name/command-line pattern takes down other projects'
168
170
  // runners too. (Plain repos, e.g. `yoke review` outside a yoke project, get
169
171
  // no pid file rather than a littered .yoke dir.)
170
- export function buildWatchdogInvocation(inv, idleTimeoutMs, ownershipRoot = inv.cwd) {
171
- if (idleTimeoutMs <= 0)
172
+ export function buildWatchdogInvocation(inv, idleTimeoutMs, ownershipRoot = inv.cwd, force = false) {
173
+ if (idleTimeoutMs <= 0 && !force)
172
174
  return inv;
173
175
  const yokeDir = join(ownershipRoot, '.yoke');
174
176
  const pidArgs = existsSync(yokeDir) ? [`--pid-file=${join(yokeDir, 'runner.pid')}`] : [];
@@ -193,20 +195,14 @@ export function win32CommandString(command, args) {
193
195
  return [command, ...args].map(q).join(' ');
194
196
  }
195
197
  function runCli(inv) {
196
- if (process.platform === 'win32' && !/\.(?:exe|com)$/iu.test(inv.command) && inv.command !== process.execPath && inv.command !== 'node') {
197
- execSync(win32CommandString(inv.command, inv.args), {
198
- cwd: inv.cwd,
199
- input: inv.input,
200
- stdio: ['pipe', 'inherit', 'inherit'],
201
- });
202
- }
203
- else {
204
- execFileSync(inv.command, inv.args, {
205
- cwd: inv.cwd,
206
- input: inv.input,
207
- stdio: ['pipe', 'inherit', 'inherit'],
208
- });
209
- }
198
+ const launch = process.platform === 'win32' ? prepareWindowsInvocation(inv) : inv;
199
+ execFileSync(launch.command, launch.args, {
200
+ cwd: inv.cwd,
201
+ input: inv.input,
202
+ stdio: ['pipe', 'inherit', 'inherit'],
203
+ ...('env' in launch ? { env: launch.env } : {}),
204
+ windowsHide: true,
205
+ });
210
206
  }
211
207
  // Like runCli, but with stdout PIPED and returned (stderr stays inherited) — for
212
208
  // token reporting, where the agent's stdout is a machine-readable stream-json feed.
@@ -214,9 +210,8 @@ function runCli(inv) {
214
210
  // through it. Throws on a non-zero exit; the error carries the partial stdout.
215
211
  function runCliCapture(inv) {
216
212
  const opts = { cwd: inv.cwd, input: inv.input, stdio: ['pipe', 'pipe', 'inherit'], encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 };
217
- return process.platform === 'win32' && !/\.(?:exe|com)$/iu.test(inv.command) && inv.command !== process.execPath && inv.command !== 'node'
218
- ? execSync(win32CommandString(inv.command, inv.args), opts)
219
- : execFileSync(inv.command, inv.args, opts);
213
+ const launch = process.platform === 'win32' ? prepareWindowsInvocation(inv) : inv;
214
+ return execFileSync(launch.command, launch.args, { ...opts, ...('env' in launch ? { env: launch.env } : {}), windowsHide: true });
220
215
  }
221
216
  // Reviews have a machine-readable result file, so their console stream is not
222
217
  // the result channel. Buffer stderr to preserve the provider's actual failure
@@ -231,10 +226,8 @@ function runReviewCli(inv) {
231
226
  encoding: 'utf8',
232
227
  maxBuffer: 64 * 1024 * 1024,
233
228
  };
234
- if (process.platform === 'win32' && !/\.(?:exe|com)$/iu.test(inv.command) && inv.command !== process.execPath && inv.command !== 'node')
235
- execSync(win32CommandString(inv.command, inv.args), opts);
236
- else
237
- execFileSync(inv.command, inv.args, opts);
229
+ const launch = process.platform === 'win32' ? prepareWindowsInvocation(inv) : inv;
230
+ execFileSync(launch.command, launch.args, { ...opts, ...('env' in launch ? { env: launch.env } : {}), windowsHide: true });
238
231
  }
239
232
  function processFailureSummary(error) {
240
233
  const message = error instanceof Error ? error.message : String(error);
@@ -303,7 +296,7 @@ export function runReviewAgent(inv) {
303
296
  }
304
297
  }
305
298
  export function makeAsyncRunner(agent, opts = {}) {
306
- return (ctx) => startProviderProcess(agent, runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir, ctx.story), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, true, opts.permissions ?? 'safe', opts.selection), opts.process);
299
+ return (ctx) => startProviderProcess(agent, runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir, ctx.story) + (ctx.feedback ? "\nPrior independent failure; preserve useful existing changes and fix the root cause:\n" + ctx.feedback.slice(0, 8000) : ""), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, true, opts.permissions ?? 'safe', opts.selection), { ...opts.process, attempt: ctx.attempt });
307
300
  }
308
301
  export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
309
302
  // Claude always streams (see runnerInvocation) — capture the stream so tokens are
@@ -314,8 +307,10 @@ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
314
307
  opts.onStart?.(agent, opts.selection ?? {});
315
308
  const started = Date.now();
316
309
  const attributed = (tokens) => tokens ? { ...tokens, provider: agent, role: 'parent', storyId: ctx.story.id, durationMs: Date.now() - started } : undefined;
317
- const base = runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir, ctx.story), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, captureTokens, opts.permissions ?? 'safe', opts.selection);
318
- const inv = buildWatchdogInvocation(base, idleTimeoutMs);
310
+ const base = runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir, ctx.story) + (ctx.feedback ? "\nPrior independent failure; preserve useful existing changes and fix the root cause:\n" + ctx.feedback.slice(0, 8000) : ""), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, captureTokens, opts.permissions ?? 'safe', opts.selection);
311
+ const inv = buildWatchdogInvocation(base, idleTimeoutMs, ctx.targetDir, true);
312
+ if (ctx.attempt)
313
+ inv.args.splice(inv.args.indexOf('--'), 0, `--attempt=${ctx.attempt}`);
319
314
  if (captureTokens) {
320
315
  const capture = opts.execCapture ?? runCliCapture;
321
316
  try {
@@ -327,7 +322,8 @@ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
327
322
  // Salvage usage from whatever the agent streamed before dying — those tokens were spent.
328
323
  const partial = e.stdout;
329
324
  const tokens = partial == null ? undefined : parseProviderTelemetry(agent, String(partial).split(/\r?\n/)).tokens;
330
- return { success: false, summary: `${agent} failed on ${ctx.story.id}: ${e.message}`, tokens: attributed(tokens) };
325
+ const reason = readSupervision(ctx.targetDir, new Date(started).toISOString())[0]?.reason;
326
+ return { success: false, infrastructureFailure: true, summary: `${agent} failed on ${ctx.story.id}: ${reason ?? e.message}`, tokens: attributed(tokens) };
331
327
  }
332
328
  }
333
329
  try {
@@ -338,15 +334,15 @@ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
338
334
  return { success: true, summary: `${agent} implemented ${ctx.story.id}` };
339
335
  }
340
336
  catch (e) {
341
- return { success: false, summary: `${agent} failed on ${ctx.story.id}: ${e.message}` };
337
+ return { success: false, infrastructureFailure: true, summary: `${agent} failed on ${ctx.story.id}: ${e.message}` };
342
338
  }
343
339
  };
344
340
  }
345
341
  export const claudeRunner = makeRunner('claude');
346
- export function makeReviewRunner(agent, idleTimeoutMs = 0, exec) {
342
+ export function makeReviewRunner(agent, idleTimeoutMs = 0, exec, selection = {}) {
347
343
  return (ctx) => {
348
344
  const before = repositoryFingerprint(ctx.targetDir);
349
- const base = agentInvocation(agent, buildReviewPrompt(ctx.story, contextBlockFor(ctx.targetDir, ctx.story), undefined, agent), ctx.targetDir, 'read-only', { nativeMultiAgent: false });
345
+ const base = agentInvocation(agent, buildReviewPrompt(ctx.story, contextBlockFor(ctx.targetDir, ctx.story), undefined, agent), ctx.targetDir, 'read-only', { ...selection, nativeMultiAgent: false });
350
346
  const inv = buildWatchdogInvocation(base, idleTimeoutMs);
351
347
  let processFailure;
352
348
  let actualModel;
@@ -3,6 +3,8 @@ import { constants } from 'node:os';
3
3
  import { writeFileSync, rmSync } from 'node:fs';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { processIncarnation } from '../agents/process-incarnation.js';
6
+ import { prepareWindowsInvocation } from '../agents/windows-launch.js';
7
+ import { createSupervision, supervisionLimits, assertPreviousProvidersStopped } from '../agents/supervision.js';
6
8
  // Kill one recorded process tree, platform-appropriately. Exported for
7
9
  // `yoke loop cleanup` (scoped reaping of recorded runner pids).
8
10
  export function killProcessTree(pid, force = true) {
@@ -30,7 +32,7 @@ function confirmProcessStopped(pid, isProcessAlive) {
30
32
  }
31
33
  return false;
32
34
  }
33
- 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) => {
35
+ export function killProcessForCleanup(pid, platform = process.platform, runTaskkill = (command, args) => spawnSync(command, args, { stdio: 'ignore', timeout: 5000, windowsHide: true }).status, sendSignal = (target, signal) => { process.kill(target, signal); }, isProcessAlive = (target) => {
34
36
  try {
35
37
  process.kill(target, 0);
36
38
  return true;
@@ -52,7 +54,7 @@ export function killProcessForCleanup(pid, platform = process.platform, runTaskk
52
54
  }
53
55
  return confirmProcessStopped(pid, isProcessAlive);
54
56
  }
55
- export function killProcessTreeForCleanup(pid, platform = process.platform, runTaskkill = (command, args) => spawnSync(command, args, { stdio: 'ignore' }).status, sendSignal = (target, signal) => { process.kill(target, signal); }, isProcessAlive = (target) => {
57
+ export function killProcessTreeForCleanup(pid, platform = process.platform, runTaskkill = (command, args) => spawnSync(command, args, { stdio: 'ignore', timeout: 5000, windowsHide: true }).status, sendSignal = (target, signal) => { process.kill(target, signal); }, isProcessAlive = (target) => {
56
58
  try {
57
59
  process.kill(target, 0);
58
60
  return true;
@@ -91,7 +93,27 @@ export function runWatchdog(opts) {
91
93
  const spawnFn = opts.spawnFn ?? spawn;
92
94
  const out = opts.out ?? ((d) => process.stdout.write(d));
93
95
  const err = opts.err ?? ((d) => process.stderr.write(d));
94
- const child = spawnFn(opts.command, opts.args, { shell: process.platform === 'win32', detached: process.platform !== 'win32' });
96
+ let fail = () => { };
97
+ let progress = () => { };
98
+ const limits = opts.spawnFn ? { totalMs: 30 * 60_000, progressMs: 20 * 60_000 } : supervisionLimits(process.cwd());
99
+ const supervision = opts.spawnFn ? undefined : createSupervision(process.cwd(), reason => fail(reason), () => progress(), opts.attempt);
100
+ let launch;
101
+ try {
102
+ if (!opts.spawnFn)
103
+ assertPreviousProvidersStopped(process.cwd());
104
+ launch = process.platform === 'win32' && !opts.spawnFn
105
+ ? prepareWindowsInvocation({ command: opts.command, args: opts.args, input: '', cwd: process.cwd() })
106
+ : { command: opts.command, args: opts.args, env: process.env };
107
+ }
108
+ catch (error) {
109
+ const reason = error.message;
110
+ supervision?.stop(reason);
111
+ err(`Yoke infrastructure: ${reason}\n`);
112
+ return Promise.resolve(125);
113
+ }
114
+ const child = spawnFn(launch.command, launch.args, { shell: false, detached: process.platform !== 'win32', env: launch.env, windowsHide: true });
115
+ const incarnation = child.pid === undefined || opts.spawnFn ? undefined : processIncarnation(child.pid);
116
+ supervision?.start(child.pid, 'shell' in launch ? launch.shell : undefined, incarnation);
95
117
  if (opts.stdin && child.stdin) {
96
118
  try {
97
119
  opts.stdin.pipe(child.stdin);
@@ -115,7 +137,17 @@ export function runWatchdog(opts) {
115
137
  const graceMs = opts.graceMs ?? 5000;
116
138
  // Explicitly-passed killTree wins (including an explicit undefined, which pins
117
139
  // the per-process signal path — tests use this to be platform-independent).
118
- const killTree = 'killTree' in opts ? opts.killTree : (pid, _force) => killProcessTreeForCleanup(pid);
140
+ const killTree = 'killTree' in opts ? opts.killTree : opts.spawnFn ? undefined : (pid, _force) => {
141
+ try {
142
+ process.kill(pid, 0);
143
+ }
144
+ catch {
145
+ return true;
146
+ }
147
+ if (!opts.spawnFn && (!incarnation || processIncarnation(pid) !== incarnation))
148
+ return false;
149
+ return killProcessTreeForCleanup(pid);
150
+ };
119
151
  // Terminate the child — via the tree-killer when we have one and a pid,
120
152
  // otherwise per-process signals (POSIX default; SIGKILL is uncatchable).
121
153
  let terminationRequested = false;
@@ -135,6 +167,9 @@ export function runWatchdog(opts) {
135
167
  let timer;
136
168
  let graceTimer;
137
169
  let killedForIdle = false;
170
+ let reason;
171
+ let totalTimer;
172
+ let progressTimer;
138
173
  // Clear BOTH the idle timer and the post-SIGTERM grace timer so no dangling
139
174
  // timers survive on any terminal path (close/error) or on each re-arm.
140
175
  const clear = () => {
@@ -160,6 +195,7 @@ export function runWatchdog(opts) {
160
195
  timer = setTimeout(() => {
161
196
  timer = undefined;
162
197
  killedForIdle = true;
198
+ reason = 'provider-output-timeout';
163
199
  terminate(child, false);
164
200
  // Escalation: a child that catches/ignores the soft kill would never emit
165
201
  // 'close' and the promise would hang forever — defeating the watchdog.
@@ -169,14 +205,52 @@ export function runWatchdog(opts) {
169
205
  graceTimer = setTimeout(() => {
170
206
  graceTimer = undefined;
171
207
  terminate(child, true);
208
+ supervision?.stop(reason, terminationConfirmed);
209
+ if (totalTimer)
210
+ clearTimeout(totalTimer);
211
+ if (progressTimer)
212
+ clearTimeout(progressTimer);
213
+ resolve(124);
172
214
  }, graceMs);
173
215
  }, opts.idleMs);
174
216
  };
175
- child.stdout.on('data', (d) => { out(d); arm(); });
176
- child.stderr.on('data', (d) => { err(d); arm(); });
177
- child.on('error', () => { clear(); removePidFile(); resolve(127); });
178
- child.on('close', (code, signal) => {
217
+ fail = (failure) => {
218
+ if (killedForIdle)
219
+ return;
220
+ killedForIdle = true;
221
+ reason = failure;
222
+ err(`Yoke infrastructure: ${failure}\n`);
179
223
  clear();
224
+ terminate(child, false);
225
+ graceTimer = setTimeout(() => {
226
+ terminate(child, true);
227
+ supervision?.stop(reason, terminationConfirmed);
228
+ if (totalTimer)
229
+ clearTimeout(totalTimer);
230
+ if (progressTimer)
231
+ clearTimeout(progressTimer);
232
+ resolve(125);
233
+ }, graceMs);
234
+ };
235
+ progress = () => {
236
+ if (progressTimer)
237
+ clearTimeout(progressTimer);
238
+ if ((opts.progressMs ?? limits.progressMs) > 0)
239
+ progressTimer = setTimeout(() => fail('provider-progress-timeout'), opts.progressMs ?? limits.progressMs);
240
+ };
241
+ if ((opts.totalMs ?? limits.totalMs) > 0)
242
+ totalTimer = setTimeout(() => fail('provider-total-timeout'), opts.totalMs ?? limits.totalMs);
243
+ progress();
244
+ child.stdout.on('data', (d) => { out(d); supervision?.output('stdout', String(d)); arm(); });
245
+ child.stderr.on('data', (d) => { err(d); supervision?.output('stderr', String(d)); arm(); });
246
+ const clearAll = () => { clear(); if (totalTimer)
247
+ clearTimeout(totalTimer); if (progressTimer)
248
+ clearTimeout(progressTimer); };
249
+ child.on('error', () => { clearAll(); supervision?.stop('provider-spawn-failed'); removePidFile(); resolve(127); });
250
+ child.on('close', (code, signal) => {
251
+ supervision?.flush();
252
+ clearAll();
253
+ supervision?.stop(reason ?? (code === 0 ? 'provider-exited' : 'provider-exit-failed'), !terminationRequested || terminationConfirmed);
180
254
  if (!terminationRequested || terminationConfirmed)
181
255
  removePidFile();
182
256
  if (killedForIdle) {
@@ -199,16 +273,18 @@ export function parseWatchdogArgs(argv) {
199
273
  const rest = sep === -1 ? [] : argv.slice(sep + 1);
200
274
  const idleArg = flags.find((a) => a.startsWith('--idle-ms='));
201
275
  const idleMs = idleArg ? Number(idleArg.slice('--idle-ms='.length)) : 0;
276
+ const totalArg = flags.find(a => a.startsWith('--total-ms='))?.slice('--total-ms='.length);
277
+ const attempt = Number(flags.find(a => a.startsWith('--attempt='))?.slice('--attempt='.length));
202
278
  const pidFile = flags.find((a) => a.startsWith('--pid-file='))?.slice('--pid-file='.length);
203
279
  const [command, ...args] = rest;
204
- return { idleMs: Number.isFinite(idleMs) ? idleMs : 0, command: command ?? '', args, ...(pidFile ? { pidFile } : {}) };
280
+ return { idleMs: Number.isFinite(idleMs) ? idleMs : 0, command: command ?? '', args, ...(pidFile ? { pidFile } : {}), ...(Number.isInteger(attempt) && attempt > 0 ? { attempt } : {}), ...(totalArg && Number.isFinite(Number(totalArg)) && Number(totalArg) > 0 ? { totalMs: Number(totalArg) } : {}) };
205
281
  }
206
282
  const isMain = process.argv[1] ? pathToFileURL(process.argv[1]).href === import.meta.url : false;
207
283
  if (isMain) {
208
- const { idleMs, command, args, pidFile } = parseWatchdogArgs(process.argv.slice(2));
284
+ const { idleMs, totalMs, attempt, command, args, pidFile } = parseWatchdogArgs(process.argv.slice(2));
209
285
  if (!command) {
210
286
  process.stderr.write('watchdog: no command given\n');
211
287
  process.exit(2);
212
288
  }
213
- runWatchdog({ command, args, idleMs, stdin: process.stdin, pidFile }).then((code) => process.exit(code));
289
+ runWatchdog({ command, args, idleMs, totalMs, attempt, stdin: process.stdin, pidFile }).then((code) => process.exit(code));
214
290
  }
@@ -1,3 +1,7 @@
1
+ import { knownInfrastructureFailure } from "../routing/capability.js";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { acceptanceProtectionProblem } from "../check/command.js";
1
5
  import { isAcceptanceCriterion } from './prd.js';
2
6
  import { runQualityRepairLoop } from '../quality/loop.js';
3
7
  function emptyEvidence() { return { criteria: [] }; }
@@ -167,7 +171,7 @@ export async function runStoryWorker(input) {
167
171
  }
168
172
  let implementation;
169
173
  try {
170
- implementation = await input.runner(context);
174
+ implementation = await runWorkerImplementation(input, context, evidence);
171
175
  }
172
176
  catch (error) {
173
177
  return finalResult(input, {
@@ -178,6 +182,10 @@ export async function runStoryWorker(input) {
178
182
  }
179
183
  if (implementation.tokens)
180
184
  input.reporter?.addTokens(implementation.tokens);
185
+ if (implementation.infrastructureFailure)
186
+ implementation.routing?.recordOutcome(false, 'infrastructure');
187
+ if (implementation.infrastructureFailure || implementation.routing?.blocked)
188
+ return finalResult(input, { ...baseResult(input, evidence, implementation.summary), kind: "mechanical-failure", stage: "implementation" });
181
189
  const afterImplementationCancellation = cancellationReason(input.cancellation);
182
190
  if (afterImplementationCancellation) {
183
191
  return finalResult(input, { ...baseResult(input, evidence, afterImplementationCancellation), kind: 'cancelled' });
@@ -263,3 +271,24 @@ export async function runStoryWorker(input) {
263
271
  }
264
272
  return finalResult(input, result);
265
273
  }
274
+ async function runWorkerImplementation(input, context, evidence) {
275
+ let feedback;
276
+ for (let attempt = 0;; attempt++) {
277
+ const result = await input.runner({ ...context, feedback });
278
+ if (!result.routing?.canRetry || result.routing.blocked || attempt >= 7 || cancellationReason(input.cancellation) || input.pause?.())
279
+ return result;
280
+ if (["decision-request.yaml", "ambiguity.md", "loop.pause"].some(name => existsSync(join(context.targetDir, ".yoke", name))) || acceptanceProtectionProblem(context.targetDir))
281
+ return result;
282
+ const gates = runMechanicalGates(input, context, evidence);
283
+ if (gates.kind !== "failed")
284
+ return result;
285
+ if (knownInfrastructureFailure(gates.summary)) {
286
+ result.routing.recordOutcome(false, 'infrastructure');
287
+ return { ...result, success: false, infrastructureFailure: true, summary: gates.summary, routing: { ...result.routing, blocked: true, canRetry: false } };
288
+ }
289
+ result.routing.recordOutcome(false);
290
+ if (result.tokens)
291
+ input.reporter?.addTokens(result.tokens);
292
+ feedback = gates.summary;
293
+ }
294
+ }