@bli-cockpit/cli 0.2.5 → 0.2.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 +19 -7
- package/dist/adapters/attribution-core.js +152 -31
- package/dist/adapters/claude-attribution.js +14 -7
- package/dist/adapters/codex-attribution.js +8 -2
- package/dist/adapters/raw-evidence.js +33 -12
- package/dist/autostart.js +460 -20
- package/dist/commands/backfill.js +582 -87
- package/dist/commands/doctor.js +72 -60
- package/dist/commands/local-args.js +19 -2
- package/dist/commands/local.js +401 -229
- package/dist/commands/public-root.js +6 -1
- package/dist/commands/session-sync.js +259 -54
- package/dist/cursors/backfill-cursor.js +169 -1
- package/dist/cursors/raw-evidence-cursor.js +16 -2
- package/dist/evidence-upload-client.js +139 -107
- package/dist/local-state.js +71 -5
- package/dist/onboarding-roots.js +42 -17
- package/dist/process-runner.js +103 -0
- package/dist/raw-evidence-attribution-policy.js +25 -0
- package/dist/repo-identity.js +142 -26
- package/dist/root-normalization.js +29 -16
- package/dist/spool/local-spool.js +335 -58
- package/dist/upload.js +309 -66
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Cockpit watches how you and your AI agents work so Edward can coach from evidenc
|
|
|
4
4
|
|
|
5
5
|
## 90-second speedrun
|
|
6
6
|
|
|
7
|
-
Run these three commands on the Mac that should be collected:
|
|
7
|
+
Run these three commands on the Mac or Windows PC that should be collected:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
$ npm install -g @bli-cockpit/cli@latest
|
|
@@ -12,7 +12,7 @@ added <packages> in <seconds>s
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
$ cockpit
|
|
15
|
+
$ cockpit do-everything
|
|
16
16
|
Cockpit harvest onboarding
|
|
17
17
|
Dashboard: https://bli-cockpit-dashboard.vercel.app
|
|
18
18
|
Ticket: general ambient
|
|
@@ -45,14 +45,22 @@ The OTP proves you own an approved BLI mailbox. The JWT is used once to register
|
|
|
45
45
|
|
|
46
46
|
## Every command, what it does, and why it's called that
|
|
47
47
|
|
|
48
|
-
On a blank Mac, `npm i -g @bli-cockpit/cli && cockpit do-everything` is enough to sign in, choose roots, and converge the machine. `onboard` remains the named setup subset, and the rest exist for recovery and maintenance.
|
|
48
|
+
On a blank Mac or Windows PC, `npm i -g @bli-cockpit/cli && cockpit do-everything` is enough to sign in, choose roots, and converge the machine. `onboard` remains the named setup subset, and the rest exist for recovery and maintenance.
|
|
49
|
+
|
|
50
|
+
The core collection contract is the same on Apple Silicon macOS and native
|
|
51
|
+
Windows: every eligible Codex and Claude session attributable to an approved
|
|
52
|
+
root is collected, redacted before ingest, and either uploaded with a durable
|
|
53
|
+
receipt or surfaced with an explicit ambiguity, skip, or retry reason. A green
|
|
54
|
+
machine must not hide silently missing sessions. This support claim remains
|
|
55
|
+
release-gated: the packed candidate must pass normal-user canaries on both host
|
|
56
|
+
families before its exact version is promoted to npm `latest`.
|
|
49
57
|
|
|
50
58
|
| Command | What it does | Why it exists / why this name |
|
|
51
59
|
|---|---|---|
|
|
52
|
-
| `cockpit onboard` | The
|
|
53
|
-
| `cockpit do-everything` / `cockpit fix` | Converges a Mac from blank or already-onboarded state: latest CLI, signed-in device token, saved roots, autostart, historical backfill, raw-evidence GC, and fresh sync. Interactive first runs prompt for email OTP and collection roots; headless/`--json` runs explain and exit instead of blocking. `--dry-run` previews without writing. | Edward can post one line and every intern machine should end green. `fix` is the alias people guess. |
|
|
60
|
+
| `cockpit onboard` | The named setup subset: signs you in (email code), registers this machine, starts capture, uploads once, completes all-history Codex and Claude backfill for saved approved roots, installs the 15-min background sync, and only then prints proof you're live. | You are boarding the crew. `do-everything` calls this flow when setup is missing; rerunning it directly is safe. Partial or failed backfill blocks readiness with an exact retry reason. |
|
|
61
|
+
| `cockpit do-everything` / `cockpit fix` | Converges a Mac or Windows PC from blank or already-onboarded state: latest CLI, signed-in device token, saved roots, autostart, historical backfill, raw-evidence GC, and fresh sync. Interactive first runs prompt for email OTP and collection roots; headless/`--json` runs explain and exit instead of blocking. `--dry-run` previews without writing. | Edward can post one line and every intern machine should end green. `fix` is the alias people guess. |
|
|
54
62
|
| `cockpit status` | Prints install / sign-in / capture / upload health in one screen. | The "is it working?" command. Run it whenever you're unsure. |
|
|
55
|
-
| `cockpit backfill --all` | Uploads your HISTORICAL Codex + Claude sessions (from before Cockpit existed on this
|
|
63
|
+
| `cockpit backfill --all` | Uploads your HISTORICAL Codex + Claude sessions (from before Cockpit existed on this machine). Discovery defaults to depth 3 and 50 repos; raise `--max-depth` or `--max-repos` when the returned retry command says a cap was reached. | One-time catch-up so your past work counts too. "Backfill" = fill in the back-catalog. |
|
|
56
64
|
| `cockpit sync` | Captures and uploads once, right now. This is what the background agent runs every 15 min — you almost never type it yourself. | Named for what it does: synchronize local session files up to the dashboard. |
|
|
57
65
|
| `cockpit analyze` | Runs a fresh sync, then queues batch analysis of your latest uploaded sessions. It returns immediately; status and results appear in My Work. | One command when you want newly finished work uploaded and judged now. |
|
|
58
66
|
| `cockpit start --ticket <id>` | Tags your CURRENT work with a Linear ticket so sessions attribute to it. `--clear-ticket` returns to general capture. | "Start (working on) X." Agents usually run this for you per the agent rules. |
|
|
@@ -61,7 +69,7 @@ On a blank Mac, `npm i -g @bli-cockpit/cli && cockpit do-everything` is enough t
|
|
|
61
69
|
| `cockpit update` / `cockpit upgrade` | Updates the CLI itself from npm, then reruns onboard. Two names, one command — aliases because half of people guess each. | Self-update; run when Edward announces a new version. |
|
|
62
70
|
| `cockpit install` | Writes local config only (step 1 of onboard, standalone). | Plumbing; exists so onboard's pieces are individually runnable. You'll likely never type it. |
|
|
63
71
|
| `cockpit sessions` | Read-only: lists your observed sessions and how each attributed. | Debugging "why isn't my session showing up?" without uploading anything. |
|
|
64
|
-
| `cockpit autostart install\|uninstall\|status` | Manages the macOS launchd agent behind the 15-min background sync. | Direct control of the background piece when you need to stop/inspect it. |
|
|
72
|
+
| `cockpit autostart install\|uninstall\|status` | Manages the macOS launchd agent or per-user Windows Task Scheduler task behind the 15-min background sync. | Direct control of the background piece when you need to stop/inspect it. |
|
|
65
73
|
| `cockpit agent-rules install\|uninstall\|status` | Manages the Cockpit ticket-binding text block inside `~/.codex/AGENTS.md` / `~/.claude/CLAUDE.md`. | It's literally "the rules your agents read." Passive markdown — never an executable hook. |
|
|
66
74
|
| `cockpit serve` | Runs a tiny local HTTP status server. | Niche: lets other tools ask the collector how it's doing. |
|
|
67
75
|
| `cockpit logout`, `cockpit release`, `cockpit admin *` | `release` publishes the CLI (maintainer, repo-gated); `admin` (bootstrap/invite) is absent from the public build entirely. | Maintainer surface — interns never touch these. |
|
|
@@ -94,6 +102,10 @@ cockpit onboard --no-auth
|
|
|
94
102
|
|
|
95
103
|
`cockpit update` installs the latest public CLI and reruns onboarding checks against saved roots. `cockpit upgrade` is the same command.
|
|
96
104
|
|
|
105
|
+
`cockpit --version`, `cockpit -V`, and `cockpit version` print the installed
|
|
106
|
+
public package version. Windows autostart details and verification steps are in
|
|
107
|
+
[`docs/runbooks/cockpit-windows-task-scheduler-sync.md`](../../docs/runbooks/cockpit-windows-task-scheduler-sync.md).
|
|
108
|
+
|
|
97
109
|
## Analyze latest work
|
|
98
110
|
|
|
99
111
|
```bash
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, stableWorktreeFingerprint, } from "../repo-identity.js";
|
|
4
5
|
/**
|
|
@@ -24,6 +25,12 @@ export const ATTRIBUTED_FALLBACK_MAX_SCORE = 0.39;
|
|
|
24
25
|
export const SESSION_FILE_UUID_PATTERN = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
|
|
25
26
|
export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
26
27
|
const originLabel = options.originLabel ?? "origin_url_match";
|
|
28
|
+
const collectionRoots = collectionRootComparisonAliases(options.collectionRoots ?? []);
|
|
29
|
+
const recordedPaths = [...signals.cwds, ...signals.workspaceRoots];
|
|
30
|
+
const approvedRecordedPaths = recordedPathsWithinCollectionRoots(recordedPaths, collectionRoots);
|
|
31
|
+
const scopedWorktrees = worktreesWithinCollectionScope(worktrees, collectionRoots);
|
|
32
|
+
const cwdComparisonPaths = pathComparisonAliases(signals.cwds);
|
|
33
|
+
const workspaceRootComparisonPaths = pathComparisonAliases(signals.workspaceRoots);
|
|
27
34
|
const hasAnySignal = signals.cwds.length > 0 ||
|
|
28
35
|
signals.workspaceRoots.length > 0 ||
|
|
29
36
|
signals.originUrls.length > 0 ||
|
|
@@ -38,9 +45,9 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
|
38
45
|
// nested package that is itself a worktree). Crediting every containing root
|
|
39
46
|
// leaves the session ambiguous forever. Instead, only the DEEPEST containing
|
|
40
47
|
// root earns the path score, which is also the most specific (correct) match.
|
|
41
|
-
const cwdWinners = deepestContainers(
|
|
42
|
-
const workspaceRootWinners = deepestContainers(
|
|
43
|
-
const scored =
|
|
48
|
+
const cwdWinners = deepestContainers(cwdComparisonPaths, scopedWorktrees);
|
|
49
|
+
const workspaceRootWinners = deepestContainers(workspaceRootComparisonPaths, scopedWorktrees);
|
|
50
|
+
const scored = scopedWorktrees.map((worktree) => {
|
|
44
51
|
const matched = [];
|
|
45
52
|
let score = 0;
|
|
46
53
|
let pathScore = 0;
|
|
@@ -73,21 +80,23 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
|
73
80
|
const best = scored[0];
|
|
74
81
|
const secondBestScore = scored[1]?.score ?? 0;
|
|
75
82
|
if (!best || best.score === 0) {
|
|
76
|
-
|
|
77
|
-
if (repoPathAbsentFromDisk(recordedPaths, options.collectionRoots ?? [])) {
|
|
83
|
+
if (approvedRecordedPaths.length > 0) {
|
|
78
84
|
const folderFallback = fallbackToFolderWorkspaceForContainerCwd({
|
|
79
85
|
signals,
|
|
80
|
-
worktrees,
|
|
86
|
+
worktrees: scopedWorktrees,
|
|
87
|
+
approvedRecordedPaths,
|
|
81
88
|
});
|
|
82
89
|
if (folderFallback)
|
|
83
90
|
return folderFallback;
|
|
84
91
|
const transcriptFallback = fallbackToTranscriptOriginForDeletedRepo({
|
|
85
92
|
originLabel,
|
|
86
93
|
signals,
|
|
94
|
+
collectionRoots,
|
|
95
|
+
pathExists: options.pathExists,
|
|
87
96
|
});
|
|
88
97
|
if (transcriptFallback)
|
|
89
98
|
return transcriptFallback;
|
|
90
|
-
return skipped(terminalReasonForRecordedPaths(
|
|
99
|
+
return skipped(terminalReasonForRecordedPaths(approvedRecordedPaths, options.pathExists));
|
|
91
100
|
}
|
|
92
101
|
const reason = signals.cwds.length > 0 || signals.workspaceRoots.length > 0
|
|
93
102
|
? "cwd_outside_scanned_worktrees"
|
|
@@ -109,11 +118,16 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
|
109
118
|
};
|
|
110
119
|
}
|
|
111
120
|
if (best.pathScore === 0) {
|
|
121
|
+
if (collectionRoots.length > 0 && approvedRecordedPaths.length === 0) {
|
|
122
|
+
return unattributed(recordedPaths.length > 0
|
|
123
|
+
? "cwd_outside_scanned_worktrees"
|
|
124
|
+
: "no_matching_worktree_signals");
|
|
125
|
+
}
|
|
112
126
|
const fallback = fallbackToKnownRepoPrimaryWorkContext({
|
|
113
127
|
originLabel,
|
|
114
128
|
scored,
|
|
115
129
|
signals,
|
|
116
|
-
worktrees,
|
|
130
|
+
worktrees: scopedWorktrees,
|
|
117
131
|
});
|
|
118
132
|
if (fallback)
|
|
119
133
|
return fallback;
|
|
@@ -157,10 +171,9 @@ function skipped(reason) {
|
|
|
157
171
|
* root.
|
|
158
172
|
*/
|
|
159
173
|
function fallbackToFolderWorkspaceForContainerCwd(options) {
|
|
160
|
-
const recordedPaths = [...options.signals.cwds, ...options.signals.workspaceRoots];
|
|
161
174
|
let bestFolder = null;
|
|
162
|
-
for (const recordedPath of
|
|
163
|
-
const resolvedPath =
|
|
175
|
+
for (const recordedPath of pathComparisonAliases(options.approvedRecordedPaths)) {
|
|
176
|
+
const resolvedPath = resolveAttributionPath(recordedPath);
|
|
164
177
|
const containedWorktrees = options.worktrees.filter((worktree) => isPathStrictlyWithin(worktree.repo_root, resolvedPath));
|
|
165
178
|
if (containedWorktrees.length === 0)
|
|
166
179
|
continue;
|
|
@@ -185,7 +198,7 @@ function fallbackToFolderWorkspaceForContainerCwd(options) {
|
|
|
185
198
|
worktree: primary,
|
|
186
199
|
};
|
|
187
200
|
}
|
|
188
|
-
const repoLabel =
|
|
201
|
+
const repoLabel = basenameForAttributionPath(bestFolder.resolvedPath);
|
|
189
202
|
return {
|
|
190
203
|
state: "attributed_fallback",
|
|
191
204
|
reason: "multi_repo_folder_workspace",
|
|
@@ -213,7 +226,7 @@ function fallbackToKnownRepoPrimaryWorkContext(options) {
|
|
|
213
226
|
return null;
|
|
214
227
|
const primaryByClone = originCandidates.filter((worktree) => worktree.worktree_is_primary);
|
|
215
228
|
const cloneCandidates = primaryByClone.length > 0 ? primaryByClone : originCandidates;
|
|
216
|
-
const cloneRoots = new Set(cloneCandidates.map((worktree) =>
|
|
229
|
+
const cloneRoots = new Set(cloneCandidates.map((worktree) => normalizedAttributionPathKey(worktree.repo_root)));
|
|
217
230
|
if (cloneRoots.size > 1) {
|
|
218
231
|
return {
|
|
219
232
|
state: "ambiguous",
|
|
@@ -262,10 +275,26 @@ function fallbackToTranscriptOriginForDeletedRepo(options) {
|
|
|
262
275
|
return null;
|
|
263
276
|
if (origins.length > 1)
|
|
264
277
|
return skipped("multiple_transcript_origins");
|
|
265
|
-
|
|
278
|
+
if (!options.pathExists)
|
|
279
|
+
return null;
|
|
280
|
+
// Prefer an explicitly recorded workspace root, but only after proving that
|
|
281
|
+
// exact selected path remains inside the operator-approved boundary. A
|
|
282
|
+
// different cwd being approved must never authorize an outside workspace
|
|
283
|
+
// root (or vice versa).
|
|
284
|
+
const recordedPath = [...options.signals.workspaceRoots, ...options.signals.cwds].find((value) => {
|
|
285
|
+
if (!options.collectionRoots.some((root) => isPathWithin(value, root))) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
return !options.pathExists?.(value);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}) ?? null;
|
|
266
295
|
if (!recordedPath)
|
|
267
296
|
return null;
|
|
268
|
-
const resolvedPath =
|
|
297
|
+
const resolvedPath = resolveAttributionPath(recordedPath);
|
|
269
298
|
const origin = origins[0] ?? "";
|
|
270
299
|
return {
|
|
271
300
|
state: "attributed_fallback",
|
|
@@ -281,7 +310,7 @@ function fallbackToTranscriptOriginForDeletedRepo(options) {
|
|
|
281
310
|
repo_origin_url: origin,
|
|
282
311
|
branch: options.signals.branches[0] ?? "unknown",
|
|
283
312
|
head_sha: options.signals.headShas[0] ?? null,
|
|
284
|
-
worktree_label:
|
|
313
|
+
worktree_label: basenameForAttributionPath(resolvedPath),
|
|
285
314
|
worktree_fingerprint: stableWorktreeFingerprint(resolvedPath),
|
|
286
315
|
worktree_is_primary: false,
|
|
287
316
|
},
|
|
@@ -318,7 +347,7 @@ function deepestContainers(signalValues, worktrees) {
|
|
|
318
347
|
for (const worktree of worktrees) {
|
|
319
348
|
if (!isPathWithin(value, worktree.repo_root))
|
|
320
349
|
continue;
|
|
321
|
-
const rootLength =
|
|
350
|
+
const rootLength = normalizedAttributionPathKey(worktree.repo_root).length;
|
|
322
351
|
if (rootLength > bestRootLength) {
|
|
323
352
|
best = worktree;
|
|
324
353
|
bestRootLength = rootLength;
|
|
@@ -330,30 +359,122 @@ function deepestContainers(signalValues, worktrees) {
|
|
|
330
359
|
return winners;
|
|
331
360
|
}
|
|
332
361
|
export function isPathWithin(candidate, root) {
|
|
333
|
-
|
|
334
|
-
const normalizedRoot = path.resolve(root);
|
|
335
|
-
return (normalizedCandidate === normalizedRoot ||
|
|
336
|
-
normalizedCandidate.startsWith(normalizedRoot + path.sep));
|
|
362
|
+
return pathContainment(candidate, root, false);
|
|
337
363
|
}
|
|
338
364
|
function isPathStrictlyWithin(candidate, root) {
|
|
339
|
-
|
|
340
|
-
const normalizedRoot = path.resolve(root);
|
|
341
|
-
return (normalizedCandidate !== normalizedRoot &&
|
|
342
|
-
normalizedCandidate.startsWith(normalizedRoot + path.sep));
|
|
365
|
+
return pathContainment(candidate, root, true);
|
|
343
366
|
}
|
|
344
|
-
function
|
|
367
|
+
function recordedPathsWithinCollectionRoots(paths, collectionRoots) {
|
|
345
368
|
if (paths.length === 0 || collectionRoots.length === 0)
|
|
346
|
-
return
|
|
347
|
-
|
|
348
|
-
|
|
369
|
+
return [];
|
|
370
|
+
return paths.filter((value) => collectionRoots.some((root) => isPathWithin(value, root)));
|
|
371
|
+
}
|
|
372
|
+
function worktreesWithinCollectionScope(worktrees, collectionRoots) {
|
|
373
|
+
if (collectionRoots.length === 0)
|
|
374
|
+
return worktrees;
|
|
375
|
+
const directlyApproved = new Set(worktrees
|
|
376
|
+
.filter((worktree) => collectionRoots.some((root) => isPathWithin(worktree.repo_root, root)))
|
|
377
|
+
.map((worktree) => worktree.worktree_fingerprint));
|
|
378
|
+
const approvedRepoFingerprints = new Set(worktrees
|
|
379
|
+
.filter((worktree) => directlyApproved.has(worktree.worktree_fingerprint))
|
|
380
|
+
.map((worktree) => worktree.repo_fingerprint));
|
|
381
|
+
return worktrees.filter((worktree) => directlyApproved.has(worktree.worktree_fingerprint) ||
|
|
382
|
+
(isCodexManagedWorktreePath(worktree.repo_root) &&
|
|
383
|
+
approvedRepoFingerprints.has(worktree.repo_fingerprint)));
|
|
384
|
+
}
|
|
385
|
+
function isCodexManagedWorktreePath(value) {
|
|
386
|
+
const pathApi = attributionPathApi(value);
|
|
387
|
+
const windowsPath = isWindowsAbsolutePath(value);
|
|
388
|
+
const parts = pathApi.resolve(value).split(pathApi.sep).filter(Boolean);
|
|
389
|
+
return parts.some((part, index) => {
|
|
390
|
+
const codex = windowsPath ? part.toLowerCase() : part;
|
|
391
|
+
const nextPart = parts[index + 1] ?? "";
|
|
392
|
+
const worktrees = windowsPath ? nextPart.toLowerCase() : nextPart;
|
|
393
|
+
return codex === ".codex" && worktrees === "worktrees";
|
|
394
|
+
});
|
|
349
395
|
}
|
|
350
396
|
function sortWorktreesForFolderFallback(worktrees) {
|
|
351
397
|
return [...worktrees].sort((a, b) => Number(b.worktree_is_primary) - Number(a.worktree_is_primary) ||
|
|
352
|
-
|
|
398
|
+
normalizedAttributionPathKey(a.repo_root).localeCompare(normalizedAttributionPathKey(b.repo_root)) ||
|
|
353
399
|
a.worktree_fingerprint.localeCompare(b.worktree_fingerprint));
|
|
354
400
|
}
|
|
355
401
|
function pathDepth(value) {
|
|
356
|
-
|
|
402
|
+
const pathApi = attributionPathApi(value);
|
|
403
|
+
return pathApi.resolve(value).split(pathApi.sep).filter(Boolean).length;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Transcript paths describe the host that produced the session, which may not
|
|
407
|
+
* be the host currently running tests or replaying evidence. Detect Windows
|
|
408
|
+
* drive/UNC paths from the values themselves so attribution keeps win32
|
|
409
|
+
* separator and case rules on macOS/Linux too.
|
|
410
|
+
*/
|
|
411
|
+
function pathContainment(candidate, root, strict) {
|
|
412
|
+
const candidateIsWindows = isWindowsAbsolutePath(candidate);
|
|
413
|
+
const rootIsWindows = isWindowsAbsolutePath(root);
|
|
414
|
+
if (candidateIsWindows !== rootIsWindows)
|
|
415
|
+
return false;
|
|
416
|
+
const pathApi = candidateIsWindows ? path.win32 : path.posix;
|
|
417
|
+
const normalizedCandidate = pathApi.resolve(candidate);
|
|
418
|
+
const normalizedRoot = pathApi.resolve(root);
|
|
419
|
+
return normalizedPathContainment(normalizedCandidate, normalizedRoot, pathApi, strict);
|
|
420
|
+
}
|
|
421
|
+
function normalizedPathContainment(candidate, root, pathApi, strict) {
|
|
422
|
+
const relative = pathApi.relative(root, candidate);
|
|
423
|
+
if (relative === "")
|
|
424
|
+
return !strict;
|
|
425
|
+
return (relative !== ".." &&
|
|
426
|
+
!relative.startsWith(`..${pathApi.sep}`) &&
|
|
427
|
+
!pathApi.isAbsolute(relative));
|
|
428
|
+
}
|
|
429
|
+
function collectionRootComparisonAliases(roots) {
|
|
430
|
+
return pathComparisonAliases(roots);
|
|
431
|
+
}
|
|
432
|
+
function pathComparisonAliases(values) {
|
|
433
|
+
const aliases = [];
|
|
434
|
+
const seen = new Set();
|
|
435
|
+
for (const value of values) {
|
|
436
|
+
for (const candidate of [
|
|
437
|
+
resolveAttributionPath(value),
|
|
438
|
+
canonicalExistingAttributionPath(value),
|
|
439
|
+
]) {
|
|
440
|
+
if (!candidate)
|
|
441
|
+
continue;
|
|
442
|
+
const key = normalizedAttributionPathKey(candidate);
|
|
443
|
+
if (seen.has(key))
|
|
444
|
+
continue;
|
|
445
|
+
seen.add(key);
|
|
446
|
+
aliases.push(candidate);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return aliases;
|
|
450
|
+
}
|
|
451
|
+
function canonicalExistingAttributionPath(value) {
|
|
452
|
+
const windowsPath = isWindowsAbsolutePath(value);
|
|
453
|
+
if (windowsPath !== (process.platform === "win32"))
|
|
454
|
+
return null;
|
|
455
|
+
try {
|
|
456
|
+
return attributionPathApi(value).resolve(realpathSync(value));
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function attributionPathApi(value) {
|
|
463
|
+
return isWindowsAbsolutePath(value) ? path.win32 : path.posix;
|
|
464
|
+
}
|
|
465
|
+
function resolveAttributionPath(value) {
|
|
466
|
+
return attributionPathApi(value).resolve(value);
|
|
467
|
+
}
|
|
468
|
+
function basenameForAttributionPath(value) {
|
|
469
|
+
return attributionPathApi(value).basename(value);
|
|
470
|
+
}
|
|
471
|
+
function normalizedAttributionPathKey(value) {
|
|
472
|
+
const resolved = resolveAttributionPath(value);
|
|
473
|
+
return isWindowsAbsolutePath(value) ? resolved.toLowerCase() : resolved;
|
|
474
|
+
}
|
|
475
|
+
function isWindowsAbsolutePath(value) {
|
|
476
|
+
return (/^[A-Za-z]:[\\/]/.test(value) ||
|
|
477
|
+
/^[\\/]{2}[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/.test(value));
|
|
357
478
|
}
|
|
358
479
|
export function clampScore(value) {
|
|
359
480
|
return Math.min(1, Math.max(0, Number(value.toFixed(4))));
|
|
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
|
|
|
4
4
|
import { createReadStream, existsSync } from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { StringDecoder } from "node:string_decoder";
|
|
7
|
+
import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
|
|
7
8
|
import { SESSION_FILE_UUID_PATTERN, isPathWithin, sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName, shortHash, } from "./attribution-core.js";
|
|
8
9
|
/**
|
|
9
10
|
* Deterministic Claude Code session JSONL -> repo/worktree attribution.
|
|
@@ -92,11 +93,11 @@ async function discoverClaudeSessions(projectsDir, cutoffMs) {
|
|
|
92
93
|
try {
|
|
93
94
|
projectEntries = await fs.readdir(projectsDir, { withFileTypes: true });
|
|
94
95
|
}
|
|
95
|
-
catch {
|
|
96
|
+
catch (error) {
|
|
96
97
|
return {
|
|
97
98
|
sessions,
|
|
98
99
|
projectDirsSkipped,
|
|
99
|
-
projectDirReadFailedCount: 1,
|
|
100
|
+
projectDirReadFailedCount: isMissingPathError(error) ? 0 : 1,
|
|
100
101
|
sessionStatFailedCount,
|
|
101
102
|
sidecarDirReadFailedCount,
|
|
102
103
|
sidecarStatFailedCount,
|
|
@@ -118,8 +119,9 @@ async function discoverClaudeSessions(projectsDir, cutoffMs) {
|
|
|
118
119
|
try {
|
|
119
120
|
sessionEntries = await fs.readdir(projectDir, { withFileTypes: true });
|
|
120
121
|
}
|
|
121
|
-
catch {
|
|
122
|
-
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (!isMissingPathError(error))
|
|
124
|
+
projectDirReadFailedCount += 1;
|
|
123
125
|
continue;
|
|
124
126
|
}
|
|
125
127
|
for (const sessionEntry of sessionEntries) {
|
|
@@ -169,11 +171,11 @@ async function discoverSidecars(subagentsDir) {
|
|
|
169
171
|
try {
|
|
170
172
|
entries = await fs.readdir(subagentsDir, { withFileTypes: true });
|
|
171
173
|
}
|
|
172
|
-
catch {
|
|
174
|
+
catch (error) {
|
|
173
175
|
return {
|
|
174
176
|
sidecars: [],
|
|
175
177
|
sidecarsCapped: 0,
|
|
176
|
-
dirReadFailedCount: 1,
|
|
178
|
+
dirReadFailedCount: isMissingPathError(error) ? 0 : 1,
|
|
177
179
|
statFailedCount: 0,
|
|
178
180
|
};
|
|
179
181
|
}
|
|
@@ -212,6 +214,11 @@ async function discoverSidecars(subagentsDir) {
|
|
|
212
214
|
statFailedCount,
|
|
213
215
|
};
|
|
214
216
|
}
|
|
217
|
+
function isMissingPathError(error) {
|
|
218
|
+
return (error instanceof Error &&
|
|
219
|
+
"code" in error &&
|
|
220
|
+
error.code === "ENOENT");
|
|
221
|
+
}
|
|
215
222
|
async function attributeOneSession(session, worktrees, collectionRoots) {
|
|
216
223
|
const fileName = path.basename(session.mainFile);
|
|
217
224
|
const base = {
|
|
@@ -310,7 +317,7 @@ async function attributeOneSession(session, worktrees, collectionRoots) {
|
|
|
310
317
|
extraSignals.push("main_file_oversized");
|
|
311
318
|
if (isSchemaDriftSuspected(signals))
|
|
312
319
|
extraSignals.push("schema_drift_suspected");
|
|
313
|
-
const sidecarFiles = outcome.state
|
|
320
|
+
const sidecarFiles = isRawEvidenceUploadableAttributionState(outcome.state) && outcome.worktree
|
|
314
321
|
? await collectSidecarDiagnostics(session.sidecars, outcome.worktree)
|
|
315
322
|
: session.sidecars.map((sidecar) => ({
|
|
316
323
|
local_path: sidecar.local_path,
|
|
@@ -87,8 +87,9 @@ async function discoverCodexJsonlFiles(dir, cutoffMs) {
|
|
|
87
87
|
try {
|
|
88
88
|
entries = await fs.readdir(current, { withFileTypes: true });
|
|
89
89
|
}
|
|
90
|
-
catch {
|
|
91
|
-
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (!isMissingPathError(error))
|
|
92
|
+
directoryReadFailedCount += 1;
|
|
92
93
|
continue;
|
|
93
94
|
}
|
|
94
95
|
for (const entry of entries) {
|
|
@@ -133,6 +134,11 @@ async function discoverCodexJsonlFiles(dir, cutoffMs) {
|
|
|
133
134
|
secretPathSkippedCount,
|
|
134
135
|
};
|
|
135
136
|
}
|
|
137
|
+
function isMissingPathError(error) {
|
|
138
|
+
return (error instanceof Error &&
|
|
139
|
+
"code" in error &&
|
|
140
|
+
error.code === "ENOENT");
|
|
141
|
+
}
|
|
136
142
|
function codexSessionDirsFromOptions(options) {
|
|
137
143
|
const dirs = options.sessionsDirs ?? (options.sessionsDir ? [options.sessionsDir] : []);
|
|
138
144
|
return dedupePaths(dirs);
|
|
@@ -7,6 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import { makeSourceAdapterIdentity, } from "./common.js";
|
|
8
8
|
import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
|
|
9
9
|
import { defaultCodexSessionDirs, } from "./codex-attribution.js";
|
|
10
|
+
import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-policy.js";
|
|
10
11
|
const DEFAULT_SINCE_MINUTES = 24 * 60;
|
|
11
12
|
const DEFAULT_SESSION_LIMIT = 50;
|
|
12
13
|
const MAX_GIT_DIFF_BYTES = 2 * 1024 * 1024;
|
|
@@ -14,8 +15,8 @@ const GIT_DIFF_TIMEOUT_MS = 3_000;
|
|
|
14
15
|
export const RAW_EVIDENCE_BUCKET = "ambient-raw-evidence";
|
|
15
16
|
export const RAW_EVIDENCE_RETENTION_MODE = "remote_durable";
|
|
16
17
|
// Per-sync upload budgets enforced at COLLECTION time (D7b). A single marathon
|
|
17
|
-
// transcript can
|
|
18
|
-
// without starving the sync; overflow still defers and converges
|
|
18
|
+
// transcript can approach the 256 MiB wire cap, so 2 GiB leaves room for
|
|
19
|
+
// several files without starving the sync; overflow still defers and converges.
|
|
19
20
|
export const RAW_EVIDENCE_DEFAULT_BYTE_BUDGET = 2 * 1024 * 1024 * 1024;
|
|
20
21
|
export const RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET = 300;
|
|
21
22
|
const CLAUDE_MAX_COLLECT_FILE_BYTES = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES;
|
|
@@ -72,6 +73,12 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
72
73
|
limit: GIT_DIFF_TIMEOUT_MS,
|
|
73
74
|
applied: false,
|
|
74
75
|
},
|
|
76
|
+
{
|
|
77
|
+
source: "codex_jsonl",
|
|
78
|
+
cap_type: "max_file_bytes",
|
|
79
|
+
limit: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
|
|
80
|
+
applied: false,
|
|
81
|
+
},
|
|
75
82
|
{
|
|
76
83
|
source: "claude_jsonl",
|
|
77
84
|
cap_type: "max_file_bytes",
|
|
@@ -98,6 +105,8 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
98
105
|
recordAttributionCompleteness(collection, {
|
|
99
106
|
codex: options.codexAttributionScan,
|
|
100
107
|
claude: options.claudeAttributionScan,
|
|
108
|
+
selectedCodexPaths: new Set(options.codexSessionFiles?.map((file) => file.local_path) ?? []),
|
|
109
|
+
selectedClaudePaths: new Set(options.claudeSessionFiles?.map((file) => file.local_path) ?? []),
|
|
101
110
|
});
|
|
102
111
|
if (options.includeCodexJsonl !== false) {
|
|
103
112
|
await collectCodexJsonlFiles(collection, {
|
|
@@ -292,12 +301,14 @@ function makeRawEvidenceScan(options) {
|
|
|
292
301
|
});
|
|
293
302
|
}
|
|
294
303
|
function recordAttributionCompleteness(collection, scans) {
|
|
295
|
-
if (scans.codex)
|
|
296
|
-
recordCodexAttributionCompleteness(collection, scans.codex);
|
|
297
|
-
|
|
298
|
-
|
|
304
|
+
if (scans.codex) {
|
|
305
|
+
recordCodexAttributionCompleteness(collection, scans.codex, scans.selectedCodexPaths);
|
|
306
|
+
}
|
|
307
|
+
if (scans.claude) {
|
|
308
|
+
recordClaudeAttributionCompleteness(collection, scans.claude, scans.selectedClaudePaths);
|
|
309
|
+
}
|
|
299
310
|
}
|
|
300
|
-
function recordCodexAttributionCompleteness(collection, scan) {
|
|
311
|
+
function recordCodexAttributionCompleteness(collection, scan, selectedPaths) {
|
|
301
312
|
recordScanned(collection, "codex_attribution", scan.scanned_file_count);
|
|
302
313
|
collection.caps.push({
|
|
303
314
|
source: "codex_attribution",
|
|
@@ -316,9 +327,9 @@ function recordCodexAttributionCompleteness(collection, scan) {
|
|
|
316
327
|
recordSkipCount(collection, "codex_attribution", "directory_read_failed", scan.directory_read_failed_count);
|
|
317
328
|
recordSkipCount(collection, "codex_attribution", "file_stat_failed", scan.stat_failed_count);
|
|
318
329
|
recordSkipCount(collection, "codex_attribution", "secret_like_directory", scan.secret_path_skipped_count);
|
|
319
|
-
recordAttributionResultSkips(collection, "codex_attribution", scan.results);
|
|
330
|
+
recordAttributionResultSkips(collection, "codex_attribution", scan.results, selectedPaths);
|
|
320
331
|
}
|
|
321
|
-
function recordClaudeAttributionCompleteness(collection, scan) {
|
|
332
|
+
function recordClaudeAttributionCompleteness(collection, scan, selectedPaths) {
|
|
322
333
|
recordScanned(collection, "claude_attribution", scan.scanned_session_count);
|
|
323
334
|
if (scan.disabled_reason) {
|
|
324
335
|
recordSkipCount(collection, "claude_attribution", scan.disabled_reason, 1);
|
|
@@ -361,7 +372,7 @@ function recordClaudeAttributionCompleteness(collection, scan) {
|
|
|
361
372
|
recordSkipCount(collection, "claude_attribution", "sidecar_stat_failed", scan.sidecar_stat_failed_count);
|
|
362
373
|
recordSkipCount(collection, "claude_attribution", "sidecar_limit_overflow", scan.counts.sidecars_capped);
|
|
363
374
|
recordTruncationCount(collection, "claude_attribution", "oversized_jsonl_line", scan.counts.oversized_lines_skipped, { max_bytes: scan.max_line_buffer_bytes });
|
|
364
|
-
recordAttributionResultSkips(collection, "claude_attribution", scan.results);
|
|
375
|
+
recordAttributionResultSkips(collection, "claude_attribution", scan.results, selectedPaths);
|
|
365
376
|
for (const result of scan.results) {
|
|
366
377
|
for (const sidecar of result.sidecar_files) {
|
|
367
378
|
if (!sidecar.skipped_reason)
|
|
@@ -370,10 +381,17 @@ function recordClaudeAttributionCompleteness(collection, scan) {
|
|
|
370
381
|
}
|
|
371
382
|
}
|
|
372
383
|
}
|
|
373
|
-
function recordAttributionResultSkips(collection, source, results) {
|
|
384
|
+
function recordAttributionResultSkips(collection, source, results, selectedPaths) {
|
|
374
385
|
for (const result of results) {
|
|
375
|
-
|
|
386
|
+
// A selected fallback is being collected by this pass. A live-sync-safe
|
|
387
|
+
// fallback is also not a skip when another worktree pack owns its upload.
|
|
388
|
+
if (result.state === "attributed" ||
|
|
389
|
+
(result.state === "attributed_fallback" &&
|
|
390
|
+
(selectedPaths.has(result.file_path) ||
|
|
391
|
+
(result.worktree !== null &&
|
|
392
|
+
isLiveRawEvidenceSyncAttribution(result.state, result.reason))))) {
|
|
376
393
|
continue;
|
|
394
|
+
}
|
|
377
395
|
const reason = result.state === "skipped"
|
|
378
396
|
? result.reason
|
|
379
397
|
: `attribution_${result.state}:${result.reason}`;
|
|
@@ -409,6 +427,9 @@ async function collectCodexJsonlFiles(collection, options) {
|
|
|
409
427
|
kind: "codex_jsonl",
|
|
410
428
|
sessionId: codexSessionId,
|
|
411
429
|
mediaType: "application/jsonl",
|
|
430
|
+
// Guard before read/toString: Codex files can exceed the buffered commit
|
|
431
|
+
// ceiling just like Claude mains.
|
|
432
|
+
maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
|
|
412
433
|
redactedSummary: "Raw Codex JSONL transcript with prompts, responses, tool arguments, and tool outputs preserved locally.",
|
|
413
434
|
contentAddress: (hash16) => `codex/${safeKeySegment(codexSessionId)}/${hash16}.jsonl`,
|
|
414
435
|
});
|