@bli-cockpit/cli 0.2.53 → 0.2.55

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 (60) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +247 -0
  2. package/dist/adapters/attribution-core-paths.js +182 -0
  3. package/dist/adapters/attribution-core-score.js +159 -0
  4. package/dist/adapters/attribution-core-types.js +13 -0
  5. package/dist/adapters/attribution-core.js +13 -565
  6. package/dist/adapters/claude-attribution-discovery.js +186 -0
  7. package/dist/adapters/claude-attribution-score.js +204 -0
  8. package/dist/adapters/claude-attribution-signals.js +180 -0
  9. package/dist/adapters/claude-attribution-types.js +25 -0
  10. package/dist/adapters/claude-attribution.js +14 -569
  11. package/dist/commands/doctor-access.js +129 -0
  12. package/dist/commands/doctor-pipeline.js +326 -0
  13. package/dist/commands/doctor-registration.js +105 -0
  14. package/dist/commands/doctor-report.js +111 -0
  15. package/dist/commands/doctor-update.js +120 -0
  16. package/dist/commands/doctor.js +8 -753
  17. package/dist/commands/heartbeat.js +8 -0
  18. package/dist/commands/jarvis-contracts.js +8 -0
  19. package/dist/commands/jarvis-render.js +413 -0
  20. package/dist/commands/jarvis-turn.js +305 -0
  21. package/dist/commands/jarvis.js +23 -698
  22. package/dist/commands/local-args-collector-setup.js +250 -0
  23. package/dist/commands/local-args-collector-status.js +227 -0
  24. package/dist/commands/local-args-collector-work.js +175 -0
  25. package/dist/commands/local-args-collector.js +19 -624
  26. package/dist/commands/local-args-tower-admin.js +456 -0
  27. package/dist/commands/local-args-tower-chat.js +194 -0
  28. package/dist/commands/local-args-tower-pages.js +314 -0
  29. package/dist/commands/local-args-tower.js +13 -880
  30. package/dist/commands/local-help.js +10 -2
  31. package/dist/commands/onboard-completion.js +136 -0
  32. package/dist/commands/onboard-flows.js +165 -0
  33. package/dist/commands/onboard-setup.js +102 -0
  34. package/dist/commands/onboard.js +5 -392
  35. package/dist/commands/public-root.js +1 -1
  36. package/dist/commands/session-sync-counters.js +55 -0
  37. package/dist/commands/session-sync-health.js +8 -1
  38. package/dist/commands/session-sync-plan.js +47 -7
  39. package/dist/commands/session-sync-scan.js +4 -4
  40. package/dist/commands/session-sync.js +6 -0
  41. package/dist/commands/settings-render.js +27 -0
  42. package/dist/commands/sync-followups.js +5 -1
  43. package/dist/commands/sync.js +5 -1
  44. package/dist/commands/team-device-reasons.js +16 -0
  45. package/dist/commands/team.js +87 -7
  46. package/dist/evidence-upload-client.js +14 -763
  47. package/dist/evidence-upload-object.js +181 -0
  48. package/dist/evidence-upload-plan.js +233 -0
  49. package/dist/evidence-upload-terminal.js +309 -0
  50. package/dist/evidence-upload-transport.js +104 -0
  51. package/dist/spool/local-spool-io.js +122 -0
  52. package/dist/spool/local-spool-mutations.js +174 -0
  53. package/dist/spool/local-spool-parse.js +143 -0
  54. package/dist/spool/local-spool-types.js +22 -0
  55. package/dist/spool/local-spool.js +20 -426
  56. package/dist/upload-evidence-delivery-offer.js +144 -0
  57. package/dist/upload-evidence-delivery-reconcile.js +134 -0
  58. package/dist/upload-evidence-delivery-summary.js +205 -0
  59. package/dist/upload-evidence-delivery.js +12 -482
  60. package/package.json +3 -3
@@ -1,18 +1,9 @@
1
- import { errorMessage, writeLine } from "./cli-io.js";
2
- import { agentRuleHostLabel } from "./agent-rules-command.js";
3
- import { autostartLocationLine } from "./autostart-command.js";
4
- import { backfillRetryCommand, runBackfill, } from "./backfill.js";
5
- import { attributedSyncRunStatus, shortSha, worktreeSyncRow, writeAgentSessionSummary, } from "./collection-report.js";
6
- import { collectionRootConsentAliases, persistOnboardingRootConfig, resolveOnboardingRootsForCommand, } from "./collection-roots.js";
7
- import { addInstallEvent, reportInstallEventsBestEffort } from "./install-receipts.js";
8
- import { canReuseOnboardSession, pairLocalCollectorWithAuthFallback, readOnboardSessionReuseCandidate, requestPairingAccessTokenDetailed, resolveOnboardEmail, } from "./local-auth.js";
1
+ import { resolveOnboardEmail } from "./local-auth.js";
9
2
  import { discoverCommandWorktrees } from "./local-discovery.js";
