@bli-cockpit/cli 0.2.291 → 0.2.293
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/claude-attribution-discovery.js +22 -0
- package/dist/adapters/claude-attribution-memoized-reads.js +3 -3
- package/dist/adapters/claude-attribution.js +5 -2
- package/dist/adapters/codex-attribution-memoized-reads.js +3 -3
- package/dist/adapters/codex-attribution.js +30 -12
- package/dist/adapters/codex-segment-discovery.js +66 -0
- package/dist/adapters/codex-session-revision.js +139 -0
- package/dist/adapters/codex-session-signals.js +2 -2
- package/dist/adapters/pi-session-facts.js +28 -0
- package/dist/adapters/pi-session-reader.js +132 -0
- package/dist/adapters/raw-evidence-codex-reader.js +17 -7
- package/dist/adapters/raw-evidence-transcript-body.js +3 -0
- package/dist/adapters/raw-evidence-transcript-file.js +27 -4
- package/dist/adapters/raw-evidence-transcript-stream.js +33 -1
- package/dist/agent-plugins-converge.js +24 -5
- package/dist/collection-scope.js +2 -0
- package/dist/commands/backfill-checkpoint.js +3 -1
- package/dist/commands/backfill-history-scan.js +38 -0
- package/dist/commands/backfill-issues.js +8 -20
- package/dist/commands/backfill-scan.js +18 -40
- package/dist/commands/backfill-upload.js +1 -0
- package/dist/commands/books-usage.js +2 -1
- package/dist/commands/books.js +12 -4
- package/dist/commands/codex-hooks-trust-choice.js +51 -0
- package/dist/commands/codex-hooks-trust-prompt.js +172 -0
- package/dist/commands/codex-managed-hooks.js +226 -0
- package/dist/commands/codex-revision-facts.js +64 -0
- package/dist/commands/doctor-report.js +2 -2
- package/dist/commands/doctor-update.js +2 -0
- package/dist/commands/doctor.js +2 -0
- package/dist/commands/local-args-collector-setup.js +2 -0
- package/dist/commands/local-args-collector-status.js +5 -3
- package/dist/commands/local-args-notes-targets.js +35 -0
- package/dist/commands/local-args-tower-notes.js +7 -18
- package/dist/commands/local-help-commands-tower.js +3 -2
- package/dist/commands/local-help-commands.js +3 -2
- package/dist/commands/local-help-notes.js +2 -1
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/memory-install-codex-hooks.js +95 -2
- package/dist/commands/memory-install-receipt.js +17 -1
- package/dist/commands/memory-install-report.js +5 -4
- package/dist/commands/memory-install.js +15 -6
- package/dist/commands/memory-log.js +13 -2
- package/dist/commands/memory-map.js +6 -2
- package/dist/commands/memory-verb-list.js +1 -1
- package/dist/commands/notes-writes.js +24 -0
- package/dist/commands/notes.js +3 -2
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-record.js +3 -1
- package/dist/commands/session-sync-scan.js +14 -6
- package/dist/commands/session-sync-shelf.js +2 -0
- package/dist/commands/session-sync-upload.js +1 -0
- package/dist/commands/setup-receipt-lines.js +2 -8
- package/dist/commands/sources-discovery.js +63 -14
- package/dist/commands/sources-reconciliation.js +7 -5
- package/dist/commands/sources-verdict.js +3 -0
- package/dist/cursors/raw-evidence-cursor.js +1 -0
- package/dist/image-description-session.js +4 -2
- package/dist/native-session-roots.js +31 -0
- package/dist/t3-native-homes.js +64 -0
- package/package.json +4 -4
|
@@ -56,11 +56,27 @@ export async function collectOneEvidenceFile(collection, options) {
|
|
|
56
56
|
}
|
|
57
57
|
// BLI-3936: stat BEFORE the read. The same stamp, taken again after the work, proves the file
|
|
58
58
|
// did not move underneath it; only then is the result remembered.
|
|
59
|
-
const stamp = await statSourceForMemo(options.filePath);
|
|
59
|
+
const stamp = options.sourceProvider ? null : await statSourceForMemo(options.filePath);
|
|
60
60
|
const recipe = prepareRecipe({ imageMode: collection.budget.images?.mode, kind: options.kind });
|
|
61
61
|
if (await reuseUnchangedTranscript(collection, options, names, stamp, recipe))
|
|
62
62
|
return true;
|
|
63
|
-
|
|
63
|
+
let read;
|
|
64
|
+
try {
|
|
65
|
+
read = options.sourceProvider
|
|
66
|
+
? { status: "ok", ...await options.sourceProvider() }
|
|
67
|
+
: await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes, streamMinBytesFor(collection, options.kind));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (error instanceof TranscriptTooLargeError) {
|
|
71
|
+
markCapApplied(collection, options.kind, "max_file_bytes");
|
|
72
|
+
return skip("file_too_large");
|
|
73
|
+
}
|
|
74
|
+
const reason = error instanceof TranscriptReadError ? `file_read_failed:${error.stage}` : "file_read_failed";
|
|
75
|
+
console.error("[raw-evidence] revision source unavailable", JSON.stringify({ reason, ...describeError(error) }));
|
|
76
|
+
return skip(reason);
|
|
77
|
+
}
|
|
78
|
+
if (read.status === "ok" && options.maxFileBytes && read.byteLength > options.maxFileBytes)
|
|
79
|
+
read = { status: "too_large" };
|
|
64
80
|
if (read.status === "read_failed")
|
|
65
81
|
return skip("file_read_failed");
|
|
66
82
|
if (read.status === "too_large") {
|
|
@@ -80,8 +96,15 @@ export async function collectOneEvidenceFile(collection, options) {
|
|
|
80
96
|
}
|
|
81
97
|
catch (error) {
|
|
82
98
|
// BLI-23317: a streamed or fallback read that fails ends exactly as a whole-file read that failed.
|
|
83
|
-
if (error instanceof TranscriptReadError)
|
|
84
|
-
|
|
99
|
+
if (error instanceof TranscriptReadError) {
|
|
100
|
+
const deferred = error.stage === "composite_staging_declined" && error.underlying instanceof Error ? error.underlying.message : null;
|
|
101
|
+
if (deferred && /^(deferred_(byte|object)_budget|staging_deferred:[a-z_]+)$/.test(deferred)) {
|
|
102
|
+
markBudgetCapApplied(collection, deferred);
|
|
103
|
+
return skip(deferred);
|
|
104
|
+
}
|
|
105
|
+
const detail = options.sourceProvider ? `:${error.stage}` : "";
|
|
106
|
+
return skip(`file_read_failed${detail}`);
|
|
107
|
+
}
|
|
85
108
|
if (error instanceof TranscriptTooLargeError) {
|
|
86
109
|
markCapApplied(collection, options.kind, "max_file_bytes");
|
|
87
110
|
return skip("file_too_large");
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* read gives (`file_read_failed`, `file_too_large`), never a shorter file.
|
|
13
13
|
*/
|
|
14
14
|
import fs from "node:fs/promises";
|
|
15
|
+
import { copyLinesStream } from "../slim/keep-line-copy.js";
|
|
16
|
+
import { openStagedOutput } from "./staged-output.js";
|
|
15
17
|
import { modeDropsImages } from "@bli-cockpit/telemetry-core";
|
|
16
18
|
import { describeError } from "../health-detail.js";
|
|
17
19
|
import { TranscriptNotStreamable, TranscriptReadError, TranscriptTooLargeError, } from "./transcript-text-stream.js";
|
|
@@ -32,11 +34,13 @@ export function volatilityOf(mode, prepared) {
|
|
|
32
34
|
}
|
|
33
35
|
export async function streamTranscriptBody(session, agent, file) {
|
|
34
36
|
if (!session?.prepareTranscriptFile || !agent) {
|
|
37
|
+
if (file.openSource)
|
|
38
|
+
return streamCompositeWithoutImages(file);
|
|
35
39
|
console.error(TAG + " streamed read not available, reading the transcript whole", JSON.stringify({ reason: "stream_unavailable", kind: file.kind }));
|
|
36
40
|
return null;
|
|
37
41
|
}
|
|
38
42
|
try {
|
|
39
|
-
const prepared = await session.prepareTranscriptFile({ filePath: file.filePath, source: agent, sessionId: file.sessionId, maxFileBytes: file.maxFileBytes, stageDir: file.stageDir, omitText: file.stageDir !== undefined, ...(file.provenance ? { provenance: file.provenance } : {}) });
|
|
43
|
+
const prepared = await session.prepareTranscriptFile({ filePath: file.filePath, ...(file.openSource ? { openSource: file.openSource } : {}), source: agent, sessionId: file.sessionId, maxFileBytes: file.maxFileBytes, stageDir: file.stageDir, omitText: file.stageDir !== undefined, ...(file.provenance ? { provenance: file.provenance } : {}) });
|
|
40
44
|
const { volatile, pendingImages } = volatilityOf(session.mode, prepared);
|
|
41
45
|
console.error(TAG + " transcript streamed through image swap and slim", JSON.stringify({ kind: file.kind, mode: session.mode, byte_size: prepared.readBytes, changed: prepared.changed, images: prepared.imageCount, volatile, staged_bytes: prepared.staged?.byteLength ?? null }));
|
|
42
46
|
// BLI-23344: with a spool the text is not read back; the sanitizer reads the spool line by line.
|
|
@@ -45,12 +49,18 @@ export async function streamTranscriptBody(session, agent, file) {
|
|
|
45
49
|
catch (error) {
|
|
46
50
|
if (error instanceof TranscriptReadError || error instanceof TranscriptTooLargeError)
|
|
47
51
|
throw error;
|
|
52
|
+
if (file.openSource) {
|
|
53
|
+
console.error(TAG + " composite revision remains pending", JSON.stringify({ reason: "composite_prepare_failed", kind: file.kind, ...describeError(error) }));
|
|
54
|
+
throw new TranscriptReadError(error instanceof TranscriptNotStreamable ? error.reason : "composite_prepare_failed", error);
|
|
55
|
+
}
|
|
48
56
|
console.error(TAG + " streamed read set aside, reading the transcript whole", JSON.stringify({ reason: error instanceof TranscriptNotStreamable ? error.reason : "stream_step_failed", kind: file.kind, ...(error instanceof TranscriptNotStreamable ? {} : describeError(error)) }));
|
|
49
57
|
return null;
|
|
50
58
|
}
|
|
51
59
|
}
|
|
52
60
|
/** The whole-file read the streamed path falls back to: same failures, same labels, as the reader that did not stream. */
|
|
53
61
|
export async function readTranscriptWhole(file) {
|
|
62
|
+
if (file.openSource)
|
|
63
|
+
throw new TranscriptReadError("composite_buffering_refused", new Error("composite_buffering_refused"));
|
|
54
64
|
let bytes;
|
|
55
65
|
try {
|
|
56
66
|
bytes = await fs.readFile(file.filePath);
|
|
@@ -62,4 +72,26 @@ export async function readTranscriptWhole(file) {
|
|
|
62
72
|
if (file.maxFileBytes && bytes.byteLength > file.maxFileBytes)
|
|
63
73
|
throw new TranscriptTooLargeError(file.maxFileBytes);
|
|
64
74
|
return bytes;
|
|
75
|
+
}
|
|
76
|
+
/** Keep mode when image preparation is unavailable: same spool/mask/ack, no representative read or whole buffer. */
|
|
77
|
+
async function streamCompositeWithoutImages(file) {
|
|
78
|
+
if (!file.stageDir)
|
|
79
|
+
throw new TranscriptReadError("composite_staging_unavailable", new Error("composite_staging_unavailable"));
|
|
80
|
+
const source = await file.openSource();
|
|
81
|
+
let writer;
|
|
82
|
+
try {
|
|
83
|
+
writer = await openStagedOutput(file.stageDir);
|
|
84
|
+
await copyLinesStream(source.lines(), writer);
|
|
85
|
+
await source.verifyUnchanged();
|
|
86
|
+
const staged = await writer.finish();
|
|
87
|
+
console.error(TAG + " composite revision kept without image preparation", JSON.stringify({ reason: "composite_images_unavailable_kept", kind: file.kind, byte_size: source.length }));
|
|
88
|
+
return { held: { text: null, exactBytes: null, staged }, volatile: true, pendingImages: null, readBytes: source.length };
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
await writer?.abandon();
|
|
92
|
+
throw new TranscriptReadError(error instanceof TranscriptNotStreamable ? error.reason : "composite_prepare_failed", error);
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await source.close();
|
|
96
|
+
}
|
|
65
97
|
}
|
|
@@ -113,7 +113,12 @@ async function convergeHost(host, reading, entry, options, now) {
|
|
|
113
113
|
if (reading.installed) {
|
|
114
114
|
// Installed by us or by hand: a failure remembered from before is spent.
|
|
115
115
|
delete entry.last_reason;
|
|
116
|
-
|
|
116
|
+
// A failed refresh waits out the six-hour retry window, not a whole day: it
|
|
117
|
+
// never counts as the day's refresh, so a machine (or a Boat template
|
|
118
|
+
// snapshot) that failed once is not left on an old version (BLI-24352).
|
|
119
|
+
const due = options.refresh
|
|
120
|
+
&& msSince(entry.last_refresh_at, now) >= PLUGIN_REFRESH_EVERY_MS
|
|
121
|
+
&& msSince(entry.last_refresh_failed_at, now) >= PLUGIN_RETRY_AFTER_FAILURE_MS;
|
|
117
122
|
if (!due)
|
|
118
123
|
return { host, status: "already_installed", reason: "installed", quiet: true };
|
|
119
124
|
return refreshHost(host, reading, entry, options, now);
|
|
@@ -166,17 +171,31 @@ async function installHost(host, reading, entry, options, now) {
|
|
|
166
171
|
console.error("[agent-plugins] plugin installed", JSON.stringify({ reason: "installed", host }));
|
|
167
172
|
return { host, status: "installed", reason: "installed", quiet: false };
|
|
168
173
|
}
|
|
169
|
-
/**
|
|
174
|
+
/**
|
|
175
|
+
* The daily refresh. A failure here never un-installs anything and never counts
|
|
176
|
+
* as the day's refresh: `last_refresh_at` stays on the last clean one and the
|
|
177
|
+
* next try is after the six-hour retry window (BLI-24352).
|
|
178
|
+
*/
|
|
170
179
|
async function refreshHost(host, reading, entry, options, now) {
|
|
171
|
-
|
|
180
|
+
const failed = (reason) => {
|
|
181
|
+
entry.last_refresh_failed_at = now.toISOString();
|
|
182
|
+
entry.last_refresh_reason = reason;
|
|
183
|
+
};
|
|
172
184
|
const binary = await locateHostBinary(host, options.homeDir, options);
|
|
173
185
|
if (!binary) {
|
|
174
|
-
|
|
186
|
+
failed("binary_missing");
|
|
187
|
+
console.error("[agent-plugins] host program not found for the daily refresh", JSON.stringify({ reason: "binary_missing", host, retry_after_hours: PLUGIN_RETRY_AFTER_FAILURE_MS / 3_600_000 }));
|
|
175
188
|
return { host, status: "already_installed", reason: "refresh_skipped_binary_missing", quiet: true };
|
|
176
189
|
}
|
|
177
190
|
const failure = await runSteps(host, binary, stepsFor(host, reading, true), options);
|
|
178
|
-
if (failure)
|
|
191
|
+
if (failure) {
|
|
192
|
+
failed(failure);
|
|
193
|
+
console.error("[agent-plugins] daily refresh failed; the installed plugin stays and the refresh is retried after the failure window", JSON.stringify({ reason: `refresh_failed_${failure}`, host, retry_after_hours: PLUGIN_RETRY_AFTER_FAILURE_MS / 3_600_000 }));
|
|
179
194
|
return { host, status: "already_installed", reason: `refresh_failed_${failure}`, quiet: false };
|
|
195
|
+
}
|
|
196
|
+
entry.last_refresh_at = now.toISOString();
|
|
197
|
+
delete entry.last_refresh_failed_at;
|
|
198
|
+
delete entry.last_refresh_reason;
|
|
180
199
|
console.error("[agent-plugins] plugin refreshed", JSON.stringify({ reason: "refreshed", host }));
|
|
181
200
|
return { host, status: "refreshed", reason: "refreshed", quiet: false };
|
|
182
201
|
}
|
package/dist/collection-scope.js
CHANGED
|
@@ -146,6 +146,8 @@ export function cwdInsideRoots(cwd, roots) {
|
|
|
146
146
|
* it cannot be proven inside, so it is withheld.
|
|
147
147
|
*/
|
|
148
148
|
export function sessionInsideRoots(result, roots) {
|
|
149
|
+
if (result.local_cwds)
|
|
150
|
+
return result.local_cwds.length > 0 && result.local_cwds.every(cwd => cwdInsideRoots(cwd, roots) || Boolean(result.worktree && cwdInsideRoots(cwd, [result.worktree.repo_root])));
|
|
149
151
|
if (cwdInsideRoots(result.local_cwd, roots))
|
|
150
152
|
return true;
|
|
151
153
|
return Boolean(result.worktree && cwdInsideRoots(result.local_cwd, [result.worktree.repo_root]));
|
|
@@ -37,7 +37,8 @@ export async function recordBackfillDurableSessionPointers(options) {
|
|
|
37
37
|
// delivery, so a real pointer replaces it and the mark clears.
|
|
38
38
|
// BLI-11119: an entry the cursor itself knows is `stubbed` counts too.
|
|
39
39
|
const replacesStub = Boolean(prior?.stub_reoffer || prior?.stubbed) && prior?.uploaded_object_key !== objectKey;
|
|
40
|
-
|
|
40
|
+
const replacesRevision = candidate.source === "codex" && prior?.uploaded_source_revision !== candidate.content_hash_sha256;
|
|
41
|
+
if (!objectKey || (prior?.uploaded_object_key && !replacesStub && !replacesRevision))
|
|
41
42
|
continue;
|
|
42
43
|
if (replacesStub && !isRedactionStubObjectKey(objectKey))
|
|
43
44
|
stubsReplaced += 1;
|
|
@@ -61,6 +62,7 @@ function deliveredCursorEntry(prior, candidate, objectKey, now) {
|
|
|
61
62
|
const entry = {
|
|
62
63
|
...prior,
|
|
63
64
|
file_hash_sha256: candidate.content_hash_sha256,
|
|
65
|
+
uploaded_source_revision: isRedactionStubObjectKey(objectKey) ? prior?.uploaded_source_revision ?? null : candidate.content_hash_sha256,
|
|
64
66
|
file_mtime_ms: candidate.session_file_mtime_ms,
|
|
65
67
|
byte_size: candidate.byte_size,
|
|
66
68
|
// The whole file was delivered, so the incremental reader starts at its end.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Keep complete-history rescans separate from candidate selection and cursor policy. */
|
|
2
|
+
import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
|
|
3
|
+
import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
|
|
4
|
+
/**
|
|
5
|
+
* The adapter's own limit is a memory guard, not permission to silently leave
|
|
6
|
+
* history undiscovered. When it fires, re-scan the exact discovered population
|
|
7
|
+
* so a user-facing `--max-files` cap can resume from a truthful ordering.
|
|
8
|
+
*/
|
|
9
|
+
export async function scanCodexHistory(request, sessionsDirs, scanLimit) {
|
|
10
|
+
const scan = (limit) => scanAndAttributeCodexSessions({
|
|
11
|
+
sessionsDirs,
|
|
12
|
+
worktrees: request.worktrees,
|
|
13
|
+
now: request.now,
|
|
14
|
+
sinceMinutes: request.window.since_minutes,
|
|
15
|
+
limit,
|
|
16
|
+
collectionRoots: request.collectionRoots,
|
|
17
|
+
});
|
|
18
|
+
const firstPass = await scan(scanLimit);
|
|
19
|
+
if (!firstPass.session_limit_applied)
|
|
20
|
+
return firstPass;
|
|
21
|
+
return scan(Math.max(scanLimit + 1, firstPass.discovered_file_count));
|
|
22
|
+
}
|
|
23
|
+
/** Same rule for the Claude store: a capped first pass is rescanned in full. */
|
|
24
|
+
export async function scanClaudeHistory(request, projectsDirs, scanLimit) {
|
|
25
|
+
const scan = (limit) => scanAndAttributeClaudeSessions({
|
|
26
|
+
projectsDir: projectsDirs[0],
|
|
27
|
+
projectsDirs,
|
|
28
|
+
worktrees: request.worktrees,
|
|
29
|
+
now: request.now,
|
|
30
|
+
sinceMinutes: request.window.since_minutes,
|
|
31
|
+
limit,
|
|
32
|
+
collectionRoots: request.collectionRoots,
|
|
33
|
+
});
|
|
34
|
+
const firstPass = await scan(scanLimit);
|
|
35
|
+
if (!firstPass.session_limit_applied)
|
|
36
|
+
return firstPass;
|
|
37
|
+
return scan(Math.max(scanLimit + 1, firstPass.discovered_session_count));
|
|
38
|
+
}
|
|
@@ -29,36 +29,33 @@ import { candidateCursorKey } from "./backfill-candidates.js";
|
|
|
29
29
|
export async function backfillScanIssues(options) {
|
|
30
30
|
const issues = [];
|
|
31
31
|
if (options.codexAttribution) {
|
|
32
|
-
await addCodexStoreScanIssues(issues, options.codexAttribution
|
|
32
|
+
await addCodexStoreScanIssues(issues, options.codexAttribution);
|
|
33
33
|
}
|
|
34
34
|
if (options.claudeAttribution) {
|
|
35
|
-
await addClaudeStoreScanIssues(issues, options.claudeAttribution, options.
|
|
35
|
+
await addClaudeStoreScanIssues(issues, options.claudeAttribution, options.candidates);
|
|
36
36
|
}
|
|
37
37
|
addCandidateScanIssues(issues, options.candidates, options.omittedCandidateCount);
|
|
38
38
|
sortScanIssuesByPriority(issues);
|
|
39
39
|
return issues;
|
|
40
40
|
}
|
|
41
41
|
/** What the archived Codex store could not tell us — every one of these hides history. */
|
|
42
|
-
async function addCodexStoreScanIssues(issues, codexAttribution
|
|
42
|
+
async function addCodexStoreScanIssues(issues, codexAttribution) {
|
|
43
43
|
countScanIssue(issues, "codex_session_limit_applied", codexAttribution.session_limit_applied
|
|
44
44
|
? Math.max(1, codexAttribution.discovered_file_count -
|
|
45
45
|
codexAttribution.scanned_file_count)
|
|
46
46
|
: 0, "global");
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
countScanIssue(issues, "codex_directory_read_failed", Math.max(0, codexAttribution.directory_read_failed_count - missingTopLevelDirs), "global");
|
|
47
|
+
// The reader already excludes ENOENT. Subtracting absent default roots
|
|
48
|
+
// here would erase a failure in another configured home or T3 discovery.
|
|
49
|
+
countScanIssue(issues, "codex_directory_read_failed", codexAttribution.directory_read_failed_count, "global");
|
|
51
50
|
countScanIssue(issues, "codex_session_stat_failed", codexAttribution.stat_failed_count, "global");
|
|
52
51
|
}
|
|
53
52
|
/** The same census for the Claude store, which also has sidecars to account for. */
|
|
54
|
-
async function addClaudeStoreScanIssues(issues, claudeAttribution,
|
|
53
|
+
async function addClaudeStoreScanIssues(issues, claudeAttribution, candidates) {
|
|
55
54
|
countScanIssue(issues, "claude_session_limit_applied", claudeAttribution.session_limit_applied
|
|
56
55
|
? Math.max(1, claudeAttribution.discovered_session_count -
|
|
57
56
|
claudeAttribution.scanned_session_count)
|
|
58
57
|
: 0, "global");
|
|
59
|
-
|
|
60
|
-
countScanIssue(issues, "claude_project_dir_read_failed", Math.max(0, claudeAttribution.project_dir_read_failed_count -
|
|
61
|
-
(projectsDirMissing ? 1 : 0)), "global");
|
|
58
|
+
countScanIssue(issues, "claude_project_dir_read_failed", claudeAttribution.project_dir_read_failed_count, "global");
|
|
62
59
|
countScanIssue(issues, "claude_session_stat_failed", claudeAttribution.session_stat_failed_count, "global");
|
|
63
60
|
countScanIssue(issues, "claude_sidecar_stat_failed", claudeAttribution.sidecar_stat_failed_count, "global");
|
|
64
61
|
countScanIssue(issues, "claude_sidecar_dir_read_failed", await countUnreadableClaudeSidecarDirs(candidates), "global");
|
|
@@ -151,15 +148,6 @@ async function countUnreadableClaudeSidecarDirs(candidates) {
|
|
|
151
148
|
}
|
|
152
149
|
return unreadable;
|
|
153
150
|
}
|
|
154
|
-
async function isMissingPath(filePath) {
|
|
155
|
-
try {
|
|
156
|
-
await fs.stat(filePath);
|
|
157
|
-
return false;
|
|
158
|
-
}
|
|
159
|
-
catch (error) {
|
|
160
|
-
return isMissingFsError(error);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
151
|
function isMissingFsError(error) {
|
|
164
152
|
if (!error || typeof error !== "object")
|
|
165
153
|
return false;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { scanCodexHistory, scanClaudeHistory } from "./backfill-history-scan.js";
|
|
2
|
+
import { nativeSessionRoots } from "../native-session-roots.js";
|
|
1
3
|
/**
|
|
2
4
|
* SCAN: read both archived session stores and turn them into the candidates
|
|
3
5
|
* this run will consider, then census what could not be accounted for.
|
|
@@ -9,9 +11,6 @@
|
|
|
9
11
|
* on what was missed before what merely arrived.
|
|
10
12
|
*/
|
|
11
13
|
import os from "node:os";
|
|
12
|
-
import path from "node:path";
|
|
13
|
-
import { scanAndAttributeClaudeSessions, } from "../adapters/claude-attribution.js";
|
|
14
|
-
import { defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
|
|
15
14
|
import { emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
16
15
|
import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
17
16
|
import { liveWindowMsFor, scopeBackfillCandidates } from "./backfill-live-window.js";
|
|
@@ -136,10 +135,15 @@ async function auditScannedCandidates(scan, discoveryIncompleteReasons) {
|
|
|
136
135
|
*/
|
|
137
136
|
async function scanBackfillSessions(options) {
|
|
138
137
|
const scanLimit = options.command.maxFiles ?? 10_000;
|
|
139
|
-
const codexSessionDirs =
|
|
140
|
-
const claudeProjectsDir = path.join(options.homeDir, ".claude", "projects");
|
|
138
|
+
const { codex: codexSessionDirs, claudeProjects: claudeProjectsDir, claudeProjectDirs, discoveryFailed } = nativeSessionRoots(options.homeDir);
|
|
141
139
|
const codexScanned = options.sources.includes("codex") ? await scanCodexHistory(options, codexSessionDirs, scanLimit) : null;
|
|
142
|
-
const claudeScanned = options.sources.includes("claude_code") ? await scanClaudeHistory(options,
|
|
140
|
+
const claudeScanned = options.sources.includes("claude_code") ? await scanClaudeHistory(options, claudeProjectDirs, scanLimit) : null;
|
|
141
|
+
if (discoveryFailed) {
|
|
142
|
+
if (codexScanned)
|
|
143
|
+
codexScanned.directory_read_failed_count += 1;
|
|
144
|
+
if (claudeScanned)
|
|
145
|
+
claudeScanned.project_dir_read_failed_count += 1;
|
|
146
|
+
}
|
|
143
147
|
// BLI-23590 withholds sessions outside the approved roots first; BLI-23478 then keeps drawer sessions home.
|
|
144
148
|
const scoped = scopeHistoryScans(options, codexScanned, claudeScanned);
|
|
145
149
|
const { codexAttribution, claudeAttribution } = shelveBackfillScan(scoped.codex, scoped.claude, options.shelfDecision);
|
|
@@ -155,6 +159,13 @@ async function scanBackfillSessions(options) {
|
|
|
155
159
|
sinceMinutes: options.window.since_minutes,
|
|
156
160
|
});
|
|
157
161
|
const liveWindowMs = liveWindowMsFor(options.command);
|
|
162
|
+
const durableCodex = await readRawEvidenceCursor(getCollectorRuntimePaths(options.homeDir));
|
|
163
|
+
for (const c of scanned) {
|
|
164
|
+
const prior = durableCodex.sessions[c.session_id];
|
|
165
|
+
if (c.source === "codex" && (c.segment_paths || prior?.uploaded_source_revision) &&
|
|
166
|
+
prior?.uploaded_source_revision !== c.content_hash_sha256)
|
|
167
|
+
options.pointerlessTerminalSessions.codex.add(c.session_id);
|
|
168
|
+
}
|
|
158
169
|
const selection = selectBackfillCandidates({
|
|
159
170
|
scanned,
|
|
160
171
|
cursor: options.cursor,
|
|
@@ -188,40 +199,6 @@ async function scanBackfillSessions(options) {
|
|
|
188
199
|
live_window_ms: liveWindowMs,
|
|
189
200
|
};
|
|
190
201
|
}
|
|
191
|
-
/**
|
|
192
|
-
* The adapter's own limit is a memory guard, not permission to silently leave
|
|
193
|
-
* history undiscovered. When it fires, re-scan the exact discovered population
|
|
194
|
-
* so a user-facing `--max-files` cap can resume from a truthful ordering.
|
|
195
|
-
*/
|
|
196
|
-
async function scanCodexHistory(request, sessionsDirs, scanLimit) {
|
|
197
|
-
const scan = (limit) => scanAndAttributeCodexSessions({
|
|
198
|
-
sessionsDirs,
|
|
199
|
-
worktrees: request.worktrees,
|
|
200
|
-
now: request.now,
|
|
201
|
-
sinceMinutes: request.window.since_minutes,
|
|
202
|
-
limit,
|
|
203
|
-
collectionRoots: request.collectionRoots,
|
|
204
|
-
});
|
|
205
|
-
const firstPass = await scan(scanLimit);
|
|
206
|
-
if (!firstPass.session_limit_applied)
|
|
207
|
-
return firstPass;
|
|
208
|
-
return scan(Math.max(scanLimit + 1, firstPass.discovered_file_count));
|
|
209
|
-
}
|
|
210
|
-
/** Same rule for the Claude store: a capped first pass is rescanned in full. */
|
|
211
|
-
async function scanClaudeHistory(request, projectsDir, scanLimit) {
|
|
212
|
-
const scan = (limit) => scanAndAttributeClaudeSessions({
|
|
213
|
-
projectsDir,
|
|
214
|
-
worktrees: request.worktrees,
|
|
215
|
-
now: request.now,
|
|
216
|
-
sinceMinutes: request.window.since_minutes,
|
|
217
|
-
limit,
|
|
218
|
-
collectionRoots: request.collectionRoots,
|
|
219
|
-
});
|
|
220
|
-
const firstPass = await scan(scanLimit);
|
|
221
|
-
if (!firstPass.session_limit_applied)
|
|
222
|
-
return firstPass;
|
|
223
|
-
return scan(Math.max(scanLimit + 1, firstPass.discovered_session_count));
|
|
224
|
-
}
|
|
225
202
|
/**
|
|
226
203
|
* Which scanned sessions this run will actually work on. A session is in play
|
|
227
204
|
* if the cursor has not passed it, or if it is a terminal session that never
|
|
@@ -291,6 +268,7 @@ function normalizeCodexCandidate(result) {
|
|
|
291
268
|
return {
|
|
292
269
|
source: "codex",
|
|
293
270
|
session_id: result.codex_session_id,
|
|
271
|
+
...(result.segment_paths ? { segment_paths: result.segment_paths } : {}),
|
|
294
272
|
session_id_source: result.session_id_source,
|
|
295
273
|
file_path: result.file_path,
|
|
296
274
|
state: result.state,
|
|
@@ -231,6 +231,7 @@ function backfillBatchSyncOptions(options) {
|
|
|
231
231
|
.map((candidate) => ({
|
|
232
232
|
local_path: candidate.file_path,
|
|
233
233
|
codex_session_id: candidate.session_id,
|
|
234
|
+
...(candidate.segment_paths ? { segment_paths: candidate.segment_paths, revision_hash: candidate.content_hash_sha256 ?? undefined, revision_bytes: candidate.byte_size } : {}),
|
|
234
235
|
})),
|
|
235
236
|
codexAttributionScan: options.codexAttribution ?? undefined,
|
|
236
237
|
claudeSessionFiles: options.batch.candidates
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** `cockpit books` usage text (BLI-23711), in its own module so the help table can print it without loading the command. */
|
|
2
2
|
export const BOOKS_USAGE = [
|
|
3
|
-
"Usage: cockpit books list [--json]",
|
|
3
|
+
"Usage: cockpit books list [--json] (includes archived books, marked archived)",
|
|
4
|
+
" cockpit books archive <book> [--restore] [--json] (founders; nothing deleted)",
|
|
4
5
|
" cockpit books create <book> --title \"<title>\" [--json] (founders)",
|
|
5
6
|
" cockpit books members list|add|remove <book> [<email or user id>] [--edit] [--json] (founders)",
|
|
6
7
|
" cockpit books ls <book> <collection> [--after <doc_id>] [--limit <1-500>] [--deleted] [--json]",
|
package/dist/commands/books.js
CHANGED
|
@@ -4,7 +4,7 @@ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
|
4
4
|
import { BOOKS_USAGE } from "./books-usage.js";
|
|
5
5
|
import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
6
6
|
export { BOOKS_USAGE };
|
|
7
|
-
const ACTIONS = ["list", "create", "members", "ls", "get", "put", "delete", "batch", "export", "import"];
|
|
7
|
+
const ACTIONS = ["list", "archive", "create", "members", "ls", "get", "put", "delete", "batch", "export", "import"];
|
|
8
8
|
const BODY_MAX_CHARS = 4_000_000;
|
|
9
9
|
function usage() {
|
|
10
10
|
throw new Error(BOOKS_USAGE);
|
|
@@ -21,7 +21,7 @@ function wholeNumber(raw, min, max) {
|
|
|
21
21
|
}
|
|
22
22
|
export function parseBooksArgs(args) {
|
|
23
23
|
const values = parseNamedArgs(args, {
|
|
24
|
-
allowedFlags: ["--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data", "--json", "--home", "--dashboard-url"],
|
|
24
|
+
allowedFlags: ["--restore", "--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data", "--json", "--home", "--dashboard-url"],
|
|
25
25
|
valueFlags: ["--title", "--after", "--limit", "--version", "--data", "--home", "--dashboard-url"],
|
|
26
26
|
});
|
|
27
27
|
const [action, ...rest] = values.positionals;
|
|
@@ -30,7 +30,7 @@ export function parseBooksArgs(args) {
|
|
|
30
30
|
const flags = values.flags;
|
|
31
31
|
const has = (flag) => flags.has(flag) || values.booleans.has(flag);
|
|
32
32
|
const allowOnly = (...allowed) => {
|
|
33
|
-
for (const flag of ["--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data"])
|
|
33
|
+
for (const flag of ["--restore", "--title", "--edit", "--after", "--limit", "--deleted", "--merge", "--version", "--data"])
|
|
34
34
|
if (has(flag) && !allowed.includes(flag))
|
|
35
35
|
usage();
|
|
36
36
|
};
|
|
@@ -47,6 +47,11 @@ export function parseBooksArgs(args) {
|
|
|
47
47
|
usage();
|
|
48
48
|
allowOnly();
|
|
49
49
|
return command;
|
|
50
|
+
case "archive":
|
|
51
|
+
if (rest.length !== 1)
|
|
52
|
+
usage();
|
|
53
|
+
allowOnly("--restore");
|
|
54
|
+
return { ...command, book: rest[0], restore: values.booleans.has("--restore") };
|
|
50
55
|
case "create": {
|
|
51
56
|
const title = optionalNonEmpty(flags.get("--title"));
|
|
52
57
|
if (rest.length !== 1 || !title)
|
|
@@ -137,10 +142,13 @@ export async function runBooks(command, io) {
|
|
|
137
142
|
if (books.length === 0)
|
|
138
143
|
writeLine(io.stdout, "No books you can open.");
|
|
139
144
|
for (const b of books)
|
|
140
|
-
writeLine(io.stdout, `${b.id}\t${b.access}${b.founder ? " (founder)" : ""}\t${b.title}`);
|
|
145
|
+
writeLine(io.stdout, `${b.id}\t${b.access}${b.founder ? " (founder)" : ""}\t${b.title}${b.archived_at ? " (archived)" : ""}`);
|
|
141
146
|
return 0;
|
|
142
147
|
}
|
|
143
148
|
break;
|
|
149
|
+
case "archive":
|
|
150
|
+
answer = await booksRequest(door, "POST", `/api/books/${book}/archive`, "archive", { archived: !command.restore });
|
|
151
|
+
break;
|
|
144
152
|
case "create":
|
|
145
153
|
answer = await booksRequest(door, "POST", "/api/books", "create", { id: command.book, title: command.title });
|
|
146
154
|
break;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The person's answer to "Auto-trust the BLI Memory hooks in Codex?"
|
|
3
|
+
* (BLI-24349), kept so a "no" is never asked again.
|
|
4
|
+
*
|
|
5
|
+
* `<state_dir>/codex-hooks-trust.json`, one small object. Only a "no" changes
|
|
6
|
+
* behaviour (the question is not asked again); a "yes" is recorded for the
|
|
7
|
+
* operator reading the machine, since the system hooks file itself is what
|
|
8
|
+
* proves the yes. `cockpit doctor --trust-codex-hooks` is how a person who
|
|
9
|
+
* said no changes their mind.
|
|
10
|
+
*
|
|
11
|
+
* A file that will not read or parse counts as no answer: asking again is the
|
|
12
|
+
* harmless direction for a question whose default is yes.
|
|
13
|
+
*/
|
|
14
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
export const CODEX_HOOKS_TRUST_CHOICE_FILE = "codex-hooks-trust.json";
|
|
17
|
+
const SCHEMA = "codex-hooks-trust.v1";
|
|
18
|
+
function choiceFile(paths) {
|
|
19
|
+
return path.join(paths.state_dir, CODEX_HOOKS_TRUST_CHOICE_FILE);
|
|
20
|
+
}
|
|
21
|
+
export async function readCodexHooksTrustChoice(paths) {
|
|
22
|
+
let raw;
|
|
23
|
+
try {
|
|
24
|
+
raw = await readFile(choiceFile(paths), "utf8");
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
const code = error?.code;
|
|
28
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
29
|
+
return { choice: null, reason: "choice_absent" };
|
|
30
|
+
console.error("[codex-hooks-trust] choice file unreadable; treating as no answer", JSON.stringify({ reason: "choice_unreadable", error_code: code ?? "unknown" }));
|
|
31
|
+
return { choice: null, reason: "choice_unreadable" };
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
if (parsed?.schema === SCHEMA && (parsed.choice === "declined" || parsed.choice === "trusted")) {
|
|
36
|
+
return { choice: parsed.choice, reason: "choice_read" };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// fall through to the named reason below
|
|
41
|
+
}
|
|
42
|
+
console.error("[codex-hooks-trust] choice file does not parse; treating as no answer", JSON.stringify({ reason: "choice_unreadable", byte_size: raw.length }));
|
|
43
|
+
return { choice: null, reason: "choice_unreadable" };
|
|
44
|
+
}
|
|
45
|
+
export async function writeCodexHooksTrustChoice(paths, choice, via, now = new Date()) {
|
|
46
|
+
const file = choiceFile(paths);
|
|
47
|
+
await mkdir(paths.state_dir, { recursive: true });
|
|
48
|
+
const staged = `${file}.${process.pid}.tmp`;
|
|
49
|
+
await writeFile(staged, `${JSON.stringify({ schema: SCHEMA, choice, via, at: now.toISOString() }, null, 2)}\n`, "utf8");
|
|
50
|
+
await rename(staged, file);
|
|
51
|
+
}
|