我让 AI Agent 先别改代码,它怎么还是动手了?

🚀 欢迎来到「不用框架,手搓 AI Agent」系列第六篇。

即使没有读过前面的文章,也可以直接从这一篇开始。你只需要知道:我们已经有了一个能读取、修改文件和执行命令的最小 AI Agent。

这一篇,我们要让它学会一件更像工程师的事:面对长任务时,先调查、再规划,等人批准以后才开始写代码。

先讲一个公司里的小故事

上周五,隔壁同事探过头来:

"登录这块能不能顺手改一下?现在新旧两套接口看着有点乱,最好再补几个测试。"

"行,我先让 Agent 看看,应该挺快。"

你把需求复制给 Agent,然后起身接了杯水。

等你回来,它已经开始干活了:

text 复制代码
read_file
  src/login.ts

read_file
  src/routes.ts

edit_file
  src/login.ts

edit_file
  src/...

看起来还挺顺。没过多久,Agent 就回复:

"登录模块已经重构完成。"

你正准备收工,同事扫了一眼改动:

"等一下,你怎么改的是这个文件?"

"这不是登录入口吗?"

"这个早就不用了啊。线上走的是另外一层。还有那个旧接口不能删,客户端还在调。"

你赶紧往下翻,越看越不对:

text 复制代码
真正的登录入口还没读
旧接口有哪些调用方也没搜全
兼容逻辑被当成旧代码删了
测试命令一次都没跑

你问 Agent:

"为什么没先把调用关系查完整?"

它的回答也很合理:

"我根据当前读取到的文件完成了重构。"

这下问题就很清楚了。

你说的"先看看",是想让它先把项目摸清楚;它理解的"先看看",是读两个文件,然后边看边改。

模型不一定不会写代码。只是我们一开始就把 write_fileedit_filebash 全给了它,它当然可能在还没弄清楚的时候直接开干。

如果是把任务交给同事,我们多半会补一句:

"你先别改。把入口、调用方、兼容方案、可能踩的坑,还有准备怎么测都列一下。我看完没问题,你再动手。"

Plan Mode 到底是什么

Plan Mode 可以理解成 Agent 的"只做方案,暂不施工"阶段。

我们先以 Claude Code 为例。

在可以执行修改的模式下,Claude Code 的目标是把任务直接做完:

text 复制代码
读取项目
  ↓
修改文件
  ↓
执行命令
  ↓
验证结果

切换到 Plan Mode 之后,Claude Code 会暂时收紧修改源码的能力,先读取和搜索项目、形成计划,然后等待用户审批。

这时它的目标不是完成代码,而是先回答:

text 复制代码
真正需要改什么
涉及哪些文件和调用关系
准备按什么顺序实现
有哪些风险
最后怎样验证

因此,进入 Plan Mode 后,Agent 应该经历三个阶段:

text 复制代码
规划阶段
  读取、搜索和理解项目
  ↓
审批阶段
  提交计划,暂停等待用户确认
  ↓
执行阶段
  用户批准后进入 Code Mode
  恢复修改和命令执行能力

所以 Plan Mode 不是另一种大模型,也不只是生成一个 PLAN.md

它首先是 Harness 中的一种运行状态:

text 复制代码
当前处于什么阶段
  ↓
决定模型能看到哪些工具
  ↓
决定 Agent Loop 什么时候必须暂停

计划内容由大模型生成,但"批准前不能修改"和"提交后必须等待"应该由 Harness 保证。

为什么 Agent 总想马上改代码

这里先澄清一下:大模型不是真的"性格冲动"。

它只是会根据当前目标、上下文和可用工具,选择一个看起来最能推进任务的动作。

当用户说:

text 复制代码
帮我重构登录模块

Harness 又同时提供:

text 复制代码
read_file
write_file
edit_file
bash

那么从模型的角度看,读取和修改都是合法动作。它读到一两个相关文件后,很容易判断"信息已经够了",下一步自然就是调用 edit_file

Agent Loop 还会继续推动它向前:

text 复制代码
用户要求修改代码
  ↓
模型看到写工具
  ↓
读取少量文件
  ↓
调用 edit_file
  ↓
Harness 返回"修改成功"
  ↓
模型继续修改下一个文件

整个循环里,没有任何一步要求它:

text 复制代码
先证明自己已经找全入口
先列出影响范围
先把方案交给用户
等用户批准后才能继续

所以它不是故意乱来,而是在我们提供的 Harness 里,"直接开改"本来就是一条畅通无阻的路径。

即使在提示词里加一句:

text 复制代码
请先了解清楚,再修改代码。

模型仍然需要自己判断什么叫"了解清楚"。它可能读完两个文件,就认为这个条件已经满足了。

所以Plan Mode 做的第一件事,就是从能力层把这条路先堵住:

text 复制代码
Plan Mode 可用
├── read_file
├── list_files
├── search_files
└── submit_plan

Plan Mode 不可用
├── write_file
├── edit_file
└── bash

这时即使模型觉得"可以开始改了",它也没有修改文件的工具。它能做的只有继续读取、继续搜索,或者调用 submit_plan 把方案交出来。

不过,收走写工具只解决了"不能提前修改"。

要让它真的形成"先了解,再确认"的流程,还需要另外两层:

text 复制代码
规划提示词
  要求先探索项目,计划中写清步骤、风险和验证方式

审批状态机
  submit_plan 后暂停 Agent Loop,必须等待用户选择

所以这一篇实现的并不是一个工具开关,而是三层配合:

text 复制代码
工具限制
  保证批准前不能写

规划提示词
  引导模型先把项目了解清楚

用户审批
  决定什么时候恢复写工具

这个骨架并不是我们凭空发明的。Claude Code、Cursor、Codex 和 Oh My Pi 的 Plan Mode,都能看到类似的"先规划、再审批、后执行"思路,只是每家把它落在了不同的地方:

产品 和我们相似的地方 主要差异
Claude Code Plan Mode 会先调查项目、提交方案,批准前不修改源码 它把 Plan 直接做成了权限模式,并提供了多种批准后的执行方式
Cursor 先搜索代码库、询问关键问题、生成计划,然后等待用户批准 它的计划可以作为 Markdown 直接编辑和保存
Codex 同样强调先探索真实项目,再输出可执行的完整方案 它更像一种规划协作协议;公开资料并没有说内部也使用名为 submit_plan 的工具
Oh My Pi 有独立的 Plan 角色、计划产物和批准入口 还支持批准后清空、压缩或保留规划上下文,比我们的第一版复杂得多
Pi 原版 可以通过扩展实现这套流程 核心本身明确没有内置 Plan Mode,不能说它默认就是这样实现的

因此,我们这一版更准确的说法是:

用 Claude Code 式的权限边界和审批流程做骨架,再加入 Codex 式的规划提示词。

我们自己定义的 submit_plan,可以理解为一个明确的"规划完成"事件。它的作用和其他 Agent 里的"退出 Plan Mode"或"提交计划"很像,但不代表这些产品内部都使用了同名工具。

我们希望 Agent 也遵守同样的流程:

text 复制代码
先进入 Plan Mode
  ↓
只读取和搜索项目
  ↓
提交实施计划
  ↓
等待用户审批
  ↓
批准后进入 Code Mode
  ↓
修改代码并更新执行进度

最终的终端体验会是这样:

text 复制代码
> /plan
已进入 Plan Mode,只能读取和搜索项目。

> 重构登录模块,兼容旧接口并补上测试

Agent 读取目录、搜索调用关系、分析风险......

请选择:
1. 批准并执行
2. 继续修改计划
3. 取消

> 1
计划已批准,已进入 Code Mode。
已创建任务目录:
.powercode/tasks/20260817-143012000-重构登录模块/

批准之前,Agent 看不到写文件和执行命令的工具;批准之后,它才开始修改代码,并用 TODO 记录自己做到哪一步。

这就是这一篇要实现的 Plan Mode:

它不是一句"请先想清楚"的提示词,而是一套由 Harness 控制的"只读---审批---执行"状态机。

这一篇要实现什么

基于第五篇完成后的 powercode,我们增加六项能力:

  1. codeplan 两种模式;
  2. /plan/code/status 三个命令;
  3. Plan Mode 只能读取、列目录和搜索;
  4. 模型必须通过 submit_plan 提交计划标题、完整正文和执行步骤;
  5. Agent Loop 提交计划后暂停,等待用户审批;
  6. 批准后进入 Code Mode,在 .powercode/tasks/<plan-name>/ 中生成 PLAN.mdTODO.md 再执行。

最后的工具边界是:

text 复制代码
Plan Mode
├── read_file       # 读取指定文件的内容
├── list_files       # 查看项目目录结构
├── search_files     # 在项目中搜索文本和代码
└── submit_plan      # 提交完整计划并进入审批阶段

Code Mode
├── read_file       # 读取指定文件的内容
├── list_files       # 查看项目目录结构
├── search_files     # 在项目中搜索文本和代码
├── write_file      # 创建文件或写入完整内容
├── edit_file       # 对已有文件进行定向修改
└── bash            # 执行构建、测试等终端命令

为什么 Plan Mode 不提供 Bash?

不是因为 Bash 完全不能用,而是这个 Demo 的规划阶段根本不需要它。

Plan Mode 只需要完成三件事:

text 复制代码
read_file
  只读取一个文件

list_files
  只列出目录结构

search_files
  只搜索文本

这三个工具已经足够让 Agent 看清目录结构、找到相关代码并读取文件内容。

如果再把 Bash 交给 Plan Mode,我们就还要判断每条 Shell 命令到底只是查询,还是可能写文件、删文件或访问网络。这会引入很多和本文主线无关的边界处理。

像 Codex 和 Claude Code 这类成熟产品,可以继续通过命令解析、权限规则、用户审批和沙箱来约束 Bash。但对我们的第一版来说,最清楚的选择就是:

Plan Mode 只提供规划真正需要的专用查询工具,暂时不提供 Bash。


系列目录

  1. 不用框架,手搓 AI Agent:(一)先让它跑起来
  2. 不用 LangChain,手搓 AI Agent:给大模型装上"手",让它自己读项目文件
  3. 原来 AI Agent 的核心循环这么简单:手搓一个 Agent Loop
  4. Claude Code 是怎么自己改代码的?答案藏在这 4 个工具里
  5. 不用 LangChain:用 200 行代码手搓 Agent 对话记忆与上下文压缩
  6. 本文:AI Agent 如何执行长任务?给它加上 Plan Mode 和任务清单
  7. 更多实战持续更新中......

🚀 本节配套源码:powercode 👈 点它

如果中途遇到问题,可以对照源码排查。后续章节的代码也会持续更新,如果这个项目对你有帮助,欢迎点个 Star ⭐

先说明仓库和目录

本文继续使用主项目 powercode

第五篇结束后,我们已经把当时的代码冻结在:

text 复制代码
powercode/demos/05-session-compaction

这个目录是第五篇的独立 Demo,用来回看 Session 和 Compactor,不再继续修改。

第六篇要改的是主项目源码:

text 复制代码
powercode/
├── provider.json
├── src/                         # 本文继续开发的 Agent 源码
│   ├── agent.ts
│   ├── main.ts
│   ├── plan.ts                  # 本文新增
│   ├── plan-files.ts            # 本文新增
│   ├── options.ts               # 本文新增
│   ├── context/
│   │   ├── session.ts
│   │   └── compactor.ts
│   └── tools/
│       ├── read-file.ts
│       ├── write-file.ts
│       ├── edit-file.ts
│       ├── bash.ts
│       ├── list-files.ts        # 本文新增
│       ├── search-files.ts      # 本文新增
│       └── submit-plan.ts       # 本文新增
├── demos/
│   └── 05-session-compaction/   # 第五篇完成态,不再修改
└── workspace/
    └── task-board/              # 交给 Agent 规划和执行的练习目录
        └── .powercode/          # 运行plan后生成的任务工件
            └── tasks/
                └── <plan-name>/
                    ├── PLAN.md
                    └── TODO.md

三个目录不要混在一起:

text 复制代码
powercode/src
  我们正在开发的 Agent Harness

powercode/demos/05-session-compaction
  第五篇的冻结版本

powercode/workspace/task-board
  Agent 实际读取、搜索和修改的练习项目

后面的代码都基于第五篇完成后的主项目继续添加。没有读过前文也没关系:本文涉及的新文件和关键修改都会完整给出。

先说结论:Plan 和 TODO 是两个概念,但可以放在同一份文档

Plan 和 TODO 经常一起出现,但它们解决的不是同一个问题。

text 复制代码
Plan
  决定准备怎么做
  发生在执行之前
  需要用户审核

TODO
  记录现在做到哪一步
  发生在计划批准之后
  随执行进度更新

有些 Agent 会把两者放在同一份文档里。例如 Codex 生成的计划文档中,就可能直接出现这样的勾选清单:

markdown 复制代码
- [ ] 找到登录模块的真实入口
- [ ] 梳理旧接口的调用方
- [ ] 实现兼容层
- [ ] 补充测试并验证

在计划还没有批准时,这些勾选项表示的是"准备怎么做",本质上仍然是 Plan 的任务分解。如果 Agent 在批准后继续更新勾选状态,这份文档就同时承担了 TODO 的职责。

所以真正需要分开的是两种状态:

text 复制代码
批准前
  这是待审核的计划清单

批准后
  这是正在执行的进度清单

至于物理上是一个文件还是两个文件,属于 Harness 的实现选择。这一篇为了让职责更清楚,会为每次批准的计划创建一个独立任务目录,再在里面保存两份文件:

text 复制代码
.powercode/tasks/<plan-name>/
├── PLAN.md
│   保存用户批准的方案,尽量保持稳定

└── TODO.md
    从计划步骤生成,执行时持续更新

这样不同任务的计划和进度不会互相覆盖,以后也更容易增加任务历史和中断恢复。

完整流程应该是:

text 复制代码
Code Mode
  ↓ 用户输入 /plan
Plan Mode
  ↓ 只读项目、搜索代码、澄清需求
提交 Plan
  ↓
等待用户审批
  ├── 继续修改计划
  ├── 取消
  └── 批准
        ↓
      Code Mode
        ↓
      创建 .powercode/tasks/<plan-name>/
        ↓
      写入 PLAN.md 和 TODO.md
        ↓
      修改代码、逐项验证、更新 TODO

这和上一篇的上下文压缩也不是一回事:

text 复制代码
Session + Compactor
  解决"这一轮模型能看到什么"

Plan Mode
  解决"用户批准前,Agent 能做什么"

任务目录 + PLAN.md + TODO.md
  解决"这是哪次任务、批准了什么,以及执行到哪里"

三个问题,应该放在 Harness 的不同位置处理。


为什么不能只写一句"请先规划"

最简单的做法,是在 system prompt 里写:

text 复制代码
请先分析需求,不要修改代码。

这可以提高模型先思考的概率,但它不是可靠的权限边界。

如果 Harness 仍然把下面这些工具全部交给模型:

text 复制代码
read_file
write_file
edit_file
bash

模型依然有能力修改项目。提示词只是在劝它不要写,并没有真正收走写权限。

Claude Code 官方把 Plan Mode 放在 permission mode 体系里:Plan 阶段允许读取和探索,但不修改源码;计划完成后先交给用户审核,批准后再切换到执行权限。

参考资料:Claude Code Permission Modes

这正好能说明 Harness 的作用:

大模型负责生成方案,Harness 负责决定当前状态、提供哪些工具,以及什么时候暂停等待用户。

这一篇采用的组合是:

text 复制代码
Claude Code 式权限状态机
  +
更明确的规划提示词
  +
批准后的 TODO 执行进度

第一版暂时不实现独立 Plan 模型、Plan 子 Agent、上下文清空或全屏审核器。这些都是生产级 Plan Mode 可以继续增加的能力:

  • 独立 Plan 模型:规划阶段专门切换到更擅长推理的模型,批准后再换回更快或更便宜的执行模型。它本质上是模型路由,Agent Loop 还是同一个。
  • Plan 子 Agent:主 Agent 不亲自做完整规划,而是启动一个拥有独立上下文和工具的子 Agent 研究项目,最后把方案交回主 Agent。这会引入多 Agent 调度和结果交接。
  • 上下文清空:用户批准后,不把规划时的整段对话继续交给执行模型,只保留已批准的 Plan 和必要信息。清空的是消息上下文,不是删除项目文件。
  • 全屏审核器:不在终端里直接打印一大段 Plan,而是打开独立界面,让用户滚动查看、编辑、批注、批准或退回计划。这属于 TUI 交互增强。

独立 Plan 模型和 Plan 子 Agent 容易混淆:前者只是"换一个大模型思考",后者是"再启动一个 Agent 完成规划任务"。

