@brunobracaioli/aibrain 0.4.0-beta.3 → 0.4.0-beta.5

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
@@ -28,6 +28,18 @@ roles. Nothing is written to `~/.codex`, `~/.agents` or the project, and the
28
28
  session's folder is deleted on exit. An older Codex starts without the pack and
29
29
  says `codex_outdated`.
30
30
 
31
+ Licensed `aisac claude` sessions also load guardrail hooks as the session plugin
32
+ `aisac-guardrails`.
33
+ - **Default on:** `secrets-guard` refuses secret files by name. Four more hooks
34
+ keep continuity: a repository snapshot at start and after a compaction, a
35
+ docs reminder, and the NOTES/spec/CHANGELOG pass.
36
+ - **Default off:** `git-guard`, `destructive-guard` and `format-on-edit`.
37
+ - **Changing them:** `aisac hooks` lists them, and `aisac hooks enable|disable
38
+ <id>` or `aisac hooks reset` changes the next session. The agent cannot change
39
+ them from inside a session.
40
+ - **Code:** it ships in this package and runs locally. Nothing comes from the
41
+ server.
42
+
31
43
  Codex capture also requires the `flock` utility from `util-linux`, normally
32
44
  included in Linux/WSL. The launcher and doctor check availability before a
33
45
  monitored Codex session starts. Capture coordination is released by the kernel
@@ -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.3';
19
+ export const ADAPTER_VERSION = '0.4.0-beta.5';
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
@@ -36,8 +36,10 @@ import {
36
36
  resolvePackLocale,
37
37
  writeStoredLocale,
38
38
  } from './engineering-pack.mjs';
39
+ import { extensionsSummary, hooksCommand } from './extensions-cli.mjs';
40
+ import { anyFormatter } from './guardrails/format.mjs';
39
41
 
40
- const VERSION = '0.4.0-beta.3';
42
+ const VERSION = '0.4.0-beta.5';
41
43
  // The command is `aisac`; `aibrain` is an alias installed from the same entry.
42
44
  const COMMAND = 'aisac';
43
45
  const ENDPOINT = 'https://mcp.ai-brain.aisac.cloud/mcp';
@@ -57,6 +59,9 @@ function usage() {
57
59
  console.log(
58
60
  '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`.',
59
61
  );
62
+ console.log(
63
+ '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.',
64
+ );
60
65
  }
61
66
 
62
67
  // Telemetry is opt-in; setup omits the flag to preserve the stored choice.
@@ -539,6 +544,16 @@ async function setup(args) {
539
544
  if (line !== null) console.log(line);
540
545
  }
541
546
 
547
+ /** Extension state plus whether `format-on-edit` has a formatter in this folder (FR8). */
548
+ async function doctorExtensions() {
549
+ const summary = await extensionsSummary(stateRoot());
550
+ if (!summary.hooks.includes('format-on-edit')) return { ...summary, formatOnEdit: 'off' };
551
+ return {
552
+ ...summary,
553
+ formatOnEdit: (await anyFormatter(process.cwd())) ? 'ready' : 'no_formatter_found',
554
+ };
555
+ }
556
+
542
557
  /** @param {boolean} json */
543
558
  async function doctor(json) {
544
559
  const platform = platformCheck();
@@ -633,6 +648,7 @@ async function doctor(json) {
633
648
  },
634
649
  engineering,
635
650
  engineeringPack: await engineeringPackState(),
651
+ extensions: await doctorExtensions(),
636
652
  paidOrPilot: probe?.ok
637
653
  ? 'provider_free_probe_ok; paid entitlement or pilot invitation remains server-enforced'
638
654
  : config
@@ -669,6 +685,7 @@ async function status(json) {
669
685
  config?.claudeObservation?.disclosedAt !== undefined,
670
686
  },
671
687
  engineeringPack: await engineeringPackState(),
688
+ extensions: await extensionsSummary(stateRoot()),
672
689
  monitoredSession: null,
673
690
  monitoringStatus: 'not_observed',
674
691
  };
