Planning mode guidelines and context injection

Planning mode guidelines and context injection for Claude Code.
plan:review: reviews how an implementation diverged from its approved plan, dispatching a clean-context agent over the plan and the diff to surface drift and follow-upsscripts/context.sh and scripts/plan-inject.sh: inject planning guidelines the first time a session reaches plan mode, whether that first signal is the EnterPlanMode call, a prompt, or a tool call, and append delegation guidance when the orchestrator runs on an expensive modelhooks/gate.ts: gates ExitPlanMode, denying an unchanged plan resubmission, a resubmission that keeps the prior plan nearly intact, and a plan over 10k characters, re-arming while a rework stays over the threshold up to two fires per sessionhooks/write-warn.ts: on Write/Edit to a plan file under the default ~/.claude/plans/ directory, warns via additionalContext when its content is already over the gate's 10k-character limit, so a draft can be trimmed before it reaches ExitPlanMode. hooks.json scopes it to that directory with a per-hook if, so it never spawns for an unrelated write, which means it has no visibility into a custom plansDirectory settingmod/register.ts: after each Write or Edit to the plan file named in the plan-mode reminder, shows its size as a share of the gate's limit in the status line once it reaches 90% (plan: 10.2k (102%), rounded down), counting string.length the way the gate does, and clears it when a plan is approved. Display only: it adds nothing to the model's context. Through mod-events it logs plan.count for each count, plan.crossed whenever the count moves over or back under the limit, and plan.present for each ExitPlanMode with its size and whether it went through, so the session index can measure whittle loops. It loads only where CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, and the command hooks work without it. hooks/gate.test.ts pins its limit to the gate'sevals/gate/: offline replay of every recorded presentation through the gate, plus headless rework runs that compare deny textsTwo hooks share one job: inject the guidelines exactly once per plan-mode session. The UserPromptSubmit hook (scripts/context.sh) catches a prompt submitted while already in plan mode. A PreToolUse hook (scripts/plan-inject.sh, matcher *) catches the case where a session toggles into plan mode after its last prompt, so no UserPromptSubmit fires before ExitPlanMode. permission_mode rides on every PreToolUse, so the first tool call in plan mode is a reliable injection point. No matcher expresses "whichever tool comes first in plan mode", so the entry keeps matcher * and the script tests the raw payload with a shell case before spawning jq, so a tool call outside plan mode exits without the parse. Both read permission_mode and share a plan-injected marker under $CLAUDE_PLAN_MARKER_ROOT/<session_id>/, so whichever fires first wins and the other short-circuits.
A third entry registers the same script on PostToolUse with matcher EnterPlanMode. A session that researches in auto mode, enters plan mode, and writes the plan in one turn reaches its next tool call only at ExitPlanMode, so under the PreToolUse entry alone the guidelines arrive after the plan they were meant to shape. This entry needs no mode test, because EnterPlanMode carries the pre-switch mode before it runs. PostToolUse is what makes it safe: reaching it means the call landed, so an interrupted EnterPlanMode cannot spend the marker and silence both paths for the rest of the session. The script also exits on a payload carrying agent_id, since a subagent's call would otherwise spend the parent's marker on context only the subagent sees.
Both paths call scripts/injection-content.sh to assemble the content. It emits references/guidelines.md always, and reads the latest assistant model from the transcript. When that model is an expensive orchestrator (opus, fable, mythos), it appends references/delegation.md, which requires the plan to carry a Delegation section laying out the agent/model/effort DAG. UserPromptSubmit returns the content on stdout. PreToolUse returns it as hookSpecificOutput.additionalContext. Any read or parse problem falls back to the guidelines alone.
The PreToolUse gate hook keeps per-session state under /tmp/claude/<session_id>/ and runs three checks in order. It denies a resubmission whose text is unchanged from the last presentation. It denies a resubmission that keeps nearly every prior line and introduces at least one new one, which catches a line swap that nets zero growth as well as a plain append. It denies a plan over 10k characters, the same limit the injected guidelines state, re-arming while a rework remains over the threshold, capped at two fires per session so a deny loop is impossible.
Every check denies, including the two that read as advice. A PreToolUse hook's permissionDecision: "ask" is inert on ExitPlanMode: the tool runs its own plan-approval prompt, and the harness drops the hook's permissionDecisionReason and systemMessage alike, so neither the user nor the transcript sees them. deny is the only decision that carries a reason back. Each denial is recoverable in one step, since the model reworks the plan and presents again, and the size check spends its marker on first use. hooks/fixtures/re-presents.json holds presentation sequences recorded from real sessions. gate.test.ts replays them one process per presentation and snapshots the permissionDecision alongside the rule, so a check that stops deciding surfaces as a changed snapshot rather than as silence.
A missing session id or a plan that is not a string fails open silently, since neither is a fault. Anything else, an unusable state directory or a crash mid-decision, prints to stderr and exits 1: the presentation still goes through, and the failure is visible rather than reading as a plan that passed every check.
Each denial reason states the fix directly: rework against the feedback, delete superseded text, move detail to sidecar files, or split the plan.
plan:review reads the approved plan from the file Claude Code writes under ~/.claude/plans/ and injects into the session on plan exit. It dispatches a clean-context agent that diffs the plan against the branch's base (resolved from the open PR, not assumed to be main) using the rubric in skills/review/references/divergence.md.
bun test plugins/plan
bun scripts/mod-test.ts planmod/register.ts 105 lines1import type { On } from "claude-code";
2
3// The plan gate denies a plan whose `plan.length`, in UTF-16 code units, exceeds this.
4export const LIMIT = 10_000;
5const SHOWN_FROM = LIMIT * 0.9;
6
7function percent(chars: number): number {
8 return Math.floor((chars * 100) / LIMIT);
9}
10
11// Both figures round down so a plan under the limit never reads 10k or 100%.
12export function statusText(chars: number): string | undefined {
13 if (chars < SHOWN_FROM) return undefined;
14 return `${Math.floor(chars / 100) / 10}k (${percent(chars)}%)`;
15}
16
17function basename(path: string): string {
18 return path.slice(path.lastIndexOf("/") + 1);
19}
20
21export function register(on: On): void {
22 let planFile: string | undefined;
23 let shown = false;
24 let wasOver = false;
25
26 on("session.start", ($, e, next) => {
27 void $.modEvents.emit({ mod: "plan", event: "session.start" });
28 return next(e);
29 });
30
31 // The plan-mode reminder names the session's plan file, so sidecars beside it don't count.
32 on("prompt.attachment", { type: "plan_mode" }, ($, e, next) => {
33 const path = e.detail?.planFilePath;
34 if (e.agentId === undefined && path !== undefined && path !== planFile) {
35 planFile = path;
36 wasOver = false;
37 }
38 return next(e);
39 });
40
41 on("tool.call", { tool: ["Write", "Edit"] }, async ($, e, next) => {
42 const result = await next(e);
43 if (e.file_path !== planFile || result.deny !== undefined || result.isError) return result;
44
45 let chars: number;
46 try {
47 chars = (await $.fs.read(e.file_path)).length;
48 } catch {
49 if (shown) {
50 shown = false;
51 $.ui.status(undefined);
52 }
53 return result;
54 }
55
56 const over = chars > LIMIT;
57 const file = basename(e.file_path);
58 const text = statusText(chars);
59 if (text !== undefined || shown) $.ui.status(text);
60 shown = text !== undefined;
61 await $.modEvents.emit({
62 mod: "plan",
63 event: "count",
64 detail: { file, chars, limit: LIMIT, over, tool: e.tool },
65 });
66 if (over !== wasOver) {
67 await $.modEvents.emit({
68 mod: "plan",
69 event: "crossed",
70 detail: { file, chars, limit: LIMIT, direction: over ? "over" : "under" },
71 });
72 }
73 wasOver = over;
74 return result;
75 }).catch(($, e, next) => next(e));
76
77 on("tool.call", { tool: "ExitPlanMode" }, async ($, e, next) => {
78 const result = await next(e);
79 const denied = result.deny !== undefined || result.isError === true;
80 if (!denied && shown) {
81 shown = false;
82 $.ui.status(undefined);
83 }
84 if (planFile === undefined) return result;
85
86 let chars: number;
87 try {
88 chars = (await $.fs.read(planFile)).length;
89 } catch {
90 if (shown) {
91 shown = false;
92 $.ui.status(undefined);
93 }
94 return result;
95 }
96 await $.modEvents.emit({
97 mod: "plan",
98 event: "present",
99 ok: !denied,
100 detail: { file: basename(planFile), chars, limit: LIMIT, over: chars > LIMIT },
101 });
102 return result;
103 }).catch(($, e, next) => next(e));
104}
105