@bli-cockpit/cli 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/attribution-core.js +172 -0
- package/dist/adapters/claude-attribution.js +535 -0
- package/dist/adapters/codex-attribution.js +16 -134
- package/dist/adapters/common.js +4 -1
- package/dist/adapters/local-sources.js +21 -2
- package/dist/adapters/raw-evidence.js +205 -90
- package/dist/commands/local.js +580 -87
- package/dist/cursors/raw-evidence-cursor.js +65 -14
- package/dist/local-state.js +1 -1
- package/dist/repo-identity.js +50 -4
- package/dist/sync-lock.js +113 -0
- package/dist/upload.js +8 -0
- package/package.json +2 -2
package/dist/commands/local.js
CHANGED
|
@@ -4,7 +4,11 @@ import { createCollectorServer } from "../server.js";
|
|
|
4
4
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
|
|
5
5
|
import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope, } from "../upload.js";
|
|
6
6
|
import { scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
|
|
7
|
-
import {
|
|
7
|
+
import { scanAndAttributeClaudeSessions, } from "../adapters/claude-attribution.js";
|
|
8
|
+
import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET, } from "../adapters/raw-evidence.js";
|
|
9
|
+
import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
10
|
+
import { readLocalCollectorConfig } from "../local-state.js";
|
|
11
|
+
import { acquireSyncLock } from "../sync-lock.js";
|
|
8
12
|
import { discoverGitWorktrees, } from "../repo-identity.js";
|
|
9
13
|
export const rootCommandNames = new Set([
|
|
10
14
|
"onboard",
|
|
@@ -15,6 +19,7 @@ export const rootCommandNames = new Set([
|
|
|
15
19
|
"start",
|
|
16
20
|
"sync",
|
|
17
21
|
"status",
|
|
22
|
+
"sessions",
|
|
18
23
|
"serve",
|
|
19
24
|
]);
|
|
20
25
|
export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
@@ -48,6 +53,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
48
53
|
return await runSync(command, io);
|
|
49
54
|
case "status":
|
|
50
55
|
return await runStatus(command, io);
|
|
56
|
+
case "sessions":
|
|
57
|
+
return await runSessions(command, io);
|
|
51
58
|
case "serve":
|
|
52
59
|
return await runServe(command, io);
|
|
53
60
|
}
|
|
@@ -69,6 +76,7 @@ export function localCommandHelp(command) {
|
|
|
69
76
|
" cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
70
77
|
" cockpit sync [--repo <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
71
78
|
" cockpit status [--repo <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
79
|
+
" cockpit sessions [--source codex|claude] [--repo <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
72
80
|
" cockpit serve [--port <port>] [--repo <path>]",
|
|
73
81
|
].join("\n");
|
|
74
82
|
}
|
|
@@ -124,9 +132,11 @@ function localSubcommandHelp(command) {
|
|
|
124
132
|
"Usage: cockpit sync [--repo <path>] [--dashboard-url <url>] [--json]",
|
|
125
133
|
"",
|
|
126
134
|
"Uploads latest local ambient envelope(s), or spools safe retries if blocked.",
|
|
127
|
-
"Parent folders sync each child git worktree; Codex
|
|
128
|
-
"
|
|
129
|
-
"
|
|
135
|
+
"Parent folders sync each child git worktree; Codex AND Claude Code JSONL",
|
|
136
|
+
"transcripts (and Claude subagent sidecars) are attributed to repos",
|
|
137
|
+
"deterministically and ambiguous transcripts are retained as unattributed",
|
|
138
|
+
"instead of being duplicated across repos. Use `cockpit sessions` to see why",
|
|
139
|
+
"a session is or is not collected.",
|
|
130
140
|
"Newly discovered repos get a general ambient work context automatically.",
|
|
131
141
|
"Discovery scans 3 folder levels and up to 50 repos by default; tune with",
|
|
132
142
|
"--max-depth and --max-repos.",
|
|
@@ -140,6 +150,17 @@ function localSubcommandHelp(command) {
|
|
|
140
150
|
"Prints install, pairing, active work, upload, and retry state.",
|
|
141
151
|
],
|
|
142
152
|
],
|
|
153
|
+
[
|
|
154
|
+
"sessions",
|
|
155
|
+
[
|
|
156
|
+
"Usage: cockpit sessions [--source codex|claude] [--repo <path>] [--json]",
|
|
157
|
+
"",
|
|
158
|
+
"Read-only: re-runs Codex + Claude session attribution and prints each",
|
|
159
|
+
"session's id, source, state, reason, scores, signals, and per-sidecar",
|
|
160
|
+
"skip reasons. No upload, no cursor writes. Answers \"why is session X",
|
|
161
|
+
"missing?\" locally — counts and labels only, never paths or content.",
|
|
162
|
+
],
|
|
163
|
+
],
|
|
143
164
|
[
|
|
144
165
|
"serve",
|
|
145
166
|
[
|
|
@@ -175,6 +196,8 @@ function parseLocalArgs(argv) {
|
|
|
175
196
|
return parseSyncArgs(argv.slice(1));
|
|
176
197
|
case "status":
|
|
177
198
|
return parseStatusArgs(argv.slice(1));
|
|
199
|
+
case "sessions":
|
|
200
|
+
return parseSessionsArgs(argv.slice(1));
|
|
178
201
|
case "serve":
|
|
179
202
|
return parseServeArgs(argv.slice(1));
|
|
180
203
|
default:
|
|
@@ -362,6 +385,26 @@ function parseStatusArgs(args) {
|
|
|
362
385
|
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
363
386
|
};
|
|
364
387
|
}
|
|
388
|
+
function parseSessionsArgs(args) {
|
|
389
|
+
const values = parseNamedArgs(args, {
|
|
390
|
+
allowedFlags: ["--home", "--repo", "--source", "--json", "--max-depth", "--max-repos"],
|
|
391
|
+
valueFlags: ["--home", "--repo", "--source", "--max-depth", "--max-repos"],
|
|
392
|
+
});
|
|
393
|
+
assertNoPositionals(values.positionals, "sessions");
|
|
394
|
+
const source = values.flags.get("--source");
|
|
395
|
+
if (source !== undefined && source !== "codex" && source !== "claude") {
|
|
396
|
+
throw new Error("--source must be 'codex' or 'claude'.");
|
|
397
|
+
}
|
|
398
|
+
return {
|
|
399
|
+
kind: "sessions",
|
|
400
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
401
|
+
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
402
|
+
source,
|
|
403
|
+
json: values.booleans.has("--json"),
|
|
404
|
+
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
405
|
+
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
365
408
|
function parseServeArgs(args) {
|
|
366
409
|
const values = parseNamedArgs(args, {
|
|
367
410
|
allowedFlags: ["--home", "--repo", "--port"],
|
|
@@ -554,8 +597,7 @@ async function runOnboard(command, io) {
|
|
|
554
597
|
writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
|
|
555
598
|
writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
|
|
556
599
|
writeLine(io.stdout, rawEvidenceSyncLine(sync));
|
|
557
|
-
|
|
558
|
-
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
600
|
+
writeAgentSessionSummary(io, run.summary);
|
|
559
601
|
writeLine(io.stdout, "5/5 Status ready.");
|
|
560
602
|
writeLine(io.stdout, `Upload state: ${status.upload_state}`);
|
|
561
603
|
writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
|
|
@@ -616,21 +658,68 @@ async function readOnboardSessionReuseCandidate(homeDir) {
|
|
|
616
658
|
function normalizeUrlForComparison(value) {
|
|
617
659
|
return value ? normalizeUrl(value) : null;
|
|
618
660
|
}
|
|
661
|
+
const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
|
|
662
|
+
const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
|
|
663
|
+
const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
619
664
|
/**
|
|
620
|
-
* Shared sync orchestration for single-repo and parent-folder
|
|
621
|
-
* sessions are scanned and attributed once across
|
|
622
|
-
* each worktree syncs with only its own attributed
|
|
623
|
-
*
|
|
624
|
-
*
|
|
665
|
+
* Shared dual-source sync orchestration for single-repo and parent-folder
|
|
666
|
+
* modes. Codex AND Claude Code sessions are scanned and attributed once across
|
|
667
|
+
* every discovered worktree; each worktree syncs with only its own attributed
|
|
668
|
+
* transcripts (codex + claude main + sidecars), and the
|
|
669
|
+
* ambiguous/unattributed/skipped remainder is reported with reason labels and a
|
|
670
|
+
* `source` discriminator instead of being duplicated into every repo or
|
|
671
|
+
* silently dropped. The session row's upload state maps ONLY from the main-file
|
|
672
|
+
* outcome (D3); sidecar outcomes aggregate into CLI counts.
|
|
625
673
|
*/
|
|
626
674
|
async function runAttributedWorktreeSync(options) {
|
|
627
675
|
const now = new Date();
|
|
628
676
|
const homeDir = options.homeDir ?? os.homedir();
|
|
629
|
-
const
|
|
677
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
678
|
+
const claudeEnabled = await isClaudeCollectionEnabled(paths);
|
|
679
|
+
const codexAttribution = await scanAndAttributeCodexSessions({
|
|
630
680
|
sessionsDir: path.join(homeDir, ".codex", "sessions"),
|
|
631
681
|
worktrees: options.worktrees,
|
|
632
682
|
now,
|
|
633
683
|
});
|
|
684
|
+
// First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
|
|
685
|
+
// days so the first sync captures retroactive history instead of only 24h.
|
|
686
|
+
const claudeCursorExists = await fileExists(path.join(paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
|
|
687
|
+
const firstRunBackfill = claudeEnabled && !claudeCursorExists;
|
|
688
|
+
const claudeAttribution = claudeEnabled
|
|
689
|
+
? await scanAndAttributeClaudeSessions({
|
|
690
|
+
projectsDir: path.join(homeDir, ".claude", "projects"),
|
|
691
|
+
worktrees: options.worktrees,
|
|
692
|
+
now,
|
|
693
|
+
sinceMinutes: firstRunBackfill
|
|
694
|
+
? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
|
|
695
|
+
: undefined,
|
|
696
|
+
})
|
|
697
|
+
: emptyClaudeScan();
|
|
698
|
+
// Read the Claude cursor up front: damping decisions need prior upload state.
|
|
699
|
+
const claudeCursorBefore = claudeEnabled
|
|
700
|
+
? await readRawEvidenceCursor(paths, {
|
|
701
|
+
filename: CLAUDE_CURSOR_FILENAME,
|
|
702
|
+
}).catch(() => emptyRawEvidenceCursorState())
|
|
703
|
+
: emptyRawEvidenceCursorState();
|
|
704
|
+
// sessionId -> prior durable pointer for damped sessions (drives the
|
|
705
|
+
// growth_damped count + the skip_main decision).
|
|
706
|
+
const dampedClaudePointers = new Map();
|
|
707
|
+
// sessionId -> prior durable pointer for EVERY already-durable Claude session
|
|
708
|
+
// (superset of damped). A session that was durable before but had no fresh
|
|
709
|
+
// upload this sync (damped, spooled, budget-deferred) reports reused_existing
|
|
710
|
+
// with this pointer instead of not_uploaded, so the store row never flips.
|
|
711
|
+
const claudePriorDurablePointers = new Map();
|
|
712
|
+
for (const [sessionId, entry] of Object.entries(claudeCursorBefore.sessions)) {
|
|
713
|
+
if (entry.uploaded_object_key) {
|
|
714
|
+
claudePriorDurablePointers.set(sessionId, entry.uploaded_object_key);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
// One shared budget for the whole sync (D7b is per-sync): a parent-folder
|
|
718
|
+
// sync over many worktrees honors a single byte/object cap rather than N×.
|
|
719
|
+
const rawEvidenceBudget = {
|
|
720
|
+
remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
|
|
721
|
+
remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
|
|
722
|
+
};
|
|
634
723
|
const outcomes = [];
|
|
635
724
|
let ok = true;
|
|
636
725
|
for (const worktree of options.worktrees) {
|
|
@@ -645,11 +734,32 @@ async function runAttributedWorktreeSync(options) {
|
|
|
645
734
|
sessionId: options.sessionId,
|
|
646
735
|
});
|
|
647
736
|
}
|
|
737
|
+
const claudeSessionFiles = claudeAttribution.results
|
|
738
|
+
.filter((result) => result.state === "attributed" &&
|
|
739
|
+
result.worktree?.worktree_fingerprint ===
|
|
740
|
+
worktree.worktree_fingerprint)
|
|
741
|
+
.map((result) => {
|
|
742
|
+
const damped = !result.main_file_oversized &&
|
|
743
|
+
shouldDampClaudeMain(result, claudeCursorBefore, now);
|
|
744
|
+
if (damped) {
|
|
745
|
+
dampedClaudePointers.set(result.claude_session_id, claudeCursorBefore.sessions[result.claude_session_id]
|
|
746
|
+
?.uploaded_object_key ?? null);
|
|
747
|
+
}
|
|
748
|
+
return {
|
|
749
|
+
local_path: result.file_path,
|
|
750
|
+
claude_session_id: result.claude_session_id,
|
|
751
|
+
main_file_oversized: result.main_file_oversized,
|
|
752
|
+
skip_main: damped,
|
|
753
|
+
sidecar_files: result.sidecar_files
|
|
754
|
+
.filter((sidecar) => !sidecar.skipped_reason)
|
|
755
|
+
.map((sidecar) => ({ local_path: sidecar.local_path })),
|
|
756
|
+
};
|
|
757
|
+
});
|
|
648
758
|
const syncOptions = {
|
|
649
759
|
homeDir: options.homeDir,
|
|
650
760
|
repoRoot: worktree.repo_root,
|
|
651
761
|
dashboardUrl: options.dashboardUrl,
|
|
652
|
-
codexSessionFiles:
|
|
762
|
+
codexSessionFiles: codexAttribution.results
|
|
653
763
|
.filter((result) => result.state === "attributed" &&
|
|
654
764
|
result.worktree?.worktree_fingerprint ===
|
|
655
765
|
worktree.worktree_fingerprint)
|
|
@@ -657,6 +767,8 @@ async function runAttributedWorktreeSync(options) {
|
|
|
657
767
|
local_path: result.file_path,
|
|
658
768
|
codex_session_id: result.codex_session_id,
|
|
659
769
|
})),
|
|
770
|
+
claudeSessionFiles,
|
|
771
|
+
rawEvidenceBudget,
|
|
660
772
|
fetch: options.fetchImpl,
|
|
661
773
|
};
|
|
662
774
|
let sync;
|
|
@@ -684,39 +796,54 @@ async function runAttributedWorktreeSync(options) {
|
|
|
684
796
|
ok = ok && sync.status === "uploaded";
|
|
685
797
|
outcomes.push({ worktree, context, sync });
|
|
686
798
|
}
|
|
687
|
-
const sessions =
|
|
799
|
+
const sessions = buildAgentSessionReport({
|
|
800
|
+
codexResults: codexAttribution.results,
|
|
801
|
+
claudeResults: claudeAttribution.results,
|
|
802
|
+
outcomes,
|
|
803
|
+
now,
|
|
804
|
+
claudePriorDurablePointers,
|
|
805
|
+
});
|
|
688
806
|
// The sessions cursor is an optimization; a broken local state dir must not
|
|
689
807
|
// turn already-completed syncs into a CLI crash.
|
|
690
|
-
let
|
|
808
|
+
let codexStaleCount = 0;
|
|
809
|
+
let claudeStaleCount = 0;
|
|
691
810
|
try {
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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);
|
|
811
|
+
const codexCursor = await readRawEvidenceCursor(paths);
|
|
812
|
+
codexStaleCount = recordSourceObservations({
|
|
813
|
+
cursor: codexCursor,
|
|
814
|
+
results: codexAttribution.results,
|
|
815
|
+
sessions,
|
|
816
|
+
source: "codex",
|
|
817
|
+
sessionIdOf: (result) => result.codex_session_id,
|
|
818
|
+
now,
|
|
819
|
+
});
|
|
820
|
+
codexCursor.updated_at = now.toISOString();
|
|
821
|
+
await writeRawEvidenceCursor(paths, codexCursor);
|
|
716
822
|
}
|
|
717
823
|
catch {
|
|
718
824
|
// Best-effort: stale counts read 0 and observations re-record next sync.
|
|
719
825
|
}
|
|
826
|
+
if (claudeEnabled) {
|
|
827
|
+
try {
|
|
828
|
+
claudeStaleCount = recordSourceObservations({
|
|
829
|
+
cursor: claudeCursorBefore,
|
|
830
|
+
results: claudeAttribution.results,
|
|
831
|
+
sessions,
|
|
832
|
+
source: "claude_code",
|
|
833
|
+
sessionIdOf: (result) => result.claude_session_id,
|
|
834
|
+
now,
|
|
835
|
+
priorCursor: claudeCursorBefore,
|
|
836
|
+
});
|
|
837
|
+
claudeCursorBefore.updated_at = now.toISOString();
|
|
838
|
+
await writeRawEvidenceCursor(paths, claudeCursorBefore, {
|
|
839
|
+
filename: CLAUDE_CURSOR_FILENAME,
|
|
840
|
+
sessionsOnly: true,
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
catch {
|
|
844
|
+
// Best-effort: a broken Claude cursor must not fail the sync.
|
|
845
|
+
}
|
|
846
|
+
}
|
|
720
847
|
const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
|
|
721
848
|
const report = firstUploaded
|
|
722
849
|
? await postCodexSessionReport({
|
|
@@ -731,21 +858,17 @@ async function runAttributedWorktreeSync(options) {
|
|
|
731
858
|
posted: false,
|
|
732
859
|
reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
|
|
733
860
|
};
|
|
734
|
-
|
|
735
|
-
|
|
861
|
+
const summary = buildAgentSessionSummary({
|
|
862
|
+
codexAttribution,
|
|
863
|
+
claudeAttribution,
|
|
736
864
|
outcomes,
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
stale: staleSessionCount,
|
|
745
|
-
report_posted: report.posted,
|
|
746
|
-
report_reason: report.reason,
|
|
747
|
-
},
|
|
748
|
-
};
|
|
865
|
+
codexStaleCount,
|
|
866
|
+
claudeStaleCount,
|
|
867
|
+
firstRunBackfill,
|
|
868
|
+
growthDamped: dampedClaudePointers.size,
|
|
869
|
+
report,
|
|
870
|
+
});
|
|
871
|
+
return { ok, outcomes, codexAttribution, claudeAttribution, summary };
|
|
749
872
|
}
|
|
750
873
|
const ATTRIBUTION_STATE_RANK = {
|
|
751
874
|
attributed: 3,
|
|
@@ -753,38 +876,100 @@ const ATTRIBUTION_STATE_RANK = {
|
|
|
753
876
|
unattributed: 1,
|
|
754
877
|
skipped: 0,
|
|
755
878
|
};
|
|
756
|
-
function
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
879
|
+
function normalizeCodexResult(result) {
|
|
880
|
+
return {
|
|
881
|
+
source: "codex",
|
|
882
|
+
session_id: result.codex_session_id,
|
|
883
|
+
state: result.state,
|
|
884
|
+
reason: result.reason,
|
|
885
|
+
signals: result.signals,
|
|
886
|
+
attribution_score: result.attribution_score,
|
|
887
|
+
path_score: result.path_score,
|
|
888
|
+
content_hash_sha256: result.content_hash_sha256,
|
|
889
|
+
byte_size: result.byte_size,
|
|
890
|
+
session_file_mtime: result.session_file_mtime,
|
|
891
|
+
session_file_mtime_ms: result.session_file_mtime_ms,
|
|
892
|
+
worktree: result.worktree,
|
|
893
|
+
cwd_basename: result.cwd_basename,
|
|
894
|
+
cwd_hash: result.cwd_hash,
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
function normalizeClaudeResult(result) {
|
|
898
|
+
return {
|
|
899
|
+
source: "claude_code",
|
|
900
|
+
session_id: result.claude_session_id,
|
|
901
|
+
state: result.state,
|
|
902
|
+
reason: result.reason,
|
|
903
|
+
signals: result.signals,
|
|
904
|
+
attribution_score: result.attribution_score,
|
|
905
|
+
path_score: result.path_score,
|
|
906
|
+
content_hash_sha256: result.content_hash_sha256,
|
|
907
|
+
byte_size: result.byte_size,
|
|
908
|
+
session_file_mtime: result.session_file_mtime,
|
|
909
|
+
session_file_mtime_ms: result.session_file_mtime_ms,
|
|
910
|
+
worktree: result.worktree,
|
|
911
|
+
cwd_basename: result.cwd_basename,
|
|
912
|
+
cwd_hash: result.cwd_hash,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Generalizes the per-session report across sources. Dedupe is per
|
|
917
|
+
* `(source, session_id)` so a Codex session and a Claude session that happen to
|
|
918
|
+
* share an id are never collapsed. Upload state maps ONLY from the main-file
|
|
919
|
+
* outcome (kind `codex_jsonl` / `claude_jsonl`); sidecar outcomes never set a
|
|
920
|
+
* session's upload state (D3). Damped Claude sessions report `reused_existing`
|
|
921
|
+
* carrying their prior durable pointer.
|
|
922
|
+
*/
|
|
923
|
+
function buildAgentSessionReport(options) {
|
|
924
|
+
const normalized = [
|
|
925
|
+
...options.codexResults.map(normalizeCodexResult),
|
|
926
|
+
...options.claudeResults.map(normalizeClaudeResult),
|
|
927
|
+
];
|
|
928
|
+
const bestByKey = new Map();
|
|
929
|
+
for (const result of normalized) {
|
|
930
|
+
const key = `${result.source}:${result.session_id}`;
|
|
931
|
+
const existing = bestByKey.get(key);
|
|
763
932
|
if (!existing ||
|
|
764
933
|
(ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
|
|
765
934
|
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
|
|
766
935
|
((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
|
|
767
936
|
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
|
|
768
937
|
result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
|
|
769
|
-
|
|
938
|
+
bestByKey.set(key, result);
|
|
770
939
|
}
|
|
771
940
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
941
|
+
// Main-file outcomes only (D3): a sidecar making it must never mark a session
|
|
942
|
+
// uploaded when the main did not.
|
|
943
|
+
const uploadByKey = new Map();
|
|
944
|
+
for (const outcome of options.outcomes) {
|
|
775
945
|
if (outcome.sync.status !== "uploaded")
|
|
776
946
|
continue;
|
|
777
947
|
for (const upload of outcome.sync.raw_evidence_outcomes) {
|
|
778
|
-
if (upload.codex_session_id
|
|
779
|
-
|
|
780
|
-
|
|
948
|
+
if (!upload.codex_session_id || !upload.raw_evidence_pointer_id)
|
|
949
|
+
continue;
|
|
950
|
+
const source = upload.kind === "claude_jsonl"
|
|
951
|
+
? "claude_code"
|
|
952
|
+
: upload.kind === "codex_jsonl"
|
|
953
|
+
? "codex"
|
|
954
|
+
: null;
|
|
955
|
+
if (!source)
|
|
956
|
+
continue; // sidecars and other kinds do not set session state
|
|
957
|
+
uploadByKey.set(`${source}:${upload.codex_session_id}`, upload);
|
|
781
958
|
}
|
|
782
959
|
}
|
|
783
|
-
return
|
|
784
|
-
const
|
|
960
|
+
return [...bestByKey.values()].map((result) => {
|
|
961
|
+
const key = `${result.source}:${result.session_id}`;
|
|
962
|
+
const upload = uploadByKey.get(key);
|
|
963
|
+
// A previously-durable Claude session with no fresh main upload this sync
|
|
964
|
+
// (damped / spooled / budget-deferred) reports reused_existing + its prior
|
|
965
|
+
// pointer rather than not_uploaded, so the store row never flips.
|
|
966
|
+
const priorDurablePointer = result.source === "claude_code"
|
|
967
|
+
? (options.claudePriorDurablePointers.get(result.session_id) ?? null)
|
|
968
|
+
: null;
|
|
785
969
|
return {
|
|
786
|
-
codex_session_id: result.
|
|
787
|
-
|
|
970
|
+
codex_session_id: result.session_id,
|
|
971
|
+
source: result.source,
|
|
972
|
+
observed_at: options.now.toISOString(),
|
|
788
973
|
attribution_state: result.state,
|
|
789
974
|
attribution_reason: result.reason,
|
|
790
975
|
attribution_score: result.attribution_score,
|
|
@@ -810,23 +995,215 @@ function buildCodexSessionReport(results, outcomes, now) {
|
|
|
810
995
|
raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
|
|
811
996
|
upload_state: upload.upload_state,
|
|
812
997
|
}
|
|
813
|
-
:
|
|
814
|
-
? {
|
|
815
|
-
|
|
998
|
+
: priorDurablePointer
|
|
999
|
+
? {
|
|
1000
|
+
raw_evidence_pointer_id: priorDurablePointer,
|
|
1001
|
+
upload_state: "reused_existing",
|
|
1002
|
+
}
|
|
1003
|
+
: result.state === "attributed"
|
|
1004
|
+
? { upload_state: "not_uploaded" }
|
|
1005
|
+
: {}),
|
|
816
1006
|
};
|
|
817
1007
|
});
|
|
818
1008
|
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Records per-source session observations into its cursor and returns the stale
|
|
1011
|
+
* count. Damped/reused Claude sessions carry forward their prior upload
|
|
1012
|
+
* timestamp + byte size so the 6h damping window keeps counting from the real
|
|
1013
|
+
* last upload (otherwise a slowly-growing file would never re-upload — D21).
|
|
1014
|
+
*/
|
|
1015
|
+
function recordSourceObservations(options) {
|
|
1016
|
+
const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
|
|
1017
|
+
const stale = countStaleSessions(options.cursor, seen);
|
|
1018
|
+
for (const result of options.results) {
|
|
1019
|
+
const sessionId = options.sessionIdOf(result);
|
|
1020
|
+
const reported = options.sessions.find((session) => session.source === options.source &&
|
|
1021
|
+
session.codex_session_id === sessionId);
|
|
1022
|
+
const uploadedThisSync = reported?.upload_state === "uploaded";
|
|
1023
|
+
const durableThisSync = reported?.upload_state === "uploaded" ||
|
|
1024
|
+
reported?.upload_state === "reused_existing";
|
|
1025
|
+
const prior = options.priorCursor?.sessions[sessionId];
|
|
1026
|
+
// D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
|
|
1027
|
+
// upload-failed for a session that was ALREADY durable must NOT wipe the
|
|
1028
|
+
// prior durable state — otherwise damping is forfeited forever and the
|
|
1029
|
+
// store row oscillates uploaded -> not_uploaded hourly. Carry the prior
|
|
1030
|
+
// durable pointer/timestamp/size forward unless we durably uploaded anew.
|
|
1031
|
+
const uploadedObjectKey = durableThisSync
|
|
1032
|
+
? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
|
|
1033
|
+
: (prior?.uploaded_object_key ?? null);
|
|
1034
|
+
const uploadedAt = uploadedThisSync
|
|
1035
|
+
? options.now.toISOString()
|
|
1036
|
+
: (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
|
|
1037
|
+
const uploadedByteSize = uploadedThisSync
|
|
1038
|
+
? result.byte_size
|
|
1039
|
+
: (prior?.uploaded_byte_size ??
|
|
1040
|
+
(durableThisSync ? result.byte_size : null));
|
|
1041
|
+
const entry = {
|
|
1042
|
+
file_hash_sha256: result.content_hash_sha256,
|
|
1043
|
+
file_mtime_ms: result.session_file_mtime_ms,
|
|
1044
|
+
byte_size: result.byte_size,
|
|
1045
|
+
// Durable byte offset reflects how many bytes are durable remotely (the
|
|
1046
|
+
// last uploaded size), not the current file size.
|
|
1047
|
+
byte_offset: uploadedByteSize ?? 0,
|
|
1048
|
+
state: result.state,
|
|
1049
|
+
reason: result.reason,
|
|
1050
|
+
worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
|
|
1051
|
+
uploaded_object_key: uploadedObjectKey,
|
|
1052
|
+
uploaded_at: uploadedAt,
|
|
1053
|
+
uploaded_byte_size: uploadedByteSize,
|
|
1054
|
+
last_seen_at: options.now.toISOString(),
|
|
1055
|
+
};
|
|
1056
|
+
recordSessionObservation(options.cursor, sessionId, entry);
|
|
1057
|
+
}
|
|
1058
|
+
return stale;
|
|
1059
|
+
}
|
|
1060
|
+
function shouldDampClaudeMain(result, cursor, now) {
|
|
1061
|
+
const entry = cursor.sessions[result.claude_session_id];
|
|
1062
|
+
if (!entry ||
|
|
1063
|
+
!entry.uploaded_object_key ||
|
|
1064
|
+
!entry.uploaded_at ||
|
|
1065
|
+
entry.uploaded_byte_size == null) {
|
|
1066
|
+
return false;
|
|
1067
|
+
}
|
|
1068
|
+
const grew = result.byte_size > entry.uploaded_byte_size;
|
|
1069
|
+
if (!grew)
|
|
1070
|
+
return false; // unchanged content reuses via the object cursor
|
|
1071
|
+
const growth = result.byte_size - entry.uploaded_byte_size;
|
|
1072
|
+
const ageMs = now.getTime() - Date.parse(entry.uploaded_at);
|
|
1073
|
+
return (growth <= CLAUDE_DAMP_GROWTH_BYTES &&
|
|
1074
|
+
Number.isFinite(ageMs) &&
|
|
1075
|
+
ageMs <= CLAUDE_DAMP_MAX_AGE_MS);
|
|
1076
|
+
}
|
|
1077
|
+
function buildAgentSessionSummary(options) {
|
|
1078
|
+
const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
|
|
1079
|
+
const attributedClaude = options.claudeAttribution.results.filter((result) => result.state === "attributed");
|
|
1080
|
+
const sidecarsCollected = attributedClaude.reduce((total, result) => total +
|
|
1081
|
+
result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
|
|
1082
|
+
const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
|
|
1083
|
+
result.sidecar_files.filter((sidecar) => sidecar.skipped_reason).length, 0);
|
|
1084
|
+
const codex = {
|
|
1085
|
+
scanned: options.codexAttribution.scanned_file_count,
|
|
1086
|
+
attributed: options.codexAttribution.counts.attributed,
|
|
1087
|
+
ambiguous: options.codexAttribution.counts.ambiguous,
|
|
1088
|
+
unattributed: options.codexAttribution.counts.unattributed,
|
|
1089
|
+
skipped: options.codexAttribution.counts.skipped,
|
|
1090
|
+
stale: options.codexStaleCount,
|
|
1091
|
+
};
|
|
1092
|
+
const claude = {
|
|
1093
|
+
scanned: options.claudeAttribution.scanned_session_count,
|
|
1094
|
+
attributed: options.claudeAttribution.counts.attributed,
|
|
1095
|
+
ambiguous: options.claudeAttribution.counts.ambiguous,
|
|
1096
|
+
unattributed: options.claudeAttribution.counts.unattributed,
|
|
1097
|
+
skipped: options.claudeAttribution.counts.skipped,
|
|
1098
|
+
stale: options.claudeStaleCount,
|
|
1099
|
+
sidecars_collected: sidecarsCollected,
|
|
1100
|
+
sidecars_uploaded: sidecarOutcomes.filter((upload) => upload.upload_state === "uploaded" ||
|
|
1101
|
+
upload.upload_state === "reused_existing").length,
|
|
1102
|
+
sidecars_skipped: sidecarsSkipped,
|
|
1103
|
+
sidecars_capped: options.claudeAttribution.counts.sidecars_capped,
|
|
1104
|
+
sidecars_failed: sidecarOutcomes.filter((upload) => upload.upload_state === "upload_failed").length,
|
|
1105
|
+
mains_oversized: options.claudeAttribution.counts.mains_oversized,
|
|
1106
|
+
oversized_lines_skipped: options.claudeAttribution.counts.oversized_lines_skipped,
|
|
1107
|
+
project_dirs_skipped: options.claudeAttribution.project_dirs_skipped,
|
|
1108
|
+
sessions_schema_drift: options.claudeAttribution.counts.sessions_schema_drift,
|
|
1109
|
+
growth_damped: options.growthDamped,
|
|
1110
|
+
first_run_backfill: options.firstRunBackfill,
|
|
1111
|
+
};
|
|
1112
|
+
return {
|
|
1113
|
+
scanned: codex.scanned,
|
|
1114
|
+
attributed: codex.attributed,
|
|
1115
|
+
ambiguous: codex.ambiguous,
|
|
1116
|
+
unattributed: codex.unattributed,
|
|
1117
|
+
skipped: codex.skipped,
|
|
1118
|
+
stale: codex.stale,
|
|
1119
|
+
report_posted: options.report.posted,
|
|
1120
|
+
report_reason: options.report.reason,
|
|
1121
|
+
codex,
|
|
1122
|
+
claude,
|
|
1123
|
+
files_deferred_byte_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_byte_budget, 0),
|
|
1124
|
+
files_deferred_object_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_object_budget, 0),
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
function emptyClaudeScan() {
|
|
1128
|
+
return {
|
|
1129
|
+
results: [],
|
|
1130
|
+
scanned_session_count: 0,
|
|
1131
|
+
project_dirs_skipped: 0,
|
|
1132
|
+
counts: {
|
|
1133
|
+
attributed: 0,
|
|
1134
|
+
ambiguous: 0,
|
|
1135
|
+
unattributed: 0,
|
|
1136
|
+
skipped: 0,
|
|
1137
|
+
mains_oversized: 0,
|
|
1138
|
+
oversized_lines_skipped: 0,
|
|
1139
|
+
sessions_schema_drift: 0,
|
|
1140
|
+
sidecars_capped: 0,
|
|
1141
|
+
},
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
async function isClaudeCollectionEnabled(paths) {
|
|
1145
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
1146
|
+
return config?.collect_claude_jsonl !== false;
|
|
1147
|
+
}
|
|
1148
|
+
async function fileExists(filePath) {
|
|
1149
|
+
const { stat } = await import("node:fs/promises");
|
|
1150
|
+
return stat(filePath).then(() => true, () => false);
|
|
1151
|
+
}
|
|
819
1152
|
function shortSha(value) {
|
|
820
1153
|
return value ? value.slice(0, 12) : "unknown";
|
|
821
1154
|
}
|
|
822
|
-
function
|
|
823
|
-
return
|
|
1155
|
+
function sourceFunnelLine(label, counts) {
|
|
1156
|
+
return `${label} sessions: attributed ${counts.attributed}, ambiguous ${counts.ambiguous}, unattributed ${counts.unattributed}, skipped ${counts.skipped}, stale ${counts.stale}`;
|
|
824
1157
|
}
|
|
825
1158
|
function attributionReportLine(summary) {
|
|
826
1159
|
return summary.report_posted
|
|
827
1160
|
? "Attribution report: recorded"
|
|
828
1161
|
: `Attribution report: skipped (${summary.report_reason})`;
|
|
829
1162
|
}
|
|
1163
|
+
/**
|
|
1164
|
+
* One funnel line per source, an anomaly diagnostics line only when something
|
|
1165
|
+
* is nonzero (clean syncs stay one line per source), and the report line.
|
|
1166
|
+
* Counts only — project-dir slugs encode full local paths and never print
|
|
1167
|
+
* (B.4 §5).
|
|
1168
|
+
*/
|
|
1169
|
+
function writeAgentSessionSummary(io, summary) {
|
|
1170
|
+
writeLine(io.stdout, sourceFunnelLine("Codex", summary.codex));
|
|
1171
|
+
writeLine(io.stdout, sourceFunnelLine("Claude", summary.claude));
|
|
1172
|
+
const claudeDiagnostics = claudeDiagnosticsLine(summary);
|
|
1173
|
+
if (claudeDiagnostics)
|
|
1174
|
+
writeLine(io.stdout, claudeDiagnostics);
|
|
1175
|
+
writeLine(io.stdout, attributionReportLine(summary));
|
|
1176
|
+
}
|
|
1177
|
+
function claudeDiagnosticsLine(summary) {
|
|
1178
|
+
// Anomaly-only (D34): sidecars_collected/uploaded are normal-operation
|
|
1179
|
+
// counters and must NOT trigger this line, or a healthy orchestrated sync
|
|
1180
|
+
// prints it 48×/day in launchd logs. Clean syncs stay one line per source.
|
|
1181
|
+
const claude = summary.claude;
|
|
1182
|
+
const parts = [];
|
|
1183
|
+
if (claude.sidecars_skipped)
|
|
1184
|
+
parts.push(`sidecars_skipped ${claude.sidecars_skipped}`);
|
|
1185
|
+
if (claude.sidecars_capped)
|
|
1186
|
+
parts.push(`sidecars_capped ${claude.sidecars_capped}`);
|
|
1187
|
+
if (claude.sidecars_failed)
|
|
1188
|
+
parts.push(`sidecars_failed ${claude.sidecars_failed}`);
|
|
1189
|
+
if (claude.mains_oversized)
|
|
1190
|
+
parts.push(`mains_oversized ${claude.mains_oversized}`);
|
|
1191
|
+
if (claude.oversized_lines_skipped)
|
|
1192
|
+
parts.push(`oversized_lines_skipped ${claude.oversized_lines_skipped}`);
|
|
1193
|
+
if (claude.project_dirs_skipped)
|
|
1194
|
+
parts.push(`project_dirs_skipped ${claude.project_dirs_skipped}`);
|
|
1195
|
+
if (claude.sessions_schema_drift)
|
|
1196
|
+
parts.push(`schema_drift ${claude.sessions_schema_drift}`);
|
|
1197
|
+
if (claude.growth_damped)
|
|
1198
|
+
parts.push(`growth_damped ${claude.growth_damped}`);
|
|
1199
|
+
if (claude.first_run_backfill)
|
|
1200
|
+
parts.push("first_run_backfill");
|
|
1201
|
+
if (summary.files_deferred_byte_budget)
|
|
1202
|
+
parts.push(`deferred_byte_budget ${summary.files_deferred_byte_budget}`);
|
|
1203
|
+
if (summary.files_deferred_object_budget)
|
|
1204
|
+
parts.push(`deferred_object_budget ${summary.files_deferred_object_budget}`);
|
|
1205
|
+
return parts.length > 0 ? `Claude diagnostics: ${parts.join(", ")}` : null;
|
|
1206
|
+
}
|
|
830
1207
|
function rawEvidenceSyncLine(sync) {
|
|
831
1208
|
const failures = sync.raw_evidence_failure_reasons.length > 0
|
|
832
1209
|
? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
|
|
@@ -865,7 +1242,7 @@ async function runMultiRepoOnboard(command, io, worktrees) {
|
|
|
865
1242
|
worktrees,
|
|
866
1243
|
fetchImpl: io.fetch,
|
|
867
1244
|
});
|
|
868
|
-
const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run
|
|
1245
|
+
const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
|
|
869
1246
|
if (!command.json) {
|
|
870
1247
|
for (const [index, outcome] of run.outcomes.entries()) {
|
|
871
1248
|
const row = results[index];
|
|
@@ -876,8 +1253,7 @@ async function runMultiRepoOnboard(command, io, worktrees) {
|
|
|
876
1253
|
}
|
|
877
1254
|
writeLine(io.stdout, "4/5 Parent worktree sync complete.");
|
|
878
1255
|
writeLine(io.stdout, `Uploaded: ${results.filter((row) => row["upload_status"] === "uploaded").length}/${results.length}`);
|
|
879
|
-
|
|
880
|
-
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
1256
|
+
writeAgentSessionSummary(io, run.summary);
|
|
881
1257
|
writeLine(io.stdout, "5/5 Status ready.");
|
|
882
1258
|
writeLine(run.ok ? io.stdout : io.stderr, run.ok
|
|
883
1259
|
? "PASS: Cockpit collector is ready for harvest."
|
|
@@ -886,10 +1262,13 @@ async function runMultiRepoOnboard(command, io, worktrees) {
|
|
|
886
1262
|
}
|
|
887
1263
|
return { ok: run.ok, results, codex_sessions: run.summary };
|
|
888
1264
|
}
|
|
889
|
-
function worktreeSyncRow(outcome,
|
|
1265
|
+
function worktreeSyncRow(outcome, run) {
|
|
890
1266
|
const { worktree, context, sync } = outcome;
|
|
891
|
-
const
|
|
892
|
-
result.worktree?.worktree_fingerprint === worktree.worktree_fingerprint
|
|
1267
|
+
const matchesWorktree = (result) => result.state === "attributed" &&
|
|
1268
|
+
result.worktree?.worktree_fingerprint === worktree.worktree_fingerprint;
|
|
1269
|
+
const codexSessionCount = run.codexAttribution.results.filter(matchesWorktree).length;
|
|
1270
|
+
const claudeSessionCount = run.claudeAttribution.results.filter(matchesWorktree).length;
|
|
1271
|
+
const attributedSessionCount = codexSessionCount + claudeSessionCount;
|
|
893
1272
|
return {
|
|
894
1273
|
repo_label: context?.repo_label ?? worktree.repo_label,
|
|
895
1274
|
repo_fingerprint: context?.repo_fingerprint ?? worktree.repo_fingerprint,
|
|
@@ -906,6 +1285,8 @@ function worktreeSyncRow(outcome, attribution) {
|
|
|
906
1285
|
raw_evidence_failed_count: sync.raw_evidence_failed_count,
|
|
907
1286
|
raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
|
|
908
1287
|
attributed_session_count: attributedSessionCount,
|
|
1288
|
+
codex_session_count: codexSessionCount,
|
|
1289
|
+
claude_session_count: claudeSessionCount,
|
|
909
1290
|
cursor_tracked_object_count: sync.cursor_tracked_object_count,
|
|
910
1291
|
failure_reason: sync.status === "spooled" ? sync.failure_reason : null,
|
|
911
1292
|
};
|
|
@@ -1042,6 +1423,26 @@ async function runStart(command, io) {
|
|
|
1042
1423
|
return 0;
|
|
1043
1424
|
}
|
|
1044
1425
|
async function runSync(command, io) {
|
|
1426
|
+
// Single-flight: a launchd timer and a manual sync must not interleave the
|
|
1427
|
+
// cursor read-modify-write. A blocked invocation exits cleanly (B.4 §7).
|
|
1428
|
+
const lock = await acquireSyncLock(getCollectorRuntimePaths(command.homeDir));
|
|
1429
|
+
if (!lock.acquired) {
|
|
1430
|
+
if (command.json) {
|
|
1431
|
+
writeLine(io.stdout, JSON.stringify({ status: "sync_already_running", held_since: lock.held_since }, null, 2));
|
|
1432
|
+
}
|
|
1433
|
+
else {
|
|
1434
|
+
writeLine(io.stdout, "Cockpit sync already running; skipping this run.");
|
|
1435
|
+
}
|
|
1436
|
+
return 0;
|
|
1437
|
+
}
|
|
1438
|
+
try {
|
|
1439
|
+
return await runSyncLocked(command, io);
|
|
1440
|
+
}
|
|
1441
|
+
finally {
|
|
1442
|
+
await lock.handle.release();
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
async function runSyncLocked(command, io) {
|
|
1045
1446
|
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
1046
1447
|
const run = await runAttributedWorktreeSync({
|
|
1047
1448
|
homeDir: command.homeDir,
|
|
@@ -1051,7 +1452,7 @@ async function runSync(command, io) {
|
|
|
1051
1452
|
fetchImpl: io.fetch,
|
|
1052
1453
|
});
|
|
1053
1454
|
if (worktrees.length > 1) {
|
|
1054
|
-
const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run
|
|
1455
|
+
const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
|
|
1055
1456
|
if (command.json) {
|
|
1056
1457
|
writeLine(io.stdout, JSON.stringify({
|
|
1057
1458
|
mode: "multi_repo",
|
|
@@ -1069,8 +1470,7 @@ async function runSync(command, io) {
|
|
|
1069
1470
|
const failureSuffix = sync.status === "spooled" ? ` reason:${sync.failure_reason}` : "";
|
|
1070
1471
|
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
1472
|
}
|
|
1072
|
-
|
|
1073
|
-
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
1473
|
+
writeAgentSessionSummary(io, run.summary);
|
|
1074
1474
|
return run.ok ? 0 : 1;
|
|
1075
1475
|
}
|
|
1076
1476
|
const result = run.outcomes[0]?.sync;
|
|
@@ -1090,8 +1490,7 @@ async function runSync(command, io) {
|
|
|
1090
1490
|
writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
|
|
1091
1491
|
writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
|
|
1092
1492
|
writeLine(io.stdout, rawEvidenceSyncLine(result));
|
|
1093
|
-
|
|
1094
|
-
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
1493
|
+
writeAgentSessionSummary(io, run.summary);
|
|
1095
1494
|
writeLine(io.stdout, cursorStatusLine(result));
|
|
1096
1495
|
return 0;
|
|
1097
1496
|
}
|
|
@@ -1145,6 +1544,100 @@ async function runStatus(command, io) {
|
|
|
1145
1544
|
function displayTicketId(ticketId) {
|
|
1146
1545
|
return ticketId ?? "general ambient";
|
|
1147
1546
|
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Read-only diagnostic: re-runs attribution (no upload, no cursor writes) and
|
|
1549
|
+
* prints why each session is or is not collected. Per-session reasons otherwise
|
|
1550
|
+
* live only in a service-role table with no UI, so this is the operator's local
|
|
1551
|
+
* answer to "why is session X missing?" (B.4 §6). Counts and labels only — the
|
|
1552
|
+
* project-dir slug encodes a local path and is never printed.
|
|
1553
|
+
*/
|
|
1554
|
+
async function runSessions(command, io) {
|
|
1555
|
+
const now = new Date();
|
|
1556
|
+
const homeDir = command.homeDir ?? os.homedir();
|
|
1557
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
1558
|
+
const wantCodex = command.source !== "claude";
|
|
1559
|
+
const wantClaude = command.source !== "codex";
|
|
1560
|
+
const codex = wantCodex
|
|
1561
|
+
? await scanAndAttributeCodexSessions({
|
|
1562
|
+
sessionsDir: path.join(homeDir, ".codex", "sessions"),
|
|
1563
|
+
worktrees,
|
|
1564
|
+
now,
|
|
1565
|
+
})
|
|
1566
|
+
: null;
|
|
1567
|
+
const claude = wantClaude
|
|
1568
|
+
? await scanAndAttributeClaudeSessions({
|
|
1569
|
+
projectsDir: path.join(homeDir, ".claude", "projects"),
|
|
1570
|
+
worktrees,
|
|
1571
|
+
now,
|
|
1572
|
+
})
|
|
1573
|
+
: null;
|
|
1574
|
+
// Safe output contract (B.4 §6): id, state, reason, scores, signals, sidecar
|
|
1575
|
+
// skip reasons, plus the repo_label basename. Branch is intentionally omitted
|
|
1576
|
+
// — branch names can carry operator-authored task/customer text.
|
|
1577
|
+
const codexRows = (codex?.results ?? []).map((result) => ({
|
|
1578
|
+
source: "codex",
|
|
1579
|
+
session_id: result.codex_session_id,
|
|
1580
|
+
state: result.state,
|
|
1581
|
+
reason: result.reason,
|
|
1582
|
+
attribution_score: result.attribution_score,
|
|
1583
|
+
path_score: result.path_score,
|
|
1584
|
+
signals: result.signals,
|
|
1585
|
+
repo_label: result.worktree?.repo_label ?? null,
|
|
1586
|
+
}));
|
|
1587
|
+
const claudeRows = (claude?.results ?? []).map((result) => ({
|
|
1588
|
+
source: "claude_code",
|
|
1589
|
+
session_id: result.claude_session_id,
|
|
1590
|
+
state: result.state,
|
|
1591
|
+
reason: result.reason,
|
|
1592
|
+
attribution_score: result.attribution_score,
|
|
1593
|
+
path_score: result.path_score,
|
|
1594
|
+
signals: result.signals,
|
|
1595
|
+
repo_label: result.worktree?.repo_label ?? null,
|
|
1596
|
+
main_file_oversized: result.main_file_oversized,
|
|
1597
|
+
sidecar_skips: result.sidecar_files
|
|
1598
|
+
.filter((sidecar) => sidecar.skipped_reason)
|
|
1599
|
+
.map((sidecar) => ({
|
|
1600
|
+
file_name: sidecar.file_name,
|
|
1601
|
+
reason: sidecar.skipped_reason,
|
|
1602
|
+
})),
|
|
1603
|
+
}));
|
|
1604
|
+
if (command.json) {
|
|
1605
|
+
writeLine(io.stdout, JSON.stringify({
|
|
1606
|
+
...(codex
|
|
1607
|
+
? { codex: { counts: codex.counts, sessions: codexRows } }
|
|
1608
|
+
: {}),
|
|
1609
|
+
...(claude
|
|
1610
|
+
? {
|
|
1611
|
+
claude: {
|
|
1612
|
+
counts: claude.counts,
|
|
1613
|
+
project_dirs_skipped: claude.project_dirs_skipped,
|
|
1614
|
+
sessions: claudeRows,
|
|
1615
|
+
},
|
|
1616
|
+
}
|
|
1617
|
+
: {}),
|
|
1618
|
+
}, null, 2));
|
|
1619
|
+
return 0;
|
|
1620
|
+
}
|
|
1621
|
+
writeLine(io.stdout, "Cockpit sessions (read-only attribution)");
|
|
1622
|
+
for (const row of codexRows) {
|
|
1623
|
+
writeLine(io.stdout, sessionRowLine(row));
|
|
1624
|
+
}
|
|
1625
|
+
for (const row of claudeRows) {
|
|
1626
|
+
writeLine(io.stdout, sessionRowLine(row));
|
|
1627
|
+
for (const sidecar of row.sidecar_skips) {
|
|
1628
|
+
writeLine(io.stdout, ` sidecar ${sidecar.file_name}: ${sidecar.reason}`);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
if (codexRows.length === 0 && claudeRows.length === 0) {
|
|
1632
|
+
writeLine(io.stdout, "No sessions observed in the scan window.");
|
|
1633
|
+
}
|
|
1634
|
+
return 0;
|
|
1635
|
+
}
|
|
1636
|
+
function sessionRowLine(row) {
|
|
1637
|
+
const repo = row.repo_label ? ` repo:${row.repo_label}` : "";
|
|
1638
|
+
const signals = row.signals.length > 0 ? ` signals:${row.signals.join("|")}` : "";
|
|
1639
|
+
return `- [${row.source}] ${row.session_id} ${row.state} (${row.reason}) score:${row.attribution_score} path:${row.path_score}${repo}${signals}`;
|
|
1640
|
+
}
|
|
1148
1641
|
async function runServe(command, io) {
|
|
1149
1642
|
const server = createCollectorServer(command);
|
|
1150
1643
|
await new Promise((resolve) => {
|