@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/dist/pick.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // Picks agent, model, effort and account for a role (port of orq-pick, plus fixed roles).
2
2
  // fixed role: the first agent of [agent, ...fallback] that passes the filters wins — no scoring, never swapped
3
3
  // for an agent the user didn't list.
4
- // auto role: every pool agent that passes is scored:
4
+ // auto role: every (agent, model) pair that passes is scored (a level may list several models):
5
5
  // quality * delivery + tokens * economy + speed * quickness − rotation * (picks in the last `window`)
6
6
  // − reserve (agents with reserve = true) + explore (fewer than `min_samples` runs, fading with n, none after
7
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, nobody reviews their own code (unless no other agent can), cross-vendor rule, a model for the role/level,
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,
9
9
  // quota under the limits: policy.quota_week (95) on the weekly window, and on the 5h one policy.quota_5h per level
10
10
  // (simple 90, medium 85, heavy 80): a heavy task needs more room left to finish.
11
11
  // An agent's `headroom` (points) lowers both limits for it: that share of its accounts stays for the conductors.
@@ -13,7 +13,7 @@
13
13
  // Pacing: an account whose usage, at the pace so far in a window (used ÷ elapsed share), projects past
14
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,
15
15
  // and otherwise loses policy.pacing_penalty (default 0.3, 0 = off) of score — soft, never a drop.
16
- import { agentDef, usesPrompt } from "./agents.js";
16
+ import { agentDef, baseCommit, usesPrompt } from "./agents.js";
17
17
  import { m } from "./i18n.js";
18
18
  import { humanReset } from "./quota.js";
19
19
  import { legacyRole, resolveRole, vendorOf } from "./roles.js";
@@ -73,17 +73,25 @@ export function limitsFor(pol, level) {
73
73
  const five = { ...FIVE, ...(pol.quota_5h ?? {}) };
74
74
  return [Math.min(Number(five[level] ?? five.medium), Number(pol.quota_limit ?? 90)), Number(pol.quota_week ?? 95)];
75
75
  }
76
- export function modelFor(a, role, level) {
77
- if (a.models === undefined)
78
- return ["", undefined]; // no models configured: the CLI's default
79
- const models = a.models;
76
+ /** The agent's models section for a role, with the same fallbacks as the roles (fix → impl, watchdog → opinion → review). */
77
+ function roleModels(a, role) {
78
+ const models = (a.models ?? {});
80
79
  const lr = legacyRole(role);
81
- const ms = models[lr] ?? (lr === "fix" ? models.impl : lr === "watchdog" ? models.opinion ?? models.review : undefined) ?? {};
82
- const spec = ms[level] ?? ms.default ?? ms.medium ?? ms.simple;
83
- if (!spec)
84
- return undefined;
85
- const [model, effort] = spec.split("@");
86
- return [model, effort || undefined];
80
+ return models[lr] ?? (lr === "fix" ? models.impl : lr === "watchdog" ? models.opinion ?? models.review : undefined) ?? {};
81
+ }
82
+ const specList = (spec) => (Array.isArray(spec) ? spec : spec ? [spec] : []).map(String).filter(Boolean)
83
+ .map((x) => { const [model, effort] = x.split("@"); return [model, effort || undefined]; });
84
+ /** Every candidate model for the role/level, in the order configured; undefined when none is. */
85
+ export function modelsFor(a, role, level) {
86
+ if (a.models === undefined)
87
+ return [["", undefined]]; // no models configured: the CLI's default
88
+ const ms = roleModels(a, role);
89
+ const list = specList(ms[level] ?? ms.default ?? ms.medium ?? ms.simple);
90
+ return list.length ? list : undefined;
91
+ }
92
+ /** The first candidate model for the role/level. */
93
+ export function modelFor(a, role, level) {
94
+ return modelsFor(a, role, level)?.[0];
87
95
  }
88
96
  // --- per-model quota ---------------------------------------------------------------------------------------------------
89
97
  // A collector may add lines "<account>/<model> 5h=.. week=.." (antigravity: one bucket per model group). The model a pick is about
@@ -98,25 +106,33 @@ export function modelKey(model, keys) {
98
106
  }
99
107
  /** The models of an agent's role section lighter than `level`, the closest first, without repeats of `tried`. */
100
108
  export function lighterModels(a, role, level, tried) {
101
- const models = a.models;
102
- if (!models)
109
+ if (!a.models)
103
110
  return [];
104
- const lr = legacyRole(role);
105
- const ms = models[lr] ?? (lr === "fix" ? models.impl : lr === "watchdog" ? models.opinion ?? models.review : undefined) ?? {};
111
+ const ms = roleModels(a, role);
106
112
  const rank = LEVEL_RANK[level] ?? 1, seen = new Set(tried);
107
113
  const out = [];
108
114
  for (const [, spec] of Object.entries(ms).filter(([lv]) => (LEVEL_RANK[lv] ?? 1) < rank)
109
115
  .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]);
116
+ for (const [model, effort] of specList(spec)) {
117
+ if (seen.has(model))
118
+ continue;
119
+ seen.add(model);
120
+ out.push([model, effort]);
121
+ }
115
122
  }
