@euanmsm/preflight 0.1.0 → 0.2.1
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 +42 -4
- package/package.json +2 -2
- package/preflight.example.json +8 -1
- package/src/gate.mjs +110 -19
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,11 +62,40 @@ 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
|
+
|
|
86
|
+
## Subagents
|
|
87
|
+
|
|
88
|
+
A subagent or workflow agent must load the skills itself. The gate checks the
|
|
89
|
+
calling agent's own transcript, so skills the main session loaded do not count
|
|
90
|
+
for its subagents. That is deliberate: a subagent never sees the main session's
|
|
91
|
+
context, so it has not read those conventions either.
|
|
92
|
+
|
|
56
93
|
## When it does not block
|
|
57
94
|
|
|
58
|
-
The gate fails open. A missing map, an unreadable transcript, a
|
|
59
|
-
|
|
60
|
-
a tool that cannot do its job.
|
|
95
|
+
The gate fails open. A missing map, an unreadable transcript, a subagent
|
|
96
|
+
transcript it cannot find, a file outside the repository or a malformed payload
|
|
97
|
+
all allow the edit rather than halting work on a tool that cannot do its job.
|
|
98
|
+
`PREFLIGHT=off` disables it for one command.
|
|
61
99
|
|
|
62
100
|
Deliberate: a gate that breaks your session when its own config has a typo is a
|
|
63
101
|
gate you will remove within the week.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@euanmsm/preflight",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Claude Code PreToolUse hook that blocks an edit until
|
|
3
|
+
"version": "0.2.1",
|
|
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,12 +2,14 @@
|
|
|
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 by the agent making it — by the tool's name when a tool rule matches,
|
|
7
|
+
// otherwise by the path of the file it writes. A subagent is checked against
|
|
8
|
+
// its own transcript, not its parent's. Fails open on any error.
|
|
7
9
|
|
|
8
10
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
9
11
|
import { homedir } from 'node:os';
|
|
10
|
-
import { join, relative } from 'node:path';
|
|
12
|
+
import { basename, dirname, join, relative } from 'node:path';
|
|
11
13
|
|
|
12
14
|
import { compile, loadConfig, repoRoot } from '@euanmsm/devkit-core';
|
|
13
15
|
|
|
@@ -18,7 +20,11 @@ function allow() {
|
|
|
18
20
|
process.exit(0);
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Blocks the tool call, showing the agent which skills to load.
|
|
25
|
+
*
|
|
26
|
+
* @param reason - Which skills the edit needs first
|
|
27
|
+
*/
|
|
22
28
|
function deny(reason) {
|
|
23
29
|
process.stdout.write(
|
|
24
30
|
JSON.stringify({
|
|
@@ -33,12 +39,26 @@ function deny(reason) {
|
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
|
-
* Locates the
|
|
42
|
+
* Locates the transcript of the agent making the tool call.
|
|
37
43
|
*
|
|
38
44
|
* @param input - The hook payload
|
|
39
45
|
* @returns Path to the transcript, or null when it cannot be found
|
|
40
46
|
*/
|
|
41
47
|
export function findTranscript(input) {
|
|
48
|
+
const session = findSessionTranscript(input);
|
|
49
|
+
if (!session || !input.agent_id) return session;
|
|
50
|
+
|
|
51
|
+
return findAgentTranscript(session, input.agent_id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Locates the main session's transcript, which the payload names even when a
|
|
56
|
+
* subagent makes the call.
|
|
57
|
+
*
|
|
58
|
+
* @param input - The hook payload
|
|
59
|
+
* @returns Path to the transcript, or null when it cannot be found
|
|
60
|
+
*/
|
|
61
|
+
function findSessionTranscript(input) {
|
|
42
62
|
if (input.transcript_path && existsSync(input.transcript_path))
|
|
43
63
|
return input.transcript_path;
|
|
44
64
|
|
|
@@ -53,6 +73,30 @@ export function findTranscript(input) {
|
|
|
53
73
|
return null;
|
|
54
74
|
}
|
|
55
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Locates a subagent's transcript beside its session's.
|
|
78
|
+
*
|
|
79
|
+
* @param sessionTranscript - Path to the main session's transcript
|
|
80
|
+
* @param agentId - The subagent's id from the hook payload
|
|
81
|
+
* @returns Path to the transcript, or null when it cannot be found
|
|
82
|
+
*/
|
|
83
|
+
export function findAgentTranscript(sessionTranscript, agentId) {
|
|
84
|
+
const subagents = join(
|
|
85
|
+
dirname(sessionTranscript),
|
|
86
|
+
basename(sessionTranscript, '.jsonl'),
|
|
87
|
+
'subagents',
|
|
88
|
+
);
|
|
89
|
+
if (!existsSync(subagents)) return null;
|
|
90
|
+
|
|
91
|
+
// Workflow agents sit one level down, under workflows/<run>/.
|
|
92
|
+
const name = `agent-${agentId}.jsonl`;
|
|
93
|
+
const hit = readdirSync(subagents, { recursive: true }).find(
|
|
94
|
+
(entry) => basename(entry) === name,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return hit ? join(subagents, hit) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
56
100
|
/**
|
|
57
101
|
* Reads every skill loaded in a transcript.
|
|
58
102
|
*
|
|
@@ -95,7 +139,50 @@ export function requiredFor(rel, map) {
|
|
|
95
139
|
return [...required];
|
|
96
140
|
}
|
|
97
141
|
|
|
98
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* Names the skills a tool requires, whatever file it touches.
|
|
144
|
+
*
|
|
145
|
+
* @param tool - The tool name from the hook payload
|
|
146
|
+
* @param map - Parsed skill map
|
|
147
|
+
* @returns Required skill names, empty when no tool rule matches
|
|
148
|
+
*/
|
|
149
|
+
export function requiredForTool(tool, map) {
|
|
150
|
+
// First match wins, like primary.
|
|
151
|
+
const hit = (map.tools ?? []).find((rule) => matches(rule.pattern, tool));
|
|
152
|
+
return hit ? [...hit.skills] : [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Finds what a tool call needs and what to call it in the deny message.
|
|
157
|
+
*
|
|
158
|
+
* @param input - The hook payload
|
|
159
|
+
* @param map - Parsed skill map
|
|
160
|
+
* @param root - Repository root
|
|
161
|
+
* @returns The tool name or path, and its skills; null when nothing governs it
|
|
162
|
+
*/
|
|
163
|
+
function gateFor(input, map, root) {
|
|
164
|
+
const tool = input?.tool_name ?? '';
|
|
165
|
+
const toolSkills = requiredForTool(tool, map);
|
|
166
|
+
if (toolSkills.length > 0) return { subject: tool, skills: toolSkills };
|
|
167
|
+
|
|
168
|
+
const filePath = input?.tool_input?.file_path;
|
|
169
|
+
if (!filePath) return null;
|
|
170
|
+
|
|
171
|
+
const rel = relative(root, filePath);
|
|
172
|
+
|
|
173
|
+
// A file in another checkout is not this repo's to govern.
|
|
174
|
+
if (rel.startsWith('..')) return null;
|
|
175
|
+
|
|
176
|
+
return { subject: rel, skills: requiredFor(rel, map) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Tests one path against one pattern.
|
|
181
|
+
*
|
|
182
|
+
* @param pattern - A regex from the config
|
|
183
|
+
* @param rel - Repo-relative path
|
|
184
|
+
* @returns Whether it matches, and false when the pattern will not compile
|
|
185
|
+
*/
|
|
99
186
|
function matches(pattern, rel) {
|
|
100
187
|
return compile([pattern]).some((p) => p.test(rel));
|
|
101
188
|
}
|
|
@@ -105,34 +192,38 @@ export function main() {
|
|
|
105
192
|
if (process.env.PREFLIGHT === 'off') allow();
|
|
106
193
|
|
|
107
194
|
const input = JSON.parse(readFileSync(0, 'utf8'));
|
|
108
|
-
const filePath = input?.tool_input?.file_path;
|
|
109
|
-
if (!filePath) allow();
|
|
110
|
-
|
|
111
|
-
const root = repoRoot();
|
|
112
|
-
const rel = relative(root, filePath);
|
|
113
195
|
|
|
114
|
-
//
|
|
115
|
-
|
|
196
|
+
// Outside a repository there is no map to read, so nothing is governed.
|
|
197
|
+
let root;
|
|
198
|
+
try {
|
|
199
|
+
root = repoRoot();
|
|
200
|
+
} catch {
|
|
201
|
+
allow();
|
|
202
|
+
}
|
|
116
203
|
|
|
117
204
|
const map = loadConfig(CONFIG_NAME, null, root);
|
|
118
205
|
if (!map) allow();
|
|
119
206
|
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
207
|
+
const gate = gateFor(input, map, root);
|
|
208
|
+
if (!gate || gate.skills.length === 0) allow();
|
|
122
209
|
|
|
123
210
|
const transcript = findTranscript(input);
|
|
124
211
|
if (!transcript) allow();
|
|
125
212
|
|
|
126
213
|
const loaded = loadedSkills(transcript);
|
|
127
|
-
const missing =
|
|
214
|
+
const missing = gate.skills.filter((s) => !loaded.has(s));
|
|
128
215
|
if (missing.length === 0) allow();
|
|
129
216
|
|
|
130
217
|
const calls = missing.map((s) => ` Skill(skill: "${s}")`).join('\n');
|
|
218
|
+
const scope = input.agent_id
|
|
219
|
+
? `Skills loaded by the parent session do not count here — load them yourself.\n\n`
|
|
220
|
+
: '';
|
|
131
221
|
|
|
132
222
|
deny(
|
|
133
|
-
`BLOCKED — ${
|
|
134
|
-
|
|
135
|
-
`
|
|
223
|
+
`BLOCKED — ${gate.subject} is governed by convention skills you have not loaded this session.\n\n` +
|
|
224
|
+
scope +
|
|
225
|
+
`Load them, then make this call again:\n${calls}\n\n` +
|
|
226
|
+
`These skills hold the conventions this call must follow. Do not work around this by ` +
|
|
136
227
|
`writing from memory. The mapping lives in .devkit/${CONFIG_NAME}.`,
|
|
137
228
|
);
|
|
138
229
|
}
|