@bli-cockpit/cli 0.2.100 → 0.2.102

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.
@@ -141,13 +141,14 @@ export function cockpitAgentRulesBlock(options = {}) {
141
141
  ? `- Only applies when the current working directory is inside the Tower-onboarded workspace/repo: \`${scopePaths[0]}\`. Outside that folder, do not run Tower ticket binding or sync commands for private chats or unrelated repos.`
142
142
  : scopePaths.length > 1
143
143
  ? `- Only applies when the current working directory is inside one of these Tower-onboarded workspace roots: ${scopePaths.map((scopePath) => `\`${scopePath}\``).join(", ")}. Outside those folders, do not run Tower ticket binding or sync commands for private chats or unrelated repos.`
144
- : "- Only applies when the current working directory is inside the workspace/repo that ran `cockpit onboard` or `cockpit agent-rules install`. Outside that folder, do not run Tower ticket binding or sync commands for private chats or unrelated repos.";
144
+ : "- Only applies when the current working directory is inside the workspace/repo that ran `cockpit doctor` or `cockpit agent-rules install`. Outside that folder, do not run Tower ticket binding or sync commands for private chats or unrelated repos.";
145
145
  return [
146
146
  MANAGED_BLOCK_START,
147
147
  "## Tower Ticket Binding",
148
148
  "",
149
149
  scopeLine,
150
150
  "- Ticketed work (implement / debug / review / PR / ship): run `cockpit start --ticket <ticket-id> --workspace \"$PWD\"` before the first code edit (the flag is `--ticket`). No ticket ID visible → search Linear first; none exists and the work is ticket-worthy → create a narrow Linear ticket, then bind.",
151
+ "- If setup or collection needs repair, run `cockpit doctor`. It repairs this machine and prints any remaining action.",
151
152
  "- Truly no ticket → say the session stays in general ambient capture; never invent one.",
152
153
  "- After the first meaningful checkpoint, run `cockpit sync --workspace \"$PWD\" --json`.",
153
154
  "",
@@ -54,7 +54,7 @@ export async function inspectBackfillLock(paths, now = new Date()) {
54
54
  now.getTime() - record.heartbeat_ms > BACKFILL_LOCK_STALE_TAKEOVER_MS) {
55
55
  return { held: false, held_since: null };
56
56
  }
57
- return { held: true, held_since: record.heartbeat_at };
57
+ return { held: true, held_since: record.heartbeat_at, pid: record.pid };
58
58
  }
59
59
  export function backfillLockPath(paths) {
60
60
  return path.join(paths.cursors_dir, BACKFILL_LOCK_FILENAME);
@@ -1,5 +1,7 @@
1
+ import { sendCollectorHeartbeatBestEffort } from "./heartbeat.js";
2
+ import { isInteractiveDoctorFix } from "./doctor-report.js";
1
3
  import { describeError } from "../health-detail.js";
2
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
4
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, toSessionReference, } from "../local-state.js";
3
5
  import { normalizeCollectionRoots } from "../root-normalization.js";
4
6
  import { detectSecondCockpitInstall } from "../second-install.js";
5
7
  import { hardStop, needsFix, ok, skipped } from "./doctor-report.js";
@@ -12,6 +14,21 @@ import { hardStop, needsFix, ok, skipped } from "./doctor-report.js";
12
14
  * operator decide.
13
15
  */
