@bli-cockpit/cli 0.2.53 → 0.2.55

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 (60) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +247 -0
  2. package/dist/adapters/attribution-core-paths.js +182 -0
  3. package/dist/adapters/attribution-core-score.js +159 -0
  4. package/dist/adapters/attribution-core-types.js +13 -0
  5. package/dist/adapters/attribution-core.js +13 -565
  6. package/dist/adapters/claude-attribution-discovery.js +186 -0
  7. package/dist/adapters/claude-attribution-score.js +204 -0
  8. package/dist/adapters/claude-attribution-signals.js +180 -0
  9. package/dist/adapters/claude-attribution-types.js +25 -0
  10. package/dist/adapters/claude-attribution.js +14 -569
  11. package/dist/commands/doctor-access.js +129 -0
  12. package/dist/commands/doctor-pipeline.js +326 -0
  13. package/dist/commands/doctor-registration.js +105 -0
  14. package/dist/commands/doctor-report.js +111 -0
  15. package/dist/commands/doctor-update.js +120 -0
  16. package/dist/commands/doctor.js +8 -753
  17. package/dist/commands/heartbeat.js +8 -0
  18. package/dist/commands/jarvis-contracts.js +8 -0
  19. package/dist/commands/jarvis-render.js +413 -0
  20. package/dist/commands/jarvis-turn.js +305 -0
  21. package/dist/commands/jarvis.js +23 -698
  22. package/dist/commands/local-args-collector-setup.js +250 -0
  23. package/dist/commands/local-args-collector-status.js +227 -0
  24. package/dist/commands/local-args-collector-work.js +175 -0
  25. package/dist/commands/local-args-collector.js +19 -624
  26. package/dist/commands/local-args-tower-admin.js +456 -0
  27. package/dist/commands/local-args-tower-chat.js +194 -0
  28. package/dist/commands/local-args-tower-pages.js +314 -0
  29. package/dist/commands/local-args-tower.js +13 -880
  30. package/dist/commands/local-help.js +10 -2
  31. package/dist/commands/onboard-completion.js +136 -0
  32. package/dist/commands/onboard-flows.js +165 -0
  33. package/dist/commands/onboard-setup.js +102 -0
  34. package/dist/commands/onboard.js +5 -392
  35. package/dist/commands/public-root.js +1 -1
  36. package/dist/commands/session-sync-counters.js +55 -0
  37. package/dist/commands/session-sync-health.js +8 -1
  38. package/dist/commands/session-sync-plan.js +47 -7
  39. package/dist/commands/session-sync-scan.js +4 -4
  40. package/dist/commands/session-sync.js +6 -0
  41. package/dist/commands/settings-render.js +27 -0
  42. package/dist/commands/sync-followups.js +5 -1
  43. package/dist/commands/sync.js +5 -1
  44. package/dist/commands/team-device-reasons.js +16 -0
  45. package/dist/commands/team.js +87 -7
  46. package/dist/evidence-upload-client.js +14 -763
  47. package/dist/evidence-upload-object.js +181 -0
  48. package/dist/evidence-upload-plan.js +233 -0
  49. package/dist/evidence-upload-terminal.js +309 -0
  50. package/dist/evidence-upload-transport.js +104 -0
  51. package/dist/spool/local-spool-io.js +122 -0
  52. package/dist/spool/local-spool-mutations.js +174 -0
  53. package/dist/spool/local-spool-parse.js +143 -0
  54. package/dist/spool/local-spool-types.js +22 -0
  55. package/dist/spool/local-spool.js +20 -426
  56. package/dist/upload-evidence-delivery-offer.js +144 -0
  57. package/dist/upload-evidence-delivery-reconcile.js +134 -0
  58. package/dist/upload-evidence-delivery-summary.js +205 -0
  59. package/dist/upload-evidence-delivery.js +12 -482
  60. package/package.json +3 -3
