@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
@@ -0,0 +1,96 @@
1
+ /** Bounded session-only SQLite projections. No settings, credentials, DB copies or long-lived transactions. */
2
+ import { existsSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { createHash } from "node:crypto";
7
+ const MAX_ROWS = 10_000;
8
+ const MAX_TEXT_BYTES = 32 * 1024 * 1024;
9
+ const sha = (text) => createHash("sha256").update(text).digest("hex");
10
+ function withDatabase(homeDir, absent, read) {
11
+ const file = path.join(homeDir, ".t3", "userdata", "statev2.sqlite");
12
+ if (!existsSync(file))
13
+ return absent;
14
+ let db;
15
+ try {
16
+ const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
17
+ db = new DatabaseSync(file, { readOnly: true });
18
+ return read(db);
19
+ }
20
+ catch (error) {
21
+ const reason = error instanceof Error && error.message.startsWith("t3_projection_") ? error.message : "t3_projection_unreadable";
22
+ console.error("[t3-projection] source unavailable", JSON.stringify({ reason }));
23
+ throw new Error(reason);
24
+ }
25
+ finally {
26
+ db?.close();
27
+ }
28
+ }
29
+ /** The small identity inventory lets singleton native sessions use the same composite path. */
30
+ export function t3CodexProjectionSessionIds(homeDir = os.homedir()) {
31
+ return withDatabase(homeDir, new Set(), db => {
32
+ const rows = db.prepare(`SELECT DISTINCT json_extract(payload_json, '$.nativeThreadRef.nativeId') AS native_id
33
+ FROM orchestration_v2_projection_provider_threads WHERE driver = 'codex'
34
+ AND json_extract(payload_json, '$.nativeThreadRef.driver') = 'codex'
35
+ AND json_extract(payload_json, '$.nativeThreadRef.strength') = 'strong'
36
+ ORDER BY native_id LIMIT ${MAX_ROWS + 1}`).all();
37
+ if (rows.length > MAX_ROWS)
38
+ throw new Error("t3_projection_inventory_capped");
39
+ return new Set(rows.flatMap(r => typeof r["native_id"] === "string" && r["native_id"] ? [r["native_id"]] : []));
40
+ });
41
+ }
42
+ /** One SELECT pins the WAL snapshot; only explicit observed assistant and strong native identity fields leave SQLite. */
43
+ export function readT3CodexProjection(nativeSessionId, homeDir = os.homedir()) {
44
+ return withDatabase(homeDir, [], db => {
45
+ const rows = db.prepare(`SELECT m.message_id, m.thread_id, m.created_at, m.updated_at,
46
+ CASE WHEN length(CAST(json_extract(m.payload_json, '$.text') AS BLOB)) <= ${MAX_TEXT_BYTES}
47
+ THEN json_extract(m.payload_json, '$.text') END AS text,
48
+ length(CAST(json_extract(m.payload_json, '$.text') AS BLOB)) AS text_bytes,
49
+ json_extract(i.payload_json, '$.nativeItemRef.nativeId') AS item_id,
50
+ json_extract(t.payload_json, '$.nativeTurnRef.nativeId') AS turn_id
51
+ FROM orchestration_v2_projection_provider_threads AS p
52
+ CROSS JOIN orchestration_v2_projection_turn_items AS i ON i.thread_id = p.thread_id AND i.provider_thread_id = p.provider_thread_id
53
+ JOIN orchestration_v2_projection_provider_turns AS t ON t.provider_turn_id = i.provider_turn_id AND t.provider_thread_id = p.provider_thread_id
54
+ JOIN orchestration_v2_projection_messages AS m ON m.message_id = json_extract(i.payload_json, '$.messageId') AND m.thread_id = i.thread_id AND m.run_id = i.run_id
55
+ WHERE p.driver = 'codex' AND json_extract(p.payload_json, '$.nativeThreadRef.nativeId') = ?
56
+ AND json_extract(p.payload_json, '$.nativeThreadRef.driver') = 'codex'
57
+ AND json_extract(p.payload_json, '$.nativeThreadRef.strength') = 'strong'
58
+ AND i.type = 'assistant_message' AND m.role = 'assistant' AND m.streaming = 0
59
+ AND json_extract(i.payload_json, '$.nativeItemRef.driver') = 'codex'
60
+ AND json_extract(i.payload_json, '$.nativeItemRef.strength') = 'strong'
61
+ AND json_extract(t.payload_json, '$.nativeTurnRef.driver') = 'codex'
62
+ AND json_extract(t.payload_json, '$.nativeTurnRef.strength') = 'strong'
63
+ ORDER BY m.created_at, m.message_id, turn_id, item_id LIMIT ${MAX_ROWS + 1}`).iterate(nativeSessionId);
64
+ const messages = [];
65
+ // CROSS JOIN pins the small provider-thread inventory outermost; otherwise
66
+ // installed SQLite scans every unrelated turn-item payload before filtering.
67
+ let textBytes = 0, projectionBytes = 0, returned = 0;
68
+ for (const r of rows) {
69
+ if (++returned > MAX_ROWS)
70
+ throw new Error("t3_projection_messages_capped");
71
+ if (typeof r["text_bytes"] === "number" && r["text_bytes"] > MAX_TEXT_BYTES)
72
+ throw new Error("t3_projection_bytes_capped");
73
+ if (typeof r["text"] !== "string" || !r["text"].trim())
74
+ continue;
75
+ textBytes += Buffer.byteLength(r["text"]);
76
+ if (textBytes > MAX_TEXT_BYTES)
77
+ throw new Error("t3_projection_bytes_capped");
78
+ for (const field of ["message_id", "thread_id", "item_id", "turn_id", "created_at", "updated_at"]) {
79
+ if (typeof r[field] !== "string" || !r[field])
80
+ throw new Error("t3_projection_mapping_invalid");
81
+ }
82
+ const message = { schema_version: "t3-message.v1", native_session_id: nativeSessionId,
83
+ native_turn_id: r["turn_id"], native_item_id: r["item_id"],
84
+ t3_thread_id: r["thread_id"], t3_message_id: r["message_id"],
85
+ role: "assistant", created_at: r["created_at"], updated_at: r["updated_at"],
86
+ text_sha256: sha(r["text"]), text: r["text"], provenance: "t3-0.1.7:orchestration_v2_projection_messages" };
87
+ projectionBytes += Buffer.byteLength(JSON.stringify(message));
88
+ if (projectionBytes > MAX_TEXT_BYTES)
89
+ throw new Error("t3_projection_bytes_capped");
90
+ messages.push(message);
91
+ }
92
+ return messages;
93
+ });
94
+ }
95
+ /** Hashes include source identity, timestamps and original text revisions, not database/WAL file stamps. */
96
+ export function t3ProjectionRevision(messages) { return sha(JSON.stringify(messages)); }
@@ -62,7 +62,7 @@ function deliveredCursorEntry(prior, candidate, objectKey, now) {
62
62
  const entry = {
63
63
  ...prior,
64
64
  file_hash_sha256: candidate.content_hash_sha256,
65
- uploaded_source_revision: isRedactionStubObjectKey(objectKey) ? prior?.uploaded_source_revision ?? null : candidate.content_hash_sha256,
65
+ uploaded_source_revision: candidate.t3_projection_incomplete || isRedactionStubObjectKey(objectKey) ? prior?.uploaded_source_revision ?? null : candidate.content_hash_sha256,
66
66
  file_mtime_ms: candidate.session_file_mtime_ms,
67
67
  byte_size: candidate.byte_size,
68
68
  // The whole file was delivered, so the incremental reader starts at its end.
@@ -48,6 +48,11 @@ async function addCodexStoreScanIssues(issues, codexAttribution) {
48
48
  // here would erase a failure in another configured home or T3 discovery.
49
49
  countScanIssue(issues, "codex_directory_read_failed", codexAttribution.directory_read_failed_count, "global");
50
50
  countScanIssue(issues, "codex_session_stat_failed", codexAttribution.stat_failed_count, "global");
51
+ if (codexAttribution.projection_failure_reason)
52
+ countScanIssue(issues, codexAttribution.projection_failure_reason, 1, "global");
53
+ for (const result of codexAttribution.results)
54
+ if (result.t3_projection_failure_reason)
55
+ countScanIssue(issues, result.t3_projection_failure_reason, 1, "global");
51
56
  }
52
57
  /** The same census for the Claude store, which also has sidecars to account for. */
53
58
  async function addClaudeStoreScanIssues(issues, claudeAttribution, candidates) {
@@ -16,6 +16,12 @@ export function writeDryRunSummary(io, options) {
16
16
  writeLine(io.stdout, `Verify after upload: ${options.dashboardUrl}/my-work`);
17
17
  }
18
18
  export function writeHumanBackfillResult(result, io) {
19
+ if (result.opencode) {
20
+ const source = result.opencode;
21
+ writeLine(io.stdout, `OpenCode: ${source.discovered} discovered, ${source.eligible} eligible, ${source.delivered} delivered, ${source.pending} pending, ${source.withheld} withheld (${source.coverage}).`);
22
+ for (const reason of source.reasons)
23
+ writeLine(io.stderr, `OpenCode: ${reason}`);
24
+ }
19
25
  if (result.status === "complete") {
20
26
  writeLine(io.stdout, `PASS: ${result.counts.backfilled} backfilled, ${result.counts.skipped} skipped (table). Verify: ${result.verify_url}`);
21
27
  writeReasonTable(io, result.counts.reasons);
@@ -18,7 +18,7 @@ import { summarizeStubReoffer } from "./backfill-stub-reoffer.js";
18
18
  * blocks completion on its own — every completion-gating computation below
19
19
  * excludes it explicitly via `oversizedCandidateKeys`/`blockingScanIssues`.
20
20
  */
21
- export async function reportBackfillOutcome(command, io, ctx, upload) {
21
+ export async function reportBackfillOutcome(command, io, ctx, upload, nativePartial = false) {
22
22
  let { blockedAt, failureReason } = upload;
23
23
  const posted = await postBackfillSessionReport(command, io, ctx, upload);
24
24
  if (!posted.acknowledged && !blockedAt) {
@@ -36,8 +36,8 @@ export async function reportBackfillOutcome(command, io, ctx, upload) {
36
36
  ctx,
37
37
  upload,
38
38
  posted,
39
- stoppedEarly: Boolean(blockedAt),
40
- failureReason,
39
+ stoppedEarly: Boolean(blockedAt) || nativePartial,
40
+ failureReason: failureReason ?? (nativePartial ? "opencode_backfill_pending" : undefined),
41
41
  });
42
42
  if (completion.status === "complete" && command.all && !command.stubPass) {
43
43
  await writeAllHistoryCompletionMarker(ctx);
@@ -269,6 +269,8 @@ function normalizeCodexCandidate(result) {
269
269
  source: "codex",
270
270
  session_id: result.codex_session_id,
271
271
  ...(result.segment_paths ? { segment_paths: result.segment_paths } : {}),
272
+ ...(result.t3_projection ? { t3_projection: true } : {}),
273
+ ...(result.t3_projection_incomplete ? { t3_projection_incomplete: true } : {}),
272
274
  session_id_source: result.session_id_source,
273
275
  file_path: result.file_path,
274
276
  state: result.state,
@@ -98,6 +98,8 @@ async function discoverBackfillWorktrees(roots, command) {
98
98
  });
99
99
  }
100
100
  export function selectedSources(source) {
101
+ if (source === "opencode")
102
+ return []; // Native inventory and cursor are handled by the shared native pass.
101
103
  if (source === "codex")
102
104
  return ["codex"];
103
105
  if (source === "claude")
@@ -19,10 +19,10 @@ const BACKFILL_MAX_CONSECUTIVE_FAILURES = 3;
19
19
  * missing" would fail every batch it happens to share with genuinely uploaded
20
20
  * siblings, so it is excluded via `oversizedCandidateKeys` throughout.
21
21
  */
22
- export async function uploadBackfillBatches(command, io, lock, ctx) {
22
+ export async function uploadBackfillBatches(command, io, lock, ctx, sharedBudget) {
23
23
  const uploadable = uploadableCandidates(ctx.scan.candidates);
24
24
  const batches = planUploadBatches(command, uploadable);
25
- const rawEvidenceBudget = {
25
+ const rawEvidenceBudget = sharedBudget ?? {
26
26
  remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
27
27
  remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
28
28
  };
@@ -231,6 +231,7 @@ function backfillBatchSyncOptions(options) {
231
231
  .map((candidate) => ({
232
232
  local_path: candidate.file_path,
233
233
  codex_session_id: candidate.session_id,
234
+ ...(candidate.t3_projection ? { t3_projection: true } : {}),
234
235
  ...(candidate.segment_paths ? { segment_paths: candidate.segment_paths, revision_hash: candidate.content_hash_sha256 ?? undefined, revision_bytes: candidate.byte_size } : {}),
235
236
  })),
236
237
  codexAttributionScan: options.codexAttribution ?? undefined,
@@ -37,6 +37,8 @@
37
37
  * both earned a durable pointer and were acknowledged by the server, so a
38
38
  * misjudged "resolved" loses that history for good.
39
39
  */
40
+ import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
41
+ import { syncOpenCodeSessions } from "./opencode-sync.js";
40
42
  import { acquireBackfillLock } from "../backfill-lock.js";
41
43
  import { writeBackfillCursor } from "../cursors/backfill-cursor.js";
42
44
  import { DEFAULT_BACKFILL_SINCE_DAYS, backfillRetryCommand, defaultBackfillWindowNotice, } from "./backfill-command-line.js";
@@ -105,8 +107,16 @@ export async function runBackfill(command, io) {
105
107
  if (scanned.kind === "blocked")
106
108
  return scanned.result;
107
109
  const plan = await planBackfillRun(command, io, scanned);
108
- if (plan.kind === "result")
110
+ if (plan.kind === "result") {
111
+ if (command.dryRun && (!command.source || command.source === "opencode") && plan.result.status !== "blocked") {
112
+ plan.result.opencode = await syncOpenCodeSessions({ homeDir: scanned.paths.home_dir, worktrees: scanned.worktrees, collectionRoots: scanned.collectionRoots,
113
+ dashboardUrl: scanned.dashboardUrl, fetchImpl: io.fetch ?? globalThis.fetch, dryRun: true,
114
+ sinceMs: command.all ? undefined : now.getTime() - (command.sinceDays ?? DEFAULT_BACKFILL_SINCE_DAYS) * 86400000 });
115
+ if (plan.result.opencode.coverage === "partial")
116
+ plan.result.status = "partial";
117
+ }
109
118
  return plan.result;
119
+ }
110
120
  const { paths } = scanned;
111
121
  // BLI-11112: the sync's stub pass does not take the backfill lock. It runs
112
122
  // under the collection lock, which already serialises it against any
@@ -144,8 +154,15 @@ export async function runBackfill(command, io) {
144
154
  // BLI-11102: mark server-named stubs as not delivered, under the lock
145
155
  // because sync writes the same cursor files.
146
156
  await markStubReoffers(paths, scanned.stubReoffer, now);
147
- const upload = await uploadBackfillBatches(command, io, lock, scanned);
148
- return await reportBackfillOutcome(command, io, scanned, upload);
157
+ const budget = { remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET };
158
+ const upload = await uploadBackfillBatches(command, io, lock, scanned, budget);
159
+ const opencode = (!command.source || command.source === "opencode") ? await syncOpenCodeSessions({ homeDir: paths.home_dir, worktrees: scanned.worktrees, collectionRoots: scanned.collectionRoots,
160
+ dashboardUrl: scanned.dashboardUrl, fetchImpl: io.fetch ?? globalThis.fetch, budget, deadlineMs: command.deadlineMs,
161
+ sinceMs: command.all ? undefined : now.getTime() - (command.sinceDays ?? DEFAULT_BACKFILL_SINCE_DAYS) * 86400000 }) : undefined;
162
+ const result = await reportBackfillOutcome(command, io, scanned, upload, opencode?.coverage === "partial");
163
+ if (opencode)
164
+ result.opencode = opencode;
165
+ return result;
149
166
  }
150
167
  finally {
151
168
  await collectionLock.handle.release();
@@ -5,9 +5,9 @@ import { imageStateKey } from "../image-description-state.js";
5
5
  import { openCodexSessionRevision } from "../adapters/codex-session-revision.js";
6
6
  import { createSlimmer } from "../slim/slim-transcript.js";
7
7
  import { scanLines, decideLinePlan, writeLinePlan } from "../adapters/raw-evidence-sanitize-lines.js";
8
- export function codexRevisionFacts(paths, mode, describedKeys, slimAware) {
8
+ export function codexRevisionFacts(paths, mode, describedKeys, slimAware, t3Projection = false) {
9
9
  const form = async (drop) => {
10
- const source = await openCodexSessionRevision(paths);
10
+ const source = await openCodexSessionRevision(paths, { t3Projection });
11
11
  try {
12
12
  const lines = { async *lines() {
13
13
  const slimmer = createSlimmer("codex");
@@ -46,7 +46,7 @@ export function codexRevisionFacts(paths, mode, describedKeys, slimAware) {
46
46
  let full, slim;
47
47
  return {
48
48
  slimAware,
49
- async rawSha256() { const source = await openCodexSessionRevision(paths); try {
49
+ async rawSha256() { const source = await openCodexSessionRevision(paths, { t3Projection }); try {
50
50
  return source.sha256;
51
51
  }
52
52
  finally {
@@ -32,6 +32,12 @@ function attributionReportLine(summary) {
32
32
  export function writeAgentSessionSummary(io, summary) {
33
33
  writeLine(io.stdout, sourceFunnelLine("Codex", summary.codex));
34
34
  writeLine(io.stdout, sourceFunnelLine("Claude", summary.claude));
35
+ if (summary.opencode) {
36
+ const source = summary.opencode;
37
+ writeLine(io.stdout, `OpenCode sessions: ${source.discovered} discovered, ${source.eligible} eligible, ${source.delivered} delivered, ${source.pending} pending, ${source.withheld} withheld (${source.coverage}).`);
38
+ for (const reason of source.reasons)
39
+ writeLine(io.stdout, `OpenCode: ${reason}`);
40
+ }
35
41
  const claudeDiagnostics = claudeDiagnosticsLine(summary);
36
42
  if (claudeDiagnostics)
37
43
  writeLine(io.stdout, claudeDiagnostics);
@@ -69,8 +69,8 @@ export function parseSessionsArgs(args) {
69
69
  });
70
70
  assertNoPositionals(values.positionals, "sessions");
71
71
  const source = values.flags.get("--source");
72
- if (source !== undefined && source !== "codex" && source !== "claude") {
73
- throw new Error("--source must be 'codex' or 'claude'. Omit it to scan both.");
72
+ if (source !== undefined && source !== "codex" && source !== "claude" && source !== "opencode") {
73
+ throw new Error("--source must be 'codex', 'claude' or 'opencode'. Omit it to scan all sources.");
74
74
  }
75
75
  const sinceDays = optionalPositiveInteger(values.flags.get("--since-days"), "--since-days");
76
76
  const all = values.booleans.has("--all");
@@ -151,8 +151,8 @@ export function parseBackfillArgs(args) {
151
151
  });
152
152
  assertNoPositionals(values.positionals, "backfill");
153
153
  const source = values.flags.get("--source");
154
- if (source !== undefined && source !== "codex" && source !== "claude") {
155
- throw new Error("--source must be 'codex' or 'claude'. Omit it to scan both.");
154
+ if (source !== undefined && source !== "codex" && source !== "claude" && source !== "opencode") {
155
+ throw new Error("--source must be 'codex', 'claude' or 'opencode'. Omit it to scan all sources.");
156
156
  }
157
157
  const sinceDays = optionalPositiveInteger(values.flags.get("--since-days"), "--since-days");
158
158
  const all = values.booleans.has("--all");
@@ -251,7 +251,7 @@ export const TOWER_COMMAND_HELP = [
251
251
  "usage",
252
252
  [
253
253
  "Usage: cockpit usage people [--by-repo | --by-topic | --by-subject | --by-task | --by-client] [--min-confidence high|medium|low] [--person <email|me>] [--all] [--since <n>d|<n>h|<iso>] [--detail] [--until <iso>] [--include-automated] [--json]",
254
- " cockpit usage sessions [--person <exact-name>] [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--tool claude|codex] [--since <window>] [--until <iso>] [--cursor <cursor>] [--limit <n>] [--json]",
254
+ " cockpit usage sessions [--person <exact-name>] [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--tool claude|codex|opencode] [--since <window>] [--until <iso>] [--cursor <cursor>] [--limit <n>] [--json]",
255
255
  " cockpit usage session <id> [--from-window <n>] [--json]",
256
256
  " --by-topic groups work types; --by-subject groups open subjects from session summaries.",
257
257
  "",
@@ -146,9 +146,9 @@ const COMMAND_SUMMARY_LINES = [
146
146
  " cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
147
147
  " cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|summarize <id>|move <id>|place <id>] [--folder <path>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--apply] [--yes] [--dashboard-url <url>] [--json]",
148
148
  " cockpit notes [accounts [--show-webhook <account-id>]|connect --provider fellow --workspace <sub> --key-stdin [--name <n>]|connect --provider wisprflow|recordings <note-id>|recording <id>|webhook <account-id> --secret-stdin|detach <account-id>|sync <account-id>] [--json] (the key and the signing secret are read from stdin, never a flag)",
149
- " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--lock-wait <seconds>] [--yes] [--workspace <path>] [--json]",
149
+ " cockpit backfill (--since-days <n>|--all) [--source codex|claude|opencode] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--lock-wait <seconds>] [--yes] [--workspace <path>] [--json]",
150
150
  " cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
151
- " cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
151
+ " cockpit sessions [--source codex|claude|opencode] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
152
152
  " cockpit serve [--port <port>] [--workspace <path>]",
153
153
  " cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
154
154
  " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
@@ -190,7 +190,7 @@ const COMMAND_SUMMARY_LINES = [
190
190
  " cockpit manager <clients|client <client>|run <run-id>|list <client>|queue <client>|show <id>|add <client> --title <t>|edit <id> --version <v>|done|reopen|link|approve|dismiss|proposal|archive|unarchive> [--timeline] [--dashboard-url <url>] [--json]",
191
191
  " cockpit image <describe <file> [--session <id>]|show <sha256-or-prefix>> [--dashboard-url <url>] [--json]",
192
192
  " cockpit setup [--since <duration|date>] [--until <date>] [--machines] [--details] [--person <name|email>] [--compare <name,name>] [--json]",
193
- " cockpit usage sessions [--person <exact-name>] [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--tool claude|codex] [--since <window>] [--until <iso>] [--cursor <cursor>] [--limit <n>] [--json]",
193
+ " cockpit usage sessions [--person <exact-name>] [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--tool claude|codex|opencode] [--since <window>] [--until <iso>] [--cursor <cursor>] [--limit <n>] [--json]",
194
194
  " cockpit usage session <id> [--from-window <n>] [--json]",
195
195
  " --by-topic groups work types; --by-subject groups open subjects from session summaries.",
196
196
  " cockpit ticket mutation-status <request-uuid> [--json] (check a lost write response)",
@@ -0,0 +1,163 @@
1
+ /** Route OpenCode's third native format through the existing evidence pack, spool, report queue and durable cursor. */
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, startLocalWorkContext, startLocalWorkContextForAttributedTarget } from "../local-state.js";
5
+ import { writeJsonFileAtomic } from "../local-state-files.js";
6
+ import { reportShelvedSessions } from "../shelf-reports.js";
7
+ import { openCodeCollectionPolicy } from "../opencode-policy.js";
8
+ import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
9
+ import { cwdInsideRoots } from "../collection-scope.js";
10
+ import { scoreSignalsAgainstWorktrees, sha256 } from "../adapters/attribution-core.js";
11
+ import { outsideRootsWorkspace, isOutsideRootsWorkspace } from "./backfill-outside-roots.js";
12
+ import { syncLocalAmbientEnvelope } from "../upload.js";
13
+ import { readRawEvidenceCursor, writeRawEvidenceCursor } from "../cursors/raw-evidence-cursor.js";
14
+ import { readOpenCodeInventory, acknowledgeOpenCodeUsage, OPENCODE_CURSOR_FILENAME } from "../opencode-inventory.js";
15
+ import { queueCodexSessionReport, flushPendingCodexSessionReports } from "../upload-session-reports.js";
16
+ import { recordSourceRetryFailure, clearSourceRetryFailure } from "../spool/local-spool.js";
17
+ import { describeError } from "../health-detail.js";
18
+ /** The all-history inventory is resumable through existing per-object acknowledgments, never a second delivery engine. */
19
+ export async function syncOpenCodeSessions(options) {
20
+ const paths = getCollectorRuntimePaths(options.homeDir);
21
+ const now = new Date();
22
+ const summary = { source: "opencode", new_since_last_scan: 0, outside_roots: 0, discovered: 0, eligible: 0, delivered: 0, pending: 0, withheld: 0, unique: 0, coverage: "complete", reasons: [] };
23
+ try {
24
+ const policy = await openCodeCollectionPolicy(options);
25
+ if (!policy.enabled) {
26
+ const held = await readOpenCodeInventory({ homeDir: options.homeDir, stateDir: paths.state_dir, metadataOnly: true, include: () => false, sinceMs: options.sinceMs });
27
+ summary.discovered = held.discovered;
28
+ summary.unique = held.discovered;
29
+ summary.withheld = held.withheld;
30
+ summary.coverage = held.failures.length || policy.reason === "owner_check_unavailable" ? "partial" : held.absent ? "absent" : "withheld";
31
+ summary.reasons.push(policy.reason, ...held.failures.map(failure => failure.reason));
32
+ if (summary.coverage === "partial")
33
+ await recordSourceRetryFailure(paths, { source: "opencode", attemptedAt: now.toISOString(), reason: held.failures[0]?.reason ?? policy.reason });
34
+ console.error("[opencode sync] source withheld", JSON.stringify(summary));
35
+ return summary;
36
+ }
37
+ const attributed = (directory) => scoreSignalsAgainstWorktrees({ cwds: [directory], workspaceRoots: [], originUrls: [], branches: [], headShas: [] }, options.worktrees, { collectionRoots: options.collectionRoots });
38
+ const cursor = await readRawEvidenceCursor(paths, { filename: OPENCODE_CURSOR_FILENAME, sessionsOnly: true });
39
+ const inventory = await readOpenCodeInventory({ homeDir: options.homeDir, stateDir: paths.state_dir, metadataOnly: options.dryRun, sinceMs: options.sinceMs, deliveredSessionIds: new Set(Object.entries(cursor.sessions).filter(([, entry]) => entry.uploaded_object_key).map(([id]) => id)), include: policy.include, countOnly: policy.drawer });
40
+ summary.discovered = inventory.discovered;
41
+ summary.withheld = inventory.withheld;
42
+ summary.eligible = inventory.eligible;
43
+ summary.pending = inventory.eligible - inventory.files.length;
44
+ summary.unique = inventory.discovered;
45
+ summary.reasons.push(...new Set(inventory.failures.map((failure) => failure.reason)));
46
+ if (inventory.absent)
47
+ summary.coverage = "absent";
48
+ if (options.dryRun) {
49
+ summary.pending = inventory.eligible;
50
+ if (inventory.failures.length)
51
+ summary.coverage = "partial";
52
+ return summary;
53
+ }
54
+ if (inventory.shelved.length) {
55
+ const [device, config] = await Promise.all([readLocalCollectorSessionFile(paths), readLocalCollectorConfig(paths)]);
56
+ const shelfReport = await reportShelvedSessions({ paths, dashboardUrl: options.dashboardUrl ?? config?.dashboard_url ?? null,
57
+ deviceToken: device?.device_token ?? null, fetchImpl: options.fetchImpl, allow: config?.shelf_metrics_allow ?? [], now,
58
+ shelved: inventory.shelved.map(file => ({ key: `opencode:${file.session.id}`, source: "opencode", reason: "drawer_shelf", facts: file.facts, recencyMs: file.session.updated_at_ms })) });
59
+ if (shelfReport.failed || shelfReport.not_counted)
60
+ summary.reasons.push("opencode_shelf_metrics_pending");
61
+ }
62
+ summary.new_since_last_scan = inventory.files.filter((file) => !cursor.sessions[file.session.id]).length;
63
+ summary.outside_roots = inventory.files.filter((file) => !cwdInsideRoots(file.session.directory, options.collectionRoots)).length;
64
+ const durableSessions = new Set();
65
+ const groups = new Map();
66
+ for (const file of inventory.files) {
67
+ const worktree = attributed(file.session.directory).worktree ?? outsideRootsWorkspace(options.homeDir);
68
+ const group = groups.get(worktree.worktree_fingerprint) ?? { worktree, files: [] };
69
+ group.files.push(file);
70
+ groups.set(worktree.worktree_fingerprint, group);
71
+ }
72
+ const budget = options.budget ?? { remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET };
73
+ for (const { worktree, files } of groups.values()) {
74
+ if (options.deadlineMs && Date.now() >= options.deadlineMs) {
75
+ summary.reasons.push("opencode_deadline_deferred");
76
+ summary.pending = summary.eligible - summary.delivered;
77
+ break;
78
+ }
79
+ if (isOutsideRootsWorkspace(worktree))
80
+ await fs.mkdir(worktree.repo_root, { recursive: true, mode: 0o700 });
81
+ const contextOptions = { homeDir: options.homeDir, repoRoot: worktree.repo_root, branch: worktree.branch, leaveActiveContext: true };
82
+ const context = isOutsideRootsWorkspace(worktree) ? await startLocalWorkContextForAttributedTarget(contextOptions, worktree) : await startLocalWorkContext(contextOptions);
83
+ const sync = await syncLocalAmbientEnvelope({ homeDir: options.homeDir, repoRoot: worktree.repo_root, dashboardUrl: options.dashboardUrl,
84
+ rawEvidenceBudget: budget, codexSessionFiles: [], claudeSessionFiles: [], nativeSessionFiles: files.map((file) => ({ local_path: file.local_path, session_id: file.session.id, source: "opencode", redaction: file.redaction })), fetch: options.fetchImpl });
85
+ if (sync.status !== "uploaded")
86
+ summary.reasons.push(sync.failure_reason);
87
+ const rows = files.map((file) => {
88
+ const attribution = attributed(file.session.directory);
89
+ const outcome = sync.raw_evidence_outcomes.find((item) => item.kind === "opencode_jsonl" && item.codex_session_id === file.session.id);
90
+ const pointer = outcome && (outcome.upload_state === "uploaded" || outcome.upload_state === "reused_existing") ? outcome.raw_evidence_pointer_id : null;
91
+ if (!pointer)
92
+ summary.reasons.push(outcome?.reason ?? "opencode_delivery_pending");
93
+ return { source: "opencode", codex_session_id: file.session.id, observed_at: now.toISOString(), attribution_state: attribution.state,
94
+ attribution_reason: attribution.reason, attribution_score: attribution.attribution_score, path_score: attribution.path_score, signals: attribution.signals,
95
+ session_file_hash_sha256: file.revision, session_file_byte_size: file.byte_size, session_file_mtime: new Date(file.session.updated_at_ms).toISOString(), session_facts: file.facts,
96
+ repo_fingerprint: attribution.worktree?.repo_fingerprint, worktree_fingerprint: attribution.worktree?.worktree_fingerprint,
97
+ repo_label: attribution.worktree?.repo_label, branch: attribution.worktree?.branch, cwd_basename: path.basename(file.session.directory), cwd_hash: sha256(Buffer.from(file.session.directory)),
98
+ root_scope: cwdInsideRoots(file.session.directory, options.collectionRoots) ? "inside" : "outside",
99
+ raw_evidence_pointer_id: pointer ?? undefined, upload_state: pointer ? "uploaded" : "not_uploaded", upload_reason: pointer ? "durable_evidence_acknowledged" : outcome?.reason ?? "opencode_delivery_pending" };
100
+ });
101
+ // Durable metadata queue precedes cursor movement; an unavailable report endpoint cannot age sessions out.
102
+ await queueCodexSessionReport({ homeDir: options.homeDir, dashboardUrl: sync.dashboard_url, generatedAt: now.toISOString(), workContextId: context.work_context_id,
103
+ repoLabel: worktree.repo_label, branch: worktree.branch, repoFingerprint: worktree.repo_fingerprint, repoOriginUrl: worktree.repo_origin_url,
104
+ worktreeLabel: worktree.worktree_label, worktreeFingerprint: worktree.worktree_fingerprint, worktreeIsPrimary: worktree.worktree_is_primary, sessions: rows });
105
+ for (const [index, file] of files.entries()) {
106
+ const row = rows[index];
107
+ const pointer = row.raw_evidence_pointer_id ?? null;
108
+ if (pointer) {
109
+ summary.delivered += 1;
110
+ durableSessions.add(file.session.id);
111
+ }
112
+ else
113
+ summary.pending += 1;
114
+ cursor.sessions[file.session.id] = { file_hash_sha256: file.revision, file_mtime_ms: file.session.updated_at_ms, byte_size: file.byte_size, byte_offset: pointer ? file.byte_size : 0,
115
+ state: row.attribution_state, reason: row.attribution_reason, worktree_fingerprint: row.worktree_fingerprint ?? null, uploaded_object_key: pointer,
116
+ uploaded_at: pointer ? now.toISOString() : null, uploaded_byte_size: pointer ? file.byte_size : null, last_seen_at: now.toISOString() };
117
+ }
118
+ cursor.updated_at = now.toISOString();
119
+ await writeRawEvidenceCursor(paths, cursor, { filename: OPENCODE_CURSOR_FILENAME, sessionsOnly: true });
120
+ }
121
+ const report = await flushPendingCodexSessionReports({ homeDir: options.homeDir, fetch: options.fetchImpl });
122
+ if (report.posted && report.failed_count === 0) {
123
+ await acknowledgeOpenCodeUsage(paths.state_dir, durableSessions);
124
+ // The shared pack/spool owns acknowledged bytes now; these are disposable redacted reader derivatives.
125
+ for (const file of inventory.files)
126
+ if (durableSessions.has(file.session.id))
127
+ await fs.unlink(file.local_path).catch(() => undefined);
128
+ }
129
+ if (inventory.files.length && (!report.posted || report.failed_count > 0)) {
130
+ summary.reasons.push(report.reason);
131
+ summary.pending += summary.delivered;
132
+ summary.delivered = 0;
133
+ }
134
+ summary.reasons = [...new Set(summary.reasons)];
135
+ if (summary.pending || summary.reasons.length)
136
+ summary.coverage = "partial";
137
+ if (summary.coverage === "partial")
138
+ await recordSourceRetryFailure(paths, { source: "opencode", attemptedAt: now.toISOString(), reason: summary.reasons[0] ?? "opencode_delivery_pending" });
139
+ else
140
+ await clearSourceRetryFailure(paths, "opencode", now.toISOString());
141
+ }
142
+ catch (error) {
143
+ summary.pending = Math.max(summary.pending, summary.eligible - summary.delivered);
144
+ summary.coverage = "partial";
145
+ summary.reasons.push("opencode_sync_failed");
146
+ await recordSourceRetryFailure(paths, { source: "opencode", attemptedAt: now.toISOString(), reason: "opencode_sync_failed" });
147
+ console.error("[opencode sync] pass failed; native sessions remain retryable", JSON.stringify({ reason: "opencode_sync_failed", ...describeError(error) }));
148
+ }
149
+ finally {
150
+ if (!options.dryRun) {
151
+ try {
152
+ await writeJsonFileAtomic(path.join(paths.state_dir, "opencode-collection-status.json"), { observed_at: now.toISOString(), ...summary });
153
+ }
154
+ catch (error) {
155
+ summary.coverage = "partial";
156
+ summary.reasons.push("opencode_coverage_receipt_write_failed");
157
+ console.error("[opencode sync] coverage receipt not written", JSON.stringify({ reason: "opencode_coverage_receipt_write_failed", ...describeError(error) }));
158
+ }
159
+ }
160
+ }
161
+ console.error("[opencode sync] pass finished", JSON.stringify(summary));
162
+ return summary;
163
+ }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.293");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.294");
25
25
  return 0;
26
26
  }
27
27
 
@@ -170,4 +170,8 @@ export function recordUnexplainedFailure(ledger) {
170
170
  label: SYNC_FAILED_WITHOUT_REASON,
171
171
  rendered: SYNC_FAILED_WITHOUT_REASON,
172
172
  });
173
+ }
174
+ /** Keep native partial outcomes in the same closed failure ledger as legacy sources. */
175
+ export function recordOpenCodeFailures(ledger, native) {
176
+ ledger.fail(native.coverage === "partial", "opencode_scan_read_failed", native.reasons[0] ?? "opencode_delivery_pending");
173
177
  }
@@ -322,7 +322,7 @@ function recordSourceObservation(options, result) {
322
322
  : (prior?.uploaded_byte_size ?? (durableThisSync ? result.byte_size : null));
323
323
  const entry = {
324
324
  file_hash_sha256: result.content_hash_sha256,
325
- uploaded_source_revision: durableThisSync && !isRedactionStubObjectKey(uploadedObjectKey)
325
+ uploaded_source_revision: durableThisSync && !("t3_projection_incomplete" in result && result.t3_projection_incomplete) && !isRedactionStubObjectKey(uploadedObjectKey)
326
326
  ? result.content_hash_sha256 : prior?.uploaded_source_revision ?? null,
327
327
  file_mtime_ms: result.session_file_mtime_ms,
328
328
  byte_size: result.byte_size,
@@ -39,7 +39,7 @@ export async function syncEveryTargetWorktree(options) {
39
39
  // sessionId -> prior durable pointer for damped sessions (drives the
40
40
  // growth_damped count + the skip_main decision).
41
41
  const dampedClaudePointers = new Map();
42
- const rawEvidenceBudget = {
42
+ const rawEvidenceBudget = run.rawEvidenceBudget ?? {
43
43
  remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET,
44
44
  remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET,
45
45
  };
@@ -180,6 +180,7 @@ function ambientEnvelopeForWorktree(options) {
180
180
  .map((result) => ({
181
181
  local_path: result.file_path,
182
182
  codex_session_id: result.codex_session_id,
183
+ ...(result.t3_projection ? { t3_projection: true } : {}),
183
184
  ...(result.segment_paths ? { segment_paths: result.segment_paths, revision_hash: result.content_hash_sha256 ?? undefined, revision_bytes: result.byte_size } : {}),
184
185
  })),
185
186
  codexAttributionScan: scan.codexAttribution,
@@ -1,3 +1,4 @@
1
+ import { createSyncFailureLedger, recordOpenCodeFailures } from "./session-sync-failures.js";
1
2
  import { readLocalWorkContextForRepo } from "../local-state.js";
2
3
  /**
3
4
  * One attributed sync pass: scan both session stores, attribute what they hold,
@@ -39,6 +40,8 @@ import { readLocalWorkContextForRepo } from "../local-state.js";
39
40
  * `runtimeFiles`, or the repo tests stay green while the packed CLI breaks.
40
41
  */
41
42
  import os from "node:os";
43
+ import { RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } from "../adapters/raw-evidence.js";
44
+ import { syncOpenCodeSessions } from "./opencode-sync.js";
42
45
  import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile } from "../local-state.js";
43
46
  import { reportShelvedSessions } from "../shelf-reports.js";
44
47
  import { splitScanByShelf } from "./session-sync-shelf.js";
@@ -106,7 +109,7 @@ export async function runAttributedWorktreeSync(requested) {
106
109
  return heldSyncRun(scannedAll);
107
110
  const shelfSplit = splitScanByShelf({ scan: scannedAll, worktrees: requested.worktrees, decision: shelfDecision });
108
111
  const scanned = shelfSplit.scan;
109
- const options = { ...requested, worktrees: shelfSplit.worktrees };
112
+ const options = { ...requested, worktrees: shelfSplit.worktrees, rawEvidenceBudget: requested.rawEvidenceBudget ?? { remainingBytes: RAW_EVIDENCE_DEFAULT_BYTE_BUDGET, remainingObjects: RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET } };
110
113
  // BLI-17916: roots are a label, never a gate. Outside-root sessions ride this
111
114
  // tick's upload under the synthetic workspace, a few per tick, newest first.
112
115
  const scan = await admitOutsideRootSessions({
@@ -141,8 +144,22 @@ export async function runAttributedWorktreeSync(requested) {
141
144
  reportCountedSessionFacts(sessions);
142
145
  const shelfReports = await sendShelfReports(options, paths, shelfSplit, now);
143
146
  const run = await finishAttributedSync(options, { paths, now, plan, scan, sessions, worktreePass });
147
+ const opencode = await syncOpenCodeSessions({ homeDir, worktrees: requested.worktrees, collectionRoots: plan.collectionRoots, dashboardUrl: requested.dashboardUrl, fetchImpl: requested.fetchImpl, budget: options.rawEvidenceBudget });
148
+ const failures = createSyncFailureLedger();
149
+ for (const failure of run.failure_records)
150
+ failures.add(failure);
151
+ recordOpenCodeFailures(failures, opencode);
144
152
  return {
145
153
  ...run,
154
+ opencode,
155
+ summary: { ...run.summary, opencode },
156
+ sessions_new_this_tick: run.sessions_new_this_tick + opencode.new_since_last_scan,
157
+ sessions_outside_root: run.sessions_outside_root + opencode.outside_roots,
158
+ ok: run.ok && opencode.coverage !== "partial",
159
+ failure_reasons: [...run.failure_reasons, ...(opencode.coverage === "partial" ? opencode.reasons.length ? opencode.reasons : ["opencode_delivery_pending"] : [])],
160
+ failure_records: failures.sortedRecords(),
161
+ sessions_observed: run.sessions_observed + opencode.eligible,
162
+ sessions_pending_upload: run.sessions_pending_upload + opencode.pending,
146
163
  sessions_shelved: shelfSplit.shelved.length,
147
164
  packs_refused: worktreePass.packsRefused ?? 0,
148
165
  shelf_reports: shelfReports,