@devrik-tools/claude-gates 0.1.2 → 0.2.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 (106) hide show
  1. package/.claude-plugin/marketplace.json +7 -1
  2. package/cli/__tests__/config.test.mjs +113 -101
  3. package/cli/__tests__/install.test.mjs +102 -0
  4. package/cli/__tests__/materialize.test.mjs +95 -0
  5. package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
  6. package/cli/__tests__/task.test.mjs +124 -0
  7. package/cli/__tests__/version-consistency.test.mjs +44 -0
  8. package/cli/config.mjs +124 -113
  9. package/cli/index.mjs +100 -97
  10. package/cli/init.mjs +259 -256
  11. package/cli/install.mjs +150 -80
  12. package/cli/materialize.mjs +102 -51
  13. package/cli/registry.mjs +136 -127
  14. package/cli/task.mjs +140 -0
  15. package/package.json +2 -1
  16. package/plugins/gates/.claude-plugin/plugin.json +8 -2
  17. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  18. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  19. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  20. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  21. package/plugins/gates/hooks/doctor.mjs +207 -0
  22. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  23. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  24. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  25. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  26. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  27. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  28. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  29. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  30. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  31. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  32. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  33. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  34. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  35. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  37. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  38. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  39. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  40. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  41. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  42. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  43. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  44. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  45. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  46. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  47. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  48. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  49. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  50. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  52. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  53. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  54. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  55. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  56. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  57. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  58. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  59. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  60. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  61. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  62. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  63. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  64. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  65. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  66. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  68. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  69. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  70. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  71. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  72. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  73. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  74. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  75. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  77. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  79. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  80. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  81. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  82. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  83. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  84. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  85. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  86. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  87. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  88. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  89. package/plugins/gates/hooks/hooks.json +346 -286
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  93. package/plugins/gates/hooks/lib/config.mjs +172 -165
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  95. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  96. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  97. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  98. package/plugins/tasks/.claude-plugin/plugin.json +14 -0
  99. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  100. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  101. package/plugins/tasks/hooks/hooks.json +26 -0
  102. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  103. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  104. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  105. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  106. package/registry.json +793 -686
