@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
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.236");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.238");
25
25
  return 0;
26
26
  }
27
27
 
@@ -165,6 +165,45 @@ export async function admitOutsideRootSessions(options) {
165
165
  if (sightings.length === 0) {
166
166
  return { ...scan, outsideRoots: null };
167
167
  }
168
+ const { admitted, holds, counts, budget } = selectOutsideRootSessions(sightings, plan);
169
+ if (admitted.length === 0) {
170
+ console.error("[session-sync] outside-root raw upload", JSON.stringify({ reason: "nothing_to_upload_outside_roots", ...counts, ...budget }));
171
+ return { ...scan, outsideRoots: { workspace: null, holds, counts } };
172
+ }
173
+ const workspace = outsideRootsWorkspace(options.homeDir);
174
+ try {
175
+ await ensureOutsideRootsWorkspace(workspace);
176
+ }
177
+ catch (error) {
178
+ // Not silent, and not a crash: every outside-root session keeps waiting and
179
+ // says why, and the in-root sessions of this tick are unaffected.
180
+ for (const sighting of admitted) {
181
+ holds.set(outsideRootKey(sighting.source, sighting.sessionId), "outside_root_workspace_unavailable");
182
+ }
183
+ console.error("[session-sync] outside-root raw upload", JSON.stringify({ reason: "workspace_unavailable", ...counts, ...budget, ...describeError(error) }));
184
+ return { ...scan, outsideRoots: { workspace: null, holds, counts } };
185
+ }
186
+ const admittedKeys = new Set(admitted.map((sighting) => outsideRootKey(sighting.source, sighting.sessionId)));
187
+ const labelled = {
188
+ ...scan,
189
+ codexAttribution: {
190
+ ...scan.codexAttribution,
191
+ results: scan.codexAttribution.results.map((result) => admittedKeys.has(outsideRootKey("codex", result.codex_session_id)) && isUnclaimedOutsideRootResult(result)
192
+ ? { ...result, worktree: workspace }
193
+ : result),
194
+ },
195
+ claudeAttribution: {
196
+ ...scan.claudeAttribution,
197
+ results: scan.claudeAttribution.results.map((result) => admittedKeys.has(outsideRootKey("claude_code", result.claude_session_id)) && isUnclaimedOutsideRootResult(result)
198
+ ? { ...result, worktree: workspace }
199
+ : result),
200
+ },
201
+ outsideRoots: { workspace, holds, counts },
202
+ };
203
+ console.error("[session-sync] outside-root raw upload", JSON.stringify({ reason: "outside_root_sessions_admitted", ...counts, ...budget }));
204
+ return labelled;
205
+ }
206
+ function selectOutsideRootSessions(sightings, plan) {
168
207
  const cursorFor = (source) => source === "codex" ? plan.codexCursorBefore : plan.claudeCursorBefore;
169
208
  const free = [];
170
209
  const owed = [];
@@ -207,42 +246,7 @@ export async function admitOutsideRootSessions(options) {
207
246
  bytes_per_tick: OUTSIDE_ROOT_BYTES_PER_TICK,
208
247
  objects_per_tick: OUTSIDE_ROOT_OBJECTS_PER_TICK,
209
248
  };
210
- if (admitted.length === 0) {
211
- console.error("[session-sync] outside-root raw upload", JSON.stringify({ reason: "nothing_to_upload_outside_roots", ...counts, ...budget }));
212
- return { ...scan, outsideRoots: { workspace: null, holds, counts } };
213
- }
214
- const workspace = outsideRootsWorkspace(options.homeDir);
215
- try {
216
- await ensureOutsideRootsWorkspace(workspace);
217
- }
218
- catch (error) {
219
- // Not silent, and not a crash: every outside-root session keeps waiting and
220
- // says why, and the in-root sessions of this tick are unaffected.
221
- for (const sighting of admitted) {
222
- holds.set(outsideRootKey(sighting.source, sighting.sessionId), "outside_root_workspace_unavailable");
223
- }
224
- console.error("[session-sync] outside-root raw upload", JSON.stringify({ reason: "workspace_unavailable", ...counts, ...budget, ...describeError(error) }));
225
- return { ...scan, outsideRoots: { workspace: null, holds, counts } };
226
- }
227
- const admittedKeys = new Set(admitted.map((sighting) => outsideRootKey(sighting.source, sighting.sessionId)));
228
- const labelled = {
229
- ...scan,
230
- codexAttribution: {
231
- ...scan.codexAttribution,
232
- results: scan.codexAttribution.results.map((result) => admittedKeys.has(outsideRootKey("codex", result.codex_session_id)) && isUnclaimedOutsideRootResult(result)
233
- ? { ...result, worktree: workspace }
234
- : result),
235
- },
236
- claudeAttribution: {
237
- ...scan.claudeAttribution,
238
- results: scan.claudeAttribution.results.map((result) => admittedKeys.has(outsideRootKey("claude_code", result.claude_session_id)) && isUnclaimedOutsideRootResult(result)
239
- ? { ...result, worktree: workspace }
240
- : result),
241
- },
242
- outsideRoots: { workspace, holds, counts },
243
- };
244
- console.error("[session-sync] outside-root raw upload", JSON.stringify({ reason: "outside_root_sessions_admitted", ...counts, ...budget }));
245
- return labelled;
249
+ return { admitted, holds, counts, budget };
246
250
  }
