@brunobracaioli/aibrain 0.4.0-beta.12 → 0.4.0-beta.13

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.
package/README.md CHANGED
@@ -41,6 +41,12 @@ Licensed `aisac claude` sessions also load guardrail hooks as the session plugin
41
41
  them from inside a session.
42
42
  - **Code:** it ships in this package and runs locally. Nothing comes from the
43
43
  server.
44
+ - **Codex (0.166.0 or later):** licensed `aisac codex` sessions get the same hooks
45
+ from session flags. Codex runs them only after you approve the AISAC hooks once
46
+ in `/hooks`, and you can switch each one off there too. The launch line says how
47
+ many are on and how many wait for that review; `aisac doctor` reports the same
48
+ under `extensions.codexHooks`. A new Node path (for example after an nvm or
49
+ Homebrew upgrade) changes the hook commands, so Codex asks for the review again.
44
50
 
45
51
  Licensed sessions of `aisac claude` and `aisac codex` can also load reviewed MCP
46
52
  servers: `github` (read-only, token in `GITHUB_PERSONAL_ACCESS_TOKEN`), `supabase`
@@ -16,7 +16,7 @@ export const MAX_LOG_FILES = 3;
16
16
  const LOCK_WAIT_MS = 250;
17
17
  const LOCK_STALE_MS = 30_000;
18
18
  const ADMISSION_MS = 60 * 60 * 1000;
19
- export const ADAPTER_VERSION = '0.4.0-beta.12';
19
+ export const ADAPTER_VERSION = '0.4.0-beta.13';
20
20
  const MAX_DIAGNOSTIC_COUNT = 10_000_000;
21
21
  const MAX_DIAGNOSTIC_BYTES = 128 * 1024 * 1024;
22
22
  const CHECKPOINT_BACKENDS = new Set(['native_store', 'private_file']);
