@bli-cockpit/cli 0.2.285 → 0.2.287

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/capture-opt-outs.js +129 -0
  2. package/dist/collection-scope.js +201 -0
  3. package/dist/commands/agent-session-report.js +3 -0
  4. package/dist/commands/analyze.js +2 -0
  5. package/dist/commands/backfill-scan.js +16 -11
  6. package/dist/commands/backfill-scope.js +16 -4
  7. package/dist/commands/backfill-session-report.js +2 -0
  8. package/dist/commands/cli-io.js +1 -3
  9. package/dist/commands/doctor-access.js +7 -0
  10. package/dist/commands/doctor-registration.js +39 -0
  11. package/dist/commands/doctor-report.js +1 -0
  12. package/dist/commands/doctor-shelf.js +62 -0
  13. package/dist/commands/doctor-update.js +2 -0
  14. package/dist/commands/doctor-usage-telemetry.js +18 -2
  15. package/dist/commands/doctor.js +9 -0
  16. package/dist/commands/heartbeat-post.js +19 -0
  17. package/dist/commands/heartbeat.js +13 -2
  18. package/dist/commands/install-receipts.js +4 -0
  19. package/dist/commands/local-args-collector-setup.js +2 -0
  20. package/dist/commands/local-args-collector-status.js +7 -21
  21. package/dist/commands/local-args.js +2 -0
  22. package/dist/commands/local-discovery.js +2 -2
  23. package/dist/commands/local-help-commands-tower.js +6 -1
  24. package/dist/commands/local-help-commands.js +15 -2
  25. package/dist/commands/local-help.js +4 -2
  26. package/dist/commands/local.js +7 -0
  27. package/dist/commands/memory-capture-prompt.js +130 -0
  28. package/dist/commands/memory-install-claude.js +51 -4
  29. package/dist/commands/memory-install-report.js +19 -3
  30. package/dist/commands/memory-install-toml.js +22 -0
  31. package/dist/commands/memory-install.js +110 -11
  32. package/dist/commands/memory-store.js +5 -0
  33. package/dist/commands/memory-switch.js +165 -0
  34. package/dist/commands/memory-uninstall.js +187 -0
  35. package/dist/commands/memory-verb-list.js +6 -2
  36. package/dist/commands/onboard.js +5 -0
  37. package/dist/commands/public-root.js +1 -1
  38. package/dist/commands/roots-label.js +85 -0
  39. package/dist/commands/roots.js +28 -10
  40. package/dist/commands/session-sync-plan.js +8 -4
  41. package/dist/commands/session-sync-scan.js +25 -3
  42. package/dist/commands/session-sync-shelf.js +143 -0
  43. package/dist/commands/session-sync-upload.js +6 -2
  44. package/dist/commands/session-sync.js +68 -8
  45. package/dist/commands/shelf.js +165 -0
  46. package/dist/commands/sources-check.js +2 -2
  47. package/dist/commands/sources-reconciliation.js +2 -1
  48. package/dist/commands/sync-doctor.js +2 -1
  49. package/dist/commands/sync-receipt.js +1 -0
  50. package/dist/commands/sync-report.js +1 -0
  51. package/dist/commands/sync-stub-heal.js +2 -2
  52. package/dist/commands/sync.js +12 -0
  53. package/dist/error-message.js +11 -0
  54. package/dist/evidence-reconcile-client.js +2 -0
  55. package/dist/evidence-redelivery-plan.js +6 -0
  56. package/dist/evidence-redelivery.js +6 -1
  57. package/dist/evidence-stubs-client.js +2 -0
  58. package/dist/evidence-transcripts-client.js +2 -0
  59. package/dist/evidence-upload-transport.js +2 -0
  60. package/dist/founder-standing-file.js +52 -0
  61. package/dist/founder-standing.js +117 -0
  62. package/dist/image-describe-client.js +2 -1
  63. package/dist/live-founder-memo.js +57 -0
  64. package/dist/local-state-config.js +13 -7
  65. package/dist/memory-capture-choice.js +73 -0
  66. package/dist/root-work.js +55 -7
  67. package/dist/shelf-decision.js +52 -0
  68. package/dist/shelf-headers.js +15 -0
  69. package/dist/shelf-reports.js +167 -0
  70. package/dist/shelf-state.js +112 -0
  71. package/dist/source-opt-out.js +83 -6
  72. package/dist/staging-gate.js +11 -3
  73. package/dist/state-dir-audit.js +2 -0
  74. package/dist/tower-client.js +4 -1
  75. package/dist/upload-agent-artifacts.js +2 -0
  76. package/dist/upload-envelope-build.js +2 -2
  77. package/dist/upload-ingest-receipt.js +2 -0
  78. package/dist/upload-session-reports-wire.js +2 -0
  79. package/package.json +4 -4
@@ -78,6 +78,8 @@ export function reexecDoctor(command, io) {
78
78
  const args = ["doctor"];
79
79
  if (command.noUsageTelemetry)
80
80
  args.push("--no-usage-telemetry");
81
+ if (command.noMemory)
82
+ args.push("--no-memory");
81
83
  if (command.homeDir)
82
84
  args.push("--home", command.homeDir);
83
85
  if (command.backfillBudgetSeconds)
@@ -1,6 +1,7 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
- import { getCollectorRuntimePaths, readLocalCollectorSessionFile } from "../local-state.js";
3
+ import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile } from "../local-state.js";
4
+ import { founderRoutingOn } from "./doctor-shelf.js";
4
5
  import { claudeSettingsFile } from "./memory-install-claude.js";
5
6
  import { codexConfigFile } from "./tower-mcp-codex.js";
6
7
  import { defaultMemoryFileIo } from "./memory-install-files.js";
