done-done-axi 0.3.0 → 0.3.2
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 +10 -7
- package/dist/agents.js +133 -23
- package/dist/cli.js +165 -47
- package/dist/config.js +20 -18
- package/dist/dom.js +57 -5
- package/dist/judge.js +34 -74
- package/dist/look.js +116 -0
- package/docs/reference.md +62 -23
- package/docs/releasing.md +12 -0
- package/package.json +1 -1
- package/skills/done-done/SKILL.md +10 -0
package/README.md
CHANGED
|
@@ -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
|
|
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: `init` asks in a terminal, or takes `--judge <agent> --judge-model <model>`. The judge must differ from the author by agent or by model: the same agent at another model is fine, the same agent and model is refused (`judge.allow_same: true` is an opt-out only). For example, with pi on DeepSeek as the author, `done-done init --agent pi --model commandcode/deepseek/deepseek-v4.1-flash --judge pi --judge-model zai/glm-5.3-flash` is accepted. 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:
|
|
105
105
|
|
|
106
106
|
```yaml
|
|
107
107
|
journeys:
|
|
@@ -121,9 +121,9 @@ 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 agent, so `run` and `check` work before it. The judge also needs the optional `@byokit/decide@0.8.0` kit, which a plain npm install leaves out: until it is there, `doctor` reports the judge `missing`, and `verify`, `check --judge` and `design` cannot judge. Add it with the line `doctor` prints: `npx -y -p done-done-axi -p @byokit/decide@0.8.0 done-done-axi doctor`. The items are listed in the [reference](docs/reference.md#check-the-setup).
|
|
127
127
|
|
|
128
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)).
|
|
129
129
|
|
|
@@ -147,6 +147,8 @@ No journey yet? `done-done verify --outcome "<what a user can now do>"` has your
|
|
|
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,9 +159,10 @@ 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
|
|
162
|
+
- 👀 **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 must differ from the author by agent or by model (the same agent and model is refused). `--no-judge` or `judge.enabled: false` skips it when you choose to, and `check` and `receipt` judge only with `--judge`.
|
|
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
|
-
-
|
|
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
168
|
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.
|
|
@@ -185,12 +188,12 @@ Another fleet works the same way: add the include text to whatever your workers
|
|
|
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
|
|
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
|
|
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 (on by default in `verify`), 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
|
|
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
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { dirname, isAbsolute, join } from 'node:path';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, extname, isAbsolute, join } from 'node:path';
|
|
5
5
|
import { stripVTControlCharacters } from 'node:util';
|
|
6
6
|
import { AGENTS, ANSWERER_JUDGES, CHEAP, 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,27 @@ 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
|
+
}
|
|
40
|
+
// opencode prints this on stderr and auto-rejects the ask when a headless run reaches a permission a non-interactive
|
|
41
|
+
// call can never answer. The drive then ends with no answer, so name the permission and paths it refused instead of
|
|
42
|
+
// the bare "no JSON object in the answer" its empty output would give.
|
|
43
|
+
const PERMISSION_REJECT = /permission requested:\s*(\S+)\s*\(([^)]*)\);\s*auto-rejecting/;
|
|
20
44
|
class ProviderError extends Error {
|
|
21
45
|
detail;
|
|
22
46
|
constructor(text, prefix = '', suffix = '') {
|
|
@@ -78,6 +102,7 @@ export function extractJsonObject(text, required = []) {
|
|
|
78
102
|
// message chunks of acpx's JSON-RPC event stream. Every tool is denied, and a permission prompt is denied too, since
|
|
79
103
|
// nobody can answer it. No model means the target's own default.
|
|
80
104
|
const ACP = {
|
|
105
|
+
streams: true,
|
|
81
106
|
argv: (c, dir) => ['--cwd', c.tools === 'none' ? dir : c.cwd, '--format', 'json', '--json-strict', '--deny-all',
|
|
82
107
|
'--non-interactive-permissions', 'deny', ...(c.model ? ['--model', c.model] : []), c.agent.slice('acp:'.length), 'exec', '--file', '-'],
|
|
83
108
|
answer: (out, c) => {
|
|
@@ -96,12 +121,21 @@ const PI_TOOL_LESS = ['--no-tools', '--no-extensions', '--no-skills', '--no-prom
|
|
|
96
121
|
const ADAPTERS = {
|
|
97
122
|
// claude -p: --json-schema puts the validated object in structured_output; --tools '' turns every tool off.
|
|
98
123
|
// An edit step accepts edits only in cwd and --add-dir folders, and may run commands (the drive launches the app).
|
|
124
|
+
// The judge's call runs with the user's own sign-in, and with --safe-mode when safeMode is set.
|
|
125
|
+
// 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.
|
|
99
126
|
claude: {
|
|
100
|
-
argv: (c) => ['-p', '--output-format', 'json'
|
|
127
|
+
argv: (c) => ['-p', '--output-format', c.images?.length ? 'stream-json' : 'json', ...(c.images?.length ? ['--verbose', '--input-format', 'stream-json'] : []),
|
|
128
|
+
'--json-schema', JSON.stringify(c.schema), '--model', c.model, '--no-session-persistence',
|
|
101
129
|
...(c.tools === 'edit' ? ['--permission-mode', 'acceptEdits', '--allowedTools', 'Bash', ...(c.dirs ?? []).flatMap((d) => ['--add-dir', d])] : ['--tools', c.tools === 'read' ? 'Read,Grep,Glob' : '']),
|
|
102
|
-
...(c.tools === 'edit' ? [] : ['--setting-sources', '']), ...(c.tools === 'read' ? ['--strict-mcp-config'] : [])],
|
|
103
|
-
|
|
104
|
-
|
|
130
|
+
...(c.tools === 'edit' ? [] : ['--setting-sources', '']), ...(c.tools === 'read' ? ['--strict-mcp-config'] : []), ...(c.safeMode ? ['--safe-mode'] : [])],
|
|
131
|
+
stdin: (c, prompt) => (c.images?.length ? `${JSON.stringify({ type: 'user', session_id: '', parent_tool_use_id: null, message: { role: 'user', content: [
|
|
132
|
+
...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') } }]),
|
|
133
|
+
{ type: 'text', text: prompt }
|
|
134
|
+
] } })}\n` : prompt),
|
|
135
|
+
answer: (out, c) => {
|
|
136
|
+
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);
|
|
137
|
+
if (!r)
|
|
138
|
+
throw new ProviderError('claude printed no result');
|
|
105
139
|
if (r.is_error)
|
|
106
140
|
throw new ProviderError(String(r.result || r.subtype));
|
|
107
141
|
return r.structured_output;
|
|
@@ -111,6 +145,8 @@ const ADAPTERS = {
|
|
|
111
145
|
// and the answer is the JSON object in the text parts of its JSON event stream.
|
|
112
146
|
opencode: {
|
|
113
147
|
file: true,
|
|
148
|
+
streams: true,
|
|
149
|
+
readsImages: true,
|
|
114
150
|
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)])],
|
|
115
151
|
answer: (out, c) => {
|
|
116
152
|
const events = out.split('\n').filter((l) => l.startsWith('{')).map((l) => JSON.parse(l));
|
|
@@ -126,18 +162,37 @@ const ADAPTERS = {
|
|
|
126
162
|
}
|
|
127
163
|
return extractJsonObject(text, c.schema.required);
|
|
128
164
|
},
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
165
|
+
// A headless `run` cannot answer a permission ask, so any ask auto-rejects and the drive ends with no answer.
|
|
166
|
+
// Driving the app runs commands that read or execute paths outside the repo (a browser, a system binary), so an
|
|
167
|
+
// edit step allows every external directory except the user's home folder, where only the run's own folders are
|
|
168
|
+
// allowed again; opencode's data folder (its login and database) is denied even when it is outside the home folder.
|
|
169
|
+
// opencode takes the last rule that matches, so the run folders come after the denies. Tool-less calls deny all.
|
|
170
|
+
env: (c) => {
|
|
171
|
+
if (c.tools !== 'edit')
|
|
172
|
+
return { OPENCODE_PERMISSION: JSON.stringify({ '*': 'deny' }) };
|
|
173
|
+
const home = homedir();
|
|
174
|
+
const data = join(process.env.XDG_DATA_HOME || join(home, '.local', 'share'), 'opencode');
|
|
175
|
+
return { OPENCODE_PERMISSION: JSON.stringify({ external_directory: {
|
|
176
|
+
'*': 'allow', [home]: 'deny', [`${home}/*`]: 'deny', [data]: 'deny', [`${data}/*`]: 'deny',
|
|
177
|
+
...Object.fromEntries((c.dirs ?? []).flatMap((d) => [[d, 'allow'], [`${d}/*`, 'allow']])),
|
|
178
|
+
} }) };
|
|
179
|
+
},
|
|
180
|
+
// A permission the run refused: the drive could not continue, so this is the reason, not a missing answer.
|
|
181
|
+
reject: (stderr) => {
|
|
182
|
+
const m = PERMISSION_REJECT.exec(stripVTControlCharacters(stderr));
|
|
183
|
+
return m ? `it asked for permission to ${m[1]} (${m[2]}), which a headless run cannot answer and auto-rejects` : '';
|
|
184
|
+
},
|
|
132
185
|
},
|
|
133
|
-
// pi -p reads the prompt on stdin (probed
|
|
186
|
+
// pi -p reads the prompt on stdin (probed: with no positional message, stdin becomes the user
|
|
134
187
|
// message); --mode json streams JSONL events whose assistant message_end holds the answer text,
|
|
135
188
|
// --no-session keeps the probe ephemeral. An @<file> argument attaches an image to that message.
|
|
136
|
-
// --no-tools turns every built-in and extension tool off (probed
|
|
189
|
+
// --no-tools turns every built-in and extension tool off (probed: a canary file outside the cwd went unread).
|
|
137
190
|
// A tool-less call also loads no extensions, skills, prompt templates or context files: they would add the user's own
|
|
138
|
-
// context to a fresh judge, and extension loading took 2 minutes on a real setup (probed
|
|
191
|
+
// context to a fresh judge, and extension loading took 2 minutes on a real setup (probed), past the judge's timeout.
|
|
139
192
|
// Pi has no permission gate for edit steps, so doctor warns about it. Without a model, pi runs its own default.
|
|
140
193
|
pi: {
|
|
194
|
+
streams: true,
|
|
195
|
+
readsImages: true,
|
|
141
196
|
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)}`)],
|
|
142
197
|
answer: (out, c) => {
|
|
143
198
|
let text = '';
|
|
@@ -166,11 +221,12 @@ const ADAPTERS = {
|
|
|
166
221
|
// answer, and --ephemeral keeps no session. An edit step runs in codex's workspace-write sandbox, which writes only the
|
|
167
222
|
// repo, the run's folders (--add-dir) and $TMPDIR; it may use the network, since the drive launches the app and talks
|
|
168
223
|
// to it, and it keeps the user's config.toml. Without a model, codex runs its own default.
|
|
169
|
-
// A tool-less call is refused:
|
|
224
|
+
// A tool-less call is refused: codex with the user's config.toml kept its MCP server, which read a
|
|
170
225
|
// canary file outside the folder, and --disable does not turn off its multi-agent tools.
|
|
171
226
|
codex: {
|
|
227
|
+
streams: true,
|
|
172
228
|
editOnly: 'codex cannot run tool-less: with your config.toml it keeps its MCP tools, which read files outside the folder',
|
|
173
|
-
//
|
|
229
|
+
// Each sandboxed command runs in its own process namespace, so a server or browser bridge started in the
|
|
174
230
|
// background by one command was gone by the next one, and a drive that launched the app and then browsed it captured nothing.
|
|
175
231
|
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).',
|
|
176
232
|
argv: (c) => ['exec', '--json', '--ephemeral', ...(c.model ? ['-m', c.model] : []),
|
|
@@ -207,6 +263,7 @@ const ADAPTERS = {
|
|
|
207
263
|
// policy folder and removes every tool, MCP ones too, from the model (its policy-engine.md). An @<file> in the -p text
|
|
208
264
|
// 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).
|
|
209
265
|
gemini: {
|
|
266
|
+
readsImages: true,
|
|
210
267
|
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',
|
|
211
268
|
...(c.tools === 'none' ? ['--policy', join(dir, 'deny-all.toml'), '--skip-trust'] : []),
|
|
212
269
|
...(c.tools === 'edit' ? ['--allowed-tools', 'run_shell_command', ...(c.dirs ?? []).flatMap((d) => ['--include-directories', d])] : [])],
|
|
@@ -262,7 +319,6 @@ export function agentPath(agent, cfg) {
|
|
|
262
319
|
acpxRuns.set(acpx, spawnSync(acpx, ['--version'], { stdio: 'ignore', timeout: 5_000 }).status === 0);
|
|
263
320
|
return acpxRuns.get(acpx) ? acpx : '';
|
|
264
321
|
}
|
|
265
|
-
export const claudeBin = (cfg) => agentPath('claude', cfg);
|
|
266
322
|
export const routeOf = (agent) => (agent === 'claude' ? 'claude-code' : `answerer:${agent}`);
|
|
267
323
|
export const JUDGES = AGENTS.filter((a) => a === 'claude' || ANSWERER_JUDGES.includes(a));
|
|
268
324
|
// Like the agent, the judge is exactly the one the user set; with none, done-done asks the user (in a terminal) or
|
|
@@ -288,14 +344,14 @@ export function selectJudge(cfg, found, fix = false) {
|
|
|
288
344
|
if (fix && independence !== 'agent')
|
|
289
345
|
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>');
|
|
290
346
|
if (independence === 'session-only' && !cfg.judge.allow_same)
|
|
291
|
-
throw new UsageError(`judge: the judge ${agent} (model ${model}) is the author's own agent and model (${found.agent}), and the judge
|
|
347
|
+
throw new UsageError(`judge: the judge ${agent} (model ${model}) is the author's own agent and model (${found.agent}), and the judge must differ from the author by agent or by model`, `done-done init --judge <agent> --judge-model <model>, with another agent or the same agent at another model; judge.allow_same: true in ~/.done-done/config.yaml is an opt-out only, for a fresh session of the same agent and model`);
|
|
292
348
|
return { route, agent, model, independence, author: { agent: found.agent, model: found.model } };
|
|
293
349
|
}
|
|
294
350
|
const DRAIN_MS = 500;
|
|
295
351
|
const OUTPUT_CAP = 64 << 20;
|
|
296
352
|
// One agent process. An edit step gets its own process group, so whatever it started (a dev server, a browser) is
|
|
297
353
|
// killed with it when it ends, times out or writes past the output cap.
|
|
298
|
-
function start(bin, argv, c, cwd, input, env) {
|
|
354
|
+
function start(bin, argv, c, cwd, input, env, idleMs) {
|
|
299
355
|
const group = c.tools === 'edit';
|
|
300
356
|
return new Promise((done) => {
|
|
301
357
|
const child = spawn(bin, argv, { cwd, env, detached: group });
|
|
@@ -303,6 +359,39 @@ function start(bin, argv, c, cwd, input, env) {
|
|
|
303
359
|
let stderr = '';
|
|
304
360
|
let error;
|
|
305
361
|
let drain;
|
|
362
|
+
let idle;
|
|
363
|
+
// 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,
|
|
364
|
+
// 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,
|
|
365
|
+
// and only a rejected rate_limit_event on stdout stops it, so its complete lines are parsed one at a time.
|
|
366
|
+
let pendingStdout = '';
|
|
367
|
+
const limited = (chunk, stdoutLine) => {
|
|
368
|
+
let line;
|
|
369
|
+
if (c.agent === 'claude' && c.images?.length) {
|
|
370
|
+
if (!stdoutLine)
|
|
371
|
+
return;
|
|
372
|
+
const text = pendingStdout + chunk;
|
|
373
|
+
const end = text.lastIndexOf('\n') + 1;
|
|
374
|
+
pendingStdout = text.slice(end);
|
|
375
|
+
line = text.slice(0, end).split('\n').find(isRejectedRateLimit);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
line = stripVTControlCharacters(chunk).split(/\r?\n/).find((l) => RATE_LIMIT.test(l) && (!stdoutLine || /reset|retry/i.test(l)));
|
|
379
|
+
}
|
|
380
|
+
if (!line || error)
|
|
381
|
+
return;
|
|
382
|
+
error = new Error(`${c.agent} hit a rate limit and stopped: ${line.trim().slice(0, 200)}`);
|
|
383
|
+
kill();
|
|
384
|
+
};
|
|
385
|
+
// Any output (stdout or stderr) shows the agent is working; silence for idleMs is a hang.
|
|
386
|
+
const quiet = () => {
|
|
387
|
+
if (!idleMs)
|
|
388
|
+
return;
|
|
389
|
+
clearTimeout(idle);
|
|
390
|
+
idle = setTimeout(() => {
|
|
391
|
+
error = new Error(`${c.agent} printed nothing for ${Math.round(idleMs / 1000)}s`);
|
|
392
|
+
kill();
|
|
393
|
+
}, idleMs);
|
|
394
|
+
};
|
|
306
395
|
const kill = () => {
|
|
307
396
|
try {
|
|
308
397
|
if (group)
|
|
@@ -324,18 +413,24 @@ function start(bin, argv, c, cwd, input, env) {
|
|
|
324
413
|
}, c.timeoutMs);
|
|
325
414
|
child.on('error', (e) => {
|
|
326
415
|
clearTimeout(timer);
|
|
416
|
+
clearTimeout(idle);
|
|
327
417
|
done({ status: null, stdout, stderr, error: e });
|
|
328
418
|
});
|
|
329
419
|
if (!child.pid)
|
|
330
420
|
return;
|
|
331
421
|
if (group)
|
|
332
422
|
c.onSpawn?.(child.pid);
|
|
423
|
+
quiet();
|
|
333
424
|
child.stdout.setEncoding('utf8').on('data', (d) => {
|
|
425
|
+
quiet();
|
|
426
|
+
limited(d, true);
|
|
334
427
|
stdout += d;
|
|
335
428
|
if (stdout.length > OUTPUT_CAP)
|
|
336
429
|
overflow();
|
|
337
430
|
});
|
|
338
431
|
child.stderr.setEncoding('utf8').on('data', (d) => {
|
|
432
|
+
quiet();
|
|
433
|
+
limited(d, false);
|
|
339
434
|
stderr += d;
|
|
340
435
|
if (stderr.length > OUTPUT_CAP)
|
|
341
436
|
overflow();
|
|
@@ -348,6 +443,7 @@ function start(bin, argv, c, cwd, input, env) {
|
|
|
348
443
|
});
|
|
349
444
|
child.on('close', (status) => {
|
|
350
445
|
clearTimeout(timer);
|
|
446
|
+
clearTimeout(idle);
|
|
351
447
|
clearTimeout(drain);
|
|
352
448
|
if (group)
|
|
353
449
|
kill();
|
|
@@ -374,8 +470,9 @@ export async function askAgent(c) {
|
|
|
374
470
|
throw new Error(`no adapter for ${c.agent} yet`);
|
|
375
471
|
if (adapter.editOnly && c.tools !== 'edit')
|
|
376
472
|
throw new Error(adapter.editOnly);
|
|
377
|
-
|
|
378
|
-
|
|
473
|
+
if (adapter.readsImages)
|
|
474
|
+
for (const i of c.images ?? [])
|
|
475
|
+
writeFileSync(join(dir, i.name), i.bytes, { mode: 0o600 });
|
|
379
476
|
for (const [name, text] of Object.entries(adapter.files?.(c) ?? {}))
|
|
380
477
|
writeFileSync(join(dir, name), text, { mode: 0o600 });
|
|
381
478
|
const bin = c.path;
|
|
@@ -385,15 +482,20 @@ export async function askAgent(c) {
|
|
|
385
482
|
for (;;) {
|
|
386
483
|
attempts++;
|
|
387
484
|
// An ask-again names what was wrong with the last answer. The trailer ends on words, not the schema: a weak model
|
|
388
|
-
// (
|
|
485
|
+
// (seen with one opencode model) copied a schema that closed the prompt back as its answer.
|
|
389
486
|
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`;
|
|
390
487
|
const prompt = c.tools === 'edit' && adapter.editNote ? `${c.prompt}\n\n${adapter.editNote}` : c.prompt;
|
|
391
488
|
const promptText = (attempts === 1 ? prompt : `${prompt}\n\nYour previous answer was not valid (${lastError}).`) + schemaTrailer;
|
|
392
489
|
if (adapter.file)
|
|
393
490
|
writeFileSync(join(dir, 'prompt.md'), promptText, { mode: 0o600 });
|
|
394
|
-
|
|
491
|
+
const input = adapter.file ? '' : adapter.stdin ? adapter.stdin(c, promptText) : promptText;
|
|
492
|
+
res = await start(bin, argv(dir), c, cwd, input, { ...process.env, ...c.env, ...adapter.env?.(c) }, adapter.streams ? c.idleMs ?? AGENT_IDLE_MS : 0);
|
|
395
493
|
if (res.error)
|
|
396
494
|
throw res.error;
|
|
495
|
+
// A permission the run refused explains a call with no answer; name it rather than the exit code text.
|
|
496
|
+
const refused = adapter.reject?.(String(res.stderr ?? '')) ?? '';
|
|
497
|
+
if (res.status !== 0 && refused)
|
|
498
|
+
throw new ProviderError(refused, `${c.agent} exited ${res.status}: `);
|
|
397
499
|
if (res.status !== 0) {
|
|
398
500
|
// An agent that exits non-zero may still say why in its own output (claude: a usage limit, a bad model).
|
|
399
501
|
let text = String(res.stderr || res.stdout).trim().slice(-500);
|
|
@@ -421,6 +523,9 @@ export async function askAgent(c) {
|
|
|
421
523
|
catch (e) {
|
|
422
524
|
if (e instanceof ProviderError)
|
|
423
525
|
throw e;
|
|
526
|
+
// A refused permission is why there is no usable answer, not a bad answer: name it instead of "no JSON object".
|
|
527
|
+
if (refused)
|
|
528
|
+
throw new ProviderError(refused, `${c.agent} did not finish: `);
|
|
424
529
|
// Only a tool-less call is asked again: an edit or read step may already have changed the repo or read it.
|
|
425
530
|
lastError = e.message;
|
|
426
531
|
if (c.tools !== 'none' || attempts >= (c.attempts ?? ASK_ATTEMPTS))
|
|
@@ -429,8 +534,13 @@ export async function askAgent(c) {
|
|
|
429
534
|
}
|
|
430
535
|
}
|
|
431
536
|
catch (e) {
|
|
432
|
-
out
|
|
433
|
-
|
|
537
|
+
// A silent, hung or timed-out agent says why only on stderr, so its no-answer reason quotes that. A provider error already
|
|
538
|
+
// carries its own line, so it is left as it is.
|
|
539
|
+
const said = stderrHead(String(res?.stderr ?? ''));
|
|
540
|
+
const provider = e instanceof ProviderError;
|
|
541
|
+
const note = (text) => (!provider && said && !text.includes(said.split(' | ')[0]) ? `${text}; ${c.agent} stderr: ${said}` : text);
|
|
542
|
+
out = { ok: false, error: note(e.message), ...(!provider && said ? { stderr: said } : {}) };
|
|
543
|
+
failure = note(e instanceof ProviderError ? e.detail : e.message);
|
|
434
544
|
}
|
|
435
545
|
finally {
|
|
436
546
|
if (dir)
|