@bli-cockpit/cli 0.2.291 → 0.2.293

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 (61) hide show
  1. package/dist/adapters/claude-attribution-discovery.js +22 -0
  2. package/dist/adapters/claude-attribution-memoized-reads.js +3 -3
  3. package/dist/adapters/claude-attribution.js +5 -2
  4. package/dist/adapters/codex-attribution-memoized-reads.js +3 -3
  5. package/dist/adapters/codex-attribution.js +30 -12
  6. package/dist/adapters/codex-segment-discovery.js +66 -0
  7. package/dist/adapters/codex-session-revision.js +139 -0
  8. package/dist/adapters/codex-session-signals.js +2 -2
  9. package/dist/adapters/pi-session-facts.js +28 -0
  10. package/dist/adapters/pi-session-reader.js +132 -0
  11. package/dist/adapters/raw-evidence-codex-reader.js +17 -7
  12. package/dist/adapters/raw-evidence-transcript-body.js +3 -0
  13. package/dist/adapters/raw-evidence-transcript-file.js +27 -4
  14. package/dist/adapters/raw-evidence-transcript-stream.js +33 -1
  15. package/dist/agent-plugins-converge.js +24 -5
  16. package/dist/collection-scope.js +2 -0
  17. package/dist/commands/backfill-checkpoint.js +3 -1
  18. package/dist/commands/backfill-history-scan.js +38 -0
  19. package/dist/commands/backfill-issues.js +8 -20
  20. package/dist/commands/backfill-scan.js +18 -40
  21. package/dist/commands/backfill-upload.js +1 -0
  22. package/dist/commands/books-usage.js +2 -1
  23. package/dist/commands/books.js +12 -4
  24. package/dist/commands/codex-hooks-trust-choice.js +51 -0
  25. package/dist/commands/codex-hooks-trust-prompt.js +172 -0
  26. package/dist/commands/codex-managed-hooks.js +226 -0
  27. package/dist/commands/codex-revision-facts.js +64 -0
  28. package/dist/commands/doctor-report.js +2 -2
  29. package/dist/commands/doctor-update.js +2 -0
  30. package/dist/commands/doctor.js +2 -0
  31. package/dist/commands/local-args-collector-setup.js +2 -0
  32. package/dist/commands/local-args-collector-status.js +5 -3
  33. package/dist/commands/local-args-notes-targets.js +35 -0
  34. package/dist/commands/local-args-tower-notes.js +7 -18
  35. package/dist/commands/local-help-commands-tower.js +3 -2
  36. package/dist/commands/local-help-commands.js +3 -2
  37. package/dist/commands/local-help-notes.js +2 -1
  38. package/dist/commands/local-help.js +1 -1
  39. package/dist/commands/memory-install-codex-hooks.js +95 -2
  40. package/dist/commands/memory-install-receipt.js +17 -1
  41. package/dist/commands/memory-install-report.js +5 -4
  42. package/dist/commands/memory-install.js +15 -6
  43. package/dist/commands/memory-log.js +13 -2
  44. package/dist/commands/memory-map.js +6 -2
  45. package/dist/commands/memory-verb-list.js +1 -1
  46. package/dist/commands/notes-writes.js +24 -0
  47. package/dist/commands/notes.js +3 -2
  48. package/dist/commands/public-root.js +1 -1
  49. package/dist/commands/session-sync-record.js +3 -1
  50. package/dist/commands/session-sync-scan.js +14 -6
  51. package/dist/commands/session-sync-shelf.js +2 -0
  52. package/dist/commands/session-sync-upload.js +1 -0
  53. package/dist/commands/setup-receipt-lines.js +2 -8
  54. package/dist/commands/sources-discovery.js +63 -14
  55. package/dist/commands/sources-reconciliation.js +7 -5
  56. package/dist/commands/sources-verdict.js +3 -0
  57. package/dist/cursors/raw-evidence-cursor.js +1 -0
  58. package/dist/image-description-session.js +4 -2
  59. package/dist/native-session-roots.js +31 -0
  60. package/dist/t3-native-homes.js +64 -0
  61. package/package.json +4 -4
