done-done-axi 0.6.6 → 0.6.8
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 +74 -1
- package/dist/agents.js +72 -15
- package/dist/cli.js +247 -21
- package/dist/config.js +5 -2
- package/dist/judge.js +43 -48
- package/dist/maintain.js +5 -0
- package/docs/reference.md +71 -6
- package/docs/releasing.md +3 -8
- package/package.json +1 -1
- package/skills/done-done/SKILL.md +5 -0
package/README.md
CHANGED
|
@@ -37,6 +37,12 @@ You need **[Node 22.18+](https://nodejs.org) and git**, plus your own signed-in
|
|
|
37
37
|
2. **Set up your repo.** `init` asks you, in a terminal, which coding agent drives your app and which judge checks it. done-done never picks either for you. It then writes a minimal `.done-done.yaml` and a verify skill: how to launch and drive your app. Outside a terminal, pass `--agent <agent> --model <model> --judge <agent> --judge-model <model>` instead.
|
|
38
38
|
3. **Get your first result.** Say what works now, in plain words. `verify` has your agent drive the real product the way a user does and save screenshots or output. A fresh judge checks whether each capture shows your claim. A pass names the capture and writes a receipt for this commit. Anything else stops at a gate with the judge's reason, and `done-done respond` answers it. No judge pass, no receipt.
|
|
39
39
|
|
|
40
|
+
Optional setup check: `done-done doctor --probe` requires a real answer from your chosen agent; it checks setup, not whether your app works.
|
|
41
|
+
|
|
42
|
+
For a signed-in Claude Code setup, pass `--agent claude --model claude-haiku-5-5 --judge claude --judge-model claude-haiku-5-5` to `init`. For Codex, use `--agent codex --model gpt-6.1-sol` with that same judge. Codex readers and probes require a CLI with `exec --ignore-user-config` and `debug models --bundled`: they keep your login, but disable MCP, project instructions and model-provided tools; edit/drive steps keep your normal Codex config. Codex is not a judge route.
|
|
43
|
+
|
|
44
|
+
Claude Code and Codex are proven live. **OpenCode is not yet proven live:** its free tier rejects done-done's tools-off calls with HTTP 403 (`opencode/deepseek-v4.1-flash-free`), so `init`, `doctor --probe` and a judged run are not proven there, and `opencode/muse-spark-1.3-contributor-free` may be absent from the installed catalog. To try OpenCode, choose a model your plan already covers from `opencode models` with `--agent opencode --model <provider/model>`; done-done never needs a new account or API spend.
|
|
45
|
+
|
|
40
46
|
No result? `done-done doctor` changes nothing and names each missing piece, such as a verify skill `init` could not write, with the exact command that fixes it. Commit `.done-done.yaml` and the verify skill so later runs and your teammates use the same recipe. You choose the agents and models; captures go to your chosen judge's provider, on your own plan or key. [Manual CLI](#manual-cli) walks through recording a journey, checking it and saving a receipt.
|
|
41
47
|
|
|
42
48
|
## Why
|
|
@@ -100,6 +106,72 @@ The skill is the recommended path, but it is not the only one.
|
|
|
100
106
|
|
|
101
107
|
Prefer a terminal to an agent skill? The [manual walkthrough](docs/manual-cli.md) covers installation without sudo, choosing agents, capture recipes, core and upgrade journeys, hooks, `doctor`, checks, receipts and the verify/fix loop. All behavior details live there or in the [reference](docs/reference.md).
|
|
102
108
|
|
|
109
|
+
## Write journeys in plain words
|
|
110
|
+
|
|
111
|
+
A journey is the saved script that drives your app and captures what a user sees. You do not have to write it yourself. Describe it, and the agent you chose at `init` writes it:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
done-done journey add "greet Umer and check that Hello Umer is printed"
|
|
115
|
+
done-done journey enhance greet-umer-prints-hello "add checks for an empty name and an unknown command"
|
|
116
|
+
done-done journey improve greet-umer-prints-hello "also check that greeting Zoë prints exactly Hello Zoë"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- **`add "<description>"`** writes a new journey: a capture script in `journeys/`, its entry in `.done-done.yaml` and a feature in your verify skill. It names the journey from your words and prints that name.
|
|
120
|
+
- **`enhance <name> ["<focus>"]`** deepens one journey you already have with more steps, edge cases and checks, and keeps the checks it has. Leave out the focus for a general pass.
|
|
121
|
+
- **`improve <name> "<instruction>"`** makes one specific change to one journey and nothing else.
|
|
122
|
+
|
|
123
|
+
Each command prints the files it changed, then runs the journey once. This is the real output of the three lines above, from a clean install on a small greeting CLI with Claude Code as the agent (run paths cut short with `…`):
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
$ done-done journey add "greet Umer and check that Hello Umer is printed"
|
|
127
|
+
Added journey greet-umer-prints-hello through claude. Review these repo changes:
|
|
128
|
+
.agents/skills/done-done-verify-hello-umer/features/README.md | 1 +
|
|
129
|
+
.done-done.yaml | 3 +++
|
|
130
|
+
2 files changed, 4 insertions(+)
|
|
131
|
+
journeys/greet-umer-prints-hello.mjs | 29 new lines
|
|
132
|
+
.agents/skills/done-done-verify-hello-umer/features/greet-umer-prints-hello.md | 13 new lines
|
|
133
|
+
The first run is a dirty-tree trial, not clean proof. Review and commit, then: done-done run greet-umer-prints-hello
|
|
134
|
+
ok greet-umer-prints-hello
|
|
135
|
+
command: run
|
|
136
|
+
result: recorded
|
|
137
|
+
dirty: true
|
|
138
|
+
files: 1
|
|
139
|
+
evidence[1]: …/terminal.txt
|
|
140
|
+
|
|
141
|
+
$ done-done journey enhance greet-umer-prints-hello "add checks for an empty name and an unknown command"
|
|
142
|
+
Enhanced journey greet-umer-prints-hello through claude. Review these repo changes:
|
|
143
|
+
.../features/greet-umer-prints-hello.md | 8 +++++++
|
|
144
|
+
.done-done.yaml | 4 +++-
|
|
145
|
+
journeys/greet-umer-prints-hello.mjs | 27 +++++++++++++++++++---
|
|
146
|
+
3 files changed, 35 insertions(+), 4 deletions(-)
|
|
147
|
+
…
|
|
148
|
+
files: 3
|
|
149
|
+
evidence[3]: …/terminal-empty-name.txt,…/terminal-unknown-command.txt,…/terminal.txt
|
|
150
|
+
|
|
151
|
+
$ done-done journey improve greet-umer-prints-hello "also check that greeting Zoë prints exactly Hello Zoë"
|
|
152
|
+
Improved journey greet-umer-prints-hello through claude. Review these repo changes:
|
|
153
|
+
.../features/greet-umer-prints-hello.md | 8 ++++++++
|
|
154
|
+
.done-done.yaml | 1 +
|
|
155
|
+
journeys/greet-umer-prints-hello.mjs | 11 ++++++++++-
|
|
156
|
+
3 files changed, 19 insertions(+), 1 deletion(-)
|
|
157
|
+
…
|
|
158
|
+
files: 4
|
|
159
|
+
evidence[4]: …/terminal-empty-name.txt,…/terminal-unknown-command.txt,…/terminal-zoe.txt,…/terminal.txt
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`.done-done.yaml` keeps what you asked for next to the journey:
|
|
163
|
+
|
|
164
|
+
```yaml
|
|
165
|
+
journeys:
|
|
166
|
+
greet-umer-prints-hello:
|
|
167
|
+
command:
|
|
168
|
+
# Enhanced focus: "add checks for an empty name and an unknown command"
|
|
169
|
+
# Improved instruction: "also check that greeting Zoë prints exactly Hello Zoë"
|
|
170
|
+
node journeys/greet-umer-prints-hello.mjs
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
That first run is a trial on uncommitted files, not proof. Read the generated script as you would any code an agent wrote, commit it, then `done-done run greet-umer-prints-hello` records clean proof (`dirty: false`). The saved script needs no agent or model, so CI can replay it. Generation itself runs only on your machine, never in CI, and uses only the agent you configured. `done-done journey --help` shows these examples; the [reference](docs/reference.md#add-a-journey-from-a-description) has every rule.
|
|
174
|
+
|
|
103
175
|
## What it checks
|
|
104
176
|
|
|
105
177
|
- 🧪 **The real product, not code checks**: Proof must come from driving the app, CLI or site the way a user does. A config whose journeys only run tests, typecheck, lint or a build, or proof whose captures all come from such runs, is refused by `check`, `receipt` and `gate`, and `doctor` flags it ([rule](docs/reference.md#real-product-evidence)).
|
|
@@ -155,11 +227,12 @@ Another fleet works the same way: add the include text to whatever your workers
|
|
|
155
227
|
- [Worked example: a CLI project](docs/example-cli.md): set up, one journey, check and receipt on a command-line tool.
|
|
156
228
|
- [Worked example: create and maintain a verify skill](docs/example-create-maintain.md): init writes the skill, a source change makes it drift, maintain fixes it, then a real browser journey is judged.
|
|
157
229
|
- [Full reference](docs/reference.md): every command and option, the Checks block rules, each file check, and both config files.
|
|
230
|
+
- [Config reference](docs/reference.md#config): optional global `judge.account_command` selects a pooled Claude account for judge and agent calls; an exported `CLAUDE_CONFIG_DIR` wins, and a failed pick stops the call.
|
|
158
231
|
- [Report a problem or ask a question](https://github.com/umeranjum17/done-done-axi/issues)
|
|
159
232
|
|
|
160
233
|
## What's next
|
|
161
234
|
|
|
162
|
-
So far: `init`, `doctor`, `run` (including `--open` and `--close`), `check`, `receipt`, `affected` (the journeys a diff needs), `lint` (a no-model gate on a Checks block), `gate` (a no-model CI check of a committed receipt), `verify` (by journey or by your coding agent, with a gate you answer with `respond`), `maintain`, the capture judge (always on), the `design` visual check, the `look` step, the run tools (`status`, `attach`, `logs`, `rerun`, `abort`, `signoff`, `history`) and the opt-in session hooks for Claude Code, Codex, opencode, Gemini CLI and pi. Coming next: the [milestones in the vision](VISION.md#next-milestones).
|
|
235
|
+
So far: `init`, `doctor`, `run` (including `--open` and `--close`), `check`, `receipt`, `affected` (the journeys a diff needs), `lint` (a no-model gate on a Checks block), `gate` (a no-model CI check of a committed receipt), `verify` (by journey or by your coding agent, with a gate you answer with `respond`), `journey add`, `enhance` and `improve` (journeys from plain words), `maintain`, the capture judge (always on), the `design` visual check, the `look` step, the run tools (`status`, `attach`, `logs`, `rerun`, `abort`, `signoff`, `history`) and the opt-in session hooks for Claude Code, Codex, opencode, Gemini CLI and pi. Coming next: the [milestones in the vision](VISION.md#next-milestones).
|
|
163
236
|
|
|
164
237
|
## Develop
|
|
165
238
|
|
package/dist/agents.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir, tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, extname, isAbsolute, join } from 'node:path';
|
|
5
5
|
import { stripVTControlCharacters } from 'node:util';
|
|
@@ -255,16 +255,34 @@ const ADAPTERS = {
|
|
|
255
255
|
// answer, and --ephemeral keeps no session. An edit step runs in codex's workspace-write sandbox, which writes only the
|
|
256
256
|
// repo, the run's folders (--add-dir) and $TMPDIR; it may use the network, since the drive launches the app and talks
|
|
257
257
|
// to it, and it keeps the user's config.toml. Without a model, codex runs its own default.
|
|
258
|
-
//
|
|
259
|
-
//
|
|
258
|
+
// Readers keep normal authentication but not user config (MCP), instructions or tools. Feature flags alone
|
|
259
|
+
// are insufficient: the bundled model catalog can force code-mode, collaboration and apply_patch back on.
|
|
260
|
+
// Copy only that public catalog into this call's scratch folder and remove its tool capabilities too.
|
|
260
261
|
codex: {
|
|
261
262
|
streams: true,
|
|
262
|
-
|
|
263
|
+
files: (c) => {
|
|
264
|
+
if (c.tools === 'edit')
|
|
265
|
+
return {};
|
|
266
|
+
const r = spawnSync(c.path, ['debug', 'models', '--bundled'], { encoding: 'utf8', timeout: 10_000, maxBuffer: 16 << 20 });
|
|
267
|
+
if (r.status !== 0)
|
|
268
|
+
throw new Error(`codex tool-less setup: ${r.error?.message || r.stderr || `debug models --bundled exited ${r.status}`}; update Codex CLI`);
|
|
269
|
+
const catalog = JSON.parse(r.stdout);
|
|
270
|
+
if (!Array.isArray(catalog.models) || !catalog.models.length || (c.model && !catalog.models.some((m) => m.slug === c.model)))
|
|
271
|
+
throw new Error(`codex tool-less setup: bundled catalog does not list ${c.model || 'any model'}; choose a model from codex debug models --bundled`);
|
|
272
|
+
for (const m of catalog.models)
|
|
273
|
+
Object.assign(m, { shell_type: 'disabled', apply_patch_tool_type: null, tool_mode: 'direct', multi_agent_version: null, experimental_supported_tools: [], supports_search_tool: false });
|
|
274
|
+
return { 'models.json': JSON.stringify(catalog) };
|
|
275
|
+
},
|
|
263
276
|
// Each sandboxed command runs in its own process namespace, so a server or browser bridge started in the
|
|
264
277
|
// background by one command was gone by the next one, and a drive that launched the app and then browsed it captured nothing.
|
|
265
278
|
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).',
|
|
266
|
-
argv: (c) => ['exec', '--json', '--ephemeral', ...(c.model ? ['-m', c.model] : []),
|
|
267
|
-
'--sandbox', 'workspace-write', '-c', 'sandbox_workspace_write.network_access=true', ...(c.dirs ?? []).flatMap((d) => ['--add-dir', d])]
|
|
279
|
+
argv: (c, dir) => [...(c.tools === 'edit' ? [] : ['--no-daemon']), 'exec', '--json', '--ephemeral', ...(c.model ? ['-m', c.model] : []),
|
|
280
|
+
...(c.tools === 'edit' ? ['--sandbox', 'workspace-write', '-c', 'sandbox_workspace_write.network_access=true', ...(c.dirs ?? []).flatMap((d) => ['--add-dir', d])] : [
|
|
281
|
+
'--ignore-user-config', '--ignore-rules', '--skip-git-repo-check', '--sandbox', 'read-only',
|
|
282
|
+
'-c', 'project_doc_max_bytes=0', '-c', `model_catalog_json=${JSON.stringify(join(dir, 'models.json'))}`,
|
|
283
|
+
'-c', 'web_search="disabled"', '-c', 'tools.update_plan.enabled=false', '-c', 'tools.experimental_request_user_input.enabled=false',
|
|
284
|
+
...['shell_tool', 'multi_agent', 'apps', 'plugins', 'skill_search', 'tool_suggest', 'image_generation', 'view_image', 'sleep_tool', 'goals'].flatMap((f) => ['--disable', f]),
|
|
285
|
+
])],
|
|
268
286
|
answer: (out, c) => {
|
|
269
287
|
let text = '';
|
|
270
288
|
let failed = '';
|
|
@@ -326,6 +344,7 @@ export const noAgent = () => askError('agent', agentChoices().join(', '), 'never
|
|
|
326
344
|
export const modelFor = (cfg, agent) => cfg.agent_config[agent]?.model || CHEAP[agent]?.model || '';
|
|
327
345
|
export const agentRun = (cfg, agent, model = modelFor(cfg, agent)) => ({
|
|
328
346
|
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),
|
|
347
|
+
...(agent === 'claude' && cfg.judge.account_command ? { accountCommand: cfg.judge.account_command } : {}),
|
|
329
348
|
});
|
|
330
349
|
export const notStartable = (agent, override = '') => (override ? `agent_path_override.${agent} ${override} is not an executable file` : `${agent} is not on PATH`);
|
|
331
350
|
// The known agent an agent names, for the judge-independence check: acp:claude-code is claude; a target that
|
|
@@ -335,11 +354,9 @@ export function knownAgentOf(agent) {
|
|
|
335
354
|
return AGENTS.find((a) => target === a || (target.startsWith(a) && !/[A-Za-z0-9]/.test(target[a.length]))) ?? agent;
|
|
336
355
|
}
|
|
337
356
|
const adapterFor = (agent) => (agent.startsWith('acp:') ? ACP : Object.hasOwn(ADAPTERS, agent) ? ADAPTERS[agent] : undefined);
|
|
338
|
-
// Why an agent cannot run a tool-less call, '' when it can.
|
|
339
|
-
export const editOnlyReason = (agent) => adapterFor(agent)?.editOnly ?? '';
|
|
340
357
|
// Why an agent cannot read a repo tool-less from text plain code gathers, '' when it can: gemini's none mode is a
|
|
341
358
|
// read-only plan mode, not tool-free.
|
|
342
|
-
export const toollessReason = (agent) => (agent === 'gemini' ? 'gemini cannot read tool-less yet, since its none mode is read-only plan mode, not tool-free' :
|
|
359
|
+
export const toollessReason = (agent) => (agent === 'gemini' ? 'gemini cannot read tool-less yet, since its none mode is read-only plan mode, not tool-free' : '');
|
|
343
360
|
// A binary from config: a bare name is looked up on PATH, an absolute path is used when it is an executable file.
|
|
344
361
|
export const findBin = (bin) => (isAbsolute(bin) ? (isExecutable(bin) ? bin : '') : onPath(bin));
|
|
345
362
|
// The binary that starts an agent: agent_path_override.<agent>, else its name on PATH. An acp:<target> agent starts
|
|
@@ -493,6 +510,44 @@ function start(bin, argv, c, cwd, input, env, idleMs) {
|
|
|
493
510
|
child.stdin.end(input);
|
|
494
511
|
});
|
|
495
512
|
}
|
|
513
|
+
let accountPick;
|
|
514
|
+
// One pick (including failure) per process, shared by readers, drives and the judge; never change the caller's env.
|
|
515
|
+
export function claudeAccount(agent, command) {
|
|
516
|
+
if (agent !== 'claude' || !command || process.env.CLAUDE_CONFIG_DIR !== undefined)
|
|
517
|
+
return Promise.resolve(undefined);
|
|
518
|
+
return accountPick ??= Promise.resolve().then(() => {
|
|
519
|
+
const fail = (why) => { throw new UsageError(`judge.account_command: ${why}`, 'Fix judge.account_command in the global config, or export CLAUDE_CONFIG_DIR for this call'); };
|
|
520
|
+
const r = spawnSync(command, { shell: true, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 });
|
|
521
|
+
if (r.error)
|
|
522
|
+
fail(r.error.code ?? 'command could not start');
|
|
523
|
+
let pick;
|
|
524
|
+
try {
|
|
525
|
+
pick = JSON.parse(r.stdout);
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
fail(r.status === 0 ? 'invalid JSON' : `command exited ${r.status}: ${errorLine(r.stderr) || 'no pick returned'}`);
|
|
529
|
+
}
|
|
530
|
+
if (r.status !== 0)
|
|
531
|
+
fail(`command exited ${r.status}: ${typeof pick?.reason === 'string' ? errorLine(pick.reason) : errorLine(r.stderr) || 'no pick returned'}`);
|
|
532
|
+
if (pick?.ok !== true)
|
|
533
|
+
fail(typeof pick?.reason === 'string' ? errorLine(pick.reason) : 'ok must be true');
|
|
534
|
+
if (typeof pick.id !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(pick.id))
|
|
535
|
+
fail('invalid account id');
|
|
536
|
+
const folder = pick.set?.CLAUDE_CONFIG_DIR;
|
|
537
|
+
let directory = false;
|
|
538
|
+
try {
|
|
539
|
+
directory = typeof folder === 'string' && isAbsolute(folder) && statSync(folder).isDirectory();
|
|
540
|
+
}
|
|
541
|
+
catch { }
|
|
542
|
+
if (!directory)
|
|
543
|
+
fail('set.CLAUDE_CONFIG_DIR must be an absolute existing folder');
|
|
544
|
+
if (!Array.isArray(pick.unset) || !pick.unset.every((n) => typeof n === 'string' && /^[A-Za-z_]\w*$/.test(n)))
|
|
545
|
+
fail('unset must be a list of environment variable names');
|
|
546
|
+
if (pick.unset.includes('CLAUDE_CONFIG_DIR'))
|
|
547
|
+
fail('cannot unset CLAUDE_CONFIG_DIR');
|
|
548
|
+
return { id: pick.id, folder, unset: pick.unset };
|
|
549
|
+
});
|
|
550
|
+
}
|
|
496
551
|
export async function askAgent(c) {
|
|
497
552
|
const adapter = adapterFor(c.agent);
|
|
498
553
|
const argv = (d) => (adapter ? [...adapter.argv(c, d), ...(c.effort ? EFFORT_FLAGS[c.agent]?.(c.effort) ?? [] : []), ...(c.args ?? [])] : []);
|
|
@@ -508,16 +563,18 @@ export async function askAgent(c) {
|
|
|
508
563
|
cwd = c.tools === 'none' ? dir : c.cwd;
|
|
509
564
|
if (!adapter)
|
|
510
565
|
throw new Error(`no adapter for ${c.agent} yet`);
|
|
511
|
-
|
|
512
|
-
|
|
566
|
+
const bin = c.path;
|
|
567
|
+
if (!bin)
|
|
568
|
+
throw new Error(c.agent.startsWith('acp:') ? `${c.agent} needs acpx, and acpx is not startable; ${ACPX_INSTALL}` : notStartable(c.agent, c.override));
|
|
569
|
+
const account = await claudeAccount(c.agent, c.accountCommand);
|
|
570
|
+
const env = { ...process.env, ...c.env, ...adapter.env?.(c), ...(account && { CLAUDE_CONFIG_DIR: account.folder }) };
|
|
571
|
+
for (const name of account?.unset ?? [])
|
|
572
|
+
delete env[name];
|
|
513
573
|
if (adapter.readsImages)
|
|
514
574
|
for (const i of c.images ?? [])
|
|
515
575
|
writeFileSync(join(dir, i.name), i.bytes, { mode: 0o600 });
|
|
516
576
|
for (const [name, text] of Object.entries(adapter.files?.(c) ?? {}))
|
|
517
577
|
writeFileSync(join(dir, name), text, { mode: 0o600 });
|
|
518
|
-
const bin = c.path;
|
|
519
|
-
if (!bin)
|
|
520
|
-
throw new Error(c.agent.startsWith('acp:') ? `${c.agent} needs acpx, and acpx is not startable; ${ACPX_INSTALL}` : notStartable(c.agent, c.override));
|
|
521
578
|
let lastError = '';
|
|
522
579
|
for (;;) {
|
|
523
580
|
attempts++;
|
|
@@ -529,7 +586,7 @@ export async function askAgent(c) {
|
|
|
529
586
|
if (adapter.file)
|
|
530
587
|
writeFileSync(join(dir, 'prompt.md'), promptText, { mode: 0o600 });
|
|
531
588
|
const input = adapter.file ? '' : adapter.stdin ? adapter.stdin(c, promptText) : promptText;
|
|
532
|
-
res = await start(bin, argv(dir), c, cwd, input,
|
|
589
|
+
res = await start(bin, argv(dir), c, cwd, input, env, adapter.streams ? c.idleMs ?? AGENT_IDLE_MS : 0);
|
|
533
590
|
if (res.error)
|
|
534
591
|
throw res.error;
|
|
535
592
|
// A permission the run refused explains a call with no answer; name it rather than the exit code text.
|
package/dist/cli.js
CHANGED
|
@@ -9,11 +9,11 @@ import { createInterface } from 'node:readline/promises';
|
|
|
9
9
|
import { parseArgs } from 'node:util';
|
|
10
10
|
import { encode } from '@toon-format/toon';
|
|
11
11
|
import { Document, parseDocument } from 'yaml';
|
|
12
|
-
import { ACPX_INSTALL, agentChoices, agentPath, agentRun, askAgent,
|
|
12
|
+
import { ACPX_INSTALL, agentChoices, agentPath, agentRun, askAgent, findBin, JUDGES, modelFor, noAgent, noJudge, notStartable, routeOf, selectAgent, selectFallback, selectJudge, toollessReason } from './agents.js';
|
|
13
13
|
import { AGENTS, CHEAP, globalConfigText, home, inputPattern, isAgent, isJudgeRoute, loadConfig, parseDuration, tilde, UsageError } from './config.js';
|
|
14
14
|
import { backend, judge, judgeable, onPath, quote, screen } from './judge.js';
|
|
15
15
|
import { startInstance, stopInstance } from './instance.js';
|
|
16
|
-
import { attemptProblem, changedSince, commitFix, dirtyTracked, FIX_SCHEMA, inWorktree, maintainSteps, named, pathspec, readerProblem, repoState, triage } from './maintain.js';
|
|
16
|
+
import { attemptProblem, changedSince, commitFix, dirtyTracked, FIX_SCHEMA, inWorktree, maintainSteps, journeyProposal, named, pathspec, readerProblem, repoState, triage } from './maintain.js';
|
|
17
17
|
import { cellEnv, cellName, cellSuffix, hasMatrix, matrixManifest, splitMatrix } from './matrix.js';
|
|
18
18
|
import { design, KINDS } from './design.js';
|
|
19
19
|
import { checkMedia } from './media.js';
|
|
@@ -31,6 +31,7 @@ const journeyExample = 'journeys: { smoke: { command: "<drive the core user jour
|
|
|
31
31
|
const JOURNEYS_TEXT = `journeys (.done-done.yaml): the command runs with DONE_DONE_EVIDENCE_ROOT (this run's own new folder in the evidence
|
|
32
32
|
root) and DONE_DONE_RUN_ID set, and \${RUN_ID} in it is replaced; it must write its files into that folder, e.g.
|
|
33
33
|
${journeyExample}
|
|
34
|
+
or describe it in plain words and your agent writes the script: done-done journey add "<what a user does and sees>" (done-done journey --help has an example of add, enhance and improve)
|
|
34
35
|
real product: a journey whose command only runs tests, typecheck, lint or a build (npm test, yarn run check, tsc, pytest, or a test or guard
|
|
35
36
|
script such as bash tests/x.test.sh or node scripts/floor-guard.mjs) is code-only;
|
|
36
37
|
init --journey refuses it, and check, receipt and gate refuse proof that comes only from code-only runs or a config of only code-only journeys
|
|
@@ -106,6 +107,7 @@ const CONFIG_TEXT = `config: ~/.done-done/config.yaml (global; DONE_DONE_HOME mo
|
|
|
106
107
|
agent (either file): claude, codex, opencode, pi, gemini or acp:<target>, set by the user (done-done init --agent); done-done never picks one.
|
|
107
108
|
global only: agent_config.<agent>.{model,effort} (effort: minimal, low, medium, high, xhigh, max), agent_args_override.<agent>
|
|
108
109
|
(extra flags), agent_path_override.<agent> (binary), acpx_path, agent_timeout (one agent call, default 30m; a streaming agent that prints nothing for 5m is stopped early),
|
|
110
|
+
judge.account_command (optional shell command printing a Claude account pick; exported CLAUDE_CONFIG_DIR wins; failure stops the call),
|
|
109
111
|
evidence.local_root, judge.{route,model,floor} (the judge and shared confidence floor, set by the user with
|
|
110
112
|
init --judge; never picked). done-done doctor shows where each agent setting came from.`;
|
|
111
113
|
// One table drives `--help` and every `<command> --help`: each command's usage lines and the flags it takes.
|
|
@@ -145,6 +147,8 @@ const FLAGS = {
|
|
|
145
147
|
'--model <model>': 'with --agent: that agent\'s model, saved as agent_config.<agent>.model (opencode: provider/model)',
|
|
146
148
|
'--judge <agent>': `the judge done-done starts (${JUDGES.join(' or ')}), saved as judge.route in ~/.done-done/config.yaml (any agent and model, the author's own too); without it, init asks in a terminal`,
|
|
147
149
|
'--judge-model <model>': 'with --judge <agent>: the judge\'s model, saved as judge.model',
|
|
150
|
+
'--before': 'record HEAD at PreToolUse; without it consume the matching PostToolUse event and capture the commit',
|
|
151
|
+
'--after-commit': 'with --hook claude, opt in to Bash lifecycle hooks that rerun affected journeys after a commit and propose missing skill coverage (no agent or judge started)',
|
|
148
152
|
'--hook <agent>': 'also add a SessionStart hook to .claude/settings.json (claude), .codex/hooks.json (codex) or .gemini/settings.json (gemini) that shows `done-done` to each session, or a plugin to .opencode/plugins (opencode) or an extension to .pi/extensions (pi) that adds it to the first message of each session (opt-in)',
|
|
149
153
|
};
|
|
150
154
|
// `more` is the reference text a command needs beyond its flags, shared with the top-level help.
|
|
@@ -156,6 +160,7 @@ const COMMANDS = {
|
|
|
156
160
|
['done-done init --agent opencode --model opencode-go/muse-spark-1.3-contributor', 'set the coding agent done-done starts and its model (in a terminal, init asks when none is set)'],
|
|
157
161
|
['done-done init --judge pi --judge-model openai-codex/gpt-6-luna', 'set the judge done-done starts and its model (in a terminal, init asks when none is set)'],
|
|
158
162
|
['done-done init --hook claude', 'also show this repo\'s done-done state at each Claude Code session start'],
|
|
163
|
+
['done-done init --hook claude --after-commit', 'also opt in to rerun affected journeys after a Bash commit; propose missing coverage without editing the skill'],
|
|
159
164
|
['done-done init --hook codex', 'also show this repo\'s done-done state at each Codex session start'],
|
|
160
165
|
['done-done init --hook opencode', 'also add this repo\'s done-done state to the first message of each opencode session'],
|
|
161
166
|
['done-done init --hook pi', 'also add this repo\'s done-done state to the first prompt of each pi session'],
|
|
@@ -164,7 +169,7 @@ const COMMANDS = {
|
|
|
164
169
|
['done-done init --journey <name> --core -- <command...>', 'record the core user journey: the one thing a user cannot live without, end to end on the real target'],
|
|
165
170
|
['done-done init --journey upgrade --upgrade -- <command...>', 'record the release path: install the previous shipped version, upgrade over it in place, drive the core journey'],
|
|
166
171
|
],
|
|
167
|
-
flags: ['--agent <agent>', '--model <model>', '--judge <agent>', '--judge-model <model>', '--hook <agent>', '--journey <name>', '--core', '--upgrade', '-- <command...>'],
|
|
172
|
+
flags: ['--agent <agent>', '--model <model>', '--judge <agent>', '--judge-model <model>', '--hook <agent>', '--after-commit', '--journey <name>', '--core', '--upgrade', '-- <command...>'],
|
|
168
173
|
more: [JOURNEYS_TEXT, CONFIG_TEXT],
|
|
169
174
|
},
|
|
170
175
|
doctor: {
|
|
@@ -211,6 +216,19 @@ const COMMANDS = {
|
|
|
211
216
|
flags: ['--outcome "<what a user can now do>"', '--feature <name>', '--claim-id <id>', '--wait', '--no-wait', '--foreground'],
|
|
212
217
|
more: [RUNS_TEXT],
|
|
213
218
|
},
|
|
219
|
+
journey: {
|
|
220
|
+
usage: [['done-done journey add "<description>"', 'use your configured agent to add a capture script, journey config and verify feature, print a diff summary, then run it once'], ['done-done journey enhance <name> ["<focus>"]', 'deepen one existing journey, preserve its checks and settings, print a diff summary, then run it once'], ['done-done journey improve <name> "<instruction>"', 'apply one specific instruction to an existing journey, print a diff summary, then run it once']],
|
|
221
|
+
flags: [],
|
|
222
|
+
more: [`examples (a greeting CLI; add names the journey from your words and prints that name):
|
|
223
|
+
done-done journey add "greet Umer and check that Hello Umer is printed"
|
|
224
|
+
writes journeys/greet-umer-prints-hello.mjs, its .done-done.yaml entry and a verify skill feature
|
|
225
|
+
done-done journey enhance greet-umer-prints-hello "add checks for an empty name and an unknown command"
|
|
226
|
+
keeps every existing check and adds the edge cases (leave out the focus for a general pass)
|
|
227
|
+
done-done journey improve greet-umer-prints-hello "also check that greeting Zoë prints exactly Hello Zoë"
|
|
228
|
+
makes only that one change
|
|
229
|
+
each prints the files it changed and runs the journey once as a dirty-tree trial; review the script like any code, commit it, then
|
|
230
|
+
done-done run greet-umer-prints-hello records clean proof with no agent or model (CI can replay it)`, 'Run init first. Generation runs locally, never in CI, and uses only the agent you configured; it never chooses one. A journey that spends is saved but not run: use verify for approval.'],
|
|
231
|
+
},
|
|
214
232
|
maintain: {
|
|
215
233
|
usage: [['done-done maintain [--no-wait | --wait | --foreground]', 'check the verify skill against the source in a run: links, index, cited file:line, one read-only reader per feature; with auto_fix.maintain, the agent fixes the skill on a done-done branch']],
|
|
216
234
|
flags: ['--wait', '--no-wait', '--foreground'],
|
|
@@ -257,6 +275,11 @@ const COMMANDS = {
|
|
|
257
275
|
checks.generated_paths: ["gen/**", "*.snap"] adds your own globs to the generated files above; the default is none.
|
|
258
276
|
Every run is appended to lint.log next to the store. Exit 0 PASS or WARN, 1 FAIL, 2 usage error. Needs gh for a PR URL`],
|
|
259
277
|
},
|
|
278
|
+
'after-commit': {
|
|
279
|
+
usage: [['done-done after-commit [--before]', 'Claude Bash lifecycle hook; reads its event on stdin and reruns affected journey commands after a commit']],
|
|
280
|
+
flags: ['--before'],
|
|
281
|
+
more: ['Install only by opting in: done-done init --hook claude --after-commit. No agent or judge is selected or started; uncovered files produce a proposal outside the skill.'],
|
|
282
|
+
},
|
|
260
283
|
affected: {
|
|
261
284
|
usage: [['done-done affected [<base>]', "the journeys a change needs, from the verify skill's feature map and the paths it cites (the core journey when unsure); exit 1 until a receipt for HEAD covers them"]],
|
|
262
285
|
flags: [],
|
|
@@ -953,7 +976,7 @@ async function runCheck(argv, run) {
|
|
|
953
976
|
findings.push({ level: 'inconclusive', why: `the outcome names ${part}, but no passing capture shows it: capture ${part} and cite that capture` });
|
|
954
977
|
const rows = cap(j.judged);
|
|
955
978
|
const { choice } = j;
|
|
956
|
-
const judged = { ...(choice && { route: choice.route, model: choice.model, independence: choice.independence, author: choice.author.agent ? `${choice.author.agent} (model ${choice.author.model || 'none'})` : 'no agent set' }), judged: rows.items.map(({ sha256, ...r }) => r) };
|
|
979
|
+
const judged = { ...(choice && { route: choice.route, model: choice.model, independence: choice.independence, author: choice.author.agent ? `${choice.author.agent} (model ${choice.author.model || 'none'})` : 'no agent set' }), ...(j.detail && { detail: j.detail }), judged: rows.items.map(({ sha256, ...r }) => r) };
|
|
957
980
|
if (choice) {
|
|
958
981
|
const byHash = new Map(captures.map((c) => [c.sha256, receiptPath(repo, cfg, cite(c.name))]));
|
|
959
982
|
judgeBlock = { route: choice.route, model: choice.model, independence: choice.independence, author: choice.author, verdicts: j.judged.map((r) => ({ path: byHash.get(r.sha256), sha256: r.sha256, verdict: r.verdict, ...(r.seen && { seen: r.seen }) })) };
|
|
@@ -1245,7 +1268,7 @@ const STALE_MS = 60_000;
|
|
|
1245
1268
|
const runsRoot = (app) => join(home(), 'runs', app);
|
|
1246
1269
|
const verifySteps = (cfg) => ['resolve', 'drive', 'check', 'judge', ...(cfg.auto_fix.verify ? ['triage', 'fix'] : []), 'gate', 'receipt', 'report'];
|
|
1247
1270
|
// Commands an agent drive must not run: they answer a gate, write a receipt or start a run that can write one.
|
|
1248
|
-
const DRIVE_REFUSED = ['respond', 'receipt', 'verify', 'rerun', 'maintain'];
|
|
1271
|
+
const DRIVE_REFUSED = ['respond', 'receipt', 'verify', 'rerun', 'maintain', 'after-commit', 'journey'];
|
|
1249
1272
|
// One agent drive may launch the app, drive it and save captures; past this it is killed with everything it started.
|
|
1250
1273
|
const DRIVE_SCHEMA = {
|
|
1251
1274
|
type: 'object',
|
|
@@ -2986,6 +3009,142 @@ function verifyFeatureMd(app, s, f) {
|
|
|
2986
3009
|
const drive = clause(f.drive);
|
|
2987
3010
|
return `# ${f.title}\n\nPart of the ${app} verification map. Source: \`${cited(f.source)}\`.\n\n## How to get to it (user POV)\n\n${clause(f.reach)}.\n\n## Driving it\n\nHarness: ${clause(s.harness)}.\n\n1. Launch the app as SKILL.md says and wait until it is ready.\n2. ${drive[0].toUpperCase()}${drive.slice(1)}.\n3. Capture: ${capture}, as SKILL.md's Evidence section says.\n4. Stop what you started.\n\n${NO_EDIT}.\n`;
|
|
2988
3011
|
}
|
|
3012
|
+
// Generation is read-only, like init. Only plain code writes the returned recipe; subsequent runs need no agent.
|
|
3013
|
+
async function cmdJourney(argv) {
|
|
3014
|
+
const { positionals } = parseArgs({ args: argv, allowPositionals: true });
|
|
3015
|
+
const action = positionals[0], improve = action === 'improve', named = action === 'enhance' || improve;
|
|
3016
|
+
if ((!named && (action !== 'add' || positionals.length !== 2)) || (named && ![2, 3].includes(positionals.length)) || !positionals[1]?.trim() || positionals[2] === '' || (improve && !positionals[2]?.trim()))
|
|
3017
|
+
throw new UsageError('Use: done-done journey add "<description>" | done-done journey enhance <name> ["<focus>"] | done-done journey improve <name> "<instruction>"');
|
|
3018
|
+
const request = named ? positionals[2]?.trim() || 'Add steps, edge cases, boundary sizes and states, and stronger checks on this path.' : positionals[1];
|
|
3019
|
+
const label = improve ? 'Improved' : 'Enhanced';
|
|
3020
|
+
if (process.env.CI)
|
|
3021
|
+
throw new UsageError(`journey ${action} uses an agent to generate repo changes; run it locally, review and commit them, then run the saved journey in CI`);
|
|
3022
|
+
const repo = repoRoot(), cfg = loadConfig(repo);
|
|
3023
|
+
const config = join(repo, '.done-done.yaml'), index = join(repo, cfg.skill, 'features/README.md');
|
|
3024
|
+
if (![config, join(repo, cfg.skill, 'SKILL.md'), index].every(existsSync))
|
|
3025
|
+
throw new UsageError(`journey ${action} needs the project config and verify feature map`, 'Run: done-done init');
|
|
3026
|
+
const inside = under(repo, index);
|
|
3027
|
+
if (!inside || cfg.skill.split(/[\\/]/).includes('..'))
|
|
3028
|
+
throw new UsageError(`journey ${action} needs a verify skill inside the repo`);
|
|
3029
|
+
const safePath = (p) => { if (symlinkOnPath(repo, p))
|
|
3030
|
+
throw new UsageError(`${shown(repo, p)} is reached through a symlink; nothing written`); };
|
|
3031
|
+
for (const p of [config, index, join(repo, cfg.skill, 'SKILL.md'), join(repo, 'journeys')])
|
|
3032
|
+
safePath(p);
|
|
3033
|
+
const name = named ? positionals[1] : '';
|
|
3034
|
+
if (named && !Object.hasOwn(cfg.journeys, name))
|
|
3035
|
+
throw new UsageError(`unknown journey ${name}; nothing written`);
|
|
3036
|
+
if (named && !/^[a-z0-9][a-z0-9-]{0,59}$/.test(name))
|
|
3037
|
+
throw new UsageError(`${action} needs a lowercase kebab-case journey name`);
|
|
3038
|
+
const priorScript = join(repo, 'journeys', `${name}.mjs`), priorFeature = join(dirname(index), `${name}.md`);
|
|
3039
|
+
if (named) {
|
|
3040
|
+
for (const p of [priorScript, priorFeature])
|
|
3041
|
+
safePath(p);
|
|
3042
|
+
if (existsSync(priorScript) && (cfg.journeys[name].command !== `node journeys/${name}.mjs` || Object.entries(cfg.journeys).some(([n, j]) => n !== name && j.command.includes(`journeys/${name}.mjs`))))
|
|
3043
|
+
throw new UsageError(`${shown(repo, priorScript)} is not exclusive to this journey; nothing written`);
|
|
3044
|
+
if (existsSync(priorFeature) && !readFileSync(priorFeature, 'utf8').includes(`done-done run ${name}`))
|
|
3045
|
+
throw new UsageError(`${shown(repo, priorFeature)} does not name this journey; nothing written`);
|
|
3046
|
+
}
|
|
3047
|
+
const agent = selectAgent(cfg), toolless = agent.agent !== 'claude';
|
|
3048
|
+
if (!agent.path)
|
|
3049
|
+
throw new UsageError(notStartable(agent.agent, agent.override));
|
|
3050
|
+
if (toolless && toollessReason(agent.agent))
|
|
3051
|
+
throw new UsageError(toollessReason(agent.agent), 'Use an agent supported by init: done-done init --agent <agent> --model <model>');
|
|
3052
|
+
const before = repoState(repo);
|
|
3053
|
+
const context = repoContext(repo, cfg.skill);
|
|
3054
|
+
const fields = ['name', 'title', 'source', 'script', 'checks', 'spends', 'blocker'];
|
|
3055
|
+
const schema = { type: 'object', properties: Object.fromEntries(fields.map(k => [k, STR])), required: fields, additionalProperties: false };
|
|
3056
|
+
const record = join(home(), 'agents', `journey-${action}-${randomBytes(8).toString('hex')}.json`);
|
|
3057
|
+
const goal = named ? `${improve ? 'Improve' : 'Enhance'} ONLY the existing journey ${JSON.stringify(name)}. Keep that exact name. ${improve ? 'Instruction' : 'Focus'}: ${JSON.stringify(request)}.
|
|
3058
|
+
Preserve every existing drive step and assertion; ${improve ? 'apply only this specific instruction, without unrelated expansion' : 'add concrete deeper coverage on this same path'}, not another journey. Respect existing spend restrictions, timeout, role and matrix settings. Return the COMPLETE replacement script, not a patch.
|
|
3059
|
+
Existing journey: ${JSON.stringify(cfg.journeys[name])}
|
|
3060
|
+
Existing script:\n${repoText(priorScript)}\nExisting feature:\n${repoText(priorFeature)}` : `Add ONE journey for this user request: ${JSON.stringify(request)}.`;
|
|
3061
|
+
const a = await askAgent({ ...agent, cwd: repo, tools: toolless ? 'none' : 'read', record, schema, prompt: `${goal}
|
|
3062
|
+
Read only; never edit files, run commands or drive the app during generation. Use only the real handles in the supplied repository source. Never open secret-looking files or include secret values. Return blocker with the real cause if this cannot be driven safely; leave blocker empty otherwise.
|
|
3063
|
+
Return name (lowercase kebab-case), title, source (one tracked non-secret file shown below), script (a complete Node ESM capture script), checks (the concrete assertions and expected user-visible result), spends (empty unless it uses a paid service or sends/posts externally), blocker. Empty spends/blocker means a zero-character JSON string, not a string containing quote characters. Generation cost itself is not a journey spend.
|
|
3064
|
+
Return script as source text, not JSON.stringify(source). After the JSON response is decoded, JavaScript statements must have actual newline separators, not literal backslash-n sequences. Do not double-escape the source.
|
|
3065
|
+
The script runs from the repo root as node journeys/<name>.mjs. Drive the REAL app the way a user does, not tests, typechecks, lint or builds. Use isolated temporary state and clean up only what the script started. Use Umer for demo data. No installed app state or real accounts. Reuse existing proof handles where possible.
|
|
3066
|
+
The saved script MUST be deterministic and self-contained: no agents, model calls, done-done verify, generation or Firstmate dependency at runtime. Use node:assert/strict to check actual output/side effects; a failed check must exit nonzero. Write captures of the actual resulting state into process.env.DONE_DONE_EVIDENCE_ROOT, never invented output. Write steps.json there (${STEPS_SHAPE}) from the actual drive. Honour DONE_DONE_BASE_URL and matrix env when configured; screenshot/page-check records for visual flows. Do not embed absolute checkout paths.
|
|
3067
|
+
Existing verify skill:\n${repoText(join(repo, cfg.skill, 'SKILL.md'))}\nFeature map:\n${repoText(index)}\n\n${context.text}` });
|
|
3068
|
+
if (repoState(repo) !== before)
|
|
3069
|
+
throw new UsageError('the repo changed during generation; no recipe was written; review the changes before retrying');
|
|
3070
|
+
if (!a.ok)
|
|
3071
|
+
throw new UsageError(`${agent.agent} did not generate a journey: ${a.error}; record ${a.record ?? record}`);
|
|
3072
|
+
const s = a.answer;
|
|
3073
|
+
for (const field of ['blocker', 'spends'])
|
|
3074
|
+
if (!/[\p{L}\p{N}]/u.test(s[field]))
|
|
3075
|
+
s[field] = '';
|
|
3076
|
+
if (s.blocker.trim())
|
|
3077
|
+
throw new UsageError(`journey ${action} blocked: ${s.blocker}`);
|
|
3078
|
+
if (!/^[a-z0-9][a-z0-9-]{0,59}$/.test(s.name) || s.name === 'readme')
|
|
3079
|
+
throw new UsageError('the agent returned an invalid journey name');
|
|
3080
|
+
if (named ? s.name !== name : Object.hasOwn(cfg.journeys, s.name))
|
|
3081
|
+
throw new UsageError(named ? 'the agent renamed the journey; nothing written' : `journey ${s.name} already exists; nothing written`);
|
|
3082
|
+
if (!s.title.trim() || !s.checks.trim() || !s.script.includes('DONE_DONE_EVIDENCE_ROOT'))
|
|
3083
|
+
throw new UsageError('the agent returned no title, checks or capture script; nothing written');
|
|
3084
|
+
if (absoluteInside(s.script, repo))
|
|
3085
|
+
throw new UsageError('the agent embedded an absolute checkout path; nothing written');
|
|
3086
|
+
if (!context.files.has(s.source) || SECRET.test(s.source) || (toolless && !context.read.has(s.source)))
|
|
3087
|
+
throw new UsageError(`the agent cited an unavailable source: ${s.source}; nothing written`);
|
|
3088
|
+
// Parse, never execute, before touching any project file.
|
|
3089
|
+
try {
|
|
3090
|
+
execFileSync(process.execPath, ['--check', '--input-type=module'], { input: s.script, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
3091
|
+
}
|
|
3092
|
+
catch (e) {
|
|
3093
|
+
throw new UsageError(`the agent returned invalid JavaScript; nothing written: ${String(e.stderr || e.message).trim()}`, `Review the generator answer: ${a.record ?? record}`);
|
|
3094
|
+
}
|
|
3095
|
+
const script = join(repo, 'journeys', `${s.name}.mjs`), feature = join(dirname(index), `${s.name}.md`);
|
|
3096
|
+
for (const p of [script, feature]) {
|
|
3097
|
+
safePath(p);
|
|
3098
|
+
if (!named && existsSync(p))
|
|
3099
|
+
throw new UsageError(`${shown(repo, p)} already exists; nothing written`);
|
|
3100
|
+
}
|
|
3101
|
+
const originalConfig = readFileSync(config, 'utf8'), originalIndex = readFileSync(index, 'utf8');
|
|
3102
|
+
const doc = parseDocument(originalConfig);
|
|
3103
|
+
if (doc.get('journeys') == null)
|
|
3104
|
+
doc.set('journeys', doc.createNode({}));
|
|
3105
|
+
if (!named)
|
|
3106
|
+
doc.setIn(['journeys', s.name], doc.createNode({}));
|
|
3107
|
+
doc.setIn(['journeys', s.name, 'command'], `node journeys/${s.name}.mjs`);
|
|
3108
|
+
if (s.spends.trim())
|
|
3109
|
+
doc.setIn(['journeys', s.name, 'spends'], [cfg.journeys[s.name]?.spends, s.spends.trim()].filter(Boolean).join('; '));
|
|
3110
|
+
if (named) {
|
|
3111
|
+
const command = doc.getIn(['journeys', s.name, 'command'], true);
|
|
3112
|
+
command.commentBefore = [command.commentBefore, ` ${label} ${improve ? 'instruction' : 'focus'}: ${JSON.stringify(request)}`].filter(Boolean).join('\n');
|
|
3113
|
+
}
|
|
3114
|
+
const nextConfig = doc.toString({ lineWidth: 0 });
|
|
3115
|
+
loadConfig(repo, nextConfig);
|
|
3116
|
+
const featureText = `# ${s.title}\n\nRequest: ${request}\n\nSource: \`${s.source}\`, \`journeys/${s.name}.mjs\`.\n\n## Drive and capture\n\nRun \`done-done run ${s.name}\`. The committed script drives the app, checks the result and writes captures and steps.json into $DONE_DONE_EVIDENCE_ROOT.\n\n## Checks\n\n${s.checks}\n`;
|
|
3117
|
+
const nextFeature = named && existsSync(feature) ? `${readFileSync(feature, 'utf8').trimEnd()}\n\n## ${label} coverage\n\n${improve ? 'Instruction' : 'Focus'}: ${request}\n\nSource: \`${s.source}\`.\n\n${s.checks}\n` : featureText;
|
|
3118
|
+
const nextIndex = named && originalIndex.includes(`](${s.name}.md)`) ? originalIndex : `${originalIndex.trimEnd()}\n- [${s.title}](${s.name}.md): run \`done-done run ${s.name}\`\n`;
|
|
3119
|
+
const files = new Map([[script, `${s.script.trim()}\n`], [feature, nextFeature], [index, nextIndex], [config, nextConfig]]);
|
|
3120
|
+
const originals = new Map([...files.keys()].map(p => [p, existsSync(p) ? readFileSync(p, 'utf8') : null]));
|
|
3121
|
+
const written = [];
|
|
3122
|
+
try {
|
|
3123
|
+
for (const [p, text] of files) {
|
|
3124
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
3125
|
+
const fd = openSync(p, originals.get(p) === null ? 'wx' : 'w');
|
|
3126
|
+
written.push(p);
|
|
3127
|
+
try {
|
|
3128
|
+
writeFileSync(fd, text);
|
|
3129
|
+
}
|
|
3130
|
+
finally {
|
|
3131
|
+
closeSync(fd);
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
catch (e) {
|
|
3136
|
+
for (const p of written.reverse()) {
|
|
3137
|
+
const original = originals.get(p);
|
|
3138
|
+
if (original !== null)
|
|
3139
|
+
writeFileSync(p, original);
|
|
3140
|
+
else
|
|
3141
|
+
rmSync(p);
|
|
3142
|
+
}
|
|
3143
|
+
throw e;
|
|
3144
|
+
}
|
|
3145
|
+
process.stderr.write(`${named ? label : 'Added'} journey ${s.name} through ${agent.agent}. Review these repo changes:\n${git(repo, 'diff', '--stat', '--', ...[...files.keys()].map(p => relative(repo, p)))}\n${[script, feature].filter(p => originals.get(p) === null).map(p => ` ${relative(repo, p)} | ${files.get(p).split('\n').length - 1} new lines`).join('\n')}\nThe first run is a dirty-tree trial, not clean proof. Review and commit, then: done-done run ${s.name}\n`);
|
|
3146
|
+
return cmdRun([s.name]);
|
|
3147
|
+
}
|
|
2989
3148
|
// An entry's role marks, the text doctor reads (src/proof.ts namedJourney).
|
|
2990
3149
|
const marks = (f) => `${f.core ? ` ${MARK.core}` : ''}${f.upgrade ? ` ${MARK.upgrade}` : ''}`;
|
|
2991
3150
|
function verifyFeaturesMd(app, features, omitted) {
|
|
@@ -3050,7 +3209,7 @@ async function scaffoldVerify(repo, cfg) {
|
|
|
3050
3209
|
// As in maintain, only claude reads the repo with its own tools; every other agent gets the files from plain code.
|
|
3051
3210
|
const toolless = agent.agent !== 'claude';
|
|
3052
3211
|
if (toolless && toollessReason(agent.agent))
|
|
3053
|
-
return { status: `not written: ${toollessReason(agent.agent)}; set the verify skill agent to claude, opencode, pi or acp with done-done init --agent <agent> --model <model>`, failed: true };
|
|
3212
|
+
return { status: `not written: ${toollessReason(agent.agent)}; set the verify skill agent to claude, codex, opencode, pi or acp with done-done init --agent <agent> --model <model>`, failed: true };
|
|
3054
3213
|
const context = repoContext(repo, cfg.skill);
|
|
3055
3214
|
// Every schema-valid answer cites tracked files, so with none there is nothing to ask for.
|
|
3056
3215
|
if (!context.files.size)
|
|
@@ -3141,12 +3300,14 @@ async function scaffoldVerify(repo, cfg) {
|
|
|
3141
3300
|
// Claude Code reads it from the repo's .claude/settings.json, Codex from the repo's .codex/hooks.json
|
|
3142
3301
|
// (project scope; Codex docs: <repo>/.codex/hooks.json, SessionStart, matcher omitted matches every start).
|
|
3143
3302
|
const HOOK_COMMAND = 'command -v done-done >/dev/null && done-done || true';
|
|
3303
|
+
const COMMIT_BEFORE = 'if command -v done-done >/dev/null; then done-done after-commit --before; fi';
|
|
3304
|
+
const COMMIT_AFTER = 'if command -v done-done >/dev/null; then done-done after-commit; fi';
|
|
3144
3305
|
// Gemini CLI reads a SessionStart hook from the repo's .gemini/settings.json (its hooks reference) but shows plain
|
|
3145
3306
|
// stdout only to the person, so the state goes to the model as JSON hookSpecificOutput.additionalContext.
|
|
3146
3307
|
// done-done runs on node, so node is on PATH whenever done-done is.
|
|
3147
3308
|
const GEMINI_HOOK = `command -v done-done >/dev/null && done-done 2>/dev/null | node -e 'process.stdin.setEncoding("utf8");let s="";process.stdin.on("data",(d)=>s+=d).on("end",()=>s.trim()&&process.stdout.write(JSON.stringify({hookSpecificOutput:{additionalContext:s.trim()}})))' || true`;
|
|
3148
3309
|
// A repo settings file with a SessionStart hook running `command` merged in, keeping every other key and hook; no text when it is already there.
|
|
3149
|
-
function sessionStartSettings(repo, rel, command, agent) {
|
|
3310
|
+
function sessionStartSettings(repo, rel, command, agent, afterCommit = false) {
|
|
3150
3311
|
const file = join(repo, rel);
|
|
3151
3312
|
const bad = (why) => new UsageError(`${rel} ${why}`, `Fix ${rel}, then re-run: done-done init --hook ${agent}`);
|
|
3152
3313
|
const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -3163,13 +3324,17 @@ function sessionStartSettings(repo, rel, command, agent) {
|
|
|
3163
3324
|
throw bad('is not a JSON object');
|
|
3164
3325
|
if (settings.hooks !== undefined && !isObject(settings.hooks))
|
|
3165
3326
|
throw bad('has "hooks" that is not an object');
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3327
|
+
let changed = false;
|
|
3328
|
+
for (const [event, cmd] of [['SessionStart', command], ...(afterCommit ? [['PreToolUse', COMMIT_BEFORE], ['PostToolUse', COMMIT_AFTER]] : [])]) {
|
|
3329
|
+
const hooks = settings.hooks?.[event] ?? [];
|
|
3330
|
+
if (!Array.isArray(hooks))
|
|
3331
|
+
throw bad(`has "hooks.${event}" that is not a list`);
|
|
3332
|
+
if (hooks.some((m) => Array.isArray(m?.hooks) && m.hooks.some((h) => h?.command === cmd)))
|
|
3333
|
+
continue;
|
|
3334
|
+
settings.hooks = { ...settings.hooks, [event]: [...hooks, { ...(event !== 'SessionStart' && { matcher: 'Bash' }), hooks: [{ type: 'command', command: cmd, ...(event !== 'SessionStart' && { timeout: 600 }) }] }] };
|
|
3335
|
+
changed = true;
|
|
3336
|
+
}
|
|
3337
|
+
return { file, ...(changed && { text: `${JSON.stringify(settings, null, 2)}\n` }) };
|
|
3173
3338
|
}
|
|
3174
3339
|
// A file init owns outright: written once, unchanged when identical, never overwritten when someone else's.
|
|
3175
3340
|
function ownedFile(repo, rel, text, agent) {
|
|
@@ -3448,13 +3613,15 @@ async function cmdInit(argv) {
|
|
|
3448
3613
|
const dash = argv.indexOf('--');
|
|
3449
3614
|
const { values } = parseArgs({
|
|
3450
3615
|
args: dash < 0 ? argv : argv.slice(0, dash),
|
|
3451
|
-
options: { agent: { type: 'string' }, model: { type: 'string' }, judge: { type: 'string' }, 'judge-model': { type: 'string' }, hook: { type: 'string' }, journey: { type: 'string' }, core: { type: 'boolean' }, upgrade: { type: 'boolean' } },
|
|
3616
|
+
options: { agent: { type: 'string' }, model: { type: 'string' }, judge: { type: 'string' }, 'judge-model': { type: 'string' }, hook: { type: 'string' }, 'after-commit': { type: 'boolean' }, journey: { type: 'string' }, core: { type: 'boolean' }, upgrade: { type: 'boolean' } },
|
|
3452
3617
|
allowPositionals: false,
|
|
3453
3618
|
});
|
|
3454
3619
|
const parts = dash < 0 ? [] : argv.slice(dash + 1);
|
|
3455
3620
|
const journey = values.journey ?? null;
|
|
3456
3621
|
if (values.hook !== undefined && !Object.hasOwn(HOOKS, values.hook))
|
|
3457
3622
|
throw new UsageError(`--hook ${values.hook} is not supported; the supported values are ${Object.keys(HOOKS).join(', ')}`, `Run: done-done init --hook <${Object.keys(HOOKS).join('|')}>`);
|
|
3623
|
+
if (values['after-commit'] && values.hook !== 'claude')
|
|
3624
|
+
throw new UsageError('--after-commit needs --hook claude', 'Run: done-done init --hook claude --after-commit');
|
|
3458
3625
|
if (values.model !== undefined && values.agent === undefined)
|
|
3459
3626
|
throw new UsageError('--model needs --agent', `Run: done-done init --agent <agent> --model ${values.model}`);
|
|
3460
3627
|
if (values.agent !== undefined)
|
|
@@ -3488,7 +3655,7 @@ async function cmdInit(argv) {
|
|
|
3488
3655
|
throw new UsageError(`verify skill ${cfg.skill} has a .. segment; use a path inside the repo`, 'Set skill to a repo-relative path in .done-done.yaml');
|
|
3489
3656
|
const renamed = renameLegacySkill(repo, cfg);
|
|
3490
3657
|
const spec = values.hook === undefined ? undefined : HOOKS[values.hook];
|
|
3491
|
-
const hook = spec?.make(repo); //
|
|
3658
|
+
const hook = values['after-commit'] ? sessionStartSettings(repo, '.claude/settings.json', HOOK_COMMAND, 'claude', true) : spec?.make(repo); // validated before any write
|
|
3492
3659
|
// The agent: --agent, else with none set the user's pick in a terminal (asked before any write); never done-done's.
|
|
3493
3660
|
const choice = values.agent !== undefined ? { agent: values.agent, model: values.model ?? '' } : !cfg.agent && interactive() ? await pick('agent', cfg) : undefined;
|
|
3494
3661
|
if (choice)
|
|
@@ -3830,10 +3997,7 @@ async function cmdDoctor(argv) {
|
|
|
3830
3997
|
}
|
|
3831
3998
|
if (values.probe && !agent)
|
|
3832
3999
|
checks.push(unknownRow('probe'));
|
|
3833
|
-
|
|
3834
|
-
if (values.probe && agent && toolless)
|
|
3835
|
-
checks.push({ check: 'probe', status: 'warning', detail: `${toolless}; ${agent.agent} is not probed, its edit steps run through verify`, fix: '' });
|
|
3836
|
-
else if (values.probe && agent) {
|
|
4000
|
+
if (values.probe && agent) {
|
|
3837
4001
|
const record = join(home(), 'agents', `probe-${new Date().toISOString().replace(/[:.]/g, '-')}-${agent.agent}.json`);
|
|
3838
4002
|
const probe = !agent.path ? { ok: false, error: notFound, fix: installFix }
|
|
3839
4003
|
: await askAgent({ ...agent, prompt: 'This is a connectivity probe from done-done doctor. Use no tools. Answer with ok set to true.', schema: PROBE_SCHEMA, cwd: repo ?? process.cwd(), tools: 'none', record, idleMs: PROBE_IDLE_MS });
|
|
@@ -4183,6 +4347,64 @@ function cmdAffected(argv) {
|
|
|
4183
4347
|
});
|
|
4184
4348
|
return fresh ? PASS : FAIL;
|
|
4185
4349
|
}
|
|
4350
|
+
// Claude's opt-in Bash lifecycle hook compares HEAD before/after the exact tool call, not shell command text.
|
|
4351
|
+
async function cmdAfterCommit(argv) {
|
|
4352
|
+
const { values } = parseArgs({ args: argv, options: { before: { type: 'boolean' } } });
|
|
4353
|
+
let event;
|
|
4354
|
+
try {
|
|
4355
|
+
event = JSON.parse(readFileSync(0, 'utf8'));
|
|
4356
|
+
}
|
|
4357
|
+
catch (e) {
|
|
4358
|
+
throw new UsageError(`after-commit needs a Claude hook JSON event: ${e.message}`);
|
|
4359
|
+
}
|
|
4360
|
+
if (event?.tool_name !== 'Bash')
|
|
4361
|
+
return PASS;
|
|
4362
|
+
if (typeof event.tool_use_id !== 'string' || !event.tool_use_id || typeof event.session_id !== 'string' || !event.session_id)
|
|
4363
|
+
throw new UsageError('after-commit needs session_id and tool_use_id');
|
|
4364
|
+
if (event.hook_event_name !== (values.before ? 'PreToolUse' : 'PostToolUse'))
|
|
4365
|
+
throw new UsageError('after-commit: hook event does not match --before');
|
|
4366
|
+
const repo = repoRoot(), cfg = loadConfig(repo);
|
|
4367
|
+
const state = join(home(), 'hooks', `${sha256(Buffer.from(JSON.stringify([repo, event.session_id, event.tool_use_id])))}.json`);
|
|
4368
|
+
const current = spawnSync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: repo, encoding: 'utf8' });
|
|
4369
|
+
if (values.before) {
|
|
4370
|
+
mkdirSync(dirname(state), { recursive: true });
|
|
4371
|
+
writeFileSync(state, JSON.stringify({ commit: current.status === 0 ? current.stdout.trim() : null }));
|
|
4372
|
+
return PASS;
|
|
4373
|
+
}
|
|
4374
|
+
if (!existsSync(state))
|
|
4375
|
+
return PASS; // no paired start, or this event was already handled
|
|
4376
|
+
const before = JSON.parse(readFileSync(state, 'utf8')).commit;
|
|
4377
|
+
rmSync(state);
|
|
4378
|
+
if (current.status !== 0)
|
|
4379
|
+
return PASS;
|
|
4380
|
+
const h = head(repo);
|
|
4381
|
+
if (before === h.commit || !/^commit(?:\s|:)/.test(git(repo, 'reflog', '-1', '--format=%gs')))
|
|
4382
|
+
return PASS;
|
|
4383
|
+
if (h.dirty)
|
|
4384
|
+
throw new UsageError('after-commit: tracked changes remain; commit them before capturing proof', 'Run the affected journeys from a clean commit: done-done affected');
|
|
4385
|
+
const changed = git(repo, ...(before ? ['diff', '--name-only', '--no-renames', '-z', before, 'HEAD'] : ['diff-tree', '--root', '--no-commit-id', '-r', '--name-only', '-z', 'HEAD'])).split('\0').filter((p) => p && !p.startsWith(`${GATE_DIR}/`) && !cfg.evidence.ignore_paths.some((g) => matchesGlob(p, g)));
|
|
4386
|
+
const m = mapDiff(repo, cfg, changed, git(repo, 'ls-files', '-z').split('\0').filter(Boolean));
|
|
4387
|
+
let proposal;
|
|
4388
|
+
if (m.unmapped.length) {
|
|
4389
|
+
proposal = join(cfg.evidence.root, '.done-done', 'proposals', `${h.commit}.md`);
|
|
4390
|
+
mkdirSync(dirname(proposal), { recursive: true });
|
|
4391
|
+
writeFileSync(proposal, journeyProposal(cfg.skill, h.commit, m.unmapped, m.journeys.map((j) => j.name)));
|
|
4392
|
+
}
|
|
4393
|
+
emit({ command: 'after-commit', head: h.commit, journeys: m.journeys.map((j) => j.name), features: m.features.map((f) => f.name), ...(proposal && { proposal: citable(repo, proposal) }), help: ['Proposal only: review and apply it yourself; no verify skill file was changed. Runs capture evidence, not a judged pass or receipt.'] });
|
|
4394
|
+
let code = PASS;
|
|
4395
|
+
for (const j of m.journeys) {
|
|
4396
|
+
if (head(repo).commit !== h.commit)
|
|
4397
|
+
throw new UsageError('after-commit: HEAD moved before the next journey; stopped');
|
|
4398
|
+
const result = await cmdRun([j.name]);
|
|
4399
|
+
if (result !== PASS)
|
|
4400
|
+
code = result;
|
|
4401
|
+
}
|
|
4402
|
+
if (m.features.length || (!m.journeys.length && m.fallback)) {
|
|
4403
|
+
emit({ command: 'after-commit', result: 'needs-recipe', help: ['No command can drive these features automatically. Record a real journey with done-done init --journey <name> -- <command>, or ask the user to choose an agent and outcome for done-done verify --feature <name>.'] });
|
|
4404
|
+
return INCONCLUSIVE;
|
|
4405
|
+
}
|
|
4406
|
+
return code;
|
|
4407
|
+
}
|
|
4186
4408
|
// Bare `done-done`: orient a new user or agent with the config state and the next step.
|
|
4187
4409
|
function cmdBare() {
|
|
4188
4410
|
let repo;
|
|
@@ -4256,6 +4478,8 @@ async function main(argv) {
|
|
|
4256
4478
|
throw new UsageError(`${cmd} refuses to run inside an agent drive (DONE_DONE_DRIVE is set), so the agent does not answer its own gate, write a receipt or start a run by accident; this guards against accidents and is not a sandbox`, 'Stop the drive and report what you captured; done-done answers the run and writes the receipt');
|
|
4257
4479
|
if (cmd === 'init')
|
|
4258
4480
|
return await cmdInit(rest);
|
|
4481
|
+
if (cmd === 'journey')
|
|
4482
|
+
return await cmdJourney(rest);
|
|
4259
4483
|
if (cmd === 'doctor')
|
|
4260
4484
|
return await cmdDoctor(rest);
|
|
4261
4485
|
if (cmd === 'run')
|
|
@@ -4278,6 +4502,8 @@ async function main(argv) {
|
|
|
4278
4502
|
return cmdGate(rest);
|
|
4279
4503
|
if (cmd === 'affected')
|
|
4280
4504
|
return cmdAffected(rest);
|
|
4505
|
+
if (cmd === 'after-commit')
|
|
4506
|
+
return await cmdAfterCommit(rest);
|
|
4281
4507
|
if (cmd === 'status')
|
|
4282
4508
|
return cmdStatus(rest);
|
|
4283
4509
|
if (cmd === 'attach')
|
|
@@ -4311,7 +4537,7 @@ async function main(argv) {
|
|
|
4311
4537
|
throw new UsageError(`unknown command ${cmd}`);
|
|
4312
4538
|
}
|
|
4313
4539
|
catch (e) {
|
|
4314
|
-
const msg = (e.stderr || e.message ||
|
|
4540
|
+
const msg = String(e.stderr || e.message || e).trim();
|
|
4315
4541
|
emit({ command: sub ?? 'done-done', error: msg, help: e instanceof UsageError && e.help ? [e.help] : errorHelp(msg, sub) });
|
|
4316
4542
|
return USAGE;
|
|
4317
4543
|
}
|
package/dist/config.js
CHANGED
|
@@ -15,7 +15,7 @@ export class UsageError extends Error {
|
|
|
15
15
|
// The coding agents a user can set as agent.
|
|
16
16
|
export const AGENTS = ['claude', 'codex', 'opencode', 'pi', 'gemini'];
|
|
17
17
|
// The agents whose CLI can read an attached image with every tool off, so they judge through the answerer route;
|
|
18
|
-
// claude judges through claude-code. An acp:<target> agent cannot be sent an image.
|
|
18
|
+
// claude judges through claude-code. An acp:<target> agent cannot be sent an image. Codex image judging is not implemented.
|
|
19
19
|
export const ANSWERER_JUDGES = ['opencode', 'pi', 'gemini'];
|
|
20
20
|
// BYOKit key routes: a judge on the user's own API key, read from this environment variable, with no agent CLI.
|
|
21
21
|
// openai:key needs judge.model; a route with a model here runs it when judge.model is empty (the free MiniMax route).
|
|
@@ -84,6 +84,7 @@ const KEYS = {
|
|
|
84
84
|
'judge.route': { scope: 'global', type: 'route' },
|
|
85
85
|
'judge.floor': { scope: 'global', type: 'number' },
|
|
86
86
|
'judge.env_file': { scope: 'global', type: 'string' },
|
|
87
|
+
'judge.account_command': { scope: 'global', type: 'string' },
|
|
87
88
|
};
|
|
88
89
|
const SECTIONS = new Set(['evidence', 'secrets', 'checks', 'judge', 'signoff']);
|
|
89
90
|
const MAPS = new Set(['auto_fix', 'agent_config', 'agent_args_override', 'agent_path_override']);
|
|
@@ -318,7 +319,7 @@ function defaultConfig(repo) {
|
|
|
318
319
|
secrets: { patterns: {}, allow: [] },
|
|
319
320
|
checks: { fix_title: '^(fix|bugfix|hotfix)\\b', upstream_catch_up: false, moved_from: false, generated_paths: [] },
|
|
320
321
|
signoff: { post: false, board: '' },
|
|
321
|
-
judge: { route: '', model: '', floor: 0.7, env_file: '' },
|
|
322
|
+
judge: { route: '', model: '', floor: 0.7, env_file: '', account_command: '' },
|
|
322
323
|
trusted: false,
|
|
323
324
|
from: {},
|
|
324
325
|
};
|
|
@@ -405,5 +406,7 @@ judge:
|
|
|
405
406
|
# A file of KEY=value lines (a provider API key) handed to the judge's agent, or to openai:key, for names your shell has not
|
|
406
407
|
# exported, so the judge works in any shell. The key stays in that file: done-done never copies or prints it.
|
|
407
408
|
# env_file: ~/.config/done-done/judge.env
|
|
409
|
+
# Optional shell command printing one account-pick JSON for Claude judge and agent calls; an exported CLAUDE_CONFIG_DIR wins.
|
|
410
|
+
# account_command: account-picker pick claude --json
|
|
408
411
|
`;
|
|
409
412
|
}
|
package/dist/judge.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { accessSync, constants, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { delimiter, isAbsolute, join } from 'node:path';
|
|
5
|
-
import { agentRun, askAgent, notStartable } from './agents.js';
|
|
4
|
+
import { basename, delimiter, isAbsolute, join } from 'node:path';
|
|
5
|
+
import { agentRun, askAgent, claudeAccount, notStartable } from './agents.js';
|
|
6
6
|
import { home, KEY_ROUTES, parseDuration, tilde, UsageError } from './config.js';
|
|
7
7
|
import { downscaleToPng } from './media.js';
|
|
8
8
|
const MAX_SIDE = 1280; // long edge sent to the model, in pixels
|
|
@@ -10,7 +10,7 @@ const MAX_CALLS = 10; // model requests per check; later captures are unsure
|
|
|
10
10
|
const MAX_TEXT = 64 * 1024; // bytes of a text capture sent to the model; a longer one is unsure, never cut
|
|
11
11
|
// The reading rules: the model names the element the outcome claims and quotes it before it decides, and a text capture
|
|
12
12
|
// is read entry by entry. The floor is not here: the user sets it (judge.floor), and judge() applies it.
|
|
13
|
-
export const VERSION =
|
|
13
|
+
export const VERSION = 13; // bump when QUESTION, the options, the cache key or the stored verdict change, so older cached verdicts are not reused
|
|
14
14
|
const OPTIONS = {
|
|
15
15
|
correct: 'The screenshot shows the screen and state the outcome describes.',
|
|
16
16
|
wrong: 'The screenshot shows a different screen, an error, a crash, a dialog or overlay (such as a keyboard) hiding the content, or a state that contradicts the outcome. Choose it only when the quote you give shows that.',
|
|
@@ -33,9 +33,8 @@ const TEXT_OPTIONS = {
|
|
|
33
33
|
unclear: 'The text cannot settle it: it is unreadable, or the outcome names nothing this output could show.',
|
|
34
34
|
};
|
|
35
35
|
// The coverage reading: an outcome that names several parts (widths, themes, frames) needs each one shown by a passing
|
|
36
|
-
// capture
|
|
37
|
-
|
|
38
|
-
const PARTS = 'If the outcome names several parts that each need their own capture (for example several widths, themes or frames, such as "at 390 and 1440 wide"), list each one in parts, in the order the outcome names them, as a short label naming just that part, such as "390 wide" or "dark theme"; list none when it names one part or none. In covers, list the position in parts (0 for the first, 1 for the next) of each part this capture itself shows.';
|
|
36
|
+
// capture. In a batch, capture_0 defines one shared list; every covers position refers to that list, not another label.
|
|
37
|
+
const PARTS = 'Inspect only this question\'s listed image IDs or text, never substitute another question\'s image even if it better matches the cited name. Use the cited capture name in the state to match this capture to the outcome\'s own named part, not another capture\'s part. A capture showing one part is not wrong for lacking the other parts; a visible contradiction of its own part is still wrong. If the outcome names several parts that each need their own capture (for example several widths, themes or frames, such as "at 390 and 1440 wide"), list each one in parts, in the order the outcome names them, as a short label naming just that part, such as "390 wide" or "dark theme"; list none when it names one part or none. In covers, list the position in parts (0 for the first, 1 for the next) of each part this capture itself shows.';
|
|
39
38
|
const TEXT_READ = 'Read every entry before judging: every width, every frame, every record, not only the first. Judge the capture on what it shows: a capture that shows the outcome for some of the widths or frames the outcome names is not wrong for lacking the others, since other captures in the check cover them. Write the rationale first: say how many entries you read, and quote the fields and values you relied on.';
|
|
40
39
|
const TEXT_QUESTION = {
|
|
41
40
|
kind: 'choice',
|
|
@@ -147,6 +146,7 @@ export async function backend(cfg, choice, record = () => '', questions = { rele
|
|
|
147
146
|
const claude = choice.agent === 'claude';
|
|
148
147
|
if (!bin)
|
|
149
148
|
throw new UsageError(`judge: ${notStartable(choice.agent, override)}`, claude ? (override ? 'Fix agent_path_override.claude in ~/.done-done/config.yaml, or install Claude Code' : 'Install Claude Code, or set agent_path_override.claude to its absolute path') : `Install ${choice.agent}, or ask the user for another judge and save it: done-done init --judge <agent> --judge-model <model>`);
|
|
149
|
+
const account = await claudeAccount(choice.agent, cfg.judge.account_command);
|
|
150
150
|
const { answerer, resolve } = await kit();
|
|
151
151
|
let lastError = '';
|
|
152
152
|
// The kit hides a failed call's message from the verdict, so the real cause is kept here for the result.
|
|
@@ -168,7 +168,7 @@ export async function backend(cfg, choice, record = () => '', questions = { rele
|
|
|
168
168
|
return { text: JSON.stringify(a.answer) };
|
|
169
169
|
},
|
|
170
170
|
}).ask;
|
|
171
|
-
return { ask, resolve, lastError: () => lastError, detail: claude ? `claude binary at ${bin} and the judge kit are present; it judges with your own claude sign-in` : `${choice.agent} at ${bin} and the judge kit are present; it judges with every tool off` };
|
|
171
|
+
return { ask, resolve, lastError: () => lastError, detail: account ? `Claude account ${account.id}` : claude ? `claude binary at ${bin} and the judge kit are present; it judges with your own claude sign-in` : `${choice.agent} at ${bin} and the judge kit are present; it judges with every tool off` };
|
|
172
172
|
}
|
|
173
173
|
// The parts list as the model gave it: labels trimmed and in its own order, so a cover position still names its label.
|
|
174
174
|
const labels = (x) => (Array.isArray(x) && x.every((s) => typeof s === 'string') ? x.map((s) => s.trim()) : undefined);
|
|
@@ -200,10 +200,17 @@ export function prepare(c) {
|
|
|
200
200
|
// with its cause, never a verdict: the command stops with no result and no receipt.
|
|
201
201
|
// An answer the kit cannot read (all-zero probabilities, an option left out) would abstain on every question as if the
|
|
202
202
|
// judge were unsure, so it is asked once more, and one that still cannot be read is an error too.
|
|
203
|
-
export async function askJudge(cfg, be, choice, state, questions, images) {
|
|
203
|
+
export async function askJudge(cfg, be, choice, state, questions, images, sharedParts = '') {
|
|
204
204
|
const again = 'Fix the cause above (done-done doctor checks the judge), then run this again';
|
|
205
205
|
let answers;
|
|
206
|
-
const resolved = (r) =>
|
|
206
|
+
const resolved = (r) => {
|
|
207
|
+
const answers = Object.fromEntries(Object.entries(questions).map(([k, q]) => [k, be.resolve(q, r[k])]));
|
|
208
|
+
// One answer defines the identities before coverage is validated or blank/repeated labels are collapsed.
|
|
209
|
+
if (sharedParts)
|
|
210
|
+
for (const answer of Object.values(answers))
|
|
211
|
+
Object.assign(answer, { parts: lists(answers[sharedParts]).parts });
|
|
212
|
+
return answers;
|
|
213
|
+
};
|
|
207
214
|
const malformed = (a) => Object.keys(questions).filter((k) => /^(malformed|no answer)/.test(a[k].reason ?? ''));
|
|
208
215
|
// The parts rule reads the lists the answer carries, so a reply that leaves one out has not answered: without them the
|
|
209
216
|
// rule would silently require no part.
|
|
@@ -231,9 +238,13 @@ export async function askJudge(cfg, be, choice, state, questions, images) {
|
|
|
231
238
|
throw new UsageError(`judge: the judge did not answer: no parts and covers lists for ${noParts.join(', ')}, so the check cannot see which parts it shows`, again);
|
|
232
239
|
return answers;
|
|
233
240
|
}
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
const
|
|
241
|
+
// All rows in a check use one list. Labels are display text, never cross-reply identities.
|
|
242
|
+
const missingParts = (judged) => {
|
|
243
|
+
const shown = new Set(judged.filter((r) => r.verdict === 'pass').flatMap((r) => r.covers ?? []));
|
|
244
|
+
return (judged[0]?.parts ?? []).filter((_, i) => !shown.has(i));
|
|
245
|
+
};
|
|
246
|
+
// Keep the cited basename beside the actual image bytes on every provider, not only in the JSON state.
|
|
247
|
+
const imageId = (id, c) => `${id}__${basename(c.name).replace(/\.[^.]*$/, '').replace(/[^\w.-]/g, '_')}`;
|
|
237
248
|
export const screen = (c) => c.media.type === 'png' || c.media.type === 'jpeg';
|
|
238
249
|
// The judge reads screens as images and text captures (logs, JSON, CLI output) as text; video and other files are not judged.
|
|
239
250
|
export const judgeable = (c) => screen(c) || c.media.type === 'text';
|
|
@@ -287,7 +298,7 @@ async function judgeBatch(cfg, choice, outcome, captures, records, dir) {
|
|
|
287
298
|
return { c, pair, row: { path: c.name, sha256: c.sha256, ...(pair && { before: pair.name }) } };
|
|
288
299
|
});
|
|
289
300
|
const { route, model } = choice;
|
|
290
|
-
const key = createHash('sha256').update(JSON.stringify([VERSION, captures.map((c) => [c.sha256, c.before?.sha256]), outcome, route, model, cfg.judge.floor])).digest('hex');
|
|
301
|
+
const key = createHash('sha256').update(JSON.stringify([VERSION, captures.map((c) => [c.name, c.sha256, c.before?.name, c.before?.sha256]), outcome, route, model, cfg.judge.floor])).digest('hex');
|
|
291
302
|
const hit = readCache(dir, key);
|
|
292
303
|
if (Array.isArray(hit?.judged) && hit.judged.length === rows.length && hit.judged.every((r) => ['pass', 'fail', 'unsure'].includes(r?.verdict) && typeof r.reason === 'string' && (r.parts === undefined || coverageOf(r))))
|
|
293
304
|
return { judged: [...hit.judged.map((r) => ({ ...r, cached: true })), ...suffix], notJudged, choice, missing: hit.missing ?? [] };
|
|
@@ -301,18 +312,18 @@ async function judgeBatch(cfg, choice, outcome, captures, records, dir) {
|
|
|
301
312
|
if (typeof input === 'string' || typeof before === 'string')
|
|
302
313
|
throw new UsageError(`judge: ${c.name} cannot be sent to the judge: ${input}`, 'Capture it again as the error says, then re-run this command');
|
|
303
314
|
const base = 'text' in input ? before ? TEXT_PAIR : TEXT_QUESTION : before ? PAIR : QUESTION;
|
|
304
|
-
const beforeId = `before_${i}
|
|
305
|
-
const captureId = `capture_${i}
|
|
315
|
+
const beforeId = pair && imageId(`before_${i}`, pair);
|
|
316
|
+
const captureId = imageId(`capture_${i}`, c);
|
|
306
317
|
if ('mime' in input)
|
|
307
318
|
images[captureId] = input;
|
|
308
|
-
if (before && 'mime' in before)
|
|
319
|
+
if (beforeId && before && 'mime' in before)
|
|
309
320
|
images[beforeId] = before;
|
|
310
|
-
state[id] = { ...(before && 'text' in before && { before: before.text }), ...(typeof input === 'object' && 'text' in input && { capture: input.text }) };
|
|
311
|
-
questions[id] = { ...base, images: 'text' in input ? [] : before && 'mime' in before ? [beforeId, captureId] : [captureId], instructions: `${base.instructions} ${PARTS}
|
|
321
|
+
state[id] = { name: basename(c.name), ...(pair && { before_name: basename(pair.name) }), ...(before && 'text' in before && { before: before.text }), ...(typeof input === 'object' && 'text' in input && { capture: input.text }) };
|
|
322
|
+
questions[id] = { ...base, images: 'text' in input ? [] : beforeId && before && 'mime' in before ? [beforeId, captureId] : [captureId], instructions: `${base.instructions} ${PARTS} Use one shared parts list for this entire request: define it in capture_0.parts, in outcome order. Every capture's covers indexes that shared list, never a separately worded or reordered list.`, floor: cfg.judge.floor, ...(route === 'openai:key' ? {} : { answerFields: FIELDS }) };
|
|
312
323
|
}
|
|
313
324
|
const call = join(records, `judge-${stamp}-1`);
|
|
314
325
|
const be = await backend(cfg, choice, (request) => `${call}-${request}.agent.json`, questions);
|
|
315
|
-
const answers = await askJudge(cfg, be, choice, state, questions, images);
|
|
326
|
+
const answers = await askJudge(cfg, be, choice, state, questions, images, 'capture_0');
|
|
316
327
|
const judged = rows.map(({ row, c }, i) => {
|
|
317
328
|
const a = answers[`capture_${i}`];
|
|
318
329
|
const shape = questions[`capture_${i}`];
|
|
@@ -320,28 +331,21 @@ async function judgeBatch(cfg, choice, outcome, captures, records, dir) {
|
|
|
320
331
|
const cov = coverageOf(a);
|
|
321
332
|
return { ...row, verdict: a.abstained ? 'unsure' : a.answer === 'correct' ? 'pass' : a.answer === 'wrong' ? 'fail' : 'unsure', reason: a.abstained ? `not confident enough (${a.reason})` : reason, ...(a.rationale && { seen: a.rationale }), ...(route !== 'openai:key' && cov?.parts.length ? cov : {}), cached: false };
|
|
322
333
|
});
|
|
323
|
-
const
|
|
324
|
-
const shown = new Set();
|
|
325
|
-
for (const r of judged) {
|
|
326
|
-
for (const p of r.parts ?? [])
|
|
327
|
-
if (norm(p))
|
|
328
|
-
named.set(norm(p), named.get(norm(p)) ?? p.trim());
|
|
329
|
-
if (r.verdict === 'pass')
|
|
330
|
-
for (const i of r.covers ?? [])
|
|
331
|
-
shown.add(norm(r.parts?.[i] ?? ''));
|
|
332
|
-
}
|
|
333
|
-
const missing = [...named].filter(([n]) => !shown.has(n)).map(([, p]) => p);
|
|
334
|
+
const missing = missingParts(judged);
|
|
334
335
|
writeCache(dir, key, { judged, missing, route, model });
|
|
335
336
|
return { judged: [...judged, ...suffix], notJudged, choice, missing };
|
|
336
337
|
}
|
|
337
338
|
async function judgeOn(cfg, choose, outcome, captures, records) {
|
|
339
|
+
// Check the account even on a verdict cache hit: a broken configured picker must never produce a pass.
|
|
340
|
+
const account = await claudeAccount(choose().agent, cfg.judge.account_command);
|
|
341
|
+
const detail = account ? { detail: `Claude account ${account.id}` } : {};
|
|
338
342
|
const dir = join(home(), 'runs', cfg.app, 'judge');
|
|
339
343
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
340
344
|
const judged = [];
|
|
341
345
|
const notJudged = [];
|
|
342
346
|
const batchChoice = captures.filter(judgeable).length > 1 ? choose() : undefined;
|
|
343
347
|
if (batchChoice && batchChoice.route !== 'openai:key')
|
|
344
|
-
return judgeBatch(cfg, batchChoice, outcome, captures, records, dir);
|
|
348
|
+
return { ...await judgeBatch(cfg, batchChoice, outcome, captures, records, dir), ...detail };
|
|
345
349
|
let choice;
|
|
346
350
|
let be;
|
|
347
351
|
let calls = 0;
|
|
@@ -356,7 +360,7 @@ async function judgeOn(cfg, choose, outcome, captures, records) {
|
|
|
356
360
|
const pair = c.before && judgeable(c.before) && screen(c.before) === screen(c) ? c.before : undefined; // a screen pairs with a screen, text with text
|
|
357
361
|
const row = { path: c.name, sha256: c.sha256, ...(pair && { before: pair.name }) };
|
|
358
362
|
// The floor is in the key: it decides pass or inconclusive, so a changed judge.floor asks the model again.
|
|
359
|
-
const key = createHash('sha256').update(JSON.stringify([VERSION, c.sha256, outcome, route, model, cfg.judge.floor, ...(pair ? [pair.sha256] : [])])).digest('hex');
|
|
363
|
+
const key = createHash('sha256').update(JSON.stringify([VERSION, c.name, c.sha256, outcome, route, model, cfg.judge.floor, ...(pair ? [pair.name, pair.sha256] : [])])).digest('hex');
|
|
360
364
|
const hit = readCache(dir, key);
|
|
361
365
|
// A stored coverage pair goes back through the same resolver as a reply's, so an entry whose covers do not index its
|
|
362
366
|
// parts is unreadable, and asking the model again is the miss readCache promises.
|
|
@@ -380,12 +384,14 @@ async function judgeOn(cfg, choose, outcome, captures, records) {
|
|
|
380
384
|
const base = 'text' in input ? (before ? TEXT_PAIR : TEXT_QUESTION) : before ? PAIR : QUESTION;
|
|
381
385
|
// The parts rule asks the reply for the coverage lists as answer fields on routes that return them. A key route returns
|
|
382
386
|
// no lists, so it keeps requiring no part (docs/reference.md).
|
|
383
|
-
const
|
|
387
|
+
const captureId = imageId('capture', c);
|
|
388
|
+
const beforeId = pair && imageId('before', pair);
|
|
389
|
+
const shape = { ...base, images: 'text' in input ? [] : beforeId ? [beforeId, captureId] : [captureId], instructions: `${base.instructions} ${PARTS}`, floor: cfg.judge.floor, ...(Object.hasOwn(KEY_ROUTES, choice.route) ? {} : { answerFields: FIELDS }) };
|
|
384
390
|
// Every shape asks the same answer keys, so the first one sets the answer schema the whole check's backend sends.
|
|
385
391
|
be ??= await backend(cfg, choice, (request) => `${call}-${request}.agent.json`, { relevance: shape });
|
|
386
392
|
const answers = 'text' in input
|
|
387
|
-
? await askJudge(cfg, be, choice, { outcome, ...(before && 'text' in before && { before: before.text }), capture: input.text }, { relevance: shape }, {})
|
|
388
|
-
: await askJudge(cfg, be, choice, { outcome }, { relevance: shape }, before && 'mime' in before ? { before,
|
|
393
|
+
? await askJudge(cfg, be, choice, { outcome, name: basename(c.name), ...(pair && { before_name: basename(pair.name) }), ...(before && 'text' in before && { before: before.text }), capture: input.text }, { relevance: shape }, {})
|
|
394
|
+
: await askJudge(cfg, be, choice, { outcome, name: basename(c.name), ...(pair && { before_name: basename(pair.name) }) }, { relevance: shape }, beforeId && before && 'mime' in before ? { [beforeId]: before, [captureId]: input } : { [captureId]: input });
|
|
389
395
|
const a = answers.relevance;
|
|
390
396
|
const options = shape.options;
|
|
391
397
|
const reason = a.personReason || a.rationale || options[String(a.answer)] || '';
|
|
@@ -398,17 +404,6 @@ async function judgeOn(cfg, choose, outcome, captures, records) {
|
|
|
398
404
|
writeCache(dir, key, { verdict: verdict.verdict, reason: verdict.reason, ...seen, ...coverage, route, model });
|
|
399
405
|
judged.push(verdict);
|
|
400
406
|
}
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
const shown = new Set();
|
|
404
|
-
for (const r of judged) {
|
|
405
|
-
const parts = r.parts ?? [];
|
|
406
|
-
for (const p of parts)
|
|
407
|
-
if (norm(p))
|
|
408
|
-
named.set(norm(p), named.get(norm(p)) ?? p.trim());
|
|
409
|
-
if (r.verdict === 'pass')
|
|
410
|
-
for (const i of r.covers ?? [])
|
|
411
|
-
shown.add(norm(parts[i]));
|
|
412
|
-
}
|
|
413
|
-
return { judged, notJudged, choice, missing: [...named].filter(([n]) => !shown.has(n)).map(([, p]) => p) };
|
|
407
|
+
// Only openai:key can have several independent calls here; it returns no coverage fields.
|
|
408
|
+
return { judged, notJudged, choice, missing: missingParts(judged), ...detail };
|
|
414
409
|
}
|
package/dist/maintain.js
CHANGED
|
@@ -500,6 +500,11 @@ const tryText = (p) => {
|
|
|
500
500
|
// checks each attempt, and done-done makes the commit; the user's branch and checkout are never touched.
|
|
501
501
|
// Every path the fixer changed in the worktree since `base`: tracked edits (and commits) and new files; ignored ones unless `ignored` is false.
|
|
502
502
|
export const changedSince = (wt, base, ignored = true) => [...new Set([...git(wt, 'diff', '--name-only', '--no-renames', '-z', base).split('\0'), ...git(wt, 'ls-files', '--others', ...(ignored ? [] : ['--exclude-standard']), '-z').split('\0')])].filter(Boolean);
|
|
503
|
+
// A reviewable feature-map addition, not an invented drive recipe or an edit to the user's skill.
|
|
504
|
+
export function journeyProposal(skill, commit, files, journeys) {
|
|
505
|
+
const name = `change-${commit.slice(0, 12)}`;
|
|
506
|
+
return `# Missing journey proposal for ${commit}\n\nProposal only. Review and complete the recipe before applying it.\n\nAdd to ${skill}/features/README.md:\n- [Changed surface](${name}.md): cover the changed files below\n\nProposed ${skill}/features/${name}.md:\n\n# Changed surface\n\nSource: ${files.map((p) => JSON.stringify(p)).join(', ')}\n\n## Drive\n\n${journeys.length ? `Start from the existing fallback: ${journeys.map((j) => `done-done run ${j}`).join('; ')}.` : 'Record a core journey first: done-done init --journey <name> --core -- <command>.'}\nExtend that real-product recipe to exercise the changed surface, then name its journey here with run <name>. Do not treat the fallback alone as coverage of these files.\n\n## Capture\n\nSave the resulting user-visible output and steps.json into $DONE_DONE_EVIDENCE_ROOT. Review the evidence before recording a receipt.\n`;
|
|
507
|
+
}
|
|
503
508
|
export const named = (paths) => `${paths.slice(0, LEFT_NAMED).join(', ')}${paths.length > LEFT_NAMED ? `, +${paths.length - LEFT_NAMED} more` : ''}`;
|
|
504
509
|
export const dirtyTracked = (repo) => git(repo, 'diff', '--name-only', '--no-renames', '-z', 'HEAD').split('\0').filter(Boolean);
|
|
505
510
|
// A detached done-done worktree at `commit` for the body, removed after it whatever happens; a branch made from it stays.
|
package/docs/reference.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# done-done reference
|
|
2
2
|
|
|
3
|
-
Everything `init`, `doctor`, `run`, `affected`, `check`, `receipt` and the config files do. For the overview and quick start, see the [README](../README.md).
|
|
3
|
+
Everything `init`, `journey`, `doctor`, `run`, `affected`, `check`, `receipt` and the config files do. For the overview and quick start, see the [README](../README.md).
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -16,7 +16,7 @@ done-done init
|
|
|
16
16
|
|
|
17
17
|
`init` writes a minimal `.done-done.yaml` only when one does not exist: just `app`, the repo directory name, so every checkout of the repo finds the same evidence (see [Where proof lives](#where-proof-lives)). It sets no `evidence.root`, so evidence goes outside the repo. When an existing config puts `evidence.root` inside the repo (an older `root: .proof`), `init` adds it to `.gitignore` when git does not already ignore it. A plain `init` never rewrites the config: re-running is a no-op and a customized `.done-done.yaml` is left byte-for-byte; `.gitignore` gains a line only when the ignore entry is missing. A malformed `.done-done.yaml` makes `init` exit 2 with the file named and untouched. Its result says whether the config was `written` or left `unchanged`, the evidence root, what it added to `.gitignore`, the verify skill outcome (`verify_skill`), and the next step.
|
|
18
18
|
|
|
19
|
-
`init` also has the agent the user set write the verify skill at `skill` (default `.agents/skills/done-done-verify-<app>`) from the repo's own files: plain code gives it the tracked file list and the text of the files that show how the repo proves itself (manifests, `.done-done.yaml` journeys, verify, e2e, smoke and demo scripts, `bin/` files, Dockerfiles, then source files (including Vue, Svelte, HTML, CSS and common compiled-language sources); JSON, YAML and Markdown are excluded except for manifests; at most 150,000 characters, and the prompt names what it left out; a `.env` or `.env.*` file, a file or folder whose path contains `secret`, `credential`, `token` or `password`, and `*.pem`, `*.key`, `*.p12` files never have their text sent by `init`, the agent is told not to open them, and no launch or feature can cite them; the tracked file list still names them), and one read-only call answers with the launch, readiness check, harness, evidence and up to five features (claude reads the repo with its read tools; every other agent gets only that text, and
|
|
19
|
+
`init` also has the agent the user set write the verify skill at `skill` (default `.agents/skills/done-done-verify-<app>`) from the repo's own files: plain code gives it the tracked file list and the text of the files that show how the repo proves itself (manifests, `.done-done.yaml` journeys, verify, e2e, smoke and demo scripts, `bin/` files, Dockerfiles, then source files (including Vue, Svelte, HTML, CSS and common compiled-language sources); JSON, YAML and Markdown are excluded except for manifests; at most 150,000 characters, and the prompt names what it left out; a `.env` or `.env.*` file, a file or folder whose path contains `secret`, `credential`, `token` or `password`, and `*.pem`, `*.key`, `*.p12` files never have their text sent by `init`, the agent is told not to open them, and no launch or feature can cite them; the tracked file list still names them), and one read-only call answers with the launch, readiness check, harness, evidence and up to five features (claude reads the repo with its read tools; every other agent gets only that text, and gemini cannot run it tool-less). Launch prefers the repo's own safe proof path, a stub or demo engine with its own state, and when the repo shows none, `SKILL.md` opens Launch with `Not isolated:` and what the launch would touch, and `verify_skill` says so. `Safe:` is written only when the model marks the launch safe and its source is a tracked file that is not a manifest such as `package.json` or `README.md`; any other launch is `Not isolated:`, and its steps are not parsed. Each feature names the repo file it comes from. The prompt says a test suite, typecheck, lint or build is never a launch, a drive or the proof, asks for exactly one feature marked `core` (the core user journey) and, when a tracked file shows the app ships an installable artifact, one marked `upgrade` (see [Core journey and release path](#core-journey-and-release-path)), and plain code refuses an answer with no core feature, more than one, or no upgrade feature for such an app unless the agent explicitly reports `first_release: true` (the repo says no earlier release yet; false when unknown). That first-release answer writes the skill with `Upgrade journey pending` in `Release proof`, so `maintain` and `affected` can use its core feature map without inventing an earlier version; it does not mark an upgrade journey or satisfy doctor's release check; `SKILL.md` gains a `Core journey` section (and a `Release proof` section for such an app) and `features/README.md` marks those entries `(core journey)` and `(upgrade journey)`. The prompt also says and plain code refuses an answer whose launch steps or a feature's drive is code-only ([Real-product evidence](#real-product-evidence)), as it refuses an answer that cites a file the repo does not track, a secret-looking file, or, for a tool-less agent, a tracked file whose text its prompt left out. It writes `SKILL.md`, plus `features/README.md` and one file per feature with numbered drive and capture steps; `features/README.md` says how many more were found and not written. With no agent set, no skill is written and `init` asks for the agent (exit 2); an agent that fails or answers with an uncited or missing field writes no skill, and `verify_skill` names the cause and the call record (exit 1). On a repo with nothing tracked yet (a fresh `git init`), no skill is written and no agent is asked — no answer could cite a tracked file — and `verify_skill` names the remedy: commit the repo's files, then re-run (exit 1). The write is all-or-nothing: when any target already exists — `SKILL.md`, `features/README.md` or a `features/<name>.md`, as a file or a directory — nothing is written and `verify_skill` says `skipped: <paths> already exist; delete the skill folder to regenerate`, so a re-run after a lost `SKILL.md` can never pair new answers with the old feature map; a finished skill is left byte-identical and a re-run asks the agent nothing. A symlink on the path skips the scaffold and reports why in `verify_skill`, with exit 0. Otherwise the files are written into a temporary directory inside the skill's parent and moved into place, so a failed write leaves no partial skill: the temporary directory and any moved file are removed, `init` exits 1, and `verify_skill` names the error and says to re-run `init`. The config is kept either way. A `skill` path with a `..` segment is refused (exit 2) before anything is written. A repo whose older `init` wrote `.agents/skills/verify-<app>` (and sets no `skill`) keeps working: every command reads that folder, and the next `init` moves it once to `.agents/skills/done-done-verify-<app>` (also updating the `name:` line in its `SKILL.md`) and reports `skill_renamed: <old> -> <new>`; when the prefixed folder already exists, the old one is left alone. Commit `.done-done.yaml` and the verify skill before the first capture: a capture from a tree with uncommitted changes fails `check`, and `init` says so in its help.
|
|
20
20
|
|
|
21
21
|
```sh
|
|
22
22
|
done-done init --agent opencode --model opencode-go/muse-spark-1.3-contributor
|
|
@@ -38,6 +38,22 @@ done-done init --hook claude
|
|
|
38
38
|
|
|
39
39
|
`--hook claude` also adds a Claude Code SessionStart hook to the repo's `.claude/settings.json` (project scope; your `~/.claude` is never touched), so each session starts with the bare `done-done` state as context. The hook runs `command -v done-done >/dev/null && done-done || true`: without done-done on PATH it prints nothing and exits 0, so it never breaks a session. An existing settings file keeps every other key and hook; re-running adds no duplicate. The result's `claude_hook` says `added to .claude/settings.json` or `unchanged`. Any other `--hook` value exits 2 naming the supported values, and a malformed `.claude/settings.json` exits 2 untouched. Commit `.claude/settings.json` to share the hook.
|
|
40
40
|
|
|
41
|
+
### Opt-in capture after a commit (Claude Bash)
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
done-done init --hook claude --after-commit
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Plain `--hook claude` stays SessionStart-only. Adding `--after-commit` explicitly installs paired `PreToolUse` and `PostToolUse` command hooks matched to `Bash`, keeping all other settings and hooks; rerunning adds no duplicates. It is supported only with `--hook claude` (otherwise exit 2). Remove the two lifecycle entries to opt out; keep SessionStart if you still want orientation. No home agent settings or Git hooks are changed.
|
|
48
|
+
|
|
49
|
+
The hooks invoke `done-done after-commit --before` and `done-done after-commit`, reading Claude's JSON event on stdin. The start records HEAD under `$DONE_DONE_HOME/hooks`, keyed by this checkout, session and tool-call id. The finish consumes that snapshot once. A changed HEAD whose latest reflog entry is a commit (including amend and initial commits) triggers capture; no commit, an unpaired or duplicate finish, or a checkout alone runs nothing. This covers commits made by Bash in the current project checkout, not commits in other repos, other tools, external terminals or sessions without the opt-in. An interrupted Bash call with no finish cannot capture; run `done-done affected` and the named journeys manually. Git reflogs must be enabled. Hooks have a 600-second timeout; a timed-out capture is not proof.
|
|
50
|
+
|
|
51
|
+
From a clean tracked tree, the command maps the committed diff since that Bash call began using the same model-free mapping as `affected`: it runs each named journey with `done-done run`, including the core fallback for uncovered files (or every real journey when no core is named). Initial commits map all their files. Ignored paths and committed gate receipts need nothing. Each journey writes the usual sealed, commit-bound evidence record; capture is **not** a judged pass or receipt. No agent or model is chosen or started, and no judge is invoked, even if one is configured. Spend-gated journeys retain `run`'s refusal and recovery command. Failures report their real cause; a feature with no journey command reports `needs-recipe` (exit 3) instead of silently selecting an agent or inventing an outcome. Commit remaining tracked changes before capturing; a dirty finish is refused.
|
|
52
|
+
|
|
53
|
+
For files that no feature or journey covers, it writes `<evidence.root>/.done-done/proposals/<head>.md`. This proposal names the uncovered files, a suggested feature-map entry and feature file, and the core recipe to extend. The user reviews and completes the real-product drive before applying it to the verify skill; the hook never edits the skill or commits a proposal. Duplicate delivery does not capture again. Review the evidence and record a judged receipt separately with `done-done check` / `done-done receipt`.
|
|
54
|
+
|
|
55
|
+
Reproduce the full flow without any agent or judge: `done-done run after-commit`. Its fixture installs the actual lifecycle commands, commits a mapped source change and an uncovered file, checks clean sealed runs at both commits and a proposal-only skill addition, and rejects duplicate, no-commit and checkout triggers.
|
|
56
|
+
|
|
41
57
|
```sh
|
|
42
58
|
done-done init --hook codex
|
|
43
59
|
```
|
|
@@ -82,7 +98,51 @@ done-done doctor --probe
|
|
|
82
98
|
|
|
83
99
|
done-done starts the coding agent you choose, and never picks one. `agent:` names exactly one (`claude`, `codex`, `opencode`, `pi`, `gemini` or `acp:<target>`); there is no auto-detection and no fallback list, so an agent that is not installed or cannot start stops the command with the reason. While no agent is set, a command that starts it asks in a terminal (a numbered list of agents, then of models) and saves the answer to the global config; anywhere else (an agent calling done-done) it exits 2 with `no agent is set: ask the user which agent to use (claude, codex, opencode, pi, gemini, acp:<target>); never pick one for them` and the command that saves the answer: `done-done init --agent <agent> --model <model>`. `doctor` reports an unset agent as missing with that same fix. The model is `agent_config.<agent>.model` (global config only), else the agent's cheap default (`claude-haiku-5-5` for claude, `opencode-go/muse-spark-1.3-contributor` for opencode, `flash-lite` for gemini; claude and opencode also default to `effort: low`). That table is the only place done-done names a default model, and `init` writes it into the global config. `agent_config.<agent>.effort` (`minimal`, `low`, `medium`, `high`, `xhigh` or `max`, or `''` for the CLI's own) goes to the CLI's own flag as written: `--effort` (claude), `-c model_reasoning_effort="<level>"` (codex), `--variant` (opencode) and `--thinking` (pi); gemini and `acp:<target>` have none, so an effort for them is a config error. `agent_args_override.<agent>` adds extra CLI flags after done-done's own (do not repeat a flag done-done passes), `agent_path_override.<agent>` starts that binary instead of the one on PATH, `acpx_path` names the acpx bridge, and `agent_timeout` (default `30m`) bounds each agent call: a verify drive, a maintain reader, the judge and the doctor probe. A streaming agent (pi, opencode, codex, `acp:<target>`) that prints nothing, on stdout or stderr, for 5 minutes is stopped early (30 seconds for the doctor probe); the no-answer reason then quotes its stderr and the call record path, so a hung agent costs minutes, not the whole timeout. A rate limit the CLI prints (HTTP 429, a usage limit) ends any agent call at that line, with the limit and its reset time as the reason (for pi's JSON events, the provider's own message, not the event); the one exception is a claude judge call with images, which stops only at claude's own rejected rate-limit event, so its retries run. claude and gemini print one answer at the end, so only `agent_timeout` bounds them. These defaults are the cost guard for those agents: done-done never defaults to a flagship model for them. codex, pi and `acp:<target>` have no public cheap alias, so with no `agent_config.<agent>.model` (for `acp:<target>`, `agent_config.acp:<target>.model`) they run their own configured model, which can be a flagship; that setting caps their cost. `agent: acp:<target>` (for example `acp:gemini`) starts any Agent Client Protocol agent through [acpx](https://github.com/openclaw/acpx), which you install yourself (`npm install -g acpx`). doctor reports an `acp:` agent missing, with that install step, when acpx is not on PATH or does not start.
|
|
84
100
|
|
|
85
|
-
Each call is a fresh, single-shot session that must answer one JSON object matching a schema. The prompt goes on stdin (claude, codex, pi, gemini, acp) or into a 0600 prompt file (opencode), never in argv. done-done can start claude (`claude -p --output-format json --json-schema <schema> --model <model> --no-session-persistence`, all tools off with `--tools ''`; tool-less calls also pass `--setting-sources ''` and run from a scratch folder, so no project settings or hooks load; read-only calls allow only `--tools Read,Grep,Glob` and pass `--setting-sources ''` and `--strict-mcp-config`, so no user settings or MCP servers load), opencode (`opencode run "Follow the attached instructions." --format json -m <provider/model> -f <prompt file>`, with the schema in the 0600 prompt file and every tool denied through `OPENCODE_PERMISSION` except on an edit step ([Drive by agent](#drive-by-agent), Scope)), pi (`pi -p --mode json --no-session --no-tools`, tools off for every read or probe call, since pi has no read-only mode that refuses outside reads, and `--model <model>` only when one is set) and gemini (`gemini -p "Follow the instructions on stdin." -o json -m <model> --approval-mode plan|auto_edit`, where `plan` is read-only for the probe and read steps and `auto_edit` is used for edit steps; `--skip-trust` is added only to the tool-less probe in its scratch folder, so repo calls keep Gemini's workspace trust gate) and codex (`codex exec --json --ephemeral [-m <model>]`, the answer being the last agent message of its JSONL events; an edit step runs in codex's `--sandbox workspace-write` with the run's evidence and scratch folders added by `--add-dir` and network on, so the drive can launch the app and talk to it, while it keeps your own codex config).
|
|
101
|
+
Each call is a fresh, single-shot session that must answer one JSON object matching a schema. The prompt goes on stdin (claude, codex, pi, gemini, acp) or into a 0600 prompt file (opencode), never in argv. done-done can start claude (`claude -p --output-format json --json-schema <schema> --model <model> --no-session-persistence`, all tools off with `--tools ''`; tool-less calls also pass `--setting-sources ''` and run from a scratch folder, so no project settings or hooks load; read-only calls allow only `--tools Read,Grep,Glob` and pass `--setting-sources ''` and `--strict-mcp-config`, so no user settings or MCP servers load), opencode (`opencode run "Follow the attached instructions." --format json -m <provider/model> -f <prompt file>`, with the schema in the 0600 prompt file and every tool denied through `OPENCODE_PERMISSION` except on an edit step ([Drive by agent](#drive-by-agent), Scope)), pi (`pi -p --mode json --no-session --no-tools`, tools off for every read or probe call, since pi has no read-only mode that refuses outside reads, and `--model <model>` only when one is set) and gemini (`gemini -p "Follow the instructions on stdin." -o json -m <model> --approval-mode plan|auto_edit`, where `plan` is read-only for the probe and read steps and `auto_edit` is used for edit steps; `--skip-trust` is added only to the tool-less probe in its scratch folder, so repo calls keep Gemini's workspace trust gate) and codex (`codex exec --json --ephemeral [-m <model>]`, the answer being the last agent message of its JSONL events; an edit step runs in codex's `--sandbox workspace-write` with the run's evidence and scratch folders added by `--add-dir` and network on, so the drive can launch the app and talk to it, while it keeps your own codex config). Codex readers and probes run a separate `--no-daemon exec --ignore-user-config --ignore-rules --skip-git-repo-check --sandbox read-only` call, with project instructions disabled and your normal authentication untouched. Feature flags alone do not remove all tools: model metadata can force code-mode, collaboration and patch tools back on. done-done reads the CLI's public bundled catalog (`codex debug models --bundled`), writes a scratch-only copy with those capabilities removed, disables the remaining tool features and web search, and supplies it through `model_catalog_json`. A missing catalog, unsupported CLI or model not in that catalog fails with its cause; no unsafe fallback or copied login is used. `doctor --probe` now requires a real Codex answer, and `init` and maintain use the same clean reader. Codex image judging is not implemented, so it remains unavailable as a judge route. `acp:<target>` runs `acpx --cwd <dir> --format json --json-strict --deny-all --non-interactive-permissions deny [--model <model>] <target> exec --file -`, with the prompt and schema on stdin; every tool is denied. pi has no permission gate for edit steps, so doctor adds a `tool_gate` warning row whenever pi is the agent: every pi tool runs without approval. codex's sandbox covers its shell too, so doctor adds a `tool_gate` warning row whenever codex is the agent: a drive's recipe tool can write only where the drive may (see [Drive by agent](#drive-by-agent), Scope).
|
|
102
|
+
|
|
103
|
+
### Add a journey from a description
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
done-done journey add "sign in then post a card from the phone"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Run `init` first and commit the app's source so the generator can read it. `journey add` uses exactly your configured `agent`, model and agent settings, like init's verify scaffold; no agent is chosen for you. It supports the same read-only generators as init (claude, codex, opencode, pi and ACP; gemini reports its unsupported tool-less mode). No Firstmate installation is needed. Nothing generates or runs until you invoke the command. Generation refuses CI; run it locally instead.
|
|
110
|
+
|
|
111
|
+
The agent reads the repository context and existing verify skill and returns one static Node ESM capture script with assertions, expected results and a tracked source citation. A blocker or invalid answer writes nothing. A script syntax failure reports the native JavaScript error and generator call-record location instead of crashing while formatting stderr. Plain code checks the citation and script syntax, preserves existing config comments and feature entries, and adds:
|
|
112
|
+
|
|
113
|
+
- `journeys/<name>.mjs`: drive steps, checks, actual captures and `steps.json`;
|
|
114
|
+
- `.done-done.yaml`: `journeys.<name>.command: node journeys/<name>.mjs`;
|
|
115
|
+
- `<verify skill>/features/<name>.md` and its `features/README.md` entry: the request, source, drive and checks.
|
|
116
|
+
|
|
117
|
+
An existing journey or target file is never overwritten. The skill must be inside the repo; symlinked output paths are refused. Repo changes during generation stop the write. The command prints a diff summary to stderr and immediately runs the saved journey once; stdout is the usual `run` result (also with `--json`). A failing capture/check returns nonzero and leaves the generated changes available for review. A journey declaring `spends` is saved but **not run**: use `done-done verify <name> --outcome "<claim>"` and its approval gate.
|
|
118
|
+
|
|
119
|
+
The first execution is an uncommitted trial, not clean proof. Review the generated code as you would any executable code: generation is not a sandbox or a guarantee that an agent's recipe is correct or safe. Commit it, then run `done-done run <name>` again and check its captures. The saved script is instructed to use isolated state and deterministic checks, never agents or models at runtime; replay needs no configured agent. CI can execute that reviewed script without generation, a model or a key; use `gate` for committed receipt validation as described below. Use `journey enhance` to deepen an existing journey rather than adding another.
|
|
120
|
+
|
|
121
|
+
### Enhance one existing journey
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
done-done journey enhance greet-umer
|
|
125
|
+
done-done journey enhance greet-umer "focus on empty input, long names and error states"
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`enhance <name> ["<focus>"]` uses the same configured generator and safety checks as `add`: local invocation only, no model in CI, no agent selection and no Firstmate requirement. The name must be an existing lowercase kebab-case journey. Without a focus it asks for more steps, edge cases, boundary sizes, states and stronger checks on that path. It supplies the current command, script and feature to the agent and instructs it to retain existing steps and assertions. Review the resulting code: this instruction is not a semantic guarantee.
|
|
129
|
+
|
|
130
|
+
It writes a complete `journeys/<name>.mjs`, updates that journey's command with a reviewable focus comment in `.done-done.yaml`, and appends the focus and checks to its verify feature (creating the feature and map entry when absent). Other journeys, feature-map entries, existing feature text, role marks, timeout and spend restrictions are retained. An existing canonical script must belong exclusively to this journey; a colliding feature must already name `done-done run <name>`. Otherwise nothing is written. A renamed answer is refused, as are symlinks and invalid scripts or source citations.
|
|
131
|
+
|
|
132
|
+
The diff summary and immediate dirty-tree trial work exactly as for `add`; failed assertions return nonzero with changes left for review. A spend-gated journey is saved but not executed. Review and commit, then `done-done run <name>` records clean evidence with no generation or agent needed, including in CI. Use `journey improve` to apply one specific instruction rather than broadly deepening coverage.
|
|
133
|
+
|
|
134
|
+
### Improve one journey with an instruction
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
done-done journey improve greet-umer "also assert the exact Unicode greeting for Zoë"
|
|
138
|
+
done-done journey improve checkout "assert the toast text"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`improve <name> "<instruction>"` requires one existing lowercase kebab-case journey name and one nonblank plain instruction. It uses your configured agent and model, never chooses an agent, and refuses generation in CI. No Firstmate installation is needed; nothing generates or runs unless you invoke it.
|
|
142
|
+
|
|
143
|
+
Like `enhance`, it supplies the current journey, script and verify feature to the read-only generator. Unlike broad enhancement, it asks for only the specific requested change, retaining existing steps and assertions. It writes a complete static `journeys/<name>.mjs`, adds an `Improved instruction` comment to the journey command in `.done-done.yaml`, and appends `Improved coverage` with the instruction and checks to the verify feature. Existing feature text, sibling journeys, map entries and journey settings are retained. It shares `add`/`enhance`'s source, syntax, collision, symlink and generation-change checks; failures report their cause without writing a recipe.
|
|
144
|
+
|
|
145
|
+
It prints a diff summary and runs that saved journey once. A failed assertion returns nonzero and leaves the edits for review; an existing or newly declared spend restriction prevents the trial from running. The first run is dirty-tree evidence, not clean proof. Review the generated executable code: preservation and deterministic behavior are instructions to the generator, not semantic guarantees. Commit and replay with `done-done run <name>` for clean evidence; the saved recipe needs no agent or model in CI. This command targets one named journey, not a project-wide rewrite.
|
|
86
146
|
|
|
87
147
|
### Run a journey
|
|
88
148
|
|
|
@@ -411,7 +471,7 @@ done-done check --from pr-body.md # always judged
|
|
|
411
471
|
|
|
412
472
|
- **Bounded.** Captures longer than 1280 px on their long side are downscaled first to PNG (8-bit PNG or JPEG). One request covers all captures in a check (including base-ref pairs), except `openai:key`, which retains one request per capture and its 10-request cap; `agent_timeout` bounds each request. An answer that cannot be read (a question left out, or probabilities that do not sum to 1) is asked once more; one that still cannot be read is an error with its cause (no result) and is not cached.
|
|
413
473
|
- **Any judge the user sets runs.** The judge is a fresh session of its own, so it may be the author's own agent and model, and the fixer's too under `auto_fix`; done-done never refuses or warns about the user's choice. The author is the coding agent done-done starts (`agent:`), or `no agent set`. An `acp:<target>` author counts as the agent its target names (`acp:claude-code` is claude), and an author with no known model is never `model` apart. The result and doctor's `judge_choice` row name how far apart they are: `agent` (another agent), `model` (the same agent at another model) or `session-only` (the same agent and model, in a fresh session). `judge.allow_same` is gone: a config that still sets it is a config error (exit 2) that says to delete the line.
|
|
414
|
-
- **Cached and recorded.** Verdicts are kept in `$DONE_DONE_HOME/runs/<app>/judge/`, keyed by the full capture set
|
|
474
|
+
- **Cached and recorded.** Verdicts are kept in `$DONE_DONE_HOME/runs/<app>/judge/`, keyed by the full capture set's cited names and sha256 hashes (including each base capture's name and hash, for a pair), the outcome text, the route, the model and `judge.floor`, so a re-run asks no model. Each answerer call writes its agent's 0600 call record (what ran and what came back; never the prompt text) to the run's `agents/` folder under `verify`, else to `$DONE_DONE_HOME/agents/`; the `claude-code` route writes one too, since it runs your `claude` the same way.
|
|
415
475
|
- **Only runs on a passing check.** When the deterministic checks already fail, no capture is judged, and a judge pass never turns a failed check into a pass.
|
|
416
476
|
|
|
417
477
|
| Verdict | Result | Exit |
|
|
@@ -423,7 +483,9 @@ done-done check --from pr-body.md # always judged
|
|
|
423
483
|
|
|
424
484
|
Under `verify`, a `fail` or `unsure` verdict does not end the run: it parks it at its [gate](#the-gate-and-respond).
|
|
425
485
|
|
|
426
|
-
The result gains a `judge` block with the route, the model, the independence, the author and, for each judged file, its verdict, a short reason and whether it came from the cache, and, when the outcome names several parts, the parts it lists and the positions of the ones it shows (`parts`, `covers`). For the `claude-code` route, a missing `claude` binary or an `agent_path_override.claude` that is not an executable file, or a Node older than 22.18 is a config error (exit 2) whose `help` line is the command to fix it. A `claude` that is not signed in (checked only when a call fails) is exit 2 too, with `claude auth login` as the `help` line, the same sign-in you use in your shell. done-done does not check which plan that sign-in is on, so `claude` uses whatever sign-in your environment gives it. The kit cannot tell a bad `judge.model` from a used-up plan, so the error names both. For `openai:key`, an unset `OPENAI_API_KEY` is a config error (exit 2) too, and a refused key or a bad `judge.model` is an error with the HTTP status, naming both. The judge's confidence is the model's own estimate, not a calibrated score. `judge.floor` (global config, a number from 0 to 1, default `0.7`) is the least probability the judge's pick may carry and still count: a pick below it is `unsure` with `not confident enough (confidence <n> below floor <f>)`, and never a pass. It governs every judge: check and receipt relevance, maintain triage, and the design check. An outcome that names several parts (widths, themes or frames, such as "at 390 and 1440 wide") needs each one shown by a passing capture
|
|
486
|
+
The result gains a `judge` block with the route, the model, the independence, the author and, for each judged file, its verdict, a short reason and whether it came from the cache, and, when the outcome names several parts, the parts it lists and the positions of the ones it shows (`parts`, `covers`). For the `claude-code` route, a missing `claude` binary or an `agent_path_override.claude` that is not an executable file, or a Node older than 22.18 is a config error (exit 2) whose `help` line is the command to fix it. A `claude` that is not signed in (checked only when a call fails) is exit 2 too, with `claude auth login` as the `help` line, the same sign-in you use in your shell. done-done does not check which plan that sign-in is on, so `claude` uses whatever sign-in your environment gives it. The kit cannot tell a bad `judge.model` from a used-up plan, so the error names both. For `openai:key`, an unset `OPENAI_API_KEY` is a config error (exit 2) too, and a refused key or a bad `judge.model` is an error with the HTTP status, naming both. The judge's confidence is the model's own estimate, not a calibrated score. `judge.floor` (global config, a number from 0 to 1, default `0.7`) is the least probability the judge's pick may carry and still count: a pick below it is `unsure` with `not confident enough (confidence <n> below floor <f>)`, and never a pass. It governs every judge: check and receipt relevance, maintain triage, and the design check. An outcome that names several parts (widths, themes or frames, such as "at 390 and 1440 wide") needs each one shown by a passing capture. Each image attachment's identifier includes its cited basename beside the actual bytes, and each question's state retains the cited capture's basename in `name` (and `before_name` for a base pair), never its owner or repository directory. The instructions tell the judge to compare a named still or an explicitly extracted recording frame against that file's own claim part, not another screen's part. Recordings themselves are probed, not frame-extracted or sent to the relevance judge. Renamed citations are new judge inputs, even with identical bytes. In the single request, `capture_0.parts` defines one shared list in outcome order; every capture's `covers` indexes that list. Other answers cannot redefine identities by relabelling or reordering their own `parts`: labels are display text, never matched across calls. A shared position no passing capture shows makes the check inconclusive, naming that part. The lists are the judge kit's answer fields (`answerFields` on the question), so they arrive on the answer the kit resolves; the `openai:key` route is asked for no such field, so it still requires no part (even for a multi-part claim). A missing shared list, missing `covers`, or a position outside the shared list is asked once more, then is an error (exit 2). A request that cannot fit all captures stops with its real cause and no result; it is not split into independent lists or accepted as partial proof. Version-10 and earlier verdict caches are invalidated: unnamed inputs, old full-set missing-part results and independent per-capture labels are never reinterpreted as named shared identities. Other routes already ignore legacy per-capture cache entries when judging a multi-capture set. Drive the five-part regression and its missing-part, cache and request-refusal controls with `done-done run shared-parts`; it uses deterministic provider adapters, not a hosted model. Each request asks the judge to name the element the outcome claims and quote what it sees before it decides, and a text capture (JSON, a log) is read entry by entry, every width and frame, before the judge answers; the reason shown is the model's one-sentence person reason, else its rationale; each judged file also shows the model's rationale, the quote it read, under `seen` when the model gave one. The floor is part of the verdict cache key, so a changed floor asks the judge again.
|
|
487
|
+
|
|
488
|
+
**Remaining limitation:** names bind attachments and cached verdicts, but do not isolate the model's view of a batch. In an adversarial control with two filenames swapped, the configured judge still substituted the other attached image and falsely passed. This change does not guarantee image-to-claim pairing accuracy; image isolation requires a separate follow-up.
|
|
427
489
|
|
|
428
490
|
### Design check
|
|
429
491
|
|
|
@@ -577,7 +639,7 @@ done-done maintain # --no-wait, --wait (the default), or --foreground (fo
|
|
|
577
639
|
|
|
578
640
|
- `locate`: the verify skill (`skill`, default `.agents/skills/done-done-verify-<app>`) must have a `SKILL.md`; with none, the run ends `blocked` and points at `done-done init`.
|
|
579
641
|
- `hygiene` (plain code): every local markdown link in the skill must resolve (`dead-link`); `features/README.md` must exist (`index`), list each `features/*.md` file (`unlisted`) and list it only once (`duplicate`).
|
|
580
|
-
- `source-wave`: one fresh reader per feature file, at the configured agent (`agent`, `agent_config`). Every reader except claude's gets no tools (pi `--no-tools`, opencode and acp with every tool denied);
|
|
642
|
+
- `source-wave`: one fresh reader per feature file, at the configured agent (`agent`, `agent_config`). Every reader except claude's gets no tools (pi `--no-tools`, opencode and acp with every tool denied); gemini cannot read tool-less, so `maintain` never runs without a reader: an agent that cannot read tool-less, or that done-done cannot start, stops it before any run with exit 2 and the reason (use claude, codex, opencode, pi or acp). Where readers run, plain code puts the feature file and the text of each tracked file it cites into the prompt, each file capped at 20,000 characters (a cut file is marked `[truncated after 20000 characters]` in the prompt, with no finding of its own) and all of a feature's cited text at 60,000 characters, with one `truncated` notice naming the feature and the cited files left out when any are dropped. Only claude readers get read-only tools in the repo (`--tools Read,Grep,Glob` with `--setting-sources ''` and `--strict-mcp-config`, so no user settings or MCP servers load): a live probe showed opencode reads through an in-repo symlink to an outside target, while claude refuses it. A feature file names its source by path with or without a line (`public/index.html`, `src/app.js:12`; `init` writes names without lines), and each named tracked file is sent. A tool-less reader whose feature file names no tracked file is `coverage` (the run ends `blocked`): with no source it could only call the feature right, so a `clean` would mean nothing was read. `SKILL.md` is read the same way, after the features, when it names tracked source: its launch and readiness claims (a health route, a port variable) drift like a feature's. Statements about done-done itself (the `instance` block, `DONE_DONE_*` variables) are never drift. Each reader explains the feature from source and returns its drift (each statement the source contradicts, with the source file:line) and one live recipe. Readers run one after another; a run makes at most 30 recorded agent calls, each bounded by `agent_timeout`; a tool-less call that does not answer valid JSON is tried up to three times in all within its record, so a run starts at most 90 agent processes. A feature file longer than 20,000 characters is read only up to that cap, and a `truncated` notice names it; the notice is listed but does not make the result `drift`. A symlink on the skill path (the skill folder, its features folder, the index or a feature file) is a blocking `coverage` finding and is never read, so a committed link cannot send a file outside the repo to the agent; a skill reached through a symlinked ancestor is refused at `locate`. Readers cannot read outside the repo: claude runs without `--add-dir` or a permission bypass, so reads outside its working directory are refused.
|
|
581
643
|
- `reconcile`: the tool-less call names at most 500 recently changed paths, most recent first, and says how many older ones it left out (those get no `unmapped` check). Plain code checks each `path:line` the skill cites (`citation`) and each file:line a reader cites. A bare file name (`app.js:50`) is checked against the tracked files whose path ends with it, and the line must exist in one of them; no tracked match is a finding. A repo path (`src/app.js:50`) must be a tracked file too, so an untracked or ignored file is a citation finding in the skill and an unverified drift in a reader's answer. A bare name is a citation only when its extension is one that a tracked file has, so host:port text such as `api.example.com:8443` is ignored. A reader's drift cites files the same way, and its drift becomes a `drift` finding only when its citation exists, and unverified citations are listed under `unverified` in the run's status (and in the step log), with a count and the first few. An answer item in an unexpected shape, or a list field that is not a list, is listed there too and never trusted. One tool-less call merges the recipes into as few app states as it can and names files changed in the last 30 days (`git log`, outside the skill folder) that no feature covers; each one that is in that list becomes an `unmapped` finding. maintain gives no result without its readers: with no agent set it asks, as in [Agents](#agents). Gemini cannot be a reader yet: its no-tools mode is read-only plan mode, not tool-free, so `maintain` with gemini stops with exit 2 before any run.
|
|
582
644
|
|
|
583
645
|
The result is `clean` (no findings but notices, exit 0), `drift` (findings to fix by hand, or with `auto_fix`, exit 1), or `blocked` (no skill, a skill path that is a symlink or cannot be read, a reader that did not answer, more feature files than the call cap, or a repo that changed; exit 3). A blocked run still lists the findings from the plain checks it finished. `status` lists the findings (`--full` for all of them), each with an id, kind, file, line and what is wrong. Each agent call is recorded in `agents/<n>-<name>.json` in the run folder, without the prompt text; the readers' answers are in `logs/source-wave.log` and the merged recipes in `logs/reconcile.log`.
|
|
@@ -674,8 +736,11 @@ judge: # done-done's own judge; check, receipt, v
|
|
|
674
736
|
model: '' # default: that agent's agent_config model, or openai-codex/gpt-6-luna for pi
|
|
675
737
|
floor: 0.7 # every judge's pick must reach this probability to count; below it the verdict is unsure (inconclusive), never a pass
|
|
676
738
|
# env_file: ~/.config/done-done/judge.env # KEY=value lines (a provider API key) handed to the judge for names your shell has not exported; the key stays in that file
|
|
739
|
+
# account_command: account-picker pick claude --json # optional shell command for Claude judge and agent calls (verify/maintain too), once per process; exported CLAUDE_CONFIG_DIR wins
|
|
677
740
|
```
|
|
678
741
|
|
|
742
|
+
`judge.account_command` must print one JSON object: `{"ok":true,"id":"pool-1","set":{"CLAUDE_CONFIG_DIR":"/absolute/existing/folder"},"unset":["ANTHROPIC_API_KEY"]}`. Only the selected folder is set, and each `unset` name is deleted from the spawned environment; it cannot unset `CLAUDE_CONFIG_DIR`. The detail names only the account id. A failed or invalid pick stops the call with its cause and no verdict, never falling back to the default login. Unset, it preserves normal Claude sign-in behavior; other agents and API-key routes do not run it.
|
|
743
|
+
|
|
679
744
|
An `agent_config` entry merges with the defaults field by field, so `claude: { model: claude-sonnet-5-5 }` keeps `effort: low`; the other maps merge entry by entry. `done-done doctor` shows which file set each agent setting.
|
|
680
745
|
|
|
681
746
|
**Project:** `<repo>/.done-done.yaml`.
|
package/docs/releasing.md
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
# 0.
|
|
1
|
+
# 0.6.8 patch release
|
|
2
2
|
|
|
3
3
|
For `--citation-base` behavior and usage, see [Check a claim](reference.md#check-a-claim).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
This release adds opt-in pooled accounts for judge calls, documents how to add and improve journeys, fixes journey generator handling of punctuation-only flags and baseline paths, and pairs judge images with cited filenames correctly.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
npx --package done-done-axi@0.3.1 done-done check --citation-base /path/to/evidence --from checks.md
|
|
9
|
-
npx --package done-done-axi@0.3.1 done-done receipt --citation-base /path/to/evidence --from checks.md
|
|
10
|
-
```
|
|
11
|
-
|
|
12
|
-
Do not publish an unmerged candidate. The existing `release` workflow on main tests and publishes an unpublished package version using npm trusted publishing. After merge, inspect that workflow and `npm view done-done-axi@0.3.1 version dist.integrity` before attempting any manual release. If automation has not published and the release owner explicitly authorizes it, from the exact clean merged candidate run the project tests, `npm run build:skill -- --check`, `npm pack`, then `npm publish --provenance --access public` in the trusted release environment. Inspect any ambiguous result before retrying; do not change npm login.
|
|
7
|
+
Do not publish an unmerged candidate. The existing `release` workflow on main tests and publishes an unpublished package version using npm trusted publishing. After merge, confirm `npm view done-done-axi@0.6.8 version dist.integrity` and smoke-test a clean `npx` install with `done-done --version`. Do not run `npm publish` manually unless the workflow fails and the release is blocked; if so, stop and report.
|
package/package.json
CHANGED
|
@@ -48,6 +48,11 @@ and exits 1 until the receipt for HEAD has a run of each. Cite a file it flags i
|
|
|
48
48
|
A run waiting at its judge gate (`awaiting_approval`) did not pass, and nothing moves it past its judge: show the user
|
|
49
49
|
its findings and captures, and end it with `done-done respond --action fix` only when they say so.
|
|
50
50
|
A journey that `spends` runs only after the user approves spending: `done-done run <journey>` refuses it, so ask the user and use `verify`.
|
|
51
|
+
To add a journey, describe it in plain words instead of writing its script: `done-done journey add "<what a user does and sees>"`
|
|
52
|
+
has the user's configured agent write the capture script, its `.done-done.yaml` entry and a verify skill feature, then runs it once.
|
|
53
|
+
`done-done journey enhance <name> ["<focus>"]` deepens one existing journey and keeps its checks; `done-done journey improve <name> "<instruction>"`
|
|
54
|
+
makes one specific change to it. Each first run is a dirty-tree trial: review the script like any code, commit it, then
|
|
55
|
+
`done-done run <name>` for proof. `done-done journey --help` shows an example of each.
|
|
51
56
|
|
|
52
57
|
For a web page, `done-done look <url>` is the look step a journey calls: it captures the page at a 390x844 phone viewport
|
|
53
58
|
with its page-check record and exits 1 on squashed, clipped or cut-off UI, naming each element. `--act <selector>` clicks a
|