@hecer/yoke 1.19.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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +27 -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 +11 -7
  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 +10 -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 +91 -25
  38. package/dist/loop/run-state.js +116 -0
  39. package/dist/prd/explore.js +12 -4
  40. package/dist/retrofit/config.js +1 -0
  41. package/dist/retrofit/gitignore.js +3 -0
  42. package/docs/CODEX-COMPARISON-2026-09-29.md +27 -0
  43. package/docs/CONTINUOUS-EXPLORATION.md +6 -2
  44. package/docs/GOALS-RESOURCE-AUDIT-2026-09-29.md +92 -0
  45. package/docs/GOALS.md +109 -0
  46. package/docs/RELEASE-VALIDATION-1.20.0.md +100 -0
  47. package/docs/VERIFIED-PROJECTS.md +4 -4
  48. package/docs/parallel-execution.md +15 -0
  49. package/docs/superpowers/plans/2026-09-30-goals-resources-release.md +78 -0
  50. package/gemini-extension.json +1 -1
  51. package/package.json +5 -1
@@ -8,11 +8,159 @@ import { defaultConfig, loadConfig, resolveVerifyCommand } from '../retrofit/con
8
8
  import { commandVerifier } from '../loop/verify.js';
9
9
  import { workspaceFingerprint } from '../workspace/fingerprint.js';
10
10
  import { statePath } from '../workspace/state.js';
11
+ import { spawn } from 'node:child_process';
12
+ import { acquireSharedWorker } from '../loop/resource-pool.js';
13
+ import { compactCommandOutput } from '../output/compact.js';
14
+ import { writeOutputArtifact } from '../output/artifact.js';
15
+ import { DEFAULT_OUTPUT_POLICY } from '../output/types.js';
16
+ import { createProviderProcessRecord, filesystemProviderProcessRecordAdapter } from '../agents/process-record.js';
17
+ import { trackProcessRecordIdentity } from '../agents/process-record-identity.js';
11
18
  const Criterion = z.object({ id: z.string().min(1).max(120), text: z.string().min(1).max(8000), commands: z.array(z.string().min(1).max(8000)).max(30) }).strict();
12
19
  const Acceptance = z.object({ version: z.literal(1), criteria: z.array(Criterion).max(200), protected: z.array(z.string().min(1)).max(500).default([]) }).strict().superRefine((value, ctx) => {
13
20
  if (new Set(value.criteria.map(c => c.id)).size !== value.criteria.length)
14
21
  ctx.addIssue({ code: 'custom', message: 'Duplicate acceptance criterion id' });
15
22
  });
