@bli-cockpit/cli 0.2.54 → 0.2.56
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-fallbacks.js +247 -0
- package/dist/adapters/attribution-core-paths.js +182 -0
- package/dist/adapters/attribution-core-score.js +159 -0
- package/dist/adapters/attribution-core-types.js +13 -0
- package/dist/adapters/attribution-core.js +13 -565
- package/dist/adapters/claude-attribution-discovery.js +186 -0
- package/dist/adapters/claude-attribution-score.js +204 -0
- package/dist/adapters/claude-attribution-signals.js +180 -0
- package/dist/adapters/claude-attribution-types.js +25 -0
- package/dist/adapters/claude-attribution.js +14 -569
- package/dist/commands/doctor-access.js +129 -0
- package/dist/commands/doctor-pipeline.js +326 -0
- package/dist/commands/doctor-registration.js +105 -0
- package/dist/commands/doctor-report.js +111 -0
- package/dist/commands/doctor-update.js +120 -0
- package/dist/commands/doctor.js +8 -753
- package/dist/commands/heartbeat.js +8 -0
- package/dist/commands/jarvis-contracts.js +8 -0
- package/dist/commands/jarvis-render.js +413 -0
- package/dist/commands/jarvis-turn.js +305 -0
- package/dist/commands/jarvis.js +23 -698
- package/dist/commands/local-args-collector-setup.js +250 -0
- package/dist/commands/local-args-collector-status.js +227 -0
- package/dist/commands/local-args-collector-work.js +175 -0
- package/dist/commands/local-args-collector.js +19 -624
- package/dist/commands/local-args-tower-admin.js +456 -0
- package/dist/commands/local-args-tower-chat.js +194 -0
- package/dist/commands/local-args-tower-pages.js +314 -0
- package/dist/commands/local-args-tower.js +13 -880
- package/dist/commands/local-help.js +10 -2
- package/dist/commands/onboard-completion.js +136 -0
- package/dist/commands/onboard-flows.js +165 -0
- package/dist/commands/onboard-setup.js +102 -0
- package/dist/commands/onboard.js +5 -392
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-counters.js +55 -0
- package/dist/commands/session-sync-health.js +8 -1
- package/dist/commands/session-sync-plan.js +47 -7
- package/dist/commands/session-sync-scan.js +4 -4
- package/dist/commands/session-sync.js +6 -0
- package/dist/commands/settings-render.js +27 -0
- package/dist/commands/sync-followups.js +5 -1
- package/dist/commands/sync.js +5 -1
- package/dist/commands/team-device-reasons.js +16 -0
- package/dist/commands/team.js +87 -7
- package/dist/evidence-upload-client.js +14 -763
- package/dist/evidence-upload-object.js +181 -0
- package/dist/evidence-upload-plan.js +233 -0
- package/dist/evidence-upload-terminal.js +309 -0
- package/dist/evidence-upload-transport.js +104 -0
- package/dist/spool/local-spool-io.js +122 -0
- package/dist/spool/local-spool-mutations.js +174 -0
- package/dist/spool/local-spool-parse.js +143 -0
- package/dist/spool/local-spool-types.js +22 -0
- package/dist/spool/local-spool.js +20 -426
- package/dist/upload-evidence-delivery-offer.js +144 -0
- package/dist/upload-evidence-delivery-reconcile.js +134 -0
- package/dist/upload-evidence-delivery-summary.js +205 -0
- package/dist/upload-evidence-delivery.js +12 -482
- package/package.json +3 -3
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, stableWorktreeFingerprint, } from "../repo-identity.js";
|
|
2
|
+
import { ATTRIBUTED_FALLBACK_MAX_SCORE, SCORE_CWD_MATCH, SCORE_ORIGIN_MATCH, } from "./attribution-core-types.js";
|
|
3
|
+
import { basenameForAttributionPath, clampScore, isPathStrictlyWithin, isPathWithin, normalizedAttributionPathKey, pathComparisonAliases, pathDepth, resolveAttributionPath, sortWorktreesForFolderFallback, } from "./attribution-core-paths.js";
|
|
4
|
+
/**
|
|
5
|
+
* The rescue paths `scoreSignalsAgainstWorktrees` reaches for once a clean
|
|
6
|
+
* winner does not emerge: a wrapper folder holding one or more repos, a
|
|
7
|
+
* known repo's primary clone standing in for an ambiguous one, transcript
|
|
8
|
+
* origin metadata for a since-deleted repo, and the terminal reasons a
|
|
9
|
+
* session ends up `skipped`/`unattributed` for. All of these run only after
|
|
10
|
+
* a recorded path has already been proven inside an approved collection
|
|
11
|
+
* root (or, for the origin fallback, after that same proof); none of them
|
|
12
|
+
* expand consent on their own.
|
|
13
|
+
*/
|
|
14
|
+
export function unattributed(reason) {
|
|
15
|
+
return {
|
|
16
|
+
state: "unattributed",
|
|
17
|
+
reason,
|
|
18
|
+
signals: [],
|
|
19
|
+
attribution_score: 0,
|
|
20
|
+
path_score: 0,
|
|
21
|
+
worktree: null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function skipped(reason) {
|
|
25
|
+
return {
|
|
26
|
+
state: "skipped",
|
|
27
|
+
reason,
|
|
28
|
+
signals: [],
|
|
29
|
+
attribution_score: 0,
|
|
30
|
+
path_score: 0,
|
|
31
|
+
worktree: null,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Gives an inside-boundary terminal attribution a durable upload context
|
|
36
|
+
* without pretending the session belongs to any discovered repository. The
|
|
37
|
+
* original state, reason, signals, and scores are deliberately untouched: the
|
|
38
|
+
* synthetic folder is transport identity, not stronger attribution.
|
|
39
|
+
*/
|
|
40
|
+
export function attachApprovedRootWorkspace(options) {
|
|
41
|
+
if (options.outcome.worktree || options.approvedRecordedPaths.length === 0) {
|
|
42
|
+
return options.outcome;
|
|
43
|
+
}
|
|
44
|
+
const approvedRoot = deepestApprovedRootForRecordedPaths(options.approvedRecordedPaths, options.collectionRoots);
|
|
45
|
+
if (!approvedRoot)
|
|
46
|
+
return options.outcome;
|
|
47
|
+
const repoLabel = basenameForAttributionPath(approvedRoot);
|
|
48
|
+
return {
|
|
49
|
+
...options.outcome,
|
|
50
|
+
worktree: {
|
|
51
|
+
requested_path: approvedRoot,
|
|
52
|
+
repo_root: approvedRoot,
|
|
53
|
+
repo_label: repoLabel,
|
|
54
|
+
repo_fingerprint: repoFingerprintFromLocalRoot(approvedRoot),
|
|
55
|
+
repo_origin_url: null,
|
|
56
|
+
branch: options.signals.branches[0] ?? "unknown",
|
|
57
|
+
head_sha: options.signals.headShas[0] ?? null,
|
|
58
|
+
worktree_label: repoLabel,
|
|
59
|
+
worktree_fingerprint: stableWorktreeFingerprint(approvedRoot),
|
|
60
|
+
worktree_is_primary: true,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function deepestApprovedRootForRecordedPaths(recordedPaths, collectionRoots) {
|
|
65
|
+
const candidates = collectionRoots.filter((root) => recordedPaths.some((recordedPath) => isPathWithin(recordedPath, root)));
|
|
66
|
+
candidates.sort((a, b) => pathDepth(b) - pathDepth(a) ||
|
|
67
|
+
normalizedAttributionPathKey(a).localeCompare(normalizedAttributionPathKey(b)));
|
|
68
|
+
return candidates[0] ? resolveAttributionPath(candidates[0]) : null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Handles sessions launched from wrapper folders that intentionally contain
|
|
72
|
+
* several repos. The wrapper path is the user's working context even though it
|
|
73
|
+
* is not itself a git repo, and the consent boundary is unchanged because this
|
|
74
|
+
* only runs after the recorded path was proven under an approved collection
|
|
75
|
+
* root.
|
|
76
|
+
*/
|
|
77
|
+
export function fallbackToFolderWorkspaceForContainerCwd(options) {
|
|
78
|
+
let bestFolder = null;
|
|
79
|
+
for (const recordedPath of pathComparisonAliases(options.approvedRecordedPaths)) {
|
|
80
|
+
const resolvedPath = resolveAttributionPath(recordedPath);
|
|
81
|
+
const containedWorktrees = options.worktrees.filter((worktree) => isPathStrictlyWithin(worktree.repo_root, resolvedPath));
|
|
82
|
+
if (containedWorktrees.length === 0)
|
|
83
|
+
continue;
|
|
84
|
+
const depth = pathDepth(resolvedPath);
|
|
85
|
+
if (!bestFolder || depth > bestFolder.depth) {
|
|
86
|
+
bestFolder = { resolvedPath, depth, containedWorktrees };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!bestFolder)
|
|
90
|
+
return null;
|
|
91
|
+
const repoFingerprints = new Set(bestFolder.containedWorktrees.map((worktree) => worktree.repo_fingerprint));
|
|
92
|
+
if (repoFingerprints.size === 1) {
|
|
93
|
+
const primary = sortWorktreesForFolderFallback(bestFolder.containedWorktrees)[0];
|
|
94
|
+
if (!primary)
|
|
95
|
+
return null;
|
|
96
|
+
return {
|
|
97
|
+
state: "attributed_fallback",
|
|
98
|
+
reason: "single_repo_folder_fallback",
|
|
99
|
+
signals: ["single_repo_folder_fallback"],
|
|
100
|
+
attribution_score: clampScore(Math.min(SCORE_CWD_MATCH, ATTRIBUTED_FALLBACK_MAX_SCORE)),
|
|
101
|
+
path_score: 0,
|
|
102
|
+
worktree: primary,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const repoLabel = basenameForAttributionPath(bestFolder.resolvedPath);
|
|
106
|
+
return {
|
|
107
|
+
state: "attributed_fallback",
|
|
108
|
+
reason: "multi_repo_folder_workspace",
|
|
109
|
+
signals: ["multi_repo_folder_workspace"],
|
|
110
|
+
attribution_score: clampScore(ATTRIBUTED_FALLBACK_MAX_SCORE),
|
|
111
|
+
path_score: 0,
|
|
112
|
+
worktree: {
|
|
113
|
+
requested_path: bestFolder.resolvedPath,
|
|
114
|
+
repo_root: bestFolder.resolvedPath,
|
|
115
|
+
repo_label: repoLabel,
|
|
116
|
+
repo_fingerprint: repoFingerprintFromLocalRoot(bestFolder.resolvedPath),
|
|
117
|
+
repo_origin_url: null,
|
|
118
|
+
branch: options.signals.branches[0] ?? "unknown",
|
|
119
|
+
head_sha: options.signals.headShas[0] ?? null,
|
|
120
|
+
worktree_label: repoLabel,
|
|
121
|
+
worktree_fingerprint: stableWorktreeFingerprint(bestFolder.resolvedPath),
|
|
122
|
+
worktree_is_primary: true,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export function fallbackToKnownRepoPrimaryWorkContext(options) {
|
|
127
|
+
const originCandidates = options.worktrees.filter((worktree) => worktree.repo_origin_url &&
|
|
128
|
+
options.signals.originUrls.includes(worktree.repo_origin_url));
|
|
129
|
+
if (originCandidates.length === 0)
|
|
130
|
+
return null;
|
|
131
|
+
const primaryByClone = originCandidates.filter((worktree) => worktree.worktree_is_primary);
|
|
132
|
+
const cloneCandidates = primaryByClone.length > 0 ? primaryByClone : originCandidates;
|
|
133
|
+
const cloneRoots = new Set(cloneCandidates.map((worktree) => normalizedAttributionPathKey(worktree.repo_root)));
|
|
134
|
+
if (cloneRoots.size > 1) {
|
|
135
|
+
return {
|
|
136
|
+
state: "ambiguous",
|
|
137
|
+
reason: "multiple_repos_share_origin",
|
|
138
|
+
signals: [options.originLabel],
|
|
139
|
+
attribution_score: ATTRIBUTED_FALLBACK_MAX_SCORE,
|
|
140
|
+
path_score: 0,
|
|
141
|
+
worktree: null,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const primary = primaryByClone[0] ??
|
|
145
|
+
originCandidates.find((worktree) => worktree.worktree_is_primary) ??
|
|
146
|
+
originCandidates[0] ??
|
|
147
|
+
null;
|
|
148
|
+
if (!primary)
|
|
149
|
+
return null;
|
|
150
|
+
const primaryScore = options.scored.find((entry) => entry.worktree.worktree_fingerprint === primary.worktree_fingerprint);
|
|
151
|
+
const signals = new Set([
|
|
152
|
+
...(primaryScore?.matched ?? [options.originLabel]),
|
|
153
|
+
"repo_primary_fallback",
|
|
154
|
+
]);
|
|
155
|
+
return {
|
|
156
|
+
state: "attributed_fallback",
|
|
157
|
+
reason: "known_repo_primary_fallback",
|
|
158
|
+
signals: [...signals],
|
|
159
|
+
attribution_score: clampScore(Math.min(primaryScore?.score ?? SCORE_ORIGIN_MATCH, ATTRIBUTED_FALLBACK_MAX_SCORE)),
|
|
160
|
+
path_score: 0,
|
|
161
|
+
worktree: primary,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Reconstructs a repo/worktree identity from transcript origin metadata only
|
|
166
|
+
* after the caller has already proven a recorded cwd/workspace root sits under
|
|
167
|
+
* an operator-approved collection root. The origin is never allowed to widen
|
|
168
|
+
* consent by itself: it only rescues sessions for repos that were once inside
|
|
169
|
+
* the approved scan boundary but have since been deleted from disk, where the
|
|
170
|
+
* transcript is still durable enough to carry the normalized remote identity.
|
|
171
|
+
*/
|
|
172
|
+
export function fallbackToTranscriptOriginForDeletedRepo(options) {
|
|
173
|
+
const origins = [
|
|
174
|
+
...new Set(options.signals.originUrls
|
|
175
|
+
.map((origin) => normalizeGitOrigin(origin))
|
|
176
|
+
.filter(Boolean)),
|
|
177
|
+
];
|
|
178
|
+
if (origins.length === 0)
|
|
179
|
+
return null;
|
|
180
|
+
if (origins.length > 1)
|
|
181
|
+
return skipped("multiple_transcript_origins");
|
|
182
|
+
if (!options.pathExists)
|
|
183
|
+
return null;
|
|
184
|
+
// Prefer an explicitly recorded workspace root, but only after proving that
|
|
185
|
+
// exact selected path remains inside the operator-approved boundary. A
|
|
186
|
+
// different cwd being approved must never authorize an outside workspace
|
|
187
|
+
// root (or vice versa).
|
|
188
|
+
const recordedPath = [...options.signals.workspaceRoots, ...options.signals.cwds].find((value) => {
|
|
189
|
+
if (!options.collectionRoots.some((root) => isPathWithin(value, root))) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
return !options.pathExists?.(value);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Deliberately silent (BLI-3238). This is an existence PROBE and
|
|
197
|
+
// failure is the answer: a path we cannot test is a path we cannot
|
|
198
|
+
// claim is missing, so the candidate is passed over. The scorer is
|
|
199
|
+
// pure and synchronous by design and has no channel to report on.
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}) ?? null;
|
|
203
|
+
if (!recordedPath)
|
|
204
|
+
return null;
|
|
205
|
+
const resolvedPath = resolveAttributionPath(recordedPath);
|
|
206
|
+
const origin = origins[0] ?? "";
|
|
207
|
+
return {
|
|
208
|
+
state: "attributed_fallback",
|
|
209
|
+
reason: "transcript_origin_fallback",
|
|
210
|
+
signals: ["transcript_origin_fallback", options.originLabel],
|
|
211
|
+
attribution_score: clampScore(SCORE_ORIGIN_MATCH),
|
|
212
|
+
path_score: 0,
|
|
213
|
+
worktree: {
|
|
214
|
+
requested_path: resolvedPath,
|
|
215
|
+
repo_root: resolvedPath,
|
|
216
|
+
repo_label: repoLabelFromOrigin(origin),
|
|
217
|
+
repo_fingerprint: repoFingerprintFromOrigin(origin),
|
|
218
|
+
repo_origin_url: origin,
|
|
219
|
+
branch: options.signals.branches[0] ?? "unknown",
|
|
220
|
+
head_sha: options.signals.headShas[0] ?? null,
|
|
221
|
+
worktree_label: basenameForAttributionPath(resolvedPath),
|
|
222
|
+
worktree_fingerprint: stableWorktreeFingerprint(resolvedPath),
|
|
223
|
+
worktree_is_primary: false,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
export function terminalReasonForRecordedPaths(paths, pathExists) {
|
|
228
|
+
// A recorded path under a collection root can mean two different things:
|
|
229
|
+
// deleted/missing repo (retryable) or an existing non-repo folder. The latter
|
|
230
|
+
// is common with wrapper-folder workflows, so adapters inject existence only
|
|
231
|
+
// at the boundary and the pure scorer keeps the old label when no hook exists.
|
|
232
|
+
if (!pathExists)
|
|
233
|
+
return "repo_not_on_disk";
|
|
234
|
+
const anyRecordedPathExists = paths.some((value) => {
|
|
235
|
+
try {
|
|
236
|
+
return pathExists(value);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Deliberately silent (BLI-3238), same probe as above: "cannot test"
|
|
240
|
+
// folds into "does not exist", which is the conservative direction —
|
|
241
|
+
// it keeps the older `repo_not_on_disk` label rather than inventing
|
|
242
|
+
// `cwd_not_a_repo`.
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
return anyRecordedPathExists ? "cwd_not_a_repo" : "repo_not_on_disk";
|
|
247
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { SESSION_FILE_UUID_PATTERN } from "./attribution-core-types.js";
|
|
5
|
+
/**
|
|
6
|
+
* Cross-platform path containment and worktree-matching primitives, plus the
|
|
7
|
+
* small value-normalization helpers (session ids, hashes, score clamping)
|
|
8
|
+
* that ride alongside them. Transcript paths describe the host that produced
|
|
9
|
+
* the session, which may not be the host currently running tests or
|
|
10
|
+
* replaying evidence, so every comparison here detects Windows drive/UNC
|
|
11
|
+
* paths from the value itself rather than trusting `process.platform`.
|
|
12
|
+
*/
|
|
13
|
+
export function isPathWithin(candidate, root) {
|
|
14
|
+
return pathContainment(candidate, root, false);
|
|
15
|
+
}
|
|
16
|
+
export function isPathStrictlyWithin(candidate, root) {
|
|
17
|
+
return pathContainment(candidate, root, true);
|
|
18
|
+
}
|
|
19
|
+
export function recordedPathsWithinCollectionRoots(paths, collectionRoots) {
|
|
20
|
+
if (paths.length === 0 || collectionRoots.length === 0)
|
|
21
|
+
return [];
|
|
22
|
+
return paths.filter((value) => collectionRoots.some((root) => isPathWithin(value, root)));
|
|
23
|
+
}
|
|
24
|
+
export function worktreesWithinCollectionScope(worktrees, collectionRoots) {
|
|
25
|
+
if (collectionRoots.length === 0)
|
|
26
|
+
return worktrees;
|
|
27
|
+
const directlyApproved = new Set(worktrees
|
|
28
|
+
.filter((worktree) => collectionRoots.some((root) => isPathWithin(worktree.repo_root, root)))
|
|
29
|
+
.map((worktree) => worktree.worktree_fingerprint));
|
|
30
|
+
const approvedRepoFingerprints = new Set(worktrees
|
|
31
|
+
.filter((worktree) => directlyApproved.has(worktree.worktree_fingerprint))
|
|
32
|
+
.map((worktree) => worktree.repo_fingerprint));
|
|
33
|
+
return worktrees.filter((worktree) => directlyApproved.has(worktree.worktree_fingerprint) ||
|
|
34
|
+
(isCodexManagedWorktreePath(worktree.repo_root) &&
|
|
35
|
+
approvedRepoFingerprints.has(worktree.repo_fingerprint)));
|
|
36
|
+
}
|
|
37
|
+
function isCodexManagedWorktreePath(value) {
|
|
38
|
+
const pathApi = attributionPathApi(value);
|
|
39
|
+
const windowsPath = isWindowsAbsolutePath(value);
|
|
40
|
+
const parts = pathApi.resolve(value).split(pathApi.sep).filter(Boolean);
|
|
41
|
+
return parts.some((part, index) => {
|
|
42
|
+
const codex = windowsPath ? part.toLowerCase() : part;
|
|
43
|
+
const nextPart = parts[index + 1] ?? "";
|
|
44
|
+
const worktrees = windowsPath ? nextPart.toLowerCase() : nextPart;
|
|
45
|
+
return codex === ".codex" && worktrees === "worktrees";
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export function sortWorktreesForFolderFallback(worktrees) {
|
|
49
|
+
return [...worktrees].sort((a, b) => Number(b.worktree_is_primary) - Number(a.worktree_is_primary) ||
|
|
50
|
+
normalizedAttributionPathKey(a.repo_root).localeCompare(normalizedAttributionPathKey(b.repo_root)) ||
|
|
51
|
+
a.worktree_fingerprint.localeCompare(b.worktree_fingerprint));
|
|
52
|
+
}
|
|
53
|
+
export function pathDepth(value) {
|
|
54
|
+
const pathApi = attributionPathApi(value);
|
|
55
|
+
return pathApi.resolve(value).split(pathApi.sep).filter(Boolean).length;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* For each path signal, the single worktree whose root most specifically
|
|
59
|
+
* contains it (the longest containing root). Returns the set of worktree
|
|
60
|
+
* fingerprints that win at least one path signal — these are the only roots
|
|
61
|
+
* credited with the path score (D6).
|
|
62
|
+
*/
|
|
63
|
+
export function deepestContainers(signalValues, worktrees) {
|
|
64
|
+
const winners = new Set();
|
|
65
|
+
for (const value of signalValues) {
|
|
66
|
+
let best = null;
|
|
67
|
+
let bestRootLength = -1;
|
|
68
|
+
for (const worktree of worktrees) {
|
|
69
|
+
if (!isPathWithin(value, worktree.repo_root))
|
|
70
|
+
continue;
|
|
71
|
+
const rootLength = normalizedAttributionPathKey(worktree.repo_root).length;
|
|
72
|
+
if (rootLength > bestRootLength) {
|
|
73
|
+
best = worktree;
|
|
74
|
+
bestRootLength = rootLength;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (best)
|
|
78
|
+
winners.add(best.worktree_fingerprint);
|
|
79
|
+
}
|
|
80
|
+
return winners;
|
|
81
|
+
}
|
|
82
|
+
function pathContainment(candidate, root, strict) {
|
|
83
|
+
const candidateIsWindows = isWindowsAbsolutePath(candidate);
|
|
84
|
+
const rootIsWindows = isWindowsAbsolutePath(root);
|
|
85
|
+
if (candidateIsWindows !== rootIsWindows)
|
|
86
|
+
return false;
|
|
87
|
+
const pathApi = candidateIsWindows ? path.win32 : path.posix;
|
|
88
|
+
const normalizedCandidate = pathApi.resolve(candidate);
|
|
89
|
+
const normalizedRoot = pathApi.resolve(root);
|
|
90
|
+
return normalizedPathContainment(normalizedCandidate, normalizedRoot, pathApi, strict);
|
|
91
|
+
}
|
|
92
|
+
function normalizedPathContainment(candidate, root, pathApi, strict) {
|
|
93
|
+
const relative = pathApi.relative(root, candidate);
|
|
94
|
+
if (relative === "")
|
|
95
|
+
return !strict;
|
|
96
|
+
return (relative !== ".." &&
|
|
97
|
+
!relative.startsWith(`..${pathApi.sep}`) &&
|
|
98
|
+
!pathApi.isAbsolute(relative));
|
|
99
|
+
}
|
|
100
|
+
export function collectionRootComparisonAliases(roots) {
|
|
101
|
+
return pathComparisonAliases(roots);
|
|
102
|
+
}
|
|
103
|
+
export function pathComparisonAliases(values) {
|
|
104
|
+
const aliases = [];
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
for (const value of values) {
|
|
107
|
+
for (const candidate of [
|
|
108
|
+
resolveAttributionPath(value),
|
|
109
|
+
canonicalExistingAttributionPath(value),
|
|
110
|
+
]) {
|
|
111
|
+
if (!candidate)
|
|
112
|
+
continue;
|
|
113
|
+
const key = normalizedAttributionPathKey(candidate);
|
|
114
|
+
if (seen.has(key))
|
|
115
|
+
continue;
|
|
116
|
+
seen.add(key);
|
|
117
|
+
aliases.push(candidate);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return aliases;
|
|
121
|
+
}
|
|
122
|
+
function canonicalExistingAttributionPath(value) {
|
|
123
|
+
const windowsPath = isWindowsAbsolutePath(value);
|
|
124
|
+
if (windowsPath !== (process.platform === "win32"))
|
|
125
|
+
return null;
|
|
126
|
+
try {
|
|
127
|
+
return attributionPathApi(value).resolve(realpathSync(value));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Deliberately silent (BLI-3238). `realpathSync` is the test for "does
|
|
131
|
+
// this path resolve to something real?", and `null` — "no canonical form"
|
|
132
|
+
// — is the answer, not a degradation. Callers already treat a null here
|
|
133
|
+
// as one more alias that does not apply.
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function attributionPathApi(value) {
|
|
138
|
+
return isWindowsAbsolutePath(value) ? path.win32 : path.posix;
|
|
139
|
+
}
|
|
140
|
+
export function resolveAttributionPath(value) {
|
|
141
|
+
return attributionPathApi(value).resolve(value);
|
|
142
|
+
}
|
|
143
|
+
export function basenameForAttributionPath(value) {
|
|
144
|
+
return attributionPathApi(value).basename(value);
|
|
145
|
+
}
|
|
146
|
+
export function normalizedAttributionPathKey(value) {
|
|
147
|
+
const resolved = resolveAttributionPath(value);
|
|
148
|
+
return isWindowsAbsolutePath(value) ? resolved.toLowerCase() : resolved;
|
|
149
|
+
}
|
|
150
|
+
function isWindowsAbsolutePath(value) {
|
|
151
|
+
return (/^[A-Za-z]:[\\/]/.test(value) ||
|
|
152
|
+
/^[\\/]{2}[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/.test(value));
|
|
153
|
+
}
|
|
154
|
+
export function clampScore(value) {
|
|
155
|
+
return Math.min(1, Math.max(0, Number(value.toFixed(4))));
|
|
156
|
+
}
|
|
157
|
+
export function sessionIdFromFileName(fileName) {
|
|
158
|
+
const match = fileName.match(SESSION_FILE_UUID_PATTERN);
|
|
159
|
+
return match?.[1]?.toLowerCase() ?? null;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Session ids come from file content and end up inside remote object keys, so
|
|
163
|
+
* anything outside the safe charset falls back to a hash of the raw value
|
|
164
|
+
* instead of poisoning every begin request in the batch.
|
|
165
|
+
*/
|
|
166
|
+
export function sanitizeSessionId(value) {
|
|
167
|
+
if (!value)
|
|
168
|
+
return null;
|
|
169
|
+
if (/^[A-Za-z0-9._-]{4,80}$/.test(value))
|
|
170
|
+
return value;
|
|
171
|
+
return shortHash(value);
|
|
172
|
+
}
|
|
173
|
+
export function shortHash(value) {
|
|
174
|
+
return crypto
|
|
175
|
+
.createHash("sha256")
|
|
176
|
+
.update(value, "utf8")
|
|
177
|
+
.digest("hex")
|
|
178
|
+
.slice(0, 16);
|
|
179
|
+
}
|
|
180
|
+
export function sha256(value) {
|
|
181
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
182
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { ATTRIBUTION_MIN_MARGIN, ATTRIBUTION_MIN_SCORE, SCORE_BRANCH_MATCH, SCORE_CWD_MATCH, SCORE_HEAD_SHA_MATCH, SCORE_ORIGIN_MATCH, SCORE_WORKSPACE_ROOT_MATCH, } from "./attribution-core-types.js";
|
|
2
|
+
import { clampScore, collectionRootComparisonAliases, deepestContainers, pathComparisonAliases, recordedPathsWithinCollectionRoots, worktreesWithinCollectionScope, } from "./attribution-core-paths.js";
|
|
3
|
+
import { attachApprovedRootWorkspace, fallbackToFolderWorkspaceForContainerCwd, fallbackToKnownRepoPrimaryWorkContext, fallbackToTranscriptOriginForDeletedRepo, skipped, terminalReasonForRecordedPaths, unattributed, } from "./attribution-core-fallbacks.js";
|
|
4
|
+
/**
|
|
5
|
+
* The scoring entry point. Both Codex and Claude hand their normalized
|
|
6
|
+
* signals here and get scored against the same candidate worktrees with the
|
|
7
|
+
* same weights, threshold, and margin — one scorer means cross-source
|
|
8
|
+
* comparison is honest: a session attributes the same way no matter which
|
|
9
|
+
* 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. When no
|
|
14
|
+
* worktree wins outright, the fallback siblings get a turn in order: a
|
|
15
|
+
* wrapper-folder workspace, a known repo's primary clone, or a transcript
|
|
16
|
+
* origin standing in for a deleted repo.
|
|
17
|
+
*/
|
|
18
|
+
export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
|
|
19
|
+
const originLabel = options.originLabel ?? "origin_url_match";
|
|
20
|
+
const collectionRoots = collectionRootComparisonAliases(options.collectionRoots ?? []);
|
|
21
|
+
const recordedPaths = [...signals.cwds, ...signals.workspaceRoots];
|
|
22
|
+
const approvedRecordedPaths = recordedPathsWithinCollectionRoots(recordedPaths, collectionRoots);
|
|
23
|
+
const scopedWorktrees = worktreesWithinCollectionScope(worktrees, collectionRoots);
|
|
24
|
+
const cwdComparisonPaths = pathComparisonAliases(signals.cwds);
|
|
25
|
+
const workspaceRootComparisonPaths = pathComparisonAliases(signals.workspaceRoots);
|
|
26
|
+
const hasAnySignal = signals.cwds.length > 0 ||
|
|
27
|
+
signals.workspaceRoots.length > 0 ||
|
|
28
|
+
signals.originUrls.length > 0 ||
|
|
29
|
+
signals.branches.length > 0 ||
|
|
30
|
+
signals.headShas.length > 0;
|
|
31
|
+
if (!hasAnySignal) {
|
|
32
|
+
return unattributed("no_repo_signals");
|
|
33
|
+
}
|
|
34
|
+
// D6 deepest-root tie-break: a path signal can sit inside several candidate
|
|
35
|
+
// roots when one root is nested inside another (e.g. Claude's
|
|
36
|
+
// `<repo>/.claude/worktrees/<name>` linked worktrees, or any monorepo with a
|
|
37
|
+
// nested package that is itself a worktree). Crediting every containing root
|
|
38
|
+
// leaves the session ambiguous forever. Instead, only the DEEPEST containing
|
|
39
|
+
// root earns the path score, which is also the most specific (correct) match.
|
|
40
|
+
const cwdWinners = deepestContainers(cwdComparisonPaths, scopedWorktrees);
|
|
41
|
+
const workspaceRootWinners = deepestContainers(workspaceRootComparisonPaths, scopedWorktrees);
|
|
42
|
+
const scored = scopedWorktrees.map((worktree) => {
|
|
43
|
+
const matched = [];
|
|
44
|
+
let score = 0;
|
|
45
|
+
let pathScore = 0;
|
|
46
|
+
if (cwdWinners.has(worktree.worktree_fingerprint)) {
|
|
47
|
+
score += SCORE_CWD_MATCH;
|
|
48
|
+
pathScore += SCORE_CWD_MATCH;
|
|
49
|
+
matched.push("cwd_match");
|
|
50
|
+
}
|
|
51
|
+
if (workspaceRootWinners.has(worktree.worktree_fingerprint)) {
|
|
52
|
+
score += SCORE_WORKSPACE_ROOT_MATCH;
|
|
53
|
+
pathScore += SCORE_WORKSPACE_ROOT_MATCH;
|
|
54
|
+
matched.push("workspace_root_match");
|
|
55
|
+
}
|
|
56
|
+
if (worktree.repo_origin_url &&
|
|
57
|
+
signals.originUrls.includes(worktree.repo_origin_url)) {
|
|
58
|
+
score += SCORE_ORIGIN_MATCH;
|
|
59
|
+
matched.push(originLabel);
|
|
60
|
+
}
|
|
61
|
+
if (signals.branches.includes(worktree.branch)) {
|
|
62
|
+
score += SCORE_BRANCH_MATCH;
|
|
63
|
+
matched.push("branch_match");
|
|
64
|
+
}
|
|
65
|
+
if (worktree.head_sha && signals.headShas.includes(worktree.head_sha)) {
|
|
66
|
+
score += SCORE_HEAD_SHA_MATCH;
|
|
67
|
+
matched.push("head_sha_match");
|
|
68
|
+
}
|
|
69
|
+
return { worktree, score, pathScore, matched };
|
|
70
|
+
});
|
|
71
|
+
scored.sort((a, b) => b.score - a.score);
|
|
72
|
+
const best = scored[0];
|
|
73
|
+
const secondBestScore = scored[1]?.score ?? 0;
|
|
74
|
+
if (!best || best.score === 0) {
|
|
75
|
+
if (approvedRecordedPaths.length > 0) {
|
|
76
|
+
const folderFallback = fallbackToFolderWorkspaceForContainerCwd({
|
|
77
|
+
signals,
|
|
78
|
+
worktrees: scopedWorktrees,
|
|
79
|
+
approvedRecordedPaths,
|
|
80
|
+
});
|
|
81
|
+
if (folderFallback)
|
|
82
|
+
return folderFallback;
|
|
83
|
+
const transcriptFallback = fallbackToTranscriptOriginForDeletedRepo({
|
|
84
|
+
originLabel,
|
|
85
|
+
signals,
|
|
86
|
+
collectionRoots,
|
|
87
|
+
pathExists: options.pathExists,
|
|
88
|
+
});
|
|
89
|
+
if (transcriptFallback) {
|
|
90
|
+
return attachApprovedRootWorkspace({
|
|
91
|
+
outcome: transcriptFallback,
|
|
92
|
+
signals,
|
|
93
|
+
collectionRoots,
|
|
94
|
+
approvedRecordedPaths,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return attachApprovedRootWorkspace({
|
|
98
|
+
outcome: skipped(terminalReasonForRecordedPaths(approvedRecordedPaths, options.pathExists)),
|
|
99
|
+
signals,
|
|
100
|
+
collectionRoots,
|
|
101
|
+
approvedRecordedPaths,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const reason = signals.cwds.length > 0 || signals.workspaceRoots.length > 0
|
|
105
|
+
? "cwd_outside_scanned_worktrees"
|
|
106
|
+
: "no_matching_worktree_signals";
|
|
107
|
+
return unattributed(reason);
|
|
108
|
+
}
|
|
109
|
+
const score = clampScore(best.score);
|
|
110
|
+
const pathScore = clampScore(best.pathScore);
|
|
111
|
+
if (best.pathScore > 0 &&
|
|
112
|
+
best.score >= ATTRIBUTION_MIN_SCORE &&
|
|
113
|
+
best.score - secondBestScore >= ATTRIBUTION_MIN_MARGIN) {
|
|
114
|
+
return {
|
|
115
|
+
state: "attributed",
|
|
116
|
+
reason: "deterministic_signal_match",
|
|
117
|
+
signals: best.matched,
|
|
118
|
+
attribution_score: score,
|
|
119
|
+
path_score: pathScore,
|
|
120
|
+
worktree: best.worktree,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (best.pathScore === 0) {
|
|
124
|
+
if (collectionRoots.length > 0 && approvedRecordedPaths.length === 0) {
|
|
125
|
+
return unattributed(recordedPaths.length > 0
|
|
126
|
+
? "cwd_outside_scanned_worktrees"
|
|
127
|
+
: "no_matching_worktree_signals");
|
|
128
|
+
}
|
|
129
|
+
const fallback = fallbackToKnownRepoPrimaryWorkContext({
|
|
130
|
+
originLabel,
|
|
131
|
+
scored,
|
|
132
|
+
signals,
|
|
133
|
+
worktrees: scopedWorktrees,
|
|
134
|
+
});
|
|
135
|
+
if (fallback) {
|
|
136
|
+
return attachApprovedRootWorkspace({
|
|
137
|
+
outcome: fallback,
|
|
138
|
+
signals,
|
|
139
|
+
collectionRoots,
|
|
140
|
+
approvedRecordedPaths,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return attachApprovedRootWorkspace({
|
|
145
|
+
outcome: {
|
|
146
|
+
state: "ambiguous",
|
|
147
|
+
reason: best.score - secondBestScore < ATTRIBUTION_MIN_MARGIN
|
|
148
|
+
? "multiple_worktrees_close_scores"
|
|
149
|
+
: "signal_score_below_threshold",
|
|
150
|
+
signals: best.matched,
|
|
151
|
+
attribution_score: score,
|
|
152
|
+
path_score: pathScore,
|
|
153
|
+
worktree: null,
|
|
154
|
+
},
|
|
155
|
+
signals,
|
|
156
|
+
collectionRoots,
|
|
157
|
+
approvedRecordedPaths,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scoring weights, thresholds, and shapes `attribution-core.ts` and its
|
|
3
|
+
* siblings (score, fallbacks, paths) all agree on.
|
|
4
|
+
*/
|
|
5
|
+
export const SCORE_CWD_MATCH = 0.5;
|
|
6
|
+
export const SCORE_WORKSPACE_ROOT_MATCH = 0.1;
|
|
7
|
+
export const SCORE_ORIGIN_MATCH = 0.3;
|
|
8
|
+
export const SCORE_BRANCH_MATCH = 0.15;
|
|
9
|
+
export const SCORE_HEAD_SHA_MATCH = 0.05;
|
|
10
|
+
export const ATTRIBUTION_MIN_SCORE = 0.4;
|
|
11
|
+
export const ATTRIBUTION_MIN_MARGIN = 0.15;
|
|
12
|
+
export const ATTRIBUTED_FALLBACK_MAX_SCORE = 0.39;
|
|
13
|
+
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;
|