@irythill/orq 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -162,6 +162,9 @@ handoff_at = 85 # % of the window → orq handoff moves
162
162
  max_per_account = 2 # an account already holding this many conductors is never a handoff target
163
163
  reset_grace_min = 15 # every window over the trigger resetting within this many minutes skips the move
164
164
 
165
+ [git]
166
+ issue_pattern = "(?<![a-z0-9])(?<issue>[a-z][a-z0-9]*-\\d+)(?![a-z0-9])" # Branch/worktree name → issue ID, for `orq gc` and `orq ci` — default shown; regex, named group `issue` (else group 1)
167
+
165
168
  [policy]
166
169
  quota_week = 95 # ≥ 95% of the week → agent out
167
170
  quota_5h = { simple = 90, medium = 85, heavy = 80 } # same for the 5h window, by task level
package/README.pt-BR.md CHANGED
@@ -156,6 +156,9 @@ handoff_at = 85 # % da janela → o orq handoff move o
156
156
  max_per_account = 2 # conta que já tem esse tanto de conductors nunca é alvo do handoff
157
157
  reset_grace_min = 15 # toda janela acima do gatilho renova dentro desses minutos → pula a mudança
158
158
 
159
+ [git]
160
+ issue_pattern = "(?<![a-z0-9])(?<issue>[a-z][a-z0-9]*-\\d+)(?![a-z0-9])" # Nome de branch/worktree → ID da issue, para `orq gc` e `orq ci` — padrão mostrado; regex, grupo nomeado `issue` (senão o grupo 1)
161
+
159
162
  [policy]
160
163
  quota_limit = 90 # caps the 5h pick limit; also sets ! in `orq quota --line`
161
164
  pacing_penalty = 0.3 # score penalty when usage at the current pace would pass the pick limit (5h by level, week: quota_week) by the reset (0 = off)
package/dist/agents.js CHANGED
@@ -154,7 +154,14 @@ export function expandTemplate(t, vars) {
154
154
  if (g >= 0)
155
155
  while (j < words.length && words[j].group === g)
156
156
  j++;
157
- out.push(...expandWords(words.slice(i, j).map((w) => w.text), vars, g >= 0));
157
+ const got = expandWords(words.slice(i, j).map((w) => w.text), vars, g >= 0);
158
+ // `--flag {placeholder}` with the placeholder empty: drop the flag too, or it would swallow the next argument.
159
+ const ph = g < 0 ? /^\{(\w+)(?::[^}]*)?\}$/.exec(words[i].text) : null, prev = words[i - 1];
160
+ if (!got.length && ph && !Array.isArray(vars[ph[1]]) && prev && prev.group < 0 && /^-[^\s{]/.test(prev.text)
161
+ && out[out.length - 1] === prev.text)
162
+ out.pop();
163
+ else
164
+ out.push(...got);
158
165
  i = j;
159
166
  }
160
167
  return out;
@@ -255,11 +262,34 @@ export const FORMATS = {
255
262
  }
256
263
  if (errs.length)
257
264
  answer += (answer ? "\n" : "") + errs.join("\n") + "\n";
258
- return { session: lastOf(res.map((r) => r.conversation_id).filter(Boolean)), answer, rc: errs.length || wasDenied ? 3 : undefined,
265
+ const exhausted = agyExhausted(log, res);
266
+ return { exhausted, session: lastOf(res.map((r) => r.conversation_id).filter(Boolean)), answer, rc: errs.length || wasDenied ? 3 : undefined,
259
267
  denied: wasDenied ? (denied.length ? denied : ["(command)"]) : undefined };
260
268
  },
261
269
  },
262
270
  };
271
+ /** "Resets in 123h12m32s" → seconds (undefined without such a duration). */
272
+ export function parseResetIn(text) {
273
+ const r = /Resets in\s+(?:(\d+)h)?\s*(?:(\d+)m)?\s*(?:(\d+)s)?/i.exec(text);
274
+ if (!r || !(r[1] || r[2] || r[3]))
275
+ return undefined;
276
+ return Number(r[1] ?? 0) * 3600 + Number(r[2] ?? 0) * 60 + Number(r[3] ?? 0);
277
+ }
278
+ /** agy's quota failure (RESOURCE_EXHAUSTED / "quota reached"), from the result's error or the AGY_ERROR line. */
279
+ function agyExhausted(log, res) {
280
+ const msgs = res.filter((r) => r.status === "ERROR" || r.error).map((r) => String(r.error ?? ""));
281
+ for (const l of log.split(/\r?\n/)) {
282
+ if (!l.startsWith("AGY_ERROR:"))
283
+ continue;
284
+ const j = json(l.slice(10).trim());
285
+ if (j)
286
+ msgs.push(`${j.status ?? ""} ${j.short_error ?? ""}`);
287
+ }
288
+ const text = msgs.find((t) => /RESOURCE_EXHAUSTED|quota reached/i.test(t));
289
+ if (!text)
290
+ return undefined;
291
+ return { secs: parseResetIn(text), message: text.replace(/^.*?(?:\(code 429\):\s*)/, "").trim() };
292
+ }
263
293
  /** The model in an agent's argv (-m / --model / --model=x), without the provider prefix. */
