@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
|
@@ -1,143 +1,161 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
4
|
-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
-
import { tmpdir } from 'node:os';
|
|
6
|
-
import { dirname, join } from 'node:path';
|
|
7
|
-
import { test } from 'node:test';
|
|
8
|
-
import { fileURLToPath } from 'node:url';
|
|
9
|
-
|
|
10
|
-
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
11
|
-
const STATE_ROOT = join(tmpdir(), 'claude-gates', 'circuit-breaker');
|
|
12
|
-
|
|
13
|
-
function runGate(payload, { config } = {}) {
|
|
14
|
-
const project = mkdtempSync(join(tmpdir(), 'circuit-breaker-'));
|
|
15
|
-
mkdirSync(join(project, '.git'));
|
|
16
|
-
if (config) {
|
|
17
|
-
mkdirSync(join(project, '.ai'));
|
|
18
|
-
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
19
|
-
}
|
|
20
|
-
const out = execFileSync(process.execPath, [GATE], {
|
|
21
|
-
input: JSON.stringify(payload),
|
|
22
|
-
encoding: 'utf8',
|
|
23
|
-
cwd: project,
|
|
24
|
-
// Isolate from the user's real global config: point homedir() at the temp
|
|
25
|
-
// project so the global-config fallback finds nothing (registry default).
|
|
26
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
27
|
-
});
|
|
28
|
-
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function delegate(prompt, sessionId, extra = {}) {
|
|
32
|
-
return {
|
|
33
|
-
tool_name: 'Agent',
|
|
34
|
-
session_id: sessionId,
|
|
35
|
-
tool_input: { prompt, subagent_type: 'worker-senior', ...extra },
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
function isDeny(result) {
|
|
39
|
-
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const ENABLED = {
|
|
43
|
-
config: { gates: { requireCircuitBreakerOnDelegation: true } },
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
function freshSession() {
|
|
47
|
-
return `test-${randomUUID()}`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function cleanupSession(sessionId) {
|
|
51
|
-
try {
|
|
52
|
-
rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
|
|
53
|
-
} catch {
|
|
54
|
-
// best-effort cleanup
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
test('cuts the same delegation retried without substantial change', () => {
|
|
59
|
-
const sessionId = freshSession();
|
|
60
|
-
try {
|
|
61
|
-
const prompt = [
|
|
62
|
-
'Objetivo: fix the circuit breaker false positive in guard scoring.',
|
|
63
|
-
'',
|
|
64
|
-
'QUE SI: update lib/scoring.mjs to fix the Dice threshold.',
|
|
65
|
-
].join('\n');
|
|
66
|
-
|
|
67
|
-
assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
|
|
68
|
-
assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
|
|
69
|
-
assert.ok(isDeny(runGate(delegate(prompt, sessionId), ENABLED)));
|
|
70
|
-
} finally {
|
|
71
|
-
cleanupSession(sessionId);
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test('does not accumulate two genuinely different tasks to the same subagent', () => {
|
|
76
|
-
const sessionId = freshSession();
|
|
77
|
-
try {
|
|
78
|
-
const taskA =
|
|
79
|
-
'Objetivo: fix the login redirect bug.\n\nQUE SI: update src/auth/redirect.js.';
|
|
80
|
-
const taskB =
|
|
81
|
-
'Objetivo: migrate the billing schema to the new payments table.\n\nQUE SI: update db/migrations/billing.sql.';
|
|
82
|
-
|
|
83
|
-
assert.equal(runGate(delegate(taskA, sessionId), ENABLED), null);
|
|
84
|
-
assert.equal(runGate(delegate(taskB, sessionId), ENABLED), null);
|
|
85
|
-
assert.equal(runGate(delegate(taskA, sessionId), ENABLED), null);
|
|
86
|
-
assert.equal(runGate(delegate(taskB, sessionId), ENABLED), null);
|
|
87
|
-
} finally {
|
|
88
|
-
cleanupSession(sessionId);
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test('the retry/force escape hatch allows and resets the counter', () => {
|
|
93
|
-
const sessionId = freshSession();
|
|
94
|
-
try {
|
|
95
|
-
const prompt =
|
|
96
|
-
'Objetivo: fix the flaky test in the payment suite.\n\nQUE SI: stabilize test/payment.spec.js.';
|
|
97
|
-
|
|
98
|
-
assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
|
|
99
|
-
assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
|
|
100
|
-
assert.equal(
|
|
101
|
-
runGate(delegate(`${prompt}\n\nforce it, retry.`, sessionId), ENABLED),
|
|
102
|
-
null,
|
|
103
|
-
);
|
|
104
|
-
// Counter was reset: the same prompt again should not deny immediately.
|
|
105
|
-
assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
|
|
106
|
-
} finally {
|
|
107
|
-
cleanupSession(sessionId);
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test('
|
|
112
|
-
const sessionId = freshSession();
|
|
113
|
-
try {
|
|
114
|
-
const prompt =
|
|
115
|
-
'Objetivo: fix the
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
assert.equal(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
assert.equal(runGate(delegate(prompt, sessionId),
|
|
139
|
-
assert.
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { test } from 'node:test';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
11
|
+
const STATE_ROOT = join(tmpdir(), 'claude-gates', 'circuit-breaker');
|
|
12
|
+
|
|
13
|
+
function runGate(payload, { config } = {}) {
|
|
14
|
+
const project = mkdtempSync(join(tmpdir(), 'circuit-breaker-'));
|
|
15
|
+
mkdirSync(join(project, '.git'));
|
|
16
|
+
if (config) {
|
|
17
|
+
mkdirSync(join(project, '.ai'));
|
|
18
|
+
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
19
|
+
}
|
|
20
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
21
|
+
input: JSON.stringify(payload),
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
cwd: project,
|
|
24
|
+
// Isolate from the user's real global config: point homedir() at the temp
|
|
25
|
+
// project so the global-config fallback finds nothing (registry default).
|
|
26
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
27
|
+
});
|
|
28
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function delegate(prompt, sessionId, extra = {}) {
|
|
32
|
+
return {
|
|
33
|
+
tool_name: 'Agent',
|
|
34
|
+
session_id: sessionId,
|
|
35
|
+
tool_input: { prompt, subagent_type: 'worker-senior', ...extra },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function isDeny(result) {
|
|
39
|
+
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ENABLED = {
|
|
43
|
+
config: { gates: { requireCircuitBreakerOnDelegation: true } },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function freshSession() {
|
|
47
|
+
return `test-${randomUUID()}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function cleanupSession(sessionId) {
|
|
51
|
+
try {
|
|
52
|
+
rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
|
|
53
|
+
} catch {
|
|
54
|
+
// best-effort cleanup
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test('cuts the same delegation retried without substantial change', () => {
|
|
59
|
+
const sessionId = freshSession();
|
|
60
|
+
try {
|
|
61
|
+
const prompt = [
|
|
62
|
+
'Objetivo: fix the circuit breaker false positive in guard scoring.',
|
|
63
|
+
'',
|
|
64
|
+
'QUE SI: update lib/scoring.mjs to fix the Dice threshold.',
|
|
65
|
+
].join('\n');
|
|
66
|
+
|
|
67
|
+
assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
|
|
68
|
+
assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
|
|
69
|
+
assert.ok(isDeny(runGate(delegate(prompt, sessionId), ENABLED)));
|
|
70
|
+
} finally {
|
|
71
|
+
cleanupSession(sessionId);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('does not accumulate two genuinely different tasks to the same subagent', () => {
|
|
76
|
+
const sessionId = freshSession();
|
|
77
|
+
try {
|
|
78
|
+
const taskA =
|
|
79
|
+
'Objetivo: fix the login redirect bug.\n\nQUE SI: update src/auth/redirect.js.';
|
|
80
|
+
const taskB =
|
|
81
|
+
'Objetivo: migrate the billing schema to the new payments table.\n\nQUE SI: update db/migrations/billing.sql.';
|
|
82
|
+
|
|
83
|
+
assert.equal(runGate(delegate(taskA, sessionId), ENABLED), null);
|
|
84
|
+
assert.equal(runGate(delegate(taskB, sessionId), ENABLED), null);
|
|
85
|
+
assert.equal(runGate(delegate(taskA, sessionId), ENABLED), null);
|
|
86
|
+
assert.equal(runGate(delegate(taskB, sessionId), ENABLED), null);
|
|
87
|
+
} finally {
|
|
88
|
+
cleanupSession(sessionId);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('the retry/force escape hatch allows and resets the counter', () => {
|
|
93
|
+
const sessionId = freshSession();
|
|
94
|
+
try {
|
|
95
|
+
const prompt =
|
|
96
|
+
'Objetivo: fix the flaky test in the payment suite.\n\nQUE SI: stabilize test/payment.spec.js.';
|
|
97
|
+
|
|
98
|
+
assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
|
|
99
|
+
assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
|
|
100
|
+
assert.equal(
|
|
101
|
+
runGate(delegate(`${prompt}\n\nforce it, retry.`, sessionId), ENABLED),
|
|
102
|
+
null,
|
|
103
|
+
);
|
|
104
|
+
// Counter was reset: the same prompt again should not deny immediately.
|
|
105
|
+
assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
|
|
106
|
+
} finally {
|
|
107
|
+
cleanupSession(sessionId);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('bilingual control: the Spanish override imperative resets the counter exactly like its English equivalent', () => {
|
|
112
|
+
const sessionId = freshSession();
|
|
113
|
+
try {
|
|
114
|
+
const prompt =
|
|
115
|
+
'Objetivo: fix the flaky test in the payment suite.\n\nQUE SI: stabilize test/payment.spec.js.';
|
|
116
|
+
|
|
117
|
+
assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
|
|
118
|
+
assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
|
|
119
|
+
assert.equal(
|
|
120
|
+
runGate(delegate(`${prompt}\n\nreintentalo, forzalo.`, sessionId), ENABLED),
|
|
121
|
+
null,
|
|
122
|
+
);
|
|
123
|
+
assert.equal(runGate(delegate(prompt, sessionId), ENABLED), null);
|
|
124
|
+
} finally {
|
|
125
|
+
cleanupSession(sessionId);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('disabled by config: the gate does not run', () => {
|
|
130
|
+
const sessionId = freshSession();
|
|
131
|
+
try {
|
|
132
|
+
const prompt =
|
|
133
|
+
'Objetivo: fix the login redirect bug.\n\nQUE SI: update src/auth/redirect.js.';
|
|
134
|
+
const disabled = {
|
|
135
|
+
config: { gates: { requireCircuitBreakerOnDelegation: false } },
|
|
136
|
+
};
|
|
137
|
+
assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
|
|
138
|
+
assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
|
|
139
|
+
assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
|
|
140
|
+
assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
|
|
141
|
+
} finally {
|
|
142
|
+
cleanupSession(sessionId);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('project retryThreshold override cuts sooner', () => {
|
|
147
|
+
const sessionId = freshSession();
|
|
148
|
+
try {
|
|
149
|
+
const config = {
|
|
150
|
+
gates: {
|
|
151
|
+
requireCircuitBreakerOnDelegation: { enabled: true, retryThreshold: 2 },
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
const prompt =
|
|
155
|
+
'Objetivo: fix the login redirect bug.\n\nQUE SI: update src/auth/redirect.js.';
|
|
156
|
+
assert.equal(runGate(delegate(prompt, sessionId), { config }), null);
|
|
157
|
+
assert.ok(isDeny(runGate(delegate(prompt, sessionId), { config })));
|
|
158
|
+
} finally {
|
|
159
|
+
cleanupSession(sessionId);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
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, skills } = {}) {
|
|
12
|
+
const project = mkdtempSync(join(tmpdir(), 'dependency-skills-edge-'));
|
|
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
|
+
if (skills) {
|
|
19
|
+
for (const name of skills) {
|
|
20
|
+
mkdirSync(join(project, '.claude', 'skills', name), { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const out = execFileSync(process.execPath, [GATE], {
|
|
24
|
+
input: JSON.stringify(payload),
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
cwd: project,
|
|
27
|
+
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
28
|
+
});
|
|
29
|
+
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isWarn(result) {
|
|
33
|
+
return result?.hookSpecificOutput?.additionalContext !== undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// FIXED: skillNameMatches no longer does bidirectional substring matching. A skill dir must
|
|
37
|
+
// EQUAL the dependency or contain it as a whole `-`-segment, so an unrelated short name like
|
|
38
|
+
// "rip" no longer satisfies "stripe" — the gate now correctly warns (no false exemption).
|
|
39
|
+
test('FIXED: an unrelated skill directory name that is a substring of the dependency no longer satisfies the check', () => {
|
|
40
|
+
const package_ = JSON.stringify({ dependencies: { stripe: '1.0.0' } });
|
|
41
|
+
const result = runGate(writePackageJson(package_), { skills: ['rip'] });
|
|
42
|
+
assert.ok(isWarn(result), 'unrelated skill dir "rip" must NOT satisfy dependency "stripe"; the gate warns');
|
|
43
|
+
|
|
44
|
+
function writePackageJson(content) {
|
|
45
|
+
return { tool_name: 'Write', tool_input: { file_path: '/repo/package.json', content } };
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('OK: a genuinely matching skill segment still exempts (stripe-payments covers stripe)', () => {
|
|
50
|
+
const package_ = JSON.stringify({ dependencies: { stripe: '1.0.0' } });
|
|
51
|
+
const result = runGate(writePackageJson(package_), { skills: ['stripe-payments'] });
|
|
52
|
+
assert.equal(result, null, 'a skill whose name contains the dependency as a segment still covers it');
|
|
53
|
+
|
|
54
|
+
function writePackageJson(content) {
|
|
55
|
+
return { tool_name: 'Write', tool_input: { file_path: '/repo/package.json', content } };
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// FIXED: content is now read through writtenContentOf, which covers write_to_file's `text`
|
|
60
|
+
// field (and every other native/MCP shape). An unreviewed new dependency written via an MCP
|
|
61
|
+
// write tool is no longer silently skipped.
|
|
62
|
+
test('FIXED: write_to_file with a different content field name is now checked', () => {
|
|
63
|
+
const package_ = JSON.stringify({ dependencies: { stripe: '1.0.0' } });
|
|
64
|
+
const result = runGate({
|
|
65
|
+
tool_name: 'write_to_file',
|
|
66
|
+
tool_input: { file_path: '/repo/package.json', text: package_ },
|
|
67
|
+
});
|
|
68
|
+
assert.ok(isWarn(result), 'the new dependency written via write_to_file (text field) must now be caught');
|
|
69
|
+
});
|
|
@@ -1,118 +1,126 @@
|
|
|
1
|
-
import { readdirSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
runGate,
|
|
5
|
+
warn,
|
|
6
|
+
toolInGroups,
|
|
7
|
+
writtenPathOf,
|
|
8
|
+
writtenContentOf,
|
|
9
|
+
} from '../../lib/hook-io.mjs';
|
|
10
|
+
|
|
11
|
+
const GATE_ID = 'dependency-skills';
|
|
12
|
+
const CONFIG_KEY = 'requireSkillForNewDependency';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_DEPS_WITHOUT_OWN_API = [
|
|
15
|
+
'clsx',
|
|
16
|
+
'classnames',
|
|
17
|
+
'lodash.debounce',
|
|
18
|
+
'lodash.throttle',
|
|
19
|
+
'lodash.merge',
|
|
20
|
+
'tslib',
|
|
21
|
+
'core-js',
|
|
22
|
+
'regenerator-runtime',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function normalize(name) {
|
|
26
|
+
return name.replace(/^@/, '').replace(/\//g, '-').toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isExempt(dependencyName, dependenciesWithoutOwnApi) {
|
|
30
|
+
if (dependencyName.startsWith('@types/')) return true;
|
|
31
|
+
return dependenciesWithoutOwnApi.includes(dependencyName);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function listSkillDirectories(skillsDirectoryPath) {
|
|
35
|
+
try {
|
|
36
|
+
return readdirSync(skillsDirectoryPath, { withFileTypes: true })
|
|
37
|
+
.filter((entry) => entry.isDirectory())
|
|
38
|
+
.map((entry) => entry.name);
|
|
39
|
+
} catch {
|
|
40
|
+
// No skills directory in this project — nothing to cross-check against.
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A skill covers a dependency when its normalized name equals the dependency's, or contains
|
|
46
|
+
// it as a whole `-`-segment (a `stripe-payments` skill covers `stripe`). The old check was
|
|
47
|
+
// bidirectional substring, so a skill dir `rip` "matched" `stripe` (rip ⊂ st-rip-e) — a false
|
|
48
|
+
// exemption that silenced the warning for an unreviewed dependency.
|
|
49
|
+
function skillNameMatches(normalizedDependency, skillDirectoryName) {
|
|
50
|
+
const normalizedSkill = normalize(skillDirectoryName);
|
|
51
|
+
if (normalizedSkill === normalizedDependency) return true;
|
|
52
|
+
return normalizedSkill.split('-').includes(normalizedDependency);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hasMatchingSkill(dependencyName, skillDirectories) {
|
|
56
|
+
const normalizedDependency = normalize(dependencyName);
|
|
57
|
+
return skillDirectories.some((skillDirectoryName) =>
|
|
58
|
+
skillNameMatches(normalizedDependency, skillDirectoryName),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function needsSkillReview(dependencyName, parameters, skillDirectories) {
|
|
63
|
+
if (isExempt(dependencyName, parameters.depsWithoutOwnApi)) return false;
|
|
64
|
+
return !hasMatchingSkill(dependencyName, skillDirectories);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Parsed `package.json` dependency names, or null when it cannot be read yet. */
|
|
68
|
+
function dependencyNamesIn(content) {
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(content);
|
|
72
|
+
} catch {
|
|
73
|
+
// Mid-edit or partially written package.json — nothing reliable to check yet.
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const dependencies = {
|
|
77
|
+
...(parsed.dependencies ?? {}),
|
|
78
|
+
...(parsed.devDependencies ?? {}),
|
|
79
|
+
};
|
|
80
|
+
return Object.keys(dependencies);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
runGate(
|
|
84
|
+
{
|
|
85
|
+
id: GATE_ID,
|
|
86
|
+
configKey: CONFIG_KEY,
|
|
87
|
+
enabledByDefault: true,
|
|
88
|
+
defaultParams: {
|
|
89
|
+
depsWithoutOwnApi: DEFAULT_DEPS_WITHOUT_OWN_API,
|
|
90
|
+
projectSkillsDir: '.claude/skills',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
({ toolName, toolInput, parameters }) => {
|
|
94
|
+
if (!toolInGroups(toolName, ['write'])) return;
|
|
95
|
+
|
|
96
|
+
const filePath = writtenPathOf(toolInput);
|
|
97
|
+
if (!filePath.replace(/\\/g, '/').endsWith('package.json')) return;
|
|
98
|
+
|
|
99
|
+
const content = writtenContentOf(toolInput);
|
|
100
|
+
if (!content) return;
|
|
101
|
+
|
|
102
|
+
const dependencyNames = dependencyNamesIn(content);
|
|
103
|
+
if (!dependencyNames || dependencyNames.length === 0) return;
|
|
104
|
+
|
|
105
|
+
const skillDirectories = listSkillDirectories(
|
|
106
|
+
join(process.cwd(), parameters.projectSkillsDir),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const unmatched = dependencyNames.filter((name) =>
|
|
110
|
+
needsSkillReview(name, parameters, skillDirectories),
|
|
111
|
+
);
|
|
112
|
+
if (unmatched.length === 0) return;
|
|
113
|
+
|
|
114
|
+
warn(
|
|
115
|
+
GATE_ID,
|
|
116
|
+
`New dependencies without a matching skill in ${parameters.projectSkillsDir}: ${unmatched.join(', ')}. Consider adding a skill documenting how to use them, or add them to depsWithoutOwnApi if they need none.`,
|
|
117
|
+
);
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Deliberately simplified vs. the source guard (guard-dependencies-skills.mjs):
|
|
122
|
+
// dropped the hardcoded alias table (stripe -> stripe-payments, etc.) and the
|
|
123
|
+
// .ai/skills-baseline.json freeze mechanism — that belongs to a separate
|
|
124
|
+
// adopt-stack-and-skills.mjs script that does not exist in this repo. This
|
|
125
|
+
// gate only does simple substring matching between dependency name and skill
|
|
126
|
+
// directory name, both normalized.
|