@bli-cockpit/cli 0.2.236 → 0.2.238

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 (143) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +15 -11
  2. package/dist/adapters/attribution-core-score.js +111 -87
  3. package/dist/adapters/car-state.js +38 -35
  4. package/dist/adapters/claude-attribution-discovery.js +81 -56
  5. package/dist/adapters/claude-attribution-score.js +108 -94
  6. package/dist/adapters/claude-attribution-signals.js +52 -42
  7. package/dist/adapters/codex-attribution.js +42 -33
  8. package/dist/adapters/codex-session-signals.js +11 -9
  9. package/dist/adapters/git-state.js +56 -50
  10. package/dist/adapters/local-sources.js +52 -43
  11. package/dist/adapters/raw-evidence-git-diff.js +4 -2
  12. package/dist/adapters/raw-evidence-sanitize.js +15 -12
  13. package/dist/adapters/raw-evidence-transcript-file.js +32 -31
  14. package/dist/autostart-self-heal.js +18 -2
  15. package/dist/commands/agent-session-report.js +51 -59
  16. package/dist/commands/agent-setup-observed.js +30 -23
  17. package/dist/commands/agent-setup.js +96 -45
  18. package/dist/commands/autostart-heal.js +8 -2
  19. package/dist/commands/autostart-reading.js +3 -0
  20. package/dist/commands/backfill-evidence-outcomes.js +9 -6
  21. package/dist/commands/backfill-issues.js +3 -0
  22. package/dist/commands/backfill-scan.js +27 -22
  23. package/dist/commands/backfill-upload.js +69 -60
  24. package/dist/commands/backfill.js +26 -21
  25. package/dist/commands/brief-done.js +4 -1
  26. package/dist/commands/brief-edit.js +71 -46
  27. package/dist/commands/cal-detail.js +35 -0
  28. package/dist/commands/cal-overlay.js +106 -0
  29. package/dist/commands/cal.js +40 -23
  30. package/dist/commands/clean.js +4 -1
  31. package/dist/commands/docs-read.js +5 -2
  32. package/dist/commands/doctor-backfill.js +88 -71
  33. package/dist/commands/doctor-pipeline.js +59 -46
  34. package/dist/commands/doctor-usage-telemetry.js +2 -1
  35. package/dist/commands/heartbeat-post.js +116 -0
  36. package/dist/commands/heartbeat-token-lock.js +46 -33
  37. package/dist/commands/heartbeat.js +43 -125
  38. package/dist/commands/install-update.js +17 -15
  39. package/dist/commands/issue-show.js +27 -28
  40. package/dist/commands/issue-write.js +70 -23
  41. package/dist/commands/issue.js +68 -37
  42. package/dist/commands/jarvis-live-answer.js +99 -0
  43. package/dist/commands/jarvis-render.js +1 -94
  44. package/dist/commands/jarvis-trace.js +30 -22
  45. package/dist/commands/jarvis-turn-report.js +124 -0
  46. package/dist/commands/jarvis-turn.js +108 -182
  47. package/dist/commands/jarvis.js +26 -17
  48. package/dist/commands/local-args-collector-setup.js +82 -84
  49. package/dist/commands/local-args-collector-work.js +37 -38
  50. package/dist/commands/local-args-tower-admin.js +155 -142
  51. package/dist/commands/local-args-tower-cal.js +54 -48
  52. package/dist/commands/local-args-tower-careers.js +73 -54
  53. package/dist/commands/local-args-tower-docs-msg.js +56 -188
  54. package/dist/commands/local-args-tower-github.js +26 -20
  55. package/dist/commands/local-args-tower-hits.js +32 -20
  56. package/dist/commands/local-args-tower-manager.js +85 -4
  57. package/dist/commands/local-args-tower-memory.js +139 -117
  58. package/dist/commands/local-args-tower-msg.js +149 -0
  59. package/dist/commands/local-args-tower-notes.js +346 -0
  60. package/dist/commands/local-args-tower-pages.js +100 -378
  61. package/dist/commands/local-args-tower-settings.js +28 -29
  62. package/dist/commands/local-args-tower-work.js +52 -33
  63. package/dist/commands/local-args.js +80 -129
  64. package/dist/commands/local-help-commands-tower-ops.js +15 -4
  65. package/dist/commands/local-help-commands-tower.js +11 -4
  66. package/dist/commands/local-help-commands.js +342 -341
  67. package/dist/commands/local-help.js +87 -83
  68. package/dist/commands/local-run-guards.js +100 -0
  69. package/dist/commands/local.js +137 -206
  70. package/dist/commands/login.js +13 -9
  71. package/dist/commands/logs.js +55 -38
  72. package/dist/commands/mail-attachments.js +44 -26
  73. package/dist/commands/mail.js +6 -4
  74. package/dist/commands/manager-render-home.js +208 -0
  75. package/dist/commands/manager-render.js +7 -3
  76. package/dist/commands/manager-requests.js +33 -0
  77. package/dist/commands/manager.js +39 -5
  78. package/dist/commands/memory-consolidation.js +18 -9
  79. package/dist/commands/memory-daemon-probe.js +37 -28
  80. package/dist/commands/memory-install-config.js +64 -60
  81. package/dist/commands/memory-install.js +21 -41
  82. package/dist/commands/memory-requeue.js +26 -19
  83. package/dist/commands/memory-rules.js +26 -18
  84. package/dist/commands/memory-search-print.js +26 -0
  85. package/dist/commands/memory-store.js +14 -30
  86. package/dist/commands/memory-verb-list.js +49 -48
  87. package/dist/commands/msg.js +51 -9
  88. package/dist/commands/notes-accounts.js +49 -32
  89. package/dist/commands/notes-sync-loop.js +115 -89
  90. package/dist/commands/notes-wisprflow-login.js +44 -23
  91. package/dist/commands/notes-writes.js +115 -100
  92. package/dist/commands/notes.js +33 -55
  93. package/dist/commands/onboard-flows.js +69 -51
  94. package/dist/commands/onboard.js +11 -9
  95. package/dist/commands/ops-render-memory.js +10 -7
  96. package/dist/commands/ops-render.js +33 -30
  97. package/dist/commands/ops.js +3 -0
  98. package/dist/commands/public-root.js +1 -1
  99. package/dist/commands/session-sync-outside-roots.js +40 -36
  100. package/dist/commands/session-sync-plan.js +14 -10
  101. package/dist/commands/session-sync-upload.js +24 -22
  102. package/dist/commands/session-sync.js +19 -8
  103. package/dist/commands/slack.js +25 -16
  104. package/dist/commands/sources-check.js +13 -7
  105. package/dist/commands/sources-discovery.js +25 -17
  106. package/dist/commands/sources-verdict.js +19 -18
  107. package/dist/commands/speed.js +23 -19
  108. package/dist/commands/start.js +20 -16
  109. package/dist/commands/sync-doctor.js +52 -48
  110. package/dist/commands/sync.js +22 -16
  111. package/dist/commands/usage-telemetry-toml.js +71 -45
  112. package/dist/commands/usage.js +91 -67
  113. package/dist/commands/workbook-render.js +10 -7
  114. package/dist/evidence-reconcile-client.js +75 -64
  115. package/dist/evidence-redelivery-plan.js +37 -34
  116. package/dist/evidence-redelivery.js +32 -29
  117. package/dist/evidence-transcripts-client.js +20 -20
  118. package/dist/evidence-upload-object.js +86 -89
  119. package/dist/evidence-upload-plan.js +94 -83
  120. package/dist/evidence-upload-terminal.js +45 -50
  121. package/dist/image-description-session.js +156 -159
  122. package/dist/local-state-pairing-code.js +39 -31
  123. package/dist/local-state-status.js +9 -2
  124. package/dist/onboarding-roots.js +5 -8
  125. package/dist/process-runner.js +8 -8
  126. package/dist/raw-evidence-gc.js +33 -38
  127. package/dist/raw-evidence-stuck-summary.js +7 -14
  128. package/dist/repo-identity.js +26 -23
  129. package/dist/scheduled-self-update.js +11 -15
  130. package/dist/second-install.js +3 -0
  131. package/dist/slim/codex-rules.js +23 -36
  132. package/dist/sync-lock.js +5 -15
  133. package/dist/tower-client.js +36 -53
  134. package/dist/tower-stream.js +7 -24
  135. package/dist/upload-agent-artifacts.js +58 -64
  136. package/dist/upload-envelope-build.js +60 -59
  137. package/dist/upload-evidence-delivery-reconcile.js +58 -56
  138. package/dist/upload-evidence-delivery-summary.js +32 -34
  139. package/dist/upload-ingest-receipt.js +20 -23
  140. package/dist/upload-session-reports-queue.js +4 -1
  141. package/dist/upload-session-reports-wire.js +64 -68
  142. package/dist/upload-sync.js +171 -157
  143. package/package.json +2 -2
