@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
@@ -0,0 +1,102 @@
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 newProject({ config } = {}) {
12
+ const project = mkdtempSync(join(tmpdir(), 'recurrence-lock-edge-'));
13
+ mkdirSync(join(project, '.git'));
14
+ mkdirSync(join(project, '.ai'), { recursive: true });
15
+ if (config) {
16
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
17
+ }
18
+ return project;
19
+ }
20
+
21
+ function runGateIn(project, payload) {
22
+ const out = execFileSync(process.execPath, [GATE], {
23
+ input: JSON.stringify(payload),
24
+ encoding: 'utf8',
25
+ cwd: project,
26
+ env: { ...process.env, HOME: project, USERPROFILE: project },
27
+ });
28
+ return out.trim() ? JSON.parse(out.trim()) : null;
29
+ }
30
+
31
+ function exec() {
32
+ return { tool_name: 'Bash', tool_input: { command: 'echo hi' } };
33
+ }
34
+ function isDeny(result) {
35
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
36
+ }
37
+
38
+ // EDGE CASE (BUG): mcp__ide__executeCode is part of TOOL_GROUPS.execution (lib/hook-io.mjs
39
+ // line 37-46) and this gate checks TOOL_GROUPS.execution.includes(toolName), so this should
40
+ // actually be covered. Verify it is (defensive check — not assumed).
41
+ test('OK: mcp__ide__executeCode is covered because it is in TOOL_GROUPS.execution', () => {
42
+ const project = newProject();
43
+ writeFileSync(
44
+ join(project, '.ai', 'reincidencias.json'),
45
+ JSON.stringify({ classes: [{ class: 'x', occurrences: [1, 2], status: 'open' }] }),
46
+ );
47
+ assert.ok(isDeny(runGateIn(project, { tool_name: 'mcp__ide__executeCode', tool_input: {} })));
48
+ });
49
+
50
+ // FIXED (was BUG): status is now trimmed before lowercasing, so a padded/capitalized status
51
+ // like "Closed " (trailing space) is correctly recognized as closed and no longer sticks the
52
+ // lock on a legitimately-resolved recurrence. A genuine typo like "closd" must still NOT be
53
+ // recognized (no typo-tolerance) and stays open-blocking, as expected.
54
+ test('FIXED: a status of "Closed " (capitalized, trailing space) is trimmed and recognized as closed, unblocking the recurrence', () => {
55
+ const project = newProject();
56
+ writeFileSync(
57
+ join(project, '.ai', 'reincidencias.json'),
58
+ JSON.stringify({ classes: [{ class: 'x', occurrences: [1, 2], status: 'Closed ' }] }),
59
+ );
60
+ assert.equal(runGateIn(project, exec()), null, 'a padded/capitalized "Closed " status must be trimmed and recognized as closed, allowing the tool call through');
61
+ });
62
+
63
+ test('OK: a genuine typo status like "closd" is NOT recognized as closed and stays open-blocking (no typo-tolerance)', () => {
64
+ const project = newProject();
65
+ writeFileSync(
66
+ join(project, '.ai', 'reincidencias.json'),
67
+ JSON.stringify({ classes: [{ class: 'x', occurrences: [1, 2], status: 'closd' }] }),
68
+ );
69
+ assert.ok(isDeny(runGateIn(project, exec())), 'a typo status must not be silently treated as closed');
70
+ });
71
+
72
+ // FIXED (was BUG): occurrences are now deduplicated (by id/hash, or by value for bare
73
+ // scalars) before counting against the threshold. `occurrences: [1, 1]` is the same
74
+ // occurrence logged twice (e.g. by a race or a bug in the writer) and must no longer count
75
+ // as two distinct occurrences.
76
+ test('FIXED: duplicate identical entries in occurrences[] are deduplicated before counting, so [1, 1] does not reach a threshold of 2', () => {
77
+ const project = newProject();
78
+ writeFileSync(
79
+ join(project, '.ai', 'reincidencias.json'),
80
+ JSON.stringify({ classes: [{ class: 'dup', occurrences: [1, 1], status: 'open' }] }),
81
+ );
82
+ assert.equal(runGateIn(project, exec()), null, 'duplicate occurrence entries must be deduplicated before comparing against thresholdAppearances');
83
+ });
84
+
85
+ // OK: genuinely distinct occurrences (even with the same shape) must still count normally —
86
+ // dedup must not under-count real, distinct recurrences.
87
+ test('OK: distinct occurrence entries (identified by id) still count toward the threshold normally', () => {
88
+ const project = newProject();
89
+ writeFileSync(
90
+ join(project, '.ai', 'reincidencias.json'),
91
+ JSON.stringify({
92
+ classes: [
93
+ {
94
+ class: 'distinct',
95
+ occurrences: [{ id: 'a' }, { id: 'b' }],
96
+ status: 'open',
97
+ },
98
+ ],
99
+ }),
100
+ );
101
+ assert.ok(isDeny(runGateIn(project, exec())), 'two genuinely distinct occurrences (different ids) must still trip the threshold');
102
+ });
@@ -0,0 +1,94 @@
1
+ // Edge-case probes for reuse-before-build. Previously: (1) TOOL_FOLDERS matching was a
2
+ // bare substring test on the normalized path, so a legitimate folder like
3
+ // "src/mytools/thing.mjs" was misclassified as a tool build (false positive) because
4
+ // it contains the substring "tools/"; (2) checkDelegation read the prompt only from
5
+ // toolInput.prompt/description, so a delegation using another field name (e.g. `task`)
6
+ // was invisible; (3) an MCP-style write tool name (not in the old TOOL_GROUPS.write
7
+ // Set) bypassed the gate entirely. All three are now fixed: isExecutableToolPath
8
+ // compares whole path SEGMENTS (not substrings), delegationPromptOf() reads
9
+ // task/instructions/message/input too, and toolInGroups() classifies any mcp__* tool
10
+ // by its action segment so an unrecognized MCP write name is still caught.
11
+ import assert from 'node:assert/strict';
12
+ import { execFileSync } from 'node:child_process';
13
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { dirname, join } from 'node:path';
16
+ import { test } from 'node:test';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
20
+
21
+ function runGate(payload, { config } = {}) {
22
+ const project = mkdtempSync(join(tmpdir(), 'reuse-before-build-edge-'));
23
+ mkdirSync(join(project, '.git'));
24
+ mkdirSync(join(project, '.ai'));
25
+ const effectiveConfig = config ?? {
26
+ gates: { requireReuseCheckBeforeBuilding: true },
27
+ };
28
+ writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(effectiveConfig));
29
+ const out = execFileSync(process.execPath, [GATE], {
30
+ input: JSON.stringify(payload),
31
+ encoding: 'utf8',
32
+ cwd: project,
33
+ env: { ...process.env, HOME: project, USERPROFILE: project },
34
+ });
35
+ return out.trim() ? JSON.parse(out.trim()) : null;
36
+ }
37
+
38
+ function isDeny(result) {
39
+ return result?.hookSpecificOutput?.permissionDecision === 'deny';
40
+ }
41
+
42
+ test('FIXED: a legitimate "mytools/" folder is no longer misclassified as a tool build', () => {
43
+ const payload = {
44
+ tool_name: 'Write',
45
+ tool_input: { file_path: 'src/mytools/thing.mjs', content: 'export function thing(){}' },
46
+ };
47
+ assert.equal(
48
+ isDeny(runGate(payload)),
49
+ false,
50
+ 'isExecutableToolPath must compare whole path segments, not substrings: "mytools" ' +
51
+ 'is not the segment "tools"',
52
+ );
53
+ });
54
+
55
+ test('FIXED: a real "tools/" folder is still caught (control for the segment fix)', () => {
56
+ const payload = {
57
+ tool_name: 'Write',
58
+ tool_input: { file_path: 'tools/thing.mjs', content: 'export function thing(){}' },
59
+ };
60
+ assert.ok(
61
+ isDeny(runGate(payload)),
62
+ 'a genuine tools/ segment must still trigger the gate',
63
+ );
64
+ });
65
+
66
+ test('FIXED: an MCP-style write tool name is no longer invisible to the gate', () => {
67
+ const payload = {
68
+ tool_name: 'mcp__filesystem__write_file',
69
+ tool_input: { file_path: 'scripts/new-tool.mjs', content: 'export function parse(){}' },
70
+ };
71
+ assert.ok(
72
+ isDeny(runGate(payload)),
73
+ 'toolInGroups must classify an MCP write tool by its action segment (write_file)',
74
+ );
75
+ });
76
+
77
+ test('FIXED: a delegation prompt carried in `task` (not prompt/description) is now checked', () => {
78
+ const payload = {
79
+ tool_name: 'Agent',
80
+ tool_input: { task: 'Please write a new script that parses CSV files.' },
81
+ };
82
+ assert.ok(
83
+ isDeny(runGate(payload)),
84
+ 'delegationPromptOf must read the `task` field',
85
+ );
86
+ });
87
+
88
+ test('OK: the equivalent Write-tool payload IS caught (control)', () => {
89
+ const payload = {
90
+ tool_name: 'Write',
91
+ tool_input: { file_path: 'scripts/new-tool.mjs', content: 'export function parse(){}' },
92
+ };
93
+ assert.ok(isDeny(runGate(payload)), 'control failed');
94
+ });
@@ -1,150 +1,159 @@
1
- // reuse-before-build — do not reinvent the wheel. Before building a new script, gate,
2
- // verifier or generic helper, consult the per-project tool map: a record of what already
3
- // solves a given need, local first, cloud second. This gate is the deterministic half —
4
- // it consults the map and objects when a build is starting without evidence of a check.
5
- // It never reaches the network: the cloud search (npm, marketplaces) is done by the
6
- // assistant or the `claude-gates tool-map` CLI, so the hook stays self-contained.
7
- //
8
- // ── What a project can configure (params) ───────────────────────────────────────────
9
- // toolMapFile path to the per-project tool map, relative to the project root.
10
- // Default .ai/tool-map.json.
11
- //
12
- // ── How it decides (deterministic, no judgment) ─────────────────────────────────────
13
- // A write that creates an executable tool (by extension) inside a tools folder, or a
14
- // delegation prompt that asks to build one, must carry evidence that the wheel was
15
- // checked: either an audit phrase in the text, or the need already recorded in the tool
16
- // map. Absent both, the gate blocks with an actionable message — consult the map, then
17
- // (if truly absent) search local→cloud and record the result with the CLI.
18
-
19
- import { existsSync, readFileSync } from 'node:fs';
20
- import { dirname, extname, join } from 'node:path';
21
- import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
22
-
23
- const GATE_ID = 'reuse-before-build';
24
- const CONFIG_KEY = 'requireReuseCheckBeforeBuilding';
25
-
26
- const WRITE_TOOLS = new Set(TOOL_GROUPS.write);
27
- const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
28
-
29
- const DEFAULT_TOOL_MAP_FILE = join('.ai', 'tool-map.json');
30
- const EXECUTABLE_EXTENSIONS = new Set([
31
- '.js',
32
- '.mjs',
33
- '.cjs',
34
- '.ts',
35
- '.py',
36
- '.sh',
37
- '.ps1',
38
- ]);
39
- const TOOL_FOLDERS = ['scripts/', 'hooks/', 'tools/', 'gates/'];
40
- // A map word must be this long to count as a real signal (skip "the", "csv" is 3 but rare).
41
- const MIN_SIGNIFICANT_WORD_LENGTH = 4;
42
-
43
- // A verb of creation next to a tool noun. Deliberately narrow: "write a report" or
44
- // "create a folder" do not match because the noun is not one of these.
45
- const BUILD_INTENT_PATTERN =
46
- /\b(write|create|build|implement|add)\s+(a |the )?(new )?(script|verifier|gate|hook|linter|checker|tool)\b/i;
47
- // Evidence the wheel was already checked: any phrase stating the audit result.
48
- const AUDIT_DONE_PATTERN =
49
- /(already exists|no existing tool|there is no plugin|audited|checked whether|nothing does this|justification:)/i;
50
-
51
- function projectRootOf(startDirectory) {
52
- let current = startDirectory;
53
- while (true) {
54
- if (existsSync(join(current, '.git'))) return current;
55
- const parent = dirname(current);
56
- if (parent === current) return null;
57
- current = parent;
58
- }
59
- }
60
-
61
- /**
62
- * Whether the tool map already records a need matching the given text. The map is a JSON
63
- * blob; a cheap lowercased contains-check over the significant words of the build target
64
- * is enough — the gate does not parse the map's shape, so the CLI is free to evolve it.
65
- * A word of 4+ chars found in the map counts as "this need is already tracked".
66
- */
67
- function needRecordedInMap(startDirectory, toolMapFile, targetText) {
68
- const root = projectRootOf(startDirectory);
69
- if (!root) return false;
70
- const path = join(root, toolMapFile);
71
- if (!existsSync(path)) return false;
72
- let map;
73
- try {
74
- map = readFileSync(path, 'utf8').toLowerCase();
75
- } catch {
76
- return false;
77
- }
78
- const words = targetText
79
- .toLowerCase()
80
- .split(/[^a-z0-9]+/)
81
- .filter((word) => word.length >= MIN_SIGNIFICANT_WORD_LENGTH);
82
- return words.some((word) => map.includes(word));
83
- }
84
-
85
- function isExecutableToolPath(filePath) {
86
- const inToolFolder = TOOL_FOLDERS.some((folder) =>
87
- filePath.replace(/\\/g, '/').includes(folder),
88
- );
89
- return (
90
- inToolFolder && EXECUTABLE_EXTENSIONS.has(extname(filePath).toLowerCase())
91
- );
92
- }
93
-
94
- const DENY_MESSAGE =
95
- 'Do not reinvent the wheel. Before building this, consult the project tool map and ' +
96
- 'confirm nothing already covers it, auditing in this order: (1) LOCAL the repo and ' +
97
- 'installed deps (search by name and usage, read the manifest); (2) CONTEXT7 — resolve ' +
98
- 'the candidate library and read its docs to confirm whether it truly covers the need; ' +
99
- '(3) WEB — search npm and plugin marketplaces for a maintained package. If it is ' +
100
- 'genuinely absent, record the finding (e.g. `claude-gates tool-map add`) and state the ' +
101
- 'audit in the text, so the exploration is not repeated next time.';
102
-
103
- /** A build is allowed when its text declares an audit or the need is already in the map. */
104
- function buildIsCleared(text, cwd, toolMapFile) {
105
- return (
106
- AUDIT_DONE_PATTERN.test(text) || needRecordedInMap(cwd, toolMapFile, text)
107
- );
108
- }
109
-
110
- /** Delegation: block a build-a-tool prompt that is not cleared. */
111
- function checkDelegation(toolInput, cwd, toolMapFile) {
112
- const prompt = String(toolInput.prompt ?? toolInput.description ?? '');
113
- if (!BUILD_INTENT_PATTERN.test(prompt)) return;
114
- if (buildIsCleared(prompt, cwd, toolMapFile)) return;
115
- deny(GATE_ID, DENY_MESSAGE);
116
- }
117
-
118
- /** Write: block a new executable tool that is not cleared by its content or the map. */
119
- function checkWrite(toolInput, cwd, toolMapFile) {
120
- const rawPath = String(
121
- toolInput.file_path ?? toolInput.target_file ?? toolInput.path ?? '',
122
- );
123
- const filePath = rawPath.replace(/\\/g, '/');
124
- if (!isExecutableToolPath(filePath)) return;
125
- // Editing an EXISTING file is not building a new tool — only creation triggers the audit.
126
- // A file already on disk is an edit, so it is allowed (lets the gates maintain themselves).
127
- if (rawPath && existsSync(rawPath)) return;
128
- const content = String(toolInput.content ?? toolInput.CodeContent ?? '');
129
- if (AUDIT_DONE_PATTERN.test(content)) return;
130
- if (needRecordedInMap(cwd, toolMapFile, filePath)) return;
131
- deny(GATE_ID, DENY_MESSAGE);
132
- }
133
-
134
- runGate(
135
- {
136
- id: GATE_ID,
137
- configKey: CONFIG_KEY,
138
- enabledByDefault: false,
139
- defaultParams: { toolMapFile: DEFAULT_TOOL_MAP_FILE },
140
- },
141
- ({ toolName, toolInput, parameters }) => {
142
- const toolMapFile = parameters.toolMapFile ?? DEFAULT_TOOL_MAP_FILE;
143
- const cwd = process.cwd();
144
- if (DELEGATION_TOOLS.has(toolName)) {
145
- checkDelegation(toolInput, cwd, toolMapFile);
146
- } else if (WRITE_TOOLS.has(toolName)) {
147
- checkWrite(toolInput, cwd, toolMapFile);
148
- }
149
- },
150
- );
1
+ // reuse-before-build — do not reinvent the wheel. Before building a new script, gate,
2
+ // verifier or generic helper, consult the per-project tool map: a record of what already
3
+ // solves a given need, local first, cloud second. This gate is the deterministic half —
4
+ // it consults the map and objects when a build is starting without evidence of a check.
5
+ // It never reaches the network: the cloud search (npm, marketplaces) is done by the
6
+ // assistant or the `claude-gates tool-map` CLI, so the hook stays self-contained.
7
+ //
8
+ // ── What a project can configure (params) ───────────────────────────────────────────
9
+ // toolMapFile path to the per-project tool map, relative to the project root.
10
+ // Default .ai/tool-map.json.
11
+ //
12
+ // ── How it decides (deterministic, no judgment) ─────────────────────────────────────
13
+ // A write that creates an executable tool (by extension) inside a tools folder, or a
14
+ // delegation prompt that asks to build one, must carry evidence that the wheel was
15
+ // checked: either an audit phrase in the text, or the need already recorded in the tool
16
+ // map. Absent both, the gate blocks with an actionable message — consult the map, then
17
+ // (if truly absent) search local→cloud and record the result with the CLI.
18
+
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { dirname, extname, join } from 'node:path';
21
+ import {
22
+ runGate,
23
+ deny,
24
+ toolInGroups,
25
+ writtenContentOf,
26
+ writtenPathOf,
27
+ delegationPromptOf,
28
+ } from '../../lib/hook-io.mjs';
29
+
30
+ const GATE_ID = 'reuse-before-build';
31
+ const CONFIG_KEY = 'requireReuseCheckBeforeBuilding';
32
+
33
+ const DEFAULT_TOOL_MAP_FILE = join('.ai', 'tool-map.json');
34
+ const EXECUTABLE_EXTENSIONS = new Set([
35
+ '.js',
36
+ '.mjs',
37
+ '.cjs',
38
+ '.ts',
39
+ '.py',
40
+ '.sh',
41
+ '.ps1',
42
+ ]);
43
+ const TOOL_FOLDERS = ['scripts/', 'hooks/', 'tools/', 'gates/'];
44
+ // A map word must be this long to count as a real signal (skip "the", "csv" is 3 but rare).
45
+ const MIN_SIGNIFICANT_WORD_LENGTH = 4;
46
+
47
+ // A verb of creation next to a tool noun. Deliberately narrow: "write a report" or
48
+ // "create a folder" do not match because the noun is not one of these.
49
+ const BUILD_INTENT_PATTERN =
50
+ /\b(write|create|build|implement|add)\s+(a |the )?(new )?(script|verifier|gate|hook|linter|checker|tool)\b/i;
51
+ // Evidence the wheel was already checked: any phrase stating the audit result.
52
+ const AUDIT_DONE_PATTERN =
53
+ /(already exists|no existing tool|there is no plugin|audited|checked whether|nothing does this|justification:)/i;
54
+
55
+ function projectRootOf(startDirectory) {
56
+ let current = startDirectory;
57
+ while (true) {
58
+ if (existsSync(join(current, '.git'))) return current;
59
+ const parent = dirname(current);
60
+ if (parent === current) return null;
61
+ current = parent;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Whether the tool map already records a need matching the given text. The map is a JSON
67
+ * blob; a cheap lowercased contains-check over the significant words of the build target
68
+ * is enough — the gate does not parse the map's shape, so the CLI is free to evolve it.
69
+ * A word of 4+ chars found in the map counts as "this need is already tracked".
70
+ */
71
+ function needRecordedInMap(startDirectory, toolMapFile, targetText) {
72
+ const root = projectRootOf(startDirectory);
73
+ if (!root) return false;
74
+ const path = join(root, toolMapFile);
75
+ if (!existsSync(path)) return false;
76
+ let map;
77
+ try {
78
+ map = readFileSync(path, 'utf8').toLowerCase();
79
+ } catch {
80
+ return false;
81
+ }
82
+ const words = targetText
83
+ .toLowerCase()
84
+ .split(/[^a-z0-9]+/)
85
+ .filter((word) => word.length >= MIN_SIGNIFICANT_WORD_LENGTH);
86
+ return words.some((word) => map.includes(word));
87
+ }
88
+
89
+ /** Whether the path has a real path SEGMENT equal to one of the tool-folder names
90
+ * (e.g. `tools`), not merely a substring match -- `src/mytools/thing.mjs` contains the
91
+ * substring "tools/" but its actual segments are `src`, `mytools`, `thing.mjs`, none of
92
+ * which is `tools`. Splitting on the separator and comparing whole segments closes that
93
+ * false positive without allowing a matching false negative. */
94
+ function isExecutableToolPath(filePath) {
95
+ const segments = filePath.replace(/\\/g, '/').split('/');
96
+ const toolFolderNames = TOOL_FOLDERS.map((folder) => folder.replace(/\/$/, ''));
97
+ const inToolFolder = segments.some((segment) =>
98
+ toolFolderNames.includes(segment),
99
+ );
100
+ return (
101
+ inToolFolder && EXECUTABLE_EXTENSIONS.has(extname(filePath).toLowerCase())
102
+ );
103
+ }
104
+
105
+ const DENY_MESSAGE =
106
+ 'Do not reinvent the wheel. Before building this, consult the project tool map and ' +
107
+ 'confirm nothing already covers it, auditing in this order: (1) LOCAL — the repo and ' +
108
+ 'installed deps (search by name and usage, read the manifest); (2) CONTEXT7 — resolve ' +
109
+ 'the candidate library and read its docs to confirm whether it truly covers the need; ' +
110
+ '(3) WEB search npm and plugin marketplaces for a maintained package. If it is ' +
111
+ 'genuinely absent, record the finding (e.g. `claude-gates tool-map add`) and state the ' +
112
+ 'audit in the text, so the exploration is not repeated next time.';
113
+
114
+ /** A build is allowed when its text declares an audit or the need is already in the map. */
115
+ function buildIsCleared(text, cwd, toolMapFile) {
116
+ return (
117
+ AUDIT_DONE_PATTERN.test(text) || needRecordedInMap(cwd, toolMapFile, text)
118
+ );
119
+ }
120
+
121
+ /** Delegation: block a build-a-tool prompt that is not cleared. */
122
+ function checkDelegation(toolInput, cwd, toolMapFile) {
123
+ const prompt = delegationPromptOf(toolInput);
124
+ if (!BUILD_INTENT_PATTERN.test(prompt)) return;
125
+ if (buildIsCleared(prompt, cwd, toolMapFile)) return;
126
+ deny(GATE_ID, DENY_MESSAGE);
127
+ }
128
+
129
+ /** Write: block a new executable tool that is not cleared by its content or the map. */
130
+ function checkWrite(toolInput, cwd, toolMapFile) {
131
+ const rawPath = writtenPathOf(toolInput);
132
+ const filePath = rawPath.replace(/\\/g, '/');
133
+ if (!isExecutableToolPath(filePath)) return;
134
+ // Editing an EXISTING file is not building a new tool — only creation triggers the audit.
135
+ // A file already on disk is an edit, so it is allowed (lets the gates maintain themselves).
136
+ if (rawPath && existsSync(rawPath)) return;
137
+ const content = writtenContentOf(toolInput);
138
+ if (AUDIT_DONE_PATTERN.test(content)) return;
139
+ if (needRecordedInMap(cwd, toolMapFile, filePath)) return;
140
+ deny(GATE_ID, DENY_MESSAGE);
141
+ }
142
+
143
+ runGate(
144
+ {
145
+ id: GATE_ID,
146
+ configKey: CONFIG_KEY,
147
+ enabledByDefault: false,
148
+ defaultParams: { toolMapFile: DEFAULT_TOOL_MAP_FILE },
149
+ },
150
+ ({ toolName, toolInput, parameters }) => {
151
+ const toolMapFile = parameters.toolMapFile ?? DEFAULT_TOOL_MAP_FILE;
152
+ const cwd = process.cwd();
153
+ if (toolInGroups(toolName, ['delegation'])) {
154
+ checkDelegation(toolInput, cwd, toolMapFile);
155
+ } else if (toolInGroups(toolName, ['write'])) {
156
+ checkWrite(toolInput, cwd, toolMapFile);
157
+ }
158
+ },
159
+ );