@irythill/orq 0.2.0 → 0.4.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 +49 -12
- package/README.pt-BR.md +41 -12
- package/dist/agents.js +12 -0
- package/dist/ci.js +239 -0
- package/dist/cli.js +7 -1
- package/dist/commands/agent.js +270 -29
- package/dist/commands/block.js +20 -3
- package/dist/commands/board.js +87 -8
- package/dist/commands/ci.js +48 -0
- package/dist/commands/claim.js +148 -12
- package/dist/commands/completion.js +3 -2
- package/dist/commands/gc.js +1 -1
- package/dist/commands/handoff.js +259 -0
- package/dist/commands/pick.js +18 -3
- package/dist/commands/quota.js +34 -9
- package/dist/commands/rounds.js +58 -0
- package/dist/config.js +4 -1
- package/dist/identity.js +55 -0
- package/dist/pick.js +106 -9
- package/dist/process.js +19 -11
- package/dist/quota.js +83 -21
- package/dist/rounds.js +167 -0
- package/dist/terminal.js +26 -0
- package/dist/testrun.js +26 -5
- package/package.json +1 -1
- package/skills/issue/SKILL.md +47 -10
- package/skills/orq-setup/SKILL.md +1 -1
- package/templates/workspace.CLAUDE.md +7 -0
package/dist/quota.js
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
// false left out of the list entirely
|
|
11
11
|
// "none" no collector: never skipped for quota; use `orq block` when it runs out
|
|
12
12
|
// { command = "..." } any command printing lines "<name> 5h=NN% week=NN% [note]"
|
|
13
|
-
// Output line (read by orq-pick and the board): "<name> 5h=NN% week=NN% [note]".
|
|
13
|
+
// Output line (read by orq-pick and the board): "<name> 5h=NN% week=NN% [reset5h=epoch] [resetweek=epoch] [note]".
|
|
14
14
|
// - = the plan has no such window (not limiting); ? = no data (expired login, collector failed).
|
|
15
|
+
// reset5h/resetweek (epoch seconds, optional) sit between the windows and the note: whichever window's reset time
|
|
16
|
+
// the collector could read, when it's known.
|
|
15
17
|
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
16
19
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
17
20
|
import { homedir, userInfo } from "node:os";
|
|
18
21
|
import { join } from "node:path";
|
|
@@ -22,21 +25,45 @@ import { readSecret } from "./keyring.js";
|
|
|
22
25
|
import { stateDir } from "./paths.js";
|
|
23
26
|
import { m } from "./i18n.js";
|
|
24
27
|
const show = (p) => (typeof p === "number" ? `${Math.floor(p)}%` : p);
|
|
25
|
-
|
|
28
|
+
// reset5h/resetweek (epoch seconds) sit between the windows and the note: parseQuota/parseWindows only look at the
|
|
29
|
+
// first three words, so old cached lines (no reset fields) and lines with them both parse the same way.
|
|
30
|
+
export const format = (q) => `${q.name} 5h=${show(q.five)} week=${show(q.week)}`
|
|
31
|
+
+ (q.reset5h !== undefined ? ` reset5h=${q.reset5h}` : "") + (q.resetWeek !== undefined ? ` resetweek=${q.resetWeek}` : "")
|
|
32
|
+
+ (q.span5h !== undefined ? ` span5h=${q.span5h}` : "") + (q.spanWeek !== undefined ? ` spanweek=${q.spanWeek}` : "")
|
|
33
|
+
+ (q.note ? ` [${q.note}]` : "");
|
|
26
34
|
const noData = (name, note) => ({ name, five: "?", week: "?", note });
|
|
35
|
+
const WD = [["Sun", "dom"], ["Mon", "seg"], ["Tue", "ter"], ["Wed", "qua"], ["Thu", "qui"], ["Fri", "sex"], ["Sat", "sáb"]];
|
|
36
|
+
/** A reset epoch (seconds) as "14:30" when it falls today, else weekday + time. */
|
|
37
|
+
export function humanReset(epochSec, now = Date.now()) {
|
|
38
|
+
const d = new Date(epochSec * 1000), n = new Date(now);
|
|
39
|
+
const hhmm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
40
|
+
if (d.toDateString() === n.toDateString())
|
|
41
|
+
return hhmm;
|
|
42
|
+
const [en, pt] = WD[d.getDay()];
|
|
43
|
+
return `${m(en, pt)} ${hhmm}`;
|
|
44
|
+
}
|
|
27
45
|
const KEEP_MS = 5 * 3600_000;
|
|
28
46
|
const cacheFile = (name) => join(stateDir(), "quota", `${name}.json`);
|
|
29
|
-
|
|
47
|
+
const resetOf = (w) => {
|
|
48
|
+
const t = w.resetsAt ? Date.parse(w.resetsAt) : NaN;
|
|
49
|
+
return Number.isNaN(t) ? undefined : Math.floor(t / 1000);
|
|
50
|
+
};
|
|
51
|
+
// `acct` identifies the login behind a reading (antigravity: one login per machine, swapped by the user): a cache
|
|
52
|
+
// written under another login must never stand in for this one.
|
|
53
|
+
function remember(name, five, week, acct) {
|
|
30
54
|
try {
|
|
31
|
-
writeAtomic(cacheFile(name), JSON.stringify({ five, week, at: Date.now() }));
|
|
55
|
+
writeAtomic(cacheFile(name), JSON.stringify({ five, week, at: Date.now(), ...(acct ? { acct } : {}) }));
|
|
32
56
|
}
|
|
33
57
|
catch { /* best effort */ }
|
|
34
|
-
|
|
58
|
+
const r5 = resetOf(five), rw = resetOf(week);
|
|
59
|
+
return { name, five: five.pct, week: week.pct, ...(r5 !== undefined ? { reset5h: r5 } : {}), ...(rw !== undefined ? { resetWeek: rw } : {}) };
|
|
35
60
|
}
|
|
36
61
|
/** The cached reading (its age in the note), window by window; nothing usable → the "no data" line. */
|
|
37
|
-
export function fromCache(name, why, now = Date.now()) {
|
|
62
|
+
export function fromCache(name, why, now = Date.now(), acct) {
|
|
38
63
|
try {
|
|
39
64
|
const c = JSON.parse(readFileSync(cacheFile(name), "utf8"));
|
|
65
|
+
if (acct !== undefined && c.acct !== acct)
|
|
66
|
+
return noData(name, why); // another login's reading
|
|
40
67
|
const still = (w) => {
|
|
41
68
|
if (w.resetsAt) {
|
|
42
69
|
const t = Date.parse(w.resetsAt);
|
|
@@ -48,7 +75,10 @@ export function fromCache(name, why, now = Date.now()) {
|
|
|
48
75
|
const five = still(c.five), week = still(c.week);
|
|
49
76
|
if (five !== undefined && week !== undefined) {
|
|
50
77
|
const min = Math.round((now - c.at) / 60_000);
|
|
51
|
-
|
|
78
|
+
const r5 = resetOf(c.five), rw = resetOf(c.week);
|
|
79
|
+
return { name, five, week, ...(r5 !== undefined && r5 * 1000 > now ? { reset5h: r5 } : {}),
|
|
80
|
+
...(rw !== undefined && rw * 1000 > now ? { resetWeek: rw } : {}),
|
|
81
|
+
note: `${why}; ` + m(`last reading, ${min} min ago`, `última leitura, há ${min} min`) };
|
|
52
82
|
}
|
|
53
83
|
}
|
|
54
84
|
catch { /* no cache */ }
|
|
@@ -143,10 +173,14 @@ export function codexFromSession(text, nowSec) {
|
|
|
143
173
|
return undefined;
|
|
144
174
|
const ws = [last.primary, last.secondary].filter((w) => !!w);
|
|
145
175
|
const pct = (w) => !w ? "-" : (w.resets_at ?? 0) < nowSec ? 0 : Math.floor(w.used_percent ?? 0);
|
|
176
|
+
const reset = (w) => w?.resets_at !== undefined && w.resets_at >= nowSec ? w.resets_at : undefined;
|
|
146
177
|
const short = ws.find((x) => (x.window_minutes ?? 0) <= 360);
|
|
147
178
|
const long = ws.filter((x) => (x.window_minutes ?? 0) > 360).sort((a, b) => b.window_minutes - a.window_minutes)[0];
|
|
148
179
|
const span = long && long.window_minutes !== 10080 ? long.window_minutes : undefined;
|
|
149
|
-
|
|
180
|
+
const r5 = reset(short), rw = reset(long);
|
|
181
|
+
const span5 = short && short.window_minutes && short.window_minutes !== 300 ? short.window_minutes : undefined;
|
|
182
|
+
return { five: pct(short), week: pct(long), ...(span ? { span } : {}), ...(span5 ? { span5 } : {}), ...(r5 !== undefined ? { reset5h: r5 } : {}),
|
|
183
|
+
...(rw !== undefined ? { resetWeek: rw } : {}) };
|
|
150
184
|
}
|
|
151
185
|
export function codexQuota() {
|
|
152
186
|
const f = latestCodexSession();
|
|
@@ -156,9 +190,9 @@ export function codexQuota() {
|
|
|
156
190
|
if (!q)
|
|
157
191
|
return noData("codex", m("no limit data", "sem dados de limite"));
|
|
158
192
|
const age = Math.round((Date.now() - statSync(f).mtimeMs) / 60_000);
|
|
159
|
-
const { span, ...pcts } = q;
|
|
193
|
+
const { span, span5, ...pcts } = q;
|
|
160
194
|
const d = span && Math.round(span / 1440);
|
|
161
|
-
return { name: "codex", ...pcts, note: (d ? m(`${d}-day window in the week slot; `, `janela de ${d} dias no lugar da semana; `) : "")
|
|
195
|
+
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; `) : "")
|
|
162
196
|
+ m(`from the last session, ${age} min ago`, `da última sessão, há ${age} min`) };
|
|
163
197
|
}
|
|
164
198
|
/** Worst usage per window across models: a reset within 6 h belongs to the 5h window, a later one to the week. */
|
|
@@ -179,7 +213,7 @@ export function antigravityFromModels(models, now = Date.now()) {
|
|
|
179
213
|
}
|
|
180
214
|
return { five, week };
|
|
181
215
|
}
|
|
182
|
-
function agyToken() {
|
|
216
|
+
export function agyToken() {
|
|
183
217
|
// agy (go-keyring) keeps {"token": {access_token, expiry, ...}} under service "gemini", user "antigravity"
|
|
184
218
|
// (on Windows go-keyring names the credential "gemini:antigravity").
|
|
185
219
|
const raw = readSecret("gemini", "antigravity", "gemini:antigravity");
|
|
@@ -187,21 +221,34 @@ function agyToken() {
|
|
|
187
221
|
return undefined;
|
|
188
222
|
try {
|
|
189
223
|
const t = JSON.parse(raw).token;
|
|
190
|
-
|
|
224
|
+
// The refresh token is stable for a login and differs between accounts (the access token rotates hourly).
|
|
225
|
+
const id = t?.refresh_token ?? t?.id_token;
|
|
226
|
+
return { access: t?.access_token, expiry: t?.expiry, refresh: !!t?.refresh_token,
|
|
227
|
+
...(id ? { acct: createHash("sha256").update(String(id)).digest("hex").slice(0, 16) } : {}) };
|
|
191
228
|
}
|
|
192
229
|
catch {
|
|
193
230
|
return undefined;
|
|
194
231
|
}
|
|
195
232
|
}
|
|
233
|
+
/** Why the stored agy token can't be used as is: idle (expired, agy renews it on its next run) or login expired (no refresh token). */
|
|
234
|
+
export function agyNote(t, now = Date.now()) {
|
|
235
|
+
const at = t.expiry ? Date.parse(t.expiry) : NaN;
|
|
236
|
+
if (Number.isNaN(at) || at >= now)
|
|
237
|
+
return undefined;
|
|
238
|
+
return t.refresh ? m("token idle (agy refreshes on next run)", "token ocioso (o agy renova no próximo uso)")
|
|
239
|
+
: m("login expired: run `agy models`", "login expirado: rode `agy models`");
|
|
240
|
+
}
|
|
196
241
|
export async function antigravityQuota() {
|
|
197
242
|
const name = "antigravity";
|
|
198
243
|
const t = agyToken();
|
|
199
244
|
if (!t?.access)
|
|
200
245
|
return noData(name, m("no agy login in the keyring", "sem login do agy no chaveiro"));
|
|
201
|
-
// Never refresh it here (that is agy's login)
|
|
246
|
+
// 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
|
+
// passed expiry with a refresh token is just idle (a note, not an expired login); without one, "run agy once".
|
|
202
248
|
const expired = m("login expired: run `agy models`", "login expirado: rode `agy models`");
|
|
203
|
-
|
|
204
|
-
|
|
249
|
+
const note = agyNote(t);
|
|
250
|
+
if (note)
|
|
251
|
+
return fromCache(name, note, undefined, t.acct);
|
|
205
252
|
try {
|
|
206
253
|
const r = await fetch("https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", {
|
|
207
254
|
method: "POST", body: "{}",
|
|
@@ -210,30 +257,45 @@ export async function antigravityQuota() {
|
|
|
210
257
|
signal: AbortSignal.timeout(15_000),
|
|
211
258
|
});
|
|
212
259
|
if (r.status === 401)
|
|
213
|
-
return fromCache(name, expired);
|
|
260
|
+
return fromCache(name, expired, undefined, t.acct);
|
|
214
261
|
if (!r.ok)
|
|
215
|
-
return fromCache(name, `HTTP ${r.status}
|
|
262
|
+
return fromCache(name, `HTTP ${r.status}`, undefined, t.acct);
|
|
216
263
|
const j = await r.json();
|
|
217
264
|
const { five, week } = antigravityFromModels(j.models ?? {});
|
|
218
|
-
return remember(name, five, week);
|
|
265
|
+
return remember(name, five, week, t.acct);
|
|
219
266
|
}
|
|
220
267
|
catch (e) {
|
|
221
|
-
return fromCache(name, m("collector failed", "coletor falhou") + `: ${e.message}
|
|
268
|
+
return fromCache(name, m("collector failed", "coletor falhou") + `: ${e.message}`, undefined, t.acct);
|
|
222
269
|
}
|
|
223
270
|
}
|
|
224
271
|
// --- copilot --------------------------------------------------------------------------------------------------------
|
|
272
|
+
/** The same moment one calendar month earlier; a day the month lacks (31 Mar → Feb) clamps to its last day. */
|
|
273
|
+
export function monthBefore(d) {
|
|
274
|
+
const out = new Date(d);
|
|
275
|
+
out.setDate(1);
|
|
276
|
+
out.setMonth(out.getMonth() - 1);
|
|
277
|
+
out.setDate(Math.min(d.getDate(), new Date(out.getFullYear(), out.getMonth() + 1, 0).getDate()));
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
225
280
|
/** Premium requests used this month, from the snapshot `gh api /copilot_internal/user` returns. No 5h window. */
|
|
226
281
|
export function copilotFromUser(j) {
|
|
227
282
|
const u = j;
|
|
228
283
|
const p = u?.quota_snapshots?.premium_interactions;
|
|
229
284
|
if (!p)
|
|
230
285
|
return noData("copilot", m("no premium request data", "sem dados de requisições premium"));
|
|
286
|
+
// A bare date ("2026-11-01") would parse as UTC midnight: local midnight matches the date the note itself shows
|
|
287
|
+
// (same fix as parseLocal in commands/block.ts).
|
|
288
|
+
const at = u.quota_reset_date
|
|
289
|
+
? Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(u.quota_reset_date) ? `${u.quota_reset_date}T00:00` : u.quota_reset_date)
|
|
290
|
+
: NaN;
|
|
291
|
+
const prev = Number.isNaN(at) ? new Date(NaN) : monthBefore(new Date(at));
|
|
292
|
+
const resetWeek = Number.isNaN(at) ? {} : { resetWeek: Math.floor(at / 1000), spanWeek: Math.round((at - prev.getTime()) / 60_000) };
|
|
231
293
|
if (p.unlimited)
|
|
232
294
|
return { name: "copilot", five: "-", week: "-", note: m("unlimited", "ilimitado") };
|
|
233
295
|
if (typeof p.percent_remaining !== "number")
|
|
234
296
|
return noData("copilot", m("no premium request data", "sem dados de requisições premium"));
|
|
235
297
|
const reset = u.quota_reset_date ? m(`, resets ${u.quota_reset_date}`, `, renova em ${u.quota_reset_date}`) : "";
|
|
236
|
-
return { name: "copilot", five: "-", week: Math.max(0, 100 - p.percent_remaining),
|
|
298
|
+
return { name: "copilot", five: "-", week: Math.max(0, 100 - p.percent_remaining), ...resetWeek,
|
|
237
299
|
note: m("monthly premium requests", "requisições premium do mês") + reset };
|
|
238
300
|
}
|
|
239
301
|
export function copilotQuota() {
|
|
@@ -266,7 +328,7 @@ export function minimaxFromRemains(j) {
|
|
|
266
328
|
return { five: worst("current_interval_remaining_percent", "end_time"),
|
|
267
329
|
week: worst("current_weekly_remaining_percent", "weekly_end_time") };
|
|
268
330
|
}
|
|
269
|
-
function minimaxKey() {
|
|
331
|
+
export function minimaxKey() {
|
|
270
332
|
if (process.env.MINIMAX_API_KEY)
|
|
271
333
|
return process.env.MINIMAX_API_KEY;
|
|
272
334
|
const data = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
|
package/dist/rounds.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Watchdog layer 1: per-issue round metrics from the files already in .orquestra/<ID>/, no LLM and no extra recording.
|
|
2
|
+
// fix-<n>.md the blockers sent to the implementer in fix round n (its mtime = the round started)
|
|
3
|
+
// review-<repo>.out.md the first review (round 0); review-<repo>-<n>.out.md the review after fix round n
|
|
4
|
+
// Rounds = highest fix-<n> (or the .rounds-<session> counter `orq agent -R` keeps). Findings are the tagged list items
|
|
5
|
+
// of each review ("- [blocker] src/a.ts:10 title", or a leading bold severity word in PT/EN: "1. **Bloqueante** — ...",
|
|
6
|
+
// "- **Sugestão — `a.ts:7–8`:** ..."; "**Critério 1**" is not one). Files may be markdown links; a ".worktrees/<dir>/" prefix is
|
|
7
|
+
// stripped so a file matches across rounds. A finding "repeats" when a later review has one on the same file
|
|
8
|
+
// with similar words (token overlap, see similar()).
|
|
9
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
export const SIMILAR_FILE = 0.4; // token overlap needed when both findings name the same file
|
|
12
|
+
export const SIMILAR_LOOSE = 0.6; // ... when a file is missing on either side
|
|
13
|
+
export const SLOW_FACTOR = 2; // a round this many times slower than the median of the others is flagged
|
|
14
|
+
export const SLOW_MIN_SECS = 600; // ... but only if it also took at least this long
|
|
15
|
+
export const REPEAT_WARN = 2; // a finding seen in this many reviews: warn
|
|
16
|
+
export const REPEAT_STOP = 3; // ... in this many: stop (it came back after two fix rounds)
|
|
17
|
+
export const HEAVY = new Set(["blocker", "major", "critical"]); // only these count towards repeats and the alert
|
|
18
|
+
const read = (f) => { try {
|
|
19
|
+
return readFileSync(f, "utf8");
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return "";
|
|
23
|
+
} };
|
|
24
|
+
const STOP = new Set(["the", "a", "an", "of", "in", "on", "to", "is", "are", "and", "or", "for", "with", "not", "no", "this", "that", "it",
|
|
25
|
+
"de", "do", "da", "o", "os", "as", "um", "uma", "em", "no", "na", "para", "com", "que", "se", "não"]);
|
|
26
|
+
const words = (s) => new Set(s.toLowerCase().replace(/[^\p{L}\p{N}_]+/gu, " ").split(" ").filter((w) => w.length > 2 && !STOP.has(w)));
|
|
27
|
+
const SEV = {
|
|
28
|
+
bloqueante: "blocker", bloqueador: "blocker", blocker: "blocker", critico: "critical", critical: "critical",
|
|
29
|
+
importante: "major", important: "major", major: "major", menor: "minor", minor: "minor",
|
|
30
|
+
sugestao: "suggestion", suggestion: "suggestion", nit: "nit",
|
|
31
|
+
};
|
|
32
|
+
const fold = (w) => w.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
|
|
33
|
+
const URL = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
34
|
+
// GitHub-style ".../blob/<ref>/<path>" → "<path>" (no query, no #L4), so URL mentions match plain-path mentions.
|
|
35
|
+
const blobPath = (u) => /^[a-z][a-z0-9+.-]*:\/\/[^/]+\/[^/]+\/[^/]+\/blob\/[^/]+\/([^#?\s)]+)/i.exec(u)?.[1];
|
|
36
|
+
const LINK = /\[([^\]]*)\]\(([^)\s]*)\)/g;
|
|
37
|
+
// Reviews run in a different worktree per round (".worktrees/<repo>-<id>.review-<pid>/"): drop it so the same file matches across rounds.
|
|
38
|
+
const repoRel = (f) => f.replace(/\\/g, "/").replace(/^.*\.worktrees\/[^/]+\//, "");
|
|
39
|
+
/** One review-list item → finding. Only items carrying a severity (a "[blocker]" tag or a leading bold "**Bloqueante**") count. */
|
|
40
|
+
export function parseFindings(text, round) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const line of text.split("\n")) {
|
|
43
|
+
const it = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
|
44
|
+
if (!it)
|
|
45
|
+
continue;
|
|
46
|
+
const sevOf = (w) => { const k = fold(w ?? ""); return Object.hasOwn(SEV, k) ? SEV[k] : undefined; };
|
|
47
|
+
let m0 = /^\*\*\s*(\p{L}+)/u.exec(it[1]); // a leading bold severity wins over a [tag]
|
|
48
|
+
if (!sevOf(m0?.[1]))
|
|
49
|
+
m0 = /\[(blocker|major|minor|nit|suggestion|important|critical)\]/i.exec(it[1]);
|
|
50
|
+
const sev = sevOf(m0?.[1]);
|
|
51
|
+
if (!m0 || !sev)
|
|
52
|
+
continue;
|
|
53
|
+
const rest = it[1].replace(m0[0], " ");
|
|
54
|
+
const byTarget = rest.replace(LINK, (_, t, p) => ` ${URL.test(p) ? blobPath(p) ?? t : repoRel(p)} `)
|
|
55
|
+
.replace(/[a-z][a-z0-9+.-]*:\/\/\S+/gi, (u) => ` ${blobPath(u) ?? ""} `);
|
|
56
|
+
const m = /([\w@./\\-]+\.\w+)(?::\d+(?:[-\u2013]\d+)?)?/.exec(byTarget);
|
|
57
|
+
const file = m ? repoRel(m[1]) : "";
|
|
58
|
+
out.push({ round, severity: sev, file, title: rest.replace(LINK, "$1").replace(/[`*_]/g, "").replace(/\s+/g, " ").trim() });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** Same finding? Same file (when both name one) and enough shared words; without a file, a stricter overlap. */
|
|
63
|
+
export function similar(a, b) {
|
|
64
|
+
const wa = words(a.title), wb = words(b.title);
|
|
65
|
+
if (!wa.size || !wb.size)
|
|
66
|
+
return false;
|
|
67
|
+
let inter = 0;
|
|
68
|
+
for (const w of wa)
|
|
69
|
+
if (wb.has(w))
|
|
70
|
+
inter++;
|
|
71
|
+
const j = inter / (wa.size + wb.size - inter), both = a.file && b.file;
|
|
72
|
+
if (both && a.file !== b.file)
|
|
73
|
+
return false;
|
|
74
|
+
return j >= (both ? SIMILAR_FILE : SIMILAR_LOOSE);
|
|
75
|
+
}
|
|
76
|
+
/** Findings that reappear in more than one round. Each group is matched against its first member. */
|
|
77
|
+
export function repeats(fs) {
|
|
78
|
+
const groups = [];
|
|
79
|
+
for (const f of [...fs].sort((a, b) => a.round - b.round)) {
|
|
80
|
+
const g = groups.find((x) => similar(x[0], f));
|
|
81
|
+
if (g)
|
|
82
|
+
g.push(f);
|
|
83
|
+
else
|
|
84
|
+
groups.push([f]);
|
|
85
|
+
}
|
|
86
|
+
return groups.map((g) => ({ file: g[0].file, title: g[0].title, rounds: [...new Set(g.map((x) => x.round))] }))
|
|
87
|
+
.filter((r) => r.rounds.length >= REPEAT_WARN);
|
|
88
|
+
}
|
|
89
|
+
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
|
+
/** Fix rounds far above the median of the other fix rounds (round 0 is the implementation: never compared; needs 3 fix rounds). */
|
|
91
|
+
export function slowRounds(all) {
|
|
92
|
+
const per = all.filter((r) => r.round > 0);
|
|
93
|
+
if (per.length < 3)
|
|
94
|
+
return [];
|
|
95
|
+
return per.filter((r) => r.secs >= SLOW_MIN_SECS && r.secs > SLOW_FACTOR * median(per.filter((x) => x !== r).map((x) => x.secs))).map((r) => r.round);
|
|
96
|
+
}
|
|
97
|
+
/** The pure part: facts from the files → numbers, alert level and why. */
|
|
98
|
+
export function metrics(issue, f) {
|
|
99
|
+
const reps = repeats(f.findings.filter((x) => HEAVY.has(x.severity))), reasons = [];
|
|
100
|
+
let level = "ok";
|
|
101
|
+
const raise = (l, why) => { reasons.push(why); if (l === "stop" || level === "ok")
|
|
102
|
+
level = l; };
|
|
103
|
+
if (f.rounds > f.max)
|
|
104
|
+
raise("stop", { code: "over_max", n: f.rounds, max: f.max });
|
|
105
|
+
else if (f.rounds >= f.max && f.max > 0)
|
|
106
|
+
raise("warn", { code: "at_max", n: f.rounds, max: f.max });
|
|
107
|
+
const worst = Math.max(0, ...reps.map((r) => r.rounds.length));
|
|
108
|
+
if (worst >= REPEAT_STOP)
|
|
109
|
+
raise("stop", { code: "same_finding", n: worst });
|
|
110
|
+
else if (reps.length)
|
|
111
|
+
raise("warn", { code: "repeated", n: reps.length });
|
|
112
|
+
const slow = slowRounds(f.perRound);
|
|
113
|
+
if (slow.length)
|
|
114
|
+
raise("warn", { code: "slow", n: slow.length, rounds: slow });
|
|
115
|
+
return { issue, rounds: f.rounds, max: f.max, secs: f.secs, perRound: f.perRound, findings: f.findings.length, repeats: reps, level, reasons };
|
|
116
|
+
}
|
|
117
|
+
/** The impure part: read .orquestra/<issue>/. undefined when the issue has no fix or review files at all. */
|
|
118
|
+
export function collectRounds(ws, issue, max) {
|
|
119
|
+
const dir = join(ws, ".orquestra", issue);
|
|
120
|
+
let names;
|
|
121
|
+
try {
|
|
122
|
+
names = readdirSync(dir);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const mt = (n) => statSync(join(dir, n)).mtimeMs;
|
|
128
|
+
const fixes = new Map(), reviews = new Map();
|
|
129
|
+
let counter = 0;
|
|
130
|
+
for (const n of names) {
|
|
131
|
+
const fx = /^fix-(\d+)\.md$/.exec(n);
|
|
132
|
+
if (fx)
|
|
133
|
+
fixes.set(Number(fx[1]), mt(n));
|
|
134
|
+
}
|
|
135
|
+
for (const n of names) {
|
|
136
|
+
// A trailing -<n> is a round only when fix-<n>.md exists; otherwise it is part of the repo name (round 0).
|
|
137
|
+
const rv = /^review-(.+?)(?:-(\d+))?\.out\.md$/.exec(n), k = rv && rv[2] !== undefined && fixes.has(Number(rv[2])) ? Number(rv[2]) : 0;
|
|
138
|
+
if (rv)
|
|
139
|
+
reviews.set(k, [...(reviews.get(k) ?? []), n]);
|
|
140
|
+
else if (n.startsWith(".rounds-"))
|
|
141
|
+
counter = Math.max(counter, Number(read(join(dir, n))) || 0);
|
|
142
|
+
}
|
|
143
|
+
if (!reviews.size && !fixes.size)
|
|
144
|
+
return undefined;
|
|
145
|
+
const rounds = Math.max(counter, 0, ...fixes.keys());
|
|
146
|
+
const all = names.filter((n) => !n.startsWith(".")).map(mt), t0 = Math.min(...all), t1 = Math.max(...all);
|
|
147
|
+
// Round k runs from fix-k.md (round 0: the oldest file) to the next fix, the last one to the newest file.
|
|
148
|
+
const starts = [t0, ...Array.from({ length: rounds }, (_, i) => fixes.get(i + 1) ?? NaN)];
|
|
149
|
+
const perRound = starts.map((s, k) => {
|
|
150
|
+
const next = k < rounds ? starts[k + 1] : t1;
|
|
151
|
+
return { round: k, secs: Number.isNaN(s) || Number.isNaN(next) ? 0 : Math.max(0, Math.round((next - s) / 1000)) };
|
|
152
|
+
});
|
|
153
|
+
const findings = [...reviews].flatMap(([k, fs]) => fs.flatMap((n) => parseFindings(read(join(dir, n)), k)));
|
|
154
|
+
return metrics(issue, { rounds, max, perRound, findings, secs: Math.round((t1 - t0) / 1000) });
|
|
155
|
+
}
|
|
156
|
+
/** Cheap change signature of an issue folder (names + mtimes, no reads) for caching collectRounds between redraws. */
|
|
157
|
+
export function roundsSig(ws, issue) {
|
|
158
|
+
const dir = join(ws, ".orquestra", issue);
|
|
159
|
+
try {
|
|
160
|
+
return readdirSync(dir).filter((n) => /^(fix-\d+\.md|review-.+\.out\.md|\.rounds-.+)$/.test(n)).map((n) => `${n}:${statSync(join(dir, n)).mtimeMs}`).join("|");
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Board marker: "r2/2 ⚠", "r3/2 ✗"; just "r1/2" when ok. */
|
|
167
|
+
export const mark = (x) => `r${x.rounds}/${x.max}${x.level === "warn" ? " ⚠" : x.level === "stop" ? " ✗" : ""}`;
|
package/dist/terminal.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// tmux a background window in the current tmux session
|
|
4
4
|
// plain a detached background process; its screen output goes to <state dir>/agents/<run>.log
|
|
5
5
|
// auto = herdr inside a herdr pane (HERDR_PANE_ID), else tmux inside tmux (TMUX), else plain.
|
|
6
|
+
// The agent's own screen (-T) needs herdr or tmux; follow-ups are typed into it while it is open.
|
|
6
7
|
import { spawn, spawnSync } from "node:child_process";
|
|
7
8
|
import { closeSync, mkdirSync, openSync } from "node:fs";
|
|
8
9
|
import { join } from "node:path";
|
|
@@ -69,6 +70,31 @@ export function launch(host, l) {
|
|
|
69
70
|
closeSync(fd);
|
|
70
71
|
return `pid:${c.pid}`;
|
|
71
72
|
}
|
|
73
|
+
/** Is the screen `launch` returned still open? (herdr pane or tmux pane; a background pid has no screen) */
|
|
74
|
+
export function screenOpen(where) {
|
|
75
|
+
if (where.startsWith("tmux:"))
|
|
76
|
+
return spawnSync("tmux", ["display-message", "-p", "-t", where.slice(5), "#{pane_id}"], { stdio: "ignore" }).status === 0;
|
|
77
|
+
if (!where || where.startsWith("pid:"))
|
|
78
|
+
return false;
|
|
79
|
+
return spawnSync("herdr", ["pane", "get", where], { stdio: "ignore" }).status === 0;
|
|
80
|
+
}
|
|
81
|
+
/** Type `text` into an open screen and submit it (a follow-up to an agent's own TUI). */
|
|
82
|
+
export function typeInto(where, text) {
|
|
83
|
+
if (where.startsWith("tmux:")) {
|
|
84
|
+
// A bracketed paste keeps a multi-line prompt as one message; then Enter sends it.
|
|
85
|
+
const t = where.slice(5), run = (a, input) => {
|
|
86
|
+
const r = spawnSync("tmux", a, { input, encoding: "utf8" });
|
|
87
|
+
if (r.status !== 0)
|
|
88
|
+
throw new Error(`tmux ${a[0]}: ${(r.stderr || "").trim()}`);
|
|
89
|
+
};
|
|
90
|
+
run(["load-buffer", "-b", "orq-prompt", "-"], text);
|
|
91
|
+
run(["paste-buffer", "-p", "-d", "-b", "orq-prompt", "-t", t]);
|
|
92
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300); // let the TUI take the paste before Enter
|
|
93
|
+
run(["send-keys", "-t", t, "Enter"]);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
herdr(["agent", "prompt", where, text]);
|
|
97
|
+
}
|
|
72
98
|
/** After a run: close our own herdr tab (the result stays in the files). tmux windows close when the runner exits. */
|
|
73
99
|
export function closeOwnTab(host) {
|
|
74
100
|
const p = process.env.HERDR_PANE_ID;
|
package/dist/testrun.js
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
// memory cap (src/process.ts) and read its JSON report into "<file relative to the repo> :: <full test name>" lines.
|
|
3
3
|
// The runner's own bin (node_modules/<runner>/package.json "bin") runs with this node, so Windows needs no shell;
|
|
4
4
|
// without it, npx as before. Also the background job both share (launch on a TerminalHost, queue, .pid/.done, notify).
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
6
|
import { createWriteStream, existsSync, lstatSync, readFileSync, realpathSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { writeAtomic } from "./fsutil.js";
|
|
10
10
|
import { m } from "./i18n.js";
|
|
11
11
|
import { acquire, release } from "./lock.js";
|
|
12
|
+
import { stateDir } from "./paths.js";
|
|
12
13
|
import { killTree, parseSize, runCapped } from "./process.js";
|
|
13
14
|
import { closeOwnTab, detectHost, launch } from "./terminal.js";
|
|
14
15
|
export const JS_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/;
|
|
@@ -196,8 +197,28 @@ export async function inJob(j, label, slots, body, done) {
|
|
|
196
197
|
}
|
|
197
198
|
return st === "ok" ? 0 : 1;
|
|
198
199
|
}
|
|
199
|
-
/**
|
|
200
|
-
export
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
/** Where `orq handoff` records the pane that replaced a conductor's. */
|
|
201
|
+
export const handoffFile = (pane) => join(stateDir(), "handoff", pane.replace(/\W/g, "_"));
|
|
202
|
+
/** The conductor is a herdr pane: type the line into it (herdr-notify waits for it to leave a dialog). In the
|
|
203
|
+
* background when the caller must not wait for that (an agent's end-of-turn hook holds its screen meanwhile). */
|
|
204
|
+
export function notify(pane, line, background = false) {
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
while (!seen.has(pane) && existsSync(handoffFile(pane))) {
|
|
207
|
+
seen.add(pane);
|
|
208
|
+
let next;
|
|
209
|
+
try {
|
|
210
|
+
next = readFileSync(handoffFile(pane), "utf8").trim();
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
break;
|
|
214
|
+
} // gone between the two calls
|
|
215
|
+
if (!next || next === pane)
|
|
216
|
+
break;
|
|
217
|
+
pane = next; // follow repeated handoffs to the current conductor
|
|
218
|
+
}
|
|
219
|
+
const local = join(dirname(CLI), "..", "setup", "herdr-notify"), cmd = existsSync(local) ? local : "herdr-notify";
|
|
220
|
+
if (background)
|
|
221
|
+
spawn(cmd, [pane, line], { detached: true, stdio: "ignore" }).on("error", () => { }).unref();
|
|
222
|
+
else
|
|
223
|
+
spawnSync(cmd, [pane, line], { stdio: "inherit" });
|
|
203
224
|
}
|
package/package.json
CHANGED
package/skills/issue/SKILL.md
CHANGED
|
@@ -15,7 +15,7 @@ start. Below, `$WS` = workspace.
|
|
|
15
15
|
**Inside herdr** (`HERDR_ENV=1`): children open in herdr tabs and their notices reach your pane — the flow below.
|
|
16
16
|
**Outside herdr** (any terminal, tmux, any OS): the same flow with these swaps —
|
|
17
17
|
- `herdr-agent ...` → `orq agent ...` (same flags; it is the same program). Children run in tmux windows inside tmux,
|
|
18
|
-
else in the background (their screen goes to a log; the answer is still `<prefix>.out.md`).
|
|
18
|
+
else in the background (their screen goes to a log; the answer is still `<prefix>.out.md`). `-T` needs tmux.
|
|
19
19
|
- `herdr-claim` → `orq claim` (set `ORQ_CONDUCTOR_ID` to a fixed name per conductor), `herdr-deps` → `orq deps`.
|
|
20
20
|
- **Completion signal:** no line arrives in your pane. After dispatching, run `orq wait <prefix>` **as a background
|
|
21
21
|
task** (Claude Code: Bash with `run_in_background`); its exit wakes you and its output is the line
|
|
@@ -30,7 +30,15 @@ Issue artifacts (prompts, notes) live in `$WS/.orquestra/<ID>/`.
|
|
|
30
30
|
## 1. Read the issue
|
|
31
31
|
- **First, claim it:** `herdr-claim -w $WS <ID>`. Exit 1 = another conductor owns it: **stop** and tell the user
|
|
32
32
|
(don't read it, don't dispatch anything). Taking it over (e.g. switching accounts) only with the user's OK:
|
|
33
|
-
`herdr-claim -w $WS -f <ID>`. Several conductors may share a workspace; `herdr-claim -w $WS -l` lists who has what
|
|
33
|
+
`herdr-claim -w $WS -f <ID>`. Several conductors may share a workspace; `herdr-claim -w $WS -l` lists who has what,
|
|
34
|
+
with each conductor's name (its herdr workspace label, e.g. "emp: orq-cms-dsgn"; outside herdr, `ORQ_CONDUCTOR_ID`).
|
|
35
|
+
- **The user names another conductor** ("take over orq-cms-dsgn's work"): never guess a pane id. Run
|
|
36
|
+
`orq claim -w $WS --from "<what they said>"` (any part of the name works). It lists that conductor's issues; exit 3 =
|
|
37
|
+
the name matches more than one conductor (show the list, ask which); exit 1 = none. With the user's OK,
|
|
38
|
+
`orq claim -w $WS --from "<name>" -f` takes them all. **Resuming** an issue someone else started: don't start over.
|
|
39
|
+
Read `$O/` first — `impl-<repo>.md`/`.out.md` (prompt, answer, `## Decisions`), `.pick` files, the last
|
|
40
|
+
`fix-<n>.md` and `review-<repo>.out.md`, `verify-<repo>.txt` — and the Plane comments, then go on from the step the
|
|
41
|
+
files show (a `.pane`/`.id` without `.done` = a child still running: `orq wait` for it instead of dispatching again).
|
|
34
42
|
- `mcp__plane__workitem` `retrieve_by_identifier` with `workitem_identifier: "<ID>"`; then `workitem_comment list`.
|
|
35
43
|
- Extract: goal, **acceptance criteria**, affected repos, links. No acceptance criteria → write a short proposal and
|
|
36
44
|
**ask the user** before dispatching.
|
|
@@ -44,6 +52,14 @@ One task per repo. Level:
|
|
|
44
52
|
- **medium** — business rule, API↔front integration, several files.
|
|
45
53
|
- **heavy** — architecture, data migration, hard/concurrency bug, security.
|
|
46
54
|
|
|
55
|
+
**Scope check, before dispatching.** Estimate the size. Split into sub-issues or sequential batches (each its own PR)
|
|
56
|
+
when it touches several unrelated areas, changes roughly >400 lines, mixes refactor with behavior change, or has
|
|
57
|
+
independent acceptance criteria.
|
|
58
|
+
- Each batch is independently testable and reviewable; dispatch in order, one implementer per batch. Record the split in
|
|
59
|
+
Plane (sub-issues or a checklist comment) before starting.
|
|
60
|
+
- Scope too big mid-run (reviews hitting `policy.max_rounds`, repeated findings, the implementer asking to change
|
|
61
|
+
scope): stop, split the remainder, don't force more rounds.
|
|
62
|
+
|
|
47
63
|
## 3. Pick agent and model: always `orq pick`, never by hand
|
|
48
64
|
`orq pick` decides agent, model, effort and account (user config + delivery and token history + current quota +
|
|
49
65
|
rotation). **Do not choose by preference, by a rule you read elsewhere, or by what worked last time**: run it before
|
|
@@ -51,8 +67,10 @@ EVERY dispatch (implementation, review, second opinion) and use its output verba
|
|
|
51
67
|
```bash
|
|
52
68
|
orq pick impl --level simple|medium|heavy # → e.g. -a codex -m gpt-6-sol -e low
|
|
53
69
|
orq pick review --level <level> --impl-agent <agent that implemented> # → another vendor, e.g. -a opencode -m ...
|
|
54
|
-
orq pick opinion
|
|
70
|
+
orq pick opinion --impl-agent <agent that implemented> # second opinion (e.g. Copilot), see step 5
|
|
55
71
|
```
|
|
72
|
+
More than one agent eligible for `review`/`opinion` (declared, not excluded or blocked, with a model for the role; quota is not considered) and neither `--impl-agent` nor `--impl-vendor` given → exit 2:
|
|
73
|
+
always pass one of them (`--no-record`/`--explain` do not change this).
|
|
56
74
|
The output goes straight into `herdr-agent` (`herdr-agent $(orq pick ...) -g ... -t ... -d ... -o ... -- prompt.md`).
|
|
57
75
|
- Exit 3 = no candidate (quota exhausted, no agent with that role): **stop and ask the user**.
|
|
58
76
|
- Implementer failed review twice (step 6) → pick again without it: `orq pick impl --level <level> --exclude-agent <agent>`.
|
|
@@ -64,10 +82,10 @@ The output goes straight into `herdr-agent` (`herdr-agent $(orq pick ...) -g ...
|
|
|
64
82
|
`opencode run` / `agy -p` / `copilot -p`) in a herdr tab (workspace = group `-g`, tab = title) with live output.
|
|
65
83
|
When it finishes it writes `<prefix>.done` and sends ONE line `[HERDR-AGENT DONE] <title> status=ok|fail ...` to your
|
|
66
84
|
pane; no MCP, and a **4 GB memory cap per agent** (own cgroup: if it blows up, only it dies). NEVER `herdr agent start`
|
|
67
|
-
for children (no memory cap, no answer file). **Screen mode (`-T`, or `HERDR_AGENT_TUI=1
|
|
68
|
-
agent's own TUI instead of the log; same cap, same files, same signal (end-of-turn hook). The screen stays
|
|
69
|
-
the turn (it holds memory until the tab closes); `-R` with the tab open types the follow-up into it. Use it
|
|
70
|
-
user wants to watch. The repo must already be trusted in Codex/Claude (otherwise the TUI stops at the "trust this
|
|
85
|
+
for children (no memory cap, no answer file). **Screen mode (`-T`, or `HERDR_AGENT_TUI=1`; herdr or tmux):** the tab
|
|
86
|
+
shows the agent's own TUI instead of the log; same cap, same files, same signal (end-of-turn hook). The screen stays
|
|
87
|
+
open after the turn (it holds memory until the tab closes); `-R` with the tab open types the follow-up into it. Use it
|
|
88
|
+
when the user wants to watch. The repo must already be trusted in Codex/Claude (otherwise the TUI stops at the "trust this
|
|
71
89
|
folder?" dialog — tell the user, don't approve it for them).
|
|
72
90
|
|
|
73
91
|
## 4. Dispatch the implementer
|
|
@@ -108,7 +126,7 @@ tell me".
|
|
|
108
126
|
orq pick impl --level <level> > $O/impl-<repo>.pick # the fix rounds reuse it (step 6)
|
|
109
127
|
herdr-agent $(cat $O/impl-<repo>.pick) -g <group> -t "<ID> impl <repo>" -d $WT -o $O/impl-<repo> -- $O/impl-<repo>.md
|
|
110
128
|
```
|
|
111
|
-
- The Claude account (`-c`) already comes from `orq pick` (the
|
|
129
|
+
- The Claude account (`-c`) already comes from `orq pick` (one under the limit whose pace stays within it, then the least used).
|
|
112
130
|
- Full final answer in `$O/impl-<repo>.out.md`; log in `.log`; the agent's session id in `.id`.
|
|
113
131
|
- Codex's sandbox has no network: `git fetch` is yours, beforehand (above). Push/PR: yours (step 7).
|
|
114
132
|
- `herdr-agent` returns immediately (no need to background it); the child's pane is in `<prefix>.pane`
|
|
@@ -161,6 +179,13 @@ are in the attached verify)". If the reviewer ends with `status=fail` and no ver
|
|
|
161
179
|
`--exclude-agent` applies **only to redoing that review** (save the new pick to `review-<repo>.pick`); the next
|
|
162
180
|
round reuses whoever gave the verdict. An agent that failed twice in a row with no verdict →
|
|
163
181
|
`orq block <agent> --days 1` and tell the user.
|
|
182
|
+
**Review tools** (an agent whose `run` has no `{prompt}`/`{prompt_text}`/stdin, e.g. a CodeRabbit-like CLI with
|
|
183
|
+
`ok_exit`/`filter` in the config): same command; it ignores the prompt and the attachments and reviews the branch
|
|
184
|
+
against `{base}` itself. Read its answer like any other: `status=ok` with `rc≠0` is a verdict with findings, not a
|
|
185
|
+
failure. Its high-severity items are the blockers, still under the step 6 rule (`file:line` + concrete failure).
|
|
186
|
+
If the tool's answer opens with (or, when short, consists of) a statement that nothing was reviewed (e.g. Corvus's "Nada para revisar"), `agents.<name>.no_review` (a
|
|
187
|
+
regex) makes `orq agent` end that run `status=fail`: `orq pick`'s fallback and the conductor treat it as not run,
|
|
188
|
+
same as an empty answer.
|
|
164
189
|
**Second opinion** (`orq pick opinion`, read only, same attachments): only on **heavy** issues or when reviewer and
|
|
165
190
|
implementer disagree about a blocker. It never becomes a round by itself: you weigh both answers and decide.
|
|
166
191
|
**Before dispatching:** `git -C $R fetch origin $BASE; git -C $WT merge-base --is-ancestor origin/$BASE HEAD` — if it
|
|
@@ -180,7 +205,7 @@ repo checklist.
|
|
|
180
205
|
**Only `[blocker]` items open a round.** An item tagged blocker without `file:line` and a concrete failure, or that
|
|
181
206
|
only restates a `## Decisions` choice, counts as a suggestion; when you downgrade one, say why in the Plane comment.
|
|
182
207
|
No blockers left → APPROVED, even with suggestions.
|
|
183
|
-
Blocking problems → write the list (blockers only) to `$O/fix-<n>.md` and continue the implementer's session (any agent):
|
|
208
|
+
Blocking problems → write the list (blockers only) to `$O/fix-<n>.md` (always this name; reviews `$O/review-<repo>-<n>`, never `fix<n>-<repo>` or other variants — a later conductor counts rounds by these files) and continue the implementer's session (any agent):
|
|
184
209
|
`herdr-agent $(orq pick fix --same $O/impl-<repo>.pick) -g <group> -t "<ID> fix<n> <repo>" -d $WT -o $O/impl-<repo> -R $(cat $O/impl-<repo>.id) -- $O/fix-<n>.md`.
|
|
185
210
|
A fix is a continuation: same agent, model, account and session, effort one level up (`--same` reads the implementer's
|
|
186
211
|
pick; never a new `orq pick`, never `-a` alone — without `-m` the session resumes on the CLI's default model, which
|
|
@@ -196,10 +221,18 @@ Then a new `herdr-verify` (4b) and the **round review** (step 5 with these chang
|
|
|
196
221
|
(the full `-b $BASE` diff stays, as context).
|
|
197
222
|
- The prompt asks a closed question: "for each item in fix-<n>.md: RESOLVED or NOT RESOLVED (why); then the fix diff:
|
|
198
223
|
any regression it introduced, as `[blocker]`/`[suggestion]`. Outside the fix diff, report only a blocker you missed
|
|
199
|
-
before that would break production, and say why it wasn't in the first review". Anything else is a suggestion.
|
|
224
|
+
before that would break production, and say why it wasn't in the first review". Anything else is a suggestion. A review tool can't answer that: it reviews the whole
|
|
225
|
+
branch again, so you do the matching — a finding is a blocker this round only if it is inside `fix-<n>.diff` or is a
|
|
226
|
+
NOT RESOLVED item of `fix-<n>.md`; one you already decided on (fixed, rejected or downgraded) stays decided. **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, `-F` only with their OK; over it → `orq pick impl
|
|
200
227
|
--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
|
|
201
228
|
don't become a round.
|
|
202
229
|
|
|
230
|
+
## Screenshots / visual validation
|
|
231
|
+
Only when the issue asks for it. **One** attempt (a job with `--effort low`, a short prompt that says which screens and
|
|
232
|
+
where to save the images), at most ~6 images, and read them only as files you need to judge — never re-read the same
|
|
233
|
+
image. If it fails, tell the user instead of dispatching a second `shots` job. Playwright runs inside that child's
|
|
234
|
+
worktree, never as an MCP in the conductor (images in the conductor's context are re-billed every turn).
|
|
235
|
+
|
|
203
236
|
## 7. Close
|
|
204
237
|
**Commit/PR conventions belong to the repo, not to you.** The **implementer, in the worktree** (where the repo's
|
|
205
238
|
rules live) commits and writes the PR. Find and cite in the prompt, by path, whatever the repo has:
|
|
@@ -212,6 +245,10 @@ rules live) commits and writes the PR. Find and cite in the prompt, by path, wha
|
|
|
212
245
|
issue) and the answer in `$O/impl-<repo>.out.md`. Check the opened PR against the template.
|
|
213
246
|
- Plane comment (`workitem_comment create`): summary, repo(s), branch, agents and models used (`orq pick` output),
|
|
214
247
|
review result, checks that ran.
|
|
248
|
+
- **Review bots on the PR** (a GitHub App reviewer): one more input, not a new review cycle. Only a finding with
|
|
249
|
+
high severity, `file:line` and a concrete failure, not already decided in the rounds, goes into a fix round (it
|
|
250
|
+
counts towards `policy.max_rounds`); the rest goes into the Plane comment or a follow-up issue. Don't re-request the
|
|
251
|
+
bot's review to "get it clean".
|
|
215
252
|
- **CI after every push:** `gh pr checks <n>` (no `--watch`; if pending, end your turn and check on the next notice).
|
|
216
253
|
Red → `gh run view <id> --log-failed | tail -40`: error from the diff → fix round; error already on the base → create
|
|
217
254
|
a Plane issue (workspace standard) and tell the user. "Ready to merge" only with green checks or an explained failure
|
|
@@ -61,7 +61,7 @@ Offer `$ORQ/examples/irythill/config.toml` as an example, saying clearly **it is
|
|
|
61
61
|
|
|
62
62
|
## 4. Policy
|
|
63
63
|
Explain simply, then suggest the example's values and ask if they want to change any:
|
|
64
|
-
- `
|
|
64
|
+
- `quota_week` (95) and `quota_5h` (simple 90, medium 85, heavy 80): an agent at or above these % is left out.
|
|
65
65
|
- weights — already suggested by `orq setup` from the parallel tasks: `quality` (did it deliver?), `tokens` (spent
|
|
66
66
|
little?), `speed` (finished fast?), `rotation` (penalty for being picked recently, spreads the work).
|
|
67
67
|
- `max_rounds` (2) review rounds before handing back; `machine.agent_mem_max` / `test_mem_max` come from `orq setup`
|