@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
@@ -34,18 +34,32 @@ async function gateResult(gates, path, worker) {
34
34
  if (criteria.length > 0 && !gates.verifyCriterion)
35
35
  return { passed: false, summary: 'criterion verifier is not configured' };
36
36
  for (const criterion of criteria) {
37
- const result = gates.verifyCriterion?.(path, story, criterion);
37
+ const result = withStoryContext(story.id, () => gates.verifyCriterion?.(path, story, criterion));
38
38
  if (!result?.passed)
39
39
  return { passed: false, summary: result?.summary ?? 'criterion verification failed' };
40
40
  }
41
41
  for (const gate of [gates.verify, gates.design, gates.perf, gates.audit]) {
42
42
  if (!gate)
43
43
  continue;
44
- const result = gate(path, story);
44
+ const result = withStoryContext(story.id, () => gate(path, story));
45
45
  if (!result.passed)
46
46
  return result;
47
47
  }
48
- return gates.qualityReview?.(path, story, worker) ?? { passed: true, summary: 'integrated gates passed' };
48
+ return withStoryContext(story.id, () => gates.qualityReview?.(path, story, worker)) ?? { passed: true, summary: 'integrated gates passed' };
49
+ }
50
+ // All gate APIs are synchronous: never leave ambient story context set across an await.
51
+ function withStoryContext(storyId, gate) {
52
+ const previous = process.env.YOKE_STORY;
53
+ process.env.YOKE_STORY = storyId;
54
+ try {
55
+ return gate();
56
+ }
57
+ finally {
58
+ if (previous === undefined)
59
+ delete process.env.YOKE_STORY;
60
+ else
61
+ process.env.YOKE_STORY = previous;
62
+ }
49
63
  }
