@devrik-tools/claude-gates 0.7.2 → 0.9.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 (83) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.es.md +129 -11
  3. package/README.md +120 -10
  4. package/cli/doctor.mjs +132 -0
  5. package/cli/evidence.mjs +78 -0
  6. package/cli/hooks-manifest.mjs +89 -0
  7. package/cli/index.mjs +124 -6
  8. package/cli/init.mjs +97 -3
  9. package/cli/log.mjs +70 -0
  10. package/cli/materialize.mjs +36 -2
  11. package/cli/registry.mjs +12 -1
  12. package/cli/selection.mjs +18 -0
  13. package/cli/smoke-fixtures.json +159 -25
  14. package/cli/task.mjs +104 -11
  15. package/cli/toggle.mjs +125 -0
  16. package/package.json +4 -3
  17. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  18. package/plugins/gates/hooks/doctor.mjs +47 -1
  19. package/plugins/gates/hooks/gates/atomic-commit/index.mjs +92 -119
  20. package/plugins/gates/hooks/gates/audit-before-build/index.mjs +101 -66
  21. package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -8
  22. package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +13 -64
  23. package/plugins/gates/hooks/gates/bash-commands/index.mjs +186 -163
  24. package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +100 -101
  25. package/plugins/gates/hooks/gates/brief-approved/index.mjs +71 -140
  26. package/plugins/gates/hooks/gates/brief-before-delegate/index.mjs +56 -137
  27. package/plugins/gates/hooks/gates/capability-map/index.mjs +280 -506
  28. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +94 -215
  29. package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
  30. package/plugins/gates/hooks/gates/dependency-skills/index.mjs +127 -46
  31. package/plugins/gates/hooks/gates/diagnosis-before-patch/index.mjs +81 -19
  32. package/plugins/gates/hooks/gates/engram-first/index.mjs +41 -0
  33. package/plugins/gates/hooks/gates/engram-first/session-start.mjs +64 -0
  34. package/plugins/gates/hooks/gates/engram-first/shared.mjs +90 -0
  35. package/plugins/gates/hooks/gates/engram-first/stop.mjs +23 -0
  36. package/plugins/gates/hooks/gates/engram-first/track.mjs +97 -0
  37. package/plugins/gates/hooks/gates/feature-catalog/index.mjs +156 -50
  38. package/plugins/gates/hooks/gates/force-parallel/index.mjs +53 -95
  39. package/plugins/gates/hooks/gates/forge-flow/index.mjs +51 -83
  40. package/plugins/gates/hooks/gates/implementation-pipeline/index.mjs +40 -80
  41. package/plugins/gates/hooks/gates/intent-flow/index.mjs +23 -149
  42. package/plugins/gates/hooks/gates/library-docs/index.mjs +338 -0
  43. package/plugins/gates/hooks/gates/library-docs/track.mjs +95 -0
  44. package/plugins/gates/hooks/gates/lint-commit/index.mjs +88 -97
  45. package/plugins/gates/hooks/gates/mandatory-flow/index.mjs +48 -95
  46. package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +69 -31
  47. package/plugins/gates/hooks/gates/never-assume/index.mjs +23 -18
  48. package/plugins/gates/hooks/gates/no-blocking/index.mjs +127 -86
  49. package/plugins/gates/hooks/gates/no-coauthor/index.mjs +108 -88
  50. package/plugins/gates/hooks/gates/no-explanatory-comments/index.mjs +281 -0
  51. package/plugins/gates/hooks/gates/no-lint-suppression/index.mjs +170 -110
  52. package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +46 -77
  53. package/plugins/gates/hooks/gates/no-reconfirm/index.mjs +85 -47
  54. package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
  55. package/plugins/gates/hooks/gates/protected-paths/index.mjs +137 -90
  56. package/plugins/gates/hooks/gates/recurrence-lock/index.mjs +95 -51
  57. package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
  58. package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
  59. package/plugins/gates/hooks/gates/reuse-before-build/index.mjs +72 -216
  60. package/plugins/gates/hooks/gates/risk-level/index.mjs +51 -204
  61. package/plugins/gates/hooks/gates/root-cause-first/index.mjs +34 -17
  62. package/plugins/gates/hooks/gates/root-whitelist/index.mjs +160 -95
  63. package/plugins/gates/hooks/gates/rule-skill-autodiscovery/index.mjs +196 -97
  64. package/plugins/gates/hooks/gates/sdd-specs/index.mjs +90 -143
  65. package/plugins/gates/hooks/gates/staged-lint/index.mjs +98 -106
  66. package/plugins/gates/hooks/gates/stop-pending/index.mjs +47 -148
  67. package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +78 -68
  68. package/plugins/gates/hooks/gates/test-matrix/index.mjs +53 -88
  69. package/plugins/gates/hooks/gates/tool-map/index.mjs +80 -110
  70. package/plugins/gates/hooks/hooks.json +152 -42
  71. package/plugins/gates/hooks/lib/config.mjs +6 -3
  72. package/plugins/gates/hooks/lib/delegation.mjs +352 -0
  73. package/plugins/gates/hooks/lib/gate-log.mjs +141 -0
  74. package/plugins/gates/hooks/lib/git.mjs +262 -0
  75. package/plugins/gates/hooks/lib/hook-io.mjs +451 -83
  76. package/plugins/gates/hooks/lib/session-state.mjs +150 -0
  77. package/plugins/gates/hooks/lib/signals.mjs +18 -13
  78. package/plugins/gates/hooks/lib/testing.mjs +148 -0
  79. package/plugins/gates/hooks/lib/tools.mjs +144 -0
  80. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  81. package/plugins/tasks/hooks/lib/task-store.mjs +12 -6
  82. package/plugins/tasks/hooks/register-requests.mjs +37 -10
  83. package/registry.json +249 -38
