@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 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 file outside the
59
- repository or a malformed payload all allow the edit rather than halting work on
60
- a tool that cannot do its job. `PREFLIGHT=off` disables it for one command.
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.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.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": {
@@ -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,12 +2,14 @@
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 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
- /** Blocks the tool call, showing the agent which skills to load. */
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 session transcript the hook payload belongs to.
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
- /** True when a pattern compiles and matches, false when it does neither. */
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
- // A file in another checkout is not this repo's to govern.
115
- if (rel.startsWith('..')) allow();
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 required = requiredFor(rel, map);
121
- if (required.length === 0) allow();
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 = required.filter((s) => !loaded.has(s));
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 — ${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 ` +
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
  }