@@ -1,27 +1,7 @@
1
1
  /**
2
- * The one shared "call the dashboard as me" client (BLI-3457).
3
- *
4
- * Every command that speaks to Tower on the paired person's behalf — `jarvis`
5
- * today, the rest of the CLI-parity surface next — needs the same three
6
- * things: the paired device session, a request that carries its device token,
7
- * and a failure that names itself instead of throwing a transport stack trace
8
- * at a person. Before this module each command grew its own copy; the copies
9
- * drifted (one normalised the dashboard URL, one did not) and only one of them
10
- * ever named a reason.
11
- *
12
- * Contract:
13
- * - Nothing here throws for a transport error. `towerRequest` returns
14
- * `{ ok: false, reason }` for every network-shaped failure, and the reason
15
- * label is the thing that travels to the operator.
16
- * - `loadPairedSession` still throws, because "this machine is not paired" is
17
- * not a transport failure — it is a precondition the caller cannot proceed
18
- * past, and the command's top level turns it into one plain sentence.
19
- * - The device token is attached to the request and never returned, logged, or
20
- * put in a reason. Log lines here carry metadata only.
21
- *
22
- * The HTTP vocabulary (`normalizeDashboardUrl`, `readResponseJson`,
23
- * `responseErrorMessage`) is reused from `upload-http.ts` rather than copied —
24
- * the upload path and the conversation path answer to the same rules.
2
+ * Share authenticated Tower requests across CLI capabilities.
3
+ * HTTP refusals remain responses for callers to interpret; transport failures
4
+ * carry named reasons. Warm reads reuse the daemon and writes invalidate cache.
25
5
  */
