done-done-axi 0.6.6 → 0.6.7
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 +6 -0
- package/dist/agents.js +27 -13
- package/dist/cli.js +193 -19
- package/dist/config.js +1 -1
- package/dist/judge.js +21 -34
- package/dist/maintain.js +5 -0
- package/docs/reference.md +39 -5
- package/package.json +1 -1
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
|
package/dist/agents.js
CHANGED
|
@@ -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 = '';
|
|
@@ -335,11 +353,9 @@ export function knownAgentOf(agent) {
|
|
|
335
353
|
return AGENTS.find((a) => target === a || (target.startsWith(a) && !/[A-Za-z0-9]/.test(target[a.length]))) ?? agent;
|
|
336
354
|
}
|
|
337
355
|
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
356
|
// Why an agent cannot read a repo tool-less from text plain code gathers, '' when it can: gemini's none mode is a
|
|
341
357
|
// 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' :
|
|
358
|
+
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
359
|
// A binary from config: a bare name is looked up on PATH, an absolute path is used when it is an executable file.
|
|
344
360
|
export const findBin = (bin) => (isAbsolute(bin) ? (isExecutable(bin) ? bin : '') : onPath(bin));
|
|
345
361
|
// The binary that starts an agent: agent_path_override.<agent>, else its name on PATH. An acp:<target> agent starts
|
|
@@ -508,16 +524,14 @@ export async function askAgent(c) {
|
|
|
508
524
|
cwd = c.tools === 'none' ? dir : c.cwd;
|
|
509
525
|
if (!adapter)
|
|
510
526
|
throw new Error(`no adapter for ${c.agent} yet`);
|
|
511
|
-
|
|
512
|
-
|
|
527
|
+
const bin = c.path;
|
|
528
|
+
if (!bin)
|
|
529
|
+
throw new Error(c.agent.startsWith('acp:') ? `${c.agent} needs acpx, and acpx is not startable; ${ACPX_INSTALL}` : notStartable(c.agent, c.override));
|
|
513
530
|
if (adapter.readsImages)
|
|
514
531
|
for (const i of c.images ?? [])
|
|
515
532
|
writeFileSync(join(dir, i.name), i.bytes, { mode: 0o600 });
|
|
516
533
|
for (const [name, text] of Object.entries(adapter.files?.(c) ?? {}))
|
|
517
534
|
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
535
|
let lastError = '';
|
|
522
536
|
for (;;) {
|
|
523
537
|
attempts++;
|
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';
|
|
@@ -145,6 +145,8 @@ const FLAGS = {
|
|
|
145
145
|
'--model <model>': 'with --agent: that agent\'s model, saved as agent_config.<agent>.model (opencode: provider/model)',
|
|
146
146
|
'--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
147
|
'--judge-model <model>': 'with --judge <agent>: the judge\'s model, saved as judge.model',
|
|
148
|
+
'--before': 'record HEAD at PreToolUse; without it consume the matching PostToolUse event and capture the commit',
|
|
149
|
+
'--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
150
|
'--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
151
|
};
|
|
150
152
|
// `more` is the reference text a command needs beyond its flags, shared with the top-level help.
|
|
@@ -156,6 +158,7 @@ const COMMANDS = {
|
|
|
156
158
|
['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
159
|
['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
160
|
['done-done init --hook claude', 'also show this repo\'s done-done state at each Claude Code session start'],
|
|
161
|
+
['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
162
|
['done-done init --hook codex', 'also show this repo\'s done-done state at each Codex session start'],
|
|
160
163
|
['done-done init --hook opencode', 'also add this repo\'s done-done state to the first message of each opencode session'],
|
|
161
164
|
['done-done init --hook pi', 'also add this repo\'s done-done state to the first prompt of each pi session'],
|
|
@@ -164,7 +167,7 @@ const COMMANDS = {
|
|
|
164
167
|
['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
168
|
['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
169
|
],
|
|
167
|
-
flags: ['--agent <agent>', '--model <model>', '--judge <agent>', '--judge-model <model>', '--hook <agent>', '--journey <name>', '--core', '--upgrade', '-- <command...>'],
|
|
170
|
+
flags: ['--agent <agent>', '--model <model>', '--judge <agent>', '--judge-model <model>', '--hook <agent>', '--after-commit', '--journey <name>', '--core', '--upgrade', '-- <command...>'],
|
|
168
171
|
more: [JOURNEYS_TEXT, CONFIG_TEXT],
|
|
169
172
|
},
|
|
170
173
|
doctor: {
|
|
@@ -211,6 +214,11 @@ const COMMANDS = {
|
|
|
211
214
|
flags: ['--outcome "<what a user can now do>"', '--feature <name>', '--claim-id <id>', '--wait', '--no-wait', '--foreground'],
|
|
212
215
|
more: [RUNS_TEXT],
|
|
213
216
|
},
|
|
217
|
+
journey: {
|
|
218
|
+
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']],
|
|
219
|
+
flags: [],
|
|
220
|
+
more: ['Run init first. Generation runs locally, never in CI, and never chooses an agent. Review and commit the generated files, then re-run for clean proof. A journey that spends is saved but not run: use verify for approval.'],
|
|
221
|
+
},
|
|
214
222
|
maintain: {
|
|
215
223
|
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
224
|
flags: ['--wait', '--no-wait', '--foreground'],
|
|
@@ -257,6 +265,11 @@ const COMMANDS = {
|
|
|
257
265
|
checks.generated_paths: ["gen/**", "*.snap"] adds your own globs to the generated files above; the default is none.
|
|
258
266
|
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
267
|
},
|
|
268
|
+
'after-commit': {
|
|
269
|
+
usage: [['done-done after-commit [--before]', 'Claude Bash lifecycle hook; reads its event on stdin and reruns affected journey commands after a commit']],
|
|
270
|
+
flags: ['--before'],
|
|
271
|
+
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.'],
|
|
272
|
+
},
|
|
260
273
|
affected: {
|
|
261
274
|
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
275
|
flags: [],
|
|
@@ -1245,7 +1258,7 @@ const STALE_MS = 60_000;
|
|
|
1245
1258
|
const runsRoot = (app) => join(home(), 'runs', app);
|
|
1246
1259
|
const verifySteps = (cfg) => ['resolve', 'drive', 'check', 'judge', ...(cfg.auto_fix.verify ? ['triage', 'fix'] : []), 'gate', 'receipt', 'report'];
|
|
1247
1260
|
// 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'];
|
|
1261
|
+
const DRIVE_REFUSED = ['respond', 'receipt', 'verify', 'rerun', 'maintain', 'after-commit', 'journey'];
|
|
1249
1262
|
// One agent drive may launch the app, drive it and save captures; past this it is killed with everything it started.
|
|
1250
1263
|
const DRIVE_SCHEMA = {
|
|
1251
1264
|
type: 'object',
|
|
@@ -2986,6 +2999,100 @@ function verifyFeatureMd(app, s, f) {
|
|
|
2986
2999
|
const drive = clause(f.drive);
|
|
2987
3000
|
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
3001
|
}
|
|
3002
|
+
// Generation is read-only, like init. Only plain code writes the returned recipe; subsequent runs need no agent.
|
|
3003
|
+
async function cmdJourney(argv) {
|
|
3004
|
+
const { positionals } = parseArgs({ args: argv, allowPositionals: true });
|
|
3005
|
+
if (positionals.length !== 2 || positionals[0] !== 'add' || !positionals[1].trim())
|
|
3006
|
+
throw new UsageError('Use: done-done journey add "<description>"');
|
|
3007
|
+
if (process.env.CI)
|
|
3008
|
+
throw new UsageError('journey add uses an agent to generate repo changes; run it locally, review and commit them, then run the saved journey in CI');
|
|
3009
|
+
const repo = repoRoot(), cfg = loadConfig(repo);
|
|
3010
|
+
const config = join(repo, '.done-done.yaml'), index = join(repo, cfg.skill, 'features/README.md');
|
|
3011
|
+
if (![config, join(repo, cfg.skill, 'SKILL.md'), index].every(existsSync))
|
|
3012
|
+
throw new UsageError('journey add needs the project config and verify feature map', 'Run: done-done init');
|
|
3013
|
+
const inside = under(repo, index);
|
|
3014
|
+
if (!inside || cfg.skill.split(/[\\/]/).includes('..'))
|
|
3015
|
+
throw new UsageError('journey add needs a verify skill inside the repo');
|
|
3016
|
+
const safePath = (p) => { if (symlinkOnPath(repo, p))
|
|
3017
|
+
throw new UsageError(`${shown(repo, p)} is reached through a symlink; nothing written`); };
|
|
3018
|
+
for (const p of [config, index, join(repo, cfg.skill, 'SKILL.md'), join(repo, 'journeys')])
|
|
3019
|
+
safePath(p);
|
|
3020
|
+
const agent = selectAgent(cfg), toolless = agent.agent !== 'claude';
|
|
3021
|
+
if (!agent.path)
|
|
3022
|
+
throw new UsageError(notStartable(agent.agent, agent.override));
|
|
3023
|
+
if (toolless && toollessReason(agent.agent))
|
|
3024
|
+
throw new UsageError(toollessReason(agent.agent), 'Use an agent supported by init: done-done init --agent <agent> --model <model>');
|
|
3025
|
+
const before = repoState(repo);
|
|
3026
|
+
const context = repoContext(repo, cfg.skill);
|
|
3027
|
+
const fields = ['name', 'title', 'source', 'script', 'checks', 'spends', 'blocker'];
|
|
3028
|
+
const schema = { type: 'object', properties: Object.fromEntries(fields.map(k => [k, STR])), required: fields, additionalProperties: false };
|
|
3029
|
+
const record = join(home(), 'agents', `journey-add-${randomBytes(8).toString('hex')}.json`);
|
|
3030
|
+
const a = await askAgent({ ...agent, cwd: repo, tools: toolless ? 'none' : 'read', record, schema, prompt: `Add ONE journey for this user request: ${JSON.stringify(positionals[1])}.
|
|
3031
|
+
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.
|
|
3032
|
+
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.
|
|
3033
|
+
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.
|
|
3034
|
+
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.
|
|
3035
|
+
Existing verify skill:\n${repoText(join(repo, cfg.skill, 'SKILL.md'))}\nFeature map:\n${repoText(index)}\n\n${context.text}` });
|
|
3036
|
+
if (repoState(repo) !== before)
|
|
3037
|
+
throw new UsageError('the repo changed during generation; no recipe was written; review the changes before retrying');
|
|
3038
|
+
if (!a.ok)
|
|
3039
|
+
throw new UsageError(`${agent.agent} did not generate a journey: ${a.error}; record ${a.record ?? record}`);
|
|
3040
|
+
const s = a.answer;
|
|
3041
|
+
if (s.blocker.trim())
|
|
3042
|
+
throw new UsageError(`journey not added: ${s.blocker}`);
|
|
3043
|
+
if (!/^[a-z0-9][a-z0-9-]{0,59}$/.test(s.name) || s.name === 'readme')
|
|
3044
|
+
throw new UsageError('the agent returned an invalid journey name');
|
|
3045
|
+
if (Object.hasOwn(cfg.journeys, s.name))
|
|
3046
|
+
throw new UsageError(`journey ${s.name} already exists; nothing written`);
|
|
3047
|
+
if (!s.title.trim() || !s.checks.trim() || !s.script.includes('DONE_DONE_EVIDENCE_ROOT'))
|
|
3048
|
+
throw new UsageError('the agent returned no title, checks or capture script; nothing written');
|
|
3049
|
+
if (absoluteInside(s.script, repo))
|
|
3050
|
+
throw new UsageError('the agent embedded an absolute checkout path; nothing written');
|
|
3051
|
+
if (!context.files.has(s.source) || SECRET.test(s.source) || (toolless && !context.read.has(s.source)))
|
|
3052
|
+
throw new UsageError(`the agent cited an unavailable source: ${s.source}; nothing written`);
|
|
3053
|
+
// Parse, never execute, before touching any project file.
|
|
3054
|
+
execFileSync(process.execPath, ['--check', '--input-type=module'], { input: s.script, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
3055
|
+
const script = join(repo, 'journeys', `${s.name}.mjs`), feature = join(dirname(index), `${s.name}.md`);
|
|
3056
|
+
for (const p of [script, feature]) {
|
|
3057
|
+
safePath(p);
|
|
3058
|
+
if (existsSync(p))
|
|
3059
|
+
throw new UsageError(`${shown(repo, p)} already exists; nothing written`);
|
|
3060
|
+
}
|
|
3061
|
+
const originalConfig = readFileSync(config, 'utf8'), originalIndex = readFileSync(index, 'utf8');
|
|
3062
|
+
const doc = parseDocument(originalConfig);
|
|
3063
|
+
if (doc.get('journeys') == null)
|
|
3064
|
+
doc.set('journeys', doc.createNode({}));
|
|
3065
|
+
doc.setIn(['journeys', s.name], { command: `node journeys/${s.name}.mjs`, ...(s.spends.trim() && { spends: s.spends.trim() }) });
|
|
3066
|
+
const nextConfig = doc.toString({ lineWidth: 0 });
|
|
3067
|
+
loadConfig(repo, nextConfig);
|
|
3068
|
+
const featureText = `# ${s.title}\n\nRequest: ${positionals[1]}\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`;
|
|
3069
|
+
const files = new Map([[script, `${s.script.trim()}\n`], [feature, featureText], [index, `${originalIndex.trimEnd()}\n- [${s.title}](${s.name}.md): run \`done-done run ${s.name}\`\n`], [config, nextConfig]]);
|
|
3070
|
+
const written = [];
|
|
3071
|
+
try {
|
|
3072
|
+
for (const [p, text] of files) {
|
|
3073
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
3074
|
+
const fd = openSync(p, p === script || p === feature ? 'wx' : 'w');
|
|
3075
|
+
written.push(p);
|
|
3076
|
+
try {
|
|
3077
|
+
writeFileSync(fd, text);
|
|
3078
|
+
}
|
|
3079
|
+
finally {
|
|
3080
|
+
closeSync(fd);
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
catch (e) {
|
|
3085
|
+
for (const p of written.reverse()) {
|
|
3086
|
+
if (p === config || p === index)
|
|
3087
|
+
writeFileSync(p, p === config ? originalConfig : originalIndex);
|
|
3088
|
+
else
|
|
3089
|
+
rmSync(p);
|
|
3090
|
+
}
|
|
3091
|
+
throw e;
|
|
3092
|
+
}
|
|
3093
|
+
process.stderr.write(`Added journey ${s.name} through ${agent.agent}. Review these repo changes:\n${git(repo, 'diff', '--stat', '--', relative(repo, config), relative(repo, index))}\n${[script, feature].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`);
|
|
3094
|
+
return cmdRun([s.name]);
|
|
3095
|
+
}
|
|
2989
3096
|
// An entry's role marks, the text doctor reads (src/proof.ts namedJourney).
|
|
2990
3097
|
const marks = (f) => `${f.core ? ` ${MARK.core}` : ''}${f.upgrade ? ` ${MARK.upgrade}` : ''}`;
|
|
2991
3098
|
function verifyFeaturesMd(app, features, omitted) {
|
|
@@ -3050,7 +3157,7 @@ async function scaffoldVerify(repo, cfg) {
|
|
|
3050
3157
|
// As in maintain, only claude reads the repo with its own tools; every other agent gets the files from plain code.
|
|
3051
3158
|
const toolless = agent.agent !== 'claude';
|
|
3052
3159
|
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 };
|
|
3160
|
+
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
3161
|
const context = repoContext(repo, cfg.skill);
|
|
3055
3162
|
// Every schema-valid answer cites tracked files, so with none there is nothing to ask for.
|
|
3056
3163
|
if (!context.files.size)
|
|
@@ -3141,12 +3248,14 @@ async function scaffoldVerify(repo, cfg) {
|
|
|
3141
3248
|
// Claude Code reads it from the repo's .claude/settings.json, Codex from the repo's .codex/hooks.json
|
|
3142
3249
|
// (project scope; Codex docs: <repo>/.codex/hooks.json, SessionStart, matcher omitted matches every start).
|
|
3143
3250
|
const HOOK_COMMAND = 'command -v done-done >/dev/null && done-done || true';
|
|
3251
|
+
const COMMIT_BEFORE = 'if command -v done-done >/dev/null; then done-done after-commit --before; fi';
|
|
3252
|
+
const COMMIT_AFTER = 'if command -v done-done >/dev/null; then done-done after-commit; fi';
|
|
3144
3253
|
// Gemini CLI reads a SessionStart hook from the repo's .gemini/settings.json (its hooks reference) but shows plain
|
|
3145
3254
|
// stdout only to the person, so the state goes to the model as JSON hookSpecificOutput.additionalContext.
|
|
3146
3255
|
// done-done runs on node, so node is on PATH whenever done-done is.
|
|
3147
3256
|
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
3257
|
// 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) {
|
|
3258
|
+
function sessionStartSettings(repo, rel, command, agent, afterCommit = false) {
|
|
3150
3259
|
const file = join(repo, rel);
|
|
3151
3260
|
const bad = (why) => new UsageError(`${rel} ${why}`, `Fix ${rel}, then re-run: done-done init --hook ${agent}`);
|
|
3152
3261
|
const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -3163,13 +3272,17 @@ function sessionStartSettings(repo, rel, command, agent) {
|
|
|
3163
3272
|
throw bad('is not a JSON object');
|
|
3164
3273
|
if (settings.hooks !== undefined && !isObject(settings.hooks))
|
|
3165
3274
|
throw bad('has "hooks" that is not an object');
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3275
|
+
let changed = false;
|
|
3276
|
+
for (const [event, cmd] of [['SessionStart', command], ...(afterCommit ? [['PreToolUse', COMMIT_BEFORE], ['PostToolUse', COMMIT_AFTER]] : [])]) {
|
|
3277
|
+
const hooks = settings.hooks?.[event] ?? [];
|
|
3278
|
+
if (!Array.isArray(hooks))
|
|
3279
|
+
throw bad(`has "hooks.${event}" that is not a list`);
|
|
3280
|
+
if (hooks.some((m) => Array.isArray(m?.hooks) && m.hooks.some((h) => h?.command === cmd)))
|
|
3281
|
+
continue;
|
|
3282
|
+
settings.hooks = { ...settings.hooks, [event]: [...hooks, { ...(event !== 'SessionStart' && { matcher: 'Bash' }), hooks: [{ type: 'command', command: cmd, ...(event !== 'SessionStart' && { timeout: 600 }) }] }] };
|
|
3283
|
+
changed = true;
|
|
3284
|
+
}
|
|
3285
|
+
return { file, ...(changed && { text: `${JSON.stringify(settings, null, 2)}\n` }) };
|
|
3173
3286
|
}
|
|
3174
3287
|
// A file init owns outright: written once, unchanged when identical, never overwritten when someone else's.
|
|
3175
3288
|
function ownedFile(repo, rel, text, agent) {
|
|
@@ -3448,13 +3561,15 @@ async function cmdInit(argv) {
|
|
|
3448
3561
|
const dash = argv.indexOf('--');
|
|
3449
3562
|
const { values } = parseArgs({
|
|
3450
3563
|
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' } },
|
|
3564
|
+
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
3565
|
allowPositionals: false,
|
|
3453
3566
|
});
|
|
3454
3567
|
const parts = dash < 0 ? [] : argv.slice(dash + 1);
|
|
3455
3568
|
const journey = values.journey ?? null;
|
|
3456
3569
|
if (values.hook !== undefined && !Object.hasOwn(HOOKS, values.hook))
|
|
3457
3570
|
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('|')}>`);
|
|
3571
|
+
if (values['after-commit'] && values.hook !== 'claude')
|
|
3572
|
+
throw new UsageError('--after-commit needs --hook claude', 'Run: done-done init --hook claude --after-commit');
|
|
3458
3573
|
if (values.model !== undefined && values.agent === undefined)
|
|
3459
3574
|
throw new UsageError('--model needs --agent', `Run: done-done init --agent <agent> --model ${values.model}`);
|
|
3460
3575
|
if (values.agent !== undefined)
|
|
@@ -3488,7 +3603,7 @@ async function cmdInit(argv) {
|
|
|
3488
3603
|
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
3604
|
const renamed = renameLegacySkill(repo, cfg);
|
|
3490
3605
|
const spec = values.hook === undefined ? undefined : HOOKS[values.hook];
|
|
3491
|
-
const hook = spec?.make(repo); //
|
|
3606
|
+
const hook = values['after-commit'] ? sessionStartSettings(repo, '.claude/settings.json', HOOK_COMMAND, 'claude', true) : spec?.make(repo); // validated before any write
|
|
3492
3607
|
// The agent: --agent, else with none set the user's pick in a terminal (asked before any write); never done-done's.
|
|
3493
3608
|
const choice = values.agent !== undefined ? { agent: values.agent, model: values.model ?? '' } : !cfg.agent && interactive() ? await pick('agent', cfg) : undefined;
|
|
3494
3609
|
if (choice)
|
|
@@ -3830,10 +3945,7 @@ async function cmdDoctor(argv) {
|
|
|
3830
3945
|
}
|
|
3831
3946
|
if (values.probe && !agent)
|
|
3832
3947
|
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) {
|
|
3948
|
+
if (values.probe && agent) {
|
|
3837
3949
|
const record = join(home(), 'agents', `probe-${new Date().toISOString().replace(/[:.]/g, '-')}-${agent.agent}.json`);
|
|
3838
3950
|
const probe = !agent.path ? { ok: false, error: notFound, fix: installFix }
|
|
3839
3951
|
: 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 +4295,64 @@ function cmdAffected(argv) {
|
|
|
4183
4295
|
});
|
|
4184
4296
|
return fresh ? PASS : FAIL;
|
|
4185
4297
|
}
|
|
4298
|
+
// Claude's opt-in Bash lifecycle hook compares HEAD before/after the exact tool call, not shell command text.
|
|
4299
|
+
async function cmdAfterCommit(argv) {
|
|
4300
|
+
const { values } = parseArgs({ args: argv, options: { before: { type: 'boolean' } } });
|
|
4301
|
+
let event;
|
|
4302
|
+
try {
|
|
4303
|
+
event = JSON.parse(readFileSync(0, 'utf8'));
|
|
4304
|
+
}
|
|
4305
|
+
catch (e) {
|
|
4306
|
+
throw new UsageError(`after-commit needs a Claude hook JSON event: ${e.message}`);
|
|
4307
|
+
}
|
|
4308
|
+
if (event?.tool_name !== 'Bash')
|
|
4309
|
+
return PASS;
|
|
4310
|
+
if (typeof event.tool_use_id !== 'string' || !event.tool_use_id || typeof event.session_id !== 'string' || !event.session_id)
|
|
4311
|
+
throw new UsageError('after-commit needs session_id and tool_use_id');
|
|
4312
|
+
if (event.hook_event_name !== (values.before ? 'PreToolUse' : 'PostToolUse'))
|
|
4313
|
+
throw new UsageError('after-commit: hook event does not match --before');
|
|
4314
|
+
const repo = repoRoot(), cfg = loadConfig(repo);
|
|
4315
|
+
const state = join(home(), 'hooks', `${sha256(Buffer.from(JSON.stringify([repo, event.session_id, event.tool_use_id])))}.json`);
|
|
4316
|
+
const current = spawnSync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: repo, encoding: 'utf8' });
|
|
4317
|
+
if (values.before) {
|
|
4318
|
+
mkdirSync(dirname(state), { recursive: true });
|
|
4319
|
+
writeFileSync(state, JSON.stringify({ commit: current.status === 0 ? current.stdout.trim() : null }));
|
|
4320
|
+
return PASS;
|
|
4321
|
+
}
|
|
4322
|
+
if (!existsSync(state))
|
|
4323
|
+
return PASS; // no paired start, or this event was already handled
|
|
4324
|
+
const before = JSON.parse(readFileSync(state, 'utf8')).commit;
|
|
4325
|
+
rmSync(state);
|
|
4326
|
+
if (current.status !== 0)
|
|
4327
|
+
return PASS;
|
|
4328
|
+
const h = head(repo);
|
|
4329
|
+
if (before === h.commit || !/^commit(?:\s|:)/.test(git(repo, 'reflog', '-1', '--format=%gs')))
|
|
4330
|
+
return PASS;
|
|
4331
|
+
if (h.dirty)
|
|
4332
|
+
throw new UsageError('after-commit: tracked changes remain; commit them before capturing proof', 'Run the affected journeys from a clean commit: done-done affected');
|
|
4333
|
+
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)));
|
|
4334
|
+
const m = mapDiff(repo, cfg, changed, git(repo, 'ls-files', '-z').split('\0').filter(Boolean));
|
|
4335
|
+
let proposal;
|
|
4336
|
+
if (m.unmapped.length) {
|
|
4337
|
+
proposal = join(cfg.evidence.root, '.done-done', 'proposals', `${h.commit}.md`);
|
|
4338
|
+
mkdirSync(dirname(proposal), { recursive: true });
|
|
4339
|
+
writeFileSync(proposal, journeyProposal(cfg.skill, h.commit, m.unmapped, m.journeys.map((j) => j.name)));
|
|
4340
|
+
}
|
|
4341
|
+
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.'] });
|
|
4342
|
+
let code = PASS;
|
|
4343
|
+
for (const j of m.journeys) {
|
|
4344
|
+
if (head(repo).commit !== h.commit)
|
|
4345
|
+
throw new UsageError('after-commit: HEAD moved before the next journey; stopped');
|
|
4346
|
+
const result = await cmdRun([j.name]);
|
|
4347
|
+
if (result !== PASS)
|
|
4348
|
+
code = result;
|
|
4349
|
+
}
|
|
4350
|
+
if (m.features.length || (!m.journeys.length && m.fallback)) {
|
|
4351
|
+
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>.'] });
|
|
4352
|
+
return INCONCLUSIVE;
|
|
4353
|
+
}
|
|
4354
|
+
return code;
|
|
4355
|
+
}
|
|
4186
4356
|
// Bare `done-done`: orient a new user or agent with the config state and the next step.
|
|
4187
4357
|
function cmdBare() {
|
|
4188
4358
|
let repo;
|
|
@@ -4256,6 +4426,8 @@ async function main(argv) {
|
|
|
4256
4426
|
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
4427
|
if (cmd === 'init')
|
|
4258
4428
|
return await cmdInit(rest);
|
|
4429
|
+
if (cmd === 'journey')
|
|
4430
|
+
return await cmdJourney(rest);
|
|
4259
4431
|
if (cmd === 'doctor')
|
|
4260
4432
|
return await cmdDoctor(rest);
|
|
4261
4433
|
if (cmd === 'run')
|
|
@@ -4278,6 +4450,8 @@ async function main(argv) {
|
|
|
4278
4450
|
return cmdGate(rest);
|
|
4279
4451
|
if (cmd === 'affected')
|
|
4280
4452
|
return cmdAffected(rest);
|
|
4453
|
+
if (cmd === 'after-commit')
|
|
4454
|
+
return await cmdAfterCommit(rest);
|
|
4281
4455
|
if (cmd === 'status')
|
|
4282
4456
|
return cmdStatus(rest);
|
|
4283
4457
|
if (cmd === 'attach')
|
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).
|
package/dist/judge.js
CHANGED
|
@@ -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 = 10; // 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,8 +33,7 @@ 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
|
-
// own model call, so the check matches those parts by name, never by position.
|
|
36
|
+
// capture. In a batch, capture_0 defines one shared list; every covers position refers to that list, not another label.
|
|
38
37
|
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.';
|
|
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 = {
|
|
@@ -200,10 +199,17 @@ export function prepare(c) {
|
|
|
200
199
|
// with its cause, never a verdict: the command stops with no result and no receipt.
|
|
201
200
|
// An answer the kit cannot read (all-zero probabilities, an option left out) would abstain on every question as if the
|
|
202
201
|
// 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) {
|
|
202
|
+
export async function askJudge(cfg, be, choice, state, questions, images, sharedParts = '') {
|
|
204
203
|
const again = 'Fix the cause above (done-done doctor checks the judge), then run this again';
|
|
205
204
|
let answers;
|
|
206
|
-
const resolved = (r) =>
|
|
205
|
+
const resolved = (r) => {
|
|
206
|
+
const answers = Object.fromEntries(Object.entries(questions).map(([k, q]) => [k, be.resolve(q, r[k])]));
|
|
207
|
+
// One answer defines the identities before coverage is validated or blank/repeated labels are collapsed.
|
|
208
|
+
if (sharedParts)
|
|
209
|
+
for (const answer of Object.values(answers))
|
|
210
|
+
Object.assign(answer, { parts: lists(answers[sharedParts]).parts });
|
|
211
|
+
return answers;
|
|
212
|
+
};
|
|
207
213
|
const malformed = (a) => Object.keys(questions).filter((k) => /^(malformed|no answer)/.test(a[k].reason ?? ''));
|
|
208
214
|
// The parts rule reads the lists the answer carries, so a reply that leaves one out has not answered: without them the
|
|
209
215
|
// rule would silently require no part.
|
|
@@ -231,9 +237,11 @@ export async function askJudge(cfg, be, choice, state, questions, images) {
|
|
|
231
237
|
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
238
|
return answers;
|
|
233
239
|
}
|
|
234
|
-
//
|
|
235
|
-
|
|
236
|
-
const
|
|
240
|
+
// All rows in a check use one list. Labels are display text, never cross-reply identities.
|
|
241
|
+
const missingParts = (judged) => {
|
|
242
|
+
const shown = new Set(judged.filter((r) => r.verdict === 'pass').flatMap((r) => r.covers ?? []));
|
|
243
|
+
return (judged[0]?.parts ?? []).filter((_, i) => !shown.has(i));
|
|
244
|
+
};
|
|
237
245
|
export const screen = (c) => c.media.type === 'png' || c.media.type === 'jpeg';
|
|
238
246
|
// The judge reads screens as images and text captures (logs, JSON, CLI output) as text; video and other files are not judged.
|
|
239
247
|
export const judgeable = (c) => screen(c) || c.media.type === 'text';
|
|
@@ -308,11 +316,11 @@ async function judgeBatch(cfg, choice, outcome, captures, records, dir) {
|
|
|
308
316
|
if (before && 'mime' in before)
|
|
309
317
|
images[beforeId] = before;
|
|
310
318
|
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}
|
|
319
|
+
questions[id] = { ...base, images: 'text' in input ? [] : 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
320
|
}
|
|
313
321
|
const call = join(records, `judge-${stamp}-1`);
|
|
314
322
|
const be = await backend(cfg, choice, (request) => `${call}-${request}.agent.json`, questions);
|
|
315
|
-
const answers = await askJudge(cfg, be, choice, state, questions, images);
|
|
323
|
+
const answers = await askJudge(cfg, be, choice, state, questions, images, 'capture_0');
|
|
316
324
|
const judged = rows.map(({ row, c }, i) => {
|
|
317
325
|
const a = answers[`capture_${i}`];
|
|
318
326
|
const shape = questions[`capture_${i}`];
|
|
@@ -320,17 +328,7 @@ async function judgeBatch(cfg, choice, outcome, captures, records, dir) {
|
|
|
320
328
|
const cov = coverageOf(a);
|
|
321
329
|
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
330
|
});
|
|
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);
|
|
331
|
+
const missing = missingParts(judged);
|
|
334
332
|
writeCache(dir, key, { judged, missing, route, model });
|
|
335
333
|
return { judged: [...judged, ...suffix], notJudged, choice, missing };
|
|
336
334
|
}
|
|
@@ -398,17 +396,6 @@ async function judgeOn(cfg, choose, outcome, captures, records) {
|
|
|
398
396
|
writeCache(dir, key, { verdict: verdict.verdict, reason: verdict.reason, ...seen, ...coverage, route, model });
|
|
399
397
|
judged.push(verdict);
|
|
400
398
|
}
|
|
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) };
|
|
399
|
+
// Only openai:key can have several independent calls here; it returns no coverage fields.
|
|
400
|
+
return { judged, notJudged, choice, missing: missingParts(judged) };
|
|
414
401
|
}
|
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,25 @@ 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. 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. Enhancement or instruction-based revision of an existing journey is not part of `journey add`.
|
|
86
120
|
|
|
87
121
|
### Run a journey
|
|
88
122
|
|
|
@@ -423,7 +457,7 @@ done-done check --from pr-body.md # always judged
|
|
|
423
457
|
|
|
424
458
|
Under `verify`, a `fail` or `unsure` verdict does not end the run: it parks it at its [gate](#the-gate-and-respond).
|
|
425
459
|
|
|
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
|
|
460
|
+
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. 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-9 verdict caches are invalidated by the shared-list change: old full-set missing-part results and independent per-capture labels are never reinterpreted as 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.
|
|
427
461
|
|
|
428
462
|
### Design check
|
|
429
463
|
|
|
@@ -577,7 +611,7 @@ done-done maintain # --no-wait, --wait (the default), or --foreground (fo
|
|
|
577
611
|
|
|
578
612
|
- `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
613
|
- `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);
|
|
614
|
+
- `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
615
|
- `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
616
|
|
|
583
617
|
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`.
|
package/package.json
CHANGED