@irythill/orq 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -16
- package/README.pt-BR.md +37 -17
- package/dist/agents.js +107 -4
- package/dist/ci.js +6 -6
- package/dist/commands/agent.js +203 -44
- package/dist/commands/board.js +1 -1
- package/dist/commands/claim.js +8 -1
- package/dist/commands/conductor.js +7 -6
- package/dist/commands/gc.js +9 -11
- package/dist/commands/pick.js +16 -4
- package/dist/commands/quota.js +1 -1
- package/dist/commands/rounds.js +48 -18
- package/dist/commands/setup.js +2 -6
- package/dist/commands/stats.js +2 -2
- package/dist/config.js +3 -0
- package/dist/diagnose.js +3 -1
- package/dist/install.js +5 -0
- package/dist/issue.js +47 -0
- package/dist/pick.js +125 -19
- package/dist/process.js +28 -7
- package/dist/quota.js +85 -19
- package/dist/rounds.js +40 -4
- package/dist/stats.js +55 -14
- package/package.json +6 -5
- package/skills/issue/SKILL.md +18 -7
- package/templates/conductor.instructions.md +31 -0
package/dist/commands/pick.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
+
import { isCommitBase, missingBinary } from "../agents.js";
|
|
8
9
|
import { loadConfig } from "../config.js";
|
|
9
10
|
import { m } from "../i18n.js";
|
|
10
11
|
import { asArgs, escalate, parseQuota, modelFor, parseWindows, pick as choose } from "../pick.js";
|
|
@@ -14,7 +15,7 @@ import { PICK_ROLES, resolveRole, vendorOf } from "../roles.js";
|
|
|
14
15
|
import { collectRuns, rows } from "../stats.js";
|
|
15
16
|
import { load as loadBlocks } from "./block.js";
|
|
16
17
|
export const USAGE = `orq pick <impl|fix|worker|review|opinion|watchdog> [--level simple|medium|heavy] [--impl-vendor <v>|--impl-agent <a>]
|
|
17
|
-
[--exclude-agent <a>]... [-w <workspace>] [--explain] [--no-record]
|
|
18
|
+
[--exclude-agent <a>]... [-w <workspace>] [--base <ref>] [--explain] [--no-record]
|
|
18
19
|
orq pick fix --same <impl pick file> same agent/model/account as the implementer, effort one level up`;
|
|
19
20
|
// The board refreshes this file; fresher than 10 min → no new quota requests.
|
|
20
21
|
const QCACHE = join(process.env.XDG_RUNTIME_DIR ?? tmpdir(), "herdr-board.quota");
|
|
@@ -48,7 +49,7 @@ export async function pick(argv) {
|
|
|
48
49
|
return 0;
|
|
49
50
|
}
|
|
50
51
|
let level = "medium", implVendor, implAgent, ws;
|
|
51
|
-
let explain = false, record = true;
|
|
52
|
+
let explain = false, record = true, base;
|
|
52
53
|
const exclude = new Set();
|
|
53
54
|
for (let i = 1; i < argv.length; i++) {
|
|
54
55
|
const a = argv[i];
|
|
@@ -62,6 +63,8 @@ export async function pick(argv) {
|
|
|
62
63
|
exclude.add(argv[++i]);
|
|
63
64
|
else if (a === "-w")
|
|
64
65
|
ws = expand(argv[++i]);
|
|
66
|
+
else if (a === "--base")
|
|
67
|
+
base = argv[++i];
|
|
65
68
|
else if (a === "--explain")
|
|
66
69
|
explain = true;
|
|
67
70
|
else if (a === "--no-record")
|
|
@@ -83,7 +86,7 @@ export async function pick(argv) {
|
|
|
83
86
|
// Eligible, not just declared: an agent that is excluded, blocked or has no model for this role/level can't be picked.
|
|
84
87
|
const blocked = loadBlocks();
|
|
85
88
|
const declared = r.mode === "auto"
|
|
86
|
-
? r.pool.filter((n) => agents[n] && !exclude.has(n) && !(n in blocked) && modelFor(agents[n], role, level)) : [];
|
|
89
|
+
? r.pool.filter((n) => agents[n] && !exclude.has(n) && !(n in blocked) && !missingBinary(cfg, n) && modelFor(agents[n], role, level)) : [];
|
|
87
90
|
if (declared.length > 1) {
|
|
88
91
|
console.error(`orq pick: ${m(`more than one agent eligible for ${role} (${declared.join(", ")}) before quota checks: pass --impl-agent or --impl-vendor`, `mais de um agente elegível para ${role} (${declared.join(", ")}) antes da checagem de quota: informe --impl-agent ou --impl-vendor`)}`);
|
|
89
92
|
return 2;
|
|
@@ -102,7 +105,16 @@ export async function pick(argv) {
|
|
|
102
105
|
} })
|
|
103
106
|
.filter((p) => p.role === role).slice(-window);
|
|
104
107
|
const qt = await quotaText(cfg);
|
|
105
|
-
|
|
108
|
+
// A review of a commit base (a round delta) leaves out agents that can't diff against one (agents.<name>.base_commit).
|
|
109
|
+
const commitBase = role === "review" && Boolean(base) && isCommitBase((ws ?? cfg._workspace ?? process.cwd()), base);
|
|
110
|
+
const r0 = resolveRole(cfg, role);
|
|
111
|
+
const missing = new Map();
|
|
112
|
+
for (const n of r0.mode === "fixed" ? r0.order : r0.pool) {
|
|
113
|
+
const why = agents[n] && missingBinary(cfg, n);
|
|
114
|
+
if (why)
|
|
115
|
+
missing.set(n, why);
|
|
116
|
+
}
|
|
117
|
+
const res = choose({ cfg, role, level, implVendor, implAgent, exclude, commitBase, missing, stats, quota: parseQuota(qt), windows: parseWindows(qt),
|
|
106
118
|
blocks: new Set(Object.keys(loadBlocks())), recent });
|
|
107
119
|
if (!res.best) {
|
|
108
120
|
console.error(`orq pick: ${m("no candidate for ", "nenhum candidato para ")}${role}`
|
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
|
@@ -23,6 +23,7 @@ const why = (r) => ({
|
|
|
23
23
|
at_max: m(`rounds ${r.n} = max_rounds ${r.max}`, `rodadas ${r.n} = max_rounds ${r.max}`),
|
|
24
24
|
same_finding: m(`same finding in ${r.n} reviews`, `mesmo achado em ${r.n} revisões`),
|
|
25
25
|
repeated: m(`${r.n} repeated finding(s)`, `${r.n} achado(s) repetido(s)`),
|
|
26
|
+
fresh: m(`new heavy findings in fix rounds ${r.rounds?.join(", ")}: split the issue`, `achados graves novos nas rodadas ${r.rounds?.join(", ")}: divida a issue`),
|
|
26
27
|
slow: m(`round ${r.rounds?.join(", ")} much slower than the others`, `rodada ${r.rounds?.join(", ")} bem mais lenta que as outras`),
|
|
27
28
|
})[r.code];
|
|
28
29
|
export async function rounds(argv) {
|
|
@@ -65,6 +66,8 @@ export async function rounds(argv) {
|
|
|
65
66
|
}
|
|
66
67
|
console.log(`${id} ${mark(x)} ${m("alert", "alerta")}: ${x.level}${x.reasons.length ? ` (${x.reasons.map(why).join("; ")})` : ""}`);
|
|
67
68
|
console.log(m(`rounds ${x.rounds} of max ${x.max}; total time ${dur(x.secs)}; ${x.findings} finding(s) across reviews`, `rodadas ${x.rounds} de no máx. ${x.max}; tempo total ${dur(x.secs)}; ${x.findings} achado(s) nas revisões`));
|
|
69
|
+
if (x.followups)
|
|
70
|
+
console.log(m("follow-ups (only review counts): ", "follow-ups (só review conta): ") + Object.entries(x.followups).map(([k, n]) => `${k} ${n}`).join(", "));
|
|
68
71
|
for (const r of x.perRound)
|
|
69
72
|
console.log(` ${r.round === 0 ? m("impl + review", "impl + revisão") : m(`fix ${r.round} + review`, `fix ${r.round} + revisão`)}: ${dur(r.secs)}`);
|
|
70
73
|
if (x.repeats.length)
|
|
@@ -96,37 +99,64 @@ async function diagnoseIssue(ws, id, x) {
|
|
|
96
99
|
}
|
|
97
100
|
const ev = evidence(dir);
|
|
98
101
|
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-"));
|
|
102
|
+
// A failed watchdog (e.g. a model catalog timeout) gets one retry with the next candidate, never the same agent.
|
|
103
|
+
const excluded = [];
|
|
104
|
+
let first;
|
|
105
|
+
const scratches = [];
|
|
107
106
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
107
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
108
|
+
const p = orq("pick", "watchdog", "--level", "simple", "-w", ws, ...(implPick ? ["--impl-agent", implPick] : []), ...excluded.flatMap((e) => ["--exclude-agent", e]));
|
|
109
|
+
if (p.status !== 0) {
|
|
110
|
+
if (first) {
|
|
111
|
+
console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
|
|
112
|
+
return first.rc;
|
|
113
|
+
}
|
|
114
|
+
console.error(p.stderr.trim() || `orq pick watchdog: exit ${p.status}`);
|
|
115
|
+
return p.status ?? 1;
|
|
116
|
+
}
|
|
117
|
+
const picked = p.stdout.trim().split(/\s+/), agent = /(?:^|\s)-a (\S+)/.exec(p.stdout)?.[1];
|
|
118
|
+
// The agent runs in a scratch folder (everything it needs is in the prompt): it can't touch the issue files, even write sandboxed.
|
|
119
|
+
const scratch = mkdtempSync(join(tmpdir(), "orq-watchdog-"));
|
|
120
|
+
scratches.push(scratch);
|
|
121
|
+
const run = `${prefix}-${attempt}`;
|
|
122
|
+
{
|
|
123
|
+
writeFileSync(`${run}.md`, readFileSync(`${prefix}.md`));
|
|
124
|
+
const a = orq("agent", ...picked, "-r", "-t", `${id} watchdog`, "-d", scratch, "-g", "watchdog", "-o", run, "--", `${run}.md`);
|
|
125
|
+
process.stderr.write(a.stderr);
|
|
126
|
+
process.stdout.write(a.stdout);
|
|
127
|
+
const rc = a.status !== 0 ? a.status ?? 1 : await finish(await wait(["--timeout", String(WATCHDOG_TIMEOUT), run]), run, out, agent, attempt === 1 || !agent);
|
|
128
|
+
if (rc === 0 || rc === 124 || !agent)
|
|
129
|
+
return rc;
|
|
130
|
+
if (attempt === 0)
|
|
131
|
+
console.error(m(`watchdog ${agent} failed; retrying with another agent`, `watchdog ${agent} falhou; tentando com outro agente`));
|
|
132
|
+
first = { rc };
|
|
133
|
+
excluded.push(agent);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return first?.rc ?? 1;
|
|
137
|
+
// Removed only after every attempt, and best-effort: on Windows a just-finished agent can still hold its cwd (EPERM/EBUSY).
|
|
114
138
|
}
|
|
115
139
|
finally {
|
|
116
|
-
|
|
140
|
+
for (const s of scratches) {
|
|
141
|
+
try {
|
|
142
|
+
rmSync(s, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
143
|
+
}
|
|
144
|
+
catch { /* temp dir: the OS cleans it */ }
|
|
145
|
+
}
|
|
117
146
|
}
|
|
118
147
|
}
|
|
119
|
-
async function finish(rc, prefix, out) {
|
|
148
|
+
async function finish(rc, prefix, out, agent, final = true) {
|
|
120
149
|
if (rc === 124) {
|
|
121
150
|
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
151
|
return 124;
|
|
123
152
|
}
|
|
124
153
|
if (rc !== 0 || !existsSync(`${prefix}.out.md`)) {
|
|
125
|
-
|
|
154
|
+
if (final)
|
|
155
|
+
console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
|
|
126
156
|
return 1;
|
|
127
157
|
}
|
|
128
158
|
copyFileSync(`${prefix}.out.md`, out);
|
|
129
|
-
console.log(m(`diagnosis saved to ${out}`, `diagnóstico gravado em ${out}`));
|
|
159
|
+
console.log(m(`diagnosis saved to ${out}${agent ? ` (by ${agent})` : ""}`, `diagnóstico gravado em ${out}${agent ? ` (por ${agent})` : ""}`));
|
|
130
160
|
console.log(recommendation(readFileSync(out, "utf8")) ?? m("(no RECOMMENDATION line in the answer)", "(sem linha RECOMMENDATION na resposta)"));
|
|
131
161
|
return 0;
|
|
132
162
|
}
|
package/dist/commands/setup.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
10
|
import { copyFileSync, existsSync, readFileSync } from "node:fs";
|
|
11
11
|
import { homedir, totalmem } from "node:os";
|
|
12
|
-
import {
|
|
12
|
+
import { join } from "node:path";
|
|
13
13
|
import { createInterface } from "node:readline/promises";
|
|
14
|
+
import { onPath } from "../agents.js";
|
|
14
15
|
import { writeAtomic } from "../fsutil.js";
|
|
15
16
|
import { banner, infoLines } from "../banner.js";
|
|
16
17
|
import { getPath, loadConfig } from "../config.js";
|
|
@@ -28,11 +29,6 @@ const CLIS = {
|
|
|
28
29
|
antigravity: { bin: "agy", test: 'agy -p "reply ok"' },
|
|
29
30
|
copilot: { bin: "copilot", test: 'copilot -p "reply ok" -s' },
|
|
30
31
|
};
|
|
31
|
-
/** Is `bin` on PATH? (PATHEXT on Windows: claude.cmd, codex.exe…) */
|
|
32
|
-
export function onPath(bin) {
|
|
33
|
-
const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
34
|
-
return (process.env.PATH ?? "").split(delimiter).some((d) => d && exts.some((e) => existsSync(join(d, bin + e))));
|
|
35
|
-
}
|
|
36
32
|
export const detect = () => Object.keys(CLIS).filter((n) => onPath(CLIS[n].bin));
|
|
37
33
|
function write(toml) {
|
|
38
34
|
const f = userConfigFile();
|
package/dist/commands/stats.js
CHANGED
|
@@ -3,7 +3,7 @@ import { getPath, loadConfig } from "../config.js";
|
|
|
3
3
|
import { m } from "../i18n.js";
|
|
4
4
|
import { expand } from "../paths.js";
|
|
5
5
|
import { collectRuns, rows } from "../stats.js";
|
|
6
|
-
export const USAGE = "orq stats [-w <workspace>] [--days N] [--json] scoreboard: delivery, time
|
|
6
|
+
export const USAGE = "orq stats [-w <workspace>] [--days N] [--json] scoreboard per agent and model: delivery, time, tokens";
|
|
7
7
|
const k = (n) => (n === null ? "-" : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
|
|
8
8
|
const ORDER = ["impl", "fix", "review", "watchdog", "other"];
|
|
9
9
|
export function stats(argv) {
|
|
@@ -35,7 +35,7 @@ export function stats(argv) {
|
|
|
35
35
|
for (const x of [...rs].sort((a, b) => ORDER.indexOf(a.role) - ORDER.indexOf(b.role) || b.n - a.n)) {
|
|
36
36
|
const ap = x.approved !== null ? `${x.approved}% (${x.n_appr})` : "-";
|
|
37
37
|
console.log(`${x.role.padEnd(7)} ${x.agent.padEnd(12)} ${x.model.slice(0, 22).padEnd(22)} ${String(x.n).padStart(4)} `
|
|
38
|
-
+ `${String(x.ok).padStart(5)} ${ap.padStart(12)} ${String(x.min).padStart(6)} ${k(x.new).padStart(10)} ${k(x.tot).padStart(10)}`);
|
|
38
|
+
+ `${String(x.ok).padStart(5)} ${ap.padStart(12)} ${String(x.min ?? "-").padStart(6)} ${k(x.new).padStart(10)} ${k(x.tot).padStart(10)}`);
|
|
39
39
|
}
|
|
40
40
|
return 0;
|
|
41
41
|
}
|
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/diagnose.js
CHANGED
|
@@ -18,6 +18,7 @@ const reason = (r) => ({
|
|
|
18
18
|
at_max: `rounds ${r.n} reached max_rounds ${r.max}`,
|
|
19
19
|
same_finding: `the same heavy finding came back in ${r.n} reviews`,
|
|
20
20
|
repeated: `${r.n} heavy finding(s) repeated across reviews`,
|
|
21
|
+
fresh: `the reviews of fix rounds ${r.rounds?.join(", ")} raised new heavy findings outside what was fixed (scope too big?)`,
|
|
21
22
|
slow: `round ${r.rounds?.join(", ")} was much slower than the others`,
|
|
22
23
|
})[r.code];
|
|
23
24
|
const mins = (s) => `${Math.round(s / 60)} min`;
|
|
@@ -39,7 +40,8 @@ export function buildPrompt(x, fixes, reviews, cap = DIAG_CAP) {
|
|
|
39
40
|
`## What to decide`,
|
|
40
41
|
`Look for the real cause, for example: reviewer and implementer disagree about the requirement or the style; the scope is too big for one`,
|
|
41
42
|
`branch; a flaky or environment-dependent test; an ambiguous spec or acceptance criterion; fixes that fix the symptom of each review and`,
|
|
42
|
-
`introduce the next one; a reviewer that keeps raising new things instead of checking the previous ones
|
|
43
|
+
`introduce the next one; a reviewer that keeps raising new things instead of checking the previous ones (re-reading the whole branch every
|
|
44
|
+
round: findings outside the fix diff don't make a round, they go to a follow-up list — recommend split, not continue).`,
|
|
43
45
|
``,
|
|
44
46
|
`## Answer format`,
|
|
45
47
|
`A short diagnosis (at most 15 lines): the cause, with the evidence (which round, which file or finding). Then, as the LAST line, exactly:`,
|
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,8 +3,9 @@
|
|
|
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
|
|
7
|
-
//
|
|
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.
|
|
8
|
+
// Filters: --exclude-agent, orq block, a CLI that doesn't resolve on PATH, 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.
|
|
10
11
|
// An agent's `headroom` (points) lowers both limits for it: that share of its accounts stays for the conductors.
|
|
@@ -12,7 +13,7 @@
|
|
|
12
13
|
// Pacing: an account whose usage, at the pace so far in a window (used ÷ elapsed share), projects past
|
|
13
14
|
// 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
15
|
// and otherwise loses policy.pacing_penalty (default 0.3, 0 = off) of score — soft, never a drop.
|
|
15
|
-
import { agentDef, usesPrompt } from "./agents.js";
|
|
16
|
+
import { agentDef, baseCommit, usesPrompt } from "./agents.js";
|
|
16
17
|
import { m } from "./i18n.js";
|
|
17
18
|
import { humanReset } from "./quota.js";
|
|
18
19
|
import { legacyRole, resolveRole, vendorOf } from "./roles.js";
|
|
@@ -84,8 +85,47 @@ 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 ?? {}) });
|
|
120
|
+
/** The merged definition (built-in + config) can review a commit base (agents.<name>.base_commit); an agent that can't be resolved can't. */
|
|
121
|
+
function takesBaseCommit(cfg, name) {
|
|
122
|
+
try {
|
|
123
|
+
return baseCommit(agentDef(cfg, name));
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
89
129
|
/** The merged definition (built-in + config) takes a prompt; an agent that can't be resolved doesn't. */
|
|
90
130
|
function takesPrompt(cfg, name) {
|
|
91
131
|
try {
|
|
@@ -125,6 +165,11 @@ export function pick(inp) {
|
|
|
125
165
|
out(name, m("blocked (orq block -l)", "bloqueado (orq block -l)"));
|
|
126
166
|
continue;
|
|
127
167
|
}
|
|
168
|
+
const gone = inp.missing?.get(name);
|
|
169
|
+
if (gone) {
|
|
170
|
+
out(name, gone);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
128
173
|
if ((role === "review" || role === "opinion" || role === "watchdog") && name === inp.implAgent && someoneElse) {
|
|
129
174
|
out(name, m("it implemented this code", "foi quem implementou"));
|
|
130
175
|
continue;
|
|
@@ -134,6 +179,10 @@ export function pick(inp) {
|
|
|
134
179
|
out(name, m("same vendor as the implementer", "mesmo fornecedor do implementador"));
|
|
135
180
|
continue;
|
|
136
181
|
}
|
|
182
|
+
if (role === "review" && inp.commitBase && !takesBaseCommit(cfg, name)) {
|
|
183
|
+
out(name, m("can't review a commit base (base_commit = false)", "não revisa uma base que é commit (base_commit = false)"));
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
137
186
|
if (role === "watchdog" && !takesPrompt(cfg, name)) {
|
|
138
187
|
out(name, m("ignores prompts (a review tool)", "ignora prompts (ferramenta de revisão)"));
|
|
139
188
|
continue;
|
|
@@ -198,14 +247,49 @@ export function pick(inp) {
|
|
|
198
247
|
return { pace: paces.length ? Math.max(...paces) : undefined,
|
|
199
248
|
hot: penalty > 0 && ((p5 !== undefined && p5 > limit5) || (pw !== undefined && pw > limitWeek)) };
|
|
200
249
|
};
|
|
250
|
+
// The chosen account's per-model buckets: an exhausted model gives way to a lighter one of the same agent.
|
|
251
|
+
let mfx = mf;
|
|
252
|
+
const acc0 = usable.length ? [...usable].sort((x, y) => x[1] - y[1])[0][0] : undefined;
|
|
253
|
+
const mkeys = acc0 ? Object.keys(inp.windows ?? {}).filter((k) => k.startsWith(`${acc0}/`)).map((k) => k.slice(acc0.length + 1)) : [];
|
|
254
|
+
if (acc0 && mkeys.length) {
|
|
255
|
+
const over = (model) => {
|
|
256
|
+
const k = modelKey(model, mkeys), q = k ? inp.windows?.[`${acc0}/${k}`] : undefined;
|
|
257
|
+
if (!q)
|
|
258
|
+
return undefined;
|
|
259
|
+
const five = (q.five ?? -1) >= limit5, week = (q.week ?? -1) >= limitWeek;
|
|
260
|
+
if (!five && !week)
|
|
261
|
+
return undefined;
|
|
262
|
+
const at = [five ? q.resetFive : undefined, week ? q.resetWeek : undefined].filter((t) => t !== undefined);
|
|
263
|
+
return `${model} ${five ? `5h ${q.five}%` : `${m("week", "semana")} ${q.week}%`}`
|
|
264
|
+
+ (at.length ? ` (${m("back", "volta")} ${humanReset(Math.max(...at))})` : "");
|
|
265
|
+
};
|
|
266
|
+
const tried = [], skipped = [];
|
|
267
|
+
let found;
|
|
268
|
+
for (const cand of [mf, ...lighterModels(a, role, level, [mf[0]])]) {
|
|
269
|
+
const why = over(cand[0]);
|
|
270
|
+
if (!why) {
|
|
271
|
+
found = cand;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
tried.push(cand[0]);
|
|
275
|
+
skipped.push(why);
|
|
276
|
+
}
|
|
277
|
+
if (!found) {
|
|
278
|
+
out(name, `${m("every model over its quota", "todo modelo acima da cota")}: ${skipped.join(", ")}`);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (skipped.length)
|
|
282
|
+
out(name, `${m("quota exhausted", "cota esgotada")}: ${skipped.join(", ")}; ${m("using", "usando")} ${found[0]}`);
|
|
283
|
+
mfx = found;
|
|
284
|
+
}
|
|
201
285
|
const ranked = usable.map(([n, v]) => ({ n, v: v, ...paceOf(n) }))
|
|
202
286
|
.sort((x, y) => Number(x.hot) - Number(y.hot) || x.v - y.v);
|
|
203
287
|
const chosen = ranked[0];
|
|
204
288
|
const acct = qn.length > 1 ? chosen?.n : undefined;
|
|
205
289
|
const statRole = role === "fix" || role === "worker" ? "impl" : role;
|
|
206
|
-
const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (
|
|
290
|
+
const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (mfx[0].split("/").pop() || "default"));
|
|
207
291
|
const st = stOf(statRole) ?? (role === "watchdog" ? stOf("review") : undefined); // no watchdog runs yet: its review record
|
|
208
|
-
pass.push({ agent: name, model:
|
|
292
|
+
pass.push({ agent: name, model: mfx[0], effort: mfx[1], acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
|
|
209
293
|
if (res.mode === "fixed")
|
|
210
294
|
break; // the first that passes wins
|
|
211
295
|
}
|
|
@@ -215,24 +299,46 @@ export function pick(inp) {
|
|
|
215
299
|
why: m(`fixed by [roles.${res.section}]`, `fixo em [roles.${res.section}]`) };
|
|
216
300
|
return { mode: "fixed", best, cands: best ? [best] : [], notes };
|
|
217
301
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
302
|
+
// Each axis blends the agent's prior with what was measured, (prior·k + observed·n) / (k + n) with k = min_samples,
|
|
303
|
+
// so a couple of runs already move the score instead of being ignored until the threshold.
|
|
304
|
+
const k = Math.max(0, w.min_samples);
|
|
305
|
+
const few = (st) => (st?.n ?? 0) < k;
|
|
306
|
+
const blend = (prior, obs, n) => obs === undefined || k + n <= 0 ? prior : (prior * k + obs * n) / (k + n);
|
|
307
|
+
// The reference cost/time is the best of the well-sampled agents, or of everyone measured when none is.
|
|
308
|
+
const best = (vals) => {
|
|
309
|
+
const solid = vals.filter((x) => x.solid);
|
|
310
|
+
const use = solid.length ? solid : vals;
|
|
311
|
+
return use.length ? Math.min(...use.map((x) => x.v)) : undefined;
|
|
312
|
+
};
|
|
313
|
+
const refTok = best(pass.filter((c) => c.st?.new).map((c) => ({ v: c.st.new, solid: !few(c.st) })));
|
|
314
|
+
const refMin = best(pass.filter((c) => c.st?.min).map((c) => ({ v: Math.max(c.st.min, 0.1), solid: !few(c.st) })));
|
|
315
|
+
const axis = (label, prior, obs, n) => {
|
|
316
|
+
const v = blend(prior, obs, n);
|
|
317
|
+
return { v, txt: `${label}=${v.toFixed(2)}` + (obs === undefined ? "" : `(${prior.toFixed(2)}→${obs.toFixed(2)})`) };
|
|
318
|
+
};
|
|
221
319
|
const cands = pass.map((c) => {
|
|
222
320
|
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 =
|
|
321
|
+
const appr = role === "impl" && c.st?.approved != null && c.st.n_appr >= 3;
|
|
322
|
+
const deliv = appr ? c.st.approved / 100 : c.st ? c.st.ok / 100 : undefined;
|
|
323
|
+
const d = axis(m("delivery", "entrega"), prior, deliv, appr ? c.st.n_appr : n);
|
|
324
|
+
const t = axis(m("economy", "economia"), prior, c.st?.new && refTok ? Math.min(1, refTok / c.st.new) : undefined, n);
|
|
325
|
+
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
326
|
const used = inp.recent.filter((p) => p.agent === c.agent).length;
|
|
327
|
+
// Exploration fades as samples come in and stops after explore_streak picks of the agent in a row.
|
|
328
|
+
let streak = 0;
|
|
329
|
+
for (let i = inp.recent.length - 1; i >= 0 && inp.recent[i].agent === c.agent; i--)
|
|
330
|
+
streak++;
|
|
331
|
+
const capped = few(c.st) && streak >= w.explore_streak;
|
|
332
|
+
const explore = few(c.st) && !capped ? w.explore * (k - n) / k : 0;
|
|
229
333
|
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} `
|
|
334
|
+
const score = w.quality * d.v + w.tokens * t.v + w.speed * s.v - w.rotation * used
|
|
335
|
+
- (reserve ? w.reserve : 0) + explore - (hot ? penalty : 0);
|
|
336
|
+
const why = `${d.txt} ${t.txt} ${s.txt} ${m("rotation", "rodízio")}=-${used} `
|
|
234
337
|
+ (reserve ? m("reserve ", "reserva ") : "")
|
|
235
|
-
+ (hot ? `${m("pace", "ritmo")}=${Math.round(c.pace)}%→-${penalty} ` : "")
|
|
338
|
+
+ (hot ? `${m("pace", "ritmo")}=${Math.round(c.pace)}%→-${penalty} ` : "")
|
|
339
|
+
+ (explore ? `${m("explore", "explorar")}=+${explore.toFixed(2)} ` : "")
|
|
340
|
+
+ (capped ? `${m("explore capped", "exploração limitada")}(${streak}x) ` : "")
|
|
341
|
+
+ (few(c.st) ? `${m("few samples", "poucos dados")}(n=${n})` : `n=${n}`);
|
|
236
342
|
return { agent: c.agent, model: c.model, effort: c.effort, acct: c.acct, score, why };
|
|
237
343
|
}).sort((x, y) => y.score - x.score);
|
|
238
344
|
return { mode: "auto", best: cands[0], cands, notes };
|
package/dist/process.js
CHANGED
|
@@ -104,6 +104,15 @@ export function killTree(pid, group = true) {
|
|
|
104
104
|
}
|
|
105
105
|
catch { /* gone */ } // no group of its own
|
|
106
106
|
}
|
|
107
|
+
/** Exit code when the command never started (spawn threw or emitted "error"). */
|
|
108
|
+
export const SPAWN_FAILED = 127;
|
|
109
|
+
/** Node throws some spawn errors synchronously (E2BIG, EINVAL...) and emits others (ENOENT): one path for both. */
|
|
110
|
+
function startFailed(e, o) {
|
|
111
|
+
const err = e;
|
|
112
|
+
console.error(`orq: ${err.message}`);
|
|
113
|
+
o.onError?.(err);
|
|
114
|
+
return Promise.resolve(SPAWN_FAILED);
|
|
115
|
+
}
|
|
107
116
|
/** cmd.exe quoting for one argument (only used when Windows needs a shell). */
|
|
108
117
|
export const winQuote = (a) => /[\s"&|<>^%()]/.test(a) || a === "" ? `"${a.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"` : a;
|
|
109
118
|
const gb = (b) => `${(b / 2 ** 30).toFixed(1)} GB`;
|
|
@@ -113,18 +122,30 @@ export function runCapped(cmd, args, o = {}) {
|
|
|
113
122
|
if (mode === "kernel") {
|
|
114
123
|
const sd = ["--user", "--scope", "-q", ...noExp(), ...(o.unit ? [`--unit=${o.unit}`] : []), "-p", `MemoryMax=${o.cap}`,
|
|
115
124
|
...(o.swap !== undefined ? ["-p", `MemorySwapMax=${o.swap}`] : []), "--", cmd, ...args];
|
|
116
|
-
|
|
125
|
+
let c;
|
|
126
|
+
try {
|
|
127
|
+
c = spawn("systemd-run", sd, { stdio: o.stdio ?? "inherit", cwd: o.cwd, env: o.env });
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
return startFailed(e, o);
|
|
131
|
+
}
|
|
117
132
|
o.onSpawn?.(c);
|
|
118
|
-
return wait(c);
|
|
133
|
+
return wait(c, o);
|
|
119
134
|
}
|
|
120
135
|
const win = process.platform === "win32";
|
|
121
136
|
// Own process group on Linux/macOS, so the whole tree can be killed at once (not for a screen: it needs the terminal).
|
|
122
137
|
// Windows: a shell only for .cmd/.bat shims (npx, claude…); an .exe runs directly (paths with spaces stay intact).
|
|
123
138
|
const shell = win && !/\.exe$/i.test(cmd), q = shell && o.quote;
|
|
124
|
-
|
|
139
|
+
let child;
|
|
140
|
+
try {
|
|
141
|
+
child = spawn(q ? winQuote(cmd) : cmd, q ? args.map(winQuote) : args, { stdio: o.stdio ?? "inherit", cwd: o.cwd, env: o.env, detached: !win && !o.tty, shell, windowsHide: true });
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
return startFailed(e, o);
|
|
145
|
+
}
|
|
125
146
|
o.onSpawn?.(child);
|
|
126
147
|
if (mode === "off" || !child.pid)
|
|
127
|
-
return wait(child);
|
|
148
|
+
return wait(child, o);
|
|
128
149
|
const fwd = (sig) => () => { try {
|
|
129
150
|
process.kill(-child.pid, sig);
|
|
130
151
|
}
|
|
@@ -149,14 +170,14 @@ export function runCapped(cmd, args, o = {}) {
|
|
|
149
170
|
killTree(child.pid, !o.tty);
|
|
150
171
|
}
|
|
151
172
|
}, o.intervalMs ?? 1000);
|
|
152
|
-
return wait(child).then((code) => {
|
|
173
|
+
return wait(child, o).then((code) => {
|
|
153
174
|
clearInterval(timer);
|
|
154
175
|
process.off("SIGINT", onInt);
|
|
155
176
|
process.off("SIGTERM", onTerm);
|
|
156
177
|
return killed ? 137 : code;
|
|
157
178
|
});
|
|
158
179
|
}
|
|
159
|
-
const wait = (c) => new Promise((res) => {
|
|
160
|
-
c.on("error", (e) => {
|
|
180
|
+
const wait = (c, o = {}) => new Promise((res) => {
|
|
181
|
+
c.on("error", (e) => { startFailed(e, o).then(res); });
|
|
161
182
|
c.on("exit", (code, sig) => res(code ?? (sig ? 128 + 9 : 1)));
|
|
162
183
|
});
|