done-done-axi 0.1.0 → 0.2.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 +25 -15
- package/dist/agents.js +410 -0
- package/dist/cli.js +1866 -84
- package/dist/config.js +182 -26
- package/dist/design.js +195 -0
- package/dist/judge.js +175 -80
- package/dist/maintain.js +591 -0
- package/dist/media.js +28 -9
- package/docs/reference.md +231 -29
- package/integrations/firstmate/brief-include.md +6 -4
- package/integrations/firstmate/merge-gate +7 -3
- package/package.json +4 -4
- package/skills/done-done/SKILL.md +11 -1
package/README.md
CHANGED
|
@@ -54,12 +54,12 @@ 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, `.pi/skills/done-done/` for Pi, `.agents/skills/done-done/` for Codex, opencode 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
65
|
The skill tells your agent to use `done-done` if it is on PATH and otherwise run the GitHub package through `npx`, so the CLI comes along on demand:
|
|
@@ -94,13 +94,13 @@ npm install -g --prefix ~/.local --allow-git=all github:umeranjum17/done-done-ax
|
|
|
94
94
|
|
|
95
95
|
To skip the install, run `npx -y --allow-git=all github:umeranjum17/done-done-axi` wherever this README says `done-done`.
|
|
96
96
|
|
|
97
|
-
Once
|
|
97
|
+
Once done-done-axi is published to npm, the registry line takes over as the install:
|
|
98
98
|
|
|
99
99
|
```sh
|
|
100
100
|
npm install -g --prefix ~/.local done-done-axi
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
<!-- TODO(dd-npm-name): when done-done-axi
|
|
103
|
+
<!-- TODO(dd-npm-name): when done-done-axi is published to npm, flip the primary back to the registry line above and make `npx -y done-done-axi` the skip-the-install path. -->
|
|
104
104
|
|
|
105
105
|
**2. Set it up in your app's repo**
|
|
106
106
|
|
|
@@ -109,7 +109,7 @@ cd your-app
|
|
|
109
109
|
done-done init
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
`init` writes a minimal `.done-done.yaml` (only if you don't already have one) that names your app. It 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:
|
|
112
|
+
`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 screenshot (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
113
|
|
|
114
114
|
```yaml
|
|
115
115
|
journeys:
|
|
@@ -119,15 +119,22 @@ journeys:
|
|
|
119
119
|
|
|
120
120
|
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
121
|
|
|
122
|
-
Optional: `done-done init --
|
|
122
|
+
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
123
|
|
|
124
|
-
|
|
124
|
+
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
125
|
|
|
126
126
|
```sh
|
|
127
|
+
done-done init --journey smoke -- 'npm test > $DONE_DONE_EVIDENCE_ROOT/${RUN_ID}.log'
|
|
127
128
|
git add .done-done.yaml
|
|
128
129
|
git commit -m "Add done-done"
|
|
129
130
|
```
|
|
130
131
|
|
|
132
|
+
A `run -- <command>` whose command matches no recorded journey still runs, but its result hints that the recipe is not in the repo.
|
|
133
|
+
|
|
134
|
+
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).
|
|
135
|
+
|
|
136
|
+
`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)).
|
|
137
|
+
|
|
131
138
|
**3. Run it, then check it**
|
|
132
139
|
|
|
133
140
|
```sh
|
|
@@ -144,6 +151,8 @@ done-done receipt --outcome "it starts" ~/.done-done/evidence/your-app/*/*.log
|
|
|
144
151
|
done-done receipt --verify "$(git rev-parse HEAD)"
|
|
145
152
|
```
|
|
146
153
|
|
|
154
|
+
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 screenshots. When it says a screenshot is wrong or unclear, or a capture it cannot read (a log or video) 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`. 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)).
|
|
155
|
+
|
|
147
156
|
Run `done-done` with no arguments to see the current config and the next step.
|
|
148
157
|
|
|
149
158
|
## What it checks
|
|
@@ -154,37 +163,38 @@ Run `done-done` with no arguments to see the current config and the next step.
|
|
|
154
163
|
- ♻️ **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`.
|
|
155
164
|
- 🧾 **Honest claims**: Reads a pull request's `## Checks` block — from a file, a URL, or stdin — and makes sure every line is filled in.
|
|
156
165
|
- 📌 **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)).
|
|
157
|
-
- 👀 **Shows the outcome** (
|
|
166
|
+
- 👀 **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, the CLI of opencode, pi or Gemini CLI, or your own OpenAI API key), to say whether each screenshot is the right screen for the claim, and a wrong screen 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`.
|
|
167
|
+
- 🎨 **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 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)).
|
|
158
168
|
- 🔒 **Safe in CI**: `--trusted-ref origin/main` reads the rules from your main branch, so a pull request can't loosen them.
|
|
159
169
|
|
|
160
|
-
Every answer is an exit code: **0** pass, **1** fail, **2** usage or config error, **3** can't tell either way.
|
|
170
|
+
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.
|
|
161
171
|
|
|
162
172
|
## Use with Firstmate or another agent fleet
|
|
163
173
|
|
|
164
174
|
[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/).
|
|
165
175
|
|
|
166
|
-
Install the skill once per machine for your workers' agent (
|
|
176
|
+
Install the skill once per machine for your workers' agent (the same `-a` values), install the CLI from GitHub, and copy two files from it into your Firstmate home:
|
|
167
177
|
|
|
168
178
|
```sh
|
|
169
|
-
npx skills add umeranjum17/done-done-axi --skill done-done -g -a
|
|
179
|
+
npx skills add umeranjum17/done-done-axi --skill done-done -g -a <agent> -y
|
|
170
180
|
npm install -g --allow-git=all github:umeranjum17/done-done-axi
|
|
171
181
|
cat "$(npm root -g)/done-done-axi/integrations/firstmate/brief-include.md" >> "$FM_HOME/config/brief-include.md"
|
|
172
182
|
cp "$(npm root -g)/done-done-axi/integrations/firstmate/merge-gate" "$FM_HOME/config/merge-gate"
|
|
173
183
|
```
|
|
174
184
|
|
|
175
185
|
- **`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.
|
|
176
|
-
- **`merge-gate`** (optional): `merge-gate <task-id> <worktree
|
|
186
|
+
- **`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 does not cd and runs git and done-done in the current directory (the caller's project clone). 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.
|
|
177
187
|
|
|
178
188
|
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.
|
|
179
189
|
|
|
180
190
|
## Learn more
|
|
181
191
|
|
|
182
|
-
- [Full reference](docs/reference.md): every `init`, `run`, `check` and `receipt` option, the Checks block rules, each file check, and both config files.
|
|
192
|
+
- [Full reference](docs/reference.md): every `init`, `doctor`, `run`, `check` and `receipt` option, the Checks block rules, each file check, and both config files.
|
|
183
193
|
- [Report a problem or ask a question](https://github.com/umeranjum17/done-done-axi/issues)
|
|
184
194
|
|
|
185
195
|
## What's next
|
|
186
196
|
|
|
187
|
-
So far: `init`, `run`, `check`, `receipt`, the
|
|
197
|
+
So far: `init`, `doctor`, `run`, `check`, `receipt`, `verify` (by journey or by your coding agent, with a gate you answer with `respond`), the screenshot 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.
|
|
188
198
|
|
|
189
199
|
## Develop
|
|
190
200
|
|
package/dist/agents.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
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
|
+
// gemini -p text is appended to stdin, so -p carries only a fixed trailer and the prompt goes on stdin;
|
|
166
|
+
// -o json prints one object {response, stats, error?} (its headless.md). plan mode is read-only, so a
|
|
167
|
+
// headless call never blocks on an approval prompt; auto_edit (edits approved) plus the shell tool is only for edit steps,
|
|
168
|
+
// with the run's folders added to the workspace.
|
|
169
|
+
// --skip-trust is only for the tool-less probe in its scratch folder (the headless path in its trusted-folders.md): done-done
|
|
170
|
+
// never runs interactively, so the trust dialog could never be answered there. Repo calls keep Gemini's trust gate.
|
|
171
|
+
// plan mode still reads files, so a tool-less call also loads a deny-all --policy, which replaces the user's own
|
|
172
|
+
// policy folder and removes every tool, MCP ones too, from the model (its policy-engine.md). An @<file> in the -p text
|
|
173
|
+
// 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).
|
|
174
|
+
gemini: {
|
|
175
|
+
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',
|
|
176
|
+
...(c.tools === 'none' ? ['--policy', join(dir, 'deny-all.toml'), '--skip-trust'] : []),
|
|
177
|
+
...(c.tools === 'edit' ? ['--allowed-tools', 'run_shell_command', ...(c.dirs ?? []).flatMap((d) => ['--include-directories', d])] : [])],
|
|
178
|
+
files: (c) => (c.tools === 'none' ? { 'deny-all.toml': '[[rule]]\ntoolName = "*"\ndecision = "deny"\npriority = 999\n' } : {}),
|
|
179
|
+
hint: 'if gemini refused this folder as untrusted, trust it in Gemini CLI first, then retry',
|
|
180
|
+
answer: (out, c) => {
|
|
181
|
+
const r = JSON.parse(out);
|
|
182
|
+
if (!r.response && r.error)
|
|
183
|
+
throw new ProviderError(JSON.stringify(r.error));
|
|
184
|
+
return extractJsonObject(String(r.response ?? ''), c.schema.required);
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
// The author agent: exactly the one the user set, never one done-done detects or falls back to. With none set,
|
|
189
|
+
// done-done asks the user (in a terminal) or stops with the command that saves their answer.
|
|
190
|
+
export function selectAgent(cfg) {
|
|
191
|
+
if (!cfg.agent)
|
|
192
|
+
throw noAgent();
|
|
193
|
+
return agentRun(cfg, cfg.agent);
|
|
194
|
+
}
|
|
195
|
+
// The agents a user can pick, and the error that tells a calling agent to ask the user rather than pick one itself.
|
|
196
|
+
export const agentChoices = () => [...AGENTS.filter(supported), 'acp:<target>'];
|
|
197
|
+
// 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.
|
|
198
|
+
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}`);
|
|
199
|
+
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}`);
|
|
200
|
+
export const modelFor = (cfg, agent) => cfg.agent_config[agent]?.model || CHEAP[agent]?.model || '';
|
|
201
|
+
export const agentRun = (cfg, agent, model = modelFor(cfg, agent)) => ({
|
|
202
|
+
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),
|
|
203
|
+
});
|
|
204
|
+
export const notStartable = (agent, override = '') => (override ? `agent_path_override.${agent} ${override} is not an executable file` : `${agent} is not on PATH`);
|
|
205
|
+
// The known agent an agent names, for the judge-independence check: acp:claude-code is claude; a target that
|
|
206
|
+
// names no known agent (acp:claudex, acp:foo) stays as named.
|
|
207
|
+
export function knownAgentOf(agent) {
|
|
208
|
+
const target = agent.replace(/^acp:/, '');
|
|
209
|
+
return AGENTS.find((a) => target === a || (target.startsWith(a) && !/[A-Za-z0-9]/.test(target[a.length]))) ?? agent;
|
|
210
|
+
}
|
|
211
|
+
const adapterFor = (agent) => (agent.startsWith('acp:') ? ACP : Object.hasOwn(ADAPTERS, agent) ? ADAPTERS[agent] : undefined);
|
|
212
|
+
export function supported(agent) {
|
|
213
|
+
return !!adapterFor(agent);
|
|
214
|
+
}
|
|
215
|
+
// A binary from config: a bare name is looked up on PATH, an absolute path is used when it is an executable file.
|
|
216
|
+
export const findBin = (bin) => (isAbsolute(bin) ? (isExecutable(bin) ? bin : '') : onPath(bin));
|
|
217
|
+
// The binary that starts an agent: agent_path_override.<agent>, else its name on PATH. An acp:<target> agent starts
|
|
218
|
+
// through acpx (acpx_path), which counts as installed only when it runs (a version-manager shim with no version set
|
|
219
|
+
// is on PATH but starts nothing).
|
|
220
|
+
const acpxRuns = new Map();
|
|
221
|
+
export function agentPath(agent, cfg) {
|
|
222
|
+
if (!agent.startsWith('acp:'))
|
|
223
|
+
return findBin(cfg?.agent_path_override[agent] || agent);
|
|
224
|
+
const acpx = findBin(cfg?.acpx_path || 'acpx');
|
|
225
|
+
if (!acpx)
|
|
226
|
+
return '';
|
|
227
|
+
if (!acpxRuns.has(acpx))
|
|
228
|
+
acpxRuns.set(acpx, spawnSync(acpx, ['--version'], { stdio: 'ignore', timeout: 5_000 }).status === 0);
|
|
229
|
+
return acpxRuns.get(acpx) ? acpx : '';
|
|
230
|
+
}
|
|
231
|
+
export const claudeBin = (cfg) => agentPath('claude', cfg);
|
|
232
|
+
export const routeOf = (agent) => (agent === 'claude' ? 'claude-code' : `answerer:${agent}`);
|
|
233
|
+
export const JUDGES = AGENTS.filter((a) => a === 'claude' || ANSWERER_JUDGES.includes(a));
|
|
234
|
+
// Like the agent, the judge is exactly the one the user set; with none, done-done asks the user (in a terminal) or
|
|
235
|
+
// stops with this error, so a calling agent asks its user rather than picking one.
|
|
236
|
+
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}`);
|
|
237
|
+
export function selectJudge(cfg, found) {
|
|
238
|
+
const route = cfg.judge.route;
|
|
239
|
+
if (!route)
|
|
240
|
+
throw noJudge();
|
|
241
|
+
// A key route judges on the user's own key at judge.model; an agent route judges through that agent at its model.
|
|
242
|
+
const keyed = Object.hasOwn(KEY_ROUTES, route);
|
|
243
|
+
if (keyed && !cfg.judge.model)
|
|
244
|
+
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');
|
|
245
|
+
const agent = keyed ? route : route === 'claude-code' ? 'claude' : route.slice('answerer:'.length);
|
|
246
|
+
const model = cfg.judge.model || modelFor(cfg, agent);
|
|
247
|
+
if (!model)
|
|
248
|
+
throw new UsageError(`judge: no model for ${route}`, `Set judge.model in ~/.done-done/config.yaml`);
|
|
249
|
+
// acp:claude-code is claude for independence; an author with no known model is never apart by model.
|
|
250
|
+
const independence = keyed
|
|
251
|
+
? found.model && model === found.model ? 'session-only' : 'agent'
|
|
252
|
+
: agent !== knownAgentOf(found.agent) ? 'agent' : found.model && model !== found.model ? 'model' : 'session-only';
|
|
253
|
+
if (independence === 'session-only' && !cfg.judge.allow_same)
|
|
254
|
+
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`);
|
|
255
|
+
return { route, agent, model, independence, author: { agent: found.agent, model: found.model } };
|
|
256
|
+
}
|
|
257
|
+
const DRAIN_MS = 500;
|
|
258
|
+
const OUTPUT_CAP = 64 << 20;
|
|
259
|
+
// One agent process. An edit step gets its own process group, so whatever it started (a dev server, a browser) is
|
|
260
|
+
// killed with it when it ends, times out or writes past the output cap.
|
|
261
|
+
function start(bin, argv, c, cwd, input, env) {
|
|
262
|
+
const group = c.tools === 'edit';
|
|
263
|
+
return new Promise((done) => {
|
|
264
|
+
const child = spawn(bin, argv, { cwd, env, detached: group });
|
|
265
|
+
let stdout = '';
|
|
266
|
+
let stderr = '';
|
|
267
|
+
let error;
|
|
268
|
+
let drain;
|
|
269
|
+
const kill = () => {
|
|
270
|
+
try {
|
|
271
|
+
if (group)
|
|
272
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
273
|
+
else
|
|
274
|
+
child.kill('SIGKILL');
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// Already gone.
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
const overflow = () => {
|
|
281
|
+
error ??= new Error(`${c.agent} wrote more than ${OUTPUT_CAP >> 20} MiB of output`);
|
|
282
|
+
kill();
|
|
283
|
+
};
|
|
284
|
+
const timer = setTimeout(() => {
|
|
285
|
+
error = new Error(`${c.agent} timed out after ${Math.round(c.timeoutMs / 1000)}s`);
|
|
286
|
+
kill();
|
|
287
|
+
}, c.timeoutMs);
|
|
288
|
+
child.on('error', (e) => {
|
|
289
|
+
clearTimeout(timer);
|
|
290
|
+
done({ status: null, stdout, stderr, error: e });
|
|
291
|
+
});
|
|
292
|
+
if (!child.pid)
|
|
293
|
+
return;
|
|
294
|
+
if (group)
|
|
295
|
+
c.onSpawn?.(child.pid);
|
|
296
|
+
child.stdout.setEncoding('utf8').on('data', (d) => {
|
|
297
|
+
stdout += d;
|
|
298
|
+
if (stdout.length > OUTPUT_CAP)
|
|
299
|
+
overflow();
|
|
300
|
+
});
|
|
301
|
+
child.stderr.setEncoding('utf8').on('data', (d) => {
|
|
302
|
+
stderr += d;
|
|
303
|
+
if (stderr.length > OUTPUT_CAP)
|
|
304
|
+
overflow();
|
|
305
|
+
});
|
|
306
|
+
child.on('exit', () => {
|
|
307
|
+
drain = setTimeout(() => {
|
|
308
|
+
child.stdout.destroy();
|
|
309
|
+
child.stderr.destroy();
|
|
310
|
+
}, DRAIN_MS);
|
|
311
|
+
});
|
|
312
|
+
child.on('close', (status) => {
|
|
313
|
+
clearTimeout(timer);
|
|
314
|
+
clearTimeout(drain);
|
|
315
|
+
if (group)
|
|
316
|
+
kill();
|
|
317
|
+
done({ status, stdout, stderr, error });
|
|
318
|
+
});
|
|
319
|
+
child.stdin.on('error', () => { }); // an agent that exits without reading its stdin
|
|
320
|
+
child.stdin.end(input);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
export async function askAgent(c) {
|
|
324
|
+
const adapter = adapterFor(c.agent);
|
|
325
|
+
const argv = (d) => (adapter ? [...adapter.argv(c, d), ...(c.effort ? EFFORT_FLAGS[c.agent]?.(c.effort) ?? [] : []), ...(c.args ?? [])] : []);
|
|
326
|
+
const started = Date.now();
|
|
327
|
+
let dir = '';
|
|
328
|
+
let cwd = c.cwd;
|
|
329
|
+
let res;
|
|
330
|
+
let attempts = 0;
|
|
331
|
+
let out;
|
|
332
|
+
let failure;
|
|
333
|
+
try {
|
|
334
|
+
dir = mkdtempSync(join(tmpdir(), 'done-done-agent-'));
|
|
335
|
+
cwd = c.tools === 'none' ? dir : c.cwd;
|
|
336
|
+
if (!adapter)
|
|
337
|
+
throw new Error(`no adapter for ${c.agent} yet`);
|
|
338
|
+
for (const i of c.images ?? [])
|
|
339
|
+
writeFileSync(join(dir, i.name), i.bytes, { mode: 0o600 });
|
|
340
|
+
for (const [name, text] of Object.entries(adapter.files?.(c) ?? {}))
|
|
341
|
+
writeFileSync(join(dir, name), text, { mode: 0o600 });
|
|
342
|
+
const bin = c.path;
|
|
343
|
+
if (!bin)
|
|
344
|
+
throw new Error(c.agent.startsWith('acp:') ? `${c.agent} needs acpx, and acpx is not startable; ${ACPX_INSTALL}` : notStartable(c.agent, c.override));
|
|
345
|
+
let lastError = '';
|
|
346
|
+
for (;;) {
|
|
347
|
+
attempts++;
|
|
348
|
+
// An ask-again names what was wrong with the last answer.
|
|
349
|
+
const schemaTrailer = `\n\nAnswer with only one JSON object that matches this JSON schema, and no other text:\n${JSON.stringify(c.schema)}\n`;
|
|
350
|
+
const promptText = (attempts === 1 ? c.prompt : `${c.prompt}\n\nYour previous answer was not valid (${lastError}).`) + schemaTrailer;
|
|
351
|
+
if (adapter.file)
|
|
352
|
+
writeFileSync(join(dir, 'prompt.md'), promptText, { mode: 0o600 });
|
|
353
|
+
res = await start(bin, argv(dir), c, cwd, adapter.file ? '' : promptText, { ...process.env, ...c.env, ...adapter.env?.(c) });
|
|
354
|
+
if (res.error)
|
|
355
|
+
throw res.error;
|
|
356
|
+
if (res.status !== 0) {
|
|
357
|
+
// An agent that exits non-zero may still say why in its own output (claude: a usage limit, a bad model).
|
|
358
|
+
let text = String(res.stderr || res.stdout).trim().slice(-500);
|
|
359
|
+
try {
|
|
360
|
+
adapter.answer(String(res.stdout), c);
|
|
361
|
+
}
|
|
362
|
+
catch (e) {
|
|
363
|
+
if (e instanceof ProviderError)
|
|
364
|
+
text = e.detail;
|
|
365
|
+
else if (!(e instanceof SyntaxError))
|
|
366
|
+
text = e.message;
|
|
367
|
+
}
|
|
368
|
+
throw new ProviderError(text, `${c.agent} exited ${res.status}: `, adapter.hint && c.tools !== 'none' ? `; ${adapter.hint}` : '');
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const answer = adapter.answer(String(res.stdout), c);
|
|
372
|
+
if (!answer || typeof answer !== 'object' || Array.isArray(answer))
|
|
373
|
+
throw new Error(`the answer is not a JSON object: ${JSON.stringify(answer)}`);
|
|
374
|
+
const missing = (c.schema.required ?? []).filter((k) => !(k in answer));
|
|
375
|
+
if (missing.length)
|
|
376
|
+
throw new Error(`the answer does not match the schema (missing ${missing.join(', ')}): ${JSON.stringify(answer).slice(0, 200)}`);
|
|
377
|
+
out = { ok: true, answer: answer };
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
catch (e) {
|
|
381
|
+
if (e instanceof ProviderError)
|
|
382
|
+
throw e;
|
|
383
|
+
// Only a tool-less call is asked again: an edit or read step may already have changed the repo or read it.
|
|
384
|
+
lastError = e.message;
|
|
385
|
+
if (c.tools !== 'none' || attempts >= (c.attempts ?? ASK_ATTEMPTS))
|
|
386
|
+
throw new Error(`the agent did not answer with valid JSON after ${attempts} ${attempts === 1 ? 'try' : 'tries'}: ${lastError}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch (e) {
|
|
391
|
+
out = { ok: false, error: e.message };
|
|
392
|
+
failure = e instanceof ProviderError ? e.detail : e.message;
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
if (dir)
|
|
396
|
+
rmSync(dir, { recursive: true, force: true });
|
|
397
|
+
}
|
|
398
|
+
// The record holds what ran and what came back, never the prompt text or an image.
|
|
399
|
+
const { prompt, images, ...call } = c;
|
|
400
|
+
try {
|
|
401
|
+
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) };
|
|
402
|
+
mkdirSync(dirname(c.record), { recursive: true });
|
|
403
|
+
writeFileSync(c.record, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
404
|
+
out = { ...out, record: c.record };
|
|
405
|
+
}
|
|
406
|
+
catch (e) {
|
|
407
|
+
out = { ...out, recordError: e.message };
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|