@hecer/yoke 1.19.0 → 1.21.1

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 (55) 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 +4 -1
  5. package/bench/RESULTS.md +30 -0
  6. package/bench/analyze-codex-comparison.mjs +22 -0
  7. package/bench/compare-codex.mjs +71 -0
  8. package/bench/fixtures/independent-utils/.yoke/prd.yaml +30 -0
  9. package/bench/fixtures/independent-utils/bench-verify.mjs +4 -0
  10. package/bench/fixtures/independent-utils/package.json +1 -0
  11. package/bench/fixtures/independent-utils/src/chunk.mjs +1 -0
  12. package/bench/fixtures/independent-utils/src/range.mjs +1 -0
  13. package/bench/fixtures/independent-utils/src/unique.mjs +1 -0
  14. package/bench/fixtures/independent-utils/tests/STORY-1.test.mjs +8 -0
  15. package/bench/fixtures/independent-utils/tests/STORY-2.test.mjs +8 -0
  16. package/bench/fixtures/independent-utils/tests/STORY-3.test.mjs +8 -0
  17. package/bench/probe-goal-integration.mjs +93 -0
  18. package/bench/probe-parallel-gate-context.mjs +29 -0
  19. package/bench/results/codex-comparison-2026-09-29.json +305 -0
  20. package/bench/results/goal-integration-probes-2026-09-29.json +107 -0
  21. package/bench/results/native-goal-validation-2026-09-30.json +122 -0
  22. package/bench/results/parallel-validation-2026-09-30.json +86 -0
  23. package/dist/agents/process-record-identity.js +33 -0
  24. package/dist/check/command.js +218 -0
  25. package/dist/cli.js +85 -12
  26. package/dist/dashboard/server.js +26 -6
  27. package/dist/goals/codex-native.js +340 -0
  28. package/dist/goals/command.js +230 -29
  29. package/dist/loop/cleanup.js +110 -1
  30. package/dist/loop/dispatcher.js +82 -10
  31. package/dist/loop/git.js +1 -1
  32. package/dist/loop/parallel-adapters.js +53 -7
  33. package/dist/loop/parallel-command.js +8 -1
  34. package/dist/loop/recovery.js +81 -2
  35. package/dist/loop/reporter.js +3 -0
  36. package/dist/loop/resource-pool.js +21 -15
  37. package/dist/loop/run-command.js +98 -27
  38. package/dist/loop/run-state.js +116 -0
  39. package/dist/prd/explore.js +12 -4
  40. package/dist/retrofit/command.js +22 -0
  41. package/dist/retrofit/config.js +1 -0
  42. package/dist/retrofit/gitignore.js +3 -0
  43. package/dist/retrofit/planners/claude.js +4 -4
  44. package/dist/retrofit/wsl.js +23 -3
  45. package/dist/setup/command.js +47 -5
  46. package/docs/CODEX-COMPARISON-2026-09-29.md +27 -0
  47. package/docs/CONTINUOUS-EXPLORATION.md +6 -2
  48. package/docs/GOALS-RESOURCE-AUDIT-2026-09-29.md +92 -0
  49. package/docs/GOALS.md +109 -0
  50. package/docs/RELEASE-VALIDATION-1.20.0.md +100 -0
  51. package/docs/VERIFIED-PROJECTS.md +4 -4
  52. package/docs/parallel-execution.md +15 -0
  53. package/docs/superpowers/plans/2026-09-30-goals-resources-release.md +78 -0
  54. package/gemini-extension.json +1 -1
  55. package/package.json +5 -1
@@ -2,13 +2,15 @@ import { roleSelection } from "../routing/capability.js";
2
2
  import { join } from 'node:path';
3
3
  import { existsSync, unlinkSync } from 'node:fs';
4
4
  import { loadConfig, saveConfig, defaultConfig, resolveOutputPolicy, resolveVerifyCommand } from '../retrofit/config.js';
5
- import { allPass, loadPrd, progress, selectNextStory } from './prd.js';
5
+ import { allPass, loadPrd, progress, selectNextStory, storyPathSegment } from './prd.js';
6
6
  import { pauseFilePath, requestLoopPause, runLoop } from './loop.js';
7
7
  import { commitPaths, realGitOps } from './git.js';
8
8
  import { makeRunner, makeReviewRunner, isAgentAvailable } from './runner.js';
9
9
  import { commandVerifier, commandsVerifier, retryingVerifier } from './verify.js';
10
10
  import { readStatus, makeReporter, fmtDuration } from './reporter.js';
11
- import { acquireLock, releaseLock } from './lock.js';
11
+ import { acquireLock, releaseLock, readLock } from './lock.js';
12
+ import { statePath } from '../workspace/state.js';
13
+ import { accountRunIterations, createLoopRun, readRunState, writeRunState } from './run-state.js';
12
14
  import { maybeAutoUpgrade } from '../update/upgrade.js';
