@bli-cockpit/cli 0.2.5 → 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,13 +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
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";
22
24
  export const rootCommandNames = new Set([
23
25
  "onboard",
24
26
  "update",
@@ -108,7 +110,7 @@ export function localCommandHelp(command) {
108
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]",
109
111
  " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
110
112
  " cockpit upgrade [same flags as update]",
111
- " 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]",
112
114
  " cockpit fix [same flags as do-everything]",
113
115
  " cockpit install [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--json]",
114
116
  " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--no-auth] [--json]",
@@ -117,7 +119,7 @@ export function localCommandHelp(command) {
117
119
  " cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
118
120
  " cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
119
121
  " cockpit analyze [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
120
- " 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]",
121
123
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
122
124
  " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
123
125
  " cockpit serve [--port <port>] [--workspace <path>]",
@@ -133,9 +135,10 @@ function localSubcommandHelp(command) {
133
135
  [
134
136
  "onboard",
135
137
  [
136
- "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]",
137
139
  "",
138
- "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.",
139
142
  "If --workspace is a parent folder, scans child git repos/worktrees and rolls them up by repo.",
140
143
  "`--repo <path>` remains supported as a backward-compatible alias.",
141
144
  `Omit --dashboard-url for normal production setup (${DEFAULT_DASHBOARD_URL}).`,
@@ -160,8 +163,8 @@ function localSubcommandHelp(command) {
160
163
  "Usage: cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
161
164
  "",
162
165
  "Updates the global public CLI from npm, then reruns `cockpit onboard`",
163
- "with the same setup flags so pairing, saved roots, agent rules,",
164
- "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.",
165
168
  "`cockpit upgrade` is an alias.",
166
169
  ],
167
170
  ],
@@ -176,10 +179,11 @@ function localSubcommandHelp(command) {
176
179
  [
177
180
  "do-everything",
178
181
  [
179
- "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]",
180
183
  "",
181
- "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.",
182
185
  "`cockpit fix` is an alias.",
186
+ "Maintainer canary: use `--update-tag next` so self-update and re-exec stay on the prerelease candidate.",
183
187
  "--dry-run prints the checks and would-fix steps without writing config, plists, cursors, or install telemetry.",
184
188
  ],
185
189
  ],
@@ -255,12 +259,14 @@ function localSubcommandHelp(command) {
255
259
  [
256
260
  "backfill",
257
261
  [
258
- "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]",
259
263
  "",
260
264
  "Backfills historical Codex and Claude Code session evidence using the saved collection roots from local config when --workspace is omitted.",
261
265
  "Omit --source to scan both sources; there is no --source all literal.",
262
266
  "The non-all window is capped at the collector session paired_at timestamp.",
263
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.",
264
270
  "--dry-run validates the paired collector and dashboard reachability, prints the same summary tables, and writes nothing.",
265
271
  ],
266
272
  ],
@@ -300,12 +306,15 @@ function localSubcommandHelp(command) {
300
306
  [
301
307
  "Usage: cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
302
308
  "",
303
- "Installs a macOS launchd LaunchAgent that runs `cockpit sync` at login and",
304
- "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.",
305
313
  "Action defaults to `install`. `--workspace` is the parent work folder to sync.",
306
314
  "`--repo <path>` remains supported as a backward-compatible alias.",
307
315
  "Omit --dashboard-url for production; pass it only for staging/custom dashboards.",
308
- "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.",
309
318
  ],
310
319
  ],
311
320
  [
@@ -418,7 +427,7 @@ async function runUpdate(command, io) {
418
427
  writeLine(io.stdout, JSON.stringify({
419
428
  status: "blocked",
420
429
  step: "npm_install",
421
- command: `npm ${SELF_UPDATE_INSTALL_ARGS.join(" ")}`,
430
+ command: `npm ${selfUpdateInstallArgs().join(" ")}`,
422
431
  exit_code: error.result.code,
423
432
  }, null, 2));
424
433
  }
@@ -443,12 +452,6 @@ async function runUpdate(command, io) {
443
452
  addInstallEvent(installEvents, "onboard_rerun", onboard.code === 0 ? "ok" : "fail", onboard.code === 0 ? undefined : updateOnboardFailureCode(onboard));
444
453
  return finish(onboard.code);
445
454
  }
446
- const SELF_UPDATE_INSTALL_ARGS = [
447
- "install",
448
- "-g",
449
- "@bli-cockpit/cli@latest",
450
- "--prefer-online",
451
- ];
452
455
  export class SelfUpdateError extends Error {
453
456
  result;
454
457
  eacces;
@@ -461,16 +464,25 @@ export class SelfUpdateError extends Error {
461
464
  }
462
465
  export async function runSelfUpdate(io, options = {}) {
463
466
  const exec = io.exec ?? defaultExec();
467
+ const tag = options.tag ?? "latest";
464
468
  if (!options.json) {
465
- writeLine(io.stdout, "Updating Cockpit CLI from npm...");
469
+ writeLine(io.stdout, `Updating Cockpit CLI from npm (${tag})...`);
466
470
  }
467
- const install = await exec("npm", [...SELF_UPDATE_INSTALL_ARGS]);
471
+ const install = await exec("npm", selfUpdateInstallArgs(tag));
468
472
  writeExecOutput(io, install, { stdout: !options.json, stderr: true });
469
473
  if (install.code !== 0) {
470
474
  throw new SelfUpdateError(install);
471
475
  }
472
476
  return { updated: true, version: LOCAL_COLLECTOR_VERSION };
473
477
  }
478
+ function selfUpdateInstallArgs(tag = "latest") {
479
+ return [
480
+ "install",
481
+ "-g",
482
+ `@bli-cockpit/cli@${tag}`,
483
+ "--prefer-online",
484
+ ];
485
+ }
474
486
  async function runRelease(command, io) {
475
487
  const releaseRoot = await findPublicReleaseRoot(process.cwd());
476
488
  if (!releaseRoot) {
@@ -644,6 +656,17 @@ function addAutostartInstallEvent(events, result) {
644
656
  }
645
657
  addInstallEvent(events, "autostart", result.loaded === false ? "fail" : "ok", result.loaded === false ? "autostart_load_failed" : undefined);
646
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
+ }
647
670
  function onboardFailureStep(blocker, events) {
648
671
  if (blocker === COLLECTION_ROOT_REQUIRED)
649
672
  return "install";
@@ -1023,11 +1046,7 @@ async function readGitConfigEmail(root) {
1023
1046
  });
1024
1047
  });
1025
1048
  }
1026
- /**
1027
- * After a successful onboard, refreshes the launchd autostart agent so intern
1028
- * machines keep syncing without anyone re-running Cockpit. Runtimes without an
1029
- * exec runner skip this because launchd installation cannot be attempted there.
1030
- */
1049
+ /** Refreshes the host autostart backend after successful onboarding. */
1031
1050
  async function refreshOnboardAutostart(command, roots, io) {
1032
1051
  if (!io.exec)
1033
1052
  return null;
@@ -1046,8 +1065,8 @@ async function refreshOnboardAutostart(command, roots, io) {
1046
1065
  }
1047
1066
  writeLine(io.stdout, result.loaded
1048
1067
  ? "Background autostart refreshed; Cockpit syncs at login and every 15 min."
1049
- : "Background autostart refreshed, but launchctl load reported a problem; check `cockpit autostart status`.");
1050
- 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));
1051
1070
  return result;
1052
1071
  }
1053
1072
  async function refreshOnboardAgentRules(command, roots, io) {
@@ -1088,21 +1107,66 @@ function writeOnboardLiveStatus(io, command, roots, options) {
1088
1107
  (options.status?.session_state === "valid" ? "existing valid session" : "pending")}`);
1089
1108
  writeLine(io.stdout, `Background sync: ${backgroundSyncLine(options.autostart)}`);
1090
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))`);
1091
1111
  writeLine(io.stdout, `Agent rules: ${options.agentRules ? onboardAgentRulesInstallLine(options.agentRules) : "not refreshed"}`);
1092
1112
  writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}/my-work`);
1093
- if (options.backfillHint)
1094
- writeLine(io.stdout, options.backfillHint);
1095
1113
  writeLine(io.stdout, "Next: cockpit status");
1096
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
+ }
1097
1161
  function backgroundSyncLine(result) {
1098
1162
  if (!result)
1099
- return "not refreshed (launchd runner unavailable)";
1163
+ return "not refreshed (autostart runner unavailable)";
1100
1164
  if (result.status === "unsupported")
1101
1165
  return `unsupported (${result.message})`;
1102
1166
  if (result.status === "installed" && result.loaded)
1103
1167
  return "installed";
1104
1168
  if (result.status === "installed") {
1105
- return `installed with warning (${result.message ?? "launchctl load failed"})`;
1169
+ return `installed with warning (${result.message ?? "operating-system scheduler load failed"})`;
1106
1170
  }
1107
1171
  return result.status;
1108
1172
  }
@@ -1184,18 +1248,17 @@ async function runOnboard(command, io) {
1184
1248
  let rootsResult = null;
1185
1249
  let agentRules = null;
1186
1250
  let autostart = null;
1187
- let backfillHint = null;
1188
1251
  try {
1189
1252
  if (!command.json) {
1190
1253
  writeLine(io.stdout, "Cockpit harvest onboarding");
1191
1254
  writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
1192
1255
  writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
1193
1256
  }
1194
- backfillHint = await onboardBackfillHint(command.homeDir);
1195
1257
  const resolvedRoots = await resolveOnboardingRootsForCommand(command, io);
1196
1258
  const existingConfig = resolvedRoots.existingConfig;
1197
1259
  rootsResult = resolvedRoots.rootsResult;
1198
1260
  const collectionRoots = rootsResult.roots;
1261
+ const attributionCollectionRoots = await collectionRootConsentAliases(collectionRoots);
1199
1262
  const primaryRoot = resolvedRoots.primaryRoot;
1200
1263
  const resolvedCommand = {
1201
1264
  ...command,
@@ -1263,31 +1326,81 @@ async function runOnboard(command, io) {
1263
1326
  writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
1264
1327
  }
1265
1328
  }
1266
- 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);
1267
1334
  if (worktrees.length > 1) {
1268
1335
  const multi = await runMultiRepoOnboard(resolvedCommand, io, worktrees);
1269
1336
  addInstallEvent(installEvents, "work_context", "ok");
1270
1337
  addInstallEvent(installEvents, "sync", multi.ok ? "ok" : "fail", multi.ok ? undefined : "sync_blocked");
1271
- if (multi.ok) {
1272
- agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1273
- addInstallEvent(installEvents, "agent_rules", "ok");
1274
- autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1275
- 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);
1276
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);
1277
1383
  if (command.json) {
1278
1384
  writeLine(io.stdout, JSON.stringify({
1279
- ...onboardResult(multi.ok ? "pass" : "blocked", resolvedCommand, install, pair, null, null),
1385
+ ...onboardResult(onboardOk ? "pass" : "blocked", resolvedCommand, install, pair, null, null),
1280
1386
  collection_roots: collectionRoots,
1281
1387
  root_resolution: rootsResult,
1282
1388
  agent_rules: agentRules,
1283
1389
  autostart,
1284
- backfill_hint: backfillHint,
1390
+ backfill: completedBackfill,
1285
1391
  mode: "multi_repo",
1286
1392
  repos: multi.results,
1287
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
+ : {}),
1288
1400
  }, null, 2));
1289
1401
  }
1290
- if (multi.ok && !command.json) {
1402
+ if (onboardOk && !command.json) {
1403
+ writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
1291
1404
  writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
1292
1405
  pair,
1293
1406
  status,
@@ -1295,10 +1408,13 @@ async function runOnboard(command, io) {
1295
1408
  agentRules,
1296
1409
  autostart,
1297
1410
  initialSyncOk: true,
1298
- backfillHint,
1411
+ backfill: completedBackfill,
1299
1412
  });
1300
1413
  }
1301
- return finish(multi.ok ? 0 : 1);
1414
+ else if (multi.ok && !command.json) {
1415
+ writeOnboardAutostartBlocker(io, autostart);
1416
+ }
1417
+ return finish(onboardOk ? 0 : 1);
1302
1418
  }
1303
1419
  const worktreeRoot = worktrees[0]?.repo_root ?? primaryRoot;
1304
1420
  const context = await startLocalWorkContext({
@@ -1318,6 +1434,7 @@ async function runOnboard(command, io) {
1318
1434
  const run = await runAttributedWorktreeSync({
1319
1435
  homeDir: command.homeDir,
1320
1436
  dashboardUrl: command.dashboardUrl,
1437
+ collectionRoots: attributionCollectionRoots,
1321
1438
  startContexts: false,
1322
1439
  worktrees,
1323
1440
  fetchImpl: io.fetch,
@@ -1331,39 +1448,76 @@ async function runOnboard(command, io) {
1331
1448
  repoRoot: worktreeRoot,
1332
1449
  branch: command.branch,
1333
1450
  });
1334
- if (sync.status !== "uploaded") {
1451
+ if (!run.ok || sync.status !== "uploaded") {
1335
1452
  addInstallEvent(installEvents, "sync", "fail", "sync_blocked");
1453
+ const runStatus = attributedSyncRunStatus(run);
1336
1454
  if (command.json) {
1337
1455
  writeLine(io.stdout, JSON.stringify({
1338
1456
  ...onboardResult("blocked", resolvedCommand, install, pair, sync, status),
1339
1457
  collection_roots: collectionRoots,
1340
1458
  root_resolution: rootsResult,
1341
1459
  codex_sessions: run.summary,
1460
+ collection_status: runStatus,
1342
1461
  }, null, 2));
1343
1462
  }
1344
1463
  else {
1345
- writeLine(io.stderr, "BLOCKED: ambient upload failed; safe retry metadata was spooled.");
1346
- writeLine(io.stderr, `Failure: ${sync.failure_reason}`);
1347
- 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}`);
1348
1472
  }
1349
1473
  return finish(1);
1350
1474
  }
1351
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
+ }
1352
1499
  if (command.json) {
1353
1500
  agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1354
1501
  addInstallEvent(installEvents, "agent_rules", "ok");
1355
1502
  autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1356
1503
  addAutostartInstallEvent(installEvents, autostart);
1504
+ const onboardOk = !onboardAutostartFailed(autostart);
1357
1505
  writeLine(io.stdout, JSON.stringify({
1358
- ...onboardResult("pass", resolvedCommand, install, pair, sync, status),
1506
+ ...onboardResult(onboardOk ? "pass" : "blocked", resolvedCommand, install, pair, sync, status),
1359
1507
  collection_roots: collectionRoots,
1360
1508
  root_resolution: rootsResult,
1361
1509
  agent_rules: agentRules,
1362
1510
  autostart,
1363
- backfill_hint: backfillHint,
1511
+ backfill: completedBackfill,
1364
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
+ : {}),
1365
1519
  }, null, 2));
