@hecer/yoke 0.7.0 → 0.9.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,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 — 2026-07-22
4
+
5
+ ### Added
6
+ - **Performance budget gate** (`perf.command` in `.yoke/config.yaml`, optional `perf.retries`).
7
+ A benchmark command with the same contract as verify (exit 0 = within budget) runs **after
8
+ verify** on every story — new loop phase `perf`, `YOKE_STORY` exposed, worktree-aware in
9
+ `--isolate` mode. A red benchmark blocks the story
10
+ (`story S6 exceeded its performance budget: …`) no matter how clean the diff is. When the
11
+ gate is configured, the implementer prompt names the budget command so agents keep hot
12
+ paths efficient and never "simplify away" an optimization without re-running the benchmark.
13
+ - **`performance` canon skill** (28 skills now): efficiency as a measured requirement —
14
+ clean-by-default with the decision ladder (minimal-code → measurable acceptance criterion →
15
+ project perf gate), profile-first, optimize leaves not boundaries, benchmarks committed as
16
+ tests, the *why* of every optimization versioned so future agents don't clean fast code
17
+ back to slow.
18
+ - **`authoring-prd` guidance**: performance requirements belong in acceptance criteria as
19
+ numbers ("imports 1M rows in < 2s"), and every clarifying question belongs in the planning
20
+ round — a criterion still needing a decision is not loop-ready.
21
+
22
+ ## 0.8.0 — 2026-07-20
23
+
24
+ ### Added
25
+ - **Live progress + ETA.** Story completions are now first-class events: the console shows
26
+ `✓ S6 done in 4m28s — 20/45 (44%) · ~1h40m left`, every status (file, NDJSON stream,
27
+ `yoke loop status`) carries `percent` and an `eta` block. The estimate averages the
28
+ durations of stories completed **in this run** (current velocity) and falls back to the
29
+ persisted history of previous runs (`.yoke/story-durations.json`, last 50, gitignored).
30
+ No data → no estimate, never an invented one.
31
+ - **Ambiguity policy** (`loop.onAmbiguity` / `--on-ambiguity=<resolve|abort>`). The runner
32
+ prompt now always forbids asking questions (a loop run has nobody to answer). Default
33
+ `resolve`: the agent settles ambiguous criteria itself, states the interpretation, and the
34
+ loop never stops. Opt-in `abort`: the agent writes its open questions to
35
+ `.yoke/ambiguity.md` and stops; the loop consumes the file, skips verify (an unimplemented
36
+ story would otherwise pass on pre-existing green tests), and blocks with the question as
37
+ the reason. Companion principle: clarifying questions belong in the planning round, before
38
+ the loop starts.
39
+
3
40
  ## 0.7.0 — 2026-07-17
4
41
 
5
42
  ### Added
package/README.md CHANGED
@@ -6,14 +6,18 @@
6
6
 
7
7
  **Yoke** installs one curated canon of skills, **mechanical safety gates**, and tool wiring into any project — natively for **Claude Code, OpenAI Codex CLI, and Gemini CLI**. Then, when you want it, an opt-in autonomous loop ships your spec story-by-story: tested, cross-model-reviewed, committed — **with a screenshot to prove every story and a video for every failure**.
8
8
 
