portable-agent-layer 0.63.0 → 0.63.2

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.
@@ -4,33 +4,39 @@
4
4
  "sessionStart": [
5
5
  {
6
6
  "type": "command",
7
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts"
7
+ "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts",
8
+ "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts"
8
9
  }
9
10
  ],
10
11
  "userPromptSubmitted": [
11
12
  {
12
13
  "type": "command",
13
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts"
14
+ "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts",
15
+ "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts"
14
16
  }
15
17
  ],
16
18
  "preToolUse": [
17
19
  {
18
20
  "type": "command",
19
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts"
21
+ "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts",
22
+ "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts"
20
23
  },
21
24
  {
22
25
  "type": "command",
23
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
26
+ "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts",
27
+ "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts"
24
28
  },
25
29
  {
26
30
  "type": "command",
27
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
31
+ "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts",
32
+ "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts"
28
33
  }
29
34
  ],
30
35
  "agentStop": [
31
36
  {
32
37
  "type": "command",
33
- "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts"
38
+ "bash": "PAL_AGENT=copilot bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts",
39
+ "powershell": "$env:PAL_AGENT='copilot'; bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts"
34
40
  }
35
41
  ]
36
42
  }
@@ -0,0 +1,37 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "type": "command",
7
+ "command": "bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts --agent=vscode"
8
+ }
9
+ ],
10
+ "UserPromptSubmit": [
11
+ {
12
+ "type": "command",
13
+ "command": "bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts --agent=vscode"
14
+ }
15
+ ],
16
+ "PreToolUse": [
17
+ {
18
+ "type": "command",
19
+ "command": "bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts --agent=vscode"
20
+ },
21
+ {
22
+ "type": "command",
23
+ "command": "bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts --agent=vscode"
24
+ },
25
+ {
26
+ "type": "command",
27
+ "command": "bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts --agent=vscode"
28
+ }
29
+ ],
30
+ "Stop": [
31
+ {
32
+ "type": "command",
33
+ "command": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts --agent=vscode"
34
+ }
35
+ ]
36
+ }
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.63.0",
3
+ "version": "0.63.2",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -37,7 +37,7 @@ import { homedir } from "node:os";
37
37
  import { resolve } from "node:path";
38
38
  import { inference, previewInferenceRoute } from "../hooks/lib/inference";
39
39
  import { DEBUG_LOG_MAX_ROTATED, logDebug } from "../hooks/lib/log";
40
- import { palHome, palPkg, platform } from "../hooks/lib/paths";
40
+ import { palHome, palPkg, paths, platform } from "../hooks/lib/paths";
41
41
  import { hasRealContent, SETUP_STEPS, STEP_ORDER } from "../hooks/lib/setup";
42
42
  import { log } from "../targets/lib";
43
43
  import { checkPendingMigrations } from "./migrate";
@@ -80,6 +80,20 @@ function checkTool(cmd: string, versionArgs: string[] = ["--version"]): ToolChec
80
80
  return { name: cmd, available: false };
81
81
  }
82
82
 
