@bli-cockpit/cli 0.2.243 → 0.2.245

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 (39) hide show
  1. package/dist/adapters/raw-evidence-budget-admission.js +63 -0
  2. package/dist/adapters/raw-evidence-collection-state.js +6 -61
  3. package/dist/commands/issue-contracts.js +65 -4
  4. package/dist/commands/issue-list-page.js +34 -6
  5. package/dist/commands/issue-page-args.js +64 -0
  6. package/dist/commands/issue-relations.js +3 -2
  7. package/dist/commands/issue-show.js +13 -2
  8. package/dist/commands/issue-versions.js +5 -8
  9. package/dist/commands/issue-write.js +71 -16
  10. package/dist/commands/issue.js +22 -3
  11. package/dist/commands/local-args-collector-sources.js +2 -1
  12. package/dist/commands/local-args-tower-milestone.js +120 -0
  13. package/dist/commands/local-args-tower-work.js +44 -51
  14. package/dist/commands/local-args-tower.js +1 -0
  15. package/dist/commands/local-args.js +2 -0
  16. package/dist/commands/local-command-shapes-milestone.js +1 -0
  17. package/dist/commands/local-help-commands-tower-ops.js +4 -4
  18. package/dist/commands/local-help-commands-tower.js +6 -3
  19. package/dist/commands/local-help-commands.js +3 -1
  20. package/dist/commands/local-help-milestone.js +23 -0
  21. package/dist/commands/local-help-project.js +1 -0
  22. package/dist/commands/local-help.js +3 -1
  23. package/dist/commands/local.js +2 -0
  24. package/dist/commands/manager-hints.js +4 -2
  25. package/dist/commands/manager-limits.json +3 -1
  26. package/dist/commands/manager-read-reasons.js +1 -0
  27. package/dist/commands/manager-render-home.js +13 -5
  28. package/dist/commands/manager-render.js +3 -0
  29. package/dist/commands/manager.js +3 -1
  30. package/dist/commands/milestone.js +157 -0
  31. package/dist/commands/project-args.js +78 -2
  32. package/dist/commands/project.js +52 -0
  33. package/dist/commands/public-root.js +1 -1
  34. package/dist/commands/session-sync-outside-roots.js +72 -14
  35. package/dist/commands/sources-check.js +63 -2
  36. package/dist/commands/sources-discovery.js +1 -1
  37. package/dist/commands/sources-reconciliation.js +87 -0
  38. package/dist/evidence-transcripts-client.js +1 -0
  39. package/package.json +4 -4
@@ -12,6 +12,22 @@ import { pageFieldsOf, pageFooter } from "./issue-list-page.js";
12
12
  import { readBody } from "./issue-contracts.js";
13
13
  const TAG = "[project cli]";
14
14
  const READ_DEADLINE_MS = 30_000;
