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