跪拜 Guibai
← Back to the summary

Plan Mode Is a State Machine, Not a Prompt


theme: fancy highlight: a11y-dark

🚀 Welcome to the sixth installment of the "Building an AI Agent Without Frameworks" series.

Even if you haven't read the previous articles, you can start directly from this one. All you need to know is: we already have a minimal AI Agent that can read, modify files, and execute commands.

In this article, we will teach it to act more like an engineer: when facing a long task, investigate first, then plan, and wait for human approval before starting to write code.

A Little Story from the Office

Last Friday, a colleague leaned over:

"Can you quickly fix the login module? The old and new interfaces are a mess right now, and it'd be best to add a few tests."

"Sure, I'll let the Agent take a look first. Should be quick."

You copy the requirements to the Agent and go get a glass of water.

When you come back, it's already working:

read_file
  src/login.ts

read_file
  src/routes.ts

edit_file
  src/login.ts

edit_file
  src/...

It looks pretty smooth. Soon, the Agent replies:

"The login module has been refactored."

You're about to wrap up when your colleague glances at the changes:

"Wait, why did you change this file?"

"Isn't this the login entry point?"

"This one hasn't been used for ages. The online system uses a different layer. And that old interface can't be deleted; the client is still calling it."

You quickly scroll down, and things look worse and worse:

The real login entry point was never read
The callers of the old interface weren't fully searched
The compatibility logic was deleted as if it were old code
The test commands were never run once

You ask the Agent:

"Why didn't you fully investigate the call relationships first?"

Its answer is also quite reasonable:

"I completed the refactoring based on the files I read."

Now the problem is very clear.

Your "take a look first" meant it should thoroughly understand the project first; its understanding of "take a look first" was to read two files and then start modifying as it went.

The model isn't necessarily incapable of writing code. It's just that we gave it write_file, edit_file, and bash from the very beginning, so it naturally might start working before fully understanding the situation.

If you were handing the task to a colleague, you'd probably add:

"Don't change anything yet. List the entry points, callers, compatibility plan, potential pitfalls, and how you plan to test it. I'll review it, and you can start once I'm okay with it."

What Exactly Is Plan Mode

Plan Mode can be understood as the Agent's "propose a solution, don't build yet" phase.

Let's take Claude Code as an example first.

In a mode where modifications are allowed, Claude Code's goal is to complete the task directly:

Read project
  ↓
Modify files
  ↓
Execute commands
  ↓
Verify results

After switching to Plan Mode, Claude Code temporarily restricts the ability to modify source code. It first reads and searches the project, forms a plan, and then waits for user approval.

At this point, its goal is not to complete the code, but to first answer:

What really needs to be changed
Which files and call relationships are involved
In what order should it be implemented
What are the risks
How to verify it in the end

Therefore, after entering Plan Mode, the Agent should go through three stages:

Planning Phase
  Read, search, and understand the project
  ↓
Approval Phase
  Submit the plan, pause and wait for user confirmation
  ↓
Execution Phase
  Enter Code Mode after user approval
  Restore modification and command execution capabilities

So Plan Mode is not another large language model, nor is it just generating a PLAN.md.

It is first and foremost a running state within the Harness:

What phase are we currently in?
  ↓
Determines which tools the model can see
  ↓
Determines when the Agent Loop must pause

The plan content is generated by the large model, but the "cannot modify before approval" and "must wait after submission" rules should be guaranteed by the Harness.

Why the Agent Always Wants to Change Code Immediately

Let's clarify something first: the large model isn't truly "impulsive by nature."

It simply chooses an action that seems most likely to advance the task based on the current goal, context, and available tools.

When the user says:

Help me refactor the login module

And the Harness simultaneously provides:

read_file
write_file
edit_file
bash

Then from the model's perspective, both reading and modifying are legitimate actions. After reading one or two related files, it easily judges that "the information is sufficient," and the next natural step is to call edit_file.

The Agent Loop will also continue to push it forward:

User requests code modification
  ↓
Model sees write tools
  ↓
Reads a few files
  ↓
Calls edit_file
  ↓
Harness returns "Modification successful"
  ↓
Model continues to modify the next file

At no point in the entire loop is it required to:

First prove it has found all entry points
First list the scope of impact
First present the plan to the user
Wait for user approval before continuing

So it's not intentionally messing up; rather, in the Harness we provided, "start modifying directly" is a completely unobstructed path.

Even if we add a line to the prompt:

Please understand the situation thoroughly before modifying the code.

The model still needs to judge for itself what "understand thoroughly" means. It might read two files and consider that condition met.

So the first thing Plan Mode does is block this path at the capability level:

Plan Mode Available
├── read_file
├── list_files
├── search_files
└── submit_plan

Plan Mode Unavailable
├── write_file
├── edit_file
└── bash

Now, even if the model thinks "it can start modifying," it doesn't have the tools to modify files. All it can do is continue reading, continue searching, or call submit_plan to hand over the plan.

However, taking away the write tools only solves "cannot modify prematurely."

To truly make it form a "first understand, then confirm" workflow, two more layers are needed:

Planning Prompt
  Requires exploring the project first, and clearly writing steps, risks, and verification methods in the plan

Approval State Machine
  Pause the Agent Loop after submit_plan, must wait for user choice

So what this article implements is not just a tool switch, but a three-layer coordination:

Tool Restriction
  Guarantees no writing before approval

Planning Prompt
  Guides the model to understand the project thoroughly first

User Approval
  Decides when to restore write tools

This skeleton wasn't invented out of thin air. The Plan Modes of Claude Code, Cursor, Codex, and Oh My Pi all show a similar "plan first, approve, then execute" approach, just implemented in different places:

Product Similarities to Our Approach Main Differences
Claude Code Plan Mode investigates the project, submits a plan, and doesn't modify source code before approval It makes Plan a direct permission mode and offers multiple execution methods after approval
Cursor Searches the codebase first, asks key questions, generates a plan, then waits for user approval Its plan can be directly edited and saved as Markdown
Codex Also emphasizes exploring the real project first, then outputting an executable, complete plan It's more of a planning collaboration protocol; public materials don't say it internally uses a tool named submit_plan
Oh My Pi Has an independent Plan role, plan artifacts, and an approval entry point Also supports clearing, compressing, or retaining planning context after approval, much more complex than our first version
Original Pi This workflow can be implemented through extensions The core itself explicitly has no built-in Plan Mode; it cannot be said to be implemented this way by default

