@bli-cockpit/cli 0.2.38 → 0.2.39

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.
@@ -0,0 +1,490 @@
1
+ /**
2
+ * `cockpit notes` — the meeting-notes surface in the terminal (BLI-3459).
3
+ *
4
+ * The library, one note, your own shelf, putting a note in from a file or the
5
+ * clipboard, sharing it and taking it back, and moving it to another shelf.
6
+ * Everything `/meeting-notes` does in a browser, typed.
7
+ *
8
+ * This file owns terminal input and output ONLY. It holds no access rule, no
9
+ * naming scheme and no idea what a note may contain: every command is one call
10
+ * to a dashboard route, which runs the same code the page runs. In particular a
11
+ * paste goes through `POST /api/notes/upload` and never near the ingester
12
+ * directly — the route is what supplies `submittedAt` (BLI-3336), which is the
13
+ * whole reason a note that names no day of its own is stored as a dated context
14
+ * note instead of refused.
15
+ *
16
+ * Two contracts the shape enforces:
17
+ *
18
+ * - **stdout is the answer, stderr is the commentary.** A `--json` run puts
19
+ * exactly one object on stdout; progress, receipts and reasons go to stderr,
20
+ * the same split the rest of the CLI keeps.
21
+ * - **Sharing is a deliberate act.** `share` asks in a terminal and requires
22
+ * `--yes` without one. `--json` implies `--yes`, because a machine-readable
23
+ * run has nobody to ask and asking it to guess is worse than the flag it
24
+ * already typed the command for.
25
+ */
26
+ import { isInteractiveStdin, readLine, writeLine, yesByDefault } from "./cli-io.js";
27
+ import { NOTE_SLOW_UPLOAD_BYTES, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
28
+ import { loadPairedSession, towerFailureDetail, towerRequest, } from "../tower-client.js";
29
+ import { readResponseJson } from "../upload-http.js";
30
+ /**
31
+ * The upload route's own ceiling is `maxDuration = 300` — reading a note is one
32
+ * model call over the whole file. The client waits slightly longer so the
33
+ * server's named failure wins the race whenever it manages to send one.
34
+ */
35
+ const UPLOAD_DEADLINE_MS = 305_000;
36
+ const READ_DEADLINE_MS = 60_000;
37
+ const PASTE_MAX_CHARS = 2_000_000;
38
+ const TAG = "[notes cli]";
39
+ export async function runNotes(command, io) {
40
+ const session = await loadPairedSession("notes", command.homeDir);
41
+ const door = {
42
+ dashboardUrl: command.dashboardUrl ?? session.dashboard_url,
43
+ deviceToken: session.device_token,
44
+ io,
45
+ json: command.json,
46
+ };
47
+ switch (command.action) {
48
+ case "list":
49
+ return listNotes(command, door);
50
+ case "shelves":
51
+ return listShelves(command, door);
52
+ case "show":
53
+ return showNote(command, door);
54
+ case "shelf":
55
+ return showShelf(command, door);
56
+ case "upload":
57
+ return uploadNotes(command, door);
58
+ case "paste":
59
+ return pasteNote(command, door);
60
+ case "share":
61
+ case "unshare":
62
+ return shareNote(command, door);
63
+ case "move":
64
+ return moveNote(command, door);
65
+ }
66
+ }
67
+ function libraryQuery(command) {
68
+ const query = new URLSearchParams();
69
+ if (command.series)
70
+ query.set("series", command.series);
71
+ if (command.meetingKind)
72
+ query.set("kind", command.meetingKind);
73
+ if (command.since)
74
+ query.set("since", command.since);
75
+ if (command.until)
76
+ query.set("until", command.until);
77
+ if (command.limit !== undefined)
78
+ query.set("limit", String(command.limit));
79
+ const rendered = query.toString();
80
+ return rendered === "" ? "" : `?${rendered}`;
81
+ }
82
+ async function listNotes(command, door) {
83
+ const answer = await ask(door, {
84
+ path: `/api/notes/library${libraryQuery(command)}`,
85
+ method: "GET",
86
+ label: "notes list",
87
+ timeoutMs: READ_DEADLINE_MS,
88
+ });
89
+ if (!answer.ok)
90
+ return fail(door, answer.reason, answer.detail);
91
+ const body = answer.body;
92
+ if (door.json)
93
+ return emit(door, body);
94
+ sayScope(door, body);
95
+ const series = body.series ?? [];
96
+ if (series.length === 0) {
97
+ writeLine(door.io.stdout, "No meeting notes matched.");
98
+ return 0;
99
+ }
100
+ for (const one of series) {
101
+ writeLine(door.io.stdout, "");
102
+ writeLine(door.io.stdout, `${one.heading} (${one.notes.length})`);
103
+ for (const note of one.notes) {
104
+ const who = note.participants.length > 0 ? ` — ${note.participants.join(", ")}` : "";
105
+ writeLine(door.io.stdout, ` ${note.meetingDate} ${note.id} ${note.fileName}${who}`);
106
+ }
107
+ }
108
+ writeLine(door.io.stdout, "");
109
+ writeLine(door.io.stdout, `${body.count ?? 0} note(s)${body.more ? ", and more exist beyond the limit" : ""}.`);
110
+ return 0;
111
+ }
112
+ async function listShelves(command, door) {
113
+ const answer = await ask(door, {
114
+ path: `/api/notes/library${libraryQuery(command)}`,
115
+ method: "GET",
116
+ label: "notes shelves",
117
+ timeoutMs: READ_DEADLINE_MS,
118
+ });
119
+ if (!answer.ok)
120
+ return fail(door, answer.reason, answer.detail);
121
+ const body = answer.body;
122
+ const shelves = (body.series ?? []).map((one) => ({
123
+ shelf: one.heading,
124
+ notes: one.notes.length,
125
+ // A shelf somebody typed, rather than the one a kind implies. Only the
126
+ // first kind is reported: a shelf is free text and can hold any of them.
127
+ custom: (body.categories ?? []).includes(one.heading),
128
+ }));
129
+ if (door.json)
130
+ return emit(door, { scope: body.scope, shelves });
131
+ sayScope(door, body);
132
+ if (shelves.length === 0) {
133
+ writeLine(door.io.stdout, "No shelves yet.");
134
+ return 0;
135
+ }
136
+ for (const shelf of shelves) {
137
+ writeLine(door.io.stdout, `${String(shelf.notes).padStart(4)} ${shelf.shelf}${shelf.custom ? "" : " (from the meeting kind)"}`);
138
+ }
139
+ return 0;
140
+ }
141
+ async function showNote(command, door) {
142
+ const answer = await ask(door, {
143
+ path: `/api/notes/library/${encodeURIComponent(command.noteId ?? "")}`,
144
+ method: "GET",
145
+ label: "notes show",
146
+ timeoutMs: READ_DEADLINE_MS,
147
+ });
148
+ if (!answer.ok)
149
+ return fail(door, answer.reason, answer.detail);
150
+ const body = answer.body;
151
+ if (door.json)
152
+ return emit(door, body);
153
+ const note = body.note;
154
+ if (!note)
155
+ return fail(door, "no_note_in_answer", "Tower answered without a note.");
156
+ sayScope(door, body);
157
+ writeLine(door.io.stdout, note.title);
158
+ writeLine(door.io.stdout, `${note.meetingDate} · ${note.shelf} · ${note.fileName} · ${note.lineCount} lines`);
159
+ if (note.participants.length > 0) {
160
+ writeLine(door.io.stdout, `In the room: ${note.participants.join(", ")}`);
161
+ }
162
+ writeLine(door.io.stdout, note.visibility);
163
+ writeLine(door.io.stdout, "");
164
+ writeLine(door.io.stdout, note.content);
165
+ return 0;
166
+ }
167
+ async function showShelf(command, door) {
168
+ const query = command.limit === undefined ? "" : `?limit=${command.limit}`;
169
+ const answer = await ask(door, {
170
+ path: `/api/notes/shelf${query}`,
171
+ method: "GET",
172
+ label: "notes shelf",
173
+ timeoutMs: READ_DEADLINE_MS,
174
+ });
175
+ if (!answer.ok)
176
+ return fail(door, answer.reason, answer.detail);
177
+ const body = answer.body;
178
+ if (door.json)
179
+ return emit(door, body);
180
+ sayScope(door, body);
181
+ const notes = body.notes ?? [];
182
+ if (notes.length === 0) {
183
+ writeLine(door.io.stdout, "You have not put any notes in yet.");
184
+ return 0;
185
+ }
186
+ for (const note of notes) {
187
+ const counts = note.countsKnown
188
+ ? `${note.statements} statements, ${note.openToTheTeam} open to the team, ${note.keptBack} kept back`
189
+ : "counts unknown on this server";
190
+ writeLine(door.io.stdout, `${note.meetingDate} ${note.id} ${note.shared ? "shared " : "yours "} ${note.name}`);
191
+ writeLine(door.io.stdout, ` ${counts}`);
192
+ }
193
+ return 0;
194
+ }
195
+ async function uploadNotes(command, door) {
196
+ const paths = command.paths ?? [];
197
+ const results = [];
198
+ let worstExit = 0;
199
+ for (const filePath of paths) {
200
+ // Read and screen locally BEFORE any network call — a refusal here never
201
+ // reaches the dashboard, same discipline as `cockpit jarvis --image`.
202
+ const read = await readNoteFile(filePath);
203
+ if (!read.ok) {
204
+ const sentence = noteFileRefusalSentence(read.refusal, filePath);
205
+ writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: read.refusal, detail: read.detail })}`);
206
+ if (!door.json)
207
+ writeLine(door.io.stderr, sentence);
208
+ results.push({ path: filePath, ok: false, reason: read.refusal });
209
+ worstExit = 1;
210
+ continue;
211
+ }
212
+ if (read.bytes.byteLength >= NOTE_SLOW_UPLOAD_BYTES) {
213
+ // The route reads the whole note with one model call and may take
214
+ // minutes. A still cursor reads as a hang, so say what is happening.
215
+ writeLine(door.io.stderr, `Reading ${read.fileName} (${Math.round(read.bytes.byteLength / 1024)} KB). This can take a couple of minutes.`);
216
+ }
217
+ const form = new FormData();
218
+ form.set("file", new File([new Uint8Array(read.bytes)], read.fileName));
219
+ if (command.exclude)
220
+ form.set("exclusions", command.exclude);
221
+ const answer = await ask(door, {
222
+ path: "/api/notes/upload",
223
+ method: "POST",
224
+ label: "notes upload",
225
+ timeoutMs: UPLOAD_DEADLINE_MS,
226
+ body: form,
227
+ });
228
+ if (!answer.ok) {
229
+ writeLine(door.io.stderr, `${TAG} upload failed ${JSON.stringify({ reason: answer.reason })}`);
230
+ if (!door.json)
231
+ writeLine(door.io.stderr, `${read.fileName}: ${answer.detail}`);
232
+ results.push({ path: filePath, ok: false, reason: answer.reason });
233
+ worstExit = 1;
234
+ continue;
235
+ }
236
+ const body = answer.body;
237
+ results.push({ path: filePath, ok: body.stored === true, body });
238
+ if (body.stored !== true)
239
+ worstExit = 1;
240
+ if (!door.json)
241
+ sayUpload(door, body);
242
+ writeLine(door.io.stderr, `${TAG} upload answered ${JSON.stringify({
243
+ stored: body.stored === true,
244
+ note_id: body.noteId ?? null,
245
+ scope: body.scope ?? null,
246
+ byte_size: read.bytes.byteLength,
247
+ extension: read.extension,
248
+ })}`);
249
+ }
250
+ if (door.json) {
251
+ emit(door, { ok: worstExit === 0, uploaded: results.length, results });
252
+ }
253
+ return worstExit;
254
+ }
255
+ async function pasteNote(command, door) {
256
+ let text;
257
+ if (command.filePath) {
258
+ const read = await readNoteFile(command.filePath);
259
+ if (!read.ok) {
260
+ const sentence = noteFileRefusalSentence(read.refusal, command.filePath);
261
+ writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: read.refusal, detail: read.detail })}`);
262
+ return fail(door, read.refusal, sentence);
263
+ }
264
+ text = read.bytes.toString("utf8");
265
+ }
266
+ else {
267
+ if (isInteractiveStdin(door.io)) {
268
+ return fail(door, "nothing_piped", "Pipe the note in (`pbpaste | cockpit notes paste` on macOS, "
269
+ + "`Get-Clipboard | cockpit notes paste` on Windows) or pass --file <path>.");
270
+ }
271
+ try {
272
+ text = await readAll(door.io.stdin);
273
+ }
274
+ catch (error) {
275
+ return fail(door, "paste_too_long", errorText(error));
276
+ }
277
+ }
278
+ if (text.trim() === "") {
279
+ return fail(door, "empty_paste", "There was nothing to paste. Nothing was sent.");
280
+ }
281
+ const form = new FormData();
282
+ form.set("text", text);
283
+ if (command.name)
284
+ form.set("name", command.name);
285
+ if (command.exclude)
286
+ form.set("exclusions", command.exclude);
287
+ if (text.length >= NOTE_SLOW_UPLOAD_BYTES) {
288
+ writeLine(door.io.stderr, `Reading ${Math.round(text.length / 1024)} KB of pasted text. This can take a couple of minutes.`);
289
+ }
290
+ const answer = await ask(door, {
291
+ path: "/api/notes/upload",
292
+ method: "POST",
293
+ label: "notes paste",
294
+ timeoutMs: UPLOAD_DEADLINE_MS,
295
+ body: form,
296
+ });
297
+ if (!answer.ok)
298
+ return fail(door, answer.reason, answer.detail);
299
+ const body = answer.body;
300
+ writeLine(door.io.stderr, `${TAG} paste answered ${JSON.stringify({
301
+ stored: body.stored === true,
302
+ note_id: body.noteId ?? null,
303
+ scope: body.scope ?? null,
304
+ chars: text.length,
305
+ named_by_caller: Boolean(command.name),
306
+ })}`);
307
+ if (door.json)
308
+ return emit(door, body, body.stored === true ? 0 : 1);
309
+ sayUpload(door, body);
310
+ return body.stored === true ? 0 : 1;
311
+ }
312
+ async function shareNote(command, door) {
313
+ const wantsToShare = command.action === "share";
314
+ // Only sharing asks. Taking a note back narrows who can read it, and nobody
315
+ // needs to be talked out of that.
316
+ if (wantsToShare && !command.yes) {
317
+ if (!isInteractiveStdin(door.io)) {
318
+ return fail(door, "confirmation_required", "Sharing a note lets everyone signed in read it. Pass --yes to do it without being asked.");
319
+ }
320
+ const answer = await readLine(door.io, "Share this note with everyone signed in? [Y/n] ");
321
+ if (!yesByDefault(answer)) {
322
+ writeLine(door.io.stderr, `${TAG} share declined ${JSON.stringify({ reason: "operator_said_no" })}`);
323
+ writeLine(door.io.stdout, "Left alone. Nothing changed.");
324
+ return 0;
325
+ }
326
+ }
327
+ const answer = await ask(door, {
328
+ path: "/api/notes/share",
329
+ method: "POST",
330
+ label: wantsToShare ? "notes share" : "notes unshare",
331
+ timeoutMs: READ_DEADLINE_MS,
332
+ body: { note_id: command.noteId, share: wantsToShare },
333
+ });
334
+ if (!answer.ok)
335
+ return fail(door, answer.reason, answer.detail);
336
+ const body = answer.body;
337
+ writeLine(door.io.stderr, `${TAG} ${wantsToShare ? "shared" : "taken back"} ${JSON.stringify({
338
+ note_id: command.noteId ?? null,
339
+ stored: body.stored === true,
340
+ })}`);
341
+ if (door.json)
342
+ return emit(door, body);
343
+ sayUpload(door, body);
344
+ return 0;
345
+ }
346
+ async function moveNote(command, door) {
347
+ const answer = await ask(door, {
348
+ path: "/api/notes/move",
349
+ method: "POST",
350
+ label: "notes move",
351
+ timeoutMs: READ_DEADLINE_MS,
352
+ // `--clear-shelf` sends the empty string, which is what the browser's own
353
+ // move box sends when a person empties it.
354
+ body: { note_id: command.noteId, category: command.clearShelf ? "" : command.to },
355
+ });
356
+ if (!answer.ok)
357
+ return fail(door, answer.reason, answer.detail);
358
+ const body = answer.body;
359
+ writeLine(door.io.stderr, `${TAG} move answered ${JSON.stringify({
360
+ note_id: command.noteId ?? null,
361
+ ok: body.ok === true,
362
+ reason: body.reason ?? null,
363
+ cleared: Boolean(command.clearShelf),
364
+ })}`);
365
+ if (door.json)
366
+ return emit(door, body, body.ok === true ? 0 : 1);
367
+ writeLine(door.io.stdout, body.headline ?? "Tower answered without a sentence.");
368
+ for (const line of body.lines ?? [])
369
+ writeLine(door.io.stdout, line);
370
+ return body.ok === true ? 0 : 1;
371
+ }
372
+ /**
373
+ * One request, and a refusal that keeps the ROUTE'S own words.
374
+ *
375
+ * `towerJsonRequest` maps a non-2xx through `responseErrorMessage`, which looks
376
+ * for `message` or `error` — the shape the ingest routes answer with. Every
377
+ * notes door answers in the browser's shape instead (`headline` plus `lines`,
378
+ * with a `reason` label beside them), because the same body is what the page
379
+ * renders. Reading it here rather than widening `responseErrorMessage` keeps one
380
+ * meaning per field: the sentence is written where the outcome is known, and the
381
+ * terminal relays it rather than inventing a second wording for the same thing.
382
+ */
383
+ async function ask(door, options) {
384
+ const result = await towerRequest({
385
+ dashboardUrl: door.dashboardUrl,
386
+ path: options.path,
387
+ deviceToken: door.deviceToken,
388
+ fetch: door.io.fetch,
389
+ method: options.method,
390
+ label: options.label,
391
+ timeoutMs: options.timeoutMs,
392
+ ...(options.body === undefined ? {} : { body: options.body }),
393
+ log: (line) => writeLine(door.io.stderr, line),
394
+ });
395
+ if (!result.ok) {
396
+ const failure = result;
397
+ return {
398
+ ok: false,
399
+ reason: failure.reason,
400
+ detail: towerFailureDetail(failure.reason, failure.detail),
401
+ };
402
+ }
403
+ const body = await readResponseJson(result.response);
404
+ if (!result.response.ok) {
405
+ const status = result.response.status;
406
+ return {
407
+ ok: false,
408
+ reason: refusalReason(body) ?? `http_${status}`,
409
+ detail: refusalSentence(body) ?? `Tower answered ${status} and said nothing about why.`,
410
+ };
411
+ }
412
+ return { ok: true, body };
413
+ }
414
+ function refusalReason(body) {
415
+ if (!body || typeof body !== "object")
416
+ return null;
417
+ const record = body;
418
+ for (const key of ["reason", "error", "code"]) {
419
+ const value = record[key];
420
+ if (typeof value === "string" && value.trim() !== "")
421
+ return value;
422
+ }
423
+ return null;
424
+ }
425
+ function refusalSentence(body) {
426
+ if (!body || typeof body !== "object")
427
+ return null;
428
+ const record = body;
429
+ const headline = typeof record["headline"] === "string" ? record["headline"] : null;
430
+ const message = typeof record["message"] === "string" ? record["message"] : null;
431
+ const lines = Array.isArray(record["lines"])
432
+ ? record["lines"].filter((line) => typeof line === "string")
433
+ : [];
434
+ const said = [headline ?? message, ...lines].filter(Boolean);
435
+ return said.length > 0 ? said.join(" ") : null;
436
+ }
437
+ /** One machine-readable object on stdout, and nothing else on it. */
438
+ function emit(door, body, exitCode = 0) {
439
+ writeLine(door.io.stdout, JSON.stringify(body));
440
+ return exitCode;
441
+ }
442
+ function fail(door, reason, detail) {
443
+ writeLine(door.io.stderr, `${TAG} refused ${JSON.stringify({ reason })}`);
444
+ if (door.json) {
445
+ writeLine(door.io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
446
+ }
447
+ else {
448
+ writeLine(door.io.stderr, detail);
449
+ }
450
+ return 1;
451
+ }
452
+ /**
453
+ * The route's own words for what happened. Never rephrased here — the sentences
454
+ * are written where the outcome is known, and a second wording in the terminal
455
+ * would be a second thing to keep in step.
456
+ */
457
+ function sayUpload(door, body) {
458
+ writeLine(door.io.stdout, body.headline ?? "Tower answered without a sentence.");
459
+ for (const line of body.lines ?? [])
460
+ writeLine(door.io.stdout, line);
461
+ if (body.noteId)
462
+ writeLine(door.io.stdout, `Note id: ${body.noteId}`);
463
+ }
464
+ /**
465
+ * Says out loud when an answer is narrower than the one a browser would give.
466
+ *
467
+ * A degraded read is still a real answer and is never withheld — but a person
468
+ * who cannot see their own unshared note has to be told that is why, not left
469
+ * to conclude it was never stored.
470
+ */
471
+ function sayScope(door, body) {
472
+ if (!body.degradedBecause)
473
+ return;
474
+ writeLine(door.io.stderr, `${TAG} narrowed ${JSON.stringify({ scope: body.scope ?? null, reason: body.degradedBecause })}`);
475
+ writeLine(door.io.stderr, body.degradedNote ?? "This answer is narrower than the browser's.");
476
+ }
477
+ async function readAll(stream) {
478
+ stream.setEncoding("utf8");
479
+ let text = "";
480
+ for await (const chunk of stream) {
481
+ text += chunk;
482
+ if (text.length > PASTE_MAX_CHARS) {
483
+ throw new Error(`A pasted note is limited to ${PASTE_MAX_CHARS} characters. Save it to a file and use --file instead.`);
484
+ }
485
+ }
486
+ return text;
487
+ }
488
+ function errorText(error) {
489
+ return error instanceof Error ? error.message : String(error);
490
+ }
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.38");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.39");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,172 @@
1
+ /**
2
+ * How a Scout board reads in a terminal, and which card a typed id means.
3
+ *
4
+ * Pure functions, no io and no network: `scout.ts` fetches, this lays out. The
5
+ * split is BLI-3460's, taken so the layout can be asserted line for line by a
6
+ * test that never opens a socket.
7
+ *
8
+ * **The sentences are the page's, not this file's.** Every standing line — the
9
+ * watch line, the headline, the quiet reason, all three section labels —
10
+ * arrives in the GET payload from the dashboard's `lib/cockpit/scout-lines.ts`,
11
+ * the same module the browser surface renders. Nothing here words anything; a
12
+ * terminal that writes its own "LAST SWEEP" will eventually disagree with the
13
+ * page about whether the sweep ran.
14
+ *
15
+ * **A bounded read says so.** The server returns at most 8 cards and 12
16
+ * signals; when there are more, `truncationLines` prints how many. A quiet
17
+ * board and a truncated board must never look alike.
18
+ */
19
+ const DIM = "\x1b[2m";
20
+ const RESET = "\x1b[0m";
21
+ // ------------------------------------------------------------------ rendering
22
+ /** The whole board as terminal lines, in the page's three sections and order. */
23
+ export function renderScoutBoard(payload) {
24
+ const board = payload.board ?? {};
25
+ const lines = payload.lines ?? {};
26
+ const out = [];
27
+ if (lines.watch)
28
+ out.push(lines.watch);
29
+ if (lines.headline)
30
+ out.push(lines.headline);
31
+ const experiments = board.experiments ?? [];
32
+ out.push("", lines.waitingLabel ?? "Waiting on you");
33
+ if (experiments.length === 0) {
34
+ out.push(indent(lines.quiet ?? "Nothing is waiting on a decision right now."));
35
+ }
36
+ else {
37
+ for (const card of experiments)
38
+ out.push(...renderExperiment(card));
39
+ }
40
+ const settled = board.settled ?? [];
41
+ if (settled.length > 0) {
42
+ out.push("", lines.settledLabel ?? "Already decided");
43
+ for (const card of settled) {
44
+ out.push(` ⏺ ${shortId(card.id ?? "")} · ${card.status === "done" ? "RAN" : "DISMISSED"} · ${card.claimSummary ?? card.title ?? ""}`);
45
+ }
46
+ }
47
+ const signals = board.signals ?? [];
48
+ out.push("", lines.rawWatchLabel ?? "The raw watch");
49
+ if (signals.length === 0) {
50
+ out.push(indent(lines.rawWatchEmpty ?? "Nothing was gathered in this window."));
51
+ }
52
+ else {
53
+ for (const signal of signals)
54
+ out.push(...renderSignal(signal));
55
+ }
56
+ const truncation = truncationLines(board);
57
+ if (truncation.length > 0)
58
+ out.push("", ...truncation);
59
+ return out;
60
+ }
61
+ function renderExperiment(card) {
62
+ const sourceCount = card.sourceCount ?? 0;
63
+ const head = [
64
+ shortId(card.id ?? ""),
65
+ card.status === "started" ? "RUNNING" : "SUGGESTED",
66
+ sourceCount === 0
67
+ ? "no source signals"
68
+ : `${sourceCount} source signal${sourceCount === 1 ? "" : "s"}`,
69
+ dayStamp(card.createdAt),
70
+ ]
71
+ .filter(Boolean)
72
+ .join(" · ");
73
+ const out = [` ⏺ ${head}`];
74
+ if (card.claimSummary)
75
+ out.push(` ${card.claimSummary}`);
76
+ if (card.title)
77
+ out.push(dim(` ${card.title}`));
78
+ for (const row of card.crossref ?? []) {
79
+ if (row.person)
80
+ out.push(dim(` On your team · ${row.person} · ${row.observedPattern ?? ""}`));
81
+ }
82
+ if (card.expectedPayoff)
83
+ out.push(dim(` Worth trying · ${card.expectedPayoff}`));
84
+ for (const source of card.sources ?? []) {
85
+ if (source.title)
86
+ out.push(dim(` ${source.title} · ${source.source ?? ""} · ${source.url ?? ""}`));
87
+ }
88
+ return out;
89
+ }
90
+ function renderSignal(signal) {
91
+ const out = [` ⏺ ${signal.title ?? "untitled"} · ${dayStamp(signal.gatheredAt)}`];
92
+ if (signal.synopsis)
93
+ out.push(dim(` ${signal.synopsis}`));
94
+ const foot = [
95
+ signal.source,
96
+ signal.evidenceStrength ?? undefined,
97
+ signal.status === "promoted" ? "promoted" : undefined,
98
+ (signal.tags ?? []).slice(0, 4).join(", ") || undefined,
99
+ signal.url,
100
+ ].filter((part) => Boolean(part));
101
+ if (foot.length > 0)
102
+ out.push(dim(` ${foot.join(" · ")}`));
103
+ return out;
104
+ }
105
+ /**
106
+ * One line per bounded read that did not fit. Silence here means the board on
107
+ * screen IS the board — which is only true because this says otherwise when it
108
+ * is not.
109
+ */
110
+ export function truncationLines(board) {
111
+ const coverage = board.coverage ?? {};
112
+ const parts = [
113
+ ["waiting cards", coverage.openExperiments],
114
+ ["decided cards", coverage.settledExperiments],
115
+ ["signals", coverage.signals],
116
+ ];
117
+ const out = [];
118
+ for (const [label, read] of parts) {
119
+ if (!read?.truncated)
120
+ continue;
121
+ const returned = read.returned ?? 0;
122
+ out.push(typeof read.total === "number"
123
+ ? `Bounded read · ${label}: ${returned} of ${read.total} shown. The rest is in Tower.`
124
+ : `Bounded read · ${label}: ${returned} shown, and there are more. The rest is in Tower.`);
125
+ }
126
+ return out;
127
+ }
128
+ /**
129
+ * Which card a typed reference means, decided against the board that was just
130
+ * read — never against a guess. An exact id wins outright; otherwise a prefix
131
+ * must match exactly one card, and two matches are refused by name rather than
132
+ * silently taking the first.
133
+ */
134
+ export function resolveExperimentRef(board, ref) {
135
+ const needle = ref.trim().toLowerCase();
136
+ if (!needle)
137
+ return { status: "no_match" };
138
+ const cards = [...(board.experiments ?? []), ...(board.settled ?? [])].filter((card) => typeof card.id === "string");
139
+ const exact = cards.find((card) => card.id.toLowerCase() === needle);
140
+ if (exact)
141
+ return { status: "ok", id: exact.id, card: exact };
142
+ const matches = cards.filter((card) => card.id.toLowerCase().startsWith(needle));
143
+ if (matches.length === 0)
144
+ return { status: "no_match" };
145
+ if (matches.length > 1) {
146
+ return { status: "ambiguous_prefix", candidates: matches.map((card) => shortId(card.id)) };
147
+ }
148
+ const only = matches[0];
149
+ return { status: "ok", id: only.id, card: only };
150
+ }
151
+ export function refusalSentence(resolution, ref) {
152
+ if (resolution.status === "no_match") {
153
+ return `No card on the board starts with "${ref}". Run \`cockpit scout\` to see the ids.`;
154
+ }
155
+ if (resolution.status === "ambiguous_prefix") {
156
+ return `${resolution.candidates.length} cards start with "${ref}" (${resolution.candidates.join(", ")}). Use more characters.`;
157
+ }
158
+ return "";
159
+ }
160
+ // ----------------------------------------------------------------- small parts
161
+ export function shortId(id) {
162
+ return id.slice(0, 6);
163
+ }
164
+ function dayStamp(iso) {
165
+ return iso ? iso.slice(0, 10) : "";
166
+ }
167
+ function dim(text) {
168
+ return `${DIM}${text}${RESET}`;
169
+ }
170
+ function indent(text) {
171
+ return ` ${text}`;
172
+ }