@bli-cockpit/cli 0.2.57 → 0.2.59

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 (53) hide show
  1. package/dist/commands/browser-open.js +88 -0
  2. package/dist/commands/docs.js +27 -6
  3. package/dist/commands/doctor-report.js +17 -1
  4. package/dist/commands/doctor.js +12 -2
  5. package/dist/commands/heartbeat.js +65 -1
  6. package/dist/commands/issue-contracts.js +99 -0
  7. package/dist/commands/issue-write.js +129 -0
  8. package/dist/commands/issue.js +189 -0
  9. package/dist/commands/jarvis-answer-envelope.js +80 -0
  10. package/dist/commands/jarvis-turn.js +16 -1
  11. package/dist/commands/jarvis.js +3 -0
  12. package/dist/commands/local-args-collector-setup.js +17 -0
  13. package/dist/commands/local-args-tower-admin.js +12 -2
  14. package/dist/commands/local-args-tower-docs-msg.js +95 -6
  15. package/dist/commands/local-args-tower-search.js +50 -0
  16. package/dist/commands/local-args-tower-work.js +178 -0
  17. package/dist/commands/local-args-tower.js +7 -1
  18. package/dist/commands/local-args.js +29 -14
  19. package/dist/commands/local-command-shapes.js +12 -0
  20. package/dist/commands/local-help-commands.js +643 -0
  21. package/dist/commands/local-help.js +12 -551
  22. package/dist/commands/local.js +9 -0
  23. package/dist/commands/login.js +91 -8
  24. package/dist/commands/memory-install-claude.js +35 -15
  25. package/dist/commands/memory-install-codex-hooks.js +200 -0
  26. package/dist/commands/memory-install-codex.js +12 -2
  27. package/dist/commands/memory-install-config.js +140 -0
  28. package/dist/commands/memory-install-receipt.js +222 -0
  29. package/dist/commands/memory-install-report.js +113 -0
  30. package/dist/commands/memory-install.js +99 -276
  31. package/dist/commands/msg.js +85 -2
  32. package/dist/commands/notes-door.js +120 -0
  33. package/dist/commands/notes-reads.js +134 -0
  34. package/dist/commands/notes-writes.js +208 -0
  35. package/dist/commands/notes.js +16 -442
  36. package/dist/commands/onboard-completion.js +47 -0
  37. package/dist/commands/onboard-setup.js +82 -2
  38. package/dist/commands/ops-render-memory.js +76 -0
  39. package/dist/commands/ops-render.js +13 -1
  40. package/dist/commands/ops.js +65 -3
  41. package/dist/commands/project.js +38 -0
  42. package/dist/commands/public-root.js +1 -1
  43. package/dist/commands/search.js +122 -0
  44. package/dist/commands/setup-receipt-lines.js +71 -0
  45. package/dist/commands/setup-receipt.js +241 -0
  46. package/dist/commands/status.js +20 -1
  47. package/dist/local-state-pairing-code.js +200 -0
  48. package/dist/local-state.js +6 -0
  49. package/dist/repo-identity-fingerprint.js +88 -0
  50. package/dist/repo-identity-git.js +76 -0
  51. package/dist/repo-identity-linked-worktrees.js +81 -0
  52. package/dist/repo-identity.js +5 -222
  53. package/package.json +7 -7