23
+ export async function checkProjectAsync(directory, options = {}) {
24
+ const controller = new AbortController();
25
+ const abort = () => controller.abort(options.signal?.reason ?? 'Verification cancelled');
26
+ if (options.signal?.aborted)
27
+ abort();
28
+ else
29
+ options.signal?.addEventListener('abort', abort, { once: true });
30
+ const remaining = options.deadline === undefined ? undefined : options.deadline - Date.now();
31
+ if (remaining !== undefined && remaining <= 0)
32
+ controller.abort('Verification deadline reached');
33
+ const timer = remaining === undefined || remaining <= 0 ? undefined : setTimeout(() => controller.abort('Verification deadline reached'), remaining);
34
+ let lease;
35
+ let report;
36
+ let cleanupUnconfirmed = false;
37
+ try {
38
+ try {
39
+ lease = await acquireSharedWorker({ targetDir: directory, storyId: 'project-check', provider: 'codex', role: 'integration', resource: 'check', signal: controller.signal });
40
+ }
41
+ catch (error) {
42
+ if (!controller.signal.aborted)
43
+ throw error;
44
+ // Persist failed evidence even when cancellation happens while queued.
45
+ }
46
+ if (options.deadline !== undefined && Date.now() >= options.deadline)
47
+ controller.abort('Verification deadline reached');
48
+ report = await checkProjectAsyncAdmitted(directory, { ...options, signal: controller.signal, onCleanupUnconfirmed: confirmed => { cleanupUnconfirmed = true; if (confirmed)
49
+ void confirmed.then(() => lease?.release()).catch(() => { }); } });
50
+ return report;
51
+ }
52
+ finally {
53
+ if (timer)
54
+ clearTimeout(timer);
55
+ options.signal?.removeEventListener('abort', abort);
56
+ if (!cleanupUnconfirmed)
57
+ await lease?.release();
58
+ }
59
+ }
60
+ function verifyCommandAsync(command, root, signal) {
61
+ if (signal?.aborted)
62
+ return Promise.resolve({ passed: false, summary: 'Verification cancelled or deadline reached' });
63
+ return new Promise(resolveResult => {
64
+ const child = spawn(command, { cwd: root, shell: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
65
+ const record = child.pid ? createProviderProcessRecord(realpathSync(root), child.pid, 'verification', `unverified:${new Date().toISOString()}`) : undefined;
66
+ let publishFailed = false;
67
+ if (record) {
68
+ try {
69
+ filesystemProviderProcessRecordAdapter.publish(record);
70
+ }
71
+ catch {
72
+ publishFailed = true;
73
+ }
74
+ }
75
+ const cancelIdentity = trackProcessRecordIdentity(record, child);
76
+ let stdout = '', stderr = '', captured = 0, stopped = '', settled = false, exited = false, treeStopped = process.platform !== 'win32';
77
+ let resolveConfirmed = () => { };
78
+ const cleanupConfirmed = new Promise(resolveValue => { resolveConfirmed = resolveValue; });
79
+ const confirm = () => { if (exited && (!stopped || treeStopped)) {
80
+ cancelIdentity();
81
+ if (record)
82
+ filesystemProviderProcessRecordAdapter.remove(record.path);
83
+ resolveConfirmed();
84
+ } };
85
+ const finish = (passed, reason = '', cleanupUnconfirmed = false) => {
86
+ if (settled)
87
+ return;
88
+ settled = true;
89
+ clearTimeout(timer);
90
+ if (cleanupTimer)
91
+ clearTimeout(cleanupTimer);
92
+ signal?.removeEventListener('abort', abort);
93
+ cancelIdentity();
94
+ if (record && exited && !cleanupUnconfirmed)
95
+ filesystemProviderProcessRecordAdapter.remove(record.path);
96
+ if (passed) {
97
+ resolveResult({ passed: true, summary: `verify passed: ${command}` });
98
+ return;
99
+ }
100
+ const raw = `=== stdout ===\n${stdout}\n=== stderr ===\n${stderr}`;
101
+ const compacted = compactCommandOutput(raw, { previewBytes: DEFAULT_OUTPUT_POLICY.previewBytes });
102
+ const parts = [`verify failed: ${command}${reason ? ` (${reason})` : ''}`, compacted.preview];
103
+ if (compacted.originalBytes > DEFAULT_OUTPUT_POLICY.artifactThresholdBytes) {
104
+ try {
105
+ parts.push(writeOutputArtifact(root, raw, { phase: 'verify', storyId: process.env.YOKE_STORY }).marker);
106
+ }
107
+ catch {
108
+ parts.push('[artifact unavailable]');
109
+ }
110
+ }
111
+ resolveResult({ passed: false, summary: parts.filter(Boolean).join('\n'), ...(cleanupUnconfirmed ? { cleanupUnconfirmed: true, cleanupConfirmed } : {}) });
112
+ };
113
+ let cleanupTimer;
114
+ const stop = (reason) => {
115
+ if (stopped || settled)
116
+ return;
117
+ stopped = reason;
118
+ if (child.pid) {
119
+ if (process.platform === 'win32') {
120
+ const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
121
+ killer.on('error', () => { });
122
+ killer.on('close', code => { treeStopped = code === 0; confirm(); if (exited && treeStopped)
123
+ finish(false, stopped); });
124
+ killer.unref();
125
+ }
126
+ else {
127
+ try {
128
+ process.kill(-child.pid, 'SIGKILL');
129
+ }
130
+ catch {
131
+ child.kill('SIGKILL');
132
+ }
133
+ }
134
+ }
135
+ if (process.platform !== 'win32')
136
+ child.kill('SIGKILL');
137
+ cleanupTimer = setTimeout(() => { if (process.platform !== 'win32')
138
+ child.kill('SIGKILL'); finish(false, `${stopped}; process cleanup could not be confirmed`, !exited || !treeStopped); }, 4000);
139
+ };
140
+ const abort = () => stop('cancelled or deadline reached');
141
+ const timer = setTimeout(() => stop('timed out'), 600_000);
142
+ const append = (stream, data) => {
143
+ const room = Math.max(0, 16 * 1024 * 1024 - captured);
144
+ captured += data.length;
145
+ if (stream === 'stdout')
146
+ stdout += data.subarray(0, room).toString('utf8');
147
+ else
148
+ stderr += data.subarray(0, room).toString('utf8');
149
+ if (captured > 16 * 1024 * 1024)
150
+ stop('capture limit exceeded');
151
+ };
152
+ child.stdout.on('data', data => append('stdout', data));
153
+ child.stderr.on('data', data => append('stderr', data));
154
+ child.on('error', () => finish(false, 'command could not start'));
155
+ child.on('close', code => { exited = true; confirm(); if (!stopped || treeStopped)
156
+ finish(!stopped && code === 0, stopped); });
157
+ signal?.addEventListener('abort', abort, { once: true });
158
+ if (signal?.aborted)
159
+ abort();
160
+ if (publishFailed)
161
+ stop('process ownership record could not be published');
162
+ });
163
+ }
16
164
  export function loadAcceptance(root) {
17
165
  const file = statePath(root, 'acceptance.yaml');
18
166
  return existsSync(file) ? Acceptance.parse(parse(readFileSync(file, 'utf8'))) : null;
@@ -112,3 +260,73 @@ export function checkProject(directory, options = {}) {
112
260
  return report;
113
261
  }
114
262
  export function checkExitCode(report) { return report.status === 'passed' ? 0 : report.status === 'failed' ? 1 : 2; }
263
+ async function checkProjectAsyncAdmitted(directory, options) {
264
+ const root = realpathSync(directory);
265
+ const started = Date.now();
266
+ const before = workspaceFingerprint(root);
267
+ const problem = acceptanceProtectionProblem(root);
268
+ const criteria = [];
269
+ let cleanupUnconfirmed = false;
270
+ const rawExecute = options.execute ?? ((command, cwd) => verifyCommandAsync(command, cwd, options.signal));
271
+ const execute = async (command, cwd, signal) => {
272
+ if (cleanupUnconfirmed)
273
+ return { passed: false, summary: 'Previous verification cleanup unconfirmed; no further commands admitted', cleanupUnconfirmed: true };
274
+ const result = await rawExecute(command, cwd, signal);
275
+ if (result.cleanupUnconfirmed) {
276
+ cleanupUnconfirmed = true;
277
+ options.onCleanupUnconfirmed?.(result.cleanupConfirmed);
278
+ }
279
+ return result;
280
+ };
281
+ if (problem)
282
+ criteria.push({ id: 'protected-acceptance', text: 'Acceptance infrastructure unchanged', commands: [], status: 'failed', summary: problem });
283
+ else {
284
+ const manifest = loadAcceptance(root);
285
+ for (const criterion of manifest?.criteria ?? []) {
286
+ const results = [];
287
+ for (const command of criterion.commands) {
288
+ results.push(await (async () => {
289
+ try {
290
+ if (options.signal?.aborted)
291
+ throw new Error('Verification cancelled or deadline reached');
292
+ return await execute(command, root, options.signal);
293
+ }
294
+ catch (error) {
295
+ return { passed: false, summary: error.message };
296
+ }
297
+ })());
298
+ }
299
+ criteria.push({ ...criterion, status: results.length === 0 ? 'unverified' : results.every(r => r.passed) ? 'passed' : 'failed', summary: results.map(r => r.summary).join('\n') || 'No executable acceptance mapped' });
300
+ }
301
+ const command = resolveVerifyCommand(root, loadConfig(root) ?? defaultConfig('1.6.2'));
302
+ if (command) {
303
+ let result;
304
+ try {
305
+ if (options.signal?.aborted)
306
+ throw new Error('Verification cancelled or deadline reached');
307
+ result = await execute(command, root, options.signal);
308
+ }
309
+ catch (error) {
310
+ result = { passed: false, summary: error.message };
311
+ }
312
+ criteria.push({ id: 'project-suite', text: 'Configured project verification', commands: [command], status: result.passed ? 'passed' : 'failed', summary: result.summary });
313
+ }
314
+ if (options.requirement)
315
+ criteria.push({ id: 'requested-outcome', text: options.requirement, commands: [], status: 'unverified', summary: 'Map this outcome to executable criteria in .yoke/acceptance.yaml; a green suite alone is not proof of this requirement.' });
316
+ if (criteria.length === 0)
317
+ criteria.push({ id: 'acceptance', text: 'Project acceptance', commands: [], status: 'unverified', summary: 'No acceptance manifest or project verification command found' });
318
+ }
319
+ const changed = workspaceFingerprint(root) !== before;
320
+ const afterProblem = acceptanceProtectionProblem(root);
321
+ if (changed || afterProblem)
322
+ criteria.push({ id: 'source-integrity', text: 'Checked source remained stable', commands: [], status: 'failed', summary: afterProblem ?? 'Source changed during verification; run check again on a stable tree' });
323
+ if (options.signal?.aborted)
324
+ criteria.push({ id: 'verification-cancelled', text: 'Verification completed within its allowed run', commands: [], status: 'failed', summary: 'Verification cancelled or deadline reached' });
325
+ const status = criteria.some(c => c.status === 'failed') ? 'failed' : criteria.some(c => c.status === 'unverified') ? 'unverified' : 'passed';
326
+ const id = randomUUID();
327
+ const evidencePath = statePath(root, 'checks', `${id}.json`);
328
+ const report = { ...(cleanupUnconfirmed ? { cleanupUnconfirmed: true } : {}), version: 1, id, generatedAt: new Date().toISOString(), fingerprint: before, status, summary: changed ? 'Source changed during verification' : `${criteria.filter(c => c.status === 'passed').length}/${criteria.length} checks passed; ${status}`, criteria, durationMs: Date.now() - started, evidencePath };
329
+ mkdirSync(dirname(evidencePath), { recursive: true });
330
+ writeFileSync(evidencePath, JSON.stringify(report, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
331
+ return report;
332
+ }
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { realpathSync } from 'node:fs';
5
5
  import { checkProject, checkExitCode, protectAcceptance } from './check/command.js';
6
6
  import { registerProject, listProjects, unregisterProject } from './dashboard/registry.js';
7
7
  import { startDashboard } from './dashboard/server.js';
8
- import { createProjectGoal, readProjectGoal, runProjectGoal, pauseProjectGoal, goalHandoff, budgetProjectGoal } from './goals/command.js';
8
+ import { createProjectGoal, readProjectGoal, runProjectGoal, pauseProjectGoal, goalHandoff, budgetProjectGoal, bindProjectGoal } from './goals/command.js';
9
9
  import { validateCanon } from './canon/validate.js';
10
10
  import { runRetrofit } from './retrofit/command.js';
11
11
  import { setLoopEnabled, loopStatus, runLoopCommand } from './loop/run-command.js';
@@ -250,7 +250,7 @@ export function main(argv) {
250
250
  const objective = value('objective');
251
251
  if (!objective)
252
252
  throw new Error('Use goal set [dir] --objective="..." with executable .yoke/acceptance.yaml criteria');
253
- const goal = createProjectGoal(targetDir, objective, { maxAttempts: value('attempts') ? Number(value('attempts')) : undefined, maxMinutes: value('minutes') ? Number(value('minutes')) : undefined, tokenBudget: value('tokens') ? Number(value('tokens')) : undefined });
253
+ const goal = createProjectGoal(targetDir, objective, { maxAttempts: value('attempts') ? Number(value('attempts')) : undefined, maxMinutes: value('minutes') ? Number(value('minutes')) : undefined, maxWallMinutes: value('wall-minutes') ? Number(value('wall-minutes')) : undefined, tokenBudget: value('tokens') ? Number(value('tokens')) : undefined, acceptanceIds: value('criteria')?.split(',') });
254
254
  console.log(JSON.stringify(goal));
255
255
  return 0;
256
256
  }
@@ -258,8 +258,12 @@ export function main(argv) {
258
258
  console.log(JSON.stringify(readProjectGoal(targetDir)));
259
259
  return 0;
260
260
  }
261
+ if (sub === 'bind') {
262
+ console.log(JSON.stringify(bindProjectGoal(targetDir, value('criteria')?.split(',') ?? [])));
263
+ return 0;
264
+ }
261
265
  if (sub === 'budget') {
262
- console.log(JSON.stringify(budgetProjectGoal(targetDir, { maxAttempts: value('attempts') ? Number(value('attempts')) : undefined, maxMinutes: value('minutes') ? Number(value('minutes')) : undefined, tokenBudget: value('tokens') ? Number(value('tokens')) : undefined, clearTokenBudget: rest.includes('--clear-token-budget') })));
266
+ console.log(JSON.stringify(budgetProjectGoal(targetDir, { maxAttempts: value('attempts') ? Number(value('attempts')) : undefined, maxMinutes: value('minutes') ? Number(value('minutes')) : undefined, maxWallMinutes: value('wall-minutes') ? Number(value('wall-minutes')) : undefined, tokenBudget: value('tokens') ? Number(value('tokens')) : undefined, clearTokenBudget: rest.includes('--clear-token-budget') })));
263
267
  return 0;
264
268
  }
265
269
  if (sub === 'handoff') {
@@ -272,15 +276,15 @@ export function main(argv) {
272
276
  return 0;
273
277
  }
274
278
  if (sub === 'run' || sub === 'resume') {
275
- const provider = value('runner') ?? 'codex';
276
- if (!SUPPORTED_AGENTS.includes(provider))
279
+ const provider = value('runner');
280
+ if (provider && !SUPPORTED_AGENTS.includes(provider))
277
281
  throw new Error('Unknown runner');
278
- return runProjectGoal(targetDir, { provider: provider, selection: { model: value('model') } }).then(goal => {
282
+ return runProjectGoal(targetDir, { provider: provider, selection: { model: value('model'), reasoningEffort: value('effort'), bare: rest.includes('--bare') ? true : undefined }, native: rest.includes('--native-goal') ? true : rest.includes('--no-native-goal') ? false : undefined }).then(goal => {
279
283
  console.log(JSON.stringify(goal));
280
284
  return goal.status === 'complete' ? 0 : 1;
281
285
  }).catch(error => { console.error(`Goal: ${error.message}`); return 2; });
282
286
  }
283
- throw new Error('Use goal set|status|run|resume|pause|handoff');
287
+ throw new Error('Use goal set|bind|status|run|resume|pause|handoff|budget');
284
288
  }
285
289
  catch (error) {
286
290
  console.error(`Goal: ${error.message}`);
@@ -10,7 +10,8 @@ import { dashboardPage } from './page.js';
10
10
  import { readEvents } from '../observability/events.js';
11
11
  import { estimateSchedule } from '../estimation/schedule.js';
12
12
  import { pauseProjectGoal, runProjectGoal } from '../goals/command.js';
13
- import { runLoopCommand } from '../loop/run-command.js';
13
+ import { runLoopCommand, resumeLoopCommand } from '../loop/run-command.js';
14
+ import { readRunState } from '../loop/run-state.js';
14
15
  import { isPidAlive, readLock } from '../loop/lock.js';
15
16
  import { appendEvent } from '../observability/events.js';
16
17
  import { queueChange } from '../change/inbox.js';
@@ -331,9 +332,11 @@ export async function startDashboard(options = {}) {
331
332
  send(409, { error: project.error });
332
333
  return;
333
334
  }
334
- if (current.goal)
335
+ const saved = readRunState(project.root);
336
+ if (current.goal && (!saved || saved.mode === 'goal'))
335
337
  pauseProjectGoal(project.root);
336
- writePauseSignal(project.root);
338
+ if (!saved || saved.mode !== 'goal')
339
+ writePauseSignal(project.root);
337
340
  const response = { status: 'pause-requested' };
338
341
  send(200, response);
339
342
  return;
@@ -355,13 +358,30 @@ export async function startDashboard(options = {}) {
355
358
  send(409, { status: 'already-running', error: 'Project is already running' });
356
359
  return;
357
360
  }
358
- const goal = current.goal !== null && current.goal.status !== 'complete';
361
+ let saved;
362
+ try {
363
+ saved = readRunState(project.root);
364
+ }
365
+ catch {
366
+ send(409, { error: 'Saved execution state is invalid; start a fresh run from the CLI' });
367
+ return;
368
+ }
369
+ const goal = saved ? saved.mode === 'goal' : current.goal !== null && current.goal.status !== 'complete';
370
+ if (saved?.mode === 'goal') {
371
+ const rawGoal = safeFile(project.root, '.yoke/goal.json');
372
+ if (!rawGoal || JSON.parse(rawGoal).id !== saved.goalId) {
373
+ send(409, { error: 'Saved goal identity no longer matches this project' });
374
+ return;
375
+ }
376
+ }
359
377
  activeResumes.add(project.root);
360
378
  void Promise.resolve().then(async () => {
361
379
  if (goal)
362
- await runProjectGoal(project.root);
380
+ await runProjectGoal(project.root, saved?.mode === 'goal' ? { ...saved.options, provider: saved.options.agent, expectedGoalId: saved.goalId } : {});
381
+ else if (saved)
382
+ await resumeLoopCommand(project.root);
363
383
  else
364
- await runLoopCommand(project.root, {});
384
+ await runLoopCommand(project.root, { permissions: 'safe' });
365
385
  }).catch(() => undefined).finally(() => { activeResumes.delete(project.root); });
366
386
  const response = { status: 'resume-requested' };
367
387
  send(202, response);