@irythill/orq 0.4.0 → 0.6.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.
@@ -11,6 +11,7 @@ import { tmpdir } from "node:os";
11
11
  import { basename, dirname, join, resolve } from "node:path";
12
12
  import { getPath, loadConfig } from "../config.js";
13
13
  import { m } from "../i18n.js";
14
+ import { ackPrefix } from "../notice.js";
14
15
  import { dropWorktree, failures, git, inJob, JS_EXT, launchJob, linkModules, runnerArgs, runnerOf, runTests, testCap, today, unsupported } from "../testrun.js";
15
16
  export const USAGE = "orq verify -w <workspace> -r <repo> -d <worktree> -b <base> -o <prefix> [-g group] [-x] tests of the diff, new vs pre-existing failures";
16
17
  export async function verify(argv) {
@@ -36,6 +37,7 @@ export async function verify(argv) {
36
37
  }
37
38
  const out = resolve(o.o);
38
39
  mkdirSync(dirname(out), { recursive: true });
40
+ ackPrefix(out); // re-running the tests of an issue: its failure notices were seen
39
41
  rmSync(`${out}.done`, { force: true });
40
42
  rmSync(`${out}.txt`, { force: true });
41
43
  // Title "<ID> verify <repo>" (ID = the prefix's folder, .orquestra/<ID>/) so closing the issue finds the tab.
@@ -6,6 +6,8 @@ import { existsSync, readFileSync } from "node:fs";
6
6
  import { basename } from "node:path";
7
7
  import { m } from "../i18n.js";
8
8
  import { alive } from "../lock.js";
9
+ import { ackPrefix } from "../notice.js";
10
+ import { doneStatus } from "../process.js";
9
11
  export const USAGE = "orq wait [--any] [--timeout <s>] [--answer] <prefix>... block until child agents finish (exit 1 = one failed)";
10
12
  const read = (f) => { try {
11
13
  return readFileSync(f, "utf8");
@@ -28,7 +30,10 @@ export function doneLine(prefix) {
28
30
  }
29
31
  catch { /* verify/baseline: no job */ }
30
32
  const answer = [`${prefix}.out.md`, `${prefix}.txt`].find(existsSync) ?? prefix;
31
- return { line: `[ORQ AGENT DONE] ${title} ${done} answer: ${answer}`, ok: /\bstatus=ok\b/.test(done) };
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" };
32
37
  }
33
38
  export async function wait(argv) {
34
39
  let any = false, timeout = 0, answer = false;
@@ -61,6 +66,7 @@ export async function wait(argv) {
61
66
  continue;
62
67
  pending.splice(pending.indexOf(p), 1);
63
68
  console.log(d.line);
69
+ ackPrefix(p); // the conductor has the result in hand: this issue's failure notice is acknowledged
64
70
  if (answer) {
65
71
  const t = read(`${p}.out.md`);
66
72
  if (t)
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,7 +28,7 @@ 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" }, // lang: auto | en | pt · theme: gunmetal | chrome | lcd | terminal
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" },
@@ -0,0 +1,105 @@
1
+ // Watchdog layer 2: `orq rounds <ID> --diagnose` asks a cheap, independent agent WHY the rounds don't converge.
2
+ // Advisory only (read only agent, no edits). Its answer is kept in .orquestra/<ID>/diagnosis-<rounds>.md; `orq agent -R -F`
3
+ // past policy.max_rounds needs that file for the current round count, newer than the latest fix/review file.
4
+ import { readdirSync, readFileSync, statSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { countRounds } from "./rounds.js";
7
+ export const DIAG_CAP = 24000; // characters of fix/review text put in the prompt
8
+ export const RECOMMENDATIONS = ["continue", "split", "change-implementer", "ask-user"];
9
+ /** Keep the start and the end of a long text (the verdict is usually at one of them). */
10
+ export function trim(s, max) {
11
+ if (s.length <= max)
12
+ return s;
13
+ const head = Math.ceil(max * 0.6), tail = max - head;
14
+ return `${s.slice(0, head)}\n[... ${s.length - max} characters trimmed ...]\n${s.slice(s.length - tail)}`;
15
+ }
16
+ const reason = (r) => ({
17
+ over_max: `rounds ${r.n} is over max_rounds ${r.max}`,
18
+ at_max: `rounds ${r.n} reached max_rounds ${r.max}`,
19
+ same_finding: `the same heavy finding came back in ${r.n} reviews`,
20
+ repeated: `${r.n} heavy finding(s) repeated across reviews`,
21
+ slow: `round ${r.rounds?.join(", ")} was much slower than the others`,
22
+ })[r.code];
23
+ const mins = (s) => `${Math.round(s / 60)} min`;
24
+ /** The pure part: metrics + fix files + review outputs → the watchdog prompt. Each document is trimmed to its share of `cap`. */
25
+ export function buildPrompt(x, fixes, reviews, cap = DIAG_CAP) {
26
+ const docs = [...fixes, ...reviews], each = docs.length ? Math.max(1500, Math.floor(cap / docs.length)) : 0;
27
+ const block = (d) => `--- ${d.name} ---\n${trim(d.text.trim(), each)}\n--- end of ${d.name} ---`;
28
+ return [
29
+ `You are an independent watchdog for issue ${x.issue}. An implementer agent and a reviewer agent have been going back and forth`,
30
+ `and the review/fix loop is not converging (alert: ${x.level}). Diagnose WHY. You are advisory only: do not edit, create or run anything;`,
31
+ `answer from the material below.`,
32
+ ``,
33
+ `## Metrics`,
34
+ `- fix rounds: ${x.rounds} (policy.max_rounds = ${x.max}); total time ${mins(x.secs)}; ${x.findings} finding(s) across reviews`,
35
+ ...x.perRound.map((r) => `- ${r.round === 0 ? "implementation + first review" : `fix ${r.round} + review`}: ${mins(r.secs)}`),
36
+ ...(x.reasons.length ? [`- why the alert: ${x.reasons.map(reason).join("; ")}`] : []),
37
+ ...(x.repeats.length ? [`- heavy findings that repeat:`, ...x.repeats.map((r) => ` - rounds ${r.rounds.join(",")}: ${r.file ? `${r.file} ` : ""}${r.title.slice(0, 160)}`)] : []),
38
+ ``,
39
+ `## What to decide`,
40
+ `Look for the real cause, for example: reviewer and implementer disagree about the requirement or the style; the scope is too big for one`,
41
+ `branch; a flaky or environment-dependent test; an ambiguous spec or acceptance criterion; fixes that fix the symptom of each review and`,
42
+ `introduce the next one; a reviewer that keeps raising new things instead of checking the previous ones.`,
43
+ ``,
44
+ `## Answer format`,
45
+ `A short diagnosis (at most 15 lines): the cause, with the evidence (which round, which file or finding). Then, as the LAST line, exactly:`,
46
+ `RECOMMENDATION: <${RECOMMENDATIONS.join(" | ")}> - one sentence why`,
47
+ `where continue = one more round is likely to converge, split = split the issue into smaller ones, change-implementer = another`,
48
+ `implementer should take over, ask-user = a decision or information only the user has is blocking.`,
49
+ ``,
50
+ `## Material`,
51
+ docs.length ? docs.map(block).join("\n\n") : "(no fix or review files)",
52
+ ``,
53
+ ].join("\n");
54
+ }
55
+ /** The "RECOMMENDATION: ..." line of a diagnosis (the last one), without markdown emphasis; undefined when there is none. */
56
+ export function recommendation(text) {
57
+ const ls = text.split("\n").map((l) => l.replace(/[*_`]/g, "").trim()).filter((l) => /^RECOMMENDATION\s*:/i.test(l));
58
+ return ls.length ? ls[ls.length - 1] : undefined;
59
+ }
60
+ const num = (re, n) => { const r = re.exec(n); return r ? Number(r[1]) : undefined; };
61
+ /** The pure gate for `orq agent -R -F` past max_rounds. ok = diagnosis-<rounds>.md exists and is not older than the latest
62
+ * fix-<n>.md / review-*.out.md; missing = no such file; stale = it is older than one of them (the loop went on after it). */
63
+ export function diagnosisGate(rounds, files) {
64
+ const d = files.find((f) => num(/^diagnosis-(\d+)\.md$/, f.name) === rounds);
65
+ if (!d)
66
+ return "missing";
67
+ const latest = Math.max(0, ...files.filter((f) => /^(fix-\d+\.md|review-.+\.out\.md)$/.test(f.name)).map((f) => f.mtimeMs));
68
+ return d.mtimeMs >= latest ? "ok" : "stale";
69
+ }
70
+ const times = (dir) => {
71
+ try {
72
+ return readdirSync(dir).map((name) => ({ name, mtimeMs: statSync(join(dir, name)).mtimeMs }));
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ };
78
+ const read = (f) => { try {
79
+ return readFileSync(f, "utf8");
80
+ }
81
+ catch {
82
+ return "";
83
+ } };
84
+ /** The impure part of the gate: the folder .orquestra/<ID>/ → verdict and the round count it was judged for. */
85
+ export function gateFor(dir) {
86
+ const files = times(dir), rounds = countRounds(dir, files.map((f) => f.name)); // the same count `orq rounds` shows
87
+ return { verdict: diagnosisGate(rounds, files), rounds };
88
+ }
89
+ /** fix-<n>.md and review-*.out.md of an issue folder, in round order, as documents for buildPrompt. */
90
+ export function evidence(dir) {
91
+ const names = times(dir).map((f) => f.name);
92
+ const fixes = names.filter((n) => /^fix-\d+\.md$/.test(n)).sort((a, b) => num(/(\d+)/, a) - num(/(\d+)/, b));
93
+ const rv = (n) => num(/-(\d+)\.out\.md$/, n) ?? 0;
94
+ const reviews = names.filter((n) => /^review-.+\.out\.md$/.test(n)).sort((a, b) => rv(a) - rv(b) || a.localeCompare(b));
95
+ const doc = (n) => ({ name: n, text: read(join(dir, n)) });
96
+ return { fixes: fixes.map(doc), reviews: reviews.map(doc) };
97
+ }
98
+ /** Highest diagnosis-<n>.md of an issue folder → its recommendation line (for `orq rounds`). */
99
+ export function latestDiagnosis(dir) {
100
+ const ns = times(dir).map((f) => num(/^diagnosis-(\d+)\.md$/, f.name)).filter((n) => n !== undefined);
101
+ if (!ns.length)
102
+ return undefined;
103
+ const n = Math.max(...ns);
104
+ return { n, line: recommendation(read(join(dir, `diagnosis-${n}.md`))) };
105
+ }
package/dist/notice.js ADDED
@@ -0,0 +1,310 @@
1
+ // Notices to the conductor: a finished child (orq agent / verify / baseline) or a red CI tells the conductor's herdr
2
+ // pane. Delivery is its own detached process (`orq notice <pane> <line>`), so it outlives whoever sent it (a job tab
3
+ // that closes, an end-of-turn hook), and everything it does is written to <state>/notices.jsonl, one JSON per line:
4
+ // notice the line, the pane, the issue, loud or not try one attempt: agent state + result
5
+ // delivered herdr accepted the prompt failed gave up (retries exhausted)
6
+ // ack the conductor acted on the issue afterwards (orq wait printed its result, or orq verify / orq agent ran)
7
+ // Besides the typed line (which a busy or confused conductor can miss) a FAILURE is also signalled where a person
8
+ // looks: a herdr notification with sound, a "✗" title on the conductor's pane (display only, expires by itself) and,
9
+ // if ui.desktop_notify allows, a desktop notification. Successes stay quiet: a plain herdr notification, nothing else.
10
+ // `orq board` reads the same log to show failures that were not delivered or not yet acknowledged.
11
+ import { spawn, spawnSync } from "node:child_process";
12
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { getPath, loadConfig } from "./config.js";
16
+ import { m } from "./i18n.js";
17
+ import { stateDir } from "./paths.js";
18
+ export const logFile = () => join(stateDir(), "notices.jsonl");
19
+ /** Where `orq handoff` records the pane that replaced a conductor's. */
20
+ export const handoffFile = (pane) => join(stateDir(), "handoff", pane.replace(/\W/g, "_"));
21
+ /** Wait before retry n (1-based): 5s, 15s, 45s, 2min, 5min — six attempts, about eight minutes in all. */
22
+ export const BACKOFF_S = [5, 15, 45, 120, 300];
23
+ export const WINDOW_MS = 60 * 60_000; // the board looks at failures this recent
24
+ // A notice with no outcome is "in flight" while its delivery process is alive. It writes an event per attempt, and the
25
+ // longest silence between two is the longest backoff plus one attempt's timeouts (get 10s + wait 70s + prompt 20s):
26
+ // past that (plus a margin) with nothing new in the log, the process died and the notice counts as undelivered.
27
+ export const GRACE_MS = (BACKOFF_S[BACKOFF_S.length - 1] + 100 + 30) * 1000;
28
+ const TITLE_TTL_MS = 30 * 60_000; // the "✗" pane title clears itself
29
+ /** After `tries` attempts (1-based) that ended in `r`: done, wait and try again, or give up. */
30
+ export function nextStep(tries, r) {
31
+ if (r === "sent")
32
+ return { kind: "done" };
33
+ return tries <= BACKOFF_S.length ? { kind: "retry", afterMs: BACKOFF_S[tries - 1] * 1000 } : { kind: "giveup" };
34
+ }
35
+ /** A failure is loud (sound, pane title, desktop): "status=" other than ok in a done line, or a red CI line. */
36
+ export function isLoud(line) {
37
+ const st = /\bstatus=(\S+)/.exec(line)?.[1];
38
+ if (st !== undefined)
39
+ return st !== "ok";
40
+ return /^\[ORQ CI\].* failed\b/.test(line);
41
+ }
42
+ /** The issue a notice is about: from the answer path (.orquestra/<ID>/...), else the first ID-looking word. */
43
+ export function issueOf(text) {
44
+ return /\.orquestra[\\/]([^\\/\s]+)[\\/]/.exec(text)?.[1] ?? /\b([A-Z][A-Z0-9]*-\d+)\b/.exec(text)?.[1];
45
+ }
46
+ /** Popup title and body from a notice line: "✗ <title>" / "✓ <title>", details in the body. */
47
+ export function popup(line) {
48
+ const loud = isLoud(line);
49
+ const t = line.replace(/^\[(HERDR-AGENT DONE|ORQ AGENT DONE|ORQ CI)\] /, "");
50
+ const i = t.indexOf(" status=");
51
+ const title = i < 0 ? t : t.slice(0, i), body = i < 0 ? "" : t.slice(i + 1).replace(/ answer: .*/, "");
52
+ return { title: `${loud ? "✗" : /\bstatus=/.test(line) ? "✓" : "•"} ${title}`, body };
53
+ }
54
+ /** ui.desktop_notify: true | false | "auto"; default on, but only on Linux with notify-send. */
55
+ export function desktopEnabled(setting, platform, hasBin) {
56
+ if (setting === false || setting === "false" || setting === "off")
57
+ return false;
58
+ return platform === "linux" && hasBin;
59
+ }
60
+ export function append(e, file = logFile()) {
61
+ try {
62
+ mkdirSync(dirname(file), { recursive: true });
63
+ appendFileSync(file, JSON.stringify({ at: Date.now(), ...e }) + "\n");
64
+ // Rotate instead of rewriting: a rename is atomic, so a concurrent detached writer's append lands in the old or the
65
+ // new file, never lost. readLog() reads both.
66
+ if (statSync(file).size > 512 * 1024)
67
+ renameSync(file, `${file}.1`);
68
+ }
69
+ catch { /* the log never takes the caller down */ }
70
+ }
71
+ export function readLog(file = logFile()) {
72
+ const one = (f) => {
73
+ try {
74
+ return readFileSync(f, "utf8").split("\n").filter(Boolean).flatMap((l) => { try {
75
+ return [JSON.parse(l)];
76
+ }
77
+ catch {
78
+ return [];
79
+ } });
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ };
85
+ return [...one(`${file}.1`), ...one(file)];
86
+ }
87
+ /**
88
+ * The failures the board puts in red: loud notices from the last `windowMs` that were not delivered (retries exhausted,
89
+ * or no outcome after `graceMs` — the delivering process died) or were delivered but not acknowledged. Acknowledged =
90
+ * an `ack` for the same issue at or after the notice. One entry per issue (the newest). Newest first.
91
+ */
92
+ export function pendingNotices(events, now, windowMs = WINDOW_MS, graceMs = GRACE_MS) {
93
+ const by = new Map();
94
+ for (const e of events)
95
+ by.set(e.id, [...(by.get(e.id) ?? []), e]);
96
+ const out = new Map();
97
+ for (const [id, evs] of by) {
98
+ const n = evs.find((e) => e.ev === "notice");
99
+ if (!n || !n.loud || now - n.at > windowMs)
100
+ continue;
101
+ if (n.issue && events.some((e) => e.ev === "ack" && e.issue === n.issue && e.at >= n.at))
102
+ continue;
103
+ const delivered = evs.some((e) => e.ev === "delivered"), failed = evs.some((e) => e.ev === "failed");
104
+ if (!delivered && !failed && now - Math.max(...evs.map((e) => e.at)) < graceMs)
105
+ continue; // still being delivered
106
+ const p = { id, ...(n.issue ? { issue: n.issue } : {}), line: n.line ?? "", at: n.at, why: delivered ? "unacknowledged" : "undelivered" };
107
+ // A delivered notice with no issue has nothing to acknowledge against: nothing to show.
108
+ if (p.why === "unacknowledged" && !n.issue)
109
+ continue;
110
+ const key = n.issue ?? id, old = out.get(key);
111
+ if (!old || old.at < p.at)
112
+ out.set(key, p);
113
+ }
114
+ return [...out.values()].sort((a, b) => b.at - a.at);
115
+ }
116
+ const herdrOk = (r) => r.status === 0;
117
+ /** `herdr agent get` → the agent's state (idle | working | blocked | done | unknown), undefined when there is no such agent. */
118
+ export function agentState(r) {
119
+ if (!herdrOk(r))
120
+ return undefined;
121
+ try {
122
+ return JSON.parse(r.stdout).result?.agent?.agent_status ?? "unknown";
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
127
+ }
128
+ /** One try: look at the agent, wait out a dialog, submit the prompt. */
129
+ export function attempt(pane, line, d) {
130
+ const got = d.run("herdr", ["agent", "get", pane], 10_000), state = agentState(got);
131
+ if (state === undefined)
132
+ return { r: "no-agent", detail: (got.stderr || got.stdout).trim().slice(0, 200) || `exit ${got.status}` };
133
+ // Blocked = a dialog is open: typing now is rejected. Give it a minute to be answered; busy (working) is sent at
134
+ // once — the agent's own input queue holds the line until its turn ends.
135
+ if (state === "blocked")
136
+ d.run("herdr", ["agent", "wait", pane, "--until", "idle", "--until", "done", "--until", "working", "--timeout", "60000"], 70_000);
137
+ const p = d.run("herdr", ["agent", "prompt", pane, line], 20_000);
138
+ if (herdrOk(p))
139
+ return { r: "sent", state, detail: state === "working" ? "agent was working: queued in its input" : "ok" };
140
+ const err = (p.stderr || p.stdout).trim().slice(0, 200) || `exit ${p.status}`;
141
+ return { r: /agent_blocked/.test(err) ? "blocked" : "error", state, detail: err };
142
+ }
143
+ /** The visual signals, independent of the conductor reading text. Never throws. */
144
+ export function signal(pane, line, d) {
145
+ const loud = isLoud(line), { title, body } = popup(line);
146
+ try {
147
+ d.run("herdr", ["notification", "show", title, ...(body ? ["--body", body] : []), "--sound", loud ? "request" : "none"], 10_000);
148
+ if (!loud)
149
+ return;
150
+ if (pane)
151
+ d.run("herdr", ["pane", "report-metadata", "--source", "orq", "--title", title, "--ttl-ms", String(TITLE_TTL_MS), pane], 10_000);
152
+ if (d.desktop)
153
+ d.run("notify-send", ["-u", "critical", "-a", "orq", title, body], 10_000);
154
+ }
155
+ catch { /* best effort */ }
156
+ }
157
+ /** Deliver one notice: signals first, then the typed line with retries. True when herdr accepted it. */
158
+ export async function deliver(id, pane, line, d) {
159
+ signal(pane, line, d);
160
+ if (!pane)
161
+ return true; // no conductor pane: the signals are all there is
162
+ for (let n = 1;; n++) {
163
+ const a = attempt(pane, line, d);
164
+ d.log({ ev: "try", n, pane, detail: `${a.r}${a.state ? ` (agent ${a.state})` : ""}: ${a.detail}` });
165
+ const s = nextStep(n, a.r);
166
+ if (s.kind === "done") {
167
+ d.log({ ev: "delivered", n, pane });
168
+ return true;
169
+ }
170
+ if (s.kind === "giveup") {
171
+ d.log({ ev: "failed", n, pane, detail: a.detail });
172
+ if (isLoud(line) && d.desktop)
173
+ d.run("notify-send", ["-u", "critical", "-a", "orq", m("✗ notice NOT delivered to the conductor", "✗ aviso NÃO entregue ao conductor"), popup(line).title], 10_000);
174
+ return false;
175
+ }
176
+ await d.sleep(s.afterMs);
177
+ }
178
+ }
179
+ const exec = (cmd, args, timeoutMs = 15_000) => {
180
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: timeoutMs });
181
+ return { status: r.error ? null : r.status, stdout: r.stdout ?? "", stderr: r.stderr || (r.error ? String(r.error.message) : "") };
182
+ };
183
+ const hasBin = (b) => spawnSync(process.platform === "win32" ? "where" : "which", [b], { stdio: "ignore" }).status === 0;
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();
202
+ while (!seen.has(pane) && existsSync(handoffFile(pane))) {
203
+ seen.add(pane);
204
+ let next;
205
+ try {
206
+ next = readFileSync(handoffFile(pane), "utf8").trim();
207
+ }
208
+ catch {
209
+ break;
210
+ }
211
+ if (!next || next === pane)
212
+ break;
213
+ chain.push(next);
214
+ pane = next;
215
+ }
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];
229
+ }
230
+ function realDeps(id, pane, issue) {
231
+ let setting;
232
+ try {
233
+ setting = getPath(loadConfig().cfg, "ui.desktop_notify");
234
+ }
235
+ catch { /* broken config: defaults */ }
236
+ return {
237
+ run: exec, sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
238
+ log: (e) => append({ id, pane, ...(issue ? { issue } : {}), ...e }),
239
+ desktop: desktopEnabled(setting, process.platform, hasBin("notify-send")),
240
+ };
241
+ }
242
+ const CLI = fileURLToPath(new URL("./cli.js", import.meta.url));
243
+ /**
244
+ * Tell the conductor's herdr pane. Returns at once: logs the notice, then starts `orq notice --send` detached so the
245
+ * delivery (signals, retries, waiting out a dialog) survives the caller and nothing waits on it.
246
+ */
247
+ export function notify(pane, line, start = startDetached) {
248
+ const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
249
+ pane = currentPane(pane);
250
+ append({ id, ev: "notice", pane, loud: isLoud(line), line, ...(issueOf(line) ? { issue: issueOf(line) } : {}) });
251
+ try {
252
+ start(["notice", "--send", id, pane, line]);
253
+ }
254
+ catch {
255
+ append({ id, ev: "failed", pane, detail: "could not start the delivery process" });
256
+ }
257
+ }
258
+ function startDetached(argv) {
259
+ spawn(process.execPath, [CLI, ...argv], { detached: true, stdio: "ignore" })
260
+ .on("error", () => append({ id: argv[2], ev: "failed", pane: argv[3], detail: "could not start the delivery process" })).unref();
261
+ }
262
+ /** The delivery process: `orq notice --send <id> <pane> <line>`. */
263
+ export async function send(id, pane, line) {
264
+ return deliver(id, pane, line, realDeps(id, pane, issueOf(line)));
265
+ }
266
+ /** Acknowledge an issue's failures: the conductor acted on it. Clears the "✗" pane titles it set. Never throws. */
267
+ export function ack(issue, run = exec, file = logFile()) {
268
+ if (!issue)
269
+ return;
270
+ try {
271
+ const open = pendingNotices(readLog(file), Date.now(), WINDOW_MS, 0).filter((p) => p.issue === issue);
272
+ if (!open.length)
273
+ return;
274
+ append({ id: `ack-${Date.now().toString(36)}`, ev: "ack", issue }, file);
275
+ // Per pane: the title shows the newest failure still open there; cleared only when none remain.
276
+ const evs = readLog(file), paneOf = (id) => evs.find((e) => e.id === id && e.ev === "notice")?.pane;
277
+ const left = pendingNotices(evs, Date.now(), WINDOW_MS, 0);
278
+ for (const pane of new Set(open.map((p) => paneOf(p.id)).filter((x) => !!x))) {
279
+ const next = left.find((p) => paneOf(p.id) === pane);
280
+ run("herdr", ["pane", "report-metadata", "--source", "orq", ...(next ? ["--title", popup(next.line).title, "--ttl-ms", String(TITLE_TTL_MS)] : ["--clear-title"]), pane], 5_000);
281
+ }
282
+ }
283
+ catch { /* best effort */ }
284
+ }
285
+ /** ack() for a run prefix (<ws>/.orquestra/<ID>/<name>). */
286
+ export const ackPrefix = (prefix) => ack(issueOf(prefix.replace(/\\/g, "/")));
287
+ export const USAGE = "orq notice [--list] [-n N] the delivery log of notices to conductors (what was sent, retried, failed)";
288
+ export async function noticeCmd(argv) {
289
+ if (argv[0] === "--send")
290
+ return (await send(argv[1], argv[2], argv.slice(3).join(" "))) ? 0 : 1;
291
+ let n = 20;
292
+ for (let i = 0; i < argv.length; i++) {
293
+ if (argv[i] === "-n")
294
+ n = Number(argv[++i]);
295
+ else if (argv[i] !== "--list") {
296
+ console.error(USAGE);
297
+ return 2;
298
+ }
299
+ }
300
+ const evs = readLog();
301
+ if (!evs.length) {
302
+ console.log(m(`no notices yet (${logFile()})`, `nenhum aviso ainda (${logFile()})`));
303
+ return 0;
304
+ }
305
+ for (const e of evs.slice(-Math.max(1, n || 20))) {
306
+ const t = new Date(e.at).toTimeString().slice(0, 8);
307
+ console.log(`${t} ${e.id} ${e.ev}${e.loud ? " LOUD" : ""}${e.pane ? ` pane=${e.pane}` : ""}${e.issue ? ` issue=${e.issue}` : ""}${e.n ? ` try=${e.n}` : ""} ${e.line ?? e.detail ?? ""}`.trimEnd());
308
+ }
309
+ return 0;
310
+ }
package/dist/pick.js CHANGED
@@ -1,3 +1,18 @@
1
+ // Picks agent, model, effort and account for a role (port of orq-pick, plus fixed roles).
2
+ // fixed role: the first agent of [agent, ...fallback] that passes the filters wins — no scoring, never swapped
3
+ // for an agent the user didn't list.
4
+ // auto role: every pool agent that passes is scored:
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)
7
+ // 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
+ // quota under the limits: policy.quota_week (95) on the weekly window, and on the 5h one policy.quota_5h per level
9
+ // (simple 90, medium 85, heavy 80): a heavy task needs more room left to finish.
10
+ // An agent's `headroom` (points) lowers both limits for it: that share of its accounts stays for the conductors.
11
+ // An account whose quota reading says the login expired is skipped, with the fix command in the note.
12
+ // Pacing: an account whose usage, at the pace so far in a window (used ÷ elapsed share), projects past
13
+ // that window's pick limit (the filter's) at its reset is passed over for another account under the limit when the agent has several,
14
+ // and otherwise loses policy.pacing_penalty (default 0.3, 0 = off) of score — soft, never a drop.
15
+ import { agentDef, usesPrompt } from "./agents.js";
1
16
  import { m } from "./i18n.js";
2
17
  import { humanReset } from "./quota.js";
3
18
  import { legacyRole, resolveRole, vendorOf } from "./roles.js";
@@ -62,7 +77,7 @@ export function modelFor(a, role, level) {
62
77
  return ["", undefined]; // no models configured: the CLI's default
63
78
  const models = a.models;
64
79
  const lr = legacyRole(role);
65
- const ms = models[lr] ?? (lr === "fix" ? models.impl : undefined) ?? {};
80
+ const ms = models[lr] ?? (lr === "fix" ? models.impl : lr === "watchdog" ? models.opinion ?? models.review : undefined) ?? {};
66
81
  const spec = ms[level] ?? ms.default ?? ms.medium ?? ms.simple;
67
82
  if (!spec)
68
83
  return undefined;
@@ -71,6 +86,15 @@ export function modelFor(a, role, level) {
71
86
  }
72
87
  const WEIGHTS = { quality: 0.4, tokens: 0.45, speed: 0.15, rotation: 0.2, reserve: 0.3, explore: 0.1, min_samples: 8, window: 6 };
73
88
  export const weights = (cfg) => ({ ...WEIGHTS, ...((cfg.policy ?? {}).weights ?? {}) });
89
+ /** The merged definition (built-in + config) takes a prompt; an agent that can't be resolved doesn't. */
90
+ function takesPrompt(cfg, name) {
91
+ try {
92
+ return usesPrompt(agentDef(cfg, name));
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
74
98
  export function pick(inp) {
75
99
  const { cfg, role, level } = inp;
76
100
  const agents = (cfg.agents ?? {});
@@ -101,15 +125,19 @@ export function pick(inp) {
101
125
  out(name, m("blocked (orq block -l)", "bloqueado (orq block -l)"));
102
126
  continue;
103
127
  }
104
- if ((role === "review" || role === "opinion") && name === inp.implAgent && someoneElse) {
128
+ if ((role === "review" || role === "opinion" || role === "watchdog") && name === inp.implAgent && someoneElse) {
105
129
  out(name, m("it implemented this code", "foi quem implementou"));
106
130
  continue;
107
131
  }
108
- if ((role === "review" || role === "opinion") && res.rule === "other-vendor" && inp.implVendor && otherVendor
132
+ if ((role === "review" || role === "opinion" || role === "watchdog") && res.rule === "other-vendor" && inp.implVendor && otherVendor
109
133
  && vendorOf(name, a) === inp.implVendor) {
110
134
  out(name, m("same vendor as the implementer", "mesmo fornecedor do implementador"));
111
135
  continue;
112
136
  }
137
+ if (role === "watchdog" && !takesPrompt(cfg, name)) {
138
+ out(name, m("ignores prompts (a review tool)", "ignora prompts (ferramenta de revisão)"));
139
+ continue;
140
+ }
113
141
  const mf = modelFor(a, role, level);
114
142
  if (!mf) {
115
143
  out(name, m(`no model for ${role}/${level}`, `sem modelo para ${role}/${level}`));
@@ -175,7 +203,8 @@ export function pick(inp) {
175
203
  const chosen = ranked[0];
176
204
  const acct = qn.length > 1 ? chosen?.n : undefined;
177
205
  const statRole = role === "fix" || role === "worker" ? "impl" : role;
178
- const st = inp.stats.find((r) => r.role === statRole && r.agent === name && r.model === (mf[0].split("/").pop() || "default"));
206
+ const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (mf[0].split("/").pop() || "default"));
207
+ const st = stOf(statRole) ?? (role === "watchdog" ? stOf("review") : undefined); // no watchdog runs yet: its review record
179
208
  pass.push({ agent: name, model: mf[0], effort: mf[1], acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
180
209
  if (res.mode === "fixed")
181
210
  break; // the first that passes wins
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" });
package/dist/roles.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** What the conductor asks for (orq pick <role>) → the [roles] section that answers it. */
2
- export const PICK_ROLES = ["impl", "fix", "worker", "review", "opinion"];
2
+ export const PICK_ROLES = ["impl", "fix", "worker", "review", "opinion", "watchdog"];
3
3
  const SECTION = {
4
- impl: ["primary"], fix: ["primary"], worker: ["workers", "primary"], review: ["reviewer"], opinion: ["opinion"],
4
+ impl: ["primary"], fix: ["primary"], worker: ["workers", "primary"], review: ["reviewer"], opinion: ["opinion"], watchdog: ["watchdog"],
5
5
  };
6
6
  /** The role name used in the old agents.<name>.roles lists. */
7
7
  export const legacyRole = (r) => (r === "worker" ? "impl" : r);
@@ -10,7 +10,16 @@ export const BUILTIN_VENDORS = {
10
10
  claude: "anthropic", codex: "openai", antigravity: "google", copilot: "github",
11
11
  };
12
12
  export const vendorOf = (name, a) => a?.vendor ?? BUILTIN_VENDORS[name];
13
+ const count = (r) => (r.mode === "fixed" ? r.order : r.pool).length;
14
+ /** The watchdog (orq rounds --diagnose) uses its own role when something declares it, else the opinion pool, else the review one. */
13
15
  export function resolveRole(cfg, role) {
16
+ const own = direct(cfg, role);
17
+ if (role !== "watchdog" || count(own))
18
+ return own;
19
+ const op = direct(cfg, "opinion");
20
+ return count(op) ? op : direct(cfg, "review");
21
+ }
22
+ function direct(cfg, role) {
14
23
  const agents = (cfg.agents ?? {});
15
24
  const roles = (cfg.roles ?? {});
16
25
  const policyRule = String((cfg.policy ?? {}).review ?? "other-vendor");
package/dist/rounds.js CHANGED
@@ -114,6 +114,18 @@ export function metrics(issue, f) {
114
114
  raise("warn", { code: "slow", n: slow.length, rounds: slow });
115
115
  return { issue, rounds: f.rounds, max: f.max, secs: f.secs, perRound: f.perRound, findings: f.findings.length, repeats: reps, level, reasons };
116
116
  }
117
+ /** Rounds of an issue folder: the highest .rounds-<session> counter or fix-<n>.md. Shared by `orq rounds` and the -F gate. */
118
+ export function countRounds(dir, names) {
119
+ let n = 0;
120
+ for (const f of names) {
121
+ const fx = /^fix-(\d+)\.md$/.exec(f);
122
+ if (fx)
123
+ n = Math.max(n, Number(fx[1]));
124
+ else if (f.startsWith(".rounds-"))
125
+ n = Math.max(n, Number(read(join(dir, f))) || 0);
126
+ }
127
+ return n;
128
+ }
117
129
  /** The impure part: read .orquestra/<issue>/. undefined when the issue has no fix or review files at all. */
118
130
  export function collectRounds(ws, issue, max) {
119
131
  const dir = join(ws, ".orquestra", issue);
@@ -126,7 +138,6 @@ export function collectRounds(ws, issue, max) {
126
138
  }
127
139
  const mt = (n) => statSync(join(dir, n)).mtimeMs;
128
140
  const fixes = new Map(), reviews = new Map();
129
- let counter = 0;
130
141
  for (const n of names) {
131
142
  const fx = /^fix-(\d+)\.md$/.exec(n);
132
143
  if (fx)
@@ -137,13 +148,11 @@ export function collectRounds(ws, issue, max) {
137
148
  const rv = /^review-(.+?)(?:-(\d+))?\.out\.md$/.exec(n), k = rv && rv[2] !== undefined && fixes.has(Number(rv[2])) ? Number(rv[2]) : 0;
138
149
  if (rv)
139
150
  reviews.set(k, [...(reviews.get(k) ?? []), n]);
140
- else if (n.startsWith(".rounds-"))
141
- counter = Math.max(counter, Number(read(join(dir, n))) || 0);
142
151
  }
143
152
  if (!reviews.size && !fixes.size)
144
153
  return undefined;
145
- const rounds = Math.max(counter, 0, ...fixes.keys());
146
- const all = names.filter((n) => !n.startsWith(".")).map(mt), t0 = Math.min(...all), t1 = Math.max(...all);
154
+ const rounds = countRounds(dir, names);
155
+ const all = names.filter((n) => !n.startsWith(".") && !/^(diagnosis|watchdog)-/.test(n)).map(mt), t0 = Math.min(...all), t1 = Math.max(...all);
147
156
  // Round k runs from fix-k.md (round 0: the oldest file) to the next fix, the last one to the newest file.
148
157
  const starts = [t0, ...Array.from({ length: rounds }, (_, i) => fixes.get(i + 1) ?? NaN)];
149
158
  const perRound = starts.map((s, k) => {