@euanmsm/preflight 0.1.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 ADDED
@@ -0,0 +1,63 @@
1
+ # @euanmsm/preflight
2
+
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.
5
+
6
+ The problem it solves: an agent that has not opened your conventions writes code
7
+ from memory of how code is usually written, not how _your_ code is written. You
8
+ then spend the review asking for the same changes you asked for last week. This
9
+ turns the conventions from something an agent might read into something it must.
10
+
11
+ ## Installing
12
+
13
+ ```sh
14
+ npm i -D @euanmsm/preflight
15
+ mkdir -p .devkit
16
+ cp node_modules/@euanmsm/preflight/preflight.example.json .devkit/preflight.json
17
+ ```
18
+
19
+ Then register it as a `PreToolUse` hook in `.claude/settings.json`:
20
+
21
+ ```json
22
+ {
23
+ "hooks": {
24
+ "PreToolUse": [
25
+ {
26
+ "matcher": "Edit|Write",
27
+ "hooks": [{ "type": "command", "command": "npx preflight" }]
28
+ }
29
+ ]
30
+ }
31
+ }
32
+ ```
33
+
34
+ ## The map
35
+
36
+ `.devkit/preflight.json` says which skills each path needs. Patterns are regular
37
+ expressions tested against the path relative to the repository root.
38
+
39
+ ```json
40
+ {
41
+ "exclude": ["node_modules/", "\\.d\\.ts$"],
42
+ "primary": [
43
+ { "pattern": "^src/api/", "skills": ["api-routes"] },
44
+ { "pattern": "^src/", "skills": ["readability"] }
45
+ ],
46
+ "universal": [{ "pattern": "\\.(tsx?|mjs)$", "skills": ["comments"] }]
47
+ }
48
+ ```
49
+
50
+ `exclude` wins over everything. `primary` is **first match wins**, so list the
51
+ most specific pattern first — `^src/api/` above `^src/`, never the other way
52
+ round. `universal` rules always add on top of whichever primary rule matched.
53
+
54
+ A path no rule names requires nothing, so the gate is opt-in per directory.
55
+
56
+ ## When it does not block
57
+
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.
61
+
62
+ Deliberate: a gate that breaks your session when its own config has a typo is a
63
+ gate you will remove within the week.
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/gate.mjs';
3
+
4
+ // A missing config, an unreadable transcript or a malformed payload allows the edit.
5
+ try {
6
+ main();
7
+ } catch (error) {
8
+ process.stderr.write(
9
+ `preflight: edit allowed unchecked — ${error.message}\n`,
10
+ );
11
+ process.exit(0);
12
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
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",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/euanmsm/devkit.git",
10
+ "directory": "packages/preflight"
11
+ },
12
+ "bin": {
13
+ "preflight": "bin/preflight.mjs"
14
+ },
15
+ "exports": {
16
+ ".": "./src/gate.mjs"
17
+ },
18
+ "files": [
19
+ "bin",
20
+ "src",
21
+ "preflight.example.json"
22
+ ],
23
+ "engines": {
24
+ "node": ">=22.11"
25
+ },
26
+ "dependencies": {
27
+ "@euanmsm/devkit-core": "0.1.0"
28
+ }
29
+ }
@@ -0,0 +1,23 @@
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.",
3
+
4
+ "exclude": [
5
+ "node_modules/",
6
+ "^tmp/",
7
+ "\\.tmp\\.",
8
+ "\\.min\\.[cm]?js$",
9
+ "\\.d\\.ts$"
10
+ ],
11
+
12
+ "primary": [
13
+ { "pattern": "^_CONVENTIONS/", "skills": ["conventions"] },
14
+ { "pattern": "(^|/)README\\.md$", "skills": ["readmes"] }
15
+ ],
16
+
17
+ "universal": [
18
+ {
19
+ "pattern": "\\.(tsx?|mjs|cjs|js)$",
20
+ "skills": ["comments", "readability"]
21
+ }
22
+ ]
23
+ }
package/src/gate.mjs ADDED
@@ -0,0 +1,138 @@
1
+ // ============================================================================
2
+ // Skill Gate
3
+ // ============================================================================
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.
7
+
8
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import { join, relative } from 'node:path';
11
+
12
+ import { compile, loadConfig, repoRoot } from '@euanmsm/devkit-core';
13
+
14
+ const CONFIG_NAME = 'preflight.json';
15
+
16
+ /** Anything other than an explicit deny lets the tool call through. */
17
+ function allow() {
18
+ process.exit(0);
19
+ }
20
+
21
+ /** Blocks the tool call, showing the agent which skills to load. */
22
+ function deny(reason) {
23
+ process.stdout.write(
24
+ JSON.stringify({
25
+ hookSpecificOutput: {
26
+ hookEventName: 'PreToolUse',
27
+ permissionDecision: 'deny',
28
+ permissionDecisionReason: reason,
29
+ },
30
+ }),
31
+ );
32
+ process.exit(0);
33
+ }
34
+
35
+ /**
36
+ * Locates the session transcript the hook payload belongs to.
37
+ *
38
+ * @param input - The hook payload
39
+ * @returns Path to the transcript, or null when it cannot be found
40
+ */
41
+ export function findTranscript(input) {
42
+ if (input.transcript_path && existsSync(input.transcript_path))
43
+ return input.transcript_path;
44
+
45
+ const projects = join(homedir(), '.claude', 'projects');
46
+ if (!input.session_id || !existsSync(projects)) return null;
47
+
48
+ for (const dir of readdirSync(projects)) {
49
+ const candidate = join(projects, dir, `${input.session_id}.jsonl`);
50
+ if (existsSync(candidate)) return candidate;
51
+ }
52
+
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Reads every skill loaded in a transcript.
58
+ *
59
+ * @param transcriptPath - Path to the session's JSONL transcript
60
+ * @returns Skill names, one per Skill tool call
61
+ */
62
+ export function loadedSkills(transcriptPath) {
63
+ const raw = readFileSync(transcriptPath, 'utf8');
64
+ const skills = new Set();
65
+
66
+ for (const match of raw.matchAll(
67
+ /"name":"Skill","input":\{"skill":"([^"]+)"/g,
68
+ )) {
69
+ skills.add(match[1]);
70
+ }
71
+
72
+ return skills;
73
+ }
74
+
75
+ /**
76
+ * Names the skills a path requires.
77
+ *
78
+ * @param rel - Repo-relative path of the edited file
79
+ * @param map - Parsed skill map
80
+ * @returns Required skill names, empty when the path is ungoverned
81
+ */
82
+ export function requiredFor(rel, map) {
83
+ if (compile(map.exclude).some((p) => p.test(rel))) return [];
84
+
85
+ const required = new Set();
86
+
87
+ // First match wins, so the map lists the most specific pattern first.
88
+ const hit = (map.primary ?? []).find((rule) => matches(rule.pattern, rel));
89
+ if (hit) hit.skills.forEach((s) => required.add(s));
90
+
91
+ for (const rule of map.universal ?? []) {
92
+ if (matches(rule.pattern, rel)) rule.skills.forEach((s) => required.add(s));
93
+ }
94
+
95
+ return [...required];
96
+ }
97
+
98
+ /** True when a pattern compiles and matches, false when it does neither. */
99
+ function matches(pattern, rel) {
100
+ return compile([pattern]).some((p) => p.test(rel));
101
+ }
102
+
103
+ /** Reads the hook payload and denies when a required skill is missing. */
104
+ export function main() {
105
+ if (process.env.PREFLIGHT === 'off') allow();
106
+
107
+ 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
+
114
+ // A file in another checkout is not this repo's to govern.
115
+ if (rel.startsWith('..')) allow();
116
+
117
+ const map = loadConfig(CONFIG_NAME, null, root);
118
+ if (!map) allow();
119
+
120
+ const required = requiredFor(rel, map);
121
+ if (required.length === 0) allow();
122
+
123
+ const transcript = findTranscript(input);
124
+ if (!transcript) allow();
125
+
126
+ const loaded = loadedSkills(transcript);
127
+ const missing = required.filter((s) => !loaded.has(s));
128
+ if (missing.length === 0) allow();
129
+
130
+ const calls = missing.map((s) => ` Skill(skill: "${s}")`).join('\n');
131
+
132
+ 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 ` +
136
+ `writing from memory. The mapping lives in .devkit/${CONFIG_NAME}.`,
137
+ );
138
+ }