@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 +37 -0
- package/README.md +73 -15
- package/canon/manifest.yaml +2 -0
- package/canon/skills/authoring-prd/SKILL.md +8 -0
- package/canon/skills/performance/SKILL.md +48 -0
- package/dist/cli.js +7 -2
- package/dist/loop/loop.js +76 -25
- package/dist/loop/reporter.js +69 -3
- package/dist/loop/run-command.js +18 -3
- package/dist/loop/runner.js +9 -3
- package/dist/retrofit/config.js +10 -1
- package/dist/retrofit/gitignore.js +2 -0
- package/package.json +1 -1
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
|
+
[](https://www.npmjs.com/package/@hecer/yoke)
|
|
10
|
+
[](https://www.npmjs.com/package/@hecer/yoke)
|
|
9
11
|
[](https://github.com/HECer/yoke/actions/workflows/ci.yml)
|
|
10
12
|
[](#-license)
|
|
11
13
|

|
|
12
14
|

|
|
13
|
-

|
|
14
16
|

|
|
15
17
|

|
|
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…
|
|
50
|
-
|
|
51
|
-
▶ STORY-
|
|
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 —
|
|
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 —
|
|
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 (
|
|
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
|
|
327
|
-
|
|
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:
|
|
330
|
-
|
|
331
|
-
|
|
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`,
|
|
364
|
-
`yoke retrofit` gitignores them (along with
|
|
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
|
|
package/canon/manifest.yaml
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
}
|
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);
|
|
@@ -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, {
|
|
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,
|
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', 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.', '-
|
|
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;
|
package/dist/retrofit/config.js
CHANGED
|
@@ -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({
|
|
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).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hecer/yoke",
|
|
3
|
-
"version": "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": {
|