done-done-axi 0.1.1 → 0.3.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 +30 -27
- package/dist/agents.js +451 -0
- package/dist/cli.js +2627 -168
- package/dist/config.js +255 -27
- package/dist/design.js +199 -0
- package/dist/dom.js +83 -0
- package/dist/instance.js +106 -0
- package/dist/judge.js +223 -83
- package/dist/maintain.js +613 -0
- package/dist/matrix.js +79 -0
- package/dist/media.js +28 -9
- package/dist/secrets.js +45 -0
- package/docs/media/config-dark.svg +1 -1
- package/docs/media/config-light.svg +1 -1
- package/docs/media/hero-dark.svg +137 -135
- package/docs/media/hero-light.svg +137 -135
- package/docs/media/make.mjs +9 -9
- package/docs/media/pass-dark.svg +4 -4
- package/docs/media/pass-light.svg +4 -4
- package/docs/media/reasons-dark.svg +19 -18
- package/docs/media/reasons-light.svg +19 -18
- package/docs/media/run-dark.svg +19 -18
- package/docs/media/run-light.svg +19 -18
- package/docs/reference.md +315 -35
- package/integrations/firstmate/brief-include.md +4 -3
- package/integrations/firstmate/merge-gate +6 -7
- package/package.json +4 -4
- package/skills/done-done/SKILL.md +13 -2
package/README.md
CHANGED
|
@@ -54,18 +54,18 @@ done-done is an [AXI](https://axi.md), which means -
|
|
|
54
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`:
|
|
55
55
|
|
|
56
56
|
```sh
|
|
57
|
-
npx skills add umeranjum17/done-done-axi --skill done-done -a
|
|
57
|
+
npx skills add umeranjum17/done-done-axi --skill done-done -a <agent> -y
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
`<agent>` is `claude-code`, `codex`, `opencode`, `pi` or `gemini-cli`. `-a` and `-y` skip the installer's four prompts and keep it from writing skill folders for agents you do not use.
|
|
61
61
|
|
|
62
|
-
The skill lands in this project (`.claude/skills/done-done/` for Claude Code, `.agents/skills/done-done/` for Codex and
|
|
62
|
+
The skill lands in this project (`.claude/skills/done-done/` for Claude Code, `.agents/skills/done-done/` for Codex, opencode, Pi and Gemini CLI) next to a `skills-lock.json`: commit both so teammates and CI get it too. Or add `-g` to install it once for all your projects (for example `~/.claude/skills/` for Claude Code) and leave the project untouched.
|
|
63
63
|
|
|
64
64
|
That is the entire setup - no npm install needed.
|
|
65
|
-
The skill tells your agent to use `done-done` if it is on PATH and otherwise run the
|
|
65
|
+
The skill tells your agent to use `done-done` if it is on PATH and otherwise run the npm package through `npx`, so the CLI comes along on demand:
|
|
66
66
|
|
|
67
67
|
```sh
|
|
68
|
-
npx -y
|
|
68
|
+
npx -y done-done-axi --help
|
|
69
69
|
```
|
|
70
70
|
|
|
71
71
|
The skill stays a short stub and sends the agent to `--help` and the bare command for current instructions, so an installed copy cannot go stale against a newer CLI.
|
|
@@ -86,21 +86,13 @@ The skill is the recommended path, but it is not the only one.
|
|
|
86
86
|
|
|
87
87
|
**1. Install**
|
|
88
88
|
|
|
89
|
-
```sh
|
|
90
|
-
npm install -g --prefix ~/.local --allow-git=all github:umeranjum17/done-done-axi
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
`--prefix ~/.local` installs into your home folder, so it needs no `sudo`. `--allow-git=all` is needed on npm 12, which blocks GitHub installs by default (verified accepted on npm 10). This installs two names for the same CLI: `done-done-axi` and the short `done-done` used below. If `command -v done-done` prints nothing, add `~/.local/bin` to your `PATH`: `export PATH="$HOME/.local/bin:$PATH"`.
|
|
94
|
-
|
|
95
|
-
To skip the install, run `npx -y --allow-git=all github:umeranjum17/done-done-axi` wherever this README says `done-done`.
|
|
96
|
-
|
|
97
|
-
Once done-done-axi is published to npm, the registry line takes over as the install:
|
|
98
|
-
|
|
99
89
|
```sh
|
|
100
90
|
npm install -g --prefix ~/.local done-done-axi
|
|
101
91
|
```
|
|
102
92
|
|
|
103
|
-
|
|
93
|
+
`--prefix ~/.local` installs into your home folder, so it needs no `sudo`. This installs two names for the same CLI: `done-done-axi` and the short `done-done` used below. If `command -v done-done` prints nothing, add `~/.local/bin` to your `PATH`: `export PATH="$HOME/.local/bin:$PATH"`.
|
|
94
|
+
|
|
95
|
+
To skip the install, run `npx -y done-done-axi` wherever this README says `done-done`.
|
|
104
96
|
|
|
105
97
|
**2. Set it up in your app's repo**
|
|
106
98
|
|
|
@@ -109,7 +101,7 @@ cd your-app
|
|
|
109
101
|
done-done init
|
|
110
102
|
```
|
|
111
103
|
|
|
112
|
-
`init` writes a minimal `.done-done.yaml` (only if you don't already have one) that names your app. A plain `init` never overwrites an existing config. 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 `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:
|
|
113
105
|
|
|
114
106
|
```yaml
|
|
115
107
|
journeys:
|
|
@@ -119,9 +111,9 @@ journeys:
|
|
|
119
111
|
|
|
120
112
|
The command runs from the repo root with `DONE_DONE_EVIDENCE_ROOT` (a new folder for this run in the evidence root) and `DONE_DONE_RUN_ID` set, and `${RUN_ID}` in it is replaced. A run that writes nothing into its folder fails.
|
|
121
113
|
|
|
122
|
-
Optional: `done-done init --
|
|
114
|
+
Optional: `done-done init --hook <claude|codex|opencode|gemini|pi>` adds a session hook or plugin to the repo that shows each session the bare `done-done` state. Each agent's file and behavior are in the [reference](docs/reference.md#set-up-a-repo).
|
|
123
115
|
|
|
124
|
-
Record the recipe in the repo so anyone can re-run the proof, then commit it (and `.claude/settings.json`, `.codex/hooks.json`,
|
|
116
|
+
Record the recipe in the repo so anyone can re-run the proof, then commit it (and `.claude/settings.json`, `.codex/hooks.json`, `.gemini/settings.json`, `.opencode/plugins/done-done.js` or `.pi/extensions/done-done.js` if you added a hook):
|
|
125
117
|
|
|
126
118
|
```sh
|
|
127
119
|
done-done init --journey smoke -- 'npm test > $DONE_DONE_EVIDENCE_ROOT/${RUN_ID}.log'
|
|
@@ -131,6 +123,10 @@ git commit -m "Add done-done"
|
|
|
131
123
|
|
|
132
124
|
A `run -- <command>` whose command matches no recorded journey still runs, but its result hints that the recipe is not in the repo.
|
|
133
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).
|
|
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)).
|
|
129
|
+
|
|
134
130
|
**3. Run it, then check it**
|
|
135
131
|
|
|
136
132
|
```sh
|
|
@@ -147,6 +143,10 @@ done-done receipt --outcome "it starts" ~/.done-done/evidence/your-app/*/*.log
|
|
|
147
143
|
done-done receipt --verify "$(git rev-parse HEAD)"
|
|
148
144
|
```
|
|
149
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)).
|
|
147
|
+
|
|
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
|
+
|
|
150
150
|
Run `done-done` with no arguments to see the current config and the next step.
|
|
151
151
|
|
|
152
152
|
## What it checks
|
|
@@ -157,37 +157,40 @@ Run `done-done` with no arguments to see the current config and the next step.
|
|
|
157
157
|
- ♻️ **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
158
|
- 🧾 **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
159
|
- 📌 **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** (
|
|
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`.
|
|
161
|
+
- 🎨 **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)).
|
|
161
163
|
- 🔒 **Safe in CI**: `--trusted-ref origin/main` reads the rules from your main branch, so a pull request can't loosen them.
|
|
162
164
|
|
|
163
|
-
Every answer is an exit code: **0** pass, **1** fail, **2** usage or config error, **3** can't tell either way.
|
|
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.
|
|
164
166
|
|
|
165
167
|
## Use with Firstmate or another agent fleet
|
|
166
168
|
|
|
167
169
|
[Firstmate](https://github.com/kunchenguid/firstmate) hands tasks to agent workers and merges their work. done-done ships what those workers need to prove each task, and what the merge needs to check that proof, in [`integrations/firstmate/`](integrations/firstmate/).
|
|
168
170
|
|
|
169
|
-
Install the skill once per machine for your workers' agent (
|
|
171
|
+
Install the skill once per machine for your workers' agent (the same `-a` values), install the CLI from npm, and copy two files from it into your Firstmate home:
|
|
170
172
|
|
|
171
173
|
```sh
|
|
172
|
-
npx skills add umeranjum17/done-done-axi --skill done-done -g -a
|
|
173
|
-
npm install -g
|
|
174
|
+
npx skills add umeranjum17/done-done-axi --skill done-done -g -a <agent> -y
|
|
175
|
+
npm install -g done-done-axi
|
|
174
176
|
cat "$(npm root -g)/done-done-axi/integrations/firstmate/brief-include.md" >> "$FM_HOME/config/brief-include.md"
|
|
175
177
|
cp "$(npm root -g)/done-done-axi/integrations/firstmate/merge-gate" "$FM_HOME/config/merge-gate"
|
|
176
178
|
```
|
|
177
179
|
|
|
178
180
|
- **`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.
|
|
179
|
-
- **`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: the gate
|
|
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.
|
|
180
182
|
|
|
181
183
|
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.
|
|
182
184
|
|
|
183
185
|
## Learn more
|
|
184
186
|
|
|
185
|
-
- [
|
|
187
|
+
- [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.
|
|
186
189
|
- [Report a problem or ask a question](https://github.com/umeranjum17/done-done-axi/issues)
|
|
187
190
|
|
|
188
191
|
## What's next
|
|
189
192
|
|
|
190
|
-
So far: `init`, `run`, `check`, `receipt`, the
|
|
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.
|
|
191
194
|
|
|
192
195
|
## Develop
|
|
193
196
|
|
package/dist/agents.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
5
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
6
|
+
import { AGENTS, ANSWERER_JUDGES, CHEAP, EFFORT_FLAGS, KEY_ROUTES, parseDuration, UsageError } from './config.js';
|
|
7
|
+
import { isExecutable, onPath } from './judge.js';
|
|
8
|
+
// argv never carries the prompt: it goes on stdin, or (file: true) into a 0600 prompt file the argv points at.
|
|
9
|
+
// done-done parses the answer, validates it against the schema, and asks a tool-less call again, at most ASK_ATTEMPTS calls.
|
|
10
|
+
const ASK_ATTEMPTS = 3;
|
|
11
|
+
// What a user sees of an agent's error: no colour codes, only the last line that names an error (not a
|
|
12
|
+
// stack frame, an indented field or a lone brace), at most 200 characters. The call record keeps the raw stderr.
|
|
13
|
+
const ERROR_WORD = /\b(error|failed|denied|cancelled|refused|not|invalid|unauthorized)\b/i;
|
|
14
|
+
function errorLine(text) {
|
|
15
|
+
const lines = stripVTControlCharacters(text).split(/[\r\n]+/).filter((l) => /\w/.test(l));
|
|
16
|
+
const top = lines.filter((l) => !/^\s/.test(l));
|
|
17
|
+
const line = (top.findLast((l) => ERROR_WORD.test(l)) ?? top.at(-1) ?? lines.at(-1) ?? '').trim();
|
|
18
|
+
return line.length > 200 ? `${line.slice(0, 199)}…` : line;
|
|
19
|
+
}
|
|
20
|
+
class ProviderError extends Error {
|
|
21
|
+
detail;
|
|
22
|
+
constructor(text, prefix = '', suffix = '') {
|
|
23
|
+
super(`${prefix}${errorLine(text)}${suffix}`);
|
|
24
|
+
this.detail = `${prefix}${text.slice(0, 500)}${suffix}`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// The answer object in an agent's free text: the first top-level '{'..'}' span that parses to an object with every
|
|
28
|
+
// required key, skipping braces and quotes in narration. With no such span, the last span that parsed is returned, so
|
|
29
|
+
// the caller reports the missing keys. A span still open at the end of the text was a stray brace in narration, so the
|
|
30
|
+
// scan restarts one character after it; restarts are capped so the worst case stays bounded.
|
|
31
|
+
const MAX_SPAN_RESTARTS = 50;
|
|
32
|
+
function scanSpans(text, from, spans) {
|
|
33
|
+
let depth = 0;
|
|
34
|
+
let start = 0;
|
|
35
|
+
let inString = false;
|
|
36
|
+
for (let i = from; i < text.length; i++) {
|
|
37
|
+
const ch = text[i];
|
|
38
|
+
if (depth === 0) {
|
|
39
|
+
if (ch === '{') {
|
|
40
|
+
depth = 1;
|
|
41
|
+
start = i;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else if (inString) {
|
|
45
|
+
if (ch === '\\')
|
|
46
|
+
i++;
|
|
47
|
+
else if (ch === '"')
|
|
48
|
+
inString = false;
|
|
49
|
+
}
|
|
50
|
+
else if (ch === '"')
|
|
51
|
+
inString = true;
|
|
52
|
+
else if (ch === '{')
|
|
53
|
+
depth++;
|
|
54
|
+
else if (ch === '}' && --depth === 0)
|
|
55
|
+
spans.push(text.slice(start, i + 1));
|
|
56
|
+
}
|
|
57
|
+
return depth > 0 ? start : -1;
|
|
58
|
+
}
|
|
59
|
+
// ponytail: a stray quote inside narration braces can hide the answer; the ask-again loop (a tool-less call is asked again up to ASK_ATTEMPTS times) covers it, so the scanner stops growing here.
|
|
60
|
+
export function extractJsonObject(text, required = []) {
|
|
61
|
+
const spans = [];
|
|
62
|
+
let open = scanSpans(text, 0, spans);
|
|
63
|
+
for (let restarts = 0; open >= 0 && restarts < MAX_SPAN_RESTARTS; restarts++)
|
|
64
|
+
open = scanSpans(text, open + 1, spans);
|
|
65
|
+
const parsed = spans.flatMap((span) => { try {
|
|
66
|
+
return [JSON.parse(span)];
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return [];
|
|
70
|
+
} });
|
|
71
|
+
const answer = parsed.find((v) => required.every((k) => k in v)) ?? parsed[parsed.length - 1];
|
|
72
|
+
if (!answer)
|
|
73
|
+
throw new Error(`no JSON object in the answer: ${text.slice(0, 200)}`);
|
|
74
|
+
return answer;
|
|
75
|
+
}
|
|
76
|
+
// acp:<target> runs any Agent Client Protocol agent through the user's acpx bridge (acpx's own registry names
|
|
77
|
+
// the targets): the prompt and the schema on stdin (exec --file -), and the answer is the JSON object in the agent's
|
|
78
|
+
// message chunks of acpx's JSON-RPC event stream. Every tool is denied, and a permission prompt is denied too, since
|
|
79
|
+
// nobody can answer it. No model means the target's own default.
|
|
80
|
+
const ACP = {
|
|
81
|
+
argv: (c, dir) => ['--cwd', c.tools === 'none' ? dir : c.cwd, '--format', 'json', '--json-strict', '--deny-all',
|
|
82
|
+
'--non-interactive-permissions', 'deny', ...(c.model ? ['--model', c.model] : []), c.agent.slice('acp:'.length), 'exec', '--file', '-'],
|
|
83
|
+
answer: (out, c) => {
|
|
84
|
+
const events = out.split('\n').filter((l) => l.startsWith('{')).map((l) => JSON.parse(l));
|
|
85
|
+
const text = events.filter((e) => e.method === 'session/update' && e.params?.update?.sessionUpdate === 'agent_message_chunk')
|
|
86
|
+
.map((e) => e.params.update.content?.text ?? '').join('');
|
|
87
|
+
// An error response fails the call only when the agent said nothing: a refused side request is not the answer.
|
|
88
|
+
const error = events.find((e) => e.error)?.error;
|
|
89
|
+
if (!text && error)
|
|
90
|
+
throw new ProviderError(String(error.message ?? JSON.stringify(error)).slice(0, 500));
|
|
91
|
+
return extractJsonObject(text, c.schema.required);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
export const ACPX_INSTALL = 'Install the acpx ACP bridge (npm install -g acpx, https://github.com/openclaw/acpx), then re-run: done-done doctor';
|
|
95
|
+
const PI_TOOL_LESS = ['--no-tools', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-context-files'];
|
|
96
|
+
const ADAPTERS = {
|
|
97
|
+
// claude -p: --json-schema puts the validated object in structured_output; --tools '' turns every tool off.
|
|
98
|
+
// An edit step accepts edits only in cwd and --add-dir folders, and may run commands (the drive launches the app).
|
|
99
|
+
claude: {
|
|
100
|
+
argv: (c) => ['-p', '--output-format', 'json', '--json-schema', JSON.stringify(c.schema), '--model', c.model, '--no-session-persistence',
|
|
101
|
+
...(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
|
+
answer: (out) => {
|
|
104
|
+
const r = JSON.parse(out);
|
|
105
|
+
if (r.is_error)
|
|
106
|
+
throw new ProviderError(String(r.result || r.subtype));
|
|
107
|
+
return r.structured_output;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
// opencode run reads no stdin: the prompt and the schema go in an attached file,
|
|
111
|
+
// and the answer is the JSON object in the text parts of its JSON event stream.
|
|
112
|
+
opencode: {
|
|
113
|
+
file: true,
|
|
114
|
+
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
|
+
answer: (out, c) => {
|
|
116
|
+
const events = out.split('\n').filter((l) => l.startsWith('{')).map((l) => JSON.parse(l));
|
|
117
|
+
const error = events.find((e) => e.type === 'error');
|
|
118
|
+
if (error)
|
|
119
|
+
throw new ProviderError(JSON.stringify(error.error ?? error));
|
|
120
|
+
let text = '';
|
|
121
|
+
for (const e of events) {
|
|
122
|
+
if (e.type === 'step_start')
|
|
123
|
+
text = '';
|
|
124
|
+
if (e.type === 'text')
|
|
125
|
+
text += e.part.text;
|
|
126
|
+
}
|
|
127
|
+
return extractJsonObject(text, c.schema.required);
|
|
128
|
+
},
|
|
129
|
+
// An edit step keeps the user's own permissions, where a folder outside cwd asks and so is refused in `run`; only the
|
|
130
|
+
// run's own folders are allowed.
|
|
131
|
+
env: (c) => ({ OPENCODE_PERMISSION: JSON.stringify(c.tools === 'edit' ? { external_directory: Object.fromEntries((c.dirs ?? []).flatMap((d) => [[d, 'allow'], [`${d}/*`, 'allow']])) } : { '*': 'deny' }) }),
|
|
132
|
+
},
|
|
133
|
+
// pi -p reads the prompt on stdin (probed 12 Oct: with no positional message, stdin becomes the user
|
|
134
|
+
// message); --mode json streams JSONL events whose assistant message_end holds the answer text,
|
|
135
|
+
// --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 13 Oct: a canary file outside the cwd went unread).
|
|
137
|
+
// 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 13 Oct), past the judge's timeout.
|
|
139
|
+
// Pi has no permission gate for edit steps, so doctor warns about it. Without a model, pi runs its own default.
|
|
140
|
+
pi: {
|
|
141
|
+
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
|
+
answer: (out, c) => {
|
|
143
|
+
let text = '';
|
|
144
|
+
let failure = '';
|
|
145
|
+
for (const line of out.split('\n')) {
|
|
146
|
+
if (!line.startsWith('{'))
|
|
147
|
+
continue;
|
|
148
|
+
let e;
|
|
149
|
+
try {
|
|
150
|
+
e = JSON.parse(line);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (e.type === 'error')
|
|
156
|
+
failure = JSON.stringify(e.error ?? e);
|
|
157
|
+
if (e.type === 'message_end' && e.message?.role === 'assistant')
|
|
158
|
+
text = (e.message.content ?? []).filter((p) => p?.type === 'text').map((p) => p.text).join('');
|
|
159
|
+
}
|
|
160
|
+
if (!text && failure)
|
|
161
|
+
throw new ProviderError(failure);
|
|
162
|
+
return extractJsonObject(text, c.schema.required);
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
// codex exec with no prompt argument reads it on stdin; --json streams JSONL events whose last agent_message holds the
|
|
166
|
+
// answer, and --ephemeral keeps no session. An edit step runs in codex's workspace-write sandbox, which writes only the
|
|
167
|
+
// repo, the run's folders (--add-dir) and $TMPDIR; it may use the network, since the drive launches the app and talks
|
|
168
|
+
// 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: probed live 13 Oct, codex with the user's config.toml kept its MCP server, which read a
|
|
170
|
+
// canary file outside the folder, and --disable does not turn off its multi-agent tools.
|
|
171
|
+
codex: {
|
|
172
|
+
editOnly: 'codex cannot run tool-less: with your config.toml it keeps its MCP tools, which read files outside the folder',
|
|
173
|
+
// Seen live 13 Oct: each sandboxed command runs in its own process namespace, so a server or browser bridge started in the
|
|
174
|
+
// background by one command was gone by the next one, and a drive that launched the app and then browsed it captured nothing.
|
|
175
|
+
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
|
+
argv: (c) => ['exec', '--json', '--ephemeral', ...(c.model ? ['-m', c.model] : []),
|
|
177
|
+
'--sandbox', 'workspace-write', '-c', 'sandbox_workspace_write.network_access=true', ...(c.dirs ?? []).flatMap((d) => ['--add-dir', d])],
|
|
178
|
+
answer: (out, c) => {
|
|
179
|
+
let text = '';
|
|
180
|
+
let failed = '';
|
|
181
|
+
let error = '';
|
|
182
|
+
for (const line of out.split('\n')) {
|
|
183
|
+
if (!line.startsWith('{'))
|
|
184
|
+
continue;
|
|
185
|
+
const e = JSON.parse(line);
|
|
186
|
+
if (e.type === 'item.completed' && e.item?.type === 'agent_message')
|
|
187
|
+
text = String(e.item.text ?? '');
|
|
188
|
+
if (e.type === 'turn.failed')
|
|
189
|
+
failed = String(e.error?.message ?? JSON.stringify(e));
|
|
190
|
+
if (e.type === 'error')
|
|
191
|
+
error = String(e.message ?? JSON.stringify(e));
|
|
192
|
+
}
|
|
193
|
+
// A failed turn has no answer; an error event (a retried stream) fails the call only when the agent said nothing.
|
|
194
|
+
const why = failed || (text ? '' : error);
|
|
195
|
+
if (why)
|
|
196
|
+
throw new ProviderError(why);
|
|
197
|
+
return extractJsonObject(text, c.schema.required);
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
// gemini -p text is appended to stdin, so -p carries only a fixed trailer and the prompt goes on stdin;
|
|
201
|
+
// -o json prints one object {response, stats, error?} (its headless.md). plan mode is read-only, so a
|
|
202
|
+
// headless call never blocks on an approval prompt; auto_edit (edits approved) plus the shell tool is only for edit steps,
|
|
203
|
+
// with the run's folders added to the workspace.
|
|
204
|
+
// --skip-trust is only for the tool-less probe in its scratch folder (the headless path in its trusted-folders.md): done-done
|
|
205
|
+
// never runs interactively, so the trust dialog could never be answered there. Repo calls keep Gemini's trust gate.
|
|
206
|
+
// plan mode still reads files, so a tool-less call also loads a deny-all --policy, which replaces the user's own
|
|
207
|
+
// policy folder and removes every tool, MCP ones too, from the model (its policy-engine.md). An @<file> in the -p text
|
|
208
|
+
// 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
|
+
gemini: {
|
|
210
|
+
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
|
+
...(c.tools === 'none' ? ['--policy', join(dir, 'deny-all.toml'), '--skip-trust'] : []),
|
|
212
|
+
...(c.tools === 'edit' ? ['--allowed-tools', 'run_shell_command', ...(c.dirs ?? []).flatMap((d) => ['--include-directories', d])] : [])],
|
|
213
|
+
files: (c) => (c.tools === 'none' ? { 'deny-all.toml': '[[rule]]\ntoolName = "*"\ndecision = "deny"\npriority = 999\n' } : {}),
|
|
214
|
+
hint: 'if gemini refused this folder as untrusted, trust it in Gemini CLI first, then retry',
|
|
215
|
+
answer: (out, c) => {
|
|
216
|
+
const r = JSON.parse(out);
|
|
217
|
+
if (!r.response && r.error)
|
|
218
|
+
throw new ProviderError(JSON.stringify(r.error));
|
|
219
|
+
return extractJsonObject(String(r.response ?? ''), c.schema.required);
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
// The author agent: exactly the one the user set, never one done-done detects or falls back to. With none set,
|
|
224
|
+
// done-done asks the user (in a terminal) or stops with the command that saves their answer.
|
|
225
|
+
export function selectAgent(cfg) {
|
|
226
|
+
if (!cfg.agent)
|
|
227
|
+
throw noAgent();
|
|
228
|
+
return agentRun(cfg, cfg.agent);
|
|
229
|
+
}
|
|
230
|
+
// The agents a user can pick, and the error that tells a calling agent to ask the user rather than pick one itself.
|
|
231
|
+
export const agentChoices = () => [...AGENTS, 'acp:<target>'];
|
|
232
|
+
// The error a command gives when the agent or the judge is not set: the ask, the choices, and the command that saves the answer.
|
|
233
|
+
const askError = (kind, choices, rule, save) => new UsageError(`no ${kind} is set: ask the user which ${kind} to use (${choices}); ${rule}`, `Save the user's answer: ${save}`);
|
|
234
|
+
export const noAgent = () => askError('agent', agentChoices().join(', '), 'never pick one for them', `done-done init --agent <agent> --model <model>, e.g. done-done init --agent opencode --model ${CHEAP.opencode.model}`);
|
|
235
|
+
export const modelFor = (cfg, agent) => cfg.agent_config[agent]?.model || CHEAP[agent]?.model || '';
|
|
236
|
+
export const agentRun = (cfg, agent, model = modelFor(cfg, agent)) => ({
|
|
237
|
+
agent, model, effort: cfg.agent_config[agent]?.effort ?? '', args: cfg.agent_args_override[agent] ?? [], path: agentPath(agent, cfg), override: cfg.agent_path_override[agent] ?? '', timeoutMs: parseDuration(cfg.agent_timeout),
|
|
238
|
+
});
|
|
239
|
+
export const notStartable = (agent, override = '') => (override ? `agent_path_override.${agent} ${override} is not an executable file` : `${agent} is not on PATH`);
|
|
240
|
+
// The known agent an agent names, for the judge-independence check: acp:claude-code is claude; a target that
|
|
241
|
+
// names no known agent (acp:claudex, acp:foo) stays as named.
|
|
242
|
+
export function knownAgentOf(agent) {
|
|
243
|
+
const target = agent.replace(/^acp:/, '');
|
|
244
|
+
return AGENTS.find((a) => target === a || (target.startsWith(a) && !/[A-Za-z0-9]/.test(target[a.length]))) ?? agent;
|
|
245
|
+
}
|
|
246
|
+
const adapterFor = (agent) => (agent.startsWith('acp:') ? ACP : Object.hasOwn(ADAPTERS, agent) ? ADAPTERS[agent] : undefined);
|
|
247
|
+
// Why an agent cannot run a tool-less call, '' when it can.
|
|
248
|
+
export const editOnlyReason = (agent) => adapterFor(agent)?.editOnly ?? '';
|
|
249
|
+
// A binary from config: a bare name is looked up on PATH, an absolute path is used when it is an executable file.
|
|
250
|
+
export const findBin = (bin) => (isAbsolute(bin) ? (isExecutable(bin) ? bin : '') : onPath(bin));
|
|
251
|
+
// The binary that starts an agent: agent_path_override.<agent>, else its name on PATH. An acp:<target> agent starts
|
|
252
|
+
// through acpx (acpx_path), which counts as installed only when it runs (a version-manager shim with no version set
|
|
253
|
+
// is on PATH but starts nothing).
|
|
254
|
+
const acpxRuns = new Map();
|
|
255
|
+
export function agentPath(agent, cfg) {
|
|
256
|
+
if (!agent.startsWith('acp:'))
|
|
257
|
+
return findBin(cfg?.agent_path_override[agent] || agent);
|
|
258
|
+
const acpx = findBin(cfg?.acpx_path || 'acpx');
|
|
259
|
+
if (!acpx)
|
|
260
|
+
return '';
|
|
261
|
+
if (!acpxRuns.has(acpx))
|
|
262
|
+
acpxRuns.set(acpx, spawnSync(acpx, ['--version'], { stdio: 'ignore', timeout: 5_000 }).status === 0);
|
|
263
|
+
return acpxRuns.get(acpx) ? acpx : '';
|
|
264
|
+
}
|
|
265
|
+
export const claudeBin = (cfg) => agentPath('claude', cfg);
|
|
266
|
+
export const routeOf = (agent) => (agent === 'claude' ? 'claude-code' : `answerer:${agent}`);
|
|
267
|
+
export const JUDGES = AGENTS.filter((a) => a === 'claude' || ANSWERER_JUDGES.includes(a));
|
|
268
|
+
// Like the agent, the judge is exactly the one the user set; with none, done-done asks the user (in a terminal) or
|
|
269
|
+
// stops with this error, so a calling agent asks its user rather than picking one.
|
|
270
|
+
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}`);
|
|
271
|
+
// 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.
|
|
272
|
+
export function selectJudge(cfg, found, fix = false) {
|
|
273
|
+
const route = cfg.judge.route;
|
|
274
|
+
if (!route)
|
|
275
|
+
throw noJudge();
|
|
276
|
+
// A key route judges on the user's own key at judge.model; an agent route judges through that agent at its model.
|
|
277
|
+
const keyed = Object.hasOwn(KEY_ROUTES, route);
|
|
278
|
+
if (keyed && !cfg.judge.model)
|
|
279
|
+
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');
|
|
280
|
+
const agent = keyed ? route : route === 'claude-code' ? 'claude' : route.slice('answerer:'.length);
|
|
281
|
+
const model = cfg.judge.model || modelFor(cfg, agent);
|
|
282
|
+
if (!model)
|
|
283
|
+
throw new UsageError(`judge: no model for ${route}`, `Set judge.model in ~/.done-done/config.yaml`);
|
|
284
|
+
// acp:claude-code is claude for independence; an author with no known model is never apart by model.
|
|
285
|
+
const independence = keyed
|
|
286
|
+
? found.model && model === found.model ? 'session-only' : 'agent'
|
|
287
|
+
: agent !== knownAgentOf(found.agent) ? 'agent' : found.model && model !== found.model ? 'model' : 'session-only';
|
|
288
|
+
if (fix && independence !== 'agent')
|
|
289
|
+
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
|
+
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 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`);
|
|
292
|
+
return { route, agent, model, independence, author: { agent: found.agent, model: found.model } };
|
|
293
|
+
}
|
|
294
|
+
const DRAIN_MS = 500;
|
|
295
|
+
const OUTPUT_CAP = 64 << 20;
|
|
296
|
+
// One agent process. An edit step gets its own process group, so whatever it started (a dev server, a browser) is
|
|
297
|
+
// killed with it when it ends, times out or writes past the output cap.
|
|
298
|
+
function start(bin, argv, c, cwd, input, env) {
|
|
299
|
+
const group = c.tools === 'edit';
|
|
300
|
+
return new Promise((done) => {
|
|
301
|
+
const child = spawn(bin, argv, { cwd, env, detached: group });
|
|
302
|
+
let stdout = '';
|
|
303
|
+
let stderr = '';
|
|
304
|
+
let error;
|
|
305
|
+
let drain;
|
|
306
|
+
const kill = () => {
|
|
307
|
+
try {
|
|
308
|
+
if (group)
|
|
309
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
310
|
+
else
|
|
311
|
+
child.kill('SIGKILL');
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
// Already gone.
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
const overflow = () => {
|
|
318
|
+
error ??= new Error(`${c.agent} wrote more than ${OUTPUT_CAP >> 20} MiB of output`);
|
|
319
|
+
kill();
|
|
320
|
+
};
|
|
321
|
+
const timer = setTimeout(() => {
|
|
322
|
+
error = new Error(`${c.agent} timed out after ${Math.round(c.timeoutMs / 1000)}s`);
|
|
323
|
+
kill();
|
|
324
|
+
}, c.timeoutMs);
|
|
325
|
+
child.on('error', (e) => {
|
|
326
|
+
clearTimeout(timer);
|
|
327
|
+
done({ status: null, stdout, stderr, error: e });
|
|
328
|
+
});
|
|
329
|
+
if (!child.pid)
|
|
330
|
+
return;
|
|
331
|
+
if (group)
|
|
332
|
+
c.onSpawn?.(child.pid);
|
|
333
|
+
child.stdout.setEncoding('utf8').on('data', (d) => {
|
|
334
|
+
stdout += d;
|
|
335
|
+
if (stdout.length > OUTPUT_CAP)
|
|
336
|
+
overflow();
|
|
337
|
+
});
|
|
338
|
+
child.stderr.setEncoding('utf8').on('data', (d) => {
|
|
339
|
+
stderr += d;
|
|
340
|
+
if (stderr.length > OUTPUT_CAP)
|
|
341
|
+
overflow();
|
|
342
|
+
});
|
|
343
|
+
child.on('exit', () => {
|
|
344
|
+
drain = setTimeout(() => {
|
|
345
|
+
child.stdout.destroy();
|
|
346
|
+
child.stderr.destroy();
|
|
347
|
+
}, DRAIN_MS);
|
|
348
|
+
});
|
|
349
|
+
child.on('close', (status) => {
|
|
350
|
+
clearTimeout(timer);
|
|
351
|
+
clearTimeout(drain);
|
|
352
|
+
if (group)
|
|
353
|
+
kill();
|
|
354
|
+
done({ status, stdout, stderr, error });
|
|
355
|
+
});
|
|
356
|
+
child.stdin.on('error', () => { }); // an agent that exits without reading its stdin
|
|
357
|
+
child.stdin.end(input);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
export async function askAgent(c) {
|
|
361
|
+
const adapter = adapterFor(c.agent);
|
|
362
|
+
const argv = (d) => (adapter ? [...adapter.argv(c, d), ...(c.effort ? EFFORT_FLAGS[c.agent]?.(c.effort) ?? [] : []), ...(c.args ?? [])] : []);
|
|
363
|
+
const started = Date.now();
|
|
364
|
+
let dir = '';
|
|
365
|
+
let cwd = c.cwd;
|
|
366
|
+
let res;
|
|
367
|
+
let attempts = 0;
|
|
368
|
+
let out;
|
|
369
|
+
let failure;
|
|
370
|
+
try {
|
|
371
|
+
dir = mkdtempSync(join(tmpdir(), 'done-done-agent-'));
|
|
372
|
+
cwd = c.tools === 'none' ? dir : c.cwd;
|
|
373
|
+
if (!adapter)
|
|
374
|
+
throw new Error(`no adapter for ${c.agent} yet`);
|
|
375
|
+
if (adapter.editOnly && c.tools !== 'edit')
|
|
376
|
+
throw new Error(adapter.editOnly);
|
|
377
|
+
for (const i of c.images ?? [])
|
|
378
|
+
writeFileSync(join(dir, i.name), i.bytes, { mode: 0o600 });
|
|
379
|
+
for (const [name, text] of Object.entries(adapter.files?.(c) ?? {}))
|
|
380
|
+
writeFileSync(join(dir, name), text, { mode: 0o600 });
|
|
381
|
+
const bin = c.path;
|
|
382
|
+
if (!bin)
|
|
383
|
+
throw new Error(c.agent.startsWith('acp:') ? `${c.agent} needs acpx, and acpx is not startable; ${ACPX_INSTALL}` : notStartable(c.agent, c.override));
|
|
384
|
+
let lastError = '';
|
|
385
|
+
for (;;) {
|
|
386
|
+
attempts++;
|
|
387
|
+
// An ask-again names what was wrong with the last answer. The trailer ends on words, not the schema: a weak model
|
|
388
|
+
// (Go Muse, 13 Oct) copied a schema that closed the prompt back as its answer.
|
|
389
|
+
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
|
+
const prompt = c.tools === 'edit' && adapter.editNote ? `${c.prompt}\n\n${adapter.editNote}` : c.prompt;
|
|
391
|
+
const promptText = (attempts === 1 ? prompt : `${prompt}\n\nYour previous answer was not valid (${lastError}).`) + schemaTrailer;
|
|
392
|
+
if (adapter.file)
|
|
393
|
+
writeFileSync(join(dir, 'prompt.md'), promptText, { mode: 0o600 });
|
|
394
|
+
res = await start(bin, argv(dir), c, cwd, adapter.file ? '' : promptText, { ...process.env, ...c.env, ...adapter.env?.(c) });
|
|
395
|
+
if (res.error)
|
|
396
|
+
throw res.error;
|
|
397
|
+
if (res.status !== 0) {
|
|
398
|
+
// An agent that exits non-zero may still say why in its own output (claude: a usage limit, a bad model).
|
|
399
|
+
let text = String(res.stderr || res.stdout).trim().slice(-500);
|
|
400
|
+
try {
|
|
401
|
+
adapter.answer(String(res.stdout), c);
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
if (e instanceof ProviderError)
|
|
405
|
+
text = e.detail;
|
|
406
|
+
else if (!(e instanceof SyntaxError))
|
|
407
|
+
text = e.message;
|
|
408
|
+
}
|
|
409
|
+
throw new ProviderError(text, `${c.agent} exited ${res.status}: `, adapter.hint && c.tools !== 'none' ? `; ${adapter.hint}` : '');
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
const answer = adapter.answer(String(res.stdout), c);
|
|
413
|
+
if (!answer || typeof answer !== 'object' || Array.isArray(answer))
|
|
414
|
+
throw new Error(`the answer is not a JSON object: ${JSON.stringify(answer)}`);
|
|
415
|
+
const missing = (c.schema.required ?? []).filter((k) => !(k in answer));
|
|
416
|
+
if (missing.length)
|
|
417
|
+
throw new Error(`the answer does not match the schema (missing ${missing.join(', ')}): ${JSON.stringify(answer).slice(0, 200)}`);
|
|
418
|
+
out = { ok: true, answer: answer };
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
catch (e) {
|
|
422
|
+
if (e instanceof ProviderError)
|
|
423
|
+
throw e;
|
|
424
|
+
// Only a tool-less call is asked again: an edit or read step may already have changed the repo or read it.
|
|
425
|
+
lastError = e.message;
|
|
426
|
+
if (c.tools !== 'none' || attempts >= (c.attempts ?? ASK_ATTEMPTS))
|
|
427
|
+
throw new Error(`the agent did not answer with valid JSON after ${attempts} ${attempts === 1 ? 'try' : 'tries'}: ${lastError}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
catch (e) {
|
|
432
|
+
out = { ok: false, error: e.message };
|
|
433
|
+
failure = e instanceof ProviderError ? e.detail : e.message;
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
if (dir)
|
|
437
|
+
rmSync(dir, { recursive: true, force: true });
|
|
438
|
+
}
|
|
439
|
+
// The record holds what ran and what came back, never the prompt text or an image.
|
|
440
|
+
const { prompt, images, ...call } = c;
|
|
441
|
+
try {
|
|
442
|
+
const record = { ...call, ...(images && { images: images.map((i) => i.name) }), cwd, prompt_bytes: Buffer.byteLength(prompt), argv: argv('<tmp>'), ms: Date.now() - started, exit: res?.status ?? null, attempts, ...out, error: failure, stderr: String(res?.stderr ?? '').slice(-2000) };
|
|
443
|
+
mkdirSync(dirname(c.record), { recursive: true });
|
|
444
|
+
writeFileSync(c.record, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
445
|
+
out = { ...out, record: c.record };
|
|
446
|
+
}
|
|
447
|
+
catch (e) {
|
|
448
|
+
out = { ...out, recordError: e.message };
|
|
449
|
+
}
|
|
450
|
+
return out;
|
|
451
|
+
}
|