@bli-cockpit/cli 0.1.29 → 0.1.30
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 +7 -1
- package/dist/adapters/attribution-core.js +70 -1
- package/dist/adapters/claude-attribution.js +4 -3
- package/dist/adapters/codex-attribution.js +6 -5
- package/dist/adapters/common.js +2 -5
- package/dist/backfill-lock.js +108 -0
- package/dist/commands/backfill.js +964 -0
- package/dist/commands/local-args.js +59 -1
- package/dist/commands/local.js +305 -8
- package/dist/commands/session-sync.js +22 -14
- package/dist/cursors/backfill-cursor.js +130 -0
- package/dist/upload.js +114 -25
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -50,6 +50,9 @@ installs the latest public CLI from npm, then reruns onboarding checks to
|
|
|
50
50
|
refresh pairing, agent rules, autostart, and an initial sync against saved
|
|
51
51
|
roots. `cockpit upgrade` is a compatibility alias. `--repo <path>` remains
|
|
52
52
|
supported for older prompts and the agent ticket-binding guardrail.
|
|
53
|
+
When onboarding reports archived local sessions without a completed backfill,
|
|
54
|
+
follow [`docs/runbooks/cockpit-backfill.md`](../../docs/runbooks/cockpit-backfill.md):
|
|
55
|
+
run `cockpit update`, then run `cockpit backfill --all` as a separate command.
|
|
53
56
|
|
|
54
57
|
On machines where Codex or Claude agents will do ticketed work, `cockpit
|
|
55
58
|
onboard` refreshes `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` after harvest
|
|
@@ -176,6 +179,8 @@ Local files:
|
|
|
176
179
|
- `~/.local/state/bli-cockpit/cursors/raw-evidence.json`: hashes and labels of
|
|
177
180
|
already-durable evidence so repeated syncs skip re-uploading the same
|
|
178
181
|
content (no raw content is stored in the cursor).
|
|
182
|
+
- `~/.local/state/bli-cockpit/cursors/backfill.json`: high-water progress for
|
|
183
|
+
the explicit `cockpit backfill` command.
|
|
179
184
|
- `.codex-autorunner/contextspace/active_context.md` in the work repo when a
|
|
180
185
|
work context is active.
|
|
181
186
|
|
|
@@ -220,7 +225,8 @@ cockpit sessions --workspace "$PWD" --json
|
|
|
220
225
|
```
|
|
221
226
|
|
|
222
227
|
This preview includes both active and archived Codex sessions in the bounded
|
|
223
|
-
|
|
228
|
+
scan window, plus Claude Code sessions when Claude collection is enabled. Use
|
|
229
|
+
`--since-days N` or `--all` when debugging older local history.
|
|
224
230
|
|
|
225
231
|
Remote metadata path:
|
|
226
232
|
|
|
@@ -19,6 +19,7 @@ export const SCORE_BRANCH_MATCH = 0.15;
|
|
|
19
19
|
export const SCORE_HEAD_SHA_MATCH = 0.05;
|
|
20
20
|
export const ATTRIBUTION_MIN_SCORE = 0.4;
|
|
21
21
|
export const ATTRIBUTION_MIN_MARGIN = 0.15;
|
|
22
|
+
export const ATTRIBUTED_FALLBACK_MAX_SCORE = 0.39;
|
|
22
23
|
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;
|
|
23
24
|
export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
24
25
|
const originLabel = options.originLabel ?? "origin_url_match";
|
|
@@ -71,6 +72,9 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
|
71
72
|
const best = scored[0];
|
|
72
73
|
const secondBestScore = scored[1]?.score ?? 0;
|
|
73
74
|
if (!best || best.score === 0) {
|
|
75
|
+
if (repoPathAbsentFromDisk([...signals.cwds, ...signals.workspaceRoots], options.collectionRoots ?? [])) {
|
|
76
|
+
return skipped("repo_not_on_disk");
|
|
77
|
+
}
|
|
74
78
|
const reason = signals.cwds.length > 0 || signals.workspaceRoots.length > 0
|
|
75
79
|
? "cwd_outside_scanned_worktrees"
|
|
76
80
|
: "no_matching_worktree_signals";
|
|
@@ -78,7 +82,8 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
|
78
82
|
}
|
|
79
83
|
const score = clampScore(best.score);
|
|
80
84
|
const pathScore = clampScore(best.pathScore);
|
|
81
|
-
if (best.
|
|
85
|
+
if (best.pathScore > 0 &&
|
|
86
|
+
best.score >= ATTRIBUTION_MIN_SCORE &&
|
|
82
87
|
best.score - secondBestScore >= ATTRIBUTION_MIN_MARGIN) {
|
|
83
88
|
return {
|
|
84
89
|
state: "attributed",
|
|
@@ -89,6 +94,16 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
|
89
94
|
worktree: best.worktree,
|
|
90
95
|
};
|
|
91
96
|
}
|
|
97
|
+
if (best.pathScore === 0) {
|
|
98
|
+
const fallback = fallbackToKnownRepoPrimaryWorkContext({
|
|
99
|
+
originLabel,
|
|
100
|
+
scored,
|
|
101
|
+
signals,
|
|
102
|
+
worktrees,
|
|
103
|
+
});
|
|
104
|
+
if (fallback)
|
|
105
|
+
return fallback;
|
|
106
|
+
}
|
|
92
107
|
return {
|
|
93
108
|
state: "ambiguous",
|
|
94
109
|
reason: best.score - secondBestScore < ATTRIBUTION_MIN_MARGIN
|
|
@@ -110,6 +125,54 @@ function unattributed(reason) {
|
|
|
110
125
|
worktree: null,
|
|
111
126
|
};
|
|
112
127
|
}
|
|
128
|
+
function skipped(reason) {
|
|
129
|
+
return {
|
|
130
|
+
state: "skipped",
|
|
131
|
+
reason,
|
|
132
|
+
signals: [],
|
|
133
|
+
attribution_score: 0,
|
|
134
|
+
path_score: 0,
|
|
135
|
+
worktree: null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function fallbackToKnownRepoPrimaryWorkContext(options) {
|
|
139
|
+
const originCandidates = options.worktrees.filter((worktree) => worktree.repo_origin_url &&
|
|
140
|
+
options.signals.originUrls.includes(worktree.repo_origin_url));
|
|
141
|
+
if (originCandidates.length === 0)
|
|
142
|
+
return null;
|
|
143
|
+
const primaryByClone = originCandidates.filter((worktree) => worktree.worktree_is_primary);
|
|
144
|
+
const cloneCandidates = primaryByClone.length > 0 ? primaryByClone : originCandidates;
|
|
145
|
+
const cloneRoots = new Set(cloneCandidates.map((worktree) => path.resolve(worktree.repo_root)));
|
|
146
|
+
if (cloneRoots.size > 1) {
|
|
147
|
+
return {
|
|
148
|
+
state: "ambiguous",
|
|
149
|
+
reason: "multiple_repos_share_origin",
|
|
150
|
+
signals: [options.originLabel],
|
|
151
|
+
attribution_score: ATTRIBUTED_FALLBACK_MAX_SCORE,
|
|
152
|
+
path_score: 0,
|
|
153
|
+
worktree: null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const primary = primaryByClone[0] ??
|
|
157
|
+
originCandidates.find((worktree) => worktree.worktree_is_primary) ??
|
|
158
|
+
originCandidates[0] ??
|
|
159
|
+
null;
|
|
160
|
+
if (!primary)
|
|
161
|
+
return null;
|
|
162
|
+
const primaryScore = options.scored.find((entry) => entry.worktree.worktree_fingerprint === primary.worktree_fingerprint);
|
|
163
|
+
const signals = new Set([
|
|
164
|
+
...(primaryScore?.matched ?? [options.originLabel]),
|
|
165
|
+
"repo_primary_fallback",
|
|
166
|
+
]);
|
|
167
|
+
return {
|
|
168
|
+
state: "attributed_fallback",
|
|
169
|
+
reason: "known_repo_primary_fallback",
|
|
170
|
+
signals: [...signals],
|
|
171
|
+
attribution_score: clampScore(Math.min(primaryScore?.score ?? SCORE_ORIGIN_MATCH, ATTRIBUTED_FALLBACK_MAX_SCORE)),
|
|
172
|
+
path_score: 0,
|
|
173
|
+
worktree: primary,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
113
176
|
/**
|
|
114
177
|
* For each path signal, the single worktree whose root most specifically
|
|
115
178
|
* contains it (the longest containing root). Returns the set of worktree
|
|
@@ -141,6 +204,12 @@ export function isPathWithin(candidate, root) {
|
|
|
141
204
|
return (normalizedCandidate === normalizedRoot ||
|
|
142
205
|
normalizedCandidate.startsWith(normalizedRoot + path.sep));
|
|
143
206
|
}
|
|
207
|
+
function repoPathAbsentFromDisk(paths, collectionRoots) {
|
|
208
|
+
if (paths.length === 0 || collectionRoots.length === 0)
|
|
209
|
+
return false;
|
|
210
|
+
const roots = collectionRoots.map((root) => path.resolve(root));
|
|
211
|
+
return paths.some((value) => roots.some((root) => isPathWithin(value, root)));
|
|
212
|
+
}
|
|
144
213
|
export function clampScore(value) {
|
|
145
214
|
return Math.min(1, Math.max(0, Number(value.toFixed(4))));
|
|
146
215
|
}
|
|
@@ -48,7 +48,7 @@ export async function scanAndAttributeClaudeSessions(options) {
|
|
|
48
48
|
.slice(0, limit);
|
|
49
49
|
const results = [];
|
|
50
50
|
for (const session of sessions) {
|
|
51
|
-
results.push(await attributeOneSession(session, options.worktrees));
|
|
51
|
+
results.push(await attributeOneSession(session, options.worktrees, options.collectionRoots ?? []));
|
|
52
52
|
}
|
|
53
53
|
const sumBy = (pick) => results.reduce((total, result) => total + pick(result), 0);
|
|
54
54
|
const countState = (state) => results.filter((result) => result.state === state).length;
|
|
@@ -69,6 +69,7 @@ export async function scanAndAttributeClaudeSessions(options) {
|
|
|
69
69
|
sidecar_stat_failed_count: discovery.sidecarStatFailedCount,
|
|
70
70
|
counts: {
|
|
71
71
|
attributed: countState("attributed"),
|
|
72
|
+
attributed_fallback: countState("attributed_fallback"),
|
|
72
73
|
ambiguous: countState("ambiguous"),
|
|
73
74
|
unattributed: countState("unattributed"),
|
|
74
75
|
skipped: countState("skipped"),
|
|
@@ -211,7 +212,7 @@ async function discoverSidecars(subagentsDir) {
|
|
|
211
212
|
statFailedCount,
|
|
212
213
|
};
|
|
213
214
|
}
|
|
214
|
-
async function attributeOneSession(session, worktrees) {
|
|
215
|
+
async function attributeOneSession(session, worktrees, collectionRoots) {
|
|
215
216
|
const fileName = path.basename(session.mainFile);
|
|
216
217
|
const base = {
|
|
217
218
|
file_path: session.mainFile,
|
|
@@ -303,7 +304,7 @@ async function attributeOneSession(session, worktrees) {
|
|
|
303
304
|
originUrls: signals.repository_urls,
|
|
304
305
|
branches: signals.branches,
|
|
305
306
|
headShas: [],
|
|
306
|
-
}, worktrees, { originLabel: "pr_repo_match" });
|
|
307
|
+
}, worktrees, { collectionRoots, originLabel: "pr_repo_match" });
|
|
307
308
|
const extraSignals = [];
|
|
308
309
|
if (base.main_file_oversized)
|
|
309
310
|
extraSignals.push("main_file_oversized");
|
|
@@ -21,8 +21,8 @@ import { sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName,
|
|
|
21
21
|
export { sanitizeSessionId, sessionIdFromFileName };
|
|
22
22
|
export const CODEX_ATTRIBUTION_DEFAULT_SINCE_MINUTES = 24 * 60;
|
|
23
23
|
export const CODEX_ATTRIBUTION_DEFAULT_SESSION_LIMIT = 50;
|
|
24
|
-
export const
|
|
25
|
-
export const
|
|
24
|
+
export const CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES = 14 * 24 * 60;
|
|
25
|
+
export const CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT = 500;
|
|
26
26
|
// Compatibility export only. Codex attribution streams full files for metadata
|
|
27
27
|
// and no longer rejects sessions by file size; raw evidence collection enforces
|
|
28
28
|
// upload budgets and content guards later.
|
|
@@ -42,7 +42,7 @@ export async function scanAndAttributeCodexSessions(options) {
|
|
|
42
42
|
const files = discovery.files.slice(0, limit);
|
|
43
43
|
const results = [];
|
|
44
44
|
for (const file of files) {
|
|
45
|
-
results.push(await attributeOneSession(file, options.worktrees));
|
|
45
|
+
results.push(await attributeOneSession(file, options.worktrees, options.collectionRoots ?? []));
|
|
46
46
|
}
|
|
47
47
|
return {
|
|
48
48
|
results,
|
|
@@ -57,6 +57,7 @@ export async function scanAndAttributeCodexSessions(options) {
|
|
|
57
57
|
secret_path_skipped_count: discovery.secretPathSkippedCount,
|
|
58
58
|
counts: {
|
|
59
59
|
attributed: results.filter((entry) => entry.state === "attributed").length,
|
|
60
|
+
attributed_fallback: results.filter((entry) => entry.state === "attributed_fallback").length,
|
|
60
61
|
ambiguous: results.filter((entry) => entry.state === "ambiguous").length,
|
|
61
62
|
unattributed: results.filter((entry) => entry.state === "unattributed")
|
|
62
63
|
.length,
|
|
@@ -148,7 +149,7 @@ function dedupePaths(values) {
|
|
|
148
149
|
}
|
|
149
150
|
return out;
|
|
150
151
|
}
|
|
151
|
-
async function attributeOneSession(file, worktrees) {
|
|
152
|
+
async function attributeOneSession(file, worktrees, collectionRoots) {
|
|
152
153
|
const fileName = path.basename(file.file);
|
|
153
154
|
const base = {
|
|
154
155
|
file_path: file.file,
|
|
@@ -200,7 +201,7 @@ async function attributeOneSession(file, worktrees) {
|
|
|
200
201
|
originUrls: signals.repository_urls,
|
|
201
202
|
branches: signals.branches,
|
|
202
203
|
headShas: signals.commit_hashes,
|
|
203
|
-
}, worktrees);
|
|
204
|
+
}, worktrees, { collectionRoots });
|
|
204
205
|
return { ...base, ...outcome };
|
|
205
206
|
}
|
|
206
207
|
async function readCodexMetadataSignals(filePath) {
|
package/dist/adapters/common.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SourceScanResultSchema, } from "@bli-cockpit/telemetry-core";
|
|
1
|
+
import { SourceScanResultSchema, parseTicketIdFromText, } from "@bli-cockpit/telemetry-core";
|
|
2
2
|
import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
|
|
3
3
|
export function makeSourceAdapterIdentity(captureSource, adapterName) {
|
|
4
4
|
return {
|
|
@@ -34,7 +34,4 @@ export function makeSourceScan(context, captureSource, adapterName, status, reas
|
|
|
34
34
|
diagnostic_labels: [reasonLabel],
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
|
-
export
|
|
38
|
-
const match = value.match(/\b[A-Z][A-Z0-9]{1,12}-\d+\b/);
|
|
39
|
-
return match?.[0] ?? null;
|
|
40
|
-
}
|
|
37
|
+
export { parseTicketIdFromText };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const BACKFILL_LOCK_FILENAME = "backfill.lock";
|
|
5
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
6
|
+
export const BACKFILL_LOCK_STALE_TAKEOVER_MS = 5 * 60_000;
|
|
7
|
+
export async function acquireBackfillLock(paths, now = new Date()) {
|
|
8
|
+
const lockPath = backfillLockPath(paths);
|
|
9
|
+
await fs.mkdir(paths.cursors_dir, { recursive: true, mode: 0o700 });
|
|
10
|
+
const token = crypto.randomUUID();
|
|
11
|
+
const existing = await readBackfillLockRecord(lockPath);
|
|
12
|
+
if (existing &&
|
|
13
|
+
now.getTime() - existing.heartbeat_ms <= BACKFILL_LOCK_STALE_TAKEOVER_MS) {
|
|
14
|
+
return { acquired: false, held_since: existing.heartbeat_at };
|
|
15
|
+
}
|
|
16
|
+
let owned = await tryExclusiveCreate(lockPath, token, now);
|
|
17
|
+
if (!owned) {
|
|
18
|
+
const current = await readBackfillLockRecord(lockPath);
|
|
19
|
+
if (current &&
|
|
20
|
+
now.getTime() - current.heartbeat_ms <= BACKFILL_LOCK_STALE_TAKEOVER_MS) {
|
|
21
|
+
return { acquired: false, held_since: current.heartbeat_at };
|
|
22
|
+
}
|
|
23
|
+
await writeBackfillLock(lockPath, token, now);
|
|
24
|
+
owned = true;
|
|
25
|
+
}
|
|
26
|
+
const heartbeat = async () => {
|
|
27
|
+
const current = await readBackfillLockRecord(lockPath);
|
|
28
|
+
if (current && current.token === token) {
|
|
29
|
+
await writeBackfillLock(lockPath, token, new Date());
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const timer = setInterval(() => {
|
|
33
|
+
void heartbeat().catch(() => undefined);
|
|
34
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
35
|
+
timer.unref?.();
|
|
36
|
+
return {
|
|
37
|
+
acquired: true,
|
|
38
|
+
handle: {
|
|
39
|
+
heartbeat,
|
|
40
|
+
release: async () => {
|
|
41
|
+
clearInterval(timer);
|
|
42
|
+
const current = await readBackfillLockRecord(lockPath);
|
|
43
|
+
if (current && current.token === token) {
|
|
44
|
+
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export async function inspectBackfillLock(paths, now = new Date()) {
|
|
51
|
+
const record = await readBackfillLockRecord(backfillLockPath(paths));
|
|
52
|
+
if (!record ||
|
|
53
|
+
now.getTime() - record.heartbeat_ms > BACKFILL_LOCK_STALE_TAKEOVER_MS) {
|
|
54
|
+
return { held: false, held_since: null };
|
|
55
|
+
}
|
|
56
|
+
return { held: true, held_since: record.heartbeat_at };
|
|
57
|
+
}
|
|
58
|
+
export function backfillLockPath(paths) {
|
|
59
|
+
return path.join(paths.cursors_dir, BACKFILL_LOCK_FILENAME);
|
|
60
|
+
}
|
|
61
|
+
async function tryExclusiveCreate(lockPath, token, now) {
|
|
62
|
+
try {
|
|
63
|
+
const handle = await fs.open(lockPath, "wx", 0o600);
|
|
64
|
+
await handle.writeFile(serializeBackfillLock(token, now));
|
|
65
|
+
await handle.close();
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function writeBackfillLock(lockPath, token, now) {
|
|
73
|
+
await fs.writeFile(lockPath, serializeBackfillLock(token, now), {
|
|
74
|
+
mode: 0o600,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function serializeBackfillLock(token, now) {
|
|
78
|
+
const record = {
|
|
79
|
+
pid: process.pid,
|
|
80
|
+
token,
|
|
81
|
+
heartbeat_at: now.toISOString(),
|
|
82
|
+
heartbeat_ms: now.getTime(),
|
|
83
|
+
};
|
|
84
|
+
return `${JSON.stringify(record)}\n`;
|
|
85
|
+
}
|
|
86
|
+
async function readBackfillLockRecord(lockPath) {
|
|
87
|
+
try {
|
|
88
|
+
const raw = JSON.parse(await fs.readFile(lockPath, "utf8"));
|
|
89
|
+
if (!raw || typeof raw !== "object")
|
|
90
|
+
return null;
|
|
91
|
+
const record = raw;
|
|
92
|
+
const heartbeatMs = record["heartbeat_ms"];
|
|
93
|
+
if (typeof heartbeatMs !== "number" || !Number.isFinite(heartbeatMs)) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
pid: typeof record["pid"] === "number" ? record["pid"] : -1,
|
|
98
|
+
token: typeof record["token"] === "string" ? record["token"] : "",
|
|
99
|
+
heartbeat_at: typeof record["heartbeat_at"] === "string"
|
|
100
|
+
? record["heartbeat_at"]
|
|
101
|
+
: new Date(heartbeatMs).toISOString(),
|
|
102
|
+
heartbeat_ms: heartbeatMs,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|