@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,11 @@
1
1
  import { loadConfig } from "../retrofit/config.js";
2
2
  import { makeAsyncAdaptiveRunner } from "../routing/router.js";
3
3
  import { resolvePlanner } from '../routing/planning.js';
4
- import { randomUUID } from 'node:crypto';
4
+ import { randomUUID, createHash } from 'node:crypto';
5
5
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync, readdirSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { z } from 'zod';
8
- import { acceptanceProtectionProblem, checkProject, loadAcceptance, protectAcceptance } from '../check/command.js';
8
+ import { acceptanceProtectionProblem, checkProjectAsync, loadAcceptance, protectAcceptance } from '../check/command.js';
9
9
  import { acquireLock, releaseLock, isPidAlive } from '../loop/lock.js';
10
10
  import { reapProviderProcesses, isProviderTreeAlive } from '../loop/cleanup.js';
11
11
  import { killProcessTreeForCleanup } from '../loop/watchdog.js';
@@ -13,8 +13,43 @@ import { buildProviderInvocation, startProviderProcess } from '../agents/provide
13
13
  import { AgentSchema } from '../agents/contracts.js';
14
14
  import { appendEvent } from '../observability/events.js';
15
15
  import { statePath } from '../workspace/state.js';
16
+ import { acquireSharedWorker } from '../loop/resource-pool.js';
17
+ import { recordGoalRun } from '../loop/run-state.js';
18
+ import { executeCodexGoal, synchronizeNativeGoal, NativeGoalUnavailableError } from './codex-native.js';
16
19
  const Attempt = z.object({ provider: AgentSchema, model: z.string().optional(), startedAt: z.string(), durationMs: z.number().nonnegative(), success: z.boolean(), summary: z.string(), checkId: z.string(), inputTokens: z.number().nonnegative().optional(), outputTokens: z.number().nonnegative().optional() });
17
- const Goal = z.object({ version: z.literal(1), id: z.string().uuid(), objective: z.string().trim().min(1).max(16000), status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), createdAt: z.string(), updatedAt: z.string(), maxAttempts: z.number().int().min(1).max(20), maxMinutes: z.number().positive().max(1440), tokenBudget: z.number().int().positive().optional(), attempts: z.array(Attempt), reason: z.string().optional(), lastCheck: z.string().optional(), pendingAttempt: z.object({ provider: AgentSchema, model: z.string().optional(), startedAt: z.string().datetime() }).optional() }).strict();
20
+ const Goal = z.object({
21
+ version: z.literal(1), id: z.string().uuid(), objective: z.string().trim().min(1).max(16000),
22
+ status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), createdAt: z.string(), updatedAt: z.string(),
23
+ maxAttempts: z.number().int().min(1).max(20), maxMinutes: z.number().positive().max(1440),
24
+ maxWallMinutes: z.number().positive().max(10080).optional(), wallDurationMs: z.number().nonnegative().default(0),
25
+ tokenBudget: z.number().int().positive().optional(), attempts: z.array(Attempt), reason: z.string().optional(), lastCheck: z.string().optional(),
26
+ acceptanceBinding: z.object({ criterionIds: z.array(z.string()).min(1), manifestDigest: z.string().regex(/^[a-f0-9]{64}$/), objectiveDigest: z.string().regex(/^[a-f0-9]{64}$/) }).strict().optional(),
27
+ nativeBinding: z.object({ provider: z.literal('codex'), threadId: z.string().min(1), objectiveRevision: z.string().regex(/^[a-f0-9]{64}$/), modelProvider: z.string().optional(), model: z.string().optional(), usageInvalid: z.literal(true).optional(), usageBaseline: z.object({ inputTokens: z.number().int().nonnegative().safe(), outputTokens: z.number().int().nonnegative().safe(), cachedInputTokens: z.number().int().nonnegative().safe().optional(), cacheWriteInputTokens: z.number().int().nonnegative().safe().optional(), reasoningOutputTokens: z.number().int().nonnegative().safe().optional() }).strict().optional() }).strict().optional(),
28
+ nativeTokensUsed: z.number().nonnegative().optional(),
29
+ pendingRunStartedAt: z.string().datetime().optional(),
30
+ pendingAttempt: z.object({ provider: AgentSchema, model: z.string().optional(), startedAt: z.string().datetime() }).optional(),
31
+ }).strict();
32
+ const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
33
+ function acceptanceBinding(root, objective, ids) {
34
+ const manifest = loadAcceptance(root);
35
+ if (!manifest || !ids.length || new Set(ids).size !== ids.length || ids.some(id => !manifest.criteria.some(c => c.id === id && c.commands.length)))
36
+ throw new Error('Bind a nonempty unique list of executable acceptance criteria');
37
+ return { criterionIds: ids, manifestDigest: digest(manifest), objectiveDigest: digest(objective) };
38
+ }
39
+ export function bindProjectGoal(root, ids) {
40
+ const lock = acquireLock(root);
41
+ if (!lock.acquired)
42
+ throw new Error('Project is busy');
43
+ try {
44
+ const goal = readProjectGoal(root);
45
+ if (!goal)
46
+ throw new Error('No project goal');
47
+ return save(root, { ...goal, acceptanceBinding: acceptanceBinding(root, goal.objective, ids) });
48
+ }
49
+ finally {
50
+ releaseLock(root, lock.ownerToken);
51
+ }
52
+ }
18
53
  const goalPath = (root) => statePath(root, 'goal.json');
