@bli-cockpit/cli 0.2.237 → 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 (139) 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 +73 -30
  29. package/dist/commands/cal.js +36 -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/heartbeat-post.js +116 -0
  35. package/dist/commands/heartbeat-token-lock.js +46 -33
  36. package/dist/commands/heartbeat.js +43 -125
  37. package/dist/commands/install-update.js +17 -15
  38. package/dist/commands/issue-show.js +27 -28
  39. package/dist/commands/issue-write.js +70 -23
  40. package/dist/commands/issue.js +68 -37
  41. package/dist/commands/jarvis-live-answer.js +99 -0
  42. package/dist/commands/jarvis-render.js +1 -94
  43. package/dist/commands/jarvis-trace.js +30 -22
  44. package/dist/commands/jarvis-turn-report.js +124 -0
  45. package/dist/commands/jarvis-turn.js +108 -182
  46. package/dist/commands/jarvis.js +26 -17
  47. package/dist/commands/local-args-collector-setup.js +82 -84
  48. package/dist/commands/local-args-collector-work.js +37 -38
  49. package/dist/commands/local-args-tower-admin.js +155 -142
  50. package/dist/commands/local-args-tower-cal.js +52 -50
  51. package/dist/commands/local-args-tower-careers.js +73 -54
  52. package/dist/commands/local-args-tower-docs-msg.js +55 -45
  53. package/dist/commands/local-args-tower-github.js +26 -20
  54. package/dist/commands/local-args-tower-hits.js +32 -20
  55. package/dist/commands/local-args-tower-manager.js +85 -4
  56. package/dist/commands/local-args-tower-memory.js +139 -117
  57. package/dist/commands/local-args-tower-notes.js +346 -0
  58. package/dist/commands/local-args-tower-pages.js +100 -378
  59. package/dist/commands/local-args-tower-settings.js +28 -29
  60. package/dist/commands/local-args-tower-work.js +52 -33
  61. package/dist/commands/local-args.js +80 -129
  62. package/dist/commands/local-help-commands-tower-ops.js +15 -4
  63. package/dist/commands/local-help-commands-tower.js +6 -2
  64. package/dist/commands/local-help-commands.js +342 -341
  65. package/dist/commands/local-help.js +87 -83
  66. package/dist/commands/local-run-guards.js +100 -0
  67. package/dist/commands/local.js +137 -206
  68. package/dist/commands/login.js +13 -9
  69. package/dist/commands/logs.js +55 -38
  70. package/dist/commands/mail-attachments.js +44 -26
  71. package/dist/commands/manager-render-home.js +208 -0
  72. package/dist/commands/manager-render.js +7 -3
  73. package/dist/commands/manager-requests.js +33 -0
  74. package/dist/commands/manager.js +39 -5
  75. package/dist/commands/memory-consolidation.js +18 -9
  76. package/dist/commands/memory-daemon-probe.js +37 -28
  77. package/dist/commands/memory-install-config.js +64 -60
  78. package/dist/commands/memory-install.js +21 -41
  79. package/dist/commands/memory-requeue.js +26 -19
  80. package/dist/commands/memory-rules.js +26 -18
  81. package/dist/commands/memory-search-print.js +26 -0
  82. package/dist/commands/memory-store.js +14 -30
  83. package/dist/commands/memory-verb-list.js +49 -48
  84. package/dist/commands/notes-accounts.js +49 -32
  85. package/dist/commands/notes-sync-loop.js +115 -89
  86. package/dist/commands/notes-wisprflow-login.js +44 -23
  87. package/dist/commands/notes-writes.js +115 -100
  88. package/dist/commands/notes.js +33 -55
  89. package/dist/commands/onboard-flows.js +69 -51
  90. package/dist/commands/onboard.js +11 -9
  91. package/dist/commands/ops-render-memory.js +10 -7
  92. package/dist/commands/ops-render.js +33 -30
  93. package/dist/commands/ops.js +3 -0
  94. package/dist/commands/public-root.js +1 -1
  95. package/dist/commands/session-sync-outside-roots.js +40 -36
  96. package/dist/commands/session-sync-plan.js +14 -10
  97. package/dist/commands/session-sync-upload.js +24 -22
  98. package/dist/commands/session-sync.js +19 -8
  99. package/dist/commands/slack.js +25 -16
  100. package/dist/commands/sources-check.js +13 -7
  101. package/dist/commands/sources-discovery.js +25 -17
  102. package/dist/commands/sources-verdict.js +19 -18
  103. package/dist/commands/speed.js +23 -19
  104. package/dist/commands/start.js +20 -16
  105. package/dist/commands/sync-doctor.js +52 -48
  106. package/dist/commands/sync.js +22 -16
  107. package/dist/commands/usage-telemetry-toml.js +71 -45
  108. package/dist/commands/usage.js +91 -67
  109. package/dist/commands/workbook-render.js +10 -7
  110. package/dist/evidence-reconcile-client.js +75 -64
  111. package/dist/evidence-redelivery-plan.js +37 -34
  112. package/dist/evidence-redelivery.js +32 -29
  113. package/dist/evidence-transcripts-client.js +20 -20
  114. package/dist/evidence-upload-object.js +86 -89
  115. package/dist/evidence-upload-plan.js +94 -83
  116. package/dist/evidence-upload-terminal.js +45 -50
  117. package/dist/image-description-session.js +156 -159
  118. package/dist/local-state-pairing-code.js +39 -31
  119. package/dist/local-state-status.js +9 -2
  120. package/dist/onboarding-roots.js +5 -8
  121. package/dist/process-runner.js +8 -8
  122. package/dist/raw-evidence-gc.js +33 -38
  123. package/dist/raw-evidence-stuck-summary.js +7 -14
  124. package/dist/repo-identity.js +26 -23
  125. package/dist/scheduled-self-update.js +11 -15
  126. package/dist/second-install.js +3 -0
  127. package/dist/slim/codex-rules.js +23 -36
  128. package/dist/sync-lock.js +5 -15
  129. package/dist/tower-client.js +36 -53
  130. package/dist/tower-stream.js +7 -24
  131. package/dist/upload-agent-artifacts.js +58 -64
  132. package/dist/upload-envelope-build.js +60 -59
  133. package/dist/upload-evidence-delivery-reconcile.js +58 -56
  134. package/dist/upload-evidence-delivery-summary.js +32 -34
  135. package/dist/upload-ingest-receipt.js +20 -23
  136. package/dist/upload-session-reports-queue.js +4 -1
  137. package/dist/upload-session-reports-wire.js +64 -68
  138. package/dist/upload-sync.js +171 -157
  139. package/package.json +2 -2