@@ -1,96 +1,41 @@
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).
1
+ // circuit-breaker — cuts the same delegation retried without substantial change within a
2
+ // session. A hook cannot see why a previous attempt did not land, but relaunching an
3
+ // essentially identical prompt is the signal that it did not.
8
4
  //
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.
5
+ // Decisions: identity is an extracted signature (GOAL line, IN SCOPE body, mentioned paths)
6
+ // compared by Dice over word bigrams, not the raw prompt — the shared delegation template
7
+ // would otherwise make unrelated tasks look alike. The attempt count is recomputed from
8
+ // stored signatures across every key (a one-word edit is still the same task), never read
9
+ // as a trusted number off disk. The key is a hash of the signature, not the caller-chosen
10
+ // subagent_type. An override imperative counts only in a short sentence, so "force it" inside
11
+ // a long instruction is task vocabulary, not the user's decision to proceed. A threshold
12
+ // below 2 is treated as 2: a first attempt is never a retry.
54
13
 
55
14
  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
15
  import {
60
16
  runGate,
61
17
  deny,
18
+ escapeRegExp,
62
19
  toolInGroups,
63
20
  delegationPromptOf,
64
21
  } from '../../lib/hook-io.mjs';
22
+ import {
23
+ readSessionState,
24
+ writeSessionState,
25
+ } from '../../lib/session-state.mjs';
65
26
 
66
27
  const GATE_ID = 'circuit-breaker';
67
28
  const CONFIG_KEY = 'requireCircuitBreakerOnDelegation';
68
29
 
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
30
  const DEFAULT_RETRY_THRESHOLD = 2;
31
+ const MIN_RETRY_THRESHOLD = 2;
72
32
  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).
33
+ const MAX_OVERRIDE_SENTENCE_WORDS = 8;
34
+ const MIN_TOKEN_LENGTH = 2;
35
+
92
36
  const OVERRIDE_BOUNDARY_BEFORE = String.raw`(?<![\p{L}\p{N}_])`;
93
37
  const OVERRIDE_BOUNDARY_AFTER = String.raw`(?![\p{L}\p{N}_])`;
