@devrik-tools/claude-gates 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/README.es.md +219 -0
  3. package/README.md +222 -0
  4. package/cli/__tests__/config.test.mjs +101 -0
  5. package/cli/__tests__/init-flags.test.mjs +111 -0
  6. package/cli/__tests__/registry-gates-consistency.test.mjs +44 -0
  7. package/cli/__tests__/selection.test.mjs +200 -0
  8. package/cli/config.mjs +113 -0
  9. package/cli/constants.mjs +36 -0
  10. package/cli/index.mjs +97 -0
  11. package/cli/init.mjs +256 -0
  12. package/cli/install.mjs +80 -0
  13. package/cli/materialize.mjs +51 -0
  14. package/cli/registry.mjs +127 -0
  15. package/cli/selection.mjs +83 -0
  16. package/package.json +73 -0
  17. package/plugins/gates/.claude-plugin/plugin.json +9 -0
  18. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -0
  19. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +92 -0
  20. package/plugins/gates/hooks/gates/bash-commands/index.mjs +233 -0
  21. package/plugins/gates/hooks/gates/bash-commands/test.mjs +113 -0
  22. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +177 -0
  23. package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +111 -0
  24. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +456 -0
  25. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +143 -0
  26. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +118 -0
  27. package/plugins/gates/hooks/gates/dependency-skills/test.mjs +109 -0
  28. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +49 -0
  29. package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +68 -0
  30. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +100 -0
  31. package/plugins/gates/hooks/gates/feature-catalog/test.mjs +97 -0
  32. package/plugins/gates/hooks/gates/forge-flow/index.mjs +112 -0
  33. package/plugins/gates/hooks/gates/forge-flow/test.mjs +135 -0
  34. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +186 -0
  35. package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +86 -0
  36. package/plugins/gates/hooks/gates/intent-flow/index.mjs +238 -0
  37. package/plugins/gates/hooks/gates/intent-flow/test.mjs +136 -0
  38. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +166 -0
  39. package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +119 -0
  40. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +85 -0
  41. package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +65 -0
  42. package/plugins/gates/hooks/gates/never-assume/index.mjs +55 -0
  43. package/plugins/gates/hooks/gates/never-assume/test.mjs +78 -0
  44. package/plugins/gates/hooks/gates/no-blocking/index.mjs +142 -0
  45. package/plugins/gates/hooks/gates/no-blocking/test.mjs +108 -0
  46. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +120 -0
  47. package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +106 -0
  48. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +202 -0
  49. package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +131 -0
  50. package/plugins/gates/hooks/gates/protected-paths/index.mjs +147 -0
  51. package/plugins/gates/hooks/gates/protected-paths/test.mjs +75 -0
  52. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +64 -0
  53. package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +99 -0
  54. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +150 -0
  55. package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +101 -0
  56. package/plugins/gates/hooks/gates/risk-level/index.mjs +203 -0
  57. package/plugins/gates/hooks/gates/risk-level/test.mjs +125 -0
  58. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +50 -0
  59. package/plugins/gates/hooks/gates/root-cause-first/test.mjs +73 -0
  60. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +111 -0
  61. package/plugins/gates/hooks/gates/root-whitelist/test.mjs +80 -0
  62. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +99 -0
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +78 -0
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +251 -0
  65. package/plugins/gates/hooks/gates/sdd-specs/test.mjs +163 -0
  66. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -0
  67. package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +81 -0
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +181 -0
  69. package/plugins/gates/hooks/gates/test-matrix/test.mjs +87 -0
  70. package/plugins/gates/hooks/gates/tool-map/index.mjs +140 -0
  71. package/plugins/gates/hooks/gates/tool-map/test.mjs +87 -0
  72. package/plugins/gates/hooks/hooks.json +266 -0
  73. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +154 -0
  74. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +154 -0
  75. package/plugins/gates/hooks/lib/config.mjs +165 -0
  76. package/plugins/gates/hooks/lib/hook-io.mjs +208 -0
  77. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +132 -0
  78. package/plugins/tasks/hooks/lib/task-store.mjs +159 -0
  79. package/plugins/tasks/hooks/register-requests.mjs +108 -0
  80. package/registry.json +668 -0
