@euanmsm/preflight 0.2.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.
Files changed (3) hide show
  1. package/README.md +11 -3
  2. package/package.json +1 -1
  3. package/src/gate.mjs +47 -4
package/README.md CHANGED
@@ -83,11 +83,19 @@ skills are the only ones the call needs — path rules are not checked for that
83
83
  call. The deny message names the tool when a tool rule matched and the file when
84
84
  a path rule did.
85
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
+
86
93
  ## When it does not block
87
94
 
88
- The gate fails open. A missing map, an unreadable transcript, a file outside the
89
- repository or a malformed payload all allow the edit rather than halting work on
90
- 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.
91
99
 
92
100
  Deliberate: a gate that breaks your session when its own config has a typo is a
93
101
  gate you will remove within the week.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@euanmsm/preflight",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
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",
package/src/gate.mjs CHANGED
@@ -3,12 +3,13 @@
3
3
  // ============================================================================
4
4
  //
5
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.
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.
8
9
 
9
10
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
10
11
  import { homedir } from 'node:os';
11
- import { join, relative } from 'node:path';
12
+ import { basename, dirname, join, relative } from 'node:path';
12
13
 
13
14
  import { compile, loadConfig, repoRoot } from '@euanmsm/devkit-core';
14
15
 
@@ -38,12 +39,26 @@ function deny(reason) {
38
39
  }
39
40
 
40
41
  /**
41
- * Locates the session transcript the hook payload belongs to.
42
+ * Locates the transcript of the agent making the tool call.
42
43
  *
43
44
  * @param input - The hook payload
44
45
  * @returns Path to the transcript, or null when it cannot be found
45
46
  */
46
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) {
47
62
  if (input.transcript_path && existsSync(input.transcript_path))
48
63
  return input.transcript_path;
49
64
 
@@ -58,6 +73,30 @@ export function findTranscript(input) {
58
73
  return null;
59
74
  }
60
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
+
61
100
  /**
62
101
  * Reads every skill loaded in a transcript.
63
102
  *
@@ -176,9 +215,13 @@ export function main() {
176
215
  if (missing.length === 0) allow();
177
216
 
178
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
+ : '';
179
221
 
180
222
  deny(
181
223
  `BLOCKED — ${gate.subject} is governed by convention skills you have not loaded this session.\n\n` +
224
+ scope +
182
225
  `Load them, then make this call again:\n${calls}\n\n` +
183
226
  `These skills hold the conventions this call must follow. Do not work around this by ` +
184
227
  `writing from memory. The mapping lives in .devkit/${CONFIG_NAME}.`,