done-done-axi 0.3.1 → 0.4.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/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  done-done won't accept "done" without real, fresh proof from your product, captured at the exact commit being called done.
11
11
 
12
- [![Node 22.5+](https://img.shields.io/badge/node-22.5%2B-3fb950?logo=nodedotjs&logoColor=white)](https://nodejs.org) [![Status: early preview](https://img.shields.io/badge/status-early%20preview-d29922)](#whats-next) [![npm: done-done-axi](https://img.shields.io/npm/v/done-done-axi?logo=npm&logoColor=white&label=npm)](https://www.npmjs.com/package/done-done-axi)
12
+ [![Node 22.18+](https://img.shields.io/badge/node-22.18%2B-3fb950?logo=nodedotjs&logoColor=white)](https://nodejs.org) [![Status: early preview](https://img.shields.io/badge/status-early%20preview-d29922)](#whats-next) [![npm: done-done-axi](https://img.shields.io/npm/v/done-done-axi?logo=npm&logoColor=white&label=npm)](https://www.npmjs.com/package/done-done-axi)
13
13
 
14
14
  <br>
15
15
 
@@ -35,10 +35,10 @@ done-done runs your capture and ties every screenshot, recording and log it save
35
35
 
36
36
  ## See it work
37
37
 
38
- Four moments from a demo notes app. Every picture is real output from the current build.
38
+ Four moments from a demo notes app. Every picture is real output from the current build; the demo's judge is a stub that says the screen shows the outcome, so no model runs.
39
39
 
40
- <picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/run-dark.svg"><img src="docs/media/run-light.svg" alt="done-done run sync records one screenshot for the current commit and passes." width="400" align="top"></picture>
41
- <picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/pass-dark.svg"><img src="docs/media/pass-light.svg" alt="A Checks block cites the new screenshot, and done-done check passes." width="400" align="top"></picture>
40
+ <picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/run-dark.svg"><img src="docs/media/run-light.svg" alt="done-done run sync records one screenshot for the current commit." width="400" align="top"></picture>
41
+ <picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/pass-dark.svg"><img src="docs/media/pass-light.svg" alt="A Checks block cites the new screenshot, the judge passes it, and done-done check passes." width="400" align="top"></picture>
42
42
 
43
43
  <picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/reasons-dark.svg"><img src="docs/media/reasons-light.svg" alt="done-done check fails: one screenshot is from an older commit, one file is empty, one is a blank frame." width="400" align="top"></picture>
44
44
  <picture><source media="(prefers-color-scheme: dark)" srcset="docs/media/config-dark.svg"><img src="docs/media/config-light.svg" alt="done-done run stops with exit 2 because the config has an unknown key." width="400" align="top"></picture>
@@ -51,7 +51,7 @@ done-done is an [AXI](https://axi.md), which means -
51
51
 
52
52
  ## Use It From Your Agent
53
53
 
54
- You need Node 22.5 or later and git. Give your agent the done-done skill in the [Agent Skills](https://agentskills.io) format with [`npx skills`](https://github.com/vercel-labs/skills), naming your agent with `-a`:
54
+ You need Node 22.18 or later and git. Give your agent the done-done skill in the [Agent Skills](https://agentskills.io) format with [`npx skills`](https://github.com/vercel-labs/skills), naming your agent with `-a`:
55
55
 
56
56
  ```sh
57
57
  npx skills add umeranjum17/done-done-axi --skill done-done -a <agent> -y
@@ -101,7 +101,7 @@ cd your-app
101
101
  done-done init
102
102
  ```
103
103
 
104
- `init` writes a minimal `.done-done.yaml` (only if you don't already have one) that names your app, and a commented global `~/.done-done/config.yaml` (only if there is none). done-done never picks the coding agent it starts: `init` asks you in a terminal, or takes `--agent <agent> --model <model>`. It never picks the judge either, the agent of its own that `verify` starts to look at each capture (never the one that did the work): `init` asks in a terminal, or takes `--judge <agent> --judge-model <model>`. The config keys are in the [reference](docs/reference.md#config). A plain `init` never overwrites an existing config. It also scaffolds a verify skill at `.agents/skills/verify-<app>` from the repo's own files, for your agent to finish with one live drive. Evidence lives outside the repo, in `~/.done-done/evidence/<app>`, so it outlives the checkout that captured it. Then describe how to capture proof — swap the command for whatever drives your real app and saves screenshots, recordings or logs:
104
+ `init` writes a minimal `.done-done.yaml` (only if you don't already have one) that names your app, and a commented global `~/.done-done/config.yaml` (only if there is none). done-done never picks the coding agent it starts: `init` asks you in a terminal, or takes `--agent <agent> --model <model>`. It never picks the judge either, the agent of its own that `check`, `receipt`, `verify` and `design` always start to look at each capture: `init` asks in a terminal, or takes `--judge <agent> --judge-model <model>`. Any agent and model you choose runs, your coding agent's own too (the judge is a fresh session of its own). The config keys are in the [reference](docs/reference.md#config). A plain `init` never overwrites an existing config. It writes no verify skill: write `.agents/skills/verify-<app>/SKILL.md` by hand, with a launch that never touches real accounts or your own state. Evidence lives outside the repo, in `~/.done-done/evidence/<app>`, so it outlives the checkout that captured it. For an `answerer:pi` judge with no explicit model, done-done uses `openai-codex/gpt-6-luna`; `judge.model` or `agent_config.pi.model` overrides it. Then describe how to capture proof — swap the command for whatever drives your real app and saves screenshots, recordings or logs:
105
105
 
106
106
  ```yaml
107
107
  journeys:
@@ -121,11 +121,11 @@ git add .done-done.yaml
121
121
  git commit -m "Add done-done"
122
122
  ```
123
123
 
124
- A `run -- <command>` whose command matches no recorded journey still runs, but its result hints that the recipe is not in the repo.
124
+ A `run -- <command>` whose command matches no recorded journey still runs, but its result hints that the recipe is not in the repo. For a proof you drive by hand or by your agent instead of a journey, `done-done run --open` prints a run id and a folder to capture into, and `done-done run --close <run-id>` seals it ([how](docs/reference.md#run-a-journey)).
125
125
 
126
- At any point, `done-done doctor` checks the setup and changes nothing except the `--probe` call record. Each item reports `ok`, `warning` or `missing` with the exact fix command; an agent you have not set yet is `missing`, with the command that saves your choice. `done-done doctor --probe` also asks the agent one question to prove it answers. The items are listed in the [reference](docs/reference.md#check-the-setup).
126
+ At any point, `done-done doctor` checks the setup and changes nothing except the `--probe` call record. Each item reports `ok`, `warning` or `missing` with the exact fix command; an agent you have not set yet is `missing`, with the command that saves your choice. `done-done doctor --probe` also asks the agent one question to prove it answers; on a new machine that probe fails until the agent is signed in (for pi, run `pi` and then `/login`). The quick start above never calls the coding agent, so `run` and `check` work before it is signed in; `check` does call the judge, so set one first. The items are listed in the [reference](docs/reference.md#check-the-setup).
127
127
 
128
- `done-done maintain` keeps the verify skill true: it checks the skill's links and feature index, checks that every `file:line` it cites exists, and has your coding agent read the source for each feature, read-only, to report drift. It ends `clean`, `drift` with the findings to fix, or `blocked`. With `auto_fix: { maintain: <n> }` (as in no-mistakes; 0, the default, reports only), your judge first sorts each drift finding (a skill out of date, or a product that may be wrong, which stops and asks you), then your agent fixes the skill folder only, in a done-done worktree, and maintain runs again, up to n times. A clean end is `changed`: one commit on a `done-done/maintain-<run-id>` branch, your branch untouched ([reference](docs/reference.md#keep-the-verify-skill-true)).
128
+ `done-done maintain` keeps the verify skill true: it checks the skill's links and feature index, checks that every `file:line` it cites exists, and has your coding agent read the source for each feature, read-only, to report drift. It ends `clean`, `drift` with the findings to fix, or `blocked`. It never runs without that reader: an agent that is not set asks, and one that cannot start or cannot read without tools stops it before the run (exit 2). With `auto_fix: { maintain: <n> }` (as in no-mistakes; 0, the default, reports only), your judge first sorts each drift finding (a skill out of date, or a product that may be wrong, which stops and asks you), then your agent fixes the skill folder only, in a done-done worktree, and maintain runs again, up to n times. A clean end is `changed`: one commit on a `done-done/maintain-<run-id>` branch, your branch untouched ([reference](docs/reference.md#keep-the-verify-skill-true)).
129
129
 
130
130
  **3. Run it, then check it**
131
131
 
@@ -134,7 +134,7 @@ done-done run smoke
134
134
  done-done check --outcome "it starts" ~/.done-done/evidence/your-app/*/*.log
135
135
  ```
136
136
 
137
- Each command prints one machine-readable result. By default it is [TOON](https://github.com/toon-format/spec) (the compact AXI convention), and a passing check shows `result: pass`. Add `--json` for JSON, or `--full` to list every evidence file or finding instead of the first few. After later runs, use the `run_id` the result prints in place of the first `*`.
137
+ Each command prints one machine-readable result. By default it is [TOON](https://github.com/toon-format/spec) (the compact AXI convention). `run` shows `result: recorded`: it captured evidence, and says nothing about whether it proves the claim. `check` starts your judge on each capture, and only a check the judge passes shows `result: pass`; with no judge set it asks for one, and a judge that fails stops with the cause (exit 2), never a result. Add `--json` for JSON, or `--full` to list every evidence file or finding instead of the first few. After later runs, use the `run_id` the result prints in place of the first `*`.
138
138
 
139
139
  When a check passes, save a receipt and re-verify it later:
140
140
 
@@ -143,10 +143,12 @@ done-done receipt --outcome "it starts" ~/.done-done/evidence/your-app/*/*.log
143
143
  done-done receipt --verify "$(git rev-parse HEAD)"
144
144
  ```
145
145
 
146
- No journey yet? `done-done verify --outcome "<what a user can now do>"` has your coding agent drive one feature of the verify skill the way a user does (`--feature <name>` picks it). A judge that is not the author's own agent and model then checks the captures. When it says a capture is wrong or unclear, or a capture it cannot read (a video or other file) is not judged, the run waits at its gate (exit 4) until you answer with `done-done respond --action approve|skip --reason "<why>"` or `--action fix`. Until then, a new `verify` or `rerun` of the same claim in that checkout is refused, so nothing goes around the gate. Approve and skip are logged with who and why, and they end the run `overridden`, never `pass`, so `receipt --verify` still refuses it ([details](docs/reference.md#drive-by-agent)).
146
+ No journey yet? `done-done verify --outcome "<what a user can now do>"` has your coding agent drive one feature of the verify skill the way a user does (`--feature <name>` picks it). Your judge then checks the captures. When it says a capture is wrong or unclear, or a capture it cannot read (a video or other file) is not judged, the run waits at its gate (exit 4) until you answer with `done-done respond --action fix`, which ends it `fail` so you fix the product or the recipe and run again: nothing moves a run past its judge, and no receipt is written without a judge pass. Until then, a new `verify` or `rerun` of the same claim in that checkout is refused, so nothing goes around the gate ([details](docs/reference.md#drive-by-agent)).
147
147
 
148
148
  With `auto_fix: { verify: <n> }` (0, the default, reports only), a failed verify is fixed with your own agent: your judge first sorts each wrong screen (a product bug to fix, or a question that waits at the gate for you), then your agent fixes the product in a done-done worktree with your ignored files copied in, and verify runs again there, up to n times. A pass ends `changed`, with the fix on a `done-done/verify-<run-id>` branch and your branch untouched ([details](docs/reference.md#fix-the-product)).
149
149
 
150
+ While a `verify` or `maintain` run goes, `done-done status` shows its steps and next move, `done-done attach` streams its events, and `done-done logs --step <name>` shows one step's log. `done-done rerun` runs a failed verify run again from the failed step, `done-done abort` stops a live run, `done-done signoff` writes a verify run's sign-off message, and `done-done history` lists past runs.
151
+
150
152
  Run `done-done` with no arguments to see the current config and the next step.
151
153
 
152
154
  ## What it checks
@@ -157,12 +159,13 @@ Run `done-done` with no arguments to see the current config and the next step.
157
159
  - ♻️ **Not reused**: Proof made only of files already used for a different claim fails, and so does citing the same file twice. A claim is named by `--claim-id <id>`, which defaults to your branch, so rewording it keeps its proof. Several claims on one branch each need their own `--claim-id`.
158
160
  - 🧾 **Honest claims**: Reads a pull request's `## Checks` block — from a file, a URL, or stdin — and makes sure every line is filled in.
159
161
  - 📌 **Verifiable later**: `receipt` records a passing check against the exact commit and the sha256 of every file it cited; `receipt --verify` re-checks it from any checkout of that commit on the same machine (a merge gate or a fresh clone), or in CI once the evidence folder is copied there ([how](docs/reference.md#where-proof-lives)).
160
- - 👀 **Shows the outcome** (on by default in `verify`; `check` and `receipt` with `--judge`): `verify` starts done-done's own judge, a fresh, tools-off agent on your own plan or key (Claude Code, opencode, pi or Gemini CLI, or your own OpenAI API key), to say whether each capture is the right screen for the claim (and each text capture, such as a log, JSON or CLI output, the right output), and a wrong one fails. You choose the judge (`done-done init --judge <agent> --judge-model <model>`, or pick it from the list `verify` shows in a terminal); done-done never picks one, and it is never the agent and model that did the work. `--no-judge` or `judge.enabled: false` skips it when you choose to, and `check` and `receipt` judge only with `--judge`.
162
+ - 👀 **Shows the outcome** (always): `check`, `receipt` and `verify` start done-done's own judge, a fresh, tools-off agent on your own plan or key (Claude Code, opencode, pi or Gemini CLI, or your own OpenAI API key), to say whether each capture is the right screen for the claim (and each text capture, such as a log, JSON or CLI output, the right output), and a wrong one fails. There is no way to turn it off: done-done gives no result without it. The checks above never pass on their own, and a check that cites only files the judge cannot read (a video) stops before the judge, with exit 2. You choose the judge route (`done-done init --judge <agent> --judge-model <model>`, or pick it from the list done-done shows in a terminal); for a Pi judge with no explicit model, done-done defaults to `openai-codex/gpt-6-luna` unless `agent_config.pi.model` is set. Any agent and model you choose runs, your coding agent's own too. A judge that fails or times out stops the command with its cause (exit 2; a `verify` run stops with exit 2 and its cause in the run record), and no receipt is written.
161
163
  - 🎨 **Looks right**: `done-done design <screens...> [--mock <screen>=<mock>]` has the same judge look at each captured screen, alone or next to its approved mock, and fails it on stretched or squashed images, clipped or squashed text, overlap, elements off-screen, blank or black strips and broken layout, naming each defect and where it is ([how](docs/reference.md#design-check)).
162
- - 🧩 **Page is clean** (with a `dom` block): each captured screen also needs its page-check record. A page that overflows the viewport, logs a console error, has a failed request or shows squashed text fails, unless an allow list names it ([how](docs/reference.md#dom-assertions)).
164
+ - 📐 **Fits the screen**: `done-done look <url>` opens a web page in headless Chrome at a 390x844 phone viewport, captures it, and fails the journey that calls it on squashed text, clipped text, elements cut off by an edge or a page wider than the screen, naming each element, with no model ([how](docs/reference.md#look-check)).
165
+ - 🧩 **Page is clean** (with a `dom` block): each captured screen also needs its page-check record. A page that overflows the viewport, logs a console error, has a failed request or shows squashed, clipped or cut-off UI fails, unless an allow list names it ([how](docs/reference.md#dom-assertions)).
163
166
  - 🔒 **Safe in CI**: `--trusted-ref origin/main` reads the rules from your main branch, so a pull request can't loosen them.
164
167
 
165
- Every answer is an exit code: **0** pass, **1** fail (or overridden at the gate), **2** usage or config error, **3** can't tell either way, **4** a `verify`, `maintain` or `rerun` run is still running, or a `verify` run waits at its gate.
168
+ Every answer is an exit code: **0** pass, **1** fail, **2** usage or config error (including no agent or judge set, a check that fails before its judge, a capture the judge cannot be sent, or a judge that did not answer: never a result), **3** can't tell either way, **4** a `verify`, `maintain` or `rerun` run is still running, or a `verify` run waits at its gate.
166
169
 
167
170
  ## Use with Firstmate or another agent fleet
168
171
 
@@ -178,19 +181,19 @@ cp "$(npm root -g)/done-done-axi/integrations/firstmate/merge-gate" "$FM_HOME/co
178
181
  ```
179
182
 
180
183
  - **`brief-include.md`**: Firstmate adds `config/brief-include.md` to every worker brief. It tells the worker to set up done-done, run a journey on its committed change, write a `## Checks` block, pass `check`, and record a `receipt`. The evidence and receipt stay in `~/.done-done/evidence/<app>` after teardown deletes the worktree.
181
- - **`merge-gate`** (optional): `merge-gate <task-id> <worktree|-> <head> <pr-url|->`. In the worktree, it refuses a PR whose head is not `<head>`, then runs `done-done check --from <PR URL or $FM_HOME/data/<task-id>/checks.md> --trusted-ref <default branch>` (`origin/HEAD`, else `origin/main` or `origin/master`, else the local `main` or `master` in a repo with no remote), then `done-done receipt --verify <head>`. Both paths read done-done's rules from your default branch, never from the task's own branch, so merge `.done-done.yaml` there before you rely on it. The gate does not fetch: it reads the local `origin` refs, so run `git fetch origin` before you run it. It needs `done-done` on PATH (the `npm install` line above). A nonzero exit refuses the merge, and done-done's output says why. A worktree of `-` means the task recorded none, so there is no repo to check: the gate refuses (exit 2) rather than check whatever folder it was called from. Its `check` runs with `--judge`, so the judge the user set must pass each capture. Upstream Firstmate does not call a merge gate yet, so this file has no effect until it does; you can run it by hand before a merge.
184
+ - **`merge-gate`** (optional): `merge-gate <task-id> <worktree|-> <head> <pr-url|->`. In the worktree, it refuses a PR whose head is not `<head>`, then runs `done-done check --from <PR URL or $FM_HOME/data/<task-id>/checks.md> --trusted-ref <default branch>` (`origin/HEAD`, else `origin/main` or `origin/master`, else the local `main` or `master` in a repo with no remote), then `done-done receipt --verify <head>`. Both paths read done-done's rules from your default branch, never from the task's own branch, so merge `.done-done.yaml` there before you rely on it. The gate does not fetch: it reads the local `origin` refs, so run `git fetch origin` before you run it. It needs `done-done` on PATH (the `npm install` line above). A nonzero exit refuses the merge, and done-done's output says why. A worktree of `-` means the task recorded none, so there is no repo to check: the gate refuses (exit 2) rather than check whatever folder it was called from. Its `check` always starts the judge the user set, which must pass each capture; with none set, the gate refuses (exit 2). Upstream Firstmate does not call a merge gate yet, so this file has no effect until it does; you can run it by hand before a merge.
182
185
 
183
186
  Another fleet works the same way: add the include text to whatever your workers read first, and run `merge-gate` before each merge, with `FM_HOME` set to the folder that holds `data/<task-id>/checks.md` for local-only tasks.
184
187
 
185
188
  ## Learn more
186
189
 
187
190
  - [Vision](VISION.md): the three-tool family, the rules they share, and where done-done goes next.
188
- - [Full reference](docs/reference.md): every `init`, `doctor`, `run`, `check` and `receipt` option, the Checks block rules, each file check, and both config files.
191
+ - [Full reference](docs/reference.md): every command and option, the Checks block rules, each file check, and both config files.
189
192
  - [Report a problem or ask a question](https://github.com/umeranjum17/done-done-axi/issues)
190
193
 
191
194
  ## What's next
192
195
 
193
- So far: `init`, `doctor`, `run`, `check`, `receipt`, `verify` (by journey or by your coding agent, with a gate you answer with `respond`), the capture judge (on by default in `verify`), the `design` visual check and the opt-in session hooks for Claude Code, Codex, opencode, Gemini CLI and pi. Coming next: help writing the proof steps for your app.
196
+ So far: `init`, `doctor`, `run` (including `--open` and `--close`), `check`, `receipt`, `verify` (by journey or by your coding agent, with a gate you answer with `respond`), `maintain`, the capture judge (always on), the `design` visual check, the `look` step, the run tools (`status`, `attach`, `logs`, `rerun`, `abort`, `signoff`, `history`) and the opt-in session hooks for Claude Code, Codex, opencode, Gemini CLI and pi. Coming next: help writing the proof steps for your app.
194
197
 
195
198
  ## Develop
196
199
 
@@ -212,4 +215,4 @@ npx -y --allow-git=all github:umeranjum17/done-done-axi --help
212
215
 
213
216
  `dist/` is committed, so installing from GitHub needs no build step. `npm test` rebuilds it and fails while the rebuilt `dist/` differs from the commit or has files git does not track, so `git add` the new `dist/` files with your change.
214
217
 
215
- The terminal pictures (hero, run, pass, reasons, config) are real output from the current build; redraw them with `npx tsc && node docs/media/make.mjs`. The wordmark and the flow picture are hand-made SVGs.
218
+ The terminal pictures (hero, run, pass, reasons, config) are real output from the current build, with a stub judge (no model); redraw them with `npx tsc && node docs/media/make.mjs`. The wordmark and the flow picture are hand-made SVGs.
package/dist/agents.js CHANGED
@@ -1,13 +1,16 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { homedir, tmpdir } from 'node:os';
4
- import { dirname, isAbsolute, join } from 'node:path';
4
+ import { dirname, extname, isAbsolute, join } from 'node:path';
5
5
  import { stripVTControlCharacters } from 'node:util';
6
- import { AGENTS, ANSWERER_JUDGES, CHEAP, EFFORT_FLAGS, KEY_ROUTES, parseDuration, UsageError } from './config.js';
6
+ import { AGENTS, ANSWERER_JUDGES, CHEAP, JUDGE_DEFAULT, EFFORT_FLAGS, KEY_ROUTES, parseDuration, UsageError } from './config.js';
7
7
  import { isExecutable, onPath } from './judge.js';
8
8
  // argv never carries the prompt: it goes on stdin, or (file: true) into a 0600 prompt file the argv points at.
9
9
  // done-done parses the answer, validates it against the schema, and asks a tool-less call again, at most ASK_ATTEMPTS calls.
10
10
  const ASK_ATTEMPTS = 3;
11
+ // A streaming agent that prints nothing for this long is stopped, so a hung CLI ends in minutes, not at the agent_timeout.
12
+ // Only streaming adapters are watched: claude and gemini print their one answer at the end, so silence is their normal state.
13
+ export const AGENT_IDLE_MS = 5 * 60_000;
11
14
  // What a user sees of an agent's error: no colour codes, only the last line that names an error (not a
12
15
  // stack frame, an indented field or a lone brace), at most 200 characters. The call record keeps the raw stderr.
13
16
  const ERROR_WORD = /\b(error|failed|denied|cancelled|refused|not|invalid|unauthorized)\b/i;
@@ -17,6 +20,23 @@ function errorLine(text) {
17
20
  const line = (top.findLast((l) => ERROR_WORD.test(l)) ?? top.at(-1) ?? lines.at(-1) ?? '').trim();
18
21
  return line.length > 200 ? `${line.slice(0, 199)}…` : line;
19
22
  }
23
+ // A provider's rate limit (HTTP 429, a usage limit with its reset time). The CLI may retry it for many minutes, so the call stops at the first line that says so.
24
+ const RATE_LIMIT = /\b429\b|rate[ -]?limit|usage limit|too many requests/i;
25
+ const isRejectedRateLimit = (l) => {
26
+ try {
27
+ const e = JSON.parse(l);
28
+ return e.type === 'rate_limit_event' && e.rate_limit_info?.status === 'rejected';
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ };
34
+ // The first lines of an agent's stderr, one line: what it said before it failed or went quiet. Indented lines (stack frames, fields) are
35
+ // left out, as in errorLine. '' when it said nothing.
36
+ function stderrHead(stderr) {
37
+ const head = stripVTControlCharacters(stderr).split(/\r?\n/).filter((l) => /\w/.test(l) && !/^\s/.test(l)).slice(0, 2).map((l) => l.trim()).join(' | ');
38
+ return head.length > 300 ? `${head.slice(0, 299)}…` : head;
39
+ }
20
40
  // opencode prints this on stderr and auto-rejects the ask when a headless run reaches a permission a non-interactive
21
41
  // call can never answer. The drive then ends with no answer, so name the permission and paths it refused instead of
22
42
  // the bare "no JSON object in the answer" its empty output would give.
@@ -82,6 +102,7 @@ export function extractJsonObject(text, required = []) {
82
102
  // message chunks of acpx's JSON-RPC event stream. Every tool is denied, and a permission prompt is denied too, since
83
103
  // nobody can answer it. No model means the target's own default.
84
104
  const ACP = {
105
+ streams: true,
85
106
  argv: (c, dir) => ['--cwd', c.tools === 'none' ? dir : c.cwd, '--format', 'json', '--json-strict', '--deny-all',
86
107
  '--non-interactive-permissions', 'deny', ...(c.model ? ['--model', c.model] : []), c.agent.slice('acp:'.length), 'exec', '--file', '-'],
87
108
  answer: (out, c) => {
@@ -97,15 +118,31 @@ const ACP = {
97
118
  };
98
119
  export const ACPX_INSTALL = 'Install the acpx ACP bridge (npm install -g acpx, https://github.com/openclaw/acpx), then re-run: done-done doctor';
99
120
  const PI_TOOL_LESS = ['--no-tools', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-context-files'];
121
+ // --no-extensions also hides the extension that supplies a model's provider: claude-bridge models were 0 with it, 13 with
122
+ // -e npm:pi-claude-bridge. A tool-less call loads that one extension and no other: the rest add tools or prompt text.
123
+ const PI_PROVIDER_EXTENSIONS = { 'claude-bridge': 'npm:pi-claude-bridge' };
124
+ const piProviderExtension = (model) => {
125
+ const provider = model?.split('/')[0] ?? '';
126
+ return Object.hasOwn(PI_PROVIDER_EXTENSIONS, provider) ? ['-e', PI_PROVIDER_EXTENSIONS[provider]] : [];
127
+ };
100
128
  const ADAPTERS = {
101
129
  // claude -p: --json-schema puts the validated object in structured_output; --tools '' turns every tool off.
102
130
  // An edit step accepts edits only in cwd and --add-dir folders, and may run commands (the drive launches the app).
131
+ // The judge's call runs with the user's own sign-in, and with --safe-mode when safeMode is set.
132
+ // A call with images sends them as stream-json content blocks, as claude's SDK does, and its result is the event of type result.
103
133
  claude: {
104
- argv: (c) => ['-p', '--output-format', 'json', '--json-schema', JSON.stringify(c.schema), '--model', c.model, '--no-session-persistence',
134
+ argv: (c) => ['-p', '--output-format', c.images?.length ? 'stream-json' : 'json', ...(c.images?.length ? ['--verbose', '--input-format', 'stream-json'] : []),
135
+ '--json-schema', JSON.stringify(c.schema), '--model', c.model, '--no-session-persistence',
105
136
  ...(c.tools === 'edit' ? ['--permission-mode', 'acceptEdits', '--allowedTools', 'Bash', ...(c.dirs ?? []).flatMap((d) => ['--add-dir', d])] : ['--tools', c.tools === 'read' ? 'Read,Grep,Glob' : '']),
106
- ...(c.tools === 'edit' ? [] : ['--setting-sources', '']), ...(c.tools === 'read' ? ['--strict-mcp-config'] : [])],
107
- answer: (out) => {
108
- const r = JSON.parse(out);
137
+ ...(c.tools === 'edit' ? [] : ['--setting-sources', '']), ...(c.tools === 'read' ? ['--strict-mcp-config'] : []), ...(c.safeMode ? ['--safe-mode'] : [])],
138
+ stdin: (c, prompt) => (c.images?.length ? `${JSON.stringify({ type: 'user', session_id: '', parent_tool_use_id: null, message: { role: 'user', content: [
139
+ ...c.images.flatMap((i) => [{ type: 'text', text: `Image: ${i.name.replace(/\.[^.]+$/, '')}` }, { type: 'image', source: { type: 'base64', media_type: `image/${extname(i.name).slice(1)}`, data: i.bytes.toString('base64') } }]),
140
+ { type: 'text', text: prompt }
141
+ ] } })}\n` : prompt),
142
+ answer: (out, c) => {
143
+ const r = c.images?.length ? out.split('\n').filter((l) => l.startsWith('{')).map((l) => JSON.parse(l)).findLast((e) => e.type === 'result') : JSON.parse(out);
144
+ if (!r)
145
+ throw new ProviderError('claude printed no result');
109
146
  if (r.is_error)
110
147
  throw new ProviderError(String(r.result || r.subtype));
111
148
  return r.structured_output;
@@ -115,6 +152,8 @@ const ADAPTERS = {
115
152
  // and the answer is the JSON object in the text parts of its JSON event stream.
116
153
  opencode: {
117
154
  file: true,
155
+ streams: true,
156
+ readsImages: true,
118
157
  argv: (c, dir) => ['run', 'Follow the attached instructions.', '--format', 'json', '-m', c.model, '-f', join(dir, 'prompt.md'), ...(c.images ?? []).flatMap((i) => ['-f', join(dir, i.name)])],
119
158
  answer: (out, c) => {
120
159
  const events = out.split('\n').filter((l) => l.startsWith('{')).map((l) => JSON.parse(l));
@@ -151,15 +190,18 @@ const ADAPTERS = {
151
190
  return m ? `it asked for permission to ${m[1]} (${m[2]}), which a headless run cannot answer and auto-rejects` : '';
152
191
  },
153
192
  },
154
- // pi -p reads the prompt on stdin (probed 12 Oct: with no positional message, stdin becomes the user
193
+ // pi -p reads the prompt on stdin (probed: with no positional message, stdin becomes the user
155
194
  // message); --mode json streams JSONL events whose assistant message_end holds the answer text,
156
195
  // --no-session keeps the probe ephemeral. An @<file> argument attaches an image to that message.
157
- // --no-tools turns every built-in and extension tool off (probed 13 Oct: a canary file outside the cwd went unread).
158
- // A tool-less call also loads no extensions, skills, prompt templates or context files: they would add the user's own
159
- // context to a fresh judge, and extension loading took 2 minutes on a real setup (probed 13 Oct), past the judge's timeout.
196
+ // --no-tools turns every built-in and extension tool off (probed: a canary file outside the cwd went unread).
197
+ // A tool-less call also loads no skills, prompt templates or context files, and only the chosen model's provider
198
+ // extension (piProviderExtension): the others would add the user's own context to a fresh judge, and loading all of
199
+ // them took 2 minutes on a real setup (probed), past the judge's timeout.
160
200
  // Pi has no permission gate for edit steps, so doctor warns about it. Without a model, pi runs its own default.
161
201
  pi: {
162
- argv: (c, dir) => ['-p', '--mode', 'json', '--no-session', ...(c.tools === 'edit' ? [] : PI_TOOL_LESS), ...(c.model ? ['--model', c.model] : []), ...(c.images ?? []).map((i) => `@${join(dir, i.name)}`)],
202
+ streams: true,
203
+ readsImages: true,
204
+ argv: (c, dir) => ['-p', '--mode', 'json', '--no-session', ...(c.tools === 'edit' ? [] : [...PI_TOOL_LESS, ...piProviderExtension(c.model)]), ...(c.model ? ['--model', c.model] : []), ...(c.images ?? []).map((i) => `@${join(dir, i.name)}`)],
163
205
  answer: (out, c) => {
164
206
  let text = '';
165
207
  let failure = '';
@@ -187,11 +229,12 @@ const ADAPTERS = {
187
229
  // answer, and --ephemeral keeps no session. An edit step runs in codex's workspace-write sandbox, which writes only the
188
230
  // repo, the run's folders (--add-dir) and $TMPDIR; it may use the network, since the drive launches the app and talks
189
231
  // to it, and it keeps the user's config.toml. Without a model, codex runs its own default.
190
- // A tool-less call is refused: probed live 13 Oct, codex with the user's config.toml kept its MCP server, which read a
232
+ // A tool-less call is refused: codex with the user's config.toml kept its MCP server, which read a
191
233
  // canary file outside the folder, and --disable does not turn off its multi-agent tools.
192
234
  codex: {
235
+ streams: true,
193
236
  editOnly: 'codex cannot run tool-less: with your config.toml it keeps its MCP tools, which read files outside the folder',
194
- // Seen live 13 Oct: each sandboxed command runs in its own process namespace, so a server or browser bridge started in the
237
+ // Each sandboxed command runs in its own process namespace, so a server or browser bridge started in the
195
238
  // background by one command was gone by the next one, and a drive that launched the app and then browsed it captured nothing.
196
239
  editNote: 'Your shell sandbox ends every process a command started, background ones too, when that command ends. So start what a task needs (an app, a server, a browser bridge), use it, save what it produces and stop it within one shell command (a script in $TMPDIR is fine).',
197
240
  argv: (c) => ['exec', '--json', '--ephemeral', ...(c.model ? ['-m', c.model] : []),
@@ -228,6 +271,7 @@ const ADAPTERS = {
228
271
  // policy folder and removes every tool, MCP ones too, from the model (its policy-engine.md). An @<file> in the -p text
229
272
  // attaches an image: per Gemini CLI's source, its own @-command code reads it, outside the tool policy, and only inside the cwd, the call's scratch folder; not yet proven live (Gemini CLI was not signed in on the test machine).
230
273
  gemini: {
274
+ readsImages: true,
231
275
  argv: (c, dir) => ['-p', ['Follow the instructions on stdin.', ...(c.images ?? []).map((i) => `@${i.name}`)].join(' '), '-o', 'json', '-m', c.model, '--approval-mode', c.tools === 'edit' ? 'auto_edit' : 'plan',
232
276
  ...(c.tools === 'none' ? ['--policy', join(dir, 'deny-all.toml'), '--skip-trust'] : []),
233
277
  ...(c.tools === 'edit' ? ['--allowed-tools', 'run_shell_command', ...(c.dirs ?? []).flatMap((d) => ['--include-directories', d])] : [])],
@@ -267,6 +311,9 @@ export function knownAgentOf(agent) {
267
311
  const adapterFor = (agent) => (agent.startsWith('acp:') ? ACP : Object.hasOwn(ADAPTERS, agent) ? ADAPTERS[agent] : undefined);
268
312
  // Why an agent cannot run a tool-less call, '' when it can.
269
313
  export const editOnlyReason = (agent) => adapterFor(agent)?.editOnly ?? '';
314
+ // Why an agent cannot read a repo tool-less from text plain code gathers, '' when it can: gemini's none mode is a
315
+ // read-only plan mode, not tool-free.
316
+ export const toollessReason = (agent) => (agent === 'gemini' ? 'gemini cannot read tool-less yet, since its none mode is read-only plan mode, not tool-free' : editOnlyReason(agent));
270
317
  // A binary from config: a bare name is looked up on PATH, an absolute path is used when it is an executable file.
271
318
  export const findBin = (bin) => (isAbsolute(bin) ? (isExecutable(bin) ? bin : '') : onPath(bin));
272
319
  // The binary that starts an agent: agent_path_override.<agent>, else its name on PATH. An acp:<target> agent starts
@@ -283,14 +330,13 @@ export function agentPath(agent, cfg) {
283
330
  acpxRuns.set(acpx, spawnSync(acpx, ['--version'], { stdio: 'ignore', timeout: 5_000 }).status === 0);
284
331
  return acpxRuns.get(acpx) ? acpx : '';
285
332
  }
286
- export const claudeBin = (cfg) => agentPath('claude', cfg);
287
333
  export const routeOf = (agent) => (agent === 'claude' ? 'claude-code' : `answerer:${agent}`);
288
334
  export const JUDGES = AGENTS.filter((a) => a === 'claude' || ANSWERER_JUDGES.includes(a));
289
335
  // Like the agent, the judge is exactly the one the user set; with none, done-done asks the user (in a terminal) or
290
336
  // stops with this error, so a calling agent asks its user rather than picking one.
291
- export const noJudge = () => askError('judge', JUDGES.join(', '), 'never pick one for them, and never the agent that did the work', `done-done init --judge <agent> --judge-model <model>, e.g. done-done init --judge opencode --judge-model ${CHEAP.opencode.model}`);
292
- // fix: the judge on a fix path (auto_fix) is never the fixer's own agent, at any model, and judge.allow_same does not apply.
293
- export function selectJudge(cfg, found, fix = false) {
337
+ export const noJudge = () => askError('judge', JUDGES.join(', '), 'never pick one for them', `done-done init --judge <agent> --judge-model <model>, e.g. done-done init --judge opencode --judge-model ${CHEAP.opencode.model}`);
338
+ // The user's judge may be any agent and model they choose, the author's own too: it judges in a fresh session.
339
+ export function selectJudge(cfg, found) {
294
340
  const route = cfg.judge.route;
295
341
  if (!route)
296
342
  throw noJudge();
@@ -299,24 +345,20 @@ export function selectJudge(cfg, found, fix = false) {
299
345
  if (keyed && !cfg.judge.model)
300
346
  throw new UsageError(`judge: judge.route ${route} needs judge.model, a model your key can use that reads images`, 'Set judge.model in ~/.done-done/config.yaml');
301
347
  const agent = keyed ? route : route === 'claude-code' ? 'claude' : route.slice('answerer:'.length);
302
- const model = cfg.judge.model || modelFor(cfg, agent);
348
+ const model = cfg.judge.model || (agent === 'pi' ? cfg.agent_config.pi?.model || JUDGE_DEFAULT : modelFor(cfg, agent));
303
349
  if (!model)
304
350
  throw new UsageError(`judge: no model for ${route}`, `Set judge.model in ~/.done-done/config.yaml`);
305
351
  // acp:claude-code is claude for independence; an author with no known model is never apart by model.
306
352
  const independence = keyed
307
353
  ? found.model && model === found.model ? 'session-only' : 'agent'
308
354
  : agent !== knownAgentOf(found.agent) ? 'agent' : found.model && model !== found.model ? 'model' : 'session-only';
309
- if (fix && independence !== 'agent')
310
- throw new UsageError(`judge: the judge ${agent} (model ${model}) is the fixer's own agent (${found.agent}) at any model, and the judge is never the agent that did the work; judge.allow_same does not apply to auto_fix`, 'Ask the user for another judge agent and save it: done-done init --judge <agent> --judge-model <model>');
311
- if (independence === 'session-only' && !cfg.judge.allow_same)
312
- throw new UsageError(`judge: the judge ${agent} (model ${model}) is the author's own agent and model (${found.agent}), and the judge is never the agent that did the work`, `Ask the user for another judge agent or model and save it: done-done init --judge <agent> --judge-model <model>; or set judge.allow_same: true in ~/.done-done/config.yaml to accept a fresh session of the same one`);
313
355
  return { route, agent, model, independence, author: { agent: found.agent, model: found.model } };
314
356
  }
315
357
  const DRAIN_MS = 500;
316
358
  const OUTPUT_CAP = 64 << 20;
317
359
  // One agent process. An edit step gets its own process group, so whatever it started (a dev server, a browser) is
318
360
  // killed with it when it ends, times out or writes past the output cap.
319
- function start(bin, argv, c, cwd, input, env) {
361
+ function start(bin, argv, c, cwd, input, env, idleMs) {
320
362
  const group = c.tools === 'edit';
321
363
  return new Promise((done) => {
322
364
  const child = spawn(bin, argv, { cwd, env, detached: group });
@@ -324,6 +366,39 @@ function start(bin, argv, c, cwd, input, env) {
324
366
  let stderr = '';
325
367
  let error;
326
368
  let drain;
369
+ let idle;
370
+ // A rate-limit line ends the call with that line, whatever the agent does next. On stdout the line must also say when it resets or retries,
371
+ // so an answer that only quotes a 429 from the app's code does not stop the call. A claude call with images streams JSON lines,
372
+ // and only a rejected rate_limit_event on stdout stops it, so its complete lines are parsed one at a time.
373
+ let pendingStdout = '';
374
+ const limited = (chunk, stdoutLine) => {
375
+ let line;
376
+ if (c.agent === 'claude' && c.images?.length) {
377
+ if (!stdoutLine)
378
+ return;
379
+ const text = pendingStdout + chunk;
380
+ const end = text.lastIndexOf('\n') + 1;
381
+ pendingStdout = text.slice(end);
382
+ line = text.slice(0, end).split('\n').find(isRejectedRateLimit);
383
+ }
384
+ else {
385
+ line = stripVTControlCharacters(chunk).split(/\r?\n/).find((l) => RATE_LIMIT.test(l) && (!stdoutLine || /reset|retry/i.test(l)));
386
+ }
387
+ if (!line || error)
388
+ return;
389
+ error = new Error(`${c.agent} hit a rate limit and stopped: ${line.trim().slice(0, 200)}`);
390
+ kill();
391
+ };
392
+ // Any output (stdout or stderr) shows the agent is working; silence for idleMs is a hang.
393
+ const quiet = () => {
394
+ if (!idleMs)
395
+ return;
396
+ clearTimeout(idle);
397
+ idle = setTimeout(() => {
398
+ error = new Error(`${c.agent} printed nothing for ${Math.round(idleMs / 1000)}s`);
399
+ kill();
400
+ }, idleMs);
401
+ };
327
402
  const kill = () => {
328
403
  try {
329
404
  if (group)
@@ -345,18 +420,24 @@ function start(bin, argv, c, cwd, input, env) {
345
420
  }, c.timeoutMs);
346
421
  child.on('error', (e) => {
347
422
  clearTimeout(timer);
423
+ clearTimeout(idle);
348
424
  done({ status: null, stdout, stderr, error: e });
349
425
  });
350
426
  if (!child.pid)
351
427
  return;
352
428
  if (group)
353
429
  c.onSpawn?.(child.pid);
430
+ quiet();
354
431
  child.stdout.setEncoding('utf8').on('data', (d) => {
432
+ quiet();
433
+ limited(d, true);
355
434
  stdout += d;
356
435
  if (stdout.length > OUTPUT_CAP)
357
436
  overflow();
358
437
  });
359
438
  child.stderr.setEncoding('utf8').on('data', (d) => {
439
+ quiet();
440
+ limited(d, false);
360
441
  stderr += d;
361
442
  if (stderr.length > OUTPUT_CAP)
362
443
  overflow();
@@ -369,6 +450,7 @@ function start(bin, argv, c, cwd, input, env) {
369
450
  });
370
451
  child.on('close', (status) => {
371
452
  clearTimeout(timer);
453
+ clearTimeout(idle);
372
454
  clearTimeout(drain);
373
455
  if (group)
374
456
  kill();
@@ -395,8 +477,9 @@ export async function askAgent(c) {
395
477
  throw new Error(`no adapter for ${c.agent} yet`);
396
478
  if (adapter.editOnly && c.tools !== 'edit')
397
479
  throw new Error(adapter.editOnly);
398
- for (const i of c.images ?? [])
399
- writeFileSync(join(dir, i.name), i.bytes, { mode: 0o600 });
480
+ if (adapter.readsImages)
481
+ for (const i of c.images ?? [])
482
+ writeFileSync(join(dir, i.name), i.bytes, { mode: 0o600 });
400
483
  for (const [name, text] of Object.entries(adapter.files?.(c) ?? {}))
401
484
  writeFileSync(join(dir, name), text, { mode: 0o600 });
402
485
  const bin = c.path;
@@ -406,13 +489,14 @@ export async function askAgent(c) {
406
489
  for (;;) {
407
490
  attempts++;
408
491
  // An ask-again names what was wrong with the last answer. The trailer ends on words, not the schema: a weak model
409
- // (Go Muse, 13 Oct) copied a schema that closed the prompt back as its answer.
492
+ // (seen with one opencode model) copied a schema that closed the prompt back as its answer.
410
493
  const schemaTrailer = `\n\nAnswer with only one JSON object that matches this JSON schema:\n${JSON.stringify(c.schema)}\nThat object holds your own values, never the schema itself, and you send no other text.\n`;
411
494
  const prompt = c.tools === 'edit' && adapter.editNote ? `${c.prompt}\n\n${adapter.editNote}` : c.prompt;
412
495
  const promptText = (attempts === 1 ? prompt : `${prompt}\n\nYour previous answer was not valid (${lastError}).`) + schemaTrailer;
413
496
  if (adapter.file)
414
497
  writeFileSync(join(dir, 'prompt.md'), promptText, { mode: 0o600 });
415
- res = await start(bin, argv(dir), c, cwd, adapter.file ? '' : promptText, { ...process.env, ...c.env, ...adapter.env?.(c) });
498
+ const input = adapter.file ? '' : adapter.stdin ? adapter.stdin(c, promptText) : promptText;
499
+ res = await start(bin, argv(dir), c, cwd, input, { ...process.env, ...c.env, ...adapter.env?.(c) }, adapter.streams ? c.idleMs ?? AGENT_IDLE_MS : 0);
416
500
  if (res.error)
417
501
  throw res.error;
418
502
  // A permission the run refused explains a call with no answer; name it rather than the exit code text.
@@ -457,8 +541,13 @@ export async function askAgent(c) {
457
541
  }
458
542
  }
459
543
  catch (e) {
460
- out = { ok: false, error: e.message };
461
- failure = e instanceof ProviderError ? e.detail : e.message;
544
+ // A silent, hung or timed-out agent says why only on stderr, so its no-answer reason quotes that. A provider error already
545
+ // carries its own line, so it is left as it is.
546
+ const said = stderrHead(String(res?.stderr ?? ''));
547
+ const provider = e instanceof ProviderError;
548
+ const note = (text) => (!provider && said && !text.includes(said.split(' | ')[0]) ? `${text}; ${c.agent} stderr: ${said}` : text);
549
+ out = { ok: false, error: note(e.message), ...(!provider && said ? { stderr: said } : {}) };
550
+ failure = note(e instanceof ProviderError ? e.detail : e.message);
462
551
  }
463
552
  finally {
464
553
  if (dir)