@@ -83,7 +84,7 @@ async function usageTelemetryStep(context, checkOnly) {
83
84
  if (!session?.device_token)
84
85
  return needsFix("usage-telemetry", "device_token_missing", "usage telemetry: needs Tower sign-in");
85
86
  const result = await configureUsageTelemetry({ homeDir: home, origin: context.command.dashboardUrl, token: session.device_token,
86
- optOut: context.command.noUsageTelemetry, checkOnly, io: defaultMemoryFileIo({ mode: 0o600 }) });
87
+ optOut: context.command.noUsageTelemetry || (await founderWithholdsModels(home, context.io.fetch)), checkOnly, io: defaultMemoryFileIo({ mode: 0o600 }) });
87
88
  const message = result.off ? "usage telemetry: off (explicit opt-out)" : "usage telemetry: on (claude code, codex)";
88
89
  return checkOnly && result.changed ? needsFix("usage-telemetry", "settings_differ", message) : ok("usage-telemetry", result.off ? "explicit_opt_out" : "already_current", message);
89
90
  }
@@ -93,5 +94,20 @@ async function usageTelemetryStep(context, checkOnly) {
93
94
  return fail("usage-telemetry", reason, "usage telemetry: settings could not be safely merged");
94
95
  }
95
96
  }
97
+ /**
98
+ * BLI-23478 (three shelves, design 3.2 category C): usage telemetry names the model and tool of every session, so
99
+ * on a founder's laptop, once founder routing is on, it is opted out unless the founder allowed `model` with
100
+ * `cockpit shelf metrics --allow`. The opt-out persists like any other (the marker), so allowing `model` later
101
+ * needs the marker removed by hand: deliberate, since turning a channel back on is a person's call.
102
+ */
103
+ async function founderWithholdsModels(homeDir, fetchImpl) {
104
+ if (!(await founderRoutingOn(homeDir, fetchImpl)))
105
+ return false;
106
+ const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
107
+ const withheld = !(config?.shelf_metrics_allow ?? []).includes("model");
108
+ if (withheld)
109
+ console.error("[usage telemetry] founder opt-out", JSON.stringify({ reason: "shelf_model_category_off" }));
110
+ return withheld;
111
+ }
96
112
  export const checkUsageTelemetry = (context) => usageTelemetryStep(context, true);
97
113
  export const fixUsageTelemetry = (context) => usageTelemetryStep(context, false);
@@ -1,5 +1,8 @@
1
1
  import { checkSlackConnected } from "./doctor-slack.js";
2
2
  import { checkUsageTelemetry, fixUsageTelemetry } from "./doctor-usage-telemetry.js";
3
+ import { settleDoctorMemoryChoice } from "./memory-capture-prompt.js";
4
+ import { reportIgnoredOptOuts } from "../capture-opt-outs.js";
5
+ import { checkShelfRouting, fixShelfRouting } from "./doctor-shelf.js";
3
6
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION } from "../local-state.js";
4
7
  import { withCommandGuard } from "../command-guard.js";
5
8
  import { installHttp1GlobalFetch } from "../http1-fetch.js";
