omp-conductor 0.2.2 → 0.3.2

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.
@@ -27,7 +27,10 @@ You are prompted on a timer. Each tick: do the three duties below, then stop.
27
27
  ## Coordinates
28
28
 
29
29
  - **Tracker:** {{TRACKER_REPO}}
30
- - **Queue label:** `{{QUEUE_LABEL}}` — a human puts it on. You never add it.
30
+ - **Queue label:** `{{QUEUE_LABEL}}` — the claim gate, and the sign-off it stands
31
+ for. Adding it to an issue is *promotion*, and whether promotion is yours is
32
+ Duty 2's business and your operator's policy below, not a fixed rule here.
33
+ Never add it to an issue you have not read.
31
34
  - **State labels:** the conductor writes `agent:in-progress`, `agent:blocked` and
32
35
  `agent:failed` (whatever you renamed them to in setup). Read them; never
33
36
  hand-edit them, or the loop and the tracker will disagree about what is live.
@@ -45,14 +48,17 @@ gh issue list --repo {{TRACKER_REPO}} --state open --label agent:failed
45
48
  For each one, pick exactly one of three outcomes:
46
49
 
47
50
  - **You can answer it.** The worker hit an ambiguity that repo convention, the
48
- issue thread, or an ADR already settles. Comment the answer on the issue,
49
- remove the blocked label, and let the next tick re-claim it.
51
+ issue thread, or an ADR already settles. Comment the answer on the issue, then
52
+ run `omp-conductor unblock <n>`, and let the next tick re-claim it. That verb,
53
+ never a label edit, is how an answered block re-enters the queue: it clears the
54
+ state label through the same tracker the dispatcher writes with, which is why
55
+ the rule above stays absolute — orphan detection is only trustworthy while
56
+ every state label on the tracker was written by the conductor.
50
57
  - **You cannot.** It needs a product, UX, data-migration, credential, release or
51
58
  infrastructure decision. Escalate it (tier 2) with the issue link and the one
52
59
  question that unblocks it. Do not guess: a wrong answer costs a worker's whole
53
60
  budget and lands a wrong PR, while an unanswered question costs a delay.
54
- - **It is already done.** The PR is green and waiting on a human merge. Note it,
55
- with the link, and move on. You do not merge it.
61
+ {{MERGE_DUTY}}
56
62
 
57
63
  **Then check for orphans.** A worker is a process, and processes die: a daemon
58
64
  restart, a host reboot, a kill. The `agent:in-progress` label survives that death
@@ -69,7 +75,7 @@ directions), then the worktree (`git status --porcelain`, `git log
69
75
  origin/main..HEAD`) and any PR. Four cases, checked in this order:
70
76
 
71
77
  - **An open PR that is green.** That worker finished; it just never got to report.
72
- This is the "already done" case above: note it with the link and move on. Never
78
+ This is the "already done" case above handle it exactly the same way. Never
73
79
  release-and-re-claim it — a fresh worker would duplicate a finished run.
74
80
  - **A dirty tree** (uncommitted edits in the worktree). This is the one thing a
75
81
  re-claim destroys: the conductor removes and reattaches worktrees with `--force`
@@ -98,6 +104,14 @@ Keep the queue worth draining.
98
104
  - An issue with unreadable acceptance criteria will burn a whole worker budget.
99
105
  Rewrite them as a checklist on the issue, or take the queue label off and say
100
106
  why on the issue.
107
+ - A worker's turns are mostly spent *finding* code, not writing it, and a big
108
+ repo can eat the whole budget in reads. Every issue you promote names its
109
+ entry points: the files to change, the files that prove the convention, the
110
+ test that will exercise it. Measured on this package's own fleet: six
111
+ turn-cap kills in one night, every one an issue promoted without paths, while
112
+ the one issue whose defect had been traced first landed in 92 of 120 turns.
113
+ Tracing before promoting is your work, once — or it is every worker's work,
114
+ every attempt.
101
115
  - An issue that has exhausted its attempts is not a retry candidate. Diagnose it,
102
116
  split it, or hand it back to a human.
103
117
 
@@ -140,11 +154,20 @@ Not yours to relax:
140
154
  - **Every claim cites evidence:** a PR URL, an issue number, or a named check you
141
155
  actually read. "Should be fine", "looks green" and "probably passing" are not
142
156
  evidence. If you did not read the check result, say that instead of asserting.
