@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
@@ -8,430 +8,19 @@
8
8
  *
9
9
  * Table of contents:
10
10
  *
11
- * - `queueCodexSessionReport` — write the rows down first. A report survives its
12
- * source file, so an endpoint outage cannot age the evidence out.
13
- * - `flushPendingCodexSessionReports` drain that queue with current
14
- * credentials; each report is deleted only against a receipt we can read.
15
- * - `postCodexSessionReport` — the one-shot path, for a harvest that already has
16
- * a repo in hand.
17
- * - `reportCodexSessionAttributions` / `postCodexSessionAttributionChunk` the
18
- * wire: chunk at the endpoint's session cap, then attempt each chunk under a
19
- * decision table of "final answer" versus "worth another attempt".
11
+ * - `upload-session-reports-queue.ts` — `queueCodexSessionReport` writes the
12
+ * rows down first, so a report survives its source file and an endpoint
13
+ * outage cannot age the evidence out; `flushPendingCodexSessionReports`
14
+ * drains that queue with current credentials, deleting each report only
15
+ * against a receipt it can read.
16
+ * - `upload-session-reports-wire.ts` — `postCodexSessionReport`, the one-shot
17
+ * path for a harvest that already has a repo in hand, and
18
+ * `reportCodexSessionAttributions`, the wire itself: chunk at the endpoint's
19
+ * session cap, then attempt each chunk under a decision table of "final
20
+ * answer" versus "worth another attempt".
20
21
  *
21
- * Nothing here throws at the caller. Every failure comes back as a reason label
22
- * so a dashboard that is old, down or unreachable cannot fail a harvest.
22
+ * Nothing here throws at the caller. Every failure comes back as a reason
23
+ * label so a dashboard that is old, down or unreachable cannot fail a harvest.
23
24
  */