38
+ // One short regex per imperative: a single big alternation trips the regex-complexity lint.
94
39
  const OVERRIDE_ALTERNATIVES = [
95
40
  String.raw`reintent[aá]lo`,
96
41
  String.raw`reintenta(lo)?`,
@@ -108,15 +53,19 @@ const OVERRIDE_PATTERNS = OVERRIDE_ALTERNATIVES.map(
108
53
  ),
109
54
  );
110
55
 
56
+ function wordCount(text) {
57
+ return text.split(/\s+/).filter(Boolean).length;
58
+ }
59
+
111
60
  function isOverrideImperative(text) {
112
- return OVERRIDE_PATTERNS.some((pattern) => pattern.test(text));
61
+ return String(text)
62
+ .split(/[.!?;\n]+/)
63
+ .filter((sentence) => wordCount(sentence) <= MAX_OVERRIDE_SENTENCE_WORDS)
64
+ .some((sentence) =>
65
+ OVERRIDE_PATTERNS.some((pattern) => pattern.test(sentence)),
66
+ );
113
67
  }
114
68
 
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
69
  const SECTION_HEADING_NAMES = [
121
70
  'scope',
122
71
  'steps',
@@ -135,45 +84,28 @@ const SECTION_HEADING_NAMES = [
135
84
  ];
136
85
 
137
86
  function headingNamePattern(name) {
138
- const escaped = name
139
- .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
140
- .replace(/ /g, '\\s+');
141
- return new RegExp(escaped, 'i');
87
+ return new RegExp(escapeRegExp(name).replace(/ /g, '\\s+'), 'i');
142
88
  }
143
89
 
144
90
  const HEADING_NAME_PATTERNS = SECTION_HEADING_NAMES.map(headingNamePattern);
145
91
 
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
92
  function indexOfSectionMarker(text) {
149
93
  let earliest = -1;
150
94
  for (const namePattern of HEADING_NAME_PATTERNS) {
151
95
  const match = namePattern.exec(text);
152
96
  if (!match) continue;
153
97
  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
98
+ if (!/^\s*:/.test(after)) continue;
155
99
  if (earliest === -1 || match.index < earliest) earliest = match.index;
156
100
  }
157
101
  return earliest;
158
102
  }
159
103
 
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
- );
104
+ const LEVEL_LINE_PATTERNS = [
105
+ new RegExp('^level\\s*:?$', 'i'),
106
+ new RegExp('^nivel\\s*:?$', 'i'),
107
+ ];
172
108
 
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
109
  function stripLeadingMarkup(line) {
178
110
  return line
179
111
  .trim()
@@ -182,11 +114,10 @@ function stripLeadingMarkup(line) {
182
114
  .replace(/^\*\*/, '');
183
115
  }
184
116
 
185
- /** A line that is pure template scaffolding: discarded on the fallback path. */
186
117
  function isScaffoldingLine(line) {
187
118
  const stripped = stripLeadingMarkup(line);
188
- if (/^level\s*:?$/i.test(stripped)) return true;
189
- if (SPANISH_LEVEL_LINE_PATTERN.test(stripped)) return true;
119
+ if (LEVEL_LINE_PATTERNS.some((pattern) => pattern.test(stripped)))
120
+ return true;
190
121
  return HEADING_NAME_PATTERNS.some((namePattern) => {
191
122
  const match = namePattern.exec(stripped);
192
123
  return (
@@ -197,15 +128,9 @@ function isScaffoldingLine(line) {
197
128
  });
198
129
  }
199
130
 
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
131
  const GOAL_HEADING_PATTERN = /^(objetivo|goal|meta):(.*)$/i;
205
132
  const IN_SCOPE_HEADING_PATTERN = /^(que\s+s[ií]|in\s+scope|lo\s+pedido)\b/i;
206
133
 
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
134
  function isEndOfInScope(line) {
210
135
  const stripped = stripLeadingMarkup(line);
211
136
  return HEADING_NAME_PATTERNS.some((namePattern) => {
@@ -214,9 +139,6 @@ function isEndOfInScope(line) {
214
139
  });
215
140
  }
216
141
 
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
142
  const WINDOWS_DRIVE_PATH_PATTERN = /[A-Za-z]:[\\/][^\s"'`,;)\]]+/g;
221
143
  const KNOWN_EXTENSIONS = [
222
144
  'mjs',
@@ -244,10 +166,8 @@ const KNOWN_EXTENSION_FILENAME_PATTERN = new RegExp(
244
166
  `[\\w-]+\\.(?:${KNOWN_EXTENSIONS.join('|')})\\b`,
245
167
  'g',
246
168
  );
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.
169
+ // A flat character class validated in JS afterwards: no nested repeated groups for a
170
+ // backtracking engine to explore.
251
171
  const PATH_LIKE_RUN_PATTERN = /[\w./\\-]+/g;
252
172
 
253
173
  function looksLikeRelativePath(run) {
@@ -267,25 +187,20 @@ const STOP_WORDS = new Set(
267
187
  ).split(' '),
268
188
  );
269
189
 
270
- function statePathFor(sessionId) {
271
- return join(STATE_ROOT, sessionId, 'state.json');
272
- }
273
-
274
190
  function stripDiacritics(text) {
275
- return text.normalize('NFD').replace(/[̀-ͯ]/g, '');
191
+ return text.normalize('NFD').replace(/\p{M}/gu, '');
276
192
  }
277
193
 
278
194
  function tokenize(text) {
279
195
  return stripDiacritics(String(text).toLowerCase())
280
196
  .split(/[^a-z0-9]+/)
281
- .filter((token) => token.length > 1 && !STOP_WORDS.has(token));
197
+ .filter(
198
+ (token) => token.length >= MIN_TOKEN_LENGTH && !STOP_WORDS.has(token),
199
+ );
282
200
  }
283
201
 
284
202
  const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', ')', ']']);
285
203
 
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
204
  function stripTrailingPunctuation(text) {
290
205
  let end = text.length;
291
206
  while (end > 0 && TRAILING_PUNCTUATION.has(text[end - 1])) end -= 1;
@@ -313,8 +228,11 @@ function mentionedPaths(prompt) {
313
228
  return [...found].sort();
314
229
  }
315
230
 
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). */
231
+ function contentAfterHeading(line) {
232
+ const colon = line.indexOf(':');
233
+ return colon === -1 ? '' : line.slice(colon + 1).trim();
234
+ }
235
+
318
236
  function identityText(prompt) {
319
237
  const lines = String(prompt).split(/\r?\n/);
320
238
  const parts = [];
@@ -332,6 +250,7 @@ function identityText(prompt) {
332
250
  }
333
251
 
334
252
  if (IN_SCOPE_HEADING_PATTERN.test(strippedLine)) {
253
+ parts.push(contentAfterHeading(strippedLine));
335
254
  for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
336
255
  if (isEndOfInScope(lines[cursor])) break;
337
256
  parts.push(lines[cursor]);
@@ -342,7 +261,6 @@ function identityText(prompt) {
342
261
  return parts.join(' ').trim();
343
262
  }
344
263
 
345
- /** Fallback path: whole prompt minus lines that are pure scaffolding. */
346
264
  function textWithoutScaffolding(prompt) {
347
265
  return String(prompt)
348
266
  .split(/\r?\n/)
@@ -351,8 +269,6 @@ function textWithoutScaffolding(prompt) {
351
269
  .trim();
352
270
  }
353
271
 
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
272
  function identitySignature(prompt) {
357
273
  const identity =
358
274
  identityText(prompt) || textWithoutScaffolding(prompt) || String(prompt);
@@ -367,6 +283,7 @@ function identitySignature(prompt) {
367
283
  features.push(...tokens);
368
284
  }
369
285
 
286
+ // Paths are duplicated so they weigh more than prose: they discriminate tasks best.
370
287
  for (const path of mentionedPaths(prompt)) {
371
288
  features.push(`path:${path}`, `path:${path}`);
372
289
  }
@@ -374,7 +291,6 @@ function identitySignature(prompt) {
374
291
  return features;
375
292
  }
376
293
 
377
- /** Dice coefficient over a multiset of features. */
378
294
  function similarity(featuresA, featuresB) {
379
295
  if (!Array.isArray(featuresA) || !Array.isArray(featuresB)) return 0;
380
296
  if (featuresA.length === 0 || featuresB.length === 0) return 0;
@@ -395,57 +311,41 @@ function similarity(featuresA, featuresB) {
395
311
  return (2 * intersection) / (featuresA.length + featuresB.length);
396
312
  }
397
313
 
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
314
  function occurrencesFor(state, key) {
416
315
  const value = state[key];
417
316
  if (!Array.isArray(value)) return [];
418
317
  return value.filter((entry) => entry && Array.isArray(entry.signature));
419
318
  }
420
319
 
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.
320
+ function similarOccurrenceCount(state, signature, similarityThreshold) {
321
+ let count = 0;
322
+ for (const key of Object.keys(state)) {
323
+ for (const entry of occurrencesFor(state, key)) {
324
+ if (similarity(entry.signature, signature) >= similarityThreshold)
325
+ count += 1;
326
+ }
427
327
  }
328
+ return count;
428
329
  }
429
330
 
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
331
  function identityKey(signature) {
446
332
  return createHash('sha256').update(signature.join(' ')).digest('hex');
447
333
  }
448
334
 
335
+ function withoutRelatedKeys(state, key, signature, similarityThreshold) {
336
+ const next = { ...state };
337
+ for (const existingKey of Object.keys(next)) {
338
+ const isRelated =
339
+ existingKey === key ||
340
+ occurrencesFor(next, existingKey).some(
341
+ (entry) =>
342
+ similarity(entry.signature, signature) >= similarityThreshold,
343
+ );
344
+ if (isRelated) delete next[existingKey];
345
+ }
346
+ return next;
347
+ }
348
+
449
349
  function denyRepeatedAttempt(count) {
450
350
  deny(
451
351
  CONFIG_KEY,
@@ -468,60 +368,39 @@ runGate(
468
368
  similarityThreshold: DEFAULT_SIMILARITY_THRESHOLD,
469
369
  },
470
370
  },
471
- ({ toolName, toolInput, sessionId, parameters }) => {
371
+ ({ toolName, toolInput, sessionId, parameters, cwd }) => {
472
372
  if (!toolInGroups(toolName, ['delegation'])) return;
473
373
 
474
374
  const prompt = delegationPromptOf(toolInput);
475
375
  if (!prompt.trim()) return;
476
376
 
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
-
377
+ const stateOptions = { cwd };
378
+ const state = readSessionState(GATE_ID, sessionId, {}, stateOptions);
484
379
  const signature = identitySignature(prompt);
485
380
  const key = identityKey(signature);
381
+ const { similarityThreshold } = parameters;
486
382
 
487
383
  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);
384
+ writeSessionState(
385
+ GATE_ID,
386
+ sessionId,
387
+ withoutRelatedKeys(state, key, signature, similarityThreshold),
388
+ stateOptions,
389
+ );
507
390
  return;
508
391
  }
509
392
 
510
- const occurrences = occurrencesFor(state, key);
511
- const count = countSimilarOccurrences(
512
- occurrences,
513
- signature,
514
- parameters.similarityThreshold,
515
- );
393
+ // Count includes the current attempt (+1) against stored occurrences.
394
+ // Recording happens in the PostToolUse tracker (track.mjs), not here: a
395
+ // delegation rejected by ANOTHER gate (running in parallel) must not inflate
396
+ // the counter — only delegations that actually launched count as attempts.
397
+ const count =
398
+ 1 + similarOccurrenceCount(state, signature, similarityThreshold);
516
399
 
517
- const updated = [...occurrences, { signature, seenAt: Date.now() }].slice(
518
- -MAX_ENTRIES_PER_KEY,
400
+ const retryThreshold = Math.max(
401
+ MIN_RETRY_THRESHOLD,
402
+ parameters.retryThreshold,
519
403
  );
520
- state[key] = updated;
521
- writeState(statePath, state);
522
-
523
- if (count >= parameters.retryThreshold) {
524
- denyRepeatedAttempt(count);
525
- }
404
+ if (count >= retryThreshold) denyRepeatedAttempt(count);
526
405
  },
527
406
  );