@euanmsm/preflight 0.1.0 → 0.2.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.md +31 -1
- package/package.json +2 -2
- package/preflight.example.json +8 -1
- package/src/gate.mjs +65 -17
package/README.md
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# @euanmsm/preflight
|
|
2
2
|
|
|
3
3
|
A Claude Code hook that blocks an edit until the agent has read the conventions
|
|
4
|
-
governing the file it is about to change
|
|
4
|
+
governing the file it is about to change — or blocks any other tool call, such
|
|
5
|
+
as an MCP write, until it has read the conventions for that tool.
|
|
5
6
|
|
|
6
7
|
The problem it solves: an agent that has not opened your conventions writes code
|
|
7
8
|
from memory of how code is usually written, not how _your_ code is written. You
|
|
@@ -25,12 +26,20 @@ Then register it as a `PreToolUse` hook in `.claude/settings.json`:
|
|
|
25
26
|
{
|
|
26
27
|
"matcher": "Edit|Write",
|
|
27
28
|
"hooks": [{ "type": "command", "command": "npx preflight" }]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"matcher": "mcp__.*[Ll]inear.*__save_(issue|comment|document|project)",
|
|
32
|
+
"hooks": [{ "type": "command", "command": "npx preflight" }]
|
|
28
33
|
}
|
|
29
34
|
]
|
|
30
35
|
}
|
|
31
36
|
}
|
|
32
37
|
```
|
|
33
38
|
|
|
39
|
+
The second entry is only needed for `tools` rules. The hook only runs for tools
|
|
40
|
+
its `matcher` lets through, so a `tools` rule for a tool no matcher covers never
|
|
41
|
+
fires. Keep the matcher and the rule's pattern in step.
|
|
42
|
+
|
|
34
43
|
## The map
|
|
35
44
|
|
|
36
45
|
`.devkit/preflight.json` says which skills each path needs. Patterns are regular
|
|
@@ -53,6 +62,27 @@ round. `universal` rules always add on top of whichever primary rule matched.
|
|
|
53
62
|
|
|
54
63
|
A path no rule names requires nothing, so the gate is opt-in per directory.
|
|
55
64
|
|
|
65
|
+
### Gating a tool by name
|
|
66
|
+
|
|
67
|
+
`tools` rules match the tool name instead of a path, so they can gate calls that
|
|
68
|
+
write no file at all:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"tools": [
|
|
73
|
+
{
|
|
74
|
+
"pattern": "^mcp__.*[Ll]inear.*__save_(issue|comment|document|project)$",
|
|
75
|
+
"skills": ["linear"]
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`tools` is first match wins, like `primary`. When a tool rule matches, its
|
|
82
|
+
skills are the only ones the call needs — path rules are not checked for that
|
|
83
|
+
call. The deny message names the tool when a tool rule matched and the file when
|
|
84
|
+
a path rule did.
|
|
85
|
+
|
|
56
86
|
## When it does not block
|
|
57
87
|
|
|
58
88
|
The gate fails open. A missing map, an unreadable transcript, a file outside the
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@euanmsm/preflight",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Claude Code PreToolUse hook that blocks an edit until
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Claude Code PreToolUse hook that blocks an edit or tool call until its governing convention skill has been loaded",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
package/preflight.example.json
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_readme": "
|
|
2
|
+
"_readme": "Skill map. `tools` patterns are JS regexes tested against the tool name, and a match gates the call whatever file it touches. Every other pattern is tested against the repo-relative path. `tools` and `primary` are first-match-wins, so order matters — most specific first. `universal` rules always add on top.",
|
|
3
|
+
|
|
4
|
+
"tools": [
|
|
5
|
+
{
|
|
6
|
+
"pattern": "^mcp__.*[Ll]inear.*__save_(issue|comment|document|project)$",
|
|
7
|
+
"skills": ["linear"]
|
|
8
|
+
}
|
|
9
|
+
],
|
|
3
10
|
|
|
4
11
|
"exclude": [
|
|
5
12
|
"node_modules/",
|
package/src/gate.mjs
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Skill Gate
|
|
3
3
|
// ============================================================================
|
|
4
4
|
//
|
|
5
|
-
// PreToolUse hook
|
|
6
|
-
//
|
|
5
|
+
// PreToolUse hook. Blocks a tool call until the skills it needs have been
|
|
6
|
+
// loaded this session — by the tool's name when a tool rule matches, otherwise
|
|
7
|
+
// by the path of the file it writes. Fails open on any error.
|
|
7
8
|
|
|
8
9
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
9
10
|
import { homedir } from 'node:os';
|
|
@@ -18,7 +19,11 @@ function allow() {
|
|
|
18
19
|
process.exit(0);
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Blocks the tool call, showing the agent which skills to load.
|
|
24
|
+
*
|
|
25
|
+
* @param reason - Which skills the edit needs first
|
|
26
|
+
*/
|
|
22
27
|
function deny(reason) {
|
|
23
28
|
process.stdout.write(
|
|
24
29
|
JSON.stringify({
|
|
@@ -95,7 +100,50 @@ export function requiredFor(rel, map) {
|
|
|
95
100
|
return [...required];
|
|
96
101
|
}
|
|
97
102
|
|
|
98
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Names the skills a tool requires, whatever file it touches.
|
|
105
|
+
*
|
|
106
|
+
* @param tool - The tool name from the hook payload
|
|
107
|
+
* @param map - Parsed skill map
|
|
108
|
+
* @returns Required skill names, empty when no tool rule matches
|
|
109
|
+
*/
|
|
110
|
+
export function requiredForTool(tool, map) {
|
|
111
|
+
// First match wins, like primary.
|
|
112
|
+
const hit = (map.tools ?? []).find((rule) => matches(rule.pattern, tool));
|
|
113
|
+
return hit ? [...hit.skills] : [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Finds what a tool call needs and what to call it in the deny message.
|
|
118
|
+
*
|
|
119
|
+
* @param input - The hook payload
|
|
120
|
+
* @param map - Parsed skill map
|
|
121
|
+
* @param root - Repository root
|
|
122
|
+
* @returns The tool name or path, and its skills; null when nothing governs it
|
|
123
|
+
*/
|
|
124
|
+
function gateFor(input, map, root) {
|
|
125
|
+
const tool = input?.tool_name ?? '';
|
|
126
|
+
const toolSkills = requiredForTool(tool, map);
|
|
127
|
+
if (toolSkills.length > 0) return { subject: tool, skills: toolSkills };
|
|
128
|
+
|
|
129
|
+
const filePath = input?.tool_input?.file_path;
|
|
130
|
+
if (!filePath) return null;
|
|
131
|
+
|
|
132
|
+
const rel = relative(root, filePath);
|
|
133
|
+
|
|
134
|
+
// A file in another checkout is not this repo's to govern.
|
|
135
|
+
if (rel.startsWith('..')) return null;
|
|
136
|
+
|
|
137
|
+
return { subject: rel, skills: requiredFor(rel, map) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Tests one path against one pattern.
|
|
142
|
+
*
|
|
143
|
+
* @param pattern - A regex from the config
|
|
144
|
+
* @param rel - Repo-relative path
|
|
145
|
+
* @returns Whether it matches, and false when the pattern will not compile
|
|
146
|
+
*/
|
|
99
147
|
function matches(pattern, rel) {
|
|
100
148
|
return compile([pattern]).some((p) => p.test(rel));
|
|
101
149
|
}
|
|
@@ -105,34 +153,34 @@ export function main() {
|
|
|
105
153
|
if (process.env.PREFLIGHT === 'off') allow();
|
|
106
154
|
|
|
107
155
|
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
108
|
-
const filePath = input?.tool_input?.file_path;
|
|
109
|
-
if (!filePath) allow();
|
|
110
156
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
157
|
+
// Outside a repository there is no map to read, so nothing is governed.
|
|
158
|
+
let root;
|
|
159
|
+
try {
|
|
160
|
+
root = repoRoot();
|
|
161
|
+
} catch {
|
|
162
|
+
allow();
|
|
163
|
+
}
|
|
116
164
|
|
|
117
165
|
const map = loadConfig(CONFIG_NAME, null, root);
|
|
118
166
|
if (!map) allow();
|
|
119
167
|
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
168
|
+
const gate = gateFor(input, map, root);
|
|
169
|
+
if (!gate || gate.skills.length === 0) allow();
|
|
122
170
|
|
|
123
171
|
const transcript = findTranscript(input);
|
|
124
172
|
if (!transcript) allow();
|
|
125
173
|
|
|
126
174
|
const loaded = loadedSkills(transcript);
|
|
127
|
-
const missing =
|
|
175
|
+
const missing = gate.skills.filter((s) => !loaded.has(s));
|
|
128
176
|
if (missing.length === 0) allow();
|
|
129
177
|
|
|
130
178
|
const calls = missing.map((s) => ` Skill(skill: "${s}")`).join('\n');
|
|
131
179
|
|
|
132
180
|
deny(
|
|
133
|
-
`BLOCKED — ${
|
|
134
|
-
`Load them, then make this
|
|
135
|
-
`These skills hold the conventions this
|
|
181
|
+
`BLOCKED — ${gate.subject} is governed by convention skills you have not loaded this session.\n\n` +
|
|
182
|
+
`Load them, then make this call again:\n${calls}\n\n` +
|
|
183
|
+
`These skills hold the conventions this call must follow. Do not work around this by ` +
|
|
136
184
|
`writing from memory. The mapping lives in .devkit/${CONFIG_NAME}.`,
|
|
137
185
|
);
|
|
138
186
|
}
|