247
251
  /**
248
252
  * Says why an outside-root session did not ride this tick, on its report row.
@@ -47,16 +47,7 @@ export async function planSyncFromLocalState(options) {
47
47
  const config = await readLocalCollectorConfig(options.paths).catch(() => null);
48
48
  const claudeEnabled = config?.collect_claude_jsonl !== false;
49
49
  const collectionRoots = normalizeCollectionRoots(options.approvedCollectionRoots ?? config?.default_repo_paths ?? []);
50
- const [codexCursorBefore, claudeCursorBefore, uploadSpool, outsideRootCatchUpPending] = await Promise.all([
51
- readRawEvidenceCursor(options.paths).catch(() => emptyRawEvidenceCursorState()),
52
- claudeEnabled
53
- ? readRawEvidenceCursor(options.paths, {
54
- filename: CLAUDE_CURSOR_FILENAME,
55
- }).catch(() => emptyRawEvidenceCursorState())
56
- : Promise.resolve(emptyRawEvidenceCursorState()),
57
- readLocalUploadSpoolState(options.paths).catch(() => null),
58
- outsideRootCatchUpIsPending(options.paths),
59
- ]);
50
+ const [codexCursorBefore, claudeCursorBefore, uploadSpool, outsideRootCatchUpPending] = await readSyncRetryState(options.paths, claudeEnabled);
60
51
  // A spooled upload from a CLI old enough not to have recorded its source
61
52
  // could have come from either one, so both sources own it until it clears.
62
53
  const legacyRetryPending = Boolean(uploadSpool?.pending_uploads.some((entry) => entry.raw_evidence_file_count > 0 && entry.retry_sources.length === 0));
@@ -78,6 +69,19 @@ export async function planSyncFromLocalState(options) {
78
69
  outsideRootCatchUpPending,
79
70
  };
80
71
  }
72
+ async function readSyncRetryState(paths, claudeEnabled) {
73
+ const [codexCursorBefore, claudeCursorBefore, uploadSpool, outsideRootCatchUpPending] = await Promise.all([
74
+ readRawEvidenceCursor(paths).catch(() => emptyRawEvidenceCursorState()),
75
+ claudeEnabled
76
+ ? readRawEvidenceCursor(paths, {
77
+ filename: CLAUDE_CURSOR_FILENAME,
78
+ }).catch(() => emptyRawEvidenceCursorState())
79
+ : Promise.resolve(emptyRawEvidenceCursorState()),
80
+ readLocalUploadSpoolState(paths).catch(() => null),
81
+ outsideRootCatchUpIsPending(paths),
82
+ ]);
83
+ return [codexCursorBefore, claudeCursorBefore, uploadSpool, outsideRootCatchUpPending];
84
+ }
81
85
  /**
82
86
  * How far back a retry-widened scan should reopen: the age of the oldest
83
87
  * undurable file this cursor is still carrying, plus a day of buffer — or
@@ -44,28 +44,7 @@ export async function syncEveryTargetWorktree(options) {
44
44
  items: syncWorktrees,
45
45
  shouldYield: run.yieldCheck,
46
46
  describe: (outcome) => ({ status: outcome.sync.status }),
47
- runOne: (worktree) => {
48
- // The outside-root pass has its own per-tick allowance (bytes and files),
49
- // so a busy in-root tick cannot starve it and it cannot take in-root room.
50
- if (isOutsideRootsWorkspace(worktree)) {
51
- giveOutsideRootsTheirOwnAllowance(rawEvidenceBudget);
52
- }
53
- return syncOneWorktree({
54
- run,
55
- now: options.now,
56
- plan: options.plan,
57
- scan,
58
- worktree,
59
- syncWorktrees,
60
- rawEvidenceBudget,
61
- // A target git discovery never produced is one the fallback attribution
62
- // synthesized, and it syncs through its own attributed work context.
63
- isDiscoveredWorktree: discoveredWorktreeKeys.has(liveSyncTargetKey(worktree)),
64
- recordDampedClaudeSession: (sessionId, priorPointer) => {
65
- dampedClaudePointers.set(sessionId, priorPointer);
66
- },
67
- });
68
- },
47
+ runOne: (worktree) => syncTargetWithAllowance(options, worktree, syncWorktrees, rawEvidenceBudget, discoveredWorktreeKeys, dampedClaudePointers),
69
48
  });
70
49
  const outcomes = loop.results;
71
50
  const everyWorktreeUploaded = outcomes.every((outcome) => outcome.sync.status === "uploaded");
@@ -77,6 +56,29 @@ export async function syncEveryTargetWorktree(options) {
77
56
  yieldedWorktreesLeft: loop.left,
78
57
  };
79
58
  }
59
+ function syncTargetWithAllowance(options, worktree, syncWorktrees, rawEvidenceBudget, discoveredWorktreeKeys, dampedClaudePointers) {
60
+ const { run, scan } = options;
61
+ // The outside-root pass has its own per-tick allowance (bytes and files),
62
+ // so a busy in-root tick cannot starve it and it cannot take in-root room.
63
+ if (isOutsideRootsWorkspace(worktree)) {
64
+ giveOutsideRootsTheirOwnAllowance(rawEvidenceBudget);
65
+ }
66
+ return syncOneWorktree({
67
+ run,
68
+ now: options.now,
69
+ plan: options.plan,
70
+ scan,
71
+ worktree,
72
+ syncWorktrees,
73
+ rawEvidenceBudget,
74
+ // A target git discovery never produced is one the fallback attribution
75
+ // synthesized, and it syncs through its own attributed work context.
76
+ isDiscoveredWorktree: discoveredWorktreeKeys.has(liveSyncTargetKey(worktree)),
77
+ recordDampedClaudeSession: (sessionId, priorPointer) => {
78
+ dampedClaudePointers.set(sessionId, priorPointer);
79
+ },
80
+ });
81
+ }
80
82
  /** Every Claude session the cursor already holds a durable pointer for. */
