@bli-cockpit/cli 0.2.56 → 0.2.57

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.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `bli-tower`'s registration — the docs_* and msg_* MCP server, beside
3
+ * `bli-memory` (BLI-3706, "Agent-friendly is the definition of done").
4
+ *
5
+ * Rides `cockpit memory install`/`cockpit memory status` rather than a new
6
+ * verb: that command already runs unasked from `do-everything` and the daily
7
+ * sync tick (`memory-install.ts`'s own header), which is the ONLY way a
8
+ * registration reaches every intern machine without anyone typing anything.
9
+ * A second, un-invoked `cockpit mcp install` command would ship the feature
10
+ * and still leave every existing machine unregistered until someone learned
11
+ * to type the new verb.
12
+ *
13
+ * `no_bin_no_write` applies here too, for the identical reason
14
+ * `memory-install.ts` states it for `bli-memory-mcp`: a Claude Code entry
15
+ * pointing at a server that is not on this machine is a dead MCP connection
16
+ * on every startup, not a graceful absence.
17
+ */
18
+ import os from "node:os";
19
+ import { resolveMcpBin } from "./mcp-bin-resolve.js";
20
+ import { installClaudeTowerIntegration, inspectClaudeTowerIntegration } from "./tower-mcp-claude.js";
21
+ import { installCodexTowerIntegration, inspectCodexTowerIntegration } from "./tower-mcp-codex.js";
22
+ import { builtinTowerInstallConfig, TOWER_MCP_BIN } from "./tower-mcp-contract.js";
23
+ import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
24
+ async function resolveTowerConfig(deps) {
25
+ const found = await resolveMcpBin({
26
+ binName: TOWER_MCP_BIN,
27
+ env: envWithNodeRuntimeOnPath(deps.env),
28
+ platform: deps.platform,
29
+ fileExists: deps.fileExists,
30
+ cliEntryPoint: deps.cliEntryPoint,
31
+ realpath: deps.realpath,
32
+ });
33
+ if (!found) {
34
+ return {
35
+ config: null,
36
+ binTarget: {
37
+ target: "tower_bin",
38
+ status: "skipped",
39
+ reason: "bin_missing",
40
+ detail: `${TOWER_MCP_BIN} is not installed beside this CLI or on PATH; nothing was written, and the next daily run will try again`,
41
+ },
42
+ };
43
+ }
44
+ return {
45
+ config: builtinTowerInstallConfig({ binPath: found.path, platform: deps.platform, dashboardUrl: deps.dashboardUrl }),
46
+ binTarget: { target: "tower_bin", status: "already", reason: "bin_present_template_used" },
47
+ };
48
+ }
49
+ export async function installTowerIntegration(deps, dryRun) {
50
+ const resolved = await resolveTowerConfig(deps);
51
+ const targets = [resolved.binTarget];
52
+ if (!resolved.config)
53
+ return targets;
54
+ targets.push(await installClaudeTowerIntegration({ homeDir: deps.homeDir, config: resolved.config, dryRun, io: deps.io }));
55
+ targets.push(await installCodexTowerIntegration({ homeDir: deps.homeDir, config: resolved.config, dryRun, io: deps.io }));
56
+ return targets;
57
+ }
58
+ export async function inspectTowerIntegration(deps) {
59
+ const resolved = await resolveTowerConfig(deps);
60
+ const targets = [resolved.binTarget];
61
+ if (!resolved.config)
62
+ return targets;
63
+ targets.push(await inspectClaudeTowerIntegration({ homeDir: deps.homeDir, config: resolved.config, io: deps.io }));
64
+ targets.push(await inspectCodexTowerIntegration({ homeDir: deps.homeDir, config: resolved.config, io: deps.io }));
65
+ return targets;
66
+ }
67
+ export function defaultTowerDeps(overrides) {
68
+ return {
69
+ platform: process.platform,
70
+ homeDir: os.homedir(),
71
+ dashboardUrl: overrides.dashboardUrl ?? "",
72
+ env: process.env,
73
+ ...overrides,
74
+ };
75
+ }
@@ -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
+ }
@@ -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
+ }