@@ -31,6 +34,10 @@ export async function runDoctorWithDeps(command, io, deps) {
31
34
  const deadlineMs = doctorRunDeadline(command, runStarted);
32
35
  const context = { command, io, deps, deadlineMs };
33
36
  console.error("[doctor] run started", JSON.stringify({ json: command.json, check_only: Boolean(command.dryRun || command.checkOnly), budget_seconds: deadlineMs === null ? null : DOCTOR_JSON_BUDGET_SECONDS }));
37
+ // BLI-22640: `--no-memory`, or the one-time question, before any row reads the choice.
38
+ await settleDoctorMemoryChoice(command, io);
39
+ // Founders only: a non-founder's opt-outs are ignored, and doctor says so in one line.
40
+ await reportIgnoredOptOuts({ ...(command.homeDir ? { homeDir: command.homeDir } : {}), json: command.json }, io);
34
41
  const rows = [];
35
42
  const repairs = [];
36
43
  const diagnoseOnly = command.dryRun || command.checkOnly;
@@ -178,6 +185,8 @@ function doctorInvariants() {
178
185
  // no fix because a session outside an approved root is not a defect
179
186
  // doctor can repair; it names the counts and moves on.
180
187
  { id: "roots-cover-work", check: context => context.deps.readRootsCoverWork(context) },
188
+ // BLI-23478: a founder's drawer default once Tower's lock is on (`doctor-shelf.ts`); everyone else: one ok line.
189
+ { id: "shelf-routing", check: (c) => (c.deps.checkShelfRouting ?? checkShelfRouting)(c), fix: (c) => (c.deps.fixShelfRouting ?? fixShelfRouting)(c) },
181
190
  // Deliberately has NO fix: uninstalling software the operator did not ask
182
191
  // to have uninstalled is not a repair (BLI-3218/BLI-3553). Doctor names the
183
192
  // other install and the exact command; the person decides.
@@ -11,6 +11,10 @@ import { describeError } from "../health-detail.js";
11
11
  import { classifySyncHealthError } from "../sync-health-class.js";
12
12
  import { noteHeartbeatRefusal, recordHeartbeatPendingToken, recordHeartbeatTokenExpiry, TOKEN_ROTATION_HEADER, } from "./heartbeat-token.js";
13
13
  import { noteDoctorRequest } from "./sync-doctor-request.js";
14
+ import { recordFounderStanding } from "../founder-standing-file.js";
15
+ import { getCollectorRuntimePaths } from "../local-state.js";
16
+ import { collectorShelfHeaders } from "../shelf-headers.js";
17
+ import { recordShelfState } from "../shelf-state.js";
14
18
  const HEARTBEAT_TIMEOUT_MS = 5_000;
15
19
  export async function postHeartbeat(options, deviceToken, heartbeat, memory) {
16
20
  const controller = new AbortController();
@@ -21,6 +25,8 @@ export async function postHeartbeat(options, deviceToken, heartbeat, memory) {
21
25
  headers: {
22
26
  "Content-Type": "application/json",
23
27
  Authorization: `Bearer ${deviceToken}`,
28
+ // BLI-23478: the CLI release, so Tower records `cli_too_old` on an owner's old laptop.
29
+ ...collectorShelfHeaders(),
24
30
  // BLI-17994. "I store a renewed token if you send one": the dashboard
25
31
  // only rotates for a collector that says so.
26
32
  [TOKEN_ROTATION_HEADER]: "1",
@@ -84,6 +90,19 @@ async function recordAcceptedHeartbeat(options, response, heartbeat, memory) {
84
90
  // door reports, which moves only when this request promoted a token.
85
91
  await recordHeartbeatPendingToken(options.homeDir, reply);
86
92
  await recordHeartbeatTokenExpiry(options.homeDir, response);
93
+ // BLI-22640: Tower's answer to "is this machine's person a founder?", kept
94
+ // for DISPLAY (doctor, support). No decision reads this file: every reader
95
+ // that decides what leaves the machine asks Tower live. Only a Tower reply
96
+ // writes it. A reply that could not read the owners list says null, and the
97
+ // last displayed answer stands.
98
+ if (typeof reply?.["is_owner"] === "boolean") {
99
+ await recordFounderStanding(getCollectorRuntimePaths(options.homeDir), reply["is_owner"], "heartbeat");
100
+ }
101
+ else {
102
+ console.error("[heartbeat] no founder answer in this reply; the last one stands", JSON.stringify({ reason: "is_owner_absent" }));
103
+ }
104
+ // BLI-23478 (three shelves): what the lock is for this laptop's person, for the hooks, the MCP server and doctor.
105
+ await recordShelfState(options.homeDir, reply);
87
106
  console.error("[heartbeat] checked in", JSON.stringify({
88
107
  reason: "heartbeat_recorded",
89
108
  http_status: response.status,
@@ -24,7 +24,7 @@ import { collectAgentSetup } from "./agent-setup.js";
24
24
  import fs from "node:fs";
25
25
  import os from "node:os";
26
26
  import path from "node:path";
27
- import { COLLECTOR_HEARTBEAT_SCHEMA_VERSION, MemoryInstallReceiptSchema, setupReceiptGaps, } from "@bli-cockpit/telemetry-core";
27
+ import { COLLECTOR_HEARTBEAT_MAX_ROOTS, COLLECTOR_HEARTBEAT_SCHEMA_VERSION, MemoryInstallReceiptSchema, setupReceiptGaps, } from "@bli-cockpit/telemetry-core";
28
28
  import { readCachedSetupReceipt } from "./setup-receipt.js";
29
29
  import { readStagingInventory } from "../disk-usage.js";
30
30
  import { readStagingGate } from "../staging-gate.js";
@@ -32,6 +32,8 @@ import { describeError } from "../health-detail.js";
32
32
  import { shouldSuppressFleetReceipts, } from "../dev-build.js";
33
33
  import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
34
34
  import { postHeartbeat } from "./heartbeat-post.js";
35
+ import { readCaptureOptOuts } from "../capture-opt-outs.js";
36
+ import { teamRootsForHeartbeat } from "../shelf-state.js";
35
37
  import { readHeartbeatSession } from "./heartbeat-token.js";
36
38
  import { withTokenRotationLock } from "./heartbeat-token-lock.js";
37
39
  import { readMemoryReceiptFile } from "./memory-install-receipt.js";
@@ -103,6 +105,9 @@ export function buildCollectorHeartbeat(options) {
103
105
  collector_version: options.collectorVersion ?? LOCAL_COLLECTOR_VERSION,
104
106
  os_platform: platform,
105
107
  roots: collectionRootLabels(options.roots, platform),
108
+ ...(options.shelvedRoots
109
+ ? { shelved_roots: Array.from({ length: Math.min(options.shelvedRoots, COLLECTOR_HEARTBEAT_MAX_ROOTS) }, () => ({ shelf: "drawer" })) }
110
+ : {}),
106
111
  last_sync_status: options.facts.status,
107
112
  ...(options.facts.outsideRootDirectories ? { outside_root_directories: options.facts.outsideRootDirectories } : {}),
108
113
  ...(options.facts.reason ? { last_sync_reason: options.facts.reason } : {}),
@@ -118,6 +123,7 @@ export function buildCollectorHeartbeat(options) {
118
123
  ...(typeof options.facts.sessionsPendingUpload === "number"
119
124
  ? { sessions_pending_upload: options.facts.sessionsPendingUpload }
120
125
  : {}),
126
+ ...(options.facts.collectionScope ? { collection_scope: options.facts.collectionScope } : {}),
121
127
  ...(typeof options.facts.stagingUncommittedBytes === "number"
122
128
  ? { staging_uncommitted_bytes: options.facts.stagingUncommittedBytes }
123
129
  : {}),
@@ -127,6 +133,7 @@ export function buildCollectorHeartbeat(options) {
127
133
  ...(options.facts.disk ? { disk: options.facts.disk } : {}),
128
134
  ...(options.memoryInstall ? { memory_install: options.memoryInstall } : {}),
129
135
  ...(options.setupReceipt ? { setup_receipt: options.setupReceipt } : {}),
136
+ ...(options.captureOptOuts ? { capture_opt_outs: options.captureOptOuts } : {}),
130
137
  };
131
138
  }
132
139
  /**
@@ -280,9 +287,13 @@ async function sendCollectorHeartbeatLocked(options) {
280
287
  }).catch(() => ({ receipt: null, reason: "receipt_read_threw" }));
281
288
  const setupReceipt = await readCachedSetupReceipt(options.homeDir, options.now ?? new Date());
282
289
  logSetupReceiptCarried(setupReceipt);
290
+ // BLI-23478: a drawer-labelled folder's name and path hash never travel; only its shelf, counted.
291
+ const shelfRoots = await teamRootsForHeartbeat(options.roots, options.homeDir, options.io.fetch);
283
292
  const heartbeat = buildCollectorHeartbeat({
284
- roots: options.roots,
293
+ roots: shelfRoots.team,
294
+ shelvedRoots: shelfRoots.shelved,
285
295
  facts: options.facts,
296
+ captureOptOuts: await readCaptureOptOuts(options.homeDir),
286
297
  setupReceipt: setupReceipt ? { ...setupReceipt, agent_setup: await collectAgentSetup(options.homeDir ?? os.homedir()) } : null,
287
298
  ...(options.now ? { now: options.now } : {}),
288
299
  memoryInstall: memory.receipt,
@@ -144,6 +144,9 @@ async function deliverPendingInstallEvents(options, paths, deviceToken) {
144
144
  if (receipt.imageHandlingMode !== undefined && options.observed) {
145
145
  options.observed.imageHandlingMode = receipt.imageHandlingMode;
146
146
  }
147
+ if (receipt.collectionScope !== undefined && options.observed) {
148
+ options.observed.collectionScope = receipt.collectionScope;
149
+ }
147
150
  delivered += 1;
148
151
  })));
149
152
  }
@@ -215,6 +218,7 @@ async function readReceiptReply(response) {
215
218
  ? receipt.min_cli_version.trim()
216
219
  : null,
217
220
  imageHandlingMode: receipt?.image_handling_mode,
221
+ collectionScope: receipt?.collection_scope,
218
222
  };
219
223
  }
220
224
  async function keepFailedInstallEventEntry(paths, entry, error, failures) {
@@ -95,6 +95,7 @@ const DOCTOR_ALLOWED_FLAGS = [
95
95
  "--check",
96
96
  "--no-repair",
97
97
  "--no-usage-telemetry",
98
+ "--no-memory",
98
99
  "--lock-wait",
99
100
  "--backfill-budget",
100
101
  "--json",
@@ -141,6 +142,7 @@ export function parseDoctorArgs(alias, args) {
141
142
  return {
142
143
  kind: "doctor",
143
144
  noUsageTelemetry: values.booleans.has("--no-usage-telemetry"),
145
+ noMemory: values.booleans.has("--no-memory"),
144
146
  checkOnly: values.booleans.has("--check") || values.booleans.has("--no-repair"),
145
147
  // Absent stays absent (BLI-11101): `doctor-budget.ts` owns the defaults,
146
148
  // and `--json` without either flag runs under one bounded budget.
@@ -168,23 +168,9 @@ export function parseAgentRulesArgs(args) {
168
168
  json: values.booleans.has("--json"),
169
169
  };
170
170
  }
171
- /**
172
- * BLI-3580, amended by BLI-4047.
173
- *
174
- * `cockpit memory` is now SIX verbs behind one noun: the two that configure
175
- * this machine (`install`, `status`), the experience log (`log`), and the four
176
- * that read and write the memory store itself (`search`, `save`, `update`,
177
- * `forget`, parsed next door in `local-args-tower-memory.ts`).
178
- *
179
- * **A bare `cockpit memory` prints the verb list; it no longer installs.**
180
- * BLI-3580 made `install` the default because the command is run unasked by
181
- * `doctor` and by the sync tick, but those callers build
182
- * `{ kind: "memory", action: "install" }` in code (`doctor-registration.ts`,
183
- * `sync-followups-memory.ts`, `onboard-completion.ts`) and never pass through
184
- * this parser. The only caller the default ever served was a PERSON typing the
185
- * noun to find out what it does, and what they got was an MCP server and three
186
- * hooks registered on their machine. Installing stays one word away.
187
- */
171
+ const MEMORY_MACHINE_ACTIONS = new Set([
172
+ "install", "uninstall", "on", "off", "status", "help",
173
+ ]);
188
174
  export function parseMemoryArgs(args) {
189
175
  // The verb word first, always: reading it off `args[0]` rather than "the
190
176
  // first token without a leading dash" is what keeps `cockpit memory --home
@@ -214,16 +200,16 @@ export function parseMemoryArgs(args) {
214
200
  if (values.flags.has("--store") || values.booleans.has("--reason-stdin"))
215
201
  throw new Error("--store and --reason-stdin require memory log");
216
202
  if (values.positionals.length > 1) {
217
- throw new Error("memory accepts at most one action (install|status).");
203
+ throw new Error("memory accepts at most one action (on|off|install|uninstall|status).");
218
204
  }
219
205
  // BLI-4047: no positional means "tell me what this noun does", not "install".
220
206
  const action = values.positionals[0] ?? "help";
221
- if (action !== "install" && action !== "status" && action !== "help") {
222
- throw new Error("memory action must be install, status, help, log, search, save, update, or forget.");
207
+ if (!MEMORY_MACHINE_ACTIONS.has(action)) {
208
+ throw new Error("memory action must be on, off, install, uninstall, status, help, log, search, save, update, or forget.");
223
209
  }
224
210
  return {
225
211
  kind: "memory",
226
- action,
212
+ action: action,
227
213
  homeDir: optionalNonEmpty(values.flags.get("--home")),
228
214
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
229
215
  dryRun: values.booleans.has("--dry-run"),
@@ -20,6 +20,7 @@ import { parseSkillsArgs } from "./skills.js";
20
20
  import { parseSpendArgs } from "./spend.js";
21
21
  import { parsePushArgs } from "./push.js";
22
22
  import { parseRootsArgs } from "./roots.js";
23
+ import { parseShelfArgs } from "./shelf.js";
23
24
  import { parseSlackPrivacyArgs } from "./slack-privacy-args.js";
24
25
  import { parseMilestoneArgs } from "./local-args-tower-milestone.js";
25
26
  import { SURFACE_ALIASES } from "./local-help.js";
@@ -42,6 +43,7 @@ export { SCOUT_MIN_PREFIX_LENGTH, SLACK_WORKSPACE_KEYS, WORKBOOK_MIN_WIDTH, ISSU
42
43
  */
43
44
  const COMMAND_PARSERS = new Map([
44
45
  ["roots", (_command, rest) => parseRootsArgs(rest)],
46
+ ["shelf", (_command, rest) => parseShelfArgs(rest)],
45
47
  ["onboard", (_command, rest) => parseOnboardArgs(rest)],
46
48
  ["update", (command, rest) => parseUpdateArgs(command, rest)],
47
49
  ["upgrade", (command, rest) => parseUpdateArgs(command, rest)],
@@ -16,7 +16,7 @@
16
16
  * binding a ticket from a scratch folder to every approved root on the machine
17
17
  * is far wider than the operator asked for, so it keeps the old refusal.
18
18
  */
19
- import { resolveSourceSwitches } from "../source-opt-out.js";
19
+ import { readSourceSwitches } from "../source-opt-out.js";
20
20
  import { writeLine } from "./cli-io.js";
21
21
  import { resolveDiscoveryLimits, saveDiscoveryLimits, } from "../discovery-limits.js";
22
22
  import { getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
@@ -196,7 +196,7 @@ export async function scanWorkDirectories(homeDir, persist = true) {
196
196
  return null; throw error; });
197
197
  const now = new Date();
198
198
  const options = { worktrees: [], collectionRoots: config?.default_repo_paths ?? [], now, sinceMinutes: ROOT_WORK_SCAN_WINDOW_DAYS * 24 * 60, limit: Number.MAX_SAFE_INTEGER };
199
- const switches = resolveSourceSwitches(config);
199
+ const switches = await readSourceSwitches(config, paths, "discovery");
200
200
  if (!switches.codex)
201
201
  console.error("[discovery] codex scan withheld by operator opt-out", JSON.stringify({ reason: switches.codexReason }));
202
202
  const codex = switches.codex ? await scanAndAttributeCodexSessions({ ...options, sessionsDirs: defaultCodexSessionDirs(homeDir) }) : null;
@@ -412,7 +412,7 @@ export const TOWER_COMMAND_HELP = [
412
412
  [
413
413
  "memory",
414
414
  [
415
- "Usage: cockpit memory [map|knowledge|rules|search|save|update|forget|private|public|profile|consolidation|chain|source|exam|requeue|install|status|log] [flags]",
415
+ "Usage: cockpit memory [map|knowledge|rules|search|save|update|forget|private|public|profile|consolidation|chain|source|exam|requeue|on|off|install|uninstall|status|log] [flags]",
416
416
  "",
417
417
  " map [--json] — projects, topics, memory counts and importance from the shared map.",
418
418
  " knowledge [--person <name>] [--project <name>] [--by-project] [--limit <n>] [--json] — ranked project ideas.",
@@ -469,6 +469,11 @@ export const TOWER_COMMAND_HELP = [
469
469
  "Idempotent: it merges with what is already there, never duplicates its own",
470
470
  "entries, and reads the stored config back before reporting success.",
471
471
  "`cockpit doctor` runs it, and the sync tick re-runs it at most once a day.",
472
+ "off (alias uninstall) turns BLI Memory off on THIS machine: it records the choice, removes",
473
+ "our hooks and MCP entry from both hosts (your other hooks stay), and doctor, onboard,",
474
+ "update and the sync tick never add them back; the Stop and PreCompact hooks send nothing.",
475
+ "on (alias install) records the opposite choice and registers them again. Each prints",
476
+ "one line saying what changed and how to undo it. See docs/architecture/agent-memory.md.",
472
477
  "See docs/runbooks/bli-memory-install.md.",
473
478
  "log appends an opinion to ~/.codex/AGENT-EXPERIENCE.md and posts it to Tower.",
474
479
  "Use --reason-stdin instead of a quoted reason; --json reports shipped status.",
@@ -26,7 +26,18 @@ const COMMAND_HELP = new Map([
26
26
  ["skills", ["Usage: cockpit skills library [--weeks <1-52>] [--json] | cockpit skills refresh [--weeks <1-104>] [--json]", "The Skill Library: every agent skill and command our GitHub repos define (name, description, repo, path, versions) with weekly use by person, from the sessions Tower already collects. Uses that match no scanned skill are listed as unmatched, never dropped. The first line is the coverage: repos scanned, skills, invocations matched of total. A member sees their own usage; a super_admin sees everyone's except an owner's. refresh scans the repos and recomputes the weekly totals now (super_admin only; a stale library also refreshes itself when read). MCP twins: skills_library, skills_refresh. Web: TEAM > Skills."]],
27
27
  ["push", ["Usage: cockpit push status | test [--dashboard-url <url>] [--json]", "Phone notifications. status lists your saved devices and whether the server has its push keys; test sends a test push to every saved device. Turn a phone on from Settings on the phone."]],
28
28
  ["spend", ["Usage: cockpit spend memory [--days 1|7|30] [--dashboard-url <url>] [--json]", "Spend by memory operation (write, recall, dream, update_forget, backfill, label, exam, predictor, people, who, intentions, salience, chat, other), ranked top first with list and real dollars, share, trend against the previous window, and how much of the real OpenAI bill the ledger explains. What it does not explain is shown as untagged. A super_admin reads it."]],
29
- ["roots", ["Usage: cockpit roots add <dir> [--allow-home-root] [--json]", "Approves a work directory, saves an approval receipt, and adds it to collection. Run cockpit doctor afterward."]],
29
+ ["roots", [
30
+ "Usage: cockpit roots add <dir> [--allow-home-root] [--json] | label <dir> team|drawer [--json] | list [--json]",
31
+ "add approves a work directory, saves an approval receipt, and adds it to collection. Run cockpit doctor afterward.",
32
+ "label (founders' laptops): team sends that folder's sessions and memories to Tower as today; drawer keeps them on this laptop and sends only bare numbers. safe is refused until the safe is live.",
33
+ "list shows each approved or labelled folder and the shelf it resolves to.",
34
+ ]],
35
+ ["shelf", [
36
+ "Usage: cockpit shelf status | default team|drawer | metrics --allow <day,tool,model,spawn|none> | drawer purge --older-than <days> [--json]",
37
+ "A founder's shelves on THIS laptop. status: Tower's founder lock, the default, and what is in the drawer here (not backed up).",
38
+ "default: where work outside every labelled folder goes. metrics: the opt-in categories a drawer session's bare record may carry (all off by default).",
39
+ "drawer purge: the only way drawer items are removed.",
40
+ ]],
30
41
  // BLI-3709: the Tower nouns live next door, split along the same seam the
31
42
  // argument parsers already use — see `local-help-commands-tower.ts`.
32
43
  ...TOWER_COMMAND_HELP,
@@ -92,12 +103,14 @@ const COMMAND_HELP = new Map([
92
103
  [
93
104
  "doctor",
94
105
  [
95
- "Usage: cockpit doctor [--workspace <path>] [--check | --no-repair] [--no-usage-telemetry] [--lock-wait <seconds>] [--backfill-budget <seconds>] [--json]",
106
+ "Usage: cockpit doctor [--workspace <path>] [--check | --no-repair] [--no-usage-telemetry] [--no-memory] [--lock-wait <seconds>] [--backfill-budget <seconds>] [--json]",
96
107
  "",
97
108
  "Diagnose, repair, and verify this machine.",
98
109
  "Repairs CLI, background sync, sign-in, roots, memory and hooks, agent rules, catch-up, uploads, and cleanup.",
99
110
  "roots-cover-work is informational: it names how many sessions are inside vs. outside saved roots (all already uploaded, outside ones labelled) and gives cockpit roots add commands for attributing a folder's sessions to a repo (outside-root transcripts upload either way). It never needs a fix.",
100
111
  "--no-usage-telemetry switches off Claude Code and Codex usage export, including later doctor runs.",
112
+ "--no-memory turns BLI Memory off on this machine (same as `cockpit memory off`), including later runs and the background sync.",
113
+ "In a terminal, the first doctor or onboard asks once whether BLI Memory may save your sessions; `cockpit memory on|off` changes the answer.",
101
114
  "--check or --no-repair diagnoses without repairs. --dry-run previews repairs.",
102
115
  "--backfill-budget defaults to 900 seconds; catch-up repeats chunks until complete, with progress every 30 seconds.",
103
116
  "Doctor never waits for the background sync: a running sync is reported as running (with how long ago it started) and doctor finishes; a sync that stopped responding or made no progress for 15 minutes is ended and doctor carries on. --lock-wait is accepted and changes nothing. Progress prints at most every 30 seconds and says what changed.",
@@ -36,6 +36,7 @@ export const SURFACE_ALIASES = new Map([
36
36
  ]);
37
37
  export const rootCommandNames = new Set([
38
38
  "roots",
39
+ "shelf",
39
40
  "onboard",
40
41
  "update",
41
42
  "upgrade",
@@ -110,7 +111,8 @@ export const rootCommandNames = new Set([
110
111
  export const publicCommandNames = new Set([...rootCommandNames].filter((name) => name !== "do-everything" && name !== "fix"));
111
112
  /** The one-screen summary `cockpit --help` prints: one line per command, then the shared footnotes. */
112
113
  const COMMAND_SUMMARY_LINES = [
113
- " cockpit roots add <dir> [--allow-home-root] [--json]",
114
+ " cockpit roots add <dir> [--allow-home-root] [--json] | label <dir> team|drawer | list",
115
+ " cockpit shelf status | default team|drawer | metrics --allow <categories|none> | drawer purge --older-than <days> [--json]",
114
116
  " cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--branch <name>] [--no-auth] [--max-depth <n>] [--max-repos <n>] [--json]",
115
117
  " cockpit update [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--workspace <path>] [--allow-home-root] [--no-auth] [--json]",
116
118
  " cockpit upgrade [same flags as update]",
@@ -148,7 +150,7 @@ const COMMAND_SUMMARY_LINES = [
148
150
  " cockpit serve [--port <port>] [--workspace <path>]",
149
151
  " cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
150
152
  " cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
151
- " cockpit memory [search \"<question>\"|save|update <id>|forget <id>|profile|install|status|log <win|loss|noise> --store <bli|supermemory|both> \"<reason>\"] [--container <tag>] [--limit <n>] [--no-recent] [--include-originals|--no-originals] [--include-source|--no-source] [--custom-id <id>] [--extract] [--content-stdin] [--reason <label>] [--file <path>] [--dashboard-url <url>] [--dry-run] [--json]",
153
+ " cockpit memory [search \"<question>\"|save|update <id>|forget <id>|profile|on|off|install|uninstall|status|log <win|loss|noise> --store <bli|supermemory|both> \"<reason>\"] [--container <tag>] [--limit <n>] [--no-recent] [--include-originals|--no-originals] [--include-source|--no-source] [--custom-id <id>] [--extract] [--content-stdin] [--reason <label>] [--file <path>] [--dashboard-url <url>] [--dry-run] [--json]",
152
154
  " cockpit clean [--dry-run] [--all-committed] [--reconcile] [--dashboard-url <url>] [--json]",
153
155
  " cockpit sources check [--reconcile] [--older-than <days>] [--dashboard-url <url>] [--json]",
154
156
  " cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>|delete <id|slug>|trash|versions <id|slug>|restore <id>] [--parent <id>|root|--clear-parent] [--query <text> [--word]] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
@@ -9,6 +9,8 @@ export { rootCommandNames, localCommandHelp, SURFACE_ALIASES, unknownCommandJson
9
9
  /** Commands that act on THIS machine; the rest talk to Tower. */
10
10
  const MACHINE_COMMAND_KINDS = [
11
11
  "roots",
12
+ // BLI-23478 (three shelves): acts on this laptop only.
13
+ "shelf",
12
14
  "install",
13
15
  "onboard",
14
16
  "update",
@@ -81,6 +83,7 @@ export async function runLocalCockpitCli(argv, io = { ...defaultIo(), runDoctorA
81
83
  async function runMachineCommand(command, io) {
82
84
  switch (command.kind) {
83
85
  case "roots": return await (await import("./roots.js")).runRoots(command, io);
86
+ case "shelf": return await (await import("./shelf.js")).runShelf(command, io);
84
87
  case "install": return await (await import("./install-update.js")).runInstall(command, io);
85
88
  case "onboard": return await (await import("./onboard.js")).runOnboard(command, io);
86
89
  case "update": return await (await import("./install-update.js")).runUpdate(command, io);
@@ -109,6 +112,10 @@ async function runMachineCommand(command, io) {
109
112
  // caller asks for it by name in code.
110
113
  if (command.action === "help")
111
114
  return (await import("./memory-store.js")).runMemoryVerbList(io, command.json);
115
+ // BLI-22640: the per-machine switch. `install`/`status` keep their page.
116
+ if (command.action === "on" || command.action === "off" || command.action === "uninstall") {
117
+ return await (await import("./memory-switch.js")).runMemorySwitch({ ...command, action: command.action }, io);
118
+ }
112
119
  return await (await import("./memory-install.js")).runMemoryInstall(command, io);
113
120
  case "memory-store": return await (await import("./memory-store.js")).runMemoryStore(command, io);
114
121
  case "clean": return await (await import("./clean.js")).runClean(command, io);
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The one-time BLI Memory question (BLI-22640).
3
+ *
4
+ * An interactive `cockpit doctor` or `cockpit onboard` on a machine that has
5
+ * never chosen asks once, and the answer becomes this machine's choice
6
+ * (`memory-capture-choice.ts`). Once answered, never asked again: `cockpit
7
+ * memory on|off` is how a person changes their mind.
8
+ *
9
+ * Asked only when a person is plainly there: stdin AND stdout are terminals,
10
+ * `--json` is off, and this is not the scheduler's tick, a dry run or a
11
+ * `--check`. launchd, Task Scheduler, CI and every piped run keep today's
12
+ * default (on) without a question, and say why in the log.
13
+ *
14
+ * Founders only (Jeetesh's review of 2026-10-25): the question is shown only
15
+ * when Tower answers, live, that this machine's person is a founder. Anybody
16
+ * else never sees it and keeps BLI Memory on; the log names why
17
+ * (`not_founder`, or `founder_check_unavailable` when Tower could not be
18
+ * asked). A cached "not a founder" skips the question without asking Tower.
19
+ *
20
+ * An answer that is neither yes nor no is asked once more; a second unclear
21
+ * answer records NOTHING, so the default holds and the next interactive run
22
+ * asks again. A choice this important is never inferred from a typo.
23
+ */
24
+ import os from "node:os";
25
+ import { readLine, writeLine } from "./cli-io.js";
26
+ import { getCollectorRuntimePaths } from "../local-state.js";
27
+ import { readMemoryCaptureChoice, writeMemoryCaptureChoice } from "../memory-capture-choice.js";
28
+ import { mayRecordOff } from "../founder-standing.js";
29
+ import { parseYesNo } from "../yes-no.js";
30
+ export const MEMORY_CHOICE_QUESTION = "BLI Memory: save what you work on so agents remember it across sessions? [Y/n] ";
31
+ export const MEMORY_CHOICE_EXPLAINER = "Answering n keeps BLI Memory's hooks off on this machine, so nothing from your sessions is sent to memory; `cockpit memory on` or `cockpit memory off` changes it later.";
32
+ /** Why this run may not ask, or null when it may. */
33
+ export function memoryChoicePromptBlocker(io, options) {
34
+ if (options.json)
35
+ return "json";
36
+ if (options.automaticTick)
37
+ return "scheduled_tick";
38
+ if (options.dryRun || options.checkOnly)
39
+ return "diagnose_only";
40
+ if (((io.env ?? process.env)["CI"] ?? "") !== "")
41
+ return "ci";
42
+ const stdinTty = Boolean(io.stdin.isTTY);
43
+ const stdoutTty = Boolean(io.stdout.isTTY);
44
+ return stdinTty && stdoutTty ? null : "not_a_tty";
45
+ }
46
+ export async function askMemoryChoiceOnce(io, options) {
47
+ const blocker = memoryChoicePromptBlocker(io, options);
48
+ if (blocker) {
49
+ console.error("[memory-capture] question not asked; the default stands", JSON.stringify({ reason: blocker }));
50
+ return "not_interactive";
51
+ }
52
+ const paths = getCollectorRuntimePaths(options.homeDir ?? os.homedir());
53
+ const current = await readMemoryCaptureChoice(paths);
54
+ if (current.state !== "unchosen") {
55
+ console.error("[memory-capture] question not asked; already answered", JSON.stringify({ reason: "already_chosen", state: current.state }));
56
+ return "already_chosen";
57
+ }
58
+ const founder = await founderMayBeAsked(io, options);
59
+ if (founder !== null)
60
+ return founder;
61
+ writeLine(io.stdout, MEMORY_CHOICE_EXPLAINER);
62
+ let verdict = parseYesNo(await readLine(io, MEMORY_CHOICE_QUESTION), { emptyIsYes: true });
63
+ if (verdict === "unclear") {
64
+ verdict = parseYesNo(await readLine(io, `Please answer y or n. ${MEMORY_CHOICE_QUESTION}`), { emptyIsYes: true });
65
+ }
66
+ if (verdict === "unclear") {
67
+ console.error("[memory-capture] answer unclear twice; nothing recorded", JSON.stringify({ reason: "unclear_answer" }));
68
+ writeLine(io.stdout, "No answer recorded; BLI Memory stays on for now, and the next `cockpit doctor` asks again.");
69
+ return "unclear_answer";
70
+ }
71
+ const on = verdict === "yes";
72
+ try {
73
+ await writeMemoryCaptureChoice(paths, on, "prompt");
74
+ }
75
+ catch (error) {
76
+ console.error("[memory-capture] answer could not be saved", JSON.stringify({ reason: "choice_write_failed", error_name: error instanceof Error ? error.name : typeof error }));
77
+ writeLine(io.stdout, "Your answer could not be saved; run `cockpit memory " + (on ? "on" : "off") + "` to record it.");
78
+ return "choice_write_failed";
79
+ }
80
+ writeLine(io.stdout, on
81
+ ? "BLI Memory is on for this machine. Undo: cockpit memory off"
82
+ : "BLI Memory stays off on this machine; its hooks are removed and not added back. Undo: cockpit memory on");
83
+ return on ? "chose_on" : "chose_off";
84
+ }
85
+ /** Null when the question may be shown; otherwise why not. Logs both branches. */
86
+ async function founderMayBeAsked(io, options) {
87
+ // Only Tower's live "founder" is enough to ask, because the answer can
88
+ // record an off; the saved copy decides nothing (BLI-22640).
89
+ const permission = await mayRecordOff({
90
+ ...(options.homeDir ? { homeDir: options.homeDir } : {}),
91
+ ...(options.dashboardUrl ? { dashboardUrl: options.dashboardUrl } : {}),
92
+ fetch: io.fetch,
93
+ door: "prompt",
94
+ });
95
+ if (permission.allowed)
96
+ return null;
97
+ const reason = permission.reason === "memory_off_founders_only" ? "not_founder" : "founder_check_unavailable";
98
+ console.error("[memory-capture] question not asked; founders only", JSON.stringify({ reason, source: "live", detail: permission.detail }));
99
+ return reason;
100
+ }
101
+ /**
102
+ * What `cockpit doctor` does with the choice before its first row: honour
103
+ * `--no-memory`, otherwise ask once when a person is there. Never throws; a
104
+ * doctor run is never lost to this question.
105
+ */
106
+ export async function settleDoctorMemoryChoice(command, io) {
107
+ try {
108
+ if (command.noMemory && !command.dryRun && !command.checkOnly) {
109
+ // Founders only, on Tower's live answer; anybody else is told why and doctor carries on.
110
+ const permission = await mayRecordOff({
111
+ ...(command.homeDir ? { homeDir: command.homeDir } : {}),
112
+ ...(command.dashboardUrl ? { dashboardUrl: command.dashboardUrl } : {}),
113
+ fetch: io.fetch,
114
+ door: "doctor_no_memory",
115
+ });
116
+ if (!permission.allowed) {
117
+ // stdout belongs to `--json`; the refusal is on stderr there, by name.
118
+ writeLine(command.json ? io.stderr : io.stdout, `--no-memory: ${permission.line}`);
119
+ return "flag_refused";
120
+ }
121
+ await writeMemoryCaptureChoice(getCollectorRuntimePaths(command.homeDir ?? os.homedir()), false, "doctor_flag");
122
+ return "flag_off";
123
+ }
124
+ return await askMemoryChoiceOnce(io, command);
125
+ }
126
+ catch (error) {
127
+ console.error("[memory-capture] choice step failed; doctor carries on with what is recorded", JSON.stringify({ reason: "choice_step_threw", error_name: error instanceof Error ? error.name : typeof error }));
128
+ return "choice_write_failed";
129
+ }
130
+ }
@@ -200,8 +200,10 @@ export function applyHookGroups(root, hookSpecs) {
200
200
  ? hooks[spec.event].slice()
201
201
  : [];
202
202
  // Somebody else's Stop hook survives untouched; only OUR entries — matched
203
- // by bin name plus `hook`, wherever the bin ended up — are replaced.
204
- const survivors = groups.filter((group) => !groupIsOurs(group));
203
+ // by bin name plus `hook`, wherever the bin ended up — are replaced. A group
204
+ // a person shares with ours keeps their commands (BLI-22640, the Codex
205
+ // review point): only our command comes out of it.
206
+ const survivors = stripOurHooks(groups).groups;
205
207
  survivors.push({
206
208
  hooks: [
207
209
  {
@@ -268,9 +270,54 @@ export function ourHookCount(root) {
268
270
  }
269
271
  return count;
270
272
  }
271
- function groupIsOurs(group) {
273
+ /** Exported for `memory-uninstall.ts` (BLI-22640): the off-switch removes exactly what the installer would replace. */
274
+ export function groupIsOurs(group) {
272
275
  return hookCommands(group).some(isMemoryHookCommand);
273
276
  }
277
+ /**
278
+ * BLI-22640 (the Codex review point on PR 2615). Takes OUR commands out of
279
+ * each hook group and keeps everything else: a group a person shares with
280
+ * ours keeps their own commands (and its matcher and any other key), and only
281
+ * a group left with no hooks at all is dropped. A group that holds none of
282
+ * ours comes back as the same object, byte for byte once serialised.
283
+ *
284
+ * `removed` counts our commands taken out. Used by the installer (before it
285
+ * appends our fresh group) and by `cockpit memory off`.
286
+ */
287
+ export function stripOurHooks(groups) {
288
+ const kept = [];
289
+ let removed = 0;
290
+ for (const group of groups) {
291
+ const record = asRecord(group);
292
+ const entries = record && Array.isArray(record["hooks"]) ? record["hooks"] : null;
293
+ if (!record || !entries) {
294
+ kept.push(group);
295
+ continue;
296
+ }
297
+ const others = entries.filter((entry) => {
298
+ const command = asRecord(entry)?.["command"];
299
+ return !(typeof command === "string" && isMemoryHookCommand(command));
300
+ });
301
+ removed += entries.length - others.length;
302
+ if (others.length === entries.length)
303
+ kept.push(group);
304
+ else if (others.length > 0)
305
+ kept.push({ ...record, hooks: others });
306
+ }
307
+ return { groups: kept, removed };
308
+ }
309
+ /** How many of OUR hook commands a settings or hooks.json root holds, across every event and group. */
310
+ export function ourHookCommandCount(root) {
311
+ const hooks = asRecord(root["hooks"]);
312
+ if (!hooks)
313
+ return 0;
314
+ let count = 0;
315
+ for (const groups of Object.values(hooks)) {
316
+ if (Array.isArray(groups))
317
+ count += stripOurHooks(groups).removed;
318
+ }
319
+ return count;
320
+ }
274
321
  function hookCommands(group) {
275
322
  const record = asRecord(group);
276
323
  if (!record || !Array.isArray(record["hooks"]))
@@ -310,7 +357,7 @@ function sameStringArray(value, expected) {
310
357
  * itself, so this is cosmetic — but a diff that reindents a person's whole
311
358
  * configuration reads like damage even when it is not.
312
359
  */
313
- function serializeJson(root, previous) {
360
+ export function serializeJson(root, previous) {
314
361
  const indent = detectIndent(previous);
315
362
  return `${JSON.stringify(root, null, indent)}\n`;
316
363
  }