@@ -0,0 +1,172 @@
1
+ /**
2
+ * "Auto-trust the BLI Memory hooks in Codex? [Y/n]" (BLI-24349).
3
+ *
4
+ * Codex skips a hook from a person's own `~/.codex/hooks.json` until they
5
+ * trust it in `/hooks`, and asks again whenever the hook changes. On
6
+ * 2026-11-04 three of eight reporting machines were stuck there. This is the
7
+ * one question that ends it: a yes puts the four BLI Memory hooks in Codex's
8
+ * system folder, where Codex runs them as managed hooks with no trust step
9
+ * (`codex-managed-hooks.ts` has the proof), and takes the user-scope copy out
10
+ * so nothing runs twice.
11
+ *
12
+ * Rules (Edward, 2026-11-04):
13
+ * - Default yes: Enter or y trusts.
14
+ * - Asked only when a person is plainly there (same test as the BLI Memory
15
+ * question: stdin and stdout are terminals, no `--json`, not the scheduled
16
+ * tick, not `--check`/`--dry-run`, not CI). Every other run never prompts
17
+ * and never changes trust; the receipt keeps saying `needs_trust`.
18
+ * - A no is remembered and not asked again. `cockpit doctor
19
+ * --trust-codex-hooks` is the yes without the question, for a person who
20
+ * changes their mind.
21
+ * - The elevation (one sudo on macOS, one UAC prompt on Windows) happens only
22
+ * after a yes.
23
+ * - Every branch logs one reason label: `trusted_by_prompt`, `declined`,
24
+ * `non_interactive_skip`, `elevation_refused`, `write_failed:<reason>`,
25
+ * plus the no-question ones (`already_managed`, `already_trusted`,
26
+ * `declined_before`, `not_needed:<why>`, `unclear_answer`).
27
+ */
28
+ import os from "node:os";
29
+ import { readLine, writeLine } from "./cli-io.js";
30
+ import { getCollectorRuntimePaths } from "../local-state.js";
31
+ import { isMemoryCaptureOff } from "../memory-capture-choice.js";
32
+ import { parseYesNo } from "../yes-no.js";
33
+ import { memoryChoicePromptBlocker } from "./memory-capture-prompt.js";
34
+ import { codexSystemHooksFile, defaultCodexElevationRunner, readCodexManagedHooks, writeCodexManagedHooks, } from "./codex-managed-hooks.js";
35
+ import { expectedCodexTrustRows, installCodexMemoryHooks, readCodexHookTrustFromDisk, } from "./memory-install-codex-hooks.js";
36
+ import { readCodexHooksTrustChoice, writeCodexHooksTrustChoice } from "./codex-hooks-trust-choice.js";
37
+ import { defaultMemoryFileIo } from "./memory-install-files.js";
38
+ import { resolveMemoryConfig } from "./memory-install-config.js";
39
+ export const CODEX_HOOKS_TRUST_QUESTION = "Auto-trust the BLI Memory hooks in Codex? [Y/n] ";
40
+ export const CODEX_HOOKS_TRUST_FLAG = "--trust-codex-hooks";
41
+ /**
42
+ * The whole question, start to finish. Never throws: a doctor run is never
43
+ * lost to it. Returns the reason label it logged.
44
+ */
45
+ export async function settleCodexHooksTrust(io, options, deps = {}) {
46
+ try {
47
+ const outcome = await decide(io, options, deps);
48
+ console.error("[codex-hooks-trust] settled", JSON.stringify({ reason: outcome }));
49
+ return outcome;
50
+ }
51
+ catch (error) {
52
+ const outcome = "write_failed:step_threw";
53
+ console.error("[codex-hooks-trust] settled", JSON.stringify({ reason: outcome, error_name: error instanceof Error ? error.name : typeof error }));
54
+ return outcome;
55
+ }
56
+ }
57
+ async function decide(io, options, deps) {
58
+ // First, and before anything is read: a run with nobody at the keyboard
59
+ // never prompts and never changes trust. The receipt carries the state.
60
+ const blocker = memoryChoicePromptBlocker(io, options);
61
+ if (blocker) {
62
+ console.error("[codex-hooks-trust] not asked; nobody at a terminal", JSON.stringify({ reason: "non_interactive_skip", blocker }));
63
+ return "non_interactive_skip";
64
+ }
65
+ const homeDir = options.homeDir ?? os.homedir();
66
+ const paths = getCollectorRuntimePaths(homeDir);
67
+ const platform = deps.platform ?? process.platform;
68
+ const fileIo = deps.io ?? defaultMemoryFileIo();
69
+ const off = await (deps.memoryOff ?? (() => isMemoryCaptureOff(paths, { fetch: io.fetch })))();
70
+ if (off)
71
+ return "not_needed:memory_off";
72
+ const config = await (deps.resolveConfig ?? (() => defaultResolveConfig(io, options, platform)))();
73
+ if (!config)
74
+ return "not_needed:bin_missing";
75
+ const trust = await readCodexHookTrustFromDisk({ homeDir, io: fileIo });
76
+ if (trust.feature === "disabled")
77
+ return "not_needed:hooks_disabled_in_config";
78
+ const file = deps.systemHooksFile ?? codexSystemHooksFile(platform, io.env ?? process.env);
79
+ const managed = await readCodexManagedHooks({ config, io: fileIo, file });
80
+ if (managed.state === "current")
81
+ return "already_managed";
82
+ // A machine somebody already trusted in /hooks runs the hooks today. The
83
+ // question is for untrusted machines (and a stale managed copy).
84
+ if (managed.state !== "stale" && trust.trustedRows >= expectedCodexTrustRows(config))
85
+ return "already_trusted";
86
+ if (!options.trustCodexHooks) {
87
+ const choice = await readCodexHooksTrustChoice(paths);
88
+ if (choice.choice === "declined") {
89
+ writeLine(io.stdout, `Codex hooks: not auto-trusted (you said no earlier). \`cockpit doctor ${CODEX_HOOKS_TRUST_FLAG}\` trusts them.`);
90
+ return "declined_before";
91
+ }
92
+ const answer = await ask(io, managed.state === "stale");
93
+ if (answer === "unclear") {
94
+ writeLine(io.stdout, "No answer recorded; the next `cockpit doctor` asks again.");
95
+ return "unclear_answer";
96
+ }
97
+ if (answer === "no") {
98
+ await recordChoice(paths, "declined", "prompt");
99
+ writeLine(io.stdout, `OK, not trusted. Codex skips the BLI Memory hooks until they are trusted; this is not asked again. ` +
100
+ `Change your mind: cockpit doctor ${CODEX_HOOKS_TRUST_FLAG} (or trust them in Codex's /hooks).`);
101
+ return "declined";
102
+ }
103
+ }
104
+ writeLine(io.stdout, platform === "win32"
105
+ ? `Windows may ask once for administrator permission to write ${file}.`
106
+ : `This writes ${file}; your computer asks for your password once (sudo).`);
107
+ const written = await writeCodexManagedHooks({
108
+ config,
109
+ io: fileIo,
110
+ file,
111
+ platform,
112
+ elevate: deps.elevate ?? defaultCodexElevationRunner(),
113
+ });
114
+ if (written.outcome === "elevation_refused") {
115
+ writeLine(io.stdout, "Not trusted: the administrator step was cancelled. The next `cockpit doctor` asks again.");
116
+ return "elevation_refused";
117
+ }
118
+ if (written.outcome === "write_failed") {
119
+ writeLine(io.stdout, `Not trusted: the system hooks file could not be written (${written.reason}). Fallback: trust them in Codex's /hooks.`);
120
+ return written.reason;
121
+ }
122
+ // The system copy carries the hooks now; take the user copy out so nothing runs twice.
123
+ const userCopy = await installCodexMemoryHooks({ homeDir, config, dryRun: false, io: fileIo, systemHooksFile: file });
124
+ if (userCopy.status === "failed") {
125
+ console.error("[codex-hooks-trust] system copy written; user copy not removed", JSON.stringify({ reason: userCopy.reason }));
126
+ }
127
+ await recordChoice(paths, "trusted", options.trustCodexHooks ? "flag" : "prompt");
128
+ writeLine(io.stdout, "Codex hooks: trusted. Codex now runs the BLI Memory hooks as managed hooks, with no /hooks review.");
129
+ return options.trustCodexHooks ? "trusted_by_flag" : "trusted_by_prompt";
130
+ }
131
+ async function ask(io, stale) {
132
+ writeLine(io.stdout, stale
133
+ ? "BLI Memory's Codex hooks changed since they were trusted on this machine. Yes updates the trusted copy."
134
+ : "Codex will not run BLI Memory's hooks (recall and save) until they are trusted. Yes trusts them for this machine, once.");
135
+ let verdict = parseYesNo(await readLine(io, CODEX_HOOKS_TRUST_QUESTION), { emptyIsYes: true });
136
+ if (verdict === "unclear") {
137
+ verdict = parseYesNo(await readLine(io, `Please answer y or n. ${CODEX_HOOKS_TRUST_QUESTION}`), { emptyIsYes: true });
138
+ }
139
+ return verdict;
140
+ }
141
+ async function recordChoice(paths, choice, via) {
142
+ try {
143
+ await writeCodexHooksTrustChoice(paths, choice, via);
144
+ }
145
+ catch (error) {
146
+ // The trust itself (or the "no") already happened; only the memory of it is lost.
147
+ console.error("[codex-hooks-trust] answer not saved", JSON.stringify({ reason: "choice_write_failed", choice, error_name: error instanceof Error ? error.name : typeof error }));
148
+ }
149
+ }
150
+ async function defaultResolveConfig(io, options, platform) {
151
+ const resolved = await resolveMemoryConfig({
152
+ kind: "memory",
153
+ action: "status",
154
+ ...(options.homeDir ? { homeDir: options.homeDir } : {}),
155
+ ...(options.dashboardUrl ? { dashboardUrl: options.dashboardUrl } : {}),
156
+ dryRun: false,
157
+ json: false,
158
+ }, io, platform, {});
159
+ return resolved.config;
160
+ }
161
+ /** What `cockpit doctor` calls: its own flags mapped onto the question's options. */
162
+ export function settleDoctorCodexHooksTrust(command, io) {
163
+ return settleCodexHooksTrust(io, {
164
+ json: command.json,
165
+ ...(command.homeDir ? { homeDir: command.homeDir } : {}),
166
+ ...(command.automaticTick ? { automaticTick: true } : {}),
167
+ dryRun: command.dryRun,
168
+ ...(command.checkOnly ? { checkOnly: true } : {}),
169
+ dashboardUrl: command.dashboardUrl,
170
+ ...(command.trustCodexHooks ? { trustCodexHooks: true } : {}),
171
+ });
172
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * BLI Memory's Codex hooks as MANAGED hooks (BLI-24349).
3
+ *
4
+ * Codex will not run a hook from a person's own `~/.codex/hooks.json` until
5
+ * they trust it in `/hooks`, and asks again whenever the hook text changes.
6
+ * Hooks in Codex's SYSTEM config folder are different: Codex marks them
7
+ * "managed, trusted by policy" and runs them with no review at all.
8
+ *
9
+ * macOS (and any Unix) /etc/codex/hooks.json
10
+ * Windows %ProgramData%\OpenAI\Codex\hooks.json
11
+ *
12
+ * ## Proven, not assumed (2026-11-04, Codex CLI 0.166.0)
13
+ *
14
+ * On a Linux box signed in with a personal ChatGPT account (plan `pro`, no
15
+ * Business workspace), with no `[hooks.state]` row anywhere, Codex's own
16
+ * `hooks/list` reported hooks in `/etc/codex/hooks.json` as `source: system,
17
+ * isManaged: true, trustStatus: managed`, and a real turn fired session-start,
18
+ * prompt and stop. The source agrees: `hook_metadata_for_config_layer_source`
19
+ * in openai/codex `codex-rs/hooks/src/engine/discovery.rs` maps the system
20
+ * config layer to `(HookSource::System, true)`, and nothing on that path reads
21
+ * the account. Windows was NOT run on a real machine (the test VM was out of
22
+ * reach); its folder comes from `windows_codex_system_dir` in
23
+ * `codex-rs/config/src/loader/mod.rs` (known folder ProgramData). Receipts:
24
+ * the BLI-24349 ticket comments and `docs/runbooks/codex-hooks-trust.md`.
25
+ *
26
+ * ## Why `hooks.json`, not `requirements.toml`
27
+ *
28
+ * Hooks in `requirements.toml` are managed too, but a broken write to that file
29
+ * stops Codex from starting at all ("failed to parse requirements layer",
30
+ * exit 1). A broken system `hooks.json` costs one warning line and the turn
31
+ * still runs. `requirements.toml` is also the admin policy file an
32
+ * organisation's device management owns; this module never touches it, and
33
+ * never sets `allow_managed_hooks_only` (that would switch off a person's own
34
+ * hooks).
35
+ *
36
+ * ## The write
37
+ *
38
+ * The system folder belongs to the administrator, so the write is tried as
39
+ * the person first (works on Windows when ProgramData lets users create
40
+ * folders, which is its default) and elevated only when that is refused:
41
+ * one `sudo` on macOS, one UAC prompt on Windows. Only our hook groups are
42
+ * replaced; anything else in the file survives, exactly like the user-scope
43
+ * writer (`applyHookGroups`). The result is read back off disk before it
44
+ * counts (BLI-2541).
45
+ */
46
+ import { spawnSync } from "node:child_process";
47
+ import { randomBytes } from "node:crypto";
48
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
49
+ import os from "node:os";
50
+ import path from "node:path";
51
+ import { applyHookGroups, hookGroupsMatch, ourHookCount, parseJsonRecord, serializeJson, } from "./memory-install-claude.js";
52
+ /**
53
+ * Moves the system folder somewhere else. The test suite sets it
54
+ * (`vitest.setup.ts`) so no test ever reads or elevates into the real
55
+ * `/etc/codex` or ProgramData, the same guard it keeps on HOME. Read from the
56
+ * process too, because callers inject a trimmed `env` for other reasons.
57
+ */
58
+ export const CODEX_SYSTEM_DIR_ENV = "COCKPIT_CODEX_SYSTEM_DIR";
59
+ /** Codex's system config folder on this platform. */
60
+ export function codexSystemDir(platform, env = process.env) {
61
+ const override = (env[CODEX_SYSTEM_DIR_ENV] ?? process.env[CODEX_SYSTEM_DIR_ENV] ?? "").trim();
62
+ if (override)
63
+ return override;
64
+ if (platform === "win32") {
65
+ const programData = (env["ProgramData"] ?? env["PROGRAMDATA"] ?? "").trim() || "C:\\ProgramData";
66
+ return path.win32.join(programData, "OpenAI", "Codex");
67
+ }
68
+ return "/etc/codex";
69
+ }
70
+ /** `/etc/codex/hooks.json`, or `%ProgramData%\OpenAI\Codex\hooks.json` on Windows. */
71
+ export function codexSystemHooksFile(platform, env = process.env) {
72
+ const dir = codexSystemDir(platform, env);
73
+ if ((env[CODEX_SYSTEM_DIR_ENV] ?? process.env[CODEX_SYSTEM_DIR_ENV] ?? "").trim())
74
+ return path.join(dir, "hooks.json");
75
+ return platform === "win32" ? path.win32.join(dir, "hooks.json") : path.posix.join(dir, "hooks.json");
76
+ }
77
+ /** An injected override (null = do not look) or this platform's system hooks file. */
78
+ export function codexSystemHooksFileFor(override, platform, env) {
79
+ return override !== undefined ? override : codexSystemHooksFile(platform, env ?? process.env);
80
+ }
81
+ export async function readCodexManagedHooks(options) {
82
+ let raw;
83
+ try {
84
+ raw = await options.io.readText(options.file);
85
+ }
86
+ catch {
87
+ return { state: "unreadable", file: options.file };
88
+ }
89
+ if (raw === null || !raw.trim())
90
+ return { state: "absent", file: options.file };
91
+ const root = parseJsonRecord(raw);
92
+ if (!root)
93
+ return { state: "unreadable", file: options.file };
94
+ if (hookGroupsMatch(root, options.config.hooks))
95
+ return { state: "current", file: options.file };
96
+ return { state: ourHookCount(root) > 0 ? "stale" : "absent", file: options.file };
97
+ }
98
+ /**
99
+ * Puts our four hooks into the system file. Never throws.
100
+ *
101
+ * Order: read what is there (refuse to replace a file that will not parse,
102
+ * same rule as every other config we touch), merge our groups in, try the
103
+ * write as the person, elevate only on a permission refusal, read back.
104
+ */
105
+ export async function writeCodexManagedHooks(options) {
106
+ const { file } = options;
107
+ let raw;
108
+ try {
109
+ raw = await options.io.readText(file);
110
+ }
111
+ catch (error) {
112
+ // Reading a world-readable file failed for a reason other than "absent".
113
+ return { outcome: "write_failed", reason: `write_failed:${errorCode(error, "read_failed")}`, file };
114
+ }
115
+ const existing = raw === null || !raw.trim() ? {} : parseJsonRecord(raw);
116
+ if (!existing) {
117
+ // Somebody else's system hooks file that does not parse: not ours to replace.
118
+ return { outcome: "write_failed", reason: "write_failed:system_file_unparseable", file };
119
+ }
120
+ if (hookGroupsMatch(existing, options.config.hooks)) {
121
+ return { outcome: "written", reason: "already_current", file };
122
+ }
123
+ const contents = serializeJson(applyHookGroups(structuredClone(existing), options.config.hooks), raw ?? "");
124
+ let reason = "wrote_direct";
125
+ try {
126
+ await options.io.writeText(file, contents);
127
+ }
128
+ catch (error) {
129
+ const code = errorCode(error, "write_error");
130
+ if (code !== "EACCES" && code !== "EPERM") {
131
+ return { outcome: "write_failed", reason: `write_failed:${code}`, file };
132
+ }
133
+ console.error("[codex-hooks-trust] the system hooks folder needs an administrator; asking the OS once", JSON.stringify({ reason: "elevation_needed", platform: options.platform, error_code: code }));
134
+ const elevated = await elevatedCopy({ ...options, contents });
135
+ if (elevated.status === "refused")
136
+ return { outcome: "elevation_refused", reason: "elevation_refused", file };
137
+ if (elevated.status === "failed")
138
+ return { outcome: "write_failed", reason: `write_failed:${elevated.reason}`, file };
139
+ reason = "wrote_elevated";
140
+ }
141
+ // BLI-2541: what the disk holds, not what we sent.
142
+ const stored = await readCodexManagedHooks({ config: options.config, io: options.io, file });
143
+ if (stored.state !== "current") {
144
+ return { outcome: "write_failed", reason: "write_failed:read_back_mismatch", file };
145
+ }
146
+ return { outcome: "written", reason, file };
147
+ }
148
+ async function elevatedCopy(options) {
149
+ let stagingDir = null;
150
+ try {
151
+ stagingDir = await mkdtemp(path.join(options.tempDir ?? os.tmpdir(), "cockpit-codex-hooks-"));
152
+ const stagedFile = path.join(stagingDir, `hooks.${randomBytes(4).toString("hex")}.json`);
153
+ await writeFile(stagedFile, options.contents, "utf8");
154
+ const pathApi = options.platform === "win32" ? path.win32 : path.posix;
155
+ return await options.elevate({
156
+ platform: options.platform,
157
+ stagedFile,
158
+ targetDir: pathApi.dirname(options.file),
159
+ targetFile: options.file,
160
+ });
161
+ }
162
+ catch (error) {
163
+ return { status: "failed", reason: errorCode(error, "staging_failed") };
164
+ }
165
+ finally {
166
+ if (stagingDir)
167
+ await rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
168
+ }
169
+ }
170
+ /**
171
+ * The real elevation: `sudo` on macOS, UAC on Windows. Both talk to the person
172
+ * directly (sudo reads the password from the terminal, UAC is its own secure
173
+ * desktop), so nothing secret ever passes through this process.
174
+ */
175
+ export function defaultCodexElevationRunner(spawn = spawnSync) {
176
+ return async (request) => (request.platform === "win32" ? elevateWindows(request, spawn) : elevateUnix(request, spawn));
177
+ }
178
+ function elevateUnix(request, spawn) {
179
+ // `sudo -v` is the one password prompt; a wrong password three times, Ctrl-C
180
+ // or a machine where this person is not an administrator all end here.
181
+ const validate = spawn("sudo", ["-v"], { stdio: ["inherit", "inherit", "inherit"], windowsHide: true });
182
+ if (validate.error)
183
+ return { status: "failed", reason: errorCode(validate.error, "sudo_unavailable") };
184
+ if (validate.status !== 0)
185
+ return { status: "refused" };
186
+ // `-n`: the credential is cached now, so this never prompts a second time.
187
+ // Paths travel as arguments, never spliced into the script.
188
+ const copy = spawn("sudo", ["-n", "/bin/sh", "-c", 'mkdir -p "$1" && cp "$2" "$3" && chmod 644 "$3"', "sh", request.targetDir, request.stagedFile, request.targetFile], { stdio: ["ignore", "inherit", "inherit"], windowsHide: true });
189
+ if (copy.error)
190
+ return { status: "failed", reason: errorCode(copy.error, "sudo_copy_failed") };
191
+ return copy.status === 0 ? { status: "ok" } : { status: "failed", reason: `elevated_exit_${copy.status ?? "signal"}` };
192
+ }
193
+ /** Windows exit code for "the operation was cancelled by the user" (UAC "No"). */
194
+ const ERROR_CANCELLED = 1223;
195
+ function elevateWindows(request, spawn) {
196
+ const inner = codexWindowsElevatedScript(request);
197
+ const encoded = Buffer.from(inner, "utf16le").toString("base64");
198
+ const outer = "try { $p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden " +
199
+ `-ArgumentList @('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-EncodedCommand','${encoded}'); exit $p.ExitCode } ` +
200
+ `catch { exit ${ERROR_CANCELLED} }`;
201
+ const result = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", outer], {
202
+ stdio: ["ignore", "ignore", "ignore"],
203
+ windowsHide: true,
204
+ });
205
+ if (result.error)
206
+ return { status: "failed", reason: errorCode(result.error, "powershell_unavailable") };
207
+ if (result.status === ERROR_CANCELLED)
208
+ return { status: "refused" };
209
+ return result.status === 0 ? { status: "ok" } : { status: "failed", reason: `elevated_exit_${result.status ?? "signal"}` };
210
+ }
211
+ /** The script the elevated PowerShell runs. Exported for its test; paths are single-quoted literals. */
212
+ export function codexWindowsElevatedScript(request) {
213
+ const quote = (value) => `'${value.replace(/'/gu, "''")}'`;
214
+ return [
215
+ "$ErrorActionPreference = 'Stop'",
216
+ `New-Item -ItemType Directory -Force -Path ${quote(request.targetDir)} | Out-Null`,
217
+ `Copy-Item -LiteralPath ${quote(request.stagedFile)} -Destination ${quote(request.targetFile)} -Force`,
218
+ "exit 0",
219
+ ].join("; ");
220
+ }
221
+ /** A short, charset-safe code for a failure: `EACCES`, `ENOENT`, or the fallback. */
222
+ function errorCode(error, fallback) {
223
+ const code = error?.code;
224
+ const raw = typeof code === "string" && code.trim() ? code : fallback;
225
+ return raw.replace(/[^A-Za-z0-9_.-]/gu, "_").slice(0, 40);
226
+ }
@@ -0,0 +1,64 @@
1
+ /** Compare a composite revision with the SAME line/slim/redaction rules as upload. No raw bundle or network. */
2
+ import { createHash } from "node:crypto";
3
+ import { findEmbeddedImages, modeDropsImages, replaceImagesWithMarkers } from "@bli-cockpit/telemetry-core";
4
+ import { imageStateKey } from "../image-description-state.js";
5
+ import { openCodexSessionRevision } from "../adapters/codex-session-revision.js";
6
+ import { createSlimmer } from "../slim/slim-transcript.js";
7
+ import { scanLines, decideLinePlan, writeLinePlan } from "../adapters/raw-evidence-sanitize-lines.js";
8
+ export function codexRevisionFacts(paths, mode, describedKeys, slimAware) {
9
+ const form = async (drop) => {
10
+ const source = await openCodexSessionRevision(paths);
11
+ try {
12
+ const lines = { async *lines() {
13
+ const slimmer = createSlimmer("codex");
14
+ for await (const line of source.lines()) {
15
+ if (line.start === source.length)
16
+ continue;
17
+ let text = new TextDecoder("utf-8", { fatal: true }).decode(line.bytes);
18
+ if (drop) {
19
+ const images = findEmbeddedImages(text);
20
+ text = replaceImagesWithMarkers(text, images, image => describedKeys.has(imageStateKey(image.sha256))).text;
21
+ text = slimmer.slimLine(text);
22
+ }
23
+ yield text + "\n";
24
+ }
25
+ } };
26
+ const options = { redactedFields: ["codex_jsonl.body"], secretLikeFileName: false };
27
+ const plan = decideLinePlan(await scanLines(lines, options), options);
28
+ if (plan.kind === "whole_mask" || plan.kind === "guard_line")
29
+ return null;
30
+ const hash = createHash("sha256");
31
+ let byte_size = 0;
32
+ const write = async (text) => { hash.update(text); byte_size += Buffer.byteLength(text); };
33
+ if (plan.kind === "clean") {
34
+ for await (const line of lines.lines())
35
+ await write(line);
36
+ }
37
+ else
38
+ await writeLinePlan(lines, plan, options, write);
39
+ await source.verifyUnchanged();
40
+ return { sha256: hash.digest("hex"), byte_size };
41
+ }
42
+ finally {
43
+ await source.close();
44
+ }
45
+ };
46
+ let full, slim;
47
+ return {
48
+ slimAware,
49
+ async rawSha256() { const source = await openCodexSessionRevision(paths); try {
50
+ return source.sha256;
51
+ }
52
+ finally {
53
+ await source.close();
54
+ } },
55
+ wholeForm: () => full ??= form(false),
56
+ sanitized: () => modeDropsImages(mode) ? slim ??= form(true) : full ??= form(false),
57
+ async slimSplit() {
58
+ if (!slimAware)
59
+ return null;
60
+ const [a, b] = await Promise.all([full ??= form(false), slim ??= form(true)]);
61
+ return a && b && a.sha256 !== b.sha256 ? { full: a, slim: b } : null;
62
+ },
63
+ };
64
+ }
@@ -1,4 +1,4 @@
1
- import { CODEX_HOOKS_TRUST_GUIDE_URL, setupReceiptPieces } from "@bli-cockpit/telemetry-core";
1
+ import { codexHooksTrustAction, setupReceiptPieces } from "@bli-cockpit/telemetry-core";
2
2
  import { redactedHealthDetail } from "../health-detail.js";
