@bli-cockpit/cli 0.1.6 → 0.1.8

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,535 @@
1
+ import { SECRET_FILE_SEGMENT_PATTERN, containsSecretLikeContent, } from "@bli-cockpit/telemetry-core";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import { createReadStream } from "node:fs";
5
+ import path from "node:path";
6
+ import { StringDecoder } from "node:string_decoder";
7
+ import { SESSION_FILE_UUID_PATTERN, isPathWithin, sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName, shortHash, } from "./attribution-core.js";
8
+ /**
9
+ * Deterministic Claude Code session JSONL -> repo/worktree attribution.
10
+ *
11
+ * Mirrors the Codex adapter, built on the shared attribution-core. Only
12
+ * allowlisted envelope fields are ever read from a transcript — `type`, `cwd`,
13
+ * `gitBranch`, `sessionId`, and `prRepository` on pr-link records. Message
14
+ * bodies, prompts, AI titles, tool results, attachments, and snapshots are
15
+ * never extracted, printed, or summarized.
16
+ *
17
+ * Transcript layout (verified):
18
+ * ~/.claude/projects/<path-slug>/
19
+ * <session-uuid>.jsonl main transcript
20
+ * <session-uuid>/subagents/agent-*.jsonl subagent transcripts (D3)
21
+ * `tool-results/`, `workflows/`, `memory/`, and `*.meta.json` are never walked.
22
+ */
23
+ export const CLAUDE_ATTRIBUTION_DEFAULT_SINCE_MINUTES = 24 * 60;
24
+ export const CLAUDE_ATTRIBUTION_DEFAULT_SESSION_LIMIT = 50;
25
+ // Upload cap, NOT an attribution cap (D7): an oversized main file is still
26
+ // scored and attributed via a streamed read; only the file's bytes stay local.
27
+ export const CLAUDE_SESSION_MAX_FILE_BYTES = 10 * 1024 * 1024;
28
+ export const CLAUDE_SESSION_MAX_SIDECAR_FILES = 40;
29
+ // JSONL lines holding a large tool result can be multi-MiB; a streamed read of
30
+ // an oversized main caps the per-line buffer so one giant line cannot blow
31
+ // memory. Lines over the cap are skipped + counted; the head is still guarded.
32
+ const MAX_LINE_BUFFER_BYTES = 2 * 1024 * 1024;
33
+ const SCHEMA_DRIFT_MIN_RECORDS = 20;
34
+ const SCHEMA_DRIFT_MIN_HIT_RATE = 0.5;
35
+ const CONTENT_RECORD_TYPES = new Set([
36
+ "user",
37
+ "assistant",
38
+ "system",
39
+ "attachment",
40
+ ]);
41
+ export async function scanAndAttributeClaudeSessions(options) {
42
+ const sinceMinutes = options.sinceMinutes ?? CLAUDE_ATTRIBUTION_DEFAULT_SINCE_MINUTES;
43
+ const limit = options.limit ?? CLAUDE_ATTRIBUTION_DEFAULT_SESSION_LIMIT;
44
+ const cutoffMs = options.now.getTime() - sinceMinutes * 60 * 1000;
45
+ const discovery = await discoverClaudeSessions(options.projectsDir, cutoffMs);
46
+ const sessions = discovery.sessions
47
+ .sort((a, b) => b.recencyMs - a.recencyMs)
48
+ .slice(0, limit);
49
+ const results = [];
50
+ for (const session of sessions) {
51
+ results.push(await attributeOneSession(session, options.worktrees));
52
+ }
53
+ const sumBy = (pick) => results.reduce((total, result) => total + pick(result), 0);
54
+ const countState = (state) => results.filter((result) => result.state === state).length;
55
+ return {
56
+ results,
57
+ scanned_session_count: sessions.length,
58
+ project_dirs_skipped: discovery.projectDirsSkipped,
59
+ counts: {
60
+ attributed: countState("attributed"),
61
+ ambiguous: countState("ambiguous"),
62
+ unattributed: countState("unattributed"),
63
+ skipped: countState("skipped"),
64
+ mains_oversized: results.filter((result) => result.main_file_oversized)
65
+ .length,
66
+ oversized_lines_skipped: sumBy((result) => result.oversized_lines_skipped),
67
+ sessions_schema_drift: results.filter((result) => result.signals.includes("schema_drift_suspected")).length,
68
+ sidecars_capped: sumBy((result) => result.sidecars_capped),
69
+ },
70
+ };
71
+ }
72
+ async function discoverClaudeSessions(projectsDir, cutoffMs) {
73
+ const sessions = [];
74
+ let projectDirsSkipped = 0;
75
+ let projectEntries;
76
+ try {
77
+ projectEntries = await fs.readdir(projectsDir, { withFileTypes: true });
78
+ }
79
+ catch {
80
+ return { sessions, projectDirsSkipped };
81
+ }
82
+ for (const projectEntry of projectEntries) {
83
+ if (!projectEntry.isDirectory())
84
+ continue;
85
+ // A project slug encodes the full cwd, so a repo named e.g.
86
+ // `credentials-service` poisons its slug. Never read inside such a dir, but
87
+ // count the skip so a whole repo silently missing is visible (D12). The
88
+ // slug itself is a local path encoding and never printed.
89
+ if (isSecretLikePath(projectEntry.name)) {
90
+ projectDirsSkipped += 1;
91
+ continue;
92
+ }
93
+ const projectDir = path.join(projectsDir, projectEntry.name);
94
+ let sessionEntries;
95
+ try {
96
+ sessionEntries = await fs.readdir(projectDir, { withFileTypes: true });
97
+ }
98
+ catch {
99
+ continue;
100
+ }
101
+ for (const sessionEntry of sessionEntries) {
102
+ if (!sessionEntry.isFile())
103
+ continue;
104
+ if (!SESSION_FILE_UUID_PATTERN.test(sessionEntry.name))
105
+ continue;
106
+ const mainFile = path.join(projectDir, sessionEntry.name);
107
+ let mainStat;
108
+ try {
109
+ mainStat = await fs.stat(mainFile);
110
+ }
111
+ catch {
112
+ continue;
113
+ }
114
+ const sessionUuid = sessionEntry.name.replace(/\.jsonl$/i, "");
115
+ const { sidecars, sidecarsCapped } = await discoverSidecars(path.join(projectDir, sessionUuid, "subagents"));
116
+ const recencyMs = Math.max(mainStat.mtimeMs, ...sidecars.map((sidecar) => sidecar.mtimeMs));
117
+ // D10: a session is recent if its main file OR any sidecar is in window.
118
+ if (recencyMs < cutoffMs)
119
+ continue;
120
+ sessions.push({
121
+ mainFile,
122
+ mainMtimeMs: mainStat.mtimeMs,
123
+ mainByteSize: mainStat.size,
124
+ recencyMs,
125
+ sidecars,
126
+ sidecarsCapped,
127
+ });
128
+ }
129
+ }
130
+ return { sessions, projectDirsSkipped };
131
+ }
132
+ async function discoverSidecars(subagentsDir) {
133
+ let entries;
134
+ try {
135
+ entries = await fs.readdir(subagentsDir, { withFileTypes: true });
136
+ }
137
+ catch {
138
+ return { sidecars: [], sidecarsCapped: 0 };
139
+ }
140
+ const discovered = [];
141
+ for (const entry of entries) {
142
+ // Only agent transcripts. `*.meta.json` carry operator-authored
143
+ // descriptions and are never harvested (D3).
144
+ if (!entry.isFile())
145
+ continue;
146
+ if (!entry.name.startsWith("agent-") || !entry.name.endsWith(".jsonl")) {
147
+ continue;
148
+ }
149
+ const local_path = path.join(subagentsDir, entry.name);
150
+ let stat;
151
+ try {
152
+ stat = await fs.stat(local_path);
153
+ }
154
+ catch {
155
+ continue;
156
+ }
157
+ discovered.push({
158
+ local_path,
159
+ file_name: entry.name,
160
+ mtimeMs: stat.mtimeMs,
161
+ byteSize: stat.size,
162
+ });
163
+ }
164
+ discovered.sort((a, b) => b.mtimeMs - a.mtimeMs);
165
+ const capped = Math.max(0, discovered.length - CLAUDE_SESSION_MAX_SIDECAR_FILES);
166
+ return {
167
+ sidecars: discovered.slice(0, CLAUDE_SESSION_MAX_SIDECAR_FILES),
168
+ sidecarsCapped: capped,
169
+ };
170
+ }
171
+ async function attributeOneSession(session, worktrees) {
172
+ const fileName = path.basename(session.mainFile);
173
+ const base = {
174
+ file_path: session.mainFile,
175
+ file_name: fileName,
176
+ claude_session_id: sessionIdFromFileName(fileName) ?? shortHash(session.mainFile),
177
+ cwd_basename: null,
178
+ cwd_hash: null,
179
+ session_file_mtime: new Date(session.mainMtimeMs).toISOString(),
180
+ session_file_mtime_ms: session.mainMtimeMs,
181
+ session_recency_ms: session.recencyMs,
182
+ byte_size: session.mainByteSize,
183
+ content_hash_sha256: null,
184
+ main_file_oversized: false,
185
+ oversized_lines_skipped: 0,
186
+ sidecars_capped: session.sidecarsCapped,
187
+ sidecar_files: [],
188
+ };
189
+ if (isSecretLikePath(fileName)) {
190
+ return skippedResult(base, "secret_like_file_name");
191
+ }
192
+ if (session.mainByteSize === 0) {
193
+ return skippedResult(base, "empty_file");
194
+ }
195
+ let signals;
196
+ let secretLike;
197
+ const oversized = session.mainByteSize > CLAUDE_SESSION_MAX_FILE_BYTES;
198
+ if (oversized) {
199
+ // D7: stream the oversized main so signals + secret guard still run, but the
200
+ // bytes themselves are never uploaded (collection skips it as file_too_large).
201
+ let streamed;
202
+ try {
203
+ streamed = await streamMainSignals(session.mainFile);
204
+ }
205
+ catch {
206
+ // A read race on one oversized main must not abort the whole scan; honor
207
+ // the per-file skip contract.
208
+ return skippedResult(base, "file_read_failed");
209
+ }
210
+ signals = streamed.signals;
211
+ secretLike = streamed.secretLike;
212
+ base.content_hash_sha256 = streamed.contentHash;
213
+ base.byte_size = streamed.byteSize;
214
+ base.main_file_oversized = true;
215
+ base.oversized_lines_skipped = streamed.oversizedLinesSkipped;
216
+ }
217
+ else {
218
+ let raw;
219
+ try {
220
+ raw = await fs.readFile(session.mainFile);
221
+ }
222
+ catch {
223
+ return skippedResult(base, "file_read_failed");
224
+ }
225
+ const content = raw.toString("utf8");
226
+ base.content_hash_sha256 = sha256(raw);
227
+ base.byte_size = raw.byteLength;
228
+ if (content.trim() === "") {
229
+ return skippedResult(base, "empty_file");
230
+ }
231
+ signals = extractClaudeSessionSignals(content);
232
+ secretLike = containsSecretLikeContent(content);
233
+ }
234
+ if (secretLike) {
235
+ return skippedResult(base, "secret_like_content_guard");
236
+ }
237
+ const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
238
+ if (metaSessionId) {
239
+ base.claude_session_id = metaSessionId;
240
+ }
241
+ const primaryCwd = signals.cwds[0] ?? null;
242
+ if (primaryCwd) {
243
+ base.cwd_basename = path.basename(primaryCwd) || null;
244
+ base.cwd_hash = shortHash(primaryCwd);
245
+ }
246
+ if (signals.line_count === 0 || signals.parse_error_count === signals.line_count) {
247
+ return {
248
+ ...base,
249
+ state: "unattributed",
250
+ reason: "jsonl_parse_failed",
251
+ signals: [],
252
+ attribution_score: 0,
253
+ path_score: 0,
254
+ worktree: null,
255
+ };
256
+ }
257
+ const outcome = scoreSignalsAgainstWorktrees({
258
+ cwds: signals.cwds,
259
+ workspaceRoots: [],
260
+ originUrls: signals.repository_urls,
261
+ branches: signals.branches,
262
+ headShas: [],
263
+ }, worktrees, { originLabel: "pr_repo_match" });
264
+ const extraSignals = [];
265
+ if (base.main_file_oversized)
266
+ extraSignals.push("main_file_oversized");
267
+ if (isSchemaDriftSuspected(signals))
268
+ extraSignals.push("schema_drift_suspected");
269
+ const sidecarFiles = outcome.state === "attributed" && outcome.worktree
270
+ ? await collectSidecarDiagnostics(session.sidecars, outcome.worktree)
271
+ : session.sidecars.map((sidecar) => ({
272
+ local_path: sidecar.local_path,
273
+ file_name: sidecar.file_name,
274
+ byte_size: sidecar.byteSize,
275
+ content_hash_sha256: null,
276
+ skipped_reason: null,
277
+ }));
278
+ return {
279
+ ...base,
280
+ state: outcome.state,
281
+ reason: outcome.reason,
282
+ signals: [...outcome.signals, ...extraSignals],
283
+ attribution_score: outcome.attribution_score,
284
+ path_score: outcome.path_score,
285
+ worktree: outcome.worktree,
286
+ sidecar_files: sidecarFiles,
287
+ };
288
+ }
289
+ /**
290
+ * Validates and content-addresses an attributed session's sidecars. Each
291
+ * sidecar's envelope cwd must resolve within the attributed worktree root, or
292
+ * it is skipped `sidecar_cwd_mismatch` rather than uploaded under the wrong
293
+ * work context. The secret guard and size cap run here too; a guarded or
294
+ * oversized sidecar is recorded with a reason and the session stays
295
+ * harvestable.
296
+ */
297
+ async function collectSidecarDiagnostics(sidecars, worktree) {
298
+ const out = [];
299
+ for (const sidecar of sidecars) {
300
+ const entry = {
301
+ local_path: sidecar.local_path,
302
+ file_name: sidecar.file_name,
303
+ byte_size: sidecar.byteSize,
304
+ content_hash_sha256: null,
305
+ skipped_reason: null,
306
+ };
307
+ if (isSecretLikePath(sidecar.file_name)) {
308
+ out.push({ ...entry, skipped_reason: "secret_like_file_name" });
309
+ continue;
310
+ }
311
+ if (sidecar.byteSize > CLAUDE_SESSION_MAX_FILE_BYTES) {
312
+ out.push({ ...entry, skipped_reason: "file_too_large" });
313
+ continue;
314
+ }
315
+ let raw;
316
+ try {
317
+ raw = await fs.readFile(sidecar.local_path);
318
+ }
319
+ catch {
320
+ out.push({ ...entry, skipped_reason: "file_read_failed" });
321
+ continue;
322
+ }
323
+ const content = raw.toString("utf8");
324
+ if (containsSecretLikeContent(content)) {
325
+ out.push({ ...entry, skipped_reason: "secret_like_content_guard" });
326
+ continue;
327
+ }
328
+ const sidecarCwds = extractClaudeSessionSignals(content).cwds;
329
+ if (sidecarCwds.length > 0 &&
330
+ !sidecarCwds.some((cwd) => isPathWithin(cwd, worktree.repo_root))) {
331
+ out.push({ ...entry, skipped_reason: "sidecar_cwd_mismatch" });
332
+ continue;
333
+ }
334
+ out.push({
335
+ ...entry,
336
+ byte_size: raw.byteLength,
337
+ content_hash_sha256: sha256(raw),
338
+ });
339
+ }
340
+ return out;
341
+ }
342
+ /**
343
+ * Allowlisted signal extraction. Reads only `type`, `cwd`, `gitBranch`,
344
+ * `sessionId`, and `prRepository` (pr-link records). Structurally identical to
345
+ * the Codex extractor, with the allowlist enforced by construction — no generic
346
+ * record walk that could surface message bodies.
347
+ */
348
+ export function extractClaudeSessionSignals(content) {
349
+ const accumulator = createSignalAccumulator();
350
+ for (const line of content.split("\n")) {
351
+ accumulator.processLine(line);
352
+ }
353
+ return accumulator.finalize();
354
+ }
355
+ function createSignalAccumulator() {
356
+ const sessionIds = new Set();
357
+ const cwds = new Set();
358
+ const branches = new Set();
359
+ const repositoryUrls = new Set();
360
+ let lineCount = 0;
361
+ let parseErrorCount = 0;
362
+ let contentRecordCount = 0;
363
+ let contentRecordsWithEnvelope = 0;
364
+ return {
365
+ processLine(line) {
366
+ if (!line.trim())
367
+ return;
368
+ lineCount += 1;
369
+ let record;
370
+ try {
371
+ record = JSON.parse(line);
372
+ }
373
+ catch {
374
+ parseErrorCount += 1;
375
+ return;
376
+ }
377
+ if (!record || typeof record !== "object")
378
+ return;
379
+ const entry = record;
380
+ const type = typeof entry["type"] === "string" ? entry["type"] : "";
381
+ const cwd = stringOrNull(entry["cwd"]);
382
+ const sessionId = stringOrNull(entry["sessionId"]);
383
+ if (cwd)
384
+ cwds.add(cwd);
385
+ if (sessionId)
386
+ sessionIds.add(sessionId);
387
+ const branch = stringOrNull(entry["gitBranch"]);
388
+ if (branch)
389
+ branches.add(branch);
390
+ if (type === "pr-link") {
391
+ const repository = stringOrNull(entry["prRepository"]);
392
+ if (repository)
393
+ repositoryUrls.add(normalizePrRepository(repository));
394
+ }
395
+ // D11 canary input: among content records, how many carry both cwd and
396
+ // sessionId. gitBranch is intentionally excluded — it is legitimately
397
+ // absent in non-git directories and would false-positive desktop sessions.
398
+ if (CONTENT_RECORD_TYPES.has(type)) {
399
+ contentRecordCount += 1;
400
+ if (cwd && sessionId)
401
+ contentRecordsWithEnvelope += 1;
402
+ }
403
+ },
404
+ finalize() {
405
+ return {
406
+ session_ids: [...sessionIds],
407
+ cwds: [...cwds],
408
+ branches: [...branches],
409
+ repository_urls: [...repositoryUrls],
410
+ line_count: lineCount,
411
+ parse_error_count: parseErrorCount,
412
+ content_record_count: contentRecordCount,
413
+ envelope_field_hit_rate: contentRecordCount === 0
414
+ ? 1
415
+ : contentRecordsWithEnvelope / contentRecordCount,
416
+ };
417
+ },
418
+ };
419
+ }
420
+ function isSchemaDriftSuspected(signals) {
421
+ // Only meaningful with enough records to be a signal, not noise.
422
+ if (signals.line_count < SCHEMA_DRIFT_MIN_RECORDS)
423
+ return false;
424
+ // A `type`-field rename yields zero recognized content records, which would
425
+ // otherwise leave the hit-rate defaulted to 1 and hide the most structural
426
+ // drift the canary exists to catch — flag a record-heavy file with no
427
+ // recognizable content records too.
428
+ if (signals.content_record_count === 0)
429
+ return true;
430
+ return signals.envelope_field_hit_rate < SCHEMA_DRIFT_MIN_HIT_RATE;
431
+ }
432
+ async function streamMainSignals(filePath) {
433
+ const accumulator = createSignalAccumulator();
434
+ const hash = crypto.createHash("sha256");
435
+ let secretLike = false;
436
+ let oversizedLinesSkipped = 0;
437
+ let byteSize = 0;
438
+ let pending = "";
439
+ let pendingTruncated = false;
440
+ const flushCompletedLine = (lineText) => {
441
+ if (pendingTruncated) {
442
+ // The line exceeded the buffer; guard the head we retained, count it, drop.
443
+ oversizedLinesSkipped += 1;
444
+ if (containsSecretLikeContent(pending))
445
+ secretLike = true;
446
+ pending = "";
447
+ pendingTruncated = false;
448
+ return;
449
+ }
450
+ const full = pending + lineText;
451
+ pending = "";
452
+ if (containsSecretLikeContent(full))
453
+ secretLike = true;
454
+ accumulator.processLine(full);
455
+ };
456
+ // StringDecoder buffers an incomplete multibyte char across chunk boundaries
457
+ // so a UTF-8 character split by the read boundary never decodes to a fragment
458
+ // that could weaken the secret guard or corrupt a cwd signal. The content
459
+ // hash is over raw bytes (below) and is unaffected either way.
460
+ const decoder = new StringDecoder("utf8");
461
+ const consumeText = (text) => {
462
+ let remaining = text;
463
+ let newlineIndex = remaining.indexOf("\n");
464
+ while (newlineIndex !== -1) {
465
+ flushCompletedLine(remaining.slice(0, newlineIndex));
466
+ remaining = remaining.slice(newlineIndex + 1);
467
+ newlineIndex = remaining.indexOf("\n");
468
+ }
469
+ if (pendingTruncated)
470
+ return; // already over buffer; ignore until newline
471
+ if (pending.length + remaining.length > MAX_LINE_BUFFER_BYTES) {
472
+ pending = (pending + remaining).slice(0, MAX_LINE_BUFFER_BYTES);
473
+ pendingTruncated = true;
474
+ }
475
+ else {
476
+ pending += remaining;
477
+ }
478
+ };
479
+ await new Promise((resolve, reject) => {
480
+ const stream = createReadStream(filePath);
481
+ stream.on("data", (chunk) => {
482
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
483
+ hash.update(buffer);
484
+ byteSize += buffer.byteLength;
485
+ consumeText(decoder.write(buffer));
486
+ });
487
+ stream.on("end", () => {
488
+ const tail = decoder.end();
489
+ if (tail)
490
+ consumeText(tail);
491
+ if (pending.length > 0 || pendingTruncated)
492
+ flushCompletedLine("");
493
+ resolve();
494
+ });
495
+ stream.on("error", reject);
496
+ });
497
+ return {
498
+ signals: accumulator.finalize(),
499
+ secretLike,
500
+ oversizedLinesSkipped,
501
+ contentHash: hash.digest("hex"),
502
+ byteSize,
503
+ };
504
+ }
505
+ function normalizePrRepository(repository) {
506
+ const trimmed = repository
507
+ .trim()
508
+ .replace(/\.git$/i, "")
509
+ .replace(/^https?:\/\//i, "")
510
+ .replace(/^github\.com\//i, "")
511
+ .replace(/^\/+|\/+$/g, "")
512
+ .toLowerCase();
513
+ // GitHub-only by construction (D5); a non-GitHub origin simply never matches.
514
+ return `github.com/${trimmed}`;
515
+ }
516
+ function skippedResult(base, reason) {
517
+ return {
518
+ ...base,
519
+ state: "skipped",
520
+ reason,
521
+ signals: [],
522
+ attribution_score: 0,
523
+ path_score: 0,
524
+ worktree: null,
525
+ };
526
+ }
527
+ function stringOrNull(value) {
528
+ return typeof value === "string" && value.trim() ? value.trim() : null;
529
+ }
530
+ function isSecretLikePath(value) {
531
+ return SECRET_FILE_SEGMENT_PATTERN.test(value);
532
+ }
533
+ function sha256(value) {
534
+ return crypto.createHash("sha256").update(value).digest("hex");
535
+ }