@brunobracaioli/aibrain 0.4.0-beta.11 → 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
@@ -19,8 +19,9 @@ For licensed accounts, `aisac claude` downloads the AISAC Engineering pack for
19
19
  the session, verifies its Ed25519 signature against the key pinned in this
20
20
  release, and loads it as the `aisac-engineering` plugin from the session's
21
21
  private folder, which is deleted on exit. Any failure starts Claude without the
22
- pack. `aisac setup --lang pt-BR|en` picks the pack language without a network
23
- call. The pack is never part of this npm package.
22
+ pack. Once set up, `aisac setup --lang pt-BR|en` picks the pack language without a
23
+ network call; on a machine that was never set up it runs the full setup in that
24
+ language. The pack is never part of this npm package.
24
25
 
25
26
  `aisac codex` (Codex 0.164.0 or later) loads the same verified pack for that
26
27
  session only: its skills are added to the Codex app-server the launch owns
@@ -40,6 +41,12 @@ Licensed `aisac claude` sessions also load guardrail hooks as the session plugin
40
41
  them from inside a session.
41
42
  - **Code:** it ships in this package and runs locally. Nothing comes from the
42
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.
43
50
 
44
51
  Licensed sessions of `aisac claude` and `aisac codex` can also load reviewed MCP
45
52
  servers: `github` (read-only, token in `GITHUB_PERSONAL_ACCESS_TOKEN`), `supabase`
@@ -89,7 +96,7 @@ connection. Its failure or timeout closes only that auxiliary connection.
89
96
  ```sh
90
97
  # Install the published beta:
91
98
  npm install -g @aisac/cli@beta
92
- aisac setup --client both
99
+ aisac setup # configures the clients on PATH; --client claude|codex|both to choose
93
100
  aisac doctor --json
94
101
  aisac claude
95
102
  aisac codex
@@ -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.11';
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.11';
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';
@@ -52,6 +54,9 @@ function usage() {
52
54
  console.log(
53
55
  `${COMMAND} ${VERSION}\n\nUsage:\n ${COMMAND} setup [--client claude|codex|both] [--context-profile upstream|conservative]\n [--compact-at 1..95] [--keep-threshold 0..1] [--preserve-recent 1..100]\n [--min-reduction 0..1] [--truncate-head-chars 0..4096]\n [--max-state-tokens 1000..25000] [--max-request-tokens 1000..30000]\n [--tool-policy compatible|read_only] [--goal TEXT]\n ${COMMAND} doctor [--json]\n ${COMMAND} status [--json]\n ${COMMAND} disable\n ${COMMAND} setup --undo\n ${COMMAND} claude [-- native-args...]\n ${COMMAND} codex [-- native-args...]\n\nPolicy notes:\n Automatic monitoring asks for compaction at compact-at percent; state/request\n limits are estimated upper bounds and do not override server quotas.\n min-reduction, truncate-head-chars and tool-policy apply only to Claude;\n Codex keeps native compaction and same-session recovery.`,
54
56
  );
57
+ console.log(
58
+ 'Clients: setup without --client configures the clients installed on PATH (Claude Code, Codex, or both).',
59
+ );
55
60
  console.log(
56
61
  'Telemetry: setup --telemetry on|off (omitted preserves the stored choice; fresh defaults off).',
57
62
  );
@@ -59,10 +64,10 @@ function usage() {
59
64
  'Claude shadow: setup --claude-shadow on|off and --claude-diagnostics on|off (preview stays applied=false).',
60
65
  );
61
66
  console.log(
62
- 'AISAC Engineering: `aisac claude` and `aisac codex` (Codex 0.164+) load the licensed method; setup --lang pt-BR|en picks its language; setup and doctor report the license; status stays offline. `aibrain` is an alias of `aisac`.',
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`.',
63
68
  );
64
69
  console.log(
65
- '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.',
66
71
  );