package/cli/install.mjs CHANGED
@@ -1,80 +1,150 @@
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
- }
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 marketplaceManifest() {
21
+ return JSON.parse(readFileSync(MARKETPLACE_PATH, 'utf8').replace(/^/, ''));
22
+ }
23
+
24
+ /**
25
+ * The name Claude Code actually registered THIS directory's marketplace under. It is usually
26
+ * the manifest's `name`, but not always: if the user added the same directory earlier under a
27
+ * different name (e.g. `devrik`), Claude Code keeps that original registration name, and
28
+ * `plugin marketplace add` is a no-op that does not rename it. Installing as `plugin@<name>`
29
+ * then fails with "not found in marketplace <name>". So we ask Claude Code which registered
30
+ * marketplace points at our REPOSITORY_ROOT and use that name; we fall back to the manifest
31
+ * name when the listing is unavailable (e.g. no `claude` binary, or a parsing change).
32
+ */
33
+ function registeredMarketplaceName(runClaude, fallbackName) {
34
+ try {
35
+ const listing = runClaude(['plugin', 'marketplace', 'list']);
36
+ // Each marketplace block prints a name line then a `Source: … (<path>)` line. Find the
37
+ // block whose source path is our repo root and return its name.
38
+ const root = REPOSITORY_ROOT.replace(/[\\/]+$/, '');
39
+ const lines = listing.split(/\r?\n/);
40
+ let currentName = null;
41
+ for (const line of lines) {
42
+ const nameMatch = line.match(/^\s*(?:❯\s*)?([A-Za-z0-9_-]+)\s*$/);
43
+ if (nameMatch) currentName = nameMatch[1];
44
+ const sourceMatch = line.match(/Source:.*\(([^)]+)\)/);
45
+ if (sourceMatch && currentName) {
46
+ const sourcePath = sourceMatch[1].replace(/[\\/]+$/, '');
47
+ if (sourcePath.toLowerCase() === root.toLowerCase()) return currentName;
48
+ }
49
+ }
50
+ } catch {
51
+ // Listing unavailable — fall back to the manifest name below.
52
+ }
53
+ return fallbackName;
54
+ }
55
+
56
+ /** Every plugin the manifest declares, paired with the marketplace's registered name. */
57
+ function marketplaceAndPlugins(runClaude = realClaude) {
58
+ const manifest = marketplaceManifest();
59
+ const marketplace = registeredMarketplaceName(runClaude, manifest.name);
60
+ return manifest.plugins.map((plugin) => ({
61
+ marketplace,
62
+ plugin: plugin.name,
63
+ }));
64
+ }
65
+
66
+ /** The `claude plugin install …` lines, one per plugin the manifest declares. */
67
+ export function pluginInstallCommands() {
68
+ return marketplaceAndPlugins().map(
69
+ ({ marketplace, plugin }) => `claude plugin install ${plugin}@${marketplace}`,
70
+ );
71
+ }
72
+
73
+ /** Back-compat single-line form: the first plugin's install command. */
74
+ export function pluginInstallCommand() {
75
+ return pluginInstallCommands()[0];
76
+ }
77
+
78
+ function realClaude(commandArguments) {
79
+ return execFileSync(CLAUDE_BIN, commandArguments, {
80
+ encoding: 'utf8',
81
+ stdio: 'pipe',
82
+ });
83
+ }
84
+
85
+ function reasonFor(error) {
86
+ return error?.code === 'ENOENT'
87
+ ? 'the `claude` command was not found on PATH'
88
+ : (error?.stderr || error?.message || String(error)).trim().split('\n')[0];
89
+ }
90
+
91
+ /**
92
+ * Registers the marketplace (idempotent: a second add just reports it already exists, which
93
+ * is not fatal) once, then installs EVERY plugin the manifest declares at the scope matching
94
+ * the config choice. A project that adopts claude-gates gets all of its plugins (gates,
95
+ * tasks, …), not just the first — a single failed install does not stop the rest from being
96
+ * attempted, so one broken plugin never silently blocks another that would have worked.
97
+ *
98
+ * Returns { installed, scope, results } where `installed` is true only if every plugin
99
+ * installed; `results` is a per-plugin { plugin, installed, reason? } list so a caller can
100
+ * report exactly which ones need the manual command.
101
+ *
102
+ * `runClaude` is an injectable seam (defaults to the real `claude` binary) so tests can
103
+ * exercise the multi-plugin partial-failure logic without actually invoking the CLI and
104
+ * installing plugins on the machine running the test.
105
+ */
106
+ export function installPlugin(
107
+ configScope,
108
+ { cwd = process.cwd(), runClaude = realClaude } = {},
109
+ ) {
110
+ const scope = PLUGIN_SCOPE[configScope] ?? 'user';
111
+
112
+ try {
113
+ runClaude(['plugin', 'marketplace', 'add', REPOSITORY_ROOT, '--scope', 'user']);
114
+ } catch {
115
+ // Already registered, or the marketplace add is a no-op — install can still proceed.
116
+ }
117
+
118
+ // Resolve the registered marketplace name AFTER the add, so a fresh registration is seen and
119
+ // a pre-existing one (under any name) is matched by its source path. Doing it here, not at
120
+ // module top, means the name reflects the live registration this run just ensured.
121
+ const targets = marketplaceAndPlugins(runClaude);
122
+
123
+ const results = targets.map(({ marketplace, plugin }) => {
124
+ try {
125
+ runClaude([
126
+ 'plugin',
127
+ 'install',
128
+ `${plugin}@${marketplace}`,
129
+ '--yes',
130
+ '--scope',
131
+ scope,
132
+ ]);
133
+ return { plugin, installed: true };
134
+ } catch (error) {
135
+ return { plugin, installed: false, reason: reasonFor(error) };
136
+ }
137
+ });
138
+
139
+ const installed = results.every((result) => result.installed);
140
+ if (installed) return { installed: true, scope, results };
141
+
142
+ const failed = results.filter((result) => !result.installed);
143
+ return {
144
+ installed: false,
145
+ scope,
146
+ results,
147
+ reason: failed.map((result) => `${result.plugin}: ${result.reason}`).join('; '),
148
+ cwd,
149
+ };
150
+ }
@@ -1,51 +1,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
-
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
- }
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
+ }
package/cli/registry.mjs CHANGED
@@ -1,127 +1,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
- 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
- }
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
+ }