@bli-cockpit/cli 0.2.53 → 0.2.55

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.
Files changed (60) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +247 -0
  2. package/dist/adapters/attribution-core-paths.js +182 -0
  3. package/dist/adapters/attribution-core-score.js +159 -0
  4. package/dist/adapters/attribution-core-types.js +13 -0
  5. package/dist/adapters/attribution-core.js +13 -565
  6. package/dist/adapters/claude-attribution-discovery.js +186 -0
  7. package/dist/adapters/claude-attribution-score.js +204 -0
  8. package/dist/adapters/claude-attribution-signals.js +180 -0
  9. package/dist/adapters/claude-attribution-types.js +25 -0
  10. package/dist/adapters/claude-attribution.js +14 -569
  11. package/dist/commands/doctor-access.js +129 -0
  12. package/dist/commands/doctor-pipeline.js +326 -0
  13. package/dist/commands/doctor-registration.js +105 -0
  14. package/dist/commands/doctor-report.js +111 -0
  15. package/dist/commands/doctor-update.js +120 -0
  16. package/dist/commands/doctor.js +8 -753
  17. package/dist/commands/heartbeat.js +8 -0
  18. package/dist/commands/jarvis-contracts.js +8 -0
  19. package/dist/commands/jarvis-render.js +413 -0
  20. package/dist/commands/jarvis-turn.js +305 -0
  21. package/dist/commands/jarvis.js +23 -698
  22. package/dist/commands/local-args-collector-setup.js +250 -0
  23. package/dist/commands/local-args-collector-status.js +227 -0
  24. package/dist/commands/local-args-collector-work.js +175 -0
  25. package/dist/commands/local-args-collector.js +19 -624
  26. package/dist/commands/local-args-tower-admin.js +456 -0
  27. package/dist/commands/local-args-tower-chat.js +194 -0
  28. package/dist/commands/local-args-tower-pages.js +314 -0
  29. package/dist/commands/local-args-tower.js +13 -880
  30. package/dist/commands/local-help.js +10 -2
  31. package/dist/commands/onboard-completion.js +136 -0
  32. package/dist/commands/onboard-flows.js +165 -0
  33. package/dist/commands/onboard-setup.js +102 -0
  34. package/dist/commands/onboard.js +5 -392
  35. package/dist/commands/public-root.js +1 -1
  36. package/dist/commands/session-sync-counters.js +55 -0
  37. package/dist/commands/session-sync-health.js +8 -1
  38. package/dist/commands/session-sync-plan.js +47 -7
  39. package/dist/commands/session-sync-scan.js +4 -4
  40. package/dist/commands/session-sync.js +6 -0
  41. package/dist/commands/settings-render.js +27 -0
  42. package/dist/commands/sync-followups.js +5 -1
  43. package/dist/commands/sync.js +5 -1
  44. package/dist/commands/team-device-reasons.js +16 -0
  45. package/dist/commands/team.js +87 -7
  46. package/dist/evidence-upload-client.js +14 -763
  47. package/dist/evidence-upload-object.js +181 -0
  48. package/dist/evidence-upload-plan.js +233 -0
  49. package/dist/evidence-upload-terminal.js +309 -0
  50. package/dist/evidence-upload-transport.js +104 -0
  51. package/dist/spool/local-spool-io.js +122 -0
  52. package/dist/spool/local-spool-mutations.js +174 -0
  53. package/dist/spool/local-spool-parse.js +143 -0
  54. package/dist/spool/local-spool-types.js +22 -0
  55. package/dist/spool/local-spool.js +20 -426
  56. package/dist/upload-evidence-delivery-offer.js +144 -0
  57. package/dist/upload-evidence-delivery-reconcile.js +134 -0
  58. package/dist/upload-evidence-delivery-summary.js +205 -0
  59. package/dist/upload-evidence-delivery.js +12 -482
  60. 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;