@bli-cockpit/cli 0.2.4 → 0.2.7

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.
@@ -1,10 +1,10 @@
1
- import { execFile, spawn } from "node:child_process";
2
- import { access, readdir, readFile, stat } from "node:fs/promises";
1
+ import { execFile } from "node:child_process";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { createCollectorServer } from "../server.js";
6
6
  import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
7
- import { runBackfillCommand } from "./backfill.js";
7
+ import { backfillRetryCommand, runBackfill, runBackfillCommand, } from "./backfill.js";
8
8
  import { runDoctor } from "./doctor.js";
9
9
  import { inspectBackfillLock } from "../backfill-lock.js";
10
10
  import { parseLocalArgs, normalizeUrl } from "./local-args.js";
@@ -12,12 +12,15 @@ import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from
12
12
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
13
13
  import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
14
14
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
15
- import { backfillCompletionMarkerPath, readBackfillCursor, } from "../cursors/backfill-cursor.js";
15
+ import { backfillCompletionCovers, emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
16
16
  import { acquireSyncLock } from "../sync-lock.js";
17
- import { discoverGitWorktrees } from "../repo-identity.js";
18
- import { runAttributedWorktreeSync, } from "./session-sync.js";
17
+ import { collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
18
+ import { runAttributedWorktreeSync, matchesLiveSyncWorktree, } from "./session-sync.js";
19
19
  import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
20
20
  import { rawEvidenceGcSummary, runRawEvidenceLocalGc, } from "../raw-evidence-gc.js";
21
+ import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
22
+ import { createCapturedExecRunner, createInteractiveExecRunner, } from "../process-runner.js";
23
+ import { normalizeCollectionRoots } from "../root-normalization.js";
21
24
  export const rootCommandNames = new Set([
22
25
  "onboard",
23
26
  "update",
@@ -107,7 +110,7 @@ export function localCommandHelp(command) {
107
110
  " 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]",
108
111
  " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
109
112
  " cockpit upgrade [same flags as update]",
110
- " cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--dry-run] [--json]",
113
+ " cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--update-tag <tag>] [--dry-run] [--json]",
111
114
  " cockpit fix [same flags as do-everything]",
112
115
  " cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
113
116
  " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
@@ -116,7 +119,7 @@ export function localCommandHelp(command) {
116
119
  " cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
117
120
  " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
118
121
  " cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
119
- " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--yes] [--workspace <path>] [--json]",
122
+ " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
120
123
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
121
124
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
122
125
  " cockpit serve [--port <port>] [--workspace <path>]",
@@ -132,9 +135,10 @@ function localSubcommandHelp(command) {
132
135
  [
133
136
  "onboard",
134
137
  [
135
- "Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--branch <name>] [--no-auth] [--json]",
138
+ "Usage: 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]",
136
139
  "",
137
- "Installs, pairs, starts work context(s), syncs once, and prints readiness proof.",
140
+ "Installs, pairs, starts work context(s), syncs once, completes all-history",
141
+ "Codex and Claude backfill for the saved roots, and then prints readiness proof.",
138
142
  "If --workspace is a parent folder, scans child git repos/worktrees and rolls them up by repo.",
139
143
  "`--repo <path>` remains supported as a backward-compatible alias.",
140
144
  `Omit --dashboard-url for normal production setup (${DEFAULT_DASHBOARD_URL}).`,
@@ -159,8 +163,8 @@ function localSubcommandHelp(command) {
159
163
  "Usage: cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
160
164
  "",
161
165
  "Updates the global public CLI from npm, then reruns `cockpit onboard`",
162
- "with the same setup flags so pairing, saved roots, agent rules,",
163
- "autostart, and the initial sync are refreshed in one command.",
166
+ "with the same setup flags so pairing, saved roots, all-history backfill,",
167
+ "agent rules, autostart, and the initial sync are refreshed in one command.",
164
168
  "`cockpit upgrade` is an alias.",
165
169
  ],
166
170
  ],
@@ -175,10 +179,11 @@ function localSubcommandHelp(command) {
175
179
  [
176
180
  "do-everything",
177
181
  [
178
- "Usage: cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--dry-run] [--json]",
182
+ "Usage: cockpit do-everything [--workspace <path>] [--dashboard-url <url>] [--update-tag <tag>] [--dry-run] [--json]",
179
183
  "",
180
- "Converges an already-onboarded intern machine: latest CLI, auth, saved roots, autostart, backfill, raw-evidence GC, and sync freshness.",
184
+ "Converges a blank, existing, or reused intern machine: latest CLI, interactive auth and collection-root recovery when needed, saved roots, autostart, all-history backfill, raw-evidence GC, and sync freshness.",
181
185
  "`cockpit fix` is an alias.",
186
+ "Maintainer canary: use `--update-tag next` so self-update and re-exec stay on the prerelease candidate.",
182
187
  "--dry-run prints the checks and would-fix steps without writing config, plists, cursors, or install telemetry.",
183
188
  ],
184
189
  ],
@@ -254,12 +259,14 @@ function localSubcommandHelp(command) {
254
259
  [
255
260
  "backfill",
256
261
  [
257
- "Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--yes] [--workspace <path>] [--json]",
262
+ "Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
258
263
  "",
259
264
  "Backfills historical Codex and Claude Code session evidence using the saved collection roots from local config when --workspace is omitted.",
260
265
  "Omit --source to scan both sources; there is no --source all literal.",
261
266
  "The non-all window is capped at the collector session paired_at timestamp.",
262
267
  "--all requires a dry-run review and TTY confirmation; pass --yes for headless agent runs.",
268
+ "Repo discovery scans 3 folder levels and up to 50 repos by default.",
269
+ "Raise --max-depth or --max-repos when a partial result reports a discovery cap.",
263
270
  "--dry-run validates the paired collector and dashboard reachability, prints the same summary tables, and writes nothing.",
264
271
  ],
265
272
  ],
@@ -299,12 +306,15 @@ function localSubcommandHelp(command) {
299
306
  [
300
307
  "Usage: cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
301
308
  "",
302
- "Installs a macOS launchd LaunchAgent that runs `cockpit sync` at login and",
303
- "every 15 min (default), surviving reboots — so machines never drift to Stale.",
309
+ "Installs background sync through launchd on Apple Silicon macOS or a",
310
+ "per-user Task Scheduler task on native Windows.",
311
+ "Runs `cockpit sync` every 15 min by default and survives reboots;",
312
+ "the Windows task runs while the user is signed in.",
304
313
  "Action defaults to `install`. `--workspace` is the parent work folder to sync.",
305
314
  "`--repo <path>` remains supported as a backward-compatible alias.",
306
315
  "Omit --dashboard-url for production; pass it only for staging/custom dashboards.",
307
- "macOS-only for now; see docs/runbooks/cockpit-launchd-sync.md.",
316
+ "See docs/runbooks/cockpit-launchd-sync.md and",
317
+ "docs/runbooks/cockpit-windows-task-scheduler-sync.md.",
308
318
  ],
309
319
  ],
310
320
  [
@@ -417,7 +427,7 @@ async function runUpdate(command, io) {
417
427
  writeLine(io.stdout, JSON.stringify({
418
428
  status: "blocked",
419
429
  step: "npm_install",
420
- command: `npm ${SELF_UPDATE_INSTALL_ARGS.join(" ")}`,
430
+ command: `npm ${selfUpdateInstallArgs().join(" ")}`,
421
431
  exit_code: error.result.code,
422
432
  }, null, 2));
423
433
  }
@@ -442,12 +452,6 @@ async function runUpdate(command, io) {
442
452
  addInstallEvent(installEvents, "onboard_rerun", onboard.code === 0 ? "ok" : "fail", onboard.code === 0 ? undefined : updateOnboardFailureCode(onboard));
443
453
  return finish(onboard.code);
444
454
  }
445
- const SELF_UPDATE_INSTALL_ARGS = [
446
- "install",
447
- "-g",
448
- "@bli-cockpit/cli@latest",
449
- "--prefer-online",
450
- ];
451
455
  export class SelfUpdateError extends Error {
452
456
  result;
453
457
  eacces;
@@ -460,16 +464,25 @@ export class SelfUpdateError extends Error {
460
464
  }
461
465
  export async function runSelfUpdate(io, options = {}) {
462
466
  const exec = io.exec ?? defaultExec();
467
+ const tag = options.tag ?? "latest";
463
468
  if (!options.json) {
464
- writeLine(io.stdout, "Updating Cockpit CLI from npm...");
469
+ writeLine(io.stdout, `Updating Cockpit CLI from npm (${tag})...`);
465
470
  }
466
- const install = await exec("npm", [...SELF_UPDATE_INSTALL_ARGS]);
471
+ const install = await exec("npm", selfUpdateInstallArgs(tag));
467
472
  writeExecOutput(io, install, { stdout: !options.json, stderr: true });
468
473
  if (install.code !== 0) {
469
474
  throw new SelfUpdateError(install);
470
475
  }
471
476
  return { updated: true, version: LOCAL_COLLECTOR_VERSION };
472
477
  }
478
+ function selfUpdateInstallArgs(tag = "latest") {
479
+ return [
480
+ "install",
481
+ "-g",
482
+ `@bli-cockpit/cli@${tag}`,
483
+ "--prefer-online",
484
+ ];
485
+ }
473
486
  async function runRelease(command, io) {
474
487
  const releaseRoot = await findPublicReleaseRoot(process.cwd());
475
488
  if (!releaseRoot) {
@@ -643,6 +656,17 @@ function addAutostartInstallEvent(events, result) {
643
656
  }
644
657
  addInstallEvent(events, "autostart", result.loaded === false ? "fail" : "ok", result.loaded === false ? "autostart_load_failed" : undefined);
645
658
  }
659
+ function onboardAutostartFailed(result) {
660
+ return (result !== null &&
661
+ result.status !== "unsupported" &&
662
+ result.loaded === false);
663
+ }
664
+ function writeOnboardAutostartBlocker(io, result) {
665
+ writeLine(io.stderr, "BLOCKED: initial collection succeeded, but recurring background collection is not running.");
666
+ if (result?.message)
667
+ writeLine(io.stderr, `Failure: ${result.message}`);
668
+ writeLine(io.stderr, "Next: run `cockpit autostart install`, then require `cockpit autostart status --json` to exit 0.");
669
+ }
646
670
  function onboardFailureStep(blocker, events) {
647
671
  if (blocker === COLLECTION_ROOT_REQUIRED)
648
672
  return "install";
@@ -671,6 +695,28 @@ export async function reportInstallEventsBestEffort(options) {
671
695
  if (options.events.length === 0)
672
696
  return;
673
697
  const paths = getCollectorRuntimePaths(options.homeDir);
698
+ try {
699
+ await enqueueInstallEventEntry(paths, {
700
+ dashboardUrl: options.dashboardUrl,
701
+ cliVersion: LOCAL_COLLECTOR_VERSION,
702
+ command: options.command,
703
+ osPlatform: os.platform(),
704
+ events: options.events.map((event) => ({
705
+ step: event.step.trim().slice(0, 120),
706
+ status: event.status,
707
+ ...(event.error_code
708
+ ? { error_code: sanitizeInstallErrorCode(event.error_code) }
709
+ : {}),
710
+ ...(event.at ? { at: event.at } : {}),
711
+ })),
712
+ });
713
+ }
714
+ catch {
715
+ if (options.json) {
716
+ writeLine(options.io.stderr, "Install event outbox unavailable: local_write_failed");
717
+ }
718
+ return;
719
+ }
674
720
  const session = await readLocalCollectorSessionFile(paths).catch(() => null);
675
721
  if (!session ||
676
722
  session.session_state !== "valid" ||
@@ -678,34 +724,47 @@ export async function reportInstallEventsBestEffort(options) {
678
724
  !session.device_token) {
679
725
  return;
680
726
  }
681
- const controller = new AbortController();
682
- const timeout = setTimeout(() => controller.abort(), 5_000);
683
- try {
684
- const response = await options.io.fetch(`${options.dashboardUrl}/api/ambient/install-events`, {
685
- method: "POST",
686
- headers: {
687
- "Content-Type": "application/json",
688
- Authorization: `Bearer ${session.device_token}`,
689
- },
690
- body: JSON.stringify({
691
- cli_version: LOCAL_COLLECTOR_VERSION,
692
- command: options.command,
693
- os_platform: os.platform(),
694
- events: options.events.slice(0, 40),
695
- }),
696
- signal: controller.signal,
697
- });
698
- if (!response.ok) {
699
- throw new Error(`http_${response.status}`);
700
- }
701
- }
702
- catch (error) {
703
- if (options.json) {
704
- writeLine(options.io.stderr, `Install event telemetry skipped: ${classifyInstallTelemetryError(error)}`);
705
- }
727
+ const pending = (await readPendingInstallEventEntries(paths)).slice(0, 20);
728
+ const failures = [];
729
+ for (let offset = 0; offset < pending.length; offset += 5) {
730
+ await Promise.all(pending.slice(offset, offset + 5).map(async (entry) => {
731
+ const controller = new AbortController();
732
+ const timeout = setTimeout(() => controller.abort(), 5_000);
733
+ try {
734
+ const response = await options.io.fetch(`${entry.dashboard_url}/api/ambient/install-events`, {
735
+ method: "POST",
736
+ headers: {
737
+ "Content-Type": "application/json",
738
+ Authorization: `Bearer ${session.device_token}`,
739
+ },
740
+ body: JSON.stringify({
741
+ cli_version: entry.cli_version,
742
+ command: entry.command,
743
+ os_platform: entry.os_platform,
744
+ events: entry.events,
745
+ }),
746
+ signal: controller.signal,
747
+ });
748
+ if (!response.ok) {
749
+ throw new Error(`http_${response.status}`);
750
+ }
751
+ await removeInstallEventEntry(paths, entry.outbox_id);
752
+ }
753
+ catch (error) {
754
+ const failureReason = classifyInstallTelemetryError(error);
755
+ failures.push(failureReason);
756
+ await recordInstallEventAttemptFailure(paths, entry, {
757
+ attemptedAt: new Date().toISOString(),
758
+ failureReason,
759
+ }).catch(() => undefined);
760
+ }
761
+ finally {
762
+ clearTimeout(timeout);
763
+ }
764
+ }));
706
765
  }
707
- finally {
708
- clearTimeout(timeout);
766
+ if (options.json && failures.length > 0) {
767
+ writeLine(options.io.stderr, `Install event telemetry queued for retry: ${[...new Set(failures)].join(",")}`);
709
768
  }
710
769
  }
711
770
  function classifyInstallTelemetryError(error) {
@@ -987,11 +1046,7 @@ async function readGitConfigEmail(root) {
987
1046
  });
988
1047
  });
989
1048
  }
990
- /**
991
- * After a successful onboard, refreshes the launchd autostart agent so intern
992
- * machines keep syncing without anyone re-running Cockpit. Runtimes without an
993
- * exec runner skip this because launchd installation cannot be attempted there.
994
- */
1049
+ /** Refreshes the host autostart backend after successful onboarding. */
995
1050
  async function refreshOnboardAutostart(command, roots, io) {
996
1051
  if (!io.exec)
997
1052
  return null;
@@ -1010,8 +1065,8 @@ async function refreshOnboardAutostart(command, roots, io) {
1010
1065
  }
1011
1066
  writeLine(io.stdout, result.loaded
1012
1067
  ? "Background autostart refreshed; Cockpit syncs at login and every 15 min."
1013
- : "Background autostart refreshed, but launchctl load reported a problem; check `cockpit autostart status`.");
1014
- writeLine(io.stdout, `Plist: ${result.plist_path}`);
1068
+ : "Background autostart refreshed, but the operating-system scheduler reported a problem; check `cockpit autostart status`.");
1069
+ writeLine(io.stdout, autostartLocationLine(result));
1015
1070
  return result;
1016
1071
  }
1017
1072
  async function refreshOnboardAgentRules(command, roots, io) {
@@ -1052,21 +1107,66 @@ function writeOnboardLiveStatus(io, command, roots, options) {
1052
1107
  (options.status?.session_state === "valid" ? "existing valid session" : "pending")}`);
1053
1108
  writeLine(io.stdout, `Background sync: ${backgroundSyncLine(options.autostart)}`);
1054
1109
  writeLine(io.stdout, `Initial sync: ${options.initialSyncOk ? options.sync?.status ?? "uploaded" : "blocked"}`);
1110
+ writeLine(io.stdout, `Historical backfill: complete (${options.backfill.counts.backfilled} uploaded, ${options.backfill.counts.skipped} explicit skip(s))`);
1055
1111
  writeLine(io.stdout, `Agent rules: ${options.agentRules ? onboardAgentRulesInstallLine(options.agentRules) : "not refreshed"}`);
1056
1112
  writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}/my-work`);
1057
- if (options.backfillHint)
1058
- writeLine(io.stdout, options.backfillHint);
1059
1113
  writeLine(io.stdout, "Next: cockpit status");
1060
1114
  }
1115
+ function requiredOnboardBackfillCommand(command) {
1116
+ return {
1117
+ homeDir: command.homeDir,
1118
+ all: true,
1119
+ dryRun: false,
1120
+ maxDepth: command.maxDepth,
1121
+ maxRepos: command.maxRepos,
1122
+ yes: true,
1123
+ json: true,
1124
+ };
1125
+ }
1126
+ async function runRequiredOnboardBackfill(command, io) {
1127
+ const backfillCommand = requiredOnboardBackfillCommand(command);
1128
+ const retryCommand = backfillRetryCommand(backfillCommand);
1129
+ try {
1130
+ const result = await runBackfill(backfillCommand, io);
1131
+ return {
1132
+ status: result.status,
1133
+ failureReason: result.status === "complete"
1134
+ ? null
1135
+ : result.failure_reason ?? `backfill_${result.status}`,
1136
+ retryCommand: result.retry_command,
1137
+ result,
1138
+ };
1139
+ }
1140
+ catch (error) {
1141
+ return {
1142
+ status: "failed",
1143
+ failureReason: errorMessage(error),
1144
+ retryCommand,
1145
+ result: null,
1146
+ };
1147
+ }
1148
+ }
1149
+ function onboardBackfillPayload(outcome) {
1150
+ return (outcome.result ?? {
1151
+ status: "blocked",
1152
+ failure_reason: outcome.failureReason,
1153
+ retry_command: outcome.retryCommand,
1154
+ });
1155
+ }
1156
+ function writeOnboardBackfillBlocker(io, outcome) {
1157
+ writeLine(io.stderr, "BLOCKED: all-history Codex and Claude backfill is incomplete for the approved collection roots.");
1158
+ writeLine(io.stderr, `Failure: ${outcome.failureReason ?? `backfill_${outcome.status}`}`);
1159
+ writeLine(io.stderr, `Retry: ${outcome.retryCommand}`);
1160
+ }
1061
1161
  function backgroundSyncLine(result) {
1062
1162
  if (!result)
1063
- return "not refreshed (launchd runner unavailable)";
1163
+ return "not refreshed (autostart runner unavailable)";
1064
1164
  if (result.status === "unsupported")
1065
1165
  return `unsupported (${result.message})`;
1066
1166
  if (result.status === "installed" && result.loaded)
1067
1167
  return "installed";
1068
1168
  if (result.status === "installed") {
1069
- return `installed with warning (${result.message ?? "launchctl load failed"})`;
1169
+ return `installed with warning (${result.message ?? "operating-system scheduler load failed"})`;
1070
1170
  }
1071
1171
  return result.status;
1072
1172
  }
@@ -1148,18 +1248,17 @@ async function runOnboard(command, io) {
1148
1248
  let rootsResult = null;
1149
1249
  let agentRules = null;
1150
1250
  let autostart = null;
1151
- let backfillHint = null;
1152
1251
  try {
1153
1252
  if (!command.json) {
1154
1253
  writeLine(io.stdout, "Cockpit harvest onboarding");
1155
1254
  writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
1156
1255
  writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
1157
1256
  }
1158
- backfillHint = await onboardBackfillHint(command.homeDir);
1159
1257
  const resolvedRoots = await resolveOnboardingRootsForCommand(command, io);
1160
1258
  const existingConfig = resolvedRoots.existingConfig;
1161
1259
  rootsResult = resolvedRoots.rootsResult;
1162
1260
  const collectionRoots = rootsResult.roots;
1261
+ const attributionCollectionRoots = await collectionRootConsentAliases(collectionRoots);
1163
1262
  const primaryRoot = resolvedRoots.primaryRoot;
1164
1263
  const resolvedCommand = {
1165
1264
  ...command,
@@ -1227,31 +1326,81 @@ async function runOnboard(command, io) {
1227
1326
  writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
1228
1327
  }
1229
1328
  }
1230
- const worktrees = await discoverCommandWorktrees(collectionRoots, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
1329
+ const worktrees = await discoverCommandWorktrees(collectionRoots, {
1330
+ maxDepth: command.maxDepth,
1331
+ maxRepos: command.maxRepos,
1332
+ allowEmpty: true,
1333
+ }, io);
1231
1334
  if (worktrees.length > 1) {
1232
1335
  const multi = await runMultiRepoOnboard(resolvedCommand, io, worktrees);
1233
1336
  addInstallEvent(installEvents, "work_context", "ok");
1234
1337
  addInstallEvent(installEvents, "sync", multi.ok ? "ok" : "fail", multi.ok ? undefined : "sync_blocked");
1235
- if (multi.ok) {
1236
- agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1237
- addInstallEvent(installEvents, "agent_rules", "ok");
1238
- autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1239
- addAutostartInstallEvent(installEvents, autostart);
1338
+ if (!multi.ok) {
1339
+ if (command.json) {
1340
+ writeLine(io.stdout, JSON.stringify({
1341
+ ...onboardResult("blocked", resolvedCommand, install, pair, null, null),
1342
+ collection_roots: collectionRoots,
1343
+ root_resolution: rootsResult,
1344
+ mode: "multi_repo",
1345
+ repos: multi.results,
1346
+ codex_sessions: multi.codex_sessions,
1347
+ blocker: "sync_blocked",
1348
+ next_step: "Run `cockpit sync --json` until every root returns an uploaded receipt.",
1349
+ }, null, 2));
1350
+ }
1351
+ return finish(1);
1240
1352
  }
1353
+ const backfill = await runRequiredOnboardBackfill(resolvedCommand, io);
1354
+ const completedBackfill = backfill.status === "complete" ? backfill.result : null;
1355
+ addInstallEvent(installEvents, "backfill", completedBackfill ? "ok" : "fail", completedBackfill
1356
+ ? undefined
1357
+ : backfill.failureReason ?? "backfill_incomplete");
1358
+ if (!completedBackfill) {
1359
+ if (command.json) {
1360
+ writeLine(io.stdout, JSON.stringify({
1361
+ ...onboardResult("blocked", resolvedCommand, install, pair, null, null),
1362
+ collection_roots: collectionRoots,
1363
+ root_resolution: rootsResult,
1364
+ mode: "multi_repo",
1365
+ repos: multi.results,
1366
+ codex_sessions: multi.codex_sessions,
1367
+ backfill: onboardBackfillPayload(backfill),
1368
+ blocker: "backfill_incomplete",
1369
+ backfill_failure_reason: backfill.failureReason,
1370
+ next_step: backfill.retryCommand,
1371
+ }, null, 2));
1372
+ }
1373
+ else {
1374
+ writeOnboardBackfillBlocker(io, backfill);
1375
+ }
1376
+ return finish(1);
1377
+ }
1378
+ agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1379
+ addInstallEvent(installEvents, "agent_rules", "ok");
1380
+ autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1381
+ addAutostartInstallEvent(installEvents, autostart);
1382
+ const onboardOk = !onboardAutostartFailed(autostart);
1241
1383
  if (command.json) {
1242
1384
  writeLine(io.stdout, JSON.stringify({
1243
- ...onboardResult(multi.ok ? "pass" : "blocked", resolvedCommand, install, pair, null, null),
1385
+ ...onboardResult(onboardOk ? "pass" : "blocked", resolvedCommand, install, pair, null, null),
1244
1386
  collection_roots: collectionRoots,
1245
1387
  root_resolution: rootsResult,
1246
1388
  agent_rules: agentRules,
1247
1389
  autostart,
1248
- backfill_hint: backfillHint,
1390
+ backfill: completedBackfill,
1249
1391
  mode: "multi_repo",
1250
1392
  repos: multi.results,
1251
1393
  codex_sessions: multi.codex_sessions,
1394
+ ...(!onboardOk && multi.ok
1395
+ ? {
1396
+ blocker: "autostart_load_failed",
1397
+ next_step: "Run `cockpit autostart install`, then require `cockpit autostart status --json` to exit 0.",
1398
+ }
1399
+ : {}),
1252
1400
  }, null, 2));
1253
1401
  }
1254
- if (multi.ok && !command.json) {
1402
+ if (onboardOk && !command.json) {
1403
+ writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
1255
1404
  writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
1256
1405
  pair,
1257
1406
  status,
@@ -1259,10 +1408,13 @@ async function runOnboard(command, io) {
1259
1408
  agentRules,
1260
1409
  autostart,
1261
1410
  initialSyncOk: true,
1262
- backfillHint,
1411
+ backfill: completedBackfill,
1263
1412
  });
1264
1413
  }
1265
- return finish(multi.ok ? 0 : 1);
1414
+ else if (multi.ok && !command.json) {
1415
+ writeOnboardAutostartBlocker(io, autostart);
1416
+ }
1417
+ return finish(onboardOk ? 0 : 1);
1266
1418
  }
1267
1419
  const worktreeRoot = worktrees[0]?.repo_root ?? primaryRoot;
1268
1420
  const context = await startLocalWorkContext({
@@ -1282,6 +1434,7 @@ async function runOnboard(command, io) {
1282
1434
  const run = await runAttributedWorktreeSync({
1283
1435
  homeDir: command.homeDir,
1284
1436
  dashboardUrl: command.dashboardUrl,
1437
+ collectionRoots: attributionCollectionRoots,
1285
1438
  startContexts: false,
1286
1439
  worktrees,
1287
1440
  fetchImpl: io.fetch,
@@ -1295,39 +1448,76 @@ async function runOnboard(command, io) {
1295
1448
  repoRoot: worktreeRoot,
1296
1449
  branch: command.branch,
1297
1450
  });
1298
- if (sync.status !== "uploaded") {
1451
+ if (!run.ok || sync.status !== "uploaded") {
1299
1452
  addInstallEvent(installEvents, "sync", "fail", "sync_blocked");
1453
+ const runStatus = attributedSyncRunStatus(run);
1300
1454
  if (command.json) {
1301
1455
  writeLine(io.stdout, JSON.stringify({
1302
1456
  ...onboardResult("blocked", resolvedCommand, install, pair, sync, status),
1303
1457
  collection_roots: collectionRoots,
1304
1458
  root_resolution: rootsResult,
1305
1459
  codex_sessions: run.summary,
1460
+ collection_status: runStatus,
1306
1461
  }, null, 2));
1307
1462
  }
1308
1463
  else {
1309
- writeLine(io.stderr, "BLOCKED: ambient upload failed; safe retry metadata was spooled.");
1310
- writeLine(io.stderr, `Failure: ${sync.failure_reason}`);
1311
- writeLine(io.stderr, `Retry: ${sync.retry_command}`);
1464
+ const syncFailureReason = sync.status === "spooled" ? sync.failure_reason : null;
1465
+ const retryCommand = sync.status === "spooled"
1466
+ ? sync.retry_command
1467
+ : `cockpit sync --workspace ${JSON.stringify(worktreeRoot)}`;
1468
+ writeLine(io.stderr, "BLOCKED: initial collection is incomplete; retry until every eligible session has a durable receipt or explicit terminal reason.");
1469
+ writeLine(io.stderr, `Failure: ${syncFailureReason ?? (run.summary.report_posted ? runStatus : run.summary.report_reason)}`);
1470
+ writeAgentSessionSummary(io, run.summary);
1471
+ writeLine(io.stderr, `Retry: ${retryCommand}`);
1312
1472
  }
1313
1473
  return finish(1);
1314
1474
  }
1315
1475
  addInstallEvent(installEvents, "sync", "ok");
1476
+ const backfill = await runRequiredOnboardBackfill(resolvedCommand, io);
1477
+ const completedBackfill = backfill.status === "complete" ? backfill.result : null;
1478
+ addInstallEvent(installEvents, "backfill", completedBackfill ? "ok" : "fail", completedBackfill
1479
+ ? undefined
1480
+ : backfill.failureReason ?? "backfill_incomplete");
1481
+ if (!completedBackfill) {
1482
+ if (command.json) {
1483
+ writeLine(io.stdout, JSON.stringify({
1484
+ ...onboardResult("blocked", resolvedCommand, install, pair, sync, status),
1485
+ collection_roots: collectionRoots,
1486
+ root_resolution: rootsResult,
1487
+ codex_sessions: run.summary,
1488
+ backfill: onboardBackfillPayload(backfill),
1489
+ blocker: "backfill_incomplete",
1490
+ backfill_failure_reason: backfill.failureReason,
1491
+ next_step: backfill.retryCommand,
1492
+ }, null, 2));
1493
+ }
1494
+ else {
1495
+ writeOnboardBackfillBlocker(io, backfill);
1496
+ }
1497
+ return finish(1);
1498
+ }
1316
1499
  if (command.json) {
1317
1500
  agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1318
1501
  addInstallEvent(installEvents, "agent_rules", "ok");
1319
1502
  autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1320
1503
  addAutostartInstallEvent(installEvents, autostart);
1504
+ const onboardOk = !onboardAutostartFailed(autostart);
1321
1505
  writeLine(io.stdout, JSON.stringify({
1322
- ...onboardResult("pass", resolvedCommand, install, pair, sync, status),
1506
+ ...onboardResult(onboardOk ? "pass" : "blocked", resolvedCommand, install, pair, sync, status),
1323
1507
  collection_roots: collectionRoots,
1324
1508
  root_resolution: rootsResult,
1325
1509
  agent_rules: agentRules,
1326
1510
  autostart,
1327
- backfill_hint: backfillHint,
1511
+ backfill: completedBackfill,
1328
1512
  codex_sessions: run.summary,
1513
+ ...(!onboardOk
1514
+ ? {
1515
+ blocker: "autostart_load_failed",
1516
+ next_step: "Run `cockpit autostart install`, then require `cockpit autostart status --json` to exit 0.",
1517
+ }
1518
+ : {}),
1329
1519
  }, null, 2));
1330
- return finish(0);
1520
+ return finish(onboardOk ? 0 : 1);
1331
1521
  }
1332
1522
  writeLine(io.stdout, "4/5 Ambient metadata uploaded.");
1333
1523
  writeLine(io.stdout, `HTTP: ${sync.http_status}`);
@@ -1339,12 +1529,16 @@ async function runOnboard(command, io) {
1339
1529
  writeAgentSessionSummary(io, run.summary);
1340
1530
  writeLine(io.stdout, "5/5 Status ready.");
1341
1531
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
1342
- writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
1343
1532
  writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
1344
1533
  agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1345
1534
  addInstallEvent(installEvents, "agent_rules", "ok");
1346
1535
  autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1347
1536
  addAutostartInstallEvent(installEvents, autostart);
1537
+ if (onboardAutostartFailed(autostart)) {
1538
+ writeOnboardAutostartBlocker(io, autostart);
1539
+ return finish(1);
1540
+ }
1541
+ writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
1348
1542
  writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
1349
1543
  pair,
1350
1544
  status,
@@ -1352,7 +1546,7 @@ async function runOnboard(command, io) {
1352
1546
  agentRules,
1353
1547
  autostart,
1354
1548
  initialSyncOk: true,
1355
- backfillHint,
1549
+ backfill: completedBackfill,
1356
1550
  });
1357
1551
  return finish(0);
1358
1552
  }
@@ -1420,7 +1614,8 @@ function shortSha(value) {
1420
1614
  return value ? value.slice(0, 12) : "unknown";
1421
1615
  }
1422
1616
  function sourceFunnelLine(label, counts) {
1423
- return `${label} sessions: attributed ${counts.attributed}, ambiguous ${counts.ambiguous}, unattributed ${counts.unattributed}, skipped ${counts.skipped}, stale ${counts.stale}`;
1617
+ const readFailures = counts.read_failures > 0 ? `, read_failures ${counts.read_failures}` : "";
1618
+ return `${label} sessions: attributed ${counts.attributed}, fallback ${counts.attributed_fallback}, ambiguous ${counts.ambiguous}, unattributed ${counts.unattributed}, skipped ${counts.skipped}, stale ${counts.stale}${readFailures}`;
1424
1619
  }
1425
1620
  function attributionReportLine(summary) {
1426
1621
  return summary.report_posted
@@ -1475,7 +1670,10 @@ function rawEvidenceSyncLine(sync) {
1475
1670
  const failures = sync.raw_evidence_failure_reasons.length > 0
1476
1671
  ? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
1477
1672
  : "";
1478
- return `Raw evidence: uploaded ${sync.raw_evidence_uploaded_object_count} object(s) in ${sync.raw_evidence_uploaded_chunk_count} chunk(s), reused ${sync.raw_evidence_reused_count}, failed ${sync.raw_evidence_failed_count}${failures}`;
1673
+ const retries = sync.raw_evidence_retry_reasons.length > 0
1674
+ ? ` retry_required: ${sync.raw_evidence_retry_reasons.join(",")}`
1675
+ : "";
1676
+ return `Raw evidence: uploaded ${sync.raw_evidence_uploaded_object_count} object(s) in ${sync.raw_evidence_uploaded_chunk_count} chunk(s), reused ${sync.raw_evidence_reused_count}, failed ${sync.raw_evidence_failed_count}${failures}${retries}`;
1479
1677
  }
1480
1678
  function cursorStatusLine(sync) {
1481
1679
  return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
@@ -1485,75 +1683,69 @@ const DEFAULT_DISCOVERY_MAX_REPOS = 50;
1485
1683
  const ALL_SESSION_SCAN_WINDOW_MINUTES = 20 * 365 * 24 * 60;
1486
1684
  const SESSION_SCAN_OVERRIDE_LIMIT = 10_000;
1487
1685
  async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
1488
- if (Array.isArray(repoRoot)) {
1489
- const discovered = [];
1490
- for (const root of repoRoot) {
1491
- discovered.push(...(await discoverCommandWorktrees(root, discovery, io)));
1492
- }
1493
- return dedupeWorktrees(discovered);
1494
- }
1495
1686
  const maxWorktrees = discovery.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS;
1496
- const worktrees = await discoverGitWorktrees(repoRoot ?? process.cwd(), {
1687
+ const roots = Array.isArray(repoRoot)
1688
+ ? repoRoot
1689
+ : [repoRoot ?? process.cwd()];
1690
+ const result = await discoverGitWorktreesInRootsWithStatus(roots, {
1497
1691
  maxDepth: discovery.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
1498
1692
  maxWorktrees,
1499
1693
  });
1500
- if (worktrees.length === 0) {
1501
- throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
1694
+ const worktrees = result.worktrees;
1695
+ if (!result.complete) {
1696
+ const reasons = result.incomplete_reasons.join(",");
1697
+ if (io) {
1698
+ writeLine(io.stderr, `BLOCKED: repo discovery is incomplete (${reasons}); no collection cursor was advanced.`);
1699
+ }
1700
+ throw new Error(`repo discovery incomplete: ${reasons}. Raise --max-depth or --max-repos until every approved-root repo is included.`);
1502
1701
  }
1503
- if (io && worktrees.length >= maxWorktrees) {
1504
- writeLine(io.stderr, `Warning: repo discovery hit the cap of ${maxWorktrees}; additional repos may be excluded. Raise --max-repos if this folder really holds more.`);
1702
+ if (worktrees.length === 0 && !discovery.allowEmpty) {
1703
+ throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
1505
1704
  }
1506
1705
  return worktrees;
1507
1706
  }
1508
- function dedupeWorktrees(worktrees) {
1509
- const seen = new Set();
1510
- const deduped = [];
1511
- for (const worktree of worktrees) {
1512
- const key = worktree.worktree_fingerprint || path.resolve(worktree.repo_root);
1513
- if (seen.has(key))
1514
- continue;
1515
- seen.add(key);
1516
- deduped.push(worktree);
1517
- }
1518
- return deduped;
1519
- }
1520
1707
  async function runMultiRepoOnboard(command, io, worktrees) {
1521
1708
  if (!command.json) {
1522
1709
  writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
1523
1710
  }
1711
+ const collectionRoots = await collectionRootConsentAliases(command.collectionRoots ??
1712
+ (command.repoRoot ? [command.repoRoot] : []));
1524
1713
  const run = await runAttributedWorktreeSync({
1525
1714
  homeDir: command.homeDir,
1526
1715
  dashboardUrl: command.dashboardUrl,
1527
1716
  branch: command.branch,
1528
1717
  activeTicketId: command.activeTicketId,
1718
+ collectionRoots,
1529
1719
  startContexts: true,
1530
1720
  worktrees,
1531
1721
  fetchImpl: io.fetch,
1532
1722
  });
1533
1723
  const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
1724
+ const runStatus = attributedSyncRunStatus(run);
1534
1725
  if (!command.json) {
1535
1726
  for (const [index, outcome] of run.outcomes.entries()) {
1536
1727
  const row = results[index];
1537
1728
  if (!row)
1538
1729
  continue;
1539
1730
  const uploaded = outcome.sync.status === "uploaded";
1540
- writeLine(uploaded ? io.stdout : io.stderr, `${uploaded ? "PASS" : "BLOCKED"} ${row["repo_label"]}/${row["worktree_label"]} (${row["branch"]}) head:${shortSha(outcome.sync.head_sha ?? outcome.worktree.head_sha)} ${outcome.sync.status} objects:${outcome.sync.raw_evidence_uploaded_object_count} chunks:${outcome.sync.raw_evidence_uploaded_chunk_count} reused:${outcome.sync.raw_evidence_reused_count} failed:${outcome.sync.raw_evidence_failed_count} sessions:${row["attributed_session_count"]}${row["failure_reason"] ? ` reason:${row["failure_reason"]}` : ""}`);
1731
+ writeLine(uploaded ? io.stdout : io.stderr, `${uploaded ? "SYNCED" : "BLOCKED"} ${row["repo_label"]}/${row["worktree_label"]} (${row["branch"]}) head:${shortSha(outcome.sync.head_sha ?? outcome.worktree.head_sha)} ${outcome.sync.status} objects:${outcome.sync.raw_evidence_uploaded_object_count} chunks:${outcome.sync.raw_evidence_uploaded_chunk_count} reused:${outcome.sync.raw_evidence_reused_count} failed:${outcome.sync.raw_evidence_failed_count} sessions:${row["attributed_session_count"]}${row["failure_reason"] ? ` reason:${row["failure_reason"]}` : ""}`);
1541
1732
  }
1542
1733
  writeLine(io.stdout, "4/5 Parent worktree sync complete.");
1543
1734
  writeLine(io.stdout, `Uploaded: ${results.filter((row) => row["upload_status"] === "uploaded").length}/${results.length}`);
1544
1735
  writeAgentSessionSummary(io, run.summary);
1545
- writeLine(io.stdout, "5/5 Status ready.");
1546
- writeLine(run.ok ? io.stdout : io.stderr, run.ok
1547
- ? "PASS: Cockpit collector is ready for harvest."
1548
- : "BLOCKED: One or more worktree uploads were spooled.");
1736
+ if (run.ok) {
1737
+ writeLine(io.stdout, "Live sync receipts complete; verifying all-history Codex and Claude backfill.");
1738
+ }
1739
+ else {
1740
+ writeLine(io.stderr, `BLOCKED: Cockpit collection is ${runStatus}; retry until every eligible session has a durable receipt or explicit terminal reason.`);
1741
+ }
1549
1742
  writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
1550
1743
  }
1551
1744
  return { ok: run.ok, results, codex_sessions: run.summary };
1552
1745
  }
1553
1746
  function worktreeSyncRow(outcome, run) {
1554
1747
  const { worktree, context, sync } = outcome;
1555
- const matchesWorktree = (result) => result.state === "attributed" &&
1556
- result.worktree?.worktree_fingerprint === worktree.worktree_fingerprint;
1748
+ const matchesWorktree = (result) => matchesLiveSyncWorktree(result, worktree);
1557
1749
  const codexSessionCount = run.codexAttribution.results.filter(matchesWorktree).length;
1558
1750
  const claudeSessionCount = run.claudeAttribution.results.filter(matchesWorktree).length;
1559
1751
  const attributedSessionCount = codexSessionCount + claudeSessionCount;
@@ -1572,6 +1764,8 @@ function worktreeSyncRow(outcome, run) {
1572
1764
  raw_evidence_reused_count: sync.raw_evidence_reused_count,
1573
1765
  raw_evidence_failed_count: sync.raw_evidence_failed_count,
1574
1766
  raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
1767
+ raw_evidence_retry_required: sync.raw_evidence_retry_required,
1768
+ raw_evidence_retry_reasons: sync.raw_evidence_retry_reasons,
1575
1769
  attributed_session_count: attributedSessionCount,
1576
1770
  codex_session_count: codexSessionCount,
1577
1771
  claude_session_count: claudeSessionCount,
@@ -1742,6 +1936,48 @@ async function runStart(command, io) {
1742
1936
  return 0;
1743
1937
  }
1744
1938
  async function runSync(command, io) {
1939
+ const paths = getCollectorRuntimePaths(command.homeDir);
1940
+ const config = await readLocalCollectorConfig(paths).catch(() => null);
1941
+ const dashboardUrl = command.dashboardUrl ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
1942
+ await reportInstallEventsBestEffort({
1943
+ homeDir: command.homeDir,
1944
+ dashboardUrl,
1945
+ command: "sync",
1946
+ events: [{ step: "sync_started", status: "ok" }],
1947
+ json: command.json,
1948
+ io,
1949
+ });
1950
+ try {
1951
+ const result = await runSyncWithHealthReceipt(command, io);
1952
+ await reportInstallEventsBestEffort({
1953
+ homeDir: command.homeDir,
1954
+ dashboardUrl,
1955
+ command: "sync",
1956
+ events: [result.completion],
1957
+ json: command.json,
1958
+ io,
1959
+ });
1960
+ return result.exitCode;
1961
+ }
1962
+ catch (error) {
1963
+ await reportInstallEventsBestEffort({
1964
+ homeDir: command.homeDir,
1965
+ dashboardUrl,
1966
+ command: "sync",
1967
+ events: [
1968
+ {
1969
+ step: "sync_complete",
1970
+ status: "fail",
1971
+ error_code: classifySyncHealthError(error),
1972
+ },
1973
+ ],
1974
+ json: command.json,
1975
+ io,
1976
+ });
1977
+ throw error;
1978
+ }
1979
+ }
1980
+ async function runSyncWithHealthReceipt(command, io) {
1745
1981
  const backfillLock = await inspectBackfillLock(getCollectorRuntimePaths(command.homeDir));
1746
1982
  if (backfillLock.held) {
1747
1983
  if (command.json) {
@@ -1749,48 +1985,101 @@ async function runSync(command, io) {
1749
1985
  status: "live_sync_paused_during_backfill",
1750
1986
  reason: "live sync paused during backfill",
1751
1987
  held_since: backfillLock.held_since,
1988
+ collection_complete: false,
1989
+ upload_state: "not_uploaded",
1990
+ retryable: true,
1752
1991
  }, null, 2));
1753
1992
  }
1754
1993
  else {
1755
1994
  writeLine(io.stdout, "live sync paused during backfill");
1756
1995
  }
1757
- return 0;
1996
+ return {
1997
+ exitCode: 0,
1998
+ completion: {
1999
+ step: "sync_complete",
2000
+ status: "skipped",
2001
+ error_code: "live_sync_paused_during_backfill",
2002
+ },
2003
+ };
1758
2004
  }
1759
2005
  // Single-flight: a launchd timer and a manual sync must not interleave the
1760
2006
  // cursor read-modify-write. A blocked invocation exits cleanly (B.4 §7).
1761
2007
  const lock = await acquireSyncLock(getCollectorRuntimePaths(command.homeDir));
1762
2008
  if (!lock.acquired) {
1763
2009
  if (command.json) {
1764
- writeLine(io.stdout, JSON.stringify({ status: "sync_already_running", held_since: lock.held_since }, null, 2));
2010
+ writeLine(io.stdout, JSON.stringify({
2011
+ status: "sync_already_running",
2012
+ reason: "another sync owns the collection lock",
2013
+ held_since: lock.held_since,
2014
+ collection_complete: false,
2015
+ upload_state: "not_uploaded",
2016
+ retryable: true,
2017
+ }, null, 2));
1765
2018
  }
1766
2019
  else {
1767
2020
  writeLine(io.stdout, "Cockpit sync already running; skipping this run.");
1768
2021
  }
1769
- return 0;
2022
+ return {
2023
+ exitCode: 0,
2024
+ completion: {
2025
+ step: "sync_complete",
2026
+ status: "skipped",
2027
+ error_code: "sync_already_running",
2028
+ },
2029
+ };
1770
2030
  }
1771
2031
  try {
1772
- return await runSyncLocked(command, io);
2032
+ const exitCode = await runSyncLocked(command, io);
2033
+ return {
2034
+ exitCode,
2035
+ completion: {
2036
+ step: "sync_complete",
2037
+ status: exitCode === 0 ? "ok" : "fail",
2038
+ ...(exitCode === 0 ? {} : { error_code: "sync_failed" }),
2039
+ },
2040
+ };
1773
2041
  }
1774
2042
  finally {
1775
2043
  await lock.handle.release();
1776
2044
  }
1777
2045
  }
2046
+ function classifySyncHealthError(error) {
2047
+ const message = errorMessage(error);
2048
+ if (/auth|token|session|unauthorized|forbidden|401|403/iu.test(message)) {
2049
+ return "auth_failed";
2050
+ }
2051
+ if (/fetch|network|enotfound|econnrefused|timeout/iu.test(message)) {
2052
+ return "network_failed";
2053
+ }
2054
+ if (/collection.root|workspace|repo|worktree/iu.test(message)) {
2055
+ return "collection_root_failed";
2056
+ }
2057
+ return "sync_failed";
2058
+ }
1778
2059
  async function runSyncLocked(command, io) {
1779
- const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
2060
+ const collectionRoots = await resolveSyncCollectionRoots(command);
2061
+ const worktrees = await discoverCommandWorktrees(collectionRoots, {
2062
+ maxDepth: command.maxDepth,
2063
+ maxRepos: command.maxRepos,
2064
+ allowEmpty: true,
2065
+ }, io);
1780
2066
  const run = await runAttributedWorktreeSync({
1781
2067
  homeDir: command.homeDir,
1782
2068
  dashboardUrl: command.dashboardUrl,
2069
+ collectionRoots,
1783
2070
  startContexts: false,
1784
2071
  worktrees,
1785
2072
  fetchImpl: io.fetch,
1786
2073
  });
1787
- if (worktrees.length > 1) {
2074
+ if (run.outcomes.length > 1) {
1788
2075
  const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
2076
+ const runStatus = attributedSyncRunStatus(run);
1789
2077
  const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
1790
2078
  if (command.json) {
1791
2079
  writeLine(io.stdout, JSON.stringify({
1792
2080
  mode: "multi_repo",
1793
- status: run.ok ? "uploaded" : "spooled",
2081
+ status: runStatus,
2082
+ collection_complete: run.ok,
1794
2083
  results: run.outcomes.map((outcome) => outcome.sync),
1795
2084
  repos: rows,
1796
2085
  codex_sessions: run.summary,
@@ -1798,7 +2087,7 @@ async function runSyncLocked(command, io) {
1798
2087
  }, null, 2));
1799
2088
  return run.ok ? 0 : 1;
1800
2089
  }
1801
- writeLine(run.ok ? io.stdout : io.stderr, `Cockpit parent sync ${run.ok ? "uploaded" : "spooled"} ${run.outcomes.filter((outcome) => outcome.sync.status === "uploaded").length}/${run.outcomes.length} worktree(s).`);
2090
+ writeLine(run.ok ? io.stdout : io.stderr, `Cockpit parent sync ${runStatus} ${run.outcomes.filter((outcome) => outcome.sync.status === "uploaded").length}/${run.outcomes.length} worktree(s).`);
1802
2091
  for (const outcome of run.outcomes) {
1803
2092
  const { worktree, sync } = outcome;
1804
2093
  const uploaded = sync.status === "uploaded";
@@ -1814,12 +2103,19 @@ async function runSyncLocked(command, io) {
1814
2103
  if (!result) {
1815
2104
  throw new Error("Sync produced no result for the repo worktree.");
1816
2105
  }
1817
- const gc = result.status === "uploaded" ? await runSyncRawEvidenceGc(command, io) : null;
2106
+ const runStatus = attributedSyncRunStatus(run);
2107
+ const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
1818
2108
  if (command.json) {
1819
- writeLine(io.stdout, JSON.stringify({ ...result, codex_sessions: run.summary, raw_evidence_gc: gc }, null, 2));
1820
- return result.status === "uploaded" ? 0 : 1;
2109
+ writeLine(io.stdout, JSON.stringify({
2110
+ ...result,
2111
+ status: runStatus,
2112
+ collection_complete: run.ok,
2113
+ codex_sessions: run.summary,
2114
+ raw_evidence_gc: gc,
2115
+ }, null, 2));
2116
+ return run.ok ? 0 : 1;
1821
2117
  }
1822
- if (result.status === "uploaded") {
2118
+ if (run.ok) {
1823
2119
  writeLine(io.stdout, "Cockpit ambient envelope uploaded.");
1824
2120
  writeLine(io.stdout, `Ticket: ${displayTicketId(result.ticket_id)}`);
1825
2121
  writeLine(io.stdout, `Context: ${result.work_context_id}`);
@@ -1834,12 +2130,43 @@ async function runSyncLocked(command, io) {
1834
2130
  writeLine(io.stdout, rawEvidenceGcSummary(gc));
1835
2131
  return 0;
1836
2132
  }
2133
+ if (result.status === "uploaded") {
2134
+ writeLine(io.stderr, "Cockpit ambient upload was accepted, but session collection is partial; retry `cockpit sync`.");
2135
+ writeAgentSessionSummary(io, run.summary);
2136
+ return 1;
2137
+ }
1837
2138
  writeLine(io.stderr, "Cockpit ambient upload failed; safe retry metadata was spooled.");
1838
2139
  writeLine(io.stderr, `Ticket: ${displayTicketId(result.ticket_id)}`);
1839
2140
  writeLine(io.stderr, `Failure: ${result.failure_reason}`);
1840
2141
  writeLine(io.stderr, `Retry: ${result.retry_command}`);
1841
2142
  return 1;
1842
2143
  }
2144
+ async function resolveSyncCollectionRoots(command) {
2145
+ const explicitRoots = normalizeCollectionRoots(command.repoRoot ? [command.repoRoot] : []);
2146
+ if (explicitRoots.length > 0) {
2147
+ return collectionRootConsentAliases(explicitRoots);
2148
+ }
2149
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths(command.homeDir)).catch(() => null);
2150
+ const savedRoots = normalizeCollectionRoots(config?.default_repo_paths ?? []);
2151
+ if (savedRoots.length > 0) {
2152
+ return collectionRootConsentAliases(savedRoots);
2153
+ }
2154
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: no explicit or saved collection root is available.`);
2155
+ }
2156
+ async function collectionRootConsentAliases(roots) {
2157
+ // Keep both spellings when an existing root is reached through a filesystem
2158
+ // alias such as macOS `/var` -> `/private/var`. Deleted child paths cannot be
2159
+ // realpathed later, so either transcript spelling must remain inside the same
2160
+ // operator-approved physical root.
2161
+ return normalizeCollectionRoots(await collectionRootPathAliases(roots));
2162
+ }
2163
+ function attributedSyncRunStatus(run) {
2164
+ if (run.ok)
2165
+ return "uploaded";
2166
+ return run.outcomes.every((outcome) => outcome.sync.status === "uploaded")
2167
+ ? "partial"
2168
+ : "spooled";
2169
+ }
1843
2170
  async function runAnalyze(command, io) {
1844
2171
  const syncStdout = [];
1845
2172
  const syncStderr = [];
@@ -2015,6 +2342,8 @@ async function runStatus(command, io) {
2015
2342
  writeLine(io.stdout, `last_upload_success: ${status.last_upload_success_at ?? "never"}`);
2016
2343
  writeLine(io.stdout, `last_upload_failure: ${status.last_upload_failure_reason ?? "none"}`);
2017
2344
  writeLine(io.stdout, `pending_uploads: ${status.pending_upload_count}`);
2345
+ writeLine(io.stdout, `pending_health_receipts: ${status.pending_health_receipt_count}`);
2346
+ writeLine(io.stdout, `last_health_receipt_failure: ${status.last_health_receipt_failure_reason ?? "none"}`);
2018
2347
  writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
2019
2348
  for (const detail of status.details)
2020
2349
  writeLine(io.stdout, `- ${detail}`);
@@ -2028,39 +2357,20 @@ function displayWorkLabel(status) {
2028
2357
  return `${status.work_label} (${status.work_id})`;
2029
2358
  return status.work_label ?? status.work_id ?? "no active work context";
2030
2359
  }
2031
- async function fileExists(filePath) {
2032
- try {
2033
- await access(filePath);
2034
- return true;
2035
- }
2036
- catch {
2037
- return false;
2038
- }
2039
- }
2040
- async function onboardBackfillHint(homeDir) {
2041
- const paths = getCollectorRuntimePaths(homeDir);
2042
- if (await fileExists(backfillCompletionMarkerPath(paths)))
2043
- return null;
2044
- const hasArchivedSessions = await hasAnyJsonlFile([
2045
- path.join(homeDir ?? os.homedir(), ".codex", "archived_sessions"),
2046
- ]);
2047
- return hasArchivedSessions
2048
- ? "Backfill: archived sessions found. Run `cockpit backfill --all` to include older local history."
2049
- : null;
2050
- }
2051
2360
  async function inspectBackfillCursor(homeDir) {
2052
2361
  const paths = getCollectorRuntimePaths(homeDir);
2362
+ const roots = await currentBackfillRoots(homeDir);
2053
2363
  const marker = await readBackfillCompletionMarker(paths);
2054
- if (marker) {
2364
+ if (backfillCompletionCovers(marker, roots, ["codex", "claude_code"])) {
2055
2365
  return {
2056
2366
  state: "done",
2057
2367
  remaining_count: 0,
2058
- updated_at: marker.cursor.updated_at,
2059
- completed_at: marker.completed_at,
2060
- sources: summarizeBackfillCursorSources(marker.cursor),
2368
+ updated_at: marker?.cursor.updated_at ?? null,
2369
+ completed_at: marker?.completed_at ?? null,
2370
+ sources: summarizeBackfillCursorSources(marker?.cursor ?? emptyBackfillCursorState()),
2061
2371
  };
2062
2372
  }
2063
- const cursor = await readBackfillCursor(paths);
2373
+ const cursor = (prepareBackfillCursorForScope(await readBackfillCursor(paths), roots, ["codex", "claude_code"])).cursor;
2064
2374
  if (!cursor.updated_at) {
2065
2375
  return {
2066
2376
  state: "never_run",
@@ -2078,60 +2388,9 @@ async function inspectBackfillCursor(homeDir) {
2078
2388
  sources: summarizeBackfillCursorSources(cursor),
2079
2389
  };
2080
2390
  }
2081
- async function readBackfillCompletionMarker(paths) {
2082
- try {
2083
- const raw = JSON.parse(await readFile(backfillCompletionMarkerPath(paths), "utf8"));
2084
- if (typeof raw.completed_at !== "string")
2085
- return null;
2086
- const cursor = raw.cursor && typeof raw.cursor === "object"
2087
- ? normalizeBackfillCursor(raw.cursor)
2088
- : await readBackfillCursor(paths);
2089
- return { completed_at: raw.completed_at, cursor };
2090
- }
2091
- catch {
2092
- return null;
2093
- }
2094
- }
2095
- function normalizeBackfillCursor(value) {
2096
- const record = value;
2097
- return {
2098
- schema_version: "cockpit-backfill-cursor.v1",
2099
- updated_at: typeof record.updated_at === "string" && record.updated_at
2100
- ? record.updated_at
2101
- : null,
2102
- sources: {
2103
- codex: {
2104
- oldest_mtime_ms_processed: typeof record.sources?.codex?.oldest_mtime_ms_processed === "number"
2105
- ? record.sources.codex.oldest_mtime_ms_processed
2106
- : null,
2107
- oldest_mtime_processed: typeof record.sources?.codex?.oldest_mtime_processed === "string"
2108
- ? record.sources.codex.oldest_mtime_processed
2109
- : null,
2110
- state_counts: numberRecord(record.sources?.codex?.state_counts),
2111
- reason_counts: numberRecord(record.sources?.codex?.reason_counts),
2112
- },
2113
- claude_code: {
2114
- oldest_mtime_ms_processed: typeof record.sources?.claude_code?.oldest_mtime_ms_processed === "number"
2115
- ? record.sources.claude_code.oldest_mtime_ms_processed
2116
- : null,
2117
- oldest_mtime_processed: typeof record.sources?.claude_code?.oldest_mtime_processed === "string"
2118
- ? record.sources.claude_code.oldest_mtime_processed
2119
- : null,
2120
- state_counts: numberRecord(record.sources?.claude_code?.state_counts),
2121
- reason_counts: numberRecord(record.sources?.claude_code?.reason_counts),
2122
- },
2123
- },
2124
- };
2125
- }
2126
- function numberRecord(value) {
2127
- if (!value || typeof value !== "object")
2128
- return {};
2129
- const out = {};
2130
- for (const [key, raw] of Object.entries(value)) {
2131
- if (typeof raw === "number" && Number.isFinite(raw))
2132
- out[key] = raw;
2133
- }
2134
- return out;
2391
+ async function currentBackfillRoots(homeDir) {
2392
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
2393
+ return normalizeCollectionRoots(config?.default_repo_paths ?? []);
2135
2394
  }
2136
2395
  function summarizeBackfillCursorSources(cursor) {
2137
2396
  return {
@@ -2190,14 +2449,6 @@ async function countClaudeMainFilesBeforeCursor(projectsDir, oldestProcessedMs)
2190
2449
  });
2191
2450
  return count;
2192
2451
  }
2193
- async function hasAnyJsonlFile(roots) {
2194
- let found = false;
2195
- await walkFiles(roots, async (_filePath, entryName) => {
2196
- if (entryName.endsWith(".jsonl"))
2197
- found = true;
2198
- }, () => found);
2199
- return found;
2200
- }
2201
2452
  async function walkFiles(roots, onFile, shouldStop = () => false) {
2202
2453
  const stack = [...roots];
2203
2454
  while (stack.length > 0 && !shouldStop()) {
@@ -2387,23 +2638,51 @@ async function runServe(command, io) {
2387
2638
  }
2388
2639
  async function runAutostart(command, io) {
2389
2640
  const exec = io.exec ?? defaultExec();
2641
+ const repoRoots = command.action === "install" || command.action === "status"
2642
+ ? await resolveAutostartRoots(command.homeDir, command.repoRoot)
2643
+ : [];
2390
2644
  const result = command.action === "install"
2391
2645
  ? await installAutostartAgent({
2392
2646
  homeDir: command.homeDir,
2393
- repoRoot: command.repoRoot,
2647
+ repoRoot: repoRoots[0],
2648
+ repoRoots,
2394
2649
  dashboardUrl: command.dashboardUrl,
2395
2650
  intervalSeconds: command.intervalSeconds,
2396
2651
  exec,
2397
2652
  })
2398
2653
  : command.action === "uninstall"
2399
2654
  ? await uninstallAutostartAgent({ homeDir: command.homeDir, exec })
2400
- : await autostartStatus({ homeDir: command.homeDir, exec });
2655
+ : await autostartStatus({
2656
+ homeDir: command.homeDir,
2657
+ repoRoot: repoRoots[0],
2658
+ repoRoots,
2659
+ dashboardUrl: command.dashboardUrl,
2660
+ intervalSeconds: command.intervalSeconds,
2661
+ exec,
2662
+ });
2401
2663
  if (command.json) {
2402
2664
  writeLine(io.stdout, JSON.stringify(result, null, 2));
2403
- return result.status === "unsupported" ? 1 : 0;
2665
+ return autostartExitCode(command.action, result);
2404
2666
  }
2405
2667
  writeAutostartResult(io, result);
2406
- return result.status === "unsupported" ? 1 : 0;
2668
+ return autostartExitCode(command.action, result);
2669
+ }
2670
+ function autostartExitCode(action, result) {
2671
+ if (result.status === "unsupported")
2672
+ return 1;
2673
+ if (action === "status")
2674
+ return result.status === "loaded" ? 0 : 1;
2675
+ if (action === "install" && result.loaded === false)
2676
+ return 1;
2677
+ if (action === "uninstall" && result.status === "not_loaded")
2678
+ return 1;
2679
+ return 0;
2680
+ }
2681
+ async function resolveAutostartRoots(homeDir, explicitRoot) {
2682
+ if (explicitRoot)
2683
+ return [explicitRoot];
2684
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
2685
+ return config?.default_repo_paths ?? [];
2407
2686
  }
2408
2687
  async function runAgentRules(command, io) {
2409
2688
  const hosts = agentRuleHosts(command.host);
@@ -2453,9 +2732,9 @@ function writeAutostartResult(io, result) {
2453
2732
  case "installed":
2454
2733
  writeLine(io.stdout, result.loaded
2455
2734
  ? "Cockpit autostart installed and loaded."
2456
- : "Cockpit autostart installed (launchctl load reported a problem).");
2735
+ : "Cockpit autostart installed (the operating-system scheduler reported a problem).");
2457
2736
  writeLine(io.stdout, `Label: ${result.label}`);
2458
- writeLine(io.stdout, `Plist: ${result.plist_path}`);
2737
+ writeLine(io.stdout, autostartLocationLine(result));
2459
2738
  writeLine(io.stdout, `Interval: every ${result.interval_seconds}s`);
2460
2739
  writeLine(io.stdout, `Repo: ${result.work_dir}`);
2461
2740
  writeLine(io.stdout, `Dashboard: ${result.dashboard_url}`);
@@ -2464,45 +2743,37 @@ function writeAutostartResult(io, result) {
2464
2743
  return;
2465
2744
  case "uninstalled":
2466
2745
  writeLine(io.stdout, "Cockpit autostart removed.");
2467
- writeLine(io.stdout, `Plist: ${result.plist_path}`);
2746
+ writeLine(io.stdout, autostartLocationLine(result));
2468
2747
  return;
2469
2748
  case "absent":
2470
2749
  writeLine(io.stdout, "Cockpit autostart is not installed.");
2471
- writeLine(io.stdout, `Plist: ${result.plist_path}`);
2750
+ writeLine(io.stdout, autostartLocationLine(result));
2472
2751
  return;
2473
2752
  case "loaded":
2474
2753
  writeLine(io.stdout, "Cockpit autostart is installed and loaded.");
2475
- writeLine(io.stdout, `Plist: ${result.plist_path}`);
2754
+ writeLine(io.stdout, autostartLocationLine(result));
2476
2755
  return;
2477
2756
  case "not_loaded":
2478
- writeLine(io.stdout, "Cockpit autostart plist exists but is not loaded; run `cockpit autostart install` to reload.");
2479
- writeLine(io.stdout, `Plist: ${result.plist_path}`);
2757
+ writeLine(io.stdout, "Cockpit autostart exists but is disabled or not loaded; run `cockpit autostart install` to repair it.");
2758
+ writeLine(io.stdout, autostartLocationLine(result));
2759
+ if (result.message)
2760
+ writeLine(io.stderr, result.message);
2480
2761
  return;
2481
2762
  case "unsupported":
2482
2763
  writeLine(io.stderr, `Autostart unsupported: ${result.message}`);
2483
2764
  return;
2484
2765
  }
2485
2766
  }
2767
+ function autostartLocationLine(result) {
2768
+ return result.task_name
2769
+ ? `Task: ${result.task_name}`
2770
+ : `Plist: ${result.plist_path}`;
2771
+ }
2486
2772
  function defaultExec() {
2487
- return (cmd, args) => new Promise((resolve) => {
2488
- execFile(cmd, args, { encoding: "utf8" }, (err, stdout, stderr) => {
2489
- // execFile's error `.code` is the exit code when numeric, but a string
2490
- // (e.g. "ENOENT") for spawn failures — treat those as a generic 1.
2491
- const code = err == null ? 0 : typeof err.code === "number" ? err.code : 1;
2492
- resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
2493
- });
2494
- });
2773
+ return createCapturedExecRunner();
2495
2774
  }
2496
2775
  function defaultInteractiveExec() {
2497
- return (cmd, args) => new Promise((resolve) => {
2498
- const child = spawn(cmd, args, { stdio: "inherit" });
2499
- child.on("error", (error) => {
2500
- resolve({ code: 1, stdout: "", stderr: errorMessage(error) });
2501
- });
2502
- child.on("close", (code) => {
2503
- resolve({ code: code ?? 1, stdout: "", stderr: "" });
2504
- });
2505
- });
2776
+ return createInteractiveExecRunner();
2506
2777
  }
2507
2778
  function defaultIo() {
2508
2779
  if (!globalThis.fetch) {