@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.
package/dist/quota.js CHANGED
@@ -24,6 +24,7 @@ import { writeAtomic } from "./fsutil.js";
24
24
  import { readSecret } from "./keyring.js";
25
25
  import { stateDir } from "./paths.js";
26
26
  import { m } from "./i18n.js";
27
+ import { modelKey } from "./pick.js";
27
28
  const show = (p) => (typeof p === "number" ? `${Math.floor(p)}%` : p);
28
29
  // reset5h/resetweek (epoch seconds) sit between the windows and the note: parseQuota/parseWindows only look at the
29
30
  // first three words, so old cached lines (no reset fields) and lines with them both parse the same way.
@@ -50,9 +51,9 @@ const resetOf = (w) => {
50
51
  };
51
52
  // `acct` identifies the login behind a reading (antigravity: one login per machine, swapped by the user): a cache
52
53
  // written under another login must never stand in for this one.
53
- function remember(name, five, week, acct) {
54
+ function remember(name, five, week, acct, buckets) {
54
55
  try {
55
- writeAtomic(cacheFile(name), JSON.stringify({ five, week, at: Date.now(), ...(acct ? { acct } : {}) }));
56
+ writeAtomic(cacheFile(name), JSON.stringify({ five, week, at: Date.now(), ...(acct ? { acct } : {}), ...(buckets ? { buckets } : {}) }));
56
57
  }
57
58
  catch { /* best effort */ }
58
59
  const r5 = resetOf(five), rw = resetOf(week);
@@ -195,23 +196,83 @@ export function codexQuota() {
195
196
  return { name: "codex", ...pcts, ...(span5 ? { span5h: span5 } : {}), ...(span ? { spanWeek: span } : {}), note: (d ? m(`${d}-day window in the week slot; `, `janela de ${d} dias no lugar da semana; `) : "")
196
197
  + m(`from the last session, ${age} min ago`, `da última sessão, há ${age} min`) };
197
198
  }
198
- /** Worst usage per window across models: a reset within 6 h belongs to the 5h window, a later one to the week. */
199
+ /** Per-model buckets, and the agent's own pair: its least used bucket (the agent still has quota while any
200
+ * model does; `orq pick` checks the model it is about to choose against that model's own bucket). */
199
201
  export function antigravityFromModels(models, now = Date.now()) {
200
202
  const five = { pct: "-" }, week = { pct: "-" };
201
- for (const { quotaInfo: q } of Object.values(models ?? {})) {
202
- if (typeof q?.remainingFraction !== "number" || !q.resetTime)
203
+ const buckets = {};
204
+ for (const [id, { quotaInfo: q } = {}] of Object.entries(models ?? {})) {
205
+ if (!q?.resetTime)
203
206
  continue;
204
207
  const at = Date.parse(q.resetTime);
205
208
  if (Number.isNaN(at))
206
209
  continue;
207
- const w = at - now <= 6 * 3600_000 ? five : week;
208
- const used = Math.round((1 - q.remainingFraction) * 100);
209
- if (w.pct === "-" || used > w.pct) {
210
- w.pct = used;
211
- w.resetsAt = q.resetTime;
210
+ const left = typeof q.remainingFraction === "number" ? q.remainingFraction : 0;
211
+ const used = Math.min(100, Math.max(0, Math.round((1 - left) * 100)));
212
+ const isWeek = at - now > 6 * 3600_000;
213
+ buckets[id] = { used, resetsAt: q.resetTime, week: isWeek };
214
+ }
215
+ // The agent's pair is its least used bucket overall, in that bucket's window (the other stays "-"): a weekly cap on
216
+ // one model must not read as the whole agent being out.
217
+ const best = Object.values(buckets).sort((x, y) => x.used - y.used || Number(x.week) - Number(y.week))[0];
218
+ if (best)
219
+ Object.assign(best.week ? week : five, { pct: best.used, resetsAt: best.resetsAt });
220
+ return { five, week, buckets };
221
+ }
222
+ /** One quota line per model, named "<agent>/<model>", with its bucket in the 5h or the week slot and "-" in the other. */
223
+ export function modelLines(name, buckets, now = Date.now(), note) {
224
+ const out = [];
225
+ for (const [id, b] of Object.entries(buckets)) {
226
+ const at = Date.parse(b.resetsAt);
227
+ if (Number.isNaN(at) || at <= now)
228
+ continue; // reset since the reading: no figure to stand on
229
+ const reset = Math.floor(at / 1000);
230
+ out.push({ name: `${name}/${id}`, five: b.week ? "-" : b.used, week: b.week ? b.used : "-",
231
+ ...(b.week ? { resetWeek: reset } : { reset5h: reset }), ...(note ? { note } : {}) });
232
+ }
233
+ return out;
234
+ }
235
+ /** Learned exhausted buckets (from a run that hit the limit) outlive API readings until their reset, unless the API
236
+ * returns that model with something left. The API's own bucket for the model stands in when it also says exhausted. */
237
+ export function mergeLearned(api, learned, now = Date.now()) {
238
+ const out = { ...api };
239
+ for (const [id, b] of Object.entries(learned)) {
240
+ if (!b.learned || Date.parse(b.resetsAt) <= now)
241
+ continue;
242
+ const key = modelKey(id, Object.keys(api));
243
+ if (key && api[key].used < 100)
244
+ continue; // the API says it has quota again
245
+ out[id] = b;
246
+ }
247
+ return out;
248
+ }
249
+ /** Record that a run found `model` out of quota for `secs` more seconds (best effort; keeps the cached reading). */
250
+ export function learnExhausted(name, model, secs, acct, now = Date.now()) {
251
+ const at = now + secs * 1000;
252
+ const b = { used: 100, resetsAt: new Date(at).toISOString(), week: at - now > 6 * 3600_000, learned: true };
253
+ try {
254
+ let c = { five: { pct: "-" }, week: { pct: "-" }, at: now };
255
+ try {
256
+ c = JSON.parse(readFileSync(cacheFile(name), "utf8"));
212
257
  }
258
+ catch { /* none yet */ }
259
+ if (acct !== undefined && c.acct !== undefined && c.acct !== acct)
260
+ c = { five: { pct: "-" }, week: { pct: "-" }, at: now };
261
+ const buckets = { ...(c.buckets ?? {}), [model]: b };
262
+ writeAtomic(cacheFile(name), JSON.stringify({ ...c, ...(acct ? { acct } : {}), buckets }));
263
+ }
264
+ catch { /* best effort */ }
265
+ return b;
266
+ }
267
+ /** The per-model buckets of the last good reading, when it was this login's. */
268
+ function cachedBuckets(name, acct) {
269
+ try {
270
+ const c = JSON.parse(readFileSync(cacheFile(name), "utf8"));
271
+ return acct !== undefined && c.acct !== acct ? {} : c.buckets ?? {};
272
+ }
273
+ catch {
274
+ return {};
213
275
  }
214
- return { five, week };
215
276
  }
216
277
  export function agyToken() {
217
278
  // agy (go-keyring) keeps {"token": {access_token, expiry, ...}} under service "gemini", user "antigravity"
@@ -242,13 +303,17 @@ export async function antigravityQuota() {
242
303
  const name = "antigravity";
243
304
  const t = agyToken();
244
305
  if (!t?.access)
245
- return noData(name, m("no agy login in the keyring", "sem login do agy no chaveiro"));
306
+ return [noData(name, m("no agy login in the keyring", "sem login do agy no chaveiro"))];
246
307
  // Never refresh it here (that is agy's login). The access token lives 1h and agy renews it only when it runs, so a
247
308
  // passed expiry with a refresh token is just idle (a note, not an expired login); without one, "run agy once".
248
309
  const expired = m("login expired: run `agy models`", "login expirado: rode `agy models`");
310
+ const stale = (why) => {
311
+ const line = fromCache(name, why, undefined, t.acct);
312
+ return [line, ...modelLines(name, cachedBuckets(name, t.acct), Date.now(), line.note)];
313
+ };
249
314
  const note = agyNote(t);
250
315
  if (note)
251
- return fromCache(name, note, undefined, t.acct);
316
+ return stale(note);
252
317
  try {
253
318
  const r = await fetch("https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", {
254
319
  method: "POST", body: "{}",
@@ -257,15 +322,16 @@ export async function antigravityQuota() {
257
322
  signal: AbortSignal.timeout(15_000),
258
323
  });
259
324
  if (r.status === 401)
260
- return fromCache(name, expired, undefined, t.acct);
325
+ return stale(expired);
261
326
  if (!r.ok)
262
- return fromCache(name, `HTTP ${r.status}`, undefined, t.acct);
327
+ return stale(`HTTP ${r.status}`);
263
328
  const j = await r.json();
264
- const { five, week } = antigravityFromModels(j.models ?? {});
265
- return remember(name, five, week, t.acct);
329
+ const { five, week, buckets: api } = antigravityFromModels(j.models ?? {});
330
+ const buckets = mergeLearned(api, cachedBuckets(name, t.acct));
331
+ return [remember(name, five, week, t.acct, buckets), ...modelLines(name, buckets)];
266
332
  }
267
333
  catch (e) {
268
- return fromCache(name, m("collector failed", "coletor falhou") + `: ${e.message}`, undefined, t.acct);
334
+ return stale(m("collector failed", "coletor falhou") + `: ${e.message}`);
269
335
  }
270
336
  }
271
337
  // --- copilot --------------------------------------------------------------------------------------------------------
@@ -385,7 +451,7 @@ export async function collect(sources, want = []) {
385
451
  if (name === "codex")
386
452
  return wanted(name) ? [format(codexQuota())] : [];
387
453
  if (name === "antigravity")
388
- return wanted(name) ? [format(await antigravityQuota())] : [];
454
+ return wanted(name) ? (await antigravityQuota()).map(format) : [];
389
455
  if (name === "copilot")
390
456
  return wanted(name) ? [format(copilotQuota())] : [];
391
457
  if (name === "opencode" || name === "minimax")
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.0",
3
+ "version": "0.7.0",
4
4
  "description": "Orchestrate coding agents (Claude Code, Codex, OpenCode, Antigravity, Copilot) from one conductor.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,10 +11,11 @@
11
11
  "dist",
12
12
  "!dist/**/*.test.js",
13
13
  "skills",
14
- "templates/workspace.CLAUDE.md"
14
+ "templates/workspace.CLAUDE.md",
15
+ "templates/conductor.instructions.md"
15
16
  ],
16
17
  "engines": {
17
- "node": ">=20"
18
+ "node": ">=22"
18
19
  },
19
20
  "repository": {
20
21
  "type": "git",
@@ -38,11 +39,11 @@
38
39
  "prepack": "tsc"
39
40
  },
40
41
  "dependencies": {
41
- "@napi-rs/keyring": "^2.1.1",
42
+ "@napi-rs/keyring": "^2.1.2",
42
43
  "smol-toml": "^1.9.1"
43
44
  },
44
45
  "devDependencies": {
45
- "@types/node": "^24.0.0",
46
+ "@types/node": "^22.20.5",
46
47
  "typescript": "^7.0.2"
47
48
  }
48
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
 
@@ -0,0 +1,31 @@
1
+ # Conductor "{{NAME}}" — {{NAME}} ecosystem
2
+
3
+ You orchestrate agents in the `{{WORKSPACE}}` ecosystem, in herdr (`HERDR_ENV=1`) or in any other terminal.
4
+ Don't write code yourself. Opened by `orq conductor`. Talk to the user in the user's language.
5
+ 1. Read `{{WORKSPACE}}/CLAUDE.md` (repos, groups, base branch, Plane, issue standard, MCP limitations).
6
+ 2. For every Plane issue, follow the `/issue <ID>` skill.
7
+ 3. Never push, open a PR, merge or deploy unless the user asks. When unsure about scope or cost, ask.
8
+ 4. **Children only through `orq agent`** (implementer, fix, review), with the agent chosen by `orq pick`; tests through
9
+ `orq verify` (it checks the base by itself; no `orq baseline` in the flow), as the skill says. NEVER
10
+ `herdr agent start` for children (no memory cap, no answer file); to watch the agent's own screen, `orq agent -T` (herdr or tmux).
11
+ 5. **Completion = one single signal:** in herdr, the line `[HERDR-AGENT DONE] <title> status=ok|fail ... answer: <file>`
12
+ arriving in your pane; elsewhere, `orq wait <prefix>` (as a background task) printing
13
+ `[ORQ AGENT DONE] <title> status=ok|fail ... answer: <file>` (plus the `<prefix>.done` file either way). When it
14
+ arrives: read the answer file and act. Nothing else means done.
15
+ 6. **"Is it still running?"** is answered by the file only: `<prefix>.done` exists → finished; it doesn't → running;
16
+ check `<prefix>.log` (last write time) or, in herdr, peek at the pane: `herdr pane read $(cat <prefix>.pane) --source recent`.
17
+ NEVER `pgrep`/`ps` (they match their own command line).
18
+ 7. **Never block waiting** (`herdr agent wait`, `pane wait-output`, `sleep`): dispatch, report in one line and end
19
+ your turn (outside herdr: `orq wait` in the background, never in the foreground unless your harness has no background tasks).
20
+ 8. Worktrees always from `origin/<repo base>`, created with `git worktree add` (never `herdr worktree create`).
21
+ 9. Between batches of unrelated issues, suggest `/clear` to the user: state lives in Plane and in `.orquestra/`.
22
+
23
+ ## How you are used
24
+ - **Dispatcher, not consultant.** Your job: issue → dispatch → report → PR/cleanup. Long questions about architecture,
25
+ infrastructure (DNS, email, deploy, webhooks) or business decisions: answer in at most 3 lines and suggest "take it
26
+ to a design session; come back with the decision recorded in the issue".
27
+ - **The issue is the source.** A decision made in chat → record it in Plane (description or comment) before
28
+ dispatching; the child's prompt comes from the issue, not from the conversation.
29
+ - **Context:** past ~150k tokens or after closing a batch of related issues → suggest `/clear` (state in Plane and in
30
+ `.orquestra/`).
31
+ - 1–3 line answers. Final issue report: 3–5 lines + the PR link.