@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.
- package/README.es.md +39 -4
- package/README.md +39 -4
- 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 +1 -0
- package/cli/selection.mjs +23 -1
- package/cli/smoke-fixtures.json +8 -0
- package/package.json +2 -2
- package/plugins/gates/hooks/gates/capability-map/index.mjs +37 -208
- 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 +20 -0
- package/plugins/gates/hooks/lib/capabilities.mjs +401 -0
- package/plugins/gates/hooks/lib/hook-io.mjs +4 -0
- package/plugins/gates/hooks/lib/signals.mjs +91 -0
- package/registry.json +58 -0
package/cli/install.mjs
CHANGED
|
@@ -5,11 +5,19 @@
|
|
|
5
5
|
|
|
6
6
|
import { execFileSync } from 'node:child_process';
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
8
9
|
import { SCOPES } from './config.mjs';
|
|
9
10
|
import { MARKETPLACE_PATH, REPOSITORY_ROOT } from './constants.mjs';
|
|
11
|
+
import { compareVersions, parsePluginList } from './doctor.mjs';
|
|
10
12
|
|
|
11
13
|
const CLAUDE_BIN = 'claude';
|
|
12
14
|
|
|
15
|
+
/** The version of THIS package — what an install is expected to leave behind. */
|
|
16
|
+
function packageVersion() {
|
|
17
|
+
return JSON.parse(readFileSync(join(REPOSITORY_ROOT, 'package.json'), 'utf8'))
|
|
18
|
+
.version;
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
// Config scope decides where the plugin is installed: a project selection stays local to
|
|
14
22
|
// this project (its .claude/settings.json); a global selection installs for every project.
|
|
15
23
|
const PLUGIN_SCOPE = Object.freeze({
|
|
@@ -198,6 +206,47 @@ function removePreviousInstalls(targets, scope, runClaude) {
|
|
|
198
206
|
}
|
|
199
207
|
}
|
|
200
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Whether the install ACTUALLY took, described as a problem string (null when it did).
|
|
211
|
+
*
|
|
212
|
+
* `claude plugin install` exiting 0 is not evidence that anything changed: a marketplace
|
|
213
|
+
* still serving a stale path, a plugin that resolves but is never enabled, or an update
|
|
214
|
+
* that silently no-ops all exit 0 too. Reporting success on the exit code alone is what
|
|
215
|
+
* produced the "it says it worked but nothing updated" failure — the installer claimed a
|
|
216
|
+
* result it had not checked, and only `doctor`, run separately and later, ever noticed.
|
|
217
|
+
*
|
|
218
|
+
* So the post-condition is read back from Claude Code itself: the plugin must now appear
|
|
219
|
+
* in `plugin list`, at a version not older than this package's. Reuses doctor's parser
|
|
220
|
+
* rather than a second one, so what install verifies and what doctor reports can never
|
|
221
|
+
* disagree. A verification that cannot run (no `claude` on PATH, unparseable output) is
|
|
222
|
+
* NOT treated as failure — that would turn a working install into a false alarm; only a
|
|
223
|
+
* definite mismatch is reported.
|
|
224
|
+
*/
|
|
225
|
+
function installationProblem(runClaude, plugin) {
|
|
226
|
+
let listed;
|
|
227
|
+
try {
|
|
228
|
+
listed = parsePluginList(runClaude(['plugin', 'list']));
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
if (listed.length === 0) return null;
|
|
233
|
+
|
|
234
|
+
const found = listed.filter((entry) => entry.plugin === plugin);
|
|
235
|
+
if (found.length === 0)
|
|
236
|
+
return 'the install reported success but the plugin is not in `claude plugin list`';
|
|
237
|
+
|
|
238
|
+
const expected = packageVersion();
|
|
239
|
+
const stale = found.filter(
|
|
240
|
+
(entry) => entry.version && compareVersions(entry.version, expected) < 0,
|
|
241
|
+
);
|
|
242
|
+
if (stale.length === found.length)
|
|
243
|
+
return (
|
|
244
|
+
`still running ${stale[0].version} after installing ${expected} — the marketplace is ` +
|
|
245
|
+
'serving a stale copy. Run `claude plugin marketplace remove`, then re-run init from this package.'
|
|
246
|
+
);
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
201
250
|
/**
|
|
202
251
|
* Registers the marketplace (idempotent: a second add just reports it already exists, which
|
|
203
252
|
* is not fatal) once, then installs EVERY plugin the manifest declares at the scope matching
|
|
@@ -259,10 +308,13 @@ export function installPlugin(
|
|
|
259
308
|
'--scope',
|
|
260
309
|
scope,
|
|
261
310
|
]);
|
|
262
|
-
return { plugin, installed: true };
|
|
263
311
|
} catch (error) {
|
|
264
312
|
return { plugin, installed: false, reason: reasonFor(error) };
|
|
265
313
|
}
|
|
314
|
+
const problem = installationProblem(runClaude, plugin);
|
|
315
|
+
return problem
|
|
316
|
+
? { plugin, installed: false, reason: problem }
|
|
317
|
+
: { plugin, installed: true };
|
|
266
318
|
});
|
|
267
319
|
|
|
268
320
|
const installed = results.every((result) => result.installed);
|
package/cli/registry.mjs
CHANGED
package/cli/selection.mjs
CHANGED
|
@@ -10,8 +10,25 @@ export const MODES = Object.freeze({
|
|
|
10
10
|
GRANULAR: 'granular',
|
|
11
11
|
DEFAULTS: 'defaults',
|
|
12
12
|
NONE: 'none',
|
|
13
|
+
/**
|
|
14
|
+
* Only what this config has never decided about. Resolves exactly like GRANULAR — by the
|
|
15
|
+
* time picks arrive the user has already chosen from a list narrowed to the new gates —
|
|
16
|
+
* but the caller builds that list with `newGatesFor`, so an upgrade can adopt what a
|
|
17
|
+
* release added without re-answering, or silently flipping, anything already in the file.
|
|
18
|
+
*/
|
|
19
|
+
NEW: 'new',
|
|
13
20
|
});
|
|
14
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The gates a config has never decided about: no entry under `gates` for their configKey.
|
|
24
|
+
* An entry set to `false` counts as DECIDED — the user turned it off on purpose, and
|
|
25
|
+
* offering it again as "new" would be how a deliberate opt-out gets undone by an upgrade.
|
|
26
|
+
*/
|
|
27
|
+
export function newGatesFor(registry, existingGates = {}) {
|
|
28
|
+
const decided = new Set(Object.keys(existingGates ?? {}));
|
|
29
|
+
return allGates(registry).filter((gate) => !decided.has(gate.configKey));
|
|
30
|
+
}
|
|
31
|
+
|
|
15
32
|
function assertKnown(chosen, known, kind) {
|
|
16
33
|
const unknown = [...chosen].filter((id) => !known.has(id));
|
|
17
34
|
if (unknown.length > 0)
|
|
@@ -35,6 +52,11 @@ const STRATEGIES = {
|
|
|
35
52
|
gates.filter((gate) => chosen.has(gate.family)).map((gate) => gate.id),
|
|
36
53
|
);
|
|
37
54
|
},
|
|
55
|
+
[MODES.NEW]: (gates, _registry, picks) => {
|
|
56
|
+
const chosen = new Set(picks.gates ?? []);
|
|
57
|
+
assertKnown(chosen, new Set(gates.map((gate) => gate.id)), 'gate');
|
|
58
|
+
return chosen;
|
|
59
|
+
},
|
|
38
60
|
[MODES.GRANULAR]: (gates, _registry, picks) => {
|
|
39
61
|
const chosen = new Set(picks.gates ?? []);
|
|
40
62
|
assertKnown(chosen, new Set(gates.map((gate) => gate.id)), 'gate');
|
|
@@ -93,7 +115,7 @@ export function namedGatesFor(registry, mode, picks = {}) {
|
|
|
93
115
|
const chosen = new Set(picks.families ?? []);
|
|
94
116
|
return keysOf((gate) => chosen.has(gate.family));
|
|
95
117
|
}
|
|
96
|
-
if (mode === MODES.GRANULAR) {
|
|
118
|
+
if (mode === MODES.GRANULAR || mode === MODES.NEW) {
|
|
97
119
|
const chosen = new Set(picks.gates ?? []);
|
|
98
120
|
return keysOf((gate) => chosen.has(gate.id));
|
|
99
121
|
}
|
package/cli/smoke-fixtures.json
CHANGED
|
@@ -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",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devrik-tools/claude-gates",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
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",
|
|
@@ -75,4 +75,4 @@
|
|
|
75
75
|
"globals": "^17.11.0",
|
|
76
76
|
"prettier": "^3.9.6"
|
|
77
77
|
}
|
|
78
|
-
}
|
|
78
|
+
}
|
|
@@ -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 {
|
|
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
|
-
//
|
|
359
|
-
//
|
|
360
|
-
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
286
|
+
workNatureOf(promptOf(payload)),
|
|
287
|
+
settings,
|
|
459
288
|
);
|
|
460
289
|
if (inject) process.stdout.write(renderCatalog(catalog, settings.kinds));
|
|
461
290
|
}
|
|
@@ -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
|
+
}
|