@devrik-tools/claude-gates 0.4.0 → 0.7.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 (57) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/README.es.md +39 -4
  3. package/README.md +34 -5
  4. package/cli/config.mjs +126 -124
  5. package/cli/init.mjs +303 -276
  6. package/cli/install.mjs +281 -175
  7. package/cli/materialize.mjs +103 -102
  8. package/cli/registry.mjs +139 -136
  9. package/cli/smoke-fixtures.json +65 -0
  10. package/cli/task.mjs +140 -140
  11. package/package.json +1 -1
  12. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  13. package/plugins/gates/hooks/ask-adoption.mjs +147 -147
  14. package/plugins/gates/hooks/doctor.mjs +207 -207
  15. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +229 -0
  16. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +110 -88
  17. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +50 -50
  18. package/plugins/gates/hooks/gates/bash-commands/index.mjs +215 -215
  19. package/plugins/gates/hooks/gates/brief-approved/index.mjs +216 -0
  20. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +269 -265
  21. package/plugins/gates/hooks/gates/capability-map/index.mjs +701 -0
  22. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +527 -501
  23. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +48 -43
  24. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -83
  25. package/plugins/gates/hooks/gates/force-parallel/index.mjs +134 -119
  26. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -134
  27. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -187
  28. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -260
  29. package/plugins/gates/hooks/gates/lint-commit/index.mjs +152 -149
  30. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -180
  31. package/plugins/gates/hooks/gates/never-assume/index.mjs +59 -58
  32. package/plugins/gates/hooks/gates/no-blocking/index.mjs +163 -148
  33. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +127 -0
  34. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +183 -0
  35. package/plugins/gates/hooks/gates/protected-paths/index.mjs +149 -144
  36. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +91 -89
  37. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +263 -159
  38. package/plugins/gates/hooks/gates/risk-level/index.mjs +265 -263
  39. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +57 -56
  40. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +211 -131
  41. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +181 -184
  42. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -256
  43. package/plugins/gates/hooks/gates/staged-lint/index.mjs +187 -0
  44. package/plugins/gates/hooks/gates/stop-pending/index.mjs +169 -164
  45. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -187
  46. package/plugins/gates/hooks/gates/tool-map/index.mjs +168 -143
  47. package/plugins/gates/hooks/hooks.json +61 -0
  48. package/plugins/gates/hooks/lib/config.mjs +179 -172
  49. package/plugins/gates/hooks/lib/hook-io.mjs +367 -357
  50. package/plugins/gates/hooks/lib/signals.mjs +172 -127
  51. package/plugins/gates/hooks/wiring-check.mjs +227 -227
  52. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  53. package/plugins/tasks/hooks/hooks.json +26 -26
  54. package/plugins/tasks/hooks/lib/task-store.mjs +217 -197
  55. package/plugins/tasks/hooks/register-requests.mjs +145 -145
  56. package/plugins/tasks/hooks/session-tasks.mjs +108 -108
  57. package/registry.json +192 -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
+ }
@@ -157,6 +157,20 @@
157
157
  "needsState": true,
158
158
  "note": "auto-off unless a features tree exists; needs seeded .ai/feature_list.json + features dir."
159
159
  },
160
+ {
161
+ "id": "brief-approved",
162
+ "configKey": "requireApprovedBriefBeforeImplementing",
163
+ "enabledByDefault": false,
164
+ "type": "deny",
165
+ "payload": {
166
+ "tool_name": "Agent",
167
+ "tool_input": {
168
+ "prompt": "Nivel: STANDARD\nImplementa .ai/features/checkout/brief.md."
169
+ }
170
+ },
171
+ "needsState": true,
172
+ "note": "auto-off unless a features tree exists; needs a seeded .ai/features/<name>/brief.md with no approved frontmatter."
173
+ },
160
174
  {
161
175
  "id": "implementation-pipeline",
162
176
  "configKey": "requireImplementationPipeline",
@@ -385,6 +399,57 @@
385
399
  "tool_input": { "questions": [{ "question": "A or B?" }] }
386
400
  },
387
401
  "needsState": false
402
+ },
403
+ {
404
+ "id": "no-coauthor",
405
+ "configKey": "blockCoauthorTrailers",
406
+ "enabledByDefault": true,
407
+ "type": "deny",
408
+ "payload": {
409
+ "tool_name": "Bash",
410
+ "tool_input": {
411
+ "command": "git commit -m \"fix\n\nCo-Authored-By: Claude <noreply@anthropic.com>\""
412
+ }
413
+ },
414
+ "needsState": false
415
+ },
416
+ {
417
+ "id": "no-lint-suppression",
418
+ "configKey": "blockLintSuppression",
419
+ "enabledByDefault": true,
420
+ "type": "deny",
421
+ "payload": {
422
+ "tool_name": "Write",
423
+ "tool_input": {
424
+ "file_path": "/repo/a.js",
425
+ "content": "const x = 1; // eslint-disable-next-line no-unused-vars"
426
+ }
427
+ },
428
+ "needsState": false
429
+ },
430
+ {
431
+ "id": "staged-lint",
432
+ "configKey": "blockCommitWithStagedLintErrors",
433
+ "enabledByDefault": false,
434
+ "type": "deny",
435
+ "payload": {
436
+ "tool_name": "Bash",
437
+ "tool_input": { "command": "git commit -m \"wip\"" }
438
+ },
439
+ "needsState": true,
440
+ "note": "needs a git repo with a staged lintable file that fails lint and a resolvable linter; a bare payload cannot reproduce the staged-file scan."
441
+ },
442
+ {
443
+ "id": "atomic-commit",
444
+ "configKey": "blockNonAtomicCommits",
445
+ "enabledByDefault": false,
446
+ "type": "deny",
447
+ "payload": {
448
+ "tool_name": "Bash",
449
+ "tool_input": { "command": "git commit -m \"stuff\"" }
450
+ },
451
+ "needsState": true,
452
+ "note": "needs a git repo with a staged set that mixes >maxNatures natures or exceeds maxFiles; a bare payload cannot reproduce the staged-file scan."
388
453
  }
389
454
  ]
390
455
  }