@irythill/orq 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +240 -2
- package/README.pt-BR.md +242 -0
- package/dist/accounts.js +18 -0
- package/dist/agents.js +202 -0
- package/dist/banner.js +87 -0
- package/dist/cli.js +92 -0
- package/dist/commands/agent.js +334 -0
- package/dist/commands/baseline.js +101 -0
- package/dist/commands/block.js +74 -0
- package/dist/commands/board.js +435 -0
- package/dist/commands/claim.js +88 -0
- package/dist/commands/completion.js +88 -0
- package/dist/commands/config.js +35 -0
- package/dist/commands/deps.js +82 -0
- package/dist/commands/gc.js +164 -0
- package/dist/commands/instructions.js +66 -0
- package/dist/commands/lang.js +35 -0
- package/dist/commands/lock.js +69 -0
- package/dist/commands/pick.js +111 -0
- package/dist/commands/quota.js +40 -0
- package/dist/commands/run.js +46 -0
- package/dist/commands/set.js +27 -0
- package/dist/commands/setup.js +187 -0
- package/dist/commands/stats.js +41 -0
- package/dist/commands/verify.js +123 -0
- package/dist/commands/wait.js +81 -0
- package/dist/config.js +93 -0
- package/dist/fsutil.js +9 -0
- package/dist/i18n.js +21 -0
- package/dist/install.js +137 -0
- package/dist/keyring.js +51 -0
- package/dist/lock.js +81 -0
- package/dist/paths.js +34 -0
- package/dist/pick.js +126 -0
- package/dist/process.js +147 -0
- package/dist/quota.js +334 -0
- package/dist/roles.js +28 -0
- package/dist/stats.js +159 -0
- package/dist/suggest.js +121 -0
- package/dist/terminal.js +83 -0
- package/dist/testrun.js +203 -0
- package/dist/toml-set.js +46 -0
- package/package.json +46 -4
- package/skills/issue/SKILL.md +229 -0
- package/skills/orq-setup/SKILL.md +114 -0
- package/skills/orq-status/SKILL.md +37 -0
- package/templates/workspace.CLAUDE.md +49 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// orq deps — the worktree's node_modules is a LINK to the main repo's (never install in a worktree: ~1 GB and tens of
|
|
2
|
+
// thousands of files per copy). If the main repo's deps are behind origin/<base> and it is clean and on <base>:
|
|
3
|
+
// fast-forward + ONE install there. One install per repo at a time (orq lock); the second waits, then only links.
|
|
4
|
+
// Windows: a directory junction (no admin rights needed); elsewhere a symlink.
|
|
5
|
+
// orq deps -r <main repo> -b <base> -w <worktree> exit 0 = linked · 3 = main repo dirty or not on <base>: ASK THE USER
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { appendFileSync, existsSync, lstatSync, readFileSync, rmSync, symlinkSync } from "node:fs";
|
|
8
|
+
import { basename, join, resolve } from "node:path";
|
|
9
|
+
import { m } from "../i18n.js";
|
|
10
|
+
import { acquire, release } from "../lock.js";
|
|
11
|
+
export const USAGE = "orq deps -r <main repo> -b <base> -w <worktree> link node_modules into a worktree (exit 3 = ask the user)";
|
|
12
|
+
const git = (cwd, ...a) => spawnSync("git", ["-C", cwd, ...a], { encoding: "utf8" });
|
|
13
|
+
export function installer(repo) {
|
|
14
|
+
const has = (f) => existsSync(join(repo, f));
|
|
15
|
+
if (has("bun.lock") || has("bun.lockb"))
|
|
16
|
+
return ["bun", "install"];
|
|
17
|
+
if (has("pnpm-lock.yaml"))
|
|
18
|
+
return ["pnpm", "install"];
|
|
19
|
+
if (has("yarn.lock"))
|
|
20
|
+
return ["yarn", "install"];
|
|
21
|
+
return ["npm", "install"];
|
|
22
|
+
}
|
|
23
|
+
export function link(target, at) {
|
|
24
|
+
try {
|
|
25
|
+
if (lstatSync(at))
|
|
26
|
+
rmSync(at, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
catch { /* not there */ }
|
|
29
|
+
symlinkSync(target, at, process.platform === "win32" ? "junction" : "dir");
|
|
30
|
+
}
|
|
31
|
+
export async function deps(argv) {
|
|
32
|
+
let R = "", B = "", WT = "";
|
|
33
|
+
for (let i = 0; i < argv.length; i++) {
|
|
34
|
+
if (argv[i] === "-r")
|
|
35
|
+
R = resolve(argv[++i]);
|
|
36
|
+
else if (argv[i] === "-b")
|
|
37
|
+
B = argv[++i];
|
|
38
|
+
else if (argv[i] === "-w")
|
|
39
|
+
WT = resolve(argv[++i]);
|
|
40
|
+
else {
|
|
41
|
+
console.error(USAGE);
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!R || !B || !WT) {
|
|
46
|
+
console.error(USAGE);
|
|
47
|
+
return 2;
|
|
48
|
+
}
|
|
49
|
+
const gd = git(R, "rev-parse", "--path-format=absolute", "--git-common-dir").stdout.trim();
|
|
50
|
+
const ex = join(gd, "info", "exclude");
|
|
51
|
+
if (!(existsSync(ex) ? readFileSync(ex, "utf8") : "").split(/\r?\n/).includes("node_modules"))
|
|
52
|
+
appendFileSync(ex, "node_modules\n");
|
|
53
|
+
const name = `deps-${basename(R)}`;
|
|
54
|
+
await acquire(name, process.pid, `deps ${basename(R)}`, 1, () => console.log(m(`another conductor is installing dependencies in ${basename(R)}; waiting...`, `outro conductor está instalando dependências em ${basename(R)}; aguardando...`)));
|
|
55
|
+
try {
|
|
56
|
+
git(R, "fetch", "-q", "origin", B);
|
|
57
|
+
const locks = ["package.json", "bun.lock", "bun.lockb", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"]
|
|
58
|
+
.filter((f) => existsSync(join(R, f)));
|
|
59
|
+
const stale = git(R, "diff", "--quiet", "HEAD", `origin/${B}`, "--", ...locks).status !== 0;
|
|
60
|
+
if (stale || !existsSync(join(R, "node_modules"))) {
|
|
61
|
+
const br = git(R, "branch", "--show-current").stdout.trim();
|
|
62
|
+
if (br !== B || git(R, "status", "--porcelain", "--untracked-files=no").stdout.trim()) {
|
|
63
|
+
console.log(m(`deps are stale, but ${basename(R)} is on '${br}' or has changes: not touching it. Ask the user.`, `deps defasadas, mas ${basename(R)} está em '${br}' ou com alterações: não mexo. Pergunte ao usuário.`));
|
|
64
|
+
return 3;
|
|
65
|
+
}
|
|
66
|
+
git(R, "merge", "-q", "--ff-only", `origin/${B}`);
|
|
67
|
+
const cmd = installer(R);
|
|
68
|
+
console.log(`${m("installing dependencies in", "instalando dependências em")} ${basename(R)} (${cmd.join(" ")})...`);
|
|
69
|
+
const r = spawnSync(cmd[0], cmd.slice(1), { cwd: R, stdio: "ignore", shell: process.platform === "win32" });
|
|
70
|
+
if (r.status !== 0) {
|
|
71
|
+
console.error(`${cmd.join(" ")}: exit ${r.status}`);
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
link(join(R, "node_modules"), join(WT, "node_modules"));
|
|
76
|
+
console.log(`ok: ${join(WT, "node_modules")} -> ${join(R, "node_modules")}`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
release(name, process.pid);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// orq gc — find issue worktrees whose branch is already safe to remove.
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
4
|
+
import { basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { loadConfig } from "../config.js";
|
|
6
|
+
import { m } from "../i18n.js";
|
|
7
|
+
import { aliveOwner } from "./claim.js";
|
|
8
|
+
export const USAGE = "orq gc [-w <workspace>] [--yes] [--days N] [--json] remove merged issue worktrees";
|
|
9
|
+
const git = (cwd, ...args) => spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
|
|
10
|
+
// Paths as git prints them can differ from ours (macOS /var → /private/var, Windows C:/ vs C:\): compare real paths.
|
|
11
|
+
const real = (p) => { try {
|
|
12
|
+
return realpathSync.native(p);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return resolve(p);
|
|
16
|
+
} };
|
|
17
|
+
const inside = (dir, p) => { const r = relative(dir, p); return !!r && !r.startsWith("..") && !isAbsolute(r); };
|
|
18
|
+
// ORQ_GH may be a .js file (tests: a fake gh that also runs on Windows).
|
|
19
|
+
const gh = (cwd, args) => {
|
|
20
|
+
const bin = process.env.ORQ_GH || "gh";
|
|
21
|
+
return bin.endsWith(".js") ? spawnSync(process.execPath, [bin, ...args], { cwd, encoding: "utf8" })
|
|
22
|
+
: spawnSync(bin, args, { cwd, encoding: "utf8" });
|
|
23
|
+
};
|
|
24
|
+
const output = (cwd, ...args) => { const r = git(cwd, ...args); return r.status === 0 ? r.stdout.trim() : undefined; };
|
|
25
|
+
export async function gc(argv, clock = Date.now) {
|
|
26
|
+
let ws = process.cwd(), yes = false, json = false, days = 14;
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
if (argv[i] === "-w" && argv[i + 1])
|
|
29
|
+
ws = resolve(argv[++i]);
|
|
30
|
+
else if (argv[i] === "--yes")
|
|
31
|
+
yes = true;
|
|
32
|
+
else if (argv[i] === "--json")
|
|
33
|
+
json = true;
|
|
34
|
+
else if (argv[i] === "--days" && argv[i + 1] && /^\d+$/.test(argv[i + 1]))
|
|
35
|
+
days = Number(argv[++i]);
|
|
36
|
+
else {
|
|
37
|
+
console.error(USAGE);
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const { cfg } = loadConfig(ws), rows = [];
|
|
42
|
+
let ghUnavailable = false, fetchWarned = false;
|
|
43
|
+
const root = join(ws, ".worktrees");
|
|
44
|
+
const orq = join(ws, ".orquestra");
|
|
45
|
+
const issues = existsSync(orq) ? readdirSync(orq) : [];
|
|
46
|
+
for (const repoName of readdirSync(ws, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)) {
|
|
47
|
+
const repo = join(ws, repoName);
|
|
48
|
+
if (!existsSync(join(repo, ".git")))
|
|
49
|
+
continue;
|
|
50
|
+
const listings = output(repo, "worktree", "list", "--porcelain");
|
|
51
|
+
if (listings === undefined)
|
|
52
|
+
continue;
|
|
53
|
+
const worktrees = listings.split(/\n\n/).map((block) => Object.fromEntries(block.split("\n").map((line) => {
|
|
54
|
+
const i = line.indexOf(" ");
|
|
55
|
+
return [i < 0 ? line : line.slice(0, i), i < 0 ? "" : line.slice(i + 1)];
|
|
56
|
+
})));
|
|
57
|
+
const main = real(worktrees[0]?.worktree ?? repo);
|
|
58
|
+
const conf = cfg.repos?.[repoName];
|
|
59
|
+
const head = output(repo, "symbolic-ref", "refs/remotes/origin/HEAD")?.replace("refs/remotes/", "");
|
|
60
|
+
const prod = conf?.prod_ref || head || "origin/main";
|
|
61
|
+
const fetchRef = prod.replace(/^origin\//, "");
|
|
62
|
+
if (git(repo, "fetch", "origin", fetchRef).status !== 0 && !fetchWarned) {
|
|
63
|
+
console.error(m("WARNING: git fetch failed; using local refs", "AVISO: git fetch falhou; usando as refs locais"));
|
|
64
|
+
fetchWarned = true;
|
|
65
|
+
}
|
|
66
|
+
const prodTree = output(repo, "rev-parse", `${prod}^{tree}`);
|
|
67
|
+
for (const wt of worktrees) {
|
|
68
|
+
const path = real(wt.worktree ?? "");
|
|
69
|
+
if (!wt.worktree || path === main || !inside(real(root), path))
|
|
70
|
+
continue;
|
|
71
|
+
const branch = wt.branch?.replace(/^refs\/heads\//, "") ?? "";
|
|
72
|
+
if (!branch)
|
|
73
|
+
continue;
|
|
74
|
+
// The branch carries the ID in lowercase (plane-abc-123); .orquestra/ keeps it as written (ABC-123).
|
|
75
|
+
const id = /^.*?plane-(.+)$/.exec(branch)?.[1];
|
|
76
|
+
const issue = id && issues.find((d) => d.toLowerCase() === id.toLowerCase());
|
|
77
|
+
const status = output(path, "status", "--porcelain");
|
|
78
|
+
const unpushed = output(repo, "rev-list", branch, "--not", "--remotes");
|
|
79
|
+
let reason = "", verdict = "REMOVABLE";
|
|
80
|
+
if (status) {
|
|
81
|
+
verdict = "KEEP";
|
|
82
|
+
reason = m("uncommitted changes", "alterações não commitadas");
|
|
83
|
+
}
|
|
84
|
+
const ownerFile = issue && join(ws, ".orquestra", issue, "owner");
|
|
85
|
+
if (verdict !== "KEEP" && ownerFile && existsSync(ownerFile) && aliveOwner(readFileSync(ownerFile, "utf8"))) {
|
|
86
|
+
verdict = "KEEP";
|
|
87
|
+
reason = m("issue has a live owner", "issue tem um responsável ativo");
|
|
88
|
+
}
|
|
89
|
+
// A .pane/.id without a same-stem .done is treated as a running child.
|
|
90
|
+
const issueDir = issue && join(ws, ".orquestra", issue);
|
|
91
|
+
if (verdict !== "KEEP" && issueDir && existsSync(issueDir)) {
|
|
92
|
+
const files = readdirSync(issueDir), running = files.some((f) => /\.(pane|id)$/.test(f) && !existsSync(join(issueDir, `${f.replace(/\.(pane|id)$/, "")}.done`)));
|
|
93
|
+
if (running) {
|
|
94
|
+
verdict = "KEEP";
|
|
95
|
+
reason = m("child agent may still be running", "um agente filho pode estar em execução");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
let merged = false;
|
|
99
|
+
if (verdict !== "KEEP") {
|
|
100
|
+
// Merged counts only if the PR's head is the local head: a commit made after the merge must not be lost.
|
|
101
|
+
const pr = gh(repo, ["pr", "list", "--head", branch, "--state", "merged", "--json", "number,headRefOid", "--limit", "1"]);
|
|
102
|
+
const local = output(repo, "rev-parse", branch);
|
|
103
|
+
if (pr.status === 0) {
|
|
104
|
+
try {
|
|
105
|
+
const p = JSON.parse(pr.stdout)[0];
|
|
106
|
+
merged = !!p && (!p.headRefOid || p.headRefOid === local);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
ghUnavailable = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else
|
|
113
|
+
ghUnavailable = true;
|
|
114
|
+
}
|
|
115
|
+
const inBase = verdict !== "KEEP" && !!prodTree && output(repo, "merge-tree", "--write-tree", prod, branch) === prodTree;
|
|
116
|
+
if (verdict !== "KEEP" && unpushed && !merged && !inBase) {
|
|
117
|
+
verdict = "KEEP";
|
|
118
|
+
reason = m("commits not contained in remote refs", "commits fora das refs remotas");
|
|
119
|
+
}
|
|
120
|
+
else if (verdict !== "KEEP" && (merged || inBase)) {
|
|
121
|
+
verdict = "REMOVABLE";
|
|
122
|
+
reason = merged ? "PR merged" : m("content is in base", "conteúdo já está na base");
|
|
123
|
+
}
|
|
124
|
+
else if (verdict !== "KEEP") {
|
|
125
|
+
const closed = gh(repo, ["pr", "list", "--head", branch, "--state", "closed", "--json", "number", "--limit", "1"]);
|
|
126
|
+
const isClosed = closed?.status === 0 && (() => { try {
|
|
127
|
+
return JSON.parse(closed.stdout).length > 0;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
} })();
|
|
132
|
+
const commit = output(repo, "log", "-1", "--format=%ct", branch), old = commit !== undefined && clock() - Number(commit) * 1000 > days * 86400_000;
|
|
133
|
+
verdict = "STALE";
|
|
134
|
+
reason = isClosed ? m("PR closed without merge", "PR fechada sem merge") : old ? m(`last commit is older than ${days} days`, `último commit tem mais de ${days} dias`) : m("not merged", "não integrado");
|
|
135
|
+
}
|
|
136
|
+
rows.push({ repo: repoName, worktree: basename(path), branch, verdict, reason, path });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (ghUnavailable)
|
|
140
|
+
console.error(m("NOTE: gh unavailable or failed; PR signals were skipped", "NOTA: gh indisponível ou falhou; sinais de PR ignorados"));
|
|
141
|
+
let failed = false;
|
|
142
|
+
if (yes)
|
|
143
|
+
for (const row of rows.filter((r) => r.verdict === "REMOVABLE")) {
|
|
144
|
+
const removed = git(join(ws, row.repo), "worktree", "remove", row.path);
|
|
145
|
+
if (removed.status !== 0) {
|
|
146
|
+
failed = true;
|
|
147
|
+
row.reason += `; ${m("worktree removal failed", "falha ao remover worktree")}`;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (git(join(ws, row.repo), "branch", "-D", row.branch).status !== 0) {
|
|
151
|
+
failed = true;
|
|
152
|
+
row.reason += `; ${m("branch deletion failed", "falha ao excluir branch")}`;
|
|
153
|
+
}
|
|
154
|
+
git(join(ws, row.repo), "worktree", "prune");
|
|
155
|
+
}
|
|
156
|
+
if (json)
|
|
157
|
+
console.log(JSON.stringify(rows));
|
|
158
|
+
else {
|
|
159
|
+
const table = [["REPO", "WORKTREE", "BRANCH", "VERDICT", "REASON"], ...rows.map((r) => [r.repo, r.worktree, r.branch, r.verdict, r.reason])];
|
|
160
|
+
const w = table[0].map((_, i) => Math.max(...table.map((t) => t[i].length)));
|
|
161
|
+
console.log(table.map((t) => t.map((c, i) => i < t.length - 1 ? c.padEnd(w[i]) : c).join(" ")).join("\n"));
|
|
162
|
+
}
|
|
163
|
+
return failed ? 1 : 0;
|
|
164
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// orq instructions — the /issue playbook (skills/issue/SKILL.md, one source) in the conductor's own format:
|
|
2
|
+
// claude <workspace>/.claude/skills/issue/SKILL.md (Claude Code loads it as the /issue skill)
|
|
3
|
+
// agents <workspace>/AGENTS.md (Codex, OpenCode and others that read AGENTS.md)
|
|
4
|
+
// AGENTS.md may hold the user's own text: orq only replaces its block between the orq:issue markers.
|
|
5
|
+
// orq instructions claude|agents [-w <workspace>] [--print]
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { writeAtomic } from "../fsutil.js";
|
|
10
|
+
import { m } from "../i18n.js";
|
|
11
|
+
export const USAGE = "orq instructions claude|agents [-w <workspace>] [--print] write the /issue playbook for the conductor";
|
|
12
|
+
const SRC = fileURLToPath(new URL("../../skills/issue/SKILL.md", import.meta.url));
|
|
13
|
+
const BEGIN = "<!-- orq:issue begin (generated by `orq instructions agents`; edits inside are overwritten) -->";
|
|
14
|
+
const END = "<!-- orq:issue end -->";
|
|
15
|
+
/** The playbook for harnesses without skills: no frontmatter, a trigger line, and how to wait without background tasks. */
|
|
16
|
+
export function agentsBlock(skill) {
|
|
17
|
+
const body = skill.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim();
|
|
18
|
+
return `${BEGIN}
|
|
19
|
+
When the user says "/issue <ID>", "take issue X" or "run ABC-123", follow this playbook.
|
|
20
|
+
If your harness cannot run a command in the background, wait for children with \`orq wait\` in the foreground
|
|
21
|
+
(\`orq wait --any <prefix>...\`): it is the one allowed blocking wait.
|
|
22
|
+
|
|
23
|
+
${body}
|
|
24
|
+
${END}
|
|
25
|
+
`;
|
|
26
|
+
}
|
|
27
|
+
/** Put the block into an existing AGENTS.md text: replace ours, or append it. */
|
|
28
|
+
export function mergeBlock(cur, block) {
|
|
29
|
+
const a = cur.indexOf(BEGIN), b = cur.indexOf(END);
|
|
30
|
+
if (a >= 0 && b > a)
|
|
31
|
+
return cur.slice(0, a) + block.trimEnd() + cur.slice(b + END.length);
|
|
32
|
+
return cur ? `${cur.trimEnd()}\n\n${block}` : block;
|
|
33
|
+
}
|
|
34
|
+
export async function instructions(argv) {
|
|
35
|
+
let ws = process.cwd(), print = false, fmt;
|
|
36
|
+
for (let i = 0; i < argv.length; i++) {
|
|
37
|
+
if (argv[i] === "-w")
|
|
38
|
+
ws = resolve(argv[++i]);
|
|
39
|
+
else if (argv[i] === "--print")
|
|
40
|
+
print = true;
|
|
41
|
+
else if (argv[i] === "claude" || argv[i] === "agents")
|
|
42
|
+
fmt = argv[i];
|
|
43
|
+
else {
|
|
44
|
+
console.error(USAGE);
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!fmt) {
|
|
49
|
+
console.error(USAGE);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
if (!existsSync(SRC)) {
|
|
53
|
+
console.error(m(`playbook not found: ${SRC}`, `playbook não encontrado: ${SRC}`));
|
|
54
|
+
return 2;
|
|
55
|
+
}
|
|
56
|
+
const skill = readFileSync(SRC, "utf8");
|
|
57
|
+
const file = fmt === "claude" ? join(ws, ".claude", "skills", "issue", "SKILL.md") : join(ws, "AGENTS.md");
|
|
58
|
+
const text = fmt === "claude" ? skill : mergeBlock(existsSync(file) ? readFileSync(file, "utf8") : "", agentsBlock(skill));
|
|
59
|
+
if (print) {
|
|
60
|
+
process.stdout.write(text);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
writeAtomic(file, text);
|
|
64
|
+
console.log(m(`wrote ${file}`, `escrito ${file}`));
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// orq lang show the current language
|
|
2
|
+
// orq lang pt|en|auto set it (auto = system locale)
|
|
3
|
+
// orq lang toggle switch between en and pt
|
|
4
|
+
// --notify also show a herdr pop-up (used by the herdr key binding)
|
|
5
|
+
// Applies to whatever starts afterwards (board, notices, orq pick...); running agents keep their language.
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { getPath, loadConfig } from "../config.js";
|
|
8
|
+
import { detectLang } from "../i18n.js";
|
|
9
|
+
import { setValue } from "./set.js";
|
|
10
|
+
export const USAGE = "orq lang [pt|en|auto|toggle] [--notify]";
|
|
11
|
+
const cfgLang = () => getPath(loadConfig().cfg, "ui.lang");
|
|
12
|
+
export function lang(argv) {
|
|
13
|
+
let notify = false, arg = "";
|
|
14
|
+
for (const a of argv) {
|
|
15
|
+
if (a === "--notify")
|
|
16
|
+
notify = true;
|
|
17
|
+
else if (["pt", "en", "auto", "toggle"].includes(a))
|
|
18
|
+
arg = a;
|
|
19
|
+
else {
|
|
20
|
+
console.error(`usage: ${USAGE}`);
|
|
21
|
+
return 2;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const cur = detectLang(cfgLang(), false); // effective language, ignoring $ORQ_LANG
|
|
25
|
+
if (!arg) {
|
|
26
|
+
console.log(`${cur} (ui.lang = ${cfgLang() ?? "auto"})`);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
setValue("ui", "lang", arg === "toggle" ? (cur === "pt" ? "en" : "pt") : arg);
|
|
30
|
+
const msg = detectLang(cfgLang(), false) === "pt" ? "Idioma do orq: Português" : "orq language: English";
|
|
31
|
+
console.log(msg);
|
|
32
|
+
if (notify)
|
|
33
|
+
spawnSync("herdr", ["notification", "show", msg], { stdio: "ignore" });
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// orq lock — the portable test queue (src/lock.ts). Scripts hold a slot for their own pid:
|
|
2
|
+
// orq lock wait verify --pid $$ --label "verify ABC-1 api" blocks until it holds a slot (slots: machine.test_slots)
|
|
3
|
+
// orq lock release verify --pid $$ (also freed by itself when that process dies)
|
|
4
|
+
// orq lock run verify [--label t] -- <cmd> [args] runs a command holding a slot
|
|
5
|
+
// orq lock status [verify] [--json] holders and waiters, in order
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { getPath, loadConfig } from "../config.js";
|
|
8
|
+
import { m } from "../i18n.js";
|
|
9
|
+
import { acquire, release, status } from "../lock.js";
|
|
10
|
+
export const USAGE = `orq lock wait|release <name> [--pid P] [--label text] test queue (slots = machine.test_slots)
|
|
11
|
+
orq lock run <name> [--label text] -- <cmd>... · orq lock status [<name>] [--json]`;
|
|
12
|
+
const slots = () => Number(getPath(loadConfig().cfg, "machine.test_slots") ?? 1);
|
|
13
|
+
const since = (at) => `${Math.round((Date.now() - at) / 1000)}s`;
|
|
14
|
+
export async function lock(argv) {
|
|
15
|
+
const [sub, name = "verify", ...rest] = argv;
|
|
16
|
+
let pid = process.ppid, label = "", json = false;
|
|
17
|
+
const dd = rest.indexOf("--");
|
|
18
|
+
const opts = dd >= 0 ? rest.slice(0, dd) : rest, cmd = dd >= 0 ? rest.slice(dd + 1) : [];
|
|
19
|
+
for (let i = 0; i < opts.length; i++) {
|
|
20
|
+
if (opts[i] === "--pid")
|
|
21
|
+
pid = Number(opts[++i]);
|
|
22
|
+
else if (opts[i] === "--label")
|
|
23
|
+
label = opts[++i];
|
|
24
|
+
else if (opts[i] === "--json")
|
|
25
|
+
json = true;
|
|
26
|
+
else {
|
|
27
|
+
console.error(USAGE);
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const waitMsg = () => console.log(m("another verification is running; waiting for my turn...", "outra verificação em andamento; aguardando a vez..."));
|
|
32
|
+
switch (sub) {
|
|
33
|
+
case "wait":
|
|
34
|
+
await acquire(name, pid, label, slots(), waitMsg);
|
|
35
|
+
return 0;
|
|
36
|
+
case "release":
|
|
37
|
+
release(name, pid);
|
|
38
|
+
return 0;
|
|
39
|
+
case "run": {
|
|
40
|
+
if (!cmd.length) {
|
|
41
|
+
console.error(USAGE);
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
await acquire(name, process.pid, label || cmd.join(" "), slots(), waitMsg);
|
|
45
|
+
try {
|
|
46
|
+
return spawnSync(cmd[0], cmd.slice(1), { stdio: "inherit", shell: process.platform === "win32" }).status ?? 1;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
release(name, process.pid);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
case "status": {
|
|
53
|
+
const s = status(name);
|
|
54
|
+
if (json) {
|
|
55
|
+
console.log(JSON.stringify(s));
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
for (const e of s.holders)
|
|
59
|
+
console.log(`▶ ${e.pid} ${e.label} (${since(e.at)})`);
|
|
60
|
+
s.waiters.forEach((e, i) => console.log(`${i + 1}. ${e.pid} ${e.label} (${m("waiting", "aguardando")} ${since(e.at)})`));
|
|
61
|
+
if (!s.holders.length && !s.waiters.length)
|
|
62
|
+
console.log(m("queue empty", "fila vazia"));
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
default:
|
|
66
|
+
console.error(USAGE);
|
|
67
|
+
return 2;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// orq pick — agent, model, effort and account for a role, from [roles] (fixed or auto), the history, quota and blocks.
|
|
2
|
+
// Prints the runner arguments (e.g. `-a codex -m gpt-6-sol -e low -c work`); each pick is recorded in
|
|
3
|
+
// <state dir>/picks.jsonl (that feeds rotation). Exit 3 = no candidate: ask the user.
|
|
4
|
+
// `orq pick fix --same <file>`: the implementer's saved pick, effort one level up (no choice, nothing recorded).
|
|
5
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { loadConfig } from "../config.js";
|
|
9
|
+
import { m } from "../i18n.js";
|
|
10
|
+
import { asArgs, escalate, parseQuota, pick as choose } from "../pick.js";
|
|
11
|
+
import { expand, stateDir } from "../paths.js";
|
|
12
|
+
import { collect } from "../quota.js";
|
|
13
|
+
import { PICK_ROLES, vendorOf } from "../roles.js";
|
|
14
|
+
import { collectRuns, rows } from "../stats.js";
|
|
15
|
+
import { load as loadBlocks } from "./block.js";
|
|
16
|
+
export const USAGE = `orq pick <impl|fix|worker|review|opinion> [--level simple|medium|heavy] [--impl-vendor <v>|--impl-agent <a>]
|
|
17
|
+
[--exclude-agent <a>]... [-w <workspace>] [--explain] [--no-record]
|
|
18
|
+
orq pick fix --same <impl pick file> same agent/model/account as the implementer, effort one level up`;
|
|
19
|
+
// The board refreshes this file; fresher than 10 min → no new quota requests.
|
|
20
|
+
const QCACHE = join(process.env.XDG_RUNTIME_DIR ?? tmpdir(), "herdr-board.quota");
|
|
21
|
+
async function quotaText(cfg) {
|
|
22
|
+
if (existsSync(QCACHE) && Date.now() - statSync(QCACHE).mtimeMs < 600_000)
|
|
23
|
+
return readFileSync(QCACHE, "utf8");
|
|
24
|
+
return (await collect((cfg.quota ?? {}))).join("\n");
|
|
25
|
+
}
|
|
26
|
+
export async function pick(argv) {
|
|
27
|
+
const role = argv[0];
|
|
28
|
+
if (!PICK_ROLES.includes(role)) {
|
|
29
|
+
console.error(USAGE);
|
|
30
|
+
return 2;
|
|
31
|
+
}
|
|
32
|
+
if (role === "fix" && argv[1] === "--same") {
|
|
33
|
+
const f = argv[2];
|
|
34
|
+
if (!f || argv.length > 3) {
|
|
35
|
+
console.error(USAGE);
|
|
36
|
+
return 2;
|
|
37
|
+
}
|
|
38
|
+
if (!existsSync(f)) {
|
|
39
|
+
console.error(`orq pick: ${m("no such file: ", "arquivo não existe: ")}${f}`);
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
const prev = readFileSync(f, "utf8").trim();
|
|
43
|
+
if (!/(^|\s)-a \S/.test(prev)) {
|
|
44
|
+
console.error(`orq pick: ${m("not a pick: ", "não é um pick: ")}${f}`);
|
|
45
|
+
return 2;
|
|
46
|
+
}
|
|
47
|
+
console.log(escalate(prev));
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
let level = "medium", implVendor, implAgent, ws;
|
|
51
|
+
let explain = false, record = true;
|
|
52
|
+
const exclude = new Set();
|
|
53
|
+
for (let i = 1; i < argv.length; i++) {
|
|
54
|
+
const a = argv[i];
|
|
55
|
+
if (a === "--level")
|
|
56
|
+
level = argv[++i];
|
|
57
|
+
else if (a === "--impl-vendor")
|
|
58
|
+
implVendor = argv[++i];
|
|
59
|
+
else if (a === "--impl-agent")
|
|
60
|
+
implAgent = argv[++i];
|
|
61
|
+
else if (a === "--exclude-agent")
|
|
62
|
+
exclude.add(argv[++i]);
|
|
63
|
+
else if (a === "-w")
|
|
64
|
+
ws = expand(argv[++i]);
|
|
65
|
+
else if (a === "--explain")
|
|
66
|
+
explain = true;
|
|
67
|
+
else if (a === "--no-record")
|
|
68
|
+
record = false;
|
|
69
|
+
else {
|
|
70
|
+
console.error(USAGE);
|
|
71
|
+
return 2;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const { cfg } = loadConfig(ws);
|
|
75
|
+
const agents = (cfg.agents ?? {});
|
|
76
|
+
if (implAgent && !implVendor)
|
|
77
|
+
implVendor = vendorOf(implAgent, agents[implAgent]);
|
|
78
|
+
const w = (cfg.policy ?? {});
|
|
79
|
+
const stats = rows(collectRuns((ws ?? cfg._workspace ?? process.cwd()), Number(w.history_days ?? 30)));
|
|
80
|
+
const picksF = join(stateDir(), "picks.jsonl");
|
|
81
|
+
const window = Number(((w.weights ?? {}).window) ?? 6);
|
|
82
|
+
const recent = (existsSync(picksF) ? readFileSync(picksF, "utf8").split("\n").filter(Boolean).slice(-200) : [])
|
|
83
|
+
.map((l) => { try {
|
|
84
|
+
return JSON.parse(l);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return {};
|
|
88
|
+
} })
|
|
89
|
+
.filter((p) => p.role === role).slice(-window);
|
|
90
|
+
const res = choose({ cfg, role, level, implVendor, exclude, stats, quota: parseQuota(await quotaText(cfg)),
|
|
91
|
+
blocks: new Set(Object.keys(loadBlocks())), recent });
|
|
92
|
+
if (!res.best) {
|
|
93
|
+
console.error(`orq pick: ${m("no candidate for ", "nenhum candidato para ")}${role}`
|
|
94
|
+
+ (res.notes.length ? `; ${res.notes.join("; ")}` : ""));
|
|
95
|
+
return 3;
|
|
96
|
+
}
|
|
97
|
+
if (explain) {
|
|
98
|
+
for (const c of res.cands)
|
|
99
|
+
console.error(`${c === res.best ? "→" : " "} ${res.mode === "fixed" ? " fixed" : (c.score >= 0 ? "+" : "") + c.score.toFixed(3)} `
|
|
100
|
+
+ `${c.agent.padEnd(12)} ${c.model.padEnd(24)} ${c.why}`);
|
|
101
|
+
for (const n of res.notes)
|
|
102
|
+
console.error(` ${m("out", "fora")}: ${n}`);
|
|
103
|
+
}
|
|
104
|
+
if (record) {
|
|
105
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
106
|
+
appendFileSync(picksF, JSON.stringify({ t: Math.floor(Date.now() / 1000), role, level, agent: res.best.agent,
|
|
107
|
+
model: res.best.model }) + "\n");
|
|
108
|
+
}
|
|
109
|
+
console.log(asArgs(res.best));
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// orq quota [name...] quota of each source in [quota] (default: all) — lines "<name> 5h=NN% week=NN% [note]"
|
|
2
|
+
// orq quota --line one line for a status bar (herdr's tab_bar_right command entry): the board's cache, never
|
|
3
|
+
// the network — a stale cache starts the board's background collection, like the board does.
|
|
4
|
+
import { getPath, loadConfig } from "../config.js";
|
|
5
|
+
import { m } from "../i18n.js";
|
|
6
|
+
import { collect } from "../quota.js";
|
|
7
|
+
import { quota as cached } from "./board.js";
|
|
8
|
+
import { load as loadBlocks } from "./block.js";
|
|
9
|
+
export const USAGE = `orq quota [name...] quota per agent/account ([quota] in the config)
|
|
10
|
+
orq quota --line one line for a status bar, from the board's cache (e.g. herdr tab_bar_right)`;
|
|
11
|
+
const pct = (p) => (/^\d+%$/.test(p) ? Number(p.slice(0, -1)) : undefined);
|
|
12
|
+
/** "work 64 · home 93! · codex 81 · minimax ?": the tighter of the two windows per source; ! at or over the pick
|
|
13
|
+
* limit, ⊘ blocked (orq block), ? no data; sources with no window at all ("-" and "-") are left out. */
|
|
14
|
+
export function quotaLine(lines, limit, blocked, age) {
|
|
15
|
+
const parts = lines.filter((q) => q.five !== "-" || q.week !== "-").map((q) => {
|
|
16
|
+
if (blocked.has(q.name))
|
|
17
|
+
return `${q.name} ⊘`;
|
|
18
|
+
const ns = [pct(q.five), pct(q.week)].filter((n) => n !== undefined);
|
|
19
|
+
if (!ns.length)
|
|
20
|
+
return `${q.name} ?`;
|
|
21
|
+
const top = Math.max(...ns);
|
|
22
|
+
return `${q.name} ${top}${top >= limit ? "!" : ""}`;
|
|
23
|
+
});
|
|
24
|
+
if (!parts.length)
|
|
25
|
+
return age === undefined ? m("quota: collecting...", "cota: coletando...") : "";
|
|
26
|
+
const old = age !== undefined && age > 15 * 60_000 ? ` (${Math.round(age / 60_000)} min)` : "";
|
|
27
|
+
return parts.join(" · ") + old;
|
|
28
|
+
}
|
|
29
|
+
export async function quota(argv) {
|
|
30
|
+
const { cfg } = loadConfig();
|
|
31
|
+
if (argv[0] === "--line") {
|
|
32
|
+
const q = cached(Date.now());
|
|
33
|
+
console.log(quotaLine(q.lines, Number(getPath(cfg, "policy.quota_limit") ?? 90), new Set(Object.keys(loadBlocks())), q.age));
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
const sources = (getPath(cfg, "quota") ?? {});
|
|
37
|
+
for (const l of await collect(sources, argv))
|
|
38
|
+
console.log(l);
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// orq run — run a command under a memory cap on any OS (src/process.ts).
|
|
2
|
+
// orq run [--agent <name> | --test] [--mem 4G|off] [--swap 1G] [--mode auto|kernel|watchdog|off] [--unit name] -- <cmd>...
|
|
3
|
+
// Cap: --mem, else agents.<name>.mem_max (with --agent), else machine.test_mem_max (--test) or machine.agent_mem_max.
|
|
4
|
+
// Mode: --mode, else machine.mem_enforce. Exit code = the command's; 137 = killed for memory.
|
|
5
|
+
import { loadConfig } from "../config.js";
|
|
6
|
+
import { parseSize, resolveMode, runCapped } from "../process.js";
|
|
7
|
+
export const USAGE = "orq run [--agent <a> | --test] [--mem 4G|off] [--mode auto|kernel|watchdog|off] -- <cmd>... memory-capped run";
|
|
8
|
+
export async function run(argv) {
|
|
9
|
+
const dd = argv.indexOf("--");
|
|
10
|
+
if (dd < 0 || dd === argv.length - 1) {
|
|
11
|
+
console.error(USAGE);
|
|
12
|
+
return 2;
|
|
13
|
+
}
|
|
14
|
+
const opts = argv.slice(0, dd), cmd = argv.slice(dd + 1);
|
|
15
|
+
let agent, test = false, mem, swap;
|
|
16
|
+
let mode, unit, explain = false;
|
|
17
|
+
for (let i = 0; i < opts.length; i++) {
|
|
18
|
+
const a = opts[i];
|
|
19
|
+
if (a === "--agent")
|
|
20
|
+
agent = opts[++i];
|
|
21
|
+
else if (a === "--test")
|
|
22
|
+
test = true;
|
|
23
|
+
else if (a === "--mem")
|
|
24
|
+
mem = opts[++i];
|
|
25
|
+
else if (a === "--swap")
|
|
26
|
+
swap = opts[++i];
|
|
27
|
+
else if (a === "--mode")
|
|
28
|
+
mode = opts[++i];
|
|
29
|
+
else if (a === "--unit")
|
|
30
|
+
unit = opts[++i];
|
|
31
|
+
else if (a === "--explain")
|
|
32
|
+
explain = true;
|
|
33
|
+
else {
|
|
34
|
+
console.error(USAGE);
|
|
35
|
+
return 2;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const { cfg } = loadConfig();
|
|
39
|
+
const mach = (cfg.machine ?? {});
|
|
40
|
+
const agentCap = agent ? (cfg.agents ?? {})[agent]?.mem_max : undefined;
|
|
41
|
+
const cap = parseSize(mem ?? agentCap ?? String((test ? mach.test_mem_max : mach.agent_mem_max) ?? "off"));
|
|
42
|
+
const m = mode ?? mach.mem_enforce ?? "auto";
|
|
43
|
+
if (explain)
|
|
44
|
+
console.error(`orq run: cap=${cap ?? "off"} mode=${resolveMode(m, cap)}`);
|
|
45
|
+
return runCapped(cmd[0], cmd.slice(1), { cap, mode: m, unit, swap: parseSize(swap ?? mach.agent_swap_max) });
|
|
46
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// orq set <section> <key> <value> — writes one string value into your orq config, atomically.
|
|
2
|
+
// TOML: replaces `key = ...` inside [section], adds the key or the section if missing; everything else is kept.
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { writeAtomic } from "../fsutil.js";
|
|
5
|
+
import { userConfigFile } from "../paths.js";
|
|
6
|
+
import { setTomlString } from "../toml-set.js";
|
|
7
|
+
export const USAGE = "orq set <section> <key> <value> e.g. orq set ui theme lcd";
|
|
8
|
+
export function setValue(section, key, value) {
|
|
9
|
+
const f = userConfigFile();
|
|
10
|
+
const text = existsSync(f) ? readFileSync(f, "utf8") : "";
|
|
11
|
+
if (f.endsWith(".json")) {
|
|
12
|
+
const j = text.trim() ? JSON.parse(text) : {};
|
|
13
|
+
j[section] = { ...(j[section] ?? {}), [key]: value };
|
|
14
|
+
writeAtomic(f, JSON.stringify(j, null, 2) + "\n");
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
writeAtomic(f, setTomlString(text, section, key, value));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function set(argv) {
|
|
21
|
+
if (argv.length !== 3) {
|
|
22
|
+
console.error(USAGE);
|
|
23
|
+
return 2;
|
|
24
|
+
}
|
|
25
|
+
setValue(argv[0], argv[1], argv[2]);
|
|
26
|
+
return 0;
|
|
27
|
+
}
|