@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.
- package/dist/adapters/claude-attribution-score.js +24 -4
- package/dist/adapters/claude-attribution-signals.js +13 -6
- package/dist/adapters/claude-attribution-types.js +1 -1
- package/dist/adapters/codex-attribution.js +7 -143
- package/dist/adapters/codex-session-signals.js +173 -0
- package/dist/agent-rules-unmanaged-block.js +97 -0
- package/dist/agent-rules.js +1 -82
- package/dist/commands/agent-session-report.js +6 -0
- package/dist/commands/local-args-tower-admin.js +8 -151
- package/dist/commands/local-args-tower-settings.js +186 -0
- package/dist/commands/onboard-report-blockers.js +58 -0
- package/dist/commands/onboard-report.js +2 -57
- package/dist/commands/ops-render-ladder.js +25 -0
- package/dist/commands/ops-render.js +7 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync.js +10 -0
- package/dist/commands/settings-overview.js +94 -0
- package/dist/commands/settings.js +2 -82
- package/dist/commands/usage.js +4 -1
- package/dist/onboarding-root-guidance.js +60 -0
- package/dist/onboarding-roots.js +6 -59
- package/package.json +4 -4
|
@@ -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 {
|
|
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
|
+
}
|