@irythill/orq 0.6.0 → 0.6.1
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/README.md +3 -0
- package/README.pt-BR.md +3 -0
- package/dist/agents.js +32 -2
- package/dist/ci.js +6 -6
- package/dist/commands/agent.js +30 -0
- package/dist/commands/gc.js +9 -11
- package/dist/commands/quota.js +1 -1
- package/dist/commands/rounds.js +45 -18
- package/dist/config.js +3 -0
- package/dist/install.js +5 -0
- package/dist/issue.js +47 -0
- package/dist/pick.js +105 -17
- package/dist/quota.js +85 -19
- package/package.json +3 -2
- package/templates/conductor.instructions.md +31 -0
package/README.md
CHANGED
|
@@ -162,6 +162,9 @@ handoff_at = 85 # % of the window → orq handoff moves
|
|
|
162
162
|
max_per_account = 2 # an account already holding this many conductors is never a handoff target
|
|
163
163
|
reset_grace_min = 15 # every window over the trigger resetting within this many minutes skips the move
|
|
164
164
|
|
|
165
|
+
[git]
|
|
166
|
+
issue_pattern = "(?<![a-z0-9])(?<issue>[a-z][a-z0-9]*-\\d+)(?![a-z0-9])" # Branch/worktree name → issue ID, for `orq gc` and `orq ci` — default shown; regex, named group `issue` (else group 1)
|
|
167
|
+
|
|
165
168
|
[policy]
|
|
166
169
|
quota_week = 95 # ≥ 95% of the week → agent out
|
|
167
170
|
quota_5h = { simple = 90, medium = 85, heavy = 80 } # same for the 5h window, by task level
|
package/README.pt-BR.md
CHANGED
|
@@ -156,6 +156,9 @@ handoff_at = 85 # % da janela → o orq handoff move o
|
|
|
156
156
|
max_per_account = 2 # conta que já tem esse tanto de conductors nunca é alvo do handoff
|
|
157
157
|
reset_grace_min = 15 # toda janela acima do gatilho renova dentro desses minutos → pula a mudança
|
|
158
158
|
|
|
159
|
+
[git]
|
|
160
|
+
issue_pattern = "(?<![a-z0-9])(?<issue>[a-z][a-z0-9]*-\\d+)(?![a-z0-9])" # Nome de branch/worktree → ID da issue, para `orq gc` e `orq ci` — padrão mostrado; regex, grupo nomeado `issue` (senão o grupo 1)
|
|
161
|
+
|
|
159
162
|
[policy]
|
|
160
163
|
quota_limit = 90 # caps the 5h pick limit; also sets ! in `orq quota --line`
|
|
161
164
|
pacing_penalty = 0.3 # score penalty when usage at the current pace would pass the pick limit (5h by level, week: quota_week) by the reset (0 = off)
|
package/dist/agents.js
CHANGED
|
@@ -154,7 +154,14 @@ export function expandTemplate(t, vars) {
|
|
|
154
154
|
if (g >= 0)
|
|
155
155
|
while (j < words.length && words[j].group === g)
|
|
156
156
|
j++;
|
|
157
|
-
|
|
157
|
+
const got = expandWords(words.slice(i, j).map((w) => w.text), vars, g >= 0);
|
|
158
|
+
// `--flag {placeholder}` with the placeholder empty: drop the flag too, or it would swallow the next argument.
|
|
159
|
+
const ph = g < 0 ? /^\{(\w+)(?::[^}]*)?\}$/.exec(words[i].text) : null, prev = words[i - 1];
|
|
160
|
+
if (!got.length && ph && !Array.isArray(vars[ph[1]]) && prev && prev.group < 0 && /^-[^\s{]/.test(prev.text)
|
|
161
|
+
&& out[out.length - 1] === prev.text)
|
|
162
|
+
out.pop();
|
|
163
|
+
else
|
|
164
|
+
out.push(...got);
|
|
158
165
|
i = j;
|
|
159
166
|
}
|
|
160
167
|
return out;
|
|
@@ -255,11 +262,34 @@ export const FORMATS = {
|
|
|
255
262
|
}
|
|
256
263
|
if (errs.length)
|
|
257
264
|
answer += (answer ? "\n" : "") + errs.join("\n") + "\n";
|
|
258
|
-
|
|
265
|
+
const exhausted = agyExhausted(log, res);
|
|
266
|
+
return { exhausted, session: lastOf(res.map((r) => r.conversation_id).filter(Boolean)), answer, rc: errs.length || wasDenied ? 3 : undefined,
|
|
259
267
|
denied: wasDenied ? (denied.length ? denied : ["(command)"]) : undefined };
|
|
260
268
|
},
|
|
261
269
|
},
|
|
262
270
|
};
|
|
271
|
+
/** "Resets in 123h12m32s" → seconds (undefined without such a duration). */
|
|
272
|
+
export function parseResetIn(text) {
|
|
273
|
+
const r = /Resets in\s+(?:(\d+)h)?\s*(?:(\d+)m)?\s*(?:(\d+)s)?/i.exec(text);
|
|
274
|
+
if (!r || !(r[1] || r[2] || r[3]))
|
|
275
|
+
return undefined;
|
|
276
|
+
return Number(r[1] ?? 0) * 3600 + Number(r[2] ?? 0) * 60 + Number(r[3] ?? 0);
|
|
277
|
+
}
|
|
278
|
+
/** agy's quota failure (RESOURCE_EXHAUSTED / "quota reached"), from the result's error or the AGY_ERROR line. */
|
|
279
|
+
function agyExhausted(log, res) {
|
|
280
|
+
const msgs = res.filter((r) => r.status === "ERROR" || r.error).map((r) => String(r.error ?? ""));
|
|
281
|
+
for (const l of log.split(/\r?\n/)) {
|
|
282
|
+
if (!l.startsWith("AGY_ERROR:"))
|
|
283
|
+
continue;
|
|
284
|
+
const j = json(l.slice(10).trim());
|
|
285
|
+
if (j)
|
|
286
|
+
msgs.push(`${j.status ?? ""} ${j.short_error ?? ""}`);
|
|
287
|
+
}
|
|
288
|
+
const text = msgs.find((t) => /RESOURCE_EXHAUSTED|quota reached/i.test(t));
|
|
289
|
+
if (!text)
|
|
290
|
+
return undefined;
|
|
291
|
+
return { secs: parseResetIn(text), message: text.replace(/^.*?(?:\(code 429\):\s*)/, "").trim() };
|
|
292
|
+
}
|
|
263
293
|
/** The model in an agent's argv (-m / --model / --model=x), without the provider prefix. */
|
|
264
294
|
export function argvModel(a) {
|
|
265
295
|
const i = a.findIndex((x) => x === "-m" || x === "--model");
|
package/dist/ci.js
CHANGED
|
@@ -15,6 +15,7 @@ import { join } from "node:path";
|
|
|
15
15
|
import { aliveOwner } from "./commands/claim.js";
|
|
16
16
|
import { writeAtomic } from "./fsutil.js";
|
|
17
17
|
import { m } from "./i18n.js";
|
|
18
|
+
import { issueFromName, issuePattern } from "./issue.js";
|
|
18
19
|
import { alive } from "./lock.js";
|
|
19
20
|
import { stateDir } from "./paths.js";
|
|
20
21
|
import { notify } from "./testrun.js";
|
|
@@ -93,11 +94,10 @@ export function listWorktrees(ws) {
|
|
|
93
94
|
return out;
|
|
94
95
|
}
|
|
95
96
|
/** The repo + branch an issue's worktree carries, the same match `orq gc` uses: a worktree branch ending in
|
|
96
|
-
* "plane
|
|
97
|
+
* the issue ID (git.issue_pattern; any "plane-" prefix or suffix is ignored) under <ws>/.worktrees. An issue can have one per repo (several repos in play).
|
|
97
98
|
* `all` lets a caller looping over several issues pass its own listWorktrees(ws) once instead of one per issue. */
|
|
98
|
-
export function issueBranches(ws, issue, all = listWorktrees(ws)) {
|
|
99
|
-
|
|
100
|
-
return all.filter((b) => b.branch.toLowerCase().endsWith(suffix));
|
|
99
|
+
export function issueBranches(ws, issue, all = listWorktrees(ws), pattern) {
|
|
100
|
+
return all.filter((b) => issueFromName(b.branch, pattern)?.toLowerCase() === issue.toLowerCase());
|
|
101
101
|
}
|
|
102
102
|
// GitHub's statusCheckRollup mixes CheckRun (status/conclusion, the GitHub Actions / Checks API shape) with the
|
|
103
103
|
// legacy commit-status shape (StatusContext: just `state`, no status/conclusion at all) for repos whose CI still
|
|
@@ -204,8 +204,8 @@ function notifyDesktop(issue, repo, state) {
|
|
|
204
204
|
export function collectCi(ws, hooks = {}) {
|
|
205
205
|
// Detached: collectCi() must never block on a conductor stuck in a dialog (herdr-notify waits up to 30 min).
|
|
206
206
|
const desktop = hooks.desktop ?? notifyDesktop, toOwner = hooks.owner ?? ((pane, line) => notify(pane, line)), notice = hooks.notice ?? ((l) => console.log(l));
|
|
207
|
-
const now = Date.now(), worktrees = listWorktrees(ws);
|
|
208
|
-
const found = claimedIssues(ws).flatMap(({ id, owner, kind }) => issueBranches(ws, id, worktrees).map(({ repo, branch }) => ({ id, owner, kind, repo, branch, ...prStatus(join(ws, repo), branch) })));
|
|
207
|
+
const now = Date.now(), worktrees = listWorktrees(ws), pattern = issuePattern(ws);
|
|
208
|
+
const found = claimedIssues(ws).flatMap(({ id, owner, kind }) => issueBranches(ws, id, worktrees, pattern).map(({ repo, branch }) => ({ id, owner, kind, repo, branch, ...prStatus(join(ws, repo), branch) })));
|
|
209
209
|
const toNotify = [];
|
|
210
210
|
const mine = withCacheLock(() => {
|
|
211
211
|
const cache = loadCache(), mine = [];
|
package/dist/commands/agent.js
CHANGED
|
@@ -20,6 +20,7 @@ import { getPath, loadConfig } from "../config.js";
|
|
|
20
20
|
import { writeAtomic } from "../fsutil.js";
|
|
21
21
|
import { m } from "../i18n.js";
|
|
22
22
|
import { alive } from "../lock.js";
|
|
23
|
+
import { agyToken, humanReset, learnExhausted } from "../quota.js";
|
|
23
24
|
import { stateDir } from "../paths.js";
|
|
24
25
|
import { killedRc, killTree, parseSize, resolveMode, runCapped } from "../process.js";
|
|
25
26
|
import { closeOwnTab, detectHost, launch, screenOpen, shq, typeInto } from "../terminal.js";
|
|
@@ -342,6 +343,12 @@ function turn(file, kind, rest) {
|
|
|
342
343
|
return 0;
|
|
343
344
|
answer = String(e["last-assistant-message"] ?? "");
|
|
344
345
|
sid = e["thread-id"];
|
|
346
|
+
// Codex also runs internal turns (e.g. generating the tab title) and notifies for them: only the main thread counts.
|
|
347
|
+
const known = existsSync(`${O}.id`) ? readFileSync(`${O}.id`, "utf8").trim() : "";
|
|
348
|
+
if (known && sid && sid !== known)
|
|
349
|
+
return 0;
|
|
350
|
+
if (isTitleOnly(answer))
|
|
351
|
+
return 0;
|
|
345
352
|
}
|
|
346
353
|
else if (kind === "claude") {
|
|
347
354
|
const e = JSON.parse(readFileSync(0, "utf8"));
|
|
@@ -376,6 +383,16 @@ function turn(file, kind, rest) {
|
|
|
376
383
|
}
|
|
377
384
|
return 0;
|
|
378
385
|
}
|
|
386
|
+
/** The `{"title": "..."}` answer of Codex's session-title turn (a JSON object whose only key is `title`). */
|
|
387
|
+
function isTitleOnly(answer) {
|
|
388
|
+
try {
|
|
389
|
+
const o = JSON.parse(answer);
|
|
390
|
+
return typeof o === "object" && o !== null && !Array.isArray(o) && Object.keys(o).length === 1 && "title" in o;
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
379
396
|
/** The last text Claude wrote, from its transcript (older Stop hooks have no last_assistant_message). */
|
|
380
397
|
function lastClaudeText(transcript) {
|
|
381
398
|
let text = "";
|
|
@@ -647,6 +664,19 @@ async function runJob(file) {
|
|
|
647
664
|
else
|
|
648
665
|
res.answer = `${res.answer ?? ""}\nNo changes were made in the worktree.\n`;
|
|
649
666
|
}
|
|
667
|
+
// Out of quota: not a code failure. Learn the model's bucket (orq quota / pick see it) and say why, so the conductor re-picks.
|
|
668
|
+
if (res.exhausted) {
|
|
669
|
+
const model = argvModel(j.argv);
|
|
670
|
+
let until = "";
|
|
671
|
+
if (res.exhausted.secs !== undefined) {
|
|
672
|
+
const at = Date.now() + res.exhausted.secs * 1000;
|
|
673
|
+
if (model)
|
|
674
|
+
learnExhausted("antigravity", model, res.exhausted.secs, agyToken()?.acct);
|
|
675
|
+
until = ` ${m("until", "até")} ${humanReset(Math.floor(at / 1000))} (${new Date(at).toISOString()})`;
|
|
676
|
+
}
|
|
677
|
+
res.answer = `quota exhausted: ${model ?? j.agent}${until}\n\n${res.exhausted.message}\n`;
|
|
678
|
+
res.rc = res.rc || 3;
|
|
679
|
+
}
|
|
650
680
|
if (j.answer !== "file")
|
|
651
681
|
writeFileSync(`${O}.out.md`, res.answer ?? "");
|
|
652
682
|
const sid = j.fromLog ? new RegExp(j.fromLog, "m").exec(logText)?.[1] : res.session ?? j.session;
|
package/dist/commands/gc.js
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, rmSync } from "nod
|
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
5
|
import { loadConfig } from "../config.js";
|
|
6
6
|
import { m } from "../i18n.js";
|
|
7
|
+
import { issueFromName, issuePattern, issuesWithLiveAgent } from "../issue.js";
|
|
7
8
|
import { alive } from "../lock.js";
|
|
8
9
|
import { stateDir } from "../paths.js";
|
|
9
10
|
import { aliveOwner } from "./claim.js";
|
|
@@ -69,10 +70,11 @@ export async function gc(argv, clock = Date.now) {
|
|
|
69
70
|
return 2;
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
|
-
const { cfg } = loadConfig(ws), rows = [];
|
|
73
|
+
const { cfg } = loadConfig(ws), rows = [], issuePat = issuePattern(ws);
|
|
73
74
|
let ghUnavailable = false, fetchWarned = false;
|
|
74
75
|
const root = join(ws, ".worktrees");
|
|
75
76
|
const orq = join(ws, ".orquestra");
|
|
77
|
+
const liveAgents = issuesWithLiveAgent();
|
|
76
78
|
const issues = existsSync(orq) ? readdirSync(orq).filter((x) => !x.startsWith(".")) : [];
|
|
77
79
|
for (const repoName of readdirSync(ws, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)) {
|
|
78
80
|
const repo = join(ws, repoName);
|
|
@@ -102,8 +104,8 @@ export async function gc(argv, clock = Date.now) {
|
|
|
102
104
|
const branch = wt.branch?.replace(/^refs\/heads\//, "") ?? "";
|
|
103
105
|
if (!branch)
|
|
104
106
|
continue;
|
|
105
|
-
// The branch carries the ID in lowercase (
|
|
106
|
-
const id =
|
|
107
|
+
// The branch carries the ID in lowercase (abc-123, maybe with a prefix or -suffix); .orquestra/ keeps it as written (ABC-123).
|
|
108
|
+
const id = issueFromName(branch, issuePat);
|
|
107
109
|
const issue = id && issues.find((d) => d.toLowerCase() === id.toLowerCase());
|
|
108
110
|
const status = output(path, "status", "--porcelain");
|
|
109
111
|
const unpushed = output(repo, "rev-list", branch, "--not", "--remotes");
|
|
@@ -117,14 +119,10 @@ export async function gc(argv, clock = Date.now) {
|
|
|
117
119
|
verdict = "KEEP";
|
|
118
120
|
reason = m("issue has a live owner", "issue tem um responsável ativo");
|
|
119
121
|
}
|
|
120
|
-
// A
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (running) {
|
|
125
|
-
verdict = "KEEP";
|
|
126
|
-
reason = m("child agent may still be running", "um agente filho pode estar em execução");
|
|
127
|
-
}
|
|
122
|
+
// A child agent of the issue whose state says running and whose pid is alive (the board's rule).
|
|
123
|
+
if (verdict !== "KEEP" && issue && liveAgents.has(issue)) {
|
|
124
|
+
verdict = "KEEP";
|
|
125
|
+
reason = m("child agent may still be running", "um agente filho pode estar em execução");
|
|
128
126
|
}
|
|
129
127
|
let merged = false;
|
|
130
128
|
if (verdict !== "KEEP") {
|
package/dist/commands/quota.js
CHANGED
|
@@ -34,7 +34,7 @@ export function humanizeQuotaLine(line, now = Date.now()) {
|
|
|
34
34
|
* `policy.quota_week`) — the same thresholds `orq pick` applies for an unspecified task level; ⊘ blocked
|
|
35
35
|
* (orq block), ? no data; sources with no window at all ("-" and "-") are left out. */
|
|
36
36
|
export function quotaLine(lines, limit5, limitWeek, blocked, age) {
|
|
37
|
-
const parts = lines.filter((q) => q.five !== "-" || q.week !== "-").map((q) => {
|
|
37
|
+
const parts = lines.filter((q) => !q.name.includes("/") && (q.five !== "-" || q.week !== "-")).map((q) => {
|
|
38
38
|
if (blocked.has(q.name))
|
|
39
39
|
return `${q.name} ⊘`;
|
|
40
40
|
const five = pct(q.five), week = pct(q.week);
|
package/dist/commands/rounds.js
CHANGED
|
@@ -96,37 +96,64 @@ async function diagnoseIssue(ws, id, x) {
|
|
|
96
96
|
}
|
|
97
97
|
const ev = evidence(dir);
|
|
98
98
|
writeFileSync(`${prefix}.md`, buildPrompt(x, ev.fixes, ev.reviews));
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
const picked = p.stdout.trim().split(/\s+/);
|
|
105
|
-
// The agent runs in a scratch folder (everything it needs is in the prompt): it can't touch the issue files, even write sandboxed.
|
|
106
|
-
const scratch = mkdtempSync(join(tmpdir(), "orq-watchdog-"));
|
|
99
|
+
// A failed watchdog (e.g. a model catalog timeout) gets one retry with the next candidate, never the same agent.
|
|
100
|
+
const excluded = [];
|
|
101
|
+
let first;
|
|
102
|
+
const scratches = [];
|
|
107
103
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
104
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
105
|
+
const p = orq("pick", "watchdog", "--level", "simple", "-w", ws, ...(implPick ? ["--impl-agent", implPick] : []), ...excluded.flatMap((e) => ["--exclude-agent", e]));
|
|
106
|
+
if (p.status !== 0) {
|
|
107
|
+
if (first) {
|
|
108
|
+
console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
|
|
109
|
+
return first.rc;
|
|
110
|
+
}
|
|
111
|
+
console.error(p.stderr.trim() || `orq pick watchdog: exit ${p.status}`);
|
|
112
|
+
return p.status ?? 1;
|
|
113
|
+
}
|
|
114
|
+
const picked = p.stdout.trim().split(/\s+/), agent = /(?:^|\s)-a (\S+)/.exec(p.stdout)?.[1];
|
|
115
|
+
// The agent runs in a scratch folder (everything it needs is in the prompt): it can't touch the issue files, even write sandboxed.
|
|
116
|
+
const scratch = mkdtempSync(join(tmpdir(), "orq-watchdog-"));
|
|
117
|
+
scratches.push(scratch);
|
|
118
|
+
const run = `${prefix}-${attempt}`;
|
|
119
|
+
{
|
|
120
|
+
writeFileSync(`${run}.md`, readFileSync(`${prefix}.md`));
|
|
121
|
+
const a = orq("agent", ...picked, "-r", "-t", `${id} watchdog`, "-d", scratch, "-g", "watchdog", "-o", run, "--", `${run}.md`);
|
|
122
|
+
process.stderr.write(a.stderr);
|
|
123
|
+
process.stdout.write(a.stdout);
|
|
124
|
+
const rc = a.status !== 0 ? a.status ?? 1 : await finish(await wait(["--timeout", String(WATCHDOG_TIMEOUT), run]), run, out, agent, attempt === 1 || !agent);
|
|
125
|
+
if (rc === 0 || rc === 124 || !agent)
|
|
126
|
+
return rc;
|
|
127
|
+
if (attempt === 0)
|
|
128
|
+
console.error(m(`watchdog ${agent} failed; retrying with another agent`, `watchdog ${agent} falhou; tentando com outro agente`));
|
|
129
|
+
first = { rc };
|
|
130
|
+
excluded.push(agent);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return first?.rc ?? 1;
|
|
134
|
+
// Removed only after every attempt, and best-effort: on Windows a just-finished agent can still hold its cwd (EPERM/EBUSY).
|
|
114
135
|
}
|
|
115
136
|
finally {
|
|
116
|
-
|
|
137
|
+
for (const s of scratches) {
|
|
138
|
+
try {
|
|
139
|
+
rmSync(s, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
140
|
+
}
|
|
141
|
+
catch { /* temp dir: the OS cleans it */ }
|
|
142
|
+
}
|
|
117
143
|
}
|
|
118
144
|
}
|
|
119
|
-
async function finish(rc, prefix, out) {
|
|
145
|
+
async function finish(rc, prefix, out, agent, final = true) {
|
|
120
146
|
if (rc === 124) {
|
|
121
147
|
console.error(m(`diagnosis not written: the watchdog agent did not finish in ${WATCHDOG_TIMEOUT}s`, `diagnóstico não gravado: o agente watchdog não terminou em ${WATCHDOG_TIMEOUT}s`));
|
|
122
148
|
return 124;
|
|
123
149
|
}
|
|
124
150
|
if (rc !== 0 || !existsSync(`${prefix}.out.md`)) {
|
|
125
|
-
|
|
151
|
+
if (final)
|
|
152
|
+
console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
|
|
126
153
|
return 1;
|
|
127
154
|
}
|
|
128
155
|
copyFileSync(`${prefix}.out.md`, out);
|
|
129
|
-
console.log(m(`diagnosis saved to ${out}`, `diagnóstico gravado em ${out}`));
|
|
156
|
+
console.log(m(`diagnosis saved to ${out}${agent ? ` (by ${agent})` : ""}`, `diagnóstico gravado em ${out}${agent ? ` (por ${agent})` : ""}`));
|
|
130
157
|
console.log(recommendation(readFileSync(out, "utf8")) ?? m("(no RECOMMENDATION line in the answer)", "(sem linha RECOMMENDATION na resposta)"));
|
|
131
158
|
return 0;
|
|
132
159
|
}
|
package/dist/config.js
CHANGED
|
@@ -32,6 +32,9 @@ export const DEFAULTS = {
|
|
|
32
32
|
// Where quota comes from, per agent: "builtin" | "none" | false | { command = "..." } (see src/quota.ts).
|
|
33
33
|
// "claude" covers every account in claude-accounts.
|
|
34
34
|
quota: { claude: "builtin", codex: "builtin", opencode: "builtin", antigravity: "builtin", copilot: "builtin" },
|
|
35
|
+
// Issue ID in a branch/worktree name (orq gc, orq ci): case-insensitive regex, the named group "issue" (else group 1) is the ID.
|
|
36
|
+
// The default finds <LETTERS>-<DIGITS> anywhere (feature/ABC-123, abc-123-fix, plane-abc-123-api-2); suffixes are ignored.
|
|
37
|
+
git: { issue_pattern: "(?<![a-z0-9])(?<issue>[a-z][a-z0-9]*-\\d+)(?![a-z0-9])" },
|
|
35
38
|
tracker: { kind: "none" }, // plane | none
|
|
36
39
|
repos: {}, // defined by the workspace
|
|
37
40
|
};
|
package/dist/install.js
CHANGED
|
@@ -89,6 +89,11 @@ export function prepareWorkspace(dir, fmt, name = basename(resolve(dir))) {
|
|
|
89
89
|
}
|
|
90
90
|
else
|
|
91
91
|
out.push(m(` ok ${map} (kept)`, ` ok ${map} (mantido)`));
|
|
92
|
+
// The conductor's instructions (what `orq conductor` looks for): regenerated on every run.
|
|
93
|
+
const ci = join(ws, ".orquestra-conductor.md");
|
|
94
|
+
const t = readFileSync(join(PKG, "templates", "conductor.instructions.md"), "utf8");
|
|
95
|
+
writeAtomic(ci, t.split("{{WORKSPACE}}").join(ws).split("{{NAME}}").join(name));
|
|
96
|
+
out.push(` ${m("wrote", "escrevi")} ${ci}`);
|
|
92
97
|
const r = spawnSync(process.execPath, [join(PKG, "dist", "cli.js"), "instructions", fmt, "-w", ws], { encoding: "utf8" });
|
|
93
98
|
out.push(` ${(r.stdout || r.stderr).trim()}`);
|
|
94
99
|
return out;
|
package/dist/issue.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The issue a branch or worktree belongs to, and which issues have an agent really running.
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { getPath, loadConfig } from "./config.js";
|
|
5
|
+
import { alive } from "./lock.js";
|
|
6
|
+
import { stateDir } from "./paths.js";
|
|
7
|
+
/** Finds "<LETTERS>-<DIGITS>" anywhere in the name, as its own token; anything after the number is a suffix. */
|
|
8
|
+
export const DEFAULT_ISSUE_PATTERN = "(?<![a-z0-9])(?<issue>[a-z][a-z0-9]*-\\d+)(?![a-z0-9])";
|
|
9
|
+
/** The issue ID (as written in the name) from a branch or worktree name like "me/plane-proj-68-api-2" or
|
|
10
|
+
* "feature/ABC-123". `pattern` is [git] issue_pattern: a case-insensitive regex whose named group "issue"
|
|
11
|
+
* (else group 1, else the whole match) is the ID. */
|
|
12
|
+
export function issueFromName(name, pattern = DEFAULT_ISSUE_PATTERN) {
|
|
13
|
+
let re;
|
|
14
|
+
try {
|
|
15
|
+
re = new RegExp(pattern, "i");
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
throw new Error(`git.issue_pattern: ${e.message}`);
|
|
19
|
+
}
|
|
20
|
+
const r = re.exec(name);
|
|
21
|
+
return r ? (r.groups?.issue ?? r[1] ?? r[0]) : undefined;
|
|
22
|
+
}
|
|
23
|
+
/** [git] issue_pattern of the workspace's merged config (default when unset). */
|
|
24
|
+
export function issuePattern(ws) {
|
|
25
|
+
const v = getPath(loadConfig(ws).cfg, "git.issue_pattern");
|
|
26
|
+
return typeof v === "string" && v ? v : DEFAULT_ISSUE_PATTERN;
|
|
27
|
+
}
|
|
28
|
+
/** Issue directories (basename of the run prefix's folder) with an agent whose state says running and whose pid is
|
|
29
|
+
* alive: the same rule the board uses for "running". */
|
|
30
|
+
export function issuesWithLiveAgent() {
|
|
31
|
+
const dir = join(stateDir(), "agents"), out = new Set();
|
|
32
|
+
let files = [];
|
|
33
|
+
try {
|
|
34
|
+
files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
35
|
+
}
|
|
36
|
+
catch { /* no runs yet */ }
|
|
37
|
+
for (const f of files) {
|
|
38
|
+
try {
|
|
39
|
+
const s = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
40
|
+
if (s.status === "ok" || s.status === "fail" || s.status === "killed" || !alive(Number(s.pid)))
|
|
41
|
+
continue;
|
|
42
|
+
out.add(basename(dirname(String(s.prefix ?? ""))));
|
|
43
|
+
}
|
|
44
|
+
catch { /* unreadable state: ignore */ }
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
package/dist/pick.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// for an agent the user didn't list.
|
|
4
4
|
// auto role: every pool agent that passes is scored:
|
|
5
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
|
|
6
|
+
// − reserve (agents with reserve = true) + explore (fewer than `min_samples` runs, fading with n, none after
|
|
7
|
+
// `explore_streak` picks in a row). Each axis is (prior·k + observed·n)/(k + n), k = min_samples.
|
|
7
8
|
// Filters: --exclude-agent, orq block, nobody reviews their own code (unless no other agent can), cross-vendor rule, a model for the role/level,
|
|
8
9
|
// quota under the limits: policy.quota_week (95) on the weekly window, and on the 5h one policy.quota_5h per level
|
|
9
10
|
// (simple 90, medium 85, heavy 80): a heavy task needs more room left to finish.
|
|
@@ -84,7 +85,37 @@ export function modelFor(a, role, level) {
|
|
|
84
85
|
const [model, effort] = spec.split("@");
|
|
85
86
|
return [model, effort || undefined];
|
|
86
87
|
}
|
|
87
|
-
|
|
88
|
+
// --- per-model quota ---------------------------------------------------------------------------------------------------
|
|
89
|
+
// A collector may add lines "<account>/<model> 5h=.. week=.." (antigravity: one bucket per model group). The model a pick is about
|
|
90
|
+
// to choose is checked against its own line, and an exhausted one gives way to a lighter model the agent has configured.
|
|
91
|
+
const LEVEL_RANK = { simple: 0, medium: 1, default: 1, heavy: 2 };
|
|
92
|
+
/** The quota line key for a configured model: the exact id, else the id without its -low/-medium/-high suffix, plain or
|
|
93
|
+
* as "<base>-tiered" (one bucket serves all three efforts of a tiered model). */
|
|
94
|
+
export function modelKey(model, keys) {
|
|
95
|
+
const id = model.split("/").pop() ?? model;
|
|
96
|
+
const base = id.replace(/-(low|medium|high)$/, "");
|
|
97
|
+
return [id, base, `${base}-tiered`].find((k) => keys.includes(k));
|
|
98
|
+
}
|
|
99
|
+
/** The models of an agent's role section lighter than `level`, the closest first, without repeats of `tried`. */
|
|
100
|
+
export function lighterModels(a, role, level, tried) {
|
|
101
|
+
const models = a.models;
|
|
102
|
+
if (!models)
|
|
103
|
+
return [];
|
|
104
|
+
const lr = legacyRole(role);
|
|
105
|
+
const ms = models[lr] ?? (lr === "fix" ? models.impl : lr === "watchdog" ? models.opinion ?? models.review : undefined) ?? {};
|
|
106
|
+
const rank = LEVEL_RANK[level] ?? 1, seen = new Set(tried);
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const [, spec] of Object.entries(ms).filter(([lv]) => (LEVEL_RANK[lv] ?? 1) < rank)
|
|
109
|
+
.sort((x, y) => (LEVEL_RANK[y[0]] ?? 1) - (LEVEL_RANK[x[0]] ?? 1))) {
|
|
110
|
+
const [model, effort] = spec.split("@");
|
|
111
|
+
if (seen.has(model))
|
|
112
|
+
continue;
|
|
113
|
+
seen.add(model);
|
|
114
|
+
out.push([model, effort || undefined]);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
const WEIGHTS = { quality: 0.4, tokens: 0.45, speed: 0.15, rotation: 0.2, reserve: 0.3, explore: 0.1, explore_streak: 2, min_samples: 8, window: 6 };
|
|
88
119
|
export const weights = (cfg) => ({ ...WEIGHTS, ...((cfg.policy ?? {}).weights ?? {}) });
|
|
89
120
|
/** The merged definition (built-in + config) takes a prompt; an agent that can't be resolved doesn't. */
|
|
90
121
|
function takesPrompt(cfg, name) {
|
|
@@ -198,14 +229,49 @@ export function pick(inp) {
|
|
|
198
229
|
return { pace: paces.length ? Math.max(...paces) : undefined,
|
|
199
230
|
hot: penalty > 0 && ((p5 !== undefined && p5 > limit5) || (pw !== undefined && pw > limitWeek)) };
|
|
200
231
|
};
|
|
232
|
+
// The chosen account's per-model buckets: an exhausted model gives way to a lighter one of the same agent.
|
|
233
|
+
let mfx = mf;
|
|
234
|
+
const acc0 = usable.length ? [...usable].sort((x, y) => x[1] - y[1])[0][0] : undefined;
|
|
235
|
+
const mkeys = acc0 ? Object.keys(inp.windows ?? {}).filter((k) => k.startsWith(`${acc0}/`)).map((k) => k.slice(acc0.length + 1)) : [];
|
|
236
|
+
if (acc0 && mkeys.length) {
|
|
237
|
+
const over = (model) => {
|
|
238
|
+
const k = modelKey(model, mkeys), q = k ? inp.windows?.[`${acc0}/${k}`] : undefined;
|
|
239
|
+
if (!q)
|
|
240
|
+
return undefined;
|
|
241
|
+
const five = (q.five ?? -1) >= limit5, week = (q.week ?? -1) >= limitWeek;
|
|
242
|
+
if (!five && !week)
|
|
243
|
+
return undefined;
|
|
244
|
+
const at = [five ? q.resetFive : undefined, week ? q.resetWeek : undefined].filter((t) => t !== undefined);
|
|
245
|
+
return `${model} ${five ? `5h ${q.five}%` : `${m("week", "semana")} ${q.week}%`}`
|
|
246
|
+
+ (at.length ? ` (${m("back", "volta")} ${humanReset(Math.max(...at))})` : "");
|
|
247
|
+
};
|
|
248
|
+
const tried = [], skipped = [];
|
|
249
|
+
let found;
|
|
250
|
+
for (const cand of [mf, ...lighterModels(a, role, level, [mf[0]])]) {
|
|
251
|
+
const why = over(cand[0]);
|
|
252
|
+
if (!why) {
|
|
253
|
+
found = cand;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
tried.push(cand[0]);
|
|
257
|
+
skipped.push(why);
|
|
258
|
+
}
|
|
259
|
+
if (!found) {
|
|
260
|
+
out(name, `${m("every model over its quota", "todo modelo acima da cota")}: ${skipped.join(", ")}`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (skipped.length)
|
|
264
|
+
out(name, `${m("quota exhausted", "cota esgotada")}: ${skipped.join(", ")}; ${m("using", "usando")} ${found[0]}`);
|
|
265
|
+
mfx = found;
|
|
266
|
+
}
|
|
201
267
|
const ranked = usable.map(([n, v]) => ({ n, v: v, ...paceOf(n) }))
|
|
202
268
|
.sort((x, y) => Number(x.hot) - Number(y.hot) || x.v - y.v);
|
|
203
269
|
const chosen = ranked[0];
|
|
204
270
|
const acct = qn.length > 1 ? chosen?.n : undefined;
|
|
205
271
|
const statRole = role === "fix" || role === "worker" ? "impl" : role;
|
|
206
|
-
const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (
|
|
272
|
+
const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (mfx[0].split("/").pop() || "default"));
|
|
207
273
|
const st = stOf(statRole) ?? (role === "watchdog" ? stOf("review") : undefined); // no watchdog runs yet: its review record
|
|
208
|
-
pass.push({ agent: name, model:
|
|
274
|
+
pass.push({ agent: name, model: mfx[0], effort: mfx[1], acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
|
|
209
275
|
if (res.mode === "fixed")
|
|
210
276
|
break; // the first that passes wins
|
|
211
277
|
}
|
|
@@ -215,24 +281,46 @@ export function pick(inp) {
|
|
|
215
281
|
why: m(`fixed by [roles.${res.section}]`, `fixo em [roles.${res.section}]`) };
|
|
216
282
|
return { mode: "fixed", best, cands: best ? [best] : [], notes };
|
|
217
283
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
284
|
+
// Each axis blends the agent's prior with what was measured, (prior·k + observed·n) / (k + n) with k = min_samples,
|
|
285
|
+
// so a couple of runs already move the score instead of being ignored until the threshold.
|
|
286
|
+
const k = Math.max(0, w.min_samples);
|
|
287
|
+
const few = (st) => (st?.n ?? 0) < k;
|
|
288
|
+
const blend = (prior, obs, n) => obs === undefined || k + n <= 0 ? prior : (prior * k + obs * n) / (k + n);
|
|
289
|
+
// The reference cost/time is the best of the well-sampled agents, or of everyone measured when none is.
|
|
290
|
+
const best = (vals) => {
|
|
291
|
+
const solid = vals.filter((x) => x.solid);
|
|
292
|
+
const use = solid.length ? solid : vals;
|
|
293
|
+
return use.length ? Math.min(...use.map((x) => x.v)) : undefined;
|
|
294
|
+
};
|
|
295
|
+
const refTok = best(pass.filter((c) => c.st?.new).map((c) => ({ v: c.st.new, solid: !few(c.st) })));
|
|
296
|
+
const refMin = best(pass.filter((c) => c.st?.min).map((c) => ({ v: Math.max(c.st.min, 0.1), solid: !few(c.st) })));
|
|
297
|
+
const axis = (label, prior, obs, n) => {
|
|
298
|
+
const v = blend(prior, obs, n);
|
|
299
|
+
return { v, txt: `${label}=${v.toFixed(2)}` + (obs === undefined ? "" : `(${prior.toFixed(2)}→${obs.toFixed(2)})`) };
|
|
300
|
+
};
|
|
221
301
|
const cands = pass.map((c) => {
|
|
222
302
|
const prior = Number(c.a.prior ?? 0.6), n = c.st?.n ?? 0, reserve = Boolean(c.a.reserve);
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
const d =
|
|
226
|
-
const t =
|
|
227
|
-
const s =
|
|
303
|
+
const appr = role === "impl" && c.st?.approved != null && c.st.n_appr >= 3;
|
|
304
|
+
const deliv = appr ? c.st.approved / 100 : c.st ? c.st.ok / 100 : undefined;
|
|
305
|
+
const d = axis(m("delivery", "entrega"), prior, deliv, appr ? c.st.n_appr : n);
|
|
306
|
+
const t = axis(m("economy", "economia"), prior, c.st?.new && refTok ? Math.min(1, refTok / c.st.new) : undefined, n);
|
|
307
|
+
const s = axis(m("speed", "rapidez"), prior, c.st?.min && refMin ? Math.min(1, refMin / Math.max(c.st.min, 0.1)) : undefined, n);
|
|
228
308
|
const used = inp.recent.filter((p) => p.agent === c.agent).length;
|
|
309
|
+
// Exploration fades as samples come in and stops after explore_streak picks of the agent in a row.
|
|
310
|
+
let streak = 0;
|
|
311
|
+
for (let i = inp.recent.length - 1; i >= 0 && inp.recent[i].agent === c.agent; i--)
|
|
312
|
+
streak++;
|
|
313
|
+
const capped = few(c.st) && streak >= w.explore_streak;
|
|
314
|
+
const explore = few(c.st) && !capped ? w.explore * (k - n) / k : 0;
|
|
229
315
|
const hot = Boolean(c.hot) && penalty > 0;
|
|
230
|
-
const score = w.quality * d + w.tokens * t + w.speed * s - w.rotation * used
|
|
231
|
-
- (reserve ? w.reserve : 0) +
|
|
232
|
-
const why = `${
|
|
233
|
-
+ `${m("speed", "rapidez")}=${s.toFixed(2)} ${m("rotation", "rodízio")}=-${used} `
|
|
316
|
+
const score = w.quality * d.v + w.tokens * t.v + w.speed * s.v - w.rotation * used
|
|
317
|
+
- (reserve ? w.reserve : 0) + explore - (hot ? penalty : 0);
|
|
318
|
+
const why = `${d.txt} ${t.txt} ${s.txt} ${m("rotation", "rodízio")}=-${used} `
|
|
234
319
|
+ (reserve ? m("reserve ", "reserva ") : "")
|
|
235
|
-
+ (hot ? `${m("pace", "ritmo")}=${Math.round(c.pace)}%→-${penalty} ` : "")
|
|
320
|
+
+ (hot ? `${m("pace", "ritmo")}=${Math.round(c.pace)}%→-${penalty} ` : "")
|
|
321
|
+
+ (explore ? `${m("explore", "explorar")}=+${explore.toFixed(2)} ` : "")
|
|
322
|
+
+ (capped ? `${m("explore capped", "exploração limitada")}(${streak}x) ` : "")
|
|
323
|
+
+ (few(c.st) ? `${m("few samples", "poucos dados")}(n=${n})` : `n=${n}`);
|
|
236
324
|
return { agent: c.agent, model: c.model, effort: c.effort, acct: c.acct, score, why };
|
|
237
325
|
}).sort((x, y) => y.score - x.score);
|
|
238
326
|
return { mode: "auto", best: cands[0], cands, notes };
|
package/dist/quota.js
CHANGED
|
@@ -24,6 +24,7 @@ import { writeAtomic } from "./fsutil.js";
|
|
|
24
24
|
import { readSecret } from "./keyring.js";
|
|
25
25
|
import { stateDir } from "./paths.js";
|
|
26
26
|
import { m } from "./i18n.js";
|
|
27
|
+
import { modelKey } from "./pick.js";
|
|
27
28
|
const show = (p) => (typeof p === "number" ? `${Math.floor(p)}%` : p);
|
|
28
29
|
// reset5h/resetweek (epoch seconds) sit between the windows and the note: parseQuota/parseWindows only look at the
|
|
29
30
|
// first three words, so old cached lines (no reset fields) and lines with them both parse the same way.
|
|
@@ -50,9 +51,9 @@ const resetOf = (w) => {
|
|
|
50
51
|
};
|
|
51
52
|
// `acct` identifies the login behind a reading (antigravity: one login per machine, swapped by the user): a cache
|
|
52
53
|
// written under another login must never stand in for this one.
|
|
53
|
-
function remember(name, five, week, acct) {
|
|
54
|
+
function remember(name, five, week, acct, buckets) {
|
|
54
55
|
try {
|
|
55
|
-
writeAtomic(cacheFile(name), JSON.stringify({ five, week, at: Date.now(), ...(acct ? { acct } : {}) }));
|
|
56
|
+
writeAtomic(cacheFile(name), JSON.stringify({ five, week, at: Date.now(), ...(acct ? { acct } : {}), ...(buckets ? { buckets } : {}) }));
|
|
56
57
|
}
|
|
57
58
|
catch { /* best effort */ }
|
|
58
59
|
const r5 = resetOf(five), rw = resetOf(week);
|
|
@@ -195,23 +196,83 @@ export function codexQuota() {
|
|
|
195
196
|
return { name: "codex", ...pcts, ...(span5 ? { span5h: span5 } : {}), ...(span ? { spanWeek: span } : {}), note: (d ? m(`${d}-day window in the week slot; `, `janela de ${d} dias no lugar da semana; `) : "")
|
|
196
197
|
+ m(`from the last session, ${age} min ago`, `da última sessão, há ${age} min`) };
|
|
197
198
|
}
|
|
198
|
-
/**
|
|
199
|
+
/** Per-model buckets, and the agent's own pair: its least used bucket (the agent still has quota while any
|
|
200
|
+
* model does; `orq pick` checks the model it is about to choose against that model's own bucket). */
|
|
199
201
|
export function antigravityFromModels(models, now = Date.now()) {
|
|
200
202
|
const five = { pct: "-" }, week = { pct: "-" };
|
|
201
|
-
|
|
202
|
-
|
|
203
|
+
const buckets = {};
|
|
204
|
+
for (const [id, { quotaInfo: q } = {}] of Object.entries(models ?? {})) {
|
|
205
|
+
if (!q?.resetTime)
|
|
203
206
|
continue;
|
|
204
207
|
const at = Date.parse(q.resetTime);
|
|
205
208
|
if (Number.isNaN(at))
|
|
206
209
|
continue;
|
|
207
|
-
const
|
|
208
|
-
const used = Math.round((1 -
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
const left = typeof q.remainingFraction === "number" ? q.remainingFraction : 0;
|
|
211
|
+
const used = Math.min(100, Math.max(0, Math.round((1 - left) * 100)));
|
|
212
|
+
const isWeek = at - now > 6 * 3600_000;
|
|
213
|
+
buckets[id] = { used, resetsAt: q.resetTime, week: isWeek };
|
|
214
|
+
}
|
|
215
|
+
// The agent's pair is its least used bucket overall, in that bucket's window (the other stays "-"): a weekly cap on
|
|
216
|
+
// one model must not read as the whole agent being out.
|
|
217
|
+
const best = Object.values(buckets).sort((x, y) => x.used - y.used || Number(x.week) - Number(y.week))[0];
|
|
218
|
+
if (best)
|
|
219
|
+
Object.assign(best.week ? week : five, { pct: best.used, resetsAt: best.resetsAt });
|
|
220
|
+
return { five, week, buckets };
|
|
221
|
+
}
|
|
222
|
+
/** One quota line per model, named "<agent>/<model>", with its bucket in the 5h or the week slot and "-" in the other. */
|
|
223
|
+
export function modelLines(name, buckets, now = Date.now(), note) {
|
|
224
|
+
const out = [];
|
|
225
|
+
for (const [id, b] of Object.entries(buckets)) {
|
|
226
|
+
const at = Date.parse(b.resetsAt);
|
|
227
|
+
if (Number.isNaN(at) || at <= now)
|
|
228
|
+
continue; // reset since the reading: no figure to stand on
|
|
229
|
+
const reset = Math.floor(at / 1000);
|
|
230
|
+
out.push({ name: `${name}/${id}`, five: b.week ? "-" : b.used, week: b.week ? b.used : "-",
|
|
231
|
+
...(b.week ? { resetWeek: reset } : { reset5h: reset }), ...(note ? { note } : {}) });
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
/** Learned exhausted buckets (from a run that hit the limit) outlive API readings until their reset, unless the API
|
|
236
|
+
* returns that model with something left. The API's own bucket for the model stands in when it also says exhausted. */
|
|
237
|
+
export function mergeLearned(api, learned, now = Date.now()) {
|
|
238
|
+
const out = { ...api };
|
|
239
|
+
for (const [id, b] of Object.entries(learned)) {
|
|
240
|
+
if (!b.learned || Date.parse(b.resetsAt) <= now)
|
|
241
|
+
continue;
|
|
242
|
+
const key = modelKey(id, Object.keys(api));
|
|
243
|
+
if (key && api[key].used < 100)
|
|
244
|
+
continue; // the API says it has quota again
|
|
245
|
+
out[id] = b;
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
/** Record that a run found `model` out of quota for `secs` more seconds (best effort; keeps the cached reading). */
|
|
250
|
+
export function learnExhausted(name, model, secs, acct, now = Date.now()) {
|
|
251
|
+
const at = now + secs * 1000;
|
|
252
|
+
const b = { used: 100, resetsAt: new Date(at).toISOString(), week: at - now > 6 * 3600_000, learned: true };
|
|
253
|
+
try {
|
|
254
|
+
let c = { five: { pct: "-" }, week: { pct: "-" }, at: now };
|
|
255
|
+
try {
|
|
256
|
+
c = JSON.parse(readFileSync(cacheFile(name), "utf8"));
|
|
212
257
|
}
|
|
258
|
+
catch { /* none yet */ }
|
|
259
|
+
if (acct !== undefined && c.acct !== undefined && c.acct !== acct)
|
|
260
|
+
c = { five: { pct: "-" }, week: { pct: "-" }, at: now };
|
|
261
|
+
const buckets = { ...(c.buckets ?? {}), [model]: b };
|
|
262
|
+
writeAtomic(cacheFile(name), JSON.stringify({ ...c, ...(acct ? { acct } : {}), buckets }));
|
|
263
|
+
}
|
|
264
|
+
catch { /* best effort */ }
|
|
265
|
+
return b;
|
|
266
|
+
}
|
|
267
|
+
/** The per-model buckets of the last good reading, when it was this login's. */
|
|
268
|
+
function cachedBuckets(name, acct) {
|
|
269
|
+
try {
|
|
270
|
+
const c = JSON.parse(readFileSync(cacheFile(name), "utf8"));
|
|
271
|
+
return acct !== undefined && c.acct !== acct ? {} : c.buckets ?? {};
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return {};
|
|
213
275
|
}
|
|
214
|
-
return { five, week };
|
|
215
276
|
}
|
|
216
277
|
export function agyToken() {
|
|
217
278
|
// agy (go-keyring) keeps {"token": {access_token, expiry, ...}} under service "gemini", user "antigravity"
|
|
@@ -242,13 +303,17 @@ export async function antigravityQuota() {
|
|
|
242
303
|
const name = "antigravity";
|
|
243
304
|
const t = agyToken();
|
|
244
305
|
if (!t?.access)
|
|
245
|
-
return noData(name, m("no agy login in the keyring", "sem login do agy no chaveiro"));
|
|
306
|
+
return [noData(name, m("no agy login in the keyring", "sem login do agy no chaveiro"))];
|
|
246
307
|
// Never refresh it here (that is agy's login). The access token lives 1h and agy renews it only when it runs, so a
|
|
247
308
|
// passed expiry with a refresh token is just idle (a note, not an expired login); without one, "run agy once".
|
|
248
309
|
const expired = m("login expired: run `agy models`", "login expirado: rode `agy models`");
|
|
310
|
+
const stale = (why) => {
|
|
311
|
+
const line = fromCache(name, why, undefined, t.acct);
|
|
312
|
+
return [line, ...modelLines(name, cachedBuckets(name, t.acct), Date.now(), line.note)];
|
|
313
|
+
};
|
|
249
314
|
const note = agyNote(t);
|
|
250
315
|
if (note)
|
|
251
|
-
return
|
|
316
|
+
return stale(note);
|
|
252
317
|
try {
|
|
253
318
|
const r = await fetch("https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", {
|
|
254
319
|
method: "POST", body: "{}",
|
|
@@ -257,15 +322,16 @@ export async function antigravityQuota() {
|
|
|
257
322
|
signal: AbortSignal.timeout(15_000),
|
|
258
323
|
});
|
|
259
324
|
if (r.status === 401)
|
|
260
|
-
return
|
|
325
|
+
return stale(expired);
|
|
261
326
|
if (!r.ok)
|
|
262
|
-
return
|
|
327
|
+
return stale(`HTTP ${r.status}`);
|
|
263
328
|
const j = await r.json();
|
|
264
|
-
const { five, week } = antigravityFromModels(j.models ?? {});
|
|
265
|
-
|
|
329
|
+
const { five, week, buckets: api } = antigravityFromModels(j.models ?? {});
|
|
330
|
+
const buckets = mergeLearned(api, cachedBuckets(name, t.acct));
|
|
331
|
+
return [remember(name, five, week, t.acct, buckets), ...modelLines(name, buckets)];
|
|
266
332
|
}
|
|
267
333
|
catch (e) {
|
|
268
|
-
return
|
|
334
|
+
return stale(m("collector failed", "coletor falhou") + `: ${e.message}`);
|
|
269
335
|
}
|
|
270
336
|
}
|
|
271
337
|
// --- copilot --------------------------------------------------------------------------------------------------------
|
|
@@ -385,7 +451,7 @@ export async function collect(sources, want = []) {
|
|
|
385
451
|
if (name === "codex")
|
|
386
452
|
return wanted(name) ? [format(codexQuota())] : [];
|
|
387
453
|
if (name === "antigravity")
|
|
388
|
-
return wanted(name) ?
|
|
454
|
+
return wanted(name) ? (await antigravityQuota()).map(format) : [];
|
|
389
455
|
if (name === "copilot")
|
|
390
456
|
return wanted(name) ? [format(copilotQuota())] : [];
|
|
391
457
|
if (name === "opencode" || name === "minimax")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@irythill/orq",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "Orchestrate coding agents (Claude Code, Codex, OpenCode, Antigravity, Copilot) from one conductor.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"dist",
|
|
12
12
|
"!dist/**/*.test.js",
|
|
13
13
|
"skills",
|
|
14
|
-
"templates/workspace.CLAUDE.md"
|
|
14
|
+
"templates/workspace.CLAUDE.md",
|
|
15
|
+
"templates/conductor.instructions.md"
|
|
15
16
|
],
|
|
16
17
|
"engines": {
|
|
17
18
|
"node": ">=20"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Conductor "{{NAME}}" — {{NAME}} ecosystem
|
|
2
|
+
|
|
3
|
+
You orchestrate agents in the `{{WORKSPACE}}` ecosystem, in herdr (`HERDR_ENV=1`) or in any other terminal.
|
|
4
|
+
Don't write code yourself. Opened by `orq conductor`. Talk to the user in the user's language.
|
|
5
|
+
1. Read `{{WORKSPACE}}/CLAUDE.md` (repos, groups, base branch, Plane, issue standard, MCP limitations).
|
|
6
|
+
2. For every Plane issue, follow the `/issue <ID>` skill.
|
|
7
|
+
3. Never push, open a PR, merge or deploy unless the user asks. When unsure about scope or cost, ask.
|
|
8
|
+
4. **Children only through `orq agent`** (implementer, fix, review), with the agent chosen by `orq pick`; tests through
|
|
9
|
+
`orq verify` (it checks the base by itself; no `orq baseline` in the flow), as the skill says. NEVER
|
|
10
|
+
`herdr agent start` for children (no memory cap, no answer file); to watch the agent's own screen, `orq agent -T` (herdr or tmux).
|
|
11
|
+
5. **Completion = one single signal:** in herdr, the line `[HERDR-AGENT DONE] <title> status=ok|fail ... answer: <file>`
|
|
12
|
+
arriving in your pane; elsewhere, `orq wait <prefix>` (as a background task) printing
|
|
13
|
+
`[ORQ AGENT DONE] <title> status=ok|fail ... answer: <file>` (plus the `<prefix>.done` file either way). When it
|
|
14
|
+
arrives: read the answer file and act. Nothing else means done.
|
|
15
|
+
6. **"Is it still running?"** is answered by the file only: `<prefix>.done` exists → finished; it doesn't → running;
|
|
16
|
+
check `<prefix>.log` (last write time) or, in herdr, peek at the pane: `herdr pane read $(cat <prefix>.pane) --source recent`.
|
|
17
|
+
NEVER `pgrep`/`ps` (they match their own command line).
|
|
18
|
+
7. **Never block waiting** (`herdr agent wait`, `pane wait-output`, `sleep`): dispatch, report in one line and end
|
|
19
|
+
your turn (outside herdr: `orq wait` in the background, never in the foreground unless your harness has no background tasks).
|
|
20
|
+
8. Worktrees always from `origin/<repo base>`, created with `git worktree add` (never `herdr worktree create`).
|
|
21
|
+
9. Between batches of unrelated issues, suggest `/clear` to the user: state lives in Plane and in `.orquestra/`.
|
|
22
|
+
|
|
23
|
+
## How you are used
|
|
24
|
+
- **Dispatcher, not consultant.** Your job: issue → dispatch → report → PR/cleanup. Long questions about architecture,
|
|
25
|
+
infrastructure (DNS, email, deploy, webhooks) or business decisions: answer in at most 3 lines and suggest "take it
|
|
26
|
+
to a design session; come back with the decision recorded in the issue".
|
|
27
|
+
- **The issue is the source.** A decision made in chat → record it in Plane (description or comment) before
|
|
28
|
+
dispatching; the child's prompt comes from the issue, not from the conversation.
|
|
29
|
+
- **Context:** past ~150k tokens or after closing a batch of related issues → suggest `/clear` (state in Plane and in
|
|
30
|
+
`.orquestra/`).
|
|
31
|
+
- 1–3 line answers. Final issue report: 3–5 lines + the PR link.
|