@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.
Files changed (68) hide show
  1. package/.codex-plugin/plugin.json +7 -0
  2. package/CHANGELOG.md +169 -149
  3. package/README.md +24 -16
  4. package/TODOS.md +8 -0
  5. package/agents/docs.toml +6 -0
  6. package/agents/implementer.toml +6 -0
  7. package/agents/reviewer.toml +6 -0
  8. package/agents/security.toml +6 -0
  9. package/bench/README.md +45 -42
  10. package/bench/RESULTS.md +46 -36
  11. package/bench/result-schema.mjs +12 -0
  12. package/bench/results/claude-2026-07-27T18-03-26.json +50 -0
  13. package/bench/results/codex-unavailable-1785175418318.json +15 -0
  14. package/bench/results/gemini-2026-07-27T18-03-44.json +46 -0
  15. package/bench/run-matrix.mjs +26 -0
  16. package/bench/run.mjs +127 -115
  17. package/canon/loop/prd.schema.md +5 -0
  18. package/canon/manifest.yaml +1 -1
  19. package/canon/skills/authoring-prd/SKILL.md +6 -0
  20. package/canon/skills/ship/SKILL.md +2 -7
  21. package/canon/tools/codex-rtk-hook.mjs +36 -0
  22. package/dist/agents/providers.js +23 -0
  23. package/dist/agents/telemetry.js +30 -0
  24. package/dist/agents/types.js +1 -0
  25. package/dist/audit/changes.js +6 -0
  26. package/dist/audit/command.js +64 -0
  27. package/dist/audit/dependencies.js +21 -0
  28. package/dist/audit/secrets.js +16 -0
  29. package/dist/audit/types.js +1 -0
  30. package/dist/cli.js +22 -4
  31. package/dist/loop/claims.js +57 -0
  32. package/dist/loop/cleanup.js +10 -4
  33. package/dist/loop/git.js +8 -2
  34. package/dist/loop/identity.js +27 -0
  35. package/dist/loop/loop.js +20 -2
  36. package/dist/loop/merge-queue.js +20 -0
  37. package/dist/loop/parallel.js +39 -0
  38. package/dist/loop/prd.js +48 -2
  39. package/dist/loop/run-command.js +51 -6
  40. package/dist/loop/runner.js +41 -29
  41. package/dist/loop/scheduler.js +8 -0
  42. package/dist/prd/command.js +6 -0
  43. package/dist/retrofit/config.js +12 -0
  44. package/dist/retrofit/planners/codex.js +64 -19
  45. package/dist/review/command.js +52 -12
  46. package/dist/review/verdict.js +45 -0
  47. package/docs/MIGRATING-TO-1.0.md +33 -0
  48. package/docs/superpowers/plans/2026-07-27-yoke-1.0-release.md +205 -0
  49. package/docs/superpowers/specs/2026-07-27-yoke-1.0-hardening-and-codex-parity-design.md +164 -0
  50. package/hooks/hooks.json +19 -0
  51. package/package.json +82 -67
  52. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/config.yaml +0 -6
  53. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/context/DECISIONS.md +0 -9
  54. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/prd.yaml +0 -38
  55. package/bench/.runs/claude-2026-07-09T22-34-01/bench-verify.mjs +0 -15
  56. package/bench/.runs/claude-2026-07-09T22-34-01/package.json +0 -9
  57. package/bench/.runs/claude-2026-07-09T22-34-01/src/index.mjs +0 -48
  58. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-1.test.mjs +0 -24
  59. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-2.test.mjs +0 -28
  60. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-3.test.mjs +0 -25
  61. package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/config.yaml +0 -6
  62. package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/prd.yaml +0 -32
  63. package/bench/.runs/gemini-2026-07-09T22-34-02/bench-verify.mjs +0 -15
  64. package/bench/.runs/gemini-2026-07-09T22-34-02/package.json +0 -9
  65. package/bench/.runs/gemini-2026-07-09T22-34-02/src/index.mjs +0 -3
  66. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-1.test.mjs +0 -24
  67. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-2.test.mjs +0 -28
  68. 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
+ }
@@ -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
- try {
66
- git(['worktree', 'prune'], targetDir);
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 ? 'Nothing to clean.' : `Removed ${removed} worktree(s)${failed > 0 ? `, ${failed} failed` : ''}.`);
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
- execFileSync('git', ['-c', 'commit.gpgsign=false', 'commit', '-m', message], { cwd: dir, stdio: 'pipe' });
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
- return PrdSchema.parse(parse(readFileSync(file, 'utf8')));
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 open = stories.filter(s => !s.passes);
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
  }
@@ -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 (!available(reviewerAgent)) {
118
- console.error(`Reviewer agent CLI "${reviewerAgent}" was not found on PATH. Install it, or pick another with --reviewer=<claude|codex|gemini>.`);
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(reviewerAgent, idleMs);
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: opts.git ?? realGitOps,
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
  });