Therefore, a more accurate description of our version is:

Using Claude Code-style permission boundaries and approval workflow as the skeleton, and adding Codex-style planning prompts.

Our self-defined submit_plan can be understood as an explicit "planning complete" event. Its role is very similar to "exit Plan Mode" or "submit plan" in other Agents, but it doesn't mean these products all use a tool with the same name internally.

We want the Agent to follow the same process:

First enter Plan Mode
  ↓
Only read and search the project
  ↓
Submit an implementation plan
  ↓
Wait for user approval
  ↓
Enter Code Mode after approval
  ↓
Modify code and update execution progress

The final terminal experience will look like this:

> /plan
Entered Plan Mode. Can only read and search the project.

> Refactor the login module, make it compatible with the old interface, and add tests

Agent reads directories, searches call relationships, analyzes risks...

Please choose:
1. Approve and execute
2. Continue modifying the plan
3. Cancel

> 1
Plan approved. Entered Code Mode.
Created task directory:
.powercode/tasks/20260817-143012000-refactor-login-module/

Before approval, the Agent cannot see the tools for writing files and executing commands; after approval, it starts modifying code and uses a TODO list to record its progress.

This is the Plan Mode we will implement in this article:

It's not a prompt saying "please think first," but a "read-only—approval—execution" state machine controlled by the Harness.

What We Will Implement in This Article

Based on the powercode completed after the fifth article, we add six capabilities:

  1. Two modes: code and plan;
  2. Three commands: /plan, /code, /status;
  3. Plan Mode can only read, list directories, and search;
  4. The model must submit a plan title, full body, and execution steps via submit_plan;
  5. The Agent Loop pauses after submitting a plan, waiting for user approval;
  6. After approval, it enters Code Mode, generates PLAN.md and TODO.md in .powercode/tasks/<plan-name>/, and then executes.

The final tool boundaries are:

Plan Mode
├── read_file       # Read the content of a specified file
├── list_files       # View the project directory structure
├── search_files     # Search for text and code within the project
└── submit_plan      # Submit the complete plan and enter the approval phase

Code Mode
├── read_file       # Read the content of a specified file
├── list_files       # View the project directory structure
├── search_files     # Search for text and code within the project
├── write_file      # Create a file or write complete content
├── edit_file       # Make targeted modifications to an existing file
└── bash            # Execute terminal commands like build and test

Why doesn't Plan Mode provide Bash?

Not because Bash is completely unusable, but because this Demo's planning phase simply doesn't need it.

Plan Mode only needs to accomplish three things:

read_file
  Only reads a single file

list_files
  Only lists directory structure

search_files
  Only searches text

These three tools are already sufficient for the Agent to see the directory structure, find relevant code, and read file contents.

If we also gave Bash to Plan Mode, we would then need to judge whether each shell command is just a query, or if it could write files, delete files, or access the network. This would introduce a lot of boundary handling unrelated to the main topic of this article.

Mature products like Codex and Claude Code can continue to constrain Bash through command parsing, permission rules, user approval, and sandboxing. But for our first version, the clearest choice is:

Plan Mode only provides the dedicated query tools truly needed for planning, and does not provide Bash for now.


Series Directory

  1. Building an AI Agent Without Frameworks: (1) Get It Running First
  2. Without LangChain, Build an AI Agent: Give the LLM "Hands" to Read Project Files
  3. The Core Loop of an AI Agent Is This Simple: Build an Agent Loop by Hand
  4. How Does Claude Code Modify Code Itself? The Answer Lies in These 4 Tools
  5. Without LangChain: Build Agent Conversation Memory and Context Compaction with 200 Lines of Code
  6. This Article: How Does an AI Agent Execute Long Tasks? Add Plan Mode and a Task List
  7. More practical content continuously updated...

🚀 Source code for this section: powercode 👈 Click it

If you encounter problems midway, you can check the source code for troubleshooting. The code for subsequent chapters will also be continuously updated. If this project is helpful to you, a Star ⭐ is welcome.

First, Clarify the Repository and Directory

This article continues using the main project powercode.

After the fifth article, we had already frozen the code at that point in:

powercode/demos/05-session-compaction

This directory is an independent Demo for the fifth article, used to review Session and Compactor, and will not be modified further.

What the sixth article modifies is the main project source code:

powercode/
├── provider.json
├── src/                         # Agent source code we continue to develop in this article
│   ├── agent.ts
│   ├── main.ts
│   ├── plan.ts                  # New in this article
│   ├── plan-files.ts            # New in this article
│   ├── options.ts               # New in this article
│   ├── context/
│   │   ├── session.ts
│   │   └── compactor.ts
│   └── tools/
│       ├── read-file.ts
│       ├── write-file.ts
│       ├── edit-file.ts
│       ├── bash.ts
│       ├── list-files.ts        # New in this article
│       ├── search-files.ts      # New in this article
│       └── submit-plan.ts       # New in this article
├── demos/
│   └── 05-session-compaction/   # Fifth article's finished state, no longer modified
└── workspace/
    └── task-board/              # Practice directory for the Agent to plan and execute
        └── .powercode/          # Task artifacts generated after running plan
            └── tasks/
                └── <plan-name>/
                    ├── PLAN.md
                    └── TODO.md

Don't mix up the three directories:

powercode/src
  The Agent Harness we are developing

powercode/demos/05-session-compaction
  The frozen version from the fifth article

powercode/workspace/task-board
  The practice project the Agent actually reads, searches, and modifies

All subsequent code is added based on the main project after the fifth article. It's okay if you haven't read the previous ones: the new files and key modifications involved in this article will be provided in full.

First, the Conclusion: Plan and TODO Are Two Concepts, but Can Be in the Same Document

Plan and TODO often appear together, but they don't solve the same problem.

