@bli-cockpit/cli 0.2.111 → 0.2.113
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/codex-attribution.js +5 -143
- package/dist/adapters/codex-session-signals.js +161 -0
- package/dist/agent-rules-unmanaged-block.js +97 -0
- package/dist/agent-rules.js +1 -82
- package/dist/commands/agent-session-report.js +3 -0
- package/dist/commands/local-args-tower-admin.js +8 -151
- package/dist/commands/local-args-tower-settings.js +186 -0
- package/dist/commands/local-args-tower-usage.js +6 -6
- package/dist/commands/local-help-commands-tower.js +2 -2
- package/dist/commands/local-help.js +2 -2
- 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 +12 -6
- package/dist/onboarding-root-guidance.js +60 -0
- package/dist/onboarding-roots.js +6 -59
- package/package.json +4 -4
|
@@ -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"),
|
|
@@ -223,139 +222,6 @@ async function attributeOneSession(file, worktrees, collectionRoots) {
|
|
|
223
222
|
}, worktrees, { collectionRoots, pathExists: existsSync });
|
|
224
223
|
return { ...base, ...outcome };
|
|
225
224
|
}
|
|
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
225
|
function skippedResult(base, reason) {
|
|
360
226
|
return {
|
|
361
227
|
...base,
|
|
@@ -366,8 +232,4 @@ function skippedResult(base, reason) {
|
|
|
366
232
|
path_score: 0,
|
|
367
233
|
worktree: null,
|
|
368
234
|
};
|
|
369
|
-
}
|
|
370
|
-
function addString(target, value) {
|
|
371
|
-
if (typeof value === "string" && value.trim())
|
|
372
|
-
target.add(value.trim());
|
|
373
235
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
import { createReadStream } from "node:fs";
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
import { StringDecoder } from "node:string_decoder";
|
|
18
|
+
import { normalizeGitOrigin } from "../repo-identity.js";
|
|
19
|
+
/** A line this long is not a session record; it is counted as a parse error and dropped. */
|
|
20
|
+
const CODEX_ATTRIBUTION_MAX_LINE_BYTES = 2 * 1024 * 1024;
|
|
21
|
+
/**
|
|
22
|
+
* One session file, streamed: the metadata signals, the sha256 of every byte
|
|
23
|
+
* that went past, and the size the walk should believe.
|
|
24
|
+
*/
|
|
25
|
+
export async function readCodexMetadataSignals(filePath) {
|
|
26
|
+
const hash = crypto.createHash("sha256");
|
|
27
|
+
const decoder = new StringDecoder("utf8");
|
|
28
|
+
const state = makeSignalExtractionState();
|
|
29
|
+
const stream = createReadStream(filePath);
|
|
30
|
+
let byteSize = 0;
|
|
31
|
+
let lineBuffer = "";
|
|
32
|
+
let discardingOversizedLine = false;
|
|
33
|
+
for await (const chunk of stream) {
|
|
34
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
35
|
+
byteSize += buffer.byteLength;
|
|
36
|
+
hash.update(buffer);
|
|
37
|
+
const text = decoder.write(buffer);
|
|
38
|
+
const segments = text.split("\n");
|
|
39
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
40
|
+
const segment = segments[index] ?? "";
|
|
41
|
+
const lineEnded = index < segments.length - 1;
|
|
42
|
+
if (discardingOversizedLine) {
|
|
43
|
+
if (lineEnded)
|
|
44
|
+
discardingOversizedLine = false;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
lineBuffer += segment;
|
|
48
|
+
if (Buffer.byteLength(lineBuffer, "utf8") > CODEX_ATTRIBUTION_MAX_LINE_BYTES) {
|
|
49
|
+
state.lineCount += 1;
|
|
50
|
+
state.parseErrorCount += 1;
|
|
51
|
+
lineBuffer = "";
|
|
52
|
+
discardingOversizedLine = !lineEnded;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (lineEnded) {
|
|
56
|
+
absorbCodexSessionLine(state, lineBuffer);
|
|
57
|
+
lineBuffer = "";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const rest = decoder.end();
|
|
62
|
+
if (rest)
|
|
63
|
+
lineBuffer += rest;
|
|
64
|
+
if (lineBuffer.trim())
|
|
65
|
+
absorbCodexSessionLine(state, lineBuffer);
|
|
66
|
+
return {
|
|
67
|
+
signals: codexSignalsFromState(state),
|
|
68
|
+
contentHashSha256: hash.digest("hex"),
|
|
69
|
+
byteSize,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function extractCodexSessionSignals(content) {
|
|
73
|
+
const state = makeSignalExtractionState();
|
|
74
|
+
for (const line of content.split("\n")) {
|
|
75
|
+
absorbCodexSessionLine(state, line);
|
|
76
|
+
}
|
|
77
|
+
return codexSignalsFromState(state);
|
|
78
|
+
}
|
|
79
|
+
function makeSignalExtractionState() {
|
|
80
|
+
return {
|
|
81
|
+
sessionIds: new Set(),
|
|
82
|
+
cwds: new Set(),
|
|
83
|
+
workspaceRoots: new Set(),
|
|
84
|
+
branches: new Set(),
|
|
85
|
+
commitHashes: new Set(),
|
|
86
|
+
repositoryUrls: new Set(),
|
|
87
|
+
lineCount: 0,
|
|
88
|
+
parseErrorCount: 0,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function absorbCodexSessionLine(state, line) {
|
|
92
|
+
if (!line.trim())
|
|
93
|
+
return;
|
|
94
|
+
state.lineCount += 1;
|
|
95
|
+
let record;
|
|
96
|
+
try {
|
|
97
|
+
record = JSON.parse(line);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Deliberately silent (BLI-3238). Per LINE, in files with hundreds of
|
|
101
|
+
// thousands of them, and the last line of a live session is routinely
|
|
102
|
+
// half-written — this is expected, not a failure. The count travels in
|
|
103
|
+
// `parseErrorCount` on the scan result, which is the right grain, and the
|
|
104
|
+
// error object would carry a fragment of the transcript.
|
|
105
|
+
state.parseErrorCount += 1;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!record || typeof record !== "object")
|
|
109
|
+
return;
|
|
110
|
+
const type = record.type;
|
|
111
|
+
if (type !== "session_meta" && type !== "turn_context")
|
|
112
|
+
return;
|
|
113
|
+
const payload = record.payload;
|
|
114
|
+
if (!payload || typeof payload !== "object")
|
|
115
|
+
return;
|
|
116
|
+
const payloadRecord = payload;
|
|
117
|
+
if (type === "session_meta") {
|
|
118
|
+
addString(state.sessionIds, payloadRecord["id"]);
|
|
119
|
+
addString(state.cwds, payloadRecord["cwd"]);
|
|
120
|
+
const git = payloadRecord["git"];
|
|
121
|
+
if (git && typeof git === "object") {
|
|
122
|
+
const gitRecord = git;
|
|
123
|
+
addString(state.branches, gitRecord["branch"] ?? gitRecord["current_branch"]);
|
|
124
|
+
addString(state.commitHashes, gitRecord["commit_hash"]);
|
|
125
|
+
const repositoryUrl = gitRecord["repository_url"];
|
|
126
|
+
if (typeof repositoryUrl === "string" && repositoryUrl.trim()) {
|
|
127
|
+
state.repositoryUrls.add(normalizeGitOrigin(repositoryUrl));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else if (type === "turn_context") {
|
|
132
|
+
addString(state.cwds, payloadRecord["cwd"]);
|
|
133
|
+
const roots = payloadRecord["workspace_roots"];
|
|
134
|
+
if (Array.isArray(roots)) {
|
|
135
|
+
for (const root of roots) {
|
|
136
|
+
if (typeof root === "string") {
|
|
137
|
+
addString(state.workspaceRoots, root);
|
|
138
|
+
}
|
|
139
|
+
else if (root && typeof root === "object") {
|
|
140
|
+
addString(state.workspaceRoots, root["path"]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function codexSignalsFromState(state) {
|
|
147
|
+
return {
|
|
148
|
+
session_ids: [...state.sessionIds],
|
|
149
|
+
cwds: [...state.cwds],
|
|
150
|
+
workspace_roots: [...state.workspaceRoots],
|
|
151
|
+
branches: [...state.branches],
|
|
152
|
+
commit_hashes: [...state.commitHashes],
|
|
153
|
+
repository_urls: [...state.repositoryUrls],
|
|
154
|
+
line_count: state.lineCount,
|
|
155
|
+
parse_error_count: state.parseErrorCount,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function addString(target, value) {
|
|
159
|
+
if (typeof value === "string" && value.trim())
|
|
160
|
+
target.add(value.trim());
|
|
161
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/agent-rules.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { findStaleUnmanagedTicketBindingBlock, hasEquivalentUnmanagedTicketBinding, replaceLineSpan, } from "./agent-rules-unmanaged-block.js";
|
|
4
5
|
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
5
6
|
const MANAGED_BLOCK_START = "<!-- BLI_COCKPIT_AGENT_RULES:START -->";
|
|
6
7
|
const MANAGED_BLOCK_END = "<!-- BLI_COCKPIT_AGENT_RULES:END -->";
|
|
@@ -260,88 +261,6 @@ function managedBlockPattern() {
|
|
|
260
261
|
function extractManagedBlock(contents) {
|
|
261
262
|
return contents.match(managedBlockPattern())?.[0] ?? null;
|
|
262
263
|
}
|
|
263
|
-
function hasEquivalentUnmanagedTicketBinding(contents, scopePaths = []) {
|
|
264
|
-
const text = normalizeRuleText(contents);
|
|
265
|
-
if (!hasTicketBindingCues(text))
|
|
266
|
-
return false;
|
|
267
|
-
if (!hasRepoScopeGuard(text))
|
|
268
|
-
return false;
|
|
269
|
-
if (scopePaths.some((scopePath) => !hasScopePath(text, scopePath)))
|
|
270
|
-
return false;
|
|
271
|
-
if (!hasTicketLookupOrCreationCue(text))
|
|
272
|
-
return false;
|
|
273
|
-
// Contents without the QA-receipts rule are the older vocabulary and must
|
|
274
|
-
// read as stale — otherwise machines with a hand-written ticket-binding
|
|
275
|
-
// section never receive the definition-of-done rule.
|
|
276
|
-
if (!hasQaReceiptsCue(text))
|
|
277
|
-
return false;
|
|
278
|
-
const signals = [
|
|
279
|
-
/cockpit\s+start\s+--ticket\b/u,
|
|
280
|
-
/before\s+(?:the\s+)?first\s+code\s+edit|before\s+ticketed\s+implementation/u,
|
|
281
|
-
/general\s+ambient/u,
|
|
282
|
-
/cockpit\s+sync\s+--repo|cockpit\s+sync\s+--workspace|fresh\s+ticket\/session\s+binding\s+metadata/u,
|
|
283
|
-
/use\s+--ticket|the\s+flag\s+is\s+--ticket|do\s+not\s+invent\s+--ticketid/u,
|
|
284
|
-
];
|
|
285
|
-
const score = signals.filter((signal) => signal.test(text)).length;
|
|
286
|
-
return score >= 4;
|
|
287
|
-
}
|
|
288
|
-
function hasQaReceiptsCue(text) {
|
|
289
|
-
return /computer-use\s+qa\s+pass/u.test(text)
|
|
290
|
-
&& /receipts/u.test(text);
|
|
291
|
-
}
|
|
292
|
-
function hasTicketLookupOrCreationCue(text) {
|
|
293
|
-
return /search\s+linear|create\s+(?:a\s+)?(?:narrow\s+)?linear\s+ticket|new\s+linear\s+ticket/u.test(text);
|
|
294
|
-
}
|
|
295
|
-
function hasScopePath(text, scopePath) {
|
|
296
|
-
return text.includes(normalizeRuleText(path.resolve(scopePath)));
|
|
297
|
-
}
|
|
298
|
-
function hasRepoScopeGuard(text) {
|
|
299
|
-
return (/only\s+applies\s+when\s+the\s+current\s+working\s+directory\s+is\s+inside/u.test(text) ||
|
|
300
|
-
/outside\s+that\s+(?:folder|workspace|repo).*(?:do\s+not|dont)\s+run\s+cockpit/u.test(text) ||
|
|
301
|
-
/private\s+chats\s+or\s+unrelated\s+repos/u.test(text));
|
|
302
|
-
}
|
|
303
|
-
function findStaleUnmanagedTicketBindingBlock(contents, scopePaths = []) {
|
|
304
|
-
const lines = contents.split("\n");
|
|
305
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
306
|
-
if (!/^#{1,6}\s+.*(?:cockpit\s+)?ticket\s+binding\b/iu.test(lines[index] ?? "")) {
|
|
307
|
-
continue;
|
|
308
|
-
}
|
|
309
|
-
let endLine = lines.length;
|
|
310
|
-
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
311
|
-
if (/^#{1,6}\s+\S/u.test(lines[cursor] ?? "")) {
|
|
312
|
-
endLine = cursor;
|
|
313
|
-
break;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
const candidate = lines.slice(index, endLine).join("\n");
|
|
317
|
-
const normalized = normalizeRuleText(candidate);
|
|
318
|
-
if (hasTicketBindingCues(normalized) &&
|
|
319
|
-
!hasEquivalentUnmanagedTicketBinding(candidate, scopePaths)) {
|
|
320
|
-
return { startLine: index, endLine };
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
return null;
|
|
324
|
-
}
|
|
325
|
-
function replaceLineSpan(contents, startLine, endLine, replacement) {
|
|
326
|
-
const lines = contents.split("\n");
|
|
327
|
-
const before = lines.slice(0, startLine).join("\n").trimEnd();
|
|
328
|
-
const after = lines.slice(endLine).join("\n").trimStart();
|
|
329
|
-
return [before, replacement, after]
|
|
330
|
-
.filter((part) => part.trim().length > 0)
|
|
331
|
-
.join("\n\n")
|
|
332
|
-
.replace(/\n{3,}/gu, "\n\n")
|
|
333
|
-
.trimEnd() + "\n";
|
|
334
|
-
}
|
|
335
|
-
function hasTicketBindingCues(text) {
|
|
336
|
-
return /\bcockpit\b/u.test(text) && /\bticket\b/u.test(text) && /binding|agent|linear/u.test(text);
|
|
337
|
-
}
|
|
338
|
-
function normalizeRuleText(contents) {
|
|
339
|
-
return contents
|
|
340
|
-
.toLowerCase()
|
|
341
|
-
.replace(/[`"'<>]/gu, "")
|
|
342
|
-
.replace(/\s+/gu, " ")
|
|
343
|
-
.trim();
|
|
344
|
-
}
|
|
345
264
|
function escapeRegExp(value) {
|
|
346
265
|
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
347
266
|
}
|
|
@@ -139,6 +139,8 @@ function sessionReportEntry(result, context) {
|
|
|
139
139
|
? (context.claudePriorDurablePointers.get(result.session_id) ?? null)
|
|
140
140
|
: null;
|
|
141
141
|
return {
|
|
142
|
+
ticket_id: context.ticketByWorktree?.get(result.worktree?.worktree_fingerprint ?? "") ?? null,
|
|
143
|
+
ticket_binding_source: context.ticketByWorktree?.has(result.worktree?.worktree_fingerprint ?? "") ? "explicit_bind" : null,
|
|
142
144
|
codex_session_id: result.session_id,
|
|
143
145
|
source: result.source,
|
|
144
146
|
observed_at: context.now.toISOString(),
|
|
@@ -225,6 +227,7 @@ export function buildAgentSessionReport(options) {
|
|
|
225
227
|
const uploadOutcomes = sessionUploadOutcomesByKey(options.outcomes);
|
|
226
228
|
return [...bestByKey.values()].map((result) => sessionReportEntry(result, {
|
|
227
229
|
...uploadOutcomes,
|
|
230
|
+
ticketByWorktree: options.ticketByWorktree,
|
|
228
231
|
claudePriorDurablePointers: options.claudePriorDurablePointers,
|
|
229
232
|
now: options.now,
|
|
230
233
|
}));
|