@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
@@ -0,0 +1,126 @@
1
+ // require-monitor — enforces that every background command has an associated Monitor.
2
+ // Two-phase enforcement:
3
+ // 1. PreToolUse on shell: a command with run_in_background: true MUST carry the marker
4
+ // MONITOR-PLANNED: <reason>. Without it, the background is denied.
5
+ // 2. PreToolUse on execution+monitor: if the session has pending unmonitored backgrounds
6
+ // and the current tool is NOT Monitor, deny — forcing the agent to create a Monitor
7
+ // before doing anything else. When the current tool IS Monitor, the pending background
8
+ // is cleared.
9
+ //
10
+ // The marker is deterministic: no model judgment required. The pending-check is deterministic
11
+ // too: state is on disk, the check is a read + compare, and the deny is unconditional.
12
+
13
+ import {
14
+ deny,
15
+ runGate,
16
+ shellCommandOf,
17
+ toolInGroups,
18
+ } from '../../lib/hook-io.mjs';
19
+ import {
20
+ readSessionState,
21
+ writeSessionState,
22
+ } from '../../lib/session-state.mjs';
23
+
24
+ const GATE_ID = 'require-monitor';
25
+ const CONFIG_KEY = 'requireMonitorForBackground';
26
+
27
+ const DEFAULT_MARKER = 'MONITOR-PLANNED:';
28
+ const MARKER_REASON_PATTERN = /\S\s\S/;
29
+ const MAX_PENDING = 20;
30
+ const MAX_COMMAND_PREVIEW_LENGTH = 200;
31
+
32
+ function hasMarker(command, marker) {
33
+ if (!marker) return false;
34
+ const markerIndex = command.indexOf(marker);
35
+ if (markerIndex === -1) return false;
36
+ const afterMarker = command.slice(markerIndex + marker.length);
37
+ return MARKER_REASON_PATTERN.test(afterMarker);
38
+ }
39
+
40
+ function pendingBackgrounds(state) {
41
+ return Array.isArray(state.pending) ? state.pending : [];
42
+ }
43
+
44
+ function addPending(state, entry) {
45
+ const pending = [...pendingBackgrounds(state), entry].slice(-MAX_PENDING);
46
+ return { ...state, pending };
47
+ }
48
+
49
+ function clearOldestPending(state) {
50
+ const pending = pendingBackgrounds(state);
51
+ if (pending.length === 0) return state;
52
+ return { ...state, pending: pending.slice(1) };
53
+ }
54
+
55
+ runGate(
56
+ {
57
+ id: GATE_ID,
58
+ configKey: CONFIG_KEY,
59
+ enabledByDefault: true,
60
+ defaultParams: {
61
+ monitorPlannedMarker: DEFAULT_MARKER,
62
+ },
63
+ },
64
+ ({ toolName, toolInput, sessionId, parameters, cwd }) => {
65
+ const isShell = toolInGroups(toolName, ['shell']);
66
+ const isMonitor = toolInGroups(toolName, ['monitor']);
67
+ const isExecution = toolInGroups(toolName, ['execution']);
68
+
69
+ if (!isShell && !isMonitor && !isExecution) return;
70
+
71
+ const stateOptions = { cwd };
72
+ const state = readSessionState(GATE_ID, sessionId, {}, stateOptions);
73
+ const marker = String(parameters.monitorPlannedMarker ?? DEFAULT_MARKER);
74
+
75
+ // Phase 2: if Monitor tool, clear oldest pending and allow.
76
+ if (isMonitor) {
77
+ const pending = pendingBackgrounds(state);
78
+ if (pending.length > 0) {
79
+ writeSessionState(
80
+ GATE_ID,
81
+ sessionId,
82
+ clearOldestPending(state),
83
+ stateOptions,
84
+ );
85
+ }
86
+ return;
87
+ }
88
+
89
+ // Phase 1: shell with run_in_background requires the marker.
90
+ if (isShell && toolInput.run_in_background === true) {
91
+ const command = shellCommandOf(toolInput);
92
+ if (!hasMarker(command, marker)) {
93
+ deny(
94
+ CONFIG_KEY,
95
+ `A background command MUST declare its Monitor plan. Add "${marker} <what the ` +
96
+ 'Monitor will watch for>" to the command. Every background process needs a ' +
97
+ 'Monitor — an unmonitored background is invisible work that can hang or fail ' +
98
+ 'silently. The marker is a commitment to create a Monitor immediately after.',
99
+ );
100
+ }
101
+ writeSessionState(
102
+ GATE_ID,
103
+ sessionId,
104
+ addPending(state, {
105
+ command: command.slice(0, MAX_COMMAND_PREVIEW_LENGTH),
106
+ startedAt: Date.now(),
107
+ }),
108
+ stateOptions,
109
+ );
110
+ return;
111
+ }
112
+
113
+ // Phase 2: any execution/shell tool while backgrounds are unmonitored.
114
+ const pending = pendingBackgrounds(state);
115
+ if (pending.length > 0) {
116
+ const oldest = pending[0];
117
+ deny(
118
+ CONFIG_KEY,
119
+ `There are ${pending.length} background command(s) without a Monitor. Create a ` +
120
+ `Monitor for the pending background before doing anything else. Oldest pending: ` +
121
+ `"${oldest.command ?? '(unknown)'}". Use the Monitor tool to observe it, then ` +
122
+ 'proceed with your next action.',
123
+ );
124
+ }
125
+ },
126
+ );
@@ -0,0 +1,88 @@
1
+ // require-task-split — denies writes and execution when the active task is too large
2
+ // to implement without sub-tasks. Deterministic: reads active.json, checks size and
3
+ // children count, denies unconditionally when the criteria are not met.
4
+ //
5
+ // Inspired by the Depth Tree method (unlazy): each sub-task must own specific files
6
+ // (OWNS), carry its own verification gate, and be independently completable. If the
7
+ // task's scope is ambiguous or the description is too vague to split confidently, the
8
+ // model must ask the user to clarify before registering sub-tasks.
9
+ //
10
+ // Sizes that require splitting: medium, large, unspecified (anything that is not
11
+ // explicitly small or trivial). A task that already has sub-tasks (children with
12
+ // matching parentId) passes. A project with no tasks also passes (nothing to enforce).
13
+
14
+ import { join } from 'node:path';
15
+ import { projectRootOf, readJsonOrNull } from '../../lib/config.mjs';
16
+ import { deny, runGate, toolInGroups } from '../../lib/hook-io.mjs';
17
+
18
+ const GATE_ID = 'require-task-split';
19
+ const CONFIG_KEY = 'requireTaskSplitBeforeImplementing';
20
+
21
+ const ACTIVE_TASKS_FILE = join('.ai', 'tasks', 'active.json');
22
+ const SIZES_EXEMPT_FROM_SPLIT = new Set(['small', 'trivial']);
23
+ const IMPLEMENTATION_STATUSES = new Set(['open', 'in_forge']);
24
+
25
+ function readActiveTasks(root) {
26
+ const parsed = readJsonOrNull(join(root, ACTIVE_TASKS_FILE));
27
+ const tasks = Array.isArray(parsed?.tasks) ? parsed.tasks : [];
28
+ return tasks.filter((task) => task && typeof task === 'object');
29
+ }
30
+
31
+ function hasChildren(tasks, parentId) {
32
+ return tasks.some((task) => task.parentId === parentId);
33
+ }
34
+
35
+ function unsplitLargeTasks(tasks) {
36
+ return tasks.filter((task) => {
37
+ if (!IMPLEMENTATION_STATUSES.has(task.status)) return false;
38
+ if (task.parentId) return false;
39
+ if (SIZES_EXEMPT_FROM_SPLIT.has(task.size)) return false;
40
+ return !hasChildren(tasks, task.id);
41
+ });
42
+ }
43
+
44
+ const DENY_MESSAGE_TEMPLATE = (count, lines) =>
45
+ `${count} task(s) need splitting before implementation:\n${lines}\n\n` +
46
+ 'BEFORE SPLITTING — check scope clarity:\n' +
47
+ ' If the task description is vague or you are unsure what files/modules are affected,\n' +
48
+ ' ASK THE USER to clarify the scope before registering sub-tasks. Do not guess.\n\n' +
49
+ 'SPLITTING RULES (Depth Tree method):\n' +
50
+ ' 1. Each sub-task must be small and independently verifiable\n' +
51
+ ' 2. Each sub-task should OWN specific files — no two sub-tasks modify the same file\n' +
52
+ ' 3. Each sub-task carries its own verification gate (--verify-command or --verify-path)\n' +
53
+ ' 4. Split at natural boundaries: one module, one function, one test file\n' +
54
+ ' 5. If the task has unclear scope, ask the user — do not split blindly\n\n' +
55
+ 'Register sub-tasks:\n' +
56
+ ' claude-gates task add "<what this sub-task delivers>" --parent <parent-id> --size small \\\n' +
57
+ ' --verify-command "<check>" [--verify-expect <text>] \\\n' +
58
+ ' --description "OWNS: <file1>, <file2>"\n\n' +
59
+ 'Only after sub-tasks are registered can you proceed with writes and execution.';
60
+
61
+ runGate(
62
+ {
63
+ id: GATE_ID,
64
+ configKey: CONFIG_KEY,
65
+ enabledByDefault: true,
66
+ defaultParams: {},
67
+ },
68
+ ({ toolName, cwd }) => {
69
+ const isWrite = toolInGroups(toolName, ['write']);
70
+ const isExecution = toolInGroups(toolName, ['execution']);
71
+ if (!isWrite && !isExecution) return;
72
+
73
+ const root = projectRootOf(cwd) ?? cwd;
74
+ const tasks = readActiveTasks(root);
75
+ if (tasks.length === 0) return;
76
+
77
+ const unsplit = unsplitLargeTasks(tasks);
78
+ if (unsplit.length === 0) return;
79
+
80
+ const lines = unsplit
81
+ .map(
82
+ (task) =>
83
+ ` - [${task.status}] ${task.id}: ${task.title} (size: ${task.size ?? 'unspecified'})`,
84
+ )
85
+ .join('\n');
86
+ deny(CONFIG_KEY, DENY_MESSAGE_TEMPLATE(unsplit.length, lines));
87
+ },
88
+ );
@@ -0,0 +1,138 @@
1
+ // skill-first — the READ half of the capability pair, and the deterministic counterpart to
2
+ // capability-map. capability-map tells the model what it has; nothing made the model act on
3
+ // it. Its catalog is injected at UserPromptSubmit, throttled, carrying an advisory line —
4
+ // so a turn that writes forty files gets one soft reminder at the top and none at the
5
+ // moment each action is actually taken. This gate closes that half: before an action that
6
+ // a listed skill plausibly covers, it requires evidence the question was asked.
7
+ //
8
+ // The pair mirrors reuse-before-build/tool-map: one shared definition of the catalog
9
+ // (lib/capabilities.mjs), a write half that records, a read half that judges.
10
+ //
11
+ // Deliberately narrow, because relevance here is a lexical heuristic and a false deny is
12
+ // expensive. It only speaks when ALL of these hold:
13
+ // · the action carries enough text to judge (`minTextChars`);
14
+ // · a skill is plausibly relevant — the action NAMES it, or shares `minTokenOverlap`
15
+ // distinctive tokens with its description;
16
+ // · the session shows no sign the question was already asked.
17
+ // Everything else is the silent path.
18
+ //
19
+ // Three ways to clear it, all cheap and none requiring filesystem exploration:
20
+ // 1. invoke the relevant skill (the Skill tool; recorded by this gate's track.mjs);
21
+ // 2. state the decision in the content/prompt ("using the dataviz skill", "no skill
22
+ // covers this") — the same escape-hatch shape reuse-before-build uses;
23
+ // 3. turn the gate off for the project.
24
+ //
25
+ // Off by default: it rests on a heuristic, and a gate that guesses must be opted into.
26
+
27
+ import {
28
+ entriesForKind,
29
+ hasSkillAuditEvidence,
30
+ relevantCapabilities,
31
+ } from '../../lib/capabilities.mjs';
32
+ import { projectRootOf } from '../../lib/config.mjs';
33
+ import {
34
+ delegationPromptOf,
35
+ deny,
36
+ runGate,
37
+ shellCommandOf,
38
+ toolInGroups,
39
+ writtenContentOf,
40
+ writtenPathOf,
41
+ } from '../../lib/hook-io.mjs';
42
+ import { readSessionState } from '../../lib/session-state.mjs';
43
+
44
+ export const GATE_ID = 'skill-first';
45
+ export const CONFIG_KEY = 'requireSkillCheckBeforeActing';
46
+
47
+ // How many of the shared tokens the deny message shows: enough to make the match
48
+ // legible, few enough to keep the line short.
49
+ const SHOWN_SHARED_TOKENS = 4;
50
+
51
+ const DEFAULT_PARAMS = {
52
+ kinds: ['skills'],
53
+ minTokenOverlap: 3,
54
+ maxMatches: 3,
55
+ minTextChars: 40,
56
+ extraSkillsDirs: [],
57
+ extraAgentsDirs: [],
58
+ extraCommandsDirs: [],
59
+ };
60
+
61
+ /** The text that describes what this action is about to do, per tool shape. */
62
+ function actionTextOf(toolName, toolInput) {
63
+ if (toolInGroups(toolName, ['delegation']))
64
+ return delegationPromptOf(toolInput);
65
+ if (toolInGroups(toolName, ['write'])) {
66
+ return `${writtenPathOf(toolInput)}\n${writtenContentOf(toolInput)}`;
67
+ }
68
+ if (toolInGroups(toolName, ['shell'])) return shellCommandOf(toolInput);
69
+ return '';
70
+ }
71
+
72
+ function catalogEntries(root, parameters) {
73
+ const entries = [];
74
+ for (const kind of parameters.kinds) {
75
+ entries.push(...entriesForKind(String(kind), root, parameters));
76
+ }
77
+ return entries;
78
+ }
79
+
80
+ /** Skills this session already loaded, as recorded by track.mjs. */
81
+ function invokedSkills(sessionId, cwd) {
82
+ const state = readSessionState(GATE_ID, sessionId, {}, { cwd });
83
+ const names = Array.isArray(state.skillsInvoked) ? state.skillsInvoked : [];
84
+ return new Set(names.map((name) => String(name).toLowerCase()));
85
+ }
86
+
87
+ function describeMatch(match) {
88
+ if (match.named) return `${match.name} (named in this action)`;
89
+ const shared = match.shared.slice(0, SHOWN_SHARED_TOKENS).join(', ');
90
+ return `${match.name} (matches on: ${shared})`;
91
+ }
92
+
93
+ function denyMessage(matches, isDelegation) {
94
+ const target = isDelegation ? "this delegation's prompt" : 'the content';
95
+ return (
96
+ `Blocked: ${matches.length} available skill(s) look relevant to this action, and nothing ` +
97
+ `shows they were considered:\n ${matches.map(describeMatch).join('\n ')}\n` +
98
+ 'Pick ONE, then retry the same action:\n' +
99
+ ` 1. The skill covers this — load it (the Skill tool) and follow it instead of improvising.\n` +
100
+ ` 2. It does not fit — add ONE line to ${target} saying so, e.g. "no skill covers this", ` +
101
+ 'or name the one you are following, e.g. "using the <name> skill".\n' +
102
+ 'No filesystem exploration is required: the catalog was read for you, and the audit is ' +
103
+ 'one sentence.'
104
+ );
105
+ }
106
+
107
+ runGate(
108
+ {
109
+ id: GATE_ID,
110
+ configKey: CONFIG_KEY,
111
+ enabledByDefault: false,
112
+ defaultParams: DEFAULT_PARAMS,
113
+ },
114
+ ({ toolName, toolInput, sessionId, parameters, cwd }) => {
115
+ const isDelegation = toolInGroups(toolName, ['delegation']);
116
+ if (!isDelegation && !toolInGroups(toolName, ['execution'])) return;
117
+
118
+ const text = actionTextOf(toolName, toolInput);
119
+ if (text.trim().length < parameters.minTextChars) return;
120
+ if (hasSkillAuditEvidence(text)) return;
121
+
122
+ const root = projectRootOf(cwd) ?? cwd;
123
+ const matches = relevantCapabilities(
124
+ text,
125
+ catalogEntries(root, parameters),
126
+ {
127
+ minTokenOverlap: parameters.minTokenOverlap,
128
+ maxMatches: parameters.maxMatches,
129
+ },
130
+ );
131
+ if (matches.length === 0) return;
132
+
133
+ const invoked = invokedSkills(sessionId, cwd);
134
+ if (matches.some((match) => invoked.has(match.name.toLowerCase()))) return;
135
+
136
+ deny(CONFIG_KEY, denyMessage(matches, isDelegation));
137
+ },
138
+ );
@@ -0,0 +1,66 @@
1
+ // skill-first/track.mjs — PostToolUse. Records which skills this session actually loaded,
2
+ // so the gate can tell "the model reached for a skill" from "the model never asked". This
3
+ // is the strongest of the three ways to clear skill-first, and the only one that is not
4
+ // the model's own assertion: it is the runtime observing a real Skill call.
5
+ //
6
+ // Never blocks and never speaks; a failure here only costs the session that one signal.
7
+
8
+ import {
9
+ allow,
10
+ mcpActionSegment,
11
+ readHookPayload,
12
+ sessionIdOf,
13
+ toolInGroups,
14
+ toolInputOf,
15
+ toolNameOf,
16
+ } from '../../lib/hook-io.mjs';
17
+ import { updateSessionState } from '../../lib/session-state.mjs';
18
+
19
+ const GATE_ID = 'skill-first';
20
+ const MAX_ENTRIES = 60;
21
+ const MAX_ENTRY_LENGTH = 120;
22
+
23
+ const NAME_FIELDS = ['skill', 'skill_name', 'skillName', 'name', 'id'];
24
+
25
+ /** The skill a Skill-style call names, across native and MCP field shapes. */
26
+ function skillNameOf(toolInput, toolName) {
27
+ for (const field of NAME_FIELDS) {
28
+ const value = toolInput?.[field];
29
+ if (typeof value === 'string' && value.trim()) return value.trim();
30
+ }
31
+ // An MCP server may encode the skill in the action segment instead of an argument.
32
+ return mcpActionSegment(String(toolName)) || null;
33
+ }
34
+
35
+ // A plugin skill arrives as `plugin:skill`, a directory-scoped one as `path/to:skill`;
36
+ // the catalog knows it by the bare name, so both spellings are recorded.
37
+ function spellingsOf(name) {
38
+ const bare = name.includes(':') ? name.split(':').at(-1) : name;
39
+ return [...new Set([name, bare])]
40
+ .filter(Boolean)
41
+ .map((entry) => entry.slice(0, MAX_ENTRY_LENGTH));
42
+ }
43
+
44
+ function main() {
45
+ const rawPayload = readHookPayload();
46
+ if (rawPayload === null) allow();
47
+ const toolName = toolNameOf(rawPayload) ?? '';
48
+ if (!toolInGroups(toolName, ['skill'])) allow();
49
+
50
+ const name = skillNameOf(toolInputOf(rawPayload), toolName);
51
+ if (!name) allow();
52
+
53
+ updateSessionState(GATE_ID, sessionIdOf(rawPayload), {}, (state) => ({
54
+ ...state,
55
+ skillsInvoked: [...(state.skillsInvoked ?? []), ...spellingsOf(name)].slice(
56
+ -MAX_ENTRIES,
57
+ ),
58
+ }));
59
+ allow();
60
+ }
61
+
62
+ try {
63
+ main();
64
+ } catch {
65
+ allow();
66
+ }
@@ -51,6 +51,16 @@
51
51
  }
