@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.
- package/.claude-plugin/marketplace.json +7 -1
- package/cli/__tests__/config.test.mjs +113 -101
- package/cli/__tests__/install.test.mjs +102 -0
- package/cli/__tests__/materialize.test.mjs +95 -0
- package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
- package/cli/__tests__/task.test.mjs +124 -0
- package/cli/__tests__/version-consistency.test.mjs +44 -0
- package/cli/config.mjs +124 -113
- package/cli/index.mjs +100 -97
- package/cli/init.mjs +259 -256
- package/cli/install.mjs +150 -80
- package/cli/materialize.mjs +102 -51
- package/cli/registry.mjs +136 -127
- package/cli/task.mjs +140 -0
- package/package.json +2 -1
- package/plugins/gates/.claude-plugin/plugin.json +8 -2
- package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
- package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
- package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
- package/plugins/gates/hooks/ask-adoption.mjs +147 -0
- package/plugins/gates/hooks/doctor.mjs +207 -0
- package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
- package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
- package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
- package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
- package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
- package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
- package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
- package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
- package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
- package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
- package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
- package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
- package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
- package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
- package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
- package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
- package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
- package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
- package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
- package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
- package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
- package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
- package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
- package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
- package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
- package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
- package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
- package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
- package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
- package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
- package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
- package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
- package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
- package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
- package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
- package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
- package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
- package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
- package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
- package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
- package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
- package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
- package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
- package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
- package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
- package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
- package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
- package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
- package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
- package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
- package/plugins/gates/hooks/hooks.json +346 -286
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
- package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
- package/plugins/gates/hooks/lib/config.mjs +172 -165
- package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
- package/plugins/gates/hooks/lib/signals.mjs +127 -0
- package/plugins/gates/hooks/wiring-check.mjs +227 -0
- package/plugins/tasks/.claude-plugin/plugin.json +14 -0
- package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
- package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
- package/plugins/tasks/hooks/hooks.json +26 -0
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
- package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
- package/plugins/tasks/hooks/register-requests.mjs +141 -108
- package/plugins/tasks/hooks/session-tasks.mjs +108 -0
- 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 {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
'.
|
|
36
|
-
'.
|
|
37
|
-
'.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
if (!
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
);
|