@irythill/orq 0.1.0 → 0.3.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/LICENSE +21 -21
- package/README.md +241 -240
- package/README.pt-BR.md +242 -241
- package/dist/agents.js +12 -0
- package/dist/cli.js +24 -22
- package/dist/commands/agent.js +212 -26
- package/dist/commands/block.js +2 -2
- package/dist/commands/board.js +1 -1
- package/dist/commands/claim.js +1 -1
- package/dist/commands/completion.js +43 -43
- package/dist/commands/config.js +2 -2
- package/dist/commands/gc.js +164 -0
- package/dist/commands/instructions.js +7 -7
- package/dist/commands/lock.js +1 -1
- package/dist/commands/pick.js +23 -3
- package/dist/commands/quota.js +32 -2
- package/dist/commands/setup.js +3 -3
- package/dist/config.js +1 -1
- package/dist/pick.js +11 -0
- package/dist/process.js +19 -11
- package/dist/quota.js +67 -9
- package/dist/terminal.js +26 -0
- package/dist/testrun.js +9 -5
- package/package.json +48 -27
- package/skills/issue/SKILL.md +229 -197
- package/skills/orq-setup/SKILL.md +114 -114
- package/skills/orq-status/SKILL.md +37 -37
- package/templates/workspace.CLAUDE.md +49 -49
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";
|
|
@@ -14,11 +18,12 @@ import { agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../age
|
|
|
14
18
|
import { 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] [-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, 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,8 @@ 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
|
-
return 2;
|
|
69
|
-
}
|
|
73
|
+
else if (x === "-T")
|
|
74
|
+
tui = true;
|
|
70
75
|
else if (!x.startsWith("-") && prompt === undefined)
|
|
71
76
|
prompt = x;
|
|
72
77
|
else {
|
|
@@ -94,10 +99,27 @@ export async function agent(argv) {
|
|
|
94
99
|
console.error(m(`-c only applies to agents with Claude accounts (claude)`, `-c só vale para agentes com contas Claude (claude)`));
|
|
95
100
|
return 2;
|
|
96
101
|
}
|
|
97
|
-
|
|
102
|
+
const mach = (cfg.machine ?? {});
|
|
103
|
+
const host = detectHost(cfg);
|
|
104
|
+
// Screen by default (machine.tui, HERDR_AGENT_TUI=1) only where it can be: a terminal host and an agent with a screen.
|
|
105
|
+
if (!tui && (process.env.HERDR_AGENT_TUI === "1" || mach.tui === true) && host !== "plain" && def.tui && (!resume || def.tui_resume))
|
|
106
|
+
tui = true;
|
|
107
|
+
if (tui && !def.tui) {
|
|
108
|
+
console.error(m(`-T: ${a} has no screen mode (agents.${a}.tui)`, `-T: ${a} não tem modo tela (agents.${a}.tui)`));
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
if (resume && !tui && !def.resume) {
|
|
98
112
|
console.error(m(`${a} has no resume template (agents.${a}.resume)`, `${a} não tem template de resume (agents.${a}.resume)`));
|
|
99
113
|
return 2;
|
|
100
114
|
}
|
|
115
|
+
if (tui && resume && !def.tui_resume) {
|
|
116
|
+
console.error(m(`-T -R: ${a} has no tui_resume template`, `-T -R: ${a} não tem template tui_resume`));
|
|
117
|
+
return 2;
|
|
118
|
+
}
|
|
119
|
+
if (tui && host === "plain") {
|
|
120
|
+
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)"));
|
|
121
|
+
return 2;
|
|
122
|
+
}
|
|
101
123
|
const repo = resolve(d), O = resolve(o);
|
|
102
124
|
let P = resolve(prompt);
|
|
103
125
|
if (!existsSync(repo)) {
|
|
@@ -119,9 +141,6 @@ export async function agent(argv) {
|
|
|
119
141
|
writeFileSync(`${O}.diff`, r.stdout);
|
|
120
142
|
inc.push(`${O}.diff`);
|
|
121
143
|
}
|
|
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
144
|
if (inc.length) {
|
|
126
145
|
let text = readFileSync(P, "utf8");
|
|
127
146
|
for (const f of inc)
|
|
@@ -129,6 +148,24 @@ export async function agent(argv) {
|
|
|
129
148
|
writeFileSync(`${O}.prompt.md`, text);
|
|
130
149
|
P = `${O}.prompt.md`;
|
|
131
150
|
}
|
|
151
|
+
// Follow-ups (-R) reuse the prefix: remove the previous round's result so ".done exists" means "this one finished".
|
|
152
|
+
// -T -R with the screen still open: the follow-up is typed into it (its runner, and .pid, carry on). Open = the agent
|
|
153
|
+
// is still running there, not just the pane: after it exits the pane may hold a shell, which must never get the text.
|
|
154
|
+
let open = "", pid = 0;
|
|
155
|
+
try {
|
|
156
|
+
open = readFileSync(`${O}.pane`, "utf8").trim();
|
|
157
|
+
pid = Number(readFileSync(`${O}.pid`, "utf8"));
|
|
158
|
+
}
|
|
159
|
+
catch { /* first run or ended */ }
|
|
160
|
+
if (tui && resume && alive(pid) && screenOpen(open)) {
|
|
161
|
+
for (const s of [".done", ".out.md"])
|
|
162
|
+
rmSync(O + s, { force: true });
|
|
163
|
+
typeInto(open, readFileSync(P, "utf8"));
|
|
164
|
+
console.log(`[orq agent] ${t} → ${open} ${m("(follow-up typed into the open screen)", "(follow-up digitado na tela aberta)")}`);
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
for (const s of [".done", ".out.md", ".pid"])
|
|
168
|
+
rmSync(O + s, { force: true });
|
|
132
169
|
const throwaway = Boolean(def.throwaway && ro && !resume);
|
|
133
170
|
const dir = throwaway ? `${repo}.review-${process.pid}` : repo;
|
|
134
171
|
const gd = git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir");
|
|
@@ -140,22 +177,36 @@ export async function agent(argv) {
|
|
|
140
177
|
}
|
|
141
178
|
catch { /* none */ }
|
|
142
179
|
const session = resume || (def.session === "uuid" ? randomUUID() : "");
|
|
180
|
+
const tpl = tui ? (resume ? def.tui_resume : def.tui) : resume ? def.resume : def.run;
|
|
181
|
+
// End of turn (-T): the agent runs `orq agent --turn <job> <kind>`; Claude gets it as a Stop hook, Codex as notify,
|
|
182
|
+
// OpenCode through a plugin orq writes next to the job.
|
|
183
|
+
const hook = [process.execPath, CLI, "agent", "--turn", `${O}.job.json`, def.turn_hook ?? "text"];
|
|
184
|
+
// OpenCode v2 drops a partial MCP entry without a word: each one turned off needs its type and command too.
|
|
185
|
+
const off = { type: "local", command: ["true"], enabled: false };
|
|
186
|
+
const ocCfg = {
|
|
187
|
+
mcp: { plane: off, shadcn: off, "linear-server": off },
|
|
188
|
+
permission: { bash: "allow", question: "deny", webfetch: "deny",
|
|
189
|
+
external_directory: { "*": "deny", ...(gd ? { [`${gd}/*`]: "allow" } : {}), "/tmp/*": "allow", ...(nm ? { [`${nm}/*`]: "allow" } : {}) },
|
|
190
|
+
edit: ro ? "deny" : "allow" },
|
|
191
|
+
};
|
|
192
|
+
if (tui && def.turn_hook === "opencode") {
|
|
193
|
+
mkdirSync(`${O}.opencode`, { recursive: true }); // a v2 plugin is a folder with index.js
|
|
194
|
+
writeFileSync(join(`${O}.opencode`, "index.js"), opencodePlugin(O, hook));
|
|
195
|
+
ocCfg.plugin = [`${O}.opencode`];
|
|
196
|
+
if (model && model !== "auto")
|
|
197
|
+
ocCfg.model = model; // the screen has no -m
|
|
198
|
+
}
|
|
143
199
|
const vars = {
|
|
144
|
-
prompt: P, prompt_text:
|
|
200
|
+
prompt: P, prompt_text: tpl.includes("{prompt_text}") ? readFileSync(P, "utf8") : "",
|
|
145
201
|
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
202
|
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
|
-
}),
|
|
203
|
+
gitdir: gd ?? "", gitroots: roots, gitroots_json: JSON.stringify(roots), mode: [], opencode_config: JSON.stringify(ocCfg),
|
|
204
|
+
hook_cmd: hook.map(shq).join(" "), hook_json: JSON.stringify(hook),
|
|
205
|
+
hook_settings: JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command: hook.map(shq).join(" ") }] }] } }),
|
|
154
206
|
};
|
|
155
207
|
vars.mode = expandTemplate((ro ? def.readonly : def.write) || "", vars);
|
|
156
|
-
const tpl = resume ? def.resume : def.run;
|
|
157
208
|
const cmd = expandTemplate(tpl, vars);
|
|
158
|
-
if (!tpl.includes("{mode}") && !resume)
|
|
209
|
+
if (!tpl.includes("{mode}") && !resume && !tui)
|
|
159
210
|
cmd.push(...vars.mode);
|
|
160
211
|
const env = {};
|
|
161
212
|
for (const [k, val] of Object.entries(def.env ?? {}))
|
|
@@ -168,9 +219,7 @@ export async function agent(argv) {
|
|
|
168
219
|
}
|
|
169
220
|
env.CLAUDE_CONFIG_DIR = ac.dir;
|
|
170
221
|
}
|
|
171
|
-
const mach = (cfg.machine ?? {});
|
|
172
222
|
const closeAfter = Number(process.env.HERDR_CLOSE_DELAY ?? mach.close_tab_after ?? 20);
|
|
173
|
-
const host = detectHost(cfg);
|
|
174
223
|
const unit = `herdr-agent-${Math.floor(Date.now() / 1000)}-${process.pid}`; // herdr-board lists herdr-agent-* scopes
|
|
175
224
|
const job = {
|
|
176
225
|
agent: a, title: t, repo, dir, prefix: O, prompt: P, argv: cmd, env, unset: def.unset_env ?? [], stdin: Boolean(def.stdin),
|
|
@@ -179,7 +228,7 @@ export async function agent(argv) {
|
|
|
179
228
|
host, unit, cap: parseSize(def.mem_max ?? mach.agent_mem_max ?? "off"),
|
|
180
229
|
swap: parseSize(mach.agent_swap_max), mode: mach.mem_enforce ?? "auto",
|
|
181
230
|
throwaway, promptInDir: Boolean(def.prompt_in_dir), closeAfter,
|
|
182
|
-
keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0,
|
|
231
|
+
keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0, tui,
|
|
183
232
|
};
|
|
184
233
|
writeAtomic(`${O}.job.json`, JSON.stringify(job, null, 1));
|
|
185
234
|
// Readable copy of the command (also what orq stats and herdr-board read).
|
|
@@ -187,9 +236,102 @@ export async function agent(argv) {
|
|
|
187
236
|
`cd ${shq(dir)}\n${Object.entries(env).map(([k, val]) => `${k}=${shq(val)} `).join("")}${cmd.map(shq).join(" ")}${job.stdin ? ` < ${shq(P)}` : ""}\n`);
|
|
188
237
|
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
238
|
writeFileSync(`${O}.pane`, `${where}\n`);
|
|
190
|
-
console.log(`[orq agent] ${t} → ${where}`);
|
|
239
|
+
console.log(`[orq agent] ${t} → ${where}${tui ? " (TUI)" : ""}`);
|
|
191
240
|
return 0;
|
|
192
241
|
}
|
|
242
|
+
/** OpenCode (v2) plugin for -T: when the main session's run ends, its last answer goes to `orq agent --turn`. */
|
|
243
|
+
function opencodePlugin(O, hook) {
|
|
244
|
+
return `// orq agent -T end of turn (generated)
|
|
245
|
+
import { writeFileSync } from "fs"; import { spawn } from "child_process";
|
|
246
|
+
export default {
|
|
247
|
+
id: "orq-turn",
|
|
248
|
+
setup: async (ctx) => {
|
|
249
|
+
const sub = new Set(), text = new Map(); // child sessions (subagents); answer text per session
|
|
250
|
+
(async () => {
|
|
251
|
+
for await (const e of ctx.event.subscribe()) {
|
|
252
|
+
const d = e.data ?? {}, id = d.sessionID;
|
|
253
|
+
if (e.type === "session.created" && d.parentID) sub.add(id);
|
|
254
|
+
if (!id || sub.has(id)) continue;
|
|
255
|
+
if (e.type === "session.execution.started") text.set(id, { msg: "", parts: [] });
|
|
256
|
+
else if (e.type === "session.text.ended") {
|
|
257
|
+
const t = text.get(id) ?? { msg: "", parts: [] };
|
|
258
|
+
if (t.msg !== d.assistantMessageID) { t.msg = d.assistantMessageID; t.parts = []; } // only the last message
|
|
259
|
+
t.parts[d.ordinal ?? t.parts.length] = d.text ?? ""; text.set(id, t);
|
|
260
|
+
} else if (e.type.startsWith("session.execution.") && e.type !== "session.execution.started") {
|
|
261
|
+
const f = ${JSON.stringify(`${O}.turn.md`)};
|
|
262
|
+
writeFileSync(f, (text.get(id)?.parts ?? []).filter((x) => x !== undefined).join("\\n"));
|
|
263
|
+
spawn(${JSON.stringify(hook[0])}, [...${JSON.stringify(hook.slice(1))}, id, f], { detached: true, stdio: "ignore" }).unref();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
})().catch(() => {});
|
|
267
|
+
return {};
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
`;
|
|
271
|
+
}
|
|
272
|
+
/** End of a turn on the agent's screen: answer, session and .done from the agent's own hook payload. Never fails the agent. */
|
|
273
|
+
function turn(file, kind, rest) {
|
|
274
|
+
try {
|
|
275
|
+
const j = JSON.parse(readFileSync(file, "utf8")), O = j.prefix;
|
|
276
|
+
let answer = "", sid;
|
|
277
|
+
if (kind === "codex") {
|
|
278
|
+
const e = JSON.parse(rest[rest.length - 1] ?? "{}");
|
|
279
|
+
if (e.type !== "agent-turn-complete")
|
|
280
|
+
return 0;
|
|
281
|
+
answer = String(e["last-assistant-message"] ?? "");
|
|
282
|
+
sid = e["thread-id"];
|
|
283
|
+
}
|
|
284
|
+
else if (kind === "claude") {
|
|
285
|
+
const e = JSON.parse(readFileSync(0, "utf8"));
|
|
286
|
+
if (e.stop_hook_active)
|
|
287
|
+
return 0;
|
|
288
|
+
sid = e.session_id;
|
|
289
|
+
answer = String(e.last_assistant_message ?? "") || lastClaudeText(String(e.transcript_path ?? ""));
|
|
290
|
+
}
|
|
291
|
+
else if (kind === "opencode") {
|
|
292
|
+
sid = rest[0];
|
|
293
|
+
try {
|
|
294
|
+
answer = readFileSync(rest[1], "utf8");
|
|
295
|
+
rmSync(rest[1], { force: true });
|
|
296
|
+
}
|
|
297
|
+
catch { /* none */ }
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
sid = rest[0];
|
|
301
|
+
answer = readFileSync(0, "utf8");
|
|
302
|
+
}
|
|
303
|
+
writeFileSync(`${O}.out.md`, answer);
|
|
304
|
+
if (sid)
|
|
305
|
+
writeFileSync(`${O}.id`, `${sid}\n`);
|
|
306
|
+
const st = /\S/.test(answer) ? "ok" : "fail";
|
|
307
|
+
writeAtomic(`${O}.done`, `status=${st} rc=0 at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
|
|
308
|
+
if (j.parent)
|
|
309
|
+
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md`, true);
|
|
310
|
+
}
|
|
311
|
+
catch (e) {
|
|
312
|
+
console.error(`orq agent --turn: ${e.message}`);
|
|
313
|
+
}
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
/** The last text Claude wrote, from its transcript (older Stop hooks have no last_assistant_message). */
|
|
317
|
+
function lastClaudeText(transcript) {
|
|
318
|
+
let text = "";
|
|
319
|
+
try {
|
|
320
|
+
for (const l of readFileSync(transcript, "utf8").split(/\r?\n/)) {
|
|
321
|
+
try {
|
|
322
|
+
const e = JSON.parse(l);
|
|
323
|
+
if (e.type !== "assistant")
|
|
324
|
+
continue;
|
|
325
|
+
const t = (e.message?.content ?? []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
|
|
326
|
+
if (t)
|
|
327
|
+
text = t;
|
|
328
|
+
}
|
|
329
|
+
catch { /* not JSON */ }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch { /* no transcript */ }
|
|
333
|
+
return text;
|
|
334
|
+
}
|
|
193
335
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
194
336
|
/** Splits a stream into lines for `each`. */
|
|
195
337
|
function lines(s, each) {
|
|
@@ -218,7 +360,6 @@ async function runJob(file) {
|
|
|
218
360
|
catch { /* best effort */ } };
|
|
219
361
|
state();
|
|
220
362
|
const fmt = FORMATS[j.format] ?? FORMATS.text;
|
|
221
|
-
const log = createWriteStream(`${O}.log`);
|
|
222
363
|
let rc = 0, stdout = "", logText = "";
|
|
223
364
|
const pre = () => {
|
|
224
365
|
if (j.throwaway) {
|
|
@@ -254,6 +395,51 @@ async function runJob(file) {
|
|
|
254
395
|
if (j.throwaway)
|
|
255
396
|
dropWorktree(j.repo, j.dir);
|
|
256
397
|
};
|
|
398
|
+
if (j.tui) {
|
|
399
|
+
// The agent's own screen: the terminal is its; answers come from the end-of-turn hook (`--turn`).
|
|
400
|
+
let ended = false;
|
|
401
|
+
const end = (code) => {
|
|
402
|
+
if (ended)
|
|
403
|
+
return "fail";
|
|
404
|
+
ended = true;
|
|
405
|
+
post();
|
|
406
|
+
// Closed before any turn ended (or mid follow-up): a failure, so `orq wait` doesn't hang.
|
|
407
|
+
const ok = /\bstatus=ok\b/.test(existsSync(`${O}.done`) ? readFileSync(`${O}.done`, "utf8") : "");
|
|
408
|
+
if (!existsSync(`${O}.done`)) {
|
|
409
|
+
writeAtomic(`${O}.done`, `status=fail rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
|
|
410
|
+
if (j.parent)
|
|
411
|
+
notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`, true);
|
|
412
|
+
}
|
|
413
|
+
rmSync(`${O}.pid`, { force: true });
|
|
414
|
+
return ok ? "ok" : "fail";
|
|
415
|
+
};
|
|
416
|
+
if (!pre())
|
|
417
|
+
rc = 2;
|
|
418
|
+
else {
|
|
419
|
+
const env = { ...process.env, ...j.env };
|
|
420
|
+
for (const k of j.unset)
|
|
421
|
+
delete env[k];
|
|
422
|
+
// Ctrl+C belongs to the agent (it reaches us too, as part of the terminal's foreground group). The screen going
|
|
423
|
+
// away (tab or tmux server closed) still ends the agent and cleans up.
|
|
424
|
+
let child;
|
|
425
|
+
process.on("SIGINT", () => { });
|
|
426
|
+
const gone = () => { if (child)
|
|
427
|
+
killTree(child, false); end(129); process.exit(0); };
|
|
428
|
+
for (const sig of ["SIGHUP", "SIGTERM"])
|
|
429
|
+
process.on(sig, gone);
|
|
430
|
+
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,
|
|
431
|
+
onSpawn: (c) => { child = c.pid; } });
|
|
432
|
+
}
|
|
433
|
+
const st = end(rc);
|
|
434
|
+
state({ status: st, rc, end: new Date().toISOString() });
|
|
435
|
+
console.log(`\n[${j.agent} (TUI) ${m("exited", "encerrado")}] ${m("answer", "resposta")}: ${O}.out.md`);
|
|
436
|
+
if (!j.keep) {
|
|
437
|
+
await sleep(j.closeAfter * 1000);
|
|
438
|
+
closeOwnTab(j.host);
|
|
439
|
+
}
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
const log = createWriteStream(`${O}.log`);
|
|
257
443
|
if (!pre())
|
|
258
444
|
rc = 2;
|
|
259
445
|
else {
|
package/dist/commands/block.js
CHANGED
|
@@ -6,8 +6,8 @@ import { join } from "node:path";
|
|
|
6
6
|
import { writeAtomic } from "../fsutil.js";
|
|
7
7
|
import { m } from "../i18n.js";
|
|
8
8
|
import { stateDir } from "../paths.js";
|
|
9
|
-
export const USAGE = `orq block <agent|account> [--until YYYY-MM-DD[THH:MM] | --days N] [--reason "text"] default: 7 days
|
|
10
|
-
orq block -r <agent|account> unblock early
|
|
9
|
+
export const USAGE = `orq block <agent|account> [--until YYYY-MM-DD[THH:MM] | --days N] [--reason "text"] default: 7 days
|
|
10
|
+
orq block -r <agent|account> unblock early
|
|
11
11
|
orq block -l list active blocks (expired ones disappear by themselves)`;
|
|
12
12
|
const file = () => join(stateDir(), "blocks.json");
|
|
13
13
|
const now = () => Date.now() / 1000;
|
package/dist/commands/board.js
CHANGED
|
@@ -153,7 +153,7 @@ export function parseQuota(text) {
|
|
|
153
153
|
return r ? [{ name: r[1], five: r[2], week: r[3], ...(r[4] ? { note: r[4] } : {}) }] : [];
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
|
-
function quota(now) {
|
|
156
|
+
export function quota(now) {
|
|
157
157
|
let age;
|
|
158
158
|
try {
|
|
159
159
|
age = now - statSync(qfile()).mtimeMs;
|
package/dist/commands/claim.js
CHANGED
|
@@ -12,7 +12,7 @@ import { m } from "../i18n.js";
|
|
|
12
12
|
import { acquire, release } from "../lock.js";
|
|
13
13
|
export const USAGE = `orq claim [-w <workspace>] <ID> | -f <ID> | -r <ID> | -l issue owner among conductors (exit 1 = someone else's)`;
|
|
14
14
|
const field = (text, k) => new RegExp(`${k}=(\\S+)`).exec(text)?.[1] ?? "";
|
|
15
|
-
function aliveOwner(text) {
|
|
15
|
+
export function aliveOwner(text) {
|
|
16
16
|
const id = field(text, "pane"), kind = field(text, "kind") || "herdr";
|
|
17
17
|
if (kind === "herdr")
|
|
18
18
|
return spawnSync("herdr", ["agent", "get", id], { stdio: "ignore" }).status === 0;
|
|
@@ -11,7 +11,7 @@ const DESC = {
|
|
|
11
11
|
quota: "quota per agent/account", pick: "pick agent, model and account for a role", stats: "scoreboard per agent",
|
|
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",
|
|
14
|
+
instructions: "write the /issue playbook", claim: "issue owner among conductors", deps: "link node_modules into a worktree", gc: "remove merged issue worktrees",
|
|
15
15
|
block: "block an agent or account", completion: "shell completion script",
|
|
16
16
|
};
|
|
17
17
|
/** Fixed words after a subcommand. */
|
|
@@ -27,52 +27,52 @@ export function script(shell, cmds) {
|
|
|
27
27
|
const all = cmds.join(" ");
|
|
28
28
|
const cases = (fmt) => Object.entries(WORDS).filter(([c]) => cmds.includes(c)).map(([c, w]) => fmt(c, w.join(" "))).join("\n");
|
|
29
29
|
switch (shell) {
|
|
30
|
-
case "bash": return `# orq completion (bash): eval "$(orq completion bash)" in ~/.bashrc
|
|
31
|
-
_orq() {
|
|
32
|
-
local cur=\${COMP_WORDS[COMP_CWORD]}
|
|
33
|
-
if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=($(compgen -W "${all}" -- "$cur")); return; fi
|
|
34
|
-
[ "$COMP_CWORD" -eq 2 ] || return
|
|
35
|
-
case \${COMP_WORDS[1]} in
|
|
36
|
-
${cases((c, w) => ` ${c}) COMPREPLY=($(compgen -W "${w}" -- "$cur")) ;;`)}
|
|
37
|
-
esac
|
|
38
|
-
}
|
|
39
|
-
complete -o default -F _orq orq
|
|
30
|
+
case "bash": return `# orq completion (bash): eval "$(orq completion bash)" in ~/.bashrc
|
|
31
|
+
_orq() {
|
|
32
|
+
local cur=\${COMP_WORDS[COMP_CWORD]}
|
|
33
|
+
if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=($(compgen -W "${all}" -- "$cur")); return; fi
|
|
34
|
+
[ "$COMP_CWORD" -eq 2 ] || return
|
|
35
|
+
case \${COMP_WORDS[1]} in
|
|
36
|
+
${cases((c, w) => ` ${c}) COMPREPLY=($(compgen -W "${w}" -- "$cur")) ;;`)}
|
|
37
|
+
esac
|
|
38
|
+
}
|
|
39
|
+
complete -o default -F _orq orq
|
|
40
40
|
`;
|
|
41
|
-
case "zsh": return `# orq completion (zsh): eval "$(orq completion zsh)" in ~/.zshrc, after compinit
|
|
42
|
-
_orq() {
|
|
43
|
-
if (( CURRENT == 2 )); then compadd -- ${all}; return; fi
|
|
44
|
-
if (( CURRENT == 3 )); then
|
|
45
|
-
case $words[2] in
|
|
46
|
-
${cases((c, w) => ` ${c}) compadd -- ${w}; return ;;`)}
|
|
47
|
-
esac
|
|
48
|
-
fi
|
|
49
|
-
_files
|
|
50
|
-
}
|
|
51
|
-
compdef _orq orq
|
|
41
|
+
case "zsh": return `# orq completion (zsh): eval "$(orq completion zsh)" in ~/.zshrc, after compinit
|
|
42
|
+
_orq() {
|
|
43
|
+
if (( CURRENT == 2 )); then compadd -- ${all}; return; fi
|
|
44
|
+
if (( CURRENT == 3 )); then
|
|
45
|
+
case $words[2] in
|
|
46
|
+
${cases((c, w) => ` ${c}) compadd -- ${w}; return ;;`)}
|
|
47
|
+
esac
|
|
48
|
+
fi
|
|
49
|
+
_files
|
|
50
|
+
}
|
|
51
|
+
compdef _orq orq
|
|
52
52
|
`;
|
|
53
|
-
case "fish": return `# orq completion (fish): orq completion fish > ~/.config/fish/completions/orq.fish
|
|
54
|
-
complete -c orq -f -n __fish_use_subcommand
|
|
55
|
-
${cmds.map((c) => `complete -c orq -n __fish_use_subcommand -a ${c}${DESC[c] ? ` -d ${qf(DESC[c])}` : ""}`).join("\n")}
|
|
56
|
-
${cases((c, w) => `complete -c orq -n "__fish_seen_subcommand_from ${c}" -a ${qf(w)}`)}
|
|
53
|
+
case "fish": return `# orq completion (fish): orq completion fish > ~/.config/fish/completions/orq.fish
|
|
54
|
+
complete -c orq -f -n __fish_use_subcommand
|
|
55
|
+
${cmds.map((c) => `complete -c orq -n __fish_use_subcommand -a ${c}${DESC[c] ? ` -d ${qf(DESC[c])}` : ""}`).join("\n")}
|
|
56
|
+
${cases((c, w) => `complete -c orq -n "__fish_seen_subcommand_from ${c}" -a ${qf(w)}`)}
|
|
57
57
|
`;
|
|
58
58
|
case "powershell":
|
|
59
|
-
case "pwsh": return `# orq completion (PowerShell): add to $PROFILE: orq completion powershell | Out-String | Invoke-Expression
|
|
60
|
-
Register-ArgumentCompleter -Native -CommandName orq -ScriptBlock {
|
|
61
|
-
param($word, $ast, $cursor)
|
|
62
|
-
$w = @($ast.CommandElements | ForEach-Object { $_.ToString() })
|
|
63
|
-
$n = $w.Count; if ($word -ne '') { $n-- }
|
|
64
|
-
$words = @{
|
|
65
|
-
${Object.entries(WORDS).filter(([c]) => cmds.includes(c)).map(([c, w]) => ` ${q(c)} = @(${w.map(q).join(", ")})`).join("\n")}
|
|
66
|
-
}
|
|
67
|
-
$desc = @{
|
|
68
|
-
${cmds.map((c) => ` ${q(c)} = ${q(DESC[c] ?? c)}`).join("\n")}
|
|
69
|
-
}
|
|
70
|
-
if ($n -eq 1) { $list = @(${cmds.map(q).join(", ")}) } elseif ($n -eq 2 -and $words.ContainsKey($w[1])) { $list = $words[$w[1]] } else { return }
|
|
71
|
-
$list | Where-Object { $_ -like "$word*" } | ForEach-Object {
|
|
72
|
-
$tip = if ($n -eq 1) { $desc[$_] } else { $_ }
|
|
73
|
-
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $tip)
|
|
74
|
-
}
|
|
75
|
-
}
|
|
59
|
+
case "pwsh": return `# orq completion (PowerShell): add to $PROFILE: orq completion powershell | Out-String | Invoke-Expression
|
|
60
|
+
Register-ArgumentCompleter -Native -CommandName orq -ScriptBlock {
|
|
61
|
+
param($word, $ast, $cursor)
|
|
62
|
+
$w = @($ast.CommandElements | ForEach-Object { $_.ToString() })
|
|
63
|
+
$n = $w.Count; if ($word -ne '') { $n-- }
|
|
64
|
+
$words = @{
|
|
65
|
+
${Object.entries(WORDS).filter(([c]) => cmds.includes(c)).map(([c, w]) => ` ${q(c)} = @(${w.map(q).join(", ")})`).join("\n")}
|
|
66
|
+
}
|
|
67
|
+
$desc = @{
|
|
68
|
+
${cmds.map((c) => ` ${q(c)} = ${q(DESC[c] ?? c)}`).join("\n")}
|
|
69
|
+
}
|
|
70
|
+
if ($n -eq 1) { $list = @(${cmds.map(q).join(", ")}) } elseif ($n -eq 2 -and $words.ContainsKey($w[1])) { $list = $words[$w[1]] } else { return }
|
|
71
|
+
$list | Where-Object { $_ -like "$word*" } | ForEach-Object {
|
|
72
|
+
$tip = if ($n -eq 1) { $desc[$_] } else { $_ }
|
|
73
|
+
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $tip)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
76
|
`;
|
|
77
77
|
default: return undefined;
|
|
78
78
|
}
|
package/dist/commands/config.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import { getPath, loadConfig } from "../config.js";
|
|
6
6
|
import { expand } from "../paths.js";
|
|
7
7
|
import { m } from "../i18n.js";
|
|
8
|
-
export const USAGE = `orq config [-w <workspace>] everything, as JSON
|
|
9
|
-
orq config [-w <workspace>] get <a.b.c> one value (plain text if scalar; JSON if object/list)
|
|
8
|
+
export const USAGE = `orq config [-w <workspace>] everything, as JSON
|
|
9
|
+
orq config [-w <workspace>] get <a.b.c> one value (plain text if scalar; JSON if object/list)
|
|
10
10
|
orq config [-w <workspace>] files which files were read`;
|
|
11
11
|
export function config(argv) {
|
|
12
12
|
let ws;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// orq gc — find issue worktrees whose branch is already safe to remove.
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { loadConfig } from "../config.js";
|
|
6
|
+
import { m } from "../i18n.js";
|
|
7
|
+
import { aliveOwner } from "./claim.js";
|
|
8
|
+
export const USAGE = "orq gc [-w <workspace>] [--yes] [--days N] [--json] remove merged issue worktrees";
|
|
9
|
+
const git = (cwd, ...args) => spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
|
|
10
|
+
// Paths as git prints them can differ from ours (macOS /var → /private/var, Windows C:/ vs C:\): compare real paths.
|
|
11
|
+
const real = (p) => { try {
|
|
12
|
+
return realpathSync.native(p);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return resolve(p);
|
|
16
|
+
} };
|
|
17
|
+
const inside = (dir, p) => { const r = relative(dir, p); return !!r && !r.startsWith("..") && !isAbsolute(r); };
|
|
18
|
+
// ORQ_GH may be a .js file (tests: a fake gh that also runs on Windows).
|
|
19
|
+
const gh = (cwd, args) => {
|
|
20
|
+
const bin = process.env.ORQ_GH || "gh";
|
|
21
|
+
return bin.endsWith(".js") ? spawnSync(process.execPath, [bin, ...args], { cwd, encoding: "utf8" })
|
|
22
|
+
: spawnSync(bin, args, { cwd, encoding: "utf8" });
|
|
23
|
+
};
|
|
24
|
+
const output = (cwd, ...args) => { const r = git(cwd, ...args); return r.status === 0 ? r.stdout.trim() : undefined; };
|
|
25
|
+
export async function gc(argv, clock = Date.now) {
|
|
26
|
+
let ws = process.cwd(), yes = false, json = false, days = 14;
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
if (argv[i] === "-w" && argv[i + 1])
|
|
29
|
+
ws = resolve(argv[++i]);
|
|
30
|
+
else if (argv[i] === "--yes")
|
|
31
|
+
yes = true;
|
|
32
|
+
else if (argv[i] === "--json")
|
|
33
|
+
json = true;
|
|
34
|
+
else if (argv[i] === "--days" && argv[i + 1] && /^\d+$/.test(argv[i + 1]))
|
|
35
|
+
days = Number(argv[++i]);
|
|
36
|
+
else {
|
|
37
|
+
console.error(USAGE);
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const { cfg } = loadConfig(ws), rows = [];
|
|
42
|
+
let ghUnavailable = false, fetchWarned = false;
|
|
43
|
+
const root = join(ws, ".worktrees");
|
|
44
|
+
const orq = join(ws, ".orquestra");
|
|
45
|
+
const issues = existsSync(orq) ? readdirSync(orq) : [];
|
|
46
|
+
for (const repoName of readdirSync(ws, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)) {
|
|
47
|
+
const repo = join(ws, repoName);
|
|
48
|
+
if (!existsSync(join(repo, ".git")))
|
|
49
|
+
continue;
|
|
50
|
+
const listings = output(repo, "worktree", "list", "--porcelain");
|
|
51
|
+
if (listings === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
const worktrees = listings.split(/\n\n/).map((block) => Object.fromEntries(block.split("\n").map((line) => {
|
|
54
|
+
const i = line.indexOf(" ");
|
|
55
|
+
return [i < 0 ? line : line.slice(0, i), i < 0 ? "" : line.slice(i + 1)];
|
|
56
|
+
})));
|
|
57
|
+
const main = real(worktrees[0]?.worktree ?? repo);
|
|
58
|
+
const conf = cfg.repos?.[repoName];
|
|
59
|
+
const head = output(repo, "symbolic-ref", "refs/remotes/origin/HEAD")?.replace("refs/remotes/", "");
|
|
60
|
+
const prod = conf?.prod_ref || head || "origin/main";
|
|
61
|
+
const fetchRef = prod.replace(/^origin\//, "");
|
|
62
|
+
if (git(repo, "fetch", "origin", fetchRef).status !== 0 && !fetchWarned) {
|
|
63
|
+
console.error(m("WARNING: git fetch failed; using local refs", "AVISO: git fetch falhou; usando as refs locais"));
|
|
64
|
+
fetchWarned = true;
|
|
65
|
+
}
|
|
66
|
+
const prodTree = output(repo, "rev-parse", `${prod}^{tree}`);
|
|
67
|
+
for (const wt of worktrees) {
|
|
68
|
+
const path = real(wt.worktree ?? "");
|
|
69
|
+
if (!wt.worktree || path === main || !inside(real(root), path))
|
|
70
|
+
continue;
|
|
71
|
+
const branch = wt.branch?.replace(/^refs\/heads\//, "") ?? "";
|
|
72
|
+
if (!branch)
|
|
73
|
+
continue;
|
|
74
|
+
// The branch carries the ID in lowercase (plane-abc-123); .orquestra/ keeps it as written (ABC-123).
|
|
75
|
+
const id = /^.*?plane-(.+)$/.exec(branch)?.[1];
|
|
76
|
+
const issue = id && issues.find((d) => d.toLowerCase() === id.toLowerCase());
|
|
77
|
+
const status = output(path, "status", "--porcelain");
|
|
78
|
+
const unpushed = output(repo, "rev-list", branch, "--not", "--remotes");
|
|
79
|
+
let reason = "", verdict = "REMOVABLE";
|
|
80
|
+
if (status) {
|
|
81
|
+
verdict = "KEEP";
|
|
82
|
+
reason = m("uncommitted changes", "alterações não commitadas");
|
|
83
|
+
}
|
|
84
|
+
const ownerFile = issue && join(ws, ".orquestra", issue, "owner");
|
|
85
|
+
if (verdict !== "KEEP" && ownerFile && existsSync(ownerFile) && aliveOwner(readFileSync(ownerFile, "utf8"))) {
|
|
86
|
+
verdict = "KEEP";
|
|
87
|
+
reason = m("issue has a live owner", "issue tem um responsável ativo");
|
|
88
|
+
}
|
|
89
|
+
// A .pane/.id without a same-stem .done is treated as a running child.
|
|
90
|
+
const issueDir = issue && join(ws, ".orquestra", issue);
|
|
91
|
+
if (verdict !== "KEEP" && issueDir && existsSync(issueDir)) {
|
|
92
|
+
const files = readdirSync(issueDir), running = files.some((f) => /\.(pane|id)$/.test(f) && !existsSync(join(issueDir, `${f.replace(/\.(pane|id)$/, "")}.done`)));
|
|
93
|
+
if (running) {
|
|
94
|
+
verdict = "KEEP";
|
|
95
|
+
reason = m("child agent may still be running", "um agente filho pode estar em execução");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
let merged = false;
|
|
99
|
+
if (verdict !== "KEEP") {
|
|
100
|
+
// Merged counts only if the PR's head is the local head: a commit made after the merge must not be lost.
|
|
101
|
+
const pr = gh(repo, ["pr", "list", "--head", branch, "--state", "merged", "--json", "number,headRefOid", "--limit", "1"]);
|
|
102
|
+
const local = output(repo, "rev-parse", branch);
|
|
103
|
+
if (pr.status === 0) {
|
|
104
|
+
try {
|
|
105
|
+
const p = JSON.parse(pr.stdout)[0];
|
|
106
|
+
merged = !!p && (!p.headRefOid || p.headRefOid === local);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
ghUnavailable = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else
|
|
113
|
+
ghUnavailable = true;
|
|
114
|
+
}
|
|
115
|
+
const inBase = verdict !== "KEEP" && !!prodTree && output(repo, "merge-tree", "--write-tree", prod, branch) === prodTree;
|
|
116
|
+
if (verdict !== "KEEP" && unpushed && !merged && !inBase) {
|
|
117
|
+
verdict = "KEEP";
|
|
118
|
+
reason = m("commits not contained in remote refs", "commits fora das refs remotas");
|
|
119
|
+
}
|
|
120
|
+
else if (verdict !== "KEEP" && (merged || inBase)) {
|
|
121
|
+
verdict = "REMOVABLE";
|
|
122
|
+
reason = merged ? "PR merged" : m("content is in base", "conteúdo já está na base");
|
|
123
|
+
}
|
|
124
|
+
else if (verdict !== "KEEP") {
|
|
125
|
+
const closed = gh(repo, ["pr", "list", "--head", branch, "--state", "closed", "--json", "number", "--limit", "1"]);
|
|
126
|
+
const isClosed = closed?.status === 0 && (() => { try {
|
|
127
|
+
return JSON.parse(closed.stdout).length > 0;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
} })();
|
|
132
|
+
const commit = output(repo, "log", "-1", "--format=%ct", branch), old = commit !== undefined && clock() - Number(commit) * 1000 > days * 86400_000;
|
|
133
|
+
verdict = "STALE";
|
|
134
|
+
reason = isClosed ? m("PR closed without merge", "PR fechada sem merge") : old ? m(`last commit is older than ${days} days`, `último commit tem mais de ${days} dias`) : m("not merged", "não integrado");
|
|
135
|
+
}
|
|
136
|
+
rows.push({ repo: repoName, worktree: basename(path), branch, verdict, reason, path });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (ghUnavailable)
|
|
140
|
+
console.error(m("NOTE: gh unavailable or failed; PR signals were skipped", "NOTA: gh indisponível ou falhou; sinais de PR ignorados"));
|
|
141
|
+
let failed = false;
|
|
142
|
+
if (yes)
|
|
143
|
+
for (const row of rows.filter((r) => r.verdict === "REMOVABLE")) {
|
|
144
|
+
const removed = git(join(ws, row.repo), "worktree", "remove", row.path);
|
|
145
|
+
if (removed.status !== 0) {
|
|
146
|
+
failed = true;
|
|
147
|
+
row.reason += `; ${m("worktree removal failed", "falha ao remover worktree")}`;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (git(join(ws, row.repo), "branch", "-D", row.branch).status !== 0) {
|
|
151
|
+
failed = true;
|
|
152
|
+
row.reason += `; ${m("branch deletion failed", "falha ao excluir branch")}`;
|
|
153
|
+
}
|
|
154
|
+
git(join(ws, row.repo), "worktree", "prune");
|
|
155
|
+
}
|
|
156
|
+
if (json)
|
|
157
|
+
console.log(JSON.stringify(rows));
|
|
158
|
+
else {
|
|
159
|
+
const table = [["REPO", "WORKTREE", "BRANCH", "VERDICT", "REASON"], ...rows.map((r) => [r.repo, r.worktree, r.branch, r.verdict, r.reason])];
|
|
160
|
+
const w = table[0].map((_, i) => Math.max(...table.map((t) => t[i].length)));
|
|
161
|
+
console.log(table.map((t) => t.map((c, i) => i < t.length - 1 ? c.padEnd(w[i]) : c).join(" ")).join("\n"));
|
|
162
|
+
}
|
|
163
|
+
return failed ? 1 : 0;
|
|
164
|
+
}
|