1366
- return finish(0);
1520
+ return finish(onboardOk ? 0 : 1);
1367
1521
  }
1368
1522
  writeLine(io.stdout, "4/5 Ambient metadata uploaded.");
1369
1523
  writeLine(io.stdout, `HTTP: ${sync.http_status}`);
@@ -1375,12 +1529,16 @@ async function runOnboard(command, io) {
1375
1529
  writeAgentSessionSummary(io, run.summary);
1376
1530
  writeLine(io.stdout, "5/5 Status ready.");
1377
1531
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
1378
- writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
1379
1532
  writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
1380
1533
  agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
1381
1534
  addInstallEvent(installEvents, "agent_rules", "ok");
1382
1535
  autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
1383
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.");
1384
1542
  writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
1385
1543
  pair,
1386
1544
  status,
@@ -1388,7 +1546,7 @@ async function runOnboard(command, io) {
1388
1546
  agentRules,
1389
1547
  autostart,
1390
1548
  initialSyncOk: true,
1391
- backfillHint,
1549
+ backfill: completedBackfill,
1392
1550
  });
1393
1551
  return finish(0);
1394
1552
  }
@@ -1456,7 +1614,8 @@ function shortSha(value) {
1456
1614
  return value ? value.slice(0, 12) : "unknown";
1457
1615
  }