10
- import { addAutostartInstallEvent, addOnboardFailureEvent, classifyOnboardBlocker, onboardAutostartFailed, } from "./onboard-receipts.js";
11
- import { nextStepForOnboardBlocker, onboardAgentRulesInstallLine, onboardJsonPayload, reportOnboardMultiRepoResult, reportOnboardSyncBlocked, writeOnboardAutostartBlocker, writeOnboardBackfillBlockedResult, writeOnboardBanner, writeOnboardJson, writeOnboardLiveStatus, writeOnboardMultiRepoSyncBlocked, writeOnboardSingleRepoResultJson, writeOnboardUploadNarrative, writeOnboardWorkContextStarted, writePairingInstructions, } from "./onboard-report.js";
12
- import { runAttributedWorktreeSync, } from "./session-sync.js";
13
- import { installAgentRules } from "../agent-rules.js";
14
- import { installAutostartAgent } from "../autostart.js";
15
- import { inspectLocalCollectorStatus, logoutLocalCollector, startLocalWorkContext, } from "../local-state.js";
3
+ import { handleOnboardFailure, } from "./onboard-completion.js";
4
+ import { runOnboardMultiRepoFlow, runOnboardSingleRepoFlow, } from "./onboard-flows.js";
5
+ import { writeOnboardBanner } from "./onboard-report.js";
6
+ import { prepareOnboardRoots, installOnboardConfig, pairForOnboarding } from "./onboard-setup.js";
16
7
  /**
17
8
  * The onboarding runbook: resolve roots, install, pair, discover worktrees,
18
9
  * then hand off to whichever arm matches what was found — one repo runs the
@@ -68,382 +59,4 @@ export async function runOnboard(command, io) {
68
59
  catch (error) {
69
60
  return await handleOnboardFailure(error, command, rootsResult, install, pair, syncHolder.current, installEvents, io);
70
61
  }
71
- }
72
- /** The parent-folder arm: sync every discovered worktree, then backfill and finish setup once. */
73
- async function runOnboardMultiRepoFlow(ctx) {
74
- const { command, resolvedCommand, collectionRoots, rootsResult, install, pair, installEvents, io, worktrees, } = ctx;
75
- const multi = await runMultiRepoOnboard(resolvedCommand, io, worktrees);
76
- addInstallEvent(installEvents, "work_context", "ok");
77
- addInstallEvent(installEvents, "sync", multi.ok ? "ok" : "fail", multi.ok ? undefined : "sync_blocked");
78
- if (!multi.ok) {
79
- if (command.json)
80
- writeOnboardMultiRepoSyncBlocked(ctx, multi);
81
- return finishOnboardRun(command, installEvents, io, 1);
82
- }
83
- const backfill = await runRequiredOnboardBackfill(resolvedCommand, io);
84
- const completedBackfill = backfill.status === "complete" ? backfill.result : null;
85
- addInstallEvent(installEvents, "backfill", completedBackfill ? "ok" : "fail", completedBackfill
86
- ? undefined
87
- : backfill.failureReason ?? "backfill_incomplete");
88
- if (!completedBackfill) {
89
- writeOnboardBackfillBlockedResult(io, {
90
- json: command.json,
91
- base: onboardJsonPayload("blocked", resolvedCommand, install, pair, null, null, rootsResult),
92
- extra: {
93
- mode: "multi_repo",
94
- repos: multi.results,
95
- codex_sessions: multi.codex_sessions,
96
- },
97
- backfill,
98
- });
99
- return finishOnboardRun(command, installEvents, io, 1);
100
- }
101
- const multiSetup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
102
- reportOnboardMultiRepoResult(ctx, {
103
- multi,
104
- backfill: completedBackfill,
105
- setup: multiSetup,
106
- });
107
- return finishOnboardRun(command, installEvents, io, multiSetup.ok ? 0 : 1);
108
- }
109
- /** The single-repo arm: start the work context, sync it, backfill, then finish setup once. */
110
- async function runOnboardSingleRepoFlow(ctx, single) {
111
- const { command, resolvedCommand, collectionRoots, rootsResult, install, pair, installEvents, io, worktrees, } = ctx;
112
- const worktreeRoot = worktrees[0]?.repo_root ?? single.primaryRoot;
113
- const context = await startLocalWorkContext({
114
- homeDir: command.homeDir,
115
- repoRoot: worktreeRoot,
116
- branch: command.branch,
117
- activeTicketId: command.activeTicketId,
118
- });
119
- if (!command.json)
120
- writeOnboardWorkContextStarted(io, context);
121
- addInstallEvent(installEvents, "work_context", "ok");
122
- const run = await runAttributedWorktreeSync({
123
- homeDir: command.homeDir,
124
- dashboardUrl: command.dashboardUrl,
125
- collectionRoots: single.attributionCollectionRoots,
126
- startContexts: false,
127
- worktrees,
128
- fetchImpl: io.fetch,
129
- });
130
- const sync = run.outcomes[0]?.sync ?? null;
131
- single.syncHolder.current = sync;
132
- if (!sync) {
133
- throw new Error("Onboard sync produced no result for the repo worktree.");
134
- }
135
- const status = await inspectLocalCollectorStatus({
136
- homeDir: command.homeDir,
137
- repoRoot: worktreeRoot,
138
- branch: command.branch,
139
- });
140
- if (!run.ok || sync.status !== "uploaded") {
141
- addInstallEvent(installEvents, "sync", "fail", "sync_blocked");
142
- reportOnboardSyncBlocked(ctx, { run, sync, status, worktreeRoot });
143
- return finishOnboardRun(command, installEvents, io, 1);
144
- }
145
- addInstallEvent(installEvents, "sync", "ok");
146
- const backfill = await runRequiredOnboardBackfill(resolvedCommand, io);
147
- const completedBackfill = backfill.status === "complete" ? backfill.result : null;
148
- addInstallEvent(installEvents, "backfill", completedBackfill ? "ok" : "fail", completedBackfill
149
- ? undefined
150
- : backfill.failureReason ?? "backfill_incomplete");
151
- if (!completedBackfill) {
152
- writeOnboardBackfillBlockedResult(io, {
153
- json: command.json,
154
- base: onboardJsonPayload("blocked", resolvedCommand, install, pair, sync, status, rootsResult),
155
- extra: { codex_sessions: run.summary },
156
- backfill,
157
- });
158
- return finishOnboardRun(command, installEvents, io, 1);
159
- }
160
- if (command.json) {
161
- const jsonSetup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
162
- writeOnboardSingleRepoResultJson(ctx, {
163
- run,
164
- sync,
165
- status,
166
- backfill: completedBackfill,
167
- setup: jsonSetup,
168
- });
169
- return finishOnboardRun(command, installEvents, io, jsonSetup.ok ? 0 : 1);
170
- }
171
- writeOnboardUploadNarrative(io, { command, run, sync, status });
172
- const setup = await completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io);
173
- const { agentRules, autostart } = setup;
174
- if (!setup.ok) {
175
- writeOnboardAutostartBlocker(io, autostart);
176
- return finishOnboardRun(command, installEvents, io, 1);
177
- }
178
- writeLine(io.stdout, "PASS: Tower is set up and collecting.");
179
- writeOnboardLiveStatus(io, resolvedCommand, collectionRoots, {
180
- pair,
181
- status,
182
- sync,
183
- agentRules,
184
- autostart,
185
- initialSyncOk: true,
186
- backfill: completedBackfill,
187
- });
188
- return finishOnboardRun(command, installEvents, io, 0);
189
- }
190
- /**
191
- * Every uncaught error from either onboarding arm lands here. `status` is
192
- * always recomputed fresh against whichever root got furthest, never trusted
193
- * from before the throw — the arms already recompute it themselves on their
194
- * own success paths, so nothing upstream is lost by not threading it through.
195
- */
196
- async function handleOnboardFailure(error, command, rootsResult, install, pair, sync, installEvents, io) {
197
- const message = errorMessage(error);
198
- const statusRoot = rootsResult?.roots[0] ?? command.repoRoot;
199
- const status = statusRoot
200
- ? await inspectLocalCollectorStatus({
201
- homeDir: command.homeDir,
202
- repoRoot: statusRoot,
203
- branch: command.branch,
204
- }).catch(() => null)
205
- : null;
206
- const blocker = classifyOnboardBlocker(message);
207
- addOnboardFailureEvent(installEvents, blocker);
208
- const nextStep = nextStepForOnboardBlocker(blocker, command);
209
- if (command.json) {
210
- writeOnboardJson(io, onboardJsonPayload("blocked", command, install, pair, sync, status, rootsResult, {
211
- blocker,
212
- message,
213
- next_step: nextStep,
214
- }));
215
- }
216
- else {
217
- writeLine(io.stderr, `BLOCKED: ${message}`);
218
- writeLine(io.stderr, `Next: ${nextStep}`);
219
- }
220
- return finishOnboardRun(command, installEvents, io, 1);
221
- }
222
- /** Step 0: which folders are approved, and who claims to own this machine's uploads. */
223
- async function prepareOnboardRoots(command, installEvents, io) {
224
- const resolvedRoots = await resolveOnboardingRootsForCommand(command, io);
225
- const rootsResult = resolvedRoots.rootsResult;
226
- const collectionRoots = rootsResult.roots;
227
- const attributionCollectionRoots = await collectionRootConsentAliases(collectionRoots);
228
- const primaryRoot = resolvedRoots.primaryRoot;
229
- const resolvedCommand = {
230
- ...command,
231
- repoRoot: primaryRoot,
232
- collectionRoots,
233
- };
234
- if (!command.json) {
235
- writeLine(io.stdout, `Collecting from: ${collectionRoots.join(", ")}`);
236
- }
237
- if (rootsResult.homeRootOptIn) {
238
- addInstallEvent(installEvents, "home_root_optin", "ok");
239
- }
240
- return {
241
- resolvedRoots,
242
- rootsResult,
243
- collectionRoots,
244
- attributionCollectionRoots,
245
- primaryRoot,
246
- existingConfig: resolvedRoots.existingConfig,
247
- resolvedCommand,
248
- };
249
- }
250
- /** Step 1: write the local config that makes this machine a known collector. */
251
- async function installOnboardConfig(command, resolvedRoots, installEvents, io) {
252
- const install = await persistOnboardingRootConfig(command, resolvedRoots);
253
- addInstallEvent(installEvents, "install", "ok");
254
- if (!command.json) {
255
- writeLine(io.stdout, "1/5 Installed local collector.");
256
- writeLine(io.stdout, `Config: ${install.paths.config_file}`);
257
- }
258
- return install;
259
- }
260
- /**
261
- * Step 2 of onboarding: reuse the device session when it already belongs to
262
- * this owner on this dashboard, otherwise sign in and pair — logging the old
263
- * session out first when it belongs to someone else.
264
- *
265
- * Returns null when an existing session was reused, which is why the caller's
266
- * `pair` stays null and the readiness proof falls back to the status file.
267
- */
268
- async function pairForOnboarding(command, input, installEvents, io) {
269
- const installedStatus = await inspectLocalCollectorStatus({
270
- homeDir: command.homeDir,
271
- repoRoot: input.primaryRoot,
272
- branch: command.branch,
273
- });
274
- const installedSession = await readOnboardSessionReuseCandidate(command.homeDir);
275
- const canReuseInstalledSession = canReuseOnboardSession(installedSession, input.claimedOwnerEmail, command.dashboardUrl);
276
- if (installedStatus.session_state === "valid" && canReuseInstalledSession) {
277
- addInstallEvent(installEvents, "auth", "skipped", "existing_session");
278
- addInstallEvent(installEvents, "pair", "skipped", "existing_session");
279
- if (!command.json) {
280
- writeLine(io.stdout, "2/5 Existing valid device session found; pairing skipped.");
281
- }
282
- return null;
283
- }
284
- if (installedStatus.session_state === "valid") {
285
- await logoutLocalCollector({ homeDir: command.homeDir });
286
- if (!command.json) {
287
- writeLine(io.stdout, "2/5 Existing valid device session does not match requested owner or dashboard; pairing again.");
288
- }
289
- }
290
- const authResult = await requestPairingAccessTokenDetailed({
291
- dashboardUrl: command.dashboardUrl,
292
- email: input.claimedOwnerEmail,
293
- noAuth: command.noAuth,
294
- json: command.json,
295
- }, io);
296
- addInstallEvent(installEvents, "auth", authResult.status, authResult.errorCode);
297
- const pair = await pairLocalCollectorWithAuthFallback({
298
- homeDir: command.homeDir,
299
- dashboardUrl: command.dashboardUrl,
300
- claimedOwnerEmail: input.claimedOwnerEmail,
301
- deviceName: command.deviceName,
302
- pollIntervalMs: command.pollIntervalMs,
303
- timeoutMs: command.timeoutMs,
304
- pairingAccessToken: authResult.accessToken,
305
- fetch: io.fetch,
306
- onPairStarted: command.json
307
- ? undefined
308
- : (request) => writePairingInstructions(io, request),
309
- }, io);
310
- addInstallEvent(installEvents, "pair", "ok");
311
- if (!command.json) {
312
- writeLine(io.stdout, "2/5 Device paired.");
313
- writeLine(io.stdout, `User: ${pair.session.email ?? pair.session.auth_subject_id}`);
314
- writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
315
- }
316
- return pair;
317
- }
318
- async function runMultiRepoOnboard(command, io, worktrees) {
319
- if (!command.json) {
320
- writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
321
- }
322
- const collectionRoots = await collectionRootConsentAliases(command.collectionRoots ??
323
- (command.repoRoot ? [command.repoRoot] : []));
324
- const run = await runAttributedWorktreeSync({
325
- homeDir: command.homeDir,
326
- dashboardUrl: command.dashboardUrl,
327
- branch: command.branch,
328
- activeTicketId: command.activeTicketId,
329
- collectionRoots,
330
- startContexts: true,
331
- worktrees,
332
- fetchImpl: io.fetch,
333
- });
334
- const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run));
335
- const collectionRunStatus = attributedSyncRunStatus(run);
336
- if (!command.json) {
337
- for (const [index, outcome] of run.outcomes.entries()) {
338
- const row = results[index];
339
- if (!row)
340
- continue;
341
- const uploaded = outcome.sync.status === "uploaded";
342
- writeLine(uploaded ? io.stdout : io.stderr, `${uploaded ? "SYNCED" : "BLOCKED"} ${row["repo_label"]}/${row["worktree_label"]} (${row["branch"]}) head:${shortSha(outcome.sync.head_sha ?? outcome.worktree.head_sha)} ${outcome.sync.status} objects:${outcome.sync.raw_evidence_uploaded_object_count} chunks:${outcome.sync.raw_evidence_uploaded_chunk_count} reused:${outcome.sync.raw_evidence_reused_count} failed:${outcome.sync.raw_evidence_failed_count} sessions:${row["attributed_session_count"]}${row["failure_reason"] ? ` reason:${row["failure_reason"]}` : ""}`);
343
- }
344
- writeLine(io.stdout, "4/5 Parent worktree sync complete.");
345
- writeLine(io.stdout, `Uploaded: ${results.filter((row) => row["upload_status"] === "uploaded").length}/${results.length}`);
346
- writeAgentSessionSummary(io, run.summary);
347
- if (run.ok) {
348
- writeLine(io.stdout, "Live sync receipts complete; verifying all-history Codex and Claude backfill.");
349
- }
350
- else {
351
- writeLine(io.stderr, `BLOCKED: Tower collection is ${collectionRunStatus}; retry until every eligible session has a durable receipt or explicit terminal reason.`);
352
- }
353
- writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
354
- }
355
- return { ok: run.ok, results, codex_sessions: run.summary };
356
- }
357
- function requiredOnboardBackfillCommand(command) {
358
- return {
359
- homeDir: command.homeDir,
360
- all: true,
361
- dryRun: false,
362
- maxDepth: command.maxDepth,
363
- maxRepos: command.maxRepos,
364
- yes: true,
365
- json: true,
366
- };
367
- }
368
- async function runRequiredOnboardBackfill(command, io) {
369
- const backfillCommand = requiredOnboardBackfillCommand(command);
370
- const retryCommand = backfillRetryCommand(backfillCommand);
371
- try {
372
- const result = await runBackfill(backfillCommand, io);
373
- return {
374
- status: result.status,
375
- failureReason: result.status === "complete"
376
- ? null
377
- : result.failure_reason ?? `backfill_${result.status}`,
378
- retryCommand: result.retry_command,
379
- result,
380
- };
381
- }
382
- catch (error) {
383
- return {
384
- status: "failed",
385
- failureReason: errorMessage(error),
386
- retryCommand,
387
- result: null,
388
- };
389
- }
390
- }
391
- async function completeOnboardingSetup(resolvedCommand, collectionRoots, installEvents, io) {
392
- const agentRules = await refreshOnboardAgentRules(resolvedCommand, collectionRoots, io);
393
- addInstallEvent(installEvents, "agent_rules", "ok");
394
- const autostart = await refreshOnboardAutostart(resolvedCommand, collectionRoots, io);
395
- addAutostartInstallEvent(installEvents, autostart);
396
- return { agentRules, autostart, ok: !onboardAutostartFailed(autostart) };
397
- }
398
- async function refreshOnboardAgentRules(command, roots, io) {
399
- const result = await installAgentRules({
400
- homeDir: command.homeDir,
401
- scopePaths: roots,
402
- });
403
- if (command.json)
404
- return result;
405
- writeLine(io.stdout, `Agent rules: ${onboardAgentRulesInstallLine(result)}`);
406
- for (const target of result.targets) {
407
- writeLine(io.stdout, `${agentRuleHostLabel(target.host)}: ${target.rules_file}`);
408
- }
409
- return result;
410
- }
411
- /** Refreshes the host autostart backend after successful onboarding. */
412
- async function refreshOnboardAutostart(command, roots, io) {
413
- if (!io.exec)
414
- return null;
415
- const result = await installAutostartAgent({
416
- homeDir: command.homeDir,
417
- repoRoot: roots[0],
418
- repoRoots: roots,
419
- dashboardUrl: command.dashboardUrl,
420
- exec: io.exec,
421
- });
422
- if (command.json)
423
- return result;
424
- if (result.status === "unsupported") {
425
- writeLine(io.stdout, `Background autostart unsupported: ${result.message}`);
426
- return result;
427
- }
428
- writeLine(io.stdout, result.loaded
429
- ? "Background autostart refreshed; Tower syncs at login and every 15 min."
430
- : "Background autostart refreshed, but the operating-system scheduler reported a problem; check `cockpit autostart status`.");
431
- writeLine(io.stdout, autostartLocationLine(result));
432
- return result;
433
- }
434
- /**
435
- * Every onboard exit funnels through here so the best-effort install-event
436
- * report always fires, pass or blocked — replaces a `finish` closure that used
437
- * to live inside `runOnboard` so the flow functions below can call it too.
438
- */
439
- async function finishOnboardRun(command, installEvents, io, code) {
440
- await reportInstallEventsBestEffort({
441
- homeDir: command.homeDir,
442
- dashboardUrl: command.dashboardUrl,
443
- command: "onboard",
444
- events: installEvents,
445
- json: command.json,
446
- io,
447
- });
448
- return code;
449
62
  }
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.53");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.55");
19
19
  return 0;
