@bli-cockpit/cli 0.2.112 → 0.2.114

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.
@@ -1,3 +1,4 @@
1
+ import { finalizeSessionFacts, newSessionFactsAccumulator, observeSessionFactsContent, observeSessionFactsFile, } from "@bli-cockpit/telemetry-core";
1
2
  import crypto from "node:crypto";
2
3
  import { existsSync } from "node:fs";
3
4
  import fs from "node:fs/promises";
@@ -11,9 +12,15 @@ import { CLAUDE_SESSION_MAX_FILE_BYTES, } from "./claude-attribution-types.js";
11
12
  * Turns one discovered session into an attribution verdict: read (or, for an
12
13
  * oversized main, stream) its signals, score them against the known
13
14
  * worktrees, and validate its sidecars against the winning worktree.
15
+ *
16
+ * The same pass counts the session's deterministic facts (BLI-4341) so the
17
+ * upload carries its own token totals. Only files that belong to this session
18
+ * are counted: a sidecar rejected for a cwd mismatch is another worktree's
19
+ * work and must not inflate this session's numbers.
14
20
  */
15
21
  export async function attributeOneSession(session, worktrees, collectionRoots) {
16
22
  const fileName = path.basename(session.mainFile);
23
+ const factsAcc = newSessionFactsAccumulator("claude_code");
17
24
  const base = {
18
25
  file_path: session.mainFile,
19
26
  file_name: fileName,
@@ -29,6 +36,7 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
29
36
  oversized_lines_skipped: 0,
30
37
  sidecars_capped: session.sidecarsCapped,
31
38
  sidecar_files: [],
39
+ session_facts: null,
32
40
  };
33
41
  if (session.mainByteSize === 0) {
34
42
  return skippedResult(base, "empty_file");
@@ -40,7 +48,7 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
40
48
  // bytes themselves are not uploaded until the collection ceiling changes.
41
49
  let streamed;
42
50
  try {
43
- streamed = await streamMainSignals(session.mainFile);
51
+ streamed = await streamMainSignals(session.mainFile, factsAcc);
44
52
  }
45
53
  catch (error) {
46
54
  // A read race on one oversized main must not abort the whole scan; honor
@@ -55,6 +63,10 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
55
63
  return skippedResult(base, "file_read_failed");
56
64
  }
57
65
  signals = streamed.signals;
66
+ observeSessionFactsFile(factsAcc, {
67
+ bytes: streamed.byteSize,
68
+ lines: streamed.signals.line_count,
69
+ });
58
70
  base.content_hash_sha256 = streamed.contentHash;
59
71
  base.byte_size = streamed.byteSize;
60
72
  base.main_file_oversized = true;
@@ -80,7 +92,11 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
80
92
  if (content.trim() === "") {
81
93
  return skippedResult(base, "empty_file");
82
94
  }
83
- signals = extractClaudeSessionSignals(content);
95
+ signals = extractClaudeSessionSignals(content, factsAcc);
96
+ observeSessionFactsFile(factsAcc, {
97
+ bytes: raw.byteLength,
98
+ lines: signals.line_count,
99
+ });
84
100
  }
85
101
  const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
86
102
  if (metaSessionId) {
@@ -116,7 +132,7 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
116
132
  if (isSchemaDriftSuspected(signals))
117
133
  extraSignals.push("schema_drift_suspected");
118
134
  const sidecarFiles = isRawEvidenceUploadableAttributionState(outcome.state, outcome.worktree !== null) && outcome.worktree
119
- ? await collectSidecarDiagnostics(session.sidecars, outcome.worktree)
135
+ ? await collectSidecarDiagnostics(session.sidecars, outcome.worktree, factsAcc)
120
136
  : session.sidecars.map((sidecar) => ({
121
137
  local_path: sidecar.local_path,
122
138
  file_name: sidecar.file_name,
@@ -126,6 +142,7 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
126
142
  }));
127
143
  return {
128
144
  ...base,
145
+ session_facts: finalizeSessionFacts(factsAcc),
129
146
  state: outcome.state,
130
147
  reason: outcome.reason,
131
148
  signals: [...outcome.signals, ...extraSignals],
@@ -143,7 +160,7 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
143
160
  * oversized sidecar is recorded with a reason and the session stays
144
161
  * harvestable.
145
162
  */
146
- async function collectSidecarDiagnostics(sidecars, worktree) {
163
+ async function collectSidecarDiagnostics(sidecars, worktree, facts) {
147
164
  const out = [];
148
165
  for (const sidecar of sidecars) {
149
166
  const entry = {
@@ -181,6 +198,9 @@ async function collectSidecarDiagnostics(sidecars, worktree) {
181
198
  out.push({ ...entry, skipped_reason: "sidecar_cwd_mismatch" });
182
199
  continue;
183
200
  }
201
+ // Accepted: this subagent's turns are part of this session, so its tokens
202
+ // are too. Counted only now, after the cwd check has cleared it.
203
+ observeSessionFactsContent(facts, content);
184
204
  out.push({
185
205
  ...entry,
186
206
  byte_size: raw.byteLength,
@@ -1,3 +1,4 @@
1
+ import { observeSessionFactsRecord, } from "@bli-cockpit/telemetry-core";
1
2
  import crypto from "node:crypto";
2
3
  import { createReadStream } from "node:fs";
3
4
  import { StringDecoder } from "node:string_decoder";
@@ -7,15 +8,18 @@ import { CONTENT_RECORD_TYPES, MAX_LINE_BUFFER_BYTES, SCHEMA_DRIFT_MIN_HIT_RATE,
7
8
  * `cwd`, `gitBranch`, `sessionId`, and `prRepository` (pr-link records).
8
9
  * Structurally identical to the Codex extractor, with the allowlist enforced
9
10
  * by construction — no generic record walk that could surface message bodies.
11
+ *
12
+ * Pass a session-facts accumulator to have the SAME parse feed the upload-time
13
+ * deterministic count (BLI-4341); without one the pass is unchanged.
10
14
  */
11
- export function extractClaudeSessionSignals(content) {
12
- const accumulator = createSignalAccumulator();
15
+ export function extractClaudeSessionSignals(content, facts) {
16
+ const accumulator = createSignalAccumulator(facts);
13
17
  for (const line of content.split("\n")) {
14
18
  accumulator.processLine(line);
15
19
  }
16
20
  return accumulator.finalize();
17
21
  }
18
- function createSignalAccumulator() {
22
+ function createSignalAccumulator(facts) {
19
23
  const sessionIds = new Set();
20
24
  const cwds = new Set();
21
25
  const branches = new Set();
@@ -41,9 +45,12 @@ function createSignalAccumulator() {
41
45
  parseErrorCount += 1;
42
46
  return;
43
47
  }
44
- if (!record || typeof record !== "object")
48
+ if (!record || typeof record !== "object" || Array.isArray(record))
45
49
  return;
46
50
  const entry = record;
51
+ // Counting is not reading: the facts module returns totals, never text.
52
+ if (facts)
53
+ observeSessionFactsRecord(facts, entry);
47
54
  const type = typeof entry["type"] === "string" ? entry["type"] : "";
48
55
  const cwd = stringOrNull(entry["cwd"]);
49
56
  const sessionId = stringOrNull(entry["sessionId"]);
@@ -96,8 +103,8 @@ export function isSchemaDriftSuspected(signals) {
96
103
  return true;
97
104
  return signals.envelope_field_hit_rate < SCHEMA_DRIFT_MIN_HIT_RATE;
98
105
  }
99
- export async function streamMainSignals(filePath) {
100
- const accumulator = createSignalAccumulator();
106
+ export async function streamMainSignals(filePath, facts) {
107
+ const accumulator = createSignalAccumulator(facts);
101
108
  const hash = crypto.createHash("sha256");
102
109
  let oversizedLinesSkipped = 0;
103
110
  let byteSize = 0;
@@ -1,4 +1,4 @@
1
- import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
1
+ import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, } from "@bli-cockpit/telemetry-core";
2
2
  /**
3
3
  * The shapes and tuning constants `claude-attribution.ts` and its siblings
4
4
  * (discovery, score, signals) all agree on. Kept in one module so none of
@@ -1,10 +1,8 @@
1
- import { createReadStream, existsSync } from "node:fs";
2
- import crypto from "node:crypto";
1
+ import { existsSync } from "node:fs";
3
2
  import fs from "node:fs/promises";
4
3
  import path from "node:path";
5
- import { StringDecoder } from "node:string_decoder";
6
- import { normalizeGitOrigin } from "../repo-identity.js";
7
4
  import { describeError } from "../health-detail.js";
5
+ import { extractCodexSessionSignals, readCodexMetadataSignals, } from "./codex-session-signals.js";
8
6
  import { sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName, shortHash, } from "./attribution-core.js";
9
7
  /**
10
8
  * Deterministic Codex session JSONL -> repo/worktree attribution.
@@ -17,8 +15,10 @@ import { sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName,
17
15
  * attribute identically.
18
16
  */
19
17
  // Re-exported for callers (and tests) that historically imported these from
20
- // the codex adapter; the single implementation lives in attribution-core.
18
+ // the codex adapter; the single implementation lives in attribution-core, and
19
+ // the signal reading in codex-session-signals.
21
20
  export { sanitizeSessionId, sessionIdFromFileName };
21
+ export { extractCodexSessionSignals };
22
22
  export const CODEX_ATTRIBUTION_DEFAULT_SINCE_MINUTES = 24 * 60;
23
23
  export const CODEX_ATTRIBUTION_DEFAULT_SESSION_LIMIT = 50;
24
24
  export const CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES = 14 * 24 * 60;
@@ -27,7 +27,6 @@ export const CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT = 500;
27
27
  // and no longer rejects sessions by file size; raw evidence collection enforces
28
28
  // upload budgets and content guards later.
29
29
  export const CODEX_SESSION_MAX_FILE_BYTES = 10 * 1024 * 1024;
30
- const CODEX_ATTRIBUTION_MAX_LINE_BYTES = 2 * 1024 * 1024;
31
30
  export function defaultCodexSessionDirs(homeDir) {
32
31
  return [
33
32
  path.join(homeDir, ".codex", "sessions"),
@@ -174,6 +173,7 @@ async function attributeOneSession(file, worktrees, collectionRoots) {
174
173
  session_file_mtime_ms: file.mtimeMs,
175
174
  byte_size: file.byteSize,
176
175
  content_hash_sha256: null,
176
+ session_facts: null,
177
177
  };
178
178
  let read;
179
179
  try {
@@ -192,6 +192,7 @@ async function attributeOneSession(file, worktrees, collectionRoots) {
192
192
  }
193
193
  base.content_hash_sha256 = read.contentHashSha256;
194
194
  base.byte_size = read.byteSize;
195
+ base.session_facts = read.facts;
195
196
  const signals = read.signals;
196
197
  const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
197
198
  if (metaSessionId) {
@@ -223,139 +224,6 @@ async function attributeOneSession(file, worktrees, collectionRoots) {
223
224
  }, worktrees, { collectionRoots, pathExists: existsSync });
224
225
  return { ...base, ...outcome };
225
226
  }
226
- async function readCodexMetadataSignals(filePath) {
227
- const hash = crypto.createHash("sha256");
228
- const decoder = new StringDecoder("utf8");
229
- const state = makeSignalExtractionState();
230
- const stream = createReadStream(filePath);
231
- let byteSize = 0;
232
- let lineBuffer = "";
233
- let discardingOversizedLine = false;
234
- for await (const chunk of stream) {
235
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
236
- byteSize += buffer.byteLength;
237
- hash.update(buffer);
238
- const text = decoder.write(buffer);
239
- const segments = text.split("\n");
240
- for (let index = 0; index < segments.length; index += 1) {
241
- const segment = segments[index] ?? "";
242
- const lineEnded = index < segments.length - 1;
243
- if (discardingOversizedLine) {
244
- if (lineEnded)
245
- discardingOversizedLine = false;
246
- continue;
247
- }
248
- lineBuffer += segment;
249
- if (Buffer.byteLength(lineBuffer, "utf8") > CODEX_ATTRIBUTION_MAX_LINE_BYTES) {
250
- state.lineCount += 1;
251
- state.parseErrorCount += 1;
252
- lineBuffer = "";
253
- discardingOversizedLine = !lineEnded;
254
- continue;
255
- }
256
- if (lineEnded) {
257
- absorbCodexSessionLine(state, lineBuffer);
258
- lineBuffer = "";
259
- }
260
- }
261
- }
262
- const rest = decoder.end();
263
- if (rest)
264
- lineBuffer += rest;
265
- if (lineBuffer.trim())
266
- absorbCodexSessionLine(state, lineBuffer);
267
- return {
268
- signals: codexSignalsFromState(state),
269
- contentHashSha256: hash.digest("hex"),
270
- byteSize,
271
- };
272
- }
273
- export function extractCodexSessionSignals(content) {
274
- const state = makeSignalExtractionState();
275
- for (const line of content.split("\n")) {
276
- absorbCodexSessionLine(state, line);
277
- }
278
- return codexSignalsFromState(state);
279
- }
280
- function makeSignalExtractionState() {
281
- return {
282
- sessionIds: new Set(),
283
- cwds: new Set(),
284
- workspaceRoots: new Set(),
285
- branches: new Set(),
286
- commitHashes: new Set(),
287
- repositoryUrls: new Set(),
288
- lineCount: 0,
289
- parseErrorCount: 0,
290
- };
291
- }
292
- function absorbCodexSessionLine(state, line) {
293
- if (!line.trim())
294
- return;
295
- state.lineCount += 1;
296
- let record;
297
- try {
298
- record = JSON.parse(line);
299
- }
300
- catch {
301
- // Deliberately silent (BLI-3238). Per LINE, in files with hundreds of
302
- // thousands of them, and the last line of a live session is routinely
303
- // half-written — this is expected, not a failure. The count travels in
304
- // `parseErrorCount` on the scan result, which is the right grain, and the
305
- // error object would carry a fragment of the transcript.
306
- state.parseErrorCount += 1;
307
- return;
308
- }
309
- if (!record || typeof record !== "object")
310
- return;
311
- const type = record.type;
312
- if (type !== "session_meta" && type !== "turn_context")
313
- return;
314
- const payload = record.payload;
315
- if (!payload || typeof payload !== "object")
316
- return;
317
- const payloadRecord = payload;
318
- if (type === "session_meta") {
319
- addString(state.sessionIds, payloadRecord["id"]);
320
- addString(state.cwds, payloadRecord["cwd"]);
321
- const git = payloadRecord["git"];
322
- if (git && typeof git === "object") {
323
- const gitRecord = git;
324
- addString(state.branches, gitRecord["branch"] ?? gitRecord["current_branch"]);
325
- addString(state.commitHashes, gitRecord["commit_hash"]);
326
- const repositoryUrl = gitRecord["repository_url"];
327
- if (typeof repositoryUrl === "string" && repositoryUrl.trim()) {
328
- state.repositoryUrls.add(normalizeGitOrigin(repositoryUrl));
329
- }
330
- }
331
- }
332
- else if (type === "turn_context") {
333
- addString(state.cwds, payloadRecord["cwd"]);
334
- const roots = payloadRecord["workspace_roots"];
335
- if (Array.isArray(roots)) {
336
- for (const root of roots) {
337
- if (typeof root === "string") {
338
- addString(state.workspaceRoots, root);
339
- }
340
- else if (root && typeof root === "object") {
341
- addString(state.workspaceRoots, root["path"]);
342
- }
343
- }
344
- }
345
- }
346
- }
347
- function codexSignalsFromState(state) {
348
- return {
349
- session_ids: [...state.sessionIds],
350
- cwds: [...state.cwds],
351
- workspace_roots: [...state.workspaceRoots],
352
- branches: [...state.branches],
353
- commit_hashes: [...state.commitHashes],
354
- repository_urls: [...state.repositoryUrls],
355
- line_count: state.lineCount,
356
- parse_error_count: state.parseErrorCount,
357
- };
358
- }
359
227
  function skippedResult(base, reason) {
360
228
  return {
361
229
  ...base,
@@ -366,8 +234,4 @@ function skippedResult(base, reason) {
366
234
  path_score: 0,
367
235
  worktree: null,
368
236
  };
369
- }
370
- function addString(target, value) {
371
- if (typeof value === "string" && value.trim())
372
- target.add(value.trim());
373
237
  }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * What a Codex session file says about WHERE it ran, and nothing else.
3
+ *
4
+ * Only metadata-bearing records are inspected (session_meta, turn_context):
5
+ * cwd, workspace roots, and git branch/commit/origin. Prompt, response,
6
+ * reasoning and tool payload fields are never extracted, printed or
7
+ * summarized. Split out of codex-attribution.ts so the file that DECIDES a
8
+ * session's repo reads as the decision, and this one as the reading.
9
+ *
10
+ * Two doors, same extraction: `readCodexMetadataSignals` streams a file off
11
+ * disk (sessions run to hundreds of megabytes, so the file is never held in
12
+ * memory and its sha256 is taken on the way past), and
13
+ * `extractCodexSessionSignals` takes content already in hand.
14
+ *
15
+ * The same streaming pass also counts the session's deterministic facts
16
+ * (BLI-4341): token totals per model, turn and tool counts, timestamps, size.
17
+ * The bytes are already in hand and already parsed, so counting them here
18
+ * costs one function call per record and saves the server a download.
19
+ */
20
+ import { finalizeSessionFacts, newSessionFactsAccumulator, observeSessionFactsFile, observeSessionFactsRecord, } from "@bli-cockpit/telemetry-core";
21
+ import { createReadStream } from "node:fs";
22
+ import crypto from "node:crypto";
23
+ import { StringDecoder } from "node:string_decoder";
24
+ import { normalizeGitOrigin } from "../repo-identity.js";
25
+ /** A line this long is not a session record; it is counted as a parse error and dropped. */
26
+ const CODEX_ATTRIBUTION_MAX_LINE_BYTES = 2 * 1024 * 1024;
27
+ /**
28
+ * One session file, streamed: the metadata signals, the sha256 of every byte
29
+ * that went past, and the size the walk should believe.
30
+ */
31
+ export async function readCodexMetadataSignals(filePath) {
32
+ const hash = crypto.createHash("sha256");
33
+ const decoder = new StringDecoder("utf8");
34
+ const state = makeSignalExtractionState();
35
+ const stream = createReadStream(filePath);
36
+ let byteSize = 0;
37
+ let lineBuffer = "";
38
+ let discardingOversizedLine = false;
39
+ for await (const chunk of stream) {
40
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
41
+ byteSize += buffer.byteLength;
42
+ hash.update(buffer);
43
+ const text = decoder.write(buffer);
44
+ const segments = text.split("\n");
45
+ for (let index = 0; index < segments.length; index += 1) {
46
+ const segment = segments[index] ?? "";
47
+ const lineEnded = index < segments.length - 1;
48
+ if (discardingOversizedLine) {
49
+ if (lineEnded)
50
+ discardingOversizedLine = false;
51
+ continue;
52
+ }
53
+ lineBuffer += segment;
54
+ if (Buffer.byteLength(lineBuffer, "utf8") > CODEX_ATTRIBUTION_MAX_LINE_BYTES) {
55
+ state.lineCount += 1;
56
+ state.parseErrorCount += 1;
57
+ lineBuffer = "";
58
+ discardingOversizedLine = !lineEnded;
59
+ continue;
60
+ }
61
+ if (lineEnded) {
62
+ absorbCodexSessionLine(state, lineBuffer);
63
+ lineBuffer = "";
64
+ }
65
+ }
66
+ }
67
+ const rest = decoder.end();
68
+ if (rest)
69
+ lineBuffer += rest;
70
+ if (lineBuffer.trim())
71
+ absorbCodexSessionLine(state, lineBuffer);
72
+ observeSessionFactsFile(state.facts, { bytes: byteSize, lines: state.lineCount });
73
+ return {
74
+ signals: codexSignalsFromState(state),
75
+ facts: finalizeSessionFacts(state.facts),
76
+ contentHashSha256: hash.digest("hex"),
77
+ byteSize,
78
+ };
79
+ }
80
+ export function extractCodexSessionSignals(content) {
81
+ const state = makeSignalExtractionState();
82
+ for (const line of content.split("\n")) {
83
+ absorbCodexSessionLine(state, line);
84
+ }
85
+ return codexSignalsFromState(state);
86
+ }
87
+ function makeSignalExtractionState() {
88
+ return {
89
+ sessionIds: new Set(),
90
+ cwds: new Set(),
91
+ workspaceRoots: new Set(),
92
+ branches: new Set(),
93
+ commitHashes: new Set(),
94
+ repositoryUrls: new Set(),
95
+ lineCount: 0,
96
+ parseErrorCount: 0,
97
+ facts: newSessionFactsAccumulator("codex"),
98
+ };
99
+ }
100
+ function absorbCodexSessionLine(state, line) {
101
+ if (!line.trim())
102
+ return;
103
+ state.lineCount += 1;
104
+ let record;
105
+ try {
106
+ record = JSON.parse(line);
107
+ }
108
+ catch {
109
+ // Deliberately silent (BLI-3238). Per LINE, in files with hundreds of
110
+ // thousands of them, and the last line of a live session is routinely
111
+ // half-written — this is expected, not a failure. The count travels in
112
+ // `parseErrorCount` on the scan result, which is the right grain, and the
113
+ // error object would carry a fragment of the transcript.
114
+ state.parseErrorCount += 1;
115
+ return;
116
+ }
117
+ if (!record || typeof record !== "object" || Array.isArray(record))
118
+ return;
119
+ // Every record feeds the facts count; only the two metadata types below feed
120
+ // attribution.
121
+ observeSessionFactsRecord(state.facts, record);
122
+ const type = record.type;
123
+ if (type !== "session_meta" && type !== "turn_context")
124
+ return;
125
+ const payload = record.payload;
126
+ if (!payload || typeof payload !== "object")
127
+ return;
128
+ const payloadRecord = payload;
129
+ if (type === "session_meta") {
130
+ addString(state.sessionIds, payloadRecord["id"]);
131
+ addString(state.cwds, payloadRecord["cwd"]);
132
+ const git = payloadRecord["git"];
133
+ if (git && typeof git === "object") {
134
+ const gitRecord = git;
135
+ addString(state.branches, gitRecord["branch"] ?? gitRecord["current_branch"]);
136
+ addString(state.commitHashes, gitRecord["commit_hash"]);
137
+ const repositoryUrl = gitRecord["repository_url"];
138
+ if (typeof repositoryUrl === "string" && repositoryUrl.trim()) {
139
+ state.repositoryUrls.add(normalizeGitOrigin(repositoryUrl));
140
+ }
141
+ }
142
+ }
143
+ else if (type === "turn_context") {
144
+ addString(state.cwds, payloadRecord["cwd"]);
145
+ const roots = payloadRecord["workspace_roots"];
146
+ if (Array.isArray(roots)) {
147
+ for (const root of roots) {
148
+ if (typeof root === "string") {
149
+ addString(state.workspaceRoots, root);
150
+ }
151
+ else if (root && typeof root === "object") {
152
+ addString(state.workspaceRoots, root["path"]);
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ function codexSignalsFromState(state) {
159
+ return {
160
+ session_ids: [...state.sessionIds],
161
+ cwds: [...state.cwds],
162
+ workspace_roots: [...state.workspaceRoots],
163
+ branches: [...state.branches],
164
+ commit_hashes: [...state.commitHashes],
165
+ repository_urls: [...state.repositoryUrls],
166
+ line_count: state.lineCount,
167
+ parse_error_count: state.parseErrorCount,
168
+ };
169
+ }
170
+ function addString(target, value) {
171
+ if (typeof value === "string" && value.trim())
172
+ target.add(value.trim());
173
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * A ticket-binding section somebody wrote by hand, and whether it already says
3
+ * what our managed block says.
4
+ *
5
+ * Split out of agent-rules.ts (BLI-10108), which writes the block; this file
6
+ * only READS what is already in a person's rules file and answers two
7
+ * questions: is this good enough to leave alone (`equivalent`), or is it the
8
+ * older vocabulary we should replace (`stale`)?
9
+ *
10
+ * The matching is deliberately loose. These sections were typed by people and
11
+ * by other agents over months, so the test is a handful of cues plus a score,
12
+ * not equality, and every cue is lowercased, unquoted and whitespace-flattened
13
+ * first so formatting never decides the answer.
14
+ */
15
+ import path from "node:path";
16
+ export function hasEquivalentUnmanagedTicketBinding(contents, scopePaths = []) {
17
+ const text = normalizeRuleText(contents);
18
+ if (!hasTicketBindingCues(text))
19
+ return false;
20
+ if (!hasRepoScopeGuard(text))
21
+ return false;
22
+ if (scopePaths.some((scopePath) => !hasScopePath(text, scopePath)))
23
+ return false;
24
+ if (!hasTicketLookupOrCreationCue(text))
25
+ return false;
26
+ // Contents without the QA-receipts rule are the older vocabulary and must
27
+ // read as stale — otherwise machines with a hand-written ticket-binding
28
+ // section never receive the definition-of-done rule.
29
+ if (!hasQaReceiptsCue(text))
30
+ return false;
31
+ const signals = [
32
+ /cockpit\s+start\s+--ticket\b/u,
33
+ /before\s+(?:the\s+)?first\s+code\s+edit|before\s+ticketed\s+implementation/u,
34
+ /general\s+ambient/u,
35
+ /cockpit\s+sync\s+--repo|cockpit\s+sync\s+--workspace|fresh\s+ticket\/session\s+binding\s+metadata/u,
36
+ /use\s+--ticket|the\s+flag\s+is\s+--ticket|do\s+not\s+invent\s+--ticketid/u,
37
+ ];
38
+ const score = signals.filter((signal) => signal.test(text)).length;
39
+ return score >= 4;
40
+ }
41
+ function hasQaReceiptsCue(text) {
42
+ return /computer-use\s+qa\s+pass/u.test(text)
43
+ && /receipts/u.test(text);
44
+ }
45
+ function hasTicketLookupOrCreationCue(text) {
46
+ return /search\s+linear|create\s+(?:a\s+)?(?:narrow\s+)?linear\s+ticket|new\s+linear\s+ticket/u.test(text);
47
+ }
48
+ function hasScopePath(text, scopePath) {
49
+ return text.includes(normalizeRuleText(path.resolve(scopePath)));
50
+ }
51
+ function hasRepoScopeGuard(text) {
52
+ return (/only\s+applies\s+when\s+the\s+current\s+working\s+directory\s+is\s+inside/u.test(text) ||
53
+ /outside\s+that\s+(?:folder|workspace|repo).*(?:do\s+not|dont)\s+run\s+cockpit/u.test(text) ||
54
+ /private\s+chats\s+or\s+unrelated\s+repos/u.test(text));
55
+ }
56
+ export function findStaleUnmanagedTicketBindingBlock(contents, scopePaths = []) {
57
+ const lines = contents.split("\n");
58
+ for (let index = 0; index < lines.length; index += 1) {
59
+ if (!/^#{1,6}\s+.*(?:cockpit\s+)?ticket\s+binding\b/iu.test(lines[index] ?? "")) {
60
+ continue;
61
+ }
62
+ let endLine = lines.length;
63
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
64
+ if (/^#{1,6}\s+\S/u.test(lines[cursor] ?? "")) {
65
+ endLine = cursor;
66
+ break;
67
+ }
68
+ }
69
+ const candidate = lines.slice(index, endLine).join("\n");
70
+ const normalized = normalizeRuleText(candidate);
71
+ if (hasTicketBindingCues(normalized) &&
72
+ !hasEquivalentUnmanagedTicketBinding(candidate, scopePaths)) {
73
+ return { startLine: index, endLine };
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+ export function replaceLineSpan(contents, startLine, endLine, replacement) {
79
+ const lines = contents.split("\n");
80
+ const before = lines.slice(0, startLine).join("\n").trimEnd();
81
+ const after = lines.slice(endLine).join("\n").trimStart();
82
+ return [before, replacement, after]
83
+ .filter((part) => part.trim().length > 0)
84
+ .join("\n\n")
85
+ .replace(/\n{3,}/gu, "\n\n")
86
+ .trimEnd() + "\n";
87
+ }
88
+ function hasTicketBindingCues(text) {
89
+ return /\bcockpit\b/u.test(text) && /\bticket\b/u.test(text) && /binding|agent|linear/u.test(text);
90
+ }
91
+ function normalizeRuleText(contents) {
92
+ return contents
93
+ .toLowerCase()
94
+ .replace(/[`"'<>]/gu, "")
95
+ .replace(/\s+/gu, " ")
96
+ .trim();
97
+ }