1458
1616
  function sourceFunnelLine(label, counts) {
1459
- 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}`;
1460
1619
  }
1461
1620
  function attributionReportLine(summary) {
1462
1621
  return summary.report_posted
@@ -1511,7 +1670,10 @@ function rawEvidenceSyncLine(sync) {
1511
1670
  const failures = sync.raw_evidence_failure_reasons.length > 0
1512
1671
  ? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
1513
1672
  : "";
1514
- 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}`;
1515
1677
  }
1516
1678
  function cursorStatusLine(sync) {
1517
1679
  return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
@@ -1521,75 +1683,69 @@ const DEFAULT_DISCOVERY_MAX_REPOS = 50;
1521
1683
  const ALL_SESSION_SCAN_WINDOW_MINUTES = 20 * 365 * 24 * 60;
1522
1684
  const SESSION_SCAN_OVERRIDE_LIMIT = 10_000;
1523
1685
  async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
1524
- if (Array.isArray(repoRoot)) {
1525
- const discovered = [];
1526
- for (const root of repoRoot) {
1527
- discovered.push(...(await discoverCommandWorktrees(root, discovery, io)));
1528
- }
1529
- return dedupeWorktrees(discovered);
1530
- }
1531
1686
  const maxWorktrees = discovery.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS;
1532
- 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, {
1533
1691
  maxDepth: discovery.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
1534
1692
  maxWorktrees,
1535
1693
  });
