@opsee/cli 0.11.9

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 (85) hide show
  1. package/README.md +1962 -0
  2. package/bin/opsee.js +28 -0
  3. package/package.json +40 -0
  4. package/skills/README.md +3 -0
  5. package/skills/to-issues/SKILL.md +92 -0
  6. package/skills/to-issues/agents/openai.yaml +5 -0
  7. package/skills/to-spec/SKILL.md +79 -0
  8. package/skills/to-spec/agents/openai.yaml +5 -0
  9. package/skills/wayfinder/SKILL.md +138 -0
  10. package/skills/wayfinder/agents/openai.yaml +5 -0
  11. package/src/args.ts +676 -0
  12. package/src/cli.ts +341 -0
  13. package/src/commands/account.ts +121 -0
  14. package/src/commands/deps.ts +11 -0
  15. package/src/commands/foreman-control.ts +242 -0
  16. package/src/commands/foreman-debug.ts +131 -0
  17. package/src/commands/foreman-plan.ts +213 -0
  18. package/src/commands/foreman-service.ts +186 -0
  19. package/src/commands/foreman-up.ts +165 -0
  20. package/src/commands/foreman-views.ts +398 -0
  21. package/src/commands/foreman.ts +465 -0
  22. package/src/commands/init.ts +176 -0
  23. package/src/commands/initiative.ts +192 -0
  24. package/src/commands/login.ts +24 -0
  25. package/src/commands/whoami.ts +15 -0
  26. package/src/foreman/account-store.ts +96 -0
  27. package/src/foreman/account.ts +474 -0
  28. package/src/foreman/claude-worker-adapter.ts +412 -0
  29. package/src/foreman/codex-worker-adapter.ts +472 -0
  30. package/src/foreman/completion-report.ts +153 -0
  31. package/src/foreman/core/context.ts +169 -0
  32. package/src/foreman/core/defects.ts +280 -0
  33. package/src/foreman/core/exec.ts +20 -0
  34. package/src/foreman/core/gates.ts +493 -0
  35. package/src/foreman/core/handoff.ts +163 -0
  36. package/src/foreman/core/install.ts +109 -0
  37. package/src/foreman/core/learnings.ts +368 -0
  38. package/src/foreman/core/outbox-tracker.ts +192 -0
  39. package/src/foreman/core/pin.ts +226 -0
  40. package/src/foreman/core/plan-context.ts +238 -0
  41. package/src/foreman/core/process-table.ts +535 -0
  42. package/src/foreman/core/reconcile.ts +227 -0
  43. package/src/foreman/core/report.ts +60 -0
  44. package/src/foreman/core/run.ts +2836 -0
  45. package/src/foreman/core/scheduler.ts +244 -0
  46. package/src/foreman/core/summary.ts +166 -0
  47. package/src/foreman/core/text.ts +97 -0
  48. package/src/foreman/core/transcripts.ts +38 -0
  49. package/src/foreman/core/triage.ts +138 -0
  50. package/src/foreman/core/verifier.ts +800 -0
  51. package/src/foreman/core/views.ts +940 -0
  52. package/src/foreman/core/work-contract.ts +152 -0
  53. package/src/foreman/core/workspace.ts +335 -0
  54. package/src/foreman/fake-handoff.ts +33 -0
  55. package/src/foreman/fake-learnings.ts +26 -0
  56. package/src/foreman/fake-remote-api.ts +70 -0
  57. package/src/foreman/fake-tracker-adapter.ts +355 -0
  58. package/src/foreman/fake-worker-adapter.ts +221 -0
  59. package/src/foreman/host.ts +75 -0
  60. package/src/foreman/local-dir.ts +28 -0
  61. package/src/foreman/opsee-tracker-adapter.ts +612 -0
  62. package/src/foreman/process-group.ts +160 -0
  63. package/src/foreman/remote-api.ts +283 -0
  64. package/src/foreman/run-recipe.ts +274 -0
  65. package/src/foreman/service-unit.ts +257 -0
  66. package/src/foreman/tracker-adapter.ts +298 -0
  67. package/src/foreman/triage-draft.ts +40 -0
  68. package/src/foreman/vendor.ts +23 -0
  69. package/src/foreman/verdict.ts +120 -0
  70. package/src/foreman/worker-adapter.ts +177 -0
  71. package/src/foreman/worker-process.ts +488 -0
  72. package/src/identity.ts +49 -0
  73. package/src/index.ts +3 -0
  74. package/src/init/managed.ts +84 -0
  75. package/src/init/mcp-config.ts +77 -0
  76. package/src/init/paths.ts +16 -0
  77. package/src/init/pointer-block.ts +45 -0
  78. package/src/init/project.ts +22 -0
  79. package/src/init/prompt.ts +45 -0
  80. package/src/init/run-recipe-config.ts +133 -0
  81. package/src/init/skills.ts +38 -0
  82. package/src/init/text.ts +22 -0
  83. package/src/init/tracker-doc.ts +106 -0
  84. package/src/opsee-config.ts +116 -0
  85. package/templates/issue-tracker.md +162 -0
@@ -0,0 +1,940 @@
1
+ /**
2
+ * The Foreman's three read-only views, as text (OPS-280; see ../../../CONTEXT.md: Slot, Account,
3
+ * Paused, Run Record, Hand-off, Defect, Breaker, Proposed Learning, Process Table).
4
+ *
5
+ * `status` is the Process Table and the Accounts file, side by side: how many Slots each Account
6
+ * has and how many are spent, the Workers alive right now, and every Paused Account and paused
7
+ * Initiative with when it lifts. `logs` is one Worker's transcript. `review` is the Run Record
8
+ * regrouped by what a human does with it in the morning: Hand-offs to look at, Defects filed,
9
+ * Breakers hit, Proposed Learnings to accept.
10
+ *
11
+ * Everything here renders to a terminal, and almost nothing it renders is text a person wrote.
12
+ * A Task's identifier and title come from the Tracker; an Account's pause reason is the vendor's
13
+ * own sentence; a Gate's name and command, a Defect's title, a Verifier's evidence URL and a
14
+ * Worker's assistant output are all written by an agent or printed by a command, and a
15
+ * full-privilege Worker may edit the Run Recipe inside its Workspace. mcp/src/utils/format/
16
+ * run-record.ts renders the same events into Markdown and treats every such string as hostile;
17
+ * this file owes the same discipline to a different target, where the hazard is not a heading
18
+ * marker but an ANSI escape that repaints the screen, a `\r` that overwrites the line just
19
+ * printed, or a bidi override that reverses what a name reads as. So every value that comes off an
20
+ * event, a row, an Account or a Task goes through `printableOneLine` (core/text.ts, OPS-277),
21
+ * which collapses whitespace and then drops the whole `Cc`/`Cf` range. There is no second
22
+ * treatment and no exception: a field that is too long is clamped by the same call.
23
+ *
24
+ * Two things that discipline does *not* buy on its own, and which this file handles separately.
25
+ * Stripping the control ranges does not make `String.length` a column count — five CJK characters
26
+ * are five units long and ten columns wide, and an emoji is one code point and two columns — so
27
+ * the table pads by `width` below rather than by `.length`. And `safe` is fed values that a type
28
+ * says are strings but a `JSON.parse` of a Worker-written transcript line may make anything at
29
+ * all, so it coerces rather than trusting the declared type: one crafted append must not be able
30
+ * to end `opsee foreman logs` with a TypeError.
31
+ */
32
+ import type { RunEvent } from "@opsee/mcp-server/gen/api/v1/initiative_pb.js";
33
+ import type { Account } from "../account.js";
34
+ import { pausedUntil } from "../account.js";
35
+ import type { TrackerTask } from "../tracker-adapter.js";
36
+ import { gateVerdictOf, GATE_SKIPPED_SUMMARY } from "./gates.js";
37
+ import { STATUS_LABELS } from "./run.js";
38
+ import { verdictStatusOf } from "./verifier.js";
39
+ import { DISPATCH_PID_GRACE_MS, pidIsAlive, type WorkerRow } from "./process-table.js";
40
+ import { Slots } from "./scheduler.js";
41
+ import { count, printableOneLine } from "./text.js";
42
+
43
+ /** How much of a name, a title or a URL is printed on a line of a table or a list. Longer than the
44
+ * vendor-prose limit `printableOneLine` defaults to, because a Task title and a pull request URL
45
+ * are both meant to be read whole, and short enough that one poisoned field cannot own the view. */
46
+ export const FIELD_LIMIT = 200;
47
+
48
+ /** How much of one line of a Worker's transcript or a Defect's steps is printed. A wide terminal,
49
+ * not a paragraph: the transcript file and the pull request hold the rest. */
50
+ export const LINE_LIMIT = 500;
51
+
52
+ /**
53
+ * One field on a line of this view's own prose. See the module comment: never a raw string.
54
+ *
55
+ * `unknown`, not `string | undefined`, on purpose. Every other caller does hand it a string, but
56
+ * `formatTranscriptLine` hands it fields off a `JSON.parse` of a line a Worker appended, where the
57
+ * declared type is a hope rather than a fact: `{"type":"tool","name":42}` is well-formed JSON and
58
+ * a `number` where a `string` was promised. This is the one chokepoint every printed value goes
59
+ * through, so a throw here is a crash of the whole command; it coerces instead. A number or a
60
+ * boolean reads as itself, an object or an array becomes a marker rather than `[object Object]` or
61
+ * a `toString` that could itself throw, and nothing reaches the terminal unstripped either way.
62
+ */
63
+ export function safe(text: unknown, limit = FIELD_LIMIT): string {
64
+ if (typeof text === "string") return printableOneLine(text, limit);
65
+ if (text === undefined || text === null) return "";
66
+ if (typeof text === "number" || typeof text === "boolean" || typeof text === "bigint") return printableOneLine(String(text), limit);
67
+ return `(${Array.isArray(text) ? "array" : typeof text})`;
68
+ }
69
+
70
+ /** `safe`, but empty text becomes a visible stand-in rather than a hole in the line. */
71
+ function safeOr(text: unknown, fallback: string, limit = FIELD_LIMIT): string {
72
+ const value = safe(text, limit);
73
+ return value === "" ? fallback : value;
74
+ }
75
+
76
+ /** Characters a terminal draws two columns wide: the CJK and Hangul blocks, the fullwidth forms,
77
+ * and the emoji that default to an emoji presentation. Not exhaustive — East_Asian_Width is not a
78
+ * property JavaScript's regexes expose, and a terminal's own idea of a width is its business — but
79
+ * it covers what a Task title or an Account name realistically carries. */
80
+ const WIDE =
81
+ /^(?:[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uA960-\uA97F\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE19\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6]|[\u{17000}-\u{18AFF}]|[\u{20000}-\u{3FFFD}]|\p{Emoji_Presentation})$/u;
82
+
83
+ /**
84
+ * How many terminal columns a string takes.
85
+ *
86
+ * Not `.length`: that is UTF-16 code units, and the two differ in both directions. `日本語です` is
87
+ * five units and ten columns; `😀` is two units and two columns; a combining acute is one unit and
88
+ * no column at all. Stripping the `Cc`/`Cf` ranges does nothing about any of that, so a Japanese
89
+ * Account name — or a hostile one made of combining marks — would knock every row below it out of
90
+ * line if the padding measured units. Zero-width joiners and variation selectors are `Cf` and are
91
+ * already gone by the time a cell gets here.
92
+ */
93
+ export function width(text: string): number {
94
+ let columns = 0;
95
+ for (const ch of text) {
96
+ if (/^[\p{Mn}\p{Me}]$/u.test(ch)) continue;
97
+ columns += WIDE.test(ch) ? 2 : 1;
98
+ }
99
+ return columns;
100
+ }
101
+
102
+ /** A fixed-width table from already-`safe` cells: the header, then the rows, columns padded to the
103
+ * widest cell. The last column is never padded, so a long final value does not trail spaces. */
104
+ export function table(rows: string[][]): string[] {
105
+ if (rows.length === 0) return [];
106
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => width(r[i] ?? ""))));
107
+ return rows.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : `${cell ?? ""}${" ".repeat(Math.max(0, widths[i] - width(cell ?? "")))}`)).join(" "));
108
+ }
109
+
110
+ /** `2m`, `1h04m`, `3d02h`: how long ago something was, for a column that must stay narrow. */
111
+ export function age(ms: number): string {
112
+ if (ms < 0) return "0s";
113
+ const s = Math.floor(ms / 1000);
114
+ if (s < 60) return `${s}s`;
115
+ const m = Math.floor(s / 60);
116
+ if (m < 60) return `${m}m`;
117
+ const h = Math.floor(m / 60);
118
+ if (h < 24) return `${h}h${String(m % 60).padStart(2, "0")}m`;
119
+ return `${Math.floor(h / 24)}d${String(h % 24).padStart(2, "0")}h`;
120
+ }
121
+
122
+ // --- status ---------------------------------------------------------------------------------
123
+
124
+ /**
125
+ * One Account's Slots as the Process Table shows them (`Slot`, CONTEXT.md).
126
+ *
127
+ * The Slots themselves are a Run's, held in that process's `Slots` object (core/scheduler.ts);
128
+ * another process cannot read them. What it can read is the one thing the two share, the Process
129
+ * Table: a row per Worker the Foreman has running. So a Slot in use is counted as a row, and the
130
+ * Account's own arithmetic — how many of its `cap` are implementer Slots, whether it reserves one
131
+ * for Verifiers — is taken from `Slots` itself rather than restated here, so the two can never
132
+ * drift.
133
+ *
134
+ * `verifying` is a floor at cap 2 and above, and the view says so. A dispatch in its Verifier
135
+ * round holds the reserved Slot rather than an implementer one, and the only trace of a round on
136
+ * the row is `appPid`, the pid of the app the round started (core/run.ts). A Verifier round on a
137
+ * repository whose Run Recipe starts no app therefore looks like an implementer here, which costs
138
+ * one implementer Slot on paper and never the other way round.
139
+ *
140
+ * At cap 1 there is no reserved Slot at all — `Slots.enterVerification` returns at once when
141
+ * `reservesVerifierSlot` is false — so a Verifier round there keeps the implementer Slot it
142
+ * already had, while `run.ts` sets `appPid` on the row regardless of cap. Counting that row as
143
+ * verifying would subtract it from the implementers and invent an implementer Slot that the
144
+ * running Foreman will never dispatch into, which is the one direction this must not err in. So
145
+ * `verifying` is forced to 0 at cap 1: the round is counted as what it is holding.
146
+ *
147
+ * The remaining honest caveat is that `free` is derived from `cap` as the Accounts file reads it
148
+ * *now*, while a live Run pinned its `Slots` to the cap the file had when it started
149
+ * (`new Slots(account.cap)`, core/run.ts). `foreman account set --cap` between the two leaves this
150
+ * view arithmetically right about the file and wrong about the Run, and there is nothing in the
151
+ * Process Table that would say so. Short of that — and short of a Run that a cap change has
152
+ * outlived — `free` is never reported higher than it really is.
153
+ */
154
+ export interface AccountSlots {
155
+ account: Account;
156
+ /** Worker rows on this Account whose Worker is still there: one per turn really running. */
157
+ used: number;
158
+ /** Of those, the ones a Verifier round has an app running for (see above: a floor above cap 1,
159
+ * and always 0 at cap 1, where a round holds no Slot of its own). */
160
+ verifying: number;
161
+ /** Rows that are not visibly verifying, so they are counted against the implementer Slots. */
162
+ implementers: number;
163
+ /** Slots that are not implementers': the reserved Verifier Slot, 1 or 0 (`cap` 1 reserves none). */
164
+ reserved: number;
165
+ /** Implementer Slots a Ready Task could take right now, floored at 0. */
166
+ free: number;
167
+ /** Rows on this Account whose process is gone: counted in no Slot, still listed as Workers. */
168
+ stale: number;
169
+ }
170
+
171
+ /**
172
+ * Whether a Worker row still stands for a Worker, by the rule `otherForemanWorkersOn` applies when
173
+ * it decides whether an Account has room (core/process-table.ts).
174
+ *
175
+ * The rows outlive the Foreman that wrote them: a SIGKILLed Run leaves every one of its rows
176
+ * behind, and nothing sweeps them but the next Reconcile. Counting those as Slots in use would
177
+ * have `status` report a full fleet on an empty machine, while `foreman run` on that same Account
178
+ * is permitted and dispatches — the exact misreading `status` exists to prevent, in the other
179
+ * direction. A row with no pid inside the dispatch-commit-to-launch window is a Foreman that
180
+ * really is starting a Worker and no pid check can see it, so it counts.
181
+ */
182
+ export function rowIsRunning(row: WorkerRow, at: number, isAlive: (pid: number | undefined) => boolean = pidIsAlive): boolean {
183
+ if (row.pid !== undefined) return isAlive(row.pid);
184
+ return at - row.startedAt < DISPATCH_PID_GRACE_MS;
185
+ }
186
+
187
+ export interface SlotsOptions {
188
+ now: number;
189
+ isAlive?: (pid: number | undefined) => boolean;
190
+ }
191
+
192
+ /** Every registered Account with its Slots, in the order the Accounts file has them. */
193
+ export function accountSlots(accounts: Account[], rows: WorkerRow[], options: SlotsOptions): AccountSlots[] {
194
+ const { now, isAlive } = options;
195
+ return accounts.map((account) => {
196
+ const all = rows.filter((r) => r.account === account.name);
197
+ const mine = all.filter((r) => rowIsRunning(r, now, isAlive));
198
+ // The Account's own arithmetic, not a copy of it. A cap the accounts file should never hold
199
+ // (`registerAccount` and `FileAccountStore.load` both refuse one) would throw in `Slots`; a
200
+ // read-only view reports it instead of dying on someone else's bad file.
201
+ let implementerCap = Math.max(1, account.cap - 1);
202
+ let reservesVerifierSlot = account.cap > 1;
203
+ try {
204
+ const slots = new Slots(account.cap);
205
+ implementerCap = slots.implementerCap;
206
+ reservesVerifierSlot = slots.reservesVerifierSlot;
207
+ } catch {
208
+ // Left as the fallback above; the cap is printed as it stands, so the file's own value shows.
209
+ }
210
+ // See above: at cap 1 the round is in the implementer's own Slot, so `appPid` says nothing
211
+ // about which Slot is held and must not be allowed to conjure a free one.
212
+ const verifying = reservesVerifierSlot ? mine.filter((r) => r.appPid !== undefined).length : 0;
213
+ const implementers = mine.length - verifying;
214
+ return {
215
+ account,
216
+ used: mine.length,
217
+ verifying,
218
+ implementers,
219
+ reserved: reservesVerifierSlot ? 1 : 0,
220
+ // A quarantined Account has no free Slot to report, whatever its cap and whatever is running
221
+ // on it (OPS-288): the Run will not dispatch there, so a number above zero would be an
222
+ // invitation `foreman run` does not honour. Its live Workers are still counted and listed —
223
+ // they were started while the credential worked and may still finish.
224
+ free: account.quarantined ? 0 : Math.max(0, implementerCap - implementers),
225
+ stale: all.length - mine.length,
226
+ };
227
+ });
228
+ }
229
+
230
+ /** Worker rows on an Account no registered Account matches: a de-registered Account, or a row from
231
+ * another machine's accounts file. They are in the Workers table but in no Account's `USED`, so
232
+ * `status` says so rather than letting the two sections quietly disagree. */
233
+ export function unregisteredAccounts(accounts: Account[], rows: WorkerRow[]): Array<{ account: string; rows: number }> {
234
+ const known = new Set(accounts.map((a) => a.name));
235
+ const counts = new Map<string, number>();
236
+ for (const row of rows) {
237
+ if (known.has(row.account)) continue;
238
+ counts.set(row.account, (counts.get(row.account) ?? 0) + 1);
239
+ }
240
+ return [...counts.entries()].map(([account, n]) => ({ account, rows: n }));
241
+ }
242
+
243
+ export interface StatusInput {
244
+ accounts: Account[];
245
+ /** Every Worker row, whatever its Initiative or Account (`ProcessTable.liveWorkers()`). */
246
+ rows: WorkerRow[];
247
+ /** Initiatives a human has paused (`ProcessTable.pausedInitiatives()`). */
248
+ paused: Array<{ initiativeId: number; pausedAt: number }>;
249
+ /** Where the table lives, so a reader knows which machine's fleet this is. */
250
+ tablePath: string;
251
+ now: number;
252
+ /** Seam for the pid check the Slot arithmetic and the STATE column share; the real one by
253
+ * default. Tests inject it because a test's pid numbers are not this machine's processes. */
254
+ isAlive?: (pid: number | undefined) => boolean;
255
+ }
256
+
257
+ /** What a row's `control` mark and pid say about the Worker, in one word for the STATE column. */
258
+ function workerState(row: WorkerRow, now: number, isAlive?: (pid: number | undefined) => boolean): string {
259
+ // First, because it is the one that changes what the other words mean: a row whose Worker is
260
+ // gone is a leftover of a Foreman that died, and calling it "attached" or "running" is how a
261
+ // reader concludes the fleet is full when it is empty. `USED` does not count it either.
262
+ if (!rowIsRunning(row, now, isAlive)) return "gone";
263
+ if (row.control === "attached") return "attached";
264
+ if (row.control === "released") return "released";
265
+ if (row.control === "cancelled") return "cancelled";
266
+ if (row.appPid !== undefined) return "verifying";
267
+ if (row.pid === undefined) return row.sessionId === undefined ? "starting" : "no pid";
268
+ return "running";
269
+ }
270
+
271
+ /**
272
+ * `opsee foreman status`: the Slots, the Workers and the Paused state, read fresh.
273
+ *
274
+ * Nothing is cached and nothing is derived from a previous call — the table is SQLite on disk that
275
+ * the Run writes, so one read is one tick's worth of truth, which is what "reflects the Process
276
+ * Table within one tick of a change" asks for.
277
+ */
278
+ export function formatStatus(input: StatusInput): string[] {
279
+ const { rows, now } = input;
280
+ const isAlive = input.isAlive;
281
+ const lines: string[] = [];
282
+ const slots = accountSlots(input.accounts, rows, { now, isAlive });
283
+
284
+ lines.push(`Process Table: ${safe(input.tablePath, LINE_LIMIT)}`);
285
+ lines.push("");
286
+
287
+ lines.push("Accounts");
288
+ if (input.accounts.length === 0) {
289
+ lines.push(" No Account registered; register one with: opsee foreman account add --vendor claude --config-dir <dir>");
290
+ } else {
291
+ const body = slots.map((s) => {
292
+ const until = pausedUntil(s.account, now);
293
+ const reason = s.account.paused?.reason ? ` (${safe(s.account.paused.reason)})` : "";
294
+ // Quarantine is reported ahead of a pause and instead of it (OPS-288). An Account may well
295
+ // have been Paused before its credential died, but only one of the two facts still asks the
296
+ // reader for anything, and only one of them will not lift on its own.
297
+ const state = s.account.quarantined
298
+ ? `quarantined since ${safe(s.account.quarantined.at)} (${safe(s.account.quarantined.reason)}); opsee foreman account resume ${safe(s.account.name)}`
299
+ : until
300
+ ? `Paused until ${safe(until.toISOString())}${reason}`
301
+ : s.account.paused
302
+ ? `active (pause lapsed ${safe(s.account.paused.until)})`
303
+ : "active";
304
+ return [
305
+ safe(s.account.name),
306
+ safe(s.account.vendor),
307
+ String(s.account.cap),
308
+ String(s.used),
309
+ String(s.reserved),
310
+ String(s.free),
311
+ state,
312
+ ];
313
+ });
314
+ for (const line of table([["ACCOUNT", "VENDOR", "CAP", "USED", "RESERVED", "FREE", "STATE"], ...body])) lines.push(` ${line}`);
315
+ const verifying = slots.reduce((n, s) => n + s.verifying, 0);
316
+ const stale = slots.reduce((n, s) => n + s.stale, 0);
317
+ lines.push(
318
+ ` USED is the Worker rows on the Account whose Worker is still there, RESERVED its Verifier Slot (every Account above cap 1 keeps one), FREE the implementer Slots a Ready Task could take.` +
319
+ (verifying ? ` ${count(verifying, "Worker")} of those rows is in a Verifier round and holds the reserved Slot rather than an implementer one.` : "") +
320
+ (stale ? ` ${count(stale, "row")} below is marked gone — its process is not there — and is counted in no Account's USED.` : ""),
321
+ );
322
+ lines.push(
323
+ ` FREE is read from the Accounts file's cap as it stands now; a Run that was started before opsee foreman account set --cap pinned the old one and will not fill a Slot this adds.`,
324
+ );
325
+ }
326
+ const unregistered = unregisteredAccounts(input.accounts, rows);
327
+ if (unregistered.length > 0) {
328
+ lines.push(
329
+ ` ${count(
330
+ unregistered.reduce((n, u) => n + u.rows, 0),
331
+ "Worker",
332
+ )} on ${count(unregistered.length, "Account")} this machine has not registered (${unregistered.map((u) => `${safe(u.account)} ×${u.rows}`).join(", ")}): listed under Workers below, counted in no USED above.`,
333
+ );
334
+ }
335
+ lines.push("");
336
+
337
+ lines.push(`Workers (${rows.length})`);
338
+ if (rows.length === 0) {
339
+ lines.push(" None: no Foreman on this machine has a Worker running.");
340
+ } else {
341
+ const body = rows.map((r) => [
342
+ safe(r.identifier),
343
+ String(r.initiativeId),
344
+ safe(r.account),
345
+ safe(r.vendor),
346
+ String(r.attempt),
347
+ r.pid === undefined ? "—" : String(r.pid),
348
+ age(now - r.startedAt),
349
+ age(now - r.lastOutputAt),
350
+ workerState(r, now, isAlive),
351
+ ]);
352
+ for (const line of table([["TASK", "INITIATIVE", "ACCOUNT", "VENDOR", "ATTEMPT", "PID", "AGE", "QUIET", "STATE"], ...body])) lines.push(` ${line}`);
353
+ lines.push(" AGE is since the turn started, QUIET since its last output. A row with no pid is a dispatch that has not launched yet, or one whose Worker Adapter reports none.");
354
+ lines.push(" STATE gone is a row whose process is not there — a Foreman that was killed leaves its rows behind, and the next Reconcile settles them; it holds no Slot in the meantime.");
355
+ }
356
+ lines.push("");
357
+
358
+ lines.push(`Paused Initiatives (${input.paused.length})`);
359
+ if (input.paused.length === 0) {
360
+ lines.push(" None: no Run on this machine is holding new dispatch (opsee foreman pause <initiativeId>).");
361
+ } else {
362
+ for (const p of input.paused) {
363
+ lines.push(` Initiative ${p.initiativeId}: paused ${age(now - p.pausedAt)} ago; in-flight Workers finish, no new dispatch until: opsee foreman resume ${p.initiativeId}`);
364
+ }
365
+ }
366
+
367
+ return lines;
368
+ }
369
+
370
+ // --- logs -----------------------------------------------------------------------------------
371
+
372
+ /** One line of a transcript file: the adapter event, plus the `at` the store stamps it with
373
+ * (core/transcripts.ts). Every field is `unknown` rather than optionally its own type, because the
374
+ * file is read as it stands: a line half-written by a Worker that was killed mid-append is a line
375
+ * this must survive, and so is `{"type":"tool","name":42}`, which is well-formed JSON that no
376
+ * declared type makes true. The Worker writing the file is the thing this view is watching, at
377
+ * full privilege on this machine; the shapes below are what it chose to write, not a contract. */
378
+ interface TranscriptEvent {
379
+ at?: unknown;
380
+ type?: unknown;
381
+ text?: unknown;
382
+ name?: unknown;
383
+ message?: unknown;
384
+ reason?: unknown;
385
+ resetAt?: unknown;
386
+ sessionId?: unknown;
387
+ silentMs?: unknown;
388
+ details?: unknown;
389
+ report?: unknown;
390
+ }
391
+
392
+ /** `10:15:03` from the ISO stamp the transcript store writes, or blank when the line has none or
393
+ * the line's `at` is not a stamp at all. */
394
+ function clock(at: unknown): string {
395
+ if (typeof at !== "string" && typeof at !== "number") return " ";
396
+ const d = new Date(at);
397
+ return Number.isFinite(d.getTime()) ? d.toISOString().slice(11, 19) : " ";
398
+ }
399
+
400
+ /** A `report` off a transcript line, when it is an object at all: `{"type":"completed","report":7}`
401
+ * is as writable as the real shape, and `(7).outcome` is `undefined` rather than a throw only by
402
+ * luck of JavaScript's rules. Read it explicitly instead. */
403
+ function reportOf(value: unknown): { outcome?: unknown; summary?: unknown } {
404
+ return typeof value === "object" && value !== null ? (value as { outcome?: unknown; summary?: unknown }) : {};
405
+ }
406
+
407
+ /**
408
+ * One JSONL line of a transcript as a human reads it, or several for a multi-line assistant
409
+ * message: the timestamp, what kind of event it was, and its text.
410
+ *
411
+ * Every part of the payload is the vendor's — the assistant's own prose, a tool name it chose, the
412
+ * vendor's error text — so each printed line goes through `safe` on its own. Splitting first and
413
+ * flattening each piece keeps the shape of a code block or a list the Worker printed while still
414
+ * dropping every escape and carriage return in it; collapsing the whole message to one line would
415
+ * be just as safe and much harder to read.
416
+ *
417
+ * A line that is not JSON at all is printed as the raw text it is, flattened the same way: a
418
+ * truncated append is worth seeing, not worth crashing on.
419
+ */
420
+ export function formatTranscriptLine(raw: string): string[] {
421
+ const line = raw.trim();
422
+ if (!line) return [];
423
+ let event: TranscriptEvent;
424
+ try {
425
+ const parsed: unknown = JSON.parse(line);
426
+ if (typeof parsed !== "object" || parsed === null) return [` ? ${safe(line, LINE_LIMIT)}`];
427
+ event = parsed as TranscriptEvent;
428
+ } catch {
429
+ return [` ? ${safe(line, LINE_LIMIT)}`];
430
+ }
431
+ const at = clock(event.at);
432
+ const say = (mark: string, text: string): string => `${at} ${mark} ${text}`;
433
+ const report = reportOf(event.report);
434
+ switch (event.type) {
435
+ case "output": {
436
+ // `safe` first when the text is not a string: only a real string has lines to split, and a
437
+ // number or an object has to become one before `.split` is reached at all.
438
+ const text = typeof event.text === "string" ? event.text : safe(event.text, LINE_LIMIT);
439
+ const body = text.split("\n").map((l) => safe(l, LINE_LIMIT)).filter((l, i, all) => l !== "" || (i > 0 && i < all.length - 1));
440
+ return body.length === 0 ? [] : body.map((l) => say("|", l));
441
+ }
442
+ case "tool":
443
+ return [say("·", `tool ${safeOr(event.name, "(unnamed)")}`)];
444
+ case "started":
445
+ return [say("+", `started, session ${safeOr(event.sessionId, "(none)")}`)];
446
+ case "rate_limited": {
447
+ const until = safe(event.resetAt);
448
+ return [say("!", `rate limited${until ? ` until ${until}` : ""}: ${safe(event.message, LINE_LIMIT)}`)];
449
+ }
450
+ case "stalled": {
451
+ const silent = Number(event.silentMs ?? 0);
452
+ return [say("!", `stalled: no output for ${Number.isFinite(silent) ? silent : 0}ms`)];
453
+ }
454
+ case "completed": {
455
+ const summary = safe(report.summary, LINE_LIMIT);
456
+ return [say("=", `completed: ${safeOr(report.outcome, "(no outcome)")}${summary ? ` — ${summary}` : ""}`)];
457
+ }
458
+ case "failed":
459
+ return [say("=", `failed (${safeOr(event.reason, "unknown")}): ${safe(event.message, LINE_LIMIT)}`)];
460
+ default:
461
+ return [say("?", safe(line, LINE_LIMIT))];
462
+ }
463
+ }
464
+
465
+ /** Where a Worker's transcript is and whether the Worker is still writing to it. */
466
+ export interface TranscriptTarget {
467
+ path: string;
468
+ identifier: string;
469
+ initiativeId: number;
470
+ attempt: number;
471
+ /** The Process Table row, when the Worker is still one the Foreman believes is running. */
472
+ row?: WorkerRow;
473
+ }
474
+
475
+ /**
476
+ * The header `logs` prints before the first line, which says which of the two things it is showing
477
+ * — a live stream or the stored tail of a turn that is over — because the two look the same once
478
+ * the lines start.
479
+ *
480
+ * Two separate facts, and they must not be conflated. Whether the turn is over is `target.row`:
481
+ * the Run removes the row when a turn settles, so a row present means a Worker is running,
482
+ * whatever this command was asked to do about it. Whether this invocation will follow the file is
483
+ * `live`, which `--no-follow` turns off on a Worker that is very much still running. Branching the
484
+ * *claim* on `live` would have `--no-follow` announce that a turn is over while the row it just
485
+ * read the path out of says otherwise, and an operator who believes it stops waiting.
486
+ */
487
+ export function transcriptHeader(target: TranscriptTarget, options: { live: boolean; tail: number }): string[] {
488
+ const where = `${safe(target.identifier)} attempt ${target.attempt} · Initiative ${target.initiativeId} · ${safe(target.path, LINE_LIMIT)}`;
489
+ const row = target.row;
490
+ if (row && options.live) {
491
+ return [
492
+ `Streaming ${where}`,
493
+ `The Worker is running on Account ${safe(row.account)}${row.pid === undefined ? " (no pid reported)" : ` (pid ${row.pid})`}; new output appears as it arrives. Ctrl-C to stop watching — it does not stop the Worker.`,
494
+ "",
495
+ ];
496
+ }
497
+ if (row) {
498
+ return [
499
+ `Snapshot of ${where}`,
500
+ `The Worker is still running on Account ${safe(row.account)}${row.pid === undefined ? " (no pid reported)" : ` (pid ${row.pid})`}: this is the last ${count(options.tail, "line")} as the file stands right now, and --no-follow means nothing more is printed. Drop --no-follow to watch it.`,
501
+ "",
502
+ ];
503
+ }
504
+ return [
505
+ `Stored tail of ${where}`,
506
+ `This turn is over: the Process Table has no Worker on the Task, so this is the last ${count(options.tail, "line")} of the transcript it left behind, not a live stream.`,
507
+ "",
508
+ ];
509
+ }
510
+
511
+ // --- review ---------------------------------------------------------------------------------
512
+
513
+ /** A Task as `review` names it: by identifier and title, never by id alone.
514
+ *
515
+ * `labels` is here because the Status Label is the only place a Task blocked *before* any attempt
516
+ * is recorded at all — the Foreman refuses those without appending to the Run Record, since nothing
517
+ * was dispatched. It costs nothing to carry: `getInitiativeContext` already returns whole
518
+ * `TrackerTask`s and the command used to narrow them away. */
519
+ export type NamedTask = Pick<TrackerTask, "id" | "identifier" | "title" | "url" | "labels">;
520
+
521
+ export interface ReviewInput {
522
+ initiativeId: number;
523
+ initiative?: { title: string; status: string };
524
+ run?: { status: string; eventCount: number | bigint };
525
+ /** The Run Record in id order, as `readRunRecord` returns it. */
526
+ events: RunEvent[];
527
+ /** Every Task of the Initiative, by id: what turns a `task_id` on an event into a name. */
528
+ tasks: Map<number, NamedTask>;
529
+ /** The Initiative's memory entries of kind `learning`, newest first (core/context.ts reads the
530
+ * same ones for sibling Workers). */
531
+ learnings: Array<{ body: string; sourceTaskId?: number; isAgent: boolean }>;
532
+ /** Set when the Initiative's task list could not be read. `tasks` is then empty, which the
533
+ * Blocked section must report as "not known" rather than render as "nothing is blocked": the
534
+ * Status Label lives on the Task, so no task list means no answer, not an empty one. */
535
+ tasksUnavailable?: boolean;
536
+ }
537
+
538
+ /** `OPS-280 "Give the terminal its three views"`, or the id when the Initiative's task list has no
539
+ * such Task (a Defect filed into another Initiative, a Task deleted since). */
540
+ export function nameTask(tasks: Map<number, NamedTask>, id: number | undefined): string {
541
+ if (id === undefined) return "(no Task)";
542
+ const task = tasks.get(id);
543
+ if (!task) return `Task ${id} (not on this Initiative)`;
544
+ const title = safe(task.title);
545
+ return `${safeOr(task.identifier, `Task ${id}`)}${title ? ` "${title}"` : ""}`;
546
+ }
547
+
548
+ /**
549
+ * One Hand-off a human has to look at: the newest attempt event that named a pull request, per
550
+ * URL. A Task that was handed off and then re-dispatched has one entry, not one per attempt.
551
+ *
552
+ * The key is the URL alone, not the URL and the Task. That is deliberate — one pull request is one
553
+ * thing to read, and its state is whatever the last attempt left it at — but it has a consequence
554
+ * worth naming: two Tasks that both named the same pull request collapse into a single entry
555
+ * carrying whichever `task_id` came later on the Record. The list counts pull requests to review,
556
+ * so that is the right count; it is not a list of every Task that touched one.
557
+ */
558
+ export interface ReviewHandOff {
559
+ url: string;
560
+ taskId?: number;
561
+ attempt: number;
562
+ outcome: string;
563
+ /** True when this attempt's Gates were skipped: nothing on the machine ran the repository's
564
+ * tests, lint or typecheck against the Hand-off, so it reached In review unchecked. Read off the
565
+ * attempt event's own Gates line (`GATE_SKIPPED_SUMMARY`), which is where `settle` puts it. */
566
+ gatesSkipped?: boolean;
567
+ /** What the Gates said about this attempt, and what the Verifier said: the two facts that decide
568
+ * whether the pull request is a human's to read or the Foreman's to come back to. */
569
+ gates: "passed" | "failed" | "none";
570
+ verdict: "passed" | "failed" | "none";
571
+ }
572
+
573
+ /** A Hand-off the Foreman itself does not consider ready: its Gates failed, or its Verifier found
574
+ * Defects. The pull request is open and linked either way, which is exactly why it has to be told
575
+ * apart from one that passed — a list that mixes them sends a human to read work the Foreman
576
+ * already knows is not finished. */
577
+ export function isHeld(h: ReviewHandOff): boolean {
578
+ return h.gates === "failed" || h.verdict === "failed";
579
+ }
580
+
581
+ /** Why it is held, in the order a reader wants it: the Gates first, since a failed Gate is why the
582
+ * Verifier never ran. */
583
+ export function heldReason(h: ReviewHandOff): string {
584
+ const reasons: string[] = [];
585
+ if (h.gates === "failed") reasons.push("the Gates failed");
586
+ if (h.verdict === "failed") reasons.push("the Verifier found Defects");
587
+ return reasons.join(" and ");
588
+ }
589
+
590
+ export function handOffsOf(events: RunEvent[], tasks?: Map<number, NamedTask>): ReviewHandOff[] {
591
+ const byUrl = new Map<string, ReviewHandOff>();
592
+ // One pass per Task rather than per Hand-off: both helpers below walk the Task's whole slice of
593
+ // the Record, and a Task with several attempts would otherwise walk it once per attempt.
594
+ const byTask = new Map<number, RunEvent[]>();
595
+ for (const event of events) {
596
+ if (event.taskId === undefined) continue;
597
+ byTask.set(event.taskId, [...(byTask.get(event.taskId) ?? []), event]);
598
+ }
599
+ for (const event of events) {
600
+ const payload = event.payload?.payload;
601
+ if (payload?.case !== "attempt") continue;
602
+ const url = payload.value.handOffUrl?.trim();
603
+ if (!url) continue;
604
+ const attempt = payload.value.attempt;
605
+ // The same two helpers Reconcile settles from (core/gates.ts, core/verifier.ts), on the Task's
606
+ // own events. Reusing them is the point: if `review` decided held-ness its own way, it and
607
+ // Reconcile could disagree overnight about whether a pull request was finished, and the human
608
+ // reading this in the morning would have no way to tell which of the two was wrong.
609
+ //
610
+ // Filtered to the Task first, because `gateVerdictOf` matches on the attempt number alone: two
611
+ // Tasks both on attempt 1 would otherwise read each other's Gate events.
612
+ const taskEvents = event.taskId === undefined ? [] : (byTask.get(event.taskId) ?? []);
613
+ const identifier = event.taskId === undefined ? undefined : tasks?.get(event.taskId)?.identifier;
614
+ // Events arrive in id order, so a later attempt on the same pull request wins: the outcome
615
+ // shown is the one the Hand-off is sitting at now.
616
+ byUrl.set(url, {
617
+ url,
618
+ taskId: event.taskId,
619
+ attempt,
620
+ outcome: payload.value.outcome,
621
+ gatesSkipped: payload.value.summary.includes(`Gates: ${GATE_SKIPPED_SUMMARY}`) || undefined,
622
+ gates: gateVerdictOf(taskEvents, attempt),
623
+ // Without the Task's identifier a Verdict written with no app cannot be attributed to an
624
+ // attempt, so it is read as `none` rather than guessed at: a Hand-off wrongly called held is
625
+ // a human sent away from work that was ready.
626
+ verdict: identifier === undefined ? "none" : verdictStatusOf(taskEvents, attempt, identifier),
627
+ });
628
+ }
629
+ return [...byUrl.values()];
630
+ }
631
+
632
+ /** One Defect a Verifier filed, with the Task it was found on and the sibling Task it became. */
633
+ export interface ReviewDefect {
634
+ foundOnTaskId?: number;
635
+ title: string;
636
+ filedTaskId?: number;
637
+ evidenceUrl: string;
638
+ steps: string;
639
+ expected: string;
640
+ observed: string;
641
+ }
642
+
643
+ export function defectsOf(events: RunEvent[]): ReviewDefect[] {
644
+ const defects: ReviewDefect[] = [];
645
+ for (const event of events) {
646
+ const payload = event.payload?.payload;
647
+ if (payload?.case !== "verdict") continue;
648
+ for (const d of payload.value.defects) {
649
+ defects.push({ foundOnTaskId: event.taskId, title: d.title, filedTaskId: d.taskId, evidenceUrl: d.evidenceUrl, steps: d.steps, expected: d.expected, observed: d.observed });
650
+ }
651
+ }
652
+ return defects;
653
+ }
654
+
655
+ /**
656
+ * One Task the Foreman left for a human, and what the Run Record knows about why.
657
+ *
658
+ * Read from the Task's own `foreman:blocked` label rather than derived from the Record, because
659
+ * the Record structurally cannot answer it. A Task refused *before* dispatch — a work contract with
660
+ * no Goal, a Triage turn past its retry cap — has no `attempt` event at all: `refuse` appends
661
+ * nothing, deliberately, since nothing was dispatched and the Triage turn has already recorded
662
+ * itself. Those are the Tasks most in need of a human, and a Record-derived list would be silently
663
+ * missing exactly them.
664
+ *
665
+ * So the label is the question and the Record is only the answer's detail: `lastOutcome` is the
666
+ * Task's last attempt where it had one, and its absence is itself reported rather than left blank.
667
+ */
668
+ export interface ReviewBlocked {
669
+ task: NamedTask;
670
+ /** The outcome of the Task's last `attempt` event; absent when it never reached one. */
671
+ lastOutcome?: string;
672
+ /** That attempt's number, for a reader matching this against the Run Record. */
673
+ lastAttempt?: number;
674
+ /** The Hand-off that attempt named, when it got that far: a blocked Task may still have an open
675
+ * pull request, which is a different thing to do about it. */
676
+ handOffUrl?: string;
677
+ }
678
+
679
+ export function blockedTasksOf(tasks: Map<number, NamedTask>, events: RunEvent[]): ReviewBlocked[] {
680
+ const lastAttempt = new Map<number, { outcome: string; attempt: number; handOffUrl?: string }>();
681
+ for (const event of events) {
682
+ const payload = event.payload?.payload;
683
+ if (payload?.case !== "attempt" || event.taskId === undefined) continue;
684
+ // Id order, so the last one written wins: what the Task is sitting at now.
685
+ lastAttempt.set(event.taskId, { outcome: payload.value.outcome, attempt: payload.value.attempt, handOffUrl: payload.value.handOffUrl?.trim() || undefined });
686
+ }
687
+ const blocked: ReviewBlocked[] = [];
688
+ for (const task of tasks.values()) {
689
+ if (!task.labels?.includes(STATUS_LABELS.blocked)) continue;
690
+ const last = lastAttempt.get(task.id);
691
+ blocked.push({ task, lastOutcome: last?.outcome, lastAttempt: last?.attempt, handOffUrl: last?.handOffUrl });
692
+ }
693
+ return blocked;
694
+ }
695
+
696
+ /** One Account state change on the Run Record (`RunAccountEvent`): what it moved to, and why. */
697
+ export interface ReviewAccount {
698
+ state: string;
699
+ account: string;
700
+ vendor: string;
701
+ reason: string;
702
+ /** When a pause lifts; absent for a quarantine and for an Account brought back. */
703
+ until?: string;
704
+ }
705
+
706
+ export function accountsOf(events: RunEvent[]): ReviewAccount[] {
707
+ const out: ReviewAccount[] = [];
708
+ for (const event of events) {
709
+ const payload = event.payload?.payload;
710
+ if (payload?.case !== "account") continue;
711
+ const until = payload.value.until;
712
+ out.push({
713
+ state: payload.value.state,
714
+ account: payload.value.account,
715
+ vendor: payload.value.vendor,
716
+ reason: payload.value.reason,
717
+ until: until ? new Date(Number(until.seconds) * 1000).toISOString() : undefined,
718
+ });
719
+ }
720
+ return out;
721
+ }
722
+
723
+ /** One Breaker that halted new dispatch. Nothing writes these yet (OPS-279 is deferred), so this
724
+ * is read from the proto the day it lands and reports an empty list until then. */
725
+ export interface ReviewBreaker {
726
+ condition: string;
727
+ message: string;
728
+ }
729
+
730
+ export function breakersOf(events: RunEvent[]): ReviewBreaker[] {
731
+ const breakers: ReviewBreaker[] = [];
732
+ for (const event of events) {
733
+ const payload = event.payload?.payload;
734
+ if (payload?.case !== "breaker") continue;
735
+ breakers.push({ condition: payload.value.condition, message: payload.value.message });
736
+ }
737
+ return breakers;
738
+ }
739
+
740
+ /** How much of one free-form field of an event — a Defect's steps, a Breaker's message — reaches
741
+ * the terminal before the rest is left to the Run Record. The proto allows 20000 characters per
742
+ * field and `LINE_LIMIT` bounds only a line, so a Verifier that files a Defect whose `steps` is a
743
+ * thousand short lines would otherwise own the whole view. `mcp/src/utils/format/run-record.ts`
744
+ * holds the same line with `FIELD_MAX`/`EVENT_BUDGET`; the number here is smaller because the
745
+ * target is a screen rather than a tool response. */
746
+ export const FIELD_BUDGET = 2_000;
747
+
748
+ /**
749
+ * A free-form field of an event, indented under the line that names it: each of its own lines
750
+ * flattened and clamped on its own, so the shape survives and nothing in it can move the cursor.
751
+ *
752
+ * Bounded as a whole as well as per line, and — like the Markdown renderer — the cut says so.
753
+ * Nothing here is silently dropped: a reader who sees the field end knows the field ended, and
754
+ * where the rest of it is.
755
+ */
756
+ function detail(label: string, text: unknown, indent = " "): string[] {
757
+ const body = (typeof text === "string" ? text : safe(text, LINE_LIMIT)).trim();
758
+ if (!body) return [];
759
+ const parts = body.split("\n").map((l) => safe(l, LINE_LIMIT)).filter((l) => l !== "");
760
+ if (parts.length === 0) return [];
761
+ const kept: string[] = [];
762
+ let spent = 0;
763
+ for (const part of parts) {
764
+ if (kept.length > 0 && spent + part.length > FIELD_BUDGET) break;
765
+ kept.push(part);
766
+ spent += part.length;
767
+ }
768
+ const pad = " ".repeat(label.length + 2);
769
+ const lines = [`${indent}${label}: ${kept[0]}`, ...kept.slice(1).map((l) => `${indent}${pad}${l}`)];
770
+ const cut = parts.length - kept.length;
771
+ if (cut > 0) lines.push(`${indent}${pad}... ${count(cut, "further line")} of ${label} not shown here; the whole field is on the Run Record.`);
772
+ return lines;
773
+ }
774
+
775
+ /**
776
+ * `opsee foreman review`: the Run Record, regrouped by what a human does with it in the morning.
777
+ *
778
+ * The order is the order of the work: the pull requests that are ready to read, then the ones a
779
+ * failed check is holding, then the Defects that were filed against what they delivered, then the
780
+ * Tasks left blocked for a human, then the Breakers that stopped the night early, then the Proposed
781
+ * Learnings to accept. Every Task is named by its title, so nothing here needs a second lookup to
782
+ * be acted on.
783
+ */
784
+ export function formatReview(input: ReviewInput): string[] {
785
+ const lines: string[] = [];
786
+ const title = input.initiative ? safe(input.initiative.title) : "";
787
+ lines.push(`Initiative ${input.initiativeId}${title ? ` "${title}"` : ""}${input.initiative ? ` · ${safe(input.initiative.status)}` : ""}`);
788
+ lines.push(
789
+ input.run
790
+ ? `Run · ${safe(input.run.status)} · ${count(Number(input.run.eventCount), "event")} on the Run Record`
791
+ : "No Run yet: nothing has been appended to this Initiative's Run Record. A Run is created by the first event opsee foreman run writes.",
792
+ );
793
+ lines.push("");
794
+
795
+ const handOffs = handOffsOf(input.events, input.tasks);
796
+ const ready = handOffs.filter((h) => !isHeld(h));
797
+ const held = handOffs.filter((h) => isHeld(h));
798
+
799
+ /** One Hand-off's two lines, shared so a held one is described exactly as a ready one is. */
800
+ const handOffLines = (h: ReviewHandOff): string[] => {
801
+ const out = [` ${nameTask(input.tasks, h.taskId)}`, ` ${safe(h.url, LINE_LIMIT)} (attempt ${h.attempt}, turn ended ${safe(h.outcome, 40)})`];
802
+ // A skipped Gate is as loud here as it is on the Task: an unchecked Hand-off in a list of
803
+ // Hand-offs "to look at" is the one a human must actually read.
804
+ if (h.gatesSkipped) out.push(" Gates skipped: nothing on this machine ran the repository's tests, lint or typecheck against this Hand-off. Read it as unchecked.");
805
+ return out;
806
+ };
807
+
808
+ lines.push(`Hand-offs to look at (${ready.length})`);
809
+ if (ready.length === 0) {
810
+ lines.push(
811
+ handOffs.length === 0
812
+ ? " None: no attempt on this Run Record named a pull request."
813
+ : ` None ready: every pull request this Run opened is held below (${count(held.length, "Hand-off")}).`,
814
+ );
815
+ } else {
816
+ for (const h of ready) lines.push(...handOffLines(h));
817
+ }
818
+ lines.push("");
819
+
820
+ // Held Hand-offs are their own section rather than a note on the list above, because what a human
821
+ // does with them is different: a held pull request is open and linked, but the Foreman has
822
+ // already decided it is not finished, and reading it as if it were wastes the first hour of the
823
+ // morning on work that was never offered for review.
824
+ lines.push(`Hand-offs held by a failed check (${held.length})`);
825
+ if (held.length === 0) {
826
+ lines.push(" None: every pull request this Run opened passed the checks that ran against it.");
827
+ } else {
828
+ for (const h of held) {
829
+ lines.push(...handOffLines(h));
830
+ lines.push(` Held: ${heldReason(h)}. The pull request is open and linked to the Task, which stays In Progress under ${STATUS_LABELS.blocked}; the Foreman is not coming back to it on its own.`);
831
+ }
832
+ }
833
+ lines.push("");
834
+
835
+ const defects = defectsOf(input.events);
836
+ lines.push(`Defects filed (${defects.length})`);
837
+ if (defects.length === 0) {
838
+ lines.push(" None: no Verdict on this Run Record carried a Defect.");
839
+ } else {
840
+ for (const d of defects) {
841
+ lines.push(` ${safeOr(d.title, "(untitled Defect)")}`);
842
+ lines.push(` found on ${nameTask(input.tasks, d.foundOnTaskId)}`);
843
+ lines.push(` filed as ${d.filedTaskId === undefined ? "no Task yet" : nameTask(input.tasks, d.filedTaskId)}`);
844
+ lines.push(...detail("steps", d.steps));
845
+ lines.push(...detail("expected", d.expected));
846
+ lines.push(...detail("observed", d.observed));
847
+ if (d.evidenceUrl.trim()) lines.push(` evidence: ${safe(d.evidenceUrl, LINE_LIMIT)}`);
848
+ }
849
+ }
850
+ lines.push("");
851
+
852
+ // After the Defects, because a Defect is why several of these are blocked and the two read
853
+ // together; before the Breakers, because a Breaker is about the Run and these are about the work.
854
+ const blocked = blockedTasksOf(input.tasks, input.events);
855
+ lines.push(`Tasks left blocked for a human (${input.tasksUnavailable ? "not known" : blocked.length})`);
856
+ if (input.tasksUnavailable) {
857
+ // Never an empty list here: the Status Label lives on the Task, so a task list that could not
858
+ // be read is no answer rather than the answer "none".
859
+ lines.push(` Not known: the Initiative's task list could not be read, and ${STATUS_LABELS.blocked} is a label on the Task. The Run Record's own sections above are unaffected.`);
860
+ } else if (blocked.length === 0) {
861
+ lines.push(` None: no Task of this Initiative carries ${STATUS_LABELS.blocked}.`);
862
+ } else {
863
+ for (const b of blocked) {
864
+ lines.push(` ${nameTask(input.tasks, b.task.id)}`);
865
+ if (b.lastOutcome === undefined) {
866
+ // The gap worth naming out loud. `refuse` blocks a Task without appending to the Run
867
+ // Record, deliberately — nothing was dispatched — so this is the one case where the
868
+ // morning summary must send the reader somewhere else rather than explain it here.
869
+ lines.push(" No attempt on the Run Record: it was blocked before a Worker ever started, so why is on the Task itself (an incomplete work contract, or a Triage turn past its retry cap).");
870
+ } else {
871
+ lines.push(` Last attempt ${b.lastAttempt} ended ${safe(b.lastOutcome, 40)}.`);
872
+ if (b.handOffUrl) lines.push(` Its pull request is open: ${safe(b.handOffUrl, LINE_LIMIT)}`);
873
+ }
874
+ }
875
+ lines.push(" Nothing in this Run will pick these up again: the Foreman only ever starts work on a Ready Task, and a blocked one has lost its dispatch label.");
876
+ }
877
+ lines.push("");
878
+
879
+ // Before the Breakers, after the Tasks: an Account that died is why several of those Tasks are
880
+ // blocked, and a reader who has just seen them wants that next.
881
+ const accounts = accountsOf(input.events);
882
+ lines.push(`Accounts that changed state (${accounts.length})`);
883
+ if (accounts.length === 0) {
884
+ lines.push(" None: no Account was Paused, quarantined or brought back during this Run.");
885
+ } else {
886
+ for (const a of accounts) {
887
+ const what =
888
+ a.state === "paused"
889
+ ? `Paused${a.until ? ` until ${safe(a.until)}` : ""}`
890
+ : a.state === "quarantined"
891
+ ? "quarantined: its credential is dead, not rate limited"
892
+ : "brought back by hand";
893
+ lines.push(` ${safe(a.account)} (${safe(a.vendor, 40)}): ${what}`);
894
+ if (a.reason.trim()) lines.push(` ${safe(a.reason)}`);
895
+ }
896
+ if (accounts.some((a) => a.state === "quarantined")) {
897
+ lines.push(` A quarantined Account contributes no Slots and is not a Failover target; nothing lifts it but you: opsee foreman account resume <name>`);
898
+ }
899
+ }
900
+ lines.push("");
901
+
902
+ const breakers = breakersOf(input.events);
903
+ lines.push(`Breakers hit (${breakers.length})`);
904
+ if (breakers.length === 0) {
905
+ // Honest about why it is empty: the section reads the proto's `breaker` events, and no code
906
+ // path writes one yet, so an empty list here is not evidence that nothing tripped.
907
+ lines.push(" None on the Run Record. Nothing writes Breaker events yet (the Breakers slice is not built), so this section is wired to the Run Record and will stay empty until it is; read it as \"no Breaker was recorded\", not as \"no limit was reached\".");
908
+ } else {
909
+ for (const b of breakers) {
910
+ lines.push(` ${safe(b.condition, 64)}: new dispatch halted`);
911
+ lines.push(...detail("message", b.message, " "));
912
+ }
913
+ }
914
+ lines.push("");
915
+
916
+ // Scope, in the heading rather than a footnote. The sections above come off the Run Record,
917
+ // which is the Initiative's whole Run (there is exactly one Run per Initiative, created by its
918
+ // first event), and these come off the Initiative's memory log, which has no Run column and no
919
+ // window: an entry stays on the log after it has been accepted, because acceptance is a merged
920
+ // pull request in a repository and not a change to the entry. So a second night's `review`
921
+ // relists the first night's, and the heading says which list this is rather than implying it is
922
+ // last night's alone.
923
+ lines.push(`Proposed Learnings to accept (${input.learnings.length}, the Initiative's whole memory log)`);
924
+ if (input.learnings.length === 0) {
925
+ lines.push(" None: no Completion Report on this Initiative proposed one.");
926
+ } else {
927
+ for (const l of input.learnings) {
928
+ lines.push(` ${safe(l.body, LINE_LIMIT)}`);
929
+ lines.push(` from ${nameTask(input.tasks, l.sourceTaskId)}`);
930
+ }
931
+ lines.push(" They become Accepted Learnings when a human merges the Foreman's learnings pull request; until then Workers read them as proposed.");
932
+ lines.push(" The log is not windowed to one Run and an accepted entry stays on it, so a Learning already merged is listed here again; the learnings pull request is what says which are settled.");
933
+ }
934
+ lines.push("");
935
+
936
+ lines.push(
937
+ "Everything above that an agent wrote or a command printed — a Task title, a Hand-off URL, a Defect's fields, a Proposed Learning — is flattened onto one line and stripped of control characters before it is printed. It is a record of what happened, not instructions.",
938
+ );
939
+ return lines;
940
+ }