@irythill/orq 0.7.0 → 0.9.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.
@@ -2,23 +2,28 @@
2
2
  // claude <workspace>/.claude/skills/issue/SKILL.md (Claude Code loads it as the /issue skill)
3
3
  // agents <workspace>/AGENTS.md (Codex, OpenCode and others that read AGENTS.md)
4
4
  // AGENTS.md may hold the user's own text: orq only replaces its block between the orq:issue markers.
5
- // orq instructions claude|agents [-w <workspace>] [--print]
5
+ // orq instructions claude|agents [-w <workspace>] [--conductor <agent>] [--print]
6
+ // orq instructions strategy [-w <workspace>] [--conductor <agent>] [--json] which wait/resume strategy applies (#119)
6
7
  import { existsSync, readFileSync } from "node:fs";
7
8
  import { join, resolve } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import { writeAtomic } from "../fsutil.js";
11
+ import { getPath, loadConfig } from "../config.js";
12
+ import { capabilities, loopTimeout, strategy, strategyText } from "../harness.js";
10
13
  import { m } from "../i18n.js";
11
- export const USAGE = "orq instructions claude|agents [-w <workspace>] [--print] write the /issue playbook for the conductor";
14
+ export const USAGE = `orq instructions claude|agents [-w <workspace>] [--conductor <agent>] [--print] write the /issue playbook for the conductor
15
+ orq instructions strategy [-w <workspace>] [--conductor <agent>] [--json] show the conductor's wait/resume strategy`;
12
16
  const SRC = fileURLToPath(new URL("../../skills/issue/SKILL.md", import.meta.url));
13
17
  const BEGIN = "<!-- orq:issue begin (generated by `orq instructions agents`; edits inside are overwritten) -->";
14
18
  const END = "<!-- orq:issue end -->";
