@irythill/orq 0.5.0 → 0.6.1
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 +59 -38
- package/README.pt-BR.md +60 -38
- package/dist/agents.js +80 -6
- package/dist/ci.js +6 -6
- package/dist/cli.js +3 -1
- package/dist/commands/agent.js +146 -20
- package/dist/commands/board.js +90 -20
- package/dist/commands/claim.js +2 -0
- package/dist/commands/completion.js +1 -1
- package/dist/commands/conductor.js +104 -0
- package/dist/commands/gc.js +57 -21
- package/dist/commands/handoff.js +1 -0
- package/dist/commands/quota.js +1 -1
- package/dist/commands/rounds.js +45 -18
- package/dist/commands/wait.js +5 -1
- package/dist/config.js +5 -1
- package/dist/install.js +5 -0
- package/dist/issue.js +47 -0
- package/dist/notice.js +33 -5
- package/dist/pick.js +105 -17
- package/dist/process.js +7 -0
- package/dist/quota.js +85 -19
- package/dist/stats.js +2 -1
- package/package.json +3 -2
- package/skills/issue/SKILL.md +41 -41
- package/templates/conductor.instructions.md +31 -0
- package/templates/workspace.CLAUDE.md +6 -5
package/dist/commands/gc.js
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
// orq gc — find issue worktrees whose branch is already safe to remove.
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
-
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
5
|
import { loadConfig } from "../config.js";
|
|
6
6
|
import { m } from "../i18n.js";
|
|
7
|
+
import { issueFromName, issuePattern, issuesWithLiveAgent } from "../issue.js";
|
|
8
|
+
import { alive } from "../lock.js";
|
|
9
|
+
import { stateDir } from "../paths.js";
|
|
7
10
|
import { aliveOwner } from "./claim.js";
|
|
8
|
-
export const USAGE = "orq gc [-w <workspace>] [--yes] [--days N] [--json] remove merged issue worktrees";
|
|
11
|
+
export const USAGE = "orq gc [-w <workspace>] [--yes] [--days N] [--json] remove merged issue worktrees and stale agent state";
|
|
9
12
|
const git = (cwd, ...args) => spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
|
|
10
13
|
// Paths as git prints them can differ from ours (macOS /var → /private/var, Windows C:/ vs C:\): compare real paths.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
14
|
+
// A path that does not exist (yet) is resolved through its deepest existing ancestor, so both sides get the same prefix.
|
|
15
|
+
const real = (p) => {
|
|
16
|
+
const abs = resolve(p);
|
|
17
|
+
try {
|
|
18
|
+
return realpathSync.native(abs);
|
|
19
|
+
}
|
|
20
|
+
catch { /* missing: resolve the parent */ }
|
|
21
|
+
const up = dirname(abs);
|
|
22
|
+
return up === abs ? abs : join(real(up), basename(abs));
|
|
23
|
+
};
|
|
24
|
+
const fold = (p) => (process.platform === "win32" ? p.toLowerCase() : p); // NTFS is case-insensitive
|
|
25
|
+
const inside = (dir, p) => { const r = relative(fold(dir), fold(p)); return !!r && !r.startsWith("..") && !isAbsolute(r); };
|
|
18
26
|
// ORQ_GH may be a .js file (tests: a fake gh that also runs on Windows).
|
|
19
27
|
const gh = (cwd, args) => {
|
|
20
28
|
const bin = process.env.ORQ_GH || "gh";
|
|
@@ -22,6 +30,30 @@ const gh = (cwd, args) => {
|
|
|
22
30
|
: spawnSync(bin, args, { cwd, encoding: "utf8" });
|
|
23
31
|
};
|
|
24
32
|
const output = (cwd, ...args) => { const r = git(cwd, ...args); return r.status === 0 ? r.stdout.trim() : undefined; };
|
|
33
|
+
/** Per-agent state files (<state>/agents/<unit>.json) of runs under `ws` that are finished or dead and older than `days`. */
|
|
34
|
+
function staleAgentStates(ws, days, now) {
|
|
35
|
+
const dir = join(stateDir(), "agents"), out = [];
|
|
36
|
+
let files = [];
|
|
37
|
+
try {
|
|
38
|
+
files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
39
|
+
}
|
|
40
|
+
catch { /* no runs yet */ }
|
|
41
|
+
for (const f of files) {
|
|
42
|
+
try {
|
|
43
|
+
const s = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
44
|
+
if (!inside(real(ws), real(String(s.prefix ?? ""))))
|
|
45
|
+
continue;
|
|
46
|
+
const finished = s.status === "ok" || s.status === "fail" || s.status === "killed";
|
|
47
|
+
if (!finished && alive(Number(s.pid)))
|
|
48
|
+
continue;
|
|
49
|
+
const since = Date.parse(String(s.end ?? s.at));
|
|
50
|
+
if (now - since > days * 86400_000)
|
|
51
|
+
out.push(join(dir, f));
|
|
52
|
+
}
|
|
53
|
+
catch { /* unreadable state: leave it */ }
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
25
57
|
export async function gc(argv, clock = Date.now) {
|
|
26
58
|
let ws = process.cwd(), yes = false, json = false, days = 14;
|
|
27
59
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -38,10 +70,11 @@ export async function gc(argv, clock = Date.now) {
|
|
|
38
70
|
return 2;
|
|
39
71
|
}
|
|
40
72
|
}
|
|
41
|
-
const { cfg } = loadConfig(ws), rows = [];
|
|
73
|
+
const { cfg } = loadConfig(ws), rows = [], issuePat = issuePattern(ws);
|
|
42
74
|
let ghUnavailable = false, fetchWarned = false;
|
|
43
75
|
const root = join(ws, ".worktrees");
|
|
44
76
|
const orq = join(ws, ".orquestra");
|
|
77
|
+
const liveAgents = issuesWithLiveAgent();
|
|
45
78
|
const issues = existsSync(orq) ? readdirSync(orq).filter((x) => !x.startsWith(".")) : [];
|
|
46
79
|
for (const repoName of readdirSync(ws, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)) {
|
|
47
80
|
const repo = join(ws, repoName);
|
|
@@ -71,8 +104,8 @@ export async function gc(argv, clock = Date.now) {
|
|
|
71
104
|
const branch = wt.branch?.replace(/^refs\/heads\//, "") ?? "";
|
|
72
105
|
if (!branch)
|
|
73
106
|
continue;
|
|
74
|
-
// The branch carries the ID in lowercase (
|
|
75
|
-
const id =
|
|
107
|
+
// The branch carries the ID in lowercase (abc-123, maybe with a prefix or -suffix); .orquestra/ keeps it as written (ABC-123).
|
|
108
|
+
const id = issueFromName(branch, issuePat);
|
|
76
109
|
const issue = id && issues.find((d) => d.toLowerCase() === id.toLowerCase());
|
|
77
110
|
const status = output(path, "status", "--porcelain");
|
|
78
111
|
const unpushed = output(repo, "rev-list", branch, "--not", "--remotes");
|
|
@@ -86,14 +119,10 @@ export async function gc(argv, clock = Date.now) {
|
|
|
86
119
|
verdict = "KEEP";
|
|
87
120
|
reason = m("issue has a live owner", "issue tem um responsável ativo");
|
|
88
121
|
}
|
|
89
|
-
// A
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (running) {
|
|
94
|
-
verdict = "KEEP";
|
|
95
|
-
reason = m("child agent may still be running", "um agente filho pode estar em execução");
|
|
96
|
-
}
|
|
122
|
+
// A child agent of the issue whose state says running and whose pid is alive (the board's rule).
|
|
123
|
+
if (verdict !== "KEEP" && issue && liveAgents.has(issue)) {
|
|
124
|
+
verdict = "KEEP";
|
|
125
|
+
reason = m("child agent may still be running", "um agente filho pode estar em execução");
|
|
97
126
|
}
|
|
98
127
|
let merged = false;
|
|
99
128
|
if (verdict !== "KEEP") {
|
|
@@ -153,6 +182,10 @@ export async function gc(argv, clock = Date.now) {
|
|
|
153
182
|
}
|
|
154
183
|
git(join(ws, row.repo), "worktree", "prune");
|
|
155
184
|
}
|
|
185
|
+
const stale = staleAgentStates(ws, days, clock());
|
|
186
|
+
if (yes)
|
|
187
|
+
for (const f of stale)
|
|
188
|
+
rmSync(f, { force: true });
|
|
156
189
|
if (json)
|
|
157
190
|
console.log(JSON.stringify(rows));
|
|
158
191
|
else {
|
|
@@ -160,5 +193,8 @@ export async function gc(argv, clock = Date.now) {
|
|
|
160
193
|
const w = table[0].map((_, i) => Math.max(...table.map((t) => t[i].length)));
|
|
161
194
|
console.log(table.map((t) => t.map((c, i) => i < t.length - 1 ? c.padEnd(w[i]) : c).join(" ")).join("\n"));
|
|
162
195
|
}
|
|
196
|
+
if (stale.length && !json)
|
|
197
|
+
console.log(yes ? m(`pruned ${stale.length} stale agent state file(s)`, `${stale.length} arquivo(s) de estado de agente antigo(s) removido(s)`)
|
|
198
|
+
: m(`${stale.length} stale agent state file(s) (finished/dead, older than ${days} days): --yes removes them`, `${stale.length} arquivo(s) de estado de agente antigo(s) (terminados/mortos, mais de ${days} dias): --yes remove`));
|
|
163
199
|
return failed ? 1 : 0;
|
|
164
200
|
}
|
package/dist/commands/handoff.js
CHANGED
|
@@ -163,6 +163,7 @@ async function move(ws, c, to, dir, model, mark) {
|
|
|
163
163
|
for (const id of c.issues)
|
|
164
164
|
writeAtomic(join(ws, ".orquestra", id, "owner"), `pane=${pane} kind=herdr account=${basename(dir)} at=${new Date().toISOString()} name=${label}\n`);
|
|
165
165
|
writeAtomic(handoffFile(c.pane), pane);
|
|
166
|
+
rmSync(handoffFile(pane), { force: true }); // the new pane is live: a leftover redirect out of it would make a cycle
|
|
166
167
|
const regs = join(ws, ".orquestra", ".conductors");
|
|
167
168
|
mkdirSync(regs, { recursive: true });
|
|
168
169
|
writeAtomic(join(regs, pane.replace(/[^A-Za-z0-9_-]/g, "_")), `pane=${pane} kind=herdr account=${basename(dir)} at=${new Date().toISOString()} name=${label}\n`);
|
package/dist/commands/quota.js
CHANGED
|
@@ -34,7 +34,7 @@ export function humanizeQuotaLine(line, now = Date.now()) {
|
|
|
34
34
|
* `policy.quota_week`) — the same thresholds `orq pick` applies for an unspecified task level; ⊘ blocked
|
|
35
35
|
* (orq block), ? no data; sources with no window at all ("-" and "-") are left out. */
|
|
36
36
|
export function quotaLine(lines, limit5, limitWeek, blocked, age) {
|
|
37
|
-
const parts = lines.filter((q) => q.five !== "-" || q.week !== "-").map((q) => {
|
|
37
|
+
const parts = lines.filter((q) => !q.name.includes("/") && (q.five !== "-" || q.week !== "-")).map((q) => {
|
|
38
38
|
if (blocked.has(q.name))
|
|
39
39
|
return `${q.name} ⊘`;
|
|
40
40
|
const five = pct(q.five), week = pct(q.week);
|
package/dist/commands/rounds.js
CHANGED
|
@@ -96,37 +96,64 @@ async function diagnoseIssue(ws, id, x) {
|
|
|
96
96
|
}
|
|
97
97
|
const ev = evidence(dir);
|
|
98
98
|
writeFileSync(`${prefix}.md`, buildPrompt(x, ev.fixes, ev.reviews));
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
const picked = p.stdout.trim().split(/\s+/);
|
|
105
|
-
// The agent runs in a scratch folder (everything it needs is in the prompt): it can't touch the issue files, even write sandboxed.
|
|
106
|
-
const scratch = mkdtempSync(join(tmpdir(), "orq-watchdog-"));
|
|
99
|
+
// A failed watchdog (e.g. a model catalog timeout) gets one retry with the next candidate, never the same agent.
|
|
100
|
+
const excluded = [];
|
|
101
|
+
let first;
|
|
102
|
+
const scratches = [];
|
|
107
103
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
104
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
105
|
+
const p = orq("pick", "watchdog", "--level", "simple", "-w", ws, ...(implPick ? ["--impl-agent", implPick] : []), ...excluded.flatMap((e) => ["--exclude-agent", e]));
|
|
106
|
+
if (p.status !== 0) {
|
|
107
|
+
if (first) {
|
|
108
|
+
console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
|
|
109
|
+
return first.rc;
|
|
110
|
+
}
|
|
111
|
+
console.error(p.stderr.trim() || `orq pick watchdog: exit ${p.status}`);
|
|
112
|
+
return p.status ?? 1;
|
|
113
|
+
}
|
|
114
|
+
const picked = p.stdout.trim().split(/\s+/), agent = /(?:^|\s)-a (\S+)/.exec(p.stdout)?.[1];
|
|
115
|
+
// The agent runs in a scratch folder (everything it needs is in the prompt): it can't touch the issue files, even write sandboxed.
|
|
116
|
+
const scratch = mkdtempSync(join(tmpdir(), "orq-watchdog-"));
|
|
117
|
+
scratches.push(scratch);
|
|
118
|
+
const run = `${prefix}-${attempt}`;
|
|
119
|
+
{
|
|
120
|
+
writeFileSync(`${run}.md`, readFileSync(`${prefix}.md`));
|
|
121
|
+
const a = orq("agent", ...picked, "-r", "-t", `${id} watchdog`, "-d", scratch, "-g", "watchdog", "-o", run, "--", `${run}.md`);
|
|
122
|
+
process.stderr.write(a.stderr);
|
|
123
|
+
process.stdout.write(a.stdout);
|
|
124
|
+
const rc = a.status !== 0 ? a.status ?? 1 : await finish(await wait(["--timeout", String(WATCHDOG_TIMEOUT), run]), run, out, agent, attempt === 1 || !agent);
|
|
125
|
+
if (rc === 0 || rc === 124 || !agent)
|
|
126
|
+
return rc;
|
|
127
|
+
if (attempt === 0)
|
|
128
|
+
console.error(m(`watchdog ${agent} failed; retrying with another agent`, `watchdog ${agent} falhou; tentando com outro agente`));
|
|
129
|
+
first = { rc };
|
|
130
|
+
excluded.push(agent);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return first?.rc ?? 1;
|
|
134
|
+
// Removed only after every attempt, and best-effort: on Windows a just-finished agent can still hold its cwd (EPERM/EBUSY).
|
|
114
135
|
}
|
|
115
136
|
finally {
|
|
116
|
-
|
|
137
|
+
for (const s of scratches) {
|
|
138
|
+
try {
|
|
139
|
+
rmSync(s, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
140
|
+
}
|
|
141
|
+
catch { /* temp dir: the OS cleans it */ }
|
|
142
|
+
}
|
|
117
143
|
}
|
|
118
144
|
}
|
|
119
|
-
async function finish(rc, prefix, out) {
|
|
145
|
+
async function finish(rc, prefix, out, agent, final = true) {
|
|
120
146
|
if (rc === 124) {
|
|
121
147
|
console.error(m(`diagnosis not written: the watchdog agent did not finish in ${WATCHDOG_TIMEOUT}s`, `diagnóstico não gravado: o agente watchdog não terminou em ${WATCHDOG_TIMEOUT}s`));
|
|
122
148
|
return 124;
|
|
123
149
|
}
|
|
124
150
|
if (rc !== 0 || !existsSync(`${prefix}.out.md`)) {
|
|
125
|
-
|
|
151
|
+
if (final)
|
|
152
|
+
console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
|
|
126
153
|
return 1;
|
|
127
154
|
}
|
|
128
155
|
copyFileSync(`${prefix}.out.md`, out);
|
|
129
|
-
console.log(m(`diagnosis saved to ${out}`, `diagnóstico gravado em ${out}`));
|
|
156
|
+
console.log(m(`diagnosis saved to ${out}${agent ? ` (by ${agent})` : ""}`, `diagnóstico gravado em ${out}${agent ? ` (por ${agent})` : ""}`));
|
|
130
157
|
console.log(recommendation(readFileSync(out, "utf8")) ?? m("(no RECOMMENDATION line in the answer)", "(sem linha RECOMMENDATION na resposta)"));
|
|
131
158
|
return 0;
|
|
132
159
|
}
|
package/dist/commands/wait.js
CHANGED
|
@@ -7,6 +7,7 @@ import { basename } from "node:path";
|
|
|
7
7
|
import { m } from "../i18n.js";
|
|
8
8
|
import { alive } from "../lock.js";
|
|
9
9
|
import { ackPrefix } from "../notice.js";
|
|
10
|
+
import { doneStatus } from "../process.js";
|
|
10
11
|
export const USAGE = "orq wait [--any] [--timeout <s>] [--answer] <prefix>... block until child agents finish (exit 1 = one failed)";
|
|
11
12
|
const read = (f) => { try {
|
|
12
13
|
return readFileSync(f, "utf8");
|
|
@@ -29,7 +30,10 @@ export function doneLine(prefix) {
|
|
|
29
30
|
}
|
|
30
31
|
catch { /* verify/baseline: no job */ }
|
|
31
32
|
const answer = [`${prefix}.out.md`, `${prefix}.txt`].find(existsSync) ?? prefix;
|
|
32
|
-
|
|
33
|
+
const status = doneStatus(done);
|
|
34
|
+
if (status === "killed")
|
|
35
|
+
done = done.replace(/\bstatus=\S+/, "status=killed"); // legacy "status=ok rc=137"
|
|
36
|
+
return { line: `[ORQ AGENT DONE] ${title} ${done} answer: ${answer}`, ok: status === "ok" };
|
|
33
37
|
}
|
|
34
38
|
export async function wait(argv) {
|
|
35
39
|
let any = false, timeout = 0, answer = false;
|
package/dist/config.js
CHANGED
|
@@ -15,6 +15,7 @@ export const DEFAULTS = {
|
|
|
15
15
|
mem_enforce: "auto", // auto (kernel cap where possible, else watchdog) | kernel | watchdog | off
|
|
16
16
|
test_slots: 1, // test suites in parallel on the machine
|
|
17
17
|
close_tab_after: 20, // seconds until a finished child tab closes (0 = never)
|
|
18
|
+
tui_idle_close_min: 30, // minutes a finished -T agent waits for a follow-up before it is closed (0 = never)
|
|
18
19
|
tui: false, // children in the agent's own screen (-T) by default
|
|
19
20
|
terminal: "auto", // where children show their output: auto (herdr, else tmux, else plain) | herdr | tmux | plain
|
|
20
21
|
},
|
|
@@ -27,10 +28,13 @@ export const DEFAULTS = {
|
|
|
27
28
|
max_rounds: 2, // fix rounds before stopping and calling the user
|
|
28
29
|
review: "other-vendor", // other-vendor | any
|
|
29
30
|
},
|
|
30
|
-
ui: { lang: "auto", theme: "gunmetal", desktop_notify: true }, // lang: auto | en | pt · theme: gunmetal | chrome | lcd | terminal · desktop_notify: desktop popup for failures (Linux, notify-send)
|
|
31
|
+
ui: { lang: "auto", theme: "gunmetal", desktop_notify: true, board_hide_hours: 6 }, // board_hide_hours: finished/dead agents older than this leave `orq board` (still in --json) · lang: auto | en | pt · theme: gunmetal | chrome | lcd | terminal · desktop_notify: desktop popup for failures (Linux, notify-send)
|
|
31
32
|
// Where quota comes from, per agent: "builtin" | "none" | false | { command = "..." } (see src/quota.ts).
|
|
32
33
|
// "claude" covers every account in claude-accounts.
|
|
33
34
|
quota: { claude: "builtin", codex: "builtin", opencode: "builtin", antigravity: "builtin", copilot: "builtin" },
|
|
35
|
+
// Issue ID in a branch/worktree name (orq gc, orq ci): case-insensitive regex, the named group "issue" (else group 1) is the ID.
|
|
36
|
+
// The default finds <LETTERS>-<DIGITS> anywhere (feature/ABC-123, abc-123-fix, plane-abc-123-api-2); suffixes are ignored.
|
|
37
|
+
git: { issue_pattern: "(?<![a-z0-9])(?<issue>[a-z][a-z0-9]*-\\d+)(?![a-z0-9])" },
|
|
34
38
|
tracker: { kind: "none" }, // plane | none
|
|
35
39
|
repos: {}, // defined by the workspace
|
|
36
40
|
};
|
package/dist/install.js
CHANGED
|
@@ -89,6 +89,11 @@ export function prepareWorkspace(dir, fmt, name = basename(resolve(dir))) {
|
|
|
89
89
|
}
|
|
90
90
|
else
|
|
91
91
|
out.push(m(` ok ${map} (kept)`, ` ok ${map} (mantido)`));
|
|
92
|
+
// The conductor's instructions (what `orq conductor` looks for): regenerated on every run.
|
|
93
|
+
const ci = join(ws, ".orquestra-conductor.md");
|
|
94
|
+
const t = readFileSync(join(PKG, "templates", "conductor.instructions.md"), "utf8");
|
|
95
|
+
writeAtomic(ci, t.split("{{WORKSPACE}}").join(ws).split("{{NAME}}").join(name));
|
|
96
|
+
out.push(` ${m("wrote", "escrevi")} ${ci}`);
|
|
92
97
|
const r = spawnSync(process.execPath, [join(PKG, "dist", "cli.js"), "instructions", fmt, "-w", ws], { encoding: "utf8" });
|
|
93
98
|
out.push(` ${(r.stdout || r.stderr).trim()}`);
|
|
94
99
|
return out;
|
package/dist/issue.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The issue a branch or worktree belongs to, and which issues have an agent really running.
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { getPath, loadConfig } from "./config.js";
|
|
5
|
+
import { alive } from "./lock.js";
|
|
6
|
+
import { stateDir } from "./paths.js";
|
|
7
|
+
/** Finds "<LETTERS>-<DIGITS>" anywhere in the name, as its own token; anything after the number is a suffix. */
|
|
8
|
+
export const DEFAULT_ISSUE_PATTERN = "(?<![a-z0-9])(?<issue>[a-z][a-z0-9]*-\\d+)(?![a-z0-9])";
|
|
9
|
+
/** The issue ID (as written in the name) from a branch or worktree name like "me/plane-proj-68-api-2" or
|
|
10
|
+
* "feature/ABC-123". `pattern` is [git] issue_pattern: a case-insensitive regex whose named group "issue"
|
|
11
|
+
* (else group 1, else the whole match) is the ID. */
|
|
12
|
+
export function issueFromName(name, pattern = DEFAULT_ISSUE_PATTERN) {
|
|
13
|
+
let re;
|
|
14
|
+
try {
|
|
15
|
+
re = new RegExp(pattern, "i");
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
throw new Error(`git.issue_pattern: ${e.message}`);
|
|
19
|
+
}
|
|
20
|
+
const r = re.exec(name);
|
|
21
|
+
return r ? (r.groups?.issue ?? r[1] ?? r[0]) : undefined;
|
|
22
|
+
}
|
|
23
|
+
/** [git] issue_pattern of the workspace's merged config (default when unset). */
|
|
24
|
+
export function issuePattern(ws) {
|
|
25
|
+
const v = getPath(loadConfig(ws).cfg, "git.issue_pattern");
|
|
26
|
+
return typeof v === "string" && v ? v : DEFAULT_ISSUE_PATTERN;
|
|
27
|
+
}
|
|
28
|
+
/** Issue directories (basename of the run prefix's folder) with an agent whose state says running and whose pid is
|
|
29
|
+
* alive: the same rule the board uses for "running". */
|
|
30
|
+
export function issuesWithLiveAgent() {
|
|
31
|
+
const dir = join(stateDir(), "agents"), out = new Set();
|
|
32
|
+
let files = [];
|
|
33
|
+
try {
|
|
34
|
+
files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
35
|
+
}
|
|
36
|
+
catch { /* no runs yet */ }
|
|
37
|
+
for (const f of files) {
|
|
38
|
+
try {
|
|
39
|
+
const s = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
40
|
+
if (s.status === "ok" || s.status === "fail" || s.status === "killed" || !alive(Number(s.pid)))
|
|
41
|
+
continue;
|
|
42
|
+
out.add(basename(dirname(String(s.prefix ?? ""))));
|
|
43
|
+
}
|
|
44
|
+
catch { /* unreadable state: ignore */ }
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
package/dist/notice.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// if ui.desktop_notify allows, a desktop notification. Successes stay quiet: a plain herdr notification, nothing else.
|
|
10
10
|
// `orq board` reads the same log to show failures that were not delivered or not yet acknowledged.
|
|
11
11
|
import { spawn, spawnSync } from "node:child_process";
|
|
12
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
|
|
12
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs";
|
|
13
13
|
import { dirname, join } from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { getPath, loadConfig } from "./config.js";
|
|
@@ -181,9 +181,24 @@ const exec = (cmd, args, timeoutMs = 15_000) => {
|
|
|
181
181
|
return { status: r.error ? null : r.status, stdout: r.stdout ?? "", stderr: r.stderr || (r.error ? String(r.error.message) : "") };
|
|
182
182
|
};
|
|
183
183
|
const hasBin = (b) => spawnSync(process.platform === "win32" ? "where" : "which", [b], { stdio: "ignore" }).status === 0;
|
|
184
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
184
|
+
/** Does herdr know this pane? true | false (herdr answered pane_not_found) | undefined (unreachable or unclear).
|
|
185
|
+
* `pane get`, not `agent get`: a live pane whose agent exited has no agent but is still a place to type. */
|
|
186
|
+
export function paneExists(pane, run = exec) {
|
|
187
|
+
const r = run("herdr", ["pane", "get", pane], 2_000);
|
|
188
|
+
if (r.status === 0)
|
|
189
|
+
return true;
|
|
190
|
+
if (r.status === null)
|
|
191
|
+
return undefined; // herdr is not installed / did not answer
|
|
192
|
+
return /"code"\s*:\s*"pane_not_found"/.test(`${r.stdout}\n${r.stderr}`) ? false : undefined;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Follow `orq handoff` redirects to the pane that now holds the conductor (stops at cycles and unreadable files).
|
|
196
|
+
* Walks the whole chain by files and returns the furthest pane that still exists, so a dead middle hop (A→B→C, B gone,
|
|
197
|
+
* C alive) does not strand the notice. The redirect out of that last live pane leads nowhere live: it is deleted (and
|
|
198
|
+
* logged). When herdr cannot be asked, a hop counts as live (the old behaviour: follow blindly).
|
|
199
|
+
*/
|
|
200
|
+
export function currentPane(pane, run = exec) {
|
|
201
|
+
const chain = [pane], seen = new Set();
|
|
187
202
|
while (!seen.has(pane) && existsSync(handoffFile(pane))) {
|
|
188
203
|
seen.add(pane);
|
|
189
204
|
let next;
|
|
@@ -195,9 +210,22 @@ export function currentPane(pane) {
|
|
|
195
210
|
}
|
|
196
211
|
if (!next || next === pane)
|
|
197
212
|
break;
|
|
213
|
+
chain.push(next);
|
|
198
214
|
pane = next;
|
|
199
215
|
}
|
|
200
|
-
|
|
216
|
+
let last = 0;
|
|
217
|
+
for (let i = 1; i < chain.length; i++)
|
|
218
|
+
if (paneExists(chain[i], run) !== false)
|
|
219
|
+
last = i;
|
|
220
|
+
// A redirect out of the last live pane exists only when the chain went on to dead panes.
|
|
221
|
+
if (last < chain.length - 1 && existsSync(handoffFile(chain[last]))) {
|
|
222
|
+
try {
|
|
223
|
+
rmSync(handoffFile(chain[last]), { force: true });
|
|
224
|
+
append({ id: `redirect-${Date.now().toString(36)}`, ev: "redirect", pane: chain[last], detail: `stale handoff to closed ${chain[last + 1]} removed` });
|
|
225
|
+
}
|
|
226
|
+
catch { /* best effort */ }
|
|
227
|
+
}
|
|
228
|
+
return chain[last];
|
|
201
229
|
}
|
|
202
230
|
function realDeps(id, pane, issue) {
|
|
203
231
|
let setting;
|
package/dist/pick.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// for an agent the user didn't list.
|
|
4
4
|
// auto role: every pool agent that passes is scored:
|
|
5
5
|
// quality * delivery + tokens * economy + speed * quickness − rotation * (picks in the last `window`)
|
|
6
|
-
// − reserve (agents with reserve = true) + explore (fewer than `min_samples` runs
|
|
6
|
+
// − reserve (agents with reserve = true) + explore (fewer than `min_samples` runs, fading with n, none after
|
|
7
|
+
// `explore_streak` picks in a row). Each axis is (prior·k + observed·n)/(k + n), k = min_samples.
|
|
7
8
|
// Filters: --exclude-agent, orq block, nobody reviews their own code (unless no other agent can), cross-vendor rule, a model for the role/level,
|
|
8
9
|
// quota under the limits: policy.quota_week (95) on the weekly window, and on the 5h one policy.quota_5h per level
|
|
9
10
|
// (simple 90, medium 85, heavy 80): a heavy task needs more room left to finish.
|
|
@@ -84,7 +85,37 @@ export function modelFor(a, role, level) {
|
|
|
84
85
|
const [model, effort] = spec.split("@");
|
|
85
86
|
return [model, effort || undefined];
|
|
86
87
|
}
|
|
87
|
-
|
|
88
|
+
// --- per-model quota ---------------------------------------------------------------------------------------------------
|
|
89
|
+
// A collector may add lines "<account>/<model> 5h=.. week=.." (antigravity: one bucket per model group). The model a pick is about
|
|
90
|
+
// to choose is checked against its own line, and an exhausted one gives way to a lighter model the agent has configured.
|
|
91
|
+
const LEVEL_RANK = { simple: 0, medium: 1, default: 1, heavy: 2 };
|
|
92
|
+
/** The quota line key for a configured model: the exact id, else the id without its -low/-medium/-high suffix, plain or
|
|
93
|
+
* as "<base>-tiered" (one bucket serves all three efforts of a tiered model). */
|
|
94
|
+
export function modelKey(model, keys) {
|
|
95
|
+
const id = model.split("/").pop() ?? model;
|
|
96
|
+
const base = id.replace(/-(low|medium|high)$/, "");
|
|
97
|
+
return [id, base, `${base}-tiered`].find((k) => keys.includes(k));
|
|
98
|
+
}
|
|
99
|
+
/** The models of an agent's role section lighter than `level`, the closest first, without repeats of `tried`. */
|
|
100
|
+
export function lighterModels(a, role, level, tried) {
|
|
101
|
+
const models = a.models;
|
|
102
|
+
if (!models)
|
|
103
|
+
return [];
|
|
104
|
+
const lr = legacyRole(role);
|
|
105
|
+
const ms = models[lr] ?? (lr === "fix" ? models.impl : lr === "watchdog" ? models.opinion ?? models.review : undefined) ?? {};
|
|
106
|
+
const rank = LEVEL_RANK[level] ?? 1, seen = new Set(tried);
|
|
107
|
+
const out = [];
|
|
108
|
+
for (const [, spec] of Object.entries(ms).filter(([lv]) => (LEVEL_RANK[lv] ?? 1) < rank)
|
|
109
|
+
.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]);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
const WEIGHTS = { quality: 0.4, tokens: 0.45, speed: 0.15, rotation: 0.2, reserve: 0.3, explore: 0.1, explore_streak: 2, min_samples: 8, window: 6 };
|
|
88
119
|
export const weights = (cfg) => ({ ...WEIGHTS, ...((cfg.policy ?? {}).weights ?? {}) });
|
|
89
120
|
/** The merged definition (built-in + config) takes a prompt; an agent that can't be resolved doesn't. */
|
|
90
121
|
function takesPrompt(cfg, name) {
|
|
@@ -198,14 +229,49 @@ export function pick(inp) {
|
|
|
198
229
|
return { pace: paces.length ? Math.max(...paces) : undefined,
|
|
199
230
|
hot: penalty > 0 && ((p5 !== undefined && p5 > limit5) || (pw !== undefined && pw > limitWeek)) };
|
|
200
231
|
};
|
|
232
|
+
// The chosen account's per-model buckets: an exhausted model gives way to a lighter one of the same agent.
|
|
233
|
+
let mfx = mf;
|
|
234
|
+
const acc0 = usable.length ? [...usable].sort((x, y) => x[1] - y[1])[0][0] : undefined;
|
|
235
|
+
const mkeys = acc0 ? Object.keys(inp.windows ?? {}).filter((k) => k.startsWith(`${acc0}/`)).map((k) => k.slice(acc0.length + 1)) : [];
|
|
236
|
+
if (acc0 && mkeys.length) {
|
|
237
|
+
const over = (model) => {
|
|
238
|
+
const k = modelKey(model, mkeys), q = k ? inp.windows?.[`${acc0}/${k}`] : undefined;
|
|
239
|
+
if (!q)
|
|
240
|
+
return undefined;
|
|
241
|
+
const five = (q.five ?? -1) >= limit5, week = (q.week ?? -1) >= limitWeek;
|
|
242
|
+
if (!five && !week)
|
|
243
|
+
return undefined;
|
|
244
|
+
const at = [five ? q.resetFive : undefined, week ? q.resetWeek : undefined].filter((t) => t !== undefined);
|
|
245
|
+
return `${model} ${five ? `5h ${q.five}%` : `${m("week", "semana")} ${q.week}%`}`
|
|
246
|
+
+ (at.length ? ` (${m("back", "volta")} ${humanReset(Math.max(...at))})` : "");
|
|
247
|
+
};
|
|
248
|
+
const tried = [], skipped = [];
|
|
249
|
+
let found;
|
|
250
|
+
for (const cand of [mf, ...lighterModels(a, role, level, [mf[0]])]) {
|
|
251
|
+
const why = over(cand[0]);
|
|
252
|
+
if (!why) {
|
|
253
|
+
found = cand;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
tried.push(cand[0]);
|
|
257
|
+
skipped.push(why);
|
|
258
|
+
}
|
|
259
|
+
if (!found) {
|
|
260
|
+
out(name, `${m("every model over its quota", "todo modelo acima da cota")}: ${skipped.join(", ")}`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (skipped.length)
|
|
264
|
+
out(name, `${m("quota exhausted", "cota esgotada")}: ${skipped.join(", ")}; ${m("using", "usando")} ${found[0]}`);
|
|
265
|
+
mfx = found;
|
|
266
|
+
}
|
|
201
267
|
const ranked = usable.map(([n, v]) => ({ n, v: v, ...paceOf(n) }))
|
|
202
268
|
.sort((x, y) => Number(x.hot) - Number(y.hot) || x.v - y.v);
|
|
203
269
|
const chosen = ranked[0];
|
|
204
270
|
const acct = qn.length > 1 ? chosen?.n : undefined;
|
|
205
271
|
const statRole = role === "fix" || role === "worker" ? "impl" : role;
|
|
206
|
-
const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (
|
|
272
|
+
const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (mfx[0].split("/").pop() || "default"));
|
|
207
273
|
const st = stOf(statRole) ?? (role === "watchdog" ? stOf("review") : undefined); // no watchdog runs yet: its review record
|
|
208
|
-
pass.push({ agent: name, model:
|
|
274
|
+
pass.push({ agent: name, model: mfx[0], effort: mfx[1], acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
|
|
209
275
|
if (res.mode === "fixed")
|
|
210
276
|
break; // the first that passes wins
|
|
211
277
|
}
|
|
@@ -215,24 +281,46 @@ export function pick(inp) {
|
|
|
215
281
|
why: m(`fixed by [roles.${res.section}]`, `fixo em [roles.${res.section}]`) };
|
|
216
282
|
return { mode: "fixed", best, cands: best ? [best] : [], notes };
|
|
217
283
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
284
|
+
// Each axis blends the agent's prior with what was measured, (prior·k + observed·n) / (k + n) with k = min_samples,
|
|
285
|
+
// so a couple of runs already move the score instead of being ignored until the threshold.
|
|
286
|
+
const k = Math.max(0, w.min_samples);
|
|
287
|
+
const few = (st) => (st?.n ?? 0) < k;
|
|
288
|
+
const blend = (prior, obs, n) => obs === undefined || k + n <= 0 ? prior : (prior * k + obs * n) / (k + n);
|
|
289
|
+
// The reference cost/time is the best of the well-sampled agents, or of everyone measured when none is.
|
|
290
|
+
const best = (vals) => {
|
|
291
|
+
const solid = vals.filter((x) => x.solid);
|
|
292
|
+
const use = solid.length ? solid : vals;
|
|
293
|
+
return use.length ? Math.min(...use.map((x) => x.v)) : undefined;
|
|
294
|
+
};
|
|
295
|
+
const refTok = best(pass.filter((c) => c.st?.new).map((c) => ({ v: c.st.new, solid: !few(c.st) })));
|
|
296
|
+
const refMin = best(pass.filter((c) => c.st?.min).map((c) => ({ v: Math.max(c.st.min, 0.1), solid: !few(c.st) })));
|
|
297
|
+
const axis = (label, prior, obs, n) => {
|
|
298
|
+
const v = blend(prior, obs, n);
|
|
299
|
+
return { v, txt: `${label}=${v.toFixed(2)}` + (obs === undefined ? "" : `(${prior.toFixed(2)}→${obs.toFixed(2)})`) };
|
|
300
|
+
};
|
|
221
301
|
const cands = pass.map((c) => {
|
|
222
302
|
const prior = Number(c.a.prior ?? 0.6), n = c.st?.n ?? 0, reserve = Boolean(c.a.reserve);
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
const d =
|
|
226
|
-
const t =
|
|
227
|
-
const s =
|
|
303
|
+
const appr = role === "impl" && c.st?.approved != null && c.st.n_appr >= 3;
|
|
304
|
+
const deliv = appr ? c.st.approved / 100 : c.st ? c.st.ok / 100 : undefined;
|
|
305
|
+
const d = axis(m("delivery", "entrega"), prior, deliv, appr ? c.st.n_appr : n);
|
|
306
|
+
const t = axis(m("economy", "economia"), prior, c.st?.new && refTok ? Math.min(1, refTok / c.st.new) : undefined, n);
|
|
307
|
+
const s = axis(m("speed", "rapidez"), prior, c.st?.min && refMin ? Math.min(1, refMin / Math.max(c.st.min, 0.1)) : undefined, n);
|
|
228
308
|
const used = inp.recent.filter((p) => p.agent === c.agent).length;
|
|
309
|
+
// Exploration fades as samples come in and stops after explore_streak picks of the agent in a row.
|
|
310
|
+
let streak = 0;
|
|
311
|
+
for (let i = inp.recent.length - 1; i >= 0 && inp.recent[i].agent === c.agent; i--)
|
|
312
|
+
streak++;
|
|
313
|
+
const capped = few(c.st) && streak >= w.explore_streak;
|
|
314
|
+
const explore = few(c.st) && !capped ? w.explore * (k - n) / k : 0;
|
|
229
315
|
const hot = Boolean(c.hot) && penalty > 0;
|
|
230
|
-
const score = w.quality * d + w.tokens * t + w.speed * s - w.rotation * used
|
|
231
|
-
- (reserve ? w.reserve : 0) +
|
|
232
|
-
const why = `${
|
|
233
|
-
+ `${m("speed", "rapidez")}=${s.toFixed(2)} ${m("rotation", "rodízio")}=-${used} `
|
|
316
|
+
const score = w.quality * d.v + w.tokens * t.v + w.speed * s.v - w.rotation * used
|
|
317
|
+
- (reserve ? w.reserve : 0) + explore - (hot ? penalty : 0);
|
|
318
|
+
const why = `${d.txt} ${t.txt} ${s.txt} ${m("rotation", "rodízio")}=-${used} `
|
|
234
319
|
+ (reserve ? m("reserve ", "reserva ") : "")
|
|
235
|
-
+ (hot ? `${m("pace", "ritmo")}=${Math.round(c.pace)}%→-${penalty} ` : "")
|
|
320
|
+
+ (hot ? `${m("pace", "ritmo")}=${Math.round(c.pace)}%→-${penalty} ` : "")
|
|
321
|
+
+ (explore ? `${m("explore", "explorar")}=+${explore.toFixed(2)} ` : "")
|
|
322
|
+
+ (capped ? `${m("explore capped", "exploração limitada")}(${streak}x) ` : "")
|
|
323
|
+
+ (few(c.st) ? `${m("few samples", "poucos dados")}(n=${n})` : `n=${n}`);
|
|
236
324
|
return { agent: c.agent, model: c.model, effort: c.effort, acct: c.acct, score, why };
|
|
237
325
|
}).sort((x, y) => y.score - x.score);
|
|
238
326
|
return { mode: "auto", best: cands[0], cands, notes };
|
package/dist/process.js
CHANGED
|
@@ -77,6 +77,13 @@ export function treePids(root, table = processTable()) {
|
|
|
77
77
|
export function treeMem(root, table = processTable()) {
|
|
78
78
|
return treePids(root, table).reduce((t, p) => t + (table.get(p)?.[1] ?? 0), 0);
|
|
79
79
|
}
|
|
80
|
+
/** An exit code of 128+signal (137 = SIGKILL): the process was killed, it did not finish. */
|
|
81
|
+
export const killedRc = (rc) => rc !== undefined && rc > 128 && rc <= 128 + 64;
|
|
82
|
+
/** "status=ok rc=137" (written before `killed` existed) is a kill, not a success: the status a done line really means. */
|
|
83
|
+
export function doneStatus(line) {
|
|
84
|
+
const st = /\bstatus=(\S+)/.exec(line)?.[1], rc = /\brc=(\d+)/.exec(line)?.[1];
|
|
85
|
+
return st === "ok" && killedRc(rc === undefined ? undefined : Number(rc)) ? "killed" : st;
|
|
86
|
+
}
|
|
80
87
|
export function killTree(pid, group = true) {
|
|
81
88
|
if (process.platform === "win32")
|
|
82
89
|
spawnSync("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore" });
|