Plan
  Decides how to proceed
  Happens before execution
  Needs user review

TODO
  Records the current progress
  Happens after the plan is approved
  Updated as execution progresses

Some Agents put both in the same document. For example, a plan document generated by Codex might directly contain a checklist like this:

- [ ] Find the real entry point of the login module
- [ ] Sort out the callers of the old interface
- [ ] Implement the compatibility layer
- [ ] Add tests and verify

Before the plan is approved, these checkboxes represent "how to proceed," which is essentially still a task breakdown of the Plan. If the Agent continues to update the checkbox status after approval, this document simultaneously takes on the responsibility of a TODO.

So what really needs to be separated are two states:

Before approval
  This is a plan checklist pending review

After approval
  This is an execution progress checklist

As for whether it's physically one file or two, that's an implementation choice of the Harness. To make responsibilities clearer, this article will create an independent task directory for each approved plan and save two files inside:

.powercode/tasks/<plan-name>/
├── PLAN.md
│    Saves the user-approved plan, kept as stable as possible
│
└── TODO.md
     Generated from the plan steps, continuously updated during execution

This way, the plans and progress of different tasks won't overwrite each other, and it will be easier to add task history and interruption recovery later.

The complete workflow should be:

Code Mode
  ↓ User enters /plan
Plan Mode
  ↓ Read project, search code, clarify requirements
Submit Plan
  ↓
Wait for user approval
  ├── Continue modifying the plan
  ├── Cancel
  └── Approve
        ↓
      Code Mode
        ↓
      Create .powercode/tasks/<plan-name>/
        ↓
      Write PLAN.md and TODO.md
        ↓
      Modify code, verify item by item, update TODO

This is also not the same thing as the context compaction from the previous article:

Session + Compactor
  Solves "what can the model see in this round"

Plan Mode
  Solves "what can the Agent do before user approval"

Task directory + PLAN.md + TODO.md
  Solves "which task is this, what was approved, and where is the execution at"

These three issues should be handled in different parts of the Harness.


Why You Can't Just Write "Please Plan First"

The simplest approach is to write in the system prompt:

Please analyze the requirements first, do not modify the code.

This can increase the probability of the model thinking first, but it is not a reliable permission boundary.

If the Harness still hands all the following tools to the model:

read_file
write_file
edit_file
bash

The model still has the ability to modify the project. The prompt is merely advising it not to write, without actually taking away the write permission.

The official Claude Code places Plan Mode within its permission mode system: the Plan phase allows reading and exploration but not modifying source code; after the plan is complete, it's handed to the user for review, and only after approval does it switch to execution permissions.

Reference: Claude Code Permission Modes

This perfectly illustrates the role of the Harness:

The large model is responsible for generating the plan, and the Harness is responsible for deciding the current state, which tools to provide, and when to pause and wait for the user.

The combination used in this article is:

Claude Code-style permission state machine
  +
More explicit planning prompts
  +
Post-approval TODO execution progress

The first version will not implement an independent Plan model, Plan sub-agent, context clearing, or a full-screen reviewer for now. These are all capabilities that a production-grade Plan Mode can continue to add:

An independent Plan model and a Plan sub-Agent are easily confused: the former is just "switching to a different large model to think," while the latter is "launching another Agent to complete the planning task."

This article's first version uses the same model, the same Session, and a simple CLI approval menu. We'll get the most important state boundaries right first.


Step 1: Establish the Plan Mode State Machine

This step doesn't involve the large model or tool calls yet. We'll just create an object specifically for saving the Plan Mode's running state, so the Harness can answer three questions at any time:

Is it currently Plan Mode or Code Mode?
Is the plan being generated, or is it already waiting for approval?
Is the currently saved plan pending approval, or already approved?

These answers will later determine which tools the Agent can see, and whether the Agent Loop should continue running or pause to wait for the user.

A PlanDraft will also appear in the code below. Don't be intimidated by the name; it's just the data format the Harness uses internally to save a "plan pending approval": title is used to create the task directory, content saves the complete Markdown plan body, and steps is used to generate and update the TODO.

