@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.
- package/dist/adapters/claude-attribution-discovery.js +22 -0
- package/dist/adapters/claude-attribution-memoized-reads.js +3 -3
- package/dist/adapters/claude-attribution.js +5 -2
- package/dist/adapters/codex-attribution-memoized-reads.js +3 -3
- package/dist/adapters/codex-attribution.js +51 -12
- package/dist/adapters/codex-segment-discovery.js +75 -0
- package/dist/adapters/codex-session-revision.js +194 -0
- package/dist/adapters/codex-session-signals.js +2 -2
- package/dist/adapters/local-sources.js +1 -0
- package/dist/adapters/opencode-store.js +201 -0
- package/dist/adapters/pi-session-facts.js +28 -0
- package/dist/adapters/pi-session-reader.js +132 -0
- package/dist/adapters/raw-evidence-attribution-gaps.js +5 -0
- package/dist/adapters/raw-evidence-codex-reader.js +39 -7
- package/dist/adapters/raw-evidence-transcript-body.js +9 -1
- package/dist/adapters/raw-evidence-transcript-file.js +46 -4
- package/dist/adapters/raw-evidence-transcript-stream.js +36 -1
- package/dist/adapters/raw-evidence.js +13 -0
- package/dist/adapters/t3-projection-source.js +96 -0
- package/dist/collection-scope.js +2 -0
- package/dist/commands/backfill-checkpoint.js +3 -1
- package/dist/commands/backfill-history-scan.js +38 -0
- package/dist/commands/backfill-issues.js +13 -20
- package/dist/commands/backfill-output.js +6 -0
- package/dist/commands/backfill-report.js +3 -3
- package/dist/commands/backfill-scan.js +20 -40
- package/dist/commands/backfill-scope.js +2 -0
- package/dist/commands/backfill-upload.js +4 -2
- package/dist/commands/backfill.js +20 -3
- package/dist/commands/codex-revision-facts.js +64 -0
- 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-args-notes-targets.js +35 -0
- package/dist/commands/local-args-tower-notes.js +7 -18
- package/dist/commands/local-help-commands-tower.js +2 -2
- package/dist/commands/local-help-notes.js +2 -1
- package/dist/commands/local-help.js +3 -3
- package/dist/commands/notes-writes.js +24 -0
- package/dist/commands/notes.js +3 -2
- 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 +3 -1
- package/dist/commands/session-sync-scan.js +14 -6
- package/dist/commands/session-sync-shelf.js +2 -0
- package/dist/commands/session-sync-upload.js +3 -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 +94 -14
- package/dist/commands/sources-reconciliation.js +7 -5
- package/dist/commands/sources-verdict.js +5 -2
- package/dist/cursors/raw-evidence-cursor.js +1 -0
- package/dist/image-description-session.js +4 -2
- package/dist/local-state-status.js +18 -1
- package/dist/native-session-roots.js +31 -0
- 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/t3-native-homes.js +64 -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
|
@@ -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)); }
|
package/dist/collection-scope.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
32
|
+
await addCodexStoreScanIssues(issues, options.codexAttribution);
|
|
33
33
|
}
|
|
34
34
|
if (options.claudeAttribution) {
|
|
35
|
-
await addClaudeStoreScanIssues(issues, options.claudeAttribution, options.
|
|
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
|
|
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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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
|
|
148
|
-
|
|
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 '
|
|
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 '
|
|
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
|
+
}
|