@irythill/orq 0.0.0-stage → 0.1.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 -3
- package/README.pt-BR.md +241 -0
- package/dist/accounts.js +18 -0
- package/dist/agents.js +202 -0
- package/dist/banner.js +87 -0
- package/dist/cli.js +90 -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/instructions.js +66 -0
- package/dist/commands/lang.js +35 -0
- package/dist/commands/lock.js +69 -0
- package/dist/commands/pick.js +91 -0
- package/dist/commands/quota.js +10 -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 +115 -0
- package/dist/process.js +147 -0
- package/dist/quota.js +276 -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 +27 -6
- package/skills/issue/SKILL.md +197 -0
- package/skills/orq-setup/SKILL.md +114 -0
- package/skills/orq-status/SKILL.md +37 -0
- package/templates/workspace.CLAUDE.md +49 -0
package/dist/stats.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Agent scoreboard from the .orquestra/ history: what each agent delivered and what it cost (port of orq-stats).
|
|
2
|
+
// Per run (<prefix>.run.sh + .log + .done + .out.md): agent, model, role (impl | fix | review | other), status,
|
|
3
|
+
// duration, tokens. "New" tokens = excluding cache reads (what actually weighs on quota); Codex without a session
|
|
4
|
+
// file only reports the total. Implementation delivery = the first review of the same repo in the issue said APPROVED.
|
|
5
|
+
// Limitation: follow-ups (-R) reuse the implementation prefix, so the "impl" row shows the last round of that prefix.
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { basename, join } from "node:path";
|
|
9
|
+
const ROLE = /^(impl|fix\d*|review\d*|[a-z]+-review\d*)-?/;
|
|
10
|
+
const read = (f) => { try {
|
|
11
|
+
return readFileSync(f, "utf8");
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return "";
|
|
15
|
+
} };
|
|
16
|
+
export function agentModel(run, log) {
|
|
17
|
+
const a = /\b(codex exec|codex resume|codex|claude -p|claude|opencode run|opencode|agy)\b/.exec(run);
|
|
18
|
+
const first = a ? a[1].split(" ")[0] : "?";
|
|
19
|
+
const agent = first === "agy" ? "antigravity" : first;
|
|
20
|
+
let model = /(?:\s-m|--model)\s+'?([^\s']+)/.exec(run)?.[1];
|
|
21
|
+
if (agent === "codex" && !model)
|
|
22
|
+
model = /^model: (\S+)/m.exec(log)?.[1] ?? "default";
|
|
23
|
+
return [agent, (model ?? "default").split("/").pop()];
|
|
24
|
+
}
|
|
25
|
+
function codexSession(sid) {
|
|
26
|
+
const root = join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "sessions");
|
|
27
|
+
const ls = (d) => { try {
|
|
28
|
+
return readdirSync(d);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
} };
|
|
33
|
+
for (const y of ls(root))
|
|
34
|
+
for (const mo of ls(join(root, y)))
|
|
35
|
+
for (const d of ls(join(root, y, mo))) {
|
|
36
|
+
const f = ls(join(root, y, mo, d)).find((x) => x.startsWith("rollout-") && x.endsWith(`-${sid}.jsonl`));
|
|
37
|
+
if (f)
|
|
38
|
+
return join(root, y, mo, d, f);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** [new, total] — new excludes cache reads. */
|
|
42
|
+
export function tokens(agent, log, sid = "") {
|
|
43
|
+
let neu = 0, tot = 0;
|
|
44
|
+
if (agent === "codex") {
|
|
45
|
+
const f = sid && codexSession(sid);
|
|
46
|
+
if (f) {
|
|
47
|
+
const last = read(f).split("\n").filter((l) => l.includes('"total_token_usage"')).pop();
|
|
48
|
+
const u = last && /"total_token_usage":(\{[^}]*\})/.exec(last);
|
|
49
|
+
if (u) {
|
|
50
|
+
const j = JSON.parse(u[1]);
|
|
51
|
+
return [j.total_tokens - (j.cached_input_tokens ?? 0), j.total_tokens];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const all = [...log.matchAll(/tokens used\s*\n\s*([\d,]+)/g)];
|
|
55
|
+
if (all.length)
|
|
56
|
+
tot = neu = Number(all[all.length - 1][1].replace(/,/g, ""));
|
|
57
|
+
return [neu, tot];
|
|
58
|
+
}
|
|
59
|
+
for (const line of log.split("\n")) {
|
|
60
|
+
if (!line.startsWith("{"))
|
|
61
|
+
continue;
|
|
62
|
+
let e;
|
|
63
|
+
try {
|
|
64
|
+
e = JSON.parse(line);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (agent === "claude" && e.type === "result") {
|
|
70
|
+
const u = e.usage ?? {};
|
|
71
|
+
const n = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.output_tokens ?? 0);
|
|
72
|
+
[neu, tot] = [n, n + (u.cache_read_input_tokens ?? 0)];
|
|
73
|
+
}
|
|
74
|
+
else if (agent === "opencode" && e.type === "step_finish") {
|
|
75
|
+
const t = e.part?.tokens ?? {};
|
|
76
|
+
neu += (t.total ?? 0) - (t.cache?.read ?? 0);
|
|
77
|
+
tot += t.total ?? 0;
|
|
78
|
+
}
|
|
79
|
+
else if (agent === "antigravity" && e.event === "result") {
|
|
80
|
+
const u = e.result?.usage ?? {};
|
|
81
|
+
[neu, tot] = [u.total_tokens ?? 0, (u.total_tokens ?? 0) + (u.cache_read_tokens ?? 0)]; // agy's total excludes cache
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return [neu, tot];
|
|
85
|
+
}
|
|
86
|
+
export function collectRuns(ws, days) {
|
|
87
|
+
const cutoff = Date.now() - days * 86400_000;
|
|
88
|
+
const base = join(ws, ".orquestra");
|
|
89
|
+
const runs = [];
|
|
90
|
+
const dirs = (() => { try {
|
|
91
|
+
return readdirSync(base);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return [];
|
|
95
|
+
} })();
|
|
96
|
+
for (const issue of dirs) {
|
|
97
|
+
let files;
|
|
98
|
+
try {
|
|
99
|
+
files = readdirSync(join(base, issue));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
for (const f of files.filter((x) => x.endsWith(".run.sh"))) {
|
|
105
|
+
const pre = join(base, issue, f.slice(0, -".run.sh".length));
|
|
106
|
+
const runF = `${pre}.run.sh`, done = `${pre}.done`;
|
|
107
|
+
const rs = statSync(runF);
|
|
108
|
+
if (rs.mtimeMs < cutoff || !existsSync(done))
|
|
109
|
+
continue;
|
|
110
|
+
const txt = read(runF);
|
|
111
|
+
if (txt.includes("herdr-verify") || txt.includes("herdr-baseline"))
|
|
112
|
+
continue;
|
|
113
|
+
const log = read(`${pre}.log`);
|
|
114
|
+
const [agent, model] = agentModel(txt, log);
|
|
115
|
+
const name = basename(pre);
|
|
116
|
+
const rm = ROLE.exec(name);
|
|
117
|
+
const role = rm && rm[1].includes("review") ? "review"
|
|
118
|
+
: name.startsWith("fix") || txt.includes("-R ") || txt.includes(" resume ") || txt.includes("--resume") ? "fix"
|
|
119
|
+
: name.startsWith("impl") ? "impl" : "other";
|
|
120
|
+
const out = read(`${pre}.out.md`);
|
|
121
|
+
const [neu, tot] = tokens(agent, log, read(`${pre}.id`).trim());
|
|
122
|
+
runs.push({ issue, name, agent, model, role, ok: read(done).includes("status=ok") && out.trim() !== "",
|
|
123
|
+
secs: Math.max(0, Math.floor((statSync(done).mtimeMs - rs.mtimeMs) / 1000)), new: neu, tot,
|
|
124
|
+
repo: name.includes("-") ? name.slice(name.indexOf("-") + 1) : "" });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Implementation delivery: the first review of the same repo in the issue.
|
|
128
|
+
for (const r of runs.filter((x) => x.role === "impl")) {
|
|
129
|
+
const rv = join(base, r.issue, `review-${r.repo}.out.md`);
|
|
130
|
+
if (!existsSync(rv))
|
|
131
|
+
continue;
|
|
132
|
+
// "non-blocking suggestion" shows up in approved reviews: the verdict is the word APPROVED (APROVADO in older runs).
|
|
133
|
+
const t = read(rv).slice(0, 4000).toUpperCase();
|
|
134
|
+
r.approved = (t.includes("APPROVED") || t.includes("APROVADO")) &&
|
|
135
|
+
!["NOT APPROVED", "REPROVADO", "NÃO APROVADO"].some((x) => t.includes(x));
|
|
136
|
+
}
|
|
137
|
+
return runs;
|
|
138
|
+
}
|
|
139
|
+
const median = (xs) => {
|
|
140
|
+
const s = [...xs].sort((a, b) => a - b), h = s.length >> 1;
|
|
141
|
+
return s.length % 2 ? s[h] : (s[h - 1] + s[h]) / 2;
|
|
142
|
+
};
|
|
143
|
+
export function rows(runs) {
|
|
144
|
+
const groups = new Map();
|
|
145
|
+
for (const r of runs) {
|
|
146
|
+
const k = JSON.stringify([r.role, r.agent, r.model]);
|
|
147
|
+
groups.set(k, [...(groups.get(k) ?? []), r]);
|
|
148
|
+
}
|
|
149
|
+
return [...groups.keys()].sort().map((k) => {
|
|
150
|
+
const rs = groups.get(k), [role, agent, model] = JSON.parse(k);
|
|
151
|
+
const withTok = rs.filter((r) => r.tot), appr = rs.filter((r) => r.approved !== undefined).map((r) => r.approved);
|
|
152
|
+
return { role, agent, model, n: rs.length, ok: Math.round(100 * rs.filter((r) => r.ok).length / rs.length),
|
|
153
|
+
approved: appr.length ? Math.round(100 * appr.filter(Boolean).length / appr.length) : null, n_appr: appr.length,
|
|
154
|
+
min: Math.round(median(rs.map((r) => r.secs)) / 6) / 10,
|
|
155
|
+
new: withTok.length ? Math.floor(median(withTok.map((r) => r.new))) : null,
|
|
156
|
+
tot: withTok.length ? Math.floor(median(withTok.map((r) => r.tot))) : null };
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
export const scoreboard = (ws, days) => rows(collectRuns(ws, days));
|
package/dist/suggest.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Guided setup: turns what a user knows (plans, trust, parallel tasks, RAM) into roles, weights and memory caps,
|
|
2
|
+
// with a reason for each choice. The rules are the ones the case study found by hand (docs/case-study.md):
|
|
3
|
+
// shares quota with the conductor → reserve
|
|
4
|
+
// small plan / monthly allowance → opinion only
|
|
5
|
+
// pay-as-you-go → reviewer pool, not primary
|
|
6
|
+
// no free shell when headless (agy) → reviewer only, diff attached
|
|
7
|
+
// trusted, else largest plan, not conductor → primary
|
|
8
|
+
// vendor ≠ primary's → reviewer (cross review)
|
|
9
|
+
import { m } from "./i18n.js";
|
|
10
|
+
import { BUILTIN_VENDORS } from "./roles.js";
|
|
11
|
+
/** Agents that only read when headless (their CLI denies commands outside an allowlist). */
|
|
12
|
+
const READ_ONLY = new Set(["antigravity"]);
|
|
13
|
+
const RANK = { large: 3, free: 2, payg: 1, small: 0 };
|
|
14
|
+
export function suggestRoles(a) {
|
|
15
|
+
const names = Object.keys(a.agents);
|
|
16
|
+
if (!names.length)
|
|
17
|
+
throw new Error(m("no agent to set up", "nenhum agente para configurar"));
|
|
18
|
+
const vendor = (n) => a.agents[n].vendor ?? BUILTIN_VENDORS[n] ?? n;
|
|
19
|
+
const reasons = [], notices = [];
|
|
20
|
+
const isConductor = (n) => n === a.conductor;
|
|
21
|
+
const canImpl = (n) => !READ_ONLY.has(n);
|
|
22
|
+
const single = a.preset === "single-agent" || names.length === 1;
|
|
23
|
+
// Primary: the trusted one, else the largest plan; avoid the conductor's quota, small plans and pay-as-you-go.
|
|
24
|
+
const implCands = names.filter(canImpl);
|
|
25
|
+
const byPlan = (xs) => [...xs].sort((x, y) => Number(Boolean(a.agents[y].trust)) - Number(Boolean(a.agents[x].trust))
|
|
26
|
+
|| Number(isConductor(x)) - Number(isConductor(y))
|
|
27
|
+
|| RANK[a.agents[y].plan] - RANK[a.agents[x].plan]);
|
|
28
|
+
const primary = byPlan(implCands.length ? implCands : names)[0];
|
|
29
|
+
const why = a.agents[primary].trust ? m("the one you trust most", "o que você mais confia")
|
|
30
|
+
: m("largest plan apart from the conductor's", "maior plano fora o do conductor");
|
|
31
|
+
reasons.push(`primary = ${primary}: ${why}`);
|
|
32
|
+
if (!canImpl(primary))
|
|
33
|
+
notices.push(m(`${primary} only reads when headless: it can't implement well`, `${primary} só lê no modo headless: não implementa bem`));
|
|
34
|
+
const workers = implCands.filter((n) => n !== primary && !["small", "payg"].includes(a.agents[n].plan));
|
|
35
|
+
const opinion = names.filter((n) => n !== primary && a.agents[n].plan === "small");
|
|
36
|
+
const otherVendor = names.filter((n) => vendor(n) !== vendor(primary) && !opinion.includes(n));
|
|
37
|
+
let reviewers = otherVendor.length ? otherVendor : names.filter((n) => !opinion.includes(n));
|
|
38
|
+
const rule = otherVendor.length && !single ? "other-vendor" : "any";
|
|
39
|
+
if (single)
|
|
40
|
+
reviewers = [primary];
|
|
41
|
+
if (single) {
|
|
42
|
+
notices.push(m("One agent does everything; reviews run in a fresh session. Cross-vendor review catches more — consider a second agent.", "Um agente faz tudo; a revisão roda numa sessão nova. Revisão entre fornecedores pega mais erros — considere um segundo agente."));
|
|
43
|
+
}
|
|
44
|
+
else if (!otherVendor.length) {
|
|
45
|
+
notices.push(m(`All agents are from ${vendor(primary)}: cross-vendor review is off (rule = "any"); review uses another session/model.`, `Todos os agentes são de ${vendor(primary)}: revisão entre fornecedores desligada (rule = "any"); a revisão usa outra sessão/modelo.`));
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
reasons.push(`reviewer = ${reviewers.join(", ")}: ` + m(`vendor different from ${primary}'s (cross review)`, `fornecedor diferente do de ${primary} (revisão cruzada)`));
|
|
49
|
+
}
|
|
50
|
+
const agents = {};
|
|
51
|
+
for (const n of names) {
|
|
52
|
+
const ans = a.agents[n], roles = [];
|
|
53
|
+
if (n === primary || (!single && workers.includes(n)))
|
|
54
|
+
roles.push("impl", "fix");
|
|
55
|
+
if (reviewers.includes(n))
|
|
56
|
+
roles.push("review");
|
|
57
|
+
if (opinion.includes(n) && !single)
|
|
58
|
+
roles.push("opinion");
|
|
59
|
+
agents[n] = { vendor: vendor(n), roles, ...(ans.quota ? { quota: ans.quota } : {}) };
|
|
60
|
+
if (isConductor(n) && n !== primary && !single) {
|
|
61
|
+
agents[n].reserve = true;
|
|
62
|
+
reasons.push(`${n}: reserve — ` + m("shares quota with the conductor", "divide cota com o conductor"));
|
|
63
|
+
}
|
|
64
|
+
if (ans.plan === "small" && !single)
|
|
65
|
+
reasons.push(`${n}: opinion — ` + m("small plan or monthly allowance", "plano pequeno ou franquia mensal"));
|
|
66
|
+
if (ans.plan === "payg" && n !== primary)
|
|
67
|
+
reasons.push(`${n}: ` + m("pay-as-you-go → reviews (few tokens)", "pague-por-uso → revisa (poucos tokens)"));
|
|
68
|
+
if (READ_ONLY.has(n) && n !== primary)
|
|
69
|
+
reasons.push(`${n}: ` + m("reviews only (reads when headless, diff attached)", "só revisa (só lê no headless, diff anexado)"));
|
|
70
|
+
}
|
|
71
|
+
const roles = {
|
|
72
|
+
conductor: { agent: a.conductor },
|
|
73
|
+
primary: { agent: primary, ...(workers.length ? { fallback: workers } : {}) },
|
|
74
|
+
reviewer: { pick: "auto", pool: reviewers, rule },
|
|
75
|
+
};
|
|
76
|
+
if (workers.length)
|
|
77
|
+
roles.workers = { pick: "auto", pool: [primary, ...workers] };
|
|
78
|
+
if (opinion.length && !single)
|
|
79
|
+
roles.opinion = { pick: "auto", pool: opinion };
|
|
80
|
+
const quality = a.preset === "quality" || (a.preset !== "economy" && a.parallel <= 2);
|
|
81
|
+
const weights = quality ? { quality: 0.55, tokens: 0.3, speed: 0.15 } : { quality: 0.4, tokens: 0.45, speed: 0.15 };
|
|
82
|
+
reasons.push(quality ? m("weights favor quality (few tasks at once)", "pesos priorizam qualidade (poucas tarefas por vez)")
|
|
83
|
+
: m("weights favor tokens (several tasks at once)", "pesos priorizam tokens (várias tarefas por vez)"));
|
|
84
|
+
// Memory: ~60 % of RAM for agents; rarely more than two peak at once, so it is split by min(parallel, 2); 2–8 GB
|
|
85
|
+
// each. Tests get a bit more, up to 40 % of RAM.
|
|
86
|
+
const per = Math.max(2, Math.min(8, Math.floor((a.ramGB * 0.6) / Math.min(Math.max(1, a.parallel), 2))));
|
|
87
|
+
const test = Math.max(2, Math.min(per + 2, Math.floor(a.ramGB * 0.4)));
|
|
88
|
+
return { agents, roles, weights, machine: { agent_mem_max: `${per}G`, test_mem_max: `${test}G` }, reasons, notices };
|
|
89
|
+
}
|
|
90
|
+
const q = (s) => JSON.stringify(s);
|
|
91
|
+
const list = (xs) => `[${xs.map(q).join(", ")}]`;
|
|
92
|
+
/** The proposal as a commented config.toml (new installs; agent models are left to the CLI defaults). */
|
|
93
|
+
export function toToml(p) {
|
|
94
|
+
const out = ["# Written by `orq setup`. Edit freely: everything here is a suggestion (docs/portability.md §4).", "",
|
|
95
|
+
"[machine]", `agent_mem_max = ${q(p.machine.agent_mem_max)}`, `test_mem_max = ${q(p.machine.test_mem_max)}`, ""];
|
|
96
|
+
for (const [n, a] of Object.entries(p.agents)) {
|
|
97
|
+
out.push(`[agents.${n}]`, `vendor = ${q(a.vendor)}`, `roles = ${list(a.roles)}`);
|
|
98
|
+
if (a.quota)
|
|
99
|
+
out.push(`quota = ${list(a.quota)}`);
|
|
100
|
+
if (a.reserve)
|
|
101
|
+
out.push("reserve = true");
|
|
102
|
+
out.push("# models.<role>.<level> = \"model[@effort]\" — without models, the CLI's default model is used", "");
|
|
103
|
+
}
|
|
104
|
+
for (const [r, v] of Object.entries(p.roles)) {
|
|
105
|
+
out.push(`[roles.${r}]`);
|
|
106
|
+
if (v.agent)
|
|
107
|
+
out.push(`agent = ${q(v.agent)}`);
|
|
108
|
+
if (v.fallback)
|
|
109
|
+
out.push(`fallback = ${list(v.fallback)}`);
|
|
110
|
+
if (v.pick)
|
|
111
|
+
out.push(`pick = ${q(v.pick)}`);
|
|
112
|
+
if (v.pool)
|
|
113
|
+
out.push(`pool = ${list(v.pool)}`);
|
|
114
|
+
if (v.rule)
|
|
115
|
+
out.push(`rule = ${q(v.rule)}`);
|
|
116
|
+
out.push("");
|
|
117
|
+
}
|
|
118
|
+
out.push("[policy.weights]", `quality = ${p.weights.quality}`, `tokens = ${p.weights.tokens}`, `speed = ${p.weights.speed}`, "");
|
|
119
|
+
out.push("# Why:", ...p.reasons.map((r) => `# ${r}`), ...p.notices.map((n) => `# ! ${n}`), "");
|
|
120
|
+
return out.join("\n");
|
|
121
|
+
}
|
package/dist/terminal.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// TerminalHost — where a child agent's live output goes. machine.terminal (or $ORQ_TERMINAL): auto | herdr | tmux | plain.
|
|
2
|
+
// herdr a tab in the herdr workspace named after the group (created if missing), without stealing focus (herdr-tab)
|
|
3
|
+
// tmux a background window in the current tmux session
|
|
4
|
+
// plain a detached background process; its screen output goes to <state dir>/agents/<run>.log
|
|
5
|
+
// auto = herdr inside a herdr pane (HERDR_PANE_ID), else tmux inside tmux (TMUX), else plain.
|
|
6
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
7
|
+
import { closeSync, mkdirSync, openSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { m } from "./i18n.js";
|
|
10
|
+
import { stateDir } from "./paths.js";
|
|
11
|
+
export function detectHost(cfg) {
|
|
12
|
+
const want = String(process.env.ORQ_TERMINAL || (cfg.machine ?? {}).terminal || "auto");
|
|
13
|
+
if (want === "herdr" || want === "tmux" || want === "plain")
|
|
14
|
+
return want;
|
|
15
|
+
if (want !== "auto")
|
|
16
|
+
throw new Error(m(`unknown terminal: ${want} (auto | herdr | tmux | plain)`, `terminal desconhecido: ${want} (auto | herdr | tmux | plain)`));
|
|
17
|
+
if (process.env.HERDR_PANE_ID && spawnSync("herdr", ["status", "server"], { stdio: "ignore" }).status === 0)
|
|
18
|
+
return "herdr";
|
|
19
|
+
if (process.env.TMUX)
|
|
20
|
+
return "tmux";
|
|
21
|
+
return "plain";
|
|
22
|
+
}
|
|
23
|
+
/** POSIX shell quoting (herdr and tmux type the command into a shell). */
|
|
24
|
+
export const shq = (s) => /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
25
|
+
function herdr(args) {
|
|
26
|
+
const r = spawnSync("herdr", args, { encoding: "utf8" });
|
|
27
|
+
if (r.status !== 0)
|
|
28
|
+
throw new Error(`herdr ${args.slice(0, 2).join(" ")}: ${(r.stderr || r.stdout || "").trim()}`);
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(r.stdout || "{}");
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Start `argv` on the host; returns where it runs (pane id, tmux pane, or pid:<n>). */
|
|
37
|
+
export function launch(host, l) {
|
|
38
|
+
const line = l.argv.map(shq).join(" ");
|
|
39
|
+
if (host === "herdr") {
|
|
40
|
+
if (spawnSync("herdr", ["status", "server"], { stdio: "ignore" }).status !== 0)
|
|
41
|
+
throw new Error(m("the herdr server is not running (systemctl --user start herdr)", "o servidor do herdr não está rodando (systemctl --user start herdr)"));
|
|
42
|
+
const ws = (herdr(["workspace", "list"]).result?.workspaces ?? []);
|
|
43
|
+
const w = ws.find((x) => x.label === l.group)?.workspace_id;
|
|
44
|
+
let r;
|
|
45
|
+
if (!w) {
|
|
46
|
+
r = herdr(["workspace", "create", "--cwd", l.dir, "--label", l.group, "--no-focus"]).result;
|
|
47
|
+
spawnSync("herdr", ["tab", "rename", String(r?.tab?.tab_id), l.title], { stdio: "ignore" });
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
r = herdr(["tab", "create", "--workspace", w, "--cwd", l.dir, "--label", l.title, "--no-focus"]).result;
|
|
51
|
+
const pane = r?.root_pane?.pane_id;
|
|
52
|
+
if (!pane)
|
|
53
|
+
throw new Error(m("herdr returned no pane", "o herdr não devolveu um pane"));
|
|
54
|
+
spawnSync("herdr", ["pane", "rename", pane, l.title], { stdio: "ignore" });
|
|
55
|
+
herdr(["pane", "run", pane, line]);
|
|
56
|
+
return pane;
|
|
57
|
+
}
|
|
58
|
+
if (host === "tmux") {
|
|
59
|
+
const cmd = l.keep ? `${line}; exec "\${SHELL:-sh}"` : line;
|
|
60
|
+
const r = spawnSync("tmux", ["new-window", "-d", "-P", "-F", "#{pane_id}", "-n", l.title, "-c", l.dir, cmd], { encoding: "utf8" });
|
|
61
|
+
if (r.status !== 0)
|
|
62
|
+
throw new Error(`tmux new-window: ${(r.stderr || "").trim()}`);
|
|
63
|
+
return `tmux:${r.stdout.trim()}`;
|
|
64
|
+
}
|
|
65
|
+
mkdirSync(join(stateDir(), "agents"), { recursive: true });
|
|
66
|
+
const fd = openSync(join(stateDir(), "agents", `${l.id}.log`), "a");
|
|
67
|
+
const c = spawn(l.argv[0], l.argv.slice(1), { cwd: l.dir, detached: true, stdio: ["ignore", fd, fd], windowsHide: true });
|
|
68
|
+
c.unref();
|
|
69
|
+
closeSync(fd);
|
|
70
|
+
return `pid:${c.pid}`;
|
|
71
|
+
}
|
|
72
|
+
/** After a run: close our own herdr tab (the result stays in the files). tmux windows close when the runner exits. */
|
|
73
|
+
export function closeOwnTab(host) {
|
|
74
|
+
const p = process.env.HERDR_PANE_ID;
|
|
75
|
+
if (host !== "herdr" || !p)
|
|
76
|
+
return;
|
|
77
|
+
try {
|
|
78
|
+
const tab = herdr(["pane", "get", p]).result?.pane?.tab_id;
|
|
79
|
+
if (tab)
|
|
80
|
+
spawnSync("herdr", ["tab", "close", tab], { stdio: "ignore" });
|
|
81
|
+
}
|
|
82
|
+
catch { /* already gone */ }
|
|
83
|
+
}
|
package/dist/testrun.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Test runs for orq verify / orq baseline: detect jest or vitest from package.json "test", run it under the test
|
|
2
|
+
// memory cap (src/process.ts) and read its JSON report into "<file relative to the repo> :: <full test name>" lines.
|
|
3
|
+
// The runner's own bin (node_modules/<runner>/package.json "bin") runs with this node, so Windows needs no shell;
|
|
4
|
+
// without it, npx as before. Also the background job both share (launch on a TerminalHost, queue, .pid/.done, notify).
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { createWriteStream, existsSync, lstatSync, readFileSync, realpathSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { writeAtomic } from "./fsutil.js";
|
|
10
|
+
import { m } from "./i18n.js";
|
|
11
|
+
import { acquire, release } from "./lock.js";
|
|
12
|
+
import { killTree, parseSize, runCapped } from "./process.js";
|
|
13
|
+
import { closeOwnTab, detectHost, launch } from "./terminal.js";
|
|
14
|
+
export const JS_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/;
|
|
15
|
+
export const git = (dir, ...a) => {
|
|
16
|
+
const r = spawnSync("git", ["-C", dir, ...a], { encoding: "utf8", maxBuffer: 1 << 28 });
|
|
17
|
+
return r.status === 0 ? r.stdout.trim() : undefined;
|
|
18
|
+
};
|
|
19
|
+
/** jest | vitest from the "test" script (vitest first, as herdr-verify did). */
|
|
20
|
+
export function runnerOf(dir) {
|
|
21
|
+
let script = "";
|
|
22
|
+
try {
|
|
23
|
+
script = String(JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).scripts?.test ?? "");
|
|
24
|
+
}
|
|
25
|
+
catch { /* none */ }
|
|
26
|
+
return { runner: script.includes("vitest") ? "vitest" : script.includes("jest") ? "jest" : undefined, script };
|
|
27
|
+
}
|
|
28
|
+
/** Runner arguments: "related" = tests related to these sources, "files" = these test files, "all" = full suite. */
|
|
29
|
+
export function runnerArgs(runner, mode, files, json) {
|
|
30
|
+
const some = mode !== "all";
|
|
31
|
+
if (runner === "vitest")
|
|
32
|
+
return [mode === "related" ? "related" : "run", ...files, ...(some ? ["--run", "--passWithNoTests"] : []),
|
|
33
|
+
"--reporter=json", `--outputFile=${json}`, "--maxWorkers=2"];
|
|
34
|
+
return [...(mode === "related" ? ["--findRelatedTests"] : mode === "files" ? ["--runTestsByPath"] : []), ...files,
|
|
35
|
+
...(some ? ["--passWithNoTests"] : []), "--json", `--outputFile=${json}`, "--maxWorkers=2", "--workerIdleMemoryLimit=1G"];
|
|
36
|
+
}
|
|
37
|
+
/** The runner's own JS entry in <dir>/node_modules, if any. */
|
|
38
|
+
export function localBin(dir, runner) {
|
|
39
|
+
try {
|
|
40
|
+
const pj = join(dir, "node_modules", runner, "package.json");
|
|
41
|
+
const bin = JSON.parse(readFileSync(pj, "utf8")).bin;
|
|
42
|
+
const rel = typeof bin === "string" ? bin : bin?.[runner];
|
|
43
|
+
return rel && existsSync(join(dirname(pj), rel)) ? join(dirname(pj), rel) : undefined;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Memory cap for a test suite: $VERIFY_MEM_MAX, else machine.test_mem_max. */
|
|
50
|
+
export function testCap(cfg) {
|
|
51
|
+
const mach = (cfg.machine ?? {});
|
|
52
|
+
return { cap: parseSize(process.env.VERIFY_MEM_MAX || String(mach.test_mem_max ?? "off")), mode: mach.mem_enforce ?? "auto" };
|
|
53
|
+
}
|
|
54
|
+
/** Run the runner in `dir` under the cap; its output goes to the screen and log (unless !show). Resolves to the exit code. */
|
|
55
|
+
export function runTests(dir, runner, args, cap, c, show = true) {
|
|
56
|
+
const bin = localBin(dir, runner);
|
|
57
|
+
const [cmd, argv] = bin ? [process.execPath, [bin, ...args]] : ["npx", [runner, ...args]];
|
|
58
|
+
return runCapped(cmd, argv, {
|
|
59
|
+
...cap, cwd: dir, quote: true, stdio: show ? ["ignore", "pipe", "pipe"] : "ignore",
|
|
60
|
+
onSpawn: (p) => {
|
|
61
|
+
c.child = p.pid;
|
|
62
|
+
if (show)
|
|
63
|
+
for (const s of [p.stdout, p.stderr])
|
|
64
|
+
s?.setEncoding("utf8").on("data", (d) => { for (const l of d.replace(/\r?\n$/, "").split(/\r?\n/))
|
|
65
|
+
c.say(l); });
|
|
66
|
+
},
|
|
67
|
+
}).finally(() => { c.child = undefined; });
|
|
68
|
+
}
|
|
69
|
+
/** A test file path from the report, relative to the repo folder (forward slashes, as on Linux). */
|
|
70
|
+
export function relTo(dir, file) {
|
|
71
|
+
const dirs = [dir];
|
|
72
|
+
try {
|
|
73
|
+
dirs.push(realpathSync(dir));
|
|
74
|
+
}
|
|
75
|
+
catch { /* gone */ }
|
|
76
|
+
for (const d of dirs) {
|
|
77
|
+
const r = relative(d, file);
|
|
78
|
+
if (r && !r.startsWith("..") && !isAbsolute(r))
|
|
79
|
+
return r.split("\\").join("/");
|
|
80
|
+
}
|
|
81
|
+
return file.split("\\").join("/");
|
|
82
|
+
}
|
|
83
|
+
/** Failed tests in a jest/vitest JSON report → sorted unique "<file> :: <test>" lines, plus numTotalTests. */
|
|
84
|
+
export function failures(report, dir) {
|
|
85
|
+
let j;
|
|
86
|
+
try {
|
|
87
|
+
j = JSON.parse(readFileSync(report, "utf8"));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
const failed = new Set();
|
|
93
|
+
for (const f of j.testResults ?? [])
|
|
94
|
+
for (const a of f.assertionResults ?? [])
|
|
95
|
+
if (a.status === "failed")
|
|
96
|
+
failed.add(`${relTo(dir, f.name)} :: ${a.fullName}`);
|
|
97
|
+
return { failed: [...failed].sort(), total: j.numTotalTests ?? 0 };
|
|
98
|
+
}
|
|
99
|
+
/** node_modules of the main clone into a temporary worktree (a junction on Windows: no admin rights needed). */
|
|
100
|
+
export function linkModules(from, to) {
|
|
101
|
+
try {
|
|
102
|
+
symlinkSync(realpathSync(join(from, "node_modules")), join(to, "node_modules"), process.platform === "win32" ? "junction" : "dir");
|
|
103
|
+
}
|
|
104
|
+
catch { /* none */ }
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Remove a temporary worktree. The node_modules link goes first, by itself: git on Windows can't remove a junction
|
|
108
|
+
* (and must never walk into the main clone's node_modules through it).
|
|
109
|
+
*/
|
|
110
|
+
export function dropWorktree(repo, dir) {
|
|
111
|
+
const nm = join(dir, "node_modules");
|
|
112
|
+
try {
|
|
113
|
+
if (lstatSync(nm).isSymbolicLink() || process.platform === "win32")
|
|
114
|
+
unlinkSync(nm);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
try {
|
|
118
|
+
rmdirSync(nm);
|
|
119
|
+
}
|
|
120
|
+
catch { /* none */ }
|
|
121
|
+
}
|
|
122
|
+
if (git(repo, "worktree", "remove", "--force", dir) === undefined && existsSync(dir)) {
|
|
123
|
+
rmSync(dir, { recursive: true, force: true });
|
|
124
|
+
git(repo, "worktree", "prune");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** "2026-10-27" in local time (date -I). */
|
|
128
|
+
export const today = () => {
|
|
129
|
+
const d = new Date(), p = (n) => String(n).padStart(2, "0");
|
|
130
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
131
|
+
};
|
|
132
|
+
/** A line to both the screen and the run's log. */
|
|
133
|
+
export const teller = (log) => (l) => { process.stdout.write(`${l}\n`); log.write(`${l}\n`); };
|
|
134
|
+
export const unsupported = (script) => m(`unsupported runner: '${script}' (jest or vitest)`, `runner não suportado: '${script}' (jest ou vitest)`);
|
|
135
|
+
const CLI = fileURLToPath(new URL("./cli.js", import.meta.url));
|
|
136
|
+
/** Write <out>.job.json and start `orq <cmd> --run <job>` on the host (herdr tab, tmux window or background). */
|
|
137
|
+
export function launchJob(cfg, j, group) {
|
|
138
|
+
const mach = (cfg.machine ?? {});
|
|
139
|
+
const closeAfter = Number(process.env.HERDR_CLOSE_DELAY ?? mach.close_tab_after ?? 20);
|
|
140
|
+
const job = { ...j, parent: process.env.HERDR_PANE_ID ?? "", host: detectHost(cfg), closeAfter,
|
|
141
|
+
keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0 };
|
|
142
|
+
writeAtomic(`${j.out}.job.json`, JSON.stringify(job, null, 1));
|
|
143
|
+
return launch(job.host, { group, title: j.title, dir: j.cmd === "verify" ? j.wt : j.repo, keep: job.keep,
|
|
144
|
+
argv: [process.execPath, CLI, j.cmd, "--run", `${j.out}.job.json`], id: `${j.cmd}-${Math.floor(Date.now() / 1000)}-${process.pid}` });
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Run `body` as the job's runner: holds a slot in the "verify" queue (label shown by orq board), writes <out>.pid
|
|
148
|
+
* meanwhile and <out>.done after (`done(status)` gives its fields), notifies the conductor's herdr pane, closes its
|
|
149
|
+
* own tab. A signal (tab closed, Ctrl+C) still frees the slot, removes temporary worktrees and writes .done.
|
|
150
|
+
*/
|
|
151
|
+
export async function inJob(j, label, slots, body, done) {
|
|
152
|
+
const O = j.out;
|
|
153
|
+
writeFileSync(`${O}.pid`, String(process.pid));
|
|
154
|
+
const log = createWriteStream(`${O}.log`), say = teller(log);
|
|
155
|
+
const c = { say, slots, cleanup: [] };
|
|
156
|
+
let st = "fail", ended = false;
|
|
157
|
+
const finish = () => {
|
|
158
|
+
if (ended)
|
|
159
|
+
return;
|
|
160
|
+
ended = true;
|
|
161
|
+
for (const f of c.cleanup.splice(0))
|
|
162
|
+
try {
|
|
163
|
+
f();
|
|
164
|
+
}
|
|
165
|
+
catch { /* best effort */ }
|
|
166
|
+
release("verify", process.pid);
|
|
167
|
+
const { fields, notice } = done(st);
|
|
168
|
+
writeAtomic(`${O}.done`, `${fields} at=${new Date().toISOString()}\n`);
|
|
169
|
+
rmSync(`${O}.pid`, { force: true });
|
|
170
|
+
if (j.parent)
|
|
171
|
+
notify(j.parent, `[HERDR-AGENT DONE] ${notice}`);
|
|
172
|
+
say(`[${j.cmd} ${m("finished", "terminou")}: ${fields}] ${notice.replace(/.* answer: /, "")}`);
|
|
173
|
+
};
|
|
174
|
+
const stop = () => { if (c.child)
|
|
175
|
+
killTree(c.child); finish(); process.exit(130); };
|
|
176
|
+
const sigs = process.platform === "win32" ? ["SIGINT", "SIGBREAK", "SIGHUP"] : ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
177
|
+
for (const s of sigs)
|
|
178
|
+
process.on(s, stop);
|
|
179
|
+
try {
|
|
180
|
+
await acquire("verify", process.pid, label, slots, () => say(m("another verification is running; waiting for my turn...", "outra verificação em andamento; aguardando a vez...")));
|
|
181
|
+
if (await body(c))
|
|
182
|
+
st = "ok";
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
say(`orq ${j.cmd}: ${e.message}`);
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
finish();
|
|
189
|
+
for (const s of sigs)
|
|
190
|
+
process.off(s, stop);
|
|
191
|
+
}
|
|
192
|
+
await new Promise((r) => log.end(r));
|
|
193
|
+
if (j.host !== "plain" && !j.keep) {
|
|
194
|
+
await new Promise((r) => setTimeout(r, j.closeAfter * 1000));
|
|
195
|
+
closeOwnTab(j.host);
|
|
196
|
+
}
|
|
197
|
+
return st === "ok" ? 0 : 1;
|
|
198
|
+
}
|
|
199
|
+
/** The conductor is a herdr pane: type the line into it (herdr-notify waits for it to leave a dialog). */
|
|
200
|
+
export function notify(pane, line) {
|
|
201
|
+
const local = join(dirname(CLI), "..", "setup", "herdr-notify");
|
|
202
|
+
spawnSync(existsSync(local) ? local : "herdr-notify", [pane, line], { stdio: "inherit" });
|
|
203
|
+
}
|
package/dist/toml-set.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Writes `key = "value"` into [section] of a TOML text, keeping everything else (comments, order, blank lines).
|
|
2
|
+
// Replaces the key if present, adds it at the end of the section, or adds the section at the end of the file.
|
|
3
|
+
// Same behaviour as the awk in the old setup/orq-set.
|
|
4
|
+
export function setTomlString(text, section, key, value) {
|
|
5
|
+
const header = `[${section}]`;
|
|
6
|
+
const line = `${key} = ${JSON.stringify(value)}`;
|
|
7
|
+
const keyRe = new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=`);
|
|
8
|
+
const lines = text === "" ? [] : text.replace(/\r?\n$/, "").split(/\r?\n/);
|
|
9
|
+
const out = [];
|
|
10
|
+
let inSec = false, done = false, blanks = 0;
|
|
11
|
+
const flush = () => { for (; blanks > 0; blanks--)
|
|
12
|
+
out.push(""); };
|
|
13
|
+
for (const l of lines) {
|
|
14
|
+
if (l.startsWith("[")) {
|
|
15
|
+
if (inSec && !done) {
|
|
16
|
+
out.push(line);
|
|
17
|
+
done = true;
|
|
18
|
+
}
|
|
19
|
+
flush();
|
|
20
|
+
inSec = l.trim() === header;
|
|
21
|
+
out.push(l);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (inSec && l.trim() === "") {
|
|
25
|
+
blanks++;
|
|
26
|
+
continue;
|
|
27
|
+
} // hold blank lines inside the section
|
|
28
|
+
if (inSec && keyRe.test(l)) {
|
|
29
|
+
flush();
|
|
30
|
+
out.push(line);
|
|
31
|
+
done = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
flush();
|
|
35
|
+
out.push(l);
|
|
36
|
+
}
|
|
37
|
+
if (!done) {
|
|
38
|
+
if (!inSec) {
|
|
39
|
+
flush();
|
|
40
|
+
out.push("", header);
|
|
41
|
+
}
|
|
42
|
+
out.push(line);
|
|
43
|
+
}
|
|
44
|
+
flush();
|
|
45
|
+
return out.join("\n") + "\n";
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@irythill/orq",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@irythill/orq",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Orchestrate coding agents (Claude Code, Codex, OpenCode, Antigravity, Copilot) from one conductor.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "orq": "dist/cli.js" },
|
|
8
|
+
"files": ["dist", "!dist/**/*.test.js", "skills", "templates/workspace.CLAUDE.md"],
|
|
9
|
+
"engines": { "node": ">=20" },
|
|
10
|
+
"repository": { "type": "git", "url": "git+https://github.com/irythill/orq.git" },
|
|
11
|
+
"homepage": "https://github.com/irythill/orq#readme",
|
|
12
|
+
"keywords": ["agents", "orchestration", "claude-code", "codex", "opencode", "cli"],
|
|
13
|
+
"publishConfig": { "access": "public" },
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"test": "tsc && node --test dist/orq.test.js",
|
|
17
|
+
"prepack": "tsc"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@napi-rs/keyring": "^2.1.1",
|
|
21
|
+
"smol-toml": "^1.9.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^24.0.0",
|
|
25
|
+
"typescript": "^7.0.2"
|
|
26
|
+
}
|
|
27
|
+
}
|