omp-conductor 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/src/setup.ts ADDED
@@ -0,0 +1,644 @@
1
+ /**
2
+ * The onboarding core behind `/conductor setup`.
3
+ *
4
+ * Everything here is headless and synchronously testable: the plugin owns the
5
+ * dialogs, this module owns the decisions. That split is the point — a wizard
6
+ * whose logic lives inside UI callbacks can only be verified by driving a TUI,
7
+ * so in practice it never is, and the first thing a new user touches is the
8
+ * least tested code in the package.
9
+ *
10
+ * Two functions here mutate something outside the process: `createMissingLabels`
11
+ * and `writeOrchestratorBrief`. Everything else reads, or computes. That is what
12
+ * lets the plugin show a complete plan before asking for consent, and it is a
13
+ * property worth preserving — check it before adding a function.
14
+ *
15
+ * ponytail: `gh` is shelled out to per call rather than shared with the tracker
16
+ * adapter's private `gh()`, which throws on non-zero exit. Setup needs the
17
+ * opposite: an unauthenticated or unreachable `gh` is a finding to report in the
18
+ * plan, not an exception thrown mid-prompt. The ceiling is one duplicated 15-line
19
+ * spawn helper; the upgrade path, if a third caller appears, is to lift both into
20
+ * `src/gh.ts` exposing `gh()` (throwing) over `ghTry()` (classifying).
21
+ */
22
+
23
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { dirname, join } from "node:path";
26
+ import { configPath, resolveCaps, stateDir } from "./config.ts";
27
+ import {
28
+ CONFIG_VERSION,
29
+ DEFAULT_CAPS,
30
+ type Caps,
31
+ type ConductorConfig,
32
+ type ProjectConfig,
33
+ type ReportScope,
34
+ type RepoTarget,
35
+ } from "./types.ts";
36
+ import { renderBrief } from "./worker.ts";
37
+
38
+ /**
39
+ * Every decision the wizard needs, in one plain object. Collected by the UI,
40
+ * consumed by `buildConfig`, so the prompt order can change without touching
41
+ * the shape of the config that comes out.
42
+ */
43
+ export interface SetupAnswers {
44
+ projectName: string;
45
+ /** Tracker repo as `owner/repo` — the only spelling `gh` takes without a host. */
46
+ trackerRepo: string;
47
+ queueLabel: string;
48
+ stateLabels: { inProgress: string; blocked: string; failed: string };
49
+ routingLabelPrefix: string;
50
+ targetRepos: { name: string; cloneUrl: string; defaultBranch: string; gates: { cmd: string; cwd: string }[] }[];
51
+ caps: Partial<Caps>;
52
+ /**
53
+ * Model pattern for worker sessions, in omp's model/role syntax. Absent means
54
+ * the harness default, which is the answer for anyone who has not deliberately
55
+ * pinned one.
56
+ */
57
+ workerModel?: string;
58
+ telegramChatId?: string;
59
+ fallbackToIssueComment: boolean;
60
+ /** How loud the supervising orchestrator session should be. */
61
+ reportScope: ReportScope;
62
+ /**
63
+ * Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not
64
+ * part of the config — the brief is the operator's file, and the conductor
65
+ * never reads it back — but it is a decision the wizard has to carry from the
66
+ * prompt that asked it to the step that acts on it.
67
+ */
68
+ writeOrchestratorBrief: boolean;
69
+ }
70
+
71
+ /** What `gh auth status` says the active token may do. */
72
+ export interface ScopeCheck {
73
+ ok: boolean;
74
+ login?: string;
75
+ scopes: string[];
76
+ missing: string[];
77
+ }
78
+
79
+ /** One tracker label the daemon depends on, and whether it is already there. */
80
+ export interface LabelPlan {
81
+ name: string;
82
+ /** Six hex digits, no leading `#` — what `gh label create --color` wants. */
83
+ colour: string;
84
+ description: string;
85
+ exists: boolean;
86
+ }
87
+
88
+ /** Defaults the wizard pre-fills. Exported so the prompts and this module
89
+ * cannot drift apart: one spelling of "ready-for-agent" in the package. */
90
+ export const SETUP_DEFAULTS = {
91
+ queueLabel: "ready-for-agent",
92
+ stateLabels: { inProgress: "agent:in-progress", blocked: "agent:blocked", failed: "agent:failed" },
93
+ routingLabelPrefix: "repo:",
94
+ defaultBranch: "main",
95
+ } as const;
96
+
97
+ /**
98
+ * The two report scopes as the operator meets them, described once. The wizard
99
+ * shows these labels, the plan summary quotes the description, and the rendered
100
+ * brief spells the same two options out — so "material" cannot come to mean one
101
+ * thing in the dialog and another in the session that has to honour it.
102
+ */
103
+ export const REPORT_SCOPE_CHOICES: readonly { scope: ReportScope; label: string; description: string }[] = [
104
+ {
105
+ scope: "material",
106
+ label: "Material events",
107
+ description: "escalations, plus green PRs, second failures, and anything that stops the fleet",
108
+ },
109
+ {
110
+ scope: "escalations",
111
+ label: "Escalations only",
112
+ description: "escalations when they happen, plus one daily digest — silent otherwise",
113
+ },
114
+ ];
115
+
116
+ /** The operator's own brief, rendered into the project's workspace root. */
117
+ export const ORCHESTRATOR_BRIEF_NAME = "ORCHESTRATOR.md";
118
+
119
+ /** Shipped in `files[]`, so this resolves in an installed package too. */
120
+ const ORCHESTRATOR_TEMPLATE_PATH = join(import.meta.dir, "briefs", "orchestrator.md");
121
+
122
+ /**
123
+ * `repo` writes labels and closes issues; `project` moves cards on the board.
124
+ * Both are load-bearing for an unattended loop, so a missing one is reported
125
+ * rather than discovered at 03:00 as a run that claimed work it cannot label.
126
+ */
127
+ const REQUIRED_SCOPES = ["repo", "project"] as const;
128
+
129
+ /** GitHub's own palette, so the tracker reads at a glance: green means queued,
130
+ * blue means moving, amber means waiting on you, red means it gave up. */
131
+ const LABEL_COLOURS = {
132
+ queue: "0e8a16",
133
+ inProgress: "1d76db",
134
+ blocked: "fbca04",
135
+ failed: "b60205",
136
+ } as const;
137
+
138
+ interface GhResult {
139
+ code: number;
140
+ stdout: string;
141
+ stderr: string;
142
+ }
143
+
144
+ /**
145
+ * Runs `gh` and classifies instead of throwing. stdin is a closed stream so a
146
+ * command that would read it sees EOF rather than hanging on an open pipe.
147
+ */
148
+ async function gh(argv: string[]): Promise<GhResult> {
149
+ try {
150
+ const proc = Bun.spawn(["gh", ...argv], {
151
+ stdin: new Blob([""]),
152
+ stdout: "pipe",
153
+ stderr: "pipe",
154
+ });
155
+ const [stdout, stderr, code] = await Promise.all([
156
+ new Response(proc.stdout).text(),
157
+ new Response(proc.stderr).text(),
158
+ proc.exited,
159
+ ]);
160
+ return { code, stdout, stderr };
161
+ } catch (err) {
162
+ // `gh` missing from PATH lands here. A diagnosis, not a crash: the wizard
163
+ // is mid-conversation with a human and should say what is wrong.
164
+ return { code: 127, stdout: "", stderr: err instanceof Error ? err.message : String(err) };
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Bounds `gh` stderr to the two lines that carry the diagnosis. A durable
170
+ * contract rather than a rename: every error this module raises passes through
171
+ * here, so an unexpectedly chatty `gh` build can never dump an arbitrary
172
+ * subprocess transcript into a message the caller shows or logs.
173
+ */
174
+ function briefly(stderr: string): string {
175
+ const lines = stderr
176
+ .split("\n")
177
+ .map((l) => l.trim())
178
+ .filter((l) => l.length > 0);
179
+ return lines.slice(0, 2).join(" / ") || "(no output)";
180
+ }
181
+
182
+ /**
183
+ * Reads the active `gh` token's scopes without ever handling the token itself.
184
+ *
185
+ * `gh auth status` masks the token in its own output and this function returns
186
+ * only the parsed scope names and login, so no credential can escape through
187
+ * the return value, a log line, or a thrown message — hence the deliberate
188
+ * absence of any `throw` here.
189
+ *
190
+ * ponytail: the first `Token scopes:` line wins. A `gh` configured for both
191
+ * github.com and a GHES host prints one block each, and this credits the first.
192
+ * Upgrade path: pass `--hostname` once the tracker learns about non-default hosts.
193
+ */
194
+ export async function checkTokenScopes(): Promise<ScopeCheck> {
195
+ // Older gh writes the status block to stderr, newer to stdout; read both
196
+ // rather than guessing at the installed version.
197
+ const r = await gh(["auth", "status"]);
198
+ const text = `${r.stdout}\n${r.stderr}`;
199
+
200
+ const scopeLine = /Token scopes:\s*(.*)/.exec(text);
201
+ const scopes = (scopeLine?.[1] ?? "")
202
+ .split(",")
203
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
204
+ .filter((s) => s.length > 0 && s !== "none");
205
+
206
+ // "account <login>" is gh >= 2.40, "as <login>" everything before it.
207
+ const loginMatch = /Logged in to \S+ (?:account|as) (\S+)/.exec(text);
208
+ const login = loginMatch?.[1];
209
+
210
+ const missing = REQUIRED_SCOPES.filter((s) => !scopes.includes(s));
211
+ const check: ScopeCheck = { ok: r.code === 0 && missing.length === 0, scopes, missing };
212
+ if (login !== undefined) check.login = login;
213
+ return check;
214
+ }
215
+
216
+ /**
217
+ * The four labels the loop reads and writes, and which already exist. Read-only:
218
+ * this is what the operator is shown before being asked to consent to creation.
219
+ */
220
+ export async function planLabels(trackerRepo: string, a: SetupAnswers): Promise<LabelPlan[]> {
221
+ const wanted: Omit<LabelPlan, "exists">[] = [
222
+ {
223
+ name: a.queueLabel,
224
+ colour: LABEL_COLOURS.queue,
225
+ description: "Signed off by a human as ready for the conductor to claim",
226
+ },
227
+ {
228
+ name: a.stateLabels.inProgress,
229
+ colour: LABEL_COLOURS.inProgress,
230
+ description: "A conductor worker is running on this issue",
231
+ },
232
+ {
233
+ name: a.stateLabels.blocked,
234
+ colour: LABEL_COLOURS.blocked,
235
+ description: "Parked by the conductor — waiting on a human answer",
236
+ },
237
+ {
238
+ name: a.stateLabels.failed,
239
+ colour: LABEL_COLOURS.failed,
240
+ description: "The conductor gave up on this issue after its retry budget",
241
+ },
242
+ ];
243
+
244
+ const existing = await listLabelNames(trackerRepo);
245
+
246
+ // GitHub label names are unique case-insensitively, so an operator who answers
247
+ // "Ready-For-Agent" against an existing "ready-for-agent" must not be told a
248
+ // creation is pending that would then fail.
249
+ const seen = new Set<string>();
250
+ const plan: LabelPlan[] = [];
251
+ for (const w of wanted) {
252
+ const key = w.name.toLowerCase();
253
+ if (seen.has(key)) continue;
254
+ seen.add(key);
255
+ plan.push({ ...w, exists: existing.has(key) });
256
+ }
257
+ return plan;
258
+ }
259
+
260
+ /** Lower-cased label names currently on the repo. Throws rather than guessing:
261
+ * a repo whose labels cannot be listed cannot be serviced either. */
262
+ async function listLabelNames(trackerRepo: string): Promise<Set<string>> {
263
+ const r = await gh(["label", "list", "--repo", trackerRepo, "--limit", "200", "--json", "name"]);
264
+ if (r.code !== 0) {
265
+ throw new Error(`Cannot list labels in ${trackerRepo}: ${briefly(r.stderr)}`);
266
+ }
267
+ let parsed: unknown;
268
+ try {
269
+ parsed = JSON.parse(r.stdout || "[]") as unknown;
270
+ } catch {
271
+ throw new Error(`Cannot read the label list for ${trackerRepo}: gh returned output that is not JSON.`);
272
+ }
273
+ const names = new Set<string>();
274
+ if (Array.isArray(parsed)) {
275
+ for (const entry of parsed) {
276
+ const name = (entry as { name?: unknown } | null)?.name;
277
+ if (typeof name === "string" && name.length > 0) names.add(name.toLowerCase());
278
+ }
279
+ }
280
+ return names;
281
+ }
282
+
283
+ /**
284
+ * The single mutating step in this module, and the plugin calls it only after
285
+ * the operator has confirmed the printed plan.
286
+ *
287
+ * Returns the names actually created — a label that already existed is not in
288
+ * the list, because reporting it as created would be a lie the operator would
289
+ * have to verify by hand. A concurrent creation (or a label list that was
290
+ * stale by the time we got here) is success, not an error: the end state the
291
+ * caller asked for holds either way.
292
+ */
293
+ export async function createMissingLabels(trackerRepo: string, plan: LabelPlan[]): Promise<string[]> {
294
+ const created: string[] = [];
295
+ for (const label of plan) {
296
+ if (label.exists) continue;
297
+ const r = await gh([
298
+ "label",
299
+ "create",
300
+ label.name,
301
+ "--repo",
302
+ trackerRepo,
303
+ "--color",
304
+ label.colour,
305
+ "--description",
306
+ label.description,
307
+ ]);
308
+ if (r.code === 0) {
309
+ created.push(label.name);
310
+ continue;
311
+ }
312
+ if (/already exists/i.test(r.stderr)) continue;
313
+ throw new Error(`Could not create label "${label.name}" in ${trackerRepo}: ${briefly(r.stderr)}`);
314
+ }
315
+ return created;
316
+ }
317
+
318
+ /**
319
+ * The single `ProjectConfig` the answers describe.
320
+ *
321
+ * Split out of `buildConfig` so the plan summary can show the exact project
322
+ * that would be written — including the derived worktree and mirror paths —
323
+ * without assembling a whole config and indexing back into its array.
324
+ */
325
+ function buildProject(a: SetupAnswers): ProjectConfig {
326
+ const dir = stateDir();
327
+
328
+ const repos: Record<string, RepoTarget> = {};
329
+ for (const r of a.targetRepos) {
330
+ repos[r.name] = {
331
+ name: r.name,
332
+ cloneUrl: r.cloneUrl,
333
+ defaultBranch: r.defaultBranch,
334
+ gates: r.gates.map((g) => ({ cmd: g.cmd, cwd: g.cwd })),
335
+ };
336
+ }
337
+
338
+ const escalation: ProjectConfig["escalation"] = { fallbackToIssueComment: a.fallbackToIssueComment };
339
+ if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) {
340
+ escalation.telegramChatId = a.telegramChatId.trim();
341
+ }
342
+
343
+ const caps: Partial<Caps> = {};
344
+ // Drops `undefined` members so an unanswered cap is absent from the JSON
345
+ // rather than present-and-null, which the validator would have to reject.
346
+ for (const [key, value] of Object.entries(a.caps) as [keyof Caps, number | undefined][]) {
347
+ if (typeof value === "number") caps[key] = value;
348
+ }
349
+
350
+ return {
351
+ name: a.projectName,
352
+ tracker: { kind: "github", repo: a.trackerRepo },
353
+ queueLabel: a.queueLabel,
354
+ stateLabels: { ...a.stateLabels },
355
+ routing: { labelPrefix: a.routingLabelPrefix, repos },
356
+ caps,
357
+ ...(a.workerModel !== undefined && a.workerModel.trim().length > 0
358
+ ? { workerModel: a.workerModel.trim() }
359
+ : {}),
360
+ escalation,
361
+ reporting: { scope: a.reportScope },
362
+ // Both under the state dir so one `rm -rf ~/.omp/conductor` is a complete
363
+ // uninstall, and neither can land in a repo the daemon then tries to commit.
364
+ workspaceRoot: join(dir, "worktrees"),
365
+ mirrorRoot: join(dir, "mirrors"),
366
+ };
367
+ }
368
+
369
+ /**
370
+ * Turns answers into a config the real validator accepts.
371
+ *
372
+ * `existing` is how re-running setup for one project stays safe: a same-named
373
+ * project is replaced in place (keeping its position), everything else is
374
+ * carried through untouched. Rebuilding from answers alone would silently
375
+ * delete a neighbour's entire configuration.
376
+ */
377
+ export function buildConfig(a: SetupAnswers, existing?: ConductorConfig): ConductorConfig {
378
+ const project = buildProject(a);
379
+ const previous = existing?.projects ?? [];
380
+
381
+ return {
382
+ version: CONFIG_VERSION,
383
+ // Answered caps land on the project, not here: on a re-run the global block
384
+ // is also the baseline every other project inherits, and one project's
385
+ // answers must never quietly re-budget its neighbour. An existing global
386
+ // block is kept as-is so a hand-tuned default survives setup.
387
+ defaults: { ...DEFAULT_CAPS, ...(existing?.defaults ?? {}) },
388
+ projects: previous.some((p) => p.name === project.name)
389
+ ? previous.map((p) => (p.name === project.name ? project : p))
390
+ : [...previous, project],
391
+ };
392
+ }
393
+
394
+ /**
395
+ * Where the operator's own brief lands: beside the worktrees, under the state
396
+ * directory, so it is on the same disk the fleet already owns and survives a
397
+ * reinstall of the package. Derived from the answers rather than fixed, so a
398
+ * project that ever gains a chosen workspace root keeps its brief with it.
399
+ */
400
+ export function orchestratorBriefPath(a: SetupAnswers): string {
401
+ return join(buildProject(a).workspaceRoot, ORCHESTRATOR_BRIEF_NAME);
402
+ }
403
+
404
+ /**
405
+ * The shipped template with this project's real values in it.
406
+ *
407
+ * Only the coordinates and the chosen scope are substituted: the policy text is
408
+ * left exactly as shipped, because from here on the file is the operator's to
409
+ * edit and nothing in this package reads it back.
410
+ */
411
+ export function renderOrchestratorBrief(a: SetupAnswers): string {
412
+ return renderBrief(readFileSync(ORCHESTRATOR_TEMPLATE_PATH, "utf8"), {
413
+ PROJECT: a.projectName,
414
+ TRACKER_REPO: a.trackerRepo,
415
+ QUEUE_LABEL: a.queueLabel,
416
+ REPORT_SCOPE: a.reportScope,
417
+ });
418
+ }
419
+
420
+ /**
421
+ * Writes the rendered brief and returns where it went.
422
+ *
423
+ * Unconditional by design: the "do not clobber my edits" decision belongs to the
424
+ * operator, is asked in the wizard, and arrives here as
425
+ * `answers.writeOrchestratorBrief`. A second existence check in here would make
426
+ * that dialog's answer un-actionable — an operator who says "yes, overwrite it"
427
+ * must get an overwrite.
428
+ */
429
+ export function writeOrchestratorBrief(a: SetupAnswers): string {
430
+ const path = orchestratorBriefPath(a);
431
+ mkdirSync(dirname(path), { recursive: true });
432
+ writeFileSync(path, renderOrchestratorBrief(a));
433
+ return path;
434
+ }
435
+
436
+ /**
437
+ * What can be told about an omp-telegram install without opening a socket.
438
+ *
439
+ * A named contract rather than an inferred one: consumers depend on the shape
440
+ * of the answer, not on the identity of the function that produced it.
441
+ */
442
+ export interface TelegramPresence {
443
+ /** An omp-telegram state directory exists at all. */
444
+ available: boolean;
445
+ stateDir: string;
446
+ /** A bot token line is present. Never, under any circumstance, its value. */
447
+ hasToken: boolean;
448
+ /** Set only when exactly one chat is paired, so the wizard cannot offer to
449
+ * page a chat that belongs to somebody else. */
450
+ pairedOwnerId?: string;
451
+ }
452
+
453
+ /**
454
+ * Whether omp-telegram is installed beside us, and who it is paired with.
455
+ *
456
+ * Only ever reports the *presence* of a token, never its value — a wizard that
457
+ * echoes a bot token into a plan summary has leaked it to the scrollback, the
458
+ * terminal's history and any screen recording of the session.
459
+ */
460
+ export function detectTelegram(): TelegramPresence {
461
+ const override = process.env["OMP_TELEGRAM_STATE_DIR"]?.trim();
462
+ const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
463
+
464
+ const envPath = join(dir, ".env");
465
+ const accessPath = join(dir, "access.json");
466
+ const available = existsSync(envPath) || existsSync(accessPath);
467
+ if (!available) return { available: false, stateDir: dir, hasToken: false };
468
+
469
+ const result: TelegramPresence = {
470
+ available: true,
471
+ stateDir: dir,
472
+ hasToken: hasTelegramToken(envPath),
473
+ };
474
+
475
+ const owner = pairedOwner(accessPath);
476
+ if (owner !== undefined) result.pairedOwnerId = owner;
477
+ return result;
478
+ }
479
+
480
+ /** True when `.env` carries a non-empty `TELEGRAM_BOT_TOKEN`. The value is
481
+ * compared against emptiness and then dropped on the floor. */
482
+ function hasTelegramToken(envPath: string): boolean {
483
+ let raw: string;
484
+ try {
485
+ raw = readFileSync(envPath, "utf8");
486
+ } catch {
487
+ return false;
488
+ }
489
+ for (const line of raw.split("\n")) {
490
+ const trimmed = line.trim();
491
+ if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
492
+ const match = /^(?:export\s+)?TELEGRAM_BOT_TOKEN\s*=\s*(.*)$/.exec(trimmed);
493
+ if (!match) continue;
494
+ let value = (match[1] ?? "").trim();
495
+ const quote = value[0];
496
+ if (value.length >= 2 && (quote === '"' || quote === "'") && value.endsWith(quote)) {
497
+ value = value.slice(1, -1);
498
+ }
499
+ if (value.length > 0) return true;
500
+ }
501
+ return false;
502
+ }
503
+
504
+ /**
505
+ * The paired owner id, but only when there is exactly one. Several entries
506
+ * means the wizard cannot know which human owns escalations, and guessing
507
+ * would route a tier-2 page to a stranger.
508
+ */
509
+ function pairedOwner(accessPath: string): string | undefined {
510
+ let parsed: unknown;
511
+ try {
512
+ parsed = JSON.parse(readFileSync(accessPath, "utf8")) as unknown;
513
+ } catch {
514
+ return undefined;
515
+ }
516
+ const allowFrom = (parsed as { allowFrom?: unknown } | null)?.allowFrom;
517
+ if (!Array.isArray(allowFrom) || allowFrom.length !== 1) return undefined;
518
+ const only = allowFrom[0] as unknown;
519
+ if (typeof only === "string" && only.trim().length > 0) return only.trim();
520
+ if (typeof only === "number" && Number.isFinite(only)) return String(only);
521
+ return undefined;
522
+ }
523
+
524
+ /**
525
+ * Everything that would change, as plain text, with no side effects at all.
526
+ *
527
+ * This is the consent screen. If it does not name a change, the operator did
528
+ * not agree to it — so every mutating step the plugin can take afterwards is
529
+ * listed here first, including the labels that would be created and the
530
+ * transport a tier-2 page would take.
531
+ */
532
+ export function summarisePlan(
533
+ a: SetupAnswers,
534
+ scopes: ScopeCheck,
535
+ labels: LabelPlan[],
536
+ tg: TelegramPresence,
537
+ ): string {
538
+ const project = buildProject(a);
539
+ // Caps are shown against the shipped baseline: the summary describes what
540
+ // these answers mean on their own, before any hand-edited global block.
541
+ const effective = resolveCaps(project, DEFAULT_CAPS);
542
+ const states = Object.values(a.stateLabels).join(", ");
543
+
544
+ const lines: string[] = [];
545
+
546
+ if (scopes.missing.length > 0) {
547
+ lines.push(
548
+ `!! MISSING TOKEN SCOPE: ${scopes.missing.join(", ")}`,
549
+ scopes.login === undefined
550
+ ? " `gh` reported no authenticated account — run `gh auth login` first."
551
+ : ` Signed in as ${scopes.login} with: ${scopes.scopes.join(", ") || "(no scopes reported)"}`,
552
+ " Without `repo` the daemon cannot label or close issues; without `project` it cannot move cards.",
553
+ " Fix with: gh auth refresh -s repo,project",
554
+ "",
555
+ );
556
+ }
557
+
558
+ lines.push(
559
+ `project ${a.projectName}`,
560
+ `config ${configPath()}`,
561
+ `tracker ${a.trackerRepo}`,
562
+ `github user ${scopes.login ?? "(not authenticated)"}`,
563
+ `queue query open issues in ${a.trackerRepo} labelled "${a.queueLabel}", minus anything already`,
564
+ ` labelled ${states}, routed by one "${a.routingLabelPrefix}<repo>" label`,
565
+ `worktrees ${project.workspaceRoot}`,
566
+ `mirrors ${project.mirrorRoot}`,
567
+ "",
568
+ );
569
+
570
+ const toCreate = labels.filter((l) => !l.exists);
571
+ const present = labels.filter((l) => l.exists);
572
+ lines.push(
573
+ toCreate.length === 0
574
+ ? `labels all ${labels.length} already exist in ${a.trackerRepo} — nothing to create`
575
+ : `labels would CREATE ${toCreate.length} in ${a.trackerRepo}:`,
576
+ );
577
+ for (const l of toCreate) lines.push(` + ${l.name} #${l.colour} ${l.description}`);
578
+ if (present.length > 0 && toCreate.length > 0) {
579
+ lines.push(` already present: ${present.map((l) => l.name).join(", ")}`);
580
+ }
581
+
582
+ lines.push("", `routing "${a.routingLabelPrefix}<repo>" on an issue selects one of:`);
583
+ if (a.targetRepos.length === 0) {
584
+ lines.push(" (none — nothing can be routed, setup will refuse this)");
585
+ }
586
+ for (const r of a.targetRepos) {
587
+ lines.push(` ${a.routingLabelPrefix}${r.name} ${r.cloneUrl} (${r.defaultBranch})`);
588
+ for (const g of r.gates) lines.push(` gate: ${g.cmd} [cwd ${g.cwd}]`);
589
+ if (r.gates.length === 0) {
590
+ lines.push(" gate: none — nothing is verified before a push");
591
+ }
592
+ }
593
+
594
+ lines.push("", "caps (effective)");
595
+ for (const [key, value] of Object.entries(effective)) {
596
+ const answered = Object.hasOwn(project.caps, key) ? " (answered)" : "";
597
+ lines.push(` ${key.padEnd(22)}${String(value)}${answered}`);
598
+ }
599
+ if (a.workerModel !== undefined && a.workerModel.trim().length > 0) {
600
+ lines.push(` ${"worker model".padEnd(22)}${a.workerModel.trim()} (answered)`);
601
+ }
602
+
603
+ lines.push("", "escalation");
604
+ if (a.telegramChatId !== undefined && a.telegramChatId.trim().length > 0) {
605
+ lines.push(
606
+ ` tier 2 Telegram chat ${a.telegramChatId.trim()}` +
607
+ (tg.hasToken ? "" : " — WARNING: no bot token found, this will not send"),
608
+ ` via omp-telegram at ${tg.stateDir}`,
609
+ );
610
+ } else {
611
+ lines.push(
612
+ " tier 2 issue comment only",
613
+ tg.available && tg.hasToken
614
+ ? ` note omp-telegram is installed at ${tg.stateDir} but no chat was chosen`
615
+ : ` note no omp-telegram install found at ${tg.stateDir}`,
616
+ );
617
+ }
618
+ lines.push(` fallback ${a.fallbackToIssueComment ? "comment on the issue as well" : "disabled"}`);
619
+
620
+ const chosen = REPORT_SCOPE_CHOICES.find((c) => c.scope === a.reportScope);
621
+ const briefPath = orchestratorBriefPath(a);
622
+ lines.push(
623
+ "",
624
+ "reporting",
625
+ ` scope ${a.reportScope} — ${chosen?.description ?? "unknown scope"}`,
626
+ );
627
+ if (a.writeOrchestratorBrief) {
628
+ lines.push(
629
+ existsSync(briefPath)
630
+ ? ` brief would OVERWRITE ${briefPath}`
631
+ : ` brief would write ${briefPath}`,
632
+ " yours to edit afterwards — release policy lives there, not in this package",
633
+ );
634
+ } else {
635
+ lines.push(
636
+ existsSync(briefPath)
637
+ ? ` brief not written — ${briefPath} is left exactly as it is`
638
+ : ` brief not written — no orchestrator brief at ${briefPath}`,
639
+ " the package still stops at green PRs; releases stay a human action",
640
+ );
641
+ }
642
+
643
+ return lines.join("\n");
644
+ }