@bli-cockpit/cli 0.2.56 → 0.2.58

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 (43) hide show
  1. package/dist/commands/agent-door.js +85 -0
  2. package/dist/commands/docs.js +227 -0
  3. package/dist/commands/issue-contracts.js +99 -0
  4. package/dist/commands/issue-write.js +129 -0
  5. package/dist/commands/issue.js +189 -0
  6. package/dist/commands/local-args-tower-docs-msg.js +126 -0
  7. package/dist/commands/local-args-tower-work.js +178 -0
  8. package/dist/commands/local-args-tower.js +7 -1
  9. package/dist/commands/local-args.js +10 -2
  10. package/dist/commands/local-help.js +70 -0
  11. package/dist/commands/local.js +12 -0
  12. package/dist/commands/mcp-bin-resolve.js +102 -0
  13. package/dist/commands/memory-install-claude.js +13 -5
  14. package/dist/commands/memory-install-config.js +140 -0
  15. package/dist/commands/memory-install-report.js +89 -0
  16. package/dist/commands/memory-install.js +51 -362
  17. package/dist/commands/msg.js +188 -0
  18. package/dist/commands/notes-door.js +120 -0
  19. package/dist/commands/notes-reads.js +134 -0
  20. package/dist/commands/notes-writes.js +208 -0
  21. package/dist/commands/notes.js +16 -442
  22. package/dist/commands/ops-render.js +18 -2
  23. package/dist/commands/ops.js +9 -2
  24. package/dist/commands/project.js +38 -0
  25. package/dist/commands/public-root.js +1 -1
  26. package/dist/commands/tower-mcp-claude.js +30 -0
  27. package/dist/commands/tower-mcp-codex.js +100 -0
  28. package/dist/commands/tower-mcp-contract.js +39 -0
  29. package/dist/commands/tower-mcp-install.js +75 -0
  30. package/dist/repo-identity-fingerprint.js +88 -0
  31. package/dist/repo-identity-git.js +76 -0
  32. package/dist/repo-identity-linked-worktrees.js +81 -0
  33. package/dist/repo-identity.js +5 -222
  34. package/dist/upload-envelope-build.js +240 -0
  35. package/dist/upload-envelope-event.js +198 -0
  36. package/dist/upload-envelope.js +16 -427
  37. package/dist/upload-ingest-receipt.js +121 -0
  38. package/dist/upload-session-reports-queue.js +156 -0
  39. package/dist/upload-session-reports-wire.js +275 -0
  40. package/dist/upload-session-reports.js +14 -425
  41. package/dist/upload-sync.js +291 -0
  42. package/dist/upload.js +24 -396
  43. package/package.json +6 -5
@@ -1,11 +1,9 @@
1
- import { execFile } from "node:child_process";
2
- import crypto from "node:crypto";
3
1
  import fs from "node:fs/promises";
4
2
  import path from "node:path";
