@irythill/orq 0.6.0 → 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.
@@ -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}`
@@ -34,7 +34,7 @@ export function humanizeQuotaLine(line, now = Date.now()) {
34
34
  * `policy.quota_week`) — the same thresholds `orq pick` applies for an unspecified task level; ⊘ blocked
35
35
  * (orq block), ? no data; sources with no window at all ("-" and "-") are left out. */
36
36
  export function quotaLine(lines, limit5, limitWeek, blocked, age) {
37
- const parts = lines.filter((q) => q.five !== "-" || q.week !== "-").map((q) => {
37
+ const parts = lines.filter((q) => !q.name.includes("/") && (q.five !== "-" || q.week !== "-")).map((q) => {
38
38
  if (blocked.has(q.name))
39
39
  return `${q.name} ⊘`;
40
40
  const five = pct(q.five), week = pct(q.week);
@@ -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)
@@ -96,37 +99,64 @@ async function diagnoseIssue(ws, id, x) {
96
99
  }
97
100
  const ev = evidence(dir);
98
101
  writeFileSync(`${prefix}.md`, buildPrompt(x, ev.fixes, ev.reviews));
99
- const p = orq("pick", "watchdog", "--level", "simple", "-w", ws, ...(implPick ? ["--impl-agent", implPick] : []));
100
- if (p.status !== 0) {
101
- console.error(p.stderr.trim() || `orq pick watchdog: exit ${p.status}`);
102
- return p.status ?? 1;
103
- }
104
- const picked = p.stdout.trim().split(/\s+/);
105
- // The agent runs in a scratch folder (everything it needs is in the prompt): it can't touch the issue files, even write sandboxed.
106
- const scratch = mkdtempSync(join(tmpdir(), "orq-watchdog-"));
102
+ // A failed watchdog (e.g. a model catalog timeout) gets one retry with the next candidate, never the same agent.
103
+ const excluded = [];
104
+ let first;
105
+ const scratches = [];
107
106
  try {
108
- const a = orq("agent", ...picked, "-r", "-t", `${id} watchdog`, "-d", scratch, "-g", "watchdog", "-o", prefix, "--", `${prefix}.md`);
109
- process.stderr.write(a.stderr);
110
- process.stdout.write(a.stdout);
111
- if (a.status !== 0)
112
- return a.status ?? 1;
113
- return await finish(await wait(["--timeout", String(WATCHDOG_TIMEOUT), prefix]), prefix, out);
107
+ for (let attempt = 0; attempt < 2; attempt++) {
108
+ const p = orq("pick", "watchdog", "--level", "simple", "-w", ws, ...(implPick ? ["--impl-agent", implPick] : []), ...excluded.flatMap((e) => ["--exclude-agent", e]));
109
+ if (p.status !== 0) {
110
+ if (first) {
111
+ console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
112
+ return first.rc;
113
+ }
114
+ console.error(p.stderr.trim() || `orq pick watchdog: exit ${p.status}`);
115
+ return p.status ?? 1;
116
+ }
117
+ const picked = p.stdout.trim().split(/\s+/), agent = /(?:^|\s)-a (\S+)/.exec(p.stdout)?.[1];
118
+ // The agent runs in a scratch folder (everything it needs is in the prompt): it can't touch the issue files, even write sandboxed.
119
+ const scratch = mkdtempSync(join(tmpdir(), "orq-watchdog-"));
120
+ scratches.push(scratch);
121
+ const run = `${prefix}-${attempt}`;
122
+ {
123
+ writeFileSync(`${run}.md`, readFileSync(`${prefix}.md`));
124
+ const a = orq("agent", ...picked, "-r", "-t", `${id} watchdog`, "-d", scratch, "-g", "watchdog", "-o", run, "--", `${run}.md`);
125
+ process.stderr.write(a.stderr);
126
+ process.stdout.write(a.stdout);
127
+ const rc = a.status !== 0 ? a.status ?? 1 : await finish(await wait(["--timeout", String(WATCHDOG_TIMEOUT), run]), run, out, agent, attempt === 1 || !agent);
128
+ if (rc === 0 || rc === 124 || !agent)
129
+ return rc;
130
+ if (attempt === 0)
131
+ console.error(m(`watchdog ${agent} failed; retrying with another agent`, `watchdog ${agent} falhou; tentando com outro agente`));
132
+ first = { rc };
133
+ excluded.push(agent);
134
+ }
135
+ }
136
+ return first?.rc ?? 1;
137
+ // Removed only after every attempt, and best-effort: on Windows a just-finished agent can still hold its cwd (EPERM/EBUSY).
114
138
  }
115
139
  finally {
116
- rmSync(scratch, { recursive: true, force: true });
140
+ for (const s of scratches) {
141
+ try {
142
+ rmSync(s, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
143
+ }
144
+ catch { /* temp dir: the OS cleans it */ }
145
+ }
117
146
  }
118
147
  }
119
- async function finish(rc, prefix, out) {
148
+ async function finish(rc, prefix, out, agent, final = true) {
120
149
  if (rc === 124) {
121
150
  console.error(m(`diagnosis not written: the watchdog agent did not finish in ${WATCHDOG_TIMEOUT}s`, `diagnóstico não gravado: o agente watchdog não terminou em ${WATCHDOG_TIMEOUT}s`));
122
151
  return 124;
123
152
  }
124
153
  if (rc !== 0 || !existsSync(`${prefix}.out.md`)) {
125
- console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
154
+ if (final)
155
+ console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
126
156
  return 1;
127
157
  }
128
158
  copyFileSync(`${prefix}.out.md`, out);
129
- console.log(m(`diagnosis saved to ${out}`, `diagnóstico gravado em ${out}`));
159
+ console.log(m(`diagnosis saved to ${out}${agent ? ` (by ${agent})` : ""}`, `diagnóstico gravado em ${out}${agent ? ` (por ${agent})` : ""}`));
130
160
  console.log(recommendation(readFileSync(out, "utf8")) ?? m("(no RECOMMENDATION line in the answer)", "(sem linha RECOMMENDATION na resposta)"));
131
161
  return 0;
132
162
  }
@@ -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/config.js CHANGED
@@ -32,6 +32,9 @@ export const DEFAULTS = {
32
32
  // Where quota comes from, per agent: "builtin" | "none" | false | { command = "..." } (see src/quota.ts).
33
33
  // "claude" covers every account in claude-accounts.
34
34
  quota: { claude: "builtin", codex: "builtin", opencode: "builtin", antigravity: "builtin", copilot: "builtin" },
35
+ // Issue ID in a branch/worktree name (orq gc, orq ci): case-insensitive regex, the named group "issue" (else group 1) is the ID.
36
+ // The default finds <LETTERS>-<DIGITS> anywhere (feature/ABC-123, abc-123-fix, plane-abc-123-api-2); suffixes are ignored.
37
+ git: { issue_pattern: "(?<![a-z0-9])(?<issue>[a-z][a-z0-9]*-\\d+)(?![a-z0-9])" },
35
38
  tracker: { kind: "none" }, // plane | none
36
39
  repos: {}, // defined by the workspace
37
40
  };
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:`,
package/dist/install.js CHANGED
@@ -89,6 +89,11 @@ export function prepareWorkspace(dir, fmt, name = basename(resolve(dir))) {
89
89
  }
