@hecer/yoke 0.7.0 → 0.8.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/CHANGELOG.md +18 -0
- package/README.md +34 -8
- package/dist/cli.js +7 -2
- package/dist/loop/loop.js +41 -1
- package/dist/loop/reporter.js +69 -3
- package/dist/loop/run-command.js +7 -3
- package/dist/loop/runner.js +5 -3
- package/dist/retrofit/config.js +7 -1
- package/dist/retrofit/gitignore.js +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 — 2026-07-20
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Live progress + ETA.** Story completions are now first-class events: the console shows
|
|
7
|
+
`✓ S6 done in 4m28s — 20/45 (44%) · ~1h40m left`, every status (file, NDJSON stream,
|
|
8
|
+
`yoke loop status`) carries `percent` and an `eta` block. The estimate averages the
|
|
9
|
+
durations of stories completed **in this run** (current velocity) and falls back to the
|
|
10
|
+
persisted history of previous runs (`.yoke/story-durations.json`, last 50, gitignored).
|
|
11
|
+
No data → no estimate, never an invented one.
|
|
12
|
+
- **Ambiguity policy** (`loop.onAmbiguity` / `--on-ambiguity=<resolve|abort>`). The runner
|
|
13
|
+
prompt now always forbids asking questions (a loop run has nobody to answer). Default
|
|
14
|
+
`resolve`: the agent settles ambiguous criteria itself, states the interpretation, and the
|
|
15
|
+
loop never stops. Opt-in `abort`: the agent writes its open questions to
|
|
16
|
+
`.yoke/ambiguity.md` and stops; the loop consumes the file, skips verify (an unimplemented
|
|
17
|
+
story would otherwise pass on pre-existing green tests), and blocks with the question as
|
|
18
|
+
the reason. Companion principle: clarifying questions belong in the planning round, before
|
|
19
|
+
the loop starts.
|
|
20
|
+
|
|
3
21
|
## 0.7.0 — 2026-07-17
|
|
4
22
|
|
|
5
23
|
### Added
|
package/README.md
CHANGED
|
@@ -323,12 +323,18 @@ The loop stops when every story is `passes: true`. State lives **outside the mod
|
|
|
323
323
|
|
|
324
324
|
Every iteration emits token-free, harness-side feedback (Node console + local files — **zero agent tokens**):
|
|
325
325
|
|
|
326
|
-
- **Live console
|
|
327
|
-
|
|
326
|
+
- **Live console with progress + ETA** —
|
|
327
|
+
`▶ S6 (19/45 · 42%) — implementing… · ~1h44m left (Ø 4m/story)` … `✓ S6 done in 4m28s — 20/45 (44%) · ~1h40m left`.
|
|
328
|
+
The estimate uses the **average duration of stories completed in this run** (current
|
|
329
|
+
velocity); before the first story lands it falls back to the recorded history of previous
|
|
330
|
+
runs (`.yoke/story-durations.json`, last 50 stories, gitignored). No data yet → no estimate,
|
|
331
|
+
never a made-up one.
|
|
332
|
+
- **`.yoke/loop-status.json`** — the current state (now including `percent` and an `eta`
|
|
333
|
+
block); read it any time with `yoke loop status`:
|
|
328
334
|
```
|
|
329
|
-
Loop:
|
|
330
|
-
|
|
331
|
-
|
|
335
|
+
Loop: RUNNING on S6 "Weekly digest"
|
|
336
|
+
implementing · iteration 20 · 19/45 (42%) · updated 30s ago
|
|
337
|
+
~1h44m remaining (Ø 4m/story)
|
|
332
338
|
```
|
|
333
339
|
- **`.yoke/loop.log`** — an append-only timeline of every phase transition.
|
|
334
340
|
- **`--json`** — machine mode for supervisors: every status write is *also* emitted as one
|
|
@@ -355,14 +361,34 @@ A per-iteration **idle timeout** guards against a genuinely hung agent: if the a
|
|
|
355
361
|
output is **never** killed — the output stream *is* the liveness signal. Set a project default
|
|
356
362
|
with `loop.timeoutMinutes` in `.yoke/config.yaml`.
|
|
357
363
|
|
|
364
|
+
### Ambiguous stories: questions belong in planning
|
|
365
|
+
|
|
366
|
+
A loop run has nobody to ask, so the runner prompt always forbids questions. What the agent
|
|
367
|
+
does when an acceptance criterion is genuinely ambiguous is configurable:
|
|
368
|
+
|
|
369
|
+
- **Default (`resolve`) — never stop:** the agent picks the interpretation most consistent
|
|
370
|
+
with the other criteria and the existing code, states it in its final message, and the loop
|
|
371
|
+
keeps going.
|
|
372
|
+
- **Strict (`abort`):** the agent must not guess — it writes its open question(s) to
|
|
373
|
+
`.yoke/ambiguity.md` and stops. The loop consumes that file, skips verify (an unimplemented
|
|
374
|
+
story would otherwise sail through on pre-existing green tests), and blocks with the
|
|
375
|
+
question in the reason, e.g.
|
|
376
|
+
`story S6 stopped: ambiguous acceptance criteria — Which auth provider should S6 use?`
|
|
377
|
+
Answer by sharpening the story's acceptance criteria, then re-run.
|
|
378
|
+
|
|
379
|
+
Enable strict mode per run with `yoke loop run . --on-ambiguity=abort` or per project with
|
|
380
|
+
`loop.onAmbiguity: abort` in `.yoke/config.yaml`. Either way, the cheapest fix is upstream:
|
|
381
|
+
put every clarifying question into the PRD **before** the loop starts (`yoke prd draft`
|
|
382
|
+
criteria must be testable and decision-free).
|
|
383
|
+
|
|
358
384
|
The loop trusts **verify**, not the agent's exit code: a story whose tests are green is
|
|
359
385
|
committed even if the agent process exited non-zero (a common Windows `.cmd`-wrapper ghost).
|
|
360
386
|
A failing verify is retried up to `verify.retries` times (default 1) so a transient flake
|
|
361
387
|
self-heals while a real failure still blocks.
|
|
362
388
|
|
|
363
|
-
`.yoke/loop-status.json`, `.yoke/loop.log`,
|
|
364
|
-
`yoke retrofit` gitignores them (along with
|
|
365
|
-
`.yoke/proof/`) so they never trip the clean-tree gate.
|
|
389
|
+
`.yoke/loop-status.json`, `.yoke/loop.log`, `.yoke/loop.lock`, `.yoke/story-durations.json`,
|
|
390
|
+
and `.yoke/ambiguity.md` are runtime artifacts; `yoke retrofit` gitignores them (along with
|
|
391
|
+
`.yoke/worktrees/`, `.yoke/backup/`, and `.yoke/proof/`) so they never trip the clean-tree gate.
|
|
366
392
|
|
|
367
393
|
### Single-flight guard + cleanup
|
|
368
394
|
|
package/dist/cli.js
CHANGED
|
@@ -127,9 +127,14 @@ function main(argv) {
|
|
|
127
127
|
}
|
|
128
128
|
timeoutMinutes = v;
|
|
129
129
|
}
|
|
130
|
-
|
|
130
|
+
const oaArg = rest.find(a => a.startsWith('--on-ambiguity='))?.slice('--on-ambiguity='.length);
|
|
131
|
+
if (oaArg && oaArg !== 'resolve' && oaArg !== 'abort') {
|
|
132
|
+
console.error(`Invalid --on-ambiguity value: ${oaArg} (expected resolve|abort)`);
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, reviewer, review, timeoutMinutes, json, onAmbiguity: oaArg });
|
|
131
136
|
}
|
|
132
|
-
console.log('usage: yoke loop <on|off|status|cleanup|run [--max=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--isolate] [--timeout=<minutes>] [--json]> [targetDir]');
|
|
137
|
+
console.log('usage: yoke loop <on|off|status|cleanup|run [--max=N] [--runner=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--review] [--isolate] [--timeout=<minutes>] [--on-ambiguity=<resolve|abort>] [--json]> [targetDir]');
|
|
133
138
|
return 1;
|
|
134
139
|
}
|
|
135
140
|
case 'new': {
|
package/dist/loop/loop.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, unlinkSync } from 'node:fs';
|
|
1
|
+
import { existsSync, unlinkSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
3
|
import { loadPrd, savePrd, selectNextStory, allPass, progress } from './prd.js';
|
|
4
4
|
import { stopTheLineGate, preDispatchGate } from './gates.js';
|
|
@@ -19,6 +19,29 @@ function blockReason(base, targetDir, git) {
|
|
|
19
19
|
export function pauseFilePath(targetDir) {
|
|
20
20
|
return join(targetDir, '.yoke', 'loop.pause');
|
|
21
21
|
}
|
|
22
|
+
// Abort channel for an agent that hits genuinely undecidable acceptance criteria
|
|
23
|
+
// (only instructed to use it under loop.onAmbiguity: abort). Honoured whenever
|
|
24
|
+
// present: without this check, an agent that stopped without changes would sail
|
|
25
|
+
// through verify on pre-existing green tests and be falsely marked done.
|
|
26
|
+
export function ambiguityFilePath(dir) {
|
|
27
|
+
return join(dir, '.yoke', 'ambiguity.md');
|
|
28
|
+
}
|
|
29
|
+
function consumeAmbiguity(dir) {
|
|
30
|
+
const file = ambiguityFilePath(dir);
|
|
31
|
+
if (!existsSync(file))
|
|
32
|
+
return null;
|
|
33
|
+
let content = '';
|
|
34
|
+
try {
|
|
35
|
+
content = readFileSync(file, 'utf8');
|
|
36
|
+
}
|
|
37
|
+
catch { /* the signal alone still blocks */ }
|
|
38
|
+
try {
|
|
39
|
+
unlinkSync(file);
|
|
40
|
+
}
|
|
41
|
+
catch { /* best-effort consume */ }
|
|
42
|
+
const compact = content.replace(/\s+/g, ' ').trim().slice(0, 500);
|
|
43
|
+
return compact || 'agent reported ambiguous acceptance criteria without details';
|
|
44
|
+
}
|
|
22
45
|
export function runLoop(opts) {
|
|
23
46
|
let iterations = 0;
|
|
24
47
|
const reporter = opts.reporter ?? noopReporter;
|
|
@@ -67,12 +90,19 @@ export function runLoop(opts) {
|
|
|
67
90
|
if (opts.isolate) {
|
|
68
91
|
const wt = join(opts.targetDir, '.yoke', 'worktrees', story.id);
|
|
69
92
|
const wtPrd = join(wt, relative(opts.targetDir, opts.prdPath));
|
|
93
|
+
let landed = null;
|
|
70
94
|
try {
|
|
71
95
|
opts.git.addWorktree(opts.targetDir, wt);
|
|
72
96
|
const result = opts.runner({ targetDir: wt, story });
|
|
73
97
|
iterations++;
|
|
74
98
|
if (result.tokens)
|
|
75
99
|
reporter.addTokens(result.tokens);
|
|
100
|
+
const ambiguity = consumeAmbiguity(wt);
|
|
101
|
+
if (ambiguity) {
|
|
102
|
+
const reason = `story ${story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`;
|
|
103
|
+
reporter.blocked(reason);
|
|
104
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
105
|
+
}
|
|
76
106
|
// Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
|
|
77
107
|
// exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
|
|
78
108
|
reporter.phase('verifying');
|
|
@@ -121,6 +151,7 @@ export function runLoop(opts) {
|
|
|
121
151
|
savePrd(wtPrd, updated);
|
|
122
152
|
opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`);
|
|
123
153
|
opts.git.integrate(opts.targetDir, wt);
|
|
154
|
+
landed = progress(updated);
|
|
124
155
|
}
|
|
125
156
|
catch (e) {
|
|
126
157
|
const reason = blockReason(`isolated iteration failed for ${story.id}: ${e.message}`, opts.targetDir, opts.git);
|
|
@@ -133,12 +164,20 @@ export function runLoop(opts) {
|
|
|
133
164
|
}
|
|
134
165
|
catch { /* cleanup is best-effort */ }
|
|
135
166
|
}
|
|
167
|
+
if (landed)
|
|
168
|
+
reporter.storyDone({ id: story.id, title: story.title }, landed);
|
|
136
169
|
continue;
|
|
137
170
|
}
|
|
138
171
|
const result = opts.runner({ targetDir: opts.targetDir, story });
|
|
139
172
|
iterations++;
|
|
140
173
|
if (result.tokens)
|
|
141
174
|
reporter.addTokens(result.tokens);
|
|
175
|
+
const ambiguity = consumeAmbiguity(opts.targetDir);
|
|
176
|
+
if (ambiguity) {
|
|
177
|
+
const reason = `story ${story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`;
|
|
178
|
+
reporter.blocked(reason);
|
|
179
|
+
return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
|
|
180
|
+
}
|
|
142
181
|
// Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
|
|
143
182
|
// exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
|
|
144
183
|
reporter.phase('verifying');
|
|
@@ -211,5 +250,6 @@ export function runLoop(opts) {
|
|
|
211
250
|
finalProgress: progress(stories),
|
|
212
251
|
};
|
|
213
252
|
}
|
|
253
|
+
reporter.storyDone({ id: story.id, title: story.title }, progress(updated));
|
|
214
254
|
}
|
|
215
255
|
}
|
package/dist/loop/reporter.js
CHANGED
|
@@ -23,6 +23,39 @@ export function appendLog(dir, line, capBytes = LOG_CAP_BYTES) {
|
|
|
23
23
|
const trimmed = nl >= 0 ? tail.slice(nl + 1) : tail;
|
|
24
24
|
writeFileSync(file, `# … loop.log truncated …\n${trimmed}`);
|
|
25
25
|
}
|
|
26
|
+
export function fmtDuration(ms) {
|
|
27
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
28
|
+
if (s < 60)
|
|
29
|
+
return `${s}s`;
|
|
30
|
+
const m = Math.floor(s / 60);
|
|
31
|
+
if (m < 60)
|
|
32
|
+
return s % 60 > 0 ? `${m}m${s % 60}s` : `${m}m`;
|
|
33
|
+
const h = Math.floor(m / 60);
|
|
34
|
+
return m % 60 > 0 ? `${h}h${m % 60}m` : `${h}h`;
|
|
35
|
+
}
|
|
36
|
+
export const DURATION_HISTORY_CAP = 50;
|
|
37
|
+
function durationsPath(dir) {
|
|
38
|
+
return join(dir, '.yoke', 'story-durations.json');
|
|
39
|
+
}
|
|
40
|
+
export function readDurations(dir) {
|
|
41
|
+
try {
|
|
42
|
+
const arr = JSON.parse(readFileSync(durationsPath(dir), 'utf8'));
|
|
43
|
+
if (!Array.isArray(arr))
|
|
44
|
+
return [];
|
|
45
|
+
return arr.filter((d) => typeof d?.ms === 'number' && d.ms > 0);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function appendDuration(dir, d) {
|
|
52
|
+
const all = [...readDurations(dir), d].slice(-DURATION_HISTORY_CAP);
|
|
53
|
+
try {
|
|
54
|
+
mkdirSync(join(dir, '.yoke'), { recursive: true });
|
|
55
|
+
writeFileSync(durationsPath(dir), JSON.stringify(all));
|
|
56
|
+
}
|
|
57
|
+
catch { /* observability must never abort the loop */ }
|
|
58
|
+
}
|
|
26
59
|
function statusPath(dir) {
|
|
27
60
|
return join(dir, '.yoke', 'loop-status.json');
|
|
28
61
|
}
|
|
@@ -50,8 +83,23 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
50
83
|
sink(line); };
|
|
51
84
|
let current = null;
|
|
52
85
|
let tokens;
|
|
86
|
+
// ETA source: durations of stories completed in THIS run beat the persisted
|
|
87
|
+
// history of earlier runs (current velocity over old experience).
|
|
88
|
+
const history = readDurations(dir).map(h => h.ms);
|
|
89
|
+
const runDurations = [];
|
|
90
|
+
let storyStartedAt = null;
|
|
91
|
+
const percentOf = (p) => (p.total > 0 ? Math.round((p.passed / p.total) * 100) : 0);
|
|
92
|
+
const etaFor = (p) => {
|
|
93
|
+
const pool = runDurations.length > 0 ? runDurations : history;
|
|
94
|
+
if (pool.length === 0)
|
|
95
|
+
return undefined;
|
|
96
|
+
const avg = pool.reduce((a, b) => a + b, 0) / pool.length;
|
|
97
|
+
const remainingStories = Math.max(0, p.total - p.passed);
|
|
98
|
+
return { avgStoryMs: Math.round(avg), remainingStories, etaMs: Math.round(avg * remainingStories) };
|
|
99
|
+
};
|
|
53
100
|
const persist = (status, logLabel, consoleLine) => {
|
|
54
|
-
const
|
|
101
|
+
const withPercent = { ...status, percent: percentOf(status.progress) };
|
|
102
|
+
const next = tokens ? { ...withPercent, tokens: { ...tokens } } : withPercent;
|
|
55
103
|
current = next;
|
|
56
104
|
try {
|
|
57
105
|
writeStatus(dir, next);
|
|
@@ -67,8 +115,26 @@ export function makeReporter(dir, opts = {}, now = () => new Date()) {
|
|
|
67
115
|
return {
|
|
68
116
|
storyStart(story, iteration, progress) {
|
|
69
117
|
const ts = now().toISOString();
|
|
118
|
+
storyStartedAt = now().getTime();
|
|
119
|
+
const eta = etaFor(progress);
|
|
120
|
+
const hint = eta ? ` · ~${fmtDuration(eta.etaMs)} left (Ø ${fmtDuration(eta.avgStoryMs)}/story)` : '';
|
|
70
121
|
persist({ state: 'running', phase: 'implementing', story: story.id, storyTitle: story.title,
|
|
71
|
-
iteration, progress, startedAt: ts, updatedAt: ts }, 'implementing', `▶ ${story.id} (${progress.passed}/${progress.total}) — implementing
|
|
122
|
+
iteration, progress, ...(eta ? { eta } : {}), startedAt: ts, updatedAt: ts }, 'implementing', `▶ ${story.id} (${progress.passed}/${progress.total} · ${percentOf(progress)}%) — implementing…${hint}`);
|
|
123
|
+
},
|
|
124
|
+
storyDone(story, progress) {
|
|
125
|
+
const t = now().getTime();
|
|
126
|
+
const ms = storyStartedAt !== null ? Math.max(0, t - storyStartedAt) : undefined;
|
|
127
|
+
storyStartedAt = null;
|
|
128
|
+
if (ms !== undefined) {
|
|
129
|
+
runDurations.push(ms);
|
|
130
|
+
appendDuration(dir, { storyId: story.id, ms });
|
|
131
|
+
}
|
|
132
|
+
const eta = etaFor(progress);
|
|
133
|
+
const base = current ?? emptyStatus(now().toISOString());
|
|
134
|
+
const took = ms !== undefined ? ` in ${fmtDuration(ms)}` : '';
|
|
135
|
+
const hint = eta && eta.remainingStories > 0 ? ` · ~${fmtDuration(eta.etaMs)} left` : '';
|
|
136
|
+
persist({ ...base, state: 'running', phase: undefined, story: story.id, storyTitle: story.title,
|
|
137
|
+
progress, ...(eta ? { eta } : {}), updatedAt: now().toISOString() }, 'story-done', `✓ ${story.id} done${took} — ${progress.passed}/${progress.total} (${percentOf(progress)}%)${hint}`);
|
|
72
138
|
},
|
|
73
139
|
phase(phase) {
|
|
74
140
|
if (!current)
|
|
@@ -105,5 +171,5 @@ function emptyStatus(ts) {
|
|
|
105
171
|
return { state: 'running', iteration: 0, progress: { passed: 0, total: 0 }, startedAt: ts, updatedAt: ts };
|
|
106
172
|
}
|
|
107
173
|
export const noopReporter = {
|
|
108
|
-
storyStart() { }, phase() { }, blocked() { }, complete() { }, capReached() { }, paused() { }, addTokens() { },
|
|
174
|
+
storyStart() { }, storyDone() { }, phase() { }, blocked() { }, complete() { }, capReached() { }, paused() { }, addTokens() { },
|
|
109
175
|
};
|
package/dist/loop/run-command.js
CHANGED
|
@@ -6,7 +6,7 @@ import { runLoop } from './loop.js';
|
|
|
6
6
|
import { realGitOps } from './git.js';
|
|
7
7
|
import { makeRunner, makeReviewRunner, isAgentAvailable } from './runner.js';
|
|
8
8
|
import { commandVerifier, retryingVerifier } from './verify.js';
|
|
9
|
-
import { readStatus, makeReporter } from './reporter.js';
|
|
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
12
|
export const DEFAULT_IDLE_MINUTES = 20;
|
|
@@ -46,9 +46,13 @@ export function loopStatus(targetDir, now = () => new Date()) {
|
|
|
46
46
|
if (!st)
|
|
47
47
|
return `Loop: ${enabled ? 'enabled' : 'disabled'}\nPRD: ${prog}`;
|
|
48
48
|
const head = `Loop: ${st.state.toUpperCase()}${st.story ? ` on ${st.story}${st.storyTitle ? ` "${st.storyTitle}"` : ''}` : ''}`;
|
|
49
|
-
const
|
|
49
|
+
const pct = st.percent !== undefined ? ` (${st.percent}%)` : '';
|
|
50
|
+
const meta = [st.phase, `iteration ${st.iteration}`, `${st.progress.passed}/${st.progress.total}${pct}`, `updated ${relativeTime(st.updatedAt, now())}`]
|
|
50
51
|
.filter(Boolean).join(' · ');
|
|
51
52
|
const lines = [head, ` ${meta}`];
|
|
53
|
+
if (st.state === 'running' && st.eta && st.eta.remainingStories > 0) {
|
|
54
|
+
lines.push(` ~${fmtDuration(st.eta.etaMs)} remaining (Ø ${fmtDuration(st.eta.avgStoryMs)}/story)`);
|
|
55
|
+
}
|
|
52
56
|
if (st.reason)
|
|
53
57
|
lines.push(` reason: ${st.reason}`);
|
|
54
58
|
const ageMs = now().getTime() - Date.parse(st.updatedAt);
|
|
@@ -95,7 +99,7 @@ export function runLoopCommand(targetDir, opts) {
|
|
|
95
99
|
}
|
|
96
100
|
// Token reporting is part of the machine interface: in --json mode a claude
|
|
97
101
|
// runner switches to stream-json so cumulative usage rides on every status.
|
|
98
|
-
runner = makeRunner(runnerAgent, idleMs, { tokenReport: opts.json === true });
|
|
102
|
+
runner = makeRunner(runnerAgent, idleMs, { tokenReport: opts.json === true, onAmbiguity: opts.onAmbiguity ?? config.loop.onAmbiguity });
|
|
99
103
|
}
|
|
100
104
|
let review = opts.reviewRunner;
|
|
101
105
|
if (!review && (opts.review || opts.reviewer)) {
|
package/dist/loop/runner.js
CHANGED
|
@@ -6,7 +6,7 @@ import { loadContext, formatForPrompt, contextDir } from '../context/context.js'
|
|
|
6
6
|
export function contextBlockFor(targetDir) {
|
|
7
7
|
return formatForPrompt(loadContext(contextDir(targetDir)));
|
|
8
8
|
}
|
|
9
|
-
export function buildClaudePrompt(story, context) {
|
|
9
|
+
export function buildClaudePrompt(story, context, onAmbiguity = 'resolve') {
|
|
10
10
|
const criteria = story.acceptance.map(a => `- ${a}`).join('\n');
|
|
11
11
|
const lines = [
|
|
12
12
|
'You are an autonomous coding agent running inside the Yoke loop.',
|
|
@@ -14,7 +14,9 @@ export function buildClaudePrompt(story, context) {
|
|
|
14
14
|
];
|
|
15
15
|
if (context)
|
|
16
16
|
lines.push('', context);
|
|
17
|
-
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.', '', 'Working rules:', '- Add nothing beyond what the story requires: no extra features, abstractions, comments, or defensive code for cases that cannot happen.', '- Do not create summary, plan, or analysis documents — only files the story itself needs.', '- If a check fails, fix the root cause; never bypass it (e.g. --no-verify) or pass by weakening tests.', '- Report the outcome faithfully: if a criterion is unmet or tests fail, say so plainly instead of claiming success.', '-
|
|
17
|
+
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.', '', 'Working rules:', '- Add nothing beyond what the story requires: no extra features, abstractions, comments, or defensive code for cases that cannot happen.', '- Do not create summary, plan, or analysis documents — only files the story itself needs.', '- If a check fails, fix the root cause; never bypass it (e.g. --no-verify) or pass by weakening tests.', '- Report the outcome faithfully: if a criterion is unmet or tests fail, say so plainly instead of claiming success.', '- Never ask questions or wait for input — you run unattended and nobody can answer.', onAmbiguity === 'abort'
|
|
18
|
+
? '- If an acceptance criterion is genuinely undecidable, do NOT guess: write the open question(s) to .yoke/ambiguity.md, change nothing else, and stop.'
|
|
19
|
+
: '- If an acceptance criterion is ambiguous, resolve it yourself in the way most consistent with the other criteria and the existing code, and state your interpretation in your final message.', '- Keep your final message to a few short sentences: what changed and what you verified.');
|
|
18
20
|
return lines.join('\n');
|
|
19
21
|
}
|
|
20
22
|
export function buildReviewPrompt(story, context) {
|
|
@@ -226,7 +228,7 @@ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
|
|
|
226
228
|
// redundant for claude and meaningless elsewhere; kept for caller compatibility.
|
|
227
229
|
const captureTokens = agent === 'claude';
|
|
228
230
|
return (ctx) => {
|
|
229
|
-
const base = runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir)), ctx.targetDir, captureTokens);
|
|
231
|
+
const base = runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir), opts.onAmbiguity), ctx.targetDir, captureTokens);
|
|
230
232
|
const inv = buildWatchdogInvocation(base, idleTimeoutMs);
|
|
231
233
|
if (captureTokens) {
|
|
232
234
|
const capture = opts.execCapture ?? runCliCapture;
|
package/dist/retrofit/config.js
CHANGED
|
@@ -9,7 +9,13 @@ const SmokeSchema = z.object({ baseUrl: z.string().min(1), flows: z.array(SmokeF
|
|
|
9
9
|
export const YokeConfigSchema = z.object({
|
|
10
10
|
canonVersion: z.string().min(1),
|
|
11
11
|
agents: z.array(AgentSchema),
|
|
12
|
-
loop: z.object({
|
|
12
|
+
loop: z.object({
|
|
13
|
+
enabled: z.boolean(),
|
|
14
|
+
timeoutMinutes: z.number().optional(),
|
|
15
|
+
// Ambiguous acceptance criteria: 'resolve' (default — agent decides and continues)
|
|
16
|
+
// or 'abort' (agent stops the story via .yoke/ambiguity.md for a human decision).
|
|
17
|
+
onAmbiguity: z.enum(['resolve', 'abort']).optional(),
|
|
18
|
+
}),
|
|
13
19
|
verify: z.object({ command: z.string().min(1), retries: z.number().int().nonnegative().optional() }).optional(),
|
|
14
20
|
codeGraph: CodeGraphSchema.optional(),
|
|
15
21
|
smoke: SmokeSchema.optional(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hecer/yoke",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "One harness, three agents, zero trust in \"done\" — cross-agent coding harness for Claude Code, Codex CLI, and Gemini CLI: one skill canon, mechanical safety gates, an autonomous loop with screenshot/video proofs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|