@irythill/orq 0.4.0 → 0.5.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 +11 -4
- package/README.pt-BR.md +9 -2
- package/dist/agents.js +10 -0
- package/dist/ci.js +4 -2
- package/dist/cli.js +3 -1
- package/dist/commands/agent.js +29 -11
- package/dist/commands/block.js +26 -3
- package/dist/commands/board.js +23 -11
- package/dist/commands/completion.js +2 -2
- package/dist/commands/pick.js +1 -1
- package/dist/commands/rounds.js +77 -3
- package/dist/commands/stats.js +1 -1
- package/dist/commands/verify.js +2 -0
- package/dist/commands/wait.js +2 -0
- package/dist/config.js +1 -1
- package/dist/diagnose.js +105 -0
- package/dist/notice.js +282 -0
- package/dist/pick.js +33 -4
- package/dist/roles.js +11 -2
- package/dist/rounds.js +14 -5
- package/dist/stats.js +4 -3
- package/dist/testrun.js +4 -27
- package/package.json +1 -1
- package/skills/issue/SKILL.md +9 -4
- package/templates/workspace.CLAUDE.md +2 -1
package/dist/diagnose.js
ADDED
|
@@ -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,282 @@
|
|
|
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, 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
|
+
/** Follow `orq handoff` redirects to the pane that now holds the conductor (stops at cycles and unreadable files). */
|
|
185
|
+
export function currentPane(pane) {
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
while (!seen.has(pane) && existsSync(handoffFile(pane))) {
|
|
188
|
+
seen.add(pane);
|
|
189
|
+
let next;
|
|
190
|
+
try {
|
|
191
|
+
next = readFileSync(handoffFile(pane), "utf8").trim();
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
if (!next || next === pane)
|
|
197
|
+
break;
|
|
198
|
+
pane = next;
|
|
199
|
+
}
|
|
200
|
+
return pane;
|
|
201
|
+
}
|
|
202
|
+
function realDeps(id, pane, issue) {
|
|
203
|
+
let setting;
|
|
204
|
+
try {
|
|
205
|
+
setting = getPath(loadConfig().cfg, "ui.desktop_notify");
|
|
206
|
+
}
|
|
207
|
+
catch { /* broken config: defaults */ }
|
|
208
|
+
return {
|
|
209
|
+
run: exec, sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
210
|
+
log: (e) => append({ id, pane, ...(issue ? { issue } : {}), ...e }),
|
|
211
|
+
desktop: desktopEnabled(setting, process.platform, hasBin("notify-send")),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const CLI = fileURLToPath(new URL("./cli.js", import.meta.url));
|
|
215
|
+
/**
|
|
216
|
+
* Tell the conductor's herdr pane. Returns at once: logs the notice, then starts `orq notice --send` detached so the
|
|
217
|
+
* delivery (signals, retries, waiting out a dialog) survives the caller and nothing waits on it.
|
|
218
|
+
*/
|
|
219
|
+
export function notify(pane, line, start = startDetached) {
|
|
220
|
+
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
221
|
+
pane = currentPane(pane);
|
|
222
|
+
append({ id, ev: "notice", pane, loud: isLoud(line), line, ...(issueOf(line) ? { issue: issueOf(line) } : {}) });
|
|
223
|
+
try {
|
|
224
|
+
start(["notice", "--send", id, pane, line]);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
append({ id, ev: "failed", pane, detail: "could not start the delivery process" });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function startDetached(argv) {
|
|
231
|
+
spawn(process.execPath, [CLI, ...argv], { detached: true, stdio: "ignore" })
|
|
232
|
+
.on("error", () => append({ id: argv[2], ev: "failed", pane: argv[3], detail: "could not start the delivery process" })).unref();
|
|
233
|
+
}
|
|
234
|
+
/** The delivery process: `orq notice --send <id> <pane> <line>`. */
|
|
235
|
+
export async function send(id, pane, line) {
|
|
236
|
+
return deliver(id, pane, line, realDeps(id, pane, issueOf(line)));
|
|
237
|
+
}
|
|
238
|
+
/** Acknowledge an issue's failures: the conductor acted on it. Clears the "✗" pane titles it set. Never throws. */
|
|
239
|
+
export function ack(issue, run = exec, file = logFile()) {
|
|
240
|
+
if (!issue)
|
|
241
|
+
return;
|
|
242
|
+
try {
|
|
243
|
+
const open = pendingNotices(readLog(file), Date.now(), WINDOW_MS, 0).filter((p) => p.issue === issue);
|
|
244
|
+
if (!open.length)
|
|
245
|
+
return;
|
|
246
|
+
append({ id: `ack-${Date.now().toString(36)}`, ev: "ack", issue }, file);
|
|
247
|
+
// Per pane: the title shows the newest failure still open there; cleared only when none remain.
|
|
248
|
+
const evs = readLog(file), paneOf = (id) => evs.find((e) => e.id === id && e.ev === "notice")?.pane;
|
|
249
|
+
const left = pendingNotices(evs, Date.now(), WINDOW_MS, 0);
|
|
250
|
+
for (const pane of new Set(open.map((p) => paneOf(p.id)).filter((x) => !!x))) {
|
|
251
|
+
const next = left.find((p) => paneOf(p.id) === pane);
|
|
252
|
+
run("herdr", ["pane", "report-metadata", "--source", "orq", ...(next ? ["--title", popup(next.line).title, "--ttl-ms", String(TITLE_TTL_MS)] : ["--clear-title"]), pane], 5_000);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch { /* best effort */ }
|
|
256
|
+
}
|
|
257
|
+
/** ack() for a run prefix (<ws>/.orquestra/<ID>/<name>). */
|
|
258
|
+
export const ackPrefix = (prefix) => ack(issueOf(prefix.replace(/\\/g, "/")));
|
|
259
|
+
export const USAGE = "orq notice [--list] [-n N] the delivery log of notices to conductors (what was sent, retried, failed)";
|
|
260
|
+
export async function noticeCmd(argv) {
|
|
261
|
+
if (argv[0] === "--send")
|
|
262
|
+
return (await send(argv[1], argv[2], argv.slice(3).join(" "))) ? 0 : 1;
|
|
263
|
+
let n = 20;
|
|
264
|
+
for (let i = 0; i < argv.length; i++) {
|
|
265
|
+
if (argv[i] === "-n")
|
|
266
|
+
n = Number(argv[++i]);
|
|
267
|
+
else if (argv[i] !== "--list") {
|
|
268
|
+
console.error(USAGE);
|
|
269
|
+
return 2;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const evs = readLog();
|
|
273
|
+
if (!evs.length) {
|
|
274
|
+
console.log(m(`no notices yet (${logFile()})`, `nenhum aviso ainda (${logFile()})`));
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
for (const e of evs.slice(-Math.max(1, n || 20))) {
|
|
278
|
+
const t = new Date(e.at).toTimeString().slice(0, 8);
|
|
279
|
+
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());
|
|
280
|
+
}
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
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
|
|
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/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 =
|
|
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) => {
|
package/dist/stats.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { basename, join } from "node:path";
|
|
9
|
-
const ROLE = /^(impl|fix\d*|review\d*|[a-z]+-review\d*)-?/;
|
|
9
|
+
const ROLE = /^(impl|fix\d*|review\d*|[a-z]+-review\d*|watchdog\d*)-?/;
|
|
10
10
|
const read = (f) => { try {
|
|
11
11
|
return readFileSync(f, "utf8");
|
|
12
12
|
}
|
|
@@ -115,8 +115,9 @@ export function collectRuns(ws, days) {
|
|
|
115
115
|
const name = basename(pre);
|
|
116
116
|
const rm = ROLE.exec(name);
|
|
117
117
|
const role = rm && rm[1].includes("review") ? "review"
|
|
118
|
-
:
|
|
119
|
-
: name.startsWith("
|
|
118
|
+
: rm && rm[1].startsWith("watchdog") ? "watchdog"
|
|
119
|
+
: name.startsWith("fix") || txt.includes("-R ") || txt.includes(" resume ") || txt.includes("--resume") ? "fix"
|
|
120
|
+
: name.startsWith("impl") ? "impl" : "other";
|
|
120
121
|
const out = read(`${pre}.out.md`);
|
|
121
122
|
const [neu, tot] = tokens(agent, log, read(`${pre}.id`).trim());
|
|
122
123
|
runs.push({ issue, name, agent, model, role, ok: read(done).includes("status=ok") && out.trim() !== "",
|
package/dist/testrun.js
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
// memory cap (src/process.ts) and read its JSON report into "<file relative to the repo> :: <full test name>" lines.
|
|
3
3
|
// The runner's own bin (node_modules/<runner>/package.json "bin") runs with this node, so Windows needs no shell;
|
|
4
4
|
// without it, npx as before. Also the background job both share (launch on a TerminalHost, queue, .pid/.done, notify).
|
|
5
|
-
import {
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { createWriteStream, existsSync, lstatSync, readFileSync, realpathSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { writeAtomic } from "./fsutil.js";
|
|
10
10
|
import { m } from "./i18n.js";
|
|
11
11
|
import { acquire, release } from "./lock.js";
|
|
12
|
-
import {
|
|
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
15
|
export const JS_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/;
|
|
@@ -197,28 +197,5 @@ export async function inJob(j, label, slots, body, done) {
|
|
|
197
197
|
}
|
|
198
198
|
return st === "ok" ? 0 : 1;
|
|
199
199
|
}
|
|
200
|
-
/**
|
|
201
|
-
export
|
|
202
|
-
/** The conductor is a herdr pane: type the line into it (herdr-notify waits for it to leave a dialog). In the
|
|
203
|
-
* background when the caller must not wait for that (an agent's end-of-turn hook holds its screen meanwhile). */
|
|
204
|
-
export function notify(pane, line, background = false) {
|
|
205
|
-
const seen = new Set();
|
|
206
|
-
while (!seen.has(pane) && existsSync(handoffFile(pane))) {
|
|
207
|
-
seen.add(pane);
|
|
208
|
-
let next;
|
|
209
|
-
try {
|
|
210
|
-
next = readFileSync(handoffFile(pane), "utf8").trim();
|
|
211
|
-
}
|
|
212
|
-
catch {
|
|
213
|
-
break;
|
|
214
|
-
} // gone between the two calls
|
|
215
|
-
if (!next || next === pane)
|
|
216
|
-
break;
|
|
217
|
-
pane = next; // follow repeated handoffs to the current conductor
|
|
218
|
-
}
|
|
219
|
-
const local = join(dirname(CLI), "..", "setup", "herdr-notify"), cmd = existsSync(local) ? local : "herdr-notify";
|
|
220
|
-
if (background)
|
|
221
|
-
spawn(cmd, [pane, line], { detached: true, stdio: "ignore" }).on("error", () => { }).unref();
|
|
222
|
-
else
|
|
223
|
-
spawnSync(cmd, [pane, line], { stdio: "inherit" });
|
|
224
|
-
}
|
|
200
|
+
/** Tell the conductor's herdr pane (src/notice.ts): returns at once, delivery runs detached with retries and a log. */
|
|
201
|
+
export { handoffFile, notify };
|