@irythill/orq 0.3.0 → 0.4.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,4 +1,5 @@
1
1
  import { m } from "./i18n.js";
2
+ import { humanReset } from "./quota.js";
2
3
  import { legacyRole, resolveRole, vendorOf } from "./roles.js";
3
4
  /** "<name> 5h=NN% week=NN% [note]" lines → QuotaMap (same reading as the old orq-pick). */
4
5
  export function parseQuota(text) {
@@ -17,6 +18,45 @@ export function parseQuota(text) {
17
18
  }
18
19
  return out;
19
20
  }
21
+ /** The same lines → each window's %, plus its reset time (epoch seconds) when the line carries one. */
22
+ export function parseWindows(text) {
23
+ const out = {};
24
+ const num = (v) => (v && /^\d+%$/.test(v) ? Number(v.slice(0, -1)) : undefined);
25
+ for (const l of text.split(/\r?\n/)) {
26
+ const r = /^(\S+) 5h=(\S+) week=(\S+)(?: reset5h=(\d+))?(?: resetweek=(\d+))?(?: span5h=(\d+))?(?: spanweek=(\d+))?/.exec(l.trim());
27
+ if (!r)
28
+ continue;
29
+ // "[login expired: run `agy models`; last reading, 5 min ago]" → the part before the first ";" (the fix to show)
30
+ const note = /\[([^\]]*)\]\s*$/.exec(l)?.[1].split(";")[0].trim();
31
+ out[r[1]] = { five: num(r[2]), week: num(r[3]), ...(r[4] ? { resetFive: Number(r[4]) } : {}),
32
+ ...(r[5] ? { resetWeek: Number(r[5]) } : {}), ...(r[6] ? { spanFive: Number(r[6]) } : {}),
33
+ ...(r[7] ? { spanWeek: Number(r[7]) } : {}), ...(note && EXPIRED.test(note) ? { expired: note } : note ? { note } : {}) };
34
+ }
35
+ return out;
36
+ }
37
+ // Both languages orq writes these notes in (the collectors' not-logged-in and expired-login texts).
38
+ /** agy's idle-token note (collector text, both languages). */
39
+ const IDLE = /token idle|token ocioso/i;
40
+ const EXPIRED = /login expired|login expirado|not logged in|no agy login|sem login/i;
41
+ /** Window lengths in seconds, unless the quota line publishes another (span5h/spanweek, minutes). */
42
+ const WINDOW_S = { five: 5 * 3600, week: 7 * 86400 };
43
+ /** Usage at the window's reset if the pace so far continues (used ÷ elapsed share); undefined when it can't be told:
44
+ * no figure or reset time, or under 10% of the window elapsed (too early to project). */
45
+ export function projected(pct, reset, size, now) {
46
+ if (pct === undefined || pct < 0 || reset === undefined)
47
+ return undefined;
48
+ const left = reset - now;
49
+ if (left <= 0 || left > size)
50
+ return undefined;
51
+ const elapsed = (size - left) / size;
52
+ return elapsed < 0.1 ? undefined : pct / elapsed;
53
+ }
54
+ const FIVE = { simple: 90, medium: 85, heavy: 80 };
55
+ /** [5h limit, weekly limit] for a task level; an unknown level counts as medium. */
56
+ export function limitsFor(pol, level) {
57
+ const five = { ...FIVE, ...(pol.quota_5h ?? {}) };
58
+ return [Math.min(Number(five[level] ?? five.medium), Number(pol.quota_limit ?? 90)), Number(pol.quota_week ?? 95)];
59
+ }
20
60
  export function modelFor(a, role, level) {
21
61
  if (a.models === undefined)
22
62
  return ["", undefined]; // no models configured: the CLI's default
@@ -35,13 +75,18 @@ export function pick(inp) {
35
75
  const { cfg, role, level } = inp;
36
76
  const agents = (cfg.agents ?? {});
37
77
  const pol = (cfg.policy ?? {});
38
- const limit = Number(pol.quota_limit ?? 90);
78
+ const [base5, baseWeek] = limitsFor(pol, level);
39
79
  const w = weights(cfg);
80
+ const now = inp.now ?? Date.now() / 1000;
81
+ const penalty = Number(pol.pacing_penalty ?? 0.3);
40
82
  const res = resolveRole(cfg, role);
41
83
  const names = res.mode === "fixed" ? res.order : res.pool;
42
84
  const notes = [];
43
85
  const out = (n, why) => { notes.push(`${n}: ${why}`); };
44
86
  const pass = [];
87
+ // Only with someone else to ask: a single agent must still be able to review.
88
+ const someoneElse = names.some((n) => n !== inp.implAgent && agents[n]);
89
+ const otherVendor = names.some((n) => n !== inp.implAgent && agents[n] && vendorOf(n, agents[n]) !== inp.implVendor);
45
90
  for (const name of names) {
46
91
  const a = agents[name];
47
92
  if (!a) {
@@ -56,7 +101,11 @@ export function pick(inp) {
56
101
  out(name, m("blocked (orq block -l)", "bloqueado (orq block -l)"));
57
102
  continue;
58
103
  }
59
- if ((role === "review" || role === "opinion") && res.rule === "other-vendor" && inp.implVendor
104
+ if ((role === "review" || role === "opinion") && name === inp.implAgent && someoneElse) {
105
+ out(name, m("it implemented this code", "foi quem implementou"));
106
+ continue;
107
+ }
108
+ if ((role === "review" || role === "opinion") && res.rule === "other-vendor" && inp.implVendor && otherVendor
60
109
  && vendorOf(name, a) === inp.implVendor) {
61
110
  out(name, m("same vendor as the implementer", "mesmo fornecedor do implementador"));
62
111
  continue;
@@ -66,22 +115,68 @@ export function pick(inp) {
66
115
  out(name, m(`no model for ${role}/${level}`, `sem modelo para ${role}/${level}`));
67
116
  continue;
68
117
  }
69
- // Quota: an agent with several accounts (claude) uses the least used one under the limit.
118
+ // Quota: an agent with several accounts (claude) prefers one not projecting past the limit, then the least used.
70
119
  const qn = a.quota ?? [];
120
+ const headroom = Math.max(0, Number(a.headroom ?? 0));
121
+ const limit5 = base5 - headroom, limitWeek = baseWeek - headroom;
71
122
  const accs = qn.filter((n) => !inp.blocks.has(n)).map((n) => [n, n in inp.quota ? inp.quota[n] : -1]);
72
123
  if (qn.length && !accs.length) {
73
124
  out(name, m("all accounts blocked", "todas as contas bloqueadas"));
74
125
  continue;
75
126
  }
76
- const usable = accs.filter(([, v]) => v !== null && v < limit);
127
+ const fix = accs.map(([n]) => inp.windows?.[n]?.expired).filter((x) => Boolean(x));
128
+ if (accs.length && fix.length === accs.length) {
129
+ out(name, fix[0]);
130
+ continue;
131
+ }
132
+ // An idle agy token with nothing cached: the fix is one agy run, not a quota number.
133
+ const noRead = m("no reading yet: run `agy models` once", "sem leitura ainda: rode `agy models` uma vez");
134
+ const idleNoData = ([n, v]) => v === null && IDLE.test(inp.windows?.[n]?.note ?? "");
135
+ if (accs.length && accs.every(idleNoData)) {
136
+ out(name, noRead);
137
+ continue;
138
+ }
139
+ const under = ([n, v]) => {
140
+ const q = inp.windows?.[n];
141
+ return !q?.expired && v !== null && (q?.five ?? (q ? -1 : v)) < limit5 && (q?.week ?? (q ? -1 : v)) < limitWeek;
142
+ };
143
+ const usable = accs.filter(under);
77
144
  if (accs.length && !usable.length) {
78
- out(name, `${m("quota", "cota")} >= ${limit}% ${m("or no data", "ou sem dados")} (${accs.map(([n, v]) => `${n}=${v}`).join(", ")})`);
145
+ const detail = accs.map(([n, v]) => {
146
+ const q = inp.windows?.[n], vn = v ?? -1;
147
+ if (q?.expired)
148
+ return `${n}: ${q.expired}`;
149
+ if (idleNoData([n, v]))
150
+ return `${n}: ${noRead}`;
151
+ const five = q?.five ?? (q ? -1 : vn), week = q?.week ?? (q ? -1 : vn);
152
+ const fiveOver = five >= limit5, weekOver = week >= limitWeek;
153
+ // The account is usable again only once every over-limit window has reset: the latest of their reset times.
154
+ // If one of them has no reset time, that time can't be promised at all.
155
+ const known = [fiveOver ? q?.resetFive : undefined, weekOver ? q?.resetWeek : undefined]
156
+ .filter((t) => t !== undefined);
157
+ const allKnown = (fiveOver || weekOver) && (!fiveOver || q?.resetFive !== undefined) && (!weekOver || q?.resetWeek !== undefined);
158
+ return `${n}=${v}` + (q?.note ? ` (${q.note})` : "") + (allKnown ? ` (${m("back", "volta")} ${humanReset(Math.max(...known))})` : "");
159
+ }).join(", ");
160
+ out(name, `${m("quota", "cota")} >= 5h ${limit5}% / ${m("week", "semana")} ${limitWeek}% ${m("or no data", "ou sem dados")}`
161
+ + (headroom ? ` (headroom ${headroom})` : "") + ` (${detail})`);
79
162
  continue;
80
163
  }
81
- const acct = accs.length > 1 ? [...usable].sort((x, y) => x[1] - y[1])[0][0] : undefined;
164
+ const paceOf = (n) => {
165
+ const q = inp.windows?.[n];
166
+ const p5 = projected(q?.five, q?.resetFive, (q?.spanFive ? q.spanFive * 60 : WINDOW_S.five), now);
167
+ const pw = projected(q?.week, q?.resetWeek, (q?.spanWeek ? q.spanWeek * 60 : WINDOW_S.week), now);
168
+ const paces = [p5, pw].filter((x) => x !== undefined);
169
+ // Hot: past the same per-window limit the filter uses (5h by level, week), headroom included, when pacing is on.
170
+ return { pace: paces.length ? Math.max(...paces) : undefined,
171
+ hot: penalty > 0 && ((p5 !== undefined && p5 > limit5) || (pw !== undefined && pw > limitWeek)) };
172
+ };
173
+ const ranked = usable.map(([n, v]) => ({ n, v: v, ...paceOf(n) }))
174
+ .sort((x, y) => Number(x.hot) - Number(y.hot) || x.v - y.v);
175
+ const chosen = ranked[0];
176
+ const acct = qn.length > 1 ? chosen?.n : undefined;
82
177
  const statRole = role === "fix" || role === "worker" ? "impl" : role;
83
178
  const st = inp.stats.find((r) => r.role === statRole && r.agent === name && r.model === (mf[0].split("/").pop() || "default"));
84
- pass.push({ agent: name, model: mf[0], effort: mf[1], acct, a, st });
179
+ pass.push({ agent: name, model: mf[0], effort: mf[1], acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
85
180
  if (res.mode === "fixed")
86
181
  break; // the first that passes wins
87
182
  }
@@ -102,11 +197,13 @@ export function pick(inp) {
102
197
  const t = few(c.st) || !c.st?.new || !toks.length ? prior : Math.min(...toks) / c.st.new;
103
198
  const s = few(c.st) || !c.st?.min || !mins.length ? prior : Math.min(...mins) / Math.max(c.st.min, 0.1);
104
199
  const used = inp.recent.filter((p) => p.agent === c.agent).length;
200
+ const hot = Boolean(c.hot) && penalty > 0;
105
201
  const score = w.quality * d + w.tokens * t + w.speed * s - w.rotation * used
106
- - (reserve ? w.reserve : 0) + (few(c.st) ? w.explore : 0);
202
+ - (reserve ? w.reserve : 0) + (few(c.st) ? w.explore : 0) - (hot ? penalty : 0);
107
203
  const why = `${m("delivery", "entrega")}=${d.toFixed(2)} ${m("economy", "economia")}=${t.toFixed(2)} `
108
204
  + `${m("speed", "rapidez")}=${s.toFixed(2)} ${m("rotation", "rodízio")}=-${used} `
109
- + (reserve ? m("reserve ", "reserva ") : "") + (few(c.st) ? `${m("few samples", "poucos dados")}(n=${n})` : `n=${n}`);
205
+ + (reserve ? m("reserve ", "reserva ") : "")
206
+ + (hot ? `${m("pace", "ritmo")}=${Math.round(c.pace)}%→-${penalty} ` : "") + (few(c.st) ? `${m("few samples", "poucos dados")}(n=${n})` : `n=${n}`);
110
207
  return { agent: c.agent, model: c.model, effort: c.effort, acct: c.acct, score, why };
111
208
  }).sort((x, y) => y.score - x.score);
112
209
  return { mode: "auto", best: cands[0], cands, notes };
package/dist/quota.js CHANGED
@@ -10,9 +10,12 @@
10
10
  // false left out of the list entirely
11
11
  // "none" no collector: never skipped for quota; use `orq block` when it runs out
12
12
  // { command = "..." } any command printing lines "<name> 5h=NN% week=NN% [note]"
13
- // Output line (read by orq-pick and the board): "<name> 5h=NN% week=NN% [note]".
13
+ // Output line (read by orq-pick and the board): "<name> 5h=NN% week=NN% [reset5h=epoch] [resetweek=epoch] [note]".
14
14
  // - = the plan has no such window (not limiting); ? = no data (expired login, collector failed).
15
+ // reset5h/resetweek (epoch seconds, optional) sit between the windows and the note: whichever window's reset time
16
+ // the collector could read, when it's known.
15
17
  import { spawnSync } from "node:child_process";
18
+ import { createHash } from "node:crypto";
16
19
  import { readdirSync, readFileSync, statSync } from "node:fs";
17
20
  import { homedir, userInfo } from "node:os";
18
21
  import { join } from "node:path";
@@ -22,21 +25,45 @@ import { readSecret } from "./keyring.js";
22
25
  import { stateDir } from "./paths.js";
23
26
  import { m } from "./i18n.js";
24
27
  const show = (p) => (typeof p === "number" ? `${Math.floor(p)}%` : p);
25
- export const format = (q) => `${q.name} 5h=${show(q.five)} week=${show(q.week)}${q.note ? ` [${q.note}]` : ""}`;
28
+ // reset5h/resetweek (epoch seconds) sit between the windows and the note: parseQuota/parseWindows only look at the
29
+ // first three words, so old cached lines (no reset fields) and lines with them both parse the same way.
30
+ export const format = (q) => `${q.name} 5h=${show(q.five)} week=${show(q.week)}`
31
+ + (q.reset5h !== undefined ? ` reset5h=${q.reset5h}` : "") + (q.resetWeek !== undefined ? ` resetweek=${q.resetWeek}` : "")
32
+ + (q.span5h !== undefined ? ` span5h=${q.span5h}` : "") + (q.spanWeek !== undefined ? ` spanweek=${q.spanWeek}` : "")
33
+ + (q.note ? ` [${q.note}]` : "");
26
34
  const noData = (name, note) => ({ name, five: "?", week: "?", note });
35
+ const WD = [["Sun", "dom"], ["Mon", "seg"], ["Tue", "ter"], ["Wed", "qua"], ["Thu", "qui"], ["Fri", "sex"], ["Sat", "sáb"]];
36
+ /** A reset epoch (seconds) as "14:30" when it falls today, else weekday + time. */
37
+ export function humanReset(epochSec, now = Date.now()) {
38
+ const d = new Date(epochSec * 1000), n = new Date(now);
39
+ const hhmm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
40
+ if (d.toDateString() === n.toDateString())
41
+ return hhmm;
42
+ const [en, pt] = WD[d.getDay()];
43
+ return `${m(en, pt)} ${hhmm}`;
44
+ }
27
45
  const KEEP_MS = 5 * 3600_000;
28
46
  const cacheFile = (name) => join(stateDir(), "quota", `${name}.json`);
29
- function remember(name, five, week) {
47
+ const resetOf = (w) => {
48
+ const t = w.resetsAt ? Date.parse(w.resetsAt) : NaN;
49
+ return Number.isNaN(t) ? undefined : Math.floor(t / 1000);
50
+ };
51
+ // `acct` identifies the login behind a reading (antigravity: one login per machine, swapped by the user): a cache
52
+ // written under another login must never stand in for this one.
53
+ function remember(name, five, week, acct) {
30
54
  try {
31
- writeAtomic(cacheFile(name), JSON.stringify({ five, week, at: Date.now() }));
55
+ writeAtomic(cacheFile(name), JSON.stringify({ five, week, at: Date.now(), ...(acct ? { acct } : {}) }));
32
56
  }
33
57
  catch { /* best effort */ }
34
- return { name, five: five.pct, week: week.pct };
58
+ const r5 = resetOf(five), rw = resetOf(week);
59
+ return { name, five: five.pct, week: week.pct, ...(r5 !== undefined ? { reset5h: r5 } : {}), ...(rw !== undefined ? { resetWeek: rw } : {}) };
35
60
  }
36
61
  /** The cached reading (its age in the note), window by window; nothing usable → the "no data" line. */
37
- export function fromCache(name, why, now = Date.now()) {
62
+ export function fromCache(name, why, now = Date.now(), acct) {
38
63
  try {
39
64
  const c = JSON.parse(readFileSync(cacheFile(name), "utf8"));
65
+ if (acct !== undefined && c.acct !== acct)
66
+ return noData(name, why); // another login's reading
40
67
  const still = (w) => {
41
68
  if (w.resetsAt) {
42
69
  const t = Date.parse(w.resetsAt);
@@ -48,7 +75,10 @@ export function fromCache(name, why, now = Date.now()) {
48
75
  const five = still(c.five), week = still(c.week);
49
76
  if (five !== undefined && week !== undefined) {
50
77
  const min = Math.round((now - c.at) / 60_000);
51
- return { name, five, week, note: `${why}; ` + m(`last reading, ${min} min ago`, `última leitura, há ${min} min`) };
78
+ const r5 = resetOf(c.five), rw = resetOf(c.week);
79
+ return { name, five, week, ...(r5 !== undefined && r5 * 1000 > now ? { reset5h: r5 } : {}),
80
+ ...(rw !== undefined && rw * 1000 > now ? { resetWeek: rw } : {}),
81
+ note: `${why}; ` + m(`last reading, ${min} min ago`, `última leitura, há ${min} min`) };
52
82
  }
53
83
  }
54
84
  catch { /* no cache */ }
@@ -143,10 +173,14 @@ export function codexFromSession(text, nowSec) {
143
173
  return undefined;
144
174
  const ws = [last.primary, last.secondary].filter((w) => !!w);
145
175
  const pct = (w) => !w ? "-" : (w.resets_at ?? 0) < nowSec ? 0 : Math.floor(w.used_percent ?? 0);
176
+ const reset = (w) => w?.resets_at !== undefined && w.resets_at >= nowSec ? w.resets_at : undefined;
146
177
  const short = ws.find((x) => (x.window_minutes ?? 0) <= 360);
147
178
  const long = ws.filter((x) => (x.window_minutes ?? 0) > 360).sort((a, b) => b.window_minutes - a.window_minutes)[0];
148
179
  const span = long && long.window_minutes !== 10080 ? long.window_minutes : undefined;
149
- return { five: pct(short), week: pct(long), ...(span ? { span } : {}) };
180
+ const r5 = reset(short), rw = reset(long);
181
+ const span5 = short && short.window_minutes && short.window_minutes !== 300 ? short.window_minutes : undefined;
182
+ return { five: pct(short), week: pct(long), ...(span ? { span } : {}), ...(span5 ? { span5 } : {}), ...(r5 !== undefined ? { reset5h: r5 } : {}),
183
+ ...(rw !== undefined ? { resetWeek: rw } : {}) };
150
184
  }
151
185
  export function codexQuota() {
152
186
  const f = latestCodexSession();
@@ -156,9 +190,9 @@ export function codexQuota() {
156
190
  if (!q)
157
191
  return noData("codex", m("no limit data", "sem dados de limite"));
158
192
  const age = Math.round((Date.now() - statSync(f).mtimeMs) / 60_000);
159
- const { span, ...pcts } = q;
193
+ const { span, span5, ...pcts } = q;
160
194
  const d = span && Math.round(span / 1440);
161
- return { name: "codex", ...pcts, note: (d ? m(`${d}-day window in the week slot; `, `janela de ${d} dias no lugar da semana; `) : "")
195
+ 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; `) : "")
162
196
  + m(`from the last session, ${age} min ago`, `da última sessão, há ${age} min`) };
163
197
  }
164
198
  /** Worst usage per window across models: a reset within 6 h belongs to the 5h window, a later one to the week. */
@@ -179,7 +213,7 @@ export function antigravityFromModels(models, now = Date.now()) {
179
213
  }
180
214
  return { five, week };
181
215
  }
182
- function agyToken() {
216
+ export function agyToken() {
183
217
  // agy (go-keyring) keeps {"token": {access_token, expiry, ...}} under service "gemini", user "antigravity"
184
218
  // (on Windows go-keyring names the credential "gemini:antigravity").
185
219
  const raw = readSecret("gemini", "antigravity", "gemini:antigravity");
@@ -187,21 +221,34 @@ function agyToken() {
187
221
  return undefined;
188
222
  try {
189
223
  const t = JSON.parse(raw).token;
190
- return { access: t?.access_token, expiry: t?.expiry };
224
+ // The refresh token is stable for a login and differs between accounts (the access token rotates hourly).
225
+ const id = t?.refresh_token ?? t?.id_token;
226
+ return { access: t?.access_token, expiry: t?.expiry, refresh: !!t?.refresh_token,
227
+ ...(id ? { acct: createHash("sha256").update(String(id)).digest("hex").slice(0, 16) } : {}) };
191
228
  }
192
229
  catch {
193
230
  return undefined;
194
231
  }
195
232
  }
233
+ /** Why the stored agy token can't be used as is: idle (expired, agy renews it on its next run) or login expired (no refresh token). */
234
+ export function agyNote(t, now = Date.now()) {
235
+ const at = t.expiry ? Date.parse(t.expiry) : NaN;
236
+ if (Number.isNaN(at) || at >= now)
237
+ return undefined;
238
+ return t.refresh ? m("token idle (agy refreshes on next run)", "token ocioso (o agy renova no próximo uso)")
239
+ : m("login expired: run `agy models`", "login expirado: rode `agy models`");
240
+ }
196
241
  export async function antigravityQuota() {
197
242
  const name = "antigravity";
198
243
  const t = agyToken();
199
244
  if (!t?.access)
200
245
  return noData(name, m("no agy login in the keyring", "sem login do agy no chaveiro"));
201
- // Never refresh it here (that is agy's login): an expired one just means "run agy once" (`agy models` is enough).
246
+ // 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
+ // passed expiry with a refresh token is just idle (a note, not an expired login); without one, "run agy once".
202
248
  const expired = m("login expired: run `agy models`", "login expirado: rode `agy models`");
203
- if (t.expiry && Date.parse(t.expiry) < Date.now())
204
- return fromCache(name, expired);
249
+ const note = agyNote(t);
250
+ if (note)
251
+ return fromCache(name, note, undefined, t.acct);
205
252
  try {
206
253
  const r = await fetch("https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", {
207
254
  method: "POST", body: "{}",
@@ -210,30 +257,45 @@ export async function antigravityQuota() {
210
257
  signal: AbortSignal.timeout(15_000),
211
258
  });
212
259
  if (r.status === 401)
213
- return fromCache(name, expired);
260
+ return fromCache(name, expired, undefined, t.acct);
214
261
  if (!r.ok)
215
- return fromCache(name, `HTTP ${r.status}`);
262
+ return fromCache(name, `HTTP ${r.status}`, undefined, t.acct);
216
263
  const j = await r.json();
217
264
  const { five, week } = antigravityFromModels(j.models ?? {});
218
- return remember(name, five, week);
265
+ return remember(name, five, week, t.acct);
219
266
  }
220
267
  catch (e) {
221
- return fromCache(name, m("collector failed", "coletor falhou") + `: ${e.message}`);
268
+ return fromCache(name, m("collector failed", "coletor falhou") + `: ${e.message}`, undefined, t.acct);
222
269
  }
223
270
  }
224
271
  // --- copilot --------------------------------------------------------------------------------------------------------
272
+ /** The same moment one calendar month earlier; a day the month lacks (31 Mar → Feb) clamps to its last day. */
273
+ export function monthBefore(d) {
274
+ const out = new Date(d);
275
+ out.setDate(1);
276
+ out.setMonth(out.getMonth() - 1);
277
+ out.setDate(Math.min(d.getDate(), new Date(out.getFullYear(), out.getMonth() + 1, 0).getDate()));
278
+ return out;
279
+ }
225
280
  /** Premium requests used this month, from the snapshot `gh api /copilot_internal/user` returns. No 5h window. */
226
281
  export function copilotFromUser(j) {
227
282
  const u = j;
228
283
  const p = u?.quota_snapshots?.premium_interactions;
229
284
  if (!p)
230
285
  return noData("copilot", m("no premium request data", "sem dados de requisições premium"));
286
+ // A bare date ("2026-11-01") would parse as UTC midnight: local midnight matches the date the note itself shows
287
+ // (same fix as parseLocal in commands/block.ts).
288
+ const at = u.quota_reset_date
289
+ ? Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(u.quota_reset_date) ? `${u.quota_reset_date}T00:00` : u.quota_reset_date)
290
+ : NaN;
291
+ const prev = Number.isNaN(at) ? new Date(NaN) : monthBefore(new Date(at));
292
+ const resetWeek = Number.isNaN(at) ? {} : { resetWeek: Math.floor(at / 1000), spanWeek: Math.round((at - prev.getTime()) / 60_000) };
231
293
  if (p.unlimited)
232
294
  return { name: "copilot", five: "-", week: "-", note: m("unlimited", "ilimitado") };
233
295
  if (typeof p.percent_remaining !== "number")
234
296
  return noData("copilot", m("no premium request data", "sem dados de requisições premium"));
235
297
  const reset = u.quota_reset_date ? m(`, resets ${u.quota_reset_date}`, `, renova em ${u.quota_reset_date}`) : "";
236
- return { name: "copilot", five: "-", week: Math.max(0, 100 - p.percent_remaining),
298
+ return { name: "copilot", five: "-", week: Math.max(0, 100 - p.percent_remaining), ...resetWeek,
237
299
  note: m("monthly premium requests", "requisições premium do mês") + reset };
238
300
  }
239
301
  export function copilotQuota() {
@@ -266,7 +328,7 @@ export function minimaxFromRemains(j) {
266
328
  return { five: worst("current_interval_remaining_percent", "end_time"),
267
329
  week: worst("current_weekly_remaining_percent", "weekly_end_time") };
268
330
  }
269
- function minimaxKey() {
331
+ export function minimaxKey() {
270
332
  if (process.env.MINIMAX_API_KEY)
271
333
  return process.env.MINIMAX_API_KEY;
272
334
  const data = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
package/dist/rounds.js ADDED
@@ -0,0 +1,167 @@
1
+ // Watchdog layer 1: per-issue round metrics from the files already in .orquestra/<ID>/, no LLM and no extra recording.
2
+ // fix-<n>.md the blockers sent to the implementer in fix round n (its mtime = the round started)
3
+ // review-<repo>.out.md the first review (round 0); review-<repo>-<n>.out.md the review after fix round n
4
+ // Rounds = highest fix-<n> (or the .rounds-<session> counter `orq agent -R` keeps). Findings are the tagged list items
5
+ // of each review ("- [blocker] src/a.ts:10 title", or a leading bold severity word in PT/EN: "1. **Bloqueante** — ...",
6
+ // "- **Sugestão — `a.ts:7–8`:** ..."; "**Critério 1**" is not one). Files may be markdown links; a ".worktrees/<dir>/" prefix is
7
+ // stripped so a file matches across rounds. A finding "repeats" when a later review has one on the same file
8
+ // with similar words (token overlap, see similar()).
9
+ import { readdirSync, readFileSync, statSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ export const SIMILAR_FILE = 0.4; // token overlap needed when both findings name the same file
12
+ export const SIMILAR_LOOSE = 0.6; // ... when a file is missing on either side
13
+ export const SLOW_FACTOR = 2; // a round this many times slower than the median of the others is flagged
14
+ export const SLOW_MIN_SECS = 600; // ... but only if it also took at least this long
15
+ export const REPEAT_WARN = 2; // a finding seen in this many reviews: warn
16
+ export const REPEAT_STOP = 3; // ... in this many: stop (it came back after two fix rounds)
17
+ export const HEAVY = new Set(["blocker", "major", "critical"]); // only these count towards repeats and the alert
18
+ const read = (f) => { try {
19
+ return readFileSync(f, "utf8");
20
+ }
21
+ catch {
22
+ return "";
23
+ } };
24
+ const STOP = new Set(["the", "a", "an", "of", "in", "on", "to", "is", "are", "and", "or", "for", "with", "not", "no", "this", "that", "it",
25
+ "de", "do", "da", "o", "os", "as", "um", "uma", "em", "no", "na", "para", "com", "que", "se", "não"]);
26
+ const words = (s) => new Set(s.toLowerCase().replace(/[^\p{L}\p{N}_]+/gu, " ").split(" ").filter((w) => w.length > 2 && !STOP.has(w)));
27
+ const SEV = {
28
+ bloqueante: "blocker", bloqueador: "blocker", blocker: "blocker", critico: "critical", critical: "critical",
29
+ importante: "major", important: "major", major: "major", menor: "minor", minor: "minor",
30
+ sugestao: "suggestion", suggestion: "suggestion", nit: "nit",
31
+ };
32
+ const fold = (w) => w.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
33
+ const URL = /^[a-z][a-z0-9+.-]*:\/\//i;
34
+ // GitHub-style ".../blob/<ref>/<path>" → "<path>" (no query, no #L4), so URL mentions match plain-path mentions.
35
+ const blobPath = (u) => /^[a-z][a-z0-9+.-]*:\/\/[^/]+\/[^/]+\/[^/]+\/blob\/[^/]+\/([^#?\s)]+)/i.exec(u)?.[1];
36
+ const LINK = /\[([^\]]*)\]\(([^)\s]*)\)/g;
37
+ // Reviews run in a different worktree per round (".worktrees/<repo>-<id>.review-<pid>/"): drop it so the same file matches across rounds.
38
+ const repoRel = (f) => f.replace(/\\/g, "/").replace(/^.*\.worktrees\/[^/]+\//, "");
39
+ /** One review-list item → finding. Only items carrying a severity (a "[blocker]" tag or a leading bold "**Bloqueante**") count. */
40
+ export function parseFindings(text, round) {
41
+ const out = [];
42
+ for (const line of text.split("\n")) {
43
+ const it = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line);
44
+ if (!it)
45
+ continue;
46
+ const sevOf = (w) => { const k = fold(w ?? ""); return Object.hasOwn(SEV, k) ? SEV[k] : undefined; };
47
+ let m0 = /^\*\*\s*(\p{L}+)/u.exec(it[1]); // a leading bold severity wins over a [tag]
48
+ if (!sevOf(m0?.[1]))
49
+ m0 = /\[(blocker|major|minor|nit|suggestion|important|critical)\]/i.exec(it[1]);
50
+ const sev = sevOf(m0?.[1]);
51
+ if (!m0 || !sev)
52
+ continue;
53
+ const rest = it[1].replace(m0[0], " ");
54
+ const byTarget = rest.replace(LINK, (_, t, p) => ` ${URL.test(p) ? blobPath(p) ?? t : repoRel(p)} `)
55
+ .replace(/[a-z][a-z0-9+.-]*:\/\/\S+/gi, (u) => ` ${blobPath(u) ?? ""} `);
56
+ const m = /([\w@./\\-]+\.\w+)(?::\d+(?:[-\u2013]\d+)?)?/.exec(byTarget);
57
+ const file = m ? repoRel(m[1]) : "";
58
+ out.push({ round, severity: sev, file, title: rest.replace(LINK, "$1").replace(/[`*_]/g, "").replace(/\s+/g, " ").trim() });
59
+ }
60
+ return out;
61
+ }
62
+ /** Same finding? Same file (when both name one) and enough shared words; without a file, a stricter overlap. */
63
+ export function similar(a, b) {
64
+ const wa = words(a.title), wb = words(b.title);
65
+ if (!wa.size || !wb.size)
66
+ return false;
67
+ let inter = 0;
68
+ for (const w of wa)
69
+ if (wb.has(w))
70
+ inter++;
71
+ const j = inter / (wa.size + wb.size - inter), both = a.file && b.file;
72
+ if (both && a.file !== b.file)
73
+ return false;
74
+ return j >= (both ? SIMILAR_FILE : SIMILAR_LOOSE);
75
+ }
76
+ /** Findings that reappear in more than one round. Each group is matched against its first member. */
77
+ export function repeats(fs) {
78
+ const groups = [];
79
+ for (const f of [...fs].sort((a, b) => a.round - b.round)) {
80
+ const g = groups.find((x) => similar(x[0], f));
81
+ if (g)
82
+ g.push(f);
83
+ else
84
+ groups.push([f]);
85
+ }
86
+ return groups.map((g) => ({ file: g[0].file, title: g[0].title, rounds: [...new Set(g.map((x) => x.round))] }))
87
+ .filter((r) => r.rounds.length >= REPEAT_WARN);
88
+ }
89
+ 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
+ /** Fix rounds far above the median of the other fix rounds (round 0 is the implementation: never compared; needs 3 fix rounds). */
91
+ export function slowRounds(all) {
92
+ const per = all.filter((r) => r.round > 0);
93
+ if (per.length < 3)
94
+ return [];
95
+ return per.filter((r) => r.secs >= SLOW_MIN_SECS && r.secs > SLOW_FACTOR * median(per.filter((x) => x !== r).map((x) => x.secs))).map((r) => r.round);
96
+ }
97
+ /** The pure part: facts from the files → numbers, alert level and why. */
98
+ export function metrics(issue, f) {
99
+ const reps = repeats(f.findings.filter((x) => HEAVY.has(x.severity))), reasons = [];
100
+ let level = "ok";
101
+ const raise = (l, why) => { reasons.push(why); if (l === "stop" || level === "ok")
102
+ level = l; };
103
+ if (f.rounds > f.max)
104
+ raise("stop", { code: "over_max", n: f.rounds, max: f.max });
105
+ else if (f.rounds >= f.max && f.max > 0)
106
+ raise("warn", { code: "at_max", n: f.rounds, max: f.max });
107
+ const worst = Math.max(0, ...reps.map((r) => r.rounds.length));
108
+ if (worst >= REPEAT_STOP)
109
+ raise("stop", { code: "same_finding", n: worst });
110
+ else if (reps.length)
111
+ raise("warn", { code: "repeated", n: reps.length });
112
+ const slow = slowRounds(f.perRound);
113
+ if (slow.length)
114
+ raise("warn", { code: "slow", n: slow.length, rounds: slow });
115
+ return { issue, rounds: f.rounds, max: f.max, secs: f.secs, perRound: f.perRound, findings: f.findings.length, repeats: reps, level, reasons };
116
+ }
117
+ /** The impure part: read .orquestra/<issue>/. undefined when the issue has no fix or review files at all. */
118
+ export function collectRounds(ws, issue, max) {
119
+ const dir = join(ws, ".orquestra", issue);
120
+ let names;
121
+ try {
122
+ names = readdirSync(dir);
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
127
+ const mt = (n) => statSync(join(dir, n)).mtimeMs;
128
+ const fixes = new Map(), reviews = new Map();
129
+ let counter = 0;
130
+ for (const n of names) {
131
+ const fx = /^fix-(\d+)\.md$/.exec(n);
132
+ if (fx)
133
+ fixes.set(Number(fx[1]), mt(n));
134
+ }
135
+ for (const n of names) {
136
+ // A trailing -<n> is a round only when fix-<n>.md exists; otherwise it is part of the repo name (round 0).
137
+ const rv = /^review-(.+?)(?:-(\d+))?\.out\.md$/.exec(n), k = rv && rv[2] !== undefined && fixes.has(Number(rv[2])) ? Number(rv[2]) : 0;
138
+ if (rv)
139
+ reviews.set(k, [...(reviews.get(k) ?? []), n]);
140
+ else if (n.startsWith(".rounds-"))
141
+ counter = Math.max(counter, Number(read(join(dir, n))) || 0);
142
+ }
143
+ if (!reviews.size && !fixes.size)
144
+ return undefined;
145
+ const rounds = Math.max(counter, 0, ...fixes.keys());
146
+ const all = names.filter((n) => !n.startsWith(".")).map(mt), t0 = Math.min(...all), t1 = Math.max(...all);
147
+ // Round k runs from fix-k.md (round 0: the oldest file) to the next fix, the last one to the newest file.
148
+ const starts = [t0, ...Array.from({ length: rounds }, (_, i) => fixes.get(i + 1) ?? NaN)];
149
+ const perRound = starts.map((s, k) => {
150
+ const next = k < rounds ? starts[k + 1] : t1;
151
+ return { round: k, secs: Number.isNaN(s) || Number.isNaN(next) ? 0 : Math.max(0, Math.round((next - s) / 1000)) };
152
+ });
153
+ const findings = [...reviews].flatMap(([k, fs]) => fs.flatMap((n) => parseFindings(read(join(dir, n)), k)));
154
+ return metrics(issue, { rounds, max, perRound, findings, secs: Math.round((t1 - t0) / 1000) });
155
+ }
156
+ /** Cheap change signature of an issue folder (names + mtimes, no reads) for caching collectRounds between redraws. */
157
+ export function roundsSig(ws, issue) {
158
+ const dir = join(ws, ".orquestra", issue);
159
+ try {
160
+ return readdirSync(dir).filter((n) => /^(fix-\d+\.md|review-.+\.out\.md|\.rounds-.+)$/.test(n)).map((n) => `${n}:${statSync(join(dir, n)).mtimeMs}`).join("|");
161
+ }
162
+ catch {
163
+ return "";
164
+ }
165
+ }
166
+ /** Board marker: "r2/2 ⚠", "r3/2 ✗"; just "r1/2" when ok. */
167
+ export const mark = (x) => `r${x.rounds}/${x.max}${x.level === "warn" ? " ⚠" : x.level === "stop" ? " ✗" : ""}`;
package/dist/testrun.js CHANGED
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
9
9
  import { writeAtomic } from "./fsutil.js";
10
10
  import { m } from "./i18n.js";
11
11
  import { acquire, release } from "./lock.js";
12
+ import { stateDir } from "./paths.js";
12
13
  import { killTree, parseSize, runCapped } from "./process.js";
13
14
  import { closeOwnTab, detectHost, launch } from "./terminal.js";
14
15
  export const JS_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/;
@@ -196,12 +197,28 @@ export async function inJob(j, label, slots, body, done) {
196
197
  }
197
198
  return st === "ok" ? 0 : 1;
198
199
  }
200
+ /** Where `orq handoff` records the pane that replaced a conductor's. */
201
+ export const handoffFile = (pane) => join(stateDir(), "handoff", pane.replace(/\W/g, "_"));
199
202
  /** The conductor is a herdr pane: type the line into it (herdr-notify waits for it to leave a dialog). In the
200
203
  * background when the caller must not wait for that (an agent's end-of-turn hook holds its screen meanwhile). */
201
204
  export function notify(pane, line, background = false) {
205
+ const seen = new Set();
206
+ while (!seen.has(pane) && existsSync(handoffFile(pane))) {
207
+ seen.add(pane);
208
+ let next;
209
+ try {
210
+ next = readFileSync(handoffFile(pane), "utf8").trim();
211
+ }
212
+ catch {
213
+ break;
214
+ } // gone between the two calls
215
+ if (!next || next === pane)
216
+ break;
217
+ pane = next; // follow repeated handoffs to the current conductor
218
+ }
202
219
  const local = join(dirname(CLI), "..", "setup", "herdr-notify"), cmd = existsSync(local) ? local : "herdr-notify";
203
220
  if (background)
204
- spawn(cmd, [pane, line], { detached: true, stdio: "ignore" }).unref();
221
+ spawn(cmd, [pane, line], { detached: true, stdio: "ignore" }).on("error", () => { }).unref();
205
222
  else
206
223
  spawnSync(cmd, [pane, line], { stdio: "inherit" });
207
224
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irythill/orq",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Orchestrate coding agents (Claude Code, Codex, OpenCode, Antigravity, Copilot) from one conductor.",
5
5
  "license": "MIT",
6
6
  "type": "module",