@bli-cockpit/cli 0.1.3 → 0.1.6

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,6 +1,11 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
1
3
  import { createCollectorServer } from "../server.js";
2
4
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
3
- import { syncLocalAmbientEnvelope } from "../upload.js";
5
+ import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope, } from "../upload.js";
6
+ import { scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
7
+ import { countStaleSessions, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
8
+ import { discoverGitWorktrees, } from "../repo-identity.js";
4
9
  export const rootCommandNames = new Set([
5
10
  "onboard",
6
11
  "install",
@@ -56,14 +61,14 @@ export function localCommandHelp(command) {
56
61
  if (command)
57
62
  return localSubcommandHelp(command);
58
63
  return [
59
- " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--json]",
64
+ " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
60
65
  " cockpit install [--dashboard-url <url>] [--repo <path>] [--json]",
61
66
  " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
62
67
  " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
63
68
  " cockpit logout",
64
- " cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--json]",
65
- " cockpit sync [--repo <path>] [--dashboard-url <url>] [--json]",
66
- " cockpit status [--repo <path>] [--json]",
69
+ " cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
70
+ " cockpit sync [--repo <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
71
+ " cockpit status [--repo <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
67
72
  " cockpit serve [--port <port>] [--repo <path>]",
68
73
  ].join("\n");
69
74
  }
@@ -72,9 +77,10 @@ function localSubcommandHelp(command) {
72
77
  [
73
78
  "onboard",
74
79
  [
75
- "Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--json]",
80
+ "Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--branch <name>] [--json]",
76
81
  "",
77
- "Installs, pairs, starts a work context, syncs once, and prints readiness proof.",
82
+ "Installs, pairs, starts work context(s), syncs once, and prints readiness proof.",
83
+ "If --repo is a parent folder, scans child git repos/worktrees and rolls them up by repo.",
78
84
  "Use --email on shared or reused machines; mismatched existing sessions are re-paired.",
79
85
  ],
80
86
  ],
@@ -108,7 +114,8 @@ function localSubcommandHelp(command) {
108
114
  [
109
115
  "Usage: cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--json]",
110
116
  "",
111
- "Starts local ambient capture. Add --ticket only when the work already has a visible ticket.",
117
+ "Starts local ambient capture. Parent folders start each child git worktree.",
118
+ "Add --ticket only when the work already has a visible ticket.",
112
119
  ],
113
120
  ],
114
121
  [
@@ -116,7 +123,13 @@ function localSubcommandHelp(command) {
116
123
  [
117
124
  "Usage: cockpit sync [--repo <path>] [--dashboard-url <url>] [--json]",
118
125
  "",
119
- "Uploads the latest local ambient envelope or spools a safe retry if blocked.",
126
+ "Uploads latest local ambient envelope(s), or spools safe retries if blocked.",
127
+ "Parent folders sync each child git worktree; Codex JSONL transcripts are attributed",
128
+ "to repos deterministically and ambiguous transcripts are retained as unattributed",
129
+ "instead of being duplicated across repos.",
130
+ "Newly discovered repos get a general ambient work context automatically.",
131
+ "Discovery scans 3 folder levels and up to 50 repos by default; tune with",
132
+ "--max-depth and --max-repos.",
120
133
  ],
121
134
  ],
122
135
  [
@@ -181,6 +194,8 @@ function parseOnboardArgs(args) {
181
194
  "--json",
182
195
  "--poll-interval-ms",
183
196
  "--timeout-ms",
197
+ "--max-depth",
198
+ "--max-repos",
184
199
  ],
185
200
  valueFlags: [
186
201
  "--home",
@@ -192,6 +207,8 @@ function parseOnboardArgs(args) {
192
207
  "--branch",
193
208
  "--poll-interval-ms",
194
209
  "--timeout-ms",
210
+ "--max-depth",
211
+ "--max-repos",
195
212
  ],
196
213
  });
197
214
  assertNoPositionals(values.positionals, "onboard");
@@ -207,6 +224,8 @@ function parseOnboardArgs(args) {
207
224
  json: values.booleans.has("--json"),
208
225
  pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
209
226
  timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
227
+ maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
228
+ maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
210
229
  };
211
230
  }
212
231
  function parseInstallArgs(args) {
@@ -284,6 +303,8 @@ function parseStartArgs(args) {
284
303
  "--operator-id",
285
304
  "--session-id",
286
305
  "--json",
306
+ "--max-depth",
307
+ "--max-repos",
287
308
  ],
288
309
  valueFlags: [
289
310
  "--home",
@@ -292,6 +313,8 @@ function parseStartArgs(args) {
292
313
  "--ticket",
293
314
  "--operator-id",
294
315
  "--session-id",
316
+ "--max-depth",
317
+ "--max-repos",
295
318
  ],
296
319
  });
297
320
  assertNoPositionals(values.positionals, "start");
@@ -304,12 +327,14 @@ function parseStartArgs(args) {
304
327
  operatorId: optionalNonEmpty(values.flags.get("--operator-id")),
305
328
  sessionId: optionalNonEmpty(values.flags.get("--session-id")),
306
329
  json: values.booleans.has("--json"),
330
+ maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
331
+ maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
307
332
  };
308
333
  }
309
334
  function parseSyncArgs(args) {
310
335
  const values = parseNamedArgs(args, {
311
- allowedFlags: ["--home", "--repo", "--dashboard-url", "--json"],
312
- valueFlags: ["--home", "--repo", "--dashboard-url"],
336
+ allowedFlags: ["--home", "--repo", "--dashboard-url", "--json", "--max-depth", "--max-repos"],
337
+ valueFlags: ["--home", "--repo", "--dashboard-url", "--max-depth", "--max-repos"],
313
338
  });
314
339
  assertNoPositionals(values.positionals, "sync");
315
340
  return {
@@ -318,12 +343,14 @@ function parseSyncArgs(args) {
318
343
  repoRoot: optionalNonEmpty(values.flags.get("--repo")),
319
344
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
320
345
  json: values.booleans.has("--json"),
346
+ maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
347
+ maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
321
348
  };
322
349
  }
323
350
  function parseStatusArgs(args) {
324
351
  const values = parseNamedArgs(args, {
325
- allowedFlags: ["--home", "--repo", "--json"],
326
- valueFlags: ["--home", "--repo"],
352
+ allowedFlags: ["--home", "--repo", "--json", "--max-depth", "--max-repos"],
353
+ valueFlags: ["--home", "--repo", "--max-depth", "--max-repos"],
327
354
  });
328
355
  assertNoPositionals(values.positionals, "status");
329
356
  return {
@@ -331,6 +358,8 @@ function parseStatusArgs(args) {
331
358
  homeDir: optionalNonEmpty(values.flags.get("--home")),
332
359
  repoRoot: optionalNonEmpty(values.flags.get("--repo")),
333
360
  json: values.booleans.has("--json"),
361
+ maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
362
+ maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
334
363
  };
335
364
  }
336
365
  function parseServeArgs(args) {
@@ -455,6 +484,19 @@ async function runOnboard(command, io) {
455
484
  writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
456
485
  }
457
486
  }
487
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
488
+ if (worktrees.length > 1) {
489
+ const multi = await runMultiRepoOnboard(command, io, worktrees);
490
+ if (command.json) {
491
+ writeLine(io.stdout, JSON.stringify({
492
+ ...onboardResult(multi.ok ? "pass" : "blocked", command, install, pair, null, null),
493
+ mode: "multi_repo",
494
+ repos: multi.results,
495
+ codex_sessions: multi.codex_sessions,
496
+ }, null, 2));
497
+ }
498
+ return multi.ok ? 0 : 1;
499
+ }
458
500
  const context = await startLocalWorkContext({
459
501
  homeDir: command.homeDir,
460
502
  repoRoot: command.repoRoot,
@@ -468,12 +510,17 @@ async function runOnboard(command, io) {
468
510
  writeLine(io.stdout, `Ticket: ${context.active_ticket_id ?? "general ambient"}`);
469
511
  writeLine(io.stdout, `Context: ${context.work_context_id}`);
470
512
  }
471
- sync = await syncLocalAmbientEnvelope({
513
+ const run = await runAttributedWorktreeSync({
472
514
  homeDir: command.homeDir,
473
- repoRoot: command.repoRoot,
474
515
  dashboardUrl: command.dashboardUrl,
475
- fetch: io.fetch,
516
+ startContexts: false,
517
+ worktrees,
518
+ fetchImpl: io.fetch,
476
519
  });
520
+ sync = run.outcomes[0]?.sync ?? null;
521
+ if (!sync) {
522
+ throw new Error("Onboard sync produced no result for the repo worktree.");
523
+ }
477
524
  status = await inspectLocalCollectorStatus({
478
525
  homeDir: command.homeDir,
479
526
  repoRoot: command.repoRoot,
@@ -481,7 +528,10 @@ async function runOnboard(command, io) {
481
528
  });
482
529
  if (sync.status !== "uploaded") {
483
530
  if (command.json) {
484
- writeLine(io.stdout, JSON.stringify(onboardResult("blocked", command, install, pair, sync, status), null, 2));
531
+ writeLine(io.stdout, JSON.stringify({
532
+ ...onboardResult("blocked", command, install, pair, sync, status),
533
+ codex_sessions: run.summary,
534
+ }, null, 2));
485
535
  }
486
536
  else {
487
537
  writeLine(io.stderr, "BLOCKED: ambient upload failed; safe retry metadata was spooled.");
@@ -491,7 +541,10 @@ async function runOnboard(command, io) {
491
541
  return 1;
492
542
  }
493
543
  if (command.json) {
494
- writeLine(io.stdout, JSON.stringify(onboardResult("pass", command, install, pair, sync, status), null, 2));
544
+ writeLine(io.stdout, JSON.stringify({
545
+ ...onboardResult("pass", command, install, pair, sync, status),
546
+ codex_sessions: run.summary,
547
+ }, null, 2));
495
548
  return 0;
496
549
  }
497
550
  writeLine(io.stdout, "4/5 Ambient metadata uploaded.");
@@ -500,6 +553,9 @@ async function runOnboard(command, io) {
500
553
  writeLine(io.stdout, `Sources: ${sync.source_scan_count}`);
501
554
  writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
502
555
  writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
556
+ writeLine(io.stdout, rawEvidenceSyncLine(sync));
557
+ writeLine(io.stdout, codexSummaryLine(run.summary));
558
+ writeLine(io.stdout, attributionReportLine(run.summary));
503
559
  writeLine(io.stdout, "5/5 Status ready.");
504
560
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
505
561
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
@@ -560,6 +616,300 @@ async function readOnboardSessionReuseCandidate(homeDir) {
560
616
  function normalizeUrlForComparison(value) {
561
617
  return value ? normalizeUrl(value) : null;
562
618
  }
619
+ /**
620
+ * Shared sync orchestration for single-repo and parent-folder modes: Codex
621
+ * sessions are scanned and attributed once across every discovered worktree,
622
+ * each worktree syncs with only its own attributed transcripts, and the
623
+ * ambiguous/unattributed/skipped remainder is reported with reason labels
624
+ * instead of being duplicated into every repo or silently dropped.
625
+ */
626
+ async function runAttributedWorktreeSync(options) {
627
+ const now = new Date();
628
+ const homeDir = options.homeDir ?? os.homedir();
629
+ const attribution = await scanAndAttributeCodexSessions({
630
+ sessionsDir: path.join(homeDir, ".codex", "sessions"),
631
+ worktrees: options.worktrees,
632
+ now,
633
+ });
634
+ const outcomes = [];
635
+ let ok = true;
636
+ for (const worktree of options.worktrees) {
637
+ let context = null;
638
+ if (options.startContexts) {
639
+ context = await startLocalWorkContext({
640
+ homeDir: options.homeDir,
641
+ repoRoot: worktree.repo_root,
642
+ branch: options.branch,
643
+ activeTicketId: options.activeTicketId,
644
+ operatorId: options.operatorId,
645
+ sessionId: options.sessionId,
646
+ });
647
+ }
648
+ const syncOptions = {
649
+ homeDir: options.homeDir,
650
+ repoRoot: worktree.repo_root,
651
+ dashboardUrl: options.dashboardUrl,
652
+ codexSessionFiles: attribution.results
653
+ .filter((result) => result.state === "attributed" &&
654
+ result.worktree?.worktree_fingerprint ===
655
+ worktree.worktree_fingerprint)
656
+ .map((result) => ({
657
+ local_path: result.file_path,
658
+ codex_session_id: result.codex_session_id,
659
+ })),
660
+ fetch: options.fetchImpl,
661
+ };
662
+ let sync;
663
+ try {
664
+ sync = await syncLocalAmbientEnvelope(syncOptions);
665
+ }
666
+ catch (error) {
667
+ // A newly cloned repo has no work context yet. Capture is permissive
668
+ // and ticket binding comes later, so start general ambient capture for
669
+ // it instead of blocking every other repo's sync until someone runs
670
+ // `cockpit start` by hand.
671
+ if (error instanceof LocalUploadBlockedError &&
672
+ error.blocker === "missing_context") {
673
+ context = await startLocalWorkContext({
674
+ homeDir: options.homeDir,
675
+ repoRoot: worktree.repo_root,
676
+ branch: options.branch,
677
+ });
678
+ sync = await syncLocalAmbientEnvelope(syncOptions);
679
+ }
680
+ else {
681
+ throw error;
682
+ }
683
+ }
684
+ ok = ok && sync.status === "uploaded";
685
+ outcomes.push({ worktree, context, sync });
686
+ }
687
+ const sessions = buildCodexSessionReport(attribution.results, outcomes, now);
688
+ // The sessions cursor is an optimization; a broken local state dir must not
689
+ // turn already-completed syncs into a CLI crash.
690
+ let staleSessionCount = 0;
691
+ try {
692
+ const paths = getCollectorRuntimePaths(options.homeDir);
693
+ const cursor = await readRawEvidenceCursor(paths);
694
+ const seenSessionIds = new Set(attribution.results.map((result) => result.codex_session_id));
695
+ staleSessionCount = countStaleSessions(cursor, seenSessionIds);
696
+ for (const result of attribution.results) {
697
+ const reported = sessions.find((session) => session.codex_session_id === result.codex_session_id);
698
+ const sessionDurable = reported?.upload_state === "uploaded" ||
699
+ reported?.upload_state === "reused_existing";
700
+ recordSessionObservation(cursor, result.codex_session_id, {
701
+ file_hash_sha256: result.content_hash_sha256,
702
+ file_mtime_ms: result.session_file_mtime_ms,
703
+ byte_size: result.byte_size,
704
+ byte_offset: sessionDurable ? result.byte_size : 0,
705
+ state: result.state,
706
+ reason: result.reason,
707
+ worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
708
+ uploaded_object_key: sessionDurable
709
+ ? (reported?.raw_evidence_pointer_id ?? null)
710
+ : null,
711
+ last_seen_at: now.toISOString(),
712
+ });
713
+ }
714
+ cursor.updated_at = now.toISOString();
715
+ await writeRawEvidenceCursor(paths, cursor);
716
+ }
717
+ catch {
718
+ // Best-effort: stale counts read 0 and observations re-record next sync.
719
+ }
720
+ const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
721
+ const report = firstUploaded
722
+ ? await postCodexSessionReport({
723
+ homeDir: options.homeDir,
724
+ repoRoot: firstUploaded.worktree.repo_root,
725
+ dashboardUrl: options.dashboardUrl,
726
+ sessions,
727
+ fetch: options.fetchImpl,
728
+ now,
729
+ })
730
+ : {
731
+ posted: false,
732
+ reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
733
+ };
734
+ return {
735
+ ok,
736
+ outcomes,
737
+ attribution,
738
+ summary: {
739
+ scanned: attribution.scanned_file_count,
740
+ attributed: attribution.counts.attributed,
741
+ ambiguous: attribution.counts.ambiguous,
742
+ unattributed: attribution.counts.unattributed,
743
+ skipped: attribution.counts.skipped,
744
+ stale: staleSessionCount,
745
+ report_posted: report.posted,
746
+ report_reason: report.reason,
747
+ },
748
+ };
749
+ }
750
+ const ATTRIBUTION_STATE_RANK = {
751
+ attributed: 3,
752
+ ambiguous: 2,
753
+ unattributed: 1,
754
+ skipped: 0,
755
+ };
756
+ function buildCodexSessionReport(results, outcomes, now) {
757
+ // Resumed/forked sessions can span multiple files with the same session id;
758
+ // report each session once, preferring the strongest attribution and the
759
+ // freshest file.
760
+ const bestBySessionId = new Map();
761
+ for (const result of results) {
762
+ const existing = bestBySessionId.get(result.codex_session_id);
763
+ if (!existing ||
764
+ (ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
765
+ (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
766
+ ((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
767
+ (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
768
+ result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
769
+ bestBySessionId.set(result.codex_session_id, result);
770
+ }
771
+ }
772
+ const dedupedResults = [...bestBySessionId.values()];
773
+ const uploadBySessionId = new Map();
774
+ for (const outcome of outcomes) {
775
+ if (outcome.sync.status !== "uploaded")
776
+ continue;
777
+ for (const upload of outcome.sync.raw_evidence_outcomes) {
778
+ if (upload.codex_session_id && upload.raw_evidence_pointer_id) {
779
+ uploadBySessionId.set(upload.codex_session_id, upload);
780
+ }
781
+ }
782
+ }
783
+ return dedupedResults.map((result) => {
784
+ const upload = uploadBySessionId.get(result.codex_session_id);
785
+ return {
786
+ codex_session_id: result.codex_session_id,
787
+ observed_at: now.toISOString(),
788
+ attribution_state: result.state,
789
+ attribution_reason: result.reason,
790
+ attribution_score: result.attribution_score,
791
+ path_score: result.path_score,
792
+ signals: result.signals,
793
+ ...(result.content_hash_sha256
794
+ ? { session_file_hash_sha256: result.content_hash_sha256 }
795
+ : {}),
796
+ session_file_byte_size: result.byte_size,
797
+ session_file_mtime: result.session_file_mtime,
798
+ ...(result.worktree
799
+ ? {
800
+ repo_fingerprint: result.worktree.repo_fingerprint,
801
+ worktree_fingerprint: result.worktree.worktree_fingerprint,
802
+ repo_label: result.worktree.repo_label,
803
+ branch: result.worktree.branch,
804
+ }
805
+ : {}),
806
+ ...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
807
+ ...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
808
+ ...(upload
809
+ ? {
810
+ raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
811
+ upload_state: upload.upload_state,
812
+ }
813
+ : result.state === "attributed"
814
+ ? { upload_state: "not_uploaded" }
815
+ : {}),
816
+ };
817
+ });
818
+ }
819
+ function shortSha(value) {
820
+ return value ? value.slice(0, 12) : "unknown";
821
+ }
822
+ function codexSummaryLine(summary) {
823
+ return `Codex sessions: attributed ${summary.attributed}, ambiguous ${summary.ambiguous}, unattributed ${summary.unattributed}, skipped ${summary.skipped}, stale ${summary.stale}`;
824
+ }
825
+ function attributionReportLine(summary) {
826
+ return summary.report_posted
827
+ ? "Attribution report: recorded"
828
+ : `Attribution report: skipped (${summary.report_reason})`;
829
+ }
830
+ function rawEvidenceSyncLine(sync) {
831
+ const failures = sync.raw_evidence_failure_reasons.length > 0
832
+ ? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
833
+ : "";
834
+ 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}`;
835
+ }
836
+ function cursorStatusLine(sync) {
837
+ return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
838
+ }
839
+ const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
840
+ const DEFAULT_DISCOVERY_MAX_REPOS = 50;
841
+ async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
842
+ const maxWorktrees = discovery.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS;
843
+ const worktrees = await discoverGitWorktrees(repoRoot ?? process.cwd(), {
844
+ maxDepth: discovery.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
845
+ maxWorktrees,
846
+ });
847
+ if (worktrees.length === 0) {
848
+ throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
849
+ }
850
+ if (io && worktrees.length >= maxWorktrees) {
851
+ 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.`);
852
+ }
853
+ return worktrees;
854
+ }
855
+ async function runMultiRepoOnboard(command, io, worktrees) {
856
+ if (!command.json) {
857
+ writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
858
+ }
859
+ const run = await runAttributedWorktreeSync({
860
+ homeDir: command.homeDir,
861
+ dashboardUrl: command.dashboardUrl,
862
+ branch: command.branch,
863
+ activeTicketId: command.activeTicketId,
864
+ startContexts: true,
865
+ worktrees,
866
+ fetchImpl: io.fetch,
867
+ });
868
+ const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run.attribution));
869
+ if (!command.json) {
870
+ for (const [index, outcome] of run.outcomes.entries()) {
871
+ const row = results[index];
872
+ if (!row)
873
+ continue;
874
+ const uploaded = outcome.sync.status === "uploaded";
875
+ 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"]}` : ""}`);
876
+ }
877
+ writeLine(io.stdout, "4/5 Parent worktree sync complete.");
878
+ writeLine(io.stdout, `Uploaded: ${results.filter((row) => row["upload_status"] === "uploaded").length}/${results.length}`);
879
+ writeLine(io.stdout, codexSummaryLine(run.summary));
880
+ writeLine(io.stdout, attributionReportLine(run.summary));
881
+ writeLine(io.stdout, "5/5 Status ready.");
882
+ writeLine(run.ok ? io.stdout : io.stderr, run.ok
883
+ ? "PASS: Cockpit collector is ready for harvest."
884
+ : "BLOCKED: One or more worktree uploads were spooled.");
885
+ writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
886
+ }
887
+ return { ok: run.ok, results, codex_sessions: run.summary };
888
+ }
889
+ function worktreeSyncRow(outcome, attribution) {
890
+ const { worktree, context, sync } = outcome;
891
+ const attributedSessionCount = attribution.results.filter((result) => result.state === "attributed" &&
892
+ result.worktree?.worktree_fingerprint === worktree.worktree_fingerprint).length;
893
+ return {
894
+ repo_label: context?.repo_label ?? worktree.repo_label,
895
+ repo_fingerprint: context?.repo_fingerprint ?? worktree.repo_fingerprint,
896
+ worktree_label: context?.worktree_label ?? worktree.worktree_label,
897
+ worktree_fingerprint: context?.worktree_fingerprint ?? worktree.worktree_fingerprint,
898
+ branch: context?.branch ?? worktree.branch,
899
+ head_sha: sync.head_sha ?? worktree.head_sha,
900
+ work_context_id: context?.work_context_id ?? sync.work_context_id,
901
+ upload_status: sync.status,
902
+ raw_evidence_file_count: sync.raw_evidence_file_count,
903
+ raw_evidence_uploaded_object_count: sync.raw_evidence_uploaded_object_count,
904
+ raw_evidence_uploaded_chunk_count: sync.raw_evidence_uploaded_chunk_count,
905
+ raw_evidence_reused_count: sync.raw_evidence_reused_count,
906
+ raw_evidence_failed_count: sync.raw_evidence_failed_count,
907
+ raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
908
+ attributed_session_count: attributedSessionCount,
909
+ cursor_tracked_object_count: sync.cursor_tracked_object_count,
910
+ failure_reason: sync.status === "spooled" ? sync.failure_reason : null,
911
+ };
912
+ }
563
913
  async function runLogin(command, io) {
564
914
  const result = await pairLocalCollector({
565
915
  homeDir: command.homeDir,
@@ -659,6 +1009,26 @@ async function runLogout(command, io) {
659
1009
  return 0;
660
1010
  }
661
1011
  async function runStart(command, io) {
1012
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
1013
+ if (worktrees.length > 1) {
1014
+ const contexts = await Promise.all(worktrees.map((worktree) => startLocalWorkContext({
1015
+ homeDir: command.homeDir,
1016
+ repoRoot: worktree.repo_root,
1017
+ branch: command.branch,
1018
+ activeTicketId: command.activeTicketId,
1019
+ operatorId: command.operatorId,
1020
+ sessionId: command.sessionId,
1021
+ })));
1022
+ if (command.json) {
1023
+ writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", contexts }, null, 2));
1024
+ return 0;
1025
+ }
1026
+ writeLine(io.stdout, `Cockpit parent work context active for ${contexts.length} worktree(s).`);
1027
+ for (const context of contexts) {
1028
+ writeLine(io.stdout, `- ${context.repo_label ?? context.repo}/${context.worktree_label ?? "worktree"} · ${context.branch} · ${context.work_context_id}`);
1029
+ }
1030
+ return 0;
1031
+ }
662
1032
  const context = await startLocalWorkContext(command);
663
1033
  if (command.json) {
664
1034
  writeLine(io.stdout, JSON.stringify(context, null, 2));
@@ -672,23 +1042,57 @@ async function runStart(command, io) {
672
1042
  return 0;
673
1043
  }
674
1044
  async function runSync(command, io) {
675
- const result = await syncLocalAmbientEnvelope({
1045
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
1046
+ const run = await runAttributedWorktreeSync({
676
1047
  homeDir: command.homeDir,
677
- repoRoot: command.repoRoot,
678
1048
  dashboardUrl: command.dashboardUrl,
679
- fetch: io.fetch,
1049
+ startContexts: false,
1050
+ worktrees,
1051
+ fetchImpl: io.fetch,
680
1052
  });
1053
+ if (worktrees.length > 1) {
1054
+ const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run.attribution));
1055
+ if (command.json) {
1056
+ writeLine(io.stdout, JSON.stringify({
1057
+ mode: "multi_repo",
1058
+ status: run.ok ? "uploaded" : "spooled",
1059
+ results: run.outcomes.map((outcome) => outcome.sync),
1060
+ repos: rows,
1061
+ codex_sessions: run.summary,
1062
+ }, null, 2));
1063
+ return run.ok ? 0 : 1;
1064
+ }
1065
+ 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).`);
1066
+ for (const outcome of run.outcomes) {
1067
+ const { worktree, sync } = outcome;
1068
+ const uploaded = sync.status === "uploaded";
1069
+ const failureSuffix = sync.status === "spooled" ? ` reason:${sync.failure_reason}` : "";
1070
+ writeLine(uploaded ? io.stdout : io.stderr, `- ${worktree.repo_label}/${worktree.worktree_label} (${worktree.branch}) head:${shortSha(sync.head_sha ?? worktree.head_sha)} ${sync.status} objects:${sync.raw_evidence_uploaded_object_count} chunks:${sync.raw_evidence_uploaded_chunk_count} reused:${sync.raw_evidence_reused_count} failed:${sync.raw_evidence_failed_count} cursor:${sync.cursor_tracked_object_count}${failureSuffix}`);
1071
+ }
1072
+ writeLine(io.stdout, codexSummaryLine(run.summary));
1073
+ writeLine(io.stdout, attributionReportLine(run.summary));
1074
+ return run.ok ? 0 : 1;
1075
+ }
1076
+ const result = run.outcomes[0]?.sync;
1077
+ if (!result) {
1078
+ throw new Error("Sync produced no result for the repo worktree.");
1079
+ }
681
1080
  if (command.json) {
682
- writeLine(io.stdout, JSON.stringify(result, null, 2));
1081
+ writeLine(io.stdout, JSON.stringify({ ...result, codex_sessions: run.summary }, null, 2));
683
1082
  return result.status === "uploaded" ? 0 : 1;
684
1083
  }
685
1084
  if (result.status === "uploaded") {
686
1085
  writeLine(io.stdout, "Cockpit ambient envelope uploaded.");
687
1086
  writeLine(io.stdout, `Ticket: ${displayTicketId(result.ticket_id)}`);
688
1087
  writeLine(io.stdout, `Context: ${result.work_context_id}`);
1088
+ writeLine(io.stdout, `Head: ${shortSha(result.head_sha)}`);
689
1089
  writeLine(io.stdout, `Facts: ${result.event_count}`);
690
1090
  writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
691
1091
  writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
1092
+ writeLine(io.stdout, rawEvidenceSyncLine(result));
1093
+ writeLine(io.stdout, codexSummaryLine(run.summary));
1094
+ writeLine(io.stdout, attributionReportLine(run.summary));
1095
+ writeLine(io.stdout, cursorStatusLine(result));
692
1096
  return 0;
693
1097
  }
694
1098
  writeLine(io.stderr, "Cockpit ambient upload failed; safe retry metadata was spooled.");
@@ -698,6 +1102,25 @@ async function runSync(command, io) {
698
1102
  return 1;
699
1103
  }
700
1104
  async function runStatus(command, io) {
1105
+ const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
1106
+ if (worktrees.length > 1) {
1107
+ const statuses = await Promise.all(worktrees.map(async (worktree) => ({
1108
+ ...(await inspectLocalCollectorStatus({
1109
+ homeDir: command.homeDir,
1110
+ repoRoot: worktree.repo_root,
1111
+ })),
1112
+ head_sha: worktree.head_sha,
1113
+ })));
1114
+ if (command.json) {
1115
+ writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses }, null, 2));
1116
+ return 0;
1117
+ }
1118
+ writeLine(io.stdout, "Cockpit parent status");
1119
+ for (const status of statuses) {
1120
+ writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
1121
+ }
1122
+ return 0;
1123
+ }
701
1124
  const status = await inspectLocalCollectorStatus(command);
702
1125
  if (command.json) {
703
1126
  writeLine(io.stdout, JSON.stringify(status, null, 2));