@devrik-tools/claude-gates 0.1.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 (80) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/README.es.md +219 -0
  3. package/README.md +222 -0
  4. package/cli/__tests__/config.test.mjs +101 -0
  5. package/cli/__tests__/init-flags.test.mjs +111 -0
  6. package/cli/__tests__/registry-gates-consistency.test.mjs +44 -0
  7. package/cli/__tests__/selection.test.mjs +200 -0
  8. package/cli/config.mjs +113 -0
  9. package/cli/constants.mjs +36 -0
  10. package/cli/index.mjs +97 -0
  11. package/cli/init.mjs +256 -0
  12. package/cli/install.mjs +80 -0
  13. package/cli/materialize.mjs +51 -0
  14. package/cli/registry.mjs +127 -0
  15. package/cli/selection.mjs +83 -0
  16. package/package.json +73 -0
  17. package/plugins/gates/.claude-plugin/plugin.json +9 -0
  18. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -0
  19. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +92 -0
  20. package/plugins/gates/hooks/gates/bash-commands/index.mjs +233 -0
  21. package/plugins/gates/hooks/gates/bash-commands/test.mjs +113 -0
  22. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +177 -0
  23. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +111 -0
  24. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +456 -0
  25. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +143 -0
  26. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +118 -0
  27. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +109 -0
  28. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +49 -0
  29. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +68 -0
  30. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +100 -0
  31. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +97 -0
  32. package/plugins/gates/hooks/gates/forge-flow/index.mjs +112 -0
  33. package/plugins/gates/hooks/gates/forge-flow/test.mjs +135 -0
  34. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +186 -0
  35. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +86 -0
  36. package/plugins/gates/hooks/gates/intent-flow/index.mjs +238 -0
  37. package/plugins/gates/hooks/gates/intent-flow/test.mjs +136 -0
  38. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +166 -0
  39. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +119 -0
  40. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +85 -0
  41. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +65 -0
  42. package/plugins/gates/hooks/gates/never-assume/index.mjs +55 -0
  43. package/plugins/gates/hooks/gates/never-assume/test.mjs +78 -0
  44. package/plugins/gates/hooks/gates/no-blocking/index.mjs +142 -0
  45. package/plugins/gates/hooks/gates/no-blocking/test.mjs +108 -0
  46. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +120 -0
  47. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +106 -0
  48. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +202 -0
  49. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +131 -0
  50. package/plugins/gates/hooks/gates/protected-paths/index.mjs +147 -0
  51. package/plugins/gates/hooks/gates/protected-paths/test.mjs +75 -0
  52. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +64 -0
  53. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +99 -0
  54. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +150 -0
  55. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +101 -0
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +203 -0
  57. package/plugins/gates/hooks/gates/risk-level/test.mjs +125 -0
  58. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +50 -0
  59. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +73 -0
  60. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +111 -0
  61. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +80 -0
  62. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +99 -0
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +78 -0
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +251 -0
  65. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +163 -0
  66. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -0
  67. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +81 -0
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +181 -0
  69. package/plugins/gates/hooks/gates/test-matrix/test.mjs +87 -0
  70. package/plugins/gates/hooks/gates/tool-map/index.mjs +140 -0
  71. package/plugins/gates/hooks/gates/tool-map/test.mjs +87 -0
  72. package/plugins/gates/hooks/hooks.json +266 -0
  73. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +154 -0
  74. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +154 -0
  75. package/plugins/gates/hooks/lib/config.mjs +165 -0
  76. package/plugins/gates/hooks/lib/hook-io.mjs +208 -0
  77. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +132 -0
  78. package/plugins/tasks/hooks/lib/task-store.mjs +159 -0
  79. package/plugins/tasks/hooks/register-requests.mjs +108 -0
  80. package/registry.json +668 -0