@@ -46,6 +46,47 @@ export function claudeDefaultEffort(model) {
46
46
  const claudeFamily = (model) => /opus|sonnet|haiku/i.exec(model ?? "")?.[0].toLowerCase() ?? null;
47
47
  /** Bounded, explicit locations only. Never recurse through a home or read env files. */
48
48
  export async function collectAgentSetup(home, platform = process.platform, io = files, now = new Date()) {
49
+ const reader = createSetupReader(home, platform, io);
50
+ const { paths, join, read, list, json } = reader;
51
+ const resolveVersion = createVersionResolver(reader, platform, io);
52
+ // Everything slow starts together: both `--version` checks (up to 5 s each) and
53
+ // both transcript samples run side by side, so the whole step costs one wait.
54
+ const observe = async (tool) => {
55
+ const samples = await io.transcripts?.(tool, home).catch(() => []) ?? [];
56
+ return observeTranscripts(tool, samples);
57
+ };
58
+ const claudeObserving = observe("claude"), codexObserving = observe("codex");
59
+ const [claudeVersion, codexVersion, claudeObserved, codexObserved] = await Promise.all([resolveVersion("claude", claudeObserving), resolveVersion("codex", codexObserving), claudeObserving, codexObserving]);
60
+ const codexHooks = await json(".codex", "hooks.json");
61
+ const claudePlugins = await json(".claude", "plugins", "installed_plugins.json");
62
+ const { pluginSkills, pluginAgents, pluginInventoryUnknown } = await readPluginInventory(claudePlugins, paths, io);
63
+ const claudeSkills = await list(".claude", "skills");
64
+ const claudeAgents = await list(".claude", "agents");
65
+ const sharedSkills = await list(".agents", "skills");
66
+ const codexSkills = await list(".codex", "skills");
67
+ const systemSkills = await list(".codex", "skills", ".system");
68
+ const claudeSettings = await json(".claude", "settings.json");
69
+ const claudeConfig = await json(".claude.json");
70
+ const toml = await read(join(".codex", "config.toml"));
71
+ // Read only simple quoted scalar values. Complex TOML is unknown, not guessed.
72
+ const rootToml = toml?.split(/^\s*\[/m)[0] ?? "";
73
+ const scalar = (key) => safeName(new RegExp(`^${key}\\s*=\\s*"([^"\\n]+)"\\s*(?:#.*)?$`, "m").exec(rootToml)?.[1]);
74
+ const codexMcp = readCodexMcp(toml);
75
+ const claudeMcp = readClaudeMcp(claudeConfig);
76
+ const instructions = await readInstructionFiles(join, io);
77
+ const result = buildSetupInventory({
78
+ now, platform, claudeSettings, claudeObserved, codexObserved,
79
+ claudeVersion, codexVersion, claudeSkills, claudeAgents, sharedSkills, codexSkills, systemSkills,
80
+ claudePlugins, pluginInventoryUnknown, pluginSkills, pluginAgents, claudeMcp, codexMcp,
81
+ toml, rootToml, scalar, codexHooks, instructions
82
+ });
83
+ const evidence = (tool) => ({ version_source: result[tool].version_source, model_basis: result[tool].model_basis, effort_basis: result[tool].effort_basis });
84
+ console.error("[setup] versions read", JSON.stringify({ reason: "version_resolved", claude: evidence("claude"), codex: evidence("codex"), transcript_files: { claude: claudeObserved.files, codex: codexObserved.files } }));
85
+ const unknownFields = [result.claude, result.codex].reduce((count, tool) => count + ["skills", "plugins", "agents", "hooks", "mcp"].filter(key => tool[key] === null).length, 0);
86
+ console.error("[setup] inventory read", JSON.stringify({ reason: unknownFields ? "inventory_partial" : "inventory_observed", unknown_fields: unknownFields, os: result.os, mcp_count: (result.claude.mcp?.length ?? 0) + (result.codex.mcp?.length ?? 0) }));
87
+ return result;
88
+ }
89
+ function createSetupReader(home, platform, io) {
49
90
  const paths = platform === "win32" ? path.win32 : path.posix;
50
91
  const join = (...parts) => paths.join(home, ...parts);
51
92
  const read = async (file) => {
@@ -76,8 +117,12 @@ export async function collectAgentSetup(home, platform = process.platform, io =
76
117
  return null;
77
118
  }
78
119
  };
120
+ return { paths, join, read, list, json };
121
+ }
122
+ function createVersionResolver(reader, platform, io) {
123
+ const { paths, join, read } = reader;
79
124
  const packageName = (tool) => tool === "claude" ? "@anthropic-ai/claude-code" : "@openai/codex";
80
- const versionFromPackage = async (tool) => {
125
+ async function versionFromPackage(tool) {
81
126
  const parts = packageName(tool).split("/");
82
127
  // Where npm puts a global package: Windows keeps it under AppData, macOS
83
128
  // under the prefix of the node that installed it (Homebrew, /usr/local,
@@ -107,7 +152,8 @@ export async function collectAgentSetup(home, platform = process.platform, io =
107
152
  }
108
153
  }
109
154
  return null;
110
- };
155
+ }
156
+ ;
111
157
  const resolveVersion = async (tool, observing) => {
112
158
  const viaCli = await io.version?.(tool, platform).catch(() => null);
113
159
  if (safeName(viaCli))
@@ -120,16 +166,9 @@ export async function collectAgentSetup(home, platform = process.platform, io =
120
166
  const viaTranscript = safeName((await observing).version);
121
167
  return viaTranscript ? { value: viaTranscript, source: "transcript" } : { value: null, source: "none" };
122
168
  };
123
- // Everything slow starts together: both `--version` checks (up to 5 s each) and
124
- // both transcript samples run side by side, so the whole step costs one wait.
125
- const observe = async (tool) => {
126
- const samples = await io.transcripts?.(tool, home).catch(() => []) ?? [];
127
- return observeTranscripts(tool, samples);
128
- };
129
- const claudeObserving = observe("claude"), codexObserving = observe("codex");
130
- const [claudeVersion, codexVersion, claudeObserved, codexObserved] = await Promise.all([resolveVersion("claude", claudeObserving), resolveVersion("codex", codexObserving), claudeObserving, codexObserving]);
131
- const codexHooks = await json(".codex", "hooks.json");
132
- const claudePlugins = await json(".claude", "plugins", "installed_plugins.json");
169
+ return resolveVersion;
170
+ }
171
+ async function readPluginInventory(claudePlugins, paths, io) {
133
172
  const pluginSkills = [], pluginAgents = [];
134
173
  let pluginInventoryUnknown = false;
135
174
  for (const [pluginName, installations] of Object.entries(object(claudePlugins?.plugins))) {
@@ -142,45 +181,55 @@ export async function collectAgentSetup(home, platform = process.platform, io =
142
181
  continue;
143
182
  }
144
183
  for (const category of ["skills", "agents"]) {
145
- try {
146
- const entries = names(await io.list(paths.join(directory, category)));
147
- (category === "skills" ? pluginSkills : pluginAgents).push(...entries.map(name => `${pluginName}.${name}`));
148
- }
149
- catch (error) {
150
- if (error.code !== "ENOENT")
151
- pluginInventoryUnknown = true;
152
- }
184
+ const target = category === "skills" ? pluginSkills : pluginAgents;
185
+ if (await readPluginCategory(pluginName, directory, category, paths, io, target))
186
+ pluginInventoryUnknown = true;
153
187
  }
154
188
  }
155
189
  }
156
- const claudeSkills = await list(".claude", "skills");
157
- const claudeAgents = await list(".claude", "agents");
158
- const sharedSkills = await list(".agents", "skills");
159
- const codexSkills = await list(".codex", "skills");
160
- const systemSkills = await list(".codex", "skills", ".system");
161
- const claudeSettings = await json(".claude", "settings.json");
162
- const claudeConfig = await json(".claude.json");
163
- const toml = await read(join(".codex", "config.toml"));
164
- // Read only simple quoted scalar values. Complex TOML is unknown, not guessed.
165
- const rootToml = toml?.split(/^\s*\[/m)[0] ?? "";
166
- const scalar = (key) => safeName(new RegExp(`^${key}\\s*=\\s*"([^"\\n]+)"\\s*(?:#.*)?$`, "m").exec(rootToml)?.[1]);
167
- const codexMcp = toml === null ? null : [...toml.matchAll(/^\s*\[mcp_servers\.(?:"([a-z0-9_.@+-]+)"|([a-z0-9_-]+))\]\s*$/gim)].flatMap(match => {
190
+ return { pluginSkills, pluginAgents, pluginInventoryUnknown };
191
+ }
192
+ async function readPluginCategory(pluginName, directory, category, paths, io, target) {
193
+ try {
194
+ const entries = names(await io.list(paths.join(directory, category)));
195
+ target.push(...entries.map(name => `${pluginName}.${name}`));
196
+ return false;
197
+ }
198
+ catch (error) {
199
+ return error.code !== "ENOENT";
200
+ }
201
+ }
202
+ function readCodexMcp(toml) {
203
+ return toml === null ? null : [...toml.matchAll(/^\s*\[mcp_servers\.(?:"([a-z0-9_.@+-]+)"|([a-z0-9_-]+))\]\s*$/gim)].flatMap(match => {
168
204
  const name = safeName(match[1] ?? match[2]);
169
205
  if (!name)
170
206
  return [];
171
207
  const block = toml.slice(match.index + match[0].length).split(/^\s*\[/m)[0];
172
208
  return [{ name, transport: /^\s*command\s*=/m.test(block) ? "stdio" : /^\s*url\s*=/m.test(block) ? "http" : "unknown" }];
173
209
  }).slice(0, 500);
210
+ }
211
+ function readClaudeMcp(claudeConfig) {
174
212
  const claudeServers = [object(claudeConfig?.mcpServers), ...Object.values(object(claudeConfig?.projects)).map(project => object(object(project).mcpServers))];
175
- const claudeMcp = claudeConfig === null ? null : claudeServers.flatMap(servers => Object.entries(servers)).flatMap(([key, value]) => {
213
+ return claudeConfig === null ? null : claudeServers.flatMap(servers => Object.entries(servers)).flatMap(([key, value]) => {
176
214
  const name = safeName(key);
177
215
  if (!name)
178
216
  return [];
179
217
  const config = object(value);
180
- const transport = config.type === "sse" ? "sse" : config.type === "http" || config.url ? "http" : config.command ? "stdio" : "unknown";
218
+ const transport = readClaudeMcpTransport(config);
181
219
  return [{ name, transport }];
182
220
  }).slice(0, 500);
183
- const instructions = await Promise.all(["CLAUDE.md", "AGENTS.md"].map(async (name) => {
221
+ }
222
+ function readClaudeMcpTransport(config) {
223
+ if (config.type === "sse")
224
+ return "sse";
225
+ if (config.type === "http" || config.url)
226
+ return "http";
227
+ if (config.command)
228
+ return "stdio";
229
+ return "unknown";
230
+ }
231
+ async function readInstructionFiles(join, io) {
232
+ return await Promise.all(["CLAUDE.md", "AGENTS.md"].map(async (name) => {
184
233
  const file = join(name === "CLAUDE.md" ? ".claude" : ".codex", name);
185
234
  try {
186
235
  const content = await io.read(file);
@@ -190,6 +239,9 @@ export async function collectAgentSetup(home, platform = process.platform, io =
190
239
  return { name, exists: error.code === "ENOENT" ? false : null, sha256: null, bytes: null };
191
240
  }
192
241
  }));
242
+ }
243
+ function buildSetupInventory(input) {
244
+ const { now, platform, claudeSettings, claudeObserved, codexObserved, claudeVersion, codexVersion, claudeSkills, claudeAgents, sharedSkills, codexSkills, systemSkills, claudePlugins, pluginInventoryUnknown, pluginSkills, pluginAgents, claudeMcp, codexMcp, toml, rootToml, scalar, codexHooks, instructions } = input;
193
245
  // Model: what the person set, else what their sessions actually ran most.
194
246
  const claudeSetModel = safeName(claudeSettings?.model), codexSetModel = scalar("model");
195
247
  const claudeModel = claudeSetModel ?? claudeObserved.model;
@@ -198,25 +250,24 @@ export async function collectAgentSetup(home, platform = process.platform, io =
198
250
  const claudeDefaultFor = claudeSetModel && claudeFamily(claudeSetModel) !== claudeFamily(claudeObserved.model) ? claudeSetModel : claudeObserved.model ?? claudeSetModel;
199
251
  const claudeSetEffort = safeName(claudeSettings?.effortLevel), codexSetEffort = scalar("model_reasoning_effort");
200
252
  const claudeDefault = claudeSetEffort ? null : claudeDefaultEffort(claudeDefaultFor);
201
- const result = AgentSetupSchema.parse({
253
+ return AgentSetupSchema.parse({
202
254
  checked_at: now.toISOString(), os: platform === "darwin" || platform === "win32" ? platform : "other", cli_version: LOCAL_COLLECTOR_VERSION,
203
- claude: { version: claudeVersion.value, version_source: claudeVersion.source,
255
+ claude: {
256
+ version: claudeVersion.value, version_source: claudeVersion.source,
204
257
  model: claudeModel, model_basis: claudeSetModel ? "set" : claudeModel ? "used" : "none",
205
258
  effort: claudeSetEffort ?? claudeDefault, effort_basis: claudeSetEffort ? "set" : "default",
206
259
  skills: claudeSkills === null || pluginInventoryUnknown ? null : names([...claudeSkills, ...pluginSkills]), agents: claudeAgents === null || pluginInventoryUnknown ? null : names([...claudeAgents, ...pluginAgents]),
207
260
  plugins: claudePlugins === null ? null : names(Object.keys(object(claudePlugins.plugins))),
208
- hooks: claudeSettings === null ? null : names(Object.keys(object(claudeSettings.hooks))), mcp: claudeMcp },
209
- codex: { version: codexVersion.value, version_source: codexVersion.source,
261
+ hooks: claudeSettings === null ? null : names(Object.keys(object(claudeSettings.hooks))), mcp: claudeMcp
262
+ },
263
+ codex: {
264
+ version: codexVersion.value, version_source: codexVersion.source,
210
265
  model: codexSetModel ?? codexObserved.model, model_basis: codexSetModel ? "set" : codexObserved.model ? "used" : "none",
211
266
  effort: codexSetEffort ?? codexObserved.effort, effort_basis: codexSetEffort ? "set" : codexObserved.effort ? "observed" : "default",
212
267
  skills: codexSkills === null || sharedSkills === null || systemSkills === null ? null : names([...(codexSkills ?? []), ...(sharedSkills ?? []), ...(systemSkills ?? [])]),
213
268
  agents: toml === null ? null : names([...toml.matchAll(/^\s*\[agents\.([a-z0-9_-]+)\]\s*$/gim)].map(match => match[1])),
214
269
  plugins: toml === null ? null : names([...toml.matchAll(/^\s*\[plugins\."([a-z0-9_@.+-]+)"\]\s*$/gim)].map(match => match[1])),
215
- hooks: codexHooks === null && !/^notify\s*=/m.test(rootToml) ? null : names([...(codexHooks === null ? [] : Object.keys(object(codexHooks.hooks))), ...(/^notify\s*=/m.test(rootToml) ? ["notify"] : [])]), mcp: codexMcp }, instructions,
270
+ hooks: codexHooks === null && !/^notify\s*=/m.test(rootToml) ? null : names([...(codexHooks === null ? [] : Object.keys(object(codexHooks.hooks))), ...(/^notify\s*=/m.test(rootToml) ? ["notify"] : [])]), mcp: codexMcp
271
+ }, instructions,
216
272
  });
217
- const evidence = (tool) => ({ version_source: result[tool].version_source, model_basis: result[tool].model_basis, effort_basis: result[tool].effort_basis });
218
- console.error("[setup] versions read", JSON.stringify({ reason: "version_resolved", claude: evidence("claude"), codex: evidence("codex"), transcript_files: { claude: claudeObserved.files, codex: codexObserved.files } }));
219
- const unknownFields = [result.claude, result.codex].reduce((count, tool) => count + ["skills", "plugins", "agents", "hooks", "mcp"].filter(key => tool[key] === null).length, 0);
220
- console.error("[setup] inventory read", JSON.stringify({ reason: unknownFields ? "inventory_partial" : "inventory_observed", unknown_fields: unknownFields, os: result.os, mcp_count: (result.claude.mcp?.length ?? 0) + (result.codex.mcp?.length ?? 0) }));
221
- return result;
222
273
  }
@@ -78,6 +78,9 @@ async function healDetached(command, io, deps) {
78
78
  // BLI-12828: absent is created now; only a deliberate uninstall stops it.
79
79
  return { status: "skipped", reason: "autostart_uninstalled_by_operator" };
80
80
  }
81
+ return writeAndVerifyRegistration(command, exec, platform, roots, before.status);
82
+ }
83
+ async function writeAndVerifyRegistration(command, exec, platform, roots, beforeStatus) {
81
84
  let written;
82
85
  try {
83
86
  written = await writeDarwinAutostartPlist({
@@ -107,6 +110,9 @@ async function healDetached(command, io, deps) {
107
110
  detail: redactedHealthDetail(reregistered.detail ?? "").slice(0, DETAIL_MAX_CHARS),
108
111
  };
109
112
  }
113
+ return verifyRegistration(command, exec, platform, roots, beforeStatus, written.node_path_reason);
114
+ }
115
+ async function verifyRegistration(command, exec, platform, roots, beforeStatus, nodePathReason) {
110
116
  // Verify against what launchd returns, not against what we sent it
111
117
  // (BLI-2541). A bootstrap that exits 0 and a registration that reads back
112
118
  // healthy are two different claims.
@@ -127,8 +133,8 @@ async function healDetached(command, io, deps) {
127
133
  }
128
134
  return {
129
135
  status: "ok",
130
- reason: before.status === "absent" ? "autostart_created" : "autostart_healed",
131
- detail: `node_path_reason=${written.node_path_reason}`,
136
+ reason: beforeStatus === "absent" ? "autostart_created" : "autostart_healed",
137
+ detail: `node_path_reason=${nodePathReason}`,
132
138
  };
133
139
  }
134
140
  async function waitForParentExit(parentPid, deps) {
@@ -117,6 +117,9 @@ async function stateFor(result, roots, options) {
117
117
  ...detail,
118
118
  };
119
119
  }
120
+ return readRegisteredRuntime(roots, options);
121
+ }
122
+ async function readRegisteredRuntime(roots, options) {
120
123
  // BLI-3553: "loaded" only means the scheduler accepted the registration. It
121
124
  // says nothing about whether the binary that registration names still exists
122
125
  // — and `brew upgrade node` deletes exactly that. Ask the filesystem about
@@ -50,17 +50,20 @@ function summarizeEvidenceOutcomesBySession(sync) {
50
50
  else if (outcome.raw_evidence_pointer_id &&
51
51
  (outcome.upload_state === "uploaded" ||
52
52
  outcome.upload_state === "reused_existing")) {
53
- if (outcome.kind === "codex_jsonl" || outcome.kind === "claude_jsonl") {
54
- summary.durableMainCount += 1;
55
- }
56
- else if (outcome.kind === "claude_jsonl_sidecar") {
57
- summary.durableSidecarCount += 1;
58
- }
53
+ countDurableEvidence(summary, outcome);
59
54
  }
60
55
  outcomesBySession.set(key, summary);
61
56
  }
62
57
  return outcomesBySession;
63
58
  }
59
+ function countDurableEvidence(summary, outcome) {
60
+ if (outcome.kind === "codex_jsonl" || outcome.kind === "claude_jsonl") {
61
+ summary.durableMainCount += 1;
62
+ }
63
+ else if (outcome.kind === "claude_jsonl_sidecar") {
64
+ summary.durableSidecarCount += 1;
65
+ }
66
+ }
64
67
  /** Sidecars this session owes a pointer for; one already skipped at scan time is not owed. */
65
68
  function expectedSidecarCount(candidate) {
66
69
  if (candidate.source !== "claude_code")
@@ -71,6 +71,9 @@ function addCandidateScanIssues(issues, candidates, omittedCandidateCount) {
71
71
  countScanIssue(issues, "candidate_worktree_unavailable", candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) && candidate.worktree === null).length, "candidate");
72
72
  countScanIssue(issues, "claude_main_file_too_large", candidates.filter((candidate) => candidate.source === "claude_code" &&
73
73
  candidate.claude?.main_file_oversized).length, "candidate");
74
+ addClaudeSidecarIssues(issues, candidates);
75
+ }
76
+ function addClaudeSidecarIssues(issues, candidates) {
74
77
  countScanIssue(issues, "claude_sidecar_limit_applied", candidates.reduce((total, candidate) => total + (candidate.claude?.sidecars_capped ?? 0), 0), "candidate");
75
78
  countScanIssue(issues, "claude_sidecar_file_unreadable", candidates.reduce((total, candidate) => total +
76
79
  (candidate.claude?.sidecar_files.filter((sidecar) => sidecar.skipped_reason === "file_read_failed" ||
@@ -44,28 +44,7 @@ export async function scanBackfillRun(command, io, now) {
44
44
  };
45
45
  }
46
46
  const { collectionRoots, sources, worktreeDiscovery } = scope;
47
- const storedCursor = command.dryRun
48
- ? emptyBackfillCursorState()
49
- : await readBackfillCursor(paths);
50
- const scopedCursor = prepareBackfillCursorForScope(storedCursor, collectionRoots, sources);
51
- const cursor = scopedCursor.cursor;
52
- const pointerlessTerminalSessions = await loadPointerlessTerminalSessions(paths, sources);
53
- // BLI-11102: a redaction-crash stub is not a delivery. The server names
54
- // them; those sessions reopen exactly like pointer-less ones. A dry run
55
- // keeps its one-request contract (the reachability probe) and does not ask.
56
- // BLI-11112: the sync's stub pass brings the door's answer with it.
57
- const stubReoffer = command.stubPass
58
- ? command.stubPass.plan
59
- : command.dryRun
60
- ? emptyStubReofferPlan("dry_run")
61
- : await loadStubReofferPlan({
62
- paths,
63
- dashboardUrl: scope.dashboardUrl,
64
- sources,
65
- fetchImpl: io.fetch,
66
- });
67
- reopenStubbedSessions(pointerlessTerminalSessions, stubReoffer);
68
- const stubCursorHashes = await loadStubCursorHashes(paths, stubReoffer);
47
+ const { scopedCursor, cursor, pointerlessTerminalSessions, stubReoffer, stubCursorHashes } = await reopenUndeliveredHistory(command, io, paths, scope);
69
48
  const scan = await scanBackfillSessions({
70
49
  command,
71
50
  homeDir: command.homeDir ?? os.homedir(),
@@ -99,6 +78,32 @@ export async function scanBackfillRun(command, io, now) {
99
78
  deterministicSkipCandidateKeys: deterministicSkipCandidateKeys(scan.candidates),
100
79
  };
101
80
  }
81
+ async function reopenUndeliveredHistory(command, io, paths, scope) {
82
+ const { collectionRoots, sources } = scope;
83
+ const storedCursor = command.dryRun
84
+ ? emptyBackfillCursorState()
85
+ : await readBackfillCursor(paths);
86
+ const scopedCursor = prepareBackfillCursorForScope(storedCursor, collectionRoots, sources);
87
+ const cursor = scopedCursor.cursor;
88
+ const pointerlessTerminalSessions = await loadPointerlessTerminalSessions(paths, sources);
89
+ // BLI-11102: a redaction-crash stub is not a delivery. The server names
90
+ // them; those sessions reopen exactly like pointer-less ones. A dry run
91
+ // keeps its one-request contract (the reachability probe) and does not ask.
92
+ // BLI-11112: the sync's stub pass brings the door's answer with it.
93
+ const stubReoffer = command.stubPass
94
+ ? command.stubPass.plan
95
+ : command.dryRun
96
+ ? emptyStubReofferPlan("dry_run")
97
+ : await loadStubReofferPlan({
98
+ paths,
99
+ dashboardUrl: scope.dashboardUrl,
100
+ sources,
101
+ fetchImpl: io.fetch,
102
+ });
103
+ reopenStubbedSessions(pointerlessTerminalSessions, stubReoffer);
104
+ const stubCursorHashes = await loadStubCursorHashes(paths, stubReoffer);
105
+ return { scopedCursor, cursor, pointerlessTerminalSessions, stubReoffer, stubCursorHashes };
106
+ }
102
107
  /**
103
108
  * Finishes the scan's ledger before anything acts on it: adds what repo
104
109
  * discovery could not enumerate, reads every candidate file once to find the
@@ -21,15 +21,7 @@ const BACKFILL_MAX_CONSECUTIVE_FAILURES = 3;
21
21
  */
22
22
  export async function uploadBackfillBatches(command, io, lock, ctx) {
23
23
  const uploadable = uploadableCandidates(ctx.scan.candidates);
24
- // Doctor needs a boundary after each session, and so does the scheduled
25
- // sync's stub pass (BLI-11112), which hands the lock to a waiting backfill
26
- // at the next one. Ordinary backfill retains its existing batching.
27
- const plannedBatches = buildBackfillBatches(uploadable);
28
- const batches = command.deadlineMs !== undefined || command.onProgress || command.stubPass
29
- ? plannedBatches.flatMap((batch) => batch.candidates.map((candidate) => ({
30
- worktree: batch.worktree, candidates: [candidate],
31
- })))
32
- : plannedBatches;
24
+ const batches = planUploadBatches(command, uploadable);
33
25
  const rawEvidenceBudget = {
34
26
  remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
35
27
  remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
@@ -38,29 +30,8 @@ export async function uploadBackfillBatches(command, io, lock, ctx) {
38
30
  let consecutiveFailures = 0;
39
31
  let blockedAt;
40
32
  let failureReason = blockingScanIssues(ctx.scan.issues)[0]?.reason;
41
- const stoppedAt = (what, index) => ({
42
- what,
43
- batch_index: index + 1,
44
- batch_total: batches.length,
45
- done: tally.done,
46
- total: uploadable.length,
47
- });
48
- const reportProgress = () => {
49
- if (!command.onProgress)
50
- return;
51
- const unresolved = new Set([
52
- ...uploadable.map(candidateCursorKey).filter((key) => !tally.durableCandidateKeys.has(key)),
53
- ...ctx.scan.retryable_candidate_keys,
54
- ]);
55
- command.onProgress({
56
- processed: tally.done,
57
- uploaded: tally.backfilledSessions,
58
- remaining: unresolved.size + ctx.scan.omitted_candidate_count + ctx.scan.issues
59
- .filter((issue) => issue.scope === "global")
60
- .reduce((total, issue) => total + issue.count, 0),
61
- elapsedMs: Date.now() - ctx.now.getTime(),
62
- });
63
- };
33
+ const stoppedAt = (what, index) => describeBatchStop(what, index, batches.length, tally.done, uploadable.length);
34
+ const reportProgress = () => reportBackfillProgress(command, ctx, uploadable, tally);
64
35
  reportProgress();
65
36
  // BLI-11091: outside-root sessions upload under a synthetic workspace that
66
37
  // only exists once something needs it (a dry run never gets here).
@@ -69,15 +40,10 @@ export async function uploadBackfillBatches(command, io, lock, ctx) {
69
40
  await ensureOutsideRootsWorkspace(outsideWorkspace);
70
41
  for (const [index, batch] of batches.entries()) {
71
42
  await lock.handle.heartbeat();
72
- if (command.deadlineMs !== undefined && Date.now() >= command.deadlineMs) {
73
- blockedAt = stoppedAt("backfill time budget reached", index);
74
- failureReason = "deferred_budget_exhausted";
75
- break;
76
- }
77
- if (command.stubPass?.shouldYield && (await command.stubPass.shouldYield())) {
78
- blockedAt = stoppedAt("yielded to a waiting backfill", index);
79
- failureReason = "yielded_to_backfill";
80
- console.error("[collector stubs] stub pass yielded the collection lock to a waiting backfill", JSON.stringify({ reason: "backfill_waiting", sessions_done: index, sessions_left: batches.length - index }));
43
+ const before = await stopBeforeBatch(command, index, batches.length);
44
+ if (before) {
45
+ blockedAt = stoppedAt(before.what, index);
46
+ failureReason = before.reason;
81
47
  break;
82
48
  }
83
49
  const outcome = await uploadOneBackfillBatch(command, io, ctx, batch, rawEvidenceBudget);
@@ -88,30 +54,73 @@ export async function uploadBackfillBatches(command, io, lock, ctx) {
88
54
  writeLine(io.stdout, `Uploaded ${tally.done}/${uploadable.length} (batch ${index + 1}/${batches.length})`);
89
55
  }
90
56
  await yieldToEventLoop();
91
- if (outcome.deferred) {
92
- blockedAt = stoppedAt("raw evidence budget exhausted", index);
93
- failureReason = "deferred_budget_exhausted";
94
- break;
95
- }
96
- // BLI-12832: the disk gate refused a copy. Every later batch would be
97
- // refused the same way, so stop here; the cursor only advanced through
98
- // sessions that earned a durable pointer, and the next run starts from
99
- // the same place once room returns.
100
- const stagingDeferred = stagingDeferredFiles(rawEvidenceBudget.staging);
101
- if (stagingDeferred > 0) {
102
- blockedAt = stoppedAt("staging paused: not enough room to copy more evidence", index);
103
- failureReason = "deferred_budget_exhausted";
104
- console.error("[backfill] stopped, the staging gate is holding new copies", JSON.stringify({ reason: "staging_deferred", files_deferred: stagingDeferred, batch_index: index + 1 }));
105
- break;
106
- }
107
- if (consecutiveFailures >= BACKFILL_MAX_CONSECUTIVE_FAILURES) {
108
- blockedAt = stoppedAt("consecutive upload failures", index);
109
- failureReason = failedBatchReason(outcome.sync, outcome.missingDurableMain);
57
+ const after = stopAfterBatch(outcome, rawEvidenceBudget, consecutiveFailures, index);
58
+ if (after) {
59
+ blockedAt = stoppedAt(after.what, index);
60
+ failureReason = after.reason;
110
61
  break;
111
62
  }
112
63
  }
113
64
  return { ...tally, uploadable, batches, blockedAt, failureReason };
114
65
  }
66
+ function planUploadBatches(command, uploadable) {
67
+ // Doctor needs a boundary after each session, and so does the scheduled
68
+ // sync's stub pass (BLI-11112), which hands the lock to a waiting backfill
69
+ // at the next one. Ordinary backfill retains its existing batching.
70
+ const plannedBatches = buildBackfillBatches(uploadable);
71
+ return command.deadlineMs !== undefined || command.onProgress || command.stubPass
72
+ ? plannedBatches.flatMap((batch) => batch.candidates.map((candidate) => ({
73
+ worktree: batch.worktree, candidates: [candidate],
74
+ })))
75
+ : plannedBatches;
76
+ }
77
+ function describeBatchStop(what, index, batchTotal, done, total) {
78
+ return { what, batch_index: index + 1, batch_total: batchTotal, done, total };
79
+ }
80
+ function reportBackfillProgress(command, ctx, uploadable, tally) {
81
+ if (!command.onProgress)
82
+ return;
83
+ const unresolved = new Set([
84
+ ...uploadable.map(candidateCursorKey).filter((key) => !tally.durableCandidateKeys.has(key)),
85
+ ...ctx.scan.retryable_candidate_keys,
86
+ ]);
87
+ command.onProgress({
88
+ processed: tally.done,
89
+ uploaded: tally.backfilledSessions,
90
+ remaining: unresolved.size + ctx.scan.omitted_candidate_count + ctx.scan.issues
91
+ .filter((issue) => issue.scope === "global")
92
+ .reduce((total, issue) => total + issue.count, 0),
93
+ elapsedMs: Date.now() - ctx.now.getTime(),
94
+ });
95
+ }
96
+ async function stopBeforeBatch(command, index, batchCount) {
97
+ if (command.deadlineMs !== undefined && Date.now() >= command.deadlineMs) {
98
+ return { what: "backfill time budget reached", reason: "deferred_budget_exhausted" };
99
+ }
100
+ if (command.stubPass?.shouldYield && (await command.stubPass.shouldYield())) {
101
+ console.error("[collector stubs] stub pass yielded the collection lock to a waiting backfill", JSON.stringify({ reason: "backfill_waiting", sessions_done: index, sessions_left: batchCount - index }));
102
+ return { what: "yielded to a waiting backfill", reason: "yielded_to_backfill" };
103
+ }
104
+ return null;
105
+ }
106
+ function stopAfterBatch(outcome, rawEvidenceBudget, consecutiveFailures, index) {
107
+ if (outcome.deferred) {
108
+ return { what: "raw evidence budget exhausted", reason: "deferred_budget_exhausted" };
109
+ }
110
+ // BLI-12832: the disk gate refused a copy. Every later batch would be
111
+ // refused the same way, so stop here; the cursor only advanced through
112
+ // sessions that earned a durable pointer, and the next run starts from
113
+ // the same place once room returns.
114
+ const stagingDeferred = stagingDeferredFiles(rawEvidenceBudget.staging);
115
+ if (stagingDeferred > 0) {
116
+ console.error("[backfill] stopped, the staging gate is holding new copies", JSON.stringify({ reason: "staging_deferred", files_deferred: stagingDeferred, batch_index: index + 1 }));
117
+ return { what: "staging paused: not enough room to copy more evidence", reason: "deferred_budget_exhausted" };
118
+ }
119
+ if (consecutiveFailures >= BACKFILL_MAX_CONSECUTIVE_FAILURES) {
120
+ return { what: "consecutive upload failures", reason: failedBatchReason(outcome.sync, outcome.missingDurableMain) };
121
+ }
122
+ return null;
123
+ }
115
124
  /** Uploads one batch and judges it; every judgement here is a named predicate. */
116
125
  async function uploadOneBackfillBatch(command, io, ctx, batch, rawEvidenceBudget) {
117
126
  const sync = await syncBackfillBatch({
@@ -121,27 +121,12 @@ export async function runBackfill(command, io) {
121
121
  // is working, so wait for it (bounded) instead of exiting at batch 0. Giving
122
122
  // up is `sync_lock_held`, a blocked result and so a non-zero exit. The
123
123
  // sync's own stub pass (BLI-11112) runs inside the sync's lock and skips it.
124
- let collectionLock;
125
- if (command.stubPass) {
126
- collectionLock = { handle: { release: async () => undefined } };
127
- }
128
- else {
129
- let waited;
130
- try {
131
- waited = await acquireSyncLockWithWait(paths, command.syncLockWaitMs ?? BACKFILL_SYNC_LOCK_WAIT_MS);
132
- }
133
- catch (error) {
134
- await lock.handle.release();
135
- throw error;
136
- }
137
- if (!waited.acquired) {
138
- await lock.handle.release();
139
- return lockHeldResult(command, scanned, {
140
- failureReason: "sync_lock_held",
141
- what: "sync collection lock held",
142
- });
143
- }
144
- collectionLock = waited.lock;
124
+ const collectionLock = await waitForCollectionLock(command, paths, lock);
125
+ if (!collectionLock) {
126
+ return lockHeldResult(command, scanned, {
127
+ failureReason: "sync_lock_held",
128
+ what: "sync collection lock held",
129
+ });
145
130
  }
146
131
  try {
147
132
  // Backfill and scheduled/manual sync share upload spool and raw-evidence
@@ -161,4 +146,24 @@ export async function runBackfill(command, io) {
161
146
  await collectionLock.handle.release();
162
147
  await lock.handle.release();
163
148
  }
149
+ }
150
+ async function waitForCollectionLock(command, paths, lock) {
151
+ if (command.stubPass) {
152
+ return { handle: { release: async () => undefined } };
153
+ }
154
+ else {
155
+ let waited;
156
+ try {
157
+ waited = await acquireSyncLockWithWait(paths, command.syncLockWaitMs ?? BACKFILL_SYNC_LOCK_WAIT_MS);
158
+ }
159
+ catch (error) {
160
+ await lock.handle.release();
161
+ throw error;
162
+ }
163
+ if (!waited.acquired) {
164
+ await lock.handle.release();
165
+ return null;
166
+ }
167
+ return waited.lock;
168
+ }
164
169
  }
@@ -63,6 +63,10 @@ export async function runBriefDone(command, io) {
63
63
  writeFailure(command, io, body.error ?? "not_saved", body.reply ?? body.error ?? "It did not save.");
64
64
  return 1;
65
65
  }
66
+ writeTickSuccess(command, io, body, done, found);
67
+ return 0;
68
+ }
69
+ function writeTickSuccess(command, io, body, done, found) {
66
70
  if (command.json) {
67
71
  writeLine(io.stdout, JSON.stringify(body));
68
72
  }
@@ -76,7 +80,6 @@ export async function runBriefDone(command, io) {
76
80
  item_key: found.itemKey,
77
81
  has_ticket: found.ticketIdentifier != null,
78
82
  })}`);
79
- return 0;
80
83
  }
81
84
  /** The page, with its claim ids, so an item can be checked before anything is sent. */
82
85
  async function readThePage(dashboardUrl, deviceToken, io, log) {