@bli-cockpit/cli 0.2.252 → 0.2.253

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.
@@ -0,0 +1,85 @@
1
+ import { captureSessionFactsDelta, observeSessionFactsContent, observeSessionFactsFile, parseSessionFactsDelta, SESSION_FACTS_VERSION, SKILL_USE_VERSION, } from "@bli-cockpit/telemetry-core";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import { extractClaudeSessionSignals } from "./claude-attribution-signals.js";
5
+ import { statSourceForMemo } from "./transcript-read-memo.js";
6
+ /**
7
+ * The reads attribution does of one Claude file, remembered by the file's stamp (BLI-22797;
8
+ * `transcript-read-memo.ts`). Every read goes through the same capture-then-merge path whether the file was
9
+ * opened or recalled, so a recalled file leaves the session's facts exactly as a fresh read does by construction
10
+ * (`session-facts-delta.ts`). Bump SIGNALS_VERSION when `claude-attribution-signals.ts` changes what it extracts.
11
+ */
12
+ const SIGNALS_VERSION = "signals.v1";
13
+ export const ATTRIBUTION_READ_RECIPE = ["attribution-reads.v1", SIGNALS_VERSION, SESSION_FACTS_VERSION, SKILL_USE_VERSION].join("|");
14
+ const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
15
+ const isStrings = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
16
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v);
17
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
18
+ function parseSidecarRead(data) {
19
+ if (!isObj(data) || !isStrings(data["cwds"]) || !isNum(data["byte_size"]) || typeof data["sha256"] !== "string")
20
+ return null;
21
+ const facts = parseSessionFactsDelta(data["facts"]);
22
+ return facts ? { cwds: data["cwds"], byte_size: data["byte_size"], sha256: data["sha256"], facts } : null;
23
+ }
24
+ function parseMainRead(data) {
25
+ if (!isObj(data) || !isNum(data["byte_size"]) || typeof data["sha256"] !== "string" || !isObj(data["signals"]))
26
+ return null;
27
+ const s = data["signals"];
28
+ if (!isStrings(s["session_ids"]) || !isStrings(s["cwds"]) || !isStrings(s["branches"]) || !isStrings(s["repository_urls"]))
29
+ return null;
30
+ if (!isNum(s["line_count"]) || !isNum(s["parse_error_count"]) || !isNum(s["content_record_count"]) || !isNum(s["envelope_field_hit_rate"]))
31
+ return null;
32
+ const facts = parseSessionFactsDelta(data["facts"]);
33
+ return facts ? { signals: s, byte_size: data["byte_size"], sha256: data["sha256"], facts } : null;
34
+ }
35
+ /** The sidecar's cwds, size, hash and facts contribution: recalled when its stamp is unchanged, else read (a read failure throws). */
36
+ export async function readSidecar(memo, localPath) {
37
+ const stamp = memo ? await statSourceForMemo(localPath) : null;
38
+ if (memo && stamp) {
39
+ const hit = memo.recall(localPath, stamp, ATTRIBUTION_READ_RECIPE, stamp.size, parseSidecarRead);
40
+ if (hit)
41
+ return hit;
42
+ }
43
+ else
44
+ memo?.note("stamp_missing");
45
+ const raw = await fs.readFile(localPath);
46
+ const content = raw.toString("utf8");
47
+ const read = {
48
+ cwds: extractClaudeSessionSignals(content).cwds,
49
+ byte_size: raw.byteLength,
50
+ sha256: sha256(raw),
51
+ facts: captureSessionFactsDelta("claude_code", (acc) => void observeSessionFactsContent(acc, content)),
52
+ };
53
+ if (memo && stamp) {
54
+ memo.note("fresh");
55
+ memo.remember(localPath, stamp, ATTRIBUTION_READ_RECIPE, read);
56
+ }
57
+ return read;
58
+ }
59
+ /** The main transcript's signals, size, hash and facts contribution. `{ empty: true }` when it holds only whitespace (never remembered). */
60
+ export async function readMain(memo, filePath) {
61
+ const stamp = memo ? await statSourceForMemo(filePath) : null;
62
+ if (memo && stamp) {
63
+ const hit = memo.recall(filePath, stamp, ATTRIBUTION_READ_RECIPE, stamp.size, parseMainRead);
64
+ if (hit)
65
+ return hit;
66
+ }
67
+ else
68
+ memo?.note("stamp_missing");
69
+ const raw = await fs.readFile(filePath);
70
+ const content = raw.toString("utf8");
71
+ const hash = sha256(raw);
72
+ if (content.trim() === "")
73
+ return { empty: true, byte_size: raw.byteLength, sha256: hash };
74
+ let signals;
75
+ const facts = captureSessionFactsDelta("claude_code", (acc) => {
76
+ signals = extractClaudeSessionSignals(content, acc);
77
+ observeSessionFactsFile(acc, { bytes: raw.byteLength, lines: signals.line_count });
78
+ });
79
+ const read = { signals: signals, byte_size: raw.byteLength, sha256: hash, facts };
80
+ if (memo && stamp) {
81
+ memo.note("fresh");
82
+ memo.remember(filePath, stamp, ATTRIBUTION_READ_RECIPE, read);
83
+ }
84
+ return read;
85
+ }
@@ -1,12 +1,11 @@
1
- import { finalizeSessionFacts, newSessionFactsAccumulator, observeSessionFactsContent, observeSessionFactsFile, } from "@bli-cockpit/telemetry-core";
2
- import crypto from "node:crypto";
1
+ import { finalizeSessionFacts, mergeSessionFactsDelta, newSessionFactsAccumulator, observeSessionFactsFile, } from "@bli-cockpit/telemetry-core";
3
2
  import { existsSync } from "node:fs";