24
- import { CODEX_SESSION_REPORT_MAX_SESSIONS, CodexSessionAttributionReportResponseSchema, } from "@bli-cockpit/telemetry-core";
25
- import path from "node:path";
26
- import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
27
- import { readLocalUploadSpoolState, recordPendingSessionReport, recordSessionReportFailure, recordSessionReportSuccess, } from "./spool/local-spool.js";
28
- import { makeCollectorProvenance, makeUploadWorkContext, safeRepoLabel, } from "./upload-envelope.js";
29
- import { normalizeDashboardUrl, readResponseJson } from "./upload-http.js";
30
- import { describeError } from "./health-detail.js";
31
- /** Attempts per chunk, and the linear 250ms-per-attempt backoff between them. */
32
- const DEFAULT_REPORT_ATTEMPTS = 3;
33
- const REPORT_RETRY_BACKOFF_STEP_MS = 250;
34
- /**
35
- * Posts a Codex session attribution report using the paired collector
36
- * credentials and the work context of a representative repo. Failures come
37
- * back as reason labels so the harvest never hard-fails on reporting.
38
- */
39
- export async function postCodexSessionReport(options) {
40
- if (options.sessions.length === 0) {
41
- return emptyCodexSessionReportResult("no_sessions_observed");
42
- }
43
- const fetchImpl = options.fetch ?? globalThis.fetch;
44
- if (!fetchImpl) {
45
- throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
46
- }
47
- try {
48
- const paths = getCollectorRuntimePaths(options.homeDir);
49
- const config = await readLocalCollectorConfig(paths);
50
- const sessionFile = await readLocalCollectorSessionFile(paths);
51
- const session = await readLocalSessionReference(paths);
52
- if (session.session_state !== "valid") {
53
- return emptyCodexSessionReportResult("collector_not_paired");
54
- }
55
- const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
56
- const activeContext = await readLocalWorkContextForRepo(paths, repoRoot);
57
- const repoLabel = safeRepoLabel(activeContext.repo_label ?? repoRoot);
58
- const provenance = makeCollectorProvenance({
59
- context: makeUploadWorkContext({
60
- activeContext,
61
- session,
62
- repoLabel,
63
- now: options.now ?? new Date(),
64
- }),
65
- session,
66
- repoLabel,
67
- });
68
- return await reportCodexSessionAttributions({
69
- fetchImpl,
70
- dashboardUrl: normalizeDashboardUrl(options.dashboardUrl ?? sessionFile.dashboard_url ?? config.dashboard_url),
71
- deviceToken: sessionFile.device_token,
72
- provenance,
73
- generatedAt: (options.now ?? new Date()).toISOString(),
74
- sessions: options.sessions,
75
- });
76
- }
77
- catch (error) {
78
- // `collector_not_ready` covers this whole block — config read, session
79
- // read, work-context read AND the report request itself. The label is
80
- // kept (it is what the receipt contract carries) but it is broader than
81
- // its name suggests: a network failure inside `reportCodexSessionAttributions`
82
- // also lands here and reads as "this machine is not set up". The detail is
83
- // now the only way to tell those apart. Flagged, not reclassified —
84
- // narrowing the label is a contract change (BLI-3238).
85
- console.error("[session-reports] could not report session attributions", JSON.stringify({
86
- reason: "collector_not_ready",
87
- session_count: options.sessions.length,
88
- ...describeError(error),
89
- }));
90
- return emptyCodexSessionReportResult("collector_not_ready");
91
- }
92
- }
93
- /**
94
- * Queues the safe session-attribution rows before the network request. The
95
- * queue is merged per work context, so an endpoint outage retains every
96
- * observed session without growing one duplicate report per scheduler pass.
97
- */
98
- export async function queueCodexSessionReport(options) {
99
- if (options.sessions.length === 0)
100
- return null;
101
- const paths = getCollectorRuntimePaths(options.homeDir);
102
- return await recordPendingSessionReport(paths, {
103
- attempted_at: options.generatedAt,
104
- dashboard_url: normalizeDashboardUrl(options.dashboardUrl),
105
- generated_at: options.generatedAt,
106
- work_context_id: options.workContextId,
107
- repo_label: safeRepoLabel(options.repoLabel),
108
- branch: options.branch,
109
- repo_fingerprint: options.repoFingerprint,
110
- repo_origin_url: options.repoOriginUrl,
111
- worktree_label: options.worktreeLabel,
112
- worktree_fingerprint: options.worktreeFingerprint,
113
- worktree_is_primary: options.worktreeIsPrimary,
114
- sessions: options.sessions,
115
- });
116
- }
117
- /**
118
- * Flushes every durable session-attribution report using current credentials.
119
- * Report payloads do not depend on the source files still being inside the live
120
- * scan window, so a transient endpoint failure cannot silently age them out.
121
- */
122
- export async function flushPendingCodexSessionReports(options) {
123
- const paths = getCollectorRuntimePaths(options.homeDir);
124
- const state = await readLocalUploadSpoolState(paths);
125
- if (state.pending_session_reports.length === 0) {
126
- return emptyCodexSessionReportResult("no_pending_session_reports");
127
- }
128
- const attemptedAt = (options.now ?? new Date()).toISOString();
129
- const fetchImpl = options.fetch ?? globalThis.fetch;
130
- if (!fetchImpl) {
131
- throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
132
- }
133
- let sessionFile;
134
- let session;
135
- try {
136
- [sessionFile, session] = await Promise.all([
137
- readLocalCollectorSessionFile(paths),
138
- readLocalSessionReference(paths),
139
- ]);
140
- }
141
- catch (error) {
142
- // This marks every queued report as failed. The count is what makes it
143
- // worth a line: an unreadable session file here strands N reports at once
144
- // and the only visible trace is a spool that stops draining (BLI-3238).
145
- console.error("[session-reports] session file unreadable, failing the pending reports", JSON.stringify({
146
- reason: "collector_not_ready",
147
- pending_report_count: state.pending_session_reports.length,
148
- ...describeError(error),
149
- }));
150
- return await failPendingSessionReports(paths, state.pending_session_reports, attemptedAt, "collector_not_ready");
151
- }
152
- if (session.session_state !== "valid") {
153
- return await failPendingSessionReports(paths, state.pending_session_reports, attemptedAt, "collector_not_paired");
154
- }
155
- const results = [];
156
- for (const pending of state.pending_session_reports) {
157
- const result = await reportCodexSessionAttributions({
158
- fetchImpl,
159
- dashboardUrl: normalizeDashboardUrl(pending.dashboard_url || sessionFile.dashboard_url),
160
- deviceToken: sessionFile.device_token,
161
- provenance: provenanceForPendingReport(pending, session),
162
- generatedAt: pending.generated_at,
163
- sessions: pending.sessions,
164
- maxAttemptsPerRequest: options.maxAttemptsPerRequest,
165
- sleep: options.sleep,
166
- });
167
- results.push(result);
168
- if (result.posted) {
169
- await recordSessionReportSuccess(paths, {
170
- reportId: pending.report_id,
171
- attemptedAt,
172
- });
173
- }
174
- else {
175
- await recordSessionReportFailure(paths, {
176
- reportId: pending.report_id,
177
- attemptedAt,
178
- reason: result.reason,
179
- });
180
- }
181
- }
182
- return combineCodexSessionReportResults(results);
183
- }
184
- /**
185
- * A queued report carries its own repo and worktree identity, captured when the
186
- * sessions were observed. Only the operator and device session come from the
187
- * credentials in use now — the report may be days old and from another branch.
188
- */
189
- function provenanceForPendingReport(pending, session) {
190
- return {
191
- capture_source: "collector_runtime",
192
- capture_adapter_version: LOCAL_COLLECTOR_VERSION,
193
- collector_version: LOCAL_COLLECTOR_VERSION,
194
- repo: pending.repo_label,
195
- branch: pending.branch,
196
- repo_label: pending.repo_label,
197
- repo_fingerprint: pending.repo_fingerprint,
198
- ...(pending.repo_origin_url
199
- ? { repo_origin_url: pending.repo_origin_url }
200
- : {}),
201
- worktree_label: pending.worktree_label,
202
- worktree_fingerprint: pending.worktree_fingerprint,
203
- worktree_is_primary: pending.worktree_is_primary,
204
- operator_id: session.operator_id,
205
- session_id: session.session_id,
206
- work_context_id: pending.work_context_id,
207
- };
208
- }
209
- async function failPendingSessionReports(paths, pendingReports, attemptedAt, reason) {
210
- for (const pending of pendingReports) {
211
- await recordSessionReportFailure(paths, {
212
- reportId: pending.report_id,
213
- attemptedAt,
214
- reason,
215
- });
216
- }
217
- return {
218
- posted: false,
219
- reason,
220
- chunk_count: 0,
221
- recorded_count: 0,
222
- failed_count: pendingReports.length,
223
- chunks: [],
224
- };
225
- }
226
- function combineCodexSessionReportResults(results) {
227
- if (results.length === 0) {
228
- return emptyCodexSessionReportResult("no_pending_session_reports");
229
- }
230
- const failed = results.filter((result) => !result.posted);
231
- return {
232
- posted: failed.length === 0,
233
- reason: failed[0]?.reason ??
234
- (results.every((result) => result.reason === "recorded")
235
- ? "recorded"
236
- : results[0]?.reason ?? "recorded"),
237
- chunk_count: results.reduce((total, result) => total + result.chunk_count, 0),
238
- recorded_count: results.reduce((total, result) => total + result.recorded_count, 0),
239
- failed_count: results.reduce((total, result) => total + result.failed_count, 0),
240
- chunks: results.flatMap((result) => result.chunks),
241
- };
242
- }
243
- /**
244
- * Reports Codex session attribution outcomes after sync. Non-fatal by design:
245
- * older dashboards without the endpoint must not fail the harvest, so the
246
- * caller receives a posted/skipped label instead of an exception.
247
- */
248
- export async function reportCodexSessionAttributions(options) {
249
- if (options.sessions.length === 0) {
250
- return emptyCodexSessionReportResult("no_sessions_observed");
251
- }
252
- const chunks = [];
253
- const sessionChunks = chunkArray(options.sessions, CODEX_SESSION_REPORT_MAX_SESSIONS);
254
- for (const [index, sessions] of sessionChunks.entries()) {
255
- const chunk = await postCodexSessionAttributionChunk({
256
- ...options,
257
- sessions,
258
- batchIndex: index + 1,
259
- });
260
- chunks.push(chunk);
261
- }
262
- const failedCount = chunks.filter((chunk) => !chunk.posted).length;
263
- const recordedCount = chunks.reduce((sum, chunk) => sum + chunk.recorded_count, 0);
264
- return {
265
- posted: failedCount === 0,
266
- reason: failedCount === 0
267
- ? "recorded"
268
- : chunks.find((chunk) => !chunk.posted)?.reason ?? "report_failed",
269
- chunk_count: chunks.length,
270
- recorded_count: recordedCount,
271
- failed_count: failedCount,
272
- chunks,
273
- };
274
- }
275
- /**
276
- * One chunk, up to three attempts, as a decision table on what came back:
277
- *
278
- * | what came back | verdict |
279
- * |-------------------------------------|----------------------------------------|
280
- * | 404 | this dashboard has no endpoint; stop |
281
- * | 2xx with a readable, full receipt | recorded; stop |
282
- * | 2xx malformed or short of the count | keep the reason, try again |
283
- * | 4xx other than 429 | the server's final answer; stop |
284
- * | 429 or 5xx | keep the reason, try again |
285
- * | transport error | keep the reason, try again, no status |
286
- *
287
- * The row that looks wrong is the third one, and it is the important one: a 2xx
288
- * we cannot read is retried because the server may have died between persisting
289
- * the rows and answering. Only a schema-valid receipt that accounts for every
290
- * distinct session is safe to delete a durable pending report against.
291
- */
292
- async function postCodexSessionAttributionChunk(options) {
293
- const maxAttempts = options.maxAttemptsPerRequest ?? DEFAULT_REPORT_ATTEMPTS;
294
- const sleep = options.sleep ?? defaultReportSleep;
295
- const requiredAcknowledgements = requiredCodexSessionAcknowledgementCount(options.sessions);
296
- let lastStatus = null;
297
- let lastFailureReason = null;
298
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
299
- try {
300
- const response = await postSessionAttributionRequest(options);
301
- lastStatus = response.status;
302
- const body = await readResponseJson(response);
303
- if (response.status === 404) {
304
- return codexSessionReportChunkResult(options, {
305
- posted: false,
306
- reason: "codex_session_api_unavailable",
307
- httpStatus: response.status,
308
- recordedCount: 0,
309
- });
310
- }
311
- if (response.ok) {
312
- const receipt = readSessionReportReceipt(body, requiredAcknowledgements);
313
- if (receipt.accepted) {
314
- return codexSessionReportChunkResult(options, {
315
- posted: true,
316
- reason: "recorded",
317
- httpStatus: response.status,
318
- recordedCount: receipt.recorded_count,
319
- });
320
- }
321
- lastFailureReason = receipt.reason;
322
- }
323
- else {
324
- lastFailureReason = `report_failed_http_${response.status}`;
325
- if (isFinalHttpFailure(response.status)) {
326
- return codexSessionReportChunkResult(options, {
327
- posted: false,
328
- reason: lastFailureReason,
329
- httpStatus: response.status,
330
- recordedCount: 0,
331
- });
332
- }
333
- }
334
- }
335
- catch (error) {
336
- // Logged per attempt, on purpose: the interesting failure is the one
337
- // that repeats. Three identical ECONNRESETs and one DNS failure followed
338
- // by two resets are different stories, and `report_network_error` alone
339
- // tells neither (BLI-3238).
340
- console.error("[session-reports] report attempt failed before a status came back", JSON.stringify({
341
- reason: "report_network_error",
342
- attempt,
343
- max_attempts: maxAttempts,
344
- batch_index: options.batchIndex,
345
- session_count: options.sessions.length,
346
- ...describeError(error),
347
- }));
348
- lastStatus = null;
349
- lastFailureReason = "report_network_error";
350
- }
351
- if (attempt < maxAttempts) {
352
- await sleep(REPORT_RETRY_BACKOFF_STEP_MS * attempt);
353
- }
354
- }
355
- return codexSessionReportChunkResult(options, {
356
- posted: false,
357
- reason: lastFailureReason ??
358
- (lastStatus === null
359
- ? "report_network_error"
360
- : `report_failed_http_${lastStatus}`),
361
- httpStatus: lastStatus,
362
- recordedCount: 0,
363
- });
364
- }
365
- function postSessionAttributionRequest(options) {
366
- return options.fetchImpl(`${options.dashboardUrl}/api/ambient/codex-sessions`, {
367
- method: "POST",
368
- headers: {
369
- "Authorization": `Bearer ${options.deviceToken}`,
370
- "Content-Type": "application/json",
371
- },
372
- body: JSON.stringify({
373
- schema_version: "ambient-codex-session-attributions.v1",
374
- generated_at: options.generatedAt,
375
- provenance: options.provenance,
376
- sessions: options.sessions,
377
- }),
378
- });
379
- }
380
- /** Is this status the server's final answer, or worth another attempt? */
381
- function isFinalHttpFailure(status) {
382
- return status < 500 && status !== 429;
383
- }
384
- /**
385
- * Can we take this 2xx body as proof the rows are durable?
386
- *
387
- * Only if it parses as the receipt schema AND accounts for every distinct
388
- * session in the chunk. Anything less is treated as no receipt at all.
389
- */
390
- function readSessionReportReceipt(body, requiredAcknowledgements) {
391
- const parsed = CodexSessionAttributionReportResponseSchema.safeParse(body);
392
- if (!parsed.success) {
393
- return { accepted: false, reason: "report_invalid_response" };
394
- }
395
- if (parsed.data.recorded_count < requiredAcknowledgements) {
396
- return { accepted: false, reason: "report_incomplete_acknowledgement" };
397
- }
398
- return { accepted: true, recorded_count: parsed.data.recorded_count };
399
- }
400
- function codexSessionReportChunkResult(options, result) {
401
- return {
402
- batch_index: options.batchIndex,
403
- session_count: options.sessions.length,
404
- posted: result.posted,
405
- reason: result.reason,
406
- http_status: result.httpStatus,
407
- recorded_count: result.recordedCount,
408
- };
409
- }
410
- function emptyCodexSessionReportResult(reason) {
411
- return {
412
- posted: false,
413
- reason,
414
- chunk_count: 0,
415
- recorded_count: 0,
416
- failed_count: 0,
417
- chunks: [],
418
- };
419
- }
420
- function chunkArray(items, size) {
421
- const chunks = [];
422
- for (let offset = 0; offset < items.length; offset += size) {
423
- chunks.push(items.slice(offset, offset + size));
424
- }
425
- return chunks;
426
- }
427
- /**
428
- * How many rows the server must acknowledge, counted per distinct session
429
- * rather than per row — the same session observed twice in one chunk is still
430
- * one row on the far side.
431
- */
432
- function requiredCodexSessionAcknowledgementCount(sessions) {
433
- return new Set(sessions.map((session) => `${session.source ?? "codex"}:${session.codex_session_id}`)).size;
434
- }
435
- function defaultReportSleep(milliseconds) {
436
- return new Promise((resolve) => setTimeout(resolve, milliseconds));
437
- }
25
+ export { flushPendingCodexSessionReports, queueCodexSessionReport, } from "./upload-session-reports-queue.js";
26
+ export { postCodexSessionReport, reportCodexSessionAttributions, } from "./upload-session-reports-wire.js";