19
54
  function save(root, goal) {
20
55
  const parsed = Goal.parse({ ...goal, updatedAt: new Date().toISOString() });
@@ -50,7 +85,7 @@ export function createProjectGoal(root, objective, limits = {}) {
50
85
  const pause = statePath(root, 'goal.pause');
51
86
  if (existsSync(pause))
52
87
  unlinkSync(pause);
53
- return save(root, Goal.parse({ version: 1, id: randomUUID(), objective, status: 'active', createdAt: now, updatedAt: now, maxAttempts: limits.maxAttempts ?? 3, maxMinutes: limits.maxMinutes ?? 30, tokenBudget: limits.tokenBudget, attempts: [] }));
88
+ return save(root, Goal.parse({ version: 1, id: randomUUID(), objective, status: 'active', createdAt: now, updatedAt: now, maxAttempts: limits.maxAttempts ?? 3, maxMinutes: limits.maxMinutes ?? 30, maxWallMinutes: limits.maxWallMinutes, tokenBudget: limits.tokenBudget, attempts: [], acceptanceBinding: limits.acceptanceIds ? acceptanceBinding(root, objective.trim(), limits.acceptanceIds) : undefined }));
54
89
  }
55
90
  finally {
56
91
  releaseLock(root, lock.ownerToken);
@@ -77,7 +112,7 @@ export function budgetProjectGoal(root, limits) {
77
112
  const goal = readProjectGoal(root);
78
113
  if (!goal)
79
114
  throw new Error('No project goal');
80
- return save(root, { ...goal, maxAttempts: limits.maxAttempts ?? goal.maxAttempts, maxMinutes: limits.maxMinutes ?? goal.maxMinutes, tokenBudget: limits.clearTokenBudget ? undefined : limits.tokenBudget ?? goal.tokenBudget });
115
+ return save(root, { ...goal, maxAttempts: limits.maxAttempts ?? goal.maxAttempts, maxMinutes: limits.maxMinutes ?? goal.maxMinutes, maxWallMinutes: limits.maxWallMinutes ?? goal.maxWallMinutes, tokenBudget: limits.clearTokenBudget ? undefined : limits.tokenBudget ?? goal.tokenBudget });
81
116
  }
82
117
  finally {
83
118
  releaseLock(root, lock.ownerToken);
@@ -101,6 +136,8 @@ export function goalHandoff(root) {
101
136
  async function executeAgent(input) {
102
137
  const handle = startProviderProcess(input.provider, buildProviderInvocation(input.provider, input.prompt, input.root, 'safe', input.selection), { signal: input.signal, idleTimeoutMs: 20 * 60_000 });
103
138
  const result = await handle.completion;
139
+ if (handle.recordPath && existsSync(handle.recordPath))
140
+ throw Object.assign(new Error('Provider process cleanup could not be confirmed'), { cleanupUnconfirmed: true });
104
141
  return { success: result.kind === 'succeeded', summary: result.kind === 'succeeded' ? 'Agent finished; independently checked below' : `${result.kind}: ${result.stderr.slice(-3000)}`, ...result.telemetry.tokens, tokens: result.telemetry.tokens };
105
142
  }
106
143
  export async function runProjectGoal(root, options = {}) {
@@ -108,10 +145,62 @@ export async function runProjectGoal(root, options = {}) {
108
145
  if (!lock.acquired)
109
146
  throw new Error('Project is busy; goal and story loops share one lock');
110
147
  let goal = null;
148
+ let runStarted;
149
+ let wallAtStart = 0;
150
+ let wallTimer;
151
+ let pauseTimer;
152
+ let cleanupUnconfirmed = false;
153
+ let resolvedSelection;
154
+ let nativeEnabled = false;
155
+ const controller = new AbortController();
156
+ const tokensUsed = () => goal.attempts.reduce((sum, a) => sum + (a.inputTokens ?? 0) + (a.outputTokens ?? 0), 0);
157
+ const finish = async (status, reason, report) => {
158
+ if (status === 'paused' && existsSync(statePath(root, 'goal.pause')))
159
+ unlinkSync(statePath(root, 'goal.pause'));
160
+ if (goal.nativeBinding && nativeEnabled) {
161
+ const nativeStatus = status === 'complete' ? 'complete' : status === 'paused' ? 'paused' : /token/i.test(reason) ? 'budgetLimited' : 'blocked';
162
+ try {
163
+ const lease = await acquireSharedWorker({ targetDir: root, storyId: goal.id, provider: 'codex', role: 'integration', signal: AbortSignal.timeout(30_000) });
164
+ let retain = false;
165
+ try {
166
+ await synchronizeNativeGoal({ root, binding: goal.nativeBinding, objective: goal.objective, status: nativeStatus, bare: resolvedSelection?.bare, signal: status === 'complete' ? AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000) });
167
+ }
168
+ catch (error) {
169
+ const cleanup = error;
170
+ retain = cleanup.cleanupUnconfirmed === true;
171
+ if (retain)
172
+ void cleanup.cleanupConfirmed?.then(() => lease.release()).catch(() => undefined);
173
+ throw error;
174
+ }
175
+ finally {
176
+ if (!retain)
177
+ await lease.release();
178
+ }
179
+ }
180
+ catch (error) {
181
+ status = 'blocked';
182
+ reason += `; native state synchronization failed: ${error.message}`;
183
+ }
184
+ }
185
+ if (status === 'complete' && controller.signal.aborted) {
186
+ status = existsSync(statePath(root, 'goal.pause')) ? 'paused' : 'blocked';
187
+ reason = `${String(controller.signal.reason)}; verified work retained`;
188
+ }
189
+ if (status === 'paused' && existsSync(statePath(root, 'goal.pause')))
190
+ unlinkSync(statePath(root, 'goal.pause'));
191
+ goal = save(root, { ...goal, status, reason, lastCheck: report?.id ?? goal.lastCheck });
192
+ return goal;
193
+ };
111
194
  try {
112
195
  goal = readProjectGoal(root);
113
196
  if (!goal)
114
197
  throw new Error('Set a goal first');
198
+ if (options.expectedGoalId && goal.id !== options.expectedGoalId) {
199
+ goal = null;
200
+ throw new Error('Saved goal identity changed before resume');
201
+ }
202
+ if (goal.pendingRunStartedAt)
203
+ goal = save(root, { ...goal, wallDurationMs: goal.wallDurationMs + Math.max(0, Date.now() - Date.parse(goal.pendingRunStartedAt)), pendingRunStartedAt: undefined });
115
204
  // Exclusive project lock held. Reconcile only this project's recorded trees;
116
205
  // unknown ownership stays blocked rather than racing an orphaned worker.
117
206
  reapProviderProcesses(root, isPidAlive, isProviderTreeAlive, killProcessTreeForCleanup);
@@ -126,6 +215,10 @@ export async function runProjectGoal(root, options = {}) {
126
215
  const manifest = loadAcceptance(root);
127
216
  if (!manifest?.criteria.length || manifest.criteria.some(c => c.commands.length === 0) || !manifest.protected.length)
128
217
  return save(root, { ...goal, status: 'blocked', reason: 'Map every acceptance criterion to an executable command and explicitly protect its test infrastructure before running a goal' });
218
+ if (!goal.acceptanceBinding)
219
+ return save(root, { ...goal, status: 'blocked', reason: 'Bind this objective explicitly with goal bind --criteria=id1,id2 before running it' });
220
+ if (goal.acceptanceBinding.manifestDigest !== digest(manifest) || goal.acceptanceBinding.objectiveDigest !== digest(goal.objective))
221
+ return save(root, { ...goal, status: 'blocked', reason: 'Acceptance binding changed; review the objective and acceptance criteria before rebinding' });
129
222
  try {
130
223
  protectAcceptance(root);
131
224
  }
@@ -133,85 +226,193 @@ export async function runProjectGoal(root, options = {}) {
133
226
  if (error.code !== 'EEXIST')
134
227
  throw error;
135
228
  }
136
- const provider = options.provider ?? 'codex';
137
- const rawExecute = options.execute ?? executeAgent;
138
229
  const config = loadConfig(root);
230
+ const provider = options.provider ?? config?.runner?.agent ?? 'codex';
231
+ const configured = config?.runner;
232
+ const selection = { provider: configured?.provider, model: configured?.model, reasoningEffort: configured?.reasoningEffort, variant: configured?.variant, bare: configured?.bare, ...Object.fromEntries(Object.entries(options.selection ?? {}).filter(([, value]) => value !== undefined)), nativeMultiAgent: false };
233
+ resolvedSelection = selection;
234
+ const native = options.native ?? config?.goals?.nativeCodex ?? false;
235
+ nativeEnabled = native;
236
+ if (native && provider === 'codex' && selection.bare) {
237
+ goal = save(root, { ...goal, status: 'blocked', reason: 'Native Codex app-server does not support bare startup; use --no-native-goal to retain bare execution or explicitly disable runner.bare' });
238
+ return goal;
239
+ }
240
+ recordGoalRun(root, goal.id, lock.ownerToken, { provider, selection, native });
241
+ wallAtStart = goal.wallDurationMs;
242
+ runStarted = Date.now();
243
+ if (goal.maxWallMinutes !== undefined) {
244
+ const remainingWall = goal.maxWallMinutes * 60_000 - wallAtStart;
245
+ if (remainingWall <= 0)
246
+ return await finish('blocked', 'Wall-time budget exhausted; unfinished work retained');
247
+ wallTimer = setTimeout(() => controller.abort('Wall-time budget exhausted'), remainingWall);
248
+ }
249
+ goal = save(root, { ...goal, pendingRunStartedAt: new Date(runStarted).toISOString() });
250
+ pauseTimer = setInterval(() => { if (existsSync(statePath(root, 'goal.pause')))
251
+ controller.abort('Pause requested'); }, 100);
252
+ let attemptAgentMs = 0;
253
+ const admitted = async (input, operation) => {
254
+ const queued = Date.now();
255
+ const lease = await acquireSharedWorker({ targetDir: root, storyId: goal.id, provider: input.provider, role: 'implementation', signal: input.signal });
256
+ const started = Date.now();
257
+ const remaining = goal.maxMinutes * 60_000 - goal.attempts.reduce((sum, a) => sum + a.durationMs, 0) - attemptAgentMs;
258
+ const timer = setTimeout(() => controller.abort('Goal agent-time budget exceeded'), Math.max(0, remaining));
259
+ try {
260
+ appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'phase-ended', phase: 'resource-wait-implementation', durationMs: started - queued });
261
+ if (remaining <= 0 || input.signal.aborted)
262
+ throw new Error('Goal execution cancelled or agent-time budget exhausted');
263
+ return await operation();
264
+ }
265
+ catch (error) {
266
+ const cleanup = error;
267
+ cleanupUnconfirmed ||= cleanup.cleanupUnconfirmed === true;
268
+ if (cleanup.cleanupUnconfirmed)
269
+ void cleanup.cleanupConfirmed?.then(() => lease.release()).catch(() => undefined);
270
+ throw error;
271
+ }
272
+ finally {
273
+ clearTimeout(timer);
274
+ attemptAgentMs += Date.now() - started;
275
+ if (!cleanupUnconfirmed)
276
+ await lease.release();
277
+ }
278
+ };
279
+ const rawExecute = async (input) => {
280
+ return admitted(input, async () => {
281
+ if (options.execute)
282
+ return await options.execute(input);
283
+ if (native && input.provider === 'codex') {
284
+ let binding = goal.nativeBinding;
285
+ if (binding && (binding.model !== input.selection.model || binding.modelProvider !== input.selection.provider))
286
+ binding = undefined;
287
+ try {
288
+ const result = await executeCodexGoal({ ...input, objective: goal.objective, tokenBudget: goal.tokenBudget === undefined ? undefined : goal.tokenBudget - tokensUsed(), binding, onBinding: (nativeBinding) => { goal = save(root, { ...goal, nativeBinding }); }, onUsageUpdate: (usage) => {
289
+ if (goal.tokenBudget !== undefined && usage.inputTokens !== undefined && usage.outputTokens !== undefined && tokensUsed() + usage.inputTokens + usage.outputTokens > goal.tokenBudget)
290
+ controller.abort('Token budget exceeded during native execution');
291
+ } });
292
+ goal = save(root, { ...goal, nativeTokensUsed: result.nativeTokensUsed });
293
+ return result;
294
+ }
295
+ catch (error) {
296
+ if (!(error instanceof NativeGoalUnavailableError))
297
+ throw error;
298
+ goal = save(root, { ...goal, nativeBinding: undefined, nativeTokensUsed: undefined });
299
+ appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'status', data: { nativeGoal: 'unavailable', fallback: 'codex-exec' } });
300
+ }
301
+ }
302
+ return await executeAgent(input);
303
+ });
304
+ };
139
305
  const execute = async (input) => {
140
306
  if (!config?.routing?.enabled || config.routing.strategy !== "capability")
141
307
  return rawExecute(input);
142
308
  const routed = makeAsyncAdaptiveRunner({ parent: provider, parentSelection: input.selection, projectRoot: root, workers: config.routing.workers, strategy: "capability", maxCandidates: config.routing.maxCandidates, maxAttempts: Math.min(goal.maxAttempts, config.routing.maxAttempts ?? 5),
143
309
  planner: resolvePlanner(config, provider, input.selection), fallback: config.routing.fallback, maxTier: config.routing.maxTier,
144
310
  captureRoute: async (agent, context, prompt, selection) => {
145
- const run = await startProviderProcess(agent, buildProviderInvocation(agent, prompt, root, "read-only", selection), { signal: input.signal, idleTimeoutMs: 20 * 60_000 }).completion;
146
- return { success: run.kind === "succeeded", summary: run.kind, output: run.stdout, tokens: run.telemetry.tokens };
311
+ return admitted({ ...input, provider: agent }, async () => {
312
+ const handle = startProviderProcess(agent, buildProviderInvocation(agent, prompt, root, "read-only", { ...selection, nativeMultiAgent: false }), { signal: input.signal, idleTimeoutMs: 20 * 60_000 });
313
+ const run = await handle.completion;
314
+ if (handle.recordPath && existsSync(handle.recordPath))
315
+ throw Object.assign(new Error('Planner process cleanup could not be confirmed'), { cleanupUnconfirmed: true });
316
+ return { success: run.kind === "succeeded", summary: run.kind, output: run.stdout, tokens: run.telemetry.tokens };
317
+ });
147
318
  },
148
319
  makeWorker: (agent, selection) => async (context) => {
149
- const result = await rawExecute({ ...input, provider: agent, selection, prompt: input.prompt + "\nPlanner approach:\n" + (context.story.assessment?.approach ?? "") });
320
+ const result = await rawExecute({ ...input, provider: agent, selection: { ...selection, nativeMultiAgent: false }, prompt: input.prompt + "\nPlanner approach:\n" + (context.story.assessment?.approach ?? "") });
150
321
  return { ...result, infrastructureFailure: !result.success, tokens: result.tokens ?? (result.inputTokens !== undefined && result.outputTokens !== undefined ? { inputTokens: result.inputTokens, outputTokens: result.outputTokens, model: result.model } : undefined) };
151
322
  }, });
152
323
  const result = await routed({ targetDir: root, story: { id: goal.id, title: goal.objective, priority: 1, passes: false, agent: provider, acceptance: manifest.criteria.map(c => c.text) } });
153
324
  const last = result.tokens?.calls?.at(-1);
154
325
  return { ...result, provider: last?.provider ?? provider, model: last?.actualModel, inputTokens: result.tokens?.measurementComplete ? result.tokens.inputTokens : undefined, outputTokens: result.tokens?.measurementComplete ? result.tokens.outputTokens : undefined };
155
326
  };
156
- let report = checkProject(root);
327
+ let report = await checkProjectAsync(root, { signal: controller.signal });
328
+ if (report.cleanupUnconfirmed) {
329
+ goal = save(root, { ...goal, status: 'blocked', reason: 'Verification cleanup could not be confirmed; retained resource ownership requires inspection', lastCheck: report.id });
330
+ return goal;
331
+ }
157
332
  while (report.status !== 'passed') {
158
333
  const protectionProblem = acceptanceProtectionProblem(root);
159
334
  if (protectionProblem)
160
- return save(root, { ...goal, status: 'blocked', reason: protectionProblem, lastCheck: report.id });
335
+ return await finish('blocked', protectionProblem, report);
161
336
  const pause = statePath(root, 'goal.pause');
162
337
  if (existsSync(pause)) {
163
338
  unlinkSync(pause);
164
- return save(root, { ...goal, status: 'paused', lastCheck: report.id, reason: 'Paused at a safe attempt boundary' });
339
+ return await finish('paused', 'Paused at a safe attempt boundary', report);
165
340
  }
341
+ if (controller.signal.aborted)
342
+ return await finish('blocked', String(controller.signal.reason), report);
166
343
  const remaining = goal.maxMinutes * 60_000 - goal.attempts.reduce((sum, a) => sum + a.durationMs, 0);
167
344
  if (remaining <= 0 || goal.attempts.length >= goal.maxAttempts)
168
- return save(root, { ...goal, status: 'blocked', lastCheck: report.id, reason: 'Execution budget exhausted; unfinished work retained' });
345
+ return await finish('blocked', 'Execution budget exhausted; unfinished work retained', report);
169
346
  if (goal.tokenBudget !== undefined && goal.attempts.length > 0) {
170
347
  const unknown = goal.attempts.some(a => a.inputTokens === undefined || a.outputTokens === undefined);
171
348
  const used = goal.attempts.reduce((sum, a) => sum + (a.inputTokens ?? 0) + (a.outputTokens ?? 0), 0);
172
349
  if (unknown || used >= goal.tokenBudget)
173
- return save(root, { ...goal, status: 'blocked', reason: unknown ? 'Token usage unknown; cannot safely start another budgeted attempt' : 'Token budget exhausted' });
350
+ return await finish('blocked', unknown ? 'Token usage unknown; cannot safely start another budgeted attempt' : 'Token budget exhausted', report);
174
351
  }
175
352
  const started = Date.now();
176
- goal = save(root, { ...goal, status: 'running', lastCheck: report.id, reason: undefined, pendingAttempt: { provider, model: options.selection?.model, startedAt: new Date(started).toISOString() } });
177
- const controller = new AbortController();
178
- const timer = setTimeout(() => controller.abort('Goal time budget exceeded'), remaining);
353
+ goal = save(root, { ...goal, status: 'running', lastCheck: report.id, reason: undefined, pendingAttempt: { provider, model: selection.model, startedAt: new Date(started).toISOString() } });
354
+ attemptAgentMs = 0;
179
355
  let result;
180
356
  try {
181
- result = await execute({ root, provider, selection: options.selection ?? {}, signal: controller.signal, prompt: `${goalHandoff(root)}\nCurrent independent findings:\n${JSON.stringify(report.criteria).slice(0, 12000)}` });
357
+ result = await execute({ root, provider, selection, signal: controller.signal, prompt: `${goalHandoff(root)}\nCurrent independent findings:\n${JSON.stringify(report.criteria).slice(0, 12000)}` });
182
358
  }
183
359
  catch (error) {
184
360
  result = { success: false, summary: error.message };
185
361
  }
186
- finally {
187
- clearTimeout(timer);
362
+ const agentDurationMs = attemptAgentMs;
363
+ if (cleanupUnconfirmed) {
364
+ goal.attempts.push({ provider, model: selection.model, startedAt: new Date(started).toISOString(), durationMs: agentDurationMs, success: false, summary: result.summary, checkId: report.id });
365
+ goal = save(root, { ...goal, pendingAttempt: undefined, status: 'blocked', reason: 'Provider cleanup could not be confirmed; process record and resource reservation retained' });
366
+ return goal;
188
367
  }
189
- const agentDurationMs = Date.now() - started;
190
368
  const checkStarted = Date.now();
191
- report = checkProject(root);
369
+ report = await checkProjectAsync(root, { signal: controller.signal });
192
370
  if (!result.routing?.blocked)
193
371
  result.routing?.recordOutcome(report.status === 'passed');
194
372
  appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'phase-ended', phase: 'verify', durationMs: Date.now() - checkStarted, outcome: report.status });
195
- goal.attempts.push({ provider: result.provider ?? provider, model: result.model ?? options.selection?.model, startedAt: new Date(started).toISOString(), durationMs: agentDurationMs, success: result.success, summary: result.summary.slice(0, 8000), checkId: report.id, inputTokens: result.inputTokens, outputTokens: result.outputTokens });
373
+ goal.attempts.push({ provider: result.provider ?? provider, model: result.model ?? selection.model, startedAt: new Date(started).toISOString(), durationMs: agentDurationMs, success: result.success, summary: result.summary.slice(0, 8000), checkId: report.id, inputTokens: result.inputTokens, outputTokens: result.outputTokens });
196
374
  const usageAvailable = result.inputTokens !== undefined && result.outputTokens !== undefined;
197
375
  appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'tokens', attemptId: `${goal.id}:${goal.attempts.length}`, durationMs: agentDurationMs, data: { ...result.tokens, provider: result.provider ?? provider, role: 'parent', model: result.model, inputTokens: result.inputTokens, outputTokens: result.outputTokens, usageAvailable } });
198
376
  appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'attempt-ended', attemptId: `${goal.id}:${goal.attempts.length}`, durationMs: Date.now() - started, outcome: report.status, data: { provider, model: result.model, usageAvailable } });
199
377
  if (report.status === 'passed')
200
378
  appendEvent(root, { runId: goal.id, timestamp: new Date().toISOString(), type: 'accepted', attemptId: `${goal.id}:${goal.attempts.length}` });
201
379
  goal = save(root, { ...goal, lastCheck: report.id, pendingAttempt: undefined });
380
+ if (report.cleanupUnconfirmed) {
381
+ goal = save(root, { ...goal, status: 'blocked', reason: 'Verification cleanup could not be confirmed; process record and resource reservation retained' });
382
+ return goal;
383
+ }
202
384
  if (result.routing?.blocked)
203
- return save(root, { ...goal, status: "blocked", reason: result.summary });
385
+ return await finish('blocked', result.summary, report);
204
386
  if (controller.signal.aborted)
205
- return save(root, { ...goal, status: 'blocked', reason: 'Time budget exceeded; work retained' });
387
+ return await finish(existsSync(statePath(root, 'goal.pause')) ? 'paused' : 'blocked', `${String(controller.signal.reason)}; work retained`, report);
388
+ if (goal.tokenBudget !== undefined && (goal.attempts.some(a => a.inputTokens === undefined || a.outputTokens === undefined) || tokensUsed() > goal.tokenBudget))
389
+ return await finish('blocked', 'Token budget exhausted or usage unknown; work retained', report);
206
390
  }
