@hecer/yoke 1.18.0 → 1.20.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 (66) 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 +86 -910
  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/canon/manifest.yaml +1 -1
  24. package/dist/agents/process-record-identity.js +33 -0
  25. package/dist/agents/process.js +42 -11
  26. package/dist/agents/providers.js +11 -2
  27. package/dist/agents/sol-pi-runtime.js +76 -0
  28. package/dist/check/command.js +218 -0
  29. package/dist/cli.js +11 -7
  30. package/dist/code-intelligence/adapters/graphify.js +1 -1
  31. package/dist/code-intelligence/adapters/mcp.js +25 -4
  32. package/dist/code-intelligence/adapters/serena.js +1 -1
  33. package/dist/code-intelligence/coordinator.js +2 -1
  34. package/dist/code-intelligence/evidence.js +12 -1
  35. package/dist/code-intelligence/mcp-client.js +2 -2
  36. package/dist/dashboard/page.js +1 -1
  37. package/dist/dashboard/panels.js +25 -3
  38. package/dist/dashboard/server.js +88 -7
  39. package/dist/goals/codex-native.js +340 -0
  40. package/dist/goals/command.js +230 -29
  41. package/dist/loop/cleanup.js +10 -1
  42. package/dist/loop/dispatcher.js +82 -10
  43. package/dist/loop/git.js +1 -1
  44. package/dist/loop/parallel-adapters.js +53 -7
  45. package/dist/loop/parallel-command.js +8 -1
  46. package/dist/loop/recovery.js +81 -2
  47. package/dist/loop/reporter.js +3 -0
  48. package/dist/loop/resource-pool.js +21 -15
  49. package/dist/loop/run-command.js +91 -25
  50. package/dist/loop/run-state.js +116 -0
  51. package/dist/prd/explore.js +12 -4
  52. package/dist/retrofit/config.js +29 -0
  53. package/dist/retrofit/gitignore.js +3 -0
  54. package/docs/CODEX-COMPARISON-2026-09-29.md +27 -0
  55. package/docs/CONTINUOUS-EXPLORATION.md +6 -2
  56. package/docs/GOALS-RESOURCE-AUDIT-2026-09-29.md +92 -0
  57. package/docs/GOALS.md +109 -0
  58. package/docs/HARNESSES.md +1 -1
  59. package/docs/RELEASE-VALIDATION-1.20.0.md +100 -0
  60. package/docs/SOL-PI.md +41 -0
  61. package/docs/VERIFIED-PROJECTS.md +4 -4
  62. package/docs/code-intelligence-stdio-fix-2026-09-27.md +8 -0
  63. package/docs/parallel-execution.md +15 -0
  64. package/docs/superpowers/plans/2026-09-30-goals-resources-release.md +78 -0
  65. package/gemini-extension.json +1 -1
  66. package/package.json +5 -1
@@ -1,11 +1,14 @@
1
1
  import { execFileSync, spawnSync } from 'node:child_process';
