@bli-cockpit/cli 0.2.286 → 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 (78) 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/state-dir-audit.js +2 -0
  73. package/dist/tower-client.js +4 -1
  74. package/dist/upload-agent-artifacts.js +2 -0
  75. package/dist/upload-envelope-build.js +2 -2
  76. package/dist/upload-ingest-receipt.js +2 -0
  77. package/dist/upload-session-reports-wire.js +2 -0
  78. package/package.json +4 -4
@@ -75,12 +75,17 @@ export function logMemoryOutcome(outcome, platform) {
75
75
  memory_on: memoryInstallGaps(outcome.receipt).length === 0,
76
76
  memory_gaps: memoryInstallGaps(outcome.receipt),
77
77
  codex_hooks: outcome.receipt.codex.hooks,
78
+ ...(outcome.opted_out ? { opted_out: true, removed_count: outcome.removed_count ?? 0 } : {}),
78
79
  };
79
80
  // stderr on both branches: launchd captures it to sync.err.log, and stdout is
80
81
  // reserved for `--json`.
81
- console.error(outcome.status === "failed"
82
- ? "[memory-install] BLI Memory is not fully registered on this machine"
83
- : "[memory-install] BLI Memory registration converged", JSON.stringify(fields));
82
+ console.error(outcome.opted_out
83
+ ? outcome.status === "failed"
84
+ ? "[memory-install] BLI Memory is off on this machine and an entry could not be removed"
85
+ : "[memory-install] BLI Memory is off on this machine; nothing of it was registered"
86
+ : outcome.status === "failed"
87
+ ? "[memory-install] BLI Memory is not fully registered on this machine"
88
+ : "[memory-install] BLI Memory registration converged", JSON.stringify(fields));
84
89
  }