26
6
  import { invalidateReadCacheSurface, readCacheSurfaceForPath, } from "@bli-cockpit/telemetry-core/node-read-cache";
27
7
  import { errorMessage } from "./commands/cli-io.js";
@@ -109,36 +89,7 @@ export async function towerRequest(options) {
109
89
  : null;
110
90
  const response = warmResponse ?? (await options.fetch(url, init));
111
91
  const respondedMs = Date.now() - startedAt;
112
- // The success branch always decided something (BLI-10450 kept the
113
- // "log the success branch too" rule intact) — it is just that under
114
- // `--json`/`--quiet` the reader already gets that decision back as the
115
- // command's own stdout object, so a second copy on stderr is noise, not
116
- // signal. The failure branch below never quiets: a request that reached
117
- // no response at all is the one case AGENTS.md's logging rule protects.
118
- if (!shouldSuppressTowerSuccessLog()) {
119
- log(`[tower client] responded ${JSON.stringify({
120
- request: options.label,
121
- http_status: response.status,
122
- content_type: response.headers.get("content-type") ?? "none",
123
- streamed: (response.headers.get("content-type") ?? "").includes("application/x-ndjson"),
124
- elapsed_ms: respondedMs,
125
- })}`);
126
- }
127
- // BLI-10746: "any write through the CLI or MCP invalidates the affected
128
- // cache". A successful (2xx) write to a covered surface throws away
129
- // every cached read for that surface — coarse, but a stale row is never
130
- // possible this way, only one avoidable extra request.
131
- if (method !== "GET" && response.ok) {
132
- const surface = readCacheSurfaceForPath(options.path);
133
- if (surface) {
134
- const stateDir = getCollectorRuntimePaths(options.homeDir).state_dir;
135
- const invalidated = invalidateReadCacheSurface(stateDir, options.deviceToken, surface);
136
- log(`[tower client] read cache invalidated ${JSON.stringify({
137
- surface,
138
- ...(invalidated.ok ? { removed: invalidated.removed } : { reason: invalidated.reason }),
139
- })}`);
140
- }
141
- }
92
+ recordTowerResponse(options, response, method, respondedMs, log);
142
93
  return { ok: true, response, startedAt, respondedMs };
143
94
  }
144
95
  catch (error) {
@@ -153,6 +104,38 @@ export async function towerRequest(options) {
153
104
  return { ok: false, reason, detail: towerFailureDetail(reason, errorMessage(error)) };
154
105
  }
155
106
  }
