@irythill/orq 0.3.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 +55 -11
- package/README.pt-BR.md +47 -11
- package/dist/agents.js +10 -0
- package/dist/ci.js +241 -0
- package/dist/cli.js +9 -1
- package/dist/commands/agent.js +86 -13
- package/dist/commands/block.js +46 -6
- package/dist/commands/board.js +109 -18
- package/dist/commands/ci.js +48 -0
- package/dist/commands/claim.js +148 -12
- package/dist/commands/completion.js +4 -3
- package/dist/commands/gc.js +1 -1
- package/dist/commands/handoff.js +259 -0
- package/dist/commands/pick.js +19 -4
- package/dist/commands/quota.js +34 -9
- package/dist/commands/rounds.js +132 -0
- 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 +5 -2
- package/dist/diagnose.js +105 -0
- package/dist/identity.js +55 -0
- package/dist/notice.js +282 -0
- package/dist/pick.js +137 -11
- package/dist/quota.js +83 -21
- package/dist/roles.js +11 -2
- package/dist/rounds.js +176 -0
- package/dist/stats.js +4 -3
- package/dist/testrun.js +4 -10
- package/package.json +1 -1
- package/skills/issue/SKILL.md +48 -6
- package/skills/orq-setup/SKILL.md +1 -1
- package/templates/workspace.CLAUDE.md +8 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// orq rounds <ID> [-w <workspace>] [--json] per-issue round metrics from .orquestra/<ID>/ (src/rounds.ts), no LLM
|
|
2
|
+
// orq rounds <ID> --diagnose [-w <workspace>] warn/stop only: a cheap read only "watchdog" agent (another vendor than the
|
|
3
|
+
// implementer when known) diagnoses why the rounds don't converge → .orquestra/<ID>/diagnosis-<rounds>.md (advisory)
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { copyFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { getPath, loadConfig } from "../config.js";
|
|
10
|
+
import { buildPrompt, evidence, latestDiagnosis, recommendation } from "../diagnose.js";
|
|
11
|
+
import { m } from "../i18n.js";
|
|
12
|
+
import { expand } from "../paths.js";
|
|
13
|
+
import { collectRounds, mark } from "../rounds.js";
|
|
14
|
+
import { alive } from "../lock.js";
|
|
15
|
+
import { wait } from "./wait.js";
|
|
16
|
+
export const USAGE = `orq rounds <ID> [-w <workspace>] [--json] rounds vs max_rounds, time per round, repeated findings, alert level
|
|
17
|
+
orq rounds <ID> --diagnose [-w <workspace>] on warn/stop: an independent cheap agent diagnoses why rounds don't converge (advisory; needed for orq agent -R -F)`;
|
|
18
|
+
export const WATCHDOG_TIMEOUT = 900; // seconds `orq rounds --diagnose` waits for the agent
|
|
19
|
+
const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
20
|
+
const dur = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h${String(Math.floor(s / 60) % 60).padStart(2, "0")}m` : s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s` : `${s}s`);
|
|
21
|
+
const why = (r) => ({
|
|
22
|
+
over_max: m(`rounds ${r.n} > max_rounds ${r.max}`, `rodadas ${r.n} > max_rounds ${r.max}`),
|
|
23
|
+
at_max: m(`rounds ${r.n} = max_rounds ${r.max}`, `rodadas ${r.n} = max_rounds ${r.max}`),
|
|
24
|
+
same_finding: m(`same finding in ${r.n} reviews`, `mesmo achado em ${r.n} revisões`),
|
|
25
|
+
repeated: m(`${r.n} repeated finding(s)`, `${r.n} achado(s) repetido(s)`),
|
|
26
|
+
slow: m(`round ${r.rounds?.join(", ")} much slower than the others`, `rodada ${r.rounds?.join(", ")} bem mais lenta que as outras`),
|
|
27
|
+
})[r.code];
|
|
28
|
+
export async function rounds(argv) {
|
|
29
|
+
let ws, id, json = false, diagnose = false;
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
if (argv[i] === "-w") {
|
|
32
|
+
if (!argv[i + 1]) {
|
|
33
|
+
console.error(USAGE);
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
ws = expand(argv[++i]);
|
|
37
|
+
}
|
|
38
|
+
else if (argv[i] === "--json")
|
|
39
|
+
json = true;
|
|
40
|
+
else if (argv[i] === "--diagnose")
|
|
41
|
+
diagnose = true;
|
|
42
|
+
else if (!id && !argv[i].startsWith("-"))
|
|
43
|
+
id = argv[i];
|
|
44
|
+
else {
|
|
45
|
+
console.error(USAGE);
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!id) {
|
|
50
|
+
console.error(USAGE);
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
ws ??= getPath(loadConfig().cfg, "_workspace") ?? process.cwd();
|
|
54
|
+
const { cfg } = loadConfig(ws); // the workspace's own policy.max_rounds
|
|
55
|
+
const x = collectRounds(ws, id, Number(getPath(cfg, "policy.max_rounds") ?? 2));
|
|
56
|
+
if (!x) {
|
|
57
|
+
console.error(m(`no rounds recorded for ${id} in ${ws}/.orquestra`, `nenhuma rodada registrada para ${id} em ${ws}/.orquestra`));
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
if (diagnose)
|
|
61
|
+
return diagnoseIssue(ws, id, x);
|
|
62
|
+
if (json) {
|
|
63
|
+
console.log(JSON.stringify(x, null, 2));
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
console.log(`${id} ${mark(x)} ${m("alert", "alerta")}: ${x.level}${x.reasons.length ? ` (${x.reasons.map(why).join("; ")})` : ""}`);
|
|
67
|
+
console.log(m(`rounds ${x.rounds} of max ${x.max}; total time ${dur(x.secs)}; ${x.findings} finding(s) across reviews`, `rodadas ${x.rounds} de no máx. ${x.max}; tempo total ${dur(x.secs)}; ${x.findings} achado(s) nas revisões`));
|
|
68
|
+
for (const r of x.perRound)
|
|
69
|
+
console.log(` ${r.round === 0 ? m("impl + review", "impl + revisão") : m(`fix ${r.round} + review`, `fix ${r.round} + revisão`)}: ${dur(r.secs)}`);
|
|
70
|
+
if (x.repeats.length)
|
|
71
|
+
console.log(m("repeated findings:", "achados repetidos:"));
|
|
72
|
+
for (const r of x.repeats)
|
|
73
|
+
console.log(` [${m("rounds", "rodadas")} ${r.rounds.join(",")}] ${r.title.slice(0, 100)}`);
|
|
74
|
+
const d = latestDiagnosis(join(ws, ".orquestra", id));
|
|
75
|
+
if (d)
|
|
76
|
+
console.log(`${m("diagnosis", "diagnóstico")} ${d.n}: ${d.line ?? m("(no RECOMMENDATION line)", "(sem linha RECOMMENDATION)")}`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
const orq = (...a) => spawnSync(process.execPath, [CLI, ...a], { encoding: "utf8" });
|
|
80
|
+
/** --diagnose: pick a watchdog, run it read only on the evidence, keep its answer as diagnosis-<rounds>.md. */
|
|
81
|
+
async function diagnoseIssue(ws, id, x) {
|
|
82
|
+
if (x.level === "ok") {
|
|
83
|
+
console.log(m(`${id}: level is ok, no diagnosis needed`, `${id}: nível ok, não precisa de diagnóstico`));
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
const dir = join(ws, ".orquestra", id), prefix = join(dir, `watchdog-${x.rounds}-${Date.now()}`), out = join(dir, `diagnosis-${x.rounds}.md`);
|
|
87
|
+
// The implementer, when known (impl-<repo>.pick saved by the playbook): the watchdog must be another vendor.
|
|
88
|
+
const implPick = readdirSync(dir).filter((n) => /^impl-.+\.pick$/.test(n)).map((n) => /(?:^|\s)-a (\S+)/.exec(readFileSync(join(dir, n), "utf8"))?.[1]).find(Boolean);
|
|
89
|
+
// A watchdog still running for this issue (a previous --diagnose that timed out or was interrupted): don't start another.
|
|
90
|
+
for (const n of readdirSync(dir).filter((f) => /^watchdog-.+\.pid$/.test(f))) {
|
|
91
|
+
const pid = Number(readFileSync(join(dir, n), "utf8"));
|
|
92
|
+
if (pid && alive(pid)) {
|
|
93
|
+
console.error(m(`a watchdog for ${id} is still running (${n}, pid ${pid}); wait for it or stop it first`, `um watchdog de ${id} ainda está rodando (${n}, pid ${pid}); espere ou encerre antes`));
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const ev = evidence(dir);
|
|
98
|
+
writeFileSync(`${prefix}.md`, buildPrompt(x, ev.fixes, ev.reviews));
|
|
99
|
+
const p = orq("pick", "watchdog", "--level", "simple", "-w", ws, ...(implPick ? ["--impl-agent", implPick] : []));
|
|
100
|
+
if (p.status !== 0) {
|
|
101
|
+
console.error(p.stderr.trim() || `orq pick watchdog: exit ${p.status}`);
|
|
102
|
+
return p.status ?? 1;
|
|
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-"));
|
|
107
|
+
try {
|
|
108
|
+
const a = orq("agent", ...picked, "-r", "-t", `${id} watchdog`, "-d", scratch, "-g", "watchdog", "-o", prefix, "--", `${prefix}.md`);
|
|
109
|
+
process.stderr.write(a.stderr);
|
|
110
|
+
process.stdout.write(a.stdout);
|
|
111
|
+
if (a.status !== 0)
|
|
112
|
+
return a.status ?? 1;
|
|
113
|
+
return await finish(await wait(["--timeout", String(WATCHDOG_TIMEOUT), prefix]), prefix, out);
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function finish(rc, prefix, out) {
|
|
120
|
+
if (rc === 124) {
|
|
121
|
+
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
|
+
return 124;
|
|
123
|
+
}
|
|
124
|
+
if (rc !== 0 || !existsSync(`${prefix}.out.md`)) {
|
|
125
|
+
console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
copyFileSync(`${prefix}.out.md`, out);
|
|
129
|
+
console.log(m(`diagnosis saved to ${out}`, `diagnóstico gravado em ${out}`));
|
|
130
|
+
console.log(recommendation(readFileSync(out, "utf8")) ?? m("(no RECOMMENDATION line in the answer)", "(sem linha RECOMMENDATION na resposta)"));
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
package/dist/commands/stats.js
CHANGED
|
@@ -5,7 +5,7 @@ import { expand } from "../paths.js";
|
|
|
5
5
|
import { collectRuns, rows } from "../stats.js";
|
|
6
6
|
export const USAGE = "orq stats [-w <workspace>] [--days N] [--json] scoreboard: delivery, time and tokens per agent";
|
|
7
7
|
const k = (n) => (n === null ? "-" : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
|
|
8
|
-
const ORDER = ["impl", "fix", "review", "other"];
|
|
8
|
+
const ORDER = ["impl", "fix", "review", "watchdog", "other"];
|
|
9
9
|
export function stats(argv) {
|
|
10
10
|
let ws, days = 30, json = false;
|
|
11
11
|
for (let i = 0; i < argv.length; i++) {
|
package/dist/commands/verify.js
CHANGED
|
@@ -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.
|
package/dist/commands/wait.js
CHANGED
|
@@ -6,6 +6,7 @@ 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";
|
|
9
10
|
export const USAGE = "orq wait [--any] [--timeout <s>] [--answer] <prefix>... block until child agents finish (exit 1 = one failed)";
|
|
10
11
|
const read = (f) => { try {
|
|
11
12
|
return readFileSync(f, "utf8");
|
|
@@ -61,6 +62,7 @@ export async function wait(argv) {
|
|
|
61
62
|
continue;
|
|
62
63
|
pending.splice(pending.indexOf(p), 1);
|
|
63
64
|
console.log(d.line);
|
|
65
|
+
ackPrefix(p); // the conductor has the result in hand: this issue's failure notice is acknowledged
|
|
64
66
|
if (answer) {
|
|
65
67
|
const t = read(`${p}.out.md`);
|
|
66
68
|
if (t)
|
package/dist/config.js
CHANGED
|
@@ -20,11 +20,14 @@ export const DEFAULTS = {
|
|
|
20
20
|
},
|
|
21
21
|
agents: {}, // only agents declared here are available; run templates: built in for 5 (src/agents.ts), or `run = ...`
|
|
22
22
|
policy: {
|
|
23
|
-
quota_limit: 90, //
|
|
23
|
+
quota_limit: 90, // also caps the 5h pick limit; marks ! in `orq quota --line`
|
|
24
|
+
pacing_penalty: 0.3, // score penalty when usage at the current pace would pass the pick limit by the window's reset (0 = off)
|
|
25
|
+
quota_week: 95, // weekly % from which an agent/account is skipped
|
|
26
|
+
quota_5h: { simple: 90, medium: 85, heavy: 80 }, // same for the 5h window, per task level
|
|
24
27
|
max_rounds: 2, // fix rounds before stopping and calling the user
|
|
25
28
|
review: "other-vendor", // other-vendor | any
|
|
26
29
|
},
|
|
27
|
-
ui: { lang: "auto", theme: "gunmetal" }, // lang: auto | en | pt · theme: gunmetal | chrome | lcd | terminal
|
|
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)
|
|
28
31
|
// Where quota comes from, per agent: "builtin" | "none" | false | { command = "..." } (see src/quota.ts).
|
|
29
32
|
// "claude" covers every account in claude-accounts.
|
|
30
33
|
quota: { claude: "builtin", codex: "builtin", opencode: "builtin", antigravity: "builtin", copilot: "builtin" },
|
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/identity.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Who is logged in behind an agent or account name — only to tell that the login CHANGED. `orq block` records it, and a
|
|
2
|
+
// block made for one login is dropped once another is there (a new account has its own quota). Ids are short hashes of
|
|
3
|
+
// something stable per login (never the secret itself); undefined = cannot tell, so the block stays until its date.
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join, resolve } from "node:path";
|
|
9
|
+
import { claudeAccounts } from "./accounts.js";
|
|
10
|
+
import { agyToken, minimaxKey } from "./quota.js";
|
|
11
|
+
const hash = (s) => createHash("sha256").update(s).digest("hex").slice(0, 16);
|
|
12
|
+
const json = (f) => { try {
|
|
13
|
+
return JSON.parse(readFileSync(f, "utf8"));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
} };
|
|
18
|
+
/** Claude Code keeps the logged-in account in .claude.json: inside the config dir, or in ~ for the default ~/.claude. */
|
|
19
|
+
function claudeId(dir) {
|
|
20
|
+
const files = [join(dir, ".claude.json"), ...(resolve(dir) === join(homedir(), ".claude") ? [join(homedir(), ".claude.json")] : [])];
|
|
21
|
+
for (const f of files) {
|
|
22
|
+
const id = json(f)?.oauthAccount?.accountUuid;
|
|
23
|
+
if (id)
|
|
24
|
+
return String(id);
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
function copilotId() {
|
|
29
|
+
const r = spawnSync("gh", ["api", "/user", "-q", ".id"], { encoding: "utf8", timeout: 15_000 });
|
|
30
|
+
return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** The login id for a block name (an agent, or a Claude account), or undefined when it cannot be told. */
|
|
33
|
+
export function loginId(name) {
|
|
34
|
+
const accs = claudeAccounts();
|
|
35
|
+
const acc = accs.find((a) => a.name === name);
|
|
36
|
+
if (acc)
|
|
37
|
+
return claudeId(acc.dir);
|
|
38
|
+
if (name === "claude") { // the whole vendor: any account changing counts
|
|
39
|
+
const ids = accs.map((a) => claudeId(a.dir));
|
|
40
|
+
return ids.every(Boolean) ? hash(ids.sort().join(",")) : undefined;
|
|
41
|
+
}
|
|
42
|
+
if (name === "codex") {
|
|
43
|
+
const t = json(join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "auth.json"))?.tokens;
|
|
44
|
+
return t?.account_id ?? undefined;
|
|
45
|
+
}
|
|
46
|
+
if (name === "antigravity" || name === "agy")
|
|
47
|
+
return agyToken()?.acct;
|
|
48
|
+
if (name === "copilot")
|
|
49
|
+
return copilotId();
|
|
50
|
+
if (name === "opencode" || name === "minimax") {
|
|
51
|
+
const k = minimaxKey();
|
|
52
|
+
return k ? hash(k) : undefined;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
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
|
+
}
|