@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -2
  3. package/README.pt-BR.md +242 -0
  4. package/dist/accounts.js +18 -0
  5. package/dist/agents.js +202 -0
  6. package/dist/banner.js +87 -0
  7. package/dist/cli.js +92 -0
  8. package/dist/commands/agent.js +334 -0
  9. package/dist/commands/baseline.js +101 -0
  10. package/dist/commands/block.js +74 -0
  11. package/dist/commands/board.js +435 -0
  12. package/dist/commands/claim.js +88 -0
  13. package/dist/commands/completion.js +88 -0
  14. package/dist/commands/config.js +35 -0
  15. package/dist/commands/deps.js +82 -0
  16. package/dist/commands/gc.js +164 -0
  17. package/dist/commands/instructions.js +66 -0
  18. package/dist/commands/lang.js +35 -0
  19. package/dist/commands/lock.js +69 -0
  20. package/dist/commands/pick.js +111 -0
  21. package/dist/commands/quota.js +40 -0
  22. package/dist/commands/run.js +46 -0
  23. package/dist/commands/set.js +27 -0
  24. package/dist/commands/setup.js +187 -0
  25. package/dist/commands/stats.js +41 -0
  26. package/dist/commands/verify.js +123 -0
  27. package/dist/commands/wait.js +81 -0
  28. package/dist/config.js +93 -0
  29. package/dist/fsutil.js +9 -0
  30. package/dist/i18n.js +21 -0
  31. package/dist/install.js +137 -0
  32. package/dist/keyring.js +51 -0
  33. package/dist/lock.js +81 -0
  34. package/dist/paths.js +34 -0
  35. package/dist/pick.js +126 -0
  36. package/dist/process.js +147 -0
  37. package/dist/quota.js +334 -0
  38. package/dist/roles.js +28 -0
  39. package/dist/stats.js +159 -0
  40. package/dist/suggest.js +121 -0
  41. package/dist/terminal.js +83 -0
  42. package/dist/testrun.js +203 -0
  43. package/dist/toml-set.js +46 -0
  44. package/package.json +46 -4
  45. package/skills/issue/SKILL.md +229 -0
  46. package/skills/orq-setup/SKILL.md +114 -0
  47. package/skills/orq-status/SKILL.md +37 -0
  48. package/templates/workspace.CLAUDE.md +49 -0
