@devrik-tools/claude-gates 0.3.1 → 0.6.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.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +25 -4
  3. package/README.md +75 -43
  4. package/cli/config.mjs +126 -124
  5. package/cli/index.mjs +154 -104
  6. package/cli/init.mjs +303 -276
  7. package/cli/install.mjs +281 -175
  8. package/cli/materialize.mjs +103 -102
  9. package/cli/registry.mjs +139 -136
  10. package/cli/smoke-fixtures.json +441 -0
  11. package/cli/smoke.mjs +129 -0
  12. package/cli/task.mjs +140 -140
  13. package/package.json +1 -1
  14. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  15. package/plugins/gates/hooks/ask-adoption.mjs +147 -147
  16. package/plugins/gates/hooks/doctor.mjs +207 -207
  17. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
  18. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
  19. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
  20. package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
  21. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
  22. package/plugins/gates/hooks/gates/capability-map/index.mjs +377 -0
  23. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
  24. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
  25. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
  26. package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
  27. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
  28. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
  29. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
  30. package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
  31. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
  32. package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
  33. package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
  34. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
  35. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
  36. package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
  37. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
  38. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
  39. package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
  40. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
  41. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
  42. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
  43. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
  44. package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
  45. package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
  46. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
  47. package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
  48. package/plugins/gates/hooks/hooks.json +51 -0
  49. package/plugins/gates/hooks/lib/config.mjs +179 -172
  50. package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
  51. package/plugins/gates/hooks/lib/signals.mjs +172 -127
  52. package/plugins/gates/hooks/wiring-check.mjs +227 -227
  53. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  54. package/plugins/tasks/hooks/hooks.json +26 -26
  55. package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
  56. package/plugins/tasks/hooks/register-requests.mjs +145 -145
  57. package/plugins/tasks/hooks/session-tasks.mjs +108 -108
  58. package/registry.json +171 -1