3
3
  import { setupReceiptBlock } from "./setup-receipt-lines.js";
4
4
  export function ok(id, code, message) {
@@ -194,7 +194,7 @@ export function doctorNeedsPerson(rows, receipt) {
194
194
  const step = key === "device" || key === "browser" ? "authed" : key === "collector.autostart" ? "autostart-alive" : "memory-registered";
195
195
  if (piece.status !== "needs_trust" && items.some((item) => item.step === step))
196
196
  continue;
197
- items.push({ step: key, reason: piece.reason ?? piece.status, action: piece.status === "needs_trust" ? `Open Codex and run /hooks to trust the installed hooks. Step by step: ${CODEX_HOOKS_TRUST_GUIDE_URL}` : defaults[step] });
197
+ items.push({ step: key, reason: piece.reason ?? piece.status, action: piece.status === "needs_trust" ? codexHooksTrustAction(piece.reason) : defaults[step] });
198
198
  }
199
199
  }
200
200
  return items;
@@ -80,6 +80,8 @@ export function reexecDoctor(command, io) {
80
80
  args.push("--no-usage-telemetry");
81
81
  if (command.noMemory)
82
82
  args.push("--no-memory");
83
+ if (command.trustCodexHooks)
84
+ args.push("--trust-codex-hooks");
83
85
  if (command.homeDir)
84
86
  args.push("--home", command.homeDir);
85
87
  if (command.backfillBudgetSeconds)
@@ -1,6 +1,7 @@
1
1
  import { checkSlackConnected } from "./doctor-slack.js";
2
2
  import { checkUsageTelemetry, fixUsageTelemetry } from "./doctor-usage-telemetry.js";
3
3
  import { settleDoctorMemoryChoice } from "./memory-capture-prompt.js";
4
+ import { settleDoctorCodexHooksTrust } from "./codex-hooks-trust-prompt.js";
4
5
  import { reportIgnoredOptOuts } from "../capture-opt-outs.js";
5
6
  import { checkShelfRouting, fixShelfRouting } from "./doctor-shelf.js";
6
7
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION } from "../local-state.js";
@@ -105,6 +106,7 @@ export async function runDoctorWithDeps(command, io, deps) {
105
106
  console.error("[doctor] row done", JSON.stringify({ row: invariant.id, elapsed_ms: rowCheckMs + fixMs, check_ms: rowCheckMs, fix_ms: fixMs, verdict: row.status, reason: row.code }));
106
107
  rows.push(row);
107
108
  }