First, create src/plan.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("Only the planning phase can submit a plan.");
    }

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

  refinePlan(): void {
    if (
      this.status !== "waiting_for_approval" ||
      !this.pendingPlan
    ) {
      throw new Error("There is currently no plan waiting to be modified.");
    }

    this.status = "planning";
  }

  approvePlan(): PlanDraft {
    if (
      this.status !== "waiting_for_approval" ||
      !this.pendingPlan
    ) {
      throw new Error("There is currently no plan waiting for approval.");
    }

    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. What mode and status Manage Respectively

Here, mode and status are intentionally separated:

mode
  Controls what capabilities the Agent currently has
  For example, Plan Mode cannot see write_file and bash

status
  Records which stage the Agent Loop has reached
  For example, planning, waiting for approval, or executing

Why not just define a boolean?

planMode: boolean

Because planMode = true can only tell us "currently in Plan Mode," but cannot express the following two completely different situations:

mode = plan
status = planning
  Agent is still reading the project, searching code, and generating a plan

mode = plan
status = waiting_for_approval
  Plan has been submitted, Agent Loop must pause

Although the tool boundaries are the same in both states, the former can still continue calling the model, while the latter can only wait for the user's choice.

2. How the Harness Receives the Model-Generated Plan

As we've been saying, a plan needs to include the goal, implementation steps, risks, and verification methods.

This content is ultimately just a piece of ordinary Markdown:

# Refactor Login Module

## Goal

Refactor the login flow while maintaining compatibility with the old interface.

## Implementation Steps

1. Find the login entry point
2. Sort out the callers of the old interface
3. Add a compatibility layer

## Risks

Older client versions might still call the original interface.

## Verification Method

Run login tests and manually verify with the old client.

Let's clarify an easily confusing point here:

The large model can directly understand the entire Markdown. We don't need to first extract sections like risks and verification methods and then re-feed them to it.

After user approval, the Harness can put the complete plan into the model's context, or it can tell the model the path to PLAN.md and let it read it itself. Both methods work.

Whether the Harness actively puts the plan in, or the Agent uses read_file to read it, the plan body must ultimately enter the model's context for the large model to understand and execute it. The real value of PLAN.md is to persist the approved plan: even if the context is later compacted or lost, the Agent can still re-read it.

If so, why define PlanDraft?

Because this article also wants the Harness to automatically complete two things:

Create a task directory based on the plan title
Generate TODO.md based on the execution steps

If we let the Harness guess the title and steps from free-form Markdown, the parsing would be very unstable. But the goal, background, risks, and verification instructions only need to be read by the user and the large model; there's no need to break them all into fields.

Therefore, the first version only structures the parts the program truly needs to process:

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

interface is just TypeScript's way of describing the shape of data. PlanDraft is not a new Agent, nor an extra draft file; it's just a JavaScript object format for saving a plan pending approval.

Why is Draft in the name?

Because when the model first submits it, this plan hasn't been approved by the user yet; it can only be considered a "plan draft pending review." Before approval, the user can still ask the model to modify or cancel it.

The data generated by the model will flow like this:

Model calls submit_plan
  ↓ Submits title, content, steps
Harness gets PlanDraft
  ↓
Saves to pendingPlan
  ↓ User approves
Moves to approvedPlan
  ↓
Generates PLAN.md and TODO.md

Now it's easier to understand each field:

title
  Plan name, later used to generate the task directory name

content
  Complete Markdown plan body
  Goal, plan, risks, and verification methods are all written here

steps
  Executable steps extracted from content
  Used to generate and update TODO.md

Note, it's not that the Harness first saves content and then parses steps from the Markdown. The model submits these three fields simultaneously when calling submit_plan, and the Harness only checks if they are non-empty strings or arrays.

There will be a small amount of duplication here: content will contain the implementation plan, and steps lists the executable items separately again. This is intentional.

content
  For user review, and for the large model to execute

steps
  For the Harness to generate and track the TODO

The Plan Mode prompt will require the two to be consistent. The first version can only constrain format and non-emptiness, and cannot semantically prove they are exactly the same; but this is sufficient for a teaching Demo. This way, the expressive power of Markdown is preserved, and the Harness doesn't need to parse the entire document itself.

3. What's the Difference Between pendingPlan and approvedPlan

Both fields save a PlanDraft, but their trust levels are different:

pendingPlan
  The model has submitted it, but the user hasn't approved it yet
  It can be asked to be modified, or it can be canceled

approvedPlan
  The user has approved it
  The Agent can treat it as the formal basis for subsequent execution

The key action during approval is moving the same plan from the "pending area" to the "approved area":

pendingPlan
  ↓ User approves
approvedPlan

This is also the meaning of these lines of code inside approvePlan():

const plan = this.pendingPlan;

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

Note, "the model generated a plan" does not mean "the plan is in effect." Only after user approval does it enter approvedPlan.

4. These Methods Are Actually State Transition Entry Points

AgentState is not responsible for calling the large model, nor for writing PLAN.md. It is only responsible for saving the state and ensuring the state can only be changed through the following entry points:

Stringing them together gives the complete state machine:

flowchart TD
    A["mode: code<br/>status: idle"] -->|"User enters /plan<br/>enterPlan()"| B["mode: plan<br/>status: planning"]
    B -->|"User enters /code<br/>enterCode()"| A
    B -->|"Model submits plan<br/>submitPlan()"| C["mode: plan<br/>status: waiting_for_approval"]
    C -->|"User requests further modification<br/>refinePlan()"| B
    C -->|"User cancels<br/>cancelPlan()"| A
    C -->|"User approves<br/>approvePlan()"| D["mode: code<br/>status: executing"]
    D -->|"Execution ends<br/>finishExecution()"| 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;

This diagram needs to be viewed on two layers:

mode inside the boxes
  code means Code Mode
  plan means Plan Mode
  It determines which tools can currently be used

status inside the boxes
  idle, planning, waiting_for_approval, executing
  Determines if the Agent Loop is planning, waiting, or executing

Methods on the arrows
  Indicate through which entry point the state can change

For example, after the model calls submitPlan(), it just moves from planning to waiting_for_approval. It is still in Plan Mode at this point, and write tools will not be restored. Next, the Agent Loop must stop and can only wait for the user to choose to continue modifying, cancel, or approve.

Only when the user chooses to approve does approvePlan() complete two things simultaneously: switch mode back to code, and switch status to executing. At this point, the Harness creates the task files and starts execution.

Actively checking the current state inside submitPlan(), refinePlan(), and approvePlan() is to prevent skipping the normal workflow. For example, submitting a plan directly in Code Mode, or approving directly without a pending plan, should both immediately throw an error.

5. formatPlan() Only Adds the Title

content is already the complete Markdown plan body, so formatPlan() doesn't need to understand or rearrange the sections inside.

title + content
  ↓ formatPlan()
A complete PLAN.md that can be displayed and saved

It won't approve the plan, won't switch modes, and won't write files. It's only responsible for concatenating the title and body. The goal, plan, risks, and verification methods remain in the model-generated content.


Step 2: Add Mode Commands to the CLI

Claude Code can switch Plan Mode with Shift+Tab.

Our first version will use three text commands first:

/plan       Enter Plan Mode
/code       Return to Code Mode
/status     View current mode and status

The reason is simple: the current project uses readline.question() for line-by-line input. If we tried to capture Shift+Tab now, we'd also need to handle raw keypresses, terminal escape sequences, and input box refreshing, and the article would veer from Agent Harness to TUI implementation.

But shortcuts and text commands should ultimately call the same state switching function:

/plan ──────────┐
                ├── state.enterPlan()
Shift+Tab ──────┘

So the first version gets the state machine right first; adding shortcuts later won't require modifying the Agent core.

Create src/options.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} requires a value.`);
  }

  return value;
}

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

parseCliOptions() allows the user to decide two things when starting PowerCode: which mode the Agent enters initially, and which project directory it operates on.

This article uses the following command to start:

npm start -- --plan -dir ./workspace/task-board

Here, the first -- after npm start is just npm's parameter separator, indicating that the following content should be passed on to PowerCode. The parameters truly belonging to PowerCode are:

--plan
  Enter Plan Mode directly after startup

-dir ./workspace/task-board
  Set workspace/task-board as the Agent's working directory

These two parameters are independent of each other: --plan determines the initial mode, and -dir determines the directory the tools can read, search, and modify. This article puts them together so the Agent immediately plans the practice project after startup, while also preventing it from experimenting with powercode's own source code.

This article explicitly uses -dir because we are still debugging the CLI via npm start inside the PowerCode source directory. In the future, after PowerCode is published as a global npm command, the user will only need to enter their own project and run powercode, and the current project directory corresponding to process.cwd() will automatically become the Agent's workspace; -dir will only be kept as an optional override parameter for specifying a project from another location.

That is to say, the most common usage in the future should be:

cd ~/projects/my-app
powercode --plan

Only when it's inconvenient to enter the target project first would you need to explicitly specify:

powercode --plan -dir ~/projects/my-app

Step 3: Add Two Truly Read-Only Exploration Tools

The fifth article already had read_file, but reading a project also requires two basic capabilities:

list_files
  View directory structure

search_files
  Search code by keyword

Create src/tools/list-files.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 must be a string.");
  }

  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: "Recursively list files and directories in the current project.",
      parameters: {
        type: "object",
        properties: {
          path: {
            type: "string",
            description: "Directory relative to the project root, defaults to the project root",
          },
        },
        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 "Directory is empty.";
    }

    const suffix =
      items.length >= MAX_ITEMS
        ? `\n...[Max ${MAX_ITEMS} items returned]...`
        : "";

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

Here, resolveInWorkDir() from the fourth article is reused.

Even if a tool is read-only, the path must be restricted from escaping the working directory. Read-only does not mean it can read arbitrary files on the user's computer.

Create src/tools/search-files.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 must be a non-empty string.");
  }

  if (
    input.path !== undefined &&
    typeof input.path !== "string"
  ) {
    throw new Error("path must be a string.");
  }

  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:
        "Search for a keyword in text files within the current project.",
      parameters: {
        type: "object",
        properties: {
          query: {
            type: "string",
            description: "The text keyword to search for",
          },
          path: {
            type: "string",
            description:
              "Search directory relative to the project root, defaults to the project root",
          },
        },
        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 `No results found for keyword: ${input.query}`;
    }

    const suffix =
      results.length >= MAX_RESULTS
        ? `\n...[Max ${MAX_RESULTS} results returned]...`
        : "";

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

This search tool has no regex, pipes, or redirection; it does one clear thing: find text within the working directory.

This is easier to explain and easier to test than handing a full Bash to Plan Mode.

Note that both tools default to skipping .powercode. What's saved here are Harness artifacts, not project source code; if historical Plans and TODOs are repeatedly searched during subsequent planning, it might interfere with the model's judgment. During the execution phase, the precise task file paths given in the approval message can still be accessed via read_file and edit_file.


Step 4: Make the Registry Responsible for Both "Hiding" and "Intercepting"

Simply not sending the write tool definitions to the model is not complete enough.

The model usually won't call a tool it hasn't seen, but the Harness should still double-check before actual execution:

First layer
  Don't put write_file, edit_file, bash into the tools parameter

Second layer
  Even if the model generates these tool names, the Registry refuses to execute them

Modify two methods in src/tools/registry.ts:

  1. Add an allowedNames parameter to getDefinitions(), only sending the tool definitions allowed in the current mode to the model;
  2. Add the same parameter to execute(), intercepting disallowed tools again before actual execution.

register() and other code remain unchanged; only the following two methods need to be replaced:

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(
      `Tool not allowed in current mode: ${name}`,
    );
  }

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

  if (!tool) {
    throw new Error(`Tool not found: ${name}`);
  }

  return tool.execute(argumentsJson);
}

At this point, the "read-only" nature of Plan Mode is no longer just a prompt.

The real boundary is:

const PLAN_TOOL_NAMES = new Set([
  "read_file",
  "list_files",
  "search_files",
  "submit_plan",
]);

write_file, edit_file, and bash are not in the set, so the model cannot see them, and the Registry will not execute them.


Step 5: Add the submit_plan Tool

If the plan were just ordinary Markdown text, it would be hard for the Agent Loop to accurately judge:

Is the model explaining its thought process?
Or has it already submitted the final plan?
Should it continue calling the model now?
Or should it pause and wait for the user?

So we add an explicit control tool:

submit_plan
  The model indicates "the plan is complete"
  The Harness switches the status to waiting_for_approval
  The Agent Loop pauses

Create src/tools/submit-plan.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} must be a non-empty string.`);
  }

  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} must be an array of strings.`);
  }

  if (value.length === 0) {
    throw new Error(`${name} cannot be empty.`);
  }

  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("Plan parameters must be an object.");
  }

  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:
        "Submit the final implementation plan and pause to wait for user approval.",
      parameters: {
        type: "object",
        properties: {
          title: {
            type: "string",
            description: "Plan title",
          },
          content: {
            type: "string",
            description:
              "Complete Markdown plan body, including goal, plan, risks, and verification method. Do not repeat the top-level title.",
          },
          steps: {
            type: "array",
            items: { type: "string" },
            description: "Implementation steps in execution order",
          },
        },
        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 "Plan submitted, waiting for user approval.";
  }
}

This tool does not modify the user's source code.

It only writes the plan title, Markdown body, and execution steps into AgentState, and then changes the Harness state. The plan content is later displayed to the user by the CLI.


Step 6: Inject Dedicated Prompts for Plan Mode

Now that the permission boundary is guaranteed by the Harness, the prompts are only responsible for improving plan quality.

When modifying src/agent.ts, first add the state-related imports:

import {
  AgentState,
  formatPlan,
} from "./plan.ts";

Then split the original single system prompt into three parts:

const CORE_SYSTEM_PROMPT = `
You are power-code, a development assistant.
Prioritize reading real files; after modifications, proactively run commands to verify results; please answer in Chinese.
All file paths are relative to the current working directory; you cannot operate on files outside the working directory.
`;

const PLAN_MODE_PROMPT = `

