@irythill/orq 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,8 @@ import { basename, dirname, join, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { claudeAccounts } from "../accounts.js";
17
17
  import { gateFor } from "../diagnose.js";
18
- import { argvModel, useThrowaway, agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../agents.js";
18
+ import { hasVerdict } from "../rounds.js";
19
+ import { argvModel, baseCommit, commitMessage, isCommitBase, stepIns, stepInNote, useThrowaway, agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../agents.js";
19
20
  import { getPath, loadConfig } from "../config.js";
20
21
  import { writeAtomic } from "../fsutil.js";
21
22
  import { m } from "../i18n.js";
@@ -26,7 +27,9 @@ import { killedRc, killTree, parseSize, resolveMode, runCapped } from "../proces
26
27
  import { closeOwnTab, detectHost, launch, screenOpen, shq, typeInto } from "../terminal.js";
27
28
  import { ackPrefix } from "../notice.js";
28
29
  import { dropWorktree, notify } from "../testrun.js";
29
- export const USAGE = "orq agent -a <agent> -t <title> -d <dir> -o <prefix> [-g group] [-m model] [-e effort] [-c account] [-i file]... [-b base] [-r] [-R session [-F [-F]]] [-T] -- <prompt.md> run a child agent";
30
+ export const USAGE = "orq agent -a <agent> -t <title> -d <dir> -o <prefix> [-g group] [-m model] [-e effort] [-c account] [-i file]... [-b base [--base-branch <branch>]] [-r] [-R session [-F [-F]] [--reason review|verify|unblock]] [-T] -- <prompt.md> run a child agent";
31
+ /** Why a follow-up (-R) runs: only `review` (fixing a reviewer's findings) counts towards policy.max_rounds. */
32
+ export const ROUND_REASONS = ["review", "verify", "unblock"];
30
33
  const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
31
34
  const PF = ".herdr-agent-prompt.md"; // prompt_in_dir file name (kept from herdr-agent)
32
35
  const git = (dir, ...a) => {
@@ -38,8 +41,8 @@ export async function agent(argv) {
38
41
  return runJob(argv[1]);
39
42
  if (argv[0] === "--turn")
40
43
  return turn(argv[1], argv[2], argv.slice(3));
41
- let a = process.env.AGENT || "", g = "", t = "", d = "", o = "", model = "", effort = "", acc = "", resume = "", base = "";
42
- let ro = false, tui = false, force = 0, prompt;
44
+ let a = process.env.AGENT || "", g = "", t = "", d = "", o = "", model = "", effort = "", acc = "", resume = "", base = "", baseBranch = "";
45
+ let ro = false, tui = false, force = 0, prompt, reason = "review";
43
46
  const inc = [];
44
47
  for (let i = 0; i < argv.length; i++) {
45
48
  const x = argv[i], v = () => argv[++i] ?? "";
@@ -77,6 +80,10 @@ export async function agent(argv) {
77
80
  tui = true;
78
81
  else if (x === "-F")
79
82
  force++;
83
+ else if (x === "--reason")
84
+ reason = v();
85
+ else if (x === "--base-branch")
86
+ baseBranch = v();
80
87
  else if (!x.startsWith("-") && prompt === undefined)
81
88
  prompt = x;
82
89
  else {
@@ -88,6 +95,14 @@ export async function agent(argv) {
88
95
  console.error(m("give the prompt file", "informe o arquivo de prompt"));
89
96
  return 2;
90
97
  }
98
+ if (!ROUND_REASONS.includes(reason)) {
99
+ console.error(m(`--reason must be one of ${ROUND_REASONS.join("|")}`, `--reason deve ser ${ROUND_REASONS.join("|")}`));
100
+ return 2;
101
+ }
102
+ if (reason !== "review" && !resume) {
103
+ console.error(m("--reason only applies to a follow-up (-R)", "--reason só vale para um follow-up (-R)"));
104
+ return 2;
105
+ }
91
106
  for (const [f, val] of [["t", t], ["d", d], ["o", o]])
92
107
  if (!val) {
93
108
  console.error(`${m("missing", "faltou")} -${f}`);
@@ -149,7 +164,14 @@ export async function agent(argv) {
149
164
  }
150
165
  // Fix rounds: every -R on the same session counts, whatever the prefix is called. policy.max_rounds is enforced here,
151
166
  // not left to the conductor to remember (a 7-round fix loop is what burns the tokens). -F = the user said go on.
167
+ // Only --reason review counts; every follow-up is logged. Written at launch, so a later refusal costs no round.
168
+ let countRound;
152
169
  if (resume) {
170
+ const lf = join(dirname(O), `.rounds-${resume}.log`);
171
+ const logRound = () => { mkdirSync(dirname(O), { recursive: true }); appendFileSync(lf, `${new Date().toISOString()} ${reason} ${basename(O)}\n`); };
172
+ countRound = logRound;
173
+ }
174
+ if (resume && reason === "review") {
153
175
  const max = Number(getPath(cfg, "policy.max_rounds") ?? 2), cf = join(dirname(O), `.rounds-${resume}`);
154
176
  let n = 0;
155
177
  try {
@@ -169,8 +191,8 @@ export async function agent(argv) {
169
191
  return 4;
170
192
  }
171
193
  }
172
- mkdirSync(dirname(O), { recursive: true });
173
- writeFileSync(cf, String(n + 1));
194
+ const logRound = countRound;
195
+ countRound = () => { logRound(); writeFileSync(cf, String(n + 1)); };
174
196
  }
175
197
  let P = resolve(prompt);
176
198
  if (!existsSync(repo)) {
@@ -183,17 +205,32 @@ export async function agent(argv) {
183
205
  }
184
206
  mkdirSync(dirname(O), { recursive: true });
185
207
  // -b <base>: attaches `git diff origin/<base>...HEAD` (review without running git; required for Antigravity).
208
+ // A base with no origin/<base> but naming a commit (a fix round's round-<n>.sha) attaches `<base>..HEAD`: only that round's delta.
209
+ // An agent that can't review a commit base (agents.<name>.base_commit) gets the branch base instead: the whole branch.
210
+ let scopeNote = "";
211
+ if (base && !baseCommit(def) && isCommitBase(repo, base)) {
212
+ const branch = baseBranch?.replace(/^origin\//, "") || git(repo, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")?.replace(/^origin\//, "");
213
+ if (!branch) {
214
+ console.error(m(`${a} can't review -b ${base} (a commit) and no branch base is known: pass --base-branch <branch>`, `${a} não revisa -b ${base} (um commit) e a base da branch é desconhecida: informe --base-branch <branch>`));
215
+ return 2;
216
+ }
217
+ scopeNote = m(`${a} can't review a commit base (agents.${a}.base_commit = false): reviewing against origin/${branch} instead. The review covers the whole branch, not just the round delta.`, `${a} não revisa uma base que é commit (agents.${a}.base_commit = false): revisando contra origin/${branch}. A revisão cobre a branch inteira, não só o delta da rodada.`);
218
+ console.log(`[orq agent] ${scopeNote}`);
219
+ base = branch;
220
+ }
186
221
  if (base) {
187
- const r = spawnSync("git", ["-C", repo, "diff", `origin/${base}...HEAD`], { encoding: "utf8", maxBuffer: 1 << 30 });
222
+ const range = isCommitBase(repo, base) ? `${base}..HEAD` : `origin/${base}...HEAD`;
223
+ const r = spawnSync("git", ["-C", repo, "diff", range], { encoding: "utf8", maxBuffer: 1 << 30 });
188
224
  if (r.status !== 0) {
189
- console.error(m(`could not build the diff against origin/${base}`, `não consegui gerar o diff contra origin/${base}`));
225
+ console.error(m(`could not build the diff ${range}`, `não consegui gerar o diff ${range}`));
190
226
  return 2;
191
227
  }
192
228
  writeFileSync(`${O}.diff`, r.stdout);
193
229
  inc.push(`${O}.diff`);
194
230
  }
195
- if (inc.length) {
196
- let text = readFileSync(P, "utf8");
231
+ const steps = stepIns(def, ro, git(repo, "rev-parse", "--git-dir") !== undefined), note = stepInNote(steps);
232
+ if (inc.length || note) {
233
+ let text = readFileSync(P, "utf8") + note;
197
234
  for (const f of inc)
198
235
  text += `\n--- ${basename(f)} ---\n${readFileSync(f, "utf8")}\n--- end of ${basename(f)} ---\n`;
199
236
  writeFileSync(`${O}.prompt.md`, text);
@@ -206,12 +243,19 @@ export async function agent(argv) {
206
243
  ackPrefix(O); // a follow-up on the issue: its failure notices were seen
207
244
  for (const s of [".done", ".out.md"])
208
245
  rmSync(O + s, { force: true });
246
+ countRound?.();
209
247
  typeInto(open, readFileSync(P, "utf8"));
210
248
  console.log(`[orq agent] ${t} → ${open} ${m("(follow-up typed into the open screen)", "(follow-up digitado na tela aberta)")}`);
211
249
  return 0;
212
250
  }
213
251
  for (const s of [".done", ".out.md", ".pid"])
214
252
  rmSync(O + s, { force: true });
253
+ // .id is the session this prefix's screen answers for (`--turn codex` drops other threads): an id left by an earlier
254
+ // run on the prefix (another agent, another session) would drop every turn of this one.
255
+ if (resume)
256
+ writeFileSync(`${O}.id`, `${resume}\n`);
257
+ else
258
+ rmSync(`${O}.id`, { force: true });
215
259
  const throwaway = useThrowaway(def, ro, Boolean(resume), git(repo, "rev-parse", "--git-dir") !== undefined);
216
260
  const dir = throwaway ? `${repo}.review-${process.pid}` : repo;
217
261
  const gd = git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir");
@@ -254,6 +298,12 @@ export async function agent(argv) {
254
298
  const cmd = expandTemplate(tpl, vars);
255
299
  if (!tpl.includes("{mode}") && !resume && !tui)
256
300
  cmd.push(...vars.mode);
301
+ // A prompt passed as an argument ({prompt_text}) can be over what the OS takes: refuse now, not with E2BIG in the tab.
302
+ const tooBig = argvTooBig(cmd);
303
+ if (tooBig) {
304
+ console.error(m(`prompt too big for ${a}: ${tooBig}. Attach less with -i (e.g. only the round's diff; the agent can run git diff itself) or use an agent that reads the prompt from stdin or a file. Nothing started.`, `prompt grande demais para ${a}: ${tooBig}. Anexe menos com -i (ex.: só o diff da rodada; o agente pode rodar git diff) ou use um agente que lê o prompt por stdin ou arquivo. Nada foi iniciado.`));
305
+ return 2;
306
+ }
257
307
  const env = {};
258
308
  for (const [k, val] of Object.entries(def.env ?? {}))
259
309
  env[k] = expandString(String(val), vars);
@@ -275,10 +325,13 @@ export async function agent(argv) {
275
325
  swap: parseSize(mach.agent_swap_max), mode: mach.mem_enforce ?? "auto",
276
326
  throwaway, promptInDir: Boolean(def.prompt_in_dir), closeAfter,
277
327
  keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0, tui,
278
- okExit: def.ok_exit, filter: def.filter, noReview: def.no_review, ro,
328
+ okExit: def.ok_exit, filter: def.filter, noReview: def.no_review, ro, orqCommits: steps.commit, orqFetches: steps.fetch, scopeNote,
329
+ // A review (-r, prefix review*) must end with a verdict; policy.review_verdict = false turns the check off.
330
+ review: ro && /^review/i.test(basename(O)) && getPath(cfg, "policy.review_verdict") !== false,
279
331
  idleCloseMin: tui ? Number(mach.tui_idle_close_min ?? 30) : undefined,
280
332
  };
281
333
  writeAtomic(`${O}.job.json`, JSON.stringify(job, null, 1));
334
+ countRound?.();
282
335
  // Readable copy of the command (also what orq stats and herdr-board read).
283
336
  writeFileSync(`${O}.run.sh`, `#!/usr/bin/env bash\n# orq agent (generated; the run itself is \`orq agent --run ${O}.job.json\`) --unit=${unit} \n` +
284
337
  `cd ${shq(dir)}\n${Object.entries(env).map(([k, val]) => `${k}=${shq(val)} `).join("")}${cmd.map(shq).join(" ")}${job.stdin ? ` < ${shq(P)}` : ""}\n`);
@@ -370,13 +423,14 @@ function turn(file, kind, rest) {
370
423
  answer = readFileSync(0, "utf8");
371
424
  }
372
425
  writeFileSync(`${O}.out.md`, answer);
426
+ appendFileSync(`${O}.turns`, `${new Date().toISOString()}\n`); // stats: work time ends at the first turn
373
427
  if (sid)
374
428
  writeFileSync(`${O}.id`, `${sid}\n`);
375
- const noReview = noReviewMatch(j.noReview, answer);
376
- const st = /\S/.test(answer) && !noReview ? "ok" : "fail";
377
- writeAtomic(`${O}.done`, `status=${st} rc=0 at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
429
+ const noReview = noReviewMatch(j.noReview, answer), noVerdict = Boolean(j.review) && /\S/.test(answer) && !hasVerdict(answer);
430
+ const st = /\S/.test(answer) && !noReview && !noVerdict ? "ok" : "fail", why = noVerdict ? " reason=no-verdict" : "";
431
+ writeAtomic(`${O}.done`, `status=${st} rc=0${why} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
378
432
  if (j.parent)
379
- notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md`);
433
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0${why} answer: ${O}.out.md`);
380
434
  }
381
435
  catch (e) {
382
436
  console.error(`orq agent --turn: ${e.message}`);
@@ -444,6 +498,29 @@ export function idleExpired(done, idleMs, now = Date.now(), busyAt = 0) {
444
498
  return false;
445
499
  }
446
500
  }
501
+ /** A -T turn ended without its hook: the screen was seen busy (`busyAt`) after the last .done (if any), and has been
502
+ * idle since `idleSince` for `graceMs`. All times epoch ms; 0 = never seen. */
503
+ export function missedTurn(done, busyAt, idleSince, graceMs, now = Date.now()) {
504
+ if (!busyAt || !idleSince || idleSince < busyAt || now - idleSince < graceMs)
505
+ return false;
506
+ try {
507
+ return statSync(done).mtimeMs < busyAt;
508
+ }
509
+ catch {
510
+ return true;
511
+ }
512
+ }
513
+ const NO_HOOK_GRACE_MS = 60_000;
514
+ /** Signals a -T turn whose end-of-turn hook never fired: .done (status=fail reason=no-hook) and the done line, with the
515
+ * screen's last lines as the answer, so the conductor isn't left waiting. A late hook still overwrites both. */
516
+ function noHook(j, pane) {
517
+ const O = j.prefix;
518
+ const r = spawnSync("herdr", ["pane", "read", pane, "--lines", "80", "--format", "text"], { encoding: "utf8", timeout: 5_000 });
519
+ writeFileSync(`${O}.out.md`, `${m("The agent's end-of-turn hook did not fire; its turn looks finished (the screen went idle). Last lines of the screen:", "O hook de fim de turno do agente não disparou; o turno parece terminado (a tela ficou ociosa). Últimas linhas da tela:")}\n\n${r.status === 0 ? r.stdout : ""}`);
520
+ writeAtomic(`${O}.done`, `status=fail rc=0 reason=no-hook at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
521
+ if (j.parent)
522
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=0 reason=no-hook answer: ${O}.out.md`);
523
+ }
447
524
  /** herdr's view of the agent on a pane: working | blocked | idle | done | unknown; undefined when it can't be read. */
448
525
  function paneStatus(pane) {
449
526
  const r = spawnSync("herdr", ["pane", "get", pane], { encoding: "utf8", timeout: 5_000 });
@@ -470,6 +547,30 @@ function lines(s, each) {
470
547
  s.on("error", () => res());
471
548
  });
472
549
  }
550
+ /** The answer of a run whose command never started: what failed and what to do, for the conductor. */
551
+ export const spawnFailText = (j, e) => `orq agent: ${j.agent} could not start (${e.code ?? "error"}): ${e.message}\n` +
552
+ (e.code === "E2BIG" ? "The prompt (with its -i attachments) is too big to pass as an argument: attach less (e.g. the round's diff only) and let the agent read the rest with git.\n"
553
+ : e.code === "ENOENT" ? `Command not found: ${j.argv[0]}. Is it installed for the current PATH (and Node version)?\n` : "");
554
+ /** Largest command line the OS takes: Linux caps each argument (MAX_ARG_STRLEN, 128 KiB), Windows the whole line. */
555
+ export function argvTooBig(argv, platform = process.platform) {
556
+ const kb = (n) => `${Math.round(n / 1024)} KB`;
557
+ if (platform === "win32") {
558
+ const n = argv.reduce((s, a) => s + a.length + 3, 0);
559
+ return n > 32_000 ? m(`the command line is ${kb(n)} (Windows takes ~32 KB)`, `a linha de comando tem ${kb(n)} (o Windows aceita ~32 KB)`) : undefined;
560
+ }
561
+ const big = Math.max(0, ...argv.map((a) => Buffer.byteLength(a)));
562
+ if (platform === "linux" && big >= 128 * 1024)
563
+ return m(`one argument is ${kb(big)} (Linux takes 128 KB per argument)`, `um argumento tem ${kb(big)} (o Linux aceita 128 KB por argumento)`);
564
+ const total = argv.reduce((s, a) => s + Buffer.byteLength(a) + 1, 0);
565
+ return total > 1_000_000 ? m(`the command line is ${kb(total)} (over ~1 MB)`, `a linha de comando tem ${kb(total)} (acima de ~1 MB)`) : undefined;
566
+ }
567
+ /** Commits everything in `dir` for an agent that can't; returns the line for its answer. */
568
+ function orqCommit(dir, msg) {
569
+ const add = spawnSync("git", ["-C", dir, "add", "-A"], { encoding: "utf8" });
570
+ const c = add.status === 0 ? spawnSync("git", ["-C", dir, "commit", "-q", "-m", msg], { encoding: "utf8" }) : add;
571
+ return c.status === 0 ? `${git(dir, "rev-parse", "--short", "HEAD")} ${msg}`
572
+ : `FAILED (exit ${c.status}): ${(c.stderr || c.stdout).trim()}. The changes are still in the worktree.`;
573
+ }
473
574
  async function runJob(file) {
474
575
  const j = JSON.parse(readFileSync(file, "utf8")), O = j.prefix;
475
576
  writeFileSync(`${O}.pid`, String(process.pid));
@@ -494,6 +595,8 @@ async function runJob(file) {
494
595
  }
495
596
  catch { /* none */ }
496
597
  }
598
+ if (j.orqFetches && spawnSync("git", ["-C", j.dir, "fetch", "-q", "origin"], { stdio: "inherit" }).status !== 0)
599
+ console.error(m("[orq agent] git fetch origin failed; the agent sees the local origin/* refs", "[orq agent] git fetch origin falhou; o agente vê as refs origin/* locais"));
497
600
  if (j.promptInDir) {
498
601
  copyFileSync(j.prompt, join(j.dir, PF));
499
602
  const ex = git(j.dir, "rev-parse", "--git-path", "info/exclude");
@@ -512,15 +615,18 @@ async function runJob(file) {
512
615
  }
513
616
  return true;
514
617
  };
515
- const post = () => {
618
+ let committed = "";
619
+ const post = (answer = "") => {
516
620
  if (j.promptInDir)
517
621
  rmSync(join(j.dir, PF), { force: true });
518
622
  if (j.throwaway)
519
623
  dropWorktree(j.repo, j.dir);
624
+ else if (j.orqCommits && existsSync(j.dir) && git(j.dir, "status", "--porcelain"))
625
+ committed = orqCommit(j.dir, commitMessage(answer, j.title));
520
626
  };
521
627
  if (j.tui) {
522
628
  // The agent's own screen: the terminal is its; answers come from the end-of-turn hook (`--turn`).
523
- let ended = false;
629
+ let ended = false, tuiErr;
524
630
  const end = (code, killed = false) => {
525
631
  if (ended)
526
632
  return "fail";
@@ -535,9 +641,10 @@ async function runJob(file) {
535
641
  notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=killed rc=${code} answer: ${O}.out.md`);
536
642
  }
537
643
  else if (!existsSync(`${O}.done`)) {
538
- writeAtomic(`${O}.done`, `status=fail rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
644
+ const why = tuiErr ? ` reason=spawn:${tuiErr.code ?? "error"}` : "";
645
+ writeAtomic(`${O}.done`, `status=fail rc=${code}${why} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
539
646
  if (j.parent)
540
- notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`);
647
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code}${why} answer: ${O}.out.md`);
541
648
  }
542
649
  rmSync(`${O}.pid`, { force: true });
543
650
  return killed ? "killed" : ok ? "ok" : "fail";
@@ -558,32 +665,40 @@ async function runJob(file) {
558
665
  for (const sig of ["SIGHUP", "SIGTERM"])
559
666
  process.on(sig, gone);
560
667
  rc = await runCapped(j.argv[0], j.argv.slice(1), { cap: j.cap, swap: j.swap, mode: j.mode, unit: j.unit, cwd: j.dir, env, quote: true, tty: true,
668
+ onError: (e) => { tuiErr = e; writeFileSync(`${O}.out.md`, spawnFailText(j, e)); },
561
669
  onSpawn: (c) => {
562
670
  child = c.pid;
563
671
  // A finished agent waits for a follow-up forever and holds its memory: after the idle timeout without a new
564
672
  // turn, kill it; the normal end below cleans up and closes the tab. "New turn" = .done removed (`-R` typed
565
673
  // into the screen removes it) or rewritten (the next end of turn), so idle = .done untouched that long.
566
- const idleMs = (j.idleCloseMin ?? 0) * 60_000;
567
- if (idleMs > 0 && !j.keep) {
568
- // On herdr the pane's agent status also catches a turn typed straight into the screen (.done only changes
569
- // when it ends). Elsewhere (tmux) only .done tells: a turn typed directly into the screen that runs longer
570
- // than the timeout can still be closed.
571
- let busyAt = 0;
572
- const pane = j.host === "herdr" ? process.env.HERDR_PANE_ID : undefined;
573
- idleT = setInterval(() => {
574
- const st = pane ? paneStatus(pane) : undefined;
575
- if (st === "working" || st === "blocked")
576
- busyAt = Date.now();
577
- if (!idleExpired(`${O}.done`, idleMs, Date.now(), busyAt))
578
- return;
579
- clearInterval(idleT);
580
- console.log(m(`[orq agent] no follow-up for ${j.idleCloseMin} min: closing the finished agent`, `[orq agent] sem follow-up há ${j.idleCloseMin} min: fechando o agente que terminou`));
581
- idleClosed = true;
582
- if (child)
583
- killTree(child, false);
584
- }, Math.min(30_000, Math.max(1_000, idleMs / 2)));
585
- idleT.unref();
586
- }
674
+ const idleMs = (j.idleCloseMin && !j.keep ? j.idleCloseMin : 0) * 60_000;
675
+ // On herdr the pane's agent status also catches a turn typed straight into the screen (.done only changes
676
+ // when it ends), and a turn whose hook never fired (busy, then idle, .done not rewritten): that one is
677
+ // signalled from here, flagged reason=no-hook, with the screen's tail as the answer. Elsewhere (tmux) only
678
+ // .done tells: a turn typed directly into the screen that runs longer than the timeout can still be closed.
679
+ const pane = j.host === "herdr" ? process.env.HERDR_PANE_ID : undefined;
680
+ if (!pane && idleMs <= 0)
681
+ return;
682
+ let busyAt = 0, idleSince = 0;
683
+ idleT = setInterval(() => {
684
+ const st = pane ? paneStatus(pane) : undefined, now = Date.now();
685
+ if (st === "working" || st === "blocked") {
686
+ busyAt = now;
687
+ idleSince = 0;
688
+ }
689
+ else if (st === "idle" || st === "done")
690
+ idleSince ||= now;
691
+ if (pane && missedTurn(`${O}.done`, busyAt, idleSince, NO_HOOK_GRACE_MS, now))
692
+ noHook(j, pane);
693
+ if (!idleExpired(`${O}.done`, idleMs, now, busyAt))
694
+ return;
695
+ clearInterval(idleT);
696
+ console.log(m(`[orq agent] no follow-up for ${j.idleCloseMin} min: closing the finished agent`, `[orq agent] sem follow-up há ${j.idleCloseMin} min: fechando o agente que terminou`));
697
+ idleClosed = true;
698
+ if (child)
699
+ killTree(child, false);
700
+ }, pane ? 15_000 : Math.min(30_000, Math.max(1_000, idleMs / 2)));
701
+ idleT.unref();
587
702
  } });
588
703
  clearInterval(idleT);
589
704
  if (idleClosed)
@@ -599,6 +714,7 @@ async function runJob(file) {
599
714
  return 0;
600
715
  }
601
716
  const log = createWriteStream(`${O}.log`);
717
+ let spawnErr;
602
718
  if (!pre())
603
719
  rc = 2;
604
720
  else {
@@ -627,6 +743,7 @@ async function runJob(file) {
627
743
  rc = await runCapped(j.argv[0], j.argv.slice(1), {
628
744
  cap: j.cap, swap: j.swap, mode: j.mode, unit: j.unit, cwd: j.dir, env, quote: true,
629
745
  stdio: [j.stdin ? openSync(j.prompt, "r") : "ignore", "pipe", "pipe"],
746
+ onError: (e) => { spawnErr = e; log.write(`[orq agent] could not start ${j.argv[0]}: ${e.message}\n`); },
630
747
  onSpawn: (c) => {
631
748
  streams = Promise.all([lines(c.stdout, (l) => onLine(l, true)), lines(c.stderr, (l) => onLine(l, false))]);
632
749
  // OpenCode sometimes doesn't exit after its final event (helpers hold the pipe): stop it after 30 s of quiet.
@@ -650,8 +767,13 @@ async function runJob(file) {
650
767
  await Promise.race([streams, sleep(5000)]);
651
768
  }
652
769
  await new Promise((r) => log.end(r));
653
- post();
654
770
  const res = fmt.finish(logText, stdout);
771
+ post(j.answer === "file" ? (existsSync(`${O}.out.md`) ? readFileSync(`${O}.out.md`, "utf8") : "") : res.answer ?? "");
772
+ if (committed)
773
+ res.answer = `${res.answer ?? ""}\n## Commit (by orq)\n${committed}\n`;
774
+ const scope = j.scopeNote ? `\n## Review scope (by orq)\n${j.scopeNote}\n` : "";
775
+ if (scope)
776
+ res.answer = `${res.answer ?? ""}${scope}`;
655
777
  // Denied commands do not sink a write run that did work: keep the diff and report ok with the denied list in the answer.
656
778
  let recovered = false;
657
779
  if (res.denied?.length && !j.ro && !j.throwaway && existsSync(j.dir)) {
@@ -679,6 +801,11 @@ async function runJob(file) {
679
801
  }
680
802
  if (j.answer !== "file")
681
803
  writeFileSync(`${O}.out.md`, res.answer ?? "");
804
+ else if (committed || scope)
805
+ appendFileSync(`${O}.out.md`, `${committed ? `\n## Commit (by orq)\n${committed}\n` : ""}${scope}`);
806
+ // The agent never started: say why in the answer (the conductor reads it) instead of leaving it empty.
807
+ if (spawnErr)
808
+ writeFileSync(`${O}.out.md`, spawnFailText(j, spawnErr));
682
809
  const sid = j.fromLog ? new RegExp(j.fromLog, "m").exec(logText)?.[1] : res.session ?? j.session;
683
810
  if (sid)
684
811
  writeFileSync(`${O}.id`, `${sid}\n`);
@@ -704,12 +831,14 @@ async function runJob(file) {
704
831
  // fallback and the conductor must see it as not run, same as a whitespace-only answer.
705
832
  const noReview = noReviewMatch(j.noReview, answer);
706
833
  // killed (SIGKILL, memory cap...) is its own status: never ok, even if an ok_exit list or a leftover answer says otherwise.
707
- const st = killedRc(rc) ? "killed" : verdict && !noReview && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
708
- writeAtomic(`${O}.done`, `status=${st} rc=${rc} at=${new Date().toISOString()} agent=${j.agent}\n`);
834
+ const noVerdict = !spawnErr && Boolean(j.review) && verdict && !noReview && /\S/.test(answer) && !hasVerdict(answer);
835
+ const st = spawnErr ? "fail" : killedRc(rc) ? "killed" : verdict && !noReview && !noVerdict && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
836
+ const why = spawnErr ? ` reason=spawn:${spawnErr.code ?? "error"}` : noVerdict ? " reason=no-verdict" : "";
837
+ writeAtomic(`${O}.done`, `status=${st} rc=${rc}${why} at=${new Date().toISOString()} agent=${j.agent}\n`);
709
838
  rmSync(`${O}.pid`, { force: true });
710
839
  state({ status: st, rc, end: new Date().toISOString() });
711
840
  if (j.parent)
712
- notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=${rc} answer: ${O}.out.md`);
841
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=${rc}${why} answer: ${O}.out.md`);
713
842
  console.log(`\n[${j.agent} ${m("finished", "terminou")}: exit ${rc} status=${st}] ${m("answer", "resposta")}: ${O}.out.md`);
714
843
  if (j.host !== "plain" && !j.keep) {
715
844
  await sleep(j.closeAfter * 1000);
@@ -466,7 +466,7 @@ export function render(s, theme, cols, mode = colorMode(), hideH = HIDE_H) {
466
466
  if (top.length)
467
467
  out.push(` ${c.mut}${m("role", "papel").padEnd(7)} ${m("agent", "agente").padEnd(12)} ${m("model", "modelo").padEnd(20)} ${"n".padStart(4)} ${"ok%".padStart(5)} ${m("approved%", "aprov%").padStart(10)} ${"min".padStart(6)}${c.r}`);
468
468
  for (const x of top)
469
- out.push(` ${c.ink2}${x.role.padEnd(7)}${c.r} ${c.ink}${fit(x.agent, 12)}${c.r} ${c.mut}${fit(x.model, 20)}${c.r} ${c.ink}${String(x.n).padStart(4)} ${String(x.ok).padStart(5)} ${(x.approved === null ? "-" : `${x.approved}%`).padStart(10)} ${String(x.min).padStart(6)}${c.r}`);
469
+ out.push(` ${c.ink2}${x.role.padEnd(7)}${c.r} ${c.ink}${fit(x.agent, 12)}${c.r} ${c.mut}${fit(x.model, 20)}${c.r} ${c.ink}${String(x.n).padStart(4)} ${String(x.ok).padStart(5)} ${(x.approved === null ? "-" : `${x.approved}%`).padStart(10)} ${String(x.min ?? "-").padStart(6)}${c.r}`);
470
470
  if (!top.length)
471
471
  out.push(` ${c.mut}${m("no runs yet", "nenhuma execução ainda")}${c.r}`);
472
472
  out.push("", `${c.rul}${"─".repeat(W - 2)}${c.r}`, ` ${c.mut}${m("q quit · r quota · l language · t theme", "q sai · r cota · l idioma · t tema")}${c.r}`);
@@ -15,6 +15,12 @@ import { acquire, release } from "../lock.js";
15
15
  import { handoffFile } from "../notice.js";
16
16
  export const USAGE = `orq claim [-w <workspace>] <ID> | -f <ID> | -r <ID> | -l | --register issue owner among conductors (exit 1 = someone else's)
17
17
  orq claim [-w <workspace>] --from <name> [-f] a conductor's issues by its name (part of it is enough); -f takes them all`;
18
+ /** The Claude session name of a conductor of `ws` (what ListAgents shows): conductor-<workspace folder>, plus
19
+ * :<name> (its herdr label or ORQ_CONDUCTOR_ID, slugged) so several conductors on one workspace can be told apart. */
20
+ export function sessionName(ws, name = "") {
21
+ const slug = name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
22
+ return `conductor-${basename(ws)}${slug ? `:${slug}` : ""}`;
23
+ }
18
24
  const field = (text, k) => new RegExp(`${k}=(\\S+)`).exec(text)?.[1] ?? "";
19
25
  export const nameOf = (text) => / name=(.*)$/m.exec(text)?.[1].trim() ?? "";
20
26
  const herdrList = (args) => {
@@ -127,7 +133,8 @@ export async function claim(argv) {
127
133
  // The herdr workspace's current label while the pane is alive; the name stored at claim time otherwise
128
134
  // (the user may have renamed the workspace since, or it may not be a herdr conductor at all).
129
135
  const n = (alive && (field(t, "kind") || "herdr") === "herdr" ? names.get(field(t, "pane")) ?? "" : "") || nameOf(t);
130
- console.log(`${d} ${n ? `"${n}" ` : ""}${t.replace(/ name=.*$/, "")} (${alive ? "alive" : "dead"})`);
136
+ console.log(`${d} ${n ? `"${n}" ` : ""}${t.replace(/ name=.*$/, "")} (${alive ? "alive" : "dead"})`
137
+ + ` session=${sessionName(ws, n)}`);
131
138
  }
132
139
  return 0;
133
140
  }
@@ -2,14 +2,14 @@
2
2
  // instructions, the chosen account and model (port of setup/herdr-conductor, for any terminal and OS).
3
3
  // orq conductor [account|-] [model] [-r <session>]
4
4
  // Workspace: the current folder if it has .orquestra-conductor.md, else $CONDUCTOR_WS. $CONDUCTOR_FILE overrides the
5
- // instructions file. The session is named conductor-<workspace folder>.
5
+ // instructions file. The session is named conductor-<workspace folder>:<herdr label or ORQ_CONDUCTOR_ID> (see sessionName).
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { existsSync } from "node:fs";
8
- import { basename, join, resolve } from "node:path";
8
+ import { join, resolve } from "node:path";
9
9
  import { claudeAccounts } from "../accounts.js";
10
10
  import { m } from "../i18n.js";
11
11
  import { runCapped } from "../process.js";
12
- import { claim } from "./claim.js";
12
+ import { claim, herdrName, sessionName } from "./claim.js";
13
13
  export const USAGE = `orq conductor [account|-] [model] [-r <session>] open the conductor (Claude) here: instructions, account, model
14
14
  model: sonnet | opus | haiku | a full id; "-" as account = default account; -r resumes that conversation`;
15
15
  export const MODELS = {
@@ -48,8 +48,8 @@ export function findWorkspace(cwd, env, exists = existsSync) {
48
48
  }
49
49
  export const instructionsFile = (ws, env) => env.CONDUCTOR_FILE ? resolve(env.CONDUCTOR_FILE) : join(ws, ".orquestra-conductor.md");
50
50
  /** The claude command line (without the executable). The instructions go by file, so no shell ever sees their text. */
51
- export function claudeArgs(ws, a, file) {
52
- return [...(a.resume ? ["--resume", a.resume] : []), "-n", `conductor-${basename(ws)}`,
51
+ export function claudeArgs(ws, a, file, name = "") {
52
+ return [...(a.resume ? ["--resume", a.resume] : []), "-n", sessionName(ws, name),
53
53
  ...(a.model ? ["--model", modelId(a.model)] : []), "--append-system-prompt-file", file];
54
54
  }
55
55
  export async function conductor(argv) {
@@ -100,5 +100,6 @@ export async function conductor(argv) {
100
100
  delete process.env.CLAUDE_CONFIG_DIR;
101
101
  else
102
102
  process.env.CLAUDE_CONFIG_DIR = saved;
103
- return runCapped("claude", claudeArgs(ws, a, file), { mode: "off", env, cwd: ws, quote: true, tty: true });
103
+ const name = (process.env.HERDR_PANE_ID ? herdrName(process.env.HERDR_PANE_ID) : "") || process.env.ORQ_CONDUCTOR_ID || "";
104
+ return runCapped("claude", claudeArgs(ws, a, file, name), { mode: "off", env, cwd: ws, quote: true, tty: true });
104
105
  }
@@ -5,6 +5,7 @@
5
5
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
+ import { isCommitBase, missingBinary } from "../agents.js";
8
9
  import { loadConfig } from "../config.js";
9
10
  import { m } from "../i18n.js";
10
11
  import { asArgs, escalate, parseQuota, modelFor, parseWindows, pick as choose } from "../pick.js";
@@ -14,7 +15,7 @@ import { PICK_ROLES, resolveRole, vendorOf } from "../roles.js";
14
15
  import { collectRuns, rows } from "../stats.js";
15
16
  import { load as loadBlocks } from "./block.js";
16
17
  export const USAGE = `orq pick <impl|fix|worker|review|opinion|watchdog> [--level simple|medium|heavy] [--impl-vendor <v>|--impl-agent <a>]
17
- [--exclude-agent <a>]... [-w <workspace>] [--explain] [--no-record]
18
+ [--exclude-agent <a>]... [-w <workspace>] [--base <ref>] [--explain] [--no-record]
18
19
  orq pick fix --same <impl pick file> same agent/model/account as the implementer, effort one level up`;
19
20
  // The board refreshes this file; fresher than 10 min → no new quota requests.
20
21
  const QCACHE = join(process.env.XDG_RUNTIME_DIR ?? tmpdir(), "herdr-board.quota");
@@ -48,7 +49,7 @@ export async function pick(argv) {
48
49
  return 0;
49
50
  }
50
51
  let level = "medium", implVendor, implAgent, ws;
51
- let explain = false, record = true;
52
+ let explain = false, record = true, base;
52
53
  const exclude = new Set();
53
54
  for (let i = 1; i < argv.length; i++) {
54
55
  const a = argv[i];
@@ -62,6 +63,8 @@ export async function pick(argv) {
62
63
  exclude.add(argv[++i]);
63
64
  else if (a === "-w")
64
65
  ws = expand(argv[++i]);
66
+ else if (a === "--base")
67
+ base = argv[++i];
65
68
  else if (a === "--explain")
66
69
  explain = true;
67
70
  else if (a === "--no-record")
@@ -83,7 +86,7 @@ export async function pick(argv) {
83
86
  // Eligible, not just declared: an agent that is excluded, blocked or has no model for this role/level can't be picked.
84
87
  const blocked = loadBlocks();
85
88
  const declared = r.mode === "auto"
86
- ? r.pool.filter((n) => agents[n] && !exclude.has(n) && !(n in blocked) && modelFor(agents[n], role, level)) : [];
89
+ ? r.pool.filter((n) => agents[n] && !exclude.has(n) && !(n in blocked) && !missingBinary(cfg, n) && modelFor(agents[n], role, level)) : [];
87
90
  if (declared.length > 1) {
88
91
  console.error(`orq pick: ${m(`more than one agent eligible for ${role} (${declared.join(", ")}) before quota checks: pass --impl-agent or --impl-vendor`, `mais de um agente elegível para ${role} (${declared.join(", ")}) antes da checagem de quota: informe --impl-agent ou --impl-vendor`)}`);
89
92
  return 2;
@@ -102,7 +105,16 @@ export async function pick(argv) {
102
105
  } })
103
106
  .filter((p) => p.role === role).slice(-window);
104
107
  const qt = await quotaText(cfg);
105
- const res = choose({ cfg, role, level, implVendor, implAgent, exclude, stats, quota: parseQuota(qt), windows: parseWindows(qt),
108
+ // A review of a commit base (a round delta) leaves out agents that can't diff against one (agents.<name>.base_commit).
109
+ const commitBase = role === "review" && Boolean(base) && isCommitBase((ws ?? cfg._workspace ?? process.cwd()), base);
110
+ const r0 = resolveRole(cfg, role);
111
+ const missing = new Map();
112
+ for (const n of r0.mode === "fixed" ? r0.order : r0.pool) {
113
+ const why = agents[n] && missingBinary(cfg, n);
114
+ if (why)
115
+ missing.set(n, why);
116
+ }
117
+ const res = choose({ cfg, role, level, implVendor, implAgent, exclude, commitBase, missing, stats, quota: parseQuota(qt), windows: parseWindows(qt),
106
118
  blocks: new Set(Object.keys(loadBlocks())), recent });
107
119
  if (!res.best) {
108
120
  console.error(`orq pick: ${m("no candidate for ", "nenhum candidato para ")}${role}`
@@ -23,6 +23,7 @@ const why = (r) => ({
23
23
  at_max: m(`rounds ${r.n} = max_rounds ${r.max}`, `rodadas ${r.n} = max_rounds ${r.max}`),
24
24
  same_finding: m(`same finding in ${r.n} reviews`, `mesmo achado em ${r.n} revisões`),
25
25
  repeated: m(`${r.n} repeated finding(s)`, `${r.n} achado(s) repetido(s)`),
26
+ fresh: m(`new heavy findings in fix rounds ${r.rounds?.join(", ")}: split the issue`, `achados graves novos nas rodadas ${r.rounds?.join(", ")}: divida a issue`),
26
27
  slow: m(`round ${r.rounds?.join(", ")} much slower than the others`, `rodada ${r.rounds?.join(", ")} bem mais lenta que as outras`),
27
28
  })[r.code];
28
29
  export async function rounds(argv) {
@@ -65,6 +66,8 @@ export async function rounds(argv) {
65
66
  }
66
67
  console.log(`${id} ${mark(x)} ${m("alert", "alerta")}: ${x.level}${x.reasons.length ? ` (${x.reasons.map(why).join("; ")})` : ""}`);
67
68
  console.log(m(`rounds ${x.rounds} of max ${x.max}; total time ${dur(x.secs)}; ${x.findings} finding(s) across reviews`, `rodadas ${x.rounds} de no máx. ${x.max}; tempo total ${dur(x.secs)}; ${x.findings} achado(s) nas revisões`));
69
+ if (x.followups)
70
+ console.log(m("follow-ups (only review counts): ", "follow-ups (só review conta): ") + Object.entries(x.followups).map(([k, n]) => `${k} ${n}`).join(", "));
68
71
  for (const r of x.perRound)
69
72
  console.log(` ${r.round === 0 ? m("impl + review", "impl + revisão") : m(`fix ${r.round} + review`, `fix ${r.round} + revisão`)}: ${dur(r.secs)}`);
70
73
  if (x.repeats.length)
@@ -9,8 +9,9 @@
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { copyFileSync, existsSync, readFileSync } from "node:fs";
11
11
  import { homedir, totalmem } from "node:os";
12
- import { delimiter, join } from "node:path";
12
+ import { join } from "node:path";
13
13
  import { createInterface } from "node:readline/promises";
14
+ import { onPath } from "../agents.js";
14
15
  import { writeAtomic } from "../fsutil.js";
15
16
  import { banner, infoLines } from "../banner.js";
16
17
  import { getPath, loadConfig } from "../config.js";
@@ -28,11 +29,6 @@ const CLIS = {
28
29
  antigravity: { bin: "agy", test: 'agy -p "reply ok"' },
29
30
  copilot: { bin: "copilot", test: 'copilot -p "reply ok" -s' },
30
31
  };
31
- /** Is `bin` on PATH? (PATHEXT on Windows: claude.cmd, codex.exe…) */
32
- export function onPath(bin) {
33
- const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
34
- return (process.env.PATH ?? "").split(delimiter).some((d) => d && exts.some((e) => existsSync(join(d, bin + e))));
35
- }
36
32
  export const detect = () => Object.keys(CLIS).filter((n) => onPath(CLIS[n].bin));
37
33
  function write(toml) {
38
34
  const f = userConfigFile();
@@ -3,7 +3,7 @@ import { getPath, loadConfig } from "../config.js";
3
3
  import { m } from "../i18n.js";
4
4
  import { expand } from "../paths.js";
5
5
  import { collectRuns, rows } from "../stats.js";
6
- export const USAGE = "orq stats [-w <workspace>] [--days N] [--json] scoreboard: delivery, time and tokens per agent";
6
+ export const USAGE = "orq stats [-w <workspace>] [--days N] [--json] scoreboard per agent and model: delivery, time, tokens";
7
7
  const k = (n) => (n === null ? "-" : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
8
8
  const ORDER = ["impl", "fix", "review", "watchdog", "other"];
9
9
  export function stats(argv) {
@@ -35,7 +35,7 @@ export function stats(argv) {
35
35
  for (const x of [...rs].sort((a, b) => ORDER.indexOf(a.role) - ORDER.indexOf(b.role) || b.n - a.n)) {
36
36
  const ap = x.approved !== null ? `${x.approved}% (${x.n_appr})` : "-";
37
37
  console.log(`${x.role.padEnd(7)} ${x.agent.padEnd(12)} ${x.model.slice(0, 22).padEnd(22)} ${String(x.n).padStart(4)} `
38
- + `${String(x.ok).padStart(5)} ${ap.padStart(12)} ${String(x.min).padStart(6)} ${k(x.new).padStart(10)} ${k(x.tot).padStart(10)}`);
38
+ + `${String(x.ok).padStart(5)} ${ap.padStart(12)} ${String(x.min ?? "-").padStart(6)} ${k(x.new).padStart(10)} ${k(x.tot).padStart(10)}`);
39
39
  }
40
40
  return 0;
41
41
  }
package/dist/diagnose.js CHANGED
@@ -18,6 +18,7 @@ const reason = (r) => ({
18
18
  at_max: `rounds ${r.n} reached max_rounds ${r.max}`,
19
19
  same_finding: `the same heavy finding came back in ${r.n} reviews`,
20
20
  repeated: `${r.n} heavy finding(s) repeated across reviews`,
21
+ fresh: `the reviews of fix rounds ${r.rounds?.join(", ")} raised new heavy findings outside what was fixed (scope too big?)`,
21
22
  slow: `round ${r.rounds?.join(", ")} was much slower than the others`,
22
23
  })[r.code];
23
24
  const mins = (s) => `${Math.round(s / 60)} min`;
@@ -39,7 +40,8 @@ export function buildPrompt(x, fixes, reviews, cap = DIAG_CAP) {
39
40
  `## What to decide`,
40
41
  `Look for the real cause, for example: reviewer and implementer disagree about the requirement or the style; the scope is too big for one`,
41
42
  `branch; a flaky or environment-dependent test; an ambiguous spec or acceptance criterion; fixes that fix the symptom of each review and`,
42
- `introduce the next one; a reviewer that keeps raising new things instead of checking the previous ones.`,
43
+ `introduce the next one; a reviewer that keeps raising new things instead of checking the previous ones (re-reading the whole branch every
44
+ round: findings outside the fix diff don't make a round, they go to a follow-up list — recommend split, not continue).`,
43
45
  ``,
44
46
  `## Answer format`,
45
47
  `A short diagnosis (at most 15 lines): the cause, with the evidence (which round, which file or finding). Then, as the LAST line, exactly:`,