@bli-cockpit/cli 0.2.0 → 0.2.2

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.
@@ -5,21 +5,25 @@ import path from "node:path";
5
5
  import { createCollectorServer } from "../server.js";
6
6
  import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
7
7
  import { runBackfillCommand } from "./backfill.js";
8
+ import { runDoctor } from "./doctor.js";
8
9
  import { inspectBackfillLock } from "../backfill-lock.js";
9
10
  import { parseLocalArgs, normalizeUrl } from "./local-args.js";
10
11
  import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
11
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
12
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
12
13
  import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
13
14
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
14
15
  import { backfillCompletionMarkerPath, readBackfillCursor, } from "../cursors/backfill-cursor.js";
15
16
  import { acquireSyncLock } from "../sync-lock.js";
16
17
  import { discoverGitWorktrees } from "../repo-identity.js";
17
18
  import { runAttributedWorktreeSync, } from "./session-sync.js";
18
- import { COLLECTION_ROOT_REQUIRED, resolveOnboardingRoots, } from "../onboarding-roots.js";
19
+ import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
20
+ import { rawEvidenceGcSummary, runRawEvidenceLocalGc, } from "../raw-evidence-gc.js";
19
21
  export const rootCommandNames = new Set([
20
22
  "onboard",
21
23
  "update",
22
24
  "upgrade",
25
+ "do-everything",
26
+ "fix",
23
27
  "install",
24
28
  "login",
25
29
  "pair",
@@ -57,6 +61,11 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
57
61
  return await runOnboard(command, io);
58
62
  case "update":
59
63
  return await runUpdate(command, io);
64
+ case "doctor":
65
+ return await runDoctor(command, io, {
66
+ reportInstallEvents: reportInstallEventsBestEffort,
67
+ selfUpdate: runSelfUpdate,
68
+ });
60
69
  case "login":
61
70
  return await runLogin(command, io);
62
71
  case "logout":
@@ -90,10 +99,12 @@ export function localCommandHelp(command) {
90
99
  if (command)
91
100
  return localSubcommandHelp(command);
92
101
  return [
93
- " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--branch <name>] [--no-auth] [--max-depth <n>] [--max-repos <n>] [--json]",
94
- " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--no-auth] [--json]",
102
+ " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--branch <name>] [--no-auth] [--max-depth <n>] [--max-repos <n>] [--json]",
103
+ " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
95
104
  " cockpit upgrade [same flags as update]",
96
- " cockpit install [--dashboard-url <url>] [--workspace <path>] [--json]",
105
+ " cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--dry-run] [--json]",
106
+ " cockpit fix [same flags as do-everything]",
107
+ " cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
97
108
  " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
98
109
  " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
99
110
  " cockpit logout",
@@ -115,7 +126,7 @@ function localSubcommandHelp(command) {
115
126
  [
116
127
  "onboard",
117
128
  [
118
- "Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--branch <name>] [--no-auth] [--json]",
129
+ "Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--branch <name>] [--no-auth] [--json]",
119
130
  "",
120
131
  "Installs, pairs, starts work context(s), syncs once, and prints readiness proof.",
121
132
  "If --workspace is a parent folder, scans child git repos/worktrees and rolls them up by repo.",
@@ -129,7 +140,7 @@ function localSubcommandHelp(command) {
129
140
  [
130
141
  "install",
131
142
  [
132
- "Usage: cockpit install [--dashboard-url <url>] [--workspace <path>] [--json]",
143
+ "Usage: cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
133
144
  "",
134
145
  "Writes local collector config. Pair with `cockpit login`, then run `cockpit start` when work begins.",
135
146
  "`--repo <path>` remains supported as a backward-compatible alias.",
@@ -139,7 +150,7 @@ function localSubcommandHelp(command) {
139
150
  [
140
151
  "update",
141
152
  [
142
- "Usage: cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--no-auth] [--json]",
153
+ "Usage: cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
143
154
  "",
144
155
  "Updates the global public CLI from npm, then reruns `cockpit onboard`",
145
156
  "with the same setup flags so pairing, saved roots, agent rules,",
@@ -155,6 +166,24 @@ function localSubcommandHelp(command) {
155
166
  "Alias for `cockpit update`.",
156
167
  ],
157
168
  ],
169
+ [
170
+ "do-everything",
171
+ [
172
+ "Usage: cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--dry-run] [--json]",
173
+ "",
174
+ "Converges an already-onboarded intern machine: latest CLI, auth, saved roots, autostart, backfill, raw-evidence GC, and sync freshness.",
175
+ "`cockpit fix` is an alias.",
176
+ "--dry-run prints the checks and would-fix steps without writing config, plists, cursors, or install telemetry.",
177
+ ],
178
+ ],
179
+ [
180
+ "fix",
181
+ [
182
+ "Usage: cockpit fix [same flags as cockpit do-everything]",
183
+ "",
184
+ "Alias for `cockpit do-everything`.",
185
+ ],
186
+ ],
158
187
  [
159
188
  "login",
160
189
  [
@@ -294,49 +323,97 @@ function isLocalHelpRequest(argv) {
294
323
  return argv.length === 2 && (argv[1] === "--help" || argv[1] === "-h");
295
324
  }
296
325
  async function runInstall(command, io) {
297
- const result = await installLocalCollector(command);
326
+ const installEvents = [];
327
+ const finish = async (code) => {
328
+ await reportInstallEventsBestEffort({
329
+ homeDir: command.homeDir,
330
+ dashboardUrl: command.dashboardUrl,
331
+ command: "install",
332
+ events: installEvents,
333
+ json: command.json,
334
+ io,
335
+ });
336
+ return code;
337
+ };
338
+ const resolved = resolveInstallCommandRoots(command);
339
+ if (resolved.homeRootOptIn) {
340
+ addInstallEvent(installEvents, "home_root_optin", "ok");
341
+ }
342
+ const result = await installLocalCollector(resolved.command);
343
+ addInstallEvent(installEvents, "install", "ok");
298
344
  if (command.json) {
299
345
  writeLine(io.stdout, JSON.stringify(result, null, 2));
300
- return 0;
346
+ return finish(0);
301
347
  }
302
348
  writeLine(io.stdout, "Cockpit local collector installed.");
303
349
  writeLine(io.stdout, `Config: ${result.paths.config_file}`);
304
350
  writeLine(io.stdout, `Session: ${result.paths.session_file}`);
305
351
  writeLine(io.stdout, "Auth: missing; upload stays local-only until pairing/login.");
306
352
  writeLine(io.stdout, "Next: run `cockpit login`, then `cockpit start` inside the repo; add `--ticket <id>` only when ticket work begins.");
307
- return 0;
353
+ return finish(0);
354
+ }
355
+ function resolveInstallCommandRoots(command) {
356
+ const detailed = normalizeRootsDetailed([command.repoRoot ?? process.cwd()], {
357
+ homeDir: command.homeDir,
358
+ allowHomeRoot: command.allowHomeRoot,
359
+ });
360
+ if (detailed.roots.length > 0) {
361
+ const root = detailed.roots[0];
362
+ return {
363
+ command: {
364
+ ...command,
365
+ repoRoot: root,
366
+ },
367
+ homeRootOptIn: Boolean(command.allowHomeRoot) &&
368
+ path.resolve(root) === path.resolve(command.homeDir ?? os.homedir()),
369
+ };
370
+ }
371
+ if (detailed.rejected.length > 0) {
372
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${rootRejectionExplanation(detailed.rejected[0], command)}`);
373
+ }
374
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${missingCollectionRootMessage(command)}`);
308
375
  }
309
376
  async function runUpdate(command, io) {
377
+ const installEvents = [];
378
+ const finish = async (code) => {
379
+ await reportInstallEventsBestEffort({
380
+ homeDir: command.homeDir,
381
+ dashboardUrl: command.dashboardUrl,
382
+ command: "update",
383
+ events: installEvents,
384
+ json: command.json,
385
+ io,
386
+ });
387
+ return code;
388
+ };
310
389
  const exec = io.exec ?? defaultExec();
311
- const installArgs = [
312
- "install",
313
- "-g",
314
- "@bli-cockpit/cli@latest",
315
- "--prefer-online",
316
- ];
317
- if (!command.json) {
318
- writeLine(io.stdout, "Updating Cockpit CLI from npm...");
390
+ try {
391
+ await runSelfUpdate(io, { json: command.json });
319
392
  }
320
- const install = await exec("npm", installArgs);
321
- writeExecOutput(io, install, { stdout: !command.json, stderr: true });
322
- if (install.code !== 0) {
393
+ catch (error) {
394
+ if (!(error instanceof SelfUpdateError))
395
+ throw error;
396
+ addInstallEvent(installEvents, "npm_install", "fail", error.eacces
397
+ ? "npm_install_eacces"
398
+ : "npm_install_failed");
323
399
  if (command.json) {
324
400
  writeLine(io.stdout, JSON.stringify({
325
401
  status: "blocked",
326
402
  step: "npm_install",
327
- command: `npm ${installArgs.join(" ")}`,
328
- exit_code: install.code,
403
+ command: `npm ${SELF_UPDATE_INSTALL_ARGS.join(" ")}`,
404
+ exit_code: error.result.code,
329
405
  }, null, 2));
330
406
  }
331
407
  else {
332
408
  writeLine(io.stderr, "BLOCKED: npm install failed; Cockpit CLI was not refreshed.");
333
- if (isNpmEaccesFailure(install.stderr)) {
409
+ if (error.eacces) {
334
410
  writeLine(io.stderr, "Fix Homebrew npm ownership once: sudo chown -R $(whoami) /opt/homebrew/lib/node_modules/@bli-cockpit /opt/homebrew/bin/cockpit");
335
411
  writeLine(io.stderr, "Do not use `sudo npm i -g`; it makes the ownership problem come back.");
336
412
  }
337
413
  }
338
- return install.code || 1;
414
+ return finish(error.result.code || 1);
339
415
  }
416
+ addInstallEvent(installEvents, "npm_install", "ok");
340
417
  if (!command.json) {
341
418
  writeLine(io.stdout, "Cockpit CLI updated. Rechecking onboarding...");
342
419
  }
@@ -345,7 +422,36 @@ async function runUpdate(command, io) {
345
422
  ...updateOnboardArgs(command),
346
423
  ]);
347
424
  writeExecOutput(io, onboard, { stdout: true, stderr: true });
348
- return onboard.code;
425
+ addInstallEvent(installEvents, "onboard_rerun", onboard.code === 0 ? "ok" : "fail", onboard.code === 0 ? undefined : updateOnboardFailureCode(onboard));
426
+ return finish(onboard.code);
427
+ }
428
+ const SELF_UPDATE_INSTALL_ARGS = [
429
+ "install",
430
+ "-g",
431
+ "@bli-cockpit/cli@latest",
432
+ "--prefer-online",
433
+ ];
434
+ export class SelfUpdateError extends Error {
435
+ result;
436
+ eacces;
437
+ constructor(result) {
438
+ super("npm install failed; Cockpit CLI was not refreshed.");
439
+ this.name = "SelfUpdateError";
440
+ this.result = result;
441
+ this.eacces = isNpmEaccesFailure(result.stderr);
442
+ }
443
+ }
444
+ export async function runSelfUpdate(io, options = {}) {
445
+ const exec = io.exec ?? defaultExec();
446
+ if (!options.json) {
447
+ writeLine(io.stdout, "Updating Cockpit CLI from npm...");
448
+ }
449
+ const install = await exec("npm", [...SELF_UPDATE_INSTALL_ARGS]);
450
+ writeExecOutput(io, install, { stdout: !options.json, stderr: true });
451
+ if (install.code !== 0) {
452
+ throw new SelfUpdateError(install);
453
+ }
454
+ return { updated: true, version: LOCAL_COLLECTOR_VERSION };
349
455
  }
350
456
  async function runRelease(command, io) {
351
457
  const releaseRoot = await findPublicReleaseRoot(process.cwd());
@@ -459,6 +565,8 @@ function updateOnboardArgs(command) {
459
565
  args.push("--max-depth", String(command.maxDepth));
460
566
  if (command.maxRepos !== undefined)
461
567
  args.push("--max-repos", String(command.maxRepos));
568
+ if (command.allowHomeRoot)
569
+ args.push("--allow-home-root");
462
570
  if (command.json)
463
571
  args.push("--json");
464
572
  return args;
@@ -466,6 +574,17 @@ function updateOnboardArgs(command) {
466
574
  function isNpmEaccesFailure(stderr) {
467
575
  return /EACCES|permission denied/i.test(stderr);
468
576
  }
577
+ function updateOnboardFailureCode(result) {
578
+ const output = `${result.stdout}\n${result.stderr}`;
579
+ if (output.includes(COLLECTION_ROOT_REQUIRED))
580
+ return COLLECTION_ROOT_REQUIRED;
581
+ if (/pairing|approval|device_pairing/i.test(output))
582
+ return "pairing_timeout";
583
+ if (/sync_blocked|spooled|upload failed|network_or_ingest/i.test(output)) {
584
+ return "sync_blocked";
585
+ }
586
+ return "onboard_rerun_failed";
587
+ }
469
588
  function updateCollectionRoots(command) {
470
589
  const roots = command.collectionRoots?.length
471
590
  ? command.collectionRoots
@@ -482,6 +601,108 @@ function updateCollectionRoots(command) {
482
601
  }
483
602
  return deduped;
484
603
  }
604
+ function addInstallEvent(events, step, status, errorCode) {
605
+ events.push({
606
+ step,
607
+ status,
608
+ ...(errorCode ? { error_code: sanitizeInstallErrorCode(errorCode) } : {}),
609
+ });
610
+ }
611
+ function addOnboardFailureEvent(events, blocker) {
612
+ const step = onboardFailureStep(blocker, events);
613
+ const existing = events.find((event) => event.step === step && event.status === "fail");
614
+ if (existing)
615
+ return;
616
+ addInstallEvent(events, step, "fail", blocker);
617
+ }
618
+ function addAutostartInstallEvent(events, result) {
619
+ if (!result) {
620
+ addInstallEvent(events, "autostart", "skipped", "runner_unavailable");
621
+ return;
622
+ }
623
+ if (result.status === "unsupported") {
624
+ addInstallEvent(events, "autostart", "skipped", "unsupported");
625
+ return;
626
+ }
627
+ addInstallEvent(events, "autostart", result.loaded === false ? "fail" : "ok", result.loaded === false ? "autostart_load_failed" : undefined);
628
+ }
629
+ function onboardFailureStep(blocker, events) {
630
+ if (blocker === COLLECTION_ROOT_REQUIRED)
631
+ return "install";
632
+ if (blocker === "device_pairing")
633
+ return "pair";
634
+ if (blocker === "work_context" || blocker === "ticket_binding" || blocker === "ticket") {
635
+ return "work_context";
636
+ }
637
+ if (blocker === "network_or_ingest")
638
+ return "sync";
639
+ if (blocker === "install")
640
+ return "install";
641
+ const lastIncomplete = ["install", "auth", "pair", "work_context", "sync"].find((step) => !events.some((event) => event.step === step));
642
+ return lastIncomplete ?? "sync";
643
+ }
644
+ function sanitizeInstallErrorCode(value) {
645
+ const normalized = value
646
+ .trim()
647
+ .toLowerCase()
648
+ .replace(/[^a-z0-9_]+/gu, "_")
649
+ .replace(/^_+|_+$/gu, "")
650
+ .slice(0, 120);
651
+ return normalized || "unknown";
652
+ }
653
+ export async function reportInstallEventsBestEffort(options) {
654
+ if (options.events.length === 0)
655
+ return;
656
+ const paths = getCollectorRuntimePaths(options.homeDir);
657
+ const session = await readLocalCollectorSessionFile(paths).catch(() => null);
658
+ if (!session ||
659
+ session.session_state !== "valid" ||
660
+ typeof session.device_token !== "string" ||
661
+ !session.device_token) {
662
+ return;
663
+ }
664
+ const controller = new AbortController();
665
+ const timeout = setTimeout(() => controller.abort(), 5_000);
666
+ try {
667
+ const response = await options.io.fetch(`${options.dashboardUrl}/api/ambient/install-events`, {
668
+ method: "POST",
669
+ headers: {
670
+ "Content-Type": "application/json",
671
+ Authorization: `Bearer ${session.device_token}`,
672
+ },
673
+ body: JSON.stringify({
674
+ cli_version: LOCAL_COLLECTOR_VERSION,
675
+ command: options.command,
676
+ os_platform: os.platform(),
677
+ events: options.events.slice(0, 40),
678
+ }),
679
+ signal: controller.signal,
680
+ });
681
+ if (!response.ok) {
682
+ throw new Error(`http_${response.status}`);
683
+ }
684
+ }
685
+ catch (error) {
686
+ if (options.json) {
687
+ writeLine(options.io.stderr, `Install event telemetry skipped: ${classifyInstallTelemetryError(error)}`);
688
+ }
689
+ }
690
+ finally {
691
+ clearTimeout(timeout);
692
+ }
693
+ }
694
+ function classifyInstallTelemetryError(error) {
695
+ if (error instanceof Error && error.name === "AbortError") {
696
+ return "timeout";
697
+ }
698
+ const message = errorMessage(error);
699
+ const status = message.match(/http_(\d{3})/i)?.[1];
700
+ if (status)
701
+ return `http_${status}`;
702
+ if (/fetch|network|ENOTFOUND|ECONNREFUSED/i.test(message))
703
+ return "network";
704
+ return "failed";
705
+ }
485
706
  function writeExecOutput(io, result, options) {
486
707
  if (options.stdout)
487
708
  writeRaw(io.stdout, result.stdout);
@@ -537,12 +758,29 @@ function onboardingRootPrompt(io) {
537
758
  return {
538
759
  confirm: async (message) => yesByDefault(await readLine(io, message)),
539
760
  input: (message) => readLine(io, message),
761
+ message: (message) => writeLine(io.stdout, message),
540
762
  };
541
763
  }
542
764
  function yesByDefault(raw) {
543
765
  const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
544
766
  return answer !== "n" && answer !== "no";
545
767
  }
768
+ const OTP_CODE_PROMPT = [
769
+ "Email code needed:",
770
+ " Check your latest Cockpit email for a 6- to 10-digit code.",
771
+ "What you can do:",
772
+ " 1) Paste the code here.",
773
+ " 2) No code yet: wait for the resend window, then rerun this command.",
774
+ " 3) Can't use email: rerun with --no-auth for manual approval.",
775
+ "Code: ",
776
+ ].join("\n");
777
+ const OTP_INVALID_MESSAGE = [
778
+ "Email code was not accepted:",
779
+ " Cockpit expects digits only, length 6 to 10.",
780
+ "What you can do:",
781
+ " 1) Rerun and paste the latest email code.",
782
+ " 2) Use manual approval: cockpit onboard --no-auth --email <you@buildlaunchiterate.ca>",
783
+ ].join("\n");
546
784
  async function resolveOnboardEmail(command, roots, config, io) {
547
785
  if (command.claimedOwnerEmail)
548
786
  return command.claimedOwnerEmail;
@@ -576,11 +814,17 @@ async function resolveInteractiveLoginEmail(command, io) {
576
814
  return promptOnboardEmail(io);
577
815
  }
578
816
  async function requestPairingAccessToken(input, io) {
817
+ return (await requestPairingAccessTokenDetailed(input, io)).accessToken;
818
+ }
819
+ async function requestPairingAccessTokenDetailed(input, io) {
579
820
  if (!input.email ||
580
821
  input.noAuth ||
581
822
  input.json ||
582
823
  !isInteractiveStdin(io)) {
583
- return undefined;
824
+ return {
825
+ status: "skipped",
826
+ errorCode: authSkippedReason(input, io),
827
+ };
584
828
  }
585
829
  try {
586
830
  const fetchImpl = io.fetch;
@@ -590,23 +834,43 @@ async function requestPairingAccessToken(input, io) {
590
834
  ? start.resend_after_seconds
591
835
  : 60;
592
836
  writeLine(io.stdout, `Code sent; valid 1h, resend in ${resendAfter}s by rerunning this command.`);
593
- const code = (await readLine(io, "Enter the 6-digit code: ")).trim();
594
- if (!/^\d{6}$/.test(code)) {
595
- throw new Error("OTP code must be 6 digits.");
837
+ const code = (await readLine(io, OTP_CODE_PROMPT)).trim();
838
+ if (!/^\d{6,10}$/.test(code)) {
839
+ throw new Error(OTP_INVALID_MESSAGE);
596
840
  }
597
841
  const verified = await postOtpVerify(fetchImpl, input.dashboardUrl, input.email, code);
598
842
  if (typeof verified.access_token !== "string" || !verified.access_token) {
599
843
  throw new Error("OTP verified but dashboard returned no access token.");
600
844
  }
601
845
  writeLine(io.stdout, `Signed in as ${input.email}.`);
602
- return verified.access_token;
846
+ return { status: "ok", accessToken: verified.access_token };
603
847
  }
604
848
  catch (error) {
605
849
  writeLine(io.stderr, `Auth step skipped: ${errorMessage(error)}`);
606
850
  writeLine(io.stderr, "Continuing with manual dashboard approval.");
607
- return undefined;
851
+ return { status: "fail", errorCode: classifyAuthError(error) };
608
852
  }
609
853
  }
854
+ function authSkippedReason(input, io) {
855
+ if (!input.email)
856
+ return "email_missing";
857
+ if (input.noAuth)
858
+ return "no_auth";
859
+ if (input.json)
860
+ return "json_mode";
861
+ if (!isInteractiveStdin(io))
862
+ return "non_interactive";
863
+ return "auth_skipped";
864
+ }
865
+ function classifyAuthError(error) {
866
+ const message = errorMessage(error);
867
+ if (/\b(otp|code|digit|invalid)\b/i.test(message))
868
+ return "otp_invalid";
869
+ if (/fetch|network|ENOTFOUND|ECONNREFUSED|HTTP/i.test(message)) {
870
+ return "network_or_auth";
871
+ }
872
+ return "auth_failed";
873
+ }
610
874
  async function pairLocalCollectorWithAuthFallback(options, io) {
611
875
  try {
612
876
  return await pairLocalCollector(options);
@@ -790,6 +1054,18 @@ function backgroundSyncLine(result) {
790
1054
  return result.status;
791
1055
  }
792
1056
  async function runOnboard(command, io) {
1057
+ const installEvents = [];
1058
+ const finish = async (code) => {
1059
+ await reportInstallEventsBestEffort({
1060
+ homeDir: command.homeDir,
1061
+ dashboardUrl: command.dashboardUrl,
1062
+ command: "onboard",
1063
+ events: installEvents,
1064
+ json: command.json,
1065
+ io,
1066
+ });
1067
+ return code;
1068
+ };
793
1069
  let install = null;
794
1070
  let pair = null;
795
1071
  let sync = null;
@@ -813,6 +1089,7 @@ async function runOnboard(command, io) {
813
1089
  explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
814
1090
  config: existingConfig,
815
1091
  interactive,
1092
+ allowHomeRoot: command.allowHomeRoot,
816
1093
  prompt: interactive ? onboardingRootPrompt(io) : undefined,
817
1094
  });
818
1095
  const collectionRoots = rootsResult.roots;
@@ -831,6 +1108,9 @@ async function runOnboard(command, io) {
831
1108
  if (!command.json) {
832
1109
  writeLine(io.stdout, `Collecting from: ${collectionRoots.join(", ")}`);
833
1110
  }
1111
+ if (rootsResult.homeRootOptIn) {
1112
+ addInstallEvent(installEvents, "home_root_optin", "ok");
1113
+ }
834
1114
  const claimedOwnerEmail = await resolveOnboardEmail(resolvedCommand, collectionRoots, existingConfig, io);
835
1115
  install = await installLocalCollector({
836
1116
  homeDir: command.homeDir,
@@ -840,6 +1120,7 @@ async function runOnboard(command, io) {
840
1120
  dashboardUrl: command.dashboardUrl,
841
1121
  deviceName: command.deviceName,
842
1122
  });
1123
+ addInstallEvent(installEvents, "install", "ok");
843
1124
  if (!command.json) {
844
1125
  writeLine(io.stdout, "1/5 Installed local collector.");
845
1126
  writeLine(io.stdout, `Config: ${install.paths.config_file}`);
@@ -852,6 +1133,8 @@ async function runOnboard(command, io) {
852
1133
  const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
853
1134
  const canReuseInstalledSession = canReuseOnboardSession(installedSession, claimedOwnerEmail, command.dashboardUrl);
854
1135
  if (installedStatus.session_state === "valid" && canReuseInstalledSession) {
1136
+ addInstallEvent(installEvents, "auth", "skipped", "existing_session");
1137
+ addInstallEvent(installEvents, "pair", "skipped", "existing_session");
855
1138
  if (!command.json) {
856
1139
  writeLine(io.stdout, "2/5 Existing valid device session found; pairing skipped.");
857
1140
  }
@@ -863,12 +1146,13 @@ async function runOnboard(command, io) {
863
1146
  writeLine(io.stdout, "2/5 Existing valid device session does not match requested owner or dashboard; pairing again.");
864
1147
  }
865
1148
  }
866
- const pairingAccessToken = await requestPairingAccessToken({
1149
+ const authResult = await requestPairingAccessTokenDetailed({
867
1150
  dashboardUrl: command.dashboardUrl,
868
1151
  email: claimedOwnerEmail,
869
1152
  noAuth: command.noAuth,
870
1153
  json: command.json,
871
1154
  }, io);
1155
+ addInstallEvent(installEvents, "auth", authResult.status, authResult.errorCode);
872
1156
  pair = await pairLocalCollectorWithAuthFallback({
873
1157
  homeDir: command.homeDir,
874
1158
  dashboardUrl: command.dashboardUrl,
@@ -876,12 +1160,13 @@ async function runOnboard(command, io) {
876
1160
  deviceName: command.deviceName,
877
1161
  pollIntervalMs: command.pollIntervalMs,
878
1162
  timeoutMs: command.timeoutMs,
879
- pairingAccessToken,
1163
+ pairingAccessToken: authResult.accessToken,
880
1164
  fetch: io.fetch,
881
1165
  onPairStarted: command.json
882
1166
  ? undefined
883
1167
  : (request) => writePairingInstructions(io, request),
884
1168
  }, io);
1169
+ addInstallEvent(installEvents, "pair", "ok");
885
1170
  if (!command.json) {
886
1171
  writeLine(io.stdout, "2/5 Device paired.");
887
1172
  writeLine(io.stdout, `User: ${pair.session.email ?? pair.session.auth_subject_id}`);
@@ -891,9 +1176,13 @@ async function runOnboard(command, io) {
891
1176
  const worktrees = await discoverCommandWorktrees(collectionRoots, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
892
1177
  if (worktrees.length > 1) {
893
1178
  const multi = await runMultiRepoOnboard(resolvedCommand, io, worktrees);
1179
+ addInstallEvent(installEvents, "work_context", "ok");
1180
+ addInstallEvent(installEvents, "sync", multi.ok ? "ok" : "fail", multi.ok ? undefined : "sync_blocked");
894
1181
  if (multi.ok) {
895
1182
  agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1183
+ addInstallEvent(installEvents, "agent_rules", "ok");
896
1184
  autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1185
+ addAutostartInstallEvent(installEvents, autostart);
897
1186
  }
898
1187
  if (command.json) {
899
1188
  writeLine(io.stdout, JSON.stringify({
@@ -919,7 +1208,7 @@ async function runOnboard(command, io) {
919
1208
  backfillHint,
920
1209
  });
921
1210
  }
922
- return multi.ok ? 0 : 1;
1211
+ return finish(multi.ok ? 0 : 1);
923
1212
  }
924
1213
  const worktreeRoot = worktrees[0]?.repo_root ?? primaryRoot;
925
1214
  const context = await startLocalWorkContext({
@@ -935,6 +1224,7 @@ async function runOnboard(command, io) {
935
1224
  writeLine(io.stdout, `Ticket: ${context.active_ticket_id ?? "general ambient"}`);
936
1225
  writeLine(io.stdout, `Context: ${context.work_context_id}`);
937
1226
  }
1227
+ addInstallEvent(installEvents, "work_context", "ok");
938
1228
  const run = await runAttributedWorktreeSync({
939
1229
  homeDir: command.homeDir,
940
1230
  dashboardUrl: command.dashboardUrl,
@@ -952,6 +1242,7 @@ async function runOnboard(command, io) {
952
1242
  branch: command.branch,
953
1243
  });
954
1244
  if (sync.status !== "uploaded") {
1245
+ addInstallEvent(installEvents, "sync", "fail", "sync_blocked");
955
1246
  if (command.json) {
956
1247
  writeLine(io.stdout, JSON.stringify({
957
1248
  ...onboardResult("blocked", resolvedCommand, install, pair, sync, status),
@@ -965,11 +1256,14 @@ async function runOnboard(command, io) {
965
1256
  writeLine(io.stderr, `Failure: ${sync.failure_reason}`);
966
1257
  writeLine(io.stderr, `Retry: ${sync.retry_command}`);
967
1258
  }
968
- return 1;
1259
+ return finish(1);
969
1260
  }
1261
+ addInstallEvent(installEvents, "sync", "ok");
970
1262
  if (command.json) {
971
1263
  agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1264
+ addInstallEvent(installEvents, "agent_rules", "ok");
972
1265
  autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1266
+ addAutostartInstallEvent(installEvents, autostart);
973
1267
  writeLine(io.stdout, JSON.stringify({
974
1268
  ...onboardResult("pass", resolvedCommand, install, pair, sync, status),
975
1269
  collection_roots: collectionRoots,
@@ -979,7 +1273,7 @@ async function runOnboard(command, io) {
979
1273
  backfill_hint: backfillHint,
980
1274
  codex_sessions: run.summary,
981
1275
  }, null, 2));
982
- return 0;
1276
+ return finish(0);
983
1277
  }
984
1278
  writeLine(io.stdout, "4/5 Ambient metadata uploaded.");
985
1279
  writeLine(io.stdout, `HTTP: ${sync.http_status}`);
@@ -994,7 +1288,9 @@ async function runOnboard(command, io) {
994
1288
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
995
1289
  writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
996
1290
  agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1291
+ addInstallEvent(installEvents, "agent_rules", "ok");
997
1292
  autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1293
+ addAutostartInstallEvent(installEvents, autostart);
998
1294
  writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
999
1295
  pair,
1000
1296
  status,
@@ -1004,7 +1300,7 @@ async function runOnboard(command, io) {
1004
1300
  initialSyncOk: true,
1005
1301
  backfillHint,
1006
1302
  });
1007
- return 0;
1303
+ return finish(0);
1008
1304
  }
1009
1305
  catch (error) {
1010
1306
  const message = errorMessage(error);
@@ -1017,7 +1313,8 @@ async function runOnboard(command, io) {
1017
1313
  }).catch(() => null)
1018
1314
  : null;
1019
1315
  const blocker = classifyOnboardBlocker(message);
1020
- const nextStep = nextStepForOnboardBlocker(blocker);
1316
+ addOnboardFailureEvent(installEvents, blocker);
1317
+ const nextStep = nextStepForOnboardBlocker(blocker, command);
1021
1318
  if (command.json) {
1022
1319
  writeLine(io.stdout, JSON.stringify({
1023
1320
  ...onboardResult("blocked", command, install, pair, sync, status),
@@ -1032,7 +1329,7 @@ async function runOnboard(command, io) {
1032
1329
  writeLine(io.stderr, `BLOCKED: ${message}`);
1033
1330
  writeLine(io.stderr, `Next: ${nextStep}`);
1034
1331
  }
1035
- return 1;
1332
+ return finish(1);
1036
1333
  }
1037
1334
  }
1038
1335
  function canReuseOnboardSession(session, claimedOwnerEmail, dashboardUrl) {
@@ -1317,10 +1614,10 @@ function classifyOnboardBlocker(message) {
1317
1614
  return "ticket_binding";
1318
1615
  return "unknown";
1319
1616
  }
1320
- function nextStepForOnboardBlocker(blocker) {
1617
+ function nextStepForOnboardBlocker(blocker, options = {}) {
1321
1618
  switch (blocker) {
1322
1619
  case COLLECTION_ROOT_REQUIRED:
1323
- return "Run `cockpit onboard --workspace <path>` or rerun in a terminal and confirm the collection root.";
1620
+ return missingCollectionRootMessage(options);
1324
1621
  case "ticket":
1325
1622
  case "ticket_binding":
1326
1623
  return "Run `cockpit start --ticket <id>` when actual ticket work begins, then run `cockpit sync`.";
@@ -1435,6 +1732,7 @@ async function runSyncLocked(command, io) {
1435
1732
  });
1436
1733
  if (worktrees.length > 1) {
1437
1734
  const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
1735
+ const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
1438
1736
  if (command.json) {
1439
1737
  writeLine(io.stdout, JSON.stringify({
1440
1738
  mode: "multi_repo",
@@ -1442,6 +1740,7 @@ async function runSyncLocked(command, io) {
1442
1740
  results: run.outcomes.map((outcome) => outcome.sync),
1443
1741
  repos: rows,
1444
1742
  codex_sessions: run.summary,
1743
+ raw_evidence_gc: gc,
1445
1744
  }, null, 2));
1446
1745
  return run.ok ? 0 : 1;
1447
1746
  }
@@ -1453,14 +1752,17 @@ async function runSyncLocked(command, io) {
1453
1752
  writeLine(uploaded ? io.stdout : io.stderr, `- ${worktree.repo_label}/${worktree.worktree_label} (${worktree.branch}) head:${shortSha(sync.head_sha ?? worktree.head_sha)} ${sync.status} objects:${sync.raw_evidence_uploaded_object_count} chunks:${sync.raw_evidence_uploaded_chunk_count} reused:${sync.raw_evidence_reused_count} failed:${sync.raw_evidence_failed_count} cursor:${sync.cursor_tracked_object_count}${failureSuffix}`);
1454
1753
  }
1455
1754
  writeAgentSessionSummary(io, run.summary);
1755
+ if (gc && !gc.skipped)
1756
+ writeLine(io.stdout, rawEvidenceGcSummary(gc));
1456
1757
  return run.ok ? 0 : 1;
1457
1758
  }
1458
1759
  const result = run.outcomes[0]?.sync;
1459
1760
  if (!result) {
1460
1761
  throw new Error("Sync produced no result for the repo worktree.");
1461
1762
  }
1763
+ const gc = result.status === "uploaded" ? await runSyncRawEvidenceGc(command, io) : null;
1462
1764
  if (command.json) {
1463
- writeLine(io.stdout, JSON.stringify({ ...result, codex_sessions: run.summary }, null, 2));
1765
+ writeLine(io.stdout, JSON.stringify({ ...result, codex_sessions: run.summary, raw_evidence_gc: gc }, null, 2));
1464
1766
  return result.status === "uploaded" ? 0 : 1;
1465
1767
  }
1466
1768
  if (result.status === "uploaded") {
@@ -1474,6 +1776,8 @@ async function runSyncLocked(command, io) {
1474
1776
  writeLine(io.stdout, rawEvidenceSyncLine(result));
1475
1777
  writeAgentSessionSummary(io, run.summary);
1476
1778
  writeLine(io.stdout, cursorStatusLine(result));
1779
+ if (gc && !gc.skipped)
1780
+ writeLine(io.stdout, rawEvidenceGcSummary(gc));
1477
1781
  return 0;
1478
1782
  }
1479
1783
  writeLine(io.stderr, "Cockpit ambient upload failed; safe retry metadata was spooled.");
@@ -1482,6 +1786,9 @@ async function runSyncLocked(command, io) {
1482
1786
  writeLine(io.stderr, `Retry: ${result.retry_command}`);
1483
1787
  return 1;
1484
1788
  }
1789
+ async function runSyncRawEvidenceGc(command, io) {
1790
+ return runRawEvidenceLocalGc(getCollectorRuntimePaths(command.homeDir), io.env);
1791
+ }
1485
1792
  async function runStatus(command, io) {
1486
1793
  const backfillCursor = await inspectBackfillCursor(command.homeDir);
1487
1794
  const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);