@hecer/yoke 0.9.0 → 1.1.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 (86) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/.codex-plugin/plugin.json +7 -0
  4. package/CHANGELOG.md +192 -149
  5. package/README.md +101 -51
  6. package/TODOS.md +8 -0
  7. package/agents/docs.toml +6 -0
  8. package/agents/implementer.toml +6 -0
  9. package/agents/reviewer.toml +6 -0
  10. package/agents/security.toml +6 -0
  11. package/bench/README.md +45 -42
  12. package/bench/RESULTS.md +46 -36
  13. package/bench/result-schema.mjs +12 -0
  14. package/bench/results/claude-2026-07-27T18-03-26.json +50 -0
  15. package/bench/results/codex-unavailable-1785175418318.json +15 -0
  16. package/bench/results/gemini-2026-07-27T18-03-44.json +46 -0
  17. package/bench/run-matrix.mjs +26 -0
  18. package/bench/run.mjs +127 -115
  19. package/canon/AGENTS.md +2 -0
  20. package/canon/loop/loop-spec.md +4 -2
  21. package/canon/loop/prd.schema.md +5 -0
  22. package/canon/manifest.yaml +2 -1
  23. package/canon/skills/authoring-prd/SKILL.md +10 -3
  24. package/canon/skills/ship/SKILL.md +2 -7
  25. package/canon/skills/workflow/SKILL.md +4 -0
  26. package/canon/skills/yoke-retrofit/SKILL.md +18 -11
  27. package/canon/skills/yoke-workflow/SKILL.md +20 -0
  28. package/canon/tools/codex-rtk-hook.mjs +36 -0
  29. package/dist/agents/host.js +26 -0
  30. package/dist/agents/providers.js +23 -0
  31. package/dist/agents/telemetry.js +30 -0
  32. package/dist/agents/types.js +1 -0
  33. package/dist/audit/changes.js +6 -0
  34. package/dist/audit/command.js +64 -0
  35. package/dist/audit/dependencies.js +21 -0
  36. package/dist/audit/secrets.js +16 -0
  37. package/dist/audit/types.js +1 -0
  38. package/dist/cli.js +189 -6
  39. package/dist/context/context.js +15 -2
  40. package/dist/loop/claims.js +57 -0
  41. package/dist/loop/cleanup.js +98 -27
  42. package/dist/loop/decision.js +517 -0
  43. package/dist/loop/git.js +31 -2
  44. package/dist/loop/identity.js +27 -0
  45. package/dist/loop/lock.js +104 -13
  46. package/dist/loop/loop.js +49 -2
  47. package/dist/loop/merge-queue.js +20 -0
  48. package/dist/loop/parallel.js +39 -0
  49. package/dist/loop/prd.js +48 -2
  50. package/dist/loop/run-command.js +118 -12
  51. package/dist/loop/runner.js +48 -30
  52. package/dist/loop/scheduler.js +8 -0
  53. package/dist/prd/command.js +30 -21
  54. package/dist/retrofit/command.js +3 -2
  55. package/dist/retrofit/config.js +16 -0
  56. package/dist/retrofit/gitignore.js +8 -0
  57. package/dist/retrofit/planners/codex.js +64 -19
  58. package/dist/retrofit/report.js +1 -1
  59. package/dist/review/command.js +52 -12
  60. package/dist/review/verdict.js +45 -0
  61. package/dist/setup/command.js +82 -0
  62. package/docs/MIGRATING-TO-1.0.md +33 -0
  63. package/docs/MIGRATING-TO-1.1.md +27 -0
  64. package/docs/PUBLISHING.md +77 -41
  65. package/docs/superpowers/plans/2026-07-27-yoke-1.0-release.md +205 -0
  66. package/docs/superpowers/specs/2026-07-27-yoke-1.0-hardening-and-codex-parity-design.md +164 -0
  67. package/gemini-extension.json +6 -0
  68. package/hooks/hooks.json +19 -0
  69. package/package.json +84 -67
  70. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/config.yaml +0 -6
  71. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/context/DECISIONS.md +0 -9
  72. package/bench/.runs/claude-2026-07-09T22-34-01/.yoke/prd.yaml +0 -38
  73. package/bench/.runs/claude-2026-07-09T22-34-01/bench-verify.mjs +0 -15
  74. package/bench/.runs/claude-2026-07-09T22-34-01/package.json +0 -9
  75. package/bench/.runs/claude-2026-07-09T22-34-01/src/index.mjs +0 -48
  76. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-1.test.mjs +0 -24
  77. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-2.test.mjs +0 -28
  78. package/bench/.runs/claude-2026-07-09T22-34-01/tests/STORY-3.test.mjs +0 -25
  79. package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/config.yaml +0 -6
  80. package/bench/.runs/gemini-2026-07-09T22-34-02/.yoke/prd.yaml +0 -32
  81. package/bench/.runs/gemini-2026-07-09T22-34-02/bench-verify.mjs +0 -15
  82. package/bench/.runs/gemini-2026-07-09T22-34-02/package.json +0 -9
  83. package/bench/.runs/gemini-2026-07-09T22-34-02/src/index.mjs +0 -3
  84. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-1.test.mjs +0 -24
  85. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-2.test.mjs +0 -28
  86. package/bench/.runs/gemini-2026-07-09T22-34-02/tests/STORY-3.test.mjs +0 -25
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' });
@@ -23,3 +29,26 @@ export const realGitOps = {
23
29
  execFileSync('git', ['merge', '--ff-only', sha], { cwd: repoDir, stdio: 'pipe' });
24
30
  },
