@bli-cockpit/cli 0.2.58 → 0.2.60

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 (43) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/browser-open.js +88 -0
  3. package/dist/commands/docs.js +32 -6
  4. package/dist/commands/doctor-report.js +17 -1
  5. package/dist/commands/doctor.js +12 -2
  6. package/dist/commands/heartbeat.js +65 -1
  7. package/dist/commands/jarvis-answer-envelope.js +82 -0
  8. package/dist/commands/jarvis-render.js +18 -0
  9. package/dist/commands/jarvis-turn.js +29 -2
  10. package/dist/commands/jarvis.js +3 -0
  11. package/dist/commands/local-args-collector-setup.js +17 -0
  12. package/dist/commands/local-args-tower-admin.js +12 -2
  13. package/dist/commands/local-args-tower-chat.js +5 -0
  14. package/dist/commands/local-args-tower-docs-msg.js +105 -6
  15. package/dist/commands/local-args-tower-search.js +50 -0
  16. package/dist/commands/local-args-tower.js +4 -1
  17. package/dist/commands/local-args.js +28 -13
  18. package/dist/commands/local-command-shapes.js +12 -0
  19. package/dist/commands/local-help-commands.js +655 -0
  20. package/dist/commands/local-help.js +11 -582
  21. package/dist/commands/local.js +3 -0
  22. package/dist/commands/login.js +91 -8
  23. package/dist/commands/memory-install-claude.js +35 -15
  24. package/dist/commands/memory-install-codex-hooks.js +200 -0
  25. package/dist/commands/memory-install-codex.js +12 -2
  26. package/dist/commands/memory-install-receipt.js +222 -0
  27. package/dist/commands/memory-install-report.js +25 -1
  28. package/dist/commands/memory-install.js +76 -2
  29. package/dist/commands/msg.js +85 -2
  30. package/dist/commands/onboard-completion.js +47 -0
  31. package/dist/commands/onboard-setup.js +82 -2
  32. package/dist/commands/ops-render-memory.js +76 -0
  33. package/dist/commands/ops-render.js +6 -0
  34. package/dist/commands/ops.js +59 -2
  35. package/dist/commands/public-root.js +1 -1
  36. package/dist/commands/search.js +122 -0
  37. package/dist/commands/setup-receipt-lines.js +71 -0
  38. package/dist/commands/setup-receipt.js +241 -0
  39. package/dist/commands/status.js +20 -1
  40. package/dist/commands/tower-mcp-install.js +4 -2
  41. package/dist/local-state-pairing-code.js +200 -0
  42. package/dist/local-state.js +6 -0
  43. package/package.json +4 -4
@@ -36,6 +36,7 @@ import { runDocs } from "./docs.js";
36
36
  import { runMsg } from "./msg.js";
37
37
  import { runIssue } from "./issue.js";
38
38
  import { runProject } from "./project.js";
39
+ import { runSearch } from "./search.js";
39
40
  import { parseLocalArgs } from "./local-args.js";
40
41
  // `./local.js` is the published entry point for this command surface: the
41
42
  // public CLI's generated root, commands/root.ts, doctor.ts and the test suite
@@ -143,6 +144,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
143
144
  return await runIssue(command, io);
144
145
  case "project":
145
146
  return await runProject(command, io);
147
+ case "search":
148
+ return await runSearch(command, io);
146
149
  case "release":
147
150
  return await runRelease(command, io);
148
151
  }
@@ -1,6 +1,9 @@
1
- import { writeLine } from "./cli-io.js";
1
+ import { openInBrowser } from "./browser-open.js";
2
+ import { errorMessage, writeLine } from "./cli-io.js";
2
3
  import { pairLocalCollectorWithAuthFallback, requestPairingAccessToken, resolveInteractiveLoginEmail, } from "./local-auth.js";
3
4
  import { ensureLocalCollectorConfig } from "../local-state.js";
5
+ import { pairLocalCollectorViaLink, } from "../local-state-pairing-code.js";
6
+ const TAG = "[login]";
4
7
  export async function runLogin(command, io) {
5
8
  // Standalone `cockpit login` must work on a fresh machine: bootstrap a
6
9
  // minimal rootless config when onboard/install has not run yet, instead of
@@ -13,6 +16,23 @@ export async function runLogin(command, io) {
13
16
  if (created && !command.json) {
14
17
  writeLine(io.stdout, `First login on this machine; wrote collector config: ${paths.config_file}`);
15
18
  }
19
+ if (!command.legacyPair) {
20
+ const linked = await tryOneLinkLogin(command, dashboardUrl, io);
21
+ if (linked) {
22
+ reportPaired(command, io, {
23
+ email: linked.session.email ?? linked.session.auth_subject_id,
24
+ deviceName: linked.session.device_name ?? linked.session.device_id ?? "unknown",
25
+ sessionFile: linked.session_file,
26
+ json: linked,
27
+ });
28
+ writeNextStep(command, io, config.default_repo_paths.length > 0);
29
+ return 0;
30
+ }
31
+ // `--pair <code>` is a code somebody minted on purpose; falling back to a
32
+ // second sign-in ceremony would be answering a different question.
33
+ if (command.pairCode)
34
+ return 1;
35
+ }
16
36
  const claimedOwnerEmail = await resolveInteractiveLoginEmail(command, io);
17
37
  const pairingAccessToken = await requestPairingAccessToken({
18
38
  dashboardUrl,
@@ -38,16 +58,79 @@ export async function runLogin(command, io) {
38
58
  writeLine(io.stdout, "Waiting for dashboard approval...");
39
59
  },
40
60
  }, io);
61
+ reportPaired(command, io, {
62
+ email: result.session.email ?? result.session.auth_subject_id,
63
+ deviceName: result.session.device_name ?? result.session.device_id ?? "unknown",
64
+ sessionFile: result.session_file,
65
+ json: result,
66
+ });
67
+ writeNextStep(command, io, config.default_repo_paths.length > 0);
68
+ return 0;
69
+ }
70
+ /**
71
+ * The one-link ceremony. Returns null when it could not finish, having already
72
+ * said why — the caller then runs the legacy arm rather than stopping, because
73
+ * a machine that cannot pair is worse than a machine that pairs the old way.
74
+ */
75
+ async function tryOneLinkLogin(command, dashboardUrl, io) {
76
+ try {
77
+ return await pairLocalCollectorViaLink({
78
+ homeDir: command.homeDir,
79
+ dashboardUrl,
80
+ deviceName: command.deviceName,
81
+ claimedOwnerEmail: command.claimedOwnerEmail,
82
+ pairCode: command.pairCode,
83
+ pollIntervalMs: command.pollIntervalMs,
84
+ timeoutMs: command.timeoutMs,
85
+ fetch: io.fetch,
86
+ onLinkReady: command.json
87
+ ? undefined
88
+ : (link) => announceLink(command, io, link),
89
+ });
90
+ }
91
+ catch (error) {
92
+ const reason = error && typeof error === "object" && "reason" in error
93
+ ? String(error.reason)
94
+ : "pair_link_failed";
95
+ console.error(`${TAG} one-link pairing did not finish`, JSON.stringify({ reason, falling_back: !command.pairCode }));
96
+ writeLine(io.stderr, `Sign-in link did not finish: ${errorMessage(error)}`);
97
+ writeLine(io.stderr, command.pairCode
98
+ ? "That code was minted in a browser; run `cockpit login` with no flags to start over."
99
+ : "Falling back to the email-code sign-in.");
100
+ return null;
101
+ }
102
+ }
103
+ /** Print the link, then try to open it. Printing first is what makes the open optional. */
104
+ function announceLink(command, io, link) {
105
+ writeLine(io.stdout, "Sign in to Tower once, and this machine is connected.");
106
+ writeLine(io.stdout, `Open: ${link.connect_url}`);
107
+ writeLine(io.stdout, "Waiting for that sign-in...");
108
+ if (command.noBrowser) {
109
+ console.error(`${TAG} browser not opened`, JSON.stringify({ reason: "no_browser_flag" }));
110
+ return;
111
+ }
112
+ const opened = openInBrowser(link.connect_url);
113
+ console.error(`${TAG} browser open attempted`, JSON.stringify(opened.ok
114
+ ? { reason: opened.reason, program: opened.program }
115
+ : { reason: opened.reason }));
116
+ if (!opened.ok) {
117
+ writeLine(io.stdout, `(${opened.detail} The link above still works.)`);
118
+ }
119
+ }
120
+ function reportPaired(command, io, paired) {
41
121
  if (command.json) {
42
- writeLine(io.stdout, JSON.stringify(result, null, 2));
43
- return 0;
122
+ writeLine(io.stdout, JSON.stringify(paired.json, null, 2));
123
+ return;
44
124
  }
45
125
  writeLine(io.stdout, "Tower collector paired.");
46
- writeLine(io.stdout, `Session: ${result.session_file}`);
47
- writeLine(io.stdout, `User: ${result.session.email ?? result.session.auth_subject_id}`);
48
- writeLine(io.stdout, `Device: ${result.session.device_name ?? result.session.device_id ?? "unknown"}`);
49
- writeLine(io.stdout, config.default_repo_paths.length > 0
126
+ writeLine(io.stdout, `Session: ${paired.sessionFile}`);
127
+ writeLine(io.stdout, `User: ${paired.email}`);
128
+ writeLine(io.stdout, `Device: ${paired.deviceName}`);
129
+ }
130
+ function writeNextStep(command, io, hasRepoPaths) {
131
+ if (command.json)
132
+ return;
133
+ writeLine(io.stdout, hasRepoPaths
50
134
  ? "Next: run `cockpit start` inside the repo."
51
135
  : "Next: run `cockpit onboard` from your repo root so Tower knows which repos to collect.");
52
- return 0;
53
136
  }
@@ -185,13 +185,21 @@ export function mcpServerMatches(root, config) {
185
185
  * Claude Code's hook shape, verbatim: `hooks.<Event>` is an ARRAY of matcher
186
186
  * groups, each `{ matcher?, hooks: [{ type: "command", command, timeout? }] }`.
187
187
  * The matcher is omitted on purpose — all three events want every source.
188
+ *
189
+ * Exported for `memory-install-codex-hooks.ts` (BLI-3729): Codex's own
190
+ * `hooks.json` uses THE SAME three levels, the same event names and the same
191
+ * `type: "command"` handler (learn.chatgpt.com/docs/hooks, read 2026-09-05), so
192
+ * the merge that keeps a person's own hooks and replaces exactly ours has one
193
+ * owner rather than two copies that drift.
188
194
  */
189
- function applyHooksAndPermissions(root, config) {
195
+ export function applyHookGroups(root, hookSpecs) {
190
196
  const hooks = asRecord(root["hooks"]) ?? {};
191
- for (const spec of config.hooks) {
197
+ for (const spec of hookSpecs) {
192
198
  const groups = Array.isArray(hooks[spec.event])
193
199
  ? hooks[spec.event].slice()
194
200
  : [];
201
+ // Somebody else's Stop hook survives untouched; only OUR entries — matched
202
+ // by bin name plus `hook`, wherever the bin ended up — are replaced.
195
203
  const survivors = groups.filter((group) => !groupIsOurs(group));
196
204
  survivors.push({
197
205
  hooks: [
@@ -205,6 +213,26 @@ function applyHooksAndPermissions(root, config) {
205
213
  hooks[spec.event] = survivors;
206
214
  }
207
215
  root["hooks"] = hooks;
216
+ return root;
217
+ }
218
+ /** Exported for `memory-install-codex-hooks.ts` — see `applyHookGroups`. */
219
+ export function hookGroupsMatch(root, hookSpecs) {
220
+ const hooks = asRecord(root["hooks"]);
221
+ if (!hooks)
222
+ return false;
223
+ for (const spec of hookSpecs) {
224
+ const groups = Array.isArray(hooks[spec.event]) ? hooks[spec.event] : [];
225
+ const ours = groups.filter(groupIsOurs);
226
+ if (ours.length !== 1)
227
+ return false;
228
+ const commands = hookCommands(ours[0]);
229
+ if (commands.length !== 1 || commands[0] !== spec.command)
230
+ return false;
231
+ }
232
+ return true;
233
+ }
234
+ function applyHooksAndPermissions(root, config) {
235
+ applyHookGroups(root, config.hooks);
208
236
  if (config.permissions_allow.length > 0) {
209
237
  const permissions = asRecord(root["permissions"]) ?? {};
210
238
  const allow = Array.isArray(permissions["allow"])
@@ -220,23 +248,14 @@ function applyHooksAndPermissions(root, config) {
220
248
  return root;
221
249
  }
222
250
  function hooksAndPermissionsMatch(root, config) {
223
- const hooks = asRecord(root["hooks"]);
224
- if (!hooks)
251
+ if (!hookGroupsMatch(root, config.hooks))
225
252
  return false;
226
- for (const spec of config.hooks) {
227
- const groups = Array.isArray(hooks[spec.event]) ? hooks[spec.event] : [];
228
- const ours = groups.filter(groupIsOurs);
229
- if (ours.length !== 1)
230
- return false;
231
- const commands = hookCommands(ours[0]);
232
- if (commands.length !== 1 || commands[0] !== spec.command)
233
- return false;
234
- }
235
253
  const allow = asRecord(root["permissions"])?.["allow"];
236
254
  const allowed = Array.isArray(allow) ? allow : [];
237
255
  return config.permissions_allow.every((rule) => allowed.includes(rule));
238
256
  }
239
- function ourHookCount(root) {
257
+ /** Exported for `memory-install-codex-hooks.ts` — see `applyHookGroups`. */
258
+ export function ourHookCount(root) {
240
259
  const hooks = asRecord(root["hooks"]);
241
260
  if (!hooks)
242
261
  return 0;
@@ -262,7 +281,8 @@ function hookCommands(group) {
262
281
  function failure(target, file, reason, detail) {
263
282
  return { target, status: "failed", reason, path: file, detail };
264
283
  }
265
- function parseJsonRecord(raw) {
284
+ /** Exported for `memory-install-codex-hooks.ts` — one JSON-record parser, not two. */
285
+ export function parseJsonRecord(raw) {
266
286
  try {
267
287
  const parsed = JSON.parse(raw);
268
288
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
@@ -0,0 +1,200 @@
1
+ /**
2
+ * CODEX HOOK PARITY (BLI-3729) — recall on every prompt and save on every turn
3
+ * for Codex, the way Claude Code has had since BLI-3580.
4
+ *
5
+ * ## What the platform actually offers, fact-checked
6
+ *
7
+ * Source: OpenAI's own hooks reference, <https://learn.chatgpt.com/docs/hooks>,
8
+ * read 2026-09-05, against `codex-cli 0.144.1`. Four findings, all of which
9
+ * changed the shape of this module:
10
+ *
11
+ * 1. **User-scope hooks exist.** Codex discovers hooks "next to active config
12
+ * layers", and names `~/.codex/hooks.json` as one of the four useful
13
+ * locations. "In untrusted projects, Codex still loads user and system
14
+ * hooks from their own active config layers." So this installs ONE file in
15
+ * the person's home directory — it does NOT walk their collection roots
16
+ * writing a `.codex/hooks.json` into every repo, which would put an
17
+ * untracked file in somebody's working tree and need trusting once per
18
+ * repo forever.
19
+ * 2. **The file shape is Claude Code's, verbatim.** Event → array of matcher
20
+ * groups → `hooks: [{ type: "command", command, timeout }]`, with the same
21
+ * event NAMES: `SessionStart`, `UserPromptSubmit`, `Stop`. That is why the
22
+ * merge here is `applyHookGroups` from `memory-install-claude.ts` and not
23
+ * a second copy: same contract, one owner.
24
+ * 3. **Hooks are on by default now.** `[features] hooks = false` turns them
25
+ * off; `codex_hooks` is a DEPRECATED ALIAS for the same key. The premise
26
+ * that a person must first opt in with `codex_hooks = true` is stale — it
27
+ * was true of the version whose trust rows are still sitting in Edward's
28
+ * `config.toml`, and it is not true of 0.144.1.
29
+ * 4. **Trust is per hook definition, and it is the whole catch.** "Before a
30
+ * non-managed command hook can run, Codex requires you to review and trust
31
+ * the exact hook definition. Codex records trust against the hook's current
32
+ * hash, so new or changed hooks are marked for review and skipped until
33
+ * trusted." `/hooks` in the CLI is that surface. So a machine can be
34
+ * perfectly installed and running nothing, which is exactly the state
35
+ * `needs_trust` exists to name — and it is the one step an intern has to
36
+ * do by hand.
37
+ *
38
+ * ## What is NOT verified
39
+ *
40
+ * Nobody has yet trusted these hooks on a real machine, so the wiring past the
41
+ * write is unproven end to end: the file shape, the event names and the trust
42
+ * mechanism come from the vendor's current documentation and from trust rows
43
+ * this repo's owner's own machine already carries, not from a green run. The
44
+ * receipt is built so that failure is visible rather than assumed away — an
45
+ * untrusted machine reads `needs_trust`, never `ok`.
46
+ *
47
+ * ## The trust read is a FLOOR, deliberately
48
+ *
49
+ * A trust row is `[hooks.state."<source>:<event>:<group>:<hook>"]` carrying
50
+ * `trusted_hash` and sometimes `enabled`. This module cannot recompute Codex's
51
+ * hash, so it cannot prove the stored hash still matches the file we just
52
+ * wrote. It counts rows for OUR hooks file that are not explicitly disabled,
53
+ * and reports `ok` only when there are at least as many as we installed hooks.
54
+ * That can understate (a trusted machine whose row keys we failed to match
55
+ * reads `needs_trust`, and the fix is one `/hooks` visit) and cannot overstate.
56
+ * A gauge that can only be too pessimistic is the right way round for this one.
57
+ */
58
+ import path from "node:path";
59
+ import { applyHookGroups, applyJsonTarget, hookGroupsMatch, inspectJsonTarget, ourHookCount, } from "./memory-install-claude.js";
60
+ export function codexHooksFile(homeDir) {
61
+ return path.join(homeDir, ".codex", "hooks.json");
62
+ }
63
+ export function codexConfigTomlFile(homeDir) {
64
+ return path.join(homeDir, ".codex", "config.toml");
65
+ }
66
+ export async function installCodexMemoryHooks(options) {
67
+ return applyJsonTarget({
68
+ target: "codex_hooks",
69
+ file: codexHooksFile(options.homeDir),
70
+ options,
71
+ apply: (root) => applyHookGroups(root, options.config.hooks),
72
+ matches: (root) => hookGroupsMatch(root, options.config.hooks),
73
+ });
74
+ }
75
+ export async function inspectCodexMemoryHooks(options) {
76
+ return inspectJsonTarget({
77
+ target: "codex_hooks",
78
+ file: codexHooksFile(options.homeDir),
79
+ io: options.io,
80
+ present: (root) => ourHookCount(root) > 0,
81
+ matches: (root) => hookGroupsMatch(root, options.config.hooks),
82
+ });
83
+ }
84
+ /**
85
+ * Reads the trust state out of `~/.codex/config.toml` WITHOUT parsing the
86
+ * whole file.
87
+ *
88
+ * That file is a person's own configuration — model, approval policy, sandbox,
89
+ * their other servers — and this module has no business understanding all of
90
+ * it. It walks lines, tracks the current table header, and looks at exactly
91
+ * two things: the `hooks` feature key, and `[hooks.state."…"]` headers whose
92
+ * quoted source is our hooks file. Nothing is written here, ever.
93
+ *
94
+ * The trust key's event segment is matched case-INSENSITIVELY and by prefix
95
+ * only: an older Codex spelled the events `user_prompt_submit`, the current
96
+ * one spells them `UserPromptSubmit`, and a reader that insisted on one
97
+ * spelling would report a trusted machine as untrusted the day the vendor
98
+ * changed its mind again.
99
+ */
100
+ export function readCodexHookTrust(raw, hooksFilePath) {
101
+ if (raw === null) {
102
+ // No config.toml at all. Hooks are on by default, and nothing has been
103
+ // trusted, which is exactly `enabled` + zero rows.
104
+ return { feature: "enabled", trustedRows: 0, disabledRows: 0 };
105
+ }
106
+ const wanted = normalizeTrustSource(hooksFilePath);
107
+ let feature = "enabled";
108
+ let currentTable = "";
109
+ let inOurTrustTable = false;
110
+ let sawTrustedHash = false;
111
+ let sawDisabled = false;
112
+ let trustedRows = 0;
113
+ let disabledRows = 0;
114
+ const flushTrustRow = () => {
115
+ if (!inOurTrustTable || !sawTrustedHash)
116
+ return;
117
+ if (sawDisabled)
118
+ disabledRows += 1;
119
+ else
120
+ trustedRows += 1;
121
+ };
122
+ for (const line of raw.split("\n")) {
123
+ const header = /^\s*\[+([^\]]+)\]+\s*$/u.exec(line);
124
+ if (header) {
125
+ flushTrustRow();
126
+ currentTable = (header[1] ?? "").trim();
127
+ inOurTrustTable = trustTableNamesOurFile(currentTable, wanted);
128
+ sawTrustedHash = false;
129
+ sawDisabled = false;
130
+ continue;
131
+ }
132
+ const assignment = /^\s*([A-Za-z0-9_-]+)\s*=\s*(.+?)\s*$/u.exec(line);
133
+ if (!assignment)
134
+ continue;
135
+ const key = (assignment[1] ?? "").toLowerCase();
136
+ const value = (assignment[2] ?? "").toLowerCase();
137
+ // `[features] hooks = false`, and its deprecated alias `codex_hooks`.
138
+ if (currentTable === "features" && (key === "hooks" || key === "codex_hooks")) {
139
+ if (value.startsWith("false"))
140
+ feature = "disabled";
141
+ continue;
142
+ }
143
+ if (!inOurTrustTable)
144
+ continue;
145
+ if (key === "trusted_hash")
146
+ sawTrustedHash = true;
147
+ if (key === "enabled" && value.startsWith("false"))
148
+ sawDisabled = true;
149
+ }
150
+ flushTrustRow();
151
+ return { feature, trustedRows, disabledRows };
152
+ }
153
+ /** Reads the person's `config.toml` through the injected io and judges trust. */
154
+ export async function readCodexHookTrustFromDisk(options) {
155
+ const file = codexConfigTomlFile(options.homeDir);
156
+ try {
157
+ const raw = await options.io.readText(file);
158
+ return readCodexHookTrust(raw, codexHooksFile(options.homeDir));
159
+ }
160
+ catch {
161
+ // An unreadable config is not an untrusted machine, and must not be
162
+ // reported as one — `config_unreadable` says which question could not be
163
+ // answered rather than answering it wrongly.
164
+ return { feature: "config_unreadable", trustedRows: 0, disabledRows: 0 };
165
+ }
166
+ }
167
+ /**
168
+ * `"/Users/x/.codex/hooks.json"` → the same string, case-folded on Windows
169
+ * only and with separators normalized, so the header we scan for compares
170
+ * equal to the path we wrote whichever way either was spelled.
171
+ */
172
+ function normalizeTrustSource(value) {
173
+ const unquoted = value.replace(/^["']|["']$/gu, "");
174
+ const slashed = unquoted.replace(/\\/gu, "/");
175
+ return process.platform === "win32" ? slashed.toLowerCase() : slashed;
176
+ }
177
+ /**
178
+ * `hooks.state."<source>:<event>:<group>:<hook>"` — is `<source>` our file?
179
+ *
180
+ * The key is split on the LAST three colons, because a Windows source is
181
+ * `C:/Users/…` and carries one of its own.
182
+ */
183
+ function trustTableNamesOurFile(tableHeader, wantedSource) {
184
+ const match = /^hooks\.state\.\s*["']([\s\S]+)["']\s*$/u.exec(tableHeader.trim());
185
+ if (!match)
186
+ return false;
187
+ const key = match[1] ?? "";
188
+ const segments = key.split(":");
189
+ if (segments.length < 4)
190
+ return false;
191
+ const source = segments.slice(0, segments.length - 3).join(":");
192
+ return normalizeTrustSource(source) === wantedSource;
193
+ }
194
+ /**
195
+ * How many hook entries a trusted machine should have rows for. One per event,
196
+ * which is what `applyHookGroups` writes.
197
+ */
198
+ export function expectedCodexTrustRows(config) {
199
+ return config.hooks.length;
200
+ }
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * The Codex half of `cockpit memory install` (BLI-3580).
3
3
  *
4
- * TWO targets:
4
+ * THREE targets:
5
5
  *
6
6
  * ~/.codex/config.toml one table, `[mcp_servers.bli-memory]`.
7
7
  * ~/.codex/skills/bli-memory/ the skill that teaches Codex when to
8
8
  * recall, save, update and forget — taking
9
9
  * over the job the four `supermemory-*`
10
10
  * skills did on Edward's machine.
11
+ * ~/.codex/hooks.json the same three lifecycle hooks Claude Code
12
+ * gets, at USER scope (BLI-3729). Owned by
13
+ * `memory-install-codex-hooks.ts`, which
14
+ * carries the vendor citation and the
15
+ * per-hook TRUST story — the one step this
16
+ * installer cannot do for a person.
11
17
  *
12
18
  * **`config.toml` is somebody's own configuration and is treated that way.**
13
19
  * The standing rule for that file is "never change HIS settings"; this respects
@@ -25,6 +31,7 @@ import path from "node:path";
25
31
  import { MEMORY_MCP_SERVER_ID, } from "./memory-install-contract.js";
26
32
  import { findTomlTableSpan, readTomlTable, renderTomlTable, upsertTomlTable, } from "./memory-install-toml.js";
27
33
  import { memoryCodexSkillFiles } from "./memory-install-skills.js";
34
+ import { installCodexMemoryHooks, inspectCodexMemoryHooks, } from "./memory-install-codex-hooks.js";
28
35
  const MEMORY_TOML_PATH = ["mcp_servers", MEMORY_MCP_SERVER_ID];
29
36
  export function codexConfigFile(homeDir) {
30
37
  return path.join(homeDir, ".codex", "config.toml");
@@ -36,6 +43,9 @@ export async function installCodexMemoryIntegration(options) {
36
43
  return [
37
44
  await applyCodexMcpTable(options),
38
45
  await applyCodexSkills(options),
46
+ // BLI-3729. Independent of the two above: a machine with the MCP table and
47
+ // no hooks is a real machine, and it is the common one today.
48
+ await installCodexMemoryHooks(options),
39
49
  ];
40
50
  }
41
51
  export async function inspectCodexMemoryIntegration(options) {
@@ -72,7 +82,7 @@ export async function inspectCodexMemoryIntegration(options) {
72
82
  path: directory,
73
83
  detail: `${current} of ${total} skill files current`,
74
84
  };
75
- return [mcp, skills];
85
+ return [mcp, skills, await inspectCodexMemoryHooks(options)];
76
86
  }
77
87
  async function applyCodexMcpTable(options) {
78
88
  const file = codexConfigFile(options.homeDir);