15
+ const PRIORITY_WORDS = ["none", "urgent", "high", "medium", "low"];
16
+ const person = (p) => p.name && p.email ? `${p.name} <${p.email}>` : (p.email ?? p.name ?? p.user_id);
17
+ /** "2026-06-01 (month)" for a period, the bare day for an exact date. */
18
+ const dated = (day, resolution) => day ? `${day}${resolution ? ` (${resolution})` : ""}` : "none";
19
+ /** The planning block `project show` prints (BLI-22360). */
20
+ export function planningLines(body) {
21
+ const project = body.project;
22
+ if (!project)
23
+ return [];
24
+ return [
25
+ `status ${project.status ?? "none"} priority ${PRIORITY_WORDS[project.priority ?? 0] ?? project.priority} start ${dated(project.start_date, project.start_date_resolution)} target ${dated(project.target_date, project.target_date_resolution)}`,
26
+ `lead ${body.lead ? person(body.lead) : "none"}`,
27
+ `members ${(body.members ?? []).length === 0 ? "none" : (body.members ?? []).map(person).join(", ")}`,
28
+ ...(body.milestones ?? []).map((m) => `milestone ${m.name}${m.archived_at ? " (archived)" : ""} target ${m.target_date ?? "none"} ${m.progress?.summary ?? ""} ${m.id}`),
29
+ ];
30
+ }
15
31
  export async function runProject(command, io) {
16
32
  const door = await openAgentDoor("project", command, io);
17
33
  if (command.action !== "list")
@@ -65,6 +81,10 @@ async function runProjectVerb(command, door) {
65
81
  writeLine(out, `id ${project?.id ?? ""} updated ${project?.updated_at ?? ""}`);
66
82
  if (project?.description)
67
83
  writeLine(out, project.description);
84
+ for (const line of planningLines(body))
85
+ writeLine(out, line);
86
+ if (project?.content)
87
+ writeLine(out, `\n${project.content}\n`);
68
88
  for (const issue of body.issues ?? [])
69
89
  writeLine(out, `${issue.identifier ?? ""} ${issue.state ?? ""} ${issue.title ?? ""}`);
70
90
  const footer = pageFooter(body, "tickets");
@@ -92,6 +112,38 @@ async function runProjectVerb(command, door) {
92
112
  else if (command.description !== undefined) {
93
113
  payload["description"] = command.description;
94
114
  }
115
+ const planning = command.planning;
116
+ if (planning) {
117
+ if (planning.lead !== undefined)
118
+ payload["lead"] = planning.lead;
119
+ if (planning.members !== undefined)
120
+ payload["members"] = planning.members;
121
+ if (planning.addMembers !== undefined)
122
+ payload["add_members"] = planning.addMembers;
123
+ if (planning.removeMembers !== undefined)
124
+ payload["remove_members"] = planning.removeMembers;
125
+ if (planning.status !== undefined)
126
+ payload["status"] = planning.status;
127
+ if (planning.priority !== undefined)
128
+ payload["priority"] = planning.priority;
129
+ if (planning.startDate !== undefined)
130
+ payload["start_date"] = planning.startDate;
131
+ if (planning.targetDate !== undefined)
132
+ payload["target_date"] = planning.targetDate;
133
+ if (planning.startDateResolution !== undefined)
134
+ payload["start_date_resolution"] = planning.startDateResolution;
135
+ if (planning.targetDateResolution !== undefined)
136
+ payload["target_date_resolution"] = planning.targetDateResolution;
137
+ if (planning.contentFilePath) {
138
+ const read = await readBody({ filePath: planning.contentFilePath }, door.io);
139
+ if (!read.ok)
140
+ return failAgentDoor(door, TAG, read.reason, read.detail);
141
+ payload["content"] = read.text;
142
+ }
143
+ else if (planning.content !== undefined) {
144
+ payload["content"] = planning.content;
145
+ }
146
+ }
95
147
  }
96
148
  const path = command.action === "create" ? base : command.action === "update" ? ref : `${ref}/${command.action}`;
97
149
  const answer = await askAgentDoor(door, {
@@ -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.243");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.245");
25
25
  return 0;
26
26
  }
27
27
 
@@ -22,7 +22,7 @@
22
22
  *
23
23
  * - A session already durable with unchanged content rides for free. It
24
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
25
+ * - A session that is new, changed or not yet uploaded is "owed". The longest-waiting
26
26
  * ones ride this tick up to a per-tick session cap. The rest wait, and their
27
27
  * row says `deferred_object_budget`, the existing "next tick has room"
28
28
  * label, instead of the old "not uploadable" one.
@@ -34,9 +34,9 @@
34
34
  *
35
35
  * The cap, the byte allowance and the object allowance are per tick, so a
36
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.
37
+ * time or the machine's memory. A persisted retry order rotates offered
38
+ * sessions behind waiting ones,
39
+ * including when their source keeps growing.
40
40
  *
41
41
  * A new sibling must also join `scripts/build-public-cli.mjs` `runtimeFiles`.
42
42
  */
@@ -45,12 +45,13 @@ import path from "node:path";
45
45
  import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
46
46
  import { isRedactionStubObjectKey } from "../cursors/raw-evidence-stub-entry.js";
47
47
  import { describeError } from "../health-detail.js";
48
- import { writeJsonFile } from "../local-state-files.js";
48
+ import { getCollectorRuntimePaths } from "../local-state.js";
49
+ import { isMissingFileError, writeJsonFileAtomic, writeJsonFile } from "../local-state-files.js";
49
50
  import { rootScopeForReason } from "./agent-session-root-scope.js";
50
51
  import { ensureOutsideRootsWorkspace, isOutsideRootsWorkspace, outsideRootsWorkspace, } from "./backfill-outside-roots.js";
51
52
  /** Most outside-root sessions that upload bytes in one tick. 4 ticks an hour, so up to 3,840 a day. */
52
53
  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
+ /** Normal outside-root byte allowance; a fresh allowance can admit one wire-bounded file. */
54
55
  export const OUTSIDE_ROOT_BYTES_PER_TICK = 256 * 1024 * 1024;
55
56
  /** Files (a Claude main plus its sidecars count one each) an outside-root pass may collect in one tick. */
56
57
  export const OUTSIDE_ROOT_OBJECTS_PER_TICK = 120;
@@ -165,7 +166,27 @@ export async function admitOutsideRootSessions(options) {
165
166
  if (sightings.length === 0) {
166
167
  return { ...scan, outsideRoots: null };
167
168
  }
168
- const { admitted, holds, counts, budget } = selectOutsideRootSessions(sightings, plan);
169
+ const queuePath = path.join(getCollectorRuntimePaths(options.homeDir).cursors_dir, "outside-root-admission.json");
170
+ const turns = await readAdmissionTurns(queuePath);
171
+ const { admitted, holds, counts, budget } = selectOutsideRootSessions(sightings, plan, turns);
172
+ // Offered work rotates even on a later disk/delivery refusal. New arrivals
173
+ // join behind existing waiters; growth never resets a session's place.
174
+ const offered = new Set(admitted.map((sighting) => outsideRootKey(sighting.source, sighting.sessionId)));
175
+ const next = Object.values(turns).reduce((max, turn) => Math.max(max, turn), 0) + 1;
176
+ const pending = {};
177
+ for (const sighting of sightings) {
178
+ const key = outsideRootKey(sighting.source, sighting.sessionId);
179
+ if (isDurableAndUnchanged(sighting.result, (sighting.source === "codex" ? plan.codexCursorBefore : plan.claudeCursorBefore).sessions[sighting.sessionId]) ||
180
+ sighting.result.byte_size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES)
181
+ continue;
182
+ pending[key] = offered.has(key) ? next + 1 : turns[key] ?? next;
183
+ }
184
+ try {
185
+ await writeJsonFileAtomic(queuePath, pending);
186
+ }
187
+ catch (error) {
188
+ console.error("[session-sync] outside-root admission order not saved", JSON.stringify({ reason: "admission_order_write_failed", ...describeError(error) }));
189
+ }
169
190
  if (admitted.length === 0) {
170
191
  console.error("[session-sync] outside-root raw upload", JSON.stringify({ reason: "nothing_to_upload_outside_roots", ...counts, ...budget }));
171
192
  return { ...scan, outsideRoots: { workspace: null, holds, counts } };
@@ -203,7 +224,24 @@ export async function admitOutsideRootSessions(options) {
203
224
  console.error("[session-sync] outside-root raw upload", JSON.stringify({ reason: "outside_root_sessions_admitted", ...counts, ...budget }));
204
225
  return labelled;
205
226
  }
206
- function selectOutsideRootSessions(sightings, plan) {
227
+ /** Session ids and queue turns only; no source paths or transcript content. */
228
+ async function readAdmissionTurns(filePath) {
229
+ try {
230
+ const value = JSON.parse(await fs.readFile(filePath, "utf8"));
231
+ if (!value || typeof value !== "object" || Array.isArray(value) ||
232
+ Object.entries(value).some(([key, turn]) => !/^(codex|claude_code):/.test(key) || !Number.isSafeInteger(turn) || Number(turn) < 0)) {
233
+ throw new Error("invalid admission order");
234
+ }
235
+ return value;
236
+ }
237
+ catch (error) {
238
+ if (!isMissingFileError(error)) {
239
+ console.error("[session-sync] outside-root admission order unavailable", JSON.stringify({ reason: "admission_order_read_failed", ...describeError(error) }));
240
+ }
241
+ return {};
242
+ }
243
+ }
244
+ function selectOutsideRootSessions(sightings, plan, turns) {
207
245
  const cursorFor = (source) => source === "codex" ? plan.codexCursorBefore : plan.claudeCursorBefore;
208
246
  const free = [];
209
247
  const owed = [];
@@ -222,12 +260,28 @@ function selectOutsideRootSessions(sightings, plan) {
222
260
  owed.push(sighting);
223
261
  }
224
262
  }
225
- // Newest first, with the session id as a tie-break so two ticks over the same
226
- // files pick the same sessions.
227
- owed.sort((left, right) => right.result.session_file_mtime_ms - left.result.session_file_mtime_ms ||
228
- left.sessionId.localeCompare(right.sessionId));
229
- const riding = owed.slice(0, OUTSIDE_ROOT_SESSIONS_PER_TICK);
230
- const waiting = owed.slice(OUTSIDE_ROOT_SESSIONS_PER_TICK);
263
+ const newTurn = Object.values(turns).reduce((max, turn) => Math.max(max, turn), 0) + 1;
264
+ owed.sort((left, right) => (turns[outsideRootKey(left.source, left.sessionId)] ?? newTurn) -
265
+ (turns[outsideRootKey(right.source, right.sessionId)] ?? newTurn) ||
266
+ left.result.session_file_mtime_ms - right.result.session_file_mtime_ms ||
267
+ outsideRootKey(left.source, left.sessionId).localeCompare(outsideRootKey(right.source, right.sessionId)));
268
+ // A main larger than the normal allowance gets a turn without other owed sessions. Otherwise a
269
+ // smaller main (or the other provider's reader, which runs first) could fill
270
+ // staging before the existing empty-staging exception ever sees it.
271
+ const riding = [];
272
+ let bytes = 0;
273
+ for (const sighting of owed) {
274
+ if (riding.length >= OUTSIDE_ROOT_SESSIONS_PER_TICK)
275
+ break;
276
+ if (bytes + sighting.result.byte_size > OUTSIDE_ROOT_BYTES_PER_TICK) {
277
+ if (riding.length === 0)
278
+ riding.push(sighting);
279
+ break;
280
+ }
281
+ riding.push(sighting);
282
+ bytes += sighting.result.byte_size;
283
+ }
284
+ const waiting = owed.slice(riding.length);
231
285
  for (const sighting of waiting) {
232
286
  holds.set(outsideRootKey(sighting.source, sighting.sessionId), OUTSIDE_ROOT_WAITING_REASON);
233
287
  }
@@ -273,6 +327,10 @@ export function applyOutsideRootHolds(rows, holds) {
273
327
  * description session stay shared (they live on the same budget object).
274
328
  */
275
329
  export function giveOutsideRootsTheirOwnAllowance(budget) {
330
+ budget.singleFileByteAllowance = {
331
+ normalBytes: OUTSIDE_ROOT_BYTES_PER_TICK,
332
+ maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
333
+ };
276
334
  budget.remainingBytes = OUTSIDE_ROOT_BYTES_PER_TICK;
277
335
  budget.remainingObjects = OUTSIDE_ROOT_OBJECTS_PER_TICK;
278
336
  }
@@ -1,3 +1,25 @@
1
+ /**
2
+ * `cockpit sources check [--older-than <days>] [--json]` — does Tower hold
3
+ * each agent transcript on this machine? (BLI-11122)
4
+ *
5
+ * On 2026-09-30 an agent freeing disk ran `find ~/.codex/... -mtime +60
6
+ * -delete` on the premise that the files were already in Tower; 349 of 1,491
7
+ * had no copy. This door is the machine answer that was missing: for every
8
+ * local Codex and Claude transcript it asks Tower which current objects it
9
+ * holds and answers `stored_full`, `stored_slim` (BLI-17273: Tower holds only the
10
+ * slim form, keep the file), `stored_partial` or `not_stored`, with the reason
11
+ * (`sources-verdict.ts`), judged against THIS machine's image mode.
12
+ *
13
+ * READ-ONLY, and nothing else: Tower never deletes a person's raw transcripts.
14
+ * Deleting one is the person's own act, outside Tower. This command reads the
15
+ * files and the server's list; it never writes, moves or deletes a file under
16
+ * `~/.codex` or `~/.claude`. `docs/runbooks/freeing-disk-on-a-collector-machine.md`.
17
+ *
18
+ * A server that cannot be asked is a failure with its reason (exit 1), never
19
+ * "not stored". A list cut short (`capped`, `failed_partial`) still answers,
20
+ * marked incomplete: a missing page can only understate what Tower holds.
21
+ */
22
+ import { fetchSourceInventory, reconcileSource } from "./sources-reconciliation.js";
1
23
  import { writeLine } from "./cli-io.js";
2
24
  import { readDescribedImageKeys } from "../image-description-state.js";
3
25
  import { resolveImageHandlingMode } from "../image-mode-state.js";
@@ -12,6 +34,10 @@ export async function runSourcesCheck(command, io, now = new Date()) {
12
34
  const startedAt = Date.now();
13
35
  const paths = getCollectorRuntimePaths(command.homeDir);
14
36
  const config = await readLocalCollectorConfig(paths).catch(() => null);
37
+ if (command.reconcile && !config) {
38
+ console.error("[collector sources] reconciliation not run", JSON.stringify({ reason: "local_config_unavailable_opt_out_unknown" }));
39
+ return fail(io, command, "local_config_unavailable_opt_out_unknown", "Local collection preferences could not be read; no transcript store was scanned.");
40
+ }
15
41
  const dashboardUrl = command.dashboardUrl ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
16
42
  const session = await readLocalCollectorSessionFile(paths).catch(() => null);
17
43
  if (!session || session.session_state !== "valid" || typeof session.device_token !== "string" || !session.device_token) {
@@ -25,7 +51,9 @@ export async function runSourcesCheck(command, io, now = new Date()) {
25
51
  }
26
52
  const homeDir = command.homeDir ?? paths.home_dir;
27
53
  const olderThanMs = command.olderThanDays === null ? null : now.getTime() - command.olderThanDays * DAY_MS;
28
- const local = await discoverLocalTranscripts({ homeDir, olderThanMs });
54
+ const optOutAll = command.reconcile === true && config?.raw_evidence_upload === "disabled";
55
+ const optOutClaude = command.reconcile === true && config?.collect_claude_jsonl === false;
56
+ const local = await discoverLocalTranscripts({ homeDir, olderThanMs, skipAll: optOutAll, skipClaude: optOutClaude });
29
57
  const cursors = {
30
58
  codex: await readRawEvidenceCursor(paths),
31
59
  claude_code: await readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME, sessionsOnly: true }),
@@ -38,6 +66,36 @@ export async function runSourcesCheck(command, io, now = new Date()) {
38
66
  console.error("[collector sources] judging against this machine's image mode", JSON.stringify({ reason: `image_mode_from_${imageMode.source}`, mode: imageMode.mode, slim_aware: imageMode.slimAware }));
39
67
  const report = emptyReport(command, remote, local.newer_count, local.file_read_failed + local.directory_read_failed);
40
68
  await judgeLocalTranscripts(local.files, remote, cursors, imageMode, describedKeys, report);
69
+ if (command.reconcile) {
70
+ const inventory = await fetchSourceInventory({ dashboardUrl, deviceToken: session.device_token, fetchImpl: io.fetch });
71
+ const { rows: _rows, ...inventoryCounts } = inventory;
72
+ const counts = { missing_row: 0, missing_pointer: 0, stale_revision: 0, unavailable_evidence: 0, explicit_opt_out: 0, unverifiable: 0, revision_verified: 0 };
73
+ const optOutSources = optOutAll ? ["codex", "claude_code"] : optOutClaude ? ["claude_code"] : [];
74
+ counts.explicit_opt_out = optOutSources.length;
75
+ for (const file of local.files) {
76
+ const finding = await reconcileSource({ file, inventory, remote,
77
+ cursor: file.kind === "claude_subagent" ? null : cursors[file.source].sessions[file.session_id] ?? null,
78
+ facts: fileFacts(file, undefined, { mode: imageMode.mode, describedKeys, slimAware: imageMode.slimAware }),
79
+ }).catch(() => ({ state: "unavailable_evidence", reason: "local_revision_unreadable" }));
80
+ counts[finding.state]++;
81
+ const entry = report.files.find(entry => entry.path === file.file_path);
82
+ if (entry)
83
+ entry.reconciliation = finding;
84
+ }
85
+ report.reconciliation = { counts, inventory: inventoryCounts,
86
+ scan: { started_at: new Date(startedAt).toISOString(), finished_at: new Date().toISOString(),
87
+ roots: ["codex/sessions", "codex/archived_sessions", "claude/projects"], older_than_days: command.olderThanDays, opt_out_sources: optOutSources },
88
+ limitations: ["Snapshot scan, not a live completeness guarantee; absent local sources cannot be discovered.", "Missing historical source identifiers remain unverifiable.", "Pointer metadata does not prove bucket bytes are readable; no storage bodies fetched.", "explicit_opt_out counts sources, not undiscovered files; opted-out stores are not scanned."],
89
+ };
90
+ if (inventory.status !== "exhausted" || report.local.unreadable > 0 || counts.unavailable_evidence > 0) {
91
+ report.status = "incomplete";
92
+ report.reason = "reconciliation_partial";
93
+ }
94
+ }
95
+ if (report.local.unreadable > 0) {
96
+ report.status = "incomplete";
97
+ report.reason = "local_scan_incomplete";
98
+ }
41
99
  reportSourcesCheck(report, remote, startedAt);
42
100
  if (command.json)
43
101
  writeLine(io.stdout, JSON.stringify(report, null, 2));
@@ -93,6 +151,7 @@ function reportSourcesCheck(report, remote, startedAt) {
93
151
  stored_partial: report.counts.stored_partial,
94
152
  not_stored: report.counts.not_stored,
95
153
  reasons: report.reasons,
154
+ ...(report.reconciliation ? { reconciliation: report.reconciliation.counts } : {}),
96
155
  elapsed_ms: Date.now() - startedAt,
97
156
  }));
98
157
  }
@@ -135,6 +194,8 @@ export function sourcesCheckLines(report) {
135
194
  ` stored in part ${report.counts.stored_partial.files} file(s), ${mb(report.counts.stored_partial.bytes)} MB`,
136
195
  ` not stored ${report.counts.not_stored.files} file(s), ${mb(report.counts.not_stored.bytes)} MB`,
137
196
  ];
197
+ if (report.reconciliation)
198
+ lines.push(` reconciliation ${Object.entries(report.reconciliation.counts).map(([state, count]) => `${state} ${count}`).join(", ")}`, ` row inventory ${report.reconciliation.inventory.status}; ${report.reconciliation.inventory.unique} unique of ${report.reconciliation.inventory.returned} returned (${report.reconciliation.inventory.requested} requested)`, ` scan ${report.reconciliation.scan.started_at} to ${report.reconciliation.scan.finished_at}; roots ${report.reconciliation.scan.roots.join(", ")}; opted out ${report.reconciliation.scan.opt_out_sources.join(", ") || "none"}`, ...report.reconciliation.limitations.map(reason => ` limitation ${reason}`));
138
199
  const reasons = Object.entries(report.reasons).sort((a, b) => b[1] - a[1]);
139
200
  if (reasons.length > 0)
140
201
  lines.push(` why ${reasons.map(([reason, count]) => `${reason} ${count}`).join(", ")}`);
@@ -144,7 +205,7 @@ export function sourcesCheckLines(report) {
144
205
  lines.push(` unreadable ${report.local.unreadable} file(s) could not be read and are not answered`);
145
206
  lines.push(` Tower's list ${report.remote.status}: ${report.remote.unique} current transcript object(s) over ${report.remote.pages} page(s)`);
146
207
  if (report.status === "incomplete") {
147
- lines.push(` INCOMPLETE Tower's list was cut short (${report.reason}); "not stored" and "stored in part" may be understated. Run it again.`);
208
+ lines.push(` INCOMPLETE The scan or evidence list is incomplete (${report.reason}); missing evidence is inconclusive. Run it again.`);
148
209
  }
149
210
  lines.push("Tower never deletes these files. `--json` lists every file with its path and verdict. Only a \"stored in full\" file is safe to delete by your own hand (never \"stored slim\"); see docs/runbooks/freeing-disk-on-a-collector-machine.md.");
150
211
  return lines;
@@ -153,7 +153,7 @@ function shapeOrder(shape) {
153
153
  }
154
154
  async function findTranscriptFiles(options, roots, result, recordFailure) {
155
155
  const found = [];
156
- const stack = [...roots.codex, roots.claudeProjects];
156
+ const stack = options.skipAll ? [] : [...roots.codex, ...(options.skipClaude ? [] : [roots.claudeProjects])];
157
157
  const seen = new Set();
158
158
  while (stack.length > 0) {
159
159
  const dir = stack.pop();
@@ -0,0 +1,87 @@
1
+ /** Metadata reconciliation only. Never writes cursors, starts collection or fetches bodies. */
2
+ import fs from "node:fs/promises";
3
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
4
+ export async function fetchSourceInventory(options) {
5
+ const rows = new Map();
6
+ const result = { status: "capped", reason: "page_limit", requested: 0, returned: 0, unique: 0, pages: 0, rows: [] };
7
+ let after = null;
8
+ try {
9
+ for (let page = 0; page < (options.maxPages ?? 400); page++) {
10
+ result.requested += 500;
11
+ const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/evidence/transcripts?inventory=1`, {
12
+ method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.deviceToken}` },
13
+ body: JSON.stringify({ schema_version: "evidence-transcripts.v1", after, limit: 500 }),
14
+ signal: AbortSignal.timeout(30_000),
15
+ });
16
+ if (!response.ok)
17
+ throw new Error(`http_${response.status}`);
18
+ const body = await response.json();
19
+ if (body.schema_version !== "source-reconciliation.v1" || !Array.isArray(body.rows) || body.rows.length > 500 ||
20
+ !(body.next_after === null || (typeof body.next_after === "string" && uuid.test(body.next_after))))
21
+ throw new Error("invalid_response");
22
+ const valid = body.rows.every((row) => row && typeof row.id === "string" && uuid.test(row.id) &&
23
+ typeof row.source === "string" && (row.codex_session_id === null || typeof row.codex_session_id === "string") &&
24
+ (row.raw_evidence_pointer_id === null || (typeof row.raw_evidence_pointer_id === "string" && uuid.test(row.raw_evidence_pointer_id))));
25
+ if (!valid)
26
+ throw new Error("invalid_response");
27
+ const next = body.next_after;
28
+ if (next && (next <= (after ?? "") || body.rows.length === 0 || next !== body.rows.at(-1).id))
29
+ throw new Error("cursor_not_advancing");
30
+ result.pages++;
31
+ result.returned += body.rows.length;
32
+ for (const row of body.rows)
33
+ rows.set(row.id, row);
34
+ if (!next) {
35
+ result.status = "exhausted";
36
+ result.reason = "ok";
37
+ break;
38
+ }
39
+ after = next;
40
+ }
41
+ }
42
+ catch (error) {
43
+ result.status = result.pages ? "failed_partial" : "unavailable";
44
+ result.reason = error instanceof Error && /^(http_\d+|invalid_response|cursor_not_advancing)$/.test(error.message) ? error.message : "request_failed";
45
+ }
46
+ result.rows = [...rows.values()];
47
+ result.unique = rows.size;
48
+ console.error("[collector sources] row inventory", JSON.stringify({ reason: result.reason, status: result.status, requested: result.requested, returned: result.returned, unique: result.unique, pages: result.pages }));
49
+ return result;
50
+ }
51
+ export async function reconcileSource(input) {
52
+ const answer = (state, reason) => ({ state, reason });
53
+ const { file, inventory, remote, cursor, facts } = input;
54
+ if (file.session_id_source === "path_hash")
55
+ return answer("unverifiable", "source_identifier_missing");
56
+ const rows = inventory.rows.filter(row => row.source === file.source && row.codex_session_id === file.session_id);
57
+ if (!rows.length && inventory.rows.some(row => (!row.source || row.source === file.source) && !row.codex_session_id?.trim())) {
58
+ return answer("unverifiable", "historical_source_identifier_missing");
59
+ }
60
+ if (!rows.length)
61
+ return inventory.status === "exhausted"
62
+ ? answer("missing_row", "source_row_absent") : answer("unavailable_evidence", `row_inventory_${inventory.status}`);
63
+ // Duplicate device rows are expected; any usable pointer can answer this file.
64
+ const pointers = new Set(rows.map(row => row.raw_evidence_pointer_id).filter(Boolean));
65
+ if (!pointers.size)
66
+ return inventory.status === "exhausted"
67
+ ? answer("missing_pointer", "source_rows_without_pointer") : answer("unavailable_evidence", `row_inventory_${inventory.status}`);
68
+ const objects = (remote.files.get(file.key) ?? []).filter(object => !object.stub &&
69
+ (file.kind === "claude_subagent" || (object.ref_id && pointers.has(object.ref_id))));
70
+ if (!objects.length)
71
+ return answer("unavailable_evidence", remote.status === "exhausted" ? "pointer_not_in_current_transcripts_or_stub" : `transcripts_${remote.status}`);
72
+ const split = facts.slimSplit ? await facts.slimSplit() : null;
73
+ const form = split?.slim ?? (facts.slimAware && facts.wholeForm ? await facts.wholeForm() : await facts.sanitized());
74
+ const stat = await fs.stat(file.file_path);
75
+ if (stat.size !== file.byte_size || stat.mtimeMs !== file.mtime_ms)
76
+ return answer("unavailable_evidence", "local_file_changed_during_check");
77
+ if (form && objects.some(object => object.content_hash_sha256 === form.sha256 || object.content_hash_sha256 === split?.full.sha256))
78
+ return answer("revision_verified", "current_redacted_hash_matches_pointer");
79
+ // Sync refreshes the observed file hash even when growth damping retains an older
80
+ // durable upload. uploaded_byte_size is the raw source size at that upload.
81
+ if (form && objects.every(object => object.content_hash_sha256 !== null) && cursor?.uploaded_object_key &&
82
+ objects.some(object => object.object_key === cursor.uploaded_object_key || object.ref_id === cursor.uploaded_object_key) &&
83
+ cursor.uploaded_byte_size !== null && cursor.uploaded_byte_size < file.byte_size) {
84
+ return answer("stale_revision", "local_tail_newer_than_cursor_upload");
85
+ }
86
+ return answer("unverifiable", form ? "revision_hash_mismatch_or_legacy_hash_missing" : "redacted_revision_unavailable");
87
+ }
@@ -78,6 +78,7 @@ function addTranscriptObject(object, files, state) {
78
78
  const key = transcriptFileKey(identity);
79
79
  const list = files.get(key) ?? [];
80
80
  list.push({
81
+ ref_id: object.ref_id,
81
82
  object_key: object.object_key,
82
83
  byte_size: object.byte_size,
83
84
  content_hash_sha256: object.content_hash_sha256,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.243",
3
+ "version": "0.2.245",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.71",
31
- "@bli-cockpit/mcp": "0.1.148",
32
- "@bli-cockpit/telemetry-core": "0.1.78"
30
+ "@bli-cockpit/memory-mcp": "0.1.72",
31
+ "@bli-cockpit/mcp": "0.1.150",
32
+ "@bli-cockpit/telemetry-core": "0.1.79"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",