@bli-cockpit/cli 0.2.28 → 0.2.30

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 (33) hide show
  1. package/README.md +22 -15
  2. package/dist/adapters/local-sources.js +1 -0
  3. package/dist/adapters/raw-evidence-completeness.js +226 -0
  4. package/dist/adapters/raw-evidence-git-diff.js +90 -0
  5. package/dist/adapters/raw-evidence-keys.js +92 -0
  6. package/dist/adapters/raw-evidence-manifest.js +132 -0
  7. package/dist/adapters/raw-evidence-pack-store.js +136 -0
  8. package/dist/adapters/raw-evidence-sanitize.js +190 -0
  9. package/dist/adapters/raw-evidence.js +656 -1257
  10. package/dist/commands/backfill.js +7 -0
  11. package/dist/commands/cli-io.js +92 -0
  12. package/dist/commands/collection-report.js +139 -0
  13. package/dist/commands/collection-roots.js +153 -0
  14. package/dist/commands/doctor.js +19 -17
  15. package/dist/commands/install-receipts.js +193 -0
  16. package/dist/commands/install-update.js +305 -0
  17. package/dist/commands/local-auth.js +268 -0
  18. package/dist/commands/local-discovery.js +100 -0
  19. package/dist/commands/local-help.js +281 -0
  20. package/dist/commands/local.js +182 -1872
  21. package/dist/commands/public-root.js +1 -1
  22. package/dist/commands/sessions.js +162 -0
  23. package/dist/commands/status.js +230 -0
  24. package/dist/evidence-upload-client.js +43 -2
  25. package/dist/raw-evidence-gc.js +1 -1
  26. package/dist/raw-evidence-staging.js +15 -2
  27. package/dist/upload-agent-artifacts.js +153 -0
  28. package/dist/upload-envelope.js +407 -0
  29. package/dist/upload-evidence-delivery.js +505 -0
  30. package/dist/upload-http.js +46 -0
  31. package/dist/upload-session-reports.js +404 -0
  32. package/dist/upload.js +132 -1264
  33. package/package.json +2 -2
