@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.
- package/README.md +40 -11
- package/README.pt-BR.md +40 -11
- package/dist/commands/claim.js +3 -1
- package/dist/commands/completion.js +3 -2
- package/dist/commands/conductor.js +123 -33
- package/dist/commands/gc.js +18 -13
- package/dist/commands/instructions.js +54 -12
- package/dist/commands/pick.js +2 -0
- package/dist/commands/setup.js +19 -6
- package/dist/harness.js +69 -0
- package/dist/install.js +24 -8
- package/dist/pick.js +57 -42
- package/dist/suggest.js +1 -1
- package/dist/testrun.js +2 -0
- package/dist/worktree-processes.js +102 -0
- package/package.json +1 -1
- package/skills/issue/SKILL.md +19 -13
- package/skills/orq-setup/SKILL.md +3 -3
- package/templates/conductor.instructions.md +12 -9
- package/templates/workspace.CLAUDE.md +2 -2
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
52
|
+
const a = argv[i];
|
|
53
|
+
if (a === "-w")
|
|
38
54
|
ws = resolve(argv[++i]);
|
|
39
|
-
else if (
|
|
55
|
+
else if (a === "--print")
|
|
40
56
|
print = true;
|
|
41
|
-
else if (
|
|
42
|
-
|
|
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;
|
package/dist/commands/pick.js
CHANGED
|
@@ -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]);
|
package/dist/commands/setup.js
CHANGED
|
@@ -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,
|
|
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 ??=
|
|
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
|
|
139
|
-
Board: orq board · quota: orq quota · completion: orq completion <shell>`, `Próximo: preencha o
|
|
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;
|
package/dist/harness.js
ADDED
|
@@ -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,
|
|
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 =
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
102
|
-
if (!models)
|
|
109
|
+
if (!a.models)
|
|
103
110
|
return [];
|
|
104
|
-
const
|
|
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]
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
191
|
-
if (!
|
|
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
|
|
251
|
-
let mfx =
|
|
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
|
|
267
|
-
let found;
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
291
|
-
const
|
|
292
|
-
|
|
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
|
|
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]
|
|
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
|
|
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
|
+
}
|