107
+ function recordTowerResponse(options, response, method, respondedMs, log) {
108
+ // The success branch always decided something (BLI-10450 kept the
109
+ // "log the success branch too" rule intact) — it is just that under
110
+ // `--json`/`--quiet` the reader already gets that decision back as the
111
+ // command's own stdout object, so a second copy on stderr is noise, not
112
+ // signal. The failure branch below never quiets: a request that reached
113
+ // no response at all is the one case AGENTS.md's logging rule protects.
114
+ if (!shouldSuppressTowerSuccessLog()) {
115
+ log(`[tower client] responded ${JSON.stringify({
116
+ request: options.label,
117
+ http_status: response.status,
118
+ content_type: response.headers.get("content-type") ?? "none",
119
+ streamed: (response.headers.get("content-type") ?? "").includes("application/x-ndjson"),
120
+ elapsed_ms: respondedMs,
121
+ })}`);
122
+ }
123
+ // BLI-10746: "any write through the CLI or MCP invalidates the affected
124
+ // cache". A successful (2xx) write to a covered surface throws away
125
+ // every cached read for that surface — coarse, but a stale row is never
126
+ // possible this way, only one avoidable extra request.
127
+ if (method !== "GET" && response.ok) {
128
+ const surface = readCacheSurfaceForPath(options.path);
129
+ if (surface) {
130
+ const stateDir = getCollectorRuntimePaths(options.homeDir).state_dir;
131
+ const invalidated = invalidateReadCacheSurface(stateDir, options.deviceToken, surface);
132
+ log(`[tower client] read cache invalidated ${JSON.stringify({
133
+ surface,
134
+ ...(invalidated.ok ? { removed: invalidated.removed } : { reason: invalidated.reason }),
135
+ })}`);
136
+ }
137
+ }
138
+ }
156
139
  /**
157
140
  * `towerRequest` plus reading the body as JSON and turning a non-2xx into a
158
141
  * named failure carrying the server's own words when it supplied any.
@@ -1,28 +1,7 @@
1
1
  /**
2
- * Reading a Tower turn as it happens (BLI-3457).
3
- *
4
- * The dashboard's conversation routes speak one JSON object per line
5
- * (`application/x-ndjson`): `activity` events as tools start and settle,
6
- * `token` events carrying the answer as it is written (BLI-3517), then exactly
7
- * one `final` event carrying what the plain JSON response used to be.
8
- * That is the same wire format the browser already reads
9
- * (`apps/dashboard/src/lib/webchat/tool-trace.ts`); this is the terminal's
10
- * reader for it.
11
- *
12
- * Three things it refuses to do:
13
- * - Throw. A stream that dies mid-turn comes back as a named reason, never an
14
- * exception in the middle of a person's answer.
15
- * - Present a truncated turn as a complete one. No `final` event means the
16
- * turn failed, and it says which way it failed
17
- * (`turn_timed_out` when the dashboard's own 120s ceiling is the likely
18
- * cause, `stream_ended_without_final` otherwise).
19
- * - Require the stream. A dashboard that has not shipped streaming yet answers
20
- * `application/json`; that single object is read as the final event and the
21
- * fallback is named `stream_not_available` rather than passed off as a
22
- * stream that happened to be quiet.
23
- *
24
- * A malformed line is skipped, named `ndjson_line_unparseable`, and the lines
25
- * after it still count — one bad byte cannot swallow an answer.
2
+ * Read Tower stream frames without losing the final answer or partial timing.
3
+ * Activity, token and revision callbacks are optional; the final result retains
4
+ * the settled answer. A truncated stream reports its last meaningful reason.
26
5
  */
27
6
  import { errorMessage } from "./commands/cli-io.js";
28
7
  import { isAbortError } from "./tower-client.js";
@@ -131,6 +110,10 @@ export async function readTowerTurn(response, options = {}) {
131
110
  activity.push(item.event);
132
111
  options.onActivity?.(item.event);
133
112
  }
113
+ return finishTowerTurn(options, { startedAt, activity, notes, streamed, final, draft, tokenFrames, firstByteMs, firstFrameMs, firstTokenMs });
114
+ }
115
+ function finishTowerTurn(options, state) {
116
+ const { startedAt, activity, notes, streamed, final, draft, tokenFrames, firstByteMs, firstFrameMs, firstTokenMs } = state;
134
117
  const elapsedMs = Date.now() - startedAt;
135
118
  const timing = { firstByteMs, firstFrameMs, firstTokenMs };
136
119
  if (!final) {
@@ -1,14 +1,6 @@
1
1
  /**
2
- * Telling the dashboard about images an agent produced or was shown.
3
- *
4
- * A screenshot pasted into a Codex or Claude session is already uploaded as raw
5
- * evidence like any other object; this is the second, separate report that says
6
- * "that object is an image, from this session, at this turn" so the dashboard
7
- * can queue it for redaction and OCR.
8
- *
9
- * Non-fatal by design: an older dashboard without the endpoint must not fail a
10
- * harvest, so every path here returns a `{ posted, reason }` label instead of
11
- * throwing.
2
+ * Register durable agent images so downstream OCR/redaction can find them.
3
+ * Reporting is best effort: return and log named outcomes without failing sync.
12
4
  */
13
5
  import { AgentImageArtifactReportRequestSchema, } from "@bli-cockpit/telemetry-core";
14
6
  import { describeError } from "./health-detail.js";
@@ -37,60 +29,9 @@ export async function reportAgentImageArtifacts(options) {
37
29
  },
38
30
  body: JSON.stringify(payload),
39
31
  });