81
83
  function claudeDurablePointersFromCursor(claudeCursorBefore) {
82
84
  const pointersBySessionId = new Map();
@@ -108,14 +108,7 @@ export async function runAttributedWorktreeSync(options) {
108
108
  plan,
109
109
  scan,
110
110
  });
111
- const ticketByWorktree = new Map();
112
- for (const worktree of options.worktrees) {
113
- const context = await readLocalWorkContextForRepo(paths, worktree.repo_root).catch(() => null);
114
- const ticket = context?.linear_ticket_id ?? context?.active_ticket_id;
115
- if (ticket)
116
- ticketByWorktree.set(worktree.worktree_fingerprint, ticket);
117
- }
118
- console.error("[session-sync] bindings read", JSON.stringify({ bound_worktrees: ticketByWorktree.size, reason: "active_work_context" }));
111
+ const ticketByWorktree = await readWorktreeTickets(paths, options.worktrees);
119
112
  // BLI-13028: which commits each session's working tree created while it ran.
120
113
  // Read from the scan as it was BEFORE the outside-root step: the synthetic
121
114
  // workspace is not a git tree, and no commit belongs to it.
@@ -131,6 +124,21 @@ export async function runAttributedWorktreeSync(options) {
131
124
  now,
132
125
  claudePriorDurablePointers: worktreePass.claudePriorDurablePointers,
133
126
  }), commitsBySession), scan.outsideRoots?.holds);