13
15
  import { resolveCommitIdentity } from './identity.js';
14
16
  import { runAudit } from '../audit/command.js';
@@ -51,7 +53,7 @@ export function setLoopEnabled(targetDir, enabled) {
51
53
  config.loop = { ...config.loop, enabled };
52
54
  saveConfig(targetDir, config);
53
55
  }
54
- export function loopStatus(targetDir, now = () => new Date()) {
56
+ export function loopStatus(targetDir, now = () => new Date(), opts) {
55
57
  const config = loadConfig(targetDir);
56
58
  const enabled = config?.loop.enabled ?? false;
57
59
  const path = prdPath(targetDir);
@@ -60,6 +62,12 @@ export function loopStatus(targetDir, now = () => new Date()) {
60
62
  const p = progress(loadPrd(path));
61
63
  prog = `${p.passed}/${p.total} stories pass`;
62
64
  }
65
+ const st = readStatus(targetDir);
66
+ if (opts?.compact) {
67
+ if (!st)
68
+ return `state=${enabled ? 'enabled' : 'disabled'} prd="${prog}"`;
69
+ return `state=${st.state} story=${st.story ?? 'none'} progress=${st.progress.passed}/${st.progress.total} phase=${st.phase} updated=${relativeTime(st.updatedAt, now())}`;
70
+ }
63
71
  const sharedPoolLine = () => {
64
72
  try {
65
73
  const pool = sharedPoolStatus();
@@ -69,7 +77,6 @@ export function loopStatus(targetDir, now = () => new Date()) {
69
77
  return `Shared pool unavailable: ${error.message}`;
70
78
  }
71
79
  };
72
- const st = readStatus(targetDir);
73
80
  if (!st)
74
81
  return `Loop: ${enabled ? 'enabled' : 'disabled'}\nPRD: ${prog}\n${sharedPoolLine()}`;
75
82
  const head = `Loop: ${st.state.toUpperCase()}${st.story ? ` on ${st.story}${st.storyTitle ? ` "${st.storyTitle}"` : ''}` : ''}`;
@@ -232,7 +239,7 @@ async function runContinuousExploration(targetDir, options) {
232
239
  console.error('--explore-limit must be a positive, supported duration');
233
240
  return 2;
234
241
  }
235
- const limitDeadline = options.exploreLimitMs === undefined ? undefined : Date.now() + options.exploreLimitMs;
242
+ const limitDeadline = options.savedRun?.exploreDeadline ?? (options.exploreLimitMs === undefined ? undefined : Date.now() + options.exploreLimitMs);
236
243
  const reporter = options.reporter ?? makeReporter(targetDir, { json: options.json, quiet: true });
237
244
  let config;
238
245
  try {
@@ -242,11 +249,11 @@ async function runContinuousExploration(targetDir, options) {
242
249
  config = null;
243
250
  }
244
251
  const defaultImplementationAgent = options.agent ?? resolveRunnerAgent(config, undefined, detectHostAgent());
245
- const defaultExplorer = options.agent ?? config?.planning?.agent ?? defaultImplementationAgent;
252
+ const defaultExplorer = options.explorationPlanner?.agent ?? options.agent ?? config?.planning?.agent ?? defaultImplementationAgent;
246
253
  const available = options.isAvailable ?? isAgentAvailable;
247
254
  const reviewRequested = !options.reviewRunner && Boolean(options.review || options.reviewer);
248
255
  const innerOptions = { ...options, isolate: true, explore: true, explorationSupervisor: true, reporter, quiet: true };
249
- let usedIterations = 0;
256
+ let usedIterations = options.savedRun?.consumedIterations ?? 0;
250
257
  let retryCount = 0;
251
258
  let explorationFailures = 0;
252
259
  let noActionScans = 0;
@@ -284,6 +291,8 @@ async function runContinuousExploration(targetDir, options) {
284
291
  return 'limit';
285
292
  const explorer = retryProviders(targetDir, explorationAgent, available);
286
293
  const result = runPrdExplore(targetDir, {
294
+ ownedLockToken: options.ownedLockToken,
295
+ ...(explorationAgent === options.explorationPlanner?.agent ? { selection: options.explorationPlanner.selection } : {}),
287
296
  runner: explorationAgent,
288
297
  timeoutMinutes: options.timeoutMinutes,
289
298
  isAvailable: options.isAvailable ?? isAgentAvailable,
@@ -370,7 +379,9 @@ async function runContinuousExploration(targetDir, options) {
370
379
  }
371
380
  const afterStatus = readStatus(targetDir);
372
381
  const changedStatus = afterStatus?.updatedAt !== beforeStatus?.updatedAt;
373
- if (changedStatus && priorProgress.passed < priorProgress.total)
382
+ if (options.savedRun)
383
+ usedIterations = options.savedRun.consumedIterations ?? usedIterations;
384
+ else if (changedStatus && priorProgress.passed < priorProgress.total)
374
385
  usedIterations += Math.max(0, afterStatus?.iteration ?? 0);
375
386
  if (unexpectedFailure) {
376
387
  retryCount++;
@@ -468,7 +479,7 @@ async function runContinuousExploration(targetDir, options) {
468
479
  }
469
480
  retryCount++;
470
481
  advanceRecoveryProviders();
471
- retryWorktree = true;
482
+ retryWorktree = (afterStatus?.parallel?.reopened ?? 0) === 0;
472
483
  const wait = retryDelay(retryCount);
473
484
  reporter.phase('waiting-recovery', `story ${afterStatus?.story ?? 'unknown'} remains incomplete: ${afterStatus?.reason ?? 'no passing completion status'}; retrying with ${implementationAgent ?? 'the configured runner'} in ${Math.ceil(wait / 1_000)} seconds`, currentProgress(targetDir));
474
485
  say(`The current task remains unfinished; its isolated work is retained. Next: retry with ${implementationAgent ?? 'the configured runner'} in ${Math.ceil(wait / 1_000)} seconds.`);
@@ -480,7 +491,7 @@ async function runContinuousExploration(targetDir, options) {
480
491
  }
481
492
  export function runLoopCommand(targetDir, opts) {
482
493
  if (opts.explore && !opts.explorationSupervisor)
483
- return runContinuousExploration(targetDir, opts);
494
+ return startContinuousExploration(targetDir, opts);
484
495
  let parallel = opts.parallel ?? 1;
485
496
  const candidates = opts.candidates ?? 1;
486
497
  if (opts.resumeWorktree && (opts.isolate === false || parallel !== 1 || candidates !== 1)) {
@@ -554,7 +565,8 @@ export function runLoopCommand(targetDir, opts) {
554
565
  return 2;
555
566
  }
556
567
  const isolate = opts.isolate ?? config.loop.isolate ?? true;
557
- const useParallelDispatcher = parallel > 1 || candidates > 1;
568
+ const retainedParallel = opts.explorationSupervisor && loadPrd(path).some(story => !story.passes && existsSync(statePath(targetDir, 'integration-recovery', `${storyPathSegment(story.id)}.json`)));
569
+ const useParallelDispatcher = parallel > 1 || candidates > 1 || retainedParallel === true;
558
570
  if (opts.resumeWorktree && (!isolate || parallel !== 1)) {
559
571
  console.error('--resume-worktree requires isolation and one worker');
560
572
  return 2;
@@ -668,11 +680,13 @@ export function runLoopCommand(targetDir, opts) {
668
680
  const routingRequested = opts.routing ?? config.routing?.enabled ?? true;
669
681
  const routingEnabled = routingRequested && Boolean(config.routing && (config.routing.workers.length || config.routing.fallback === 'block' || config.routing.maxTier || config.routing.assessmentPolicy === 'prepared'));
670
682
  const runnerSelection = {
671
- provider: config.runner?.provider,
672
- model: config.runner?.model,
673
- reasoningEffort: config.runner?.reasoningEffort,
674
- variant: config.runner?.variant,
675
- bare: config.runner?.bare,
683
+ ...(opts.selection ?? {
684
+ provider: config.runner?.provider,
685
+ model: config.runner?.model,
686
+ reasoningEffort: config.runner?.reasoningEffort,
687
+ variant: config.runner?.variant,
688
+ bare: config.runner?.bare,
689
+ }),
676
690
  nativeMultiAgent: false,
677
691
  };
678
692
  const parallelProviders = [{
@@ -735,13 +749,7 @@ export function runLoopCommand(targetDir, opts) {
735
749
  timeoutMs: idleMs,
736
750
  isAvailable: available,
737
751
  permissions,
738
- selection: {
739
- provider: config.runner?.provider,
740
- model: config.runner?.model,
741
- reasoningEffort: config.runner?.reasoningEffort,
742
- variant: config.runner?.variant,
743
- bare: config.runner?.bare,
744
- },
752
+ selection: runnerSelection,
745
753
  commit: (_path, request) => commitPaths(targetDir, ['.yoke/prd.yaml'], `yoke: plan change ${request.id}`, commitIdentity),
746
754
  }));
747
755
  let runner = opts.runner;
@@ -854,7 +862,14 @@ export function runLoopCommand(targetDir, opts) {
854
862
  }
855
863
  let lock;
856
864
  try {
857
- lock = acquireLock(targetDir);
865
+ statePath(targetDir, 'loop.lock');
866
+ if (opts.ownedLockToken) {
867
+ if (readLock(targetDir)?.ownerToken !== opts.ownedLockToken)
868
+ throw new Error('Invalid borrowed loop lock');
869
+ lock = { acquired: true, ownerToken: opts.ownedLockToken };
870
+ }
871
+ else
872
+ lock = acquireLock(targetDir);
858
873
  }
859
874
  catch (error) {
860
875
  console.error(`Cannot acquire the Yoke loop lock: ${error.message}`);
@@ -867,8 +882,22 @@ export function runLoopCommand(targetDir, opts) {
867
882
  if (lock.stalePid !== undefined) {
868
883
  console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
869
884
  }
870
- const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json, quiet: opts.quiet });
885
+ let reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json, quiet: opts.quiet });
871
886
  executionReporter = reporter;
887
+ try {
888
+ const saved = opts.savedRun ?? createLoopRun({ ...opts, agent: runnerAgent, selection: runnerSelection, parallel, isolate, routing: routingEnabled, timeoutMinutes: opts.timeoutMinutes ?? config.loop.timeoutMinutes });
889
+ if (opts.savedRun && readRunState(targetDir)?.runId !== opts.savedRun.runId)
890
+ throw new Error('Saved run changed before resume');
891
+ writeRunState(targetDir, saved, lock.ownerToken);
892
+ reporter = accountRunIterations(targetDir, saved, lock.ownerToken, reporter);
893
+ executionReporter = reporter;
894
+ }
895
+ catch (error) {
896
+ if (!opts.ownedLockToken)
897
+ releaseLock(targetDir, lock.ownerToken);
898
+ reporter.blocked(`could not persist loop run: ${error.message}`);
899
+ return 1;
900
+ }
872
901
  const buildResume = (storyId, requestId) => buildTrustedDecisionResumeState({
873
902
  storyId,
874
903
  requestId,
@@ -938,7 +967,8 @@ export function runLoopCommand(targetDir, opts) {
938
967
  completion,
939
968
  quality,
940
969
  onCriticalDecision: decision => writeDecisionResume(targetDir, buildResume(decision.storyId, decisionRequestId(decision))),
941
- }).then(code => reconcileDecisionResume() ?? code).finally(() => releaseLock(targetDir, lock.ownerToken));
970
+ }).then(code => reconcileDecisionResume() ?? code).finally(() => { if (!opts.ownedLockToken)
971
+ releaseLock(targetDir, lock.ownerToken); });
942
972
  }
943
973
  try {
944
974
  const maxIterations = opts.maxIterations ?? Number.POSITIVE_INFINITY;
@@ -984,6 +1014,47 @@ export function runLoopCommand(targetDir, opts) {
984
1014
  return 1;
985
1015
  }
986
1016
  finally {
987
- releaseLock(targetDir, lock.ownerToken);
1017
+ if (!opts.ownedLockToken)
1018
+ releaseLock(targetDir, lock.ownerToken);
988
1019
  }
989
1020
  }
1021
+ async function startContinuousExploration(targetDir, options) {
1022
+ let lock;
1023
+ try {
1024
+ if (options.isolate === false)
1025
+ throw new Error('--explore requires isolated story worktrees');
1026
+ if (options.exploreLimitMs !== undefined && (!Number.isSafeInteger(options.exploreLimitMs) || options.exploreLimitMs <= 0 || Date.now() + options.exploreLimitMs > 8_640_000_000_000_000))
1027
+ throw new Error('--explore-limit must be a positive, supported duration');
1028
+ statePath(targetDir, 'loop.lock');
1029
+ lock = acquireLock(targetDir);
1030
+ if (!lock.acquired) {
1031
+ console.error('Another loop is already running here');
1032
+ return 2;
1033
+ }
1034
+ const config = loadConfig(targetDir);
1035
+ const implementationAgent = options.agent ?? resolveRunnerAgent(config, undefined, detectHostAgent());
1036
+ const implementationSelection = options.selection ?? config?.runner ?? {};
1037
+ const explorationPlanner = options.explorationPlanner ?? resolvePlanner(config, implementationAgent, implementationSelection, options.agent);
1038
+ const run = options.savedRun ?? createLoopRun({ ...options, agent: implementationAgent, selection: implementationSelection, explorationPlanner, timeoutMinutes: options.timeoutMinutes ?? config?.loop.timeoutMinutes, ...(options.parallel === undefined && !options.parallelAuto ? typeof config?.loop.parallel === 'number' ? { parallel: config.loop.parallel } : { parallelAuto: true } : {}) });
1039
+ if (options.savedRun && readRunState(targetDir)?.runId !== run.runId)
1040
+ throw new Error('Saved run changed before resume');
1041
+ writeRunState(targetDir, run, lock.ownerToken);
1042
+ return await runContinuousExploration(targetDir, { ...options, selection: run.options.selection, explorationPlanner: run.options.explorationPlanner ?? explorationPlanner, savedRun: run, ownedLockToken: lock.ownerToken });
1043
+ }
1044
+ catch (error) {
1045
+ console.error(`Cannot start exploration: ${error.message}`);
1046
+ return 2;
1047
+ }
1048
+ finally {
1049
+ if (lock?.acquired)
1050
+ releaseLock(targetDir, lock.ownerToken);
1051
+ }
1052
+ }
1053
+ /** Resume the saved execution identity without extending its exploration deadline. */
1054
+ export function resumeLoopCommand(targetDir) {
1055
+ const run = readRunState(targetDir);
1056
+ if (!run || run.mode === 'goal')
1057
+ throw new Error('No saved loop run to resume');
1058
+ const { native: _native, ...options } = run.options;
1059
+ return runLoopCommand(targetDir, { ...options, ...(run.mode === 'loop' && options.maxIterations !== undefined ? { maxIterations: Math.max(0, options.maxIterations - (run.consumedIterations ?? 0)) } : {}), explore: run.mode === 'explore', savedRun: run, permissions: 'safe' });
1060
+ }
@@ -0,0 +1,116 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstatSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { z } from 'zod';
4
+ import { statePath } from '../workspace/state.js';
5
+ import { readLock } from './lock.js';
6
+ import { AgentSchema, ModelSelectionSchema } from '../agents/contracts.js';
7
+ const agent = AgentSchema;
8
+ const bounded = z.number().int().min(1).max(1_000_000);
9
+ const selection = ModelSelectionSchema.omit({ nativeMultiAgent: true }).strict();
10
+ const Options = z.object({
11
+ agent: agent.optional(), reviewer: agent.optional(), maxIterations: bounded.optional(), isolate: z.boolean().optional(),
12
+ resumeWorktree: z.boolean().optional(), review: z.boolean().optional(), timeoutMinutes: z.number().finite().min(0).max(10080).optional(),
13
+ json: z.boolean().optional(), onAmbiguity: z.enum(['resolve', 'abort', 'auto', 'critical']).optional(), decisionPolicy: z.enum(['auto', 'critical']).optional(),
14
+ parallel: z.number().int().min(1).max(64).optional(), parallelAuto: z.boolean().optional(), routing: z.boolean().optional(),
15
+ quality: z.boolean().optional(), qualityRounds: bounded.optional(), qualityMinutes: z.number().finite().positive().max(10080).optional(),
16
+ qualityPolicy: z.enum(['blocking', 'advisory']).optional(), candidates: z.number().int().min(1).max(5).optional(),
17
+ exploreIntervalMinutes: z.number().int().min(1).max(1440).optional(), selection: selection.optional(), native: z.boolean().optional(),
18
+ explorationPlanner: z.object({ agent, selection }).strict().optional(),
19
+ }).strict();
20
+ const State = z.object({ version: z.literal(1), runId: z.string().uuid(), startedAt: z.string().datetime(), mode: z.enum(['loop', 'explore', 'goal']), goalId: z.string().uuid().optional(), consumedIterations: z.number().int().min(0).max(1_000_000_000).optional(), exploreDeadline: z.number().int().min(0).max(8_640_000_000_000_000).optional(), options: Options }).strict().superRefine((run, ctx) => {
21
+ if ((run.mode === 'goal') !== Boolean(run.goalId))
22
+ ctx.addIssue({ code: 'custom', message: 'Run goal identity does not match mode' });
23
+ if (run.mode !== 'explore' && run.exploreDeadline !== undefined)
24
+ ctx.addIssue({ code: 'custom', message: 'Exploration deadline does not match mode' });
25
+ });
26
+ function safeOptions(options) {
27
+ const selected = {};
28
+ for (const key of Object.keys(Options.shape)) {
29
+ const value = options[key];
30
+ if (value !== undefined) {
31
+ if (key === 'selection') {
32
+ const { nativeMultiAgent: _native, ...model } = ModelSelectionSchema.parse(value);
33
+ selected[key] = selection.parse(model);
34
+ }
35
+ else if (key === 'explorationPlanner') {
36
+ const planner = value;
37
+ const { nativeMultiAgent: _native, ...model } = ModelSelectionSchema.parse(planner.selection);
38
+ selected[key] = { agent: planner.agent, selection: selection.parse(model) };
39
+ }
40
+ else
41
+ selected[key] = value;
42
+ }
43
+ }
44
+ return Options.parse(selected);
45
+ }
46
+ export function createLoopRun(options, now = Date.now()) {
47
+ const deadline = options.exploreLimitMs === undefined ? undefined : now + options.exploreLimitMs;
48
+ return State.parse({ version: 1, runId: randomUUID(), startedAt: new Date(now).toISOString(), mode: options.explore ? 'explore' : 'loop', options: safeOptions(options), ...(deadline !== undefined ? { exploreDeadline: deadline } : {}) });
49
+ }
50
+ export function readRunState(root) {
51
+ const file = statePath(root, 'run-state.json');
52
+ try {
53
+ const stat = lstatSync(file);
54
+ if (!stat.isFile())
55
+ throw new Error('Saved run is not a file');
56
+ if (stat.size > 65536)
57
+ throw new Error('Saved run is too large');
58
+ return State.parse(JSON.parse(readFileSync(file, 'utf8')));
59
+ }
60
+ catch (error) {
61
+ if (error.code === 'ENOENT')
62
+ return null;
63
+ throw error;
64
+ }
65
+ }
66
+ export function writeRunState(root, run, ownerToken) {
67
+ const validated = State.parse(run);
68
+ statePath(root, 'loop.lock');
69
+ if (!ownerToken || readLock(root)?.ownerToken !== ownerToken)
70
+ throw new Error('Saving run requires ownership of the loop lock');
71
+ const file = statePath(root, 'run-state.json');
72
+ const temp = statePath(root, `run-state.${randomUUID()}.tmp`);
73
+ try {
74
+ writeFileSync(temp, JSON.stringify(validated), { flag: 'wx', mode: 0o600 });
75
+ statePath(root, 'run-state.json');
76
+ renameSync(temp, file);
77
+ }
78
+ finally {
79
+ rmSync(temp, { force: true });
80
+ }
81
+ }
82
+ export function recordGoalRun(root, goalId, ownerToken, options = {}) {
83
+ const values = options;
84
+ let previous = null;
85
+ try {
86
+ previous = readRunState(root);
87
+ }
88
+ catch { /* an explicitly started goal may replace invalid old execution metadata */ }
89
+ const sameGoal = previous?.mode === 'goal' && previous.goalId === goalId ? previous : null;
90
+ writeRunState(root, State.parse({ version: 1, runId: sameGoal?.runId ?? randomUUID(), startedAt: sameGoal?.startedAt ?? new Date().toISOString(), mode: 'goal', goalId, options: safeOptions({ ...values, agent: values.agent ?? values.provider }) }), ownerToken);
91
+ }
92
+ /** Charge each dispatch before its reporter callback can begin provider work. */
93
+ export function accountRunIterations(root, run, ownerToken, reporter) {
94
+ const previousIterations = run.consumedIterations ?? 0;
95
+ let charged = 0;
96
+ const account = (iteration) => {
97
+ if (iteration === undefined || !Number.isSafeInteger(iteration) || iteration <= charged)
98
+ return;
99
+ const current = readRunState(root);
100
+ if (current?.runId !== run.runId)
101
+ throw new Error('Execution identity changed before dispatch');
102
+ run.consumedIterations = previousIterations + iteration;
103
+ writeRunState(root, run, ownerToken);
104
+ charged = iteration;
105
+ };
106
+ return new Proxy(reporter, {
107
+ get(target, key) {
108
+ if (key === 'storyStart')
109
+ return (...args) => { account(args[1]); target.storyStart(...args); };
110
+ if (key === 'parallel')
111
+ return (status) => { account(status.iteration); target.parallel?.(status); };
112
+ const value = Reflect.get(target, key, target);
113
+ return typeof value === 'function' ? value.bind(target) : value;
114
+ },
115
+ });
116
+ }
@@ -10,7 +10,8 @@ import { readPlanningFile } from '../routing/contracts.js';
10
10
  import { loadConfig } from '../retrofit/config.js';
11
11
  import { commitPaths, realGitOps } from '../loop/git.js';
12
12
  import { resolveCommitIdentity } from '../loop/identity.js';
13
- import { acquireLock, releaseLock } from '../loop/lock.js';
13
+ import { acquireLock, releaseLock, readLock } from '../loop/lock.js';
14
+ import { statePath } from '../workspace/state.js';
14
15
  import { allPass, AcceptanceCriterionSchema, criterionCommandProblem, isAcceptanceCriterion, loadPrd, StorySchema, validateDependencies } from '../loop/prd.js';
15
16
  import { writeScopesOverlap, validWriteScope } from '../loop/scheduler.js';
16
17
  import { withSharedWorkerSync } from '../loop/resource-pool.js';
@@ -360,7 +361,14 @@ export function runPrdExplore(root, options = {}) {
360
361
  const prompt = promptFor(brief, context, recentCompleted, options.focus);
361
362
  if (prompt.length > MAX_PROMPT_CHARS)
362
363
  throw Error(`exploration input exceeds ${MAX_PROMPT_CHARS} characters; condense the project brief and context`);
363
- lock = acquireLock(root);
364
+ statePath(root, 'loop.lock');
365
+ if (options.ownedLockToken) {
366
+ if (readLock(root)?.ownerToken !== options.ownedLockToken)
367
+ throw Error('Invalid borrowed exploration lock');
368
+ lock = { acquired: true, ownerToken: options.ownedLockToken };
369
+ }
370
+ else
371
+ lock = acquireLock(root);
364
372
  if (!lock.acquired)
365
373
  return { kind: 'retry', provider: planner.agent, summary: 'another Yoke operation owns the project lock' };
366
374
  if (options.pause?.())
@@ -370,7 +378,7 @@ export function runPrdExplore(root, options = {}) {
370
378
  || readPlanningFile(root, RECENT_EXPLORATION_FILE, RECENT_EXPLORATION_BYTES) !== recent.text
371
379
  || !realGitOps.isClean(root))
372
380
  throw Error('project changed while exploration was being prepared');
373
- const invocation = buildWatchdogInvocation(runnerInvocation(planner.agent, prompt, root, true, 'read-only', planner.selection), options.timeoutMinutes === undefined ? 20 * 60_000 : options.timeoutMinutes > 0 ? options.timeoutMinutes * 60_000 : 0);
381
+ const invocation = buildWatchdogInvocation(runnerInvocation(planner.agent, prompt, root, true, 'read-only', options.selection ?? planner.selection), options.timeoutMinutes === undefined ? 20 * 60_000 : options.timeoutMinutes > 0 ? options.timeoutMinutes * 60_000 : 0);
374
382
  const started = Date.now();
375
383
  const result = withSharedWorkerSync({ targetDir: root, storyId: 'project-exploration', provider: planner.agent, role: 'implementation' }, () => (options.run ?? runCapturedAgent)(planner.agent, invocation));
376
384
  options.onUsage?.({
@@ -420,7 +428,7 @@ export function runPrdExplore(root, options = {}) {
420
428
  return { kind: 'retry', ...(plannerAgent ? { provider: plannerAgent } : {}), summary: error instanceof Error ? error.message : String(error) };
421
429
  }
422
430
  finally {
423
- if (lock?.acquired)
431
+ if (lock?.acquired && !options.ownedLockToken)
424
432
  releaseLock(root, lock.ownerToken);
425
433
  }
426
434
  }
@@ -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 } } : {}),
@@ -88,6 +88,7 @@ export const YokeConfigSchema = z.object({
88
88
  bare: z.boolean().optional(),
89
89
  permissions: PermissionProfileSchema.optional(),
90
90
  }).optional(),
91
+ goals: z.object({ nativeCodex: z.boolean().optional() }).optional(),
91
92
  planning: z.object({
92
93
  agent: AgentSchema.optional(),
93
94
  provider: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/).optional(),
@@ -2,6 +2,9 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  export const YOKE_IGNORE_LINES = [
4
4
  '.yoke/worktrees/',
5
+ '.yoke/integration-recovery/',
6
+ '.yoke/run-state.json',
7
+ '.yoke/run-state.*.tmp',
5
8
  '.yoke/backup/',
6
9
  '.yoke/code-intelligence/',
7
10
  '.yoke/loop-status.json',
@@ -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
+ }
@@ -55,6 +55,8 @@ function parseAgents(value, fallback) {
55
55
  return parsed.length > 0 ? [...new Set(parsed)] : fallback;
56
56
  }
57
57
  function yes(value, fallback) {
58
+ if (!value)
59
+ return fallback;
58
60
  const normalized = value.trim().toLowerCase();
59
61
  if (['y', 'yes', 'j', 'ja', 'true', '1'].includes(normalized))
60
62
  return true;
@@ -99,6 +101,10 @@ export async function runSetup(targetDir, opts = {}) {
99
101
  let runner = defaultRunner;
100
102
  let decisionPolicy = defaultPolicy;
101
103
  let routing = defaultRouting;
104
+ let runnerModel = opts.runnerModel ?? existing?.runner?.model;
105
+ let runnerReasoning = opts.runnerReasoning ?? existing?.runner?.reasoningEffort;
106
+ const agentModels = { ...(opts.agentModels ?? {}) };
107
+ const agentReasoning = { ...(opts.agentReasoning ?? {}) };
102
108
  if (interactive && ask) {
103
109
  agents = parseAgents(await ask(`Agents [${defaultAgents.join(',')}] (${SUPPORTED_AGENTS.join(',')}|all): `), defaultAgents);
104
110
  const graphAnswer = (await ask(`Code graph [${defaultGraph}] (graphify|serena): `)).trim().toLowerCase();
@@ -112,12 +118,39 @@ export async function runSetup(targetDir, opts = {}) {
112
118
  if (policyAnswer === 'auto' || policyAnswer === 'critical')
113
119
  decisionPolicy = policyAnswer;
114
120
  routing = yes(await ask(`Enable adaptive multi-model routing? [${defaultRouting ? 'yes' : 'no'}]: `), defaultRouting);
121
+ if (opts.configureModels) {
122
+ const configureModels = yes(await ask(`Configure model & reasoning effort per agent? [no]: `), false);
123
+ if (configureModels) {
124
+ const rm = (await ask(`Runner model [${runnerModel ?? 'default'}]: `)).trim();
125
+ if (rm)
126
+ runnerModel = rm;
127
+ const rr = (await ask(`Runner reasoning effort (none|low|medium|high|xhigh) [${runnerReasoning ?? 'default'}]: `)).trim();
128
+ if (rr)
129
+ runnerReasoning = rr;
130
+ for (const a of agents) {
131
+ const am = (await ask(`Model for agent "${a}" [${agentModels[a] ?? 'default'}]: `)).trim();
132
+ if (am)
133
+ agentModels[a] = am;
134
+ const ar = (await ask(`Reasoning effort for agent "${a}" (none|low|medium|high|xhigh) [${agentReasoning[a] ?? 'default'}]: `)).trim();
135
+ if (ar)
136
+ agentReasoning[a] = ar;
137
+ }
138
+ }
139
+ }
115
140
  }
116
141
  if (modelProviders.length && !agents.includes('qwen'))
117
142
  agents = [...agents, 'qwen'];
118
143
  if (!agents.includes(runner))
119
144
  agents = [...agents, runner];
120
- const code = runRetrofit(targetDir, { loop, agents, codeGraph, codeIntelligence, host });
145
+ const priorRunner = existing?.runner?.agent ?? existing?.agents[0];
146
+ const runnerChanged = priorRunner !== undefined && priorRunner !== runner;
147
+ if (runnerChanged) {
148
+ if (opts.runnerModel === undefined)
149
+ runnerModel = undefined;
150
+ if (opts.runnerReasoning === undefined)
151
+ runnerReasoning = undefined;
152
+ }
153
+ const code = runRetrofit(targetDir, { loop, agents, codeGraph, codeIntelligence, host, cleanWorktrees: opts.cleanWorktrees, runnerModel, runnerReasoning, agentModels, agentReasoning });
121
154
  if (code !== 0)
122
155
  return code;
123
156
  applyActions(presetActions, targetDir, { backupDir: join(targetDir, '.yoke', 'backups', `model-presets-${Date.now()}`) });
@@ -125,14 +158,23 @@ export async function runSetup(targetDir, opts = {}) {
125
158
  if (!config)
126
159
  return 1;
127
160
  config.loop = { parallel: 'auto', isolate: true, ...config.loop, enabled: loop, decisionPolicy };
128
- const priorRunner = existing?.runner?.agent ?? existing?.agents[0];
129
161
  const selectPresetModel = presetWorkers.length > 0 && runner === 'qwen' && (!existing || (priorRunner !== undefined && priorRunner !== runner));
130
162
  if (selectPresetModel && priorRunner !== 'qwen')
131
163
  config.runner = { permissions: config.runner?.permissions };
132
- config.runner = { ...config.runner, agent: runner, ...(selectPresetModel && !config.runner?.model ? { model: presetWorkers[0].model } : {}) };
164
+ config.runner = {
165
+ ...config.runner,
166
+ agent: runner,
167
+ ...(runnerModel ? { model: runnerModel } : selectPresetModel && !config.runner?.model ? { model: presetWorkers[0].model } : {}),
168
+ ...(runnerReasoning ? { reasoningEffort: runnerReasoning } : {}),
169
+ };
133
170
  const existingWorkers = config.routing?.workers ?? [];
134
171
  const baseWorkers = existingWorkers.length > 0 && !opts.routingPreset ? existingWorkers : defaultRoutingWorkers(agents).filter(worker => !modelProviders.length || worker.agent !== 'qwen');
135
- const workers = [...baseWorkers, ...presetWorkers.filter(worker => !baseWorkers.some(existing => existing.id === worker.id))];
172
+ const rawWorkers = [...baseWorkers, ...presetWorkers.filter(worker => !baseWorkers.some(existing => existing.id === worker.id))];
173
+ const workers = rawWorkers.map(w => {
174
+ const model = agentModels[w.agent] ?? w.model;
175
+ const reasoningEffort = agentReasoning[w.agent] ?? w.reasoningEffort;
176
+ return { ...w, ...(model ? { model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}) };
177
+ });
136
178
  config.routing = {
137
179
  ...config.routing,
138
180
  enabled: routing,
@@ -145,7 +187,7 @@ export async function runSetup(targetDir, opts = {}) {
145
187
  };
146
188
  YokeConfigSchema.parse(config);
147
189
  saveConfig(targetDir, config);
148
- console.log(`Yoke setup complete: agents=${agents.join(',')} · runner=${runner} · loop=${loop ? 'on' : 'off'} · routing=${routing ? 'on' : 'off'} · decisions=${decisionPolicy}`);
190
+ console.log(`Yoke setup complete: agents=${agents.join(',')} · runner=${runner}${runnerModel ? ` (${runnerModel})` : ''}${runnerReasoning ? ` [${runnerReasoning}]` : ''} · loop=${loop ? 'on' : 'off'} · routing=${routing ? 'on' : 'off'} · decisions=${decisionPolicy}`);
149
191
  return 0;
150
192
  }
151
193
  finally {