@bli-cockpit/cli 0.2.247 → 0.2.249

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.
@@ -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: honourClaudeOptOut(selectedSources(command.source), config.collect_claude_jsonl),
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 Claude opt-out (`collect_claude_jsonl: false`) the scheduled
55
- * sync already honours (`session-sync-plan.ts`) therefore holds here too:
56
- * backfill never reads the Claude store on a machine that said no.
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 honourClaudeOptOut(sources, collectClaudeJsonl) {
59
- if (collectClaudeJsonl !== false || !sources.includes("claude_code"))
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
- console.error("[backfill] claude source withheld by operator opt-out", JSON.stringify({ reason: "collect_claude_jsonl_false", sources_before: sources.length }));
62
- return sources.filter((source) => source !== "claude_code");
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. `unlink` unlinks in the call; `keep_tower`/`take_notion` apply on the next pass (the door's `note` says so). */
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"}. ${str(body["note"]) || "Nothing else changed."}`);
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 codex = await scanAndAttributeCodexSessions({ ...options, sessionsDirs: defaultCodexSessionDirs(homeDir) });
181
- const claude = config?.collect_claude_jsonl === false ? null : await scanAndAttributeClaudeSessions({ ...options, projectsDir: (await import("node:path")).default.join(homeDir, ".claude", "projects") });
182
- if (codex.directory_read_failed_count || codex.stat_failed_count || claude?.project_dir_read_failed_count || claude?.session_stat_failed_count) {
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.results, ...(claude?.results ?? [])];
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; page editors only). A decision takes effect: unlink unlinks in that call; keep_tower is applied by the next push pass (Tower's edits go to Notion, the Notion content Tower cannot write stays in place, or the push refuses by name and the page stays stuck); take_notion is applied by the next pull pass (Notion's text becomes the page's text, Tower's kept as a version); reshare and investigate are notes. A Notion database folder (todo-list, daily-stand-ups) refuses a decision (database_folder_not_decidable). --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.247");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.249");
25
25
  return 0;
26
26
  }
27
27
 
@@ -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 (const result of scan.codexAttribution.results) {
139
- if (isUnclaimedOutsideRootResult(result)) {
140
- sightings.push({ source: "codex", sessionId: result.codex_session_id, result });
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 claudeEnabled = config?.collect_claude_jsonl !== false;
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: sourceHasPendingRetries("codex", uploadSpool, legacyRetryPending, codexSourceRetryPending, codexCursorBefore),
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
- await reconcileSourceScanRetry({
55
- paths: options.paths,
56
- source: "codex",
57
- attemptedAt: options.now.toISOString(),
58
- pendingBefore: plan.codexSourceRetryPending,
59
- reason: sourceScanRetryReason("codex", codexAttribution),
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 always scanned, unlike Claude which can be disabled entirely.
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 optOutAll = command.reconcile === true && config?.raw_evidence_upload === "disabled";
55
- const optOutClaude = command.reconcile === true && config?.collect_claude_jsonl === false;
56
- const local = await discoverLocalTranscripts({ homeDir, olderThanMs, skipAll: optOutAll, skipClaude: optOutClaude });
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 sources = config?.collect_claude_jsonl === false ? ["codex"] : ["codex", "claude_code"];
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"));
@@ -65,13 +66,19 @@ export async function installLocalCollector(options = {}) {
65
66
  // The operator's Claude opt-out survives every rewrite (doctor, onboard,
66
67
  // update, roots add); only the operator turns it back on (BLI-22341).
67
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,
68
71
  session_file_path: paths.session_file,
69
72
  state_dir_path: paths.state_dir,
70
73
  });
71
74
  await recordRootApprovals(paths, defaultRepoPaths, options.replaceRepoRoots);
72
- await writeJsonFile(paths.config_file, config);
73
- if (config.collect_claude_jsonl === false) {
74
- console.error("[install] claude session upload stays off", JSON.stringify({ reason: "collect_claude_jsonl_false" }));
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 }));
75
82
  }
76
83
  return {
77
84
  config,
@@ -81,13 +88,14 @@ export async function installLocalCollector(options = {}) {
81
88
  };
82
89
  }
83
90
  /**
84
- * The one on-disk migration this file performs: both retired raw-evidence
85
- * choices become the durable one, so a machine installed before durable
86
- * 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.
87
96
  */
88
97
  function migrateRawEvidenceUploadChoice(existingConfig) {
89
- return existingConfig?.raw_evidence_upload === "disabled" ||
90
- existingConfig?.raw_evidence_upload === "remote_short_retention_opt_in"
98
+ return existingConfig?.raw_evidence_upload === "remote_short_retention_opt_in"
91
99
  ? "remote_durable_opt_in"
92
100
  : (existingConfig?.raw_evidence_upload ?? "remote_durable_opt_in");
93
101
  }
@@ -138,7 +146,7 @@ export async function ensureLocalCollectorConfig(options = {}) {
138
146
  session_file_path: paths.session_file,
139
147
  state_dir_path: paths.state_dir,
140
148
  });
141
- await writeJsonFile(paths.config_file, config);
149
+ await writeJsonFile(paths.config_file, configForDisk(config));
142
150
  return { config, paths, created: true };
143
151
  }
144
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.247",
3
+ "version": "0.2.249",
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.72",
31
- "@bli-cockpit/mcp": "0.1.151",
32
- "@bli-cockpit/telemetry-core": "0.1.79"
30
+ "@bli-cockpit/memory-mcp": "0.1.73",
31
+ "@bli-cockpit/mcp": "0.1.153",
32
+ "@bli-cockpit/telemetry-core": "0.1.80"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",