14
16
  export async function fixAuthState(context, state) {
17
+ const renewed = await sendCollectorHeartbeatBestEffort({
18
+ homeDir: context.command.homeDir,
19
+ dashboardUrl: context.command.dashboardUrl,
20
+ roots: await doctorRoots(context),
21
+ facts: { status: "skipped", reason: "doctor_auth_renewal" },
22
+ io: context.io,
23
+ });
24
+ if (renewed) {
25
+ context.authVerified = true;
26
+ const checked = await context.deps.readAuth(context);
27
+ if (checked.status === "ok")
28
+ return checked;
29
+ }
30
+ if (!isInteractiveDoctorFix(context))
31
+ return { ...state, nextAction: "cockpit login" };
15
32
  const code = await context.deps.runLogin(context).catch((error) => {
16
33
  // Exit code 1 with no reason at all is what an operator saw when doctor
17
34
  // tried and failed to repair their auth — the same output as a login that
@@ -24,6 +41,7 @@ export async function fixAuthState(context, state) {
24
41
  });
25
42
  if (code !== 0)
26
43
  return state;
44
+ context.authVerified = true;
27
45
  const checked = await context.deps.readAuth(context);
28
46
  return checked.status === "ok" ? checked : state;
29
47
  }
@@ -42,11 +60,14 @@ export async function fixRootState(context, state) {
42
60
  return checked.status === "ok" ? checked : state;
43
61
  }
44
62
  export async function readAuthState(context) {
45
- const paths = getCollectorRuntimePaths();
63
+ const paths = getCollectorRuntimePaths(context.command.homeDir);
46
64
  const session = await readLocalCollectorSessionFile(paths).catch(() => null);
47
- if (session?.session_state === "valid" &&
65
+ if (session && toSessionReference(session).session_state === "valid" &&
48
66
  typeof session.device_token === "string" &&
49
67
  session.device_token) {
68
+ if (!context.command.checkOnly && !context.command.dryRun && !context.authVerified) {
69
+ return needsFix("authed", "token_validation_required", "checking that the saved device token is accepted");
70
+ }
50
71
  return ok("authed", "device_token_present", "this machine is signed in");
51
72
  }
52
73
  return hardStop("authed", "pairing_required", [
@@ -58,10 +79,11 @@ export async function readAuthState(context) {
58
79
  ].join("\n"));
59
80
  }
60
81
  export async function readRootState(context) {
61
- const paths = getCollectorRuntimePaths();
82
+ const paths = getCollectorRuntimePaths(context.command.homeDir);
62
83
  const config = await readLocalCollectorConfig(paths).catch(() => null);
63
84
  const roots = normalizeCollectionRoots(config?.default_repo_paths ?? []);
64
- if (roots.length > 0) {
85
+ const requested = normalizeCollectionRoots(context.command.collectionRoots ?? (context.command.repoRoot ? [context.command.repoRoot] : []));
86
+ if (roots.length > 0 && requested.every((root) => roots.includes(root))) {
65
87
  return {
66
88
  ...ok("roots-ok", "saved_roots_present", `saved roots: ${roots.join(", ")}`),
67
89
  roots,
@@ -72,7 +94,7 @@ export async function readRootState(context) {
72
94
  "What you can do:",
73
95
  ` 1) Run \`${onboardOneLiner(context.command)}\` to save the workspace roots again.`,
74
96
  " 2) If this is the wrong folder, rerun from the BLI workspace or pass `--workspace <path>`.",
75
- " 3) There is no `--repair` flag; the onboard-rerun is the repair path.",
97
+ " 3) Run `cockpit doctor` interactively to choose approved roots.",
76
98
  ].join("\n"));
77
99
  }
78
100
  /**
@@ -107,12 +129,14 @@ export async function checkSingleInstallState(context) {
107
129
  ].join("\n"));
108
130
  }
109
131
  export async function doctorRoots(context) {
132
+ if (context.command.collectionRoots?.length)
133
+ return context.command.collectionRoots;
110
134
  if (context.command.repoRoot)
111
135
  return [context.command.repoRoot];
112
- return savedRoots();
136
+ return savedRoots(context.command.homeDir);
113
137
  }
114
- export async function savedRoots() {
115
- const config = await readLocalCollectorConfig(getCollectorRuntimePaths()).catch(() => null);
138
+ export async function savedRoots(homeDir) {
139
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
116
140
  return normalizeCollectionRoots(config?.default_repo_paths ?? []);
117
141
  }
118
142
  function onboardOneLiner(command) {
@@ -120,7 +144,7 @@ function onboardOneLiner(command) {
120
144
  const dashboard = command.dashboardUrl === DEFAULT_DASHBOARD_URL
121
145
  ? ""
122
146
  : ` --dashboard-url ${shellQuote(command.dashboardUrl)}`;
123
- return `cockpit onboard --workspace ${shellQuote(workspace)}${dashboard}`;
147
+ return `cockpit doctor --workspace ${shellQuote(workspace)}${dashboard}`;
124
148
  }
125
149
  function shellQuote(value) {
126
150
  if (value === "$PWD")
@@ -0,0 +1,46 @@
1
+ import { inspectBackfillLock } from "../backfill-lock.js";
2
+ import { getCollectorRuntimePaths } from "../local-state.js";
3
+ import { inspectSyncLock } from "../sync-lock.js";
4
+ const BUSY = new Set(["sync_already_running", "backfill_already_running", "live_sync_paused_during_backfill"]);
5
+ /** Retry the operation itself: its existing exclusive lock acquisition owns the
6
+ * handoff. Merely observing a free lock never grants permission to collect. */
7
+ export async function withDoctorLockWait(context, run, timing = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
8
+ const started = timing.now();
9
+ const limit = (context.command.lockWaitSeconds ?? 600) * 1000;
10
+ let nextProgress = 0;
11
+ let row = await run();
12
+ while (BUSY.has(row.code)) {
13
+ const paths = getCollectorRuntimePaths(context.command.homeDir);
14
+ const sync = await inspectSyncLock(paths);
15
+ const backfill = await inspectBackfillLock(paths);
16
+ const owner = backfill.held
17
+ ? { pid: backfill.pid, heartbeat_at: backfill.held_since }
18
+ : sync;
19
+ const elapsed = timing.now() - started;
20
+ if (elapsed >= limit) {
21
+ const pid = owner?.pid;
22
+ let name = "unknown process";
23
+ if (pid && pid > 0 && context.io.exec) {
24
+ const result = process.platform === "win32"
25
+ ? await context.io.exec("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"])
26
+ : await context.io.exec("ps", ["-p", String(pid), "-o", "comm="]);
27
+ if (result.code === 0 && result.stdout.trim())
28
+ name = result.stdout.trim().split("\n")[0] ?? name;
29
+ }
30
+ return { ...row, status: "needs_fix", code: "lock_wait_timeout", nextAction: "cockpit doctor --lock-wait 600", message: `Waited ${Math.round(elapsed / 1000)}s for the collection lock; owner pid ${pid ?? "unknown"}, ${name}. Run \`cockpit doctor --lock-wait 600\`.` };
31
+ }
32
+ if (elapsed >= nextProgress) {
33
+ const seconds = Math.floor(elapsed / 1000);
34
+ console.error(`[doctor] ${row.id} waiting`, JSON.stringify({ reason: row.code, owner_pid: owner?.pid ?? null, heartbeat_at: owner?.heartbeat_at ?? null, elapsed_seconds: seconds }));
35
+ context.io.stderr.write(`waiting for the background sync to finish, ${Math.floor(seconds / 60)}m${seconds % 60}s\n`);
36
+ nextProgress = elapsed + 30_000;
37
+ }
38
+ await timing.sleep(Math.min(1000, limit - elapsed));
39
+ // A live heartbeat means there is no point spawning a collector yet.
40
+ const currentSync = await inspectSyncLock(paths);
41
+ const currentBackfill = await inspectBackfillLock(paths);
42
+ if (!currentSync?.held && !currentBackfill.held)
43
+ row = await run();
44
+ }
45
+ return row;
46
+ }
@@ -1,3 +1,4 @@
1
+ import { withDoctorLockWait } from "./doctor-lock-wait.js";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { inspectBackfillLock } from "../backfill-lock.js";
@@ -13,7 +14,7 @@ import { runBackfillCommand } from "./backfill.js";
13
14
  import { diskRowMessage, mib, redeliveryLine } from "./doctor-disk-words.js";
14
15
  import { doctorRoots } from "./doctor-access.js";
15
16
  import { backfillCompletionStepState, backfillFixVerdict, jsonField, parseDoctorBackfillJson, parseDoctorSyncJson, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline-verdicts.js";
16
- import { fail, needsFix, ok, skipped } from "./doctor-report.js";
17
+ import { asRecord, fail, needsFix, ok, skipped } from "./doctor-report.js";
17
18
  /**
18
19
  * The `backfill-complete`, `gc-checked`, `disk-bounded`, and `sync-fresh`
19
20
  * check family: does the collection pipeline itself have everything it
@@ -32,7 +33,7 @@ import { fail, needsFix, ok, skipped } from "./doctor-report.js";
32
33
  */
33
34
  const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
34
35
  export async function checkBackfillState(context) {
35
- const paths = getCollectorRuntimePaths();
36
+ const paths = getCollectorRuntimePaths(context.command.homeDir);
36
37
  const roots = await doctorRoots(context);
37
38
  const marker = await readBackfillCompletionMarker(paths);
38
39
  const covered = backfillCompletionStepState(marker, roots);
@@ -46,35 +47,75 @@ export async function checkBackfillState(context) {
46
47
  return needsFix("backfill-complete", cursor.updated_at ? "partial" : "never_run", "the catch-up over your old sessions has not finished");
47
48
  }
48
49
  export async function fixBackfillState(context) {
49
- const capture = capturedIo(context.io, !context.command.json);
50
- const code = await runBackfillCommand({
51
- repoRoot: context.command.repoRoot,
52
- all: true,
53
- dryRun: false,
54
- yes: true,
55
- json: true,
56
- }, capture.io);
57
- const stdout = capture.stdout();
58
- const output = `${stdout}\n${capture.stderr()}`;
59
- if (code === 0) {
60
- // Re-read the marker this run just wrote instead of hand-rolling a second
61
- // message: `checkBackfillState`'s pure core already knows how to say
62
- // "complete" vs "complete_with_oversized_skips" (BLI-2727), and this way
63
- // the two can never say something different for the same marker.
64
- const recheck = await checkBackfillState(context);
65
- if (recheck.status === "ok")
66
- return recheck;
67
- return ok("backfill-complete", "completed", "ran `cockpit backfill --all --yes`");
50
+ const started = Date.now();
51
+ const deadline = started + (context.command.backfillBudgetSeconds ?? 900) * 1000;
52
+ let caughtUp = 0;
53
+ let remaining = null;
54
+ const unfinished = (code, reason) => ({
55
+ ...needsFix("backfill-complete", code, `caught up on ${caughtUp} old sessions this run, ${remaining ?? "unknown"} still to go; ${reason}; run \`cockpit doctor\` again to continue`),
56
+ nextAction: "cockpit doctor",
57
+ });
58
+ const progress = setInterval(() => {
59
+ const seconds = Math.floor((Date.now() - started) / 1000);
60
+ context.io.stderr.write(`backfill: ${caughtUp} caught up, ${remaining ?? "unknown"} to go, ${Math.floor(seconds / 60)}m${seconds % 60}s\n`);
61
+ console.error("[doctor] backfill-complete progress", JSON.stringify({ caught_up: caughtUp, remaining, elapsed_seconds: seconds }));
62
+ }, 30_000);
63
+ try {
64
+ while (Date.now() < deadline) {
65
+ // Every chunk reacquires the collector's locks. Bound lock waiting by
66
+ // the overall budget too; never abandon an in-flight cursor write.
67
+ const chunkContext = { ...context, command: { ...context.command,
68
+ lockWaitSeconds: Math.min(context.command.lockWaitSeconds ?? 600, (deadline - Date.now()) / 1000),
69
+ } };
70
+ const row = await withDoctorLockWait(chunkContext, async () => {
71
+ if (Date.now() >= deadline)
72
+ return unfinished("backfill_budget_timeout", "backfill time budget reached");
73
+ const capture = capturedIo(context.io, false);
74
+ const code = await runBackfillCommand({
75
+ homeDir: context.command.homeDir, repoRoot: context.command.repoRoot,
76
+ all: true, dryRun: false, yes: true, json: true,
77
+ }, capture.io);
78
+ const parsed = parseDoctorBackfillJson(capture.stdout());
79
+ const counts = asRecord(parsed?.counts);
80
+ const count = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
81
+ caughtUp += count(counts?.["backfilled"]) ?? 0;
82
+ remaining = count(counts?.["remaining"]) ?? remaining;
83
+ if (code === 0 && parsed?.status === "complete") {
84
+ // Reporting totals include permanent caps. Preserve their marker
85
+ // note instead of retrying history the collector says is complete.
86
+ const checked = await checkBackfillState(context);
87
+ return ok("backfill-complete", checked.status === "ok" ? checked.code : "complete", `caught up on ${caughtUp} old sessions this run` + (checked.status === "ok" ? `; ${checked.message}` : ""));
88
+ }
89
+ const verdict = backfillFixVerdict(parsed, jsonField(capture.stderr(), "failure_reason"));
90
+ if (parsed?.status === "partial" &&
91
+ (parsed.failure_reason === "deferred_budget_exhausted" || !parsed.failure_reason)) {
92
+ return needsFix("backfill-complete", "backfill_chunk_pending", "more history remains");
93
+ }
94
+ return { ...verdict, message: typeof parsed?.failure_reason === "string" ? parsed.failure_reason : "backfill failed without a valid completion receipt" };
95
+ });
96
+ if (row.status === "ok") {
97
+ console.error("[doctor] backfill-complete finished", JSON.stringify({ caught_up: caughtUp, remaining, elapsed_seconds: Math.floor((Date.now() - started) / 1000) }));
98
+ return row;
99
+ }
100
+ if (row.code !== "backfill_chunk_pending") {
101
+ return unfinished(row.code === "lock_wait_timeout" && Date.now() >= deadline ? "backfill_budget_timeout" : row.code, row.message);
102
+ }
103
+ }
104
+ return unfinished("backfill_budget_timeout", "backfill time budget reached");
105
+ }
106
+ catch (error) {
107
+ console.error("[doctor] backfill-complete failed", JSON.stringify({ reason: "backfill_threw", error_name: error instanceof Error ? error.name : "unknown" }));
108
+ return unfinished("backfill_threw", "backfill failed");
109
+ }
110
+ finally {
111
+ clearInterval(progress);
68
112
  }
69
- const verdict = backfillFixVerdict(parseDoctorBackfillJson(stdout), jsonField(output, "failure_reason"));
70
- console.error("[cockpit-doctor] catch-up run did not finish", JSON.stringify({ reason: verdict.code, row_status: verdict.status, exit_code: code }));
71
- return verdict;
72
113
  }
73
114
  export async function checkGcState(context) {
74
115
  if (context.io.env["COCKPIT_DISABLE_GC"] === "1") {
75
116
  return skipped("gc-checked", "skipped_disabled", "cleanup is switched off");
76
117
  }
77
- const paths = getCollectorRuntimePaths();
118
+ const paths = getCollectorRuntimePaths(context.command.homeDir);
78
119
  const marker = path.join(paths.state_dir, ".last-raw-evidence-gc");
79
120
  const info = await fs.stat(marker).catch(() => null);
80
121
  if (info && Date.now() - info.mtimeMs < GC_MIN_INTERVAL_MS) {
@@ -83,7 +124,7 @@ export async function checkGcState(context) {
83
124
  return needsFix("gc-checked", "due", "cleanup is due");
84
125
  }
85
126
  export async function fixGcState(context) {
86
- const result = await runRawEvidenceLocalGc(getCollectorRuntimePaths(), context.io.env);
127
+ const result = await runRawEvidenceLocalGc(getCollectorRuntimePaths(context.command.homeDir), context.io.env);
87
128
  if (result.skipped) {
88
129
  return skipped("gc-checked", "skipped_throttled", "cleanup already ran today");
89
130
  }
@@ -190,6 +231,9 @@ export async function checkSyncState(context) {
190
231
  return needsFix("sync-fresh", "per_root_verification_required", `fresh upload proof is required for ${roots.length} saved root${roots.length === 1 ? "" : "s"}`);
191
232
  }
192
233
  export async function fixSyncState(context) {
234
+ return withDoctorLockWait(context, () => runSyncRepair(context));
235
+ }
236
+ async function runSyncRepair(context) {
193
237
  const exec = context.io.exec;
194
238
  if (!exec)
195
239
  return fail("sync-fresh", "runner_unavailable", "sync runner unavailable");
@@ -203,6 +247,8 @@ export async function fixSyncState(context) {
203
247
  const discoveryArgs = await savedDiscoveryLimitArgs(context.command.homeDir);
204
248
  for (const repoRoot of roots) {
205
249
  const args = ["sync", "--json", "--workspace", repoRoot, ...discoveryArgs];
250
+ if (context.command.homeDir)
251
+ args.push("--home", context.command.homeDir);
206
252
  if (context.command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
207
253
  args.push("--dashboard-url", context.command.dashboardUrl);
208
254
  }
@@ -220,12 +266,10 @@ export async function fixSyncState(context) {
220
266
  owner_alive: standAside.ownerAlive,
221
267
  held_since: standAside.heldSince,
222
268
  }));
223
- return standAside.ownerAlive
224
- ? ok("sync-fresh", standAside.code, "a background sync is running right now and owns the collection lock " +
225
- `(last heartbeat ${standAside.heldSince}); this machine is collecting`)
226
- : needsFix("sync-fresh", standAside.code, "sync stood aside for a collection lock whose owner has stopped " +
227
- `reporting (last heartbeat ${standAside.heldSince ?? "unknown"}); ` +
228
- "rerun `cockpit doctor` — the next sync takes the lock over");
269
+ return needsFix("sync-fresh", standAside.code, "waiting for the collection lock");
270
+ }
271
+ if (status === "live_sync_paused_during_backfill") {
272
+ return needsFix("sync-fresh", status, "waiting for historical catch-up");
229
273
  }
230
274
  if (result.code !== 0) {
231
275
  const draining = syncBacklogDrainingVerdict(parsed);
@@ -1,3 +1,5 @@
1
+ import { inspectAgentRules, installAgentRules } from "../agent-rules.js";
2
+ import { doctorRoots } from "./doctor-access.js";
1
3
  import { installAutostartAgent } from "../autostart.js";
2
4
  import { fail, needsFix, ok, skipped } from "./doctor-report.js";
3
5
  import { autostartDoctorStatus, autostartRegistrationRoots, readAutostartRegistration, } from "./autostart-reading.js";
@@ -38,6 +40,8 @@ export async function fixAutostartState(context) {
38
40
  // fix that registers a different boundary than the check inspected would
39
41
  // leave the row red forever (BLI-3793).
40
42
  const roots = await autostartRegistrationRoots(context.command.homeDir, context.command.repoRoot);
43
+ if (roots.length === 0)
44
+ return needsFix("autostart-alive", "no_roots", "Run `cockpit doctor` to choose approved collection roots first.");
41
45
  const result = await installAutostartAgent({
42
46
  ...(roots[0] ? { repoRoot: roots[0] } : {}),
43
47
  repoRoots: roots,
@@ -67,7 +71,13 @@ export async function checkMemoryState(context) {
67
71
  return memoryStepState(outcome, "check");
68
72
  }
69
73
  export async function fixMemoryState(context) {
70
- const outcome = await installMemoryIntegration(memoryCommandFor(context), context.io);
74
+ let outcome = await installMemoryIntegration(memoryCommandFor(context), context.io);
75
+ if (!outcome.bin_found) {
76
+ // The public CLI declares the memory and Tower MCP packages as dependencies.
77
+ // Reinstall through the existing updater to restore missing package files.
78
+ await context.deps.selfUpdate(context.io, { json: true, tag: context.command.updateTag });
79
+ outcome = await installMemoryIntegration(memoryCommandFor(context), context.io);
80
+ }
71
81
  return memoryStepState(outcome, "fix");
72
82
  }
73
83
  function memoryCommandFor(context) {
@@ -95,7 +105,18 @@ function memoryStepState(outcome, phase) {
95
105
  return needsFix("memory-registered", "registration_incomplete", `BLI Memory is not registered with ${pending.map((target) => target.target).join(", ")}`);
96
106
  }
97
107
  if (!outcome.bin_found) {
98
- return skipped("memory-registered", "bin_missing", "bli-memory-mcp is not on this machine yet; nothing was written and the next run will try again");
108
+ return needsFix("memory-registered", "bin_missing", "bli-memory-mcp is not on this machine yet; nothing was written and the next run will try again");
99
109
  }
100
110
  return ok("memory-registered", phase === "fix" ? "installed" : "already_installed", "BLI Memory is registered with both agent hosts");
111
+ }
112
+ export async function checkDoctorAgentRules(context) {
113
+ const result = await inspectAgentRules({ homeDir: context.command.homeDir, scopePaths: await doctorRoots(context) });
114
+ return result.installed ? ok("agent-rules", "installed", "agent rules are installed for both hosts") : needsFix("agent-rules", "missing", "run `cockpit agent-rules install`");
115
+ }
116
+ export async function fixDoctorAgentRules(context) {
117
+ const roots = await doctorRoots(context);
118
+ if (roots.length === 0)
119
+ return needsFix("agent-rules", "no_roots", "Run `cockpit doctor` to choose approved roots first.");
120
+ await installAgentRules({ homeDir: context.command.homeDir, scopePaths: roots });
121
+ return checkDoctorAgentRules(context);
101
122
  }
@@ -1,3 +1,4 @@
1
+ import { setupReceiptPieces } from "@bli-cockpit/telemetry-core";
1
2
  import { redactedHealthDetail } from "../health-detail.js";
2
3
  import { setupReceiptBlock } from "./setup-receipt-lines.js";
3
4
  export function ok(id, code, message) {
@@ -28,9 +29,9 @@ export function isInteractiveDoctorFix(context) {
28
29
  return !context.command.json && Boolean(context.io.stdin.isTTY);
29
30
  }
30
31
  export async function maybeReportDoctorEvents(context, rows) {
31
- if (context.command.dryRun)
32
- return;
33
- await context.deps.reportInstallEvents({
32
+ if (context.command.dryRun || context.command.checkOnly)
33
+ return null;
34
+ return context.deps.reportInstallEvents({
34
35
  dashboardUrl: context.command.dashboardUrl,
35
36
  command: "doctor",
36
37
  events: rows.map(doctorEvent),
@@ -40,14 +41,17 @@ export async function maybeReportDoctorEvents(context, rows) {
40
41
  }
41
42
  export function writeDoctorOutput(command, io, rows,
42
43
  /** BLI-3731. Absent means it could not be read; doctor says so rather than nothing. */
43
- setupReceipt) {
44
+ setupReceipt, repairs = []) {
45
+ const needsPerson = doctorNeedsPerson(rows, setupReceipt);
44
46
  if (command.json) {
45
47
  writeLine(io.stdout, JSON.stringify({
46
- status: rows.some((row) => row.status === "fail" || row.hardStop)
48
+ status: needsPerson.length > 0
47
49
  ? "blocked"
48
50
  : "pass",
49
51
  dry_run: command.dryRun,
50
52
  steps: rows,
53
+ repairs,
54
+ needs_person: needsPerson,
51
55
  setup_receipt: setupReceipt?.receipt ?? null,
52
56
  memory_install: setupReceipt?.memory ?? null,
53
57
  }, null, 2));
@@ -58,13 +62,13 @@ setupReceipt) {
58
62
  // sentence, not the label (BLI-3194).
59
63
  writeLine(io.stdout, "state step result");
60
64
  for (const row of rows) {
61
- writeLine(io.stdout, `${doctorMark(row)} ${row.id.padEnd(20)} ${oneLine(row.message)}`);
65
+ writeLine(io.stdout, `${doctorMark(row)} ${row.id.padEnd(20)} ${oneLine(row.message).replaceAll("—", ";")}`);
62
66
  }
63
67
  const explanations = rows.filter((row) => (row.hardStop || row.status === "fail") && row.message.includes("\n"));
64
68
  for (const row of explanations) {
65
69
  writeLine(io.stderr, "");
66
70
  writeLine(io.stderr, `${row.id}:`);
67
- writeLine(io.stderr, row.message);
71
+ writeLine(io.stderr, row.message.replaceAll("—", ";"));
68
72
  }
69
73
  // BLI-3731. The invariant table says whether collection is healthy; this
70
74
  // says whether the machine is CONNECTED — the browser sign-in, the device
@@ -75,11 +79,14 @@ setupReceipt) {
75
79
  for (const line of setupReceipt
76
80
  ? setupReceiptBlock(setupReceipt, { indent: " " })
77
81
  : [" unknown — this machine could not be read this run."]) {
78
- writeLine(io.stdout, line);
82
+ writeLine(io.stdout, line.replaceAll("—", ";"));
79
83
  }
84
+ writeLine(io.stdout, needsPerson.length === 0 ? "Everything is fixed." : `${needsPerson.length} things still need you:`);
85
+ for (const item of needsPerson)
86
+ writeLine(io.stdout, ` ${item.step}: ${item.action}`);
80
87
  }
81
88
  function doctorEvent(row) {
82
- const status = row.status === "fail" || row.hardStop
89
+ const status = row.status === "fail" || row.status === "needs_fix" || row.hardStop
83
90
  ? "fail"
84
91
  : row.status === "skipped"
85
92
  ? "skipped"
@@ -124,4 +131,36 @@ export function asRecord(value) {
124
131
  }
125
132
  export function writeLine(stream, text) {
126
133
  stream.write(`${text}\n`);
134
+ }
135
+ export function doctorNeedsPerson(rows, receipt) {
136
+ const defaults = {
137
+ "cli-latest": "npm install -g @bli-cockpit/cli@latest",
138
+ "authed": "cockpit login",
139
+ "roots-ok": "cockpit doctor",
140
+ "single-install": "Run npm uninstall -g @bli-cockpit/cli with the Node installation that owns the extra CLI listed above.",
141
+ "autostart-alive": "cockpit autostart install",
142
+ "memory-registered": "cockpit memory install",
143
+ "agent-rules": "cockpit agent-rules install",
144
+ "mcp-answers": "cockpit doctor",
145
+ "memory-daemon": "cockpit memory install",
146
+ "backfill-complete": "cockpit backfill --all --yes",
147
+ "sync-fresh": "cockpit sync --json",
148
+ "gc-checked": "cockpit doctor",
149
+ "disk-bounded": "cockpit clean --all-committed",
150
+ };
151
+ const items = rows.filter((row) => row.status === "fail" || row.status === "needs_fix" || row.hardStop).map((row) => ({
152
+ step: row.id, reason: row.code,
153
+ action: row.nextAction ?? row.message.match(/`([^`]+)`/u)?.[1] ?? defaults[row.id],
154
+ }));
155
+ if (receipt) {
156
+ for (const { key, piece } of setupReceiptPieces(receipt.receipt, receipt.memory)) {
157
+ if (piece.status === "ok" || piece.status === "skipped" || piece.status === "unsupported")
158
+ continue;
159
+ const step = key === "device" || key === "browser" ? "authed" : key === "collector.autostart" ? "autostart-alive" : "memory-registered";
160
+ if (piece.status !== "needs_trust" && items.some((item) => item.step === step))
161
+ continue;
162
+ items.push({ step: key, reason: piece.reason ?? piece.status, action: piece.status === "needs_trust" ? "Open Codex and run /hooks to trust the installed hooks." : defaults[step] });
163
+ }
164
+ }
165
+ return items;
127
166
  }
@@ -1,3 +1,4 @@
1
+ import { isSemverBelow } from "../scheduled-self-update.js";
1
2
  import { DEFAULT_DASHBOARD_URL, LOCAL_COLLECTOR_VERSION } from "../local-state.js";
2
3
  import { createInteractiveExecRunner } from "../process-runner.js";
3
4
  import { asRecord, fail, needsFix, ok } from "./doctor-report.js";
@@ -11,7 +12,7 @@ export async function checkCliLatest(context) {
11
12
  if (!latest) {
12
13
  return needsFix("cli-latest", "latest_version_unknown", `could not confirm npm latest; will run npm install for ${LOCAL_COLLECTOR_VERSION}`);
13
14
  }
14
- if (latest === LOCAL_COLLECTOR_VERSION) {
15
+ if (latest === LOCAL_COLLECTOR_VERSION || (!context.command.updateTag && /^\d+\.\d+\.\d+$/u.test(latest) && !isSemverBelow(LOCAL_COLLECTOR_VERSION, latest))) {
15
16
  return ok("cli-latest", "already_latest", `current ${LOCAL_COLLECTOR_VERSION}`);
16
17
  }
17
18
  return needsFix("cli-latest", "stale_cli", `current ${LOCAL_COLLECTOR_VERSION}; npm latest ${latest}`);
@@ -28,7 +29,7 @@ export async function fixCliLatest(context, state) {
28
29
  }
29
30
  if (context.io.env["COCKPIT_DOCTOR_REEXEC"] === "1") {
30
31
  if (state.code === "stale_cli") {
31
- return fail("cli-latest", "stale_after_self_update", "self-update ran but this process still reports the old CLI version; rerun `cockpit do-everything`.");
32
+ return fail("cli-latest", "stale_after_self_update", "self-update ran but this process still reports the old CLI version; rerun `cockpit doctor`.");
32
33
  }
33
34
  return ok("cli-latest", "updated_reexec_guarded", "self-update ran; re-exec guard already set, continuing.");
34
35
  }
@@ -67,9 +68,21 @@ function parseNpmVersion(stdout) {
67
68
  }
68
69
  }
69
70
  export function reexecDoctor(command, io) {
70
- const args = ["do-everything"];
71
- if (command.repoRoot)
72
- args.push("--workspace", command.repoRoot);
71
+ const args = ["doctor"];
72
+ if (command.homeDir)
73
+ args.push("--home", command.homeDir);
74
+ if (command.backfillBudgetSeconds)
75
+ args.push("--backfill-budget", String(command.backfillBudgetSeconds));
76
+ if (command.lockWaitSeconds)
77
+ args.push("--lock-wait", String(command.lockWaitSeconds));
78
+ if (command.allowHomeRoot)
79
+ args.push("--allow-home-root");
80
+ if (command.maxDepth)
81
+ args.push("--max-depth", String(command.maxDepth));
82
+ if (command.maxRepos)
83
+ args.push("--max-repos", String(command.maxRepos));
84
+ for (const root of command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []))
85
+ args.push("--workspace", root);
73
86
  if (command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
74
87
  args.push("--dashboard-url", command.dashboardUrl);
75
88
  }
@@ -1,11 +1,14 @@
1
+ import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
2
+ import { isSemverBelow } from "../scheduled-self-update.js";
3
+ import { describeError } from "../health-detail.js";
1
4
  import { withStage } from "../crash-guard.js";
2
5
  import { checkSingleInstallState, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
3
6
  import { backfillCompletionStepState, backfillFixVerdict, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline.js";
4
7
  import { checkMcpAnswersState } from "./doctor-mcp.js";
5
8
  import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
6
- import { checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
7
- import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, } from "./doctor-report.js";
8
- import { refreshSetupReceipt } from "./setup-receipt.js";
9
+ import { checkDoctorAgentRules, fixDoctorAgentRules, checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
10
+ import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, doctorNeedsPerson, } from "./doctor-report.js";
11
+ import { buildSetupReceipt, refreshSetupReceipt } from "./setup-receipt.js";
9
12
  import { checkCliLatest, fixCliLatest, latestCliVersionFromNpm, reexecDoctor, } from "./doctor-update.js";
10
13
  export async function runDoctor(command, io, hooks, overrides = {}) {
11
14
  const deps = { ...defaultDoctorDeps(hooks), ...overrides };
@@ -14,58 +17,93 @@ export async function runDoctor(command, io, hooks, overrides = {}) {
14
17
  export async function runDoctorWithDeps(command, io, deps) {
15
18
  const context = { command, io, deps };
16
19
  const rows = [];
20
+ const repairs = [];
21
+ const diagnoseOnly = command.dryRun || command.checkOnly;
22
+ const diagnosed = new Map();
17
23
  for (const invariant of doctorInvariants()) {
18
- const checked = await withStage(`doctor:check:${invariant.id}`, () => invariant.check(context));
19
- if (checked.status === "ok" || checked.status === "skipped") {
20
- rows.push(checked);
21
- continue;
24
+ try {
25
+ diagnosed.set(invariant.id, await withStage(`doctor:check:${invariant.id}`, () => invariant.check(context)));
22
26
  }
23
- if (command.dryRun && invariant.fix) {
24
- rows.push(dryRunPreview(checked));
25
- continue;
27
+ catch (error) {
28
+ console.error(`[doctor] ${invariant.id} diagnosis_failed`, JSON.stringify({ reason: "check_threw", ...describeError(error) }));
29
+ diagnosed.set(invariant.id, { id: invariant.id, status: "fail", code: "check_threw", message: "Run `cockpit doctor` to retry this check." });
26
30
  }
27
- const canFix = Boolean(invariant.fix) &&
28
- (!invariant.requiresInteractiveFix || isInteractiveDoctorFix(context));
29
- if (!canFix || !invariant.fix) {
30
- rows.push(withoutAFix(checked));
31
- if (checked.hardStop)
32
- break;
33
- continue;
31
+ }
32
+ for (const invariant of doctorInvariants()) {
33
+ let row;
34
+ try {
35
+ row = diagnosed.get(invariant.id);
36
+ const needsCollection = ["backfill-complete", "sync-fresh"].includes(invariant.id);
37
+ const prerequisitesReady = !needsCollection || rows.filter((prior) => prior.id === "authed" || prior.id === "roots-ok").every((prior) => prior.status === "ok");
38
+ const canFix = !diagnoseOnly && prerequisitesReady && invariant.fix &&
39
+ (!invariant.requiresInteractiveFix || isInteractiveDoctorFix(context) || Boolean(command.collectionRoots?.length));
40
+ if (row.status !== "ok" && row.status !== "skipped" && canFix && invariant.fix) {
41
+ row = await withStage(`doctor:fix:${invariant.id}`, () => invariant.fix(context, row));
42
+ repairs.push({ step: invariant.id, outcome: row.status === "ok" ? "repaired" : "needs_person", reason: row.code });
43
+ // The replacement process owns the one final receipt, including JSON.
44
+ if (row.reexecExitCode !== undefined)
45
+ return row.reexecExitCode;
46
+ row = { ...row, fixed: row.status === "ok" };
47
+ }
48
+ else {
49
+ repairs.push({ step: invariant.id, outcome: diagnoseOnly ? "check_only" : row.status === "ok" ? "already_ok" : row.status === "skipped" ? "not_needed" : "needs_person", reason: row.code });
50
+ if (command.dryRun && invariant.fix && row.status !== "ok" && row.status !== "skipped")
51
+ row = dryRunPreview(row);
52
+ }
34
53
  }
35
- // BLI-4110: named so a crash inside a repair says WHICH repair. The
36
- // 2026-09-09 incident printed a bare `read ENOTCONN` stack and the only
37
- // way to place it was the log line that happened to precede it.
38
- const fixed = await withStage(`doctor:fix:${invariant.id}`, () => invariant.fix(context, checked));
39
- rows.push({ ...fixed, fixed: fixed.status !== "fail" });
40
- if (fixed.hardStop)
41
- break;
42
- if (fixed.reexecExitCode !== undefined) {
43
- await maybeReportDoctorEvents(context, rows);
44
- writeDoctorOutput(command, io, rows, await deps.readSetupReceipt(context));
45
- return fixed.reexecExitCode;
54
+ catch (error) {
55
+ console.error(`[doctor] ${invariant.id} failed`, JSON.stringify({ reason: "step_threw", ...describeError(error) }));
56
+ row = { id: invariant.id, status: "fail", code: "step_threw", message: "The step could not finish. Run `cockpit doctor`." };
57
+ repairs.push({ step: invariant.id, outcome: "needs_person", reason: row.code });
46
58
  }
59
+ console.error(`[doctor] ${invariant.id} ${row.status}`, JSON.stringify({ reason: row.code, repaired: row.fixed ?? false }));
60
+ rows.push(row);
47
61
  }
48
- await maybeReportDoctorEvents(context, rows);
49
- // Doctor is the surface a person opens when something is wrong, so it
50
- // re-READS the machine rather than quoting a cache — and caching what it
51
- // read is what keeps the 15-minute heartbeat from paying for the probe.
52
- writeDoctorOutput(command, io, rows, await deps.readSetupReceipt(context));
53
- return rows.some((row) => row.status === "fail" || row.hardStop) ? 1 : 0;
54
- }
55
- /**
56
- * What a broken row looks like when nothing can repair it from here.
57
- *
58
- * A check that says `needs_fix` and has no fix is still `needs_fix` — the
59
- * person is being told what to do. A check that says `fail` KEEPS that word
60
- * (BLI-3804): it used to be quietly rewritten to `needs_fix`, which turned the
61
- * run green and, because `doctor-report.ts` maps every non-`fail` row to `ok`
62
- * in the install-event ledger, threw the reason label away on the way to the
63
- * fleet as well. `hardStop` is untouched and still ends the walk.
64
- */
65
- function withoutAFix(checked) {
66
- if (checked.hardStop || checked.status === "fail")
67
- return checked;
68
- return { ...checked, status: "needs_fix" };
62
+ // Re-read host state after repairs. Sync is proven by its upload receipts;
63
+ // re-running its diagnostic would always request a new upload by design.
64
+ if (!diagnoseOnly) {
65
+ for (const invariant of doctorInvariants()) {
66
+ if (["cli-latest", "authed", "roots-ok", "backfill-complete", "sync-fresh", "gc-checked", "disk-bounded"].includes(invariant.id))
67
+ continue;
68
+ const index = rows.findIndex((row) => row.id === invariant.id);
69
+ if (rows[index]?.status === "fail")
70
+ continue;
71
+ try {
72
+ const checked = await invariant.check(context);
73
+ rows[index] = { ...checked, fixed: rows[index]?.fixed && checked.status === "ok" };
74
+ console.error(`[doctor] ${invariant.id} verified`, JSON.stringify({ reason: checked.code, status: checked.status }));
75
+ if (checked.status === "needs_fix" || checked.status === "fail") {
76
+ const repair = repairs.find((entry) => entry.step === invariant.id);
77
+ if (repair) {
78
+ repair.outcome = "needs_person";
79
+ repair.reason = checked.code;
80
+ }
81
+ }
82
+ }
83
+ catch (error) {
84
+ console.error(`[doctor] ${invariant.id} recheck_failed`, JSON.stringify({ reason: "recheck_threw", ...describeError(error) }));
85
+ rows[index] = { id: invariant.id, status: "fail", code: "recheck_threw", message: "Run `cockpit doctor` to retry the host check." };
86
+ }
87
+ }
88
+ }
89
+ const floor = await maybeReportDoctorEvents(context, rows);
90
+ if (floor && isSemverBelow(LOCAL_COLLECTOR_VERSION, floor)) {
91
+ const index = rows.findIndex((row) => row.id === "cli-latest");
92
+ rows[index] = { id: "cli-latest", status: "needs_fix", code: "below_fleet_floor", message: `This process is below the fleet floor ${floor}. Run \`cockpit doctor\` to update.`, nextAction: "cockpit doctor" };
93
+ console.error("[doctor] cli-latest below_fleet_floor", JSON.stringify({ reason: "below_fleet_floor", current_version: LOCAL_COLLECTOR_VERSION, minimum_version: floor }));
94
+ }
95
+ let receipt = null;
96
+ if (!command.dryRun) {
97
+ try {
98
+ receipt = await deps.readSetupReceipt(context);
99
+ }
100
+ catch (error) {
101
+ console.error("[doctor] setup_receipt failed", JSON.stringify({ reason: "receipt_read_threw", ...describeError(error) }));
102
+ rows.push({ id: "memory-registered", status: "needs_fix", code: "receipt_read_threw", message: "Run `cockpit doctor` to read the final setup receipt." });
103
+ }
104
+ }
105
+ writeDoctorOutput(command, io, rows, receipt, repairs);
106
+ return doctorNeedsPerson(rows, receipt).length === 0 ? 0 : 1;
69
107
  }
70
108
  function doctorInvariants() {
71
109
  return [
@@ -74,7 +112,6 @@ function doctorInvariants() {
74
112
  id: "authed",
75
113
  check: (context) => context.deps.readAuth(context),
76
114
  fix: fixAuthState,
77
- requiresInteractiveFix: true,
78
115
  },
79
116
  {
80
117
  id: "roots-ok",
@@ -99,6 +136,7 @@ function doctorInvariants() {
99
136
  check: (context) => context.deps.checkMemory(context),
100
137
  fix: (context, _state) => context.deps.fixMemory(context),
101
138
  },
139
+ { id: "agent-rules", check: (context) => context.deps.checkAgentRules(context), fix: (context) => context.deps.fixAgentRules(context) },
102
140
  // BLI-3804. Right after the registration row, because it asks the second
103
141
  // half of the same question: `memory-registered` proves the entry exists
104
142
  // and names a bin, this one proves the server behind it starts, speaks the
@@ -122,6 +160,11 @@ function doctorInvariants() {
122
160
  check: (context) => context.deps.checkBackfill(context),
123
161
  fix: (context, _state) => context.deps.fixBackfill(context),
124
162
  },
163
+ {
164
+ id: "sync-fresh",
165
+ check: (context) => context.deps.checkSync(context),
166
+ fix: (context, _state) => context.deps.fixSync(context),
167
+ },
125
168
  {
126
169
  id: "gc-checked",
127
170
  check: (context) => context.deps.checkGc(context),
@@ -136,15 +179,12 @@ function doctorInvariants() {
136
179
  check: (context) => context.deps.checkDisk(context),
137
180
  fix: (context, _state) => context.deps.fixDisk(context),
138
181
  },
139
- {
140
- id: "sync-fresh",
141
- check: (context) => context.deps.checkSync(context),
142
- fix: (context, _state) => context.deps.fixSync(context),
143
- },
144
182
  ];
145
183
  }
146
184
  function defaultDoctorDeps(hooks) {
147
185
  return {
186
+ checkAgentRules: checkDoctorAgentRules,
187
+ fixAgentRules: fixDoctorAgentRules,
148
188
  latestCliVersion: latestCliVersionFromNpm,
149
189
  selfUpdate: hooks.selfUpdate,
150
190
  reexecDoctor: reexecDoctor,
@@ -168,7 +208,7 @@ function defaultDoctorDeps(hooks) {
168
208
  fixDisk: fixDiskState,
169
209
  checkSync: checkSyncState,
170
210
  fixSync: fixSyncState,
171
- readSetupReceipt: (context) => refreshSetupReceipt(context.io, {
211
+ readSetupReceipt: (context) => (context.command.checkOnly ? buildSetupReceipt : refreshSetupReceipt)(context.io, {
172
212
  ...(context.command.homeDir ? { homeDir: context.command.homeDir } : {}),
173
213
  ...(context.command.dashboardUrl
174
214
  ? { dashboardUrl: context.command.dashboardUrl }
@@ -98,6 +98,10 @@ export function parseDoctorArgs(alias, args) {
98
98
  "--dashboard-url",
99
99
  "--update-tag",
100
100
  "--dry-run",
101
+ "--check",
102
+ "--no-repair",
103
+ "--lock-wait",
104
+ "--backfill-budget",
101
105
  "--json",
102
106
  "--allow-home-root",
103
107
  "--max-depth",
@@ -113,6 +117,8 @@ export function parseDoctorArgs(alias, args) {
113
117
  "--timeout-ms",
114
118
  ],
115
119
  valueFlags: [
120
+ "--lock-wait",
121
+ "--backfill-budget",
116
122
  "--home",
117
123
  "--repo",
118
124
  "--workspace",
@@ -135,6 +141,9 @@ export function parseDoctorArgs(alias, args) {
135
141
  }
136
142
  return {
137
143
  kind: "doctor",
144
+ checkOnly: values.booleans.has("--check") || values.booleans.has("--no-repair"),
145
+ backfillBudgetSeconds: optionalPositiveInteger(values.flags.get("--backfill-budget"), "--backfill-budget") ?? 900,
146
+ lockWaitSeconds: optionalPositiveInteger(values.flags.get("--lock-wait"), "--lock-wait") ?? 600,
138
147
  alias,
139
148
  homeDir: optionalNonEmpty(values.flags.get("--home")),
140
149
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
@@ -1,8 +1,8 @@
1
1
  import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
2
2
  export function parseUsageArgs(args) {
3
- const values = parseNamedArgs(args, { allowedFlags: ["--since", "--until", "--include-automated", "--home", "--dashboard-url", "--json"], valueFlags: ["--since", "--until", "--home", "--dashboard-url"] });
3
+ const values = parseNamedArgs(args, { allowedFlags: ["--since", "--until", "--include-automated", "--detail", "--home", "--dashboard-url", "--json"], valueFlags: ["--since", "--until", "--home", "--dashboard-url"] });
4
4
  const action = values.positionals[0] ?? "people";
5
5
  if (action !== "people" || values.positionals.length > 1)
6
6
  throw new Error("usage takes one verb: people.");
7
- return { kind: "usage", action: "people", since: optionalNonEmpty(values.flags.get("--since")) ?? "30d", until: optionalNonEmpty(values.flags.get("--until")), includeAutomated: values.booleans.has("--include-automated"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")), json: values.booleans.has("--json") };
7
+ return { kind: "usage", action: "people", since: optionalNonEmpty(values.flags.get("--since")) ?? "30d", until: optionalNonEmpty(values.flags.get("--until")), detail: values.booleans.has("--detail"), includeAutomated: values.booleans.has("--include-automated"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")), json: values.booleans.has("--json") };
8
8
  }
@@ -143,12 +143,13 @@ export const TOWER_COMMAND_HELP = [
143
143
  [
144
144
  "usage",
145
145
  [
146
- "Usage: cockpit usage people [--since 30d|<iso>] [--until <iso>] [--include-automated] [--json]",
146
+ "Usage: cockpit usage people [--since <n>d|<n>h|<iso>] [--detail] [--until <iso>] [--include-automated] [--json]",
147
147
  "",
148
148
  "Claude Code and Codex usage per person: sessions observed and extracted, tokens (total, output,",
149
149
  "and the input / cache split when the row carries it), and an API list-price equivalent that is",
150
150
  "labelled as such and is never actual spend. A super_admin sees everyone; a member sees their own row.",
151
- "--since takes 7d, 30d, 90d or an ISO timestamp; --until an ISO timestamp (default now).",
151
+ "--since takes <n>d (1 to 365), <n>h (1 to 8760), or an ISO timestamp; --until an ISO timestamp (default now).",
152
+ "--detail adds Output, Input, Cache read and Cache creation. Counts use K/M/B; dollars are rounded.",
152
153
  "--include-automated adds harness, subagent and scheduled sessions, which are excluded by default.",
153
154
  "It presses the same door as the /usage page and the usage_people MCP tool (GET /api/usage/people).",
154
155
  "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
@@ -71,30 +71,43 @@ export function localSubcommandHelp(command) {
71
71
  [
72
72
  "do-everything",
73
73
  [
74
- "Usage: cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--update-tag <tag>] [--dry-run] [--json]",
74
+ "Usage: cockpit do-everything [--workspace <path>] [--check | --no-repair] [--lock-wait <seconds>] [--backfill-budget <seconds>] [--json]",
75
75
  "",
76
- "Gets this machine fully set up, whether it is brand new, already set up, or handed down: latest CLI, sign-in and collection-root recovery when needed, saved folders, background sync, catching up on old sessions, cleaning up old files, and one fresh upload.",
77
- "`cockpit fix` is an alias.",
78
- "Maintainers only: use `--update-tag next` so self-update and re-exec stay on the prerelease candidate.",
79
- "--dry-run shows what it would do without changing anything.",
76
+ "Diagnose, repair, and verify this machine. doctor, do-everything, and fix run the same job.",
77
+ "Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
78
+ "--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
79
+ "--backfill-budget defaults to 900 seconds; catch-up repeats chunks until complete, with progress every 30 seconds.",
80
+ "--lock-wait defaults to 600 seconds; progress prints every 30 seconds.",
81
+ "--json includes steps, repairs, and needs_person. Exit 0 means nothing needs you.",
82
+ "Maintainers: --update-tag next keeps self-update on the prerelease candidate.",
80
83
  ],
81
84
  ],
82
85
  [
83
86
  "fix",
84
87
  [
85
- "Usage: cockpit fix [same flags as cockpit do-everything]",
88
+ "Usage: cockpit fix [--workspace <path>] [--check | --no-repair] [--lock-wait <seconds>] [--backfill-budget <seconds>] [--json]",
86
89
  "",
87
- "Alias for `cockpit do-everything`.",
90
+ "Diagnose, repair, and verify this machine. doctor, do-everything, and fix run the same job.",
91
+ "Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
92
+ "--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
93
+ "--backfill-budget defaults to 900 seconds; catch-up repeats chunks until complete, with progress every 30 seconds.",
94
+ "--lock-wait defaults to 600 seconds; progress prints every 30 seconds.",
95
+ "--json includes steps, repairs, and needs_person. Exit 0 means nothing needs you.",
96
+ "Maintainers: --update-tag next keeps self-update on the prerelease candidate.",
88
97
  ],
89
98
  ],
90
99
  [
91
100
  "doctor",
92
101
  [
93
- "Usage: cockpit doctor [same flags as cockpit do-everything]",
102
+ "Usage: cockpit doctor [--workspace <path>] [--check | --no-repair] [--lock-wait <seconds>] [--backfill-budget <seconds>] [--json]",
94
103
  "",
95
- "Alias for `cockpit do-everything`. It checks this machine row by row —",
96
- "CLI version, sign-in, saved folders, background sync, backfill, disk and",
97
- "fixes each row it can. `--dry-run` prints the diagnosis and changes nothing.",
104
+ "Diagnose, repair, and verify this machine. doctor, do-everything, and fix run the same job.",
105
+ "Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
106
+ "--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
107
+ "--backfill-budget defaults to 900 seconds; catch-up repeats chunks until complete, with progress every 30 seconds.",
108
+ "--lock-wait defaults to 600 seconds; progress prints every 30 seconds.",
109
+ "--json includes steps, repairs, and needs_person. Exit 0 means nothing needs you.",
110
+ "Maintainers: --update-tag next keeps self-update on the prerelease candidate.",
98
111
  ],
99
112
  ],
100
113
  [
@@ -64,7 +64,7 @@ export function localCommandHelp(command) {
64
64
  " cockpit upgrade [same flags as update]",
65
65
  " cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--update-tag <tag>] [--dry-run] [--json]",
66
66
  " cockpit fix [same flags as do-everything]",
67
- " cockpit doctor [same flags as do-everything]",
67
+ " cockpit doctor [--check | --no-repair] [--lock-wait <seconds>] [--backfill-budget <seconds>] [--json]",
68
68
  " cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
69
69
  " cockpit login [--pair <code>] [--no-browser] [--legacy-pair] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
70
70
  " cockpit pair [--pair <code>] [--no-browser] [--legacy-pair] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
@@ -209,6 +209,7 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
209
209
  async function runDoctorLogin(command, io) {
210
210
  return runLogin({
211
211
  kind: "login",
212
+ homeDir: command.homeDir,
212
213
  dashboardUrl: command.dashboardUrl,
213
214
  json: command.json,
214
215
  noAuth: false,
@@ -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.100");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.102");
19
19
  return 0;
20
20
  }
21
21
 
@@ -40,12 +40,12 @@ function cockpitHelp() {
40
40
  localCommandHelp(),
41
41
  "",
42
42
  "Install: `npm install -g @bli-cockpit/cli@latest`.",
43
- "Fix everything: run `cockpit do-everything` to update, verify auth/roots, refresh autostart, backfill, GC, and sync.",
43
+ "Fix everything: run `cockpit doctor`. `do-everything` and `fix` are exact aliases; --check diagnoses without repairs.",
44
44
  "Update: run `cockpit update` to refresh the global CLI and rerun onboarding checks.",
45
45
  "Intern path: run `cockpit onboard`; it confirms a `/BLI` collection root before syncing.",
46
46
  "Headless/reused laptop path: `cockpit onboard --email <email> --workspace ~/BLI`.",
47
- "Already onboarded: run `cockpit update` from anywhere to refresh pairing, roots, agent rules, autostart, and sync.",
48
- "Agent setup: `cockpit onboard` refreshes AGENTS.md/CLAUDE.md rules; use `cockpit agent-rules install --workspace ~/BLI` for repair.",
47
+ "Already onboarded: run `cockpit doctor` from anywhere to repair and verify this machine.",
48
+ "Agent setup: `cockpit doctor` installs and verifies AGENTS.md/CLAUDE.md rules.",
49
49
  "Dashboard URL is optional for normal production use; pass `--dashboard-url` only for staging/custom dashboards or forced re-pairing.",
50
50
  "Manual collector path: `install`, `login`, `start [--ticket <id>] [--topic <label>] [--intent <intent>] [--phase <phase>]`, `sync`, `status`, `agent-rules`.",
51
51
  "Maintainer release path: merge to main, publish to `next` with `cockpit release`, canary Windows plus Apple Silicon, then deliberately promote that exact version to `latest`.",
@@ -0,0 +1,18 @@
1
+ /** Compact counts for human-facing usage tables. JSON keeps the original numbers. */
2
+ export function formatCount(value) {
3
+ if (value === null)
4
+ return "Unavailable";
5
+ // Promote rounded values at unit boundaries instead of printing 1000K or 1000M.
6
+ if (value >= 999_950_000)
7
+ return `${Number((value / 1_000_000_000).toFixed(1))}B`;
8
+ if (value >= 999_500)
9
+ return `${Number((value / 1_000_000).toFixed(1))}M`;
10
+ if (value >= 1_000)
11
+ return `${Math.round(value / 1_000)}K`;
12
+ return String(value);
13
+ }
14
+ export function formatUsageDollars(value) {
15
+ return new Intl.NumberFormat("en-US", {
16
+ style: "currency", currency: "USD", maximumFractionDigits: 0,
17
+ }).format(value);
18
+ }
@@ -1,3 +1,4 @@
1
+ import { formatCount, formatUsageDollars } from "./usage-format.js";
1
2
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
2
3
  import { writeLine } from "./cli-io.js";
3
4
  export async function runUsage(command, io) {
@@ -13,9 +14,18 @@ export async function runUsage(command, io) {
13
14
  const body = answer.body;
14
15
  if (door.json)
15
16
  return emitAgentDoor(door, body);
16
- writeLine(io.stdout, "PERSON TOKENS OUTPUT LIST EQUIVALENT COVERAGE");
17
- for (const row of body.people ?? [])
18
- writeLine(io.stdout, `${(row.display_name ?? row.email ?? "Unknown").slice(0, 20).padEnd(20)} ${String(row.tokens_total).padStart(11)} ${String(row.output_tokens).padStart(11)} ${`$${row.api_list_price_equivalent_usd.toFixed(2)}`.padStart(15)} ${row.sessions_extracted}/${row.sessions_observed}`);
17
+ const widths = [20, 8, 15, 12, 10, 10, 12, 14];
18
+ const printRow = (cells) => writeLine(io.stdout, cells.map((cell, index) => index === 0 ? cell.padEnd(widths[index]) : cell.padStart(widths[index])).join(" ").trimEnd());
19
+ printRow(["Person", "Tokens", "List equivalent", "Coverage", ...(command.detail ? ["Output", "Input", "Cache read", "Cache creation"] : [])]);
20
+ for (const row of body.people ?? []) {
21
+ printRow([
22
+ (row.display_name ?? row.email ?? "Unknown").slice(0, 20),
23
+ formatCount(row.tokens_total),
24
+ formatUsageDollars(row.api_list_price_equivalent_usd),
25
+ `${row.sessions_extracted}/${row.sessions_observed}`,
26
+ ...(command.detail ? [row.output_tokens, row.input_tokens, row.cache_read_input_tokens, row.cache_creation_input_tokens].map(formatCount) : []),
27
+ ]);
28
+ }
19
29
  writeLine(io.stdout, "");
20
30
  writeLine(io.stdout, body.api_list_price_equivalent_label ?? "API list-price equivalent (not actual spend)");
21
31
  writeLine(io.stdout, `${body.coverage?.sessions_extracted ?? 0} of ${body.coverage?.sessions_observed ?? 0} sessions extracted`);
@@ -146,7 +146,7 @@ function parseSemverTriple(value) {
146
146
  return null;
147
147
  return [Number(match[1]), Number(match[2]), Number(match[3])];
148
148
  }
149
- function isSemverBelow(left, right) {
149
+ export function isSemverBelow(left, right) {
150
150
  const parsedLeft = parseSemverTriple(left);
151
151
  const parsedRight = parseSemverTriple(right);
152
152
  if (!parsedLeft || !parsedRight)
package/dist/sync-lock.js CHANGED
@@ -142,4 +142,9 @@ async function readLock(lockPath) {
142
142
  // ACQUIRE above is the branch that owns the reporting.
143
143
  return null;
144
144
  }
145
+ }
146
+ /** Read owner metadata without acquiring or disturbing the collection lock. */
147
+ export async function inspectSyncLock(paths) {
148
+ const record = await readLock(path.join(paths.state_dir, LOCK_FILENAME));
149
+ return record ? { pid: record.pid, heartbeat_at: record.heartbeat_at, held: Date.now() - record.heartbeat_ms <= STALE_TAKEOVER_MS } : null;
145
150
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.100",
3
+ "version": "0.2.102",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.26",
31
- "@bli-cockpit/mcp": "0.1.31",
31
+ "@bli-cockpit/mcp": "0.1.32",
32
32
  "@bli-cockpit/telemetry-core": "0.1.43"
33
33
  }
34
34
  }