5
- import { promisify } from "node:util";
6
- import { containsPath, isCodexWorktreePath } from "./root-normalization.js";
7
- import { describeError, isMissingFileFailure } from "./health-detail.js";
8
- const execFileAsync = promisify(execFile);
3
+ import { hasGitMarker, resolveGitBranchWithGit, resolveBranchFromHead, runGit } from "./repo-identity-git.js";
4
+ import { repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, normalizeGitOrigin, sha256, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity-fingerprint.js";
5
+ import { expandLinkedWorktrees } from "./repo-identity-linked-worktrees.js";
6
+ export { canonicalizeCollectionRootPaths, collectionRootPathAliases, normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity-fingerprint.js";
9
7
  const SKIPPED_DIR_NAMES = new Set([
10
8
  ".cache",
11
9
  ".git",
@@ -203,112 +201,6 @@ export async function discoverGitWorktreesInRootsWithStatus(roots, options = {})
203
201
  unreadable_dirs: unreadableDirs,
204
202
  };
205
203
  }
206
- /**
207
- * Canonicalizes existing collection roots through the filesystem so transcript
208
- * paths and consent roots use the same spelling (for example `/private/var`
209
- * versus the `/var` symlink on macOS). Missing roots remain resolved as typed.
210
- */
211
- export async function canonicalizeCollectionRootPaths(roots) {
212
- const canonical = await Promise.all(roots.map(stableWorktreeRoot));
213
- return [...new Set(canonical)];
214
- }
215
- /**
216
- * Returns the operator-entered resolved paths plus filesystem-canonical aliases.
217
- * Both are needed for absent child repos because the child itself cannot be
218
- * realpathed after deletion, while its transcript may retain either spelling.
219
- */
220
- export async function collectionRootPathAliases(roots) {
221
- const resolved = roots.map((root) => path.resolve(root));
222
- const canonical = await canonicalizeCollectionRootPaths(resolved);
223
- return [...new Set([...resolved, ...canonical])];
224
- }
225
- /**
226
- * Directory discovery skips dot-dirs, so Claude Code's isolation worktrees
227
- * (`<repo>/.claude/worktrees/<name>`) and any other linked worktree are never
228
- * found by walking the filesystem. A session whose cwd sits inside one would
229
- * otherwise satisfy `isPathWithin(cwd, parentRoot)` and attribute confidently
230
- * to the PARENT with the wrong branch and worktree fingerprint. Enumerating
231
- * `git worktree list --porcelain` for each discovered repo adds the linked
232
- * worktrees as first-class candidates with their own branch/fingerprint; the
233
- * deepest-root tie-break (attribution-core D6) then attributes nested-worktree
234
- * sessions to the correct linked worktree. Benefits Codex sessions identically.
235
- */
236
- async function expandLinkedWorktrees(identities, maxWorktrees, allowedRoots) {
237
- const byFingerprint = new Map(identities.map((identity) => [identity.worktree_fingerprint, identity]));
238
- const seenRoots = new Set(identities.map((identity) => localPathKey(identity.repo_root)));
239
- let maxWorktreesReached = identities.length > maxWorktrees;
240
- for (const identity of identities.slice(0, maxWorktrees)) {
241
- // One `git worktree list` from any worktree returns every worktree of that
242
- // repo, so a single call per already-discovered repo covers its linked set.
243
- for (const worktreePath of await listLinkedWorktreePaths(identity.repo_root)) {
244
- const resolved = path.resolve(worktreePath);
245
- const rootKey = localPathKey(resolved);
246
- if (seenRoots.has(rootKey))
247
- continue;
248
- seenRoots.add(rootKey);
249
- const linked = await resolveRepoWorktreeIdentity(resolved).catch(() => null);
250
- if (!linked ||
251
- byFingerprint.has(linked.worktree_fingerprint) ||
252
- !isLinkedWorktreeWithinCollectionScope(linked, identities, allowedRoots)) {
253
- continue;
254
- }
255
- if (byFingerprint.size >= maxWorktrees) {
256
- maxWorktreesReached = true;
257
- break;
258
- }
259
- byFingerprint.set(linked.worktree_fingerprint, linked);
260
- }
261
- }
262
- const worktrees = [...byFingerprint.values()]
263
- .sort(compareIdentity)
264
- .slice(0, maxWorktrees);
265
- const incompleteReasons = maxWorktreesReached
266
- ? ["max_worktrees_reached"]
267
- : [];
268
- return {
269
- worktrees,
270
- complete: incompleteReasons.length === 0,
271
- incomplete_reasons: incompleteReasons,
272
- // Expansion asks git for its own worktree list; it never walks folders, so
273
- // it has no unreadable directories of its own to report.
274
- unreadable_dirs: [],
275
- // Linked-worktree expansion is not scoped to one root; callers merge this
276
- // into a result that already knows which roots were involved.
277
- incomplete_roots: [],
278
- };
279
- }
280
- function isLinkedWorktreeWithinCollectionScope(linked, discoveredFromApprovedRoots, allowedRoots) {
281
- if (allowedRoots.some((root) => containsPath(root, linked.repo_root))) {
282
- return true;
283
- }
284
- // Codex isolation worktrees live under ~/.codex/worktrees, outside the
285
- // approved workspace parent. They remain in scope only when Git proves they
286
- // belong to a clone discovered inside an approved root. Arbitrary sibling or
287
- // personal linked worktrees do not inherit that consent.
288
- if (!isCodexWorktreePath(linked.repo_root))
289
- return false;
290
- return discoveredFromApprovedRoots.some((identity) => identity.repo_fingerprint === linked.repo_fingerprint &&
291
- allowedRoots.some((root) => containsPath(root, identity.repo_root)));
292
- }
293
- function localPathKey(value) {
294
- const resolved = path.resolve(value);
295
- return process.platform === "win32" ? resolved.toLowerCase() : resolved;
296
- }
297
- async function listLinkedWorktreePaths(repoRoot) {
298
- const porcelain = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => "");
299
- const paths = [];
300
- for (const line of porcelain.split("\n")) {
301
- if (line.startsWith("worktree ")) {
302
- const value = line.slice("worktree ".length).trim();
303
- if (value)
304
- paths.push(value);
305
- }
306
- }
307
- return paths;
308
- }
309
- async function hasGitMarker(dir) {
310
- return fs.stat(path.join(dir, ".git")).then((stat) => stat.isDirectory() || stat.isFile(), () => false);
311
- }
312
204
  async function fallbackFilesystemIdentity(repoRoot) {
313
205
  const resolvedRoot = await stableWorktreeRoot(repoRoot);
314
206
  const repoLabel = path.basename(resolvedRoot) || "repo";
@@ -326,120 +218,11 @@ async function fallbackFilesystemIdentity(repoRoot) {
326
218
  worktree_is_primary: true,
327
219
  };
328
220
  }
329
- export async function stableWorktreeRoot(repoRoot) {
330
- const resolvedRoot = path.resolve(repoRoot);
331
- return fs.realpath(resolvedRoot).catch(() => resolvedRoot);
332
- }
333
- export function stableWorktreeFingerprint(repoRoot, pathApi = path) {
334
- return `wt-${sha256(`worktree:${normalizeFingerprintPath(repoRoot, pathApi)}`).slice(0, 24)}`;
335
- }
336
- export function repoFingerprintFromLocalRoot(root, pathApi = path) {
337
- return `repo-${sha256(`local:${normalizeFingerprintPath(root, pathApi)}`).slice(0, 24)}`;
338
- }
339
- export function repoFingerprintFromOrigin(origin) {
340
- const normalizedOrigin = normalizeGitOrigin(origin);
341
- return `repo-${sha256(`origin:${normalizedOrigin}`).slice(0, 24)}`;
342
- }
343
- async function resolveBranchFromHead(repoRoot) {
344
- try {
345
- const gitPath = path.join(repoRoot, ".git");
346
- const stat = await fs.stat(gitPath);
347
- const headPath = stat.isFile()
348
- ? path.join(await resolveLinkedGitDir(gitPath), "HEAD")
349
- : path.join(gitPath, "HEAD");
350
- const head = (await fs.readFile(headPath, "utf8")).trim();
351
- if (head.startsWith("ref: refs/heads/")) {
352
- return head.slice("ref: refs/heads/".length);
353
- }
354
- return head ? `detached:${head.slice(0, 12)}` : "unknown";
355
- }
356
- catch (error) {
357
- // Not a repo → quiet, that is an ordinary approved folder. A `.git` that
358
- // exists and will not read → every session from this worktree is labelled
359
- // branch `unknown` and, until BLI-3238, nothing said why.
360
- if (!isMissingFileFailure(error)) {
361
- console.error("[repo-identity] could not read HEAD, branch recorded as unknown", JSON.stringify({
362
- reason: "git_head_unreadable",
363
- ...describeError(error),
364
- }));
365
- }
366
- return "unknown";
367
- }
368
- }
369
- async function resolveLinkedGitDir(gitFile) {
370
- const raw = await fs.readFile(gitFile, "utf8");
371
- const match = raw.match(/^gitdir:\s*(.+)$/m);
372
- if (!match)
373
- return path.dirname(gitFile);
374
- const gitDir = match[1].trim();
375
- return path.isAbsolute(gitDir) ? gitDir : path.resolve(path.dirname(gitFile), gitDir);
376
- }
377
- export function normalizeGitOrigin(rawOrigin) {
378
- const trimmed = rawOrigin.trim();
379
- if (!trimmed)
380
- return "";
381
- const scpLike = trimmed.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
382
- if (scpLike && !trimmed.includes("://")) {
383
- return normalizeOriginParts(scpLike[1] ?? "", scpLike[2] ?? "");
384
- }
385
- try {
386
- const url = new URL(trimmed);
387
- return normalizeOriginParts(url.hostname, url.pathname);
388
- }
389
- catch {
390
- // Deliberately silent (BLI-3238). `new URL` is being used as the test for
391
- // "is this origin URL-shaped?", and a plain path or an unusual remote form
392
- // failing to parse IS the answer — the fallback below is the intended
393
- // normalization for exactly that case, not a degradation.
394
- return trimmed
395
- .replace(/\.git$/i, "")
396
- .replace(/^\/+|\/+$/g, "")
397
- .toLowerCase();
398
- }
399
- }
400
- export function repoLabelFromOrigin(origin) {
401
- const segments = origin.split("/").filter(Boolean);
402
- return segments.at(-1) ?? origin;
403
- }
404
- function normalizeOriginParts(host, repoPath) {
405
- return [
406
- host.trim().toLowerCase(),
407
- repoPath
408
- .trim()
409
- .replace(/\.git$/i, "")
410
- .replace(/^\/+|\/+$/g, "")
411
- .toLowerCase(),
412
- ]
413
- .filter(Boolean)
414
- .join("/");
415
- }
416
221
  function shouldSkipDirectory(name) {
417
222
  return name.startsWith(".") || SKIPPED_DIR_NAMES.has(name);
418
223
  }
419
- function compareIdentity(a, b) {
224
+ export function compareIdentity(a, b) {
420
225
  return (a.repo_label.localeCompare(b.repo_label) ||
421
226
  Number(b.worktree_is_primary) - Number(a.worktree_is_primary) ||
422
227
  a.worktree_label.localeCompare(b.worktree_label));
423
- }
424
- async function resolveGitBranchWithGit(repoRoot) {
425
- const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot).then((value) => value.trim(), () => "");
426
- if (branch && branch !== "HEAD")
427
- return branch;
428
- const head = await runGit(["rev-parse", "--short=12", "HEAD"], repoRoot).then((value) => value.trim(), () => "");
429
- return head ? `detached:${head}` : "unknown";
430
- }
431
- async function runGit(args, cwd) {
432
- const { stdout } = await execFileAsync("git", args, {
433
- cwd,
434
- timeout: 2_000,
435
- maxBuffer: 1024 * 1024,
436
- });
437
- return stdout;
438
- }
439
- function normalizeFingerprintPath(repoRoot, pathApi = path) {
440
- const resolved = pathApi.resolve(repoRoot);
441
- return pathApi.sep === "\\" ? resolved.toLowerCase() : resolved;
442
- }
443
- function sha256(value) {
444
- return crypto.createHash("sha256").update(value, "utf8").digest("hex");
445
228
  }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Is this machine ready to upload, and — if so — `buildLocalAmbientEnvelope`:
3
+ * five steps from a bare repo root to a validated envelope.
4
+ *
5
+ * - `readPairedCollector` — prove the collector is installed and paired, with
6
+ * its own reason and retry command for each of the three ways a machine can
7
+ * fail to be ready. "sync failed" with no next step is what turns a
8
+ * five-second fix into a support thread.
9
+ * - `buildLocalAmbientEnvelope` — find the work context for this repo, run the
10
+ * source collectors, sanitize what came back and assemble the one event
11
+ * (`upload-envelope-event.ts`), then parse the envelope against its schema.
12
+ * - The work-context and provenance helpers (`makeUploadWorkContext`,
13
+ * `makeCollectorProvenance`, `safeRepoLabel`) are also called directly by
14
+ * `upload-session-reports.ts`, which builds its own provenance without a
15
+ * full envelope.
16
+ */
17
+ import { TelemetryIngestEnvelopeSchema, } from "@bli-cockpit/telemetry-core";
18
+ import path from "node:path";
19
+ import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
20
+ import { runLocalSourceCollectors } from "./adapters/local-sources.js";
21
+ import { defaultCodexSessionDirs, } from "./adapters/codex-attribution.js";
22
+ import { normalizeDashboardUrl } from "./upload-http.js";
23
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
24
+ import { makeSourceScanCompletedEvent, sanitizeRiskFlag, sanitizeSessionReference, sanitizeSourceScanResults, selectedTicketBindingCandidate, } from "./upload-envelope-event.js";
25
+ export class LocalUploadBlockedError extends Error {
26
+ blocker;
27
+ retry_hint;
28
+ constructor(blocker, message, retryHint) {
29
+ super(message);
30
+ this.name = "LocalUploadBlockedError";
31
+ this.blocker = blocker;
32
+ this.retry_hint = retryHint;
33
+ }
34
+ }
35
+ /**
36
+ * Is this machine installed, paired, and holding a session that has not lapsed?
37
+ */
38
+ async function readPairedCollector(paths) {
39
+ const config = await readLocalCollectorConfig(paths).catch((error) => {
40
+ // `not_installed` tells the operator to reinstall the CLI. That is the
41
+ // wrong instruction for a config that exists and is corrupt, and there was
42
+ // no way to tell which one this machine hit.
43
+ if (!isMissingFileFailure(error)) {
44
+ console.error("[upload-envelope] collector config present but unreadable, reporting it as not installed", JSON.stringify({
45
+ reason: "not_installed",
46
+ ...describeError(error),
47
+ }));
48
+ }
49
+ throw new LocalUploadBlockedError("not_installed", "Local collector config missing. Install/update the CLI, then run `cockpit do-everything` before `cockpit sync`.", "npm install -g @bli-cockpit/cli@latest && cockpit do-everything");
50
+ });
51
+ const sessionFile = await readLocalCollectorSessionFile(paths).catch((error) => {
52
+ // Same trap on the pairing half: `unpaired` sends the operator to
53
+ // `cockpit login`, which does not fix an unreadable session file.
54
+ if (!isMissingFileFailure(error)) {
55
+ console.error("[upload-envelope] session file present but unreadable, reporting the machine as unpaired", JSON.stringify({
56
+ reason: "unpaired",
57
+ ...describeError(error),
58
+ }));
59
+ }
60
+ throw new LocalUploadBlockedError("unpaired", "No paired collector session found. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
61
+ });
62
+ const session = await readLocalSessionReference(paths);
63
+ if (session.session_state !== "valid") {
64
+ throw new LocalUploadBlockedError("unpaired", session.session_state === "expired"
65
+ ? "Collector session expired. Run `cockpit login` or `cockpit pair` again before `cockpit sync`."
66
+ : "Collector is not paired. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
67
+ }
68
+ return { config, sessionFile, session };
69
+ }
70
+ export async function buildLocalAmbientEnvelope(options = {}) {
71
+ const now = options.now ?? new Date();
72
+ const paths = getCollectorRuntimePaths(options.homeDir);
73
+ const collector = await readPairedCollector(paths);
74
+ const { config, sessionFile, session } = collector;
75
+ const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
76
+ const activeContext = await readLocalWorkContextForRepo(paths, repoRoot).catch((error) => {
77
+ // The operator is told "run `cockpit start`", which is right when the file
78
+ // is simply absent and wrong when it exists and will not parse — the same
79
+ // advice, forever, on a machine that has already run it (BLI-3238).
80
+ if (!isMissingFileFailure(error)) {
81
+ console.error("[upload-envelope] work context present but unreadable, reporting it as missing", JSON.stringify({
82
+ reason: "missing_context",
83
+ ...describeError(error),
84
+ }));
85
+ }
86
+ throw new LocalUploadBlockedError("missing_context", "Active work context missing. Run `cockpit start --workspace \"$PWD\"` before `cockpit sync`.", "cockpit start --workspace \"$PWD\"");
87
+ });
88
+ const repoLabel = safeRepoLabel(activeContext.repo_label ?? repoRoot);
89
+ const uploadContext = makeUploadWorkContext({
90
+ activeContext,
91
+ session,
92
+ repoLabel,
93
+ now,
94
+ });
95
+ const sourceCollection = await runLocalSourceCollectors({
96
+ repoRoot,
97
+ branch: uploadContext.branch,
98
+ operatorId: session.operator_id,
99
+ operatorLabel: session.email ?? session.auth_subject_id,
100
+ sessionId: session.session_id,
101
+ workContextId: uploadContext.work_context_id,
102
+ activeWorkContext: activeContext,
103
+ rawEvidenceStateDir: paths.state_dir,
104
+ rawEvidenceSessionsDirs: defaultCodexSessionDirs(paths.home_dir),
105
+ claudeProjectsDir: path.join(paths.home_dir, ".claude", "projects"),
106
+ rawEvidenceIncludeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
107
+ rawEvidenceIncludeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
108
+ rawEvidenceCodexSessionFiles: options.codexSessionFiles,
109
+ rawEvidenceCodexAttributionScan: options.codexAttributionScan,
110
+ rawEvidenceClaudeSessionFiles: options.claudeSessionFiles,
111
+ rawEvidenceClaudeAttributionScan: options.claudeAttributionScan,
112
+ rawEvidenceSkipContentHashes: reusableContentHashes(options, {
113
+ operatorId: session.operator_id,
114
+ workContextId: uploadContext.work_context_id,
115
+ }),
116
+ rawEvidenceByteBudget: options.rawEvidenceByteBudget,
117
+ rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
118
+ rawEvidenceBudget: options.rawEvidenceBudget,
119
+ rawEvidenceDeliveryMode: options.evidenceDeliveryMode,
120
+ now,
121
+ });
122
+ const binding = sourceCollection.binding;
123
+ const ticketBinding = selectedTicketBindingCandidate(binding);
124
+ const uploadWorkContext = {
125
+ ...uploadContext,
126
+ active_ticket_id: binding.selected_ticket_id ?? undefined,
127
+ ticket_binding_candidates: ticketBinding ? [ticketBinding] : binding.candidates,
128
+ };
129
+ const safeRiskFlags = sourceCollection.risk_flags.map((flag) => sanitizeRiskFlag(flag, repoLabel));
130
+ const events = [
131
+ makeSourceScanCompletedEvent({
132
+ context: uploadWorkContext,
133
+ generatedAt: now.toISOString(),
134
+ binding,
135
+ ticketBinding,
136
+ scans: sourceCollection.scans,
137
+ gitChangedFileCount: sourceCollection.facts.git?.changed_file_count ?? 0,
138
+ gitAddedLines: sourceCollection.facts.git?.added_lines ?? 0,
139
+ gitDeletedLines: sourceCollection.facts.git?.deleted_lines ?? 0,
140
+ carOpenTicketCount: sourceCollection.facts.car?.open_ticket_count ?? 0,
141
+ rawEvidenceFacts: sourceCollection.facts.raw_evidence,
142
+ riskFlags: safeRiskFlags,
143
+ }),
144
+ ];
145
+ const envelope = TelemetryIngestEnvelopeSchema.parse({
146
+ envelope_version: "telemetry-ingest.v1",
147
+ generated_at: now.toISOString(),
148
+ collector_version: LOCAL_COLLECTOR_VERSION,
149
+ session_reference: sanitizeSessionReference(session),
150
+ work_context: uploadWorkContext,
151
+ worktree_inventory: options.worktreeInventory ?? [],
152
+ source_scan_results: sanitizeSourceScanResults(sourceCollection.scans, repoLabel),
153
+ events,
154
+ });
155
+ return {
156
+ envelope,
157
+ dashboard_url: normalizeDashboardUrl(options.dashboardUrl ?? sessionFile.dashboard_url ?? config.dashboard_url),
158
+ device_token: sessionFile.device_token,
159
+ ticket_id: binding.selected_ticket_id ?? null,
160
+ binding,
161
+ event_count: envelope.events.length,
162
+ source_scan_count: envelope.source_scan_results.length,
163
+ risk_flag_count: safeRiskFlags.length,
164
+ repo_label: repoLabel,
165
+ head_sha: uploadContext.head_sha ?? null,
166
+ raw_evidence_upload_files: sourceCollection.facts.raw_evidence?.upload_files ?? [],
167
+ raw_evidence_facts: sourceCollection.facts.raw_evidence,
168
+ };
169
+ }
170
+ /**
171
+ * Content hashes whose bytes are already durable and may be skipped this sync.
172
+ *
173
+ * Object keys embed the work context, so a cursor entry only counts as reuse
174
+ * when it was committed under THIS operator and context. The same bytes under a
175
+ * different context still need their own pointer and evidence ref.
176
+ */
177
+ function reusableContentHashes(options, context) {
178
+ if (options.skipContentHashes)
179
+ return options.skipContentHashes;
180
+ return new Set(Object.entries(options.cursorObjects ?? {})
181
+ .filter(([, entry]) => rawEvidenceObjectKeyBelongsToWorkContext(entry.object_key, context))
182
+ .map(([hash]) => hash));
183
+ }
184
+ export function makeUploadWorkContext(options) {
185
+ const provenance = makeCollectorProvenance({
186
+ context: options.activeContext,
187
+ session: options.session,
188
+ repoLabel: options.repoLabel,
189
+ });
190
+ return {
191
+ ...options.activeContext,
192
+ repo: options.repoLabel,
193
+ repo_label: options.activeContext.repo_label ?? options.repoLabel,
194
+ repo_fingerprint: options.activeContext.repo_fingerprint,
195
+ repo_origin_url: options.activeContext.repo_origin_url,
196
+ head_sha: options.activeContext.head_sha,
197
+ worktree_label: options.activeContext.worktree_label,
198
+ worktree_fingerprint: options.activeContext.worktree_fingerprint,
199
+ worktree_is_primary: options.activeContext.worktree_is_primary,
200
+ operator_id: options.session.operator_id,
201
+ session_id: options.session.session_id,
202
+ updated_at: options.now.toISOString(),
203
+ provenance,
204
+ };
205
+ }
206
+ export function makeCollectorProvenance(options) {
207
+ return {
208
+ capture_source: "collector_runtime",
209
+ capture_adapter_version: LOCAL_COLLECTOR_VERSION,
210
+ collector_version: LOCAL_COLLECTOR_VERSION,
211
+ repo: options.repoLabel,
212
+ branch: options.context.branch,
213
+ repo_label: options.context.repo_label ?? options.repoLabel,
214
+ repo_fingerprint: options.context.repo_fingerprint,
215
+ repo_origin_url: options.context.repo_origin_url,
216
+ worktree_label: options.context.worktree_label,
217
+ worktree_fingerprint: options.context.worktree_fingerprint,
218
+ worktree_is_primary: options.context.worktree_is_primary,
219
+ operator_id: options.session.operator_id,
220
+ session_id: options.session.session_id,
221
+ work_context_id: options.context.work_context_id,
222
+ };
223
+ }
224
+ /** The basename of a repo root, never the path that led to it. */
225
+ export function safeRepoLabel(repoRoot) {
226
+ const basename = path.basename(repoRoot.replace(/[\\/]+$/, ""));
227
+ return basename || "repo";
228
+ }
229
+ /**
230
+ * Does this durable object belong to the operator and work context now syncing?
231
+ *
232
+ * Two key shapes are live: the original `<operator>/<context>/...` prefix and
233
+ * the readable `operators/.../ids/<operator>/<context>/...` layout.
234
+ */
235
+ function rawEvidenceObjectKeyBelongsToWorkContext(objectKey, context) {
236
+ const legacyPrefix = `${context.operatorId}/${context.workContextId}/`;
237
+ const readableIdGuard = `/ids/${context.operatorId}/${context.workContextId}/`;
238
+ return (objectKey.startsWith(legacyPrefix) ||
239
+ (objectKey.startsWith("operators/") && objectKey.includes(readableIdGuard)));
240
+ }