@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/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/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.7.0",
|
|
4
4
|
"description": "Orchestrate coding agents (Claude Code, Codex, OpenCode, Antigravity, Copilot) from one conductor.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -11,10 +11,11 @@
|
|
|
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
|
-
"node": ">=
|
|
18
|
+
"node": ">=22"
|
|
18
19
|
},
|
|
19
20
|
"repository": {
|
|
20
21
|
"type": "git",
|
|
@@ -38,11 +39,11 @@
|
|
|
38
39
|
"prepack": "tsc"
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
41
|
-
"@napi-rs/keyring": "^2.1.
|
|
42
|
+
"@napi-rs/keyring": "^2.1.2",
|
|
42
43
|
"smol-toml": "^1.9.1"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
45
|
-
"@types/node": "^
|
|
46
|
+
"@types/node": "^22.20.5",
|
|
46
47
|
"typescript": "^7.0.2"
|
|
47
48
|
}
|
|
48
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
|
|
|
@@ -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.
|