52
52
  ]
53
53
  },
54
+ {
55
+ "matcher": "Bash|run_command|PowerShell|Write|Edit|MultiEdit|NotebookEdit|write_to_file|replace_file_content|mcp__ide__executeCode|Monitor|mcp__.*",
56
+ "hooks": [
57
+ {
58
+ "type": "command",
59
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/require-monitor/index.mjs\"",
60
+ "timeout": 30
61
+ }
62
+ ]
63
+ },
54
64
  {
55
65
  "matcher": "Agent|Task|invoke_subagent|mcp__.*",
56
66
  "hooks": [
@@ -331,6 +341,16 @@
331
341
  }
332
342
  ]
333
343
  },
344
+ {
345
+ "matcher": "Bash|run_command|PowerShell|mcp__.*",
346
+ "hooks": [
347
+ {
348
+ "type": "command",
349
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/no-trivial-scripts/index.mjs\"",
350
+ "timeout": 30
351
+ }
352
+ ]
353
+ },
334
354
  {
335
355
  "matcher": "Write|Edit|MultiEdit|NotebookEdit|write_to_file|replace_file_content|Agent|Task|invoke_subagent|mcp__.*",
336
356
  "hooks": [
@@ -351,6 +371,16 @@
351
371
  }
352
372
  ]