207
- return save(root, { ...goal, status: 'complete', lastCheck: report.id, reason: 'All executable acceptance checks passed on the current workspace' });
391
+ if (controller.signal.aborted)
392
+ return await finish(existsSync(statePath(root, 'goal.pause')) ? 'paused' : 'blocked', String(controller.signal.reason), report);
393
+ if (goal.tokenBudget !== undefined && goal.attempts.length && (goal.attempts.some(a => a.inputTokens === undefined || a.outputTokens === undefined) || tokensUsed() > goal.tokenBudget))
394
+ return await finish('blocked', 'Token budget exhausted or usage unknown; work retained', report);
395
+ return await finish('complete', 'All bound acceptance criteria and regression checks passed on the current workspace', report);
208
396
  }
209
397
  catch (error) {
210
398
  if (goal)
211
- save(root, { ...goal, status: 'blocked', reason: error.message });
399
+ goal = save(root, { ...goal, status: 'blocked', reason: error.message });
212
400
  throw error;
213
401
  }
214
402
  finally {
215
- releaseLock(root, lock.ownerToken);
403
+ if (wallTimer)
404
+ clearTimeout(wallTimer);
405
+ if (pauseTimer)
406
+ clearInterval(pauseTimer);
407
+ try {
408
+ if (goal && runStarted !== undefined) {
409
+ goal.wallDurationMs = wallAtStart + Math.max(0, Date.now() - runStarted);
410
+ goal.pendingRunStartedAt = undefined;
411
+ save(root, goal);
412
+ }
413
+ }
414
+ finally {
415
+ releaseLock(root, lock.ownerToken);
416
+ }
216
417
  }