85
90
  export function memoryOutcomeLines(outcome) {
86
91
  const headline = memoryOutcomeHeadline(outcome);
@@ -89,6 +94,11 @@ export function memoryOutcomeLines(outcome) {
89
94
  // daemon", because nothing looked.
90
95
  if (outcome.daemon)
91
96
  lines.push(` ${memoryDaemonLine(outcome.daemon)}`);
97
+ // BLI-22640: a non-founder's "off" is ignored, and said so where they read.
98
+ if (outcome.opt_out_ignored) {
99
+ lines.push(" This machine's BLI Memory \"off\" is ignored: only Tower's founders can turn it off, " +
100
+ "because work on BLI machines goes to Tower (opt_out_ignored_not_founder).");
101
+ }
92
102
  // The trust step is the one thing a person has to do by hand, so it is said
93
103
  // here in words rather than left as a status word they have to look up.
94
104
  if (outcome.receipt.codex.hooks === "needs_trust") {
@@ -108,6 +118,12 @@ export function memoryOutcomeLines(outcome) {
108
118
  return lines;
109
119
  }
110
120
  function memoryOutcomeHeadline(outcome) {
121
+ // BLI-22640: the person's choice is the headline, whatever the files say.
122
+ if (outcome.opted_out) {
123
+ return outcome.action === "status"
124
+ ? "BLI Memory is off on this machine (your choice). `cockpit memory on` turns it back on."
125
+ : "BLI Memory is off on this machine (your choice); none of it was registered. `cockpit memory on` turns it back on.";
126
+ }
111
127
  if (outcome.status === "installed")
112
128
  return "BLI Memory registered on this machine.";
113
129
  if (outcome.status === "already")
@@ -148,6 +148,28 @@ export function removeTomlTable(text, tablePath) {
148
148
  const lines = text.split("\n");
149
149
  return [...lines.slice(0, span.start_line), ...lines.slice(span.end_line)].join("\n");
150
150
  }
151
+ /**
152
+ * BLI-22640. Removes our table AND every sub-table under it (`[a.b.env]`,
153
+ * `[a.b.tools.x]`), each with the lines it owns; every other table survives
154
+ * byte for byte. `cockpit memory off` uses it, so no half of our entry is left
155
+ * for a host to start a server from.
156
+ */
157
+ export function removeTomlTableTree(text, tablePath) {
158
+ const lines = text.split("\n");
159
+ const kept = [];
160
+ let dropping = false;
161
+ for (const line of lines) {
162
+ const header = parseTableHeader(line);
163
+ if (header || isArrayTableHeader(line)) {
164
+ dropping = header !== null &&
165
+ header.length >= tablePath.length &&
166
+ samePath(header.slice(0, tablePath.length), tablePath);
167
+ }
168
+ if (!dropping)
169
+ kept.push(line);
170
+ }
171
+ return kept.join("\n");
172
+ }
151
173
  export function renderTomlTable(tablePath, entries) {
152
174
  const header = `[${tablePath.map(renderKey).join(".")}]`;
153
175
  const body = entries.map(([key, value]) => `${renderKey(key)} = ${renderValue(value)}`);
@@ -47,7 +47,10 @@ import { installTowerIntegration, inspectTowerIntegration } from "./tower-mcp-in
47
47
  import { defaultMemoryFileIo, } from "./memory-install-files.js";
48
48
  import { installClaudeMemoryIntegration, inspectClaudeMemoryIntegration, } from "./memory-install-claude.js";
49
49
  import { installCodexMemoryIntegration, inspectCodexMemoryIntegration, } from "./memory-install-codex.js";
50
- import { resolveMemoryConfig } from "./memory-install-config.js";
50
+ import { resolveMemoryConfig, resolveMemoryMcpBin } from "./memory-install-config.js";
51
+ import { removeMemoryIntegration } from "./memory-uninstall.js";
52
+ import { readEffectiveMemoryCapture, readMemoryCaptureChoice, writeMemoryCaptureChoice } from "../memory-capture-choice.js";
53
+ import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
51
54
  import { probeMemoryDaemon } from "./memory-daemon-probe.js";
52
55
  import { expectedCodexTrustRows, readCodexHookTrustFromDisk, } from "./memory-install-codex-hooks.js";
53
56
  import { buildMemoryInstallReceipt, writeMemoryReceiptFile, } from "./memory-install-receipt.js";
@@ -60,6 +63,12 @@ export { memoryOutcomeLines, resolveDashboardUrl } from "./memory-install-report
60
63
  export { buildMemoryInstallReceipt, memoryReceiptLine, readMemoryReceiptFile, writeMemoryReceiptFile, MEMORY_RECEIPT_FILE, } from "./memory-install-receipt.js";
61
64
  export { codexHooksFile, readCodexHookTrust, } from "./memory-install-codex-hooks.js";
62
65
  export async function runMemoryInstall(command, io, deps = {}) {
66
+ // BLI-22640: a person typing `install` is choosing BLI Memory for this
67
+ // machine, which clears an earlier `off`. Unattended callers never come
68
+ // through here; they call `installMemoryIntegration` and only read the choice.
69
+ const wasOff = command.action === "install" && !command.dryRun
70
+ ? await recordInstallChoice(command, deps)
71
+ : false;
63
72
  const outcome = command.action === "status"
64
73
  ? await inspectMemoryIntegration(command, io, deps)
65
74
  : await installMemoryIntegration(command, io, deps);
@@ -77,6 +86,8 @@ export async function runMemoryInstall(command, io, deps = {}) {
77
86
  writeLine(io.stdout, JSON.stringify({ ...outcome, setup_receipt: receipt?.receipt ?? null }, null, 2));
78
87
  }
79
88
  else {
89
+ if (wasOff)
90
+ writeLine(io.stdout, "BLI Memory is back on for this machine (it was off). Undo: cockpit memory off");
80
91
  for (const line of memoryOutcomeLines(outcome))
81
92
  writeLine(io.stdout, line);
82
93
  if (receipt) {
@@ -88,10 +99,31 @@ export async function runMemoryInstall(command, io, deps = {}) {
88
99
  }
89
100
  return outcome.status === "failed" ? 1 : 0;
90
101
  }
102
+ /** Records "on" for a person-typed `install`; true when it replaced an "off". A write that fails is logged and the install still runs. */
103
+ async function recordInstallChoice(command, deps) {
104
+ const paths = getCollectorRuntimePaths(deps.homeDir ?? command.homeDir ?? os.homedir());
105
+ const wasOff = (await readMemoryCaptureChoice(paths)).state === "off";
106
+ try {
107
+ await writeMemoryCaptureChoice(paths, true, "memory_install");
108
+ }
109
+ catch (error) {
110
+ console.error("[memory-install] choice not saved; an earlier off still stands", JSON.stringify({ reason: "choice_write_failed", error_name: error instanceof Error ? error.name : typeof error }));
111
+ return false;
112
+ }
113
+ return wasOff;
114
+ }
91
115
  export async function installMemoryIntegration(command, io, deps = {}) {
92
116
  const homeDir = deps.homeDir ?? command.homeDir ?? os.homedir();
93
117
  const platform = deps.platform ?? process.platform;
94
118
  const fileIo = deps.io ?? defaultMemoryFileIo();
119
+ // BLI-22640: the person's "off" outranks every unattended caller (doctor,
120
+ // onboard, update, the sync tick), because they all come through here.
121
+ // Only a founder's "off" is obeyed (Jeetesh's review of 2026-10-25); for
122
+ // anybody else the install runs as usual and the outcome names what it ignored.
123
+ const capture = await readEffectiveMemoryCapture(getCollectorRuntimePaths(homeDir), { fetch: io.fetch, ...(command.dashboardUrl ? { dashboardUrl: command.dashboardUrl } : {}) });
124
+ if (capture.off && capture.reading.state === "off") {
125
+ return installWhileOptedOut(command, io, deps, { homeDir, platform, fileIo, reason: capture.reading.reason });
126
+ }
95
127
  const resolved = await resolveMemoryConfig(command, io, platform, deps);
96
128
  const targets = [resolved.binTarget];
97
129
  // `no_bin_no_write`: with no resolved server there is nothing safe to
@@ -106,18 +138,10 @@ export async function installMemoryIntegration(command, io, deps = {}) {
106
138
  // tower-mcp-install.ts's header for why this rides here rather than a
107
139
  // separate, un-invoked verb. Independent of the memory outcome above: a
108
140
  // machine with one server but not the other is a real machine.
109
- targets.push(...(await installTowerIntegration({
110
- io: fileIo,
111
- platform,
112
- homeDir,
113
- dashboardUrl: await resolveDashboardUrl(command, deps),
114
- env: io.env ?? process.env,
115
- fileExists: deps.fileExists,
116
- cliEntryPoint: deps.cliEntryPoint,
117
- realpath: deps.realpath,
118
- }, command.dryRun)));
141
+ targets.push(...(await towerTargets(command, io, deps, { homeDir, platform, fileIo })));
119
142
  const outcome = {
120
143
  action: "install",
144
+ ...(capture.ignored ? { opt_out_ignored: "opt_out_ignored_not_founder" } : {}),
121
145
  ...(await readBackOutcome(targets, resolved, homeDir, fileIo)),
122
146
  };
123
147
  logMemoryOutcome(outcome, platform);
@@ -129,6 +153,75 @@ export async function installMemoryIntegration(command, io, deps = {}) {
129
153
  }
130
154
  return outcome;
131
155
  }
156
+ /** BLI-3706: `bli-tower` registers beside `bli-memory`, opted out or not. */
157
+ async function towerTargets(command, io, deps, scope) {
158
+ return installTowerIntegration({
159
+ io: scope.fileIo,
160
+ platform: scope.platform,
161
+ homeDir: scope.homeDir,
162
+ dashboardUrl: await resolveDashboardUrl(command, deps),
163
+ env: io.env ?? process.env,
164
+ fileExists: deps.fileExists,
165
+ cliEntryPoint: deps.cliEntryPoint,
166
+ realpath: deps.realpath,
167
+ }, command.dryRun);
168
+ }
169
+ /**
170
+ * BLI-22640. The install on a machine that turned BLI Memory off: write none
171
+ * of it, take back any of our entries that are still there (an older CLI, a
172
+ * restored backup, a hand edit), and keep `bli-tower`. Every BLI Memory target
173
+ * reads `skipped explicit_opt_out` (or `choice_unreadable`), so the sync
174
+ * tick's receipt, the heartbeat and the doctor row all name the choice.
175
+ *
176
+ * The bin is LOOKED UP but never run: `bin_found` stays a true fact about the
177
+ * machine, and no `--print-config` child is spawned for a server we will not
178
+ * register.
179
+ */
180
+ async function installWhileOptedOut(command, io, deps, scope) {
181
+ const removal = await removeMemoryIntegration({ homeDir: scope.homeDir, io: scope.fileIo, dryRun: command.dryRun });
182
+ const targets = removal.map((result) => result.status === "failed"
183
+ ? { target: result.target, status: "failed", reason: result.reason, path: result.path, ...(result.detail ? { detail: result.detail } : {}) }
184
+ : {
185
+ target: result.target,
186
+ status: "skipped",
187
+ reason: scope.reason,
188
+ path: result.path,
189
+ ...(result.status === "removed" ? { detail: `removed ${result.removed ?? 0} BLI Memory entry(ies)` } : {}),
190
+ ...(result.status === "would_remove" ? { detail: `would remove ${result.removed ?? 0} BLI Memory entry(ies)` } : {}),
191
+ });
192
+ targets.push({ target: "codex_skills", status: "skipped", reason: scope.reason });
193
+ targets.push(...(await towerTargets(command, io, deps, scope)));
194
+ const bin = await resolveMemoryMcpBin({
195
+ env: envWithNodeRuntimeOnPath(io.env ?? process.env),
196
+ platform: scope.platform,
197
+ fileExists: deps.fileExists,
198
+ cliEntryPoint: deps.cliEntryPoint,
199
+ realpath: deps.realpath,
200
+ }).catch(() => null);
201
+ const failed = targets.find((target) => target.status === "failed");
202
+ const outcome = {
203
+ action: "install",
204
+ status: failed ? "failed" : "skipped",
205
+ reason: failed ? failed.reason : scope.reason,
206
+ opted_out: true,
207
+ // A dry run counts what it WOULD remove, so doctor's check can say "entries left".
208
+ removed_count: removal.reduce((sum, result) => sum + (result.status === "removed" || result.status === "would_remove" ? result.removed ?? 0 : 0), 0),
209
+ config_source: "none",
210
+ bin_found: bin !== null,
211
+ ...(bin ? { bin_source: bin.source } : {}),
212
+ targets,
213
+ receipt: buildMemoryInstallReceipt({
214
+ targets,
215
+ binFound: bin !== null,
216
+ codexTrust: await codexTrustFor(scope.homeDir, scope.fileIo),
217
+ expectedTrustRows: 0,
218
+ }),
219
+ };
220
+ logMemoryOutcome(outcome, scope.platform);
221
+ if (!command.dryRun)
222
+ await cacheReceipt(scope.homeDir, outcome.receipt);
223
+ return outcome;
224
+ }
132
225
  /** What both `install` and `status` report once the targets are known: their verdict and the receipt. */
133
226
  async function readBackOutcome(targets, resolved, homeDir, fileIo) {
134
227
  return {
@@ -197,8 +290,14 @@ export async function inspectMemoryIntegration(command, io, deps = {}) {
197
290
  cliEntryPoint: deps.cliEntryPoint,
198
291
  realpath: deps.realpath,
199
292
  })));
293
+ // BLI-22640: `status` reads what is on disk either way, and says when the
294
+ // person turned BLI Memory off, so "not registered" is not read as broken.
295
+ const capture = await readEffectiveMemoryCapture(getCollectorRuntimePaths(homeDir), { fetch: io.fetch, ...(command.dashboardUrl ? { dashboardUrl: command.dashboardUrl } : {}) });
296
+ const optedOut = capture.off;
200
297
  const outcome = {
201
298
  action: "status",
299
+ ...(optedOut ? { opted_out: true } : {}),
300
+ ...(capture.ignored ? { opt_out_ignored: "opt_out_ignored_not_founder" } : {}),
202
301
  // The one live question on this surface: is the daemon answering? It is
203
302
  // bounded, it opens nothing but a local socket, and a machine without one
204
303
  // is reported as exactly that rather than as a problem.
@@ -58,6 +58,7 @@ import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js"
58
58
  import { runMemoryRequeue } from "./memory-requeue.js";
59
59
  import { runMemoryVisibility } from "./memory-visibility.js";
60
60
  import { readNoteFile } from "./notes-file.js";
61
+ import { FOUNDER_TEXT_VERBS, founderMemoryVerbRefusal } from "../shelf-state.js";
61
62
  export { runMemoryVerbList } from "./memory-verb-list.js";
62
63
  const TAG = "[memory cli]";
63
64
  const SEARCH_DEADLINE_MS = 60_000;
@@ -69,6 +70,10 @@ const BODY_MAX_CHARS = 200_000;
69
70
  const FORGET_MAX_CHARS = 10_000;
70
71
  export async function runMemoryStore(command, io) {
71
72
  const door = await openAgentDoor("memory", command, io);
73
+ // BLI-23478: on a founder's laptop the text-carrying verbs refuse locally, as the Memory MCP does (`shelf-state.ts`).
74
+ const founderRefusal = FOUNDER_TEXT_VERBS.has(command.action) ? await founderMemoryVerbRefusal(door.homeDir, io.fetch) : null;
75
+ if (founderRefusal)
76
+ return failAgentDoor(door, TAG, founderRefusal.reason, founderRefusal.detail);
72
77
  switch (command.action) {
73
78
  case "map":
74
79
  return runMemoryMap(door);
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `cockpit memory on|off` and their long names `install|uninstall`
3
+ * (BLI-22640): the per-machine BLI Memory switch a person flips by hand.
4
+ *
5
+ * `off` records this machine's "no" (`memory-capture-choice.ts`) and then runs
6
+ * the ordinary install, which, on an opted-out machine, registers nothing of
7
+ * BLI Memory and takes back any of our hooks and MCP entries it finds
8
+ * (`installWhileOptedOut`, memory-install.ts). `on` records "yes" and runs the
9
+ * same install, which registers them. Going through the one install keeps the
10
+ * receipt, the heartbeat and the doctor row reading the same thing the switch
11
+ * just did.
12
+ *
13
+ * The choice is written FIRST. If it cannot be written, nothing else happens
14
+ * and the command fails: a removal without the recorded "no" would be undone
15
+ * by the next sync tick, which is the bug this switch exists to end.
16
+ *
17
+ * `off` and `uninstall` are founders only (Jeetesh's review of 2026-10-25):
18
+ * before anything is written, Tower is asked live whether this machine's
19
+ * person is a founder (`mayRecordOff`, founder-standing.ts). Anybody else is
20
+ * refused with `memory_off_founders_only`, and a Tower that cannot be asked
21
+ * refuses with `founder_check_unavailable`; either way nothing is written and
22
+ * BLI Memory stays on. `on` is open to everyone.
23
+ *
24
+ * Each verb prints one line: what changed, and the command that undoes it.
25
+ * `uninstall` (and `install`, in memory-install.ts) add the per-target detail
26
+ * under it.
27
+ */
28
+ import os from "node:os";
29
+ import { writeLine } from "./cli-io.js";
30
+ import { installMemoryIntegration, memoryOutcomeLines, } from "./memory-install.js";
31
+ import { removeMemoryIntegration } from "./memory-uninstall.js";
32
+ import { defaultMemoryFileIo } from "./memory-install-files.js";
33
+ import { refreshSetupReceipt } from "./setup-receipt.js";
34
+ import { getCollectorRuntimePaths } from "../local-state.js";
35
+ import { readMemoryCaptureChoice, writeMemoryCaptureChoice, } from "../memory-capture-choice.js";
36
+ import { mayRecordOff } from "../founder-standing.js";
37
+ const VIA = {
38
+ on: "memory_on",
39
+ off: "memory_off",
40
+ uninstall: "memory_uninstall",
41
+ };
42
+ export async function runMemorySwitch(command, io, deps = {}) {
43
+ const homeDir = deps.homeDir ?? command.homeDir ?? os.homedir();
44
+ const paths = getCollectorRuntimePaths(homeDir);
45
+ const turnOn = command.action === "on";
46
+ const before = await readMemoryCaptureChoice(paths);
47
+ if (!turnOn) {
48
+ const permission = await mayRecordOff({
49
+ homeDir,
50
+ ...(command.dashboardUrl ? { dashboardUrl: command.dashboardUrl } : {}),
51
+ fetch: io.fetch,
52
+ door: `memory_${command.action}`,
53
+ });
54
+ if (!permission.allowed) {
55
+ print(command, io, {
56
+ line: permission.line,
57
+ // A non-founder's "off" is never obeyed, so for them memory is on whatever the file says.
58
+ memory_capture: permission.reason === "memory_off_founders_only" || before.state !== "off",
59
+ changed: false,
60
+ status: "refused",
61
+ reason: permission.reason,
62
+ });
63
+ return 1;
64
+ }
65
+ }
66
+ if (command.dryRun)
67
+ return dryRunSwitch(command, io, deps, { homeDir, turnOn });
68
+ try {
69
+ await writeMemoryCaptureChoice(paths, turnOn, VIA[command.action]);
70
+ }
71
+ catch (error) {
72
+ const reason = error instanceof Error && error.message.startsWith("memory_capture") ? error.message : "choice_write_failed";
73
+ console.error("[memory-switch] choice not saved; nothing was changed", JSON.stringify({ reason, action: command.action }));
74
+ const line = `BLI Memory could not be turned ${turnOn ? "on" : "off"}: this machine's choice could not be saved (${reason}). Nothing was changed.`;
75
+ print(command, io, { line, memory_capture: before.state !== "off", changed: false, status: "failed", reason });
76
+ return 1;
77
+ }
78
+ const outcome = await installMemoryIntegration({ ...command, action: "install" }, io, deps);
79
+ const line = turnOn ? onLine(before, outcome) : offLine(before, outcome);
80
+ const receipt = await refreshSetupReceipt(io, {
81
+ homeDir,
82
+ ...(command.dashboardUrl ? { dashboardUrl: command.dashboardUrl } : {}),
83
+ });
84
+ const changed = turnOn ? before.state !== "on" || outcome.status === "installed" : before.state !== "off" || (outcome.removed_count ?? 0) > 0;
85
+ console.error("[memory-switch] BLI Memory switched", JSON.stringify({ action: command.action, memory_capture: turnOn, changed, status: outcome.status, reason: outcome.reason, removed_count: outcome.removed_count ?? 0 }));
86
+ print(command, io, {
87
+ line,
88
+ memory_capture: turnOn,
89
+ changed,
90
+ status: outcome.status,
91
+ reason: outcome.reason,
92
+ outcome,
93
+ setup_receipt: receipt?.receipt ?? null,
94
+ });
95
+ return outcome.status === "failed" ? 1 : 0;
96
+ }
97
+ /** Writes nothing at all: not the choice, not a host file. */
98
+ async function dryRunSwitch(command, io, deps, scope) {
99
+ if (scope.turnOn) {
100
+ const outcome = await installMemoryIntegration({ ...command, action: "install", dryRun: true }, io, deps);
101
+ const line = "Dry run: BLI Memory would be turned on for this machine and registered with Claude Code and Codex. Nothing was written.";
102
+ print(command, io, { line, memory_capture: true, changed: false, status: outcome.status, reason: "dry_run", outcome });
103
+ return 0;
104
+ }
105
+ const removal = await removeMemoryIntegration({ homeDir: scope.homeDir, io: deps.io ?? defaultMemoryFileIo(), dryRun: true });
106
+ const pending = removal.reduce((sum, result) => sum + (result.status === "would_remove" ? result.removed ?? 0 : 0), 0);
107
+ const line = `Dry run: BLI Memory would be turned off for this machine and ${pending} of its entries removed from Claude Code and Codex. Nothing was written.`;
108
+ print(command, io, { line, memory_capture: false, changed: false, status: "would_remove", reason: "dry_run", removal });
109
+ return removal.some((result) => result.status === "failed") ? 1 : 0;
110
+ }
111
+ export function offLine(before, outcome) {
112
+ const undo = " Undo: cockpit memory on";
113
+ if (outcome.status === "failed") {
114
+ const failed = outcome.targets.find((target) => target.status === "failed");
115
+ return `BLI Memory is off for this machine, but ${failed?.target ?? "an entry"} could not be cleaned (${outcome.reason}); `
116
+ + "its save hooks send nothing while it is off, and `cockpit memory off` tries again." + undo;
117
+ }
118
+ const removed = outcome.removed_count ?? 0;
119
+ if (removed > 0) {
120
+ return `BLI Memory is off for this machine: removed its ${removed} hook and MCP entries from Claude Code and Codex, `
121
+ + "and doctor, onboard, update and the background sync will not add them back." + undo;
122
+ }
123
+ if (before.state === "off")
124
+ return "BLI Memory was already off for this machine; nothing changed." + undo;
125
+ return "BLI Memory is off for this machine: nothing of it was registered, and doctor, onboard, update and the background sync will not add it." + undo;
126
+ }
127
+ export function onLine(before, outcome) {
128
+ const undo = " Undo: cockpit memory off";
129
+ if (outcome.status === "failed") {
130
+ return `BLI Memory is on for this machine, but registering it failed (${outcome.reason}); \`cockpit doctor\` repairs it.` + undo;
131
+ }
132
+ if (outcome.status === "skipped") {
133
+ return "BLI Memory is on for this machine; its server is not installed yet, so nothing was registered, "
134
+ + "and the next `cockpit doctor` or background sync registers it." + undo;
135
+ }
136
+ if (outcome.status === "installed") {
137
+ return "BLI Memory is on for this machine: registered its hooks and MCP server with Claude Code and Codex, "
138
+ + "so each session is saved and agents remember it." + undo;
139
+ }
140
+ if (before.state === "on")
141
+ return "BLI Memory was already on for this machine; nothing changed." + undo;
142
+ return "BLI Memory is on for this machine (it was already registered); your choice is saved, so doctor will not ask again." + undo;
143
+ }
144
+ function print(command, io, result) {
145
+ if (command.json) {
146
+ writeLine(io.stdout, JSON.stringify({
147
+ action: command.action,
148
+ memory_capture: result.memory_capture,
149
+ changed: result.changed,
150
+ status: result.status,
151
+ reason: result.reason,
152
+ message: result.line,
153
+ ...(result.outcome ? { targets: result.outcome.targets, receipt: result.outcome.receipt } : {}),
154
+ ...(result.removal ? { removal: result.removal } : {}),
155
+ ...(result.setup_receipt !== undefined ? { setup_receipt: result.setup_receipt } : {}),
156
+ }, null, 2));
157
+ return;
158
+ }
159
+ writeLine(io.stdout, result.line);
160
+ // `uninstall` is the long name: the same line, then what each file says.
161
+ if (command.action === "uninstall" && result.outcome) {
162
+ for (const line of memoryOutcomeLines(result.outcome).slice(1))
163
+ writeLine(io.stdout, line);
164
+ }
165
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `cockpit memory off` / `cockpit memory uninstall` (BLI-22640): take BLI
3
+ * Memory's registration back off this machine.
4
+ *
5
+ * Removes exactly what `cockpit memory install` writes that can send or
6
+ * receive conversation text, and nothing a person wrote themselves:
7
+ *
8
+ * claude_mcp ~/.claude.json `mcpServers["bli-memory"]`
9
+ * claude_hooks ~/.claude/settings.json our hook commands, by bin name (`stripOurHooks`)
10
+ * codex_mcp ~/.codex/config.toml `[mcp_servers.bli-memory]` and its sub-tables
11
+ * codex_hooks ~/.codex/hooks.json our hook commands, by bin name
12
+ *
13
+ * Only OUR commands come out of a hook group. A group a person shares with
14
+ * ours keeps their commands and its matcher; a group left empty is dropped
15
+ * (the Codex review point on PR 2615).
16
+ *
17
+ * Left in place on purpose: the read-only `permissions.allow` entry (it names
18
+ * a tool that no longer exists, so it allows nothing) and the Codex skill
19
+ * files (instructions to an agent, not a hook; they send nothing). `bli-tower`
20
+ * stays registered: it carries Tower's docs and messages tools, not memory.
21
+ *
22
+ * Same three rules as the installer: merge, never clobber (another server or
23
+ * another hook comes out untouched; a file that will not parse is refused,
24
+ * never rewritten); idempotent (a second run finds nothing and says so); and
25
+ * read back what the disk stored (BLI-2541): a removal is only `removed` once
26
+ * the re-read file holds none of ours.
27
+ *
28
+ * Never throws at the caller. The sync tick calls this to keep an opted-out
29
+ * machine clean, and a failure is a named result, not a lost tick.
30
+ */
31
+ import { MEMORY_MCP_SERVER_ID, } from "./memory-install-contract.js";
32
+ import { claudeMcpConfigFile, claudeSettingsFile, ourHookCommandCount, parseJsonRecord, stripOurHooks, serializeJson, } from "./memory-install-claude.js";
33
+ import { codexConfigFile } from "./memory-install-codex.js";
34
+ import { codexHooksFile } from "./memory-install-codex-hooks.js";
35
+ import { removeTomlTableTree } from "./memory-install-toml.js";
36
+ const MEMORY_TOML_PATH = ["mcp_servers", MEMORY_MCP_SERVER_ID];
37
+ export async function removeMemoryIntegration(options) {
38
+ const results = [
39
+ await removeFromJson("claude_mcp", claudeMcpConfigFile(options.homeDir), options, countOurServer, dropOurServer),
40
+ await removeFromJson("claude_hooks", claudeSettingsFile(options.homeDir), options, countOurHookGroups, dropOurHookGroups),
41
+ await removeFromCodexToml(options),
42
+ await removeFromJson("codex_hooks", codexHooksFile(options.homeDir), options, countOurHookGroups, dropOurHookGroups),
43
+ ];
44
+ console.error("[memory-uninstall] BLI Memory registration removal finished", JSON.stringify({
45
+ dry_run: options.dryRun,
46
+ removed_count: results.reduce((sum, result) => sum + (result.status === "removed" ? result.removed ?? 0 : 0), 0),
47
+ targets: results.map((result) => `${result.target}:${result.status}/${result.reason}`),
48
+ }));
49
+ return results;
50
+ }
51
+ /** True when every target is clean (or would be, on a dry run). */
52
+ export function removalSucceeded(results) {
53
+ return results.every((result) => result.status !== "failed");
54
+ }
55
+ /** Whether anything of ours is still on disk, for a check that must not write. */
56
+ export function removalPending(results) {
57
+ return results.some((result) => result.status === "would_remove");
58
+ }
59
+ async function removeFromJson(target, file, options, count, drop) {
60
+ let raw;
61
+ try {
62
+ raw = await options.io.readText(file);
63
+ }
64
+ catch (error) {
65
+ return failed(target, file, "read_failed", error);
66
+ }
67
+ if (raw === null || !raw.trim())
68
+ return { target, status: "absent", reason: "file_absent", path: file };
69
+ const root = parseJsonRecord(raw);
70
+ if (!root) {
71
+ return {
72
+ target,
73
+ status: "failed",
74
+ reason: "config_unreadable",
75
+ path: file,
76
+ detail: "the file is not valid JSON; nothing was changed",
77
+ };
78
+ }
79
+ const ours = count(root);
80
+ if (ours === 0)
81
+ return { target, status: "absent", reason: "entry_absent", path: file };
82
+ if (options.dryRun)
83
+ return { target, status: "would_remove", reason: "dry_run", path: file, removed: ours };
84
+ try {
85
+ await options.io.writeText(file, serializeJson(drop(structuredClone(root)), raw));
86
+ }
87
+ catch (error) {
88
+ return failed(target, file, "write_failed", error);
89
+ }
90
+ const storedRaw = await options.io.readText(file).catch(() => null);
91
+ const stored = storedRaw === null ? null : parseJsonRecord(storedRaw);
92
+ if (!stored || count(stored) !== 0) {
93
+ return {
94
+ target,
95
+ status: "failed",
96
+ reason: "read_back_mismatch",
97
+ path: file,
98
+ detail: "the file on disk still holds a BLI Memory entry",
99
+ };
100
+ }
101
+ return { target, status: "removed", reason: "removed_entry", path: file, removed: ours };
102
+ }
103
+ async function removeFromCodexToml(options) {
104
+ const target = "codex_mcp";
105
+ const file = codexConfigFile(options.homeDir);
106
+ let raw;
107
+ try {
108
+ raw = await options.io.readText(file);
109
+ }
110
+ catch (error) {
111
+ return failed(target, file, "read_failed", error);
112
+ }
113
+ if (raw === null)
114
+ return { target, status: "absent", reason: "file_absent", path: file };
115
+ const next = removeTomlTableTree(raw, MEMORY_TOML_PATH);
116
+ if (next === raw)
117
+ return { target, status: "absent", reason: "entry_absent", path: file };
118
+ if (options.dryRun)
119
+ return { target, status: "would_remove", reason: "dry_run", path: file, removed: 1 };
120
+ try {
121
+ await options.io.writeText(file, next);
122
+ }
123
+ catch (error) {
124
+ return failed(target, file, "write_failed", error);
125
+ }
126
+ const stored = await options.io.readText(file).catch(() => null);
127
+ if (stored === null || removeTomlTableTree(stored, MEMORY_TOML_PATH) !== stored) {
128
+ return {
129
+ target,
130
+ status: "failed",
131
+ reason: "read_back_mismatch",
132
+ path: file,
133
+ detail: "the table on disk still holds the bli-memory server",
134
+ };
135
+ }
136
+ return { target, status: "removed", reason: "removed_entry", path: file, removed: 1 };
137
+ }
138
+ function countOurServer(root) {
139
+ const servers = asRecord(root["mcpServers"]);
140
+ return servers && MEMORY_MCP_SERVER_ID in servers ? 1 : 0;
141
+ }
142
+ function dropOurServer(root) {
143
+ const servers = asRecord(root["mcpServers"]);
144
+ if (servers)
145
+ delete servers[MEMORY_MCP_SERVER_ID];
146
+ return root;
147
+ }
148
+ function countOurHookGroups(root) {
149
+ return ourHookCommandCount(root);
150
+ }
151
+ /**
152
+ * Takes our commands out and keeps everything else: a person's command in a
153
+ * group shared with ours stays, with the group's matcher. A group left with no
154
+ * hooks is dropped, and an event left with no groups at all is dropped, not
155
+ * left as `[]`.
156
+ */
157
+ function dropOurHookGroups(root) {
158
+ const hooks = asRecord(root["hooks"]);
159
+ if (!hooks)
160
+ return root;
161
+ for (const [event, groups] of Object.entries(hooks)) {
162
+ if (!Array.isArray(groups))
163
+ continue;
164
+ const stripped = stripOurHooks(groups);
165
+ if (stripped.removed === 0)
166
+ continue;
167
+ if (stripped.groups.length === 0)
168
+ delete hooks[event];
169
+ else
170
+ hooks[event] = stripped.groups;
171
+ }
172
+ return root;
173
+ }
174
+ function failed(target, file, reason, error) {
175
+ return {
176
+ target,
177
+ status: "failed",
178
+ reason,
179
+ path: file,
180
+ detail: error instanceof Error ? error.name : "unknown",
181
+ };
182
+ }
183
+ function asRecord(value) {
184
+ if (!value || typeof value !== "object" || Array.isArray(value))
185
+ return null;
186
+ return value;
187
+ }
@@ -47,7 +47,11 @@ const VERB_LIST_LINES = [
47
47
  " their writes; super admins only, and a DRY RUN unless you pass --apply",
48
48
  "",
49
49
  "This machine:",
50
- " cockpit memory install [--dry-run] [--json] register the MCP server and the three hooks",
50
+ " cockpit memory on [--dry-run] [--json] turn BLI Memory on here and register its hooks",
51
+ " cockpit memory off [--dry-run] [--json] turn it off here: remove its hooks and MCP entry,",
52
+ " and doctor, onboard, update and the background sync never add them back",
53
+ " cockpit memory install [--dry-run] [--json] on, printing every file it checked",
54
+ " cockpit memory uninstall [--dry-run] [--json] off, printing every file it checked",
51
55
  " cockpit memory status [--json] read the stored config back",
52
56
  "",
53
57
  "The experience log:",
@@ -73,7 +77,7 @@ export function runMemoryVerbList(io, json) {
73
77
  writeLine(io.stdout, JSON.stringify({
74
78
  ok: true,
75
79
  noun: "memory",
76
- machine_verbs: ["install", "status"],
80
+ machine_verbs: ["on", "off", "install", "uninstall", "status"],
77
81
  store_verbs: ["rules", "knowledge", "search", "save", "update", "forget", "private", "public", "profile", "consolidation", "chain", "source", "exam", "requeue"],
78
82
  log_verbs: ["log"],
79
83
  }));
@@ -1,6 +1,7 @@
1
1
  import { resolveOnboardEmail } from "./local-auth.js";
2
2
  import { discoverCommandWorktrees } from "./local-discovery.js";
3
3
  import { handleOnboardFailure, } from "./onboard-completion.js";
4
+ import { askMemoryChoiceOnce } from "./memory-capture-prompt.js";
4
5
  import { runOnboardMultiRepoFlow, runOnboardSingleRepoFlow, } from "./onboard-flows.js";
5
6
  import { writeOnboardBanner } from "./onboard-report.js";
6
7
  import { prepareOnboardRoots, installOnboardConfig, pairForOnboarding } from "./onboard-setup.js";
@@ -28,6 +29,10 @@ export async function runOnboard(command, io) {
28
29
  const claimedOwnerEmail = await resolveOnboardEmail(roots.resolvedCommand, roots.collectionRoots, roots.existingConfig, io);
29
30
  install = await installOnboardConfig(command, roots.resolvedRoots, installEvents, io);
30
31
  pair = await pairForOnboarding(command, { primaryRoot: roots.primaryRoot, claimedOwnerEmail }, installEvents, io);
32
+ // BLI-22640: asked once, before the flows below can register BLI Memory,
33
+ // and after pairing, because only a founder is asked and Tower is the one
34
+ // who says who is a founder.
35
+ await askMemoryChoiceOnce(io, { ...(command.homeDir ? { homeDir: command.homeDir } : {}), json: command.json });
31
36
  const worktrees = await discoverOnboardWorktrees(command, roots, io);
32
37
  const ctx = {
33
38
  command,