@irythill/orq 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -17
- package/README.pt-BR.md +35 -18
- package/dist/agents.js +75 -2
- package/dist/commands/agent.js +173 -44
- package/dist/commands/board.js +1 -1
- package/dist/commands/claim.js +8 -1
- package/dist/commands/conductor.js +7 -6
- package/dist/commands/pick.js +16 -4
- package/dist/commands/rounds.js +3 -0
- package/dist/commands/setup.js +2 -6
- package/dist/commands/stats.js +2 -2
- package/dist/diagnose.js +3 -1
- package/dist/pick.js +73 -42
- package/dist/process.js +28 -7
- package/dist/rounds.js +40 -4
- package/dist/stats.js +55 -14
- package/package.json +4 -4
- package/skills/issue/SKILL.md +18 -7
package/dist/commands/agent.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
|
708
|
-
|
|
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);
|
package/dist/commands/board.js
CHANGED
|
@@ -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}`);
|
package/dist/commands/claim.js
CHANGED
|
@@ -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 {
|
|
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",
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/pick.js
CHANGED
|
@@ -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
|
-
|
|
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}`
|
package/dist/commands/rounds.js
CHANGED
|
@@ -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)
|
package/dist/commands/setup.js
CHANGED
|
@@ -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 {
|
|
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();
|
package/dist/commands/stats.js
CHANGED
|
@@ -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
|
|
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:`,
|