@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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Shared deterministic attribution core for agent session transcripts (Codex
|
|
5
|
+
* and Claude Code). Both adapters extract source-shaped signals, normalize them
|
|
6
|
+
* into an {@link AttributionSignalSet}, and score them here against the same
|
|
7
|
+
* candidate worktrees with the same weights, threshold, and margin. Keeping one
|
|
8
|
+
* scorer means cross-source comparison is honest: a session attributes the same
|
|
9
|
+
* way no matter which agent produced it.
|
|
10
|
+
*
|
|
11
|
+
* A session is attributed only when exactly one worktree clearly wins (best
|
|
12
|
+
* score ≥ threshold AND beats the runner-up by the margin). Close calls stay
|
|
13
|
+
* ambiguous instead of being duplicated across repos or guessed.
|
|
14
|
+
*/
|
|
15
|
+
export const SCORE_CWD_MATCH = 0.5;
|
|
16
|
+
export const SCORE_WORKSPACE_ROOT_MATCH = 0.1;
|
|
17
|
+
export const SCORE_ORIGIN_MATCH = 0.3;
|
|
18
|
+
export const SCORE_BRANCH_MATCH = 0.15;
|
|
19
|
+
export const SCORE_HEAD_SHA_MATCH = 0.05;
|
|
20
|
+
export const ATTRIBUTION_MIN_SCORE = 0.4;
|
|
21
|
+
export const ATTRIBUTION_MIN_MARGIN = 0.15;
|
|
22
|
+
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
|
+
export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
24
|
+
const originLabel = options.originLabel ?? "origin_url_match";
|
|
25
|
+
const hasAnySignal = signals.cwds.length > 0 ||
|
|
26
|
+
signals.workspaceRoots.length > 0 ||
|
|
27
|
+
signals.originUrls.length > 0 ||
|
|
28
|
+
signals.branches.length > 0 ||
|
|
29
|
+
signals.headShas.length > 0;
|
|
30
|
+
if (!hasAnySignal) {
|
|
31
|
+
return unattributed("no_repo_signals");
|
|
32
|
+
}
|
|
33
|
+
// D6 deepest-root tie-break: a path signal can sit inside several candidate
|
|
34
|
+
// roots when one root is nested inside another (e.g. Claude's
|
|
35
|
+
// `<repo>/.claude/worktrees/<name>` linked worktrees, or any monorepo with a
|
|
36
|
+
// nested package that is itself a worktree). Crediting every containing root
|
|
37
|
+
// leaves the session ambiguous forever. Instead, only the DEEPEST containing
|
|
38
|
+
// root earns the path score, which is also the most specific (correct) match.
|
|
39
|
+
const cwdWinners = deepestContainers(signals.cwds, worktrees);
|
|
40
|
+
const workspaceRootWinners = deepestContainers(signals.workspaceRoots, worktrees);
|
|
41
|
+
const scored = worktrees.map((worktree) => {
|
|
42
|
+
const matched = [];
|
|
43
|
+
let score = 0;
|
|
44
|
+
let pathScore = 0;
|
|
45
|
+
if (cwdWinners.has(worktree.worktree_fingerprint)) {
|
|
46
|
+
score += SCORE_CWD_MATCH;
|
|
47
|
+
pathScore += SCORE_CWD_MATCH;
|
|
48
|
+
matched.push("cwd_match");
|
|
49
|
+
}
|
|
50
|
+
if (workspaceRootWinners.has(worktree.worktree_fingerprint)) {
|
|
51
|
+
score += SCORE_WORKSPACE_ROOT_MATCH;
|
|
52
|
+
pathScore += SCORE_WORKSPACE_ROOT_MATCH;
|
|
53
|
+
matched.push("workspace_root_match");
|
|
54
|
+
}
|
|
55
|
+
if (worktree.repo_origin_url &&
|
|
56
|
+
signals.originUrls.includes(worktree.repo_origin_url)) {
|
|
57
|
+
score += SCORE_ORIGIN_MATCH;
|
|
58
|
+
matched.push(originLabel);
|
|
59
|
+
}
|
|
60
|
+
if (signals.branches.includes(worktree.branch)) {
|
|
61
|
+
score += SCORE_BRANCH_MATCH;
|
|
62
|
+
matched.push("branch_match");
|
|
63
|
+
}
|
|
64
|
+
if (worktree.head_sha && signals.headShas.includes(worktree.head_sha)) {
|
|
65
|
+
score += SCORE_HEAD_SHA_MATCH;
|
|
66
|
+
matched.push("head_sha_match");
|
|
67
|
+
}
|
|
68
|
+
return { worktree, score, pathScore, matched };
|
|
69
|
+
});
|
|
70
|
+
scored.sort((a, b) => b.score - a.score);
|
|
71
|
+
const best = scored[0];
|
|
72
|
+
const secondBestScore = scored[1]?.score ?? 0;
|
|
73
|
+
if (!best || best.score === 0) {
|
|
74
|
+
const reason = signals.cwds.length > 0 || signals.workspaceRoots.length > 0
|
|
75
|
+
? "cwd_outside_scanned_worktrees"
|
|
76
|
+
: "no_matching_worktree_signals";
|
|
77
|
+
return unattributed(reason);
|
|
78
|
+
}
|
|
79
|
+
const score = clampScore(best.score);
|
|
80
|
+
const pathScore = clampScore(best.pathScore);
|
|
81
|
+
if (best.score >= ATTRIBUTION_MIN_SCORE &&
|
|
82
|
+
best.score - secondBestScore >= ATTRIBUTION_MIN_MARGIN) {
|
|
83
|
+
return {
|
|
84
|
+
state: "attributed",
|
|
85
|
+
reason: "deterministic_signal_match",
|
|
86
|
+
signals: best.matched,
|
|
87
|
+
attribution_score: score,
|
|
88
|
+
path_score: pathScore,
|
|
89
|
+
worktree: best.worktree,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
state: "ambiguous",
|
|
94
|
+
reason: best.score - secondBestScore < ATTRIBUTION_MIN_MARGIN
|
|
95
|
+
? "multiple_worktrees_close_scores"
|
|
96
|
+
: "signal_score_below_threshold",
|
|
97
|
+
signals: best.matched,
|
|
98
|
+
attribution_score: score,
|
|
99
|
+
path_score: pathScore,
|
|
100
|
+
worktree: null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function unattributed(reason) {
|
|
104
|
+
return {
|
|
105
|
+
state: "unattributed",
|
|
106
|
+
reason,
|
|
107
|
+
signals: [],
|
|
108
|
+
attribution_score: 0,
|
|
109
|
+
path_score: 0,
|
|
110
|
+
worktree: null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* For each path signal, the single worktree whose root most specifically
|
|
115
|
+
* contains it (the longest containing root). Returns the set of worktree
|
|
116
|
+
* fingerprints that win at least one path signal — these are the only roots
|
|
117
|
+
* credited with the path score (D6).
|
|
118
|
+
*/
|
|
119
|
+
function deepestContainers(signalValues, worktrees) {
|
|
120
|
+
const winners = new Set();
|
|
121
|
+
for (const value of signalValues) {
|
|
122
|
+
let best = null;
|
|
123
|
+
let bestRootLength = -1;
|
|
124
|
+
for (const worktree of worktrees) {
|
|
125
|
+
if (!isPathWithin(value, worktree.repo_root))
|
|
126
|
+
continue;
|
|
127
|
+
const rootLength = path.resolve(worktree.repo_root).length;
|
|
128
|
+
if (rootLength > bestRootLength) {
|
|
129
|
+
best = worktree;
|
|
130
|
+
bestRootLength = rootLength;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (best)
|
|
134
|
+
winners.add(best.worktree_fingerprint);
|
|
135
|
+
}
|
|
136
|
+
return winners;
|
|
137
|
+
}
|
|
138
|
+
export function isPathWithin(candidate, root) {
|
|
139
|
+
const normalizedCandidate = path.resolve(candidate);
|
|
140
|
+
const normalizedRoot = path.resolve(root);
|
|
141
|
+
return (normalizedCandidate === normalizedRoot ||
|
|
142
|
+
normalizedCandidate.startsWith(normalizedRoot + path.sep));
|
|
143
|
+
}
|
|
144
|
+
export function clampScore(value) {
|
|
145
|
+
return Math.min(1, Math.max(0, Number(value.toFixed(4))));
|
|
146
|
+
}
|
|
147
|
+
export function sessionIdFromFileName(fileName) {
|
|
148
|
+
const match = fileName.match(SESSION_FILE_UUID_PATTERN);
|
|
149
|
+
return match?.[1]?.toLowerCase() ?? null;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Session ids come from file content and end up inside remote object keys, so
|
|
153
|
+
* anything outside the safe charset falls back to a hash of the raw value
|
|
154
|
+
* instead of poisoning every begin request in the batch.
|
|
155
|
+
*/
|
|
156
|
+
export function sanitizeSessionId(value) {
|
|
157
|
+
if (!value)
|
|
158
|
+
return null;
|
|
159
|
+
if (/^[A-Za-z0-9._-]{4,80}$/.test(value))
|
|
160
|
+
return value;
|
|
161
|
+
return shortHash(value);
|
|
162
|
+
}
|
|
163
|
+
export function shortHash(value) {
|
|
164
|
+
return crypto
|
|
165
|
+
.createHash("sha256")
|
|
166
|
+
.update(value, "utf8")
|
|
167
|
+
.digest("hex")
|
|
168
|
+
.slice(0, 16);
|
|
169
|
+
}
|
|
170
|
+
export function sha256(value) {
|
|
171
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
172
|
+
}
|