@devrik-tools/claude-gates 0.8.0 → 1.0.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 (34) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/README.es.md +102 -9
  3. package/README.md +93 -9
  4. package/cli/artifacts.mjs +213 -0
  5. package/cli/constants.mjs +14 -0
  6. package/cli/doctor.mjs +2 -1
  7. package/cli/index.mjs +54 -2
  8. package/cli/init.mjs +95 -9
  9. package/cli/install.mjs +53 -1
  10. package/cli/registry.mjs +2 -0
  11. package/cli/selection.mjs +23 -1
  12. package/cli/smoke-fixtures.json +53 -3
  13. package/cli/task.mjs +69 -4
  14. package/package.json +5 -4
  15. package/plugins/gates/.claude-plugin/plugin.json +1 -1
  16. package/plugins/gates/hooks/gates/capability-map/index.mjs +37 -208
  17. package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +4 -11
  18. package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
  19. package/plugins/gates/hooks/gates/force-parallel/index.mjs +11 -12
  20. package/plugins/gates/hooks/gates/library-docs/index.mjs +107 -31
  21. package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
  22. package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
  23. package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
  24. package/plugins/gates/hooks/gates/skill-first/index.mjs +138 -0
  25. package/plugins/gates/hooks/gates/skill-first/track.mjs +66 -0
  26. package/plugins/gates/hooks/hooks.json +61 -1
  27. package/plugins/gates/hooks/lib/capabilities.mjs +401 -0
  28. package/plugins/gates/hooks/lib/hook-io.mjs +9 -2
  29. package/plugins/gates/hooks/lib/signals.mjs +91 -0
  30. package/plugins/gates/hooks/lib/testing.mjs +15 -4
  31. package/plugins/tasks/.claude-plugin/plugin.json +1 -1
  32. package/plugins/tasks/hooks/lib/task-store.mjs +6 -0
  33. package/plugins/tasks/hooks/register-requests.mjs +37 -10
  34. package/registry.json +106 -5
@@ -136,8 +136,8 @@
136
136
  {
137
137
  "id": "force-parallel",
138
138
  "configKey": "warnSequentialDelegations",
139
- "enabledByDefault": false,
140
- "type": "warn",
139
+ "enabledByDefault": true,
140
+ "type": "deny",
141
141
  "payload": {
142
142
  "tool_name": "Task",
143
143
  "tool_input": {
@@ -145,7 +145,7 @@
145
145
  }
146
146
  },
147
147
  "needsState": true,
148
- "note": "stateful: warns only on the 3rd consecutive delegation under one session."
148
+ "note": "stateful: denies only on the 3rd consecutive delegation under one session."
149
149
  },
150
150
  {
151
151
  "id": "feature-catalog",
@@ -413,6 +413,14 @@
413
413
  "needsState": true,
414
414
  "note": "side-effect only: records to .ai/tool-map.json, never denies/warns."
415
415
  },
416
+ {
417
+ "id": "skill-first",
418
+ "configKey": "requireSkillCheckBeforeActing",
419
+ "enabledByDefault": false,
420
+ "type": "deny",
421
+ "payload": null,
422
+ "needsState": true
423
+ },
416
424
  {
417
425
  "id": "forge-flow",
418
426
  "configKey": "requireForgeRunToEdit",
@@ -542,6 +550,48 @@
542
550
  }
543
551
  },
544
552
  "needsState": false
553
+ },
554
+ {
555
+ "id": "require-task-split",
556
+ "configKey": "requireTaskSplitBeforeImplementing",
557
+ "enabledByDefault": true,
558
+ "type": "deny",
559
+ "payload": {
560
+ "tool_name": "Write",
561
+ "tool_input": {
562
+ "file_path": "src/x.js",
563
+ "content": "x"
564
+ }
565
+ },
566
+ "needsState": true,
567
+ "note": "needs an .ai/tasks/active.json with a medium+ task that has no children."
568
+ },
569
+ {
570
+ "id": "require-monitor",
571
+ "configKey": "requireMonitorForBackground",
572
+ "enabledByDefault": true,
573
+ "type": "deny",
574
+ "payload": {
575
+ "tool_name": "Bash",
576
+ "tool_input": {
577
+ "command": "npm run build",
578
+ "run_in_background": true
579
+ }
580
+ },
581
+ "needsState": false
582
+ },
583
+ {
584
+ "id": "no-trivial-scripts",
585
+ "configKey": "blockTrivialInlineScripts",
586
+ "enabledByDefault": true,
587
+ "type": "deny",
588
+ "payload": {
589
+ "tool_name": "Bash",
590
+ "tool_input": {
591
+ "command": "sed -i 's/old/new/' src/a.mjs"
592
+ }
593
+ },
594
+ "needsState": false
545
595
  }