4
- import fs from "node:fs/promises";
5
3
  import path from "node:path";
6
4
  import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
7
5
  import { describeError } from "../health-detail.js";
8
6
  import { isPathWithin, sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName, shortHash, } from "./attribution-core.js";
9
- import { extractClaudeSessionSignals, isSchemaDriftSuspected, streamMainSignals, } from "./claude-attribution-signals.js";
7
+ import { isSchemaDriftSuspected, streamMainSignals } from "./claude-attribution-signals.js";
8
+ import { readMain, readSidecar } from "./claude-attribution-memoized-reads.js";
10
9
  import { CLAUDE_SESSION_MAX_FILE_BYTES, } from "./claude-attribution-types.js";
11
10
  /**
12
11
  * Turns one discovered session into an attribution verdict: read (or, for an
@@ -18,7 +17,7 @@ import { CLAUDE_SESSION_MAX_FILE_BYTES, } from "./claude-attribution-types.js";
18
17
  * are counted: a sidecar rejected for a cwd mismatch is another worktree's
19
18
  * work and must not inflate this session's numbers.
20
19
  */
21
- export async function attributeOneSession(session, worktrees, collectionRoots) {
20
+ export async function attributeOneSession(session, worktrees, collectionRoots, readMemo) {
22
21
  const factsAcc = newSessionFactsAccumulator("claude_code");
23
22
  const base = createSessionBase(session);
24
23
  if (session.mainByteSize === 0) {
@@ -26,7 +25,7 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
26
25
  }
27
26
  const read = session.mainByteSize > CLAUDE_SESSION_MAX_FILE_BYTES
28
27
  ? await streamOversizedMain(session, base, factsAcc)
29
- : await readMainSignals(session, base, factsAcc);
28
+ : await readMainSignals(session, base, factsAcc, readMemo);
30
29
  if (typeof read === "string")
31
30
  return skippedResult(base, read);
32
31
  const signals = read;
@@ -54,7 +53,7 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
54
53
  extraSignals.push("main_file_oversized");
55
54
  if (isSchemaDriftSuspected(signals))
56
55
  extraSignals.push("schema_drift_suspected");
57
- const sidecarFiles = await collectSessionSidecars(session, outcome, factsAcc);
56
+ const sidecarFiles = await collectSessionSidecars(session, outcome, factsAcc, readMemo);
58
57
  return {
59
58
  ...base,
60
59
  session_facts: finalizeSessionFacts(factsAcc),
@@ -116,10 +115,10 @@ async function streamOversizedMain(session, base, factsAcc) {
116
115
  base.oversized_lines_skipped = streamed.oversizedLinesSkipped;
117
116
  return streamed.signals;
118
117
  }
119
- async function readMainSignals(session, base, factsAcc) {
120
- let raw;
118
+ async function readMainSignals(session, base, factsAcc, readMemo) {
119
+ let read;
121
120
  try {
122
- raw = await fs.readFile(session.mainFile);
121
+ read = await readMain(readMemo, session.mainFile);
123
122
  }
124
123
  catch (error) {
125
124
  console.error("[claude-attribution] session file unreadable, skipping it", JSON.stringify({
@@ -130,18 +129,12 @@ async function readMainSignals(session, base, factsAcc) {
130
129
  }));
131
130
  return "file_read_failed";
132
131
  }
133
- const content = raw.toString("utf8");
134
- base.content_hash_sha256 = sha256(raw);
135
- base.byte_size = raw.byteLength;
136
- if (content.trim() === "") {
132
+ base.content_hash_sha256 = read.sha256;
133
+ base.byte_size = read.byte_size;
134
+ if ("empty" in read)
137
135
  return "empty_file";
138
- }
139
- const signals = extractClaudeSessionSignals(content, factsAcc);
140
- observeSessionFactsFile(factsAcc, {
141
- bytes: raw.byteLength,
142
- lines: signals.line_count,
143
- });
144
- return signals;
136
+ mergeSessionFactsDelta(factsAcc, read.facts);
137
+ return read.signals;
145
138
  }
146
139
  function applySessionMetadata(base, signals) {
147
140
  const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
@@ -155,9 +148,9 @@ function applySessionMetadata(base, signals) {
155
148
  base.cwd_hash = shortHash(primaryCwd);
156
149
  }
157
150
  }
158
- async function collectSessionSidecars(session, outcome, factsAcc) {
151
+ async function collectSessionSidecars(session, outcome, factsAcc, readMemo) {
159
152
  return isRawEvidenceUploadableAttributionState(outcome.state, outcome.worktree !== null) && outcome.worktree
160
- ? await collectSidecarDiagnostics(session.sidecars, outcome.worktree, factsAcc)
153
+ ? await collectSidecarDiagnostics(session.sidecars, outcome.worktree, factsAcc, readMemo)
161
154
  : session.sidecars.map((sidecar) => ({
162
155
  local_path: sidecar.local_path,
163
156
  file_name: sidecar.file_name,
@@ -174,7 +167,7 @@ async function collectSessionSidecars(session, outcome, factsAcc) {
174
167
  * oversized sidecar is recorded with a reason and the session stays
175
168
  * harvestable.
176
169
  */
177
- async function collectSidecarDiagnostics(sidecars, worktree, facts) {
170
+ async function collectSidecarDiagnostics(sidecars, worktree, facts, readMemo) {
178
171
  const out = [];
179
172
  for (const sidecar of sidecars) {
180
173
  const entry = {
@@ -188,9 +181,9 @@ async function collectSidecarDiagnostics(sidecars, worktree, facts) {
188
181
  out.push({ ...entry, skipped_reason: "file_too_large" });
189
182
  continue;
190
183
  }
191
- let raw;
184
+ let read;
192
185
  try {
193
- raw = await fs.readFile(sidecar.local_path);
186
+ read = await readSidecar(readMemo, sidecar.local_path);
194
187
  }
195
188
  catch (error) {
196
189
  // `file_read_failed` stays on the sidecar row. Beside it: a subagent
@@ -205,8 +198,7 @@ async function collectSidecarDiagnostics(sidecars, worktree, facts) {
205
198
  out.push({ ...entry, skipped_reason: "file_read_failed" });
206
199
  continue;
207
200
  }
208
- const content = raw.toString("utf8");
209
- const sidecarCwds = extractClaudeSessionSignals(content).cwds;
201
+ const sidecarCwds = read.cwds;
210
202
  if (sidecarCwds.length > 0 &&
211
203
  !sidecarCwds.some((cwd) => isPathWithin(cwd, worktree.repo_root))) {
212
204
  out.push({ ...entry, skipped_reason: "sidecar_cwd_mismatch" });
@@ -214,11 +206,11 @@ async function collectSidecarDiagnostics(sidecars, worktree, facts) {
214
206
  }
215
207
  // Accepted: this subagent's turns are part of this session, so its tokens
216
208
  // are too. Counted only now, after the cwd check has cleared it.
217
- observeSessionFactsContent(facts, content);
209
+ mergeSessionFactsDelta(facts, read.facts);
218
210
  out.push({
219
211
  ...entry,
220
- byte_size: raw.byteLength,
221
- content_hash_sha256: sha256(raw),
212
+ byte_size: read.byte_size,
213
+ content_hash_sha256: read.sha256,
222
214
  });
223
215
  }
224
216
  return out;
@@ -233,7 +225,4 @@ function skippedResult(base, reason) {
233
225
  path_score: 0,
234
226
  worktree: null,
235
227
  };
236
- }
237
- function sha256(value) {
238
- return crypto.createHash("sha256").update(value).digest("hex");
239
228
  }
@@ -37,7 +37,7 @@ export async function scanAndAttributeClaudeSessions(options) {
37
37
  .slice(0, limit);
38
38
  const results = [];
39
39
  for (const session of sessions) {
40
- results.push(await attributeOneSession(session, options.worktrees, options.collectionRoots ?? []));
40
+ results.push(await attributeOneSession(session, options.worktrees, options.collectionRoots ?? [], options.readMemo));
41
41
  }
42
42
  const sumBy = (pick) => results.reduce((total, result) => total + pick(result), 0);
43
43
  const countState = (state) => results.filter((result) => result.state === state).length;
@@ -24,6 +24,7 @@
24
24
  * collection is done.
25
25
  */
26
26
  import { openPrepareMemo } from "./raw-evidence-prepare-memo.js";
27
+ import { openTranscriptReadMemo } from "./transcript-read-memo.js";
27
28
  import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
28
29
  import crypto from "node:crypto";
29
30
  import fs from "node:fs/promises";
@@ -65,6 +66,7 @@ export async function openCollection(context, options, places) {
65
66
  budget.images ??= options.images;
66
67
  const stagingAllowance = (budget.staging ??= await openStagingAllowance(options.stateDir, options.env ?? process.env, { statfs: options.statfs }));
67
68
  budget.prepareMemo ??= await openPrepareMemo(options.stateDir);
69
+ budget.readMemo ??= await openTranscriptReadMemo(options.stateDir, "image_scan").catch(() => undefined);
68
70
  return {
69
71
  context,
70
72
  filesDir: path.join(places.stagingDir, "files"),
@@ -9,18 +9,42 @@
9
9
  */
10
10
  import path from "node:path";
11
11
  import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
12
+ import { statSourceForMemo } from "./transcript-read-memo.js";
13
+ import { AGENT_IMAGE_MAX_BYTES } from "./agent-image-validation.js";
12
14
  import { evidenceSourceKey } from "../raw-evidence-staging.js";
13
15
  import { sha256 } from "./raw-evidence-keys.js";
14
16
  import { markBudgetCapApplied, recordScanned, } from "./raw-evidence-completeness.js";
15
17
  import { evidenceEntry } from "./raw-evidence-manifest.js";
16
18
  import { admitEvidence, stageEvidenceBytes, } from "./raw-evidence-collection-state.js";
19
+ /**
20
+ * BLI-22797: a transcript with no image candidate is remembered by its stamp, so the next tick does not open it.
21
+ * Only that answer is remembered: an image whose bytes live in another file can change while the transcript does
22
+ * not, so a transcript that offered images is always read again. Bump the version when
23
+ * `agent-image-records.ts` changes what counts as a candidate.
24
+ */
25
+ const IMAGE_SCAN_RECIPE = ["image-scan.v1", String(AGENT_IMAGE_MAX_BYTES)].join("|");
17
26
  export async function collectAgentImagesFromTranscript(collection, options) {
18
- const result = await collectAgentImageEvidenceFromJsonlFile({
19
- filePath: options.filePath,
20
- source: options.source,
21
- sessionId: options.sessionId,
22
- sidecarId: options.sidecarId,
23
- });
27
+ const memo = collection.budget.readMemo;
28
+ const stamp = memo ? await statSourceForMemo(options.filePath) : null;
29
+ const remembered = memo && stamp
30
+ ? memo.recall(options.filePath, stamp, IMAGE_SCAN_RECIPE, stamp.size, (data) => (data === "no_image_candidates" ? true : null))
31
+ : null;
32
+ if (!remembered && memo && !stamp)
33
+ memo.note("stamp_missing");
34
+ const result = remembered
35
+ ? { images: [], skipped: [] }
36
+ : await collectAgentImageEvidenceFromJsonlFile({
37
+ filePath: options.filePath,
38
+ source: options.source,
39
+ sessionId: options.sessionId,
40
+ sidecarId: options.sidecarId,
41
+ });
42
+ if (!remembered && memo && stamp) {
43
+ memo.note("fresh");
44
+ if (result.images.length === 0 && result.skipped.length === 0) {
45
+ memo.remember(options.filePath, stamp, IMAGE_SCAN_RECIPE, "no_image_candidates");
46
+ }
47
+ }
24
48
  recordScanned(collection, options.kind, result.images.length + result.skipped.length);
25
49
  for (const skipped of result.skipped) {
26
50
  collection.skipped.push({
@@ -40,9 +40,9 @@ const MEMO_FORMAT = "prepare-memo.v1";
40
40
  /** About 700 bytes an entry, so this is at most a few MB; the oldest leave first. */
41
41
  export const PREPARE_MEMO_MAX_ENTRIES = 20_000;
42
42
  /** A source not seen for this long is a deleted or rotated session. */
43
- const FORGET_AFTER_MS = 14 * 24 * 3_600_000;
43
+ export const FORGET_AFTER_MS = 14 * 24 * 3_600_000;
44
44
  /** Refresh `seen_ms` at most this often, so an unchanged tick does not rewrite the file. */
45
- const SEEN_REFRESH_MS = 24 * 3_600_000;
45
+ export const SEEN_REFRESH_MS = 24 * 3_600_000;
46
46
  const TAG = "[raw-evidence-memo]";
47
47
  export function sourceStampOf(stat) {
48
48
  return {
@@ -146,7 +146,7 @@ export async function openPrepareMemo(stateDir) {
146
146
  * use. Only those: an entry nobody recalls (a deleted or rotated session) ages out after
147
147
  * FORGET_AFTER_MS.
148
148
  */
149
- function refreshSeen(entries, recalled, now) {
149
+ export function refreshSeen(entries, recalled, now) {
150
150
  let touched = false;
151
151
  for (const key of recalled) {
152
152
  const entry = entries.get(key);
@@ -157,13 +157,13 @@ function refreshSeen(entries, recalled, now) {
157
157
  }
158
158
  return touched;
159
159
  }
160
- function trimEntries(entries, now) {
160
+ export function trimEntries(entries, now, maxEntries = PREPARE_MEMO_MAX_ENTRIES) {
161
161
  for (const [key, entry] of entries) {
162
162
  if (now - entry.seen_ms > FORGET_AFTER_MS)
163
163
  entries.delete(key);
164
164
  }
165
165
  const newestFirst = [...entries.entries()].sort((a, b) => b[1].seen_ms - a[1].seen_ms);
166
- const kept = newestFirst.slice(0, PREPARE_MEMO_MAX_ENTRIES);
166
+ const kept = newestFirst.slice(0, maxEntries);
167
167
  if (kept.length < entries.size) {
168
168
  entries.clear();
169
169
  for (const [key, entry] of kept)
@@ -106,6 +106,7 @@ export async function collectRawEvidencePack(context, options) {
106
106
  * made it possible. Counts and bytes only. Never throws: the memo is a cache.
107
107
  */
108
108
  async function reportPrepareMemo(collection) {
109
+ await collection.budget.readMemo?.settle();
109
110
  const counts = await collection.budget.prepareMemo?.settle();
110
111
  if (!counts || counts.skipped_count + counts.rebuilt_count + counts.volatile_reused_count === 0)
111
112
  return;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Remember what a transcript file said, so an unchanged file is not opened again (BLI-22797).
3
+ *
4
+ * A sync tick with nothing new still re-read every attributed session, every subagent sidecar and every
5
+ * transcript the image step scans: on the reference Mac 33 of the 37 seconds of main-thread JavaScript went to
6
+ * reading, parsing and hashing files whose size and mtime had not moved since the tick before. This is the
7
+ * sibling of the prepare memo (`raw-evidence-prepare-memo.ts`) and uses ITS stamp: size plus inode, mtime and
8
+ * ctime in nanoseconds, so an append, rewrite or replace is never mistaken for "unchanged". The entry cap, the
9
+ * `seen_ms` ageing and the atomic write are the prepare memo's own helpers.
10
+ *
11
+ * An entry is trusted only when the stamp AND the recipe match. The recipe names the rules that produced the
12
+ * remembered answer, so changing a rule rebuilds the memo once. What an entry holds is decided by its caller
13
+ * (`data`); this file never sees a byte of transcript content, and neither does the file on disk: signals,
14
+ * hashes, sizes and counts only. A memo that is lost, corrupt or stale in the safe direction costs one slow
15
+ * tick, never a wrong answer. The file path is hashed, so no path reaches the memo either.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+ import path from "node:path";
19
+ import { isMissingFileFailure } from "../health-detail.js";
20
+ import { readJsonFile, writeJsonFileAtomic } from "../local-state-files.js";
21
+ import { PREPARE_MEMO_MAX_ENTRIES, refreshSeen, trimEntries, } from "./raw-evidence-prepare-memo.js";
22
+ export { sourceStampOf, statSourceForMemo } from "./raw-evidence-prepare-memo.js";
23
+ const MEMO_FORMAT = "transcript-read-memo.v1";
24
+ const TAG = "[transcript-reads]";
25
+ export function transcriptReadMemoPath(stateDir, name) {
26
+ return path.join(stateDir, `transcript-read-memo-${name}.json`);
27
+ }
28
+ const keyOf = (filePath) => createHash("sha256").update(filePath).digest("hex").slice(0, 32);
29
+ export async function openTranscriptReadMemo(stateDir, name) {
30
+ const entries = await readEntries(stateDir, name);
31
+ let dirty = false;
32
+ let counts = { reused: 0, fresh: 0, stamp_missing: 0, bytes_not_read: 0 };
33
+ const recalled = new Set();
34
+ return {
35
+ recall(filePath, stamp, recipe, bytes, parse) {
36
+ const key = keyOf(filePath);
37
+ const entry = entries.get(key);
38
+ if (!entry || entry.recipe !== recipe || entry.size !== stamp.size || entry.identity !== stamp.identity)
39
+ return null;
40
+ const data = parse(entry.data);
41
+ if (data === null)
42
+ return null;
43
+ recalled.add(key);
44
+ counts.reused += 1;
45
+ counts.bytes_not_read += bytes;
46
+ return data;
47
+ },
48
+ remember(filePath, stamp, recipe, data, now = Date.now()) {
49
+ entries.set(keyOf(filePath), { ...stamp, recipe, data, seen_ms: now });
50
+ dirty = true;
51
+ },
52
+ note(outcome) {
53
+ if (outcome === "fresh")
54
+ counts.fresh += 1;
55
+ else
56
+ counts.stamp_missing += 1;
57
+ },
58
+ async settle(now = Date.now()) {
59
+ const done = counts;
60
+ counts = { reused: 0, fresh: 0, stamp_missing: 0, bytes_not_read: 0 };
61
+ dirty = refreshSeen(entries, recalled, now) || dirty;
62
+ recalled.clear();
63
+ let written = false;
64
+ let reason = done.reused > 0 ? "stamp_unchanged" : done.fresh > 0 ? "no_reusable_entry" : "stamp_missing";
65
+ if (dirty) {
66
+ const before = entries.size;
67
+ const kept = trimEntries(entries, now, PREPARE_MEMO_MAX_ENTRIES);
68
+ try {
69
+ await writeJsonFileAtomic(transcriptReadMemoPath(stateDir, name), { schema_version: MEMO_FORMAT, entries: Object.fromEntries(kept) });
70
+ dirty = false;
71
+ written = true;
72
+ }
73
+ catch (error) {
74
+ reason = "memo_write_failed";
75
+ console.error(TAG + " memo not written; the next tick reads these files again", JSON.stringify({ memo: name, reason, entries: before, error_name: error instanceof Error ? error.name : typeof error }));
76
+ }
77
+ }
78
+ if (done.reused + done.fresh + done.stamp_missing === 0)
79
+ return;
80
+ console.error(TAG + " tick", JSON.stringify({ memo: name, reason, reused: done.reused, read_fresh: done.fresh, stamp_missing: done.stamp_missing, bytes_not_read: done.bytes_not_read, entries: entries.size, memo_written: written }));
81
+ },
82
+ };
83
+ }
84
+ async function readEntries(stateDir, name) {
85
+ const entries = new Map();
86
+ let raw;
87
+ try {
88
+ raw = await readJsonFile(transcriptReadMemoPath(stateDir, name));
89
+ }
90
+ catch (error) {
91
+ if (!isMissingFileFailure(error))
92
+ console.error(TAG + " memo unreadable; every file is read once", JSON.stringify({ memo: name, reason: "read_memo_unreadable" }));
93
+ return entries;
94
+ }
95
+ const stored = raw;
96
+ if (stored?.schema_version !== MEMO_FORMAT || !stored.entries || typeof stored.entries !== "object")
97
+ return entries;
98
+ let dropped = 0;
99
+ for (const [key, value] of Object.entries(stored.entries)) {
100
+ const e = value;
101
+ if (e && typeof e.size === "number" && typeof e.identity === "string" && typeof e.recipe === "string" && typeof e.seen_ms === "number" && e.data !== undefined)
102
+ entries.set(key, e);
103
+ else
104
+ dropped += 1;
105
+ }
106
+ if (dropped > 0)
107
+ console.error(TAG + " memo entries dropped as malformed", JSON.stringify({ memo: name, reason: "read_memo_entries_malformed", dropped, kept: entries.size }));
108
+ return entries;
109
+ }
@@ -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.252");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.253");
25
25
  return 0;
26
26
  }
27
27
 
@@ -15,6 +15,7 @@ import { CLAUDE_CURSOR_FILENAME } from "../cursors/raw-evidence-cursor.js";
15
15
  import { clearSourceRetryFailure, recordSourceRetryFailure, } from "../spool/local-spool.js";
16
16
  import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
17
17
  import { widenForCatchUp } from "./session-sync-outside-roots.js";
18
+ import { openTranscriptReadMemo } from "../adapters/transcript-read-memo.js";
18
19
  const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
19
20
  /** The Claude scan's own default window when nothing widens it: the last 24 hours. */
20
21
  const CLAUDE_DEFAULT_WINDOW_MINUTES = 24 * 60;
@@ -51,6 +52,7 @@ export async function scanAndAttributeBothSources(options) {
51
52
  retrySinceMinutes: plan.claudeRetrySinceMinutes,
52
53
  firstRunBackfill,
53
54
  outsideRootCatchUpPending: plan.outsideRootCatchUpPending,
55
+ stateDir: options.paths.state_dir,
54
56
  });
55
57
  if (plan.codexEnabled) {
56
58
  await reconcileSourceScanRetry({
@@ -125,24 +127,33 @@ async function scanClaudeSessionsForSync(options) {
125
127
  ? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
126
128
  : undefined, options.outsideRootCatchUpPending, CLAUDE_DEFAULT_WINDOW_MINUTES);
127
129
  const projectsDir = path.join(options.homeDir, ".claude", "projects");
128
- let scan = await scanAndAttributeClaudeSessions({
129
- projectsDir,
130
- worktrees: options.worktrees,
131
- now: options.now,
132
- collectionRoots: options.collectionRoots,
133
- sinceMinutes,
134
- });
135
- if (scan.session_limit_applied) {
136
- scan = await scanAndAttributeClaudeSessions({
130
+ // BLI-22797: one memo for both passes, settled once, so an unchanged file is never opened.
131
+ const readMemo = await openTranscriptReadMemo(options.stateDir, "attribution").catch(() => undefined);
132
+ try {
133
+ let scan = await scanAndAttributeClaudeSessions({
137
134
  projectsDir,
138
135
  worktrees: options.worktrees,
139
136
  now: options.now,
140
137
  collectionRoots: options.collectionRoots,
141
138
  sinceMinutes,
142
- limit: scan.discovered_session_count,
139
+ readMemo,
143
140
  });
141
+ if (scan.session_limit_applied) {
142
+ scan = await scanAndAttributeClaudeSessions({
143
+ projectsDir,
144
+ worktrees: options.worktrees,
145
+ now: options.now,
146
+ collectionRoots: options.collectionRoots,
147
+ sinceMinutes,
148
+ limit: scan.discovered_session_count,
149
+ readMemo,
150
+ });
151
+ }
152
+ return scan;
153
+ }
154
+ finally {
155
+ await readMemo?.settle();
144
156
  }
145
- return scan;
146
157
  }
147
158
  export function sourceScanRetryReason(source, scan) {
148
159
  const reasons = new Set();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.252",
3
+ "version": "0.2.253",
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.74",
31
- "@bli-cockpit/mcp": "0.1.155",
32
- "@bli-cockpit/telemetry-core": "0.1.81"
30
+ "@bli-cockpit/memory-mcp": "0.1.75",
31
+ "@bli-cockpit/mcp": "0.1.156",
32
+ "@bli-cockpit/telemetry-core": "0.1.82"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",