@bli-cockpit/cli 0.2.45 → 0.2.47

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.
@@ -1,12 +1,13 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { autostartStatus, installAutostartAgent } from "../autostart.js";
3
+ import { autostartStatus, installAutostartAgent, registeredRuntimePathProblems, } from "../autostart.js";
4
4
  import { savedDiscoveryLimitArgs } from "../discovery-limits.js";
5
5
  import { describeError, redactedHealthDetail } from "../health-detail.js";
6
6
  import { inspectBackfillLock } from "../backfill-lock.js";
7
7
  import { backfillCompletionCovers, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
8
8
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
9
9
  import { normalizeCollectionRoots } from "../root-normalization.js";
10
+ import { detectSecondCockpitInstall } from "../second-install.js";
10
11
  import { runRawEvidenceLocalGc, rawEvidenceGcSummary } from "../raw-evidence-gc.js";
11
12
  import { runBackfillCommand } from "./backfill.js";
12
13
  import { createInteractiveExecRunner } from "../process-runner.js";
@@ -71,6 +72,10 @@ function doctorInvariants() {
71
72
  fix: fixRootState,
72
73
  requiresInteractiveFix: true,
73
74
  },
75
+ // Deliberately has NO fix: uninstalling software the operator did not ask
76
+ // to have uninstalled is not a repair (BLI-3218/BLI-3553). Doctor names the
77
+ // other install and the exact command; the person decides.
78
+ { id: "single-install", check: (context) => context.deps.checkSingleInstall(context) },
74
79
  {
75
80
  id: "autostart-alive",
76
81
  check: (context) => context.deps.checkAutostart(context),
@@ -103,6 +108,7 @@ function defaultDoctorDeps(hooks) {
103
108
  runLogin: (context) => hooks.runLogin(context.command, context.io),
104
109
  readRoots: readRootState,
105
110
  resolveAndSaveRoots: (context) => hooks.resolveAndSaveRoots(context.command, context.io),
111
+ checkSingleInstall: checkSingleInstallState,
106
112
  checkAutostart: checkAutostartState,
107
113
  fixAutostart: fixAutostartState,
108
114
  checkBackfill: checkBackfillState,
@@ -221,6 +227,37 @@ async function readRootState(context) {
221
227
  " 3) There is no `--repair` flag; the onboard-rerun is the repair path.",
222
228
  ].join("\n"));
223
229
  }
230
+ /**
231
+ * BLI-3218/BLI-3553. Two Tower installs on one machine make each one's tick
232
+ * call the other's registration stale and re-register itself, so the plist
233
+ * flaps every 15 minutes and the device reports two CLI versions the same
234
+ * morning. Doctor OFFERS the removal and never performs it: an operator may
235
+ * have a second prefix on purpose, and `npm uninstall -g` of something nobody
236
+ * asked to remove is not a repair. The row therefore has no `fix` — it is
237
+ * information, and doctor still exits 0.
238
+ */
239
+ async function checkSingleInstallState(context) {
240
+ const exec = context.io.exec;
241
+ if (!exec) {
242
+ return skipped("single-install", "runner_unavailable", "could not look for a second Tower install");
243
+ }
244
+ const finding = await detectSecondCockpitInstall({ exec });
245
+ if (!finding.detected) {
246
+ return ok("single-install", finding.reason, finding.install_count === 1
247
+ ? "one Tower install on this machine"
248
+ : "no second Tower install found");
249
+ }
250
+ // The full paths go to the operator's own screen; redactedHealthDetail
251
+ // masks them again before any of this is uploaded.
252
+ return needsFix("single-install", "second_install_detected", [
253
+ `${finding.install_count} Tower installs are on this machine's PATH:`,
254
+ ...finding.paths.map((entry) => ` - ${entry}`),
255
+ "Both will fight over the background scheduler, re-registering it against each other.",
256
+ "Keep one. To remove the other:",
257
+ ` npm uninstall -g @bli-cockpit/cli # run it with the node that owns ${finding.other_bin ?? "the other bin"}`,
258
+ "Nothing was uninstalled — that is your call.",
259
+ ].join("\n"));
260
+ }
224
261
  async function checkAutostartState(context) {
225
262
  const exec = context.io.exec;
226
263
  if (!exec) {
@@ -234,6 +271,15 @@ async function checkAutostartState(context) {
234
271
  exec,
235
272
  });
236
273
  if (result.status === "loaded") {
274
+ // BLI-3553: "loaded" only means the scheduler accepted the registration.
275
+ // It says nothing about whether the binary that registration names still
276
+ // exists — and `brew upgrade node` deletes exactly that. Ask the
277
+ // filesystem about the paths the PLATFORM holds, not the ones this process
278
+ // happens to be running under.
279
+ const missing = await registeredRuntimePathProblems({ exec });
280
+ if (missing.length > 0) {
281
+ return needsFix("autostart-alive", "runtime_path_missing", `background sync is registered but cannot run: ${missing.join("; ")}`);
282
+ }
237
283
  return ok("autostart-alive", "already_installed", "background sync is running");
238
284
  }
239
285
  if (result.status === "unsupported") {
@@ -0,0 +1,175 @@
1
+ /**
2
+ * "I am alive, and these are the folders I may look at." One POST per tick.
3
+ *
4
+ * BLI-3551. The dashboard learned a device was alive only when an ambient
5
+ * envelope arrived, and an envelope only arrives when there was something to
6
+ * collect. A machine whose operator works entirely outside the approved roots
7
+ * therefore went silent while working perfectly: 377 ticks in 38 hours without
8
+ * `last_seen_at` moving once. Dead and quiet looked identical.
9
+ *
10
+ * Three deliberate choices:
11
+ *
12
+ * - **Not spooled.** The install-events outbox retries for days, which is right
13
+ * for a receipt and wrong for a heartbeat: a heartbeat delivered tomorrow is
14
+ * a lie about today. A failed send is logged and dropped, and the next tick
15
+ * is fifteen minutes away.
16
+ * - **Never throws, never fails the sync.** Same rule as the self-update tail.
17
+ * - **Roots travel as labels.** `{ basename, path_sha256 }` — the folder name a
18
+ * person recognises plus the stable identity of the normalized absolute path.
19
+ * The path itself never leaves the machine, exactly as with `cwd_hash` on the
20
+ * session rows.
21
+ */
22
+ import crypto from "node:crypto";
23
+ import fs from "node:fs";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import { COLLECTOR_HEARTBEAT_SCHEMA_VERSION, } from "@bli-cockpit/telemetry-core";
27
+ import { describeError } from "../health-detail.js";
28
+ import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
29
+ const HEARTBEAT_TIMEOUT_MS = 5_000;
30
+ /**
31
+ * The label form of one approved root.
32
+ *
33
+ * `path.resolve` first so `~/BLI`, `~/BLI/` and `~/BLI/.` are one identity, and
34
+ * on Windows so `c:\users\…` and `C:\Users\…` do not hash apart — the platform
35
+ * treats them as the same folder, and two machines pointing at it must compare
36
+ * equal. Case is folded ONLY on Windows, where the filesystem is
37
+ * case-insensitive; folding on macOS would merge two genuinely distinct roots.
38
+ */
39
+ export function collectionRootLabel(root, platform = process.platform) {
40
+ const pathApi = platform === "win32" ? path.win32 : path.posix;
41
+ const resolved = platform === "win32"
42
+ ? path.win32.normalize(root).replace(/[\\/]+$/u, "")
43
+ : path.posix.normalize(root).replace(/\/+$/u, "");
44
+ const canonical = platform === "win32" ? resolved.toLowerCase() : resolved;
45
+ const basename = pathApi.basename(resolved) || resolved;
46
+ return {
47
+ basename: basename.slice(0, 160),
48
+ path_sha256: crypto
49
+ .createHash("sha256")
50
+ .update(canonical, "utf8")
51
+ .digest("hex"),
52
+ };
53
+ }
54
+ /**
55
+ * The label set for a machine's approved roots, one entry per real folder.
56
+ *
57
+ * The collector deliberately keeps CONSENT ALIASES — on macOS an approved
58
+ * `/var/folders/…` root is held alongside its `/private/var/folders/…`
59
+ * realpath, so attribution matches either spelling. Both are true, and both
60
+ * name one folder; a heartbeat that shipped both would make every Mac look like
61
+ * it had twice the roots it has. Symlinks are resolved before hashing so the
62
+ * aliases collapse into the one folder they describe, and a path that cannot be
63
+ * resolved (deleted, or a Windows spelling on a POSIX host) keeps its own
64
+ * spelling rather than being dropped.
65
+ */
66
+ export function collectionRootLabels(roots, platform = process.platform, realpath = defaultRealpath) {
67
+ const byHash = new Map();
68
+ for (const root of roots) {
69
+ if (!root.trim())
70
+ continue;
71
+ let resolved = root;
72
+ try {
73
+ resolved = realpath(root);
74
+ }
75
+ catch {
76
+ // Keep the spelling we were given: a root that is not on disk right now
77
+ // is still a root this machine is allowed to look at.
78
+ }
79
+ const label = collectionRootLabel(resolved, platform);
80
+ if (!byHash.has(label.path_sha256))
81
+ byHash.set(label.path_sha256, label);
82
+ }
83
+ return [...byHash.values()];
84
+ }
85
+ function defaultRealpath(input) {
86
+ return fs.realpathSync.native(input);
87
+ }
88
+ export function buildCollectorHeartbeat(options) {
89
+ const platform = options.platform ?? os.platform();
90
+ return {
91
+ schema_version: COLLECTOR_HEARTBEAT_SCHEMA_VERSION,
92
+ generated_at: (options.now ?? new Date()).toISOString(),
93
+ collector_version: options.collectorVersion ?? LOCAL_COLLECTOR_VERSION,
94
+ os_platform: platform,
95
+ roots: collectionRootLabels(options.roots, platform),
96
+ last_sync_status: options.facts.status,
97
+ ...(options.facts.reason ? { last_sync_reason: options.facts.reason } : {}),
98
+ ...(typeof options.facts.sessionsObserved === "number"
99
+ ? { sessions_observed: options.facts.sessionsObserved }
100
+ : {}),
101
+ ...(typeof options.facts.sessionsOutsideRoot === "number"
102
+ ? { sessions_outside_root: options.facts.sessionsOutsideRoot }
103
+ : {}),
104
+ };
105
+ }
106
+ /**
107
+ * Sends the heartbeat. Returns whether it landed; never throws.
108
+ *
109
+ * Every branch says something (BLI-3551 / the logging contract): a missing
110
+ * device session, a refusal, a transport failure and the success all get one
111
+ * line, because "did this machine check in today?" must be answerable from
112
+ * `sync.err.log` alone when the dashboard says a device is quiet.
113
+ */
114
+ export async function sendCollectorHeartbeatBestEffort(options) {
115
+ const paths = getCollectorRuntimePaths(options.homeDir);
116
+ const session = await readLocalCollectorSessionFile(paths).catch(() => null);
117
+ if (!session ||
118
+ session.session_state !== "valid" ||
119
+ typeof session.device_token !== "string" ||
120
+ !session.device_token) {
121
+ console.error("[heartbeat] no valid device session on this machine; the dashboard will show it as quiet", JSON.stringify({
122
+ reason: "no_device_session",
123
+ next_action: "run `cockpit do-everything` to pair this machine again",
124
+ }));
125
+ return false;
126
+ }
127
+ const heartbeat = buildCollectorHeartbeat({
128
+ roots: options.roots,
129
+ facts: options.facts,
130
+ ...(options.now ? { now: options.now } : {}),
131
+ });
132
+ const controller = new AbortController();
133
+ const timeout = setTimeout(() => controller.abort(), HEARTBEAT_TIMEOUT_MS);
134
+ try {
135
+ const response = await options.io.fetch(`${options.dashboardUrl}/api/ambient/heartbeat`, {
136
+ method: "POST",
137
+ headers: {
138
+ "Content-Type": "application/json",
139
+ Authorization: `Bearer ${session.device_token}`,
140
+ },
141
+ body: JSON.stringify(heartbeat),
142
+ signal: controller.signal,
143
+ });
144
+ if (!response.ok) {
145
+ // A heartbeat is not retried, so the reason has to be said once, here.
146
+ console.error("[heartbeat] the dashboard refused this tick's heartbeat", JSON.stringify({
147
+ reason: "heartbeat_rejected",
148
+ http_status: response.status,
149
+ root_count: heartbeat.roots.length,
150
+ }));
151
+ return false;
152
+ }
153
+ console.error("[heartbeat] checked in", JSON.stringify({
154
+ reason: "heartbeat_recorded",
155
+ http_status: response.status,
156
+ root_count: heartbeat.roots.length,
157
+ sync_status: heartbeat.last_sync_status,
158
+ sync_reason: heartbeat.last_sync_reason ?? null,
159
+ sessions_observed: heartbeat.sessions_observed ?? null,
160
+ sessions_outside_root: heartbeat.sessions_outside_root ?? null,
161
+ }));
162
+ return true;
163
+ }
164
+ catch (error) {
165
+ console.error("[heartbeat] could not reach the dashboard this tick", JSON.stringify({
166
+ reason: "heartbeat_transport_failed",
167
+ root_count: heartbeat.roots.length,
168
+ ...describeError(error),
169
+ }));
170
+ return false;
171
+ }
172
+ finally {
173
+ clearTimeout(timeout);
174
+ }
175
+ }
@@ -14,7 +14,6 @@ import { errorMessage, writeLine } from "./cli-io.js";
14
14
  import { describeError, maskLocalIdentifiers, redactedHealthDetail, } from "../health-detail.js";
15
15
  import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
16
16
  import { redactSecretLikeContent } from "@bli-cockpit/telemetry-core";
17
- import { COLLECTION_ROOT_REQUIRED } from "../onboarding-roots.js";
18
17
  import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
19
18
  export function addInstallEvent(events, step, status, errorCode,
20
19
  // BLI-2542: the bucket alone cannot be acted on. Callers that hold the reason
@@ -188,24 +187,17 @@ function classifyInstallTelemetryError(error) {
188
187
  return "network";
189
188
  return "failed";
190
189
  }
191
- export function classifySyncHealthError(error) {
192
- const message = errorMessage(error);
193
- if (/auth|token|session|unauthorized|forbidden|401|403/iu.test(message)) {
194
- return "auth_failed";
195
- }
196
- if (/fetch|network|enotfound|econnrefused|timeout/iu.test(message)) {
197
- return "network_failed";
198
- }
199
- // Anchored on the code the collector actually throws rather than on loose
200
- // vocabulary. The old test matched /collection.root|workspace|repo|worktree/
201
- // against the message, so any failure that merely mentioned a repo was filed
202
- // as a collection-root failure and the real reason was lost (BLI-2492).
203
- if (message.includes(COLLECTION_ROOT_REQUIRED) ||
204
- /collection root/iu.test(message)) {
205
- return "collection_root_failed";
206
- }
207
- return "sync_failed";
208
- }
190
+ /**
191
+ * The bucket a `sync_complete` receipt carries.
192
+ *
193
+ * The implementation moved to `../sync-health-class.js` in BLI-3551 and is
194
+ * re-exported here because this module's export surface is what `local.ts` and
195
+ * the tests import. It no longer reads the error's message: matching
196
+ * `/auth|token|session|…/` against the text filed the sync's own
197
+ * `session_report_unposted:no_successful_sync` label as `auth_failed` on every
198
+ * tick of a machine whose credentials were fine.
199
+ */
200
+ export { classifySyncHealthError, classifySyncFailureRecords, } from "../sync-health-class.js";
209
201
  // The bucket above is for aggregation. This is the reason — the actual message,
210
202
  // redacted on the machine that produced it, before it ever leaves.
211
203
  //
@@ -14,7 +14,7 @@ import { defaultExec, defaultInteractiveExec, writeExecOutput, writeLine } from
14
14
  import { assertCollectionRootPersisted } from "./collection-roots.js";
15
15
  import { addInstallEvent, reportInstallEventsBestEffort } from "./install-receipts.js";
16
16
  import { installLocalCollector, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
17
- import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, rootRejectionExplanation, } from "../onboarding-roots.js";
17
+ import { COLLECTION_ROOT_REQUIRED, CollectionRootRequiredError, missingCollectionRootMessage, normalizeRootsDetailed, rootRejectionExplanation, } from "../onboarding-roots.js";
18
18
  export class SelfUpdateError extends Error {
19
19
  result;
20
20
  eacces;
@@ -75,9 +75,9 @@ function resolveInstallCommandRoots(command) {
75
75
  };
76
76
  }
77
77
  if (detailed.rejected.length > 0) {
78
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${rootRejectionExplanation(detailed.rejected[0], command)}`);
78
+ throw new CollectionRootRequiredError(rootRejectionExplanation(detailed.rejected[0], command));
79
79
  }
80
- throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${missingCollectionRootMessage(command)}`);
80
+ throw new CollectionRootRequiredError(missingCollectionRootMessage(command));
81
81
  }
82
82
  export async function runUpdate(command, io) {
83
83
  const installEvents = [];
@@ -174,6 +174,9 @@ async function sendOneTurn(context, prompt, io) {
174
174
  thread: context.command.thread,
175
175
  subject: context.command.subject,
176
176
  model: context.command.model,
177
+ // BLI-3484: which day's page this turn is about. Sent verbatim — the
178
+ // dashboard decides what counts as a date and whose day it is.
179
+ date: context.command.date,
177
180
  },
178
181
  log,
179
182
  });
@@ -263,6 +266,8 @@ function buildAttachmentForm(command, prompt, attachment) {
263
266
  form.set("subject", command.subject);
264
267
  if (command.model)
265
268
  form.set("model", command.model);
269
+ if (command.date)
270
+ form.set("date", command.date);
266
271
  form.set("image", new File([attachment.bytes], attachment.fileName, { type: attachment.mimeType }));
267
272
  return form;
268
273
  }
@@ -776,6 +776,7 @@ function parseAutostartArgs(args) {
776
776
  "--workspace",
777
777
  "--dashboard-url",
778
778
  "--interval-seconds",
779
+ "--parent-pid",
779
780
  "--json",
780
781
  ],
781
782
  valueFlags: [
@@ -784,13 +785,17 @@ function parseAutostartArgs(args) {
784
785
  "--workspace",
785
786
  "--dashboard-url",
786
787
  "--interval-seconds",
788
+ "--parent-pid",
787
789
  ],
788
790
  });
789
791
  if (values.positionals.length > 1) {
790
792
  throw new Error("autostart accepts at most one action (install|uninstall|status).");
791
793
  }
792
794
  const action = values.positionals[0] ?? "install";
793
- if (action !== "install" && action !== "uninstall" && action !== "status") {
795
+ if (action !== "install" &&
796
+ action !== "uninstall" &&
797
+ action !== "status" &&
798
+ action !== "heal-detached") {
794
799
  throw new Error("autostart action must be install, uninstall, or status.");
795
800
  }
796
801
  return {
@@ -800,6 +805,7 @@ function parseAutostartArgs(args) {
800
805
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
801
806
  dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
802
807
  intervalSeconds: optionalPositiveInteger(values.flags.get("--interval-seconds"), "--interval-seconds") ?? DEFAULT_AUTOSTART_INTERVAL_SECONDS,
808
+ parentPid: optionalPositiveInteger(values.flags.get("--parent-pid"), "--parent-pid"),
803
809
  json: values.booleans.has("--json"),
804
810
  };
805
811
  }
@@ -842,6 +848,8 @@ function parseJarvisArgs(args) {
842
848
  "--image",
843
849
  "--file",
844
850
  "--no-stream",
851
+ // BLI-3484: which day's page this turn is about.
852
+ "--date",
845
853
  // BLI-3458: reading back what was already said, rather than saying
846
854
  // something new. Neither takes a turn or reaches the model.
847
855
  "--threads",
@@ -858,6 +866,7 @@ function parseJarvisArgs(args) {
858
866
  "--model",
859
867
  "--image",
860
868
  "--file",
869
+ "--date",
861
870
  "--limit",
862
871
  ],
863
872
  });
@@ -892,6 +901,14 @@ function parseJarvisArgs(args) {
892
901
  if ((threads || history) && image) {
893
902
  throw new Error("jarvis --threads and --history do not take an attachment.");
894
903
  }
904
+ // BLI-3484. `--date` binds the page that was live on one of the subject's
905
+ // days, so a turn can be about Sunday's page. Refused on the two reading
906
+ // commands for the same reason an attachment is: they replay what was said
907
+ // and bind no page at all.
908
+ const date = optionalNonEmpty(values.flags.get("--date"));
909
+ if ((threads || history) && date) {
910
+ throw new Error("jarvis --threads and --history replay what was said; they bind no page.");
911
+ }
895
912
  return {
896
913
  kind: "jarvis",
897
914
  homeDir: optionalNonEmpty(values.flags.get("--home")),
@@ -905,6 +922,7 @@ function parseJarvisArgs(args) {
905
922
  // BLI-3381: no client-side allowlist — the dashboard forwards this key
906
923
  // to the inference server's own allowlist and relays its refusal.
907
924
  model: optionalNonEmpty(values.flags.get("--model")),
925
+ ...(date ? { date } : {}),
908
926
  imagePath: image ?? file,
909
927
  // BLI-3457: streaming is on unless a caller opts out. A dashboard that
910
928
  // does not stream yet still answers plain JSON, so this flag is for
@@ -1137,6 +1155,12 @@ function parseWorkbookArgs(args) {
1137
1155
  * does: the dashboard resolves it against the roster and the database (or the
1138
1156
  * route's own mirror of the database's rule) decides whether the page opens.
1139
1157
  * Nothing is decided here.
1158
+ *
1159
+ * `--date` (BLI-3484) is the terminal's `?d=`, and the same sentence applies to
1160
+ * it twice over: what counts as a date, where that person's day starts, and
1161
+ * whether anything was compiled for it are all the dashboard's answer. A local
1162
+ * copy of "when does Tuesday begin" would be a second answer to a question this
1163
+ * product already answers per person, per zone.
1140
1164
  */
1141
1165
  function parseBriefArgs(args) {
1142
1166
  const values = parseNamedArgs(args, {
@@ -1147,6 +1171,10 @@ function parseBriefArgs(args) {
1147
1171
  "--as",
1148
1172
  "--who",
1149
1173
  "--version",
1174
+ "--date",
1175
+ "--delta",
1176
+ "--against",
1177
+ "--days",
1150
1178
  "--tldr",
1151
1179
  "--full",
1152
1180
  "--versions",
@@ -1157,15 +1185,26 @@ function parseBriefArgs(args) {
1157
1185
  "--no-wait",
1158
1186
  "--json",
1159
1187
  ],
1160
- valueFlags: ["--home", "--dashboard-url", "--for", "--as", "--who", "--version", "--reason"],
1188
+ valueFlags: [
1189
+ "--home",
1190
+ "--dashboard-url",
1191
+ "--for",
1192
+ "--as",
1193
+ "--who",
1194
+ "--version",
1195
+ "--date",
1196
+ "--against",
1197
+ "--days",
1198
+ "--reason",
1199
+ ],
1161
1200
  });
1162
1201
  // Bare `cockpit brief` reads the page, which is what somebody typing it almost
1163
1202
  // always wants — the same shape `cockpit notes` and `cockpit scout` have.
1164
1203
  // `status` (BLI-3462) answers why a brief was or was not delivered.
1165
1204
  const first = values.positionals[0];
1166
1205
  const action = (first === undefined ? "read" : first);
1167
- if (!["read", "edit", "rewrite", "status"].includes(action)) {
1168
- throw new Error(`Unknown brief command: ${first}. Try edit, rewrite or status, or nothing to read it.`);
1206
+ if (!["read", "edit", "rewrite", "history", "status"].includes(action)) {
1207
+ throw new Error(`Unknown brief command: ${first}. Try edit, rewrite, history or status, or nothing to read it.`);
1169
1208
  }
1170
1209
  if (values.positionals.length > (first === undefined ? 0 : 1)) {
1171
1210
  throw new Error(`brief ${action} does not take "${values.positionals[1]}".`);
@@ -1192,6 +1231,29 @@ function parseBriefArgs(args) {
1192
1231
  if (action !== "rewrite" && (wait || noWait)) {
1193
1232
  throw new Error("--wait and --no-wait belong to `cockpit brief rewrite`.");
1194
1233
  }
1234
+ // ── Reading a past day (BLI-3484) ────────────────────────────────────────
1235
+ const date = optionalNonEmpty(values.flags.get("--date"));
1236
+ const against = optionalNonEmpty(values.flags.get("--against"));
1237
+ const delta = values.booleans.has("--delta");
1238
+ const days = optionalPositiveInteger(values.flags.get("--days"), "--days");
1239
+ if (date && action !== "read") {
1240
+ throw new Error("--date belongs to `cockpit brief` on its own — it reads one day's page.");
1241
+ }
1242
+ if (delta && action !== "read") {
1243
+ throw new Error("--delta belongs to `cockpit brief` on its own.");
1244
+ }
1245
+ if (against && !delta) {
1246
+ // Said rather than silently ignored: somebody who typed `--against` asked
1247
+ // for a comparison, and running the plain read would answer a different
1248
+ // question without saying so.
1249
+ throw new Error("--against needs --delta: it names the day to compare against.");
1250
+ }
1251
+ if (days !== undefined && action !== "history") {
1252
+ throw new Error("--days belongs to `cockpit brief history`.");
1253
+ }
1254
+ if (action === "history" && (values.booleans.has("--tldr") || values.booleans.has("--claims"))) {
1255
+ throw new Error("brief history lists days; --tldr and --claims belong to reading a page.");
1256
+ }
1195
1257
  // `--as` is accepted as an alias so the two conversational commands read the
1196
1258
  // same way; `cockpit jarvis --as <person>` has meant this since BLI-3380.
1197
1259
  // `--who` is the third spelling, and it exists because `cockpit brief status
@@ -1215,6 +1277,10 @@ function parseBriefArgs(args) {
1215
1277
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
1216
1278
  subject: forPerson ?? asPerson ?? whoPerson,
1217
1279
  version: optionalNonEmpty(values.flags.get("--version")),
1280
+ ...(date ? { date } : {}),
1281
+ ...(delta ? { delta: true } : {}),
1282
+ ...(against ? { against } : {}),
1283
+ ...(days !== undefined ? { days } : {}),
1218
1284
  tldr,
1219
1285
  versions: values.booleans.has("--versions"),
1220
1286
  claims: values.booleans.has("--claims"),
@@ -56,7 +56,7 @@ export function localCommandHelp(command) {
56
56
  " cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
57
57
  " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
58
58
  " cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
59
- " cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--threads|--history [--limit <n>]] [--dashboard-url <url>] [--json]",
59
+ " cockpit jarvis [question] [--prompt <question>] [--as <person>] [--date <YYYY-MM-DD>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--threads|--history [--limit <n>]] [--dashboard-url <url>] [--json]",
60
60
  " cockpit model [show|set <provider:model>] [--json]",
61
61
  " cockpit scout [start|dismiss|undo <experiment-id>] [--days <n>] [--dashboard-url <url>] [--json]",
62
62
  " cockpit ops [status [--job <id>] [--skips] | recompile --person <email|name|id> [--dry-run]] [--dashboard-url <url>] [--json]",
@@ -64,7 +64,7 @@ export function localCommandHelp(command) {
64
64
  " cockpit settings [personal [--chat-model <key>] [--brief-model <key>] | switches [set <key> <value>] | models [set --chat <key>] [--memory <id>] | env list|set --project <p> --file <f> --content-stdin|delete --id <uuid> [--yes]] [--json]",
65
65
  " cockpit team [members | invite <email> --role <role> [--team-id <uuid>] | role <userId> --role <role> [--yes]] [--json]",
66
66
  " cockpit workbook [<project> [<doc>]] [--section <id>] [--markdown] [--width <n>] [--dashboard-url <url>] [--json]",
67
- " cockpit brief [edit|rewrite] [--for <person>] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--reason \"<why>\"] [--wait|--no-wait] [--dashboard-url <url>] [--json]",
67
+ " cockpit brief [edit|rewrite|history] [--for <person>] [--date <YYYY-MM-DD>] [--delta [--against <YYYY-MM-DD>]] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--days <n>] [--reason \"<why>\"] [--wait|--no-wait] [--dashboard-url <url>] [--json]",
68
68
  " cockpit brief status [--who <person>] [--render] [--dashboard-url <url>] [--json]",
69
69
  " cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
70
70
  " cockpit notes [list|show <id>|shelf|shelves|upload <paths...>|paste|share <id>|unshare <id>|move <id>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--yes] [--dashboard-url <url>] [--json]",
@@ -274,10 +274,11 @@ function localSubcommandHelp(command) {
274
274
  [
275
275
  "jarvis",
276
276
  [
277
- "Usage: cockpit jarvis [question] [--prompt <question>] [--as <person>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--threads|--history [--limit <n>]] [--dashboard-url <url>] [--json]",
277
+ "Usage: cockpit jarvis [question] [--prompt <question>] [--as <person>] [--date <YYYY-MM-DD>] [--thread <name>] [--model <key>] [--image <path>|--file <path>] [--no-stream] [--threads|--history [--limit <n>]] [--dashboard-url <url>] [--json]",
278
278
  "",
279
279
  "Chats with the same JARVIS used by Tower web chat and the BLI Slack DM.",
280
280
  "--as selects the existing website person space; it changes who the chat is about, never who is authenticated.",
281
+ "--date <YYYY-MM-DD> binds the page that was live on one of that person's days (`today` and `yesterday` work too), so the turn is about that day's page instead of the newest one. A day with nothing compiled binds no page and JARVIS says so rather than answering from a different day.",
281
282
  "--model <key> requests one provider:model pair (e.g. openai:gpt-5.6-terra); an unrecognised key is refused by the dashboard, not this command.",
282
283
  "--image <path> (alias --file) attaches one local PNG, JPEG, or WEBP under 10 MB with the question, the same one-image gate the JARVIS panel uses. A bad path, an unsupported type, or an oversized file is refused with its own plain sentence — never a stack trace.",
283
284
  "Run with no question for an interactive terminal conversation.",
@@ -377,7 +378,7 @@ function localSubcommandHelp(command) {
377
378
  [
378
379
  "brief",
379
380
  [
380
- "Usage: cockpit brief [edit|rewrite] [--for <person>] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--reason \"<why>\"] [--wait|--no-wait] [--dashboard-url <url>] [--json]",
381
+ "Usage: cockpit brief [edit|rewrite|history] [--for <person>] [--date <YYYY-MM-DD>] [--delta [--against <YYYY-MM-DD>]] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--days <n>] [--reason \"<why>\"] [--wait|--no-wait] [--dashboard-url <url>] [--json]",
381
382
  "",
382
383
  "Prints the TODAY page — the same page the Tower website shows, rendered for a terminal.",
383
384
  "--for opens somebody else's page; the website's own rule decides whether you may, and it refuses in plain words when you may not.",
@@ -386,6 +387,14 @@ function localSubcommandHelp(command) {
386
387
  "--claims prints the [claimId] beside every line, which is what `cockpit correct --claim` takes.",
387
388
  "Reading it here counts as opening it, exactly as opening it in a browser does.",
388
389
  "",
390
+ "--date <YYYY-MM-DD> reads the page that was live on one day — `today` and `yesterday` work too. Days are that person's own days, in their own timezone, so a page always sits on the date its own masthead says.",
391
+ " A day with nothing compiled says so by name, and says which day is nearest. It never quietly hands you a different day's page.",
392
+ "--delta says what changed between that day and the day before it, in the same words the website's version history uses. --against <day> compares against a day you name instead.",
393
+ " Comparisons that skip versions are worked out fresh each time and say `(not kept)` — a day with several compiles is one of those.",
394
+ "",
395
+ "history — the days there are, newest first, each with its masthead line and how many times it compiled that day. --days <n> asks for more than the last 14.",
396
+ " Pass any date it prints back to `--date`.",
397
+ "",
389
398
  " cockpit brief status [--who <person>] [--render]",
390
399
  " Why your brief did or did not arrive this morning — one reason, from the same",
391
400
  " chain the Slack DM cron itself follows: not_due, not_monday, not_the_1st,",