@bli-cockpit/cli 0.1.32 → 0.1.33

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.
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import path from "node:path";
3
- import { normalizeGitOrigin, repoFingerprintFromOrigin, repoLabelFromOrigin, stableWorktreeFingerprint, } from "../repo-identity.js";
3
+ import { normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, stableWorktreeFingerprint, } from "../repo-identity.js";
4
4
  /**
5
5
  * Shared deterministic attribution core for agent session transcripts (Codex
6
6
  * and Claude Code). Both adapters extract source-shaped signals, normalize them
@@ -73,14 +73,21 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
73
73
  const best = scored[0];
74
74
  const secondBestScore = scored[1]?.score ?? 0;
75
75
  if (!best || best.score === 0) {
76
- if (repoPathAbsentFromDisk([...signals.cwds, ...signals.workspaceRoots], options.collectionRoots ?? [])) {
76
+ const recordedPaths = [...signals.cwds, ...signals.workspaceRoots];
77
+ if (repoPathAbsentFromDisk(recordedPaths, options.collectionRoots ?? [])) {
78
+ const folderFallback = fallbackToFolderWorkspaceForContainerCwd({
79
+ signals,
80
+ worktrees,
81
+ });
82
+ if (folderFallback)
83
+ return folderFallback;
77
84
  const transcriptFallback = fallbackToTranscriptOriginForDeletedRepo({
78
85
  originLabel,
79
86
  signals,
80
87
  });
81
88
  if (transcriptFallback)
82
89
  return transcriptFallback;
83
- return skipped("repo_not_on_disk");
90
+ return skipped(terminalReasonForRecordedPaths(recordedPaths, options.pathExists));
84
91
  }
85
92
  const reason = signals.cwds.length > 0 || signals.workspaceRoots.length > 0
86
93
  ? "cwd_outside_scanned_worktrees"
@@ -142,6 +149,63 @@ function skipped(reason) {
142
149
  worktree: null,
143
150
  };
144
151
  }
152
+ /**
153
+ * Handles sessions launched from wrapper folders that intentionally contain
154
+ * several repos. The wrapper path is the user's working context even though it
155
+ * is not itself a git repo, and the consent boundary is unchanged because this
156
+ * only runs after the recorded path was proven under an approved collection
157
+ * root.
158
+ */
159
+ function fallbackToFolderWorkspaceForContainerCwd(options) {
160
+ const recordedPaths = [...options.signals.cwds, ...options.signals.workspaceRoots];
161
+ let bestFolder = null;
162
+ for (const recordedPath of recordedPaths) {
163
+ const resolvedPath = path.resolve(recordedPath);
164
+ const containedWorktrees = options.worktrees.filter((worktree) => isPathStrictlyWithin(worktree.repo_root, resolvedPath));
165
+ if (containedWorktrees.length === 0)
166
+ continue;
167
+ const depth = pathDepth(resolvedPath);
168
+ if (!bestFolder || depth > bestFolder.depth) {
169
+ bestFolder = { resolvedPath, depth, containedWorktrees };
170
+ }
171
+ }
172
+ if (!bestFolder)
173
+ return null;
174
+ const repoFingerprints = new Set(bestFolder.containedWorktrees.map((worktree) => worktree.repo_fingerprint));
175
+ if (repoFingerprints.size === 1) {
176
+ const primary = sortWorktreesForFolderFallback(bestFolder.containedWorktrees)[0];
177
+ if (!primary)
178
+ return null;
179
+ return {
180
+ state: "attributed_fallback",
181
+ reason: "single_repo_folder_fallback",
182
+ signals: ["single_repo_folder_fallback"],
183
+ attribution_score: clampScore(Math.min(SCORE_CWD_MATCH, ATTRIBUTED_FALLBACK_MAX_SCORE)),
184
+ path_score: 0,
185
+ worktree: primary,
186
+ };
187
+ }
188
+ const repoLabel = path.basename(bestFolder.resolvedPath);
189
+ return {
190
+ state: "attributed_fallback",
191
+ reason: "multi_repo_folder_workspace",
192
+ signals: ["multi_repo_folder_workspace"],
193
+ attribution_score: clampScore(ATTRIBUTED_FALLBACK_MAX_SCORE),
194
+ path_score: 0,
195
+ worktree: {
196
+ requested_path: bestFolder.resolvedPath,
197
+ repo_root: bestFolder.resolvedPath,
198
+ repo_label: repoLabel,
199
+ repo_fingerprint: repoFingerprintFromLocalRoot(bestFolder.resolvedPath),
200
+ repo_origin_url: null,
201
+ branch: options.signals.branches[0] ?? "unknown",
202
+ head_sha: options.signals.headShas[0] ?? null,
203
+ worktree_label: repoLabel,
204
+ worktree_fingerprint: stableWorktreeFingerprint(bestFolder.resolvedPath),
205
+ worktree_is_primary: true,
206
+ },
207
+ };
208
+ }
145
209
  function fallbackToKnownRepoPrimaryWorkContext(options) {
146
210
  const originCandidates = options.worktrees.filter((worktree) => worktree.repo_origin_url &&
147
211
  options.signals.originUrls.includes(worktree.repo_origin_url));
@@ -223,6 +287,23 @@ function fallbackToTranscriptOriginForDeletedRepo(options) {
223
287
  },
224
288
  };
