@bli-cockpit/cli 0.2.53 → 0.2.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/adapters/attribution-core-fallbacks.js +247 -0
  2. package/dist/adapters/attribution-core-paths.js +182 -0
  3. package/dist/adapters/attribution-core-score.js +159 -0
  4. package/dist/adapters/attribution-core-types.js +13 -0
  5. package/dist/adapters/attribution-core.js +13 -565
  6. package/dist/adapters/claude-attribution-discovery.js +186 -0
  7. package/dist/adapters/claude-attribution-score.js +204 -0
  8. package/dist/adapters/claude-attribution-signals.js +180 -0
  9. package/dist/adapters/claude-attribution-types.js +25 -0
  10. package/dist/adapters/claude-attribution.js +14 -569
  11. package/dist/commands/doctor-access.js +129 -0
  12. package/dist/commands/doctor-pipeline.js +326 -0
  13. package/dist/commands/doctor-registration.js +105 -0
  14. package/dist/commands/doctor-report.js +111 -0
  15. package/dist/commands/doctor-update.js +120 -0
  16. package/dist/commands/doctor.js +8 -753
  17. package/dist/commands/heartbeat.js +8 -0
  18. package/dist/commands/jarvis-contracts.js +8 -0
  19. package/dist/commands/jarvis-render.js +413 -0
  20. package/dist/commands/jarvis-turn.js +305 -0
  21. package/dist/commands/jarvis.js +23 -698
  22. package/dist/commands/local-args-collector-setup.js +250 -0
  23. package/dist/commands/local-args-collector-status.js +227 -0
  24. package/dist/commands/local-args-collector-work.js +175 -0
  25. package/dist/commands/local-args-collector.js +19 -624
  26. package/dist/commands/local-args-tower-admin.js +456 -0
  27. package/dist/commands/local-args-tower-chat.js +194 -0
  28. package/dist/commands/local-args-tower-pages.js +314 -0
  29. package/dist/commands/local-args-tower.js +13 -880
  30. package/dist/commands/local-help.js +10 -2
  31. package/dist/commands/onboard-completion.js +136 -0
  32. package/dist/commands/onboard-flows.js +165 -0
  33. package/dist/commands/onboard-setup.js +102 -0
  34. package/dist/commands/onboard.js +5 -392
  35. package/dist/commands/public-root.js +1 -1
  36. package/dist/commands/session-sync-counters.js +55 -0
  37. package/dist/commands/session-sync-health.js +8 -1
  38. package/dist/commands/session-sync-plan.js +47 -7
  39. package/dist/commands/session-sync-scan.js +4 -4
  40. package/dist/commands/session-sync.js +6 -0
  41. package/dist/commands/settings-render.js +27 -0
  42. package/dist/commands/sync-followups.js +5 -1
  43. package/dist/commands/sync.js +5 -1
  44. package/dist/commands/team-device-reasons.js +16 -0
  45. package/dist/commands/team.js +87 -7
  46. package/dist/evidence-upload-client.js +14 -763
  47. package/dist/evidence-upload-object.js +181 -0
  48. package/dist/evidence-upload-plan.js +233 -0
  49. package/dist/evidence-upload-terminal.js +309 -0
  50. package/dist/evidence-upload-transport.js +104 -0
  51. package/dist/spool/local-spool-io.js +122 -0
  52. package/dist/spool/local-spool-mutations.js +174 -0
  53. package/dist/spool/local-spool-parse.js +143 -0
  54. package/dist/spool/local-spool-types.js +22 -0
  55. package/dist/spool/local-spool.js +20 -426
  56. package/dist/upload-evidence-delivery-offer.js +144 -0
  57. package/dist/upload-evidence-delivery-reconcile.js +134 -0
  58. package/dist/upload-evidence-delivery-summary.js +205 -0
  59. package/dist/upload-evidence-delivery.js +12 -482
  60. package/package.json +3 -3