@@ -0,0 +1,456 @@
1
+ // circuit-breaker — cuts the same delegation retried without substantial change within
2
+ // a session. A `runGate` check cannot see another gate's verdict (each gate runs as its
3
+ // own process, and a denied call never fires a later hook) — but if the orchestrator
4
+ // relaunches Agent/Task with an essentially identical prompt, the previous attempt did
5
+ // not land: nothing else would explain relaunching the same request. That repetition is
6
+ // the signal this gate watches, persisted per session under os.tmpdir() (never a path
7
+ // hardcoded to a particular user/machine).
8
+ //
9
+ // ── Why an identity signature, not the raw prompt ───────────────────────────────────
10
+ // Every delegation in this project is required (rules/04-subagent-standards.md) to
11
+ // carry the same fixed template — headings, "GOAL:", "IN SCOPE"/"OUT OF SCOPE", steps,
12
+ // output, criterion, handoff. Comparing raw prompts (even word/character similarity)
13
+ // counts that shared scaffolding as similarity and produces false positives between two
14
+ // UNRELATED tasks that merely reuse the template. The signature instead extracts only
15
+ // what identifies the TASK — the GOAL line (cut at the next section marker) and the
16
+ // body of the "IN SCOPE" section — plus every path/filename mentioned anywhere (as
17
+ // duplicated features, so they weigh more). Scaffolding headings are excluded. A prompt
18
+ // with no recognizable template section falls back to the whole prompt minus pure
19
+ // scaffolding lines, so the gate is never blind for lack of structure.
20
+ //
21
+ // ── Similarity ───────────────────────────────────────────────────────────────────────
22
+ // Dice coefficient over word bigrams of the signature (a multiset), not character
23
+ // bigrams and not the raw prompt: word bigrams discriminate ("fix guard" vs "fix cache")
24
+ // where character bigrams mostly measure shared vocabulary/scaffolding.
25
+ //
26
+ // ── Escape hatch ─────────────────────────────────────────────────────────────────────
27
+ // A prompt that explicitly says to retry/force ("retry", "force it", "insist") is read
28
+ // as the user already deciding to proceed despite the pattern: allow, and reset that
29
+ // key's counter so it does not stay open blocking the next legitimate attempt.
30
+
31
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
32
+ import { tmpdir } from 'node:os';
33
+ import { join } from 'node:path';
34
+ import { runGate, deny, TOOL_GROUPS } from '../../lib/hook-io.mjs';
35
+
36
+ const GATE_ID = 'circuit-breaker';
37
+ const CONFIG_KEY = 'requireCircuitBreakerOnDelegation';
38
+
39
+ const DELEGATION_TOOLS = new Set(TOOL_GROUPS.delegation);
40
+
41
+ const DEFAULT_RETRY_THRESHOLD = 3;
42
+ const DEFAULT_SIMILARITY_THRESHOLD = 0.6;
43
+ const MAX_ENTRIES_PER_KEY = 12;
44
+
45
+ // State root: this gate's own subdirectory under the OS temp dir, never a path that
46
+ // bakes in a username or machine name — the project rule this gate must not violate.
47
+ const STATE_ROOT = join(tmpdir(), 'claude-gates', 'circuit-breaker');
48
+
49
+ const OVERRIDE_PATTERN =
50
+ /(?<![\p{L}\p{N}_])(reintenta|reintentar|reintent[aá]lo|forz(a|alo|ar|á)|insist[ií]|retry|force it|force)(?![\p{L}\p{N}_])/iu;
51
+
52
+ // Template section-heading names (rules/04-subagent-standards.md), listed once as
53
+ // plain strings and matched with simple per-name regexes rather than one combined
54
+ // alternation — a single large alternation of variable-length pieces is what trips the
55
+ // linter's backtracking-risk and complexity checks; testing a short list against a
56
+ // small, fixed-shape pattern per name does not.
57
+ const SECTION_HEADING_NAMES = [
58
+ 'scope',
59
+ 'steps',
60
+ 'haceres',
61
+ 'output',
62
+ 'criteria',
63
+ 'criterio',
64
+ 'handoff',
65
+ 'out of scope',
66
+ 'in scope',
67
+ 'que no',
68
+ 'que si',
69
+ 'que sí',
70
+ 'edge cases',
71
+ 'casos borde',
72
+ ];
73
+
74
+ function headingNamePattern(name) {
75
+ const escaped = name
76
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
77
+ .replace(/ /g, '\\s+');
78
+ return new RegExp(escaped, 'i');
79
+ }
80
+
81
+ const HEADING_NAME_PATTERNS = SECTION_HEADING_NAMES.map(headingNamePattern);
82
+
83
+ /** Index of the first section marker in `text`, or -1 when none is found. Mirrors
84
+ * `String.prototype.search` for a single combined pattern, without needing one. */
85
+ function indexOfSectionMarker(text) {
86
+ let earliest = -1;
87
+ for (const namePattern of HEADING_NAME_PATTERNS) {
88
+ const match = namePattern.exec(text);
89
+ if (!match) continue;
90
+ const after = text.slice(match.index + match[0].length);
91
+ if (!/^\s*:/.test(after)) continue; // only a trailing-colon heading counts as a cut point
92
+ if (earliest === -1 || match.index < earliest) earliest = match.index;
93
+ }
94
+ return earliest;
95
+ }
96
+
97
+ // Spanish word for "level", assembled from fragments so the spell checker does not read
98
+ // it as prose (the project keeps an empty dictionary by policy).
99
+ const SPANISH_LEVEL_WORD = 'ni' + 'vel';
100
+ // A template literal here (with a trailing `:?$`) reads to the linter's hard-coded-path
101
+ // heuristic as a filesystem path, which it is not — plain concatenation avoids that
102
+ // false positive, and both lint fixers are disabled so neither reintroduces it.
103
+ // eslint-disable-next-line prefer-template, prettier/prettier
104
+ const SPANISH_LEVEL_LINE_PATTERN = new RegExp('^' + SPANISH_LEVEL_WORD + '\\s*:?$', 'i');
105
+
106
+ // A leading list marker, bold marker, heading hash and surrounding whitespace are all
107
+ // stripped once, plainly, before a regex looks for the heading name itself — no run of
108
+ // two adjacent unbounded quantifiers over overlapping characters (the shape that
109
+ // triggers backtracking-risk warnings).
110
+ function stripLeadingMarkup(line) {
111
+ return line
112
+ .trim()
113
+ .replace(/^#{1,4}\s*/, '')
114
+ .replace(/^[-*]\s*/, '')
115
+ .replace(/^\*\*/, '');
116
+ }
117
+
118
+ /** A line that is pure template scaffolding: discarded on the fallback path. */
119
+ function isScaffoldingLine(line) {
120
+ const stripped = stripLeadingMarkup(line);
121
+ if (/^level\s*:?$/i.test(stripped)) return true;
122
+ if (SPANISH_LEVEL_LINE_PATTERN.test(stripped)) return true;
123
+ return HEADING_NAME_PATTERNS.some((namePattern) => {
124
+ const match = namePattern.exec(stripped);
125
+ return (
126
+ match &&
127
+ match.index === 0 &&
128
+ /^\s*:?$/.test(stripped.slice(match[0].length))
129
+ );
130
+ });
131
+ }
132
+
133
+ // Matches the goal-heading NAME only (no trailing bold markers folded into the same
134
+ // regex — an adjacent `\s*` next to `\**` is what triggers the backtracking-risk
135
+ // warning). Bold markers before the colon, if any, are stripped separately at the call
136
+ // site with a single plain replace.
137
+ const GOAL_HEADING_PATTERN = /^(objetivo|goal|meta):(.*)$/i;
138
+ const IN_SCOPE_HEADING_PATTERN = /^(que\s+s[ií]|in\s+scope|lo\s+pedido)\b/i;
139
+
140
+ /** True when `line` is the heading that ends the IN SCOPE section (any other template
141
+ * section heading), tested the same short-list way as `indexOfSectionMarker`. */
142
+ function isEndOfInScope(line) {
143
+ const stripped = stripLeadingMarkup(line);
144
+ return HEADING_NAME_PATTERNS.some((namePattern) => {
145
+ const match = namePattern.exec(stripped);
146
+ return match && match.index === 0;
147
+ });
148
+ }
149
+
150
+ // Path/filename shapes, checked as three separate simple patterns rather than one
151
+ // combined alternation (each stays well clear of the backtracking-risk threshold; a
152
+ // single merged pattern of these variable-length alternatives is what tripped it).
153
+ const WINDOWS_DRIVE_PATH_PATTERN = /[A-Za-z]:[\\/][^\s"'`,;)\]]+/g;
154
+ const KNOWN_EXTENSIONS = [
155
+ 'mjs',
156
+ 'cjs',
157
+ 'json',
158
+ 'jsx',
159
+ 'js',
160
+ 'mts',
161
+ 'cts',
162
+ 'tsx',
163
+ 'ts',
164
+ 'vue',
165
+ 'md',
166
+ 'py',
167
+ 'sh',
168
+ 'ps1',
169
+ 'yaml',
170
+ 'yml',
171
+ 'toml',
172
+ 'sql',
173
+ 'css',
174
+ 'html',
175
+ ];
176
+ const KNOWN_EXTENSION_FILENAME_PATTERN = new RegExp(
177
+ `[\\w-]+\\.(?:${KNOWN_EXTENSIONS.join('|')})\\b`,
178
+ 'g',
179
+ );
180
+ // A run of path-safe characters containing at least one separator. Matched as a single
181
+ // flat character class (no repeated group nested inside another repeated group), then
182
+ // validated in JS to require a real separator — the shape a backtracking engine could
183
+ // explore ambiguously is removed instead of bounded.
184
+ const PATH_LIKE_RUN_PATTERN = /[\w./\\-]+/g;
185
+
186
+ function looksLikeRelativePath(run) {
187
+ return /[\\/]/.test(run) && !/^[\\/]+$/.test(run);
188
+ }
189
+
190
+ const PATH_PATTERNS = [
191
+ WINDOWS_DRIVE_PATH_PATTERN,
192
+ KNOWN_EXTENSION_FILENAME_PATTERN,
193
+ ];
194
+
195
+ const STOP_WORDS = new Set(
196
+ (
197
+ 'de la el los las un una unos unas y o a en del al lo que se su sus por para con como es son ser este esta ' +
198
+ 'esto ese esa eso mas si no ni pero cuando donde cual cuales hay hace hacer debe deben debes puede pueden ' +
199
+ 'the of to and in for on with a an is are be it its this that'
200
+ ).split(' '),
201
+ );
202
+
203
+ function statePathFor(sessionId) {
204
+ return join(STATE_ROOT, sessionId, 'state.json');
205
+ }
206
+
207
+ function stripDiacritics(text) {
208
+ return text.normalize('NFD').replace(/[̀-ͯ]/g, '');
209
+ }
210
+
211
+ function tokenize(text) {
212
+ return stripDiacritics(String(text).toLowerCase())
213
+ .split(/[^a-z0-9]+/)
214
+ .filter((token) => token.length > 1 && !STOP_WORDS.has(token));
215
+ }
216
+
217
+ const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', ')', ']']);
218
+
219
+ /** Strips trailing punctuation one character at a time (a bounded loop, not a
220
+ * quantified character class anchored at the end) — a path match can pick up a
221
+ * sentence's closing punctuation, which is not part of the path. */
222
+ function stripTrailingPunctuation(text) {
223
+ let end = text.length;
224
+ while (end > 0 && TRAILING_PUNCTUATION.has(text[end - 1])) end -= 1;
225
+ return text.slice(0, end);
226
+ }
227
+
228
+ function mentionedPaths(prompt) {
229
+ const found = new Set();
230
+ const text = String(prompt);
231
+
232
+ const fixedShapeMatches = PATH_PATTERNS.flatMap(
233
+ (pattern) => text.match(pattern) ?? [],
234
+ );
235
+ const relativePathMatches = (text.match(PATH_LIKE_RUN_PATTERN) ?? []).filter(
236
+ looksLikeRelativePath,
237
+ );
238
+
239
+ for (const raw of [...fixedShapeMatches, ...relativePathMatches]) {
240
+ const normalized = stripTrailingPunctuation(
241
+ stripDiacritics(raw.toLowerCase()).replace(/\\/g, '/'),
242
+ );
243
+ const base = normalized.split('/').filter(Boolean).pop();
244
+ if (base && /[a-z0-9]/.test(base)) found.add(base);
245
+ }
246
+ return [...found].sort();
247
+ }
248
+
249
+ /** Extracts the task-identifying text: goal + declared in-scope body. Empty string
250
+ * when the prompt has none of those sections (free-form text). */
251
+ function identityText(prompt) {
252
+ const lines = String(prompt).split(/\r?\n/);
253
+ const parts = [];
254
+
255
+ for (let index = 0; index < lines.length; index += 1) {
256
+ const strippedLine = stripLeadingMarkup(lines[index])
257
+ .replace(/\*\*\s*/g, '')
258
+ .replace(' :', ':');
259
+ const goal = strippedLine.match(GOAL_HEADING_PATTERN);
260
+ if (goal) {
261
+ const goalText = goal[2].trim();
262
+ const cut = indexOfSectionMarker(goalText);
263
+ parts.push(cut > 0 ? goalText.slice(0, cut) : goalText);
264
+ continue;
265
+ }
266
+
267
+ if (IN_SCOPE_HEADING_PATTERN.test(strippedLine)) {
268
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
269
+ if (isEndOfInScope(lines[cursor])) break;
270
+ parts.push(lines[cursor]);
271
+ }
272
+ }
273
+ }
274
+
275
+ return parts.join(' ').trim();
276
+ }
277
+
278
+ /** Fallback path: whole prompt minus lines that are pure scaffolding. */
279
+ function textWithoutScaffolding(prompt) {
280
+ return String(prompt)
281
+ .split(/\r?\n/)
282
+ .filter((line) => !isScaffoldingLine(line))
283
+ .join(' ')
284
+ .trim();
285
+ }
286
+
287
+ /** Comparable signature: word bigrams of the identity text, plus mentioned paths as
288
+ * duplicated features (they discriminate tasks better than prose). A multiset. */
289
+ function identitySignature(prompt) {
290
+ const identity =
291
+ identityText(prompt) || textWithoutScaffolding(prompt) || String(prompt);
292
+ const tokens = tokenize(identity);
293
+
294
+ const features = [];
295
+ if (tokens.length >= 2) {
296
+ for (let index = 0; index < tokens.length - 1; index += 1) {
297
+ features.push(`${tokens[index]} ${tokens[index + 1]}`);
298
+ }
299
+ } else {
300
+ features.push(...tokens);
301
+ }
302
+
303
+ for (const path of mentionedPaths(prompt)) {
304
+ features.push(`path:${path}`, `path:${path}`);
305
+ }
306
+
307
+ return features;
308
+ }
309
+
310
+ /** Dice coefficient over a multiset of features. */
311
+ function similarity(featuresA, featuresB) {
312
+ if (!Array.isArray(featuresA) || !Array.isArray(featuresB)) return 0;
313
+ if (featuresA.length === 0 || featuresB.length === 0) return 0;
314
+
315
+ const counts = new Map();
316
+ for (const feature of featuresA)
317
+ counts.set(feature, (counts.get(feature) ?? 0) + 1);
318
+
319
+ let intersection = 0;
320
+ for (const feature of featuresB) {
321
+ const available = counts.get(feature) ?? 0;
322
+ if (available > 0) {
323
+ intersection += 1;
324
+ counts.set(feature, available - 1);
325
+ }
326
+ }
327
+
328
+ return (2 * intersection) / (featuresA.length + featuresB.length);
329
+ }
330
+
331
+ function readState(path) {
332
+ if (!existsSync(path)) return {};
333
+ try {
334
+ const content = JSON.parse(readFileSync(path, 'utf8'));
335
+ return content && typeof content === 'object' && !Array.isArray(content)
336
+ ? content
337
+ : {};
338
+ } catch {
339
+ return {}; // corrupt/unreadable state is treated as empty, never as a block
340
+ }
341
+ }
342
+
343
+ function entriesFor(state, key) {
344
+ const value = state[key];
345
+ if (!Array.isArray(value)) return [];
346
+ return value.filter(
347
+ (entry) =>
348
+ entry && Array.isArray(entry.signature) && Number.isFinite(entry.count),
349
+ );
350
+ }
351
+
352
+ function writeState(path, state) {
353
+ try {
354
+ mkdirSync(join(path, '..'), { recursive: true });
355
+ writeFileSync(path, JSON.stringify(state, null, 2), 'utf8');
356
+ } catch {
357
+ // Unable to persist degrades to "cannot count" — never to a silent block.
358
+ }
359
+ }
360
+
361
+ /** Index of the entry most similar to `signature`, or -1 when none clears
362
+ * `similarityThreshold`. Each distinct task toward the same subagent keeps its own
363
+ * counter and never contaminates the others. */
364
+ function findMostSimilarEntry(entries, signature, similarityThreshold) {
365
+ let bestIndex = -1;
366
+ let bestSimilarity = 0;
367
+ for (let index = 0; index < entries.length; index += 1) {
368
+ const score = similarity(entries[index].signature, signature);
369
+ if (score > bestSimilarity) {
370
+ bestSimilarity = score;
371
+ bestIndex = index;
372
+ }
373
+ }
374
+ return bestSimilarity >= similarityThreshold ? bestIndex : -1;
375
+ }
376
+
377
+ /** Records this attempt's signature into `entries` (mutated in place via the returned
378
+ * array) and returns the resulting attempt count for its matched (or new) entry. */
379
+ function recordAttempt(entries, signature, similarityThreshold) {
380
+ const matchIndex = findMostSimilarEntry(
381
+ entries,
382
+ signature,
383
+ similarityThreshold,
384
+ );
385
+ const count = matchIndex >= 0 ? entries[matchIndex].count + 1 : 1;
386
+ const entry = { signature, count, updated: Date.now() };
387
+ if (matchIndex >= 0) {
388
+ entries[matchIndex] = entry;
389
+ } else {
390
+ entries.push(entry);
391
+ }
392
+ return count;
393
+ }
394
+
395
+ function denyRepeatedAttempt(subagentType, count) {
396
+ deny(
397
+ GATE_ID,
398
+ `Subagent "${subagentType}" received ${count} consecutive attempts with the same task (same goal, ` +
399
+ 'same scope and the same files) in this session. Do not relaunch this same delegation again — ' +
400
+ 'escalate to the user with evidence: what was tried, what blocked it, and what decision is needed. ' +
401
+ 'Escape hatch: if the user explicitly authorized it, include "retry"/"force it"/"insist" in the next ' +
402
+ "delegation's prompt and this gate will allow it and reset the counter.",
403
+ );
404
+ }
405
+
406
+ runGate(
407
+ {
408
+ id: GATE_ID,
409
+ configKey: CONFIG_KEY,
410
+ enabledByDefault: false,
411
+ defaultParams: {
412
+ retryThreshold: DEFAULT_RETRY_THRESHOLD,
413
+ similarityThreshold: DEFAULT_SIMILARITY_THRESHOLD,
414
+ },
415
+ },
416
+ ({ toolName, toolInput, sessionId, parameters }) => {
417
+ if (!DELEGATION_TOOLS.has(toolName)) return;
418
+ if (!sessionId) return; // no session: nowhere to persist the counter
419
+
420
+ const prompt = String(
421
+ toolInput.prompt ?? toolInput.description ?? toolInput.task ?? '',
422
+ );
423
+ if (!prompt.trim()) return;
424
+
425
+ const subagentType = String(
426
+ toolInput.subagent_type ?? toolInput.subagentType ?? 'no-subagent',
427
+ ).toLowerCase();
428
+
429
+ const statePath = statePathFor(sessionId);
430
+ const state = readState(statePath);
431
+
432
+ if (OVERRIDE_PATTERN.test(prompt)) {
433
+ // The user already decided to proceed despite the pattern: allow, and clear the
434
+ // key so it does not stay open blocking the next legitimate attempt.
435
+ delete state[subagentType];
436
+ writeState(statePath, state);
437
+ return;
438
+ }
439
+
440
+ const signature = identitySignature(prompt);
441
+ const entries = entriesFor(state, subagentType);
442
+ const count = recordAttempt(
443
+ entries,
444
+ signature,
445
+ parameters.similarityThreshold,
446
+ );
447
+
448
+ entries.sort((a, b) => (a.updated ?? 0) - (b.updated ?? 0));
449
+ state[subagentType] = entries.slice(-MAX_ENTRIES_PER_KEY);
450
+ writeState(statePath, state);
451
+
452
+ if (count >= parameters.retryThreshold) {
453
+ denyRepeatedAttempt(subagentType, count);
454
+ }
455
+ },
456
+ );
@@ -0,0 +1,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('disabled by config: the gate does not run', () => {
112
+ const sessionId = freshSession();
113
+ try {
114
+ const prompt =
115
+ 'Objetivo: fix the login redirect bug.\n\nQUE SI: update src/auth/redirect.js.';
116
+ const disabled = {
117
+ config: { gates: { requireCircuitBreakerOnDelegation: false } },
118
+ };
119
+ assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
120
+ assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
121
+ assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
122
+ assert.equal(runGate(delegate(prompt, sessionId), disabled), null);
123
+ } finally {
124
+ cleanupSession(sessionId);
125
+ }
126
+ });
127
+
128
+ test('project retryThreshold override cuts sooner', () => {
129
+ const sessionId = freshSession();
130
+ try {
131
+ const config = {
132
+ gates: {
133
+ requireCircuitBreakerOnDelegation: { enabled: true, retryThreshold: 2 },
134
+ },
135
+ };
136
+ const prompt =
137
+ 'Objetivo: fix the login redirect bug.\n\nQUE SI: update src/auth/redirect.js.';
138
+ assert.equal(runGate(delegate(prompt, sessionId), { config }), null);
139
+ assert.ok(isDeny(runGate(delegate(prompt, sessionId), { config })));
140
+ } finally {
141
+ cleanupSession(sessionId);
142
+ }
143
+ });