# Current Mode: Plan Mode

You can now only research the project and formulate plans; you cannot modify files or execute commands.

Please work in the following order:

1. First, use list_files, search_files, and read_file to understand the real project.
2. Do not ask the user for answers that can be found in the project.
3. Only ask the user questions and wait for an answer when key requirements or trade-offs cannot be confirmed from the project.
4. Do not write ordinary guesses as established facts.
5. content must be a complete Markdown plan body, clearly stating the goal, plan, risks, and verification method. Do not repeat the top-level title corresponding to title.
6. steps must be extracted from the implementation plan in content, arranged in execution order, and include necessary verification steps.
7. content and steps must not present two different plans.
8. After the plan is complete, you must call submit_plan; do not just output a block of ordinary Markdown.
`;

const EXECUTION_PROMPT = `

# Current Mode: Code Mode

The following plan has been approved by the user.

The approval message will provide the paths to PLAN.md and TODO.md for this task.
Please read these two files first, then execute from top to bottom according to TODO.md.

- After completing a step with a result, immediately update the corresponding - [ ] to - [x].
- When code has been modified but the corresponding verification is not yet complete, do not check off steps that include verification work.
- When encountering an error, first read the TODO.md for this task to confirm the current position, then fix and re-verify.
- Do not arbitrarily change the approved goals and scope.
`;

What's borrowed here is a very practical planning sequence:

Explore first
  ↓
Confirm what can be confirmed from the code yourself
  ↓
Only ask questions that truly require user decision
  ↓
Generate a plan that can be directly executed

Note, the prompt is not a security boundary.

Even if the model ignores "cannot modify files," Plan Mode cannot access write tools; this is the significance of modifying the Registry earlier.


Step 7: Dynamically Provide Tools Based on Mode

Continue modifying src/agent.ts.

First, address an easily overlooked point. To demonstrate the minimal Agent Loop, the fifth article set the maximum execution rounds to 8:

const MAX_STEPS = 8;

For a long task like "read project, generate plan, modify multiple files, verify item by item," 8 rounds are usually insufficient. Adjust it to:

const MAX_STEPS = 30;

Here, one "round" means the model completes one thought process and returns a result, not necessarily calling only one tool. The model might call multiple read_file or write_file in the same round, so multiple "Round 8" entries might appear in the log.

MAX_STEPS must still be kept; it's a safety limit to prevent the Agent from looping infinitely due to repeated searches or modifications. 30 is not a fixed standard, just a starting point more suitable for this article's long task Demo; it can be made a configuration item later.

First, define two sets of tool names outside the Agent class, which can be placed after the previous prompt constants:

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",
]);

Then add AgentState to the Agent:

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

Next, put the following two private methods inside the Agent class body, positioned after constructor and before run():

export class Agent {
  constructor(...) {
    ...
  }

  // Add getAllowedToolNames() here
  // Add buildSystemPrompt() here

  async run(...) {
    ...
  }
}

The specific code is as follows:

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;
}

This is exactly where "the entire plan is handed directly to the large model." formatPlan(approvedPlan) will add title + content as-is to the execution phase's system prompt; the large model doesn't need to rely on the Harness to parse risks or verification methods. Writing PLAN.md later is to persist the approval result, making it easy to re-read during execution and review later.

When calling the model, no longer get all tools directly:

const allowedToolNames =
  this.getAllowedToolNames();

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

Also pass the same set when executing tools:

result = await this.registry.execute(
  name,
  toolCall.function.arguments,
  allowedToolNames,
);

Finally, in the outer Agent Loop of run(), find the following tool loop:

for (const toolCall of toolCalls) {
  // Execute tool and write tool result to Session
}

Place the status check after the closing brace of this tool loop, before the outer for (let step...) enters the next round:

for (const toolCall of toolCalls) {
  // Original tool execution code
}

// Add here: All tools for this round have been executed,
// but the large model has not been requested again yet.
if (
  this.state.getStatus() ===
  "waiting_for_approval"
) {
  const plan = this.state.getPendingPlan();

  if (!plan) {
    throw new Error("Plan status is abnormal.");
  }

  return formatPlan(plan);
}

Don't put it inside for (const toolCall...), and don't put it outside the entire run(). Placing it here ensures all tool results for this round have been written to the Session, while stopping in time before the next model request.

This way, after the model calls submit_plan, the Agent Loop won't continue to let it approve itself, but will immediately return to the CLI.

Complete Key Structure of src/agent.ts

The integrated run() body is as follows:

async run(prompt: string): Promise<string> {
  if (
    this.state.getStatus() ===
    "waiting_for_approval"
  ) {
    throw new Error("The current plan is waiting for approval.");
  }

  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("Model did not return a message.");
    }

    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 ?? "Model did not return text content."
      );
    }

    for (const toolCall of toolCalls) {
      if (toolCall.type !== "function") {
        throw new Error(
          `Tool type not supported yet: ${toolCall.type}`,
        );
      }

      const name = toolCall.function.name;
      console.log(
        `Round ${step}: AI calls ${name}`,
      );

      let result: string;

      try {
        result = await this.registry.execute(
          name,
          toolCall.function.arguments,
          allowedToolNames,
        );
        console.log(`✓ ${name} execution complete\n`);
      } catch (error) {
        const reason =
          error instanceof Error
            ? error.message
            : String(error);
        result = `Tool execution failed: ${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("Plan status is abnormal.");
      }

      return formatPlan(plan);
    }
  }

  throw new Error(
    `Execution exceeded ${MAX_STEPS} rounds, stopped.`,
  );
}

