@bli-cockpit/cli 0.2.28 → 0.2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +22 -15
  2. package/dist/adapters/local-sources.js +1 -0
  3. package/dist/adapters/raw-evidence-completeness.js +226 -0
  4. package/dist/adapters/raw-evidence-git-diff.js +90 -0
  5. package/dist/adapters/raw-evidence-keys.js +92 -0
  6. package/dist/adapters/raw-evidence-manifest.js +132 -0
  7. package/dist/adapters/raw-evidence-pack-store.js +136 -0
  8. package/dist/adapters/raw-evidence-sanitize.js +190 -0
  9. package/dist/adapters/raw-evidence.js +656 -1257
  10. package/dist/commands/backfill.js +7 -0
  11. package/dist/commands/cli-io.js +92 -0
  12. package/dist/commands/collection-report.js +139 -0
  13. package/dist/commands/collection-roots.js +153 -0
  14. package/dist/commands/doctor.js +19 -17
  15. package/dist/commands/install-receipts.js +193 -0
  16. package/dist/commands/install-update.js +305 -0
  17. package/dist/commands/local-auth.js +268 -0
  18. package/dist/commands/local-discovery.js +100 -0
  19. package/dist/commands/local-help.js +281 -0
  20. package/dist/commands/local.js +182 -1872
  21. package/dist/commands/public-root.js +1 -1
  22. package/dist/commands/sessions.js +162 -0
  23. package/dist/commands/status.js +230 -0
  24. package/dist/evidence-upload-client.js +43 -2
  25. package/dist/raw-evidence-gc.js +1 -1
  26. package/dist/raw-evidence-staging.js +15 -2
  27. package/dist/upload-agent-artifacts.js +153 -0
  28. package/dist/upload-envelope.js +407 -0
  29. package/dist/upload-evidence-delivery.js +505 -0
  30. package/dist/upload-http.js +46 -0
  31. package/dist/upload-session-reports.js +404 -0
  32. package/dist/upload.js +132 -1264
  33. package/package.json +2 -2
@@ -1163,6 +1163,13 @@ async function syncBackfillBatch(options) {
1163
1163
  })),
1164
1164
  claudeAttributionScan: options.claudeAttribution ?? undefined,
1165
1165
  rawEvidenceBudget: options.rawEvidenceBudget,
1166
+ // `cockpit backfill` is only ever an operator asking — by hand, through
1167
+ // onboarding, through doctor, or by running the retry command Cockpit
1168
+ // printed. The delivery-backoff window is the scheduler's cadence, so it
1169
+ // does not gate this pass: honouring it here made the retry that follows a
1170
+ // failed commit a 15-minute no-op that reported
1171
+ // `durable_session_pointer_missing` and never named the hold (BLI-3118).
1172
+ evidenceDeliveryMode: "operator_retry",
1166
1173
  fetch: options.fetchImpl,
1167
1174
  };
