@irythill/orq 0.0.0-stage → 0.2.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 -0
- package/README.md +240 -2
- package/README.pt-BR.md +242 -0
- package/dist/accounts.js +18 -0
- package/dist/agents.js +202 -0
- package/dist/banner.js +87 -0
- package/dist/cli.js +92 -0
- package/dist/commands/agent.js +334 -0
- package/dist/commands/baseline.js +101 -0
- package/dist/commands/block.js +74 -0
- package/dist/commands/board.js +435 -0
- package/dist/commands/claim.js +88 -0
- package/dist/commands/completion.js +88 -0
- package/dist/commands/config.js +35 -0
- package/dist/commands/deps.js +82 -0
- package/dist/commands/gc.js +164 -0
- package/dist/commands/instructions.js +66 -0
- package/dist/commands/lang.js +35 -0
- package/dist/commands/lock.js +69 -0
- package/dist/commands/pick.js +111 -0
- package/dist/commands/quota.js +40 -0
- package/dist/commands/run.js +46 -0
- package/dist/commands/set.js +27 -0
- package/dist/commands/setup.js +187 -0
- package/dist/commands/stats.js +41 -0
- package/dist/commands/verify.js +123 -0
- package/dist/commands/wait.js +81 -0
- package/dist/config.js +93 -0
- package/dist/fsutil.js +9 -0
- package/dist/i18n.js +21 -0
- package/dist/install.js +137 -0
- package/dist/keyring.js +51 -0
- package/dist/lock.js +81 -0
- package/dist/paths.js +34 -0
- package/dist/pick.js +126 -0
- package/dist/process.js +147 -0
- package/dist/quota.js +334 -0
- package/dist/roles.js +28 -0
- package/dist/stats.js +159 -0
- package/dist/suggest.js +121 -0
- package/dist/terminal.js +83 -0
- package/dist/testrun.js +203 -0
- package/dist/toml-set.js +46 -0
- package/package.json +46 -4
- package/skills/issue/SKILL.md +229 -0
- package/skills/orq-setup/SKILL.md +114 -0
- package/skills/orq-status/SKILL.md +37 -0
- package/templates/workspace.CLAUDE.md +49 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// orq setup — guided first configuration: detect agent CLIs, ask what the user knows, suggest roles (src/suggest.ts),
|
|
2
|
+
// and do what a fresh `npm i -g @irythill/orq` still needs (src/install.ts): skills for Claude Code, a conductor workspace.
|
|
3
|
+
// orq setup interactive, in any terminal
|
|
4
|
+
// orq setup --detect installed agent CLIs, as JSON (for a conductor driving the setup)
|
|
5
|
+
// orq setup --answers <file|-> [--write] proposal from answers JSON (see Answers in src/suggest.ts); --write saves it
|
|
6
|
+
// orq setup --install no questions: what orq found, and the skills linked for Claude Code
|
|
7
|
+
// orq setup --workspace <dir> [--conductor <agent>] .orquestra, CLAUDE.md map, /issue playbook in its format
|
|
8
|
+
// --preset economy|quality|single-agent
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { copyFileSync, existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { homedir, totalmem } from "node:os";
|
|
12
|
+
import { delimiter, join } from "node:path";
|
|
13
|
+
import { createInterface } from "node:readline/promises";
|
|
14
|
+
import { writeAtomic } from "../fsutil.js";
|
|
15
|
+
import { banner, infoLines } from "../banner.js";
|
|
16
|
+
import { getPath, loadConfig } from "../config.js";
|
|
17
|
+
import { m } from "../i18n.js";
|
|
18
|
+
import { environment, installSkills, prepareWorkspace } from "../install.js";
|
|
19
|
+
import { expand, userConfigFile } from "../paths.js";
|
|
20
|
+
import { suggestRoles, toToml } from "../suggest.js";
|
|
21
|
+
export const USAGE = `orq setup [--preset economy|quality|single-agent] guided first configuration (roles, weights, memory)
|
|
22
|
+
orq setup --detect | --answers <file|-> [--write] | --install | --workspace <dir> [--conductor <agent>]`;
|
|
23
|
+
/** Agent name → its CLI and a cheap headless smoke test. */
|
|
24
|
+
const CLIS = {
|
|
25
|
+
claude: { bin: "claude", test: 'claude -p "reply ok"' },
|
|
26
|
+
codex: { bin: "codex", test: 'codex exec --skip-git-repo-check "reply ok"' },
|
|
27
|
+
opencode: { bin: "opencode", test: 'opencode run "reply ok"' },
|
|
28
|
+
antigravity: { bin: "agy", test: 'agy -p "reply ok"' },
|
|
29
|
+
copilot: { bin: "copilot", test: 'copilot -p "reply ok" -s' },
|
|
30
|
+
};
|
|
31
|
+
/** Is `bin` on PATH? (PATHEXT on Windows: claude.cmd, codex.exe…) */
|
|
32
|
+
export function onPath(bin) {
|
|
33
|
+
const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
34
|
+
return (process.env.PATH ?? "").split(delimiter).some((d) => d && exts.some((e) => existsSync(join(d, bin + e))));
|
|
35
|
+
}
|
|
36
|
+
export const detect = () => Object.keys(CLIS).filter((n) => onPath(CLIS[n].bin));
|
|
37
|
+
function write(toml) {
|
|
38
|
+
const f = userConfigFile();
|
|
39
|
+
if (existsSync(f)) {
|
|
40
|
+
const bak = `${f}.bak-${new Date().toISOString().replace(/\D/g, "").slice(0, 14)}`;
|
|
41
|
+
copyFileSync(f, bak);
|
|
42
|
+
console.log(m(`previous config saved as ${bak}`, `config anterior salvo em ${bak}`));
|
|
43
|
+
}
|
|
44
|
+
writeAtomic(f, toml);
|
|
45
|
+
return f;
|
|
46
|
+
}
|
|
47
|
+
function show(a) {
|
|
48
|
+
const p = suggestRoles(a), toml = toToml(p);
|
|
49
|
+
console.log(toml);
|
|
50
|
+
return toml;
|
|
51
|
+
}
|
|
52
|
+
async function interactive(preset) {
|
|
53
|
+
process.stdout.write(banner([...infoLines({}).slice(0, 3), m("setup — let's build your orchestra", "setup — vamos montar sua orquestra")]));
|
|
54
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
55
|
+
// Input closed mid-question (Ctrl+D, empty stdin): cancel cleanly instead of hanging.
|
|
56
|
+
const closed = new Promise((_, rej) => rl.once("close", () => rej(new Error("closed"))));
|
|
57
|
+
closed.catch(() => { });
|
|
58
|
+
const ask = async (q, def = "") => ((await Promise.race([rl.question(def ? `${q} [${def}] ` : `${q} `), closed])).trim() || def);
|
|
59
|
+
const yes = async (q, def = "n") => /^[ysYS]/.test(await ask(`${q} (y/n)`, def));
|
|
60
|
+
try {
|
|
61
|
+
env();
|
|
62
|
+
const found = detect();
|
|
63
|
+
if (!found.length) {
|
|
64
|
+
console.log(m("No agent CLI found (claude, codex, opencode, agy, copilot). Install one and run orq setup again.", "Nenhuma CLI de agente encontrada (claude, codex, opencode, agy, copilot). Instale uma e rode orq setup de novo."));
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
console.log(m(`Found: ${found.join(", ")}`, `Encontrados: ${found.join(", ")}`));
|
|
68
|
+
const agents = {};
|
|
69
|
+
for (const n of found) {
|
|
70
|
+
if (await yes(m(`Smoke-test ${n}? (costs a few tokens)`, `Testar ${n}? (gasta alguns tokens)`))) {
|
|
71
|
+
const r = spawnSync(CLIS[n].test, { shell: true, encoding: "utf8", timeout: 120_000 });
|
|
72
|
+
const ok = r.status === 0;
|
|
73
|
+
console.log(ok ? " ok" : m(" failed — probably not logged in", " falhou — provavelmente sem login"));
|
|
74
|
+
if (!ok && !(await yes(m(`Keep ${n} anyway?`, `Manter ${n} mesmo assim?`))))
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
else if (!(await yes(m(`Use ${n}?`, `Usar ${n}?`), "y")))
|
|
78
|
+
continue;
|
|
79
|
+
const plan = await ask(m(`${n} plan: free | small (Pro, Copilot Pro) | large (Max, high tiers) | payg`, `Plano do ${n}: free | small (Pro, Copilot Pro) | large (Max, planos altos) | payg (pague-por-uso)`), "large");
|
|
80
|
+
agents[n] = { plan: (["free", "small", "large", "payg"].includes(plan) ? plan : "large") };
|
|
81
|
+
}
|
|
82
|
+
const names = Object.keys(agents);
|
|
83
|
+
if (!names.length)
|
|
84
|
+
return 1;
|
|
85
|
+
const trust = await ask(m(`Which do you trust most to write code? (${names.join(", ")}, or empty)`, `Em qual você mais confia para escrever código? (${names.join(", ")}, ou vazio)`));
|
|
86
|
+
if (agents[trust])
|
|
87
|
+
agents[trust].trust = true;
|
|
88
|
+
const conductor = await ask(m(`Where will you talk to orq (the conductor)? (${names.join(", ")}, other)`, `Onde você vai conversar com o orq (o conductor)? (${names.join(", ")}, other)`), names.includes("claude") ? "claude" : names[0]);
|
|
89
|
+
const parallel = Number(await ask(m("How many tasks do you usually run at once?", "Quantas tarefas você costuma rodar ao mesmo tempo?"), "2")) || 2;
|
|
90
|
+
const ramGB = Number(await ask(m("RAM agents may use, in GB", "RAM que os agentes podem usar, em GB"), String(Math.round(totalmem() / 2 ** 30)))) || 8;
|
|
91
|
+
const toml = show({ agents, conductor, parallel, ramGB, preset });
|
|
92
|
+
if (await yes(m(`Write this to ${userConfigFile()}?`, `Gravar em ${userConfigFile()}?`)))
|
|
93
|
+
console.log(m(`written: ${write(toml)}`, `gravado: ${write(toml)}`));
|
|
94
|
+
if (names.includes("claude") || existsSync(join(homedir(), ".claude")))
|
|
95
|
+
skills();
|
|
96
|
+
const ws = await ask(m("Folder that groups your repos, for the conductor (empty = later: orq setup --workspace <dir>)", "Pasta que agrupa seus repos, para o conductor (vazio = depois: orq setup --workspace <pasta>)"));
|
|
97
|
+
if (ws)
|
|
98
|
+
workspace(ws, conductor);
|
|
99
|
+
next();
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
if (e.message !== "closed")
|
|
104
|
+
throw e;
|
|
105
|
+
console.log("\n" + m("setup cancelled — nothing was written", "setup cancelado — nada foi gravado"));
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
rl.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function env() {
|
|
113
|
+
console.log(m("Environment", "Ambiente"));
|
|
114
|
+
let cfg = {};
|
|
115
|
+
try {
|
|
116
|
+
cfg = loadConfig().cfg;
|
|
117
|
+
}
|
|
118
|
+
catch { /* a broken config is reported by orq config */ }
|
|
119
|
+
for (const l of environment(cfg))
|
|
120
|
+
console.log(l);
|
|
121
|
+
}
|
|
122
|
+
function skills() {
|
|
123
|
+
console.log(m("Skills for Claude Code (/issue, /orq-setup, /orq-status)", "Skills do Claude Code (/issue, /orq-setup, /orq-status)"));
|
|
124
|
+
for (const l of installSkills())
|
|
125
|
+
console.log(l);
|
|
126
|
+
}
|
|
127
|
+
/** claude → .claude/skills/issue/SKILL.md; any other conductor → AGENTS.md. */
|
|
128
|
+
function workspace(dir, conductor) {
|
|
129
|
+
let c = conductor;
|
|
130
|
+
if (!c) {
|
|
131
|
+
try {
|
|
132
|
+
c = getPath(loadConfig().cfg, "roles.conductor.agent");
|
|
133
|
+
}
|
|
134
|
+
catch { /* none */ }
|
|
135
|
+
}
|
|
136
|
+
c ??= onPath("claude") ? "claude" : "agents";
|
|
137
|
+
const d = expand(dir); // typed at the prompt: ~ is not expanded by any shell
|
|
138
|
+
console.log(`Workspace ${d}`);
|
|
139
|
+
for (const l of prepareWorkspace(d, c === "claude" ? "claude" : "agents"))
|
|
140
|
+
console.log(l);
|
|
141
|
+
}
|
|
142
|
+
const next = () => console.log(m(`Next: fill in the workspace CLAUDE.md, open your conductor in that folder and say "/issue <ID>".
|
|
143
|
+
Board: orq board · quota: orq quota · completion: orq completion <shell>`, `Próximo: preencha o CLAUDE.md do workspace, abra o seu conductor nessa pasta e diga "/issue <ID>".
|
|
144
|
+
Painel: orq board · cota: orq quota · autocompletar: orq completion <shell>`));
|
|
145
|
+
export async function setup(argv) {
|
|
146
|
+
let preset, answers, doWrite = false, ws, conductor;
|
|
147
|
+
let install = false;
|
|
148
|
+
for (let i = 0; i < argv.length; i++) {
|
|
149
|
+
const a = argv[i];
|
|
150
|
+
if (a === "--detect") {
|
|
151
|
+
console.log(JSON.stringify(detect()));
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
if (a === "--preset")
|
|
155
|
+
preset = argv[++i];
|
|
156
|
+
else if (a === "--answers")
|
|
157
|
+
answers = argv[++i];
|
|
158
|
+
else if (a === "--write")
|
|
159
|
+
doWrite = true;
|
|
160
|
+
else if (a === "--install")
|
|
161
|
+
install = true;
|
|
162
|
+
else if (a === "--workspace" && argv[i + 1])
|
|
163
|
+
ws = argv[++i];
|
|
164
|
+
else if (a === "--conductor" && argv[i + 1])
|
|
165
|
+
conductor = argv[++i];
|
|
166
|
+
else {
|
|
167
|
+
console.error(USAGE);
|
|
168
|
+
return 2;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (install || ws) {
|
|
172
|
+
if (install) {
|
|
173
|
+
env();
|
|
174
|
+
skills();
|
|
175
|
+
}
|
|
176
|
+
if (ws)
|
|
177
|
+
workspace(ws, conductor);
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
if (!answers)
|
|
181
|
+
return interactive(preset);
|
|
182
|
+
const a = JSON.parse(readFileSync(answers === "-" ? 0 : answers, "utf8"));
|
|
183
|
+
const toml = show({ ...a, preset: preset ?? a.preset });
|
|
184
|
+
if (doWrite)
|
|
185
|
+
console.error(m(`written: ${write(toml)}`, `gravado: ${write(toml)}`));
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// orq stats [-w <workspace>] [--days N] [--json] agent scoreboard from the .orquestra/ history (src/stats.ts)
|
|
2
|
+
import { getPath, loadConfig } from "../config.js";
|
|
3
|
+
import { m } from "../i18n.js";
|
|
4
|
+
import { expand } from "../paths.js";
|
|
5
|
+
import { collectRuns, rows } from "../stats.js";
|
|
6
|
+
export const USAGE = "orq stats [-w <workspace>] [--days N] [--json] scoreboard: delivery, time and tokens per agent";
|
|
7
|
+
const k = (n) => (n === null ? "-" : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
|
|
8
|
+
const ORDER = ["impl", "fix", "review", "other"];
|
|
9
|
+
export function stats(argv) {
|
|
10
|
+
let ws, days = 30, json = false;
|
|
11
|
+
for (let i = 0; i < argv.length; i++) {
|
|
12
|
+
if (argv[i] === "-w")
|
|
13
|
+
ws = expand(argv[++i]);
|
|
14
|
+
else if (argv[i] === "--days")
|
|
15
|
+
days = Number(argv[++i]);
|
|
16
|
+
else if (argv[i] === "--json")
|
|
17
|
+
json = true;
|
|
18
|
+
else {
|
|
19
|
+
console.error(USAGE);
|
|
20
|
+
return 2;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// Default workspace: the config's (nearest .orq/ upwards), else the current folder.
|
|
24
|
+
ws ??= getPath(loadConfig().cfg, "_workspace") ?? process.cwd();
|
|
25
|
+
const runs = collectRuns(ws, days), rs = rows(runs);
|
|
26
|
+
if (json) {
|
|
27
|
+
console.log(JSON.stringify(rs, null, 2));
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
console.log(m(`Scoreboard for the last ${days} days in ${ws} (${runs.length} runs; medians per run)`, `Placar dos últimos ${days} dias em ${ws} (${runs.length} execuções; medianas por execução)`) + "\n");
|
|
31
|
+
const hdr = `${m("role", "papel").padEnd(7)} ${m("agent", "agente").padEnd(12)} ${m("model", "modelo").padEnd(22)} ${"n".padStart(4)} `
|
|
32
|
+
+ `${"ok%".padStart(5)} ${m("approved%", "aprov%").padStart(12)} ${"min".padStart(6)} ${m("new tok", "tok novos").padStart(10)} `
|
|
33
|
+
+ `${m("total tok", "tok total").padStart(10)}`;
|
|
34
|
+
console.log(hdr + "\n" + "-".repeat(hdr.length));
|
|
35
|
+
for (const x of [...rs].sort((a, b) => ORDER.indexOf(a.role) - ORDER.indexOf(b.role) || b.n - a.n)) {
|
|
36
|
+
const ap = x.approved !== null ? `${x.approved}% (${x.n_appr})` : "-";
|
|
37
|
+
console.log(`${x.role.padEnd(7)} ${x.agent.padEnd(12)} ${x.model.slice(0, 22).padEnd(22)} ${String(x.n).padStart(4)} `
|
|
38
|
+
+ `${String(x.ok).padStart(5)} ${ap.padStart(12)} ${String(x.min).padStart(6)} ${k(x.new).padStart(10)} ${k(x.tot).padStart(10)}`);
|
|
39
|
+
}
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// orq verify — tests related to what changed in a worktree (port of herdr-verify), run by the conductor and not by the
|
|
2
|
+
// child (the child hits its 4 GB cap with jest/vitest). One suite at a time on the machine (the "verify" queue,
|
|
3
|
+
// machine.test_slots) under its own memory cap (machine.test_mem_max, or $VERIFY_MEM_MAX). Returns at once: the run goes
|
|
4
|
+
// to a herdr tab, a tmux window or the background (src/terminal.ts); -x runs it here, in the foreground.
|
|
5
|
+
// output: <prefix>.txt — NEW failures and failures that already exist on the base; <prefix>.done "status=ok|fail new=N".
|
|
6
|
+
// status=ok only with no new failure. On-demand base check: only if some test fails, the SAME test files run in a
|
|
7
|
+
// temporary worktree at origin/<base> (fetched right then); whatever fails there is pre-existing.
|
|
8
|
+
// Same protocol as orq agent: `orq wait <prefix>`, or "[HERDR-AGENT DONE] verify <repo> ..." in the conductor's herdr pane.
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
12
|
+
import { getPath, loadConfig } from "../config.js";
|
|
13
|
+
import { m } from "../i18n.js";
|
|
14
|
+
import { dropWorktree, failures, git, inJob, JS_EXT, launchJob, linkModules, runnerArgs, runnerOf, runTests, testCap, today, unsupported } from "../testrun.js";
|
|
15
|
+
export const USAGE = "orq verify -w <workspace> -r <repo> -d <worktree> -b <base> -o <prefix> [-g group] [-x] tests of the diff, new vs pre-existing failures";
|
|
16
|
+
export async function verify(argv) {
|
|
17
|
+
if (argv[0] === "--run")
|
|
18
|
+
return run(JSON.parse(readFileSync(argv[1], "utf8")));
|
|
19
|
+
const o = {};
|
|
20
|
+
let here = false;
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const a = argv[i];
|
|
23
|
+
if (a === "-x")
|
|
24
|
+
here = true;
|
|
25
|
+
else if (/^-[wrdbog]$/.test(a) && i + 1 < argv.length)
|
|
26
|
+
o[a[1]] = argv[++i];
|
|
27
|
+
else {
|
|
28
|
+
console.error(USAGE);
|
|
29
|
+
return 2;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const f of "wrdbo")
|
|
33
|
+
if (!o[f]) {
|
|
34
|
+
console.error(`${m("missing", "faltou")} -${f}\n${USAGE}`);
|
|
35
|
+
return 2;
|
|
36
|
+
}
|
|
37
|
+
const out = resolve(o.o);
|
|
38
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
39
|
+
rmSync(`${out}.done`, { force: true });
|
|
40
|
+
rmSync(`${out}.txt`, { force: true });
|
|
41
|
+
// Title "<ID> verify <repo>" (ID = the prefix's folder, .orquestra/<ID>/) so closing the issue finds the tab.
|
|
42
|
+
const j = { cmd: "verify", title: `${basename(dirname(out))} verify ${o.r}`, ws: resolve(o.w), repo: o.r, wt: resolve(o.d), base: o.b, out };
|
|
43
|
+
const { cfg } = loadConfig(j.ws);
|
|
44
|
+
if (here)
|
|
45
|
+
return run({ ...j, parent: process.env.HERDR_NOTIFY_PANE ?? "", host: "plain", closeAfter: 0, keep: true });
|
|
46
|
+
console.log(`[orq verify] ${j.title} → ${launchJob(cfg, j, o.g || "agents")}`);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
async function run(j) {
|
|
50
|
+
const O = j.out, R = j.repo, B = j.base, WT = j.wt;
|
|
51
|
+
const { cfg } = loadConfig(j.ws);
|
|
52
|
+
let NEW = 0;
|
|
53
|
+
return inJob(j, `verify ${basename(dirname(O))} ${R}`, Number(getPath(cfg, "machine.test_slots") ?? 1), async (c) => {
|
|
54
|
+
const diff = git(WT, "diff", "--name-only", "--diff-filter=d", `origin/${B}...HEAD`);
|
|
55
|
+
if (diff === undefined)
|
|
56
|
+
throw new Error(m(`git diff against origin/${B} failed in ${WT}`, `o git diff contra origin/${B} falhou em ${WT}`));
|
|
57
|
+
const files = diff.split(/\r?\n/).filter((f) => JS_EXT.test(f));
|
|
58
|
+
const head = git(WT, "rev-parse", "--short", "HEAD") ?? "?";
|
|
59
|
+
if (!files.length) {
|
|
60
|
+
writeFileSync(`${O}.txt`, `# verify ${R} ${head}: no JS/TS file changed relative to origin/${B}\n`);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
const { runner, script } = runnerOf(WT);
|
|
64
|
+
if (!runner)
|
|
65
|
+
throw new Error(unsupported(script));
|
|
66
|
+
const cap = testCap(cfg);
|
|
67
|
+
// One run → "<file> :: <test>" for each failure (undefined = the runner left no JSON report).
|
|
68
|
+
const tests = async (dir, mode, list) => {
|
|
69
|
+
const json = join(tmpdir(), `orq-verify-${process.pid}-${Date.now()}.json`);
|
|
70
|
+
try {
|
|
71
|
+
await runTests(dir, runner, runnerArgs(runner, mode, list, json), cap, c);
|
|
72
|
+
const r = failures(json, dir);
|
|
73
|
+
if (!r)
|
|
74
|
+
c.say(m(`no JSON result in ${dir} (did the runner die? see ${O}.log)`, `sem resultado JSON em ${dir} (o runner morreu? veja ${O}.log)`));
|
|
75
|
+
return r;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
rmSync(json, { force: true });
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const mine = await tests(WT, "related", files);
|
|
82
|
+
if (!mine)
|
|
83
|
+
throw new Error(m("the test run failed", "a execução dos testes falhou"));
|
|
84
|
+
const failed = mine.failed;
|
|
85
|
+
let baseFailed = [], baseInfo = "no failure on the branch: base not checked";
|
|
86
|
+
if (failed.length)
|
|
87
|
+
baseInfo = await onBase(j, c, failed, async (dir, list) => (await tests(dir, "files", list))?.failed, (f) => { baseFailed = f; });
|
|
88
|
+
const newList = failed.filter((f) => !baseFailed.includes(f)), oldList = failed.filter((f) => baseFailed.includes(f));
|
|
89
|
+
NEW = newList.length;
|
|
90
|
+
const text = [`# verify ${R} ${head} ${today()}: ${mine.total} tests related to ${files.length} changed files`, `# base: ${baseInfo}`,
|
|
91
|
+
"", `## New failures (${NEW})`, ...(newList.length ? newList : ["none"]),
|
|
92
|
+
"", `## Already failing on origin/${B}`, ...(oldList.length ? oldList : ["none"])].join("\n") + "\n";
|
|
93
|
+
writeFileSync(`${O}.txt`, text);
|
|
94
|
+
process.stdout.write(text);
|
|
95
|
+
return NEW === 0;
|
|
96
|
+
}, (st) => ({ fields: `status=${st} new=${NEW}`, notice: `verify ${R} status=${st} new=${NEW} answer: ${O}.txt` }));
|
|
97
|
+
}
|
|
98
|
+
/** The failing test files again, on a fresh origin/<base> in a temporary worktree (only those that exist there). */
|
|
99
|
+
async function onBase(j, c, failed, tests, set) {
|
|
100
|
+
const REPO = join(j.ws, j.repo), B = j.base, BWT = join(j.ws, ".worktrees", `verifybase-${j.repo}-${process.pid}`);
|
|
101
|
+
if (git(REPO, "fetch", "-q", "origin", B) === undefined)
|
|
102
|
+
c.say(m(`WARNING: fetching origin/${B} failed; using the local origin/${B}`, `AVISO: o fetch de origin/${B} falhou; usando o origin/${B} local`));
|
|
103
|
+
if (git(REPO, "worktree", "add", "-q", "--detach", BWT, `origin/${B}`) === undefined)
|
|
104
|
+
return `WARNING: could not create a worktree at origin/${B}; every failure counts as new`;
|
|
105
|
+
const drop = () => dropWorktree(REPO, BWT);
|
|
106
|
+
c.cleanup.push(drop);
|
|
107
|
+
try {
|
|
108
|
+
linkModules(REPO, BWT);
|
|
109
|
+
const files = [...new Set(failed.map((f) => f.replace(/ :: .*/, "")))].filter((f) => existsSync(join(BWT, f)));
|
|
110
|
+
const info = `${files.length} failing file(s) run on origin/${B} ${git(REPO, "rev-parse", "--short", `origin/${B}`) ?? "?"}`;
|
|
111
|
+
if (!files.length)
|
|
112
|
+
return info;
|
|
113
|
+
const base = await tests(BWT, files);
|
|
114
|
+
if (!base)
|
|
115
|
+
return `WARNING: the base run failed (see ${j.out}.log); every failure counts as new`;
|
|
116
|
+
set(base);
|
|
117
|
+
return info;
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
c.cleanup.splice(c.cleanup.indexOf(drop), 1);
|
|
121
|
+
drop();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// orq wait — Notifier for any conductor: block until children started by `orq agent` (or orq verify/baseline) write
|
|
2
|
+
// <prefix>.done, then print one line each: "[ORQ AGENT DONE] <title> status=ok rc=0 ... answer: <file>".
|
|
3
|
+
// Exit 0 = all ok, 1 = some failed (or its runner died without .done), 124 = timeout. With --any, returns at the first.
|
|
4
|
+
// Claude Code: run it as a background task; it wakes the conductor when it exits.
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
import { m } from "../i18n.js";
|
|
8
|
+
import { alive } from "../lock.js";
|
|
9
|
+
export const USAGE = "orq wait [--any] [--timeout <s>] [--answer] <prefix>... block until child agents finish (exit 1 = one failed)";
|
|
10
|
+
const read = (f) => { try {
|
|
11
|
+
return readFileSync(f, "utf8");
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
} };
|
|
16
|
+
/** Finished? → its summary line and whether it went well. */
|
|
17
|
+
export function doneLine(prefix) {
|
|
18
|
+
let done = read(`${prefix}.done`)?.trim();
|
|
19
|
+
if (done === undefined) {
|
|
20
|
+
const pid = Number(read(`${prefix}.pid`));
|
|
21
|
+
if (!pid || alive(pid) || existsSync(`${prefix}.done`))
|
|
22
|
+
return undefined;
|
|
23
|
+
done = `status=died ${m("(the runner exited without writing .done)", "(o runner saiu sem escrever .done)")}`;
|
|
24
|
+
}
|
|
25
|
+
let title = basename(prefix);
|
|
26
|
+
try {
|
|
27
|
+
title = JSON.parse(read(`${prefix}.job.json`) ?? "").title || title;
|
|
28
|
+
}
|
|
29
|
+
catch { /* verify/baseline: no job */ }
|
|
30
|
+
const answer = [`${prefix}.out.md`, `${prefix}.txt`].find(existsSync) ?? prefix;
|
|
31
|
+
return { line: `[ORQ AGENT DONE] ${title} ${done} answer: ${answer}`, ok: /\bstatus=ok\b/.test(done) };
|
|
32
|
+
}
|
|
33
|
+
export async function wait(argv) {
|
|
34
|
+
let any = false, timeout = 0, answer = false;
|
|
35
|
+
const pending = [];
|
|
36
|
+
for (let i = 0; i < argv.length; i++) {
|
|
37
|
+
const a = argv[i];
|
|
38
|
+
if (a === "--any")
|
|
39
|
+
any = true;
|
|
40
|
+
else if (a === "--answer")
|
|
41
|
+
answer = true;
|
|
42
|
+
else if (a === "--timeout")
|
|
43
|
+
timeout = Number(argv[++i]);
|
|
44
|
+
else if (!a.startsWith("-"))
|
|
45
|
+
pending.push(a.replace(/\.(done|out\.md)$/, ""));
|
|
46
|
+
else {
|
|
47
|
+
console.error(USAGE);
|
|
48
|
+
return 2;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!pending.length || Number.isNaN(timeout)) {
|
|
52
|
+
console.error(USAGE);
|
|
53
|
+
return 2;
|
|
54
|
+
}
|
|
55
|
+
const end = timeout > 0 ? Date.now() + timeout * 1000 : Infinity;
|
|
56
|
+
let failed = false;
|
|
57
|
+
for (;;) {
|
|
58
|
+
for (const p of [...pending]) {
|
|
59
|
+
const d = doneLine(p);
|
|
60
|
+
if (!d)
|
|
61
|
+
continue;
|
|
62
|
+
pending.splice(pending.indexOf(p), 1);
|
|
63
|
+
console.log(d.line);
|
|
64
|
+
if (answer) {
|
|
65
|
+
const t = read(`${p}.out.md`);
|
|
66
|
+
if (t)
|
|
67
|
+
console.log(t.trimEnd());
|
|
68
|
+
}
|
|
69
|
+
failed ||= !d.ok;
|
|
70
|
+
if (any)
|
|
71
|
+
return d.ok ? 0 : 1;
|
|
72
|
+
}
|
|
73
|
+
if (!pending.length)
|
|
74
|
+
return failed ? 1 : 0;
|
|
75
|
+
if (Date.now() > end) {
|
|
76
|
+
console.log(m(`still running: ${pending.join(" ")}`, `ainda rodando: ${pending.join(" ")}`));
|
|
77
|
+
return 124;
|
|
78
|
+
}
|
|
79
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
80
|
+
}
|
|
81
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The orq configuration, merged in layers (each overrides the previous, key by key):
|
|
2
|
+
// 1. built-in defaults (DEFAULTS, below)
|
|
3
|
+
// 2. personal: $ORQ_CONFIG, or <config dir>/config.toml | config.json
|
|
4
|
+
// 3. workspace: <workspace>/.orq/workspace.toml | workspace.json (searched from -w or the current folder, upwards)
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { parse as parseToml } from "smol-toml";
|
|
8
|
+
import { configDir, expand } from "./paths.js";
|
|
9
|
+
import { m } from "./i18n.js";
|
|
10
|
+
export const DEFAULTS = {
|
|
11
|
+
machine: {
|
|
12
|
+
agent_mem_max: "4G", // memory cap per agent
|
|
13
|
+
agent_swap_max: "1G",
|
|
14
|
+
test_mem_max: "6G", // cap per test suite (orq verify, orq baseline)
|
|
15
|
+
mem_enforce: "auto", // auto (kernel cap where possible, else watchdog) | kernel | watchdog | off
|
|
16
|
+
test_slots: 1, // test suites in parallel on the machine
|
|
17
|
+
close_tab_after: 20, // seconds until a finished child tab closes (0 = never)
|
|
18
|
+
tui: false, // children in the agent's own screen (-T) by default
|
|
19
|
+
terminal: "auto", // where children show their output: auto (herdr, else tmux, else plain) | herdr | tmux | plain
|
|
20
|
+
},
|
|
21
|
+
agents: {}, // only agents declared here are available; run templates: built in for 5 (src/agents.ts), or `run = ...`
|
|
22
|
+
policy: {
|
|
23
|
+
quota_limit: 90, // quota % from which an agent/account is skipped
|
|
24
|
+
max_rounds: 2, // fix rounds before stopping and calling the user
|
|
25
|
+
review: "other-vendor", // other-vendor | any
|
|
26
|
+
},
|
|
27
|
+
ui: { lang: "auto", theme: "gunmetal" }, // lang: auto | en | pt · theme: gunmetal | chrome | lcd | terminal
|
|
28
|
+
// Where quota comes from, per agent: "builtin" | "none" | false | { command = "..." } (see src/quota.ts).
|
|
29
|
+
// "claude" covers every account in claude-accounts.
|
|
30
|
+
quota: { claude: "builtin", codex: "builtin", opencode: "builtin", antigravity: "builtin", copilot: "builtin" },
|
|
31
|
+
tracker: { kind: "none" }, // plane | none
|
|
32
|
+
repos: {}, // defined by the workspace
|
|
33
|
+
};
|
|
34
|
+
export class ConfigError extends Error {
|
|
35
|
+
}
|
|
36
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
37
|
+
export function merge(base, over) {
|
|
38
|
+
const out = { ...base };
|
|
39
|
+
for (const [k, v] of Object.entries(over))
|
|
40
|
+
out[k] = isObj(v) && isObj(out[k]) ? merge(out[k], v) : v;
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
function expandAll(v) {
|
|
44
|
+
if (Array.isArray(v))
|
|
45
|
+
return v.map(expandAll);
|
|
46
|
+
if (isObj(v))
|
|
47
|
+
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, expandAll(x)]));
|
|
48
|
+
return typeof v === "string" ? expand(v) : v;
|
|
49
|
+
}
|
|
50
|
+
export function loadFile(path) {
|
|
51
|
+
const text = readFileSync(path, "utf8");
|
|
52
|
+
try {
|
|
53
|
+
return (path.endsWith(".json") ? JSON.parse(text) : parseToml(text));
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
throw new ConfigError(m(`orq config: error reading ${path}: ${e.message}`, `orq config: erro lendo ${path}: ${e.message}`));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const firstFile = (...c) => c.find((f) => f && existsSync(f));
|
|
60
|
+
export function findWorkspace(start) {
|
|
61
|
+
for (let d = resolve(start);; d = dirname(d)) {
|
|
62
|
+
const file = firstFile(join(d, ".orq", "workspace.toml"), join(d, ".orq", "workspace.json"));
|
|
63
|
+
if (file)
|
|
64
|
+
return { root: d, file };
|
|
65
|
+
if (dirname(d) === d)
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function loadConfig(workspace) {
|
|
70
|
+
const user = firstFile(process.env.ORQ_CONFIG && expand(process.env.ORQ_CONFIG), join(configDir(), "config.toml"), join(configDir(), "config.json"));
|
|
71
|
+
const ws = findWorkspace(workspace ?? process.cwd());
|
|
72
|
+
let cfg = DEFAULTS;
|
|
73
|
+
const files = [];
|
|
74
|
+
for (const f of [user, ws.file]) {
|
|
75
|
+
if (!f)
|
|
76
|
+
continue;
|
|
77
|
+
cfg = merge(cfg, loadFile(f));
|
|
78
|
+
files.push(f);
|
|
79
|
+
}
|
|
80
|
+
cfg = expandAll(cfg);
|
|
81
|
+
cfg._workspace = ws.root ?? null;
|
|
82
|
+
return { cfg, files };
|
|
83
|
+
}
|
|
84
|
+
/** Value at a dotted path (a.b.c), or undefined. */
|
|
85
|
+
export function getPath(cfg, path) {
|
|
86
|
+
let v = cfg;
|
|
87
|
+
for (const part of path.split(".")) {
|
|
88
|
+
if (!isObj(v) || !(part in v))
|
|
89
|
+
return undefined;
|
|
90
|
+
v = v[part];
|
|
91
|
+
}
|
|
92
|
+
return v;
|
|
93
|
+
}
|
package/dist/fsutil.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
/** Write via a temp file + rename, so a reader never sees a half-written file. */
|
|
4
|
+
export function writeAtomic(path, text) {
|
|
5
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
6
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
7
|
+
writeFileSync(tmp, text, "utf8");
|
|
8
|
+
renameSync(tmp, path);
|
|
9
|
+
}
|
package/dist/i18n.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function systemLang() {
|
|
2
|
+
const e = process.env;
|
|
3
|
+
const loc = e.LC_ALL || e.LC_MESSAGES || e.LANG || Intl.DateTimeFormat().resolvedOptions().locale || "";
|
|
4
|
+
return loc.toLowerCase().startsWith("pt") ? "pt" : "en";
|
|
5
|
+
}
|
|
6
|
+
export function detectLang(cfgLang, useEnv = true) {
|
|
7
|
+
const v = useEnv ? process.env.ORQ_LANG : undefined;
|
|
8
|
+
if (v === "en" || v === "pt")
|
|
9
|
+
return v;
|
|
10
|
+
if (cfgLang === "en" || cfgLang === "pt")
|
|
11
|
+
return cfgLang;
|
|
12
|
+
return systemLang();
|
|
13
|
+
}
|
|
14
|
+
let current;
|
|
15
|
+
export function setLang(l) {
|
|
16
|
+
current = l;
|
|
17
|
+
}
|
|
18
|
+
export function m(en, pt) {
|
|
19
|
+
current ??= detectLang();
|
|
20
|
+
return current === "pt" && pt !== undefined ? pt : en;
|
|
21
|
+
}
|