@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.
- package/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +13 -0
- package/.codex-plugin/plugin.json +7 -0
- package/CHANGELOG.md +192 -149
- package/README.md +101 -51
- 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/AGENTS.md +2 -0
- package/canon/loop/loop-spec.md +4 -2
- package/canon/loop/prd.schema.md +5 -0
- package/canon/manifest.yaml +2 -1
- package/canon/skills/authoring-prd/SKILL.md +10 -3
- package/canon/skills/ship/SKILL.md +2 -7
- package/canon/skills/workflow/SKILL.md +4 -0
- package/canon/skills/yoke-retrofit/SKILL.md +18 -11
- package/canon/skills/yoke-workflow/SKILL.md +20 -0
- package/canon/tools/codex-rtk-hook.mjs +36 -0
- package/dist/agents/host.js +26 -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 +189 -6
- package/dist/context/context.js +15 -2
- package/dist/loop/claims.js +57 -0
- package/dist/loop/cleanup.js +98 -27
- package/dist/loop/decision.js +517 -0
- package/dist/loop/git.js +31 -2
- package/dist/loop/identity.js +27 -0
- package/dist/loop/lock.js +104 -13
- package/dist/loop/loop.js +49 -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 +118 -12
- package/dist/loop/runner.js +48 -30
- package/dist/loop/scheduler.js +8 -0
- package/dist/prd/command.js +30 -21
- package/dist/retrofit/command.js +3 -2
- package/dist/retrofit/config.js +16 -0
- package/dist/retrofit/gitignore.js +8 -0
- package/dist/retrofit/planners/codex.js +64 -19
- package/dist/retrofit/report.js +1 -1
- package/dist/review/command.js +52 -12
- package/dist/review/verdict.js +45 -0
- package/dist/setup/command.js +82 -0
- package/docs/MIGRATING-TO-1.0.md +33 -0
- package/docs/MIGRATING-TO-1.1.md +27 -0
- package/docs/PUBLISHING.md +77 -41
- 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/gemini-extension.json +6 -0
- package/hooks/hooks.json +19 -0
- package/package.json +84 -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
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' });
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
|
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 (!
|
|
118
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
}
|