本文的第一版使用同一个模型、同一个 Session 和普通 CLI 审批菜单。我们先把最重要的状态边界做对。


第一步:建立 Plan Mode 状态机

这一步先不涉及大模型和工具调用。我们只做一个专门保存 Plan Mode 运行状态的对象,让 Harness 随时能回答三个问题:

text 复制代码
当前是 Plan Mode 还是 Code Mode?
计划正在生成,还是已经等待审批?
当前保存的是待审批计划,还是已批准计划?

这些答案之后会决定 Agent 能看到哪些工具,以及 Agent Loop 是继续运行还是暂停等待用户。

等下的代码中还会出现一个 PlanDraft。先不要被这个名字吓到,它只是 Harness 内部保存"待审批计划"的数据格式:title 用来创建任务目录,content 保存完整的 Markdown 计划正文,steps 用来生成和更新 TODO。

先新建 src/plan.ts

ts 复制代码
export type AgentMode = "code" | "plan";

export type AgentStatus =
  | "idle"
  | "planning"
  | "waiting_for_approval"
  | "executing";

export interface PlanDraft {
  title: string;
  content: string;
  steps: string[];
}

export class AgentState {
  private mode: AgentMode;
  private status: AgentStatus;
  private pendingPlan?: PlanDraft;
  private approvedPlan?: PlanDraft;

  constructor(initialMode: AgentMode = "code") {
    this.mode = initialMode;
    this.status = initialMode === "plan" ? "planning" : "idle";
  }

  getMode(): AgentMode {
    return this.mode;
  }

  getStatus(): AgentStatus {
    return this.status;
  }

  getPendingPlan(): PlanDraft | undefined {
    return this.pendingPlan;
  }

  getApprovedPlan(): PlanDraft | undefined {
    return this.approvedPlan;
  }

  enterPlan(): void {
    this.mode = "plan";
    this.status = "planning";
    this.pendingPlan = undefined;
    this.approvedPlan = undefined;
  }

  enterCode(): void {
    this.mode = "code";
    this.status = "idle";
    this.pendingPlan = undefined;
    this.approvedPlan = undefined;
  }

  submitPlan(plan: PlanDraft): void {
    if (
      this.mode !== "plan" ||
      this.status !== "planning"
    ) {
      throw new Error("只有规划阶段可以提交计划。");
    }

    this.pendingPlan = plan;
    this.status = "waiting_for_approval";
  }

  refinePlan(): void {
    if (
      this.status !== "waiting_for_approval" ||
      !this.pendingPlan
    ) {
      throw new Error("当前没有等待修改的计划。");
    }

    this.status = "planning";
  }

  approvePlan(): PlanDraft {
    if (
      this.status !== "waiting_for_approval" ||
      !this.pendingPlan
    ) {
      throw new Error("当前没有等待批准的计划。");
    }

    const plan = this.pendingPlan;

    this.mode = "code";
    this.status = "executing";
    this.pendingPlan = undefined;
    this.approvedPlan = plan;

    return plan;
  }

  cancelPlan(): void {
    this.mode = "code";
    this.status = "idle";
    this.pendingPlan = undefined;
    this.approvedPlan = undefined;
  }

  finishExecution(): void {
    if (this.status === "executing") {
      this.status = "idle";
      this.approvedPlan = undefined;
    }
  }
}

export function formatPlan(plan: PlanDraft): string {
  return `# ${plan.title}

${plan.content.trim()}
`;
}

1. modestatus 分别管什么

这里故意把 modestatus 分开:

text 复制代码
mode
  控制 Agent 当前拥有哪些能力
  例如 Plan Mode 看不到 write_file 和 bash

status
  记录 Agent Loop 已经走到哪个阶段
  例如正在规划、等待审批或正在执行

为什么不只定义一个布尔值?

ts 复制代码
planMode: boolean

因为 planMode = true 只能告诉我们"当前在 Plan Mode",却无法表达下面两种完全不同的情况:

text 复制代码
mode = plan
status = planning
  Agent 还在读项目、搜代码和生成方案

mode = plan
status = waiting_for_approval
  计划已经提交,Agent Loop 必须暂停

两种状态下的工具边界虽然一样,但前者还可以继续调用模型,后者只能等待用户选择。

2. Harness 怎样接住模型生成的计划

前面我们一直在说,计划需要包含目标、实现步骤、风险和验证方式。

这些内容最终就是一份普通的 Markdown:

markdown 复制代码
# 重构登录模块

## 目标

在兼容旧接口的前提下重构登录流程。

## 实现步骤

1. 找到登录入口
2. 梳理旧接口调用方
3. 增加兼容层

## 风险

旧版本客户端可能仍然调用原接口。

## 验证方式

运行登录测试,并手动验证旧客户端。

这里先把一个容易混淆的地方说清楚:

大模型可以直接理解整份 Markdown。我们不需要先把风险、验证方式等章节提取出来,再重新交给它。

用户批准以后,Harness 可以把完整计划放进模型上下文,也可以告诉模型 PLAN.md 的路径,让它自己读取。两种方式都可以。

不管是 Harness 主动把计划放进去,还是 Agent 使用 read_file 读取,计划正文最终都要进入模型上下文,大模型才能理解和执行。PLAN.md 的真正价值,是把批准后的计划持久化:即使上下文后来被压缩或丢失,Agent 仍然可以重新读取它。

既然如此,为什么还要定义 PlanDraft

因为这一篇还希望 Harness 自动完成两件事情:

text 复制代码
根据计划标题创建任务目录
根据执行步骤生成 TODO.md

如果让 Harness 自己从自由格式的 Markdown 中猜标题和步骤,解析会很不稳定。但目标、背景、风险和验证说明只需要给用户与大模型阅读,没有必要都拆成字段。

因此第一版只结构化程序真正需要处理的部分:

ts 复制代码
export interface PlanDraft {
  title: string;
  content: string;
  steps: string[];
}

interface 只是 TypeScript 用来描述数据形状的方式。PlanDraft 不是一个新 Agent,也不是一份额外的草稿文件,它只是一个保存待审批计划的 JavaScript 对象格式。

为什么名字里有 Draft

因为模型刚提交时,这份计划还没有经过用户批准,它只能算是"待审核的计划草案"。批准之前,用户还可以要求模型修改或者取消它。

模型产生的数据会这样流转:

text 复制代码
模型调用 submit_plan
  ↓ 提交 title、content、steps
Harness 得到 PlanDraft
  ↓
保存到 pendingPlan
  ↓ 用户批准
移入 approvedPlan
  ↓
生成 PLAN.md 和 TODO.md

现在再看每个字段,就比较容易理解了:

text 复制代码
title
  计划名称,后面用来生成任务目录名

content
  完整的 Markdown 计划正文
  目标、方案、风险和验证方式都写在这里

steps
  从 content 中提炼出的可执行步骤
  用来生成和更新 TODO.md

注意,这里不是 Harness 先保存 content,再从 Markdown 里解析出 steps。模型调用 submit_plan 时会同时提交这三个字段,Harness 只负责检查它们是否为非空字符串或数组。

这里会有少量重复:content 里会写实现方案,steps 又把可执行项单独列了一遍。这是有意为之。

text 复制代码
content
  给用户审核,也给大模型执行

steps
  给 Harness 生成和跟踪 TODO

Plan Mode 提示词会要求两者保持一致。第一版只能约束格式和非空,不能从语义上证明两者完全相同;但这已经足够完成教学 Demo。这样既保留了 Markdown 的表达能力,又不需要 Harness 自己解析整份文档。

3. pendingPlanapprovedPlan 有什么区别

这两个字段保存的都是 PlanDraft,但信任级别不同:

text 复制代码
pendingPlan
  模型已经提交,但用户还没批准
  它可以被要求修改,也可以被取消

approvedPlan
  用户已经批准
  Agent 可以把它当成后续执行的正式依据

批准时做的关键动作,就是把同一份计划从"待审批区"移到"已批准区":

text 复制代码
pendingPlan
  ↓ 用户批准
approvedPlan

这也是 approvePlan() 里面这几行代码的含义:

ts 复制代码
const plan = this.pendingPlan;

this.mode = "code";
this.status = "executing";
this.pendingPlan = undefined;
this.approvedPlan = plan;

注意,"模型生成了计划"并不代表"计划已经生效"。只有用户批准以后,它才会进入 approvedPlan

4. 这些方法实际上是状态转换入口

AgentState 并不负责调用大模型,也不负责写入 PLAN.md。它只负责保存状态,并确保状态只能通过下面这些入口改变:

  • enterPlan():进入 Plan Mode,把状态切到 planning,并清理上一次未完成的计划。
  • enterCode():手动回到 Code Mode,相当于放弃当前规划流程。
  • submitPlan():只允许在 Plan Mode 调用,保存待审批计划,并进入 waiting_for_approval
  • refinePlan():用户要求修改时,从等待审批返回 planning
  • approvePlan():把待审批计划转成已批准计划,切换到 Code Mode 并开始执行。
  • cancelPlan():取消计划,清空两个计划字段并回到 idle
  • finishExecution():执行结束后,把 executing 恢复为 idle,并清除只在本次执行期间使用的 approvedPlan。已经落盘的 PLAN.mdTODO.md 不受影响。

把它们串起来,就能看到完整的状态机:

flowchart TD A[&#34;mode: code<br/>status: idle&#34;] -->|&#34;用户输入 /plan<br/>enterPlan()&#34;| B[&#34;mode: plan<br/>status: planning&#34;] B -->|&#34;用户输入 /code<br/>enterCode()&#34;| A B -->|&#34;模型提交计划<br/>submitPlan()&#34;| C[&#34;mode: plan<br/>status: waiting_for_approval&#34;] C -->|&#34;用户要求继续修改<br/>refinePlan()&#34;| B C -->|&#34;用户取消<br/>cancelPlan()&#34;| A C -->|&#34;用户批准<br/>approvePlan()&#34;| D[&#34;mode: code<br/>status: executing&#34;] D -->|&#34;执行结束<br/>finishExecution()&#34;| A classDef code fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20; classDef plan fill:#fff8e1,stroke:#f9a825,color:#5d4037; class A,D code; class B,C plan;

这张图需要分成两层来看:

text 复制代码
方框里的 mode
  code 表示 Code Mode
  plan 表示 Plan Mode
  它决定当前能使用哪些工具

方框里的 status
  idle、planning、waiting_for_approval、executing
  决定 Agent Loop 正在规划、等待,还是执行

箭头上的方法
  表示状态只能通过哪个入口发生变化

例如,模型调用 submitPlan() 后,只是从 planning 进入 waiting_for_approval,此时仍然处于 Plan Mode,写工具不会恢复。接下来 Agent Loop 必须停住,只能等待用户选择继续修改、取消或批准。

只有用户选择批准,approvePlan() 才会同时完成两件事:把 mode 切回 code,再把 status 切到 executing。这时 Harness 才创建任务文件并开始执行。

submitPlan()refinePlan()approvePlan() 里面主动检查当前状态,是为了防止跳过正常流程。例如,在 Code Mode 下直接提交计划,或在没有待审批计划时直接批准,都应该立即报错。

5. formatPlan() 只负责补上标题

content 已经是完整的 Markdown 计划正文,所以 formatPlan() 不需要理解或重新排列里面的章节。

text 复制代码
title + content
  ↓ formatPlan()
可以展示和保存的完整 PLAN.md

它不会批准计划、不会切换模式,也不会写文件,只负责把标题和正文拼在一起。目标、方案、风险和验证方式仍然保留在模型生成的 content 中。


第二步:给 CLI 增加模式命令

Claude Code 可以通过 Shift+Tab 切换 Plan Mode。

我们的第一版先使用三个文本命令:

text 复制代码
/plan       进入 Plan Mode
/code       返回 Code Mode
/status     查看当前模式和状态

原因很简单:当前项目使用 readline.question() 做逐行输入。如果现在就捕获 Shift+Tab,还需要处理原始按键、终端转义序列和输入框刷新,文章会从 Agent Harness 跑到 TUI 实现上。

但快捷键和文本命令最终应该调用同一个状态切换函数:

text 复制代码
/plan ──────────┐
                ├── state.enterPlan()
Shift+Tab ──────┘

所以第一版先把状态机做好,以后增加快捷键不需要修改 Agent 核心。

新建 src/options.ts

ts 复制代码
export interface CliOptions {
  dir: string;
  plan: boolean;
}

function readValue(args: string[], name: string): string | undefined {
  const index = args.indexOf(name);

  if (index === -1) {
    return undefined;
  }

  const value = args[index + 1];

  if (!value || value.startsWith("-")) {
    throw new Error(`${name} 后面需要一个值。`);
  }

  return value;
}

export function parseCliOptions(args: string[]): CliOptions {
  return {
    dir: readValue(args, "-dir") ?? ".",
    plan: args.includes("--plan"),
  };
}

parseCliOptions() 让用户在启动 PowerCode 时可以决定两件事:Agent 一开始进入哪种模式,以及它要操作哪个项目目录。

本文使用下面这条命令启动:

bash 复制代码
npm start -- --plan -dir ./workspace/task-board

其中,npm start 后面的第一个 -- 只是 npm 的参数分隔符,表示把后面的内容继续交给 PowerCode。真正属于 PowerCode 的参数是:

text 复制代码
--plan
  启动后直接进入 Plan Mode

-dir ./workspace/task-board
  把 workspace/task-board 设为 Agent 的工作目录

这两个参数彼此独立:--plan 决定初始模式,-dir 决定工具可以读取、搜索和修改的目录。本文把它们放在一起,是为了让 Agent 启动后立即规划练习项目,同时避免它拿 powercode 自己的源码做实验。

本文显式使用 -dir,是因为我们目前还在 PowerCode 的源码目录中通过 npm start 调试 CLI。将来 PowerCode 发布为 npm 全局命令后,用户只需要进入自己的项目再运行 powercodeprocess.cwd() 对应的当前项目目录就会自动成为 Agent 的工作空间;-dir 只作为从其他位置指定项目时的可选覆盖参数保留。

也就是说,未来最常见的使用方式应该是:

bash 复制代码
cd ~/projects/my-app
powercode --plan

只有不方便先进入目标项目时,才需要显式指定:

bash 复制代码
powercode --plan -dir ~/projects/my-app

第三步:增加两个真正只读的探索工具

第五篇已经有 read_file,但读取项目还需要两项基础能力:

text 复制代码
list_files
  看目录结构

search_files
  按关键词查代码

新建 src/tools/list-files.ts

ts 复制代码
import { readdir } from "node:fs/promises";
import { join, relative } from "node:path";
import type { Tool } from "./types.ts";
import { resolveInWorkDir } from "./path.ts";

const MAX_ITEMS = 200;
const SKIPPED_DIRECTORIES = new Set([
  ".git",
  ".powercode",
  "dist",
  "node_modules",
]);

function parsePath(argumentsJson: string): string {
  const input = JSON.parse(argumentsJson) as {
    path?: unknown;
  };

  if (input.path !== undefined && typeof input.path !== "string") {
    throw new Error("path 必须是字符串。");
  }

  return input.path ?? ".";
}

function toDisplayPath(path: string): string {
  return path.replaceAll("\\", "/");
}

export class ListFilesTool implements Tool {
  readonly name = "list_files";

  readonly definition = {
    type: "function" as const,
    function: {
      name: this.name,
      description: "递归列出当前项目中的文件和目录。",
      parameters: {
        type: "object",
        properties: {
          path: {
            type: "string",
            description: "相对于项目根目录的目录,默认是当前项目根目录",
          },
        },
        additionalProperties: false,
      },
    },
  };

  constructor(private readonly workDir: string) {}

  async execute(argumentsJson: string): Promise<string> {
    const path = parsePath(argumentsJson);
    const start = resolveInWorkDir(this.workDir, path);
    const items: string[] = [];

    const walk = async (directory: string): Promise<void> => {
      if (items.length >= MAX_ITEMS) {
        return;
      }

      const entries = await readdir(directory, {
        withFileTypes: true,
      });

      entries.sort((left, right) =>
        left.name.localeCompare(right.name),
      );

      for (const entry of entries) {
        if (items.length >= MAX_ITEMS) {
          return;
        }

        if (entry.isSymbolicLink()) {
          continue;
        }

        if (
          entry.isDirectory() &&
          SKIPPED_DIRECTORIES.has(entry.name)
        ) {
          continue;
        }

        const fullPath = join(directory, entry.name);
        const displayPath = toDisplayPath(
          relative(this.workDir, fullPath),
        );

        items.push(
          entry.isDirectory()
            ? `${displayPath}/`
            : displayPath,
        );

        if (entry.isDirectory()) {
          await walk(fullPath);
        }
      }
    };

    await walk(start);

    if (items.length === 0) {
      return "目录为空。";
    }

    const suffix =
      items.length >= MAX_ITEMS
        ? `\n...[最多返回 ${MAX_ITEMS} 项]...`
        : "";

    return items.join("\n") + suffix;
  }
}

这里仍然复用了第四篇的 resolveInWorkDir()

即使工具只读,也必须限制路径不能逃出工作目录。只读不等于可以读取用户电脑上的任意文件。

新建 src/tools/search-files.ts

ts 复制代码
import {
  readFile,
  readdir,
  stat,
} from "node:fs/promises";
import { join, relative } from "node:path";
import type { Tool } from "./types.ts";
import { resolveInWorkDir } from "./path.ts";

const MAX_RESULTS = 50;
const MAX_FILE_BYTES = 200_000;
const SKIPPED_DIRECTORIES = new Set([
  ".git",
  ".powercode",
  "dist",
  "node_modules",
]);

interface SearchInput {
  query: string;
  path: string;
}

function parseInput(argumentsJson: string): SearchInput {
  const input = JSON.parse(argumentsJson) as {
    query?: unknown;
    path?: unknown;
  };

  if (
    typeof input.query !== "string" ||
    !input.query.trim()
  ) {
    throw new Error("query 必须是非空字符串。");
  }

  if (
    input.path !== undefined &&
    typeof input.path !== "string"
  ) {
    throw new Error("path 必须是字符串。");
  }

  return {
    query: input.query,
    path: input.path ?? ".",
  };
}

function toDisplayPath(path: string): string {
  return path.replaceAll("\\", "/");
}

export class SearchFilesTool implements Tool {
  readonly name = "search_files";

  readonly definition = {
    type: "function" as const,
    function: {
      name: this.name,
      description:
        "在当前项目的文本文件中搜索关键词。",
      parameters: {
        type: "object",
        properties: {
          query: {
            type: "string",
            description: "要搜索的文本关键词",
          },
          path: {
            type: "string",
            description:
              "相对于项目根目录的搜索目录,默认是项目根目录",
          },
        },
        required: ["query"],
        additionalProperties: false,
      },
    },
  };

  constructor(private readonly workDir: string) {}

  async execute(argumentsJson: string): Promise<string> {
    const input = parseInput(argumentsJson);
    const start = resolveInWorkDir(
      this.workDir,
      input.path,
    );
    const query = input.query.toLowerCase();
    const results: string[] = [];

    const walk = async (directory: string): Promise<void> => {
      if (results.length >= MAX_RESULTS) {
        return;
      }

      const entries = await readdir(directory, {
        withFileTypes: true,
      });

      for (const entry of entries) {
        if (results.length >= MAX_RESULTS) {
          return;
        }

        if (entry.isSymbolicLink()) {
          continue;
        }

        if (
          entry.isDirectory() &&
          SKIPPED_DIRECTORIES.has(entry.name)
        ) {
          continue;
        }

        const fullPath = join(directory, entry.name);

        if (entry.isDirectory()) {
          await walk(fullPath);
          continue;
        }

        const fileStat = await stat(fullPath);

        if (fileStat.size > MAX_FILE_BYTES) {
          continue;
        }

        const content = await readFile(fullPath, "utf8");

        if (content.includes("\0")) {
          continue;
        }

        const displayPath = toDisplayPath(
          relative(this.workDir, fullPath),
        );
        const lines = content.split(/\r?\n/);

        for (
          let index = 0;
          index < lines.length;
          index += 1
        ) {
          const line = lines[index];

          if (line.toLowerCase().includes(query)) {
            results.push(
              `${displayPath}:${index + 1}: ${line.trim()}`,
            );
          }

          if (results.length >= MAX_RESULTS) {
            return;
          }
        }
      }
    };

    await walk(start);

    if (results.length === 0) {
      return `没有找到关键词:${input.query}`;
    }

    const suffix =
      results.length >= MAX_RESULTS
        ? `\n...[最多返回 ${MAX_RESULTS} 条结果]...`
        : "";

    return results.join("\n") + suffix;
  }
}

这个搜索工具没有正则表达式、管道和重定向,只做一件明确的事:在工作目录内查找文本。

这比把完整 Bash 交给 Plan Mode 更容易解释,也更容易测试。

注意两个工具都默认跳过 .powercode。这里保存的是 Harness 工件,不是项目源码;如果后续规划时反复搜到历史 Plan 和 TODO,反而可能干扰模型判断。执行阶段仍然可以通过 read_fileedit_file 访问审批消息中给出的精确任务文件路径。


第四步:让 Registry 同时负责"隐藏"和"拦截"

只是不把写工具的定义发给模型,还不够完整。

模型通常不会调用一个没有见过的工具,但 Harness 仍然应该在真正执行前再次检查:

text 复制代码
第一层
  不把 write_file、edit_file、bash 放进 tools 参数

第二层
  即使模型生成了这些工具名,Registry 也拒绝执行

修改 src/tools/registry.ts

ts 复制代码
import type OpenAI from "openai";
import type { Tool } from "./types.ts";

export class Registry {
  private readonly tools = new Map<string, Tool>();

  register(tool: Tool): void {
    if (this.tools.has(tool.name)) {
      throw new Error(`工具重复注册:${tool.name}`);
    }

    this.tools.set(tool.name, tool);
  }

  getDefinitions(
    allowedNames?: ReadonlySet<string>,
  ): OpenAI.Chat.Completions.ChatCompletionTool[] {
    return [...this.tools.values()]
      .filter(
        (tool) =>
          !allowedNames || allowedNames.has(tool.name),
      )
      .map((tool) => tool.definition);
  }

  async execute(
    name: string,
    argumentsJson: string,
    allowedNames?: ReadonlySet<string>,
  ): Promise<string> {
    if (allowedNames && !allowedNames.has(name)) {
      throw new Error(
        `当前模式不允许调用工具:${name}`,
      );
    }

    const tool = this.tools.get(name);

    if (!tool) {
      throw new Error(`找不到工具:${name}`);
    }

    return tool.execute(argumentsJson);
  }
}

到这里,Plan Mode 的"只读"已经不只是提示词了。

真正的边界是:

ts 复制代码
const PLAN_TOOL_NAMES = new Set([
  "read_file",
  "list_files",
  "search_files",
  "submit_plan",
]);

write_fileedit_filebash 不在集合里,所以模型看不到,Registry 也不会执行。


第五步:增加 submit_plan 工具

如果计划只是普通 Markdown 文本,Agent Loop 很难准确判断:

text 复制代码
模型是在解释思路?
还是已经提交最终计划?
现在该继续调用模型?
还是应该暂停等待用户?

所以增加一个明确的控制工具:

text 复制代码
submit_plan
  模型表示"计划已经完成"
  Harness 把状态切到 waiting_for_approval
  Agent Loop 暂停

新建 src/tools/submit-plan.ts

ts 复制代码
import {
  AgentState,
  type PlanDraft,
} from "../plan.ts";
import type { Tool } from "./types.ts";

function parseString(
  input: Record<string, unknown>,
  name: string,
): string {
  const value = input[name];

  if (typeof value !== "string" || !value.trim()) {
    throw new Error(`${name} 必须是非空字符串。`);
  }

  return value.trim();
}

function parseStringArray(
  input: Record<string, unknown>,
  name: string,
): string[] {
  const value = input[name];

  if (
    !Array.isArray(value) ||
    value.some(
      (item) =>
        typeof item !== "string" || !item.trim(),
    )
  ) {
    throw new Error(`${name} 必须是字符串数组。`);
  }

  if (value.length === 0) {
    throw new Error(`${name} 不能为空。`);
  }

  return value.map((item) => item.trim());
}

function parsePlan(argumentsJson: string): PlanDraft {
  const input: unknown = JSON.parse(argumentsJson);

  if (
    typeof input !== "object" ||
    input === null ||
    Array.isArray(input)
  ) {
    throw new Error("计划参数必须是对象。");
  }

  const record = input as Record<string, unknown>;

  return {
    title: parseString(record, "title"),
    content: parseString(record, "content"),
    steps: parseStringArray(record, "steps"),
  };
}

export class SubmitPlanTool implements Tool {
  readonly name = "submit_plan";

  readonly definition = {
    type: "function" as const,
    function: {
      name: this.name,
      description:
        "提交最终实施计划,并暂停等待用户审批。",
      parameters: {
        type: "object",
        properties: {
          title: {
            type: "string",
            description: "计划标题",
          },
          content: {
            type: "string",
            description:
              "完整的 Markdown 计划正文,包含目标、方案、风险和验证方式,不重复一级标题",
          },
          steps: {
            type: "array",
            items: { type: "string" },
            description: "按执行顺序排列的实施步骤",
          },
        },
        required: [
          "title",
          "content",
          "steps",
        ],
        additionalProperties: false,
      },
    },
  };

  constructor(private readonly state: AgentState) {}

  async execute(argumentsJson: string): Promise<string> {
    const plan = parsePlan(argumentsJson);
    this.state.submitPlan(plan);

    return "计划已提交,等待用户审批。";
  }
}

这个工具不修改用户源码。

它只把计划标题、Markdown 正文和执行步骤写进 AgentState,然后改变 Harness 状态。计划内容稍后由 CLI 展示给用户。


第六步:给 Plan Mode 注入专属提示词

现在权限边界已经由 Harness 保证,提示词只负责提高计划质量。

修改 src/agent.ts 时,先补上状态相关的导入:

ts 复制代码
import {
  AgentState,
  formatPlan,
} from "./plan.ts";

然后把原来的单条 system prompt 拆成三段:

ts 复制代码
const CORE_SYSTEM_PROMPT = `
你是 power-code,一个研发助手。
请优先读取真实文件;修改后主动运行命令验证结果;请使用中文回答。
所有文件路径都相对于当前工作目录,不能操作工作目录之外的文件。
`;

const PLAN_MODE_PROMPT = `

# 当前模式:Plan Mode

你现在只能研究项目和制定计划,不能修改文件或执行命令。

请按以下顺序工作:

1. 先使用 list_files、search_files 和 read_file 了解真实项目。
2. 能从项目中找到的答案,不要询问用户。
3. 只有关键需求或取舍无法从项目确认时,才向用户提出问题并等待回答。
4. 不要把普通猜测写成已经确定的事实。
5. content 必须是一份完整的 Markdown 计划正文,写清目标、方案、风险和验证方式,不重复 title 对应的一级标题。
6. steps 必须从 content 的实施方案中提炼,按执行顺序排列,并包含必要的验证步骤。
7. content 和 steps 不能出现两套不同的方案。
8. 计划完成后必须调用 submit_plan,不要只输出一段普通 Markdown。
`;

const EXECUTION_PROMPT = `

# 当前模式:Code Mode

下面的计划已经由用户批准。

批准消息会给出本次任务的 PLAN.md 和 TODO.md 路径。
请先读取这两份文件,然后按照 TODO.md 从上到下执行。

- 完成一个有结果的步骤后,立即把对应的 - [ ] 更新为 - [x]。
- 修改代码但还没有完成对应验证时,不能勾选包含验证工作的步骤。
- 遇到错误时先读取本次任务的 TODO.md 确认当前位置,再修复并重新验证。
- 不得擅自改变已经批准的目标和范围。
`;

这里借鉴的是一种很实用的规划顺序:

text 复制代码
先探索
  ↓
能从代码确认的先自己确认
  ↓
只询问真正需要用户决定的问题
  ↓
生成可以直接执行的计划

注意,提示词不是安全边界。

即使模型忽略了"不能修改文件",Plan Mode 也拿不到写工具;这才是前面修改 Registry 的意义。


第七步:根据模式动态提供工具

继续修改 src/agent.ts

定义两组工具:

ts 复制代码
const PLAN_TOOL_NAMES = new Set([
  "read_file",
  "list_files",
  "search_files",
  "submit_plan",
]);

const CODE_TOOL_NAMES = new Set([
  "read_file",
  "list_files",
  "search_files",
  "write_file",
  "edit_file",
  "bash",
]);

然后给 Agent 增加 AgentState

ts 复制代码
constructor(
  private readonly client: ChatClient,
  private readonly registry: Registry,
  private readonly session: Session,
  private readonly state: AgentState,
) {
  this.compactor = new Compactor(
    client,
    client.getContextWindow(),
  );
}

增加两个辅助方法:

ts 复制代码
private getAllowedToolNames(): ReadonlySet<string> {
  return this.state.getMode() === "plan"
    ? PLAN_TOOL_NAMES
    : CODE_TOOL_NAMES;
}

private buildSystemPrompt(): string {
  if (this.state.getMode() === "plan") {
    return CORE_SYSTEM_PROMPT + PLAN_MODE_PROMPT;
  }

  const approvedPlan = this.state.getApprovedPlan();

  if (approvedPlan) {
    return (
      CORE_SYSTEM_PROMPT +
      EXECUTION_PROMPT +
      `\n\n${formatPlan(approvedPlan)}`
    );
  }

  return CORE_SYSTEM_PROMPT;
}

这里正是"把整份计划直接交给大模型"的位置。formatPlan(approvedPlan) 会把 title + content 原样加入执行阶段的 system prompt,大模型不需要依赖 Harness 解析风险或验证方式。后面再写入 PLAN.md,是为了把批准结果持久化,方便执行中重读和以后回看。

调用模型时,不再直接获取全部工具:

ts 复制代码
const allowedToolNames =
  this.getAllowedToolNames();

const completion = await this.client.completeWithUsage(
  messages,
  this.registry.getDefinitions(allowedToolNames),
);

执行工具时也传入同一份集合:

ts 复制代码
result = await this.registry.execute(
  name,
  toolCall.function.arguments,
  allowedToolNames,
);

最后,在一轮工具调用结束后检查状态:

ts 复制代码
if (
  this.state.getStatus() ===
  "waiting_for_approval"
) {
  const plan = this.state.getPendingPlan();

  if (!plan) {
    throw new Error("计划状态异常。");
  }

  return formatPlan(plan);
}

这样,模型调用 submit_plan 后,Agent Loop 不会继续让它自己批准自己,而是立刻返回 CLI。

src/agent.ts 的完整关键结构

整合后的 run() 主体如下:

ts 复制代码
async run(prompt: string): Promise<string> {
  if (
    this.state.getStatus() ===
    "waiting_for_approval"
  ) {
    throw new Error("当前计划正在等待审批。");
  }

  this.session.append({
    role: "user",
    content: prompt,
  });

  for (let step = 1; step <= MAX_STEPS; step += 1) {
    const memory =
      await this.compactor.buildWorkingMemory(
        this.session,
      );
    const allowedToolNames =
      this.getAllowedToolNames();

    const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] =
      [
        {
          role: "system",
          content: this.buildSystemPrompt(),
        },
        ...memory,
      ];

    const completion =
      await this.client.completeWithUsage(
        messages,
        this.registry.getDefinitions(
          allowedToolNames,
        ),
      );
    const message = completion.message;

    if (!message) {
      throw new Error("模型没有返回消息。");
    }

    this.session.append(message);

    if (
      completion.totalTokens !== undefined &&
      completion.totalTokens > 0
    ) {
      this.session.saveUsage(
        completion.totalTokens,
      );
    }

    const toolCalls = message.tool_calls ?? [];

    if (toolCalls.length === 0) {
      this.state.finishExecution();

      return (
        message.content ?? "模型没有返回文本内容。"
      );
    }

    for (const toolCall of toolCalls) {
      if (toolCall.type !== "function") {
        throw new Error(
          `暂时不支持工具类型:${toolCall.type}`,
        );
      }

      const name = toolCall.function.name;
      console.log(
        `第 ${step} 轮:AI 调用 ${name}`,
      );

      let result: string;

      try {
        result = await this.registry.execute(
          name,
          toolCall.function.arguments,
          allowedToolNames,
        );
        console.log(`✓ ${name} 执行完成\n`);
      } catch (error) {
        const reason =
          error instanceof Error
            ? error.message
            : String(error);
        result = `工具执行失败:${reason}`;
        console.log(`✗ ${result}\n`);
      }

      this.session.append({
        role: "tool",
        tool_call_id: toolCall.id,
        content: result,
      });
    }

    if (
      this.state.getStatus() ===
      "waiting_for_approval"
    ) {
      const plan = this.state.getPendingPlan();

      if (!plan) {
        throw new Error("计划状态异常。");
      }

      return formatPlan(plan);
    }
  }

  throw new Error(
    `执行超过 ${MAX_STEPS} 轮,已停止。`,
  );
}

原来的 Session 和 Compactor 不需要删除。

Plan 阶段读取的项目内容仍然进入当前会话,过长时仍然由 Compactor 构造工作记忆。我们只是改变了每一轮能使用的工具。


第八步:为每个计划创建独立任务目录

为什么不在 Plan Mode 一开始就创建独立的 TODO.md

因为这时方案还没有批准。

计划本身完全可以包含未勾选的步骤清单,Codex 就会采用这种表达方式。但在本文的双文件设计里,TODO.md 代表已经进入执行阶段的正式进度。

如果用户要求继续修改计划或直接取消,工作目录里就不应该出现一份看起来已经开始执行的 TODO.md

所以顺序是:

text 复制代码
模型提交 Plan
  ↓
CLI 展示给用户
  ↓
用户批准
  ↓
Harness 根据 Plan 标题生成安全目录名
  ↓
Harness 创建 .powercode/tasks/<plan-name>/
  ↓
Harness 写入 PLAN.md 和 TODO.md

任务目录名不能直接使用模型返回的原始标题。标题里可能有空格、斜杠、.. 或其他不适合进入路径的字符,同名计划也可能相互覆盖。

所以 Harness 需要先把标题转换成安全名称,再加上时间标识:

text 复制代码
计划标题
  重构登录模块,兼容旧接口

任务目录
  .powercode/tasks/20260817-143012000-重构登录模块-兼容旧接口/

这个 .powercode 位于用户通过 -dir 指定的执行目录中,不是固定写进 Harness 自己的源码目录。本文的启动命令使用 -dir ./workspace/task-board,因此实际位置是:

text 复制代码
powercode/workspace/task-board/.powercode/tasks/<plan-name>/

新建 src/plan-files.ts

ts 复制代码
import {
  mkdir,
  writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import {
  formatPlan,
  type PlanDraft,
} from "./plan.ts";

export interface PlanFiles {
  taskDir: string;
  planPath: string;
  todoPath: string;
}

function createTaskName(title: string): string {
  const safeTitle = title
    .normalize("NFKC")
    .trim()
    .toLowerCase()
    .replace(/[^\p{L}\p{N}]+/gu, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 60)
    .replace(/-+$/g, "");

  const digits = new Date()
    .toISOString()
    .replace(/\D/g, "")
    .slice(0, 17);
  const timestamp = [
    digits.slice(0, 8),
    digits.slice(8),
  ].join("-");

  return `${timestamp}-${safeTitle || "plan"}`;
}

function formatTodo(plan: PlanDraft): string {
  const steps = plan.steps
  
    .map((item) => `- [ ] ${item}`)
    .join("\n");

  return `# 执行清单

> 这份清单由已经批准的 PLAN.md 生成。

## 实现步骤

${steps}
`;
}

export async function saveApprovedPlan(
  workDir: string,
  plan: PlanDraft,
): Promise<PlanFiles> {
  const taskName = createTaskName(plan.title);
  const taskDir = join(
    ".powercode",
    "tasks",
    taskName,
  );
  const absoluteTaskDir = join(workDir, taskDir);
  const planPath = join(taskDir, "PLAN.md");
  const todoPath = join(taskDir, "TODO.md");

  await mkdir(absoluteTaskDir, {
    recursive: true,
  });

  await Promise.all([
    writeFile(
      join(workDir, planPath),
      formatPlan(plan),
      "utf8",
    ),
    writeFile(
      join(workDir, todoPath),
      formatTodo(plan),
      "utf8",
    ),
  ]);

  return {
    taskDir,
    planPath,
    todoPath,
  };
}

PLAN.md 保存完整的 content,因此目标、背景、风险和验证方式都不会丢失。TODO.md 只根据 steps 生成;如果某项验证必须实际执行,就应该把它作为一个明确步骤放进 steps

这里使用 Unicode 字母和数字白名单处理标题,斜杠、..、标点和连续空格都会被替换掉。时间部分精确到毫秒,用来降低同名任务发生覆盖的可能性。

这里的写入不是模型在 Plan Mode 调用 write_file

它发生在用户点击批准之后,由 Harness 自己完成:

text 复制代码
Plan Mode
  模型没有写工具

用户批准
  Harness 切到 Code Mode

Code Mode
  Harness 创建本次任务目录和计划工件
  Agent 开始执行

这条边界要在文章和代码里都说清楚。


第九步:在 CLI 里完成审批流程

最后修改 src/main.ts

先在原有导入旁边增加:

ts 复制代码
import { AgentState } from "./plan.ts";
import { saveApprovedPlan } from "./plan-files.ts";
import { ListFilesTool } from "./tools/list-files.ts";
import { SearchFilesTool } from "./tools/search-files.ts";
import { SubmitPlanTool } from "./tools/submit-plan.ts";
import { parseCliOptions } from "./options.ts";

再解析参数、注册工具和状态:

ts 复制代码
const options = parseCliOptions(
  process.argv.slice(2),
);
const workDir = resolve(process.cwd(), options.dir);
const state = new AgentState(
  options.plan ? "plan" : "code",
);
const registry = new Registry();

registry.register(new ReadFileTool(workDir));
registry.register(new ListFilesTool(workDir));
registry.register(new SearchFilesTool(workDir));
registry.register(new WriteFileTool(workDir));
registry.register(new EditFileTool(workDir));
registry.register(new BashTool(workDir));
registry.register(new SubmitPlanTool(state));

const agent = new Agent(
  client,
  registry,
  session,
  state,
);

增加状态输出:

ts 复制代码
function printStatus(): void {
  console.log(`当前模式:${state.getMode()}`);
  console.log(`当前状态:${state.getStatus()}`);

  const tools =
    state.getMode() === "plan"
      ? "read_file, list_files, search_files, submit_plan"
      : "read_file, list_files, search_files, write_file, edit_file, bash";

  console.log(`可用工具:${tools}`);
}

再增加计划审批:

ts 复制代码
async function reviewPendingPlan(): Promise<void> {
  while (
    state.getStatus() ===
    "waiting_for_approval"
  ) {
    console.log(`
请选择:
1. 批准并执行
2. 继续修改计划
3. 取消
`);

    const choice = (
      await readline.question("选择:")
    ).trim();

    if (choice === "1") {
      const plan = state.approvePlan();

      const files = await saveApprovedPlan(
        workDir,
        plan,
      );

      console.log(
        "\n计划已批准,已进入 Code Mode。",
      );
      console.log(
        `已创建任务目录:${files.taskDir}\n`,
      );

      await runPrompt(
        `计划已经批准。
计划文件:${files.planPath}
执行清单:${files.todoPath}
请先读取这两份文件,从第一项未完成任务开始执行;每完成一项后立即更新这份 TODO.md。`,
      );
      return;
    }

    if (choice === "2") {
      const feedback = (
        await readline.question(
          "请输入计划修改意见:",
        )
      ).trim();

      if (!feedback) {
        console.log("修改意见不能为空。");
        continue;
      }

      state.refinePlan();

      await runPrompt(
        `请根据下面的反馈修改计划,并再次调用 submit_plan:\n${feedback}`,
      );

      continue;
    }

    if (choice === "3") {
      state.cancelPlan();
      console.log(
        "\n计划已取消,已返回 Code Mode。",
      );
      return;
    }

    console.log("请输入 1、2 或 3。");
  }
}

最后在原来的输入循环中处理命令:

ts 复制代码
while (true) {
  const value = (
    await readline.question("> ")
  ).trim();

  if (!value) continue;
  if (value === "exit" || value === "quit") {
    break;
  }

  if (value === "/plan") {
    state.enterPlan();
    console.log(
      "已进入 Plan Mode,只能读取和搜索项目。",
    );
    continue;
  }

  if (value === "/code") {
    state.enterCode();
    console.log("已进入 Code Mode。");
    continue;
  }

  if (value === "/status") {
    printStatus();
    continue;
  }

  try {
    await runPrompt(value);
    await reviewPendingPlan();
  } catch (error) {
    const message =
      error instanceof Error
        ? error.message
        : String(error);
    console.error(`本轮执行失败:${message}`);
  }
}

现在三个命令真正对应 Harness 状态:

text 复制代码
/plan
  切换状态
  收走写工具

/code
  返回普通模式
  恢复写工具

/status
  查看模式、阶段和当前工具面

它们不是发给大模型的普通聊天内容。


跑一次完整流程

准备一个练习目录,例如:

text 复制代码
workspace/task-board/
└── src/
    └── ceshi.ts

启动项目:

bash 复制代码
npm run build
npm start -- --plan -dir ./workspace/task-board

先查看状态:

text 复制代码
> /status

当前模式:plan
当前状态:planning
可用工具:read_file, list_files, search_files, submit_plan

输入任务:

text 复制代码
请把 src/ceshi.ts 改造成一个可以直接运行的小型待办演示。
先读取项目并给出计划,不要马上修改。

这一阶段应该看到类似的工具调用:

text 复制代码
list_files
  ↓
read_file
  ↓
search_files
  ↓
submit_plan

不应该出现:

text 复制代码
write_file
edit_file
bash

计划提交后,CLI 展示完整 Plan:

markdown 复制代码
# 增加命令行待办演示

## 目标

把现有脚本改造成可以直接运行的待办展示,同时保持实现简单。

## 实现步骤

1. 阅读并保留现有入口结构。
2. 增加待办数据和格式化输出。
3. 运行 node src/ceshi.ts。
4. 确认终端输出包含全部待办项。

## 风险

- 当前文件扩展名与 Node 运行方式可能不兼容。

## 验证方式

- 运行 node src/ceshi.ts。
- 确认终端输出包含全部待办项。

然后出现审批菜单:

text 复制代码
请选择:
1. 批准并执行
2. 继续修改计划
3. 取消

选择 2 时,Agent 仍然处于 Plan Mode,只能继续读取、搜索和重新提交计划。

选择 1 时:

text 复制代码
计划已批准,已进入 Code Mode。
已创建任务目录:
.powercode/tasks/20260817-143012000-增加命令行待办演示/

用户指定的执行目录中会出现:

text 复制代码
workspace/task-board/
├── src/
│   └── ceshi.ts
└── .powercode/
    └── tasks/
        └── 20260817-143012000-增加命令行待办演示/
            ├── PLAN.md
            └── TODO.md

这时才可能出现:

text 复制代码
read_file
  .powercode/tasks/20260817-143012000-增加命令行待办演示/TODO.md

edit_file
  src/ceshi.ts

edit_file
  .powercode/tasks/20260817-143012000-增加命令行待办演示/TODO.md

bash
  node src/ceshi.ts

最终该任务目录中的 TODO.md 类似:

markdown 复制代码
# 执行清单

## 实现步骤

- [x] 阅读并保留现有入口结构
- [x] 增加待办数据和格式化输出
- [x] 运行 node src/ceshi.ts
- [x] 确认终端输出包含全部待办项

这里有一条需要诚实说明:

"Plan Mode 不能写代码"由 Harness 强制保证;"完成一步就更新 TODO"在第一版里仍然主要依靠执行提示词。

如果以后想让 TODO 更新也变成强约束,可以再增加专门的 todo_update 工具,由 Harness 记录步骤状态,而不是让模型直接编辑 Markdown。


/code 和"批准并执行"有什么区别

两者都会进入 Code Mode,但含义不同。

text 复制代码
/code
  手动退出 Plan Mode
  不代表计划已经批准
  不自动生成 TODO

批准并执行
  明确批准当前 Plan
  创建本次任务目录
  保存 PLAN.md 和 TODO.md
  自动开始执行

正常流程应该使用"批准并执行"。

/code 更像一个逃生口:用户不想继续规划时,可以直接返回普通模式。


为什么第一版不做 Shift+Tab

不是不能做,而是它不属于这一篇最重要的部分。

当前 CLI 是逐行输入:

ts 复制代码
await readline.question("> ");

要捕获 Shift+Tab,通常需要监听按键事件,并处理终端发送的转义序列。

无论用户通过什么方式切换,底层最终都只是:

ts 复制代码
state.enterPlan();

或者:

ts 复制代码
state.enterCode();

因此正确的实现顺序是:

text 复制代码
先实现状态机和权限边界
  ↓
再实现 /plan、/code
  ↓
最后把 Shift+Tab 映射到相同函数

等以后给 powercode 增加完整 TUI 时,再补快捷键会更自然。


几个容易踩的坑

1. 把 Plan Mode 写成一段提示词

如果模型仍然拿得到写工具,就不能说 Harness 已经进入只读模式。

正确做法是同时隐藏工具定义,并在执行前再次检查允许列表。

2. Plan 一提交就自动执行

这会让"审批"只剩下界面文字。

submit_plan 调用后必须把状态切到:

text 复制代码
waiting_for_approval

然后暂停 Agent Loop。

3. 在批准前把计划清单当成执行进度

计划里可以有待勾选的任务分解,但在用户批准之前,它们仍然是方案的一部分,不应显示成"正在执行"。

本文选择在批准后才生成独立的 TODO.md。如果你选择 Codex 这种单文档方案,也要在状态上区分"待审批"和"执行中"。

4. 给 Plan Mode 整个 Bash

不能只检查命令是不是以 catgrepfind 开头。管道、重定向、命令替换和子进程都会让字符串判断变得复杂。

第一版使用专用只读工具更清楚。

5. 认为 Plan 和 TODO 必须存成两份文件

文件数量不是关键,状态边界才是。

可以像本文一样,用稳定的 PLAN.md 保存批准方案,用持续变化的 TODO.md 记录执行进度;也可以像 Codex 一样,在计划文档中直接保留勾选清单。

只要 Harness 清楚记录"待审批"和"执行中",两种方案都是合理的。

6. 直接用 Plan 标题当文件夹名

Plan 标题由模型生成,不能直接当作可信的路径。必须先移除斜杠、.. 和特殊字符,限制长度,并加入时间或任务 ID 避免同名覆盖。

7. 让模型自己批准计划

模型可以生成计划,但批准权属于用户。submit_plan 之后必须回到 CLI,而不是再发一句"请确认计划可行"让模型自我判断。

8. 把敏感信息写进计划文件

.powercode/tasks/ 中的 PLAN.mdTODO.md 可能进入 Git。不要把 API Key、Cookie、访问令牌或完整敏感日志写进去。

如果这些只是本地运行状态,可以把 .powercode/ 加入 .gitignore;如果团队希望把计划当作项目文档保留,再有选择地提交。


到这里,我们真正给 Agent 增加了什么

text 复制代码
AgentState
  保存 mode、status、待审批计划和已批准计划

Plan Mode
  只允许读取、列目录、搜索和提交计划

submit_plan
  把普通模型输出变成明确的工作流事件

用户审批
  决定继续修改、取消,还是切换到执行模式

任务目录
  根据 Plan 标题生成安全名称,隔离每次任务的工件

PLAN.md + TODO.md
  分别保存已批准的方案和实时执行进度

最重要的变化不是多了两个 Markdown 文件,也不是多了 /plan 命令。

而是 Harness 第一次开始管理 Agent 的工作阶段:

text 复制代码
规划阶段
  只能研究,不能执行

审批阶段
  Agent Loop 暂停,等待人类决定

执行阶段
  恢复写工具,按照批准的计划完成任务

这就是 Plan Mode 最值得学习的地方:

它不是一句"请先想清楚"的提示词,而是一套由 Harness 控制的只读、审批和执行状态机。

相关推荐
才聚PMP34 分钟前
深陷技术内卷难突围?AI+项目管理开辟增值新赛道!
大数据·人工智能
阿里云大数据AI技术36 分钟前
基于阿里云Milvus 构建电商图文智能搜索平台
人工智能
樊小肆41 分钟前
DeepSeeker-Code源码导读04-上下文压缩
人工智能·agent
鲁邦通物联网41 分钟前
充电站柔性负荷架构演进:网络卡顿导致设备烧损,如何依托边缘计算网关重塑本地调功防线?
人工智能·边缘计算·边缘计算网关·物联网网关·5g数采·边缘计算盒子·工业级边缘计算网关
weixin_4713830344 分钟前
17 Self-RAG —— 幻觉检测 + 答案质量评估
python·agent
叠层归一研究院1 小时前
如何用程序搭建一个 AGI 种子系统(三):生长如何对接物理与数学宇宙
人工智能·python·算法·机器学习·transformer·agi
兴趣使然黄小黄1 小时前
【AI-agent】让 AI 输出可依赖:LLM 工程化的四道防线
大数据·人工智能
2501_926978331 小时前
AGI封锁的物理边界:发现模式决定封锁可行性
人工智能·经验分享·笔记·ai写作·agi
tech讯息1 小时前
企业 AI Agent 对接外部服务如何安全集成?—— 多租户业务场景优先选用 WebSocket 方案
人工智能·websocket·安全