@bli-cockpit/cli 0.1.5 → 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/README.md +5 -2
- 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 +647 -104
- 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
|
@@ -2,9 +2,13 @@ import os from "node:os";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createCollectorServer } from "../server.js";
|
|
4
4
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
|
|
5
|
-
import { postCodexSessionReport, syncLocalAmbientEnvelope, } from "../upload.js";
|
|
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
|
}
|
|
@@ -61,14 +68,15 @@ export function localCommandHelp(command) {
|
|
|
61
68
|
if (command)
|
|
62
69
|
return localSubcommandHelp(command);
|
|
63
70
|
return [
|
|
64
|
-
" cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--branch <name>] [--json]",
|
|
71
|
+
" cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
65
72
|
" cockpit install [--dashboard-url <url>] [--repo <path>] [--json]",
|
|
66
73
|
" cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
|
|
67
74
|
" cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
|
|
68
75
|
" cockpit logout",
|
|
69
|
-
" cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--json]",
|
|
70
|
-
" cockpit sync [--repo <path>] [--dashboard-url <url>] [--json]",
|
|
71
|
-
" cockpit status [--repo <path>] [--json]",
|
|
76
|
+
" cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
77
|
+
" cockpit sync [--repo <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
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,14 @@ 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.",
|
|
140
|
+
"Newly discovered repos get a general ambient work context automatically.",
|
|
141
|
+
"Discovery scans 3 folder levels and up to 50 repos by default; tune with",
|
|
142
|
+
"--max-depth and --max-repos.",
|
|
130
143
|
],
|
|
131
144
|
],
|
|
132
145
|
[
|
|
@@ -137,6 +150,17 @@ function localSubcommandHelp(command) {
|
|
|
137
150
|
"Prints install, pairing, active work, upload, and retry state.",
|
|
138
151
|
],
|
|
139
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
|
+
],
|
|
140
164
|
[
|
|
141
165
|
"serve",
|
|
142
166
|
[
|
|
@@ -172,6 +196,8 @@ function parseLocalArgs(argv) {
|
|
|
172
196
|
return parseSyncArgs(argv.slice(1));
|
|
173
197
|
case "status":
|
|
174
198
|
return parseStatusArgs(argv.slice(1));
|
|
199
|
+
case "sessions":
|
|
200
|
+
return parseSessionsArgs(argv.slice(1));
|
|
175
201
|
case "serve":
|
|
176
202
|
return parseServeArgs(argv.slice(1));
|
|
177
203
|
default:
|
|
@@ -191,6 +217,8 @@ function parseOnboardArgs(args) {
|
|
|
191
217
|
"--json",
|
|
192
218
|
"--poll-interval-ms",
|
|
193
219
|
"--timeout-ms",
|
|
220
|
+
"--max-depth",
|
|
221
|
+
"--max-repos",
|
|
194
222
|
],
|
|
195
223
|
valueFlags: [
|
|
196
224
|
"--home",
|
|
@@ -202,6 +230,8 @@ function parseOnboardArgs(args) {
|
|
|
202
230
|
"--branch",
|
|
203
231
|
"--poll-interval-ms",
|
|
204
232
|
"--timeout-ms",
|
|
233
|
+
"--max-depth",
|
|
234
|
+
"--max-repos",
|
|
205
235
|
],
|
|
206
236
|
});
|
|
207
237
|
assertNoPositionals(values.positionals, "onboard");
|
|
@@ -217,6 +247,8 @@ function parseOnboardArgs(args) {
|
|
|
217
247
|
json: values.booleans.has("--json"),
|
|
218
248
|
pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
|
|
219
249
|
timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
|
|
250
|
+
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
251
|
+
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
220
252
|
};
|
|
221
253
|
}
|
|
222
254
|
function parseInstallArgs(args) {
|
|
@@ -294,6 +326,8 @@ function parseStartArgs(args) {
|
|
|
294
326
|
"--operator-id",
|
|
295
327
|
"--session-id",
|
|
296
328
|
"--json",
|
|
329
|
+
"--max-depth",
|
|
330
|
+
"--max-repos",
|
|
297
331
|
],
|
|
298
332
|
valueFlags: [
|
|
299
333
|
"--home",
|
|
@@ -302,6 +336,8 @@ function parseStartArgs(args) {
|
|
|
302
336
|
"--ticket",
|
|
303
337
|
"--operator-id",
|
|
304
338
|
"--session-id",
|
|
339
|
+
"--max-depth",
|
|
340
|
+
"--max-repos",
|
|
305
341
|
],
|
|
306
342
|
});
|
|
307
343
|
assertNoPositionals(values.positionals, "start");
|
|
@@ -314,12 +350,14 @@ function parseStartArgs(args) {
|
|
|
314
350
|
operatorId: optionalNonEmpty(values.flags.get("--operator-id")),
|
|
315
351
|
sessionId: optionalNonEmpty(values.flags.get("--session-id")),
|
|
316
352
|
json: values.booleans.has("--json"),
|
|
353
|
+
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
354
|
+
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
317
355
|
};
|
|
318
356
|
}
|
|
319
357
|
function parseSyncArgs(args) {
|
|
320
358
|
const values = parseNamedArgs(args, {
|
|
321
|
-
allowedFlags: ["--home", "--repo", "--dashboard-url", "--json"],
|
|
322
|
-
valueFlags: ["--home", "--repo", "--dashboard-url"],
|
|
359
|
+
allowedFlags: ["--home", "--repo", "--dashboard-url", "--json", "--max-depth", "--max-repos"],
|
|
360
|
+
valueFlags: ["--home", "--repo", "--dashboard-url", "--max-depth", "--max-repos"],
|
|
323
361
|
});
|
|
324
362
|
assertNoPositionals(values.positionals, "sync");
|
|
325
363
|
return {
|
|
@@ -328,12 +366,14 @@ function parseSyncArgs(args) {
|
|
|
328
366
|
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
329
367
|
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
330
368
|
json: values.booleans.has("--json"),
|
|
369
|
+
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
370
|
+
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
331
371
|
};
|
|
332
372
|
}
|
|
333
373
|
function parseStatusArgs(args) {
|
|
334
374
|
const values = parseNamedArgs(args, {
|
|
335
|
-
allowedFlags: ["--home", "--repo", "--json"],
|
|
336
|
-
valueFlags: ["--home", "--repo"],
|
|
375
|
+
allowedFlags: ["--home", "--repo", "--json", "--max-depth", "--max-repos"],
|
|
376
|
+
valueFlags: ["--home", "--repo", "--max-depth", "--max-repos"],
|
|
337
377
|
});
|
|
338
378
|
assertNoPositionals(values.positionals, "status");
|
|
339
379
|
return {
|
|
@@ -341,6 +381,28 @@ function parseStatusArgs(args) {
|
|
|
341
381
|
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
342
382
|
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
343
383
|
json: values.booleans.has("--json"),
|
|
384
|
+
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
385
|
+
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
386
|
+
};
|
|
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"),
|
|
344
406
|
};
|
|
345
407
|
}
|
|
346
408
|
function parseServeArgs(args) {
|
|
@@ -465,7 +527,7 @@ async function runOnboard(command, io) {
|
|
|
465
527
|
writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
|
|
466
528
|
}
|
|
467
529
|
}
|
|
468
|
-
const worktrees = await discoverCommandWorktrees(command.repoRoot);
|
|
530
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
469
531
|
if (worktrees.length > 1) {
|
|
470
532
|
const multi = await runMultiRepoOnboard(command, io, worktrees);
|
|
471
533
|
if (command.json) {
|
|
@@ -535,8 +597,7 @@ async function runOnboard(command, io) {
|
|
|
535
597
|
writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
|
|
536
598
|
writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
|
|
537
599
|
writeLine(io.stdout, rawEvidenceSyncLine(sync));
|
|
538
|
-
|
|
539
|
-
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
600
|
+
writeAgentSessionSummary(io, run.summary);
|
|
540
601
|
writeLine(io.stdout, "5/5 Status ready.");
|
|
541
602
|
writeLine(io.stdout, `Upload state: ${status.upload_state}`);
|
|
542
603
|
writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
|
|
@@ -597,21 +658,68 @@ async function readOnboardSessionReuseCandidate(homeDir) {
|
|
|
597
658
|
function normalizeUrlForComparison(value) {
|
|
598
659
|
return value ? normalizeUrl(value) : null;
|
|
599
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;
|
|
600
664
|
/**
|
|
601
|
-
* Shared sync orchestration for single-repo and parent-folder
|
|
602
|
-
* sessions are scanned and attributed once across
|
|
603
|
-
* each worktree syncs with only its own attributed
|
|
604
|
-
*
|
|
605
|
-
*
|
|
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.
|
|
606
673
|
*/
|
|
607
674
|
async function runAttributedWorktreeSync(options) {
|
|
608
675
|
const now = new Date();
|
|
609
676
|
const homeDir = options.homeDir ?? os.homedir();
|
|
610
|
-
const
|
|
677
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
678
|
+
const claudeEnabled = await isClaudeCollectionEnabled(paths);
|
|
679
|
+
const codexAttribution = await scanAndAttributeCodexSessions({
|
|
611
680
|
sessionsDir: path.join(homeDir, ".codex", "sessions"),
|
|
612
681
|
worktrees: options.worktrees,
|
|
613
682
|
now,
|
|
614
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
|
+
};
|
|
615
723
|
const outcomes = [];
|
|
616
724
|
let ok = true;
|
|
617
725
|
for (const worktree of options.worktrees) {
|
|
@@ -626,11 +734,32 @@ async function runAttributedWorktreeSync(options) {
|
|
|
626
734
|
sessionId: options.sessionId,
|
|
627
735
|
});
|
|
628
736
|
}
|
|
629
|
-
const
|
|
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
|
+
});
|
|
758
|
+
const syncOptions = {
|
|
630
759
|
homeDir: options.homeDir,
|
|
631
760
|
repoRoot: worktree.repo_root,
|
|
632
761
|
dashboardUrl: options.dashboardUrl,
|
|
633
|
-
codexSessionFiles:
|
|
762
|
+
codexSessionFiles: codexAttribution.results
|
|
634
763
|
.filter((result) => result.state === "attributed" &&
|
|
635
764
|
result.worktree?.worktree_fingerprint ===
|
|
636
765
|
worktree.worktree_fingerprint)
|
|
@@ -638,44 +767,83 @@ async function runAttributedWorktreeSync(options) {
|
|
|
638
767
|
local_path: result.file_path,
|
|
639
768
|
codex_session_id: result.codex_session_id,
|
|
640
769
|
})),
|
|
770
|
+
claudeSessionFiles,
|
|
771
|
+
rawEvidenceBudget,
|
|
641
772
|
fetch: options.fetchImpl,
|
|
642
|
-
}
|
|
773
|
+
};
|
|
774
|
+
let sync;
|
|
775
|
+
try {
|
|
776
|
+
sync = await syncLocalAmbientEnvelope(syncOptions);
|
|
777
|
+
}
|
|
778
|
+
catch (error) {
|
|
779
|
+
// A newly cloned repo has no work context yet. Capture is permissive
|
|
780
|
+
// and ticket binding comes later, so start general ambient capture for
|
|
781
|
+
// it instead of blocking every other repo's sync until someone runs
|
|
782
|
+
// `cockpit start` by hand.
|
|
783
|
+
if (error instanceof LocalUploadBlockedError &&
|
|
784
|
+
error.blocker === "missing_context") {
|
|
785
|
+
context = await startLocalWorkContext({
|
|
786
|
+
homeDir: options.homeDir,
|
|
787
|
+
repoRoot: worktree.repo_root,
|
|
788
|
+
branch: options.branch,
|
|
789
|
+
});
|
|
790
|
+
sync = await syncLocalAmbientEnvelope(syncOptions);
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
throw error;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
643
796
|
ok = ok && sync.status === "uploaded";
|
|
644
797
|
outcomes.push({ worktree, context, sync });
|
|
645
798
|
}
|
|
646
|
-
const sessions =
|
|
799
|
+
const sessions = buildAgentSessionReport({
|
|
800
|
+
codexResults: codexAttribution.results,
|
|
801
|
+
claudeResults: claudeAttribution.results,
|
|
802
|
+
outcomes,
|
|
803
|
+
now,
|
|
804
|
+
claudePriorDurablePointers,
|
|
805
|
+
});
|
|
647
806
|
// The sessions cursor is an optimization; a broken local state dir must not
|
|
648
807
|
// turn already-completed syncs into a CLI crash.
|
|
649
|
-
let
|
|
808
|
+
let codexStaleCount = 0;
|
|
809
|
+
let claudeStaleCount = 0;
|
|
650
810
|
try {
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
byte_size: result.byte_size,
|
|
663
|
-
byte_offset: sessionDurable ? result.byte_size : 0,
|
|
664
|
-
state: result.state,
|
|
665
|
-
reason: result.reason,
|
|
666
|
-
worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
|
|
667
|
-
uploaded_object_key: sessionDurable
|
|
668
|
-
? (reported?.raw_evidence_pointer_id ?? null)
|
|
669
|
-
: null,
|
|
670
|
-
last_seen_at: now.toISOString(),
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
|
-
cursor.updated_at = now.toISOString();
|
|
674
|
-
await writeRawEvidenceCursor(paths, cursor);
|
|
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);
|
|
675
822
|
}
|
|
676
823
|
catch {
|
|
677
824
|
// Best-effort: stale counts read 0 and observations re-record next sync.
|
|
678
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
|
+
}
|
|
679
847
|
const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
|
|
680
848
|
const report = firstUploaded
|
|
681
849
|
? await postCodexSessionReport({
|
|
@@ -690,21 +858,17 @@ async function runAttributedWorktreeSync(options) {
|
|
|
690
858
|
posted: false,
|
|
691
859
|
reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
|
|
692
860
|
};
|
|
693
|
-
|
|
694
|
-
|
|
861
|
+
const summary = buildAgentSessionSummary({
|
|
862
|
+
codexAttribution,
|
|
863
|
+
claudeAttribution,
|
|
695
864
|
outcomes,
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
stale: staleSessionCount,
|
|
704
|
-
report_posted: report.posted,
|
|
705
|
-
report_reason: report.reason,
|
|
706
|
-
},
|
|
707
|
-
};
|
|
865
|
+
codexStaleCount,
|
|
866
|
+
claudeStaleCount,
|
|
867
|
+
firstRunBackfill,
|
|
868
|
+
growthDamped: dampedClaudePointers.size,
|
|
869
|
+
report,
|
|
870
|
+
});
|
|
871
|
+
return { ok, outcomes, codexAttribution, claudeAttribution, summary };
|
|
708
872
|
}
|
|
709
873
|
const ATTRIBUTION_STATE_RANK = {
|
|
710
874
|
attributed: 3,
|
|
@@ -712,38 +876,100 @@ const ATTRIBUTION_STATE_RANK = {
|
|
|
712
876
|
unattributed: 1,
|
|
713
877
|
skipped: 0,
|
|
714
878
|
};
|
|
715
|
-
function
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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);
|
|
722
932
|
if (!existing ||
|
|
723
933
|
(ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
|
|
724
934
|
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
|
|
725
935
|
((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
|
|
726
936
|
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
|
|
727
937
|
result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
|
|
728
|
-
|
|
938
|
+
bestByKey.set(key, result);
|
|
729
939
|
}
|
|
730
940
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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) {
|
|
734
945
|
if (outcome.sync.status !== "uploaded")
|
|
735
946
|
continue;
|
|
736
947
|
for (const upload of outcome.sync.raw_evidence_outcomes) {
|
|
737
|
-
if (upload.codex_session_id
|
|
738
|
-
|
|
739
|
-
|
|
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);
|
|
740
958
|
}
|
|
741
959
|
}
|
|
742
|
-
return
|
|
743
|
-
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;
|
|
744
969
|
return {
|
|
745
|
-
codex_session_id: result.
|
|
746
|
-
|
|
970
|
+
codex_session_id: result.session_id,
|
|
971
|
+
source: result.source,
|
|
972
|
+
observed_at: options.now.toISOString(),
|
|
747
973
|
attribution_state: result.state,
|
|
748
974
|
attribution_reason: result.reason,
|
|
749
975
|
attribution_score: result.attribution_score,
|
|
@@ -769,23 +995,215 @@ function buildCodexSessionReport(results, outcomes, now) {
|
|
|
769
995
|
raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
|
|
770
996
|
upload_state: upload.upload_state,
|
|
771
997
|
}
|
|
772
|
-
:
|
|
773
|
-
? {
|
|
774
|
-
|
|
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
|
+
: {}),
|
|
775
1006
|
};
|
|
776
1007
|
});
|
|
777
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
|
+
}
|
|
778
1152
|
function shortSha(value) {
|
|
779
1153
|
return value ? value.slice(0, 12) : "unknown";
|
|
780
1154
|
}
|
|
781
|
-
function
|
|
782
|
-
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}`;
|
|
783
1157
|
}
|
|
784
1158
|
function attributionReportLine(summary) {
|
|
785
1159
|
return summary.report_posted
|
|
786
1160
|
? "Attribution report: recorded"
|
|
787
1161
|
: `Attribution report: skipped (${summary.report_reason})`;
|
|
788
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
|
+
}
|
|
789
1207
|
function rawEvidenceSyncLine(sync) {
|
|
790
1208
|
const failures = sync.raw_evidence_failure_reasons.length > 0
|
|
791
1209
|
? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
|
|
@@ -795,11 +1213,20 @@ function rawEvidenceSyncLine(sync) {
|
|
|
795
1213
|
function cursorStatusLine(sync) {
|
|
796
1214
|
return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
|
|
797
1215
|
}
|
|
798
|
-
|
|
799
|
-
|
|
1216
|
+
const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
|
|
1217
|
+
const DEFAULT_DISCOVERY_MAX_REPOS = 50;
|
|
1218
|
+
async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
|
|
1219
|
+
const maxWorktrees = discovery.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS;
|
|
1220
|
+
const worktrees = await discoverGitWorktrees(repoRoot ?? process.cwd(), {
|
|
1221
|
+
maxDepth: discovery.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
|
|
1222
|
+
maxWorktrees,
|
|
1223
|
+
});
|
|
800
1224
|
if (worktrees.length === 0) {
|
|
801
1225
|
throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
|
|
802
1226
|
}
|
|
1227
|
+
if (io && worktrees.length >= maxWorktrees) {
|
|
1228
|
+
writeLine(io.stderr, `Warning: repo discovery hit the cap of ${maxWorktrees}; additional repos may be excluded. Raise --max-repos if this folder really holds more.`);
|
|
1229
|
+
}
|
|
803
1230
|
return worktrees;
|
|
804
1231
|
}
|
|
805
1232
|
async function runMultiRepoOnboard(command, io, worktrees) {
|
|
@@ -815,7 +1242,7 @@ async function runMultiRepoOnboard(command, io, worktrees) {
|
|
|
815
1242
|
worktrees,
|
|
816
1243
|
fetchImpl: io.fetch,
|
|
817
1244
|
});
|
|
818
|
-
const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run
|
|
1245
|
+
const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
|
|
819
1246
|
if (!command.json) {
|
|
820
1247
|
for (const [index, outcome] of run.outcomes.entries()) {
|
|
821
1248
|
const row = results[index];
|
|
@@ -826,8 +1253,7 @@ async function runMultiRepoOnboard(command, io, worktrees) {
|
|
|
826
1253
|
}
|
|
827
1254
|
writeLine(io.stdout, "4/5 Parent worktree sync complete.");
|
|
828
1255
|
writeLine(io.stdout, `Uploaded: ${results.filter((row) => row["upload_status"] === "uploaded").length}/${results.length}`);
|
|
829
|
-
|
|
830
|
-
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
1256
|
+
writeAgentSessionSummary(io, run.summary);
|
|
831
1257
|
writeLine(io.stdout, "5/5 Status ready.");
|
|
832
1258
|
writeLine(run.ok ? io.stdout : io.stderr, run.ok
|
|
833
1259
|
? "PASS: Cockpit collector is ready for harvest."
|
|
@@ -836,10 +1262,13 @@ async function runMultiRepoOnboard(command, io, worktrees) {
|
|
|
836
1262
|
}
|
|
837
1263
|
return { ok: run.ok, results, codex_sessions: run.summary };
|
|
838
1264
|
}
|
|
839
|
-
function worktreeSyncRow(outcome,
|
|
1265
|
+
function worktreeSyncRow(outcome, run) {
|
|
840
1266
|
const { worktree, context, sync } = outcome;
|
|
841
|
-
const
|
|
842
|
-
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;
|
|
843
1272
|
return {
|
|
844
1273
|
repo_label: context?.repo_label ?? worktree.repo_label,
|
|
845
1274
|
repo_fingerprint: context?.repo_fingerprint ?? worktree.repo_fingerprint,
|
|
@@ -856,6 +1285,8 @@ function worktreeSyncRow(outcome, attribution) {
|
|
|
856
1285
|
raw_evidence_failed_count: sync.raw_evidence_failed_count,
|
|
857
1286
|
raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
|
|
858
1287
|
attributed_session_count: attributedSessionCount,
|
|
1288
|
+
codex_session_count: codexSessionCount,
|
|
1289
|
+
claude_session_count: claudeSessionCount,
|
|
859
1290
|
cursor_tracked_object_count: sync.cursor_tracked_object_count,
|
|
860
1291
|
failure_reason: sync.status === "spooled" ? sync.failure_reason : null,
|
|
861
1292
|
};
|
|
@@ -959,7 +1390,7 @@ async function runLogout(command, io) {
|
|
|
959
1390
|
return 0;
|
|
960
1391
|
}
|
|
961
1392
|
async function runStart(command, io) {
|
|
962
|
-
const worktrees = await discoverCommandWorktrees(command.repoRoot);
|
|
1393
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
963
1394
|
if (worktrees.length > 1) {
|
|
964
1395
|
const contexts = await Promise.all(worktrees.map((worktree) => startLocalWorkContext({
|
|
965
1396
|
homeDir: command.homeDir,
|
|
@@ -992,7 +1423,27 @@ async function runStart(command, io) {
|
|
|
992
1423
|
return 0;
|
|
993
1424
|
}
|
|
994
1425
|
async function runSync(command, io) {
|
|
995
|
-
|
|
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) {
|
|
1446
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
996
1447
|
const run = await runAttributedWorktreeSync({
|
|
997
1448
|
homeDir: command.homeDir,
|
|
998
1449
|
dashboardUrl: command.dashboardUrl,
|
|
@@ -1001,7 +1452,7 @@ async function runSync(command, io) {
|
|
|
1001
1452
|
fetchImpl: io.fetch,
|
|
1002
1453
|
});
|
|
1003
1454
|
if (worktrees.length > 1) {
|
|
1004
|
-
const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run
|
|
1455
|
+
const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
|
|
1005
1456
|
if (command.json) {
|
|
1006
1457
|
writeLine(io.stdout, JSON.stringify({
|
|
1007
1458
|
mode: "multi_repo",
|
|
@@ -1019,8 +1470,7 @@ async function runSync(command, io) {
|
|
|
1019
1470
|
const failureSuffix = sync.status === "spooled" ? ` reason:${sync.failure_reason}` : "";
|
|
1020
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}`);
|
|
1021
1472
|
}
|
|
1022
|
-
|
|
1023
|
-
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
1473
|
+
writeAgentSessionSummary(io, run.summary);
|
|
1024
1474
|
return run.ok ? 0 : 1;
|
|
1025
1475
|
}
|
|
1026
1476
|
const result = run.outcomes[0]?.sync;
|
|
@@ -1040,8 +1490,7 @@ async function runSync(command, io) {
|
|
|
1040
1490
|
writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
|
|
1041
1491
|
writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
|
|
1042
1492
|
writeLine(io.stdout, rawEvidenceSyncLine(result));
|
|
1043
|
-
|
|
1044
|
-
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
1493
|
+
writeAgentSessionSummary(io, run.summary);
|
|
1045
1494
|
writeLine(io.stdout, cursorStatusLine(result));
|
|
1046
1495
|
return 0;
|
|
1047
1496
|
}
|
|
@@ -1052,7 +1501,7 @@ async function runSync(command, io) {
|
|
|
1052
1501
|
return 1;
|
|
1053
1502
|
}
|
|
1054
1503
|
async function runStatus(command, io) {
|
|
1055
|
-
const worktrees = await discoverCommandWorktrees(command.repoRoot);
|
|
1504
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
1056
1505
|
if (worktrees.length > 1) {
|
|
1057
1506
|
const statuses = await Promise.all(worktrees.map(async (worktree) => ({
|
|
1058
1507
|
...(await inspectLocalCollectorStatus({
|
|
@@ -1095,6 +1544,100 @@ async function runStatus(command, io) {
|
|
|
1095
1544
|
function displayTicketId(ticketId) {
|
|
1096
1545
|
return ticketId ?? "general ambient";
|
|
1097
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
|
+
}
|
|
1098
1641
|
async function runServe(command, io) {
|
|
1099
1642
|
const server = createCollectorServer(command);
|
|
1100
1643
|
await new Promise((resolve) => {
|