40
- // Both non-2xx branches returned a label and said nothing (BLI-3483). An
41
- // image that never gets reported is never redacted and never OCR'd, and
42
- // the only place that fact existed was a return value the sync discards.
43
- if (!response.ok) {
44
- const errorBody = await readResponseJson(response);
45
- // A 404 is two different facts. "No such route" is the platform
46
- // answering with an empty or HTML body; a JSON error envelope means the
47
- // route answered and refused, and calling that "the dashboard has no
48
- // agent-artifact route" hid a real refusal behind a shrug for as long as
49
- // the door rejected ambient tickets.
50
- if (response.status === 404 && !routeAnswered(errorBody)) {
51
- console.error("[agent-artifacts] nothing answered at the agent-artifact path; these images stay unreported", JSON.stringify({
52
- reason: "agent_artifact_api_unavailable",
53
- http_status: 404,
54
- artifact_count: artifacts.length,
55
- }));
56
- return {
57
- posted: false,
58
- reason: "agent_artifact_api_unavailable",
59
- recorded_count: 0,
60
- };
61
- }
62
- const serverReason = serverFailureDetail(errorBody);
63
- console.error("[agent-artifacts] report refused", JSON.stringify({
64
- reason: "report_failed",
65
- http_status: response.status,
66
- server_reason: serverReason ?? "none",
67
- artifact_count: artifacts.length,
68
- }));
69
- return {
70
- posted: false,
71
- reason: `report_failed_http_${response.status}`,
72
- recorded_count: 0,
73
- };
74
- }
75
- const body = await readResponseJson(response);
76
- const recordedCount = body && typeof body === "object"
77
- ? Number(body.recorded_count ?? 0)
78
- : 0;
79
- const recorded = Number.isFinite(recordedCount) ? recordedCount : 0;
80
- // The success branch too: "some images reported" and "every image
81
- // reported" are different facts, and a count that is short of
82
- // `artifact_count` is the only warning an operator would ever get.
83
- console.error("[agent-artifacts] report recorded", JSON.stringify({
84
- reason: "recorded",
85
- http_status: response.status,
86
- artifact_count: artifacts.length,
87
- recorded_count: recorded,
88
- }));
89
- return {
90
- posted: true,
91
- reason: "recorded",
92
- recorded_count: recorded,
93
- };
32
+ if (!response.ok)
33
+ return await reportRefusedArtifacts(response, artifacts.length);
34
+ return await recordAcceptedArtifacts(response, artifacts.length);
94
35
  }
95
36
  catch (error) {
96
37
  // The label stays `report_network_error` — it is the wire contract. What
@@ -105,6 +46,59 @@ export async function reportAgentImageArtifacts(options) {
105
46
  return { posted: false, reason: "report_network_error", recorded_count: 0 };
106
47
  }
107
48
  }
49
+ async function recordAcceptedArtifacts(response, artifactCount) {
50
+ const body = await readResponseJson(response);
51
+ const recordedCount = body && typeof body === "object"
52
+ ? Number(body.recorded_count ?? 0)
53
+ : 0;
54
+ const recorded = Number.isFinite(recordedCount) ? recordedCount : 0;
55
+ // The success branch too: "some images reported" and "every image
56
+ // reported" are different facts, and a count that is short of
57
+ // `artifact_count` is the only warning an operator would ever get.
58
+ console.error("[agent-artifacts] report recorded", JSON.stringify({
59
+ reason: "recorded",
60
+ http_status: response.status,
61
+ artifact_count: artifactCount,
62
+ recorded_count: recorded,
63
+ }));
64
+ return {
65
+ posted: true,
66
+ reason: "recorded",
67
+ recorded_count: recorded,
68
+ };
69
+ }
70
+ async function reportRefusedArtifacts(response, artifactCount) {
71
+ const errorBody = await readResponseJson(response);
72
+ // A 404 is two different facts. "No such route" is the platform
73
+ // answering with an empty or HTML body; a JSON error envelope means the
74
+ // route answered and refused, and calling that "the dashboard has no
75
+ // agent-artifact route" hid a real refusal behind a shrug for as long as
76
+ // the door rejected ambient tickets.
77
+ if (response.status === 404 && !routeAnswered(errorBody)) {
78
+ console.error("[agent-artifacts] nothing answered at the agent-artifact path; these images stay unreported", JSON.stringify({
79
+ reason: "agent_artifact_api_unavailable",
80
+ http_status: 404,
81
+ artifact_count: artifactCount,
82
+ }));
83
+ return {
84
+ posted: false,
85
+ reason: "agent_artifact_api_unavailable",
86
+ recorded_count: 0,
87
+ };
88
+ }
89
+ const serverReason = serverFailureDetail(errorBody);
90
+ console.error("[agent-artifacts] report refused", JSON.stringify({
91
+ reason: "report_failed",
92
+ http_status: response.status,
93
+ server_reason: serverReason ?? "none",
94
+ artifact_count: artifactCount,
95
+ }));
96
+ return {
97
+ posted: false,
98
+ reason: `report_failed_http_${response.status}`,
99
+ recorded_count: 0,
100
+ };
101
+ }
108
102
  /**
109
103
  * Did the route answer, or did something in front of it?
110
104
  *
@@ -1,18 +1,7 @@
1
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.
2
+ * Build a validated envelope only after proving the collector is paired.
3
+ * Read the workspace context, describe transcript images, collect sources,
4
+ * then sanitize and assemble the event. Blockers retain their retry commands.
16
5
  */
