@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 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** `▶ S6 (19/45) implementing… · verifying… ✔ committed → 20/45`.
327
- - **`.yoke/loop-status.json`**the current state; read it any time with `yoke loop status`:
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: BLOCKED on S5 "Segment schemas"
330
- verifying · iteration 19 · 18/45 · updated 2026-06-29T10:00:00.000Z
331
- reason: story did not verify (working tree has uncommitted changes — review/clean before re-running)
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`, and `.yoke/loop.lock` are runtime artifacts;
364
- `yoke retrofit` gitignores them (along with `.yoke/worktrees/`, `.yoke/backup/`, and
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
- return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, reviewer, review, timeoutMinutes, json });
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
  }
@@ -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 next = tokens ? { ...status, tokens: { ...tokens } } : status;
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
  };
@@ -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 meta = [st.phase, `iteration ${st.iteration}`, `${st.progress.passed}/${st.progress.total}`, `updated ${relativeTime(st.updatedAt, now())}`]
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)) {
@@ -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.', '- Keep your final message to a few short sentences: what changed and what you verified.');
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;
@@ -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({ enabled: z.boolean(), timeoutMinutes: z.number().optional() }),
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(),
@@ -8,6 +8,8 @@ export const YOKE_IGNORE_LINES = [
8
8
  '.yoke/loop.lock',
9
9
  '.yoke/loop.pause',
10
10
  '.yoke/runner.pid',
11
+ '.yoke/ambiguity.md',
12
+ '.yoke/story-durations.json',
11
13
  '.yoke/proof/',
12
14
  ];
13
15
  const HEADER = '# Yoke runtime artifacts (managed by yoke retrofit)';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hecer/yoke",
3
- "version": "0.7.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": {