@hecer/yoke 1.3.0 → 1.5.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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +108 -16
- package/TODOS.md +0 -3
- package/bench/README.md +55 -46
- package/bench/output-compaction.mjs +65 -0
- package/canon/loop/loop-spec.md +22 -8
- package/canon/manifest.yaml +1 -1
- package/dist/agents/contracts.js +50 -0
- package/dist/agents/process-incarnation.js +15 -0
- package/dist/agents/process-record.js +65 -0
- package/dist/agents/process-streams.js +40 -0
- package/dist/agents/process.js +177 -0
- package/dist/agents/providers.js +10 -7
- package/dist/agents/telemetry.js +62 -0
- package/dist/audit/command.js +13 -5
- package/dist/cli.js +55 -3
- package/dist/loop/candidate-boundaries.js +43 -0
- package/dist/loop/candidate-cleanup.js +98 -0
- package/dist/loop/candidate-contracts.js +1 -0
- package/dist/loop/candidate-selection.js +84 -0
- package/dist/loop/candidates.js +228 -0
- package/dist/loop/claim-lease.js +131 -0
- package/dist/loop/claims.js +177 -40
- package/dist/loop/cleanup.js +117 -15
- package/dist/loop/decision.js +31 -0
- package/dist/loop/dispatcher.js +334 -0
- package/dist/loop/git.js +6 -4
- package/dist/loop/loop.js +109 -16
- package/dist/loop/merge-queue.js +12 -6
- package/dist/loop/parallel-adapters.js +185 -0
- package/dist/loop/parallel-command.js +287 -0
- package/dist/loop/parallel.js +2 -4
- package/dist/loop/prd.js +4 -1
- package/dist/loop/reporter.js +86 -5
- package/dist/loop/run-command.js +216 -58
- package/dist/loop/runner.js +67 -32
- package/dist/loop/verify.js +65 -11
- package/dist/loop/watchdog.js +67 -8
- package/dist/loop/worker-cancellation.js +17 -0
- package/dist/loop/worker-cleanup.js +23 -0
- package/dist/loop/worker-contracts.js +1 -0
- package/dist/loop/worker.js +254 -0
- package/dist/output/artifact.js +63 -0
- package/dist/output/compact.js +192 -0
- package/dist/output/types.js +4 -0
- package/dist/quality/artifacts.js +59 -0
- package/dist/quality/candidate-comparison.js +130 -0
- package/dist/quality/command.js +316 -0
- package/dist/quality/loop.js +86 -0
- package/dist/quality/process-command.js +57 -0
- package/dist/quality/reference.js +187 -0
- package/dist/quality/repair.js +11 -0
- package/dist/quality/runner.js +66 -0
- package/dist/quality/types.js +60 -0
- package/dist/quality/verdict.js +142 -0
- package/dist/retrofit/config.js +26 -2
- package/dist/retrofit/gitignore.js +4 -0
- package/dist/review/command.js +27 -38
- package/dist/review/verdict.js +38 -7
- package/docs/MIGRATING-TO-1.4.md +70 -0
- package/docs/PUBLISHING.md +16 -2
- package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -0
- package/docs/superpowers/plans/2026-08-16-artifact-backed-output-compaction.md +329 -0
- package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -0
- package/docs/superpowers/specs/2026-08-16-artifact-backed-output-compaction-design.md +181 -0
- package/gemini-extension.json +1 -1
- package/package.json +4 -3
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { linkSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
export function createProviderProcessRecord(targetDir, childPid, workerId, startedAt = new Date().toISOString()) {
|
|
5
|
+
const worker = workerId?.replace(/[^A-Za-z0-9_-]/gu, '_') || 'call';
|
|
6
|
+
return {
|
|
7
|
+
path: join(targetDir, '.yoke', 'provider-processes', `${worker}-${randomUUID()}.json`),
|
|
8
|
+
version: 1,
|
|
9
|
+
owner: 'provider-process',
|
|
10
|
+
targetDir,
|
|
11
|
+
childPid,
|
|
12
|
+
startedAt,
|
|
13
|
+
...(workerId ? { workerId } : {}),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export const filesystemProviderProcessRecordAdapter = {
|
|
17
|
+
publish(record) {
|
|
18
|
+
const directory = join(record.targetDir, '.yoke', 'provider-processes');
|
|
19
|
+
mkdirSync(directory, { recursive: true });
|
|
20
|
+
const temporary = `${record.path}.${randomUUID()}.tmp`;
|
|
21
|
+
writeFileSync(temporary, JSON.stringify({
|
|
22
|
+
version: record.version,
|
|
23
|
+
owner: record.owner,
|
|
24
|
+
targetDir: record.targetDir,
|
|
25
|
+
childPid: record.childPid,
|
|
26
|
+
startedAt: record.startedAt,
|
|
27
|
+
...(record.workerId ? { workerId: record.workerId } : {}),
|
|
28
|
+
}), { flag: 'wx' });
|
|
29
|
+
try {
|
|
30
|
+
linkSync(temporary, record.path);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
rmSync(temporary, { force: true });
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
remove(path) {
|
|
37
|
+
rmSync(path, { force: true });
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
function isRecord(value) {
|
|
41
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
export function parseProviderProcessRecord(value) {
|
|
44
|
+
if (!isRecord(value))
|
|
45
|
+
return null;
|
|
46
|
+
const workerId = value.workerId;
|
|
47
|
+
if (value.version !== 1 ||
|
|
48
|
+
value.owner !== 'provider-process' ||
|
|
49
|
+
typeof value.targetDir !== 'string' ||
|
|
50
|
+
typeof value.childPid !== 'number' ||
|
|
51
|
+
!Number.isInteger(value.childPid) ||
|
|
52
|
+
value.childPid <= 0 ||
|
|
53
|
+
typeof value.startedAt !== 'string' ||
|
|
54
|
+
value.startedAt.length === 0 ||
|
|
55
|
+
(workerId !== undefined && typeof workerId !== 'string'))
|
|
56
|
+
return null;
|
|
57
|
+
return {
|
|
58
|
+
version: 1,
|
|
59
|
+
owner: 'provider-process',
|
|
60
|
+
targetDir: value.targetDir,
|
|
61
|
+
childPid: value.childPid,
|
|
62
|
+
startedAt: value.startedAt,
|
|
63
|
+
...(typeof workerId === 'string' ? { workerId } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { parseProviderTelemetry } from './telemetry.js';
|
|
2
|
+
export function createBoundedOutput(limitBytes) {
|
|
3
|
+
let text = '';
|
|
4
|
+
let truncated = false;
|
|
5
|
+
return {
|
|
6
|
+
append(next) {
|
|
7
|
+
const combined = `${text}${next}`;
|
|
8
|
+
if (Buffer.byteLength(combined) <= limitBytes) {
|
|
9
|
+
text = combined;
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
text = Buffer.from(combined).subarray(-limitBytes).toString('utf8');
|
|
13
|
+
truncated = true;
|
|
14
|
+
},
|
|
15
|
+
get text() { return text; },
|
|
16
|
+
get truncated() { return truncated; },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function createTelemetryAccumulator(agent) {
|
|
20
|
+
let trailing = '';
|
|
21
|
+
let telemetry = { usageAvailable: false };
|
|
22
|
+
const update = (lines) => {
|
|
23
|
+
const next = parseProviderTelemetry(agent, [...lines]);
|
|
24
|
+
if (next.usageAvailable)
|
|
25
|
+
telemetry = next;
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
append(text) {
|
|
29
|
+
const parts = `${trailing}${text}`.split(/\r?\n/u);
|
|
30
|
+
trailing = parts.pop() ?? '';
|
|
31
|
+
update(parts);
|
|
32
|
+
},
|
|
33
|
+
finish() {
|
|
34
|
+
if (trailing)
|
|
35
|
+
update([trailing]);
|
|
36
|
+
trailing = '';
|
|
37
|
+
return telemetry;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { killProcessTreeForCleanup } from '../loop/watchdog.js';
|
|
4
|
+
import { createProviderProcessRecord, filesystemProviderProcessRecordAdapter, } from './process-record.js';
|
|
5
|
+
import { createBoundedOutput, createTelemetryAccumulator } from './process-streams.js';
|
|
6
|
+
import { processIncarnation } from './process-incarnation.js';
|
|
7
|
+
function cancellationReason(signal) {
|
|
8
|
+
return typeof signal.reason === 'string' && signal.reason.length > 0
|
|
9
|
+
? signal.reason
|
|
10
|
+
: 'provider process cancellation requested';
|
|
11
|
+
}
|
|
12
|
+
export function providerSpawnOptions(invocation, platform = process.platform) {
|
|
13
|
+
const windowsCommandShim = !/[\\/]/u.test(invocation.command) || /\.(?:bat|cmd)$/iu.test(invocation.command);
|
|
14
|
+
return {
|
|
15
|
+
command: invocation.command,
|
|
16
|
+
args: invocation.args,
|
|
17
|
+
cwd: invocation.cwd,
|
|
18
|
+
shell: platform === 'win32' && windowsCommandShim,
|
|
19
|
+
detached: platform !== 'win32',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function startProviderProcess(agent, invocation, options = {}) {
|
|
23
|
+
const spawnOptions = providerSpawnOptions(invocation);
|
|
24
|
+
const child = spawn(spawnOptions.command, [...spawnOptions.args], {
|
|
25
|
+
cwd: spawnOptions.cwd,
|
|
26
|
+
shell: spawnOptions.shell,
|
|
27
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
28
|
+
detached: spawnOptions.detached,
|
|
29
|
+
});
|
|
30
|
+
const targetDir = resolve(invocation.cwd);
|
|
31
|
+
const pid = child.pid;
|
|
32
|
+
const startedAt = pid === undefined ? `unverified:${new Date().toISOString()}` : processIncarnation(pid) ?? `unverified:${new Date().toISOString()}`;
|
|
33
|
+
const record = createProviderProcessRecord(targetDir, pid ?? 0, options.workerId, startedAt);
|
|
34
|
+
const recordAdapter = options.recordAdapter ?? filesystemProviderProcessRecordAdapter;
|
|
35
|
+
const terminateProcessTree = options.terminateProcessTree ?? ((processPid) => killProcessTreeForCleanup(processPid));
|
|
36
|
+
let recordPublished = false;
|
|
37
|
+
const stdout = createBoundedOutput(options.outputLimitBytes ?? 1_048_576);
|
|
38
|
+
const stderr = createBoundedOutput(options.outputLimitBytes ?? 1_048_576);
|
|
39
|
+
const telemetry = createTelemetryAccumulator(agent);
|
|
40
|
+
const idleTimeoutMs = options.idleTimeoutMs ?? 0;
|
|
41
|
+
const terminationGraceMs = options.terminationGraceMs ?? 5_000;
|
|
42
|
+
let termination;
|
|
43
|
+
let idleTimer;
|
|
44
|
+
let forceTimer;
|
|
45
|
+
let recordFailure;
|
|
46
|
+
let terminationConfirmed = false;
|
|
47
|
+
let settled = false;
|
|
48
|
+
let resolveCompletion = () => { };
|
|
49
|
+
const completion = new Promise(resolveCompletionValue => {
|
|
50
|
+
resolveCompletion = resolveCompletionValue;
|
|
51
|
+
});
|
|
52
|
+
const removeRecord = () => {
|
|
53
|
+
if (recordPublished)
|
|
54
|
+
recordAdapter.remove(record.path);
|
|
55
|
+
};
|
|
56
|
+
const clearTimers = () => {
|
|
57
|
+
if (idleTimer)
|
|
58
|
+
clearTimeout(idleTimer);
|
|
59
|
+
if (forceTimer)
|
|
60
|
+
clearTimeout(forceTimer);
|
|
61
|
+
idleTimer = undefined;
|
|
62
|
+
forceTimer = undefined;
|
|
63
|
+
};
|
|
64
|
+
const finish = (result) => {
|
|
65
|
+
if (settled)
|
|
66
|
+
return;
|
|
67
|
+
settled = true;
|
|
68
|
+
clearTimers();
|
|
69
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
70
|
+
if (!termination || terminationConfirmed)
|
|
71
|
+
removeRecord();
|
|
72
|
+
resolveCompletion(result);
|
|
73
|
+
};
|
|
74
|
+
const evidence = () => ({
|
|
75
|
+
invocation,
|
|
76
|
+
pid,
|
|
77
|
+
stdout: stdout.text,
|
|
78
|
+
stderr: stderr.text,
|
|
79
|
+
stdoutTruncated: stdout.truncated,
|
|
80
|
+
stderrTruncated: stderr.truncated,
|
|
81
|
+
telemetry: telemetry.finish(),
|
|
82
|
+
});
|
|
83
|
+
const finalize = (exitCode) => {
|
|
84
|
+
const details = evidence();
|
|
85
|
+
if (recordFailure) {
|
|
86
|
+
finish({ ...details, kind: 'spawn-failed', error: recordFailure });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (termination?.kind === 'timed-out') {
|
|
90
|
+
finish({ ...details, kind: 'timed-out', reason: termination.reason });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (termination?.kind === 'cancelled') {
|
|
94
|
+
finish({ ...details, kind: 'cancelled', reason: termination.reason });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (exitCode === 0) {
|
|
98
|
+
finish({ ...details, kind: 'succeeded', exitCode });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
finish({ ...details, kind: 'failed', exitCode });
|
|
102
|
+
};
|
|
103
|
+
const terminate = (next) => {
|
|
104
|
+
if (termination || settled)
|
|
105
|
+
return false;
|
|
106
|
+
termination = next;
|
|
107
|
+
if (pid !== undefined)
|
|
108
|
+
terminationConfirmed = terminateProcessTree(pid, false);
|
|
109
|
+
forceTimer = setTimeout(() => {
|
|
110
|
+
if (pid !== undefined && !settled)
|
|
111
|
+
terminationConfirmed = terminateProcessTree(pid, true);
|
|
112
|
+
}, terminationGraceMs);
|
|
113
|
+
return true;
|
|
114
|
+
};
|
|
115
|
+
const armIdleTimer = () => {
|
|
116
|
+
if (idleTimeoutMs <= 0 || termination || settled)
|
|
117
|
+
return;
|
|
118
|
+
if (idleTimer)
|
|
119
|
+
clearTimeout(idleTimer);
|
|
120
|
+
idleTimer = setTimeout(() => {
|
|
121
|
+
terminate({ kind: 'timed-out', reason: 'provider process produced no output before its idle timeout' });
|
|
122
|
+
}, idleTimeoutMs);
|
|
123
|
+
};
|
|
124
|
+
const onAbort = () => {
|
|
125
|
+
if (options.signal)
|
|
126
|
+
terminate({ kind: 'cancelled', reason: cancellationReason(options.signal) });
|
|
127
|
+
};
|
|
128
|
+
const onOutput = (stream, chunk) => {
|
|
129
|
+
const text = String(chunk);
|
|
130
|
+
if (stream === 'stdout') {
|
|
131
|
+
stdout.append(text);
|
|
132
|
+
telemetry.append(text);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
stderr.append(text);
|
|
136
|
+
}
|
|
137
|
+
options.onOutput?.({ stream, text });
|
|
138
|
+
armIdleTimer();
|
|
139
|
+
};
|
|
140
|
+
child.stdout?.on('data', chunk => { onOutput('stdout', chunk); });
|
|
141
|
+
child.stderr?.on('data', chunk => { onOutput('stderr', chunk); });
|
|
142
|
+
child.stdin?.on('error', () => { });
|
|
143
|
+
child.on('close', code => { finalize(code); });
|
|
144
|
+
child.on('error', error => {
|
|
145
|
+
finish({ ...evidence(), kind: 'spawn-failed', error: recordFailure ?? error.message });
|
|
146
|
+
});
|
|
147
|
+
if (pid !== undefined) {
|
|
148
|
+
try {
|
|
149
|
+
recordAdapter.publish(record);
|
|
150
|
+
recordPublished = true;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
154
|
+
recordFailure = `process ownership record failure: ${reason}`;
|
|
155
|
+
child.stdin?.end();
|
|
156
|
+
terminate({ kind: 'cancelled', reason: recordFailure });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const handle = {
|
|
160
|
+
pid,
|
|
161
|
+
invocation,
|
|
162
|
+
recordPath: record.path,
|
|
163
|
+
completion,
|
|
164
|
+
cancel(reason) {
|
|
165
|
+
return terminate({ kind: 'cancelled', reason });
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
if (recordFailure)
|
|
169
|
+
return handle;
|
|
170
|
+
child.stdin?.end(invocation.input);
|
|
171
|
+
if (options.signal?.aborted)
|
|
172
|
+
onAbort();
|
|
173
|
+
else
|
|
174
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
175
|
+
armIdleTimer();
|
|
176
|
+
return handle;
|
|
177
|
+
}
|
package/dist/agents/providers.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ModelSelectionSchema } from './contracts.js';
|
|
2
|
+
export { providerSpawnOptions, startProviderProcess, } from './process.js';
|
|
1
3
|
const argsFor = (agent, permissions) => {
|
|
2
4
|
if (agent === 'claude') {
|
|
3
5
|
const mode = permissions === 'unsafe' ? 'bypassPermissions' : permissions === 'read-only' ? 'plan' : 'auto';
|
|
@@ -19,18 +21,19 @@ const argsFor = (agent, permissions) => {
|
|
|
19
21
|
return ['--approval-mode', approval, '--sandbox'];
|
|
20
22
|
};
|
|
21
23
|
export function buildProviderInvocation(agent, prompt, cwd, permissions = 'safe', selection = {}) {
|
|
24
|
+
const parsedSelection = ModelSelectionSchema.parse(selection);
|
|
22
25
|
const args = argsFor(agent, permissions);
|
|
23
|
-
if (
|
|
24
|
-
args.push('--model',
|
|
25
|
-
if (
|
|
26
|
+
if (parsedSelection.model)
|
|
27
|
+
args.push('--model', parsedSelection.model);
|
|
28
|
+
if (parsedSelection.reasoningEffort) {
|
|
26
29
|
if (agent === 'claude')
|
|
27
|
-
args.push('--effort',
|
|
30
|
+
args.push('--effort', parsedSelection.reasoningEffort);
|
|
28
31
|
else if (agent === 'codex')
|
|
29
|
-
args.push('--config', `model_reasoning_effort=${
|
|
32
|
+
args.push('--config', `model_reasoning_effort=${parsedSelection.reasoningEffort}`);
|
|
30
33
|
}
|
|
31
|
-
if (agent === 'codex' &&
|
|
34
|
+
if (agent === 'codex' && parsedSelection.nativeMultiAgent === false)
|
|
32
35
|
args.push('--disable', 'multi_agent');
|
|
33
|
-
if (
|
|
36
|
+
if (parsedSelection.bare) {
|
|
34
37
|
if (agent === 'codex')
|
|
35
38
|
args.push('--ignore-user-config');
|
|
36
39
|
else if (agent === 'claude')
|
package/dist/agents/telemetry.js
CHANGED
|
@@ -1,4 +1,66 @@
|
|
|
1
1
|
const finite = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
2
|
+
function parseJson(value) {
|
|
3
|
+
try {
|
|
4
|
+
return { ok: true, value: JSON.parse(value) };
|
|
5
|
+
}
|
|
6
|
+
catch (error) {
|
|
7
|
+
if (error instanceof SyntaxError)
|
|
8
|
+
return { ok: false };
|
|
9
|
+
throw error;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function isRecord(value) {
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function directMachineResult(value) {
|
|
16
|
+
return isRecord(value) && value.schemaVersion === 1 ? value : undefined;
|
|
17
|
+
}
|
|
18
|
+
export function parseProviderResult(agent, output) {
|
|
19
|
+
const whole = parseJson(output);
|
|
20
|
+
if (whole.ok) {
|
|
21
|
+
const direct = directMachineResult(whole.value);
|
|
22
|
+
if (direct !== undefined)
|
|
23
|
+
return direct;
|
|
24
|
+
}
|
|
25
|
+
const fragments = [];
|
|
26
|
+
for (const line of output.split(/\r?\n/u)) {
|
|
27
|
+
const parsed = parseJson(line);
|
|
28
|
+
if (!parsed.ok || !isRecord(parsed.value))
|
|
29
|
+
continue;
|
|
30
|
+
const event = parsed.value;
|
|
31
|
+
switch (agent) {
|
|
32
|
+
case 'claude':
|
|
33
|
+
if (event.type === 'result' && typeof event.result === 'string')
|
|
34
|
+
fragments.push(event.result);
|
|
35
|
+
break;
|
|
36
|
+
case 'codex': {
|
|
37
|
+
const item = isRecord(event.item) ? event.item : undefined;
|
|
38
|
+
if (event.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string')
|
|
39
|
+
fragments.push(item.text);
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
case 'gemini':
|
|
43
|
+
if (event.type === 'message' && event.role === 'assistant' && typeof event.content === 'string')
|
|
44
|
+
fragments.push(event.content);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const joined = parseJson(fragments.join(''));
|
|
49
|
+
if (joined.ok) {
|
|
50
|
+
const direct = directMachineResult(joined.value);
|
|
51
|
+
if (direct !== undefined)
|
|
52
|
+
return direct;
|
|
53
|
+
}
|
|
54
|
+
for (const fragment of fragments.reverse()) {
|
|
55
|
+
const parsed = parseJson(fragment);
|
|
56
|
+
if (!parsed.ok)
|
|
57
|
+
continue;
|
|
58
|
+
const direct = directMachineResult(parsed.value);
|
|
59
|
+
if (direct !== undefined)
|
|
60
|
+
return direct;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
2
64
|
export function parseProviderTelemetry(agent, lines) {
|
|
3
65
|
let inputTokens;
|
|
4
66
|
let cachedInputTokens;
|
package/dist/audit/command.js
CHANGED
|
@@ -22,12 +22,20 @@ export function runAudit(targetDir, opts = {}) {
|
|
|
22
22
|
}
|
|
23
23
|
findings.push(...scanSensitiveChanges(changed));
|
|
24
24
|
if (opts.command) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
let commandResult;
|
|
26
|
+
if (opts.commandRunner)
|
|
27
|
+
commandResult = opts.commandRunner(opts.command, targetDir);
|
|
28
|
+
else {
|
|
29
|
+
try {
|
|
30
|
+
execSync(opts.command, { cwd: targetDir, stdio: 'pipe' });
|
|
31
|
+
commandResult = { passed: true, summary: `audit passed: ${opts.command}` };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
commandResult = { passed: false, summary: `Audit command failed: ${opts.command}` };
|
|
35
|
+
}
|
|
30
36
|
}
|
|
37
|
+
if (!commandResult.passed)
|
|
38
|
+
findings.push({ ruleId: 'audit.custom-command', severity: 'high', message: commandResult.summary, file: '.yoke/config.yaml' });
|
|
31
39
|
}
|
|
32
40
|
else {
|
|
33
41
|
const dependency = opts.dependency ?? ((repoFiles) => {
|
package/dist/cli.js
CHANGED
|
@@ -52,6 +52,48 @@ export function runDesignScan(targetDir, opts) {
|
|
|
52
52
|
console.log(`${label} — ✓`);
|
|
53
53
|
return 0;
|
|
54
54
|
}
|
|
55
|
+
export function parseQualityFlags(args) {
|
|
56
|
+
const quality = args.includes('--quality') ? true : args.includes('--no-quality') ? false : undefined;
|
|
57
|
+
const qualityUnbounded = args.includes('--quality-unbounded') || undefined;
|
|
58
|
+
const parsePositive = (flag) => {
|
|
59
|
+
const raw = args.find(arg => arg.startsWith(flag));
|
|
60
|
+
if (!raw)
|
|
61
|
+
return undefined;
|
|
62
|
+
const value = Number(raw.slice(flag.length));
|
|
63
|
+
return Number.isInteger(value) && value > 0 ? value : `Invalid ${flag.slice(0, -1)} value: ${raw}`;
|
|
64
|
+
};
|
|
65
|
+
const qualityRounds = parsePositive('--quality-rounds=');
|
|
66
|
+
if (typeof qualityRounds === 'string')
|
|
67
|
+
return { ok: false, error: qualityRounds };
|
|
68
|
+
const qualityMinutes = parsePositive('--quality-minutes=');
|
|
69
|
+
if (typeof qualityMinutes === 'string')
|
|
70
|
+
return { ok: false, error: qualityMinutes };
|
|
71
|
+
const candidates = parsePositive('--candidates=');
|
|
72
|
+
if (typeof candidates === 'string')
|
|
73
|
+
return { ok: false, error: candidates };
|
|
74
|
+
const policyArg = args.find(arg => arg.startsWith('--quality-policy='))?.slice('--quality-policy='.length);
|
|
75
|
+
if (policyArg && policyArg !== 'blocking' && policyArg !== 'advisory') {
|
|
76
|
+
return { ok: false, error: `Invalid --quality-policy value: ${policyArg} (expected blocking|advisory)` };
|
|
77
|
+
}
|
|
78
|
+
const qualityPolicy = policyArg === 'blocking' || policyArg === 'advisory' ? policyArg : undefined;
|
|
79
|
+
if (quality === true && args.includes('--no-quality'))
|
|
80
|
+
return { ok: false, error: 'Cannot use --quality with --no-quality.' };
|
|
81
|
+
if (qualityUnbounded && quality === false)
|
|
82
|
+
return { ok: false, error: 'Cannot use --quality-unbounded with --no-quality.' };
|
|
83
|
+
if (qualityUnbounded && (qualityRounds !== undefined || qualityMinutes !== undefined)) {
|
|
84
|
+
return { ok: false, error: 'Cannot use --quality-unbounded with --quality-rounds or --quality-minutes.' };
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
options: {
|
|
89
|
+
...(qualityUnbounded ? { quality: true, qualityUnbounded: true } : quality !== undefined ? { quality } : {}),
|
|
90
|
+
...(qualityRounds !== undefined ? { qualityRounds } : {}),
|
|
91
|
+
...(qualityMinutes !== undefined ? { qualityMinutes } : {}),
|
|
92
|
+
...(qualityPolicy ? { qualityPolicy } : {}),
|
|
93
|
+
...(candidates !== undefined ? { candidates } : {}),
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
55
97
|
export function main(argv) {
|
|
56
98
|
const [cmd, ...rest] = argv;
|
|
57
99
|
switch (cmd) {
|
|
@@ -217,7 +259,12 @@ export function main(argv) {
|
|
|
217
259
|
return 1;
|
|
218
260
|
}
|
|
219
261
|
const { version: _version, storyId: _storyId, requestId: _requestId, answered: _answered, ...resumeOptions } = resume;
|
|
220
|
-
|
|
262
|
+
const qualityFlags = parseQualityFlags(rest);
|
|
263
|
+
if (!qualityFlags.ok) {
|
|
264
|
+
console.error(qualityFlags.error);
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
return runLoopCommand(targetDir, { ...resumeOptions, ...qualityFlags.options });
|
|
221
268
|
}
|
|
222
269
|
if (sub === 'answer') {
|
|
223
270
|
const choice = rest.find(a => a.startsWith('--choice='))?.slice('--choice='.length);
|
|
@@ -342,9 +389,14 @@ export function main(argv) {
|
|
|
342
389
|
console.error(`Invalid --decision-policy value: ${dpArg} (expected auto|critical)`);
|
|
343
390
|
return 1;
|
|
344
391
|
}
|
|
345
|
-
|
|
392
|
+
const qualityFlags = parseQualityFlags(rest);
|
|
393
|
+
if (!qualityFlags.ok) {
|
|
394
|
+
console.error(qualityFlags.error);
|
|
395
|
+
return 1;
|
|
396
|
+
}
|
|
397
|
+
return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, parallel, reviewer, review, allowSelfReview, timeoutMinutes, json, routing, onAmbiguity: oaArg, decisionPolicy: dpArg, permissions, ...qualityFlags.options });
|
|
346
398
|
}
|
|
347
|
-
console.log('usage: yoke loop <on|off|status|decision|answer|resume [--discard]|cleanup [--remove-worktrees] [--discard-stale-recovery]|run [--max=N] [--parallel=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--allow-self-review] [--routing|--no-routing] [--isolate] [--unsafe] [--timeout=<minutes>] [--decision-policy=<auto|critical>] [--json]> [targetDir]');
|
|
399
|
+
console.log('usage: yoke loop <on|off|status|decision|answer|resume [--discard] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N]|cleanup [--remove-worktrees] [--discard-stale-recovery]|run [--max=N] [--parallel=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--allow-self-review] [--routing|--no-routing] [--isolate] [--unsafe] [--timeout=<minutes>] [--decision-policy=<auto|critical>] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N] [--json]> [targetDir]');
|
|
348
400
|
return 1;
|
|
349
401
|
}
|
|
350
402
|
case 'new': {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { posix, win32 } from 'node:path';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
const CandidateEvidenceSchema = z.object({
|
|
4
|
+
digest: z.string().min(1),
|
|
5
|
+
artifacts: z.array(z.string()),
|
|
6
|
+
});
|
|
7
|
+
const CandidateIdSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
|
|
8
|
+
export function candidateConfigProblem(input) {
|
|
9
|
+
if (!Number.isInteger(input.maxCandidates) || input.maxCandidates < 1 || input.maxCandidates > 5) {
|
|
10
|
+
return 'maxCandidates must be an integer from 1 to 5';
|
|
11
|
+
}
|
|
12
|
+
if (input.candidates.length === 0)
|
|
13
|
+
return 'at least one candidate is required';
|
|
14
|
+
if (input.candidates.some(candidate => !CandidateIdSchema.safeParse(candidate.id).success)) {
|
|
15
|
+
return 'candidate ids must be non-empty safe identifiers';
|
|
16
|
+
}
|
|
17
|
+
if (new Set(input.candidates.map(candidate => candidate.id)).size !== input.candidates.length) {
|
|
18
|
+
return 'candidate ids must be unique';
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
export function parseCandidateEvidence(value) {
|
|
23
|
+
const parsed = CandidateEvidenceSchema.parse(value);
|
|
24
|
+
return { digest: parsed.digest, artifacts: [...parsed.artifacts] };
|
|
25
|
+
}
|
|
26
|
+
export function canonicalWorktreePath(path, platform) {
|
|
27
|
+
if (platform === 'win32') {
|
|
28
|
+
if (!/^(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+)/.test(path))
|
|
29
|
+
return null;
|
|
30
|
+
const normalized = win32.normalize(path).replaceAll('/', '\\').toLowerCase();
|
|
31
|
+
return trimSeparators(normalized, win32.parse(normalized).root, '\\');
|
|
32
|
+
}
|
|
33
|
+
if (!posix.isAbsolute(path))
|
|
34
|
+
return null;
|
|
35
|
+
const normalized = posix.normalize(path);
|
|
36
|
+
return trimSeparators(normalized, posix.parse(normalized).root, '/');
|
|
37
|
+
}
|
|
38
|
+
function trimSeparators(path, root, separator) {
|
|
39
|
+
let key = path;
|
|
40
|
+
while (key.length > root.length && key.endsWith(separator))
|
|
41
|
+
key = key.slice(0, -1);
|
|
42
|
+
return key;
|
|
43
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const stageOrder = ['cancel', 'reap', 'remove'];
|
|
2
|
+
export class CandidateCleanup {
|
|
3
|
+
lifecycle;
|
|
4
|
+
onLifecycle;
|
|
5
|
+
#states = new Map();
|
|
6
|
+
constructor(lifecycle, onLifecycle) {
|
|
7
|
+
this.lifecycle = lifecycle;
|
|
8
|
+
this.onLifecycle = onLifecycle;
|
|
9
|
+
}
|
|
10
|
+
register(ownership) {
|
|
11
|
+
this.#states.set(ownership.candidateId, { ownership, materialized: false, completed: new Set(), failed: new Map(), pending: new Map() });
|
|
12
|
+
}
|
|
13
|
+
markMaterialized(candidateId) {
|
|
14
|
+
const state = this.#states.get(candidateId);
|
|
15
|
+
if (state)
|
|
16
|
+
state.materialized = true;
|
|
17
|
+
}
|
|
18
|
+
cancelNow(reason) {
|
|
19
|
+
for (const state of this.#states.values()) {
|
|
20
|
+
state.ownership.controller.abort(reason);
|
|
21
|
+
this.onLifecycle?.(state.ownership, 'cleaning', reason);
|
|
22
|
+
void this.#runStage(state, 'cancel', () => this.lifecycle.cancel(state.ownership, reason));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async cleanup(ownership, reason) {
|
|
26
|
+
const state = this.#states.get(ownership.candidateId);
|
|
27
|
+
if (!state)
|
|
28
|
+
return;
|
|
29
|
+
ownership.controller.abort(reason);
|
|
30
|
+
this.onLifecycle?.(ownership, 'cleaning', reason);
|
|
31
|
+
if (!await this.#runStage(state, 'cancel', () => this.lifecycle.cancel(ownership, reason)))
|
|
32
|
+
return;
|
|
33
|
+
if (!await this.#runStage(state, 'reap', () => this.lifecycle.reap(ownership)))
|
|
34
|
+
return;
|
|
35
|
+
if (await this.#runStage(state, 'remove', () => this.lifecycle.remove(ownership)))
|
|
36
|
+
this.onLifecycle?.(ownership, 'removed', reason);
|
|
37
|
+
}
|
|
38
|
+
async cleanupAll(reason, keepCandidateId) {
|
|
39
|
+
for (const state of this.#states.values()) {
|
|
40
|
+
if (state.ownership.candidateId !== keepCandidateId)
|
|
41
|
+
await this.cleanup(state.ownership, reason);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
hasFailures() {
|
|
45
|
+
return [...this.#states.values()].some(state => state.failed.size > 0);
|
|
46
|
+
}
|
|
47
|
+
failureSummary() {
|
|
48
|
+
return this.recovery().flatMap(item => item.failedStages.map(failure => `${item.ownership.candidateId}:${failure.stage}: ${failure.summary}`)).join('; ');
|
|
49
|
+
}
|
|
50
|
+
recovery() {
|
|
51
|
+
return [...this.#states.values()].filter(state => state.failed.size > 0).map(state => ({
|
|
52
|
+
ownership: {
|
|
53
|
+
candidateId: state.ownership.candidateId,
|
|
54
|
+
coordinatorId: state.ownership.coordinatorId,
|
|
55
|
+
ownerToken: state.ownership.ownerToken,
|
|
56
|
+
storyId: state.ownership.storyId,
|
|
57
|
+
provider: { ...state.ownership.provider },
|
|
58
|
+
worktree: { ...state.ownership.worktree },
|
|
59
|
+
},
|
|
60
|
+
materialized: state.materialized,
|
|
61
|
+
completedStages: stageOrder.filter(stage => state.completed.has(stage)),
|
|
62
|
+
failedStages: stageOrder.flatMap(stage => {
|
|
63
|
+
const summary = state.failed.get(stage);
|
|
64
|
+
return summary ? [{ stage, summary }] : [];
|
|
65
|
+
}),
|
|
66
|
+
pendingStages: stageOrder.filter(stage => !state.completed.has(stage) && !state.failed.has(stage)),
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
#runStage(state, stage, operation) {
|
|
70
|
+
if (state.completed.has(stage))
|
|
71
|
+
return Promise.resolve(true);
|
|
72
|
+
const pending = state.pending.get(stage);
|
|
73
|
+
if (pending)
|
|
74
|
+
return pending.then(() => state.completed.has(stage));
|
|
75
|
+
try {
|
|
76
|
+
const result = operation();
|
|
77
|
+
if (!(result instanceof Promise)) {
|
|
78
|
+
state.completed.add(stage);
|
|
79
|
+
state.failed.delete(stage);
|
|
80
|
+
return Promise.resolve(true);
|
|
81
|
+
}
|
|
82
|
+
const task = result.then(() => {
|
|
83
|
+
state.completed.add(stage);
|
|
84
|
+
state.failed.delete(stage);
|
|
85
|
+
}).catch(error => {
|
|
86
|
+
state.failed.set(stage, error instanceof Error ? error.message : String(error));
|
|
87
|
+
}).finally(() => {
|
|
88
|
+
state.pending.delete(stage);
|
|
89
|
+
});
|
|
90
|
+
state.pending.set(stage, task);
|
|
91
|
+
return task.then(() => state.completed.has(stage));
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
state.failed.set(stage, error instanceof Error ? error.message : String(error));
|
|
95
|
+
return Promise.resolve(false);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|