omp-conductor 0.15.11 → 0.15.13

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.
Files changed (51) hide show
  1. package/REFERENCE.md +107 -60
  2. package/package.json +1 -1
  3. package/schema/config.schema.json +3 -0
  4. package/src/briefs/orchestrator.md +64 -11
  5. package/src/briefs/policy.md +19 -3
  6. package/src/briefs/worker.md +11 -8
  7. package/src/cli.ts +41 -21
  8. package/src/commands/context.ts +102 -1
  9. package/src/commands/doctor.ts +4 -2
  10. package/src/commands/intake.ts +26 -5
  11. package/src/commands/message.ts +80 -32
  12. package/src/commands/report.ts +38 -2
  13. package/src/commands/restart.ts +81 -54
  14. package/src/commands/setup.ts +61 -11
  15. package/src/commands/stop.ts +45 -22
  16. package/src/commands/upgrade-rollback.ts +9 -0
  17. package/src/config-schema.ts +9 -0
  18. package/src/config.ts +35 -1
  19. package/src/daemon.ts +588 -37
  20. package/src/dashboard/app.js +398 -59
  21. package/src/dashboard/index.html +27 -0
  22. package/src/dashboard/server.ts +219 -5
  23. package/src/dashboard/style.css +169 -1
  24. package/src/doctor.ts +419 -45
  25. package/src/escalate.ts +8 -0
  26. package/src/failure-class.ts +37 -0
  27. package/src/fleet.ts +49 -2
  28. package/src/gitops.ts +157 -0
  29. package/src/lifecycle.ts +113 -2
  30. package/src/model-fallback.ts +177 -0
  31. package/src/omp.ts +115 -13
  32. package/src/orchestrator-down.ts +231 -0
  33. package/src/orchestrator-tick.ts +108 -5
  34. package/src/orchestrator.ts +18 -4
  35. package/src/privileged.ts +10 -0
  36. package/src/release-policy.ts +373 -28
  37. package/src/session-host.ts +11 -5
  38. package/src/setup-host.ts +665 -70
  39. package/src/setup-install.ts +275 -28
  40. package/src/setup-wizard.ts +339 -126
  41. package/src/setup.ts +25 -0
  42. package/src/stop-provenance.ts +66 -0
  43. package/src/store.ts +194 -1
  44. package/src/tracker/github.ts +47 -0
  45. package/src/types.ts +182 -0
  46. package/src/upgrade.ts +110 -32
  47. package/src/verbs/protocol.ts +16 -3
  48. package/src/verbs/server.ts +27 -1
  49. package/src/wizard-ui.ts +261 -46
  50. package/src/worker.ts +24 -3
  51. package/systemd/omp-conductor.service.example +7 -3
