@bli-cockpit/cli 0.1.26 → 0.1.28

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/README.md CHANGED
@@ -45,11 +45,11 @@ cockpit onboard --email <APPROVED_EMAIL> --workspace ~/BLI --workspace ~/side-pr
45
45
  The CLI defaults to the production dashboard. Normal intern/operator setup,
46
46
  updates, and syncs omit `--dashboard-url`. Pass `--dashboard-url` only for
47
47
  staging, a custom dashboard, or deliberately forcing a different dashboard
48
- pairing. Already-onboarded users update with
49
- `npm install -g @bli-cockpit/cli@latest`, then run
50
- `cockpit onboard` from anywhere to refresh pairing, agent rules, autostart, and
51
- an initial sync against saved roots. `--repo <path>` remains supported for older
52
- prompts and the agent ticket-binding guardrail.
48
+ pairing. Already-onboarded users update with `cockpit update` from anywhere. It
49
+ installs the latest public CLI from npm, then reruns onboarding checks to
50
+ refresh pairing, agent rules, autostart, and an initial sync against saved
51
+ roots. `cockpit upgrade` is a compatibility alias. `--repo <path>` remains
52
+ supported for older prompts and the agent ticket-binding guardrail.
53
53
 
54
54
  On machines where Codex or Claude agents will do ticketed work, `cockpit
55
55
  onboard` refreshes `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` after harvest
@@ -191,6 +191,10 @@ Remote dashboard:
191
191
  for screenshots/images explicitly attached into Codex or Claude sessions;
192
192
  - Codex session attribution records (session id, file hash, attribution state
193
193
  and reason labels, scores) accepted by `/api/ambient/codex-sessions`.
194
+ The local collector scans both active `~/.codex/sessions` files and archived
195
+ `~/.codex/archived_sessions` files, using only `session_meta` and
196
+ `turn_context` metadata for attribution. Transcript bytes are sanitized or
197
+ blocked later by raw evidence collection before upload.
194
198
 
195
199
  New raw evidence object keys are readable from the Storage browser:
196
200
 
@@ -212,6 +216,9 @@ Local attribution preview:
212
216
  cockpit sessions --workspace "$PWD" --json
213
217
  ```
214
218
 
219
+ This preview includes both active and archived Codex sessions in the bounded
220
+ backfill window, plus Claude Code sessions when Claude collection is enabled.
221
+
215
222
  Remote metadata path:
216
223
 
217
224
  1. Query `ambient_evidence_refs` by `operator_user_id`, `received_at`, and
@@ -1,8 +1,11 @@
1
- import { SECRET_FILE_SEGMENT_PATTERN, containsSecretLikeContent, } from "@bli-cockpit/telemetry-core";
1
+ import { SECRET_FILE_SEGMENT_PATTERN, } from "@bli-cockpit/telemetry-core";
2
+ import { createReadStream } from "node:fs";
3
+ import crypto from "node:crypto";
2
4
  import fs from "node:fs/promises";
3
5
  import path from "node:path";
6
+ import { StringDecoder } from "node:string_decoder";
4
7
  import { normalizeGitOrigin } from "../repo-identity.js";
5
- import { sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName, sha256, shortHash, } from "./attribution-core.js";
8
+ import { sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName, shortHash, } from "./attribution-core.js";
6
9
  /**
7
10
  * Deterministic Codex session JSONL -> repo/worktree attribution.
8
11
  *
@@ -18,12 +21,24 @@ import { sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName,
18
21
  export { sanitizeSessionId, sessionIdFromFileName };
19
22
  export const CODEX_ATTRIBUTION_DEFAULT_SINCE_MINUTES = 24 * 60;
20
23
  export const CODEX_ATTRIBUTION_DEFAULT_SESSION_LIMIT = 50;
24
+ export const CODEX_ATTRIBUTION_BACKFILL_SINCE_MINUTES = 14 * 24 * 60;
25
+ export const CODEX_ATTRIBUTION_BACKFILL_SESSION_LIMIT = 500;
26
+ // Compatibility export only. Codex attribution streams full files for metadata
27
+ // and no longer rejects sessions by file size; raw evidence collection enforces
28
+ // upload budgets and content guards later.
21
29
  export const CODEX_SESSION_MAX_FILE_BYTES = 10 * 1024 * 1024;
30
+ const CODEX_ATTRIBUTION_MAX_LINE_BYTES = 2 * 1024 * 1024;
31
+ export function defaultCodexSessionDirs(homeDir) {
32
+ return [
33
+ path.join(homeDir, ".codex", "sessions"),
34
+ path.join(homeDir, ".codex", "archived_sessions"),
35
+ ];
36
+ }
22
37
  export async function scanAndAttributeCodexSessions(options) {
23
38
  const sinceMinutes = options.sinceMinutes ?? CODEX_ATTRIBUTION_DEFAULT_SINCE_MINUTES;
24
39
  const limit = options.limit ?? CODEX_ATTRIBUTION_DEFAULT_SESSION_LIMIT;
25
40
  const cutoffMs = options.now.getTime() - sinceMinutes * 60 * 1000;
26
- const discovery = await discoverCodexJsonlFiles(options.sessionsDir, cutoffMs);
41
+ const discovery = await discoverCodexJsonlFiles(codexSessionDirsFromOptions(options), cutoffMs);
27
42
  const files = discovery.files.slice(0, limit);
28
43
  const results = [];
29
44
  for (const file of files) {
@@ -36,7 +51,7 @@ export async function scanAndAttributeCodexSessions(options) {
36
51
  since_minutes: sinceMinutes,
37
52
  session_limit: limit,
38
53
  session_limit_applied: discovery.files.length > limit,
39
- max_file_bytes: CODEX_SESSION_MAX_FILE_BYTES,
54
+ max_file_bytes: 0,
40
55
  directory_read_failed_count: discovery.directoryReadFailedCount,
41
56
  stat_failed_count: discovery.statFailedCount,
42
57
  secret_path_skipped_count: discovery.secretPathSkippedCount,
@@ -57,7 +72,8 @@ async function discoverCodexJsonlFiles(dir, cutoffMs) {
57
72
  let directoryReadFailedCount = 0;
58
73
  let statFailedCount = 0;
59
74
  let secretPathSkippedCount = 0;
60
- const stack = [dir];
75
+ const stack = Array.isArray(dir) ? [...dir] : [dir];
76
+ const seenFiles = new Set();
61
77
  while (stack.length > 0) {
62
78
  const current = stack.pop();
63
79
  if (!current)
@@ -100,7 +116,11 @@ async function discoverCodexJsonlFiles(dir, cutoffMs) {
100
116
  continue;
101
117
  }
102
118
  if (stat.mtimeMs >= cutoffMs) {
103
- out.push({ file: full, mtimeMs: stat.mtimeMs, byteSize: stat.size });
119
+ const dedupeKey = await fs.realpath(full).catch(() => path.resolve(full));
120
+ if (!seenFiles.has(dedupeKey)) {
121
+ seenFiles.add(dedupeKey);
122
+ out.push({ file: full, mtimeMs: stat.mtimeMs, byteSize: stat.size });
123
+ }
104
124
  }
105
125
  }
106
126
  }
@@ -112,6 +132,22 @@ async function discoverCodexJsonlFiles(dir, cutoffMs) {
112
132
  secretPathSkippedCount,
113
133
  };
114
134
  }
135
+ function codexSessionDirsFromOptions(options) {
136
+ const dirs = options.sessionsDirs ?? (options.sessionsDir ? [options.sessionsDir] : []);
137
+ return dedupePaths(dirs);
138
+ }
139
+ function dedupePaths(values) {
140
+ const seen = new Set();
141
+ const out = [];
142
+ for (const value of values) {
143
+ const resolved = path.resolve(value);
144
+ if (seen.has(resolved))
145
+ continue;
146
+ seen.add(resolved);
147
+ out.push(resolved);
148
+ }
149
+ return out;
150
+ }
115
151
  async function attributeOneSession(file, worktrees) {
116
152
  const fileName = path.basename(file.file);
117
153
  const base = {
@@ -128,23 +164,16 @@ async function attributeOneSession(file, worktrees) {
128
164
  if (isSecretLikePath(fileName)) {
129
165
  return skippedResult(base, "secret_like_file_name");
130
166
  }
131
- if (file.byteSize > CODEX_SESSION_MAX_FILE_BYTES) {
132
- return skippedResult(base, "file_too_large");
133
- }
134
- let raw;
167
+ let read;
135
168
  try {
136
- raw = await fs.readFile(file.file);
169
+ read = await readCodexMetadataSignals(file.file);
137
170
  }
138
171
  catch {
139
172
  return skippedResult(base, "file_read_failed");
140
173
  }
141
- const content = raw.toString("utf8");
142
- base.content_hash_sha256 = sha256(raw);
143
- base.byte_size = raw.byteLength;
144
- if (containsSecretLikeContent(content)) {
145
- return skippedResult(base, "secret_like_content_guard");
146
- }
147
- const signals = extractCodexSessionSignals(content);
174
+ base.content_hash_sha256 = read.contentHashSha256;
175
+ base.byte_size = read.byteSize;
176
+ const signals = read.signals;
148
177
  const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
149
178
  if (metaSessionId) {
150
179
  base.codex_session_id = metaSessionId;
@@ -174,72 +203,132 @@ async function attributeOneSession(file, worktrees) {
174
203
  }, worktrees);
175
204
  return { ...base, ...outcome };
176
205
  }
206
+ async function readCodexMetadataSignals(filePath) {
207
+ const hash = crypto.createHash("sha256");
208
+ const decoder = new StringDecoder("utf8");
209
+ const state = makeSignalExtractionState();
210
+ const stream = createReadStream(filePath);
211
+ let byteSize = 0;
212
+ let lineBuffer = "";
213
+ let discardingOversizedLine = false;
214
+ for await (const chunk of stream) {
215
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
216
+ byteSize += buffer.byteLength;
217
+ hash.update(buffer);
218
+ const text = decoder.write(buffer);
219
+ const segments = text.split("\n");
220
+ for (let index = 0; index < segments.length; index += 1) {
221
+ const segment = segments[index] ?? "";
222
+ const lineEnded = index < segments.length - 1;
223
+ if (discardingOversizedLine) {
224
+ if (lineEnded)
225
+ discardingOversizedLine = false;
226
+ continue;
227
+ }
228
+ lineBuffer += segment;
229
+ if (Buffer.byteLength(lineBuffer, "utf8") > CODEX_ATTRIBUTION_MAX_LINE_BYTES) {
230
+ state.lineCount += 1;
231
+ state.parseErrorCount += 1;
232
+ lineBuffer = "";
233
+ discardingOversizedLine = !lineEnded;
234
+ continue;
235
+ }
236
+ if (lineEnded) {
237
+ absorbCodexSessionLine(state, lineBuffer);
238
+ lineBuffer = "";
239
+ }
240
+ }
241
+ }
242
+ const rest = decoder.end();
243
+ if (rest)
244
+ lineBuffer += rest;
245
+ if (lineBuffer.trim())
246
+ absorbCodexSessionLine(state, lineBuffer);
247
+ return {
248
+ signals: codexSignalsFromState(state),
249
+ contentHashSha256: hash.digest("hex"),
250
+ byteSize,
251
+ };
252
+ }
177
253
  export function extractCodexSessionSignals(content) {
178
- const sessionIds = new Set();
179
- const cwds = new Set();
180
- const workspaceRoots = new Set();
181
- const branches = new Set();
182
- const commitHashes = new Set();
183
- const repositoryUrls = new Set();
184
- let lineCount = 0;
185
- let parseErrorCount = 0;
254
+ const state = makeSignalExtractionState();
186
255
  for (const line of content.split("\n")) {
187
- if (!line.trim())
188
- continue;
189
- lineCount += 1;
190
- let record;
191
- try {
192
- record = JSON.parse(line);
193
- }
194
- catch {
195
- parseErrorCount += 1;
196
- continue;
197
- }
198
- if (!record || typeof record !== "object")
199
- continue;
200
- const type = record.type;
201
- const payload = record.payload;
202
- if (!payload || typeof payload !== "object")
203
- continue;
204
- const payloadRecord = payload;
205
- if (type === "session_meta") {
206
- addString(sessionIds, payloadRecord["id"]);
207
- addString(cwds, payloadRecord["cwd"]);
208
- const git = payloadRecord["git"];
209
- if (git && typeof git === "object") {
210
- const gitRecord = git;
211
- addString(branches, gitRecord["branch"] ?? gitRecord["current_branch"]);
212
- addString(commitHashes, gitRecord["commit_hash"]);
213
- const repositoryUrl = gitRecord["repository_url"];
214
- if (typeof repositoryUrl === "string" && repositoryUrl.trim()) {
215
- repositoryUrls.add(normalizeGitOrigin(repositoryUrl));
216
- }
256
+ absorbCodexSessionLine(state, line);
257
+ }
258
+ return codexSignalsFromState(state);
259
+ }
260
+ function makeSignalExtractionState() {
261
+ return {
262
+ sessionIds: new Set(),
263
+ cwds: new Set(),
264
+ workspaceRoots: new Set(),
265
+ branches: new Set(),
266
+ commitHashes: new Set(),
267
+ repositoryUrls: new Set(),
268
+ lineCount: 0,
269
+ parseErrorCount: 0,
270
+ };
271
+ }
272
+ function absorbCodexSessionLine(state, line) {
273
+ if (!line.trim())
274
+ return;
275
+ state.lineCount += 1;
276
+ let record;
277
+ try {
278
+ record = JSON.parse(line);
279
+ }
280
+ catch {
281
+ state.parseErrorCount += 1;
282
+ return;
283
+ }
284
+ if (!record || typeof record !== "object")
285
+ return;
286
+ const type = record.type;
287
+ if (type !== "session_meta" && type !== "turn_context")
288
+ return;
289
+ const payload = record.payload;
290
+ if (!payload || typeof payload !== "object")
291
+ return;
292
+ const payloadRecord = payload;
293
+ if (type === "session_meta") {
294
+ addString(state.sessionIds, payloadRecord["id"]);
295
+ addString(state.cwds, payloadRecord["cwd"]);
296
+ const git = payloadRecord["git"];
297
+ if (git && typeof git === "object") {
298
+ const gitRecord = git;
299
+ addString(state.branches, gitRecord["branch"] ?? gitRecord["current_branch"]);
300
+ addString(state.commitHashes, gitRecord["commit_hash"]);
301
+ const repositoryUrl = gitRecord["repository_url"];
302
+ if (typeof repositoryUrl === "string" && repositoryUrl.trim()) {
303
+ state.repositoryUrls.add(normalizeGitOrigin(repositoryUrl));
217
304
  }
218
305
  }
219
- else if (type === "turn_context") {
220
- addString(cwds, payloadRecord["cwd"]);
221
- const roots = payloadRecord["workspace_roots"];
222
- if (Array.isArray(roots)) {
223
- for (const root of roots) {
224
- if (typeof root === "string") {
225
- addString(workspaceRoots, root);
226
- }
227
- else if (root && typeof root === "object") {
228
- addString(workspaceRoots, root["path"]);
229
- }
306
+ }
307
+ else if (type === "turn_context") {
308
+ addString(state.cwds, payloadRecord["cwd"]);
309
+ const roots = payloadRecord["workspace_roots"];
310
+ if (Array.isArray(roots)) {
311
+ for (const root of roots) {
312
+ if (typeof root === "string") {
313
+ addString(state.workspaceRoots, root);
314
+ }
315
+ else if (root && typeof root === "object") {
316
+ addString(state.workspaceRoots, root["path"]);
230
317
  }
231
318
  }
232
319
  }
233
320
  }
321
+ }
322
+ function codexSignalsFromState(state) {
234
323
  return {
235
- session_ids: [...sessionIds],
236
- cwds: [...cwds],
237
- workspace_roots: [...workspaceRoots],
238
- branches: [...branches],
239
- commit_hashes: [...commitHashes],
240
- repository_urls: [...repositoryUrls],
241
- line_count: lineCount,
242
- parse_error_count: parseErrorCount,
324
+ session_ids: [...state.sessionIds],
325
+ cwds: [...state.cwds],
326
+ workspace_roots: [...state.workspaceRoots],
327
+ branches: [...state.branches],
328
+ commit_hashes: [...state.commitHashes],
329
+ repository_urls: [...state.repositoryUrls],
330
+ line_count: state.lineCount,
331
+ parse_error_count: state.parseErrorCount,
243
332
  };
244
333
  }
245
334
  function skippedResult(base, reason) {
@@ -25,6 +25,7 @@ export async function runLocalSourceCollectors(options) {
25
25
  stateDir: options.rawEvidenceStateDir,
26
26
  repoRoot: options.repoRoot,
27
27
  sessionsDir: options.rawEvidenceSessionsDir,
28
+ sessionsDirs: options.rawEvidenceSessionsDirs,
28
29
  includeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
29
30
  includeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
30
31
  codexSessionFiles: options.rawEvidenceCodexSessionFiles,
@@ -6,6 +6,7 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { makeSourceAdapterIdentity, } from "./common.js";
8
8
  import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
9
+ import { defaultCodexSessionDirs, } from "./codex-attribution.js";
9
10
  const DEFAULT_SINCE_MINUTES = 24 * 60;
10
11
  const DEFAULT_SESSION_LIMIT = 50;
11
12
  const MAX_GIT_DIFF_BYTES = 2 * 1024 * 1024;
@@ -104,6 +105,7 @@ export async function collectRawEvidencePack(context, options) {
104
105
  await collectCodexJsonlFiles(collection, {
105
106
  codexSessionFiles: options.codexSessionFiles,
106
107
  sessionsDir: options.sessionsDir,
108
+ sessionsDirs: options.sessionsDirs,
107
109
  sinceMinutes,
108
110
  limit: sessionLimit,
109
111
  });
@@ -311,11 +313,6 @@ function recordCodexAttributionCompleteness(collection, scan) {
311
313
  limit: scan.session_limit,
312
314
  observed: scan.discovered_file_count,
313
315
  applied: scan.session_limit_applied,
314
- }, {
315
- source: "codex_attribution",
316
- cap_type: "max_file_bytes",
317
- limit: scan.max_file_bytes,
318
- applied: scan.results.some((result) => result.reason === "file_too_large"),
319
316
  });
320
317
  recordSkipCount(collection, "codex_attribution", "session_limit_overflow", Math.max(0, scan.discovered_file_count - scan.scanned_file_count));
321
318
  recordSkipCount(collection, "codex_attribution", "directory_read_failed", scan.directory_read_failed_count);
@@ -391,7 +388,10 @@ async function collectCodexJsonlFiles(collection, options) {
391
388
  filePath: file.local_path,
392
389
  codexSessionId: file.codex_session_id,
393
390
  }))
394
- : (await walkJsonlFiles(options.sessionsDir ?? path.join(os.homedir(), ".codex", "sessions"), collection.context.now.getTime() - options.sinceMinutes * 60 * 1000))
391
+ : (await walkJsonlFiles(options.sessionsDirs ??
392
+ (options.sessionsDir
393
+ ? [options.sessionsDir]
394
+ : defaultCodexSessionDirs(os.homedir())), collection.context.now.getTime() - options.sinceMinutes * 60 * 1000))
395
395
  .map((filePath) => ({ filePath, codexSessionId: null }));
396
396
  collection.caps.push({
397
397
  source: "codex_jsonl",
@@ -573,10 +573,9 @@ async function collectOneAgentImageFile(collection, options) {
573
573
  }
574
574
  /**
575
575
  * Reads, secret-guards, content-addresses, budget-checks, and copies one
576
- * attributed transcript into the pack. The secret guard runs again here even
577
- * though attribution already guarded — three reads per file (attribution,
578
- * collection, server commit) is the accepted defense-in-depth cost; do not
579
- * "optimize" a layer away.
576
+ * attributed transcript into the pack. Attribution only reads metadata records;
577
+ * this collection layer and the server commit layer are the two content guards
578
+ * that decide whether transcript bytes can become durable evidence.
580
579
  */
581
580
  async function collectOneEvidenceFile(collection, options) {
582
581
  const fileName = path.basename(options.filePath);
@@ -1075,7 +1074,8 @@ function makeEvidenceCompleteness(collection, options) {
1075
1074
  }
1076
1075
  async function walkJsonlFiles(dir, cutoffMs) {
1077
1076
  const out = [];
1078
- const stack = [dir];
1077
+ const stack = Array.isArray(dir) ? [...dir] : [dir];
1078
+ const seen = new Set();
1079
1079
  while (stack.length > 0) {
1080
1080
  const current = stack.pop();
1081
1081
  if (!current || isSecretLikePath(current))
@@ -1098,8 +1098,13 @@ async function walkJsonlFiles(dir, cutoffMs) {
1098
1098
  if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
1099
1099
  continue;
1100
1100
  const stat = await fs.stat(full);
1101
- if (stat.mtimeMs >= cutoffMs)
1102
- out.push({ file: full, mtimeMs: stat.mtimeMs });
1101
+ if (stat.mtimeMs >= cutoffMs) {
1102
+ const dedupeKey = await fs.realpath(full).catch(() => path.resolve(full));
1103
+ if (!seen.has(dedupeKey)) {
1104
+ seen.add(dedupeKey);
1105
+ out.push({ file: full, mtimeMs: stat.mtimeMs });
1106
+ }
1107
+ }
1103
1108
  }
1104
1109
  }
1105
1110
  out.sort((a, b) => b.mtimeMs - a.mtimeMs);
@@ -12,6 +12,9 @@ export function parseLocalArgs(argv) {
12
12
  switch (command) {
13
13
  case "onboard":
14
14
  return parseOnboardArgs(argv.slice(1));
15
+ case "update":
16
+ case "upgrade":
17
+ return parseUpdateArgs(command, argv.slice(1));
15
18
  case "install":
16
19
  return parseInstallArgs(argv.slice(1));
17
20
  case "login":
@@ -33,11 +36,13 @@ export function parseLocalArgs(argv) {
33
36
  return parseAutostartArgs(argv.slice(1));
34
37
  case "agent-rules":
35
38
  return parseAgentRulesArgs(argv.slice(1));
39
+ case "release":
40
+ return parseReleaseArgs(argv.slice(1));
36
41
  default:
37
42
  throw new Error(`Unknown local command: ${command ?? ""}`);
38
43
  }
39
44
  }
40
- function parseOnboardArgs(args) {
45
+ function parseOnboardLikeArgs(args, command) {
41
46
  const values = parseNamedArgs(args, {
42
47
  allowedFlags: [
43
48
  "--home",
@@ -69,13 +74,13 @@ function parseOnboardArgs(args) {
69
74
  "--max-repos",
70
75
  ],
71
76
  });
72
- assertNoPositionals(values.positionals, "onboard");
77
+ assertNoPositionals(values.positionals, command);
73
78
  return {
74
- kind: "onboard",
75
79
  homeDir: optionalNonEmpty(values.flags.get("--home")),
76
80
  repoRoot: optionalNonEmpty(workRootFlagValue(values)),
77
81
  collectionRoots: optionalNonEmptyList(workRootFlagValues(values)),
78
82
  dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
83
+ dashboardUrlExplicit: values.flags.has("--dashboard-url"),
79
84
  claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
80
85
  deviceName: optionalNonEmpty(values.flags.get("--device-name")),
81
86
  activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
@@ -87,6 +92,16 @@ function parseOnboardArgs(args) {
87
92
  maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
88
93
  };
89
94
  }
95
+ function parseOnboardArgs(args) {
96
+ return { kind: "onboard", ...parseOnboardLikeArgs(args, "onboard") };
97
+ }
98
+ function parseUpdateArgs(alias, args) {
99
+ return {
100
+ kind: "update",
101
+ alias,
102
+ ...parseOnboardLikeArgs(args, alias),
103
+ };
104
+ }
90
105
  function parseInstallArgs(args) {
91
106
  const values = parseNamedArgs(args, {
92
107
  allowedFlags: [
@@ -115,6 +130,30 @@ function parseInstallArgs(args) {
115
130
  json: values.booleans.has("--json"),
116
131
  };
117
132
  }
133
+ function parseReleaseArgs(args) {
134
+ const values = parseNamedArgs(args, {
135
+ allowedFlags: [
136
+ "--dry-run",
137
+ "--tag",
138
+ "--access",
139
+ "--otp",
140
+ "--skip-checks",
141
+ ],
142
+ valueFlags: ["--tag", "--access", "--otp"],
143
+ });
144
+ assertNoPositionals(values.positionals, "release");
145
+ const releaseArgs = [];
146
+ if (values.booleans.has("--dry-run"))
147
+ releaseArgs.push("--dry-run");
148
+ if (values.booleans.has("--skip-checks"))
149
+ releaseArgs.push("--skip-checks");
150
+ for (const flag of ["--tag", "--access", "--otp"]) {
151
+ const value = values.flags.get(flag);
152
+ if (value !== undefined)
153
+ releaseArgs.push(flag, value);
154
+ }
155
+ return { kind: "release", args: releaseArgs };
156
+ }
118
157
  function parseLoginArgs(args) {
119
158
  const values = parseNamedArgs(args, {
120
159
  allowedFlags: [
@@ -1,4 +1,5 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { createCollectorServer } from "../server.js";
@@ -6,7 +7,7 @@ import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../a
6
7
  import { parseLocalArgs, normalizeUrl } from "./local-args.js";
7
8
  import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
8
9
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
9
- import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
10
+ import { CODEX_ATTRIBUTION_BACKFILL_SESSION_LIMIT, CODEX_ATTRIBUTION_BACKFILL_SINCE_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
10
11
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
11
12
  import { acquireSyncLock } from "../sync-lock.js";
12
13
  import { discoverGitWorktrees } from "../repo-identity.js";
@@ -14,6 +15,8 @@ import { runAttributedWorktreeSync, } from "./session-sync.js";
14
15
  import { COLLECTION_ROOT_REQUIRED, resolveOnboardingRoots, } from "../onboarding-roots.js";
15
16
  export const rootCommandNames = new Set([
16
17
  "onboard",
18
+ "update",
19
+ "upgrade",
17
20
  "install",
18
21
  "login",
19
22
  "pair",
@@ -25,6 +28,7 @@ export const rootCommandNames = new Set([
25
28
  "serve",
26
29
  "autostart",
27
30
  "agent-rules",
31
+ "release",
28
32
  ]);
29
33
  export async function runLocalCockpitCli(argv, io = defaultIo()) {
30
34
  if (isLocalHelpRequest(argv)) {
@@ -47,6 +51,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
47
51
  return await runInstall(command, io);
48
52
  case "onboard":
49
53
  return await runOnboard(command, io);
54
+ case "update":
55
+ return await runUpdate(command, io);
50
56
  case "login":
51
57
  return await runLogin(command, io);
52
58
  case "logout":
@@ -65,6 +71,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
65
71
  return await runAutostart(command, io);
66
72
  case "agent-rules":
67
73
  return await runAgentRules(command, io);
74
+ case "release":
75
+ return await runRelease(command, io);
68
76
  }
69
77
  }
70
78
  catch (error) {
@@ -77,6 +85,8 @@ export function localCommandHelp(command) {
77
85
  return localSubcommandHelp(command);
78
86
  return [
79
87
  " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
88
+ " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--json]",
89
+ " cockpit upgrade [same flags as update]",
80
90
  " cockpit install [--dashboard-url <url>] [--workspace <path>] [--json]",
81
91
  " cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
82
92
  " cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
@@ -88,6 +98,7 @@ export function localCommandHelp(command) {
88
98
  " cockpit serve [--port <port>] [--workspace <path>]",
89
99
  " cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
90
100
  " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
101
+ " cockpit release [--dry-run] [--skip-checks] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
91
102
  "",
92
103
  `Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
93
104
  ].join("\n");
@@ -118,6 +129,25 @@ function localSubcommandHelp(command) {
118
129
  "Omit --dashboard-url for the production dashboard; pass it only for staging/custom dashboards.",
119
130
  ],
120
131
  ],
132
+ [
133
+ "update",
134
+ [
135
+ "Usage: cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--json]",
136
+ "",
137
+ "Updates the global public CLI from npm, then reruns `cockpit onboard`",
138
+ "with the same setup flags so pairing, saved roots, agent rules,",
139
+ "autostart, and the initial sync are refreshed in one command.",
140
+ "`cockpit upgrade` is an alias.",
141
+ ],
142
+ ],
143
+ [
144
+ "upgrade",
145
+ [
146
+ "Usage: cockpit upgrade [same flags as cockpit update]",
147
+ "",
148
+ "Alias for `cockpit update`.",
149
+ ],
150
+ ],
121
151
  [
122
152
  "login",
123
153
  [
@@ -222,6 +252,17 @@ function localSubcommandHelp(command) {
222
252
  "Action defaults to `install`.",
223
253
  ],
224
254
  ],
255
+ [
256
+ "release",
257
+ [
258
+ "Usage: cockpit release [--dry-run] [--skip-checks] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
259
+ "",
260
+ "Maintainer-only helper. Run from inside the bli-cockpit repo checkout.",
261
+ "Requires a clean `main` branch and runs `git pull --ff-only` before publishing.",
262
+ "Delegates to `npm run publish:public -- ...` so public packages are",
263
+ "built, checked, and published in the safe telemetry-core then CLI order.",
264
+ ],
265
+ ],
225
266
  ]);
226
267
  return (helpByCommand.get(command) ?? [localCommandHelp()]).join("\n");
227
268
  }
@@ -244,6 +285,186 @@ async function runInstall(command, io) {
244
285
  writeLine(io.stdout, "Next: run `cockpit login`, then `cockpit start` inside the repo; add `--ticket <id>` only when ticket work begins.");
245
286
  return 0;
246
287
  }
288
+ async function runUpdate(command, io) {
289
+ const exec = io.exec ?? defaultExec();
290
+ const installArgs = [
291
+ "install",
292
+ "-g",
293
+ "@bli-cockpit/cli@latest",
294
+ "--prefer-online",
295
+ ];
296
+ if (!command.json) {
297
+ writeLine(io.stdout, "Updating Cockpit CLI from npm...");
298
+ }
299
+ const install = await exec("npm", installArgs);
300
+ writeExecOutput(io, install, { stdout: !command.json, stderr: true });
301
+ if (install.code !== 0) {
302
+ if (command.json) {
303
+ writeLine(io.stdout, JSON.stringify({
304
+ status: "blocked",
305
+ step: "npm_install",
306
+ command: `npm ${installArgs.join(" ")}`,
307
+ exit_code: install.code,
308
+ }, null, 2));
309
+ }
310
+ else {
311
+ writeLine(io.stderr, "BLOCKED: npm install failed; Cockpit CLI was not refreshed.");
312
+ }
313
+ return install.code || 1;
314
+ }
315
+ if (!command.json) {
316
+ writeLine(io.stdout, "Cockpit CLI updated. Rechecking onboarding...");
317
+ }
318
+ const onboard = await exec("cockpit", [
319
+ "onboard",
320
+ ...updateOnboardArgs(command),
321
+ ]);
322
+ writeExecOutput(io, onboard, { stdout: true, stderr: true });
323
+ return onboard.code;
324
+ }
325
+ async function runRelease(command, io) {
326
+ const releaseRoot = await findPublicReleaseRoot(process.cwd());
327
+ if (!releaseRoot) {
328
+ writeLine(io.stderr, "cockpit release must be run inside the bli-cockpit repo checkout (missing publish:public script).");
329
+ return 1;
330
+ }
331
+ const exec = io.exec ?? defaultExec();
332
+ const gitReady = await prepareReleaseMainBranch(releaseRoot, exec, io);
333
+ if (!gitReady)
334
+ return 1;
335
+ writeLine(io.stdout, "Running Cockpit public package release...");
336
+ const npmArgs = ["--prefix", releaseRoot, "run", "publish:public"];
337
+ if (command.args.length > 0)
338
+ npmArgs.push("--", ...command.args);
339
+ const releaseExec = io.interactiveExec ?? defaultInteractiveExec();
340
+ const result = await releaseExec("npm", npmArgs);
341
+ writeExecOutput(io, result, { stdout: true, stderr: true });
342
+ return result.code;
343
+ }
344
+ async function prepareReleaseMainBranch(releaseRoot, exec, io) {
345
+ const branch = await exec("git", [
346
+ "-C",
347
+ releaseRoot,
348
+ "rev-parse",
349
+ "--abbrev-ref",
350
+ "HEAD",
351
+ ]);
352
+ writeExecOutput(io, branch, { stdout: false, stderr: true });
353
+ if (branch.code !== 0) {
354
+ writeLine(io.stderr, "BLOCKED: cockpit release could not read the current git branch.");
355
+ return false;
356
+ }
357
+ const currentBranch = branch.stdout.trim();
358
+ if (currentBranch !== "main") {
359
+ writeLine(io.stderr, `BLOCKED: cockpit release only publishes from main. Current branch is ${currentBranch || "unknown"}.`);
360
+ writeLine(io.stderr, "Merge the release changes, switch to main, then rerun `cockpit release`.");
361
+ return false;
362
+ }
363
+ const status = await exec("git", [
364
+ "-C",
365
+ releaseRoot,
366
+ "status",
367
+ "--porcelain",
368
+ ]);
369
+ writeExecOutput(io, status, { stdout: false, stderr: true });
370
+ if (status.code !== 0) {
371
+ writeLine(io.stderr, "BLOCKED: cockpit release could not inspect git status.");
372
+ return false;
373
+ }
374
+ if (status.stdout.trim()) {
375
+ writeLine(io.stderr, "BLOCKED: cockpit release requires a clean main checkout.");
376
+ writeLine(io.stderr, "Commit or discard local changes, then rerun `cockpit release`.");
377
+ return false;
378
+ }
379
+ writeLine(io.stdout, "Syncing main with git pull --ff-only...");
380
+ const pull = await exec("git", ["-C", releaseRoot, "pull", "--ff-only"]);
381
+ writeExecOutput(io, pull, { stdout: true, stderr: true });
382
+ if (pull.code !== 0) {
383
+ writeLine(io.stderr, "BLOCKED: git pull --ff-only failed; main is not safely current.");
384
+ return false;
385
+ }
386
+ return true;
387
+ }
388
+ async function findPublicReleaseRoot(startDir) {
389
+ let current = path.resolve(startDir);
390
+ while (true) {
391
+ const packageJsonPath = path.join(current, "package.json");
392
+ try {
393
+ const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
394
+ if (packageJson.scripts?.["publish:public"] !== undefined)
395
+ return current;
396
+ }
397
+ catch {
398
+ // Keep walking: nested packages may be missing package.json or have one
399
+ // without the release script.
400
+ }
401
+ const parent = path.dirname(current);
402
+ if (parent === current)
403
+ return null;
404
+ current = parent;
405
+ }
406
+ }
407
+ function updateOnboardArgs(command) {
408
+ const args = [];
409
+ if (command.homeDir)
410
+ args.push("--home", command.homeDir);
411
+ for (const root of updateCollectionRoots(command)) {
412
+ args.push("--workspace", root);
413
+ }
414
+ if (command.dashboardUrlExplicit) {
415
+ args.push("--dashboard-url", command.dashboardUrl);
416
+ }
417
+ if (command.claimedOwnerEmail)
418
+ args.push("--email", command.claimedOwnerEmail);
419
+ if (command.deviceName)
420
+ args.push("--device-name", command.deviceName);
421
+ if (command.activeTicketId)
422
+ args.push("--ticket", command.activeTicketId);
423
+ if (command.branch)
424
+ args.push("--branch", command.branch);
425
+ if (command.pollIntervalMs !== undefined) {
426
+ args.push("--poll-interval-ms", String(command.pollIntervalMs));
427
+ }
428
+ if (command.timeoutMs !== undefined) {
429
+ args.push("--timeout-ms", String(command.timeoutMs));
430
+ }
431
+ if (command.maxDepth !== undefined)
432
+ args.push("--max-depth", String(command.maxDepth));
433
+ if (command.maxRepos !== undefined)
434
+ args.push("--max-repos", String(command.maxRepos));
435
+ if (command.json)
436
+ args.push("--json");
437
+ return args;
438
+ }
439
+ function updateCollectionRoots(command) {
440
+ const roots = command.collectionRoots?.length
441
+ ? command.collectionRoots
442
+ : command.repoRoot
443
+ ? [command.repoRoot]
444
+ : [];
445
+ const seen = new Set();
446
+ const deduped = [];
447
+ for (const root of roots) {
448
+ if (seen.has(root))
449
+ continue;
450
+ seen.add(root);
451
+ deduped.push(root);
452
+ }
453
+ return deduped;
454
+ }
455
+ function writeExecOutput(io, result, options) {
456
+ if (options.stdout)
457
+ writeRaw(io.stdout, result.stdout);
458
+ if (options.stderr)
459
+ writeRaw(io.stderr, result.stderr);
460
+ }
461
+ function writeRaw(stream, text) {
462
+ if (!text)
463
+ return;
464
+ stream.write(text);
465
+ if (!text.endsWith("\n"))
466
+ stream.write("\n");
467
+ }
247
468
  function isInteractiveStdin(io) {
248
469
  return Boolean(io.stdin.isTTY);
249
470
  }
