@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 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.1.0",
4
- "description": "Claude Code PreToolUse hook that blocks an edit until the file's governing convention skill has been loaded",
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": {
@@ -1,5 +1,12 @@
1
1
  {
2
- "_readme": "Path-to-skill map. Patterns are JS regexes tested against the repo-relative path. `primary` is first-match-wins, so order matters — most specific first. `universal` rules always add on top.",
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 on Edit and Write. Blocks the edit until the file's required
6
- // convention skills have been loaded this session. Fails open on any error.
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
- /** Blocks the tool call, showing the agent which skills to load. */
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
- /** True when a pattern compiles and matches, false when it does neither. */
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
- const root = repoRoot();
112
- const rel = relative(root, filePath);
113
-
114
- // A file in another checkout is not this repo's to govern.
115
- if (rel.startsWith('..')) allow();
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 required = requiredFor(rel, map);
121
- if (required.length === 0) allow();
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 = required.filter((s) => !loaded.has(s));
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 — ${rel} is governed by convention skills you have not loaded this session.\n\n` +
134
- `Load them, then make this edit again:\n${calls}\n\n` +
135
- `These skills hold the conventions this file must follow. Do not work around this by ` +
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
  }