@@ -717,6 +734,7 @@ async function main(argv) {
717
734
  else console.log(records);
718
735
  return;
719
736
  }
737
+ if (command === 'hooks') return hooksCommand(rest);
720
738
  if (command === 'disable') {
721
739
  if (rest.length > 0) throw new Error('disable_does_not_accept_options');
722
740
  return removeOwnedSetup();
@@ -0,0 +1,140 @@
1
+ // @ts-check
2
+ /**
3
+ * The AISAC Engineering extension catalog (spec aisac-engineering-extensions,
4
+ * FR1, FR2). It ships with the client and is the only source of hooks: the
5
+ * server and the licensed pack can neither add nor change entries (ADR-0048).
6
+ * The MCP catalog arrives in E6c.
7
+ */
8
+
9
+ /** @typedef {'pt-BR' | 'en'} CatalogLocale */
10
+ /** @typedef {'PreToolUse' | 'PostToolUse' | 'UserPromptSubmit' | 'SessionStart' | 'PreCompact' | 'Stop'} HookEvent */
11
+ /** @typedef {'block' | 'advise' | 'context'} HookMode */
12
+ /**
13
+ * @typedef {object} HookBinding
14
+ * @property {HookEvent} event
15
+ * @property {string} [matcher] Tool names (PreToolUse, PostToolUse) or SessionStart sources.
16
+ */
17
+ /**
18
+ * @typedef {object} HookEntry
19
+ * @property {string} id
20
+ * @property {HookMode} mode
21
+ * @property {boolean} defaultOn
22
+ * @property {readonly ('claude' | 'codex')[]} clients
23
+ * @property {readonly HookBinding[]} bindings
24
+ * @property {Readonly<Record<CatalogLocale, string>>} description
25
+ */
26
+
27
+ export const CATALOG_ID = /^[a-z][a-z0-9-]{0,31}$/u;
28
+
29
+ const EDIT_TOOLS = 'Edit|MultiEdit|Write';
30
+
31
+ /** @type {readonly HookEntry[]} */
32
+ export const HOOK_CATALOG = Object.freeze([
33
+ {
34
+ id: 'secrets-guard',
35
+ mode: 'block',
36
+ defaultOn: true,
37
+ clients: ['claude'],
38
+ bindings: [
39
+ { event: 'PreToolUse', matcher: 'Read|Edit|MultiEdit|Write|NotebookEdit|Grep|Bash' },
40
+ ],
41
+ description: {
42
+ en: 'Refuses reading or writing secret files (.env, keys, credentials); decides from names only.',
43
+ 'pt-BR':
44
+ 'Recusa ler ou gravar arquivos de segredo (.env, chaves, credenciais); decide só pelo nome.',
45
+ },
46
+ },
47
+ {
48
+ id: 'git-guard',
49
+ mode: 'block',
50
+ defaultOn: false,
51
+ clients: ['claude'],
52
+ bindings: [{ event: 'PreToolUse', matcher: 'Bash' }],
53
+ description: {
54
+ en: 'Refuses git push to main or master and force pushes (--force-with-lease passes).',
55
+ 'pt-BR': 'Recusa git push na main ou master e push forçado (--force-with-lease passa).',
56
+ },
57
+ },
58
+ {
59
+ id: 'destructive-guard',
60
+ mode: 'block',
61
+ defaultOn: false,
62
+ clients: ['claude'],
63
+ bindings: [{ event: 'PreToolUse', matcher: 'Bash' }],
64
+ description: {
65
+ en: 'Refuses rm -rf on /, ~, . or .. and DROP DATABASE, DROP TABLE or TRUNCATE.',
66
+ 'pt-BR': 'Recusa rm -rf em /, ~, . ou .. e DROP DATABASE, DROP TABLE ou TRUNCATE.',
67
+ },
68
+ },
69
+ {
70
+ id: 'format-on-edit',
71
+ mode: 'advise',
72
+ defaultOn: false,
73
+ clients: ['claude'],
74
+ bindings: [{ event: 'PostToolUse', matcher: EDIT_TOOLS }],
75
+ description: {
76
+ en: "Formats the edited file with the project's own formatter, when one is installed.",
77
+ 'pt-BR': 'Formata o arquivo editado com o formatador do próprio projeto, quando houver.',
78
+ },
79
+ },
80
+ {
81
+ id: 'docs-reminder',
82
+ mode: 'advise',
83
+ defaultOn: true,
84
+ clients: ['claude'],
85
+ bindings: [{ event: 'PostToolUse', matcher: EDIT_TOOLS }, { event: 'UserPromptSubmit' }],
86
+ description: {
87
+ en: 'After a source file changes in a project with docs/, reminds once per turn to update the docs.',
88
+ 'pt-BR':
89
+ 'Quando um arquivo de código muda num projeto com docs/, lembra uma vez por turno de atualizar a documentação.',
90
+ },
91
+ },
92
+ {
93
+ id: 'session-context',
94
+ mode: 'context',
95
+ defaultOn: true,
96
+ clients: ['claude'],
97
+ bindings: [{ event: 'SessionStart', matcher: 'startup|resume|clear' }],
98
+ description: {
99
+ en: 'Starts the session with a short repository snapshot (branch, status, recent commits).',
100
+ 'pt-BR': 'Abre a sessão com um resumo do repositório (branch, status, commits recentes).',
101
+ },
102
+ },
103
+ {
104
+ id: 'compact-continuity',
105
+ mode: 'context',
106
+ defaultOn: true,
107
+ clients: ['claude'],
108
+ bindings: [{ event: 'PreCompact' }, { event: 'SessionStart', matcher: 'compact' }],
109
+ description: {
110
+ en: 'Saves a repository snapshot before a compaction and restores it right after.',
111
+ 'pt-BR': 'Salva um resumo do repositório antes do compact e o devolve logo depois.',
112
+ },
113
+ },
114
+ {
115
+ id: 'continuity-reminder',
116
+ mode: 'block',
117
+ defaultOn: true,
118
+ clients: ['claude'],
119
+ bindings: [
120
+ { event: 'PostToolUse', matcher: EDIT_TOOLS },
121
+ { event: 'UserPromptSubmit' },
122
+ { event: 'Stop' },
123
+ ],
124
+ description: {
125
+ en: 'In a project with NOTES.md, asks once per turn to update NOTES.md, the spec or the CHANGELOG after code changes.',
126
+ 'pt-BR':
127
+ 'Num projeto com NOTES.md, pede uma vez por turno para atualizar NOTES.md, a spec ou o CHANGELOG depois de mudanças no código.',
128
+ },
129
+ },
130
+ ]);
131
+
132
+ /** @param {string} id @returns {HookEntry | undefined} */
133
+ export function hookById(id) {
134
+ return HOOK_CATALOG.find((entry) => entry.id === id);
135
+ }
136
+
137
+ /** @returns {string[]} */
138
+ export function hookIds() {
139
+ return HOOK_CATALOG.map((entry) => entry.id);
140
+ }
@@ -0,0 +1,239 @@
1
+ // @ts-check
2
+ /**
3
+ * `aisac hooks` (spec aisac-engineering-extensions FR4): list, enable,
4
+ * disable and reset guardrail hooks, offline. On a TTY without arguments it
5
+ * opens a checklist. Choices apply to the next session; running sessions
6
+ * keep their set.
7
+ */
8
+ import { emitKeypressEvents } from 'node:readline';
9
+ import { HOOK_CATALOG, hookById, hookIds } from './extensions-catalog.mjs';
10
+ import {
11
+ readPreferences,
12
+ resolveHooks,
13
+ unknownHookIds,
14
+ writePreferences,
15
+ } from './extensions-preferences.mjs';
16
+ import { readLastLaunch, readStoredLocale, resolvePackLocale } from './engineering-pack.mjs';
17
+ import { stateRoot } from './state.mjs';
18
+
19
+ const USAGE_EXIT_CODE = 2;
20
+ const ID_WIDTH = Math.max(...HOOK_CATALOG.map((entry) => entry.id.length));
21
+ const NEXT_SESSION =
22
+ 'Applies to the next `aisac claude` session; running sessions keep their current set.';
23
+ const LICENSE_NOTE = 'Hooks load only with an active AISAC Engineering license.';
24
+ const CHECKLIST_HEADER =
25
+ 'AISAC Engineering hooks: up/down moves, space toggles, enter saves, q cancels.';
26
+ const DEFAULT_COLUMNS = 80;
27
+ const MIN_COLUMNS = 20;
28
+ const ELLIPSIS = '…';
29
+
30
+ /**
31
+ * Cut a checklist row to the terminal width. The redraw moves the cursor up one
32
+ * row per hook, which only holds while no row wraps; one column stays free so
33
+ * no terminal wraps at the last column either.
34
+ * @param {string} line
35
+ * @param {number | undefined} columns
36
+ */
37
+ export function fitLine(line, columns) {
38
+ const total =
39
+ typeof columns === 'number' && Number.isInteger(columns) && columns > 0
40
+ ? columns
41
+ : DEFAULT_COLUMNS;
42
+ const width = Math.max(MIN_COLUMNS, total) - 1;
43
+ const characters = [...line];
44
+ if (characters.length <= width) return line;
45
+ return `${characters.slice(0, width - 1).join('')}${ELLIPSIS}`;
46
+ }
47
+
48
+ /**
49
+ * @typedef {object} ChecklistState
50
+ * @property {number} cursor
51
+ * @property {boolean[]} enabled
52
+ */
53
+
54
+ /**
55
+ * One key press in the checklist (pure, tested without a TTY).
56
+ * @param {ChecklistState} state
57
+ * @param {{ name?: string, ctrl?: boolean, sequence?: string }} key
58
+ * @returns {{ state: ChecklistState, done: 'save' | 'cancel' | null }}
59
+ */
60
+ export function checklistStep(state, key) {
61
+ const last = state.enabled.length - 1;
62
+ if ((key.ctrl === true && key.name === 'c') || key.name === 'escape' || key.name === 'q')
63
+ return { state, done: 'cancel' };
64
+ if (key.name === 'return' || key.name === 'enter') return { state, done: 'save' };
65
+ if (key.name === 'up' || key.name === 'k')
66
+ return { state: { ...state, cursor: Math.max(0, state.cursor - 1) }, done: null };
67
+ if (key.name === 'down' || key.name === 'j')
68
+ return { state: { ...state, cursor: Math.min(last, state.cursor + 1) }, done: null };
69
+ if (key.name === 'space' || key.sequence === ' ') {
70
+ const enabled = state.enabled.map((value, index) => (index === state.cursor ? !value : value));
71
+ return { state: { ...state, enabled }, done: null };
72
+ }
73
+ return { state, done: null };
74
+ }
75
+
76
+ /** @param {'pt-BR' | 'en'} locale @param {ReturnType<typeof resolveHooks>} hooks @param {ChecklistState} [state] */
77
+ function renderLines(locale, hooks, state) {
78
+ return hooks.map((hook, index) => {
79
+ const enabled = state === undefined ? hook.enabled : state.enabled[index];
80
+ const pointer = state === undefined ? ' ' : index === state.cursor ? '>' : ' ';
81
+ const source = state === undefined ? ` ${hook.source.padEnd(7)}` : '';
82
+ return `${pointer} [${enabled ? 'x' : ' '}] ${hook.entry.id.padEnd(ID_WIDTH)} ${hook.entry.mode.padEnd(8)}${source} ${hook.entry.description[locale]}`;
83
+ });
84
+ }
85
+
86
+ /** @param {string} root */
87
+ async function licensedLastLaunch(root) {
88
+ const last = await readLastLaunch(root);
89
+ return last?.state === 'loaded';
90
+ }
91
+
92
+ /**
93
+ * Store explicit choices, keeping MCP choices as they are.
94
+ * @param {Record<string, boolean>} hooks
95
+ */
96
+ async function saveHooks(hooks) {
97
+ const current = await readPreferences(stateRoot());
98
+ await writePreferences({ hooks, mcp: current.status === 'valid' ? current.mcp : {} });
99
+ if (current.status === 'invalid')
100
+ console.log('The previous extensions file was invalid and was replaced.');
101
+ }
102
+
103
+ /** @param {string[]} ids */
104
+ function unknownIds(ids) {
105
+ return ids.filter((id) => hookById(id) === undefined);
106
+ }
107
+
108
+ /** @param {boolean} licensed */
109
+ function afterChange(licensed) {
110
+ console.log(NEXT_SESSION);
111
+ if (!licensed) console.log(LICENSE_NOTE);
112
+ }
113
+
114
+ /**
115
+ * Interactive checklist. Resolves to the new enabled flags, or null on cancel.
116
+ * @param {'pt-BR' | 'en'} locale @param {ReturnType<typeof resolveHooks>} hooks
117
+ * @returns {Promise<boolean[] | null>}
118
+ */
119
+ function runChecklist(locale, hooks) {
120
+ return new Promise((resolve) => {
121
+ let state = { cursor: 0, enabled: hooks.map((hook) => hook.enabled) };
122
+ const draw = (/** @type {boolean} */ first) => {
123
+ if (!first) process.stdout.write(`\u001b[${hooks.length}A\u001b[J`);
124
+ const rows = renderLines(locale, hooks, state).map((line) =>
125
+ fitLine(line, process.stdout.columns),
126
+ );
127
+ process.stdout.write(`${rows.join('\n')}\n`);
128
+ };
129
+ console.log(CHECKLIST_HEADER);
130
+ draw(true);
131
+ emitKeypressEvents(process.stdin);
132
+ process.stdin.setRawMode(true);
133
+ process.stdin.resume();
134
+ /** @param {string} _text @param {{ name?: string, ctrl?: boolean, sequence?: string }} key */
135
+ const onKey = (_text, key) => {
136
+ const step = checklistStep(state, key ?? {});
137
+ state = step.state;
138
+ if (step.done === null) return draw(false);
139
+ process.stdin.off('keypress', onKey);
140
+ process.stdin.setRawMode(false);
141
+ process.stdin.pause();
142
+ resolve(step.done === 'save' ? state.enabled : null);
143
+ };
144
+ process.stdin.on('keypress', onKey);
145
+ });
146
+ }
147
+
148
+ /**
149
+ * `aisac hooks [list|enable|disable|reset] [ids...] [--json]`.
150
+ * @param {string[]} args
151
+ */
152
+ export async function hooksCommand(args) {
153
+ const json = args.includes('--json');
154
+ const words = args.filter((arg) => arg !== '--json');
155
+ const [action = 'list', ...ids] = words;
156
+ const root = stateRoot();
157
+ const locale = resolvePackLocale(await readStoredLocale(root));
158
+ const preferences = await readPreferences(root);
159
+ const hooks = resolveHooks(preferences);
160
+ const licensed = await licensedLastLaunch(root);
161
+
162
+ if (action === 'list' && ids.length === 0) {
163
+ if (json) {
164
+ console.log(
165
+ JSON.stringify({
166
+ preferences: preferences.status,
167
+ licensedLastLaunch: licensed,
168
+ hooks: hooks.map((hook) => ({
169
+ id: hook.entry.id,
170
+ enabled: hook.enabled,
171
+ source: hook.source,
172
+ mode: hook.entry.mode,
173
+ clients: hook.entry.clients,
174
+ description: hook.entry.description[locale],
175
+ })),
176
+ unknown: unknownHookIds(preferences),
177
+ }),
178
+ );
179
+ return;
180
+ }
181
+ if (args.length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
182
+ const chosen = await runChecklist(locale, hooks);
183
+ if (chosen === null) return console.log('No change.');
184
+ /** @type {Record<string, boolean>} */
185
+ const choices = preferences.status === 'valid' ? { ...preferences.hooks } : {};
186
+ hooks.forEach((hook, index) => {
187
+ if (chosen[index] !== hook.enabled) choices[hook.entry.id] = chosen[index] === true;
188
+ });
189
+ await saveHooks(choices);
190
+ return afterChange(licensed);
191
+ }
192
+ console.log('AISAC Engineering hooks (Claude Code):');
193
+ console.log(renderLines(locale, hooks).join('\n'));
194
+ if (preferences.status === 'invalid')
195
+ console.log('The extensions file is invalid: the defaults apply. Run `aisac hooks reset`.');
196
+ console.log(
197
+ 'Change with `aisac hooks enable <id>`, `aisac hooks disable <id>` or `aisac hooks reset`.',
198
+ );
199
+ if (!licensed) console.log(LICENSE_NOTE);
200
+ return;
201
+ }
202
+ if (action === 'reset' && ids.length === 0) {
203
+ await saveHooks({});
204
+ console.log('Hooks reset to the defaults.');
205
+ return afterChange(licensed);
206
+ }
207
+ if ((action === 'enable' || action === 'disable') && ids.length > 0) {
208
+ const unknown = unknownIds(ids);
209
+ if (unknown.length > 0) {
210
+ console.error(
211
+ `aisac: unknown hook id: ${unknown.join(', ')}. Valid: ${hookIds().join(', ')}.`,
212
+ );
213
+ process.exitCode = USAGE_EXIT_CODE;
214
+ return;
215
+ }
216
+ /** @type {Record<string, boolean>} */
217
+ const choices = preferences.status === 'valid' ? { ...preferences.hooks } : {};
218
+ for (const id of ids) choices[id] = action === 'enable';
219
+ await saveHooks(choices);
220
+ console.log(`${action === 'enable' ? 'Enabled' : 'Disabled'}: ${ids.join(', ')}.`);
221
+ return afterChange(licensed);
222
+ }
223
+ console.error(
224
+ 'aisac: usage: aisac hooks [--json] | aisac hooks enable <id>... | aisac hooks disable <id>... | aisac hooks reset',
225
+ );
226
+ process.exitCode = USAGE_EXIT_CODE;
227
+ }
228
+
229
+ /** Offline extension summary for `status` and `doctor`. @param {string} root */
230
+ export async function extensionsSummary(root) {
231
+ const preferences = await readPreferences(root);
232
+ return {
233
+ preferences: preferences.status,
234
+ hooks: resolveHooks(preferences)
235
+ .filter((hook) => hook.enabled)
236
+ .map((hook) => hook.entry.id),
237
+ unknown: unknownHookIds(preferences),
238
+ };
239
+ }
@@ -0,0 +1,122 @@
1
+ // @ts-check
2
+ /**
3
+ * The user's extension choices (spec aisac-engineering-extensions FR3):
4
+ * `<state>/extensions.json`, mode 0600, written atomically, at most 16 KiB.
5
+ * Only explicit choices are stored; a missing id means the catalog default.
6
+ * An invalid file never fails a launch: hooks fall back to the defaults and
7
+ * every MCP server stays off.
8
+ */
9
+ import { lstat, readFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { atomicJson, ensureStateRoot } from './state.mjs';
12
+ import { CATALOG_ID, HOOK_CATALOG, hookById } from './extensions-catalog.mjs';
13
+
14
+ export const EXTENSIONS_FILE = 'extensions.json';
15
+ const SCHEMA_VERSION = 1;
16
+ const MAX_PREFERENCES_BYTES = 16 * 1024;
17
+ const MAX_CHOICES = 64;
18
+ const TOP_LEVEL_KEYS = ['hooks', 'mcp', 'schemaVersion'];
19
+
20
+ /**
21
+ * @typedef {object} ExtensionPreferences
22
+ * @property {'missing' | 'valid' | 'invalid'} status
23
+ * @property {Record<string, boolean>} hooks
24
+ * @property {Record<string, boolean>} mcp
25
+ */
26
+
27
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
28
+ function isRecord(value) {
29
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
30
+ }
31
+
32
+ /** @param {unknown} value @returns {Record<string, boolean> | null} */
33
+ function choices(value) {
34
+ if (!isRecord(value)) return null;
35
+ const entries = Object.entries(value);
36
+ if (entries.length > MAX_CHOICES) return null;
37
+ /** @type {Record<string, boolean>} */
38
+ const result = {};
39
+ for (const [id, enabled] of entries) {
40
+ if (!CATALOG_ID.test(id) || typeof enabled !== 'boolean') return null;
41
+ result[id] = enabled;
42
+ }
43
+ return result;
44
+ }
45
+
46
+ /**
47
+ * Validate a parsed preferences document. @param {unknown} value
48
+ * @returns {{ hooks: Record<string, boolean>, mcp: Record<string, boolean> } | null}
49
+ */
50
+ export function parsePreferences(value) {
51
+ if (!isRecord(value)) return null;
52
+ const keys = Object.keys(value).sort();
53
+ if (keys.length !== TOP_LEVEL_KEYS.length || keys.some((key, i) => key !== TOP_LEVEL_KEYS[i]))
54
+ return null;
55
+ if (value.schemaVersion !== SCHEMA_VERSION) return null;
56
+ const hooks = choices(value.hooks);
57
+ const mcp = choices(value.mcp);
58
+ return hooks === null || mcp === null ? null : { hooks, mcp };
59
+ }
60
+
61
+ /** @param {string} root @returns {string} */
62
+ export function preferencesPath(root) {
63
+ return join(root, EXTENSIONS_FILE);
64
+ }
65
+
66
+ /** @param {string} root @returns {Promise<ExtensionPreferences>} */
67
+ export async function readPreferences(root) {
68
+ const path = preferencesPath(root);
69
+ try {
70
+ const info = await lstat(path);
71
+ if (!info.isFile() || info.size > MAX_PREFERENCES_BYTES)
72
+ return { status: 'invalid', hooks: {}, mcp: {} };
73
+ const parsed = parsePreferences(JSON.parse(await readFile(path, 'utf8')));
74
+ return parsed === null
75
+ ? { status: 'invalid', hooks: {}, mcp: {} }
76
+ : { status: 'valid', ...parsed };
77
+ } catch (error) {
78
+ const missing = error instanceof Error && 'code' in error && error.code === 'ENOENT';
79
+ return { status: missing ? 'missing' : 'invalid', hooks: {}, mcp: {} };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Store choices. Only valid catalog ids reach this point (the CLI checks).
85
+ * @param {{ hooks: Record<string, boolean>, mcp: Record<string, boolean> }} value
86
+ */
87
+ export async function writePreferences(value) {
88
+ const root = await ensureStateRoot();
89
+ await atomicJson(preferencesPath(root), {
90
+ schemaVersion: SCHEMA_VERSION,
91
+ hooks: value.hooks,
92
+ mcp: value.mcp,
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Each catalog hook with its effective state. An invalid file means defaults.
98
+ * @param {ExtensionPreferences} preferences
99
+ */
100
+ export function resolveHooks(preferences) {
101
+ const chosen = preferences.status === 'valid' ? preferences.hooks : {};
102
+ return HOOK_CATALOG.map((entry) => {
103
+ const choice = chosen[entry.id];
104
+ return {
105
+ entry,
106
+ enabled: choice ?? entry.defaultOn,
107
+ source: /** @type {'default' | 'choice'} */ (choice === undefined ? 'default' : 'choice'),
108
+ };
109
+ });
110
+ }
111
+
112
+ /** @param {ExtensionPreferences} preferences @returns {string[]} */
113
+ export function enabledHookIds(preferences) {
114
+ return resolveHooks(preferences)
115
+ .filter((hook) => hook.enabled)
116
+ .map((hook) => hook.entry.id);
117
+ }
118
+
119
+ /** Ids stored in the file that this client's catalog does not know. @param {ExtensionPreferences} preferences */
120
+ export function unknownHookIds(preferences) {
121
+ return Object.keys(preferences.hooks).filter((id) => hookById(id) === undefined);
122
+ }