@bli-cockpit/cli 0.2.292 → 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 (69) hide show
  1. package/dist/adapters/claude-attribution-discovery.js +22 -0
  2. package/dist/adapters/claude-attribution-memoized-reads.js +3 -3
  3. package/dist/adapters/claude-attribution.js +5 -2
  4. package/dist/adapters/codex-attribution-memoized-reads.js +3 -3
  5. package/dist/adapters/codex-attribution.js +51 -12
  6. package/dist/adapters/codex-segment-discovery.js +75 -0
  7. package/dist/adapters/codex-session-revision.js +194 -0
  8. package/dist/adapters/codex-session-signals.js +2 -2
  9. package/dist/adapters/local-sources.js +1 -0
  10. package/dist/adapters/opencode-store.js +201 -0
  11. package/dist/adapters/pi-session-facts.js +28 -0
  12. package/dist/adapters/pi-session-reader.js +132 -0
  13. package/dist/adapters/raw-evidence-attribution-gaps.js +5 -0
  14. package/dist/adapters/raw-evidence-codex-reader.js +39 -7
  15. package/dist/adapters/raw-evidence-transcript-body.js +9 -1
  16. package/dist/adapters/raw-evidence-transcript-file.js +46 -4
  17. package/dist/adapters/raw-evidence-transcript-stream.js +36 -1
  18. package/dist/adapters/raw-evidence.js +13 -0
  19. package/dist/adapters/t3-projection-source.js +96 -0
  20. package/dist/collection-scope.js +2 -0
  21. package/dist/commands/backfill-checkpoint.js +3 -1
  22. package/dist/commands/backfill-history-scan.js +38 -0
  23. package/dist/commands/backfill-issues.js +13 -20
  24. package/dist/commands/backfill-output.js +6 -0
  25. package/dist/commands/backfill-report.js +3 -3
  26. package/dist/commands/backfill-scan.js +20 -40
  27. package/dist/commands/backfill-scope.js +2 -0
  28. package/dist/commands/backfill-upload.js +4 -2
  29. package/dist/commands/backfill.js +20 -3
  30. package/dist/commands/codex-revision-facts.js +64 -0
  31. package/dist/commands/collection-report.js +6 -0
  32. package/dist/commands/local-args-collector-status.js +2 -2
  33. package/dist/commands/local-args-collector-work.js +2 -2
  34. package/dist/commands/local-args-notes-targets.js +35 -0
  35. package/dist/commands/local-args-tower-notes.js +7 -18
  36. package/dist/commands/local-help-commands-tower.js +2 -2
  37. package/dist/commands/local-help-notes.js +2 -1
  38. package/dist/commands/local-help.js +3 -3
  39. package/dist/commands/notes-writes.js +24 -0
  40. package/dist/commands/notes.js +3 -2
  41. package/dist/commands/opencode-sync.js +163 -0
  42. package/dist/commands/public-root.js +1 -1
  43. package/dist/commands/session-sync-failures.js +4 -0
  44. package/dist/commands/session-sync-record.js +3 -1
  45. package/dist/commands/session-sync-scan.js +14 -6
  46. package/dist/commands/session-sync-shelf.js +2 -0
  47. package/dist/commands/session-sync-upload.js +3 -1
  48. package/dist/commands/session-sync.js +18 -1
  49. package/dist/commands/sessions.js +27 -8
  50. package/dist/commands/sources-check.js +54 -47
  51. package/dist/commands/sources-discovery.js +94 -14
  52. package/dist/commands/sources-reconciliation.js +7 -5
  53. package/dist/commands/sources-verdict.js +5 -2
  54. package/dist/cursors/raw-evidence-cursor.js +1 -0
  55. package/dist/image-description-session.js +4 -2
  56. package/dist/local-state-status.js +18 -1
  57. package/dist/native-session-roots.js +31 -0
  58. package/dist/opencode-inventory.js +203 -0
  59. package/dist/opencode-policy.js +29 -0
  60. package/dist/slim/slim-transcript.js +3 -2
  61. package/dist/source-opt-out.js +2 -1
  62. package/dist/spool/local-spool-parse.js +1 -1
  63. package/dist/state-dir-audit.js +2 -0
  64. package/dist/sync-health-class.js +2 -0
  65. package/dist/t3-native-homes.js +64 -0
  66. package/dist/upload-envelope-build.js +1 -0
  67. package/dist/upload-evidence-delivery-summary.js +10 -0
  68. package/dist/upload-session-reports-wire.js +15 -8
  69. package/package.json +4 -4
