@devrik-tools/claude-gates 0.9.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.
@@ -371,6 +371,16 @@
371
371
  }
372
372
  ]
373
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
+ },
374
384
  {
375
385
  "matcher": "WebSearch|WebFetch|mcp__.*",
376
386
  "hooks": [
@@ -433,6 +443,16 @@
433
443
  }
434
444
  ]
435
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
+ },
436
456
  {
437
457
  "matcher": "WebSearch|WebFetch|mcp__.*",
438
458
  "hooks": [
@@ -0,0 +1,401 @@
1
+ // capabilities.mjs — the shared definition of "what AI capabilities does this project
2
+ // have". Discovery is one implementation here, so the two halves of the capability pair
3
+ // cannot drift apart, exactly like lib/tools.mjs serves reuse-before-build and tool-map:
4
+ // · capability-map (UserPromptSubmit) renders the catalog INTO the model's context;
5
+ // · skill-first (PreToolUse) reads the same catalog to judge whether the action about
6
+ // to run has a skill that already covers it.
7
+ // Two gates deriving the catalog from two private copies of `readdirSync` was the drift
8
+ // this module exists to prevent.
9
+ //
10
+ // The catalog IS the directory listing, never a hardcoded copy, so it is autosynced by
11
+ // construction. Project roots are scanned BEFORE ~/.claude so a project capability
12
+ // shadows a global one of the same name. `.agents/skills` and `.ai/skills` (home and
13
+ // project) are scanned as skill-only roots because other installers write there.
14
+ //
15
+ // Node built-ins only: a gate importing this must keep working installed on its own.
16
+
17
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { basename, extname, isAbsolute, join } from 'node:path';
20
+
21
+ export const KIND_EXTENSIONS = Object.freeze({
22
+ agents: ['.md'],
23
+ commands: ['.md', '.toml'],
24
+ });
25
+
26
+ function isDirectory(path) {
27
+ try {
28
+ return statSync(path).isDirectory();
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export function mtimeMsOf(path) {
35
+ try {
36
+ return statSync(path).mtimeMs;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ // ── Front matter ────────────────────────────────────────────────────────────────────
43
+ // A bare block-scalar indicator (`>`, `>-`, `|`, `|-`) means the value is on the following
44
+ // indented lines; without this the blurb rendered as ">".
45
+ const BLOCK_SCALAR_INDICATOR_PATTERN = /^[|>][+-]?\d*$/;
46
+
47
+ function readBlockScalarValue(lines, startIndex) {
48
+ const parts = [];
49
+ for (let index = startIndex; index < lines.length; index += 1) {
50
+ const line = lines[index];
51
+ if (line.trim() === '---') break;
52
+ if (!/^[ \t]+\S/.test(line)) break;
53
+ parts.push(line.trim());
54
+ }
55
+ return parts.join(' ');
56
+ }
57
+
58
+ // Parsed line by line (no multi-line regex) so a large body can never backtrack.
59
+ export function parseFrontMatter(fileText) {
60
+ const lines = fileText.split(/\r?\n/);
61
+ if (lines[0]?.trim() !== '---') return { name: '', description: '' };
62
+ let name = '';
63
+ let description = '';
64
+ for (let index = 1; index < lines.length; index += 1) {
65
+ const line = lines[index];
66
+ if (line.trim() === '---') break;
67
+ const separator = line.indexOf(':');
68
+ if (separator < 0) continue;
69
+ const key = line.slice(0, separator).trim();
70
+ let value = line
71
+ .slice(separator + 1)
72
+ .trim()
73
+ .replace(/^["']|["']$/g, '');
74
+ if (BLOCK_SCALAR_INDICATOR_PATTERN.test(value)) {
75
+ value = readBlockScalarValue(lines, index + 1);
76
+ }
77
+ if (key === 'name') name = value;
78
+ else if (key === 'description') description = value;
79
+ }
80
+ return { name, description };
81
+ }
82
+
83
+ export function truncateAtWordBoundary(text, maxChars) {
84
+ if (text.length <= maxChars) return text;
85
+ const budget = text.slice(0, maxChars - 1);
86
+ const lastSpace = budget.lastIndexOf(' ');
87
+ const cut = lastSpace > 0 ? budget.slice(0, lastSpace) : budget;
88
+ return `${cut.trimEnd()}…`;
89
+ }
90
+
91
+ export function firstClause(description, maxClauseChars) {
92
+ if (!description) return '';
93
+ const sentenceEnd = description.indexOf('. ');
94
+ const clause =
95
+ sentenceEnd > 0 ? description.slice(0, sentenceEnd) : description;
96
+ return truncateAtWordBoundary(clause, maxClauseChars);
97
+ }
98
+
99
+ // ── Discovery ───────────────────────────────────────────────────────────────────────
100
+ function filesUnder(directory, extensions) {
101
+ let names;
102
+ try {
103
+ names = readdirSync(directory);
104
+ } catch {
105
+ return [];
106
+ }
107
+ const files = [];
108
+ for (const name of names) {
109
+ const full = join(directory, name);
110
+ if (isDirectory(full)) files.push(...filesUnder(full, extensions));
111
+ else if (extensions.includes(extname(name).toLowerCase())) files.push(full);
112
+ }
113
+ return files;
114
+ }
115
+
116
+ function entryFor(file, fallbackName) {
117
+ const mtimeMs = mtimeMsOf(file);
118
+ if (mtimeMs === null) return null;
119
+ let content;
120
+ try {
121
+ content = readFileSync(file, 'utf8');
122
+ } catch {
123
+ return null;
124
+ }
125
+ const { name, description } = parseFrontMatter(content);
126
+ return {
127
+ name: name || fallbackName,
128
+ description,
129
+ stamp: `${file}:${mtimeMs}`,
130
+ };
131
+ }
132
+
133
+ function skillEntriesUnder(skillsRoot) {
134
+ let names;
135
+ try {
136
+ names = readdirSync(skillsRoot);
137
+ } catch {
138
+ return [];
139
+ }
140
+ return names
141
+ .filter((name) => isDirectory(join(skillsRoot, name)))
142
+ .map((name) => entryFor(join(skillsRoot, name, 'SKILL.md'), name))
143
+ .filter(Boolean);
144
+ }
145
+
146
+ function fileEntriesUnder(directory, extensions) {
147
+ return filesUnder(directory, extensions)
148
+ .map((file) => entryFor(file, basename(file, extname(file))))
149
+ .filter(Boolean);
150
+ }
151
+
152
+ function resolveExtra(root, directory) {
153
+ return isAbsolute(directory) ? directory : join(root, directory);
154
+ }
155
+
156
+ export function skillRootsFor(root, extraDirectories = []) {
157
+ return [
158
+ join(root, '.claude', 'skills'),
159
+ join(root, '.agents', 'skills'),
160
+ join(root, '.ai', 'skills'),
161
+ join(homedir(), '.claude', 'skills'),
162
+ join(homedir(), '.agents', 'skills'),
163
+ join(homedir(), '.ai', 'skills'),
164
+ ...extraDirectories.map((directory) => resolveExtra(root, directory)),
165
+ ];
166
+ }
167
+
168
+ export function fileRootsFor(root, kind, extraDirectories = []) {
169
+ return [
170
+ join(root, '.claude', kind),
171
+ join(homedir(), '.claude', kind),
172
+ ...extraDirectories.map((directory) => resolveExtra(root, directory)),
173
+ ];
174
+ }
175
+
176
+ function extraDirectoriesFor(kind, settings) {
177
+ if (kind === 'agents') return settings.extraAgentsDirs ?? [];
178
+ if (kind === 'commands') return settings.extraCommandsDirs ?? [];
179
+ return settings.extraSkillsDirs ?? [];
180
+ }
181
+
182
+ function collectKind(kind, root, settings) {
183
+ const extra = extraDirectoriesFor(kind, settings);
184
+ if (kind === 'skills')
185
+ return skillRootsFor(root, extra).flatMap(skillEntriesUnder);
186
+ const extensions = KIND_EXTENSIONS[kind];
187
+ if (!extensions) return [];
188
+ return fileRootsFor(root, kind, extra).flatMap((directory) =>
189
+ fileEntriesUnder(directory, extensions),
190
+ );
191
+ }
192
+
193
+ // First occurrence wins, and project roots come first: a project capability shadows a
194
+ // global one of the same name.
195
+ export function entriesForKind(kind, root, settings = {}) {
196
+ const seen = new Set();
197
+ const unique = [];
198
+ for (const entry of collectKind(kind, root, settings)) {
199
+ if (seen.has(entry.name)) continue;
200
+ seen.add(entry.name);
201
+ unique.push(entry);
202
+ }
203
+ return unique.sort((a, b) => a.name.localeCompare(b.name));
204
+ }
205
+
206
+ /** The raw catalog `{ kind: [{ name, description, stamp }] }`, kinds with no entry omitted. */
207
+ export function buildRawCatalog(root, settings) {
208
+ const catalog = {};
209
+ for (const kind of settings.kinds) {
210
+ const entries = entriesForKind(kind, root, settings);
211
+ if (entries.length > 0) catalog[kind] = entries;
212
+ }
213
+ return catalog;
214
+ }
215
+
216
+ // ── Relevance ───────────────────────────────────────────────────────────────────────
217
+ // Which capabilities plausibly cover the action about to run. Deliberately a LEXICAL
218
+ // heuristic over the catalog's own front matter, not a model call: a gate is a
219
+ // deterministic process with no network, and a skill's `description` is already written
220
+ // as its trigger ("Use when the user asks about…"), so it is the honest thing to match
221
+ // against. Two independent signals, because each alone is weak:
222
+ // · the capability's NAME appearing in the action text — precise, near zero false
223
+ // positives, but silent whenever the model never names the skill (the common case);
224
+ // · TOKEN OVERLAP with the description — catches the unnamed case, at the cost of
225
+ // needing a threshold to stay quiet.
226
+ // Honest limitation: no lexical rule recognizes a paraphrase that shares no vocabulary
227
+ // with the description. This narrows the blind spot, it does not close it — which is why
228
+ // the gate that consumes it ships off by default and clears on an explicit statement.
229
+
230
+ const MIN_TOKEN_LENGTH = 4;
231
+ const NAME_MATCH_SCORE = 100;
232
+ const DEFAULT_MIN_TOKEN_OVERLAP = 3;
233
+ const DEFAULT_MAX_MATCHES = 3;
234
+ const MAX_TEXT_CHARS = 20000;
235
+
236
+ // Words carrying no discriminating power in this domain: they appear in almost every
237
+ // skill description AND in almost every prompt, so counting them as overlap would make
238
+ // every action match every skill. ES + EN, matching lib/signals.mjs's bilingual scope.
239
+ const STOPWORDS = new Set(
240
+ (
241
+ 'this that they them then than with when what which while where whose whom about ' +
242
+ 'into onto from over under after before during your yours their there these those ' +
243
+ 'have has had having been being does doing done should would could must will shall ' +
244
+ 'also only just very much more most less least other others same such each every ' +
245
+ 'user users use uses used using make makes made need needs needed want wants ' +
246
+ 'skill skills agent agents command commands claude anthropic tool tools ' +
247
+ 'file files code codebase project projects repo repository directory folder ' +
248
+ 'work works working task tasks thing things stuff item items step steps ' +
249
+ 'help helps helping ask asks asked answer answers question questions ' +
250
+ 'para pero como cuando donde porque aunque desde hasta sobre entre segun ' +
251
+ 'este esta estos estas esto aquel aquella ellos ellas nosotros ustedes ' +
252
+ 'tiene tienen tener teniendo hacer hace hacen hecho hacia siendo estar ' +
253
+ 'debe deben debes puede pueden podes podemos quiere quieren necesita necesitan ' +
254
+ 'usuario usuarios usar usando usa archivo archivos carpeta directorio ' +
255
+ 'proyecto proyectos codigo tarea tareas cosa cosas paso pasos ' +
256
+ 'cualquier cualquiera todos todas alguno alguna mismo misma otro otra ' +
257
+ 'ayuda ayudar pregunta preguntas respuesta respuestas'
258
+ ).split(/\s+/),
259
+ );
260
+
261
+ const WORD_SEPARATOR = /[^\p{L}\p{N}]+/u;
262
+
263
+ /** Distinctive lowercase tokens of a text: long enough, and not a domain stopword. */
264
+ export function distinctiveTokens(text) {
265
+ const tokens = new Set();
266
+ for (const raw of String(text ?? '')
267
+ .slice(0, MAX_TEXT_CHARS)
268
+ .toLowerCase()
269
+ .split(WORD_SEPARATOR)) {
270
+ if (raw.length >= MIN_TOKEN_LENGTH && !STOPWORDS.has(raw)) tokens.add(raw);
271
+ }
272
+ return tokens;
273
+ }
274
+
275
+ function escapeRegExpSource(text) {
276
+ return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
277
+ }
278
+
279
+ // A capability's name is written `code-review` / `a11y_doctrine` but referred to in prose
280
+ // as "code review", so both spellings must count as naming it.
281
+ function namePattern(name) {
282
+ const spaced = String(name)
283
+ .split(/[-_]/)
284
+ .filter(Boolean)
285
+ .map(escapeRegExpSource)
286
+ .join('[\\s\\-_]*');
287
+ if (!spaced) return null;
288
+ try {
289
+ return new RegExp(`(?<![\\p{L}\\p{N}_])${spaced}(?![\\p{L}\\p{N}_])`, 'iu');
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+
295
+ function triggerTokensOf(entry) {
296
+ return distinctiveTokens(
297
+ `${String(entry.name).replace(/[-_]/g, ' ')} ${entry.description ?? ''}`,
298
+ );
299
+ }
300
+
301
+ // How many catalog entries mention each token. A token nearly every skill uses
302
+ // ("analytics", "build") says almost nothing about WHICH skill fits, while a token only
303
+ // one skill uses ("tooltip") is close to decisive — so ranking by a raw token count
304
+ // hands the match to whichever skill happens to have the longest, most generic
305
+ // description. Weighting each shared token by 1/frequency is what makes the deny name
306
+ // the skill that actually fits: it is the ranking, not the threshold, so `minTokenOverlap`
307
+ // keeps meaning a plain, predictable count of shared tokens.
308
+ function documentFrequencies(tokenSets) {
309
+ const frequencies = new Map();
310
+ for (const tokens of tokenSets) {
311
+ for (const token of tokens)
312
+ frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
313
+ }
314
+ return frequencies;
315
+ }
316
+
317
+ function scoreEntry(
318
+ entry,
319
+ triggerTokens,
320
+ actionTokens,
321
+ actionText,
322
+ frequencies,
323
+ ) {
324
+ const pattern = namePattern(entry.name);
325
+ const named = pattern !== null && pattern.test(actionText);
326
+ const shared = [];
327
+ let weight = 0;
328
+ for (const token of triggerTokens) {
329
+ if (!actionTokens.has(token)) continue;
330
+ shared.push(token);
331
+ weight += 1 / (frequencies.get(token) ?? 1);
332
+ }
333
+ // The rarest shared tokens first, so the deny message explains the match with the
334
+ // words that actually drove it.
335
+ shared.sort((a, b) => (frequencies.get(a) ?? 1) - (frequencies.get(b) ?? 1));
336
+ return {
337
+ named,
338
+ overlap: shared.length,
339
+ shared,
340
+ score: (named ? NAME_MATCH_SCORE : 0) + weight,
341
+ };
342
+ }
343
+
344
+ /**
345
+ * The capabilities that plausibly cover `actionText`, strongest first. A capability
346
+ * qualifies when the action NAMES it, or when it shares at least `minTokenOverlap`
347
+ * distinctive tokens with its description. Returns `[]` — the common, silent path —
348
+ * whenever nothing clears the bar.
349
+ */
350
+ export function relevantCapabilities(actionText, entries, options = {}) {
351
+ const minTokenOverlap = options.minTokenOverlap ?? DEFAULT_MIN_TOKEN_OVERLAP;
352
+ const maxMatches = options.maxMatches ?? DEFAULT_MAX_MATCHES;
353
+ const text = String(actionText ?? '').slice(0, MAX_TEXT_CHARS);
354
+ if (!text.trim()) return [];
355
+ const actionTokens = distinctiveTokens(text);
356
+ if (actionTokens.size === 0) return [];
357
+
358
+ const named = (entries ?? []).filter((entry) => entry?.name);
359
+ const tokenSets = named.map(triggerTokensOf);
360
+ const frequencies = documentFrequencies(tokenSets);
361
+
362
+ const matches = [];
363
+ for (const [index, entry] of named.entries()) {
364
+ const scored = scoreEntry(
365
+ entry,
366
+ tokenSets[index],
367
+ actionTokens,
368
+ text,
369
+ frequencies,
370
+ );
371
+ if (!scored.named && scored.overlap < minTokenOverlap) continue;
372
+ matches.push({ name: entry.name, ...scored });
373
+ }
374
+ return matches.sort((a, b) => b.score - a.score).slice(0, maxMatches);
375
+ }
376
+
377
+ // ── The audit statement that clears the check ───────────────────────────────────────
378
+ // One explicit sentence, in the written content or the delegation prompt, in ES or EN.
379
+ // The point is not the wording: it is that a decision about skills was made and recorded
380
+ // where a reader will see it, instead of the question never being asked.
381
+ // An optional capability name may sit between the verb and the noun ("using the dataviz
382
+ // skill"), so the name is allowed but never required.
383
+ const AUDIT_NAME = String.raw`(?:the\s+)?(?:[\w./-]+\s+)?`;
384
+
385
+ export const SKILL_AUDIT_PATTERN = new RegExp(
386
+ [
387
+ String.raw`(?:using|used|use|via|per|applying|apply)\s+${AUDIT_NAME}skills?\b`,
388
+ String.raw`\bskills?\s*[:=]\s*\S`,
389
+ String.raw`\bno\s+skills?\s+(?:covers?|applies|apply|matches|match|fits)\b`,
390
+ String.raw`\bskill[-\s]checked\b`,
391
+ String.raw`(?:ninguna|sin)\s+skill`,
392
+ String.raw`(?:usando|uso|use|aplicando)\s+(?:la\s+)?(?:[\w./-]+\s+)?skill`,
393
+ String.raw`\bskill\s+(?:relevante|aplicable)`,
394
+ ].join('|'),
395
+ 'iu',
396
+ );
397
+
398
+ /** Whether a text carries the explicit statement that skills were considered. */
399
+ export function hasSkillAuditEvidence(text) {
400
+ return SKILL_AUDIT_PATTERN.test(String(text ?? ''));
401
+ }
@@ -60,6 +60,9 @@ export const TOOL_GROUPS = Object.freeze({
60
60
  research: ['WebSearch', 'WebFetch'],
61
61
  // Observation tool for background processes.
62
62
  monitor: ['Monitor'],
63
+ // Loading a packaged capability. Tracked (not blocked) so a gate can tell whether the
64
+ // model actually reached for a skill during this session.
65
+ skill: ['Skill'],
63
66
  });
64
67
 
65
68
  /** Every concrete tool name a set of groups expands to, de-duplicated. */
@@ -94,6 +97,7 @@ const MCP_GROUP_SIGNALS = Object.freeze({
94
97
  /(?:write|edit|create|append|patch|replace|insert|modify|save|update|shell|bash|exec|run|command|terminal|process|spawn|cmd|powershell|sh)/i,
95
98
  research: /(?:search|fetch|browse|docs|documentation|library|lookup|query)/i,
96
99
  monitor: /(?:monitor|observe|watch|stream|tail|follow|subscribe)/i,
100
+ skill: /(?:skill|capability|playbook)/i,
97
101
  });
98
102
 
99
103
  const MCP_TOOL_PREFIX = 'mcp__';
@@ -175,3 +175,94 @@ export function isBuildIntent(text) {
175
175
  }
176
176
  return false;
177
177
  }
178
+
179
+ // ── WORK_NATURE: what KIND of work a prompt is asking for ───────────────────────────
180
+ // Used by capability-map to answer "did the nature of the work change?" — the trigger the
181
+ // catalog injection was missing. Its previous re-injection rule fired only when the
182
+ // CATALOG changed on disk, so a session that pivoted from debugging to designing kept
183
+ // whatever stale reminder the throttle had last emitted.
184
+ //
185
+ // This is a coarse lexical classifier, and deliberately so: a wrong answer costs one
186
+ // extra (harmless, never-blocking) injection of a catalog the model can ignore, so the
187
+ // bar for a term is "does it usually signal this kind of work", not certainty. Order
188
+ // matters for ties — the more specific natures are declared before the generic ones,
189
+ // because `implement`'s verbs (write/create/add) also appear inside every other nature.
190
+
191
+ const WORK_NATURE_TERMS = [
192
+ [
193
+ 'debug',
194
+ 'debug|debugg\\p{L}*|depur\\p{L}*|bug|bugs|error|errores|falla|fallas|fallando|' +
195
+ 'broken|roto|rota|crash|crashes|traceback|stacktrace|reproduce|reproducir|' +
196
+ 'arregl\\p{L}*|corrig\\p{L}*|corregir|fix|fixes|fixing|diagnos\\p{L}*',
197
+ ],
198
+ [
199
+ 'test',
200
+ 'test|tests|testing|prueba|pruebas|probar|spec|specs|coverage|cobertura|' +
201
+ 'assert|asserts|asercion\\p{L}*|jest|vitest|mocha|pytest|e2e|fixture|fixtures',
202
+ ],
203
+ [
204
+ 'review',
205
+ 'review|reviews|revis\\p{L}*|auditor\\p{L}*|audit|audita|lint|linter|' +
206
+ 'code review|pull request|diff',
207
+ ],
208
+ [
209
+ 'release',
210
+ 'deploy|desplieg\\p{L}*|release|publica|publicar|publish|ship|version|versionar|' +
211
+ 'changelog|commit|merge|tag|rollout',
212
+ ],
213
+ [
214
+ 'refactor',
215
+ 'refactor\\p{L}*|simplif\\p{L}*|clean up|limpi\\p{L}*|renombr\\p{L}*|rename|' +
216
+ 'extract|extraer|deduplicat\\p{L}*|reorganiz\\p{L}*|migrate|migrar',
217
+ ],
218
+ [
219
+ 'design',
220
+ 'design|dise[nñ]\\p{L}*|mockup|wireframe|layout|maqueta|estilo|estilos|' +
221
+ 'css|tailwind|figma|paleta|palette|tipograf\\p{L}*|responsive',
222
+ ],
223
+ [
224
+ 'docs',
225
+ 'readme|changelog|documenta\\p{L}*|documentation|docstring|tutorial|guide|gu[ií]a|' +
226
+ 'manual|comentar|comment|comments',
227
+ ],
228
+ [
229
+ 'research',
230
+ 'research|investig\\p{L}*|explor\\p{L}*|explore|averigu\\p{L}*|analiz\\p{L}*|' +
231
+ 'analyze|analysis|compare|comparar|evalu\\p{L}*|study|estudiar|find out|' +
232
+ 'entender|understand|search|buscar',
233
+ ],
234
+ [
235
+ 'implement',
236
+ 'implement\\p{L}*|build|construi\\p{L}*|construye|create|crear|crea|' +
237
+ 'write|escrib\\p{L}*|add|agreg\\p{L}*|a[nñ]ad\\p{L}*|feature|funcionalidad|' +
238
+ 'endpoint|componente|component|integra\\p{L}*',
239
+ ],
240
+ ];
241
+
242
+ const GENERAL_WORK_NATURE = 'general';
243
+
244
+ const WORK_NATURE_PATTERNS = WORK_NATURE_TERMS.map(([nature, terms]) => [
245
+ nature,
246
+ new RegExp(withUnicodeWordBoundary(terms).source, 'giu'),
247
+ ]);
248
+
249
+ /**
250
+ * The dominant kind of work a text is asking for, or 'general' when nothing matches.
251
+ * Scored by how many nature terms occur, so a passing mention loses to a sustained one;
252
+ * ties go to whichever nature is declared first (most specific wins).
253
+ */
254
+ export function workNatureOf(text) {
255
+ const source = String(text ?? '');
256
+ if (!source.trim()) return GENERAL_WORK_NATURE;
257
+ let best = GENERAL_WORK_NATURE;
258
+ let bestScore = 0;
259
+ for (const [nature, pattern] of WORK_NATURE_PATTERNS) {
260
+ pattern.lastIndex = 0;
261
+ const score = [...source.matchAll(pattern)].length;
262
+ if (score > bestScore) {
263
+ best = nature;
264
+ bestScore = score;
265
+ }
266
+ }
267
+ return best;
268
+ }
package/registry.json CHANGED
@@ -838,6 +838,59 @@
838
838
  "description": "Regex sources; a file whose basename matches any is a helper regardless of folder."
839
839
  }
840
840
  ]
841
+ },
842
+ {
843
+ "id": "skill-first",
844
+ "configKey": "requireSkillCheckBeforeActing",
845
+ "default": false,
846
+ "event": "PreToolUse",
847
+ "tools": ["execution", "delegation"],
848
+ "script": "gates/skill-first/index.mjs",
849
+ "extraScripts": [
850
+ {
851
+ "event": "PostToolUse",
852
+ "script": "gates/skill-first/track.mjs",
853
+ "tools": ["skill"]
854
+ }
855
+ ],
856
+ "description": "Denies a write, command or delegation that an available skill plausibly covers until the question was asked: load the skill (the Skill tool, recorded by its tracker), or state the decision in the content/prompt (\"using the <name> skill\", \"no skill covers this\"). Relevance is lexical over the same catalog capability-map injects: the action names the skill, or shares minTokenOverlap distinctive tokens with its description. Silent when nothing matches.",
857
+ "params": [
858
+ {
859
+ "name": "kinds",
860
+ "type": "string[]",
861
+ "description": "Which capability kinds are judged for relevance: skills, agents, commands. Default skills only."
862
+ },
863
+ {
864
+ "name": "minTokenOverlap",
865
+ "type": "number",
866
+ "description": "How many distinctive tokens an action must share with a capability's description to count as relevant. Raise it to make the gate quieter. Default 3."
867
+ },
868
+ {
869
+ "name": "maxMatches",
870
+ "type": "number",
871
+ "description": "How many relevant capabilities the deny message lists. Default 3."
872
+ },
873
+ {
874
+ "name": "minTextChars",
875
+ "type": "number",
876
+ "description": "Actions with less text than this are never judged (a bare command has nothing to match). Default 40."
877
+ },
878
+ {
879
+ "name": "extraSkillsDirs",
880
+ "type": "string[]",
881
+ "description": "Extra skill roots scanned in addition to the default project and home roots."
882
+ },
883
+ {
884
+ "name": "extraAgentsDirs",
885
+ "type": "string[]",
886
+ "description": "Extra agent roots, used only when 'agents' is in kinds."
887
+ },
888
+ {
889
+ "name": "extraCommandsDirs",
890
+ "type": "string[]",
891
+ "description": "Extra command roots, used only when 'commands' is in kinds."
892
+ }
893
+ ]
841
894
  }
842
895
  ]
843
896
  },
@@ -1095,6 +1148,11 @@
1095
1148
  "name": "blurbOverridesFile",
1096
1149
  "type": "string",
1097
1150
  "description": "Path, relative to the project root, of a JSON map name -> hand-written blurb (default .ai/blurb-overrides.json)."
1151
+ },
1152
+ {
1153
+ "name": "reinjectOnWorkNatureChange",
1154
+ "type": "boolean",
1155
+ "description": "Re-inject the catalog when the prompt's kind of work changes (debug -> design -> release), not only when the catalog itself changed. Default true; set false to fall back to the throttle alone."
1098
1156
  }
1099
1157
  ],
1100
1158
  "timeoutSeconds": 10