Skip to content

Commit f8d4969

Browse files
authored
feat(hook): improvement hook context injection for planning (#459)
* feat(shared): add improvement hook reader utility Adds a runtime-agnostic module for reading improvement hook files from ~/.plannotator/hooks/. Uses a hardcoded base path with an allowlist of known hook files and a 50KB size cap to prevent runaway context injection. Designed for cross-harness reuse (Claude Code, OpenCode, Pi, etc.). For provenance purposes, this commit was AI assisted. * feat(hook): wire up improve-context PreToolUse hook for EnterPlanMode Adds a PreToolUse hook on EnterPlanMode that injects corrective planning instructions into Claude's context at plan mode entry. The new `plannotator improve-context` subcommand reads the improvement hook file and returns additionalContext, or silently passes through if no file exists. For provenance purposes, this commit was AI assisted. * fix(skill): update compound skill improvement hook path Moves the improvement hook file path from ~/.plannotator/compound/ to ~/.plannotator/hooks/compound/ to namespace hook-injectable files separately from other plannotator data. For provenance purposes, this commit was AI assisted. * fix(shared): add legacy path fallback for improvement hook reader Users who ran the compound skill before the path migration have their improvement hook file at ~/.plannotator/compound/ instead of the new ~/.plannotator/hooks/compound/ path. Add a fallback that checks the legacy path only when the new path is absent — if the new path exists but is invalid (empty, oversized), no fallback occurs to prevent resurrecting stale instructions. Includes tests for new-path-wins, legacy-fallback, and invalid-new-blocks-legacy scenarios. For provenance purposes, this commit was AI assisted.
1 parent 588b6a4 commit f8d4969

7 files changed

Lines changed: 295 additions & 4 deletions

File tree

apps/hook/hooks/hooks.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
{
22
"hooks": {
3+
"PreToolUse": [
4+
{
5+
"matcher": "EnterPlanMode",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"command": "plannotator improve-context",
10+
"timeout": 5
11+
}
12+
]
13+
}
14+
],
315
"PermissionRequest": [
416
{
517
"matcher": "ExitPlanMode",

apps/hook/server/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export function formatTopLevelHelp(): string {
1919
" plannotator last",
2020
" plannotator archive",
2121
" plannotator sessions",
22+
" plannotator improve-context",
2223
"",
2324
"Note:",
2425
" running 'plannotator' without arguments is for hook integration and expects JSON on stdin",

apps/hook/server/index.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Plannotator CLI for Claude Code & Copilot CLI
33
*
4-
* Supports seven modes:
4+
* Supports eight modes:
55
*
66
* 1. Plan Review (default, no args):
77
* - Spawned by ExitPlanMode hook (Claude Code)
@@ -37,6 +37,11 @@
3737
* - Annotate the last assistant message from a Copilot CLI session
3838
* - Parses events.jsonl from session state
3939
*
40+
* 8. Improve Context (`plannotator improve-context`):
41+
* - Spawned by PreToolUse hook on EnterPlanMode
42+
* - Reads improvement hook file from ~/.plannotator/hooks/
43+
* - Returns additionalContext or silently passes through
44+
*
4045
* Global flags:
4146
* --help - Show top-level usage information
4247
* --browser <name> - Override which browser to open (e.g. "Google Chrome")
@@ -68,6 +73,7 @@ import { registerSession, unregisterSession, listSessions } from "@plannotator/s
6873
import { openBrowser } from "@plannotator/server/browser";
6974
import { detectProjectName } from "@plannotator/server/project";
7075
import { planDenyFeedback } from "@plannotator/shared/feedback-templates";
76+
import { readImprovementHook } from "@plannotator/shared/improvement-hooks";
7177
import type { Origin } from "@plannotator/shared/agents";
7278
import { findSessionLogsForCwd, resolveSessionLogByPpid, findSessionLogsByAncestorWalk, getLastRenderedMessage, type RenderedMessage } from "./session-log";
7379
import { findCodexRolloutByThreadId, getLastCodexMessage } from "./codex-session";
@@ -700,6 +706,37 @@ if (args[0] === "sessions") {
700706
console.log(result.feedback || "No feedback provided.");
701707
process.exit(0);
702708

709+
} else if (args[0] === "improve-context") {
710+
// ============================================
711+
// IMPROVEMENT HOOK CONTEXT INJECTION MODE
712+
// ============================================
713+
//
714+
// Called by PreToolUse hook on EnterPlanMode.
715+
// Reads the improvement hook file and returns additionalContext.
716+
// No file = exit 0 silently (passthrough).
717+
718+
// Must consume stdin (Claude Code hooks deliver event JSON on stdin)
719+
await Bun.stdin.text();
720+
721+
const hook = readImprovementHook("enterplanmode-improve");
722+
if (!hook) process.exit(0);
723+
724+
const context = [
725+
"[Plannotator Improvement Hook]",
726+
"The following corrective instructions were generated from analysis of previous plan denial patterns.",
727+
"Apply these guidelines when writing your plan:\n",
728+
hook.content,
729+
].join("\n");
730+
731+
console.log(JSON.stringify({
732+
hookSpecificOutput: {
733+
hookEventName: "PreToolUse",
734+
additionalContext: context,
735+
},
736+
}));
737+
738+
process.exit(0);
739+
703740
} else {
704741
// ============================================
705742
// PLAN REVIEW MODE (default)

apps/skills/plannotator-compound/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -473,10 +473,10 @@ every future planning session automatically.
473473
The hook file lives at:
474474

475475
```
476-
~/.plannotator/compound/enterplanmode-improve-hook.txt
476+
~/.plannotator/hooks/compound/enterplanmode-improve-hook.txt
477477
```
478478

479-
Create the `~/.plannotator/compound/` directory if it doesn't exist.
479+
Create the `~/.plannotator/hooks/compound/` directory if it doesn't exist.
480480

481481
The file contents should be the corrective prompt instructions from Phase 3 —
482482
the same numbered list that appears in section 7 of the HTML report. Write them
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* Tests for improvement hook reader.
3+
*
4+
* Run: bun test packages/shared/improvement-hooks.test.ts
5+
*/
6+
7+
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
8+
import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs";
9+
import { join } from "path";
10+
import { tmpdir } from "os";
11+
12+
// We need to override the base dirs used by readImprovementHook.
13+
// Since the module uses homedir() at import time, we mock it via
14+
// a test harness that sets HOME to a temp directory.
15+
16+
const TEST_HOME = join(tmpdir(), `improvement-hooks-test-${Date.now()}`);
17+
const NEW_BASE = join(TEST_HOME, ".plannotator", "hooks");
18+
const LEGACY_BASE = join(TEST_HOME, ".plannotator");
19+
const HOOK_RELATIVE = "compound/enterplanmode-improve-hook.txt";
20+
21+
function setupTestHome() {
22+
mkdirSync(join(NEW_BASE, "compound"), { recursive: true });
23+
mkdirSync(join(LEGACY_BASE, "compound"), { recursive: true });
24+
}
25+
26+
function cleanTestHome() {
27+
if (existsSync(TEST_HOME)) {
28+
rmSync(TEST_HOME, { recursive: true, force: true });
29+
}
30+
}
31+
32+
// Since the module reads homedir() at import time, we need to
33+
// re-import with HOME overridden. Use a helper that spawns a
34+
// small inline script to test each scenario in isolation.
35+
async function runScenario(setup: {
36+
newPathContent?: string | null;
37+
legacyPathContent?: string | null;
38+
}): Promise<{ content: string; filePath: string } | null> {
39+
setupTestHome();
40+
41+
const newPath = join(NEW_BASE, HOOK_RELATIVE);
42+
const legacyPath = join(LEGACY_BASE, HOOK_RELATIVE);
43+
44+
if (setup.newPathContent !== undefined && setup.newPathContent !== null) {
45+
writeFileSync(newPath, setup.newPathContent);
46+
}
47+
if (setup.legacyPathContent !== undefined && setup.legacyPathContent !== null) {
48+
writeFileSync(legacyPath, setup.legacyPathContent);
49+
}
50+
51+
// Run in a subprocess with HOME overridden so homedir() returns TEST_HOME
52+
const proc = Bun.spawn(
53+
[
54+
"bun",
55+
"-e",
56+
`
57+
import { readImprovementHook } from "./packages/shared/improvement-hooks";
58+
const result = readImprovementHook("enterplanmode-improve");
59+
console.log(JSON.stringify(result));
60+
`,
61+
],
62+
{
63+
env: { ...process.env, HOME: TEST_HOME },
64+
cwd: join(import.meta.dir, "../.."),
65+
stdout: "pipe",
66+
stderr: "pipe",
67+
},
68+
);
69+
70+
const stdout = await new Response(proc.stdout).text();
71+
const exitCode = await proc.exited;
72+
73+
if (exitCode !== 0) {
74+
const stderr = await new Response(proc.stderr).text();
75+
throw new Error(`Subprocess failed (exit ${exitCode}): ${stderr}`);
76+
}
77+
78+
const parsed = JSON.parse(stdout.trim());
79+
return parsed;
80+
}
81+
82+
describe("readImprovementHook", () => {
83+
beforeEach(setupTestHome);
84+
afterEach(cleanTestHome);
85+
86+
test("returns content from new path when file exists", async () => {
87+
const result = await runScenario({
88+
newPathContent: "Focus on error handling",
89+
});
90+
expect(result).not.toBeNull();
91+
expect(result!.content).toBe("Focus on error handling");
92+
expect(result!.filePath).toContain(".plannotator/hooks/compound/");
93+
});
94+
95+
test("new path wins over legacy path", async () => {
96+
const result = await runScenario({
97+
newPathContent: "New instructions",
98+
legacyPathContent: "Old instructions",
99+
});
100+
expect(result).not.toBeNull();
101+
expect(result!.content).toBe("New instructions");
102+
expect(result!.filePath).toContain(".plannotator/hooks/compound/");
103+
});
104+
105+
test("falls back to legacy path when new path is absent", async () => {
106+
const result = await runScenario({
107+
legacyPathContent: "Legacy instructions",
108+
});
109+
expect(result).not.toBeNull();
110+
expect(result!.content).toBe("Legacy instructions");
111+
expect(result!.filePath).toContain(".plannotator/compound/");
112+
expect(result!.filePath).not.toContain(".plannotator/hooks/");
113+
});
114+
115+
test("returns null when new path exists but is empty (no legacy fallback)", async () => {
116+
const result = await runScenario({
117+
newPathContent: "",
118+
legacyPathContent: "Legacy instructions",
119+
});
120+
expect(result).toBeNull();
121+
});
122+
123+
test("returns null when no files exist", async () => {
124+
const result = await runScenario({});
125+
expect(result).toBeNull();
126+
});
127+
128+
test("returns null when new path is whitespace-only (no legacy fallback)", async () => {
129+
const result = await runScenario({
130+
newPathContent: " \n \n ",
131+
legacyPathContent: "Legacy instructions",
132+
});
133+
expect(result).toBeNull();
134+
});
135+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Improvement Hook Reader
3+
*
4+
* Reads improvement hook files from ~/.plannotator/hooks/.
5+
* Falls back to the legacy path (~/.plannotator/) when the new-path
6+
* file is absent, for compatibility with files written before the
7+
* path migration. If the new-path file exists but is invalid (empty,
8+
* oversized, not a regular file), the legacy path is NOT consulted —
9+
* this prevents resurrecting stale instructions.
10+
*
11+
* Runtime-agnostic: uses only node:fs, node:path, node:os.
12+
*
13+
* Security model:
14+
* - Hardcoded base paths (no user input determines file path)
15+
* - KNOWN_HOOKS allowlist (only pre-registered relative paths)
16+
* - Size cap to prevent runaway context injection
17+
* - Same trust model as ~/.plannotator/config.json
18+
*/
19+
20+
import { homedir } from "os";
21+
import { join } from "path";
22+
import { readFileSync, statSync } from "fs";
23+
24+
/** Base directory for hook-injectable files (new path) */
25+
const HOOKS_BASE_DIR = join(homedir(), ".plannotator", "hooks");
26+
27+
/** Legacy base directory (pre-migration path) */
28+
const LEGACY_BASE_DIR = join(homedir(), ".plannotator");
29+
30+
/** Maximum file size to read (50 KB) */
31+
const MAX_FILE_SIZE = 50 * 1024;
32+
33+
/**
34+
* Known improvement hook file paths, keyed by hook name.
35+
* `path` is relative to HOOKS_BASE_DIR (~/.plannotator/hooks/).
36+
* `legacyPath` is relative to LEGACY_BASE_DIR (~/.plannotator/).
37+
*/
38+
const KNOWN_HOOKS = {
39+
"enterplanmode-improve": {
40+
path: "compound/enterplanmode-improve-hook.txt",
41+
legacyPath: "compound/enterplanmode-improve-hook.txt",
42+
},
43+
} as const;
44+
45+
export type ImprovementHookName = keyof typeof KNOWN_HOOKS;
46+
47+
export interface ImprovementHookResult {
48+
content: string;
49+
hookName: ImprovementHookName;
50+
filePath: string;
51+
}
52+
53+
/** Check whether a path exists on disk (any file type). */
54+
function fileExists(path: string): boolean {
55+
try {
56+
statSync(path);
57+
return true;
58+
} catch {
59+
return false;
60+
}
61+
}
62+
63+
/** Validate and read a hook file. Returns the result, or null if invalid. */
64+
function tryReadHookFile(
65+
filePath: string,
66+
hookName: ImprovementHookName,
67+
): ImprovementHookResult | null {
68+
try {
69+
const stat = statSync(filePath);
70+
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_FILE_SIZE) return null;
71+
72+
const content = readFileSync(filePath, "utf-8").trim();
73+
if (!content) return null;
74+
75+
return { content, hookName, filePath };
76+
} catch {
77+
return null;
78+
}
79+
}
80+
81+
/**
82+
* Read an improvement hook file by name.
83+
*
84+
* Lookup order:
85+
* 1. New path (HOOKS_BASE_DIR + path). If it exists and validates, return it.
86+
* 2. If the new path exists but is invalid (empty, oversized, etc.), return null.
87+
* 3. Only if the new path does not exist, try the legacy path (LEGACY_BASE_DIR + legacyPath).
88+
*/
89+
export function readImprovementHook(
90+
hookName: ImprovementHookName,
91+
): ImprovementHookResult | null {
92+
const entry = KNOWN_HOOKS[hookName];
93+
if (!entry) return null;
94+
95+
const newPath = join(HOOKS_BASE_DIR, entry.path);
96+
97+
// New path exists — use it exclusively (even if invalid)
98+
if (fileExists(newPath)) {
99+
return tryReadHookFile(newPath, hookName);
100+
}
101+
102+
// New path absent — fall back to legacy path
103+
const legacyFilePath = join(LEGACY_BASE_DIR, entry.legacyPath);
104+
return tryReadHookFile(legacyFilePath, hookName);
105+
}

packages/shared/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"./resolve-file": "./resolve-file.ts",
2222
"./external-annotation": "./external-annotation.ts",
2323
"./agent-jobs": "./agent-jobs.ts",
24-
"./config": "./config.ts"
24+
"./config": "./config.ts",
25+
"./improvement-hooks": "./improvement-hooks.ts"
2526
}
2627
}

0 commit comments

Comments
 (0)