@hecer/yoke 1.20.0 → 1.22.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 (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +46 -0
  4. package/README.md +6 -1
  5. package/bench/analyze-codex-comparison.mjs +90 -17
  6. package/bench/compare-codex.mjs +159 -36
  7. package/bench/result-schema.mjs +132 -0
  8. package/canon/manifest.yaml +1 -1
  9. package/dist/agents/pi-telemetry.js +2 -1
  10. package/dist/agents/process-streams.js +12 -64
  11. package/dist/agents/provider-selection.js +12 -0
  12. package/dist/agents/telemetry.js +52 -52
  13. package/dist/change/inbox.js +8 -3
  14. package/dist/check/command.js +69 -17
  15. package/dist/check/delivery.js +121 -0
  16. package/dist/cli.js +86 -8
  17. package/dist/code-intelligence/budgets.js +138 -0
  18. package/dist/code-intelligence/contracts.js +2 -0
  19. package/dist/code-intelligence/coordinator.js +156 -84
  20. package/dist/code-intelligence/evidence.js +87 -34
  21. package/dist/code-intelligence/mcp-client.js +10 -2
  22. package/dist/code-intelligence/mcp-server.js +10 -10
  23. package/dist/dashboard/analytics.js +5 -3
  24. package/dist/goals/command.js +183 -53
  25. package/dist/goals/usage.js +87 -0
  26. package/dist/loop/candidate-cleanup.js +47 -17
  27. package/dist/loop/candidates.js +17 -11
  28. package/dist/loop/cleanup.js +100 -0
  29. package/dist/loop/dispatcher.js +89 -26
  30. package/dist/loop/failure.js +104 -0
  31. package/dist/loop/gate-snapshot.js +19 -0
  32. package/dist/loop/git.js +1 -1
  33. package/dist/loop/loop.js +100 -66
  34. package/dist/loop/parallel-adapters.js +22 -4
  35. package/dist/loop/parallel-command.js +32 -5
  36. package/dist/loop/recovery.js +23 -5
  37. package/dist/loop/reporter.js +21 -4
  38. package/dist/loop/run-command.js +102 -48
  39. package/dist/loop/runner.js +5 -4
  40. package/dist/loop/worker.js +145 -91
  41. package/dist/observability/history.js +1 -1
  42. package/dist/observability/invocation.js +42 -0
  43. package/dist/observability/usage.js +17 -0
  44. package/dist/prd/command.js +20 -7
  45. package/dist/prd/decompose.js +5 -2
  46. package/dist/retrofit/command.js +22 -0
  47. package/dist/retrofit/config.js +26 -1
  48. package/dist/retrofit/gitignore.js +2 -0
  49. package/dist/retrofit/planners/claude.js +4 -4
  50. package/dist/retrofit/wsl.js +23 -3
  51. package/dist/routing/attempts.js +241 -0
  52. package/dist/routing/capability.js +13 -9
  53. package/dist/routing/optimization.js +73 -0
  54. package/dist/routing/registry.js +7 -1
  55. package/dist/routing/router.js +280 -127
  56. package/dist/setup/command.js +54 -6
  57. package/dist/smoke/command.js +302 -74
  58. package/docs/BENCHMARK-MANIFEST.md +131 -0
  59. package/docs/CODE-INTELLIGENCE.md +43 -1
  60. package/docs/CODEX-COMPARISON-2026-09-29.md +15 -0
  61. package/docs/DELIVERY-JOURNEYS.md +199 -0
  62. package/docs/ECONOMIC-ROUTING.md +180 -0
  63. package/docs/GOALS.md +61 -4
  64. package/docs/RELEASE-VALIDATION-1.22.0.md +115 -0
  65. package/docs/parallel-execution.md +37 -9
  66. package/gemini-extension.json +1 -1
  67. package/package.json +1 -1
@@ -3,6 +3,9 @@ import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { acceptanceProtectionProblem } from "../check/command.js";
5
5
  import { isAcceptanceCriterion } from './prd.js';
6
+ import { observeFailure } from './failure.js';
7
+ import { consumeAmbiguity } from './loop.js';
8
+ import { gateIdentity, snapshotGates, reuseGates } from './gate-snapshot.js';
6
9
  import { runQualityRepairLoop } from '../quality/loop.js';
7
10
  function emptyEvidence() { return { criteria: [] }; }
8
11
  function errorMessage(error) {
@@ -40,6 +43,7 @@ function reviewOutcome(result) {
40
43
  return { kind: 'malformed', summary: result.summary };
41
44
  }
42
45
  function runMechanicalGates(input, context, evidence) {
46
+ evidence.criteria = [];
43
47
  const criteria = context.story.acceptance.filter(isAcceptanceCriterion);
44
48
  if (criteria.length === 0) {
45
49
  if (input.requireCriterionEvidence)
@@ -170,8 +174,13 @@ export async function runStoryWorker(input) {
170
174
  return finalResult(input, { ...baseResult(input, evidence, preflight.summary), kind: 'mechanical-failure', stage: 'quality' });
171
175
  }
172
176
  let implementation;
177
+ let verified;
178
+ let failure;
173
179
  try {
174
- implementation = await runWorkerImplementation(input, context, evidence);
180
+ const outcome = await runWorkerImplementation(input, context, evidence);
181
+ implementation = outcome.result;
182
+ verified = outcome.gates;
183
+ failure = outcome.failure;
175
184
  }
176
185
  catch (error) {
177
186
  return finalResult(input, {
@@ -185,110 +194,155 @@ export async function runStoryWorker(input) {
185
194
  if (implementation.infrastructureFailure)
186
195
  implementation.routing?.recordOutcome(false, 'infrastructure');
187
196
  if (implementation.infrastructureFailure || implementation.routing?.blocked)
188
- return finalResult(input, { ...baseResult(input, evidence, implementation.summary), kind: "mechanical-failure", stage: "implementation" });
189
- const afterImplementationCancellation = cancellationReason(input.cancellation);
190
- if (afterImplementationCancellation) {
191
- return finalResult(input, { ...baseResult(input, evidence, afterImplementationCancellation), kind: 'cancelled' });
192
- }
193
- const decision = input.beforeGates?.(context);
194
- if (decision) {
195
- return finalResult(input, { ...baseResult(input, evidence, decision), kind: 'paused', reason: 'decision' });
196
- }
197
- const gates = runMechanicalGates(input, context, evidence);
198
- if (gates.kind === 'cancelled') {
199
- return finalResult(input, { ...baseResult(input, evidence, gates.summary), kind: 'cancelled' });
200
- }
201
- if (gates.kind === 'failed') {
202
- implementation.routing?.recordOutcome(false);
203
- return finalResult(input, { ...baseResult(input, evidence, gates.summary), kind: 'mechanical-failure', stage: gates.stage });
204
- }
205
- const summary = implementation.success
206
- ? implementation.summary
207
- : `${implementation.summary} (runner exited non-zero but verify is green)`;
208
- if (!qualityEnabled && !input.review) {
209
- return finalResult(input, {
210
- ...baseResult(input, evidence, summary),
211
- kind: 'candidate',
212
- routing: { outcome: 'pending-integration', ...(implementation.routing ? { recordOutcome: implementation.routing.recordOutcome } : {}) },
213
- ...(implementation.tokens ? { tokens: implementation.tokens } : {}),
197
+ return finalResult(input, { ...baseResult(input, evidence, implementation.summary), kind: "mechanical-failure", stage: "implementation", ...(failure ? { failure } : {}) });
198
+ try {
199
+ const afterImplementationCancellation = cancellationReason(input.cancellation);
200
+ if (afterImplementationCancellation) {
201
+ implementation.routing?.recordOutcome(false, 'infrastructure');
202
+ return finalResult(input, { ...baseResult(input, evidence, afterImplementationCancellation), kind: 'cancelled' });
203
+ }
204
+ const ambiguity = consumeAmbiguity(context.targetDir);
205
+ if (ambiguity) {
206
+ implementation.routing?.recordOutcome(false, 'infrastructure');
207
+ return finalResult(input, {
208
+ ...baseResult(input, evidence, `story ${context.story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`),
209
+ kind: 'paused', reason: 'ambiguity',
210
+ });
211
+ }
212
+ const decision = input.beforeGates?.(context);
213
+ if (decision) {
214
+ implementation.routing?.recordOutcome(false, 'infrastructure');
215
+ return finalResult(input, { ...baseResult(input, evidence, decision), kind: 'paused', reason: 'decision' });
216
+ }
217
+ if (input.pause?.()) {
218
+ implementation.routing?.recordOutcome(false, 'infrastructure');
219
+ return finalResult(input, { ...baseResult(input, evidence, 'worker paused before acceptance checks'), kind: 'paused' });
220
+ }
221
+ const gates = reuseGates(context.targetDir, context.story, verified) ?? runMechanicalGates(input, context, evidence);
222
+ if (gates.kind === 'cancelled') {
223
+ implementation.routing?.recordOutcome(false, 'infrastructure');
224
+ return finalResult(input, { ...baseResult(input, evidence, gates.summary), kind: 'cancelled' });
225
+ }
226
+ if (gates.kind === 'failed') {
227
+ implementation.routing?.recordOutcome(false);
228
+ const observed = input.failureRoot ? observeFailure({ root: input.failureRoot, scope: input.failureScope, directory: context.targetDir, story: context.story, stage: gates.stage, summary: gates.summary }) : undefined;
229
+ return finalResult(input, { ...baseResult(input, evidence, observed?.feedback ?? gates.summary), kind: 'mechanical-failure', stage: gates.stage, ...(observed ? { failure: observed.failure } : {}) });
230
+ }
231
+ const summary = implementation.success
232
+ ? implementation.summary
233
+ : `${implementation.summary} (runner exited non-zero but verify is green)`;
234
+ if (!qualityEnabled && !input.review) {
235
+ return finalResult(input, {
236
+ ...baseResult(input, evidence, summary),
237
+ kind: 'candidate',
238
+ routing: { outcome: 'pending-integration', ...(implementation.routing ? { recordOutcome: implementation.routing.recordOutcome } : {}) },
239
+ ...(implementation.tokens ? { tokens: implementation.tokens } : {}),
240
+ });
241
+ }
242
+ const beforeQualityCancellation = cancellationReason(input.cancellation);
243
+ if (beforeQualityCancellation) {
244
+ implementation.routing?.recordOutcome(false, 'infrastructure');
245
+ return finalResult(input, { ...baseResult(input, evidence, beforeQualityCancellation), kind: 'cancelled' });
246
+ }
247
+ const qualityStage = qualityEnabled ? input.qualityStage : undefined;
248
+ const review = input.review;
249
+ let qualityFailure;
250
+ const quality = runQualityRepairLoop({
251
+ ...(qualityStage
252
+ ? { quality: (round) => {
253
+ input.reporter?.phase('comparing');
254
+ return qualityStage(context, round);
255
+ } }
256
+ : {}),
257
+ ...(review
258
+ ? { review: () => {
259
+ input.reporter?.phase('reviewing');
260
+ return reviewOutcome(review(context));
261
+ } }
262
+ : {}),
263
+ repair: request => {
264
+ if (!input.repair)
265
+ return { kind: 'blocked', summary: 'repair callback is not configured' };
266
+ input.reporter?.phase('repairing');
267
+ const observed = input.failureRoot ? observeFailure({ root: input.failureRoot, scope: input.failureScope, directory: context.targetDir, story: context.story, stage: 'quality', summary: JSON.stringify(request.finding) }) : undefined;
268
+ if (observed?.action === 'blocked') {
269
+ qualityFailure = observed.failure;
270
+ return { kind: 'blocked', summary: observed.feedback };
271
+ }
272
+ const targetedRequest = observed?.action === 'diagnose' ? { ...request, finding: { ...request.finding, message: observed.feedback } } : request;
273
+ const repair = input.repair(context, targetedRequest);
274
+ return repair.success ? { kind: 'repaired' } : { kind: 'blocked', summary: repair.summary };
275
+ },
276
+ rerunGates: () => {
277
+ const rerun = runMechanicalGates(input, context, evidence);
278
+ if (rerun.kind === 'passed')
279
+ return { kind: 'passed' };
280
+ if (rerun.kind === 'cancelled')
281
+ return rerun;
282
+ return { kind: 'failed', stage: rerun.stage, summary: rerun.summary };
283
+ },
284
+ ...(input.repairLimits ? { limits: input.repairLimits } : {}),
285
+ pause: input.pause,
286
+ onStatus: status => {
287
+ const metadata = input.qualityMetadata?.(context);
288
+ input.reporter?.quality({ ...status, ...(metadata ?? { policy: 'blocking' }) });
289
+ },
214
290
  });
291
+ evidence.quality = quality;
292
+ const afterQualityCancellation = cancellationReason(input.cancellation);
293
+ if (afterQualityCancellation) {
294
+ implementation.routing?.recordOutcome(false, 'infrastructure');
295
+ return finalResult(input, { ...baseResult(input, evidence, afterQualityCancellation), kind: 'cancelled' });
296
+ }
297
+ const result = { ...resultFromQuality(input, evidence, summary, quality), ...(qualityFailure ? { failure: qualityFailure } : {}) };
298
+ if (result.kind === 'paused' || result.kind === 'cancelled' || result.kind === 'review-failure' || result.kind === 'quality-failure')
299
+ implementation.routing?.recordOutcome(false, 'infrastructure');
300
+ else if (result.kind !== 'candidate')
301
+ implementation.routing?.recordOutcome(false);
302
+ if (result.kind === 'candidate') {
303
+ return finalResult(input, {
304
+ ...result,
305
+ routing: { outcome: 'pending-integration', ...(implementation.routing ? { recordOutcome: implementation.routing.recordOutcome } : {}) },
306
+ ...(implementation.tokens ? { tokens: implementation.tokens } : {}),
307
+ });
308
+ }
309
+ return finalResult(input, result);
215
310
  }
216
- const beforeQualityCancellation = cancellationReason(input.cancellation);
217
- if (beforeQualityCancellation) {
218
- return finalResult(input, { ...baseResult(input, evidence, beforeQualityCancellation), kind: 'cancelled' });
219
- }
220
- const qualityStage = qualityEnabled ? input.qualityStage : undefined;
221
- const review = input.review;
222
- const quality = runQualityRepairLoop({
223
- ...(qualityStage
224
- ? { quality: (round) => {
225
- input.reporter?.phase('comparing');
226
- return qualityStage(context, round);
227
- } }
228
- : {}),
229
- ...(review
230
- ? { review: () => {
231
- input.reporter?.phase('reviewing');
232
- return reviewOutcome(review(context));
233
- } }
234
- : {}),
235
- repair: request => {
236
- if (!input.repair)
237
- return { kind: 'blocked', summary: 'repair callback is not configured' };
238
- input.reporter?.phase('repairing');
239
- const repair = input.repair(context, request);
240
- return repair.success ? { kind: 'repaired' } : { kind: 'blocked', summary: repair.summary };
241
- },
242
- rerunGates: () => {
243
- const rerun = runMechanicalGates(input, context, evidence);
244
- if (rerun.kind === 'passed')
245
- return { kind: 'passed' };
246
- if (rerun.kind === 'cancelled')
247
- return rerun;
248
- return { kind: 'failed', stage: rerun.stage, summary: rerun.summary };
249
- },
250
- ...(input.repairLimits ? { limits: input.repairLimits } : {}),
251
- pause: input.pause,
252
- onStatus: status => {
253
- const metadata = input.qualityMetadata?.(context);
254
- input.reporter?.quality({ ...status, ...(metadata ?? { policy: 'blocking' }) });
255
- },
256
- });
257
- evidence.quality = quality;
258
- const afterQualityCancellation = cancellationReason(input.cancellation);
259
- if (afterQualityCancellation) {
260
- return finalResult(input, { ...baseResult(input, evidence, afterQualityCancellation), kind: 'cancelled' });
261
- }
262
- const result = resultFromQuality(input, evidence, summary, quality);
263
- if (result.kind !== 'candidate' && result.kind !== 'paused')
264
- implementation.routing?.recordOutcome(false);
265
- if (result.kind === 'candidate') {
266
- return finalResult(input, {
267
- ...result,
268
- routing: { outcome: 'pending-integration', ...(implementation.routing ? { recordOutcome: implementation.routing.recordOutcome } : {}) },
269
- ...(implementation.tokens ? { tokens: implementation.tokens } : {}),
270
- });
311
+ catch (error) {
312
+ implementation.routing?.recordOutcome(false, 'infrastructure');
313
+ throw error;
271
314
  }
272
- return finalResult(input, result);
273
315
  }
274
316
  async function runWorkerImplementation(input, context, evidence) {
275
- let feedback;
317
+ let feedback = input.feedback;
276
318
  for (let attempt = 0;; attempt++) {
277
319
  const result = await input.runner({ ...context, feedback });
278
320
  if (!result.routing?.canRetry || result.routing.blocked || attempt >= 7 || cancellationReason(input.cancellation) || input.pause?.())
279
- return result;
321
+ return { result };
280
322
  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;
323
+ return { result };
324
+ const before = gateIdentity(context.targetDir, context.story);
325
+ let gates;
326
+ try {
327
+ gates = runMechanicalGates(input, context, evidence);
328
+ }
329
+ catch (error) {
330
+ result.routing.recordOutcome(false, 'infrastructure');
331
+ throw error;
332
+ }
333
+ if (gates.kind !== 'failed') {
334
+ return { result, ...(gates.kind === 'passed' ? { gates: snapshotGates(context.targetDir, context.story, before, gates) } : {}) };
335
+ }
285
336
  if (knownInfrastructureFailure(gates.summary)) {
286
337
  result.routing.recordOutcome(false, 'infrastructure');
287
- return { ...result, success: false, infrastructureFailure: true, summary: gates.summary, routing: { ...result.routing, blocked: true, canRetry: false } };
338
+ return { result: { ...result, success: false, infrastructureFailure: true, summary: gates.summary, routing: { ...result.routing, blocked: true, canRetry: false } } };
288
339
  }
289
340
  result.routing.recordOutcome(false);
341
+ const observed = input.failureRoot ? observeFailure({ root: input.failureRoot, scope: input.failureScope, directory: context.targetDir, story: context.story, stage: gates.stage, summary: gates.summary }) : undefined;
342
+ if (observed?.action === 'blocked')
343
+ return { result: { ...result, success: false, summary: observed.feedback, routing: { ...result.routing, blocked: true, canRetry: false } }, failure: observed.failure };
290
344
  if (result.tokens)
291
345
  input.reporter?.addTokens(result.tokens);
292
- feedback = gates.summary;
346
+ feedback = observed?.feedback ?? gates.summary;
293
347
  }
294
348
  }
@@ -30,7 +30,7 @@ export function archiveMeasurement(root, event) {
30
30
  throw error;
31
31
  }
32
32
  const data = event.data ?? {};
33
- const allowed = ['inputTokens', 'outputTokens', 'cachedInputTokens', 'cacheWriteInputTokens', 'reasoningOutputTokens', 'totalCostUsd', 'agent', 'provider', 'model', 'actualModel', 'requestedModel', 'variant', 'role', 'calls', 'measurementComplete', 'costMeasurementComplete', 'usageAvailable', 'prediction', 'errorMs', 'withinObservedRange', 'escalated'];
33
+ const allowed = ['inputTokens', 'outputTokens', 'cachedInputTokens', 'cacheWriteInputTokens', 'reasoningOutputTokens', 'totalCostUsd', 'agent', 'provider', 'model', 'actualModel', 'requestedProvider', 'requestedModel', 'requestedReasoningEffort', 'requestedVariant', 'variant', 'role', 'callId', 'parentCallId', 'calls', 'measurementComplete', 'costMeasurementComplete', 'usageAvailable', 'prediction', 'errorMs', 'withinObservedRange', 'escalated'];
34
34
  const compact = { ...event, data: Object.fromEntries(allowed.filter(key => data[key] !== undefined).map(key => [key, data[key]])) };
35
35
  appendFileSync(file, JSON.stringify(compact) + '\n');
36
36
  }
@@ -0,0 +1,42 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { parseProviderTelemetry } from '../agents/telemetry.js';
3
+ import { appendEvent } from './events.js';
4
+ import { providerTelemetryUsage } from './usage.js';
5
+ /** A single invocation boundary, including failures and injected runner seams. */
6
+ export function measureInvocation(options) {
7
+ const started = Date.now(), callId = randomUUID(), runId = options.runId ?? randomUUID();
8
+ let result;
9
+ let failure;
10
+ const base = { runId, storyId: options.storyId, attemptId: callId, agent: options.agent, role: options.role };
11
+ appendEvent(options.root, { ...base, timestamp: new Date(started).toISOString(), type: 'status', data: { callId, parentCallId: options.parentCallId, state: 'started' } });
12
+ try {
13
+ result = options.execute(options.invocation);
14
+ return result;
15
+ }
16
+ catch (error) {
17
+ failure = error;
18
+ throw error;
19
+ }
20
+ finally {
21
+ let salvaged;
22
+ try {
23
+ const stdout = failure && typeof failure === 'object' && 'stdout' in failure ? failure.stdout : undefined;
24
+ salvaged = stdout == null ? undefined : providerTelemetryUsage(parseProviderTelemetry(options.agent, String(stdout).split(/\r?\n/u)));
25
+ }
26
+ catch { /* Failed measurement must not replace the original provider error. */ }
27
+ const usage = result?.tokens ?? salvaged;
28
+ const complete = usage !== undefined && usage.measurementComplete !== false;
29
+ const durationMs = Date.now() - started;
30
+ appendEvent(options.root, {
31
+ ...base, timestamp: new Date().toISOString(), type: 'tokens', durationMs, outcome: result?.success ? 'succeeded' : 'failed',
32
+ data: {
33
+ ...usage, callId, parentCallId: options.parentCallId,
34
+ provider: options.agent, role: options.role,
35
+ requestedProvider: options.selection?.provider, requestedModel: options.selection?.model,
36
+ requestedReasoningEffort: options.selection?.reasoningEffort, requestedVariant: options.selection?.variant,
37
+ actualModel: usage?.model, measurementComplete: complete, usageAvailable: complete,
38
+ costMeasurementComplete: usage?.costMeasurementComplete ?? (typeof usage?.totalCostUsd === 'number'),
39
+ },
40
+ });
41
+ }
42
+ }
@@ -0,0 +1,17 @@
1
+ /** Preserve known lower bounds; missing telemetry must never become a free call. */
2
+ export function providerTelemetryUsage(telemetry) {
3
+ if (!telemetry.tokens && !telemetry.partialUsage)
4
+ return undefined;
5
+ const known = { ...telemetry.partialUsage, ...telemetry.tokens };
6
+ if (!Object.values(known).some(value => typeof value === 'number'))
7
+ return undefined;
8
+ const model = known.model ?? (telemetry.reportedModels?.length === 1 ? telemetry.reportedModels[0] : undefined);
9
+ return {
10
+ ...known,
11
+ ...(model ? { model } : {}),
12
+ inputTokens: known.inputTokens ?? 0,
13
+ outputTokens: known.outputTokens ?? 0,
14
+ measurementComplete: telemetry.usageAvailable,
15
+ costMeasurementComplete: typeof telemetry.tokens?.totalCostUsd === 'number',
16
+ };
17
+ }
@@ -7,9 +7,11 @@ import { bindAssessments, preparedProblems } from './assess.js';
7
7
  import { resolvePlanner } from '../routing/planning.js';
8
8
  import { readPlanningFile } from '../routing/contracts.js';
9
9
  import { acquireLock, releaseLock } from '../loop/lock.js';
10
- import { agentInvocation, buildWatchdogInvocation, runAgent, isAgentAvailable, } from '../loop/runner.js';
10
+ import { agentInvocation, buildWatchdogInvocation, runCapturedAgent, isAgentAvailable, } from '../loop/runner.js';
11
11
  import { resolveIdleMs } from '../loop/run-command.js';
12
12
  import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
13
+ import { measureInvocation } from '../observability/invocation.js';
14
+ import { statePath } from '../workspace/state.js';
13
15
  export const PRD_TEMPLATE = `# Yoke PRD — the loop picks the lowest-priority open story each iteration.
14
16
  # Story format (see canon/loop/prd.schema.md):
15
17
  # - id: STORY-1
@@ -92,14 +94,25 @@ export function runPrdDraft(targetDir, opts) {
92
94
  }
93
95
  try {
94
96
  const before = readPlanningFile(targetDir, '.yoke/prd.yaml');
95
- const rollback = () => { if (before === undefined)
96
- rmSync(path, { force: true });
97
- else
98
- writeFileSync(path, before); };
97
+ const rollback = () => {
98
+ const destination = join(statePath(targetDir), 'prd.yaml');
99
+ // Unlink a provider-created file link instead of writing through it.
100
+ rmSync(destination, { force: true });
101
+ if (before !== undefined)
102
+ writeFileSync(destination, before, { flag: 'wx' });
103
+ };
99
104
  const inv = agentInvocation(agent, buildPrdDraftPrompt(idea, planningBrief), targetDir, 'safe', planner.selection);
100
105
  console.log(`Drafting PRD with ${agent}...`);
101
- const run = opts.run ?? ((i) => runAgent(buildWatchdogInvocation(i, idleMs)));
102
- const result = run(inv);
106
+ const run = opts.run ?? ((i) => runCapturedAgent(agent, buildWatchdogInvocation(i, idleMs)));
107
+ let result;
108
+ try {
109
+ result = measureInvocation({ root: targetDir, agent, role: 'prd-draft', selection: planner.selection, invocation: inv, execute: run });
110
+ }
111
+ catch (error) {
112
+ rollback();
113
+ console.error(`PRD draft failed: ${error.message}`);
114
+ return 1;
115
+ }
103
116
  if (!result.success) {
104
117
  rollback();
105
118
  console.error(`PRD draft failed: ${result.summary}`);
@@ -11,7 +11,8 @@ import { writeScopesOverlap, validWriteScope } from '../loop/scheduler.js';
11
11
  import { withSharedWorkerSync } from '../loop/resource-pool.js';
12
12
  import { loadConfig } from '../retrofit/config.js';
13
13
  import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
14
- import { agentInvocation, buildWatchdogInvocation, isAgentAvailable, runAgent } from '../loop/runner.js';
14
+ import { agentInvocation, buildWatchdogInvocation, isAgentAvailable, runCapturedAgent } from '../loop/runner.js';
15
+ import { measureInvocation } from '../observability/invocation.js';
15
16
  const ProposalSchema = z.array(z.object({
16
17
  id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/u),
17
18
  title: z.string().min(1).max(300),
@@ -181,7 +182,9 @@ export function runPrdDecompose(root, options) {
181
182
  if (prompt.length > 60_000)
182
183
  throw Error('Planning input exceeds 60000 characters; condense .yoke/plan.md first');
183
184
  const invocation = agentInvocation(planner.agent, prompt, root, 'safe', planner.selection);
184
- const result = withSharedWorkerSync({ targetDir: root, storyId: `prd-decompose:${parent.id}`, provider: planner.agent, role: 'implementation' }, () => (options.run ?? (item => runAgent(buildWatchdogInvocation(item, options.timeoutMinutes === undefined ? 20 * 60_000 : options.timeoutMinutes > 0 ? options.timeoutMinutes * 60_000 : 0))))(invocation));
185
+ const result = withSharedWorkerSync({ targetDir: root, storyId: `prd-decompose:${parent.id}`, provider: planner.agent, role: 'implementation' }, () => measureInvocation({ root, agent: planner.agent, role: 'prd-decompose', storyId: parent.id, selection: planner.selection, invocation,
186
+ execute: options.run ?? (item => runCapturedAgent(planner.agent, buildWatchdogInvocation(item, options.timeoutMinutes === undefined ? 20 * 60_000 : options.timeoutMinutes > 0 ? options.timeoutMinutes * 60_000 : 0))),
187
+ }));
185
188
  if (!result.success)
186
189
  throw Error(`planning request failed: ${result.summary}`);
187
190
  if (!existsSync(proposalPath))
@@ -8,9 +8,16 @@ import { ensureGitignore } from './gitignore.js';
8
8
  import { loadConfig, saveConfig, defaultConfig } from './config.js';
9
9
  import { loadManifest } from '../canon/manifest.js';
10
10
  import { detectHostAgent } from '../agents/host.js';
11
+ import { pruneWorktrees } from '../loop/cleanup.js';
11
12
  export function runRetrofit(targetDir, opts) {
12
13
  const canonDir = resolveCanonDir();
13
14
  const canonVersion = loadManifest(join(canonDir, 'manifest.yaml')).version;
15
+ if (opts.cleanWorktrees) {
16
+ const pruneRes = pruneWorktrees(targetDir);
17
+ if (pruneRes.removed.length > 0) {
18
+ console.log(`Pruned ${pruneRes.removed.length} orphaned worktree(s).`);
19
+ }
20
+ }
14
21
  const detection = detectProject(targetDir);
15
22
  const agents = opts.agents && opts.agents.length > 0
16
23
  ? opts.agents
@@ -26,6 +33,19 @@ export function runRetrofit(targetDir, opts) {
26
33
  ensureGitignore(targetDir);
27
34
  const priorAgents = existing?.agents ?? [];
28
35
  const mergedAgents = [...new Set([...priorAgents, ...agents])];
36
+ const runnerConfig = { ...(existing?.runner ?? {}) };
37
+ if (opts.runnerModel !== undefined)
38
+ runnerConfig.model = opts.runnerModel;
39
+ if (opts.runnerReasoning !== undefined)
40
+ runnerConfig.reasoningEffort = opts.runnerReasoning;
41
+ let updatedWorkers = existing?.routing?.workers;
42
+ if (updatedWorkers && (opts.agentModels || opts.agentReasoning)) {
43
+ updatedWorkers = updatedWorkers.map(w => {
44
+ const model = opts.agentModels?.[w.agent] ?? w.model;
45
+ const reasoningEffort = opts.agentReasoning?.[w.agent] ?? w.reasoningEffort;
46
+ return { ...w, ...(model ? { model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}) };
47
+ });
48
+ }
29
49
  const config = {
30
50
  ...(existing ?? defaultConfig(canonVersion)),
31
51
  canonVersion,
@@ -33,6 +53,8 @@ export function runRetrofit(targetDir, opts) {
33
53
  loop: { ...existing?.loop, enabled: opts.loop },
34
54
  codeGraph,
35
55
  codeIntelligence: { ...(existing?.codeIntelligence ?? {}), mode: codeIntelligence },
56
+ ...(Object.keys(runnerConfig).length > 0 ? { runner: runnerConfig } : {}),
57
+ ...(existing?.routing && updatedWorkers ? { routing: { ...existing.routing, workers: updatedWorkers } } : {}),
36
58
  ...(existing?.design
37
59
  ? { design: existing.design }
38
60
  : detection.ui.detected ? { design: { mode: 'auto', max: 4 } } : {}),
@@ -16,7 +16,21 @@ const CodeIntelligenceSchema = z.object({
16
16
  policy: z.object({ allowNetwork: z.boolean().default(false), excludePatterns: z.array(z.string().min(1)).max(100).default([]), maxFileBytes: z.number().int().positive().max(20_000_000).optional() }).optional(),
17
17
  limits: z.object({ tokenBudget: z.number().int().min(128).max(16000).default(2400), timeoutMs: z.number().int().min(100).max(600000).default(10000), maxBytes: z.number().int().positive().max(10_000_000).default(2_000_000), maxBackends: z.number().int().min(1).max(3).default(3) }).optional(),
18
18
  });
19
- const SmokeFlowSchema = z.object({ name: z.string().min(1), path: z.string().min(1), landmark: z.string().optional() });
19
+ const SmokeStepTimeout = z.number().int().min(1).max(30000).optional();
20
+ const SmokeSelector = z.string().min(1).max(4096);
21
+ const SmokeStepSchema = z.discriminatedUnion('action', [
22
+ z.object({ action: z.literal('click'), selector: SmokeSelector, timeoutMs: SmokeStepTimeout }).strict(),
23
+ z.object({ action: z.literal('fill'), selector: SmokeSelector, value: z.string().max(8192).optional(), valueEnv: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/u).optional(), timeoutMs: SmokeStepTimeout }).strict(),
24
+ z.object({ action: z.literal('press'), selector: SmokeSelector, key: z.string().min(1).max(128), timeoutMs: SmokeStepTimeout }).strict(),
25
+ z.object({ action: z.literal('expect-visible'), selector: SmokeSelector, timeoutMs: SmokeStepTimeout }).strict(),
26
+ z.object({ action: z.literal('expect-text'), selector: SmokeSelector, text: z.string().max(8192), exact: z.boolean().optional(), timeoutMs: SmokeStepTimeout }).strict(),
27
+ z.object({ action: z.literal('expect-url'), url: z.string().min(1).max(4096), timeoutMs: SmokeStepTimeout }).strict(),
28
+ z.object({ action: z.literal('reload'), timeoutMs: SmokeStepTimeout }).strict(),
29
+ ]).superRefine((step, context) => {
30
+ if (step.action === 'fill' && (step.value === undefined) === (step.valueEnv === undefined))
31
+ context.addIssue({ code: 'custom', message: 'A fill step needs exactly one of value or valueEnv' });
32
+ });
33
+ const SmokeFlowSchema = z.object({ name: z.string().min(1), path: z.string().min(1), landmark: z.string().optional(), timeoutMs: z.number().int().min(1).max(120000).optional(), steps: z.array(SmokeStepSchema).min(1).max(50).optional() });
20
34
  const SmokeSchema = z.object({ baseUrl: z.string().min(1), flows: z.array(SmokeFlowSchema).min(1) });
21
35
  const OutputPolicySchema = z.object({
22
36
  previewBytes: z.number().int().positive().optional(),
@@ -56,6 +70,12 @@ const RoutingWorkerSchema = z.object({
56
70
  capabilities: z.array(z.string().min(1)).default([]),
57
71
  tier: z.enum(['light', 'standard', 'strong', 'frontier']).optional(),
58
72
  roles: z.array(z.enum(['implementation', 'reviewer', 'critic', 'repair'])).optional(),
73
+ profileMetadata: z.object({
74
+ version: z.literal(1),
75
+ catalog: z.string().min(1).max(120),
76
+ source: z.enum(['yoke-default', 'operator']),
77
+ basis: z.literal('configured-prior'),
78
+ }).strict().optional(),
59
79
  });
60
80
  const RoutingRuleSchema = z.object({
61
81
  area: z.string().min(1).optional(),
@@ -104,6 +124,11 @@ export const YokeConfigSchema = z.object({
104
124
  assessmentPolicy: z.enum(['on-demand', 'prepared']).optional(),
105
125
  fallback: z.enum(['parent', 'block']).optional(),
106
126
  maxTier: z.enum(['light', 'standard', 'strong', 'frontier']).optional(),
127
+ optimization: z.object({
128
+ version: z.literal(1),
129
+ objective: z.enum(['cost', 'speed', 'balanced']),
130
+ minSamples: z.number().int().min(10).max(10000).optional(),
131
+ }).strict().optional(),
107
132
  maxCandidates: z.number().int().min(1).max(5).default(3),
108
133
  orchestrator: z.object({
109
134
  provider: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/).optional(),
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  export const YOKE_IGNORE_LINES = [
4
4
  '.yoke/worktrees/',
5
5
  '.yoke/integration-recovery/',
6
+ '.yoke/failure-progress/',
6
7
  '.yoke/run-state.json',
7
8
  '.yoke/run-state.*.tmp',
8
9
  '.yoke/backup/',
@@ -31,6 +32,7 @@ export const YOKE_IGNORE_LINES = [
31
32
  '.yoke/checks/',
32
33
  '.yoke/events/',
33
34
  '.yoke/history/', '.yoke/routing/',
35
+ '.yoke/routing-attempts/',
34
36
  '.yoke/goal.json',
35
37
  '.yoke/goal.json.*.tmp',
36
38
  '.yoke/goal.pause',
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { loadManifest } from '../../canon/manifest.js';
4
4
  import { mcpServers, rtkInstruction } from '../tools.js';
5
- import { hasWsl } from '../wsl.js';
5
+ import { hasWsl, hasRtk } from '../wsl.js';
6
6
  import { detectGstack } from '../gstack.js';
7
7
  import { PRESERVE_SCAFFOLD } from '../preserve.js';
8
8
  import { skillPackageActions } from '../skill-actions.js';
@@ -22,7 +22,7 @@ This project uses the Yoke harness. Baseline instructions:
22
22
  ${rtkNote ? `\n${rtkNote}\n` : ''}${composeNote ? `\n${composeNote}\n` : ''}
23
23
  ${PRESERVE_SCAFFOLD}
24
24
  `;
25
- export function planClaude(canonDir, targetDir, wslAvailable = hasWsl(), codeGraph = 'graphify', gstackDetected = detectGstack(targetDir), codeIntelligence = 'off') {
25
+ export function planClaude(canonDir, targetDir, wslAvailable = (hasWsl() || hasRtk()), codeGraph = 'graphify', gstackDetected = detectGstack(targetDir), codeIntelligence = 'off', rtkAvailable) {
26
26
  const manifest = loadManifest(join(canonDir, 'manifest.yaml'));
27
27
  const actions = [];
28
28
  for (const skill of manifest.skills) {
@@ -34,8 +34,8 @@ export function planClaude(canonDir, targetDir, wslAvailable = hasWsl(), codeGra
34
34
  content: readFileSync(join(canonDir, 'AGENTS.md'), 'utf8'),
35
35
  reason: 'baseline instructions',
36
36
  });
37
- // rtk: PreToolUse hook needs WSL on Windows; otherwise fall back to instruction mode.
38
- const rtkHookable = wslAvailable;
37
+ // rtk: PreToolUse hook works natively if rtk is available, or via WSL on Windows.
38
+ const rtkHookable = rtkAvailable !== undefined ? (wslAvailable || rtkAvailable) : wslAvailable;
39
39
  actions.push({
40
40
  kind: 'write',
41
41
  target: 'CLAUDE.md',
@@ -1,7 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- // True only on Windows where a WSL distribution responds. Used to decide whether
3
- // rtk can use its transparent PreToolUse hook (needs WSL) or must fall back to
4
- // instruction mode. Never throws.
2
+ import { join } from 'node:path';
3
+ // True only on Windows where a WSL distribution responds.
5
4
  export function hasWsl() {
6
5
  if (process.platform !== 'win32')
7
6
  return false;
@@ -13,3 +12,24 @@ export function hasWsl() {
13
12
  return false;
14
13
  }
15
14
  }
15
+ // True if the rtk binary is available natively or on PATH. Never throws.
16
+ export function hasRtk() {
17
+ try {
18
+ const cmd = process.platform === 'win32' ? 'rtk.exe' : 'rtk';
19
+ execFileSync(cmd, ['--version'], { stdio: 'pipe', timeout: 3000 });
20
+ return true;
21
+ }
22
+ catch {
23
+ if (process.platform === 'win32') {
24
+ const home = process.env.USERPROFILE ?? process.env.HOME;
25
+ if (home) {
26
+ try {
27
+ execFileSync(join(home, '.local', 'bin', 'rtk.exe'), ['--version'], { stdio: 'pipe', timeout: 3000 });
28
+ return true;
29
+ }
30
+ catch { /* ignore */ }
31
+ }
32
+ }
33
+ return false;
34
+ }
35
+ }