@devrik-tools/claude-gates 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +7 -1
  2. package/cli/__tests__/config.test.mjs +113 -101
  3. package/cli/__tests__/install.test.mjs +102 -0
  4. package/cli/__tests__/materialize.test.mjs +95 -0
  5. package/cli/__tests__/registry-gates-consistency.test.mjs +52 -44
  6. package/cli/__tests__/task.test.mjs +124 -0
  7. package/cli/__tests__/version-consistency.test.mjs +44 -0
  8. package/cli/config.mjs +124 -113
  9. package/cli/index.mjs +100 -97
  10. package/cli/init.mjs +259 -256
  11. package/cli/install.mjs +150 -80
  12. package/cli/materialize.mjs +102 -51
  13. package/cli/registry.mjs +136 -127
  14. package/cli/task.mjs +140 -0
  15. package/package.json +2 -1
  16. package/plugins/gates/.claude-plugin/plugin.json +8 -2
  17. package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +83 -0
  18. package/plugins/gates/hooks/__tests__/doctor.test.mjs +85 -0
  19. package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +65 -0
  20. package/plugins/gates/hooks/ask-adoption.mjs +147 -0
  21. package/plugins/gates/hooks/doctor.mjs +207 -0
  22. package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +83 -0
  23. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +88 -88
  24. package/plugins/gates/hooks/gates/audit-before-build/test.mjs +117 -92
  25. package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +104 -0
  26. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +48 -45
  27. package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +165 -0
  28. package/plugins/gates/hooks/gates/bash-commands/index.mjs +285 -268
  29. package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +151 -0
  30. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +265 -177
  31. package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +207 -0
  32. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +501 -456
  33. package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +161 -143
  34. package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +69 -0
  35. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +126 -118
  36. package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +68 -0
  37. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +43 -49
  38. package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +83 -0
  39. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +83 -100
  40. package/plugins/gates/hooks/gates/force-parallel/index.mjs +119 -0
  41. package/plugins/gates/hooks/gates/force-parallel/test.mjs +88 -0
  42. package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +155 -0
  43. package/plugins/gates/hooks/gates/forge-flow/index.mjs +134 -112
  44. package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +66 -0
  45. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +187 -186
  46. package/plugins/gates/hooks/gates/intent-flow/index.mjs +260 -238
  47. package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +96 -0
  48. package/plugins/gates/hooks/gates/intent-flow/test.mjs +165 -136
  49. package/plugins/gates/hooks/gates/lint-commit/index.mjs +149 -0
  50. package/plugins/gates/hooks/gates/lint-commit/test.mjs +85 -0
  51. package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +100 -0
  52. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +180 -166
  53. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +79 -85
  54. package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +80 -0
  55. package/plugins/gates/hooks/gates/never-assume/index.mjs +58 -55
  56. package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +71 -0
  57. package/plugins/gates/hooks/gates/never-assume/test.mjs +95 -78
  58. package/plugins/gates/hooks/gates/no-blocking/index.mjs +148 -142
  59. package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +109 -0
  60. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +123 -120
  61. package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +83 -0
  62. package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +91 -0
  63. package/plugins/gates/hooks/gates/protected-paths/index.mjs +144 -147
  64. package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +126 -0
  65. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +89 -64
  66. package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +102 -0
  67. package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +94 -0
  68. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +159 -150
  69. package/plugins/gates/hooks/gates/risk-level/index.mjs +263 -203
  70. package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +116 -0
  71. package/plugins/gates/hooks/gates/risk-level/test.mjs +153 -125
  72. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +56 -50
  73. package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +58 -0
  74. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +116 -111
  75. package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +97 -0
  76. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +184 -99
  77. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +126 -0
  78. package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +130 -0
  79. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +256 -251
  80. package/plugins/gates/hooks/gates/stop-pending/index.mjs +159 -0
  81. package/plugins/gates/hooks/gates/stop-pending/test.mjs +114 -0
  82. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +101 -101
  83. package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +72 -0
  84. package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +63 -0
  85. package/plugins/gates/hooks/gates/test-matrix/index.mjs +187 -181
  86. package/plugins/gates/hooks/gates/test-matrix/test.mjs +114 -87
  87. package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +62 -0
  88. package/plugins/gates/hooks/gates/tool-map/index.mjs +143 -140
  89. package/plugins/gates/hooks/hooks.json +346 -286
  90. package/plugins/gates/hooks/lib/__tests__/config.test.mjs +173 -154
  91. package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +210 -154
  92. package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +114 -0
  93. package/plugins/gates/hooks/lib/config.mjs +172 -165
  94. package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +189 -0
  95. package/plugins/gates/hooks/lib/hook-io.mjs +318 -208
  96. package/plugins/gates/hooks/lib/signals.mjs +127 -0
  97. package/plugins/gates/hooks/wiring-check.mjs +227 -0
  98. package/plugins/tasks/.claude-plugin/plugin.json +14 -0
  99. package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +100 -0
  100. package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +95 -0
  101. package/plugins/tasks/hooks/hooks.json +26 -0
  102. package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +180 -132
  103. package/plugins/tasks/hooks/lib/task-store.mjs +197 -159
  104. package/plugins/tasks/hooks/register-requests.mjs +141 -108
  105. package/plugins/tasks/hooks/session-tasks.mjs +108 -0
  106. package/registry.json +793 -686
