@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/README.es.md +102 -9
- package/README.md +93 -9
- package/cli/artifacts.mjs +213 -0
- package/cli/constants.mjs +14 -0
- package/cli/doctor.mjs +2 -1
- package/cli/index.mjs +54 -2
- package/cli/init.mjs +95 -9
- package/cli/install.mjs +53 -1
- package/cli/registry.mjs +2 -0
- package/cli/selection.mjs +23 -1
- package/cli/smoke-fixtures.json +53 -3
- package/cli/task.mjs +69 -4
- package/package.json +5 -4
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/gates/capability-map/index.mjs +37 -208
- package/plugins/gates/hooks/gates/circuit-breaker/index.mjs +4 -11
- package/plugins/gates/hooks/gates/circuit-breaker/track.mjs +285 -0
- package/plugins/gates/hooks/gates/force-parallel/index.mjs +11 -12
- package/plugins/gates/hooks/gates/library-docs/index.mjs +107 -31
- package/plugins/gates/hooks/gates/no-trivial-scripts/index.mjs +114 -0
- package/plugins/gates/hooks/gates/require-monitor/index.mjs +126 -0
- package/plugins/gates/hooks/gates/require-task-split/index.mjs +88 -0
- package/plugins/gates/hooks/gates/skill-first/index.mjs +138 -0
- package/plugins/gates/hooks/gates/skill-first/track.mjs +66 -0
- package/plugins/gates/hooks/hooks.json +61 -1
- package/plugins/gates/hooks/lib/capabilities.mjs +401 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +9 -2
- package/plugins/gates/hooks/lib/signals.mjs +91 -0
- package/plugins/gates/hooks/lib/testing.mjs +15 -4
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/lib/task-store.mjs +6 -0
- package/plugins/tasks/hooks/register-requests.mjs +37 -10
- package/registry.json +106 -5
|
@@ -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
|
+
}
|
|
@@ -58,6 +58,11 @@ export const TOOL_GROUPS = Object.freeze({
|
|
|
58
58
|
],
|
|
59
59
|
// Research surfaces: the web and documentation lookups the research gates sequence.
|
|
60
60
|
research: ['WebSearch', 'WebFetch'],
|
|
61
|
+
// Observation tool for background processes.
|
|
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'],
|
|
61
66
|
});
|
|
62
67
|
|
|
63
68
|
/** Every concrete tool name a set of groups expands to, de-duplicated. */
|
|
@@ -91,6 +96,8 @@ const MCP_GROUP_SIGNALS = Object.freeze({
|
|
|
91
96
|
execution:
|
|
92
97
|
/(?:write|edit|create|append|patch|replace|insert|modify|save|update|shell|bash|exec|run|command|terminal|process|spawn|cmd|powershell|sh)/i,
|
|
93
98
|
research: /(?:search|fetch|browse|docs|documentation|library|lookup|query)/i,
|
|
99
|
+
monitor: /(?:monitor|observe|watch|stream|tail|follow|subscribe)/i,
|
|
100
|
+
skill: /(?:skill|capability|playbook)/i,
|
|
94
101
|
});
|
|
95
102
|
|
|
96
103
|
const MCP_TOOL_PREFIX = 'mcp__';
|
|
@@ -560,10 +567,10 @@ export function allow() {
|
|
|
560
567
|
/** A Stop-hook block: makes the agent continue instead of ending the turn. */
|
|
561
568
|
export function block(label, reason) {
|
|
562
569
|
record(DECISIONS.BLOCK, reason);
|
|
563
|
-
process.
|
|
570
|
+
process.stderr.write(
|
|
564
571
|
JSON.stringify({ decision: 'block', reason: `[${label}] ${reason}` }),
|
|
565
572
|
);
|
|
566
|
-
process.exit(
|
|
573
|
+
process.exit(2);
|
|
567
574
|
}
|
|
568
575
|
|
|
569
576
|
export const SEVERITY = Object.freeze({ DENY: 'deny', WARN: 'warn' });
|
|
@@ -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
|
+
}
|
|
@@ -53,7 +53,7 @@ export function runGateProcess(
|
|
|
53
53
|
} = {},
|
|
54
54
|
) {
|
|
55
55
|
const root = project ?? makeProject({ config, files });
|
|
56
|
-
const
|
|
56
|
+
const options = {
|
|
57
57
|
input: typeof payload === 'string' ? payload : JSON.stringify(payload),
|
|
58
58
|
encoding: 'utf8',
|
|
59
59
|
cwd: cwd ?? root,
|
|
@@ -65,9 +65,20 @@ export function runGateProcess(
|
|
|
65
65
|
...environment,
|
|
66
66
|
},
|
|
67
67
|
timeout,
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
const out = execFileSync(process.execPath, [gatePath], options);
|
|
71
|
+
const trimmed = out.trim();
|
|
72
|
+
return trimmed ? JSON.parse(trimmed) : null;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error.status === 2) {
|
|
75
|
+
const stderr = String(error.stderr ?? '').trim();
|
|
76
|
+
const stdout = String(error.stdout ?? '').trim();
|
|
77
|
+
const source = stderr || stdout;
|
|
78
|
+
return source ? JSON.parse(source) : null;
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
/** 'deny' | 'warn' | 'block' | null from a gate's parsed output. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tasks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Deterministic task tracking for Claude Code: persists tasks the model registers via the CLI, reminds of open tasks on a message counter, and lists active tasks on session start.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Devrik"
|
|
@@ -170,6 +170,12 @@ export function openTaskStore(startDirectory) {
|
|
|
170
170
|
writeCollection(activePath, collection);
|
|
171
171
|
return task;
|
|
172
172
|
},
|
|
173
|
+
/** Active sub-tasks whose parentId equals the given id. */
|
|
174
|
+
childrenOf(id) {
|
|
175
|
+
return readCollection(activePath).tasks.filter(
|
|
176
|
+
(task) => task.parentId === id,
|
|
177
|
+
);
|
|
178
|
+
},
|
|
173
179
|
/** Merges fields into the active task with matching id. Null if not found. */
|
|
174
180
|
update(id, fields) {
|
|
175
181
|
const collection = readCollection(activePath);
|
|
@@ -77,17 +77,44 @@ function readPayload() {
|
|
|
77
77
|
|
|
78
78
|
// Asked on every message. The hook does not classify — it asks the model to, and the
|
|
79
79
|
// model is the one that persists (via the CLI, which enforces the store's own rules,
|
|
80
|
-
// e.g. evidence on close).
|
|
81
|
-
//
|
|
82
|
-
// judgment call (whether this message describes a new task), same shape as ask-adoption.
|
|
80
|
+
// e.g. evidence on close). Default to registering: the USER defines what is a task, not
|
|
81
|
+
// the model. The model must not use judgment to skip what the user considers actionable.
|
|
83
82
|
const CLASSIFY_PROMPT =
|
|
84
|
-
|
|
85
|
-
'
|
|
86
|
-
'
|
|
87
|
-
'
|
|
88
|
-
|
|
89
|
-
'
|
|
90
|
-
'
|
|
83
|
+
"[tasks] MANDATORY — before writing your reply, you MUST register the user's message as a " +
|
|
84
|
+
'task unless it is UNAMBIGUOUSLY one of these: (a) pure small talk with no request ("hello", ' +
|
|
85
|
+
'"thanks"), (b) a yes/no answer to a question YOU asked, (c) a message that says only "continue" ' +
|
|
86
|
+
'or "go ahead". Everything else is a task — including questions that require research, review ' +
|
|
87
|
+
'requests, error reports, follow-ups that add scope, corrections, and messages with multiple ' +
|
|
88
|
+
'requests (register one task per distinct request). DEFAULT TO REGISTERING: when in doubt, ' +
|
|
89
|
+
'register.\n\n' +
|
|
90
|
+
'VERIFICATION REQUIRED: every task MUST include a deterministic verification criterion. Use ' +
|
|
91
|
+
'one of these:\n' +
|
|
92
|
+
' --verify-command "<shell command>" [--verify-expect <text>] (command must exit 0 when done)\n' +
|
|
93
|
+
' --verify-path <file-or-dir> [--verify-contains <text>] (must exist when done)\n' +
|
|
94
|
+
'Examples:\n' +
|
|
95
|
+
' claude-gates task add "Fix login bug" --verify-command "npm test -- --grep login" --verify-expect "passing"\n' +
|
|
96
|
+
' claude-gates task add "Add config file" --verify-path "src/config.ts" --verify-contains "export"\n' +
|
|
97
|
+
'Pick the criterion that a machine can check: a test that passes, a file that exists, a grep ' +
|
|
98
|
+
'that matches. If the task is a question/research, use --verify-path for the file where the ' +
|
|
99
|
+
'answer will be written, or --verify-command "claude-gates task list" --verify-expect "done".\n\n' +
|
|
100
|
+
'SPLITTING (Depth Tree): tasks with size medium or larger MUST be split into sub-tasks before ' +
|
|
101
|
+
'implementation. SCOPE FIRST: if the task description is vague or you are unsure what files or ' +
|
|
102
|
+
'modules are affected, ASK THE USER to clarify the scope before splitting — do not guess. ' +
|
|
103
|
+
'Once scope is clear:\n' +
|
|
104
|
+
' 1. Each sub-task OWNS specific files (state in --description "OWNS: <paths>") — no overlap\n' +
|
|
105
|
+
' 2. Each sub-task is --size small and independently verifiable\n' +
|
|
106
|
+
' 3. Split at natural boundaries: one module, one function, one test file\n' +
|
|
107
|
+
' 4. Register parent first, then sub-tasks with --parent <parent-id>\n' +
|
|
108
|
+
'Example:\n' +
|
|
109
|
+
' claude-gates task add "Refactor auth" --size large --verify-command "npm test" --verify-expect "passing"\n' +
|
|
110
|
+
' claude-gates task add "Extract token validation" --parent <id> --size small ' +
|
|
111
|
+
'--verify-path "src/auth/validate.ts" --description "OWNS: src/auth/validate.ts"\n' +
|
|
112
|
+
' claude-gates task add "Add token refresh" --parent <id> --size small ' +
|
|
113
|
+
'--verify-command "npm test -- --grep refresh" --description "OWNS: src/auth/refresh.ts"\n\n' +
|
|
114
|
+
'Run `claude-gates task add "<title>" [--description <text>] [--size <size>] --verify-command|--verify-path ...` ' +
|
|
115
|
+
"from the project root (or the CLI's absolute path if `claude-gates` is not on PATH). Do not defer " +
|
|
116
|
+
'this, do not decide to register it "later", do not silently skip it because the answer seems ' +
|
|
117
|
+
"obvious. The user's flow takes priority over your judgment of what deserves tracking.";
|
|
91
118
|
|
|
92
119
|
function formatReminder(tasks) {
|
|
93
120
|
const shown = tasks.slice(0, MAX_TASKS_SHOWN);
|