@bli-cockpit/cli 0.1.2 → 0.1.5

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 { 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,7 +61,7 @@ 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>] [--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]",
@@ -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,10 @@ 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.",
120
130
  ],
121
131
  ],
122
132
  [
@@ -455,6 +465,19 @@ async function runOnboard(command, io) {
455
465
  writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
456
466
  }
457
467
  }
468
+ const worktrees = await discoverCommandWorktrees(command.repoRoot);
469
+ if (worktrees.length > 1) {
470
+ const multi = await runMultiRepoOnboard(command, io, worktrees);
471
+ if (command.json) {
472
+ writeLine(io.stdout, JSON.stringify({
473
+ ...onboardResult(multi.ok ? "pass" : "blocked", command, install, pair, null, null),
474
+ mode: "multi_repo",
475
+ repos: multi.results,
476
+ codex_sessions: multi.codex_sessions,
477
+ }, null, 2));
478
+ }
479
+ return multi.ok ? 0 : 1;
480
+ }
458
481
  const context = await startLocalWorkContext({
459
482
  homeDir: command.homeDir,
460
483
  repoRoot: command.repoRoot,
@@ -468,12 +491,17 @@ async function runOnboard(command, io) {
468
491
  writeLine(io.stdout, `Ticket: ${context.active_ticket_id ?? "general ambient"}`);
469
492
  writeLine(io.stdout, `Context: ${context.work_context_id}`);
470
493
  }
471
- sync = await syncLocalAmbientEnvelope({
494
+ const run = await runAttributedWorktreeSync({
472
495
  homeDir: command.homeDir,
473
- repoRoot: command.repoRoot,
474
496
  dashboardUrl: command.dashboardUrl,
475
- fetch: io.fetch,
497
+ startContexts: false,
498
+ worktrees,
499
+ fetchImpl: io.fetch,
476
500
  });
501
+ sync = run.outcomes[0]?.sync ?? null;
502
+ if (!sync) {
503
+ throw new Error("Onboard sync produced no result for the repo worktree.");
504
+ }
477
505
  status = await inspectLocalCollectorStatus({
478
506
  homeDir: command.homeDir,
479
507
  repoRoot: command.repoRoot,
@@ -481,7 +509,10 @@ async function runOnboard(command, io) {
481
509
  });
482
510
  if (sync.status !== "uploaded") {
483
511
  if (command.json) {
484
- writeLine(io.stdout, JSON.stringify(onboardResult("blocked", command, install, pair, sync, status), null, 2));
512
+ writeLine(io.stdout, JSON.stringify({
513
+ ...onboardResult("blocked", command, install, pair, sync, status),
514
+ codex_sessions: run.summary,
515
+ }, null, 2));
485
516
  }
486
517
  else {
487
518
  writeLine(io.stderr, "BLOCKED: ambient upload failed; safe retry metadata was spooled.");
@@ -491,7 +522,10 @@ async function runOnboard(command, io) {
491
522
  return 1;
492
523
  }
493
524
  if (command.json) {
494
- writeLine(io.stdout, JSON.stringify(onboardResult("pass", command, install, pair, sync, status), null, 2));
525
+ writeLine(io.stdout, JSON.stringify({
526
+ ...onboardResult("pass", command, install, pair, sync, status),
527
+ codex_sessions: run.summary,
528
+ }, null, 2));
495
529
  return 0;
496
530
  }
497
531
  writeLine(io.stdout, "4/5 Ambient metadata uploaded.");
@@ -500,6 +534,9 @@ async function runOnboard(command, io) {
500
534
  writeLine(io.stdout, `Sources: ${sync.source_scan_count}`);
501
535
  writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
502
536
  writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
537
+ writeLine(io.stdout, rawEvidenceSyncLine(sync));
538
+ writeLine(io.stdout, codexSummaryLine(run.summary));
539
+ writeLine(io.stdout, attributionReportLine(run.summary));
503
540
  writeLine(io.stdout, "5/5 Status ready.");
504
541
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
505
542
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
@@ -560,6 +597,269 @@ async function readOnboardSessionReuseCandidate(homeDir) {
560
597
  function normalizeUrlForComparison(value) {
561
598
  return value ? normalizeUrl(value) : null;
562
599
  }
600
+ /**
601
+ * Shared sync orchestration for single-repo and parent-folder modes: Codex
602
+ * sessions are scanned and attributed once across every discovered worktree,
603
+ * each worktree syncs with only its own attributed transcripts, and the
604
+ * ambiguous/unattributed/skipped remainder is reported with reason labels
605
+ * instead of being duplicated into every repo or silently dropped.
606
+ */
607
+ async function runAttributedWorktreeSync(options) {
608
+ const now = new Date();
609
+ const homeDir = options.homeDir ?? os.homedir();
610
+ const attribution = await scanAndAttributeCodexSessions({
611
+ sessionsDir: path.join(homeDir, ".codex", "sessions"),
612
+ worktrees: options.worktrees,
613
+ now,
614
+ });
615
+ const outcomes = [];
616
+ let ok = true;
617
+ for (const worktree of options.worktrees) {
618
+ let context = null;
619
+ if (options.startContexts) {
620
+ context = await startLocalWorkContext({
621
+ homeDir: options.homeDir,
622
+ repoRoot: worktree.repo_root,
623
+ branch: options.branch,
624
+ activeTicketId: options.activeTicketId,
625
+ operatorId: options.operatorId,
626
+ sessionId: options.sessionId,
627
+ });
628
+ }
629
+ const sync = await syncLocalAmbientEnvelope({
630
+ homeDir: options.homeDir,
631
+ repoRoot: worktree.repo_root,
632
+ dashboardUrl: options.dashboardUrl,
633
+ codexSessionFiles: attribution.results
634
+ .filter((result) => result.state === "attributed" &&
635
+ result.worktree?.worktree_fingerprint ===
636
+ worktree.worktree_fingerprint)
637
+ .map((result) => ({
638
+ local_path: result.file_path,
639
+ codex_session_id: result.codex_session_id,
640
+ })),
641
+ fetch: options.fetchImpl,
642
+ });
643
+ ok = ok && sync.status === "uploaded";
644
+ outcomes.push({ worktree, context, sync });
645
+ }
646
+ const sessions = buildCodexSessionReport(attribution.results, outcomes, now);
647
+ // The sessions cursor is an optimization; a broken local state dir must not
648
+ // turn already-completed syncs into a CLI crash.
649
+ let staleSessionCount = 0;
650
+ try {
651
+ const paths = getCollectorRuntimePaths(options.homeDir);
652
+ const cursor = await readRawEvidenceCursor(paths);
653
+ const seenSessionIds = new Set(attribution.results.map((result) => result.codex_session_id));
654
+ staleSessionCount = countStaleSessions(cursor, seenSessionIds);
655
+ for (const result of attribution.results) {
656
+ const reported = sessions.find((session) => session.codex_session_id === result.codex_session_id);
657
+ const sessionDurable = reported?.upload_state === "uploaded" ||
658
+ reported?.upload_state === "reused_existing";
659
+ recordSessionObservation(cursor, result.codex_session_id, {
660
+ file_hash_sha256: result.content_hash_sha256,
661
+ file_mtime_ms: result.session_file_mtime_ms,
662
+ byte_size: result.byte_size,
663
+ byte_offset: sessionDurable ? result.byte_size : 0,
664
+ state: result.state,
665
+ reason: result.reason,
666
+ worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
667
+ uploaded_object_key: sessionDurable
668
+ ? (reported?.raw_evidence_pointer_id ?? null)
669
+ : null,
670
+ last_seen_at: now.toISOString(),
671
+ });
672
+ }
673
+ cursor.updated_at = now.toISOString();
674
+ await writeRawEvidenceCursor(paths, cursor);
675
+ }
676
+ catch {
677
+ // Best-effort: stale counts read 0 and observations re-record next sync.
678
+ }
679
+ const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
680
+ const report = firstUploaded
681
+ ? await postCodexSessionReport({
682
+ homeDir: options.homeDir,
683
+ repoRoot: firstUploaded.worktree.repo_root,
684
+ dashboardUrl: options.dashboardUrl,
685
+ sessions,
686
+ fetch: options.fetchImpl,
687
+ now,
688
+ })
689
+ : {
690
+ posted: false,
691
+ reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
692
+ };
693
+ return {
694
+ ok,
695
+ outcomes,
696
+ attribution,
697
+ summary: {
698
+ scanned: attribution.scanned_file_count,
699
+ attributed: attribution.counts.attributed,
700
+ ambiguous: attribution.counts.ambiguous,
701
+ unattributed: attribution.counts.unattributed,
702
+ skipped: attribution.counts.skipped,
703
+ stale: staleSessionCount,
704
+ report_posted: report.posted,
705
+ report_reason: report.reason,
706
+ },
707
+ };
708
+ }
709
+ const ATTRIBUTION_STATE_RANK = {
710
+ attributed: 3,
711
+ ambiguous: 2,
712
+ unattributed: 1,
713
+ skipped: 0,
714
+ };
715
+ function buildCodexSessionReport(results, outcomes, now) {
716
+ // Resumed/forked sessions can span multiple files with the same session id;
717
+ // report each session once, preferring the strongest attribution and the
718
+ // freshest file.
719
+ const bestBySessionId = new Map();
720
+ for (const result of results) {
721
+ const existing = bestBySessionId.get(result.codex_session_id);
722
+ if (!existing ||
723
+ (ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
724
+ (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
725
+ ((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
726
+ (ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
727
+ result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
728
+ bestBySessionId.set(result.codex_session_id, result);
729
+ }
730
+ }
731
+ const dedupedResults = [...bestBySessionId.values()];
732
+ const uploadBySessionId = new Map();
733
+ for (const outcome of outcomes) {
734
+ if (outcome.sync.status !== "uploaded")
735
+ continue;
736
+ for (const upload of outcome.sync.raw_evidence_outcomes) {
737
+ if (upload.codex_session_id && upload.raw_evidence_pointer_id) {
738
+ uploadBySessionId.set(upload.codex_session_id, upload);
739
+ }
740
+ }
741
+ }
742
+ return dedupedResults.map((result) => {
743
+ const upload = uploadBySessionId.get(result.codex_session_id);
744
+ return {
745
+ codex_session_id: result.codex_session_id,
746
+ observed_at: now.toISOString(),
747
+ attribution_state: result.state,
748
+ attribution_reason: result.reason,
749
+ attribution_score: result.attribution_score,
750
+ path_score: result.path_score,
751
+ signals: result.signals,
752
+ ...(result.content_hash_sha256
753
+ ? { session_file_hash_sha256: result.content_hash_sha256 }
754
+ : {}),
755
+ session_file_byte_size: result.byte_size,
756
+ session_file_mtime: result.session_file_mtime,
757
+ ...(result.worktree
758
+ ? {
759
+ repo_fingerprint: result.worktree.repo_fingerprint,
760
+ worktree_fingerprint: result.worktree.worktree_fingerprint,
761
+ repo_label: result.worktree.repo_label,
762
+ branch: result.worktree.branch,
763
+ }
764
+ : {}),
765
+ ...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
766
+ ...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
767
+ ...(upload
768
+ ? {
769
+ raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
770
+ upload_state: upload.upload_state,
771
+ }
772
+ : result.state === "attributed"
773
+ ? { upload_state: "not_uploaded" }
774
+ : {}),
775
+ };
776
+ });
777
+ }
778
+ function shortSha(value) {
779
+ return value ? value.slice(0, 12) : "unknown";
780
+ }
781
+ function codexSummaryLine(summary) {
782
+ return `Codex sessions: attributed ${summary.attributed}, ambiguous ${summary.ambiguous}, unattributed ${summary.unattributed}, skipped ${summary.skipped}, stale ${summary.stale}`;
783
+ }
784
+ function attributionReportLine(summary) {
785
+ return summary.report_posted
786
+ ? "Attribution report: recorded"
787
+ : `Attribution report: skipped (${summary.report_reason})`;
788
+ }
789
+ function rawEvidenceSyncLine(sync) {
790
+ const failures = sync.raw_evidence_failure_reasons.length > 0
791
+ ? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
792
+ : "";
793
+ 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}`;
794
+ }
795
+ function cursorStatusLine(sync) {
796
+ return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
797
+ }
798
+ async function discoverCommandWorktrees(repoRoot) {
799
+ const worktrees = await discoverGitWorktrees(repoRoot ?? process.cwd());
800
+ if (worktrees.length === 0) {
801
+ throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
802
+ }
803
+ return worktrees;
804
+ }
805
+ async function runMultiRepoOnboard(command, io, worktrees) {
806
+ if (!command.json) {
807
+ writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
808
+ }
809
+ const run = await runAttributedWorktreeSync({
810
+ homeDir: command.homeDir,
811
+ dashboardUrl: command.dashboardUrl,
812
+ branch: command.branch,
813
+ activeTicketId: command.activeTicketId,
814
+ startContexts: true,
815
+ worktrees,
816
+ fetchImpl: io.fetch,
817
+ });
818
+ const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run.attribution));
819
+ if (!command.json) {
820
+ for (const [index, outcome] of run.outcomes.entries()) {
821
+ const row = results[index];
822
+ if (!row)
823
+ continue;
824
+ const uploaded = outcome.sync.status === "uploaded";
825
+ 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"]}` : ""}`);
826
+ }
827
+ writeLine(io.stdout, "4/5 Parent worktree sync complete.");
828
+ writeLine(io.stdout, `Uploaded: ${results.filter((row) => row["upload_status"] === "uploaded").length}/${results.length}`);
829
+ writeLine(io.stdout, codexSummaryLine(run.summary));
830
+ writeLine(io.stdout, attributionReportLine(run.summary));
831
+ writeLine(io.stdout, "5/5 Status ready.");
832
+ writeLine(run.ok ? io.stdout : io.stderr, run.ok
833
+ ? "PASS: Cockpit collector is ready for harvest."
834
+ : "BLOCKED: One or more worktree uploads were spooled.");
835
+ writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
836
+ }
837
+ return { ok: run.ok, results, codex_sessions: run.summary };
838
+ }
839
+ function worktreeSyncRow(outcome, attribution) {
840
+ const { worktree, context, sync } = outcome;
841
+ const attributedSessionCount = attribution.results.filter((result) => result.state === "attributed" &&
842
+ result.worktree?.worktree_fingerprint === worktree.worktree_fingerprint).length;
843
+ return {
844
+ repo_label: context?.repo_label ?? worktree.repo_label,
845
+ repo_fingerprint: context?.repo_fingerprint ?? worktree.repo_fingerprint,
846
+ worktree_label: context?.worktree_label ?? worktree.worktree_label,
847
+ worktree_fingerprint: context?.worktree_fingerprint ?? worktree.worktree_fingerprint,
848
+ branch: context?.branch ?? worktree.branch,
849
+ head_sha: sync.head_sha ?? worktree.head_sha,
850
+ work_context_id: context?.work_context_id ?? sync.work_context_id,
851
+ upload_status: sync.status,
852
+ raw_evidence_file_count: sync.raw_evidence_file_count,
853
+ raw_evidence_uploaded_object_count: sync.raw_evidence_uploaded_object_count,
854
+ raw_evidence_uploaded_chunk_count: sync.raw_evidence_uploaded_chunk_count,
855
+ raw_evidence_reused_count: sync.raw_evidence_reused_count,
856
+ raw_evidence_failed_count: sync.raw_evidence_failed_count,
857
+ raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
858
+ attributed_session_count: attributedSessionCount,
859
+ cursor_tracked_object_count: sync.cursor_tracked_object_count,
860
+ failure_reason: sync.status === "spooled" ? sync.failure_reason : null,
861
+ };
862
+ }
563
863
  async function runLogin(command, io) {
564
864
  const result = await pairLocalCollector({
565
865
  homeDir: command.homeDir,
@@ -636,7 +936,7 @@ function nextStepForOnboardBlocker(blocker) {
636
936
  case "ticket_binding":
637
937
  return "Run `cockpit start --ticket <id>` when actual ticket work begins, then run `cockpit sync`.";
638
938
  case "device_pairing":
639
- return "Open the pairing URL, approve the exact code in Cockpit, then rerun `cockpit onboard`.";
939
+ return "Approve from Ambient -> Collector approvals, or paste the pairing code there, then rerun `cockpit onboard`.";
640
940
  case "network_or_ingest":
641
941
  return "Check dashboard URL/network, then run `cockpit sync --json` or rerun `cockpit onboard`.";
642
942
  case "install":
@@ -659,6 +959,26 @@ async function runLogout(command, io) {
659
959
  return 0;
660
960
  }
661
961
  async function runStart(command, io) {
962
+ const worktrees = await discoverCommandWorktrees(command.repoRoot);
963
+ if (worktrees.length > 1) {
964
+ const contexts = await Promise.all(worktrees.map((worktree) => startLocalWorkContext({
965
+ homeDir: command.homeDir,
966
+ repoRoot: worktree.repo_root,
967
+ branch: command.branch,
968
+ activeTicketId: command.activeTicketId,
969
+ operatorId: command.operatorId,
970
+ sessionId: command.sessionId,
971
+ })));
972
+ if (command.json) {
973
+ writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", contexts }, null, 2));
974
+ return 0;
975
+ }
976
+ writeLine(io.stdout, `Cockpit parent work context active for ${contexts.length} worktree(s).`);
977
+ for (const context of contexts) {
978
+ writeLine(io.stdout, `- ${context.repo_label ?? context.repo}/${context.worktree_label ?? "worktree"} · ${context.branch} · ${context.work_context_id}`);
979
+ }
980
+ return 0;
981
+ }
662
982
  const context = await startLocalWorkContext(command);
663
983
  if (command.json) {
664
984
  writeLine(io.stdout, JSON.stringify(context, null, 2));
@@ -672,23 +992,57 @@ async function runStart(command, io) {
672
992
  return 0;
673
993
  }
674
994
  async function runSync(command, io) {
675
- const result = await syncLocalAmbientEnvelope({
995
+ const worktrees = await discoverCommandWorktrees(command.repoRoot);
996
+ const run = await runAttributedWorktreeSync({
676
997
  homeDir: command.homeDir,
677
- repoRoot: command.repoRoot,
678
998
  dashboardUrl: command.dashboardUrl,
679
- fetch: io.fetch,
999
+ startContexts: false,
1000
+ worktrees,
1001
+ fetchImpl: io.fetch,
680
1002
  });
1003
+ if (worktrees.length > 1) {
1004
+ const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run.attribution));
1005
+ if (command.json) {
1006
+ writeLine(io.stdout, JSON.stringify({
1007
+ mode: "multi_repo",
1008
+ status: run.ok ? "uploaded" : "spooled",
1009
+ results: run.outcomes.map((outcome) => outcome.sync),
1010
+ repos: rows,
1011
+ codex_sessions: run.summary,
1012
+ }, null, 2));
1013
+ return run.ok ? 0 : 1;
1014
+ }
1015
+ 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).`);
1016
+ for (const outcome of run.outcomes) {
1017
+ const { worktree, sync } = outcome;
1018
+ const uploaded = sync.status === "uploaded";
1019
+ const failureSuffix = sync.status === "spooled" ? ` reason:${sync.failure_reason}` : "";
1020
+ 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}`);
1021
+ }
1022
+ writeLine(io.stdout, codexSummaryLine(run.summary));
1023
+ writeLine(io.stdout, attributionReportLine(run.summary));
1024
+ return run.ok ? 0 : 1;
1025
+ }
1026
+ const result = run.outcomes[0]?.sync;
1027
+ if (!result) {
1028
+ throw new Error("Sync produced no result for the repo worktree.");
1029
+ }
681
1030
  if (command.json) {
682
- writeLine(io.stdout, JSON.stringify(result, null, 2));
1031
+ writeLine(io.stdout, JSON.stringify({ ...result, codex_sessions: run.summary }, null, 2));
683
1032
  return result.status === "uploaded" ? 0 : 1;
684
1033
  }
685
1034
  if (result.status === "uploaded") {
686
1035
  writeLine(io.stdout, "Cockpit ambient envelope uploaded.");
687
1036
  writeLine(io.stdout, `Ticket: ${displayTicketId(result.ticket_id)}`);
688
1037
  writeLine(io.stdout, `Context: ${result.work_context_id}`);
1038
+ writeLine(io.stdout, `Head: ${shortSha(result.head_sha)}`);
689
1039
  writeLine(io.stdout, `Facts: ${result.event_count}`);
690
1040
  writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
691
1041
  writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
1042
+ writeLine(io.stdout, rawEvidenceSyncLine(result));
1043
+ writeLine(io.stdout, codexSummaryLine(run.summary));
1044
+ writeLine(io.stdout, attributionReportLine(run.summary));
1045
+ writeLine(io.stdout, cursorStatusLine(result));
692
1046
  return 0;
693
1047
  }
694
1048
  writeLine(io.stderr, "Cockpit ambient upload failed; safe retry metadata was spooled.");
@@ -698,6 +1052,25 @@ async function runSync(command, io) {
698
1052
  return 1;
699
1053
  }
700
1054
  async function runStatus(command, io) {
1055
+ const worktrees = await discoverCommandWorktrees(command.repoRoot);
1056
+ if (worktrees.length > 1) {
1057
+ const statuses = await Promise.all(worktrees.map(async (worktree) => ({
1058
+ ...(await inspectLocalCollectorStatus({
1059
+ homeDir: command.homeDir,
1060
+ repoRoot: worktree.repo_root,
1061
+ })),
1062
+ head_sha: worktree.head_sha,
1063
+ })));
1064
+ if (command.json) {
1065
+ writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses }, null, 2));
1066
+ return 0;
1067
+ }
1068
+ writeLine(io.stdout, "Cockpit parent status");
1069
+ for (const status of statuses) {
1070
+ writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
1071
+ }
1072
+ return 0;
1073
+ }
701
1074
  const status = await inspectLocalCollectorStatus(command);
702
1075
  if (command.json) {
703
1076
  writeLine(io.stdout, JSON.stringify(status, null, 2));
@@ -0,0 +1,129 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ const CURSOR_FILENAME = "raw-evidence.json";
4
+ const MAX_TRACKED_OBJECTS = 500;
5
+ const MAX_TRACKED_SESSIONS = 500;
6
+ export function emptyRawEvidenceCursorState() {
7
+ return {
8
+ schema_version: "cockpit-raw-evidence-cursor.v1",
9
+ updated_at: null,
10
+ objects: {},
11
+ sessions: {},
12
+ };
13
+ }
14
+ export async function readRawEvidenceCursor(paths) {
15
+ try {
16
+ const raw = JSON.parse(await fs.readFile(rawEvidenceCursorPath(paths), "utf8"));
17
+ return parseCursorState(raw);
18
+ }
19
+ catch {
20
+ return emptyRawEvidenceCursorState();
21
+ }
22
+ }
23
+ export async function writeRawEvidenceCursor(paths, state) {
24
+ const filePath = rawEvidenceCursorPath(paths);
25
+ await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
26
+ const pruned = pruneCursorState(state);
27
+ await fs.writeFile(filePath, `${JSON.stringify(pruned, null, 2)}\n`, {
28
+ mode: 0o600,
29
+ });
30
+ if (process.platform !== "win32") {
31
+ await fs.chmod(filePath, 0o600).catch(() => undefined);
32
+ }
33
+ }
34
+ export function markObjectCommitted(state, contentHash, entry) {
35
+ state.objects[contentHash] = entry;
36
+ }
37
+ export function hasCommittedObject(state, contentHash) {
38
+ return Boolean(contentHash && state.objects[contentHash]);
39
+ }
40
+ export function recordSessionObservation(state, codexSessionId, entry) {
41
+ state.sessions[codexSessionId] = entry;
42
+ }
43
+ /**
44
+ * Sessions tracked by the cursor that no longer appear in the current scan
45
+ * window are stale: previously observed work that stopped producing evidence.
46
+ */
47
+ export function countStaleSessions(state, seenSessionIds) {
48
+ return Object.keys(state.sessions).filter((id) => !seenSessionIds.has(id))
49
+ .length;
50
+ }
51
+ function pruneCursorState(state) {
52
+ return {
53
+ ...state,
54
+ objects: pruneNewest(state.objects, MAX_TRACKED_OBJECTS, (entry) => entry.committed_at),
55
+ sessions: pruneNewest(state.sessions, MAX_TRACKED_SESSIONS, (entry) => entry.last_seen_at),
56
+ };
57
+ }
58
+ function pruneNewest(record, max, sortKey) {
59
+ const entries = Object.entries(record);
60
+ if (entries.length <= max)
61
+ return record;
62
+ entries.sort((a, b) => sortKey(b[1]).localeCompare(sortKey(a[1])));
63
+ return Object.fromEntries(entries.slice(0, max));
64
+ }
65
+ function parseCursorState(value) {
66
+ if (!value || typeof value !== "object")
67
+ return emptyRawEvidenceCursorState();
68
+ const record = value;
69
+ return {
70
+ schema_version: "cockpit-raw-evidence-cursor.v1",
71
+ updated_at: optionalString(record["updated_at"]),
72
+ objects: parseRecord(record["objects"], parseObjectEntry),
73
+ sessions: parseRecord(record["sessions"], parseSessionEntry),
74
+ };
75
+ }
76
+ function parseRecord(value, parseEntry) {
77
+ if (!value || typeof value !== "object")
78
+ return {};
79
+ const out = {};
80
+ for (const [key, entry] of Object.entries(value)) {
81
+ const parsed = parseEntry(entry);
82
+ if (parsed)
83
+ out[key] = parsed;
84
+ }
85
+ return out;
86
+ }
87
+ function parseObjectEntry(value) {
88
+ if (!value || typeof value !== "object")
89
+ return null;
90
+ const record = value;
91
+ const objectKey = optionalString(record["object_key"]);
92
+ const committedAt = optionalString(record["committed_at"]);
93
+ if (!objectKey || !committedAt)
94
+ return null;
95
+ return {
96
+ object_key: objectKey,
97
+ byte_size: optionalNumber(record["byte_size"]),
98
+ committed_at: committedAt,
99
+ };
100
+ }
101
+ function parseSessionEntry(value) {
102
+ if (!value || typeof value !== "object")
103
+ return null;
104
+ const record = value;
105
+ const lastSeenAt = optionalString(record["last_seen_at"]);
106
+ const state = optionalString(record["state"]);
107
+ if (!lastSeenAt || !state)
108
+ return null;
109
+ return {
110
+ file_hash_sha256: optionalString(record["file_hash_sha256"]),
111
+ file_mtime_ms: optionalNumber(record["file_mtime_ms"]),
112
+ byte_size: optionalNumber(record["byte_size"]),
113
+ byte_offset: optionalNumber(record["byte_offset"]),
114
+ state,
115
+ reason: optionalString(record["reason"]) ?? "unknown",
116
+ worktree_fingerprint: optionalString(record["worktree_fingerprint"]),
117
+ uploaded_object_key: optionalString(record["uploaded_object_key"]),
118
+ last_seen_at: lastSeenAt,
119
+ };
120
+ }
121
+ function rawEvidenceCursorPath(paths) {
122
+ return path.join(paths.cursors_dir, CURSOR_FILENAME);
123
+ }
124
+ function optionalString(value) {
125
+ return typeof value === "string" && value.trim() ? value : null;
126
+ }
127
+ function optionalNumber(value) {
128
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
129
+ }