@@ -0,0 +1,137 @@
1
+ // What a fresh `npm i -g @irythill/orq` still needs, done by `orq setup` on any OS (install.sh is a Linux extra):
2
+ // skills orq's skills (skills/*/SKILL.md, shipped in the package) linked into ~/.claude/skills and each Claude
3
+ // account's skills folder — a junction on Windows (no admin rights), a copy if linking fails. A real
4
+ // folder, or a link to another live copy (a clone of the repo), is never touched.
5
+ // workspace <folder>/.orquestra, .worktrees, CLAUDE.md from the template (only if missing) and the /issue playbook
6
+ // in the conductor's format (orq instructions)
7
+ // environment what orq found: git, terminal host, memory cap, keyring, the shell's completion line
8
+ import { spawnSync } from "node:child_process";
9
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { basename, join, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { claudeAccounts } from "./accounts.js";
14
+ import { writeAtomic } from "./fsutil.js";
15
+ import { m } from "./i18n.js";
16
+ import { backend } from "./keyring.js";
17
+ import { hasKernelCap } from "./process.js";
18
+ import { detectHost } from "./terminal.js";
19
+ export const PKG = fileURLToPath(new URL("..", import.meta.url));
20
+ const SKILLS = join(PKG, "skills");
21
+ const real = (p) => { try {
22
+ return realpathSync(p);
23
+ }
24
+ catch {
25
+ return undefined;
26
+ } };
27
+ /** Skill folders of every Claude account (~/.claude always, like install.sh). */
28
+ export function skillRoots() {
29
+ return [...new Set([join(homedir(), ".claude"), ...claudeAccounts().map((a) => a.dir)].map((d) => join(d, "skills")))];
30
+ }
31
+ /** Link each of orq's skills into each root; one line per skill and root. */
32
+ export function installSkills(roots = skillRoots()) {
33
+ const out = [];
34
+ const skills = readdirSync(SKILLS).filter((s) => existsSync(join(SKILLS, s, "SKILL.md")));
35
+ for (const root of roots)
36
+ for (const s of skills) {
37
+ const src = join(SKILLS, s), dst = join(root, s);
38
+ let st;
39
+ try {
40
+ st = lstatSync(dst);
41
+ }
42
+ catch { /* missing */ }
43
+ if (st && !st.isSymbolicLink()) {
44
+ // A copy made by an earlier `orq setup` (no links on this system) is refreshed; anything else is the user's.
45
+ if (existsSync(join(dst, ".orq-copy"))) {
46
+ rmSync(dst, { recursive: true, force: true });
47
+ st = undefined;
48
+ }
49
+ else {
50
+ out.push(` ${m("skip", "pulei")} ${dst} ${m("(a real folder, not a link: move it away to install)", "(uma pasta de verdade, não um link: tire-a do caminho para instalar)")}`);
51
+ continue;
52
+ }
53
+ }
54
+ if (st) {
55
+ const to = real(dst);
56
+ if (to === real(src)) {
57
+ out.push(` ok ${dst}`);
58
+ continue;
59
+ }
60
+ if (to && existsSync(join(to, "SKILL.md"))) {
61
+ out.push(` ${m("keep", "mantive")} ${dst} → ${readlinkSync(dst)}`);
62
+ continue;
63
+ }
64
+ rmSync(dst, { force: true }); // dangling (e.g. an old node version's global folder)
65
+ }
66
+ mkdirSync(root, { recursive: true });
67
+ try {
68
+ symlinkSync(src, dst, process.platform === "win32" ? "junction" : "dir");
69
+ out.push(` link ${dst} → ${src}`);
70
+ }
71
+ catch {
72
+ cpSync(src, dst, { recursive: true });
73
+ writeAtomic(join(dst, ".orq-copy"), m("copied by orq setup; refreshed on every run\n", "copiado pelo orq setup; atualizado a cada execução\n"));
74
+ out.push(` ${m("copy", "cópia")} ${dst}`);
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+ /** Prepare a conductor's workspace folder; `fmt` = claude (SKILL.md) | agents (AGENTS.md). */
80
+ export function prepareWorkspace(dir, fmt, name = basename(resolve(dir))) {
81
+ const ws = resolve(dir), out = [];
82
+ for (const d of [".orquestra", ".worktrees"])
83
+ mkdirSync(join(ws, d), { recursive: true });
84
+ const map = join(ws, "CLAUDE.md");
85
+ if (!existsSync(map)) {
86
+ const t = readFileSync(join(PKG, "templates", "workspace.CLAUDE.md"), "utf8");
87
+ writeAtomic(map, t.split("{{WORKSPACE}}").join(ws).split("{{NAME}}").join(name));
88
+ out.push(m(` created ${map} — fill in repos, group, base branch, Plane and issue standard`, ` criado ${map} — preencha repos, grupo, branch base, Plane e padrão de issue`));
89
+ }
90
+ else
91
+ out.push(m(` ok ${map} (kept)`, ` ok ${map} (mantido)`));
92
+ const r = spawnSync(process.execPath, [join(PKG, "dist", "cli.js"), "instructions", fmt, "-w", ws], { encoding: "utf8" });
93
+ out.push(` ${(r.stdout || r.stderr).trim()}`);
94
+ return out;
95
+ }
96
+ const SHELLS = ["bash", "zsh", "fish", "powershell"];
97
+ /** The user's shell, from $SHELL (PowerShell on Windows without one). */
98
+ export function userShell() {
99
+ const s = basename(process.env.SHELL ?? "").replace(/\.exe$/i, "");
100
+ if (s === "pwsh")
101
+ return "powershell";
102
+ if (SHELLS.includes(s))
103
+ return s;
104
+ return process.platform === "win32" ? "powershell" : undefined;
105
+ }
106
+ /** The line that turns completion on, per shell. */
107
+ export const completionLine = (s) => ({
108
+ bash: `echo 'eval "$(orq completion bash)"' >> ~/.bashrc`,
109
+ zsh: `echo 'eval "$(orq completion zsh)"' >> ~/.zshrc # ${m("after compinit", "depois do compinit")}`,
110
+ fish: "orq completion fish > ~/.config/fish/completions/orq.fish",
111
+ powershell: "Add-Content $PROFILE 'orq completion powershell | Out-String | Invoke-Expression'",
112
+ }[s]);
113
+ /** What orq found on this machine, one line each (required things first). */
114
+ export function environment(cfg) {
115
+ const has = (c, a = ["--version"]) => spawnSync(c, a, { stdio: "ignore", timeout: 10_000 }).status === 0;
116
+ const row = (k, v) => ` ${k.padEnd(10)} ${v}`;
117
+ let host;
118
+ try {
119
+ host = detectHost(cfg);
120
+ }
121
+ catch (e) {
122
+ host = e.message;
123
+ }
124
+ const mode = String((cfg.machine ?? {}).mem_enforce ?? "auto");
125
+ const kb = backend(), sh = userShell();
126
+ return [
127
+ row("node", process.version),
128
+ row("git", has("git") ? "ok" : m("MISSING — required (worktrees, diffs, tests): install git", "FALTANDO — obrigatório (worktrees, diffs, testes): instale o git")),
129
+ row("terminal", `${host} ${m("(children: herdr tab, tmux window or background; machine.terminal)", "(filhos: aba do herdr, janela do tmux ou segundo plano; machine.terminal)")}`),
130
+ row(m("memory", "memória"), mode === "off" ? "off" : mode === "watchdog" || !hasKernelCap()
131
+ ? m("watchdog (samples the process tree; machine.mem_enforce)", "vigia (amostra a árvore de processos; machine.mem_enforce)")
132
+ : m("kernel (systemd scope)", "kernel (scope do systemd)")),
133
+ row("keyring", kb === "native" ? "ok" : kb === "cli" ? m("OS command (native module did not load)", "comando do SO (o módulo nativo não carregou)")
134
+ : m("none — Antigravity/macOS Claude quota shows ?", "nenhum — a cota do Antigravity/Claude no macOS mostra ?")),
135
+ row("shell", sh ? `${sh} — ${m("completion", "autocompletar")}: ${completionLine(sh)}` : m("unknown — orq completion bash|zsh|fish|powershell", "desconhecido — orq completion bash|zsh|fish|powershell")),
136
+ ];
137
+ }
@@ -0,0 +1,51 @@
1
+ // Keyring — read a secret another tool keeps in the OS keyring (Secret Service on Linux, Keychain on macOS, Credential
2
+ // Manager on Windows) through @napi-rs/keyring. orq only reads; it never writes or refreshes someone else's login.
3
+ // The native module may not load (a platform without a prebuilt binary, a broken install): then the OS's own CLI where
4
+ // there is one (Linux secret-tool, macOS security), else nothing. $ORQ_KEYRING = native | cli | off forces a backend.
5
+ import { spawnSync } from "node:child_process";
6
+ import { createRequire } from "node:module";
7
+ let native;
8
+ function load() {
9
+ if (native === undefined) {
10
+ try {
11
+ native = createRequire(import.meta.url)("@napi-rs/keyring");
12
+ }
13
+ catch {
14
+ native = null;
15
+ }
16
+ }
17
+ return native;
18
+ }
19
+ const cli = () => process.platform === "linux" ? "secret-tool" : process.platform === "darwin" ? "security" : undefined;
20
+ /** Which backend reads secrets here. */
21
+ export function backend() {
22
+ const want = process.env.ORQ_KEYRING;
23
+ if (want === "off")
24
+ return "none";
25
+ if (want !== "cli" && load())
26
+ return "native";
27
+ return cli() && spawnSync(cli(), ["--help"], { stdio: "ignore", timeout: 5000 }).error === undefined ? "cli" : "none";
28
+ }
29
+ /**
30
+ * The secret stored for (service, account), or undefined. `target` is the Windows credential name when the tool that
31
+ * wrote it doesn't use the keyring-rs default ("<account>.<service>"), e.g. go-keyring's "<service>:<account>".
32
+ */
33
+ export function readSecret(service, account, target) {
34
+ const b = backend();
35
+ if (b === "native") {
36
+ try {
37
+ const e = target && process.platform === "win32" ? load().Entry.withTarget(target, service, account) : new (load().Entry)(service, account);
38
+ return e.getPassword() ?? undefined;
39
+ }
40
+ catch {
41
+ return undefined;
42
+ } // no keyring service (headless Linux without D-Bus), locked, denied
43
+ }
44
+ if (b === "cli") {
45
+ const args = process.platform === "linux" ? ["lookup", "service", service, "username", account]
46
+ : ["find-generic-password", "-s", service, "-a", account, "-w"];
47
+ const r = spawnSync(cli(), args, { encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "ignore"] });
48
+ return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : undefined;
49
+ }
50
+ return undefined;
51
+ }
package/dist/lock.js ADDED
@@ -0,0 +1,81 @@
1
+ // Portable FIFO lock with N slots (the test queue), made of plain files — no flock, no /proc/locks.
2
+ // <state dir>/locks/<name>/<ms>-<pid>.json one entry per process that asked: {pid, label, at, held}
3
+ // Holders are the entries with held = true whose process is alive. A waiter takes a slot when fewer than `slots` are
4
+ // held and it is the oldest live non-held entry, after its file has existed for SETTLE_MS (so an older entry still
5
+ // being written is seen first). Dead processes' entries are swept by whoever looks, so a crash never leaves the queue
6
+ // stuck. Works the same on Linux, macOS and Windows (process.kill(pid, 0) checks liveness everywhere).
7
+ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { writeAtomic } from "./fsutil.js";
10
+ import { stateDir } from "./paths.js";
11
+ const SETTLE_MS = 250;
12
+ const dir = (name) => join(stateDir(), "locks", name);
13
+ export function alive(pid) {
14
+ if (!(pid > 0))
15
+ return false; // kill(0, …) would signal our own process group and "succeed"
16
+ try {
17
+ process.kill(pid, 0);
18
+ return true;
19
+ }
20
+ catch (e) {
21
+ return e.code === "EPERM";
22
+ }
23
+ }
24
+ /** Live entries in queue order (dead ones are removed). */
25
+ export function entries(name) {
26
+ let files;
27
+ try {
28
+ files = readdirSync(dir(name)).filter((f) => f.endsWith(".json"));
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ const out = [];
34
+ for (const f of files) {
35
+ const file = join(dir(name), f);
36
+ try {
37
+ const e = { ...JSON.parse(readFileSync(file, "utf8")), file };
38
+ if (alive(e.pid))
39
+ out.push(e);
40
+ else
41
+ rmSync(file, { force: true });
42
+ }
43
+ catch { /* being written or removed: skip this round */ }
44
+ }
45
+ return out.sort((a, b) => a.at - b.at || a.pid - b.pid);
46
+ }
47
+ export const status = (name) => {
48
+ const all = entries(name);
49
+ return { holders: all.filter((e) => e.held), waiters: all.filter((e) => !e.held) };
50
+ };
51
+ /** Queue `pid` and resolve once it holds a slot. `onWait` is called once if it has to wait. */
52
+ export async function acquire(name, pid, label, slots = 1, onWait, pollMs = 500) {
53
+ mkdirSync(dir(name), { recursive: true });
54
+ const mine = entries(name).find((e) => e.pid === pid);
55
+ if (mine?.held)
56
+ return; // re-entrant for the same process
57
+ const at = mine?.at ?? Date.now();
58
+ const file = mine?.file ?? join(dir(name), `${at}-${pid}.json`);
59
+ writeAtomic(file, JSON.stringify({ pid, label, at, held: false }));
60
+ let told = false;
61
+ for (;;) {
62
+ const all = entries(name), held = all.filter((e) => e.held).length;
63
+ const first = all.find((e) => !e.held);
64
+ const settled = Date.now() - statSync(file).mtimeMs >= SETTLE_MS || Date.now() - at >= SETTLE_MS;
65
+ if (held < Math.max(1, slots) && first?.pid === pid && settled) {
66
+ writeAtomic(file, JSON.stringify({ pid, label, at, held: true }));
67
+ return;
68
+ }
69
+ if (!told && (held >= slots || first?.pid !== pid)) {
70
+ onWait?.(all.findIndex((e) => e.pid === pid) - held + 1);
71
+ told = true;
72
+ }
73
+ await new Promise((r) => setTimeout(r, pollMs));
74
+ }
75
+ }
76
+ export function release(name, pid) {
77
+ const e = entries(name).find((x) => x.pid === pid);
78
+ if (e)
79
+ rmSync(e.file, { force: true });
80
+ return Boolean(e);
81
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,34 @@
1
+ // Where orq keeps its files, per OS. Linux/macOS keep the XDG paths the bash/Python scripts always used, so an
2
+ // existing ~/.config/orq and ~/.local/state/orq keep working; Windows uses %APPDATA% / %LOCALAPPDATA%.
3
+ import { existsSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ const env = process.env;
7
+ const win = process.platform === "win32";
8
+ export function configDir() {
9
+ if (env.XDG_CONFIG_HOME)
10
+ return join(env.XDG_CONFIG_HOME, "orq");
11
+ if (win)
12
+ return join(env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "orq");
13
+ return join(homedir(), ".config", "orq");
14
+ }
15
+ export function stateDir() {
16
+ if (env.XDG_STATE_HOME)
17
+ return join(env.XDG_STATE_HOME, "orq");
18
+ if (win)
19
+ return join(env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "orq", "state");
20
+ return join(homedir(), ".local", "state", "orq");
21
+ }
22
+ /** The personal config file to write: $ORQ_CONFIG, else config.json if only that exists, else config.toml. */
23
+ export function userConfigFile() {
24
+ if (env.ORQ_CONFIG)
25
+ return env.ORQ_CONFIG;
26
+ const toml = join(configDir(), "config.toml"), json = join(configDir(), "config.json");
27
+ return existsSync(json) && !existsSync(toml) ? json : toml;
28
+ }
29
+ /** Expand a leading ~ and $VAR / ${VAR} (unknown variables are left as they are, like Python's expandvars). */
30
+ export function expand(s) {
31
+ if (s === "~" || s.startsWith("~/") || s.startsWith("~\\"))
32
+ s = homedir() + s.slice(1);
33
+ return s.replace(/\$(\w+)|\$\{(\w+)\}/g, (all, a, b) => env[a ?? b] ?? all);
34
+ }
package/dist/pick.js ADDED
@@ -0,0 +1,126 @@
1
+ import { m } from "./i18n.js";
2
+ import { legacyRole, resolveRole, vendorOf } from "./roles.js";
3
+ /** "<name> 5h=NN% week=NN% [note]" lines → QuotaMap (same reading as the old orq-pick). */
4
+ export function parseQuota(text) {
5
+ const out = {};
6
+ for (const line of text.split(/\r?\n/)) {
7
+ const p = line.split(/\s+/);
8
+ if (p.length < 3)
9
+ continue;
10
+ const vals = p.slice(1, 3).map((x) => x.replace(/.*=/, ""));
11
+ if (vals.some((v) => v.startsWith("?"))) {
12
+ out[p[0]] = null;
13
+ continue;
14
+ }
15
+ const nums = vals.map((v) => v.replace(/%$/, "")).filter((v) => /^\d+$/.test(v)).map(Number);
16
+ out[p[0]] = nums.length ? Math.max(...nums) : -1;
17
+ }
18
+ return out;
19
+ }
20
+ export function modelFor(a, role, level) {
21
+ if (a.models === undefined)
22
+ return ["", undefined]; // no models configured: the CLI's default
23
+ const models = a.models;
24
+ const lr = legacyRole(role);
25
+ const ms = models[lr] ?? (lr === "fix" ? models.impl : undefined) ?? {};
26
+ const spec = ms[level] ?? ms.default ?? ms.medium ?? ms.simple;
27
+ if (!spec)
28
+ return undefined;
29
+ const [model, effort] = spec.split("@");
30
+ return [model, effort || undefined];
31
+ }
32
+ const WEIGHTS = { quality: 0.4, tokens: 0.45, speed: 0.15, rotation: 0.2, reserve: 0.3, explore: 0.1, min_samples: 8, window: 6 };
33
+ export const weights = (cfg) => ({ ...WEIGHTS, ...((cfg.policy ?? {}).weights ?? {}) });
34
+ export function pick(inp) {
35
+ const { cfg, role, level } = inp;
36
+ const agents = (cfg.agents ?? {});
37
+ const pol = (cfg.policy ?? {});
38
+ const limit = Number(pol.quota_limit ?? 90);
39
+ const w = weights(cfg);
40
+ const res = resolveRole(cfg, role);
41
+ const names = res.mode === "fixed" ? res.order : res.pool;
42
+ const notes = [];
43
+ const out = (n, why) => { notes.push(`${n}: ${why}`); };
44
+ const pass = [];
45
+ for (const name of names) {
46
+ const a = agents[name];
47
+ if (!a) {
48
+ out(name, m("not declared in [agents]", "não declarado em [agents]"));
49
+ continue;
50
+ }
51
+ if (inp.exclude.has(name)) {
52
+ out(name, m("excluded (--exclude-agent)", "excluído (--exclude-agent)"));
53
+ continue;
54
+ }
55
+ if (inp.blocks.has(name)) {
56
+ out(name, m("blocked (orq block -l)", "bloqueado (orq block -l)"));
57
+ continue;
58
+ }
59
+ if ((role === "review" || role === "opinion") && res.rule === "other-vendor" && inp.implVendor
60
+ && vendorOf(name, a) === inp.implVendor) {
61
+ out(name, m("same vendor as the implementer", "mesmo fornecedor do implementador"));
62
+ continue;
63
+ }
64
+ const mf = modelFor(a, role, level);
65
+ if (!mf) {
66
+ out(name, m(`no model for ${role}/${level}`, `sem modelo para ${role}/${level}`));
67
+ continue;
68
+ }
69
+ // Quota: an agent with several accounts (claude) uses the least used one under the limit.
70
+ const qn = a.quota ?? [];
71
+ const accs = qn.filter((n) => !inp.blocks.has(n)).map((n) => [n, n in inp.quota ? inp.quota[n] : -1]);
72
+ if (qn.length && !accs.length) {
73
+ out(name, m("all accounts blocked", "todas as contas bloqueadas"));
74
+ continue;
75
+ }
76
+ const usable = accs.filter(([, v]) => v !== null && v < limit);
77
+ if (accs.length && !usable.length) {
78
+ out(name, `${m("quota", "cota")} >= ${limit}% ${m("or no data", "ou sem dados")} (${accs.map(([n, v]) => `${n}=${v}`).join(", ")})`);
79
+ continue;
80
+ }
81
+ const acct = accs.length > 1 ? [...usable].sort((x, y) => x[1] - y[1])[0][0] : undefined;
82
+ const statRole = role === "fix" || role === "worker" ? "impl" : role;
83
+ const st = inp.stats.find((r) => r.role === statRole && r.agent === name && r.model === (mf[0].split("/").pop() || "default"));
84
+ pass.push({ agent: name, model: mf[0], effort: mf[1], acct, a, st });
85
+ if (res.mode === "fixed")
86
+ break; // the first that passes wins
87
+ }
88
+ if (res.mode === "fixed") {
89
+ const c = pass[0];
90
+ const best = c && { agent: c.agent, model: c.model, effort: c.effort, acct: c.acct, score: 0,
91
+ why: m(`fixed by [roles.${res.section}]`, `fixo em [roles.${res.section}]`) };
92
+ return { mode: "fixed", best, cands: best ? [best] : [], notes };
93
+ }
94
+ const few = (st) => (st?.n ?? 0) < w.min_samples;
95
+ const toks = pass.filter((c) => c.st?.new && !few(c.st)).map((c) => c.st.new);
96
+ const mins = pass.filter((c) => c.st?.min && !few(c.st)).map((c) => c.st.min);
97
+ const cands = pass.map((c) => {
98
+ const prior = Number(c.a.prior ?? 0.6), n = c.st?.n ?? 0, reserve = Boolean(c.a.reserve);
99
+ const deliv = role === "impl" && c.st?.approved != null && c.st.n_appr >= 3 ? c.st.approved / 100
100
+ : c.st ? c.st.ok / 100 : undefined;
101
+ const d = few(c.st) || deliv === undefined ? prior : deliv;
102
+ const t = few(c.st) || !c.st?.new || !toks.length ? prior : Math.min(...toks) / c.st.new;
103
+ const s = few(c.st) || !c.st?.min || !mins.length ? prior : Math.min(...mins) / Math.max(c.st.min, 0.1);
104
+ const used = inp.recent.filter((p) => p.agent === c.agent).length;
105
+ const score = w.quality * d + w.tokens * t + w.speed * s - w.rotation * used
106
+ - (reserve ? w.reserve : 0) + (few(c.st) ? w.explore : 0);
107
+ const why = `${m("delivery", "entrega")}=${d.toFixed(2)} ${m("economy", "economia")}=${t.toFixed(2)} `
108
+ + `${m("speed", "rapidez")}=${s.toFixed(2)} ${m("rotation", "rodízio")}=-${used} `
109
+ + (reserve ? m("reserve ", "reserva ") : "") + (few(c.st) ? `${m("few samples", "poucos dados")}(n=${n})` : `n=${n}`);
110
+ return { agent: c.agent, model: c.model, effort: c.effort, acct: c.acct, score, why };
111
+ }).sort((x, y) => y.score - x.score);
112
+ return { mode: "auto", best: cands[0], cands, notes };
113
+ }
114
+ const EFFORTS = ["low", "medium", "high"];
115
+ /** A fix continues the implementer's session: the same runner arguments (agent, model, account) with the effort one
116
+ * level up, capped at high. Keeping the model keeps the session's prompt cache; an unknown or absent effort stays. */
117
+ export function escalate(args) {
118
+ const p = args.trim().split(/\s+/);
119
+ const i = p.indexOf("-e");
120
+ const at = i >= 0 ? EFFORTS.indexOf(p[i + 1]) : -1;
121
+ if (at >= 0)
122
+ p[i + 1] = EFFORTS[Math.min(at + 1, EFFORTS.length - 1)];
123
+ return p.join(" ");
124
+ }
125
+ /** The arguments for the agent runner, e.g. "-a codex -m gpt-6-sol -e low -c work". */
126
+ export const asArgs = (c) => `-a ${c.agent}` + (c.model ? ` -m ${c.model}` : "") + (c.effort ? ` -e ${c.effort}` : "") + (c.acct ? ` -c ${c.acct}` : "");
@@ -0,0 +1,147 @@
1
+ // ProcessHost — run a command under a memory cap, on any OS.
2
+ // kernel Linux with a systemd user session: `systemd-run --user --scope -p MemoryMax=` (the kernel kills only
3
+ // that command, as orq always did)
4
+ // watchdog everywhere else (macOS, Windows, Linux without systemd): orq samples the command's process tree every
5
+ // few seconds and kills the whole tree above the cap
6
+ // off no cap
7
+ // mode "auto" = kernel when available, else watchdog. (Windows Job Objects need a native addon: later, if the
8
+ // watchdog proves too coarse.)
9
+ import { execFile, spawn, spawnSync } from "node:child_process";
10
+ import { m } from "./i18n.js";
11
+ /** "4G", "512M", "1.5G", "off" → bytes (undefined = no cap). */
12
+ export function parseSize(s) {
13
+ if (s === undefined || s === "" || s === "off" || s === false)
14
+ return undefined;
15
+ if (typeof s === "number")
16
+ return s;
17
+ const r = /^(\d+(?:\.\d+)?)\s*([KMGT]?)i?B?$/i.exec(s.trim());
18
+ if (!r)
19
+ throw new Error(m(`invalid size: ${s} (e.g. 4G, 512M, off)`, `tamanho inválido: ${s} (ex.: 4G, 512M, off)`));
20
+ return Math.round(Number(r[1]) * 1024 ** "BKMGT".indexOf((r[2] || "B").toUpperCase()));
21
+ }
22
+ let kernelOk;
23
+ export function hasKernelCap() {
24
+ if (process.platform !== "linux")
25
+ return false;
26
+ kernelOk ??= spawnSync("systemd-run", ["--user", "--scope", "-q", "--", "true"], { stdio: "ignore", timeout: 10_000 }).status === 0;
27
+ return kernelOk;
28
+ }
29
+ // systemd-run expands ${...} in its arguments (the prompt!) unless told not to; the flag exists in systemd >= 254.
30
+ let noExpand;
31
+ const noExp = () => noExpand ??= /--expand-environment/.test(spawnSync("systemd-run", ["--help"], { encoding: "utf8" }).stdout ?? "")
32
+ ? ["--expand-environment=no"] : [];
33
+ export function resolveMode(mode, cap) {
34
+ if (cap === undefined || mode === "off")
35
+ return "off";
36
+ if (mode === "kernel" || (mode === "auto" && hasKernelCap()))
37
+ return "kernel";
38
+ return "watchdog";
39
+ }
40
+ const LIST = process.platform === "win32"
41
+ ? ["powershell", ["-NoProfile", "-NonInteractive", "-Command",
42
+ "Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,WorkingSetSize | ForEach-Object { \"$($_.ProcessId) $($_.ParentProcessId) $($_.WorkingSetSize)\" }"]]
43
+ : ["ps", ["-A", "-o", "pid=,ppid=,rss="]];
44
+ /** pid → [ppid, bytes] for every process (ps on Linux/macOS, CIM on Windows). */
45
+ export function processTable() {
46
+ return parseTable(spawnSync(LIST[0], LIST[1], { encoding: "utf8", timeout: 15_000 }).stdout ?? "");
47
+ }
48
+ /** Same, without blocking: PowerShell takes seconds on Windows and the watchdog must keep running meanwhile. */
49
+ export function processTableAsync() {
50
+ return new Promise((res) => execFile(LIST[0], LIST[1], { encoding: "utf8", timeout: 15_000, windowsHide: true }, (_e, out) => res(parseTable(out ?? ""))));
51
+ }
52
+ function parseTable(text) {
53
+ const t = new Map();
54
+ for (const line of text.split(/\r?\n/)) {
55
+ const [pid, ppid, mem] = line.trim().split(/\s+/).map(Number);
56
+ if (pid)
57
+ t.set(pid, [ppid, process.platform === "win32" ? mem : mem * 1024]);
58
+ }
59
+ return t;
60
+ }
61
+ /** Total memory of `root` and all its descendants. */
62
+ export function treeMem(root, table = processTable()) {
63
+ const kids = new Map();
64
+ for (const [pid, [ppid]] of table)
65
+ kids.set(ppid, [...(kids.get(ppid) ?? []), pid]);
66
+ let total = 0;
67
+ const seen = new Set(), todo = [root];
68
+ while (todo.length) {
69
+ const p = todo.pop();
70
+ if (seen.has(p))
71
+ continue;
72
+ seen.add(p);
73
+ total += table.get(p)?.[1] ?? 0;
74
+ todo.push(...(kids.get(p) ?? []));
75
+ }
76
+ return total;
77
+ }
78
+ export function killTree(pid) {
79
+ if (process.platform === "win32")
80
+ spawnSync("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore" });
81
+ else
82
+ try {
83
+ process.kill(-pid, "SIGKILL");
84
+ }
85
+ catch {
86
+ try {
87
+ process.kill(pid, "SIGKILL");
88
+ }
89
+ catch { /* gone */ }
90
+ }
91
+ }
92
+ /** cmd.exe quoting for one argument (only used when Windows needs a shell). */
93
+ export const winQuote = (a) => /[\s"&|<>^%()]/.test(a) || a === "" ? `"${a.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"` : a;
94
+ const gb = (b) => `${(b / 2 ** 30).toFixed(1)} GB`;
95
+ /** Run `cmd args` with stdio inherited, under the cap; resolves to the exit code (137 = killed for memory). */
96
+ export function runCapped(cmd, args, o = {}) {
97
+ const mode = resolveMode(o.mode ?? "auto", o.cap);
98
+ if (mode === "kernel") {
99
+ const sd = ["--user", "--scope", "-q", ...noExp(), ...(o.unit ? [`--unit=${o.unit}`] : []), "-p", `MemoryMax=${o.cap}`,
100
+ ...(o.swap !== undefined ? ["-p", `MemorySwapMax=${o.swap}`] : []), "--", cmd, ...args];
101
+ const c = spawn("systemd-run", sd, { stdio: o.stdio ?? "inherit", cwd: o.cwd, env: o.env });
102
+ o.onSpawn?.(c);
103
+ return wait(c);
104
+ }
105
+ const win = process.platform === "win32";
106
+ // Own process group on Linux/macOS, so the whole tree can be killed at once.
107
+ // Windows: a shell only for .cmd/.bat shims (npx, claude…); an .exe runs directly (paths with spaces stay intact).
108
+ const shell = win && !/\.exe$/i.test(cmd), q = shell && o.quote;
109
+ const child = spawn(q ? winQuote(cmd) : cmd, q ? args.map(winQuote) : args, { stdio: o.stdio ?? "inherit", cwd: o.cwd, env: o.env, detached: !win, shell, windowsHide: true });
110
+ o.onSpawn?.(child);
111
+ if (mode === "off" || !child.pid)
112
+ return wait(child);
113
+ const fwd = (sig) => () => { try {
114
+ process.kill(-child.pid, sig);
115
+ }
116
+ catch {
117
+ child.kill(sig);
118
+ } };
119
+ const onInt = fwd("SIGINT"), onTerm = fwd("SIGTERM");
120
+ if (!win) {
121
+ process.on("SIGINT", onInt);
122
+ process.on("SIGTERM", onTerm);
123
+ }
124
+ let killed = false, sampling = false;
125
+ const timer = setInterval(async () => {
126
+ if (sampling || killed)
127
+ return; // a slow sample (Windows) never overlaps the next
128
+ sampling = true;
129
+ const used = treeMem(child.pid, await processTableAsync());
130
+ sampling = false;
131
+ if (used > o.cap && !killed) {
132
+ killed = true;
133
+ console.error(m(`orq: memory ${gb(used)} over the cap (${gb(o.cap)}): killing the process tree`, `orq: memória ${gb(used)} acima do limite (${gb(o.cap)}): encerrando a árvore de processos`));
134
+ killTree(child.pid);
135
+ }
136
+ }, o.intervalMs ?? 1000);
137
+ return wait(child).then((code) => {
138
+ clearInterval(timer);
139
+ process.off("SIGINT", onInt);
140
+ process.off("SIGTERM", onTerm);
141
+ return killed ? 137 : code;
142
+ });
143
+ }
144
+ const wait = (c) => new Promise((res) => {
145
+ c.on("error", (e) => { console.error(`orq: ${e.message}`); res(127); });
146
+ c.on("exit", (code, sig) => res(code ?? (sig ? 128 + 9 : 1)));
147
+ });