67
72
  console.log(
68
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.',
@@ -187,8 +192,11 @@ function policyOverrides(args) {
187
192
  return values;
188
193
  }
189
194
 
190
- /** @param {string[]} args @param {Awaited<ReturnType<typeof readConfig>>} previous */
191
- function selectedPolicy(args, previous) {
195
+ /**
196
+ * @param {string[]} args @param {Awaited<ReturnType<typeof readConfig>>} previous
197
+ * @param {'claude' | 'codex' | 'both'} clients
198
+ */
199
+ function selectedPolicy(args, previous, clients) {
192
200
  const explicitProfile = option(args, '--context-profile');
193
201
  const explicitV2 =
194
202
  explicitProfile !== undefined ||
@@ -227,7 +235,6 @@ function selectedPolicy(args, previous) {
227
235
  if (option(args, '--goal') === undefined && inherited.goal !== undefined)
228
236
  overrides.goal = inherited.goal;
229
237
  }
230
- const clients = option(args, '--client') ?? 'both';
231
238
  if (
232
239
  clients === 'codex' &&
233
240
  args.some(
@@ -416,6 +423,32 @@ async function disclosureAccepted(
416
423
  }
417
424
  }
418
425
 
426
+ /**
427
+ * Client selection when `--client` is omitted: the native clients found on PATH. Codex
428
+ * also needs `node`, the same pair the explicit `--client codex` path checks.
429
+ * @returns {'claude' | 'codex' | 'both' | null}
430
+ */
431
+ function detectedClients() {
432
+ const hasClaude = resolveNative('claude') !== null;
433
+ const hasCodex = resolveNative('codex') !== null && resolveNative('node') !== null;
434
+ if (hasClaude && hasCodex) return 'both';
435
+ if (hasClaude) return 'claude';
436
+ return hasCodex ? 'codex' : null;
437
+ }
438
+
439
+ /** @param {string | undefined} requested @returns {'claude' | 'codex' | 'both'} */
440
+ function setupClients(requested) {
441
+ if (requested !== undefined) {
442
+ if (requested !== 'claude' && requested !== 'codex' && requested !== 'both')
443
+ throw new Error('invalid_client');
444
+ return requested;
445
+ }
446
+ const detected = detectedClients();
447
+ if (detected === null) throw new Error('no_native_client_found');
448
+ console.log(`Clients detected on PATH: ${detected} (pass --client to choose).`);
449
+ return detected;
450
+ }
451
+
419
452
  /** @param {string[]} args */
420
453
  async function setup(args) {
421
454
  if (args.includes('--yes')) throw new Error('explicit_disclosure_flag_required');
@@ -453,8 +486,10 @@ async function setup(args) {
453
486
  throw new Error('setup_undo_cannot_have_options');
454
487
  const lang = option(args, '--lang');
455
488
  if (lang !== undefined && !isPackLocale(lang)) throw new Error('invalid_lang');
456
- if (lang !== undefined && args.length === 2) return setPackLocale(lang);
457
489
  const previous = await readConfig();
490
+ // `--lang` alone only switches the language of an existing setup. Before the first
491
+ // setup it runs the full setup (token included) and stores the language at the end.
492
+ if (lang !== undefined && args.length === 2 && previous !== null) return setPackLocale(lang);
458
493
  if (await updateLocalOptOut(previous, args)) return;
459
494
  const localOptOut =
460
495
  option(args, '--telemetry') === 'off' &&
@@ -462,8 +497,7 @@ async function setup(args) {
462
497
  if (localOptOut) return disableTelemetryLocally(previous);
463
498
  const platform = platformCheck();
464
499
  if (!platform.ok) throw new Error(`${platform.code}:${platform.message}`);
465
- const clients = option(args, '--client') ?? 'both';
466
- if (!['claude', 'codex', 'both'].includes(clients)) throw new Error('invalid_client');
500
+ const clients = setupClients(option(args, '--client'));
467
501
  if (
468
502
  clients === 'codex' &&
469
503
  (option(args, '--claude-shadow') !== undefined ||
@@ -472,7 +506,7 @@ async function setup(args) {
472
506
  throw new Error('claude_observation_requires_claude_client');
473
507
  const endpoint = option(args, '--endpoint') ?? ENDPOINT;
474
508
  validateEndpoint(endpoint);
475
- const selection = selectedPolicy(args, previous);
509
+ const selection = selectedPolicy(args, previous, clients);
476
510
  const telemetry = telemetrySelection(args, previous);
477
511
  const claudeObservation = claudeObservationSelection(args, previous);
478
512
  const policy = selection.policy;
@@ -569,8 +603,48 @@ async function setup(args) {
569
603
  if (line !== null) console.log(line);
570
604
  }
571
605
 
572
- /** Extension state plus whether `format-on-edit` has a formatter in this folder (FR8). */
573
- 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) {
574
648
  const summary = await extensionsSummary(stateRoot());
575
649
  // A token server is skipped at launch when its variable is unset (names only).
576
650
  const mcpMissingEnv = sessionServers(summary.mcp, 'claude', process.env).missingEnv.map(
@@ -581,7 +655,9 @@ async function doctorExtensions() {
581
655
  : (await anyFormatter(process.cwd()))
582
656
  ? 'ready'
583
657
  : 'no_formatter_found';
584
- 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 };
585
661
  }
586
662
 
587
663
  /** @param {boolean} json */
@@ -678,7 +754,10 @@ async function doctor(json) {
678
754
  },
679
755
  engineering,
680
756
  engineeringPack: await engineeringPackState(),
681
- extensions: await doctorExtensions(),
757
+ extensions: await doctorExtensions({
758
+ executable: native.codex,
759
+ version: codexCompatibility?.version,
760
+ }),
682
761
  paidOrPilot: probe?.ok
683
762
  ? 'provider_free_probe_ok; paid entitlement or pilot invitation remains server-enforced'
684
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
+ }