17
6
  import { priorSnapshotsFromCursor, } from "./adapters/raw-evidence-snapshot-supersede.js";
18
7
  import { TelemetryIngestEnvelopeSchema, } from "@bli-cockpit/telemetry-core";
@@ -82,18 +71,7 @@ export async function buildLocalAmbientEnvelope(options = {}) {
82
71
  const collector = await readPairedCollector(paths);
83
72
  const { config, sessionFile, session } = collector;
84
73
  const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
85
- const activeContext = await readLocalWorkContextForRepo(paths, repoRoot).catch((error) => {
86
- // The operator is told "run `cockpit start`", which is right when the file
87
- // is simply absent and wrong when it exists and will not parse — the same
88
- // advice, forever, on a machine that has already run it (BLI-3238).
89
- if (!isMissingFileFailure(error)) {
90
- console.error("[upload-envelope] work context present but unreadable, reporting it as missing", JSON.stringify({
91
- reason: "missing_context",
92
- ...describeError(error),
93
- }));
94
- }
95
- throw new LocalUploadBlockedError("missing_context", "Active work context missing. Run `cockpit start --workspace \"$PWD\"` before `cockpit sync`.", "cockpit start --workspace \"$PWD\"");
96
- });
74
+ const activeContext = await readUploadWorkContext(paths, repoRoot);
97
75
  const repoLabel = safeRepoLabel(activeContext.repo_label ?? repoRoot);
98
76
  const uploadContext = makeUploadWorkContext({
99
77
  activeContext,
@@ -110,39 +88,11 @@ export async function buildLocalAmbientEnvelope(options = {}) {
110
88
  deviceToken: sessionFile.device_token,
111
89
  fetchImpl: options.fetch,
112
90
  });
113
- const sourceCollection = await runLocalSourceCollectors({
114
- repoRoot,
115
- branch: uploadContext.branch,
116
- operatorId: session.operator_id,
117
- operatorLabel: session.email ?? session.auth_subject_id,
118
- sessionId: session.session_id,
119
- workContextId: uploadContext.work_context_id,
120
- activeWorkContext: activeContext,
121
- rawEvidenceStateDir: paths.state_dir,
122
- rawEvidenceSessionsDirs: defaultCodexSessionDirs(paths.home_dir),
123
- claudeProjectsDir: path.join(paths.home_dir, ".claude", "projects"),
124
- rawEvidenceIncludeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
125
- rawEvidenceIncludeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
126
- rawEvidenceCodexSessionFiles: options.codexSessionFiles,
127
- rawEvidenceCodexAttributionScan: options.codexAttributionScan,
128
- rawEvidenceClaudeSessionFiles: options.claudeSessionFiles,
129
- rawEvidenceClaudeAttributionScan: options.claudeAttributionScan,
130
- rawEvidenceSkipContentHashes: reusableContentHashes(options, {
131
- operatorId: session.operator_id,
132
- workContextId: uploadContext.work_context_id,
133
- }),
134
- rawEvidencePriorSnapshots: priorSnapshotsFromCursor(transcriptCursorObjects(options.cursorObjects), (objectKey) => rawEvidenceObjectKeyBelongsToWorkContext(objectKey, {
135
- operatorId: session.operator_id,
136
- workContextId: uploadContext.work_context_id,
137
- })),
138
- rawEvidenceStubReoffers: options.rawEvidenceStubReoffers,
139
- rawEvidenceByteBudget: options.rawEvidenceByteBudget,
140
- rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
141
- rawEvidenceBudget: options.rawEvidenceBudget,
142
- rawEvidenceImages,
143
- rawEvidenceDeliveryMode: options.evidenceDeliveryMode,
144
- now,
145
- });
91
+ const sourceCollection = await collectEnvelopeSources(options, paths, repoRoot, uploadContext, session, activeContext, rawEvidenceImages, now);
92
+ return assembleLocalEnvelope(options, collector, uploadContext, repoLabel, sourceCollection, now);
93
+ }
94
+ function assembleLocalEnvelope(options, collector, uploadContext, repoLabel, sourceCollection, now) {
95
+ const { config, sessionFile, session } = collector;
146
96
  const binding = sourceCollection.binding;
147
97
  const ticketBinding = selectedTicketBindingCandidate(binding);
148
98
  const uploadWorkContext = {
@@ -191,6 +141,57 @@ export async function buildLocalAmbientEnvelope(options = {}) {
191
141
  raw_evidence_facts: sourceCollection.facts.raw_evidence,
192
142
  };
193
143
  }
144
+ async function collectEnvelopeSources(options, paths, repoRoot, uploadContext, session, activeContext, rawEvidenceImages, now) {
145
+ const sourceCollection = await runLocalSourceCollectors({
146
+ repoRoot,
147
+ branch: uploadContext.branch,
148
+ operatorId: session.operator_id,
149
+ operatorLabel: session.email ?? session.auth_subject_id,
150
+ sessionId: session.session_id,
151
+ workContextId: uploadContext.work_context_id,
152
+ activeWorkContext: activeContext,
153
+ rawEvidenceStateDir: paths.state_dir,
154
+ rawEvidenceSessionsDirs: defaultCodexSessionDirs(paths.home_dir),
155
+ claudeProjectsDir: path.join(paths.home_dir, ".claude", "projects"),
156
+ rawEvidenceIncludeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
157
+ rawEvidenceIncludeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
158
+ rawEvidenceCodexSessionFiles: options.codexSessionFiles,
159
+ rawEvidenceCodexAttributionScan: options.codexAttributionScan,
160
+ rawEvidenceClaudeSessionFiles: options.claudeSessionFiles,
161
+ rawEvidenceClaudeAttributionScan: options.claudeAttributionScan,
162
+ rawEvidenceSkipContentHashes: reusableContentHashes(options, {
163
+ operatorId: session.operator_id,
164
+ workContextId: uploadContext.work_context_id,
165
+ }),
166
+ rawEvidencePriorSnapshots: priorSnapshotsFromCursor(transcriptCursorObjects(options.cursorObjects), (objectKey) => rawEvidenceObjectKeyBelongsToWorkContext(objectKey, {
167
+ operatorId: session.operator_id,
168
+ workContextId: uploadContext.work_context_id,
169
+ })),
170
+ rawEvidenceStubReoffers: options.rawEvidenceStubReoffers,
171
+ rawEvidenceByteBudget: options.rawEvidenceByteBudget,
172
+ rawEvidenceObjectBudget: options.rawEvidenceObjectBudget,
173
+ rawEvidenceBudget: options.rawEvidenceBudget,
174
+ rawEvidenceImages,
175
+ rawEvidenceDeliveryMode: options.evidenceDeliveryMode,
176
+ now,
177
+ });
178
+ return sourceCollection;
179
+ }
180
+ async function readUploadWorkContext(paths, repoRoot) {
181
+ const activeContext = await readLocalWorkContextForRepo(paths, repoRoot).catch((error) => {
182
+ // The operator is told "run `cockpit start`", which is right when the file
183
+ // is simply absent and wrong when it exists and will not parse — the same
184
+ // advice, forever, on a machine that has already run it (BLI-3238).
185
+ if (!isMissingFileFailure(error)) {
186
+ console.error("[upload-envelope] work context present but unreadable, reporting it as missing", JSON.stringify({
187
+ reason: "missing_context",
188
+ ...describeError(error),
189
+ }));
190
+ }
191
+ throw new LocalUploadBlockedError("missing_context", "Active work context missing. Run `cockpit start --workspace \"$PWD\"` before `cockpit sync`.", "cockpit start --workspace \"$PWD\"");
192
+ });
193
+ return activeContext;
194
+ }
194
195
  /**
195
196
  * Content hashes whose bytes are already durable and may be skipped this sync.
196
197
  *
@@ -1,12 +1,7 @@
1
1
  import { EvidenceCompletenessPayloadSchema, } from "@bli-cockpit/telemetry-core";
2
2
  /**
3
- * 4. Reconciling the envelope with what actually became durable.
4
- *
5
- * Ingest refuses pointers whose objects never became durable, so failed
6
- * uploads are pruned from the envelope instead of failing the whole sync.
7
- * Successful upload responses can also carry server-side sanitized hash and
8
- * redaction metadata; apply those before ingest so refs describe the bytes
9
- * actually stored in the durable bucket.
3
+ * Prune failed pointers before ingest and apply server-sanitized metadata so
4
+ * refs describe the bytes actually stored. Any successful duplicate is durable.
10
5
  */
11
6
  export function applyRawEvidenceUploadOutcomes(envelope, outcomes) {
12
7
  // Content-addressed keys mean one pointer id can carry several outcomes
@@ -27,39 +22,42 @@ export function applyRawEvidenceUploadOutcomes(envelope, outcomes) {
27
22
  return envelope;
28
23
  return {
29
24
  ...envelope,
30
- events: envelope.events.map((event) => ({
31
- ...event,
32
- metrics: failedPointerIds.size > 0
33
- ? {
34
- ...event.metrics,
35
- evidence_failed_count: (event.metrics["evidence_failed_count"] ?? 0) +
36
- failedOutcomes.length,
37
- }
38
- : event.metrics,
39
- attributes: failedPointerIds.size > 0 && event.evidence_completeness
40
- ? {
41
- ...event.attributes,
42
- evidence_completeness_schema_version: event.evidence_completeness.schema_version,
43
- evidence_completeness_status: "partial",
44
- evidence_incomplete: true,
45
- }
46
- : event.attributes,
47
- evidence_completeness: failedPointerIds.size > 0 && event.evidence_completeness
48
- ? markCompletenessUploadFailures(event.evidence_completeness, failedOutcomes)
49
- : event.evidence_completeness,
50
- raw_evidence_pointers: event.raw_evidence_pointers.flatMap((pointer) => {
51
- const pointerId = pointer.raw_evidence_pointer_id;
52
- if (failedPointerIds.has(pointerId))
53
- return [];
54
- return [durablePointers.get(pointerId) ?? pointer];
55
- }),
56
- redaction: failedPointerIds.size > 0
57
- ? {
58
- ...event.redaction,
59
- raw_evidence_pointer_ids: event.redaction.raw_evidence_pointer_ids.filter((pointerId) => !failedPointerIds.has(pointerId)),
60
- }
61
- : event.redaction,
62
- })),
25
+ events: envelope.events.map((event) => reconcileEventEvidence(event, failedPointerIds, failedOutcomes, durablePointers)),
26
+ };
27
+ }
28
+ function reconcileEventEvidence(event, failedPointerIds, failedOutcomes, durablePointers) {
29
+ return {
30
+ ...event,
31
+ metrics: failedPointerIds.size > 0
32
+ ? {
33
+ ...event.metrics,
34
+ evidence_failed_count: (event.metrics["evidence_failed_count"] ?? 0) +
35
+ failedOutcomes.length,
36
+ }
37
+ : event.metrics,
38
+ attributes: failedPointerIds.size > 0 && event.evidence_completeness
39
+ ? {
40
+ ...event.attributes,
41
+ evidence_completeness_schema_version: event.evidence_completeness.schema_version,
42
+ evidence_completeness_status: "partial",
43
+ evidence_incomplete: true,
44
+ }
45
+ : event.attributes,
46
+ evidence_completeness: failedPointerIds.size > 0 && event.evidence_completeness
47
+ ? markCompletenessUploadFailures(event.evidence_completeness, failedOutcomes)
48
+ : event.evidence_completeness,
49
+ raw_evidence_pointers: event.raw_evidence_pointers.flatMap((pointer) => {
50
+ const pointerId = pointer.raw_evidence_pointer_id;
51
+ if (failedPointerIds.has(pointerId))
52
+ return [];
53
+ return [durablePointers.get(pointerId) ?? pointer];
54
+ }),
55
+ redaction: failedPointerIds.size > 0
56
+ ? {
57
+ ...event.redaction,
58
+ raw_evidence_pointer_ids: event.redaction.raw_evidence_pointer_ids.filter((pointerId) => !failedPointerIds.has(pointerId)),
59
+ }
60
+ : event.redaction,
63
61
  };
64
62
  }
65
63
  /**
@@ -98,6 +96,25 @@ function markCompletenessUploadFailures(completeness, failedOutcomes) {
98
96
  failed_count: existing.failed_count + count,
99
97
  };
100
98
  }
99
+ const failureReasons = mergeUploadFailureReasons(completeness, failureCounts);
100
+ return EvidenceCompletenessPayloadSchema.parse({
101
+ ...completeness,
102
+ status: "partial",
103
+ source_counts: sourceCounts,
104
+ totals: {
105
+ ...completeness.totals,
106
+ failed_count: completeness.totals.failed_count + totalFailures,
107
+ },
108
+ failure_reasons: failureReasons.sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
109
+ notes: [
110
+ ...new Set([
111
+ ...completeness.notes,
112
+ "Some collected evidence did not become durable; downstream analysis should lower confidence.",
113
+ ]),
114
+ ],
115
+ });
116
+ }
117
+ function mergeUploadFailureReasons(completeness, failureCounts) {
101
118
  const failureReasons = [...completeness.failure_reasons];
102
119
  for (const [source, count] of failureCounts) {
103
120
  const reason = "upload_failed";
@@ -115,20 +132,5 @@ function markCompletenessUploadFailures(completeness, failedOutcomes) {
115
132
  }
116
133
  }
117
134
  }
118
- return EvidenceCompletenessPayloadSchema.parse({
119
- ...completeness,
120
- status: "partial",
121
- source_counts: sourceCounts,
122
- totals: {
123
- ...completeness.totals,
124
- failed_count: completeness.totals.failed_count + totalFailures,
125
- },
126
- failure_reasons: failureReasons.sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
127
- notes: [
128
- ...new Set([
129
- ...completeness.notes,
130
- "Some collected evidence did not become durable; downstream analysis should lower confidence.",
131
- ]),
132
- ],
133
- });
135
+ return failureReasons;
134
136
  }