@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
@@ -0,0 +1,50 @@
1
+ import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
2
+
3
+ const GATE_ID = 'root-cause-first';
4
+ const CONFIG_KEY = 'requireRootCauseBeforePatch';
5
+
6
+ const DEFAULT_PATCH_MARKER_PATTERNS = ['//\\s*todo:?\\s*fix\\s+later\\s+patch'];
7
+
8
+ function extractContent(toolName, toolInput) {
9
+ if (TOOL_GROUPS.delegation.includes(toolName)) {
10
+ return toolInput?.prompt;
11
+ }
12
+ return toolInput?.content ?? toolInput?.new_string;
13
+ }
14
+
15
+ runGate(
16
+ {
17
+ id: GATE_ID,
18
+ configKey: CONFIG_KEY,
19
+ enabledByDefault: false,
20
+ defaultParams: {
21
+ patchMarkerPatterns: DEFAULT_PATCH_MARKER_PATTERNS,
22
+ },
23
+ },
24
+ ({ toolName, toolInput, parameters }) => {
25
+ const isWrite = TOOL_GROUPS.write.includes(toolName);
26
+ const isDelegation = TOOL_GROUPS.delegation.includes(toolName);
27
+ if (!isWrite && !isDelegation) return;
28
+
29
+ const content = extractContent(toolName, toolInput);
30
+ if (typeof content !== 'string') return;
31
+
32
+ const patterns = parameters.patchMarkerPatterns.map(
33
+ (source) => new RegExp(source, 'i'),
34
+ );
35
+ const matched = patterns.find((pattern) => pattern.test(content));
36
+ if (!matched) return;
37
+
38
+ deny(
39
+ GATE_ID,
40
+ `Content matches a patch-without-diagnosis marker (${matched.source}). Identify and fix the root cause before patching; do not defer with a "fix later" marker.`,
41
+ );
42
+ },
43
+ );
44
+
45
+ // Simplified vs. the source guard (guard-root-cause-first.mjs): the original
46
+ // used an auxiliary lib/embedded-content-detection.mjs module to tell real
47
+ // file content apart from a quoted example inside markdown. That module does
48
+ // not exist in this repo, so this gate matches directly against the new
49
+ // content/prompt. Distinguishing markdown quotes from real code is future
50
+ // work if false positives on quoted examples become a problem.
@@ -0,0 +1,73 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
+
11
+ function runGate(payload, { config } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'root-cause-first-'));
13
+ mkdirSync(join(project, '.git'));
14
+ if (config) {
15
+ mkdirSync(join(project, '.ai'));
16
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
+ }
18
+ const out = execFileSync(process.execPath, [GATE], {
19
+ input: JSON.stringify(payload),
20
+ encoding: 'utf8',
21
+ cwd: project,
22
+ // Isolate from the user's real global config: point homedir() at the temp
23
+ // project so the global-config fallback finds nothing (registry default).
24
+ env: { ...process.env, HOME: project, USERPROFILE: project },
25
+ });
26
+ return out.trim() ? JSON.parse(out.trim()) : null;
27
+ }
28
+
29
+ function write(content) {
30
+ return {
31
+ tool_name: 'Write',
32
+ tool_input: { file_path: '/repo/src/x.js', content },
33
+ };
34
+ }
35
+ function delegate(prompt) {
36
+ return { tool_name: 'Agent', tool_input: { prompt } };
37
+ }
38
+ function isDeny(result) {
39
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
40
+ }
41
+
42
+ const ENABLE = { config: { gates: { requireRootCauseBeforePatch: true } } };
43
+
44
+ test('denies a patch-marker comment in a write', () => {
45
+ assert.ok(isDeny(runGate(write('// TODO fix later patch'), ENABLE)));
46
+ });
47
+
48
+ test('denies the same marker inside a delegation prompt', () => {
49
+ assert.ok(
50
+ isDeny(runGate(delegate('add a // TODO fix later patch comment'), ENABLE)),
51
+ );
52
+ });
53
+
54
+ test('allows innocuous content', () => {
55
+ assert.equal(runGate(write('const x = 1;'), ENABLE), null);
56
+ });
57
+
58
+ test('disabled by default (registry default is false)', () => {
59
+ assert.equal(runGate(write('// TODO fix later patch')), null);
60
+ });
61
+
62
+ test('project patchMarkerPatterns override replaces the built-in list', () => {
63
+ const config = {
64
+ gates: {
65
+ requireRootCauseBeforePatch: {
66
+ enabled: true,
67
+ patchMarkerPatterns: ['hack-me-later'],
68
+ },
69
+ },
70
+ };
71
+ assert.equal(runGate(write('// TODO fix later patch'), { config }), null);
72
+ assert.ok(isDeny(runGate(write('// hack-me-later'), { config })));
73
+ });
@@ -0,0 +1,111 @@
1
+ // root-whitelist — denies creating a new file or folder at the project's ROOT unless it
2
+ // is on the declared whitelist. Migrated from ~/.claude/hooks/guard-root-whitelist.mjs.
3
+ // A root grows orphaned files silently: nothing else stops a stray file from landing next
4
+ // to package.json. Only the top level of the project is governed — anything nested is
5
+ // this gate's business only insofar as its first path segment is the new thing at the root.
6
+ //
7
+ // ── What a project can configure (params) ───────────────────────────────────────────
8
+ // rootFilesWhitelist root-level file names allowed to be created. Replaces the
9
+ // built-in list wholesale.
10
+ // rootFoldersWhitelist root-level folder names allowed to be created. Replaces the
11
+ // built-in list wholesale.
12
+ // The defaults live here, in the source, so a project reads them and knows exactly what
13
+ // its override replaces. A dotfile/dotfolder (.gitignore, .claude) is always allowed —
14
+ // dotfiles are a different, well-known convention this gate does not police.
15
+
16
+ import { basename, normalize, sep } from 'node:path';
17
+ import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
18
+
19
+ const GATE_ID = 'root-whitelist';
20
+ const CONFIG_KEY = 'blockPathsOutsideRootWhitelist';
21
+
22
+ const WRITE_TOOLS = new Set(TOOL_GROUPS.write);
23
+
24
+ const DEFAULT_ROOT_FILES_WHITELIST = [
25
+ 'AGENTS.md',
26
+ 'CLAUDE.md',
27
+ 'README.md',
28
+ 'LICENSE',
29
+ 'package.json',
30
+ 'package-lock.json',
31
+ 'tsconfig.json',
32
+ 'eslint.config.mjs',
33
+ 'eslint.config.js',
34
+ 'eslint.config.cjs',
35
+ '.prettierrc',
36
+ '.prettierrc.json',
37
+ '.prettierignore',
38
+ '.eslintignore',
39
+ 'cspell.json',
40
+ '.cspell.json',
41
+ '.gitignore',
42
+ '.env',
43
+ '.env.example',
44
+ ];
45
+
46
+ const DEFAULT_ROOT_FOLDERS_WHITELIST = [
47
+ 'src',
48
+ 'tests',
49
+ 'docs',
50
+ 'scripts',
51
+ 'plugins',
52
+ ];
53
+
54
+ /** The path a write tool targets, across the field names different tools use. */
55
+ function writeTargetFrom(toolInput) {
56
+ return String(
57
+ toolInput.TargetFile ??
58
+ toolInput.target_file ??
59
+ toolInput.file_path ??
60
+ toolInput.path ??
61
+ '',
62
+ );
63
+ }
64
+
65
+ runGate(
66
+ {
67
+ id: GATE_ID,
68
+ configKey: CONFIG_KEY,
69
+ enabledByDefault: true,
70
+ defaultParams: {
71
+ rootFilesWhitelist: DEFAULT_ROOT_FILES_WHITELIST,
72
+ rootFoldersWhitelist: DEFAULT_ROOT_FOLDERS_WHITELIST,
73
+ },
74
+ },
75
+ ({ toolName, toolInput, parameters }) => {
76
+ if (!WRITE_TOOLS.has(toolName)) return;
77
+
78
+ const target = writeTargetFrom(toolInput);
79
+ if (!target) return;
80
+
81
+ const normalized = normalize(target);
82
+ const projectRoot = process.cwd() + sep;
83
+
84
+ // The whitelist describes the project's ROOT. A file outside the project (session
85
+ // scratchpad, system temp, another repo) is not at its root, and this rule does not
86
+ // govern it: letting it through is correct, not an exception.
87
+ if (!normalized.startsWith(projectRoot)) return;
88
+
89
+ const relativePath = normalized.slice(projectRoot.length);
90
+ const filesWhitelist = new Set(parameters.rootFilesWhitelist ?? []);
91
+ const foldersWhitelist = new Set(parameters.rootFoldersWhitelist ?? []);
92
+
93
+ if (!relativePath.includes(sep)) {
94
+ const fileName = basename(relativePath);
95
+ if (fileName.startsWith('.') || filesWhitelist.has(fileName)) return;
96
+ deny(
97
+ GATE_ID,
98
+ `'${fileName}' at the project root is not on the whitelist (${[...filesWhitelist].join(', ')}).`,
99
+ );
100
+ return;
101
+ }
102
+
103
+ const topDirectory = relativePath.split(sep)[0];
104
+ if (topDirectory.startsWith('.') || foldersWhitelist.has(topDirectory))
105
+ return;
106
+ deny(
107
+ GATE_ID,
108
+ `Folder '${topDirectory}' at the project root is not on the whitelist (${[...foldersWhitelist].join(', ')}).`,
109
+ );
110
+ },
111
+ );
@@ -0,0 +1,80 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
+
11
+ // Creates a fresh temp project (with .git so it is recognized as a root, and an optional
12
+ // .ai/config.json) and returns its path plus a `run` function that executes the gate with
13
+ // that project as cwd, building the write target relative to it. This keeps the file path
14
+ // the gate inspects and the cwd it resolves against always in sync.
15
+ function project({ config } = {}) {
16
+ const root = mkdtempSync(join(tmpdir(), 'root-whitelist-'));
17
+ mkdirSync(join(root, '.git'));
18
+ if (config) {
19
+ mkdirSync(join(root, '.ai'));
20
+ writeFileSync(join(root, '.ai', 'config.json'), JSON.stringify(config));
21
+ }
22
+ return {
23
+ root,
24
+ run(payload) {
25
+ const out = execFileSync(process.execPath, [GATE], {
26
+ input: JSON.stringify(payload),
27
+ encoding: 'utf8',
28
+ cwd: root,
29
+ // Isolate from the user's real global config: point homedir() at the temp
30
+ // project so the global-config fallback finds nothing (registry default).
31
+ env: { ...process.env, HOME: root, USERPROFILE: root },
32
+ });
33
+ return out.trim() ? JSON.parse(out.trim()) : null;
34
+ },
35
+ };
36
+ }
37
+
38
+ function write(root, relativePath) {
39
+ return {
40
+ tool_name: 'Write',
41
+ tool_input: { file_path: join(root, relativePath) },
42
+ };
43
+ }
44
+ function isDeny(result) {
45
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
46
+ }
47
+
48
+ test('denies an orphan root file and an undeclared root folder', () => {
49
+ const { root, run } = project();
50
+ assert.ok(isDeny(run(write(root, 'random-orphan.txt'))));
51
+ assert.ok(isDeny(run(write(root, join('random-folder', 'a.js')))));
52
+ });
53
+
54
+ test('allows a whitelisted root file, a whitelisted folder and a dotfile', () => {
55
+ const { root, run } = project();
56
+ assert.equal(run(write(root, 'package.json')), null);
57
+ assert.equal(run(write(root, join('src', 'index.js'))), null);
58
+ assert.equal(run(write(root, '.env')), null);
59
+ });
60
+
61
+ test('disabled by config: the gate does not run', () => {
62
+ const config = { gates: { blockPathsOutsideRootWhitelist: false } };
63
+ const { root, run } = project({ config });
64
+ assert.equal(run(write(root, 'random-orphan.txt')), null);
65
+ });
66
+
67
+ test('project rootFoldersWhitelist replaces the built-in list', () => {
68
+ const config = {
69
+ gates: {
70
+ blockPathsOutsideRootWhitelist: {
71
+ enabled: true,
72
+ rootFoldersWhitelist: ['app'],
73
+ },
74
+ },
75
+ };
76
+ const { root, run } = project({ config });
77
+ // 'src' no longer whitelisted under the override
78
+ assert.ok(isDeny(run(write(root, join('src', 'index.js')))));
79
+ assert.equal(run(write(root, join('app', 'index.js'))), null);
80
+ });
@@ -0,0 +1,99 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+ import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
5
+
6
+ const GATE_ID = 'rule-skill-autodiscovery';
7
+ const CONFIG_KEY = 'autodiscoverRulesAndSkills';
8
+
9
+ const DEFAULT_RULES_DIR = 'rules';
10
+ const DEFAULT_GATE_FILE_NAMES = [
11
+ 'gate.mjs',
12
+ 'rules.mjs',
13
+ 'check.mjs',
14
+ 'verify.mjs',
15
+ ];
16
+
17
+ function isGateFile(fileName, gateFileNames) {
18
+ return gateFileNames.includes(fileName) || fileName.endsWith('.gate.mjs');
19
+ }
20
+
21
+ function findScriptsIn(scriptsDirectory, gateFileNames) {
22
+ if (!existsSync(scriptsDirectory)) return [];
23
+ let entries;
24
+ try {
25
+ entries = readdirSync(scriptsDirectory, { withFileTypes: true });
26
+ } catch {
27
+ return [];
28
+ }
29
+ return entries
30
+ .filter((entry) => entry.isFile())
31
+ .filter((entry) => /\.(mjs|js)$/.test(entry.name))
32
+ .filter((entry) => isGateFile(entry.name, gateFileNames))
33
+ .map((entry) => join(scriptsDirectory, entry.name));
34
+ }
35
+
36
+ function discoverScripts(projectRoot, rulesDirectoryName, gateFileNames) {
37
+ const scripts = [
38
+ ...findScriptsIn(join(projectRoot, rulesDirectoryName), gateFileNames),
39
+ ];
40
+
41
+ const skillsRoot = join(projectRoot, '.claude', 'skills');
42
+ if (existsSync(skillsRoot)) {
43
+ let skillDirectories;
44
+ try {
45
+ skillDirectories = readdirSync(skillsRoot, {
46
+ withFileTypes: true,
47
+ }).filter((entry) => entry.isDirectory());
48
+ } catch {
49
+ skillDirectories = [];
50
+ }
51
+ for (const skillDirectory of skillDirectories) {
52
+ scripts.push(
53
+ ...findScriptsIn(join(skillsRoot, skillDirectory.name), gateFileNames),
54
+ );
55
+ }
56
+ }
57
+
58
+ return scripts;
59
+ }
60
+
61
+ runGate(
62
+ {
63
+ id: GATE_ID,
64
+ configKey: CONFIG_KEY,
65
+ enabledByDefault: false,
66
+ defaultParams: {
67
+ rulesDir: DEFAULT_RULES_DIR,
68
+ gateFileNames: DEFAULT_GATE_FILE_NAMES,
69
+ },
70
+ },
71
+ ({ toolName, parameters }) => {
72
+ const isExecution = TOOL_GROUPS.execution.includes(toolName);
73
+ const isDelegation = TOOL_GROUPS.delegation.includes(toolName);
74
+ if (!isExecution && !isDelegation) return;
75
+
76
+ const projectRoot = process.cwd();
77
+ const scripts = discoverScripts(
78
+ projectRoot,
79
+ parameters.rulesDir,
80
+ parameters.gateFileNames,
81
+ );
82
+ if (scripts.length === 0) return;
83
+
84
+ for (const script of scripts) {
85
+ try {
86
+ execFileSync(process.execPath, [script], {
87
+ stdio: 'ignore',
88
+ timeout: 5000,
89
+ });
90
+ } catch {
91
+ deny(
92
+ GATE_ID,
93
+ `Sub-gate failed: ${relative(projectRoot, script)}. Fix it before continuing.`,
94
+ );
95
+ return;
96
+ }
97
+ }
98
+ },
99
+ );
@@ -0,0 +1,78 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
10
+
11
+ function runGateIn(project, payload) {
12
+ const out = execFileSync(process.execPath, [GATE], {
13
+ input: JSON.stringify(payload),
14
+ encoding: 'utf8',
15
+ cwd: project,
16
+ // Isolate from the user's real global config: point homedir() at the temp
17
+ // project so the global-config fallback finds nothing (registry default).
18
+ env: { ...process.env, HOME: project, USERPROFILE: project },
19
+ });
20
+ return out.trim() ? JSON.parse(out.trim()) : null;
21
+ }
22
+
23
+ function newProject({ config } = {}) {
24
+ const project = mkdtempSync(join(tmpdir(), 'rule-skill-autodiscovery-'));
25
+ mkdirSync(join(project, '.git'));
26
+ if (config) {
27
+ mkdirSync(join(project, '.ai'));
28
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
29
+ }
30
+ return project;
31
+ }
32
+
33
+ function exec() {
34
+ return { tool_name: 'Bash', tool_input: { command: 'echo hi' } };
35
+ }
36
+ function isDeny(result) {
37
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
38
+ }
39
+
40
+ const ENABLE = { gates: { ['autodiscoverRulesAndSkills']: true } };
41
+
42
+ test('denies when a discovered rule script fails', () => {
43
+ const project = newProject({ config: ENABLE });
44
+ mkdirSync(join(project, 'rules'));
45
+ writeFileSync(join(project, 'rules', 'gate.mjs'), 'process.exit(1);');
46
+ assert.ok(isDeny(runGateIn(project, exec())));
47
+ });
48
+
49
+ test('allows when discovered rule scripts succeed', () => {
50
+ const project = newProject({ config: ENABLE });
51
+ mkdirSync(join(project, 'rules'));
52
+ writeFileSync(join(project, 'rules', 'gate.mjs'), 'process.exit(0);');
53
+ assert.equal(runGateIn(project, exec()), null);
54
+ });
55
+
56
+ test('discovers gate files inside .claude/skills subfolders', () => {
57
+ const project = newProject({ config: ENABLE });
58
+ mkdirSync(join(project, '.claude', 'skills', 'my-skill'), {
59
+ recursive: true,
60
+ });
61
+ writeFileSync(
62
+ join(project, '.claude', 'skills', 'my-skill', 'check.mjs'),
63
+ 'process.exit(1);',
64
+ );
65
+ assert.ok(isDeny(runGateIn(project, exec())));
66
+ });
67
+
68
+ test('degrades to allow when no rules dir or skills dir exists', () => {
69
+ const project = newProject({ config: ENABLE });
70
+ assert.equal(runGateIn(project, exec()), null);
71
+ });
72
+
73
+ test('disabled by default (registry default is false)', () => {
74
+ const project = newProject();
75
+ mkdirSync(join(project, 'rules'));
76
+ writeFileSync(join(project, 'rules', 'gate.mjs'), 'process.exit(1);');
77
+ assert.equal(runGateIn(project, exec()), null);
78
+ });