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