116
123
  return out;
117
124
  }
118
125
  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 };
119
126
  export const weights = (cfg) => ({ ...WEIGHTS, ...((cfg.policy ?? {}).weights ?? {}) });
127
+ /** The merged definition (built-in + config) can review a commit base (agents.<name>.base_commit); an agent that can't be resolved can't. */
128
+ function takesBaseCommit(cfg, name) {
129
+ try {
130
+ return baseCommit(agentDef(cfg, name));
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ }
120
136
  /** The merged definition (built-in + config) takes a prompt; an agent that can't be resolved doesn't. */
121
137
  function takesPrompt(cfg, name) {
122
138
  try {
@@ -156,6 +172,11 @@ export function pick(inp) {
156
172
  out(name, m("blocked (orq block -l)", "bloqueado (orq block -l)"));
157
173
  continue;
158
174
  }
175
+ const gone = inp.missing?.get(name);
176
+ if (gone) {
177
+ out(name, gone);
178
+ continue;
179
+ }
159
180
  if ((role === "review" || role === "opinion" || role === "watchdog") && name === inp.implAgent && someoneElse) {
160
181
  out(name, m("it implemented this code", "foi quem implementou"));
161
182
  continue;
@@ -165,12 +186,16 @@ export function pick(inp) {
165
186
  out(name, m("same vendor as the implementer", "mesmo fornecedor do implementador"));
166
187
  continue;
167
188
  }
189
+ if (role === "review" && inp.commitBase && !takesBaseCommit(cfg, name)) {
190
+ out(name, m("can't review a commit base (base_commit = false)", "não revisa uma base que é commit (base_commit = false)"));
191
+ continue;
192
+ }
168
193
  if (role === "watchdog" && !takesPrompt(cfg, name)) {
169
194
  out(name, m("ignores prompts (a review tool)", "ignora prompts (ferramenta de revisão)"));
170
195
  continue;
171
196
  }
172
- const mf = modelFor(a, role, level);
173
- if (!mf) {
197
+ const mfs = modelsFor(a, role, level);
198
+ if (!mfs) {
174
199
  out(name, m(`no model for ${role}/${level}`, `sem modelo para ${role}/${level}`));
175
200
  continue;
176
201
  }
@@ -229,8 +254,8 @@ export function pick(inp) {
229
254
  return { pace: paces.length ? Math.max(...paces) : undefined,
230
255
  hot: penalty > 0 && ((p5 !== undefined && p5 > limit5) || (pw !== undefined && pw > limitWeek)) };
231
256
  };
232
- // The chosen account's per-model buckets: an exhausted model gives way to a lighter one of the same agent.
233
- let mfx = mf;
257
+ // The chosen account's per-model buckets: an exhausted candidate drops out; with none left, a lighter model of the agent.
258
+ let mfx = mfs;
234
259
  const acc0 = usable.length ? [...usable].sort((x, y) => x[1] - y[1])[0][0] : undefined;
235
260
  const mkeys = acc0 ? Object.keys(inp.windows ?? {}).filter((k) => k.startsWith(`${acc0}/`)).map((k) => k.slice(acc0.length + 1)) : [];
236
261
  if (acc0 && mkeys.length) {
@@ -245,23 +270,25 @@ export function pick(inp) {
245
270
  return `${model} ${five ? `5h ${q.five}%` : `${m("week", "semana")} ${q.week}%`}`
246
271
  + (at.length ? ` (${m("back", "volta")} ${humanReset(Math.max(...at))})` : "");
247
272
  };
248
- const tried = [], skipped = [];
249
- let found;
250
- for (const cand of [mf, ...lighterModels(a, role, level, [mf[0]])]) {
251
- const why = over(cand[0]);
252
- if (!why) {
253
- found = cand;
254
- break;
273
+ const skipped = [];
274
+ let found = mfs.filter((c) => { const why = over(c[0]); if (why)
275
+ skipped.push(why); return !why; });
276
+ if (!found.length) {
277
+ for (const cand of lighterModels(a, role, level, mfs.map((c) => c[0]))) {
278
+ const why = over(cand[0]);
279
+ if (!why) {
280
+ found = [cand];
281
+ break;
282
+ }
283
+ skipped.push(why);
255
284
  }
256
- tried.push(cand[0]);
257
- skipped.push(why);
258
285
  }
259
- if (!found) {
286
+ if (!found.length) {
260
287
  out(name, `${m("every model over its quota", "todo modelo acima da cota")}: ${skipped.join(", ")}`);
261
288
  continue;
262
289
  }
263
290
  if (skipped.length)
264
- out(name, `${m("quota exhausted", "cota esgotada")}: ${skipped.join(", ")}; ${m("using", "usando")} ${found[0]}`);
291
+ out(name, `${m("quota exhausted", "cota esgotada")}: ${skipped.join(", ")}; ${m("using", "usando")} ${found.map((c) => c[0]).join(", ")}`);
265
292
  mfx = found;
266
293
  }
267
294
  const ranked = usable.map(([n, v]) => ({ n, v: v, ...paceOf(n) }))
@@ -269,9 +296,12 @@ export function pick(inp) {
269
296
  const chosen = ranked[0];
270
297
  const acct = qn.length > 1 ? chosen?.n : undefined;
271
298
  const statRole = role === "fix" || role === "worker" ? "impl" : role;
272
- const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (mfx[0].split("/").pop() || "default"));
273
- const st = stOf(statRole) ?? (role === "watchdog" ? stOf("review") : undefined); // no watchdog runs yet: its review record
274
- pass.push({ agent: name, model: mfx[0], effort: mfx[1], acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
299
+ // One candidate per (agent, model) pair, each scored on its own stats row; a fixed role takes the first model.
300
+ for (const [model, effort] of res.mode === "fixed" ? mfx.slice(0, 1) : mfx) {
301
+ const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (model.split("/").pop() || "default"));
302
+ const st = stOf(statRole) ?? (role === "watchdog" ? stOf("review") : undefined); // no watchdog runs yet: its review record
303
+ pass.push({ agent: name, model, effort, acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
304
+ }
275
305
  if (res.mode === "fixed")
276
306
  break; // the first that passes wins
277
307
  }
@@ -306,9 +336,10 @@ export function pick(inp) {
306
336
  const t = axis(m("economy", "economia"), prior, c.st?.new && refTok ? Math.min(1, refTok / c.st.new) : undefined, n);
307
337
  const s = axis(m("speed", "rapidez"), prior, c.st?.min && refMin ? Math.min(1, refMin / Math.max(c.st.min, 0.1)) : undefined, n);
308
338
  const used = inp.recent.filter((p) => p.agent === c.agent).length;
309
- // Exploration fades as samples come in and stops after explore_streak picks of the agent in a row.
339
+ // Exploration fades as samples come in and stops after explore_streak picks of the pair in a row (rotation stays per agent).
340
+ const same = (p) => p.agent === c.agent && (p.model === undefined || p.model === c.model);
310
341
  let streak = 0;
311
- for (let i = inp.recent.length - 1; i >= 0 && inp.recent[i].agent === c.agent; i--)
342
+ for (let i = inp.recent.length - 1; i >= 0 && same(inp.recent[i]); i--)
312
343
  streak++;
313
344
  const capped = few(c.st) && streak >= w.explore_streak;
314
345
  const explore = few(c.st) && !capped ? w.explore * (k - n) / k : 0;
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
  });
package/dist/rounds.js CHANGED
@@ -14,6 +14,7 @@ export const SLOW_FACTOR = 2; // a round this many times slower than the median
14
14
  export const SLOW_MIN_SECS = 600; // ... but only if it also took at least this long
15
15
  export const REPEAT_WARN = 2; // a finding seen in this many reviews: warn
16
16
  export const REPEAT_STOP = 3; // ... in this many: stop (it came back after two fix rounds)
17
+ export const FRESH_WARN = 2; // fix rounds whose review brings heavy findings never seen before: the branch is too big, split it
17
18
  export const HEAVY = new Set(["blocker", "major", "critical"]); // only these count towards repeats and the alert
18
19
  const read = (f) => { try {
19
20
  return readFileSync(f, "utf8");
@@ -40,7 +41,8 @@ const repoRel = (f) => f.replace(/\\/g, "/").replace(/^.*\.worktrees\/[^/]+\//,
40
41
  export function parseFindings(text, round) {
41
42
  const out = [];
42
43
  for (const line of text.split("\n")) {
43
- const it = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line);
44
+ // A list item, or a bare line that starts with a severity tag ("[blocker] src/a.ts:10 ...", as some reviewers write).
45
+ const it = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line) ?? /^\s*(\[(?:blocker|major|minor|nit|suggestion|important|critical)\].*)$/i.exec(line);
44
46
  if (!it)
45
47
  continue;
46
48
  const sevOf = (w) => { const k = fold(w ?? ""); return Object.hasOwn(SEV, k) ? SEV[k] : undefined; };
@@ -60,6 +62,13 @@ export function parseFindings(text, round) {
60
62
  return out;
61
63
  }
62
64
  /** Same finding? Same file (when both name one) and enough shared words; without a file, a stricter overlap. */
65
+ // An explicit "nothing to fix" verdict, PT or EN, on a line of its own or as a heading/label.
66
+ const APPROVED = /^\s*(?:[#>*_`-]+\s*)*(?:verdict|veredito|status|resultado)?\s*[:—-]?\s*\**\s*(approved|approve|lgtm|aprovad[oa]|no (?:blocking )?(?:findings|issues|blockers)|nenhum (?:achado|problema|blocker|bloqueante)|sem (?:achados|bloqueantes|blockers))\b/im;
67
+ /** Does a review answer carry a verdict: at least one finding with a severity, or an explicit approval?
68
+ * "I'll start by exploring the repo..." (a reviewer that stopped before reviewing) has neither. */
69
+ export function hasVerdict(text) {
70
+ return parseFindings(text, 0).length > 0 || APPROVED.test(text);
71
+ }
63
72
  export function similar(a, b) {
64
73
  const wa = words(a.title), wb = words(b.title);
65
74
  if (!wa.size || !wb.size)
@@ -86,6 +95,14 @@ export function repeats(fs) {
86
95
  return groups.map((g) => ({ file: g[0].file, title: g[0].title, rounds: [...new Set(g.map((x) => x.round))] }))
87
96
  .filter((r) => r.rounds.length >= REPEAT_WARN);
88
97
  }
98
+ /** Fix rounds (k ≥ 1) whose review raised a heavy finding not similar to any earlier one: a reviewer re-reading a big branch. */
99
+ export function freshRounds(fs) {
100
+ const out = new Set();
101
+ for (const f of fs)
102
+ if (f.round > 0 && !fs.some((e) => e.round < f.round && similar(e, f)))
103
+ out.add(f.round);
104
+ return [...out].sort((a, b) => a - b);
105
+ }
89
106
  const median = (xs) => { const s = [...xs].sort((a, b) => a - b), h = s.length >> 1; return s.length % 2 ? s[h] : (s[h - 1] + s[h]) / 2; };
90
107
  /** Fix rounds far above the median of the other fix rounds (round 0 is the implementation: never compared; needs 3 fix rounds). */
91
108
  export function slowRounds(all) {
@@ -96,7 +113,7 @@ export function slowRounds(all) {
96
113
  }
97
114
  /** The pure part: facts from the files → numbers, alert level and why. */
98
115
  export function metrics(issue, f) {
99
- const reps = repeats(f.findings.filter((x) => HEAVY.has(x.severity))), reasons = [];
116
+ const heavy = f.findings.filter((x) => HEAVY.has(x.severity)), reps = repeats(heavy), reasons = [];
100
117
  let level = "ok";
101
118
  const raise = (l, why) => { reasons.push(why); if (l === "stop" || level === "ok")
102
119
  level = l; };
@@ -109,6 +126,9 @@ export function metrics(issue, f) {
109
126
  raise("stop", { code: "same_finding", n: worst });
110
127
  else if (reps.length)
111
128
  raise("warn", { code: "repeated", n: reps.length });
129
+ const fresh = freshRounds(heavy);
130
+ if (fresh.length >= FRESH_WARN)
131
+ raise("warn", { code: "fresh", n: fresh.length, rounds: fresh });
112
132
  const slow = slowRounds(f.perRound);
113
133
  if (slow.length)
114
134
  raise("warn", { code: "slow", n: slow.length, rounds: slow });
@@ -121,11 +141,25 @@ export function countRounds(dir, names) {
121
141
  const fx = /^fix-(\d+)\.md$/.exec(f);
122
142
  if (fx)
123
143
  n = Math.max(n, Number(fx[1]));
124
- else if (f.startsWith(".rounds-"))
144
+ else if (f.startsWith(".rounds-") && !f.endsWith(".log"))
125
145
  n = Math.max(n, Number(read(join(dir, f))) || 0);
126
146
  }
127
147
  return n;
128
148
  }
149
+ /** Follow-ups per reason (review|verify|unblock) from the .rounds-<session>.log files `orq agent -R` appends to. */
150
+ export function roundReasons(dir, names) {
151
+ const out = {};
152
+ for (const f of names) {
153
+ if (!/^\.rounds-.+\.log$/.test(f))
154
+ continue;
155
+ for (const line of read(join(dir, f)).split("\n")) {
156
+ const r = line.split(" ")[1];
157
+ if (r)
158
+ out[r] = (out[r] ?? 0) + 1;
159
+ }
160
+ }
161
+ return out;
162
+ }
129
163
  /** The impure part: read .orquestra/<issue>/. undefined when the issue has no fix or review files at all. */
130
164
  export function collectRounds(ws, issue, max) {
131
165
  const dir = join(ws, ".orquestra", issue);
@@ -160,7 +194,9 @@ export function collectRounds(ws, issue, max) {
160
194
  return { round: k, secs: Number.isNaN(s) || Number.isNaN(next) ? 0 : Math.max(0, Math.round((next - s) / 1000)) };
161
195
  });
162
196
  const findings = [...reviews].flatMap(([k, fs]) => fs.flatMap((n) => parseFindings(read(join(dir, n)), k)));
163
- return metrics(issue, { rounds, max, perRound, findings, secs: Math.round((t1 - t0) / 1000) });
197
+ const x = metrics(issue, { rounds, max, perRound, findings, secs: Math.round((t1 - t0) / 1000) });
198
+ const fu = roundReasons(dir, names);
199
+ return Object.keys(fu).length ? { ...x, followups: fu } : x;
164
200
  }
165
201
  /** Cheap change signature of an issue folder (names + mtimes, no reads) for caching collectRounds between redraws. */
166
202
  export function roundsSig(ws, issue) {
package/dist/stats.js CHANGED
@@ -3,9 +3,13 @@
3
3
  // duration, tokens. "New" tokens = excluding cache reads (what actually weighs on quota); Codex without a session
4
4
  // file only reports the total. Implementation delivery = the first review of the same repo in the issue said APPROVED.
5
5
  // Limitation: follow-ups (-R) reuse the implementation prefix, so the "impl" row shows the last round of that prefix.
6
+ // Agent and model come from the job (<prefix>.job.json argv), then the pick (<prefix>.pick), then the log; a tool
7
+ // without models (a review CLI) has model "-". Time runs to the first turn on a screen run (<prefix>.turns): later
8
+ // turns wait on a person. Killed runs and older screen runs without .turns are counted but left out of the time median.
6
9
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
7
10
  import { homedir } from "node:os";
8
11
  import { basename, join } from "node:path";
12
+ import { argvModel, tokenize } from "./agents.js";
9
13
  import { doneStatus } from "./process.js";
10
14
  const ROLE = /^(impl|fix\d*|review\d*|[a-z]+-review\d*|watchdog\d*)-?/;
11
15
  const read = (f) => { try {
@@ -14,14 +18,40 @@ const read = (f) => { try {
14
18
  catch {
15
19
  return "";
16
20
  } };
17
- export function agentModel(run, log) {
18
- const a = /\b(codex exec|codex resume|codex|claude -p|claude|opencode run|opencode|agy)\b/.exec(run);
19
- const first = a ? a[1].split(" ")[0] : "?";
20
- const agent = first === "agy" ? "antigravity" : first;
21
- let model = /(?:\s-m|--model)\s+'?([^\s']+)/.exec(run)?.[1];
22
- if (agent === "codex" && !model)
23
- model = /^model: (\S+)/m.exec(log)?.[1] ?? "default";
24
- return [agent, (model ?? "default").split("/").pop()];
21
+ const BINS = /^(codex|claude|opencode|agy|copilot)$/;
22
+ /** The model a log says was used: Claude's init event, Codex's banner, opencode's step events. */
23
+ function logModel(log) {
24
+ return /"type"\s*:\s*"system"[^\n]*?"model"\s*:\s*"([^"]+)"/.exec(log)?.[1] ?? /^model: (\S+)/m.exec(log)?.[1]
25
+ ?? /"modelID"\s*:\s*"([^"]+)"/.exec(log)?.[1];
26
+ }
27
+ /** The argv of a generated .run.sh: its command, shell-tokenized (quotes span lines), without leading VAR=value words. */
28
+ function runArgv(run) {
29
+ const body = run.split("\n").filter((l) => !l.startsWith("#") && !l.startsWith("cd ")).join("\n");
30
+ const words = tokenize(body.replace(/\s<\s\S+\s*$/, "")).map((w) => w.text);
31
+ while (words.length && /^\w+=/.test(words[0]))
32
+ words.shift();
33
+ return words;
34
+ }
35
+ /** [agent, model] of a run. `job` is <prefix>.job.json (argv + agent), `pick` the `-a … -m …` line it was dispatched with
36
+ * (used only when it names the same agent). */
37
+ export function agentModel(run, log, job, pick = "") {
38
+ let argv = job?.argv ?? runArgv(run);
39
+ const base = (w) => w.split(/[\\/]/).pop();
40
+ // Older runs wrap the agent (systemd-run …, git worktree … && codex): start at the first agent CLI found.
41
+ const at = job ? 0 : argv.findIndex((w) => BINS.test(base(w)));
42
+ if (at > 0)
43
+ argv = argv.slice(at);
44
+ const bin = base(argv[0] ?? "");
45
+ let agent = job?.agent || bin || "?";
46
+ if (agent === "agy")
47
+ agent = "antigravity";
48
+ const picked = / -a (\S+)/.exec(` ${pick}`)?.[1] === agent ? / -m (\S+)/.exec(pick)?.[1] : undefined;
49
+ // opencode's screen takes the model in its config env, not -m.
50
+ const envModel = /"model":"([^"]+)"/.exec(job?.env?.OPENCODE_CONFIG_CONTENT ?? "")?.[1];
51
+ const model = argvModel(argv) ?? envModel ?? picked ?? logModel(log);
52
+ if (model)
53
+ return [agent, model.split("/").pop()];
54
+ return [agent, /^(codex|claude|opencode|agy)$/.test(bin) ? "default" : "-"];
25
55
  }
26
56
  function codexSession(sid) {
27
57
  const root = join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "sessions");
@@ -74,8 +104,10 @@ export function tokens(agent, log, sid = "") {
74
104
  }
75
105
  else if (agent === "opencode" && e.type === "step_finish") {
76
106
  const t = e.part?.tokens ?? {};
77
- neu += (t.total ?? 0) - (t.cache?.read ?? 0);
78
- tot += t.total ?? 0;
107
+ // Newer opencode has no total: input + output + reasoning + cache.
108
+ const all = t.total ?? (t.input ?? 0) + (t.output ?? 0) + (t.reasoning ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0);
109
+ neu += all - (t.cache?.read ?? 0);
110
+ tot += all;
79
111
  }
80
112
  else if (agent === "antigravity" && e.event === "result") {
81
113
  const u = e.result?.usage ?? {};
@@ -112,7 +144,16 @@ export function collectRuns(ws, days) {
112
144
  if (txt.includes("herdr-verify") || txt.includes("herdr-baseline"))
113
145
  continue;
114
146
  const log = read(`${pre}.log`);
115
- const [agent, model] = agentModel(txt, log);
147
+ let job;
148
+ try {
149
+ job = JSON.parse(read(`${pre}.job.json`));
150
+ }
151
+ catch { /* older run */ }
152
+ const [agent, model] = agentModel(txt, log, job, read(`${pre}.pick`));
153
+ const dt = read(done), turns = read(`${pre}.turns`).split("\n").filter(Boolean);
154
+ const first = turns.length ? Date.parse(turns[0]) : NaN;
155
+ const end = Number.isFinite(first) ? first : statSync(done).mtimeMs;
156
+ const timed = Number.isFinite(first) || (!/\bmode=tui\b/.test(dt) && doneStatus(dt) !== "killed");
116
157
  const name = basename(pre);
117
158
  const rm = ROLE.exec(name);
118
159
  const role = rm && rm[1].includes("review") ? "review"
@@ -121,8 +162,8 @@ export function collectRuns(ws, days) {
121
162
  : name.startsWith("impl") ? "impl" : "other";
122
163
  const out = read(`${pre}.out.md`);
123
164
  const [neu, tot] = tokens(agent, log, read(`${pre}.id`).trim());
124
- runs.push({ issue, name, agent, model, role, ok: doneStatus(read(done)) === "ok" && out.trim() !== "",
125
- secs: Math.max(0, Math.floor((statSync(done).mtimeMs - rs.mtimeMs) / 1000)), new: neu, tot,
165
+ runs.push({ issue, name, agent, model, role, ok: doneStatus(dt) === "ok" && out.trim() !== "",
166
+ secs: Math.max(0, Math.floor((end - rs.mtimeMs) / 1000)), timed, new: neu, tot,
126
167
  repo: name.includes("-") ? name.slice(name.indexOf("-") + 1) : "" });
127
168
  }
128
169
  }
@@ -153,7 +194,7 @@ export function rows(runs) {
153
194
  const withTok = rs.filter((r) => r.tot), appr = rs.filter((r) => r.approved !== undefined).map((r) => r.approved);
154
195
  return { role, agent, model, n: rs.length, ok: Math.round(100 * rs.filter((r) => r.ok).length / rs.length),
155
196
  approved: appr.length ? Math.round(100 * appr.filter(Boolean).length / appr.length) : null, n_appr: appr.length,
156
- min: Math.round(median(rs.map((r) => r.secs)) / 6) / 10,
197
+ min: rs.some((r) => r.timed) ? Math.round(median(rs.filter((r) => r.timed).map((r) => r.secs)) / 6) / 10 : null,
157
198
  new: withTok.length ? Math.floor(median(withTok.map((r) => r.new))) : null,
158
199
  tot: withTok.length ? Math.floor(median(withTok.map((r) => r.tot))) : null };
159
200
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irythill/orq",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Orchestrate coding agents (Claude Code, Codex, OpenCode, Antigravity, Copilot) from one conductor.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,7 +15,7 @@
15
15
  "templates/conductor.instructions.md"
16
16
  ],
17
17
  "engines": {
18
- "node": ">=20"
18
+ "node": ">=22"
19
19
  },
20
20
  "repository": {
21
21
  "type": "git",
@@ -39,11 +39,11 @@
39
39
  "prepack": "tsc"
40
40
  },
41
41
  "dependencies": {
42
- "@napi-rs/keyring": "^2.1.1",
42
+ "@napi-rs/keyring": "^2.1.2",
43
43
  "smol-toml": "^1.9.1"
44
44
  },
45
45
  "devDependencies": {
46
- "@types/node": "^24.0.0",
46
+ "@types/node": "^22.20.5",
47
47
  "typescript": "^7.0.2"
48
48
  }
49
49
  }
@@ -99,7 +99,8 @@ Always include:
99
99
  choice doesn't come back as a blocker.
100
100
  - **No tests in the child** (memory rule): "don't run jest/vitest; I run the related tests afterwards".
101
101
  - **Fresh branch:** "before your final answer: `git fetch origin <BASE> && git rebase origin/<BASE>`; on conflict stop
102
- and report; after the rebase repeat lint/tsc". Codex has no network: for it, you fetch and check in step 5.
102
+ and report; after the rebase repeat lint/tsc". An agent without network (Codex) can't fetch: `orq agent` fetches before
103
+ the run and adds a note to its prompt, so it only rebases locally.
103
104
 
104
105
  **Base = the repo's base branch from the workspace `CLAUDE.md`, always from the updated remote (`origin/<BASE>`).**
105
106
  Worktree with `git worktree add` (not `herdr worktree create`: that opens a separate herdr workspace per worktree, and
@@ -128,7 +129,7 @@ orq agent $(cat $O/impl-<repo>.pick) -g <group> -t "<ID> impl <repo>" -d $WT -o
128
129
  ```
129
130
  - The Claude account (`-c`) already comes from `orq pick` (one under the limit whose pace stays within it, then the least used).
130
131
  - Full final answer in `$O/impl-<repo>.out.md`; log in `.log`; the agent's session id in `.id`.
131
- - Codex's sandbox has no network: `git fetch` is yours, beforehand (above). Push/PR: yours (step 7).
132
+ - An agent without network (`network = false`, e.g. Codex): `orq agent` runs `git fetch origin` before a write run, so it can rebase locally. Push/PR: yours (step 7).
132
133
  - `orq agent` returns immediately (no need to background it); the child's pane is in `<prefix>.pane`
133
134
  (to peek: `<prefix>.log`; in herdr, `herdr pane read $(cat <prefix>.pane) --source recent`).
134
135
  - **No baseline in the issue flow:** don't run `orq baseline` before dispatching (the base moves with every merge and
@@ -157,7 +158,10 @@ Child with `status=ok` → run the tests related to the diff, in their own tab,
157
158
  `orq verify -w $WS -r <repo> -d $WT -b <BASE> -o $O/verify-<repo> -g <group>` → `$O/verify-<repo>.txt` with
158
159
  **new failures** and pre-existing ones: if any test fails, it runs the same test files on an updated
159
160
  `origin/<BASE>` (whatever fails there is pre-existing). You get the done line (`[HERDR-AGENT DONE]` / `[ORQ AGENT DONE]`) `verify ... new=N`.
160
- `new>0` → fix round with the list (step 6), no review. `new=0` → review.
161
+ `new>0` → follow-up with the failing tests, no review: write `$O/verify-fix-<n>.md` (not `fix-<n>.md`) and add
162
+ `--reason verify` to the `-R` call of step 6. `new=0` → review. A follow-up that only unblocks the child (files it
163
+ couldn't fetch without network, a missing tool) uses `--reason unblock`. Only review rounds (the default `--reason
164
+ review`) count towards `policy.max_rounds`.
161
165
  **Pre-existing failures get fixed too.** Any failure — tests listed as "already failing on origin/<BASE>", or a CI job
162
166
  (lint, biome, typecheck, shard, regression) failing on the PR or on the base — goes into a fix round on the issue's
163
167
  branch (separate `fix:`/`test:` commit naming the issue). Never close, move to In Review or call a PR done while
@@ -223,12 +227,19 @@ Then a new `orq verify` (4b) and the **round review** (step 5 with these changes
223
227
  - Same reviewer as the round before: `$(cat $O/review-<repo>.pick)`. Only if it is over quota, blocked or fails with
224
228
  no verdict → a new `orq pick review` (saved to the same file).
225
229
  - Extra attachments: `git -C $WT diff $(cat $O/round-<n>.sha)..HEAD > $O/fix-<n>.diff` → `-i $O/fix-<n>.md -i $O/fix-<n>.diff`
226
- (the full `-b $BASE` diff stays, as context).
230
+ (the full `-b $BASE` diff stays, as context). **A review tool** gets `-b $(cat $O/round-<n>.sha)` instead of `-b $BASE`:
231
+ `{base}` becomes that commit, so it reviews only the round's delta, not the whole branch again. Pick the round reviewer with
232
+ `orq pick review --base $(cat $O/round-<n>.sha) ...`: a tool with `base_commit = false` is skipped. Dispatched anyway, `orq agent`
233
+ falls back to the branch base (`--base-branch $BASE`) and its answer says the review covers the whole branch.
227
234
  - The prompt asks a closed question: "for each item in fix-<n>.md: RESOLVED or NOT RESOLVED (why); then the fix diff:
228
235
  any regression it introduced, as `[blocker]`/`[suggestion]`. Outside the fix diff, report only a blocker you missed
229
- before that would break production, and say why it wasn't in the first review". Anything else is a suggestion. A review tool can't answer that: it reviews the whole
230
- branch again, so you do the matching — a finding is a blocker this round only if it is inside `fix-<n>.diff` or is a
231
- NOT RESOLVED item of `fix-<n>.md`; one you already decided on (fixed, rejected or downgraded) stays decided. **Maximum: `orq config get policy.max_rounds` rounds** — `orq agent -R` refuses the next one (exit 4) and counts per session, so a renamed prefix doesn't reset it; on exit 4 stop and tell the user. With their OK run `orq rounds <ID> --diagnose` (a cheap independent agent, advisory: read its `RECOMMENDATION` — continue / split / change-implementer / ask-user — and tell the user), then `-F` (refused without a diagnosis newer than the last fix/review; `-F -F` skips that, only if the user says so); over it → `orq pick impl
236
+ before that would break production, and say why it wasn't in the first review". Anything else is a suggestion. If a review
237
+ still names something outside `fix-<n>.diff`, you do the matching — a finding is a blocker this round only if it is inside
238
+ `fix-<n>.diff` or is a NOT RESOLVED item of `fix-<n>.md`; one you already decided on (fixed, rejected or downgraded) stays decided.
239
+ **A finding outside the fix diff never opens a round by itself**, however serious: it goes to a "deferred" list in the
240
+ Plane comment (a follow-up issue). If it is a security or personal-data leak, stop and ask the user (fix in this branch
241
+ or follow-up) instead of dispatching. `orq rounds <ID>` warns `fresh` when two fix rounds brought new heavy findings:
242
+ the branch is too big — split, don't keep reviewing it whole. **Maximum: `orq config get policy.max_rounds` rounds** — `orq agent -R` refuses the next one (exit 4) and counts per session, so a renamed prefix doesn't reset it; on exit 4 stop and tell the user. With their OK run `orq rounds <ID> --diagnose` (a cheap independent agent, advisory: read its `RECOMMENDATION` — continue / split / change-implementer / ask-user — and tell the user), then `-F` (refused without a diagnosis newer than the last fix/review; `-F -F` skips that, only if the user says so); over it → `orq pick impl
232
243
  --exclude-agent <it> > $O/impl-<repo>.pick` only with the user's OK, or stop and report. Non-blocking suggestions go into the comment; they
233
244
  don't become a round.
234
245