90
90
  else
91
91
  out.push(m(` ok ${map} (kept)`, ` ok ${map} (mantido)`));
92
+ // The conductor's instructions (what `orq conductor` looks for): regenerated on every run.
93
+ const ci = join(ws, ".orquestra-conductor.md");
94
+ const t = readFileSync(join(PKG, "templates", "conductor.instructions.md"), "utf8");
95
+ writeAtomic(ci, t.split("{{WORKSPACE}}").join(ws).split("{{NAME}}").join(name));
96
+ out.push(` ${m("wrote", "escrevi")} ${ci}`);
92
97
  const r = spawnSync(process.execPath, [join(PKG, "dist", "cli.js"), "instructions", fmt, "-w", ws], { encoding: "utf8" });
93
98
  out.push(` ${(r.stdout || r.stderr).trim()}`);
94
99
  return out;
package/dist/issue.js ADDED
@@ -0,0 +1,47 @@
1
+ // The issue a branch or worktree belongs to, and which issues have an agent really running.
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { getPath, loadConfig } from "./config.js";
5
+ import { alive } from "./lock.js";
6
+ import { stateDir } from "./paths.js";
7
+ /** Finds "<LETTERS>-<DIGITS>" anywhere in the name, as its own token; anything after the number is a suffix. */
8
+ export const DEFAULT_ISSUE_PATTERN = "(?<![a-z0-9])(?<issue>[a-z][a-z0-9]*-\\d+)(?![a-z0-9])";
9
+ /** The issue ID (as written in the name) from a branch or worktree name like "me/plane-proj-68-api-2" or
10
+ * "feature/ABC-123". `pattern` is [git] issue_pattern: a case-insensitive regex whose named group "issue"
11
+ * (else group 1, else the whole match) is the ID. */
12
+ export function issueFromName(name, pattern = DEFAULT_ISSUE_PATTERN) {
13
+ let re;
14
+ try {
15
+ re = new RegExp(pattern, "i");
16
+ }
17
+ catch (e) {
18
+ throw new Error(`git.issue_pattern: ${e.message}`);
19
+ }
20
+ const r = re.exec(name);
21
+ return r ? (r.groups?.issue ?? r[1] ?? r[0]) : undefined;
22
+ }
23
+ /** [git] issue_pattern of the workspace's merged config (default when unset). */
24
+ export function issuePattern(ws) {
25
+ const v = getPath(loadConfig(ws).cfg, "git.issue_pattern");
26
+ return typeof v === "string" && v ? v : DEFAULT_ISSUE_PATTERN;
27
+ }
28
+ /** Issue directories (basename of the run prefix's folder) with an agent whose state says running and whose pid is
29
+ * alive: the same rule the board uses for "running". */
30
+ export function issuesWithLiveAgent() {
31
+ const dir = join(stateDir(), "agents"), out = new Set();
32
+ let files = [];
33
+ try {
34
+ files = readdirSync(dir).filter((f) => f.endsWith(".json"));
35
+ }
36
+ catch { /* no runs yet */ }
37
+ for (const f of files) {
38
+ try {
39
+ const s = JSON.parse(readFileSync(join(dir, f), "utf8"));
40
+ if (s.status === "ok" || s.status === "fail" || s.status === "killed" || !alive(Number(s.pid)))
41
+ continue;
42
+ out.add(basename(dirname(String(s.prefix ?? ""))));
43
+ }
44
+ catch { /* unreadable state: ignore */ }
45
+ }
46
+ return out;
47
+ }
package/dist/pick.js CHANGED
@@ -3,8 +3,9 @@
3
3
  // for an agent the user didn't list.