15
- /** The playbook for harnesses without skills: no frontmatter, a trigger line, and how to wait without background tasks. */
16
- export function agentsBlock(skill) {
19
+ /** The playbook for harnesses without skills: no frontmatter, a trigger line, and the conductor's waiting strategy. */
20
+ export function agentsBlock(skill, waiting = strategyText("unknown", capabilities("unknown"))) {
17
21
  const body = skill.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim();
18
22
  return `${BEGIN}
19
23
  When the user says "/issue <ID>", "take issue X" or "run ABC-123", follow this playbook.
20
- If your harness cannot run a command in the background, wait for children with \`orq wait\` in the foreground
21
- (\`orq wait --any <prefix>...\`): it is the one allowed blocking wait.
24
+ Read \`ORQ.md\` for the workspace map and orchestration settings (legacy workspaces: \`CLAUDE.md\`).
25
+
26
+ ${waiting}
22
27
 
23
28
  ${body}
24
29
  ${END}
@@ -31,15 +36,30 @@ export function mergeBlock(cur, block) {
31
36
  return cur.slice(0, a) + block.trimEnd() + cur.slice(b + END.length);
32
37
  return cur ? `${cur.trimEnd()}\n\n${block}` : block;
33
38
  }
39
+ /** Conductor agent: --conductor, else roles.conductor.agent, else claude. */
40
+ function conductorOf(cfg, flag) {
41
+ return flag || String(getPath(cfg, "roles.conductor.agent") ?? "claude");
42
+ }
43
+ /** AGENTS.md is read by non-Claude harnesses: a Claude conductor's wake-up must not leak into it unless asked for. */
44
+ function agentsWaiting(agent, caps, cfg, flag) {
45
+ if (agent === "claude" && !flag)
46
+ return strategyText("AGENTS.md harness", capabilities("unknown", cfg, process.env));
47
+ return strategyText(agent, caps);
48
+ }
34
49
  export async function instructions(argv) {
35
- let ws = process.cwd(), print = false, fmt;
50
+ let ws = process.cwd(), print = false, json = false, fmt, who;
36
51
  for (let i = 0; i < argv.length; i++) {
37
- if (argv[i] === "-w")
52
+ const a = argv[i];
53
+ if (a === "-w")
38
54
  ws = resolve(argv[++i]);
39
- else if (argv[i] === "--print")
55
+ else if (a === "--print")
40
56
  print = true;
41
- else if (argv[i] === "claude" || argv[i] === "agents")
42
- fmt = argv[i];
57
+ else if (a === "--json")
58
+ json = true;
59
+ else if (a === "--conductor" && argv[i + 1])
60
+ who = argv[++i];
61
+ else if (a === "claude" || a === "agents" || a === "strategy")
62
+ fmt = a;
43
63
  else {
44
64
  console.error(USAGE);
45
65
  return 2;
@@ -49,13 +69,35 @@ export async function instructions(argv) {
49
69
  console.error(USAGE);
50
70
  return 2;
51
71
  }
72
+ let cfg = {};
73
+ try {
74
+ cfg = loadConfig(ws).cfg;
75
+ }
76
+ catch { /* no config: built-in defaults */ }
77
+ const agent = conductorOf(cfg, who), caps = capabilities(agent, cfg, process.env), s = strategy(caps);
78
+ if (fmt === "strategy") {
79
+ const fallback = s === "background-task" ? "foreground-loop" : "none";
80
+ if (json) {
81
+ console.log(JSON.stringify({ conductor: agent, strategy: s, fallback, waitTimeout: loopTimeout(caps), capabilities: caps }));
82
+ return 0;
83
+ }
84
+ console.log(`conductor ${agent}: ${s}`);
85
+ console.log(` background=${caps.background} resume_on_completion=${caps.resumeOnCompletion} foreground_wait=${caps.foregroundWait} prompt_delivery=${caps.promptDelivery} foreground_timeout=${caps.foregroundTimeout}s`);
86
+ console.log(s === "background-task"
87
+ ? m(" ends its turn after dispatch; orq wait in the background wakes it (fallback: foreground loop)", " encerra o turno após o dispatch; o orq wait em background o acorda (fallback: loop em foreground)")
88
+ : s === "manual"
89
+ ? m(" cannot continue by itself: reports pending prefixes and waits for the user's message", " não continua sozinho: informa os prefixos pendentes e espera a mensagem do usuário")
90
+ : m(` stays active: orq wait --any --timeout ${loopTimeout(caps)} in a loop; it does NOT resume by itself after ending a turn`, ` fica ativo: orq wait --any --timeout ${loopTimeout(caps)} em loop; NÃO retoma sozinho depois de encerrar o turno`));
91
+ console.log(m(` override: agents.${agent}.conductor.capabilities`, ` ajuste: agents.${agent}.conductor.capabilities`));
92
+ return 0;
93
+ }
52
94
  if (!existsSync(SRC)) {
53
95
  console.error(m(`playbook not found: ${SRC}`, `playbook não encontrado: ${SRC}`));
54
96
  return 2;
55
97
  }
56
98
  const skill = readFileSync(SRC, "utf8");
57
99
  const file = fmt === "claude" ? join(ws, ".claude", "skills", "issue", "SKILL.md") : join(ws, "AGENTS.md");
58
- const text = fmt === "claude" ? skill : mergeBlock(existsSync(file) ? readFileSync(file, "utf8") : "", agentsBlock(skill));
100
+ const text = fmt === "claude" ? skill : mergeBlock(existsSync(file) ? readFileSync(file, "utf8") : "", agentsBlock(skill, agentsWaiting(agent, caps, cfg, who)));
59
101
  if (print) {
60
102
  process.stdout.write(text);
61
103
  return 0;
@@ -75,6 +75,8 @@ export async function pick(argv) {
75
75
  }
76
76
  }
77
77
  const { cfg } = loadConfig(ws);
78
+ if (process.env.ORQ_CONDUCTOR_AGENT)
79
+ cfg._conductorAgent = process.env.ORQ_CONDUCTOR_AGENT;
78
80
  const agents = (cfg.agents ?? {});
79
81
  if (implAgent && !implVendor)
80
82
  implVendor = vendorOf(implAgent, agents[implAgent]);
@@ -4,7 +4,7 @@
4
4
  // orq setup --detect installed agent CLIs, as JSON (for a conductor driving the setup)
5
5
  // orq setup --answers <file|-> [--write] proposal from answers JSON (see Answers in src/suggest.ts); --write saves it
6
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
7
+ // orq setup --workspace <dir> [--conductor <agent>] .orquestra, ORQ.md map, /issue playbook in its format
8
8
  // --preset economy|quality|single-agent
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { copyFileSync, existsSync, readFileSync } from "node:fs";
@@ -18,6 +18,7 @@ import { getPath, loadConfig } from "../config.js";
18
18
  import { m } from "../i18n.js";
19
19
  import { environment, installSkills, prepareWorkspace } from "../install.js";
20
20
  import { expand, userConfigFile } from "../paths.js";
21
+ import { setTomlString } from "../toml-set.js";
21
22
  import { suggestRoles, toToml } from "../suggest.js";
22
23
  export const USAGE = `orq setup [--preset economy|quality|single-agent] guided first configuration (roles, weights, memory)
23
24
  orq setup --detect | --answers <file|-> [--write] | --install | --workspace <dir> [--conductor <agent>]`;
@@ -125,18 +126,30 @@ function workspace(dir, conductor) {
125
126
  let c = conductor;
126
127
  if (!c) {
127
128
  try {
128
- c = getPath(loadConfig().cfg, "roles.conductor.agent");
129
+ c = getPath(loadConfig(expand(dir)).cfg, "roles.conductor.agent");
129
130
  }
130
131
  catch { /* none */ }
131
132
  }
132
- c ??= onPath("claude") ? "claude" : "agents";
133
+ c ??= detect()[0] ?? "claude";
133
134
  const d = expand(dir); // typed at the prompt: ~ is not expanded by any shell
134
135
  console.log(`Workspace ${d}`);
135
- for (const l of prepareWorkspace(d, c === "claude" ? "claude" : "agents"))
136
+ for (const l of prepareWorkspace(d, c === "claude" ? "claude" : "agents", undefined, c))
136
137
  console.log(l);
138
+ if (c !== "agents") {
139
+ const toml = join(d, ".orq", "workspace.toml"), json = join(d, ".orq", "workspace.json");
140
+ const file = existsSync(toml) || !existsSync(json) ? toml : json;
141
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
142
+ if (file.endsWith(".json")) {
143
+ const cfg = text ? JSON.parse(text) : {};
144
+ cfg.roles = { ...cfg.roles, conductor: { ...cfg.roles?.conductor, agent: c } };
145
+ writeAtomic(file, JSON.stringify(cfg, null, 2) + "\n");
146
+ }
147
+ else
148
+ writeAtomic(file, setTomlString(text, "roles.conductor", "agent", c));
149
+ }
137
150
  }
138
- const next = () => console.log(m(`Next: fill in the workspace CLAUDE.md, open your conductor in that folder and say "/issue <ID>".
139
- 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>".
151
+ const next = () => console.log(m(`Next: fill in the workspace ORQ.md, open your conductor in that folder and say "/issue <ID>".
152
+ Board: orq board · quota: orq quota · completion: orq completion <shell>`, `Próximo: preencha o ORQ.md do workspace, abra o seu conductor nessa pasta e diga "/issue <ID>".
140
153
  Painel: orq board · cota: orq quota · autocompletar: orq completion <shell>`));
141
154
  export async function setup(argv) {
142
155
  let preset, answers, doWrite = false, ws, conductor;
@@ -0,0 +1,69 @@
1
+ // Conductor harness capabilities and the wait/resume strategy they allow (#119).
2
+ // A harness is where the conductor talks to the user (Claude Code, Codex, OpenCode...). Being able to start a process in
3
+ // the background is not the same as being woken up when it ends: only `resume_on_completion` lets the conductor end its
4
+ // turn after dispatching. Everything else keeps an active, bounded `orq wait --timeout` loop.
5
+ // Config (any field optional, overrides the built-in): agents.<agent>.conductor.capabilities =
6
+ // { background, resume_on_completion, foreground_wait, foreground_timeout, prompt_delivery }
7
+ import { getPath } from "./config.js";
8
+ const LOOP = { background: false, resumeOnCompletion: false, foregroundWait: true, foregroundTimeout: 240, promptDelivery: false };
9
+ /** Built-in knowledge. Unknown harnesses get the conservative loop: never promise a wake-up nobody verified. */
10
+ export const BUILTIN_CAPABILITIES = {
11
+ claude: { background: true, resumeOnCompletion: true, foregroundWait: true, foregroundTimeout: 540, promptDelivery: false },
12
+ codex: { ...LOOP },
13
+ opencode: { ...LOOP },
14
+ copilot: { ...LOOP },
15
+ antigravity: { ...LOOP },
16
+ };
17
+ const bool = (v, d) => typeof v === "boolean" ? v : d;
18
+ /** Capabilities of `agent` as conductor: built-in, then agents.<agent>.conductor.capabilities, then the environment. */
19
+ export function capabilities(agent, cfg = {}, env = {}) {
20
+ const base = BUILTIN_CAPABILITIES[agent] ?? LOOP;
21
+ const u = (getPath(cfg, `agents.${agent}.conductor.capabilities`) ?? {});
22
+ const t = Number(u.foreground_timeout);
23
+ return {
24
+ background: bool(u.background, base.background),
25
+ resumeOnCompletion: bool(u.resume_on_completion, base.resumeOnCompletion),
26
+ foregroundWait: bool(u.foreground_wait, base.foregroundWait),
27
+ foregroundTimeout: Number.isFinite(t) && t > 0 ? Math.floor(t) : base.foregroundTimeout,
28
+ promptDelivery: bool(u.prompt_delivery, base.promptDelivery || env.HERDR_ENV === "1"),
29
+ };
30
+ }
31
+ /** Ending the turn after dispatch is only allowed with a channel that wakes the conductor. */
32
+ export function strategy(c) {
33
+ if (c.background && c.resumeOnCompletion)
34
+ return "background-task";
35
+ return c.foregroundWait ? "foreground-loop" : "manual";
36
+ }
37
+ /** Timeout for `orq wait` inside one foreground call: leave a margin under the harness limit. */
38
+ export const loopTimeout = (c) => Math.max(30, c.foregroundTimeout - 30);
39
+ /** The "Waiting strategy" section embedded in the generated conductor instructions. */
40
+ export function strategyText(agent, c) {
41
+ const herdr = "In herdr (`HERDR_ENV=1`) the done line is typed into your pane by herdr: that is your wake-up; dispatch, report in one line and end your turn.";
42
+ if (strategy(c) === "background-task")
43
+ return `## Waiting strategy: background task (${agent})
44
+ ${herdr}
45
+ Elsewhere: after dispatching, run \`orq wait --any <prefix>...\` as a **background task** of your harness and end your
46
+ turn. Its exit starts your next turn; its output is the done line. Wait again for the prefixes still pending.`;
47
+ if (strategy(c) === "manual")
48
+ return `## Waiting strategy: manual (${agent})
49
+ ${herdr}
50
+ Elsewhere your harness can neither wake you when a child ends nor keep a foreground wait open, so you cannot continue
51
+ by yourself. After dispatching, tell the user plainly: which prefixes are pending, that you will **not** continue on
52
+ your own, and that they resume you by messaging you (e.g. "status") once \`orq wait --any <pending prefixes>\` has
53
+ returned. On that message, check \`<prefix>.done\` and act; never dispatch a child again just because it is still running.`;
54
+ const t = loopTimeout(c);
55
+ return `## Waiting strategy: foreground loop (${agent})
56
+ ${herdr}
57
+ Elsewhere your harness does **not** resume you when a background process ends, so never end your turn expecting a
58
+ wake-up. After dispatching, report in one line, then loop:
59
+ 1. Run \`orq wait --any --timeout ${t} <every pending prefix>\` in the **foreground** (if your shell tool has a timeout
60
+ setting, give it more than ${t} s; if the tool still kills the command, use a shorter \`--timeout\`). If the tool
61
+ returns a running session/process id instead of an exit code, keep polling **that same** session until it exits;
62
+ don't start another \`orq wait\` while it runs.
63
+ 2. Exit 0/1 = a done line: act on it (verify, review, fix) and keep looping over what is still pending.
64
+ 3. Exit 124 = nothing finished yet: give the user a one-line status and go back to 1. A timeout is not a failure:
65
+ never dispatch the same child again because of it (\`<prefix>.done\` missing = still running).
66
+ The loop is the one allowed blocking wait. If the user writes in between, answer, then resume the loop. If you must end
67
+ your turn with children still pending, say plainly that you will **not** continue by yourself and that the user has to
68
+ message you (e.g. "status") to resume.`;
69
+ }
package/dist/install.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // skills orq's skills (skills/*/SKILL.md, shipped in the package) linked into ~/.claude/skills and each Claude
3
3
  // account's skills folder — a junction on Windows (no admin rights), a copy if linking fails. A real
4
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
5
+ // workspace <folder>/.orquestra, .worktrees, ORQ.md map, pointers and the /issue playbook
6
6
  // in the conductor's format (orq instructions)
7
7
  // environment what orq found: git, terminal host, memory cap, keyring, the shell's completion line
8
8
  import { spawnSync } from "node:child_process";
@@ -11,7 +11,9 @@ import { homedir } from "node:os";
11
11
  import { basename, join, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { claudeAccounts } from "./accounts.js";
14
+ import { loadConfig } from "./config.js";
14
15
  import { writeAtomic } from "./fsutil.js";
16
+ import { capabilities, strategyText } from "./harness.js";
15
17
  import { m } from "./i18n.js";
16
18
  import { backend } from "./keyring.js";
17
19
  import { hasKernelCap } from "./process.js";
@@ -77,25 +79,39 @@ export function installSkills(roots = skillRoots()) {
77
79
  return out;
78
80
  }
79
81
  /** Prepare a conductor's workspace folder; `fmt` = claude (SKILL.md) | agents (AGENTS.md). */
80
- export function prepareWorkspace(dir, fmt, name = basename(resolve(dir))) {
82
+ export function prepareWorkspace(dir, fmt, name = basename(resolve(dir)), conductor = fmt === "claude" ? "claude" : "unknown") {
81
83
  const ws = resolve(dir), out = [];
82
84
  for (const d of [".orquestra", ".worktrees"])
83
85
  mkdirSync(join(ws, d), { recursive: true });
84
- const map = join(ws, "CLAUDE.md");
86
+ const map = join(ws, "ORQ.md"), legacy = join(ws, "CLAUDE.md");
85
87
  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
+ const t = existsSync(legacy) ? readFileSync(legacy, "utf8") :
89
+ readFileSync(join(PKG, "templates", "workspace.CLAUDE.md"), "utf8").split("{{WORKSPACE}}").join(ws).split("{{NAME}}").join(name);
90
+ writeAtomic(map, t);
88
91
  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
92
  }
90
93
  else
91
94
  out.push(m(` ok ${map} (kept)`, ` ok ${map} (mantido)`));
95
+ if (!existsSync(legacy))
96
+ writeAtomic(legacy, "# orq workspace\n\nRead `ORQ.md` for the workspace map and orchestration settings.\n");
92
97
  // The conductor's instructions (what `orq conductor` looks for): regenerated on every run.
93
98
  const ci = join(ws, ".orquestra-conductor.md");
94
99
  const t = readFileSync(join(PKG, "templates", "conductor.instructions.md"), "utf8");
95
- writeAtomic(ci, t.split("{{WORKSPACE}}").join(ws).split("{{NAME}}").join(name));
100
+ let cfg = {};
101
+ try {
102
+ cfg = loadConfig(ws).cfg;
103
+ }
104
+ catch { /* no config yet */ }
105
+ const waiting = strategyText(conductor, capabilities(conductor, cfg, process.env));
106
+ writeAtomic(ci, t.split("{{WORKSPACE}}").join(ws).split("{{NAME}}").join(name).split("{{WAIT_STRATEGY}}").join(waiting));
96
107
  out.push(` ${m("wrote", "escrevi")} ${ci}`);
97
- const r = spawnSync(process.execPath, [join(PKG, "dist", "cli.js"), "instructions", fmt, "-w", ws], { encoding: "utf8" });
98
- out.push(` ${(r.stdout || r.stderr).trim()}`);
108
+ for (const format of fmt === "claude" ? ["agents", "claude"] : ["agents"]) {
109
+ const r = spawnSync(process.execPath, [join(PKG, "dist", "cli.js"), "instructions", format, "-w", ws,
110
+ ...(format === "agents" && conductor !== "claude" && conductor !== "unknown" ? ["--conductor", conductor] : [])], { encoding: "utf8" });
111
+ if (r.status !== 0)
112
+ throw new Error(r.stderr || `orq instructions ${format} failed`);
113
+ out.push(` ${r.stdout.trim()}`);
114
+ }
99
115
  return out;
100
116
  }
101
117
  const SHELLS = ["bash", "zsh", "fish", "powershell"];
package/dist/pick.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Picks agent, model, effort and account for a role (port of orq-pick, plus fixed roles).
2
2
  // fixed role: the first agent of [agent, ...fallback] that passes the filters wins — no scoring, never swapped
3
3
  // for an agent the user didn't list.
4
- // auto role: every pool agent that passes is scored:
4
+ // auto role: every (agent, model) pair that passes is scored (a level may list several models):
5
5
  // quality * delivery + tokens * economy + speed * quickness − rotation * (picks in the last `window`)
6
6
  // − reserve (agents with reserve = true) + explore (fewer than `min_samples` runs, fading with n, none after
7
7
  // `explore_streak` picks in a row). Each axis is (prior·k + observed·n)/(k + n), k = min_samples.
@@ -73,17 +73,25 @@ export function limitsFor(pol, level) {
73
73
  const five = { ...FIVE, ...(pol.quota_5h ?? {}) };
74
74
  return [Math.min(Number(five[level] ?? five.medium), Number(pol.quota_limit ?? 90)), Number(pol.quota_week ?? 95)];
75
75
  }
76
- export function modelFor(a, role, level) {
77
- if (a.models === undefined)
78
- return ["", undefined]; // no models configured: the CLI's default
79
- const models = a.models;
76
+ /** The agent's models section for a role, with the same fallbacks as the roles (fix → impl, watchdog → opinion → review). */
77
+ function roleModels(a, role) {
78
+ const models = (a.models ?? {});
80
79
  const lr = legacyRole(role);
81
- const ms = models[lr] ?? (lr === "fix" ? models.impl : lr === "watchdog" ? models.opinion ?? models.review : undefined) ?? {};
82
- const spec = ms[level] ?? ms.default ?? ms.medium ?? ms.simple;
83
- if (!spec)
84
- return undefined;
85
- const [model, effort] = spec.split("@");
86
- return [model, effort || undefined];
80
+ return models[lr] ?? (lr === "fix" ? models.impl : lr === "watchdog" ? models.opinion ?? models.review : undefined) ?? {};
81
+ }
82
+ const specList = (spec) => (Array.isArray(spec) ? spec : spec ? [spec] : []).map(String).filter(Boolean)
83
+ .map((x) => { const [model, effort] = x.split("@"); return [model, effort || undefined]; });
84
+ /** Every candidate model for the role/level, in the order configured; undefined when none is. */
85
+ export function modelsFor(a, role, level) {
86
+ if (a.models === undefined)
87
+ return [["", undefined]]; // no models configured: the CLI's default
88
+ const ms = roleModels(a, role);
89
+ const list = specList(ms[level] ?? ms.default ?? ms.medium ?? ms.simple);
90
+ return list.length ? list : undefined;
91
+ }
92
+ /** The first candidate model for the role/level. */
93
+ export function modelFor(a, role, level) {
94
+ return modelsFor(a, role, level)?.[0];
87
95
  }
88
96
  // --- per-model quota ---------------------------------------------------------------------------------------------------
89
97
  // A collector may add lines "<account>/<model> 5h=.. week=.." (antigravity: one bucket per model group). The model a pick is about
@@ -98,20 +106,19 @@ export function modelKey(model, keys) {
98
106
  }
99
107
  /** The models of an agent's role section lighter than `level`, the closest first, without repeats of `tried`. */
100
108
  export function lighterModels(a, role, level, tried) {
101
- const models = a.models;
102
- if (!models)
109
+ if (!a.models)
103
110
  return [];
104
- const lr = legacyRole(role);
105
- const ms = models[lr] ?? (lr === "fix" ? models.impl : lr === "watchdog" ? models.opinion ?? models.review : undefined) ?? {};
111
+ const ms = roleModels(a, role);
106
112
  const rank = LEVEL_RANK[level] ?? 1, seen = new Set(tried);
107
113
  const out = [];
108
114
  for (const [, spec] of Object.entries(ms).filter(([lv]) => (LEVEL_RANK[lv] ?? 1) < rank)
109
115
  .sort((x, y) => (LEVEL_RANK[y[0]] ?? 1) - (LEVEL_RANK[x[0]] ?? 1))) {
110
- const [model, effort] = spec.split("@");
111
- if (seen.has(model))
112
- continue;
113
- seen.add(model);
114
- out.push([model, effort || undefined]);
116
+ for (const [model, effort] of specList(spec)) {
117
+ if (seen.has(model))
118
+ continue;
119
+ seen.add(model);
120
+ out.push([model, effort]);
121
+ }
115
122
  }
116
123
  return out;
117
124
  }
@@ -138,6 +145,8 @@ function takesPrompt(cfg, name) {
138
145
  export function pick(inp) {
139
146
  const { cfg, role, level } = inp;
140
147
  const agents = (cfg.agents ?? {});
148
+ const conductor = String(cfg._conductorAgent ?? cfg.roles?.conductor?.agent ?? "");
149
+ const conductorHeadroom = Number(cfg.conductor?.headroom ?? 0);
141
150
  const pol = (cfg.policy ?? {});
142
151
  const [base5, baseWeek] = limitsFor(pol, level);
143
152
  const w = weights(cfg);
@@ -187,14 +196,14 @@ export function pick(inp) {
187
196
  out(name, m("ignores prompts (a review tool)", "ignora prompts (ferramenta de revisão)"));
188
197
  continue;
189
198
  }
190
- const mf = modelFor(a, role, level);
191
- if (!mf) {
199
+ const mfs = modelsFor(a, role, level);
200
+ if (!mfs) {
192
201
  out(name, m(`no model for ${role}/${level}`, `sem modelo para ${role}/${level}`));
193
202
  continue;
194
203
  }
195
204
  // Quota: an agent with several accounts (claude) prefers one not projecting past the limit, then the least used.
196
205
  const qn = a.quota ?? [];
197
- const headroom = Math.max(0, Number(a.headroom ?? 0));
206
+ const headroom = Math.max(0, Number(a.headroom ?? (name === conductor ? conductorHeadroom : 0)));
198
207
  const limit5 = base5 - headroom, limitWeek = baseWeek - headroom;
199
208
  const accs = qn.filter((n) => !inp.blocks.has(n)).map((n) => [n, n in inp.quota ? inp.quota[n] : -1]);
200
209
  if (qn.length && !accs.length) {
@@ -247,8 +256,8 @@ export function pick(inp) {
247
256
  return { pace: paces.length ? Math.max(...paces) : undefined,
248
257
  hot: penalty > 0 && ((p5 !== undefined && p5 > limit5) || (pw !== undefined && pw > limitWeek)) };
249
258
  };
250
- // The chosen account's per-model buckets: an exhausted model gives way to a lighter one of the same agent.
251
- let mfx = mf;
259
+ // The chosen account's per-model buckets: an exhausted candidate drops out; with none left, a lighter model of the agent.
260
+ let mfx = mfs;
252
261
  const acc0 = usable.length ? [...usable].sort((x, y) => x[1] - y[1])[0][0] : undefined;
253
262
  const mkeys = acc0 ? Object.keys(inp.windows ?? {}).filter((k) => k.startsWith(`${acc0}/`)).map((k) => k.slice(acc0.length + 1)) : [];
254
263
  if (acc0 && mkeys.length) {
@@ -263,23 +272,25 @@ export function pick(inp) {
263
272
  return `${model} ${five ? `5h ${q.five}%` : `${m("week", "semana")} ${q.week}%`}`
264
273
  + (at.length ? ` (${m("back", "volta")} ${humanReset(Math.max(...at))})` : "");
265
274
  };
266
- const tried = [], skipped = [];
267
- let found;
268
- for (const cand of [mf, ...lighterModels(a, role, level, [mf[0]])]) {
269
- const why = over(cand[0]);
270
- if (!why) {
271
- found = cand;
272
- break;
275
+ const skipped = [];
276
+ let found = mfs.filter((c) => { const why = over(c[0]); if (why)
277
+ skipped.push(why); return !why; });
278
+ if (!found.length) {
279
+ for (const cand of lighterModels(a, role, level, mfs.map((c) => c[0]))) {
280
+ const why = over(cand[0]);
281
+ if (!why) {
282
+ found = [cand];
283
+ break;
284
+ }
285
+ skipped.push(why);
273
286
  }
274
- tried.push(cand[0]);
275
- skipped.push(why);
276
287
  }
277
- if (!found) {
288
+ if (!found.length) {
278
289
  out(name, `${m("every model over its quota", "todo modelo acima da cota")}: ${skipped.join(", ")}`);
279
290
  continue;
280
291
  }
281
292
  if (skipped.length)
282
- out(name, `${m("quota exhausted", "cota esgotada")}: ${skipped.join(", ")}; ${m("using", "usando")} ${found[0]}`);
293
+ out(name, `${m("quota exhausted", "cota esgotada")}: ${skipped.join(", ")}; ${m("using", "usando")} ${found.map((c) => c[0]).join(", ")}`);
283
294
  mfx = found;
284
295
  }
285
296
  const ranked = usable.map(([n, v]) => ({ n, v: v, ...paceOf(n) }))
@@ -287,9 +298,12 @@ export function pick(inp) {
287
298
  const chosen = ranked[0];
288
299
  const acct = qn.length > 1 ? chosen?.n : undefined;
289
300
  const statRole = role === "fix" || role === "worker" ? "impl" : role;
290
- const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (mfx[0].split("/").pop() || "default"));
291
- const st = stOf(statRole) ?? (role === "watchdog" ? stOf("review") : undefined); // no watchdog runs yet: its review record
292
- pass.push({ agent: name, model: mfx[0], effort: mfx[1], acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
301
+ // One candidate per (agent, model) pair, each scored on its own stats row; a fixed role takes the first model.
302
+ for (const [model, effort] of res.mode === "fixed" ? mfx.slice(0, 1) : mfx) {
303
+ const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (model.split("/").pop() || "default"));
304
+ const st = stOf(statRole) ?? (role === "watchdog" ? stOf("review") : undefined); // no watchdog runs yet: its review record
305
+ pass.push({ agent: name, model, effort, acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
306
+ }
293
307
  if (res.mode === "fixed")
294
308
  break; // the first that passes wins
295
309
  }
@@ -317,16 +331,17 @@ export function pick(inp) {
317
331
  return { v, txt: `${label}=${v.toFixed(2)}` + (obs === undefined ? "" : `(${prior.toFixed(2)}→${obs.toFixed(2)})`) };
318
332
  };
319
333
  const cands = pass.map((c) => {
320
- const prior = Number(c.a.prior ?? 0.6), n = c.st?.n ?? 0, reserve = Boolean(c.a.reserve);
334
+ const prior = Number(c.a.prior ?? 0.6), n = c.st?.n ?? 0, reserve = Boolean(c.a.reserve ?? c.agent === conductor);
321
335
  const appr = role === "impl" && c.st?.approved != null && c.st.n_appr >= 3;
322
336
  const deliv = appr ? c.st.approved / 100 : c.st ? c.st.ok / 100 : undefined;
323
337
  const d = axis(m("delivery", "entrega"), prior, deliv, appr ? c.st.n_appr : n);
324
338
  const t = axis(m("economy", "economia"), prior, c.st?.new && refTok ? Math.min(1, refTok / c.st.new) : undefined, n);
325
339
  const s = axis(m("speed", "rapidez"), prior, c.st?.min && refMin ? Math.min(1, refMin / Math.max(c.st.min, 0.1)) : undefined, n);
326
340
  const used = inp.recent.filter((p) => p.agent === c.agent).length;
327
- // Exploration fades as samples come in and stops after explore_streak picks of the agent in a row.
341
+ // Exploration fades as samples come in and stops after explore_streak picks of the pair in a row (rotation stays per agent).
342
+ const same = (p) => p.agent === c.agent && (p.model === undefined || p.model === c.model);
328
343
  let streak = 0;
329
- for (let i = inp.recent.length - 1; i >= 0 && inp.recent[i].agent === c.agent; i--)
344
+ for (let i = inp.recent.length - 1; i >= 0 && same(inp.recent[i]); i--)
330
345
  streak++;
331
346
  const capped = few(c.st) && streak >= w.explore_streak;
332
347
  const explore = few(c.st) && !capped ? w.explore * (k - n) / k : 0;
package/dist/suggest.js CHANGED
@@ -98,7 +98,7 @@ export function toToml(p) {
98
98
  if (a.quota)
99
99
  out.push(`quota = ${list(a.quota)}`);
100
100
  if (a.reserve)
101
- out.push("reserve = true");
101
+ out.push("# reserve follows roles.conductor.agent (or the running conductor)");
102
102
  out.push("# models.<role>.<level> = \"model[@effort]\" — without models, the CLI's default model is used", "");
103
103
  }
104
104
  for (const [r, v] of Object.entries(p.roles)) {
package/dist/testrun.js CHANGED
@@ -12,6 +12,7 @@ import { acquire, release } from "./lock.js";
12
12
  import { handoffFile, notify } from "./notice.js";
13
13
  import { killTree, parseSize, runCapped } from "./process.js";
14
14
  import { closeOwnTab, detectHost, launch } from "./terminal.js";
15
+ import { stopWorktreeProcesses } from "./worktree-processes.js";
15
16
  export const JS_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/;
16
17
  export const git = (dir, ...a) => {
17
18
  const r = spawnSync("git", ["-C", dir, ...a], { encoding: "utf8", maxBuffer: 1 << 28 });
@@ -109,6 +110,7 @@ export function linkModules(from, to) {
109
110
  * (and must never walk into the main clone's node_modules through it).
110
111
  */
111
112
  export function dropWorktree(repo, dir) {
113
+ stopWorktreeProcesses(dir);
112
114
  const nm = join(dir, "node_modules");
113
115
  try {
114
116
  if (lstatSync(nm).isSymbolicLink() || process.platform === "win32")
@@ -0,0 +1,102 @@
1
+ // Stop processes whose current working directory is inside a worktree before git removes its files.
2
+ import { readFileSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
3
+ import { isAbsolute, relative, resolve, sep } from "node:path";
4
+ function inside(dir, path) {
5
+ const rel = relative(dir, path);
6
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
7
+ }
8
+ function stat(pid) {
9
+ try {
10
+ return readFileSync(`/proc/${pid}/stat`, "utf8");
11
+ }
12
+ catch {
13
+ return undefined;
14
+ }
15
+ }
16
+ /** orq itself and every ancestor (the shell or tmux pane that launched it), which must never be stopped. */
17
+ function ownChain() {
18
+ const chain = new Set();
19
+ for (let pid = process.pid; pid > 1 && !chain.has(pid);) {
20
+ chain.add(pid);
21
+ const ppid = Number(/\) \S+ (\d+)/.exec(stat(pid) ?? "")?.[1]);
22
+ if (!ppid)
23
+ break;
24
+ pid = ppid;
25
+ }
26
+ return chain;
27
+ }
28
+ /** Processes whose cwd is under `dir`, excluding orq and its ancestors; other platforms skip this check. */
29
+ export function worktreeProcesses(dir) {
30
+ if (process.platform !== "linux")
31
+ return [];
32
+ let root;
33
+ try {
34
+ root = realpathSync(dir);
35
+ }
36
+ catch {
37
+ root = resolve(dir);
38
+ }
39
+ let entries;
40
+ try {
41
+ entries = readdirSync("/proc");
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ const skip = ownChain(), pids = [];
47
+ for (const entry of entries) {
48
+ if (!/^\d+$/.test(entry))
49
+ continue;
50
+ const pid = Number(entry);
51
+ if (skip.has(pid))
52
+ continue;
53
+ try {
54
+ if (inside(root, resolve(readlinkSync(`/proc/${entry}/cwd`))))
55
+ pids.push(pid);
56
+ }
57
+ catch { /* process exited or inaccessible */ }
58
+ }
59
+ return pids;
60
+ }
61
+ /** `pid (name)` for output, so the user sees what is (or would be) stopped. */
62
+ export function describe(pid) {
63
+ try {
64
+ return `${pid} (${readFileSync(`/proc/${pid}/comm`, "utf8").trim()})`;
65
+ }
66
+ catch {
67
+ return String(pid);
68
+ }
69
+ }
70
+ /** SIGTERM matching processes, wait up to five seconds, then SIGKILL survivors. Returns `describe`d entries. */
71
+ export function stopWorktreeProcesses(dir) {
72
+ const pids = worktreeProcesses(dir), names = pids.map(describe);
73
+ for (const pid of pids)
74
+ try {
75
+ process.kill(pid, "SIGTERM");
76
+ }
77
+ catch { /* already exited */ }
78
+ const deadline = Date.now() + 5_000;
79
+ while (Date.now() < deadline && pids.some(alive))
80
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
81
+ for (const pid of pids)
82
+ if (alive(pid))
83
+ try {
84
+ process.kill(pid, "SIGKILL");
85
+ }
86
+ catch { /* already exited */ }
87
+ return names;
88
+ }
89
+ function alive(pid) {
90
+ if (process.platform === "linux") {
91
+ const s = stat(pid);
92
+ if (s === undefined || /^\d+ \(.*\) Z /.test(s))
93
+ return false;
94
+ }
95
+ try {
96
+ process.kill(pid, 0);
97
+ return true;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irythill/orq",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Orchestrate coding agents (Claude Code, Codex, OpenCode, Antigravity, Copilot) from one conductor.",
5
5
  "license": "MIT",
6
6
  "type": "module",