@@ -1,456 +1,501 @@
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
- );
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 gives a real override IMPERATIVE ("retry anyway", "force it",
28
+ // "insist") is read as the user already deciding to proceed despite the pattern: allow,
29
+ // and reset that key's counter so it does not stay open blocking the next legitimate
30
+ // attempt. The bare word "retry" mentioned as ordinary task vocabulary (e.g. "fix the
31
+ // retry loop") does NOT count only an imperative phrasing does (see OVERRIDE_PATTERN).
32
+ //
33
+ // ── Keying: by task identity, not by the caller-chosen subagent_type ────────────────
34
+ // subagent_type is free text the caller controls. Keying the counter by that string lets
35
+ // the exact same task evade detection just by varying it per relaunch. This gate instead
36
+ // keys by a hash of the normalized identity signature itself (see identitySignature
37
+ // below) the task's content, not a label the caller can rename at will.
38
+ //
39
+ // ── Trusting the count: recomputed, not stored ──────────────────────────────────────
40
+ // The persisted state is a list of past signatures (hashes + feature sets), never a raw
41
+ // counter. The attempt count for THIS call is always recomputed as
42
+ // "how many stored past entries are similar to this one, plus one for this call" — so a
43
+ // payload that pre-seeds or edits a `count` field on disk has nothing to tamper with:
44
+ // there is no counter field to overwrite, only a history the gate recounts itself. This
45
+ // does not add cryptographic integrity (no secret is available to sign with in a hook),
46
+ // but it does close the specific hole of a trusted, directly-writable numeric field.
47
+ //
48
+ // ── No session id: a stable fallback bucket, never a silent bypass ─────────────────
49
+ // A missing/blank session_id no longer disables the breaker. It falls back to a fixed,
50
+ // well-known bucket (NO_SESSION_BUCKET) instead of returning early — the per-task
51
+ // discrimination still comes from identityKey (a hash of the prompt's own identity
52
+ // signature), so two different tasks sharing that bucket never collide, while the SAME
53
+ // task repeated without a session id is still tracked and eventually trips the breaker.
54
+
55
+ import { createHash } from 'node:crypto';
56
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
57
+ import { tmpdir } from 'node:os';
58
+ import { join } from 'node:path';
59
+ import {
60
+ runGate,
61
+ deny,
62
+ toolInGroups,
63
+ delegationPromptOf,
64
+ } from '../../lib/hook-io.mjs';
65
+
66
+ const GATE_ID = 'circuit-breaker';
67
+ const CONFIG_KEY = 'requireCircuitBreakerOnDelegation';
68
+
69
+ const DEFAULT_RETRY_THRESHOLD = 3;
70
+ const DEFAULT_SIMILARITY_THRESHOLD = 0.6;
71
+ const MAX_ENTRIES_PER_KEY = 12;
72
+
73
+ // State root: this gate's own subdirectory under the OS temp dir, never a path that
74
+ // bakes in a username or machine name — the project rule this gate must not violate.
75
+ const STATE_ROOT = join(tmpdir(), 'claude-gates', 'circuit-breaker');
76
+
77
+ // Fixed bucket used when the payload carries no session id. Distinct tasks inside this
78
+ // bucket are still told apart by identityKey (derived from the prompt itself), so this
79
+ // is a location, not a discriminator — it never causes two unrelated tasks to collide.
80
+ const NO_SESSION_BUCKET = 'no-session';
81
+
82
+ // An override imperative: a directive to proceed anyway, not the bare topic word
83
+ // appearing as ordinary task vocabulary. Requires either a Spanish/English imperative
84
+ // verb form ("reintenta", "forzalo", "insisti") or the word "retry"/"force" paired
85
+ // immediately with "anyway"/"it"/"again" or similar — never "retry"/"force" alone,
86
+ // which a normal task description ("fix the retry loop") can contain innocently.
87
+ const OVERRIDE_PATTERN =
88
+ /(?<![\p{L}\p{N}_])(reintent[aá]lo|reintenta(lo)?|forz(alo|á|ar)\b(?!\s+un|\s+una)|insist[ií]|retry\s+(anyway|it|again|this)|force\s+(it|this|anyway)|do\s+it\s+anyway)(?![\p{L}\p{N}_])/iu;
89
+
90
+ // Template section-heading names (rules/04-subagent-standards.md), listed once as
91
+ // plain strings and matched with simple per-name regexes rather than one combined
92
+ // alternation a single large alternation of variable-length pieces is what trips the
93
+ // linter's backtracking-risk and complexity checks; testing a short list against a
94
+ // small, fixed-shape pattern per name does not.
95
+ const SECTION_HEADING_NAMES = [
96
+ 'scope',
97
+ 'steps',
98
+ 'haceres',
99
+ 'output',
100
+ 'criteria',
101
+ 'criterio',
102
+ 'handoff',
103
+ 'out of scope',
104
+ 'in scope',
105
+ 'que no',
106
+ 'que si',
107
+ 'que sí',
108
+ 'edge cases',
109
+ 'casos borde',
110
+ ];
111
+
112
+ function headingNamePattern(name) {
113
+ const escaped = name
114
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
115
+ .replace(/ /g, '\\s+');
116
+ return new RegExp(escaped, 'i');
117
+ }
118
+
119
+ const HEADING_NAME_PATTERNS = SECTION_HEADING_NAMES.map(headingNamePattern);
120
+
121
+ /** Index of the first section marker in `text`, or -1 when none is found. Mirrors
122
+ * `String.prototype.search` for a single combined pattern, without needing one. */
123
+ function indexOfSectionMarker(text) {
124
+ let earliest = -1;
125
+ for (const namePattern of HEADING_NAME_PATTERNS) {
126
+ const match = namePattern.exec(text);
127
+ if (!match) continue;
128
+ const after = text.slice(match.index + match[0].length);
129
+ if (!/^\s*:/.test(after)) continue; // only a trailing-colon heading counts as a cut point
130
+ if (earliest === -1 || match.index < earliest) earliest = match.index;
131
+ }
132
+ return earliest;
133
+ }
134
+
135
+ // Spanish word for "level", assembled from fragments so the spell checker does not read
136
+ // it as prose (the project keeps an empty dictionary by policy).
137
+ const SPANISH_LEVEL_WORD = 'ni' + 'vel';
138
+ // A template literal here (with a trailing `:?$`) reads to the linter's hard-coded-path
139
+ // heuristic as a filesystem path, which it is not — plain concatenation avoids that
140
+ // false positive, and both lint fixers are disabled so neither reintroduces it.
141
+ // eslint-disable-next-line prefer-template, prettier/prettier
142
+ const SPANISH_LEVEL_LINE_PATTERN = new RegExp('^' + SPANISH_LEVEL_WORD + '\\s*:?$', 'i');
143
+
144
+ // A leading list marker, bold marker, heading hash and surrounding whitespace are all
145
+ // stripped once, plainly, before a regex looks for the heading name itself — no run of
146
+ // two adjacent unbounded quantifiers over overlapping characters (the shape that
147
+ // triggers backtracking-risk warnings).
148
+ function stripLeadingMarkup(line) {
149
+ return line
150
+ .trim()
151
+ .replace(/^#{1,4}\s*/, '')
152
+ .replace(/^[-*]\s*/, '')
153
+ .replace(/^\*\*/, '');
154
+ }
155
+
156
+ /** A line that is pure template scaffolding: discarded on the fallback path. */
157
+ function isScaffoldingLine(line) {
158
+ const stripped = stripLeadingMarkup(line);
159
+ if (/^level\s*:?$/i.test(stripped)) return true;
160
+ if (SPANISH_LEVEL_LINE_PATTERN.test(stripped)) return true;
161
+ return HEADING_NAME_PATTERNS.some((namePattern) => {
162
+ const match = namePattern.exec(stripped);
163
+ return (
164
+ match &&
165
+ match.index === 0 &&
166
+ /^\s*:?$/.test(stripped.slice(match[0].length))
167
+ );
168
+ });
169
+ }
170
+
171
+ // Matches the goal-heading NAME only (no trailing bold markers folded into the same
172
+ // regex — an adjacent `\s*` next to `\**` is what triggers the backtracking-risk
173
+ // warning). Bold markers before the colon, if any, are stripped separately at the call
174
+ // site with a single plain replace.
175
+ const GOAL_HEADING_PATTERN = /^(objetivo|goal|meta):(.*)$/i;
176
+ const IN_SCOPE_HEADING_PATTERN = /^(que\s+s[ií]|in\s+scope|lo\s+pedido)\b/i;
177
+
178
+ /** True when `line` is the heading that ends the IN SCOPE section (any other template
179
+ * section heading), tested the same short-list way as `indexOfSectionMarker`. */
180
+ function isEndOfInScope(line) {
181
+ const stripped = stripLeadingMarkup(line);
182
+ return HEADING_NAME_PATTERNS.some((namePattern) => {
183
+ const match = namePattern.exec(stripped);
184
+ return match && match.index === 0;
185
+ });
186
+ }
187
+
188
+ // Path/filename shapes, checked as three separate simple patterns rather than one
189
+ // combined alternation (each stays well clear of the backtracking-risk threshold; a
190
+ // single merged pattern of these variable-length alternatives is what tripped it).
191
+ const WINDOWS_DRIVE_PATH_PATTERN = /[A-Za-z]:[\\/][^\s"'`,;)\]]+/g;
192
+ const KNOWN_EXTENSIONS = [
193
+ 'mjs',
194
+ 'cjs',
195
+ 'json',
196
+ 'jsx',
197
+ 'js',
198
+ 'mts',
199
+ 'cts',
200
+ 'tsx',
201
+ 'ts',
202
+ 'vue',
203
+ 'md',
204
+ 'py',
205
+ 'sh',
206
+ 'ps1',
207
+ 'yaml',
208
+ 'yml',
209
+ 'toml',
210
+ 'sql',
211
+ 'css',
212
+ 'html',
213
+ ];
214
+ const KNOWN_EXTENSION_FILENAME_PATTERN = new RegExp(
215
+ `[\\w-]+\\.(?:${KNOWN_EXTENSIONS.join('|')})\\b`,
216
+ 'g',
217
+ );
218
+ // A run of path-safe characters containing at least one separator. Matched as a single
219
+ // flat character class (no repeated group nested inside another repeated group), then
220
+ // validated in JS to require a real separatorthe shape a backtracking engine could
221
+ // explore ambiguously is removed instead of bounded.
222
+ const PATH_LIKE_RUN_PATTERN = /[\w./\\-]+/g;
223
+
224
+ function looksLikeRelativePath(run) {
225
+ return /[\\/]/.test(run) && !/^[\\/]+$/.test(run);
226
+ }
227
+
228
+ const PATH_PATTERNS = [
229
+ WINDOWS_DRIVE_PATH_PATTERN,
230
+ KNOWN_EXTENSION_FILENAME_PATTERN,
231
+ ];
232
+
233
+ const STOP_WORDS = new Set(
234
+ (
235
+ '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 ' +
236
+ 'esto ese esa eso mas si no ni pero cuando donde cual cuales hay hace hacer debe deben debes puede pueden ' +
237
+ 'the of to and in for on with a an is are be it its this that'
238
+ ).split(' '),
239
+ );
240
+
241
+ function statePathFor(sessionId) {
242
+ return join(STATE_ROOT, sessionId, 'state.json');
243
+ }
244
+
245
+ function stripDiacritics(text) {
246
+ return text.normalize('NFD').replace(/[̀-ͯ]/g, '');
247
+ }
248
+
249
+ function tokenize(text) {
250
+ return stripDiacritics(String(text).toLowerCase())
251
+ .split(/[^a-z0-9]+/)
252
+ .filter((token) => token.length > 1 && !STOP_WORDS.has(token));
253
+ }
254
+
255
+ const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', ')', ']']);
256
+
257
+ /** Strips trailing punctuation one character at a time (a bounded loop, not a
258
+ * quantified character class anchored at the end) — a path match can pick up a
259
+ * sentence's closing punctuation, which is not part of the path. */
260
+ function stripTrailingPunctuation(text) {
261
+ let end = text.length;
262
+ while (end > 0 && TRAILING_PUNCTUATION.has(text[end - 1])) end -= 1;
263
+ return text.slice(0, end);
264
+ }
265
+
266
+ function mentionedPaths(prompt) {
267
+ const found = new Set();
268
+ const text = String(prompt);
269
+
270
+ const fixedShapeMatches = PATH_PATTERNS.flatMap(
271
+ (pattern) => text.match(pattern) ?? [],
272
+ );
273
+ const relativePathMatches = (text.match(PATH_LIKE_RUN_PATTERN) ?? []).filter(
274
+ looksLikeRelativePath,
275
+ );
276
+
277
+ for (const raw of [...fixedShapeMatches, ...relativePathMatches]) {
278
+ const normalized = stripTrailingPunctuation(
279
+ stripDiacritics(raw.toLowerCase()).replace(/\\/g, '/'),
280
+ );
281
+ const base = normalized.split('/').filter(Boolean).pop();
282
+ if (base && /[a-z0-9]/.test(base)) found.add(base);
283
+ }
284
+ return [...found].sort();
285
+ }
286
+
287
+ /** Extracts the task-identifying text: goal + declared in-scope body. Empty string
288
+ * when the prompt has none of those sections (free-form text). */
289
+ function identityText(prompt) {
290
+ const lines = String(prompt).split(/\r?\n/);
291
+ const parts = [];
292
+
293
+ for (let index = 0; index < lines.length; index += 1) {
294
+ const strippedLine = stripLeadingMarkup(lines[index])
295
+ .replace(/\*\*\s*/g, '')
296
+ .replace(' :', ':');
297
+ const goal = strippedLine.match(GOAL_HEADING_PATTERN);
298
+ if (goal) {
299
+ const goalText = goal[2].trim();
300
+ const cut = indexOfSectionMarker(goalText);
301
+ parts.push(cut > 0 ? goalText.slice(0, cut) : goalText);
302
+ continue;
303
+ }
304
+
305
+ if (IN_SCOPE_HEADING_PATTERN.test(strippedLine)) {
306
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
307
+ if (isEndOfInScope(lines[cursor])) break;
308
+ parts.push(lines[cursor]);
309
+ }
310
+ }
311
+ }
312
+
313
+ return parts.join(' ').trim();
314
+ }
315
+
316
+ /** Fallback path: whole prompt minus lines that are pure scaffolding. */
317
+ function textWithoutScaffolding(prompt) {
318
+ return String(prompt)
319
+ .split(/\r?\n/)
320
+ .filter((line) => !isScaffoldingLine(line))
321
+ .join(' ')
322
+ .trim();
323
+ }
324
+
325
+ /** Comparable signature: word bigrams of the identity text, plus mentioned paths as
326
+ * duplicated features (they discriminate tasks better than prose). A multiset. */
327
+ function identitySignature(prompt) {
328
+ const identity =
329
+ identityText(prompt) || textWithoutScaffolding(prompt) || String(prompt);
330
+ const tokens = tokenize(identity);
331
+
332
+ const features = [];
333
+ if (tokens.length >= 2) {
334
+ for (let index = 0; index < tokens.length - 1; index += 1) {
335
+ features.push(`${tokens[index]} ${tokens[index + 1]}`);
336
+ }
337
+ } else {
338
+ features.push(...tokens);
339
+ }
340
+
341
+ for (const path of mentionedPaths(prompt)) {
342
+ features.push(`path:${path}`, `path:${path}`);
343
+ }
344
+
345
+ return features;
346
+ }
347
+
348
+ /** Dice coefficient over a multiset of features. */
349
+ function similarity(featuresA, featuresB) {
350
+ if (!Array.isArray(featuresA) || !Array.isArray(featuresB)) return 0;
351
+ if (featuresA.length === 0 || featuresB.length === 0) return 0;
352
+
353
+ const counts = new Map();
354
+ for (const feature of featuresA)
355
+ counts.set(feature, (counts.get(feature) ?? 0) + 1);
356
+
357
+ let intersection = 0;
358
+ for (const feature of featuresB) {
359
+ const available = counts.get(feature) ?? 0;
360
+ if (available > 0) {
361
+ intersection += 1;
362
+ counts.set(feature, available - 1);
363
+ }
364
+ }
365
+
366
+ return (2 * intersection) / (featuresA.length + featuresB.length);
367
+ }
368
+
369
+ function readState(path) {
370
+ if (!existsSync(path)) return {};
371
+ try {
372
+ const content = JSON.parse(readFileSync(path, 'utf8'));
373
+ return content && typeof content === 'object' && !Array.isArray(content)
374
+ ? content
375
+ : {};
376
+ } catch {
377
+ return {}; // corrupt/unreadable state is treated as empty, never as a block
378
+ }
379
+ }
380
+
381
+ /** Past occurrences recorded for a key: only `{ signature, seenAt }` entries survive —
382
+ * there is no `count` field in the persisted shape at all, so there is nothing for a
383
+ * forged/edited state file to inflate. The attempt count is always derived by counting
384
+ * how many of these stored occurrences are similar to the CURRENT signature (see
385
+ * countSimilarOccurrences), never read as a trusted number off disk. */
386
+ function occurrencesFor(state, key) {
387
+ const value = state[key];
388
+ if (!Array.isArray(value)) return [];
389
+ return value.filter(
390
+ (entry) => entry && Array.isArray(entry.signature),
391
+ );
392
+ }
393
+
394
+ function writeState(path, state) {
395
+ try {
396
+ mkdirSync(join(path, '..'), { recursive: true });
397
+ writeFileSync(path, JSON.stringify(state, null, 2), 'utf8');
398
+ } catch {
399
+ // Unable to persist degrades to "cannot count" never to a silent block.
400
+ }
401
+ }
402
+
403
+ /** How many stored past occurrences are similar enough to `signature` to count as the
404
+ * same repeated task, PLUS one for the current call itself. Recomputed fresh from the
405
+ * occurrence list every time — the count is a fact derived from stored signatures, not
406
+ * a number the state file carries and a forged file could set directly. */
407
+ function countSimilarOccurrences(occurrences, signature, similarityThreshold) {
408
+ const matches = occurrences.filter(
409
+ (entry) => similarity(entry.signature, signature) >= similarityThreshold,
410
+ );
411
+ return matches.length + 1;
412
+ }
413
+
414
+ /** Task-identity key: a hash of the normalized signature, not the caller-chosen
415
+ * subagent_type string. The exact same task tracked under a different subagent_type on
416
+ * each relaunch still lands on the same key, because the key is derived from what the
417
+ * task IS, not from a free-text label the caller can vary at will. */
418
+ function identityKey(signature) {
419
+ return createHash('sha256').update(signature.join(' ')).digest('hex');
420
+ }
421
+
422
+ function denyRepeatedAttempt(count) {
423
+ deny(
424
+ GATE_ID,
425
+ `This same task received ${count} consecutive attempts (same goal, same scope and the same files) ` +
426
+ 'in this session, regardless of which subagent_type carried it. Do not relaunch this same ' +
427
+ 'delegation again — escalate to the user with evidence: what was tried, what blocked it, and what ' +
428
+ 'decision is needed. Escape hatch: if the user explicitly authorized it, include an override ' +
429
+ 'imperative ("retry anyway"/"force it"/"insisti") in the next delegation\'s prompt and this gate ' +
430
+ 'will allow it and reset the counter.',
431
+ );
432
+ }
433
+
434
+ runGate(
435
+ {
436
+ id: GATE_ID,
437
+ configKey: CONFIG_KEY,
438
+ enabledByDefault: false,
439
+ defaultParams: {
440
+ retryThreshold: DEFAULT_RETRY_THRESHOLD,
441
+ similarityThreshold: DEFAULT_SIMILARITY_THRESHOLD,
442
+ },
443
+ },
444
+ ({ toolName, toolInput, sessionId, parameters }) => {
445
+ if (!toolInGroups(toolName, ['delegation'])) return;
446
+
447
+ const prompt = delegationPromptOf(toolInput);
448
+ if (!prompt.trim()) return;
449
+
450
+ // A missing/blank session id no longer disables the breaker: it falls back to a
451
+ // fixed bucket, with identityKey (below) still discriminating between tasks.
452
+ const effectiveSessionId = sessionId || NO_SESSION_BUCKET;
453
+
454
+ const statePath = statePathFor(effectiveSessionId);
455
+ const state = readState(statePath);
456
+
457
+ const signature = identitySignature(prompt);
458
+ const key = identityKey(signature);
459
+
460
+ if (OVERRIDE_PATTERN.test(prompt)) {
461
+ // The user already decided to proceed despite the pattern: allow, and clear
462
+ // whichever key(s) hold a similar history. The override phrase itself ("force it,
463
+ // retry.") is appended text that can shift the identity signature enough to land
464
+ // on a different hash than the original task's key — clearing only the exact
465
+ // current key would then miss the very history this call is meant to reset. Every
466
+ // existing key whose stored signature is similar to the current one (by the same
467
+ // threshold the normal path uses) is cleared, plus the current key itself.
468
+ for (const existingKey of Object.keys(state)) {
469
+ const occurrences = occurrencesFor(state, existingKey);
470
+ const isRelated =
471
+ existingKey === key ||
472
+ occurrences.some(
473
+ (entry) =>
474
+ similarity(entry.signature, signature) >=
475
+ parameters.similarityThreshold,
476
+ );
477
+ if (isRelated) delete state[existingKey];
478
+ }
479
+ writeState(statePath, state);
480
+ return;
481
+ }
482
+
483
+ const occurrences = occurrencesFor(state, key);
484
+ const count = countSimilarOccurrences(
485
+ occurrences,
486
+ signature,
487
+ parameters.similarityThreshold,
488
+ );
489
+
490
+ const updated = [
491
+ ...occurrences,
492
+ { signature, seenAt: Date.now() },
493
+ ].slice(-MAX_ENTRIES_PER_KEY);
494
+ state[key] = updated;
495
+ writeState(statePath, state);
496
+
497
+ if (count >= parameters.retryThreshold) {
498
+ denyRepeatedAttempt(count);
499
+ }
500
+ },
501
+ );