4
4
  // auto role: every pool agent that passes is scored:
5
5
  // quality * delivery + tokens * economy + speed * quickness − rotation * (picks in the last `window`)
6
- // − reserve (agents with reserve = true) + explore (fewer than `min_samples` runs)
7
- // Filters: --exclude-agent, orq block, nobody reviews their own code (unless no other agent can), cross-vendor rule, a model for the role/level,
6
+ // − reserve (agents with reserve = true) + explore (fewer than `min_samples` runs, fading with n, none after
7
+ // `explore_streak` picks in a row). Each axis is (prior·k + observed·n)/(k + n), k = min_samples.
8
+ // Filters: --exclude-agent, orq block, a CLI that doesn't resolve on PATH, nobody reviews their own code (unless no other agent can), cross-vendor rule, a model for the role/level,
8
9
  // quota under the limits: policy.quota_week (95) on the weekly window, and on the 5h one policy.quota_5h per level
9
10
  // (simple 90, medium 85, heavy 80): a heavy task needs more room left to finish.
10
11
  // An agent's `headroom` (points) lowers both limits for it: that share of its accounts stays for the conductors.
@@ -12,7 +13,7 @@
12
13
  // Pacing: an account whose usage, at the pace so far in a window (used ÷ elapsed share), projects past
13
14
  // that window's pick limit (the filter's) at its reset is passed over for another account under the limit when the agent has several,
14
15
  // and otherwise loses policy.pacing_penalty (default 0.3, 0 = off) of score — soft, never a drop.
15
- import { agentDef, usesPrompt } from "./agents.js";
16
+ import { agentDef, baseCommit, usesPrompt } from "./agents.js";
16
17
  import { m } from "./i18n.js";
17
18
  import { humanReset } from "./quota.js";
18
19
  import { legacyRole, resolveRole, vendorOf } from "./roles.js";
@@ -84,8 +85,47 @@ export function modelFor(a, role, level) {
84
85
  const [model, effort] = spec.split("@");
85
86
  return [model, effort || undefined];
86
87
  }
