@hecer/yoke 0.9.0 → 1.0.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/.codex-plugin/plugin.json +7 -0
- package/CHANGELOG.md +169 -149
- package/README.md +24 -16
- package/TODOS.md +8 -0
- package/agents/docs.toml +6 -0
- package/agents/implementer.toml +6 -0
- package/agents/reviewer.toml +6 -0
- package/agents/security.toml +6 -0
- package/bench/README.md +45 -42
- package/bench/RESULTS.md +46 -36
- package/bench/result-schema.mjs +12 -0
- package/bench/results/claude-2026-07-27T18-03-26.json +50 -0
- package/bench/results/codex-unavailable-1785175418318.json +15 -0
- package/bench/results/gemini-2026-07-27T18-03-44.json +46 -0
- package/bench/run-matrix.mjs +26 -0
- package/bench/run.mjs +127 -115
- package/canon/loop/prd.schema.md +5 -0
- package/canon/manifest.yaml +1 -1
- package/canon/skills/authoring-prd/SKILL.md +6 -0
- package/canon/skills/ship/SKILL.md +2 -7
- package/canon/tools/codex-rtk-hook.mjs +36 -0
- package/dist/agents/providers.js +23 -0
- package/dist/agents/telemetry.js +30 -0
- package/dist/agents/types.js +1 -0
- package/dist/audit/changes.js +6 -0
- package/dist/audit/command.js +64 -0
- package/dist/audit/dependencies.js +21 -0
- package/dist/audit/secrets.js +16 -0
- package/dist/audit/types.js +1 -0
- package/dist/cli.js +22 -4
- package/dist/loop/claims.js +57 -0
- package/dist/loop/cleanup.js +10 -4
- package/dist/loop/git.js +8 -2
- package/dist/loop/identity.js +27 -0
- package/dist/loop/loop.js +20 -2
- package/dist/loop/merge-queue.js +20 -0
- package/dist/loop/parallel.js +39 -0
- package/dist/loop/prd.js +48 -2
- package/dist/loop/run-command.js +51 -6
- package/dist/loop/runner.js +41 -29
- package/dist/loop/scheduler.js +8 -0
- package/dist/prd/command.js +6 -0
- package/dist/retrofit/config.js +12 -0
- package/dist/retrofit/planners/codex.js +64 -19
- package/dist/review/command.js +52 -12
- package/dist/review/verdict.js +45 -0
- package/docs/MIGRATING-TO-1.0.md +33 -0
- package/docs/superpowers/plans/2026-07-27-yoke-1.0-release.md +205 -0
- package/docs/superpowers/specs/2026-07-27-yoke-1.0-hardening-and-codex-parity-design.md +164 -0
- package/hooks/hooks.json +19 -0
- package/package.json +82 -67
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/config.yaml +0 -6
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/context/DECISIONS.md +0 -9
- package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/prd.yaml +0 -38
- package/bench/.runs/claude-2026-07-09T22-34-01/bench-verify.mjs +0 -15
- package/bench/.runs/claude-2026-07-09T22-34-01/package.json +0 -9
- package/bench/.runs/claude-2026-07-09T22-34-01/src/index.mjs +0 -48
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-1.test.mjs +0 -24
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-2.test.mjs +0 -28
- package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-3.test.mjs +0 -25
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/config.yaml +0 -6
- package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/prd.yaml +0 -32
- package/bench/.runs/gemini-2026-07-09T22-34-02/bench-verify.mjs +0 -15
- package/bench/.runs/gemini-2026-07-09T22-34-02/package.json +0 -9
- package/bench/.runs/gemini-2026-07-09T22-34-02/src/index.mjs +0 -3
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-1.test.mjs +0 -24
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-2.test.mjs +0 -28
- package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-3.test.mjs +0 -25
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const finite = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
2
|
+
export function parseProviderTelemetry(agent, lines) {
|
|
3
|
+
let inputTokens;
|
|
4
|
+
let outputTokens;
|
|
5
|
+
let model;
|
|
6
|
+
for (const line of lines) {
|
|
7
|
+
let event;
|
|
8
|
+
try {
|
|
9
|
+
event = JSON.parse(line);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
const message = event.message && typeof event.message === 'object' ? event.message : undefined;
|
|
15
|
+
const usage = (event.usage && typeof event.usage === 'object' ? event.usage : message?.usage);
|
|
16
|
+
const inValue = finite(usage?.input_tokens ?? usage?.inputTokens ?? usage?.prompt_tokens);
|
|
17
|
+
const outValue = finite(usage?.output_tokens ?? usage?.outputTokens ?? usage?.completion_tokens);
|
|
18
|
+
if (inValue !== undefined)
|
|
19
|
+
inputTokens = inValue;
|
|
20
|
+
if (outValue !== undefined)
|
|
21
|
+
outputTokens = outValue;
|
|
22
|
+
const eventModel = event.model ?? message?.model;
|
|
23
|
+
if (typeof eventModel === 'string' && eventModel)
|
|
24
|
+
model = eventModel;
|
|
25
|
+
}
|
|
26
|
+
if (inputTokens === undefined && outputTokens === undefined)
|
|
27
|
+
return { usageAvailable: false };
|
|
28
|
+
const tokens = { inputTokens: inputTokens ?? 0, outputTokens: outputTokens ?? 0, ...(model ? { model } : {}) };
|
|
29
|
+
return { usageAvailable: true, tokens };
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
const SENSITIVE = /(^|\/)(\.github\/workflows|auth|permissions?|secrets?|infra|terraform)(\/|\.|$)/i;
|
|
2
|
+
export function scanSensitiveChanges(files) {
|
|
3
|
+
return [...new Set(files)].filter(file => SENSITIVE.test(file.replace(/\\/g, '/'))).sort().map(file => ({
|
|
4
|
+
ruleId: 'changes.sensitive-path', severity: 'medium', message: 'Security-sensitive path changed; review explicitly', file,
|
|
5
|
+
}));
|
|
6
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { dependencyAuditCommand, parseDependencyAudit } from './dependencies.js';
|
|
5
|
+
import { scanSensitiveChanges } from './changes.js';
|
|
6
|
+
import { scanSecrets } from './secrets.js';
|
|
7
|
+
const lines = (value) => value.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
|
8
|
+
export function applySuppressions(findings, suppressions = [], now = new Date()) {
|
|
9
|
+
return findings.filter(finding => !suppressions.some(s => s.reason.trim() && s.ruleId === finding.ruleId && (!s.file || s.file === finding.file) && (!s.expires || Date.parse(s.expires) >= now.getTime())));
|
|
10
|
+
}
|
|
11
|
+
export function runAudit(targetDir, opts = {}) {
|
|
12
|
+
try {
|
|
13
|
+
const files = opts.files?.() ?? lines(execFileSync('git', ['ls-files'], { cwd: targetDir }).toString());
|
|
14
|
+
const changed = opts.changed?.() ?? lines(execFileSync('git', ['diff', '--name-only', 'HEAD'], { cwd: targetDir }).toString());
|
|
15
|
+
const read = opts.read ?? ((file) => readFileSync(join(targetDir, file), 'utf8'));
|
|
16
|
+
let findings = [];
|
|
17
|
+
for (const file of files) {
|
|
18
|
+
try {
|
|
19
|
+
findings.push(...scanSecrets(file, read(file)));
|
|
20
|
+
}
|
|
21
|
+
catch { /* binary/deleted file */ }
|
|
22
|
+
}
|
|
23
|
+
findings.push(...scanSensitiveChanges(changed));
|
|
24
|
+
if (opts.command) {
|
|
25
|
+
try {
|
|
26
|
+
execSync(opts.command, { cwd: targetDir, stdio: 'pipe' });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
findings.push({ ruleId: 'audit.custom-command', severity: 'high', message: `Audit command failed: ${opts.command}`, file: '.yoke/config.yaml' });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
const dependency = opts.dependency ?? ((repoFiles) => {
|
|
34
|
+
const command = dependencyAuditCommand(repoFiles);
|
|
35
|
+
if (!command)
|
|
36
|
+
return [];
|
|
37
|
+
try {
|
|
38
|
+
return parseDependencyAudit(execFileSync(command[0], command[1], { cwd: targetDir, stdio: ['ignore', 'pipe', 'pipe'] }).toString());
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
return parseDependencyAudit(String(error.stdout ?? '{}'));
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
findings.push(...dependency(files));
|
|
45
|
+
}
|
|
46
|
+
findings = applySuppressions(findings, opts.suppressions).sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.ruleId.localeCompare(b.ruleId));
|
|
47
|
+
return { code: findings.some(f => f.severity === 'high' || f.severity === 'critical') ? 1 : 0, findings };
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return { code: 2, findings: [], error: error.message };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function printAudit(result, json = false) {
|
|
54
|
+
if (json) {
|
|
55
|
+
console.log(JSON.stringify(result));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
for (const finding of result.findings)
|
|
59
|
+
console.log(`${finding.severity.toUpperCase()} ${finding.ruleId} ${finding.file}${finding.line ? `:${finding.line}` : ''} — ${finding.message}`);
|
|
60
|
+
if (result.error)
|
|
61
|
+
console.error(`Audit unavailable: ${result.error}`);
|
|
62
|
+
else
|
|
63
|
+
console.log(result.code === 0 ? '✓ audit passed' : `✗ audit found ${result.findings.length} issue(s)`);
|
|
64
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function dependencyAuditCommand(files) {
|
|
2
|
+
if (files.includes('package-lock.json'))
|
|
3
|
+
return ['npm', ['audit', '--json']];
|
|
4
|
+
if (files.includes('pnpm-lock.yaml'))
|
|
5
|
+
return ['pnpm', ['audit', '--json']];
|
|
6
|
+
if (files.includes('yarn.lock'))
|
|
7
|
+
return ['yarn', ['npm', 'audit', '--json']];
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
export function parseDependencyAudit(output) {
|
|
11
|
+
let value;
|
|
12
|
+
try {
|
|
13
|
+
value = JSON.parse(output);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return [{ ruleId: 'dependencies.audit-error', severity: 'high', message: 'Dependency audit returned invalid JSON', file: 'package manifest' }];
|
|
17
|
+
}
|
|
18
|
+
const vulnerabilities = value?.metadata?.vulnerabilities;
|
|
19
|
+
const count = Number(vulnerabilities?.high ?? 0) + Number(vulnerabilities?.critical ?? 0);
|
|
20
|
+
return count > 0 ? [{ ruleId: 'dependencies.high', severity: 'high', message: `${count} high/critical dependency vulnerabilities`, file: 'package lock' }] : [];
|
|
21
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const RULES = [
|
|
2
|
+
{ ruleId: 'secret.github-token', severity: 'critical', pattern: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g, message: 'GitHub token detected' },
|
|
3
|
+
{ ruleId: 'secret.aws-access-key', severity: 'critical', pattern: /\bAKIA[0-9A-Z]{16}\b/g, message: 'AWS access key detected' },
|
|
4
|
+
{ ruleId: 'secret.private-key', severity: 'critical', pattern: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g, message: 'Private key material detected' },
|
|
5
|
+
];
|
|
6
|
+
export function scanSecrets(file, content) {
|
|
7
|
+
const findings = [];
|
|
8
|
+
for (const rule of RULES) {
|
|
9
|
+
rule.pattern.lastIndex = 0;
|
|
10
|
+
for (const match of content.matchAll(rule.pattern)) {
|
|
11
|
+
const line = content.slice(0, match.index).split(/\r?\n/).length;
|
|
12
|
+
findings.push({ ruleId: rule.ruleId, severity: rule.severity, message: rule.message, file, line });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return findings.sort((a, b) => (a.line ?? 0) - (b.line ?? 0) || a.ruleId.localeCompare(b.ruleId));
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { runLoopCleanup } from './loop/cleanup.js';
|
|
|
13
13
|
import { runFlowSmoke } from './smoke/command.js';
|
|
14
14
|
import { maybeNotifyUpdate, currentYokeVersion } from './update/check.js';
|
|
15
15
|
import { runUpgrade } from './update/upgrade.js';
|
|
16
|
+
import { printAudit, runAudit } from './audit/command.js';
|
|
16
17
|
export { runRetrofit } from './retrofit/command.js';
|
|
17
18
|
export function runValidate(canonDir) {
|
|
18
19
|
const issues = validateCanon(canonDir);
|
|
@@ -90,7 +91,7 @@ function main(argv) {
|
|
|
90
91
|
return 0;
|
|
91
92
|
}
|
|
92
93
|
if (sub === 'cleanup')
|
|
93
|
-
return runLoopCleanup(targetDir);
|
|
94
|
+
return runLoopCleanup(targetDir, { removeWorktrees: rest.includes('--remove-worktrees') });
|
|
94
95
|
if (sub === 'run') {
|
|
95
96
|
const maxArg = rest.find(a => a.startsWith('--max='));
|
|
96
97
|
const rawMax = maxArg ? Number(maxArg.slice('--max='.length)) : 25;
|
|
@@ -116,6 +117,14 @@ function main(argv) {
|
|
|
116
117
|
reviewer = reviewerArg;
|
|
117
118
|
}
|
|
118
119
|
const review = rest.includes('--review');
|
|
120
|
+
const allowSelfReview = rest.includes('--allow-self-review');
|
|
121
|
+
const permissions = rest.includes('--unsafe') ? 'unsafe' : undefined;
|
|
122
|
+
const parallelArg = rest.find(a => a.startsWith('--parallel='));
|
|
123
|
+
const parallel = parallelArg ? Number(parallelArg.slice('--parallel='.length)) : 1;
|
|
124
|
+
if (!Number.isInteger(parallel) || parallel < 1) {
|
|
125
|
+
console.error(`Invalid --parallel value: ${parallelArg}`);
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
119
128
|
const json = rest.includes('--json');
|
|
120
129
|
const toArg = rest.find(a => a.startsWith('--timeout='));
|
|
121
130
|
let timeoutMinutes;
|
|
@@ -132,9 +141,9 @@ function main(argv) {
|
|
|
132
141
|
console.error(`Invalid --on-ambiguity value: ${oaArg} (expected resolve|abort)`);
|
|
133
142
|
return 1;
|
|
134
143
|
}
|
|
135
|
-
return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, reviewer, review, timeoutMinutes, json, onAmbiguity: oaArg });
|
|
144
|
+
return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, parallel, reviewer, review, allowSelfReview, timeoutMinutes, json, onAmbiguity: oaArg, permissions });
|
|
136
145
|
}
|
|
137
|
-
console.log('usage: yoke loop <on|off|status|cleanup|run [--max=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--isolate] [--timeout=<minutes>] [--on-ambiguity=<resolve|abort>] [--json]> [targetDir]');
|
|
146
|
+
console.log('usage: yoke loop <on|off|status|cleanup [--remove-worktrees]|run [--max=N] [--parallel=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--allow-self-review] [--isolate] [--unsafe] [--timeout=<minutes>] [--on-ambiguity=<resolve|abort>] [--json]> [targetDir]');
|
|
138
147
|
return 1;
|
|
139
148
|
}
|
|
140
149
|
case 'new': {
|
|
@@ -213,6 +222,8 @@ function main(argv) {
|
|
|
213
222
|
}
|
|
214
223
|
const base = rest.find(a => a.startsWith('--base='))?.slice('--base='.length);
|
|
215
224
|
const focus = rest.find(a => a.startsWith('--focus='))?.slice('--focus='.length);
|
|
225
|
+
const allowSelfReview = rest.includes('--allow-self-review');
|
|
226
|
+
const json = rest.includes('--json');
|
|
216
227
|
const toArg = rest.find(a => a.startsWith('--timeout='));
|
|
217
228
|
let timeoutMinutes;
|
|
218
229
|
if (toArg) {
|
|
@@ -223,7 +234,14 @@ function main(argv) {
|
|
|
223
234
|
}
|
|
224
235
|
timeoutMinutes = v;
|
|
225
236
|
}
|
|
226
|
-
return runReview(targetDir, { reviewer: reviewerArg, base, focus, timeoutMinutes });
|
|
237
|
+
return runReview(targetDir, { reviewer: reviewerArg, base, focus, allowSelfReview, json, timeoutMinutes });
|
|
238
|
+
}
|
|
239
|
+
case 'audit': {
|
|
240
|
+
const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
|
|
241
|
+
const json = rest.includes('--json');
|
|
242
|
+
const result = runAudit(targetDir);
|
|
243
|
+
printAudit(result, json);
|
|
244
|
+
return result.code;
|
|
227
245
|
}
|
|
228
246
|
case 'flow-smoke': {
|
|
229
247
|
const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const pathFor = (dir, id) => join(dir, '.yoke', 'claims', `${id}.json`);
|
|
4
|
+
export function acquireClaim(dir, storyId, owner, opts = {}) {
|
|
5
|
+
const path = pathFor(dir, storyId);
|
|
6
|
+
mkdirSync(join(dir, '.yoke', 'claims'), { recursive: true });
|
|
7
|
+
const now = opts.now ?? new Date();
|
|
8
|
+
try {
|
|
9
|
+
const old = JSON.parse(readFileSync(path, 'utf8'));
|
|
10
|
+
if (now.getTime() - Date.parse(old.claimedAt) <= (opts.staleMs ?? 30 * 60_000))
|
|
11
|
+
return null;
|
|
12
|
+
rmSync(path, { force: true });
|
|
13
|
+
}
|
|
14
|
+
catch { /* absent or malformed */ }
|
|
15
|
+
const claim = { storyId, owner, pid: opts.pid ?? process.pid, claimedAt: now.toISOString() };
|
|
16
|
+
try {
|
|
17
|
+
const fd = openSync(path, 'wx');
|
|
18
|
+
writeFileSync(fd, JSON.stringify(claim));
|
|
19
|
+
closeSync(fd);
|
|
20
|
+
return claim;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function releaseClaim(dir, storyId, owner) {
|
|
27
|
+
const path = pathFor(dir, storyId);
|
|
28
|
+
try {
|
|
29
|
+
const claim = JSON.parse(readFileSync(path, 'utf8'));
|
|
30
|
+
if (claim.owner !== owner)
|
|
31
|
+
return false;
|
|
32
|
+
rmSync(path, { force: true });
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function cleanupClaims(dir, owner) {
|
|
40
|
+
const claimsDir = join(dir, '.yoke', 'claims');
|
|
41
|
+
let removed = 0;
|
|
42
|
+
try {
|
|
43
|
+
for (const file of readdirSync(claimsDir)) {
|
|
44
|
+
const path = join(claimsDir, file);
|
|
45
|
+
try {
|
|
46
|
+
const claim = JSON.parse(readFileSync(path, 'utf8'));
|
|
47
|
+
if (!owner || claim.owner === owner) {
|
|
48
|
+
rmSync(path, { force: true });
|
|
49
|
+
removed++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch { /* scoped cleanup never guesses ownership */ }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch { /* no claims */ }
|
|
56
|
+
return removed;
|
|
57
|
+
}
|
package/dist/loop/cleanup.js
CHANGED
|
@@ -53,6 +53,10 @@ export function runLoopCleanup(targetDir, opts = {}) {
|
|
|
53
53
|
if (existsSync(wtDir)) {
|
|
54
54
|
for (const name of readdirSync(wtDir)) {
|
|
55
55
|
const path = join(wtDir, name);
|
|
56
|
+
if (!opts.removeWorktrees) {
|
|
57
|
+
console.log(`Yoke worktree retained: ${path} (pass --remove-worktrees to remove it)`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
56
60
|
try {
|
|
57
61
|
git(['worktree', 'remove', '--force', path], targetDir);
|
|
58
62
|
removed++;
|
|
@@ -62,10 +66,12 @@ export function runLoopCleanup(targetDir, opts = {}) {
|
|
|
62
66
|
failed++;
|
|
63
67
|
}
|
|
64
68
|
}
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
if (opts.removeWorktrees) {
|
|
70
|
+
try {
|
|
71
|
+
git(['worktree', 'prune'], targetDir);
|
|
72
|
+
}
|
|
73
|
+
catch { /* best-effort */ }
|
|
67
74
|
}
|
|
68
|
-
catch { /* best-effort */ }
|
|
69
75
|
}
|
|
70
76
|
const lockFile = lockPath(targetDir);
|
|
71
77
|
if (existsSync(lockFile)) {
|
|
@@ -78,6 +84,6 @@ export function runLoopCleanup(targetDir, opts = {}) {
|
|
|
78
84
|
console.log('Removed stale loop lock.');
|
|
79
85
|
}
|
|
80
86
|
}
|
|
81
|
-
console.log(removed === 0 && failed === 0 ? '
|
|
87
|
+
console.log(removed === 0 && failed === 0 ? 'No destructive cleanup performed.' : `Removed ${removed} worktree(s)${failed > 0 ? `, ${failed} failed` : ''}.`);
|
|
82
88
|
return failed === 0 ? 0 : 1;
|
|
83
89
|
}
|
package/dist/loop/git.js
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { sanitizeCommitMessage } from './identity.js';
|
|
2
3
|
export const realGitOps = {
|
|
3
4
|
isClean(dir) {
|
|
4
5
|
const out = execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString();
|
|
5
6
|
return out.trim() === '';
|
|
6
7
|
},
|
|
7
|
-
commitAll(dir, message) {
|
|
8
|
+
commitAll(dir, message, identity) {
|
|
8
9
|
execFileSync('git', ['add', '-A'], { cwd: dir, stdio: 'pipe' });
|
|
9
10
|
const status = execFileSync('git', ['status', '--porcelain'], { cwd: dir }).toString().trim();
|
|
10
11
|
if (status === '') {
|
|
11
12
|
throw new Error('nothing to commit after agent run');
|
|
12
13
|
}
|
|
13
|
-
|
|
14
|
+
const identityArgs = identity
|
|
15
|
+
? ['-c', `user.name=${identity.authorName}`, '-c', `user.email=${identity.authorEmail}`]
|
|
16
|
+
: [];
|
|
17
|
+
const authorArgs = identity ? ['--author', `${identity.authorName} <${identity.authorEmail}>`] : [];
|
|
18
|
+
const cleanMessage = sanitizeCommitMessage(message, identity?.allowCoAuthors ?? false);
|
|
19
|
+
execFileSync('git', [...identityArgs, '-c', 'commit.gpgsign=false', 'commit', ...authorArgs, '-m', cleanMessage], { cwd: dir, stdio: 'pipe' });
|
|
14
20
|
},
|
|
15
21
|
addWorktree(repoDir, worktreePath) {
|
|
16
22
|
execFileSync('git', ['worktree', 'add', '--detach', worktreePath, 'HEAD'], { cwd: repoDir, stdio: 'pipe' });
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
const readGitConfig = (key, targetDir) => {
|
|
3
|
+
try {
|
|
4
|
+
return execFileSync('git', ['config', '--get', key], { cwd: targetDir, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return '';
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
export function resolveCommitIdentity(targetDir, config, gitConfig = readGitConfig) {
|
|
11
|
+
const authorName = config?.authorName?.trim() || gitConfig('user.name', targetDir).trim();
|
|
12
|
+
const authorEmail = config?.authorEmail?.trim() || gitConfig('user.email', targetDir).trim();
|
|
13
|
+
if (!authorName)
|
|
14
|
+
throw new Error('Commit author name is missing. Set commit.authorName in .yoke/config.yaml or git config user.name.');
|
|
15
|
+
if (!authorEmail)
|
|
16
|
+
throw new Error('Commit author email is missing. Set commit.authorEmail in .yoke/config.yaml or git config user.email.');
|
|
17
|
+
return { authorName, authorEmail, allowCoAuthors: config?.allowCoAuthors ?? false };
|
|
18
|
+
}
|
|
19
|
+
export function sanitizeCommitMessage(message, allowCoAuthors) {
|
|
20
|
+
if (allowCoAuthors)
|
|
21
|
+
return message;
|
|
22
|
+
return message
|
|
23
|
+
.split(/\r?\n/)
|
|
24
|
+
.filter(line => !/^Co-Authored-By:/i.test(line.trim()))
|
|
25
|
+
.join('\n')
|
|
26
|
+
.trim();
|
|
27
|
+
}
|
package/dist/loop/loop.js
CHANGED
|
@@ -139,6 +139,15 @@ export function runLoop(opts) {
|
|
|
139
139
|
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
+
if (opts.audit) {
|
|
143
|
+
reporter.phase('audit');
|
|
144
|
+
const auditVerdict = runGate(opts.audit, wt, story.id);
|
|
145
|
+
if (!auditVerdict.passed) {
|
|
146
|
+
const reason = blockReason(`story ${story.id} failed security audit: ${auditVerdict.summary}`, opts.targetDir, opts.git);
|
|
147
|
+
reporter.blocked(reason);
|
|
148
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
142
151
|
const summary = result.success
|
|
143
152
|
? result.summary
|
|
144
153
|
: `${result.summary} (runner exited non-zero but verify is green)`;
|
|
@@ -162,7 +171,7 @@ export function runLoop(opts) {
|
|
|
162
171
|
});
|
|
163
172
|
const updated = stories.map(s => (s.id === story.id ? { ...s, passes: true } : s));
|
|
164
173
|
savePrd(wtPrd, updated);
|
|
165
|
-
opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}
|
|
174
|
+
opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`, opts.commitIdentity);
|
|
166
175
|
opts.git.integrate(opts.targetDir, wt);
|
|
167
176
|
landed = progress(updated);
|
|
168
177
|
}
|
|
@@ -217,6 +226,15 @@ export function runLoop(opts) {
|
|
|
217
226
|
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
218
227
|
}
|
|
219
228
|
}
|
|
229
|
+
if (opts.audit) {
|
|
230
|
+
reporter.phase('audit');
|
|
231
|
+
const auditVerdict = runGate(opts.audit, opts.targetDir, story.id);
|
|
232
|
+
if (!auditVerdict.passed) {
|
|
233
|
+
const reason = blockReason(`story ${story.id} failed security audit: ${auditVerdict.summary}`, opts.targetDir, opts.git);
|
|
234
|
+
reporter.blocked(reason);
|
|
235
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
220
238
|
const summary = result.success
|
|
221
239
|
? result.summary
|
|
222
240
|
: `${result.summary} (runner exited non-zero but verify is green)`;
|
|
@@ -247,7 +265,7 @@ export function runLoop(opts) {
|
|
|
247
265
|
const updated = onDisk.map(s => (s.id === story.id ? { ...s, passes: true } : s));
|
|
248
266
|
savePrd(opts.prdPath, updated);
|
|
249
267
|
try {
|
|
250
|
-
opts.git.commitAll(opts.targetDir, `yoke: complete ${story.id} ${story.title}
|
|
268
|
+
opts.git.commitAll(opts.targetDir, `yoke: complete ${story.id} ${story.title}`, opts.commitIdentity);
|
|
251
269
|
}
|
|
252
270
|
catch (e) {
|
|
253
271
|
savePrd(opts.prdPath, onDisk); // revert — never persist passes:true without a commit
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export class MergeQueue {
|
|
2
|
+
tail = Promise.resolve();
|
|
3
|
+
enqueue(job) {
|
|
4
|
+
const run = async () => {
|
|
5
|
+
try {
|
|
6
|
+
await job.rebase();
|
|
7
|
+
if (!await job.verify())
|
|
8
|
+
return { storyId: job.storyId, integrated: false, reason: 'integrated-tree verification failed' };
|
|
9
|
+
await job.integrate();
|
|
10
|
+
return { storyId: job.storyId, integrated: true };
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
return { storyId: job.storyId, integrated: false, reason: error.message };
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const result = this.tail.then(run, run);
|
|
17
|
+
this.tail = result.then(() => undefined);
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readyStories } from './scheduler.js';
|
|
2
|
+
export async function runParallelLoop(stories, opts) {
|
|
3
|
+
const completed = [];
|
|
4
|
+
const failed = [];
|
|
5
|
+
const activeAreas = new Set();
|
|
6
|
+
const active = new Map();
|
|
7
|
+
let iterations = 0;
|
|
8
|
+
let agentIndex = 0;
|
|
9
|
+
const launch = (story) => {
|
|
10
|
+
iterations++;
|
|
11
|
+
if (story.area)
|
|
12
|
+
activeAreas.add(story.area);
|
|
13
|
+
const affinity = story.agent ?? (opts.agents?.length ? opts.agents[agentIndex++ % opts.agents.length] : undefined);
|
|
14
|
+
const promise = opts.worker(story, affinity).then(result => {
|
|
15
|
+
if (result.success) {
|
|
16
|
+
story.passes = true;
|
|
17
|
+
completed.push(story.id);
|
|
18
|
+
}
|
|
19
|
+
else
|
|
20
|
+
failed.push(story.id);
|
|
21
|
+
}).catch(() => { failed.push(story.id); }).finally(() => {
|
|
22
|
+
active.delete(story.id);
|
|
23
|
+
if (story.area)
|
|
24
|
+
activeAreas.delete(story.area);
|
|
25
|
+
});
|
|
26
|
+
active.set(story.id, promise);
|
|
27
|
+
};
|
|
28
|
+
while (iterations < opts.maxIterations && !opts.paused?.()) {
|
|
29
|
+
const slots = Math.max(0, opts.maxConcurrency - active.size);
|
|
30
|
+
const ready = readyStories(stories, { activeAreas }).filter(s => !active.has(s.id) && !failed.includes(s.id)).slice(0, slots);
|
|
31
|
+
for (const story of ready)
|
|
32
|
+
launch(story);
|
|
33
|
+
if (active.size === 0)
|
|
34
|
+
break;
|
|
35
|
+
await Promise.race(active.values());
|
|
36
|
+
}
|
|
37
|
+
await Promise.all(active.values());
|
|
38
|
+
return { completed, failed, iterations, paused: opts.paused?.() ?? false };
|
|
39
|
+
}
|
package/dist/loop/prd.js
CHANGED
|
@@ -7,20 +7,66 @@ export const StorySchema = z.object({
|
|
|
7
7
|
priority: z.number(),
|
|
8
8
|
acceptance: z.array(z.string().min(1)),
|
|
9
9
|
passes: z.boolean(),
|
|
10
|
+
needs: z.array(z.string().min(1)).optional(),
|
|
11
|
+
area: z.string().min(1).optional(),
|
|
12
|
+
agent: z.enum(['claude', 'codex', 'gemini']).optional(),
|
|
10
13
|
});
|
|
11
14
|
const PrdSchema = z.array(StorySchema);
|
|
12
15
|
export function loadPrd(file) {
|
|
13
|
-
|
|
16
|
+
const stories = PrdSchema.parse(parse(readFileSync(file, 'utf8')));
|
|
17
|
+
const issues = validateDependencies(stories);
|
|
18
|
+
if (issues.length)
|
|
19
|
+
throw new Error(`Invalid PRD dependency graph:\n${issues.join('\n')}`);
|
|
20
|
+
return stories;
|
|
14
21
|
}
|
|
15
22
|
export function savePrd(file, stories) {
|
|
16
23
|
writeFileSync(file, stringify(stories));
|
|
17
24
|
}
|
|
18
25
|
export function selectNextStory(stories) {
|
|
19
|
-
const
|
|
26
|
+
const passed = new Set(stories.filter(s => s.passes).map(s => s.id));
|
|
27
|
+
const open = stories.filter(s => !s.passes && (s.needs ?? []).every(id => passed.has(id)));
|
|
20
28
|
if (open.length === 0)
|
|
21
29
|
return null;
|
|
22
30
|
return open.reduce((best, s) => (s.priority < best.priority ? s : best));
|
|
23
31
|
}
|
|
32
|
+
export function validateDependencies(stories) {
|
|
33
|
+
const issues = [];
|
|
34
|
+
const counts = new Map();
|
|
35
|
+
for (const story of stories)
|
|
36
|
+
counts.set(story.id, (counts.get(story.id) ?? 0) + 1);
|
|
37
|
+
for (const [id, count] of counts)
|
|
38
|
+
if (count > 1)
|
|
39
|
+
issues.push(`duplicate story id: ${id}`);
|
|
40
|
+
const ids = new Set(stories.map(s => s.id));
|
|
41
|
+
for (const story of stories) {
|
|
42
|
+
for (const need of story.needs ?? []) {
|
|
43
|
+
if (need === story.id)
|
|
44
|
+
issues.push(`story ${story.id} depends on itself`);
|
|
45
|
+
else if (!ids.has(need))
|
|
46
|
+
issues.push(`story ${story.id} has unknown dependency ${need}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const byId = new Map(stories.map(s => [s.id, s]));
|
|
50
|
+
const visiting = new Set();
|
|
51
|
+
const visited = new Set();
|
|
52
|
+
const walk = (id, path) => {
|
|
53
|
+
if (visiting.has(id)) {
|
|
54
|
+
issues.push(`dependency cycle: ${[...path, id].join(' -> ')}`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (visited.has(id))
|
|
58
|
+
return;
|
|
59
|
+
visiting.add(id);
|
|
60
|
+
for (const need of byId.get(id)?.needs ?? [])
|
|
61
|
+
if (byId.has(need) && need !== id)
|
|
62
|
+
walk(need, [...path, id]);
|
|
63
|
+
visiting.delete(id);
|
|
64
|
+
visited.add(id);
|
|
65
|
+
};
|
|
66
|
+
for (const id of byId.keys())
|
|
67
|
+
walk(id, []);
|
|
68
|
+
return [...new Set(issues)];
|
|
69
|
+
}
|
|
24
70
|
export function allPass(stories) {
|
|
25
71
|
return stories.length > 0 && stories.every(s => s.passes);
|
|
26
72
|
}
|
package/dist/loop/run-command.js
CHANGED
|
@@ -9,6 +9,8 @@ import { commandVerifier, retryingVerifier } from './verify.js';
|
|
|
9
9
|
import { readStatus, makeReporter, fmtDuration } from './reporter.js';
|
|
10
10
|
import { acquireLock, releaseLock } from './lock.js';
|
|
11
11
|
import { maybeAutoUpgrade } from '../update/upgrade.js';
|
|
12
|
+
import { resolveCommitIdentity } from './identity.js';
|
|
13
|
+
import { runAudit } from '../audit/command.js';
|
|
12
14
|
export const DEFAULT_IDLE_MINUTES = 20;
|
|
13
15
|
const STALE_MINUTES = 20; // a running status older than this likely means the loop died
|
|
14
16
|
export function relativeTime(fromIso, now) {
|
|
@@ -66,6 +68,10 @@ export function resolveIdleMs(flagMinutes, configMinutes) {
|
|
|
66
68
|
return minutes > 0 ? minutes * 60_000 : 0;
|
|
67
69
|
}
|
|
68
70
|
export function runLoopCommand(targetDir, opts) {
|
|
71
|
+
if ((opts.parallel ?? 1) > 1) {
|
|
72
|
+
console.error('Parallel CLI workers are not enabled yet. The dependency-aware dispatcher and merge queue are available as APIs; use --parallel=1 for the synchronous provider runner.');
|
|
73
|
+
return 2;
|
|
74
|
+
}
|
|
69
75
|
const config = loadConfig(targetDir);
|
|
70
76
|
if (!config?.loop.enabled) {
|
|
71
77
|
console.error('Loop is disabled. Enable it with: yoke loop on');
|
|
@@ -96,7 +102,30 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
96
102
|
maybeAutoUpgrade(config.update?.auto);
|
|
97
103
|
const available = opts.isAvailable ?? isAgentAvailable;
|
|
98
104
|
const runnerAgent = opts.agent ?? config.agents[0] ?? 'claude';
|
|
105
|
+
const git = opts.git ?? realGitOps;
|
|
106
|
+
let commitIdentity = opts.commitIdentity;
|
|
107
|
+
if (!commitIdentity && !opts.git) {
|
|
108
|
+
try {
|
|
109
|
+
commitIdentity = resolveCommitIdentity(targetDir, config.commit);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
console.error(error.message);
|
|
113
|
+
return 2;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
let audit = opts.audit;
|
|
117
|
+
if (!audit && config.audit?.enabled) {
|
|
118
|
+
audit = (dir) => {
|
|
119
|
+
const result = runAudit(dir, { command: config.audit?.command, suppressions: config.audit?.suppressions });
|
|
120
|
+
return { passed: result.code === 0, summary: result.error ?? (result.findings.map(f => `${f.ruleId} ${f.file}${f.line ? `:${f.line}` : ''}`).join(', ') || 'audit passed') };
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (commitIdentity) {
|
|
124
|
+
const announce = opts.json ? console.error : console.log;
|
|
125
|
+
announce(`Commits: ${commitIdentity.authorName} <${commitIdentity.authorEmail}> · co-authors: ${commitIdentity.allowCoAuthors ? 'allowed' : 'disabled'}`);
|
|
126
|
+
}
|
|
99
127
|
const idleMs = resolveIdleMs(opts.timeoutMinutes, config.loop.timeoutMinutes);
|
|
128
|
+
const permissions = opts.permissions ?? config.runner?.permissions ?? 'safe';
|
|
100
129
|
let runner = opts.runner;
|
|
101
130
|
if (!runner) {
|
|
102
131
|
if (!available(runnerAgent)) {
|
|
@@ -109,16 +138,30 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
109
138
|
tokenReport: opts.json === true,
|
|
110
139
|
onAmbiguity: opts.onAmbiguity ?? config.loop.onAmbiguity,
|
|
111
140
|
perfCommand: config.perf?.command,
|
|
141
|
+
permissions,
|
|
112
142
|
});
|
|
143
|
+
const announce = opts.json ? console.error : console.log;
|
|
144
|
+
announce(`Runner: ${runnerAgent} · permissions: ${permissions} · cwd: ${targetDir}`);
|
|
113
145
|
}
|
|
114
146
|
let review = opts.reviewRunner;
|
|
115
147
|
if (!review && (opts.review || opts.reviewer)) {
|
|
116
|
-
const reviewerAgent = opts.reviewer ?? runnerAgent;
|
|
117
|
-
if (!
|
|
118
|
-
|
|
148
|
+
const reviewerAgent = opts.reviewer ?? ['codex', 'gemini', 'claude'].find(agent => agent !== runnerAgent && available(agent));
|
|
149
|
+
if (!reviewerAgent) {
|
|
150
|
+
if (!opts.allowSelfReview) {
|
|
151
|
+
console.error('No independent reviewer CLI is available. Install or select a second agent, or pass --allow-self-review explicitly.');
|
|
152
|
+
return 2;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const resolvedReviewer = reviewerAgent ?? runnerAgent;
|
|
156
|
+
if (resolvedReviewer === runnerAgent && !opts.allowSelfReview) {
|
|
157
|
+
console.error(`Reviewer "${resolvedReviewer}" is also the implementer. Pick another agent or pass --allow-self-review explicitly.`);
|
|
158
|
+
return 2;
|
|
159
|
+
}
|
|
160
|
+
if (!available(resolvedReviewer)) {
|
|
161
|
+
console.error(`Reviewer agent CLI "${resolvedReviewer}" was not found on PATH. Install it, or pick another with --reviewer=<claude|codex|gemini>.`);
|
|
119
162
|
return 2;
|
|
120
163
|
}
|
|
121
|
-
review = makeReviewRunner(
|
|
164
|
+
review = makeReviewRunner(resolvedReviewer, idleMs);
|
|
122
165
|
}
|
|
123
166
|
const lock = acquireLock(targetDir);
|
|
124
167
|
if (!lock.acquired) {
|
|
@@ -133,11 +176,13 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
133
176
|
prdPath: path,
|
|
134
177
|
targetDir,
|
|
135
178
|
runner,
|
|
136
|
-
git
|
|
179
|
+
git,
|
|
180
|
+
commitIdentity,
|
|
137
181
|
verify,
|
|
138
182
|
perf,
|
|
183
|
+
audit,
|
|
139
184
|
maxIterations: opts.maxIterations,
|
|
140
|
-
isolate: opts.isolate ?? false,
|
|
185
|
+
isolate: (opts.parallel ?? 1) > 1 ? true : (opts.isolate ?? false),
|
|
141
186
|
review,
|
|
142
187
|
reporter: opts.reporter ?? makeReporter(targetDir, { json: opts.json }),
|
|
143
188
|
});
|