The original Session and Compactor don't need to be deleted.

The project content read during the Plan phase still enters the current session, and when it gets too long, the Compactor still constructs the working memory. We've only changed the tools available in each round.


Step 8: Create an Independent Task Directory for Each Plan

Why not create an independent TODO.md at the very beginning of Plan Mode?

Because the plan hasn't been approved yet at that point.

The plan itself can perfectly well contain an unchecked step list; Codex uses this form of expression. But in this article's two-file design, TODO.md represents the formal progress that has entered the execution phase.

If the user asks to continue modifying the plan or cancels directly, there shouldn't be a TODO.md in the working directory that looks like execution has already started.

So the order is:

Model submits Plan
  ↓
CLI displays it to the user
  ↓
User approves
  ↓
Harness generates a safe directory name based on the Plan title
  ↓
Harness creates .powercode/tasks/<plan-name>/
  ↓
Harness writes PLAN.md and TODO.md

The task directory name cannot directly use the raw title returned by the model. The title might contain spaces, slashes, .., or other characters unsuitable for paths, and plans with the same name could overwrite each other.

So the Harness needs to first convert the title into a safe name, and then add a time identifier:

Plan Title
  Refactor login module, compatible with old interface

Task Directory
  .powercode/tasks/20260817-143012000-refactor-login-module-compatible-with-old-interface/