@@ -61,6 +61,10 @@ import { DEFAULT_SESSION_LIMIT, DEFAULT_SINCE_MINUTES, newStagingDir, openCollec
61
61
  import { finishWithEmptyPack, finishWithFailedPack, finishWithPromotedPack, } from "./raw-evidence-facts.js";
62
62
  import { collectCodexJsonlFiles } from "./raw-evidence-codex-reader.js";
63
63
  import { collectClaudeJsonlFiles } from "./raw-evidence-claude-reader.js";
64
+ import { collectOneEvidenceFile } from "./raw-evidence-transcript-file.js";
65
+ import { recordScanned } from "./raw-evidence-completeness.js";
66
+ import { safeKeySegment } from "./raw-evidence-keys.js";
67
+ import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
64
68
  import { collectGitDiffFiles } from "./raw-evidence-git-diff-reader.js";
65
69
  // Re-exported so every consumer keeps importing from `adapters/raw-evidence`.
66
70
  export { RAW_EVIDENCE_BUCKET, RAW_EVIDENCE_RETENTION_MODE, } from "./raw-evidence-manifest.js";
@@ -140,6 +144,15 @@ async function collectEverySource(collection, options, sinceMinutes) {
140
144
  if (options.includeClaudeJsonl !== false && options.claudeSessionFiles) {
141
145
  await collectClaudeJsonlFiles(collection, options.claudeSessionFiles);
142
146
  }
147
+ for (const file of options.nativeSessionFiles ?? []) {
148
+ recordScanned(collection, "opencode_jsonl");
149
+ await collectOneEvidenceFile(collection, {
150
+ filePath: file.local_path, kind: "opencode_jsonl", sessionId: file.session_id, priorRedaction: file.redaction,
151
+ mediaType: "application/jsonl", maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
152
+ redactedSummary: "OpenCode native session with retained messages, tools, usage and unknown records.",
153
+ contentAddress: (hash16) => `opencode/${safeKeySegment(file.session_id)}/${hash16}.jsonl`,
154
+ });
155
+ }
143
156
  await collectGitDiffFiles(collection, options.repoRoot);
144
157
  }
145
158
  // ---------------------------------------------------------------------------
@@ -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)); }
@@ -146,6 +146,8 @@ export function cwdInsideRoots(cwd, roots) {
146
146
  * it cannot be proven inside, so it is withheld.
147
147
  */
148
148
  export function sessionInsideRoots(result, roots) {
149
+ if (result.local_cwds)
150
+ return result.local_cwds.length > 0 && result.local_cwds.every(cwd => cwdInsideRoots(cwd, roots) || Boolean(result.worktree && cwdInsideRoots(cwd, [result.worktree.repo_root])));
149
151
  if (cwdInsideRoots(result.local_cwd, roots))
150
152
  return true;
151
153
  return Boolean(result.worktree && cwdInsideRoots(result.local_cwd, [result.worktree.repo_root]));
@@ -37,7 +37,8 @@ export async function recordBackfillDurableSessionPointers(options) {
37
37
  // delivery, so a real pointer replaces it and the mark clears.
38
38
  // BLI-11119: an entry the cursor itself knows is `stubbed` counts too.
39
39
  const replacesStub = Boolean(prior?.stub_reoffer || prior?.stubbed) && prior?.uploaded_object_key !== objectKey;
40
- if (!objectKey || !prior || (prior.uploaded_object_key && !replacesStub))
40
+ const replacesRevision = candidate.source === "codex" && prior?.uploaded_source_revision !== candidate.content_hash_sha256;
41
+ if (!objectKey || (prior?.uploaded_object_key && !replacesStub && !replacesRevision))
41
42
  continue;
42
43
  if (replacesStub && !isRedactionStubObjectKey(objectKey))
43
44
  stubsReplaced += 1;
@@ -61,6 +62,7 @@ function deliveredCursorEntry(prior, candidate, objectKey, now) {
61
62
  const entry = {
62
63
  ...prior,
63
64
  file_hash_sha256: candidate.content_hash_sha256,
65
+ uploaded_source_revision: candidate.t3_projection_incomplete || isRedactionStubObjectKey(objectKey) ? prior?.uploaded_source_revision ?? null : candidate.content_hash_sha256,
64
66
  file_mtime_ms: candidate.session_file_mtime_ms,
65
67
  byte_size: candidate.byte_size,
66
68
  // The whole file was delivered, so the incremental reader starts at its end.
@@ -0,0 +1,38 @@
1
+ /** Keep complete-history rescans separate from candidate selection and cursor policy. */
2
+ import { scanAndAttributeCodexSessions } from "../adapters/codex-attribution.js";
3
+ import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
4
+ /**
5
+ * The adapter's own limit is a memory guard, not permission to silently leave
6
+ * history undiscovered. When it fires, re-scan the exact discovered population
7
+ * so a user-facing `--max-files` cap can resume from a truthful ordering.
8
+ */
9
+ export async function scanCodexHistory(request, sessionsDirs, scanLimit) {
10
+ const scan = (limit) => scanAndAttributeCodexSessions({
11
+ sessionsDirs,
12
+ worktrees: request.worktrees,
13
+ now: request.now,
14
+ sinceMinutes: request.window.since_minutes,
15
+ limit,
16
+ collectionRoots: request.collectionRoots,
17
+ });
18
+ const firstPass = await scan(scanLimit);
19
+ if (!firstPass.session_limit_applied)
20
+ return firstPass;
21
+ return scan(Math.max(scanLimit + 1, firstPass.discovered_file_count));
22
+ }
23
+ /** Same rule for the Claude store: a capped first pass is rescanned in full. */
24
+ export async function scanClaudeHistory(request, projectsDirs, scanLimit) {
25
+ const scan = (limit) => scanAndAttributeClaudeSessions({
26
+ projectsDir: projectsDirs[0],
27
+ projectsDirs,
28
+ worktrees: request.worktrees,
29
+ now: request.now,
30
+ sinceMinutes: request.window.since_minutes,
31
+ limit,
32
+ collectionRoots: request.collectionRoots,
33
+ });
34
+ const firstPass = await scan(scanLimit);
35
+ if (!firstPass.session_limit_applied)
36
+ return firstPass;
37
+ return scan(Math.max(scanLimit + 1, firstPass.discovered_session_count));
38
+ }
@@ -29,36 +29,38 @@ import { candidateCursorKey } from "./backfill-candidates.js";
29
29
  export async function backfillScanIssues(options) {
30
30
  const issues = [];
31
31
  if (options.codexAttribution) {
32
- await addCodexStoreScanIssues(issues, options.codexAttribution, options.codexSessionDirs);
32
+ await addCodexStoreScanIssues(issues, options.codexAttribution);
33
33
  }
34
34
  if (options.claudeAttribution) {
35
- await addClaudeStoreScanIssues(issues, options.claudeAttribution, options.claudeProjectsDir, options.candidates);
35
+ await addClaudeStoreScanIssues(issues, options.claudeAttribution, options.candidates);
36
36
  }
37
37
  addCandidateScanIssues(issues, options.candidates, options.omittedCandidateCount);
38
38
  sortScanIssuesByPriority(issues);
39
39
  return issues;
40
40
  }
41
41
  /** What the archived Codex store could not tell us — every one of these hides history. */
42
- async function addCodexStoreScanIssues(issues, codexAttribution, codexSessionDirs) {
42
+ async function addCodexStoreScanIssues(issues, codexAttribution) {
43
43
  countScanIssue(issues, "codex_session_limit_applied", codexAttribution.session_limit_applied
44
44
  ? Math.max(1, codexAttribution.discovered_file_count -
45
45
  codexAttribution.scanned_file_count)
46
46
  : 0, "global");
47
- // A session directory that simply does not exist on this machine is not a
48
- // read failure; subtract those before reporting one.
49
- const missingTopLevelDirs = (await Promise.all(codexSessionDirs.map(isMissingPath))).filter(Boolean).length;
50
- countScanIssue(issues, "codex_directory_read_failed", Math.max(0, codexAttribution.directory_read_failed_count - missingTopLevelDirs), "global");
47
+ // The reader already excludes ENOENT. Subtracting absent default roots
48
+ // here would erase a failure in another configured home or T3 discovery.
49
+ countScanIssue(issues, "codex_directory_read_failed", codexAttribution.directory_read_failed_count, "global");
51
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");
52
56
  }
53
57
  /** The same census for the Claude store, which also has sidecars to account for. */
54
- async function addClaudeStoreScanIssues(issues, claudeAttribution, claudeProjectsDir, candidates) {
58
+ async function addClaudeStoreScanIssues(issues, claudeAttribution, candidates) {
55
59
  countScanIssue(issues, "claude_session_limit_applied", claudeAttribution.session_limit_applied
56
60
  ? Math.max(1, claudeAttribution.discovered_session_count -
57
61
  claudeAttribution.scanned_session_count)
58
62
  : 0, "global");
59
- const projectsDirMissing = await isMissingPath(claudeProjectsDir);
60
- countScanIssue(issues, "claude_project_dir_read_failed", Math.max(0, claudeAttribution.project_dir_read_failed_count -
61
- (projectsDirMissing ? 1 : 0)), "global");
63
+ countScanIssue(issues, "claude_project_dir_read_failed", claudeAttribution.project_dir_read_failed_count, "global");
62
64
  countScanIssue(issues, "claude_session_stat_failed", claudeAttribution.session_stat_failed_count, "global");
63
65
  countScanIssue(issues, "claude_sidecar_stat_failed", claudeAttribution.sidecar_stat_failed_count, "global");
64
66
  countScanIssue(issues, "claude_sidecar_dir_read_failed", await countUnreadableClaudeSidecarDirs(candidates), "global");
@@ -151,15 +153,6 @@ async function countUnreadableClaudeSidecarDirs(candidates) {
151
153
  }
152
154
  return unreadable;
153
155
  }
154
- async function isMissingPath(filePath) {
155
- try {
156
- await fs.stat(filePath);
157
- return false;
158
- }
159
- catch (error) {
160
- return isMissingFsError(error);
161
- }
162
- }
163
156
  function isMissingFsError(error) {
164
157
  if (!error || typeof error !== "object")
165
158
  return false;
@@ -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);
@@ -1,3 +1,5 @@
1
+ import { scanCodexHistory, scanClaudeHistory } from "./backfill-history-scan.js";
2
+ import { nativeSessionRoots } from "../native-session-roots.js";
1
3
  /**
2
4
  * SCAN: read both archived session stores and turn them into the candidates
3
5
  * this run will consider, then census what could not be accounted for.
@@ -9,9 +11,6 @@
9
11
  * on what was missed before what merely arrived.
10
12
  */
11
13
  import os from "node:os";
12
- import path from "node:path";
13
- import { scanAndAttributeClaudeSessions, } from "../adapters/claude-attribution.js";
14
- import { defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
15
14
  import { emptyBackfillCursorState, prepareBackfillCursorForScope, readBackfillCursor, } from "../cursors/backfill-cursor.js";
16
15
  import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
17
16
  import { liveWindowMsFor, scopeBackfillCandidates } from "./backfill-live-window.js";
@@ -136,10 +135,15 @@ async function auditScannedCandidates(scan, discoveryIncompleteReasons) {
136
135
  */
137
136
  async function scanBackfillSessions(options) {
138
137
  const scanLimit = options.command.maxFiles ?? 10_000;
139
- const codexSessionDirs = defaultCodexSessionDirs(options.homeDir);
140
- const claudeProjectsDir = path.join(options.homeDir, ".claude", "projects");
138
+ const { codex: codexSessionDirs, claudeProjects: claudeProjectsDir, claudeProjectDirs, discoveryFailed } = nativeSessionRoots(options.homeDir);
141
139
  const codexScanned = options.sources.includes("codex") ? await scanCodexHistory(options, codexSessionDirs, scanLimit) : null;
142
- const claudeScanned = options.sources.includes("claude_code") ? await scanClaudeHistory(options, claudeProjectsDir, scanLimit) : null;
140
+ const claudeScanned = options.sources.includes("claude_code") ? await scanClaudeHistory(options, claudeProjectDirs, scanLimit) : null;
141
+ if (discoveryFailed) {
142
+ if (codexScanned)
143
+ codexScanned.directory_read_failed_count += 1;
144
+ if (claudeScanned)
145
+ claudeScanned.project_dir_read_failed_count += 1;
146
+ }
143
147
  // BLI-23590 withholds sessions outside the approved roots first; BLI-23478 then keeps drawer sessions home.
144
148
  const scoped = scopeHistoryScans(options, codexScanned, claudeScanned);
145
149
  const { codexAttribution, claudeAttribution } = shelveBackfillScan(scoped.codex, scoped.claude, options.shelfDecision);
@@ -155,6 +159,13 @@ async function scanBackfillSessions(options) {
155
159
  sinceMinutes: options.window.since_minutes,
156
160
  });
157
161
  const liveWindowMs = liveWindowMsFor(options.command);
162
+ const durableCodex = await readRawEvidenceCursor(getCollectorRuntimePaths(options.homeDir));
163
+ for (const c of scanned) {
164
+ const prior = durableCodex.sessions[c.session_id];
165
+ if (c.source === "codex" && (c.segment_paths || prior?.uploaded_source_revision) &&
166
+ prior?.uploaded_source_revision !== c.content_hash_sha256)
167
+ options.pointerlessTerminalSessions.codex.add(c.session_id);
168
+ }
158
169
  const selection = selectBackfillCandidates({
159
170
  scanned,
160
171
  cursor: options.cursor,
@@ -188,40 +199,6 @@ async function scanBackfillSessions(options) {
188
199
  live_window_ms: liveWindowMs,
189
200
  };
190
201
  }
191
- /**
192
- * The adapter's own limit is a memory guard, not permission to silently leave
193
- * history undiscovered. When it fires, re-scan the exact discovered population
194
- * so a user-facing `--max-files` cap can resume from a truthful ordering.
195
- */
196
- async function scanCodexHistory(request, sessionsDirs, scanLimit) {
197
- const scan = (limit) => scanAndAttributeCodexSessions({
198
- sessionsDirs,
199
- worktrees: request.worktrees,
200
- now: request.now,
201
- sinceMinutes: request.window.since_minutes,
202
- limit,
203
- collectionRoots: request.collectionRoots,
204
- });
205
- const firstPass = await scan(scanLimit);
206
- if (!firstPass.session_limit_applied)
207
- return firstPass;
208
- return scan(Math.max(scanLimit + 1, firstPass.discovered_file_count));
209
- }
210
- /** Same rule for the Claude store: a capped first pass is rescanned in full. */
211
- async function scanClaudeHistory(request, projectsDir, scanLimit) {
212
- const scan = (limit) => scanAndAttributeClaudeSessions({
213
- projectsDir,
214
- worktrees: request.worktrees,
215
- now: request.now,
216
- sinceMinutes: request.window.since_minutes,
217
- limit,
218
- collectionRoots: request.collectionRoots,
219
- });
220
- const firstPass = await scan(scanLimit);
221
- if (!firstPass.session_limit_applied)
222
- return firstPass;
223
- return scan(Math.max(scanLimit + 1, firstPass.discovered_session_count));
224
- }
225
202
  /**
226
203
  * Which scanned sessions this run will actually work on. A session is in play
227
204
  * if the cursor has not passed it, or if it is a terminal session that never
@@ -291,6 +268,9 @@ function normalizeCodexCandidate(result) {
291
268
  return {
292
269
  source: "codex",
293
270
  session_id: result.codex_session_id,
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 } : {}),
294
274
  session_id_source: result.session_id_source,
295
275
  file_path: result.file_path,
296
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,8 @@ 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 } : {}),
235
+ ...(candidate.segment_paths ? { segment_paths: candidate.segment_paths, revision_hash: candidate.content_hash_sha256 ?? undefined, revision_bytes: candidate.byte_size } : {}),
234
236
  })),
235
237
  codexAttributionScan: options.codexAttribution ?? undefined,
236
238
  claudeSessionFiles: options.batch.candidates
@@ -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();
@@ -0,0 +1,64 @@
1
+ /** Compare a composite revision with the SAME line/slim/redaction rules as upload. No raw bundle or network. */
2
+ import { createHash } from "node:crypto";
3
+ import { findEmbeddedImages, modeDropsImages, replaceImagesWithMarkers } from "@bli-cockpit/telemetry-core";
4
+ import { imageStateKey } from "../image-description-state.js";
5
+ import { openCodexSessionRevision } from "../adapters/codex-session-revision.js";
6
+ import { createSlimmer } from "../slim/slim-transcript.js";
7
+ import { scanLines, decideLinePlan, writeLinePlan } from "../adapters/raw-evidence-sanitize-lines.js";
8
+ export function codexRevisionFacts(paths, mode, describedKeys, slimAware, t3Projection = false) {
9
+ const form = async (drop) => {
10
+ const source = await openCodexSessionRevision(paths, { t3Projection });
11
+ try {
12
+ const lines = { async *lines() {
13
+ const slimmer = createSlimmer("codex");
14
+ for await (const line of source.lines()) {
15
+ if (line.start === source.length)
16
+ continue;
17
+ let text = new TextDecoder("utf-8", { fatal: true }).decode(line.bytes);
18
+ if (drop) {
19
+ const images = findEmbeddedImages(text);
20
+ text = replaceImagesWithMarkers(text, images, image => describedKeys.has(imageStateKey(image.sha256))).text;
21
+ text = slimmer.slimLine(text);
22
+ }
23
+ yield text + "\n";
24
+ }
25
+ } };
26
+ const options = { redactedFields: ["codex_jsonl.body"], secretLikeFileName: false };
27
+ const plan = decideLinePlan(await scanLines(lines, options), options);
28
+ if (plan.kind === "whole_mask" || plan.kind === "guard_line")
29
+ return null;
30
+ const hash = createHash("sha256");
31
+ let byte_size = 0;
32
+ const write = async (text) => { hash.update(text); byte_size += Buffer.byteLength(text); };
33
+ if (plan.kind === "clean") {
34
+ for await (const line of lines.lines())
35
+ await write(line);
36
+ }
37
+ else
38
+ await writeLinePlan(lines, plan, options, write);
39
+ await source.verifyUnchanged();
40
+ return { sha256: hash.digest("hex"), byte_size };
41
+ }
42
+ finally {
43
+ await source.close();
44
+ }
45
+ };
46
+ let full, slim;
47
+ return {
48
+ slimAware,
49
+ async rawSha256() { const source = await openCodexSessionRevision(paths, { t3Projection }); try {
50
+ return source.sha256;
51
+ }
52
+ finally {
53
+ await source.close();
54
+ } },
55
+ wholeForm: () => full ??= form(false),
56
+ sanitized: () => modeDropsImages(mode) ? slim ??= form(true) : full ??= form(false),
57
+ async slimSplit() {
58
+ if (!slimAware)
59
+ return null;
60
+ const [a, b] = await Promise.all([full ??= form(false), slim ??= form(true)]);
61
+ return a && b && a.sha256 !== b.sha256 ? { full: a, slim: b } : null;
62
+ },
63
+ };
64
+ }
@@ -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");
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The `--to` readers of `cockpit notes`: `move --to "<shelf>"` and, since BLI-24437,
3
+ * `correct <id> --from "<misheard>" --to "<right>" [--transcript]`. Sibling of
4
+ * `local-args-tower-notes.ts`, which calls both; split out so that file stays a table of contents.
5
+ */
6
+ import { optionalNonEmpty } from "./local-arg-values.js";
7
+ export function readNotesShelfTarget(action, values) {
8
+ const to = optionalNonEmpty(values.flags.get("--to"));
9
+ const clearShelf = values.booleans.has("--clear-shelf");
10
+ if (action !== "move")
11
+ return { to, clearShelf };
12
+ if (to && clearShelf) {
13
+ throw new Error("notes move accepts either --to or --clear-shelf, not both.");
14
+ }
15
+ if (!to && !clearShelf && !values.flags.get("--folder")) {
16
+ throw new Error('notes move needs --to "<shelf>", --clear-shelf, or --folder "<path>".');
17
+ }
18
+ return { to, clearShelf };
19
+ }
20
+ /** BLI-24437: `correct` needs both terms; `--from` and `--transcript` mean nothing on any other verb. */
21
+ export function readNotesCorrection(action, values) {
22
+ const from = optionalNonEmpty(values.flags.get("--from"));
23
+ const transcript = values.booleans.has("--transcript");
24
+ if (action !== "correct") {
25
+ if (from !== undefined)
26
+ throw new Error("--from belongs to `cockpit notes correct <note-id> --from <term> --to <term>`.");
27
+ if (transcript)
28
+ throw new Error("--transcript belongs to `cockpit notes correct <note-id> --from <term> --to <term> --transcript`.");
29
+ return {};
30
+ }
31
+ if (!from || !optionalNonEmpty(values.flags.get("--to"))) {
32
+ throw new Error('notes correct needs --from "<misheard term>" and --to "<right term>".');
33
+ }
34
+ return { from, ...(transcript ? { transcript: true } : {}) };
35
+ }