@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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +494 -0
  3. package/canon/AGENTS.md +28 -0
  4. package/canon/context/DECISIONS.md +4 -0
  5. package/canon/context/KNOWLEDGE.md +4 -0
  6. package/canon/context/PROJECT.md +15 -0
  7. package/canon/loop/loop-spec.md +30 -0
  8. package/canon/loop/prd.schema.md +14 -0
  9. package/canon/manifest.yaml +47 -0
  10. package/canon/policy/gates.md +7 -0
  11. package/canon/policy/roles.md +9 -0
  12. package/canon/skills/ATTRIBUTION.md +71 -0
  13. package/canon/skills/authoring-prd/SKILL.md +44 -0
  14. package/canon/skills/brainstorming/SKILL.md +164 -0
  15. package/canon/skills/dispatching-parallel-agents/SKILL.md +182 -0
  16. package/canon/skills/document-release/SKILL.md +297 -0
  17. package/canon/skills/executing-plans/SKILL.md +70 -0
  18. package/canon/skills/finishing-a-development-branch/SKILL.md +200 -0
  19. package/canon/skills/health/SKILL.md +177 -0
  20. package/canon/skills/maintaining-context/SKILL.md +34 -0
  21. package/canon/skills/minimal-code/SKILL.md +21 -0
  22. package/canon/skills/plan-ceo-review/SKILL.md +541 -0
  23. package/canon/skills/plan-eng-review/SKILL.md +362 -0
  24. package/canon/skills/receiving-code-review/SKILL.md +213 -0
  25. package/canon/skills/requesting-code-review/SKILL.md +105 -0
  26. package/canon/skills/retro/SKILL.md +397 -0
  27. package/canon/skills/review/SKILL.md +246 -0
  28. package/canon/skills/ship/SKILL.md +696 -0
  29. package/canon/skills/subagent-driven-development/SKILL.md +277 -0
  30. package/canon/skills/systematic-debugging/SKILL.md +296 -0
  31. package/canon/skills/tdd/SKILL.md +371 -0
  32. package/canon/skills/unslop-ui/SKILL.md +34 -0
  33. package/canon/skills/using-git-worktrees/SKILL.md +218 -0
  34. package/canon/skills/verification-before-completion/SKILL.md +139 -0
  35. package/canon/skills/visual-verification/SKILL.md +54 -0
  36. package/canon/skills/workflow/SKILL.md +18 -0
  37. package/canon/skills/writing-plans/SKILL.md +152 -0
  38. package/canon/skills/writing-skills/SKILL.md +655 -0
  39. package/canon/skills/yoke-retrofit/SKILL.md +18 -0
  40. package/canon/tools/graphify.md +3 -0
  41. package/canon/tools/playwright-mcp.md +3 -0
  42. package/canon/tools/rtk.md +7 -0
  43. package/canon/tools/serena.md +7 -0
  44. package/dist/canon/frontmatter.js +10 -0
  45. package/dist/canon/manifest.js +26 -0
  46. package/dist/canon/validate.js +73 -0
  47. package/dist/cli.js +244 -0
  48. package/dist/context/command.js +33 -0
  49. package/dist/context/context.js +57 -0
  50. package/dist/loop/cleanup.js +42 -0
  51. package/dist/loop/gates.js +12 -0
  52. package/dist/loop/git.js +25 -0
  53. package/dist/loop/lock.js +45 -0
  54. package/dist/loop/loop.js +190 -0
  55. package/dist/loop/prd.js +29 -0
  56. package/dist/loop/reporter.js +91 -0
  57. package/dist/loop/run-command.js +134 -0
  58. package/dist/loop/runner.js +157 -0
  59. package/dist/loop/verify.js +38 -0
  60. package/dist/loop/watchdog.js +86 -0
  61. package/dist/new/command.js +53 -0
  62. package/dist/prd/command.js +129 -0
  63. package/dist/retrofit/apply.js +54 -0
  64. package/dist/retrofit/canon-dir.js +22 -0
  65. package/dist/retrofit/command.js +37 -0
  66. package/dist/retrofit/config.js +53 -0
  67. package/dist/retrofit/context-actions.js +21 -0
  68. package/dist/retrofit/detect.js +17 -0
  69. package/dist/retrofit/gitignore.js +26 -0
  70. package/dist/retrofit/gstack.js +19 -0
  71. package/dist/retrofit/merge-json.js +38 -0
  72. package/dist/retrofit/plan.js +29 -0
  73. package/dist/retrofit/planners/claude.js +67 -0
  74. package/dist/retrofit/planners/codex.js +36 -0
  75. package/dist/retrofit/planners/gemini.js +54 -0
  76. package/dist/retrofit/report.js +14 -0
  77. package/dist/retrofit/tools.js +23 -0
  78. package/dist/retrofit/wsl.js +15 -0
  79. package/dist/review/command.js +43 -0
  80. package/dist/scan/design.js +79 -0
  81. package/dist/smoke/command.js +141 -0
  82. 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
+ }
@@ -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
+ }