20
20
  }
21
21
 
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The two honest counters the heartbeat carries beside the raw scan count
3
+ * (BLI-3645).
4
+ *
5
+ * `sessions_observed` is a re-scanned STOCK — every session file inside the
6
+ * current scan window, whether this tick found it for the first time or the
7
+ * two-hundredth (`docs/reports/collection-coverage-probe-2026-09-05.md`). It
8
+ * keeps its real name and keeps shipping, because the fleet board still wants
9
+ * "how big is this machine's session store". Neither counter below replaces
10
+ * it; both are FLOWS, computed against the cursor as it stood before this
11
+ * tick touched a single file:
12
+ *
13
+ * - `sessions_new_this_tick` — sessions this tick's scan found that were NOT
14
+ * already in that source's cursor. A session already tracked (uploaded,
15
+ * retrying, or merely re-scanned inside the normal window) is not "new".
16
+ * - `sessions_pending_upload` — sessions INSIDE an approved root, from this
17
+ * tick's report, that still have no durable pointer. This is the "only
18
+ * copy" set a manager actually needs: files that exist nowhere but this
19
+ * machine, right now. Outside-root sessions are a consented absence
20
+ * (session-first commandment) and are never counted here, the same rule
21
+ * `session-sync-health.ts` applies to `sessions_outside_root`.
22
+ */
23
+ import { OUTSIDE_APPROVED_ROOT_REASONS } from "./session-sync-health.js";
24
+ /**
25
+ * How many of this tick's attributed sessions are new to their source's
26
+ * cursor — never seen by a previous tick at all.
27
+ *
28
+ * Compares against the cursor as it stood BEFORE this tick (the `plan` step's
29
+ * read), not the cursor after recording, because the point is "first seen
30
+ * since the last tick's watermark" and the cursor only gains this tick's
31
+ * entries once recording runs.
32
+ */
33
+ export function countSessionsNewThisTick(options) {
34
+ let count = 0;
35
+ for (const session of options.sessions) {
36
+ const cursorBefore = session.source === "claude_code"
37
+ ? options.claudeCursorBefore
38
+ : options.codexCursorBefore;
39
+ if (!cursorBefore.sessions[session.codex_session_id])
40
+ count += 1;
41
+ }
42
+ return count;
43
+ }
44
+ /**
45
+ * How many of this tick's attributed sessions are inside an approved root and
46
+ * still have no durable pointer — the "only copy" set. A session that never
47
+ * gets one because it is genuinely withheld (a stated `upload_reason`, not the
48
+ * outside-root one) still counts: it is still, right now, the only copy this
49
+ * session exists as, and that is exactly the fact a manager needs surfaced,
50
+ * not silently excluded because the withholding was deliberate.
51
+ */
52
+ export function countSessionsPendingUpload(sessions) {
53
+ return sessions.filter((session) => !session.raw_evidence_pointer_id &&
54
+ !OUTSIDE_APPROVED_ROOT_REASONS.has(session.attribution_reason)).length;
55
+ }
@@ -10,6 +10,7 @@
10
10
  * Nothing here parses a rendered sentence back apart.