1168
1175
  try {
@@ -0,0 +1,92 @@
1
+ import { createCapturedExecRunner, createInteractiveExecRunner, } from "../process-runner.js";
2
+ export function writeLine(stream, text) {
3
+ stream.write(`${text}\n`);
4
+ }
5
+ /** Writes text through unchanged, adding only the trailing newline it lacks. */
6
+ export function writeRaw(stream, text) {
7
+ if (!text)
8
+ return;
9
+ stream.write(text);
10
+ if (!text.endsWith("\n"))
11
+ stream.write("\n");
12
+ }
13
+ export function errorMessage(error) {
14
+ return error instanceof Error ? error.message : String(error);
15
+ }
16
+ export function writeExecOutput(io, result, options) {
17
+ if (options.stdout)
18
+ writeRaw(io.stdout, result.stdout);
19
+ if (options.stderr)
20
+ writeRaw(io.stderr, result.stderr);
21
+ }
22
+ export function isInteractiveStdin(io) {
23
+ return Boolean(io.stdin.isTTY);
24
+ }
25
+ /**
26
+ * Reads one line from stdin after writing a prompt. Shared by the onboard email
27
+ * prompt and the autostart prompt; callers gate on `isInteractiveStdin` first so
28
+ * headless / piped / spawned runs never block on input.
29
+ */
30
+ export async function readLine(io, prompt) {
31
+ io.stdout.write(prompt);
32
+ io.stdin.setEncoding("utf8");
33
+ return new Promise((resolve) => {
34
+ const onData = (chunk) => {
35
+ io.stdin.removeListener("data", onData);
36
+ io.stdin.pause();
37
+ resolve(chunk);
38
+ };
39
+ io.stdin.resume();
40
+ io.stdin.on("data", onData);
41
+ });
42
+ }
43
+ /** Enter means yes: only an explicit `n`/`no` declines. */
44
+ export function yesByDefault(raw) {
45
+ const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
46
+ return answer !== "n" && answer !== "no";
47
+ }
48
+ /** Collects what a nested command writes instead of letting it reach the user. */
49
+ export function bufferedWritable(chunks) {
50
+ return {
51
+ write(chunk) {
52
+ chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
53
+ return true;
54
+ },
55
+ };
56
+ }
57
+ export function replayCaptured(stream, chunks) {
58
+ for (const chunk of chunks)
59
+ stream.write(chunk);
60
+ }
61
+ /** Captured `--json` stdout, parsed when it is JSON and handed back raw when it is not. */
62
+ export function parseCapturedJson(chunks) {
63
+ const text = chunks.join("").trim();
64
+ if (!text)
65
+ return null;
66
+ try {
67
+ return JSON.parse(text);
68
+ }
69
+ catch {
70
+ return text;
71
+ }
72
+ }
73
+ export function defaultExec() {
74
+ return createCapturedExecRunner();
75
+ }
76
+ export function defaultInteractiveExec() {
77
+ return createInteractiveExecRunner();
78
+ }
79
+ export function defaultIo() {
80
+ if (!globalThis.fetch) {
81
+ throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
82
+ }
83
+ return {
84
+ stdin: process.stdin,
85
+ stdout: process.stdout,
86
+ stderr: process.stderr,
87
+ env: process.env,
88
+ fetch: globalThis.fetch.bind(globalThis),
89
+ exec: defaultExec(),
90
+ interactiveExec: defaultInteractiveExec(),
91
+ };
92
+ }
@@ -0,0 +1,139 @@
1
+ import { writeLine } from "./cli-io.js";
2
+ import { matchesLiveSyncWorktree, } from "./session-sync.js";
3
+ export function shortSha(value) {
4
+ return value ? value.slice(0, 12) : "unknown";
5
+ }
6
+ export function displayTicketId(ticketId) {
7
+ return ticketId ?? "none (general work)";
8
+ }
9
+ export function displayWorkLabel(status) {
10
+ if (status.work_label && status.work_id)
11
+ return `${status.work_label} (${status.work_id})`;
12
+ return status.work_label ?? status.work_id ?? "no active work context";
13
+ }
14
+ function sourceFunnelLine(label, counts) {
15
+ // Same counters, plain words. Every counter still prints, including the ones
16
+ // that are zero: a funnel that hides a bucket cannot show where sessions go.
17
+ const readFailures = counts.read_failures > 0 ? `, ${counts.read_failures} could not be read` : "";
18
+ return `${label} sessions: ${counts.attributed} matched to a repo, ${counts.attributed_fallback} by best guess, ${counts.ambiguous} unclear which repo, ${counts.unattributed} could not be matched, ${counts.stale} too old, ${counts.skipped} skipped${readFailures}`;
19
+ }
20
+ function attributionReportLine(summary) {
21
+ // The parenthesised value is the machine reason label and never changes.
22
+ return summary.report_posted
23
+ ? "Session report: saved"
24
+ : `Session report: skipped (${summary.report_reason})`;
25
+ }
26
+ /**
27
+ * One funnel line per source, an anomaly diagnostics line only when something
28
+ * is nonzero (clean syncs stay one line per source), and the report line.
29
+ * Counts only — project-dir slugs encode full local paths and never print
30
+ * (B.4 §5).
31
+ */
32
+ export function writeAgentSessionSummary(io, summary) {
33
+ writeLine(io.stdout, sourceFunnelLine("Codex", summary.codex));
34
+ writeLine(io.stdout, sourceFunnelLine("Claude", summary.claude));
35
+ const claudeDiagnostics = claudeDiagnosticsLine(summary);
36
+ if (claudeDiagnostics)
37
+ writeLine(io.stdout, claudeDiagnostics);
38
+ writeLine(io.stdout, attributionReportLine(summary));
39
+ }
40
+ function claudeDiagnosticsLine(summary) {
41
+ // Anomaly-only (D34): sidecars_collected/uploaded are normal-operation
42
+ // counters and must NOT trigger this line, or a healthy orchestrated sync
43
+ // prints it 48×/day in launchd logs. Clean syncs stay one line per source.
44
+ const claude = summary.claude;
45
+ const parts = [];
46
+ if (claude.sidecars_skipped)
47
+ parts.push(`${claude.sidecars_skipped} helper sessions skipped`);
48
+ if (claude.sidecars_capped)
49
+ parts.push(`${claude.sidecars_capped} helper sessions cut short`);
50
+ if (claude.sidecars_failed)
51
+ parts.push(`${claude.sidecars_failed} helper sessions could not be read`);
52
+ if (claude.mains_oversized)
53
+ parts.push(`${claude.mains_oversized} sessions too big`);
54
+ if (claude.oversized_lines_skipped)
55
+ parts.push(`${claude.oversized_lines_skipped} oversized lines skipped`);
56
+ if (claude.project_dirs_skipped)
57
+ parts.push(`${claude.project_dirs_skipped} folders skipped`);
58
+ if (claude.sessions_schema_drift)
59
+ parts.push(`${claude.sessions_schema_drift} sessions in an unexpected format`);
60
+ if (claude.growth_damped)
61
+ parts.push(`${claude.growth_damped} fast-growing sessions slowed down`);
62
+ if (claude.first_run_backfill)
63
+ parts.push("first run, catching up on history");
64
+ if (summary.files_deferred_byte_budget)
65
+ parts.push(`${summary.files_deferred_byte_budget} files held back, size limit`);
66
+ if (summary.files_deferred_object_budget)
67
+ parts.push(`${summary.files_deferred_object_budget} files held back, file-count limit`);
68
+ return parts.length > 0 ? `Claude problems: ${parts.join(", ")}` : null;
69
+ }
70
+ export function rawEvidenceSyncLine(sync) {
71
+ // The reason lists themselves are machine labels and stay verbatim.
72
+ const failures = sync.raw_evidence_failure_reasons.length > 0
73
+ ? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
74
+ : "";
75
+ const retries = sync.raw_evidence_retry_reasons.length > 0
76
+ ? ` retry_required: ${sync.raw_evidence_retry_reasons.join(",")}`
77
+ : "";
78
+ // A held object and a nine-day-old first failure both belong on this line.
79
+ // Neither used to appear anywhere, which is how a 1,030-attempt loop stayed
80
+ // invisible (BLI-3066).
81
+ const held = sync.raw_evidence_delivery_held_count > 0
82
+ ? `, ${sync.raw_evidence_delivery_held_count} waiting`
83
+ : "";
84
+ const stuck = sync.raw_evidence_stuck_object_count > 0
85
+ ? `, ${sync.raw_evidence_stuck_object_count} stuck (${sync.raw_evidence_max_delivery_attempts} tries since ${sync.raw_evidence_oldest_delivery_failure_at ?? "unknown"})`
86
+ : "";
87
+ return `Files: ${sync.raw_evidence_uploaded_object_count} uploaded, ${sync.raw_evidence_reused_count} already there, ${sync.raw_evidence_failed_count} failed${held}${stuck}${failures}${retries}`;
88
+ }
89
+ export function cursorStatusLine(sync) {
90
+ return `Tracked so far: ${sync.cursor_tracked_object_count} uploaded item(s)`;
91
+ }
92
+ /**
93
+ * One line that cannot say "fine" while an object has never been accepted.
94
+ * Named reasons, worst attempt count, and the date it started.
95
+ */
96
+ export function stuckEvidenceLine(status) {
97
+ if (status.stuck_evidence_object_count === 0)
98
+ return "none";
99
+ return `${status.stuck_evidence_object_count} object(s), ${status.stuck_evidence_held_count} held, worst ${status.stuck_evidence_max_attempts} attempt(s) since ${status.stuck_evidence_oldest_failure_at ?? "unknown"} (${status.stuck_evidence_reasons.join(",") || "unknown"})`;
100
+ }
101
+ /** The `--json` row for one synced worktree, shared by onboard and sync. */
102
+ export function worktreeSyncRow(outcome, run) {
103
+ const { worktree, context, sync } = outcome;
104
+ const matchesWorktree = (result) => matchesLiveSyncWorktree(result, worktree);
105
+ const codexSessionCount = run.codexAttribution.results.filter(matchesWorktree).length;
106
+ const claudeSessionCount = run.claudeAttribution.results.filter(matchesWorktree).length;
107
+ const attributedSessionCount = codexSessionCount + claudeSessionCount;
108
+ return {
109
+ repo_label: context?.repo_label ?? worktree.repo_label,
110
+ repo_fingerprint: context?.repo_fingerprint ?? worktree.repo_fingerprint,
111
+ worktree_label: context?.worktree_label ?? worktree.worktree_label,
112
+ worktree_fingerprint: context?.worktree_fingerprint ?? worktree.worktree_fingerprint,
113
+ branch: context?.branch ?? worktree.branch,
114
+ head_sha: sync.head_sha ?? worktree.head_sha,
115
+ work_context_id: context?.work_context_id ?? sync.work_context_id,
116
+ upload_status: sync.status,
117
+ raw_evidence_file_count: sync.raw_evidence_file_count,
118
+ raw_evidence_uploaded_object_count: sync.raw_evidence_uploaded_object_count,
119
+ raw_evidence_uploaded_chunk_count: sync.raw_evidence_uploaded_chunk_count,
120
+ raw_evidence_reused_count: sync.raw_evidence_reused_count,
121
+ raw_evidence_failed_count: sync.raw_evidence_failed_count,
122
+ raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
123
+ raw_evidence_retry_required: sync.raw_evidence_retry_required,
124
+ raw_evidence_retry_reasons: sync.raw_evidence_retry_reasons,
125
+ attributed_session_count: attributedSessionCount,
126
+ codex_session_count: codexSessionCount,
127
+ claude_session_count: claudeSessionCount,
128
+ cursor_tracked_object_count: sync.cursor_tracked_object_count,
129
+ failure_reason: sync.status === "spooled" ? sync.failure_reason : null,
130
+ };
131
+ }
132
+ /** Green only when the whole run is green; an accepted upload with partial collection is `partial`. */
133
+ export function attributedSyncRunStatus(run) {
134
+ if (run.ok)
135
+ return "uploaded";
136
+ return run.outcomes.every((outcome) => outcome.sync.status === "uploaded")
137
+ ? "partial"
138
+ : "spooled";
139
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The folders this machine is allowed to collect from: resolving them, saving
3
+ * them, and reading them back to prove setup actually worked.
4
+ *
5
+ * Widening the boundary is a consent decision a person makes, never a fix an
6
+ * agent applies — so everything here either asks or reports, and the read-back
7
+ * fails loudly rather than handing over a machine that will never collect.
8
+ * Split out of commands/local.ts (BLI-3104); moved verbatim.
9
+ */
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { stat } from "node:fs/promises";
13
+ import { isInteractiveStdin, readLine, writeLine, yesByDefault } from "./cli-io.js";
14
+ import { COLLECTION_ROOT_REQUIRED, resolveOnboardingRoots, } from "../onboarding-roots.js";
15
+ import { getCollectorRuntimePaths, installLocalCollector, readLocalCollectorConfig, } from "../local-state.js";
16
+ import { collectionRootPathAliases } from "../repo-identity.js";
17
+ import { normalizeCollectionRoots } from "../root-normalization.js";
18
+ export function onboardingRootPrompt(io) {
19
+ return {
20
+ confirm: async (message) => yesByDefault(await readLine(io, message)),
21
+ input: (message) => readLine(io, message),
22
+ message: (message) => writeLine(io.stdout, message),
23
+ };
24
+ }
25
+ export async function resolveOnboardingRootsForCommand(command, io) {
26
+ const paths = getCollectorRuntimePaths(command.homeDir);
27
+ const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
28
+ const interactive = !command.json && isInteractiveStdin(io);
29
+ const rootsResult = await resolveOnboardingRoots({
30
+ homeDir: command.homeDir,
31
+ explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
32
+ config: existingConfig,
33
+ interactive,
34
+ allowHomeRoot: command.allowHomeRoot,
35
+ prompt: interactive ? onboardingRootPrompt(io) : undefined,
36
+ });
37
+ const collectionRoots = rootsResult.roots;
38
+ const primaryRoot = collectionRoots[0];
39
+ if (!primaryRoot) {
40
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed.`);
41
+ }
42
+ const replaceRepoRoots = rootsResult.source === "prompt" &&
43
+ !command.collectionRoots?.length &&
44
+ (existingConfig?.default_repo_paths.length ?? 0) > 0;
45
+ return {
46
+ existingConfig,
47
+ rootsResult,
48
+ collectionRoots,
49
+ primaryRoot,
50
+ replaceRepoRoots,
51
+ };
52
+ }
53
+ export async function persistOnboardingRootConfig(command, resolution) {
54
+ const result = await installLocalCollector({
55
+ homeDir: command.homeDir,
56
+ repoRoot: resolution.primaryRoot,
57
+ repoRoots: resolution.collectionRoots,
58
+ replaceRepoRoots: resolution.replaceRepoRoots,
59
+ dashboardUrl: command.dashboardUrl,
60
+ deviceName: command.deviceName,
61
+ });
62
+ await assertCollectionRootPersisted(command.homeDir);
63
+ return result;
64
+ }
65
+ /**
66
+ * Setup does not get to claim success on its own say-so.
67
+ *
68
+ * Onboarding used to write the config and report success without ever reading
69
+ * it back. Savina's onboard did exactly that, persisted nothing, and every
70
+ * scheduled sync afterwards threw `collection_root_required` into a log nobody
71
+ * reads — twelve consecutive failures, six days at 3 uploaded of 195, found
72
+ * only by hand-querying the database. BLI-1986 fixed one path into that state;
73
+ * this closes the state itself.
74
+ *
75
+ * So we read the config back through the SAME resolution the scheduled sync
76
+ * will use, and fail here — in front of a human who can still fix it — rather
77
+ * than silently handing back a machine that will never collect.
78
+ */
79
+ export async function assertCollectionRootPersisted(homeDir) {
80
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
81
+ const saved = normalizeCollectionRoots(config?.default_repo_paths ?? []);
82
+ if (saved.length === 0) {
83
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${collectionRootNotPersistedMessage(homeDir)}`);
84
+ }
85
+ // Present in the file is not the same as usable. A root that no longer
86
+ // exists on disk resolves to nothing at sync time, which is the same silent
87
+ // dead end arriving one step later.
88
+ const usable = [];
89
+ for (const root of saved) {
90
+ if (await directoryExists(root))
91
+ usable.push(root);
92
+ }
93
+ if (usable.length === 0) {
94
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${collectionRootMissingOnDiskMessage(saved, homeDir)}`);
95
+ }
96
+ return usable;
97
+ }
98
+ /**
99
+ * Keep both spellings when an existing root is reached through a filesystem
100
+ * alias such as macOS `/var` -> `/private/var`. Deleted child paths cannot be
101
+ * realpathed later, so either transcript spelling must remain inside the same
102
+ * operator-approved physical root.
103
+ */
104
+ export async function collectionRootConsentAliases(roots) {
105
+ return normalizeCollectionRoots(await collectionRootPathAliases(roots));
106
+ }
107
+ // Placeholders like <path-to-your-work-folder> make a person stop and think.
108
+ // These print real, paste-able commands with this machine's actual paths in
109
+ // them, so the fix is a copy away rather than a puzzle.
110
+ function collectionRootNotPersistedMessage(homeDir) {
111
+ const home = path.resolve(homeDir ?? os.homedir());
112
+ return [
113
+ "Setup finished without saving a collection root, so this machine would never collect anything.",
114
+ "Nothing was saved, so nothing is broken — setup just did not finish.",
115
+ "",
116
+ "Fix it by running ONE of these:",
117
+ "",
118
+ " # Sync everything on this machine (what most people want on a work laptop)",
119
+ " cockpit do-everything --allow-home-root",
120
+ "",
121
+ " # Or sync one folder — replace the path with where your projects live",
122
+ ` cockpit do-everything --workspace ${path.join(home, "BLI")}`,
123
+ "",
124
+ " # Or answer the folder question interactively",
125
+ " cockpit do-everything",
126
+ "",
127
+ "Then check it worked:",
128
+ " cockpit status",
129
+ ].join("\n");
130
+ }
131
+ function collectionRootMissingOnDiskMessage(saved, homeDir) {
132
+ const home = path.resolve(homeDir ?? os.homedir());
133
+ return [
134
+ "Cockpit is set up to collect from a folder that is not on this machine:",
135
+ ...saved.map((root) => ` ${root}`),
136
+ "",
137
+ "That usually means the folder was renamed, moved, or deleted since setup.",
138
+ "",
139
+ "Fix it by running ONE of these:",
140
+ "",
141
+ " # Point Cockpit at where your projects actually live now",
142
+ ` cockpit do-everything --workspace ${path.join(home, "BLI")}`,
143
+ "",
144
+ " # Or sync everything on this machine and stop worrying about the path",
145
+ " cockpit do-everything --allow-home-root",
146
+ "",
147
+ "Not sure where your projects are? This lists the folders Cockpit can see:",
148
+ " cockpit status",
149
+ ].join("\n");
150
+ }
151
+ async function directoryExists(dir) {
152
+ return stat(dir).then((stats) => stats.isDirectory(), () => false);
153
+ }
@@ -175,7 +175,7 @@ async function readAuthState(context) {
175
175
  if (session?.session_state === "valid" &&
176
176
  typeof session.device_token === "string" &&
177
177
  session.device_token) {
178
- return ok("authed", "device_token_present", "device token present");
178
+ return ok("authed", "device_token_present", "this machine is signed in");
179
179
  }
180
180
  return hardStop("authed", "pairing_required", [
181
181
  "device is not signed in.",
@@ -216,12 +216,12 @@ async function checkAutostartState(context) {
216
216
  exec,
217
217
  });
218
218
  if (result.status === "loaded") {
219
- return ok("autostart-alive", "already_installed", "autostart scheduler loaded");
219
+ return ok("autostart-alive", "already_installed", "background sync is running");
220
220
  }
221
221
  if (result.status === "unsupported") {
222
222
  return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
223
223
  }
224
- return needsFix("autostart-alive", result.status === "not_loaded" ? "not_loaded" : "absent", "autostart is not loaded");
224
+ return needsFix("autostart-alive", result.status === "not_loaded" ? "not_loaded" : "absent", "background sync is not running");
225
225
  }
226
226
  async function fixAutostartState(context) {
227
227
  const exec = context.io.exec;
@@ -241,7 +241,7 @@ async function fixAutostartState(context) {
241
241
  if (result.loaded === false) {
242
242
  return fail("autostart-alive", "autostart_load_failed", result.message ?? "operating-system scheduler load failed");
243
243
  }
244
- return ok("autostart-alive", "installed", "autostart installed and loaded");
244
+ return ok("autostart-alive", "installed", "background sync installed and running");
245
245
  }
246
246
  /**
247
247
  * Pure so it can be unit-tested without touching the real machine's home
@@ -261,10 +261,10 @@ export function backfillCompletionStepState(marker, roots) {
261
261
  }
262
262
  const oversized = marker?.oversized_skips;
263
263
  if (oversized && oversized.count > 0) {
264
- return ok("backfill-complete", "complete_with_oversized_skips", `backfill completion covers the current saved roots and both session sources ` +
265
- `(complete_with_oversized_skips · ${oversized.count} file${oversized.count === 1 ? "" : "s"} over the upload cap)`);
264
+ return ok("backfill-complete", "complete_with_oversized_skips", `caught up on old Codex and Claude sessions in every saved folder ` +
265
+ `(complete_with_oversized_skips · ${oversized.count} file${oversized.count === 1 ? "" : "s"} too big to upload)`);
266
266
  }
267
- return ok("backfill-complete", "complete", "backfill completion covers the current saved roots and both session sources");
267
+ return ok("backfill-complete", "complete", "caught up on old Codex and Claude sessions in every saved folder");
268
268
  }
269
269
  async function checkBackfillState(context) {
270
270
  const paths = getCollectorRuntimePaths();
@@ -275,10 +275,10 @@ async function checkBackfillState(context) {
275
275
  return covered;
276
276
  const lock = await inspectBackfillLock(paths);
277
277
  if (lock.held) {
278
- return needsFix("backfill-complete", "backfill_already_running", `backfill completion is not yet proven; another run holds the lock since ${lock.held_since ?? "unknown"}`);
278
+ return needsFix("backfill-complete", "backfill_already_running", `another catch-up run is still going, and this one has not finished yet (running since ${lock.held_since ?? "unknown"})`);
279
279
  }
280
280
  const cursor = await readBackfillCursor(paths);
281
- return needsFix("backfill-complete", cursor.updated_at ? "partial" : "never_run", "backfill completion marker missing");
281
+ return needsFix("backfill-complete", cursor.updated_at ? "partial" : "never_run", "the catch-up over your old sessions has not finished");
282
282
  }
283
283
  async function fixBackfillState(context) {
284
284
  const capture = capturedIo(context.io, !context.command.json);
@@ -308,20 +308,20 @@ async function fixBackfillState(context) {
308
308
  }
309
309
  async function checkGcState(context) {
310
310
  if (context.io.env["COCKPIT_DISABLE_GC"] === "1") {
311
- return skipped("gc-checked", "skipped_disabled", "raw-evidence GC disabled");
311
+ return skipped("gc-checked", "skipped_disabled", "cleanup is switched off");
312
312
  }
313
313
  const paths = getCollectorRuntimePaths();
314
314
  const marker = path.join(paths.state_dir, ".last-raw-evidence-gc");
315
315
  const info = await fs.stat(marker).catch(() => null);
316
316
  if (info && Date.now() - info.mtimeMs < GC_MIN_INTERVAL_MS) {
317
- return skipped("gc-checked", "skipped_throttled", "raw-evidence GC ran within 24h");
317
+ return skipped("gc-checked", "skipped_throttled", "cleanup already ran today");
318
318
  }
319
- return needsFix("gc-checked", "due", "raw-evidence GC is due");
319
+ return needsFix("gc-checked", "due", "cleanup is due");
320
320
  }
321
321
  async function fixGcState(context) {
322
322
  const result = await runRawEvidenceLocalGc(getCollectorRuntimePaths(), context.io.env);
323
323
  if (result.skipped) {
324
- return skipped("gc-checked", "skipped_throttled", "raw-evidence GC skipped");
324
+ return skipped("gc-checked", "skipped_throttled", "cleanup already ran today");
325
325
  }
326
326
  if (result.removed_dirs === 0) {
327
327
  return ok("gc-checked", "nothing_eligible", rawEvidenceGcSummary(result));
@@ -422,8 +422,8 @@ async function fixSyncState(context) {
422
422
  if (result.code !== 0) {
423
423
  const draining = syncBacklogDrainingVerdict(parsed);
424
424
  if (draining) {
425
- return needsFix("sync-fresh", "backlog_draining", `${repoRoot}: raw-evidence backlog is still draining (${draining.remainingObjects} ` +
426
- `object${draining.remainingObjects === 1 ? "" : "s"} deferred this tick); ` +
425
+ return needsFix("sync-fresh", "backlog_draining", `${repoRoot}: still catching up (${draining.remainingObjects} ` +
426
+ `file${draining.remainingObjects === 1 ? "" : "s"} left for later this run); ` +
427
427
  "rerun `cockpit sync` to continue");
428
428
  }
429
429
  return fail("sync-fresh", status ?? "sync_failed", `sync failed for ${repoRoot}`);
@@ -464,9 +464,11 @@ function writeDoctorOutput(command, io, rows) {
464
464
  return;
465
465
  }
466
466
  writeLine(io.stdout, command.dryRun ? "Cockpit doctor dry-run" : "Cockpit doctor");
467
- writeLine(io.stdout, "state step code result");
467
+ // The machine `code` stays in `--json`; a human reading the table wants the
468
+ // sentence, not the label (BLI-3194).
469
+ writeLine(io.stdout, "state step result");
468
470
  for (const row of rows) {
469
- writeLine(io.stdout, `${doctorMark(row)} ${row.id.padEnd(20)} ${row.code.padEnd(23)} ${oneLine(row.message)}`);
471
+ writeLine(io.stdout, `${doctorMark(row)} ${row.id.padEnd(20)} ${oneLine(row.message)}`);
470
472
  }
471
473
  const explanations = rows.filter((row) => (row.hardStop || row.status === "fail") && row.message.includes("\n"));
472
474
  for (const row of explanations) {