@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/commands/agent.js
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
// Returns at once; `orq wait <prefix>` blocks until .done. A conductor in herdr (HERDR_PANE_ID) also gets ONE line
|
|
5
5
|
// "[HERDR-AGENT DONE] <title> status=... answer: <prefix>.out.md" typed into its pane by herdr-notify.
|
|
6
6
|
// The launcher writes <prefix>.job.json and starts `orq agent --run <job>` on the terminal host; that runner does the rest.
|
|
7
|
+
// -T (or machine.tui / HERDR_AGENT_TUI=1): the agent's own screen (its `tui` template) in a herdr tab or tmux window,
|
|
8
|
+
// same cap and files. The agent calls `orq agent --turn <job> <kind>` at every end of turn, which writes .out.md/.id/
|
|
9
|
+
// .done and notifies; the screen stays open (holding memory) until you close it. -T -R with the screen still open types
|
|
10
|
+
// the follow-up into it; with the screen closed it reopens the session (tui_resume).
|
|
7
11
|
import { spawnSync } from "node:child_process";
|
|
8
12
|
import { randomUUID } from "node:crypto";
|
|
9
13
|
import { appendFileSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
@@ -11,14 +15,15 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
11
15
|
import { fileURLToPath } from "node:url";
|
|
12
16
|
import { claudeAccounts } from "../accounts.js";
|
|
13
17
|
import { agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../agents.js";
|
|
14
|
-
import { loadConfig } from "../config.js";
|
|
18
|
+
import { getPath, loadConfig } from "../config.js";
|
|
15
19
|
import { writeAtomic } from "../fsutil.js";
|
|
16
20
|
import { m } from "../i18n.js";
|
|
21
|
+
import { alive } from "../lock.js";
|
|
17
22
|
import { stateDir } from "../paths.js";
|
|
18
23
|
import { killTree, parseSize, resolveMode, runCapped } from "../process.js";
|
|
19
|
-
import { closeOwnTab, detectHost, launch, shq } from "../terminal.js";
|
|
24
|
+
import { closeOwnTab, detectHost, launch, screenOpen, shq, typeInto } from "../terminal.js";
|
|
20
25
|
import { dropWorktree, notify } from "../testrun.js";
|
|
21
|
-
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] -- <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";
|
|
22
27
|
const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
23
28
|
const PF = ".herdr-agent-prompt.md"; // prompt_in_dir file name (kept from herdr-agent)
|
|
24
29
|
const git = (dir, ...a) => {
|
|
@@ -28,8 +33,10 @@ const git = (dir, ...a) => {
|
|
|
28
33
|
export async function agent(argv) {
|
|
29
34
|
if (argv[0] === "--run")
|
|
30
35
|
return runJob(argv[1]);
|
|
36
|
+
if (argv[0] === "--turn")
|
|
37
|
+
return turn(argv[1], argv[2], argv.slice(3));
|
|
31
38
|
let a = process.env.AGENT || "codex", g = "", t = "", d = "", o = "", model = "", effort = "", acc = "", resume = "", base = "";
|
|
32
|
-
let ro = false, prompt;
|
|
39
|
+
let ro = false, tui = false, force = false, prompt;
|
|
33
40
|
const inc = [];
|
|
34
41
|
for (let i = 0; i < argv.length; i++) {
|
|
35
42
|
const x = argv[i], v = () => argv[++i] ?? "";
|
|
@@ -63,10 +70,10 @@ export async function agent(argv) {
|
|
|
63
70
|
v();
|
|
64
71
|
else if (x === "-r")
|
|
65
72
|
ro = true;
|
|
66
|
-
else if (x === "-T")
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
else if (x === "-T")
|
|
74
|
+
tui = true;
|
|
75
|
+
else if (x === "-F")
|
|
76
|
+
force = true;
|
|
70
77
|
else if (!x.startsWith("-") && prompt === undefined)
|
|
71
78
|
prompt = x;
|
|
72
79
|
else {
|
|
@@ -94,11 +101,44 @@ export async function agent(argv) {
|
|
|
94
101
|
console.error(m(`-c only applies to agents with Claude accounts (claude)`, `-c só vale para agentes com contas Claude (claude)`));
|
|
95
102
|
return 2;
|
|
96
103
|
}
|
|
97
|
-
|
|
104
|
+
const mach = (cfg.machine ?? {});
|
|
105
|
+
const host = detectHost(cfg);
|
|
106
|
+
// Screen by default (machine.tui, HERDR_AGENT_TUI=1) only where it can be: a terminal host and an agent with a screen.
|
|
107
|
+
if (!tui && (process.env.HERDR_AGENT_TUI === "1" || mach.tui === true) && host !== "plain" && def.tui && (!resume || def.tui_resume))
|
|
108
|
+
tui = true;
|
|
109
|
+
if (tui && !def.tui) {
|
|
110
|
+
console.error(m(`-T: ${a} has no screen mode (agents.${a}.tui)`, `-T: ${a} não tem modo tela (agents.${a}.tui)`));
|
|
111
|
+
return 2;
|
|
112
|
+
}
|
|
113
|
+
if (resume && !tui && !def.resume) {
|
|
98
114
|
console.error(m(`${a} has no resume template (agents.${a}.resume)`, `${a} não tem template de resume (agents.${a}.resume)`));
|
|
99
115
|
return 2;
|
|
100
116
|
}
|
|
117
|
+
if (tui && resume && !def.tui_resume) {
|
|
118
|
+
console.error(m(`-T -R: ${a} has no tui_resume template`, `-T -R: ${a} não tem template tui_resume`));
|
|
119
|
+
return 2;
|
|
120
|
+
}
|
|
121
|
+
if (tui && host === "plain") {
|
|
122
|
+
console.error(m("-T shows the agent's own screen: it needs herdr or tmux (run inside one, or set ORQ_TERMINAL)", "-T mostra a tela do próprio agente: precisa do herdr ou do tmux (rode dentro de um, ou defina ORQ_TERMINAL)"));
|
|
123
|
+
return 2;
|
|
124
|
+
}
|
|
101
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
|
+
}
|
|
102
142
|
let P = resolve(prompt);
|
|
103
143
|
if (!existsSync(repo)) {
|
|
104
144
|
console.error(m(`no folder ${repo}`, `pasta ${repo} não existe`));
|
|
@@ -119,9 +159,6 @@ export async function agent(argv) {
|
|
|
119
159
|
writeFileSync(`${O}.diff`, r.stdout);
|
|
120
160
|
inc.push(`${O}.diff`);
|
|
121
161
|
}
|
|
122
|
-
// Follow-ups (-R) reuse the prefix: remove the previous round's result so ".done exists" means "this one finished".
|
|
123
|
-
for (const s of [".done", ".out.md", ".pid"])
|
|
124
|
-
rmSync(O + s, { force: true });
|
|
125
162
|
if (inc.length) {
|
|
126
163
|
let text = readFileSync(P, "utf8");
|
|
127
164
|
for (const f of inc)
|
|
@@ -129,6 +166,24 @@ export async function agent(argv) {
|
|
|
129
166
|
writeFileSync(`${O}.prompt.md`, text);
|
|
130
167
|
P = `${O}.prompt.md`;
|
|
131
168
|
}
|
|
169
|
+
// Follow-ups (-R) reuse the prefix: remove the previous round's result so ".done exists" means "this one finished".
|
|
170
|
+
// -T -R with the screen still open: the follow-up is typed into it (its runner, and .pid, carry on). Open = the agent
|
|
171
|
+
// is still running there, not just the pane: after it exits the pane may hold a shell, which must never get the text.
|
|
172
|
+
let open = "", pid = 0;
|
|
173
|
+
try {
|
|
174
|
+
open = readFileSync(`${O}.pane`, "utf8").trim();
|
|
175
|
+
pid = Number(readFileSync(`${O}.pid`, "utf8"));
|
|
176
|
+
}
|
|
177
|
+
catch { /* first run or ended */ }
|
|
178
|
+
if (tui && resume && alive(pid) && screenOpen(open)) {
|
|
179
|
+
for (const s of [".done", ".out.md"])
|
|
180
|
+
rmSync(O + s, { force: true });
|
|
181
|
+
typeInto(open, readFileSync(P, "utf8"));
|
|
182
|
+
console.log(`[orq agent] ${t} → ${open} ${m("(follow-up typed into the open screen)", "(follow-up digitado na tela aberta)")}`);
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
for (const s of [".done", ".out.md", ".pid"])
|
|
186
|
+
rmSync(O + s, { force: true });
|
|
132
187
|
const throwaway = Boolean(def.throwaway && ro && !resume);
|
|
133
188
|
const dir = throwaway ? `${repo}.review-${process.pid}` : repo;
|
|
134
189
|
const gd = git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir");
|
|
@@ -140,22 +195,36 @@ export async function agent(argv) {
|
|
|
140
195
|
}
|
|
141
196
|
catch { /* none */ }
|
|
142
197
|
const session = resume || (def.session === "uuid" ? randomUUID() : "");
|
|
198
|
+
const tpl = tui ? (resume ? def.tui_resume : def.tui) : resume ? def.resume : def.run;
|
|
199
|
+
// End of turn (-T): the agent runs `orq agent --turn <job> <kind>`; Claude gets it as a Stop hook, Codex as notify,
|
|
200
|
+
// OpenCode through a plugin orq writes next to the job.
|
|
201
|
+
const hook = [process.execPath, CLI, "agent", "--turn", `${O}.job.json`, def.turn_hook ?? "text"];
|
|
202
|
+
// OpenCode v2 drops a partial MCP entry without a word: each one turned off needs its type and command too.
|
|
203
|
+
const off = { type: "local", command: ["true"], enabled: false };
|
|
204
|
+
const ocCfg = {
|
|
205
|
+
mcp: { plane: off, shadcn: off, "linear-server": off },
|
|
206
|
+
permission: { bash: "allow", question: "deny", webfetch: "deny",
|
|
207
|
+
external_directory: { "*": "deny", ...(gd ? { [`${gd}/*`]: "allow" } : {}), "/tmp/*": "allow", ...(nm ? { [`${nm}/*`]: "allow" } : {}) },
|
|
208
|
+
edit: ro ? "deny" : "allow" },
|
|
209
|
+
};
|
|
210
|
+
if (tui && def.turn_hook === "opencode") {
|
|
211
|
+
mkdirSync(`${O}.opencode`, { recursive: true }); // a v2 plugin is a folder with index.js
|
|
212
|
+
writeFileSync(join(`${O}.opencode`, "index.js"), opencodePlugin(O, hook));
|
|
213
|
+
ocCfg.plugin = [`${O}.opencode`];
|
|
214
|
+
if (model && model !== "auto")
|
|
215
|
+
ocCfg.model = model; // the screen has no -m
|
|
216
|
+
}
|
|
143
217
|
const vars = {
|
|
144
|
-
prompt: P, prompt_text:
|
|
218
|
+
prompt: P, prompt_text: tpl.includes("{prompt_text}") ? readFileSync(P, "utf8") : "",
|
|
145
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.`,
|
|
146
|
-
out: `${O}.out.md`, dir, repo, title: t, model: model === "auto" ? "" : model, effort, session,
|
|
147
|
-
gitdir: gd ?? "", gitroots: roots, gitroots_json: JSON.stringify(roots), mode: [],
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
permission: { bash: "allow", question: "deny", webfetch: "deny",
|
|
151
|
-
external_directory: { "*": "deny", ...(gd ? { [`${gd}/*`]: "allow" } : {}), "/tmp/*": "allow", ...(nm ? { [`${nm}/*`]: "allow" } : {}) },
|
|
152
|
-
edit: ro ? "deny" : "allow" },
|
|
153
|
-
}),
|
|
220
|
+
out: `${O}.out.md`, dir, repo, base, title: t, model: model === "auto" ? "" : model, effort, session,
|
|
221
|
+
gitdir: gd ?? "", gitroots: roots, gitroots_json: JSON.stringify(roots), mode: [], opencode_config: JSON.stringify(ocCfg),
|
|
222
|
+
hook_cmd: hook.map(shq).join(" "), hook_json: JSON.stringify(hook),
|
|
223
|
+
hook_settings: JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command: hook.map(shq).join(" ") }] }] } }),
|
|
154
224
|
};
|
|
155
225
|
vars.mode = expandTemplate((ro ? def.readonly : def.write) || "", vars);
|
|
156
|
-
const tpl = resume ? def.resume : def.run;
|
|
157
226
|
const cmd = expandTemplate(tpl, vars);
|
|
158
|
-
if (!tpl.includes("{mode}") && !resume)
|
|
227
|
+
if (!tpl.includes("{mode}") && !resume && !tui)
|
|
159
228
|
cmd.push(...vars.mode);
|
|
160
229
|
const env = {};
|
|
161
230
|
for (const [k, val] of Object.entries(def.env ?? {}))
|
|
@@ -168,9 +237,7 @@ export async function agent(argv) {
|
|
|
168
237
|
}
|
|
169
238
|
env.CLAUDE_CONFIG_DIR = ac.dir;
|
|
170
239
|
}
|
|
171
|
-
const mach = (cfg.machine ?? {});
|
|
172
240
|
const closeAfter = Number(process.env.HERDR_CLOSE_DELAY ?? mach.close_tab_after ?? 20);
|
|
173
|
-
const host = detectHost(cfg);
|
|
174
241
|
const unit = `herdr-agent-${Math.floor(Date.now() / 1000)}-${process.pid}`; // herdr-board lists herdr-agent-* scopes
|
|
175
242
|
const job = {
|
|
176
243
|
agent: a, title: t, repo, dir, prefix: O, prompt: P, argv: cmd, env, unset: def.unset_env ?? [], stdin: Boolean(def.stdin),
|
|
@@ -179,7 +246,8 @@ export async function agent(argv) {
|
|
|
179
246
|
host, unit, cap: parseSize(def.mem_max ?? mach.agent_mem_max ?? "off"),
|
|
180
247
|
swap: parseSize(mach.agent_swap_max), mode: mach.mem_enforce ?? "auto",
|
|
181
248
|
throwaway, promptInDir: Boolean(def.prompt_in_dir), closeAfter,
|
|
182
|
-
keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0,
|
|
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,
|
|
183
251
|
};
|
|
184
252
|
writeAtomic(`${O}.job.json`, JSON.stringify(job, null, 1));
|
|
185
253
|
// Readable copy of the command (also what orq stats and herdr-board read).
|
|
@@ -187,9 +255,124 @@ export async function agent(argv) {
|
|
|
187
255
|
`cd ${shq(dir)}\n${Object.entries(env).map(([k, val]) => `${k}=${shq(val)} `).join("")}${cmd.map(shq).join(" ")}${job.stdin ? ` < ${shq(P)}` : ""}\n`);
|
|
188
256
|
const where = launch(host, { group: g || "agents", title: t, dir: repo, argv: [process.execPath, CLI, "agent", "--run", `${O}.job.json`], id: unit, keep: job.keep });
|
|
189
257
|
writeFileSync(`${O}.pane`, `${where}\n`);
|
|
190
|
-
console.log(`[orq agent] ${t} → ${where}`);
|
|
258
|
+
console.log(`[orq agent] ${t} → ${where}${tui ? " (TUI)" : ""}`);
|
|
191
259
|
return 0;
|
|
192
260
|
}
|
|
261
|
+
/** OpenCode (v2) plugin for -T: when the main session's run ends, its last answer goes to `orq agent --turn`. */
|
|
262
|
+
function opencodePlugin(O, hook) {
|
|
263
|
+
return `// orq agent -T end of turn (generated)
|
|
264
|
+
import { writeFileSync } from "fs"; import { spawn } from "child_process";
|
|
265
|
+
export default {
|
|
266
|
+
id: "orq-turn",
|
|
267
|
+
setup: async (ctx) => {
|
|
268
|
+
const sub = new Set(), text = new Map(); // child sessions (subagents); answer text per session
|
|
269
|
+
(async () => {
|
|
270
|
+
for await (const e of ctx.event.subscribe()) {
|
|
271
|
+
const d = e.data ?? {}, id = d.sessionID;
|
|
272
|
+
if (e.type === "session.created" && d.parentID) sub.add(id);
|
|
273
|
+
if (!id || sub.has(id)) continue;
|
|
274
|
+
if (e.type === "session.execution.started") text.set(id, { msg: "", parts: [] });
|
|
275
|
+
else if (e.type === "session.text.ended") {
|
|
276
|
+
const t = text.get(id) ?? { msg: "", parts: [] };
|
|
277
|
+
if (t.msg !== d.assistantMessageID) { t.msg = d.assistantMessageID; t.parts = []; } // only the last message
|
|
278
|
+
t.parts[d.ordinal ?? t.parts.length] = d.text ?? ""; text.set(id, t);
|
|
279
|
+
} else if (e.type.startsWith("session.execution.") && e.type !== "session.execution.started") {
|
|
280
|
+
const f = ${JSON.stringify(`${O}.turn.md`)};
|
|
281
|
+
writeFileSync(f, (text.get(id)?.parts ?? []).filter((x) => x !== undefined).join("\\n"));
|
|
282
|
+
spawn(${JSON.stringify(hook[0])}, [...${JSON.stringify(hook.slice(1))}, id, f], { detached: true, stdio: "ignore" }).unref();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
})().catch(() => {});
|
|
286
|
+
return {};
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
`;
|
|
290
|
+
}
|
|
291
|
+
/** End of a turn on the agent's screen: answer, session and .done from the agent's own hook payload. Never fails the agent. */
|
|
292
|
+
function turn(file, kind, rest) {
|
|
293
|
+
try {
|
|
294
|
+
const j = JSON.parse(readFileSync(file, "utf8")), O = j.prefix;
|
|
295
|
+
let answer = "", sid;
|
|
296
|
+
if (kind === "codex") {
|
|
297
|
+
const e = JSON.parse(rest[rest.length - 1] ?? "{}");
|
|
298
|
+
if (e.type !== "agent-turn-complete")
|
|
299
|
+
return 0;
|
|
300
|
+
answer = String(e["last-assistant-message"] ?? "");
|
|
301
|
+
sid = e["thread-id"];
|
|
302
|
+
}
|
|
303
|
+
else if (kind === "claude") {
|
|
304
|
+
const e = JSON.parse(readFileSync(0, "utf8"));
|
|
305
|
+
if (e.stop_hook_active)
|
|
306
|
+
return 0;
|
|
307
|
+
sid = e.session_id;
|
|
308
|
+
answer = String(e.last_assistant_message ?? "") || lastClaudeText(String(e.transcript_path ?? ""));
|
|
309
|
+
}
|
|
310
|
+
else if (kind === "opencode") {
|
|
311
|
+
sid = rest[0];
|
|
312
|
+
try {
|
|
313
|
+
answer = readFileSync(rest[1], "utf8");
|
|
314
|
+
rmSync(rest[1], { force: true });
|
|
315
|
+
}
|
|
316
|
+
catch { /* none */ }
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
sid = rest[0];
|
|
320
|
+
answer = readFileSync(0, "utf8");
|
|
321
|
+
}
|
|
322
|
+
writeFileSync(`${O}.out.md`, answer);
|
|
323
|
+
if (sid)
|
|
324
|
+
writeFileSync(`${O}.id`, `${sid}\n`);
|
|
325
|
+
const noReview = noReviewMatch(j.noReview, answer);
|
|
326
|
+
const st = /\S/.test(answer) && !noReview ? "ok" : "fail";
|
|
327
|
+
writeAtomic(`${O}.done`, `status=${st} rc=0 at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
|
|
328
|
+
if (j.parent)
|
|
329
|
+
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md`, true);
|
|
330
|
+
}
|
|
331
|
+
catch (e) {
|
|
332
|
+
console.error(`orq agent --turn: ${e.message}`);
|
|
333
|
+
}
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
/** The last text Claude wrote, from its transcript (older Stop hooks have no last_assistant_message). */
|
|
337
|
+
function lastClaudeText(transcript) {
|
|
338
|
+
let text = "";
|
|
339
|
+
try {
|
|
340
|
+
for (const l of readFileSync(transcript, "utf8").split(/\r?\n/)) {
|
|
341
|
+
try {
|
|
342
|
+
const e = JSON.parse(l);
|
|
343
|
+
if (e.type !== "assistant")
|
|
344
|
+
continue;
|
|
345
|
+
const t = (e.message?.content ?? []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
|
|
346
|
+
if (t)
|
|
347
|
+
text = t;
|
|
348
|
+
}
|
|
349
|
+
catch { /* not JSON */ }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
catch { /* no transcript */ }
|
|
353
|
+
return text;
|
|
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
|
+
}
|
|
193
376
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
194
377
|
/** Splits a stream into lines for `each`. */
|
|
195
378
|
function lines(s, each) {
|
|
@@ -218,7 +401,6 @@ async function runJob(file) {
|
|
|
218
401
|
catch { /* best effort */ } };
|
|
219
402
|
state();
|
|
220
403
|
const fmt = FORMATS[j.format] ?? FORMATS.text;
|
|
221
|
-
const log = createWriteStream(`${O}.log`);
|
|
222
404
|
let rc = 0, stdout = "", logText = "";
|
|
223
405
|
const pre = () => {
|
|
224
406
|
if (j.throwaway) {
|
|
@@ -254,6 +436,51 @@ async function runJob(file) {
|
|
|
254
436
|
if (j.throwaway)
|
|
255
437
|
dropWorktree(j.repo, j.dir);
|
|
256
438
|
};
|
|
439
|
+
if (j.tui) {
|
|
440
|
+
// The agent's own screen: the terminal is its; answers come from the end-of-turn hook (`--turn`).
|
|
441
|
+
let ended = false;
|
|
442
|
+
const end = (code) => {
|
|
443
|
+
if (ended)
|
|
444
|
+
return "fail";
|
|
445
|
+
ended = true;
|
|
446
|
+
post();
|
|
447
|
+
// Closed before any turn ended (or mid follow-up): a failure, so `orq wait` doesn't hang.
|
|
448
|
+
const ok = /\bstatus=ok\b/.test(existsSync(`${O}.done`) ? readFileSync(`${O}.done`, "utf8") : "");
|
|
449
|
+
if (!existsSync(`${O}.done`)) {
|
|
450
|
+
writeAtomic(`${O}.done`, `status=fail rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
|
|
451
|
+
if (j.parent)
|
|
452
|
+
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`, true);
|
|
453
|
+
}
|
|
454
|
+
rmSync(`${O}.pid`, { force: true });
|
|
455
|
+
return ok ? "ok" : "fail";
|
|
456
|
+
};
|
|
457
|
+
if (!pre())
|
|
458
|
+
rc = 2;
|
|
459
|
+
else {
|
|
460
|
+
const env = { ...process.env, ...j.env };
|
|
461
|
+
for (const k of j.unset)
|
|
462
|
+
delete env[k];
|
|
463
|
+
// Ctrl+C belongs to the agent (it reaches us too, as part of the terminal's foreground group). The screen going
|
|
464
|
+
// away (tab or tmux server closed) still ends the agent and cleans up.
|
|
465
|
+
let child;
|
|
466
|
+
process.on("SIGINT", () => { });
|
|
467
|
+
const gone = () => { if (child)
|
|
468
|
+
killTree(child, false); end(129); process.exit(0); };
|
|
469
|
+
for (const sig of ["SIGHUP", "SIGTERM"])
|
|
470
|
+
process.on(sig, gone);
|
|
471
|
+
rc = await runCapped(j.argv[0], j.argv.slice(1), { cap: j.cap, swap: j.swap, mode: j.mode, unit: j.unit, cwd: j.dir, env, quote: true, tty: true,
|
|
472
|
+
onSpawn: (c) => { child = c.pid; } });
|
|
473
|
+
}
|
|
474
|
+
const st = end(rc);
|
|
475
|
+
state({ status: st, rc, end: new Date().toISOString() });
|
|
476
|
+
console.log(`\n[${j.agent} (TUI) ${m("exited", "encerrado")}] ${m("answer", "resposta")}: ${O}.out.md`);
|
|
477
|
+
if (!j.keep) {
|
|
478
|
+
await sleep(j.closeAfter * 1000);
|
|
479
|
+
closeOwnTab(j.host);
|
|
480
|
+
}
|
|
481
|
+
return 0;
|
|
482
|
+
}
|
|
483
|
+
const log = createWriteStream(`${O}.log`);
|
|
257
484
|
if (!pre())
|
|
258
485
|
rc = 2;
|
|
259
486
|
else {
|
|
@@ -314,12 +541,26 @@ async function runJob(file) {
|
|
|
314
541
|
writeFileSync(`${O}.id`, `${sid}\n`);
|
|
315
542
|
if (rc === 0 && res.rc)
|
|
316
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
|
+
}
|
|
317
555
|
let answer = "";
|
|
318
556
|
try {
|
|
319
557
|
answer = readFileSync(`${O}.out.md`, "utf8");
|
|
320
558
|
}
|
|
321
559
|
catch { /* none */ }
|
|
322
|
-
|
|
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
|
|
323
564
|
writeAtomic(`${O}.done`, `status=${st} rc=${rc} at=${new Date().toISOString()} agent=${j.agent}\n`);
|
|
324
565
|
rmSync(`${O}.pid`, { force: true });
|
|
325
566
|
state({ status: st, rc, end: new Date().toISOString() });
|
package/dist/commands/block.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/commands/board.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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] ? {
|
|
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("
|
|
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;
|