@irythill/orq 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +240 -2
- package/README.pt-BR.md +242 -0
- package/dist/accounts.js +18 -0
- package/dist/agents.js +202 -0
- package/dist/banner.js +87 -0
- package/dist/cli.js +92 -0
- package/dist/commands/agent.js +334 -0
- package/dist/commands/baseline.js +101 -0
- package/dist/commands/block.js +74 -0
- package/dist/commands/board.js +435 -0
- package/dist/commands/claim.js +88 -0
- package/dist/commands/completion.js +88 -0
- package/dist/commands/config.js +35 -0
- package/dist/commands/deps.js +82 -0
- package/dist/commands/gc.js +164 -0
- package/dist/commands/instructions.js +66 -0
- package/dist/commands/lang.js +35 -0
- package/dist/commands/lock.js +69 -0
- package/dist/commands/pick.js +111 -0
- package/dist/commands/quota.js +40 -0
- package/dist/commands/run.js +46 -0
- package/dist/commands/set.js +27 -0
- package/dist/commands/setup.js +187 -0
- package/dist/commands/stats.js +41 -0
- package/dist/commands/verify.js +123 -0
- package/dist/commands/wait.js +81 -0
- package/dist/config.js +93 -0
- package/dist/fsutil.js +9 -0
- package/dist/i18n.js +21 -0
- package/dist/install.js +137 -0
- package/dist/keyring.js +51 -0
- package/dist/lock.js +81 -0
- package/dist/paths.js +34 -0
- package/dist/pick.js +126 -0
- package/dist/process.js +147 -0
- package/dist/quota.js +334 -0
- package/dist/roles.js +28 -0
- package/dist/stats.js +159 -0
- package/dist/suggest.js +121 -0
- package/dist/terminal.js +83 -0
- package/dist/testrun.js +203 -0
- package/dist/toml-set.js +46 -0
- package/package.json +46 -4
- package/skills/issue/SKILL.md +229 -0
- package/skills/orq-setup/SKILL.md +114 -0
- package/skills/orq-status/SKILL.md +37 -0
- package/templates/workspace.CLAUDE.md +49 -0
package/dist/quota.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// Quota per agent/account, from the sources in the [quota] config section:
|
|
2
|
+
// "builtin" claude: the account's usage (Anthropic OAuth usage endpoint, same login Claude Code uses)
|
|
3
|
+
// codex: the rate_limits Codex writes into its latest session file
|
|
4
|
+
// antigravity: per-model quota from Cloud Code (fetchAvailableModels), with the login agy keeps
|
|
5
|
+
// in the OS keyring (src/keyring.ts: Secret Service, Keychain, Credential Manager)
|
|
6
|
+
// opencode: the MiniMax Token Plan (/v1/token_plan/remains) with $MINIMAX_API_KEY or the key
|
|
7
|
+
// OpenCode keeps in its auth.json; $MINIMAX_API_HOST for the China region
|
|
8
|
+
// copilot: premium requests left this month (`gh api /copilot_internal/user`; a monthly
|
|
9
|
+
// allowance, shown in the week slot)
|
|
10
|
+
// false left out of the list entirely
|
|
11
|
+
// "none" no collector: never skipped for quota; use `orq block` when it runs out
|
|
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]".
|
|
14
|
+
// - = the plan has no such window (not limiting); ? = no data (expired login, collector failed).
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
17
|
+
import { homedir, userInfo } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { claudeAccounts } from "./accounts.js";
|
|
20
|
+
import { writeAtomic } from "./fsutil.js";
|
|
21
|
+
import { readSecret } from "./keyring.js";
|
|
22
|
+
import { stateDir } from "./paths.js";
|
|
23
|
+
import { m } from "./i18n.js";
|
|
24
|
+
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}]` : ""}`;
|
|
26
|
+
const noData = (name, note) => ({ name, five: "?", week: "?", note });
|
|
27
|
+
const KEEP_MS = 5 * 3600_000;
|
|
28
|
+
const cacheFile = (name) => join(stateDir(), "quota", `${name}.json`);
|
|
29
|
+
function remember(name, five, week) {
|
|
30
|
+
try {
|
|
31
|
+
writeAtomic(cacheFile(name), JSON.stringify({ five, week, at: Date.now() }));
|
|
32
|
+
}
|
|
33
|
+
catch { /* best effort */ }
|
|
34
|
+
return { name, five: five.pct, week: week.pct };
|
|
35
|
+
}
|
|
36
|
+
/** 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()) {
|
|
38
|
+
try {
|
|
39
|
+
const c = JSON.parse(readFileSync(cacheFile(name), "utf8"));
|
|
40
|
+
const still = (w) => {
|
|
41
|
+
if (w.resetsAt) {
|
|
42
|
+
const t = Date.parse(w.resetsAt);
|
|
43
|
+
if (!Number.isNaN(t))
|
|
44
|
+
return t > now ? w.pct : 0;
|
|
45
|
+
}
|
|
46
|
+
return now - c.at < KEEP_MS ? w.pct : undefined;
|
|
47
|
+
};
|
|
48
|
+
const five = still(c.five), week = still(c.week);
|
|
49
|
+
if (five !== undefined && week !== undefined) {
|
|
50
|
+
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`) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch { /* no cache */ }
|
|
55
|
+
return noData(name, why);
|
|
56
|
+
}
|
|
57
|
+
function claudeToken(dir) {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(readFileSync(join(dir, ".credentials.json"), "utf8")).claudeAiOauth;
|
|
60
|
+
}
|
|
61
|
+
catch { /* not in a file: macOS keeps it in the Keychain */ }
|
|
62
|
+
if (process.platform !== "darwin")
|
|
63
|
+
return undefined;
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(readSecret("Claude Code-credentials", userInfo().username) ?? "").claudeAiOauth;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function claudeQuota(name, dir) {
|
|
72
|
+
const t = claudeToken(dir);
|
|
73
|
+
if (!t?.accessToken)
|
|
74
|
+
return noData(name, m("not logged in", "sem login"));
|
|
75
|
+
// Never refresh the token here (that would rotate Claude Code's login): an expired one just means "open claude".
|
|
76
|
+
if (t.expiresAt && t.expiresAt < Date.now())
|
|
77
|
+
return noData(name, m("login expired: open claude once", "login expirado: abra o claude uma vez"));
|
|
78
|
+
try {
|
|
79
|
+
const r = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
80
|
+
headers: { Authorization: `Bearer ${t.accessToken}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
81
|
+
signal: AbortSignal.timeout(15_000),
|
|
82
|
+
});
|
|
83
|
+
if (r.status === 401)
|
|
84
|
+
return noData(name, m("login expired: open claude once", "login expirado: abra o claude uma vez"));
|
|
85
|
+
if (!r.ok)
|
|
86
|
+
return fromCache(name, `HTTP ${r.status}`);
|
|
87
|
+
const j = await r.json();
|
|
88
|
+
const win = (w) => ({ pct: typeof w?.utilization === "number" ? w.utilization : "-",
|
|
89
|
+
...(typeof w?.resets_at === "string" ? { resetsAt: w.resets_at } : {}) });
|
|
90
|
+
return remember(name, win(j.five_hour), win(j.seven_day));
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
return fromCache(name, m("collector failed", "coletor falhou") + `: ${e.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// --- codex ----------------------------------------------------------------------------------------------------------
|
|
97
|
+
function latestCodexSession() {
|
|
98
|
+
const root = join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "sessions");
|
|
99
|
+
let best;
|
|
100
|
+
const walk = (d, depth) => {
|
|
101
|
+
let entries;
|
|
102
|
+
try {
|
|
103
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
for (const e of entries) {
|
|
109
|
+
const p = join(d, e.name);
|
|
110
|
+
if (e.isDirectory() && depth < 3)
|
|
111
|
+
walk(p, depth + 1);
|
|
112
|
+
else if (e.isFile() && e.name.endsWith(".jsonl")) {
|
|
113
|
+
const t = statSync(p).mtimeMs;
|
|
114
|
+
if (!best || t > best.t)
|
|
115
|
+
best = { f: p, t };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
walk(root, 0);
|
|
120
|
+
return best?.f;
|
|
121
|
+
}
|
|
122
|
+
/** The last rate_limits in a Codex session (.jsonl): a window up to 6 h = 5h; the longest one over that (10080 = week,
|
|
123
|
+
* 43200 on the free plan = 30 days) fills the week slot, with `span` (minutes) when it isn't a week; reset → 0. */
|
|
124
|
+
export function codexFromSession(text, nowSec) {
|
|
125
|
+
let last;
|
|
126
|
+
for (const line of text.split("\n")) {
|
|
127
|
+
if (!line.includes('"rate_limits"'))
|
|
128
|
+
continue;
|
|
129
|
+
try {
|
|
130
|
+
const find = (o) => {
|
|
131
|
+
if (o && typeof o === "object") {
|
|
132
|
+
const r = o.rate_limits;
|
|
133
|
+
if (r && typeof r === "object")
|
|
134
|
+
last = r;
|
|
135
|
+
Object.values(o).forEach(find);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
find(JSON.parse(line));
|
|
139
|
+
}
|
|
140
|
+
catch { /* partial line */ }
|
|
141
|
+
}
|
|
142
|
+
if (!last)
|
|
143
|
+
return undefined;
|
|
144
|
+
const ws = [last.primary, last.secondary].filter((w) => !!w);
|
|
145
|
+
const pct = (w) => !w ? "-" : (w.resets_at ?? 0) < nowSec ? 0 : Math.floor(w.used_percent ?? 0);
|
|
146
|
+
const short = ws.find((x) => (x.window_minutes ?? 0) <= 360);
|
|
147
|
+
const long = ws.filter((x) => (x.window_minutes ?? 0) > 360).sort((a, b) => b.window_minutes - a.window_minutes)[0];
|
|
148
|
+
const span = long && long.window_minutes !== 10080 ? long.window_minutes : undefined;
|
|
149
|
+
return { five: pct(short), week: pct(long), ...(span ? { span } : {}) };
|
|
150
|
+
}
|
|
151
|
+
export function codexQuota() {
|
|
152
|
+
const f = latestCodexSession();
|
|
153
|
+
if (!f)
|
|
154
|
+
return noData("codex", m("no Codex session yet", "nenhuma sessão do Codex ainda"));
|
|
155
|
+
const q = codexFromSession(readFileSync(f, "utf8"), Date.now() / 1000);
|
|
156
|
+
if (!q)
|
|
157
|
+
return noData("codex", m("no limit data", "sem dados de limite"));
|
|
158
|
+
const age = Math.round((Date.now() - statSync(f).mtimeMs) / 60_000);
|
|
159
|
+
const { span, ...pcts } = q;
|
|
160
|
+
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; `) : "")
|
|
162
|
+
+ m(`from the last session, ${age} min ago`, `da última sessão, há ${age} min`) };
|
|
163
|
+
}
|
|
164
|
+
/** Worst usage per window across models: a reset within 6 h belongs to the 5h window, a later one to the week. */
|
|
165
|
+
export function antigravityFromModels(models, now = Date.now()) {
|
|
166
|
+
const five = { pct: "-" }, week = { pct: "-" };
|
|
167
|
+
for (const { quotaInfo: q } of Object.values(models ?? {})) {
|
|
168
|
+
if (typeof q?.remainingFraction !== "number" || !q.resetTime)
|
|
169
|
+
continue;
|
|
170
|
+
const at = Date.parse(q.resetTime);
|
|
171
|
+
if (Number.isNaN(at))
|
|
172
|
+
continue;
|
|
173
|
+
const w = at - now <= 6 * 3600_000 ? five : week;
|
|
174
|
+
const used = Math.round((1 - q.remainingFraction) * 100);
|
|
175
|
+
if (w.pct === "-" || used > w.pct) {
|
|
176
|
+
w.pct = used;
|
|
177
|
+
w.resetsAt = q.resetTime;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { five, week };
|
|
181
|
+
}
|
|
182
|
+
function agyToken() {
|
|
183
|
+
// agy (go-keyring) keeps {"token": {access_token, expiry, ...}} under service "gemini", user "antigravity"
|
|
184
|
+
// (on Windows go-keyring names the credential "gemini:antigravity").
|
|
185
|
+
const raw = readSecret("gemini", "antigravity", "gemini:antigravity");
|
|
186
|
+
if (!raw)
|
|
187
|
+
return undefined;
|
|
188
|
+
try {
|
|
189
|
+
const t = JSON.parse(raw).token;
|
|
190
|
+
return { access: t?.access_token, expiry: t?.expiry };
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
export async function antigravityQuota() {
|
|
197
|
+
const name = "antigravity";
|
|
198
|
+
const t = agyToken();
|
|
199
|
+
if (!t?.access)
|
|
200
|
+
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).
|
|
202
|
+
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);
|
|
205
|
+
try {
|
|
206
|
+
const r = await fetch("https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", {
|
|
207
|
+
method: "POST", body: "{}",
|
|
208
|
+
// The endpoint answers 403 without a client User-Agent.
|
|
209
|
+
headers: { Authorization: `Bearer ${t.access}`, "Content-Type": "application/json", "User-Agent": "antigravity" },
|
|
210
|
+
signal: AbortSignal.timeout(15_000),
|
|
211
|
+
});
|
|
212
|
+
if (r.status === 401)
|
|
213
|
+
return fromCache(name, expired);
|
|
214
|
+
if (!r.ok)
|
|
215
|
+
return fromCache(name, `HTTP ${r.status}`);
|
|
216
|
+
const j = await r.json();
|
|
217
|
+
const { five, week } = antigravityFromModels(j.models ?? {});
|
|
218
|
+
return remember(name, five, week);
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
return fromCache(name, m("collector failed", "coletor falhou") + `: ${e.message}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// --- copilot --------------------------------------------------------------------------------------------------------
|
|
225
|
+
/** Premium requests used this month, from the snapshot `gh api /copilot_internal/user` returns. No 5h window. */
|
|
226
|
+
export function copilotFromUser(j) {
|
|
227
|
+
const u = j;
|
|
228
|
+
const p = u?.quota_snapshots?.premium_interactions;
|
|
229
|
+
if (!p)
|
|
230
|
+
return noData("copilot", m("no premium request data", "sem dados de requisições premium"));
|
|
231
|
+
if (p.unlimited)
|
|
232
|
+
return { name: "copilot", five: "-", week: "-", note: m("unlimited", "ilimitado") };
|
|
233
|
+
if (typeof p.percent_remaining !== "number")
|
|
234
|
+
return noData("copilot", m("no premium request data", "sem dados de requisições premium"));
|
|
235
|
+
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),
|
|
237
|
+
note: m("monthly premium requests", "requisições premium do mês") + reset };
|
|
238
|
+
}
|
|
239
|
+
export function copilotQuota() {
|
|
240
|
+
const r = spawnSync("gh", ["api", "/copilot_internal/user"], { encoding: "utf8", timeout: 15_000 });
|
|
241
|
+
if (r.error || r.status !== 0)
|
|
242
|
+
return noData("copilot", m("gh api failed (gh auth login?)", "gh api falhou (gh auth login?)"));
|
|
243
|
+
try {
|
|
244
|
+
return copilotFromUser(JSON.parse(r.stdout));
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return noData("copilot", m("collector failed", "coletor falhou"));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** Worst usage per window across the plan's models (remaining % → used %); resets from the windows' end times. */
|
|
251
|
+
export function minimaxFromRemains(j) {
|
|
252
|
+
const ms = j?.model_remains ?? [];
|
|
253
|
+
if (!ms.length)
|
|
254
|
+
return undefined;
|
|
255
|
+
const worst = (pct, end) => {
|
|
256
|
+
let w = { pct: "-" };
|
|
257
|
+
for (const x of ms) {
|
|
258
|
+
if (typeof x[pct] !== "number")
|
|
259
|
+
continue;
|
|
260
|
+
const used = 100 - x[pct];
|
|
261
|
+
if (w.pct === "-" || used > w.pct)
|
|
262
|
+
w = { pct: used, ...(typeof x[end] === "number" ? { resetsAt: new Date(x[end]).toISOString() } : {}) };
|
|
263
|
+
}
|
|
264
|
+
return w;
|
|
265
|
+
};
|
|
266
|
+
return { five: worst("current_interval_remaining_percent", "end_time"),
|
|
267
|
+
week: worst("current_weekly_remaining_percent", "weekly_end_time") };
|
|
268
|
+
}
|
|
269
|
+
function minimaxKey() {
|
|
270
|
+
if (process.env.MINIMAX_API_KEY)
|
|
271
|
+
return process.env.MINIMAX_API_KEY;
|
|
272
|
+
const data = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
|
|
273
|
+
try {
|
|
274
|
+
return JSON.parse(readFileSync(join(data, "opencode", "auth.json"), "utf8")).minimax?.key;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
export async function minimaxQuota(name) {
|
|
281
|
+
const key = minimaxKey();
|
|
282
|
+
// OpenCode may run another provider: without a MiniMax key there is nothing to limit.
|
|
283
|
+
if (!key)
|
|
284
|
+
return { name, five: "-", week: "-", note: m("no MiniMax key", "sem chave da MiniMax") };
|
|
285
|
+
try {
|
|
286
|
+
const host = process.env.MINIMAX_API_HOST ?? "api.minimax.io";
|
|
287
|
+
const r = await fetch(`https://${host}/v1/token_plan/remains`, {
|
|
288
|
+
headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(15_000)
|
|
289
|
+
});
|
|
290
|
+
if (!r.ok)
|
|
291
|
+
return fromCache(name, `HTTP ${r.status}`);
|
|
292
|
+
const j = await r.json();
|
|
293
|
+
if (j.base_resp?.status_code)
|
|
294
|
+
return fromCache(name, `MiniMax ${j.base_resp.status_code}: ${j.base_resp.status_msg ?? ""}`.trim());
|
|
295
|
+
const q = minimaxFromRemains(j);
|
|
296
|
+
return q ? remember(name, q.five, q.week) : noData(name, m("no plan data", "sem dados do plano"));
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
return fromCache(name, m("collector failed", "coletor falhou") + `: ${e.message}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// --- command ----------------------------------------------------------------------------------------------------------
|
|
303
|
+
function commandQuota(name, cmd) {
|
|
304
|
+
const r = spawnSync(cmd, { shell: true, encoding: "utf8", timeout: 30_000 });
|
|
305
|
+
const lines = (r.stdout ?? "").split(/\r?\n/).filter((l) => /\S+ 5h=\S+ week=\S+/.test(l));
|
|
306
|
+
return lines.length ? lines : [format(noData(name, m("collector failed", "coletor falhou")))];
|
|
307
|
+
}
|
|
308
|
+
// --- all --------------------------------------------------------------------------------------------------------------
|
|
309
|
+
/** One or more output lines per configured source, in config order. `want` filters by line name. */
|
|
310
|
+
export async function collect(sources, want = []) {
|
|
311
|
+
const wanted = (n) => !want.length || want.includes(n);
|
|
312
|
+
const jobs = Object.entries(sources).map(async ([name, src]) => {
|
|
313
|
+
if (src === false)
|
|
314
|
+
return [];
|
|
315
|
+
if (src === "none")
|
|
316
|
+
return wanted(name) ? [format({ name, five: "-", week: "-", note: m("no quota collector", "sem coletor de cota") })] : [];
|
|
317
|
+
if (typeof src === "object" && src && "command" in src)
|
|
318
|
+
return commandQuota(name, src.command).filter((l) => wanted(l.split(" ")[0]));
|
|
319
|
+
if (name === "claude") {
|
|
320
|
+
const accs = claudeAccounts().filter((a) => wanted(a.name) || (want.includes("claude")));
|
|
321
|
+
return (await Promise.all(accs.map((a) => claudeQuota(a.name, a.dir)))).map(format);
|
|
322
|
+
}
|
|
323
|
+
if (name === "codex")
|
|
324
|
+
return wanted(name) ? [format(codexQuota())] : [];
|
|
325
|
+
if (name === "antigravity")
|
|
326
|
+
return wanted(name) ? [format(await antigravityQuota())] : [];
|
|
327
|
+
if (name === "copilot")
|
|
328
|
+
return wanted(name) ? [format(copilotQuota())] : [];
|
|
329
|
+
if (name === "opencode" || name === "minimax")
|
|
330
|
+
return wanted(name) ? [format(await minimaxQuota(name))] : [];
|
|
331
|
+
return wanted(name) ? [format(noData(name, m("no builtin collector: use \"none\" or a command", "sem coletor embutido: use \"none\" ou um comando")))] : [];
|
|
332
|
+
});
|
|
333
|
+
return (await Promise.all(jobs)).flat();
|
|
334
|
+
}
|
package/dist/roles.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
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"];
|
|
3
|
+
const SECTION = {
|
|
4
|
+
impl: ["primary"], fix: ["primary"], worker: ["workers", "primary"], review: ["reviewer"], opinion: ["opinion"],
|
|
5
|
+
};
|
|
6
|
+
/** The role name used in the old agents.<name>.roles lists. */
|
|
7
|
+
export const legacyRole = (r) => (r === "worker" ? "impl" : r);
|
|
8
|
+
/** Vendors of the built-in agents, so a config may leave `vendor` out. */
|
|
9
|
+
export const BUILTIN_VENDORS = {
|
|
10
|
+
claude: "anthropic", codex: "openai", antigravity: "google", copilot: "github",
|
|
11
|
+
};
|
|
12
|
+
export const vendorOf = (name, a) => a?.vendor ?? BUILTIN_VENDORS[name];
|
|
13
|
+
export function resolveRole(cfg, role) {
|
|
14
|
+
const agents = (cfg.agents ?? {});
|
|
15
|
+
const roles = (cfg.roles ?? {});
|
|
16
|
+
const policyRule = String((cfg.policy ?? {}).review ?? "other-vendor");
|
|
17
|
+
const section = SECTION[role].find((s) => roles[s]);
|
|
18
|
+
const r = section ? roles[section] : undefined;
|
|
19
|
+
const rule = String(r?.rule ?? policyRule);
|
|
20
|
+
if (r && typeof r.agent === "string")
|
|
21
|
+
return { mode: "fixed", order: [r.agent, ...(r.fallback ?? [])], rule, section: section };
|
|
22
|
+
if (r && Array.isArray(r.pool))
|
|
23
|
+
return { mode: "auto", pool: r.pool, rule, section };
|
|
24
|
+
const legacy = legacyRole(role);
|
|
25
|
+
const pool = Object.entries(agents).filter(([, a]) => (a.roles ?? []).includes(legacy))
|
|
26
|
+
.map(([n]) => n);
|
|
27
|
+
return { mode: "auto", pool, rule, section };
|
|
28
|
+
}
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Agent scoreboard from the .orquestra/ history: what each agent delivered and what it cost (port of orq-stats).
|
|
2
|
+
// Per run (<prefix>.run.sh + .log + .done + .out.md): agent, model, role (impl | fix | review | other), status,
|
|
3
|
+
// duration, tokens. "New" tokens = excluding cache reads (what actually weighs on quota); Codex without a session
|
|
4
|
+
// file only reports the total. Implementation delivery = the first review of the same repo in the issue said APPROVED.
|
|
5
|
+
// Limitation: follow-ups (-R) reuse the implementation prefix, so the "impl" row shows the last round of that prefix.
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { basename, join } from "node:path";
|
|
9
|
+
const ROLE = /^(impl|fix\d*|review\d*|[a-z]+-review\d*)-?/;
|
|
10
|
+
const read = (f) => { try {
|
|
11
|
+
return readFileSync(f, "utf8");
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return "";
|
|
15
|
+
} };
|
|
16
|
+
export function agentModel(run, log) {
|
|
17
|
+
const a = /\b(codex exec|codex resume|codex|claude -p|claude|opencode run|opencode|agy)\b/.exec(run);
|
|
18
|
+
const first = a ? a[1].split(" ")[0] : "?";
|
|
19
|
+
const agent = first === "agy" ? "antigravity" : first;
|
|
20
|
+
let model = /(?:\s-m|--model)\s+'?([^\s']+)/.exec(run)?.[1];
|
|
21
|
+
if (agent === "codex" && !model)
|
|
22
|
+
model = /^model: (\S+)/m.exec(log)?.[1] ?? "default";
|
|
23
|
+
return [agent, (model ?? "default").split("/").pop()];
|
|
24
|
+
}
|
|
25
|
+
function codexSession(sid) {
|
|
26
|
+
const root = join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "sessions");
|
|
27
|
+
const ls = (d) => { try {
|
|
28
|
+
return readdirSync(d);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
} };
|
|
33
|
+
for (const y of ls(root))
|
|
34
|
+
for (const mo of ls(join(root, y)))
|
|
35
|
+
for (const d of ls(join(root, y, mo))) {
|
|
36
|
+
const f = ls(join(root, y, mo, d)).find((x) => x.startsWith("rollout-") && x.endsWith(`-${sid}.jsonl`));
|
|
37
|
+
if (f)
|
|
38
|
+
return join(root, y, mo, d, f);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** [new, total] — new excludes cache reads. */
|
|
42
|
+
export function tokens(agent, log, sid = "") {
|
|
43
|
+
let neu = 0, tot = 0;
|
|
44
|
+
if (agent === "codex") {
|
|
45
|
+
const f = sid && codexSession(sid);
|
|
46
|
+
if (f) {
|
|
47
|
+
const last = read(f).split("\n").filter((l) => l.includes('"total_token_usage"')).pop();
|
|
48
|
+
const u = last && /"total_token_usage":(\{[^}]*\})/.exec(last);
|
|
49
|
+
if (u) {
|
|
50
|
+
const j = JSON.parse(u[1]);
|
|
51
|
+
return [j.total_tokens - (j.cached_input_tokens ?? 0), j.total_tokens];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const all = [...log.matchAll(/tokens used\s*\n\s*([\d,]+)/g)];
|
|
55
|
+
if (all.length)
|
|
56
|
+
tot = neu = Number(all[all.length - 1][1].replace(/,/g, ""));
|
|
57
|
+
return [neu, tot];
|
|
58
|
+
}
|
|
59
|
+
for (const line of log.split("\n")) {
|
|
60
|
+
if (!line.startsWith("{"))
|
|
61
|
+
continue;
|
|
62
|
+
let e;
|
|
63
|
+
try {
|
|
64
|
+
e = JSON.parse(line);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (agent === "claude" && e.type === "result") {
|
|
70
|
+
const u = e.usage ?? {};
|
|
71
|
+
const n = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.output_tokens ?? 0);
|
|
72
|
+
[neu, tot] = [n, n + (u.cache_read_input_tokens ?? 0)];
|
|
73
|
+
}
|
|
74
|
+
else if (agent === "opencode" && e.type === "step_finish") {
|
|
75
|
+
const t = e.part?.tokens ?? {};
|
|
76
|
+
neu += (t.total ?? 0) - (t.cache?.read ?? 0);
|
|
77
|
+
tot += t.total ?? 0;
|
|
78
|
+
}
|
|
79
|
+
else if (agent === "antigravity" && e.event === "result") {
|
|
80
|
+
const u = e.result?.usage ?? {};
|
|
81
|
+
[neu, tot] = [u.total_tokens ?? 0, (u.total_tokens ?? 0) + (u.cache_read_tokens ?? 0)]; // agy's total excludes cache
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return [neu, tot];
|
|
85
|
+
}
|
|
86
|
+
export function collectRuns(ws, days) {
|
|
87
|
+
const cutoff = Date.now() - days * 86400_000;
|
|
88
|
+
const base = join(ws, ".orquestra");
|
|
89
|
+
const runs = [];
|
|
90
|
+
const dirs = (() => { try {
|
|
91
|
+
return readdirSync(base);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return [];
|
|
95
|
+
} })();
|
|
96
|
+
for (const issue of dirs) {
|
|
97
|
+
let files;
|
|
98
|
+
try {
|
|
99
|
+
files = readdirSync(join(base, issue));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
for (const f of files.filter((x) => x.endsWith(".run.sh"))) {
|
|
105
|
+
const pre = join(base, issue, f.slice(0, -".run.sh".length));
|
|
106
|
+
const runF = `${pre}.run.sh`, done = `${pre}.done`;
|
|
107
|
+
const rs = statSync(runF);
|
|
108
|
+
if (rs.mtimeMs < cutoff || !existsSync(done))
|
|
109
|
+
continue;
|
|
110
|
+
const txt = read(runF);
|
|
111
|
+
if (txt.includes("herdr-verify") || txt.includes("herdr-baseline"))
|
|
112
|
+
continue;
|
|
113
|
+
const log = read(`${pre}.log`);
|
|
114
|
+
const [agent, model] = agentModel(txt, log);
|
|
115
|
+
const name = basename(pre);
|
|
116
|
+
const rm = ROLE.exec(name);
|
|
117
|
+
const role = rm && rm[1].includes("review") ? "review"
|
|
118
|
+
: name.startsWith("fix") || txt.includes("-R ") || txt.includes(" resume ") || txt.includes("--resume") ? "fix"
|
|
119
|
+
: name.startsWith("impl") ? "impl" : "other";
|
|
120
|
+
const out = read(`${pre}.out.md`);
|
|
121
|
+
const [neu, tot] = tokens(agent, log, read(`${pre}.id`).trim());
|
|
122
|
+
runs.push({ issue, name, agent, model, role, ok: read(done).includes("status=ok") && out.trim() !== "",
|
|
123
|
+
secs: Math.max(0, Math.floor((statSync(done).mtimeMs - rs.mtimeMs) / 1000)), new: neu, tot,
|
|
124
|
+
repo: name.includes("-") ? name.slice(name.indexOf("-") + 1) : "" });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Implementation delivery: the first review of the same repo in the issue.
|
|
128
|
+
for (const r of runs.filter((x) => x.role === "impl")) {
|
|
129
|
+
const rv = join(base, r.issue, `review-${r.repo}.out.md`);
|
|
130
|
+
if (!existsSync(rv))
|
|
131
|
+
continue;
|
|
132
|
+
// "non-blocking suggestion" shows up in approved reviews: the verdict is the word APPROVED (APROVADO in older runs).
|
|
133
|
+
const t = read(rv).slice(0, 4000).toUpperCase();
|
|
134
|
+
r.approved = (t.includes("APPROVED") || t.includes("APROVADO")) &&
|
|
135
|
+
!["NOT APPROVED", "REPROVADO", "NÃO APROVADO"].some((x) => t.includes(x));
|
|
136
|
+
}
|
|
137
|
+
return runs;
|
|
138
|
+
}
|
|
139
|
+
const median = (xs) => {
|
|
140
|
+
const s = [...xs].sort((a, b) => a - b), h = s.length >> 1;
|
|
141
|
+
return s.length % 2 ? s[h] : (s[h - 1] + s[h]) / 2;
|
|
142
|
+
};
|
|
143
|
+
export function rows(runs) {
|
|
144
|
+
const groups = new Map();
|
|
145
|
+
for (const r of runs) {
|
|
146
|
+
const k = JSON.stringify([r.role, r.agent, r.model]);
|
|
147
|
+
groups.set(k, [...(groups.get(k) ?? []), r]);
|
|
148
|
+
}
|
|
149
|
+
return [...groups.keys()].sort().map((k) => {
|
|
150
|
+
const rs = groups.get(k), [role, agent, model] = JSON.parse(k);
|
|
151
|
+
const withTok = rs.filter((r) => r.tot), appr = rs.filter((r) => r.approved !== undefined).map((r) => r.approved);
|
|
152
|
+
return { role, agent, model, n: rs.length, ok: Math.round(100 * rs.filter((r) => r.ok).length / rs.length),
|
|
153
|
+
approved: appr.length ? Math.round(100 * appr.filter(Boolean).length / appr.length) : null, n_appr: appr.length,
|
|
154
|
+
min: Math.round(median(rs.map((r) => r.secs)) / 6) / 10,
|
|
155
|
+
new: withTok.length ? Math.floor(median(withTok.map((r) => r.new))) : null,
|
|
156
|
+
tot: withTok.length ? Math.floor(median(withTok.map((r) => r.tot))) : null };
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
export const scoreboard = (ws, days) => rows(collectRuns(ws, days));
|
package/dist/suggest.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Guided setup: turns what a user knows (plans, trust, parallel tasks, RAM) into roles, weights and memory caps,
|
|
2
|
+
// with a reason for each choice. The rules are the ones the case study found by hand (docs/case-study.md):
|
|
3
|
+
// shares quota with the conductor → reserve
|
|
4
|
+
// small plan / monthly allowance → opinion only
|
|
5
|
+
// pay-as-you-go → reviewer pool, not primary
|
|
6
|
+
// no free shell when headless (agy) → reviewer only, diff attached
|
|
7
|
+
// trusted, else largest plan, not conductor → primary
|
|
8
|
+
// vendor ≠ primary's → reviewer (cross review)
|
|
9
|
+
import { m } from "./i18n.js";
|
|
10
|
+
import { BUILTIN_VENDORS } from "./roles.js";
|
|
11
|
+
/** Agents that only read when headless (their CLI denies commands outside an allowlist). */
|
|
12
|
+
const READ_ONLY = new Set(["antigravity"]);
|
|
13
|
+
const RANK = { large: 3, free: 2, payg: 1, small: 0 };
|
|
14
|
+
export function suggestRoles(a) {
|
|
15
|
+
const names = Object.keys(a.agents);
|
|
16
|
+
if (!names.length)
|
|
17
|
+
throw new Error(m("no agent to set up", "nenhum agente para configurar"));
|
|
18
|
+
const vendor = (n) => a.agents[n].vendor ?? BUILTIN_VENDORS[n] ?? n;
|
|
19
|
+
const reasons = [], notices = [];
|
|
20
|
+
const isConductor = (n) => n === a.conductor;
|
|
21
|
+
const canImpl = (n) => !READ_ONLY.has(n);
|
|
22
|
+
const single = a.preset === "single-agent" || names.length === 1;
|
|
23
|
+
// Primary: the trusted one, else the largest plan; avoid the conductor's quota, small plans and pay-as-you-go.
|
|
24
|
+
const implCands = names.filter(canImpl);
|
|
25
|
+
const byPlan = (xs) => [...xs].sort((x, y) => Number(Boolean(a.agents[y].trust)) - Number(Boolean(a.agents[x].trust))
|
|
26
|
+
|| Number(isConductor(x)) - Number(isConductor(y))
|
|
27
|
+
|| RANK[a.agents[y].plan] - RANK[a.agents[x].plan]);
|
|
28
|
+
const primary = byPlan(implCands.length ? implCands : names)[0];
|
|
29
|
+
const why = a.agents[primary].trust ? m("the one you trust most", "o que você mais confia")
|
|
30
|
+
: m("largest plan apart from the conductor's", "maior plano fora o do conductor");
|
|
31
|
+
reasons.push(`primary = ${primary}: ${why}`);
|
|
32
|
+
if (!canImpl(primary))
|
|
33
|
+
notices.push(m(`${primary} only reads when headless: it can't implement well`, `${primary} só lê no modo headless: não implementa bem`));
|
|
34
|
+
const workers = implCands.filter((n) => n !== primary && !["small", "payg"].includes(a.agents[n].plan));
|
|
35
|
+
const opinion = names.filter((n) => n !== primary && a.agents[n].plan === "small");
|
|
36
|
+
const otherVendor = names.filter((n) => vendor(n) !== vendor(primary) && !opinion.includes(n));
|
|
37
|
+
let reviewers = otherVendor.length ? otherVendor : names.filter((n) => !opinion.includes(n));
|
|
38
|
+
const rule = otherVendor.length && !single ? "other-vendor" : "any";
|
|
39
|
+
if (single)
|
|
40
|
+
reviewers = [primary];
|
|
41
|
+
if (single) {
|
|
42
|
+
notices.push(m("One agent does everything; reviews run in a fresh session. Cross-vendor review catches more — consider a second agent.", "Um agente faz tudo; a revisão roda numa sessão nova. Revisão entre fornecedores pega mais erros — considere um segundo agente."));
|
|
43
|
+
}
|
|
44
|
+
else if (!otherVendor.length) {
|
|
45
|
+
notices.push(m(`All agents are from ${vendor(primary)}: cross-vendor review is off (rule = "any"); review uses another session/model.`, `Todos os agentes são de ${vendor(primary)}: revisão entre fornecedores desligada (rule = "any"); a revisão usa outra sessão/modelo.`));
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
reasons.push(`reviewer = ${reviewers.join(", ")}: ` + m(`vendor different from ${primary}'s (cross review)`, `fornecedor diferente do de ${primary} (revisão cruzada)`));
|
|
49
|
+
}
|
|
50
|
+
const agents = {};
|
|
51
|
+
for (const n of names) {
|
|
52
|
+
const ans = a.agents[n], roles = [];
|
|
53
|
+
if (n === primary || (!single && workers.includes(n)))
|
|
54
|
+
roles.push("impl", "fix");
|
|
55
|
+
if (reviewers.includes(n))
|
|
56
|
+
roles.push("review");
|
|
57
|
+
if (opinion.includes(n) && !single)
|
|
58
|
+
roles.push("opinion");
|
|
59
|
+
agents[n] = { vendor: vendor(n), roles, ...(ans.quota ? { quota: ans.quota } : {}) };
|
|
60
|
+
if (isConductor(n) && n !== primary && !single) {
|
|
61
|
+
agents[n].reserve = true;
|
|
62
|
+
reasons.push(`${n}: reserve — ` + m("shares quota with the conductor", "divide cota com o conductor"));
|
|
63
|
+
}
|
|
64
|
+
if (ans.plan === "small" && !single)
|
|
65
|
+
reasons.push(`${n}: opinion — ` + m("small plan or monthly allowance", "plano pequeno ou franquia mensal"));
|
|
66
|
+
if (ans.plan === "payg" && n !== primary)
|
|
67
|
+
reasons.push(`${n}: ` + m("pay-as-you-go → reviews (few tokens)", "pague-por-uso → revisa (poucos tokens)"));
|
|
68
|
+
if (READ_ONLY.has(n) && n !== primary)
|
|
69
|
+
reasons.push(`${n}: ` + m("reviews only (reads when headless, diff attached)", "só revisa (só lê no headless, diff anexado)"));
|
|
70
|
+
}
|
|
71
|
+
const roles = {
|
|
72
|
+
conductor: { agent: a.conductor },
|
|
73
|
+
primary: { agent: primary, ...(workers.length ? { fallback: workers } : {}) },
|
|
74
|
+
reviewer: { pick: "auto", pool: reviewers, rule },
|
|
75
|
+
};
|
|
76
|
+
if (workers.length)
|
|
77
|
+
roles.workers = { pick: "auto", pool: [primary, ...workers] };
|
|
78
|
+
if (opinion.length && !single)
|
|
79
|
+
roles.opinion = { pick: "auto", pool: opinion };
|
|
80
|
+
const quality = a.preset === "quality" || (a.preset !== "economy" && a.parallel <= 2);
|
|
81
|
+
const weights = quality ? { quality: 0.55, tokens: 0.3, speed: 0.15 } : { quality: 0.4, tokens: 0.45, speed: 0.15 };
|
|
82
|
+
reasons.push(quality ? m("weights favor quality (few tasks at once)", "pesos priorizam qualidade (poucas tarefas por vez)")
|
|
83
|
+
: m("weights favor tokens (several tasks at once)", "pesos priorizam tokens (várias tarefas por vez)"));
|
|
84
|
+
// Memory: ~60 % of RAM for agents; rarely more than two peak at once, so it is split by min(parallel, 2); 2–8 GB
|
|
85
|
+
// each. Tests get a bit more, up to 40 % of RAM.
|
|
86
|
+
const per = Math.max(2, Math.min(8, Math.floor((a.ramGB * 0.6) / Math.min(Math.max(1, a.parallel), 2))));
|
|
87
|
+
const test = Math.max(2, Math.min(per + 2, Math.floor(a.ramGB * 0.4)));
|
|
88
|
+
return { agents, roles, weights, machine: { agent_mem_max: `${per}G`, test_mem_max: `${test}G` }, reasons, notices };
|
|
89
|
+
}
|
|
90
|
+
const q = (s) => JSON.stringify(s);
|
|
91
|
+
const list = (xs) => `[${xs.map(q).join(", ")}]`;
|
|
92
|
+
/** The proposal as a commented config.toml (new installs; agent models are left to the CLI defaults). */
|
|
93
|
+
export function toToml(p) {
|
|
94
|
+
const out = ["# Written by `orq setup`. Edit freely: everything here is a suggestion (docs/portability.md §4).", "",
|
|
95
|
+
"[machine]", `agent_mem_max = ${q(p.machine.agent_mem_max)}`, `test_mem_max = ${q(p.machine.test_mem_max)}`, ""];
|
|
96
|
+
for (const [n, a] of Object.entries(p.agents)) {
|
|
97
|
+
out.push(`[agents.${n}]`, `vendor = ${q(a.vendor)}`, `roles = ${list(a.roles)}`);
|
|
98
|
+
if (a.quota)
|
|
99
|
+
out.push(`quota = ${list(a.quota)}`);
|
|
100
|
+
if (a.reserve)
|
|
101
|
+
out.push("reserve = true");
|
|
102
|
+
out.push("# models.<role>.<level> = \"model[@effort]\" — without models, the CLI's default model is used", "");
|
|
103
|
+
}
|
|
104
|
+
for (const [r, v] of Object.entries(p.roles)) {
|
|
105
|
+
out.push(`[roles.${r}]`);
|
|
106
|
+
if (v.agent)
|
|
107
|
+
out.push(`agent = ${q(v.agent)}`);
|
|
108
|
+
if (v.fallback)
|
|
109
|
+
out.push(`fallback = ${list(v.fallback)}`);
|
|
110
|
+
if (v.pick)
|
|
111
|
+
out.push(`pick = ${q(v.pick)}`);
|
|
112
|
+
if (v.pool)
|
|
113
|
+
out.push(`pool = ${list(v.pool)}`);
|
|
114
|
+
if (v.rule)
|
|
115
|
+
out.push(`rule = ${q(v.rule)}`);
|
|
116
|
+
out.push("");
|
|
117
|
+
}
|
|
118
|
+
out.push("[policy.weights]", `quality = ${p.weights.quality}`, `tokens = ${p.weights.tokens}`, `speed = ${p.weights.speed}`, "");
|
|
119
|
+
out.push("# Why:", ...p.reasons.map((r) => `# ${r}`), ...p.notices.map((n) => `# ! ${n}`), "");
|
|
120
|
+
return out.join("\n");
|
|
121
|
+
}
|