@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.
- package/dist/adapters/raw-evidence-budget-admission.js +63 -0
- package/dist/adapters/raw-evidence-collection-state.js +6 -61
- package/dist/commands/issue-contracts.js +65 -4
- package/dist/commands/issue-list-page.js +34 -6
- package/dist/commands/issue-page-args.js +64 -0
- package/dist/commands/issue-relations.js +3 -2
- package/dist/commands/issue-show.js +13 -2
- package/dist/commands/issue-versions.js +5 -8
- package/dist/commands/issue-write.js +71 -16
- package/dist/commands/issue.js +22 -3
- package/dist/commands/local-args-collector-sources.js +2 -1
- package/dist/commands/local-args-tower-milestone.js +120 -0
- package/dist/commands/local-args-tower-work.js +44 -51
- package/dist/commands/local-args-tower.js +1 -0
- package/dist/commands/local-args.js +2 -0
- package/dist/commands/local-command-shapes-milestone.js +1 -0
- package/dist/commands/local-help-commands-tower-ops.js +4 -4
- package/dist/commands/local-help-commands-tower.js +6 -3
- package/dist/commands/local-help-commands.js +3 -1
- package/dist/commands/local-help-milestone.js +23 -0
- package/dist/commands/local-help-project.js +1 -0
- package/dist/commands/local-help.js +3 -1
- package/dist/commands/local.js +2 -0
- package/dist/commands/manager-hints.js +4 -2
- package/dist/commands/manager-limits.json +3 -1
- package/dist/commands/manager-read-reasons.js +1 -0
- package/dist/commands/manager-render-home.js +13 -5
- package/dist/commands/manager-render.js +3 -0
- package/dist/commands/manager.js +3 -1
- package/dist/commands/milestone.js +157 -0
- package/dist/commands/project-args.js +78 -2
- package/dist/commands/project.js +52 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-outside-roots.js +72 -14
- package/dist/commands/sources-check.js +63 -2
- package/dist/commands/sources-discovery.js +1 -1
- package/dist/commands/sources-reconciliation.js +87 -0
- package/dist/evidence-transcripts-client.js +1 -0
- package/package.json +4 -4
package/dist/commands/project.js
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
left.
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
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",
|