83
+ /**
84
+ * Copilot ships as a VS Code extension as well as a CLI, and the extension puts
85
+ * no `copilot` binary on PATH — but both read hooks, skills and agents out of
86
+ * ~/.copilot. Fall back to that directory so PAL's copilot checks still run.
87
+ */
88
+ function checkCopilot(): ToolCheck {
89
+ const cli = checkTool("copilot", ["version"]);
90
+ if (cli.available) return cli;
91
+ if (existsSync(platform.copilotDir())) {
92
+ return { name: "copilot", available: true, version: "~/.copilot (no CLI on PATH)" };
93
+ }
94
+ return cli;
95
+ }
96
+
83
97
  function detectAgent(): string | null {
84
98
  if (checkTool("claude").available) return "claude";
85
99
  if (checkTool("opencode").available) return "opencode";
@@ -443,13 +457,18 @@ function checkCopilotInstructionsPresent(): boolean {
443
457
 
444
458
  // ── Install integrity (Tier 2 doctor checks) ──
445
459
 
446
- /** Recursively collect every `command` or `bash` field value in a hook-config JSON. */
460
+ /** Hook-config keys that carry a shell command: cross-platform and per-shell variants. */
461
+ function isHookCommandField(key: string): boolean {
462
+ return key === "command" || key === "bash" || key === "powershell";
463
+ }
464
+
465
+ /** Recursively collect every command-carrying field value in a hook-config JSON. */
447
466
  function extractAllHookCommands(obj: unknown, out: string[] = []): string[] {
448
467
  if (Array.isArray(obj)) {
449
468
  for (const item of obj) extractAllHookCommands(item, out);
450
469
  } else if (obj && typeof obj === "object") {
451
470
  for (const [k, v] of Object.entries(obj)) {
452
- if ((k === "command" || k === "bash") && typeof v === "string") {
471
+ if (isHookCommandField(k) && typeof v === "string") {
453
472
  out.push(v);
454
473
  } else {
455
474
  extractAllHookCommands(v, out);
@@ -466,14 +485,27 @@ interface HookPrefixCheck {
466
485
  firstMissing?: string;
467
486
  }
468
487
 
469
- /** Verify every command in an installed hook file starts with `PAL_AGENT=<agent>`. */
488
+ /**
489
+ * True when a hook command names its agent, by env prefix or by argv flag.
490
+ *
491
+ * An env prefix only parses in one shell family, so hook configs whose host
492
+ * shell is unknown declare the agent with a shell-agnostic `--agent=` flag.
493
+ */
494
+ function declaresAgent(cmd: string, agentName: string): boolean {
495
+ return (
496
+ cmd.startsWith(`PAL_AGENT=${agentName} `) ||
497
+ cmd.startsWith(`$env:PAL_AGENT='${agentName}'; `) ||
498
+ cmd.includes(`--agent=${agentName}`)
499
+ );
500
+ }
501
+
502
+ /** Verify every command in an installed hook file names `<agent>` as its agent. */
470
503
  function checkAgentHookPrefix(filePath: string, agentName: string): HookPrefixCheck {
471
504
  if (!existsSync(filePath)) return { ok: false, total: 0, missing: 0 };
472
505
  try {
473
506
  const data = JSON.parse(readFileSync(filePath, "utf-8"));
474
507
  const commands = extractAllHookCommands(data.hooks ?? data);
475
- const prefix = `PAL_AGENT=${agentName} `;
476
- const missing = commands.filter((c) => !c.startsWith(prefix));
508
+ const missing = commands.filter((c) => !declaresAgent(c, agentName));
477
509
  return {
478
510
  ok: commands.length > 0 && missing.length === 0,
479
511
  total: commands.length,
@@ -701,7 +733,7 @@ function doctor(silent = false): DoctorResult {
701
733
  const claude = checkTool("claude");
702
734
  const opencode = checkTool("opencode");
703
735
  const cursor = checkTool("cursor");
704
- const copilot = checkTool("copilot", ["version"]);
736
+ const copilot = checkCopilot();
705
737
  const codex = checkTool("codex");
706
738
  const rtk = checkTool("rtk");
707
739
  const hasAgent =
@@ -902,12 +934,12 @@ function doctor(silent = false): DoctorResult {
902
934
  ): void => {
903
935
  const r = checkAgentHookPrefix(filePath, agentName);
904
936
  if (r.ok) {
905
- ok(`${agentName}: PAL_AGENT=${agentName} on all ${r.total} hook commands`);
937
+ ok(`${agentName}: declared on all ${r.total} hook commands`);
906
938
  } else if (r.total === 0) {
907
939
  fail(`${agentName}: hook file missing or unreadable at ${filePath}`);
908
940
  } else {
909
941
  fail(
910
- `${agentName}: ${r.missing}/${r.total} hook commands missing PAL_AGENT=${agentName} prefix (run '${installCmd}')`
942
+ `${agentName}: ${r.missing}/${r.total} hook commands do not declare ${agentName} (run '${installCmd}')`
911
943
  );
912
944
  if (r.firstMissing) {
913
945
  log.warn(` First offender: ${r.firstMissing}…`);
@@ -1058,6 +1090,10 @@ async function init(args: string[]) {
1058
1090
  log.info(`Creating PAL home at ${home}`);
1059
1091
  mkdirSync(resolve(home, "telos"), { recursive: true });
1060
1092
  mkdirSync(resolve(home, "memory"), { recursive: true });
1093
+ // Scaffolded here, not left to generateSkillIndex: that returns early when
1094
+ // ~/.pal/skills is absent, so an init that installs no skills would leave
1095
+ // every writer of memory/state with nowhere to write.
1096
+ mkdirSync(resolve(home, "memory", "state"), { recursive: true });
1061
1097
 
1062
1098
  scaffoldTelos();
1063
1099
 
@@ -1066,16 +1102,23 @@ async function init(args: string[]) {
1066
1102
  await install(targets);
1067
1103
  }
1068
1104
 
1105
+ /**
1106
+ * Run a setup subprocess, showing its output only when it fails.
1107
+ *
1108
+ * These are idempotent and usually report "no changes", so their banners are
1109
+ * pure noise on a re-install — but the moment one fails, the reason it gives
1110
+ * is the only thing that explains the warning.
1111
+ */
1112
+ function runQuietly(cmd: string, args: string[], cwd: string): number | null {
1113
+ const r = spawnSync(cmd, args, { cwd, encoding: "utf-8", shell: true });
1114
+ if (r.status !== 0) process.stderr.write((r.stdout ?? "") + (r.stderr ?? ""));
1115
+ return r.status;
1116
+ }
1117
+
1069
1118
  async function install(targets: Targets) {
1070
1119
  // Ensure dependencies are installed
1071
1120
  const pkg = palPkg();
1072
- log.info("Installing dependencies...");
1073
- const deps = spawnSync("bun", ["install", "--frozen-lockfile"], {
1074
- cwd: pkg,
1075
- stdio: "inherit",
1076
- shell: true,
1077
- });
1078
- if (deps.status !== 0) {
1121
+ if (runQuietly("bun", ["install", "--frozen-lockfile"], pkg) !== 0) {
1079
1122
  log.warn("bun install failed — continuing anyway, but hooks may not work");
1080
1123
  }
1081
1124
 
@@ -1084,15 +1127,10 @@ async function install(targets: Targets) {
1084
1127
  // (used by tests to avoid a ~150MB download on every run).
1085
1128
  // Uses `bun x` (not `bunx`) for Windows compatibility — bunx resolves unreliably under cmd.exe.
1086
1129
  if (process.env.PAL_SKIP_BROWSER_INSTALL !== "1") {
1087
- log.info("Installing Playwright Chromium...");
1088
- const pw = spawnSync("bun", ["x", "playwright", "install", "chromium"], {
1089
- cwd: pkg,
1090
- stdio: "inherit",
1091
- shell: true,
1092
- });
1093
- if (pw.status !== 0) {
1130
+ const pw = runQuietly("bun", ["x", "playwright", "install", "chromium"], pkg);
1131
+ if (pw !== 0) {
1094
1132
  log.warn(
1095
- `playwright install chromium failed (exit ${pw.status}) — create-pdf and consulting-report skills won't work. Retry manually: bun x playwright install chromium`
1133
+ `playwright install chromium failed (exit ${pw}) — create-pdf and consulting-report skills won't work. Retry manually: bun x playwright install chromium`
1096
1134
  );
1097
1135
  }
1098
1136
  }
@@ -1112,7 +1150,8 @@ async function install(targets: Targets) {
1112
1150
  }
1113
1151
 
1114
1152
  // Scaffold TELOS + PAL settings, then prompt for missing identity
1115
- const { scaffoldTelos, scaffoldPalSettings } = await import("../targets/lib");
1153
+ const { scaffoldTelos, scaffoldPalSettings, copyPalDocs, generateSkillIndex } =
1154
+ await import("../targets/lib");
1116
1155
  const { promptIdentity } = await import("./setup-identity");
1117
1156
  const { promptTelos } = await import("./setup-telos");
1118
1157
  const { promptAttribution } = await import("./setup-attribution");
@@ -1122,6 +1161,13 @@ async function install(targets: Targets) {
1122
1161
  await promptTelos();
1123
1162
  await promptAttribution();
1124
1163
 
1164
+ // Shared, target-independent state. Every target installer used to repeat these
1165
+ // identical calls; AGENTS.md in particular must exist before any target symlinks
1166
+ // to it, so it runs once here rather than once per target.
1167
+ const { regenerateIfNeeded } = await import("../hooks/lib/claude-md");
1168
+ const palDocsCount = copyPalDocs();
1169
+ regenerateIfNeeded();
1170
+
1125
1171
  if (targets.claude) {
1126
1172
  console.log("━━━ Claude Code ━━━");
1127
1173
  await import("../targets/claude/install");
@@ -1152,6 +1198,16 @@ async function install(targets: Targets) {
1152
1198
  console.log("");
1153
1199
  }
1154
1200
 
1201
+ // The rest of the shared work reads what the installers just wrote: the index
1202
+ // walks ~/.pal/skills, and the digests land in ~/.cursor/rules and
1203
+ // ~/.copilot/instructions, which are skipped when the agent's home is absent.
1204
+ const { writeContextDigests } = await import("../hooks/handlers/context-digests");
1205
+ const indexedSkills = generateSkillIndex();
1206
+ writeContextDigests();
1207
+ log.success(
1208
+ `Shared: ${indexedSkills} skills indexed · ${palDocsCount} docs → ~/.pal/docs/ · AGENTS.md + context digests written`
1209
+ );
1210
+
1155
1211
  log.success("Done. Existing config was preserved — only new entries were added.");
1156
1212
  }
1157
1213
 
@@ -1377,7 +1433,9 @@ async function update() {
1377
1433
  function cliDebug(args: string[]) {
1378
1434
  const stateDir = resolve(palHome(), "memory", "state");
1379
1435
  const flagFile = resolve(stateDir, "debug-enabled");
1380
- const logFile = resolve(stateDir, "debug.log");
1436
+ // Must match log.ts's logFile() — reporting a different path sends anyone
1437
+ // debugging a hook to an empty file.
1438
+ const logFile = resolve(paths.debug(), "debug.log");
1381
1439
  const sub = args[0];
1382
1440
  if (sub === "on") {
1383
1441
  mkdirSync(stateDir, { recursive: true });
@@ -1,34 +1,81 @@
1
1
  /**
2
2
  * Hook: PreToolUse — Guards against dangerous commands.
3
- * Returns JSON { decision: "block", reason: "..." } to block, or exits silently to allow.
3
+ * Emits the current agent's deny response to block, or exits silently to allow.
4
4
  *
5
5
  * Fail-open design: if anything goes wrong, the command is allowed through.
6
6
  */
7
7
 
8
- import { blockResponse } from "./lib/agent";
8
+ import { blockResponse, normalizeToolUse } from "./lib/agent";
9
+ import { logDebug } from "./lib/log";
9
10
  import { checkBashCommand, checkFilePath } from "./lib/security";
10
11
  import { readStdinJSON } from "./lib/stdin";
11
12
 
12
- // preToolUse shape (Claude Code + Cursor + Codex)
13
- interface ToolUseInput {
14
- tool_name: string;
15
- hook_event_name?: string; // Codex includes this in all hook inputs
16
- tool_input: {
17
- command?: string;
18
- file_path?: string;
19
- };
20
- }
21
-
22
- // beforeShellExecution shape (Cursor only) — flat, no tool_name wrapper
13
+ // beforeShellExecution shape (Cursor only) flat, no tool-name wrapper
23
14
  interface ShellExecInput {
24
15
  command: string;
25
16
  sandbox?: boolean;
26
17
  }
27
18
 
28
- type SecurityInput = ToolUseInput | ShellExecInput;
19
+ type SecurityInput = Record<string, unknown> | ShellExecInput;
29
20
 
30
21
  function isShellExec(input: SecurityInput): input is ShellExecInput {
31
- return !("tool_name" in input) && "command" in input;
22
+ return !("tool_name" in input) && !("toolName" in input) && "command" in input;
23
+ }
24
+
25
+ // A name this list misses is a command this hook waves through, so both sets mirror
26
+ // the tool names VS Code's own Copilot build ships in its shell and edit tool sets.
27
+ const SHELL_TOOLS = [
28
+ "bash",
29
+ "shell",
30
+ "powershell",
31
+ "local_shell",
32
+ "runinterminal",
33
+ "run_in_terminal",
34
+ "terminal",
35
+ "execute_command",
36
+ ];
37
+
38
+ const FILE_WRITE_TOOLS = [
39
+ "write",
40
+ "edit",
41
+ "multiedit",
42
+ "write_file",
43
+ "apply_patch",
44
+ "applypatch",
45
+ "create",
46
+ "create_file",
47
+ "createfile",
48
+ "str_replace",
49
+ "str_replace_editor",
50
+ "insert",
51
+ "insert_edit_into_file",
52
+ "replace_string_in_file",
53
+ "multi_replace_string_in_file",
54
+ "replacestring",
55
+ "edit_notebook_file",
56
+ "notebookedit",
57
+ ];
58
+
59
+ /** First of `keys` present as a non-empty string — agents disagree on argument spelling. */
60
+ function firstStringArg(
61
+ args: Record<string, unknown>,
62
+ keys: string[]
63
+ ): string | undefined {
64
+ for (const key of keys) {
65
+ const value = args[key];
66
+ if (typeof value === "string" && value.length > 0) return value;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ /** Tool names that run a shell command, across every agent's naming. */
72
+ function runsShellCommand(toolName: string): boolean {
73
+ return SHELL_TOOLS.includes(toolName.toLowerCase());
74
+ }
75
+
76
+ /** Tool names that write to a file, across every agent's naming. */
77
+ function writesFile(toolName: string): boolean {
78
+ return FILE_WRITE_TOOLS.includes(toolName.toLowerCase());
32
79
  }
33
80
 
34
81
  try {
@@ -44,31 +91,35 @@ try {
44
91
  process.exit(0);
45
92
  }
46
93
 
47
- const hookEventName = input.hook_event_name;
94
+ const toolUse = normalizeToolUse(input);
95
+ if (!toolUse) process.exit(0);
48
96
 
49
- // preToolUse Claude: "Bash", Cursor: "Shell", Codex: "shell"
50
- const isBash =
51
- input.tool_name === "Bash" ||
52
- input.tool_name === "Shell" ||
53
- input.tool_name === "shell";
54
- const isFileWrite =
55
- input.tool_name === "Write" ||
56
- input.tool_name === "Edit" ||
57
- input.tool_name === "write_file" ||
58
- input.tool_name === "apply_patch";
97
+ // Each agent names its shell/write tools differently; log the real name so an
98
+ // unrecognized one shows up here instead of silently skipping the check.
99
+ logDebug(
100
+ "SecurityValidator",
101
+ `toolName=${toolUse.toolName} args=${Object.keys(toolUse.toolInput).join(",")}`
102
+ );
59
103
 
60
- if (isBash && input.tool_input.command) {
61
- const reason = checkBashCommand(input.tool_input.command);
104
+ const command = firstStringArg(toolUse.toolInput, ["command", "commandLine", "script"]);
105
+ if (runsShellCommand(toolUse.toolName) && typeof command === "string") {
106
+ const reason = checkBashCommand(command);
107
+ const verdict = reason ? `BLOCK(${reason})` : "ALLOW";
108
+ // "No output" from a downstream tool is indistinguishable between "denied,
109
+ // never ran" and "ran, produced nothing" — logging the verdict here, next
110
+ // to the literal command, is what actually tells the two apart.
111
+ logDebug("SecurityValidator", `bashVerdict=${verdict} command=${command}`);
62
112
  if (reason) {
63
- process.stdout.write(blockResponse(`Blocked: ${reason}`, hookEventName));
113
+ process.stdout.write(blockResponse(`Blocked: ${reason}`, toolUse.hookEventName));
64
114
  process.exit(0);
65
115
  }
66
116
  }
67
117
 
68
- if (isFileWrite && input.tool_input.file_path) {
69
- const reason = checkFilePath(input.tool_input.file_path);
118
+ const filePath = firstStringArg(toolUse.toolInput, ["file_path", "filePath", "path"]);
119
+ if (writesFile(toolUse.toolName) && typeof filePath === "string") {
120
+ const reason = checkFilePath(filePath);
70
121
  if (reason) {
71
- process.stdout.write(blockResponse(reason, hookEventName));
122
+ process.stdout.write(blockResponse(reason, toolUse.hookEventName));
72
123
  process.exit(0);
73
124
  }
74
125
  }
@@ -9,29 +9,25 @@
9
9
  * Fail-open: on any error, the skill is allowed through.
10
10
  */
11
11
 
12
- import { blockResponse } from "./lib/agent";
12
+ import { blockResponse, normalizeToolUse } from "./lib/agent";
13
13
  import { readStdinJSON } from "./lib/stdin";
14
14
 
15
15
  const BLOCKED_SKILLS = ["keybindings-help"];
16
16
 
17
- interface SkillInput {
18
- tool_name: string;
19
- tool_input: {
20
- skill?: string;
21
- };
22
- }
23
-
24
17
  try {
25
- const input = await readStdinJSON<SkillInput>();
26
- if (!input) process.exit(0);
18
+ const toolUse = normalizeToolUse(await readStdinJSON());
19
+ if (!toolUse) process.exit(0);
27
20
 
28
- const skill = (input.tool_input?.skill || "").toLowerCase().trim();
21
+ const skill = String(toolUse.toolInput.skill ?? "")
22
+ .toLowerCase()
23
+ .trim();
29
24
 
30
25
  if (BLOCKED_SKILLS.includes(skill)) {
31
26
  process.stdout.write(
32
27
  blockResponse(
33
28
  'BLOCKED: "keybindings-help" is a known false-positive triggered by position bias. ' +
34
- "The user did NOT ask about keybindings. Continue with their ACTUAL request."
29
+ "The user did NOT ask about keybindings. Continue with their ACTUAL request.",
30
+ toolUse.hookEventName
35
31
  )
36
32
  );
37
33
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { checkReadmeSync } from "./handlers/readme-sync";
10
- import { isCodex, isCursor } from "./lib/agent";
10
+ import { blockResponse, isCodex, isCursor } from "./lib/agent";
11
11
  import { logError } from "./lib/log";
12
12
  import { isPalSpawnedInference } from "./lib/spawn-guard";
13
13
  import { readStdinJSON } from "./lib/stdin";
@@ -29,8 +29,10 @@ interface StopHookInput {
29
29
 
30
30
  // Check README sync before anything else — may block the session
31
31
  try {
32
+ // A block carrying no reason stops the turn without telling the model why, so it
33
+ // is worth less than not blocking at all — require the reason to raise one.
32
34
  const decision = checkReadmeSync();
33
- if (decision.decision === "block") {
35
+ if (decision.decision === "block" && decision.reason) {
34
36
  if (isCursor()) {
35
37
  // Cursor stop hook: followup_message auto-sends to the agent
36
38
  process.stdout.write(JSON.stringify({ followup_message: decision.reason }));
@@ -38,8 +40,9 @@ try {
38
40
  // Codex stop hook: additionalContext re-queues as next prompt
39
41
  process.stdout.write(JSON.stringify({ additionalContext: decision.reason }));
40
42
  } else {
41
- // Claude Code: block decision
42
- process.stdout.write(JSON.stringify(decision));
43
+ // Claude Code, the Copilot CLI and VS Code's own Copilot each read a
44
+ // different stop-block shape; VS Code ignores the top-level keys entirely.
45
+ process.stdout.write(blockResponse(decision.reason, "Stop"));
43
46
  }
44
47
  process.exit(0);
45
48
  }