@bli-cockpit/cli 0.1.6 → 0.1.8
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 +619 -90
- package/dist/cursors/raw-evidence-cursor.js +65 -14
- package/dist/local-state.js +2 -2
- 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
|
}
|
|
@@ -81,7 +89,7 @@ function localSubcommandHelp(command) {
|
|
|
81
89
|
"",
|
|
82
90
|
"Installs, pairs, starts work context(s), syncs once, and prints readiness proof.",
|
|
83
91
|
"If --repo is a parent folder, scans child git repos/worktrees and rolls them up by repo.",
|
|
84
|
-
"
|
|
92
|
+
"Run with no flags in a terminal and it prompts for the dashboard email; pass --email to skip the prompt (and on shared/reused machines, where mismatched sessions are re-paired).",
|
|
85
93
|
],
|
|
86
94
|
],
|
|
87
95
|
[
|
|
@@ -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"],
|
|
@@ -426,6 +469,38 @@ async function runInstall(command, io) {
|
|
|
426
469
|
writeLine(io.stdout, "Next: run `cockpit login`, then `cockpit start` inside the repo; add `--ticket <id>` only when ticket work begins.");
|
|
427
470
|
return 0;
|
|
428
471
|
}
|
|
472
|
+
function isInteractiveStdin(io) {
|
|
473
|
+
return Boolean(io.stdin.isTTY);
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Reads one line from stdin so `cockpit onboard` (no flags) can ask for the
|
|
477
|
+
* dashboard email instead of forcing a `--email` flag. Only called when stdin
|
|
478
|
+
* is a TTY and we're not in --json mode, so headless / piped / spawned runs
|
|
479
|
+
* never block on input — they keep the existing behaviour (email optional; the
|
|
480
|
+
* approving admin's account owns the device). An empty answer or a non-email
|
|
481
|
+
* skips rather than failing, matching `optionalEmail`'s leniency.
|
|
482
|
+
*/
|
|
483
|
+
async function promptOnboardEmail(io) {
|
|
484
|
+
io.stdout.write("Dashboard email (press enter to skip): ");
|
|
485
|
+
io.stdin.setEncoding("utf8");
|
|
486
|
+
const raw = await new Promise((resolve) => {
|
|
487
|
+
const onData = (chunk) => {
|
|
488
|
+
io.stdin.removeListener("data", onData);
|
|
489
|
+
io.stdin.pause();
|
|
490
|
+
resolve(chunk);
|
|
491
|
+
};
|
|
492
|
+
io.stdin.resume();
|
|
493
|
+
io.stdin.on("data", onData);
|
|
494
|
+
});
|
|
495
|
+
const answer = raw.trim().toLowerCase();
|
|
496
|
+
if (!answer)
|
|
497
|
+
return undefined;
|
|
498
|
+
if (!answer.includes("@")) {
|
|
499
|
+
writeLine(io.stderr, `"${answer}" is not an email; continuing without one — the approving admin's account will own this device.`);
|
|
500
|
+
return undefined;
|
|
501
|
+
}
|
|
502
|
+
return answer;
|
|
503
|
+
}
|
|
429
504
|
async function runOnboard(command, io) {
|
|
430
505
|
let install = null;
|
|
431
506
|
let pair = null;
|
|
@@ -437,6 +512,10 @@ async function runOnboard(command, io) {
|
|
|
437
512
|
writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}`);
|
|
438
513
|
writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
|
|
439
514
|
}
|
|
515
|
+
let claimedOwnerEmail = command.claimedOwnerEmail;
|
|
516
|
+
if (!claimedOwnerEmail && !command.json && isInteractiveStdin(io)) {
|
|
517
|
+
claimedOwnerEmail = await promptOnboardEmail(io);
|
|
518
|
+
}
|
|
440
519
|
install = await installLocalCollector({
|
|
441
520
|
homeDir: command.homeDir,
|
|
442
521
|
repoRoot: command.repoRoot,
|
|
@@ -453,7 +532,7 @@ async function runOnboard(command, io) {
|
|
|
453
532
|
branch: command.branch,
|
|
454
533
|
});
|
|
455
534
|
const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
|
|
456
|
-
const canReuseInstalledSession = canReuseOnboardSession(installedSession,
|
|
535
|
+
const canReuseInstalledSession = canReuseOnboardSession(installedSession, claimedOwnerEmail, command.dashboardUrl);
|
|
457
536
|
if (installedStatus.session_state === "valid" && canReuseInstalledSession) {
|
|
458
537
|
if (!command.json) {
|
|
459
538
|
writeLine(io.stdout, "2/5 Existing valid device session found; pairing skipped.");
|
|
@@ -469,7 +548,7 @@ async function runOnboard(command, io) {
|
|
|
469
548
|
pair = await pairLocalCollector({
|
|
470
549
|
homeDir: command.homeDir,
|
|
471
550
|
dashboardUrl: command.dashboardUrl,
|
|
472
|
-
claimedOwnerEmail
|
|
551
|
+
claimedOwnerEmail,
|
|
473
552
|
deviceName: command.deviceName,
|
|
474
553
|
pollIntervalMs: command.pollIntervalMs,
|
|
475
554
|
timeoutMs: command.timeoutMs,
|
|
@@ -554,8 +633,7 @@ async function runOnboard(command, io) {
|
|
|
554
633
|
writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
|
|
555
634
|
writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
|
|
556
635
|
writeLine(io.stdout, rawEvidenceSyncLine(sync));
|
|
557
|
-
|
|
558
|
-
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
636
|
+
writeAgentSessionSummary(io, run.summary);
|
|
559
637
|
writeLine(io.stdout, "5/5 Status ready.");
|
|
560
638
|
writeLine(io.stdout, `Upload state: ${status.upload_state}`);
|
|
561
639
|
writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
|
|
@@ -616,21 +694,68 @@ async function readOnboardSessionReuseCandidate(homeDir) {
|
|
|
616
694
|
function normalizeUrlForComparison(value) {
|
|
617
695
|
return value ? normalizeUrl(value) : null;
|
|
618
696
|
}
|
|
697
|
+
const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
|
|
698
|
+
const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
|
|
699
|
+
const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
619
700
|
/**
|
|
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
|
-
*
|
|
701
|
+
* Shared dual-source sync orchestration for single-repo and parent-folder
|
|
702
|
+
* modes. Codex AND Claude Code sessions are scanned and attributed once across
|
|
703
|
+
* every discovered worktree; each worktree syncs with only its own attributed
|
|
704
|
+
* transcripts (codex + claude main + sidecars), and the
|
|
705
|
+
* ambiguous/unattributed/skipped remainder is reported with reason labels and a
|
|
706
|
+
* `source` discriminator instead of being duplicated into every repo or
|
|
707
|
+
* silently dropped. The session row's upload state maps ONLY from the main-file
|
|
708
|
+
* outcome (D3); sidecar outcomes aggregate into CLI counts.
|
|
625
709
|
*/
|
|
626
710
|
async function runAttributedWorktreeSync(options) {
|
|
627
711
|
const now = new Date();
|
|
628
712
|
const homeDir = options.homeDir ?? os.homedir();
|
|
629
|
-
const
|
|
713
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
714
|
+
const claudeEnabled = await isClaudeCollectionEnabled(paths);
|
|
715
|
+
const codexAttribution = await scanAndAttributeCodexSessions({
|
|
630
716
|
sessionsDir: path.join(homeDir, ".codex", "sessions"),
|
|
631
717
|
worktrees: options.worktrees,
|
|
632
718
|
now,
|
|
633
719
|
});
|
|
720
|
+
// First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
|
|
721
|
+
// days so the first sync captures retroactive history instead of only 24h.
|
|
722
|
+
const claudeCursorExists = await fileExists(path.join(paths.cursors_dir, CLAUDE_CURSOR_FILENAME));
|
|
723
|
+
const firstRunBackfill = claudeEnabled && !claudeCursorExists;
|
|
724
|
+
const claudeAttribution = claudeEnabled
|
|
725
|
+
? await scanAndAttributeClaudeSessions({
|
|
726
|
+
projectsDir: path.join(homeDir, ".claude", "projects"),
|
|
727
|
+
worktrees: options.worktrees,
|
|
728
|
+
now,
|
|
729
|
+
sinceMinutes: firstRunBackfill
|
|
730
|
+
? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
|
|
731
|
+
: undefined,
|
|
732
|
+
})
|
|
733
|
+
: emptyClaudeScan();
|
|
734
|
+
// Read the Claude cursor up front: damping decisions need prior upload state.
|
|
735
|
+
const claudeCursorBefore = claudeEnabled
|
|
736
|
+
? await readRawEvidenceCursor(paths, {
|
|
737
|
+
filename: CLAUDE_CURSOR_FILENAME,
|
|
738
|
+
}).catch(() => emptyRawEvidenceCursorState())
|
|
739
|
+
: emptyRawEvidenceCursorState();
|
|
740
|
+
// sessionId -> prior durable pointer for damped sessions (drives the
|
|
741
|
+
// growth_damped count + the skip_main decision).
|
|
742
|
+
const dampedClaudePointers = new Map();
|
|
743
|
+
// sessionId -> prior durable pointer for EVERY already-durable Claude session
|
|
744
|
+
// (superset of damped). A session that was durable before but had no fresh
|
|
745
|
+
// upload this sync (damped, spooled, budget-deferred) reports reused_existing
|
|
746
|
+
// with this pointer instead of not_uploaded, so the store row never flips.
|
|
747
|
+
const claudePriorDurablePointers = new Map();
|
|
748
|
+
for (const [sessionId, entry] of Object.entries(claudeCursorBefore.sessions)) {
|
|
749
|
+
if (entry.uploaded_object_key) {
|
|
750
|
+
claudePriorDurablePointers.set(sessionId, entry.uploaded_object_key);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// One shared budget for the whole sync (D7b is per-sync): a parent-folder
|
|
754
|
+
// sync over many worktrees honors a single byte/object cap rather than N×.
|
|
755
|
+
const rawEvidenceBudget = {
|
|
756
|
+
remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
|
|
757
|
+
remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
|
|
758
|
+
};
|
|
634
759
|
const outcomes = [];
|
|
635
760
|
let ok = true;
|
|
636
761
|
for (const worktree of options.worktrees) {
|
|
@@ -645,11 +770,32 @@ async function runAttributedWorktreeSync(options) {
|
|
|
645
770
|
sessionId: options.sessionId,
|
|
646
771
|
});
|
|
647
772
|
}
|
|
773
|
+
const claudeSessionFiles = claudeAttribution.results
|
|
774
|
+
.filter((result) => result.state === "attributed" &&
|
|
775
|
+
result.worktree?.worktree_fingerprint ===
|
|
776
|
+
worktree.worktree_fingerprint)
|
|
777
|
+
.map((result) => {
|
|
778
|
+
const damped = !result.main_file_oversized &&
|
|
779
|
+
shouldDampClaudeMain(result, claudeCursorBefore, now);
|
|
780
|
+
if (damped) {
|
|
781
|
+
dampedClaudePointers.set(result.claude_session_id, claudeCursorBefore.sessions[result.claude_session_id]
|
|
782
|
+
?.uploaded_object_key ?? null);
|
|
783
|
+
}
|
|
784
|
+
return {
|
|
785
|
+
local_path: result.file_path,
|
|
786
|
+
claude_session_id: result.claude_session_id,
|
|
787
|
+
main_file_oversized: result.main_file_oversized,
|
|
788
|
+
skip_main: damped,
|
|
789
|
+
sidecar_files: result.sidecar_files
|
|
790
|
+
.filter((sidecar) => !sidecar.skipped_reason)
|
|
791
|
+
.map((sidecar) => ({ local_path: sidecar.local_path })),
|
|
792
|
+
};
|
|
793
|
+
});
|
|
648
794
|
const syncOptions = {
|
|
649
795
|
homeDir: options.homeDir,
|
|
650
796
|
repoRoot: worktree.repo_root,
|
|
651
797
|
dashboardUrl: options.dashboardUrl,
|
|
652
|
-
codexSessionFiles:
|
|
798
|
+
codexSessionFiles: codexAttribution.results
|
|
653
799
|
.filter((result) => result.state === "attributed" &&
|
|
654
800
|
result.worktree?.worktree_fingerprint ===
|
|
655
801
|
worktree.worktree_fingerprint)
|
|
@@ -657,6 +803,8 @@ async function runAttributedWorktreeSync(options) {
|
|
|
657
803
|
local_path: result.file_path,
|
|
658
804
|
codex_session_id: result.codex_session_id,
|
|
659
805
|
})),
|
|
806
|
+
claudeSessionFiles,
|
|
807
|
+
rawEvidenceBudget,
|
|
660
808
|
fetch: options.fetchImpl,
|
|
661
809
|
};
|
|
662
810
|
let sync;
|
|
@@ -684,39 +832,54 @@ async function runAttributedWorktreeSync(options) {
|
|
|
684
832
|
ok = ok && sync.status === "uploaded";
|
|
685
833
|
outcomes.push({ worktree, context, sync });
|
|
686
834
|
}
|
|
687
|
-
const sessions =
|
|
835
|
+
const sessions = buildAgentSessionReport({
|
|
836
|
+
codexResults: codexAttribution.results,
|
|
837
|
+
claudeResults: claudeAttribution.results,
|
|
838
|
+
outcomes,
|
|
839
|
+
now,
|
|
840
|
+
claudePriorDurablePointers,
|
|
841
|
+
});
|
|
688
842
|
// The sessions cursor is an optimization; a broken local state dir must not
|
|
689
843
|
// turn already-completed syncs into a CLI crash.
|
|
690
|
-
let
|
|
844
|
+
let codexStaleCount = 0;
|
|
845
|
+
let claudeStaleCount = 0;
|
|
691
846
|
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);
|
|
847
|
+
const codexCursor = await readRawEvidenceCursor(paths);
|
|
848
|
+
codexStaleCount = recordSourceObservations({
|
|
849
|
+
cursor: codexCursor,
|
|
850
|
+
results: codexAttribution.results,
|
|
851
|
+
sessions,
|
|
852
|
+
source: "codex",
|
|
853
|
+
sessionIdOf: (result) => result.codex_session_id,
|
|
854
|
+
now,
|
|
855
|
+
});
|
|
856
|
+
codexCursor.updated_at = now.toISOString();
|
|
857
|
+
await writeRawEvidenceCursor(paths, codexCursor);
|
|
716
858
|
}
|
|
717
859
|
catch {
|
|
718
860
|
// Best-effort: stale counts read 0 and observations re-record next sync.
|
|
719
861
|
}
|
|
862
|
+
if (claudeEnabled) {
|
|
863
|
+
try {
|
|
864
|
+
claudeStaleCount = recordSourceObservations({
|
|
865
|
+
cursor: claudeCursorBefore,
|
|
866
|
+
results: claudeAttribution.results,
|
|
867
|
+
sessions,
|
|
868
|
+
source: "claude_code",
|
|
869
|
+
sessionIdOf: (result) => result.claude_session_id,
|
|
870
|
+
now,
|
|
871
|
+
priorCursor: claudeCursorBefore,
|
|
872
|
+
});
|
|
873
|
+
claudeCursorBefore.updated_at = now.toISOString();
|
|
874
|
+
await writeRawEvidenceCursor(paths, claudeCursorBefore, {
|
|
875
|
+
filename: CLAUDE_CURSOR_FILENAME,
|
|
876
|
+
sessionsOnly: true,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
// Best-effort: a broken Claude cursor must not fail the sync.
|
|
881
|
+
}
|
|
882
|
+
}
|
|
720
883
|
const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
|
|
721
884
|
const report = firstUploaded
|
|
722
885
|
? await postCodexSessionReport({
|
|
@@ -731,21 +894,17 @@ async function runAttributedWorktreeSync(options) {
|
|
|
731
894
|
posted: false,
|
|
732
895
|
reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
|
|
733
896
|
};
|
|
734
|
-
|
|
735
|
-
|
|
897
|
+
const summary = buildAgentSessionSummary({
|
|
898
|
+
codexAttribution,
|
|
899
|
+
claudeAttribution,
|
|
736
900
|
outcomes,
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
stale: staleSessionCount,
|
|
745
|
-
report_posted: report.posted,
|
|
746
|
-
report_reason: report.reason,
|
|
747
|
-
},
|
|
748
|
-
};
|
|
901
|
+
codexStaleCount,
|
|
902
|
+
claudeStaleCount,
|
|
903
|
+
firstRunBackfill,
|
|
904
|
+
growthDamped: dampedClaudePointers.size,
|
|
905
|
+
report,
|
|
906
|
+
});
|
|
907
|
+
return { ok, outcomes, codexAttribution, claudeAttribution, summary };
|
|
749
908
|
}
|
|
750
909
|
const ATTRIBUTION_STATE_RANK = {
|
|
751
910
|
attributed: 3,
|
|
@@ -753,38 +912,100 @@ const ATTRIBUTION_STATE_RANK = {
|
|
|
753
912
|
unattributed: 1,
|
|
754
913
|
skipped: 0,
|
|
755
914
|
};
|
|
756
|
-
function
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
915
|
+
function normalizeCodexResult(result) {
|
|
916
|
+
return {
|
|
917
|
+
source: "codex",
|
|
918
|
+
session_id: result.codex_session_id,
|
|
919
|
+
state: result.state,
|
|
920
|
+
reason: result.reason,
|
|
921
|
+
signals: result.signals,
|
|
922
|
+
attribution_score: result.attribution_score,
|
|
923
|
+
path_score: result.path_score,
|
|
924
|
+
content_hash_sha256: result.content_hash_sha256,
|
|
925
|
+
byte_size: result.byte_size,
|
|
926
|
+
session_file_mtime: result.session_file_mtime,
|
|
927
|
+
session_file_mtime_ms: result.session_file_mtime_ms,
|
|
928
|
+
worktree: result.worktree,
|
|
929
|
+
cwd_basename: result.cwd_basename,
|
|
930
|
+
cwd_hash: result.cwd_hash,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
function normalizeClaudeResult(result) {
|
|
934
|
+
return {
|
|
935
|
+
source: "claude_code",
|
|
936
|
+
session_id: result.claude_session_id,
|
|
937
|
+
state: result.state,
|
|
938
|
+
reason: result.reason,
|
|
939
|
+
signals: result.signals,
|
|
940
|
+
attribution_score: result.attribution_score,
|
|
941
|
+
path_score: result.path_score,
|
|
942
|
+
content_hash_sha256: result.content_hash_sha256,
|
|
943
|
+
byte_size: result.byte_size,
|
|
944
|
+
session_file_mtime: result.session_file_mtime,
|
|
945
|
+
session_file_mtime_ms: result.session_file_mtime_ms,
|
|
946
|
+
worktree: result.worktree,
|
|
947
|
+
cwd_basename: result.cwd_basename,
|
|
948
|
+
cwd_hash: result.cwd_hash,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Generalizes the per-session report across sources. Dedupe is per
|
|
953
|
+
* `(source, session_id)` so a Codex session and a Claude session that happen to
|
|
954
|
+
* share an id are never collapsed. Upload state maps ONLY from the main-file
|
|
955
|
+
* outcome (kind `codex_jsonl` / `claude_jsonl`); sidecar outcomes never set a
|
|
956
|
+
* session's upload state (D3). Damped Claude sessions report `reused_existing`
|
|
957
|
+
* carrying their prior durable pointer.
|
|
958
|
+
*/
|
|
959
|
+
function buildAgentSessionReport(options) {
|
|
960
|
+
const normalized = [
|
|
961
|
+
...options.codexResults.map(normalizeCodexResult),
|
|
962
|
+
...options.claudeResults.map(normalizeClaudeResult),
|
|
963
|
+
];
|
|
964
|
+
const bestByKey = new Map();
|
|
965
|
+
for (const result of normalized) {
|
|
966
|
+
const key = `${result.source}:${result.session_id}`;
|
|
967
|
+
const existing = bestByKey.get(key);
|
|
763
968
|
if (!existing ||
|
|
764
969
|
(ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
|
|
765
970
|
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
|
|
766
971
|
((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
|
|
767
972
|
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
|
|
768
973
|
result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
|
|
769
|
-
|
|
974
|
+
bestByKey.set(key, result);
|
|
770
975
|
}
|
|
771
976
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
977
|
+
// Main-file outcomes only (D3): a sidecar making it must never mark a session
|
|
978
|
+
// uploaded when the main did not.
|
|
979
|
+
const uploadByKey = new Map();
|
|
980
|
+
for (const outcome of options.outcomes) {
|
|
775
981
|
if (outcome.sync.status !== "uploaded")
|
|
776
982
|
continue;
|
|
777
983
|
for (const upload of outcome.sync.raw_evidence_outcomes) {
|
|
778
|
-
if (upload.codex_session_id
|
|
779
|
-
|
|
780
|
-
|
|
984
|
+
if (!upload.codex_session_id || !upload.raw_evidence_pointer_id)
|
|
985
|
+
continue;
|
|
986
|
+
const source = upload.kind === "claude_jsonl"
|
|
987
|
+
? "claude_code"
|
|
988
|
+
: upload.kind === "codex_jsonl"
|
|
989
|
+
? "codex"
|
|
990
|
+
: null;
|
|
991
|
+
if (!source)
|
|
992
|
+
continue; // sidecars and other kinds do not set session state
|
|
993
|
+
uploadByKey.set(`${source}:${upload.codex_session_id}`, upload);
|
|
781
994
|
}
|
|
782
995
|
}
|
|
783
|
-
return
|
|
784
|
-
const
|
|
996
|
+
return [...bestByKey.values()].map((result) => {
|
|
997
|
+
const key = `${result.source}:${result.session_id}`;
|
|
998
|
+
const upload = uploadByKey.get(key);
|
|
999
|
+
// A previously-durable Claude session with no fresh main upload this sync
|
|
1000
|
+
// (damped / spooled / budget-deferred) reports reused_existing + its prior
|
|
1001
|
+
// pointer rather than not_uploaded, so the store row never flips.
|
|
1002
|
+
const priorDurablePointer = result.source === "claude_code"
|
|
1003
|
+
? (options.claudePriorDurablePointers.get(result.session_id) ?? null)
|
|
1004
|
+
: null;
|
|
785
1005
|
return {
|
|
786
|
-
codex_session_id: result.
|
|
787
|
-
|
|
1006
|
+
codex_session_id: result.session_id,
|
|
1007
|
+
source: result.source,
|
|
1008
|
+
observed_at: options.now.toISOString(),
|
|
788
1009
|
attribution_state: result.state,
|
|
789
1010
|
attribution_reason: result.reason,
|
|
790
1011
|
attribution_score: result.attribution_score,
|
|
@@ -810,23 +1031,215 @@ function buildCodexSessionReport(results, outcomes, now) {
|
|
|
810
1031
|
raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
|
|
811
1032
|
upload_state: upload.upload_state,
|
|
812
1033
|
}
|
|
813
|
-
:
|
|
814
|
-
? {
|
|
815
|
-
|
|
1034
|
+
: priorDurablePointer
|
|
1035
|
+
? {
|
|
1036
|
+
raw_evidence_pointer_id: priorDurablePointer,
|
|
1037
|
+
upload_state: "reused_existing",
|
|
1038
|
+
}
|
|
1039
|
+
: result.state === "attributed"
|
|
1040
|
+
? { upload_state: "not_uploaded" }
|
|
1041
|
+
: {}),
|
|
816
1042
|
};
|
|
817
1043
|
});
|
|
818
1044
|
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Records per-source session observations into its cursor and returns the stale
|
|
1047
|
+
* count. Damped/reused Claude sessions carry forward their prior upload
|
|
1048
|
+
* timestamp + byte size so the 6h damping window keeps counting from the real
|
|
1049
|
+
* last upload (otherwise a slowly-growing file would never re-upload — D21).
|
|
1050
|
+
*/
|
|
1051
|
+
function recordSourceObservations(options) {
|
|
1052
|
+
const seen = new Set(options.results.map((result) => options.sessionIdOf(result)));
|
|
1053
|
+
const stale = countStaleSessions(options.cursor, seen);
|
|
1054
|
+
for (const result of options.results) {
|
|
1055
|
+
const sessionId = options.sessionIdOf(result);
|
|
1056
|
+
const reported = options.sessions.find((session) => session.source === options.source &&
|
|
1057
|
+
session.codex_session_id === sessionId);
|
|
1058
|
+
const uploadedThisSync = reported?.upload_state === "uploaded";
|
|
1059
|
+
const durableThisSync = reported?.upload_state === "uploaded" ||
|
|
1060
|
+
reported?.upload_state === "reused_existing";
|
|
1061
|
+
const prior = options.priorCursor?.sessions[sessionId];
|
|
1062
|
+
// D21 / no-flip-flop: a sync that is spooled (offline), budget-deferred, or
|
|
1063
|
+
// upload-failed for a session that was ALREADY durable must NOT wipe the
|
|
1064
|
+
// prior durable state — otherwise damping is forfeited forever and the
|
|
1065
|
+
// store row oscillates uploaded -> not_uploaded hourly. Carry the prior
|
|
1066
|
+
// durable pointer/timestamp/size forward unless we durably uploaded anew.
|
|
1067
|
+
const uploadedObjectKey = durableThisSync
|
|
1068
|
+
? (reported?.raw_evidence_pointer_id ?? prior?.uploaded_object_key ?? null)
|
|
1069
|
+
: (prior?.uploaded_object_key ?? null);
|
|
1070
|
+
const uploadedAt = uploadedThisSync
|
|
1071
|
+
? options.now.toISOString()
|
|
1072
|
+
: (prior?.uploaded_at ?? (durableThisSync ? options.now.toISOString() : null));
|
|
1073
|
+
const uploadedByteSize = uploadedThisSync
|
|
1074
|
+
? result.byte_size
|
|
1075
|
+
: (prior?.uploaded_byte_size ??
|
|
1076
|
+
(durableThisSync ? result.byte_size : null));
|
|
1077
|
+
const entry = {
|
|
1078
|
+
file_hash_sha256: result.content_hash_sha256,
|
|
1079
|
+
file_mtime_ms: result.session_file_mtime_ms,
|
|
1080
|
+
byte_size: result.byte_size,
|
|
1081
|
+
// Durable byte offset reflects how many bytes are durable remotely (the
|
|
1082
|
+
// last uploaded size), not the current file size.
|
|
1083
|
+
byte_offset: uploadedByteSize ?? 0,
|
|
1084
|
+
state: result.state,
|
|
1085
|
+
reason: result.reason,
|
|
1086
|
+
worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
|
|
1087
|
+
uploaded_object_key: uploadedObjectKey,
|
|
1088
|
+
uploaded_at: uploadedAt,
|
|
1089
|
+
uploaded_byte_size: uploadedByteSize,
|
|
1090
|
+
last_seen_at: options.now.toISOString(),
|
|
1091
|
+
};
|
|
1092
|
+
recordSessionObservation(options.cursor, sessionId, entry);
|
|
1093
|
+
}
|
|
1094
|
+
return stale;
|
|
1095
|
+
}
|
|
1096
|
+
function shouldDampClaudeMain(result, cursor, now) {
|
|
1097
|
+
const entry = cursor.sessions[result.claude_session_id];
|
|
1098
|
+
if (!entry ||
|
|
1099
|
+
!entry.uploaded_object_key ||
|
|
1100
|
+
!entry.uploaded_at ||
|
|
1101
|
+
entry.uploaded_byte_size == null) {
|
|
1102
|
+
return false;
|
|
1103
|
+
}
|
|
1104
|
+
const grew = result.byte_size > entry.uploaded_byte_size;
|
|
1105
|
+
if (!grew)
|
|
1106
|
+
return false; // unchanged content reuses via the object cursor
|
|
1107
|
+
const growth = result.byte_size - entry.uploaded_byte_size;
|
|
1108
|
+
const ageMs = now.getTime() - Date.parse(entry.uploaded_at);
|
|
1109
|
+
return (growth <= CLAUDE_DAMP_GROWTH_BYTES &&
|
|
1110
|
+
Number.isFinite(ageMs) &&
|
|
1111
|
+
ageMs <= CLAUDE_DAMP_MAX_AGE_MS);
|
|
1112
|
+
}
|
|
1113
|
+
function buildAgentSessionSummary(options) {
|
|
1114
|
+
const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
|
|
1115
|
+
const attributedClaude = options.claudeAttribution.results.filter((result) => result.state === "attributed");
|
|
1116
|
+
const sidecarsCollected = attributedClaude.reduce((total, result) => total +
|
|
1117
|
+
result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
|
|
1118
|
+
const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
|
|
1119
|
+
result.sidecar_files.filter((sidecar) => sidecar.skipped_reason).length, 0);
|
|
1120
|
+
const codex = {
|
|
1121
|
+
scanned: options.codexAttribution.scanned_file_count,
|
|
1122
|
+
attributed: options.codexAttribution.counts.attributed,
|
|
1123
|
+
ambiguous: options.codexAttribution.counts.ambiguous,
|
|
1124
|
+
unattributed: options.codexAttribution.counts.unattributed,
|
|
1125
|
+
skipped: options.codexAttribution.counts.skipped,
|
|
1126
|
+
stale: options.codexStaleCount,
|
|
1127
|
+
};
|
|
1128
|
+
const claude = {
|
|
1129
|
+
scanned: options.claudeAttribution.scanned_session_count,
|
|
1130
|
+
attributed: options.claudeAttribution.counts.attributed,
|
|
1131
|
+
ambiguous: options.claudeAttribution.counts.ambiguous,
|
|
1132
|
+
unattributed: options.claudeAttribution.counts.unattributed,
|
|
1133
|
+
skipped: options.claudeAttribution.counts.skipped,
|
|
1134
|
+
stale: options.claudeStaleCount,
|
|
1135
|
+
sidecars_collected: sidecarsCollected,
|
|
1136
|
+
sidecars_uploaded: sidecarOutcomes.filter((upload) => upload.upload_state === "uploaded" ||
|
|
1137
|
+
upload.upload_state === "reused_existing").length,
|
|
1138
|
+
sidecars_skipped: sidecarsSkipped,
|
|
1139
|
+
sidecars_capped: options.claudeAttribution.counts.sidecars_capped,
|
|
1140
|
+
sidecars_failed: sidecarOutcomes.filter((upload) => upload.upload_state === "upload_failed").length,
|
|
1141
|
+
mains_oversized: options.claudeAttribution.counts.mains_oversized,
|
|
1142
|
+
oversized_lines_skipped: options.claudeAttribution.counts.oversized_lines_skipped,
|
|
1143
|
+
project_dirs_skipped: options.claudeAttribution.project_dirs_skipped,
|
|
1144
|
+
sessions_schema_drift: options.claudeAttribution.counts.sessions_schema_drift,
|
|
1145
|
+
growth_damped: options.growthDamped,
|
|
1146
|
+
first_run_backfill: options.firstRunBackfill,
|
|
1147
|
+
};
|
|
1148
|
+
return {
|
|
1149
|
+
scanned: codex.scanned,
|
|
1150
|
+
attributed: codex.attributed,
|
|
1151
|
+
ambiguous: codex.ambiguous,
|
|
1152
|
+
unattributed: codex.unattributed,
|
|
1153
|
+
skipped: codex.skipped,
|
|
1154
|
+
stale: codex.stale,
|
|
1155
|
+
report_posted: options.report.posted,
|
|
1156
|
+
report_reason: options.report.reason,
|
|
1157
|
+
codex,
|
|
1158
|
+
claude,
|
|
1159
|
+
files_deferred_byte_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_byte_budget, 0),
|
|
1160
|
+
files_deferred_object_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_object_budget, 0),
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
function emptyClaudeScan() {
|
|
1164
|
+
return {
|
|
1165
|
+
results: [],
|
|
1166
|
+
scanned_session_count: 0,
|
|
1167
|
+
project_dirs_skipped: 0,
|
|
1168
|
+
counts: {
|
|
1169
|
+
attributed: 0,
|
|
1170
|
+
ambiguous: 0,
|
|
1171
|
+
unattributed: 0,
|
|
1172
|
+
skipped: 0,
|
|
1173
|
+
mains_oversized: 0,
|
|
1174
|
+
oversized_lines_skipped: 0,
|
|
1175
|
+
sessions_schema_drift: 0,
|
|
1176
|
+
sidecars_capped: 0,
|
|
1177
|
+
},
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
async function isClaudeCollectionEnabled(paths) {
|
|
1181
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
1182
|
+
return config?.collect_claude_jsonl !== false;
|
|
1183
|
+
}
|
|
1184
|
+
async function fileExists(filePath) {
|
|
1185
|
+
const { stat } = await import("node:fs/promises");
|
|
1186
|
+
return stat(filePath).then(() => true, () => false);
|
|
1187
|
+
}
|
|
819
1188
|
function shortSha(value) {
|
|
820
1189
|
return value ? value.slice(0, 12) : "unknown";
|
|
821
1190
|
}
|
|
822
|
-
function
|
|
823
|
-
return
|
|
1191
|
+
function sourceFunnelLine(label, counts) {
|
|
1192
|
+
return `${label} sessions: attributed ${counts.attributed}, ambiguous ${counts.ambiguous}, unattributed ${counts.unattributed}, skipped ${counts.skipped}, stale ${counts.stale}`;
|
|
824
1193
|
}
|
|
825
1194
|
function attributionReportLine(summary) {
|
|
826
1195
|
return summary.report_posted
|
|
827
1196
|
? "Attribution report: recorded"
|
|
828
1197
|
: `Attribution report: skipped (${summary.report_reason})`;
|
|
829
1198
|
}
|
|
1199
|
+
/**
|
|
1200
|
+
* One funnel line per source, an anomaly diagnostics line only when something
|
|
1201
|
+
* is nonzero (clean syncs stay one line per source), and the report line.
|
|
1202
|
+
* Counts only — project-dir slugs encode full local paths and never print
|
|
1203
|
+
* (B.4 §5).
|
|
1204
|
+
*/
|
|
1205
|
+
function writeAgentSessionSummary(io, summary) {
|
|
1206
|
+
writeLine(io.stdout, sourceFunnelLine("Codex", summary.codex));
|
|
1207
|
+
writeLine(io.stdout, sourceFunnelLine("Claude", summary.claude));
|
|
1208
|
+
const claudeDiagnostics = claudeDiagnosticsLine(summary);
|
|
1209
|
+
if (claudeDiagnostics)
|
|
1210
|
+
writeLine(io.stdout, claudeDiagnostics);
|
|
1211
|
+
writeLine(io.stdout, attributionReportLine(summary));
|
|
1212
|
+
}
|
|
1213
|
+
function claudeDiagnosticsLine(summary) {
|
|
1214
|
+
// Anomaly-only (D34): sidecars_collected/uploaded are normal-operation
|
|
1215
|
+
// counters and must NOT trigger this line, or a healthy orchestrated sync
|
|
1216
|
+
// prints it 48×/day in launchd logs. Clean syncs stay one line per source.
|
|
1217
|
+
const claude = summary.claude;
|
|
1218
|
+
const parts = [];
|
|
1219
|
+
if (claude.sidecars_skipped)
|
|
1220
|
+
parts.push(`sidecars_skipped ${claude.sidecars_skipped}`);
|
|
1221
|
+
if (claude.sidecars_capped)
|
|
1222
|
+
parts.push(`sidecars_capped ${claude.sidecars_capped}`);
|
|
1223
|
+
if (claude.sidecars_failed)
|
|
1224
|
+
parts.push(`sidecars_failed ${claude.sidecars_failed}`);
|
|
1225
|
+
if (claude.mains_oversized)
|
|
1226
|
+
parts.push(`mains_oversized ${claude.mains_oversized}`);
|
|
1227
|
+
if (claude.oversized_lines_skipped)
|
|
1228
|
+
parts.push(`oversized_lines_skipped ${claude.oversized_lines_skipped}`);
|
|
1229
|
+
if (claude.project_dirs_skipped)
|
|
1230
|
+
parts.push(`project_dirs_skipped ${claude.project_dirs_skipped}`);
|
|
1231
|
+
if (claude.sessions_schema_drift)
|
|
1232
|
+
parts.push(`schema_drift ${claude.sessions_schema_drift}`);
|
|
1233
|
+
if (claude.growth_damped)
|
|
1234
|
+
parts.push(`growth_damped ${claude.growth_damped}`);
|
|
1235
|
+
if (claude.first_run_backfill)
|
|
1236
|
+
parts.push("first_run_backfill");
|
|
1237
|
+
if (summary.files_deferred_byte_budget)
|
|
1238
|
+
parts.push(`deferred_byte_budget ${summary.files_deferred_byte_budget}`);
|
|
1239
|
+
if (summary.files_deferred_object_budget)
|
|
1240
|
+
parts.push(`deferred_object_budget ${summary.files_deferred_object_budget}`);
|
|
1241
|
+
return parts.length > 0 ? `Claude diagnostics: ${parts.join(", ")}` : null;
|
|
1242
|
+
}
|
|
830
1243
|
function rawEvidenceSyncLine(sync) {
|
|
831
1244
|
const failures = sync.raw_evidence_failure_reasons.length > 0
|
|
832
1245
|
? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
|
|
@@ -865,7 +1278,7 @@ async function runMultiRepoOnboard(command, io, worktrees) {
|
|
|
865
1278
|
worktrees,
|
|
866
1279
|
fetchImpl: io.fetch,
|
|
867
1280
|
});
|
|
868
|
-
const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run
|
|
1281
|
+
const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
|
|
869
1282
|
if (!command.json) {
|
|
870
1283
|
for (const [index, outcome] of run.outcomes.entries()) {
|
|
871
1284
|
const row = results[index];
|
|
@@ -876,8 +1289,7 @@ async function runMultiRepoOnboard(command, io, worktrees) {
|
|
|
876
1289
|
}
|
|
877
1290
|
writeLine(io.stdout, "4/5 Parent worktree sync complete.");
|
|
878
1291
|
writeLine(io.stdout, `Uploaded: ${results.filter((row) => row["upload_status"] === "uploaded").length}/${results.length}`);
|
|
879
|
-
|
|
880
|
-
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
1292
|
+
writeAgentSessionSummary(io, run.summary);
|
|
881
1293
|
writeLine(io.stdout, "5/5 Status ready.");
|
|
882
1294
|
writeLine(run.ok ? io.stdout : io.stderr, run.ok
|
|
883
1295
|
? "PASS: Cockpit collector is ready for harvest."
|
|
@@ -886,10 +1298,13 @@ async function runMultiRepoOnboard(command, io, worktrees) {
|
|
|
886
1298
|
}
|
|
887
1299
|
return { ok: run.ok, results, codex_sessions: run.summary };
|
|
888
1300
|
}
|
|
889
|
-
function worktreeSyncRow(outcome,
|
|
1301
|
+
function worktreeSyncRow(outcome, run) {
|
|
890
1302
|
const { worktree, context, sync } = outcome;
|
|
891
|
-
const
|
|
892
|
-
result.worktree?.worktree_fingerprint === worktree.worktree_fingerprint
|
|
1303
|
+
const matchesWorktree = (result) => result.state === "attributed" &&
|
|
1304
|
+
result.worktree?.worktree_fingerprint === worktree.worktree_fingerprint;
|
|
1305
|
+
const codexSessionCount = run.codexAttribution.results.filter(matchesWorktree).length;
|
|
1306
|
+
const claudeSessionCount = run.claudeAttribution.results.filter(matchesWorktree).length;
|
|
1307
|
+
const attributedSessionCount = codexSessionCount + claudeSessionCount;
|
|
893
1308
|
return {
|
|
894
1309
|
repo_label: context?.repo_label ?? worktree.repo_label,
|
|
895
1310
|
repo_fingerprint: context?.repo_fingerprint ?? worktree.repo_fingerprint,
|
|
@@ -906,6 +1321,8 @@ function worktreeSyncRow(outcome, attribution) {
|
|
|
906
1321
|
raw_evidence_failed_count: sync.raw_evidence_failed_count,
|
|
907
1322
|
raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
|
|
908
1323
|
attributed_session_count: attributedSessionCount,
|
|
1324
|
+
codex_session_count: codexSessionCount,
|
|
1325
|
+
claude_session_count: claudeSessionCount,
|
|
909
1326
|
cursor_tracked_object_count: sync.cursor_tracked_object_count,
|
|
910
1327
|
failure_reason: sync.status === "spooled" ? sync.failure_reason : null,
|
|
911
1328
|
};
|
|
@@ -1042,6 +1459,26 @@ async function runStart(command, io) {
|
|
|
1042
1459
|
return 0;
|
|
1043
1460
|
}
|
|
1044
1461
|
async function runSync(command, io) {
|
|
1462
|
+
// Single-flight: a launchd timer and a manual sync must not interleave the
|
|
1463
|
+
// cursor read-modify-write. A blocked invocation exits cleanly (B.4 §7).
|
|
1464
|
+
const lock = await acquireSyncLock(getCollectorRuntimePaths(command.homeDir));
|
|
1465
|
+
if (!lock.acquired) {
|
|
1466
|
+
if (command.json) {
|
|
1467
|
+
writeLine(io.stdout, JSON.stringify({ status: "sync_already_running", held_since: lock.held_since }, null, 2));
|
|
1468
|
+
}
|
|
1469
|
+
else {
|
|
1470
|
+
writeLine(io.stdout, "Cockpit sync already running; skipping this run.");
|
|
1471
|
+
}
|
|
1472
|
+
return 0;
|
|
1473
|
+
}
|
|
1474
|
+
try {
|
|
1475
|
+
return await runSyncLocked(command, io);
|
|
1476
|
+
}
|
|
1477
|
+
finally {
|
|
1478
|
+
await lock.handle.release();
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
async function runSyncLocked(command, io) {
|
|
1045
1482
|
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
1046
1483
|
const run = await runAttributedWorktreeSync({
|
|
1047
1484
|
homeDir: command.homeDir,
|
|
@@ -1051,7 +1488,7 @@ async function runSync(command, io) {
|
|
|
1051
1488
|
fetchImpl: io.fetch,
|
|
1052
1489
|
});
|
|
1053
1490
|
if (worktrees.length > 1) {
|
|
1054
|
-
const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run
|
|
1491
|
+
const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
|
|
1055
1492
|
if (command.json) {
|
|
1056
1493
|
writeLine(io.stdout, JSON.stringify({
|
|
1057
1494
|
mode: "multi_repo",
|
|
@@ -1069,8 +1506,7 @@ async function runSync(command, io) {
|
|
|
1069
1506
|
const failureSuffix = sync.status === "spooled" ? ` reason:${sync.failure_reason}` : "";
|
|
1070
1507
|
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
1508
|
}
|
|
1072
|
-
|
|
1073
|
-
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
1509
|
+
writeAgentSessionSummary(io, run.summary);
|
|
1074
1510
|
return run.ok ? 0 : 1;
|
|
1075
1511
|
}
|
|
1076
1512
|
const result = run.outcomes[0]?.sync;
|
|
@@ -1090,8 +1526,7 @@ async function runSync(command, io) {
|
|
|
1090
1526
|
writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
|
|
1091
1527
|
writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
|
|
1092
1528
|
writeLine(io.stdout, rawEvidenceSyncLine(result));
|
|
1093
|
-
|
|
1094
|
-
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
1529
|
+
writeAgentSessionSummary(io, run.summary);
|
|
1095
1530
|
writeLine(io.stdout, cursorStatusLine(result));
|
|
1096
1531
|
return 0;
|
|
1097
1532
|
}
|
|
@@ -1145,6 +1580,100 @@ async function runStatus(command, io) {
|
|
|
1145
1580
|
function displayTicketId(ticketId) {
|
|
1146
1581
|
return ticketId ?? "general ambient";
|
|
1147
1582
|
}
|
|
1583
|
+
/**
|
|
1584
|
+
* Read-only diagnostic: re-runs attribution (no upload, no cursor writes) and
|
|
1585
|
+
* prints why each session is or is not collected. Per-session reasons otherwise
|
|
1586
|
+
* live only in a service-role table with no UI, so this is the operator's local
|
|
1587
|
+
* answer to "why is session X missing?" (B.4 §6). Counts and labels only — the
|
|
1588
|
+
* project-dir slug encodes a local path and is never printed.
|
|
1589
|
+
*/
|
|
1590
|
+
async function runSessions(command, io) {
|
|
1591
|
+
const now = new Date();
|
|
1592
|
+
const homeDir = command.homeDir ?? os.homedir();
|
|
1593
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
1594
|
+
const wantCodex = command.source !== "claude";
|
|
1595
|
+
const wantClaude = command.source !== "codex";
|
|
1596
|
+
const codex = wantCodex
|
|
1597
|
+
? await scanAndAttributeCodexSessions({
|
|
1598
|
+
sessionsDir: path.join(homeDir, ".codex", "sessions"),
|
|
1599
|
+
worktrees,
|
|
1600
|
+
now,
|
|
1601
|
+
})
|
|
1602
|
+
: null;
|
|
1603
|
+
const claude = wantClaude
|
|
1604
|
+
? await scanAndAttributeClaudeSessions({
|
|
1605
|
+
projectsDir: path.join(homeDir, ".claude", "projects"),
|
|
1606
|
+
worktrees,
|
|
1607
|
+
now,
|
|
1608
|
+
})
|
|
1609
|
+
: null;
|
|
1610
|
+
// Safe output contract (B.4 §6): id, state, reason, scores, signals, sidecar
|
|
1611
|
+
// skip reasons, plus the repo_label basename. Branch is intentionally omitted
|
|
1612
|
+
// — branch names can carry operator-authored task/customer text.
|
|
1613
|
+
const codexRows = (codex?.results ?? []).map((result) => ({
|
|
1614
|
+
source: "codex",
|
|
1615
|
+
session_id: result.codex_session_id,
|
|
1616
|
+
state: result.state,
|
|
1617
|
+
reason: result.reason,
|
|
1618
|
+
attribution_score: result.attribution_score,
|
|
1619
|
+
path_score: result.path_score,
|
|
1620
|
+
signals: result.signals,
|
|
1621
|
+
repo_label: result.worktree?.repo_label ?? null,
|
|
1622
|
+
}));
|
|
1623
|
+
const claudeRows = (claude?.results ?? []).map((result) => ({
|
|
1624
|
+
source: "claude_code",
|
|
1625
|
+
session_id: result.claude_session_id,
|
|
1626
|
+
state: result.state,
|
|
1627
|
+
reason: result.reason,
|
|
1628
|
+
attribution_score: result.attribution_score,
|
|
1629
|
+
path_score: result.path_score,
|
|
1630
|
+
signals: result.signals,
|
|
1631
|
+
repo_label: result.worktree?.repo_label ?? null,
|
|
1632
|
+
main_file_oversized: result.main_file_oversized,
|
|
1633
|
+
sidecar_skips: result.sidecar_files
|
|
1634
|
+
.filter((sidecar) => sidecar.skipped_reason)
|
|
1635
|
+
.map((sidecar) => ({
|
|
1636
|
+
file_name: sidecar.file_name,
|
|
1637
|
+
reason: sidecar.skipped_reason,
|
|
1638
|
+
})),
|
|
1639
|
+
}));
|
|
1640
|
+
if (command.json) {
|
|
1641
|
+
writeLine(io.stdout, JSON.stringify({
|
|
1642
|
+
...(codex
|
|
1643
|
+
? { codex: { counts: codex.counts, sessions: codexRows } }
|
|
1644
|
+
: {}),
|
|
1645
|
+
...(claude
|
|
1646
|
+
? {
|
|
1647
|
+
claude: {
|
|
1648
|
+
counts: claude.counts,
|
|
1649
|
+
project_dirs_skipped: claude.project_dirs_skipped,
|
|
1650
|
+
sessions: claudeRows,
|
|
1651
|
+
},
|
|
1652
|
+
}
|
|
1653
|
+
: {}),
|
|
1654
|
+
}, null, 2));
|
|
1655
|
+
return 0;
|
|
1656
|
+
}
|
|
1657
|
+
writeLine(io.stdout, "Cockpit sessions (read-only attribution)");
|
|
1658
|
+
for (const row of codexRows) {
|
|
1659
|
+
writeLine(io.stdout, sessionRowLine(row));
|
|
1660
|
+
}
|
|
1661
|
+
for (const row of claudeRows) {
|
|
1662
|
+
writeLine(io.stdout, sessionRowLine(row));
|
|
1663
|
+
for (const sidecar of row.sidecar_skips) {
|
|
1664
|
+
writeLine(io.stdout, ` sidecar ${sidecar.file_name}: ${sidecar.reason}`);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
if (codexRows.length === 0 && claudeRows.length === 0) {
|
|
1668
|
+
writeLine(io.stdout, "No sessions observed in the scan window.");
|
|
1669
|
+
}
|
|
1670
|
+
return 0;
|
|
1671
|
+
}
|
|
1672
|
+
function sessionRowLine(row) {
|
|
1673
|
+
const repo = row.repo_label ? ` repo:${row.repo_label}` : "";
|
|
1674
|
+
const signals = row.signals.length > 0 ? ` signals:${row.signals.join("|")}` : "";
|
|
1675
|
+
return `- [${row.source}] ${row.session_id} ${row.state} (${row.reason}) score:${row.attribution_score} path:${row.path_score}${repo}${signals}`;
|
|
1676
|
+
}
|
|
1148
1677
|
async function runServe(command, io) {
|
|
1149
1678
|
const server = createCollectorServer(command);
|
|
1150
1679
|
await new Promise((resolve) => {
|