@devrik-tools/claude-gates 0.2.1 → 0.3.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 +2 -2
- package/README.es.md +7 -6
- package/README.md +16 -7
- package/cli/index.mjs +4 -0
- package/cli/init.mjs +18 -1
- package/cli/install.mjs +26 -1
- package/package.json +5 -2
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
- package/plugins/gates/hooks/hooks.json +19 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/register-requests.mjs +7 -3
- package/registry.json +738 -793
- package/cli/__tests__/config.test.mjs +0 -113
- package/cli/__tests__/init-flags.test.mjs +0 -111
- package/cli/__tests__/install.test.mjs +0 -102
- package/cli/__tests__/materialize.test.mjs +0 -95
- package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
- package/cli/__tests__/selection.test.mjs +0 -200
- package/cli/__tests__/task.test.mjs +0 -124
- package/cli/__tests__/version-consistency.test.mjs +0 -44
- package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
- package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
- package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
- package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
- package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
- package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
- package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
- package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
- package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
- package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
- package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
- package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
- package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
- package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
- package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
- package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
- package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
- package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
- package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
- package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
- package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
- package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
- package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
- package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
- package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
- package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
- package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
- package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
- package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
- package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
- package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
- package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
- package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
- package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
- package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
- package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
- package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
- package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
- package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
- package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
- package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
- package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
- package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
- package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
- package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -124
- package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
- package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
- package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
- package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
- package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
- package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
- package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
- package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
- package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -223
- package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
- package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
- package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
- package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
|
@@ -1,111 +0,0 @@
|
|
|
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(), 'brief-before-delegate-'));
|
|
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 delegate(prompt, extra = {}) {
|
|
30
|
-
return { tool_name: 'Agent', tool_input: { prompt, ...extra } };
|
|
31
|
-
}
|
|
32
|
-
function isDeny(result) {
|
|
33
|
-
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
34
|
-
}
|
|
35
|
-
const ENABLED = { config: { gates: { requireBriefBeforeDelegating: true } } };
|
|
36
|
-
|
|
37
|
-
test('denies an implementation delegation with no goal/steps/criterion', () => {
|
|
38
|
-
const result = runGate(
|
|
39
|
-
delegate('Fix the login bug please, thanks.'),
|
|
40
|
-
ENABLED,
|
|
41
|
-
);
|
|
42
|
-
assert.ok(isDeny(result));
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test('allows a complete brief with goal, steps and done-when criterion', () => {
|
|
46
|
-
const prompt = [
|
|
47
|
-
'Objetivo: fix the broken login redirect so users land on the dashboard.',
|
|
48
|
-
'',
|
|
49
|
-
'Haceres:',
|
|
50
|
-
'- update src/auth/redirect.js to use the post-login route',
|
|
51
|
-
'- add a regression test for the redirect',
|
|
52
|
-
'',
|
|
53
|
-
'Criterio: se considera hecho cuando el test de regresion pasa y el login redirige correctamente.',
|
|
54
|
-
].join('\n');
|
|
55
|
-
assert.equal(runGate(delegate(prompt), ENABLED), null);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test('allows a read-only exploration prompt without a brief', () => {
|
|
59
|
-
assert.equal(
|
|
60
|
-
runGate(
|
|
61
|
-
delegate('Investiga donde esta definida la funcion de login.'),
|
|
62
|
-
ENABLED,
|
|
63
|
-
),
|
|
64
|
-
null,
|
|
65
|
-
);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test('allows a read-only subagent even with an implementation verb', () => {
|
|
69
|
-
assert.equal(
|
|
70
|
-
runGate(
|
|
71
|
-
delegate('Implementa un resumen de como arreglar el login.', {
|
|
72
|
-
subagent_type: 'explore',
|
|
73
|
-
}),
|
|
74
|
-
ENABLED,
|
|
75
|
-
),
|
|
76
|
-
null,
|
|
77
|
-
);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test('disabled by config: the gate does not run', () => {
|
|
81
|
-
assert.equal(
|
|
82
|
-
runGate(delegate('Fix the login bug please, thanks.'), {
|
|
83
|
-
config: { gates: { requireBriefBeforeDelegating: false } },
|
|
84
|
-
}),
|
|
85
|
-
null,
|
|
86
|
-
);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test('off by default when config is silent', () => {
|
|
90
|
-
assert.equal(runGate(delegate('Fix the login bug please, thanks.')), null);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test('project minBriefLength override changes the length threshold', () => {
|
|
94
|
-
const config = {
|
|
95
|
-
gates: {
|
|
96
|
-
requireBriefBeforeDelegating: { enabled: true, minBriefLength: 20 },
|
|
97
|
-
},
|
|
98
|
-
};
|
|
99
|
-
const prompt =
|
|
100
|
-
'Fix the login bug in the auth module now, using the same approach as before.';
|
|
101
|
-
// With the default 180-char minimum, this short prompt is denied outright for length.
|
|
102
|
-
assert.ok(isDeny(runGate(delegate(prompt), ENABLED)));
|
|
103
|
-
// With the length floor lowered to 20, it clears the length check. It still misses
|
|
104
|
-
// all three signals, which is its own hard-deny path — but the reason changes: this
|
|
105
|
-
// proves the override changed the length check specifically, by using a prompt that
|
|
106
|
-
// is long enough that only two signals are missing (a goal is present), which is a
|
|
107
|
-
// warn rather than a deny.
|
|
108
|
-
const withGoal = `Goal: ${prompt}`;
|
|
109
|
-
const result = runGate(delegate(withGoal), { config });
|
|
110
|
-
assert.ok(!isDeny(result));
|
|
111
|
-
});
|
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
// Edge-case audit for circuit-breaker. Each test demonstrates a confirmed BUG or an OK.
|
|
2
|
-
// State lives at os.tmpdir()/claude-gates/circuit-breaker/<sessionId>/state.json, keyed by
|
|
3
|
-
// subagent_type. Each test uses a fresh randomUUID session id and cleans up after itself
|
|
4
|
-
// so it cannot contaminate other tests or other gates.
|
|
5
|
-
// Run: node --test circuit-breaker.edge.test.mjs
|
|
6
|
-
import assert from 'node:assert/strict';
|
|
7
|
-
import { execFileSync } from 'node:child_process';
|
|
8
|
-
import { randomUUID } from 'node:crypto';
|
|
9
|
-
import {
|
|
10
|
-
existsSync,
|
|
11
|
-
mkdirSync,
|
|
12
|
-
mkdtempSync,
|
|
13
|
-
readFileSync,
|
|
14
|
-
rmSync,
|
|
15
|
-
writeFileSync,
|
|
16
|
-
} from 'node:fs';
|
|
17
|
-
import { tmpdir } from 'node:os';
|
|
18
|
-
import { dirname, join } from 'node:path';
|
|
19
|
-
import { test } from 'node:test';
|
|
20
|
-
import { fileURLToPath } from 'node:url';
|
|
21
|
-
|
|
22
|
-
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
23
|
-
const STATE_ROOT = join(tmpdir(), 'claude-gates', 'circuit-breaker');
|
|
24
|
-
|
|
25
|
-
function runGate(payload, { config } = {}) {
|
|
26
|
-
const project = mkdtempSync(join(tmpdir(), 'circuit-breaker-edge-'));
|
|
27
|
-
mkdirSync(join(project, '.git'));
|
|
28
|
-
if (config) {
|
|
29
|
-
mkdirSync(join(project, '.ai'));
|
|
30
|
-
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
31
|
-
}
|
|
32
|
-
const out = execFileSync(process.execPath, [GATE], {
|
|
33
|
-
input: JSON.stringify(payload),
|
|
34
|
-
encoding: 'utf8',
|
|
35
|
-
cwd: project,
|
|
36
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
37
|
-
});
|
|
38
|
-
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function delegate(prompt, sessionId, extra = {}) {
|
|
42
|
-
return {
|
|
43
|
-
tool_name: 'Agent',
|
|
44
|
-
session_id: sessionId,
|
|
45
|
-
tool_input: { prompt, subagent_type: 'worker-senior', ...extra },
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
function isDeny(result) {
|
|
49
|
-
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const ENABLED = { config: { gates: { requireCircuitBreakerOnDelegation: true } } };
|
|
53
|
-
|
|
54
|
-
function freshSession() {
|
|
55
|
-
return `edge-${randomUUID()}`;
|
|
56
|
-
}
|
|
57
|
-
function cleanupSession(sessionId) {
|
|
58
|
-
try {
|
|
59
|
-
rmSync(join(STATE_ROOT, sessionId), { recursive: true, force: true });
|
|
60
|
-
} catch {
|
|
61
|
-
// best-effort cleanup
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Deliberately avoids any OVERRIDE_PATTERN word (retry/force/insist/reintenta/forz*) --
|
|
66
|
-
// a prompt describing a legitimate task can innocently contain one of those words (see
|
|
67
|
-
// the dedicated test below), which is itself a confirmed bug, so the other tests here
|
|
68
|
-
// use neutral wording to isolate what they are actually probing.
|
|
69
|
-
const SAME_TASK_PROMPT = [
|
|
70
|
-
'Objetivo: fix the queue backoff cap in the payment worker.',
|
|
71
|
-
'',
|
|
72
|
-
'QUE SI: update src/workers/payment.js to cap the backoff.',
|
|
73
|
-
].join('\n');
|
|
74
|
-
|
|
75
|
-
test('FIXED: varying subagent_type per retry no longer resets the counter — the key is derived from task identity', () => {
|
|
76
|
-
// The key is now a hash of the identity signature (identityKey), not subagent_type. The
|
|
77
|
-
// exact same task retried under a different subagent_type string each time lands on the
|
|
78
|
-
// same key every time, so the breaker still trips.
|
|
79
|
-
const sessionId = freshSession();
|
|
80
|
-
try {
|
|
81
|
-
assert.equal(
|
|
82
|
-
runGate(delegate(SAME_TASK_PROMPT, sessionId, { subagent_type: 'worker' }), ENABLED),
|
|
83
|
-
null,
|
|
84
|
-
);
|
|
85
|
-
assert.equal(
|
|
86
|
-
runGate(delegate(SAME_TASK_PROMPT, sessionId, { subagent_type: 'worker-senior' }), ENABLED),
|
|
87
|
-
null,
|
|
88
|
-
);
|
|
89
|
-
assert.ok(
|
|
90
|
-
isDeny(runGate(delegate(SAME_TASK_PROMPT, sessionId, { subagent_type: 'worker2' }), ENABLED)),
|
|
91
|
-
'third identical attempt now denies even though it used yet another subagent_type: the key follows the task, not the label',
|
|
92
|
-
);
|
|
93
|
-
} finally {
|
|
94
|
-
cleanupSession(sessionId);
|
|
95
|
-
}
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test('FIXED: forging a count field on disk no longer has any effect — there is no count field to forge', () => {
|
|
99
|
-
// The persisted shape is now a list of { signature, seenAt } occurrences with no
|
|
100
|
-
// `count` field at all. The attempt count is always recomputed by counting how many
|
|
101
|
-
// stored occurrences are similar to the CURRENT signature, so writing an arbitrary
|
|
102
|
-
// number onto the state file (there being no such field) cannot manufacture a deny.
|
|
103
|
-
const sessionId = freshSession();
|
|
104
|
-
const statePath = join(STATE_ROOT, sessionId, 'state.json');
|
|
105
|
-
try {
|
|
106
|
-
runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED); // one real occurrence recorded
|
|
107
|
-
assert.ok(existsSync(statePath), 'sanity: state file exists after first real attempt');
|
|
108
|
-
|
|
109
|
-
const state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
110
|
-
const [key] = Object.keys(state);
|
|
111
|
-
assert.ok(key, 'sanity: a key was recorded');
|
|
112
|
-
assert.equal(
|
|
113
|
-
state[key][0].count,
|
|
114
|
-
undefined,
|
|
115
|
-
'the stored entry carries no count field to forge',
|
|
116
|
-
);
|
|
117
|
-
// Attempting to inject a forged count field has no effect: the gate never reads it.
|
|
118
|
-
state[key][0].count = 999;
|
|
119
|
-
writeFileSync(statePath, JSON.stringify(state), 'utf8');
|
|
120
|
-
|
|
121
|
-
const result = runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED);
|
|
122
|
-
assert.equal(
|
|
123
|
-
result,
|
|
124
|
-
null,
|
|
125
|
-
'the forged count field is ignored: this is only the 2nd real occurrence, below the default threshold of 3',
|
|
126
|
-
);
|
|
127
|
-
} finally {
|
|
128
|
-
cleanupSession(sessionId);
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test('FIXED: no session_id no longer disables the breaker — it falls back to a fixed bucket', () => {
|
|
133
|
-
// A payload with no session_id lands in the fixed NO_SESSION_BUCKET ("no-session")
|
|
134
|
-
// directory; identityKey (a hash of the prompt's own identity) still discriminates
|
|
135
|
-
// this specific task from any other task sharing that bucket.
|
|
136
|
-
const payload = {
|
|
137
|
-
tool_name: 'Agent',
|
|
138
|
-
tool_input: {
|
|
139
|
-
prompt: 'Objetivo: fix a one-off no-session edge case.\n\nQUE SI: update src/edge/no-session.js.',
|
|
140
|
-
subagent_type: 'worker-senior',
|
|
141
|
-
},
|
|
142
|
-
};
|
|
143
|
-
try {
|
|
144
|
-
assert.equal(runGate(payload, ENABLED), null);
|
|
145
|
-
assert.equal(runGate(payload, ENABLED), null);
|
|
146
|
-
assert.ok(
|
|
147
|
-
isDeny(runGate(payload, ENABLED)),
|
|
148
|
-
'third identical attempt with no session_id now denies: the fallback bucket still tracks it',
|
|
149
|
-
);
|
|
150
|
-
} finally {
|
|
151
|
-
cleanupSession('no-session');
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test('FIXED: OVERRIDE_PATTERN no longer matches the plain word "retry" inside ordinary task vocabulary', () => {
|
|
156
|
-
// OVERRIDE_PATTERN now requires an imperative phrasing (retry anyway/it/again, force
|
|
157
|
-
// it/this/anyway, reintentalo, insisti, ...), not the bare word "retry"/"force"
|
|
158
|
-
// appearing as normal task vocabulary. A task genuinely about retry logic no longer
|
|
159
|
-
// silently defeats the breaker.
|
|
160
|
-
const sessionId = freshSession();
|
|
161
|
-
try {
|
|
162
|
-
const retryWordingPrompt = [
|
|
163
|
-
'Objetivo: fix the retry loop in the payment worker.',
|
|
164
|
-
'',
|
|
165
|
-
'QUE SI: update src/workers/payment.js to cap retries.',
|
|
166
|
-
].join('\n');
|
|
167
|
-
assert.equal(runGate(delegate(retryWordingPrompt, sessionId), ENABLED), null);
|
|
168
|
-
assert.equal(runGate(delegate(retryWordingPrompt, sessionId), ENABLED), null);
|
|
169
|
-
assert.ok(
|
|
170
|
-
isDeny(runGate(delegate(retryWordingPrompt, sessionId), ENABLED)),
|
|
171
|
-
'third identical relaunch now denies: "retry" as ordinary task vocabulary is no longer read as an override',
|
|
172
|
-
);
|
|
173
|
-
} finally {
|
|
174
|
-
cleanupSession(sessionId);
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test('OK: a genuine override imperative ("retry anyway") still allows and resets the counter', () => {
|
|
179
|
-
const sessionId = freshSession();
|
|
180
|
-
try {
|
|
181
|
-
assert.equal(runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED), null);
|
|
182
|
-
assert.equal(runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED), null);
|
|
183
|
-
assert.equal(
|
|
184
|
-
runGate(delegate(`${SAME_TASK_PROMPT}\n\nretry anyway.`, sessionId), ENABLED),
|
|
185
|
-
null,
|
|
186
|
-
'genuine override imperative still allows and resets',
|
|
187
|
-
);
|
|
188
|
-
assert.equal(
|
|
189
|
-
runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED),
|
|
190
|
-
null,
|
|
191
|
-
'counter was reset: the same prompt again does not deny immediately',
|
|
192
|
-
);
|
|
193
|
-
} finally {
|
|
194
|
-
cleanupSession(sessionId);
|
|
195
|
-
}
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
test('OK: identical prompt+subagent_type is tripped at the default retry threshold', () => {
|
|
199
|
-
const sessionId = freshSession();
|
|
200
|
-
try {
|
|
201
|
-
assert.equal(runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED), null);
|
|
202
|
-
assert.equal(runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED), null);
|
|
203
|
-
assert.ok(isDeny(runGate(delegate(SAME_TASK_PROMPT, sessionId), ENABLED)));
|
|
204
|
-
} finally {
|
|
205
|
-
cleanupSession(sessionId);
|
|
206
|
-
}
|
|
207
|
-
});
|
|
@@ -1,161 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,69 +0,0 @@
|
|
|
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,109 +0,0 @@
|
|
|
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-'));
|
|
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
|
-
// Isolate from the user's real global config: point homedir() at the temp
|
|
28
|
-
// project so the global-config fallback finds nothing (registry default).
|
|
29
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
30
|
-
});
|
|
31
|
-
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function writePackageJson(content) {
|
|
35
|
-
return {
|
|
36
|
-
tool_name: 'Write',
|
|
37
|
-
tool_input: { file_path: '/repo/package.json', content },
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
function isWarn(result) {
|
|
41
|
-
return result?.hookSpecificOutput?.additionalContext !== undefined;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
test('warns when a new dependency has no matching skill', () => {
|
|
45
|
-
const package_ = JSON.stringify({ dependencies: { stripe: '1.0.0' } });
|
|
46
|
-
assert.ok(isWarn(runGate(writePackageJson(package_))));
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test('allows when the dependency has a matching skill directory', () => {
|
|
50
|
-
const package_ = JSON.stringify({ dependencies: { stripe: '1.0.0' } });
|
|
51
|
-
assert.equal(
|
|
52
|
-
runGate(writePackageJson(package_), { skills: ['stripe'] }),
|
|
53
|
-
null,
|
|
54
|
-
);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test('allows an exempt dependency without its own API', () => {
|
|
58
|
-
const package_ = JSON.stringify({
|
|
59
|
-
dependencies: { clsx: '1.0.0', '@types/node': '1.0.0' },
|
|
60
|
-
});
|
|
61
|
-
assert.equal(runGate(writePackageJson(package_)), null);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test('allows a non-package.json write and unparseable content', () => {
|
|
65
|
-
assert.equal(
|
|
66
|
-
runGate({
|
|
67
|
-
tool_name: 'Write',
|
|
68
|
-
tool_input: { file_path: '/repo/src/index.js', content: 'x' },
|
|
69
|
-
}),
|
|
70
|
-
null,
|
|
71
|
-
);
|
|
72
|
-
assert.equal(
|
|
73
|
-
runGate(writePackageJson('{ "dependencies": { incomplete')),
|
|
74
|
-
null,
|
|
75
|
-
);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test('disabled by config: the gate does not run', () => {
|
|
79
|
-
const package_ = JSON.stringify({ dependencies: { stripe: '1.0.0' } });
|
|
80
|
-
assert.equal(
|
|
81
|
-
runGate(writePackageJson(package_), {
|
|
82
|
-
config: { gates: { requireSkillForNewDependency: false } },
|
|
83
|
-
}),
|
|
84
|
-
null,
|
|
85
|
-
);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('project projectSkillsDir override is honored', () => {
|
|
89
|
-
const package_ = JSON.stringify({ dependencies: { stripe: '1.0.0' } });
|
|
90
|
-
const config = {
|
|
91
|
-
gates: {
|
|
92
|
-
requireSkillForNewDependency: {
|
|
93
|
-
enabled: true,
|
|
94
|
-
projectSkillsDir: 'custom-skills',
|
|
95
|
-
},
|
|
96
|
-
},
|
|
97
|
-
};
|
|
98
|
-
const project = mkdtempSync(join(tmpdir(), 'dependency-skills-'));
|
|
99
|
-
mkdirSync(join(project, '.git'));
|
|
100
|
-
mkdirSync(join(project, '.ai'));
|
|
101
|
-
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
102
|
-
mkdirSync(join(project, 'custom-skills', 'stripe'), { recursive: true });
|
|
103
|
-
const out = execFileSync(process.execPath, [GATE], {
|
|
104
|
-
input: JSON.stringify(writePackageJson(package_)),
|
|
105
|
-
encoding: 'utf8',
|
|
106
|
-
cwd: project,
|
|
107
|
-
});
|
|
108
|
-
assert.equal(out.trim() ? JSON.parse(out.trim()) : null, null);
|
|
109
|
-
});
|