package/dist/cli.mjs CHANGED
@@ -40,8 +40,10 @@ import {
40
40
  import { extensionsSummary, hooksCommand, mcpCommand } from './extensions-cli.mjs';
41
41
  import { sessionServers } from './mcp-servers.mjs';
42
42
  import { anyFormatter } from './guardrails/format.mjs';
43
+ import { readCodexGuardrails } from './guardrails-plugin.mjs';
44
+ import { codexHooksStatus, supportsCodexHooks } from './codex-hooks.mjs';
43
45
 
44
- const VERSION = '0.4.0-beta.12';
46
+ const VERSION = '0.4.0-beta.13';
45
47
  // The command is `aisac`; `aibrain` is an alias installed from the same entry.
46
48
  const COMMAND = 'aisac';
47
49
  const ENDPOINT = 'https://mcp.ai-brain.aisac.cloud/mcp';
@@ -65,7 +67,7 @@ function usage() {
65
67
  'AISAC Engineering: `aisac claude` and `aisac codex` (Codex 0.164+) load the licensed method; setup --lang pt-BR|en picks its language (before the first setup it runs the full setup); setup and doctor report the license; status stays offline. `aibrain` is an alias of `aisac`.',
66
68
  );
67
69
  console.log(
68
- 'Guardrails: `aisac hooks` lists them (a checklist on a terminal); `aisac hooks enable|disable <id>...` and `aisac hooks reset` change the next `aisac claude` session.',
70
+ 'Guardrails: `aisac hooks` lists them (a checklist on a terminal); `aisac hooks enable|disable <id>...` and `aisac hooks reset` change the next `aisac claude` or `aisac codex` session.',
69
71
  );
70
72
  console.log(
71
73
  'MCP catalog: `aisac mcp` lists the reviewed servers (GitHub, Supabase, Vercel, Playwright, Context7); `aisac mcp enable|disable <id>...`, `aisac mcp reset`, `aisac mcp info <id>` and `aisac mcp login <id>` (Codex) change or explain the next session.',
@@ -601,8 +603,48 @@ async function setup(args) {
601
603
  if (line !== null) console.log(line);
602
604
  }
603
605
 
604
- /** Extension state plus whether `format-on-edit` has a formatter in this folder (FR8). */
605
- async function doctorExtensions() {
606
+ /**
607
+ * Whether Codex runs the AISAC hooks the user has on, from Codex's own
608
+ * `hooks/list` in this folder (FR6, FR8, E6b). No session starts, and Codex
609
+ * writes no config or trust record. Codex is not asked when the last launch had
610
+ * no license, because the launcher then hands Codex no hook.
611
+ * @param {{ executable: string | null, version: string | null | undefined, licensed: boolean }} codex
612
+ */
613
+ async function doctorCodexHooks({ executable, version, licensed }) {
614
+ if (executable === null) return { status: 'codex_not_found' };
615
+ if (!supportsCodexHooks(version)) return { status: 'codex_version_unsupported' };
616
+ if (!licensed) return { status: 'not_licensed' };
617
+ const read = await readCodexGuardrails({
618
+ root: stateRoot(),
619
+ packageRoot: PACKAGE_ROOT,
620
+ executable,
621
+ cwd: process.cwd(),
622
+ profile: undefined,
623
+ environment: process.env,
624
+ }).catch(() => undefined);
625
+ if (read === undefined) return { status: 'list_failed' };
626
+ if (read === null) return { status: 'none_enabled' };
627
+ const { summary } = read;
628
+ if (summary === null) return { status: 'list_failed' };
629
+ const status = codexHooksStatus(summary);
630
+ return {
631
+ status,
632
+ handlers: summary.handlers,
633
+ review: summary.review,
634
+ off: summary.off,
635
+ missing: summary.missing,
636
+ ...(summary.review.length > 0
637
+ ? { remedy: 'Run aisac codex, open /hooks and approve the AISAC hooks.' }
638
+ : {}),
639
+ };
640
+ }
641
+
642
+ /**
643
+ * Extension state plus whether `format-on-edit` has a formatter in this folder,
644
+ * and whether Codex runs the hooks (FR8).
645
+ * @param {{ executable: string | null, version: string | null | undefined }} codex
646
+ */
647
+ async function doctorExtensions(codex) {
606
648
  const summary = await extensionsSummary(stateRoot());
607
649
  // A token server is skipped at launch when its variable is unset (names only).
608
650
  const mcpMissingEnv = sessionServers(summary.mcp, 'claude', process.env).missingEnv.map(
@@ -613,7 +655,9 @@ async function doctorExtensions() {
613
655
  : (await anyFormatter(process.cwd()))
614
656
  ? 'ready'
615
657
  : 'no_formatter_found';
616
- return { ...summary, formatOnEdit, mcpMissingEnv };
658
+ const licensed = (await readLastLaunch(stateRoot()))?.state === 'loaded';
659
+ const codexHooks = await doctorCodexHooks({ ...codex, licensed });
660
+ return { ...summary, formatOnEdit, mcpMissingEnv, codexHooks };
617
661
  }
618
662
 
619
663
  /** @param {boolean} json */
@@ -710,7 +754,10 @@ async function doctor(json) {
710
754
  },
711
755
  engineering,
712
756
  engineeringPack: await engineeringPackState(),
713
- extensions: await doctorExtensions(),
757
+ extensions: await doctorExtensions({
758
+ executable: native.codex,
759
+ version: codexCompatibility?.version,
760
+ }),
714
761
  paidOrPilot: probe?.ok
715
762
  ? 'provider_free_probe_ok; paid entitlement or pilot invitation remains server-enforced'
716
763
  : config
@@ -0,0 +1,411 @@
1
+ // @ts-check
2
+ /**
3
+ * AISAC guardrails in Codex (spec aisac-engineering-extensions FR6, E6b).
4
+ *
5
+ * Codex loads session-flag hooks as `untrusted`. The user approves them once in
6
+ * `/hooks`, and Codex keys that approval, and the off switch, by position
7
+ * (`<event>:<group>:<handler>`) and command line (measured on 0.166.0). So:
8
+ * - the launch emits every catalog hook, in CODEX_HOOK_POSITIONS order, each with
9
+ * a byte-stable command: the Node executable, the dispatcher, the event and the
10
+ * hook id. No launch directory, version, locale or random value goes in it;
11
+ * - what changes per launch (the enabled hooks, the locale, the launch's state
12
+ * folder, the preferences file) reaches the hook process through the
13
+ * app-server's environment, which Codex hook processes inherit (measured on
14
+ * 0.166.0), as one JSON value in GUARDRAILS_SESSION_ENV.
15
+ * The launcher hands the monitor CODEX_HOOKS_ENV (enabled ids and locale). The
16
+ * monitor validates it against its own catalog and builds the flags and the
17
+ * session value itself, so the variable can never inject a command or a path.
18
+ * This module imports only the catalog and the TOML helper, so the monitor stays
19
+ * small.
20
+ */
21
+ import { isAbsolute, join, resolve } from 'node:path';
22
+ import { CODEX_HOOK_POSITIONS, HOOK_CATALOG, hookById } from './extensions-catalog.mjs';
23
+ import { tomlBasicString } from './codex-engineering.mjs';
24
+
25
+ /** Launcher to monitor: the enabled hook ids and the locale. */
26
+ export const CODEX_HOOKS_ENV = 'AISAC_EXTENSIONS_CODEX_HOOKS';
27
+ /** Monitor to hook processes, through the app-server's environment. */
28
+ export const GUARDRAILS_SESSION_ENV = 'AISAC_GUARDRAILS_SESSION';
29
+ const SCHEMA_VERSION = 1;
30
+ // Same names as the Claude session plugin (guardrails-plugin.mjs) and the
31
+ // preferences module (EXTENSIONS_FILE); a test keeps them equal.
32
+ const STATE_DIRECTORY = 'guardrails-state';
33
+ const PREFERENCES_FILE = 'extensions.json';
34
+ const MAX_VALUE_BYTES = 8 * 1024;
35
+ const MAX_PATH_LENGTH = 4096;
36
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u;
37
+ const LOCALES = /** @type {const} */ (['pt-BR', 'en']);
38
+ /** First Codex release whose hooks were measured with real turns (spike part 2). */
39
+ const MIN_CODEX_HOOKS_VERSION = /** @type {const} */ ([0, 166, 0]);
40
+ /** @type {Readonly<Record<string, number>>} */
41
+ const TIMEOUT_SECONDS = Object.freeze({
42
+ PreToolUse: 5,
43
+ PostToolUse: 5,
44
+ UserPromptSubmit: 5,
45
+ Stop: 5,
46
+ SessionStart: 10,
47
+ PreCompact: 10,
48
+ });
49
+ // Codex reports camelCase event names in hooks/list.
50
+ /** @type {Readonly<Record<string, string>>} */
51
+ const LISTED_EVENT = Object.freeze({
52
+ PreToolUse: 'preToolUse',
53
+ PostToolUse: 'postToolUse',
54
+ UserPromptSubmit: 'userPromptSubmit',
55
+ SessionStart: 'sessionStart',
56
+ PreCompact: 'preCompact',
57
+ Stop: 'stop',
58
+ });
59
+
60
+ /** @typedef {import('./extensions-catalog.mjs').HookEvent} HookEvent */
61
+ /** @typedef {'pt-BR' | 'en'} HookLocale */
62
+ /** @typedef {{ node: string, dispatcher: string }} HookCommandPaths */
63
+ /** @typedef {{ schemaVersion: 1, hooks: string[], locale: HookLocale }} CodexHooksValue */
64
+ /**
65
+ * @typedef {object} GuardrailsSession
66
+ * @property {1} schemaVersion
67
+ * @property {string[]} hooks The hooks the user had on when the session started.
68
+ * @property {HookLocale} locale
69
+ * @property {string} stateDir The launch's guardrail state folder, deleted on exit.
70
+ * @property {string} prefsPath The preferences file (FR2.2).
71
+ */
72
+ /**
73
+ * @typedef {object} CodexHookHandler
74
+ * @property {HookEvent} event
75
+ * @property {string} id
76
+ * @property {string | undefined} matcher
77
+ * @property {string} command
78
+ * @property {number} timeout
79
+ */
80
+
81
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
82
+ function isRecord(value) {
83
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
84
+ }
85
+
86
+ /** @param {unknown} value @returns {value is string} */
87
+ function isCleanAbsolutePath(value) {
88
+ return (
89
+ typeof value === 'string' &&
90
+ value.length > 0 &&
91
+ value.length <= MAX_PATH_LENGTH &&
92
+ !CONTROL_CHARACTERS.test(value) &&
93
+ isAbsolute(value) &&
94
+ resolve(value) === value
95
+ );
96
+ }
97
+
98
+ /** @param {unknown} value @returns {value is HookLocale} */
99
+ function isLocale(value) {
100
+ return typeof value === 'string' && LOCALES.includes(/** @type {HookLocale} */ (value));
101
+ }
102
+
103
+ /** POSIX single-quote quoting for the shell that runs the hook command. @param {string} value */
104
+ function shellQuote(value) {
105
+ return `'${value.replaceAll("'", "'\\''")}'`;
106
+ }
107
+
108
+ /** Ids that are Codex catalog hooks, in catalog order, without repeats. @param {unknown} ids */
109
+ function codexHookIds(ids) {
110
+ if (!Array.isArray(ids)) return null;
111
+ const wanted = new Set(ids);
112
+ if (ids.length !== wanted.size) return null;
113
+ for (const id of wanted)
114
+ if (typeof id !== 'string' || hookById(id)?.clients.includes('codex') !== true) return null;
115
+ return HOOK_CATALOG.map((entry) => entry.id).filter((id) => wanted.has(id));
116
+ }
117
+
118
+ /**
119
+ * Whether this Codex version gets the guardrails. A null or malformed version
120
+ * never does.
121
+ * @param {string | null | undefined} version
122
+ */
123
+ export function supportsCodexHooks(version) {
124
+ const match = /^(\d+)\.(\d+)\.(\d+)$/u.exec(version ?? '');
125
+ if (match === null) return false;
126
+ const parts = match.slice(1).map(Number);
127
+ for (let index = 0; index < MIN_CODEX_HOOKS_VERSION.length; index += 1) {
128
+ const part = /** @type {number} */ (parts[index]);
129
+ const minimum = MIN_CODEX_HOOKS_VERSION[index] ?? 0;
130
+ if (part !== minimum) return part > minimum;
131
+ }
132
+ return true;
133
+ }
134
+
135
+ /** The catalog matcher of a hook's binding for one event. @param {string} id @param {string} event */
136
+ function bindingMatcher(id, event) {
137
+ return hookById(id)?.bindings.find((binding) => binding.event === event)?.matcher;
138
+ }
139
+
140
+ /**
141
+ * The byte-stable command of one Codex hook.
142
+ * @param {HookCommandPaths} paths @param {string} event @param {string} id
143
+ */
144
+ export function codexHookCommand(paths, event, id) {
145
+ if (!isCleanAbsolutePath(paths.node) || !isCleanAbsolutePath(paths.dispatcher))
146
+ throw new Error('codex_hooks_path');
147
+ return [
148
+ shellQuote(paths.node),
149
+ shellQuote(paths.dispatcher),
150
+ '--client',
151
+ 'codex',
152
+ '--event',
153
+ event,
154
+ '--hooks',
155
+ id,
156
+ ].join(' ');
157
+ }
158
+
159
+ /**
160
+ * Every Codex hook handler, in position order: one matcher group per hook, so
161
+ * the user can switch each one off in `/hooks`.
162
+ * @param {HookCommandPaths} paths
163
+ * @returns {CodexHookHandler[]}
164
+ */
165
+ export function codexHookHandlers(paths) {
166
+ return Object.entries(CODEX_HOOK_POSITIONS).flatMap(([event, ids]) =>
167
+ ids.map((id) => ({
168
+ event: /** @type {HookEvent} */ (event),
169
+ id,
170
+ matcher: bindingMatcher(id, event),
171
+ command: codexHookCommand(paths, event, id),
172
+ timeout: TIMEOUT_SECONDS[event] ?? 5,
173
+ })),
174
+ );
175
+ }
176
+
177
+ /**
178
+ * Session-only `-c` flags for the owned app-server, one per event.
179
+ * @param {HookCommandPaths} paths
180
+ * @returns {string[]}
181
+ */
182
+ export function codexHookConfigArgs(paths) {
183
+ /** @type {Map<string, string[]>} */
184
+ const groups = new Map();
185
+ for (const handler of codexHookHandlers(paths)) {
186
+ const matcher =
187
+ handler.matcher === undefined ? '' : `matcher=${tomlBasicString(handler.matcher)},`;
188
+ const group = `{${matcher}hooks=[{type="command",command=${tomlBasicString(handler.command)},timeout=${handler.timeout}}]}`;
189
+ groups.set(handler.event, [...(groups.get(handler.event) ?? []), group]);
190
+ }
191
+ return [...groups].flatMap(([event, list]) => ['-c', `hooks.${event}=[${list.join(',')}]`]);
192
+ }
193
+
194
+ /**
195
+ * The launcher's value for the monitor. Ids that are not Codex catalog hooks
196
+ * are dropped.
197
+ * @param {string[]} hooks @param {HookLocale} locale
198
+ */
199
+ export function codexHooksValue(hooks, locale) {
200
+ const ids = codexHookIds([
201
+ ...new Set(hooks.filter((id) => hookById(id)?.clients.includes('codex') === true)),
202
+ ]);
203
+ return JSON.stringify({
204
+ schemaVersion: SCHEMA_VERSION,
205
+ hooks: ids ?? [],
206
+ locale: isLocale(locale) ? locale : 'en',
207
+ });
208
+ }
209
+
210
+ /**
211
+ * Parse the monitor's value; null when absent or when no hook is on.
212
+ * @param {string | undefined} raw
213
+ * @returns {CodexHooksValue | null}
214
+ */
215
+ export function parseCodexHooks(raw) {
216
+ if (raw === undefined || raw === '') return null;
217
+ if (Buffer.byteLength(raw, 'utf8') > MAX_VALUE_BYTES) throw new Error('codex_hooks_too_large');
218
+ /** @type {unknown} */
219
+ let value;
220
+ try {
221
+ value = JSON.parse(raw);
222
+ } catch {
223
+ throw new Error('codex_hooks_invalid');
224
+ }
225
+ if (
226
+ !isRecord(value) ||
227
+ Object.keys(value).sort().join(',') !== 'hooks,locale,schemaVersion' ||
228
+ value.schemaVersion !== SCHEMA_VERSION ||
229
+ !isLocale(value.locale)
230
+ )
231
+ throw new Error('codex_hooks_invalid');
232
+ const hooks = codexHookIds(value.hooks);
233
+ if (hooks === null) throw new Error('codex_hooks_invalid');
234
+ return hooks.length === 0 ? null : { schemaVersion: SCHEMA_VERSION, hooks, locale: value.locale };
235
+ }
236
+
237
+ /**
238
+ * The value the hook processes read, built by the monitor: the launcher's
239
+ * choices plus the launch's own state folder (deleted with the launch on exit)
240
+ * and the preferences file of the state root the pack was checked against.
241
+ * @param {CodexHooksValue} value
242
+ * @param {{ launchDirectory: string, stateRoot: string }} paths
243
+ */
244
+ export function guardrailsSessionValue(value, paths) {
245
+ const parsed = parseGuardrailsSession(
246
+ JSON.stringify({
247
+ schemaVersion: SCHEMA_VERSION,
248
+ hooks: value.hooks,
249
+ locale: value.locale,
250
+ stateDir: join(paths.launchDirectory, STATE_DIRECTORY),
251
+ prefsPath: join(resolve(paths.stateRoot), PREFERENCES_FILE),
252
+ }),
253
+ );
254
+ if (parsed === null) throw new Error('codex_hooks_session_invalid');
255
+ return JSON.stringify(parsed);
256
+ }
257
+
258
+ /**
259
+ * Parse the session value inside a hook process; null when absent or invalid,
260
+ * so the hook does nothing.
261
+ * @param {string | undefined} raw
262
+ * @returns {GuardrailsSession | null}
263
+ */
264
+ export function parseGuardrailsSession(raw) {
265
+ if (raw === undefined || raw === '' || Buffer.byteLength(raw, 'utf8') > MAX_VALUE_BYTES)
266
+ return null;
267
+ /** @type {unknown} */
268
+ let value;
269
+ try {
270
+ value = JSON.parse(raw);
271
+ } catch {
272
+ return null;
273
+ }
274
+ if (
275
+ !isRecord(value) ||
276
+ Object.keys(value).sort().join(',') !== 'hooks,locale,prefsPath,schemaVersion,stateDir' ||
277
+ value.schemaVersion !== SCHEMA_VERSION ||
278
+ !isLocale(value.locale) ||
279
+ !isCleanAbsolutePath(value.stateDir) ||
280
+ !isCleanAbsolutePath(value.prefsPath)
281
+ )
282
+ return null;
283
+ const hooks = codexHookIds(value.hooks);
284
+ if (hooks === null) return null;
285
+ return {
286
+ schemaVersion: SCHEMA_VERSION,
287
+ hooks,
288
+ locale: value.locale,
289
+ stateDir: value.stateDir,
290
+ prefsPath: value.prefsPath,
291
+ };
292
+ }
293
+
294
+ /**
295
+ * @typedef {object} CodexHooksSummary
296
+ * @property {{ total: number, trusted: number, untrusted: number, modified: number, disabled: number, missing: number }} handlers
297
+ * Every AISAC hook Codex was given, by what `hooks/list` reports.
298
+ * @property {string[]} on Enabled guardrails whose hooks are all trusted and on.
299
+ * @property {string[]} review Enabled guardrails with a hook that waits for review in `/hooks`.
300
+ * @property {string[]} off Enabled guardrails switched off in Codex (`/hooks` or config.toml).
301
+ * @property {string[]} missing Enabled guardrails Codex did not load.
302
+ */
303
+
304
+ /**
305
+ * What `hooks/list` says about the AISAC hooks. A guardrail counts as on only
306
+ * when every one of its hooks is trusted and enabled (spec FR6: an untrusted
307
+ * guardrail is never reported as on).
308
+ * @param {unknown} listed The `hooks/list` result.
309
+ * @param {HookCommandPaths} paths
310
+ * @param {string[]} enabled The guardrails the user has on.
311
+ * @returns {CodexHooksSummary}
312
+ */
313
+ export function summarizeCodexHooks(listed, paths, enabled) {
314
+ const rows = (isRecord(listed) && Array.isArray(listed.data) ? listed.data : []).flatMap(
315
+ (entry) => (isRecord(entry) && Array.isArray(entry.hooks) ? entry.hooks : []),
316
+ );
317
+ /** @type {Map<string, Record<string, unknown>>} */
318
+ const byCommand = new Map();
319
+ for (const row of rows)
320
+ if (isRecord(row) && row.source === 'sessionFlags' && typeof row.command === 'string')
321
+ byCommand.set(`${String(row.eventName)}\u0000${row.command}`, row);
322
+ const handlers = codexHookHandlers(paths);
323
+ const counts = {
324
+ total: handlers.length,
325
+ trusted: 0,
326
+ untrusted: 0,
327
+ modified: 0,
328
+ disabled: 0,
329
+ missing: 0,
330
+ };
331
+ /** @type {Map<string, 'on' | 'review' | 'off' | 'missing'>} */
332
+ const states = new Map();
333
+ const rank = { on: 0, off: 1, review: 2, missing: 3 };
334
+ for (const handler of handlers) {
335
+ const row = byCommand.get(`${LISTED_EVENT[handler.event]}\u0000${handler.command}`);
336
+ /** @type {'on' | 'review' | 'off' | 'missing'} */
337
+ let state;
338
+ if (row === undefined) {
339
+ counts.missing += 1;
340
+ state = 'missing';
341
+ } else if (row.trustStatus === 'untrusted' || row.trustStatus === 'modified') {
342
+ counts[row.trustStatus] += 1;
343
+ state = 'review';
344
+ } else if (row.trustStatus !== 'trusted' && row.trustStatus !== 'managed') {
345
+ counts.untrusted += 1;
346
+ state = 'review';
347
+ } else if (row.enabled === false) {
348
+ counts.trusted += 1;
349
+ counts.disabled += 1;
350
+ state = 'off';
351
+ } else {
352
+ counts.trusted += 1;
353
+ state = 'on';
354
+ }
355
+ const previous = states.get(handler.id);
356
+ if (previous === undefined || rank[state] > rank[previous]) states.set(handler.id, state);
357
+ }
358
+ const ids =
359
+ codexHookIds([
360
+ ...new Set(enabled.filter((id) => hookById(id)?.clients.includes('codex') === true)),
361
+ ]) ?? [];
362
+ return {
363
+ handlers: counts,
364
+ on: ids.filter((id) => states.get(id) === 'on'),
365
+ review: ids.filter((id) => states.get(id) === 'review'),
366
+ off: ids.filter((id) => states.get(id) === 'off'),
367
+ missing: ids.filter((id) => states.get(id) === 'missing'),
368
+ };
369
+ }
370
+
371
+ /**
372
+ * The doctor's one-word state for the AISAC hooks in Codex, worst first: a
373
+ * guardrail waiting for review outranks one Codex did not load, which outranks
374
+ * one switched off in Codex.
375
+ * @param {CodexHooksSummary} summary
376
+ * @returns {'needs_review' | 'not_loaded' | 'off_in_codex' | 'on'}
377
+ */
378
+ export function codexHooksStatus(summary) {
379
+ if (summary.review.length > 0) return 'needs_review';
380
+ if (summary.missing.length > 0) return 'not_loaded';
381
+ if (summary.off.length > 0) return 'off_in_codex';
382
+ return 'on';
383
+ }
384
+
385
+ /** @param {number} count */
386
+ function guardrails(count) {
387
+ return `${count} guardrail${count === 1 ? '' : 's'}`;
388
+ }
389
+
390
+ /**
391
+ * The launch line for Codex. With no summary (the listing failed), it says the
392
+ * guardrails were handed to Codex and names the review, never "on".
393
+ * @param {number} enabledCount
394
+ * @param {CodexHooksSummary | null} summary
395
+ */
396
+ export function codexHooksLine(enabledCount, summary) {
397
+ const change = '(aisac hooks to change them)';
398
+ if (summary === null)
399
+ return `AISAC Engineering: ${guardrails(enabledCount)} loaded for Codex; Codex runs them only after you approve the AISAC hooks once in /hooks ${change}.`;
400
+ const total =
401
+ summary.on.length + summary.review.length + summary.off.length + summary.missing.length;
402
+ if (summary.on.length === total) return `AISAC Engineering: ${guardrails(total)} on ${change}.`;
403
+ const parts = [`AISAC Engineering: ${summary.on.length} of ${guardrails(total)} on`];
404
+ if (summary.review.length > 0)
405
+ parts.push(
406
+ `${summary.review.length} wait for your review: open /hooks in Codex and approve the AISAC hooks`,
407
+ );
408
+ if (summary.off.length > 0) parts.push(`${summary.off.length} switched off in Codex (/hooks)`);
409
+ if (summary.missing.length > 0) parts.push(`${summary.missing.length} not loaded by Codex`);
410
+ return `${parts.join('; ')} ${change}.`;
411
+ }
@@ -499,15 +499,22 @@ async function request(child, id, method, params) {
499
499
  return message.result;
500
500
  }
501
501
 
502
- /** @param {string} executable @param {string} cwd @param {string|undefined} profile @param {NodeJS.ProcessEnv} environment */
503
- export async function readEffectiveWritableRoots(executable, cwd, profile, environment) {
504
- validateProfile(profile);
502
+ /**
503
+ * Run one short-lived, initialized app-server for a preflight read, then stop it
504
+ * and its process group. `body` gets the child and whether a signal cancelled it.
505
+ * @template T
506
+ * @param {{ executable: string, cwd: string, profile: string|undefined, environment: NodeJS.ProcessEnv, configArgs?: string[] }} options
507
+ * @param {(child: OwnedChild, cancelled: () => boolean) => Promise<T>} body
508
+ * @returns {Promise<T>}
509
+ */
510
+ async function withPreflightServer(options, body) {
511
+ validateProfile(options.profile);
505
512
  const args = [];
506
- if (profile !== undefined) args.push('--profile', profile);
507
- args.push('app-server', '--listen', 'stdio://');
508
- const child = spawn(executable, args, {
509
- cwd,
510
- env: childEnvironment(environment),
513
+ if (options.profile !== undefined) args.push('--profile', options.profile);
514
+ args.push(...(options.configArgs ?? []), 'app-server', '--listen', 'stdio://');
515
+ const child = spawn(options.executable, args, {
516
+ cwd: options.cwd,
517
+ env: childEnvironment(options.environment),
511
518
  stdio: ['pipe', 'pipe', 'ignore'],
512
519
  detached: process.platform !== 'win32',
513
520
  });
@@ -531,32 +538,7 @@ export async function readEffectiveWritableRoots(executable, cwd, profile, envir
531
538
  } catch {
532
539
  fail('preflight_failed');
533
540
  }
534
- const result = await request(child, 1, 'config/read', { cwd, includeLayers: true });
535
- const rawConfig = result.config;
536
- if (
537
- rawConfig === undefined ||
538
- rawConfig === null ||
539
- typeof rawConfig !== 'object' ||
540
- Array.isArray(rawConfig)
541
- )
542
- fail('preflight_roots_unavailable');
543
- const config = /** @type {JsonObject} */ (rawConfig);
544
- const rawSandbox = config.sandbox_workspace_write;
545
- if (cancelled) fail('preflight_cancelled');
546
- if (rawSandbox === undefined || rawSandbox === null) return [];
547
- if (typeof rawSandbox !== 'object' || Array.isArray(rawSandbox))
548
- fail('preflight_roots_unavailable');
549
- const sandbox = /** @type {JsonObject} */ (rawSandbox);
550
- const roots = sandbox.writable_roots;
551
- if (cancelled) fail('preflight_cancelled');
552
- if (roots === undefined || roots === null) return [];
553
- if (
554
- !Array.isArray(roots) ||
555
- roots.some((path) => typeof path !== 'string' || !isAbsolute(path))
556
- )
557
- fail('preflight_roots_unavailable');
558
- if (cancelled) fail('preflight_cancelled');
559
- return deduplicate(roots);
541
+ return await body(child, () => cancelled);
560
542
  } finally {
561
543
  process.removeListener('SIGINT', cancel);
562
544
  process.removeListener('SIGTERM', cancel);
@@ -565,6 +547,57 @@ export async function readEffectiveWritableRoots(executable, cwd, profile, envir
565
547
  }
566
548
  }
567
549
 
550
+ /** @param {string} executable @param {string} cwd @param {string|undefined} profile @param {NodeJS.ProcessEnv} environment */
551
+ export async function readEffectiveWritableRoots(executable, cwd, profile, environment) {
552
+ return withPreflightServer(
553
+ { executable, cwd, profile, environment },
554
+ async (child, cancelled) => {
555
+ const result = await request(child, 1, 'config/read', { cwd, includeLayers: true });
556
+ const rawConfig = result.config;
557
+ if (
558
+ rawConfig === undefined ||
559
+ rawConfig === null ||
560
+ typeof rawConfig !== 'object' ||
561
+ Array.isArray(rawConfig)
562
+ )
563
+ fail('preflight_roots_unavailable');
564
+ const config = /** @type {JsonObject} */ (rawConfig);
565
+ const rawSandbox = config.sandbox_workspace_write;
566
+ if (cancelled()) fail('preflight_cancelled');
567
+ if (rawSandbox === undefined || rawSandbox === null) return [];
568
+ if (typeof rawSandbox !== 'object' || Array.isArray(rawSandbox))
569
+ fail('preflight_roots_unavailable');
570
+ const sandbox = /** @type {JsonObject} */ (rawSandbox);
571
+ const roots = sandbox.writable_roots;
572
+ if (cancelled()) fail('preflight_cancelled');
573
+ if (roots === undefined || roots === null) return [];
574
+ if (
575
+ !Array.isArray(roots) ||
576
+ roots.some((path) => typeof path !== 'string' || !isAbsolute(path))
577
+ )
578
+ fail('preflight_roots_unavailable');
579
+ if (cancelled()) fail('preflight_cancelled');
580
+ return deduplicate(roots);
581
+ },
582
+ );
583
+ }
584
+
585
+ /**
586
+ * What Codex reports for a set of session-flag hooks (`hooks/list` for one
587
+ * folder), read from a short-lived app-server started with those flags and the
588
+ * user's own CODEX_HOME, where the trust records live. It writes no config and
589
+ * no trust record (spec aisac-engineering-extensions FR6, part 1 Q4).
590
+ * @param {{ executable: string, cwd: string, profile: string|undefined, environment: NodeJS.ProcessEnv, configArgs: string[] }} options
591
+ * @returns {Promise<JsonObject>}
592
+ */
593
+ export async function readCodexHookList(options) {
594
+ return withPreflightServer(options, async (child, cancelled) => {
595
+ const result = await request(child, 1, 'hooks/list', { cwds: [options.cwd] });
596
+ if (cancelled()) fail('preflight_cancelled');
597
+ return result;
598
+ });
599
+ }
600
+
568
601
  /** @param {LaunchPlan} plan @param {string[]} configuredRoots */
569
602
  export function makeCodexLaunchEnvelope(plan, configuredRoots) {
570
603
  const roots = deduplicate([...configuredRoots, ...plan.additionalRoots]);