@bli-cockpit/cli 0.2.246 → 0.2.248
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/commands/backfill-scope.js +17 -8
- package/dist/commands/docs-notion.js +9 -1
- package/dist/commands/docs-sync-args.js +30 -1
- package/dist/commands/docs-sync.js +28 -0
- package/dist/commands/local-discovery.js +26 -4
- package/dist/commands/local-help-commands-tower.js +1 -1
- package/dist/commands/local-help-docs-sync.js +1 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/roots.js +2 -1
- package/dist/commands/session-sync-outside-roots.js +6 -3
- package/dist/commands/session-sync-plan.js +13 -2
- package/dist/commands/session-sync-record.js +3 -0
- package/dist/commands/session-sync-scan.js +30 -8
- package/dist/commands/sources-check.js +9 -3
- package/dist/commands/sources-discovery.js +1 -1
- package/dist/commands/sync-stub-heal.js +29 -1
- package/dist/local-state-config.js +21 -7
- package/dist/local-state-pairing.js +3 -2
- package/dist/source-opt-out.js +33 -0
- package/dist/upload-envelope-build.js +11 -4
- package/package.json +4 -4
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* which stores and how far back was it asked for, and can it reach the
|
|
5
5
|
* dashboard at all. Nothing here opens a transcript.
|
|
6
6
|
*/
|
|
7
|
+
import { resolveSourceSwitches } from "../source-opt-out.js";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import { readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, } from "../local-state.js";
|
|
9
10
|
import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
|
|
@@ -44,22 +45,30 @@ export async function resolveBackfillScope(command, io, now, paths) {
|
|
|
44
45
|
collectionRoots,
|
|
45
46
|
worktreeDiscovery,
|
|
46
47
|
retryCommand: backfillDiscoveryRetryCommand(command, worktreeDiscovery),
|
|
47
|
-
sources:
|
|
48
|
+
sources: honourSourceOptOuts(selectedSources(command.source), config),
|
|
48
49
|
window,
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
52
|
/**
|
|
52
53
|
* Session-first (BLI-11091): now that outside-root sessions upload, an
|
|
53
54
|
* explicit operator opt-out is the only thing that withholds one. The
|
|
54
|
-
* per-machine
|
|
55
|
-
*
|
|
56
|
-
*
|
|
55
|
+
* per-machine opt-outs the scheduled sync already honours
|
|
56
|
+
* (`session-sync-plan.ts`) hold here too: `collect_claude_jsonl: false`,
|
|
57
|
+
* `collect_codex_jsonl: false` and `raw_evidence_upload: "disabled"` (both).
|
|
58
|
+
* Backfill never reads the store of a source the machine said no to, and logs
|
|
59
|
+
* the reason label (ticket 22557).
|
|
57
60
|
*/
|
|
58
|
-
function
|
|
59
|
-
|
|
61
|
+
function honourSourceOptOuts(sources, config) {
|
|
62
|
+
const switches = resolveSourceSwitches(config);
|
|
63
|
+
const kept = sources.filter((source) => (source === "codex" ? switches.codex : switches.claude));
|
|
64
|
+
if (kept.length === sources.length)
|
|
60
65
|
return sources;
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
for (const source of sources) {
|
|
67
|
+
if (kept.includes(source))
|
|
68
|
+
continue;
|
|
69
|
+
console.error(`[backfill] ${source === "codex" ? "codex" : "claude"} source withheld by operator opt-out`, JSON.stringify({ reason: source === "codex" ? switches.codexReason : switches.claudeReason, sources_before: sources.length }));
|
|
70
|
+
}
|
|
71
|
+
return kept;
|
|
63
72
|
}
|
|
64
73
|
function backfillCollectionRoots(command, savedRoots) {
|
|
65
74
|
if (command.repoRoot)
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
17
17
|
import { writeLine } from "./cli-io.js";
|
|
18
18
|
import { resolveDocId } from "./docs.js";
|
|
19
|
-
import { printSyncDetail } from "./docs-sync.js";
|
|
19
|
+
import { printSyncDetail, syncTriage } from "./docs-sync.js";
|
|
20
20
|
const TAG = "[docs cli]";
|
|
21
21
|
const READ_DEADLINE_MS = 30_000;
|
|
22
22
|
const WRITE_DEADLINE_MS = 60_000;
|
|
@@ -32,6 +32,9 @@ function runLine(direction, run) {
|
|
|
32
32
|
* never claims anything about Notion it has not read.
|
|
33
33
|
*/
|
|
34
34
|
export async function syncStatusDocs(command, door) {
|
|
35
|
+
// ticket 22178: `--owner|--decision|--ticket` turns the read into a label write on the same verb.
|
|
36
|
+
if (command.docsSync?.triage)
|
|
37
|
+
return syncTriage(command, door);
|
|
35
38
|
let path = "/api/docs/notion-sync";
|
|
36
39
|
if (command.docRef) {
|
|
37
40
|
const resolved = await resolveDocId(door, command.docRef);
|
|
@@ -61,6 +64,11 @@ export async function syncStatusDocs(command, door) {
|
|
|
61
64
|
const documents = body.documents ?? [];
|
|
62
65
|
for (const doc of documents) {
|
|
63
66
|
writeLine(door.io.stdout, `${doc.id} ${doc.state.padEnd(13)} ${(doc.notion_synced_at ?? "never").padEnd(25)} ${doc.slug ?? "(no slug)"} ${doc.title}`);
|
|
67
|
+
// ticket 22178: a document that is not synced says why, who owns it and what was decided.
|
|
68
|
+
if (doc.state !== "synced") {
|
|
69
|
+
const owner = doc.owner ? doc.owner.name ?? doc.owner.id : "unowned";
|
|
70
|
+
writeLine(door.io.stdout, ` why: ${doc.stuck_reason ?? "not_recorded_yet"} · owner: ${owner} · decision: ${doc.decision ?? "none"} · ticket: ${doc.ticket ?? "none"}`);
|
|
71
|
+
}
|
|
64
72
|
}
|
|
65
73
|
if (body.coverage)
|
|
66
74
|
writeLine(door.io.stdout, body.coverage);
|
|
@@ -8,10 +8,15 @@
|
|
|
8
8
|
* The door checks authority and the revision; this file only checks shape. It never picks a choice for the person.
|
|
9
9
|
*/
|
|
10
10
|
import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
11
|
-
const VALUE_FLAGS = ["--home", "--dashboard-url", "--version", "--expected-revision", "--request-id", "--under", "--cursor", "--out"];
|
|
11
|
+
const VALUE_FLAGS = ["--home", "--dashboard-url", "--version", "--expected-revision", "--request-id", "--under", "--cursor", "--out", "--owner", "--decision", "--ticket"];
|
|
12
12
|
const BOOLEAN_FLAGS = ["--json", "--keep-current", "--use-kept", "--all"];
|
|
13
13
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
14
|
+
/** ticket 22178: `sync-status <page> --owner|--decision|--ticket` writes the triage labels; without them it reads. */
|
|
15
|
+
const TRIAGE_FLAGS = ["--owner", "--decision", "--ticket"];
|
|
16
|
+
const DECISIONS = ["unlink", "take_notion", "keep_tower", "reshare", "investigate"];
|
|
14
17
|
export function isDocsSyncInvocation(args) {
|
|
18
|
+
if (args[0] === "sync-status")
|
|
19
|
+
return args.some((arg) => TRIAGE_FLAGS.includes(arg) || TRIAGE_FLAGS.some((flag) => arg.startsWith(`${flag}=`)));
|
|
15
20
|
return args[0] === "sync-resolve" || args[0] === "manifest";
|
|
16
21
|
}
|
|
17
22
|
export function parseDocsSyncArgs(args) {
|
|
@@ -33,6 +38,30 @@ export function parseDocsSyncArgs(args) {
|
|
|
33
38
|
throw new Error(`${flag} does not belong to \`cockpit docs ${verb}\`.`);
|
|
34
39
|
}
|
|
35
40
|
};
|
|
41
|
+
if (verb === "sync-status") {
|
|
42
|
+
only(TRIAGE_FLAGS);
|
|
43
|
+
if (rest.length !== 1)
|
|
44
|
+
throw new Error("docs sync-status with --owner, --decision or --ticket needs exactly one page id or slug, e.g. `cockpit docs sync-status runbook --owner me --decision reshare --ticket 22178`.");
|
|
45
|
+
const owner = optionalNonEmpty(values.flags.get("--owner"));
|
|
46
|
+
const decision = optionalNonEmpty(values.flags.get("--decision"));
|
|
47
|
+
const ticket = optionalNonEmpty(values.flags.get("--ticket"));
|
|
48
|
+
const triage = {};
|
|
49
|
+
if (owner !== undefined) {
|
|
50
|
+
const lowered = owner.toLowerCase();
|
|
51
|
+
if (lowered !== "me" && lowered !== "none" && !UUID.test(owner))
|
|
52
|
+
throw new Error("--owner is `me`, a team member's id (a UUID from `cockpit team members`), or `none` to clear it.");
|
|
53
|
+
triage.owner = lowered === "none" ? null : lowered === "me" ? "me" : owner;
|
|
54
|
+
}
|
|
55
|
+
if (decision !== undefined) {
|
|
56
|
+
const lowered = decision.toLowerCase();
|
|
57
|
+
if (lowered !== "none" && !DECISIONS.includes(lowered))
|
|
58
|
+
throw new Error(`--decision is one of ${DECISIONS.join(", ")}, or \`none\` to clear it.`);
|
|
59
|
+
triage.decision = lowered === "none" ? null : lowered;
|
|
60
|
+
}
|
|
61
|
+
if (ticket !== undefined)
|
|
62
|
+
triage.ticket = ticket.toLowerCase() === "none" ? null : ticket;
|
|
63
|
+
return { ...base, action: "sync-status", docRef: rest[0], docsSync: { triage } };
|
|
64
|
+
}
|
|
36
65
|
if (verb === "manifest") {
|
|
37
66
|
only(["--under", "--cursor", "--all", "--out"]);
|
|
38
67
|
if (rest.length !== 0)
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* sync-resolve <page> POST /api/docs/documents/[id]/notion-sync/resolve
|
|
7
7
|
* manifest GET /api/docs/manifest[?cursor=&under=]
|
|
8
8
|
*
|
|
9
|
+
* sync-status <page> --owner --decision --ticket PATCH /api/docs/documents/[id]/notion-sync (ticket 22178)
|
|
10
|
+
*
|
|
9
11
|
* Every line about a receipt, a loss or a conflict is the door's own `sentence`, printed verbatim, so this terminal says
|
|
10
12
|
* what the web panel, the MCP tool and JARVIS say. Tower never asks Notion from a request: the answer is what the last
|
|
11
13
|
* passes recorded, with its age. `manifest` reports readiness; it gives no cutover verdict.
|
|
@@ -37,6 +39,10 @@ export function printSyncDetail(door, ref, body) {
|
|
|
37
39
|
const row = list(body["documents"])[0] ?? {};
|
|
38
40
|
writeLine(door.io.stdout, `${str(row["id"])} ${str(row["state"])} ${str(row["slug"]) || "(no slug)"} ${str(row["title"])}`);
|
|
39
41
|
writeLine(door.io.stdout, str(detail["state_sentence"]));
|
|
42
|
+
if (str(row["state"]) !== "synced") {
|
|
43
|
+
const owner = object(row["owner"]);
|
|
44
|
+
writeLine(door.io.stdout, `Why: ${str(row["stuck_reason"]) || "not_recorded_yet"} · owner: ${str(owner["name"]) || str(owner["id"]) || "unowned"} · decision: ${str(row["decision"]) || "none"} · ticket: ${str(row["ticket"]) || "none"}`);
|
|
45
|
+
}
|
|
40
46
|
const receipts = object(detail["last_receipt"]);
|
|
41
47
|
for (const direction of ["push", "pull"]) {
|
|
42
48
|
const receipt = object(receipts[direction]);
|
|
@@ -63,6 +69,28 @@ export function printSyncDetail(door, ref, body) {
|
|
|
63
69
|
}
|
|
64
70
|
return 0;
|
|
65
71
|
}
|
|
72
|
+
/** ticket 22178: who owns a stuck page, what was decided, which ticket carries it. Labels only; nothing is unlinked or resolved. */
|
|
73
|
+
export async function syncTriage(command, door) {
|
|
74
|
+
const ref = command.docRef ?? "";
|
|
75
|
+
const answer = await askAgentDoor(door, {
|
|
76
|
+
path: `/api/docs/documents/${encodeURIComponent(ref)}/notion-sync`,
|
|
77
|
+
method: "PATCH",
|
|
78
|
+
label: "docs sync-status",
|
|
79
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
80
|
+
body: command.docsSync?.triage ?? {},
|
|
81
|
+
});
|
|
82
|
+
if (!answer.ok)
|
|
83
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
84
|
+
const body = answer.body;
|
|
85
|
+
writeLine(door.io.stderr, `${TAG} triage ${JSON.stringify({ outcome: body["outcome"] ?? null, changed: body["changed"] ?? [] })}`);
|
|
86
|
+
if (door.json)
|
|
87
|
+
return emitAgentDoor(door, { ...body, ok: true });
|
|
88
|
+
const changed = Array.isArray(body["changed"]) ? body["changed"].map(str) : [];
|
|
89
|
+
writeLine(door.io.stdout, str(body["outcome"]) === "unchanged"
|
|
90
|
+
? "Nothing changed: those labels were already set."
|
|
91
|
+
: `Saved ${changed.join(", ")}. Owner ${str(body["owner"]) || "unowned"}, decision ${str(body["decision"]) || "none"}, ticket ${str(body["ticket"]) || "none"}. Nothing was unlinked or resolved.`);
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
66
94
|
async function syncResolve(command, door) {
|
|
67
95
|
const ref = command.docRef ?? "";
|
|
68
96
|
const answer = await askAgentDoor(door, {
|
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding the git worktrees a command should act on, inside the operator's
|
|
3
|
+
* approved collection roots.
|
|
4
|
+
*
|
|
5
|
+
* Two rules live here and both come from BLI-2362: a folder that could not be
|
|
6
|
+
* opened is reported rather than treated as empty, and a capped scan reports
|
|
7
|
+
* partial coverage while collection continues within approved roots. Split out of
|
|
8
|
+
* commands/local.ts (BLI-3104); moved verbatim.
|
|
9
|
+
*
|
|
10
|
+
* A third rule comes from BLI-4467: when no caller passed `--workspace` and the
|
|
11
|
+
* current folder holds no repos at all, a command that ASKS for it
|
|
12
|
+
* (`fallbackToSavedRoots`) scans this machine's saved collection roots instead
|
|
13
|
+
* of refusing. `cockpit status` and `cockpit sessions` ask, because they answer
|
|
14
|
+
* "what does this machine believe about itself" and that cannot depend on which
|
|
15
|
+
* folder the operator was standing in. `cockpit start` deliberately does not:
|
|
16
|
+
* binding a ticket from a scratch folder to every approved root on the machine
|
|
17
|
+
* is far wider than the operator asked for, so it keeps the old refusal.
|
|
18
|
+
*/
|
|
19
|
+
import { resolveSourceSwitches } from "../source-opt-out.js";
|
|
1
20
|
import { writeLine } from "./cli-io.js";
|
|
2
21
|
import { resolveDiscoveryLimits, saveDiscoveryLimits, } from "../discovery-limits.js";
|
|
3
22
|
import { getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
@@ -177,11 +196,14 @@ export async function scanWorkDirectories(homeDir, persist = true) {
|
|
|
177
196
|
return null; throw error; });
|
|
178
197
|
const now = new Date();
|
|
179
198
|
const options = { worktrees: [], collectionRoots: config?.default_repo_paths ?? [], now, sinceMinutes: ROOT_WORK_SCAN_WINDOW_DAYS * 24 * 60, limit: Number.MAX_SAFE_INTEGER };
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
199
|
+
const switches = resolveSourceSwitches(config);
|
|
200
|
+
if (!switches.codex)
|
|
201
|
+
console.error("[discovery] codex scan withheld by operator opt-out", JSON.stringify({ reason: switches.codexReason }));
|
|
202
|
+
const codex = switches.codex ? await scanAndAttributeCodexSessions({ ...options, sessionsDirs: defaultCodexSessionDirs(homeDir) }) : null;
|
|
203
|
+
const claude = !switches.claude ? null : await scanAndAttributeClaudeSessions({ ...options, projectsDir: (await import("node:path")).default.join(homeDir, ".claude", "projects") });
|
|
204
|
+
if (codex?.directory_read_failed_count || codex?.stat_failed_count || claude?.project_dir_read_failed_count || claude?.session_stat_failed_count) {
|
|
183
205
|
throw new Error("work_directory_scan_incomplete");
|
|
184
206
|
}
|
|
185
|
-
const results = [...codex
|
|
207
|
+
const results = [...(codex?.results ?? []), ...(claude?.results ?? [])];
|
|
186
208
|
return persist ? saveRootWorkScan(paths, results, now) : { generated_at: now.toISOString(), observed: results.length, directories: workDirectories(results) };
|
|
187
209
|
}
|
|
@@ -60,7 +60,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
60
60
|
[
|
|
61
61
|
"docs",
|
|
62
62
|
[
|
|
63
|
-
"Usage: cockpit docs [list|search [words]|tree|read <id|slug> [<id|slug>...]|read --parent <id> --bodies|create|update <id>|delete <id|slug> [<id|slug>...]|link <id|slug> <notion-page>|sync-status [<id|slug>]|sync-resolve <id|slug> --version <id> --keep-current|--use-kept|manifest [--under <id|slug>] [--all] [--out <file>]|export <id|slug> [--format md|pdf|docx|txt] [--out <path>]|mutation-status <request-uuid>|share <id|slug> --add|--remove <email>|access <id|slug>|comments <id|slug>|comment <id|slug>|trash|versions <id|slug>|restore <id>|favorite <id|slug>|unfavorite <id|slug>|marks] [flags]",
|
|
63
|
+
"Usage: cockpit docs [list|search [words]|tree|read <id|slug> [<id|slug>...]|read --parent <id> --bodies|create|update <id>|delete <id|slug> [<id|slug>...]|link <id|slug> <notion-page>|sync-status [<id|slug>] [--owner me|<uuid>|none --decision unlink|take_notion|keep_tower|reshare|investigate|none --ticket <id>|none]|sync-resolve <id|slug> --version <id> --keep-current|--use-kept|manifest [--under <id|slug>] [--all] [--out <file>]|export <id|slug> [--format md|pdf|docx|txt] [--out <path>]|mutation-status <request-uuid>|share <id|slug> --add|--remove <email>|access <id|slug>|comments <id|slug>|comment <id|slug>|trash|versions <id|slug>|restore <id>|favorite <id|slug>|unfavorite <id|slug>|marks] [flags]",
|
|
64
64
|
"",
|
|
65
65
|
"The Tower document library, typed. Bare `cockpit docs` lists every document you may read.",
|
|
66
66
|
"list [--parent <id>|root] [--query <text> [--word]] [--limit <n>] — every document you may see: id, visibility, body size, slug, title. Metadata only — a list never carries a body, so it stays cheap enough for an agent to call (--json was 604 KB for 155 documents before BLI-3737, and is now a few tens of KB). --json shape: { documents: [{ id, slug, title, visibility, parent_id, source, updated_at, body_chars }] }.",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* from `local-help-commands-tower.ts` to keep that file under the readability ratchet.
|
|
4
4
|
*/
|
|
5
5
|
export const DOCS_SYNC_HELP = [
|
|
6
|
-
"sync-status <id|slug> — one linked page's Notion sync, from what the last push and pull passes RECORDED (Tower never asks Notion from a request): the state sentence, the newest push and pull receipt each with its reason label and age, what could not be carried (\"1 file kept only as a link\", blocks missing, content Tower cannot write back), the conflicts (versions a sync kept, open or resolved, newest 50 with a total) and the decisions made. A page Notion no longer shares reads \"Notion no longer shares this page\", not synced. --json adds { detail: { can_edit, state_sentence, last_receipt: { push, pull }, losses, conflicts: { total, open, shown, items: [{ version_id, version_number, origin, status, sentence, resolution }] }, resolutions, resolve_note, incomplete, as_of } }.",
|
|
6
|
+
"sync-status <id|slug> — one linked page's Notion sync, from what the last push and pull passes RECORDED (Tower never asks Notion from a request): the state sentence, the newest push and pull receipt each with its reason label and age, what could not be carried (\"1 file kept only as a link\", blocks missing, content Tower cannot write back), the conflicts (versions a sync kept, open or resolved, newest 50 with a total) and the decisions made. A page Notion no longer shares reads \"Notion no longer shares this page\", not synced. A document that is not synced also says WHY (stuck_reason: the reason Notion gave, archived, in_trash, object_not_found, restricted or http_<code>, or the reason the last push refused it, e.g. notion_has_blocks_tower_cannot_write), its owner (or \"unowned\"), the decision and the ticket. Set them with `sync-status <id|slug> --owner me|<team member uuid>|none --decision unlink|take_notion|keep_tower|reshare|investigate|none --ticket <id>|none` (any one or more; labels only, nothing is unlinked or resolved; page editors only). --json adds { detail: { can_edit, state_sentence, last_receipt: { push, pull }, losses, conflicts: { total, open, shown, items: [{ version_id, version_number, origin, status, sentence, resolution }] }, resolutions, resolve_note, incomplete, as_of } }.",
|
|
7
7
|
"sync-resolve <id|slug> --version <id> --keep-current|--use-kept [--expected-revision <n>] [--request-id <uuid>] — decide one conflict; you choose, Tower never picks. --keep-current records the decision and writes no text. --use-kept makes the kept version the page's current text through a version restore (a new history row, revision +1) over your --expected-revision (the page revision you read; stale = revision_conflict, nothing is written or recorded), then records the decision. One decision per kept version: a repeat answers already_resolved with the stored one and writes nothing. Refusals: resolution_write_refused (not a page editor), not_a_sync_conflict (an ordinary version), version_not_found, invalid_resolution. The next push sends the restored text to Notion through the same checks as any push; a page Notion holds content on that Tower cannot write stays protected. --json shape: { ok, outcome: resolved|already_resolved, document_id, resolution: { version_id, choice, resolved_by, resolved_at, resulting_revision }, revision, body_written, note }.",
|
|
8
8
|
"manifest [--under <id|slug>] [--cursor <c>] [--all] [--out <file>] — the library export manifest for a Notion cutover review: readiness facts only, no cutover verdict and no Notion turn-off. Per page you can read (another person's private page never appears): id, slug, title, parent, visibility, author, revision, body_sha256, body_chars, Notion link, editor ids (\"not_visible\" when you may not see them), files (id, name, type, byte size, state; no URLs, no bytes) and no body. 2,000 pages per call with next_cursor; --all walks every call (at most 100) and reports requested, returned, unique and coverage exhausted|capped|failed_partial (a failed call keeps the pages already read and prints the cursor to resume). --out writes the full JSON to a new file (never overwrites). --json shape: { ok, entries, requested, returned, unique, coverage: { status, calls, reason? }, coverage_text, failures: [{ stage, reason, count, document_ids }], next_cursor, under, generated_at }.",
|
|
9
9
|
];
|
|
@@ -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.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.248");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
package/dist/commands/roots.js
CHANGED
|
@@ -5,6 +5,7 @@ import { normalizeRootsDetailed } from "../onboarding-roots.js";
|
|
|
5
5
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
6
6
|
import { recordRootApprovals } from "../root-work.js";
|
|
7
7
|
import { writeJsonFile } from "../local-state-files.js";
|
|
8
|
+
import { configForDisk } from "../source-opt-out.js";
|
|
8
9
|
import { writeLine } from "./cli-io.js";
|
|
9
10
|
export function parseRootsArgs(args) {
|
|
10
11
|
const [action, directory, ...flags] = args;
|
|
@@ -34,7 +35,7 @@ export async function runRoots(command, io) {
|
|
|
34
35
|
const config = (await ensureLocalCollectorConfig({ homeDir })).config;
|
|
35
36
|
const updated = { ...config, default_repo_paths: normalizeCollectionRoots([...config.default_repo_paths, ...roots]) };
|
|
36
37
|
await recordRootApprovals(paths, [...config.default_repo_paths, ...roots]);
|
|
37
|
-
await writeJsonFile(paths.config_file, updated);
|
|
38
|
+
await writeJsonFile(paths.config_file, configForDisk(updated));
|
|
38
39
|
console.error("[roots] added", JSON.stringify({ added: roots.length, roots: updated.default_repo_paths.length }));
|
|
39
40
|
writeLine(io.stdout, command.json ? JSON.stringify({ ok: true, roots: updated.default_repo_paths }) : `Approved roots: ${updated.default_repo_paths.join(", ")}. Run cockpit doctor to collect the work there.`);
|
|
40
41
|
return 0;
|
|
@@ -135,9 +135,12 @@ function isDurableAndUnchanged(result, entry) {
|
|
|
135
135
|
}
|
|
136
136
|
function sightingsOf(scan, plan) {
|
|
137
137
|
const sightings = [];
|
|
138
|
-
for
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
// Same for Codex: an opted-out machine has no Codex results (`session-sync-scan.ts`).
|
|
139
|
+
if (plan.codexEnabled) {
|
|
140
|
+
for (const result of scan.codexAttribution.results) {
|
|
141
|
+
if (isUnclaimedOutsideRootResult(result)) {
|
|
142
|
+
sightings.push({ source: "codex", sessionId: result.codex_session_id, result });
|
|
143
|
+
}
|
|
141
144
|
}
|
|
142
145
|
}
|
|
143
146
|
// A machine that opted out of Claude has no Claude results to begin with
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* no per-file record), {@link RETRY_WINDOW_FALLBACK_MINUTES} is the bound
|
|
19
19
|
* instead — generous, but still a bound, never "since epoch".
|
|
20
20
|
*/
|
|
21
|
+
import { resolveSourceSwitches } from "../source-opt-out.js";
|
|
21
22
|
import { readLocalCollectorConfig, } from "../local-state.js";
|
|
22
23
|
import { CLAUDE_CURSOR_FILENAME, emptyRawEvidenceCursorState, readRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
23
24
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
@@ -45,7 +46,15 @@ export const RETRY_WINDOW_BUFFER_MINUTES = 24 * 60;
|
|
|
45
46
|
*/
|
|
46
47
|
export async function planSyncFromLocalState(options) {
|
|
47
48
|
const config = await readLocalCollectorConfig(options.paths).catch(() => null);
|
|
48
|
-
const
|
|
49
|
+
const switches = resolveSourceSwitches(config);
|
|
50
|
+
const claudeEnabled = switches.claude;
|
|
51
|
+
const codexEnabled = switches.codex;
|
|
52
|
+
if (!codexEnabled) {
|
|
53
|
+
console.error("[session-sync] codex source withheld by operator opt-out", JSON.stringify({ reason: switches.codexReason }));
|
|
54
|
+
}
|
|
55
|
+
if (!claudeEnabled) {
|
|
56
|
+
console.error("[session-sync] claude source withheld by operator opt-out", JSON.stringify({ reason: switches.claudeReason }));
|
|
57
|
+
}
|
|
49
58
|
const collectionRoots = normalizeCollectionRoots(options.approvedCollectionRoots ?? config?.default_repo_paths ?? []);
|
|
50
59
|
const [codexCursorBefore, claudeCursorBefore, uploadSpool, outsideRootCatchUpPending] = await readSyncRetryState(options.paths, claudeEnabled);
|
|
51
60
|
// A spooled upload from a CLI old enough not to have recorded its source
|
|
@@ -55,13 +64,15 @@ export async function planSyncFromLocalState(options) {
|
|
|
55
64
|
const claudeSourceRetryPending = sourceScanRetryIsPending(uploadSpool, "claude_code");
|
|
56
65
|
return {
|
|
57
66
|
claudeEnabled,
|
|
67
|
+
codexEnabled,
|
|
58
68
|
collectionRoots,
|
|
59
69
|
codexCursorBefore,
|
|
60
70
|
claudeCursorBefore,
|
|
61
71
|
uploadSpool,
|
|
62
72
|
codexSourceRetryPending,
|
|
63
73
|
claudeSourceRetryPending,
|
|
64
|
-
codexRetryPending:
|
|
74
|
+
codexRetryPending: codexEnabled &&
|
|
75
|
+
sourceHasPendingRetries("codex", uploadSpool, legacyRetryPending, codexSourceRetryPending, codexCursorBefore),
|
|
65
76
|
claudeRetryPending: claudeEnabled &&
|
|
66
77
|
sourceHasPendingRetries("claude_code", uploadSpool, legacyRetryPending, claudeSourceRetryPending, claudeCursorBefore),
|
|
67
78
|
codexRetrySinceMinutes: boundedRetrySinceMinutes(codexCursorBefore, options.now),
|
|
@@ -145,6 +145,7 @@ async function resolveFallbackDashboardUrl(homeDir, explicit) {
|
|
|
145
145
|
*/
|
|
146
146
|
async function recordBothSourceCursorObservations(options) {
|
|
147
147
|
const codexStaleCount = await recordCodexSessionObservationsForSync({
|
|
148
|
+
codexEnabled: options.plan.codexEnabled,
|
|
148
149
|
paths: options.paths,
|
|
149
150
|
codexAttribution: options.scan.codexAttribution,
|
|
150
151
|
sessions: options.sessions,
|
|
@@ -196,6 +197,8 @@ async function deliverOrExplainSessionReport(options) {
|
|
|
196
197
|
* kept as-is rather than forced to match.
|
|
197
198
|
*/
|
|
198
199
|
async function recordCodexSessionObservationsForSync(options) {
|
|
200
|
+
if (!options.codexEnabled)
|
|
201
|
+
return 0;
|
|
199
202
|
let staleCount = 0;
|
|
200
203
|
try {
|
|
201
204
|
const codexCursor = await readRawEvidenceCursor(options.paths);
|
|
@@ -28,6 +28,7 @@ const CLAUDE_DEFAULT_WINDOW_MINUTES = 24 * 60;
|
|
|
28
28
|
export async function scanAndAttributeBothSources(options) {
|
|
29
29
|
const { plan } = options;
|
|
30
30
|
const codexAttribution = await scanCodexSessionsForSync({
|
|
31
|
+
codexEnabled: plan.codexEnabled,
|
|
31
32
|
homeDir: options.homeDir,
|
|
32
33
|
worktrees: options.worktrees,
|
|
33
34
|
now: options.now,
|
|
@@ -51,13 +52,15 @@ export async function scanAndAttributeBothSources(options) {
|
|
|
51
52
|
firstRunBackfill,
|
|
52
53
|
outsideRootCatchUpPending: plan.outsideRootCatchUpPending,
|
|
53
54
|
});
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
if (plan.codexEnabled) {
|
|
56
|
+
await reconcileSourceScanRetry({
|
|
57
|
+
paths: options.paths,
|
|
58
|
+
source: "codex",
|
|
59
|
+
attemptedAt: options.now.toISOString(),
|
|
60
|
+
pendingBefore: plan.codexSourceRetryPending,
|
|
61
|
+
reason: sourceScanRetryReason("codex", codexAttribution),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
61
64
|
if (plan.claudeEnabled) {
|
|
62
65
|
await reconcileSourceScanRetry({
|
|
63
66
|
paths: options.paths,
|
|
@@ -74,9 +77,13 @@ export async function scanAndAttributeBothSources(options) {
|
|
|
74
77
|
* Scan and attribute Codex sessions for this sync, widening the window to all
|
|
75
78
|
* local history when a retry is pending. Codex has no first-run backfill
|
|
76
79
|
* concept (that is a Claude-only window, see `scanClaudeSessionsForSync`) and
|
|
77
|
-
* is
|
|
80
|
+
* is scanned unless the operator opted Codex out (`collect_codex_jsonl: false`
|
|
81
|
+
* or `raw_evidence_upload: "disabled"`), in which case the scan is empty and the
|
|
82
|
+
* plan already logged the reason.
|
|
78
83
|
*/
|
|
79
84
|
async function scanCodexSessionsForSync(options) {
|
|
85
|
+
if (!options.codexEnabled)
|
|
86
|
+
return emptyCodexScan();
|
|
80
87
|
const sinceMinutes = widenForCatchUp(options.retryPending
|
|
81
88
|
? options.retrySinceMinutes
|
|
82
89
|
: CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, options.outsideRootCatchUpPending, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES);
|
|
@@ -180,6 +187,21 @@ async function reconcileSourceScanRetry(options) {
|
|
|
180
187
|
await clearSourceRetryFailure(options.paths, options.source, options.attemptedAt);
|
|
181
188
|
}
|
|
182
189
|
}
|
|
190
|
+
function emptyCodexScan() {
|
|
191
|
+
return {
|
|
192
|
+
results: [],
|
|
193
|
+
discovered_file_count: 0,
|
|
194
|
+
scanned_file_count: 0,
|
|
195
|
+
since_minutes: 0,
|
|
196
|
+
session_limit: 0,
|
|
197
|
+
session_limit_applied: false,
|
|
198
|
+
max_file_bytes: 0,
|
|
199
|
+
directory_read_failed_count: 0,
|
|
200
|
+
stat_failed_count: 0,
|
|
201
|
+
secret_path_skipped_count: 0,
|
|
202
|
+
counts: { attributed: 0, attributed_fallback: 0, ambiguous: 0, unattributed: 0, skipped: 0 },
|
|
203
|
+
};
|
|
204
|
+
}
|
|
183
205
|
function emptyClaudeScan() {
|
|
184
206
|
return {
|
|
185
207
|
results: [],
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* "not stored". A list cut short (`capped`, `failed_partial`) still answers,
|
|
20
20
|
* marked incomplete: a missing page can only understate what Tower holds.
|
|
21
21
|
*/
|
|
22
|
+
import { resolveSourceSwitches } from "../source-opt-out.js";
|
|
22
23
|
import { fetchSourceInventory, reconcileSource } from "./sources-reconciliation.js";
|
|
23
24
|
import { writeLine } from "./cli-io.js";
|
|
24
25
|
import { readDescribedImageKeys } from "../image-description-state.js";
|
|
@@ -51,9 +52,14 @@ export async function runSourcesCheck(command, io, now = new Date()) {
|
|
|
51
52
|
}
|
|
52
53
|
const homeDir = command.homeDir ?? paths.home_dir;
|
|
53
54
|
const olderThanMs = command.olderThanDays === null ? null : now.getTime() - command.olderThanDays * DAY_MS;
|
|
54
|
-
const
|
|
55
|
-
const optOutClaude = command.reconcile === true &&
|
|
56
|
-
const
|
|
55
|
+
const switches = resolveSourceSwitches(config);
|
|
56
|
+
const optOutClaude = command.reconcile === true && !switches.claude;
|
|
57
|
+
const optOutCodex = command.reconcile === true && !switches.codex;
|
|
58
|
+
const optOutAll = optOutClaude && optOutCodex;
|
|
59
|
+
if (optOutClaude || optOutCodex) {
|
|
60
|
+
console.error("[collector sources] reconcile skips opted-out sources", JSON.stringify({ codex_reason: optOutCodex ? switches.codexReason : null, claude_reason: optOutClaude ? switches.claudeReason : null }));
|
|
61
|
+
}
|
|
62
|
+
const local = await discoverLocalTranscripts({ homeDir, olderThanMs, skipAll: optOutAll, skipClaude: optOutClaude, skipCodex: optOutCodex });
|
|
57
63
|
const cursors = {
|
|
58
64
|
codex: await readRawEvidenceCursor(paths),
|
|
59
65
|
claude_code: await readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME, sessionsOnly: true }),
|
|
@@ -153,7 +153,7 @@ function shapeOrder(shape) {
|
|
|
153
153
|
}
|
|
154
154
|
async function findTranscriptFiles(options, roots, result, recordFailure) {
|
|
155
155
|
const found = [];
|
|
156
|
-
const stack = options.skipAll ? [] : [...roots.codex, ...(options.skipClaude ? [] : [roots.claudeProjects])];
|
|
156
|
+
const stack = options.skipAll ? [] : [...(options.skipCodex ? [] : roots.codex), ...(options.skipClaude ? [] : [roots.claudeProjects])];
|
|
157
157
|
const seen = new Set();
|
|
158
158
|
while (stack.length > 0) {
|
|
159
159
|
const dir = stack.pop();
|
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLI-11112: the scheduled sync heals this machine's own redaction-crash stubs.
|
|
3
|
+
*
|
|
4
|
+
* `cockpit backfill` learned to re-offer a stubbed session (BLI‑11102), but
|
|
5
|
+
* nobody runs backfill on an intern machine, so their stubs stayed stubs. Once
|
|
6
|
+
* per tick, after the tick's own uploads and still under the collection lock,
|
|
7
|
+
* the sync asks the stubs door for this caller's stubs and re-offers up to
|
|
8
|
+
* `SYNC_STUB_SESSIONS_PER_TICK` sessions of them through the SAME backfill
|
|
9
|
+
* code: `runBackfill` with `stubPass` set. Same cursor mark, same redaction,
|
|
10
|
+
* same `stub_replaced` claim, and a claim only for a stub the door named.
|
|
11
|
+
*
|
|
12
|
+
* Cheap in the steady state: one door request, and nothing more unless a named
|
|
13
|
+
* stub belongs to a session this machine's cursor tracks. A stub from another
|
|
14
|
+
* of the operator's machines is not this machine's to heal; it is counted as
|
|
15
|
+
* remaining and skipped without a scan. Never throws and never fails the tick.
|
|
16
|
+
*/
|
|
17
|
+
import { resolveSourceSwitches } from "../source-opt-out.js";
|
|
1
18
|
import { runBackfill } from "./backfill.js";
|
|
2
19
|
import { loadStubReofferPlan } from "./backfill-stub-reoffer.js";
|
|
3
20
|
import { backfillWaitingCheck } from "./sync-yield.js";
|
|
@@ -45,7 +62,18 @@ async function runStubPass(command, io, deps, now, summary) {
|
|
|
45
62
|
const sessionFile = await readLocalCollectorSessionFile(paths).catch(() => null);
|
|
46
63
|
if (!sessionFile)
|
|
47
64
|
return "no_device_session";
|
|
48
|
-
const
|
|
65
|
+
const switches = resolveSourceSwitches(config);
|
|
66
|
+
const sources = [
|
|
67
|
+
...(switches.codex ? ["codex"] : []),
|
|
68
|
+
...(switches.claude ? ["claude_code"] : []),
|
|
69
|
+
];
|
|
70
|
+
if (sources.length === 0) {
|
|
71
|
+
console.error("[collector stubs] no source enabled", JSON.stringify({ reason: switches.codexReason }));
|
|
72
|
+
return "sources_opted_out";
|
|
73
|
+
}
|
|
74
|
+
if (!switches.codex || !switches.claude) {
|
|
75
|
+
console.error("[collector stubs] source withheld by operator opt-out", JSON.stringify({ reason: switches.codexReason ?? switches.claudeReason, sources: sources.length }));
|
|
76
|
+
}
|
|
49
77
|
const dashboardUrl = (sessionFile.dashboard_url ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL)
|
|
50
78
|
.trim()
|
|
51
79
|
.replace(/\/+$/, "");
|
|
@@ -5,6 +5,7 @@ import { readFileSync } from "node:fs";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { normalizeCollectionRoots } from "./root-normalization.js";
|
|
8
|
+
import { configForDisk, resolveSourceSwitches } from "./source-opt-out.js";
|
|
8
9
|
import { ensureRuntimeDirectories, getCollectorRuntimePaths, } from "./local-state-paths.js";
|
|
9
10
|
import { isMissingFileError, readJsonFile, writeJsonFile, } from "./local-state-files.js";
|
|
10
11
|
const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
@@ -62,11 +63,23 @@ export async function installLocalCollector(options = {}) {
|
|
|
62
63
|
operator_id: existingConfig?.operator_id,
|
|
63
64
|
default_repo_paths: defaultRepoPaths,
|
|
64
65
|
raw_evidence_upload: rawEvidenceUpload,
|
|
66
|
+
// The operator's Claude opt-out survives every rewrite (doctor, onboard,
|
|
67
|
+
// update, roots add); only the operator turns it back on (BLI-22341).
|
|
68
|
+
collect_claude_jsonl: existingConfig?.collect_claude_jsonl,
|
|
69
|
+
// Same for Codex, and for `raw_evidence_upload: "disabled"` below (ticket 22557).
|
|
70
|
+
collect_codex_jsonl: existingConfig?.collect_codex_jsonl,
|
|
65
71
|
session_file_path: paths.session_file,
|
|
66
72
|
state_dir_path: paths.state_dir,
|
|
67
73
|
});
|
|
68
74
|
await recordRootApprovals(paths, defaultRepoPaths, options.replaceRepoRoots);
|
|
69
|
-
await writeJsonFile(paths.config_file, config);
|
|
75
|
+
await writeJsonFile(paths.config_file, configForDisk(config));
|
|
76
|
+
const switches = resolveSourceSwitches(config);
|
|
77
|
+
if (!switches.claude) {
|
|
78
|
+
console.error("[install] claude session upload stays off", JSON.stringify({ reason: switches.claudeReason }));
|
|
79
|
+
}
|
|
80
|
+
if (!switches.codex) {
|
|
81
|
+
console.error("[install] codex session upload stays off", JSON.stringify({ reason: switches.codexReason }));
|
|
82
|
+
}
|
|
70
83
|
return {
|
|
71
84
|
config,
|
|
72
85
|
paths,
|
|
@@ -75,13 +88,14 @@ export async function installLocalCollector(options = {}) {
|
|
|
75
88
|
};
|
|
76
89
|
}
|
|
77
90
|
/**
|
|
78
|
-
* The one on-disk migration this file performs:
|
|
79
|
-
*
|
|
80
|
-
* evidence existed stops holding the old answer forever.
|
|
91
|
+
* The one on-disk migration this file performs: the retired short-retention
|
|
92
|
+
* raw-evidence choice becomes the durable one, so a machine installed before
|
|
93
|
+
* durable evidence existed stops holding the old answer forever. `"disabled"`
|
|
94
|
+
* is an operator's explicit opt-out of both sources and is kept until they
|
|
95
|
+
* turn it back on (ticket 22557); it is never healed to on.
|
|
81
96
|
*/
|
|
82
97
|
function migrateRawEvidenceUploadChoice(existingConfig) {
|
|
83
|
-
return existingConfig?.raw_evidence_upload === "
|
|
84
|
-
existingConfig?.raw_evidence_upload === "remote_short_retention_opt_in"
|
|
98
|
+
return existingConfig?.raw_evidence_upload === "remote_short_retention_opt_in"
|
|
85
99
|
? "remote_durable_opt_in"
|
|
86
100
|
: (existingConfig?.raw_evidence_upload ?? "remote_durable_opt_in");
|
|
87
101
|
}
|
|
@@ -132,7 +146,7 @@ export async function ensureLocalCollectorConfig(options = {}) {
|
|
|
132
146
|
session_file_path: paths.session_file,
|
|
133
147
|
state_dir_path: paths.state_dir,
|
|
134
148
|
});
|
|
135
|
-
await writeJsonFile(paths.config_file, config);
|
|
149
|
+
await writeJsonFile(paths.config_file, configForDisk(config));
|
|
136
150
|
return { config, paths, created: true };
|
|
137
151
|
}
|
|
138
152
|
/**
|
|
@@ -6,6 +6,7 @@ import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
|
6
6
|
import { serverFailureDetail } from "./upload-http.js";
|
|
7
7
|
import { ensureRuntimeDirectories, getCollectorRuntimePaths, } from "./local-state-paths.js";
|
|
8
8
|
import { isMissingFileError, writeJsonFile } from "./local-state-files.js";
|
|
9
|
+
import { configForDisk } from "./source-opt-out.js";
|
|
9
10
|
import { defaultDeviceName, LOCAL_COLLECTOR_VERSION, normalizeDashboardUrl, normalizeDeviceName, normalizeOptionalEmail, readLocalCollectorConfig, } from "./local-state-config.js";
|
|
10
11
|
import { readLocalCollectorSessionFile, toSessionReference, } from "./local-state-session.js";
|
|
11
12
|
/**
|
|
@@ -80,12 +81,12 @@ async function recordDeviceIdentityWhenChanged(paths, config, device) {
|
|
|
80
81
|
config.device_name !== device.deviceName ||
|
|
81
82
|
(device.claimedOwnerEmail &&
|
|
82
83
|
config.claimed_owner_email !== device.claimedOwnerEmail)) {
|
|
83
|
-
await writeJsonFile(paths.config_file, {
|
|
84
|
+
await writeJsonFile(paths.config_file, configForDisk({
|
|
84
85
|
...config,
|
|
85
86
|
device_id: device.deviceId,
|
|
86
87
|
device_name: device.deviceName,
|
|
87
88
|
claimed_owner_email: device.claimedOwnerEmail,
|
|
88
|
-
});
|
|
89
|
+
}));
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
/**
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const CODEX_OPT_OUT_REASON = "collect_codex_jsonl_false";
|
|
2
|
+
export const CLAUDE_OPT_OUT_REASON = "collect_claude_jsonl_false";
|
|
3
|
+
export const ALL_SOURCES_OPT_OUT_REASON = "raw_evidence_upload_disabled";
|
|
4
|
+
/** A missing or unreadable config is "no opt-out": collection stays on. */
|
|
5
|
+
export function resolveSourceSwitches(config) {
|
|
6
|
+
const all = config?.raw_evidence_upload === "disabled";
|
|
7
|
+
const codexReason = config?.collect_codex_jsonl === false
|
|
8
|
+
? CODEX_OPT_OUT_REASON
|
|
9
|
+
: all ? ALL_SOURCES_OPT_OUT_REASON : null;
|
|
10
|
+
const claudeReason = config?.collect_claude_jsonl === false
|
|
11
|
+
? CLAUDE_OPT_OUT_REASON
|
|
12
|
+
: all ? ALL_SOURCES_OPT_OUT_REASON : null;
|
|
13
|
+
return {
|
|
14
|
+
codex: codexReason === null,
|
|
15
|
+
claude: claudeReason === null,
|
|
16
|
+
codexReason,
|
|
17
|
+
claudeReason,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** The reason label for one source, or null when that source is on. */
|
|
21
|
+
export function sourceOptOutReason(config, source) {
|
|
22
|
+
const switches = resolveSourceSwitches(config);
|
|
23
|
+
return source === "codex" ? switches.codexReason : switches.claudeReason;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The key `collect_codex_jsonl` reaches disk only when it is `false`. An older
|
|
27
|
+
* CLI's strict schema rejects an unknown key, so a machine that never opted out
|
|
28
|
+
* keeps a config file every CLI version can read. Every config writer uses this.
|
|
29
|
+
*/
|
|
30
|
+
export function configForDisk(config) {
|
|
31
|
+
const { collect_codex_jsonl: codexSwitch, ...rest } = config;
|
|
32
|
+
return codexSwitch === false ? { ...rest, collect_codex_jsonl: false } : rest;
|
|
33
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Read the workspace context, describe transcript images, collect sources,
|
|
4
4
|
* then sanitize and assemble the event. Blockers retain their retry commands.
|
|
5
5
|
*/
|
|
6
|
+
import { resolveSourceSwitches } from "./source-opt-out.js";
|
|
6
7
|
import { priorSnapshotsFromCursor, } from "./adapters/raw-evidence-snapshot-supersede.js";
|
|
7
8
|
import { TelemetryIngestEnvelopeSchema, } from "@bli-cockpit/telemetry-core";
|
|
8
9
|
import path from "node:path";
|
|
@@ -88,7 +89,7 @@ export async function buildLocalAmbientEnvelope(options = {}) {
|
|
|
88
89
|
deviceToken: sessionFile.device_token,
|
|
89
90
|
fetchImpl: options.fetch,
|
|
90
91
|
});
|
|
91
|
-
const sourceCollection = await collectEnvelopeSources(options, paths, repoRoot, uploadContext, session, activeContext, rawEvidenceImages, now);
|
|
92
|
+
const sourceCollection = await collectEnvelopeSources(options, collector.config, paths, repoRoot, uploadContext, session, activeContext, rawEvidenceImages, now);
|
|
92
93
|
return assembleLocalEnvelope(options, collector, uploadContext, repoLabel, sourceCollection, now);
|
|
93
94
|
}
|
|
94
95
|
function assembleLocalEnvelope(options, collector, uploadContext, repoLabel, sourceCollection, now) {
|
|
@@ -141,7 +142,13 @@ function assembleLocalEnvelope(options, collector, uploadContext, repoLabel, sou
|
|
|
141
142
|
raw_evidence_facts: sourceCollection.facts.raw_evidence,
|
|
142
143
|
};
|
|
143
144
|
}
|
|
144
|
-
async function collectEnvelopeSources(options, paths, repoRoot, uploadContext, session, activeContext, rawEvidenceImages, now) {
|
|
145
|
+
async function collectEnvelopeSources(options, config, paths, repoRoot, uploadContext, session, activeContext, rawEvidenceImages, now) {
|
|
146
|
+
// The last gate before any transcript byte is read for upload: a source the
|
|
147
|
+
// operator opted out of is never collected, whoever the caller is (ticket 22557).
|
|
148
|
+
const switches = resolveSourceSwitches(config);
|
|
149
|
+
if (!switches.codex || !switches.claude) {
|
|
150
|
+
console.error("[upload-envelope] source withheld by operator opt-out", JSON.stringify({ codex_reason: switches.codexReason, claude_reason: switches.claudeReason }));
|
|
151
|
+
}
|
|
145
152
|
const sourceCollection = await runLocalSourceCollectors({
|
|
146
153
|
repoRoot,
|
|
147
154
|
branch: uploadContext.branch,
|
|
@@ -153,8 +160,8 @@ async function collectEnvelopeSources(options, paths, repoRoot, uploadContext, s
|
|
|
153
160
|
rawEvidenceStateDir: paths.state_dir,
|
|
154
161
|
rawEvidenceSessionsDirs: defaultCodexSessionDirs(paths.home_dir),
|
|
155
162
|
claudeProjectsDir: path.join(paths.home_dir, ".claude", "projects"),
|
|
156
|
-
rawEvidenceIncludeCodexJsonl: options.rawEvidenceIncludeCodexJsonl,
|
|
157
|
-
rawEvidenceIncludeClaudeJsonl: options.rawEvidenceIncludeClaudeJsonl,
|
|
163
|
+
rawEvidenceIncludeCodexJsonl: switches.codex ? options.rawEvidenceIncludeCodexJsonl : false,
|
|
164
|
+
rawEvidenceIncludeClaudeJsonl: switches.claude ? options.rawEvidenceIncludeClaudeJsonl : false,
|
|
158
165
|
rawEvidenceCodexSessionFiles: options.codexSessionFiles,
|
|
159
166
|
rawEvidenceCodexAttributionScan: options.codexAttributionScan,
|
|
160
167
|
rawEvidenceClaudeSessionFiles: options.claudeSessionFiles,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.248",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.73",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.152",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.80"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|