25
31
  };
32
+ export function commitPaths(dir, paths, message, identity) {
33
+ if (paths.length === 0)
34
+ throw new Error('no commit paths supplied');
35
+ const identityArgs = identity
36
+ ? ['-c', `user.name=${identity.authorName}`, '-c', `user.email=${identity.authorEmail}`]
37
+ : [];
38
+ const authorArgs = identity ? ['--author', `${identity.authorName} <${identity.authorEmail}>`] : [];
39
+ const cleanMessage = sanitizeCommitMessage(message, identity?.allowCoAuthors ?? false);
40
+ execFileSync('git', ['add', '--', ...paths], { cwd: dir, stdio: 'pipe' });
41
+ try {
42
+ const staged = execFileSync('git', ['diff', '--cached', '--name-only', '--', ...paths], { cwd: dir, stdio: 'pipe' }).toString().trim();
43
+ if (!staged)
44
+ throw new Error('nothing to commit for selected paths');
45
+ execFileSync('git', [...identityArgs, '-c', 'commit.gpgsign=false', 'commit', '--only', ...authorArgs, '-m', cleanMessage, '--', ...paths], { cwd: dir, stdio: 'pipe' });
46
+ }
47
+ catch (error) {
48
+ try {
49
+ execFileSync('git', ['reset', '--quiet', '--', ...paths], { cwd: dir, stdio: 'pipe' });
50
+ }
51
+ catch { /* best effort */ }
52
+ throw error;
53
+ }
54
+ }
@@ -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/lock.js CHANGED
@@ -1,8 +1,15 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
1
+ import { existsSync, linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
3
4
  export function lockPath(targetDir) {
4
5
  return join(targetDir, '.yoke', 'loop.lock');
5
6
  }
7
+ export function takeoverLockPath(targetDir) {
8
+ return join(targetDir, '.yoke', 'loop.lock.takeover');
9
+ }
10
+ export function takeoverRecoveryPath(targetDir) {
11
+ return join(targetDir, '.yoke', 'loop.lock.takeover.recovery');
12
+ }
6
13
  // Liveness probe via signal 0. EPERM means "exists but not ours" — alive.
7
14
  export function isPidAlive(pid) {
8
15
  if (!Number.isInteger(pid) || pid <= 0)
@@ -15,8 +22,7 @@ export function isPidAlive(pid) {
15
22
  return e.code === 'EPERM';
16
23
  }
17
24
  }
18
- export function readLock(targetDir) {
19
- const file = lockPath(targetDir);
25
+ function readLockFile(file) {
20
26
  if (!existsSync(file))
21
27
  return null;
22
28
  try {
@@ -27,19 +33,104 @@ export function readLock(targetDir) {
27
33
  return null;
28
34
  }
29
35
  }
36
+ export function readLock(targetDir) {
37
+ return readLockFile(lockPath(targetDir));
38
+ }
39
+ // Publish complete metadata in one atomic namespace operation. Unlike open('wx')
40
+ // followed by write(), contenders can never observe an empty/partial lock file.
41
+ function publishLock(file, info) {
42
+ const temp = `${file}.${info.ownerToken ?? randomUUID()}.tmp`;
43
+ writeFileSync(temp, JSON.stringify(info), { flag: 'wx' });
44
+ try {
45
+ linkSync(temp, file);
46
+ return true;
47
+ }
48
+ catch (error) {
49
+ const code = error.code;
50
+ if (code === 'EEXIST')
51
+ return false;
52
+ if (code === 'EPERM' || code === 'ENOTSUP' || code === 'EXDEV' || code === 'EACCES') {
53
+ throw new Error(`Yoke cannot publish an atomic loop lock on this filesystem (${code}). Move the project to a filesystem with hard-link support.`);
54
+ }
55
+ throw error;
56
+ }
57
+ finally {
58
+ rmSync(temp, { force: true });
59
+ }
60
+ }
61
+ function releaseLockFile(file, ownerToken) {
62
+ if (!ownerToken)
63
+ return;
64
+ try {
65
+ if (readLockFile(file)?.ownerToken === ownerToken)
66
+ rmSync(file, { force: true });
67
+ }
68
+ catch { /* best-effort */ }
69
+ }
70
+ export function acquireTakeoverLease(targetDir, pid = process.pid) {
71
+ const file = takeoverLockPath(targetDir);
72
+ mkdirSync(dirname(file), { recursive: true });
73
+ if (existsSync(takeoverRecoveryPath(targetDir))) {
74
+ return { acquired: false, holderPid: readLockFile(takeoverRecoveryPath(targetDir))?.pid };
75
+ }
76
+ const ownerToken = randomUUID();
77
+ const lease = { pid, startedAt: new Date().toISOString(), ownerToken };
78
+ return publishLock(file, lease)
79
+ ? { acquired: true, ownerToken }
80
+ : { acquired: false, holderPid: readLockFile(file)?.pid };
81
+ }
82
+ export function releaseTakeoverLease(targetDir, ownerToken) {
83
+ releaseLockFile(takeoverLockPath(targetDir), ownerToken);
84
+ }
85
+ export function acquireTakeoverRecoveryLease(targetDir, pid = process.pid) {
86
+ const file = takeoverRecoveryPath(targetDir);
87
+ mkdirSync(dirname(file), { recursive: true });
88
+ const ownerToken = randomUUID();
89
+ const lease = { pid, startedAt: new Date().toISOString(), ownerToken };
90
+ return publishLock(file, lease)
91
+ ? { acquired: true, ownerToken }
92
+ : { acquired: false, holderPid: readLockFile(file)?.pid };
93
+ }
94
+ export function releaseTakeoverRecoveryLease(targetDir, ownerToken) {
95
+ releaseLockFile(takeoverRecoveryPath(targetDir), ownerToken);
96
+ }
30
97
  export function acquireLock(targetDir, pid = process.pid) {
31
98
  const file = lockPath(targetDir);
32
- const holder = readLock(targetDir);
33
- if (holder && isPidAlive(holder.pid))
34
- return { acquired: false, holderPid: holder.pid };
99
+ const takeoverFile = takeoverLockPath(targetDir);
35
100
  mkdirSync(dirname(file), { recursive: true });
36
- writeFileSync(file, JSON.stringify({ pid, startedAt: new Date().toISOString() }));
37
- return holder ? { acquired: true, stalePid: holder.pid } : { acquired: true };
38
- }
39
- // Best-effort: a missing file or an unwritable disk must never crash loop teardown.
40
- export function releaseLock(targetDir) {
101
+ const ownerToken = randomUUID();
102
+ const candidate = { pid, startedAt: new Date().toISOString(), ownerToken };
103
+ // A stale-lock owner is currently being revalidated. Joining that race would
104
+ // let a late contender publish between removal and replacement.
105
+ const recoveryFile = takeoverRecoveryPath(targetDir);
106
+ if (existsSync(takeoverFile) || existsSync(recoveryFile)) {
107
+ return { acquired: false, holderPid: readLockFile(takeoverFile)?.pid ?? readLockFile(recoveryFile)?.pid };
108
+ }
109
+ if (publishLock(file, candidate))
110
+ return { acquired: true, ownerToken };
111
+ const observed = readLock(targetDir);
112
+ if (observed && isPidAlive(observed.pid))
113
+ return { acquired: false, holderPid: observed.pid };
114
+ // Serialize stale takeover with a second, atomically published lease. A
115
+ // crashed takeover lease is deliberately recovered by `yoke loop cleanup`;
116
+ // guessing here would recreate the same TOCTOU race one level deeper.
117
+ const takeover = acquireTakeoverLease(targetDir, pid);
118
+ if (!takeover.acquired)
119
+ return { ...takeover, stalePid: observed?.pid };
41
120
  try {
42
- rmSync(lockPath(targetDir), { force: true });
121
+ const current = readLock(targetDir);
122
+ if (current && isPidAlive(current.pid))
123
+ return { acquired: false, holderPid: current.pid, stalePid: observed?.pid };
124
+ rmSync(file, { force: true });
125
+ if (publishLock(file, candidate))
126
+ return { acquired: true, stalePid: current?.pid ?? observed?.pid, ownerToken };
127
+ return { acquired: false, holderPid: readLock(targetDir)?.pid, stalePid: current?.pid ?? observed?.pid };
43
128
  }
44
- catch { /* best-effort */ }
129
+ finally {
130
+ releaseTakeoverLease(targetDir, takeover.ownerToken);
131
+ }
132
+ }
133
+ // Best-effort and ownership-checked: teardown never deletes a successor's lock.
134
+ export function releaseLock(targetDir, ownerToken) {
135
+ releaseLockFile(lockPath(targetDir), ownerToken);
45
136
  }
package/dist/loop/loop.js CHANGED
@@ -4,6 +4,7 @@ import { loadPrd, savePrd, selectNextStory, allPass, progress } from './prd.js';
4
4
  import { stopTheLineGate, preDispatchGate } from './gates.js';
5
5
  import { appendDecision, contextDir } from '../context/context.js';
6
6
  import { noopReporter } from './reporter.js';
7
+ import { consumeDecisionRequest } from './decision.js';
7
8
  function blockReason(base, targetDir, git) {
8
9
  let dirty = false;
9
10
  try {
@@ -112,6 +113,20 @@ export function runLoop(opts) {
112
113
  iterations++;
113
114
  if (result.tokens)
114
115
  reporter.addTokens(result.tokens);
116
+ let decision;
117
+ try {
118
+ decision = consumeDecisionRequest(wt, opts.targetDir, story.id);
119
+ }
120
+ catch (error) {
121
+ const reason = `invalid critical decision request for story ${story.id}: ${error.message}`;
122
+ reporter.blocked(reason);
123
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
124
+ }
125
+ if (decision) {
126
+ const reason = `critical decision required for story ${story.id}: ${decision.question}`;
127
+ reporter.blocked(reason);
128
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
129
+ }
115
130
  const ambiguity = consumeAmbiguity(wt);
116
131
  if (ambiguity) {
117
132
  const reason = `story ${story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`;
@@ -139,6 +154,15 @@ export function runLoop(opts) {
139
154
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
140
155
  }
141
156
  }
157
+ if (opts.audit) {
158
+ reporter.phase('audit');
159
+ const auditVerdict = runGate(opts.audit, wt, story.id);
160
+ if (!auditVerdict.passed) {
161
+ const reason = blockReason(`story ${story.id} failed security audit: ${auditVerdict.summary}`, opts.targetDir, opts.git);
162
+ reporter.blocked(reason);
163
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
164
+ }
165
+ }
142
166
  const summary = result.success
143
167
  ? result.summary
144
168
  : `${result.summary} (runner exited non-zero but verify is green)`;
@@ -162,7 +186,7 @@ export function runLoop(opts) {
162
186
  });
163
187
  const updated = stories.map(s => (s.id === story.id ? { ...s, passes: true } : s));
164
188
  savePrd(wtPrd, updated);
165
- opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`);
189
+ opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`, opts.commitIdentity);
166
190
  opts.git.integrate(opts.targetDir, wt);
167
191
  landed = progress(updated);
168
192
  }
@@ -185,6 +209,20 @@ export function runLoop(opts) {
185
209
  iterations++;
186
210
  if (result.tokens)
187
211
  reporter.addTokens(result.tokens);
212
+ let decision;
213
+ try {
214
+ decision = consumeDecisionRequest(opts.targetDir, opts.targetDir, story.id);
215
+ }
216
+ catch (error) {
217
+ const reason = `invalid critical decision request for story ${story.id}: ${error.message}`;
218
+ reporter.blocked(reason);
219
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
220
+ }
221
+ if (decision) {
222
+ const reason = `critical decision required for story ${story.id}: ${decision.question}`;
223
+ reporter.blocked(reason);
224
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
225
+ }
188
226
  const ambiguity = consumeAmbiguity(opts.targetDir);
189
227
  if (ambiguity) {
190
228
  const reason = `story ${story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`;
@@ -217,6 +255,15 @@ export function runLoop(opts) {
217
255
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
218
256
  }
219
257
  }
258
+ if (opts.audit) {
259
+ reporter.phase('audit');
260
+ const auditVerdict = runGate(opts.audit, opts.targetDir, story.id);
261
+ if (!auditVerdict.passed) {
262
+ const reason = blockReason(`story ${story.id} failed security audit: ${auditVerdict.summary}`, opts.targetDir, opts.git);
263
+ reporter.blocked(reason);
264
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
265
+ }
266
+ }
220
267
  const summary = result.success
221
268
  ? result.summary
222
269
  : `${result.summary} (runner exited non-zero but verify is green)`;
@@ -247,7 +294,7 @@ export function runLoop(opts) {
247
294
  const updated = onDisk.map(s => (s.id === story.id ? { ...s, passes: true } : s));
248
295
  savePrd(opts.prdPath, updated);
249
296
  try {
250
- opts.git.commitAll(opts.targetDir, `yoke: complete ${story.id} ${story.title}`);
297
+ opts.git.commitAll(opts.targetDir, `yoke: complete ${story.id} ${story.title}`, opts.commitIdentity);
251
298
  }
252
299
  catch (e) {
253
300
  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,10 @@ 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';
14
+ import { detectHostAgent, resolveRunnerAgent } from '../agents/host.js';
15
+ import { clearDecisionResume, decisionProcessingExists, decisionRequestId, formatPendingDecision, readPendingDecision, writeDecisionResume, } from './decision.js';
12
16
  export const DEFAULT_IDLE_MINUTES = 20;
13
17
  const STALE_MINUTES = 20; // a running status older than this likely means the loop died
14
18
  export function relativeTime(fromIso, now) {
@@ -30,7 +34,7 @@ export function prdPath(targetDir) {
30
34
  export function setLoopEnabled(targetDir, enabled) {
31
35
  // TODO(C2): resolve bundled canon version instead of placeholder
32
36
  const config = loadConfig(targetDir) ?? defaultConfig('0.0.0');
33
- config.loop = { enabled };
37
+ config.loop = { ...config.loop, enabled };
34
38
  saveConfig(targetDir, config);
35
39
  }
36
40
  export function loopStatus(targetDir, now = () => new Date()) {
@@ -66,11 +70,29 @@ export function resolveIdleMs(flagMinutes, configMinutes) {
66
70
  return minutes > 0 ? minutes * 60_000 : 0;
67
71
  }
68
72
  export function runLoopCommand(targetDir, opts) {
73
+ if ((opts.parallel ?? 1) > 1) {
74
+ 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.');
75
+ return 2;
76
+ }
69
77
  const config = loadConfig(targetDir);
70
78
  if (!config?.loop.enabled) {
71
79
  console.error('Loop is disabled. Enable it with: yoke loop on');
72
80
  return 2;
73
81
  }
82
+ if (decisionProcessingExists(targetDir)) {
83
+ console.error(`A critical decision answer needs recovery. Run: yoke loop answer ${targetDir} --choice=<id>`);
84
+ return 1;
85
+ }
86
+ try {
87
+ if (readPendingDecision(targetDir)) {
88
+ console.error(`${formatPendingDecision(targetDir)}\nAnswer it with: yoke loop answer ${targetDir} --choice=<id>`);
89
+ return 1;
90
+ }
91
+ }
92
+ catch (error) {
93
+ console.error(`Invalid pending Yoke decision: ${error.message}`);
94
+ return 1;
95
+ }
74
96
  const path = prdPath(targetDir);
75
97
  if (!existsSync(path)) {
76
98
  console.error(`No PRD found at ${path}. Create one (see canon loop/prd.schema.md).`);
@@ -95,8 +117,31 @@ export function runLoopCommand(targetDir, opts) {
95
117
  // it started with; a fetched upgrade applies from the next invocation.
96
118
  maybeAutoUpgrade(config.update?.auto);
97
119
  const available = opts.isAvailable ?? isAgentAvailable;
98
- const runnerAgent = opts.agent ?? config.agents[0] ?? 'claude';
120
+ const runnerAgent = resolveRunnerAgent(config, opts.agent, detectHostAgent());
121
+ const git = opts.git ?? realGitOps;
122
+ let commitIdentity = opts.commitIdentity;
123
+ if (!commitIdentity && !opts.git) {
124
+ try {
125
+ commitIdentity = resolveCommitIdentity(targetDir, config.commit);
126
+ }
127
+ catch (error) {
128
+ console.error(error.message);
129
+ return 2;
130
+ }
131
+ }
132
+ let audit = opts.audit;
133
+ if (!audit && config.audit?.enabled) {
134
+ audit = (dir) => {
135
+ const result = runAudit(dir, { command: config.audit?.command, suppressions: config.audit?.suppressions });
136
+ return { passed: result.code === 0, summary: result.error ?? (result.findings.map(f => `${f.ruleId} ${f.file}${f.line ? `:${f.line}` : ''}`).join(', ') || 'audit passed') };
137
+ };
138
+ }
139
+ if (commitIdentity) {
140
+ const announce = opts.json ? console.error : console.log;
141
+ announce(`Commits: ${commitIdentity.authorName} <${commitIdentity.authorEmail}> · co-authors: ${commitIdentity.allowCoAuthors ? 'allowed' : 'disabled'}`);
142
+ }
99
143
  const idleMs = resolveIdleMs(opts.timeoutMinutes, config.loop.timeoutMinutes);
144
+ const permissions = opts.permissions ?? config.runner?.permissions ?? 'safe';
100
145
  let runner = opts.runner;
101
146
  if (!runner) {
102
147
  if (!available(runnerAgent)) {
@@ -107,20 +152,41 @@ export function runLoopCommand(targetDir, opts) {
107
152
  // runner switches to stream-json so cumulative usage rides on every status.
108
153
  runner = makeRunner(runnerAgent, idleMs, {
109
154
  tokenReport: opts.json === true,
110
- onAmbiguity: opts.onAmbiguity ?? config.loop.onAmbiguity,
155
+ onAmbiguity: opts.decisionPolicy ?? opts.onAmbiguity ?? config.loop.decisionPolicy ?? config.loop.onAmbiguity ?? 'auto',
111
156
  perfCommand: config.perf?.command,
157
+ permissions,
112
158
  });
159
+ const announce = opts.json ? console.error : console.log;
160
+ announce(`Runner: ${runnerAgent} · permissions: ${permissions} · cwd: ${targetDir}`);
113
161
  }
114
162
  let review = opts.reviewRunner;
115
163
  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>.`);
164
+ const reviewerAgent = opts.reviewer ?? ['codex', 'gemini', 'claude'].find(agent => agent !== runnerAgent && available(agent));
165
+ if (!reviewerAgent) {
166
+ if (!opts.allowSelfReview) {
167
+ console.error('No independent reviewer CLI is available. Install or select a second agent, or pass --allow-self-review explicitly.');
168
+ return 2;
169
+ }
170
+ }
171
+ const resolvedReviewer = reviewerAgent ?? runnerAgent;
172
+ if (resolvedReviewer === runnerAgent && !opts.allowSelfReview) {
173
+ console.error(`Reviewer "${resolvedReviewer}" is also the implementer. Pick another agent or pass --allow-self-review explicitly.`);
174
+ return 2;
175
+ }
176
+ if (!available(resolvedReviewer)) {
177
+ console.error(`Reviewer agent CLI "${resolvedReviewer}" was not found on PATH. Install it, or pick another with --reviewer=<claude|codex|gemini>.`);
119
178
  return 2;
120
179
  }
121
- review = makeReviewRunner(reviewerAgent, idleMs);
180
+ review = makeReviewRunner(resolvedReviewer, idleMs);
181
+ }
182
+ let lock;
183
+ try {
184
+ lock = acquireLock(targetDir);
185
+ }
186
+ catch (error) {
187
+ console.error(`Cannot acquire the Yoke loop lock: ${error.message}`);
188
+ return 2;
122
189
  }
123
- const lock = acquireLock(targetDir);
124
190
  if (!lock.acquired) {
125
191
  console.error(`Another loop is already running here (pid ${lock.holderPid}). If that is wrong, run: yoke loop cleanup`);
126
192
  return 2;
@@ -129,18 +195,58 @@ export function runLoopCommand(targetDir, opts) {
129
195
  console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
130
196
  }
131
197
  try {
198
+ const reporter = opts.reporter ?? makeReporter(targetDir, { json: opts.json });
132
199
  const result = runLoop({
133
200
  prdPath: path,
134
201
  targetDir,
135
202
  runner,
136
- git: opts.git ?? realGitOps,
203
+ git,
204
+ commitIdentity,
137
205
  verify,
138
206
  perf,
207
+ audit,
139
208
  maxIterations: opts.maxIterations,
140
- isolate: opts.isolate ?? false,
209
+ isolate: (opts.parallel ?? 1) > 1 ? true : (opts.isolate ?? false),
141
210
  review,
142
- reporter: opts.reporter ?? makeReporter(targetDir, { json: opts.json }),
211
+ reporter,
143
212
  });
213
+ try {
214
+ const pendingDecision = readPendingDecision(targetDir);
215
+ if (pendingDecision) {
216
+ writeDecisionResume(targetDir, {
217
+ version: 1,
218
+ storyId: pendingDecision.storyId,
219
+ requestId: decisionRequestId(pendingDecision),
220
+ answered: false,
221
+ maxIterations: opts.maxIterations,
222
+ agent: runnerAgent,
223
+ isolate: opts.isolate ?? false,
224
+ reviewer: opts.reviewer,
225
+ review: opts.review === true || opts.reviewRunner !== undefined,
226
+ allowSelfReview: opts.allowSelfReview ?? false,
227
+ timeoutMinutes: opts.timeoutMinutes ?? config.loop.timeoutMinutes,
228
+ json: opts.json ?? false,
229
+ onAmbiguity: opts.decisionPolicy
230
+ ? undefined
231
+ : opts.onAmbiguity === 'resolve' || opts.onAmbiguity === 'abort'
232
+ ? opts.onAmbiguity
233
+ : (config.loop.decisionPolicy ? undefined : config.loop.onAmbiguity),
234
+ decisionPolicy: opts.decisionPolicy
235
+ ?? (opts.onAmbiguity
236
+ ? (opts.onAmbiguity === 'auto' || opts.onAmbiguity === 'critical' ? opts.onAmbiguity : undefined)
237
+ : config.loop.decisionPolicy),
238
+ permissions,
239
+ parallel: opts.parallel ?? 1,
240
+ });
241
+ }
242
+ else
243
+ clearDecisionResume(targetDir);
244
+ }
245
+ catch (error) {
246
+ const reason = `could not persist trusted decision resume state: ${error.message}`;
247
+ reporter.blocked(reason);
248
+ return 1;
249
+ }
144
250
  // In json mode stdout belongs to the NDJSON stream — route the narrative summary to stderr.
145
251
  const say = opts.json ? (line) => console.error(line) : (line) => console.log(line);
146
252
  say(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`);
@@ -157,6 +263,6 @@ export function runLoopCommand(targetDir, opts) {
157
263
  return 1;
158
264
  }
159
265
  finally {
160
- releaseLock(targetDir);
266
+ releaseLock(targetDir, lock.ownerToken);
161
267
  }
162
268
  }