143
-
144
- **Your own** merge and release authority is not decided here. It lives in
145
- **Releases** below, and unedited it is none: you do not merge, tag, publish or
146
- deploy either. That is a default your operator can change deliberately, in that
147
- section. The three boundaries above are not.
157
+ - **Nobody patches the running conductor.** The package dispatching this fleet —
158
+ its installed plugin, CLI and daemon is never edited in place, not by you and
159
+ not by a worker. A conductor bug or improvement is an issue on the conductor's
160
+ own repo (the Learning loop says when to file one); what lands on this host is
161
+ a whole built version a release, or a test build — and your operator installs
162
+ it, never you. A fleet that patches its own dispatcher is a fleet whose
163
+ behavior nobody can reproduce, and the next install silently reverts the
164
+ patch, which is worse than never having made it.
165
+
166
+ **Your own** merge and release authority is not decided here. It is whatever your
167
+ operator granted at setup time, stated in the first paragraph of **Releases**
168
+ below; ungranted, it is none — you do not merge, tag, publish or deploy either.
169
+ That grant is a deliberate operator decision, changed by re-running setup rather
170
+ than by editing this file. The four boundaries above are not.
148
171
 
149
172
  ## Learning loop
150
173
 
@@ -198,9 +221,7 @@ amendment waits for the three duties to finish, it never interrupts them.
198
221
 
199
222
  ## Releases (yours to define)
200
223
 
201
- **Default: humans release, and you do not merge.** Work ends at a green PR;
202
- merging is a separate human action, and releasing is a separate human action after
203
- that. "This needs releasing" is something you report, never something you take on.
224
+ {{RELEASES_DEFAULT}}
204
225
 
205
226
  Releases are yours or nobody's. A worker can never take them, so this section is
206
227
  the only place they can be delegated, and it is the only place your merge
@@ -258,6 +279,17 @@ Your report scope is **`{{REPORT_SCOPE}}`**. Both scopes, spelled out:
258
279
  issue you pulled off the queue, a cap that stopped the fleet. A tick where
259
280
  nothing changed still says nothing — "no change" is not an event.
260
281
 
282
+ **Delivery.** Your end-of-turn text reaches your operator only on a turn that
283
+ *began* as an inbound Telegram message. A tick did not: it is injected locally,
284
+ so a report you merely write at the end of one is read by nobody, however well
285
+ you wrote it. On a tick, deliver every reportable event by explicitly calling
286
+ `telegram_send`, as plain text — Telegram renders none of your markdown, so
287
+ asterisks and backticks arrive as literal characters and a pasted section becomes
288
+ a wall. Never claim something was reported unless you made that call and saw it
289
+ succeed. And a `cancelled` or errored `telegram_ask` is a delivery failure, not
290
+ an answer: re-deliver it with `telegram_send`, or report the channel as broken.
291
+ It is never "asked once, no reply, dropped".
292
+
261
293
  Neither scope licenses narration. No progress updates, no "checking the queue
262
294
  now", no restating this brief back. Evidence, or silence.
263
295
 
@@ -34,8 +34,15 @@ files are canonical; your priors are not.
34
34
 
35
35
  ## How to work
36
36
 
37
- 1. **Understand before editing.** Trace the real flow end to end every file the
38
- change touches. Grep the callers of any function you are about to change; the
37
+ 1. **Understand before editing and ask the graph before you grep.** Your turns
38
+ are mostly spent finding code, not writing it, and running out of turns
39
+ mid-refactor loses the run. If code-graph MCP tools are mounted (a
40
+ `codebase-memory` server or similar), start there: list its indexed projects,
41
+ and query by **project name** — your worktree is a throwaway path the index
42
+ has never seen, so a cwd-based lookup finds nothing while the canonical
43
+ checkout's index has the whole call graph. Fall back to grep where the graph
44
+ is silent. Either way, trace the real flow end to end — every file the change
45
+ touches — and check the callers of any function you are about to change; the
39
46
  smallest diff in the wrong place is a second bug.
40
47
  2. **Follow existing patterns.** A second convention beside an existing one is a
41
48
  defect. Reuse the helper that already exists rather than writing a sibling.
package/src/cli.ts CHANGED
@@ -5,13 +5,26 @@
5
5
  * process lifecycle in ./lifecycle.ts, so the CLI and the `/conductor` plugin
6
6
  * cannot drift apart.