109
+ await (deps.settleCodexHooksTrust ?? ((c) => settleDoctorCodexHooksTrust(c.command, c.io)))(context); // BLI-24349: after memory-registered, before the re-read
108
110
  // Re-read host state after repairs. Sync is proven by its upload receipts;
109
111
  // re-running its diagnostic would always request a new upload by design.
110
112
  if (!diagnoseOnly) {
@@ -96,6 +96,7 @@ const DOCTOR_ALLOWED_FLAGS = [
96
96
  "--no-repair",
97
97
  "--no-usage-telemetry",
98
98
  "--no-memory",
99
+ "--trust-codex-hooks",
99
100
  "--lock-wait",
100
101
  "--backfill-budget",
101
102
  "--json",
@@ -143,6 +144,7 @@ export function parseDoctorArgs(alias, args) {
143
144
  kind: "doctor",
144
145
  noUsageTelemetry: values.booleans.has("--no-usage-telemetry"),
145
146
  noMemory: values.booleans.has("--no-memory"),
147
+ trustCodexHooks: values.booleans.has("--trust-codex-hooks"),
146
148
  checkOnly: values.booleans.has("--check") || values.booleans.has("--no-repair"),
147
149
  // Absent stays absent (BLI-11101): `doctor-budget.ts` owns the defaults,
148
150
  // and `--json` without either flag runs under one bounded budget.
@@ -191,11 +191,13 @@ export function parseMemoryArgs(args) {
191
191
  throw new Error("memory log takes one reason or --reason-stdin, never --dry-run");
192
192
  if (verdict !== "win" && verdict !== "loss" && verdict !== "noise")
193
193
  throw new Error("memory log needs win|loss|noise");
194
- if (store !== "bli" && store !== "supermemory" && store !== "both")
195
- throw new Error("memory log needs --store bli|supermemory|both");
194
+ // BLI-24380: BLI Memory is the only store, so --store is optional (bli). The retired
195
+ // `supermemory` and `both` still parse; runMemoryLog logs them as retired, never an error.
196
+ if (store !== undefined && store !== "bli" && store !== "supermemory" && store !== "both")
197
+ throw new Error("memory log --store takes bli (the only store)");
196
198
  if (!reasonStdin && !reason.trim())
197
199
  throw new Error("memory log needs a reason");
198
- return { kind: "memory-log", verdict, store, reason, reasonStdin, homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")), json: values.booleans.has("--json") };
200
+ return { kind: "memory-log", verdict, store: store ?? "bli", reason, reasonStdin, homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")), json: values.booleans.has("--json") };
199
201
  }
200
202
  if (values.flags.has("--store") || values.booleans.has("--reason-stdin"))
201
203
  throw new Error("--store and --reason-stdin require memory log");
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The `--to` readers of `cockpit notes`: `move --to "<shelf>"` and, since BLI-24437,
3
+ * `correct <id> --from "<misheard>" --to "<right>" [--transcript]`. Sibling of
4
+ * `local-args-tower-notes.ts`, which calls both; split out so that file stays a table of contents.
5
+ */
6
+ import { optionalNonEmpty } from "./local-arg-values.js";
7
+ export function readNotesShelfTarget(action, values) {
8
+ const to = optionalNonEmpty(values.flags.get("--to"));
9
+ const clearShelf = values.booleans.has("--clear-shelf");
10
+ if (action !== "move")
11
+ return { to, clearShelf };
12
+ if (to && clearShelf) {
13
+ throw new Error("notes move accepts either --to or --clear-shelf, not both.");
14
+ }
15
+ if (!to && !clearShelf && !values.flags.get("--folder")) {
16
+ throw new Error('notes move needs --to "<shelf>", --clear-shelf, or --folder "<path>".');
17
+ }
18
+ return { to, clearShelf };
19
+ }
20
+ /** BLI-24437: `correct` needs both terms; `--from` and `--transcript` mean nothing on any other verb. */
21
+ export function readNotesCorrection(action, values) {
22
+ const from = optionalNonEmpty(values.flags.get("--from"));
23
+ const transcript = values.booleans.has("--transcript");
24
+ if (action !== "correct") {
25
+ if (from !== undefined)
26
+ throw new Error("--from belongs to `cockpit notes correct <note-id> --from <term> --to <term>`.");
27
+ if (transcript)
28
+ throw new Error("--transcript belongs to `cockpit notes correct <note-id> --from <term> --to <term> --transcript`.");
29
+ return {};
30
+ }
31
+ if (!from || !optionalNonEmpty(values.flags.get("--to"))) {
32
+ throw new Error('notes correct needs --from "<misheard term>" and --to "<right term>".');
33
+ }
34
+ return { from, ...(transcript ? { transcript: true } : {}) };
35
+ }
@@ -7,6 +7,7 @@
7
7
  * address they have always had.
8
8
  */
9
9
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
10
+ import { readNotesCorrection, readNotesShelfTarget } from "./local-args-notes-targets.js";
10
11
  /**
11
12
  * The notetakers `cockpit notes connect` will actually seal a key for
12
13
  * (BLI-4391). It mirrors `CONNECTABLE_NOTES_PROVIDERS` in the dashboard, and
@@ -34,7 +35,7 @@ const NOTES_ACTIONS = new Set([
34
35
  "upload",
35
36
  "paste",
36
37
  "share",
37
- "unshare",
38
+ "unshare", "correct",
38
39
  "summarize",
39
40
  "move",
40
41
  "place",
@@ -61,7 +62,7 @@ const NOTES_ACTIONS = new Set([
61
62
  const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
62
63
  "show",
63
64
  "share",
64
- "unshare",
65
+ "unshare", "correct",
65
66
  "summarize",
66
67
  "move",
67
68
  "place",
@@ -82,7 +83,7 @@ const NOTES_ALLOWED_FLAGS = [
82
83
  "--file",
83
84
  "--name",
84
85
  "--exclude",
85
- "--to",
86
+ "--to", "--from", "--transcript", // BLI-24437: `correct <id> --from <term> --to <term> [--transcript]`
86
87
  "--folder",
87
88
  "--clear-shelf",
88
89
  "--apply",
@@ -122,7 +123,7 @@ const NOTES_VALUE_FLAGS = [
122
123
  "--file",
123
124
  "--name",
124
125
  "--exclude",
125
- "--to",
126
+ "--to", "--from",
126
127
  "--folder",
127
128
  "--series",
128
129
  "--kind",
@@ -180,6 +181,7 @@ export function parseNotesArgs(args) {
180
181
  exclude: optionalNonEmpty(values.flags.get("--exclude")),
181
182
  ...(to ? { to } : {}),
182
183
  ...(clearShelf ? { clearShelf } : {}),
184
+ ...readNotesCorrection(action, values),
183
185
  // BLI-4058. `place` says where a note belongs; only `--apply` moves it.
184
186
  ...(values.booleans.has("--apply") ? { apply: true } : {}),
185
187
  series: optionalNonEmpty(values.flags.get("--series")),
@@ -201,7 +203,7 @@ function readNotesAction(first) {
201
203
  // almost always wants — same reasoning as bare `cockpit` running convergence.
202
204
  const action = (first === undefined ? "list" : first);
203
205
  if (!NOTES_ACTIONS.has(action)) {
204
- throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, summarize, move, place, accounts, connect, detach, sync, sync-status, recordings, recording, webhook, granola-status, transcription-stats, or review (and record start|stop|status|cancel, app install|uninstall|status).`);
206
+ throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, correct, summarize, move, place, accounts, connect, detach, sync, sync-status, recordings, recording, webhook, granola-status, transcription-stats, or review (and record start|stop|status|cancel, app install|uninstall|status).`);
205
207
  }
206
208
  return action;
207
209
  }
@@ -368,19 +370,6 @@ function readNotesPaths(action, rest) {
368
370
  }
369
371
  return undefined;
370
372
  }
371
- function readNotesShelfTarget(action, values) {
372
- const to = optionalNonEmpty(values.flags.get("--to"));
373
- const clearShelf = values.booleans.has("--clear-shelf");
374
- if (action !== "move")
375
- return { to, clearShelf };
376
- if (to && clearShelf) {
377
- throw new Error("notes move accepts either --to or --clear-shelf, not both.");
378
- }
379
- if (!to && !clearShelf && !values.flags.get("--folder")) {
380
- throw new Error('notes move needs --to "<shelf>", --clear-shelf, or --folder "<path>".');
381
- }
382
- return { to, clearShelf };
383
- }
384
373
  function readNotesMonth(action, values) {
385
374
  const month = optionalNonEmpty(values.flags.get("--month"));
386
375
  if (month === undefined)