@bli-cockpit/cli 0.2.293 → 0.2.294

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.
Files changed (48) hide show
  1. package/dist/adapters/codex-attribution.js +28 -7
  2. package/dist/adapters/codex-segment-discovery.js +12 -3
  3. package/dist/adapters/codex-session-revision.js +58 -3
  4. package/dist/adapters/local-sources.js +1 -0
  5. package/dist/adapters/opencode-store.js +201 -0
  6. package/dist/adapters/raw-evidence-attribution-gaps.js +5 -0
  7. package/dist/adapters/raw-evidence-codex-reader.js +25 -3
  8. package/dist/adapters/raw-evidence-transcript-body.js +7 -2
  9. package/dist/adapters/raw-evidence-transcript-file.js +19 -0
  10. package/dist/adapters/raw-evidence-transcript-stream.js +6 -3
  11. package/dist/adapters/raw-evidence.js +13 -0
  12. package/dist/adapters/t3-projection-source.js +96 -0
  13. package/dist/commands/backfill-checkpoint.js +1 -1
  14. package/dist/commands/backfill-issues.js +5 -0
  15. package/dist/commands/backfill-output.js +6 -0
  16. package/dist/commands/backfill-report.js +3 -3
  17. package/dist/commands/backfill-scan.js +2 -0
  18. package/dist/commands/backfill-scope.js +2 -0
  19. package/dist/commands/backfill-upload.js +3 -2
  20. package/dist/commands/backfill.js +20 -3
  21. package/dist/commands/codex-revision-facts.js +3 -3
  22. package/dist/commands/collection-report.js +6 -0
  23. package/dist/commands/local-args-collector-status.js +2 -2
  24. package/dist/commands/local-args-collector-work.js +2 -2
  25. package/dist/commands/local-help-commands-tower.js +1 -1
  26. package/dist/commands/local-help.js +3 -3
  27. package/dist/commands/opencode-sync.js +163 -0
  28. package/dist/commands/public-root.js +1 -1
  29. package/dist/commands/session-sync-failures.js +4 -0
  30. package/dist/commands/session-sync-record.js +1 -1
  31. package/dist/commands/session-sync-upload.js +2 -1
  32. package/dist/commands/session-sync.js +18 -1
  33. package/dist/commands/sessions.js +27 -8
  34. package/dist/commands/sources-check.js +54 -47
  35. package/dist/commands/sources-discovery.js +35 -4
  36. package/dist/commands/sources-verdict.js +3 -3
  37. package/dist/local-state-status.js +18 -1
  38. package/dist/opencode-inventory.js +203 -0
  39. package/dist/opencode-policy.js +29 -0
  40. package/dist/slim/slim-transcript.js +3 -2
  41. package/dist/source-opt-out.js +2 -1
  42. package/dist/spool/local-spool-parse.js +1 -1
  43. package/dist/state-dir-audit.js +2 -0
  44. package/dist/sync-health-class.js +2 -0
  45. package/dist/upload-envelope-build.js +1 -0
  46. package/dist/upload-evidence-delivery-summary.js +10 -0
  47. package/dist/upload-session-reports-wire.js +15 -8
  48. package/package.json +4 -4
@@ -9,6 +9,8 @@
9
9
  */
10
10
  import os from "node:os";
11
11
  import path from "node:path";
12
+ import { readOpenCodeInventory } from "../opencode-inventory.js";
13
+ import { scoreSignalsAgainstWorktrees } from "../adapters/attribution-core.js";
12
14
  import { writeLine } from "./cli-io.js";
13
15
  import { discoverCommandWorktrees } from "./local-discovery.js";
14
16
  import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
@@ -30,19 +32,35 @@ export async function runSessions(command, io) {
30
32
  const window = await sessionsScanWindow(command, now);
31
33
  const { codex, claude } = await scanSessionSources(command, homeDir, worktrees, now, window);
32
34
  const { codexRows, claudeRows } = sessionRows(codex, claude);
35
+ const native = !command.source || command.source === "opencode" ? await readOpenCodeInventory({ homeDir,
36
+ stateDir: getCollectorRuntimePaths(homeDir).state_dir, metadataOnly: true,
37
+ sinceMs: window.since_minutes === null ? undefined : now.getTime() - window.since_minutes * 60_000 }) : null;
38
+ const opencode = native && (!native.absent || native.failures.length) ? { counts: { observed_count: native.discovered, read_failed: native.failures.length },
39
+ reasons: [...new Set(native.failures.map(failure => failure.reason))], sessions: native.sessions.map(session => {
40
+ const attributed = scoreSignalsAgainstWorktrees({ cwds: [session.directory], workspaceRoots: [], originUrls: [], branches: [], headShas: [] }, worktrees);
41
+ return { source: "opencode", session_id: session.id, state: attributed.state, reason: attributed.reason, attribution_score: attributed.attribution_score,
42
+ path_score: attributed.path_score, signals: attributed.signals, repo_label: attributed.worktree?.repo_label ?? null };
43
+ }) } : null;
33
44
  // BLI-3483: the scanners have always counted the folders and files they
34
45
  // could not read, and this command has always thrown those counts away
35
46
  // except for one field in `--json`. So "No sessions observed" was printed on
36
47
  // a machine where the scan had been blocked from looking — the operator's
37
48
  // one diagnostic tool answering the question with the failure removed.
38
49
  const readFailures = countReadFailures(codex, claude);
50
+ readFailures.total += native?.failures.length ?? 0;
39
51
  if (command.json) {
40
- return writeSessionJson(io, window, readFailures, codex, claude, codexRows, claudeRows);
52
+ return writeSessionJson(io, window, readFailures, codex, claude, codexRows, claudeRows, opencode);
41
53
  }
42
- return writeSessionText(io, window, readFailures, codexRows, claudeRows);
54
+ if (opencode) {
55
+ for (const row of opencode.sessions)
56
+ writeLine(io.stdout, sessionRowLine(row));
57
+ for (const reason of opencode.reasons)
58
+ writeLine(io.stdout, `OpenCode read incomplete: ${reason}`);
59
+ }
60
+ return writeSessionText(io, window, readFailures, codexRows, claudeRows, opencode?.sessions.length ?? 0);
43
61
  }