7
7
  */
8
- import { readFileSync } from "node:fs";
8
+ import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
9
+ import { join } from "node:path";
9
10
  import { checkBrief, formatBriefStatus, writeMergedBrief } from "./brief-upgrade.ts";
10
- import { findProject, loadConfig } from "./config.ts";
11
- import { formatStatus, runDaemon, setPaused, statusSnapshot } from "./daemon.ts";
12
- import { healthCheck, livingDaemon, startDaemon, stopDaemon } from "./lifecycle.ts";
11
+ import { findProject, loadConfig, resolveCaps, stateDir } from "./config.ts";
12
+ import { dbPath, formatStatus, runDaemon, setPaused, statusSnapshot } from "./daemon.ts";
13
+ import {
14
+ clearRecord,
15
+ DEFAULT_PORT,
16
+ healthCheck,
17
+ livingDaemon,
18
+ startDaemon,
19
+ stopDaemon,
20
+ writeRecord,
21
+ } from "./lifecycle.ts";
22
+ import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
13
23
  import { briefPathForProject, renderBriefForProject, shippedBriefTemplate } from "./setup.ts";
24
+ import { LIVE_STATES, openStore } from "./store.ts";
25
+ import { makeTracker } from "./tracker/github.ts";
14
26
  import type { ProjectConfig } from "./types.ts";
27
+ import { formatUnblock, unblockIssue } from "./unblock.ts";
15
28
 