87
- const WEIGHTS = { quality: 0.4, tokens: 0.45, speed: 0.15, rotation: 0.2, reserve: 0.3, explore: 0.1, min_samples: 8, window: 6 };
88
+ // --- per-model quota ---------------------------------------------------------------------------------------------------
89
+ // A collector may add lines "<account>/<model> 5h=.. week=.." (antigravity: one bucket per model group). The model a pick is about
90
+ // to choose is checked against its own line, and an exhausted one gives way to a lighter model the agent has configured.
91
+ const LEVEL_RANK = { simple: 0, medium: 1, default: 1, heavy: 2 };
92
+ /** The quota line key for a configured model: the exact id, else the id without its -low/-medium/-high suffix, plain or
93
+ * as "<base>-tiered" (one bucket serves all three efforts of a tiered model). */
94
+ export function modelKey(model, keys) {
95
+ const id = model.split("/").pop() ?? model;
96
+ const base = id.replace(/-(low|medium|high)$/, "");
97
+ return [id, base, `${base}-tiered`].find((k) => keys.includes(k));
98
+ }
99
+ /** The models of an agent's role section lighter than `level`, the closest first, without repeats of `tried`. */
100
+ export function lighterModels(a, role, level, tried) {
101
+ const models = a.models;
102
+ if (!models)
103
+ return [];
104
+ const lr = legacyRole(role);
105
+ const ms = models[lr] ?? (lr === "fix" ? models.impl : lr === "watchdog" ? models.opinion ?? models.review : undefined) ?? {};
106
+ const rank = LEVEL_RANK[level] ?? 1, seen = new Set(tried);
107
+ const out = [];
108
+ for (const [, spec] of Object.entries(ms).filter(([lv]) => (LEVEL_RANK[lv] ?? 1) < rank)
109
+ .sort((x, y) => (LEVEL_RANK[y[0]] ?? 1) - (LEVEL_RANK[x[0]] ?? 1))) {
110
+ const [model, effort] = spec.split("@");
111
+ if (seen.has(model))
112
+ continue;
113
+ seen.add(model);
114
+ out.push([model, effort || undefined]);
115
+ }
116
+ return out;
117
+ }
118
+ const WEIGHTS = { quality: 0.4, tokens: 0.45, speed: 0.15, rotation: 0.2, reserve: 0.3, explore: 0.1, explore_streak: 2, min_samples: 8, window: 6 };
88
119
  export const weights = (cfg) => ({ ...WEIGHTS, ...((cfg.policy ?? {}).weights ?? {}) });