@@ -0,0 +1,186 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { SESSION_FILE_UUID_PATTERN } from "./attribution-core.js";
4
+ import { describeError } from "../health-detail.js";
5
+ import { CLAUDE_SESSION_MAX_SIDECAR_FILES, } from "./claude-attribution-types.js";
6
+ /**
7
+ * Walks `~/.claude/projects/<path-slug>/` for session mains and their
8
+ * `subagents/agent-*.jsonl` sidecars, within the D10 recency window. Purely a
9
+ * filesystem walk — nothing here reads a transcript's content.
10
+ */
11
+ export async function discoverClaudeSessions(projectsDir, cutoffMs) {
12
+ const sessions = [];
13
+ const projectDirsSkipped = 0;
14
+ let projectDirReadFailedCount = 0;
15
+ let sessionStatFailedCount = 0;
16
+ let sidecarDirReadFailedCount = 0;
17
+ let sidecarStatFailedCount = 0;
18
+ // Same shape as the Codex walk: first reason plus counts, once. Per-file
19
+ // lines across a store of thousands would be their own silence (BLI-3238).
20
+ let firstDirectoryFailure = null;
21
+ let firstStatFailure = null;
22
+ let projectEntries;
23
+ try {
24
+ projectEntries = await fs.readdir(projectsDir, { withFileTypes: true });
25
+ }
26
+ catch (error) {
27
+ // The whole projects root. Absent means Claude Code has never run here;
28
+ // anything else means every Claude session on this machine is invisible
29
+ // and the scan still reports a clean zero.
30
+ if (!isMissingPathError(error)) {
31
+ console.error("[claude-attribution] Claude projects root unreadable; no sessions can be seen", JSON.stringify({
32
+ reason: "projects_root_unreadable",
33
+ ...describeError(error),
34
+ }));
35
+ }
36
+ return {
37
+ sessions,
38
+ projectDirsSkipped,
39
+ projectDirReadFailedCount: isMissingPathError(error) ? 0 : 1,
40
+ sessionStatFailedCount,
41
+ sidecarDirReadFailedCount,
42
+ sidecarStatFailedCount,
43
+ };
44
+ }
45
+ for (const projectEntry of projectEntries) {
46
+ if (!projectEntry.isDirectory())
47
+ continue;
48
+ const projectDir = path.join(projectsDir, projectEntry.name);
49
+ let sessionEntries;
50
+ try {
51
+ sessionEntries = await fs.readdir(projectDir, { withFileTypes: true });
52
+ }
53
+ catch (error) {
54
+ if (!isMissingPathError(error)) {
55
+ projectDirReadFailedCount += 1;
56
+ firstDirectoryFailure ??= describeError(error);
57
+ }
58
+ continue;
59
+ }
60
+ for (const sessionEntry of sessionEntries) {
61
+ if (!sessionEntry.isFile())
62
+ continue;
63
+ if (!SESSION_FILE_UUID_PATTERN.test(sessionEntry.name))
64
+ continue;
65
+ const mainFile = path.join(projectDir, sessionEntry.name);
66
+ let mainStat;
67
+ try {
68
+ mainStat = await fs.stat(mainFile);
69
+ }
70
+ catch (error) {
71
+ sessionStatFailedCount += 1;
72
+ firstStatFailure ??= describeError(error);
73
+ continue;
74
+ }
75
+ const sessionUuid = sessionEntry.name.replace(/\.jsonl$/i, "");
76
+ const sidecarDiscovery = await discoverSidecars(path.join(projectDir, sessionUuid, "subagents"));
77
+ const { sidecars, sidecarsCapped } = sidecarDiscovery;
78
+ sidecarDirReadFailedCount += sidecarDiscovery.dirReadFailedCount;
79
+ sidecarStatFailedCount += sidecarDiscovery.statFailedCount;
80
+ const recencyMs = Math.max(mainStat.mtimeMs, ...sidecars.map((sidecar) => sidecar.mtimeMs));
81
+ // D10: a session is recent if its main file OR any sidecar is in window.
82
+ if (recencyMs < cutoffMs)
83
+ continue;
84
+ sessions.push({
85
+ mainFile,
86
+ mainMtimeMs: mainStat.mtimeMs,
87
+ mainByteSize: mainStat.size,
88
+ recencyMs,
89
+ sidecars,
90
+ sidecarsCapped,
91
+ });
92
+ }
93
+ }
94
+ if (projectDirReadFailedCount > 0 || sessionStatFailedCount > 0) {
95
+ console.error("[claude-attribution] Claude sessions were invisible to the walk", JSON.stringify({
96
+ reason: "session_discovery_incomplete",
97
+ project_dir_read_failed_count: projectDirReadFailedCount,
98
+ session_stat_failed_count: sessionStatFailedCount,
99
+ found_session_count: sessions.length,
100
+ ...(firstDirectoryFailure
101
+ ? { first_directory_failure: firstDirectoryFailure }
102
+ : {}),
103
+ ...(firstStatFailure ? { first_stat_failure: firstStatFailure } : {}),
104
+ }));
105
+ }
106
+ return {
107
+ sessions,
108
+ projectDirsSkipped,
109
+ projectDirReadFailedCount,
110
+ sessionStatFailedCount,
111
+ sidecarDirReadFailedCount,
112
+ sidecarStatFailedCount,
113
+ };
114
+ }
115
+ async function discoverSidecars(subagentsDir) {
116
+ let entries;
117
+ try {
118
+ entries = await fs.readdir(subagentsDir, { withFileTypes: true });
119
+ }
120
+ catch (error) {
121
+ // Most sessions have no subagents at all, so absent is quiet. Anything
122
+ // else means the session is collected WITHOUT its subagent transcripts and
123
+ // nothing downstream can tell that from a session that had none.
124
+ if (!isMissingPathError(error)) {
125
+ console.error("[claude-attribution] subagent folder unreadable; sidecars omitted from this session", JSON.stringify({
126
+ reason: "sidecar_dir_unreadable",
127
+ ...describeError(error),
128
+ }));
129
+ }
130
+ return {
131
+ sidecars: [],
132
+ sidecarsCapped: 0,
133
+ dirReadFailedCount: isMissingPathError(error) ? 0 : 1,
134
+ statFailedCount: 0,
135
+ };
136
+ }
137
+ const discovered = [];
138
+ let statFailedCount = 0;
139
+ let firstSidecarStatFailure = null;
140
+ for (const entry of entries) {
141
+ // Only agent transcripts. `*.meta.json` carry operator-authored
142
+ // descriptions and are never harvested (D3).
143
+ if (!entry.isFile())
144
+ continue;
145
+ if (!entry.name.startsWith("agent-") || !entry.name.endsWith(".jsonl")) {
146
+ continue;
147
+ }
148
+ const local_path = path.join(subagentsDir, entry.name);
149
+ let stat;
150
+ try {
151
+ stat = await fs.stat(local_path);
152
+ }
153
+ catch (error) {
154
+ statFailedCount += 1;
155
+ firstSidecarStatFailure ??= describeError(error);
156
+ continue;
157
+ }
158
+ discovered.push({
159
+ local_path,
160
+ file_name: entry.name,
161
+ mtimeMs: stat.mtimeMs,
162
+ byteSize: stat.size,
163
+ });
164
+ }
165
+ if (statFailedCount > 0) {
166
+ console.error("[claude-attribution] subagent transcripts skipped", JSON.stringify({
167
+ reason: "sidecar_stat_failed",
168
+ stat_failed_count: statFailedCount,
169
+ found_sidecar_count: discovered.length,
170
+ ...firstSidecarStatFailure,
171
+ }));
172
+ }
173
+ discovered.sort((a, b) => b.mtimeMs - a.mtimeMs);
174
+ const capped = Math.max(0, discovered.length - CLAUDE_SESSION_MAX_SIDECAR_FILES);
175
+ return {
176
+ sidecars: discovered.slice(0, CLAUDE_SESSION_MAX_SIDECAR_FILES),
177
+ sidecarsCapped: capped,
178
+ dirReadFailedCount: 0,
179
+ statFailedCount,
180
+ };
181
+ }
182
+ function isMissingPathError(error) {
183
+ return (error instanceof Error &&
184
+ "code" in error &&
185
+ error.code === "ENOENT");
186
+ }
@@ -0,0 +1,204 @@
1
+ import crypto from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
6
+ import { describeError } from "../health-detail.js";
7
+ import { isPathWithin, sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName, shortHash, } from "./attribution-core.js";
8
+ import { extractClaudeSessionSignals, isSchemaDriftSuspected, streamMainSignals, } from "./claude-attribution-signals.js";
9
+ import { CLAUDE_SESSION_MAX_FILE_BYTES, } from "./claude-attribution-types.js";
10
+ /**
11
+ * Turns one discovered session into an attribution verdict: read (or, for an
12
+ * oversized main, stream) its signals, score them against the known
13
+ * worktrees, and validate its sidecars against the winning worktree.
14
+ */
15
+ export async function attributeOneSession(session, worktrees, collectionRoots) {
16
+ const fileName = path.basename(session.mainFile);
17
+ const base = {
18
+ file_path: session.mainFile,
19
+ file_name: fileName,
20
+ claude_session_id: sessionIdFromFileName(fileName) ?? shortHash(session.mainFile),
21
+ cwd_basename: null,
22
+ cwd_hash: null,
23
+ session_file_mtime: new Date(session.mainMtimeMs).toISOString(),
24
+ session_file_mtime_ms: session.mainMtimeMs,
25
+ session_recency_ms: session.recencyMs,
26
+ byte_size: session.mainByteSize,
27
+ content_hash_sha256: null,
28
+ main_file_oversized: false,
29
+ oversized_lines_skipped: 0,
30
+ sidecars_capped: session.sidecarsCapped,
31
+ sidecar_files: [],
32
+ };
33
+ if (session.mainByteSize === 0) {
34
+ return skippedResult(base, "empty_file");
35
+ }
36
+ let signals;
37
+ const oversized = session.mainByteSize > CLAUDE_SESSION_MAX_FILE_BYTES;
38
+ if (oversized) {
39
+ // D7: stream the oversized main so metadata signals remain available. The
40
+ // bytes themselves are not uploaded until the collection ceiling changes.
41
+ let streamed;
42
+ try {
43
+ streamed = await streamMainSignals(session.mainFile);
44
+ }
45
+ catch (error) {
46
+ // A read race on one oversized main must not abort the whole scan; honor
47
+ // the per-file skip contract. Per session, so worth a line each time:
48
+ // this is a whole session nobody will ever coach on (BLI-3238).
49
+ console.error("[claude-attribution] oversized session could not be streamed, skipping it", JSON.stringify({
50
+ reason: "file_read_failed",
51
+ stage: "stream_oversized_main",
52
+ byte_size: session.mainByteSize,
53
+ ...describeError(error),
54
+ }));
55
+ return skippedResult(base, "file_read_failed");
56
+ }
57
+ signals = streamed.signals;
58
+ base.content_hash_sha256 = streamed.contentHash;
59
+ base.byte_size = streamed.byteSize;
60
+ base.main_file_oversized = true;
61
+ base.oversized_lines_skipped = streamed.oversizedLinesSkipped;
62
+ }
63
+ else {
64
+ let raw;
65
+ try {
66
+ raw = await fs.readFile(session.mainFile);
67
+ }
68
+ catch (error) {
69
+ console.error("[claude-attribution] session file unreadable, skipping it", JSON.stringify({
70
+ reason: "file_read_failed",
71
+ stage: "read_main",
72
+ byte_size: session.mainByteSize,
73
+ ...describeError(error),
74
+ }));
75
+ return skippedResult(base, "file_read_failed");
76
+ }
77
+ const content = raw.toString("utf8");
78
+ base.content_hash_sha256 = sha256(raw);
79
+ base.byte_size = raw.byteLength;
80
+ if (content.trim() === "") {
81
+ return skippedResult(base, "empty_file");
82
+ }
83
+ signals = extractClaudeSessionSignals(content);
84
+ }
85
+ const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
86
+ if (metaSessionId) {
87
+ base.claude_session_id = metaSessionId;
88
+ }
89
+ const primaryCwd = signals.cwds[0] ?? null;
90
+ if (primaryCwd) {
91
+ base.cwd_basename = path.basename(primaryCwd) || null;
92
+ base.cwd_hash = shortHash(primaryCwd);
93
+ }
94
+ if (signals.line_count === 0 || signals.parse_error_count === signals.line_count) {
95
+ return {
96
+ ...base,
97
+ state: "unattributed",
98
+ reason: "jsonl_parse_failed",
99
+ signals: [],
100
+ attribution_score: 0,
101
+ path_score: 0,
102
+ worktree: null,
103
+ };
104
+ }
105
+ const outcome = scoreSignalsAgainstWorktrees({
106
+ cwds: signals.cwds,
107
+ workspaceRoots: [],
108
+ originUrls: signals.repository_urls,
109
+ branches: signals.branches,
110
+ headShas: [],
111
+ }, worktrees, { collectionRoots, originLabel: "pr_repo_match", pathExists: existsSync });
112
+ const extraSignals = [];
113
+ if (base.main_file_oversized)
114
+ extraSignals.push("main_file_oversized");
115
+ if (isSchemaDriftSuspected(signals))
116
+ extraSignals.push("schema_drift_suspected");
117
+ const sidecarFiles = isRawEvidenceUploadableAttributionState(outcome.state, outcome.worktree !== null) && outcome.worktree
118
+ ? await collectSidecarDiagnostics(session.sidecars, outcome.worktree)
119
+ : session.sidecars.map((sidecar) => ({
120
+ local_path: sidecar.local_path,
121
+ file_name: sidecar.file_name,
122
+ byte_size: sidecar.byteSize,
123
+ content_hash_sha256: null,
124
+ skipped_reason: null,
125
+ }));
126
+ return {
127
+ ...base,
128
+ state: outcome.state,
129
+ reason: outcome.reason,
130
+ signals: [...outcome.signals, ...extraSignals],
131
+ attribution_score: outcome.attribution_score,
132
+ path_score: outcome.path_score,
133
+ worktree: outcome.worktree,
134
+ sidecar_files: sidecarFiles,
135
+ };
136
+ }
137
+ /**
138
+ * Validates and content-addresses an attributed session's sidecars. Each
139
+ * sidecar's envelope cwd must resolve within the attributed worktree root, or
140
+ * it is skipped `sidecar_cwd_mismatch` rather than uploaded under the wrong
141
+ * work context. The secret guard and size cap run here too; a guarded or
142
+ * oversized sidecar is recorded with a reason and the session stays
143
+ * harvestable.
144
+ */
145
+ async function collectSidecarDiagnostics(sidecars, worktree) {
146
+ const out = [];
147
+ for (const sidecar of sidecars) {
148
+ const entry = {
149
+ local_path: sidecar.local_path,
150
+ file_name: sidecar.file_name,
151
+ byte_size: sidecar.byteSize,
152
+ content_hash_sha256: null,
153
+ skipped_reason: null,
154
+ };
155
+ if (sidecar.byteSize > CLAUDE_SESSION_MAX_FILE_BYTES) {
156
+ out.push({ ...entry, skipped_reason: "file_too_large" });
157
+ continue;
158
+ }
159
+ let raw;
160
+ try {
161
+ raw = await fs.readFile(sidecar.local_path);
162
+ }
163
+ catch (error) {
164
+ // `file_read_failed` stays on the sidecar row. Beside it: a subagent
165
+ // transcript that was stat'd successfully seconds ago and now will not
166
+ // read is a race worth being able to recognise (BLI-3238).
167
+ console.error("[claude-attribution] subagent transcript unreadable, skipping it", JSON.stringify({
168
+ reason: "file_read_failed",
169
+ stage: "read_sidecar",
170
+ byte_size: sidecar.byteSize,
171
+ ...describeError(error),
172
+ }));
173
+ out.push({ ...entry, skipped_reason: "file_read_failed" });
174
+ continue;
175
+ }
176
+ const content = raw.toString("utf8");
177
+ const sidecarCwds = extractClaudeSessionSignals(content).cwds;
178
+ if (sidecarCwds.length > 0 &&
179
+ !sidecarCwds.some((cwd) => isPathWithin(cwd, worktree.repo_root))) {
180
+ out.push({ ...entry, skipped_reason: "sidecar_cwd_mismatch" });
181
+ continue;
182
+ }
183
+ out.push({
184
+ ...entry,
185
+ byte_size: raw.byteLength,
186
+ content_hash_sha256: sha256(raw),
187
+ });
188
+ }
189
+ return out;
190
+ }
191
+ function skippedResult(base, reason) {
192
+ return {
193
+ ...base,
194
+ state: "skipped",
195
+ reason,
196
+ signals: [],
197
+ attribution_score: 0,
198
+ path_score: 0,
199
+ worktree: null,
200
+ };
201
+ }
202
+ function sha256(value) {
203
+ return crypto.createHash("sha256").update(value).digest("hex");
204
+ }
@@ -0,0 +1,180 @@
1
+ import crypto from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { StringDecoder } from "node:string_decoder";
4
+ import { CONTENT_RECORD_TYPES, MAX_LINE_BUFFER_BYTES, SCHEMA_DRIFT_MIN_HIT_RATE, SCHEMA_DRIFT_MIN_RECORDS, } from "./claude-attribution-types.js";
5
+ /**
6
+ * Allowlisted signal extraction, in-memory and streamed. Reads only `type`,
7
+ * `cwd`, `gitBranch`, `sessionId`, and `prRepository` (pr-link records).
8
+ * Structurally identical to the Codex extractor, with the allowlist enforced
9
+ * by construction — no generic record walk that could surface message bodies.
10
+ */
11
+ export function extractClaudeSessionSignals(content) {
12
+ const accumulator = createSignalAccumulator();
13
+ for (const line of content.split("\n")) {
14
+ accumulator.processLine(line);
15
+ }
16
+ return accumulator.finalize();
17
+ }
18
+ function createSignalAccumulator() {
19
+ const sessionIds = new Set();
20
+ const cwds = new Set();
21
+ const branches = new Set();
22
+ const repositoryUrls = new Set();
23
+ let lineCount = 0;
24
+ let parseErrorCount = 0;
25
+ let contentRecordCount = 0;
26
+ let contentRecordsWithEnvelope = 0;
27
+ return {
28
+ processLine(line) {
29
+ if (!line.trim())
30
+ return;
31
+ lineCount += 1;
32
+ let record;
33
+ try {
34
+ record = JSON.parse(line);
35
+ }
36
+ catch {
37
+ // Deliberately silent (BLI-3238), same as the Codex line parser: per
38
+ // LINE across hundreds of thousands, the last line of a live session
39
+ // is routinely half-written, the count travels in `parseErrorCount` at
40
+ // the right grain, and the error would carry transcript text.
41
+ parseErrorCount += 1;
42
+ return;
43
+ }
44
+ if (!record || typeof record !== "object")
45
+ return;
46
+ const entry = record;
47
+ const type = typeof entry["type"] === "string" ? entry["type"] : "";
48
+ const cwd = stringOrNull(entry["cwd"]);
49
+ const sessionId = stringOrNull(entry["sessionId"]);
50
+ if (cwd)
51
+ cwds.add(cwd);
52
+ if (sessionId)
53
+ sessionIds.add(sessionId);
54
+ const branch = stringOrNull(entry["gitBranch"]);
55
+ if (branch)
56
+ branches.add(branch);
57
+ if (type === "pr-link") {
58
+ const repository = stringOrNull(entry["prRepository"]);
59
+ if (repository)
60
+ repositoryUrls.add(normalizePrRepository(repository));
61
+ }
62
+ // D11 canary input: among content records, how many carry both cwd and
63
+ // sessionId. gitBranch is intentionally excluded — it is legitimately
64
+ // absent in non-git directories and would false-positive desktop sessions.
65
+ if (CONTENT_RECORD_TYPES.has(type)) {
66
+ contentRecordCount += 1;
67
+ if (cwd && sessionId)
68
+ contentRecordsWithEnvelope += 1;
69
+ }
70
+ },
71
+ finalize() {
72
+ return {
73
+ session_ids: [...sessionIds],
74
+ cwds: [...cwds],
75
+ branches: [...branches],
76
+ repository_urls: [...repositoryUrls],
77
+ line_count: lineCount,
78
+ parse_error_count: parseErrorCount,
79
+ content_record_count: contentRecordCount,
80
+ envelope_field_hit_rate: contentRecordCount === 0
81
+ ? 1
82
+ : contentRecordsWithEnvelope / contentRecordCount,
83
+ };
84
+ },
85
+ };
86
+ }
87
+ export function isSchemaDriftSuspected(signals) {
88
+ // Only meaningful with enough records to be a signal, not noise.
89
+ if (signals.line_count < SCHEMA_DRIFT_MIN_RECORDS)
90
+ return false;
91
+ // A `type`-field rename yields zero recognized content records, which would
92
+ // otherwise leave the hit-rate defaulted to 1 and hide the most structural
93
+ // drift the canary exists to catch — flag a record-heavy file with no
94
+ // recognizable content records too.
95
+ if (signals.content_record_count === 0)
96
+ return true;
97
+ return signals.envelope_field_hit_rate < SCHEMA_DRIFT_MIN_HIT_RATE;
98
+ }
99
+ export async function streamMainSignals(filePath) {
100
+ const accumulator = createSignalAccumulator();
101
+ const hash = crypto.createHash("sha256");
102
+ let oversizedLinesSkipped = 0;
103
+ let byteSize = 0;
104
+ let pending = "";
105
+ let pendingTruncated = false;
106
+ const flushCompletedLine = (lineText) => {
107
+ if (pendingTruncated) {
108
+ // The line exceeded the metadata buffer; count it and continue. The raw
109
+ // evidence collector performs full-file sanitization before upload.
110
+ oversizedLinesSkipped += 1;
111
+ pending = "";
112
+ pendingTruncated = false;
113
+ return;
114
+ }
115
+ const full = pending + lineText;
116
+ pending = "";
117
+ accumulator.processLine(full);
118
+ };
119
+ // StringDecoder buffers an incomplete multibyte char across chunk boundaries
120
+ // so a UTF-8 character split by the read boundary never decodes to a fragment
121
+ // that could weaken the secret guard or corrupt a cwd signal. The content
122
+ // hash is over raw bytes (below) and is unaffected either way.
123
+ const decoder = new StringDecoder("utf8");
124
+ const consumeText = (text) => {
125
+ let remaining = text;
126
+ let newlineIndex = remaining.indexOf("\n");
127
+ while (newlineIndex !== -1) {
128
+ flushCompletedLine(remaining.slice(0, newlineIndex));
129
+ remaining = remaining.slice(newlineIndex + 1);
130
+ newlineIndex = remaining.indexOf("\n");
131
+ }
132
+ if (pendingTruncated)
133
+ return; // already over buffer; ignore until newline
134
+ if (pending.length + remaining.length > MAX_LINE_BUFFER_BYTES) {
135
+ pending = (pending + remaining).slice(0, MAX_LINE_BUFFER_BYTES);
136
+ pendingTruncated = true;
137
+ }
138
+ else {
139
+ pending += remaining;
140
+ }
141
+ };
142
+ await new Promise((resolve, reject) => {
143
+ const stream = createReadStream(filePath);
144
+ stream.on("data", (chunk) => {
145
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
146
+ hash.update(buffer);
147
+ byteSize += buffer.byteLength;
148
+ consumeText(decoder.write(buffer));
149
+ });
150
+ stream.on("end", () => {
151
+ const tail = decoder.end();
152
+ if (tail)
153
+ consumeText(tail);
154
+ if (pending.length > 0 || pendingTruncated)
155
+ flushCompletedLine("");
156
+ resolve();
157
+ });
158
+ stream.on("error", reject);
159
+ });
160
+ return {
161
+ signals: accumulator.finalize(),
162
+ oversizedLinesSkipped,
163
+ contentHash: hash.digest("hex"),
164
+ byteSize,
165
+ };
166
+ }
167
+ function normalizePrRepository(repository) {
168
+ const trimmed = repository
169
+ .trim()
170
+ .replace(/\.git$/i, "")
171
+ .replace(/^https?:\/\//i, "")
172
+ .replace(/^github\.com\//i, "")
173
+ .replace(/^\/+|\/+$/g, "")
174
+ .toLowerCase();
175
+ // GitHub-only by construction (D5); a non-GitHub origin simply never matches.
176
+ return `github.com/${trimmed}`;
177
+ }
178
+ function stringOrNull(value) {
179
+ return typeof value === "string" && value.trim() ? value.trim() : null;
180
+ }
@@ -0,0 +1,25 @@
1
+ import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
2
+ /**
3
+ * The shapes and tuning constants `claude-attribution.ts` and its siblings
4
+ * (discovery, score, signals) all agree on. Kept in one module so none of
5
+ * them needs to import another to know what a session, a signal, or a
6
+ * discovered sidecar looks like.
7
+ */
8
+ export const CLAUDE_ATTRIBUTION_DEFAULT_SINCE_MINUTES = 24 * 60;
9
+ export const CLAUDE_ATTRIBUTION_DEFAULT_SESSION_LIMIT = 50;
10
+ // Upload cap, NOT an attribution cap (D7): an oversized main file is still
11
+ // scored and attributed via a streamed read; only the file's bytes stay local.
12
+ export const CLAUDE_SESSION_MAX_FILE_BYTES = RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES;
13
+ export const CLAUDE_SESSION_MAX_SIDECAR_FILES = 40;
14
+ // JSONL lines holding a large tool result can be multi-MiB; a streamed read of
15
+ // an oversized main caps the per-line buffer so one giant line cannot blow
16
+ // memory. Lines over the cap are skipped + counted; the head is still guarded.
17
+ export const MAX_LINE_BUFFER_BYTES = 2 * 1024 * 1024;
18
+ export const SCHEMA_DRIFT_MIN_RECORDS = 20;
19
+ export const SCHEMA_DRIFT_MIN_HIT_RATE = 0.5;
20
+ export const CONTENT_RECORD_TYPES = new Set([
21
+ "user",
22
+ "assistant",
23
+ "system",
24
+ "attachment",
25
+ ]);