@irythill/orq 0.3.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.
@@ -15,7 +15,7 @@ import { basename, dirname, join, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { claudeAccounts } from "../accounts.js";
17
17
  import { agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../agents.js";
18
- import { loadConfig } from "../config.js";
18
+ import { getPath, loadConfig } from "../config.js";
19
19
  import { writeAtomic } from "../fsutil.js";
20
20
  import { m } from "../i18n.js";
21
21
  import { alive } from "../lock.js";
@@ -23,7 +23,7 @@ import { stateDir } from "../paths.js";
23
23
  import { killTree, parseSize, resolveMode, runCapped } from "../process.js";
24
24
  import { closeOwnTab, detectHost, launch, screenOpen, shq, typeInto } from "../terminal.js";
25
25
  import { dropWorktree, notify } from "../testrun.js";
26
- export const USAGE = "orq agent -a <agent> -t <title> -d <dir> -o <prefix> [-g group] [-m model] [-e effort] [-c account] [-i file]... [-b base] [-r] [-R session] [-T] -- <prompt.md> run a child agent";
26
+ export const USAGE = "orq agent -a <agent> -t <title> -d <dir> -o <prefix> [-g group] [-m model] [-e effort] [-c account] [-i file]... [-b base] [-r] [-R session [-F]] [-T] -- <prompt.md> run a child agent";
27
27
  const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
28
28
  const PF = ".herdr-agent-prompt.md"; // prompt_in_dir file name (kept from herdr-agent)
29
29
  const git = (dir, ...a) => {
@@ -36,7 +36,7 @@ export async function agent(argv) {
36
36
  if (argv[0] === "--turn")
37
37
  return turn(argv[1], argv[2], argv.slice(3));
38
38
  let a = process.env.AGENT || "codex", g = "", t = "", d = "", o = "", model = "", effort = "", acc = "", resume = "", base = "";
39
- let ro = false, tui = false, prompt;
39
+ let ro = false, tui = false, force = false, prompt;
40
40
  const inc = [];
41
41
  for (let i = 0; i < argv.length; i++) {
42
42
  const x = argv[i], v = () => argv[++i] ?? "";
@@ -72,6 +72,8 @@ export async function agent(argv) {
72
72
  ro = true;
73
73
  else if (x === "-T")
74
74
  tui = true;
75
+ else if (x === "-F")
76
+ force = true;
75
77
  else if (!x.startsWith("-") && prompt === undefined)
76
78
  prompt = x;
77
79
  else {
@@ -121,6 +123,22 @@ export async function agent(argv) {
121
123
  return 2;
122
124
  }
123
125
  const repo = resolve(d), O = resolve(o);
126
+ // Fix rounds: every -R on the same session counts, whatever the prefix is called. policy.max_rounds is enforced here,
127
+ // not left to the conductor to remember (a 7-round fix loop is what burns the tokens). -F = the user said go on.
128
+ if (resume) {
129
+ const max = Number(getPath(cfg, "policy.max_rounds") ?? 2), cf = join(dirname(O), `.rounds-${resume}`);
130
+ let n = 0;
131
+ try {
132
+ n = Number(readFileSync(cf, "utf8")) || 0;
133
+ }
134
+ catch { /* first follow-up */ }
135
+ if (n >= max && !force) {
136
+ console.error(m(`round limit: session ${resume} already had ${n} follow-up(s) (policy.max_rounds = ${max}). Stop and tell the user; with their OK, repeat with -F.`, `limite de rodadas: a sessão ${resume} já teve ${n} follow-up(s) (policy.max_rounds = ${max}). Pare e avise o usuário; com o OK dele, repita com -F.`));
137
+ return 4;
138
+ }
139
+ mkdirSync(dirname(O), { recursive: true });
140
+ writeFileSync(cf, String(n + 1));
141
+ }
124
142
  let P = resolve(prompt);
125
143
  if (!existsSync(repo)) {
126
144
  console.error(m(`no folder ${repo}`, `pasta ${repo} não existe`));
@@ -199,7 +217,7 @@ export async function agent(argv) {
199
217
  const vars = {
200
218
  prompt: P, prompt_text: tpl.includes("{prompt_text}") ? readFileSync(P, "utf8") : "",
201
219
  prompt_hint: `Read the file ${PF} (in the current folder, with your file-reading tool): it holds your complete instructions for this task. Follow them. ${PF} is not part of the code: don't review or edit it.`,
202
- out: `${O}.out.md`, dir, repo, title: t, model: model === "auto" ? "" : model, effort, session,
220
+ out: `${O}.out.md`, dir, repo, base, title: t, model: model === "auto" ? "" : model, effort, session,
203
221
  gitdir: gd ?? "", gitroots: roots, gitroots_json: JSON.stringify(roots), mode: [], opencode_config: JSON.stringify(ocCfg),
204
222
  hook_cmd: hook.map(shq).join(" "), hook_json: JSON.stringify(hook),
205
223
  hook_settings: JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command: hook.map(shq).join(" ") }] }] } }),
@@ -229,6 +247,7 @@ export async function agent(argv) {
229
247
  swap: parseSize(mach.agent_swap_max), mode: mach.mem_enforce ?? "auto",
230
248
  throwaway, promptInDir: Boolean(def.prompt_in_dir), closeAfter,
231
249
  keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0, tui,
250
+ okExit: def.ok_exit, filter: def.filter, noReview: def.no_review,
232
251
  };
233
252
  writeAtomic(`${O}.job.json`, JSON.stringify(job, null, 1));
234
253
  // Readable copy of the command (also what orq stats and herdr-board read).
@@ -303,7 +322,8 @@ function turn(file, kind, rest) {
303
322
  writeFileSync(`${O}.out.md`, answer);
304
323
  if (sid)
305
324
  writeFileSync(`${O}.id`, `${sid}\n`);
306
- const st = /\S/.test(answer) ? "ok" : "fail";
325
+ const noReview = noReviewMatch(j.noReview, answer);
326
+ const st = /\S/.test(answer) && !noReview ? "ok" : "fail";
307
327
  writeAtomic(`${O}.done`, `status=${st} rc=0 at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
308
328
  if (j.parent)
309
329
  notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md`, true);
@@ -332,6 +352,27 @@ function lastClaudeText(transcript) {
332
352
  catch { /* no transcript */ }
333
353
  return text;
334
354
  }
355
+ /** agents.<name>.no_review tested against the start of the answer (first line) or, for a short answer, the whole
356
+ * thing — not anywhere in a long one, so a review that quotes the phrase while discussing real code doesn't match.
357
+ * An invalid pattern is logged and ignored rather than killing the runner. */
358
+ function noReviewMatch(pattern, answer) {
359
+ if (pattern === undefined)
360
+ return false;
361
+ if (typeof pattern !== "string" || !pattern.trim()) {
362
+ console.error(`orq agent: ignoring invalid agents.<name>.no_review ${JSON.stringify(pattern)}: expected a non-empty regex string`);
363
+ return false;
364
+ }
365
+ let re;
366
+ try {
367
+ re = new RegExp(pattern, "m");
368
+ }
369
+ catch (e) {
370
+ console.error(`orq agent: ignoring invalid agents.<name>.no_review pattern ${JSON.stringify(pattern)}: ${e.message}`);
371
+ return false;
372
+ }
373
+ const first = answer.split(/\r?\n/, 1)[0] ?? "";
374
+ return re.test(first) || (answer.trim().length <= 200 && re.test(answer));
375
+ }
335
376
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
336
377
  /** Splits a stream into lines for `each`. */
337
378
  function lines(s, each) {
@@ -500,12 +541,26 @@ async function runJob(file) {
500
541
  writeFileSync(`${O}.id`, `${sid}\n`);
501
542
  if (rc === 0 && res.rc)
502
543
  rc = res.rc;
544
+ // A review tool exits non-zero on findings: an ok_exit code is a verdict (rc stays in .done), not a failure.
545
+ let verdict = rc === 0 || (j.okExit ?? []).includes(rc);
546
+ if (j.filter && verdict) {
547
+ const f = spawnSync("sh", ["-c", j.filter], { cwd: j.dir, input: readFileSync(`${O}.out.md`, "utf8"), encoding: "utf8", maxBuffer: 1 << 30 });
548
+ if (f.status === 0)
549
+ writeFileSync(`${O}.out.md`, f.stdout);
550
+ else {
551
+ appendFileSync(`${O}.log`, `[orq agent] filter failed (exit ${f.status}): ${f.stderr}\n`);
552
+ verdict = false;
553
+ }
554
+ }
503
555
  let answer = "";
504
556
  try {
505
557
  answer = readFileSync(`${O}.out.md`, "utf8");
506
558
  }
507
559
  catch { /* none */ }
508
- const st = rc === 0 && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
560
+ // A reviewer whose answer says nothing was reviewed (agents.<name>.no_review) is not a verdict either: pick's
561
+ // fallback and the conductor must see it as not run, same as a whitespace-only answer.
562
+ const noReview = noReviewMatch(j.noReview, answer);
563
+ const st = verdict && !noReview && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
509
564
  writeAtomic(`${O}.done`, `status=${st} rc=${rc} at=${new Date().toISOString()} agent=${j.agent}\n`);
510
565
  rmSync(`${O}.pid`, { force: true });
511
566
  state({ status: st, rc, end: new Date().toISOString() });
@@ -1,9 +1,11 @@
1
1
  // orq block — takes an agent (or account) out of orq-pick until a date. For quotas without a collector
2
2
  // (Antigravity, Copilot, MiniMax) or any outage you know about before the agent fails.
3
- // State in <state dir>/blocks.json ({name: {until: <epoch seconds>, reason}}), shared with orq-pick.
3
+ // State in <state dir>/blocks.json ({name: {until: <epoch seconds>, reason, acct}}), shared with orq-pick. `acct` is the
4
+ // login at block time (src/identity.ts): if the agent/account is logged into another one later, the block is dropped.
4
5
  import { readFileSync } from "node:fs";
5
6
  import { join } from "node:path";
6
7
  import { writeAtomic } from "../fsutil.js";
8
+ import { loginId } from "../identity.js";
7
9
  import { m } from "../i18n.js";
8
10
  import { stateDir } from "../paths.js";
9
11
  export const USAGE = `orq block <agent|account> [--until YYYY-MM-DD[THH:MM] | --days N] [--reason "text"] default: 7 days
@@ -19,7 +21,21 @@ export function load() {
19
21
  b = JSON.parse(readFileSync(file(), "utf8"));
20
22
  }
21
23
  catch { /* missing or broken: no blocks */ }
22
- return Object.fromEntries(Object.entries(b).filter(([, v]) => (v?.until ?? 0) > now()));
24
+ const live = Object.fromEntries(Object.entries(b).filter(([, v]) => (v?.until ?? 0) > now()));
25
+ // A block made under another login (the account was switched) no longer applies: the new one has its own quota.
26
+ const kept = Object.fromEntries(Object.entries(live).filter(([k, v]) => {
27
+ if (!v.acct)
28
+ return true;
29
+ const cur = loginId(k);
30
+ return cur === undefined || cur === v.acct;
31
+ }));
32
+ if (Object.keys(kept).length !== Object.keys(live).length) {
33
+ try {
34
+ save(kept);
35
+ }
36
+ catch { /* best effort */ }
37
+ }
38
+ return kept;
23
39
  }
24
40
  const save = (b) => writeAtomic(file(), JSON.stringify(b, null, 2));
25
41
  /** "YYYY-MM-DD" or "YYYY-MM-DDTHH:MM" in local time (a bare date in JS would be UTC). */
@@ -67,7 +83,8 @@ export function block(argv) {
67
83
  console.error(USAGE);
68
84
  return 2;
69
85
  }
70
- b[name] = { until: until.getTime() / 1000, reason };
86
+ const acct = loginId(name);
87
+ b[name] = { until: until.getTime() / 1000, reason, ...(acct ? { acct } : {}) };
71
88
  save(b);
72
89
  console.log(`${name} ${m("out of orq-pick until", "fora do orq-pick até")} ${fmt(until)}`);
73
90
  return 0;
@@ -7,11 +7,12 @@
7
7
  // Refreshes every --interval s on a TTY; otherwise (or with --once) prints once; --json for scripts.
8
8
  // Keys: q quits · r refreshes the quota · l language · t theme (ui.theme: gunmetal | chrome | lcd | terminal).
9
9
  import { execFile, spawn } from "node:child_process";
10
- import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync } from "node:fs";
10
+ import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync } from "node:fs";
11
11
  import { freemem, homedir, totalmem } from "node:os";
12
12
  import { basename, dirname, join, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { colorMode } from "../banner.js";
15
+ import { collectCi, collectedAt, loadCache as loadCiCache } from "../ci.js";
15
16
  import { getPath, loadConfig } from "../config.js";
16
17
  import { writeAtomic } from "../fsutil.js";
17
18
  import { m, setLang } from "../i18n.js";
@@ -19,8 +20,10 @@ import { alive, status as lockStatus } from "../lock.js";
19
20
  import { expand, stateDir } from "../paths.js";
20
21
  import { processTableAsync, treeMem } from "../process.js";
21
22
  import { collect } from "../quota.js";
23
+ import { collectRounds, mark, roundsSig } from "../rounds.js";
22
24
  import { collectRuns, rows } from "../stats.js";
23
25
  import { load as loadBlocks } from "./block.js";
26
+ import { herdrNamesAsync, nameOf } from "./claim.js";
24
27
  import { setValue } from "./set.js";
25
28
  export const USAGE = "orq board [-w <workspace>]... [--once] [--json] [--interval S] live board: queue, agents, conductors, quota";
26
29
  const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
@@ -124,16 +127,40 @@ function agents(table, now) {
124
127
  }
125
128
  const field = (t, k) => new RegExp(`${k}=(\\S+)`).exec(t)?.[1] ?? "";
126
129
  const herdrAlive = (id) => new Promise((res) => execFile("herdr", ["agent", "get", id], { timeout: 5000, windowsHide: true }, (e) => res(!e)));
130
+ // The board redraws every few seconds: reread an issue's reviews only when its folder changed.
131
+ const roundsCache = new Map();
132
+ function cachedRounds(ws, id, max) {
133
+ const key = `${ws}\0${id}\0${max}`, sig = roundsSig(ws, id), hit = roundsCache.get(key);
134
+ if (hit && hit.sig === sig)
135
+ return hit.x;
136
+ const x = collectRounds(ws, id, max);
137
+ roundsCache.set(key, { sig, x });
138
+ return x;
139
+ }
127
140
  async function conductors(wss) {
128
141
  const by = new Map();
129
142
  for (const ws of wss) {
130
143
  const O = join(ws, ".orquestra");
131
- for (const id of existsSync(O) ? readdirSync(O) : []) {
144
+ const max = Number((() => { try {
145
+ return getPath(loadConfig(ws).cfg, "policy.max_rounds");
146
+ }
147
+ catch {
148
+ return undefined;
149
+ } })() ?? 2);
150
+ for (const id of existsSync(O) ? readdirSync(O).filter((x) => !x.startsWith(".")) : []) {
132
151
  const t = read(join(O, id, "owner")).trim();
133
152
  if (!t)
134
153
  continue;
135
154
  const who = field(t, "pane"), at = Date.parse(field(t, "at")) || 0;
136
- const c = by.get(who) ?? { id: who, kind: field(t, "kind") || "herdr", account: field(t, "account"), alive: false, issues: [], at };
155
+ const c = by.get(who) ?? { id: who, kind: field(t, "kind") || "herdr", account: field(t, "account"), alive: false, issues: [], at, name: nameOf(t) };
156
+ if (!c.name)
157
+ c.name = nameOf(t);
158
+ try {
159
+ const x = cachedRounds(ws, id, max);
160
+ if (x)
161
+ (c.rounds ??= {})[id] = { rounds: x.rounds, max: x.max, level: x.level };
162
+ }
163
+ catch { /* unreadable issue folder: no marker */ }
137
164
  c.issues.push(id);
138
165
  c.at = Math.max(c.at, at);
139
166
  by.set(who, c);
@@ -141,7 +168,15 @@ async function conductors(wss) {
141
168
  }
142
169
  const ttl = Number(process.env.ORQ_CLAIM_TTL_H ?? 24) * 3600_000;
143
170
  const out = [...by.values()];
144
- await Promise.all(out.map(async (c) => { c.alive = c.kind === "herdr" ? await herdrAlive(c.id) : Date.now() - c.at < ttl; }));
171
+ // One `herdr workspace list` per refresh, shared by every conductor (not 2 spawns each), off the event
172
+ // loop (execFile, not spawnSync) so a slow/busy herdr daemon delays only this redraw, never key handling.
173
+ const names = out.some((c) => c.kind === "herdr") ? await herdrNamesAsync(out.filter((c) => c.kind === "herdr").map((c) => c.id)) : new Map();
174
+ await Promise.all(out.map(async (c) => {
175
+ c.alive = c.kind === "herdr" ? await herdrAlive(c.id) : Date.now() - c.at < ttl;
176
+ // Alive herdr conductor: the workspace's current label (the user may have renamed it since the claim).
177
+ if (c.alive && c.kind === "herdr")
178
+ c.name = names.get(c.id) || c.name;
179
+ }));
145
180
  return out.filter((c) => c.alive).map(({ at: _, ...c }) => ({ ...c, issues: c.issues.sort() }));
146
181
  }
147
182
  // Quota: the board never waits for the network. A stale cache starts `orq board --collect-quota` detached.
@@ -149,8 +184,9 @@ const qdir = () => join(stateDir(), "board");
149
184
  const qfile = () => join(qdir(), "quota.txt");
150
185
  export function parseQuota(text) {
151
186
  return text.split(/\r?\n/).flatMap((l) => {
152
- const r = /^(\S+) 5h=(\S+) week=(\S+)(?: \[(.*)\])?/.exec(l.trim());
153
- return r ? [{ name: r[1], five: r[2], week: r[3], ...(r[4] ? { note: r[4] } : {}) }] : [];
187
+ const r = /^(\S+) 5h=(\S+) week=(\S+)(?: reset5h=(\d+))?(?: resetweek=(\d+))?(?: span5h=\d+)?(?: spanweek=\d+)?(?: \[(.*)\])?/.exec(l.trim());
188
+ return r ? [{ name: r[1], five: r[2], week: r[3], ...(r[4] ? { reset5h: Number(r[4]) } : {}),
189
+ ...(r[5] ? { resetWeek: Number(r[5]) } : {}), ...(r[6] ? { note: r[6] } : {}) }] : [];
154
190
  });
155
191
  }
156
192
  export function quota(now) {
@@ -179,6 +215,41 @@ async function collectQuota() {
179
215
  writeAtomic(qfile(), lines.join("\n") + "\n");
180
216
  return 0;
181
217
  }
218
+ // CI: the same never-block-the-board rule as quota — a stale cache starts `orq board --collect-ci -w <ws>...`
219
+ // detached, reusing this same timer instead of a new one; the board itself only ever reads the cache file.
220
+ function startCollectCi(wss) {
221
+ const lock = join(qdir(), "ci.pid");
222
+ const pid = Number(read(lock));
223
+ if (pid > 0 && alive(pid))
224
+ return;
225
+ mkdirSync(qdir(), { recursive: true });
226
+ const c = spawn(process.execPath, [CLI, "board", ...wss.flatMap((w) => ["-w", w]), "--collect-ci"], { detached: true, stdio: "ignore", windowsHide: true });
227
+ if (c.pid)
228
+ writeAtomic(lock, String(c.pid));
229
+ c.unref();
230
+ }
231
+ // Age per requested workspace, not the shared cache file's mtime: ci.json holds every workspace's entries and
232
+ // collectCi() rewrites the whole file on every run, so one workspace's collection would otherwise renew the
233
+ // mtime for all of them and starve a second workspace that was never actually collected. collectedAt() reads a
234
+ // per-workspace marker that collectCi() writes even when it finds zero entries (no claimed issue with a matching
235
+ // worktree) — without it, a workspace with nothing to collect would look permanently stale (entries' own `at`
236
+ // would never exist for it) and the board would relaunch the collector every redraw forever.
237
+ function ci(wss, now) {
238
+ const entries = loadCiCache().filter((e) => wss.includes(e.ws));
239
+ const ages = wss.map((w) => { const at = collectedAt(w); return at === undefined ? undefined : now - at; });
240
+ const age = ages.some((a) => a === undefined) ? undefined : Math.max(...ages);
241
+ if (age === undefined || age > QTTL)
242
+ startCollectCi(wss);
243
+ return { entries, age };
244
+ }
245
+ // Detached (stdio: "ignore"): collectCi()'s default notice (console.log) would otherwise vanish into /dev/null,
246
+ // so a non-herdr conductor's "CI failed" line is appended to a log file instead.
247
+ function collectCiAll(wss) {
248
+ const notice = (l) => appendFileSync(join(qdir(), "ci.log"), `${l}\n`);
249
+ for (const ws of wss)
250
+ collectCi(ws, { notice });
251
+ return 0;
252
+ }
182
253
  let sb;
183
254
  function scoreboard(ws, now) {
184
255
  if (!sb || sb.ws !== ws || now - sb.at > 60_000) {
@@ -197,7 +268,7 @@ export async function snapshot(wss, table) {
197
268
  return {
198
269
  at: new Date(now).toISOString(), mem: { used: totalmem() - freemem(), total: totalmem() }, workspaces: wss,
199
270
  queue: { holders: q.holders.map(item), waiters: q.waiters.map(item) }, agents: agents(table, now),
200
- conductors: await conductors(wss), quota: quota(now), blocks: loadBlocks(), scoreboard: scoreboard(wss[0], now),
271
+ conductors: await conductors(wss), quota: quota(now), ci: ci(wss, now), blocks: loadBlocks(), scoreboard: scoreboard(wss[0], now),
201
272
  };
202
273
  }
203
274
  // --- render -----------------------------------------------------------------------------------------------------
@@ -276,9 +347,15 @@ export function render(s, theme, cols, mode = colorMode()) {
276
347
  out.push(` ${c.mut}${m("none", "nenhum")}${c.r}`);
277
348
  section("MASTER", m("conductors", "conductors"));
278
349
  for (const k of s.conductors)
279
- out.push(` ${c.ok}●${c.r} ${c.mut}${fit(k.id, 22)}${c.r} ${c.ink2}${fit(k.account || "-", 14)}${c.r} ${c.ink}${k.issues.join(" ")}${c.r}`);
350
+ out.push(` ${c.ok}●${c.r} ${c.mut}${fit(k.name || k.id, 22)}${c.r} ${c.ink2}${fit(k.account || "-", 14)}${c.r} ${c.ink}${k.issues.map((i) => (k.rounds?.[i] ? `${i} ${mark(k.rounds[i])}` : i)).join(" ")}${c.r}`);
280
351
  if (!s.conductors.length)
281
352
  out.push(` ${c.mut}${m("no claimed issues", "nenhuma issue reivindicada")}${c.r}`);
353
+ section("MONITOR", m("CI/CD", "CI/CD"));
354
+ const ciMark = { passed: `${c.ok}✓`, failed: `${c.err}✗`, running: `${c.acc}●`, queued: `${c.mut}‖`, none: `${c.mut}·` };
355
+ for (const e of s.ci.entries)
356
+ out.push(` ${ciMark[e.state]}${c.r} ${c.ink}${fit(e.issue, 10)}${c.r} ${c.ink2}${fit(e.repo, 14)}${c.r} ${c.mut}${fit(e.branch, 24)}${c.r} ${c.ink}${(e.pr ? `#${e.pr}` : "-").padEnd(6)}${c.r} ${c.ink2}${e.state}${c.r}`);
357
+ if (!s.ci.entries.length)
358
+ out.push(` ${c.mut}${s.ci.age === undefined ? m("collecting...", "coletando...") : m("no claimed issue has a PR", "nenhuma issue reivindicada tem PR")}${c.r}`);
282
359
  section("MIXER", m("quota", "cota"));
283
360
  const win = (v, tag) => {
284
361
  const n = /^(\d+)%?$/.exec(v)?.[1];
@@ -329,6 +406,8 @@ export async function board(argv) {
329
406
  interval = Math.max(1, Number(argv[++i]) || 3);
330
407
  else if (a === "--collect-quota")
331
408
  return collectQuota();
409
+ else if (a === "--collect-ci")
410
+ return collectCiAll(wss);
332
411
  else if (a === "-h" || a === "--help") {
333
412
  console.log(USAGE);
334
413
  return 0;
@@ -0,0 +1,48 @@
1
+ // orq ci [-w <workspace>]... [--json] CI/CD signal per claimed issue with a PR: queued | running | passed |
2
+ // failed | none. This is the one caller of src/ci.ts's collectCi() that runs it in the foreground (network and
3
+ // all) instead of reading its cache — the user asked for CI status right now, so it collects right now; `orq
4
+ // board`'s CI column is the one that stays off the network and only reads the cache this same collectCi() fills.
5
+ import { resolve } from "node:path";
6
+ import { collectCi } from "../ci.js";
7
+ import { getPath, loadConfig } from "../config.js";
8
+ import { m } from "../i18n.js";
9
+ import { expand } from "../paths.js";
10
+ export const USAGE = "orq ci [-w <workspace>]... [--json] CI/CD status per claimed issue (queued|running|passed|failed|none)";
11
+ const label = (s) => ({ queued: m("queued", "na fila"), running: m("running", "rodando"),
12
+ passed: m("passed", "passou"), failed: m("failed", "falhou"), none: m("none", "nenhum") })[s];
13
+ export async function ci(argv) {
14
+ const wss = [];
15
+ let json = false;
16
+ for (let i = 0; i < argv.length; i++) {
17
+ const a = argv[i];
18
+ if (a === "-w")
19
+ wss.push(resolve(expand(argv[++i] ?? ".")));
20
+ else if (a === "--json")
21
+ json = true;
22
+ else if (a === "-h" || a === "--help") {
23
+ console.log(USAGE);
24
+ return 0;
25
+ }
26
+ else {
27
+ console.error(USAGE);
28
+ return 2;
29
+ }
30
+ }
31
+ if (!wss.length)
32
+ wss.push(getPath(loadConfig().cfg, "_workspace") ?? process.cwd());
33
+ // --json's stdout must be only the JSON: a non-herdr owner's notice goes to stderr instead of the default
34
+ // stdout, which would otherwise interleave a stray line in front of the JSON.
35
+ const hooks = json ? { notice: (line) => console.error(line) } : {};
36
+ const entries = wss.flatMap((ws) => collectCi(ws, hooks));
37
+ if (json) {
38
+ console.log(JSON.stringify(entries));
39
+ return 0;
40
+ }
41
+ if (!entries.length) {
42
+ console.log(m("no claimed issue has a PR", "nenhuma issue reivindicada tem PR"));
43
+ return 0;
44
+ }
45
+ for (const e of entries)
46
+ console.log(`${e.issue} ${e.repo} ${e.branch} ${e.pr ? `#${e.pr}` : "-"} ${label(e.state)}`);
47
+ return 0;
48
+ }
@@ -1,36 +1,112 @@
1
1
  // orq claim — owner of each issue among conductors of the same workspace (port of herdr-claim).
2
- // .orquestra/<ID>/owner = "pane=<conductor id> account=<a> at=<iso>". Another conductor is refused while the owner is
3
- // alive; a dead owner is taken over automatically.
2
+ // .orquestra/<ID>/owner = "pane=<conductor id> kind=<k> account=<a> at=<iso> name=<what the user calls it>". Another
3
+ // conductor is refused while the owner is alive; a dead owner is taken over automatically.
4
+ // name: the herdr workspace's label (the sidebar name, e.g. "emp: orq-cms-dsgn"), else ORQ_CONDUCTOR_ID. It is the
5
+ // last field and may hold spaces. `--from <name>` finds a conductor's issues by any part of that name (or its pane).
4
6
  // conductor id: HERDR_PANE_ID (alive = `herdr agent get` answers) or ORQ_CONDUCTOR_ID (alive = claimed or refreshed
5
7
  // in the last ORQ_CLAIM_TTL_H hours, default 24 — any conductor, any terminal); neither → <user>@<host>.
6
- import { spawnSync } from "node:child_process";
8
+ import { execFile, spawnSync } from "node:child_process";
7
9
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
8
10
  import { homedir, hostname, userInfo } from "node:os";
9
11
  import { basename, join, resolve } from "node:path";
10
12
  import { writeAtomic } from "../fsutil.js";
11
13
  import { m } from "../i18n.js";
12
14
  import { acquire, release } from "../lock.js";
13
- export const USAGE = `orq claim [-w <workspace>] <ID> | -f <ID> | -r <ID> | -l issue owner among conductors (exit 1 = someone else's)`;
15
+ export const USAGE = `orq claim [-w <workspace>] <ID> | -f <ID> | -r <ID> | -l | --register issue owner among conductors (exit 1 = someone else's)
16
+ orq claim [-w <workspace>] --from <name> [-f] a conductor's issues by its name (part of it is enough); -f takes them all`;
14
17
  const field = (text, k) => new RegExp(`${k}=(\\S+)`).exec(text)?.[1] ?? "";
18
+ export const nameOf = (text) => / name=(.*)$/m.exec(text)?.[1].trim() ?? "";
19
+ const herdrList = (args) => {
20
+ const r = spawnSync("herdr", args, { encoding: "utf8", timeout: 5_000 });
21
+ try {
22
+ return JSON.parse(r.stdout).result;
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ };
28
+ /** pane_id -> the label of the herdr workspace holding it (the name the user sees), for every live pane. One
29
+ * `pane list` + one `workspace list` call, however many panes are being looked up. */
30
+ export function herdrNames() {
31
+ const panes = herdrList(["pane", "list"])?.panes;
32
+ const workspaces = herdrList(["workspace", "list"])?.workspaces;
33
+ const labelOf = new Map((workspaces ?? []).map((w) => [w.workspace_id, w.label ?? ""]));
34
+ const out = new Map();
35
+ for (const p of panes ?? []) {
36
+ const label = p.workspace_id ? labelOf.get(p.workspace_id) : undefined;
37
+ if (label)
38
+ out.set(p.pane_id, label);
39
+ }
40
+ return out;
41
+ }
42
+ /** The label of the herdr workspace holding `pane` (the name the user sees), or "". */
43
+ export function herdrName(pane) {
44
+ return herdrNames().get(pane) ?? "";
45
+ }
46
+ const herdrListAsync = (args) => new Promise((res) => execFile("herdr", args, { timeout: 5_000, windowsHide: true }, (_e, stdout) => {
47
+ try {
48
+ res(JSON.parse(stdout).result);
49
+ }
50
+ catch {
51
+ res(undefined);
52
+ }
53
+ }));
54
+ /** Same as herdrNames() for the given pane ids, but off the event loop (execFile, not spawnSync) — for a live,
55
+ * repeatedly-redrawn caller like the board, where blocking would freeze the whole UI while herdr answers. One
56
+ * `workspace list` is enough when the ids carry their workspace ("w8K:p1" -> "w8K"); `pane list` is only the
57
+ * fallback for ids that don't. */
58
+ export async function herdrNamesAsync(ids) {
59
+ const workspaces = (await herdrListAsync(["workspace", "list"]))?.workspaces;
60
+ const labelOf = new Map((workspaces ?? []).map((w) => [w.workspace_id, w.label ?? ""]));
61
+ const out = new Map();
62
+ const rest = [];
63
+ for (const id of ids) {
64
+ const label = labelOf.get(id.split(":")[0]);
65
+ if (label)
66
+ out.set(id, label);
67
+ else
68
+ rest.push(id);
69
+ }
70
+ if (rest.length) {
71
+ const panes = (await herdrListAsync(["pane", "list"]))?.panes;
72
+ for (const p of panes ?? []) {
73
+ const label = p.workspace_id ? labelOf.get(p.workspace_id) : undefined;
74
+ if (label && rest.includes(p.pane_id))
75
+ out.set(p.pane_id, label);
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+ /** A `herdr agent get` result → owner alive. A timeout (busy daemon, slow herdr) must not read as a dead owner:
81
+ * only a clean non-zero exit means "not found". */
82
+ export function ownerAlive(r) {
83
+ if (r.error?.code === "ETIMEDOUT")
84
+ return true;
85
+ return r.status === 0;
86
+ }
15
87
  export function aliveOwner(text) {
16
88
  const id = field(text, "pane"), kind = field(text, "kind") || "herdr";
17
89
  if (kind === "herdr")
18
- return spawnSync("herdr", ["agent", "get", id], { stdio: "ignore" }).status === 0;
90
+ return ownerAlive(spawnSync("herdr", ["agent", "get", id], { stdio: "ignore", timeout: 15_000 }));
19
91
  const ttl = Number(process.env.ORQ_CLAIM_TTL_H ?? 24) * 3600_000;
20
92
  return Date.now() - Date.parse(field(text, "at")) < ttl;
21
93
  }
22
94
  export async function claim(argv) {
23
- let ws = process.cwd(), mode = "claim", id;
95
+ let ws = process.cwd(), mode = "claim", id, from;
24
96
  for (let i = 0; i < argv.length; i++) {
25
97
  const a = argv[i];
26
98
  if (a === "-w")
27
99
  ws = resolve(argv[++i]);
100
+ else if (a === "--from")
101
+ from = argv[++i];
28
102
  else if (a === "-f")
29
103
  mode = "force";
30
104
  else if (a === "-r")
31
105
  mode = "release";
32
106
  else if (a === "-l")
33
107
  mode = "list";
108
+ else if (a === "--register")
109
+ mode = "register";
34
110
  else if (!a.startsWith("-"))
35
111
  id = a;
36
112
  else {
@@ -40,21 +116,82 @@ export async function claim(argv) {
40
116
  }
41
117
  const O = join(ws, ".orquestra");
42
118
  if (mode === "list") {
43
- for (const d of existsSync(O) ? readdirSync(O) : []) {
119
+ const names = herdrNames(); // one herdr lookup for every issue in this listing, not one per issue
120
+ for (const d of existsSync(O) ? readdirSync(O).filter((x) => !x.startsWith(".")) : []) {
44
121
  const f = join(O, d, "owner");
45
122
  if (!existsSync(f))
46
123
  continue;
47
124
  const t = readFileSync(f, "utf8").trim();
48
- console.log(`${d} ${t} (${aliveOwner(t) ? "alive" : "dead"})`);
125
+ const alive = aliveOwner(t);
126
+ // The herdr workspace's current label while the pane is alive; the name stored at claim time otherwise
127
+ // (the user may have renamed the workspace since, or it may not be a herdr conductor at all).
128
+ const n = (alive && (field(t, "kind") || "herdr") === "herdr" ? names.get(field(t, "pane")) ?? "" : "") || nameOf(t);
129
+ console.log(`${d} ${n ? `"${n}" ` : ""}${t.replace(/ name=.*$/, "")} (${alive ? "alive" : "dead"})`);
49
130
  }
50
131
  return 0;
51
132
  }
133
+ const kind = process.env.HERDR_PANE_ID ? "herdr" : "id";
134
+ const me = process.env.HERDR_PANE_ID || process.env.ORQ_CONDUCTOR_ID || `${userInfo().username}@${hostname()}`;
135
+ const myName = (kind === "herdr" ? herdrName(me) : "") || process.env.ORQ_CONDUCTOR_ID || "";
136
+ const account = basename(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"));
137
+ const ownerLine = () => `pane=${me} kind=${kind} account=${account} at=${new Date().toISOString()}`
138
+ + (myName ? ` name=${myName}` : "") + "\n";
139
+ if (mode === "register") {
140
+ if (kind !== "herdr")
141
+ return 0;
142
+ const dir = join(O, ".conductors");
143
+ mkdirSync(dir, { recursive: true });
144
+ writeAtomic(join(dir, me.replace(/[^A-Za-z0-9_-]/g, "_")), `pane=${me} kind=herdr account=${account} at=${new Date().toISOString()} name=${myName || me}\n`);
145
+ return 0;
146
+ }
147
+ if (from !== undefined) {
148
+ if (!from) {
149
+ console.error(USAGE);
150
+ return 2;
151
+ }
152
+ const q = from.toLowerCase();
153
+ const liveNames = herdrNames(); // one herdr lookup for every issue being searched, not one per issue
154
+ const hits = (existsSync(O) ? readdirSync(O).filter((x) => !x.startsWith(".")) : []).flatMap((d) => {
155
+ const f = join(O, d, "owner");
156
+ if (!existsSync(f))
157
+ return [];
158
+ const t = readFileSync(f, "utf8").trim(), pane = field(t, "pane"), alive = aliveOwner(t);
159
+ const live = alive && (field(t, "kind") || "herdr") === "herdr" ? liveNames.get(pane) ?? "" : "";
160
+ const stored = nameOf(t), name = live || stored;
161
+ const names = [live, stored].filter(Boolean);
162
+ return pane !== me && (names.some((n) => n.toLowerCase().includes(q)) || pane.toLowerCase().includes(q))
163
+ ? [{ id: d, f, pane, name, alive }] : [];
164
+ });
165
+ if (!hits.length) {
166
+ console.log(m(`no issue owned by a conductor matching "${from}"`, `nenhuma issue de um conductor que bata com "${from}"`));
167
+ return 1;
168
+ }
169
+ const owners = [...new Set(hits.map((h) => h.pane))];
170
+ if (owners.length > 1) {
171
+ console.log(m(`"${from}" matches more than one conductor; be more specific:`, `"${from}" bate com mais de um conductor; seja mais específico:`));
172
+ for (const o of owners)
173
+ console.log(` ${hits.find((h) => h.pane === o).name || o} (${o})`);
174
+ return 3;
175
+ }
176
+ for (const h of hits)
177
+ console.log(`${h.id} "${h.name || h.pane}" (${h.alive ? "alive" : "dead"})`);
178
+ if (mode !== "force")
179
+ return 0;
180
+ await acquire("claims", process.pid, `claim --from ${from}`);
181
+ try {
182
+ for (const h of hits)
183
+ writeAtomic(h.f, ownerLine());
184
+ }
185
+ finally {
186
+ release("claims", process.pid);
187
+ }
188
+ console.log(m(`taken over: ${hits.map((h) => h.id).join(" ")} (now ${myName || me})`, `assumidas: ${hits.map((h) => h.id).join(" ")} (agora ${myName || me})`));
189
+ return 0;
190
+ }
52
191
  if (!id) {
53
192
  console.error(m("give the issue ID", "informe o ID da issue"));
54
193
  return 2;
55
194
  }
56
- const kind = process.env.HERDR_PANE_ID ? "herdr" : "id";
57
- const me = process.env.HERDR_PANE_ID || process.env.ORQ_CONDUCTOR_ID || `${userInfo().username}@${hostname()}`;
58
195
  mkdirSync(join(O, id), { recursive: true });
59
196
  const F = join(O, id, "owner");
60
197
  await acquire("claims", process.pid, `claim ${id}`); // one claim at a time
@@ -77,8 +214,7 @@ export async function claim(argv) {
77
214
  }
78
215
  console.log(m(`previous owner (${owner}) is gone; taking over ${id}`, `o dono anterior (${owner}) não existe mais; assumindo ${id}`));
79
216
  }
80
- const account = basename(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"));
81
- writeAtomic(F, `pane=${me} kind=${kind} account=${account} at=${new Date().toISOString()}\n`);
217
+ writeAtomic(F, ownerLine());
82
218
  console.log(cur && owner === me && mode === "claim" ? m(`${id} is already yours`, `${id} já é sua`) : m(`${id} is yours (${me})`, `${id} é sua (${me})`));
83
219
  return 0;
84
220
  }
@@ -8,10 +8,10 @@
8
8
  export const USAGE = "orq completion bash|zsh|fish|powershell print the shell completion script (see docs)";
9
9
  const DESC = {
10
10
  setup: "guided first configuration", config: "show the merged config", set: "write one config value", lang: "language of orq's messages",
11
- quota: "quota per agent/account", pick: "pick agent, model and account for a role", stats: "scoreboard per agent",
11
+ quota: "quota per agent/account", pick: "pick agent, model and account for a role", stats: "scoreboard per agent", rounds: "rounds, time and repeated findings of an issue",
12
12
  board: "live board", lock: "test queue", run: "run a command under a memory cap", agent: "run a child agent",
13
13
  wait: "wait for child agents", verify: "tests of a diff", baseline: "tests already failing on the base",
14
- instructions: "write the /issue playbook", claim: "issue owner among conductors", deps: "link node_modules into a worktree", gc: "remove merged issue worktrees",
14
+ instructions: "write the /issue playbook", claim: "issue owner among conductors", deps: "link node_modules into a worktree", gc: "remove merged issue worktrees", ci: "CI/CD status per claimed issue", handoff: "move a conductor low on quota to another account",
15
15
  block: "block an agent or account", completion: "shell completion script",
16
16
  };
17
17
  /** Fixed words after a subcommand. */
@@ -20,6 +20,7 @@ export const WORDS = {
20
20
  config: ["get", "files", "-w"], lang: ["pt", "en", "auto", "toggle"], lock: ["wait", "release", "run", "status"],
21
21
  pick: ["impl", "fix", "worker", "review", "opinion"], instructions: ["claude", "agents"], board: ["--once", "--json", "-w"],
22
22
  completion: ["bash", "zsh", "fish", "powershell"], stats: ["--days", "--json", "-w"], wait: ["--any", "--timeout", "--answer"],
23
+ ci: ["--json", "-w"], rounds: ["--json", "-w"],
23
24
  };
24
25
  const q = (s) => `'${s.replace(/'/g, "''")}'`; // PowerShell single quotes
25
26
  const qf = (s) => `'${s.replace(/[\\']/g, "\\$&")}'`; // fish single quotes