120
+ /** The merged definition (built-in + config) can review a commit base (agents.<name>.base_commit); an agent that can't be resolved can't. */
121
+ function takesBaseCommit(cfg, name) {
122
+ try {
123
+ return baseCommit(agentDef(cfg, name));
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
89
129
  /** The merged definition (built-in + config) takes a prompt; an agent that can't be resolved doesn't. */
90
130
  function takesPrompt(cfg, name) {
91
131
  try {
@@ -125,6 +165,11 @@ export function pick(inp) {
125
165
  out(name, m("blocked (orq block -l)", "bloqueado (orq block -l)"));
126
166
  continue;
127
167
  }
168
+ const gone = inp.missing?.get(name);
169
+ if (gone) {
170
+ out(name, gone);
171
+ continue;
172
+ }
128
173
  if ((role === "review" || role === "opinion" || role === "watchdog") && name === inp.implAgent && someoneElse) {
129
174
  out(name, m("it implemented this code", "foi quem implementou"));
130
175
  continue;
@@ -134,6 +179,10 @@ export function pick(inp) {
134
179
  out(name, m("same vendor as the implementer", "mesmo fornecedor do implementador"));
135
180
  continue;
136
181
  }
182
+ if (role === "review" && inp.commitBase && !takesBaseCommit(cfg, name)) {
183
+ out(name, m("can't review a commit base (base_commit = false)", "não revisa uma base que é commit (base_commit = false)"));
184
+ continue;
185
+ }
137
186
  if (role === "watchdog" && !takesPrompt(cfg, name)) {
138
187
  out(name, m("ignores prompts (a review tool)", "ignora prompts (ferramenta de revisão)"));
139
188
  continue;
@@ -198,14 +247,49 @@ export function pick(inp) {
198
247
  return { pace: paces.length ? Math.max(...paces) : undefined,
199
248
  hot: penalty > 0 && ((p5 !== undefined && p5 > limit5) || (pw !== undefined && pw > limitWeek)) };
200
249
  };
250
+ // The chosen account's per-model buckets: an exhausted model gives way to a lighter one of the same agent.
251
+ let mfx = mf;
252
+ const acc0 = usable.length ? [...usable].sort((x, y) => x[1] - y[1])[0][0] : undefined;
253
+ const mkeys = acc0 ? Object.keys(inp.windows ?? {}).filter((k) => k.startsWith(`${acc0}/`)).map((k) => k.slice(acc0.length + 1)) : [];
254
+ if (acc0 && mkeys.length) {
255
+ const over = (model) => {
256
+ const k = modelKey(model, mkeys), q = k ? inp.windows?.[`${acc0}/${k}`] : undefined;
257
+ if (!q)
258
+ return undefined;
259
+ const five = (q.five ?? -1) >= limit5, week = (q.week ?? -1) >= limitWeek;
260
+ if (!five && !week)
261
+ return undefined;
262
+ const at = [five ? q.resetFive : undefined, week ? q.resetWeek : undefined].filter((t) => t !== undefined);
263
+ return `${model} ${five ? `5h ${q.five}%` : `${m("week", "semana")} ${q.week}%`}`
264
+ + (at.length ? ` (${m("back", "volta")} ${humanReset(Math.max(...at))})` : "");
265
+ };
266
+ const tried = [], skipped = [];
267
+ let found;
268
+ for (const cand of [mf, ...lighterModels(a, role, level, [mf[0]])]) {
269
+ const why = over(cand[0]);
270
+ if (!why) {
271
+ found = cand;
272
+ break;
273
+ }
274
+ tried.push(cand[0]);
275
+ skipped.push(why);
276
+ }
277
+ if (!found) {
278
+ out(name, `${m("every model over its quota", "todo modelo acima da cota")}: ${skipped.join(", ")}`);
279
+ continue;
280
+ }
281
+ if (skipped.length)
282
+ out(name, `${m("quota exhausted", "cota esgotada")}: ${skipped.join(", ")}; ${m("using", "usando")} ${found[0]}`);
283
+ mfx = found;
284
+ }
201
285
  const ranked = usable.map(([n, v]) => ({ n, v: v, ...paceOf(n) }))
202
286
  .sort((x, y) => Number(x.hot) - Number(y.hot) || x.v - y.v);
203
287
  const chosen = ranked[0];
204
288
  const acct = qn.length > 1 ? chosen?.n : undefined;
205
289
  const statRole = role === "fix" || role === "worker" ? "impl" : role;
206
- const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (mf[0].split("/").pop() || "default"));
290
+ const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (mfx[0].split("/").pop() || "default"));
207
291
  const st = stOf(statRole) ?? (role === "watchdog" ? stOf("review") : undefined); // no watchdog runs yet: its review record
208
- pass.push({ agent: name, model: mf[0], effort: mf[1], acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
292
+ pass.push({ agent: name, model: mfx[0], effort: mfx[1], acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
209
293
  if (res.mode === "fixed")
210
294
  break; // the first that passes wins
211
295
  }
@@ -215,24 +299,46 @@ export function pick(inp) {
215
299
  why: m(`fixed by [roles.${res.section}]`, `fixo em [roles.${res.section}]`) };
216
300
  return { mode: "fixed", best, cands: best ? [best] : [], notes };
217
301
  }
218
- const few = (st) => (st?.n ?? 0) < w.min_samples;
219
- const toks = pass.filter((c) => c.st?.new && !few(c.st)).map((c) => c.st.new);
220
- const mins = pass.filter((c) => c.st?.min && !few(c.st)).map((c) => c.st.min);
302
+ // Each axis blends the agent's prior with what was measured, (prior·k + observed·n) / (k + n) with k = min_samples,
303
+ // so a couple of runs already move the score instead of being ignored until the threshold.
304
+ const k = Math.max(0, w.min_samples);
305
+ const few = (st) => (st?.n ?? 0) < k;
306
+ const blend = (prior, obs, n) => obs === undefined || k + n <= 0 ? prior : (prior * k + obs * n) / (k + n);
307
+ // The reference cost/time is the best of the well-sampled agents, or of everyone measured when none is.
308
+ const best = (vals) => {
309
+ const solid = vals.filter((x) => x.solid);
310
+ const use = solid.length ? solid : vals;
311
+ return use.length ? Math.min(...use.map((x) => x.v)) : undefined;
312
+ };
313
+ const refTok = best(pass.filter((c) => c.st?.new).map((c) => ({ v: c.st.new, solid: !few(c.st) })));
314
+ const refMin = best(pass.filter((c) => c.st?.min).map((c) => ({ v: Math.max(c.st.min, 0.1), solid: !few(c.st) })));
315
+ const axis = (label, prior, obs, n) => {
316
+ const v = blend(prior, obs, n);
317
+ return { v, txt: `${label}=${v.toFixed(2)}` + (obs === undefined ? "" : `(${prior.toFixed(2)}→${obs.toFixed(2)})`) };
318
+ };
221
319
  const cands = pass.map((c) => {
222
320
  const prior = Number(c.a.prior ?? 0.6), n = c.st?.n ?? 0, reserve = Boolean(c.a.reserve);
223
- const deliv = role === "impl" && c.st?.approved != null && c.st.n_appr >= 3 ? c.st.approved / 100
224
- : c.st ? c.st.ok / 100 : undefined;
225
- const d = few(c.st) || deliv === undefined ? prior : deliv;
226
- const t = few(c.st) || !c.st?.new || !toks.length ? prior : Math.min(...toks) / c.st.new;
227
- const s = few(c.st) || !c.st?.min || !mins.length ? prior : Math.min(...mins) / Math.max(c.st.min, 0.1);
321
+ const appr = role === "impl" && c.st?.approved != null && c.st.n_appr >= 3;
322
+ const deliv = appr ? c.st.approved / 100 : c.st ? c.st.ok / 100 : undefined;
323
+ const d = axis(m("delivery", "entrega"), prior, deliv, appr ? c.st.n_appr : n);
324
+ const t = axis(m("economy", "economia"), prior, c.st?.new && refTok ? Math.min(1, refTok / c.st.new) : undefined, n);
325
+ const s = axis(m("speed", "rapidez"), prior, c.st?.min && refMin ? Math.min(1, refMin / Math.max(c.st.min, 0.1)) : undefined, n);
228
326
  const used = inp.recent.filter((p) => p.agent === c.agent).length;
327
+ // Exploration fades as samples come in and stops after explore_streak picks of the agent in a row.
328
+ let streak = 0;
329
+ for (let i = inp.recent.length - 1; i >= 0 && inp.recent[i].agent === c.agent; i--)
330
+ streak++;
331
+ const capped = few(c.st) && streak >= w.explore_streak;
332
+ const explore = few(c.st) && !capped ? w.explore * (k - n) / k : 0;
229
333
  const hot = Boolean(c.hot) && penalty > 0;
230
- const score = w.quality * d + w.tokens * t + w.speed * s - w.rotation * used
231
- - (reserve ? w.reserve : 0) + (few(c.st) ? w.explore : 0) - (hot ? penalty : 0);
232
- const why = `${m("delivery", "entrega")}=${d.toFixed(2)} ${m("economy", "economia")}=${t.toFixed(2)} `
233
- + `${m("speed", "rapidez")}=${s.toFixed(2)} ${m("rotation", "rodízio")}=-${used} `
334
+ const score = w.quality * d.v + w.tokens * t.v + w.speed * s.v - w.rotation * used
335
+ - (reserve ? w.reserve : 0) + explore - (hot ? penalty : 0);
336
+ const why = `${d.txt} ${t.txt} ${s.txt} ${m("rotation", "rodízio")}=-${used} `
234
337
  + (reserve ? m("reserve ", "reserva ") : "")
235
- + (hot ? `${m("pace", "ritmo")}=${Math.round(c.pace)}%→-${penalty} ` : "") + (few(c.st) ? `${m("few samples", "poucos dados")}(n=${n})` : `n=${n}`);
338
+ + (hot ? `${m("pace", "ritmo")}=${Math.round(c.pace)}%→-${penalty} ` : "")
339
+ + (explore ? `${m("explore", "explorar")}=+${explore.toFixed(2)} ` : "")
340
+ + (capped ? `${m("explore capped", "exploração limitada")}(${streak}x) ` : "")
341
+ + (few(c.st) ? `${m("few samples", "poucos dados")}(n=${n})` : `n=${n}`);
236
342
  return { agent: c.agent, model: c.model, effort: c.effort, acct: c.acct, score, why };
237
343
  }).sort((x, y) => y.score - x.score);
238
344
  return { mode: "auto", best: cands[0], cands, notes };
package/dist/process.js CHANGED
@@ -104,6 +104,15 @@ export function killTree(pid, group = true) {
104
104
  }
105
105
  catch { /* gone */ } // no group of its own
106
106
  }
107
+ /** Exit code when the command never started (spawn threw or emitted "error"). */
108
+ export const SPAWN_FAILED = 127;
109
+ /** Node throws some spawn errors synchronously (E2BIG, EINVAL...) and emits others (ENOENT): one path for both. */
110
+ function startFailed(e, o) {
111
+ const err = e;
112
+ console.error(`orq: ${err.message}`);
113
+ o.onError?.(err);
114
+ return Promise.resolve(SPAWN_FAILED);
115
+ }
107
116
  /** cmd.exe quoting for one argument (only used when Windows needs a shell). */
108
117
  export const winQuote = (a) => /[\s"&|<>^%()]/.test(a) || a === "" ? `"${a.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"` : a;
109
118
  const gb = (b) => `${(b / 2 ** 30).toFixed(1)} GB`;
@@ -113,18 +122,30 @@ export function runCapped(cmd, args, o = {}) {
113
122
  if (mode === "kernel") {
114
123
  const sd = ["--user", "--scope", "-q", ...noExp(), ...(o.unit ? [`--unit=${o.unit}`] : []), "-p", `MemoryMax=${o.cap}`,
115
124
  ...(o.swap !== undefined ? ["-p", `MemorySwapMax=${o.swap}`] : []), "--", cmd, ...args];
116
- const c = spawn("systemd-run", sd, { stdio: o.stdio ?? "inherit", cwd: o.cwd, env: o.env });
125
+ let c;
126
+ try {
127
+ c = spawn("systemd-run", sd, { stdio: o.stdio ?? "inherit", cwd: o.cwd, env: o.env });
128
+ }
129
+ catch (e) {
130
+ return startFailed(e, o);
131
+ }
117
132
  o.onSpawn?.(c);
118
- return wait(c);
133
+ return wait(c, o);
119
134
  }
120
135
  const win = process.platform === "win32";
121
136
  // Own process group on Linux/macOS, so the whole tree can be killed at once (not for a screen: it needs the terminal).
122
137
  // Windows: a shell only for .cmd/.bat shims (npx, claude…); an .exe runs directly (paths with spaces stay intact).
123
138
  const shell = win && !/\.exe$/i.test(cmd), q = shell && o.quote;
124
- const child = spawn(q ? winQuote(cmd) : cmd, q ? args.map(winQuote) : args, { stdio: o.stdio ?? "inherit", cwd: o.cwd, env: o.env, detached: !win && !o.tty, shell, windowsHide: true });
139
+ let child;
140
+ try {
141
+ child = spawn(q ? winQuote(cmd) : cmd, q ? args.map(winQuote) : args, { stdio: o.stdio ?? "inherit", cwd: o.cwd, env: o.env, detached: !win && !o.tty, shell, windowsHide: true });
142
+ }
143
+ catch (e) {
144
+ return startFailed(e, o);
145
+ }
125
146
  o.onSpawn?.(child);
126
147
  if (mode === "off" || !child.pid)
127
- return wait(child);
148
+ return wait(child, o);
128
149
  const fwd = (sig) => () => { try {
129
150
  process.kill(-child.pid, sig);
130
151
  }
@@ -149,14 +170,14 @@ export function runCapped(cmd, args, o = {}) {
149
170
  killTree(child.pid, !o.tty);
150
171
  }
151
172
  }, o.intervalMs ?? 1000);
152
- return wait(child).then((code) => {
173
+ return wait(child, o).then((code) => {
153
174
  clearInterval(timer);
154
175
  process.off("SIGINT", onInt);
155
176
  process.off("SIGTERM", onTerm);
156
177
  return killed ? 137 : code;
157
178
  });
158
179
  }
159
- const wait = (c) => new Promise((res) => {
160
- c.on("error", (e) => { console.error(`orq: ${e.message}`); res(127); });
180
+ const wait = (c, o = {}) => new Promise((res) => {
181
+ c.on("error", (e) => { startFailed(e, o).then(res); });
161
182
  c.on("exit", (code, sig) => res(code ?? (sig ? 128 + 9 : 1)));
162
183
  });