@@ -1143,9 +1364,11 @@ async function runSessions(command, io) {
1143
1364
  const wantClaude = command.source !== "codex";
1144
1365
  const codex = wantCodex
1145
1366
  ? await scanAndAttributeCodexSessions({
1146
- sessionsDir: path.join(homeDir, ".codex", "sessions"),
1367
+ sessionsDirs: defaultCodexSessionDirs(homeDir),
1147
1368
  worktrees,
1148
1369
  now,
1370
+ sinceMinutes: CODEX_ATTRIBUTION_BACKFILL_SINCE_MINUTES,
1371
+ limit: CODEX_ATTRIBUTION_BACKFILL_SESSION_LIMIT,
1149
1372
  })
1150
1373
  : null;
1151
1374
  const claude = wantClaude
@@ -1341,6 +1564,17 @@ function defaultExec() {
1341
1564
  });
1342
1565
  });
1343
1566
  }
1567
+ function defaultInteractiveExec() {
1568
+ return (cmd, args) => new Promise((resolve) => {
1569
+ const child = spawn(cmd, args, { stdio: "inherit" });
1570
+ child.on("error", (error) => {
1571
+ resolve({ code: 1, stdout: "", stderr: errorMessage(error) });
1572
+ });
1573
+ child.on("close", (code) => {
1574
+ resolve({ code: code ?? 1, stdout: "", stderr: "" });
1575
+ });
1576
+ });
1577
+ }
1344
1578
  function defaultIo() {
1345
1579
  if (!globalThis.fetch) {
1346
1580
  throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
@@ -1352,6 +1586,7 @@ function defaultIo() {
1352
1586
  env: process.env,
1353
1587
  fetch: globalThis.fetch.bind(globalThis),
1354
1588
  exec: defaultExec(),
1589
+ interactiveExec: defaultInteractiveExec(),
1355
1590
  };
1356
1591
  }
1357
1592
  function writeLine(stream, text) {
@@ -22,13 +22,15 @@ function cockpitHelp() {
22
22
  "Usage:",
23
23
  localCommandHelp(),
24
24
  "",
25
- "Install/update: `npm install -g @bli-cockpit/cli@latest`.",
25
+ "Install: `npm install -g @bli-cockpit/cli@latest`.",
26
+ "Update: run `cockpit update` to refresh the global CLI and rerun onboarding checks.",
26
27
  "Intern path: run `cockpit onboard`; it confirms a `/BLI` collection root before syncing.",
27
28
  "Headless/reused laptop path: `cockpit onboard --email <email> --workspace ~/BLI`.",
28
- "Already onboarded: rerun `cockpit onboard` from anywhere to refresh pairing, roots, agent rules, autostart, and sync.",
29
+ "Already onboarded: run `cockpit update` from anywhere to refresh pairing, roots, agent rules, autostart, and sync.",
29
30
  "Agent setup: `cockpit onboard` refreshes AGENTS.md/CLAUDE.md rules; use `cockpit agent-rules install --workspace ~/BLI` for repair.",
30
31
  "Dashboard URL is optional for normal production use; pass `--dashboard-url` only for staging/custom dashboards or forced re-pairing.",
31
32
  "Manual collector path: `install`, `login`, `start [--ticket <id>] [--topic <label>] [--intent <intent>] [--phase <phase>]`, `sync`, `status`, `agent-rules`.",
33
+ "Maintainer release path: merge to main first, then run `cockpit release --dry-run` and `cockpit release` from a clean main checkout.",
32
34
  ].join("\n");
33
35
  }
34
36
 
@@ -7,7 +7,7 @@ import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { getCollectorRuntimePaths, startLocalWorkContext, readLocalCollectorConfig } from "../local-state.js";
9
9
  import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope } from "../upload.js";