50
64
  export function createDispatcher(options) {
51
65
  const dispatcherId = options.dispatcherId ?? randomUUID();
@@ -125,6 +139,11 @@ export function createDispatcher(options) {
125
139
  if (claims.release(input) === false)
126
140
  throw new Error('claim release failed');
127
141
  };
142
+ const cleanupRetained = createWorkerCleanup({
143
+ cleanupProcess: options.worktrees.cleanupProcess,
144
+ removeWorktree: () => undefined,
145
+ releaseClaim: releaseCandidateClaim,
146
+ });
128
147
  const persistPass = (input) => {
129
148
  if (!options.prdPath)
130
149
  return;
@@ -135,6 +154,8 @@ export function createDispatcher(options) {
135
154
  };
136
155
  const enqueue = (input, result) => {
137
156
  let reason = 'integrated verification failed';
157
+ let retained = false;
158
+ let recoveryBaseCommit = input.worktree.baseCommit;
138
159
  const integrationQueuedAt = Date.now();
139
160
  let integrationStartedAt;
140
161
  const integrationController = new AbortController();
@@ -160,6 +181,7 @@ export function createDispatcher(options) {
160
181
  reason = rebase.reason;
161
182
  throw new Error(reason);
162
183
  }
184
+ recoveryBaseCommit = rebase.expectedHead;
163
185
  return rebase.expectedHead;
164
186
  },
165
187
  verify: async () => {
@@ -169,7 +191,9 @@ export function createDispatcher(options) {
169
191
  }
170
192
  const gate = await gateResult(options.gates, input.worktree.path, input);
171
193
  reason = gate.summary;
172
- return gate.passed;
194
+ if (!gate.passed)
195
+ throw new Error(gate.summary);
196
+ return true;
173
197
  },
174
198
  integrate: async (expectedHead) => {
175
199
  if (cancellationReason)
@@ -200,6 +224,15 @@ export function createDispatcher(options) {
200
224
  return;
201
225
  case 'reopened':
202
226
  reopened.push(input.story.id);
227
+ reason = merge.reason;
228
+ if (options.worktrees.retain) {
229
+ // Retain first, then report/release. Recovery evidence must precede cleanup.
230
+ retained = true;
231
+ options.worktrees.retain({ ...input, worktree: { ...input.worktree, baseCommit: recoveryBaseCommit } }, reason);
232
+ failed.push(input.story.id);
233
+ failureReasons.set(input.story.id, `${reason}; candidate retained at ${input.worktree.path}`);
234
+ }
235
+ options.onReopened?.(input.story, { reason, worktree: input.worktree.path, baseCommit: recoveryBaseCommit, ownerToken: input.ownerToken });
203
236
  result.routing.recordOutcome?.(false);
204
237
  return;
205
238
  default: {
@@ -217,7 +250,10 @@ export function createDispatcher(options) {
217
250
  options.onIntegrationMetrics?.(input, Date.now() - integrationQueuedAt, integrationStartedAt === undefined ? undefined : Date.now() - integrationStartedAt);
218
251
  }
219
252
  finally {
220
- cleanup(input);
253
+ if (retained)
254
+ cleanupRetained(input);
255
+ else
256
+ cleanup(input);
221
257
  reportProgress();
222
258
  }
223
259
  });
@@ -227,12 +263,22 @@ export function createDispatcher(options) {
227
263
  const launch = (story) => {
228
264
  const provider = providerFor(story);
229
265
  const ownerToken = ids();
230
- const worktree = options.worktrees.create({ story, dispatcherId, ownerToken, provider });
266
+ let worktree;
267
+ try {
268
+ worktree = options.worktrees.create({ story, dispatcherId, ownerToken, provider });
269
+ }
270
+ catch (error) {
271
+ failed.push(story.id);
272
+ failureReasons.set(story.id, `worktree setup failed: ${error instanceof Error ? error.message : String(error)}`);
273
+ reportProgress();
274
+ return;
275
+ }
231
276
  const controller = new AbortController();
232
277
  const candidateRace = Boolean(options.candidateCount && options.candidateCount > 1 && options.candidateCoordinator);
233
278
  const input = { story, worktree, provider, cancellation: { signal: controller.signal }, dispatcherId, ownerToken, ...(candidateRace ? { candidateRace: true } : {}) };
234
279
  if (!claims.acquire(input)) {
235
- options.worktrees.remove(input);
280
+ if (!worktree.recovered)
281
+ options.worktrees.remove(input);
236
282
  return;
237
283
  }
238
284
  iterations += 1;
@@ -241,6 +287,19 @@ export function createDispatcher(options) {
241
287
  areas.add(story.area);
242
288
  const candidateDispatch = candidateRace;
243
289
  const activeWorker = { input, controller, task: Promise.resolve(), phase: options.acquireResource ? 'waiting-resource' : 'implementing' };
290
+ // Durable admission reporting must succeed before the worker microtask can start.
291
+ active.set(story.id, activeWorker);
292
+ try {
293
+ reportProgress();
294
+ }
295
+ catch (error) {
296
+ active.delete(story.id);
297
+ if (worktree.recovered)
298
+ cleanupRetained(input);
299
+ else
300
+ cleanup(input);
301
+ throw error;
302
+ }
244
303
  const task = Promise.resolve().then(async () => {
245
304
  const workerLease = options.acquireResource
246
305
  ? await options.acquireResource({ story, provider, role: 'implementation', units: candidateRace ? options.candidateCount : 1, signal: controller.signal })
@@ -248,6 +307,13 @@ export function createDispatcher(options) {
248
307
  activeWorker.phase = 'implementing';
249
308
  reportProgress();
250
309
  try {
310
+ if (worktree.recovered)
311
+ options.worktrees.cleanupProcess?.(input);
312
+ if (worktree.recovered)
313
+ return { source: 'worker', result: {
314
+ kind: 'candidate', storyId: story.id, worktree: worktree.path, baseCommit: worktree.baseCommit,
315
+ provider, summary: 'retained candidate recovered for independent integration checks', evidence: { criteria: [] }, routing: { outcome: 'pending-integration' },
316
+ } };
251
317
  return await (candidateDispatch && options.candidateCoordinator
252
318
  ? Promise.resolve(options.candidateCoordinator(input)).then(coordinateCandidates).then(result => ({ source: 'candidates', result }))
253
319
  : options.worker(input).then(result => ({ source: 'worker', result })));
@@ -312,7 +378,10 @@ export function createDispatcher(options) {
312
378
  if (cancellationReason || paused || options.pause?.()) {
313
379
  paused = true;
314
380
  result.routing.recordOutcome?.(false);
315
- cleanup(input);
381
+ if (input.worktree.recovered)
382
+ cleanupRetained(input);
383
+ else
384
+ cleanup(input);
316
385
  reportProgress();
317
386
  return;
318
387
  }
@@ -322,7 +391,10 @@ export function createDispatcher(options) {
322
391
  failed.push(story.id);
323
392
  failureReasons.set(story.id, error instanceof Error ? error.message : String(error));
324
393
  try {
325
- cleanup(input);
394
+ if (input.worktree.recovered)
395
+ cleanupRetained(input);
396
+ else
397
+ cleanup(input);
326
398
  }
327
399
  catch (cleanupError) {
328
400
  reportProgress();
@@ -387,7 +459,7 @@ export function createDispatcher(options) {
387
459
  if (failed.length > 0) {
388
460
  const storyId = failed[0];
389
461
  const detail = storyId ? failureReasons.get(storyId) : undefined;
390
- return { status: 'blocked', ...(storyId && detail ? { reason: `worker ${storyId} failed: ${detail}` } : {}), iterations, integrated, reopened, failed };
462
+ return { status: 'blocked', ...(storyId && detail ? { reason: `${reopened.includes(storyId) ? `story ${storyId} integration rejected` : `worker ${storyId} failed`}: ${detail}` } : {}), iterations, integrated, reopened, failed };
391
463
  }
392
464
  if (iterations >= options.maxIterations)
393
465
  return { status: 'cap-reached', iterations, integrated, reopened, failed };
package/dist/loop/git.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { sanitizeCommitMessage } from './identity.js';
3
- export const RUNTIME_PATHS = [".yoke/supervision", ".yoke/provider-processes", '.yoke/artifacts', '.yoke/events', '.yoke/history', '.yoke/routing', '.yoke/checks', '.yoke/goal.json', '.yoke/goal.pause', '.yoke/loop.pause', '.yoke/loop-status.json', '.yoke/loop.lock', '.yoke/loop.lock.takeover', '.yoke/loop.lock.takeover.recovery', '.yoke/runner.pid', '.yoke/story-durations.json'];
3
+ export const RUNTIME_PATHS = [".yoke/supervision", ".yoke/provider-processes", '.yoke/claims', '.yoke/worktrees', '.yoke/artifacts', '.yoke/events', '.yoke/history', '.yoke/routing', '.yoke/checks', '.yoke/integration-recovery', '.yoke/run-state.json', '.yoke/run-state.*.tmp', '.yoke/goal.json', '.yoke/goal.pause', '.yoke/loop.pause', '.yoke/loop-status.json', '.yoke/loop.lock', '.yoke/loop.lock.takeover', '.yoke/loop.lock.takeover.recovery', '.yoke/runner.pid', '.yoke/story-durations.json'];
4
4
  // Directory pathspecs include descendants and also exclude the directory entry.
5
5
  export const RUNTIME_EXCLUDES = RUNTIME_PATHS.map(path => `:(exclude)${path}`);
6
6
  export function stageImplementation(dir) {
@@ -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
+ }