11
11
  */
12
12
  import { createSyncFailureLedger, recordSourceScanFailures, recordUnexplainedFailure, recordUnpostedSessionReportFailure, recordWorktreeDeliveryFailures, } from "./session-sync-failures.js";
13
+ import { countSessionsNewThisTick, countSessionsPendingUpload, } from "./session-sync-counters.js";
13
14
  /**
14
15
  * Decide whether this tick failed, and record every condition that decided it.
15
16
  *
@@ -50,6 +51,12 @@ export function decideSyncHealth(options) {
50
51
  failure_records: records,
51
52
  notice,
52
53
  sessions_outside_root: sessionsOutsideRoot,
54
+ sessions_new_this_tick: countSessionsNewThisTick({
55
+ sessions: options.sessions,
56
+ codexCursorBefore: options.plan.codexCursorBefore,
57
+ claudeCursorBefore: options.plan.claudeCursorBefore,
58
+ }),
59
+ sessions_pending_upload: countSessionsPendingUpload(options.sessions),
53
60
  };
54
61
  }
55
62
  /**
@@ -60,7 +67,7 @@ export function decideSyncHealth(options) {
60
67
  * `attribution-core.ts` writes both of these and nothing else means
61
68
  * "outside the boundary".
62
69
  */
63
- const OUTSIDE_APPROVED_ROOT_REASONS = new Set([
70
+ export const OUTSIDE_APPROVED_ROOT_REASONS = new Set([
64
71
  "cwd_outside_scanned_worktrees",
65
72
  "no_matching_worktree_signals",
66
73
  ]);
@@ -4,14 +4,36 @@
4
4
  * Every read here degrades on purpose: a missing or corrupt local file becomes
5
5
  * an empty cursor or a null spool, because a collector that cannot read its own
6
6
  * bookkeeping must still collect. The one decision this step makes that changes
7
- * what gets collected is the retry width — whether a source reopens all local
8
- * history instead of its normal live window.
7
+ * what gets collected is the retry width — whether a source reopens its scan
8
+ * window, and how far.
9
+ *
10
+ * BLI-3645: a `retry_pending` source used to widen to "all local history since
11
+ * the epoch", every tick, forever, which is what made `sessions_observed` a
12
+ * 14-day-to-lifetime rolling STOCK instead of an incremental FLOW (see
13
+ * `docs/reports/collection-coverage-probe-2026-09-05.md`). The window is now
14
+ * bounded to the AGE of the oldest undurable file that source's own cursor is
15
+ * still carrying, plus one day of buffer — a real, finite number that shrinks
16
+ * back down once that file finally uploads, rather than a cutoff that never
17
+ * moves. When no cursor entry explains the retry (a legacy spooled upload with
18
+ * no per-file record), {@link RETRY_WINDOW_FALLBACK_MINUTES} is the bound
19
+ * instead — generous, but still a bound, never "since epoch".
9
20
  */
10
21
  import { readLocalCollectorConfig, } from "../local-state.js";
11
22
  import { CLAUDE_CURSOR_FILENAME, emptyRawEvidenceCursorState, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
12
23
  import { normalizeCollectionRoots } from "../root-normalization.js";
13
24
  import { readLocalUploadSpoolState, } from "../spool/local-spool.js";
14
25
  import { liveSyncCursorEntryRequiresRetry } from "./session-sync-attribution.js";
26
+ /**
27
+ * The bound applied when a retry is pending but no cursor entry names the
28
+ * stuck file (e.g. a legacy spooled upload recorded before per-source retry
29
+ * tracking existed). 30 days is well past the normal 14-day Codex / 24h
30
+ * Claude window and comfortably covers a real backlog, without re-reading a
31
+ * machine's entire lifetime of session files every 15 minutes.
32
+ */
33
+ export const RETRY_WINDOW_FALLBACK_MINUTES = 30 * 24 * 60;
34
+ /** Buffer added past the oldest undurable file's exact age, for clock skew
35
+ * and so a file sitting right at the edge of the window is not dropped. */
36
+ export const RETRY_WINDOW_BUFFER_MINUTES = 24 * 60;
15
37
  /**
16
38
  * Read the local state this sync is bound by — config, both cursors, the upload
17
39
  * spool — and turn it into the decisions the rest of the pass reads.
@@ -49,13 +71,31 @@ export async function planSyncFromLocalState(options) {
49
71
  codexRetryPending: sourceHasPendingRetries("codex", uploadSpool, legacyRetryPending, codexSourceRetryPending, codexCursorBefore),
50
72
  claudeRetryPending: claudeEnabled &&
51
73
  sourceHasPendingRetries("claude_code", uploadSpool, legacyRetryPending, claudeSourceRetryPending, claudeCursorBefore),
52
- allHistorySinceMinutes: allLocalHistorySinceMinutes(options.now),
74
+ codexRetrySinceMinutes: boundedRetrySinceMinutes(codexCursorBefore, options.now),
75
+ claudeRetrySinceMinutes: boundedRetrySinceMinutes(claudeCursorBefore, options.now),
53
76
  };
54
77
  }
55
- function allLocalHistorySinceMinutes(now) {
56
- // A cutoff just before the Unix epoch is effectively unbounded for Codex and
57
- // Claude session files while keeping date arithmetic finite and portable.
58
- return Math.ceil(now.getTime() / 60_000) + 24 * 60;
78
+ /**
79
+ * How far back a retry-widened scan should reopen: the age of the oldest
80
+ * undurable file this cursor is still carrying, plus a day of buffer — or
81
+ * {@link RETRY_WINDOW_FALLBACK_MINUTES} when no cursor entry explains the
82
+ * retry at all. Exported for the tests, which is the point: this is the exact
83
+ * arithmetic that used to be "since the epoch" and is now a real number.
84
+ */
85
+ export function boundedRetrySinceMinutes(cursor, now) {
86
+ let oldestUndurableMtimeMs = null;
87
+ for (const entry of Object.values(cursor.sessions)) {
88
+ if (!liveSyncCursorEntryRequiresRetry(entry))
89
+ continue;
90
+ if (oldestUndurableMtimeMs === null ||
91
+ entry.file_mtime_ms < oldestUndurableMtimeMs) {
92
+ oldestUndurableMtimeMs = entry.file_mtime_ms;
93
+ }
94
+ }
95
+ if (oldestUndurableMtimeMs === null)
96
+ return RETRY_WINDOW_FALLBACK_MINUTES;
97
+ const ageMinutes = Math.ceil((now.getTime() - oldestUndurableMtimeMs) / 60_000);
98
+ return Math.max(0, ageMinutes) + RETRY_WINDOW_BUFFER_MINUTES;
59
99
  }
60
100
  /** Whether the spool already recorded a source-scan retry for this source. */
61
101
  function sourceScanRetryIsPending(uploadSpool, source) {
@@ -29,7 +29,7 @@ export async function scanAndAttributeBothSources(options) {
29
29
  now: options.now,
30
30
  collectionRoots: plan.collectionRoots,
31
31
  retryPending: plan.codexRetryPending,
32
- allHistorySinceMinutes: plan.allHistorySinceMinutes,
32
+ retrySinceMinutes: plan.codexRetrySinceMinutes,
33
33
  });
34
34
  // First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
35
35
  // days so the first sync captures retroactive history instead of only 24h.
@@ -42,7 +42,7 @@ export async function scanAndAttributeBothSources(options) {
42
42
  now: options.now,
43
43
  collectionRoots: plan.collectionRoots,
44
44
  retryPending: plan.claudeRetryPending,
45
- allHistorySinceMinutes: plan.allHistorySinceMinutes,
45
+ retrySinceMinutes: plan.claudeRetrySinceMinutes,
46
46
  firstRunBackfill,
47
47
  });
48
48
  await reconcileSourceScanRetry({
@@ -71,7 +71,7 @@ export async function scanAndAttributeBothSources(options) {
71
71
  */
72
72
  async function scanCodexSessionsForSync(options) {
73
73
  const sinceMinutes = options.retryPending
74
- ? options.allHistorySinceMinutes
74
+ ? options.retrySinceMinutes
75
75
  : CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES;
76
76
  let scan = await scanAndAttributeCodexSessions({
77
77
  sessionsDirs: defaultCodexSessionDirs(options.homeDir),
@@ -106,7 +106,7 @@ async function scanClaudeSessionsForSync(options) {
106
106
  if (!options.claudeEnabled)
107
107
  return emptyClaudeScan();
108
108
  const sinceMinutes = options.retryPending
109
- ? options.allHistorySinceMinutes
109
+ ? options.retrySinceMinutes
110
110
  : options.firstRunBackfill
111
111
  ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
112
112
  : undefined;
@@ -25,6 +25,9 @@
25
25
  * class-lock test reads that file (BLI-3551).
26
26
  * - `session-sync-health.ts` — the verdict, and the notice a quiet-but-working
27
27
  * machine still emits.
28
+ * - `session-sync-counters.ts` — the two honest heartbeat counters beside the
29
+ * raw scan count: sessions new since the last tick's cursor, and sessions
30
+ * still pending a durable pointer (BLI-3645).
28
31
  *
29
32
  * What a run REPORTS — the per-session rows and the CLI funnel counts — lives
30
33
  * next door in `agent-session-report.ts` and is re-exported from here
@@ -116,6 +119,7 @@ export async function runAttributedWorktreeSync(options) {
116
119
  sessions,
117
120
  delivery,
118
121
  collectionRootCount: plan.collectionRoots.length,
122
+ plan,
119
123
  });
120
124
  return {
121
125
  ok: health.ok,
@@ -124,6 +128,8 @@ export async function runAttributedWorktreeSync(options) {
124
128
  notice: health.notice,
125
129
  sessions_observed: sessions.length,
126
130
  sessions_outside_root: health.sessions_outside_root,
131
+ sessions_new_this_tick: health.sessions_new_this_tick,
132
+ sessions_pending_upload: health.sessions_pending_upload,
127
133
  outcomes: worktreePass.outcomes,
128
134
  codexAttribution: scan.codexAttribution,
129
135
  claudeAttribution: scan.claudeAttribution,