2
- import { mkdirSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import { basename, dirname, join, resolve } from 'node:path';
4
5
  import { isProviderTreeAlive, reapProviderProcesses } from './cleanup.js';
5
6
  import { realGitOps, stageImplementation } from './git.js';
6
7
  import { isPidAlive } from './lock.js';
7
8
  import { storyPathSegment } from './prd.js';
8
9
  import { killProcessTreeForCleanup } from './watchdog.js';
10
+ import { parallelAcceptanceDigest, recoverParallelWorktree, retainParallelWorktree } from './recovery.js';
11
+ import { statePath } from '../workspace/state.js';
9
12
  export function makeParallelAdapters(targetDir, identity, injectedGit) {
10
13
  return injectedGit
11
14
  ? injectedAdapters(targetDir, identity, injectedGit)
@@ -37,18 +40,40 @@ function injectedAdapters(targetDir, identity, git) {
37
40
  function productionAdapters(targetDir, identity) {
38
41
  const owned = new Set();
39
42
  const removed = new Set();
43
+ const acceptanceDigests = new Map();
44
+ const ownershipTokens = new Map();
40
45
  return {
41
46
  worktrees: {
42
47
  create: input => {
48
+ acceptanceDigests.set(input.story.id, parallelAcceptanceDigest(targetDir));
43
49
  const path = worktreePath(targetDir, input);
50
+ const recovered = recoverParallelWorktree(targetDir, recoveryPath(targetDir, input.story.id), input.story.id);
51
+ if (recovered) {
52
+ owned.add(recovered.path);
53
+ ownershipTokens.set(recovered.path, recovered.ownerToken);
54
+ return recovered;
55
+ }
44
56
  const baseCommit = gitText(targetDir, ['rev-parse', 'HEAD']);
57
+ preflightWorktreePath(targetDir, path);
45
58
  mkdirSync(dirname(path), { recursive: true });
46
59
  realGitOps.addWorktree(targetDir, path);
47
60
  owned.add(path);
61
+ ownershipTokens.set(path, input.ownerToken);
48
62
  return { path, baseCommit };
49
63
  },
50
64
  cleanupProcess: cleanupProviderProcesses,
51
- remove: input => removeOwnedWorktree(targetDir, input, owned, removed, realGitOps),
65
+ retain: (input, reason) => {
66
+ const file = recoveryPath(targetDir, input.story.id);
67
+ // Preserve the path's original ownership identity when a retry is rejected again.
68
+ const ownerToken = ownershipTokens.get(input.worktree.path) ?? input.ownerToken;
69
+ retainParallelWorktree(targetDir, file, { storyId: input.story.id, worktree: input.worktree.path, baseCommit: input.worktree.baseCommit, prdHash: acceptanceDigests.get(input.story.id), ownerToken, reason });
70
+ },
71
+ remove: input => {
72
+ removeOwnedWorktree(targetDir, input, owned, removed, realGitOps);
73
+ const file = recoveryPath(targetDir, input.story.id);
74
+ if (input.worktree.recovered && existsSync(file))
75
+ unlinkSync(file);
76
+ },
52
77
  },
53
78
  git: {
54
79
  isClean: dir => realGitOps.isClean(dir),
@@ -56,23 +81,27 @@ function productionAdapters(targetDir, identity) {
56
81
  commit: input => realGitOps.commitAll(input.worktree.path, `yoke: complete ${input.story.id} ${input.story.title}`, identity),
57
82
  integrate: (input, expectedHead) => integrateCandidate(targetDir, input, expectedHead),
58
83
  },
59
- candidates: primary => makeCandidateLifecycle(targetDir, primary, owned, removed, realGitOps),
84
+ candidates: primary => makeCandidateLifecycle(targetDir, primary, owned, removed, realGitOps, ownershipTokens),
60
85
  };
61
86
  }
62
87
  function cleanupProviderProcesses(input) {
63
88
  reapProviderProcesses(input.worktree.path, isPidAlive, isProviderTreeAlive, killProcessTreeForCleanup);
64
89
  }
65
- function makeCandidateLifecycle(targetDir, primary, owned, removed, git) {
90
+ function makeCandidateLifecycle(targetDir, primary, owned, removed, git, ownershipTokens) {
66
91
  return {
67
92
  reserve: input => {
68
93
  const worktree = input.candidateId === 'candidate-1'
69
94
  ? primary.worktree
70
95
  : { path: candidateWorktreePath(targetDir, input), baseCommit: primary.worktree.baseCommit };
96
+ if (input.candidateId !== 'candidate-1')
97
+ ownershipTokens?.set(worktree.path, input.ownerToken);
71
98
  writeCandidateStatus(targetDir, input, worktree.path, 'reserved');
72
99
  return worktree;
73
100
  },
74
101
  materialize: ownership => {
75
102
  if (ownership.worktree.path !== primary.worktree.path) {
103
+ if (git === realGitOps)
104
+ preflightWorktreePath(targetDir, ownership.worktree.path);
76
105
  mkdirSync(dirname(ownership.worktree.path), { recursive: true });
77
106
  git.addWorktree(targetDir, ownership.worktree.path);
78
107
  owned.add(ownership.worktree.path);
@@ -92,7 +121,7 @@ function makeCandidateLifecycle(targetDir, primary, owned, removed, git) {
92
121
  };
93
122
  }
94
123
  function worktreePath(targetDir, input) {
95
- return join(targetDir, '.yoke', 'worktrees', `${storyPathSegment(input.story.id)}-${input.ownerToken}`);
124
+ return shortWorktreePath(targetDir, input.story.id, input.ownerToken);
96
125
  }
97
126
  function removeOwnedWorktree(targetDir, input, owned, removed, git) {
98
127
  if (removed.has(input.worktree.path))
@@ -105,7 +134,24 @@ function removeOwnedWorktree(targetDir, input, owned, removed, git) {
105
134
  removed.add(input.worktree.path);
106
135
  }
107
136
  function candidateWorktreePath(targetDir, input) {
108
- return join(targetDir, '.yoke', 'worktrees', `${storyPathSegment(input.storyId)}-${input.ownerToken}`);
137
+ return shortWorktreePath(targetDir, input.storyId, input.ownerToken);
138
+ }
139
+ function shortWorktreePath(targetDir, storyId, ownerToken) {
140
+ // 96 bits derived from the complete ownership identity; Git refuses an existing path.
141
+ const name = createHash('sha256').update(JSON.stringify([storyId, ownerToken])).digest('hex').slice(0, 24);
142
+ return join(targetDir, '.yoke', 'worktrees', name);
143
+ }
144
+ function recoveryPath(targetDir, storyId) {
145
+ return statePath(targetDir, 'integration-recovery', `${storyPathSegment(storyId)}.json`);
146
+ }
147
+ function preflightWorktreePath(targetDir, path) {
148
+ if (process.platform !== 'win32')
149
+ return;
150
+ const common = resolve(targetDir, gitText(targetDir, ['rev-parse', '--git-common-dir']));
151
+ const administrativePath = join(common, 'worktrees', basename(path));
152
+ if ([resolve(path, '.git'), administrativePath].some(value => value.length >= 240)) {
153
+ throw new Error(`Parallel worktree path exceeds the Windows Git path budget: ${path}. Move the project to a shorter root and retry; implementation has not started.`);
154
+ }
109
155
  }
110
156
  function writeCandidateStatus(targetDir, input, worktree, state, reason) {
111
157
  const file = join(targetDir, '.yoke', 'proof', storyPathSegment(input.storyId), 'candidates', input.candidateId, 'status.json');
@@ -50,7 +50,13 @@ export async function runParallelLoopCommand(input) {
50
50
  units: request.units,
51
51
  signal: request.signal,
52
52
  });
53
- input.reporter.resourceWait?.({ id: request.story.id, title: request.story.title }, request.role, request.units, Date.now() - started, request.provider.provider);
53
+ try {
54
+ input.reporter.resourceWait?.({ id: request.story.id, title: request.story.title }, request.role, request.units, Date.now() - started, request.provider.provider);
55
+ }
56
+ catch (error) {
57
+ await lease.release();
58
+ throw error;
59
+ }
54
60
  return lease;
55
61
  },
56
62
  resourceStatus: () => sharedPoolStatus(),
@@ -71,6 +77,7 @@ export async function runParallelLoopCommand(input) {
71
77
  pause,
72
78
  onProgress: status => input.reporter.parallel?.(status),
73
79
  onAccepted: story => input.reporter.accepted?.(story),
80
+ onReopened: (story, evidence) => input.reporter.reopened?.(story, evidence),
74
81
  gates: {
75
82
  verify: input.verify,
76
83
  design: input.design,
@@ -1,8 +1,11 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { existsSync, mkdirSync, readFileSync, realpathSync as filesystemRealpathSync, writeFileSync } from 'node:fs';
4
- import { dirname, join, relative, resolve, isAbsolute } from 'node:path';
3
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync as filesystemRealpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { basename, dirname, join, relative, resolve, isAbsolute } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
5
6
  import { z } from 'zod';
7
+ import { statePath } from '../workspace/state.js';
8
+ import { loadPrd } from './prd.js';
6
9
  const Recovery = z.object({ version: z.literal(1), root: z.string(), worktree: z.string(), base: z.string(), prdHash: z.string() }).strict();
7
10
  const digest = (file) => createHash('sha256').update(readFileSync(file)).digest('hex');
8
11
  const pathIdentity = (path) => process.platform === 'win32' ? path.toLowerCase() : path;
@@ -49,3 +52,79 @@ export function prepareIsolatedWorktree(directory, worktree, resume) {
49
52
  // Record is outside the worker checkout and binds reuse to its source state.
50
53
  writeFileSync(record, JSON.stringify({ version: 1, root, worktree: wt, base, prdHash }), { mode: 0o600 });
51
54
  }
55
+ const ParallelRecovery = z.object({
56
+ version: z.literal(1), root: z.string().max(4096), storyId: z.string().max(1024), worktree: z.string().max(4096), baseCommit: z.string().max(128),
57
+ prdHash: z.string().length(64), ownerToken: z.string().min(1).max(256), reason: z.string().max(16384), state: z.literal('retained'), recordedAt: z.string().datetime(),
58
+ }).strict();
59
+ export function parallelAcceptanceDigest(directory) {
60
+ // Accepted sibling stories change only passes. Their acceptance contracts remain protected.
61
+ const contract = loadPrd(join(directory, '.yoke', 'prd.yaml')).map(({ passes: _passes, ...story }) => story);
62
+ return createHash('sha256').update(JSON.stringify(contract)).digest('hex');
63
+ }
64
+ function parallelRecordPath(directory, file) {
65
+ const safe = statePath(directory, 'integration-recovery', basename(file));
66
+ if (pathIdentity(resolve(file)) !== pathIdentity(resolve(safe)))
67
+ throw new Error('Invalid parallel recovery record path');
68
+ return safe;
69
+ }
70
+ export function discardParallelRecoveryRecords(directory) {
71
+ const parent = statePath(directory, 'integration-recovery');
72
+ if (!existsSync(parent))
73
+ return;
74
+ for (const name of readdirSync(parent)) {
75
+ if (!name.endsWith('.json') && !name.endsWith('.tmp'))
76
+ continue;
77
+ const file = statePath(directory, 'integration-recovery', name);
78
+ if (!lstatSync(file).isFile())
79
+ throw new Error('Parallel recovery record is not a file');
80
+ rmSync(file);
81
+ }
82
+ }
83
+ /** Records are outside the candidate and bind reuse to unchanged target acceptance. */
84
+ export function retainParallelWorktree(directory, file, input) {
85
+ const root = realpathSync(directory);
86
+ const worktree = realpathSync(input.worktree);
87
+ const record = ParallelRecovery.parse({ version: 1, root, ...input, reason: input.reason.slice(0, 16384), worktree, state: 'retained', recordedAt: new Date().toISOString() });
88
+ const safe = parallelRecordPath(directory, file);
89
+ mkdirSync(dirname(safe), { recursive: true });
90
+ const temp = statePath(directory, 'integration-recovery', `${randomUUID()}.tmp`);
91
+ try {
92
+ writeFileSync(temp, JSON.stringify(record), { flag: 'wx', mode: 0o600 });
93
+ parallelRecordPath(directory, file);
94
+ renameSync(temp, safe);
95
+ }
96
+ finally {
97
+ rmSync(temp, { force: true });
98
+ }
99
+ }
100
+ export function recoverParallelWorktree(directory, file, storyId) {
101
+ const safe = parallelRecordPath(directory, file);
102
+ if (!existsSync(safe))
103
+ return undefined;
104
+ const stat = lstatSync(safe);
105
+ if (!stat.isFile())
106
+ throw new Error('Parallel recovery record is not a file');
107
+ if (stat.size > 65536)
108
+ throw new Error('Parallel recovery record is too large');
109
+ const saved = ParallelRecovery.parse(JSON.parse(readFileSync(safe, 'utf8')));
110
+ if (!existsSync(saved.worktree))
111
+ throw new Error(`Retained candidate is missing: ${saved.worktree}; resolve its recovery record before retrying`);
112
+ const root = realpathSync(directory);
113
+ const actual = realpathSync(saved.worktree);
114
+ const parent = realpathSync(join(root, '.yoke', 'worktrees'));
115
+ const rel = relative(parent, actual);
116
+ const expectedName = createHash('sha256').update(JSON.stringify([storyId, saved.ownerToken])).digest('hex').slice(0, 24);
117
+ if (saved.storyId !== storyId || pathIdentity(saved.root) !== pathIdentity(root) || pathIdentity(actual) !== pathIdentity(saved.worktree) || isAbsolute(rel) || rel !== expectedName)
118
+ throw new Error('Retained candidate ownership or path binding is invalid');
119
+ const git = (args, cwd = root) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe' }).trim();
120
+ if (saved.baseCommit !== git(['rev-parse', 'HEAD']) || saved.prdHash !== parallelAcceptanceDigest(root))
121
+ throw new Error(`Retained candidate is stale against target or PRD: ${actual}; reconcile it before retrying`);
122
+ const common = realpathSync(resolve(root, git(['rev-parse', '--git-common-dir'])));
123
+ if (pathIdentity(realpathSync(resolve(actual, git(['rev-parse', '--git-common-dir'], actual)))) !== pathIdentity(common))
124
+ throw new Error('Retained candidate belongs to another repository');
125
+ const registered = git(['worktree', 'list', '--porcelain']).split(/\r?\n/u).filter(line => line.startsWith('worktree ')).map(line => realpathSync(resolve(line.slice(9))));
126
+ if (!registered.some(path => pathIdentity(path) === pathIdentity(actual)))
127
+ throw new Error('Retained candidate is not a registered worktree');
128
+ git(['merge-base', '--is-ancestor', saved.baseCommit, 'HEAD'], actual);
129
+ return { path: actual, baseCommit: saved.baseCommit, recovered: true, ownerToken: saved.ownerToken };
130
+ }
@@ -303,6 +303,9 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
303
303
  integrationDuration(story, durationMs, provider) {
304
304
  appendEvent(dir, { runId, timestamp: now().toISOString(), type: 'phase-ended', storyId: story.id, phase: 'integration', provider, durationMs: Math.max(0, durationMs) });
305
305
  },
306
+ reopened(story, evidence) {
307
+ appendEvent(dir, { runId, timestamp: now().toISOString(), type: 'status', storyId: story.id, data: { status: 'reopened', ...evidence } });
308
+ },
306
309
  parallelWorker(status) {
307
310
  const base = current;
308
311
  const parallel = base?.parallel;
@@ -51,12 +51,12 @@ export function globalWorkerLimit(environment = process.env) {
51
51
  cachedGlobalLimit = value;
52
52
  return value;
53
53
  }
54
- function poolDir() {
54
+ function poolDir(resource) {
55
55
  const base = process.env.LOCALAPPDATA || process.env.XDG_STATE_HOME || join(homedir(), '.yoke');
56
- return join(resolve(base), 'Yoke', 'parallel-pool');
56
+ return join(resolve(base), 'Yoke', resource === 'check' ? 'check-pool' : 'parallel-pool');
57
57
  }
58
- function ensurePoolDir() {
59
- const dir = poolDir();
58
+ function ensurePoolDir(resource) {
59
+ const dir = poolDir(resource);
60
60
  mkdirSync(dir, { recursive: true });
61
61
  const stat = lstatSync(dir);
62
62
  if (!stat.isDirectory() || stat.isSymbolicLink())
@@ -119,8 +119,8 @@ function staleDead(record, now) {
119
119
  const created = Date.parse(record.createdAt);
120
120
  return !Number.isFinite(created) || now - created > STALE_OWNER_MS;
121
121
  }
122
- function withPoolLock(operation) {
123
- const dir = ensurePoolDir();
122
+ function withPoolLock(operation, resource) {
123
+ const dir = ensurePoolDir(resource);
124
124
  const lockFile = join(dir, 'pool');
125
125
  return withClaimOperations(lockFile, { now: new Date(), staleMs: STALE_OWNER_MS, isAlive: isPidAlive }, () => readRecords(dir), () => [], records => {
126
126
  const now = Date.now();
@@ -184,7 +184,7 @@ function writeRecord(dir, record) {
184
184
  }
185
185
  export async function acquireSharedWorker(request) {
186
186
  const units = request.units ?? 1;
187
- const limit = globalWorkerLimit();
187
+ const limit = request.resource === 'check' ? globalCheckLimit() : globalWorkerLimit();
188
188
  if (!Number.isInteger(units) || units < 1 || units > MAX_PROJECT_WORKERS)
189
189
  throw new Error(`Worker reservation units must be an integer from 1 to ${MAX_PROJECT_WORKERS}`);
190
190
  if (units > limit)
@@ -213,7 +213,7 @@ export async function acquireSharedWorker(request) {
213
213
  while (!published) {
214
214
  if (request.signal?.aborted)
215
215
  throw new Error(abortReason(request.signal));
216
- const result = withPoolLock((_records, dir) => { writeRecord(dir, waiting); return true; });
216
+ const result = withPoolLock((_records, dir) => { writeRecord(dir, waiting); return true; }, request.resource);
217
217
  if (result === true)
218
218
  published = true;
219
219
  else
@@ -237,7 +237,7 @@ export async function acquireSharedWorker(request) {
237
237
  replaceRecord(dir, { ...head, overtakes: head.overtakes + 1 });
238
238
  replaceRecord(dir, { ...own, state: 'active' });
239
239
  return true;
240
- });
240
+ }, request.resource);
241
241
  if (granted === true) {
242
242
  const acquiredAt = new Date().toISOString();
243
243
  let released = false;
@@ -254,7 +254,7 @@ export async function acquireSharedWorker(request) {
254
254
  return false;
255
255
  rmSync(recordPath(dir, id), { force: true });
256
256
  return true;
257
- });
257
+ }, request.resource);
258
258
  if (result !== null) {
259
259
  released = true;
260
260
  return result;
@@ -274,7 +274,7 @@ export async function acquireSharedWorker(request) {
274
274
  if (own)
275
275
  rmSync(recordPath(dir, id), { force: true });
276
276
  return true;
277
- });
277
+ }, request.resource);
278
278
  if (removed !== null)
279
279
  break;
280
280
  await waitForCapacity();
@@ -347,10 +347,10 @@ export function withSharedWorkerSync(request, operation) {
347
347
  }
348
348
  }
349
349
  }
350
- export function sharedPoolStatus() {
351
- const limit = globalWorkerLimit();
352
- const snapshot = withPoolLock(records => records.map(record => ({ ...record })));
353
- const records = snapshot ?? readRecords(ensurePoolDir()).filter(record => !staleDead(record, Date.now()));
350
+ export function sharedPoolStatus(resource) {
351
+ const limit = resource === 'check' ? globalCheckLimit() : globalWorkerLimit();
352
+ const snapshot = withPoolLock(records => records.map(record => ({ ...record })), resource);
353
+ const records = snapshot ?? readRecords(ensurePoolDir(resource)).filter(record => !staleDead(record, Date.now()));
354
354
  const sharedLimit = effectiveLimit(records, limit);
355
355
  const active = records.filter(record => record.state === 'active');
356
356
  const waiting = records.filter(record => record.state === 'waiting');
@@ -367,3 +367,9 @@ export function sharedPoolStatus() {
367
367
  oldestWaitMs: Number.isFinite(oldest) ? Math.max(0, Date.now() - oldest) : 0,
368
368
  };
369
369
  }
370
+ export function globalCheckLimit(environment = process.env) {
371
+ const raw = environment.YOKE_MAX_PARALLEL_CHECKS ?? '1';
372
+ if (!/^[1-8]$/u.test(raw))
373
+ throw new Error('YOKE_MAX_PARALLEL_CHECKS must be an integer from 1 to 8');
374
+ return Number(raw);
375
+ }
@@ -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';
@@ -232,7 +234,7 @@ async function runContinuousExploration(targetDir, options) {
232
234
  console.error('--explore-limit must be a positive, supported duration');
233
235
  return 2;
234
236
  }
235
- const limitDeadline = options.exploreLimitMs === undefined ? undefined : Date.now() + options.exploreLimitMs;
237
+ const limitDeadline = options.savedRun?.exploreDeadline ?? (options.exploreLimitMs === undefined ? undefined : Date.now() + options.exploreLimitMs);
236
238
  const reporter = options.reporter ?? makeReporter(targetDir, { json: options.json, quiet: true });
237
239
  let config;
238
240
  try {
@@ -242,11 +244,11 @@ async function runContinuousExploration(targetDir, options) {
242
244
  config = null;
243
245
  }
244
246
  const defaultImplementationAgent = options.agent ?? resolveRunnerAgent(config, undefined, detectHostAgent());
245
- const defaultExplorer = options.agent ?? config?.planning?.agent ?? defaultImplementationAgent;
247
+ const defaultExplorer = options.explorationPlanner?.agent ?? options.agent ?? config?.planning?.agent ?? defaultImplementationAgent;
246
248
  const available = options.isAvailable ?? isAgentAvailable;
247
249
  const reviewRequested = !options.reviewRunner && Boolean(options.review || options.reviewer);
248
250
  const innerOptions = { ...options, isolate: true, explore: true, explorationSupervisor: true, reporter, quiet: true };
249
- let usedIterations = 0;
251
+ let usedIterations = options.savedRun?.consumedIterations ?? 0;
250
252
  let retryCount = 0;
251
253
  let explorationFailures = 0;
252
254
  let noActionScans = 0;
@@ -284,6 +286,8 @@ async function runContinuousExploration(targetDir, options) {
284
286
  return 'limit';
285
287
  const explorer = retryProviders(targetDir, explorationAgent, available);
286
288
  const result = runPrdExplore(targetDir, {
289
+ ownedLockToken: options.ownedLockToken,
290
+ ...(explorationAgent === options.explorationPlanner?.agent ? { selection: options.explorationPlanner.selection } : {}),
287
291
  runner: explorationAgent,
288
292
  timeoutMinutes: options.timeoutMinutes,
289
293
  isAvailable: options.isAvailable ?? isAgentAvailable,
@@ -370,7 +374,9 @@ async function runContinuousExploration(targetDir, options) {
370
374
  }
371
375
  const afterStatus = readStatus(targetDir);
372
376
  const changedStatus = afterStatus?.updatedAt !== beforeStatus?.updatedAt;
373
- if (changedStatus && priorProgress.passed < priorProgress.total)
377
+ if (options.savedRun)
378
+ usedIterations = options.savedRun.consumedIterations ?? usedIterations;
379
+ else if (changedStatus && priorProgress.passed < priorProgress.total)
374
380
  usedIterations += Math.max(0, afterStatus?.iteration ?? 0);
375
381
  if (unexpectedFailure) {
376
382
  retryCount++;
@@ -468,7 +474,7 @@ async function runContinuousExploration(targetDir, options) {
468
474
  }
469
475
  retryCount++;
470
476
  advanceRecoveryProviders();
471
- retryWorktree = true;
477
+ retryWorktree = (afterStatus?.parallel?.reopened ?? 0) === 0;
472
478
  const wait = retryDelay(retryCount);
473
479
  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
480
  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 +486,7 @@ async function runContinuousExploration(targetDir, options) {
480
486
  }
481
487
  export function runLoopCommand(targetDir, opts) {
482
488
  if (opts.explore && !opts.explorationSupervisor)
483
- return runContinuousExploration(targetDir, opts);
489
+ return startContinuousExploration(targetDir, opts);
484
490
  let parallel = opts.parallel ?? 1;
485
491
  const candidates = opts.candidates ?? 1;
486
492
  if (opts.resumeWorktree && (opts.isolate === false || parallel !== 1 || candidates !== 1)) {
@@ -554,7 +560,8 @@ export function runLoopCommand(targetDir, opts) {
554
560
  return 2;
555
561
  }
556
562
  const isolate = opts.isolate ?? config.loop.isolate ?? true;
557
- const useParallelDispatcher = parallel > 1 || candidates > 1;
563
+ const retainedParallel = opts.explorationSupervisor && loadPrd(path).some(story => !story.passes && existsSync(statePath(targetDir, 'integration-recovery', `${storyPathSegment(story.id)}.json`)));
564
+ const useParallelDispatcher = parallel > 1 || candidates > 1 || retainedParallel === true;
558
565
  if (opts.resumeWorktree && (!isolate || parallel !== 1)) {
559
566
  console.error('--resume-worktree requires isolation and one worker');
560
567
  return 2;
@@ -668,11 +675,13 @@ export function runLoopCommand(targetDir, opts) {
668
675
  const routingRequested = opts.routing ?? config.routing?.enabled ?? true;
669
676
  const routingEnabled = routingRequested && Boolean(config.routing && (config.routing.workers.length || config.routing.fallback === 'block' || config.routing.maxTier || config.routing.assessmentPolicy === 'prepared'));
670
677
  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,
678
+ ...(opts.selection ?? {
679
+ provider: config.runner?.provider,
680
+ model: config.runner?.model,
681
+ reasoningEffort: config.runner?.reasoningEffort,
682
+ variant: config.runner?.variant,
683
+ bare: config.runner?.bare,
684
+ }),
676
685
  nativeMultiAgent: false,
677
686
  };
678
687
  const parallelProviders = [{
@@ -735,13 +744,7 @@ export function runLoopCommand(targetDir, opts) {
735
744
  timeoutMs: idleMs,
736
745
  isAvailable: available,
737
746
  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
- },
747
+ selection: runnerSelection,
745
748
  commit: (_path, request) => commitPaths(targetDir, ['.yoke/prd.yaml'], `yoke: plan change ${request.id}`, commitIdentity),
746
749
  }));
747
750
  let runner = opts.runner;
@@ -854,7 +857,14 @@ export function runLoopCommand(targetDir, opts) {
854
857
  }
855
858
  let lock;
856
859
  try {
857
- lock = acquireLock(targetDir);
860
+ statePath(targetDir, 'loop.lock');
861
+ if (opts.ownedLockToken) {
862
+ if (readLock(targetDir)?.ownerToken !== opts.ownedLockToken)
863
+ throw new Error('Invalid borrowed loop lock');
864
+ lock = { acquired: true, ownerToken: opts.ownedLockToken };
865
+ }
866
+ else
867
+ lock = acquireLock(targetDir);
858
868
  }
859
869
  catch (error) {
860
870
  console.error(`Cannot acquire the Yoke loop lock: ${error.message}`);
@@ -867,8 +877,22 @@ export function runLoopCommand(targetDir, opts) {
867
877
  if (lock.stalePid !== undefined) {
868
878
  console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
869
879
  }
870
- const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json, quiet: opts.quiet });
880
+ let reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json, quiet: opts.quiet });
871
881
  executionReporter = reporter;
882
+ try {
883
+ const saved = opts.savedRun ?? createLoopRun({ ...opts, agent: runnerAgent, selection: runnerSelection, parallel, isolate, routing: routingEnabled, timeoutMinutes: opts.timeoutMinutes ?? config.loop.timeoutMinutes });
884
+ if (opts.savedRun && readRunState(targetDir)?.runId !== opts.savedRun.runId)
885
+ throw new Error('Saved run changed before resume');
886
+ writeRunState(targetDir, saved, lock.ownerToken);
887
+ reporter = accountRunIterations(targetDir, saved, lock.ownerToken, reporter);
888
+ executionReporter = reporter;
889
+ }
890
+ catch (error) {
891
+ if (!opts.ownedLockToken)
892
+ releaseLock(targetDir, lock.ownerToken);
893
+ reporter.blocked(`could not persist loop run: ${error.message}`);
894
+ return 1;
895
+ }
872
896
  const buildResume = (storyId, requestId) => buildTrustedDecisionResumeState({
873
897
  storyId,
874
898
  requestId,
@@ -938,7 +962,8 @@ export function runLoopCommand(targetDir, opts) {
938
962
  completion,
939
963
  quality,
940
964
  onCriticalDecision: decision => writeDecisionResume(targetDir, buildResume(decision.storyId, decisionRequestId(decision))),
941
- }).then(code => reconcileDecisionResume() ?? code).finally(() => releaseLock(targetDir, lock.ownerToken));
965
+ }).then(code => reconcileDecisionResume() ?? code).finally(() => { if (!opts.ownedLockToken)
966
+ releaseLock(targetDir, lock.ownerToken); });
942
967
  }
943
968
  try {
944
969
  const maxIterations = opts.maxIterations ?? Number.POSITIVE_INFINITY;
@@ -984,6 +1009,47 @@ export function runLoopCommand(targetDir, opts) {
984
1009
  return 1;
985
1010
  }
986
1011
  finally {
987
- releaseLock(targetDir, lock.ownerToken);
1012
+ if (!opts.ownedLockToken)
1013
+ releaseLock(targetDir, lock.ownerToken);
988
1014
  }
989
1015
  }
1016
+ async function startContinuousExploration(targetDir, options) {
1017
+ let lock;
1018
+ try {
1019
+ if (options.isolate === false)
1020
+ throw new Error('--explore requires isolated story worktrees');
1021
+ if (options.exploreLimitMs !== undefined && (!Number.isSafeInteger(options.exploreLimitMs) || options.exploreLimitMs <= 0 || Date.now() + options.exploreLimitMs > 8_640_000_000_000_000))
1022
+ throw new Error('--explore-limit must be a positive, supported duration');
1023
+ statePath(targetDir, 'loop.lock');
1024
+ lock = acquireLock(targetDir);
1025
+ if (!lock.acquired) {
1026
+ console.error('Another loop is already running here');
1027
+ return 2;
1028
+ }
1029
+ const config = loadConfig(targetDir);
1030
+ const implementationAgent = options.agent ?? resolveRunnerAgent(config, undefined, detectHostAgent());
1031
+ const implementationSelection = options.selection ?? config?.runner ?? {};
1032
+ const explorationPlanner = options.explorationPlanner ?? resolvePlanner(config, implementationAgent, implementationSelection, options.agent);
1033
+ 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 } : {}) });
1034
+ if (options.savedRun && readRunState(targetDir)?.runId !== run.runId)
1035
+ throw new Error('Saved run changed before resume');
1036
+ writeRunState(targetDir, run, lock.ownerToken);
1037
+ return await runContinuousExploration(targetDir, { ...options, selection: run.options.selection, explorationPlanner: run.options.explorationPlanner ?? explorationPlanner, savedRun: run, ownedLockToken: lock.ownerToken });
1038
+ }
1039
+ catch (error) {
1040
+ console.error(`Cannot start exploration: ${error.message}`);
1041
+ return 2;
1042
+ }
1043
+ finally {
1044
+ if (lock?.acquired)
1045
+ releaseLock(targetDir, lock.ownerToken);
1046
+ }
1047
+ }
1048
+ /** Resume the saved execution identity without extending its exploration deadline. */
1049
+ export function resumeLoopCommand(targetDir) {
1050
+ const run = readRunState(targetDir);
1051
+ if (!run || run.mode === 'goal')
1052
+ throw new Error('No saved loop run to resume');
1053
+ const { native: _native, ...options } = run.options;
1054
+ 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' });
1055
+ }