@@ -0,0 +1,407 @@
1
+ /**
2
+ * What a sync sends: assembling one ambient telemetry envelope.
3
+ *
4
+ * Table of contents:
5
+ *
6
+ * - `LocalUploadBlockedError` — the four reasons this machine cannot upload at
7
+ * all, each carrying the command that fixes it.
8
+ * - `buildLocalAmbientEnvelope` — the entry point. Five steps: prove the
9
+ * collector is installed and paired, find the work context for this repo,
10
+ * run the source collectors, sanitize what came back, parse the envelope.
11
+ * - Work context and provenance — who captured this, on what machine, in which
12
+ * repo and worktree.
13
+ * - The `source_scan_completed` event — the single event every sync sends,
14
+ * built from the facts the collectors gathered.
15
+ * - Sanitizers — the last gate before anything leaves the machine. Local paths,
16
+ * long diagnostic strings and nested pointers are stripped here.
17
+ *
18
+ * Nothing in this file performs network I/O. `upload.ts` delivers what this
19
+ * file assembles.
20
+ */
21
+ import { TelemetryIngestEnvelopeSchema, TelemetryIngestEventDtoSchema, } from "@bli-cockpit/telemetry-core";
22
+ import path from "node:path";
23
+ import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
24
+ import { runLocalSourceCollectors } from "./adapters/local-sources.js";
25
+ import { defaultCodexSessionDirs, } from "./adapters/codex-attribution.js";
26
+ import { normalizeDashboardUrl } from "./upload-http.js";
27
+ export class LocalUploadBlockedError extends Error {
28
+ blocker;
29
+ retry_hint;
30
+ constructor(blocker, message, retryHint) {
31
+ super(message);
32
+ this.name = "LocalUploadBlockedError";
33
+ this.blocker = blocker;
34
+ this.retry_hint = retryHint;
35
+ }
36
+ }
37
+ export async function buildLocalAmbientEnvelope(options = {}) {
38
+ const now = options.now ?? new Date();
39
+ const paths = getCollectorRuntimePaths(options.homeDir);
40
+ const collector = await readPairedCollector(paths);
41
+ const { config, sessionFile, session } = collector;
42
+ const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
43
+ const activeContext = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => {
44
+ throw new LocalUploadBlockedError("missing_context", "Active work context missing. Run `cockpit start --workspace \"$PWD\"` before `cockpit sync`.", "cockpit start --workspace \"$PWD\"");
45
+ });
46
+ const repoLabel = safeRepoLabel(activeContext.repo_label ?? repoRoot);
47
+ const uploadContext = makeUploadWorkContext({
48
+ activeContext,
49
+ session,
50
+ repoLabel,
51
+ now,
52
+ });
53
+ const sourceCollection = await runLocalSourceCollectors({
54
+ repoRoot,
55
+ branch: uploadContext.branch,
56
+ operatorId: session.operator_id,
57
+ operatorLabel: session.email ?? session.auth_subject_id,
58
+ sessionId: session.session_id,
59
+ workContextId: uploadContext.work_context_id,
60
+ activeWorkContext: activeContext,
61
+ rawEvidenceStateDir: paths.state_dir,
62
+ rawEvidenceSessionsDirs: defaultCodexSessionDirs(paths.home_dir),
63
+ claudeProjectsDir: path.join(paths.home_dir, ".claude", "projects"),
64
+ rawEvidenceIncludeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
65
+ rawEvidenceIncludeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
66
+ rawEvidenceCodexSessionFiles: options.codexSessionFiles,
67
+ rawEvidenceCodexAttributionScan: options.codexAttributionScan,
68
+ rawEvidenceClaudeSessionFiles: options.claudeSessionFiles,
69
+ rawEvidenceClaudeAttributionScan: options.claudeAttributionScan,
70
+ rawEvidenceSkipContentHashes: reusableContentHashes(options, {
71
+ operatorId: session.operator_id,
72
+ workContextId: uploadContext.work_context_id,
73
+ }),
74
+ rawEvidenceByteBudget: options.rawEvidenceByteBudget,
75
+ rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
76
+ rawEvidenceBudget: options.rawEvidenceBudget,
77
+ rawEvidenceDeliveryMode: options.evidenceDeliveryMode,
78
+ now,
79
+ });
80
+ const binding = sourceCollection.binding;
81
+ const ticketBinding = selectedTicketBindingCandidate(binding);
82
+ const uploadWorkContext = {
83
+ ...uploadContext,
84
+ active_ticket_id: binding.selected_ticket_id ?? undefined,
85
+ ticket_binding_candidates: ticketBinding ? [ticketBinding] : binding.candidates,
86
+ };
87
+ const safeRiskFlags = sourceCollection.risk_flags.map((flag) => sanitizeRiskFlag(flag, repoLabel));
88
+ const events = [
89
+ makeSourceScanCompletedEvent({
90
+ context: uploadWorkContext,
91
+ generatedAt: now.toISOString(),
92
+ binding,
93
+ ticketBinding,
94
+ scans: sourceCollection.scans,
95
+ gitChangedFileCount: sourceCollection.facts.git?.changed_file_count ?? 0,
96
+ gitAddedLines: sourceCollection.facts.git?.added_lines ?? 0,
97
+ gitDeletedLines: sourceCollection.facts.git?.deleted_lines ?? 0,
98
+ carOpenTicketCount: sourceCollection.facts.car?.open_ticket_count ?? 0,
99
+ rawEvidenceFacts: sourceCollection.facts.raw_evidence,
100
+ riskFlags: safeRiskFlags,
101
+ }),
102
+ ];
103
+ const envelope = TelemetryIngestEnvelopeSchema.parse({
104
+ envelope_version: "telemetry-ingest.v1",
105
+ generated_at: now.toISOString(),
106
+ collector_version: LOCAL_COLLECTOR_VERSION,
107
+ session_reference: sanitizeSessionReference(session),
108
+ work_context: uploadWorkContext,
109
+ worktree_inventory: options.worktreeInventory ?? [],
110
+ source_scan_results: sanitizeSourceScanResults(sourceCollection.scans, repoLabel),
111
+ events,
112
+ });
113
+ return {
114
+ envelope,
115
+ dashboard_url: normalizeDashboardUrl(options.dashboardUrl ?? sessionFile.dashboard_url ?? config.dashboard_url),
116
+ device_token: sessionFile.device_token,
117
+ ticket_id: binding.selected_ticket_id ?? null,
118
+ binding,
119
+ event_count: envelope.events.length,
120
+ source_scan_count: envelope.source_scan_results.length,
121
+ risk_flag_count: safeRiskFlags.length,
122
+ repo_label: repoLabel,
123
+ head_sha: uploadContext.head_sha ?? null,
124
+ raw_evidence_upload_files: sourceCollection.facts.raw_evidence?.upload_files ?? [],
125
+ raw_evidence_facts: sourceCollection.facts.raw_evidence,
126
+ };
127
+ }
128
+ /**
129
+ * Is this machine installed, paired, and holding a session that has not lapsed?
130
+ *
131
+ * Three ways to fail, each with its own command to run, because "sync failed"
132
+ * with no next step is what turns a five-second fix into a support thread.
133
+ */
134
+ async function readPairedCollector(paths) {
135
+ const config = await readLocalCollectorConfig(paths).catch(() => {
136
+ 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");
137
+ });
138
+ const sessionFile = await readLocalCollectorSessionFile(paths).catch(() => {
139
+ throw new LocalUploadBlockedError("unpaired", "No paired collector session found. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
140
+ });
141
+ const session = await readLocalSessionReference(paths);
142
+ if (session.session_state !== "valid") {
143
+ throw new LocalUploadBlockedError("unpaired", session.session_state === "expired"
144
+ ? "Collector session expired. Run `cockpit login` or `cockpit pair` again before `cockpit sync`."
145
+ : "Collector is not paired. Run `cockpit login` or `cockpit pair` before `cockpit sync`.", "cockpit login");
146
+ }
147
+ return { config, sessionFile, session };
148
+ }
149
+ /**
150
+ * Content hashes whose bytes are already durable and may be skipped this sync.
151
+ *
152
+ * Object keys embed the work context, so a cursor entry only counts as reuse
153
+ * when it was committed under THIS operator and context. The same bytes under a
154
+ * different context still need their own pointer and evidence ref.
155
+ */
156
+ function reusableContentHashes(options, context) {
157
+ if (options.skipContentHashes)
158
+ return options.skipContentHashes;
159
+ return new Set(Object.entries(options.cursorObjects ?? {})
160
+ .filter(([, entry]) => rawEvidenceObjectKeyBelongsToWorkContext(entry.object_key, context))
161
+ .map(([hash]) => hash));
162
+ }
163
+ export function makeUploadWorkContext(options) {
164
+ const provenance = makeCollectorProvenance({
165
+ context: options.activeContext,
166
+ session: options.session,
167
+ repoLabel: options.repoLabel,
168
+ });
169
+ return {
170
+ ...options.activeContext,
171
+ repo: options.repoLabel,
172
+ repo_label: options.activeContext.repo_label ?? options.repoLabel,
173
+ repo_fingerprint: options.activeContext.repo_fingerprint,
174
+ repo_origin_url: options.activeContext.repo_origin_url,
175
+ head_sha: options.activeContext.head_sha,
176
+ worktree_label: options.activeContext.worktree_label,
177
+ worktree_fingerprint: options.activeContext.worktree_fingerprint,
178
+ worktree_is_primary: options.activeContext.worktree_is_primary,
179
+ operator_id: options.session.operator_id,
180
+ session_id: options.session.session_id,
181
+ updated_at: options.now.toISOString(),
182
+ provenance,
183
+ };
184
+ }
185
+ export function makeCollectorProvenance(options) {
186
+ return {
187
+ capture_source: "collector_runtime",
188
+ capture_adapter_version: LOCAL_COLLECTOR_VERSION,
189
+ collector_version: LOCAL_COLLECTOR_VERSION,
190
+ repo: options.repoLabel,
191
+ branch: options.context.branch,
192
+ repo_label: options.context.repo_label ?? options.repoLabel,
193
+ repo_fingerprint: options.context.repo_fingerprint,
194
+ repo_origin_url: options.context.repo_origin_url,
195
+ worktree_label: options.context.worktree_label,
196
+ worktree_fingerprint: options.context.worktree_fingerprint,
197
+ worktree_is_primary: options.context.worktree_is_primary,
198
+ operator_id: options.session.operator_id,
199
+ session_id: options.session.session_id,
200
+ work_context_id: options.context.work_context_id,
201
+ };
202
+ }
203
+ /**
204
+ * The one event every sync sends.
205
+ *
206
+ * Its privacy story turns on a single question — did this sync produce raw
207
+ * evidence objects? If it did, the bytes went to durable storage separately and
208
+ * this event carries only references to them; if it did not, the event is pure
209
+ * metadata. Both the classification and the human-readable summary follow from
210
+ * that, so it is computed once here and threaded through.
211
+ */
212
+ function makeSourceScanCompletedEvent(options) {
213
+ if (!options.context.provenance) {
214
+ throw new Error("Upload work context is missing provenance.");
215
+ }
216
+ const rawEvidencePointers = options.rawEvidenceFacts?.pointers ?? [];
217
+ const hasRawEvidence = rawEvidencePointers.length > 0;
218
+ const eventPrivacyClassification = hasRawEvidence
219
+ ? "redacted_summary"
220
+ : "metadata";
221
+ return TelemetryIngestEventDtoSchema.parse({
222
+ event_id: `ambient-sync:${options.context.work_context_id}:${options.generatedAt}`,
223
+ event_type: "source_scan_completed",
224
+ occurred_at: options.generatedAt,
225
+ provenance: options.context.provenance,
226
+ privacy_classification: eventPrivacyClassification,
227
+ redaction: {
228
+ privacy_classification: eventPrivacyClassification,
229
+ redaction_status: hasRawEvidence
230
+ ? "raw_remote_durable"
231
+ : "metadata_only",
232
+ redacted_fields: [
233
+ "prompt_body",
234
+ "response_body",
235
+ "diff_body",
236
+ "transcript_body",
237
+ "git.changed_paths",
238
+ "local_file_paths",
239
+ ],
240
+ raw_evidence_pointer_ids: rawEvidencePointers.map((pointer) => pointer.raw_evidence_pointer_id),
241
+ redacted_summary: hasRawEvidence
242
+ ? "Collector uploaded raw evidence objects separately and sent only references, hashes, source scan, binding, and risk summaries to ingest."
243
+ : "Collector uploaded metadata-only local work, source scan, binding, and risk summaries.",
244
+ },
245
+ redacted_summary: hasRawEvidence
246
+ ? "Collector uploaded raw evidence objects separately, then sent evidence references and metadata summaries."
247
+ : "Collector uploaded metadata-only source scan, ticket binding, and risk summaries.",
248
+ metrics: sourceScanEventMetrics(options),
249
+ attributes: sourceScanEventAttributes(options, hasRawEvidence),
250
+ evidence_completeness: options.rawEvidenceFacts?.evidence_completeness,
251
+ ticket_binding: options.ticketBinding ?? undefined,
252
+ risk_flags: options.riskFlags,
253
+ raw_evidence_pointers: rawEvidencePointers,
254
+ });
255
+ }
256
+ /** Every number this sync counted, all of them safe to publish. */
257
+ function sourceScanEventMetrics(options) {
258
+ const evidenceCompleteness = options.rawEvidenceFacts?.evidence_completeness;
259
+ return {
260
+ git_changed_file_count: options.gitChangedFileCount,
261
+ git_added_lines: options.gitAddedLines,
262
+ git_deleted_lines: options.gitDeletedLines,
263
+ car_open_ticket_count: options.carOpenTicketCount,
264
+ source_scan_count: options.scans.length,
265
+ risk_flag_count: options.riskFlags.length,
266
+ raw_evidence_file_count: options.rawEvidenceFacts?.file_count ?? 0,
267
+ raw_evidence_byte_size: options.rawEvidenceFacts?.byte_size ?? 0,
268
+ raw_evidence_skipped_count: options.rawEvidenceFacts?.skipped_count ?? 0,
269
+ raw_evidence_sanitized_count: options.rawEvidenceFacts?.sanitized_count ?? 0,
270
+ raw_evidence_reused_count: options.rawEvidenceFacts?.reused_count ?? 0,
271
+ evidence_scanned_count: evidenceCompleteness?.totals.scanned_count ?? 0,
272
+ evidence_included_count: evidenceCompleteness?.totals.included_count ?? 0,
273
+ evidence_skipped_count: evidenceCompleteness?.totals.skipped_count ?? 0,
274
+ evidence_truncated_count: evidenceCompleteness?.totals.truncated_count ?? 0,
275
+ evidence_deferred_count: evidenceCompleteness?.totals.deferred_count ?? 0,
276
+ evidence_reused_count: evidenceCompleteness?.totals.reused_count ?? 0,
277
+ evidence_failed_count: evidenceCompleteness?.totals.failed_count ?? 0,
278
+ };
279
+ }
280
+ /**
281
+ * Where and what this work was, plus the two claims a reader must be able to
282
+ * check without opening anything: nothing raw is in here, and here is how
283
+ * complete the evidence behind it actually is. Absent completeness reads as
284
+ * incomplete on purpose — silence is not a clean bill of health.
285
+ */
286
+ function sourceScanEventAttributes(options, hasRawEvidence) {
287
+ const evidenceCompleteness = options.rawEvidenceFacts?.evidence_completeness;
288
+ return {
289
+ repo_label: options.context.repo,
290
+ repo_fingerprint: options.context.repo_fingerprint ?? "unknown",
291
+ worktree_label: options.context.worktree_label ?? "unknown",
292
+ worktree_fingerprint: options.context.worktree_fingerprint ?? "unknown",
293
+ worktree_is_primary: options.context.worktree_is_primary ?? false,
294
+ branch: options.context.branch,
295
+ ticket_binding_state: options.binding.state,
296
+ ticket_binding_source: options.binding.selected_source ?? "none",
297
+ ticket_id: options.binding.selected_ticket_id ?? "unbound",
298
+ source_adapters: options.scans.map((scan) => scan.adapter.adapter_name),
299
+ source_statuses: options.scans.map((scan) => `${scan.adapter.adapter_name}:${scan.status}`),
300
+ redaction_mode: hasRawEvidence
301
+ ? "remote_durable_raw_evidence"
302
+ : "metadata_only",
303
+ raw_payload_included: false,
304
+ evidence_completeness_schema_version: evidenceCompleteness?.schema_version ?? "evidence-completeness.v1",
305
+ evidence_completeness_status: evidenceCompleteness?.status ?? "unknown",
306
+ evidence_incomplete: evidenceCompleteness
307
+ ? evidenceCompleteness.status !== "complete"
308
+ : true,
309
+ ...(options.context.topic_label
310
+ ? { topic_label: options.context.topic_label }
311
+ : {}),
312
+ ...(options.context.topic_summary_redacted
313
+ ? { topic_summary_redacted: options.context.topic_summary_redacted }
314
+ : {}),
315
+ ...(options.context.work_intent
316
+ ? { work_intent: options.context.work_intent }
317
+ : {}),
318
+ ...(options.context.work_phase
319
+ ? { work_phase: options.context.work_phase }
320
+ : {}),
321
+ ...(options.context.intent_source
322
+ ? { intent_source: options.context.intent_source }
323
+ : {}),
324
+ ...(options.context.intent_confidence !== undefined
325
+ ? { intent_confidence: options.context.intent_confidence }
326
+ : {}),
327
+ };
328
+ }
329
+ function sanitizeSessionReference(session) {
330
+ return {
331
+ ...session,
332
+ session_file_path: "local-session-file",
333
+ };
334
+ }
335
+ /**
336
+ * Scan results carry their own nested events, and those events must not smuggle
337
+ * evidence pointers into the envelope — the top-level event owns the pointers,
338
+ * and a duplicate ref here would be counted twice by the ingest receipt.
339
+ */
340
+ function sanitizeSourceScanResults(scans, repoLabel) {
341
+ return scans.map((scan) => ({
342
+ ...scan,
343
+ diagnostic_labels: scan.diagnostic_labels.map(sanitizeDiagnosticLabel),
344
+ events: scan.events.map((event) => ({
345
+ ...event,
346
+ raw_evidence_pointers: [],
347
+ redaction: {
348
+ ...event.redaction,
349
+ raw_evidence_pointer_ids: [],
350
+ },
351
+ })),
352
+ risk_flags: scan.risk_flags.map((flag) => sanitizeRiskFlag(flag, repoLabel)),
353
+ }));
354
+ }
355
+ /**
356
+ * A diagnostic label that carries a newline or a path separator is a local path
357
+ * or a stack trace wearing a label's clothes; keep the prefix, drop the rest.
358
+ */
359
+ function sanitizeDiagnosticLabel(label) {
360
+ if (label.includes("\n") || label.includes("/") || label.includes("\\")) {
361
+ const prefix = label.split(":", 1)[0]?.trim();
362
+ return prefix ? `${prefix}:redacted` : "diagnostic_redacted";
363
+ }
364
+ return label.length > 160 ? `${label.slice(0, 157)}...` : label;
365
+ }
366
+ function sanitizeRiskFlag(flag, repoLabel) {
367
+ return {
368
+ ...flag,
369
+ provenance: {
370
+ ...flag.provenance,
371
+ repo: repoLabel,
372
+ },
373
+ };
374
+ }
375
+ /**
376
+ * The candidate the binding actually selected, synthesised when the resolver
377
+ * chose a ticket that is not in its own candidate list (a `cockpit start`
378
+ * binding, for instance, which is a decision rather than a guess).
379
+ */
380
+ function selectedTicketBindingCandidate(binding) {
381
+ if (!binding.selected_ticket_id || !binding.selected_source)
382
+ return null;
383
+ return (binding.candidates.find((candidate) => candidate.ticket_id === binding.selected_ticket_id &&
384
+ candidate.binding_source === binding.selected_source) ?? {
385
+ ticket_id: binding.selected_ticket_id,
386
+ binding_source: binding.selected_source,
387
+ confidence: 1,
388
+ evidence_labels: [`bound_by:${binding.selected_source}`],
389
+ });
390
+ }
391
+ /** The basename of a repo root, never the path that led to it. */
392
+ export function safeRepoLabel(repoRoot) {
393
+ const basename = path.basename(repoRoot.replace(/[\\/]+$/, ""));
394
+ return basename || "repo";
395
+ }
396
+ /**
397
+ * Does this durable object belong to the operator and work context now syncing?
398
+ *
399
+ * Two key shapes are live: the original `<operator>/<context>/...` prefix and
400
+ * the readable `operators/.../ids/<operator>/<context>/...` layout.
401
+ */
402
+ function rawEvidenceObjectKeyBelongsToWorkContext(objectKey, context) {
403
+ const legacyPrefix = `${context.operatorId}/${context.workContextId}/`;
404
+ const readableIdGuard = `/ids/${context.operatorId}/${context.workContextId}/`;
405
+ return (objectKey.startsWith(legacyPrefix) ||
406
+ (objectKey.startsWith("operators/") && objectKey.includes(readableIdGuard)));
407
+ }