@bli-cockpit/cli 0.1.29 → 0.1.30

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
@@ -50,6 +50,9 @@ installs the latest public CLI from npm, then reruns onboarding checks to
50
50
  refresh pairing, agent rules, autostart, and an initial sync against saved
51
51
  roots. `cockpit upgrade` is a compatibility alias. `--repo <path>` remains
52
52
  supported for older prompts and the agent ticket-binding guardrail.
53
+ When onboarding reports archived local sessions without a completed backfill,
54
+ follow [`docs/runbooks/cockpit-backfill.md`](../../docs/runbooks/cockpit-backfill.md):
55
+ run `cockpit update`, then run `cockpit backfill --all` as a separate command.
53
56
 
54
57
  On machines where Codex or Claude agents will do ticketed work, `cockpit
55
58
  onboard` refreshes `~/.codex/AGENTS.md` and `~/.claude/CLAUDE.md` after harvest
@@ -176,6 +179,8 @@ Local files:
176
179
  - `~/.local/state/bli-cockpit/cursors/raw-evidence.json`: hashes and labels of
177
180
  already-durable evidence so repeated syncs skip re-uploading the same
178
181
  content (no raw content is stored in the cursor).
182
+ - `~/.local/state/bli-cockpit/cursors/backfill.json`: high-water progress for
183
+ the explicit `cockpit backfill` command.
179
184
  - `.codex-autorunner/contextspace/active_context.md` in the work repo when a
180
185
  work context is active.
181
186
 