264
294
  export function argvModel(a) {
265
295
  const i = a.findIndex((x) => x === "-m" || x === "--model");
package/dist/ci.js CHANGED
@@ -15,6 +15,7 @@ import { join } from "node:path";
15
15
  import { aliveOwner } from "./commands/claim.js";
16
16
  import { writeAtomic } from "./fsutil.js";
17
17
  import { m } from "./i18n.js";
18
+ import { issueFromName, issuePattern } from "./issue.js";
18
19
  import { alive } from "./lock.js";
19
20
  import { stateDir } from "./paths.js";
20
21
  import { notify } from "./testrun.js";
@@ -93,11 +94,10 @@ export function listWorktrees(ws) {
93
94
  return out;
94
95
  }
95
96
  /** The repo + branch an issue's worktree carries, the same match `orq gc` uses: a worktree branch ending in
96
- * "plane-<id, lowercase>" under <ws>/.worktrees. An issue can have one per repo (several repos in play).
97
+ * the issue ID (git.issue_pattern; any "plane-" prefix or suffix is ignored) under <ws>/.worktrees. An issue can have one per repo (several repos in play).
97
98
  * `all` lets a caller looping over several issues pass its own listWorktrees(ws) once instead of one per issue. */
98
- export function issueBranches(ws, issue, all = listWorktrees(ws)) {
99
- const suffix = `plane-${issue.toLowerCase()}`;
100
- return all.filter((b) => b.branch.toLowerCase().endsWith(suffix));
99
+ export function issueBranches(ws, issue, all = listWorktrees(ws), pattern) {
100
+ return all.filter((b) => issueFromName(b.branch, pattern)?.toLowerCase() === issue.toLowerCase());
101
101
  }
102
102
  // GitHub's statusCheckRollup mixes CheckRun (status/conclusion, the GitHub Actions / Checks API shape) with the
103
103
  // legacy commit-status shape (StatusContext: just `state`, no status/conclusion at all) for repos whose CI still
@@ -204,8 +204,8 @@ function notifyDesktop(issue, repo, state) {
204
204
  export function collectCi(ws, hooks = {}) {
205
205
  // Detached: collectCi() must never block on a conductor stuck in a dialog (herdr-notify waits up to 30 min).
206
206
  const desktop = hooks.desktop ?? notifyDesktop, toOwner = hooks.owner ?? ((pane, line) => notify(pane, line)), notice = hooks.notice ?? ((l) => console.log(l));
207
- const now = Date.now(), worktrees = listWorktrees(ws);
208
- const found = claimedIssues(ws).flatMap(({ id, owner, kind }) => issueBranches(ws, id, worktrees).map(({ repo, branch }) => ({ id, owner, kind, repo, branch, ...prStatus(join(ws, repo), branch) })));
207
+ const now = Date.now(), worktrees = listWorktrees(ws), pattern = issuePattern(ws);
208
+ const found = claimedIssues(ws).flatMap(({ id, owner, kind }) => issueBranches(ws, id, worktrees, pattern).map(({ repo, branch }) => ({ id, owner, kind, repo, branch, ...prStatus(join(ws, repo), branch) })));
209
209
  const toNotify = [];
210
210
  const mine = withCacheLock(() => {
211
211
  const cache = loadCache(), mine = [];
@@ -20,6 +20,7 @@ import { getPath, loadConfig } from "../config.js";
20
20
  import { writeAtomic } from "../fsutil.js";
21
21
  import { m } from "../i18n.js";
22
22
  import { alive } from "../lock.js";
23
+ import { agyToken, humanReset, learnExhausted } from "../quota.js";
23
24
  import { stateDir } from "../paths.js";
24
25
  import { killedRc, killTree, parseSize, resolveMode, runCapped } from "../process.js";
25
26
  import { closeOwnTab, detectHost, launch, screenOpen, shq, typeInto } from "../terminal.js";
@@ -342,6 +343,12 @@ function turn(file, kind, rest) {
342
343
  return 0;
343
344
  answer = String(e["last-assistant-message"] ?? "");
344
345
  sid = e["thread-id"];
346
+ // Codex also runs internal turns (e.g. generating the tab title) and notifies for them: only the main thread counts.
347
+ const known = existsSync(`${O}.id`) ? readFileSync(`${O}.id`, "utf8").trim() : "";
348
+ if (known && sid && sid !== known)
349
+ return 0;
350
+ if (isTitleOnly(answer))
351
+ return 0;
345
352
  }
346
353
  else if (kind === "claude") {
347
354
  const e = JSON.parse(readFileSync(0, "utf8"));
@@ -376,6 +383,16 @@ function turn(file, kind, rest) {
376
383
  }
377
384
  return 0;
378
385
  }
386
+ /** The `{"title": "..."}` answer of Codex's session-title turn (a JSON object whose only key is `title`). */
387
+ function isTitleOnly(answer) {
388
+ try {
389
+ const o = JSON.parse(answer);
390
+ return typeof o === "object" && o !== null && !Array.isArray(o) && Object.keys(o).length === 1 && "title" in o;
391
+ }
392
+ catch {
393
+ return false;
394
+ }
395
+ }
379
396
  /** The last text Claude wrote, from its transcript (older Stop hooks have no last_assistant_message). */
380
397
  function lastClaudeText(transcript) {
381
398
  let text = "";
@@ -647,6 +664,19 @@ async function runJob(file) {
647
664
  else
648
665
  res.answer = `${res.answer ?? ""}\nNo changes were made in the worktree.\n`;
649
666
  }
667
+ // Out of quota: not a code failure. Learn the model's bucket (orq quota / pick see it) and say why, so the conductor re-picks.
668
+ if (res.exhausted) {
669
+ const model = argvModel(j.argv);
670
+ let until = "";
671
+ if (res.exhausted.secs !== undefined) {
672
+ const at = Date.now() + res.exhausted.secs * 1000;
673
+ if (model)
674
+ learnExhausted("antigravity", model, res.exhausted.secs, agyToken()?.acct);
675
+ until = ` ${m("until", "até")} ${humanReset(Math.floor(at / 1000))} (${new Date(at).toISOString()})`;
676
+ }
677
+ res.answer = `quota exhausted: ${model ?? j.agent}${until}\n\n${res.exhausted.message}\n`;
678
+ res.rc = res.rc || 3;
679
+ }
650
680
  if (j.answer !== "file")
651
681
  writeFileSync(`${O}.out.md`, res.answer ?? "");
652
682
  const sid = j.fromLog ? new RegExp(j.fromLog, "m").exec(logText)?.[1] : res.session ?? j.session;
@@ -4,6 +4,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, rmSync } from "nod
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
5
  import { loadConfig } from "../config.js";
6
6
  import { m } from "../i18n.js";
7
+ import { issueFromName, issuePattern, issuesWithLiveAgent } from "../issue.js";
7
8
  import { alive } from "../lock.js";
8
9
  import { stateDir } from "../paths.js";
9
10
  import { aliveOwner } from "./claim.js";
@@ -69,10 +70,11 @@ export async function gc(argv, clock = Date.now) {
69
70
  return 2;
70
71
  }
71
72
  }
72
- const { cfg } = loadConfig(ws), rows = [];
73
+ const { cfg } = loadConfig(ws), rows = [], issuePat = issuePattern(ws);
73
74
  let ghUnavailable = false, fetchWarned = false;
74
75
  const root = join(ws, ".worktrees");
75
76
  const orq = join(ws, ".orquestra");
77
+ const liveAgents = issuesWithLiveAgent();
76
78
  const issues = existsSync(orq) ? readdirSync(orq).filter((x) => !x.startsWith(".")) : [];
77
79
  for (const repoName of readdirSync(ws, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)) {
78
80
  const repo = join(ws, repoName);
@@ -102,8 +104,8 @@ export async function gc(argv, clock = Date.now) {
102
104
  const branch = wt.branch?.replace(/^refs\/heads\//, "") ?? "";
103
105
  if (!branch)
104
106
  continue;
105
- // The branch carries the ID in lowercase (plane-abc-123); .orquestra/ keeps it as written (ABC-123).
106
- const id = /^.*?plane-(.+)$/.exec(branch)?.[1];
107
+ // The branch carries the ID in lowercase (abc-123, maybe with a prefix or -suffix); .orquestra/ keeps it as written (ABC-123).
108
+ const id = issueFromName(branch, issuePat);
107
109
  const issue = id && issues.find((d) => d.toLowerCase() === id.toLowerCase());
108
110
  const status = output(path, "status", "--porcelain");
109
111
  const unpushed = output(repo, "rev-list", branch, "--not", "--remotes");
@@ -117,14 +119,10 @@ export async function gc(argv, clock = Date.now) {
117
119
  verdict = "KEEP";
118
120
  reason = m("issue has a live owner", "issue tem um responsável ativo");
119
121
  }
120
- // A .pane/.id without a same-stem .done is treated as a running child.
121
- const issueDir = issue && join(ws, ".orquestra", issue);
122
- if (verdict !== "KEEP" && issueDir && existsSync(issueDir)) {
123
- const files = readdirSync(issueDir), running = files.some((f) => /\.(pane|id)$/.test(f) && !existsSync(join(issueDir, `${f.replace(/\.(pane|id)$/, "")}.done`)));
124
- if (running) {
125
- verdict = "KEEP";
126
- reason = m("child agent may still be running", "um agente filho pode estar em execução");
127
- }
122
+ // A child agent of the issue whose state says running and whose pid is alive (the board's rule).
123
+ if (verdict !== "KEEP" && issue && liveAgents.has(issue)) {
124
+ verdict = "KEEP";
125
+ reason = m("child agent may still be running", "um agente filho pode estar em execução");
128
126
  }
129
127
  let merged = false;
130
128
  if (verdict !== "KEEP") {
@@ -34,7 +34,7 @@ export function humanizeQuotaLine(line, now = Date.now()) {
34
34
  * `policy.quota_week`) — the same thresholds `orq pick` applies for an unspecified task level; ⊘ blocked
35
35
  * (orq block), ? no data; sources with no window at all ("-" and "-") are left out. */
36
36
  export function quotaLine(lines, limit5, limitWeek, blocked, age) {
37
- const parts = lines.filter((q) => q.five !== "-" || q.week !== "-").map((q) => {
37
+ const parts = lines.filter((q) => !q.name.includes("/") && (q.five !== "-" || q.week !== "-")).map((q) => {
38
38
  if (blocked.has(q.name))
39
39
  return `${q.name} ⊘`;
40
40
  const five = pct(q.five), week = pct(q.week);
@@ -96,37 +96,64 @@ async function diagnoseIssue(ws, id, x) {
96
96
  }
97
97
  const ev = evidence(dir);
98
98
  writeFileSync(`${prefix}.md`, buildPrompt(x, ev.fixes, ev.reviews));
99
- 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-"));
99
+ // A failed watchdog (e.g. a model catalog timeout) gets one retry with the next candidate, never the same agent.
100
+ const excluded = [];
101
+ let first;
102
+ const scratches = [];
107
103
  try {
108
- 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);
104
+ for (let attempt = 0; attempt < 2; attempt++) {
105
+ const p = orq("pick", "watchdog", "--level", "simple", "-w", ws, ...(implPick ? ["--impl-agent", implPick] : []), ...excluded.flatMap((e) => ["--exclude-agent", e]));
106
+ if (p.status !== 0) {
107
+ if (first) {
108
+ console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
109
+ return first.rc;
110
+ }
111
+ console.error(p.stderr.trim() || `orq pick watchdog: exit ${p.status}`);
112
+ return p.status ?? 1;
113
+ }
114
+ const picked = p.stdout.trim().split(/\s+/), agent = /(?:^|\s)-a (\S+)/.exec(p.stdout)?.[1];
115
+ // The agent runs in a scratch folder (everything it needs is in the prompt): it can't touch the issue files, even write sandboxed.
116
+ const scratch = mkdtempSync(join(tmpdir(), "orq-watchdog-"));
117
+ scratches.push(scratch);
118
+ const run = `${prefix}-${attempt}`;
119
+ {
120
+ writeFileSync(`${run}.md`, readFileSync(`${prefix}.md`));
121
+ const a = orq("agent", ...picked, "-r", "-t", `${id} watchdog`, "-d", scratch, "-g", "watchdog", "-o", run, "--", `${run}.md`);
122
+ process.stderr.write(a.stderr);
123
+ process.stdout.write(a.stdout);
124
+ const rc = a.status !== 0 ? a.status ?? 1 : await finish(await wait(["--timeout", String(WATCHDOG_TIMEOUT), run]), run, out, agent, attempt === 1 || !agent);
125
+ if (rc === 0 || rc === 124 || !agent)
126
+ return rc;
127
+ if (attempt === 0)
128
+ console.error(m(`watchdog ${agent} failed; retrying with another agent`, `watchdog ${agent} falhou; tentando com outro agente`));
129
+ first = { rc };
130
+ excluded.push(agent);
131
+ }
132
+ }
133
+ return first?.rc ?? 1;
134
+ // Removed only after every attempt, and best-effort: on Windows a just-finished agent can still hold its cwd (EPERM/EBUSY).
114
135
  }
115
136
  finally {
116
- rmSync(scratch, { recursive: true, force: true });
137
+ for (const s of scratches) {
138
+ try {
139
+ rmSync(s, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
140
+ }
141
+ catch { /* temp dir: the OS cleans it */ }
142
+ }
117
143
  }
118
144
  }
119
- async function finish(rc, prefix, out) {
145
+ async function finish(rc, prefix, out, agent, final = true) {
120
146
  if (rc === 124) {
121
147
  console.error(m(`diagnosis not written: the watchdog agent did not finish in ${WATCHDOG_TIMEOUT}s`, `diagnóstico não gravado: o agente watchdog não terminou em ${WATCHDOG_TIMEOUT}s`));
122
148
  return 124;
123
149
  }
124
150
  if (rc !== 0 || !existsSync(`${prefix}.out.md`)) {
125
- console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
151
+ if (final)
152
+ console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
126
153
  return 1;
127
154
  }
128
155
  copyFileSync(`${prefix}.out.md`, out);
129
- console.log(m(`diagnosis saved to ${out}`, `diagnóstico gravado em ${out}`));
156
+ console.log(m(`diagnosis saved to ${out}${agent ? ` (by ${agent})` : ""}`, `diagnóstico gravado em ${out}${agent ? ` (por ${agent})` : ""}`));
130
157
  console.log(recommendation(readFileSync(out, "utf8")) ?? m("(no RECOMMENDATION line in the answer)", "(sem linha RECOMMENDATION na resposta)"));
131
158
  return 0;
132
159
  }
package/dist/config.js CHANGED
@@ -32,6 +32,9 @@ export const DEFAULTS = {
32
32
  // Where quota comes from, per agent: "builtin" | "none" | false | { command = "..." } (see src/quota.ts).
33
33
  // "claude" covers every account in claude-accounts.
34
34
  quota: { claude: "builtin", codex: "builtin", opencode: "builtin", antigravity: "builtin", copilot: "builtin" },
35
+ // Issue ID in a branch/worktree name (orq gc, orq ci): case-insensitive regex, the named group "issue" (else group 1) is the ID.
36
+ // The default finds <LETTERS>-<DIGITS> anywhere (feature/ABC-123, abc-123-fix, plane-abc-123-api-2); suffixes are ignored.
37
+ git: { issue_pattern: "(?<![a-z0-9])(?<issue>[a-z][a-z0-9]*-\\d+)(?![a-z0-9])" },
35
38
  tracker: { kind: "none" }, // plane | none
36
39
  repos: {}, // defined by the workspace
37
40
  };
package/dist/install.js CHANGED
@@ -89,6 +89,11 @@ export function prepareWorkspace(dir, fmt, name = basename(resolve(dir))) {
89
89
  }
90
90
  else
91
91
  out.push(m(` ok ${map} (kept)`, ` ok ${map} (mantido)`));
92
+ // The conductor's instructions (what `orq conductor` looks for): regenerated on every run.
93
+ const ci = join(ws, ".orquestra-conductor.md");
94
+ const t = readFileSync(join(PKG, "templates", "conductor.instructions.md"), "utf8");
95
+ writeAtomic(ci, t.split("{{WORKSPACE}}").join(ws).split("{{NAME}}").join(name));
96
+ out.push(` ${m("wrote", "escrevi")} ${ci}`);
92
97
  const r = spawnSync(process.execPath, [join(PKG, "dist", "cli.js"), "instructions", fmt, "-w", ws], { encoding: "utf8" });
93
98
  out.push(` ${(r.stdout || r.stderr).trim()}`);
94
99
  return out;
package/dist/issue.js ADDED
@@ -0,0 +1,47 @@
1
+ // The issue a branch or worktree belongs to, and which issues have an agent really running.
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { getPath, loadConfig } from "./config.js";
5
+ import { alive } from "./lock.js";
6
+ import { stateDir } from "./paths.js";
7
+ /** Finds "<LETTERS>-<DIGITS>" anywhere in the name, as its own token; anything after the number is a suffix. */
8
+ export const DEFAULT_ISSUE_PATTERN = "(?<![a-z0-9])(?<issue>[a-z][a-z0-9]*-\\d+)(?![a-z0-9])";
9
+ /** The issue ID (as written in the name) from a branch or worktree name like "me/plane-proj-68-api-2" or
10
+ * "feature/ABC-123". `pattern` is [git] issue_pattern: a case-insensitive regex whose named group "issue"
11
+ * (else group 1, else the whole match) is the ID. */
12
+ export function issueFromName(name, pattern = DEFAULT_ISSUE_PATTERN) {
13
+ let re;
14
+ try {
15
+ re = new RegExp(pattern, "i");
16
+ }
17
+ catch (e) {
18
+ throw new Error(`git.issue_pattern: ${e.message}`);
19
+ }
20
+ const r = re.exec(name);
21
+ return r ? (r.groups?.issue ?? r[1] ?? r[0]) : undefined;
22
+ }
23
+ /** [git] issue_pattern of the workspace's merged config (default when unset). */
24
+ export function issuePattern(ws) {
25
+ const v = getPath(loadConfig(ws).cfg, "git.issue_pattern");
26
+ return typeof v === "string" && v ? v : DEFAULT_ISSUE_PATTERN;
27
+ }
28
+ /** Issue directories (basename of the run prefix's folder) with an agent whose state says running and whose pid is
29
+ * alive: the same rule the board uses for "running". */
30
+ export function issuesWithLiveAgent() {
31
+ const dir = join(stateDir(), "agents"), out = new Set();
32
+ let files = [];
33
+ try {
34
+ files = readdirSync(dir).filter((f) => f.endsWith(".json"));
35
+ }
36
+ catch { /* no runs yet */ }
37
+ for (const f of files) {
38
+ try {
39
+ const s = JSON.parse(readFileSync(join(dir, f), "utf8"));
40
+ if (s.status === "ok" || s.status === "fail" || s.status === "killed" || !alive(Number(s.pid)))
41
+ continue;
42
+ out.add(basename(dirname(String(s.prefix ?? ""))));
43
+ }
44
+ catch { /* unreadable state: ignore */ }
45
+ }
46
+ return out;
47
+ }
package/dist/pick.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // for an agent the user didn't list.
4
4
  // auto role: every pool agent that passes is scored:
5
5
  // quality * delivery + tokens * economy + speed * quickness − rotation * (picks in the last `window`)
6
- // − reserve (agents with reserve = true) + explore (fewer than `min_samples` runs)
6
+ // − reserve (agents with reserve = true) + explore (fewer than `min_samples` runs, fading with n, none after
7
+ // `explore_streak` picks in a row). Each axis is (prior·k + observed·n)/(k + n), k = min_samples.
7
8
  // Filters: --exclude-agent, orq block, nobody reviews their own code (unless no other agent can), cross-vendor rule, a model for the role/level,
8
9
  // quota under the limits: policy.quota_week (95) on the weekly window, and on the 5h one policy.quota_5h per level
9
10
  // (simple 90, medium 85, heavy 80): a heavy task needs more room left to finish.
@@ -84,7 +85,37 @@ export function modelFor(a, role, level) {
84
85
  const [model, effort] = spec.split("@");
85
86
  return [model, effort || undefined];
86
87
  }
87
- 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 };
88
+ // --- per-model quota ---------------------------------------------------------------------------------------------------
89
+ // A collector may add lines "<account>/<model> 5h=.. week=.." (antigravity: one bucket per model group). The model a pick is about
90
+ // to choose is checked against its own line, and an exhausted one gives way to a lighter model the agent has configured.
91
+ const LEVEL_RANK = { simple: 0, medium: 1, default: 1, heavy: 2 };
92
+ /** The quota line key for a configured model: the exact id, else the id without its -low/-medium/-high suffix, plain or
93
+ * as "<base>-tiered" (one bucket serves all three efforts of a tiered model). */
94
+ export function modelKey(model, keys) {
95
+ const id = model.split("/").pop() ?? model;
96
+ const base = id.replace(/-(low|medium|high)$/, "");
97
+ return [id, base, `${base}-tiered`].find((k) => keys.includes(k));
98
+ }
99
+ /** The models of an agent's role section lighter than `level`, the closest first, without repeats of `tried`. */
100
+ export function lighterModels(a, role, level, tried) {
101
+ const models = a.models;
102
+ if (!models)
103
+ return [];
104
+ const lr = legacyRole(role);
105
+ const ms = models[lr] ?? (lr === "fix" ? models.impl : lr === "watchdog" ? models.opinion ?? models.review : undefined) ?? {};
106
+ const rank = LEVEL_RANK[level] ?? 1, seen = new Set(tried);
107
+ const out = [];
108
+ for (const [, spec] of Object.entries(ms).filter(([lv]) => (LEVEL_RANK[lv] ?? 1) < rank)
109
+ .sort((x, y) => (LEVEL_RANK[y[0]] ?? 1) - (LEVEL_RANK[x[0]] ?? 1))) {
110
+ const [model, effort] = spec.split("@");
111
+ if (seen.has(model))
112
+ continue;
113
+ seen.add(model);
114
+ out.push([model, effort || undefined]);
115
+ }
116
+ return out;
117
+ }
118
+ const WEIGHTS = { quality: 0.4, tokens: 0.45, speed: 0.15, rotation: 0.2, reserve: 0.3, explore: 0.1, explore_streak: 2, min_samples: 8, window: 6 };
88
119
  export const weights = (cfg) => ({ ...WEIGHTS, ...((cfg.policy ?? {}).weights ?? {}) });
89
120
  /** The merged definition (built-in + config) takes a prompt; an agent that can't be resolved doesn't. */
90
121
  function takesPrompt(cfg, name) {
@@ -198,14 +229,49 @@ export function pick(inp) {
198
229
  return { pace: paces.length ? Math.max(...paces) : undefined,
199
230
  hot: penalty > 0 && ((p5 !== undefined && p5 > limit5) || (pw !== undefined && pw > limitWeek)) };
200
231
  };
232
+ // The chosen account's per-model buckets: an exhausted model gives way to a lighter one of the same agent.
233
+ let mfx = mf;
234
+ const acc0 = usable.length ? [...usable].sort((x, y) => x[1] - y[1])[0][0] : undefined;
235
+ const mkeys = acc0 ? Object.keys(inp.windows ?? {}).filter((k) => k.startsWith(`${acc0}/`)).map((k) => k.slice(acc0.length + 1)) : [];
236
+ if (acc0 && mkeys.length) {
237
+ const over = (model) => {
238
+ const k = modelKey(model, mkeys), q = k ? inp.windows?.[`${acc0}/${k}`] : undefined;
239
+ if (!q)
240
+ return undefined;
241
+ const five = (q.five ?? -1) >= limit5, week = (q.week ?? -1) >= limitWeek;
242
+ if (!five && !week)
243
+ return undefined;
244
+ const at = [five ? q.resetFive : undefined, week ? q.resetWeek : undefined].filter((t) => t !== undefined);
245
+ return `${model} ${five ? `5h ${q.five}%` : `${m("week", "semana")} ${q.week}%`}`
246
+ + (at.length ? ` (${m("back", "volta")} ${humanReset(Math.max(...at))})` : "");
247
+ };
248
+ const tried = [], skipped = [];
249
+ let found;
250
+ for (const cand of [mf, ...lighterModels(a, role, level, [mf[0]])]) {
251
+ const why = over(cand[0]);
252
+ if (!why) {
253
+ found = cand;
254
+ break;
255
+ }
256
+ tried.push(cand[0]);
257
+ skipped.push(why);
258
+ }
259
+ if (!found) {
260
+ out(name, `${m("every model over its quota", "todo modelo acima da cota")}: ${skipped.join(", ")}`);
261
+ continue;
262
+ }
263
+ if (skipped.length)
264
+ out(name, `${m("quota exhausted", "cota esgotada")}: ${skipped.join(", ")}; ${m("using", "usando")} ${found[0]}`);
265
+ mfx = found;
266
+ }
201
267
  const ranked = usable.map(([n, v]) => ({ n, v: v, ...paceOf(n) }))
202
268
  .sort((x, y) => Number(x.hot) - Number(y.hot) || x.v - y.v);
203
269
  const chosen = ranked[0];
204
270
  const acct = qn.length > 1 ? chosen?.n : undefined;
205
271
  const statRole = role === "fix" || role === "worker" ? "impl" : role;
206
- const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (mf[0].split("/").pop() || "default"));
272
+ const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (mfx[0].split("/").pop() || "default"));
207
273
  const st = stOf(statRole) ?? (role === "watchdog" ? stOf("review") : undefined); // no watchdog runs yet: its review record
208
- pass.push({ agent: name, model: mf[0], effort: mf[1], acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
274
+ pass.push({ agent: name, model: mfx[0], effort: mfx[1], acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
209
275
  if (res.mode === "fixed")
210
276
  break; // the first that passes wins
211
277
  }
@@ -215,24 +281,46 @@ export function pick(inp) {
215
281
  why: m(`fixed by [roles.${res.section}]`, `fixo em [roles.${res.section}]`) };
216
282
  return { mode: "fixed", best, cands: best ? [best] : [], notes };
217
283
  }
218
- const few = (st) => (st?.n ?? 0) < w.min_samples;
219
- const toks = pass.filter((c) => c.st?.new && !few(c.st)).map((c) => c.st.new);
220
- const mins = pass.filter((c) => c.st?.min && !few(c.st)).map((c) => c.st.min);
284
+ // Each axis blends the agent's prior with what was measured, (prior·k + observed·n) / (k + n) with k = min_samples,
285
+ // so a couple of runs already move the score instead of being ignored until the threshold.
286
+ const k = Math.max(0, w.min_samples);
287
+ const few = (st) => (st?.n ?? 0) < k;
288
+ const blend = (prior, obs, n) => obs === undefined || k + n <= 0 ? prior : (prior * k + obs * n) / (k + n);
289
+ // The reference cost/time is the best of the well-sampled agents, or of everyone measured when none is.
290
+ const best = (vals) => {
291
+ const solid = vals.filter((x) => x.solid);
292
+ const use = solid.length ? solid : vals;
293
+ return use.length ? Math.min(...use.map((x) => x.v)) : undefined;
294
+ };
295
+ const refTok = best(pass.filter((c) => c.st?.new).map((c) => ({ v: c.st.new, solid: !few(c.st) })));
296
+ const refMin = best(pass.filter((c) => c.st?.min).map((c) => ({ v: Math.max(c.st.min, 0.1), solid: !few(c.st) })));
297
+ const axis = (label, prior, obs, n) => {
298
+ const v = blend(prior, obs, n);
299
+ return { v, txt: `${label}=${v.toFixed(2)}` + (obs === undefined ? "" : `(${prior.toFixed(2)}→${obs.toFixed(2)})`) };
300
+ };
221
301
  const cands = pass.map((c) => {
222
302
  const prior = Number(c.a.prior ?? 0.6), n = c.st?.n ?? 0, reserve = Boolean(c.a.reserve);
223
- const deliv = role === "impl" && c.st?.approved != null && c.st.n_appr >= 3 ? c.st.approved / 100
224
- : c.st ? c.st.ok / 100 : undefined;
225
- const d = few(c.st) || deliv === undefined ? prior : deliv;
226
- const t = few(c.st) || !c.st?.new || !toks.length ? prior : Math.min(...toks) / c.st.new;
227
- const s = few(c.st) || !c.st?.min || !mins.length ? prior : Math.min(...mins) / Math.max(c.st.min, 0.1);
303
+ const appr = role === "impl" && c.st?.approved != null && c.st.n_appr >= 3;
304
+ const deliv = appr ? c.st.approved / 100 : c.st ? c.st.ok / 100 : undefined;
305
+ const d = axis(m("delivery", "entrega"), prior, deliv, appr ? c.st.n_appr : n);
306
+ const t = axis(m("economy", "economia"), prior, c.st?.new && refTok ? Math.min(1, refTok / c.st.new) : undefined, n);
307
+ const s = axis(m("speed", "rapidez"), prior, c.st?.min && refMin ? Math.min(1, refMin / Math.max(c.st.min, 0.1)) : undefined, n);
228
308
  const used = inp.recent.filter((p) => p.agent === c.agent).length;
309
+ // Exploration fades as samples come in and stops after explore_streak picks of the agent in a row.
310
+ let streak = 0;
311
+ for (let i = inp.recent.length - 1; i >= 0 && inp.recent[i].agent === c.agent; i--)
312
+ streak++;
313
+ const capped = few(c.st) && streak >= w.explore_streak;
314
+ const explore = few(c.st) && !capped ? w.explore * (k - n) / k : 0;
229
315
  const hot = Boolean(c.hot) && penalty > 0;
230
- const score = w.quality * d + w.tokens * t + w.speed * s - w.rotation * used
231
- - (reserve ? w.reserve : 0) + (few(c.st) ? w.explore : 0) - (hot ? penalty : 0);
232
- const why = `${m("delivery", "entrega")}=${d.toFixed(2)} ${m("economy", "economia")}=${t.toFixed(2)} `
233
- + `${m("speed", "rapidez")}=${s.toFixed(2)} ${m("rotation", "rodízio")}=-${used} `
316
+ const score = w.quality * d.v + w.tokens * t.v + w.speed * s.v - w.rotation * used
317
+ - (reserve ? w.reserve : 0) + explore - (hot ? penalty : 0);
318
+ const why = `${d.txt} ${t.txt} ${s.txt} ${m("rotation", "rodízio")}=-${used} `
234
319
  + (reserve ? m("reserve ", "reserva ") : "")
235
- + (hot ? `${m("pace", "ritmo")}=${Math.round(c.pace)}%→-${penalty} ` : "") + (few(c.st) ? `${m("few samples", "poucos dados")}(n=${n})` : `n=${n}`);
320
+ + (hot ? `${m("pace", "ritmo")}=${Math.round(c.pace)}%→-${penalty} ` : "")
321
+ + (explore ? `${m("explore", "explorar")}=+${explore.toFixed(2)} ` : "")
322
+ + (capped ? `${m("explore capped", "exploração limitada")}(${streak}x) ` : "")
323
+ + (few(c.st) ? `${m("few samples", "poucos dados")}(n=${n})` : `n=${n}`);
236
324
  return { agent: c.agent, model: c.model, effort: c.effort, acct: c.acct, score, why };
237
325
  }).sort((x, y) => y.score - x.score);
238
326
  return { mode: "auto", best: cands[0], cands, notes };
package/dist/quota.js CHANGED
@@ -24,6 +24,7 @@ import { writeAtomic } from "./fsutil.js";
24
24
  import { readSecret } from "./keyring.js";
25
25
  import { stateDir } from "./paths.js";
26
26
  import { m } from "./i18n.js";
27
+ import { modelKey } from "./pick.js";
27
28
  const show = (p) => (typeof p === "number" ? `${Math.floor(p)}%` : p);
28
29
  // reset5h/resetweek (epoch seconds) sit between the windows and the note: parseQuota/parseWindows only look at the
29
30
  // first three words, so old cached lines (no reset fields) and lines with them both parse the same way.
@@ -50,9 +51,9 @@ const resetOf = (w) => {
50
51
  };
51
52
  // `acct` identifies the login behind a reading (antigravity: one login per machine, swapped by the user): a cache
52
53
  // written under another login must never stand in for this one.
53
- function remember(name, five, week, acct) {
54
+ function remember(name, five, week, acct, buckets) {
54
55
  try {
55
- writeAtomic(cacheFile(name), JSON.stringify({ five, week, at: Date.now(), ...(acct ? { acct } : {}) }));
56
+ writeAtomic(cacheFile(name), JSON.stringify({ five, week, at: Date.now(), ...(acct ? { acct } : {}), ...(buckets ? { buckets } : {}) }));
56
57
  }
57
58
  catch { /* best effort */ }
58
59
  const r5 = resetOf(five), rw = resetOf(week);
@@ -195,23 +196,83 @@ export function codexQuota() {
195
196
  return { name: "codex", ...pcts, ...(span5 ? { span5h: span5 } : {}), ...(span ? { spanWeek: span } : {}), note: (d ? m(`${d}-day window in the week slot; `, `janela de ${d} dias no lugar da semana; `) : "")
196
197
  + m(`from the last session, ${age} min ago`, `da última sessão, há ${age} min`) };
197
198
  }
198
- /** Worst usage per window across models: a reset within 6 h belongs to the 5h window, a later one to the week. */
199
+ /** Per-model buckets, and the agent's own pair: its least used bucket (the agent still has quota while any
200
+ * model does; `orq pick` checks the model it is about to choose against that model's own bucket). */
199
201
  export function antigravityFromModels(models, now = Date.now()) {
200
202
  const five = { pct: "-" }, week = { pct: "-" };
201
- for (const { quotaInfo: q } of Object.values(models ?? {})) {
202
- if (typeof q?.remainingFraction !== "number" || !q.resetTime)
203
+ const buckets = {};
204
+ for (const [id, { quotaInfo: q } = {}] of Object.entries(models ?? {})) {
205
+ if (!q?.resetTime)
203
206
  continue;
204
207
  const at = Date.parse(q.resetTime);
205
208
  if (Number.isNaN(at))
206
209
  continue;
207
- const w = at - now <= 6 * 3600_000 ? five : week;
208
- const used = Math.round((1 - q.remainingFraction) * 100);
209
- if (w.pct === "-" || used > w.pct) {
210
- w.pct = used;
211
- w.resetsAt = q.resetTime;
210
+ const left = typeof q.remainingFraction === "number" ? q.remainingFraction : 0;
211
+ const used = Math.min(100, Math.max(0, Math.round((1 - left) * 100)));
212
+ const isWeek = at - now > 6 * 3600_000;
213
+ buckets[id] = { used, resetsAt: q.resetTime, week: isWeek };
214
+ }
215
+ // The agent's pair is its least used bucket overall, in that bucket's window (the other stays "-"): a weekly cap on
216
+ // one model must not read as the whole agent being out.
217
+ const best = Object.values(buckets).sort((x, y) => x.used - y.used || Number(x.week) - Number(y.week))[0];
218
+ if (best)
219
+ Object.assign(best.week ? week : five, { pct: best.used, resetsAt: best.resetsAt });
220
+ return { five, week, buckets };
221
+ }
222
+ /** One quota line per model, named "<agent>/<model>", with its bucket in the 5h or the week slot and "-" in the other. */
223
+ export function modelLines(name, buckets, now = Date.now(), note) {
224
+ const out = [];
225
+ for (const [id, b] of Object.entries(buckets)) {
226
+ const at = Date.parse(b.resetsAt);
227
+ if (Number.isNaN(at) || at <= now)
228
+ continue; // reset since the reading: no figure to stand on
229
+ const reset = Math.floor(at / 1000);
230
+ out.push({ name: `${name}/${id}`, five: b.week ? "-" : b.used, week: b.week ? b.used : "-",
231
+ ...(b.week ? { resetWeek: reset } : { reset5h: reset }), ...(note ? { note } : {}) });
232
+ }
233
+ return out;
234
+ }
235
+ /** Learned exhausted buckets (from a run that hit the limit) outlive API readings until their reset, unless the API
236
+ * returns that model with something left. The API's own bucket for the model stands in when it also says exhausted. */
237
+ export function mergeLearned(api, learned, now = Date.now()) {
238
+ const out = { ...api };
239
+ for (const [id, b] of Object.entries(learned)) {
240
+ if (!b.learned || Date.parse(b.resetsAt) <= now)
241
+ continue;
242
+ const key = modelKey(id, Object.keys(api));
243
+ if (key && api[key].used < 100)
244
+ continue; // the API says it has quota again
245
+ out[id] = b;
246
+ }
247
+ return out;
248
+ }
249
+ /** Record that a run found `model` out of quota for `secs` more seconds (best effort; keeps the cached reading). */
250
+ export function learnExhausted(name, model, secs, acct, now = Date.now()) {
251
+ const at = now + secs * 1000;
252
+ const b = { used: 100, resetsAt: new Date(at).toISOString(), week: at - now > 6 * 3600_000, learned: true };
253
+ try {
254
+ let c = { five: { pct: "-" }, week: { pct: "-" }, at: now };
255
+ try {
256
+ c = JSON.parse(readFileSync(cacheFile(name), "utf8"));
212
257
  }
258
+ catch { /* none yet */ }
259
+ if (acct !== undefined && c.acct !== undefined && c.acct !== acct)
260
+ c = { five: { pct: "-" }, week: { pct: "-" }, at: now };
261
+ const buckets = { ...(c.buckets ?? {}), [model]: b };
262
+ writeAtomic(cacheFile(name), JSON.stringify({ ...c, ...(acct ? { acct } : {}), buckets }));
263
+ }
264
+ catch { /* best effort */ }
265
+ return b;
266
+ }
267
+ /** The per-model buckets of the last good reading, when it was this login's. */
268
+ function cachedBuckets(name, acct) {
269
+ try {
270
+ const c = JSON.parse(readFileSync(cacheFile(name), "utf8"));
271
+ return acct !== undefined && c.acct !== acct ? {} : c.buckets ?? {};
272
+ }
273
+ catch {
274
+ return {};
213
275
  }
214
- return { five, week };
215
276
  }
216
277
  export function agyToken() {
217
278
  // agy (go-keyring) keeps {"token": {access_token, expiry, ...}} under service "gemini", user "antigravity"
@@ -242,13 +303,17 @@ export async function antigravityQuota() {
242
303
  const name = "antigravity";
243
304
  const t = agyToken();
244
305
  if (!t?.access)
245
- return noData(name, m("no agy login in the keyring", "sem login do agy no chaveiro"));
306
+ return [noData(name, m("no agy login in the keyring", "sem login do agy no chaveiro"))];
246
307
  // Never refresh it here (that is agy's login). The access token lives 1h and agy renews it only when it runs, so a
247
308
  // passed expiry with a refresh token is just idle (a note, not an expired login); without one, "run agy once".
248
309
  const expired = m("login expired: run `agy models`", "login expirado: rode `agy models`");
310
+ const stale = (why) => {
311
+ const line = fromCache(name, why, undefined, t.acct);
312
+ return [line, ...modelLines(name, cachedBuckets(name, t.acct), Date.now(), line.note)];
313
+ };
249
314
  const note = agyNote(t);
250
315
  if (note)
251
- return fromCache(name, note, undefined, t.acct);
316
+ return stale(note);
252
317
  try {
253
318
  const r = await fetch("https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", {
254
319
  method: "POST", body: "{}",
@@ -257,15 +322,16 @@ export async function antigravityQuota() {
257
322
  signal: AbortSignal.timeout(15_000),
258
323
  });
259
324
  if (r.status === 401)
260
- return fromCache(name, expired, undefined, t.acct);
325
+ return stale(expired);
261
326
  if (!r.ok)
262
- return fromCache(name, `HTTP ${r.status}`, undefined, t.acct);
327
+ return stale(`HTTP ${r.status}`);
263
328
  const j = await r.json();
264
- const { five, week } = antigravityFromModels(j.models ?? {});
265
- return remember(name, five, week, t.acct);
329
+ const { five, week, buckets: api } = antigravityFromModels(j.models ?? {});
330
+ const buckets = mergeLearned(api, cachedBuckets(name, t.acct));
331
+ return [remember(name, five, week, t.acct, buckets), ...modelLines(name, buckets)];
266
332
  }
267
333
  catch (e) {
268
- return fromCache(name, m("collector failed", "coletor falhou") + `: ${e.message}`, undefined, t.acct);
334
+ return stale(m("collector failed", "coletor falhou") + `: ${e.message}`);
269
335
  }
270
336
  }
271
337
  // --- copilot --------------------------------------------------------------------------------------------------------
@@ -385,7 +451,7 @@ export async function collect(sources, want = []) {
385
451
  if (name === "codex")
386
452
  return wanted(name) ? [format(codexQuota())] : [];
387
453
  if (name === "antigravity")
388
- return wanted(name) ? [format(await antigravityQuota())] : [];
454
+ return wanted(name) ? (await antigravityQuota()).map(format) : [];
389
455
  if (name === "copilot")
390
456
  return wanted(name) ? [format(copilotQuota())] : [];
391
457
  if (name === "opencode" || name === "minimax")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irythill/orq",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Orchestrate coding agents (Claude Code, Codex, OpenCode, Antigravity, Copilot) from one conductor.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,7 +11,8 @@
11
11
  "dist",
12
12
  "!dist/**/*.test.js",
13
13
  "skills",
14
- "templates/workspace.CLAUDE.md"
14
+ "templates/workspace.CLAUDE.md",
15
+ "templates/conductor.instructions.md"
15
16
  ],
16
17
  "engines": {
17
18
  "node": ">=20"
@@ -0,0 +1,31 @@
1
+ # Conductor "{{NAME}}" — {{NAME}} ecosystem
2
+
3
+ You orchestrate agents in the `{{WORKSPACE}}` ecosystem, in herdr (`HERDR_ENV=1`) or in any other terminal.
4
+ Don't write code yourself. Opened by `orq conductor`. Talk to the user in the user's language.
5
+ 1. Read `{{WORKSPACE}}/CLAUDE.md` (repos, groups, base branch, Plane, issue standard, MCP limitations).
6
+ 2. For every Plane issue, follow the `/issue <ID>` skill.
7
+ 3. Never push, open a PR, merge or deploy unless the user asks. When unsure about scope or cost, ask.
8
+ 4. **Children only through `orq agent`** (implementer, fix, review), with the agent chosen by `orq pick`; tests through
9
+ `orq verify` (it checks the base by itself; no `orq baseline` in the flow), as the skill says. NEVER
10
+ `herdr agent start` for children (no memory cap, no answer file); to watch the agent's own screen, `orq agent -T` (herdr or tmux).
11
+ 5. **Completion = one single signal:** in herdr, the line `[HERDR-AGENT DONE] <title> status=ok|fail ... answer: <file>`
12
+ arriving in your pane; elsewhere, `orq wait <prefix>` (as a background task) printing
13
+ `[ORQ AGENT DONE] <title> status=ok|fail ... answer: <file>` (plus the `<prefix>.done` file either way). When it
14
+ arrives: read the answer file and act. Nothing else means done.
15
+ 6. **"Is it still running?"** is answered by the file only: `<prefix>.done` exists → finished; it doesn't → running;
16
+ check `<prefix>.log` (last write time) or, in herdr, peek at the pane: `herdr pane read $(cat <prefix>.pane) --source recent`.
17
+ NEVER `pgrep`/`ps` (they match their own command line).
18
+ 7. **Never block waiting** (`herdr agent wait`, `pane wait-output`, `sleep`): dispatch, report in one line and end
19
+ your turn (outside herdr: `orq wait` in the background, never in the foreground unless your harness has no background tasks).
20
+ 8. Worktrees always from `origin/<repo base>`, created with `git worktree add` (never `herdr worktree create`).
21
+ 9. Between batches of unrelated issues, suggest `/clear` to the user: state lives in Plane and in `.orquestra/`.
22
+
23
+ ## How you are used
24
+ - **Dispatcher, not consultant.** Your job: issue → dispatch → report → PR/cleanup. Long questions about architecture,
25
+ infrastructure (DNS, email, deploy, webhooks) or business decisions: answer in at most 3 lines and suggest "take it
26
+ to a design session; come back with the decision recorded in the issue".
27
+ - **The issue is the source.** A decision made in chat → record it in Plane (description or comment) before
28
+ dispatching; the child's prompt comes from the issue, not from the conversation.
29
+ - **Context:** past ~150k tokens or after closing a batch of related issues → suggest `/clear` (state in Plane and in
30
+ `.orquestra/`).
31
+ - 1–3 line answers. Final issue report: 3–5 lines + the PR link.