@@ -0,0 +1,76 @@
1
+ /**
2
+ * How the BLI Memory adoption gauge reads in a terminal (BLI-3729). Pure
3
+ * layout: no io, no network, no clock of its own — the sibling of
4
+ * `ops-render.ts`, split out to keep both under the repo's readability band
5
+ * (`repo-shape/file-size-ratchet.test.ts`).
6
+ *
7
+ * Same rule as the fleet and coverage sections next door: every SENTENCE was
8
+ * written on the server (`lib/ops/memory-usage.ts`) and is printed verbatim;
9
+ * this file decides column widths and order and nothing else.
10
+ *
11
+ * Both public names are re-exported from `./ops-render.js`, so a caller of that
12
+ * module never has to know the split happened.
13
+ */
14
+ /**
15
+ * Is the org using BLI Memory? One row per person, one column per day, and the
16
+ * two agent sources counted apart from the import — an imported row is a
17
+ * backfill and must never be read as somebody using the thing.
18
+ */
19
+ export function renderMemoryUsage(memory, dim) {
20
+ const lines = ["", `MEMORY ${memory.summary ?? "(no summary)"}`];
21
+ if (memory.readError) {
22
+ lines.push(` usage could not be read (${memory.readError}); nothing is known about whether anybody is using BLI Memory`);
23
+ return lines;
24
+ }
25
+ const days = memory.days ?? [];
26
+ const people = memory.people ?? [];
27
+ if (people.length === 0) {
28
+ lines.push(dim(" nobody on the roster has any memory activity to show"));
29
+ return lines;
30
+ }
31
+ const nameWidth = Math.max(6, ...people.map((person) => (person.displayName ?? "").length));
32
+ // `MM-DD` per column: the year is the same on every one of seven days and
33
+ // spending four characters on it costs the terminal a column per day.
34
+ const header = ` ${"person".padEnd(nameWidth)} ` +
35
+ days.map((day) => day.slice(5).padStart(5)).join(" ") +
36
+ ` ${"mcp".padStart(5)} ${"hook".padStart(5)} ${"impt".padStart(5)}`;
37
+ lines.push(dim(header));
38
+ // Most active first, so the answer to "who is using it" is the top of the
39
+ // list; the unattributed bucket sinks to the bottom whatever its total,
40
+ // because it is not a person and its number is mostly the import.
41
+ const ranked = [...people].sort((left, right) => {
42
+ const leftOrphan = left.personId === null || left.personId === undefined ? 1 : 0;
43
+ const rightOrphan = right.personId === null || right.personId === undefined ? 1 : 0;
44
+ if (leftOrphan !== rightOrphan)
45
+ return leftOrphan - rightOrphan;
46
+ return agentSaves(right.counts) - agentSaves(left.counts);
47
+ });
48
+ for (const person of ranked) {
49
+ const byDay = new Map((person.days ?? []).map((entry) => [entry.day ?? "", entry]));
50
+ const cells = days
51
+ .map((day) => {
52
+ const entry = byDay.get(day);
53
+ const saved = agentSaves(entry?.counts);
54
+ // A zero prints as `·`, not `0`: seven zeroes in a row is the shape a
55
+ // reader is scanning for, and seven `0`s hide it in the noise.
56
+ return (saved === 0 ? "·" : String(saved)).padStart(5);
57
+ })
58
+ .join(" ");
59
+ const counts = person.counts ?? {};
60
+ const row = ` ${(person.displayName ?? "?").padEnd(nameWidth)} ${cells} ` +
61
+ `${String(counts.mcp ?? 0).padStart(5)} ${String(counts.stop_hook ?? 0).padStart(5)} ` +
62
+ `${String(counts.import ?? 0).padStart(5)}`;
63
+ lines.push(agentSaves(counts) > 0 ? row : dim(row));
64
+ }
65
+ if (memory.truncated) {
66
+ lines.push(dim(" this read stopped at its page cap, so every number above is a FLOOR — it can understate use and never invent it"));
67
+ }
68
+ if (memory.searches?.counted === false) {
69
+ lines.push(dim(` searches are not counted (${memory.searches.reason ?? "no ledger"}): nothing in Tower records one, so this is saves only`));
70
+ }
71
+ return lines;
72
+ }
73
+ /** The two sources that mean a PERSON's agent used memory. Never the import. */
74
+ function agentSaves(counts) {
75
+ return (counts?.mcp ?? 0) + (counts?.stop_hook ?? 0);
76
+ }
@@ -12,6 +12,7 @@
12
12
  * artifact does and does not prove — because that is exactly the moment
13
13
  * somebody is about to conclude something from it.
