@bli-cockpit/cli 0.2.196 → 0.2.198

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.
@@ -16,6 +16,18 @@ export async function runBrain(command, io) {
16
16
  writeLine(io.stderr, detail);
17
17
  return 1;
18
18
  }
19
+ if (command.action === "retry") {
20
+ const answer = await askAgentDoor(door, { path: "/api/brain/incidents/retry", method: "POST", label: "brain retry", timeoutMs: READ_DEADLINE_MS,
21
+ body: { ids: command.ids, all_needs_human: command.allNeedsHuman, project: command.project, reason: command.reason } });
22
+ if (!answer.ok)
23
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
24
+ const body = answer.body;
25
+ writeLine(io.stderr, `${TAG} retry answered ${JSON.stringify({ reason: body.reason, retried: body.retried.length, skipped: body.skipped.length, missing: body.missing.length, failures: body.failures.length })}`);
26
+ if (command.json)
27
+ return emitAgentDoor(door, body, body.ok ? 0 : 1);
28
+ writeLine(io.stdout, body.text);
29
+ return body.ok ? 0 : 1;
30
+ }
19
31
  const query = new URLSearchParams();
20
32
  if (command.project)
21
33
  query.set("project", command.project);
@@ -25,7 +25,7 @@ import { runCalAddIcal, runCalDetach, runCalShare, runCalSync } from "./cal-cale
25
25
  import { runCalDelete } from "./cal-delete.js";
26
26
  import { runCalSyncStatus } from "./cal-sync-status.js";
27
27
  import { writeLine } from "./cli-io.js";
28
- import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
28
+ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
29
29
  const TAG = "[cal cli]";
30
30
  const READ_DEADLINE_MS = 30_000;
31
31
  const WRITE_DEADLINE_MS = 60_000;