1536
- if (worktrees.length === 0) {
1537
- 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.`);
1538
1701
  }
1539
- if (io && worktrees.length >= maxWorktrees) {
1540
- 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.");
1541
1704
  }
1542
1705
  return worktrees;
1543
1706
  }
1544
- function dedupeWorktrees(worktrees) {
1545
- const seen = new Set();
1546
- const deduped = [];
1547
- for (const worktree of worktrees) {
1548
- const key = worktree.worktree_fingerprint || path.resolve(worktree.repo_root);
1549
- if (seen.has(key))
1550
- continue;
1551
- seen.add(key);
1552
- deduped.push(worktree);
1553
- }
1554
- return deduped;
1555
- }
1556
1707
  async function runMultiRepoOnboard(command, io, worktrees) {
1557
1708
  if (!command.json) {
1558
1709
  writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
1559
1710
  }
1711
+ const collectionRoots = await collectionRootConsentAliases(command.collectionRoots ??
1712
+ (command.repoRoot ? [command.repoRoot] : []));
1560
1713
  const run = await runAttributedWorktreeSync({
1561
1714
  homeDir: command.homeDir,
1562
1715
  dashboardUrl: command.dashboardUrl,
1563
1716
  branch: command.branch,
1564
1717
  activeTicketId: command.activeTicketId,
1718
+ collectionRoots,
1565
1719
  startContexts: true,
1566
1720
  worktrees,
1567
1721
  fetchImpl: io.fetch,
1568
1722
  });
1569
1723
  const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
1724
+ const runStatus = attributedSyncRunStatus(run);
1570
1725
  if (!command.json) {
1571
1726
  for (const [index, outcome] of run.outcomes.entries()) {
1572
1727
  const row = results[index];
1573
1728
  if (!row)
1574
1729
  continue;
1575
1730
  const uploaded = outcome.sync.status === "uploaded";
1576
- 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"]}` : ""}`);
1577
1732
  }
1578
1733
  writeLine(io.stdout, "4/5 Parent worktree sync complete.");
1579
1734
  writeLine(io.stdout, `Uploaded: ${results.filter((row) => row["upload_status"] === "uploaded").length}/${results.length}`);
1580
1735
  writeAgentSessionSummary(io, run.summary);
1581
- writeLine(io.stdout, "5/5 Status ready.");
1582
- writeLine(run.ok ? io.stdout : io.stderr, run.ok
1583
- ? "PASS: Cockpit collector is ready for harvest."
1584
- : "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
+ }
1585
1742
  writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
1586
1743
  }
1587
1744
  return { ok: run.ok, results, codex_sessions: run.summary };
1588
1745
  }
1589
1746
  function worktreeSyncRow(outcome, run) {
1590
1747
  const { worktree, context, sync } = outcome;
1591
- const matchesWorktree = (result) => result.state === "attributed" &&
1592
- result.worktree?.worktree_fingerprint === worktree.worktree_fingerprint;
1748
+ const matchesWorktree = (result) => matchesLiveSyncWorktree(result, worktree);
1593
1749
  const codexSessionCount = run.codexAttribution.results.filter(matchesWorktree).length;
1594
1750
  const claudeSessionCount = run.claudeAttribution.results.filter(matchesWorktree).length;
1595
1751
  const attributedSessionCount = codexSessionCount + claudeSessionCount;
@@ -1608,6 +1764,8 @@ function worktreeSyncRow(outcome, run) {
1608
1764
  raw_evidence_reused_count: sync.raw_evidence_reused_count,
1609
1765
  raw_evidence_failed_count: sync.raw_evidence_failed_count,
1610
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,
1611
1769
  attributed_session_count: attributedSessionCount,
1612
1770
  codex_session_count: codexSessionCount,
1613
1771
  claude_session_count: claudeSessionCount,
@@ -1790,22 +1948,16 @@ async function runSync(command, io) {
1790
1948
  io,
1791
1949
  });
1792
1950
  try {
1793
- const code = await runSyncWithHealthReceipt(command, io);
1951
+ const result = await runSyncWithHealthReceipt(command, io);
1794
1952
  await reportInstallEventsBestEffort({
1795
1953
  homeDir: command.homeDir,
1796
1954
  dashboardUrl,
1797
1955
  command: "sync",
1798
- events: [
1799
- {
1800
- step: "sync_complete",
1801
- status: code === 0 ? "ok" : "fail",
1802
- ...(code === 0 ? {} : { error_code: "sync_failed" }),
1803
- },
1804
- ],
1956
+ events: [result.completion],
1805
1957
  json: command.json,
1806
1958
  io,
1807
1959
  });
1808
- return code;
1960
+ return result.exitCode;
1809
1961
  }
1810
1962
  catch (error) {
1811
1963
  await reportInstallEventsBestEffort({
@@ -1833,27 +1985,59 @@ async function runSyncWithHealthReceipt(command, io) {
1833
1985
  status: "live_sync_paused_during_backfill",
1834
1986
  reason: "live sync paused during backfill",
1835
1987
  held_since: backfillLock.held_since,
1988
+ collection_complete: false,
1989
+ upload_state: "not_uploaded",
1990
+ retryable: true,
1836
1991
  }, null, 2));
1837
1992
  }
1838
1993
  else {
1839
1994
  writeLine(io.stdout, "live sync paused during backfill");
1840
1995
  }
1841
- 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
+ };
1842
2004
  }
1843
2005
  // Single-flight: a launchd timer and a manual sync must not interleave the
1844
2006
  // cursor read-modify-write. A blocked invocation exits cleanly (B.4 §7).
1845
2007
  const lock = await acquireSyncLock(getCollectorRuntimePaths(command.homeDir));
1846
2008
  if (!lock.acquired) {
1847
2009
  if (command.json) {
1848
- 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));
1849
2018
  }
1850
2019
  else {
1851
2020
  writeLine(io.stdout, "Cockpit sync already running; skipping this run.");
1852
2021
  }
1853
- return 0;
2022
+ return {
2023
+ exitCode: 0,
2024
+ completion: {
2025
+ step: "sync_complete",
2026
+ status: "skipped",
2027
+ error_code: "sync_already_running",
2028
+ },
2029
+ };
1854
2030
  }
1855
2031
  try {
1856
- 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
+ };
1857
2041
  }
1858
2042
  finally {
1859
2043
  await lock.handle.release();
@@ -1873,21 +2057,29 @@ function classifySyncHealthError(error) {
1873
2057
  return "sync_failed";
1874
2058
  }
1875
2059
  async function runSyncLocked(command, io) {
1876
- 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);
1877
2066
  const run = await runAttributedWorktreeSync({
1878
2067
  homeDir: command.homeDir,
1879
2068
  dashboardUrl: command.dashboardUrl,
2069
+ collectionRoots,
1880
2070
  startContexts: false,
1881
2071
  worktrees,
1882
2072
  fetchImpl: io.fetch,
1883
2073
  });
1884
- if (worktrees.length > 1) {
2074
+ if (run.outcomes.length > 1) {
1885
2075
  const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
2076
+ const runStatus = attributedSyncRunStatus(run);
1886
2077
  const gc = run.ok ? await runSyncRawEvidenceGc(command, io) : null;
1887
2078
  if (command.json) {
1888
2079
  writeLine(io.stdout, JSON.stringify({
1889
2080
  mode: "multi_repo",
1890
- status: run.ok ? "uploaded" : "spooled",
2081
+ status: runStatus,
2082
+ collection_complete: run.ok,
1891
2083
  results: run.outcomes.map((outcome) => outcome.sync),
1892
2084
  repos: rows,
1893
2085
  codex_sessions: run.summary,
@@ -1895,7 +2087,7 @@ async function runSyncLocked(command, io) {
1895
2087
  }, null, 2));
1896
2088
  return run.ok ? 0 : 1;
1897
2089
  }
1898
- 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).`);
1899
2091
  for (const outcome of run.outcomes) {
1900
2092
  const { worktree, sync } = outcome;
1901
2093
  const uploaded = sync.status === "uploaded";
@@ -1911,12 +2103,19 @@ async function runSyncLocked(command, io) {
1911
2103
  if (!result) {
1912
2104
  throw new Error("Sync produced no result for the repo worktree.");
1913
2105
  }
1914
- 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;
1915
2108
  if (command.json) {
1916
- writeLine(io.stdout, JSON.stringify({ ...result, codex_sessions: run.summary, raw_evidence_gc: gc }, null, 2));
1917
- 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;
1918
2117
  }
1919
- if (result.status === "uploaded") {
2118
+ if (run.ok) {
1920
2119
  writeLine(io.stdout, "Cockpit ambient envelope uploaded.");
1921
2120
  writeLine(io.stdout, `Ticket: ${displayTicketId(result.ticket_id)}`);
1922
2121
  writeLine(io.stdout, `Context: ${result.work_context_id}`);
@@ -1931,12 +2130,43 @@ async function runSyncLocked(command, io) {
1931
2130
  writeLine(io.stdout, rawEvidenceGcSummary(gc));
1932
2131
  return 0;
1933
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
+ }
1934
2138
  writeLine(io.stderr, "Cockpit ambient upload failed; safe retry metadata was spooled.");
1935
2139
  writeLine(io.stderr, `Ticket: ${displayTicketId(result.ticket_id)}`);
1936
2140
  writeLine(io.stderr, `Failure: ${result.failure_reason}`);
1937
2141
  writeLine(io.stderr, `Retry: ${result.retry_command}`);
1938
2142
  return 1;
1939
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
+ }
1940
2170
  async function runAnalyze(command, io) {
1941
2171
  const syncStdout = [];
1942
2172
  const syncStderr = [];
@@ -2127,39 +2357,20 @@ function displayWorkLabel(status) {
2127
2357
  return `${status.work_label} (${status.work_id})`;
2128
2358
  return status.work_label ?? status.work_id ?? "no active work context";
2129
2359
  }
2130
- async function fileExists(filePath) {
2131
- try {
2132
- await access(filePath);
2133
- return true;
2134
- }
2135
- catch {
2136
- return false;
2137
- }
2138
- }
2139
- async function onboardBackfillHint(homeDir) {
2140
- const paths = getCollectorRuntimePaths(homeDir);
2141
- if (await fileExists(backfillCompletionMarkerPath(paths)))
2142
- return null;
2143
- const hasArchivedSessions = await hasAnyJsonlFile([
2144
- path.join(homeDir ?? os.homedir(), ".codex", "archived_sessions"),
2145
- ]);
2146
- return hasArchivedSessions
2147
- ? "Backfill: archived sessions found. Run `cockpit backfill --all` to include older local history."
2148
- : null;
2149
- }
2150
2360
  async function inspectBackfillCursor(homeDir) {
2151
2361
  const paths = getCollectorRuntimePaths(homeDir);
2362
+ const roots = await currentBackfillRoots(homeDir);
2152
2363
  const marker = await readBackfillCompletionMarker(paths);
2153
- if (marker) {
2364
+ if (backfillCompletionCovers(marker, roots, ["codex", "claude_code"])) {
2154
2365
  return {
2155
2366
  state: "done",
2156
2367
  remaining_count: 0,
2157
- updated_at: marker.cursor.updated_at,
2158
- completed_at: marker.completed_at,
2159
- 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()),
2160
2371
  };
2161
2372
  }
2162
- const cursor = await readBackfillCursor(paths);
2373
+ const cursor = (prepareBackfillCursorForScope(await readBackfillCursor(paths), roots, ["codex", "claude_code"])).cursor;
2163
2374
  if (!cursor.updated_at) {
2164
2375
  return {
2165
2376
  state: "never_run",
@@ -2177,60 +2388,9 @@ async function inspectBackfillCursor(homeDir) {
2177
2388
  sources: summarizeBackfillCursorSources(cursor),
2178
2389
  };
2179
2390
  }
2180
- async function readBackfillCompletionMarker(paths) {
2181
- try {
2182
- const raw = JSON.parse(await readFile(backfillCompletionMarkerPath(paths), "utf8"));
2183
- if (typeof raw.completed_at !== "string")
2184
- return null;
2185
- const cursor = raw.cursor && typeof raw.cursor === "object"
2186
- ? normalizeBackfillCursor(raw.cursor)
2187
- : await readBackfillCursor(paths);
2188
- return { completed_at: raw.completed_at, cursor };
2189
- }
2190
- catch {
2191
- return null;
2192
- }
2193
- }
2194
- function normalizeBackfillCursor(value) {
2195
- const record = value;
2196
- return {
2197
- schema_version: "cockpit-backfill-cursor.v1",
2198
- updated_at: typeof record.updated_at === "string" && record.updated_at
2199
- ? record.updated_at
2200
- : null,
2201
- sources: {
2202
- codex: {
2203
- oldest_mtime_ms_processed: typeof record.sources?.codex?.oldest_mtime_ms_processed === "number"
2204
- ? record.sources.codex.oldest_mtime_ms_processed
2205
- : null,
2206
- oldest_mtime_processed: typeof record.sources?.codex?.oldest_mtime_processed === "string"
2207
- ? record.sources.codex.oldest_mtime_processed
2208
- : null,
2209
- state_counts: numberRecord(record.sources?.codex?.state_counts),
2210
- reason_counts: numberRecord(record.sources?.codex?.reason_counts),
2211
- },
2212
- claude_code: {
2213
- oldest_mtime_ms_processed: typeof record.sources?.claude_code?.oldest_mtime_ms_processed === "number"
2214
- ? record.sources.claude_code.oldest_mtime_ms_processed
2215
- : null,
2216
- oldest_mtime_processed: typeof record.sources?.claude_code?.oldest_mtime_processed === "string"
2217
- ? record.sources.claude_code.oldest_mtime_processed
2218
- : null,
2219
- state_counts: numberRecord(record.sources?.claude_code?.state_counts),
2220
- reason_counts: numberRecord(record.sources?.claude_code?.reason_counts),
2221
- },
2222
- },
2223
- };
2224
- }
2225
- function numberRecord(value) {
2226
- if (!value || typeof value !== "object")
2227
- return {};
2228
- const out = {};
2229
- for (const [key, raw] of Object.entries(value)) {
2230
- if (typeof raw === "number" && Number.isFinite(raw))
2231
- out[key] = raw;
2232
- }
2233
- return out;
2391
+ async function currentBackfillRoots(homeDir) {
2392
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
2393
+ return normalizeCollectionRoots(config?.default_repo_paths ?? []);
2234
2394
  }
2235
2395
  function summarizeBackfillCursorSources(cursor) {
2236
2396
  return {
@@ -2289,14 +2449,6 @@ async function countClaudeMainFilesBeforeCursor(projectsDir, oldestProcessedMs)
2289
2449
  });
2290
2450
  return count;
2291
2451
  }
2292
- async function hasAnyJsonlFile(roots) {
2293
- let found = false;
2294
- await walkFiles(roots, async (_filePath, entryName) => {
2295
- if (entryName.endsWith(".jsonl"))
2296
- found = true;
2297
- }, () => found);
2298
- return found;
2299
- }
2300
2452
  async function walkFiles(roots, onFile, shouldStop = () => false) {
2301
2453
  const stack = [...roots];
2302
2454
  while (stack.length > 0 && !shouldStop()) {
@@ -2486,23 +2638,51 @@ async function runServe(command, io) {
2486
2638
  }
2487
2639
  async function runAutostart(command, io) {
2488
2640
  const exec = io.exec ?? defaultExec();
2641
+ const repoRoots = command.action === "install" || command.action === "status"
2642
+ ? await resolveAutostartRoots(command.homeDir, command.repoRoot)
2643
+ : [];
2489
2644
  const result = command.action === "install"
2490
2645
  ? await installAutostartAgent({
2491
2646
  homeDir: command.homeDir,
2492
- repoRoot: command.repoRoot,
2647
+ repoRoot: repoRoots[0],
2648
+ repoRoots,
2493
2649
  dashboardUrl: command.dashboardUrl,
2494
2650
  intervalSeconds: command.intervalSeconds,
2495
2651
  exec,
2496
2652
  })
2497
2653
  : command.action === "uninstall"
2498
2654
  ? await uninstallAutostartAgent({ homeDir: command.homeDir, exec })
2499
- : 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
+ });
2500
2663
  if (command.json) {
2501
2664
  writeLine(io.stdout, JSON.stringify(result, null, 2));
2502
- return result.status === "unsupported" ? 1 : 0;
2665
+ return autostartExitCode(command.action, result);
2503
2666
  }
2504
2667
  writeAutostartResult(io, result);
2505
- 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 ?? [];
2506
2686
  }
2507
2687
  async function runAgentRules(command, io) {
2508
2688
  const hosts = agentRuleHosts(command.host);
@@ -2552,9 +2732,9 @@ function writeAutostartResult(io, result) {
2552
2732
  case "installed":
2553
2733
  writeLine(io.stdout, result.loaded
2554
2734
  ? "Cockpit autostart installed and loaded."
2555
- : "Cockpit autostart installed (launchctl load reported a problem).");
2735
+ : "Cockpit autostart installed (the operating-system scheduler reported a problem).");
2556
2736
  writeLine(io.stdout, `Label: ${result.label}`);
2557
- writeLine(io.stdout, `Plist: ${result.plist_path}`);
2737
+ writeLine(io.stdout, autostartLocationLine(result));
2558
2738
  writeLine(io.stdout, `Interval: every ${result.interval_seconds}s`);
2559
2739
  writeLine(io.stdout, `Repo: ${result.work_dir}`);
2560
2740
  writeLine(io.stdout, `Dashboard: ${result.dashboard_url}`);
@@ -2563,45 +2743,37 @@ function writeAutostartResult(io, result) {
2563
2743
  return;
2564
2744
  case "uninstalled":
2565
2745
  writeLine(io.stdout, "Cockpit autostart removed.");
2566
- writeLine(io.stdout, `Plist: ${result.plist_path}`);
2746
+ writeLine(io.stdout, autostartLocationLine(result));
2567
2747
  return;
2568
2748
  case "absent":
2569
2749
  writeLine(io.stdout, "Cockpit autostart is not installed.");
2570
- writeLine(io.stdout, `Plist: ${result.plist_path}`);
2750
+ writeLine(io.stdout, autostartLocationLine(result));
2571
2751
  return;
2572
2752
  case "loaded":
2573
2753
  writeLine(io.stdout, "Cockpit autostart is installed and loaded.");
2574
- writeLine(io.stdout, `Plist: ${result.plist_path}`);
2754
+ writeLine(io.stdout, autostartLocationLine(result));
2575
2755
  return;
2576
2756
  case "not_loaded":
2577
- writeLine(io.stdout, "Cockpit autostart plist exists but is not loaded; run `cockpit autostart install` to reload.");
2578
- 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);
2579
2761
  return;
2580
2762
  case "unsupported":
2581
2763
  writeLine(io.stderr, `Autostart unsupported: ${result.message}`);
2582
2764
  return;
2583
2765
  }
2584
2766
  }
2767
+ function autostartLocationLine(result) {
2768
+ return result.task_name
2769
+ ? `Task: ${result.task_name}`
2770
+ : `Plist: ${result.plist_path}`;
2771
+ }
2585
2772
  function defaultExec() {
2586
- return (cmd, args) => new Promise((resolve) => {
2587
- execFile(cmd, args, { encoding: "utf8" }, (err, stdout, stderr) => {
2588
- // execFile's error `.code` is the exit code when numeric, but a string
2589
- // (e.g. "ENOENT") for spawn failures — treat those as a generic 1.
2590
- const code = err == null ? 0 : typeof err.code === "number" ? err.code : 1;
2591
- resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
2592
- });
2593
- });
2773
+ return createCapturedExecRunner();
2594
2774
  }
2595
2775
  function defaultInteractiveExec() {
2596
- return (cmd, args) => new Promise((resolve) => {
2597
- const child = spawn(cmd, args, { stdio: "inherit" });
2598
- child.on("error", (error) => {
2599
- resolve({ code: 1, stdout: "", stderr: errorMessage(error) });
2600
- });
2601
- child.on("close", (code) => {
2602
- resolve({ code: code ?? 1, stdout: "", stderr: "" });
2603
- });
2604
- });
2776
+ return createInteractiveExecRunner();
2605
2777
  }
2606
2778
  function defaultIo() {
2607
2779
  if (!globalThis.fetch) {