@@ -0,0 +1,129 @@
1
+ import { describeError } from "../health-detail.js";
2
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
3
+ import { normalizeCollectionRoots } from "../root-normalization.js";
4
+ import { detectSecondCockpitInstall } from "../second-install.js";
5
+ import { hardStop, needsFix, ok, skipped } from "./doctor-report.js";
6
+ /**
7
+ * The `authed`, `roots-ok`, and `single-install` check family: who is this
8
+ * machine and where does it collect. Auth and roots are hard stops with no
9
+ * automatic fix beyond re-running login/onboarding — a machine cannot repair
10
+ * its own consent boundary. Single-install has no fix at all (see the
11
+ * function's own comment); it only names the second install and lets the
12
+ * operator decide.
13
+ */
14
+ export async function fixAuthState(context, state) {
15
+ const code = await context.deps.runLogin(context).catch((error) => {
16
+ // Exit code 1 with no reason at all is what an operator saw when doctor
17
+ // tried and failed to repair their auth — the same output as a login that
18
+ // ran and was declined (BLI-3238).
19
+ console.error("[cockpit-doctor] login repair threw", JSON.stringify({
20
+ reason: "auth_repair_failed",
21
+ ...describeError(error),
22
+ }));
23
+ return 1;
24
+ });
25
+ if (code !== 0)
26
+ return state;
27
+ const checked = await context.deps.readAuth(context);
28
+ return checked.status === "ok" ? checked : state;
29
+ }
30
+ export async function fixRootState(context, state) {
31
+ await context.deps.resolveAndSaveRoots(context).catch((error) => {
32
+ // Roots ARE the collection boundary. If saving them throws and nothing
33
+ // says so, the machine converges to "no approved root" and collects
34
+ // nothing — a green-looking doctor run over an empty boundary, which is
35
+ // the failure mode this whole ticket exists for.
36
+ console.error("[cockpit-doctor] collection roots could not be resolved or saved", JSON.stringify({
37
+ reason: "roots_repair_failed",
38
+ ...describeError(error),
39
+ }));
40
+ });
41
+ const checked = await context.deps.readRoots(context);
42
+ return checked.status === "ok" ? checked : state;
43
+ }
44
+ export async function readAuthState(context) {
45
+ const paths = getCollectorRuntimePaths();
46
+ const session = await readLocalCollectorSessionFile(paths).catch(() => null);
47
+ if (session?.session_state === "valid" &&
48
+ typeof session.device_token === "string" &&
49
+ session.device_token) {
50
+ return ok("authed", "device_token_present", "this machine is signed in");
51
+ }
52
+ return hardStop("authed", "pairing_required", [
53
+ "device is not signed in.",
54
+ "What you can do:",
55
+ " 1) Run `cockpit login` and complete the email/device approval.",
56
+ ` 2) If this is a reused machine, run \`${onboardOneLiner(context.command)}\` to refresh onboarding.`,
57
+ " 3) Send this output to Edward if approval is blocked.",
58
+ ].join("\n"));
59
+ }
60
+ export async function readRootState(context) {
61
+ const paths = getCollectorRuntimePaths();
62
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
63
+ const roots = normalizeCollectionRoots(config?.default_repo_paths ?? []);
64
+ if (roots.length > 0) {
65
+ return {
66
+ ...ok("roots-ok", "saved_roots_present", `saved roots: ${roots.join(", ")}`),
67
+ roots,
68
+ };
69
+ }
70
+ return hardStop("roots-ok", "no_roots", [
71
+ "no saved collection roots were found.",
72
+ "What you can do:",
73
+ ` 1) Run \`${onboardOneLiner(context.command)}\` to save the workspace roots again.`,
74
+ " 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.",
76
+ ].join("\n"));
77
+ }
78
+ /**
79
+ * BLI-3218/BLI-3553. Two Tower installs on one machine make each one's tick
80
+ * call the other's registration stale and re-register itself, so the plist
81
+ * flaps every 15 minutes and the device reports two CLI versions the same
82
+ * morning. Doctor OFFERS the removal and never performs it: an operator may
83
+ * have a second prefix on purpose, and `npm uninstall -g` of something nobody
84
+ * asked to remove is not a repair. The row therefore has no `fix` — it is
85
+ * information, and doctor still exits 0.
86
+ */
87
+ export async function checkSingleInstallState(context) {
88
+ const exec = context.io.exec;
89
+ if (!exec) {
90
+ return skipped("single-install", "runner_unavailable", "could not look for a second Tower install");
91
+ }
92
+ const finding = await detectSecondCockpitInstall({ exec });
93
+ if (!finding.detected) {
94
+ return ok("single-install", finding.reason, finding.install_count === 1
95
+ ? "one Tower install on this machine"
96
+ : "no second Tower install found");
97
+ }
98
+ // The full paths go to the operator's own screen; redactedHealthDetail
99
+ // masks them again before any of this is uploaded.
100
+ return needsFix("single-install", "second_install_detected", [
101
+ `${finding.install_count} Tower installs are on this machine's PATH:`,
102
+ ...finding.paths.map((entry) => ` - ${entry}`),
103
+ "Both will fight over the background scheduler, re-registering it against each other.",
104
+ "Keep one. To remove the other:",
105
+ ` npm uninstall -g @bli-cockpit/cli # run it with the node that owns ${finding.other_bin ?? "the other bin"}`,
106
+ "Nothing was uninstalled — that is your call.",
107
+ ].join("\n"));
108
+ }
109
+ export async function doctorRoots(context) {
110
+ if (context.command.repoRoot)
111
+ return [context.command.repoRoot];
112
+ return savedRoots();
113
+ }
114
+ export async function savedRoots() {
115
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths()).catch(() => null);
116
+ return normalizeCollectionRoots(config?.default_repo_paths ?? []);
117
+ }
118
+ function onboardOneLiner(command) {
119
+ const workspace = command.repoRoot ?? "$PWD";
120
+ const dashboard = command.dashboardUrl === DEFAULT_DASHBOARD_URL
121
+ ? ""
122
+ : ` --dashboard-url ${shellQuote(command.dashboardUrl)}`;
123
+ return `cockpit onboard --workspace ${shellQuote(workspace)}${dashboard}`;
124
+ }
125
+ function shellQuote(value) {
126
+ if (value === "$PWD")
127
+ return '"$PWD"';
128
+ return `'${value.replace(/'/gu, "'\\''")}'`;
129
+ }
@@ -0,0 +1,326 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { inspectBackfillLock } from "../backfill-lock.js";
4
+ import { backfillCompletionCovers, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
5
+ import { savedDiscoveryLimitArgs } from "../discovery-limits.js";
6
+ import { describeError } from "../health-detail.js";
7
+ import { runStagingPrune } from "../disk-prune.js";
8
+ import { retentionOptionsFromEnv } from "../disk-retention.js";
9
+ import { readDiskFootprint } from "../disk-usage.js";
10
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "../local-state.js";
11
+ import { runRawEvidenceLocalGc, rawEvidenceGcSummary } from "../raw-evidence-gc.js";
12
+ import { runBackfillCommand } from "./backfill.js";
13
+ import { doctorRoots } from "./doctor-access.js";
14
+ import { asRecord, fail, needsFix, ok, skipped } from "./doctor-report.js";
15
+ /**
16
+ * The `backfill-complete`, `gc-checked`, `disk-bounded`, and `sync-fresh`
17
+ * check family: does the collection pipeline itself have everything it
18
+ * should. These four run in that order because each answers a question the
19
+ * one before it cannot — GC only removes a whole pack once every file in it
20
+ * is committed, disk-bounded reads per-file against the upload ledger
21
+ * (BLI-3619), and sync-fresh is the one check whose "fix" is a live proof
22
+ * (`cockpit sync`) rather than a local computation.
23
+ */
24
+ const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
25
+ /**
26
+ * Pure so it can be unit-tested without touching the real machine's home
27
+ * directory (`getCollectorRuntimePaths()` defaults to `os.homedir()` and
28
+ * doctor never threads `--home` through the backfill steps). Returns `null`
29
+ * when the marker does not cover the roots/sources — the caller falls
30
+ * through to the lock/never-run diagnosis in that case.
31
+ *
32
+ * BLI-2727: a marker whose only outstanding entries are deterministic
33
+ * oversized-file skips is still a completed backfill — it reads green with a
34
+ * named note, never a red `needs_fix`/`fail`, so an unliftable file cap never
35
+ * reads as "backfill never completed" on repeat doctor runs.
36
+ */
37
+ export function backfillCompletionStepState(marker, roots) {
38
+ if (!backfillCompletionCovers(marker, roots, ["codex", "claude_code"])) {
39
+ return null;
40
+ }
41
+ const oversized = marker?.oversized_skips;
42
+ if (oversized && oversized.count > 0) {
43
+ return ok("backfill-complete", "complete_with_oversized_skips", `caught up on old Codex and Claude sessions in every saved folder ` +
44
+ `(complete_with_oversized_skips · ${oversized.count} file${oversized.count === 1 ? "" : "s"} too big to upload)`);
45
+ }
46
+ return ok("backfill-complete", "complete", "caught up on old Codex and Claude sessions in every saved folder");
47
+ }
48
+ export async function checkBackfillState(context) {
49
+ const paths = getCollectorRuntimePaths();
50
+ const roots = await doctorRoots(context);
51
+ const marker = await readBackfillCompletionMarker(paths);
52
+ const covered = backfillCompletionStepState(marker, roots);
53
+ if (covered)
54
+ return covered;
55
+ const lock = await inspectBackfillLock(paths);
56
+ if (lock.held) {
57
+ 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"})`);
58
+ }
59
+ const cursor = await readBackfillCursor(paths);
60
+ return needsFix("backfill-complete", cursor.updated_at ? "partial" : "never_run", "the catch-up over your old sessions has not finished");
61
+ }
62
+ export async function fixBackfillState(context) {
63
+ const capture = capturedIo(context.io, !context.command.json);
64
+ const code = await runBackfillCommand({
65
+ repoRoot: context.command.repoRoot,
66
+ all: true,
67
+ dryRun: false,
68
+ yes: true,
69
+ json: true,
70
+ }, capture.io);
71
+ const output = capture.stdout() + "\n" + capture.stderr();
72
+ if (code === 0) {
73
+ // Re-read the marker this run just wrote instead of hand-rolling a second
74
+ // message: `checkBackfillState`'s pure core already knows how to say
75
+ // "complete" vs "complete_with_oversized_skips" (BLI-2727), and this way
76
+ // the two can never say something different for the same marker.
77
+ const recheck = await checkBackfillState(context);
78
+ if (recheck.status === "ok")
79
+ return recheck;
80
+ return ok("backfill-complete", "completed", "ran `cockpit backfill --all --yes`");
81
+ }
82
+ const reason = jsonField(output, "failure_reason");
83
+ if (reason === "backfill_already_running") {
84
+ return fail("backfill-complete", "backfill_already_running", "backfill lock is still held and no scope-valid completion marker exists");
85
+ }
86
+ return fail("backfill-complete", reason ?? "backfill_failed", "backfill did not complete");
87
+ }
88
+ export async function checkGcState(context) {
89
+ if (context.io.env["COCKPIT_DISABLE_GC"] === "1") {
90
+ return skipped("gc-checked", "skipped_disabled", "cleanup is switched off");
91
+ }
92
+ const paths = getCollectorRuntimePaths();
93
+ const marker = path.join(paths.state_dir, ".last-raw-evidence-gc");
94
+ const info = await fs.stat(marker).catch(() => null);
95
+ if (info && Date.now() - info.mtimeMs < GC_MIN_INTERVAL_MS) {
96
+ return skipped("gc-checked", "skipped_throttled", "cleanup already ran today");
97
+ }
98
+ return needsFix("gc-checked", "due", "cleanup is due");
99
+ }
100
+ export async function fixGcState(context) {
101
+ const result = await runRawEvidenceLocalGc(getCollectorRuntimePaths(), context.io.env);
102
+ if (result.skipped) {
103
+ return skipped("gc-checked", "skipped_throttled", "cleanup already ran today");
104
+ }
105
+ if (result.removed_dirs === 0) {
106
+ return ok("gc-checked", "nothing_eligible", rawEvidenceGcSummary(result));
107
+ }
108
+ return ok("gc-checked", `removed_${result.removed_dirs}`, rawEvidenceGcSummary(result));
109
+ }
110
+ /**
111
+ * BLI-3619: what Cockpit is holding on this machine, in one line.
112
+ *
113
+ * Green means the staging tree fits under the cap. Over the cap is `needs_fix`
114
+ * and the fix is the same prune the tick runs, forced past its daily throttle;
115
+ * if it is still over afterwards, the message names the one command that goes
116
+ * further. Every number here is metadata — byte counts and file counts, never
117
+ * a path.
118
+ */
119
+ export async function checkDiskState(context) {
120
+ const footprint = await readDiskFootprint(getCollectorRuntimePaths(context.command.homeDir));
121
+ const { capBytes } = retentionOptionsFromEnv(context.io.env);
122
+ const message = diskRowMessage(footprint, capBytes);
123
+ return footprint.staging.total_bytes > capBytes
124
+ ? needsFix("disk-bounded", "over_staging_cap", message)
125
+ : ok("disk-bounded", "within_staging_cap", message);
126
+ }
127
+ export async function fixDiskState(context) {
128
+ const paths = getCollectorRuntimePaths(context.command.homeDir);
129
+ const pruned = await runStagingPrune(paths, {
130
+ env: context.io.env,
131
+ force: true,
132
+ });
133
+ if (pruned.status === "fail") {
134
+ return fail("disk-bounded", pruned.reason, "the prune could not run; nothing was deleted");
135
+ }
136
+ if (pruned.status === "skipped") {
137
+ return skipped("disk-bounded", pruned.reason, "cleanup is switched off");
138
+ }
139
+ const footprint = await readDiskFootprint(paths);
140
+ const message = diskRowMessage(footprint, pruned.cap_bytes);
141
+ if (!pruned.cap_blocked_by_uncommitted) {
142
+ return ok("disk-bounded", `freed_${pruned.deleted_files}`, `freed ${mib(pruned.deleted_bytes)} MB; ${message}`);
143
+ }
144
+ // Deliberately still `ok`: staging over the cap because evidence has not been
145
+ // accepted yet is the collector working, not a machine to repair. The row
146
+ // names the blockage and the one command that goes further.
147
+ return ok("disk-bounded", "staging_cap_blocked_by_uncommitted", `freed ${mib(pruned.deleted_bytes)} MB; ${message}; ${pruned.cap_blocked_count} object(s) the upload ledger cannot vouch for are holding the rest — run \`cockpit sync\` to deliver them, or \`cockpit clean --all-committed\` to drop every accepted copy now`);
148
+ }
149
+ function diskRowMessage(footprint, capBytes) {
150
+ const staging = footprint.staging;
151
+ const parts = [
152
+ `staging ${mib(staging.total_bytes)} MB (${mib(staging.committed_bytes)} committed / ${mib(staging.uncommitted_bytes)} uncommitted / ${mib(staging.unknown_bytes)} unknown) against a ${mib(capBytes)} MB cap`,
153
+ ];
154
+ // BLI-3619's second half: "unknown" means the local ledger's own capped
155
+ // memory cannot say, never that delivery failed — and the one command that
156
+ // actually answers it is named right here, not left for a person to find.
157
+ if (staging.unknown_count > 0) {
158
+ parts.push(`${staging.unknown_count} object(s) unknown to this laptop's own ledger — run \`cockpit clean --reconcile\` to ask the server`);
159
+ }
160
+ parts.push(`logs ${mib(footprint.logs.total_bytes)} MB`, `spool ${mib(footprint.spool_bytes)} MB`);
161
+ for (const vault of footprint.vaults) {
162
+ parts.push(`${vault.name} ${mib(vault.byte_size)} MB (a one-off; \`cockpit clean --all-committed\` removes it only if every file in it is accepted)`);
163
+ }
164
+ return parts.join("; ");
165
+ }
166
+ function mib(bytes) {
167
+ return (bytes / (1024 * 1024)).toFixed(1);
168
+ }
169
+ function capturedIo(io, forward) {
170
+ const stdoutChunks = [];
171
+ const stderrChunks = [];
172
+ return {
173
+ io: {
174
+ ...io,
175
+ stdout: captureStream(io.stdout, stdoutChunks, forward),
176
+ stderr: captureStream(io.stderr, stderrChunks, forward),
177
+ },
178
+ stdout: () => stdoutChunks.join(""),
179
+ stderr: () => stderrChunks.join(""),
180
+ };
181
+ }
182
+ function captureStream(target, chunks, forward) {
183
+ return {
184
+ write(chunk, encoding, callback) {
185
+ const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
186
+ chunks.push(text);
187
+ if (forward) {
188
+ if (typeof encoding === "function") {
189
+ target.write(chunk, encoding);
190
+ }
191
+ else {
192
+ target.write(chunk, encoding, callback);
193
+ }
194
+ }
195
+ else if (typeof encoding === "function") {
196
+ encoding();
197
+ }
198
+ else {
199
+ callback?.();
200
+ }
201
+ return true;
202
+ },
203
+ };
204
+ }
205
+ function jsonField(output, field) {
206
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
207
+ const match = output.match(new RegExp(`"${escaped}"\\s*:\\s*"([^"]+)"`, "u"));
208
+ return match?.[1] ?? null;
209
+ }
210
+ export async function checkSyncState(context) {
211
+ const roots = await doctorRoots(context);
212
+ if (roots.length === 0) {
213
+ return needsFix("sync-fresh", "no_roots", "sync has no saved workspace roots");
214
+ }
215
+ // Upload state is currently device-global, so it cannot prove that every
216
+ // saved root succeeded. Doctor therefore performs one explicit sync per root
217
+ // and only turns green from those command receipts.
218
+ return needsFix("sync-fresh", "per_root_verification_required", `fresh upload proof is required for ${roots.length} saved root${roots.length === 1 ? "" : "s"}`);
219
+ }
220
+ /**
221
+ * `cockpit sync --json` prints exactly one JSON document to stdout (stderr is
222
+ * for human text; see AGENTS.md logging conventions), so this is a real parse
223
+ * rather than the doctor module's usual regex field-scrape — which cannot
224
+ * disambiguate same-named fields nested under `codex_sessions.codex` vs
225
+ * `codex_sessions.claude` (BLI-2728).
226
+ */
227
+ function parseDoctorSyncJson(stdout) {
228
+ try {
229
+ const parsed = JSON.parse(stdout.trim());
230
+ return parsed && typeof parsed === "object" ? parsed : null;
231
+ }
232
+ catch (error) {
233
+ // `null` sends doctor back to its regex field-scrape, quietly losing the
234
+ // BLI-2728 disambiguation. Something wrote to stdout that was not the one
235
+ // JSON document the contract promises — a stray console.log in the
236
+ // collector would do exactly this and look like nothing at all.
237
+ console.error("[cockpit-doctor] sync --json stdout was not one JSON document", JSON.stringify({
238
+ reason: "sync_json_unparseable",
239
+ byte_size: stdout.length,
240
+ ...describeError(error),
241
+ }));
242
+ return null;
243
+ }
244
+ }
245
+ /**
246
+ * BLI-2728: a tick that only deferred objects past the per-tick raw-evidence
247
+ * object budget (`RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET`, adapters/raw-evidence.ts)
248
+ * is a backlog that is draining, not a failure — `attributedSyncRunStatus`
249
+ * marks the run not-fully-`ok` (so `cockpit sync` exits non-zero and doctor's
250
+ * exec sees `code !== 0`) purely because objects remain queued, with the
251
+ * per-repo upload itself still having succeeded. A genuine failure (auth,
252
+ * server rejection, network, a real upload_failed outcome, an unposted
253
+ * session report) must still read red — this only fires when NOTHING else in
254
+ * the tick's own summary looks wrong. Pure so it is unit-testable without a
255
+ * live exec/fs harness; the remaining-object count is read straight from the
256
+ * tick's own summary, never recomputed.
257
+ */
258
+ export function syncBacklogDrainingVerdict(parsed) {
259
+ if (!parsed)
260
+ return null;
261
+ const deferredObjects = positiveNumberOrZero(parsed.raw_evidence_deferred_object_budget);
262
+ if (deferredObjects <= 0)
263
+ return null;
264
+ const deferredBytes = positiveNumberOrZero(parsed.raw_evidence_deferred_byte_budget);
265
+ const failedCount = positiveNumberOrZero(parsed.raw_evidence_failed_count);
266
+ const retryReasons = Array.isArray(parsed.raw_evidence_retry_reasons)
267
+ ? parsed.raw_evidence_retry_reasons.length
268
+ : 0;
269
+ const sessions = asRecord(parsed.codex_sessions);
270
+ const reportPosted = sessions?.["report_posted"];
271
+ const codexReadFailures = positiveNumberOrZero(asRecord(sessions?.["codex"])?.["read_failures"]);
272
+ const claudeSessions = asRecord(sessions?.["claude"]);
273
+ const claudeReadFailures = positiveNumberOrZero(claudeSessions?.["read_failures"]);
274
+ const claudeSidecarsFailed = positiveNumberOrZero(claudeSessions?.["sidecars_failed"]);
275
+ const onlyDeferredObjectBudget = deferredBytes === 0 &&
276
+ failedCount === 0 &&
277
+ retryReasons === 0 &&
278
+ reportPosted === true &&
279
+ codexReadFailures === 0 &&
280
+ claudeReadFailures === 0 &&
281
+ claudeSidecarsFailed === 0;
282
+ return onlyDeferredObjectBudget ? { remainingObjects: deferredObjects } : null;
283
+ }
284
+ function positiveNumberOrZero(value) {
285
+ return typeof value === "number" && Number.isFinite(value) && value > 0
286
+ ? value
287
+ : 0;
288
+ }
289
+ export async function fixSyncState(context) {
290
+ const exec = context.io.exec;
291
+ if (!exec)
292
+ return fail("sync-fresh", "runner_unavailable", "sync runner unavailable");
293
+ const roots = await doctorRoots(context);
294
+ if (roots.length === 0) {
295
+ return fail("sync-fresh", "no_roots", "sync has no saved workspace roots");
296
+ }
297
+ // The remembered limits have to ride along, or the doctor's own verification
298
+ // sync scans differently from every other run and can fail closed on a
299
+ // machine the operator already fixed by hand (BLI-2362).
300
+ const discoveryArgs = await savedDiscoveryLimitArgs(context.command.homeDir);
301
+ for (const repoRoot of roots) {
302
+ const args = ["sync", "--json", "--workspace", repoRoot, ...discoveryArgs];
303
+ if (context.command.dashboardUrl !== DEFAULT_DASHBOARD_URL) {
304
+ args.push("--dashboard-url", context.command.dashboardUrl);
305
+ }
306
+ const result = await exec("cockpit", args);
307
+ const parsed = parseDoctorSyncJson(result.stdout);
308
+ const output = `${result.stdout}\n${result.stderr}`;
309
+ const status = jsonField(output, "status");
310
+ if (result.code !== 0) {
311
+ const draining = syncBacklogDrainingVerdict(parsed);
312
+ if (draining) {
313
+ return needsFix("sync-fresh", "backlog_draining", `${repoRoot}: still catching up (${draining.remainingObjects} ` +
314
+ `file${draining.remainingObjects === 1 ? "" : "s"} left for later this run); ` +
315
+ "rerun `cockpit sync` to continue");
316
+ }
317
+ return fail("sync-fresh", status ?? "sync_failed", `sync failed for ${repoRoot}`);
318
+ }
319
+ if (status !== "uploaded") {
320
+ return fail("sync-fresh", status ?? "sync_unverified", `sync did not return an uploaded receipt for ${repoRoot}`);
321
+ }
322
+ }
323
+ return ok("sync-fresh", "synced", roots.length === 1
324
+ ? "ran `cockpit sync` and received an uploaded receipt"
325
+ : `received uploaded receipts for ${roots.length} saved roots`);
326
+ }
@@ -0,0 +1,105 @@
1
+ import { autostartStatus, installAutostartAgent, registeredRuntimePathProblems, } from "../autostart.js";
2
+ import { fail, needsFix, ok, skipped } from "./doctor-report.js";
3
+ import { doctorRoots, savedRoots } from "./doctor-access.js";
4
+ import { installMemoryIntegration, inspectMemoryIntegration, } from "./memory-install.js";
5
+ /**
6
+ * The `autostart-alive` and `memory-registered` check family: registrations
7
+ * this machine should carry with nobody having to ask for them — the
8
+ * background sync scheduler and BLI Memory's MCP/hook wiring. Both fixes
9
+ * write host configuration only; neither installs software.
10
+ */
11
+ export async function checkAutostartState(context) {
12
+ const exec = context.io.exec;
13
+ if (!exec) {
14
+ return needsFix("autostart-alive", "runner_unavailable", "autostart runner unavailable; would refresh autostart");
15
+ }
16
+ const roots = await doctorRoots(context);
17
+ const result = await autostartStatus({
18
+ repoRoot: roots[0],
19
+ repoRoots: roots,
20
+ dashboardUrl: context.command.dashboardUrl,
21
+ exec,
22
+ });
23
+ if (result.status === "loaded") {
24
+ // BLI-3553: "loaded" only means the scheduler accepted the registration.
25
+ // It says nothing about whether the binary that registration names still
26
+ // exists — and `brew upgrade node` deletes exactly that. Ask the
27
+ // filesystem about the paths the PLATFORM holds, not the ones this process
28
+ // happens to be running under.
29
+ const missing = await registeredRuntimePathProblems({ exec });
30
+ if (missing.length > 0) {
31
+ return needsFix("autostart-alive", "runtime_path_missing", `background sync is registered but cannot run: ${missing.join("; ")}`);
32
+ }
33
+ return ok("autostart-alive", "already_installed", "background sync is running");
34
+ }
35
+ if (result.status === "unsupported") {
36
+ return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
37
+ }
38
+ return needsFix("autostart-alive", result.status === "not_loaded" ? "not_loaded" : "absent", "background sync is not running");
39
+ }
40
+ export async function fixAutostartState(context) {
41
+ const exec = context.io.exec;
42
+ if (!exec) {
43
+ return fail("autostart-alive", "runner_unavailable", "autostart runner unavailable");
44
+ }
45
+ const roots = await savedRoots();
46
+ const result = await installAutostartAgent({
47
+ repoRoot: context.command.repoRoot ?? roots[0],
48
+ repoRoots: context.command.repoRoot ? [context.command.repoRoot] : roots,
49
+ dashboardUrl: context.command.dashboardUrl,
50
+ exec,
51
+ });
52
+ if (result.status === "unsupported") {
53
+ return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
54
+ }
55
+ if (result.loaded === false) {
56
+ return fail("autostart-alive", "autostart_load_failed", result.message ?? "operating-system scheduler load failed");
57
+ }
58
+ return ok("autostart-alive", "installed", "background sync installed and running");
59
+ }
60
+ /**
61
+ * BLI Memory's registration on this machine (BLI-3580).
62
+ *
63
+ * The step owns the CONFIG, never the software: it writes the MCP entry, the
64
+ * hooks and the Codex table only when `bli-memory-mcp` actually resolves. With
65
+ * no server it writes NOTHING and reads `skipped bin_missing` — registering
66
+ * hooks that point at an absent binary would make every Claude Code turn print
67
+ * a hook failure, which is worse than waiting a day.
68
+ */
69
+ export async function checkMemoryState(context) {
70
+ const outcome = await inspectMemoryIntegration(memoryCommandFor(context), context.io);
71
+ return memoryStepState(outcome, "check");
72
+ }
73
+ export async function fixMemoryState(context) {
74
+ const outcome = await installMemoryIntegration(memoryCommandFor(context), context.io);
75
+ return memoryStepState(outcome, "fix");
76
+ }
77
+ function memoryCommandFor(context) {
78
+ return {
79
+ kind: "memory",
80
+ action: "install",
81
+ homeDir: context.command.homeDir,
82
+ dashboardUrl: context.command.dashboardUrl,
83
+ dryRun: false,
84
+ json: context.command.json,
85
+ };
86
+ }
87
+ function memoryStepState(outcome, phase) {
88
+ const broken = outcome.targets.filter((target) => target.target !== "bin" && target.status === "failed");
89
+ if (broken.length > 0) {
90
+ const first = broken[0];
91
+ const message = `${first?.target ?? "memory"}: ${first?.reason ?? "failed"}`;
92
+ return phase === "fix"
93
+ ? fail("memory-registered", first?.reason ?? "memory_install_failed", message)
94
+ : needsFix("memory-registered", first?.reason ?? "memory_install_failed", message);
95
+ }
96
+ const pending = outcome.targets.filter((target) => target.target !== "bin" &&
97
+ (target.status === "missing" || target.status === "mismatch"));
98
+ if (pending.length > 0) {
99
+ return needsFix("memory-registered", "registration_incomplete", `BLI Memory is not registered with ${pending.map((target) => target.target).join(", ")}`);
100
+ }
101
+ if (!outcome.bin_found) {
102
+ 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");
103
+ }
104
+ return ok("memory-registered", phase === "fix" ? "installed" : "already_installed", "BLI Memory is registered with both agent hosts");
105
+ }
@@ -0,0 +1,111 @@
1
+ import { redactedHealthDetail } from "../health-detail.js";
2
+ export function ok(id, code, message) {
3
+ return { id, status: "ok", code, message };
4
+ }
5
+ export function skipped(id, code, message) {
6
+ return { id, status: "skipped", code, message };
7
+ }
8
+ export function needsFix(id, code, message) {
9
+ return { id, status: "needs_fix", code, message };
10
+ }
11
+ export function fail(id, code, message) {
12
+ return { id, status: "fail", code, message };
13
+ }
14
+ export function hardStop(id, code, message) {
15
+ return { id, status: "fail", code, message, hardStop: true };
16
+ }
17
+ export function dryRunPreview(state) {
18
+ const preview = { ...state };
19
+ delete preview.hardStop;
20
+ return {
21
+ ...preview,
22
+ status: "needs_fix",
23
+ message: `would fix: ${oneLine(state.message)}`,
24
+ };
25
+ }
26
+ export function isInteractiveDoctorFix(context) {
27
+ return !context.command.json && Boolean(context.io.stdin.isTTY);
28
+ }
29
+ export async function maybeReportDoctorEvents(context, rows) {
30
+ if (context.command.dryRun)
31
+ return;
32
+ await context.deps.reportInstallEvents({
33
+ dashboardUrl: context.command.dashboardUrl,
34
+ command: "doctor",
35
+ events: rows.map(doctorEvent),
36
+ json: context.command.json,
37
+ io: context.io,
38
+ });
39
+ }
40
+ export function writeDoctorOutput(command, io, rows) {
41
+ if (command.json) {
42
+ writeLine(io.stdout, JSON.stringify({
43
+ status: rows.some((row) => row.status === "fail" || row.hardStop)
44
+ ? "blocked"
45
+ : "pass",
46
+ dry_run: command.dryRun,
47
+ steps: rows,
48
+ }, null, 2));
49
+ return;
50
+ }
51
+ writeLine(io.stdout, command.dryRun ? "Tower doctor dry-run" : "Tower doctor");
52
+ // The machine `code` stays in `--json`; a human reading the table wants the
53
+ // sentence, not the label (BLI-3194).
54
+ writeLine(io.stdout, "state step result");
55
+ for (const row of rows) {
56
+ writeLine(io.stdout, `${doctorMark(row)} ${row.id.padEnd(20)} ${oneLine(row.message)}`);
57
+ }
58
+ const explanations = rows.filter((row) => (row.hardStop || row.status === "fail") && row.message.includes("\n"));
59
+ for (const row of explanations) {
60
+ writeLine(io.stderr, "");
61
+ writeLine(io.stderr, `${row.id}:`);
62
+ writeLine(io.stderr, row.message);
63
+ }
64
+ }
65
+ function doctorEvent(row) {
66
+ const status = row.status === "fail" || row.hardStop
67
+ ? "fail"
68
+ : row.status === "skipped"
69
+ ? "skipped"
70
+ : "ok";
71
+ if (status === "ok")
72
+ return { step: row.id, status };
73
+ // BLI-2542. The step already computed why it failed and already printed it to
74
+ // the operator; before this, only the bucket travelled. `autostart-alive`
75
+ // reports two distinct problems at once on Windows, so the detail carries the
76
+ // whole message rather than a first line.
77
+ const detail = redactedHealthDetail(row.message);
78
+ return {
79
+ step: row.id,
80
+ status,
81
+ error_code: sanitizeEventCode(row.code),
82
+ // A detail that only repeats the bucket is noise, not a reason.
83
+ ...(detail && detail !== sanitizeEventCode(row.code)
84
+ ? { error_detail: detail }
85
+ : {}),
86
+ };
87
+ }
88
+ function doctorMark(row) {
89
+ if (row.status === "fail" || row.hardStop)
90
+ return "❌";
91
+ if (row.status === "needs_fix" || row.fixed)
92
+ return "🔧";
93
+ return "✅";
94
+ }
95
+ export function sanitizeEventCode(value) {
96
+ return (value
97
+ .trim()
98
+ .toLowerCase()
99
+ .replace(/[^a-z0-9_]+/gu, "_")
100
+ .replace(/^_+|_+$/gu, "")
101
+ .slice(0, 120) || "unknown");
102
+ }
103
+ export function oneLine(value) {
104
+ return value.split("\n")[0] ?? value;
105
+ }
106
+ export function asRecord(value) {
107
+ return value && typeof value === "object" ? value : null;
108
+ }
109
+ export function writeLine(stream, text) {
110
+ stream.write(`${text}\n`);
111
+ }