44
- function writeSessionText(io, window, readFailures, codexRows, claudeRows) {
45
- writeLine(io.stdout, "Tower sessions (read-only attribution)");
62
+ function writeSessionText(io, window, readFailures, codexRows, claudeRows, nativeCount = 0) {
63
+ writeLine(io.stdout, "TOWER sessions (read-only attribution)");
46
64
  writeLine(io.stdout, `window: ${sessionsWindowLine(window)}`);
47
65
  for (const row of codexRows) {
48
66
  writeLine(io.stdout, sessionRowLine(row));
@@ -53,7 +71,7 @@ function writeSessionText(io, window, readFailures, codexRows, claudeRows) {
53
71
  writeLine(io.stdout, ` sidecar ${sidecar.file_name}: ${sidecar.reason}`);
54
72
  }
55
73
  }
56
- if (codexRows.length === 0 && claudeRows.length === 0) {
74
+ if (codexRows.length === 0 && claudeRows.length === 0 && nativeCount === 0) {
57
75
  writeLine(io.stdout, readFailures.total > 0
58
76
  ? `No sessions listed, but ${readFailures.total} read failure(s) mean the scan could not see everywhere — this is not proof there were none.`
59
77
  : "No sessions observed in the scan window.");
@@ -66,7 +84,7 @@ function writeSessionText(io, window, readFailures, codexRows, claudeRows) {
66
84
  return 0;
67
85
  }
68
86
  async function scanSessionSources(command, homeDir, worktrees, now, window) {
69
- const codex = command.source !== "claude"
87
+ const codex = command.source !== "claude" && command.source !== "opencode"
70
88
  ? await scanAndAttributeCodexSessions({
71
89
  sessionsDirs: defaultCodexSessionDirs(homeDir),
72
90
  worktrees,
@@ -75,7 +93,7 @@ async function scanSessionSources(command, homeDir, worktrees, now, window) {
75
93
  limit: window.limit,
76
94
  })
77
95
  : null;
78
- const claude = command.source !== "codex"
96
+ const claude = command.source !== "codex" && command.source !== "opencode"
79
97
  ? await scanAndAttributeClaudeSessions({
80
98
  projectsDir: path.join(homeDir, ".claude", "projects"),
81
99
  worktrees,
@@ -86,10 +104,11 @@ async function scanSessionSources(command, homeDir, worktrees, now, window) {
86
104
  : null;
87
105
  return { codex, claude };
88
106
  }
89
- function writeSessionJson(io, window, readFailures, codex, claude, codexRows, claudeRows) {
107
+ function writeSessionJson(io, window, readFailures, codex, claude, codexRows, claudeRows, opencode) {
90
108
  writeLine(io.stdout, JSON.stringify({
91
109
  window,
92
110
  read_failures: readFailures,
111
+ ...(opencode ? { opencode } : {}),
93
112
  ...(codex ? { codex: { counts: codex.counts, sessions: codexRows } } : {}),
94
113
  ...(claude
95
114
  ? {
@@ -28,6 +28,7 @@ import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-e
28
28
  import { fetchTranscriptObjects } from "../evidence-transcripts-client.js";
29
29
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, } from "../local-state.js";
30
30
  import { discoverLocalTranscripts } from "./sources-discovery.js";
31
+ import { OPENCODE_CURSOR_FILENAME } from "../opencode-inventory.js";
31
32
  import { fileFacts, judgeSource } from "./sources-verdict.js";
32
33
  const VERDICTS = ["stored_full", "stored_slim", "stored_partial", "not_stored"];
33
34
  const DAY_MS = 24 * 60 * 60 * 1000;
@@ -55,60 +56,66 @@ export async function runSourcesCheck(command, io, now = new Date()) {
55
56
  const switches = await readSourceSwitches(config, paths, "sources_check", { fetch: io.fetch });
56
57
  const optOutClaude = command.reconcile === true && !switches.claude;
57
58
  const optOutCodex = command.reconcile === true && !switches.codex;
58
- const optOutAll = optOutClaude && optOutCodex;
59
+ const optOutAll = optOutClaude && optOutCodex && !switches.opencode;
59
60
  if (optOutClaude || optOutCodex) {
60
61
  console.error("[collector sources] reconcile skips opted-out sources", JSON.stringify({ codex_reason: optOutCodex ? switches.codexReason : null, claude_reason: optOutClaude ? switches.claudeReason : null }));
61
62
  }
62
- const local = await discoverLocalTranscripts({ homeDir, olderThanMs, skipAll: optOutAll, skipClaude: optOutClaude, skipCodex: optOutCodex });
63
- const cursors = {
64
- codex: await readRawEvidenceCursor(paths),
65
- claude_code: await readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME, sessionsOnly: true }),
66
- };
67
- const describedKeys = new Set(await readDescribedImageKeys(paths.state_dir));
68
- // BLI-17273: judge against THIS machine's mode (the cache the sync-start receipt wrote, else the
69
- // fleet default), never the bare constant. A machine that runs or ever ran slim holds slim server
70
- // copies, which must never read as safe to delete.
71
- const imageMode = await resolveImageHandlingMode(paths.state_dir);
72
- console.error("[collector sources] judging against this machine's image mode", JSON.stringify({ reason: `image_mode_from_${imageMode.source}`, mode: imageMode.mode, slim_aware: imageMode.slimAware }));
73
- const report = emptyReport(command, remote, local.newer_count, local.file_read_failed + local.directory_read_failed);
74
- await judgeLocalTranscripts(local.files, remote, cursors, imageMode, describedKeys, report);
75
- if (command.reconcile) {
76
- const inventory = await fetchSourceInventory({ dashboardUrl, deviceToken: session.device_token, fetchImpl: io.fetch });
77
- const { rows: _rows, ...inventoryCounts } = inventory;
78
- const counts = { missing_row: 0, missing_pointer: 0, stale_revision: 0, unavailable_evidence: 0, explicit_opt_out: 0, unverifiable: 0, revision_verified: 0 };
79
- const optOutSources = optOutAll ? ["codex", "claude_code"] : optOutClaude ? ["claude_code"] : [];
80
- counts.explicit_opt_out = optOutSources.length;
81
- for (const file of local.files) {
82
- const finding = await reconcileSource({ file, inventory, remote,
83
- cursor: file.kind === "claude_subagent" ? null : cursors[file.source].sessions[file.session_id] ?? null,
84
- facts: fileFacts(file, undefined, { mode: imageMode.mode, describedKeys, slimAware: imageMode.slimAware }),
85
- }).catch(() => ({ state: "unavailable_evidence", reason: "local_revision_unreadable" }));
86
- counts[finding.state]++;
87
- const entry = report.files.find(entry => entry.path === file.file_path);
88
- if (entry)
89
- entry.reconciliation = finding;
90
- }
91
- report.reconciliation = { counts, inventory: inventoryCounts,
92
- scan: { started_at: new Date(startedAt).toISOString(), finished_at: new Date().toISOString(),
93
- roots: ["codex/sessions", "codex/archived_sessions", "claude/projects"], older_than_days: command.olderThanDays, opt_out_sources: optOutSources },
94
- limitations: ["Snapshot scan, not a live completeness guarantee; absent local sources cannot be discovered.", "Missing historical source identifiers remain unverifiable.", "Pointer metadata does not prove bucket bytes are readable; no storage bodies fetched.", "explicit_opt_out counts sources, not undiscovered files; opted-out stores are not scanned."],
63
+ const local = await discoverLocalTranscripts({ homeDir, olderThanMs, skipAll: optOutAll, skipClaude: optOutClaude, skipCodex: optOutCodex, skipOpenCode: !switches.opencode });
64
+ try {
65
+ const cursors = {
66
+ opencode: await readRawEvidenceCursor(paths, { filename: OPENCODE_CURSOR_FILENAME, sessionsOnly: true }),
67
+ codex: await readRawEvidenceCursor(paths),
68
+ claude_code: await readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME, sessionsOnly: true }),
95
69
  };
96
- if (inventory.status !== "exhausted" || report.local.unreadable > 0 || counts.unavailable_evidence > 0) {
70
+ const describedKeys = new Set(await readDescribedImageKeys(paths.state_dir));
71
+ // BLI-17273: judge against THIS machine's mode (the cache the sync-start receipt wrote, else the
72
+ // fleet default), never the bare constant. A machine that runs or ever ran slim holds slim server
73
+ // copies, which must never read as safe to delete.
74
+ const imageMode = await resolveImageHandlingMode(paths.state_dir);
75
+ console.error("[collector sources] judging against this machine's image mode", JSON.stringify({ reason: `image_mode_from_${imageMode.source}`, mode: imageMode.mode, slim_aware: imageMode.slimAware }));
76
+ const report = emptyReport(command, remote, local.newer_count, local.file_read_failed + local.directory_read_failed);
77
+ await judgeLocalTranscripts(local.files, remote, cursors, imageMode, describedKeys, report);
78
+ if (command.reconcile) {
79
+ const inventory = await fetchSourceInventory({ dashboardUrl, deviceToken: session.device_token, fetchImpl: io.fetch });
80
+ const { rows: _rows, ...inventoryCounts } = inventory;
81
+ const counts = { missing_row: 0, missing_pointer: 0, stale_revision: 0, unavailable_evidence: 0, explicit_opt_out: 0, unverifiable: 0, revision_verified: 0 };
82
+ const optOutSources = optOutAll ? ["codex", "claude_code", "opencode"] : optOutClaude ? ["claude_code"] : [];
83
+ counts.explicit_opt_out = optOutSources.length;
84
+ for (const file of local.files) {
85
+ const finding = await reconcileSource({ file, inventory, remote,
86
+ cursor: file.kind === "claude_subagent" ? null : cursors[file.source].sessions[file.session_id] ?? null,
87
+ facts: fileFacts(file, undefined, { mode: imageMode.mode, describedKeys, slimAware: imageMode.slimAware }),
88
+ }).catch(() => ({ state: "unavailable_evidence", reason: "local_revision_unreadable" }));
89
+ counts[finding.state]++;
90
+ const entry = report.files.find(entry => entry.path === file.file_path);
91
+ if (entry)
92
+ entry.reconciliation = finding;
93
+ }
94
+ report.reconciliation = { counts, inventory: inventoryCounts,
95
+ scan: { started_at: new Date(startedAt).toISOString(), finished_at: new Date().toISOString(),
96
+ roots: ["codex/sessions", "codex/archived_sessions", "claude/projects", "opencode/opencode.db"], older_than_days: command.olderThanDays, opt_out_sources: optOutSources },
97
+ limitations: ["Snapshot scan, not a live completeness guarantee; absent local sources cannot be discovered.", "Missing historical source identifiers remain unverifiable.", "Pointer metadata does not prove bucket bytes are readable; no storage bodies fetched.", "explicit_opt_out counts sources, not undiscovered files; opted-out stores are not scanned."],
98
+ };
99
+ if (inventory.status !== "exhausted" || report.local.unreadable > 0 || counts.unavailable_evidence > 0) {
100
+ report.status = "incomplete";
101
+ report.reason = "reconciliation_partial";
102
+ }
103
+ }
104
+ if (report.local.unreadable > 0) {
97
105
  report.status = "incomplete";
98
- report.reason = "reconciliation_partial";
106
+ report.reason = "local_scan_incomplete";
99
107
  }
108
+ reportSourcesCheck(report, remote, startedAt);
109
+ if (command.json)
110
+ writeLine(io.stdout, JSON.stringify(report, null, 2));
111
+ else
112
+ for (const line of sourcesCheckLines(report))
113
+ writeLine(io.stdout, line);
114
+ return 0;
100
115
  }
101
- if (report.local.unreadable > 0) {
102
- report.status = "incomplete";
103
- report.reason = "local_scan_incomplete";
116
+ finally {
117
+ await local.cleanup?.();
104
118
  }
105
- reportSourcesCheck(report, remote, startedAt);
106
- if (command.json)
107
- writeLine(io.stdout, JSON.stringify(report, null, 2));
108
- else
109
- for (const line of sourcesCheckLines(report))
110
- writeLine(io.stdout, line);
111
- return 0;
112
119
  }
113
120
  async function judgeLocalTranscripts(files, remote, cursors, imageMode, describedKeys, report) {
114
121
  for (const file of files) {
@@ -129,7 +136,7 @@ async function judgeLocalTranscripts(files, remote, cursors, imageMode, describe
129
136
  report.counts[judged.verdict].bytes += file.byte_size;
130
137
  report.reasons[judged.reason] = (report.reasons[judged.reason] ?? 0) + 1;
131
138
  report.files.push({
132
- path: file.file_path,
139
+ path: file.source_locator ?? file.file_path,
133
140
  source: file.source,
134
141
  kind: file.kind,
135
142
  session_id: file.session_id,
@@ -1,5 +1,6 @@
1
1
  import { openCodexSessionRevision } from "../adapters/codex-session-revision.js";
2
2
  import { nativeSessionRoots } from "../native-session-roots.js";
3
+ import { t3CodexProjectionSessionIds } from "../adapters/t3-projection-source.js";
3
4
  /**
4
5
  * Every agent transcript on this machine, named the way the collector names
5
6
  * its upload (BLI-11122).
@@ -23,7 +24,12 @@ import { nativeSessionRoots } from "../native-session-roots.js";
23
24
  * This module reads files and never writes, moves or deletes one.
24
25
  */
25
26
  import fs from "node:fs/promises";
27
+ import { getCollectorRuntimePaths } from "../local-state.js";
28
+ import { openCodeCollectionPolicy } from "../opencode-policy.js";
29
+ import { readOpenCodeInventory } from "../opencode-inventory.js";
26
30
  import path from "node:path";
31
+ import os from "node:os";
32
+ import { openCodeDatabasePath } from "../adapters/opencode-store.js";
27
33
  import { createReadStream } from "node:fs";
28
34
  import readline from "node:readline";
29
35
  import { transcriptFileKey } from "@bli-cockpit/telemetry-core";
@@ -140,7 +146,24 @@ export async function discoverLocalTranscripts(options) {
140
146
  const recordFailure = (error) => { firstFailure ??= describeError(error); };
141
147
  const found = await findTranscriptFiles(options, roots, result, recordFailure);
142
148
  await nameTranscriptFiles(found, result, recordFailure);
143
- await groupCodexRevisions(result);
149
+ if (!options.skipAll && !options.skipOpenCode) {
150
+ const policy = await openCodeCollectionPolicy({ homeDir: options.homeDir });
151
+ const scratch = policy.enabled ? await fs.mkdtemp(path.join(os.tmpdir(), "tower-opencode-reconcile-")) : null;
152
+ if (scratch)
153
+ result.cleanup = () => fs.rm(scratch, { recursive: true, force: true });
154
+ const inventory = policy.enabled ? await readOpenCodeInventory({ homeDir: options.homeDir, stateDir: getCollectorRuntimePaths(options.homeDir).state_dir,
155
+ include: policy.include, readOnlyAccounting: true, exportDirectory: scratch }) : { files: [], failures: [] };
156
+ result.directory_read_failed += inventory.failures.length;
157
+ for (const file of inventory.files) {
158
+ if (options.olderThanMs !== null && file.session.updated_at_ms >= options.olderThanMs) {
159
+ result.newer_count += 1;
160
+ continue;
161
+ }
162
+ result.files.push({ source: "opencode", kind: "opencode_main", source_locator: `${process.env.OPENCODE_DB ?? openCodeDatabasePath(options.homeDir, process.env.XDG_DATA_HOME)}#${file.session.id}`, file_path: file.local_path, byte_size: file.byte_size, mtime_ms: file.session.updated_at_ms,
163
+ session_id: file.session.id, session_id_source: "content", key: transcriptFileKey({ source: "opencode", session_id: file.session.id, sidecar: null }) });
164
+ }
165
+ }
166
+ await groupCodexRevisions(result, options.homeDir);
144
167
  result.files = result.files.filter(f => options.olderThanMs === null || f.mtime_ms < options.olderThanMs);
145
168
  if (result.directory_read_failed > 0 || result.file_read_failed > 0) {
146
169
  console.error("[collector sources] some transcripts could not be read; they are left out of the answer", JSON.stringify({
@@ -247,7 +270,14 @@ async function nameTranscriptFiles(found, result, recordFailure) {
247
270
  }
248
271
  }
249
272
  /** One source row/pointer names the complete native session revision, never an arbitrary segment. */
250
- async function groupCodexRevisions(result) {
273
+ async function groupCodexRevisions(result, homeDir) {
274
+ let projectionIds = new Set();
275
+ try {
276
+ projectionIds = t3CodexProjectionSessionIds(homeDir);
277
+ }
278
+ catch {
279
+ result.file_read_failed++;
280
+ } // The reader logs the exact capped/unavailable reason; native files remain available.
251
281
  const groups = new Map();
252
282
  for (const file of result.files)
253
283
  if (file.source === "codex") {
@@ -257,14 +287,15 @@ async function groupCodexRevisions(result) {
257
287
  }
258
288
  const replacements = new Map();
259
289
  for (const [id, files] of groups) {
260
- if (files.length < 2)
290
+ if (files.length < 2 && !projectionIds.has(id))
261
291
  continue;
262
292
  const representative = [...files].sort((a, b) => a.file_path.localeCompare(b.file_path))[0];
263
293
  const combined = { ...representative, segment_paths: files.map(f => f.file_path).sort(),
294
+ ...(projectionIds.has(id) ? { t3_projection: true } : {}),
264
295
  segment_stamps: files.map(f => ({ path: f.file_path, size: f.byte_size, mtime: f.mtime_ms })),
265
296
  byte_size: files.reduce((n, f) => n + f.byte_size, 0), mtime_ms: Math.max(...files.map(f => f.mtime_ms)) };
266
297
  try {
267
- const source = await openCodexSessionRevision(combined.segment_paths);
298
+ const source = await openCodexSessionRevision(combined.segment_paths, { t3Projection: combined.t3_projection });
268
299
  try {
269
300
  combined.byte_size = source.length;
270
301
  }
@@ -164,7 +164,7 @@ upload = {}) {
164
164
  const describedKeys = upload.describedKeys ?? new Set();
165
165
  const slimAware = upload.slimAware ?? modeDropsImages(mode);
166
166
  if (file.segment_paths)
167
- return codexRevisionFacts(file.segment_paths, mode, describedKeys, slimAware);
167
+ return codexRevisionFacts(file.segment_paths, mode, describedKeys, slimAware, file.t3_projection);
168
168
  const redactForUpload = (text, original) => redactTranscriptForm(file, text, original);
169
169
  let split = null;
170
170
  let whole = null;
@@ -186,7 +186,7 @@ upload = {}) {
186
186
  if (!buffer)
187
187
  return null;
188
188
  // Compare against what the uploader sends: in drop mode that is the slim form, not the disk bytes.
189
- const uploadText = uploadFormOfTranscript(buffer.toString("utf8"), file.source === "codex" ? "codex" : "claude_code", mode, describedKeys);
189
+ const uploadText = file.source === "opencode" ? buffer.toString("utf8") : uploadFormOfTranscript(buffer.toString("utf8"), file.source, mode, describedKeys);
190
190
  return redactForUpload(uploadText, buffer);
191
191
  });
192
192
  return sanitized;
@@ -202,7 +202,7 @@ upload = {}) {
202
202
  if (!buffer)
203
203
  return null;
204
204
  const text = buffer.toString("utf8");
205
- const slimText = uploadFormOfTranscript(text, file.source === "codex" ? "codex" : "claude_code", "describe_and_drop", describedKeys);
205
+ const slimText = uploadFormOfTranscript(text, file.source, "describe_and_drop", describedKeys);
206
206
  // Slimming changes nothing: one form, and `sanitized()` is the answer.
207
207
  if (slimText === text)
208
208
  return null;
@@ -1,3 +1,4 @@
1
+ import { readFile } from "node:fs/promises";
1
2
  import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { readStagingInventory } from "./disk-usage.js";
@@ -18,7 +19,23 @@ import { readLocalWorkContextForRepo, workDisplayLabel, } from "./local-state-wo
18
19
  export async function inspectLocalCollectorStatus(options = {}) {
19
20
  const now = options.now ?? new Date();
20
21
  const reading = await readCollectorStatus(options, now);
21
- return collectorStatusFromReading(reading, now);
22
+ const status = collectorStatusFromReading(reading, now);
23
+ try {
24
+ const native = JSON.parse(await readFile(path.join(reading.paths.state_dir, "opencode-collection-status.json"), "utf8"));
25
+ if (native.source === "opencode" && ["complete", "partial", "absent", "withheld"].includes(native.coverage)
26
+ && [native.discovered, native.eligible, native.delivered, native.pending, native.withheld, native.unique].every(value => typeof value === "number" && Number.isFinite(value) && value >= 0)) {
27
+ const at = typeof native.observed_at === "string" && Number.isFinite(Date.parse(native.observed_at)) ? new Date(native.observed_at).toISOString() : "unknown";
28
+ const reasons = Array.isArray(native.reasons) ? native.reasons.filter((reason) => typeof reason === "string" && /^[a-z0-9_:-]{1,100}$/.test(reason)).join(", ") : "";
29
+ status.details.push(`OpenCode: ${native.coverage}; discovered ${native.discovered}, eligible ${native.eligible}, delivered ${native.delivered}, pending ${native.pending}, withheld ${native.withheld}, unique ${native.unique}; as of ${at}${reasons ? ` (${reasons})` : ""}.`);
30
+ }
31
+ else
32
+ status.details.push("OpenCode coverage unavailable: opencode_status_invalid.");
33
+ }
34
+ catch (error) {
35
+ if (error.code !== "ENOENT")
36
+ status.details.push("OpenCode coverage unavailable: opencode_status_unreadable.");
37
+ }
38
+ return status;
22
39
  }
23
40
  function collectorStatusFromReading(reading, now) {
24
41
  const { paths, config, session, identity, context, branch } = reading;
@@ -0,0 +1,203 @@
1
+ /** OpenCode's one inventory for sync, history and reconciliation, exported through shared secret guards. */
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ import { newSessionFactsAccumulator, observeSessionFactsFile, observeSessionFactsRecord, finalizeSessionFacts, nativeObject, openCodeProviderIdentity } from "@bli-cockpit/telemetry-core";
6
+ import { openCodeDatabasePath, openOpenCodeStore, OpenCodeReadError } from "./adapters/opencode-store.js";
7
+ import { openStagingAllowance, roomToStageUpTo, admitToStaging } from "./staging-gate.js";
8
+ import { sanitizeTextEvidenceForUpload } from "./adapters/raw-evidence-sanitize.js";
9
+ import { readJsonFile, writeJsonFileAtomic } from "./local-state-files.js";
10
+ export const OPENCODE_CURSOR_FILENAME = "opencode-raw-evidence.json";
11
+ /** Policy is decided from native directory metadata before exporting any conversation body. */
12
+ export async function readOpenCodeInventory(options) {
13
+ const result = { discovered: 0, sessions: [], eligible: 0, withheld: 0, files: [], shelved: [], failures: [], absent: false };
14
+ let store = null;
15
+ try {
16
+ const databasePath = options.databasePath ?? process.env.OPENCODE_DB ?? openCodeDatabasePath(options.homeDir, process.env.XDG_DATA_HOME);
17
+ store = await openOpenCodeStore(databasePath);
18
+ if (!store) {
19
+ result.absent = true;
20
+ console.error("[opencode inventory] source path absent", JSON.stringify({ source: "opencode", reason: "opencode_source_path_absent" }));
21
+ return result;
22
+ }
23
+ const sessions = [];
24
+ let afterId = "";
25
+ while (true) {
26
+ const page = store.list(afterId);
27
+ if (!page.length)
28
+ break;
29
+ sessions.push(...page.filter(session => options.sinceMs === undefined || session.updated_at_ms >= options.sinceMs));
30
+ afterId = page.at(-1).id;
31
+ }
32
+ result.discovered = sessions.length;
33
+ result.sessions = sessions;
34
+ if (options.metadataOnly) {
35
+ for (const session of sessions) {
36
+ if (!options.include || options.include(session))
37
+ result.eligible += 1;
38
+ else
39
+ result.withheld += 1;
40
+ }
41
+ console.error("[opencode inventory] metadata-only scan complete", JSON.stringify({ discovered: result.discovered, eligible: result.eligible, withheld: result.withheld }));
42
+ return result;
43
+ }
44
+ const owners = { ...await readUsageOwners(options.stateDir, "opencode-usage-tentative.json"), ...await readUsageOwners(options.stateDir, "opencode-usage-owners.json") };
45
+ const allowance = await openStagingAllowance(options.stateDir, process.env);
46
+ for (const session of sessions.sort((a, b) => Number(options.deliveredSessionIds?.has(a.id) ?? false) - Number(options.deliveredSessionIds?.has(b.id) ?? false) || a.created_at_ms - b.created_at_ms || a.id.localeCompare(b.id))) {
47
+ if (options.include && !options.include(session)) {
48
+ result.withheld += 1;
49
+ if (options.countOnly?.(session)) {
50
+ try {
51
+ result.shelved.push(await exportNativeSession(store, session, options.stateDir, owners, false));
52
+ }
53
+ catch (error) {
54
+ result.failures.push({ session_id: session.id, reason: readReason(error) });
55
+ }
56
+ }
57
+ continue;
58
+ }
59
+ result.eligible += 1;
60
+ try {
61
+ result.files.push(await exportNativeSession(store, session, options.stateDir, owners, true, allowance, options.exportDirectory));
62
+ }
63
+ catch (error) {
64
+ result.failures.push({ session_id: session.id, reason: readReason(error) });
65
+ }
66
+ }
67
+ if (!options.readOnlyAccounting)
68
+ await writeJsonFileAtomic(path.join(options.stateDir, "opencode-usage-tentative.json"), owners);
69
+ }
70
+ catch (error) {
71
+ result.failures.push({ session_id: null, reason: readReason(error) });
72
+ }
73
+ finally {
74
+ store?.close();
75
+ }
76
+ console.error("[opencode inventory] scan finished", JSON.stringify({ source: "opencode", discovered: result.discovered, exported: result.files.length, withheld: result.withheld, failed: result.failures.length, reason: result.absent ? "opencode_source_path_absent" : result.failures.length ? "opencode_inventory_partial" : "opencode_inventory_complete" }));
77
+ return result;
78
+ }
79
+ async function exportNativeSession(store, session, stateDir, owners, retainContent = true, allowance, exportDirectory) {
80
+ const directory = exportDirectory ?? path.join(stateDir, "opencode-transcripts");
81
+ if (retainContent)
82
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
83
+ const temporary = path.join(directory, `.export-${crypto.randomUUID()}`);
84
+ const output = retainContent ? await fs.open(temporary, "wx", 0o600) : null;
85
+ const hash = crypto.createHash("sha256");
86
+ const factsAccumulator = newSessionFactsAccumulator("opencode");
87
+ let lines = 0;
88
+ let bytes = 0;
89
+ let message = null;
90
+ let identities = [];
91
+ let redactedRecords = 0;
92
+ const redactionRules = new Map();
93
+ let secretMatches = 0;
94
+ const write = async (record) => {
95
+ const part = record.type === "opencode_part" ? nativeObject(record.part) : {};
96
+ if (part.type === "file" && typeof part.url === "string")
97
+ record = { ...record, attachment_source: {
98
+ representation: part.url.startsWith("data:") ? "inline_data_url" : "reference_only",
99
+ referenced_bytes_read: false,
100
+ reason: part.url.startsWith("data:") ? "native_inline_payload_available" : "external_attachment_bytes_not_read",
101
+ } };
102
+ const masked = sanitizeTextEvidenceForUpload({ text: JSON.stringify(record) + "\n", redactedFields: ["opencode_native_record"] });
103
+ if (masked.status === "redacted" && masked.completenessLabel === "redaction_crashed_stubbed")
104
+ throw new OpenCodeReadError("opencode_redaction_failed");
105
+ if (masked.status === "redacted") {
106
+ redactedRecords += 1;
107
+ secretMatches += masked.redaction.secret_like_match_count;
108
+ for (const rule of masked.redaction.rule_counts) {
109
+ const prior = redactionRules.get(rule.rule_id) ?? { rule_id: rule.rule_id, match_count: 0, redacted_char_count: 0 };
110
+ prior.match_count += rule.match_count;
111
+ prior.redacted_char_count += rule.redacted_char_count;
112
+ redactionRules.set(rule.rule_id, prior);
113
+ }
114
+ }
115
+ hash.update(masked.bytes);
116
+ bytes += masked.bytes.length;
117
+ lines += 1;
118
+ try {
119
+ observeSessionFactsRecord(factsAccumulator, JSON.parse(masked.bytes.toString("utf8")));
120
+ }
121
+ catch { /* Masked crash marker remains evidence, not an invented native record. */ }
122
+ if (output) {
123
+ const deferred = roomToStageUpTo(allowance, bytes);
124
+ if (deferred)
125
+ throw new OpenCodeReadError(`opencode_staging_deferred_${deferred}`);
126
+ await output.write(masked.bytes);
127
+ }
128
+ };
129
+ const settleUsage = async () => {
130
+ if (!message || message.role !== "assistant")
131
+ return;
132
+ const nativeIdentity = openCodeProviderIdentity(message, identities);
133
+ const identity = nativeIdentity ? crypto.createHash("sha256").update(nativeIdentity).digest("hex") : null;
134
+ const nativeTokens = nativeObject(message.tokens);
135
+ const hasUsage = !message.error && Number.isFinite(nativeObject(message.time).completed) && ["input", "output", "reasoning"].every((key) => typeof nativeTokens[key] === "number" && Number.isFinite(nativeTokens[key]) && Number(nativeTokens[key]) >= 0) && typeof message.modelID === "string";
136
+ const owner = identity ? owners[identity] : null;
137
+ const status = !identity || !hasUsage ? "unknown" : owner && owner !== session.id ? "copied" : "known";
138
+ if (identity && hasUsage && !owner)
139
+ owners[identity] = session.id;
140
+ await write({ type: "opencode_usage", status, identity, owner_session_id: identity ? owners[identity] : null, model: message.modelID ?? null, provider: message.providerID ?? null, tokens: message.tokens ?? null });
141
+ };
142
+ try {
143
+ for (const record of store.records(session.id)) {
144
+ if (record.type === "opencode_message") {
145
+ await settleUsage();
146
+ message = nativeObject(record.info);
147
+ identities = [];
148
+ }
149
+ if (record.type === "opencode_part") {
150
+ const part = nativeObject(record.part);
151
+ if (part.metadata)
152
+ identities.push({ metadata: Object.fromEntries(Object.entries(nativeObject(part.metadata)).map(([name, value]) => [name, { itemId: nativeObject(value).itemId }])) });
153
+ }
154
+ await write(record);
155
+ }
156
+ await settleUsage();
157
+ await write({ type: "opencode_capture", redacted_records: redactedRecords, accounting_scope: "persistent_native_provider_item_ownership_on_this_machine" });
158
+ await output?.close();
159
+ const revision = hash.digest("hex");
160
+ const localPath = retainContent ? path.join(directory, `${session.id}-${revision}.jsonl`) : "";
161
+ if (retainContent) {
162
+ if (allowance) {
163
+ const deferred = admitToStaging(allowance, bytes, { kind: "opencode_jsonl" });
164
+ if (deferred)
165
+ throw new OpenCodeReadError(`opencode_staging_deferred_${deferred}`);
166
+ }
167
+ await fs.rename(temporary, localPath);
168
+ }
169
+ observeSessionFactsFile(factsAccumulator, { bytes, lines });
170
+ const facts = finalizeSessionFacts(factsAccumulator);
171
+ return { session, local_path: localPath, byte_size: bytes, revision, facts,
172
+ ...(secretMatches ? { redaction: { schema_version: "raw-evidence-redaction.v1", status: "sanitized", mode: "deterministic_text_replacement",
173
+ applied_by: ["local_collector"], rule_counts: [...redactionRules.values()], secret_like_match_count: secretMatches,
174
+ redacted_fields: ["opencode_native_record"], redacted_ranges: [], sanitized_content_hash_sha256: revision, sanitized_byte_size: bytes } } : {}) };
175
+ }
176
+ catch (error) {
177
+ await output?.close().catch(() => undefined);
178
+ if (retainContent)
179
+ await fs.unlink(temporary).catch(() => undefined);
180
+ throw error;
181
+ }
182
+ }
183
+ function readReason(error) { return error instanceof OpenCodeReadError ? error.reason : "opencode_export_failed"; }
184
+ async function readUsageOwners(stateDir, filename) {
185
+ const saved = await readJsonFile(path.join(stateDir, filename)).catch((error) => {
186
+ if (error.code === "ENOENT")
187
+ return {};
188
+ throw new OpenCodeReadError("opencode_accounting_cursor_unreadable");
189
+ });
190
+ if (!saved || typeof saved !== "object" || Array.isArray(saved) || Object.entries(saved).some(([key, owner]) => !/^[a-f0-9]{64}$/.test(key) || typeof owner !== "string" || !/^ses_[A-Za-z0-9]+$/.test(owner)))
191
+ throw new OpenCodeReadError("opencode_accounting_cursor_invalid");
192
+ return saved;
193
+ }
194
+ /** A local usage owner becomes durable only after both evidence and metadata were acknowledged. */
195
+ export async function acknowledgeOpenCodeUsage(stateDir, sessions) {
196
+ const committed = await readUsageOwners(stateDir, "opencode-usage-owners.json");
197
+ const tentative = await readUsageOwners(stateDir, "opencode-usage-tentative.json");
198
+ for (const [identity, owner] of Object.entries(tentative))
199
+ if (sessions.has(owner))
200
+ committed[identity] ??= owner;
201
+ await writeJsonFileAtomic(path.join(stateDir, "opencode-usage-owners.json"), committed);
202
+ console.error("[opencode accounting] acknowledged native identity owners", JSON.stringify({ reason: "durable_native_usage_acknowledged", identities: Object.keys(committed).length, sessions: sessions.size }));
203
+ }
@@ -0,0 +1,29 @@
1
+ /** Apply existing collection choices to native directory metadata before exporting OpenCode evidence. */
2
+ import { getCollectorRuntimePaths, readLocalCollectorConfig } from "./local-state.js";
3
+ import { readSourceSwitches } from "./source-opt-out.js";
4
+ import { settleCollectionScope, sessionInsideRoots } from "./collection-scope.js";
5
+ import { decideShelfMode, sessionShelf } from "./commands/session-sync-shelf.js";
6
+ import { scoreSignalsAgainstWorktrees } from "./adapters/attribution-core.js";
7
+ export async function openCodeCollectionPolicy(options) {
8
+ const paths = getCollectorRuntimePaths(options.homeDir);
9
+ const config = await readLocalCollectorConfig(paths).catch((error) => { if (error.code === "ENOENT")
10
+ return null; throw error; });
11
+ const switches = await readSourceSwitches(config, paths, "opencode_inventory", { fetch: options.fetchImpl, dashboardUrl: options.dashboardUrl });
12
+ const shelf = await decideShelfMode({ homeDir: options.homeDir, dashboardUrl: options.dashboardUrl ?? null, fetchImpl: options.fetchImpl ?? globalThis.fetch });
13
+ const scope = await settleCollectionScope(paths.state_dir, config?.collection_scope);
14
+ const roots = options.collectionRoots ?? config?.default_repo_paths ?? [];
15
+ const enabled = switches.opencode && !switches.held && shelf.mode !== "hold";
16
+ const reason = switches.held || shelf.mode === "hold" ? "owner_check_unavailable" : !switches.opencode ? "raw_evidence_upload_disabled" : "opencode_existing_collection_policy";
17
+ const drawer = (session) => {
18
+ const attribution = scoreSignalsAgainstWorktrees({ cwds: [session.directory], workspaceRoots: [], originUrls: [], branches: [], headShas: [] }, options.worktrees ?? [], { collectionRoots: roots });
19
+ return shelf.mode === "founder" && sessionShelf({ ...attribution, local_cwd: session.directory, session_facts: null }, shelf.rules).shelf === "drawer";
20
+ };
21
+ return { enabled, reason, drawer, include: (session) => {
22
+ if (!enabled)
23
+ return false;
24
+ const attribution = scoreSignalsAgainstWorktrees({ cwds: [session.directory], workspaceRoots: [], originUrls: [], branches: [], headShas: [] }, options.worktrees ?? [], { collectionRoots: roots });
25
+ if (scope.scope === "roots_only" && !sessionInsideRoots({ ...attribution, local_cwd: session.directory }, roots))
26
+ return false;
27
+ return !drawer(session);
28
+ } };
29
+ }
@@ -36,7 +36,7 @@ import { LineEdits, SeenBlocks, isJsonObject } from "./slim-support.js";
36
36
  * once, which is intended: the stored bytes are no longer what the new rules would produce.
37
37
  * `slim-rules-version.test.ts` fails when a rule changes without this moving.
38
38
  */
39
- export const SLIM_RULES_VERSION = "slim-rules.v2";
39
+ export const SLIM_RULES_VERSION = "slim-rules.v3";
40
40
  /** The text an image marker starts with; a line carrying one is a modified line (an image became a marker). */
41
41
  const IMAGE_MARKER_START = "[image sha256:";
42
42
  export function createSlimmer(kind, rulesForTests) {
@@ -49,8 +49,9 @@ export function createSlimmer(kind, rulesForTests) {
49
49
  const applyRules = rulesForTests ?? ((record, edits) => {
50
50
  if (kind === "claude_code")
51
51
  slimClaudeRecord(record, claude, seen, edits);
52
- else
52
+ else if (kind === "codex")
53
53
  slimCodexRecord(record, codex, seen, edits);
54
+ // OpenCode keeps every native field; only the shared acknowledged-image swap changes its payload.
54
55
  });
55
56
  return {
56
57
  slimLine(line) {
@@ -46,7 +46,7 @@ export function resolveSourceSwitches(config, standing) {
46
46
  const labels = configOptOutLabels(config);
47
47
  const verdict = labels.length > 0 ? optOutVerdict(standing) : null;
48
48
  if (verdict && !verdict.obeyed) {
49
- return { codex: true, claude: true, codexReason: null, claudeReason: null, ignored: labels, verdict: verdict.reason, held: false };
49
+ return { opencode: true, codex: true, claude: true, codexReason: null, claudeReason: null, ignored: labels, verdict: verdict.reason, held: false };
50
50
  }
51
51
  const all = config?.raw_evidence_upload === "disabled";
52
52
  const codexReason = config?.collect_codex_jsonl === false
@@ -56,6 +56,7 @@ export function resolveSourceSwitches(config, standing) {
56
56
  ? CLAUDE_OPT_OUT_REASON
57
57
  : all ? ALL_SOURCES_OPT_OUT_REASON : null;
58
58
  return {
59
+ opencode: !all,
59
60
  codex: codexReason === null,
60
61
  claude: claudeReason === null,
61
62
  codexReason,
@@ -100,7 +100,7 @@ export function parseMetadataOnlySessionReportRows(value, fieldPath) {
100
100
  return sessions;
101
101
  }
102
102
  function parseRetrySource(value, fieldPath) {
103
- if (value === "codex" || value === "claude_code")
103
+ if (value === "codex" || value === "claude_code" || value === "opencode")
104
104
  return value;
105
105
  return invalidUploadSpoolState(fieldPath, '"codex" or "claude_code"');
106
106
  }