@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
|
@@ -1,8 +1,32 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Durable local cursor for raw-evidence harvesting.
|
|
6
|
+
*
|
|
7
|
+
* - `objects` maps content hashes to durable remote objects so repeated syncs
|
|
8
|
+
* acknowledge identical content instead of re-uploading it.
|
|
9
|
+
* - `sessions` tracks observed agent session files (hash, mtime, processed
|
|
10
|
+
* byte offset, last attribution outcome, last durable upload) so repeated
|
|
11
|
+
* syncs skip unchanged work, growth damping has its inputs, and stale
|
|
12
|
+
* sessions are countable instead of silently forgotten.
|
|
13
|
+
*
|
|
14
|
+
* The cursor stores hashes, ids, sizes, and labels only — never content.
|
|
15
|
+
*
|
|
16
|
+
* Per-source sessions, ONE shared objects map (D4): each source keeps its own
|
|
17
|
+
* sessions file (`raw-evidence.json` for Codex, `claude-raw-evidence.json` for
|
|
18
|
+
* Claude) so session-id namespaces never collide and stale counts stay
|
|
19
|
+
* per-source. The committed-OBJECTS map lives ONLY in the primary cursor; the
|
|
20
|
+
* Claude cursor carries sessions only and its `objects` record is asserted
|
|
21
|
+
* empty on write.
|
|
22
|
+
*/
|
|
23
|
+
export const PRIMARY_CURSOR_FILENAME = "raw-evidence.json";
|
|
24
|
+
export const CLAUDE_CURSOR_FILENAME = "claude-raw-evidence.json";
|
|
25
|
+
// Sidecars can push a single sync past 500 objects; pruning at 500 would force
|
|
26
|
+
// re-read + re-begin of durable objects every sync thereafter. Entries are
|
|
27
|
+
// ~150 B, so 5,000 is ~750 KB on disk (D4).
|
|
28
|
+
const MAX_TRACKED_OBJECTS = 5_000;
|
|
29
|
+
const MAX_TRACKED_SESSIONS = 5_000;
|
|
6
30
|
export function emptyRawEvidenceCursorState() {
|
|
7
31
|
return {
|
|
8
32
|
schema_version: "cockpit-raw-evidence-cursor.v1",
|
|
@@ -11,22 +35,44 @@ export function emptyRawEvidenceCursorState() {
|
|
|
11
35
|
sessions: {},
|
|
12
36
|
};
|
|
13
37
|
}
|
|
14
|
-
export async function readRawEvidenceCursor(paths) {
|
|
38
|
+
export async function readRawEvidenceCursor(paths, options = {}) {
|
|
15
39
|
try {
|
|
16
|
-
const raw = JSON.parse(await fs.readFile(rawEvidenceCursorPath(paths), "utf8"));
|
|
40
|
+
const raw = JSON.parse(await fs.readFile(rawEvidenceCursorPath(paths, options.filename), "utf8"));
|
|
17
41
|
return parseCursorState(raw);
|
|
18
42
|
}
|
|
19
43
|
catch {
|
|
20
44
|
return emptyRawEvidenceCursorState();
|
|
21
45
|
}
|
|
22
46
|
}
|
|
23
|
-
export async function writeRawEvidenceCursor(paths, state) {
|
|
24
|
-
const filePath = rawEvidenceCursorPath(paths);
|
|
47
|
+
export async function writeRawEvidenceCursor(paths, state, options = {}) {
|
|
48
|
+
const filePath = rawEvidenceCursorPath(paths, options.filename);
|
|
25
49
|
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
26
50
|
const pruned = pruneCursorState(state);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
51
|
+
if (options.sessionsOnly && Object.keys(pruned.objects).length > 0) {
|
|
52
|
+
// Invariant enforcement (D4): the Claude cursor never owns objects.
|
|
53
|
+
pruned.objects = {};
|
|
54
|
+
}
|
|
55
|
+
const serialized = `${JSON.stringify(pruned, null, 2)}\n`;
|
|
56
|
+
// Atomic write: a launchd sync and a manual sync can race the same cursor, so
|
|
57
|
+
// write a sibling temp file, fsync it, and rename over the target. A crash
|
|
58
|
+
// mid-write leaves either the old or the new state, never a truncated file.
|
|
59
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
60
|
+
let handle = null;
|
|
61
|
+
try {
|
|
62
|
+
handle = await fs.open(tempPath, "w", 0o600);
|
|
63
|
+
await handle.writeFile(serialized);
|
|
64
|
+
await handle.sync();
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await handle?.close();
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
await fs.rename(tempPath, filePath);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
30
76
|
if (process.platform !== "win32") {
|
|
31
77
|
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
|
32
78
|
}
|
|
@@ -37,8 +83,8 @@ export function markObjectCommitted(state, contentHash, entry) {
|
|
|
37
83
|
export function hasCommittedObject(state, contentHash) {
|
|
38
84
|
return Boolean(contentHash && state.objects[contentHash]);
|
|
39
85
|
}
|
|
40
|
-
export function recordSessionObservation(state,
|
|
41
|
-
state.sessions[
|
|
86
|
+
export function recordSessionObservation(state, sessionId, entry) {
|
|
87
|
+
state.sessions[sessionId] = entry;
|
|
42
88
|
}
|
|
43
89
|
/**
|
|
44
90
|
* Sessions tracked by the cursor that no longer appear in the current scan
|
|
@@ -115,15 +161,20 @@ function parseSessionEntry(value) {
|
|
|
115
161
|
reason: optionalString(record["reason"]) ?? "unknown",
|
|
116
162
|
worktree_fingerprint: optionalString(record["worktree_fingerprint"]),
|
|
117
163
|
uploaded_object_key: optionalString(record["uploaded_object_key"]),
|
|
164
|
+
uploaded_at: optionalString(record["uploaded_at"]),
|
|
165
|
+
uploaded_byte_size: optionalNumberOrNull(record["uploaded_byte_size"]),
|
|
118
166
|
last_seen_at: lastSeenAt,
|
|
119
167
|
};
|
|
120
168
|
}
|
|
121
|
-
function rawEvidenceCursorPath(paths) {
|
|
122
|
-
return path.join(paths.cursors_dir,
|
|
169
|
+
function rawEvidenceCursorPath(paths, filename = PRIMARY_CURSOR_FILENAME) {
|
|
170
|
+
return path.join(paths.cursors_dir, filename);
|
|
123
171
|
}
|
|
124
172
|
function optionalString(value) {
|
|
125
173
|
return typeof value === "string" && value.trim() ? value : null;
|
|
126
174
|
}
|
|
127
175
|
function optionalNumber(value) {
|
|
128
176
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
177
|
+
}
|
|
178
|
+
function optionalNumberOrNull(value) {
|
|
179
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
129
180
|
}
|
package/dist/local-state.js
CHANGED
|
@@ -5,8 +5,8 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { resolveRepoWorktreeIdentity, } from "./repo-identity.js";
|
|
7
7
|
import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
|
|
8
|
-
export const LOCAL_COLLECTOR_VERSION = "0.1.
|
|
9
|
-
export const DEFAULT_DASHBOARD_URL = "
|
|
8
|
+
export const LOCAL_COLLECTOR_VERSION = "0.1.8";
|
|
9
|
+
export const DEFAULT_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
|
|
10
10
|
export function getCollectorRuntimePaths(homeDir = os.homedir()) {
|
|
11
11
|
const paths = getUserLocalCockpitPaths(homeDir);
|
|
12
12
|
return {
|
package/dist/repo-identity.js
CHANGED
|
@@ -58,14 +58,14 @@ export async function resolveRepoWorktreeIdentity(repoRoot) {
|
|
|
58
58
|
}
|
|
59
59
|
export async function discoverGitWorktrees(root, options = {}) {
|
|
60
60
|
const resolvedRoot = path.resolve(root);
|
|
61
|
+
const maxWorktrees = options.maxWorktrees ?? 50;
|
|
61
62
|
const direct = await resolveRepoWorktreeIdentity(resolvedRoot).catch(() => null);
|
|
62
63
|
if (direct)
|
|
63
|
-
return [direct];
|
|
64
|
+
return expandLinkedWorktrees([direct], maxWorktrees);
|
|
64
65
|
if (await hasGitMarker(resolvedRoot)) {
|
|
65
|
-
return [await fallbackFilesystemIdentity(resolvedRoot)];
|
|
66
|
+
return expandLinkedWorktrees([await fallbackFilesystemIdentity(resolvedRoot)], maxWorktrees);
|
|
66
67
|
}
|
|
67
68
|
const maxDepth = options.maxDepth ?? 2;
|
|
68
|
-
const maxWorktrees = options.maxWorktrees ?? 50;
|
|
69
69
|
const discovered = new Map();
|
|
70
70
|
const stack = [{ dir: resolvedRoot, depth: 0 }];
|
|
71
71
|
const visited = new Set();
|
|
@@ -92,7 +92,53 @@ export async function discoverGitWorktrees(root, options = {}) {
|
|
|
92
92
|
stack.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 });
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
-
return [...discovered.values()].sort(compareIdentity);
|
|
95
|
+
return expandLinkedWorktrees([...discovered.values()].sort(compareIdentity), maxWorktrees);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Directory discovery skips dot-dirs, so Claude Code's isolation worktrees
|
|
99
|
+
* (`<repo>/.claude/worktrees/<name>`) and any other linked worktree are never
|
|
100
|
+
* found by walking the filesystem. A session whose cwd sits inside one would
|
|
101
|
+
* otherwise satisfy `isPathWithin(cwd, parentRoot)` and attribute confidently
|
|
102
|
+
* to the PARENT with the wrong branch and worktree fingerprint. Enumerating
|
|
103
|
+
* `git worktree list --porcelain` for each discovered repo adds the linked
|
|
104
|
+
* worktrees as first-class candidates with their own branch/fingerprint; the
|
|
105
|
+
* deepest-root tie-break (attribution-core D6) then attributes nested-worktree
|
|
106
|
+
* sessions to the correct linked worktree. Benefits Codex sessions identically.
|
|
107
|
+
*/
|
|
108
|
+
async function expandLinkedWorktrees(identities, maxWorktrees) {
|
|
109
|
+
const byFingerprint = new Map(identities.map((identity) => [identity.worktree_fingerprint, identity]));
|
|
110
|
+
const seenRoots = new Set(identities.map((identity) => path.resolve(identity.repo_root)));
|
|
111
|
+
for (const identity of identities) {
|
|
112
|
+
if (byFingerprint.size >= maxWorktrees)
|
|
113
|
+
break;
|
|
114
|
+
// One `git worktree list` from any worktree returns every worktree of that
|
|
115
|
+
// repo, so a single call per already-discovered repo covers its linked set.
|
|
116
|
+
for (const worktreePath of await listLinkedWorktreePaths(identity.repo_root)) {
|
|
117
|
+
if (byFingerprint.size >= maxWorktrees)
|
|
118
|
+
break;
|
|
119
|
+
const resolved = path.resolve(worktreePath);
|
|
120
|
+
if (seenRoots.has(resolved))
|
|
121
|
+
continue;
|
|
122
|
+
seenRoots.add(resolved);
|
|
123
|
+
const linked = await resolveRepoWorktreeIdentity(resolved).catch(() => null);
|
|
124
|
+
if (linked && !byFingerprint.has(linked.worktree_fingerprint)) {
|
|
125
|
+
byFingerprint.set(linked.worktree_fingerprint, linked);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return [...byFingerprint.values()].sort(compareIdentity);
|
|
130
|
+
}
|
|
131
|
+
async function listLinkedWorktreePaths(repoRoot) {
|
|
132
|
+
const porcelain = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => "");
|
|
133
|
+
const paths = [];
|
|
134
|
+
for (const line of porcelain.split("\n")) {
|
|
135
|
+
if (line.startsWith("worktree ")) {
|
|
136
|
+
const value = line.slice("worktree ".length).trim();
|
|
137
|
+
if (value)
|
|
138
|
+
paths.push(value);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return paths;
|
|
96
142
|
}
|
|
97
143
|
async function hasGitMarker(dir) {
|
|
98
144
|
return fs.stat(path.join(dir, ".git")).then((stat) => stat.isDirectory() || stat.isFile(), () => false);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Single-flight lock for `cockpit sync`. A launchd timer and a manual sync can
|
|
6
|
+
* fire at the same time and interleave the cursor read-modify-write; this lock
|
|
7
|
+
* lets the second invocation exit cleanly with `sync_already_running` instead.
|
|
8
|
+
*
|
|
9
|
+
* A held lock refreshes a heartbeat every 30 s. Takeover happens only when the
|
|
10
|
+
* heartbeat is more than 2 minutes stale — a fixed short timeout would fire
|
|
11
|
+
* during a legitimate long first sync and recreate the interleaving it
|
|
12
|
+
* prevents (D33). The lock file stays local and stores a pid + heartbeat only;
|
|
13
|
+
* no paths or content.
|
|
14
|
+
*/
|
|
15
|
+
const LOCK_FILENAME = "sync.lock";
|
|
16
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
17
|
+
const STALE_TAKEOVER_MS = 2 * 60_000;
|
|
18
|
+
export async function acquireSyncLock(paths, now = new Date()) {
|
|
19
|
+
const lockPath = path.join(paths.state_dir, LOCK_FILENAME);
|
|
20
|
+
await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
|
|
21
|
+
const token = crypto.randomUUID();
|
|
22
|
+
const existing = await readLock(lockPath);
|
|
23
|
+
if (existing && now.getTime() - existing.heartbeat_ms <= STALE_TAKEOVER_MS) {
|
|
24
|
+
return { acquired: false, held_since: existing.heartbeat_at };
|
|
25
|
+
}
|
|
26
|
+
// Free or stale: try an exclusive create first; if another process created it
|
|
27
|
+
// in the gap, re-check staleness before taking over.
|
|
28
|
+
let owned = await tryExclusiveCreate(lockPath, token, now);
|
|
29
|
+
if (!owned) {
|
|
30
|
+
const current = await readLock(lockPath);
|
|
31
|
+
if (current && now.getTime() - current.heartbeat_ms <= STALE_TAKEOVER_MS) {
|
|
32
|
+
return { acquired: false, held_since: current.heartbeat_at };
|
|
33
|
+
}
|
|
34
|
+
await writeLock(lockPath, token, now); // take over the stale lock
|
|
35
|
+
owned = true;
|
|
36
|
+
}
|
|
37
|
+
const timer = setInterval(() => {
|
|
38
|
+
// Refresh only while the lock is still ours; if we were taken over, stop
|
|
39
|
+
// touching the file so we cannot steal it back from the new owner.
|
|
40
|
+
void (async () => {
|
|
41
|
+
const current = await readLock(lockPath);
|
|
42
|
+
if (current && current.token === token) {
|
|
43
|
+
await writeLock(lockPath, token, new Date()).catch(() => undefined);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
clearInterval(timer);
|
|
47
|
+
}
|
|
48
|
+
})().catch(() => undefined);
|
|
49
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
50
|
+
// Never keep the CLI process alive just to refresh the heartbeat.
|
|
51
|
+
timer.unref?.();
|
|
52
|
+
return {
|
|
53
|
+
acquired: true,
|
|
54
|
+
handle: {
|
|
55
|
+
release: async () => {
|
|
56
|
+
clearInterval(timer);
|
|
57
|
+
// Ownership-safe: only remove the lock if it is still ours. A stale
|
|
58
|
+
// owner that resumes after takeover must not delete the new owner's lock.
|
|
59
|
+
const current = await readLock(lockPath);
|
|
60
|
+
if (current && current.token === token) {
|
|
61
|
+
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
async function tryExclusiveCreate(lockPath, token, now) {
|
|
68
|
+
try {
|
|
69
|
+
const handle = await fs.open(lockPath, "wx", 0o600);
|
|
70
|
+
await handle.writeFile(serializeLock(token, now));
|
|
71
|
+
await handle.close();
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function writeLock(lockPath, token, now) {
|
|
79
|
+
await fs.writeFile(lockPath, serializeLock(token, now), { mode: 0o600 });
|
|
80
|
+
}
|
|
81
|
+
function serializeLock(token, now) {
|
|
82
|
+
const record = {
|
|
83
|
+
pid: process.pid,
|
|
84
|
+
token,
|
|
85
|
+
heartbeat_at: now.toISOString(),
|
|
86
|
+
heartbeat_ms: now.getTime(),
|
|
87
|
+
};
|
|
88
|
+
return `${JSON.stringify(record)}\n`;
|
|
89
|
+
}
|
|
90
|
+
async function readLock(lockPath) {
|
|
91
|
+
try {
|
|
92
|
+
const raw = JSON.parse(await fs.readFile(lockPath, "utf8"));
|
|
93
|
+
if (!raw || typeof raw !== "object")
|
|
94
|
+
return null;
|
|
95
|
+
const record = raw;
|
|
96
|
+
const heartbeatMs = record["heartbeat_ms"];
|
|
97
|
+
const heartbeatAt = record["heartbeat_at"];
|
|
98
|
+
if (typeof heartbeatMs !== "number" || !Number.isFinite(heartbeatMs)) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
pid: typeof record["pid"] === "number" ? record["pid"] : -1,
|
|
103
|
+
token: typeof record["token"] === "string" ? record["token"] : "",
|
|
104
|
+
heartbeat_at: typeof heartbeatAt === "string"
|
|
105
|
+
? heartbeatAt
|
|
106
|
+
: new Date(heartbeatMs).toISOString(),
|
|
107
|
+
heartbeat_ms: heartbeatMs,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
package/dist/upload.js
CHANGED
|
@@ -55,9 +55,15 @@ export async function buildLocalAmbientEnvelope(options = {}) {
|
|
|
55
55
|
activeWorkContext: activeContext,
|
|
56
56
|
rawEvidenceStateDir: paths.state_dir,
|
|
57
57
|
rawEvidenceSessionsDir: path.join(paths.home_dir, ".codex", "sessions"),
|
|
58
|
+
claudeProjectsDir: path.join(paths.home_dir, ".claude", "projects"),
|
|
58
59
|
rawEvidenceIncludeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
|
|
60
|
+
rawEvidenceIncludeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
|
|
59
61
|
rawEvidenceCodexSessionFiles: options.codexSessionFiles,
|
|
62
|
+
rawEvidenceClaudeSessionFiles: options.claudeSessionFiles,
|
|
60
63
|
rawEvidenceSkipContentHashes: skipContentHashes,
|
|
64
|
+
rawEvidenceByteBudget: options.rawEvidenceByteBudget,
|
|
65
|
+
rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
|
|
66
|
+
rawEvidenceBudget: options.rawEvidenceBudget,
|
|
61
67
|
now,
|
|
62
68
|
});
|
|
63
69
|
const binding = sourceCollection.binding;
|
|
@@ -356,6 +362,8 @@ function rawEvidenceSummary(built, outcomes, uploadedChunkCount, cursor) {
|
|
|
356
362
|
})),
|
|
357
363
|
...cursorReusedOutcomes,
|
|
358
364
|
],
|
|
365
|
+
raw_evidence_deferred_byte_budget: built.raw_evidence_facts?.deferred_byte_budget_count ?? 0,
|
|
366
|
+
raw_evidence_deferred_object_budget: built.raw_evidence_facts?.deferred_object_budget_count ?? 0,
|
|
359
367
|
cursor_tracked_object_count: Object.keys(cursor.objects).length,
|
|
360
368
|
};
|
|
361
369
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,6 +26,6 @@
|
|
|
26
26
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
29
|
+
"@bli-cockpit/telemetry-core": "0.1.4"
|
|
30
30
|
}
|
|
31
31
|
}
|