546
596
  ]
547
597
  }
package/cli/task.mjs CHANGED
@@ -16,6 +16,12 @@ import { verifyCommand, verifyPath } from './evidence.mjs';
16
16
 
17
17
  const DEFAULT_SIZE = 'unspecified';
18
18
 
19
+ const VERIFY_USAGE =
20
+ 'task add requires a deterministic verification criterion: ' +
21
+ '--verify-command "<command>" [--verify-expect <text>] (command that must exit 0 when done) ' +
22
+ 'or --verify-path <path> [--verify-contains <text>] (file/dir that must exist when done). ' +
23
+ 'This defines HOW the task will be verified as complete — free text is not enough.';
24
+
19
25
  function fail(message) {
20
26
  process.stderr.write(`${message}\n`);
21
27
  process.exit(EXIT_CODE.FAILURE);
@@ -31,18 +37,41 @@ function openStoreOrFail(cwd) {
31
37
  return store;
32
38
  }
33
39
 
40
+ function buildVerifyCriteria(options) {
41
+ if (options.verifyCommand) {
42
+ return {
43
+ kind: 'command',
44
+ command: options.verifyCommand,
45
+ expect: options.verifyExpect ?? null,
46
+ };
47
+ }
48
+ if (options.verifyPath) {
49
+ return {
50
+ kind: 'path',
51
+ path: options.verifyPath,
52
+ contains: options.verifyContains ?? null,
53
+ };
54
+ }
55
+ return null;
56
+ }
57
+
34
58
  function taskAdd(title, options, { cwd = process.cwd() } = {}) {
35
59
  if (!title || !title.trim()) fail('task add requires a non-empty title.');
60
+ const verify = buildVerifyCriteria(options);
61
+ if (!verify) fail(VERIFY_USAGE);
36
62
  const store = openStoreOrFail(cwd);
37
- const task = store.add({
63
+ const task = {
38
64
  id: options.id || randomUUID(),
39
65
  title: title.trim(),
40
66
  description: options.description ?? '',
41
67
  status: STATUS.OPEN,
42
68
  size: options.size ?? DEFAULT_SIZE,
69
+ verify,
43
70
  createdAt: new Date().toISOString(),
44
71
  messages: [],
45
- });
72
+ };
73
+ if (options.parent) task.parentId = options.parent;
74
+ store.add(task);
46
75
  process.stdout.write(`${JSON.stringify(task, null, 2)}\n`);
47
76
  }
48
77
 
@@ -80,9 +109,26 @@ function collectEvidence(options, cwd) {
80
109
  return null;
81
110
  }
82
111
 
112
+ function autoVerifyFromTask(task, cwd) {
113
+ if (!task?.verify) return null;
114
+ const { kind, command, expect, path, contains } = task.verify;
115
+ if (kind === 'command' && command) {
116
+ return verifyCommand(command, { cwd, expect: expect ?? undefined });
117
+ }
118
+ if (kind === 'path' && path) {
119
+ return verifyPath(path, { cwd, contains: contains ?? undefined });
120
+ }
121
+ return null;
122
+ }
123
+
83
124
  function taskClose(id, options, { cwd = process.cwd() } = {}) {
84
125
  const store = openStoreOrFail(cwd);
85
- const evidence = collectEvidence(options, store.root);
126
+ let evidence = collectEvidence(options, store.root);
127
+ if (!evidence && options.evidence !== undefined) fail(EVIDENCE_USAGE);
128
+ if (!evidence) {
129
+ const task = store.active().find((entry) => entry.id === id);
130
+ evidence = autoVerifyFromTask(task, store.root);
131
+ }
86
132
  if (!evidence) fail(EVIDENCE_USAGE);
87
133
  if (!evidence.verified) {
88
134
  const output = evidence.outputTail ? `\n${evidence.outputTail}` : '';
@@ -121,10 +167,29 @@ export function registerTaskCommand(program) {
121
167
 
122
168
  task
123
169
  .command('add <title>')
124
- .description('Register a new open task.')
170
+ .description(
171
+ 'Register a new open task (requires a verification criterion).',
172
+ )
125
173
  .option('--id <id>', 'explicit task id (default: a generated uuid)')
126
174
  .option('--description <text>', 'longer description of the task')
127
175
  .option('--size <size>', 'rough size estimate (e.g. trivial, small, large)')
176
+ .option(
177
+ '--verify-command <command>',
178
+ 'shell command that must exit 0 when the task is done',
179
+ )
180
+ .option(
181
+ '--verify-expect <text>',
182
+ 'text the --verify-command output must contain',
183
+ )
184
+ .option(
185
+ '--verify-path <path>',
186
+ 'file or directory that must exist when the task is done',
187
+ )
188
+ .option(
189
+ '--verify-contains <text>',
190
+ 'text the --verify-path file must contain',
191
+ )
192
+ .option('--parent <id>', 'parent task id (makes this a sub-task)')
128
193
  .action((title, options) => taskAdd(title, options));
129
194
 
130
195
  task
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@devrik-tools/claude-gates",
3
- "version": "0.8.0",
4
- "description": "Installable, deterministic gates (hooks) for Claude Code: block destructive commands, protected paths, and enforce delegation/spec/quality rules. Configurable per project.",
3
+ "version": "1.0.0",
4
+ "description": "49 installable, deterministic gates (hooks) for Claude Code: block destructive commands and protected paths, enforce delegation/spec/quality/research rules, and track tasks that only close with verified evidence. Configurable per project.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "hooks",
@@ -9,7 +9,8 @@
9
9
  "guardrails",
10
10
  "pretooluse",
11
11
  "policy-enforcement",
12
- "ai-agent"
12
+ "ai-agent",
13
+ "task-tracking"
13
14
  ],
14
15
  "type": "module",
15
16
  "bin": {
@@ -74,4 +75,4 @@
74
75
  "globals": "^17.11.0",
75
76
  "prettier": "^3.9.6"
76
77
  }
77
- }
78
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gates",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Deterministic gates for Claude Code: destructive-command blocks, protected paths, delegation briefs, spec-driven flow and session-start validation. Selection lives in config, not in code.",
5
5
  "author": {
6
6
  "name": "Devrik"
@@ -9,17 +9,21 @@
9
9
  // ~/.claude/blurb-overrides.json and <project>/<blurbOverridesFile> (project wins per key).
10
10
  // `.agents/skills` and `.ai/skills` (home and project) are scanned as skill-only roots:
11
11
  // other installers write there. Never blocks; any failure injects nothing.
12
+ //
13
+ // Discovery itself lives in lib/capabilities.mjs, shared with skill-first (the PreToolUse
14
+ // half that judges whether an action has a skill covering it): one catalog definition, so
15
+ // what the model is TOLD it has and what a gate CHECKS it has can never disagree.
12
16
 
13
17
  import { createHash } from 'node:crypto';
14
- import {
15
- mkdirSync,
16
- readFileSync,
17
- readdirSync,
18
- statSync,
19
- writeFileSync,
20
- } from 'node:fs';
18
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
19
  import { homedir } from 'node:os';
22
- import { basename, dirname, extname, isAbsolute, join } from 'node:path';
20
+ import { dirname, join } from 'node:path';
21
+ import {
22
+ buildRawCatalog,
23
+ firstClause,
24
+ mtimeMsOf,
25
+ truncateAtWordBoundary,
26
+ } from '../../lib/capabilities.mjs';
23
27
  import {
24
28
  loadGateConfig,
25
29
  projectRootOf,
@@ -30,6 +34,7 @@ import {
30
34
  readSessionState,
31
35
  writeSessionState,
32
36
  } from '../../lib/session-state.mjs';
37
+ import { workNatureOf } from '../../lib/signals.mjs';
33
38
 
34
39
  const STDIN_FILE_DESCRIPTOR = 0;
35
40
  const GATE_ID = 'capability-map';
@@ -48,13 +53,9 @@ const DEFAULT_PARAMS = Object.freeze({
48
53
  mapFile: join('.ai', 'capability-map.json'),
49
54
  blurbOverridesFile: join('.ai', 'blurb-overrides.json'),
50
55
  injectEveryMessages: 10,
56
+ reinjectOnWorkNatureChange: true,
51
57
  });
52
58
 
53
- const KIND_EXTENSIONS = {
54
- agents: ['.md'],
55
- commands: ['.md', '.toml'],
56
- };
57
-
58
59
  function readPayload() {
59
60
  try {
60
61
  return JSON.parse(readFileSync(STDIN_FILE_DESCRIPTOR, 'utf8'));
@@ -63,193 +64,6 @@ function readPayload() {
63
64
  }
64
65
  }
65
66
 
66
- function isDirectory(path) {
67
- try {
68
- return statSync(path).isDirectory();
69
- } catch {
70
- return false;
71
- }
72
- }
73
-
74
- function mtimeMsOf(path) {
75
- try {
76
- return statSync(path).mtimeMs;
77
- } catch {
78
- return null;
79
- }
80
- }
81
-
82
- // ── Front matter ────────────────────────────────────────────────────────────────────
83
- // A bare block-scalar indicator (`>`, `>-`, `|`, `|-`) means the value is on the following
84
- // indented lines; without this the blurb rendered as ">".
85
- const BLOCK_SCALAR_INDICATOR_PATTERN = /^[|>][+-]?\d*$/;
86
-
87
- function readBlockScalarValue(lines, startIndex) {
88
- const parts = [];
89
- for (let index = startIndex; index < lines.length; index += 1) {
90
- const line = lines[index];
91
- if (line.trim() === '---') break;
92
- if (!/^[ \t]+\S/.test(line)) break;
93
- parts.push(line.trim());
94
- }
95
- return parts.join(' ');
96
- }
97
-
98
- // Parsed line by line (no multi-line regex) so a large body can never backtrack.
99
- function parseFrontMatter(fileText) {
100
- const lines = fileText.split(/\r?\n/);
101
- if (lines[0]?.trim() !== '---') return { name: '', description: '' };
102
- let name = '';
103
- let description = '';
104
- for (let index = 1; index < lines.length; index += 1) {
105
- const line = lines[index];
106
- if (line.trim() === '---') break;
107
- const separator = line.indexOf(':');
108
- if (separator < 0) continue;
109
- const key = line.slice(0, separator).trim();
110
- let value = line
111
- .slice(separator + 1)
112
- .trim()
113
- .replace(/^["']|["']$/g, '');
114
- if (BLOCK_SCALAR_INDICATOR_PATTERN.test(value)) {
115
- value = readBlockScalarValue(lines, index + 1);
116
- }
117
- if (key === 'name') name = value;
118
- else if (key === 'description') description = value;
119
- }
120
- return { name, description };
121
- }
122
-
123
- function truncateAtWordBoundary(text, maxChars) {
124
- if (text.length <= maxChars) return text;
125
- const budget = text.slice(0, maxChars - 1);
126
- const lastSpace = budget.lastIndexOf(' ');
127
- const cut = lastSpace > 0 ? budget.slice(0, lastSpace) : budget;
128
- return `${cut.trimEnd()}…`;
129
- }
130
-
131
- function firstClause(description, maxClauseChars) {
132
- if (!description) return '';
133
- const sentenceEnd = description.indexOf('. ');
134
- const clause =
135
- sentenceEnd > 0 ? description.slice(0, sentenceEnd) : description;
136
- return truncateAtWordBoundary(clause, maxClauseChars);
137
- }
138
-
139
- // ── Discovery ───────────────────────────────────────────────────────────────────────
140
- function filesUnder(directory, extensions) {
141
- let names;
142
- try {
143
- names = readdirSync(directory);
144
- } catch {
145
- return [];
146
- }
147
- const files = [];
148
- for (const name of names) {
149
- const full = join(directory, name);
150
- if (isDirectory(full)) files.push(...filesUnder(full, extensions));
151
- else if (extensions.includes(extname(name).toLowerCase())) files.push(full);
152
- }
153
- return files;
154
- }
155
-
156
- function entryFor(file, fallbackName) {
157
- const mtimeMs = mtimeMsOf(file);
158
- if (mtimeMs === null) return null;
159
- let content;
160
- try {
161
- content = readFileSync(file, 'utf8');
162
- } catch {
163
- return null;
164
- }
165
- const { name, description } = parseFrontMatter(content);
166
- return {
167
- name: name || fallbackName,
168
- description,
169
- stamp: `${file}:${mtimeMs}`,
170
- };
171
- }
172
-
173
- function skillEntriesUnder(skillsRoot) {
174
- let names;
175
- try {
176
- names = readdirSync(skillsRoot);
177
- } catch {
178
- return [];
179
- }
180
- return names
181
- .filter((name) => isDirectory(join(skillsRoot, name)))
182
- .map((name) => entryFor(join(skillsRoot, name, 'SKILL.md'), name))
183
- .filter(Boolean);
184
- }
185
-
186
- function fileEntriesUnder(directory, extensions) {
187
- return filesUnder(directory, extensions)
188
- .map((file) => entryFor(file, basename(file, extname(file))))
189
- .filter(Boolean);
190
- }
191
-
192
- function resolveExtra(root, directory) {
193
- return isAbsolute(directory) ? directory : join(root, directory);
194
- }
195
-
196
- function skillRootsFor(root, extraDirectories) {
197
- return [
198
- join(root, '.claude', 'skills'),
199
- join(root, '.agents', 'skills'),
200
- join(root, '.ai', 'skills'),
201
- join(homedir(), '.claude', 'skills'),
202
- join(homedir(), '.agents', 'skills'),
203
- join(homedir(), '.ai', 'skills'),
204
- ...extraDirectories.map((directory) => resolveExtra(root, directory)),
205
- ];
206
- }
207
-
208
- function fileRootsFor(root, kind, extraDirectories) {
209
- return [
210
- join(root, '.claude', kind),
211
- join(homedir(), '.claude', kind),
212
- ...extraDirectories.map((directory) => resolveExtra(root, directory)),
213
- ];
214
- }
215
-
216
- function collectKind(kind, root, settings) {
217
- if (kind === 'skills') {
218
- return skillRootsFor(root, settings.extraSkillsDirs).flatMap(
219
- skillEntriesUnder,
220
- );
221
- }
222
- const extensions = KIND_EXTENSIONS[kind];
223
- if (!extensions) return [];
224
- const extra =
225
- kind === 'agents' ? settings.extraAgentsDirs : settings.extraCommandsDirs;
226
- return fileRootsFor(root, kind, extra).flatMap((directory) =>
227
- fileEntriesUnder(directory, extensions),
228
- );
229
- }
230
-
231
- // First occurrence wins, and project roots come first: a project capability shadows a
232
- // global one of the same name.
233
- function entriesForKind(kind, root, settings) {
234
- const seen = new Set();
235
- const unique = [];
236
- for (const entry of collectKind(kind, root, settings)) {
237
- if (seen.has(entry.name)) continue;
238
- seen.add(entry.name);
239
- unique.push(entry);
240
- }
241
- return unique.sort((a, b) => a.name.localeCompare(b.name));
242
- }
243
-
244
- function buildRawCatalog(root, settings) {
245
- const catalog = {};
246
- for (const kind of settings.kinds) {
247
- const entries = entriesForKind(kind, root, settings);
248
- if (entries.length > 0) catalog[kind] = entries;
249
- }
250
- return catalog;
251
- }
252
-
253
67
  // ── Blurbs, overrides and the fingerprint ───────────────────────────────────────────
254
68
  function blurbOverridePathsFor(root, blurbOverridesFile) {
255
69
  return [
@@ -355,12 +169,20 @@ function renderCatalog(catalog, kinds) {
355
169
  return `[capabilities] available (check before improvising something one of these covers):\n${sections.join('\n')}\n`;
356
170
  }
357
171
 
358
- // A changed catalog (or the first message of a session) injects immediately; otherwise
359
- // every Nth message.
360
- function injectionDecision(session, fingerprint, injectEveryMessages) {
172
+ // Three reasons to inject, then the throttle. The catalog changing on disk was the only
173
+ // content-driven trigger the gate had, which left the case the reminder is actually for:
174
+ // the session PIVOTING to a different kind of work (debugging → designing → releasing)
175
+ // with a catalog that never moved, so the model kept whatever the throttle last emitted
176
+ // and the skills that matter for the new nature were never re-surfaced. The nature is a
177
+ // coarse lexical read of the prompt (lib/signals.mjs), and being wrong costs one extra
178
+ // injection of a never-blocking catalog — cheap enough to prefer over staying silent.
179
+ function injectionDecision(session, fingerprint, nature, settings) {
361
180
  const changed = session.fingerprint !== fingerprint;
181
+ const pivoted =
182
+ settings.reinjectOnWorkNatureChange && session.workNature !== nature;
362
183
  const nextCount = (Number(session.messageCount) || 0) + 1;
363
- const shouldInject = changed || nextCount >= injectEveryMessages;
184
+ const shouldInject =
185
+ changed || pivoted || nextCount >= settings.injectEveryMessages;
364
186
  return { shouldInject, messageCount: shouldInject ? 0 : nextCount };
365
187
  }
366
188
 
@@ -417,22 +239,28 @@ function syncMap(mapPath, rawCatalog, fingerprint, root, settings) {
417
239
  return catalog;
418
240
  }
419
241
 
420
- function shouldInjectNow(sessionId, root, fingerprint, injectEveryMessages) {
242
+ function shouldInjectNow(sessionId, root, fingerprint, nature, settings) {
421
243
  const session = readSessionState(GATE_ID, sessionId, {}, { cwd: root });
422
244
  const { shouldInject, messageCount } = injectionDecision(
423
245
  session,
424
246
  fingerprint,
425
- injectEveryMessages,
247
+ nature,
248
+ settings,
426
249
  );
427
250
  writeSessionState(
428
251
  GATE_ID,
429
252
  sessionId,
430
- { fingerprint, messageCount },
253
+ { fingerprint, messageCount, workNature: nature },
431
254
  { cwd: root },
432
255
  );
433
256
  return shouldInject;
434
257
  }
435
258
 
259
+ function promptOf(payload) {
260
+ const prompt = payload?.prompt ?? payload?.user_prompt ?? payload?.message;
261
+ return typeof prompt === 'string' ? prompt : '';
262
+ }
263
+
436
264
  function run() {
437
265
  const payload = readPayload();
438
266
  const cwd = cwdOf(payload);
@@ -455,7 +283,8 @@ function run() {
455
283
  payload.session_id ?? null,
456
284
  root,
457
285
  fingerprint,
458
- settings.injectEveryMessages,
286
+ workNatureOf(promptOf(payload)),
287
+ settings,
459
288
  );
460
289
  if (inject) process.stdout.write(renderCatalog(catalog, settings.kinds));
461
290
  }
@@ -30,7 +30,6 @@ const CONFIG_KEY = 'requireCircuitBreakerOnDelegation';
30
30
  const DEFAULT_RETRY_THRESHOLD = 2;
31
31
  const MIN_RETRY_THRESHOLD = 2;
32
32
  const DEFAULT_SIMILARITY_THRESHOLD = 0.6;
33
- const MAX_ENTRIES_PER_KEY = 12;
34
33
  const MAX_OVERRIDE_SENTENCE_WORDS = 8;
35
34
  const MIN_TOKEN_LENGTH = 2;
36
35
 
@@ -391,18 +390,12 @@ runGate(
391
390
  return;
392
391
  }
393
392
 
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.
394
397
  const count =
395
398
  1 + similarOccurrenceCount(state, signature, similarityThreshold);
396
- const occurrences = [
397
- ...occurrencesFor(state, key),
398
- { signature, seenAt: Date.now() },
399
- ].slice(-MAX_ENTRIES_PER_KEY);
400
- writeSessionState(
401
- GATE_ID,
402
- sessionId,
403
- { ...state, [key]: occurrences },
404
- stateOptions,
405
- );
406
399
 
407
400
  const retryThreshold = Math.max(
408
401
  MIN_RETRY_THRESHOLD,