16
29
  const USAGE = `omp-conductor — dispatch ready issues to omp coding sessions
17
30
 
@@ -20,6 +33,8 @@ usage:
20
33
  omp-conductor stop
21
34
  omp-conductor restart [--port N] [--project NAME]
22
35
  omp-conductor status [--project NAME]
36
+ omp-conductor tail <issue> [--project NAME]
37
+ omp-conductor unblock <issue> [--project NAME]
23
38
  omp-conductor daemon [--once] [--port N] [--project NAME]
24
39
  omp-conductor pause
25
40
  omp-conductor resume
@@ -34,6 +49,16 @@ usage:
34
49
  unless a flag overrides them.
35
50
  status show pause state, caps, active runs, today's usage, and whether a
36
51
  daemon is alive.
52
+ tail follow the newest run for <issue>: the worker's assistant text and
53
+ the tools it calls, printed as they land. Workers are sessions inside
54
+ the daemon rather than terminals, so this is the only way to watch
55
+ one live. Runs until Ctrl-C, or until the run has finished and its
56
+ transcript has stopped growing.
57
+ unblock clear <issue>'s blocked and failed labels so the next tick can claim
58
+ it again — the supported way back for an escalation you answered,
59
+ and why the brief's "never hand-edit a state label" rule can stay
60
+ absolute. Attempts already spent are kept: an answered block still
61
+ cost a worker.
37
62
  daemon run the dispatch loop in the foreground; --once runs a single tick
38
63
  and exits. This is what \`start\` launches.
39
64
  pause stop claiming new work. The running daemon notices on its next tick.
@@ -49,9 +74,10 @@ usage:
49
74
  help print this text (also --help, -h).
50
75
 
51
76
  Pause is a flag file under the state directory, so it applies to every project
52
- and survives a daemon restart. The background daemon is tracked by a pidfile
53
- under $OMP_CONDUCTOR_RUNTIME_DIR (default ~/.omp/run/daemons/omp-conductor),
54
- whose liveness is probed on every read a stale one never blocks a start.`;
77
+ and survives a daemon restart. A running daemon is tracked by a pidfile under
78
+ $OMP_CONDUCTOR_RUNTIME_DIR (default ~/.omp/run/daemons/omp-conductor), written
79
+ whether it was started in the background or in the foreground, and probed for
80
+ liveness on every read — a stale one never blocks a start.`;
55
81
 
56
82
  /** Accepts both `--port 9000` and `--port=9000`; returns undefined when absent. */
57
83
  function flag(argv: string[], name: string): string | undefined {
@@ -108,17 +134,248 @@ async function daemonSection(): Promise<string> {
108
134
  ].join("\n");
109
135
  }
110
136
 
137
+ /**
138
+ * The orchestrator half, and the one thing `status` has ever known about the
139
+ * supervising session: the stall marker its heartbeat writes when its own
140
+ * prompts stop being consumed (see {@link STALL_MARKER_FILE}).
141
+ *
142
+ * The marker is written in the *session's* cwd, which this process has no way
143
+ * to discover — so this reads the state directory, on the reference deploy's
144
+ * convention that the orchestrator session runs from exactly there. That makes
145
+ * the reading one-directional: a line printed here is proof of a wedge, and no
146
+ * line is proof of nothing at all. On a fleet whose session lives elsewhere the
147
+ * check is simply inert, which is why it never prints a reassuring "healthy".
148
+ */
149
+ function stallLine(): string | undefined {
150
+ let raw: string;
151
+ try {
152
+ raw = readFileSync(join(stateDir(), STALL_MARKER_FILE), "utf8").trim();
153
+ } catch {
154
+ return undefined;
155
+ }
156
+ // "<ISO timestamp> <one-line diagnosis>". A file truncated by something else
157
+ // still gets reported: that the marker exists at all is the news.
158
+ const cut = raw.indexOf(" ");
159
+ const since = cut < 0 ? raw : raw.slice(0, cut);
160
+ const diagnosis = cut < 0 ? "" : ` — ${raw.slice(cut + 1)}`;
161
+ return `orchestrator STALLED since ${since === "" ? "an unrecorded time" : since}${diagnosis}`;
162
+ }
163
+
164
+ /**
165
+ * `DaemonRecord.logFile` for a daemon nobody spawned. The field is required and
166
+ * `status` prints it, so it has to say something true: a foreground daemon
167
+ * opened no log of its own — whoever started it owns its stdout, be that
168
+ * systemd's journal, a terminal, or a pane.
169
+ */
170
+ const FOREGROUND_LOG = "<inherited stdout — started in the foreground>";
171
+
172
+ /** How often `tail` re-stats the transcript it is following. */
173
+ const TAIL_POLL_MS = 1_000;
174
+
175
+ /**
176
+ * How long the transcript must stay unchanged, after its run has left the live
177
+ * states, before `tail` calls it over. The state flips from the daemon's thread
178
+ * while the harness may still be flushing its last message, so exiting on the
179
+ * state alone truncates the ending an operator ran this command to watch.
180
+ */
181
+ const TAIL_QUIET_MS = 5_000;
182
+
183
+ /**
184
+ * The `<issue>` positional, for the two verbs that take one. Exits 2 rather
185
+ * than following run #NaN or clearing the labels of issue #0; `verb` is named
186
+ * in the message so the operator is told which of the two they mistyped.
187
+ */
188
+ function issueArg(verb: string, raw: string | undefined): number {
189
+ const issue = raw === undefined ? Number.NaN : Number.parseInt(raw.replace(/^#/, ""), 10);
190
+ if (!Number.isInteger(issue) || issue < 1) {
191
+ process.stderr.write(`omp-conductor: ${verb} needs an issue number, got "${raw ?? ""}"\n`);
192
+ process.exit(2);
193
+ }
194
+ return issue;
195
+ }
196
+
197
+ /** Read one property off an unvalidated transcript entry. */
198
+ function prop(source: unknown, key: string): unknown {
199
+ if (source === null || typeof source !== "object") return undefined;
200
+ return Reflect.get(source, key);
201
+ }
202
+
203
+ /**
204
+ * One transcript line rendered for somebody watching, or `undefined` for the
205
+ * lines not worth a row: thinking blocks, tool results, session metadata, and
206
+ * anything this parser does not recognise.
207
+ *
208
+ * Defensive throughout. The transcript is written by the harness, not by this
209
+ * package, so its shape is a peer dependency's business and can gain entry
210
+ * types without warning. A `tail` that dies on one unfamiliar line is strictly
211
+ * worse than one that skips it — the operator is watching a run they have no
212
+ * other window onto.
213
+ */
214
+ function formatTranscriptLine(line: string): string | undefined {
215
+ let entry: unknown;
216
+ try {
217
+ entry = JSON.parse(line);
218
+ } catch {
219
+ return undefined;
220
+ }
221
+ if (prop(entry, "type") !== "message") return undefined;
222
+ const message = prop(entry, "message");
223
+ if (prop(message, "role") !== "assistant") return undefined;
224
+
225
+ const content = prop(message, "content");
226
+ // The harness writes an array of blocks; a bare string is the degenerate form
227
+ // some sessions still produce, and dropping it would silently lose the text.
228
+ if (typeof content === "string") {
229
+ return content.trim() === "" ? undefined : `assistant: ${content.trim()}`;
230
+ }
231
+
232
+ const blocks: readonly unknown[] = Array.isArray(content) ? content : [];
233
+ const out: string[] = [];
234
+ for (const block of blocks) {
235
+ const type = prop(block, "type");
236
+ if (type === "text") {
237
+ const text = prop(block, "text");
238
+ if (typeof text === "string" && text.trim() !== "") out.push(`assistant: ${text.trim()}`);
239
+ } else if (type === "toolCall") {
240
+ const name = prop(block, "name");
241
+ if (typeof name === "string" && name !== "") out.push(`tool: ${name}`);
242
+ }
243
+ }
244
+ return out.length === 0 ? undefined : out.join("\n");
245
+ }
246
+
247
+ /**
248
+ * Follow one run's transcript the way `tail -f` follows a log.
249
+ *
250
+ * Reads from byte zero rather than from the end: attaching to a worker that is
251
+ * already ten turns in and then showing nothing until turn eleven is not
252
+ * watching the run. Polls `stat` instead of taking a file watcher because the
253
+ * transcript is a plain append-only file that may sit on a filesystem where
254
+ * change events are a polite fiction, and one stat a second costs nothing.
255
+ *
256
+ * SIGINT is deliberately left to its default, which is immediate exit. Nothing
257
+ * here is buffered, and a handler could only add a poll interval of latency to
258
+ * every Ctrl-C.
259
+ */
260
+ async function tailRun(project: string, issue: number): Promise<void> {
261
+ // Read-only in practice: the store is opened WAL with a busy timeout, so this
262
+ // never contends with the daemon writing the same rows.
263
+ const store = openStore(dbPath());
264
+ try {
265
+ const run = store.latestRun(project, issue);
266
+ if (run === undefined) throw new Error(`no run recorded for #${issue}`);
267
+ const path = run.sessionFile;
268
+ // Claimed but not yet started, or an attempt whose session never opened one.
269
+ if (path === undefined) throw new Error(`no transcript yet (state: ${run.state})`);
270
+
271
+ const fd = openSync(path, "r");
272
+ try {
273
+ let offset = 0;
274
+ let pending = Buffer.alloc(0);
275
+ let lastChange = Date.now();
276
+
277
+ for (;;) {
278
+ let size = offset;
279
+ try {
280
+ size = statSync(path).size;
281
+ } catch {
282
+ // A transcript that vanishes mid-follow is not worth crashing over.
283
+ // The run's own state, below, is what decides when this command ends.
284
+ }
285
+ // Shorter than what we have already read means truncated or replaced;
286
+ // resuming from the old offset would read the middle of another file.
287
+ if (size < offset) {
288
+ offset = 0;
289
+ pending = Buffer.alloc(0);
290
+ }
291
+ if (size > offset) {
292
+ const chunk = Buffer.allocUnsafe(size - offset);
293
+ const read = readSync(fd, chunk, 0, chunk.length, offset);
294
+ offset += read;
295
+ // Split on newlines as bytes, not as text: a UTF-8 sequence straddling
296
+ // a read boundary would be mangled by decoding each chunk on its own.
297
+ pending = Buffer.concat([pending, chunk.subarray(0, read)]);
298
+ for (;;) {
299
+ const nl = pending.indexOf(0x0a);
300
+ if (nl < 0) break;
301
+ const rendered = formatTranscriptLine(pending.subarray(0, nl).toString("utf8"));
302
+ pending = pending.subarray(nl + 1);
303
+ if (rendered !== undefined) process.stdout.write(`${rendered}\n`);
304
+ }
305
+ if (read > 0) lastChange = Date.now();
306
+ }
307
+
308
+ // Re-read this exact run every poll — not `latestRun`, which would jump
309
+ // to a retry started meanwhile and report its state against the wrong
310
+ // transcript. The daemon writes the row from another process, so looking
311
+ // is the only way to notice the run finished.
312
+ const state = store.getRun(run.id)?.state ?? run.state;
313
+ if (!LIVE_STATES.includes(state) && Date.now() - lastChange >= TAIL_QUIET_MS) {
314
+ process.stdout.write(`run ended: ${state}\n`);
315
+ return;
316
+ }
317
+ await new Promise<void>((resolve) => setTimeout(resolve, TAIL_POLL_MS));
318
+ }
319
+ } finally {
320
+ closeSync(fd);
321
+ }
322
+ } finally {
323
+ store.close();
324
+ }
325
+ }
326
+
111
327
  const argv = process.argv.slice(2);