@@ -118,7 +118,7 @@ async function window(command, door) {
118
118
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
119
119
  const body = answer.body;
120
120
  if (door.json)
121
- return emitAgentDoor(door, { ok: true, ...body });
121
+ return emitAgentDoor(door, { ok: true, ...body, ...cacheCoverage(answer) });
122
122
  if (answer.fromCache) {
123
123
  writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
124
124
  }
@@ -47,8 +47,8 @@ export async function syncStatusDocs(command, door) {
47
47
  const body = answer.body;
48
48
  if (door.json) {
49
49
  return emitAgentDoor(door, {
50
+ ...answer.body,
50
51
  ok: true,
51
- counts: body.counts,
52
52
  documents: body.documents ?? [],
53
53
  last_run: body.last_run ?? { push: null, pull: null },
54
54
  });
@@ -57,6 +57,8 @@ export async function syncStatusDocs(command, door) {
57
57
  for (const doc of documents) {
58
58
  writeLine(door.io.stdout, `${doc.id} ${doc.state.padEnd(13)} ${(doc.notion_synced_at ?? "never").padEnd(25)} ${doc.slug ?? "(no slug)"} ${doc.title}`);
59
59
  }
60
+ if (body.coverage)
61
+ writeLine(door.io.stdout, body.coverage);
60
62
  const byState = body.counts?.by_state ?? {};
61
63
  writeLine(door.io.stdout, "");
62
64
  writeLine(door.io.stdout, `${body.counts?.linked ?? documents.length} linked: ${Object.entries(byState)
@@ -32,7 +32,7 @@ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
32
32
  import { readDoc } from "./docs-read.js";
33
33
  import { linkDoc, syncStatusDocs } from "./docs-notion.js";
34
34
  import { readNoteFile } from "./notes-file.js";
35
- import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
35
+ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
36
36
  const TAG = "[docs cli]";
37
37
  const READ_DEADLINE_MS = 30_000;
38
38
  const WRITE_DEADLINE_MS = 60_000;
@@ -97,10 +97,13 @@ async function listDocs(command, door) {
97
97
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
98
98
  const documents = (answer.body.documents ?? []);
99
99
  if (door.json)
100
- return emitAgentDoor(door, { ok: true, documents });
100
+ return emitAgentDoor(door, { ok: true, ...answer.body, documents, ...cacheCoverage(answer) });
101
101
  if (answer.fromCache) {
102
102
  writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
103
103
  }
104
+ const counts = answer.body.counts;
105
+ if (counts)
106
+ writeLine(door.io.stdout, `${counts.total} documents match in all. ${counts.coverage}`);
104
107
  if (documents.length === 0) {
105
108
  writeLine(door.io.stdout, command.query ? `No document matches "${command.query}".` : "No documents.");
106
109
  return 0;
@@ -53,6 +53,7 @@ setupReceipt, repairs = []) {
53
53
  repairs,
54
54
  needs_person: needsPerson,
55
55
  setup_receipt: setupReceipt?.receipt ?? null,
56
+ setup_receipt_coverage: setupReceipt ? setupReceiptBlock(setupReceipt).join("; ") : "no data yet: no cached receipt",
56
57
  memory_install: setupReceipt?.memory ?? null,
57
58
  }, null, 2));
58
59
  return;
@@ -169,6 +170,7 @@ export function doctorNeedsPerson(rows, receipt) {
169
170
  "roots-cover-work": "cockpit doctor",
170
171
  "single-install": "Run npm uninstall -g @bli-cockpit/cli with the Node installation that owns the extra CLI listed above.",
171
172
  "autostart-alive": "cockpit doctor",
173
+ "usage-telemetry": "cockpit doctor",
172
174
  "memory-registered": "cockpit doctor",
173
175
  "agent-rules": "cockpit doctor",
174
176
  "mcp-answers": "cockpit doctor",
@@ -73,6 +73,8 @@ function parseNpmVersion(stdout) {
73
73
  }
74
74
  export function reexecDoctor(command, io) {
75
75
  const args = ["doctor"];
76
+ if (command.noUsageTelemetry)
77
+ args.push("--no-usage-telemetry");
76
78
  if (command.homeDir)
77
79
  args.push("--home", command.homeDir);
78
80
  if (command.backfillBudgetSeconds)
@@ -0,0 +1,96 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { getCollectorRuntimePaths, readLocalCollectorSessionFile } from "../local-state.js";
4
+ import { claudeSettingsFile } from "./memory-install-claude.js";
5
+ import { codexConfigFile } from "./tower-mcp-codex.js";
6
+ import { defaultMemoryFileIo } from "./memory-install-files.js";
7
+ import { mergeUsageToml } from "./usage-telemetry-toml.js";
8
+ import { fail, needsFix, ok } from "./doctor-report.js";
9
+ export function mergeClaudeUsageSettings(raw, origin, token, off) {
10
+ const root = JSON.parse(raw || "{}");
11
+ if (!root || typeof root !== "object" || Array.isArray(root) || (root.env !== undefined && (!root.env || typeof root.env !== "object" || Array.isArray(root.env))))
12
+ throw new Error("config_unreadable");
13
+ root.env = { ...root.env, CLAUDE_CODE_ENABLE_TELEMETRY: off ? "0" : "1",
14
+ OTEL_METRICS_EXPORTER: off ? "none" : "otlp", OTEL_LOGS_EXPORTER: off ? "none" : "otlp",
15
+ OTEL_EXPORTER_OTLP_PROTOCOL: "http/json", OTEL_EXPORTER_OTLP_ENDPOINT: `${new URL(origin).origin}/api/otel`,
16
+ OTEL_EXPORTER_OTLP_HEADERS: `Authorization=Bearer ${token}` };
17
+ // OTEL_LOG_USER_PROMPTS is never added or changed. Existing person keys survive.
18
+ return `${JSON.stringify(root, null, 2)}\n`;
19
+ }
20
+ export function mergeCodexUsageSettings(raw, origin, token, off) {
21
+ // Disabled exporter options remain as comments, including person-set TLS and
22
+ // headers. Restore them before enabling, rather than discard configuration.
23
+ const prefix = "# TOWER usage telemetry opt-out: ";
24
+ let table = "";
25
+ raw = raw.split("\n").flatMap(line => {
26
+ if (!off && line.startsWith(prefix))
27
+ line = line.slice(prefix.length);
28
+ const header = /^\s*\[([^\]]+)\]/.exec(line);
29
+ if (header)
30
+ table = header[1].replace(/["']/g, "").replace(/\s/g, "");
31
+ const exporterTable = /^otel\.(?:exporter|metrics_exporter)(?:\.|$)/.test(table);
32
+ const exporterAssignment = table === "otel" && /^\s*(?:exporter|metrics_exporter)\s*=/.test(line);
33
+ if (off && (exporterTable || exporterAssignment) && !/^\s*(?:exporter|metrics_exporter)\s*=\s*["']none["']/.test(line))
34
+ return [prefix + line];
35
+ if (!off && exporterAssignment && /^\s*(?:exporter|metrics_exporter)\s*=\s*["']none["']/.test(line))
36
+ return [];
37
+ return [line];
38
+ }).join("\n");
39
+ const exporter = off ? '"none"' : { "otlp-http": {
40
+ endpoint: JSON.stringify(`${new URL(origin).origin}/api/otel/v1/logs`), protocol: '"json"', headers: { Authorization: JSON.stringify(`Bearer ${token}`) },
41
+ } };
42
+ const metrics = off ? '"none"' : { "otlp-http": {
43
+ endpoint: JSON.stringify(`${new URL(origin).origin}/api/otel/v1/metrics`), protocol: '"json"', headers: { Authorization: JSON.stringify(`Bearer ${token}`) },
44
+ } };
45
+ return mergeUsageToml(raw, ["otel"], { log_user_prompt: "false", exporter, metrics_exporter: metrics });
46
+ }
47
+ export async function configureUsageTelemetry(options) {
48
+ const marker = path.join(getCollectorRuntimePaths(options.homeDir).state_dir, "usage-telemetry-opt-out.json");
49
+ const persisted = await options.io.readText(marker) !== null;
50
+ const off = persisted || Boolean(options.optOut);
51
+ let changed = Boolean(options.optOut && !persisted);
52
+ const files = [
53
+ [claudeSettingsFile(options.homeDir), mergeClaudeUsageSettings],
54
+ [codexConfigFile(options.homeDir), mergeCodexUsageSettings],
55
+ ];
56
+ const updates = [];
57
+ for (const [file, merge] of files) {
58
+ const raw = await options.io.readText(file) ?? "";
59
+ const next = merge(raw, options.origin, options.token, off);
60
+ updates.push({ file, merge, raw, next });
61
+ }
62
+ if (options.optOut && !persisted && !options.checkOnly)
63
+ await options.io.writeText(marker, '{"opt_out":true}\n');
64
+ for (const { file, merge, raw, next } of updates) {
65
+ if (raw === next)
66
+ continue;
67
+ changed = true;
68
+ if (options.checkOnly)
69
+ continue;
70
+ await options.io.writeText(file, next);
71
+ const stored = await options.io.readText(file);
72
+ if (stored === null || merge(stored, options.origin, options.token, off) !== stored)
73
+ throw new Error("read_back_mismatch");
74
+ }
75
+ console.error("[usage telemetry] configured", JSON.stringify({ reason: off ? "explicit_opt_out" : changed ? "settings_changed" : "already_current", check_only: Boolean(options.checkOnly) }));
76
+ return { off, changed };
77
+ }
78
+ async function usageTelemetryStep(context, checkOnly) {
79
+ try {
80
+ const home = context.command.homeDir ?? os.homedir();
81
+ const session = await readLocalCollectorSessionFile(getCollectorRuntimePaths(home));
82
+ if (!session?.device_token)
83
+ return needsFix("usage-telemetry", "device_token_missing", "usage telemetry: needs Tower sign-in");
84
+ const result = await configureUsageTelemetry({ homeDir: home, origin: context.command.dashboardUrl, token: session.device_token,
85
+ optOut: context.command.noUsageTelemetry, checkOnly, io: defaultMemoryFileIo({ mode: 0o600 }) });
86
+ const message = result.off ? "usage telemetry: off (explicit opt-out)" : "usage telemetry: on (claude code, codex)";
87
+ return checkOnly && result.changed ? needsFix("usage-telemetry", "settings_differ", message) : ok("usage-telemetry", result.off ? "explicit_opt_out" : "already_current", message);
88
+ }
89
+ catch (error) {
90
+ const reason = error instanceof SyntaxError ? "config_unreadable" : error instanceof Error && ["config_unreadable", "read_back_mismatch"].includes(error.message) ? error.message : "settings_io_failed";
91
+ console.error("[usage telemetry] refused", JSON.stringify({ reason }));
92
+ return fail("usage-telemetry", reason, "usage telemetry: settings could not be safely merged");
93
+ }
94
+ }
95
+ export const checkUsageTelemetry = (context) => usageTelemetryStep(context, true);
96
+ export const fixUsageTelemetry = (context) => usageTelemetryStep(context, false);
@@ -1,3 +1,4 @@
1
+ import { checkUsageTelemetry, fixUsageTelemetry } from "./doctor-usage-telemetry.js";
1
2
  import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
2
3
  import { isSemverBelow } from "../scheduled-self-update.js";
3
4
  import { describeError } from "../health-detail.js";
@@ -184,6 +185,7 @@ function doctorInvariants() {
184
185
  check: (context) => context.deps.checkMemory(context),
185
186
  fix: (context, _state) => context.deps.fixMemory(context),
186
187
  },
188
+ { id: "usage-telemetry", check: context => context.deps.checkUsageTelemetry(context), fix: context => context.deps.fixUsageTelemetry(context) },
187
189
  { id: "agent-rules", check: (context) => context.deps.checkAgentRules(context), fix: (context) => context.deps.fixAgentRules(context) },
188
190
  // BLI-3804. Right after the registration row, because it asks the second
189
191
  // half of the same question: `memory-registered` proves the entry exists
@@ -248,6 +250,8 @@ function defaultDoctorDeps(hooks) {
248
250
  checkSingleInstall: checkSingleInstallState,
249
251
  checkAutostart: checkAutostartState,
250
252
  fixAutostart: fixAutostartState,
253
+ checkUsageTelemetry,
254
+ fixUsageTelemetry,
251
255
  checkMemory: checkMemoryState,
252
256
  fixMemory: fixMemoryState,
253
257
  checkMcpAnswers: (context) => checkMcpAnswersState(context),
@@ -270,7 +270,7 @@ export async function sendCollectorHeartbeatBestEffort(options) {
270
270
  const heartbeat = buildCollectorHeartbeat({
271
271
  roots: options.roots,
272
272
  facts: options.facts,
273
- setupReceipt: { ...(setupReceipt ?? { schema_version: "setup-receipt.v1", checked_at: new Date().toISOString(), browser: { status: "unknown" }, device: { status: "unknown" }, collector: { autostart: { status: "unknown" } } }), agent_setup: await collectAgentSetup(options.homeDir ?? os.homedir()) },
273
+ setupReceipt: setupReceipt ? { ...setupReceipt, agent_setup: await collectAgentSetup(options.homeDir ?? os.homedir()) } : null,
274
274
  ...(options.now ? { now: options.now } : {}),
275
275
  memoryInstall: memory.receipt,
276
276
  });
@@ -28,7 +28,7 @@
28
28
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
29
29
  import { writeLine } from "./cli-io.js";
30
30
  import { assigneeLabel, beginAssigneeDirectoryFetch, finishAssigneeDirectory, withAssigneeIdentity } from "./issue-directory.js";
31
- import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
31
+ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
32
32
  import { showIssue } from "./issue-show.js";
33
33
  import { READ_DEADLINE_MS, TAG, resolveProjectId, sizeForEstimate, withSizeLabel, } from "./issue-contracts.js";
34
34
  import { commentIssue, createIssue, moveIssue, updateIssue } from "./issue-write.js";
@@ -154,11 +154,15 @@ async function listIssues(command, door) {
154
154
  if (door.json) {
155
155
  return emitAgentDoor(door, {
156
156
  ok: true,
157
+ ...(body.counts ? { counts: body.counts } : {}),
158
+ ...cacheCoverage(answer),
157
159
  issues: issues.map((issue) => withSizeLabel(withAssigneeIdentity(issue, directory))),
158
160
  ...(body.completeness ? { completeness: body.completeness } : {}),
159
161
  ...(body.outside_project ? { outside_project: body.outside_project } : {}),
160
162
  });
161
163
  }
164
+ if (body.counts)
165
+ writeLine(door.io.stdout, `${body.counts.total} match in all. ${body.counts.coverage}`);
162
166
  if (issues.length === 0) {
163
167
  writeLine(door.io.stdout, "No issues match that.");
164
168
  return 0;
@@ -100,6 +100,7 @@ export function parseDoctorArgs(alias, args) {
100
100
  "--dry-run",
101
101
  "--check",
102
102
  "--no-repair",
103
+ "--no-usage-telemetry",
103
104
  "--lock-wait",
104
105
  "--backfill-budget",
105
106
  "--json",
@@ -141,6 +142,7 @@ export function parseDoctorArgs(alias, args) {
141
142
  }
142
143
  return {
143
144
  kind: "doctor",
145
+ noUsageTelemetry: values.booleans.has("--no-usage-telemetry"),
144
146
  checkOnly: values.booleans.has("--check") || values.booleans.has("--no-repair"),
145
147
  // Absent stays absent (BLI-11101): `doctor-budget.ts` owns the defaults,
146
148
  // and `--json` without either flag runs under one bounded budget.
@@ -13,12 +13,12 @@ import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-value
13
13
  export const BRAIN_SHOW_STATUSES = ["open", "running", "needs_human", "fixed", "not_actionable", "cleared"];
14
14
  export function parseBrainArgs(args) {
15
15
  const values = parseNamedArgs(args, {
16
- allowedFlags: ["--project", "--show", "--home", "--dashboard-url", "--json"],
17
- valueFlags: ["--project", "--show", "--home", "--dashboard-url"],
16
+ allowedFlags: ["--project", "--show", "--home", "--dashboard-url", "--json", "--all-needs-human", "--reason"],
17
+ valueFlags: ["--project", "--show", "--home", "--dashboard-url", "--reason"],
18
18
  });
19
19
  const action = values.positionals[0];
20
- if (action !== "status" || values.positionals.length > 1) {
21
- throw new Error("brain takes one verb: `status`. Example: `cockpit brain status` or `cockpit brain status --project pearl-portal`.");
20
+ if ((action !== "status" && action !== "retry") || (action === "status" && values.positionals.length > 1)) {
21
+ throw new Error("brain takes one verb: `status` or `retry`. Example: `cockpit brain status` or `cockpit brain status --project pearl-portal`.");
22
22
  }
23
23
  const project = optionalNonEmpty(values.flags.get("--project"));
24
24
  if (project && !LOG_PROJECT_PATTERN.test(project))
@@ -26,9 +26,25 @@ export function parseBrainArgs(args) {
26
26
  const show = optionalNonEmpty(values.flags.get("--show"));
27
27
  if (show && !BRAIN_SHOW_STATUSES.includes(show))
28
28
  throw new Error(`--show wants one of ${BRAIN_SHOW_STATUSES.join(", ")}.`);
29
+ const ids = values.positionals.slice(1);
30
+ const reason = optionalNonEmpty(values.flags.get("--reason"))?.trim();
31
+ const allNeedsHuman = values.booleans.has("--all-needs-human");
32
+ if (action === "retry") {
33
+ if (!reason || reason.length > 4000)
34
+ throw new Error("--reason requires text (at most 4000 characters).");
35
+ if (!ids.length && !allNeedsHuman)
36
+ throw new Error("retry requires incident ids or --all-needs-human.");
37
+ if (ids.length > 100 || ids.some(id => !/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|BLI-[0-9]+)$/i.test(id)))
38
+ throw new Error("retry wants incident UUIDs or BLI issue identifiers (at most 100).");
39
+ if (show)
40
+ throw new Error("--show belongs to brain status.");
41
+ }
42
+ else if (reason || allNeedsHuman)
43
+ throw new Error("--reason and --all-needs-human belong to brain retry.");
29
44
  return {
30
45
  kind: "brain",
31
- action: "status",
46
+ action,
47
+ ...(action === "retry" ? { ids, reason, allNeedsHuman } : {}),
32
48
  ...(project ? { project } : {}),
33
49
  ...(show ? { show } : {}),
34
50
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
@@ -49,6 +49,10 @@ export const TOWER_OPS_COMMAND_HELP = [
49
49
  [
50
50
  "Usage: cockpit brain status [--project <id>] [--show open|running|needs_human|fixed|not_actionable|cleared] [--dashboard-url <url>] [--json]",
51
51
  "",
52
+ "Retry: cockpit brain retry <id...> [--all-needs-human] [--project <p>] --reason <text> [--json]",
53
+ "Ids are incident UUIDs or BLI issue identifiers. Retry reopens needs_human or not_actionable rows, resets attempts and claims,",
54
+ "and records who, why and when. --all-needs-human selects only needs_human; --project narrows every selection. Other states are skipped.",
55
+ "Only members and super admins can retry. One comment is posted per incident issue. MCP twin: brain_retry.",
52
56
  "Tower's incident queue. Every 15 minutes the log-digest cron turns each recurring problem in a project's logs (classes",
53
57
  "our_bug, config_drift, capacity, unknown) into ONE incident with ONE Tower issue; a repeat adds to its count, never a twin.",
54
58
  "A template quiet for 24 hours is marked cleared. This prints, per project, how many incidents are open, being worked,",
@@ -89,11 +89,12 @@ export function localSubcommandHelp(command) {
89
89
  [
90
90
  "doctor",
91
91
  [
92
- "Usage: cockpit doctor [--workspace <path>] [--check | --no-repair] [--lock-wait <seconds>] [--backfill-budget <seconds>] [--json]",
92
+ "Usage: cockpit doctor [--workspace <path>] [--check | --no-repair] [--no-usage-telemetry] [--lock-wait <seconds>] [--backfill-budget <seconds>] [--json]",
93
93
  "",
94
94
  "Diagnose, repair, and verify this machine.",
95
95
  "Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
96
96
  "roots-cover-work is informational: it names how many sessions are inside vs. outside saved roots (all already uploaded, outside ones labelled) and gives cockpit roots add commands for widening a root's redacted transcript content. It never needs a fix.",
97
+ "--no-usage-telemetry switches off Claude Code and Codex usage export, including later doctor runs.",
97
98
  "--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
98
99
  "--backfill-budget defaults to 900 seconds; catch-up repeats chunks until complete, with progress every 30 seconds.",
99
100
  "Doctor never waits for the background sync: a running sync is reported as running (with how long ago it started) and doctor finishes; a sync that stopped responding or made no progress for 15 minutes is ended and doctor carries on. --lock-wait is accepted and changes nothing. Progress prints at most every 30 seconds and says what changed.",
@@ -26,7 +26,7 @@ import { writeFile } from "node:fs/promises";
26
26
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
27
27
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
28
28
  import { composeMessage, failAttachmentsUnconfirmed, readMailBody, runMailDraft, unconfirmedAttachments, } from "./mail-compose.js";
29
- import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
29
+ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
30
30
  import { towerRequest } from "../tower-client.js";
31
31
  const TAG = "[mail cli]";
32
32
  const READ_DEADLINE_MS = 30_000;
@@ -177,7 +177,7 @@ async function inbox(command, door) {
177
177
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
178
178
  const body = answer.body;
179
179
  if (door.json)
180
- return emitAgentDoor(door, { ok: true, ...body });
180
+ return emitAgentDoor(door, { ok: true, ...body, ...cacheCoverage(answer) });
181
181
  if (answer.fromCache) {
182
182
  writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
183
183
  }
@@ -16,7 +16,7 @@ import { chmod, mkdir, readFile, rename, stat, unlink, writeFile, } from "node:f
16
16
  import path from "node:path";
17
17
  import { randomBytes } from "node:crypto";
18
18
  import { describeError, isMissingFileFailure } from "../health-detail.js";
19
- export function defaultMemoryFileIo() {
19
+ export function defaultMemoryFileIo(options = {}) {
20
20
  return {
21
21
  async readText(file) {
22
22
  try {
@@ -40,9 +40,10 @@ export function defaultMemoryFileIo() {
40
40
  .then((info) => info.mode & 0o777)
41
41
  .catch(() => null);
42
42
  try {
43
- await writeFile(staged, contents, "utf8");
44
- if (existingMode !== null)
45
- await chmod(staged, existingMode);
43
+ await writeFile(staged, contents, { encoding: "utf8", ...(options.mode !== undefined ? { mode: options.mode } : {}) });
44
+ const targetMode = options.mode ?? existingMode;
45
+ if (targetMode !== null)
46
+ await chmod(staged, targetMode);
46
47
  await rename(staged, file);
47
48
  }
48
49
  catch (error) {
@@ -53,10 +54,12 @@ export function defaultMemoryFileIo() {
53
54
  console.error("[memory-install] staged write could not be renamed; writing in place", JSON.stringify({
54
55
  reason: "staged_rename_failed",
55
56
  byte_size: contents.length,
56
- ...describeError(error),
57
+ ...(options.mode !== undefined ? { error_name: error instanceof Error ? error.name : "unknown" } : describeError(error)),
57
58
  }));
58
59
  await unlink(staged).catch(() => undefined);
59
- await writeFile(file, contents, "utf8");
60
+ await writeFile(file, contents, { encoding: "utf8", ...(options.mode !== undefined ? { mode: options.mode } : {}) });
61
+ if (options.mode !== undefined)
62
+ await chmod(file, options.mode);
60
63
  }
61
64
  },
62
65
  };
@@ -208,11 +208,9 @@ export async function readMemoryReceiptFile(options) {
208
208
  if (!receipt)
209
209
  return { receipt: null, reason: "receipt_unparseable" };
210
210
  const age = (options.now ?? new Date()).getTime() - Date.parse(receipt.checked_at);
211
- // A reading a week old is not a reading. Better to send nothing and let the
212
- // board say "not reported" than to publish a claim about a machine that has
213
- // not been looked at since.
211
+ // Keep last-known host facts, with the original checked_at and reason.
214
212
  if (Number.isFinite(age) && age > MEMORY_RECEIPT_MAX_AGE_MS) {
215
- return { receipt: null, reason: "receipt_expired" };
213
+ return { receipt, reason: "receipt_expired" };
216
214
  }
217
215
  return { receipt, reason: "receipt_read" };
218
216
  }
@@ -49,7 +49,7 @@ import { resolveContainerTag } from "@bli-cockpit/memory-mcp/dist/container-tag.
49
49
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
50
50
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
51
51
  import { runMemoryConsolidation } from "./memory-consolidation.js";
52
- import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
52
+ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
53
53
  import { runMemoryRequeue } from "./memory-requeue.js";
54
54
  import { runMemoryVisibility } from "./memory-visibility.js";
55
55
  import { readNoteFile } from "./notes-file.js";
@@ -194,7 +194,7 @@ async function searchMemory(command, door) {
194
194
  degraded: body.degraded ?? null,
195
195
  })}`);
196
196
  if (door.json)
197
- return emitAgentDoor(door, answer.body);
197
+ return emitAgentDoor(door, { ...answer.body, ...cacheCoverage(answer) });
198
198
  if (answer.fromCache) {
199
199
  writeLine(door.io.stderr, `(${cacheAgeLabel(answer.ageMs ?? 0)}, refreshing in the background)`);
200
200
  }
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
19
19
  import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
20
- import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
20
+ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
21
21
  const TAG = "[msg cli]";
22
22
  const READ_DEADLINE_MS = 30_000;
23
23
  const WRITE_DEADLINE_MS = 30_000;
@@ -64,6 +64,7 @@ async function fetchChannels(door) {
64
64
  channels: answer.body.channels ?? [],
65
65
  fromCache: answer.fromCache,
66
66
  ageMs: answer.ageMs,
67
+ coverage: answer.coverage,
67
68
  };
68
69
  }
69
70
  async function listChannels(door) {
@@ -71,7 +72,7 @@ async function listChannels(door) {
71
72
  if (!fetched.ok)
72
73
  return failAgentDoor(door, TAG, fetched.reason, fetched.detail);
73
74
  if (door.json)
74
- return emitAgentDoor(door, { ok: true, channels: fetched.channels });
75
+ return emitAgentDoor(door, { ok: true, channels: fetched.channels, ...cacheCoverage(fetched) });
75
76
  if (fetched.fromCache) {
76
77
  writeLine(door.io.stderr, `(${cacheAgeLabel(fetched.ageMs ?? 0)}, refreshing in the background)`);
77
78
  }
@@ -31,5 +31,7 @@ export async function runFolderCommand(command, door) {
31
31
  const label = command.tree ? `${" ".repeat(folder.path.split("/").length - 1)}${folder.name}` : folder.path;
32
32
  writeLine(door.io.stdout, `${label} (${folder.note_count})`);
33
33
  }
34
+ if (body.coverage)
35
+ writeLine(door.io.stdout, body.coverage);
34
36
  return 0;
35
37
  }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.196");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.198");
25
25
  return 0;
26
26
  }
27
27
 
@@ -6,7 +6,7 @@
6
6
  * Text mode: a cache hit prints INSTANTLY (its age travels with it), then a
7
7
  * background refresh is spawned so the next call is live — never blocking
8
8
  * this process on a network round trip it already has a recent answer for.
9
- * `--json` (an agent, not a person) trusts cache only under
9
+ * `--json` attempts live first when the cache is older than
10
10
  * `JSON_FRESH_BOUND_MS`, unless `--allow-stale` says otherwise; older than
11
11
  * that runs the live request inline, same as a miss. `--fresh` skips the
12
12
  * read entirely — both flags are process-wide (BLI-10450's `--quiet`
@@ -73,7 +73,7 @@ export async function readThroughCache(options) {
73
73
  else {
74
74
  log(`[read-cache] refresh skipped ${JSON.stringify({ reason: "too_recent", age_ms: ageMs })}`);
75
75
  }
76
- return { ok: true, httpStatus: cached.status, body: cached.body, fromCache: true, ageMs };
76
+ return { ok: true, httpStatus: cached.status, body: cached.body, fromCache: true, ageMs, coverage: cacheAgeLabel(ageMs) };
77
77
  }
78
78
  log(`[read-cache] too stale for --json ${JSON.stringify({ surface: options.surface, label: options.label, age_ms: ageMs })}`);
79
79
  }
@@ -86,6 +86,18 @@ export async function readThroughCache(options) {
86
86
  writeReadCacheEntryAtomic(entryPath, { status: live.httpStatus, body: live.body });
87
87
  log(`[read-cache] refreshed ${JSON.stringify({ surface: options.surface, label: options.label })}`);
88
88
  }
89
+ else if (!fresh && live.httpStatus !== 401 && live.httpStatus !== 403) {
90
+ const cached = readReadCacheEntry(entryPath);
91
+ if (cached) {
92
+ const ageMs = Date.now() - cached.storedAt;
93
+ const coverage = `served stale: ${cacheAgeLabel(ageMs)}, live failed: ${live.reason}`;
94
+ log(`[read-cache] ${coverage} ${JSON.stringify({ reason: live.reason, age_ms: ageMs })}`);
95
+ const body = cached.body && typeof cached.body === "object" && !Array.isArray(cached.body)
96
+ ? { ...cached.body, cache_coverage: coverage, cache_age_ms: ageMs, live_failure_reason: live.reason }
97
+ : cached.body;
98
+ return { ok: true, httpStatus: cached.status, body, fromCache: true, ageMs, coverage };
99
+ }
100
+ }
89
101
  return { ...live, fromCache: false, ageMs: null };
90
102
  }
91
103
  /**
@@ -112,4 +124,8 @@ export function spawnBackgroundRefresh(io, log, spawnImpl = spawn) {
112
124
  catch (error) {
113
125
  log(`[read-cache] background refresh failed to spawn ${JSON.stringify({ reason: error instanceof Error ? error.name : "unknown" })}`);
114
126
  }
127
+ }
128
+ /** JSON readers carry the same age sentence as the terminal. */
129
+ export function cacheCoverage(answer) {
130
+ return answer.fromCache ? { fromCache: true, ageMs: answer.ageMs, cache_coverage: answer.coverage ?? cacheAgeLabel(answer.ageMs ?? 0) } : {};
115
131
  }
@@ -20,7 +20,7 @@
20
20
  * being written to stdout, stderr, or an argument vector.
21
21
  */
22
22
  import { isInteractiveStdin, readLine, settleYesNo, writeLine } from "./cli-io.js";
23
- import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
23
+ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
24
24
  import { asList, asRecord, callTower, isForbidden, openTower, parseModelKey, readAllStdin, writeCommandFailure, } from "./tower-command.js";
25
25
  import { showOverview } from "./settings-overview.js";
26
26
  import { renderCliFloor, renderEnvBlobs, renderModelRouting, renderPersonal, renderSwitches, } from "./settings-render.js";
@@ -75,7 +75,7 @@ async function showPersonal(command, tower, io) {
75
75
  if (result.fromCache && !command.json) {
76
76
  writeLine(io.stderr, `(${cacheAgeLabel(result.ageMs ?? 0)}, refreshing in the background)`);
77
77
  }
78
- return writeBody(io, command.json, result.body, () => renderPersonal(asRecord(result.body)));
78
+ return writeBody(io, command.json, { ...asRecord(result.body), ...cacheCoverage(result) }, () => renderPersonal(asRecord(result.body)));
79
79
  }
80
80
  async function setPersonal(command, tower, io) {
81
81
  const result = await callTower(tower, {
@@ -10,6 +10,8 @@
10
10
  * gets ONE command or ONE click, never a paragraph and never "see the docs".
11
11
  */
12
12
  import { setupPieceMark, setupReceiptLine, setupReceiptPieces, } from "@bli-cockpit/telemetry-core";
13
+ /** Cached host facts are old after the same seven-day window as their readers. */
14
+ const OLD_READING_MS = 7 * 24 * 60 * 60 * 1000;
13
15
  /** The one fix for each piece, by key. Present for every key the block prints. */
14
16
  const FIXES = {
15
17
  browser: "Run `cockpit login` and open the link it prints.",
@@ -57,8 +59,18 @@ export function setupReceiptBlock(reading, options = {}) {
57
59
  continue;
58
60
  lines.push(`${indent} ${label} ${setupPieceMark(piece.status)}${reasonSuffix(piece)} — ${fixFor(key, piece)}`);
59
61
  }
60
- if (options.showCheckedAt) {
61
- lines.push(`${indent}Last read: ${receipt.checked_at}`);
62
+ const now = reading.readAt ? Date.parse(reading.readAt) : Date.now();
63
+ const ageMs = now - Date.parse(receipt.checked_at);
64
+ if (options.showCheckedAt || reading.refreshFailure || !Number.isFinite(ageMs) || ageMs > OLD_READING_MS) {
65
+ const age = Number.isFinite(ageMs) ? `${Math.max(0, Math.floor(ageMs / 3_600_000))}h ago` : "age unavailable";
66
+ lines.push(`${indent}As of ${receipt.checked_at} (${age})${reading.refreshFailure ? `; this run could not re-read the machine: ${reading.refreshFailure}` : ""}`);
67
+ }
68
+ if (memory) {
69
+ const memoryAgeMs = now - Date.parse(memory.checked_at);
70
+ if (!Number.isFinite(memoryAgeMs) || memoryAgeMs > OLD_READING_MS) {
71
+ const age = Number.isFinite(memoryAgeMs) ? `${Math.max(0, Math.floor(memoryAgeMs / 3_600_000))}h ago` : "age unavailable";
72
+ lines.push(`${indent}Agent hosts as of ${memory.checked_at} (${age})`);
73
+ }
62
74
  }
63
75
  return lines;
64
76
  }
@@ -39,7 +39,7 @@
39
39
  * owns the target vocabulary this translates from.
40
40
  */
41
41
  import os from "node:os";
42
- import { SETUP_RECEIPT_SCHEMA_VERSION, setupReceiptGaps, setupReceiptLine, } from "@bli-cockpit/telemetry-core";
42
+ import { SETUP_RECEIPT_SCHEMA_VERSION, SetupReceiptSchema, setupReceiptGaps, setupReceiptLine, } from "@bli-cockpit/telemetry-core";
43
43
  import { readHeartbeatMemoryReceipt } from "./heartbeat.js";
44
44
  import { autostartSetupPiece, readAutostartRegistration, } from "./autostart-reading.js";
45
45
  import { readLocalSessionReference } from "../local-state.js";
@@ -89,7 +89,7 @@ export async function buildSetupReceipt(io, options = {}) {
89
89
  gaps,
90
90
  memory_receipt: memory ? "read" : "absent",
91
91
  }));
92
- return { receipt, memory };
92
+ return { receipt, memory, readAt: checkedAt };
93
93
  }
94
94
  function withDefaultProbes(io, homeDir, options) {
95
95
  return {
@@ -167,8 +167,8 @@ export async function cacheSetupReceipt(receipt, homeDir) {
167
167
  }
168
168
  /**
169
169
  * The cached receipt, or null with a named reason. A receipt older than
170
- * `SETUP_RECEIPT_MAX_AGE_MS` is refused rather than sent: a stale word is
171
- * worse than an absent one, because absence asks and staleness answers.
170
+ * `SETUP_RECEIPT_MAX_AGE_MS` is logged as stale and sent with its original
171
+ * checked_at; readers show its age rather than discarding the last reading.
172
172
  */
173
173
  export async function readCachedSetupReceipt(homeDir, now = new Date()) {
174
174
  let raw;
@@ -187,8 +187,7 @@ export async function readCachedSetupReceipt(homeDir, now = new Date()) {
187
187
  }
188
188
  const age = now.getTime() - Date.parse(parsed.checked_at);
189
189
  if (!Number.isFinite(age) || age > SETUP_RECEIPT_MAX_AGE_MS) {
190
- console.error(`${TAG} cache too old to send`, JSON.stringify({ reason: "cache_stale", age_days: Math.round(age / 86_400_000) }));
191
- return null;
190
+ console.error(`${TAG} serving old cache`, JSON.stringify({ reason: "cache_stale", age_days: Math.round(age / 86_400_000) }));
192
191
  }
193
192
  return parsed;
194
193
  }
@@ -196,7 +195,8 @@ function safeParseReceipt(raw) {
196
195
  const record = raw;
197
196
  if (!record || record.schema_version !== SETUP_RECEIPT_SCHEMA_VERSION)
198
197
  return null;
199
- return raw;
198
+ const parsed = SetupReceiptSchema.safeParse(raw);
199
+ return parsed.success ? parsed.data : null;
200
200
  }
201
201
  /** The one line, plus the one fix per gap. Rendering lives in `setup-receipt-lines.ts`. */
202
202
  export { setupReceiptLine };
@@ -212,11 +212,23 @@ export { setupReceiptLine };
212
212
  export async function refreshSetupReceipt(io, options = {}) {
213
213
  try {
214
214
  const reading = await buildSetupReceipt(io, options);
215
+ const probeFailed = [reading.receipt.browser, reading.receipt.device, reading.receipt.collector.autostart].some(piece => piece.reason?.endsWith("probe_failed"));
216
+ if (probeFailed) {
217
+ const cached = await readCachedSetupReceipt(options.homeDir, options.now);
218
+ if (cached) {
219
+ console.error(`${TAG} could not be computed`, JSON.stringify({ reason: "receipt_build_failed" }));
220
+ return { receipt: cached, memory: reading.memory, readAt: reading.readAt, refreshFailure: "receipt_build_failed" };
221
+ }
222
+ }
215
223
  await cacheSetupReceipt(reading.receipt, options.homeDir);
216
224
  return reading;
217
225
  }
218
226
  catch (error) {
219
227
  console.error(`${TAG} could not be computed`, JSON.stringify({ reason: "receipt_build_failed", error_name: errorName(error) }));
220
- return null;
228
+ const receipt = await readCachedSetupReceipt(options.homeDir, options.now);
229
+ if (!receipt)
230
+ return null;
231
+ const memory = await readHeartbeatMemoryReceipt({ homeDir: options.homeDir, now: options.now });
232
+ return { receipt, memory: memory.receipt, readAt: (options.now ?? new Date()).toISOString(), refreshFailure: "receipt_build_failed" };
221
233
  }
222
234
  }
@@ -15,11 +15,16 @@ export async function runSetup(command, io) {
15
15
  if (door.json)
16
16
  return emitAgentDoor(door, answer.body);
17
17
  const body = answer.body;
18
- const number = (value) => value === null ? "unknown" : value.toLocaleString(undefined, { maximumFractionDigits: 1 });
18
+ const number = (value) => value === null ? "n/a" : value.toLocaleString(undefined, { maximumFractionDigits: 1 });
19
+ // BLI-13066: the server writes what each number rests on (the share of sessions counted, the sessions judged), so the CLI, the web page and JARVIS say the same words. An older server sends none.
20
+ const coverage = (words) => words ? ` (${words})` : "";
19
21
  for (const person of body.people ?? []) {
20
- writeLine(io.stdout, `${person.rank ? `#${person.rank} ` : "Unranked · "}${person.display_name ?? person.email ?? "Unknown person"}: ${number(person.prs_per_week)} merged PRs/week · ${number(person.tokens_per_pr)} tokens/merged PR · ${person.yield === null ? "unknown yield" : `${number(person.yield * 100)}% session yield`}`);
22
+ writeLine(io.stdout, `${person.rank ? `#${person.rank} ` : "Unranked · "}${person.display_name ?? person.email ?? "Unknown person"}: ${number(person.prs_per_week)} merged PRs/week · ${number(person.tokens_per_pr)} tokens/merged PR${coverage(person.coverage_words?.tokens)} · ${person.yield === null ? "no session judged yet" : `${number(person.yield * 100)}% session yield`}${coverage(person.coverage_words?.yield)}`);
21
23
  if (person.tokens_basis)
22
24
  writeLine(io.stdout, ` Token basis: ${person.tokens_basis === "total_fallback" ? "total (input/cache breakdown unavailable)" : "input + output, excluding cache reads"}`);
25
+ // What they really ran and used over the window (BLI-13067); a read that did not answer says so.
26
+ writeLine(io.stdout, ` Tokens by model: ${person.model_use?.words ?? "not available right now."}`);
27
+ writeLine(io.stdout, ` Skills used: ${person.skill_use?.words ?? "not available right now."}`);
23
28
  if (!person.machines.length)
24
29
  writeLine(io.stdout, " No setup received.");
25
30
  for (const machine of person.machines) {
@@ -39,7 +44,12 @@ export async function runSetup(command, io) {
39
44
  writeLine(io.stdout, `${pair.first ?? "Unknown person"} / ${pair.second ?? "Unknown person"}`);
40
45
  for (const difference of command.details ? [...pair.differences, ...(pair.details ?? [])] : pair.differences)
41
46
  writeLine(io.stdout, ` ${difference}`);
47
+ for (const side of ["first", "second"])
48
+ if (pair.use?.[side])
49
+ writeLine(io.stdout, ` ${side === "first" ? pair.first : pair.second}: tokens by model: ${pair.use[side].models ?? "not available right now."} Skills used: ${pair.use[side].skills ?? "not available right now."}`);
42
50
  }
51
+ for (const line of body.read_coverage ?? [])
52
+ writeLine(io.stdout, line);
43
53
  if (body.note)
44
54
  writeLine(io.stdout, body.note);
45
55
  return 0;
@@ -1,11 +1,5 @@
1
1
  /**
2
- * `cockpit status` — what this machine believes about itself: install, pairing,
3
- * active work, upload state, retry backlog, and how far historical backfill
4
- * has got.
5
2
  *
6
- * Read-only. Split out of commands/local.ts (BLI-3104). BLI-3194 rewrote the
7
- * human block into plain English, because these lines are what an intern
8
- * pastes into Slack when something looks wrong. The `--json` payload is the
9
3
  * machine contract and is byte-for-byte unchanged — `status-json-shape.test.ts`
10
4
  * holds it to that.
11
5
  *
@@ -131,7 +125,7 @@ function writeSingleRepoLines(io, status, backfillCursor, setupReceipt, disk) {
131
125
  if (status.linear_ticket_id)
132
126
  writeLine(io.stdout, `Linear: ${status.linear_ticket_id}`);
133
127
  writeLine(io.stdout, `Work: ${displayWorkLabel(status)}`);
134
- writeLine(io.stdout, `Last collected: ${lastCollectedLine(status.collector_freshness)}`);
128
+ writeLine(io.stdout, `Last collected: ${lastCollectedLine(status.collector_freshness, status.last_collected_at)}`);
135
129
  writeLine(io.stdout, `Version: ${status.collector_version}`);
136
130
  writeLine(io.stdout, `Uploads: ${uploadsLine(status.upload_state)}`);
137
131
  writeLine(io.stdout, `Last upload tried: ${status.last_upload_attempt_at ?? "never"}`);
@@ -239,7 +233,14 @@ function signedInLine(sessionState) {
239
233
  return "cannot tell";
240
234
  }
241
235
  }
242
- function lastCollectedLine(freshness) {
236
+ export function lastCollectedLine(freshness, timestamp) {
237
+ if (timestamp) {
238
+ const ageMs = Date.now() - Date.parse(timestamp);
239
+ const minutes = Math.max(0, Math.floor(ageMs / 60_000));
240
+ const age = minutes < 60 ? `${minutes}m` : minutes < 1440 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 1440)}d`;
241
+ const label = lastCollectedLine(freshness);
242
+ return `${label} (${timestamp}; ${age} ago)`;
243
+ }
243
244
  switch (freshness) {
244
245
  case "fresh":
245
246
  return "recently";
@@ -41,6 +41,7 @@ export function githubWords(github, people) {
41
41
  `GitHub: ${noun(sum("pull_requests_opened"), "pull request")} opened, ${sum("pull_requests_merged")} merged, ${noun(sum("commits"), "commit")}, ` +
42
42
  `from ${noun((github.repositories ?? []).length, "mirrored repository", "mirrored repositories")}${range}.`,
43
43
  ];
44
+ lines.push(...(github.read_coverage ?? []));
44
45
  const unmatched = github.unattributed;
45
46
  if (unmatched && unmatched.pull_requests_opened + unmatched.pull_requests_merged + unmatched.commits > 0) {
46
47
  lines.push(`Not matched to a person: ${noun(unmatched.pull_requests_opened, "pull request")} opened, ${unmatched.pull_requests_merged} merged, ${noun(unmatched.commits, "commit")}.`);
@@ -0,0 +1,116 @@
1
+ import { findTomlTableSpan, upsertTomlTable } from "./memory-install-toml.js";
2
+ const render = (value) => typeof value === "string" ? value : `{ ${Object.entries(value).map(([k, v]) => `${k} = ${render(v)}`).join(", ")} }`;
3
+ /** Split inline TOML at commas outside strings/nested arrays/tables. Keep unknown values verbatim. */
4
+ function splitEntries(text) {
5
+ const entries = [];
6
+ let start = 0, depth = 0, quote = "", escaped = false;
7
+ for (let i = 0; i < text.length; i++) {
8
+ const c = text[i];
9
+ if (quote) {
10
+ if (escaped)
11
+ escaped = false;
12
+ else if (c === "\\" && quote === '"')
13
+ escaped = true;
14
+ else if (c === quote)
15
+ quote = "";
16
+ }
17
+ else if (c === '"' || c === "'")
18
+ quote = c;
19
+ else if (c === "{" || c === "[")
20
+ depth++;
21
+ else if (c === "}" || c === "]")
22
+ depth--;
23
+ else if (c === "," && depth === 0) {
24
+ entries.push(text.slice(start, i).trim());
25
+ start = i + 1;
26
+ }
27
+ }
28
+ if (quote || depth !== 0)
29
+ throw new Error("config_unreadable");
30
+ entries.push(text.slice(start).trim());
31
+ return entries.filter(Boolean);
32
+ }
33
+ function mergeInline(previous, values) {
34
+ const raw = previous.trim();
35
+ if (!raw.startsWith("{"))
36
+ return render(values);
37
+ if (!raw.endsWith("}"))
38
+ throw new Error("config_unreadable");
39
+ const remaining = { ...values };
40
+ const entries = splitEntries(raw.slice(1, -1)).map(entry => {
41
+ const match = /^\s*([\w-]+|"[^"\n]+"|'[^'\n]+')\s*=\s*([\s\S]*)$/.exec(entry);
42
+ if (!match)
43
+ throw new Error("config_unreadable");
44
+ const key = match[1].replace(/^["']|["']$/g, "");
45
+ const value = remaining[key];
46
+ if (value === undefined)
47
+ return entry;
48
+ delete remaining[key];
49
+ return `${match[1]} = ${typeof value === "string" ? value : mergeInline(match[2], value)}`;
50
+ });
51
+ for (const [key, value] of Object.entries(remaining))
52
+ entries.push(`${key} = ${render(value)}`);
53
+ return `{ ${entries.join(", ")} }`;
54
+ }
55
+ /** Update only managed assignments; recurse through existing explicit subtables. */
56
+ export function mergeUsageToml(text, tablePath, values) {
57
+ let next = text;
58
+ const local = {};
59
+ for (const [key, value] of Object.entries(values)) {
60
+ const childPath = [...tablePath, key].join(".");
61
+ const childExists = next.split("\n").some(line => {
62
+ const header = /^\s*\[([^\]]+)\]/.exec(line)?.[1].replace(/["']/g, "").replace(/\s/g, "");
63
+ return header === childPath || header?.startsWith(childPath + ".");
64
+ });
65
+ if (typeof value !== "string" && childExists)
66
+ next = mergeUsageToml(next, [...tablePath, key], value);
67
+ else
68
+ local[key] = value;
69
+ }
70
+ const span = findTomlTableSpan(next, tablePath);
71
+ if (!span)
72
+ return Object.keys(local).length ? upsertTomlTable(next, tablePath, `[${tablePath.join(".")}]\n${Object.entries(local).map(([k, v]) => `${k} = ${render(v)}`).join("\n")}`) : next;
73
+ const lines = next.split("\n");
74
+ const remaining = { ...local };
75
+ const body = lines.slice(span.start_line + 1, span.end_line).map(line => {
76
+ const match = /^(\s*)([\w-]+|"[^"\n]+"|'[^'\n]+')\s*=\s*(.*)$/.exec(line);
77
+ if (!match)
78
+ return line;
79
+ const key = match[2].replace(/^["']|["']$/g, "");
80
+ const value = remaining[key];
81
+ if (value === undefined)
82
+ return line;
83
+ delete remaining[key];
84
+ // Strip a trailing comment only outside a string or inline table.
85
+ const raw = match[3];
86
+ let quote = "", depth = 0, comment = raw.length, escaped = false;
87
+ for (let i = 0; i < raw.length; i++) {
88
+ const c = raw[i];
89
+ if (quote) {
90
+ if (escaped)
91
+ escaped = false;
92
+ else if (c === "\\" && quote === '"')
93
+ escaped = true;
94
+ else if (c === quote)
95
+ quote = "";
96
+ }
97
+ else if (c === '"' || c === "'")
98
+ quote = c;
99
+ else if (c === "{" || c === "[")
100
+ depth++;
101
+ else if (c === "}" || c === "]")
102
+ depth--;
103
+ else if (c === "#" && depth === 0) {
104
+ comment = i;
105
+ break;
106
+ }
107
+ }
108
+ if (quote || depth !== 0)
109
+ throw new Error("config_unreadable");
110
+ const updated = typeof value === "string" ? value : mergeInline(raw.slice(0, comment), value);
111
+ return `${match[1]}${match[2]} = ${updated}${comment < raw.length ? ` ${raw.slice(comment)}` : ""}`;
112
+ });
113
+ for (const [key, value] of Object.entries(remaining))
114
+ body.push(`${key} = ${render(value)}`);
115
+ return [...lines.slice(0, span.start_line + 1), ...body, ...lines.slice(span.end_line)].join("\n");
116
+ }
@@ -116,6 +116,8 @@ export async function runUsage(command, io) {
116
116
  // date it cannot know.
117
117
  writeLine(io.stdout, "");
118
118
  writeLine(io.stdout, body.api_list_price_equivalent_label ?? "API list-price equivalent, not actual spend (this dashboard sent no price-sheet version)");
119
+ for (const line of body.read_coverage ?? [])
120
+ writeLine(io.stdout, line);
119
121
  writeLine(io.stdout, `${body.coverage?.sessions_extracted ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions extracted`);
120
122
  // BLI-4356: the gap says why. A session observed with no tokens is either
121
123
  // waiting on a count that has not run yet, or an automated one this view
@@ -43,6 +43,10 @@ export async function inspectLocalCollectorStatus(options = {}) {
43
43
  worktree_fingerprint: context?.worktree_fingerprint ?? identity.worktree_fingerprint,
44
44
  worktree_is_primary: context?.worktree_is_primary ?? identity.worktree_is_primary,
45
45
  collector_freshness: freshness,
46
+ last_collected_at: (() => {
47
+ const timestamp = context ? latestTimestamp([context.updated_at ?? context.started_at, uploadSpool.last_upload_success_at]) : null;
48
+ return timestamp === null ? null : new Date(timestamp).toISOString();
49
+ })(),
46
50
  upload_state: uploadState,
47
51
  last_upload_attempt_at: uploadSpool.last_upload_attempt_at,
48
52
  last_upload_success_at: uploadSpool.last_upload_success_at,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.196",
3
+ "version": "0.2.198",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.59",
31
- "@bli-cockpit/mcp": "0.1.108",
32
- "@bli-cockpit/telemetry-core": "0.1.68"
30
+ "@bli-cockpit/memory-mcp": "0.1.60",
31
+ "@bli-cockpit/mcp": "0.1.110",
32
+ "@bli-cockpit/telemetry-core": "0.1.69"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",