225
289
  }
290
+ function terminalReasonForRecordedPaths(paths, pathExists) {
291
+ // A recorded path under a collection root can mean two different things:
292
+ // deleted/missing repo (retryable) or an existing non-repo folder. The latter
293
+ // is common with wrapper-folder workflows, so adapters inject existence only
294
+ // at the boundary and the pure scorer keeps the old label when no hook exists.
295
+ if (!pathExists)
296
+ return "repo_not_on_disk";
297
+ const anyRecordedPathExists = paths.some((value) => {
298
+ try {
299
+ return pathExists(value);
300
+ }
301
+ catch {
302
+ return false;
303
+ }
304
+ });
305
+ return anyRecordedPathExists ? "cwd_not_a_repo" : "repo_not_on_disk";
306
+ }
226
307
  /**
227
308
  * For each path signal, the single worktree whose root most specifically
228
309
  * contains it (the longest containing root). Returns the set of worktree
@@ -254,12 +335,26 @@ export function isPathWithin(candidate, root) {
254
335
  return (normalizedCandidate === normalizedRoot ||
255
336
  normalizedCandidate.startsWith(normalizedRoot + path.sep));
256
337
  }
338
+ function isPathStrictlyWithin(candidate, root) {
339
+ const normalizedCandidate = path.resolve(candidate);
340
+ const normalizedRoot = path.resolve(root);
341
+ return (normalizedCandidate !== normalizedRoot &&
342
+ normalizedCandidate.startsWith(normalizedRoot + path.sep));
343
+ }
257
344
  function repoPathAbsentFromDisk(paths, collectionRoots) {
258
345
  if (paths.length === 0 || collectionRoots.length === 0)
259
346
  return false;
260
347
  const roots = collectionRoots.map((root) => path.resolve(root));
261
348
  return paths.some((value) => roots.some((root) => isPathWithin(value, root)));
262
349
  }
350
+ function sortWorktreesForFolderFallback(worktrees) {
351
+ return [...worktrees].sort((a, b) => Number(b.worktree_is_primary) - Number(a.worktree_is_primary) ||
352
+ path.resolve(a.repo_root).localeCompare(path.resolve(b.repo_root)) ||
353
+ a.worktree_fingerprint.localeCompare(b.worktree_fingerprint));
354
+ }
355
+ function pathDepth(value) {
356
+ return path.resolve(value).split(path.sep).filter(Boolean).length;
357
+ }
263
358
  export function clampScore(value) {
264
359
  return Math.min(1, Math.max(0, Number(value.toFixed(4))));
265
360
  }
@@ -1,7 +1,7 @@
1
1
  import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, SECRET_FILE_SEGMENT_PATTERN, containsSecretLikeContent, } from "@bli-cockpit/telemetry-core";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
- import { createReadStream } from "node:fs";
4
+ import { createReadStream, existsSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { StringDecoder } from "node:string_decoder";
7
7
  import { SESSION_FILE_UUID_PATTERN, isPathWithin, sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName, shortHash, } from "./attribution-core.js";
@@ -304,7 +304,7 @@ async function attributeOneSession(session, worktrees, collectionRoots) {
304
304
  originUrls: signals.repository_urls,
305
305
  branches: signals.branches,
306
306
  headShas: [],
307
- }, worktrees, { collectionRoots, originLabel: "pr_repo_match" });
307
+ }, worktrees, { collectionRoots, originLabel: "pr_repo_match", pathExists: existsSync });
308
308
  const extraSignals = [];
309
309
  if (base.main_file_oversized)
310
310
  extraSignals.push("main_file_oversized");
@@ -1,5 +1,5 @@
1
1
  import { SECRET_FILE_SEGMENT_PATTERN, } from "@bli-cockpit/telemetry-core";
2
- import { createReadStream } from "node:fs";
2
+ import { createReadStream, existsSync } from "node:fs";
3
3
  import crypto from "node:crypto";
4
4
  import fs from "node:fs/promises";
5
5
  import path from "node:path";
@@ -201,7 +201,7 @@ async function attributeOneSession(file, worktrees, collectionRoots) {
201
201
  originUrls: signals.repository_urls,
202
202
  branches: signals.branches,
203
203
  headShas: signals.commit_hashes,
204
- }, worktrees, { collectionRoots });
204
+ }, worktrees, { collectionRoots, pathExists: existsSync });
205
205
  return { ...base, ...outcome };
206
206
  }
207
207
  async function readCodexMetadataSignals(filePath) {
@@ -566,6 +566,30 @@ function reasonClassification(reason) {
566
566
  note: "repo must exist on disk",
567
567
  };
568
568
  }
569
+ if (reason === "cwd_not_a_repo") {
570
+ return {
571
+ classification: "permanent",
572
+ note: "cwd exists but is not a repo or folder workspace",
573
+ };
574
+ }
575
+ if (reason === "multiple_transcript_origins") {
576
+ return {
577
+ classification: "permanent",
578
+ note: "multiple transcript origins; attribution is ambiguous",
579
+ };
580
+ }
581
+ if (reason === "single_repo_folder_fallback") {
582
+ return {
583
+ classification: "permanent",
584
+ note: "uploadable single-repo folder workspace fallback",
585
+ };
586
+ }
587
+ if (reason === "multi_repo_folder_workspace") {
588
+ return {
589
+ classification: "permanent",
590
+ note: "uploadable multi-repo folder workspace fallback",
591
+ };
592
+ }
569
593
  if (reason.startsWith("deferred_")) {
570
594
  return {
571
595
  classification: "retryable",
@@ -140,7 +140,7 @@ async function hasGitMarker(dir) {
140
140
  async function fallbackFilesystemIdentity(repoRoot) {
141
141
  const resolvedRoot = await stableWorktreeRoot(repoRoot);
142
142
  const repoLabel = path.basename(resolvedRoot) || "repo";
143
- const repoFingerprint = `repo-${sha256(`local:${resolvedRoot}`).slice(0, 24)}`;
143
+ const repoFingerprint = repoFingerprintFromLocalRoot(resolvedRoot);
144
144
  return {
145
145
  requested_path: resolvedRoot,
146
146
  repo_root: resolvedRoot,
@@ -161,6 +161,9 @@ export async function stableWorktreeRoot(repoRoot) {
161
161
  export function stableWorktreeFingerprint(repoRoot) {
162
162
  return `wt-${sha256(`worktree:${normalizeFingerprintPath(repoRoot)}`).slice(0, 24)}`;
163
163
  }
164
+ export function repoFingerprintFromLocalRoot(root) {
165
+ return `repo-${sha256(`local:${path.resolve(root)}`).slice(0, 24)}`;
166
+ }
164
167
  export function repoFingerprintFromOrigin(origin) {
165
168
  const normalizedOrigin = normalizeGitOrigin(origin);
166
169
  return `repo-${sha256(`origin:${normalizedOrigin}`).slice(0, 24)}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {