done-done-axi 0.6.13 → 0.6.14

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 CHANGED
@@ -111,6 +111,7 @@ cp "$(npm root -g)/done-done-axi/integrations/firstmate/merge-gate" "$FM_HOME/co
111
111
 
112
112
  - **`brief-include.md`**: Firstmate adds `config/brief-include.md` to every worker brief. It tells the worker to set up done-done, run the journeys `done-done affected` lists for its committed change, write a `## Checks` block, pass `check`, record a `receipt`, and see `affected` exit 0.
113
113
  - **`merge-gate`** (optional): refuses a PR whose head is not the one asked for, then runs `done-done check --from <PR URL>` and `done-done receipt --verify <head>` against your default branch's rules. Upstream Firstmate does not call a merge gate yet, so run it by hand until it does ([details](docs/reference.md#merge-gate-on-the-desktop)).
114
+ - **Model pool**: point `judge.pool_command` and `judge.pool_config` at the fleet's `account-pool/bin/fm-model-pool` and done-done picks the Claude account, moves on a rate or usage limit, and falls over to DeepSeek Goat or Cursor auto ([details](docs/reference.md#config)).
114
115
 
115
116
  Another fleet works the same way: add the include text to whatever your workers read first, and run `merge-gate` before each merge, with `FM_HOME` set to the folder that holds `data/<task-id>/checks.md` for local-only tasks. Evidence and receipts stay in `~/.done-done/evidence/<app>` after teardown.
116
117
 
package/dist/agents.js CHANGED
@@ -342,9 +342,11 @@ export const agentChoices = () => [...AGENTS, 'acp:<target>'];
342
342
  const askError = (kind, choices, rule, save) => new UsageError(`no ${kind} is set: ask the user which ${kind} to use (${choices}); ${rule}`, `Save the user's answer: ${save}`);
343
343
  export const noAgent = () => askError('agent', agentChoices().join(', '), 'never pick one for them', `done-done init --agent <agent> --model <model>, e.g. done-done init --agent opencode --model ${CHEAP.opencode.model}`);
344
344
  export const modelFor = (cfg, agent) => cfg.agent_config[agent]?.model || CHEAP[agent]?.model || '';
345
+ // The configured model pool (judge.pool_command and its helper config), or undefined: standalone users keep today's behaviour.
346
+ export const modelPool = (cfg) => cfg.judge.pool_command ? { command: cfg.judge.pool_command, config: cfg.judge.pool_config, binFor: (agent) => agentPath(agent, cfg) } : undefined;
345
347
  export const agentRun = (cfg, agent, model = modelFor(cfg, agent)) => ({
346
348
  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 } : {}),
349
+ ...(agent === 'claude' && (modelPool(cfg) ? { pool: modelPool(cfg) } : cfg.judge.account_command ? { accountCommand: cfg.judge.account_command } : {})),
348
350
  });
349
351
  export const notStartable = (agent, override = '') => (override ? `agent_path_override.${agent} ${override} is not an executable file` : `${agent} is not on PATH`);
350
352
  // The known agent an agent names, for the judge-independence check: acp:claude-code is claude; a target that
@@ -415,9 +417,24 @@ function start(bin, argv, c, cwd, input, env, idleMs) {
415
417
  let error;
416
418
  let drain;
417
419
  let idle;
420
+ // pi streams JSON events, and it echoes the whole transcript (prompt and answer included), so on stdout only an event that
421
+ // reports an error is a limit: never the words it was sent or wrote.
422
+ const piQuiet = (l) => {
423
+ if (!l.trimStart().startsWith('{'))
424
+ return false;
425
+ try {
426
+ const parsed = JSON.parse(l);
427
+ return parsed?.type !== 'error' && parsed?.message?.stopReason !== 'error';
428
+ }
429
+ catch {
430
+ return false;
431
+ }
432
+ };
418
433
  // A rate-limit line ends the call with that line, whatever the agent does next. On stdout the line must also say when it resets or retries,
419
434
  // so an answer that only quotes a 429 from the app's code does not stop the call. A claude call with images streams JSON lines,
420
- // and only a rejected rate_limit_event on stdout stops it, so its complete lines are parsed one at a time.
435
+ // and only a rejected rate_limit_event on stdout stops it, so its complete lines are parsed one at a time. pi streams its whole
436
+ // transcript at the end (agent_end carries the prompt and the answer), so only its own error event is a limit there: a judge
437
+ // asked about rate limits must not be stopped by the words it was sent.
421
438
  let pendingStdout = '';
422
439
  const limited = (chunk, stdoutLine) => {
423
440
  let line;
@@ -430,7 +447,8 @@ function start(bin, argv, c, cwd, input, env, idleMs) {
430
447
  line = text.slice(0, end).split('\n').find(isRejectedRateLimit);
431
448
  }
432
449
  else {
433
- line = stripVTControlCharacters(chunk).split(/\r?\n/).find((l) => RATE_LIMIT.test(l) && (!stdoutLine || /reset|retry/i.test(l)));
450
+ line = stripVTControlCharacters(chunk).split(/\r?\n/).find((l) => RATE_LIMIT.test(l) && (!stdoutLine || /reset|retry/i.test(l))
451
+ && (!stdoutLine || c.agent !== 'pi' || !piQuiet(l)));
434
452
  if (line)
435
453
  line = limitText(line);
436
454
  }
@@ -510,6 +528,97 @@ function start(bin, argv, c, cwd, input, env, idleMs) {
510
528
  child.stdin.end(input);
511
529
  });
512
530
  }
531
+ // A fall-over route as this machine runs it: the helper names a route id, done-done runs the agent and model it has here.
532
+ const POOL_ROUTES = {
533
+ 'route:openai-codex/gpt-6.1-sol': { agent: 'codex', model: 'gpt-6.1-sol' },
534
+ 'route:deepseek/deepseek-goat': { agent: 'pi', model: 'commandcode/deepseek/deepseek-v4.1-flash' },
535
+ 'route:cursor/auto': { agent: 'pi', model: 'cursor/auto' },
536
+ };
537
+ // A typed limit moves the call to the next account or route; usage credits and overage stop it with their own cause.
538
+ const USAGE_CREDITS = /usage[ _-]?credits?|overage|credit balance|\bcredits?\b|insufficient (?:credits?|funds)|\b402\b/i;
539
+ const TYPED_LIMIT = /rate[ -]?limit|usage limit|too many requests|quota exceeded|\b429\b/i;
540
+ const poolKey = (p) => `${p.command}\0${p.config}`;
541
+ let poolFirst; // one first answer per process, as the account command is
542
+ let poolInUse; // the account or route this process is on now
543
+ // One helper call: the request JSON on stdin, one answer JSON. Nothing but the configured command passes through a shell.
544
+ function poolAsk(pool, verb, req) {
545
+ const fail = (why, unavailable = false) => {
546
+ throw new (unavailable ? ClaudeUnavailable : UsageError)(`judge.pool_command: ${why}`, 'Fix judge.pool_command and judge.pool_config in the global config, or remove them for plain Claude sign-in');
547
+ };
548
+ const r = spawnSync(`${pool.command} ${verb} --json`, { shell: true, encoding: 'utf8', input: JSON.stringify(req), timeout: 30_000, maxBuffer: 64 * 1024 });
549
+ if (r.error)
550
+ fail(r.error.code ?? 'command could not start');
551
+ const said = typeof r.stderr === 'string' ? errorLine(r.stderr) : '';
552
+ let a;
553
+ try {
554
+ a = JSON.parse(r.stdout);
555
+ }
556
+ catch {
557
+ fail(r.status === 0 ? 'invalid JSON' : `command exited ${r.status}: ${said || 'no answer'}`);
558
+ }
559
+ const reason = typeof a?.reason === 'string' ? errorLine(a.reason) : said || 'no answer';
560
+ const billing = String(a?.billing_classification ?? a?.billing ?? '');
561
+ // The helper refuses credits-enabled accounts and routes; a call that reached one anyway is refused the same way.
562
+ if (USAGE_CREDITS.test(billing) || USAGE_CREDITS.test(typeof a?.reason === 'string' ? a.reason : ''))
563
+ fail(`the pool answered ${typeof a?.id === 'string' ? a.id : 'a route'} on usage credits or overage, which done-done never spends${reason === 'no answer' ? '' : `: ${reason}`}`);
564
+ if (r.status !== 0 || a?.ok !== true)
565
+ fail(reason, a?.ok === false || a === null);
566
+ if (typeof a.id !== 'string' || !/^[A-Za-z0-9_:./-]{1,128}$/.test(a.id))
567
+ fail('invalid account or route id');
568
+ const set = a.set ?? {};
569
+ const unset = a.unset ?? [];
570
+ if (!set || typeof set !== 'object' || Array.isArray(set) || !Object.entries(set).every(([k, v]) => /^[A-Za-z_]\w*$/.test(k) && typeof v === 'string'))
571
+ fail('set must be a map of environment variable names to strings');
572
+ if (!Array.isArray(unset) || !unset.every((n) => typeof n === 'string' && /^[A-Za-z_]\w*$/.test(n)))
573
+ fail('unset must be a list of environment variable names');
574
+ if (unset.includes('CLAUDE_CONFIG_DIR'))
575
+ fail('cannot unset CLAUDE_CONFIG_DIR');
576
+ const route = typeof a.provider === 'string' && a.provider !== 'claude';
577
+ // A known route id runs on the agent and model this tool has here; any other route runs its own harness when done-done knows it.
578
+ const mapped = route ? POOL_ROUTES[a.id] ?? (AGENTS.includes(a.harness) ? { agent: a.harness, model: String(a.model ?? '') } : undefined) : undefined;
579
+ if (route && !mapped)
580
+ fail(`route ${a.id} names no agent done-done can run`, true);
581
+ if (!route) {
582
+ const folder = set.CLAUDE_CONFIG_DIR;
583
+ let directory = false;
584
+ try {
585
+ directory = typeof folder === 'string' && isAbsolute(folder) && statSync(folder).isDirectory();
586
+ }
587
+ catch { }
588
+ if (!directory)
589
+ fail('set.CLAUDE_CONFIG_DIR must be an absolute existing folder');
590
+ }
591
+ return { id: a.id, set, unset, agent: mapped?.agent ?? 'claude', model: mapped?.model ?? '', route, raw: a };
592
+ }
593
+ // The account or fall-over route a pooled call starts on: the first answer is shared by every call in this process.
594
+ function poolPickFor(pool, model, deadlineMs) {
595
+ if (poolInUse)
596
+ return Promise.resolve(poolInUse);
597
+ const key = poolKey(pool);
598
+ if (poolFirst?.key !== key)
599
+ poolFirst = { key, pick: Promise.resolve().then(() => poolAsk(pool, 'pick', { v: 1, purpose: 'work', pool_config: pool.config, model, capabilities: ['text'], deadline: Math.floor(deadlineMs / 1000) })).then((p) => (poolInUse = p)) };
600
+ return poolFirst.pick;
601
+ }
602
+ // The account id or fall-over route a Claude call answers on, named in the judge's result detail; '' when none is configured.
603
+ export async function accountDetail(cfg, agent, model) {
604
+ const pool = agent === 'claude' ? modelPool(cfg) : undefined;
605
+ if (pool) {
606
+ const deadline = Date.now() + parseDuration(cfg.agent_timeout);
607
+ const attempted = [];
608
+ let pick = await poolPickFor(pool, model, deadline);
609
+ // A route this machine cannot start (Codex is out until 30 Oct, for one) is not the one that would answer, so the
610
+ // pre-check asks for the next one exactly as the call does, and fails closed when none can start.
611
+ while (pick.route && !pool.binFor(pick.agent)) {
612
+ if (attempted.length === 5)
613
+ throw new ClaudeUnavailable(`judge.pool_command: no account or route the helper answered can start on this machine (${attempted.join(', ')})`, 'Install one of the route agents, or remove judge.pool_command and judge.pool_config');
614
+ attempted.push(pick.id);
615
+ pick = poolAsk(pool, 'next', { v: 1, purpose: 'work', pool_config: pool.config, model, capabilities: ['text'], deadline: Math.floor(deadline / 1000), selection: pick.raw, cause: 'unavailable', attempted: [...attempted] });
616
+ }
617
+ return pick.route ? `pool route ${pick.id}` : `Claude account ${pick.id}`;
618
+ }
619
+ const account = await claudeAccount(agent, cfg.judge.account_command);
620
+ return account ? `Claude account ${account.id}` : '';
621
+ }
513
622
  // A valid picker refusal is availability, not a malformed account configuration.