package/src/setup.ts CHANGED
@@ -155,6 +155,14 @@ export interface SetupAnswers {
155
155
  * survive an amend of some other area (#369).
156
156
  */
157
157
  groomBelow?: number;
158
+ /**
159
+ * Ordered fallback worker models and the provider-failure threshold that
160
+ * starts them. The wizard never asks for either — they are hand-edited —
161
+ * so they exist here only to survive an amend of some other area (#369,
162
+ * #286).
163
+ */
164
+ modelFallbacks?: string[];
165
+ modelFallbackThreshold?: number;
158
166
  telegramChatId?: string;
159
167
  /** Forum topic for tier-2 Telegram pages; absent keeps flat-chat 0.13 behaviour. */
160
168
  telegramTopicId?: number;
@@ -193,6 +201,13 @@ export interface SetupAnswers {
193
201
  * merge provenance gate.
194
202
  */
195
203
  recoveryMerges?: ProjectConfig["recoveryMerges"];
204
+ /**
205
+ * Hand-edited critical-base/safety markers (commit SHAs or refs) carried
206
+ * through setup unchanged, like {@link recoveryMerges}. The wizard never
207
+ * invents one; forgetting them during an unrelated amend would drop the
208
+ * stale-base interlock from a project that relies on it (#428).
209
+ */
210
+ criticalBase?: ProjectConfig["criticalBase"];
196
211
  /**
197
212
  * Whether to render `ORCHESTRATOR.md` into the project's workspace root. Not
198
213
  * part of the config — the brief is the operator's file, and the conductor
@@ -836,6 +851,10 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
836
851
  : {}),
837
852
  // A documented hand-edited key, so an unrelated amend must not delete it.
838
853
  ...(a.groomBelow === undefined ? {} : { groomBelow: a.groomBelow }),
854
+ // The failover chain is hand-edited too: dropping it on an unrelated amend
855
+ // would pin every run of the project onto a dead provider again (#286).
856
+ ...(a.modelFallbacks === undefined ? {} : { modelFallbacks: [...a.modelFallbacks] }),
857
+ ...(a.modelFallbackThreshold === undefined ? {} : { modelFallbackThreshold: a.modelFallbackThreshold }),
839
858
  escalation,
840
859
  authority: { ...a.authority },
841
860
  // Written out in full, never as the legacy string: the file then says which
@@ -847,6 +866,9 @@ export function buildProject(a: SetupAnswers): ProjectConfig {
847
866
  ...(a.recoveryMerges === undefined
848
867
  ? {}
849
868
  : { recoveryMerges: a.recoveryMerges.map((entry) => ({ ...entry })) }),
869
+ // Hand-edited safety markers carried unchanged on an unrelated amend
870
+ // (#428): dropping them would silently disarm the stale-base interlock.
871
+ ...(a.criticalBase === undefined ? {} : { criticalBase: [...a.criticalBase] }),
850
872
  // Written out even when it is the default, so an operator amending the
851
873
  // volume has a line in the file to point at. Opting into availability makes
852
874
  // the schedule explicit and daily; omitting it preserves the preset's
@@ -1037,11 +1059,14 @@ export function answersFromProject(p: ProjectConfig): SetupAnswers {
1037
1059
  // what keeps the rewritten config identical to the one that was read.
1038
1060
  if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
1039
1061
  if (p.groomBelow !== undefined) answers.groomBelow = p.groomBelow;
1062
+ if (p.modelFallbacks !== undefined) answers.modelFallbacks = [...p.modelFallbacks];
1063
+ if (p.modelFallbackThreshold !== undefined) answers.modelFallbackThreshold = p.modelFallbackThreshold;
1040
1064
  if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
1041
1065
  if (p.escalation.telegramTopicId !== undefined) answers.telegramTopicId = p.escalation.telegramTopicId;
1042
1066
  if (p.recoveryMerges !== undefined) {
1043
1067
  answers.recoveryMerges = p.recoveryMerges.map((entry) => ({ ...entry }));
1044
1068
  }
1069
+ if (p.criticalBase !== undefined) answers.criticalBase = [...p.criticalBase];
1045
1070
  if (p.reporting?.digest.at !== undefined) answers.dailyDigestAt = p.reporting.digest.at;
1046
1071
  if (p.reporting?.digest.timezone !== undefined) {
1047
1072
  answers.reportingTimezone = p.reporting.digest.timezone;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Provenance for stop/restart requests (#378): who asked, from which project,
3
+ * for which daemon, naming every affected project and its live-run count.
4
+ *
5
+ * Shared by the `stop` and `restart` verbs so a second convention cannot
6
+ * drift beside the first. Explicit facts only — caller pid/uid and session
7
+ * role, scope, control path, timestamp, reason — and specifically never raw
8
+ * argv, environment values or credentials: the incident this exists for was
9
+ * a stop nobody could attribute, and a "just serialise the command line"
10
+ * implementation would leak tokens into the audit trail.
11
+ */
12
+
13
+ import { loadConfig } from "./config.ts";
14
+ import { openStore, dbPath } from "./store.ts";
15
+ import { SESSION_ROLE_ENV, type DaemonStopDraft } from "./types.ts";
16
+
17
+ export interface StopProvenanceSpec {
18
+ /** The operator-visible control path: "cli stop", "cli restart", … */
19
+ controlPath: string;
20
+ /** Non-secret reason; a generated default is fine, never credentials. */
21
+ reason: string;
22
+ /** "global" when the request covered every project ("--all"); otherwise "project". */
23
+ scope: "global" | "project";
24
+ /** The originating project, when project-scoped. */
25
+ project?: string;
26
+ }
27
+
28
+ /**
29
+ * Build the provenance draft for one stop/restart request.
30
+ *
31
+ * `affected` is every configured project with its live-run count at request
32
+ * time, not just the targeted one: the shared daemon serves all of them, and
33
+ * a project-scoped stop still stops the siblings — the record names them
34
+ * instead of silently interrupting them.
35
+ *
36
+ * Pure: nothing is written. The command decides when to persist (at request
37
+ * entry for drain-style restarts whose signal may never arrive, and at the
38
+ * lifecycle chokepoint immediately before signalling for every mediated stop).
39
+ */
40
+ export function buildStopProvenance(spec: StopProvenanceSpec): DaemonStopDraft {
41
+ const cfg = loadConfig();
42
+ const store = openStore(dbPath());
43
+ try {
44
+ const affected = cfg.projects.map((project) => ({
45
+ project: project.name,
46
+ live: store.liveRuns(project.name).length,
47
+ }));
48
+ return {
49
+ controlPath: spec.controlPath,
50
+ callerPid: process.pid,
51
+ ...(typeof process.getuid === "function" ? { callerUid: process.getuid() } : {}),
52
+ // The daemon stamps SESSION_ROLE_ENV on every session it spawns, so a
53
+ // stop that a worker session's tools trigger names itself as a worker;
54
+ // everything else — the operator's shell, a systemd unit, a script — is
55
+ // the orchestrator surface (same convention as the reporting CLI).
56
+ role: process.env[SESSION_ROLE_ENV] === "worker" ? "worker" : "orchestrator",
57
+ scope: spec.scope,
58
+ ...(spec.project === undefined ? {} : { project: spec.project }),
59
+ affected,
60
+ reason: spec.reason,
61
+ unattributed: false,
62
+ };
63
+ } finally {
64
+ store.close();
65
+ }
66
+ }
package/src/store.ts CHANGED
@@ -24,6 +24,8 @@ import type {
24
24
  DecisionState,
25
25
  DigestBacklog,
26
26
  DispatchSummary,
27
+ DaemonStop,
28
+ DaemonStopDraft,
27
29
  FrictionAdmissionReason,
28
30
  FrictionKind,
29
31
  FrictionObservation,
@@ -38,6 +40,9 @@ import type {
38
40
  MaterialEventDraft,
39
41
  LabelOp,
40
42
  MergeLock,
43
+ OrchestratorDownMode,
44
+ OrchestratorIncident,
45
+ OrchestratorIncidentDraft,
41
46
  ReportDeliveryState,
42
47
  ReportDraft,
43
48
  ReportEnqueue,
@@ -82,6 +87,7 @@ const ACTIVE_PLACEHOLDERS = ACTIVE_STATES.map(() => "?").join(", ");
82
87
  const FRICTION_HOLD_REASONS: ReadonlySet<FrictionAdmissionReason> = new Set([
83
88
  "failed-attempts",
84
89
  "continuations",
90
+ "stale-base",
85
91
  "parent-lookup-error",
86
92
  "issue-state-lookup-error",
87
93
  "open-pr-lookup-error",
@@ -133,6 +139,7 @@ const UPDATABLE_COLUMNS: Record<string, true> = {
133
139
  failureClass: true,
134
140
  recoveryAction: true,
135
141
  recoveredAt: true,
142
+ model: true,
136
143
  };
137
144
 
138
145
  /** Everything SQLite will accept from us. */
@@ -169,6 +176,7 @@ interface RunRow {
169
176
  failureClass: string | null;
170
177
  recoveryAction: string | null;
171
178
  recoveredAt: number | null;
179
+ model: string | null;
172
180
  }
173
181
 
174
182
  /** The `base_health` table exactly as SQLite hands it back. */
@@ -324,6 +332,65 @@ function toIntakeItem(row: IntakeRow): IntakeItem {
324
332
  return item;
325
333
  }
326
334
 
335
+ /** The `orchestrator_incidents` table exactly as SQLite hands it back (#288). */
336
+ interface OrchestratorIncidentRow {
337
+ project: string;
338
+ mode: string;
339
+ cause: string | null;
340
+ since: number;
341
+ diverted: number;
342
+ }
343
+
344
+ /** NULL columns become absent properties, matching the other row converters. */
345
+ function toOrchestratorIncident(row: OrchestratorIncidentRow): OrchestratorIncident {
346
+ return {
347
+ project: row.project,
348
+ mode: row.mode as OrchestratorDownMode,
349
+ ...(row.cause === null ? {} : { cause: row.cause }),
350
+ since: row.since,
351
+ diverted: row.diverted,
352
+ };
353
+ }
354
+
355
+ /** The `daemon_stops` table exactly as SQLite hands it back (#378). */
356
+ interface DaemonStopRow {
357
+ id: string;
358
+ at: number;
359
+ controlPath: string;
360
+ callerPid: number | null;
361
+ callerUid: number | null;
362
+ role: string | null;
363
+ scope: string;
364
+ project: string | null;
365
+ daemonPid: number | null;
366
+ runtimeDir: string | null;
367
+ affected: string;
368
+ reason: string;
369
+ unattributed: number;
370
+ }
371
+
372
+ /**
373
+ * NULL columns become absent properties, matching the other row converters:
374
+ * a record read back out of the store deep-equals the one that went in.
375
+ */
376
+ function toDaemonStop(row: DaemonStopRow): DaemonStop {
377
+ return {
378
+ id: row.id,
379
+ at: row.at,
380
+ controlPath: row.controlPath,
381
+ ...(row.callerPid === null ? {} : { callerPid: row.callerPid }),
382
+ ...(row.callerUid === null ? {} : { callerUid: row.callerUid }),
383
+ ...(row.role === null ? {} : { role: row.role as SessionRole }),
384
+ scope: row.scope as "global" | "project",
385
+ ...(row.project === null ? {} : { project: row.project }),
386
+ ...(row.daemonPid === null ? {} : { daemonPid: row.daemonPid }),
387
+ ...(row.runtimeDir === null ? {} : { runtimeDir: row.runtimeDir }),
388
+ affected: JSON.parse(row.affected) as { project: string; live: number }[],
389
+ reason: row.reason,
390
+ unattributed: row.unattributed === 1,
391
+ };
392
+ }
393
+
327
394
  const SCHEMA = `
328
395
  CREATE TABLE IF NOT EXISTS runs (
329
396
  id TEXT PRIMARY KEY,
@@ -354,7 +421,8 @@ CREATE TABLE IF NOT EXISTS runs (
354
421
  report TEXT,
355
422
  failureClass TEXT,
356
423
  recoveryAction TEXT,
357
- recoveredAt INTEGER
424
+ recoveredAt INTEGER,
425
+ model TEXT
358
426
  );
359
427
  CREATE INDEX IF NOT EXISTS runs_project_issue ON runs (project, issue);
360
428
  CREATE INDEX IF NOT EXISTS runs_project_state ON runs (project, state);
@@ -624,6 +692,44 @@ CREATE TABLE IF NOT EXISTS intake_items (
624
692
  );
625
693
  CREATE INDEX IF NOT EXISTS intake_items_project_state
626
694
  ON intake_items (project, state, createdAt);
695
+ -- The embedded orchestrator's lost-liveness incident (#288): durable so a
696
+ -- daemon restarted while its orchestrator is still down rediscovers the open
697
+ -- incident instead of forgetting it, and the diverted counter accumulates
698
+ -- across the outage and across restarts. One row per project; removed on
699
+ -- recovery. since is the dedupe anchor for both the down page and its
700
+ -- closing recovery page. The mode CHECK keeps the two causes distinct.
701
+ CREATE TABLE IF NOT EXISTS orchestrator_incidents (
702
+ project TEXT PRIMARY KEY,
703
+ mode TEXT NOT NULL CHECK (mode IN ('start-failed', 'crashed')),
704
+ cause TEXT,
705
+ since INTEGER NOT NULL,
706
+ diverted INTEGER NOT NULL DEFAULT 0
707
+ );
708
+
709
+ -- Every stop/restart of the shared daemon, and who asked for it and why
710
+ -- (#378). Deliberately NOT partitioned by project: the daemon serves every
711
+ -- configured project, so a record written by one project's CLI must be
712
+ -- readable from another project's status the moment the daemon is down —
713
+ -- that cross-project read is the whole point. Written before signalling on
714
+ -- the mediated paths (cli stop/restart) and as an honest unattributed
715
+ -- fallback when the daemon receives a signal with no mediated request.
716
+ -- Explicit fields only: never raw argv, environment values or credentials.
717
+ CREATE TABLE IF NOT EXISTS daemon_stops (
718
+ id TEXT PRIMARY KEY,
719
+ at INTEGER NOT NULL,
720
+ controlPath TEXT NOT NULL,
721
+ callerPid INTEGER,
722
+ callerUid INTEGER,
723
+ role TEXT,
724
+ scope TEXT NOT NULL,
725
+ project TEXT,
726
+ daemonPid INTEGER,
727
+ runtimeDir TEXT,
728
+ affected TEXT NOT NULL,
729
+ reason TEXT NOT NULL,
730
+ unattributed INTEGER NOT NULL
731
+ );
732
+ CREATE INDEX IF NOT EXISTS daemon_stops_at ON daemon_stops (at);
627
733
  `;
628
734
 
629
735
  /**
@@ -707,6 +813,7 @@ function toRecord(row: RunRow): RunRecord {
707
813
  if (row.failureClass !== null) record.failureClass = row.failureClass as FailureClass;
708
814
  if (row.recoveryAction !== null) record.recoveryAction = row.recoveryAction as RecoveryAction;
709
815
  if (row.recoveredAt !== null) record.recoveredAt = row.recoveredAt;
816
+ if (row.model !== null) record.model = row.model;
710
817
  return record;
711
818
  }
712
819
 
@@ -1006,6 +1113,13 @@ export function openStore(dbPath: string): Store {
1006
1113
  db.exec(`ALTER TABLE runs ADD COLUMN ${name} ${type}`);
1007
1114
  }
1008
1115
  }
1116
+ // The model a run dispatched on was first written by the modelFallbacks
1117
+ // failover chain (#286). Rows predating the column are NULL, which is the
1118
+ // honest reading: only a chain-configured project records a model, and the
1119
+ // harness's own downgrade stays on the worker result, never in this column.
1120
+ if (!columns.some((column) => column.name === "model")) {
1121
+ db.exec("ALTER TABLE runs ADD COLUMN model TEXT");
1122
+ }
1009
1123
  // Existing held-notice rows predate exact digest ownership (#274). A NULL
1010
1124
  // report id is truthful for already-digested history and means "still owed"
1011
1125
  // only while digestedAt is also NULL.
@@ -1353,6 +1467,23 @@ export function openStore(dbPath: string): Store {
1353
1467
  const insertNotified = db.query<unknown, [string, number]>(
1354
1468
  `INSERT OR IGNORE INTO notifications ("key", at) VALUES (?, ?)`,
1355
1469
  );
1470
+ // Orchestrator-down incident (#288). One row per project; INSERT OR IGNORE is
1471
+ // the dedupe so a flapping orchestrator opens (and pages) once per incident.
1472
+ const selectOrchestratorIncident = db.query<OrchestratorIncidentRow, [string]>(
1473
+ `SELECT project, mode, cause, since, diverted FROM orchestrator_incidents WHERE project = ?`,
1474
+ );
1475
+ const insertOrchestratorIncident = db.query<unknown, [string, string, string | null, number]>(
1476
+ `INSERT OR IGNORE INTO orchestrator_incidents (project, mode, cause, since)
1477
+ VALUES (?, ?, ?, ?)`,
1478
+ );
1479
+ const bumpOrchestratorIncident = db.query<unknown, [number, string]>(
1480
+ `UPDATE orchestrator_incidents SET diverted = diverted + ? WHERE project = ?`,
1481
+ );
1482
+ const deleteOrchestratorIncident = db.query<OrchestratorIncidentRow, [string]>(
1483
+ `DELETE FROM orchestrator_incidents
1484
+ WHERE project = ?
1485
+ RETURNING project, mode, cause, since, diverted`,
1486
+ );
1356
1487
  const upsertDispatch = db.query<unknown, [string, string]>(
1357
1488
  `INSERT INTO dispatch_summaries (project, summary) VALUES (?, ?)
1358
1489
  ON CONFLICT(project) DO UPDATE SET summary = excluded.summary`,
@@ -1877,6 +2008,16 @@ export function openStore(dbPath: string): Store {
1877
2008
  ORDER BY at DESC, rowid DESC
1878
2009
  LIMIT ?`,
1879
2010
  );
2011
+ const insertDaemonStop = db.query<unknown, SqlValue[]>(
2012
+ `INSERT INTO daemon_stops
2013
+ (id, at, controlPath, callerPid, callerUid, role, scope, project, daemonPid, runtimeDir, affected, reason, unattributed)
2014
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2015
+ );
2016
+ // `rowid` breaks the tie when two requests land in the same millisecond, so
2017
+ // "newest first" never silently hides one stop behind another.
2018
+ const selectLatestDaemonStop = db.query<DaemonStopRow, []>(
2019
+ `SELECT * FROM daemon_stops ORDER BY at DESC, rowid DESC LIMIT 1`,
2020
+ );
1880
2021
  // Breaking a stale claim and taking it must be one transaction, or two
1881
2022
  // daemons both see the stale row, both delete it, and both insert.
1882
2023
  const breakStaleMergeLock = db.query<unknown, [string, number]>(
@@ -2211,6 +2352,58 @@ export function openStore(dbPath: string): Store {
2211
2352
  insertNotified.run(key, Date.now());
2212
2353
  },
2213
2354
 
2355
+ orchestratorIncident(project: string): OrchestratorIncident | undefined {
2356
+ const row = selectOrchestratorIncident.get(project);
2357
+ return row === null ? undefined : toOrchestratorIncident(row);
2358
+ },
2359
+
2360
+ openOrchestratorIncident(draft: OrchestratorIncidentDraft): boolean {
2361
+ return (
2362
+ insertOrchestratorIncident.run(
2363
+ draft.project,
2364
+ draft.mode,
2365
+ draft.cause ?? null,
2366
+ draft.since,
2367
+ ).changes === 1
2368
+ );
2369
+ },
2370
+
2371
+ bumpOrchestratorDiverted(project: string, by = 1): void {
2372
+ // Only meaningful while an incident is open: a healthy or external
2373
+ // orchestrator has no row, and the UPDATE touches nothing.
2374
+ bumpOrchestratorIncident.run(by, project);
2375
+ },
2376
+
2377
+ closeOrchestratorIncident(project: string, _at: number): OrchestratorIncident | undefined {
2378
+ const row = deleteOrchestratorIncident.get(project);
2379
+ return row === null ? undefined : toOrchestratorIncident(row);
2380
+ },
2381
+
2382
+ recordDaemonStop(draft: DaemonStopDraft): DaemonStop {
2383
+ const entry: DaemonStop = { ...draft, id: crypto.randomUUID(), at: Date.now() };
2384
+ insertDaemonStop.run(
2385
+ entry.id,
2386
+ entry.at,
2387
+ entry.controlPath,
2388
+ toSql(entry.callerPid),
2389
+ toSql(entry.callerUid),
2390
+ toSql(entry.role),
2391
+ entry.scope,
2392
+ toSql(entry.project),
2393
+ toSql(entry.daemonPid),
2394
+ toSql(entry.runtimeDir),
2395
+ JSON.stringify(entry.affected),
2396
+ entry.reason,
2397
+ entry.unattributed ? 1 : 0,
2398
+ );
2399
+ return entry;
2400
+ },
2401
+
2402
+ latestDaemonStop(): DaemonStop | undefined {
2403
+ const row = selectLatestDaemonStop.get();
2404
+ return row === null ? undefined : toDaemonStop(row);
2405
+ },
2406
+
2214
2407
  recordDispatch(project: string, summary: DispatchSummary): void {
2215
2408
  const previous = selectDispatch.get(project);
2216
2409
  upsertDispatch.run(project, JSON.stringify(summary));
@@ -24,6 +24,7 @@
24
24
  import { credentialedEnv } from "../gitops.ts";
25
25
  import { parsePrDiff } from "../diff-flags.ts";
26
26
  import type {
27
+ IssueComment,
27
28
  IssueState,
28
29
  IssueSnapshot,
29
30
  MergedPrInfo,
@@ -760,6 +761,36 @@ export function readyIssuesFromRest(raw: string): ReadyIssue[] {
760
761
  return issues;
761
762
  }
762
763
 
764
+ /**
765
+ * Parse the REST `/repos/{owner}/{repo}/issues/{n}/comments` body — already
766
+ * projected by jq to `[{author, body}]` — onto {@link IssueComment}.
767
+ *
768
+ * The REST endpoint answers chronological order (oldest first), so the array
769
+ * order is the order the brief renders. Nulls and unrecognizable payloads are
770
+ * tolerated the same way {@link readyIssuesFromRest} tolerates them: an empty
771
+ * answer means "no comments", and *only* a thrown read means "could not tell" —
772
+ * the caller distinguishes the two, because eliding them is the #517 failure.
773
+ */
774
+ export function commentsFrom(raw: string): IssueComment[] {
775
+ let parsed: unknown;
776
+ try {
777
+ parsed = JSON.parse(raw) as unknown;
778
+ } catch {
779
+ return [];
780
+ }
781
+ if (!Array.isArray(parsed)) return [];
782
+ const comments: IssueComment[] = [];
783
+ for (const entry of parsed) {
784
+ if (entry === null || typeof entry !== "object") continue;
785
+ const row = entry as { readonly [key: string]: unknown };
786
+ const author = row["author"];
787
+ const body = row["body"];
788
+ if (typeof author !== "string" || typeof body !== "string") continue;
789
+ comments.push({ author, body });
790
+ }
791
+ return comments;
792
+ }
793
+
763
794
  /** One cached page per request URL: the ETag GitHub answered, the raw JSON
764
795
  * body it covered, and whether that page advertised a rel="next" successor.
765
796
  * Module-level default so every makeTracker() in the process (board probe,
@@ -1103,6 +1134,22 @@ export function makeTracker(
1103
1134
  return pages.flatMap(readyIssuesFromRest);
1104
1135
  },
1105
1136
 
1137
+ async listComments(issue: number): Promise<IssueComment[]> {
1138
+ // REST, like `issueSnapshot` — deliberately not `gh issue view
1139
+ // --comments`, whose renderer goes through the interactive pager and
1140
+ // prints nothing on a non-TTY while still exiting 0 (the #517 failure).
1141
+ // `gh api` output is never paged. One page: the brief truncates a huge
1142
+ // thread with an explicit marker rather than paginating.
1143
+ return commentsFrom(
1144
+ await runGh([
1145
+ "api",
1146
+ `repos/${repo}/issues/${issue}/comments`,
1147
+ "--jq",
1148
+ "[.[] | {author: .user.login, body: .body}]",
1149
+ ]),
1150
+ );
1151
+ },
1152
+
1106
1153
  async addLabel(issue: number, label: string): Promise<void> {
1107
1154
  try {
1108
1155
  // REST POST is natively idempotent: re-adding a label the issue already