@bli-cockpit/cli 0.1.22 → 0.1.24

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.
@@ -74,6 +74,7 @@ function parseOnboardArgs(args) {
74
74
  kind: "onboard",
75
75
  homeDir: optionalNonEmpty(values.flags.get("--home")),
76
76
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
77
+ collectionRoots: optionalNonEmptyList(workRootFlagValues(values)),
77
78
  dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
78
79
  claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
79
80
  deviceName: optionalNonEmpty(values.flags.get("--device-name")),
@@ -403,6 +404,7 @@ function parseNamedArgs(args, options) {
403
404
  const allowed = new Set(options.allowedFlags);
404
405
  const valueFlags = new Set(options.valueFlags);
405
406
  const flags = new Map();
407
+ const flagValues = new Map();
406
408
  const booleans = new Set();
407
409
  const positionals = [];
408
410
  for (let index = 0; index < args.length; index += 1) {
@@ -422,6 +424,9 @@ function parseNamedArgs(args, options) {
422
424
  }
423
425
  rejectServiceRoleLikeArgument(value);
424
426
  flags.set(flag, value);
427
+ const existing = flagValues.get(flag) ?? [];
428
+ existing.push(value);
429
+ flagValues.set(flag, existing);
425
430
  if (inlineValue === undefined)
426
431
  index += 1;
427
432
  }
@@ -431,7 +436,7 @@ function parseNamedArgs(args, options) {
431
436
  booleans.add(flag);
432
437
  }
433
438
  }
434
- return { flags, booleans, positionals };
439
+ return { flags, flagValues, booleans, positionals };
435
440
  }
436
441
  function workRootFlagValue(values) {
437
442
  const provided = WORK_ROOT_FLAGS.filter((flag) => values.flags.has(flag));
@@ -443,6 +448,19 @@ function workRootFlagValue(values) {
443
448
  }
444
449
  return values.flags.get("--workspace") ?? values.flags.get("--repo");
445
450
  }