@@ -1,102 +1,103 @@
1
- // Materializes each gate's default params into the config that `init` writes, so every
2
- // configurable value (a whitelist, a pattern list, a threshold) lands in the user's
3
- // .ai/config.json ready to edit. The values come from the gates themselves — each gate,
4
- // run with CLAUDE_GATES_DUMP_DEFAULTS set, prints its own defaults — so there is a single
5
- // source of truth (the gate) and nothing is duplicated in the registry.
6
-
7
- import { execFileSync } from 'node:child_process';
8
- import { join } from 'node:path';
9
- import { REPOSITORY_ROOT } from './constants.mjs';
10
- import { allGates } from './registry.mjs';
11
- import { MODES } from './selection.mjs';
12
-
13
- const DUMP_ENV = 'CLAUDE_GATES_DUMP_DEFAULTS';
14
-
15
- /** A gate's `hooks/` directory: each plugin (registry's per-family `plugin`) owns its own. */
16
- function pluginHooksDir(pluginName) {
17
- return join(REPOSITORY_ROOT, 'plugins', pluginName, 'hooks');
18
- }
19
-
20
- /**
21
- * Runs one gate in defaults-dump mode and returns its built-in params. Not every gate
22
- * supports the defaults-dump protocol (only the `gates` plugin's PreToolUse gates do, via
23
- * runGate in hook-io.mjs); a script that does not recognize the env var, does not exist, or
24
- * errors for any other reason yields {} rather than aborting the whole init — the same
25
- * fallback already relied on before multi-plugin support.
26
- */
27
- function defaultParametersOf(gate) {
28
- try {
29
- const out = execFileSync(
30
- process.execPath,
31
- [join(pluginHooksDir(gate.plugin), gate.script)],
32
- { encoding: 'utf8', env: { ...process.env, [DUMP_ENV]: '1' } },
33
- );
34
- return JSON.parse(out).defaultParams ?? {};
35
- } catch {
36
- return {};
37
- }
38
- }
39
-
40
- /** The existing entry's params, stripped of `enabled` — {} for a boolean or missing entry. */
41
- function existingParamsOf(existingEntry) {
42
- if (!existingEntry || typeof existingEntry !== 'object') return {};
43
- const { enabled: _enabled, ...params } = existingEntry;
44
- return params;
45
- }
46
-
47
- /**
48
- * Turns the flat `{ configKey: enabled }` selection into the config's gates map. A gate
49
- * that declares params (per the registry) becomes `{ enabled, ...defaults }` so its knobs
50
- * are visible and editable; a gate with no params stays a plain boolean, keeping the file
51
- * compact. Only gates with params are spawned, so paramless selections cost nothing.
52
- *
53
- * `existingGates` (the project's current config, if any) and `mode` (the selection mode
54
- * this run used) together decide how much of an already-present gate survives:
55
- *
56
- * - mode === DEFAULTS: a "fill the gaps" sweep, not a directed choice. A gate already in
57
- * `existingGates` is carried over VERBATIM (enabled + every param) materializing
58
- * defaults must never be what resets a disablement or a custom pattern list the user
59
- * deliberately set.
60
- * - any other mode (ALL / FAMILIES / GRANULAR): the user named this gate (directly, or
61
- * via its family) this run, so `enabled` follows the new selection that is the whole
62
- * point of picking it. Its PARAMS still survive from the existing entry, since there is
63
- * no per-run way to pass new param values through `init`'s flags today; only `enabled`
64
- * is something this run actually decided.
65
- *
66
- * A gate absent from `existingGates` always gets a freshly computed value regardless of
67
- * mode. `mergeConfig` still does the key-by-key merge against the rest of the file
68
- * (unrelated top-level keys, gates the new registry dropped).
69
- */
70
- export function materializeGates(
71
- registry,
72
- enabledMap,
73
- existingGates = {},
74
- mode = MODES.DEFAULTS,
75
- ) {
76
- const gates = {};
77
- for (const gate of allGates(registry)) {
78
- const hasExisting = Object.prototype.hasOwnProperty.call(
79
- existingGates,
80
- gate.configKey,
81
- );
82
-
83
- if (hasExisting && mode === MODES.DEFAULTS) {
84
- gates[gate.configKey] = existingGates[gate.configKey];
85
- continue;
86
- }
87
-
88
- const enabled = enabledMap[gate.configKey] === true;
89
- const hasParameters = Array.isArray(gate.params) && gate.params.length > 0;
90
- if (!hasParameters) {
91
- gates[gate.configKey] = enabled;
92
- continue;
93
- }
94
-
95
- const defaults = defaultParametersOf(gate);
96
- const params = hasExisting
97
- ? { ...defaults, ...existingParamsOf(existingGates[gate.configKey]) }
98
- : defaults;
99
- gates[gate.configKey] = { enabled, ...params };
100
- }
101
- return gates;
102
- }
1
+ // Materializes each gate's default params into the config that `init` writes, so every
2
+ // configurable value (a whitelist, a pattern list, a threshold) lands in the user's
3
+ // .ai/config.json ready to edit. The values come from the gates themselves — each gate,
4
+ // run with CLAUDE_GATES_DUMP_DEFAULTS set, prints its own defaults — so there is a single
5
+ // source of truth (the gate) and nothing is duplicated in the registry.
6
+
7
+ import { execFileSync } from 'node:child_process';
8
+ import { join } from 'node:path';
9
+ import { REPOSITORY_ROOT } from './constants.mjs';
10
+ import { allGates } from './registry.mjs';
11
+ import { MODES } from './selection.mjs';
12
+
13
+ const DUMP_ENV = 'CLAUDE_GATES_DUMP_DEFAULTS';
14
+
15
+ /** A gate's `hooks/` directory: each plugin (registry's per-family `plugin`) owns its own. */
16
+ function pluginHooksDirectory(pluginName) {
17
+ return join(REPOSITORY_ROOT, 'plugins', pluginName, 'hooks');
18
+ }
19
+
20
+ /**
21
+ * Runs one gate in defaults-dump mode and returns its built-in params. Not every gate
22
+ * supports the defaults-dump protocol (only the `gates` plugin's PreToolUse gates do, via
23
+ * runGate in hook-io.mjs); a script that does not recognize the env var, does not exist, or
24
+ * errors for any other reason yields {} rather than aborting the whole init — the same
25
+ * fallback already relied on before multi-plugin support.
26
+ */
27
+ function defaultParametersOf(gate) {
28
+ try {
29
+ const out = execFileSync(
30
+ process.execPath,
31
+ [join(pluginHooksDirectory(gate.plugin), gate.script)],
32
+ { encoding: 'utf8', env: { ...process.env, [DUMP_ENV]: '1' } },
33
+ );
34
+ return JSON.parse(out).defaultParams ?? {};
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+
40
+ /** The existing entry's params, stripped of `enabled` — {} for a boolean or missing entry. */
41
+ function existingParametersOf(existingEntry) {
42
+ if (!existingEntry || typeof existingEntry !== 'object') return {};
43
+ const parameters = { ...existingEntry };
44
+ delete parameters.enabled;
45
+ return parameters;
46
+ }
47
+
48
+ /**
49
+ * Turns the flat `{ configKey: enabled }` selection into the config's gates map. A gate
50
+ * that declares params (per the registry) becomes `{ enabled, ...defaults }` so its knobs
51
+ * are visible and editable; a gate with no params stays a plain boolean, keeping the file
52
+ * compact. Only gates with params are spawned, so paramless selections cost nothing.
53
+ *
54
+ * `existingGates` (the project's current config, if any) and `mode` (the selection mode
55
+ * this run used) together decide how much of an already-present gate survives:
56
+ *
57
+ * - mode === DEFAULTS: a "fill the gaps" sweep, not a directed choice. A gate already in
58
+ * `existingGates` is carried over VERBATIM (enabled + every param) materializing
59
+ * defaults must never be what resets a disablement or a custom pattern list the user
60
+ * deliberately set.
61
+ * - any other mode (ALL / FAMILIES / GRANULAR): the user named this gate (directly, or
62
+ * via its family) this run, so `enabled` follows the new selection that is the whole
63
+ * point of picking it. Its PARAMS still survive from the existing entry, since there is
64
+ * no per-run way to pass new param values through `init`'s flags today; only `enabled`
65
+ * is something this run actually decided.
66
+ *
67
+ * A gate absent from `existingGates` always gets a freshly computed value regardless of
68
+ * mode. `mergeConfig` still does the key-by-key merge against the rest of the file
69
+ * (unrelated top-level keys, gates the new registry dropped).
70
+ */
71
+ export function materializeGates(
72
+ registry,
73
+ enabledMap,
74
+ existingGates = {},
75
+ mode = MODES.DEFAULTS,
76
+ ) {
77
+ const gates = {};
78
+ for (const gate of allGates(registry)) {
79
+ const hasExisting = Object.prototype.hasOwnProperty.call(
80
+ existingGates,
81
+ gate.configKey,
82
+ );
83
+
84
+ if (hasExisting && mode === MODES.DEFAULTS) {
85
+ gates[gate.configKey] = existingGates[gate.configKey];
86
+ continue;
87
+ }
88
+
89
+ const enabled = enabledMap[gate.configKey] === true;
90
+ const hasParameters = Array.isArray(gate.params) && gate.params.length > 0;
91
+ if (!hasParameters) {
92
+ gates[gate.configKey] = enabled;
93
+ continue;
94
+ }
95
+
96
+ const defaults = defaultParametersOf(gate);
97
+ const parameters = hasExisting
98
+ ? { ...defaults, ...existingParametersOf(existingGates[gate.configKey]) }
99
+ : defaults;
100
+ gates[gate.configKey] = { enabled, ...parameters };
101
+ }
102
+ return gates;
103
+ }
package/cli/registry.mjs CHANGED
@@ -1,136 +1,139 @@
1
- // Loads and validates registry.json — the catalog that menus, config and hooks derive from.
2
- // Validation is a zod schema so the shape is declared once and errors are uniform.
3
- // Fails loudly on a corrupt catalog: a menu built on invalid data would write a
4
- // configuration that no gate recognizes.
5
-
6
- import { readFileSync } from 'node:fs';
7
- import { z } from 'zod';
8
- import { REGISTRY_PATH } from './constants.mjs';
9
-
10
- export const HOOK_EVENTS = [
11
- 'PreToolUse',
12
- 'SessionStart',
13
- 'PostToolUse',
14
- 'Stop',
15
- 'UserPromptSubmit',
16
- ];
17
- export const TOOL_GROUPS = [
18
- 'write',
19
- 'shell',
20
- 'delegation',
21
- 'execution',
22
- 'question',
23
- ];
24
-
25
- /** The kinds of value a gate param can take, so the CLI can describe and validate it. */
26
- export const PARAM_TYPES = ['string', 'number', 'boolean', 'string[]'];
27
-
28
- const ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
29
- const CONFIG_KEY_PATTERN = /^[a-z][A-Za-z0-9]*$/;
30
- const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
31
- // A gate's script is a path relative to the plugin's hooks/ directory: `gates/x.mjs`
32
- // (a PreToolUse gate) or `x.mjs` (a session hook). Never absolute, never climbing out.
33
- const SCRIPT_PATTERN = /^(?!\/|[A-Za-z]:|\.\.\/)[\w./-]+\.mjs$/;
34
-
35
- /**
36
- * A configurable param a project may override. The registry declares that the param
37
- * EXISTS and its type; the default VALUE lives in the gate's own source, so the user
38
- * reads it there and knows exactly what a project override replaces.
39
- */
40
- const parameterSchema = z.object({
41
- name: z.string().regex(CONFIG_KEY_PATTERN, 'param name must be camelCase'),
42
- type: z.enum(PARAM_TYPES),
43
- description: z.string().min(1),
44
- });
45
-
46
- const gateSchema = z.object({
47
- id: z.string().regex(ID_PATTERN, 'gate id must be kebab-case'),
48
- configKey: z
49
- .string()
50
- .regex(CONFIG_KEY_PATTERN, 'configKey must be camelCase'),
51
- default: z.boolean(),
52
- event: z.enum(HOOK_EVENTS),
53
- tools: z.array(z.enum(TOOL_GROUPS)),
54
- script: z
55
- .string()
56
- .regex(SCRIPT_PATTERN, 'script must be an .mjs path relative to hooks/'),
57
- description: z.string().min(1),
58
- // Optional: a gate with no configurable params omits it.
59
- params: z.array(parameterSchema).optional(),
60
- });
61
-
62
- const familySchema = z.object({
63
- id: z.string().regex(ID_PATTERN, 'family id must be kebab-case'),
64
- name: z.string().min(1),
65
- description: z.string().min(1),
66
- // Which plugin's hooks/ directory the family's gate scripts resolve against. Optional so
67
- // existing families need no change: absent means the original 'gates' plugin.
68
- plugin: z.string().regex(ID_PATTERN, 'plugin id must be kebab-case').optional(),
69
- gates: z.array(gateSchema).min(1),
70
- });
71
-
72
- function duplicatesIn(items) {
73
- const seen = new Set();
74
- const duplicates = new Set();
75
- for (const item of items) {
76
- if (seen.has(item)) duplicates.add(item);
77
- seen.add(item);
78
- }
79
- return [...duplicates];
80
- }
81
-
82
- /** Uniqueness across families is not expressible per-field, so it is a second pass. */
83
- function duplicateProblems(candidate) {
84
- const families = Array.isArray(candidate?.families) ? candidate.families : [];
85
- const gates = families.flatMap((family) =>
86
- Array.isArray(family?.gates) ? family.gates : [],
87
- );
88
- return [
89
- ...duplicatesIn(families.map((family) => family?.id)).map(
90
- (id) => `duplicate family id: ${id}`,
91
- ),
92
- ...duplicatesIn(gates.map((gate) => gate?.id)).map(
93
- (id) => `duplicate gate id: ${id}`,
94
- ),
95
- ...duplicatesIn(gates.map((gate) => gate?.configKey)).map(
96
- (key) => `duplicate configKey: ${key}`,
97
- ),
98
- ];
99
- }
100
-
101
- export const registrySchema = z.object({
102
- gateVersion: z.string().regex(VERSION_PATTERN, 'gateVersion must be x.y.z'),
103
- families: z.array(familySchema).min(1),
104
- });
105
-
106
- /** Returns a list of human-readable problems; empty when the registry is valid. */
107
- export function validateRegistry(candidate) {
108
- const result = registrySchema.safeParse(candidate);
109
- const schemaProblems = result.success
110
- ? []
111
- : result.error.issues.map(
112
- (issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`,
113
- );
114
- return [...schemaProblems, ...duplicateProblems(candidate)];
115
- }
116
-
117
- export function loadRegistry(path = REGISTRY_PATH) {
118
- const candidate = JSON.parse(readFileSync(path, 'utf8'));
119
- const problems = validateRegistry(candidate);
120
- if (problems.length > 0) {
121
- throw new Error(`registry.json is invalid:\n- ${problems.join('\n- ')}`);
122
- }
123
- return candidate;
124
- }
125
-
126
- const DEFAULT_PLUGIN = 'gates';
127
-
128
- export function allGates(registry) {
129
- return registry.families.flatMap((family) =>
130
- family.gates.map((gate) => ({
131
- ...gate,
132
- family: family.id,
133
- plugin: family.plugin ?? DEFAULT_PLUGIN,
134
- })),
135
- );
136
- }
1
+ // Loads and validates registry.json — the catalog that menus, config and hooks derive from.
2
+ // Validation is a zod schema so the shape is declared once and errors are uniform.
3
+ // Fails loudly on a corrupt catalog: a menu built on invalid data would write a
4
+ // configuration that no gate recognizes.
5
+
6
+ import { readFileSync } from 'node:fs';
7
+ import { z } from 'zod';
8
+ import { REGISTRY_PATH } from './constants.mjs';
9
+
10
+ export const HOOK_EVENTS = [
11
+ 'PreToolUse',
12
+ 'SessionStart',
13
+ 'PostToolUse',
14
+ 'Stop',
15
+ 'UserPromptSubmit',
16
+ ];
17
+ export const TOOL_GROUPS = [
18
+ 'write',
19
+ 'shell',
20
+ 'delegation',
21
+ 'execution',
22
+ 'question',
23
+ ];
24
+
25
+ /** The kinds of value a gate param can take, so the CLI can describe and validate it. */
26
+ export const PARAM_TYPES = ['string', 'number', 'boolean', 'string[]'];
27
+
28
+ const ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
29
+ const CONFIG_KEY_PATTERN = /^[a-z][A-Za-z0-9]*$/;
30
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
31
+ // A gate's script is a path relative to the plugin's hooks/ directory: `gates/x.mjs`
32
+ // (a PreToolUse gate) or `x.mjs` (a session hook). Never absolute, never climbing out.
33
+ const SCRIPT_PATTERN = /^(?!\/|[A-Za-z]:|\.\.\/)[\w./-]+\.mjs$/;
34
+
35
+ /**
36
+ * A configurable param a project may override. The registry declares that the param
37
+ * EXISTS and its type; the default VALUE lives in the gate's own source, so the user
38
+ * reads it there and knows exactly what a project override replaces.
39
+ */
40
+ const parameterSchema = z.object({
41
+ name: z.string().regex(CONFIG_KEY_PATTERN, 'param name must be camelCase'),
42
+ type: z.enum(PARAM_TYPES),
43
+ description: z.string().min(1),
44
+ });
45
+
46
+ const gateSchema = z.object({
47
+ id: z.string().regex(ID_PATTERN, 'gate id must be kebab-case'),
48
+ configKey: z
49
+ .string()
50
+ .regex(CONFIG_KEY_PATTERN, 'configKey must be camelCase'),
51
+ default: z.boolean(),
52
+ event: z.enum(HOOK_EVENTS),
53
+ tools: z.array(z.enum(TOOL_GROUPS)),
54
+ script: z
55
+ .string()
56
+ .regex(SCRIPT_PATTERN, 'script must be an .mjs path relative to hooks/'),
57
+ description: z.string().min(1),
58
+ // Optional: a gate with no configurable params omits it.
59
+ params: z.array(parameterSchema).optional(),
60
+ });
61
+
62
+ const familySchema = z.object({
63
+ id: z.string().regex(ID_PATTERN, 'family id must be kebab-case'),
64
+ name: z.string().min(1),
65
+ description: z.string().min(1),
66
+ // Which plugin's hooks/ directory the family's gate scripts resolve against. Optional so
67
+ // existing families need no change: absent means the original 'gates' plugin.
68
+ plugin: z
69
+ .string()
70
+ .regex(ID_PATTERN, 'plugin id must be kebab-case')
71
+ .optional(),
72
+ gates: z.array(gateSchema).min(1),
73
+ });
74
+
75
+ function duplicatesIn(items) {
76
+ const seen = new Set();
77
+ const duplicates = new Set();
78
+ for (const item of items) {
79
+ if (seen.has(item)) duplicates.add(item);
80
+ seen.add(item);
81
+ }
82
+ return [...duplicates];
83
+ }
84
+
85
+ /** Uniqueness across families is not expressible per-field, so it is a second pass. */
86
+ function duplicateProblems(candidate) {
87
+ const families = Array.isArray(candidate?.families) ? candidate.families : [];
88
+ const gates = families.flatMap((family) =>
89
+ Array.isArray(family?.gates) ? family.gates : [],
90
+ );
91
+ return [
92
+ ...duplicatesIn(families.map((family) => family?.id)).map(
93
+ (id) => `duplicate family id: ${id}`,
94
+ ),
95
+ ...duplicatesIn(gates.map((gate) => gate?.id)).map(
96
+ (id) => `duplicate gate id: ${id}`,
97
+ ),
98
+ ...duplicatesIn(gates.map((gate) => gate?.configKey)).map(
99
+ (key) => `duplicate configKey: ${key}`,
100
+ ),
101
+ ];
102
+ }
103
+
104
+ export const registrySchema = z.object({
105
+ gateVersion: z.string().regex(VERSION_PATTERN, 'gateVersion must be x.y.z'),
106
+ families: z.array(familySchema).min(1),
107
+ });
108
+
109
+ /** Returns a list of human-readable problems; empty when the registry is valid. */
110
+ export function validateRegistry(candidate) {
111
+ const result = registrySchema.safeParse(candidate);
112
+ const schemaProblems = result.success
113
+ ? []
114
+ : result.error.issues.map(
115
+ (issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`,
116
+ );
117
+ return [...schemaProblems, ...duplicateProblems(candidate)];
118
+ }
119
+
120
+ export function loadRegistry(path = REGISTRY_PATH) {
121
+ const candidate = JSON.parse(readFileSync(path, 'utf8'));
122
+ const problems = validateRegistry(candidate);
123
+ if (problems.length > 0) {
124
+ throw new Error(`registry.json is invalid:\n- ${problems.join('\n- ')}`);
125
+ }
126
+ return candidate;
127
+ }
128
+
129
+ const DEFAULT_PLUGIN = 'gates';
130
+
131
+ export function allGates(registry) {
132
+ return registry.families.flatMap((family) =>
133
+ family.gates.map((gate) => ({
134
+ ...gate,
135
+ family: family.id,
136
+ plugin: family.plugin ?? DEFAULT_PLUGIN,
137
+ })),
138
+ );
139
+ }