package/cli/init.mjs ADDED
@@ -0,0 +1,256 @@
1
+ // `claude-gates init` — the interactive flow: scope → mode → picks → confirm → write.
2
+ // Every decision can also be passed as a flag so CI and scripts can run it without a TTY.
3
+
4
+ import * as prompts from '@clack/prompts';
5
+ import {
6
+ SCOPES,
7
+ configPathFor,
8
+ readConfig,
9
+ mergeConfig,
10
+ writeConfig,
11
+ } from './config.mjs';
12
+ import { EXIT_CODE } from './constants.mjs';
13
+ import { installPlugin, pluginInstallCommand } from './install.mjs';
14
+ import { materializeGates } from './materialize.mjs';
15
+ import { loadRegistry, allGates } from './registry.mjs';
16
+ import {
17
+ MODES,
18
+ resolveSelection,
19
+ adoptionOf,
20
+ summarize,
21
+ } from './selection.mjs';
22
+
23
+ const MODE_OPTIONS = [
24
+ {
25
+ value: MODES.DEFAULTS,
26
+ label: 'Recommended defaults',
27
+ hint: 'gates marked default in the registry',
28
+ },
29
+ { value: MODES.ALL, label: 'Everything', hint: 'every gate in every family' },
30
+ { value: MODES.FAMILIES, label: 'By family', hint: 'pick whole families' },
31
+ { value: MODES.GRANULAR, label: 'Granular', hint: 'pick individual gates' },
32
+ {
33
+ value: MODES.NONE,
34
+ label: 'Nothing',
35
+ hint: "record a 'no' so you are not asked again",
36
+ },
37
+ ];
38
+
39
+ /** commander option name → selection mode */
40
+ const MODE_BY_OPTION = {
41
+ defaults: MODES.DEFAULTS,
42
+ all: MODES.ALL,
43
+ none: MODES.NONE,
44
+ families: MODES.FAMILIES,
45
+ gates: MODES.GRANULAR,
46
+ };
47
+
48
+ /**
49
+ * Turns commander's options object into the decisions `runInit` needs.
50
+ * `scope`/`mode` stay `null` when no flag decided them (prompted later).
51
+ */
52
+ export function normalizeOptions(options = {}) {
53
+ const modeFlags = Object.keys(MODE_BY_OPTION).filter((name) => options[name]);
54
+ if (modeFlags.length > 1)
55
+ throw new Error(`Pick one of --${modeFlags.join(', --')}`);
56
+ if (options.project && options.global)
57
+ throw new Error('Pick one of --project, --global');
58
+
59
+ let scope = null;
60
+ if (options.project) scope = SCOPES.PROJECT;
61
+ if (options.global) scope = SCOPES.GLOBAL;
62
+
63
+ return {
64
+ scope,
65
+ mode: modeFlags.length === 1 ? MODE_BY_OPTION[modeFlags[0]] : null,
66
+ families: Array.isArray(options.families) ? options.families : [],
67
+ gates: Array.isArray(options.gates) ? options.gates : [],
68
+ yes: Boolean(options.yes),
69
+ dryRun: Boolean(options.dryRun),
70
+ install: options.install,
71
+ };
72
+ }
73
+
74
+ function bail(value) {
75
+ if (prompts.isCancel(value)) {
76
+ prompts.cancel('Nothing written.');
77
+ process.exit(EXIT_CODE.FAILURE);
78
+ }
79
+ return value;
80
+ }
81
+
82
+ async function askScope(cwd) {
83
+ return bail(
84
+ await prompts.select({
85
+ message: 'Where should these gates apply?',
86
+ options: [
87
+ {
88
+ value: SCOPES.PROJECT,
89
+ label: 'This project',
90
+ hint: configPathFor(SCOPES.PROJECT, { cwd }),
91
+ },
92
+ {
93
+ value: SCOPES.GLOBAL,
94
+ label: 'Globally (fallback for every project)',
95
+ hint: configPathFor(SCOPES.GLOBAL),
96
+ },
97
+ ],
98
+ }),
99
+ );
100
+ }
101
+
102
+ async function askMode() {
103
+ return bail(
104
+ await prompts.select({
105
+ message: 'What do you want to adopt?',
106
+ options: MODE_OPTIONS,
107
+ }),
108
+ );
109
+ }
110
+
111
+ async function askFamilies(registry) {
112
+ return bail(
113
+ await prompts.multiselect({
114
+ message: 'Pick families (space to toggle, enter to confirm)',
115
+ options: registry.families.map((family) => ({
116
+ value: family.id,
117
+ label: family.name,
118
+ hint: family.description,
119
+ })),
120
+ initialValues: registry.families
121
+ .filter((family) => family.gates.some((gate) => gate.default))
122
+ .map((family) => family.id),
123
+ required: false,
124
+ }),
125
+ );
126
+ }
127
+
128
+ async function askGates(registry) {
129
+ const options = {};
130
+ for (const family of registry.families) {
131
+ options[family.name] = family.gates.map((gate) => ({
132
+ value: gate.id,
133
+ label: gate.id,
134
+ hint: gate.description,
135
+ }));
136
+ }
137
+ return bail(
138
+ await prompts.groupMultiselect({
139
+ message: 'Pick gates (space to toggle, enter to confirm)',
140
+ options,
141
+ initialValues: allGates(registry)
142
+ .filter((gate) => gate.default)
143
+ .map((gate) => gate.id),
144
+ required: false,
145
+ }),
146
+ );
147
+ }
148
+
149
+ /** `claude plugin install <plugin>@<marketplace>`, read from the marketplace manifest, never hard-coded. */
150
+
151
+ /** Fills in whatever the flags left undecided, asking only when there is a TTY. */
152
+ async function decide(flags, registry, cwd, interactive) {
153
+ const scope =
154
+ flags.scope ?? (interactive ? await askScope(cwd) : SCOPES.PROJECT);
155
+ const mode = flags.mode ?? (interactive ? await askMode() : MODES.DEFAULTS);
156
+ const picks = { families: flags.families, gates: flags.gates };
157
+ if (interactive && mode === MODES.FAMILIES && picks.families.length === 0) {
158
+ picks.families = await askFamilies(registry);
159
+ }
160
+ if (interactive && mode === MODES.GRANULAR && picks.gates.length === 0) {
161
+ picks.gates = await askGates(registry);
162
+ }
163
+ return { scope, mode, picks };
164
+ }
165
+
166
+ function renderSummary(registry, gatesMap) {
167
+ return summarize(registry, gatesMap)
168
+ .map((row) => {
169
+ const list = row.enabled.length > 0 ? ` — ${row.enabled.join(', ')}` : '';
170
+ return `${row.name}: ${row.enabled.length}/${row.total}${list}`;
171
+ })
172
+ .join('\n');
173
+ }
174
+
175
+ async function confirmWrite(io, fileExists) {
176
+ const confirmed = bail(
177
+ await io.confirm({
178
+ message: fileExists
179
+ ? 'File exists. Merge this selection into it?'
180
+ : 'Write this file?',
181
+ }),
182
+ );
183
+ if (!confirmed) {
184
+ io.cancel('Nothing written.');
185
+ process.exit(EXIT_CODE.FAILURE);
186
+ }
187
+ }
188
+
189
+ export async function runInit(
190
+ options,
191
+ { cwd = process.cwd(), io = prompts } = {},
192
+ ) {
193
+ const flags = normalizeOptions(options);
194
+ const registry = loadRegistry();
195
+ const interactive = !flags.yes && Boolean(process.stdin.isTTY);
196
+
197
+ if (interactive) io.intro('claude-gates');
198
+
199
+ const { scope, mode, picks } = await decide(
200
+ flags,
201
+ registry,
202
+ cwd,
203
+ interactive,
204
+ );
205
+ const gates = resolveSelection(registry, mode, picks);
206
+ const path = configPathFor(scope, { cwd });
207
+ const existing = readConfig(path);
208
+
209
+ if (existing.corrupt) {
210
+ io.log.error(
211
+ `${path} exists but is not valid JSON. Fix or remove it first; nothing written.`,
212
+ );
213
+ process.exit(EXIT_CODE.FAILURE);
214
+ }
215
+
216
+ const merged = mergeConfig(existing.data, {
217
+ adopted: adoptionOf(gates),
218
+ gates: materializeGates(registry, gates),
219
+ gateVersion: registry.gateVersion,
220
+ });
221
+
222
+ io.note(renderSummary(registry, gates), `Selection (${scope}) → ${path}`);
223
+
224
+ if (flags.dryRun) {
225
+ io.outro('Dry run: nothing written.');
226
+ return { path, config: merged, written: false };
227
+ }
228
+
229
+ if (interactive) await confirmWrite(io, existing.exists);
230
+
231
+ writeConfig(path, merged);
232
+
233
+ // Install based on the selection, unless the user turned everything off or opted out.
234
+ // Additive: `claude plugin install` merges the gate hooks next to whatever is already
235
+ // configured. If it cannot run, we fall back to printing the manual command.
236
+ const wantsInstall = flags.install !== false && adoptionOf(gates) !== false;
237
+ if (!wantsInstall) {
238
+ io.outro(`Written ${path}.`);
239
+ return { path, config: merged, written: true, installed: false };
240
+ }
241
+
242
+ const result = installPlugin(scope, { cwd });
243
+ if (result.installed) {
244
+ io.outro(
245
+ `Written ${path} and installed the plugin (${result.scope} scope). ` +
246
+ 'Restart the session (or run /plugin) for the gates to load.',
247
+ );
248
+ } else {
249
+ io.log.warn(
250
+ `Config written, but the plugin was not installed automatically (${result.reason}). ` +
251
+ `Install it yourself with: ${pluginInstallCommand()}`,
252
+ );
253
+ io.outro(`Written ${path}.`);
254
+ }
255
+ return { path, config: merged, written: true, installed: result.installed };
256
+ }
@@ -0,0 +1,80 @@
1
+ // Installs the gates plugin into Claude Code from the selection `init` just wrote.
2
+ // Additive by design: `claude plugin install` merges the plugin's hooks alongside whatever
3
+ // the user already has — it never rewrites their settings. If the `claude` binary is not
4
+ // reachable, this degrades to printing the manual command, and `init` still succeeds.
5
+
6
+ import { execFileSync } from 'node:child_process';
7
+ import { readFileSync } from 'node:fs';
8
+ import { SCOPES } from './config.mjs';
9
+ import { MARKETPLACE_PATH, REPOSITORY_ROOT } from './constants.mjs';
10
+
11
+ const CLAUDE_BIN = 'claude';
12
+
13
+ // Config scope decides where the plugin is installed: a project selection stays local to
14
+ // this project (its .claude/settings.json); a global selection installs for every project.
15
+ const PLUGIN_SCOPE = Object.freeze({
16
+ [SCOPES.PROJECT]: 'project',
17
+ [SCOPES.GLOBAL]: 'user',
18
+ });
19
+
20
+ function marketplaceAndPlugin() {
21
+ const manifest = JSON.parse(readFileSync(MARKETPLACE_PATH, 'utf8'));
22
+ const [plugin] = manifest.plugins;
23
+ return { marketplace: manifest.name, plugin: plugin.name };
24
+ }
25
+
26
+ /** The `claude plugin install …` line, read from the manifest, never hard-coded. */
27
+ export function pluginInstallCommand() {
28
+ const { marketplace, plugin } = marketplaceAndPlugin();
29
+ return `claude plugin install ${plugin}@${marketplace}`;
30
+ }
31
+
32
+ function claude(commandArguments) {
33
+ return execFileSync(CLAUDE_BIN, commandArguments, {
34
+ encoding: 'utf8',
35
+ stdio: 'pipe',
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Registers the marketplace (idempotent: a second add just reports it already exists, which
41
+ * is not fatal) and installs the plugin at the scope matching the config choice.
42
+ * Returns { installed, scope } on success, or { installed:false, reason } to fall back to
43
+ * the printed command.
44
+ */
45
+ export function installPlugin(configScope, { cwd = process.cwd() } = {}) {
46
+ const { marketplace, plugin } = marketplaceAndPlugin();
47
+ const scope = PLUGIN_SCOPE[configScope] ?? 'user';
48
+
49
+ try {
50
+ try {
51
+ claude([
52
+ 'plugin',
53
+ 'marketplace',
54
+ 'add',
55
+ REPOSITORY_ROOT,
56
+ '--scope',
57
+ 'user',
58
+ ]);
59
+ } catch {
60
+ // Already registered, or the marketplace add is a no-op — install can still proceed.
61
+ }
62
+ claude([
63
+ 'plugin',
64
+ 'install',
65
+ `${plugin}@${marketplace}`,
66
+ '--yes',
67
+ '--scope',
68
+ scope,
69
+ ]);
70
+ return { installed: true, scope };
71
+ } catch (error) {
72
+ const reason =
73
+ error?.code === 'ENOENT'
74
+ ? 'the `claude` command was not found on PATH'
75
+ : (error?.stderr || error?.message || String(error))
76
+ .trim()
77
+ .split('\n')[0];
78
+ return { installed: false, reason, cwd };
79
+ }
80
+ }
@@ -0,0 +1,51 @@
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
+
12
+ const DUMP_ENV = 'CLAUDE_GATES_DUMP_DEFAULTS';
13
+ const PLUGIN_HOOKS_DIR = join(REPOSITORY_ROOT, 'plugins', 'gates', 'hooks');
14
+
15
+ /**
16
+ * Runs one gate in defaults-dump mode and returns its built-in params. The registry only
17
+ * lists gates that exist on disk (enforced by a test), so this always resolves; a genuine
18
+ * runtime failure yields {} rather than aborting the whole init.
19
+ */
20
+ function defaultParametersOf(gate) {
21
+ try {
22
+ const out = execFileSync(
23
+ process.execPath,
24
+ [join(PLUGIN_HOOKS_DIR, gate.script)],
25
+ { encoding: 'utf8', env: { ...process.env, [DUMP_ENV]: '1' } },
26
+ );
27
+ return JSON.parse(out).defaultParams ?? {};
28
+ } catch {
29
+ return {};
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Turns the flat `{ configKey: enabled }` selection into the config's gates map. A gate
35
+ * that declares params (per the registry) becomes `{ enabled, ...defaults }` so its knobs
36
+ * are visible and editable; a gate with no params stays a plain boolean, keeping the file
37
+ * compact. Only gates with params are spawned, so paramless selections cost nothing.
38
+ */
39
+ export function materializeGates(registry, enabledMap) {
40
+ const gates = {};
41
+ for (const gate of allGates(registry)) {
42
+ const enabled = enabledMap[gate.configKey] === true;
43
+ const hasParameters = Array.isArray(gate.params) && gate.params.length > 0;
44
+ if (!hasParameters) {
45
+ gates[gate.configKey] = enabled;
46
+ continue;
47
+ }
48
+ gates[gate.configKey] = { enabled, ...defaultParametersOf(gate) };
49
+ }
50
+ return gates;
51
+ }
@@ -0,0 +1,127 @@
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
+ gates: z.array(gateSchema).min(1),
67
+ });
68
+
69
+ function duplicatesIn(items) {
70
+ const seen = new Set();
71
+ const duplicates = new Set();
72
+ for (const item of items) {
73
+ if (seen.has(item)) duplicates.add(item);
74
+ seen.add(item);
75
+ }
76
+ return [...duplicates];
77
+ }
78
+
79
+ /** Uniqueness across families is not expressible per-field, so it is a second pass. */
80
+ function duplicateProblems(candidate) {
81
+ const families = Array.isArray(candidate?.families) ? candidate.families : [];
82
+ const gates = families.flatMap((family) =>
83
+ Array.isArray(family?.gates) ? family.gates : [],
84
+ );
85
+ return [
86
+ ...duplicatesIn(families.map((family) => family?.id)).map(
87
+ (id) => `duplicate family id: ${id}`,
88
+ ),
89
+ ...duplicatesIn(gates.map((gate) => gate?.id)).map(
90
+ (id) => `duplicate gate id: ${id}`,
91
+ ),
92
+ ...duplicatesIn(gates.map((gate) => gate?.configKey)).map(
93
+ (key) => `duplicate configKey: ${key}`,
94
+ ),
95
+ ];
96
+ }
97
+
98
+ export const registrySchema = z.object({
99
+ gateVersion: z.string().regex(VERSION_PATTERN, 'gateVersion must be x.y.z'),
100
+ families: z.array(familySchema).min(1),
101
+ });
102
+
103
+ /** Returns a list of human-readable problems; empty when the registry is valid. */
104
+ export function validateRegistry(candidate) {
105
+ const result = registrySchema.safeParse(candidate);
106
+ const schemaProblems = result.success
107
+ ? []
108
+ : result.error.issues.map(
109
+ (issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`,
110
+ );
111
+ return [...schemaProblems, ...duplicateProblems(candidate)];
112
+ }
113
+
114
+ export function loadRegistry(path = REGISTRY_PATH) {
115
+ const candidate = JSON.parse(readFileSync(path, 'utf8'));
116
+ const problems = validateRegistry(candidate);
117
+ if (problems.length > 0) {
118
+ throw new Error(`registry.json is invalid:\n- ${problems.join('\n- ')}`);
119
+ }
120
+ return candidate;
121
+ }
122
+
123
+ export function allGates(registry) {
124
+ return registry.families.flatMap((family) =>
125
+ family.gates.map((gate) => ({ ...gate, family: family.id })),
126
+ );
127
+ }
@@ -0,0 +1,83 @@
1
+ // Pure selection logic: turns a mode plus the user's picks into the
2
+ // `gates` map (`configKey -> boolean`) that hooks read. No I/O here so it is
3
+ // fully testable without a terminal.
4
+
5
+ import { allGates } from './registry.mjs';
6
+
7
+ export const MODES = Object.freeze({
8
+ ALL: 'all',
9
+ FAMILIES: 'families',
10
+ GRANULAR: 'granular',
11
+ DEFAULTS: 'defaults',
12
+ NONE: 'none',
13
+ });
14
+
15
+ function assertKnown(chosen, known, kind) {
16
+ const unknown = [...chosen].filter((id) => !known.has(id));
17
+ if (unknown.length > 0)
18
+ throw new Error(`Unknown ${kind} id(s): ${unknown.join(', ')}`);
19
+ }
20
+
21
+ /** mode → (gates, registry, picks) → Set of enabled gate ids */
22
+ const STRATEGIES = {
23
+ [MODES.ALL]: (gates) => new Set(gates.map((gate) => gate.id)),
24
+ [MODES.DEFAULTS]: (gates) =>
25
+ new Set(gates.filter((gate) => gate.default).map((gate) => gate.id)),
26
+ [MODES.NONE]: () => new Set(),
27
+ [MODES.FAMILIES]: (gates, registry, picks) => {
28
+ const chosen = new Set(picks.families ?? []);
29
+ assertKnown(
30
+ chosen,
31
+ new Set(registry.families.map((family) => family.id)),
32
+ 'family',
33
+ );
34
+ return new Set(
35
+ gates.filter((gate) => chosen.has(gate.family)).map((gate) => gate.id),
36
+ );
37
+ },
38
+ [MODES.GRANULAR]: (gates, _registry, picks) => {
39
+ const chosen = new Set(picks.gates ?? []);
40
+ assertKnown(chosen, new Set(gates.map((gate) => gate.id)), 'gate');
41
+ return chosen;
42
+ },
43
+ };
44
+
45
+ /**
46
+ * @param {object} registry validated registry
47
+ * @param {string} mode one of MODES
48
+ * @param {{ families?: string[], gates?: string[] }} picks family ids (FAMILIES) or gate ids (GRANULAR)
49
+ * @returns {Record<string, boolean>} configKey -> enabled
50
+ */
51
+ export function resolveSelection(registry, mode, picks = {}) {
52
+ const strategy = STRATEGIES[mode];
53
+ if (!strategy) throw new Error(`Unknown selection mode: ${mode}`);
54
+ const gates = allGates(registry);
55
+ const enabledIds = strategy(gates, registry, picks);
56
+ const result = {};
57
+ for (const gate of gates) result[gate.configKey] = enabledIds.has(gate.id);
58
+ return result;
59
+ }
60
+
61
+ /** Adoption status derived from the resolved map — what `ask-adoption` reads later. */
62
+ export function adoptionOf(gatesMap) {
63
+ const values = Object.values(gatesMap);
64
+ if (values.length === 0 || values.every((enabled) => enabled === false))
65
+ return false;
66
+ if (values.every((enabled) => enabled === true)) return true;
67
+ return 'partial';
68
+ }
69
+
70
+ /** Human summary grouped by family, for the confirmation step and the final report. */
71
+ export function summarize(registry, gatesMap) {
72
+ return registry.families.map((family) => {
73
+ const enabled = family.gates
74
+ .filter((gate) => gatesMap[gate.configKey] === true)
75
+ .map((gate) => gate.id);
76
+ return {
77
+ family: family.id,
78
+ name: family.name,
79
+ enabled,
80
+ total: family.gates.length,
81
+ };
82
+ });
83
+ }
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@devrik-tools/claude-gates",
3
+ "version": "0.1.0",
4
+ "description": "Installable, deterministic gates (hooks) for Claude Code: block destructive commands, protected paths, and enforce delegation/spec/quality rules. Configurable per project.",
5
+ "keywords": [
6
+ "claude-code",
7
+ "hooks",
8
+ "gates",
9
+ "guardrails",
10
+ "pretooluse",
11
+ "policy-enforcement",
12
+ "ai-agent"
13
+ ],
14
+ "type": "module",
15
+ "bin": {
16
+ "claude-gates": "./cli/index.mjs"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/DevRik99/claude-gates.git"
21
+ },
22
+ "homepage": "https://github.com/DevRik99/claude-gates#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/DevRik99/claude-gates/issues"
25
+ },
26
+ "author": "Devrik",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "files": [
31
+ "cli",
32
+ "registry.json",
33
+ "README.md",
34
+ "README.es.md",
35
+ "plugins",
36
+ ".claude-plugin"
37
+ ],
38
+ "scripts": {
39
+ "test": "node --test",
40
+ "test:gate": "node --test",
41
+ "registry:check": "node cli/index.mjs registry --check",
42
+ "lint": "eslint .",
43
+ "lint:fix": "eslint . --fix",
44
+ "format": "prettier --write \"cli/**/*.mjs\" \"*.mjs\" \"*.json\" \".claude-plugin/*.json\" \"plugins/gates/.claude-plugin/*.json\" \"plugins/gates/hooks/hooks.json\"",
45
+ "format:check": "prettier --check \"cli/**/*.mjs\" \"*.mjs\""
46
+ },
47
+ "engines": {
48
+ "node": ">=22.5.0"
49
+ },
50
+ "license": "MIT",
51
+ "dependencies": {
52
+ "@clack/prompts": "1.7.0",
53
+ "commander": "^15.0.0",
54
+ "find-up": "^8.0.0",
55
+ "zod": "^4.5.2"
56
+ },
57
+ "devDependencies": {
58
+ "@cspell/eslint-plugin": "^10.1.1",
59
+ "@eslint/js": "^10.0.1",
60
+ "eslint": "^10.9.1",
61
+ "eslint-config-prettier": "^10.1.8",
62
+ "eslint-plugin-boundaries": "^7.2.0",
63
+ "eslint-plugin-import-x": "^4.17.1",
64
+ "eslint-plugin-n": "^18.3.0",
65
+ "eslint-plugin-prettier": "^5.5.6",
66
+ "eslint-plugin-promise": "^7.3.0",
67
+ "eslint-plugin-sonarjs": "^4.2.0",
68
+ "eslint-plugin-unicorn": "^74.0.0",
69
+ "eslint-plugin-unused-imports": "^4.4.1",
70
+ "globals": "^17.11.0",
71
+ "prettier": "^3.9.6"
72
+ }
73
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "gates",
3
+ "version": "0.1.0",
4
+ "description": "Deterministic gates for Claude Code: destructive-command blocks, protected paths, delegation briefs, spec-driven flow and session-start validation. Selection lives in config, not in code.",
5
+ "author": {
6
+ "name": "Devrik"
7
+ },
8
+ "keywords": ["hooks", "guards", "gates", "PreToolUse", "SessionStart"]
9
+ }