@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
@@ -0,0 +1,340 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { providerSpawnOptions } from '../agents/process.js';
4
+ import { createProviderProcessRecord, filesystemProviderProcessRecordAdapter } from '../agents/process-record.js';
5
+ import { trackProcessRecordIdentity } from '../agents/process-record-identity.js';
6
+ import { resolve } from 'node:path';
7
+ import { windowsShellEnvironment } from '../agents/windows-launch.js';
8
+ export class NativeGoalUnavailableError extends Error {
9
+ constructor() { super('Codex native goal methods are unavailable'); this.name = 'NativeGoalUnavailableError'; }
10
+ }
11
+ export class NativeGoalCleanupError extends Error {
12
+ cleanupConfirmed;
13
+ cleanupUnconfirmed = true;
14
+ constructor(cleanupConfirmed) {
15
+ super('Codex native process cleanup could not be confirmed; retain its provider permit');
16
+ this.cleanupConfirmed = cleanupConfirmed;
17
+ this.name = 'NativeGoalCleanupError';
18
+ }
19
+ }
20
+ export const objectiveRevision = (objective) => createHash('sha256').update(objective).digest('hex');
21
+ function validateBinding(binding, objective, selection) {
22
+ if (binding && (binding.provider !== 'codex' || binding.objectiveRevision !== objectiveRevision(objective)))
23
+ throw new Error('Native goal binding objective or provider mismatch');
24
+ if (binding && selection && (binding.model !== selection.model || binding.modelProvider !== selection.provider))
25
+ throw new Error('Native goal binding model or model provider mismatch');
26
+ if (binding?.usageBaseline && !parseUsage(binding.usageBaseline))
27
+ throw new Error('Malformed native usage baseline');
28
+ if (binding?.usageInvalid !== undefined && binding.usageInvalid !== true)
29
+ throw new Error('Malformed native usage trust state');
30
+ }
31
+ const usageFields = ['inputTokens', 'outputTokens', 'cachedInputTokens', 'cacheWriteInputTokens', 'reasoningOutputTokens'];
32
+ function parseUsage(value) {
33
+ if (!value || typeof value !== 'object' || Array.isArray(value))
34
+ return undefined;
35
+ if (!Number.isSafeInteger(value.inputTokens) || value.inputTokens < 0 || !Number.isSafeInteger(value.outputTokens) || value.outputTokens < 0)
36
+ return undefined;
37
+ const result = { inputTokens: value.inputTokens, outputTokens: value.outputTokens };
38
+ for (const field of usageFields.slice(2)) {
39
+ if (value[field] !== undefined) {
40
+ if (!Number.isSafeInteger(value[field]) || value[field] < 0)
41
+ return undefined;
42
+ result[field] = value[field];
43
+ }
44
+ }
45
+ return result;
46
+ }
47
+ function nondecreasing(current, previous) {
48
+ return usageFields.every(field => previous[field] === undefined || ((current[field] ?? 0) >= previous[field]));
49
+ }
50
+ function usageDelta(current, previous) {
51
+ const result = { inputTokens: current.inputTokens - previous.inputTokens, outputTokens: current.outputTokens - previous.outputTokens };
52
+ for (const field of usageFields.slice(2))
53
+ if (current[field] !== undefined)
54
+ result[field] = current[field] - (previous[field] ?? 0);
55
+ return result;
56
+ }
57
+ async function goalRequest(transport, method, params) {
58
+ try {
59
+ return await transport.request(method, params);
60
+ }
61
+ catch (error) {
62
+ if (error.code === -32601)
63
+ throw new NativeGoalUnavailableError();
64
+ throw error;
65
+ }
66
+ }
67
+ /** A private stdio app-server. No protocol payloads or stderr are copied into logs. */
68
+ export function createNativeGoalTransport(root, timeoutMs = 30_000, spawnChild = spawn, config = {}) {
69
+ if (config.bare)
70
+ throw new Error('Native Codex goals do not support bare startup: app-server cannot ignore user config; use Codex exec for bare mode');
71
+ const shellEnvironment = process.platform === 'win32' ? windowsShellEnvironment() : { env: process.env };
72
+ const options = providerSpawnOptions({ command: 'codex', args: ['app-server', '--stdio', '--enable', 'goals', '-c', 'features.multi_agent=false', ...(process.platform === 'win32' ? ['-c', `shell_environment_policy.set.PATH=${JSON.stringify(shellEnvironment.env.PATH)}`] : [])], cwd: root, input: '' });
73
+ const child = spawnChild(options.command, [...options.args], { cwd: root, shell: false, detached: options.detached, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, env: shellEnvironment.env });
74
+ const record = child.pid ? createProviderProcessRecord(resolve(root), child.pid, 'native-goal', `unverified:${new Date().toISOString()}`) : undefined;
75
+ if (record) {
76
+ try {
77
+ filesystemProviderProcessRecordAdapter.publish(record);
78
+ }
79
+ catch (error) {
80
+ child.kill('SIGKILL');
81
+ throw error;
82
+ }
83
+ }
84
+ const cancelIdentity = trackProcessRecordIdentity(record, child);
85
+ let next = 1, buffer = '', closed = false, exited = false, treeStopped = process.platform !== 'win32';
86
+ let confirmCleanup = () => { };
87
+ let resolveConfirmed = () => { };
88
+ const cleanupConfirmed = new Promise(resolveValue => { resolveConfirmed = resolveValue; });
89
+ let listener = () => { };
90
+ const pending = new Map();
91
+ const fail = (error) => { for (const item of pending.values()) {
92
+ clearTimeout(item.timer);
93
+ item.reject(error);
94
+ } ; pending.clear(); listener('yoke/transportFailure', {}); };
95
+ const write = (value) => { if (closed)
96
+ throw new Error('Codex native transport closed'); child.stdin.write(JSON.stringify(value) + '\n'); };
97
+ child.stdout.setEncoding('utf8');
98
+ child.stdout.on('data', (chunk) => {
99
+ buffer += chunk;
100
+ if (Buffer.byteLength(buffer) > 1_048_576) {
101
+ fail(new Error('Codex native protocol output exceeded limit'));
102
+ void close().catch(() => { });
103
+ return;
104
+ }
105
+ let index;
106
+ while ((index = buffer.indexOf('\n')) >= 0) {
107
+ const line = buffer.slice(0, index);
108
+ buffer = buffer.slice(index + 1);
109
+ if (!line.trim())
110
+ continue;
111
+ try {
112
+ const message = JSON.parse(line);
113
+ if (!message || typeof message !== 'object')
114
+ throw new Error('Malformed Codex native protocol reply');
115
+ if (message.method && message.id !== undefined) {
116
+ write({ id: message.id, error: { code: -32601, message: 'Yoke does not permit interactive server requests' } });
117
+ continue;
118
+ }
119
+ if (typeof message.method === 'string') {
120
+ listener(message.method, message.params);
121
+ continue;
122
+ }
123
+ const item = pending.get(message.id);
124
+ if (!item)
125
+ continue;
126
+ pending.delete(message.id);
127
+ clearTimeout(item.timer);
128
+ if (message.error)
129
+ item.reject(Object.assign(new Error('Codex native RPC failed'), { code: message.error.code }));
130
+ else if ('result' in message)
131
+ item.resolve(message.result);
132
+ else
133
+ item.reject(new Error('Malformed Codex native protocol reply'));
134
+ }
135
+ catch {
136
+ fail(new Error('Malformed Codex native protocol reply'));
137
+ void close().catch(() => { });
138
+ return;
139
+ }
140
+ }
141
+ });
142
+ child.stderr.resume();
143
+ child.on('error', () => fail(new Error('Codex native app-server could not start')));
144
+ child.on('close', () => { exited = true; cancelIdentity(); if (!closed || treeStopped) {
145
+ if (record)
146
+ filesystemProviderProcessRecordAdapter.remove(record.path);
147
+ resolveConfirmed();
148
+ } ; confirmCleanup(); fail(new Error('Codex native app-server exited')); });
149
+ child.stdin.on('error', () => fail(new Error('Codex native app-server input closed')));
150
+ let cleanup;
151
+ function close() {
152
+ if (cleanup)
153
+ return cleanup;
154
+ cleanup = exited || !child.pid ? Promise.resolve() : new Promise((resolveCleanup, rejectCleanup) => { const bound = setTimeout(() => rejectCleanup(new NativeGoalCleanupError(cleanupConfirmed)), process.platform === 'win32' ? 15000 : 5000); confirmCleanup = () => { if (exited && treeStopped) {
155
+ clearTimeout(bound);
156
+ if (record)
157
+ filesystemProviderProcessRecordAdapter.remove(record.path);
158
+ resolveConfirmed();
159
+ resolveCleanup();
160
+ } }; });
161
+ closed = true;
162
+ fail(new Error('Codex native transport closed'));
163
+ // Terminate the exact owned process and its descendants, without synchronous waits.
164
+ if (child.pid && child.exitCode === null) {
165
+ if (process.platform === 'win32') {
166
+ const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
167
+ killer.on('error', () => { });
168
+ killer.on('close', code => { treeStopped = code === 0; confirmCleanup(); });
169
+ killer.unref();
170
+ }
171
+ else {
172
+ try {
173
+ process.kill(-child.pid, 'SIGKILL');
174
+ }
175
+ catch {
176
+ child.kill('SIGKILL');
177
+ }
178
+ }
179
+ if (process.platform !== 'win32')
180
+ child.kill('SIGKILL');
181
+ }
182
+ if (!child.pid || exited)
183
+ child.stdin.end();
184
+ return cleanup;
185
+ }
186
+ return {
187
+ request(method, params) { return new Promise((resolve, reject) => { const id = next++; const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Codex native RPC timeout: ${method}`)); }, timeoutMs); pending.set(id, { resolve, reject, timer }); try {
188
+ write({ id, method, params });
189
+ }
190
+ catch (error) {
191
+ pending.delete(id);
192
+ clearTimeout(timer);
193
+ reject(error);
194
+ } }); },
195
+ notify(method, params) { write({ method, params }); }, onNotification(fn) { listener = fn; }, close,
196
+ };
197
+ }
198
+ async function initialize(transport) {
199
+ await transport.request('initialize', { clientInfo: { name: 'yoke', version: '1' }, capabilities: { experimentalApi: true } });
200
+ transport.notify('initialized');
201
+ }
202
+ function nativeGoal(response, objective) {
203
+ const goal = response?.goal;
204
+ if (!goal || goal.objective !== objective || !['active', 'paused', 'blocked', 'usageLimited', 'budgetLimited', 'complete'].includes(goal.status))
205
+ throw new Error('Native goal objective or status mismatch');
206
+ return goal;
207
+ }
208
+ export async function executeCodexGoal(input) {
209
+ if (input.selection.bare)
210
+ throw new Error('Native Codex goals do not support bare startup: app-server cannot ignore user config; use Codex exec for bare mode');
211
+ validateBinding(input.binding, input.objective, input.selection);
212
+ if (input.signal.aborted)
213
+ throw new Error('Native goal execution cancelled');
214
+ if (input.tokenBudget !== undefined && (!Number.isSafeInteger(input.tokenBudget) || input.tokenBudget <= 0))
215
+ throw new Error('Native goal remaining token budget must be positive');
216
+ const transport = input.transport ?? createNativeGoalTransport(input.root, undefined, undefined, { bare: input.selection.bare });
217
+ let threadId = input.binding?.threadId, turnId, usage;
218
+ const baseline = input.binding ? input.binding.usageBaseline : { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, cacheWriteInputTokens: 0, reasoningOutputTokens: 0 };
219
+ const completedTurns = new Map(), turnUsage = new Map();
220
+ let lastUsageUpdate;
221
+ const publishUsageUpdate = () => {
222
+ if (!turnId || !baseline || input.signal.aborted)
223
+ return;
224
+ const measured = turnUsage.get(turnId);
225
+ if (!measured?.total || measured.invalid || !nondecreasing(measured.total, baseline))
226
+ return;
227
+ const delta = usageDelta(measured.total, baseline), snapshot = JSON.stringify(delta);
228
+ if (snapshot === lastUsageUpdate)
229
+ return;
230
+ lastUsageUpdate = snapshot;
231
+ input.onUsageUpdate?.(delta);
232
+ };
233
+ let resolveTurn = () => { }, rejectTurn = () => { };
234
+ const completion = new Promise((resolve, reject) => { resolveTurn = resolve; rejectTurn = reject; });
235
+ completion.catch(() => { });
236
+ const backgroundClose = () => { void Promise.resolve(transport.close()).catch(() => { }); };
237
+ const onAbort = () => { if (threadId && turnId)
238
+ void transport.request('turn/interrupt', { threadId, turnId }).catch(() => { }); rejectTurn(new Error('Native goal execution cancelled')); backgroundClose(); };
239
+ const timer = setTimeout(() => { rejectTurn(new Error('Native goal turn timeout')); backgroundClose(); }, input.timeoutMs ?? 20 * 60_000);
240
+ input.signal.addEventListener('abort', onAbort, { once: true });
241
+ transport.onNotification((method, params) => {
242
+ if (method === 'yoke/transportFailure') {
243
+ rejectTurn(new Error('Codex native transport failed'));
244
+ return;
245
+ }
246
+ if (params?.threadId !== threadId)
247
+ return;
248
+ if (method === 'thread/tokenUsage/updated') {
249
+ if (turnId && params.turnId !== turnId)
250
+ return;
251
+ if (typeof params.turnId !== 'string')
252
+ return;
253
+ const total = parseUsage(params.tokenUsage?.total), prior = turnUsage.get(params.turnId);
254
+ const decreased = Boolean(prior?.decreased || (total && baseline && !nondecreasing(total, baseline)) || (total && prior?.total && !nondecreasing(total, prior.total)));
255
+ const invalid = Boolean(prior?.invalid || !total || decreased || input.binding?.usageInvalid);
256
+ turnUsage.set(params.turnId, { total, invalid, decreased });
257
+ if (turnUsage.size > 32)
258
+ turnUsage.delete(turnUsage.keys().next().value);
259
+ publishUsageUpdate();
260
+ }
261
+ if (method === 'turn/completed' && typeof params.turn?.id === 'string') {
262
+ completedTurns.set(params.turn.id, params.turn);
263
+ if (turnId && params.turn.id === turnId)
264
+ resolveTurn(params.turn);
265
+ if (completedTurns.size > 32)
266
+ completedTurns.delete(completedTurns.keys().next().value);
267
+ }
268
+ });
269
+ try {
270
+ await initialize(transport);
271
+ const started = await transport.request(input.binding ? 'thread/resume' : 'thread/start', { ...(threadId ? { threadId } : {}), cwd: input.root, model: input.selection.model, modelProvider: input.selection.provider, approvalPolicy: 'never', sandbox: 'workspace-write', config: { 'features.multi_agent': false } });
272
+ if (typeof started?.thread?.id !== 'string' || (threadId && started.thread.id !== threadId))
273
+ throw new Error('Malformed native thread binding');
274
+ threadId = started.thread.id;
275
+ const binding = { provider: 'codex', threadId: threadId, objectiveRevision: objectiveRevision(input.objective), model: input.selection.model, modelProvider: input.selection.provider, ...(baseline ? { usageBaseline: baseline } : {}), ...(input.binding?.usageInvalid ? { usageInvalid: true } : {}) };
276
+ let used = 0;
277
+ if (input.binding) {
278
+ const goal = nativeGoal(await goalRequest(transport, 'thread/goal/get', { threadId }), input.objective);
279
+ if (!Number.isSafeInteger(goal.tokensUsed) || goal.tokensUsed < 0)
280
+ throw new Error('Malformed native goal usage');
281
+ used = goal.tokensUsed;
282
+ }
283
+ const budget = input.tokenBudget === undefined ? null : used + input.tokenBudget;
284
+ if (budget !== null && !Number.isSafeInteger(budget))
285
+ throw new Error('Native goal token budget overflow');
286
+ await goalRequest(transport, 'thread/goal/set', { threadId, objective: input.objective, tokenBudget: budget, status: 'paused' });
287
+ await input.onBinding?.(binding);
288
+ const turn = await transport.request('turn/start', { threadId, input: [{ type: 'text', text: input.prompt }], ...(input.selection.reasoningEffort ? { effort: input.selection.reasoningEffort } : {}) });
289
+ if (typeof turn?.turn?.id !== 'string')
290
+ throw new Error('Malformed native turn reply');
291
+ turnId = turn.turn.id;
292
+ publishUsageUpdate();
293
+ if (input.signal.aborted)
294
+ throw new Error('Native goal execution cancelled');
295
+ const result = completedTurns.get(turnId) ?? await completion;
296
+ if (result?.id !== turnId || !['completed', 'failed', 'interrupted'].includes(result.status))
297
+ throw new Error('Malformed native turn completion');
298
+ const observed = nativeGoal(await goalRequest(transport, 'thread/goal/get', { threadId }), input.objective);
299
+ await goalRequest(transport, 'thread/goal/set', { threadId, status: 'paused' });
300
+ const measured = turnUsage.get(turnId);
301
+ if (measured?.total && !measured.invalid && (!baseline || nondecreasing(measured.total, baseline))) {
302
+ if (baseline)
303
+ usage = usageDelta(measured.total, baseline);
304
+ binding.usageBaseline = measured.total;
305
+ await input.onBinding?.(binding);
306
+ }
307
+ if (measured?.decreased) {
308
+ binding.usageInvalid = true;
309
+ await input.onBinding?.(binding);
310
+ }
311
+ if (usage)
312
+ input.onUsage?.(usage);
313
+ return { success: result.status === 'completed', summary: result.status === 'completed' ? 'Native Codex turn finished; requires independent Yoke check' : `Native Codex turn ${result.status}`, tokens: usage, ...usage, provider: 'codex', nativeBinding: binding, nativeStatus: 'paused', nativeObservedStatus: observed.status, ...(Number.isSafeInteger(observed.tokensUsed) && observed.tokensUsed >= 0 ? { nativeTokensUsed: observed.tokensUsed } : {}) };
314
+ }
315
+ finally {
316
+ clearTimeout(timer);
317
+ input.signal.removeEventListener('abort', onAbort);
318
+ await transport.close();
319
+ }
320
+ }
321
+ export async function synchronizeNativeGoal(input) {
322
+ if (input.bare)
323
+ throw new Error('Native Codex goals do not support bare startup: app-server cannot ignore user config; use Codex exec for bare mode');
324
+ validateBinding(input.binding, input.objective);
325
+ if (input.signal?.aborted)
326
+ throw new Error('Native goal synchronization cancelled');
327
+ const transport = input.transport ?? createNativeGoalTransport(input.root, undefined, undefined, { bare: input.bare });
328
+ const abort = () => { void Promise.resolve(transport.close()).catch(() => { }); };
329
+ input.signal?.addEventListener('abort', abort, { once: true });
330
+ try {
331
+ await initialize(transport);
332
+ await transport.request('thread/resume', { threadId: input.binding.threadId, cwd: input.root });
333
+ nativeGoal(await goalRequest(transport, 'thread/goal/get', { threadId: input.binding.threadId }), input.objective);
334
+ await goalRequest(transport, 'thread/goal/set', { threadId: input.binding.threadId, status: input.status, ...(input.tokenBudget === undefined ? {} : { tokenBudget: input.tokenBudget }) });
335
+ }
336
+ finally {
337
+ input.signal?.removeEventListener('abort', abort);
338
+ await transport.close();
339
+ }
340
+ }