This .powercode is located in the execution directory specified by the user via -dir, not hardcoded into the Harness's own source directory. This article's startup command uses -dir ./workspace/task-board, so the actual location is:

powercode/workspace/task-board/.powercode/tasks/<plan-name>/

Create src/plan-files.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 `# Execution Checklist

> This checklist is generated from the approved PLAN.md.

## Implementation Steps

${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 saves the complete content, so the goal, background, risks, and verification method are not lost. TODO.md is generated only from steps; if a certain verification must be practically executed, it should be included as an explicit step in steps.

A Unicode letter and number whitelist is used here to process the title; slashes, .., punctuation, and consecutive spaces will all be replaced. The time part is precise to milliseconds to reduce the possibility of overwriting tasks with the same name.

The writing here is not the model calling write_file in Plan Mode.

It happens after the user clicks approve, completed by the Harness itself:

Plan Mode
  Model has no write tools

User approves
  Harness switches to Code Mode

Code Mode
  Harness creates the task directory and plan artifacts for this task
  Agent starts execution

This boundary needs to be clearly stated in both the article and the code.


Step 9: Complete the Approval Workflow in the CLI

Finally, modify src/main.ts.

First, add alongside the original imports:

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";

Then parse arguments, register tools, and state:

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,
);

printStatus() is a top-level helper function in src/main.ts. Place it after the agent creation is complete, before reviewPendingPlan() and the input loop at the very bottom:

const state = new AgentState(...);
const registry = new Registry();
const agent = new Agent(...);

// Add printStatus() here

// Define reviewPendingPlan() later
// Finally, the while (true) input loop

The specific code is as follows:

function printStatus(): void {
  console.log(`Current Mode: ${state.getMode()}`);
  console.log(`Current Status: ${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(`Available Tools: ${tools}`);
}

Then add plan approval:

async function reviewPendingPlan(): Promise<void> {
  while (
    state.getStatus() ===
    "waiting_for_approval"
  ) {
    console.log(`
Please choose:
1. Approve and execute
2. Continue modifying the plan
3. Cancel
`);

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

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

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

      console.log(
        "\nPlan approved. Entered Code Mode.",
      );
      console.log(
        `Created task directory: ${files.taskDir}\n`,
      );

      await runPrompt(
        `The plan has been approved.
Plan file: ${files.planPath}
Execution checklist: ${files.todoPath}
Please read these two files first, and start executing from the first uncompleted task; immediately update this TODO.md after completing each item.`,
      );
      return;
    }

    if (choice === "2") {
      const feedback = (
        await readline.question(
          "Please enter plan modification feedback: ",
        )
      ).trim();

      if (!feedback) {
        console.log("Modification feedback cannot be empty.");
        continue;
      }

      state.refinePlan();

      await runPrompt(
        `Please modify the plan based on the following feedback, and call submit_plan again:\n${feedback}`,
      );

      continue;
    }

    if (choice === "3") {
      state.cancelPlan();
      console.log(
        "\nPlan cancelled. Returned to Code Mode.",
      );
      return;
    }

    console.log("Please enter 1, 2, or 3.");
  }
}

Finally, handle commands in the original input loop:

while (true) {
  const value = (
    await readline.question("> ")
  ).trim();

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

  if (value === "/plan") {
    state.enterPlan();
    console.log(
      "Entered Plan Mode. Can only read and search the project.",
    );
    continue;
  }

  if (value === "/code") {
    state.enterCode();
    console.log("Entered 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(`Execution failed in this round: ${message}`);
  }
}

Now the three commands truly correspond to Harness states:

/plan
  Switch state
  Take away write tools

/code
  Return to normal mode
  Restore write tools

/status
  View mode, phase, and current tool surface

They are not ordinary chat content sent to the large model.


Run a Complete Workflow

Prepare a practice directory, for example:

workspace/task-board/
└── src/
    └── ceshi.ts

Start the project:

npm run build
npm start -- --plan -dir ./workspace/task-board

First, check the status:

> /status

Current Mode: plan
Current Status: planning
Available Tools: read_file, list_files, search_files, submit_plan

Enter a task:

Please transform src/ceshi.ts into a small, directly runnable todo demo.
Read the project first and provide a plan; do not modify immediately.

At this stage, you should see tool calls similar to:

list_files
  ↓
read_file
  ↓
search_files
  ↓
submit_plan

Should not appear:

write_file
edit_file
bash

After the plan is submitted, the CLI displays the complete Plan:

# Add command-line todo demo

## Goal

Transform the existing script into a directly runnable todo display, while keeping the implementation simple.

## Implementation Steps

1. Read and retain the existing entry structure.
2. Add todo data and formatted output.
3. Run node src/ceshi.ts.
4. Confirm the terminal output contains all todo items.

## Risks

- The current file extension might be incompatible with the Node execution method.

## Verification Method

- Run node src/ceshi.ts.
- Confirm the terminal output contains all todo items.

Then the approval menu appears:

Please choose:
1. Approve and execute
2. Continue modifying the plan
3. Cancel

When choosing 2, the Agent is still in Plan Mode and can only continue reading, searching, and resubmitting the plan.

When choosing 1:

Plan approved. Entered Code Mode.
Created task directory:
.powercode/tasks/20260817-143012000-add-command-line-todo-demo/

The user-specified execution directory will now contain:

workspace/task-board/
├── src/
│   └── ceshi.ts
└── .powercode/
    └── tasks/
        └── 20260817-143012000-add-command-line-todo-demo/
            ├── PLAN.md
            └── TODO.md

Only now might the following appear:

read_file
  .powercode/tasks/20260817-143012000-add-command-line-todo-demo/TODO.md

edit_file
  src/ceshi.ts

edit_file
  .powercode/tasks/20260817-143012000-add-command-line-todo-demo/TODO.md

bash
  node src/ceshi.ts

Finally, the TODO.md in that task directory will look something like:

# Execution Checklist

## Implementation Steps

- [x] Read and retain the existing entry structure
- [x] Add todo data and formatted output
- [x] Run node src/ceshi.ts
- [x] Confirm the terminal output contains all todo items

One thing needs to be honestly stated here:

"Plan Mode cannot write code" is enforced by the Harness; "update TODO upon completing a step" in the first version still mainly relies on the execution prompt.

If we want TODO updates to become a hard constraint in the future, we can add a dedicated todo_update tool, where the Harness records the step status instead of letting the model directly edit the Markdown.


What's the Difference Between /code and "Approve and Execute"

Both will enter Code Mode, but their meanings are different.

/code
  Manually exit Plan Mode
  Does not mean the plan is approved
  Does not automatically generate a TODO

Approve and Execute
  Explicitly approves the current Plan
  Creates the task directory for this task
  Saves PLAN.md and TODO.md
  Automatically starts execution

The normal workflow should use "Approve and Execute."

/code is more of an escape hatch: when the user doesn't want to continue planning, they can directly return to normal mode.


Why the First Version Doesn't Implement Shift+Tab

It's not that it can't be done, but that it's not the most important part of this article.

The current CLI is line-by-line input:

await readline.question("> ");

To capture Shift+Tab, you typically need to listen for keypress events and handle the escape sequences sent by the terminal.

No matter how the user switches, the underlying action is ultimately just:

state.enterPlan();

or:

state.enterCode();

Therefore, the correct implementation order is:

First implement the state machine and permission boundaries
  ↓
Then implement /plan, /code
  ↓
Finally map Shift+Tab to the same functions

It will be more natural to add shortcuts when we add a full TUI to powercode later.


A Few Easy Pitfalls

1. Writing Plan Mode as a Prompt

If the model can still access write tools, you cannot say the Harness has entered read-only mode.

The correct approach is to simultaneously hide the tool definitions and check the allowed list again before execution.

2. Automatically Executing a Plan Upon Submission

This would make "approval" just interface text.

After submit_plan is called, the status must be switched to:

waiting_for_approval

And then the Agent Loop must be paused.

3. Treating the Plan Checklist as Execution Progress Before Approval

The plan can have a task breakdown with checkboxes, but before user approval, they are still part of the proposal and should not be displayed as "in progress."

This article chooses to generate an independent TODO.md only after approval. If you choose a single-document approach like Codex, you must also distinguish between "pending approval" and "executing" in the state.

4. Giving Plan Mode the Entire Bash

You can't just check if a command starts with cat, grep, find. Pipes, redirections, command substitution, and subprocesses all make string judgment complex.

Using dedicated read-only tools is clearer for the first version.

5. Thinking Plan and TODO Must Be Saved as Two Files

The number of files is not the key; the state boundary is.

You can, like this article, use a stable PLAN.md to save the approved plan and a continuously changing TODO.md to record execution progress; or you can, like Codex, keep a checklist directly in the plan document.

As long as the Harness clearly records "pending approval" and "executing," both approaches are reasonable.

6. Directly Using the Plan Title as a Folder Name

The Plan title is generated by the model and cannot be directly used as a trusted path. Slashes, .., and special characters must first be removed, the length limited, and a timestamp or task ID added to avoid overwriting names.

7. Letting the Model Approve Its Own Plan

The model can generate a plan, but the approval authority belongs to the user. After submit_plan, it must return to the CLI, not send another "please confirm the plan is feasible" for the model to self-judge.

8. Writing Sensitive Information into Plan Files

PLAN.md and TODO.md in .powercode/tasks/ might enter Git. Do not write API Keys, Cookies, access tokens, or complete sensitive logs into them.

If these are just local runtime states, you can add .powercode/ to .gitignore; if the team wants to keep plans as project documentation, then selectively commit them.


What We've Truly Added to the Agent Here

AgentState
  Saves mode, status, pending plan, and approved plan

Plan Mode
  Only allows reading, listing directories, searching, and submitting plans

submit_plan
  Turns ordinary model output into an explicit workflow event

User Approval
  Decides to continue modifying, cancel, or switch to execution mode

Task Directory
  Generates a safe name based on the Plan title, isolating artifacts for each task

PLAN.md + TODO.md
  Respectively save the approved plan and real-time execution progress

The most important change is not the addition of two Markdown files, nor the /plan command.

It's that the Harness has started managing the Agent's work phases for the first time:

Planning Phase
  Can only research, cannot execute

Approval Phase
  Agent Loop pauses, waiting for human decision

Execution Phase
  Restores write tools, completes tasks according to the approved plan

This is the most valuable lesson of Plan Mode:

It's not a prompt saying "please think first," but a read-only, approval, and execution state machine controlled by the Harness.


Preview of the Next Article

Now, powercode can already look at a project first, write a plan, and then follow the TODO step-by-step after we approve it.

But when you actually use it to modify a project, you'll quickly encounter a very practical problem:

bash execution fails
  ↓
Agent changes a parameter and executes again
  ↓
Still the same error
  ↓
Continues repeating until the maximum rounds are exhausted

Sometimes it will also prematurely mark a TODO as complete when the verification hasn't truly passed. Plan Mode can manage "think first before acting," but it can't yet manage how the Agent should self-rescue after a failure.

So in the next article, the seventh of this series, we'll continue to solve:

What to do when AI keeps reporting errors? Teach it to self-rescue instead of spinning in place.

We'll add failure classification, repeated failure detection, and recovery prompts to powercode. So the Harness doesn't just throw the error back to the model as-is, but can tell it: where it failed, whether it's a repeated failure, and what different approach to try next.