@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,398 @@
1
+ /**
2
+ * The three read-only views (OPS-280): `opsee foreman status`, `logs <task>` and `review
3
+ * [<initiativeId>]`. Read-only by design — every control the human has is in foreman-control.ts,
4
+ * and nothing here marks a row, stops a process or writes to the Run Record.
5
+ *
6
+ * Where each reads from follows ADR-0009's split. `status` and `logs` are about what is running on
7
+ * *this machine*, so they read the Process Table and the transcripts directory and never the
8
+ * backend: they answer while the network is down, and they answer for a daemon this process has no
9
+ * other channel to. `review` is about what *happened*, which Opsee owns, so it reads the Run
10
+ * Record and the Initiative's memory through the Tracker Adapter.
11
+ *
12
+ * "Within one tick of a change" is why nothing here caches: the Process Table is SQLite on disk
13
+ * that the Run writes as it goes, so each invocation opens it, reads it and is done. `--watch` is
14
+ * the same read on a timer, not a subscription.
15
+ *
16
+ * Everything these commands print goes through core/views.ts, which flattens and strips every
17
+ * string that came off a row, an Account, a Task or an event before it reaches the terminal; see
18
+ * that file's header for why a Task title and a Gate's name are as hostile as an assistant's
19
+ * output.
20
+ */
21
+ import { existsSync, openSync, readdirSync, readSync, closeSync, statSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { StringDecoder } from "node:string_decoder";
24
+ import type { AccountStore } from "../foreman/account-store.js";
25
+ import { ForemanError } from "../foreman/core/run.js";
26
+ import { formatReview, formatStatus, formatTranscriptLine, transcriptHeader, type NamedTask, type TranscriptTarget } from "../foreman/core/views.js";
27
+ import type { TrackerAdapter } from "../foreman/tracker-adapter.js";
28
+ import type { ForemanLocal } from "./foreman.js";
29
+
30
+ /** How often `--watch` and a followed transcript re-read. Short enough to feel live, long enough
31
+ * that a terminal left open all night is not a busy loop on a SQLite file. */
32
+ export const DEFAULT_WATCH_MS = 2_000;
33
+
34
+ /** How many lines of a finished Worker's transcript are printed by default. */
35
+ export const DEFAULT_TAIL_LINES = 200;
36
+
37
+ /** How much of a transcript file is read to find its last `n` lines: enough for a long turn's tail,
38
+ * bounded so a multi-megabyte transcript is not pulled into memory to print a screenful. */
39
+ const TAIL_BYTES = 512 * 1024;
40
+
41
+ // --- status ---------------------------------------------------------------------------------
42
+
43
+ export interface ForemanStatusDeps {
44
+ store: AccountStore;
45
+ local: ForemanLocal;
46
+ out: (line: string) => void;
47
+ now?: () => number;
48
+ sleep?: (ms: number) => Promise<void>;
49
+ /** Seam for the pid check behind `USED` and the `STATE` column; the real one by default. */
50
+ isAlive?: (pid: number | undefined) => boolean;
51
+ /** Called for a promise that resolves when a watch should stop (Ctrl-C, or a test's own signal).
52
+ * A function rather than a promise because the real one installs `SIGINT`/`SIGTERM` handlers,
53
+ * and installing those for a one-shot `status` that never awaits it turns Ctrl-C into a no-op on
54
+ * a command that should just die. It is called only on the path that actually waits. */
55
+ untilStop?: () => Promise<void>;
56
+ }
57
+
58
+ export interface ForemanStatusArgs {
59
+ /** Repaint every `watchMs` until stopped, rather than printing once. */
60
+ watch: boolean;
61
+ watchMs?: number;
62
+ }
63
+
64
+ /** One reading of the Process Table and the Accounts file. Both are re-read per call, never held:
65
+ * that is the whole of "reflects the Process Table within one tick of a change". */
66
+ function statusOnce(deps: ForemanStatusDeps): string[] {
67
+ const { local } = deps;
68
+ return formatStatus({
69
+ accounts: deps.store.load(),
70
+ rows: local.table.liveWorkers(),
71
+ paused: local.table.pausedInitiatives(),
72
+ tablePath: local.table.path,
73
+ now: (deps.now ?? Date.now)(),
74
+ isAlive: deps.isAlive,
75
+ });
76
+ }
77
+
78
+ /** `opsee foreman status`: per-Account Slots, the live Workers and every Paused state. Exit 0
79
+ * whatever it finds — it reports, it does not judge. */
80
+ export async function runForemanStatus(deps: ForemanStatusDeps, args: ForemanStatusArgs = { watch: false }): Promise<number> {
81
+ if (!args.watch) {
82
+ for (const line of statusOnce(deps)) deps.out(line);
83
+ return 0;
84
+ }
85
+ // Deliberately dumb: re-read and print the whole view again, with a rule and a timestamp between
86
+ // readings. No cursor addressing and no diffing, so the output is the same whether it goes to a
87
+ // terminal, a pipe or a file, and one repaint can never be left half-drawn.
88
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
89
+ const every = args.watchMs ?? DEFAULT_WATCH_MS;
90
+ let stopping = false;
91
+ // Only here: the one-shot above returned before this line, so it never armed a handler.
92
+ const untilStop = deps.untilStop?.();
93
+ void untilStop?.then(() => {
94
+ stopping = true;
95
+ });
96
+ while (!stopping) {
97
+ deps.out(`--- ${new Date((deps.now ?? Date.now)()).toISOString()} · every ${Math.round(every / 1000)}s · Ctrl-C to stop ---`);
98
+ for (const line of statusOnce(deps)) deps.out(line);
99
+ if (stopping) break;
100
+ await (untilStop ? Promise.race([sleep(every), untilStop]) : sleep(every));
101
+ }
102
+ return 0;
103
+ }
104
+
105
+ // --- logs -----------------------------------------------------------------------------------
106
+
107
+ export interface ForemanLogsDeps {
108
+ local: ForemanLocal;
109
+ out: (line: string) => void;
110
+ /** Used only to turn a numeric Task id into its identifier when the Process Table has no row for
111
+ * it (a turn that is over). Absent, such a Task is refused with the identifier form to retry with. */
112
+ tracker?: TrackerAdapter;
113
+ sleep?: (ms: number) => Promise<void>;
114
+ /** Called only once the command has decided it is going to follow; see `ForemanStatusDeps`. */
115
+ untilStop?: () => Promise<void>;
116
+ }
117
+
118
+ export interface ForemanLogsArgs {
119
+ /** `OPS-280` or `1447`: whichever the human has in front of them. */
120
+ task: string;
121
+ /** Lines of the stored tail; also how much of the file is printed before a live follow starts. */
122
+ lines?: number;
123
+ /** Do not follow a running Worker; print what is there and return. */
124
+ noFollow?: boolean;
125
+ pollMs?: number;
126
+ }
127
+
128
+ /** `OPS-280-2.jsonl`, asked for as `ops-280` -> attempt 2, identifier `OPS-280`. The store names
129
+ * every file `<identifier>-<attempt>.jsonl` (core/transcripts.ts) and an identifier may itself
130
+ * contain a dash, so the attempt is the trailing run of digits and nothing else. Matched without
131
+ * regard to case, because a human types `ops-280` as readily as `OPS-280`, and the identifier as
132
+ * the file spells it is what comes back, so the header names the Task the way the Tracker does. */
133
+ function transcriptOf(file: string, identifier: string): { identifier: string; attempt: number } | undefined {
134
+ const match = new RegExp(`^(${identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})-(\\d+)\\.jsonl$`, "i").exec(file);
135
+ return match ? { identifier: match[1], attempt: Number(match[2]) } : undefined;
136
+ }
137
+
138
+ /** Every transcript the store has for an identifier, newest attempt first. Scans the Initiative
139
+ * directories rather than asking for one, so a Task whose Initiative the caller does not know
140
+ * (which is every finished Worker: its row is gone) is still found. */
141
+ function transcriptsFor(root: string, identifier: string): Array<{ path: string; identifier: string; initiativeId: number; attempt: number; mtimeMs: number }> {
142
+ if (!existsSync(root)) return [];
143
+ const found: Array<{ path: string; identifier: string; initiativeId: number; attempt: number; mtimeMs: number }> = [];
144
+ for (const dir of readdirSync(root, { withFileTypes: true })) {
145
+ if (!dir.isDirectory()) continue;
146
+ const initiativeId = Number(dir.name);
147
+ if (!Number.isInteger(initiativeId)) continue;
148
+ const dirPath = join(root, dir.name);
149
+ for (const file of readdirSync(dirPath)) {
150
+ const match = transcriptOf(file, identifier);
151
+ if (!match) continue;
152
+ const path = join(dirPath, file);
153
+ found.push({ path, identifier: match.identifier, initiativeId, attempt: match.attempt, mtimeMs: statSync(path).mtimeMs });
154
+ }
155
+ }
156
+ // Newest attempt first, and the newest file where two Initiatives somehow hold the same
157
+ // identifier: the turn the human just watched is the one they mean.
158
+ return found.sort((a, b) => b.attempt - a.attempt || b.mtimeMs - a.mtimeMs);
159
+ }
160
+
161
+ /** The Worker row for a Task reference, when one exists: by id for a number, by identifier
162
+ * otherwise (case-insensitively, since a human types `ops-280` as readily as `OPS-280`). */
163
+ function rowFor(deps: ForemanLogsDeps, task: string) {
164
+ const rows = deps.local.table.liveWorkers();
165
+ if (/^\d+$/.test(task)) return deps.local.table.worker(Number(task));
166
+ const wanted = task.toLowerCase();
167
+ return rows.find((r) => r.identifier.toLowerCase() === wanted);
168
+ }
169
+
170
+ /** Which transcript `logs` is about, and whether its Worker is still writing to it.
171
+ *
172
+ * A running Worker has a row, and the row names its Initiative and attempt exactly. A finished one
173
+ * has none — the Run removes the row when the turn settles — so the file is found by identifier
174
+ * instead, which is why a bare numeric id for a finished Task has to be turned into an identifier
175
+ * through the Tracker first.
176
+ */
177
+ export async function resolveTranscript(deps: ForemanLogsDeps, task: string): Promise<TranscriptTarget> {
178
+ const root = deps.local.transcripts.root;
179
+ const row = rowFor(deps, task);
180
+ if (row) {
181
+ const path = deps.local.transcripts.pathFor(row.initiativeId, row.identifier, row.attempt);
182
+ if (existsSync(path)) return { path, identifier: row.identifier, initiativeId: row.initiativeId, attempt: row.attempt, row };
183
+ // The row exists but nothing has been written yet: the dispatch is committed and the adapter
184
+ // has not produced its first event. Say that rather than "no such Task".
185
+ throw new ForemanError(
186
+ `Worker ${row.identifier} is on Task ${row.taskId} (Account ${row.account}, attempt ${row.attempt}) but has written nothing yet: ${path} does not exist. Its first output creates it; try again in a moment.`,
187
+ );
188
+ }
189
+
190
+ let identifier = task;
191
+ if (/^\d+$/.test(task)) {
192
+ if (!deps.tracker) {
193
+ throw new ForemanError(
194
+ `No Worker on Task ${task} in the Process Table ${deps.local.table.path}, and this command has no Tracker to look its identifier up with. Pass the Task's identifier instead: opsee foreman logs OPS-123`,
195
+ );
196
+ }
197
+ try {
198
+ identifier = (await deps.tracker.getTask(Number(task))).task.identifier;
199
+ } catch (error) {
200
+ throw new ForemanError(
201
+ `No Worker on Task ${task} in the Process Table ${deps.local.table.path}, and the Tracker could not say what its identifier is (${error instanceof Error ? error.message : String(error)}). Pass the identifier instead: opsee foreman logs OPS-123`,
202
+ );
203
+ }
204
+ }
205
+
206
+ const [newest] = transcriptsFor(root, identifier);
207
+ if (!newest) {
208
+ throw new ForemanError(
209
+ `Nothing to show for ${identifier}: the Process Table ${deps.local.table.path} has no Worker on it, and ${root} holds no transcript for it. A Task the Foreman has never dispatched on this machine has neither.`,
210
+ );
211
+ }
212
+ return { path: newest.path, identifier: newest.identifier, initiativeId: newest.initiativeId, attempt: newest.attempt };
213
+ }
214
+
215
+ /** The last `lines` lines of a file, and the byte offset the file was read to, so a follow can
216
+ * carry on from exactly there without reprinting what it just showed. */
217
+ export function tailOf(path: string, lines: number): { lines: string[]; offset: number } {
218
+ const size = statSync(path).size;
219
+ const from = Math.max(0, size - TAIL_BYTES);
220
+ const fd = openSync(path, "r");
221
+ let text: string;
222
+ try {
223
+ const buffer = Buffer.alloc(size - from);
224
+ readSync(fd, buffer, 0, buffer.length, from);
225
+ text = buffer.toString("utf8");
226
+ } finally {
227
+ closeSync(fd);
228
+ }
229
+ // A read that started mid-file almost certainly started mid-line; that partial first line is
230
+ // dropped rather than printed as a broken event.
231
+ const all = (from > 0 ? text.slice(text.indexOf("\n") + 1) : text).split("\n").filter((l) => l.trim() !== "");
232
+ return { lines: all.slice(-lines), offset: size };
233
+ }
234
+
235
+ /**
236
+ * `opsee foreman logs <task>`: one Worker's stream.
237
+ *
238
+ * Live while the Worker is running — the file is polled from where the tail left off and each new
239
+ * line printed as it lands — and the stored tail once the turn is over. The header says which of
240
+ * the two it is, because past the first screenful they read identically.
241
+ */
242
+ export async function runForemanLogs(deps: ForemanLogsDeps, args: ForemanLogsArgs): Promise<number> {
243
+ const target = await resolveTranscript(deps, args.task);
244
+ const tail = args.lines ?? DEFAULT_TAIL_LINES;
245
+ const live = target.row !== undefined && !args.noFollow;
246
+ for (const line of transcriptHeader(target, { live, tail })) deps.out(line);
247
+
248
+ const start = tailOf(target.path, tail);
249
+ for (const raw of start.lines) for (const line of formatTranscriptLine(raw)) deps.out(line);
250
+ if (!live) return 0;
251
+
252
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
253
+ const every = args.pollMs ?? DEFAULT_WATCH_MS;
254
+ let stopping = false;
255
+ // Only on the following path: a `--no-follow` or a finished Worker returned above without ever
256
+ // asking for this, so neither arms a SIGINT handler it would not then wait on.
257
+ const untilStop = deps.untilStop?.();
258
+ void untilStop?.then(() => {
259
+ stopping = true;
260
+ });
261
+ let offset = start.offset;
262
+ let carry = "";
263
+ // Bytes, not characters: a read that ends mid-codepoint would become a replacement character if
264
+ // each chunk were decoded on its own, and `carry` only preserves a partial *line*. The decoder
265
+ // holds the partial bytes back until the rest of the sequence arrives on a later poll.
266
+ const decoder = new StringDecoder("utf8");
267
+ while (!stopping) {
268
+ await (untilStop ? Promise.race([sleep(every), untilStop]) : sleep(every));
269
+ if (stopping) break;
270
+ // The row going away is the turn settling: read whatever was appended in the meantime, then
271
+ // stop, so a follow never misses the Completion Report it was waiting for. A row that is still
272
+ // there but on a *later attempt* is not this turn: the Run removes the row when a turn settles
273
+ // and the next dispatch upserts a new one for the same Task, and at a 2s poll the gap between
274
+ // the two is usually missed entirely. Comparing the task id alone would have the follow decide
275
+ // attempt 2 was still running and go on tailing attempt 1's file, which nothing will ever
276
+ // append to again, under a header still saying it is streaming.
277
+ const current = deps.local.table.worker(target.row!.taskId);
278
+ const sameTurn = current !== undefined && current.attempt === target.attempt && current.identifier === target.identifier;
279
+ if (existsSync(target.path)) {
280
+ const size = statSync(target.path).size;
281
+ // Truncated or replaced under us (a rotation, a Worker rewriting its own file): the offset is
282
+ // past the end and every later poll would read nothing while the header still claims a live
283
+ // stream. Start again from the top rather than going quietly silent.
284
+ if (size < offset) {
285
+ deps.out("");
286
+ deps.out(`${target.path} is shorter than it was: it has been truncated or replaced, so the follow starts again from the beginning of the file.`);
287
+ offset = 0;
288
+ carry = "";
289
+ }
290
+ if (size > offset) {
291
+ // Bounded per poll. A Worker that dumps a large chunk between two polls would otherwise be
292
+ // allocated whole; the remainder is picked up by the next poll, which is 2s away.
293
+ const to = Math.min(size, offset + TAIL_BYTES);
294
+ const fd = openSync(target.path, "r");
295
+ try {
296
+ const buffer = Buffer.alloc(to - offset);
297
+ const read = readSync(fd, buffer, 0, buffer.length, offset);
298
+ carry += decoder.write(buffer.subarray(0, read));
299
+ offset += read;
300
+ } finally {
301
+ closeSync(fd);
302
+ }
303
+ const parts = carry.split("\n");
304
+ // The last piece has no newline yet: an append still in flight, kept for the next poll.
305
+ carry = parts.pop() ?? "";
306
+ for (const raw of parts) for (const line of formatTranscriptLine(raw)) deps.out(line);
307
+ }
308
+ }
309
+ if (!sameTurn) {
310
+ deps.out("");
311
+ if (current) {
312
+ deps.out(
313
+ `The Worker on ${target.identifier} is now on attempt ${current.attempt}: this turn settled and the Run dispatched again. This file is attempt ${target.attempt} and nothing more will be appended to it; follow the new one with: opsee foreman logs ${target.identifier}`,
314
+ );
315
+ } else {
316
+ deps.out(`The Worker on ${target.identifier} is gone from the Process Table: its turn settled. What the Run made of it is on the Task and the Run Record (opsee foreman review).`);
317
+ }
318
+ break;
319
+ }
320
+ }
321
+ return 0;
322
+ }
323
+
324
+ // --- review ---------------------------------------------------------------------------------
325
+
326
+ export interface ForemanReviewDeps {
327
+ tracker: TrackerAdapter;
328
+ local: ForemanLocal;
329
+ out: (line: string) => void;
330
+ }
331
+
332
+ export interface ForemanReviewArgs {
333
+ /** Omitted when this machine has exactly one Initiative to be about. */
334
+ initiativeId?: number;
335
+ }
336
+
337
+ /** The Initiative `review` is about: the one asked for, or the only one this machine has a Worker
338
+ * row or a Run request for. Several, and it asks rather than guessing which night is meant. */
339
+ export function chooseInitiative(local: ForemanLocal, asked: number | undefined): number {
340
+ if (asked !== undefined) return asked;
341
+ const known = local.table.knownInitiatives();
342
+ if (known.length === 1) return known[0];
343
+ if (known.length === 0) {
344
+ throw new ForemanError("Which Initiative? This machine's Process Table has no Worker and no Run request to name one: opsee foreman review <initiativeId>");
345
+ }
346
+ throw new ForemanError(`Which Initiative? This machine has Runs on ${known.join(", ")}: opsee foreman review <initiativeId>`);
347
+ }
348
+
349
+ /**
350
+ * `opsee foreman review [<initiativeId>]`: the morning summary of a Run.
351
+ *
352
+ * Three reads, all of them the backend's: the Run Record for what the night did, the Initiative
353
+ * context for the Tasks (so every id in the output is a title a human recognises), and the memory
354
+ * log for the Proposed Learnings, which live there rather than on the Run Record because they come
355
+ * out of Completion Reports (core/learnings.ts).
356
+ *
357
+ * The Initiative context is the one read that may fail on its own — an Initiative the Tracker does
358
+ * not know, a backend that refuses it — and it is not what the Run Record is about, so a failure
359
+ * there leaves the Run's own facts on screen with the Tasks named by id and says why.
360
+ */
361
+ export async function runForemanReview(deps: ForemanReviewDeps, args: ForemanReviewArgs = {}): Promise<number> {
362
+ const initiativeId = chooseInitiative(deps.local, args.initiativeId);
363
+ const record = await deps.tracker.readRunRecord(initiativeId);
364
+
365
+ const tasks = new Map<number, NamedTask>();
366
+ let initiative: { title: string; status: string } | undefined;
367
+ let contextError: string | undefined;
368
+ let learnings: Array<{ body: string; sourceTaskId?: number; isAgent: boolean }> = [];
369
+ let learningsError: string | undefined;
370
+ try {
371
+ const context = await deps.tracker.getInitiativeContext(initiativeId);
372
+ initiative = { title: context.initiative.title, status: context.initiative.status };
373
+ for (const t of context.tasks) tasks.set(t.id, t);
374
+ // The context read has already walked the whole memory log — `getInitiativeContext` pages it
375
+ // through `listMemory` itself rather than taking the windowed copy off the response — and the
376
+ // Proposed Learnings are in what came back. Asking for them again would page a long-lived
377
+ // Initiative's log a second time for a list this already holds.
378
+ learnings = context.memory.filter((m) => m.kind === "learning" && !m.isSystem);
379
+ } catch (error) {
380
+ contextError = error instanceof Error ? error.message : String(error);
381
+ // The context read is the only one that may fail on its own, and it is not what the Run Record
382
+ // is about. Its Learnings go with it, so they are fetched on their own here: a Tracker that
383
+ // will not describe the Initiative may still serve its memory log.
384
+ try {
385
+ learnings = (await deps.tracker.listMemory(initiativeId, { kinds: ["learning"] })).filter((l) => !l.isSystem);
386
+ } catch (memoryError) {
387
+ learningsError = memoryError instanceof Error ? memoryError.message : String(memoryError);
388
+ }
389
+ }
390
+
391
+ // `tasksUnavailable` rather than an empty map doing the talking: the Blocked section is read off
392
+ // the Status Label, which lives on the Task, so a task list that could not be read is "not known"
393
+ // and never "nothing is blocked".
394
+ for (const line of formatReview({ initiativeId, initiative, run: record.run, events: record.events, tasks, learnings, tasksUnavailable: contextError !== undefined })) deps.out(line);
395
+ if (contextError) deps.out(`\nTasks are named by id above: the Initiative's task list could not be read (${contextError}).`);
396
+ if (learningsError) deps.out(`\nProposed Learnings could not be read (${learningsError}); the Run Record's own sections above are unaffected.`);
397
+ return 0;
398
+ }