127
+ reportCountedSessionFacts(sessions);
128
+ return finishAttributedSync(options, { paths, now, plan, scan, sessions, worktreePass });
129
+ }
130
+ async function readWorktreeTickets(paths, worktrees) {
131
+ const ticketByWorktree = new Map();
132
+ for (const worktree of worktrees) {
133
+ const context = await readLocalWorkContextForRepo(paths, worktree.repo_root).catch(() => null);
134
+ const ticket = context?.linear_ticket_id ?? context?.active_ticket_id;
135
+ if (ticket)
136
+ ticketByWorktree.set(worktree.worktree_fingerprint, ticket);
137
+ }
138
+ console.error("[session-sync] bindings read", JSON.stringify({ bound_worktrees: ticketByWorktree.size, reason: "active_work_context" }));
139
+ return ticketByWorktree;
140
+ }
141
+ function reportCountedSessionFacts(sessions) {
134
142
  // BLI-4341: every session should arrive counted. Say how many did, and say
135
143
  // it on the success path too: a silent drop from "all" to "some" is exactly
136
144
  // the kind of decay nobody notices.
@@ -141,6 +149,9 @@ export async function runAttributedWorktreeSync(options) {
141
149
  counted: sessionsCounted,
142
150
  tokens_total: sessions.reduce((total, session) => total + (session.session_facts?.total_tokens ?? 0), 0),
143
151
  }));