451
+ function workRootFlagValues(values) {
452
+ const roots = [];
453
+ for (const flag of WORK_ROOT_FLAGS) {
454
+ roots.push(...(values.flagValues.get(flag) ?? []));
455
+ }
456
+ return roots;
457
+ }
458
+ function optionalNonEmptyList(values) {
459
+ const filtered = values
460
+ .map((value) => optionalNonEmpty(value))
461
+ .filter((value) => Boolean(value));
462
+ return filtered.length > 0 ? filtered : undefined;
463
+ }
446
464
  function assertNoPositionals(positionals, command) {
447
465
  if (positionals.length > 0) {
448
466
  throw new Error(`${command} does not accept positional arguments.`);
@@ -4,13 +4,14 @@ import path from "node:path";
4
4
  import { createCollectorServer } from "../server.js";
5
5
  import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
6
6
  import { parseLocalArgs, normalizeUrl } from "./local-args.js";
7
- import { autostartStatus, installAutostartAgent, uninstallAutostartAgent } from "../autostart.js";
8
- import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext } from "../local-state.js";
7
+ import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
8
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
9
9
  import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
10
10
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
11
11
  import { acquireSyncLock } from "../sync-lock.js";
12
12
  import { discoverGitWorktrees } from "../repo-identity.js";
13
- import { runAttributedWorktreeSync } from "./session-sync.js";
13
+ import { runAttributedWorktreeSync, } from "./session-sync.js";
14
+ import { COLLECTION_ROOT_REQUIRED, resolveOnboardingRoots, } from "../onboarding-roots.js";
14
15
  export const rootCommandNames = new Set([
15
16
  "onboard",
16
17
  "install",
@@ -281,68 +282,112 @@ async function promptOnboardEmail(io) {
281
282
  }
282
283
  return answer;
283
284
  }
285
+ function onboardingRootPrompt(io) {
286
+ return {
287
+ confirm: async (message) => yesByDefault(await readLine(io, message)),
288
+ input: (message) => readLine(io, message),
289
+ };
290
+ }
291
+ function yesByDefault(raw) {
292
+ const answer = raw.trim().split(/\s+/u)[0]?.toLowerCase() ?? "";
293
+ return answer !== "n" && answer !== "no";
294
+ }
295
+ async function resolveOnboardEmail(command, roots, config, io) {
296
+ if (command.claimedOwnerEmail)
297
+ return command.claimedOwnerEmail;
298
+ const inferred = await inferOnboardEmail(command.homeDir, roots, config);
299
+ const interactive = !command.json && isInteractiveStdin(io);
300
+ if (!inferred) {
301
+ return interactive ? promptOnboardEmail(io) : undefined;
302
+ }
303
+ if (inferred.source === "session") {
304
+ if (!command.json)
305
+ writeLine(io.stdout, `Dashboard email: ${inferred.email}`);
306
+ return inferred.email;
307
+ }
308
+ if (!interactive)
309
+ return inferred.email;
310
+ const answer = (await readLine(io, `Use ${inferred.email} for Cockpit pairing? [Y/n] `)).trim().toLowerCase();
311
+ const typedEmail = answer.split(/\s+/u).find((part) => part.includes("@"));
312
+ if (typedEmail)
313
+ return typedEmail;
314
+ if (yesByDefault(answer)) {
315
+ return inferred.email;
316
+ }
317
+ return promptOnboardEmail(io);
318
+ }
319
+ async function inferOnboardEmail(homeDir, roots, config) {
320
+ const session = await readOnboardSessionReuseCandidate(homeDir).catch(() => null);
321
+ const sessionEmail = normalizeEmailForComparison(session?.email);
322
+ if (session?.session_state === "valid" && sessionEmail) {
323
+ return { email: sessionEmail, source: "session" };
324
+ }
325
+ const configEmail = normalizeEmailForComparison(config?.claimed_owner_email);
326
+ if (configEmail)
327
+ return { email: configEmail, source: "config" };
328
+ const gitEmail = await inferUniqueGitEmail(roots);
329
+ return gitEmail ? { email: gitEmail, source: "git" } : null;
330
+ }
331
+ async function inferUniqueGitEmail(roots) {
332
+ const emails = new Set();
333
+ for (const root of roots) {
334
+ const email = await readGitConfigEmail(root);
335
+ if (email)
336
+ emails.add(email);
337
+ }
338
+ return emails.size === 1 ? [...emails][0] ?? null : null;
339
+ }
340
+ async function readGitConfigEmail(root) {
341
+ return new Promise((resolve) => {
342
+ execFile("git", ["config", "user.email"], { cwd: root, encoding: "utf8" }, (error, stdout) => {
343
+ if (error) {
344
+ resolve(null);
345
+ return;
346
+ }
347
+ const email = normalizeEmailForComparison(stdout);
348
+ resolve(email?.includes("@") ? email : null);
349
+ });
350
+ });
351
+ }
284
352
  /**
285
- * After a successful onboard, offers to install the launchd autostart agent so a
286
- * Mac mini keeps syncing without anyone re-running cockpit — this is the fix for
287
- * interns drifting to Stale. Only in an interactive, non-JSON run with a real
288
- * exec runner (`io.exec`): headless / piped / spawned onboards and tests that
289
- * pass no exec skip it entirely. Declining leaves onboarding's success untouched.
353
+ * After a successful onboard, refreshes the launchd autostart agent so intern
354
+ * machines keep syncing without anyone re-running Cockpit. Runtimes without an
355
+ * exec runner skip this because launchd installation cannot be attempted there.
290
356
  */
291
- async function maybeOfferAutostart(command, io) {
292
- if (command.json || !isInteractiveStdin(io) || !io.exec)
293
- return;
294
- const answer = (await readLine(io, "Keep Cockpit syncing in the background, even after restart? [Y/n] "))
295
- .trim()
296
- .toLowerCase();
297
- if (answer === "n" || answer === "no") {
298
- writeLine(io.stdout, "Skipped background autostart. Run `cockpit autostart install` anytime.");
299
- return;
300
- }
357
+ async function refreshOnboardAutostart(command, roots, io) {
358
+ if (!io.exec)
359
+ return null;
301
360
  const result = await installAutostartAgent({
302
361
  homeDir: command.homeDir,
303
- repoRoot: command.repoRoot,
362
+ repoRoot: roots[0],
363
+ repoRoots: roots,
304
364
  dashboardUrl: command.dashboardUrl,
305
365
  exec: io.exec,
306
366
  });
367
+ if (command.json)
368
+ return result;
307
369
  if (result.status === "unsupported") {
308
370
  writeLine(io.stdout, `Background autostart unsupported: ${result.message}`);
309
- return;
371
+ return result;
310
372
  }
311
373
  writeLine(io.stdout, result.loaded
312
- ? "Background autostart installed; Cockpit syncs at login and every 15 min."
313
- : "Background autostart installed, but launchctl load reported a problem; check `cockpit autostart status`.");
374
+ ? "Background autostart refreshed; Cockpit syncs at login and every 15 min."
375
+ : "Background autostart refreshed, but launchctl load reported a problem; check `cockpit autostart status`.");
314
376
  writeLine(io.stdout, `Plist: ${result.plist_path}`);
377
+ return result;
315
378
  }
316
- async function maybeOfferAgentRules(command, io) {
317
- if (command.json || !isInteractiveStdin(io))
318
- return;
319
- const scopePath = path.resolve(command.repoRoot ?? process.cwd());
320
- const current = await inspectAgentRules({
379
+ async function refreshOnboardAgentRules(command, roots, io) {
380
+ const result = await installAgentRules({
321
381
  homeDir: command.homeDir,
322
- scopePath,
382
+ scopePaths: roots,
323
383
  });
324
- if (current.installed) {
325
- writeLine(io.stdout, onboardAgentRulesAlreadyInstalledLine(current));
326
- return;
327
- }
328
- const answer = (await readLine(io, `Add Cockpit ticket-binding rules scoped to ${scopePath} to AGENTS.md and CLAUDE.md? [Y/n] `))
329
- .trim()
330
- .toLowerCase();
331
- if (answer === "n" || answer === "no") {
332
- writeLine(io.stdout, "Skipped agent rules. Run `cockpit agent-rules install --workspace \"$PWD\"` anytime.");
333
- return;
334
- }
335
- const result = await installAgentRules({ homeDir: command.homeDir, scopePath });
384
+ if (command.json)
385
+ return result;
336
386
  writeLine(io.stdout, `Agent rules: ${onboardAgentRulesInstallLine(result)}`);
337
387
  for (const target of result.targets) {
338
388
  writeLine(io.stdout, `${agentRuleHostLabel(target.host)}: ${target.rules_file}`);
339
389
  }
340
- }
341
- function onboardAgentRulesAlreadyInstalledLine(result) {
342
- const hasEquivalent = result.targets.some((target) => target.state === "equivalent");
343
- return hasEquivalent
344
- ? "Agent rules: matching Cockpit ticket-binding guidance already exists."
345
- : "Agent rules: already current in AGENTS.md and CLAUDE.md.";
390
+ return result;
346
391
  }
347
392
  function onboardAgentRulesInstallLine(result) {
348
393
  if (result.targets.some((target) => target.stale_block_replaced)) {
@@ -359,24 +404,78 @@ function onboardAgentRulesInstallLine(result) {
359
404
  return "not installed.";
360
405
  }
361
406
  }
407
+ function writeOnboardLiveStatus(io, command, roots, options) {
408
+ writeLine(io.stdout, "You're live.");
409
+ writeLine(io.stdout, "Collecting from:");
410
+ for (const root of roots)
411
+ writeLine(io.stdout, `- ${root}`);
412
+ writeLine(io.stdout, `Paired user: ${options.pair?.session.email ??
413
+ options.pair?.session.auth_subject_id ??
414
+ (options.status?.session_state === "valid" ? "existing valid session" : "pending")}`);
415
+ writeLine(io.stdout, `Background sync: ${backgroundSyncLine(options.autostart)}`);
416
+ writeLine(io.stdout, `Initial sync: ${options.initialSyncOk ? options.sync?.status ?? "uploaded" : "blocked"}`);
417
+ writeLine(io.stdout, `Agent rules: ${options.agentRules ? onboardAgentRulesInstallLine(options.agentRules) : "not refreshed"}`);
418
+ writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}/my-work`);
419
+ writeLine(io.stdout, "Next: cockpit status");
420
+ }
421
+ function backgroundSyncLine(result) {
422
+ if (!result)
423
+ return "not refreshed (launchd runner unavailable)";
424
+ if (result.status === "unsupported")
425
+ return `unsupported (${result.message})`;
426
+ if (result.status === "installed" && result.loaded)
427
+ return "installed";
428
+ if (result.status === "installed") {
429
+ return `installed with warning (${result.message ?? "launchctl load failed"})`;
430
+ }
431
+ return result.status;
432
+ }
362
433
  async function runOnboard(command, io) {
363
434
  let install = null;
364
435
  let pair = null;
365
436
  let sync = null;
366
437
  let status = null;
438
+ let rootsResult = null;
439
+ let agentRules = null;
440
+ let autostart = null;
367
441
  try {
368
442
  if (!command.json) {
369
443
  writeLine(io.stdout, "Cockpit harvest onboarding");
370
444
  writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
371
445
  writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
372
446
  }
373
- let claimedOwnerEmail = command.claimedOwnerEmail;
374
- if (!claimedOwnerEmail && !command.json && isInteractiveStdin(io)) {
375
- claimedOwnerEmail = await promptOnboardEmail(io);
447
+ const paths = getCollectorRuntimePaths(command.homeDir);
448
+ const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
449
+ const interactive = !command.json && isInteractiveStdin(io);
450
+ rootsResult = await resolveOnboardingRoots({
451
+ homeDir: command.homeDir,
452
+ explicitRoots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
453
+ config: existingConfig,
454
+ interactive,
455
+ prompt: interactive ? onboardingRootPrompt(io) : undefined,
456
+ });
457
+ const collectionRoots = rootsResult.roots;
458
+ const primaryRoot = collectionRoots[0];
459
+ if (!primaryRoot) {
460
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: no collection root confirmed.`);
461
+ }
462
+ const resolvedCommand = {
463
+ ...command,
464
+ repoRoot: primaryRoot,
465
+ collectionRoots,
466
+ };
467
+ const replaceRepoRoots = rootsResult.source === "prompt" &&
468
+ !command.collectionRoots?.length &&
469
+ (existingConfig?.default_repo_paths.length ?? 0) > 0;
470
+ if (!command.json) {
471
+ writeLine(io.stdout, `Collecting from: ${collectionRoots.join(", ")}`);
376
472
  }
473
+ const claimedOwnerEmail = await resolveOnboardEmail(resolvedCommand, collectionRoots, existingConfig, io);
377
474
  install = await installLocalCollector({
378
475
  homeDir: command.homeDir,
379
- repoRoot: command.repoRoot,
476
+ repoRoot: primaryRoot,
477
+ repoRoots: collectionRoots,
478
+ replaceRepoRoots,
380
479
  dashboardUrl: command.dashboardUrl,
381
480
  deviceName: command.deviceName,
382
481
  });
@@ -386,7 +485,7 @@ async function runOnboard(command, io) {
386
485
  }
387
486
  const installedStatus = await inspectLocalCollectorStatus({
388
487
  homeDir: command.homeDir,
389
- repoRoot: command.repoRoot,
488
+ repoRoot: primaryRoot,
390
489
  branch: command.branch,
391
490
  });
392
491
  const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
@@ -421,26 +520,41 @@ async function runOnboard(command, io) {
421
520
  writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
422
521
  }
423
522
  }
424
- const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
523
+ const worktrees = await discoverCommandWorktrees(collectionRoots, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
425
524
  if (worktrees.length > 1) {
426
- const multi = await runMultiRepoOnboard(command, io, worktrees);
525
+ const multi = await runMultiRepoOnboard(resolvedCommand, io, worktrees);
526
+ if (multi.ok) {
527
+ agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
528
+ autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
529
+ }
427
530
  if (command.json) {
428
531
  writeLine(io.stdout, JSON.stringify({
429
- ...onboardResult(multi.ok ? "pass" : "blocked", command, install, pair, null, null),
532
+ ...onboardResult(multi.ok ? "pass" : "blocked", resolvedCommand, install, pair, null, null),
533
+ collection_roots: collectionRoots,
534
+ root_resolution: rootsResult,
535
+ agent_rules: agentRules,
536
+ autostart,
430
537
  mode: "multi_repo",
431
538
  repos: multi.results,
432
539
  codex_sessions: multi.codex_sessions,
433
540
  }, null, 2));
434
541
  }
435
- if (multi.ok) {
436
- await maybeOfferAgentRules(command, io);
437
- await maybeOfferAutostart(command, io);
542
+ if (multi.ok && !command.json) {
543
+ writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
544
+ pair,
545
+ status,
546
+ sync: null,
547
+ agentRules,
548
+ autostart,
549
+ initialSyncOk: true,
550
+ });
438
551
  }
439
552
  return multi.ok ? 0 : 1;
440
553
  }
554
+ const worktreeRoot = worktrees[0]?.repo_root ?? primaryRoot;
441
555
  const context = await startLocalWorkContext({
442
556
  homeDir: command.homeDir,
443
- repoRoot: command.repoRoot,
557
+ repoRoot: worktreeRoot,
444
558
  branch: command.branch,
445
559
  activeTicketId: command.activeTicketId,
446
560
  });
@@ -464,13 +578,15 @@ async function runOnboard(command, io) {
464
578
  }
465
579
  status = await inspectLocalCollectorStatus({
466
580
  homeDir: command.homeDir,
467
- repoRoot: command.repoRoot,
581
+ repoRoot: worktreeRoot,
468
582
  branch: command.branch,
469
583
  });
470
584
  if (sync.status !== "uploaded") {
471
585
  if (command.json) {
472
586
  writeLine(io.stdout, JSON.stringify({
473
- ...onboardResult("blocked", command, install, pair, sync, status),
587
+ ...onboardResult("blocked", resolvedCommand, install, pair, sync, status),
588
+ collection_roots: collectionRoots,
589
+ root_resolution: rootsResult,
474
590
  codex_sessions: run.summary,
475
591
  }, null, 2));
476
592
  }
@@ -482,8 +598,14 @@ async function runOnboard(command, io) {
482
598
  return 1;
483
599
  }
484
600
  if (command.json) {
601
+ agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
602
+ autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
485
603
  writeLine(io.stdout, JSON.stringify({
486
- ...onboardResult("pass", command, install, pair, sync, status),
604
+ ...onboardResult("pass", resolvedCommand, install, pair, sync, status),
605
+ collection_roots: collectionRoots,
606
+ root_resolution: rootsResult,
607
+ agent_rules: agentRules,
608
+ autostart,
487
609
  codex_sessions: run.summary,
488
610
  }, null, 2));
489
611
  return 0;
@@ -500,22 +622,35 @@ async function runOnboard(command, io) {
500
622
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
501
623
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
502
624
  writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
503
- await maybeOfferAgentRules(command, io);
504
- await maybeOfferAutostart(command, io);
625
+ agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
626
+ autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
627
+ writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
628
+ pair,
629
+ status,
630
+ sync,
631
+ agentRules,
632
+ autostart,
633
+ initialSyncOk: true,
634
+ });
505
635
  return 0;
506
636
  }
507
637
  catch (error) {
508
638
  const message = errorMessage(error);
509
- status = await inspectLocalCollectorStatus({
510
- homeDir: command.homeDir,
511
- repoRoot: command.repoRoot,
512
- branch: command.branch,
513
- }).catch(() => null);
639
+ const statusRoot = rootsResult?.roots[0] ?? command.repoRoot;
640
+ status = statusRoot
641
+ ? await inspectLocalCollectorStatus({
642
+ homeDir: command.homeDir,
643
+ repoRoot: statusRoot,
644
+ branch: command.branch,
645
+ }).catch(() => null)
646
+ : null;
514
647
  const blocker = classifyOnboardBlocker(message);
515
648
  const nextStep = nextStepForOnboardBlocker(blocker);
516
649
  if (command.json) {
517
650
  writeLine(io.stdout, JSON.stringify({
518
651
  ...onboardResult("blocked", command, install, pair, sync, status),
652
+ collection_roots: rootsResult?.roots ?? [],
653
+ root_resolution: rootsResult,
519
654
  blocker,
520
655
  message,
521
656
  next_step: nextStep,
@@ -625,6 +760,13 @@ function cursorStatusLine(sync) {
625
760
  const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
626
761
  const DEFAULT_DISCOVERY_MAX_REPOS = 50;
627
762
  async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
763
+ if (Array.isArray(repoRoot)) {
764
+ const discovered = [];
765
+ for (const root of repoRoot) {
766
+ discovered.push(...(await discoverCommandWorktrees(root, discovery, io)));
767
+ }
768
+ return dedupeWorktrees(discovered);
769
+ }
628
770
  const maxWorktrees = discovery.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS;
629
771
  const worktrees = await discoverGitWorktrees(repoRoot ?? process.cwd(), {
630
772
  maxDepth: discovery.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
@@ -638,6 +780,18 @@ async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
638
780
  }
639
781
  return worktrees;
640
782
  }
783
+ function dedupeWorktrees(worktrees) {
784
+ const seen = new Set();
785
+ const deduped = [];
786
+ for (const worktree of worktrees) {
787
+ const key = worktree.worktree_fingerprint || path.resolve(worktree.repo_root);
788
+ if (seen.has(key))
789
+ continue;
790
+ seen.add(key);
791
+ deduped.push(worktree);
792
+ }
793
+ return deduped;
794
+ }
641
795
  async function runMultiRepoOnboard(command, io, worktrees) {
642
796
  if (!command.json) {
643
797
  writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
@@ -743,6 +897,7 @@ function onboardResult(resultStatus, command, install, pair, sync, status) {
743
897
  work_context_id: sync?.work_context_id ?? status?.work_context_id ?? null,
744
898
  config_file: install?.paths.config_file ?? status?.config_file ?? null,
745
899
  session_file: install?.paths.session_file ?? status?.session_file ?? null,
900
+ collection_roots: command.collectionRoots ?? (command.repoRoot ? [command.repoRoot] : []),
746
901
  paired_user: pair?.session.email ?? pair?.session.auth_subject_id ?? null,
747
902
  paired_device: pair?.session.device_name ?? pair?.session.device_id ?? null,
748
903
  upload_status: sync?.status ?? null,
@@ -756,6 +911,8 @@ function onboardResult(resultStatus, command, install, pair, sync, status) {
756
911
  };
757
912
  }
758
913
  function classifyOnboardBlocker(message) {
914
+ if (message.includes(COLLECTION_ROOT_REQUIRED))
915
+ return COLLECTION_ROOT_REQUIRED;
759
916
  if (/ticket/i.test(message))
760
917
  return "ticket";
761
918
  if (/pair|paired|approval|expired|revoked/i.test(message))
@@ -772,6 +929,8 @@ function classifyOnboardBlocker(message) {
772
929
  }
773
930
  function nextStepForOnboardBlocker(blocker) {
774
931
  switch (blocker) {
932
+ case COLLECTION_ROOT_REQUIRED:
933
+ return "Run `cockpit onboard --workspace <path>` or rerun in a terminal and confirm the collection root.";
775
934
  case "ticket":
776
935
  case "ticket_binding":
777
936
  return "Run `cockpit start --ticket <id>` when actual ticket work begins, then run `cockpit sync`.";
@@ -23,9 +23,10 @@ function cockpitHelp() {
23
23
  localCommandHelp(),
24
24
  "",
25
25
  "Install/update: `npm install -g @bli-cockpit/cli@latest`.",
26
- "Intern path: run `cockpit onboard` from the repo root; add `--ticket <id>` only when work already has a ticket.",
27
- "Already onboarded: run `cockpit sync --workspace \"$PWD\" --json`.",
28
- "Agent setup: interactive `cockpit onboard` offers AGENTS.md/CLAUDE.md rules; use `cockpit agent-rules install --workspace \"$PWD\"` for repair/headless setup.",
26
+ "Intern path: run `cockpit onboard`; it confirms a `/BLI` collection root before syncing.",
27
+ "Headless/reused laptop path: `cockpit onboard --email <email> --workspace ~/BLI`.",
28
+ "Already onboarded: rerun `cockpit onboard` from anywhere to refresh pairing, roots, agent rules, autostart, and sync.",
29
+ "Agent setup: `cockpit onboard` refreshes AGENTS.md/CLAUDE.md rules; use `cockpit agent-rules install --workspace ~/BLI` for repair.",
29
30
  "Dashboard URL is optional for normal production use; pass `--dashboard-url` only for staging/custom dashboards or forced re-pairing.",
30
31
  "Manual collector path: `install`, `login`, `start [--ticket <id>] [--topic <label>] [--intent <intent>] [--phase <phase>]`, `sync`, `status`, `agent-rules`.",
31
32
  ].join("\n");
@@ -1,4 +1,4 @@
1
- import { RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, } from "@bli-cockpit/telemetry-core";
1
+ import { RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, RawEvidenceRedactionMetadataSchema, RawEvidenceUploadCommitResponseSchema, } from "@bli-cockpit/telemetry-core";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  const DEFAULT_MAX_ATTEMPTS = 3;
@@ -114,7 +114,7 @@ export async function uploadRawEvidenceFilesChunked(options) {
114
114
  function duplicateOutcome(primary, duplicate) {
115
115
  return {
116
116
  ...primary,
117
- pointer: duplicate.pointer,
117
+ pointer: pointerWithUploadedMetadata(duplicate.pointer, primary.pointer),
118
118
  codex_session_id: duplicate.codex_session_id ?? null,
119
119
  kind: duplicate.kind ?? "unknown",
120
120
  artifact_metadata: duplicate.artifact_metadata,
@@ -181,9 +181,10 @@ async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
181
181
  const commitStatus = commit.body && typeof commit.body === "object"
182
182
  ? commit.body.status
183
183
  : undefined;
184
+ const committedPointer = pointerWithCommitResponse(entry.file.pointer, commit.body);
184
185
  if (commitStatus === "already_committed") {
185
186
  return {
186
- pointer: entry.file.pointer,
187
+ pointer: committedPointer,
187
188
  object_key: objectKey,
188
189
  codex_session_id: entry.file.codex_session_id ?? null,
189
190
  kind: entry.file.kind ?? "unknown",
@@ -194,7 +195,7 @@ async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
194
195
  };
195
196
  }
196
197
  return {
197
- pointer: entry.file.pointer,
198
+ pointer: committedPointer,
198
199
  object_key: objectKey,
199
200
  codex_session_id: entry.file.codex_session_id ?? null,
200
201
  kind: entry.file.kind ?? "unknown",
@@ -227,8 +228,9 @@ async function uploadWithLegacyFallback(options, loaded, outcomes) {
227
228
  ],
228
229
  });
229
230
  if (response.ok) {
231
+ const pointer = pointerWithLegacyUploadResponse(entry.file.pointer, response.body);
230
232
  outcome = {
231
- pointer: entry.file.pointer,
233
+ pointer,
232
234
  object_key: entry.file.pointer.object_key ?? "",
233
235
  codex_session_id: entry.file.codex_session_id ?? null,
234
236
  kind: entry.file.kind ?? "unknown",
@@ -331,6 +333,50 @@ function failedOutcome(file, reason, uploadedChunks = 0) {
331
333
  uploaded_chunk_count: uploadedChunks,
332
334
  };
333
335
  }
336
+ function pointerWithCommitResponse(pointer, body) {
337
+ const parsed = RawEvidenceUploadCommitResponseSchema.safeParse(body);
338
+ if (!parsed.success)
339
+ return pointer;
340
+ return pointerWithUploadedMetadata(pointer, {
341
+ content_hash_sha256: parsed.data.content_hash_sha256,
342
+ byte_size: parsed.data.byte_size,
343
+ redaction: parsed.data.redaction,
344
+ });
345
+ }
346
+ function pointerWithLegacyUploadResponse(pointer, body) {
347
+ if (!body || typeof body !== "object")
348
+ return pointer;
349
+ const uploaded = body.uploaded;
350
+ if (!Array.isArray(uploaded))
351
+ return pointer;
352
+ const entry = uploaded.find((candidate) => {
353
+ if (!candidate || typeof candidate !== "object")
354
+ return false;
355
+ return (candidate
356
+ .raw_evidence_pointer_id === pointer.raw_evidence_pointer_id);
357
+ });
358
+ if (!entry || typeof entry !== "object")
359
+ return pointer;
360
+ const record = entry;
361
+ return pointerWithUploadedMetadata(pointer, {
362
+ content_hash_sha256: typeof record["content_hash_sha256"] === "string"
363
+ ? record["content_hash_sha256"]
364
+ : undefined,
365
+ byte_size: typeof record["byte_size"] === "number" ? record["byte_size"] : undefined,
366
+ redaction: record["redaction"],
367
+ });
368
+ }
369
+ function pointerWithUploadedMetadata(pointer, metadata) {
370
+ const redaction = RawEvidenceRedactionMetadataSchema.safeParse(metadata.redaction);
371
+ return {
372
+ ...pointer,
373
+ content_hash_sha256: typeof metadata.content_hash_sha256 === "string"
374
+ ? metadata.content_hash_sha256
375
+ : pointer.content_hash_sha256,
376
+ byte_size: typeof metadata.byte_size === "number" ? metadata.byte_size : pointer.byte_size,
377
+ redaction: redaction.success ? redaction.data : pointer.redaction,
378
+ };
379
+ }
334
380
  function summarizeOutcomes(outcomes, usedLegacyFallback) {
335
381
  const uploaded = outcomes.filter((outcome) => outcome.upload_state === "uploaded");
336
382
  return {
@@ -21,12 +21,15 @@ export function getCollectorRuntimePaths(homeDir = os.homedir()) {
21
21
  }
22
22
  export async function installLocalCollector(options = {}) {
23
23
  const homeDir = options.homeDir ?? os.homedir();
24
- const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
24
+ const repoRoots = normalizeRepoRoots(options.repoRoots);
25
+ const repoRoot = path.resolve(options.repoRoot ?? repoRoots[0] ?? process.cwd());
25
26
  const paths = getCollectorRuntimePaths(homeDir);
26
27
  await ensureRuntimeDirectories(paths);
27
28
  const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
28
- const defaultRepoPaths = new Set(existingConfig?.default_repo_paths ?? []);
29
- defaultRepoPaths.add(repoRoot);
29
+ const defaultRepoPaths = new Set(options.replaceRepoRoots ? [] : (existingConfig?.default_repo_paths ?? []));
30
+ for (const root of repoRoots.length > 0 ? repoRoots : [repoRoot]) {
31
+ defaultRepoPaths.add(root);
32
+ }
30
33
  const rawEvidenceUpload = existingConfig?.raw_evidence_upload === "disabled" ||
31
34
  existingConfig?.raw_evidence_upload === "remote_short_retention_opt_in"
32
35
  ? "remote_durable_opt_in"
@@ -55,6 +58,20 @@ export async function installLocalCollector(options = {}) {
55
58
  message: "Local collector installed. Pair/login is still required before remote upload.",
56
59
  };
57
60
  }
61
+ function normalizeRepoRoots(repoRoots) {
62
+ if (!repoRoots)
63
+ return [];
64
+ const seen = new Set();
65
+ const normalized = [];
66
+ for (const root of repoRoots) {
67
+ const resolved = path.resolve(root);
68
+ if (seen.has(resolved))
69
+ continue;
70
+ seen.add(resolved);
71
+ normalized.push(resolved);
72
+ }
73
+ return normalized;
74
+ }
58
75
  export async function pairLocalCollector(options = {}) {
59
76
  const homeDir = options.homeDir ?? os.homedir();
60
77
  const paths = getCollectorRuntimePaths(homeDir);