353
373
  },
374
+ {
375
+ "matcher": "Write|Edit|MultiEdit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|PowerShell|mcp__ide__executeCode|Agent|Task|invoke_subagent|mcp__.*",
376
+ "hooks": [
377
+ {
378
+ "type": "command",
379
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/skill-first/index.mjs\"",
380
+ "timeout": 30
381
+ }
382
+ ]
383
+ },
354
384
  {
355
385
  "matcher": "WebSearch|WebFetch|mcp__.*",
356
386
  "hooks": [
@@ -362,7 +392,7 @@
362
392
  ]
363
393
  },
364
394
  {
365
- "matcher": "Write|Edit|MultiEdit|NotebookEdit|write_to_file|replace_file_content|mcp__.*",
395
+ "matcher": "Write|Edit|MultiEdit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|PowerShell|mcp__.*",
366
396
  "hooks": [
367
397
  {
368
398
  "type": "command",
@@ -390,9 +420,39 @@
390
420
  "timeout": 30
391
421
  }
392
422
  ]
423
+ },
424
+ {
425
+ "matcher": "Write|Edit|MultiEdit|NotebookEdit|write_to_file|replace_file_content|Bash|run_command|PowerShell|mcp__ide__executeCode|mcp__.*",
426
+ "hooks": [
427
+ {
428
+ "type": "command",
429
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/require-task-split/index.mjs\"",
430
+ "timeout": 30
431
+ }
432
+ ]
393
433
  }
394
434
  ],
395
435
  "PostToolUse": [
436
+ {
437
+ "matcher": "Agent|Task|invoke_subagent|mcp__.*",
438
+ "hooks": [
439
+ {
440
+ "type": "command",
441
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/circuit-breaker/track.mjs\"",
442
+ "timeout": 30
443
+ }
444
+ ]
445
+ },
446
+ {
447
+ "matcher": "Skill|mcp__.*",
448
+ "hooks": [
449
+ {
450
+ "type": "command",
451
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gates/skill-first/track.mjs\"",
452
+ "timeout": 30
453
+ }
454
+ ]
455
+ },
396
456
  {
397
457
  "matcher": "WebSearch|WebFetch|mcp__.*",
398
458
  "hooks": [