152
+ }
153
+ async function finishAttributedSync(options, context) {
154
+ const { paths, now, plan, scan, sessions, worktreePass } = context;
144
155
  const delivery = await reportSessionsAndAdvanceCursors({
145
156
  run: options,
146
157
  paths,
@@ -31,21 +31,27 @@ export async function runSlack(command, io) {
31
31
  return writeCommandFailure(io, command.json, result);
32
32
  writeLine(io.stderr, `[slack cli] connection answered ${JSON.stringify({ action: command.action, workspace: command.workspace ?? "all", reason: "ok" })}`);
33
33
  const body = asRecord(result.body);
34
- if (command.json)
35
- writeLine(io.stdout, JSON.stringify(body));
36
- else if (command.action === "disconnect")
37
- writeLine(io.stdout, `Disconnected Slack for ${command.workspace}.`);
38
- else
39
- for (const connection of (body["connections"] ?? [])) {
40
- if (!command.workspace || command.workspace === connection["workspace"])
41
- writeLine(io.stdout, `${connection["workspace"]}: ${connection["connected"] ? "connected" : "Connect Slack"} ${new URL(String(connection["connectPath"]), tower.dashboardUrl).toString()}`);
42
- }
34
+ writeConnectionReply(command, io, body, tower.dashboardUrl);
43
35
  return 0;
44
36
  }
45
37
  return command.action === "coverage"
46
38
  ? runCoverageAction(command, io, tower)
47
39
  : runReadAction(command, io, tower);
48
40
  }
41
+ function writeConnectionReply(command, io, body, dashboardUrl) {
42
+ if (command.json) {
43
+ writeLine(io.stdout, JSON.stringify(body));
44
+ return;
45
+ }
46
+ if (command.action === "disconnect") {
47
+ writeLine(io.stdout, `Disconnected Slack for ${command.workspace}.`);
48
+ return;
49
+ }
50
+ for (const connection of (body["connections"] ?? [])) {
51
+ if (!command.workspace || command.workspace === connection["workspace"])
52
+ writeLine(io.stdout, `${connection["workspace"]}: ${connection["connected"] ? "connected" : "Connect Slack"} ${new URL(String(connection["connectPath"]), dashboardUrl).toString()}`);
53
+ }
54
+ }
49
55
  async function runCoverageAction(command, io, tower) {
50
56
  const params = new URLSearchParams();
51
57
  if (command.workspace)
@@ -266,13 +272,7 @@ function writeReadReply(command, io, payload, read, messages) {
266
272
  else {
267
273
  if (messages.length > 0)
268
274
  writeLine(io.stdout, "");
269
- for (const message of messages) {
270
- writeLine(io.stdout, `#${String(message["channel"] ?? "?")} ${String(message["author"] ?? "?")} ${String(message["messageTs"] ?? "?")}`);
271
- writeLine(io.stdout, ` ${String(message["text"] ?? "")}`);
272
- if (typeof message["permalink"] === "string") {
273
- writeLine(io.stdout, dim(` ${message["permalink"]}`, styled));
274
- }
275
- }
275
+ writeExpandedMessages(io, messages, styled);
276
276
  }
277
277
  // A refusal's summary IS its note (tools-slack.ts `boundary`); print it once.
278
278
  if (typeof read["note"] === "string" && read["note"] && read["note"] !== header) {
@@ -280,6 +280,15 @@ function writeReadReply(command, io, payload, read, messages) {
280
280
  writeLine(io.stdout, dim(read["note"], styled));
281
281
  }
282
282
  }
283
+ function writeExpandedMessages(io, messages, styled) {
284
+ for (const message of messages) {
285
+ writeLine(io.stdout, `#${String(message["channel"] ?? "?")} ${String(message["author"] ?? "?")} ${String(message["messageTs"] ?? "?")}`);
286
+ writeLine(io.stdout, ` ${String(message["text"] ?? "")}`);
287
+ if (typeof message["permalink"] === "string") {
288
+ writeLine(io.stdout, dim(` ${message["permalink"]}`, styled));
289
+ }
290
+ }
291
+ }
283
292
  function compactMessageLine(message) {
284
293
  return `- #${String(message["channel"] ?? "?")} — ${String(message["author"] ?? "?")} — `
285
294
  + `${String(message["messageTs"] ?? "?")} — "${String(message["text"] ?? "")}" — ${String(message["permalink"] ?? "")}`;
@@ -37,7 +37,17 @@ export async function runSourcesCheck(command, io, now = new Date()) {
37
37
  const imageMode = await resolveImageHandlingMode(paths.state_dir);
38
38
  console.error("[collector sources] judging against this machine's image mode", JSON.stringify({ reason: `image_mode_from_${imageMode.source}`, mode: imageMode.mode, slim_aware: imageMode.slimAware }));
39
39
  const report = emptyReport(command, remote, local.newer_count, local.file_read_failed + local.directory_read_failed);
40
- for (const file of local.files) {
40
+ await judgeLocalTranscripts(local.files, remote, cursors, imageMode, describedKeys, report);
41
+ reportSourcesCheck(report, remote, startedAt);
42
+ if (command.json)
43
+ writeLine(io.stdout, JSON.stringify(report, null, 2));
44
+ else
45
+ for (const line of sourcesCheckLines(report))
46
+ writeLine(io.stdout, line);
47
+ return 0;
48
+ }
49
+ async function judgeLocalTranscripts(files, remote, cursors, imageMode, describedKeys, report) {
50
+ for (const file of files) {
41
51
  const judged = await judgeSource({
42
52
  file,
43
53
  remotes: remote.files.get(file.key) ?? [],
@@ -67,6 +77,8 @@ export async function runSourcesCheck(command, io, now = new Date()) {
67
77
  });
68
78
  }
69
79
  report.files.sort((a, b) => VERDICTS.indexOf(a.verdict) - VERDICTS.indexOf(b.verdict) || a.path.localeCompare(b.path));
80
+ }
81
+ function reportSourcesCheck(report, remote, startedAt) {
70
82
  // Counts and bytes only: never a path, a session id or a byte of content.
71
83
  console.error("[collector sources] check", JSON.stringify({
72
84
  reason: `sources_check_${report.status}`,
@@ -83,12 +95,6 @@ export async function runSourcesCheck(command, io, now = new Date()) {
83
95
  reasons: report.reasons,
84
96
  elapsed_ms: Date.now() - startedAt,
85
97
  }));
86
- if (command.json)
87
- writeLine(io.stdout, JSON.stringify(report, null, 2));
88
- else
89
- for (const line of sourcesCheckLines(report))
90
- writeLine(io.stdout, line);
91
- return 0;
92
98
  }
93
99
  function emptyReport(command, remote, newerSkipped, unreadable) {
94
100
  const { files, ...remoteCounts } = remote;
@@ -133,8 +133,26 @@ function trimmed(value) {
133
133
  export async function discoverLocalTranscripts(options) {
134
134
  const roots = sourcesRoots(options.homeDir);
135
135
  const result = { files: [], newer_count: 0, directory_read_failed: 0, file_read_failed: 0 };
136
- const found = [];
137
136
  let firstFailure = null;
137
+ const recordFailure = (error) => { firstFailure ??= describeError(error); };
138
+ const found = await findTranscriptFiles(options, roots, result, recordFailure);
139
+ await nameTranscriptFiles(found, result, recordFailure);
140
+ if (result.directory_read_failed > 0 || result.file_read_failed > 0) {
141
+ console.error("[collector sources] some transcripts could not be read; they are left out of the answer", JSON.stringify({
142
+ reason: "sources_discovery_incomplete",
143
+ directory_read_failed: result.directory_read_failed,
144
+ file_read_failed: result.file_read_failed,
145
+ found_count: result.files.length,
146
+ ...(firstFailure ? { first_failure: firstFailure } : {}),
147
+ }));
148
+ }
149
+ return result;
150
+ }
151
+ function shapeOrder(shape) {
152
+ return shape.kind === "claude_main" ? 0 : shape.kind === "codex_rollout" ? 1 : 2;
153
+ }
154
+ async function findTranscriptFiles(options, roots, result, recordFailure) {
155
+ const found = [];
138
156
  const stack = [...roots.codex, roots.claudeProjects];
139
157
  const seen = new Set();
140
158
  while (stack.length > 0) {
@@ -146,7 +164,7 @@ export async function discoverLocalTranscripts(options) {
146
164
  catch (error) {
147
165
  if (error?.code !== "ENOENT") {
148
166
  result.directory_read_failed += 1;
149
- firstFailure ??= describeError(error);
167
+ recordFailure(error);
150
168
  }
151
169
  continue;
152
170
  }
@@ -164,7 +182,7 @@ export async function discoverLocalTranscripts(options) {
164
182
  seen.add(full);
165
183
  const stat = await fs.stat(full).catch((error) => {
166
184
  result.file_read_failed += 1;
167
- firstFailure ??= describeError(error);
185
+ recordFailure(error);
168
186
  return null;
169
187
  });
170
188
  if (!stat)
@@ -176,6 +194,9 @@ export async function discoverLocalTranscripts(options) {
176
194
  found.push({ file: full, size: stat.size, mtimeMs: stat.mtimeMs, shape });
177
195
  }
178
196
  }
197
+ return found;
198
+ }
199
+ async function nameTranscriptFiles(found, result, recordFailure) {
179
200
  // Claude mains first, so a subagent file can take its main's content id.
180
201
  const claudeMainIds = new Map();
181
202
  found.sort((a, b) => shapeOrder(a.shape) - shapeOrder(b.shape) || a.file.localeCompare(b.file));
@@ -185,7 +206,7 @@ export async function discoverLocalTranscripts(options) {
185
206
  if (item.shape.kind !== "claude_subagent") {
186
207
  const read = await readContentSessionId(item.file, source).then((id) => ({ ok: true, id }), (error) => {
187
208
  result.file_read_failed += 1;
188
- firstFailure ??= describeError(error);
209
+ recordFailure(error);
189
210
  return { ok: false, id: null };
190
211
  });
191
212
  if (!read.ok)
@@ -216,17 +237,4 @@ export async function discoverLocalTranscripts(options) {
216
237
  key: transcriptKeyFor(source, named.session_id, item.shape.kind === "claude_subagent" ? item.shape.sidecar_stem : null),
217
238
  });
218
239
  }
219
- if (result.directory_read_failed > 0 || result.file_read_failed > 0) {
220
- console.error("[collector sources] some transcripts could not be read; they are left out of the answer", JSON.stringify({
221
- reason: "sources_discovery_incomplete",
222
- directory_read_failed: result.directory_read_failed,
223
- file_read_failed: result.file_read_failed,
224
- found_count: result.files.length,
225
- ...(firstFailure ? { first_failure: firstFailure } : {}),
226
- }));
227
- }
228
- return result;
229
- }
230
- function shapeOrder(shape) {
231
- return shape.kind === "claude_main" ? 0 : shape.kind === "codex_rollout" ? 1 : 2;
232
240
  }
@@ -162,24 +162,7 @@ upload = {}) {
162
162
  const mode = upload.mode ?? IMAGE_HANDLING_MODE;
163
163
  const describedKeys = upload.describedKeys ?? new Set();
164
164
  const slimAware = upload.slimAware ?? mode === "describe_and_drop";
165
- const redactForUpload = (text, original) => {
166
- const result = sanitizeTextEvidenceForUpload({
167
- text,
168
- originalBytes: text === original.toString("utf8") ? original : Buffer.from(text, "utf8"),
169
- redactedFields: [`${file.kind === "codex_rollout" ? "codex_jsonl" : file.kind === "claude_main" ? "claude_jsonl" : "claude_jsonl_sidecar"}.body`],
170
- secretLikeFileName: isSecretLikePath(path.basename(file.file_path)),
171
- });
172
- const hash = sha256(result.bytes);
173
- if (hash === REDACTION_STUB_SHA256)
174
- return null;
175
- // A whole-file mask is one 40-byte line, not the content: every copy Tower holds would look
176
- // "at least as large" and read stored_full. No redacted form, so the raw size answers instead.
177
- if (isWholeFileMask(result.bytes)) {
178
- console.error("[sources-verdict] redacted form is the whole-file mask: judged by raw size", JSON.stringify({ reason: "whole_file_mask", source: file.source, file_bytes: file.byte_size }));
179
- return null;
180
- }
181
- return { sha256: hash, byte_size: result.bytes.byteLength };
182
- };
165
+ const redactForUpload = (text, original) => redactTranscriptForm(file, text, original);
183
166
  let split = null;
184
167
  let whole = null;
185
168
  let bytes = null;
@@ -230,6 +213,24 @@ upload = {}) {
230
213
  },
231
214
  };
232
215
  }
216
+ function redactTranscriptForm(file, text, original) {
217
+ const result = sanitizeTextEvidenceForUpload({
218
+ text,
219
+ originalBytes: text === original.toString("utf8") ? original : Buffer.from(text, "utf8"),
220
+ redactedFields: [`${file.kind === "codex_rollout" ? "codex_jsonl" : file.kind === "claude_main" ? "claude_jsonl" : "claude_jsonl_sidecar"}.body`],
221
+ secretLikeFileName: isSecretLikePath(path.basename(file.file_path)),
222
+ });
223
+ const hash = sha256(result.bytes);
224
+ if (hash === REDACTION_STUB_SHA256)
225
+ return null;
226
+ // A whole-file mask is one 40-byte line, not the content: every copy Tower holds would look
227
+ // "at least as large" and read stored_full. No redacted form, so the raw size answers instead.
228
+ if (isWholeFileMask(result.bytes)) {
229
+ console.error("[sources-verdict] redacted form is the whole-file mask: judged by raw size", JSON.stringify({ reason: "whole_file_mask", source: file.source, file_bytes: file.byte_size }));
230
+ return null;
231
+ }
232
+ return { sha256: hash, byte_size: result.bytes.byteLength };
233
+ }
233
234
  function sha256(buffer) {
234
235
  return crypto.createHash("sha256").update(buffer).digest("hex");
235
236
  }
@@ -41,25 +41,7 @@ export async function runSpeed(command, io, deps = {}) {
41
41
  : everyPage;
42
42
  const results = [];
43
43
  for (const page of chosen) {
44
- const samples = [];
45
- for (let run = 0; run < runs; run += 1) {
46
- const sample = await timeOneRead(page, { dashboardUrl, deviceToken: session.device_token, io, now, log });
47
- samples.push(sample);
48
- // A page this account may not open answers the same way every time.
49
- if (classifyStatus(sample.status) === "not_permitted")
50
- break;
51
- }
52
- const summary = summarizeSamples(page, samples);
53
- results.push(summary);
54
- log(`[speed cli] page measured ${JSON.stringify({
55
- page: summary.page,
56
- outcome: summary.outcome,
57
- http_status: summary.http_status,
58
- runs: samples.length,
59
- failed_runs: summary.failed_runs,
60
- p50_ms: summary.p50_ms,
61
- ...(summary.reason ? { reason: summary.reason } : {}),
62
- })}`);
44
+ results.push(await measurePage(page, runs, { dashboardUrl, deviceToken: session.device_token, io, now, log }));
63
45
  }
64
46
  const ranked = rankPages(results);
65
47
  const failed = ranked.filter((page) => page.outcome === "failed").length;
@@ -86,6 +68,28 @@ export async function runSpeed(command, io, deps = {}) {
86
68
  }
87
69
  return failed === 0 ? 0 : 1;
88
70
  }
71
+ async function measurePage(page, runs, context) {
72
+ const { dashboardUrl, deviceToken, io, now, log } = context;
73
+ const samples = [];
74
+ for (let run = 0; run < runs; run += 1) {
75
+ const sample = await timeOneRead(page, { dashboardUrl, deviceToken, io, now, log });
76
+ samples.push(sample);
77
+ // A page this account may not open answers the same way every time.
78
+ if (classifyStatus(sample.status) === "not_permitted")
79
+ break;
80
+ }
81
+ const summary = summarizeSamples(page, samples);
82
+ log(`[speed cli] page measured ${JSON.stringify({
83
+ page: summary.page,
84
+ outcome: summary.outcome,
85
+ http_status: summary.http_status,
86
+ runs: samples.length,
87
+ failed_runs: summary.failed_runs,
88
+ p50_ms: summary.p50_ms,
89
+ ...(summary.reason ? { reason: summary.reason } : {}),
90
+ })}`);
91
+ return summary;
92
+ }
89
93
  async function timeOneRead(page, context) {
90
94
  const startedAt = context.now();
91
95
  const answer = await towerRequest({
@@ -13,22 +13,7 @@ export async function runStart(command, io) {
13
13
  };
14
14
  const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos, homeDir: command.homeDir }, io);
15
15
  if (worktrees.length > 1) {
16
- const contexts = await Promise.all(worktrees.map((worktree) => startLocalWorkContext({
17
- homeDir: command.homeDir,
18
- repoRoot: worktree.repo_root,
19
- branch: command.branch,
20
- activeTicketId: command.activeTicketId,
21
- ...ticketIds,
22
- clearTicket: command.clearTicket,
23
- topicLabel: command.topicLabel,
24
- topicSummaryRedacted: command.topicSummaryRedacted,
25
- workIntent: command.workIntent,
26
- workPhase: command.workPhase,
27
- intentSource: command.intentSource,
28
- intentConfidence: command.intentConfidence,
29
- operatorId: command.operatorId,
30
- sessionId: command.sessionId,
31
- })));
16
+ const contexts = await startDiscoveredWorktrees(command, ticketIds, worktrees);
32
17
  if (command.json) {
33
18
  writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", contexts, ...(binding ? { ticket_binding: binding } : {}) }, null, 2));
34
19
  return 0;
@@ -56,6 +41,25 @@ export async function runStart(command, io) {
56
41
  writeLine(io.stdout, `Context: ${context.work_context_id}`);
57
42
  return 0;
58
43
  }
44
+ async function startDiscoveredWorktrees(command, ticketIds, worktrees) {
45
+ const contexts = await Promise.all(worktrees.map((worktree) => startLocalWorkContext({
46
+ homeDir: command.homeDir,
47
+ repoRoot: worktree.repo_root,
48
+ branch: command.branch,
49
+ activeTicketId: command.activeTicketId,
50
+ ...ticketIds,
51
+ clearTicket: command.clearTicket,
52
+ topicLabel: command.topicLabel,
53
+ topicSummaryRedacted: command.topicSummaryRedacted,
54
+ workIntent: command.workIntent,
55
+ workPhase: command.workPhase,
56
+ intentSource: command.intentSource,
57
+ intentConfidence: command.intentConfidence,
58
+ operatorId: command.operatorId,
59
+ sessionId: command.sessionId,
60
+ })));
61
+ return contexts;
62
+ }
59
63
  /**
60
64
  * Both trackers' ids when they are known, and the reason when one is not. A
61
65
  * lookup that resolved nothing still prints its sentence: a person who typed