9
+ [![npm](https://img.shields.io/npm/v/%40hecer%2Fyoke?logo=npm&color=CB3837)](https://www.npmjs.com/package/@hecer/yoke)
10
+ [![npm downloads](https://img.shields.io/npm/dm/%40hecer%2Fyoke?logo=npm)](https://www.npmjs.com/package/@hecer/yoke)
9
11
  [![CI](https://github.com/HECer/yoke/actions/workflows/ci.yml/badge.svg)](https://github.com/HECer/yoke/actions/workflows/ci.yml)
10
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](#-license)
11
13
  ![Node](https://img.shields.io/badge/node-%E2%89%A520-339933?logo=node.js&logoColor=white)
12
14
  ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
13
- ![Tests](https://img.shields.io/badge/tests-322%20passing-brightgreen.svg)
15
+ ![Tests](https://img.shields.io/badge/tests-439%20passing-brightgreen.svg)
14
16
  ![Agents](https://img.shields.io/badge/agents-Claude%20%7C%20Codex%20%7C%20Gemini-8A2BE2)
15
17
  ![Built with TDD](https://img.shields.io/badge/built%20with-TDD%20%2B%20review-ff69b4.svg)
16
18
 
19
+ **Install:** [`npm i -g @hecer/yoke`](https://www.npmjs.com/package/@hecer/yoke)
20
+
17
21
  </div>
18
22
 
19
23
  > **TL;DR** — `yoke new my-app --idea="..."` scaffolds a git repo, installs the harness for all three agents, and drafts a story backlog from your idea. `yoke loop run my-app --isolate --review` then implements it story by story behind hard gates: **clean tree → acceptance criteria → your real tests green → an independent model approves → commit**. If any gate is red, nothing is committed. When a story is done, there's a photo of it in `.yoke/proof/<story>/`.
@@ -46,16 +50,18 @@ $ yoke prd check reading-app
46
50
 
47
51
  $ yoke loop on reading-app
48
52
  $ yoke loop run reading-app --isolate --review --max=10
49
- ▶ STORY-1 (0/8) — implementing… · verifying… · reviewing… committed → 1/8
50
- STORY-2 (1/8) — implementing… · verifying… ✔ committed → 2/8
51
- ▶ STORY-3 (2/8) — implementing… · verifying… blocked: story did not verify (tests red)
53
+ ▶ STORY-1 (0/8 · 0%) — implementing… · verifying… · reviewing… · committing…
54
+ STORY-1 done in 3m12s — 1/8 (13%) · ~22m left
55
+ ▶ STORY-2 (1/8 · 13%) — implementing… · ~22m left 3m12s/story)
56
+ ✓ STORY-2 done in 2m48s — 2/8 (25%) · ~18m left
57
+ ▶ STORY-3 (2/8 · 25%) — implementing… ✘ blocked: story did not verify (tests red)
52
58
  # nothing was committed. fix, then re-run.
53
59
 
54
60
  $ ls reading-app/.yoke/proof/STORY-2/
55
61
  home.png list.png # photographic evidence, labelled per story
56
62
  ```
57
63
 
58
- Every claim in that transcript is enforced by code paths with tests behind them — 322 of them, and this repo was built by its own loop and gates ([how it was built](#-why--how-it-was-built)).
64
+ Every claim in that transcript is enforced by code paths with tests behind them — 439 of them, and this repo was built by its own loop and gates ([how it was built](#-why--how-it-was-built)).
59
65
 
60
66
  ## 🚀 Quickstart
61
67
 
@@ -197,7 +203,7 @@ Three layers — **Canon** (`yoke validate`) → **Retrofit** (`yoke retrofit`)
197
203
  > instructions (tech stack, workflow, `@`-includes) inside it. Works in any yoke-written file;
198
204
  > content *outside* the markers is still replaced (and backed up under `.yoke/backup/`).
199
205
 
200
- ## 🧰 What's in the canon — 27 skills
206
+ ## 🧰 What's in the canon — 28 skills
201
207
 
202
208
  `yoke retrofit` installs all of these into each agent natively. Provenance is credited in [`canon/skills/ATTRIBUTION.md`](canon/skills/ATTRIBUTION.md).
203
209
 
@@ -233,13 +239,14 @@ To stop overlapping skills from auto-invoking against each other, `canon/AGENTS.
233
239
  | `retro` | Engineering retrospective from commit history |
234
240
  | `document-release` | Post-ship documentation sync (README / CHANGELOG / …) |
235
241
 
236
- **Yoke-native** — *authored or adapted for this harness (7)*
242
+ **Yoke-native** — *authored or adapted for this harness (8)*
237
243
 
238
244
  | Skill | What it does |
239
245
  |---|---|
240
246
  | `yoke-retrofit` | Set up the Yoke harness in a project (detect → plan → apply) |
241
247
  | `authoring-prd` | Slice a product idea into loop-ready stories with testable acceptance criteria |
242
248
  | `minimal-code` | Write the least code that solves the task (YAGNI; ponytail-derived) |
249
+ | `performance` | Efficiency as a measured requirement: benchmarks as tests, budgets as gates, optimizations local + documented |
243
250
  | `maintaining-context` | Keep `.yoke/context/` the durable source of truth (the Context layer) |
244
251
  | `workflow` | The default order of operations, from idea to deploy |
245
252
  | `unslop-ui` | Detect & remove AI-slop design tells (purple gradients, neon glow, emoji-icons…) |
@@ -302,6 +309,7 @@ yoke loop run . \
302
309
  --runner=codex \ # implement with Codex…
303
310
  --reviewer=claude \ # …review with Claude (role separation)
304
311
  --isolate \ # each story in a throwaway git worktree
312
+ --on-ambiguity=abort \ # strict: stop on undecidable criteria instead of guessing
305
313
  --max=20
306
314
  yoke loop off . # disable
307
315
  ```
@@ -323,12 +331,18 @@ The loop stops when every story is `passes: true`. State lives **outside the mod
323
331
 
324
332
  Every iteration emits token-free, harness-side feedback (Node console + local files — **zero agent tokens**):
325
333
 
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`:
334
+ - **Live console with progress + ETA**
335
+ `▶ S6 (19/45 · 42%) implementing… · ~1h44m left 4m/story)` `✓ S6 done in 4m28s — 20/45 (44%) · ~1h40m left`.
336
+ The estimate uses the **average duration of stories completed in this run** (current
337
+ velocity); before the first story lands it falls back to the recorded history of previous
338
+ runs (`.yoke/story-durations.json`, last 50 stories, gitignored). No data yet → no estimate,
339
+ never a made-up one.
340
+ - **`.yoke/loop-status.json`** — the current state (now including `percent` and an `eta`
341
+ block); read it any time with `yoke loop status`:
328
342
  ```
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)
343
+ Loop: RUNNING on S6 "Weekly digest"
344
+ implementing · iteration 20 · 19/45 (42%) · updated 30s ago
345
+ ~1h44m remaining (Ø 4m/story)
332
346
  ```
333
347
  - **`.yoke/loop.log`** — an append-only timeline of every phase transition.
334
348
  - **`--json`** — machine mode for supervisors: every status write is *also* emitted as one
@@ -355,14 +369,58 @@ A per-iteration **idle timeout** guards against a genuinely hung agent: if the a
355
369
  output is **never** killed — the output stream *is* the liveness signal. Set a project default
356
370
  with `loop.timeoutMinutes` in `.yoke/config.yaml`.
357
371
 
372
+ ### Ambiguous stories: questions belong in planning
373
+
374
+ A loop run has nobody to ask, so the runner prompt always forbids questions. What the agent
375
+ does when an acceptance criterion is genuinely ambiguous is configurable:
376
+
377
+ - **Default (`resolve`) — never stop:** the agent picks the interpretation most consistent
378
+ with the other criteria and the existing code, states it in its final message, and the loop
379
+ keeps going.
380
+ - **Strict (`abort`):** the agent must not guess — it writes its open question(s) to
381
+ `.yoke/ambiguity.md` and stops. The loop consumes that file, skips verify (an unimplemented
382
+ story would otherwise sail through on pre-existing green tests), and blocks with the
383
+ question in the reason, e.g.
384
+ `story S6 stopped: ambiguous acceptance criteria — Which auth provider should S6 use?`
385
+ Answer by sharpening the story's acceptance criteria, then re-run.
386
+
387
+ Enable strict mode per run with `yoke loop run . --on-ambiguity=abort` or per project with
388
+ `loop.onAmbiguity: abort` in `.yoke/config.yaml`. Either way, the cheapest fix is upstream:
389
+ put every clarifying question into the PRD **before** the loop starts (`yoke prd draft`
390
+ criteria must be testable and decision-free).
391
+
392
+ ### Performance budgets: efficiency as a gate, not a style
393
+
394
+ Clean code is the default (the `minimal-code` skill) — but when efficiency matters, "should
395
+ be fast" is a vibe the loop cannot enforce. Yoke makes it mechanical, at two levels:
396
+
397
+ - **Per story:** write the requirement as a **measurable acceptance criterion**
398
+ ("imports 1M rows in < 2s, asserted by the bench test") and let your verify tests measure
399
+ it — no new machinery needed.
400
+ - **Per project:** wire a benchmark as a standing **perf gate** in `.yoke/config.yaml`:
401
+
402
+ ```yaml
403
+ perf:
404
+ command: node bench/check-budget.mjs # exit 0 = within budget
405
+ retries: 1 # benchmarks are noisy; same retry logic as verify
406
+ ```
407
+
408
+ The loop runs it **after verify** on every story (phase `perf`, with `YOKE_STORY` set); a
409
+ red benchmark blocks the story — `story S6 exceeded its performance budget: p95 62ms > budget 50ms` —
410
+ no matter how clean the diff was. The implementer prompt names the budget command, so the
411
+ agent knows not to trade hot-path efficiency for style and never "simplifies away" an
412
+ optimization without re-running the benchmark. The `performance` canon skill carries the
413
+ method: profile first, optimize leaves not boundaries, commit benchmarks as tests, version
414
+ the *why* of every optimization in `context/DECISIONS.md`.
415
+
358
416
  The loop trusts **verify**, not the agent's exit code: a story whose tests are green is
359
417
  committed even if the agent process exited non-zero (a common Windows `.cmd`-wrapper ghost).
360
418
  A failing verify is retried up to `verify.retries` times (default 1) so a transient flake
361
419
  self-heals while a real failure still blocks.
362
420
 
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.
421
+ `.yoke/loop-status.json`, `.yoke/loop.log`, `.yoke/loop.lock`, `.yoke/story-durations.json`,
422
+ and `.yoke/ambiguity.md` are runtime artifacts; `yoke retrofit` gitignores them (along with
423
+ `.yoke/worktrees/`, `.yoke/backup/`, and `.yoke/proof/`) so they never trip the clean-tree gate.
366
424
 
367
425
  ### Single-flight guard + cleanup
368
426
 
@@ -34,6 +34,8 @@ skills:
34
34
  - { id: visual-verification, path: skills/visual-verification, kind: methodology }
35
35
  # zero-to-100 bootstrap (kind: methodology)
36
36
  - { id: authoring-prd, path: skills/authoring-prd, kind: methodology }
37
+ # measured efficiency (kind: methodology)
38
+ - { id: performance, path: skills/performance, kind: methodology }
37
39
  policy:
38
40
  - { path: policy/gates.md }
39
41
  - { path: policy/roles.md }
@@ -21,6 +21,14 @@ good stories (small, testable, ordered) let it run overnight.
21
21
  5. **Greenfield: STORY-1 scaffolds.** Project skeleton + runnable test suite + a criterion
22
22
  that the verify command (`verify.command` in `.yoke/config.yaml`) exits 0. Every later
23
23
  story stands on a green pipeline.
24
+ 6. **Performance requirements are acceptance criteria — with numbers.** "Should be fast" is
25
+ a vibe the loop cannot gate; "imports 1M rows in < 2s (asserted by the bench test)" is a
26
+ criterion. If the whole project has a budget, wire `perf.command` in `.yoke/config.yaml`
27
+ (see the `performance` skill) instead of repeating it per story.
28
+ 7. **Ask everything now.** Clarifying questions belong in this planning round — a loop run
29
+ has nobody to ask. A criterion that still needs a decision ("TBD", "choose a provider")
30
+ is not loop-ready; resolve it here or the agent will either guess (default) or block
31
+ (`--on-ambiguity=abort`).
24
32
 
25
33
  ## Format (`.yoke/prd.yaml`)
26
34
 
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: performance
3
+ description: Use when a task has efficiency requirements or touches a hot path — make performance a measured requirement (benchmarks as tests, budgets as gates), keep interfaces clean and optimizations local, and version the WHY so future agents don't "clean up" fast code back to slow.
4
+ ---
5
+
6
+ # Performance (measured, not vibed)
7
+
8
+ "Efficient" is a requirement, not a code style. Untested performance claims rot exactly like
9
+ untested behavior claims. This skill makes efficiency mechanical — the same move Yoke makes
10
+ for everything else.
11
+
12
+ ## The decision ladder
13
+
14
+ 1. **Default: clean + minimal.** For ~90% of code, the `minimal-code` rules ARE the
15
+ performance strategy — less code, fewer layers, no speculative abstraction. Do not
16
+ micro-optimize code that no measurement flagged (premature optimization).
17
+ 2. **Performance requirement? Make it an acceptance criterion.** A number, not an adjective:
18
+ - Good: "imports 1M rows in < 2s", "p95 request latency < 50ms in the bench test",
19
+ "no allocation inside the render loop (verified by the bench assertion)"
20
+ - Bad: "should be fast", "optimize the importer"
21
+ 3. **Whole-project budget? Use the perf gate.** Set `perf.command` in `.yoke/config.yaml`
22
+ (a benchmark script; exit 0 = within budget). The loop runs it after verify — a story
23
+ that breaks the budget is blocked, no matter how clean its diff is.
24
+
25
+ ## Writing efficient code that agents can maintain
26
+
27
+ - **Clean at the boundaries, aggressive in the leaves.** Interfaces, data flow, and names
28
+ stay simple and obvious. Optimization lives inside a few clearly-bounded leaf functions
29
+ whose contracts are pinned by tests. An ugly-fast function is maintainable; an
30
+ ugly-fast architecture is not.
31
+ - **Profile before optimizing.** Find the actual hot 5% (a profiler, a timing harness, the
32
+ bench script) — never optimize from intuition. Record the measurement in the PR/commit.
33
+ - **Benchmarks are tests. Commit them.** An optimization without a committed benchmark is
34
+ one refactor away from silently disappearing. The bench script doubles as `perf.command`.
35
+ - **Version the WHY.** Every non-obvious optimization gets a one-line comment
36
+ (`perf: avoids N+1 — see bench/import.mjs`) and, if it shaped a design, a line in
37
+ `context/DECISIONS.md`. The most common AI maintenance accident is a later agent
38
+ "simplifying" fast code back to slow because nothing said why it was shaped that way.
39
+ - **Know the classics before reaching for cleverness:** right data structure (map vs list
40
+ scan), batching over per-item round trips (N+1), streaming over buffering, avoiding
41
+ repeated work in loops, caching only with a measured hit rate and an invalidation story.
42
+
43
+ ## Red flags
44
+
45
+ - Optimizing without a measurement or a budget → stop, measure first.
46
+ - A "refactor" or "cleanup" story touching code with `perf:` comments → re-run the bench
47
+ before AND after; keep the numbers in the story outcome.
48
+ - Hand-rolled cleverness where the stdlib is already O(right) → `minimal-code` wins.
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,44 @@ 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
+ // Run a gate command with the story id exposed via YOKE_STORY (restored after),
30
+ // so cumulative fixtures and story-aware benchmarks know which story is on trial.
31
+ function runGate(gate, dir, storyId) {
32
+ const prev = process.env.YOKE_STORY;
33
+ process.env.YOKE_STORY = storyId;
34
+ try {
35
+ return gate(dir);
36
+ }
37
+ finally {
38
+ if (prev === undefined)
39
+ delete process.env.YOKE_STORY;
40
+ else
41
+ process.env.YOKE_STORY = prev;
42
+ }
43
+ }
44
+ function consumeAmbiguity(dir) {
45
+ const file = ambiguityFilePath(dir);
46
+ if (!existsSync(file))
47
+ return null;
48
+ let content = '';
49
+ try {
50
+ content = readFileSync(file, 'utf8');
51
+ }
52
+ catch { /* the signal alone still blocks */ }
53
+ try {
54
+ unlinkSync(file);
55
+ }
56
+ catch { /* best-effort consume */ }
57
+ const compact = content.replace(/\s+/g, ' ').trim().slice(0, 500);
58
+ return compact || 'agent reported ambiguous acceptance criteria without details';
59
+ }
22
60
  export function runLoop(opts) {
23
61
  let iterations = 0;
24
62
  const reporter = opts.reporter ?? noopReporter;
@@ -67,27 +105,23 @@ export function runLoop(opts) {
67
105
  if (opts.isolate) {
68
106
  const wt = join(opts.targetDir, '.yoke', 'worktrees', story.id);
69
107
  const wtPrd = join(wt, relative(opts.targetDir, opts.prdPath));
108
+ let landed = null;
70
109
  try {
71
110
  opts.git.addWorktree(opts.targetDir, wt);
72
111
  const result = opts.runner({ targetDir: wt, story });
73
112
  iterations++;
74
113
  if (result.tokens)
75
114
  reporter.addTokens(result.tokens);
115
+ const ambiguity = consumeAmbiguity(wt);
116
+ if (ambiguity) {
117
+ const reason = `story ${story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`;
118
+ reporter.blocked(reason);
119
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
120
+ }
76
121
  // Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
77
122
  // exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
78
123
  reporter.phase('verifying');
79
- const prevStory = process.env.YOKE_STORY;
80
- process.env.YOKE_STORY = story.id;
81
- let verdict;
82
- try {
83
- verdict = opts.verify(wt);
84
- }
85
- finally {
86
- if (prevStory === undefined)
87
- delete process.env.YOKE_STORY;
88
- else
89
- process.env.YOKE_STORY = prevStory;
90
- }
124
+ const verdict = runGate(opts.verify, wt, story.id);
91
125
  if (!verdict.passed) {
92
126
  const base = result.success
93
127
  ? `story ${story.id} did not verify: ${verdict.summary}`
@@ -96,6 +130,15 @@ export function runLoop(opts) {
96
130
  reporter.blocked(reason);
97
131
  return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
98
132
  }
133
+ if (opts.perf) {
134
+ reporter.phase('perf');
135
+ const perfVerdict = runGate(opts.perf, wt, story.id);
136
+ if (!perfVerdict.passed) {
137
+ const reason = blockReason(`story ${story.id} exceeded its performance budget: ${perfVerdict.summary}`, opts.targetDir, opts.git);
138
+ reporter.blocked(reason);
139
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
140
+ }
141
+ }
99
142
  const summary = result.success
100
143
  ? result.summary
101
144
  : `${result.summary} (runner exited non-zero but verify is green)`;
@@ -121,6 +164,7 @@ export function runLoop(opts) {
121
164
  savePrd(wtPrd, updated);
122
165
  opts.git.commitAll(wt, `yoke: complete ${story.id} ${story.title}`);
123
166
  opts.git.integrate(opts.targetDir, wt);
167
+ landed = progress(updated);
124
168
  }
125
169
  catch (e) {
126
170
  const reason = blockReason(`isolated iteration failed for ${story.id}: ${e.message}`, opts.targetDir, opts.git);
@@ -133,27 +177,24 @@ export function runLoop(opts) {
133
177
  }
134
178
  catch { /* cleanup is best-effort */ }
135
179
  }
180
+ if (landed)
181
+ reporter.storyDone({ id: story.id, title: story.title }, landed);
136
182
  continue;
137
183
  }
138
184
  const result = opts.runner({ targetDir: opts.targetDir, story });
139
185
  iterations++;
140
186
  if (result.tokens)
141
187
  reporter.addTokens(result.tokens);
188
+ const ambiguity = consumeAmbiguity(opts.targetDir);
189
+ if (ambiguity) {
190
+ const reason = `story ${story.id} stopped: ambiguous acceptance criteria — ${ambiguity}`;
191
+ reporter.blocked(reason);
192
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
193
+ }
142
194
  // Verify is the source of truth — NOT the runner's exit code. A spurious non-zero
143
195
  // exit (e.g. a Windows .cmd wrapper ghost) must not block a story whose tests are green.
144
196
  reporter.phase('verifying');
145
- const prevStory = process.env.YOKE_STORY;
146
- process.env.YOKE_STORY = story.id;
147
- let verdict;
148
- try {
149
- verdict = opts.verify(opts.targetDir);
150
- }
151
- finally {
152
- if (prevStory === undefined)
153
- delete process.env.YOKE_STORY;
154
- else
155
- process.env.YOKE_STORY = prevStory;
156
- }
197
+ const verdict = runGate(opts.verify, opts.targetDir, story.id);
157
198
  if (!verdict.passed) {
158
199
  const base = result.success
159
200
  ? `story ${story.id} did not verify: ${verdict.summary}`
@@ -167,6 +208,15 @@ export function runLoop(opts) {
167
208
  finalProgress: progress(stories),
168
209
  };
169
210
  }
211
+ if (opts.perf) {
212
+ reporter.phase('perf');
213
+ const perfVerdict = runGate(opts.perf, opts.targetDir, story.id);
214
+ if (!perfVerdict.passed) {
215
+ const reason = blockReason(`story ${story.id} exceeded its performance budget: ${perfVerdict.summary}`, opts.targetDir, opts.git);
216
+ reporter.blocked(reason);
217
+ return { status: 'blocked', iterations, reason, finalProgress: progress(stories) };
218
+ }
219
+ }
170
220
  const summary = result.success
171
221
  ? result.summary
172
222
  : `${result.summary} (runner exited non-zero but verify is green)`;
@@ -211,5 +261,6 @@ export function runLoop(opts) {
211
261
  finalProgress: progress(stories),
212
262
  };
213
263
  }
264
+ reporter.storyDone({ id: story.id, title: story.title }, progress(updated));
214
265
  }
215
266
  }
@@ -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);
@@ -81,6 +85,12 @@ export function runLoopCommand(targetDir, opts) {
81
85
  }
82
86
  verify = retryingVerifier(commandVerifier(command), config.verify?.retries ?? 1);
83
87
  }
88
+ // Optional performance budget gate: same contract as verify (exit 0 = within
89
+ // budget), same flake tolerance (benchmarks are noisy).
90
+ let perf = opts.perf;
91
+ if (!perf && config.perf?.command) {
92
+ perf = retryingVerifier(commandVerifier(config.perf.command), config.perf.retries ?? 1);
93
+ }
84
94
  // Opt-in self-update, loop START only — this run keeps executing the version
85
95
  // it started with; a fetched upgrade applies from the next invocation.
86
96
  maybeAutoUpgrade(config.update?.auto);
@@ -95,7 +105,11 @@ export function runLoopCommand(targetDir, opts) {
95
105
  }
96
106
  // Token reporting is part of the machine interface: in --json mode a claude
97
107
  // runner switches to stream-json so cumulative usage rides on every status.
98
- runner = makeRunner(runnerAgent, idleMs, { tokenReport: opts.json === true });
108
+ runner = makeRunner(runnerAgent, idleMs, {
109
+ tokenReport: opts.json === true,
110
+ onAmbiguity: opts.onAmbiguity ?? config.loop.onAmbiguity,
111
+ perfCommand: config.perf?.command,
112
+ });
99
113
  }
100
114
  let review = opts.reviewRunner;
101
115
  if (!review && (opts.review || opts.reviewer)) {
@@ -121,6 +135,7 @@ export function runLoopCommand(targetDir, opts) {
121
135
  runner,
122
136
  git: opts.git ?? realGitOps,
123
137
  verify,
138
+ perf,
124
139
  maxIterations: opts.maxIterations,
125
140
  isolate: opts.isolate ?? false,
126
141
  review,
@@ -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', perfCommand) {
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,13 @@ 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.');
20
+ if (perfCommand) {
21
+ lines.push(`- This project enforces a performance budget: \`${perfCommand}\` must exit 0 or the story is blocked. Keep hot paths efficient, and never simplify away an existing optimization without re-running that benchmark.`);
22
+ }
23
+ lines.push('- Keep your final message to a few short sentences: what changed and what you verified.');
18
24
  return lines.join('\n');
19
25
  }
20
26
  export function buildReviewPrompt(story, context) {
@@ -226,7 +232,7 @@ export function makeRunner(agent, idleTimeoutMs = 0, opts = {}) {
226
232
  // redundant for claude and meaningless elsewhere; kept for caller compatibility.
227
233
  const captureTokens = agent === 'claude';
228
234
  return (ctx) => {
229
- const base = runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir)), ctx.targetDir, captureTokens);
235
+ const base = runnerInvocation(agent, buildClaudePrompt(ctx.story, contextBlockFor(ctx.targetDir), opts.onAmbiguity, opts.perfCommand), ctx.targetDir, captureTokens);
230
236
  const inv = buildWatchdogInvocation(base, idleTimeoutMs);
231
237
  if (captureTokens) {
232
238
  const capture = opts.execCapture ?? runCliCapture;
@@ -9,8 +9,17 @@ 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(),
20
+ // Optional performance budget gate: a benchmark command that must exit 0 for a
21
+ // story to land (runs after verify). Benchmarks are noisy → retried like verify.
22
+ perf: z.object({ command: z.string().min(1), retries: z.number().int().nonnegative().optional() }).optional(),
14
23
  codeGraph: CodeGraphSchema.optional(),
15
24
  smoke: SmokeSchema.optional(),
16
25
  // Opt-in: upgrade yoke at loop START when a newer version is cached (never mid-run).
@@ -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.9.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": {