@irythill/orq 0.6.1 → 0.8.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 +24 -17
- package/README.pt-BR.md +35 -18
- package/dist/agents.js +75 -2
- package/dist/commands/agent.js +173 -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/pick.js +16 -4
- package/dist/commands/rounds.js +3 -0
- package/dist/commands/setup.js +2 -6
- package/dist/commands/stats.js +2 -2
- package/dist/diagnose.js +3 -1
- package/dist/pick.js +73 -42
- package/dist/process.js +28 -7
- package/dist/rounds.js +40 -4
- package/dist/stats.js +55 -14
- package/package.json +4 -4
- package/skills/issue/SKILL.md +18 -7
package/dist/pick.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Picks agent, model, effort and account for a role (port of orq-pick, plus fixed roles).
|
|
2
2
|
// fixed role: the first agent of [agent, ...fallback] that passes the filters wins — no scoring, never swapped
|
|
3
3
|
// for an agent the user didn't list.
|
|
4
|
-
// auto role: every
|
|
4
|
+
// auto role: every (agent, model) pair that passes is scored (a level may list several models):
|
|
5
5
|
// quality * delivery + tokens * economy + speed * quickness − rotation * (picks in the last `window`)
|
|
6
6
|
// − reserve (agents with reserve = true) + explore (fewer than `min_samples` runs, fading with n, none after
|
|
7
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, nobody reviews their own code (unless no other agent can), cross-vendor rule, a model for the role/level,
|
|
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,
|
|
9
9
|
// quota under the limits: policy.quota_week (95) on the weekly window, and on the 5h one policy.quota_5h per level
|
|
10
10
|
// (simple 90, medium 85, heavy 80): a heavy task needs more room left to finish.
|
|
11
11
|
// An agent's `headroom` (points) lowers both limits for it: that share of its accounts stays for the conductors.
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// Pacing: an account whose usage, at the pace so far in a window (used ÷ elapsed share), projects past
|
|
14
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,
|
|
15
15
|
// and otherwise loses policy.pacing_penalty (default 0.3, 0 = off) of score — soft, never a drop.
|
|
16
|
-
import { agentDef, usesPrompt } from "./agents.js";
|
|
16
|
+
import { agentDef, baseCommit, usesPrompt } from "./agents.js";
|
|
17
17
|
import { m } from "./i18n.js";
|
|
18
18
|
import { humanReset } from "./quota.js";
|
|
19
19
|
import { legacyRole, resolveRole, vendorOf } from "./roles.js";
|
|
@@ -73,17 +73,25 @@ export function limitsFor(pol, level) {
|
|
|
73
73
|
const five = { ...FIVE, ...(pol.quota_5h ?? {}) };
|
|
74
74
|
return [Math.min(Number(five[level] ?? five.medium), Number(pol.quota_limit ?? 90)), Number(pol.quota_week ?? 95)];
|
|
75
75
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const models = a.models;
|
|
76
|
+
/** The agent's models section for a role, with the same fallbacks as the roles (fix → impl, watchdog → opinion → review). */
|
|
77
|
+
function roleModels(a, role) {
|
|
78
|
+
const models = (a.models ?? {});
|
|
80
79
|
const lr = legacyRole(role);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
80
|
+
return models[lr] ?? (lr === "fix" ? models.impl : lr === "watchdog" ? models.opinion ?? models.review : undefined) ?? {};
|
|
81
|
+
}
|
|
82
|
+
const specList = (spec) => (Array.isArray(spec) ? spec : spec ? [spec] : []).map(String).filter(Boolean)
|
|
83
|
+
.map((x) => { const [model, effort] = x.split("@"); return [model, effort || undefined]; });
|
|
84
|
+
/** Every candidate model for the role/level, in the order configured; undefined when none is. */
|
|
85
|
+
export function modelsFor(a, role, level) {
|
|
86
|
+
if (a.models === undefined)
|
|
87
|
+
return [["", undefined]]; // no models configured: the CLI's default
|
|
88
|
+
const ms = roleModels(a, role);
|
|
89
|
+
const list = specList(ms[level] ?? ms.default ?? ms.medium ?? ms.simple);
|
|
90
|
+
return list.length ? list : undefined;
|
|
91
|
+
}
|
|
92
|
+
/** The first candidate model for the role/level. */
|
|
93
|
+
export function modelFor(a, role, level) {
|
|
94
|
+
return modelsFor(a, role, level)?.[0];
|
|
87
95
|
}
|
|
88
96
|
// --- per-model quota ---------------------------------------------------------------------------------------------------
|
|
89
97
|
// A collector may add lines "<account>/<model> 5h=.. week=.." (antigravity: one bucket per model group). The model a pick is about
|
|
@@ -98,25 +106,33 @@ export function modelKey(model, keys) {
|
|
|
98
106
|
}
|
|
99
107
|
/** The models of an agent's role section lighter than `level`, the closest first, without repeats of `tried`. */
|
|
100
108
|
export function lighterModels(a, role, level, tried) {
|
|
101
|
-
|
|
102
|
-
if (!models)
|
|
109
|
+
if (!a.models)
|
|
103
110
|
return [];
|
|
104
|
-
const
|
|
105
|
-
const ms = models[lr] ?? (lr === "fix" ? models.impl : lr === "watchdog" ? models.opinion ?? models.review : undefined) ?? {};
|
|
111
|
+
const ms = roleModels(a, role);
|
|
106
112
|
const rank = LEVEL_RANK[level] ?? 1, seen = new Set(tried);
|
|
107
113
|
const out = [];
|
|
108
114
|
for (const [, spec] of Object.entries(ms).filter(([lv]) => (LEVEL_RANK[lv] ?? 1) < rank)
|
|
109
115
|
.sort((x, y) => (LEVEL_RANK[y[0]] ?? 1) - (LEVEL_RANK[x[0]] ?? 1))) {
|
|
110
|
-
const [model, effort]
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
116
|
+
for (const [model, effort] of specList(spec)) {
|
|
117
|
+
if (seen.has(model))
|
|
118
|
+
continue;
|
|
119
|
+
seen.add(model);
|
|
120
|
+
out.push([model, effort]);
|
|
121
|
+
}
|
|
115
122
|
}
|
|
116
123
|
return out;
|
|
117
124
|
}
|
|
118
125
|
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 };
|
|
119
126
|
export const weights = (cfg) => ({ ...WEIGHTS, ...((cfg.policy ?? {}).weights ?? {}) });
|
|
127
|
+
/** The merged definition (built-in + config) can review a commit base (agents.<name>.base_commit); an agent that can't be resolved can't. */
|
|
128
|
+
function takesBaseCommit(cfg, name) {
|
|
129
|
+
try {
|
|
130
|
+
return baseCommit(agentDef(cfg, name));
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
120
136
|
/** The merged definition (built-in + config) takes a prompt; an agent that can't be resolved doesn't. */
|
|
121
137
|
function takesPrompt(cfg, name) {
|
|
122
138
|
try {
|
|
@@ -156,6 +172,11 @@ export function pick(inp) {
|
|
|
156
172
|
out(name, m("blocked (orq block -l)", "bloqueado (orq block -l)"));
|
|
157
173
|
continue;
|
|
158
174
|
}
|
|
175
|
+
const gone = inp.missing?.get(name);
|
|
176
|
+
if (gone) {
|
|
177
|
+
out(name, gone);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
159
180
|
if ((role === "review" || role === "opinion" || role === "watchdog") && name === inp.implAgent && someoneElse) {
|
|
160
181
|
out(name, m("it implemented this code", "foi quem implementou"));
|
|
161
182
|
continue;
|
|
@@ -165,12 +186,16 @@ export function pick(inp) {
|
|
|
165
186
|
out(name, m("same vendor as the implementer", "mesmo fornecedor do implementador"));
|
|
166
187
|
continue;
|
|
167
188
|
}
|
|
189
|
+
if (role === "review" && inp.commitBase && !takesBaseCommit(cfg, name)) {
|
|
190
|
+
out(name, m("can't review a commit base (base_commit = false)", "não revisa uma base que é commit (base_commit = false)"));
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
168
193
|
if (role === "watchdog" && !takesPrompt(cfg, name)) {
|
|
169
194
|
out(name, m("ignores prompts (a review tool)", "ignora prompts (ferramenta de revisão)"));
|
|
170
195
|
continue;
|
|
171
196
|
}
|
|
172
|
-
const
|
|
173
|
-
if (!
|
|
197
|
+
const mfs = modelsFor(a, role, level);
|
|
198
|
+
if (!mfs) {
|
|
174
199
|
out(name, m(`no model for ${role}/${level}`, `sem modelo para ${role}/${level}`));
|
|
175
200
|
continue;
|
|
176
201
|
}
|
|
@@ -229,8 +254,8 @@ export function pick(inp) {
|
|
|
229
254
|
return { pace: paces.length ? Math.max(...paces) : undefined,
|
|
230
255
|
hot: penalty > 0 && ((p5 !== undefined && p5 > limit5) || (pw !== undefined && pw > limitWeek)) };
|
|
231
256
|
};
|
|
232
|
-
// The chosen account's per-model buckets: an exhausted
|
|
233
|
-
let mfx =
|
|
257
|
+
// The chosen account's per-model buckets: an exhausted candidate drops out; with none left, a lighter model of the agent.
|
|
258
|
+
let mfx = mfs;
|
|
234
259
|
const acc0 = usable.length ? [...usable].sort((x, y) => x[1] - y[1])[0][0] : undefined;
|
|
235
260
|
const mkeys = acc0 ? Object.keys(inp.windows ?? {}).filter((k) => k.startsWith(`${acc0}/`)).map((k) => k.slice(acc0.length + 1)) : [];
|
|
236
261
|
if (acc0 && mkeys.length) {
|
|
@@ -245,23 +270,25 @@ export function pick(inp) {
|
|
|
245
270
|
return `${model} ${five ? `5h ${q.five}%` : `${m("week", "semana")} ${q.week}%`}`
|
|
246
271
|
+ (at.length ? ` (${m("back", "volta")} ${humanReset(Math.max(...at))})` : "");
|
|
247
272
|
};
|
|
248
|
-
const
|
|
249
|
-
let found;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
273
|
+
const skipped = [];
|
|
274
|
+
let found = mfs.filter((c) => { const why = over(c[0]); if (why)
|
|
275
|
+
skipped.push(why); return !why; });
|
|
276
|
+
if (!found.length) {
|
|
277
|
+
for (const cand of lighterModels(a, role, level, mfs.map((c) => c[0]))) {
|
|
278
|
+
const why = over(cand[0]);
|
|
279
|
+
if (!why) {
|
|
280
|
+
found = [cand];
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
skipped.push(why);
|
|
255
284
|
}
|
|
256
|
-
tried.push(cand[0]);
|
|
257
|
-
skipped.push(why);
|
|
258
285
|
}
|
|
259
|
-
if (!found) {
|
|
286
|
+
if (!found.length) {
|
|
260
287
|
out(name, `${m("every model over its quota", "todo modelo acima da cota")}: ${skipped.join(", ")}`);
|
|
261
288
|
continue;
|
|
262
289
|
}
|
|
263
290
|
if (skipped.length)
|
|
264
|
-
out(name, `${m("quota exhausted", "cota esgotada")}: ${skipped.join(", ")}; ${m("using", "usando")} ${found[0]}`);
|
|
291
|
+
out(name, `${m("quota exhausted", "cota esgotada")}: ${skipped.join(", ")}; ${m("using", "usando")} ${found.map((c) => c[0]).join(", ")}`);
|
|
265
292
|
mfx = found;
|
|
266
293
|
}
|
|
267
294
|
const ranked = usable.map(([n, v]) => ({ n, v: v, ...paceOf(n) }))
|
|
@@ -269,9 +296,12 @@ export function pick(inp) {
|
|
|
269
296
|
const chosen = ranked[0];
|
|
270
297
|
const acct = qn.length > 1 ? chosen?.n : undefined;
|
|
271
298
|
const statRole = role === "fix" || role === "worker" ? "impl" : role;
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
299
|
+
// One candidate per (agent, model) pair, each scored on its own stats row; a fixed role takes the first model.
|
|
300
|
+
for (const [model, effort] of res.mode === "fixed" ? mfx.slice(0, 1) : mfx) {
|
|
301
|
+
const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (model.split("/").pop() || "default"));
|
|
302
|
+
const st = stOf(statRole) ?? (role === "watchdog" ? stOf("review") : undefined); // no watchdog runs yet: its review record
|
|
303
|
+
pass.push({ agent: name, model, effort, acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
|
|
304
|
+
}
|
|
275
305
|
if (res.mode === "fixed")
|
|
276
306
|
break; // the first that passes wins
|
|
277
307
|
}
|
|
@@ -306,9 +336,10 @@ export function pick(inp) {
|
|
|
306
336
|
const t = axis(m("economy", "economia"), prior, c.st?.new && refTok ? Math.min(1, refTok / c.st.new) : undefined, n);
|
|
307
337
|
const s = axis(m("speed", "rapidez"), prior, c.st?.min && refMin ? Math.min(1, refMin / Math.max(c.st.min, 0.1)) : undefined, n);
|
|
308
338
|
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
|
|
339
|
+
// Exploration fades as samples come in and stops after explore_streak picks of the pair in a row (rotation stays per agent).
|
|
340
|
+
const same = (p) => p.agent === c.agent && (p.model === undefined || p.model === c.model);
|
|
310
341
|
let streak = 0;
|
|
311
|
-
for (let i = inp.recent.length - 1; i >= 0 && inp.recent[i]
|
|
342
|
+
for (let i = inp.recent.length - 1; i >= 0 && same(inp.recent[i]); i--)
|
|
312
343
|
streak++;
|
|
313
344
|
const capped = few(c.st) && streak >= w.explore_streak;
|
|
314
345
|
const explore = few(c.st) && !capped ? w.explore * (k - n) / k : 0;
|
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
|
});
|
package/dist/rounds.js
CHANGED
|
@@ -14,6 +14,7 @@ export const SLOW_FACTOR = 2; // a round this many times slower than the median
|
|
|
14
14
|
export const SLOW_MIN_SECS = 600; // ... but only if it also took at least this long
|
|
15
15
|
export const REPEAT_WARN = 2; // a finding seen in this many reviews: warn
|
|
16
16
|
export const REPEAT_STOP = 3; // ... in this many: stop (it came back after two fix rounds)
|
|
17
|
+
export const FRESH_WARN = 2; // fix rounds whose review brings heavy findings never seen before: the branch is too big, split it
|
|
17
18
|
export const HEAVY = new Set(["blocker", "major", "critical"]); // only these count towards repeats and the alert
|
|
18
19
|
const read = (f) => { try {
|
|
19
20
|
return readFileSync(f, "utf8");
|
|
@@ -40,7 +41,8 @@ const repoRel = (f) => f.replace(/\\/g, "/").replace(/^.*\.worktrees\/[^/]+\//,
|
|
|
40
41
|
export function parseFindings(text, round) {
|
|
41
42
|
const out = [];
|
|
42
43
|
for (const line of text.split("\n")) {
|
|
43
|
-
|
|
44
|
+
// A list item, or a bare line that starts with a severity tag ("[blocker] src/a.ts:10 ...", as some reviewers write).
|
|
45
|
+
const it = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line) ?? /^\s*(\[(?:blocker|major|minor|nit|suggestion|important|critical)\].*)$/i.exec(line);
|
|
44
46
|
if (!it)
|
|
45
47
|
continue;
|
|
46
48
|
const sevOf = (w) => { const k = fold(w ?? ""); return Object.hasOwn(SEV, k) ? SEV[k] : undefined; };
|
|
@@ -60,6 +62,13 @@ export function parseFindings(text, round) {
|
|
|
60
62
|
return out;
|
|
61
63
|
}
|
|
62
64
|
/** Same finding? Same file (when both name one) and enough shared words; without a file, a stricter overlap. */
|
|
65
|
+
// An explicit "nothing to fix" verdict, PT or EN, on a line of its own or as a heading/label.
|
|
66
|
+
const APPROVED = /^\s*(?:[#>*_`-]+\s*)*(?:verdict|veredito|status|resultado)?\s*[:—-]?\s*\**\s*(approved|approve|lgtm|aprovad[oa]|no (?:blocking )?(?:findings|issues|blockers)|nenhum (?:achado|problema|blocker|bloqueante)|sem (?:achados|bloqueantes|blockers))\b/im;
|
|
67
|
+
/** Does a review answer carry a verdict: at least one finding with a severity, or an explicit approval?
|
|
68
|
+
* "I'll start by exploring the repo..." (a reviewer that stopped before reviewing) has neither. */
|
|
69
|
+
export function hasVerdict(text) {
|
|
70
|
+
return parseFindings(text, 0).length > 0 || APPROVED.test(text);
|
|
71
|
+
}
|
|
63
72
|
export function similar(a, b) {
|
|
64
73
|
const wa = words(a.title), wb = words(b.title);
|
|
65
74
|
if (!wa.size || !wb.size)
|
|
@@ -86,6 +95,14 @@ export function repeats(fs) {
|
|
|
86
95
|
return groups.map((g) => ({ file: g[0].file, title: g[0].title, rounds: [...new Set(g.map((x) => x.round))] }))
|
|
87
96
|
.filter((r) => r.rounds.length >= REPEAT_WARN);
|
|
88
97
|
}
|
|
98
|
+
/** Fix rounds (k ≥ 1) whose review raised a heavy finding not similar to any earlier one: a reviewer re-reading a big branch. */
|
|
99
|
+
export function freshRounds(fs) {
|
|
100
|
+
const out = new Set();
|
|
101
|
+
for (const f of fs)
|
|
102
|
+
if (f.round > 0 && !fs.some((e) => e.round < f.round && similar(e, f)))
|
|
103
|
+
out.add(f.round);
|
|
104
|
+
return [...out].sort((a, b) => a - b);
|
|
105
|
+
}
|
|
89
106
|
const median = (xs) => { const s = [...xs].sort((a, b) => a - b), h = s.length >> 1; return s.length % 2 ? s[h] : (s[h - 1] + s[h]) / 2; };
|
|
90
107
|
/** Fix rounds far above the median of the other fix rounds (round 0 is the implementation: never compared; needs 3 fix rounds). */
|
|
91
108
|
export function slowRounds(all) {
|
|
@@ -96,7 +113,7 @@ export function slowRounds(all) {
|
|
|
96
113
|
}
|
|
97
114
|
/** The pure part: facts from the files → numbers, alert level and why. */
|
|
98
115
|
export function metrics(issue, f) {
|
|
99
|
-
const
|
|
116
|
+
const heavy = f.findings.filter((x) => HEAVY.has(x.severity)), reps = repeats(heavy), reasons = [];
|
|
100
117
|
let level = "ok";
|
|
101
118
|
const raise = (l, why) => { reasons.push(why); if (l === "stop" || level === "ok")
|
|
102
119
|
level = l; };
|
|
@@ -109,6 +126,9 @@ export function metrics(issue, f) {
|
|
|
109
126
|
raise("stop", { code: "same_finding", n: worst });
|
|
110
127
|
else if (reps.length)
|
|
111
128
|
raise("warn", { code: "repeated", n: reps.length });
|
|
129
|
+
const fresh = freshRounds(heavy);
|
|
130
|
+
if (fresh.length >= FRESH_WARN)
|
|
131
|
+
raise("warn", { code: "fresh", n: fresh.length, rounds: fresh });
|
|
112
132
|
const slow = slowRounds(f.perRound);
|
|
113
133
|
if (slow.length)
|
|
114
134
|
raise("warn", { code: "slow", n: slow.length, rounds: slow });
|
|
@@ -121,11 +141,25 @@ export function countRounds(dir, names) {
|
|
|
121
141
|
const fx = /^fix-(\d+)\.md$/.exec(f);
|
|
122
142
|
if (fx)
|
|
123
143
|
n = Math.max(n, Number(fx[1]));
|
|
124
|
-
else if (f.startsWith(".rounds-"))
|
|
144
|
+
else if (f.startsWith(".rounds-") && !f.endsWith(".log"))
|
|
125
145
|
n = Math.max(n, Number(read(join(dir, f))) || 0);
|
|
126
146
|
}
|
|
127
147
|
return n;
|
|
128
148
|
}
|
|
149
|
+
/** Follow-ups per reason (review|verify|unblock) from the .rounds-<session>.log files `orq agent -R` appends to. */
|
|
150
|
+
export function roundReasons(dir, names) {
|
|
151
|
+
const out = {};
|
|
152
|
+
for (const f of names) {
|
|
153
|
+
if (!/^\.rounds-.+\.log$/.test(f))
|
|
154
|
+
continue;
|
|
155
|
+
for (const line of read(join(dir, f)).split("\n")) {
|
|
156
|
+
const r = line.split(" ")[1];
|
|
157
|
+
if (r)
|
|
158
|
+
out[r] = (out[r] ?? 0) + 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
129
163
|
/** The impure part: read .orquestra/<issue>/. undefined when the issue has no fix or review files at all. */
|
|
130
164
|
export function collectRounds(ws, issue, max) {
|
|
131
165
|
const dir = join(ws, ".orquestra", issue);
|
|
@@ -160,7 +194,9 @@ export function collectRounds(ws, issue, max) {
|
|
|
160
194
|
return { round: k, secs: Number.isNaN(s) || Number.isNaN(next) ? 0 : Math.max(0, Math.round((next - s) / 1000)) };
|
|
161
195
|
});
|
|
162
196
|
const findings = [...reviews].flatMap(([k, fs]) => fs.flatMap((n) => parseFindings(read(join(dir, n)), k)));
|
|
163
|
-
|
|
197
|
+
const x = metrics(issue, { rounds, max, perRound, findings, secs: Math.round((t1 - t0) / 1000) });
|
|
198
|
+
const fu = roundReasons(dir, names);
|
|
199
|
+
return Object.keys(fu).length ? { ...x, followups: fu } : x;
|
|
164
200
|
}
|
|
165
201
|
/** Cheap change signature of an issue folder (names + mtimes, no reads) for caching collectRounds between redraws. */
|
|
166
202
|
export function roundsSig(ws, issue) {
|
package/dist/stats.js
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
// duration, tokens. "New" tokens = excluding cache reads (what actually weighs on quota); Codex without a session
|
|
4
4
|
// file only reports the total. Implementation delivery = the first review of the same repo in the issue said APPROVED.
|
|
5
5
|
// Limitation: follow-ups (-R) reuse the implementation prefix, so the "impl" row shows the last round of that prefix.
|
|
6
|
+
// Agent and model come from the job (<prefix>.job.json argv), then the pick (<prefix>.pick), then the log; a tool
|
|
7
|
+
// without models (a review CLI) has model "-". Time runs to the first turn on a screen run (<prefix>.turns): later
|
|
8
|
+
// turns wait on a person. Killed runs and older screen runs without .turns are counted but left out of the time median.
|
|
6
9
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
10
|
import { homedir } from "node:os";
|
|
8
11
|
import { basename, join } from "node:path";
|
|
12
|
+
import { argvModel, tokenize } from "./agents.js";
|
|
9
13
|
import { doneStatus } from "./process.js";
|
|
10
14
|
const ROLE = /^(impl|fix\d*|review\d*|[a-z]+-review\d*|watchdog\d*)-?/;
|
|
11
15
|
const read = (f) => { try {
|
|
@@ -14,14 +18,40 @@ const read = (f) => { try {
|
|
|
14
18
|
catch {
|
|
15
19
|
return "";
|
|
16
20
|
} };
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
const BINS = /^(codex|claude|opencode|agy|copilot)$/;
|
|
22
|
+
/** The model a log says was used: Claude's init event, Codex's banner, opencode's step events. */
|
|
23
|
+
function logModel(log) {
|
|
24
|
+
return /"type"\s*:\s*"system"[^\n]*?"model"\s*:\s*"([^"]+)"/.exec(log)?.[1] ?? /^model: (\S+)/m.exec(log)?.[1]
|
|
25
|
+
?? /"modelID"\s*:\s*"([^"]+)"/.exec(log)?.[1];
|
|
26
|
+
}
|
|
27
|
+
/** The argv of a generated .run.sh: its command, shell-tokenized (quotes span lines), without leading VAR=value words. */
|
|
28
|
+
function runArgv(run) {
|
|
29
|
+
const body = run.split("\n").filter((l) => !l.startsWith("#") && !l.startsWith("cd ")).join("\n");
|
|
30
|
+
const words = tokenize(body.replace(/\s<\s\S+\s*$/, "")).map((w) => w.text);
|
|
31
|
+
while (words.length && /^\w+=/.test(words[0]))
|
|
32
|
+
words.shift();
|
|
33
|
+
return words;
|
|
34
|
+
}
|
|
35
|
+
/** [agent, model] of a run. `job` is <prefix>.job.json (argv + agent), `pick` the `-a … -m …` line it was dispatched with
|
|
36
|
+
* (used only when it names the same agent). */
|
|
37
|
+
export function agentModel(run, log, job, pick = "") {
|
|
38
|
+
let argv = job?.argv ?? runArgv(run);
|
|
39
|
+
const base = (w) => w.split(/[\\/]/).pop();
|
|
40
|
+
// Older runs wrap the agent (systemd-run …, git worktree … && codex): start at the first agent CLI found.
|
|
41
|
+
const at = job ? 0 : argv.findIndex((w) => BINS.test(base(w)));
|
|
42
|
+
if (at > 0)
|
|
43
|
+
argv = argv.slice(at);
|
|
44
|
+
const bin = base(argv[0] ?? "");
|
|
45
|
+
let agent = job?.agent || bin || "?";
|
|
46
|
+
if (agent === "agy")
|
|
47
|
+
agent = "antigravity";
|
|
48
|
+
const picked = / -a (\S+)/.exec(` ${pick}`)?.[1] === agent ? / -m (\S+)/.exec(pick)?.[1] : undefined;
|
|
49
|
+
// opencode's screen takes the model in its config env, not -m.
|
|
50
|
+
const envModel = /"model":"([^"]+)"/.exec(job?.env?.OPENCODE_CONFIG_CONTENT ?? "")?.[1];
|
|
51
|
+
const model = argvModel(argv) ?? envModel ?? picked ?? logModel(log);
|
|
52
|
+
if (model)
|
|
53
|
+
return [agent, model.split("/").pop()];
|
|
54
|
+
return [agent, /^(codex|claude|opencode|agy)$/.test(bin) ? "default" : "-"];
|
|
25
55
|
}
|
|
26
56
|
function codexSession(sid) {
|
|
27
57
|
const root = join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "sessions");
|
|
@@ -74,8 +104,10 @@ export function tokens(agent, log, sid = "") {
|
|
|
74
104
|
}
|
|
75
105
|
else if (agent === "opencode" && e.type === "step_finish") {
|
|
76
106
|
const t = e.part?.tokens ?? {};
|
|
77
|
-
|
|
78
|
-
|
|
107
|
+
// Newer opencode has no total: input + output + reasoning + cache.
|
|
108
|
+
const all = t.total ?? (t.input ?? 0) + (t.output ?? 0) + (t.reasoning ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0);
|
|
109
|
+
neu += all - (t.cache?.read ?? 0);
|
|
110
|
+
tot += all;
|
|
79
111
|
}
|
|
80
112
|
else if (agent === "antigravity" && e.event === "result") {
|
|
81
113
|
const u = e.result?.usage ?? {};
|
|
@@ -112,7 +144,16 @@ export function collectRuns(ws, days) {
|
|
|
112
144
|
if (txt.includes("herdr-verify") || txt.includes("herdr-baseline"))
|
|
113
145
|
continue;
|
|
114
146
|
const log = read(`${pre}.log`);
|
|
115
|
-
|
|
147
|
+
let job;
|
|
148
|
+
try {
|
|
149
|
+
job = JSON.parse(read(`${pre}.job.json`));
|
|
150
|
+
}
|
|
151
|
+
catch { /* older run */ }
|
|
152
|
+
const [agent, model] = agentModel(txt, log, job, read(`${pre}.pick`));
|
|
153
|
+
const dt = read(done), turns = read(`${pre}.turns`).split("\n").filter(Boolean);
|
|
154
|
+
const first = turns.length ? Date.parse(turns[0]) : NaN;
|
|
155
|
+
const end = Number.isFinite(first) ? first : statSync(done).mtimeMs;
|
|
156
|
+
const timed = Number.isFinite(first) || (!/\bmode=tui\b/.test(dt) && doneStatus(dt) !== "killed");
|
|
116
157
|
const name = basename(pre);
|
|
117
158
|
const rm = ROLE.exec(name);
|
|
118
159
|
const role = rm && rm[1].includes("review") ? "review"
|
|
@@ -121,8 +162,8 @@ export function collectRuns(ws, days) {
|
|
|
121
162
|
: name.startsWith("impl") ? "impl" : "other";
|
|
122
163
|
const out = read(`${pre}.out.md`);
|
|
123
164
|
const [neu, tot] = tokens(agent, log, read(`${pre}.id`).trim());
|
|
124
|
-
runs.push({ issue, name, agent, model, role, ok: doneStatus(
|
|
125
|
-
secs: Math.max(0, Math.floor((
|
|
165
|
+
runs.push({ issue, name, agent, model, role, ok: doneStatus(dt) === "ok" && out.trim() !== "",
|
|
166
|
+
secs: Math.max(0, Math.floor((end - rs.mtimeMs) / 1000)), timed, new: neu, tot,
|
|
126
167
|
repo: name.includes("-") ? name.slice(name.indexOf("-") + 1) : "" });
|
|
127
168
|
}
|
|
128
169
|
}
|
|
@@ -153,7 +194,7 @@ export function rows(runs) {
|
|
|
153
194
|
const withTok = rs.filter((r) => r.tot), appr = rs.filter((r) => r.approved !== undefined).map((r) => r.approved);
|
|
154
195
|
return { role, agent, model, n: rs.length, ok: Math.round(100 * rs.filter((r) => r.ok).length / rs.length),
|
|
155
196
|
approved: appr.length ? Math.round(100 * appr.filter(Boolean).length / appr.length) : null, n_appr: appr.length,
|
|
156
|
-
min: Math.round(median(rs.map((r) => r.secs)) / 6) / 10,
|
|
197
|
+
min: rs.some((r) => r.timed) ? Math.round(median(rs.filter((r) => r.timed).map((r) => r.secs)) / 6) / 10 : null,
|
|
157
198
|
new: withTok.length ? Math.floor(median(withTok.map((r) => r.new))) : null,
|
|
158
199
|
tot: withTok.length ? Math.floor(median(withTok.map((r) => r.tot))) : null };
|
|
159
200
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@irythill/orq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Orchestrate coding agents (Claude Code, Codex, OpenCode, Antigravity, Copilot) from one conductor.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"templates/conductor.instructions.md"
|
|
16
16
|
],
|
|
17
17
|
"engines": {
|
|
18
|
-
"node": ">=
|
|
18
|
+
"node": ">=22"
|
|
19
19
|
},
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
@@ -39,11 +39,11 @@
|
|
|
39
39
|
"prepack": "tsc"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@napi-rs/keyring": "^2.1.
|
|
42
|
+
"@napi-rs/keyring": "^2.1.2",
|
|
43
43
|
"smol-toml": "^1.9.1"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"@types/node": "^
|
|
46
|
+
"@types/node": "^22.20.5",
|
|
47
47
|
"typescript": "^7.0.2"
|
|
48
48
|
}
|
|
49
49
|
}
|
package/skills/issue/SKILL.md
CHANGED
|
@@ -99,7 +99,8 @@ Always include:
|
|
|
99
99
|
choice doesn't come back as a blocker.
|
|
100
100
|
- **No tests in the child** (memory rule): "don't run jest/vitest; I run the related tests afterwards".
|
|
101
101
|
- **Fresh branch:** "before your final answer: `git fetch origin <BASE> && git rebase origin/<BASE>`; on conflict stop
|
|
102
|
-
and report; after the rebase repeat lint/tsc".
|
|
102
|
+
and report; after the rebase repeat lint/tsc". An agent without network (Codex) can't fetch: `orq agent` fetches before
|
|
103
|
+
the run and adds a note to its prompt, so it only rebases locally.
|
|
103
104
|
|
|
104
105
|
**Base = the repo's base branch from the workspace `CLAUDE.md`, always from the updated remote (`origin/<BASE>`).**
|
|
105
106
|
Worktree with `git worktree add` (not `herdr worktree create`: that opens a separate herdr workspace per worktree, and
|
|
@@ -128,7 +129,7 @@ orq agent $(cat $O/impl-<repo>.pick) -g <group> -t "<ID> impl <repo>" -d $WT -o
|
|
|
128
129
|
```
|
|
129
130
|
- The Claude account (`-c`) already comes from `orq pick` (one under the limit whose pace stays within it, then the least used).
|
|
130
131
|
- Full final answer in `$O/impl-<repo>.out.md`; log in `.log`; the agent's session id in `.id`.
|
|
131
|
-
-
|
|
132
|
+
- An agent without network (`network = false`, e.g. Codex): `orq agent` runs `git fetch origin` before a write run, so it can rebase locally. Push/PR: yours (step 7).
|
|
132
133
|
- `orq agent` returns immediately (no need to background it); the child's pane is in `<prefix>.pane`
|
|
133
134
|
(to peek: `<prefix>.log`; in herdr, `herdr pane read $(cat <prefix>.pane) --source recent`).
|
|
134
135
|
- **No baseline in the issue flow:** don't run `orq baseline` before dispatching (the base moves with every merge and
|
|
@@ -157,7 +158,10 @@ Child with `status=ok` → run the tests related to the diff, in their own tab,
|
|
|
157
158
|
`orq verify -w $WS -r <repo> -d $WT -b <BASE> -o $O/verify-<repo> -g <group>` → `$O/verify-<repo>.txt` with
|
|
158
159
|
**new failures** and pre-existing ones: if any test fails, it runs the same test files on an updated
|
|
159
160
|
`origin/<BASE>` (whatever fails there is pre-existing). You get the done line (`[HERDR-AGENT DONE]` / `[ORQ AGENT DONE]`) `verify ... new=N`.
|
|
160
|
-
`new>0` →
|
|
161
|
+
`new>0` → follow-up with the failing tests, no review: write `$O/verify-fix-<n>.md` (not `fix-<n>.md`) and add
|
|
162
|
+
`--reason verify` to the `-R` call of step 6. `new=0` → review. A follow-up that only unblocks the child (files it
|
|
163
|
+
couldn't fetch without network, a missing tool) uses `--reason unblock`. Only review rounds (the default `--reason
|
|
164
|
+
review`) count towards `policy.max_rounds`.
|
|
161
165
|
**Pre-existing failures get fixed too.** Any failure — tests listed as "already failing on origin/<BASE>", or a CI job
|
|
162
166
|
(lint, biome, typecheck, shard, regression) failing on the PR or on the base — goes into a fix round on the issue's
|
|
163
167
|
branch (separate `fix:`/`test:` commit naming the issue). Never close, move to In Review or call a PR done while
|
|
@@ -223,12 +227,19 @@ Then a new `orq verify` (4b) and the **round review** (step 5 with these changes
|
|
|
223
227
|
- Same reviewer as the round before: `$(cat $O/review-<repo>.pick)`. Only if it is over quota, blocked or fails with
|
|
224
228
|
no verdict → a new `orq pick review` (saved to the same file).
|
|
225
229
|
- Extra attachments: `git -C $WT diff $(cat $O/round-<n>.sha)..HEAD > $O/fix-<n>.diff` → `-i $O/fix-<n>.md -i $O/fix-<n>.diff`
|
|
226
|
-
(the full `-b $BASE` diff stays, as context).
|
|
230
|
+
(the full `-b $BASE` diff stays, as context). **A review tool** gets `-b $(cat $O/round-<n>.sha)` instead of `-b $BASE`:
|
|
231
|
+
`{base}` becomes that commit, so it reviews only the round's delta, not the whole branch again. Pick the round reviewer with
|
|
232
|
+
`orq pick review --base $(cat $O/round-<n>.sha) ...`: a tool with `base_commit = false` is skipped. Dispatched anyway, `orq agent`
|
|
233
|
+
falls back to the branch base (`--base-branch $BASE`) and its answer says the review covers the whole branch.
|
|
227
234
|
- The prompt asks a closed question: "for each item in fix-<n>.md: RESOLVED or NOT RESOLVED (why); then the fix diff:
|
|
228
235
|
any regression it introduced, as `[blocker]`/`[suggestion]`. Outside the fix diff, report only a blocker you missed
|
|
229
|
-
before that would break production, and say why it wasn't in the first review". Anything else is a suggestion.
|
|
230
|
-
|
|
231
|
-
NOT RESOLVED item of `fix-<n>.md`; one you already decided on (fixed, rejected or downgraded) stays decided.
|
|
236
|
+
before that would break production, and say why it wasn't in the first review". Anything else is a suggestion. If a review
|
|
237
|
+
still names something outside `fix-<n>.diff`, you do the matching — a finding is a blocker this round only if it is inside
|
|
238
|
+
`fix-<n>.diff` or is a NOT RESOLVED item of `fix-<n>.md`; one you already decided on (fixed, rejected or downgraded) stays decided.
|
|
239
|
+
**A finding outside the fix diff never opens a round by itself**, however serious: it goes to a "deferred" list in the
|
|
240
|
+
Plane comment (a follow-up issue). If it is a security or personal-data leak, stop and ask the user (fix in this branch
|
|
241
|
+
or follow-up) instead of dispatching. `orq rounds <ID>` warns `fresh` when two fix rounds brought new heavy findings:
|
|
242
|
+
the branch is too big — split, don't keep reviewing it whole. **Maximum: `orq config get policy.max_rounds` rounds** — `orq agent -R` refuses the next one (exit 4) and counts per session, so a renamed prefix doesn't reset it; on exit 4 stop and tell the user. With their OK run `orq rounds <ID> --diagnose` (a cheap independent agent, advisory: read its `RECOMMENDATION` — continue / split / change-implementer / ask-user — and tell the user), then `-F` (refused without a diagnosis newer than the last fix/review; `-F -F` skips that, only if the user says so); over it → `orq pick impl
|
|
232
243
|
--exclude-agent <it> > $O/impl-<repo>.pick` only with the user's OK, or stop and report. Non-blocking suggestions go into the comment; they
|
|
233
244
|
don't become a round.
|
|
234
245
|
|