@bli-cockpit/cli 0.2.210 → 0.2.211

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.
@@ -246,7 +246,7 @@ export function buildAgentSessionReport(options) {
246
246
  */
247
247
  export function buildAgentSessionSummary(options) {
248
248
  const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
249
- const attributedClaude = options.claudeAttribution.results.filter((result) => isLiveRawEvidenceSyncAttribution(result.state, result.reason));
249
+ const attributedClaude = options.claudeAttribution.results.filter((result) => isLiveRawEvidenceSyncAttribution(result.state, result.reason, result.worktree !== null));
250
250
  const sidecarsCollected = attributedClaude.reduce((total, result) => total +
251
251
  result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
252
252
  const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
@@ -160,10 +160,12 @@ function shellQuote(value) {
160
160
  * a gate on it, so a session sitting outside one is not a defect doctor found
161
161
  * and cannot repair. This row is therefore always informational (`ok`): it
162
162
  * reports how many of this scan's sessions were inside vs. outside, never
163
- * asks for approval, and attempts no fix. Widening a root only adds the redacted transcript BYTES for those
164
- * directories, so the message names `cockpit roots add` as an OPTIONAL step an
165
- * operator can choose, never something doctor, or any person, is waiting on to
166
- * approve. The
163
+ * asks for approval, and attempts no fix. Since BLI-17916 the scheduled sync
164
+ * uploads the redacted transcript of an outside-root session too (under the
165
+ * collector's outside-roots workspace), so widening a root buys only
166
+ * attribution to a repo; the message names `cockpit roots add` as an OPTIONAL
167
+ * step an operator can choose, never something doctor, or any person, is
168
+ * waiting on to approve. The
167
169
  * row only turns red the ordinary way every other doctor check does: the scan
168
170
  * itself throwing (`work_directory_scan_incomplete` from unreadable session
169
171
  * folders), which `runDoctorWithDeps`'s existing `check_threw` handling
@@ -180,7 +182,7 @@ export async function readRootsCoverWork(context) {
180
182
  if (!outsideCount)
181
183
  return ok("roots-cover-work", "work_covered", `${scan.observed} sessions seen; no working folders outside your saved roots. ${sessionCoverageLine(scan, outsideCount)}`);
182
184
  return {
183
- ...ok("roots-cover-work", "work_outside_roots_labelled", `${insideCount} of ${scan.observed} sessions inside your saved roots, ${outsideCount} outside — every one already reached the server, the outside ones carrying an outside label. Nothing needs approving. Optional: add a folder as a root to also upload its redacted transcript text (scan ${scan.generated_at}):\n${outside.map(entry => `${entry.count}: ${entry.directory}\n ${rootsAddCommand(entry.directory, process.platform, homeDir)}`).join("\n")}\n\n${sessionCoverageLine(scan, outsideCount)}`),
185
+ ...ok("roots-cover-work", "work_outside_roots_labelled", `${insideCount} of ${scan.observed} sessions inside your saved roots, ${outsideCount} outside — every one already reached the server, the outside ones carrying an outside label, and their redacted transcripts upload too, a few per sync, newest first. Nothing needs approving. Optional: add a folder as a root to attribute its sessions to a repo (scan ${scan.generated_at}):\n${outside.map(entry => `${entry.count}: ${entry.directory}\n ${rootsAddCommand(entry.directory, process.platform, homeDir)}`).join("\n")}\n\n${sessionCoverageLine(scan, outsideCount)}`),
184
186
  nextAction: rootsAddCommand(outside[0].directory, process.platform, homeDir),
185
187
  };
186
188
  }
@@ -93,7 +93,7 @@ export function localSubcommandHelp(command) {
93
93
  "",
94
94
  "Diagnose, repair, and verify this machine.",
95
95
  "Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
96
- "roots-cover-work is informational: it names how many sessions are inside vs. outside saved roots (all already uploaded, outside ones labelled) and gives cockpit roots add commands for widening a root's redacted transcript content. It never needs a fix.",
96
+ "roots-cover-work is informational: it names how many sessions are inside vs. outside saved roots (all already uploaded, outside ones labelled) and gives cockpit roots add commands for attributing a folder's sessions to a repo (outside-root transcripts upload either way). It never needs a fix.",
97
97
  "--no-usage-telemetry switches off Claude Code and Codex usage export, including later doctor runs.",
98
98
  "--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
99
99
  "--backfill-budget defaults to 900 seconds; catch-up repeats chunks until complete, with progress every 30 seconds.",
@@ -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.210");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.211");
25
25
  return 0;
26
26
  }
27
27
 
@@ -81,6 +81,8 @@ function renderExperiment(card, styled) {
81
81
  for (const row of card.crossref ?? []) {
82
82
  if (row.person) {
83
83
  out.push(dim(` On your team · ${row.person} · ${row.observedPattern ?? ""}`, styled));
84
+ if (row.provenanceLine)
85
+ out.push(dim(` ${row.provenanceLine}`, styled));
84
86
  }
85
87
  }
86
88
  if (card.expectedPayoff)
@@ -11,6 +11,7 @@
11
11
  * re-derived anywhere else.
12
12
  */
13
13
  import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-policy.js";
14
+ import { outsideRootEntryOwesUpload } from "./session-sync-outside-roots.js";
14
15
  /**
15
16
  * Live sync remains reason-allowlisted even though historical backfill accepts
16
17
  * every deterministic fallback state.
@@ -18,9 +19,16 @@ import { isLiveRawEvidenceSyncAttribution } from "../raw-evidence-attribution-po
18
19
  export function isLiveSyncCollectableAttributionState(state, reason, hasApprovedWorkspace = false) {
19
20
  return isLiveRawEvidenceSyncAttribution(state, reason, hasApprovedWorkspace);
20
21
  }
21
- export function liveSyncCursorEntryRequiresRetry(entry) {
22
- return (!entry.uploaded_object_key &&
23
- (isLiveSyncCollectableAttributionState(entry.state, entry.reason, Boolean(entry.worktree_fingerprint)) || entry.reason === "repo_not_on_disk"));
22
+ /**
23
+ * Does this cursor entry still owe a transcript upload, so the next scan window
24
+ * must reach back to it? An outside-root session owes one too (BLI-17916): the
25
+ * scheduled sync uploads it now, a few per tick, and the window stays open
26
+ * until the last of them is durable.
27
+ */
28
+ export function liveSyncCursorEntryRequiresRetry(entry, now = new Date()) {
29
+ return ((!entry.uploaded_object_key &&
30
+ (isLiveSyncCollectableAttributionState(entry.state, entry.reason, Boolean(entry.worktree_fingerprint)) || entry.reason === "repo_not_on_disk")) ||
31
+ outsideRootEntryOwesUpload(entry, now));
24
32
  }
25
33
  export function matchesLiveSyncWorktree(result, target) {
26
34
  return (isLiveRawEvidenceSyncAttribution(result.state, result.reason, result.worktree !== null) &&
@@ -118,6 +118,6 @@ function announceNothingInRoot(sessionsOutsideRoot, collectionRootCount) {
118
118
  reason: "nothing_in_root",
119
119
  sessions_outside_root: sessionsOutsideRoot,
120
120
  collection_root_count: collectionRootCount,
121
- next_action: "widen the approved roots (an operator decision) if this machine should be collecting here",
121
+ next_action: "none needed: outside-root sessions upload under the outside-roots label (see the [session-sync] outside-root raw upload log line for why none did this tick); add a root only to attribute them to a repo",
122
122
  }));
123
123
  }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * BLI-17916: the scheduled sync uploads the transcript of a session that sits
3
+ * outside every approved root, the same way `cockpit backfill` does.
4
+ *
5
+ * Edward's ruling of 2026-09-17 (ticket 10734) made approved roots a LABEL on
6
+ * the upload, never a gate. The scheduled sync honoured it for the session
7
+ * REPORT only: the redacted transcript bytes of an outside-root session went up
8
+ * through `cockpit backfill` and nothing else, so on a machine where backfill
9
+ * never finished about a third of the last 30 days of sessions (2,301 of 7,815)
10
+ * could never be summarised.
11
+ *
12
+ * This is the one step that closes it. After the scan, before the upload, it
13
+ * picks which outside-root sessions ride this tick and hands each the SAME
14
+ * synthetic workspace backfill uses (`backfill-outside-roots.ts`), so the rest
15
+ * of the pass needs no special case: a session with a workspace is an ordinary
16
+ * sync target, goes through the ordinary envelope, and meets the same redaction.
17
+ * Attribution is left alone: the state stays `unattributed`, the reason stays
18
+ * `cwd_outside_scanned_worktrees`, the row stays `root_scope: "outside"`, and
19
+ * the workspace is transport identity only, never a repo the machine owns.
20
+ *
21
+ * What this step decides, and says out loud:
22
+ *
23
+ * - A session already durable with unchanged content rides for free. It
24
+ * uploads nothing (the object cursor reuses it) and keeps its row stable.
25
+ * - A session that is new, changed or not yet uploaded is "owed". The newest
26
+ * ones ride this tick up to a per-tick session cap. The rest wait, and their
27
+ * row says `deferred_object_budget`, the existing "next tick has room"
28
+ * label, instead of the old "not uploadable" one.
29
+ * - A transcript larger than the wire cap can never upload, so it does not
30
+ * spend a slot every tick; its row says `file_too_large`.
31
+ * - Nothing here runs for a source the operator switched off:
32
+ * `collect_claude_jsonl: false` means the Claude store is never read (the
33
+ * plan, not this step, decides that), so there are no Claude results to pick.
34
+ *
35
+ * The cap, the byte allowance and the object allowance are per tick, so a
36
+ * 1,800-session backlog drains across many ticks and never blows one tick's
37
+ * time or the machine's memory. Newest first: the sessions a person is most
38
+ * likely to be waiting on summarise first, and a backlog of old ones never
39
+ * starves today's.
40
+ *
41
+ * A new sibling must also join `scripts/build-public-cli.mjs` `runtimeFiles`.
42
+ */
43
+ import fs from "node:fs/promises";
44
+ import path from "node:path";
45
+ import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
46
+ import { isRedactionStubObjectKey } from "../cursors/raw-evidence-stub-entry.js";
47
+ import { describeError } from "../health-detail.js";
48
+ import { writeJsonFile } from "../local-state-files.js";
49
+ import { rootScopeForReason } from "./agent-session-root-scope.js";
50
+ import { ensureOutsideRootsWorkspace, isOutsideRootsWorkspace, outsideRootsWorkspace, } from "./backfill-outside-roots.js";
51
+ /** Most outside-root sessions that upload bytes in one tick. 4 ticks an hour, so up to 3,840 a day. */
52
+ export const OUTSIDE_ROOT_SESSIONS_PER_TICK = 40;
53
+ /** Redacted bytes an outside-root pass may stage in one tick, kept apart from the in-root allowance. */
54
+ export const OUTSIDE_ROOT_BYTES_PER_TICK = 256 * 1024 * 1024;
55
+ /** Files (a Claude main plus its sidecars count one each) an outside-root pass may collect in one tick. */
56
+ export const OUTSIDE_ROOT_OBJECTS_PER_TICK = 120;
57
+ /**
58
+ * How far back the one-time catch-up scan reaches, and the oldest outside-root
59
+ * session that still widens a later tick's window. Longer history stays
60
+ * `cockpit backfill`'s job.
61
+ */
62
+ export const OUTSIDE_ROOT_HORIZON_DAYS = 30;
63
+ export const OUTSIDE_ROOT_HORIZON_MINUTES = OUTSIDE_ROOT_HORIZON_DAYS * 24 * 60;
64
+ /** The upload_reason a session carries while it waits for a later tick. The existing transient label. */
65
+ export const OUTSIDE_ROOT_WAITING_REASON = "deferred_object_budget";
66
+ /** The upload_reason of a transcript over the wire cap. The existing deterministic label. */
67
+ export const OUTSIDE_ROOT_TOO_LARGE_REASON = "file_too_large";
68
+ const CATCH_UP_MARKER = "outside-root-catchup.json";
69
+ export function outsideRootCatchUpMarkerPath(paths) {
70
+ return path.join(paths.cursors_dir, CATCH_UP_MARKER);
71
+ }
72
+ /** Has the one-time 30-day look-back already run on this machine? */
73
+ export async function outsideRootCatchUpIsPending(paths) {
74
+ try {
75
+ await fs.access(outsideRootCatchUpMarkerPath(paths));
76
+ return false;
77
+ }
78
+ catch {
79
+ return true;
80
+ }
81
+ }
82
+ /**
83
+ * Record that the look-back ran, so later ticks go back to the normal window.
84
+ * Best effort: a marker that cannot be written only means the next tick looks
85
+ * back once more, and it says so.
86
+ */
87
+ export async function markOutsideRootCatchUpDone(paths, facts) {
88
+ try {
89
+ await writeJsonFile(outsideRootCatchUpMarkerPath(paths), {
90
+ completed_at: facts.now.toISOString(),
91
+ window_days: OUTSIDE_ROOT_HORIZON_DAYS,
92
+ sessions_seen: facts.sessions_seen,
93
+ });
94
+ console.error("[session-sync] outside-root catch-up look-back done", JSON.stringify({ reason: "catch_up_marker_written", window_days: OUTSIDE_ROOT_HORIZON_DAYS, sessions_seen: facts.sessions_seen }));
95
+ }
96
+ catch (error) {
97
+ console.error("[session-sync] outside-root catch-up marker not written", JSON.stringify({ reason: "catch_up_marker_write_failed", ...describeError(error) }));
98
+ }
99
+ }
100
+ /** Widens a scan window to the catch-up horizon while the look-back is owed. */
101
+ export function widenForCatchUp(sinceMinutes, catchUpPending, normalMinutes) {
102
+ if (!catchUpPending)
103
+ return sinceMinutes;
104
+ return Math.max(sinceMinutes ?? normalMinutes, OUTSIDE_ROOT_HORIZON_MINUTES);
105
+ }
106
+ /**
107
+ * Does this cursor entry belong to an outside-root session that still owes its
108
+ * transcript? Such a session keeps the next scan window open back to its age,
109
+ * the same way an undurable in-root session does. A transcript over the wire
110
+ * cap never counts (it can never become durable), and neither does one older
111
+ * than the horizon (that is backfill's).
112
+ */
113
+ export function outsideRootEntryOwesUpload(entry, now = new Date()) {
114
+ if (rootScopeForReason(entry.reason) !== "outside")
115
+ return false;
116
+ if (entry.uploaded_object_key && !isRedactionStubObjectKey(entry.uploaded_object_key))
117
+ return false;
118
+ if (entry.byte_size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES)
119
+ return false;
120
+ return now.getTime() - entry.file_mtime_ms <= OUTSIDE_ROOT_HORIZON_MINUTES * 60_000;
121
+ }
122
+ /** Is this scan result a session outside every approved root with no worktree of its own? */
123
+ function isUnclaimedOutsideRootResult(result) {
124
+ return !result.worktree && rootScopeForReason(result.reason) === "outside";
125
+ }
126
+ /** Already durable remotely with the bytes the scan just read: it needs no upload. */
127
+ function isDurableAndUnchanged(result, entry) {
128
+ return Boolean(entry &&
129
+ entry.uploaded_object_key &&
130
+ !isRedactionStubObjectKey(entry.uploaded_object_key) &&
131
+ !entry.stubbed &&
132
+ result.content_hash_sha256 &&
133
+ entry.file_hash_sha256 === result.content_hash_sha256);
134
+ }
135
+ function sightingsOf(scan, plan) {
136
+ const sightings = [];
137
+ for (const result of scan.codexAttribution.results) {
138
+ if (isUnclaimedOutsideRootResult(result)) {
139
+ sightings.push({ source: "codex", sessionId: result.codex_session_id, result });
140
+ }
141
+ }
142
+ // A machine that opted out of Claude has no Claude results to begin with
143
+ // (`session-sync-scan.ts` returns an empty scan); the guard keeps this step
144
+ // honest on its own as well.
145
+ if (plan.claudeEnabled) {
146
+ for (const result of scan.claudeAttribution.results) {
147
+ if (isUnclaimedOutsideRootResult(result)) {
148
+ sightings.push({ source: "claude_code", sessionId: result.claude_session_id, result });
149
+ }
150
+ }
151
+ }
152
+ return sightings;
153
+ }
154
+ export function outsideRootKey(source, sessionId) {
155
+ return `${source}:${sessionId}`;
156
+ }
157
+ /**
158
+ * Pick this tick's outside-root sessions and give them the synthetic workspace.
159
+ * Returns the scan to upload from (the input scan when nothing is admitted) with
160
+ * the admission facts the upload and the report steps read.
161
+ */
162
+ export async function admitOutsideRootSessions(options) {
163
+ const { plan, scan } = options;
164
+ const sightings = sightingsOf(scan, plan);
165
+ if (sightings.length === 0) {
166
+ return { ...scan, outsideRoots: null };
167
+ }
168
+ const cursorFor = (source) => source === "codex" ? plan.codexCursorBefore : plan.claudeCursorBefore;
169
+ const free = [];
170
+ const owed = [];
171
+ const holds = new Map();
172
+ let tooLarge = 0;
173
+ for (const sighting of sightings) {
174
+ const entry = cursorFor(sighting.source).sessions[sighting.sessionId];
175
+ if (sighting.result.byte_size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
176
+ holds.set(outsideRootKey(sighting.source, sighting.sessionId), OUTSIDE_ROOT_TOO_LARGE_REASON);
177
+ tooLarge += 1;
178
+ }
179
+ else if (isDurableAndUnchanged(sighting.result, entry)) {
180
+ free.push(sighting);
181
+ }
182
+ else {
183
+ owed.push(sighting);
184
+ }
185
+ }
186
+ // Newest first, with the session id as a tie-break so two ticks over the same
187
+ // files pick the same sessions.
188
+ owed.sort((left, right) => right.result.session_file_mtime_ms - left.result.session_file_mtime_ms ||
189
+ left.sessionId.localeCompare(right.sessionId));
190
+ const riding = owed.slice(0, OUTSIDE_ROOT_SESSIONS_PER_TICK);
191
+ const waiting = owed.slice(OUTSIDE_ROOT_SESSIONS_PER_TICK);
192
+ for (const sighting of waiting) {
193
+ holds.set(outsideRootKey(sighting.source, sighting.sessionId), OUTSIDE_ROOT_WAITING_REASON);
194
+ }
195
+ const admitted = [...riding, ...free];
196
+ const counts = {
197
+ outside_root_sessions: sightings.length,
198
+ riding_owed: riding.length,
199
+ riding_already_durable: free.length,
200
+ waiting_next_tick: waiting.length,
201
+ too_large: tooLarge,
202
+ codex_riding: admitted.filter((sighting) => sighting.source === "codex").length,
203
+ claude_riding: admitted.filter((sighting) => sighting.source === "claude_code").length,
204
+ };
205
+ const budget = {
206
+ sessions_per_tick: OUTSIDE_ROOT_SESSIONS_PER_TICK,
207
+ bytes_per_tick: OUTSIDE_ROOT_BYTES_PER_TICK,
208
+ objects_per_tick: OUTSIDE_ROOT_OBJECTS_PER_TICK,
209
+ };
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;
246
+ }
247
+ /**
248
+ * Says why an outside-root session did not ride this tick, on its report row.
249
+ * A row that has a pointer, or a reason the upload itself recorded, is left
250
+ * alone; the rest were never offered an upload, so the step that decided
251
+ * ("waiting for a later tick", "over the wire cap") names it instead of the old
252
+ * "not uploadable" label, which stopped being true with this ticket.
253
+ */
254
+ export function applyOutsideRootHolds(rows, holds) {
255
+ if (!holds || holds.size === 0)
256
+ return rows;
257
+ return rows.map((row) => {
258
+ const hold = holds.get(outsideRootKey(row.source, row.codex_session_id));
259
+ if (!hold || row.raw_evidence_pointer_id || row.upload_state !== "not_uploaded")
260
+ return row;
261
+ if (row.upload_reason && !row.upload_reason.startsWith("not_uploadable_attribution_state"))
262
+ return row;
263
+ return { ...row, upload_reason: hold };
264
+ });
265
+ }
266
+ /**
267
+ * The outside-root pass gets its own allowance, so a busy in-root tick cannot
268
+ * starve it and it cannot take the in-root room. The disk gate and the image
269
+ * description session stay shared (they live on the same budget object).
270
+ */
271
+ export function giveOutsideRootsTheirOwnAllowance(budget) {
272
+ budget.remainingBytes = OUTSIDE_ROOT_BYTES_PER_TICK;
273
+ budget.remainingObjects = OUTSIDE_ROOT_OBJECTS_PER_TICK;
274
+ }
275
+ /** The sync target list with the outside-roots workspace moved last, so in-root evidence goes first. */
276
+ export function outsideRootsLast(worktrees) {
277
+ return [
278
+ ...worktrees.filter((worktree) => !isOutsideRootsWorkspace(worktree)),
279
+ ...worktrees.filter((worktree) => isOutsideRootsWorkspace(worktree)),
280
+ ];
281
+ }
@@ -23,6 +23,7 @@ import { CLAUDE_CURSOR_FILENAME, emptyRawEvidenceCursorState, readRawEvidenceCur
23
23
  import { normalizeCollectionRoots } from "../root-normalization.js";
24
24
  import { readLocalUploadSpoolState, } from "../spool/local-spool.js";
25
25
  import { liveSyncCursorEntryRequiresRetry } from "./session-sync-attribution.js";
26
+ import { outsideRootCatchUpIsPending } from "./session-sync-outside-roots.js";
26
27
  /**
27
28
  * The bound applied when a retry is pending but no cursor entry names the
28
29
  * stuck file (e.g. a legacy spooled upload recorded before per-source retry
@@ -46,7 +47,7 @@ export async function planSyncFromLocalState(options) {
46
47
  const config = await readLocalCollectorConfig(options.paths).catch(() => null);
47
48
  const claudeEnabled = config?.collect_claude_jsonl !== false;
48
49
  const collectionRoots = normalizeCollectionRoots(options.approvedCollectionRoots ?? config?.default_repo_paths ?? []);
49
- const [codexCursorBefore, claudeCursorBefore, uploadSpool] = await Promise.all([
50
+ const [codexCursorBefore, claudeCursorBefore, uploadSpool, outsideRootCatchUpPending] = await Promise.all([
50
51
  readRawEvidenceCursor(options.paths).catch(() => emptyRawEvidenceCursorState()),
51
52
  claudeEnabled
52
53
  ? readRawEvidenceCursor(options.paths, {
@@ -54,6 +55,7 @@ export async function planSyncFromLocalState(options) {
54
55
  }).catch(() => emptyRawEvidenceCursorState())
55
56
  : Promise.resolve(emptyRawEvidenceCursorState()),
56
57
  readLocalUploadSpoolState(options.paths).catch(() => null),
58
+ outsideRootCatchUpIsPending(options.paths),
57
59
  ]);
58
60
  // A spooled upload from a CLI old enough not to have recorded its source
59
61
  // could have come from either one, so both sources own it until it clears.
@@ -73,6 +75,7 @@ export async function planSyncFromLocalState(options) {
73
75
  sourceHasPendingRetries("claude_code", uploadSpool, legacyRetryPending, claudeSourceRetryPending, claudeCursorBefore),
74
76
  codexRetrySinceMinutes: boundedRetrySinceMinutes(codexCursorBefore, options.now),
75
77
  claudeRetrySinceMinutes: boundedRetrySinceMinutes(claudeCursorBefore, options.now),
78
+ outsideRootCatchUpPending,
76
79
  };
77
80
  }
78
81
  /**
@@ -85,7 +88,7 @@ export async function planSyncFromLocalState(options) {
85
88
  export function boundedRetrySinceMinutes(cursor, now) {
86
89
  let oldestUndurableMtimeMs = null;
87
90
  for (const entry of Object.values(cursor.sessions)) {
88
- if (!liveSyncCursorEntryRequiresRetry(entry))
91
+ if (!liveSyncCursorEntryRequiresRetry(entry, now))
89
92
  continue;
90
93
  if (oldestUndurableMtimeMs === null ||
91
94
  entry.file_mtime_ms < oldestUndurableMtimeMs) {
@@ -14,7 +14,10 @@ import { scanAndAttributeClaudeSessions, } from "../adapters/claude-attribution.
14
14
  import { CLAUDE_CURSOR_FILENAME } from "../cursors/raw-evidence-cursor.js";
15
15
  import { clearSourceRetryFailure, recordSourceRetryFailure, } from "../spool/local-spool.js";
16
16
  import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
17
+ import { widenForCatchUp } from "./session-sync-outside-roots.js";
17
18
  const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
19
+ /** The Claude scan's own default window when nothing widens it: the last 24 hours. */
20
+ const CLAUDE_DEFAULT_WINDOW_MINUTES = 24 * 60;
18
21
  /**
19
22
  * Scan and attribute both session sources, then settle each source's scan-retry
20
23
  * bookkeeping against what the scan actually found.
@@ -31,6 +34,7 @@ export async function scanAndAttributeBothSources(options) {
31
34
  collectionRoots: plan.collectionRoots,
32
35
  retryPending: plan.codexRetryPending,
33
36
  retrySinceMinutes: plan.codexRetrySinceMinutes,
37
+ outsideRootCatchUpPending: plan.outsideRootCatchUpPending,
34
38
  });
35
39
  // First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
36
40
  // days so the first sync captures retroactive history instead of only 24h.
@@ -45,6 +49,7 @@ export async function scanAndAttributeBothSources(options) {
45
49
  retryPending: plan.claudeRetryPending,
46
50
  retrySinceMinutes: plan.claudeRetrySinceMinutes,
47
51
  firstRunBackfill,
52
+ outsideRootCatchUpPending: plan.outsideRootCatchUpPending,
48
53
  });
49
54
  await reconcileSourceScanRetry({
50
55
  paths: options.paths,
@@ -72,9 +77,9 @@ export async function scanAndAttributeBothSources(options) {
72
77
  * is always scanned, unlike Claude which can be disabled entirely.
73
78
  */
74
79
  async function scanCodexSessionsForSync(options) {
75
- const sinceMinutes = options.retryPending
80
+ const sinceMinutes = widenForCatchUp(options.retryPending
76
81
  ? options.retrySinceMinutes
77
- : CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES;
82
+ : CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, options.outsideRootCatchUpPending, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES);
78
83
  let scan = await scanAndAttributeCodexSessions({
79
84
  sessionsDirs: defaultCodexSessionDirs(options.homeDir),
80
85
  worktrees: options.worktrees,
@@ -107,11 +112,11 @@ async function scanCodexSessionsForSync(options) {
107
112
  async function scanClaudeSessionsForSync(options) {
108
113
  if (!options.claudeEnabled)
109
114
  return emptyClaudeScan();
110
- const sinceMinutes = options.retryPending
115
+ const sinceMinutes = widenForCatchUp(options.retryPending
111
116
  ? options.retrySinceMinutes
112
117
  : options.firstRunBackfill
113
118
  ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
114
- : undefined;
119
+ : undefined, options.outsideRootCatchUpPending, CLAUDE_DEFAULT_WINDOW_MINUTES);
115
120
  const projectsDir = path.join(options.homeDir, ".claude", "projects");
116
121
  let scan = await scanAndAttributeClaudeSessions({
117
122
  projectsDir,
@@ -10,6 +10,8 @@ import { startLocalWorkContext, startLocalWorkContextForAttributedTarget, } from
10
10
  import { LocalUploadBlockedError, syncLocalAmbientEnvelope, } from "../upload.js";
11
11
  import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET, } from "../adapters/raw-evidence.js";
12
12
  import { liveSyncTargetKey, liveSyncTargetWorktrees, matchesLiveSyncWorktree, } from "./session-sync-attribution.js";
13
+ import { isOutsideRootsWorkspace } from "./backfill-outside-roots.js";
14
+ import { giveOutsideRootsTheirOwnAllowance, outsideRootsLast, } from "./session-sync-outside-roots.js";
13
15
  import { runYieldingAtBoundaries } from "./sync-yield.js";
14
16
  const CLAUDE_DAMP_GROWTH_BYTES = 256 * 1024;
15
17
  const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
@@ -22,10 +24,12 @@ const CLAUDE_DAMP_MAX_AGE_MS = 6 * 60 * 60 * 1000;
22
24
  */
23
25
  export async function syncEveryTargetWorktree(options) {
24
26
  const { run, scan } = options;
25
- const syncWorktrees = liveSyncTargetWorktrees(run.worktrees, [
27
+ // BLI-17916: the outside-roots workspace goes last, so in-root evidence is
28
+ // never held up behind it.
29
+ const syncWorktrees = outsideRootsLast(liveSyncTargetWorktrees(run.worktrees, [
26
30
  ...scan.codexAttribution.results,
27
31
  ...scan.claudeAttribution.results,
28
- ]);
32
+ ]));
29
33
  const discoveredWorktreeKeys = new Set(run.worktrees.map(liveSyncTargetKey));
30
34
  // sessionId -> prior durable pointer for damped sessions (drives the
31
35
  // growth_damped count + the skip_main decision).
@@ -40,21 +44,28 @@ export async function syncEveryTargetWorktree(options) {
40
44
  items: syncWorktrees,
41
45
  shouldYield: run.yieldCheck,
42
46
  describe: (outcome) => ({ status: outcome.sync.status }),
43
- runOne: (worktree) => syncOneWorktree({
44
- run,
45
- now: options.now,
46
- plan: options.plan,
47
- scan,
48
- worktree,
49
- syncWorktrees,
50
- rawEvidenceBudget,
51
- // A target git discovery never produced is one the fallback attribution
52
- // synthesized, and it syncs through its own attributed work context.
53
- isDiscoveredWorktree: discoveredWorktreeKeys.has(liveSyncTargetKey(worktree)),
54
- recordDampedClaudeSession: (sessionId, priorPointer) => {
55
- dampedClaudePointers.set(sessionId, priorPointer);
56
- },
57
- }),
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
+ },
58
69
  });
59
70
  const outcomes = loop.results;
60
71
  const everyWorktreeUploaded = outcomes.every((outcome) => outcome.sync.status === "uploaded");
@@ -87,12 +98,14 @@ function claudeDurablePointersFromCursor(claudeCursorBefore) {
87
98
  */
88
99
  async function syncOneWorktree(options) {
89
100
  const { run, worktree } = options;
90
- const attributedSyntheticTarget = options.isDiscoveredWorktree
91
- ? null
92
- : worktree;
93
- const ensureContext = () => startWorktreeContext(options, attributedSyntheticTarget);
101
+ // The outside-roots workspace starts its work context the way `cockpit
102
+ // backfill` does (a plain folder, no attributed-target proof): it is a
103
+ // transport folder the collector owns, not a repo anyone attributed.
104
+ const outsideRoots = isOutsideRootsWorkspace(worktree);
105
+ const attributedSyntheticTarget = options.isDiscoveredWorktree || outsideRoots ? null : worktree;
106
+ const ensureContext = () => startWorktreeContext(options, attributedSyntheticTarget, outsideRoots);
94
107
  let context = null;
95
- if (run.startContexts || attributedSyntheticTarget) {
108
+ if (run.startContexts || attributedSyntheticTarget || outsideRoots) {
96
109
  context = await ensureContext();
97
110
  }
98
111
  const syncOptions = ambientEnvelopeForWorktree(options);
@@ -112,15 +125,21 @@ async function syncOneWorktree(options) {
112
125
  }
113
126
  return { worktree, context, sync };
114
127
  }
115
- function startWorktreeContext(options, attributedSyntheticTarget) {
128
+ function startWorktreeContext(options, attributedSyntheticTarget, outsideRoots) {
116
129
  const { run, worktree } = options;
117
130
  const contextOptions = {
118
131
  homeDir: run.homeDir,
119
132
  repoRoot: worktree.repo_root,
120
- activeTicketId: run.activeTicketId,
133
+ // The caller's ticket and branch belong to the caller's repo, never to the
134
+ // outside-roots workspace.
135
+ ...(outsideRoots ? {} : { activeTicketId: run.activeTicketId }),
121
136
  operatorId: run.operatorId,
122
137
  sessionId: run.sessionId,
123
- ...(attributedSyntheticTarget ? {} : { branch: run.branch }),
138
+ ...(outsideRoots
139
+ ? { branch: worktree.branch, leaveActiveContext: true }
140
+ : attributedSyntheticTarget
141
+ ? {}
142
+ : { branch: run.branch }),
124
143
  };
125
144
  return attributedSyntheticTarget
126
145
  ? startLocalWorkContextForAttributedTarget(contextOptions, attributedSyntheticTarget)
@@ -43,7 +43,8 @@ import { getCollectorRuntimePaths } from "../local-state.js";
43
43
  import { buildAgentSessionReport, buildAgentSessionSummary, } from "./agent-session-report.js";
44
44
  import { collectSessionCommits, withSessionCommits } from "../session-commits.js";
45
45
  import { planSyncFromLocalState } from "./session-sync-plan.js";
46
- import { scanAndAttributeBothSources } from "./session-sync-scan.js";
46
+ import { scanAndAttributeBothSources, sourceScanFailureReason, } from "./session-sync-scan.js";
47
+ import { admitOutsideRootSessions, applyOutsideRootHolds, markOutsideRootCatchUpDone, } from "./session-sync-outside-roots.js";
47
48
  import { syncEveryTargetWorktree } from "./session-sync-upload.js";
48
49
  import { reportSessionsAndAdvanceCursors } from "./session-sync-record.js";
49
50
  import { decideSyncHealth } from "./session-sync-health.js";
@@ -55,6 +56,11 @@ export { sourceScanFailureReason, sourceScanRetryReason, } from "./session-sync-
55
56
  export { SYNC_FAILED_WITHOUT_REASON } from "./session-sync-failures.js";
56
57
  export { nothingInRootCount } from "./session-sync-health.js";
57
58
  export { isDeliveryHoldReason, summarizeDeliveryHold, } from "./session-sync-hold.js";
59
+ /** Did either session store fail to read this tick? A failed read means the scan saw less than exists. */
60
+ function scanHadReadFailure(scan) {
61
+ return (sourceScanFailureReason("codex", scan.codexAttribution) !== null ||
62
+ sourceScanFailureReason("claude_code", scan.claudeAttribution) !== null);
63
+ }
58
64
  /**
59
65
  * Shared dual-source sync orchestration for single-repo and parent-folder
60
66
  * modes. Codex AND Claude Code sessions are scanned and attributed once across
@@ -62,7 +68,8 @@ export { isDeliveryHoldReason, summarizeDeliveryHold, } from "./session-sync-hol
62
68
  * fallback-attributed transcripts (Codex + Claude main + sidecars). The
63
69
  * ambiguous/unattributed/skipped sessions inside an approved root retain those
64
70
  * labels while syncing once through that root's synthetic folder workspace.
65
- * Outside-root sessions remain diagnostic-only. The session row's upload state
71
+ * Outside-root sessions keep their labels and upload through the collector's
72
+ * own outside-roots workspace, a few per tick (BLI-17916). The session row's upload state
66
73
  * maps ONLY from the main-file outcome (D3); sidecar outcomes aggregate into
67
74
  * CLI counts.
68
75
  *
@@ -80,13 +87,21 @@ export async function runAttributedWorktreeSync(options) {
80
87
  now,
81
88
  approvedCollectionRoots: options.collectionRoots,
82
89
  });
83
- const scan = await scanAndAttributeBothSources({
90
+ const scanned = await scanAndAttributeBothSources({
84
91
  paths,
85
92
  homeDir,
86
93
  now,
87
94
  worktrees: options.worktrees,
88
95
  plan,
89
96
  });
97
+ // BLI-17916: roots are a label, never a gate. Outside-root sessions ride this
98
+ // tick's upload under the synthetic workspace, a few per tick, newest first.
99
+ const scan = await admitOutsideRootSessions({
100
+ homeDir: options.homeDir,
101
+ plan,
102
+ scan: scanned,
103
+ now,
104
+ });
90
105
  const worktreePass = await syncEveryTargetWorktree({
91
106
  run: options,
92
107
  now,
@@ -102,18 +117,20 @@ export async function runAttributedWorktreeSync(options) {
102
117
  }
103
118
  console.error("[session-sync] bindings read", JSON.stringify({ bound_worktrees: ticketByWorktree.size, reason: "active_work_context" }));
104
119
  // BLI-13028: which commits each session's working tree created while it ran.
120
+ // Read from the scan as it was BEFORE the outside-root step: the synthetic
121
+ // workspace is not a git tree, and no commit belongs to it.
105
122
  const commitsBySession = await collectSessionCommits([
106
- ...scan.codexAttribution.results.map((result) => ({ key: `codex:${result.codex_session_id}`, result })),
107
- ...scan.claudeAttribution.results.map((result) => ({ key: `claude_code:${result.claude_session_id}`, result })),
123
+ ...scanned.codexAttribution.results.map((result) => ({ key: `codex:${result.codex_session_id}`, result })),
124
+ ...scanned.claudeAttribution.results.map((result) => ({ key: `claude_code:${result.claude_session_id}`, result })),
108
125
  ]);
109
- const sessions = withSessionCommits(buildAgentSessionReport({
126
+ const sessions = applyOutsideRootHolds(withSessionCommits(buildAgentSessionReport({
110
127
  ticketByWorktree,
111
128
  codexResults: scan.codexAttribution.results,
112
129
  claudeResults: scan.claudeAttribution.results,
113
130
  outcomes: worktreePass.outcomes,
114
131
  now,
115
132
  claudePriorDurablePointers: worktreePass.claudePriorDurablePointers,
116
- }), commitsBySession);
133
+ }), commitsBySession), scan.outsideRoots?.holds);
117
134
  // BLI-4341: every session should arrive counted. Say how many did, and say
118
135
  // it on the success path too: a silent drop from "all" to "some" is exactly
119
136
  // the kind of decay nobody notices.
@@ -133,6 +150,11 @@ export async function runAttributedWorktreeSync(options) {
133
150
  sessions,
134
151
  outcomes: worktreePass.outcomes,
135
152
  });
153
+ // The one-time 30-day look-back is done once a tick read both stores without
154
+ // a failure and recorded what it saw in the cursors.
155
+ if (plan.outsideRootCatchUpPending && !scanHadReadFailure(scan)) {
156
+ await markOutsideRootCatchUpDone(paths, { now, sessions_seen: sessions.length });
157
+ }
136
158
  const summary = buildAgentSessionSummary({
137
159
  codexAttribution: scan.codexAttribution,
138
160
  claudeAttribution: scan.claudeAttribution,
@@ -63,7 +63,7 @@ export async function reportNoWorktreeSync(command, io, run, dedup) {
63
63
  if (run.notice?.startsWith("nothing_in_root")) {
64
64
  // Says out loud what the receipt now says to the dashboard: the sessions
65
65
  // this machine ran were all outside the folders it is allowed to look at.
66
- writeLine(io.stdout, `Every session seen this run was outside your approved folders (${run.notice}). Nothing was collected, and nothing is broken.`);
66
+ writeLine(io.stdout, `Every session seen this run was outside your approved folders (${run.notice}). No transcript was uploaded for them this run (the log line "outside-root raw upload" says why), and nothing is broken.`);
67
67
  }
68
68
  writeAgentSessionSummary(io, run.summary);
69
69
  if (gc && !gc.skipped)
@@ -64,7 +64,9 @@ async function writeLocalWorkContext(options, attributedIdentity) {
64
64
  existingContext,
65
65
  });
66
66
  await writeJsonFile(workContextFile(paths, identity.worktree_fingerprint), context);
67
- await writeJsonFile(paths.active_work_context_file, context);
67
+ if (!options.leaveActiveContext) {
68
+ await writeJsonFile(paths.active_work_context_file, context);
69
+ }
68
70
  return context;
69
71
  }
70
72
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.210",
3
+ "version": "0.2.211",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {