# Step 42: Plan Mode
一句话导读:加一种特殊模式——AI 只读研究、绝不改动,把实施计划交你审批,批准后才允许写。看点是它没有任何全新机制,全靠 AppState、Hooks、系统提示三块已有积木编排出「提示引导 + 钩子硬拦 + ExitPlanMode 唯一出口」的三道防线。
# 一、这一步做了什么(What)
新增一个模式和一个工具(tools/exitPlanMode.ts),复用前面的积木搭出来:
/plan 进入计划模式(state.planMode = true)
↓
① 系统提示最前面注入 "# Plan Mode (ACTIVE)" 强指令(只研究、用 ExitPlanMode 提交计划)
② PreToolUse 钩子(step36)拦截一切"会改东西"的工具:Write / Agent / AgentBatch
↓
AI 用 Read/Glob/Grep/Bash(只读) 研究 → 调 ExitPlanMode({plan})
↓
ExitPlanMode 工具把计划打印出来,问你 y/n
├─ 批准 → state.planMode = false(解除护栏)→ "可以实施了"
└─ 拒绝 → 留在计划模式,带着你的反馈让它改计划
2
3
4
5
6
7
8
9
10
实测:计划模式下 Write 被拦、Read 放行;批准后 Write 放行;系统提示按模式注入 # Plan Mode。
# 二、面试官视角:为什么要做?(Why)
面试题:想让 AI「先出计划再动手」,直接在系统提示里写一句「请先给计划、别改文件」不就行了?为什么要专门做一整套 Plan Mode,还要加钩子硬拦?
因为只靠提示的约束是不可靠的软约束,而「绝不改文件」这种安全边界必须是硬约束。
系统提示是「引导」——模型通常会听,但不保证。它可能理解偏差、可能在多轮后忘了、可能被后续内容带跑,直接就去调了 Write。对「先看看计划」这种偏好,模型偶尔不听后果不大;但 Plan Mode 的承诺是「在你批准前,我一个字都不会改」——这是用户敢放心让 AI 研究敏感代码库的信任基础。信任基础不能建立在「模型应该会听话」上。
所以 Plan Mode 用三道防线,每道补上一道的漏洞:
- 提示引导(软):告诉模型「现在是计划模式,只研究、用 ExitPlanMode 提交计划」——让它主动配合,大多数情况够了。
- 钩子硬拦(硬):即使模型不听、真去调
Write,PreToolUse 钩子也会deny它——这是兜底的物理边界,不依赖模型自觉。 - 单一出口(可控):只有
ExitPlanMode工具能解除护栏,且必须过用户 y/n 审批——放行点唯一且集中,逻辑不散乱。
这题真正考的是一个安全设计原则:关键约束要有可编程的强制点,不能只靠提示。 提示负责「让模型想做对的事」,钩子负责「让模型想做错也做不了」。
# 三、原理:它是怎么工作的(How)
# 三块积木如何拼
| 防线 | 做法 | 复用了哪一步 |
|---|---|---|
| 模式状态 | state.planMode | step35 AppState |
| 提示引导 | buildSystemPrompt(..., planMode) 注入 # Plan Mode | step40/41 的系统提示 |
| 只读硬护栏 | PreToolUse 钩子 deny Write/Agent/AgentBatch | step36 Hooks |
| 提交出口 | ExitPlanMode 工具(factory 注入「展示+审批」) | step37/38 的工厂注入模式 |
# ① 提示注入(软引导)
buildSystemPrompt 按 planMode 在系统提示最前面注入强指令(放最前是为了最高权重):
...(planMode ? [
"# Plan Mode (ACTIVE)",
"You are in plan mode: you may ONLY research; you must NOT change anything (no Write, no mutating Bash commands, no sub-agents that modify files).",
"Use read-only tools (Read/Glob/Grep, read-only Bash) to understand the task, then call ExitPlanMode with a concise step-by-step plan and WAIT for approval.",
] : []),
2
3
4
5
# ② 只读钩子(硬护栏)
复用 step36 的 PreToolUse——注册一个钩子,计划模式下拦截会改东西的工具:
const PLAN_BLOCKED = new Set(["Write", "Agent", "AgentBatch"]);
hooks.onPreToolUse("plan-mode-readonly", (e) => {
if (state.planMode && PLAN_BLOCKED.has(e.tool)) {
return { deny: "当前是计划模式,不能修改。请先用 ExitPlanMode 提交计划并获得批准。" };
}
});
2
3
4
5
6
被 deny 的工具会生成 blocked by hook: ... 反馈给模型(step36 的机制),让它知道「这条路被挡了、该去提交计划」。
# ③ ExitPlanMode(唯一出口)
工具本体不含审批逻辑——审批(展示计划 + 问 y/n + 关闭 planMode)由 index.ts 注入进来:
// index.ts 注入审批逻辑
createExitPlanModeTool(async (plan) => {
console.log("\n─── 实施计划 ───\n" + plan);
const a = await ask("批准这个计划吗?(y=批准并开始实施 / n=不批准): ");
const approved = a.toLowerCase() === "y";
if (approved) state.planMode = false; // ★ 唯一解除护栏的地方
return { approved };
});
// exitPlanMode.ts 工具体
async execute(input): Promise<ToolResult> {
const plan = String(input?.plan ?? "");
const { approved } = await present(plan);
if (approved)
return { content: [{ type: "text", text: "计划已批准,已退出计划模式。现在可以开始实施了。" }] };
return { content: [{ type: "text",
text: "用户未批准该计划。仍处于计划模式——请根据用户反馈调整后再次提交,不要直接实施。" }],
isError: true };
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
拒绝时 state.planMode 保持 true——护栏不解除,模型带着反馈继续留在只读模式改计划。
# 数据流
/plan → state.planMode = true
↓ 每轮系统提示注入 "# Plan Mode (ACTIVE)"(软)
↓ 每次工具调用先过 plan-mode-readonly 钩子(硬)
AI: Read/Glob/Grep 研究 → 放行
AI: 想 Write → 钩子 deny → "blocked by hook" 喂回 → AI 转去提交计划
AI: ExitPlanMode({plan})
↓ present(plan) → 打印计划 → ask y/n
├─ y → state.planMode=false(护栏全解)→ 后续 Write 放行
└─ n → planMode 仍 true → AI 带反馈改计划
2
3
4
5
6
7
8
9
# 四、深入追问(面试常见 follow-up)
Q:如果只保留钩子硬拦、去掉系统提示引导,行不行?
A:能保证安全,但体验很差。没有提示引导,模型不知道「现在该只研究、最后用 ExitPlanMode 交计划」——它会照常去尝试 Write,被钩子拦、反馈、再试、再被拦,反复撞墙,最后可能都不知道要调 ExitPlanMode。提示让模型主动做对的事(高效路径),钩子只在它做错时兜底(安全网)。 两者是「引导 + 兜底」的分工,缺了提示,模型会低效地反复试错。
Q:反过来,只保留提示、去掉钩子呢?
A:那就退化成「不可靠的软约束」——大多数时候模型会听,但只要有一次它没听、直接 Write 改了文件,Plan Mode「批准前绝不改动」的承诺就破产了,用户的信任基础崩塌。对安全边界,「大多数时候有效」等于无效。必须有钩子这道可编程强制点。
Q:为什么解除护栏的地方只有 ExitPlanMode 审批通过这一处?分散几个出口不行吗?
A:单一出口是可控性的关键。state.planMode = false 只在「用户 y/n 审批通过」时发生,别处一律不碰。这样「什么情况下会开始改文件」的答案唯一且集中——审计、推理、改逻辑都只看一处。多个出口意味着多个「可能意外解除护栏」的地方,任何一个有 bug 都会破坏安全承诺。安全相关的状态翻转,出口越少越可控。
Q:ExitPlanMode 工具本身为什么不含审批 UI,而要 index.ts 注入 present?
A:延续 step37/38 的工厂注入模式。工具层不该知道「怎么跟用户交互」(console.log、ask、读 y/n 这些是 I/O)——那是外壳的事。工具只定义「提交计划 → 拿到 approved → 据此返回」的逻辑骨架,把「展示+审批」作为 present 回调注进来。这样工具可测(注入假 present)、可换 UI(Ink/SDK 换个 present 即可),职责干净。
# 五、设计权衡
- 黑名单 vs 白名单:这里用黑名单(
PLAN_BLOCKED = {Write, Agent, AgentBatch})拦「会改东西」的工具。好处是简单;风险是新加一个写工具容易忘了加进黑名单(漏网)。更严的做法是白名单(只放行 Read/Glob/Grep 等已知只读工具),默认拒绝。教学项目用黑名单够清楚,生产环境安全边界更偏向白名单。 - 「只读 Bash」判定粗糙:Bash 既能只读(
cat)也能改文件(rm),我们没细分——真实版会更细地判定「这条 Bash 命令是不是只读」。当前实现主要靠拦 Write/Agent 这几个明确的写工具,Bash 的写操作还要靠 step36 的危险命令钩子 + 提示引导兜。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
state.planMode | plan 权限模式(shift+Tab 切换) |
| 只读钩子拦截写操作 | plan 模式下禁用 Edit/Write/mutating Bash |
ExitPlanMode 工具 | ExitPlanMode(提交计划、请求批准) |
| 提示 + 钩子 + 出口 三道防线 | 真实版同样是引导 + 强制 + 审批 |
| 还缺:进入/退出的 UI 模式切换、更细的「只读 Bash」判定、计划保存 | 真实版都有 |
# 七、一句话总结
Plan Mode = 三块旧积木搭出的安全模式:AppState 存模式、系统提示做软引导、Hooks 做硬护栏、ExitPlanMode 做唯一审批出口。它证明了一个安全设计原则——关键约束(绝不改文件)必须有可编程强制点,不能只靠提示;提示让模型想做对、钩子让它想做错也做不了,二者缺一不可。
# 下一节预告
step43 转向上下文工程:把环境信息注入系统提示、支持 @文件 展开,让 AI 拿到更贴合当前工作现场的上下文。