112
328
  const cmd = argv[0];
113
329
 
114
330
  try {
115
331
  switch (cmd) {
116
332
  case "daemon": {
117
- await runDaemon({
118
- once: argv.includes("--once"),
119
- port: portFlag(argv),
120
- project: flag(argv, "project"),
121
- });
333
+ // Until now only `lifecycle.startDaemon()` — the spawn path — wrote the
334
+ // pidfile, which left a daemon started in the foreground (which is how
335
+ // systemd runs it) invisible twice over: `omp-conductor status` reported
336
+ // no daemon at all, and a `daemon --once` drill run beside it saw
337
+ // `livingDaemon() === undefined`, concluded nothing else was dispatching,
338
+ // and reconciled the live daemon's in-flight runs as orphans. Writing the
339
+ // record here closes both holes.
340
+ const once = argv.includes("--once");
341
+ const port = portFlag(argv);
342
+ const project = flag(argv, "project");
343
+
344
+ // `--once` registers nothing, on purpose. It is precisely the single-tick
345
+ // drill the orphan guard exists to protect, so a drill that announced
346
+ // itself as the daemon would be the process that misleads the next reader
347
+ // — and would clear the real daemon's record on its way out.
348
+ if (once) {
349
+ await runDaemon({ once, port, project });
350
+ break;
351
+ }
352
+
353
+ const running = livingDaemon();
354
+ if (running !== undefined && running.pid !== process.pid) {
355
+ process.stderr.write(`omp-conductor: another daemon is alive (pid ${running.pid}); stop it first\n`);
356
+ process.exit(1);
357
+ }
358
+
359
+ // A living record that already names this pid was written by the `start`
360
+ // that spawned us, and it knows the log file our stdout is really going
361
+ // to. Replacing it with a guess would be a downgrade.
362
+ if (running === undefined) {
363
+ writeRecord({
364
+ pid: process.pid,
365
+ port: port ?? DEFAULT_PORT,
366
+ startedAt: Date.now(),
367
+ logFile: FOREGROUND_LOG,
368
+ ...(project === undefined ? {} : { project }),
369
+ });
370
+ }
371
+
372
+ try {
373
+ await runDaemon({ once, port, project });
374
+ } finally {
375
+ // The record names a pid that is about to stop existing. Leaving it
376
+ // behind makes the next reader probe a ghost before believing us.
377
+ clearRecord();
378
+ }
122
379
  break;
123
380
  }
124
381
 
@@ -159,7 +416,28 @@ try {
159
416
 
160
417
  case "status": {
161
418
  const snapshot = formatStatus(statusSnapshot(flag(argv, "project")));
162
- process.stdout.write(`${snapshot}\n\n${await daemonSection()}\n`);
419
+ const stalled = stallLine();
420
+ process.stdout.write(`${snapshot}\n\n${await daemonSection()}\n${stalled === undefined ? "" : `\n${stalled}\n`}`);
421
+ break;
422
+ }
423
+
424
+ case "tail": {
425
+ const issue = issueArg("tail", argv[1]);
426
+ await tailRun(findProject(loadConfig(), flag(argv, "project")).name, issue);
427
+ break;
428
+ }
429
+
430
+ case "unblock": {
431
+ const issue = issueArg("unblock", argv[1]);
432
+ const cfg = loadConfig();
433
+ const project = findProject(cfg, flag(argv, "project"));
434
+ const store = openStore(dbPath());
435
+ try {
436
+ const outcome = await unblockIssue(project, makeTracker(project), store, issue);
437
+ process.stdout.write(`${formatUnblock(issue, outcome, project, resolveCaps(project, cfg.defaults))}\n`);
438
+ } finally {
439
+ store.close();
440
+ }
163
441
  break;
164
442
  }
165
443
 
package/src/config.ts CHANGED
@@ -16,9 +16,12 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, wri
16
16
  import { homedir } from "node:os";
17
17
  import { dirname, join } from "node:path";
18
18
  import {
19
+ AUTHORITY_HOLDERS,
19
20
  CONFIG_VERSION,
21
+ DEFAULT_AUTHORITY,
20
22
  DEFAULT_CAPS,
21
23
  DEFAULT_REPORT_SCOPE,
24
+ ORCHESTRATOR_MODES,
22
25
  READABLE_CONFIG_VERSIONS,
23
26
  REPORT_SCOPES,
24
27
  type Caps,
@@ -43,8 +46,10 @@ type Raw = { readonly [key: string]: unknown };
43
46
  /** Derived from the data so a new `Caps` field cannot be silently ignored. */
44
47
  const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[];
45
48
 
46
- /** Quoted for error messages, from the same data the guard below reads. */
47
- const REPORT_SCOPE_LIST = REPORT_SCOPES.map((s) => `"${s}"`).join(" or ");
49
+ /** Quoted for error messages, from the same data the guards below read. */
50
+ const REPORT_SCOPE_LIST = quoteList(REPORT_SCOPES);
51
+ const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS);
52
+ const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
48
53
 
49
54
  /** `owner/repo`, the only tracker spelling `gh` accepts without a host. */
50
55
  const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
@@ -267,13 +272,8 @@ function normalizeProject(
267
272
 
268
273
  const stateLabels = raw["stateLabels"] as Raw | undefined;
269
274
 
270
- const escalationIn = raw["escalation"] as Raw | undefined;
271
- const chatId = escalationIn?.["telegramChatId"];
272
- const escalation: ProjectConfig["escalation"] = {
273
- // Absent means "yes, still tell me": a silently stuck run is the worst case.
274
- fallbackToIssueComment: escalationIn?.["fallbackToIssueComment"] !== false,
275
- };
276
- if (nonEmptyString(chatId)) escalation.telegramChatId = chatId;
275
+ const escalation = normalizeEscalation(raw["escalation"], label, problems);
276
+ const authority = normalizeAuthority(raw["authority"], label, problems);
277
277
 
278
278
  const caps = coerceCaps(raw["caps"], `${label}: caps`, problems, legacyCaps);
279
279
  const reporting = normalizeReporting(raw["reporting"], label, problems);
@@ -298,6 +298,7 @@ function normalizeProject(
298
298
  caps,
299
299
  ...(workerModel === undefined ? {} : { workerModel }),
300
300
  escalation,
301
+ authority,
301
302
  reporting,
302
303
  workspaceRoot: expandHome(pickString(raw["workspaceRoot"], join(stateDir(), "worktrees"))),
303
304
  mirrorRoot: expandHome(pickString(raw["mirrorRoot"], join(stateDir(), "mirrors"))),
@@ -326,14 +327,92 @@ function normalizeReporting(parsed: unknown, label: string, problems: string[]):
326
327
  problems.push(`${label}: reporting has unknown key(s): ${unknownKeys.join(", ")}`);
327
328
  }
328
329
 
329
- const declared = raw["scope"];
330
- if (declared === undefined) return { scope: DEFAULT_REPORT_SCOPE };
331
- const scope = REPORT_SCOPES.find((s) => s === declared);
332
- if (scope === undefined) {
333
- problems.push(`${label}: reporting.scope must be ${REPORT_SCOPE_LIST}, found ${JSON.stringify(declared)}`);
334
- return { scope: DEFAULT_REPORT_SCOPE };
330
+ return {
331
+ scope: pickLiteral(
332
+ raw["scope"],
333
+ REPORT_SCOPES,
334
+ DEFAULT_REPORT_SCOPE,
335
+ `${label}: reporting.scope`,
336
+ REPORT_SCOPE_LIST,
337
+ problems,
338
+ ),
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Who triages escalations, and how they are delivered when nobody answers.
344
+ *
345
+ * `orchestrator` is validated rather than folded to the default for the reason
346
+ * `reporting.scope` is: a misspelt `"externl"` that quietly resolved to
347
+ * `"embedded"` would start a second brain beside the operator's own session,
348
+ * and both of them would triage the same issue from different transcripts.
349
+ */
350
+ function normalizeEscalation(parsed: unknown, label: string, problems: string[]): ProjectConfig["escalation"] {
351
+ let raw: Raw = {};
352
+ if (parsed !== undefined) {
353
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) raw = parsed as Raw;
354
+ else problems.push(`${label}: escalation must be an object`);
355
+ }
356
+
357
+ const escalation: ProjectConfig["escalation"] = {
358
+ // Absent means "yes, still tell me": a silently stuck run is the worst case.
359
+ fallbackToIssueComment: raw["fallbackToIssueComment"] !== false,
360
+ orchestrator: pickLiteral(
361
+ raw["orchestrator"],
362
+ ORCHESTRATOR_MODES,
363
+ "embedded",
364
+ `${label}: escalation.orchestrator`,
365
+ ORCHESTRATOR_MODE_LIST,
366
+ problems,
367
+ ),
368
+ };
369
+ const chatId = raw["telegramChatId"];
370
+ if (nonEmptyString(chatId)) escalation.telegramChatId = chatId;
371
+ return escalation;
372
+ }
373
+
374
+ /**
375
+ * Who lands PRs and who cuts releases. Both default to the human: this is the
376
+ * one config value that decides whether an unattended session may write to a
377
+ * main branch, so it is granted explicitly or not at all.
378
+ *
379
+ * Unknown keys are rejected outright, as in `reporting` and for the same
380
+ * reason: the object has exactly two members, so an unrecognised one is a typo
381
+ * every time — and a `authority: { merges: "orchestrator" }` that loaded
382
+ * cleanly would read as delegated while the orchestrator was still told to keep
383
+ * its hands off.
384
+ */
385
+ function normalizeAuthority(parsed: unknown, label: string, problems: string[]): ProjectConfig["authority"] {
386
+ if (parsed === undefined) return { ...DEFAULT_AUTHORITY };
387
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
388
+ problems.push(`${label}: authority must be an object with "merge" and "release" of ${AUTHORITY_HOLDER_LIST}`);
389
+ return { ...DEFAULT_AUTHORITY };
335
390
  }
336
- return { scope };
391
+ const raw = parsed as Raw;
392
+
393
+ const unknownKeys = Object.keys(raw).filter((k) => k !== "merge" && k !== "release");
394
+ if (unknownKeys.length > 0) {
395
+ problems.push(`${label}: authority has unknown key(s): ${unknownKeys.join(", ")}`);
396
+ }
397
+
398
+ return {
399
+ merge: pickLiteral(
400
+ raw["merge"],
401
+ AUTHORITY_HOLDERS,
402
+ DEFAULT_AUTHORITY.merge,
403
+ `${label}: authority.merge`,
404
+ AUTHORITY_HOLDER_LIST,
405
+ problems,
406
+ ),
407
+ release: pickLiteral(
408
+ raw["release"],
409
+ AUTHORITY_HOLDERS,
410
+ DEFAULT_AUTHORITY.release,
411
+ `${label}: authority.release`,
412
+ AUTHORITY_HOLDER_LIST,
413
+ problems,
414
+ ),
415
+ };
337
416
  }
338
417
 
339
418
  function normalizeRepos(parsed: unknown, label: string, problems: string[]): Record<string, RepoTarget> {
@@ -439,6 +518,38 @@ function pickString(v: unknown, fallback: string): string {
439
518
  return nonEmptyString(v) ? v : fallback;
440
519
  }
441
520
 
521
+ /** Quoted alternatives for an error message, from the same data the guard reads. */
522
+ function quoteList(values: readonly string[]): string {
523
+ return values.map((v) => `"${v}"`).join(" or ");
524
+ }
525
+
526
+ /**
527
+ * One rule for "a declared literal out of a closed set, else the documented
528
+ * default", used by every such field here.
529
+ *
530
+ * Absent takes the default silently; a value outside the set is always
531
+ * reported and never folded. Each of these sets decides something the operator
532
+ * would otherwise believe they had configured — who merges, who triages, how
533
+ * loud the fleet is — and a typo that resolves to the default reads exactly
534
+ * like a deliberate choice in the file afterwards.
535
+ */
536
+ function pickLiteral<T extends string>(
537
+ v: unknown,
538
+ allowed: readonly T[],
539
+ fallback: T,
540
+ field: string,
541
+ quoted: string,
542
+ problems: string[],
543
+ ): T {
544
+ if (v === undefined) return fallback;
545
+ const hit = allowed.find((a) => a === v);
546
+ if (hit === undefined) {
547
+ problems.push(`${field} must be ${quoted}, found ${JSON.stringify(v)}`);
548
+ return fallback;
549
+ }
550
+ return hit;
551
+ }
552
+
442
553
  /** `~/x` in a hand-written config must not create a literal `~` directory. */
443
554
  function expandHome(p: string): string {
444
555
  if (p === "~") return homedir();