@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.
- package/dist/adapters/codex-attribution.js +28 -7
- package/dist/adapters/codex-segment-discovery.js +12 -3
- package/dist/adapters/codex-session-revision.js +58 -3
- package/dist/adapters/local-sources.js +1 -0
- package/dist/adapters/opencode-store.js +201 -0
- package/dist/adapters/raw-evidence-attribution-gaps.js +5 -0
- package/dist/adapters/raw-evidence-codex-reader.js +25 -3
- package/dist/adapters/raw-evidence-transcript-body.js +7 -2
- package/dist/adapters/raw-evidence-transcript-file.js +19 -0
- package/dist/adapters/raw-evidence-transcript-stream.js +6 -3
- package/dist/adapters/raw-evidence.js +13 -0
- package/dist/adapters/t3-projection-source.js +96 -0
- package/dist/commands/backfill-checkpoint.js +1 -1
- package/dist/commands/backfill-issues.js +5 -0
- package/dist/commands/backfill-output.js +6 -0
- package/dist/commands/backfill-report.js +3 -3
- package/dist/commands/backfill-scan.js +2 -0
- package/dist/commands/backfill-scope.js +2 -0
- package/dist/commands/backfill-upload.js +3 -2
- package/dist/commands/backfill.js +20 -3
- package/dist/commands/codex-revision-facts.js +3 -3
- package/dist/commands/collection-report.js +6 -0
- package/dist/commands/local-args-collector-status.js +2 -2
- package/dist/commands/local-args-collector-work.js +2 -2
- package/dist/commands/local-help-commands-tower.js +1 -1
- package/dist/commands/local-help.js +3 -3
- package/dist/commands/opencode-sync.js +163 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-failures.js +4 -0
- package/dist/commands/session-sync-record.js +1 -1
- package/dist/commands/session-sync-upload.js +2 -1
- package/dist/commands/session-sync.js +18 -1
- package/dist/commands/sessions.js +27 -8
- package/dist/commands/sources-check.js +54 -47
- package/dist/commands/sources-discovery.js +35 -4
- package/dist/commands/sources-verdict.js +3 -3
- package/dist/local-state-status.js +18 -1
- package/dist/opencode-inventory.js +203 -0
- package/dist/opencode-policy.js +29 -0
- package/dist/slim/slim-transcript.js +3 -2
- package/dist/source-opt-out.js +2 -1
- package/dist/spool/local-spool-parse.js +1 -1
- package/dist/state-dir-audit.js +2 -0
- package/dist/sync-health-class.js +2 -0
- package/dist/upload-envelope-build.js +1 -0
- package/dist/upload-evidence-delivery-summary.js +10 -0
- package/dist/upload-session-reports-wire.js +15 -8
- 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
|
-
|
|
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, "
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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 = "
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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.
|
|
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) {
|
package/dist/source-opt-out.js
CHANGED
|
@@ -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
|
}
|