14
14
  */
15
+ export { renderMemoryUsage } from "./ops-render-memory.js";
15
16
  /** The word a person reads. Short, fixed width, and never a bare colour. */
16
17
  export function verdictWord(verdict) {
17
18
  switch (verdict) {
@@ -19,6 +20,11 @@ export function verdictWord(verdict) {
19
20
  return "ok";
20
21
  case "stale":
21
22
  return "STALE";
23
+ // BLI-3723. Its own word, not a shade of STALE: a `failing` reader's input
24
+ // is CURRENT and its answer is bad, which sends a person to a laptop
25
+ // rather than to a cron config.
26
+ case "failing":
27
+ return "BAD";
22
28
  case "never_produced":
23
29
  return "EMPTY";
24
30
  case "unreadable":
@@ -69,7 +75,9 @@ export function renderOpsStatus(payload, dim) {
69
75
  for (const row of rows) {
70
76
  const verdict = verdictWord(row.verdict).padEnd(6);
71
77
  const id = (row.id ?? "?").padEnd(idWidth);
72
- const age = ageWord(row.ageHours).padStart(7);
78
+ // BLI-3723: a row that has no age BY DESIGN says `n/a`, never `—`. The two
79
+ // look alike and mean opposite things — `—` is "the table is empty".
80
+ const age = (row.ageAbsence ? "n/a" : ageWord(row.ageHours)).padStart(7);
73
81
  // BLI-3722: name the device this age belongs to right in the row — a bare
74
82
  // `STALE collector-fleet 11h` was already misread once (BLI-3699) as "the
75
83
  // fleet", not "one machine in it", and the name is otherwise buried in
@@ -77,6 +85,10 @@ export function renderOpsStatus(payload, dim) {
77
85
  const staleName = row.verdict === "stale" && row.staleDeviceName ? ` ${dim(`(${row.staleDeviceName})`)}` : "";
78
86
  lines.push(`${verdict} ${id} ${age}${staleName} ${dim(intervalWord(row))}`);
79
87
  if (row.verdict !== "healthy") {
88
+ // The absence of an age is explained BEFORE the detail, because it is
89
+ // the first thing a reader's eye stopped on.
90
+ if (row.ageAbsence)
91
+ lines.push(dim(` no age: ${row.ageAbsence}`));
80
92
  if (row.detail)
81
93
  lines.push(dim(` ${row.detail}`));
82
94
  if (row.caveat)
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * **Exit codes are the whole point of running this in a script.** Zero when
14
14
  * every job that CAN be answered for is healthy; 1 when anything is stale,
15
- * empty or unreadable. `no_artifact` — a job that writes no row — does not fail
15
+ * failing, empty or unreadable. `no_artifact` — a job that writes no row — does not fail
16
16
  * the command, and it does not pass silently either: it prints as `n/a` with
17
17
  * the reason there is nothing to read.
18
18
  *
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { colorEnabled, dim, writeLine } from "./cli-io.js";
27
27
  import { renderOpsStatus } from "./ops-render.js";
28
+ import { renderMemoryUsage, } from "./ops-render-memory.js";
28
29
  import { asRecord, callTower, openTower, writeCommandFailure } from "./tower-command.js";
29
30
  /**
30
31
  * A whole compile, plus a little. `maxDuration` on `/api/ops/recompile` is 800
@@ -60,15 +61,40 @@ async function runOpsStatus(command, io, tower) {
60
61
  }
61
62
  const payload = asRecord(result.body);
62
63
  const rows = payload.pipelines ?? [];
63
- const unhealthy = rows.filter((row) => row.verdict === "stale" || row.verdict === "never_produced" || row.verdict === "unreadable");
64
+ // BLI-3729. A SECOND door, asked for only when `--memory` was: the adoption
65
+ // gauge is a different question from pipeline health and a different read,
66
+ // and putting it on every `cockpit ops` would make the common run slower for
67
+ // a number most runs do not want. Its failure is its own line and never the
68
+ // status board's exit code — "is the org using memory" going unread is not a
69
+ // pipeline outage.
70
+ const memory = command.memory ? await readMemoryUsage(command, io, tower) : null;
71
+ // BLI-3723: `failing` joins the list. An older CLI that has never heard of it
72
+ // simply does not count it — which is why the server keeps the sentence in
73
+ // `detail`, so an un-upgraded terminal still PRINTS the outage even when it
74
+ // exits 0 over it.
75
+ const unhealthy = rows.filter((row) => row.verdict === "stale" ||
76
+ row.verdict === "never_produced" ||
77
+ row.verdict === "unreadable" ||
78
+ row.verdict === "failing");
64
79
  if (command.json) {
65
- writeLine(io.stdout, JSON.stringify(payload));
80
+ writeLine(io.stdout, JSON.stringify(memory ? { ...payload, memory: memory.section } : payload));
66
81
  }
67
82
  else {
68
83
  const styled = colorEnabled(io);
69
84
  for (const line of renderOpsStatus(payload, (text) => dim(text, styled))) {
70
85
  writeLine(io.stdout, line);
71
86
  }
87
+ if (memory?.section) {
88
+ for (const line of renderMemoryUsage(memory.section, (text) => dim(text, styled))) {
89
+ writeLine(io.stdout, line);
90
+ }
91
+ }
92
+ else if (memory) {
93
+ // Never a silent gap where a section was asked for: the reason the gauge
94
+ // could not be read is printed where the gauge would have been.
95
+ writeLine(io.stdout, "");
96
+ writeLine(io.stdout, `MEMORY not read (${memory.reason})`);
97
+ }
72
98
  }
73
99
  // Both branches log — an all-green board that says nothing cannot answer
74
100
  // "did anybody look today?".
@@ -84,9 +110,45 @@ async function runOpsStatus(command, io, tower) {
84
110
  fleet_devices: payload.fleet?.counts?.devices ?? null,
85
111
  fleet_red: payload.fleet?.counts?.red ?? null,
86
112
  fleet_amber: payload.fleet?.counts?.amber ?? null,
113
+ // BLI-3729: whether the adoption gauge was asked for, and what it said.
114
+ // Both branches, as everywhere else here.
115
+ memory_asked: Boolean(command.memory),
116
+ memory_reason: memory?.reason ?? null,
117
+ memory_agent_saves: memory?.section
118
+ ? (memory.section.counts?.mcp ?? 0) + (memory.section.counts?.stop_hook ?? 0)
119
+ : null,
87
120
  })}`);
88
121
  return unhealthy.length > 0 ? 1 : 0;
89
122
  }
123
+ /**
124
+ * The adoption gauge, read through its own door.
125
+ *
126
+ * Never fails the board: a `cockpit ops --memory` whose memory half is
127
+ * unreadable still prints the pipelines, the fleet and the coverage, and says
128
+ * in one line which half is missing and why. The status board's exit code is
129
+ * about collection health, and adoption is not that.
130
+ */
131
+ async function readMemoryUsage(command, io, tower) {
132
+ const params = new URLSearchParams();
133
+ if (command.memoryDays !== undefined)
134
+ params.set("days", String(command.memoryDays));
135
+ const query = params.toString();
136
+ const result = await callTower(tower, {
137
+ path: `/api/ops/memory-usage${query ? `?${query}` : ""}`,
138
+ label: "ops-memory-usage",
139
+ });
140
+ if (!result.ok) {
141
+ writeLine(io.stderr, `[ops cli] memory usage not read ${JSON.stringify({
142
+ reason: result.reason,
143
+ http_status: result.httpStatus ?? null,
144
+ })}`);
145
+ return { section: null, reason: result.reason };
146
+ }
147
+ const body = asRecord(result.body);
148
+ if (!body.memory)
149
+ return { section: null, reason: "memory_section_absent" };
150
+ return { section: body.memory, reason: "ok" };
151
+ }
90
152
  async function runOpsRecompile(command, io, tower) {
91
153
  const person = command.person ?? "";
92
154
  if (!command.json) {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `cockpit project list` — the projects issues are filed under (BLI-3716).
3
+ *
4
+ * One verb, because `GET /api/work/projects` is the whole of the Work
5
+ * surface's project door today (BLI-3703 scoped projects to list-only; there
6
+ * is no create/update/delete route to put a terminal in front of). Its own
7
+ * command rather than `cockpit issue projects` because a person says
8
+ * "projects", and `cockpit issue list --project <name>` resolves a name
9
+ * against exactly this list.
10
+ */
11
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
12
+ import { writeLine } from "./cli-io.js";
13
+ const TAG = "[project cli]";
14
+ const READ_DEADLINE_MS = 30_000;
15
+ export async function runProject(command, io) {
16
+ const door = await openAgentDoor("project", command, io);
17
+ const answer = await askAgentDoor(door, {
18
+ path: `/api/work/projects${command.includeArchived ? "?include_archived=true" : ""}`,
19
+ method: "GET",
20
+ label: "project list",
21
+ timeoutMs: READ_DEADLINE_MS,
22
+ });
23
+ if (!answer.ok)
24
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
25
+ const projects = answer.body.projects ?? [];
26
+ if (door.json)
27
+ return emitAgentDoor(door, { ok: true, projects });
28
+ if (projects.length === 0) {
29
+ writeLine(door.io.stdout, "No projects.");
30
+ return 0;
31
+ }
32
+ for (const project of projects) {
33
+ writeLine(door.io.stdout, `${project.id} ${project.archived_at ? "archived" : "active "} ${project.name}`);
34
+ }
35
+ writeLine(door.io.stdout, "");
36
+ writeLine(door.io.stdout, `${projects.length} project(s).`);
37
+ return 0;
38
+ }
@@ -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.57");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.59");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `cockpit search` — one bar over five corpora, in a terminal (BLI-3728).
3
+ *
4
+ * The SAME door the browser's search bar presses: `GET /api/search`, opted
5
+ * into the collector device token in `route-caller-census.test.ts`. So a
6
+ * result a person reads in the rail is the same row, the same ranking and the
7
+ * same snippet an agent gets here. That is the whole point of the ticket's
8
+ * "agent-friendly is the definition of done" clause; nothing about search is
9
+ * computed on this side.
10
+ *
11
+ * WHAT THIS PRINTS THAT THE BROWSER ALSO SHOWS
12
+ * --------------------------------------------
13
+ * A corpus that could not answer gets its own line. "Messages did not answer"
14
+ * and "no messages matched" are different facts, and a terminal that folds
15
+ * them together lets a person conclude the record is silent when the search
16
+ * was simply broken. Same rule, same words, as the overlay.
17
+ *
18
+ * `[[` / `]]` are `ts_headline`'s markers, chosen server-side precisely
19
+ * BECAUSE they are inert in a terminal (`src/lib/search/snippet.ts`). Without
20
+ * a TTY they are stripped; with one they become reverse video, and nothing
21
+ * here parses HTML.
22
+ *
23
+ * A refusal keeps the door's own `reason` label verbatim (`agent-door.ts`):
24
+ * `needs_rls_client`, `query_too_short`, `query_too_long`, `unknown_kind`,
25
+ * `read_failed` — never rephrased here.
26
+ */
27
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
28
+ import { writeLine } from "./cli-io.js";
29
+ const TAG = "[search cli]";
30
+ const READ_DEADLINE_MS = 30_000;
31
+ /** Group headings, in the order a person reads them. Mirrors the overlay. */
32
+ const KIND_LABELS = {
33
+ doc: "DOCUMENTS",
34
+ msg: "MESSAGES",
35
+ issue: "ISSUES",
36
+ note: "MEETING NOTES",
37
+ memory: "MEMORY",
38
+ };
39
+ const KIND_ORDER = ["doc", "msg", "issue", "note", "memory"];
40
+ export async function runSearch(command, io) {
41
+ const door = await openAgentDoor("search", command, io);
42
+ const params = new URLSearchParams({ q: command.query });
43
+ if (command.kinds && command.kinds.length > 0)
44
+ params.set("kinds", command.kinds.join(","));
45
+ if (command.limit !== undefined)
46
+ params.set("limit", String(command.limit));
47
+ const answer = await askAgentDoor(door, {
48
+ path: `/api/search?${params.toString()}`,
49
+ method: "GET",
50
+ label: "search",
51
+ timeoutMs: READ_DEADLINE_MS,
52
+ });
53
+ if (!answer.ok)
54
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
55
+ const body = answer.body;
56
+ if (door.json)
57
+ return emitAgentDoor(door, body);
58
+ return render(door, body);
59
+ }
60
+ function render(door, body) {
61
+ const hits = body.hits ?? [];
62
+ const failures = body.failures ?? {};
63
+ const colour = Boolean(door.io.stdout.isTTY);
64
+ if (hits.length === 0) {
65
+ if (Object.keys(failures).length === 0) {
66
+ writeLine(door.io.stdout, "Nothing matched. Every corpus answered — this is silence, not a failure.");
67
+ }
68
+ else {
69
+ writeLine(door.io.stdout, "Nothing matched in the corpora that answered.");
70
+ }
71
+ }
72
+ for (const kind of KIND_ORDER) {
73
+ const group = hits.filter((hit) => hit.kind === kind);
74
+ if (group.length === 0)
75
+ continue;
76
+ writeLine(door.io.stdout, "");
77
+ writeLine(door.io.stdout, KIND_LABELS[kind] ?? kind.toUpperCase());
78
+ for (const hit of group) {
79
+ writeLine(door.io.stdout, ` ${hit.title}`);
80
+ const snippet = paintSnippet(hit.snippet, colour);
81
+ if (snippet.length > 0)
82
+ writeLine(door.io.stdout, ` ${snippet}`);
83
+ const meta = [
84
+ hit.author,
85
+ hit.date ? hit.date.slice(0, 10) : null,
86
+ (hit.channels ?? []).join("+"),
87
+ `score ${hit.score}`,
88
+ // A memory has no address, and saying so is better than printing a
89
+ // blank column somebody reads as a missing link.
90
+ hit.href ?? "(no page — the text above is the whole record)",
91
+ ]
92
+ .filter(Boolean)
93
+ .join(" · ");
94
+ writeLine(door.io.stdout, ` ${meta}`);
95
+ }
96
+ }
97
+ // Never folded into "nothing matched". Same rule as the overlay.
98
+ const failed = Object.entries(failures);
99
+ if (failed.length > 0) {
100
+ writeLine(door.io.stdout, "");
101
+ for (const [kind, reason] of failed) {
102
+ writeLine(door.io.stdout, `${KIND_LABELS[kind] ?? kind.toUpperCase()} did not answer (${reason}). Nothing from there is in this list.`);
103
+ }
104
+ }
105
+ writeLine(door.io.stdout, "");
106
+ writeLine(door.io.stdout, `${hits.length} result(s) in ${body.elapsedMs ?? 0} ms.`);
107
+ return 0;
108
+ }
109
+ const REVERSE_ON = "\u001B[7m";
110
+ const REVERSE_OFF = "\u001B[27m";
111
+ /**
112
+ * `[[match]]` into reverse video, or into plain text without a TTY.
113
+ *
114
+ * A pipe gets clean text — `cockpit search x | grep` must not have to know
115
+ * about escape codes — and a terminal gets the highlight the browser shows.
116
+ */
117
+ export function paintSnippet(snippet, colour) {
118
+ const collapsed = snippet.replace(/\s+/g, " ").trim();
119
+ if (!colour)
120
+ return collapsed.split("[[").join("").split("]]").join("");
121
+ return collapsed.split("[[").join(REVERSE_ON).split("]]").join(REVERSE_OFF);
122
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * What the setup receipt SAYS (BLI-3731). One line, then one fix per gap.
3
+ *
4
+ * Split from `setup-receipt.ts` for the reason the repo splits everything:
5
+ * computing a state and wording it are different jobs, and only one of them
6
+ * touches the machine. This file touches nothing.
7
+ *
8
+ * The rule for the fix lines is the one the message standard already sets — a
9
+ * refusal that does not say what to do next is half a message. Every gap here
10
+ * gets ONE command or ONE click, never a paragraph and never "see the docs".
11
+ */
12
+ import { setupPieceMark, setupReceiptLine, setupReceiptPieces, } from "@bli-cockpit/telemetry-core";
13
+ /** The one fix for each piece, by key. Present for every key the block prints. */
14
+ const FIXES = {
15
+ browser: "Run `cockpit login` and open the link it prints.",
16
+ device: "Run `cockpit login` — this machine's session has lapsed.",
17
+ "claude.mcp": "Run `cockpit memory install`.",
18
+ "claude.hooks": "Run `cockpit memory install`.",
19
+ "codex.mcp": "Run `cockpit memory install`.",
20
+ "codex.skill": "Run `cockpit memory install`.",
21
+ "codex.hooks": "Run `cockpit memory install`.",
22
+ "collector.autostart": "Run `cockpit autostart install`.",
23
+ };
24
+ /** Codex hooks a person switched OFF is a decision, not a fault. */
25
+ const UNSUPPORTED_FIX = "Switched off in your own config; nothing to do.";
26
+ /**
27
+ * The one exception to "a gap gets a command": Codex hooks that are installed
28
+ * and waiting to be trusted are finished by a PERSON, in the Codex UI, and no
29
+ * command we could print would do it.
30
+ */
31
+ const NEEDS_TRUST_FIX = "Open Codex and press `/hooks` to trust them (once, ~15s).";
32
+ /**
33
+ * The block a terminal prints: the one line, then one fix per gap, then
34
+ * nothing. A fully connected machine gets one line and no advice — a receipt
35
+ * that keeps talking after saying yes trains people to stop reading it.
36
+ */
37
+ export function setupReceiptBlock(reading, options = {}) {
38
+ const { receipt, memory } = reading;
39
+ const indent = options.indent ?? "";
40
+ const lines = [`${indent}${setupReceiptLine(receipt, memory)}`];
41
+ for (const { key, label, piece } of setupReceiptPieces(receipt, memory)) {
42
+ if (piece.status === "ok")
43
+ continue;
44
+ lines.push(`${indent} ${label} ${setupPieceMark(piece.status)}${reasonSuffix(piece)} — ${fixFor(key, piece)}`);
45
+ }
46
+ if (options.showCheckedAt) {
47
+ lines.push(`${indent}Last read: ${receipt.checked_at}`);
48
+ }
49
+ return lines;
50
+ }
51
+ /** The whole receipt in one string, for a single `writeLine`. */
52
+ export function setupReceiptText(reading, options = {}) {
53
+ return setupReceiptBlock(reading, options).join("\n");
54
+ }
55
+ function fixFor(key, piece) {
56
+ if (piece.status === "needs_trust")
57
+ return NEEDS_TRUST_FIX;
58
+ if (piece.status === "unsupported")
59
+ return UNSUPPORTED_FIX;
60
+ if (piece.status === "unknown") {
61
+ // "We did not look" is not somebody's chore. Say what would look.
62
+ return "Not read this run. Run `cockpit doctor` to check it.";
63
+ }
64
+ if (piece.status === "skipped") {
65
+ return "Skipped on purpose; nothing to do.";
66
+ }
67
+ return FIXES[key] ?? "Run `cockpit doctor`.";
68
+ }
69
+ function reasonSuffix(piece) {
70
+ return piece.reason ? ` (${piece.reason})` : "";
71
+ }