@irythill/orq 0.3.0 → 0.5.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,20 @@
1
+ // Picks agent, model, effort and account for a role (port of orq-pick, plus fixed roles).
2
+ // fixed role: the first agent of [agent, ...fallback] that passes the filters wins — no scoring, never swapped
3
+ // for an agent the user didn't list.
4
+ // auto role: every pool agent that passes is scored:
5
+ // quality * delivery + tokens * economy + speed * quickness − rotation * (picks in the last `window`)
6
+ // − reserve (agents with reserve = true) + explore (fewer than `min_samples` runs)
7
+ // Filters: --exclude-agent, orq block, nobody reviews their own code (unless no other agent can), cross-vendor rule, a model for the role/level,
8
+ // quota under the limits: policy.quota_week (95) on the weekly window, and on the 5h one policy.quota_5h per level
9
+ // (simple 90, medium 85, heavy 80): a heavy task needs more room left to finish.
10
+ // An agent's `headroom` (points) lowers both limits for it: that share of its accounts stays for the conductors.
11
+ // An account whose quota reading says the login expired is skipped, with the fix command in the note.
12
+ // Pacing: an account whose usage, at the pace so far in a window (used ÷ elapsed share), projects past
13
+ // that window's pick limit (the filter's) at its reset is passed over for another account under the limit when the agent has several,
14
+ // and otherwise loses policy.pacing_penalty (default 0.3, 0 = off) of score — soft, never a drop.
15
+ import { agentDef, usesPrompt } from "./agents.js";
1
16
  import { m } from "./i18n.js";
17
+ import { humanReset } from "./quota.js";
2
18
  import { legacyRole, resolveRole, vendorOf } from "./roles.js";
3
19
  /** "<name> 5h=NN% week=NN% [note]" lines → QuotaMap (same reading as the old orq-pick). */
4
20
  export function parseQuota(text) {
@@ -17,12 +33,51 @@ export function parseQuota(text) {
17
33
  }
18
34
  return out;
19
35
  }