10
- import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
10
+ import { CODEX_ATTRIBUTION_BACKFILL_SESSION_LIMIT, CODEX_ATTRIBUTION_BACKFILL_SINCE_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
11
11
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
12
12
  import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
13
13
  import { CLAUDE_CURSOR_FILENAME, countStaleSessions, emptyRawEvidenceCursorState, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
@@ -30,9 +30,11 @@ export async function runAttributedWorktreeSync(options) {
30
30
  const paths = getCollectorRuntimePaths(options.homeDir);
31
31
  const claudeEnabled = await isClaudeCollectionEnabled(paths);
32
32
  const codexAttribution = await scanAndAttributeCodexSessions({
33
- sessionsDir: path.join(homeDir, ".codex", "sessions"),
33
+ sessionsDirs: defaultCodexSessionDirs(homeDir),
34
34
  worktrees: options.worktrees,
35
35
  now,
36
+ sinceMinutes: CODEX_ATTRIBUTION_BACKFILL_SINCE_MINUTES,
37
+ limit: CODEX_ATTRIBUTION_BACKFILL_SESSION_LIMIT,
36
38
  });
37
39
  // First run (D B.4 §8): without a Claude cursor yet, widen the window to 14
38
40
  // days so the first sync captures retroactive history instead of only 24h.
package/dist/upload.js CHANGED
@@ -2,6 +2,7 @@ import { AgentImageArtifactReportRequestSchema, EvidenceCompletenessPayloadSchem
2
2
  import path from "node:path";
3
3
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, readLocalWorkContextForRepo, } from "./local-state.js";
4
4
  import { runLocalSourceCollectors } from "./adapters/local-sources.js";
5
+ import { defaultCodexSessionDirs, } from "./adapters/codex-attribution.js";
5
6
  import { uploadRawEvidenceFilesChunked, } from "./evidence-upload-client.js";
6
7
  import { markObjectCommitted, readRawEvidenceCursor, writeRawEvidenceCursor, } from "./cursors/raw-evidence-cursor.js";
7
8
  import { recordUploadBlocked, recordUploadFailure, recordUploadSuccess, } from "./spool/local-spool.js";
@@ -57,7 +58,7 @@ export async function buildLocalAmbientEnvelope(options = {}) {
57
58
  workContextId: uploadContext.work_context_id,
58
59
  activeWorkContext: activeContext,
59
60
  rawEvidenceStateDir: paths.state_dir,
60
- rawEvidenceSessionsDir: path.join(paths.home_dir, ".codex", "sessions"),
61
+ rawEvidenceSessionsDirs: defaultCodexSessionDirs(paths.home_dir),
61
62
  claudeProjectsDir: path.join(paths.home_dir, ".claude", "projects"),
62
63
  rawEvidenceIncludeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
63
64
  rawEvidenceIncludeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {