vexp-cli 3.2.5 → 3.3.0

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.
@@ -0,0 +1,608 @@
1
+ /**
2
+ * Does Codex run vexp's project hook? Read from Codex's own records.
3
+ *
4
+ * Codex runs a hook from a project's `.codex/hooks.json` only when two things
5
+ * hold, and neither is visible from the project:
6
+ *
7
+ * 1. the project is trusted: `[projects."<path>"] trust_level = "trusted"`
8
+ * in `$CODEX_HOME/config.toml` (an untrusted or unknown project's layer is
9
+ * loaded disabled, and its hooks are not even listed);
10
+ * 2. the hook itself is approved: `[hooks.state."<key>"] trusted_hash =
11
+ * "sha256:..."` in the same file, where the key names the hooks.json and
12
+ * the hook's position in it, and the hash is over its normalised
13
+ * definition. A different hash reads as "modified" and does not run.
14
+ *
15
+ * Codex writes them when the user answers: the folder question at startup,
16
+ * `/hooks` in the terminal UI, or Trust in the VS Code Codex extension
17
+ * (Codex panel > Settings > Hooks). vexp never writes them on the user's
18
+ * behalf. This module only reads them, so doctor can say which step is
19
+ * missing, and says plainly when it cannot tell.
20
+ *
21
+ * On Windows one approval does not cover both places: the key spells the
22
+ * folder as the session's cwd does, and a terminal says `C:\` where the VS
23
+ * Code extension says `c:\` (hooks/list on codex.exe 0.155), so doctor
24
+ * checks the two spellings apart. Project trust can also come from config
25
+ * layers doctor does not read (the system config, the legacy managed
26
+ * config); when one exists and the user config says nothing, doctor says it
27
+ * cannot tell.
28
+ *
29
+ * Every rule below is from the Codex source at openai/codex c98e263
30
+ * (unchanged on main at b35a7af, 2026-09-25):
31
+ * - key: hooks/src/lib.rs hook_key; hooks/src/engine/discovery.rs (key_source
32
+ * is the hooks.json path as displayed, spelled like the thread cwd)
33
+ * - hash: discovery.rs hook_hash + normalize_command_hook;
34
+ * config/src/fingerprint.rs version_for_toml
35
+ * - state: hooks/src/config_rules.rs (user layer only; keys trimmed)
36
+ * - trust: config/src/loader/mod.rs decision_for_dir,
37
+ * normalized_project_trust_keys, project_trust_for_lookup_key
38
+ * - layers: config/src/loader/mod.rs load_config_layers_state (system
39
+ * /etc/codex/config.toml, %ProgramData%\OpenAI\Codex\config.toml);
40
+ * layer_io.rs managed_config_default_path (/etc/codex/managed_config.toml
41
+ * on Unix only)
42
+ * - flag: features/src/legacy.rs (`codex_hooks` is the old name of `hooks`)
43
+ */
44
+ import * as crypto from "crypto";
45
+ import * as fs from "fs";
46
+ import * as os from "os";
47
+ import * as path from "path";
48
+ /** `$CODEX_HOME`, else `~/.codex`: where Codex keeps its user config. */
49
+ export function codexHome(env = process.env) {
50
+ const fromEnv = env.CODEX_HOME?.trim();
51
+ return fromEnv ? fromEnv : path.join(os.homedir(), ".codex");
52
+ }
53
+ // ---------------------------------------------------------------------------
54
+ // The hook's trust identity
55
+ // ---------------------------------------------------------------------------
56
+ /** The fields of a command handler Codex reads (hook_config.rs HookHandlerConfig::Command). */
57
+ const HANDLER_KEYS = new Set([
58
+ "type",
59
+ "command",
60
+ "commandWindows",
61
+ "command_windows",
62
+ "timeout",
63
+ "async",
64
+ "statusMessage",
65
+ "additionalContextLimit",
66
+ ]);
67
+ /** Codex's default additionalContext spill threshold; a limit equal to it is not hashed. */
68
+ const DEFAULT_CONTEXT_LIMIT = 2500;
69
+ /** JSON with every object's keys in sorted order, as serde_json writes a canonical value. */
70
+ function canonicalJson(v) {
71
+ if (Array.isArray(v))
72
+ return `[${v.map(canonicalJson).join(",")}]`;
73
+ if (v && typeof v === "object") {
74
+ const o = v;
75
+ return `{${Object.keys(o)
76
+ .sort()
77
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`)
78
+ .join(",")}}`;
79
+ }
80
+ return JSON.stringify(v);
81
+ }
82
+ /**
83
+ * The hash Codex records when the user approves a UserPromptSubmit command
84
+ * hook, or undefined when this handler has anything doctor cannot vouch for
85
+ * reproducing exactly.
86
+ *
87
+ * Codex hashes the normalised handler, not the file's text: the command the
88
+ * platform runs (`commandWindows`, when set, on Windows; `command` elsewhere),
89
+ * the timeout (600 when unset), `async`, and `statusMessage` /
90
+ * `additionalContextLimit` when set. `commandWindows` itself is never hashed,
91
+ * so adding it changes nothing off Windows.
92
+ */
93
+ export function codexHookHash(handler, platform = process.platform) {
94
+ if (Object.keys(handler).some((k) => !HANDLER_KEYS.has(k)))
95
+ return undefined;
96
+ if (handler.type !== "command" || typeof handler.command !== "string")
97
+ return undefined;
98
+ const windowsCommand = handler.commandWindows ?? handler.command_windows;
99
+ if (windowsCommand !== undefined && typeof windowsCommand !== "string")
100
+ return undefined;
101
+ const command = platform === "win32" && typeof windowsCommand === "string" ? windowsCommand : handler.command;
102
+ if (command.trim() === "")
103
+ return undefined;
104
+ const timeout = handler.timeout ?? 600;
105
+ if (typeof timeout !== "number" || !Number.isSafeInteger(timeout) || timeout < 0)
106
+ return undefined;
107
+ const isAsync = handler.async ?? false;
108
+ if (typeof isAsync !== "boolean")
109
+ return undefined;
110
+ const normalized = { type: "command", command, timeout: Math.max(timeout, 1), async: isAsync };
111
+ if (handler.statusMessage !== undefined) {
112
+ if (typeof handler.statusMessage !== "string")
113
+ return undefined;
114
+ normalized.statusMessage = handler.statusMessage;
115
+ }
116
+ if (handler.additionalContextLimit !== undefined) {
117
+ const limit = handler.additionalContextLimit;
118
+ if (typeof limit !== "number" || !Number.isSafeInteger(limit) || limit < 0)
119
+ return undefined;
120
+ if (limit !== DEFAULT_CONTEXT_LIMIT)
121
+ normalized.additionalContextLimit = limit;
122
+ }
123
+ const identity = { event_name: "user_prompt_submit", hooks: [normalized] };
124
+ return "sha256:" + crypto.createHash("sha256").update(canonicalJson(identity)).digest("hex");
125
+ }
126
+ /** The key of Codex's trust record for one hook: `<hooks.json>:user_prompt_submit:<group>:<handler>`. */
127
+ export function codexHookKey(hooksJsonPath, groupIndex, handlerIndex) {
128
+ return `${hooksJsonPath}:user_prompt_submit:${groupIndex}:${handlerIndex}`;
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Reading $CODEX_HOME/config.toml
132
+ // ---------------------------------------------------------------------------
133
+ /**
134
+ * A TOML reader for the parts of Codex's config doctor needs: tables, array
135
+ * tables, dotted and quoted keys, strings (all four forms), booleans,
136
+ * numbers, arrays and inline tables. It throws on anything it does not
137
+ * understand; a caller that catches that says "cannot tell", never guesses.
138
+ */
139
+ export function parseToml(text) {
140
+ const root = {};
141
+ let current = root;
142
+ let i = 0;
143
+ const s = text.replace(/^\uFEFF/, "");
144
+ const fail = (msg) => {
145
+ const line = s.slice(0, i).split("\n").length;
146
+ throw new Error(`line ${line}: ${msg}`);
147
+ };
148
+ const ws = () => {
149
+ while (i < s.length && (s[i] === " " || s[i] === "\t"))
150
+ i++;
151
+ };
152
+ const wsNl = () => {
153
+ for (;;) {
154
+ ws();
155
+ if (s[i] === "#")
156
+ while (i < s.length && s[i] !== "\n")
157
+ i++;
158
+ if (s[i] === "\n" || s[i] === "\r")
159
+ i++;
160
+ else
161
+ break;
162
+ }
163
+ };
164
+ const basic = () => {
165
+ i++; // opening quote
166
+ let out = "";
167
+ while (i < s.length && s[i] !== '"') {
168
+ if (s[i] === "\n")
169
+ fail("newline in a string");
170
+ if (s[i] !== "\\") {
171
+ out += s[i++];
172
+ continue;
173
+ }
174
+ const e = s[i + 1];
175
+ i += 2;
176
+ const simple = { b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", '"': '"', "\\": "\\" };
177
+ if (e in simple)
178
+ out += simple[e];
179
+ else if (e === "u" || e === "U") {
180
+ const n = e === "u" ? 4 : 8;
181
+ const hex = s.slice(i, i + n);
182
+ if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length !== n)
183
+ fail("bad unicode escape");
184
+ out += String.fromCodePoint(parseInt(hex, 16));
185
+ i += n;
186
+ }
187
+ else
188
+ fail(`bad escape \\${e}`);
189
+ }
190
+ if (s[i] !== '"')
191
+ fail("unterminated string");
192
+ i++;
193
+ return out;
194
+ };
195
+ const literal = () => {
196
+ const end = s.indexOf("'", i + 1);
197
+ if (end < 0 || s.slice(i + 1, end).includes("\n"))
198
+ fail("unterminated literal string");
199
+ const out = s.slice(i + 1, end);
200
+ i = end + 1;
201
+ return out;
202
+ };
203
+ const multiline = (quote) => {
204
+ i += 3;
205
+ if (s[i] === "\r")
206
+ i++;
207
+ if (s[i] === "\n")
208
+ i++;
209
+ let end = s.indexOf(quote, i);
210
+ if (end < 0)
211
+ fail("unterminated multi-line string");
212
+ // Up to two more quotes right before the closing three belong to the content.
213
+ for (let extra = 0; extra < 2 && s[end + 3] === quote[0]; extra++)
214
+ end++;
215
+ const raw = s.slice(i, end);
216
+ i = end + 3;
217
+ if (quote === "'''")
218
+ return raw;
219
+ return raw
220
+ .replace(/\\[ \t]*\r?\n\s*/g, "")
221
+ .replace(/\\(u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|[btnfr"\\])/g, (_, e) => e.length > 1
222
+ ? String.fromCodePoint(parseInt(e.slice(1), 16))
223
+ : { b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", '"': '"', "\\": "\\" }[e]);
224
+ };
225
+ const keyPart = () => {
226
+ ws();
227
+ if (s[i] === '"')
228
+ return basic();
229
+ if (s[i] === "'")
230
+ return literal();
231
+ const m = /^[A-Za-z0-9_-]+/.exec(s.slice(i, i + 256));
232
+ if (!m)
233
+ fail("expected a key");
234
+ i += m[0].length;
235
+ return m[0];
236
+ };
237
+ const keyPath = () => {
238
+ const parts = [keyPart()];
239
+ for (;;) {
240
+ ws();
241
+ if (s[i] !== ".")
242
+ return parts;
243
+ i++;
244
+ parts.push(keyPart());
245
+ }
246
+ };
247
+ const value = () => {
248
+ ws();
249
+ if (s.startsWith('"""', i))
250
+ return multiline('"""');
251
+ if (s.startsWith("'''", i))
252
+ return multiline("'''");
253
+ if (s[i] === '"')
254
+ return basic();
255
+ if (s[i] === "'")
256
+ return literal();
257
+ if (s[i] === "[") {
258
+ i++;
259
+ const arr = [];
260
+ for (;;) {
261
+ wsNl();
262
+ if (s[i] === "]") {
263
+ i++;
264
+ return arr;
265
+ }
266
+ arr.push(value());
267
+ wsNl();
268
+ if (s[i] === ",")
269
+ i++;
270
+ else if (s[i] !== "]")
271
+ fail("expected , or ] in an array");
272
+ }
273
+ }
274
+ if (s[i] === "{") {
275
+ i++;
276
+ const obj = {};
277
+ ws();
278
+ if (s[i] === "}") {
279
+ i++;
280
+ return obj;
281
+ }
282
+ for (;;) {
283
+ const k = keyPath();
284
+ ws();
285
+ if (s[i] !== "=")
286
+ fail("expected = in an inline table");
287
+ i++;
288
+ assign(obj, k, value());
289
+ ws();
290
+ if (s[i] === ",")
291
+ i++;
292
+ else if (s[i] === "}") {
293
+ i++;
294
+ return obj;
295
+ }
296
+ else
297
+ fail("expected , or } in an inline table");
298
+ }
299
+ }
300
+ const m = /^[^\s,\]}#]+/.exec(s.slice(i, i + 256));
301
+ if (!m)
302
+ fail("expected a value");
303
+ const tok = m[0];
304
+ // A date-time may carry a space before its time part: keep it whole.
305
+ let full = tok;
306
+ const time = /^ \d{2}:\d{2}/.exec(s.slice(i + tok.length));
307
+ if (/^\d{4}-\d{2}-\d{2}$/.test(tok) && time) {
308
+ const rest = /^ [^\s,\]}#]+/.exec(s.slice(i + tok.length));
309
+ full += rest[0];
310
+ }
311
+ i += full.length;
312
+ if (full === "true")
313
+ return true;
314
+ if (full === "false")
315
+ return false;
316
+ const num = full.replace(/_/g, "");
317
+ if (/^[+-]?(\d+|0x[0-9a-fA-F]+|0o[0-7]+|0b[01]+)$/.test(num))
318
+ return Number(num.replace(/^\+/, ""));
319
+ if (/^[+-]?(\d+(\.\d+)?([eE][+-]?\d+)?|inf|nan)$/.test(num))
320
+ return Number(num);
321
+ return full; // dates and times, kept as written
322
+ };
323
+ const table = (at, keys) => {
324
+ let t = at;
325
+ for (const k of keys) {
326
+ let next = t[k];
327
+ if (Array.isArray(next))
328
+ next = next[next.length - 1];
329
+ if (next === undefined) {
330
+ next = {};
331
+ t[k] = next;
332
+ }
333
+ if (!next || typeof next !== "object" || Array.isArray(next))
334
+ fail(`${keys.join(".")} is not a table`);
335
+ t = next;
336
+ }
337
+ return t;
338
+ };
339
+ const assign = (at, keys, v) => {
340
+ const t = table(at, keys.slice(0, -1));
341
+ t[keys[keys.length - 1]] = v;
342
+ };
343
+ for (;;) {
344
+ wsNl();
345
+ if (i >= s.length)
346
+ return root;
347
+ if (s[i] === "[") {
348
+ const arrayTable = s[i + 1] === "[";
349
+ i += arrayTable ? 2 : 1;
350
+ const keys = keyPath();
351
+ ws();
352
+ if (!s.startsWith(arrayTable ? "]]" : "]", i))
353
+ fail("unterminated table header");
354
+ i += arrayTable ? 2 : 1;
355
+ if (arrayTable) {
356
+ const parent = table(root, keys.slice(0, -1));
357
+ const last = keys[keys.length - 1];
358
+ const list = Array.isArray(parent[last]) ? parent[last] : [];
359
+ const entry = {};
360
+ list.push(entry);
361
+ parent[last] = list;
362
+ current = entry;
363
+ }
364
+ else
365
+ current = table(root, keys);
366
+ }
367
+ else {
368
+ const keys = keyPath();
369
+ ws();
370
+ if (s[i] !== "=")
371
+ fail("expected =");
372
+ i++;
373
+ assign(current, keys, value());
374
+ }
375
+ ws();
376
+ if (s[i] === "#")
377
+ while (i < s.length && s[i] !== "\n")
378
+ i++;
379
+ if (i < s.length && s[i] !== "\n" && s[i] !== "\r")
380
+ fail("expected the end of the line");
381
+ }
382
+ }
383
+ /**
384
+ * Config files besides `$CODEX_HOME/config.toml` that can trust a project,
385
+ * as far as a file on disk shows them: the system config and, on Unix, the
386
+ * legacy managed config. A selected profile, the cloud bundle and macOS
387
+ * device profiles cannot be seen from here.
388
+ */
389
+ export function codexOtherConfigLayers(platform = process.platform, env = process.env, exists = fs.existsSync) {
390
+ const files = platform === "win32"
391
+ ? [path.win32.join(env.ProgramData || env.PROGRAMDATA || "C:\\ProgramData", "OpenAI", "Codex", "config.toml")]
392
+ : ["/etc/codex/config.toml", "/etc/codex/managed_config.toml"];
393
+ return files.filter((f) => exists(f));
394
+ }
395
+ /** `C:\x\` -> `c:\x` on Windows (Codex lower-cases project keys there), trailing separators and `\\?\` dropped. */
396
+ function trustLookupKey(p, platform) {
397
+ let k = p.replace(/^\\\\\?\\/, "");
398
+ if (k.length > 1)
399
+ k = k.replace(/[\\/]+$/, "") || k;
400
+ return platform === "win32" ? k.toLowerCase() : k;
401
+ }
402
+ /** Codex's own verdict on vexp's hook, as far as its user config shows it. */
403
+ export function codexHookTrust(input) {
404
+ const out = { hooksDisabled: false, project: "unknown", hook: "unknown" };
405
+ const unseen = input.otherLayers.length
406
+ ? `Codex also reads ${input.otherLayers.join(" and ")}, which can trust a project, and doctor does not read ${input.otherLayers.length > 1 ? "them" : "it"}`
407
+ : undefined;
408
+ if (input.userConfig === undefined) {
409
+ out.project = unseen ? "unknown" : "absent";
410
+ out.hook = "not-approved";
411
+ if (unseen)
412
+ out.why = unseen;
413
+ return out;
414
+ }
415
+ let cfg;
416
+ try {
417
+ cfg = parseToml(input.userConfig);
418
+ }
419
+ catch (err) {
420
+ out.why = `${input.userConfigPath} could not be read (${err instanceof Error ? err.message : String(err)})`;
421
+ return out;
422
+ }
423
+ // `hooks` is the flag's name; `codex_hooks` its old one, which counts only
424
+ // when the new one is not set.
425
+ const features = cfg.features;
426
+ const flag = features?.hooks !== undefined ? "hooks" : features?.codex_hooks !== undefined ? "codex_hooks" : undefined;
427
+ out.hooksDisabled = flag !== undefined && features?.[flag] === false;
428
+ if (out.hooksDisabled)
429
+ out.hooksDisabledBy = flag;
430
+ // Project trust: the first candidate with an entry decides, as in Codex,
431
+ // and for each an exact spelling wins over one that only matches once
432
+ // normalised (the first of those in key order).
433
+ const projects = (cfg.projects && typeof cfg.projects === "object" ? cfg.projects : {});
434
+ const levelOf = (key) => {
435
+ const v = projects[key];
436
+ return v && typeof v === "object" ? v.trust_level : undefined;
437
+ };
438
+ const projectEntry = (candidate) => {
439
+ const want = trustLookupKey(candidate, input.platform);
440
+ if (want in projects)
441
+ return want;
442
+ return Object.keys(projects)
443
+ .sort()
444
+ .find((k) => trustLookupKey(k, input.platform) === want);
445
+ };
446
+ for (const candidate of input.trustCandidates) {
447
+ const hit = projectEntry(candidate);
448
+ if (hit === undefined)
449
+ continue;
450
+ const level = levelOf(hit);
451
+ out.projectKey = hit;
452
+ out.project = level === "trusted" ? "trusted" : level === "untrusted" ? "untrusted" : "unknown";
453
+ if (out.project === "unknown")
454
+ out.why = `the entry for ${hit} has trust_level ${JSON.stringify(level)}, which doctor does not know`;
455
+ break;
456
+ }
457
+ if (!out.projectKey) {
458
+ out.project = unseen ? "unknown" : "absent";
459
+ if (unseen)
460
+ out.why = unseen;
461
+ }
462
+ // The hook's own record: looked up exactly, as Codex does.
463
+ const hash = codexHookHash(input.handler, input.platform);
464
+ const hooks = cfg.hooks;
465
+ const state = (hooks?.state && typeof hooks.state === "object" ? hooks.state : {});
466
+ const records = new Map();
467
+ for (const [k, v] of Object.entries(state)) {
468
+ if (v && typeof v === "object" && !Array.isArray(v))
469
+ records.set(k.trim(), v);
470
+ }
471
+ const stateOf = (key) => {
472
+ const record = records.get(key);
473
+ if (record?.enabled === false)
474
+ return "disabled";
475
+ if (typeof record?.trusted_hash !== "string")
476
+ return "not-approved";
477
+ if (!hash) {
478
+ out.why ??= "vexp's hook entry in .codex/hooks.json carries fields doctor cannot hash the way Codex does";
479
+ return "unknown";
480
+ }
481
+ return record.trusted_hash === hash ? "approved" : "modified";
482
+ };
483
+ const key = codexHookKey(input.hooksJsonPath, input.groupIndex, input.handlerIndex);
484
+ const probed = [key];
485
+ out.hook = stateOf(key);
486
+ if (input.platform === "win32" && /^[A-Za-z]:/.test(key)) {
487
+ const spelled = (drive) => drive + key.slice(1);
488
+ out.surfaces = [
489
+ { where: "terminal", key: spelled(key[0].toUpperCase()), hook: "unknown" },
490
+ { where: "vscode", key: spelled(key[0].toLowerCase()), hook: "unknown" },
491
+ ];
492
+ for (const sfc of out.surfaces) {
493
+ sfc.hook = stateOf(sfc.key);
494
+ probed.push(sfc.key);
495
+ }
496
+ out.hook = out.surfaces.find((sfc) => sfc.hook !== "approved")?.hook ?? "approved";
497
+ }
498
+ if (input.platform === "win32" && probed.every((k) => !records.has(k))) {
499
+ // A record under another spelling of the folder does not count for this
500
+ // one. Say so rather than leave the user wondering where it went.
501
+ out.otherSpelling = [...records.keys()].find((k) => k.toLowerCase() === key.toLowerCase());
502
+ }
503
+ return out;
504
+ }
505
+ /**
506
+ * The folders whose trust entry decides this project's in Codex: the folder
507
+ * holding `.codex`, then the git root (Codex's project-root marker is `.git`),
508
+ * then the main worktree's root. Real paths, as Codex canonicalises them.
509
+ */
510
+ export function codexTrustCandidates(root, git) {
511
+ const out = [];
512
+ const add = (p) => {
513
+ if (!p)
514
+ return;
515
+ let real = p;
516
+ try {
517
+ real = fs.realpathSync.native(p);
518
+ }
519
+ catch {
520
+ /* keep as given */
521
+ }
522
+ for (const c of [real, p])
523
+ if (!out.includes(c))
524
+ out.push(c);
525
+ };
526
+ add(root);
527
+ add(git(["rev-parse", "--show-toplevel"]));
528
+ const common = git(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
529
+ if (common && path.basename(common) === ".git")
530
+ add(path.dirname(common));
531
+ return out;
532
+ }
533
+ /** How to approve the hook in each place Codex runs it from. */
534
+ const APPROVE_IN_VSCODE = "in the Codex extension for VS Code, open the Codex panel > Settings > Hooks and trust vexp-hint";
535
+ const approveInTerminal = (root) => `in a terminal, run codex in ${root}, type /hooks and approve vexp-hint`;
536
+ /** Doctor's lines for the trust verdict. */
537
+ export function codexTrustFindings(t, root, configPath, levels) {
538
+ const { OK, WARN } = levels;
539
+ const approve = `${APPROVE_IN_VSCODE}; ${approveInTerminal(root)}`;
540
+ if (t.hooksDisabled) {
541
+ const flag = t.hooksDisabledBy ?? "hooks";
542
+ return [{ level: WARN, message: `Codex hooks are switched off ([features] ${flag} = false in ${configPath}): Codex runs no hook, vexp's included. Remove that line to turn them back on.` }];
543
+ }
544
+ const out = [];
545
+ switch (t.project) {
546
+ case "trusted":
547
+ out.push({ level: OK, message: `Codex trusts this project (${t.projectKey})` });
548
+ break;
549
+ case "untrusted":
550
+ out.push({
551
+ level: WARN,
552
+ message: `Codex has this project marked untrusted (${t.projectKey} in ${configPath}), so it loads none of its hooks. To change it: set trust_level = "trusted" for that entry, or delete the entry and answer the trust question the next time you start codex here.`,
553
+ });
554
+ break;
555
+ case "absent":
556
+ out.push({
557
+ level: WARN,
558
+ message: `Codex has not been told to trust this project, and it loads no project hook until it is: start codex in ${root} and answer yes when it asks whether to trust the folder.`,
559
+ });
560
+ break;
561
+ default:
562
+ out.push({ level: WARN, message: `cannot tell whether Codex trusts this project: ${t.why ?? "no reason recorded"}.` });
563
+ }
564
+ // Windows: the terminal and the VS Code extension keep separate records.
565
+ // When they disagree, say which place has it and how to fix the other.
566
+ const split = t.surfaces && new Set(t.surfaces.map((sfc) => sfc.hook)).size > 1;
567
+ if (split) {
568
+ for (const sfc of t.surfaces) {
569
+ const place = sfc.where === "terminal" ? "codex in a terminal" : "the Codex extension for VS Code";
570
+ const fix = sfc.where === "terminal" ? approveInTerminal(root) : APPROVE_IN_VSCODE;
571
+ if (sfc.hook === "approved")
572
+ out.push({ level: OK, message: `Codex has vexp's hook approved for ${place} (${sfc.key})` });
573
+ else if (sfc.hook === "unknown")
574
+ out.push({ level: WARN, message: `cannot tell whether ${place} has vexp's hook approved: ${t.why ?? "no reason recorded"}.` });
575
+ else
576
+ out.push({
577
+ level: WARN,
578
+ message: `vexp's hook is ${sfc.hook === "modified" ? "changed since you approved it" : sfc.hook === "disabled" ? "switched off" : "not approved"} for ${place} (${sfc.key}), which keeps its own record, so it does not run there. To fix it: ${fix}.`,
579
+ });
580
+ }
581
+ return out;
582
+ }
583
+ const both = t.surfaces ? " (for codex in a terminal and in the VS Code extension alike)" : "";
584
+ switch (t.hook) {
585
+ case "approved":
586
+ out.push({ level: OK, message: `Codex has vexp's hook approved${both} (its trust record matches the hook as written)` });
587
+ break;
588
+ case "modified":
589
+ out.push({
590
+ level: WARN,
591
+ message: `vexp's hook changed since you approved it in Codex, so Codex does not run it. To fix it, approve it again: ${approve}.`,
592
+ });
593
+ break;
594
+ case "disabled":
595
+ out.push({ level: WARN, message: `vexp's hook is switched off in Codex (enabled = false in ${configPath}). Turn it back on (${approve}) if you want per-prompt orientation.` });
596
+ break;
597
+ case "not-approved":
598
+ out.push({
599
+ level: WARN,
600
+ message: `Codex has no approval on record for vexp's hook, so it does not run it. To fix it: ${approve}.` +
601
+ (t.otherSpelling ? `\n a record exists for ${t.otherSpelling}: the same folder spelled differently, which Codex does not count for this one.` : ""),
602
+ });
603
+ break;
604
+ default:
605
+ out.push({ level: WARN, message: `cannot tell whether Codex has vexp's hook approved: ${t.why ?? "no reason recorded"}. codex's /hooks screen shows it.` });
606
+ }
607
+ return out;
608
+ }