@@ -220,7 +225,8 @@ cockpit sessions --workspace "$PWD" --json
220
225
  ```
221
226
 
222
227
  This preview includes both active and archived Codex sessions in the bounded
223
- backfill window, plus Claude Code sessions when Claude collection is enabled.
228
+ scan window, plus Claude Code sessions when Claude collection is enabled. Use
229
+ `--since-days N` or `--all` when debugging older local history.
224
230
 
225
231
  Remote metadata path:
226
232
 
@@ -19,6 +19,7 @@ export const SCORE_BRANCH_MATCH = 0.15;
19
19
  export const SCORE_HEAD_SHA_MATCH = 0.05;
20
20
  export const ATTRIBUTION_MIN_SCORE = 0.4;
21
21
  export const ATTRIBUTION_MIN_MARGIN = 0.15;
22
+ export const ATTRIBUTED_FALLBACK_MAX_SCORE = 0.39;
22
23
  export const SESSION_FILE_UUID_PATTERN = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
23
24
  export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
24
25
  const originLabel = options.originLabel ?? "origin_url_match";
@@ -71,6 +72,9 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
71
72
  const best = scored[0];
72
73
  const secondBestScore = scored[1]?.score ?? 0;
73
74
  if (!best || best.score === 0) {
75
+ if (repoPathAbsentFromDisk([...signals.cwds, ...signals.workspaceRoots], options.collectionRoots ?? [])) {
76
+ return skipped("repo_not_on_disk");
77
+ }
74
78
  const reason = signals.cwds.length > 0 || signals.workspaceRoots.length > 0
75
79
  ? "cwd_outside_scanned_worktrees"
76
80
  : "no_matching_worktree_signals";
@@ -78,7 +82,8 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
78
82
  }
79
83
  const score = clampScore(best.score);
80
84
  const pathScore = clampScore(best.pathScore);
81
- if (best.score >= ATTRIBUTION_MIN_SCORE &&
85
+ if (best.pathScore > 0 &&
86
+ best.score >= ATTRIBUTION_MIN_SCORE &&
82
87
  best.score - secondBestScore >= ATTRIBUTION_MIN_MARGIN) {
83
88
  return {
84
89
  state: "attributed",
@@ -89,6 +94,16 @@ export function scoreSignalsAgainstWorktrees(signals, worktrees, options = {}) {
89
94
  worktree: best.worktree,
90
95
  };
91
96
  }
97
+ if (best.pathScore === 0) {
98
+ const fallback = fallbackToKnownRepoPrimaryWorkContext({
99
+ originLabel,
100
+ scored,
101
+ signals,
102
+ worktrees,
103
+ });
104
+ if (fallback)
105
+ return fallback;
106
+ }
92
107
  return {
93
108
  state: "ambiguous",
94
109
  reason: best.score - secondBestScore < ATTRIBUTION_MIN_MARGIN
@@ -110,6 +125,54 @@ function unattributed(reason) {
110
125
  worktree: null,
111
126
  };
112
127
  }
128
+ function skipped(reason) {
129
+ return {
130
+ state: "skipped",
131
+ reason,
132
+ signals: [],
133
+ attribution_score: 0,
134
+ path_score: 0,
135
+ worktree: null,
136
+ };
137
+ }
138
+ function fallbackToKnownRepoPrimaryWorkContext(options) {
139
+ const originCandidates = options.worktrees.filter((worktree) => worktree.repo_origin_url &&
140
+ options.signals.originUrls.includes(worktree.repo_origin_url));
141
+ if (originCandidates.length === 0)
142
+ return null;
143
+ const primaryByClone = originCandidates.filter((worktree) => worktree.worktree_is_primary);
144
+ const cloneCandidates = primaryByClone.length > 0 ? primaryByClone : originCandidates;
145
+ const cloneRoots = new Set(cloneCandidates.map((worktree) => path.resolve(worktree.repo_root)));
146
+ if (cloneRoots.size > 1) {
147
+ return {
148
+ state: "ambiguous",
149
+ reason: "multiple_repos_share_origin",
150
+ signals: [options.originLabel],
151
+ attribution_score: ATTRIBUTED_FALLBACK_MAX_SCORE,
152
+ path_score: 0,
153
+ worktree: null,
154
+ };
155
+ }
156
+ const primary = primaryByClone[0] ??
157
+ originCandidates.find((worktree) => worktree.worktree_is_primary) ??
158
+ originCandidates[0] ??
159
+ null;
160
+ if (!primary)
161
+ return null;
162
+ const primaryScore = options.scored.find((entry) => entry.worktree.worktree_fingerprint === primary.worktree_fingerprint);
163
+ const signals = new Set([
164
+ ...(primaryScore?.matched ?? [options.originLabel]),
165
+ "repo_primary_fallback",
166
+ ]);
167
+ return {
168
+ state: "attributed_fallback",
169
+ reason: "known_repo_primary_fallback",
170
+ signals: [...signals],
171
+ attribution_score: clampScore(Math.min(primaryScore?.score ?? SCORE_ORIGIN_MATCH, ATTRIBUTED_FALLBACK_MAX_SCORE)),
172
+ path_score: 0,
173
+ worktree: primary,
174
+ };
175
+ }
113
176
  /**
114
177
  * For each path signal, the single worktree whose root most specifically
115
178
  * contains it (the longest containing root). Returns the set of worktree
@@ -141,6 +204,12 @@ export function isPathWithin(candidate, root) {
141
204
  return (normalizedCandidate === normalizedRoot ||
142
205
  normalizedCandidate.startsWith(normalizedRoot + path.sep));
143
206
  }
207
+ function repoPathAbsentFromDisk(paths, collectionRoots) {
208
+ if (paths.length === 0 || collectionRoots.length === 0)
209
+ return false;
210
+ const roots = collectionRoots.map((root) => path.resolve(root));
211
+ return paths.some((value) => roots.some((root) => isPathWithin(value, root)));
212
+ }
144
213
  export function clampScore(value) {
145
214
  return Math.min(1, Math.max(0, Number(value.toFixed(4))));
146
215
  }
@@ -48,7 +48,7 @@ export async function scanAndAttributeClaudeSessions(options) {
48
48
  .slice(0, limit);
49
49
  const results = [];
50
50
  for (const session of sessions) {
51
- results.push(await attributeOneSession(session, options.worktrees));
51
+ results.push(await attributeOneSession(session, options.worktrees, options.collectionRoots ?? []));
52
52
  }
53
53
  const sumBy = (pick) => results.reduce((total, result) => total + pick(result), 0);
54
54
  const countState = (state) => results.filter((result) => result.state === state).length;
@@ -69,6 +69,7 @@ export async function scanAndAttributeClaudeSessions(options) {
69
69
  sidecar_stat_failed_count: discovery.sidecarStatFailedCount,
70
70
  counts: {
71
71
  attributed: countState("attributed"),
72
+ attributed_fallback: countState("attributed_fallback"),
72
73
  ambiguous: countState("ambiguous"),
73
74
  unattributed: countState("unattributed"),
74
75
  skipped: countState("skipped"),
@@ -211,7 +212,7 @@ async function discoverSidecars(subagentsDir) {
211
212
  statFailedCount,
212
213
  };
213
214
  }
214
- async function attributeOneSession(session, worktrees) {
215
+ async function attributeOneSession(session, worktrees, collectionRoots) {
215
216
  const fileName = path.basename(session.mainFile);
216
217
  const base = {
217
218
  file_path: session.mainFile,
@@ -303,7 +304,7 @@ async function attributeOneSession(session, worktrees) {
303
304
  originUrls: signals.repository_urls,
304
305
  branches: signals.branches,
305
306
  headShas: [],
306
- }, worktrees, { originLabel: "pr_repo_match" });
307
+ }, worktrees, { collectionRoots, originLabel: "pr_repo_match" });
307
308
  const extraSignals = [];
308
309
  if (base.main_file_oversized)
309
310
  extraSignals.push("main_file_oversized");
@@ -21,8 +21,8 @@ import { sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName,
21
21
  export { sanitizeSessionId, sessionIdFromFileName };
22
22
  export const CODEX_ATTRIBUTION_DEFAULT_SINCE_MINUTES = 24 * 60;
23
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;
24
+ export const CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES = 14 * 24 * 60;
25
+ export const CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT = 500;
26
26
  // Compatibility export only. Codex attribution streams full files for metadata
27
27
  // and no longer rejects sessions by file size; raw evidence collection enforces
28
28
  // upload budgets and content guards later.
@@ -42,7 +42,7 @@ export async function scanAndAttributeCodexSessions(options) {
42
42
  const files = discovery.files.slice(0, limit);
43
43
  const results = [];
44
44
  for (const file of files) {
45
- results.push(await attributeOneSession(file, options.worktrees));
45
+ results.push(await attributeOneSession(file, options.worktrees, options.collectionRoots ?? []));
46
46
  }
47
47
  return {
48
48
  results,
@@ -57,6 +57,7 @@ export async function scanAndAttributeCodexSessions(options) {
57
57
  secret_path_skipped_count: discovery.secretPathSkippedCount,
58
58
  counts: {
59
59
  attributed: results.filter((entry) => entry.state === "attributed").length,
60
+ attributed_fallback: results.filter((entry) => entry.state === "attributed_fallback").length,
60
61
  ambiguous: results.filter((entry) => entry.state === "ambiguous").length,
61
62
  unattributed: results.filter((entry) => entry.state === "unattributed")
62
63
  .length,
@@ -148,7 +149,7 @@ function dedupePaths(values) {
148
149
  }
149
150
  return out;
150
151
  }
151
- async function attributeOneSession(file, worktrees) {
152
+ async function attributeOneSession(file, worktrees, collectionRoots) {
152
153
  const fileName = path.basename(file.file);
153
154
  const base = {
154
155
  file_path: file.file,
@@ -200,7 +201,7 @@ async function attributeOneSession(file, worktrees) {
200
201
  originUrls: signals.repository_urls,
201
202
  branches: signals.branches,
202
203
  headShas: signals.commit_hashes,
203
- }, worktrees);
204
+ }, worktrees, { collectionRoots });
204
205
  return { ...base, ...outcome };
205
206
  }
206
207
  async function readCodexMetadataSignals(filePath) {
@@ -1,4 +1,4 @@
1
- import { SourceScanResultSchema, } from "@bli-cockpit/telemetry-core";
1
+ import { SourceScanResultSchema, parseTicketIdFromText, } from "@bli-cockpit/telemetry-core";
2
2
  import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
3
3
  export function makeSourceAdapterIdentity(captureSource, adapterName) {
4
4
  return {
@@ -34,7 +34,4 @@ export function makeSourceScan(context, captureSource, adapterName, status, reas
34
34
  diagnostic_labels: [reasonLabel],
35
35
  });
36
36
  }
37
- export function parseTicketIdFromText(value) {
38
- const match = value.match(/\b[A-Z][A-Z0-9]{1,12}-\d+\b/);
39
- return match?.[0] ?? null;
40
- }
37
+ export { parseTicketIdFromText };
@@ -0,0 +1,108 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ const BACKFILL_LOCK_FILENAME = "backfill.lock";
5
+ const HEARTBEAT_INTERVAL_MS = 30_000;
6
+ export const BACKFILL_LOCK_STALE_TAKEOVER_MS = 5 * 60_000;
7
+ export async function acquireBackfillLock(paths, now = new Date()) {
8
+ const lockPath = backfillLockPath(paths);
9
+ await fs.mkdir(paths.cursors_dir, { recursive: true, mode: 0o700 });
10
+ const token = crypto.randomUUID();
11
+ const existing = await readBackfillLockRecord(lockPath);
12
+ if (existing &&
13
+ now.getTime() - existing.heartbeat_ms <= BACKFILL_LOCK_STALE_TAKEOVER_MS) {
14
+ return { acquired: false, held_since: existing.heartbeat_at };
15
+ }
16
+ let owned = await tryExclusiveCreate(lockPath, token, now);
17
+ if (!owned) {
18
+ const current = await readBackfillLockRecord(lockPath);
19
+ if (current &&
20
+ now.getTime() - current.heartbeat_ms <= BACKFILL_LOCK_STALE_TAKEOVER_MS) {
21
+ return { acquired: false, held_since: current.heartbeat_at };
22
+ }
23
+ await writeBackfillLock(lockPath, token, now);
24
+ owned = true;
25
+ }
26
+ const heartbeat = async () => {
27
+ const current = await readBackfillLockRecord(lockPath);
28
+ if (current && current.token === token) {
29
+ await writeBackfillLock(lockPath, token, new Date());
30
+ }
31
+ };
32
+ const timer = setInterval(() => {
33
+ void heartbeat().catch(() => undefined);
34
+ }, HEARTBEAT_INTERVAL_MS);
35
+ timer.unref?.();
36
+ return {
37
+ acquired: true,
38
+ handle: {
39
+ heartbeat,
40
+ release: async () => {
41
+ clearInterval(timer);
42
+ const current = await readBackfillLockRecord(lockPath);
43
+ if (current && current.token === token) {
44
+ await fs.rm(lockPath, { force: true }).catch(() => undefined);
45
+ }
46
+ },
47
+ },
48
+ };
49
+ }
50
+ export async function inspectBackfillLock(paths, now = new Date()) {
51
+ const record = await readBackfillLockRecord(backfillLockPath(paths));
52
+ if (!record ||
53
+ now.getTime() - record.heartbeat_ms > BACKFILL_LOCK_STALE_TAKEOVER_MS) {
54
+ return { held: false, held_since: null };
55
+ }
56
+ return { held: true, held_since: record.heartbeat_at };
57
+ }
58
+ export function backfillLockPath(paths) {
59
+ return path.join(paths.cursors_dir, BACKFILL_LOCK_FILENAME);
60
+ }
61
+ async function tryExclusiveCreate(lockPath, token, now) {
62
+ try {
63
+ const handle = await fs.open(lockPath, "wx", 0o600);
64
+ await handle.writeFile(serializeBackfillLock(token, now));
65
+ await handle.close();
66
+ return true;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ async function writeBackfillLock(lockPath, token, now) {
73
+ await fs.writeFile(lockPath, serializeBackfillLock(token, now), {
74
+ mode: 0o600,
75
+ });
76
+ }
77
+ function serializeBackfillLock(token, now) {
78
+ const record = {
79
+ pid: process.pid,
80
+ token,
81
+ heartbeat_at: now.toISOString(),
82
+ heartbeat_ms: now.getTime(),
83
+ };
84
+ return `${JSON.stringify(record)}\n`;
85
+ }
86
+ async function readBackfillLockRecord(lockPath) {
87
+ try {
88
+ const raw = JSON.parse(await fs.readFile(lockPath, "utf8"));
89
+ if (!raw || typeof raw !== "object")
90
+ return null;
91
+ const record = raw;
92
+ const heartbeatMs = record["heartbeat_ms"];
93
+ if (typeof heartbeatMs !== "number" || !Number.isFinite(heartbeatMs)) {
94
+ return null;
95
+ }
96
+ return {
97
+ pid: typeof record["pid"] === "number" ? record["pid"] : -1,
98
+ token: typeof record["token"] === "string" ? record["token"] : "",
99
+ heartbeat_at: typeof record["heartbeat_at"] === "string"
100
+ ? record["heartbeat_at"]
101
+ : new Date(heartbeatMs).toISOString(),
102
+ heartbeat_ms: heartbeatMs,
103
+ };
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }