@hecer/yoke 0.2.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/LICENSE +21 -0
- package/README.md +494 -0
- package/canon/AGENTS.md +28 -0
- package/canon/context/DECISIONS.md +4 -0
- package/canon/context/KNOWLEDGE.md +4 -0
- package/canon/context/PROJECT.md +15 -0
- package/canon/loop/loop-spec.md +30 -0
- package/canon/loop/prd.schema.md +14 -0
- package/canon/manifest.yaml +47 -0
- package/canon/policy/gates.md +7 -0
- package/canon/policy/roles.md +9 -0
- package/canon/skills/ATTRIBUTION.md +71 -0
- package/canon/skills/authoring-prd/SKILL.md +44 -0
- package/canon/skills/brainstorming/SKILL.md +164 -0
- package/canon/skills/dispatching-parallel-agents/SKILL.md +182 -0
- package/canon/skills/document-release/SKILL.md +297 -0
- package/canon/skills/executing-plans/SKILL.md +70 -0
- package/canon/skills/finishing-a-development-branch/SKILL.md +200 -0
- package/canon/skills/health/SKILL.md +177 -0
- package/canon/skills/maintaining-context/SKILL.md +34 -0
- package/canon/skills/minimal-code/SKILL.md +21 -0
- package/canon/skills/plan-ceo-review/SKILL.md +541 -0
- package/canon/skills/plan-eng-review/SKILL.md +362 -0
- package/canon/skills/receiving-code-review/SKILL.md +213 -0
- package/canon/skills/requesting-code-review/SKILL.md +105 -0
- package/canon/skills/retro/SKILL.md +397 -0
- package/canon/skills/review/SKILL.md +246 -0
- package/canon/skills/ship/SKILL.md +696 -0
- package/canon/skills/subagent-driven-development/SKILL.md +277 -0
- package/canon/skills/systematic-debugging/SKILL.md +296 -0
- package/canon/skills/tdd/SKILL.md +371 -0
- package/canon/skills/unslop-ui/SKILL.md +34 -0
- package/canon/skills/using-git-worktrees/SKILL.md +218 -0
- package/canon/skills/verification-before-completion/SKILL.md +139 -0
- package/canon/skills/visual-verification/SKILL.md +54 -0
- package/canon/skills/workflow/SKILL.md +18 -0
- package/canon/skills/writing-plans/SKILL.md +152 -0
- package/canon/skills/writing-skills/SKILL.md +655 -0
- package/canon/skills/yoke-retrofit/SKILL.md +18 -0
- package/canon/tools/graphify.md +3 -0
- package/canon/tools/playwright-mcp.md +3 -0
- package/canon/tools/rtk.md +7 -0
- package/canon/tools/serena.md +7 -0
- package/dist/canon/frontmatter.js +10 -0
- package/dist/canon/manifest.js +26 -0
- package/dist/canon/validate.js +73 -0
- package/dist/cli.js +244 -0
- package/dist/context/command.js +33 -0
- package/dist/context/context.js +57 -0
- package/dist/loop/cleanup.js +42 -0
- package/dist/loop/gates.js +12 -0
- package/dist/loop/git.js +25 -0
- package/dist/loop/lock.js +45 -0
- package/dist/loop/loop.js +190 -0
- package/dist/loop/prd.js +29 -0
- package/dist/loop/reporter.js +91 -0
- package/dist/loop/run-command.js +134 -0
- package/dist/loop/runner.js +157 -0
- package/dist/loop/verify.js +38 -0
- package/dist/loop/watchdog.js +86 -0
- package/dist/new/command.js +53 -0
- package/dist/prd/command.js +129 -0
- package/dist/retrofit/apply.js +54 -0
- package/dist/retrofit/canon-dir.js +22 -0
- package/dist/retrofit/command.js +37 -0
- package/dist/retrofit/config.js +53 -0
- package/dist/retrofit/context-actions.js +21 -0
- package/dist/retrofit/detect.js +17 -0
- package/dist/retrofit/gitignore.js +26 -0
- package/dist/retrofit/gstack.js +19 -0
- package/dist/retrofit/merge-json.js +38 -0
- package/dist/retrofit/plan.js +29 -0
- package/dist/retrofit/planners/claude.js +67 -0
- package/dist/retrofit/planners/codex.js +36 -0
- package/dist/retrofit/planners/gemini.js +54 -0
- package/dist/retrofit/report.js +14 -0
- package/dist/retrofit/tools.js +23 -0
- package/dist/retrofit/wsl.js +15 -0
- package/dist/review/command.js +43 -0
- package/dist/scan/design.js +79 -0
- package/dist/smoke/command.js +141 -0
- package/package.json +61 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { join, relative } from 'node:path';
|
|
2
|
+
import { loadPrd, savePrd, selectNextStory, allPass, progress } from './prd.js';
|
|
3
|
+
import { stopTheLineGate, preDispatchGate } from './gates.js';
|
|
4
|
+
import { appendDecision, contextDir } from '../context/context.js';
|
|
5
|
+
import { noopReporter } from './reporter.js';
|
|
6
|
+
function blockReason(base, targetDir, git) {
|
|
7
|
+
let dirty = false;
|
|
8
|
+
try {
|
|
9
|
+
dirty = !git.isClean(targetDir);
|
|
10
|
+
}
|
|
11
|
+
catch { /* ignore */ }
|
|
12
|
+
return dirty
|
|
13
|
+
? `${base} (working tree has uncommitted changes from the blocked story — review/clean before re-running)`
|
|
14
|
+
: base;
|
|
15
|
+
}
|
|
16
|
+
export function runLoop(opts) {
|
|
17
|
+
let iterations = 0;
|
|
18
|
+
const reporter = opts.reporter ?? noopReporter;
|
|
19
|
+
const initial = loadPrd(opts.prdPath);
|
|
20
|
+
if (initial.length === 0) {
|
|
21
|
+
reporter.blocked('PRD has no stories');
|
|
22
|
+
return { status: 'blocked', iterations: 0, reason: 'PRD has no stories', finalProgress: { passed: 0, total: 0 } };
|
|
23
|
+
}
|
|
24
|
+
for (;;) {
|
|
25
|
+
const stories = loadPrd(opts.prdPath);
|
|
26
|
+
if (allPass(stories)) {
|
|
27
|
+
reporter.complete(progress(stories));
|
|
28
|
+
return { status: 'complete', iterations, finalProgress: progress(stories) };
|
|
29
|
+
}
|
|
30
|
+
if (iterations >= opts.maxIterations) {
|
|
31
|
+
reporter.capReached(progress(stories));
|
|
32
|
+
return { status: 'cap-reached', iterations, finalProgress: progress(stories) };
|
|
33
|
+
}
|
|
34
|
+
const pre = preDispatchGate(opts.targetDir, opts.git);
|
|
35
|
+
if (!pre.ok) {
|
|
36
|
+
reporter.blocked(pre.reason ?? 'pre-dispatch gate failed');
|
|
37
|
+
return { status: 'blocked', iterations, reason: pre.reason, finalProgress: progress(stories) };
|
|
38
|
+
}
|
|
39
|
+
const story = selectNextStory(stories);
|
|
40
|
+
if (!story) {
|
|
41
|
+
reporter.complete(progress(stories));
|
|
42
|
+
return { status: 'complete', iterations, finalProgress: progress(stories) };
|
|
43
|
+
}
|
|
44
|
+
const stl = stopTheLineGate(story);
|
|
45
|
+
if (!stl.ok) {
|
|
46
|
+
reporter.blocked(stl.reason ?? 'stop-the-line gate failed');
|
|
47
|
+
return { status: 'blocked', iterations, reason: stl.reason, finalProgress: progress(stories) };
|
|
48
|
+
}
|
|
49
|
+
reporter.storyStart({ id: story.id, title: story.title }, iterations + 1, progress(stories));
|
|
50
|
+
if (opts.isolate) {
|
|
51
|
+
const wt = join(opts.targetDir, '.yoke', 'worktrees', story.id);
|
|
52
|
+
const wtPrd = join(wt, relative(opts.targetDir, opts.prdPath));
|
|
53
|
+
try {
|
|
54
|
+
opts.git.addWorktree(opts.targetDir, wt);
|
|
55
|
+
const result = opts.runner({ targetDir: wt, story });
|
|
56
|
+
iterations++;
|
|
57
|
+
// Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
|
|
58
|
+
// exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
|
|
59
|
+
reporter.phase('verifying');
|
|
60
|
+
const prevStory = process.env.YOKE_STORY;
|
|
61
|
+
process.env.YOKE_STORY = story.id;
|
|
62
|
+
let verdict;
|
|
63
|
+
try {
|
|
64
|
+
verdict = opts.verify(wt);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
if (prevStory === undefined)
|
|
68
|
+
delete process.env.YOKE_STORY;
|
|
69
|
+
else
|
|
70
|
+
process.env.YOKE_STORY = prevStory;
|
|
71
|
+
}
|
|
72
|
+
if (!verdict.passed) {
|
|
73
|
+
const base = result.success
|
|
74
|
+
? `story ${story.id} did not verify: ${verdict.summary}`
|
|
75
|
+
: `story ${story.id} runner failed (${result.summary}) and verify is red: ${verdict.summary}`;
|
|
76
|
+
const reason = blockReason(base, opts.targetDir, opts.git);
|
|
77
|
+
reporter.blocked(reason);
|
|
78
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
79
|
+
}
|
|
80
|
+
const summary = result.success
|
|
81
|
+
? result.summary
|
|
82
|
+
: `${result.summary} (runner exited non-zero but verify is green)`;
|
|
83
|
+
if (opts.review) {
|
|
84
|
+
reporter.phase('reviewing');
|
|
85
|
+
const reviewResult = opts.review({ targetDir: wt, story });
|
|
86
|
+
if (!reviewResult.success) {
|
|
87
|
+
const reason = blockReason(`story ${story.id} rejected in review: ${reviewResult.summary}`, opts.targetDir, opts.git);
|
|
88
|
+
reporter.blocked(reason);
|
|
89
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// The worktree is a checkout of committed HEAD, so the agent above reads
|
|
93
|
+
// context from HEAD's .yoke/context — commit context changes for --isolate
|
|
94
|
+
// to honour them. We write the decision here so `integrate` carries it back.
|
|
95
|
+
reporter.phase('committing');
|
|
96
|
+
appendDecision(contextDir(wt), {
|
|
97
|
+
storyId: story.id,
|
|
98
|
+
title: story.title,
|
|
99
|
+
summary,
|
|
100
|
+
});
|
|
101
|
+
const updated = stories.map(s => (s.id === story.id ? { ...s, passes: true } : s));
|
|
102
|
+
savePrd(wtPrd, updated);
|
|
103
|
+
opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`);
|
|
104
|
+
opts.git.integrate(opts.targetDir, wt);
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
const reason = blockReason(`isolated iteration failed for ${story.id}: ${e.message}`, opts.targetDir, opts.git);
|
|
108
|
+
reporter.blocked(reason);
|
|
109
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
try {
|
|
113
|
+
opts.git.removeWorktree(opts.targetDir, wt);
|
|
114
|
+
}
|
|
115
|
+
catch { /* cleanup is best-effort */ }
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const result = opts.runner({ targetDir: opts.targetDir, story });
|
|
120
|
+
iterations++;
|
|
121
|
+
// Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
|
|
122
|
+
// exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
|
|
123
|
+
reporter.phase('verifying');
|
|
124
|
+
const prevStory = process.env.YOKE_STORY;
|
|
125
|
+
process.env.YOKE_STORY = story.id;
|
|
126
|
+
let verdict;
|
|
127
|
+
try {
|
|
128
|
+
verdict = opts.verify(opts.targetDir);
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
if (prevStory === undefined)
|
|
132
|
+
delete process.env.YOKE_STORY;
|
|
133
|
+
else
|
|
134
|
+
process.env.YOKE_STORY = prevStory;
|
|
135
|
+
}
|
|
136
|
+
if (!verdict.passed) {
|
|
137
|
+
const base = result.success
|
|
138
|
+
? `story ${story.id} did not verify: ${verdict.summary}`
|
|
139
|
+
: `story ${story.id} runner failed (${result.summary}) and verify is red: ${verdict.summary}`;
|
|
140
|
+
const reason = blockReason(base, opts.targetDir, opts.git);
|
|
141
|
+
reporter.blocked(reason);
|
|
142
|
+
return {
|
|
143
|
+
status: 'blocked',
|
|
144
|
+
iterations,
|
|
145
|
+
reason,
|
|
146
|
+
finalProgress: progress(stories),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const summary = result.success
|
|
150
|
+
? result.summary
|
|
151
|
+
: `${result.summary} (runner exited non-zero but verify is green)`;
|
|
152
|
+
if (opts.review) {
|
|
153
|
+
reporter.phase('reviewing');
|
|
154
|
+
const reviewResult = opts.review({ targetDir: opts.targetDir, story });
|
|
155
|
+
if (!reviewResult.success) {
|
|
156
|
+
const reason = blockReason(`story ${story.id} rejected in review: ${reviewResult.summary}`, opts.targetDir, opts.git);
|
|
157
|
+
reporter.blocked(reason);
|
|
158
|
+
return {
|
|
159
|
+
status: 'blocked',
|
|
160
|
+
iterations,
|
|
161
|
+
reason,
|
|
162
|
+
finalProgress: progress(stories),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
reporter.phase('committing');
|
|
167
|
+
const dec = appendDecision(contextDir(opts.targetDir), {
|
|
168
|
+
storyId: story.id,
|
|
169
|
+
title: story.title,
|
|
170
|
+
summary,
|
|
171
|
+
});
|
|
172
|
+
const updated = stories.map(s => (s.id === story.id ? { ...s, passes: true } : s));
|
|
173
|
+
savePrd(opts.prdPath, updated);
|
|
174
|
+
try {
|
|
175
|
+
opts.git.commitAll(opts.targetDir, `yoke: complete ${story.id} ${story.title}`);
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
savePrd(opts.prdPath, stories); // revert — never persist passes:true without a commit
|
|
179
|
+
dec.rollback(); // and never leave an orphan decision
|
|
180
|
+
const reason = blockReason(`commit failed for ${story.id}: ${e.message}`, opts.targetDir, opts.git);
|
|
181
|
+
reporter.blocked(reason);
|
|
182
|
+
return {
|
|
183
|
+
status: 'blocked',
|
|
184
|
+
iterations,
|
|
185
|
+
reason,
|
|
186
|
+
finalProgress: progress(stories),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
package/dist/loop/prd.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { parse, stringify } from 'yaml';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
export const StorySchema = z.object({
|
|
5
|
+
id: z.string().min(1),
|
|
6
|
+
title: z.string().min(1),
|
|
7
|
+
priority: z.number(),
|
|
8
|
+
acceptance: z.array(z.string().min(1)),
|
|
9
|
+
passes: z.boolean(),
|
|
10
|
+
});
|
|
11
|
+
const PrdSchema = z.array(StorySchema);
|
|
12
|
+
export function loadPrd(file) {
|
|
13
|
+
return PrdSchema.parse(parse(readFileSync(file, 'utf8')));
|
|
14
|
+
}
|
|
15
|
+
export function savePrd(file, stories) {
|
|
16
|
+
writeFileSync(file, stringify(stories));
|
|
17
|
+
}
|
|
18
|
+
export function selectNextStory(stories) {
|
|
19
|
+
const open = stories.filter(s => !s.passes);
|
|
20
|
+
if (open.length === 0)
|
|
21
|
+
return null;
|
|
22
|
+
return open.reduce((best, s) => (s.priority < best.priority ? s : best));
|
|
23
|
+
}
|
|
24
|
+
export function allPass(stories) {
|
|
25
|
+
return stories.length > 0 && stories.every(s => s.passes);
|
|
26
|
+
}
|
|
27
|
+
export function progress(stories) {
|
|
28
|
+
return { passed: stories.filter(s => s.passes).length, total: stories.length };
|
|
29
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, appendFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export const LOG_CAP_BYTES = 256 * 1024;
|
|
4
|
+
// Append a line to .yoke/loop.log, keeping the file bounded: once it exceeds
|
|
5
|
+
// capBytes, truncate to the recent tail (starting at a line boundary) so the log
|
|
6
|
+
// can never grow without bound across many loop runs.
|
|
7
|
+
export function appendLog(dir, line, capBytes = LOG_CAP_BYTES) {
|
|
8
|
+
const file = join(dir, '.yoke', 'loop.log');
|
|
9
|
+
mkdirSync(join(dir, '.yoke'), { recursive: true });
|
|
10
|
+
appendFileSync(file, line + '\n');
|
|
11
|
+
let size;
|
|
12
|
+
try {
|
|
13
|
+
size = statSync(file).size;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (size <= capBytes)
|
|
19
|
+
return;
|
|
20
|
+
const content = readFileSync(file, 'utf8');
|
|
21
|
+
const tail = content.slice(content.length - Math.floor(capBytes / 2));
|
|
22
|
+
const nl = tail.indexOf('\n');
|
|
23
|
+
const trimmed = nl >= 0 ? tail.slice(nl + 1) : tail;
|
|
24
|
+
writeFileSync(file, `# … loop.log truncated …\n${trimmed}`);
|
|
25
|
+
}
|
|
26
|
+
function statusPath(dir) {
|
|
27
|
+
return join(dir, '.yoke', 'loop-status.json');
|
|
28
|
+
}
|
|
29
|
+
export function writeStatus(dir, status) {
|
|
30
|
+
const file = statusPath(dir);
|
|
31
|
+
mkdirSync(join(dir, '.yoke'), { recursive: true });
|
|
32
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
33
|
+
writeFileSync(tmp, JSON.stringify(status, null, 2));
|
|
34
|
+
renameSync(tmp, file);
|
|
35
|
+
}
|
|
36
|
+
export function readStatus(dir) {
|
|
37
|
+
const file = statusPath(dir);
|
|
38
|
+
if (!existsSync(file))
|
|
39
|
+
return null;
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
48
|
+
const sink = opts.log ?? ((line) => process.stdout.write(line + '\n'));
|
|
49
|
+
const emitConsole = (line) => { if (!opts.quiet)
|
|
50
|
+
sink(line); };
|
|
51
|
+
let current = null;
|
|
52
|
+
const persist = (next, logLabel, consoleLine) => {
|
|
53
|
+
current = next;
|
|
54
|
+
try {
|
|
55
|
+
writeStatus(dir, next);
|
|
56
|
+
appendLog(dir, `${next.updatedAt} ${logLabel} ${next.story ?? '-'} ${next.reason ?? ''}`.trimEnd());
|
|
57
|
+
}
|
|
58
|
+
catch { /* observability must never abort the loop */ }
|
|
59
|
+
emitConsole(consoleLine);
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
storyStart(story, iteration, progress) {
|
|
63
|
+
const ts = now().toISOString();
|
|
64
|
+
persist({ state: 'running', phase: 'implementing', story: story.id, storyTitle: story.title,
|
|
65
|
+
iteration, progress, startedAt: ts, updatedAt: ts }, 'implementing', `▶ ${story.id} (${progress.passed}/${progress.total}) — implementing…`);
|
|
66
|
+
},
|
|
67
|
+
phase(phase) {
|
|
68
|
+
if (!current)
|
|
69
|
+
return;
|
|
70
|
+
persist({ ...current, phase, updatedAt: now().toISOString() }, phase, ` · ${phase}…`);
|
|
71
|
+
},
|
|
72
|
+
blocked(reason) {
|
|
73
|
+
const base = current ?? emptyStatus(now().toISOString());
|
|
74
|
+
persist({ ...base, state: 'blocked', reason, updatedAt: now().toISOString() }, 'blocked', `■ blocked on ${base.story ?? '?'}: ${reason}`);
|
|
75
|
+
},
|
|
76
|
+
complete(progress) {
|
|
77
|
+
persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'complete', phase: undefined,
|
|
78
|
+
progress, reason: undefined, updatedAt: now().toISOString() }, 'complete', `✔ loop complete — ${progress.passed}/${progress.total}`);
|
|
79
|
+
},
|
|
80
|
+
capReached(progress) {
|
|
81
|
+
persist({ ...(current ?? emptyStatus(now().toISOString())), state: 'cap-reached', phase: undefined,
|
|
82
|
+
progress, updatedAt: now().toISOString() }, 'cap-reached', `◾ iteration cap reached — ${progress.passed}/${progress.total}`);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function emptyStatus(ts) {
|
|
87
|
+
return { state: 'running', iteration: 0, progress: { passed: 0, total: 0 }, startedAt: ts, updatedAt: ts };
|
|
88
|
+
}
|
|
89
|
+
export const noopReporter = {
|
|
90
|
+
storyStart() { }, phase() { }, blocked() { }, complete() { }, capReached() { },
|
|
91
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { loadConfig, saveConfig, defaultConfig, resolveVerifyCommand } from '../retrofit/config.js';
|
|
4
|
+
import { loadPrd, progress } from './prd.js';
|
|
5
|
+
import { runLoop } from './loop.js';
|
|
6
|
+
import { realGitOps } from './git.js';
|
|
7
|
+
import { makeRunner, makeReviewRunner, isAgentAvailable } from './runner.js';
|
|
8
|
+
import { commandVerifier, retryingVerifier } from './verify.js';
|
|
9
|
+
import { readStatus, makeReporter } from './reporter.js';
|
|
10
|
+
import { acquireLock, releaseLock } from './lock.js';
|
|
11
|
+
export const DEFAULT_IDLE_MINUTES = 20;
|
|
12
|
+
const STALE_MINUTES = 20; // a running status older than this likely means the loop died
|
|
13
|
+
export function relativeTime(fromIso, now) {
|
|
14
|
+
const ms = Math.max(0, now.getTime() - Date.parse(fromIso));
|
|
15
|
+
const s = Math.floor(ms / 1000);
|
|
16
|
+
if (s < 60)
|
|
17
|
+
return `${s}s ago`;
|
|
18
|
+
const m = Math.floor(s / 60);
|
|
19
|
+
if (m < 60)
|
|
20
|
+
return `${m}m ago`;
|
|
21
|
+
const h = Math.floor(m / 60);
|
|
22
|
+
if (h < 24)
|
|
23
|
+
return `${h}h ago`;
|
|
24
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
25
|
+
}
|
|
26
|
+
export function prdPath(targetDir) {
|
|
27
|
+
return join(targetDir, '.yoke', 'prd.yaml');
|
|
28
|
+
}
|
|
29
|
+
export function setLoopEnabled(targetDir, enabled) {
|
|
30
|
+
// TODO(C2): resolve bundled canon version instead of placeholder
|
|
31
|
+
const config = loadConfig(targetDir) ?? defaultConfig('0.0.0');
|
|
32
|
+
config.loop = { enabled };
|
|
33
|
+
saveConfig(targetDir, config);
|
|
34
|
+
}
|
|
35
|
+
export function loopStatus(targetDir, now = () => new Date()) {
|
|
36
|
+
const config = loadConfig(targetDir);
|
|
37
|
+
const enabled = config?.loop.enabled ?? false;
|
|
38
|
+
const path = prdPath(targetDir);
|
|
39
|
+
let prog = 'no PRD';
|
|
40
|
+
if (existsSync(path)) {
|
|
41
|
+
const p = progress(loadPrd(path));
|
|
42
|
+
prog = `${p.passed}/${p.total} stories pass`;
|
|
43
|
+
}
|
|
44
|
+
const st = readStatus(targetDir);
|
|
45
|
+
if (!st)
|
|
46
|
+
return `Loop: ${enabled ? 'enabled' : 'disabled'}\nPRD: ${prog}`;
|
|
47
|
+
const head = `Loop: ${st.state.toUpperCase()}${st.story ? ` on ${st.story}${st.storyTitle ? ` "${st.storyTitle}"` : ''}` : ''}`;
|
|
48
|
+
const meta = [st.phase, `iteration ${st.iteration}`, `${st.progress.passed}/${st.progress.total}`, `updated ${relativeTime(st.updatedAt, now())}`]
|
|
49
|
+
.filter(Boolean).join(' · ');
|
|
50
|
+
const lines = [head, ` ${meta}`];
|
|
51
|
+
if (st.reason)
|
|
52
|
+
lines.push(` reason: ${st.reason}`);
|
|
53
|
+
const ageMs = now().getTime() - Date.parse(st.updatedAt);
|
|
54
|
+
if (st.state === 'running' && ageMs > STALE_MINUTES * 60_000) {
|
|
55
|
+
lines.push(` ⚠ possibly stuck — no update in ${relativeTime(st.updatedAt, now())}`);
|
|
56
|
+
}
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|
|
59
|
+
export function resolveIdleMs(flagMinutes, configMinutes) {
|
|
60
|
+
const minutes = flagMinutes ?? configMinutes ?? DEFAULT_IDLE_MINUTES;
|
|
61
|
+
return minutes > 0 ? minutes * 60_000 : 0;
|
|
62
|
+
}
|
|
63
|
+
export function runLoopCommand(targetDir, opts) {
|
|
64
|
+
const config = loadConfig(targetDir);
|
|
65
|
+
if (!config?.loop.enabled) {
|
|
66
|
+
console.error('Loop is disabled. Enable it with: yoke loop on');
|
|
67
|
+
return 2;
|
|
68
|
+
}
|
|
69
|
+
const path = prdPath(targetDir);
|
|
70
|
+
if (!existsSync(path)) {
|
|
71
|
+
console.error(`No PRD found at ${path}. Create one (see canon loop/prd.schema.md).`);
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
let verify = opts.verify;
|
|
75
|
+
if (!verify) {
|
|
76
|
+
const command = resolveVerifyCommand(targetDir, config);
|
|
77
|
+
if (!command) {
|
|
78
|
+
console.error('No verify command configured. Set verify.command in .yoke/config.yaml (e.g. "npm test") so the loop can confirm tests pass before marking work done.');
|
|
79
|
+
return 2;
|
|
80
|
+
}
|
|
81
|
+
verify = retryingVerifier(commandVerifier(command), config.verify?.retries ?? 1);
|
|
82
|
+
}
|
|
83
|
+
const available = opts.isAvailable ?? isAgentAvailable;
|
|
84
|
+
const runnerAgent = opts.agent ?? config.agents[0] ?? 'claude';
|
|
85
|
+
const idleMs = resolveIdleMs(opts.timeoutMinutes, config.loop.timeoutMinutes);
|
|
86
|
+
let runner = opts.runner;
|
|
87
|
+
if (!runner) {
|
|
88
|
+
if (!available(runnerAgent)) {
|
|
89
|
+
console.error(`Agent CLI "${runnerAgent}" was not found on PATH. Install it, or pick another with --runner=<claude|codex|gemini>.`);
|
|
90
|
+
return 2;
|
|
91
|
+
}
|
|
92
|
+
runner = makeRunner(runnerAgent, idleMs);
|
|
93
|
+
}
|
|
94
|
+
let review = opts.reviewRunner;
|
|
95
|
+
if (!review && (opts.review || opts.reviewer)) {
|
|
96
|
+
const reviewerAgent = opts.reviewer ?? runnerAgent;
|
|
97
|
+
if (!available(reviewerAgent)) {
|
|
98
|
+
console.error(`Reviewer agent CLI "${reviewerAgent}" was not found on PATH. Install it, or pick another with --reviewer=<claude|codex|gemini>.`);
|
|
99
|
+
return 2;
|
|
100
|
+
}
|
|
101
|
+
review = makeReviewRunner(reviewerAgent, idleMs);
|
|
102
|
+
}
|
|
103
|
+
const lock = acquireLock(targetDir);
|
|
104
|
+
if (!lock.acquired) {
|
|
105
|
+
console.error(`Another loop is already running here (pid ${lock.holderPid}). If that is wrong, run: yoke loop cleanup`);
|
|
106
|
+
return 2;
|
|
107
|
+
}
|
|
108
|
+
if (lock.stalePid !== undefined) {
|
|
109
|
+
console.warn(`Took over a stale loop lock (pid ${lock.stalePid} is gone).`);
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const result = runLoop({
|
|
113
|
+
prdPath: path,
|
|
114
|
+
targetDir,
|
|
115
|
+
runner,
|
|
116
|
+
git: opts.git ?? realGitOps,
|
|
117
|
+
verify,
|
|
118
|
+
maxIterations: opts.maxIterations,
|
|
119
|
+
isolate: opts.isolate ?? false,
|
|
120
|
+
review,
|
|
121
|
+
reporter: opts.reporter ?? makeReporter(targetDir),
|
|
122
|
+
});
|
|
123
|
+
console.log(`Loop ${result.status} after ${result.iterations} iteration(s): ${result.finalProgress.passed}/${result.finalProgress.total} stories pass`);
|
|
124
|
+
if (result.reason)
|
|
125
|
+
console.log(`Reason: ${result.reason}`);
|
|
126
|
+
if (result.reason && /api key|please run \/login|not logged in/i.test(result.reason)) {
|
|
127
|
+
console.log('Hint: the agent CLI has no credentials in this environment. Set ANTHROPIC_API_KEY or log the agent in for headless use.');
|
|
128
|
+
}
|
|
129
|
+
return result.status === 'complete' ? 0 : 1;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
releaseLock(targetDir);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { loadContext, formatForPrompt, contextDir } from '../context/context.js';
|
|
4
|
+
export function contextBlockFor(targetDir) {
|
|
5
|
+
return formatForPrompt(loadContext(contextDir(targetDir)));
|
|
6
|
+
}
|
|
7
|
+
export function buildClaudePrompt(story, context) {
|
|
8
|
+
const criteria = story.acceptance.map(a => `- ${a}`).join('\n');
|
|
9
|
+
const lines = [
|
|
10
|
+
'You are an autonomous coding agent running inside the Yoke loop.',
|
|
11
|
+
'Implement ONLY this story and nothing else. Follow test-driven development.',
|
|
12
|
+
];
|
|
13
|
+
if (context)
|
|
14
|
+
lines.push('', context);
|
|
15
|
+
lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria (Definition of Done):', criteria, '', "When done, ensure the project's full test suite passes.", 'Do NOT commit — the loop commits on your behalf after verifying.');
|
|
16
|
+
return lines.join('\n');
|
|
17
|
+
}
|
|
18
|
+
export function buildReviewPrompt(story, context) {
|
|
19
|
+
const criteria = story.acceptance.map(a => `- ${a}`).join('\n');
|
|
20
|
+
const lines = [
|
|
21
|
+
'You are an independent reviewer inside the Yoke loop. You did NOT implement this change.',
|
|
22
|
+
'Review the current uncommitted working-tree changes against the story below.',
|
|
23
|
+
];
|
|
24
|
+
if (context)
|
|
25
|
+
lines.push('', context);
|
|
26
|
+
lines.push('', `Story ${story.id}: ${story.title}`, 'Acceptance criteria:', criteria, '', 'Approve by exiting 0 ONLY if every acceptance criterion is met and the change is sound.', 'If you find ANY blocking issue (an unmet criterion, a bug, a missing test), exit non-zero to reject.', 'Do not modify files. Do not commit.');
|
|
27
|
+
return lines.join('\n');
|
|
28
|
+
}
|
|
29
|
+
export function buildStandaloneReviewPrompt(scope, focus) {
|
|
30
|
+
const lines = [
|
|
31
|
+
'You are an independent reviewer. You did NOT write this change.',
|
|
32
|
+
`Review ${scope}. Run git yourself to see the diff (e.g. \`git diff\`, or \`git diff <base>..HEAD\`).`,
|
|
33
|
+
'Judge it for correctness, unmet intent, missing tests, and obvious bug or security risks.',
|
|
34
|
+
];
|
|
35
|
+
if (focus)
|
|
36
|
+
lines.push(`Pay particular attention to: ${focus}.`);
|
|
37
|
+
lines.push('', 'Approve by exiting 0 ONLY if the change is sound and complete.', 'If you find ANY blocking issue, exit non-zero to reject and explain what is wrong.', 'Do not modify files. Do not commit.');
|
|
38
|
+
return lines.join('\n');
|
|
39
|
+
}
|
|
40
|
+
const AGENT_SPECS = {
|
|
41
|
+
claude: { command: 'claude', baseArgs: ['-p'] },
|
|
42
|
+
codex: { command: 'codex', baseArgs: ['exec'] },
|
|
43
|
+
gemini: { command: 'gemini', baseArgs: ['-p'] },
|
|
44
|
+
};
|
|
45
|
+
export function agentInvocation(agent, prompt, cwd) {
|
|
46
|
+
const spec = AGENT_SPECS[agent];
|
|
47
|
+
return { command: spec.command, args: spec.baseArgs, input: prompt, cwd };
|
|
48
|
+
}
|
|
49
|
+
export function claudeInvocation(prompt, cwd) {
|
|
50
|
+
return agentInvocation('claude', prompt, cwd);
|
|
51
|
+
}
|
|
52
|
+
function watchdogPath() {
|
|
53
|
+
// runner.js and watchdog.js sit side by side (dist/loop/ at runtime, src/loop/ under tsx)
|
|
54
|
+
return fileURLToPath(new URL('./watchdog.js', import.meta.url));
|
|
55
|
+
}
|
|
56
|
+
// When idleTimeoutMs > 0, run the agent THROUGH the watchdog so a silent hang is
|
|
57
|
+
// killed after idleTimeoutMs of no output. The prompt still flows via stdin.
|
|
58
|
+
export function buildWatchdogInvocation(inv, idleTimeoutMs) {
|
|
59
|
+
if (idleTimeoutMs <= 0)
|
|
60
|
+
return inv;
|
|
61
|
+
return {
|
|
62
|
+
command: 'node',
|
|
63
|
+
args: [watchdogPath(), `--idle-ms=${idleTimeoutMs}`, '--', inv.command, ...inv.args],
|
|
64
|
+
input: inv.input,
|
|
65
|
+
cwd: inv.cwd,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// Execute a CLI invocation. On Windows the agent CLIs are `.cmd` shims that
|
|
69
|
+
// execFileSync cannot resolve without a shell; but passing an args array with
|
|
70
|
+
// shell:true triggers DEP0190. So on win32 we run a single command string via
|
|
71
|
+
// execSync (our args are literal flags, never user data — the prompt is piped via
|
|
72
|
+
// stdin), which avoids the warning. On other platforms execFileSync with no shell
|
|
73
|
+
// is already warning-free. Throws on a non-zero exit (caller catches).
|
|
74
|
+
// Build a win32 command string, quoting only args that contain whitespace.
|
|
75
|
+
// Existing agent flags (claude -p, codex exec) have no spaces, so they are
|
|
76
|
+
// unchanged; an absolute watchdog path with spaces gets quoted.
|
|
77
|
+
export function win32CommandString(command, args) {
|
|
78
|
+
const q = (s) => (/\s/.test(s) ? `"${s}"` : s);
|
|
79
|
+
return [command, ...args].map(q).join(' ');
|
|
80
|
+
}
|
|
81
|
+
function runCli(inv) {
|
|
82
|
+
if (process.platform === 'win32') {
|
|
83
|
+
execSync(win32CommandString(inv.command, inv.args), {
|
|
84
|
+
cwd: inv.cwd,
|
|
85
|
+
input: inv.input,
|
|
86
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
execFileSync(inv.command, inv.args, {
|
|
91
|
+
cwd: inv.cwd,
|
|
92
|
+
input: inv.input,
|
|
93
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Probe whether a CLI is on PATH via `<command> --version`. Same win32/other split
|
|
98
|
+
// as runCli to stay DEP0190-free. Never throws.
|
|
99
|
+
function probeVersion(command) {
|
|
100
|
+
try {
|
|
101
|
+
if (process.platform === 'win32') {
|
|
102
|
+
execSync(`${command} --version`, { stdio: 'pipe', timeout: 5000 });
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
execFileSync(command, ['--version'], { stdio: 'pipe', timeout: 5000 });
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Reusable one-shot invocation runner for callers outside the loop (e.g. `yoke review`).
|
|
114
|
+
// Mirrors makeRunner's try/catch: success=true when the CLI exits 0, false when it throws.
|
|
115
|
+
export function runAgent(inv) {
|
|
116
|
+
try {
|
|
117
|
+
runCli(inv);
|
|
118
|
+
return { success: true, summary: 'exited 0' };
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
return { success: false, summary: e.message };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function makeRunner(agent, idleTimeoutMs = 0) {
|
|
125
|
+
return (ctx) => {
|
|
126
|
+
const base = agentInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir)), ctx.targetDir);
|
|
127
|
+
const inv = buildWatchdogInvocation(base, idleTimeoutMs);
|
|
128
|
+
try {
|
|
129
|
+
// NOTE: the loop trusts the agent's exit code as a proxy for "it ran".
|
|
130
|
+
// Independent verification happens in the loop (Baustein C2), not here.
|
|
131
|
+
runCli(inv);
|
|
132
|
+
return { success: true, summary: `${agent} implemented ${ctx.story.id}` };
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
return { success: false, summary: `${agent} failed on ${ctx.story.id}: ${e.message}` };
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export const claudeRunner = makeRunner('claude');
|
|
140
|
+
export function makeReviewRunner(agent, idleTimeoutMs = 0) {
|
|
141
|
+
return (ctx) => {
|
|
142
|
+
const base = agentInvocation(agent, buildReviewPrompt(ctx.story, contextBlockFor(ctx.targetDir)), ctx.targetDir);
|
|
143
|
+
const inv = buildWatchdogInvocation(base, idleTimeoutMs);
|
|
144
|
+
try {
|
|
145
|
+
runCli(inv);
|
|
146
|
+
return { success: true, summary: `${agent} approved ${ctx.story.id}` };
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
return { success: false, summary: `${agent} rejected ${ctx.story.id}: ${e.message}` };
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// Probe whether the agent's CLI is on PATH (so the loop can refuse upfront with a
|
|
154
|
+
// clear message instead of failing mid-run with spawn ENOENT). Never throws.
|
|
155
|
+
export function isAgentAvailable(agent) {
|
|
156
|
+
return probeVersion(AGENT_SPECS[agent].command);
|
|
157
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
// Runs a shell command in the target dir; passed = exit 0. execSync goes through the
|
|
3
|
+
// shell, so `npm test` resolves npm.cmd on Windows. Output is captured (not streamed).
|
|
4
|
+
export function commandVerifier(command) {
|
|
5
|
+
return (targetDir) => {
|
|
6
|
+
try {
|
|
7
|
+
execSync(command, { cwd: targetDir, stdio: 'pipe', timeout: 600_000 });
|
|
8
|
+
return { passed: true, summary: `verify passed: ${command}` };
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
const err = e;
|
|
12
|
+
const out = (err.stderr?.toString('utf8') ?? '') || (err.stdout?.toString('utf8') ?? '');
|
|
13
|
+
const tail = out.trim().split('\n').slice(-5).join('\n');
|
|
14
|
+
const suffix = err.signal === 'SIGTERM' ? ' (timed out)' : (tail ? `\n${tail}` : '');
|
|
15
|
+
return { passed: false, summary: `verify failed: ${command}${suffix}` };
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
// Re-run a failing verifier up to `retries` times; the first pass wins. Lets a
|
|
20
|
+
// transient flake (e.g. a load-induced async timeout) self-heal while a real
|
|
21
|
+
// failure still fails (it stays red across every attempt).
|
|
22
|
+
export function retryingVerifier(inner, retries) {
|
|
23
|
+
return (targetDir) => {
|
|
24
|
+
let last = inner(targetDir);
|
|
25
|
+
let attempt = 0;
|
|
26
|
+
while (!last.passed && attempt < retries) {
|
|
27
|
+
attempt++;
|
|
28
|
+
last = inner(targetDir);
|
|
29
|
+
}
|
|
30
|
+
if (last.passed && attempt > 0) {
|
|
31
|
+
return { passed: true, summary: `${last.summary} (passed on retry ${attempt})` };
|
|
32
|
+
}
|
|
33
|
+
if (!last.passed && attempt > 0) {
|
|
34
|
+
return { passed: false, summary: `${last.summary} (still failing after ${attempt} retr${attempt === 1 ? 'y' : 'ies'})` };
|
|
35
|
+
}
|
|
36
|
+
return last;
|
|
37
|
+
};
|
|
38
|
+
}
|