514
623
  export class ClaudeUnavailable extends UsageError {
515
624
  }
@@ -552,8 +661,44 @@ export function claudeAccount(agent, command) {
552
661
  });
553
662
  }
554
663
  export async function askAgent(c) {
555
- const adapter = adapterFor(c.agent);
556
- const argv = (d) => (adapter ? [...adapter.argv(c, d), ...(c.effort ? EFFORT_FLAGS[c.agent]?.(c.effort) ?? [] : []), ...(c.args ?? [])] : []);
664
+ // The model pool owns the call's agent, model and account environment: a typed limit moves it to the helper's next
665
+ // account or fall-over route, and the call's own timeout is the one deadline that whole chain shares.
666
+ const deadline = Date.now() + c.timeoutMs;
667
+ let call = c;
668
+ let picked;
669
+ const attempted = [];
670
+ const argv = (d) => {
671
+ const adapter = adapterFor(call.agent);
672
+ return adapter ? [...adapter.argv(call, d), ...(call.effort ? EFFORT_FLAGS[call.agent]?.(call.effort) ?? [] : []), ...(call.args ?? [])] : [];
673
+ };
674
+ // The helper's next account or route that can run here, inside the one deadline: a route whose agent this machine cannot
675
+ // start (Codex is out until 30 Oct, for one) joins `attempted` and the helper is asked for the next one.
676
+ const poolStep = (cause) => {
677
+ const pool = call.pool;
678
+ while (pool && picked && Date.now() + 5_000 < deadline) {
679
+ const prior = picked;
680
+ const next = poolAsk(pool, 'next', { v: 1, purpose: 'work', pool_config: pool.config, model: c.model, capabilities: ['text'], deadline: Math.floor(deadline / 1000), selection: prior.raw, cause, attempted: [...attempted] });
681
+ const bin = next.agent === c.agent ? c.path : pool.binFor(next.agent);
682
+ if (bin) {
683
+ picked = poolInUse = next;
684
+ call = { ...call, agent: next.agent, model: next.model || c.model, path: bin, effort: next.agent === c.agent ? c.effort : '' };
685
+ return true;
686
+ }
687
+ attempted.push(next.id);
688
+ }
689
+ return false;
690
+ };
691
+ // One move on a typed limit: the helper rests the limited account and answers the next account or fall-over route.
692
+ const moveOn = async (text) => {
693
+ if (!call.pool || !picked)
694
+ return false;
695
+ if (USAGE_CREDITS.test(text))
696
+ throw new UsageError(`done-done never spends usage credits or overage: ${picked.id} answered (${errorLine(text)})`, 'Use an account or route on your plan, then re-run; nothing is charged');
697
+ if (!TYPED_LIMIT.test(text))
698
+ return false;
699
+ attempted.push(picked.id);
700
+ return poolStep('rate_limit');
701
+ };
557
702
  const started = Date.now();
558
703
  let dir = '';
559
704
  let cwd = c.cwd;
@@ -564,43 +709,69 @@ export async function askAgent(c) {
564
709
  try {
565
710
  dir = mkdtempSync(join(tmpdir(), 'done-done-agent-'));
566
711
  cwd = c.tools === 'none' ? dir : c.cwd;
567
- if (!adapter)
568
- throw new Error(`no adapter for ${c.agent} yet`);
569
- const bin = c.path;
570
- if (!bin)
571
- throw new Error(c.agent.startsWith('acp:') ? `${c.agent} needs acpx, and acpx is not startable; ${ACPX_INSTALL}` : notStartable(c.agent, c.override));
572
- const account = await claudeAccount(c.agent, c.accountCommand);
573
- const env = { ...process.env, ...c.env, ...adapter.env?.(c), ...(account && { CLAUDE_CONFIG_DIR: account.folder }) };
574
- for (const name of account?.unset ?? [])
575
- delete env[name];
576
- if (adapter.readsImages)
577
- for (const i of c.images ?? [])
578
- writeFileSync(join(dir, i.name), i.bytes, { mode: 0o600 });
579
- for (const [name, text] of Object.entries(adapter.files?.(c) ?? {}))
580
- writeFileSync(join(dir, name), text, { mode: 0o600 });
712
+ if (call.pool) {
713
+ // The pool's first answer, or the next one when the route it names cannot start here.
714
+ const pool = call.pool;
715
+ picked = await poolPickFor(pool, call.model, deadline);
716
+ const bin = picked.agent === c.agent ? c.path : pool.binFor(picked.agent);
717
+ if (bin)
718
+ call = { ...call, agent: picked.agent, model: picked.model || call.model, path: bin, effort: picked.agent === c.agent ? c.effort : '' };
719
+ else {
720
+ attempted.push(picked.id);
721
+ if (!poolStep('unavailable'))
722
+ throw new ClaudeUnavailable(`judge.pool_command: no account or route the helper answered can start on this machine (${[...attempted].join(', ')})`, 'Install one of the route agents, or remove judge.pool_command and judge.pool_config');
723
+ }
724
+ }
581
725
  let lastError = '';
582
726
  for (;;) {
727
+ const adapter = adapterFor(call.agent);
728
+ if (!adapter)
729
+ throw new Error(`no adapter for ${call.agent} yet`);
730
+ const bin = call.path;
731
+ if (!bin)
732
+ throw new Error(call.agent.startsWith('acp:') ? `${call.agent} needs acpx, and acpx is not startable; ${ACPX_INSTALL}` : notStartable(call.agent, call.override));
733
+ const account = call.accountCommand ? await claudeAccount(call.agent, call.accountCommand) : undefined;
734
+ const env = { ...process.env, ...call.env, ...adapter.env?.(call), ...(account && { CLAUDE_CONFIG_DIR: account.folder }) };
735
+ for (const name of account?.unset ?? [])
736
+ delete env[name];
737
+ if (picked) {
738
+ for (const [name, value] of Object.entries(picked.set))
739
+ env[name] = value;
740
+ for (const name of picked.unset)
741
+ delete env[name];
742
+ }
743
+ if (adapter.readsImages)
744
+ for (const i of call.images ?? [])
745
+ writeFileSync(join(dir, i.name), i.bytes, { mode: 0o600 });
746
+ for (const [name, text] of Object.entries(adapter.files?.(call) ?? {}))
747
+ writeFileSync(join(dir, name), text, { mode: 0o600 });
583
748
  attempts++;
584
749
  // An ask-again names what was wrong with the last answer. The trailer ends on words, not the schema: a weak model
585
750
  // (seen with one opencode model) copied a schema that closed the prompt back as its answer.
586
- const schemaTrailer = `\n\nAnswer with only one JSON object that matches this JSON schema:\n${JSON.stringify(c.schema)}\nThat object holds your own values, never the schema itself, and you send no other text.\n`;
587
- const prompt = c.tools === 'edit' && adapter.editNote ? `${c.prompt}\n\n${adapter.editNote}` : c.prompt;
751
+ const schemaTrailer = `\n\nAnswer with only one JSON object that matches this JSON schema:\n${JSON.stringify(call.schema)}\nThat object holds your own values, never the schema itself, and you send no other text.\n`;
752
+ const prompt = call.tools === 'edit' && adapter.editNote ? `${call.prompt}\n\n${adapter.editNote}` : call.prompt;
588
753
  const promptText = (attempts === 1 ? prompt : `${prompt}\n\nYour previous answer was not valid (${lastError}).`) + schemaTrailer;
589
754
  if (adapter.file)
590
755
  writeFileSync(join(dir, 'prompt.md'), promptText, { mode: 0o600 });
591
- const input = adapter.file ? '' : adapter.stdin ? adapter.stdin(c, promptText) : promptText;
592
- res = await start(bin, argv(dir), c, cwd, input, env, adapter.streams ? c.idleMs ?? AGENT_IDLE_MS : 0);
593
- if (res.error)
756
+ const input = adapter.file ? '' : adapter.stdin ? adapter.stdin(call, promptText) : promptText;
757
+ const remain = Math.max(1_000, Math.min(call.timeoutMs, deadline - Date.now()));
758
+ res = await start(bin, argv(dir), { ...call, timeoutMs: remain }, cwd, input, env, adapter.streams ? call.idleMs ?? AGENT_IDLE_MS : 0);
759
+ if (res.error) {
760
+ if (await moveOn(res.error.message)) {
761
+ attempts--;
762
+ continue;
763
+ }
594
764
  throw res.error;
765
+ }
595
766
  // A permission the run refused explains a call with no answer; name it rather than the exit code text.
596
767
  const refused = adapter.reject?.(String(res.stderr ?? '')) ?? '';
597
768
  if (res.status !== 0 && refused)
598
- throw new ProviderError(refused, `${c.agent} exited ${res.status}: `);
769
+ throw new ProviderError(refused, `${call.agent} exited ${res.status}: `);
599
770
  if (res.status !== 0) {
600
771
  // An agent that exits non-zero may still say why in its own output (claude: a usage limit, a bad model).
601
772
  let text = String(res.stderr || res.stdout).trim().slice(-500);
602
773
  try {
603
- adapter.answer(String(res.stdout), c);
774
+ adapter.answer(String(res.stdout), call);
604
775
  }
605
776
  catch (e) {
606
777
  if (e instanceof ProviderError)
@@ -608,16 +779,20 @@ export async function askAgent(c) {
608
779
  else if (!(e instanceof SyntaxError))
609
780
  text = e.message;
610
781
  }
611
- throw new ProviderError(text, `${c.agent} exited ${res.status}: `, adapter.hint && c.tools !== 'none' ? `; ${adapter.hint}` : '');
782
+ if (await moveOn(text)) {
783
+ attempts--;
784
+ continue;
785
+ }
786
+ throw new ProviderError(text, `${call.agent} exited ${res.status}: `, adapter.hint && call.tools !== 'none' ? `; ${adapter.hint}` : '');
612
787
  }
613
788
  try {
614
- const answer = adapter.answer(String(res.stdout), c);
789
+ const answer = adapter.answer(String(res.stdout), call);
615
790
  if (!answer || typeof answer !== 'object' || Array.isArray(answer))
616
791
  throw new Error(`the answer is not a JSON object: ${JSON.stringify(answer)}`);
617
- const missing = (c.schema.required ?? []).filter((k) => !(k in answer));
792
+ const missing = (call.schema.required ?? []).filter((k) => !(k in answer));
618
793
  if (missing.length)
619
794
  throw new Error(`the answer does not match the schema (missing ${missing.join(', ')}): ${JSON.stringify(answer).slice(0, 200)}`);
620
- out = { ok: true, answer: answer };
795
+ out = { ok: true, answer: answer, ...(picked && (picked.route ? { route: picked.id } : { account: picked.id })) };
621
796
  break;
622
797
  }
623
798
  catch (e) {
@@ -625,10 +800,10 @@ export async function askAgent(c) {
625
800
  throw e;
626
801
  // A refused permission is why there is no usable answer, not a bad answer: name it instead of "no JSON object".
627
802
  if (refused)
628
- throw new ProviderError(refused, `${c.agent} did not finish: `);
803
+ throw new ProviderError(refused, `${call.agent} did not finish: `);
629
804
  // Only a tool-less call is asked again: an edit or read step may already have changed the repo or read it.
630
805
  lastError = e.message;
631
- if (c.tools !== 'none' || attempts >= (c.attempts ?? ASK_ATTEMPTS))
806
+ if (call.tools !== 'none' || attempts >= (call.attempts ?? ASK_ATTEMPTS))
632
807
  throw new Error(`the agent did not answer with valid JSON after ${attempts} ${attempts === 1 ? 'try' : 'tries'}: ${lastError}`);
633
808
  }
634
809
  }
@@ -638,7 +813,7 @@ export async function askAgent(c) {
638
813
  // carries its own line, so it is left as it is.
639
814
  const said = stderrHead(String(res?.stderr ?? ''));
640
815
  const provider = e instanceof ProviderError;
641
- const note = (text) => (!provider && said && !text.includes(said.split(' | ')[0]) ? `${text}; ${c.agent} stderr: ${said}` : text);
816
+ const note = (text) => (!provider && said && !text.includes(said.split(' | ')[0]) ? `${text}; ${call.agent} stderr: ${said}` : text);
642
817
  out = { ok: false, error: note(e.message), ...(!provider && said ? { stderr: said } : {}) };
643
818
  failure = note(e instanceof ProviderError ? e.detail : e.message);
644
819
  }
@@ -647,9 +822,9 @@ export async function askAgent(c) {
647
822
  rmSync(dir, { recursive: true, force: true });
648
823
  }
649
824
  // The record holds what ran and what came back, never the prompt text or an image.
650
- const { prompt, images, ...call } = c;
825
+ const { prompt, images, pool, ...spec } = c;
651
826
  try {
652
- const record = { ...call, ...(images && { images: images.map((i) => i.name) }), cwd, prompt_bytes: Buffer.byteLength(prompt), argv: argv('<tmp>'), ms: Date.now() - started, exit: res?.status ?? null, attempts, ...out, error: failure, stderr: String(res?.stderr ?? '').slice(-2000) };
827
+ const record = { ...spec, agent: call.agent, model: call.model, ...(picked && (picked.route ? { route: picked.id } : { account: picked.id })), ...(images && { images: images.map((i) => i.name) }), cwd, prompt_bytes: Buffer.byteLength(prompt), argv: argv('<tmp>'), ms: Date.now() - started, exit: res?.status ?? null, attempts, ...out, error: failure, stderr: String(res?.stderr ?? '').slice(-2000) };
653
828
  mkdirSync(dirname(c.record), { recursive: true });
654
829
  writeFileSync(c.record, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
655
830
  out = { ...out, record: c.record };
package/dist/cli.js CHANGED
@@ -118,6 +118,7 @@ const CONFIG_TEXT = `config: ~/.done-done/config.yaml (global; DONE_DONE_HOME mo
118
118
  global only: agent_config.<agent>.{model,effort} (effort: minimal, low, medium, high, xhigh, max), agent_args_override.<agent>
119
119
  (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),
120
120
  judge.account_command (optional Claude account pick; exported CLAUDE_CONFIG_DIR wins),
121
+ judge.pool_command with judge.pool_config (optional shared model pool: the Claude account, a move on a rate/usage limit, and a fall-over route),
121
122
  judge.fallback_route (optional second judge, same values as judge.route; Claude usage/rate/sign-in limits or an empty pool trigger it;
122
123
  e.g. answerer:pi with agent_config.pi.model: openai-codex/gpt-6.1-sol; unset keeps Claude failures as errors; no fallback chains),
123
124
  evidence.local_root, judge.{route,model,floor} (the judge and shared confidence floor, set by the user with
package/dist/config.js CHANGED
@@ -89,6 +89,8 @@ const KEYS = {
89
89
  'judge.floor': { scope: 'global', type: 'number' },
90
90
  'judge.env_file': { scope: 'global', type: 'string' },
91
91
  'judge.account_command': { scope: 'global', type: 'string' },
92
+ 'judge.pool_command': { scope: 'global', type: 'string' },
93
+ 'judge.pool_config': { scope: 'global', type: 'string' },
92
94
  'judge.fallback_route': { scope: 'global', type: 'route' },
93
95
  };
94
96
  const SECTIONS = new Set(['evidence', 'secrets', 'checks', 'judge', 'signoff']);
@@ -369,7 +371,7 @@ function defaultConfig(repo) {
369
371
  secrets: { patterns: {}, allow: [] },
370
372
  checks: { fix_title: '^(fix|bugfix|hotfix)\\b', upstream_catch_up: false, moved_from: false, generated_paths: [] },
371
373
  signoff: { post: false, board: '' },
372
- judge: { route: '', fallback_route: '', model: '', floor: 0.7, env_file: '', account_command: '' },
374
+ judge: { route: '', fallback_route: '', model: '', floor: 0.7, env_file: '', account_command: '', pool_command: '', pool_config: '' },
373
375
  trusted: false,
374
376
  from: {},
375
377
  };
@@ -382,6 +384,18 @@ export function loadConfig(repo, projectText) {
382
384
  apply(cfg, readOptional(globalFile), globalFile, 'global');
383
385
  const projectFile = join(repo, '.done-done.yaml');
384
386
  apply(cfg, projectText ?? readOptional(projectFile), projectFile, 'repo');
387
+ // The model pool needs both its helper command and the helper's own pool config; it replaces the Claude account command.
388
+ if (cfg.judge.pool_config) {
389
+ cfg.judge.pool_config = tilde(cfg.judge.pool_config);
390
+ if (!isAbsolute(cfg.judge.pool_config))
391
+ throw new UsageError(`${globalFile}: judge.pool_config must be the absolute path of the model pool helper's config/account-pool`, 'Set judge.pool_config in the global config, or remove it');
392
+ }
393
+ if (cfg.judge.pool_command) {
394
+ if (!cfg.judge.pool_config)
395
+ throw new UsageError(`${globalFile}: judge.pool_command needs judge.pool_config, the absolute path of the helper's config/account-pool`, 'Set judge.pool_config in the global config, or remove judge.pool_command');
396
+ if (cfg.judge.account_command)
397
+ throw new UsageError(`${globalFile}: judge.pool_command and judge.account_command are both set: the model pool already picks the Claude account`, 'Delete the judge.account_command line in the global config');
398
+ }
385
399
  // A skill dir an older init wrote (verify-<app>) is still read until init renames it to the prefixed name.
386
400
  cfg.skill ||= existsSync(join(repo, `.agents/skills/done-done-verify-${cfg.app}`)) || !existsSync(join(repo, `.agents/skills/verify-${cfg.app}`))
387
401
  ? `.agents/skills/done-done-verify-${cfg.app}` : `.agents/skills/verify-${cfg.app}`;
@@ -460,5 +474,9 @@ judge:
460
474
  # env_file: ~/.config/done-done/judge.env
461
475
  # Optional shell command printing one account-pick JSON for Claude judge and agent calls; an exported CLAUDE_CONFIG_DIR wins.
462
476
  # account_command: account-picker pick claude --json
477
+ # Optional shared model pool (firstmate-addons account-pool/bin/fm-model-pool): it picks the Claude account, moves on a
478
+ # rate/usage limit, and falls over to another route; pool_config is the helper's own config/account-pool file.
479
+ # pool_command: fm-model-pool
480
+ # pool_config: ~/firstmate/config/account-pool
463
481
  `;
464
482
  }
package/dist/judge.js CHANGED
@@ -2,7 +2,7 @@ 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
4
  import { basename, delimiter, isAbsolute, join } from 'node:path';
5
- import { agentRun, askAgent, claudeAccount, ClaudeUnavailable, notStartable } from './agents.js';
5
+ import { accountDetail, agentRun, askAgent, ClaudeUnavailable, 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
@@ -146,7 +146,7 @@ export async function backend(cfg, choice, record = () => '', questions = { rele
146
146
  const claude = choice.agent === 'claude';
147
147
  if (!bin)
148
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);
149
+ const account = await accountDetail(cfg, choice.agent, choice.model);
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: 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` };
171
+ return { ask, resolve, lastError: () => lastError, detail: account || (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);
@@ -354,16 +354,21 @@ async function judgeBatch(cfg, choice, outcome, captures, records, dir) {
354
354
  return { judged, notJudged, choice, missing };
355
355
  }
356
356
  async function judgeOn(cfg, choose, outcome, captures, records) {
357
- // Check the account even on a verdict cache hit: a broken configured picker must never produce a pass.
358
- const account = await claudeAccount(choose().agent, cfg.judge.account_command);
359
- const detail = account ? { detail: `Claude account ${account.id}` } : {};
357
+ // Check the account or pool route even on a verdict cache hit: a broken configured picker must never produce a pass.
358
+ const first = choose();
359
+ await accountDetail(cfg, first.agent, first.model);
360
+ // After the calls, the detail names the account id or fall-over route that actually answered.
361
+ const named = async (result) => {
362
+ const detail = await accountDetail(cfg, first.agent, first.model);
363
+ return detail ? { ...result, detail } : result;
364
+ };
360
365
  const dir = join(home(), 'runs', cfg.app, 'judge');
361
366
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
362
367
  const judged = [];
363
368
  const notJudged = [];
364
369
  const batchChoice = captures.filter(judgeable).length > 1 ? choose() : undefined;
365
370
  if (batchChoice && batchChoice.route !== 'openai:key')
366
- return { ...await judgeBatch(cfg, batchChoice, outcome, captures, records, dir), ...detail };
371
+ return named(await judgeBatch(cfg, batchChoice, outcome, captures, records, dir));
367
372
  let choice;
368
373
  let be;
369
374
  let calls = 0;
@@ -423,5 +428,5 @@ async function judgeOn(cfg, choose, outcome, captures, records) {
423
428
  judged.push(verdict);
424
429
  }
425
430
  // Only openai:key can have several independent calls here; it returns no coverage fields.
426
- return { judged, notJudged, choice, missing: missingParts(judged), ...detail };
431
+ return named({ judged, notJudged, choice, missing: missingParts(judged) });
427
432
  }
package/docs/reference.md CHANGED
@@ -795,10 +795,14 @@ judge: # done-done's own judge; check, receipt, v
795
795
  floor: 0.7 # every judge's pick must reach this probability to count; below it the verdict is unsure (inconclusive), never a pass
796
796
  # 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
797
797
  # 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
798
+ # pool_command: fm-model-pool # optional shared model pool (Firstmate account-pool/bin/fm-model-pool): the Claude account, limit moves and fall-over
799
+ # pool_config: ~/firstmate/config/account-pool # the helper's own config/account-pool, absolute; needs pool_command
798
800
  ```
799
801
 
800
802
  `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 pick returning `ok: false` (or JSON `null`) is an unavailable Claude route and uses `judge.fallback_route` when configured; otherwise it stops with the same cause and no verdict. An invalid pick still stops the call. Neither case falls back to the default Claude login. Unset, it preserves normal Claude sign-in behavior; other agents and API-key routes do not run it.
801
803
 
804
+ **Shared model pool.** Global `judge.pool_command` opts a home into a pool helper that picks the Claude account, moves on a rate/usage limit and falls over to another model route; `judge.pool_config` is the helper's own `config/account-pool`, absolute. It speaks the pool's protocol v1: done-done runs `<pool_command> pick --json` with one request JSON on stdin (`v: 1`, `purpose: work`, `pool_config`, the call's `model` and one overall deadline) and later `<pool_command> next --json` with the prior answer, `cause: rate_limit` and every account or route already `attempted`. The answer is either a Claude account (`id`, `set`, `unset`, as `judge.account_command` above) or a fall-over route (`provider` other than `claude`, `harness`, `model`). One answer per process is shared by the Claude judge, readers and drives; each call's environment gets that account's folder and its `unset` names removed, and nothing else. On a typed rate or usage limit the helper's next account or route runs that same call, once per account, inside the call's single `agent_timeout` deadline. A route the helper answers runs on the agent and model done-done has here: `route:deepseek/deepseek-goat` is `pi` at `commandcode/deepseek/deepseek-v4.1-flash`, `route:cursor/auto` is `pi` at `cursor/auto`, and any other route runs its own harness when that is an agent done-done knows. A route whose agent is not installed here is skipped and the helper is asked for the next one. Usage credits and overage are never spent: an account or route that enables them stops the call with a plain error naming it, and nothing is charged. The result's detail names the account id or the route id that answered, never a folder or a token. A pick returning `ok: false` (or JSON `null`) is an unavailable Claude route and uses `judge.fallback_route` when configured; invalid JSON or a malformed answer stops the call. Unset, plain Claude sign-in is unchanged. `judge.account_command` and `judge.pool_command` cannot both be set.
805
+
802
806
  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.
803
807
 
804
808
  **Project:** `<repo>/.done-done.yaml`.
package/docs/releasing.md CHANGED
@@ -1,5 +1,5 @@
1
- # 0.6.13 patch release
1
+ # 0.6.14 patch release
2
2
 
3
- This release carries the rewritten README to the npm page: a real judged run on the first screen, the hero images rendered from captured CLI transcripts, and reference detail moved into the docs.
3
+ This release lets a home point its judge at a shared model pool: done-done picks the Claude account, moves on when one is rate or usage limited, and falls over to DeepSeek Goat or Cursor auto when every Claude account is under the floor.
4
4
 
5
- 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.13 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.
5
+ 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.14 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "done-done-axi",
3
- "version": "0.6.13",
3
+ "version": "0.6.14",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Prove your app really works: done-done drives your app the way a user does, a fresh judge checks every capture, and the proof is tied to a commit.",
6
6
  "type": "module",
@@ -98,6 +98,8 @@ fresh session of its own, looks at each capture. `check` and `receipt` start the
98
98
  and nothing turns it off: there is no result without it. When it says no judge is set, ask the user which
99
99
  judge (and model) to use and save their answer with the command it prints; never choose one for them. When
100
100
  the judge fails, report its error to the user; never call the work proven without a judge pass.
101
+ An optional shared model pool (`judge.pool_command` and `judge.pool_config`) picks the Claude account, moves on a rate or usage
102
+ limit, and falls over to DeepSeek Goat or Cursor auto; results name the account or route that answered, and usage credits are never spent.
101
103
  An explicitly configured `judge.fallback_route` can judge when Claude has no eligible account, sign-in or usage left;
102
104
  `answerer:pi` with `agent_config.pi.model: openai-codex/gpt-6.1-sol` uses the existing Codex login.
103
105
  Read `done-done --help` for fallback configuration; results name the route that actually answered.