36
+ /** The same lines → each window's %, plus its reset time (epoch seconds) when the line carries one. */
37
+ export function parseWindows(text) {
38
+ const out = {};
39
+ const num = (v) => (v && /^\d+%$/.test(v) ? Number(v.slice(0, -1)) : undefined);
40
+ for (const l of text.split(/\r?\n/)) {
41
+ const r = /^(\S+) 5h=(\S+) week=(\S+)(?: reset5h=(\d+))?(?: resetweek=(\d+))?(?: span5h=(\d+))?(?: spanweek=(\d+))?/.exec(l.trim());
42
+ if (!r)
43
+ continue;
44
+ // "[login expired: run `agy models`; last reading, 5 min ago]" → the part before the first ";" (the fix to show)
45
+ const note = /\[([^\]]*)\]\s*$/.exec(l)?.[1].split(";")[0].trim();
46
+ out[r[1]] = { five: num(r[2]), week: num(r[3]), ...(r[4] ? { resetFive: Number(r[4]) } : {}),
47
+ ...(r[5] ? { resetWeek: Number(r[5]) } : {}), ...(r[6] ? { spanFive: Number(r[6]) } : {}),
48
+ ...(r[7] ? { spanWeek: Number(r[7]) } : {}), ...(note && EXPIRED.test(note) ? { expired: note } : note ? { note } : {}) };
49
+ }
50
+ return out;
51
+ }
52
+ // Both languages orq writes these notes in (the collectors' not-logged-in and expired-login texts).
53
+ /** agy's idle-token note (collector text, both languages). */
54
+ const IDLE = /token idle|token ocioso/i;
55
+ const EXPIRED = /login expired|login expirado|not logged in|no agy login|sem login/i;
56
+ /** Window lengths in seconds, unless the quota line publishes another (span5h/spanweek, minutes). */
57
+ const WINDOW_S = { five: 5 * 3600, week: 7 * 86400 };
58
+ /** Usage at the window's reset if the pace so far continues (used ÷ elapsed share); undefined when it can't be told:
59
+ * no figure or reset time, or under 10% of the window elapsed (too early to project). */
60
+ export function projected(pct, reset, size, now) {
61
+ if (pct === undefined || pct < 0 || reset === undefined)
62
+ return undefined;
63
+ const left = reset - now;
64
+ if (left <= 0 || left > size)
65
+ return undefined;
66
+ const elapsed = (size - left) / size;
67
+ return elapsed < 0.1 ? undefined : pct / elapsed;
68
+ }
69
+ const FIVE = { simple: 90, medium: 85, heavy: 80 };
70
+ /** [5h limit, weekly limit] for a task level; an unknown level counts as medium. */
71
+ export function limitsFor(pol, level) {
72
+ const five = { ...FIVE, ...(pol.quota_5h ?? {}) };
73
+ return [Math.min(Number(five[level] ?? five.medium), Number(pol.quota_limit ?? 90)), Number(pol.quota_week ?? 95)];
74
+ }
20
75
  export function modelFor(a, role, level) {
21
76
  if (a.models === undefined)
22
77
  return ["", undefined]; // no models configured: the CLI's default
23
78
  const models = a.models;
24
79
  const lr = legacyRole(role);
25
- const ms = models[lr] ?? (lr === "fix" ? models.impl : undefined) ?? {};
80
+ const ms = models[lr] ?? (lr === "fix" ? models.impl : lr === "watchdog" ? models.opinion ?? models.review : undefined) ?? {};
26
81
  const spec = ms[level] ?? ms.default ?? ms.medium ?? ms.simple;
27
82
  if (!spec)
28
83
  return undefined;
@@ -31,17 +86,31 @@ export function modelFor(a, role, level) {
31
86
  }
32
87
  const WEIGHTS = { quality: 0.4, tokens: 0.45, speed: 0.15, rotation: 0.2, reserve: 0.3, explore: 0.1, min_samples: 8, window: 6 };
33
88
  export const weights = (cfg) => ({ ...WEIGHTS, ...((cfg.policy ?? {}).weights ?? {}) });
89
+ /** The merged definition (built-in + config) takes a prompt; an agent that can't be resolved doesn't. */
90
+ function takesPrompt(cfg, name) {
91
+ try {
92
+ return usesPrompt(agentDef(cfg, name));
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
34
98
  export function pick(inp) {
35
99
  const { cfg, role, level } = inp;
36
100
  const agents = (cfg.agents ?? {});
37
101
  const pol = (cfg.policy ?? {});
38
- const limit = Number(pol.quota_limit ?? 90);
102
+ const [base5, baseWeek] = limitsFor(pol, level);
39
103
  const w = weights(cfg);
104
+ const now = inp.now ?? Date.now() / 1000;
105
+ const penalty = Number(pol.pacing_penalty ?? 0.3);
40
106
  const res = resolveRole(cfg, role);
41
107
  const names = res.mode === "fixed" ? res.order : res.pool;
42
108
  const notes = [];
43
109
  const out = (n, why) => { notes.push(`${n}: ${why}`); };
44
110
  const pass = [];
111
+ // Only with someone else to ask: a single agent must still be able to review.
112
+ const someoneElse = names.some((n) => n !== inp.implAgent && agents[n]);
113
+ const otherVendor = names.some((n) => n !== inp.implAgent && agents[n] && vendorOf(n, agents[n]) !== inp.implVendor);
45
114
  for (const name of names) {
46
115
  const a = agents[name];
47
116
  if (!a) {
@@ -56,32 +125,87 @@ export function pick(inp) {
56
125
  out(name, m("blocked (orq block -l)", "bloqueado (orq block -l)"));
57
126
  continue;
58
127
  }
59
- if ((role === "review" || role === "opinion") && res.rule === "other-vendor" && inp.implVendor
128
+ if ((role === "review" || role === "opinion" || role === "watchdog") && name === inp.implAgent && someoneElse) {
129
+ out(name, m("it implemented this code", "foi quem implementou"));
130
+ continue;
131
+ }
132
+ if ((role === "review" || role === "opinion" || role === "watchdog") && res.rule === "other-vendor" && inp.implVendor && otherVendor
60
133
  && vendorOf(name, a) === inp.implVendor) {
61
134
  out(name, m("same vendor as the implementer", "mesmo fornecedor do implementador"));
62
135
  continue;
63
136
  }
137
+ if (role === "watchdog" && !takesPrompt(cfg, name)) {
138
+ out(name, m("ignores prompts (a review tool)", "ignora prompts (ferramenta de revisão)"));
139
+ continue;
140
+ }
64
141
  const mf = modelFor(a, role, level);
65
142
  if (!mf) {
66
143
  out(name, m(`no model for ${role}/${level}`, `sem modelo para ${role}/${level}`));
67
144
  continue;
68
145
  }
69
- // Quota: an agent with several accounts (claude) uses the least used one under the limit.
146
+ // Quota: an agent with several accounts (claude) prefers one not projecting past the limit, then the least used.
70
147
  const qn = a.quota ?? [];
148
+ const headroom = Math.max(0, Number(a.headroom ?? 0));
149
+ const limit5 = base5 - headroom, limitWeek = baseWeek - headroom;
71
150
  const accs = qn.filter((n) => !inp.blocks.has(n)).map((n) => [n, n in inp.quota ? inp.quota[n] : -1]);
72
151
  if (qn.length && !accs.length) {
73
152
  out(name, m("all accounts blocked", "todas as contas bloqueadas"));
74
153
  continue;
75
154
  }
76
- const usable = accs.filter(([, v]) => v !== null && v < limit);
155
+ const fix = accs.map(([n]) => inp.windows?.[n]?.expired).filter((x) => Boolean(x));
156
+ if (accs.length && fix.length === accs.length) {
157
+ out(name, fix[0]);
158
+ continue;
159
+ }
160
+ // An idle agy token with nothing cached: the fix is one agy run, not a quota number.
161
+ const noRead = m("no reading yet: run `agy models` once", "sem leitura ainda: rode `agy models` uma vez");
162
+ const idleNoData = ([n, v]) => v === null && IDLE.test(inp.windows?.[n]?.note ?? "");
163
+ if (accs.length && accs.every(idleNoData)) {
164
+ out(name, noRead);
165
+ continue;
166
+ }
167
+ const under = ([n, v]) => {
168
+ const q = inp.windows?.[n];
169
+ return !q?.expired && v !== null && (q?.five ?? (q ? -1 : v)) < limit5 && (q?.week ?? (q ? -1 : v)) < limitWeek;
170
+ };
171
+ const usable = accs.filter(under);
77
172
  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(", ")})`);
173
+ const detail = accs.map(([n, v]) => {
174
+ const q = inp.windows?.[n], vn = v ?? -1;
175
+ if (q?.expired)
176
+ return `${n}: ${q.expired}`;
177
+ if (idleNoData([n, v]))
178
+ return `${n}: ${noRead}`;
179
+ const five = q?.five ?? (q ? -1 : vn), week = q?.week ?? (q ? -1 : vn);
180
+ const fiveOver = five >= limit5, weekOver = week >= limitWeek;
181
+ // The account is usable again only once every over-limit window has reset: the latest of their reset times.
182
+ // If one of them has no reset time, that time can't be promised at all.
183
+ const known = [fiveOver ? q?.resetFive : undefined, weekOver ? q?.resetWeek : undefined]
184
+ .filter((t) => t !== undefined);
185
+ const allKnown = (fiveOver || weekOver) && (!fiveOver || q?.resetFive !== undefined) && (!weekOver || q?.resetWeek !== undefined);
186
+ return `${n}=${v}` + (q?.note ? ` (${q.note})` : "") + (allKnown ? ` (${m("back", "volta")} ${humanReset(Math.max(...known))})` : "");
187
+ }).join(", ");
188
+ out(name, `${m("quota", "cota")} >= 5h ${limit5}% / ${m("week", "semana")} ${limitWeek}% ${m("or no data", "ou sem dados")}`
189
+ + (headroom ? ` (headroom ${headroom})` : "") + ` (${detail})`);
79
190
  continue;
80
191
  }
81
- const acct = accs.length > 1 ? [...usable].sort((x, y) => x[1] - y[1])[0][0] : undefined;
192
+ const paceOf = (n) => {
193
+ const q = inp.windows?.[n];
194
+ const p5 = projected(q?.five, q?.resetFive, (q?.spanFive ? q.spanFive * 60 : WINDOW_S.five), now);
195
+ const pw = projected(q?.week, q?.resetWeek, (q?.spanWeek ? q.spanWeek * 60 : WINDOW_S.week), now);
196
+ const paces = [p5, pw].filter((x) => x !== undefined);
197
+ // Hot: past the same per-window limit the filter uses (5h by level, week), headroom included, when pacing is on.
198
+ return { pace: paces.length ? Math.max(...paces) : undefined,
199
+ hot: penalty > 0 && ((p5 !== undefined && p5 > limit5) || (pw !== undefined && pw > limitWeek)) };
200
+ };
201
+ const ranked = usable.map(([n, v]) => ({ n, v: v, ...paceOf(n) }))
202
+ .sort((x, y) => Number(x.hot) - Number(y.hot) || x.v - y.v);
203
+ const chosen = ranked[0];
204
+ const acct = qn.length > 1 ? chosen?.n : undefined;
82
205
  const statRole = role === "fix" || role === "worker" ? "impl" : role;
83
- 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 });
206
+ const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (mf[0].split("/").pop() || "default"));
207
+ const st = stOf(statRole) ?? (role === "watchdog" ? stOf("review") : undefined); // no watchdog runs yet: its review record
208
+ pass.push({ agent: name, model: mf[0], effort: mf[1], acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
85
209
  if (res.mode === "fixed")
86
210
  break; // the first that passes wins
87
211
  }
@@ -102,11 +226,13 @@ export function pick(inp) {
102
226
  const t = few(c.st) || !c.st?.new || !toks.length ? prior : Math.min(...toks) / c.st.new;
103
227
  const s = few(c.st) || !c.st?.min || !mins.length ? prior : Math.min(...mins) / Math.max(c.st.min, 0.1);
104
228
  const used = inp.recent.filter((p) => p.agent === c.agent).length;
229
+ const hot = Boolean(c.hot) && penalty > 0;
105
230
  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);
231
+ - (reserve ? w.reserve : 0) + (few(c.st) ? w.explore : 0) - (hot ? penalty : 0);
107
232
  const why = `${m("delivery", "entrega")}=${d.toFixed(2)} ${m("economy", "economia")}=${t.toFixed(2)} `
108
233
  + `${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}`);
234
+ + (reserve ? m("reserve ", "reserva ") : "")
235
+ + (hot ? `${m("pace", "ritmo")}=${Math.round(c.pace)}%→-${penalty} ` : "") + (few(c.st) ? `${m("few samples", "poucos dados")}(n=${n})` : `n=${n}`);
110
236
  return { agent: c.agent, model: c.model, effort: c.effort, acct: c.acct, score, why };
111
237
  }).sort((x, y) => y.score - x.score);
112
238
  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/roles.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** What the conductor asks for (orq pick <role>) → the [roles] section that answers it. */
2
- export const PICK_ROLES = ["impl", "fix", "worker", "review", "opinion"];
2
+ export const PICK_ROLES = ["impl", "fix", "worker", "review", "opinion", "watchdog"];
3
3
  const SECTION = {
4
- impl: ["primary"], fix: ["primary"], worker: ["workers", "primary"], review: ["reviewer"], opinion: ["opinion"],
4
+ impl: ["primary"], fix: ["primary"], worker: ["workers", "primary"], review: ["reviewer"], opinion: ["opinion"], watchdog: ["watchdog"],
5
5
  };
6
6
  /** The role name used in the old agents.<name>.roles lists. */
7
7
  export const legacyRole = (r) => (r === "worker" ? "impl" : r);
@@ -10,7 +10,16 @@ export const BUILTIN_VENDORS = {
10
10
  claude: "anthropic", codex: "openai", antigravity: "google", copilot: "github",
11
11
  };
12
12
  export const vendorOf = (name, a) => a?.vendor ?? BUILTIN_VENDORS[name];
13
+ const count = (r) => (r.mode === "fixed" ? r.order : r.pool).length;
14
+ /** The watchdog (orq rounds --diagnose) uses its own role when something declares it, else the opinion pool, else the review one. */
13
15
  export function resolveRole(cfg, role) {
16
+ const own = direct(cfg, role);
17
+ if (role !== "watchdog" || count(own))
18
+ return own;
19
+ const op = direct(cfg, "opinion");
20
+ return count(op) ? op : direct(cfg, "review");
21
+ }
22
+ function direct(cfg, role) {
14
23
  const agents = (cfg.agents ?? {});
15
24
  const roles = (cfg.roles ?? {});
16
25
  const policyRule = String((cfg.policy ?? {}).review ?? "other-vendor");
package/dist/rounds.js ADDED
@@ -0,0 +1,176 @@
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
+ /** Rounds of an issue folder: the highest .rounds-<session> counter or fix-<n>.md. Shared by `orq rounds` and the -F gate. */
118
+ export function countRounds(dir, names) {
119
+ let n = 0;
120
+ for (const f of names) {
121
+ const fx = /^fix-(\d+)\.md$/.exec(f);
122
+ if (fx)
123
+ n = Math.max(n, Number(fx[1]));
124
+ else if (f.startsWith(".rounds-"))
125
+ n = Math.max(n, Number(read(join(dir, f))) || 0);
126
+ }
127
+ return n;
128
+ }
129
+ /** The impure part: read .orquestra/<issue>/. undefined when the issue has no fix or review files at all. */
130
+ export function collectRounds(ws, issue, max) {
131
+ const dir = join(ws, ".orquestra", issue);
132
+ let names;
133
+ try {
134
+ names = readdirSync(dir);
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ const mt = (n) => statSync(join(dir, n)).mtimeMs;
140
+ const fixes = new Map(), reviews = new Map();
141
+ for (const n of names) {
142
+ const fx = /^fix-(\d+)\.md$/.exec(n);
143
+ if (fx)
144
+ fixes.set(Number(fx[1]), mt(n));
145
+ }
146
+ for (const n of names) {
147
+ // A trailing -<n> is a round only when fix-<n>.md exists; otherwise it is part of the repo name (round 0).
148
+ const rv = /^review-(.+?)(?:-(\d+))?\.out\.md$/.exec(n), k = rv && rv[2] !== undefined && fixes.has(Number(rv[2])) ? Number(rv[2]) : 0;
149
+ if (rv)
150
+ reviews.set(k, [...(reviews.get(k) ?? []), n]);
151
+ }
152
+ if (!reviews.size && !fixes.size)
153
+ return undefined;
154
+ const rounds = countRounds(dir, names);
155
+ const all = names.filter((n) => !n.startsWith(".") && !/^(diagnosis|watchdog)-/.test(n)).map(mt), t0 = Math.min(...all), t1 = Math.max(...all);
156
+ // Round k runs from fix-k.md (round 0: the oldest file) to the next fix, the last one to the newest file.
157
+ const starts = [t0, ...Array.from({ length: rounds }, (_, i) => fixes.get(i + 1) ?? NaN)];
158
+ const perRound = starts.map((s, k) => {
159
+ const next = k < rounds ? starts[k + 1] : t1;
160
+ return { round: k, secs: Number.isNaN(s) || Number.isNaN(next) ? 0 : Math.max(0, Math.round((next - s) / 1000)) };
161
+ });
162
+ 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) });
164
+ }
165
+ /** Cheap change signature of an issue folder (names + mtimes, no reads) for caching collectRounds between redraws. */
166
+ export function roundsSig(ws, issue) {
167
+ const dir = join(ws, ".orquestra", issue);
168
+ try {
169
+ return readdirSync(dir).filter((n) => /^(fix-\d+\.md|review-.+\.out\.md|\.rounds-.+)$/.test(n)).map((n) => `${n}:${statSync(join(dir, n)).mtimeMs}`).join("|");
170
+ }
171
+ catch {
172
+ return "";
173
+ }
174
+ }
175
+ /** Board marker: "r2/2 ⚠", "r3/2 ✗"; just "r1/2" when ok. */
176
+ export const mark = (x) => `r${x.rounds}/${x.max}${x.level === "warn" ? " ⚠" : x.level === "stop" ? " ✗" : ""}`;