217
418
  }
@@ -7,6 +7,7 @@ import { acquireTakeoverLease, acquireTakeoverRecoveryLease, lockPath, takeoverL
7
7
  import { killProcessForCleanup, killProcessTreeForCleanup } from './watchdog.js';
8
8
  import { cleanupClaims } from './claims.js';
9
9
  import { clearStatus } from './reporter.js';
10
+ import { discardParallelRecoveryRecords } from './recovery.js';
10
11
  // Reap orphaned runners PROJECT-SCOPED: kill only pids recorded in this project's
11
12
  // .yoke/runner.pid files (main dir + each worktree). Never by process-name or
12
13
  // command-line pattern — that takes down runners belonging to OTHER projects,
@@ -217,8 +218,16 @@ export function runLoopCleanup(targetDir, opts = {}) {
217
218
  }
218
219
  if (killed > 0)
219
220
  console.log(`Killed ${killed} orphaned process tree(s) from project-scoped Yoke records.`);
220
- if (opts.removeWorktrees && failed === 0)
221
+ if (opts.removeWorktrees && failed === 0) {
221
222
  cleanupClaims(targetDir);
223
+ try {
224
+ discardParallelRecoveryRecords(targetDir);
225
+ }
226
+ catch (error) {
227
+ console.error(`Failed to discard parallel recovery records: ${error.message}`);
228
+ failed++;
229
+ }
230
+ }
222
231
  const lockFile = lockPath(targetDir);
223
232
  if (existsSync(lockFile)) {
224
233
  rmSync(lockFile, { force: true });
@@ -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) {