From 0c0b75ad7b0d5a1c2d4b0ef4cef2bfb7a7a8fac2 Mon Sep 17 00:00:00 2001 From: rsimpson2 Date: Thu, 2 Apr 2026 10:06:06 -0400 Subject: [PATCH 1/4] feat: add plan-mode switch that disables mutating tools - Add --plan CLI flag to start in plan mode - Add /plan slash command to toggle plan mode at runtime - When plan mode is active, mutating tools (edit_file, write_file, bash, subagent) are denied - Read-only tools (read_file, glob, grep) remain available - Add enablePlanMode/disablePlanMode/isMutatingTool exports to tool registry - Thread planMode through ResolvedConfig and runCli --- src/cli.ts | 11 +++++++++-- src/cli/runCli.ts | 16 ++++++++-------- src/config/types.ts | 1 + src/repl.ts | 23 +++++++++++++++++++++-- src/repl/commands.ts | 18 +++++++++++++++++- src/tools/index.ts | 35 +++++++++++++++++++++++++++++++++++ 6 files changed, 91 insertions(+), 13 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 840fff9..7f59f14 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,10 +21,17 @@ const program = new Command(); program.name(metadata.name).description(metadata.description).version(metadata.version); program.option("--resume ", "Resume a saved session by identifier"); +program.option("--plan", "Start in plan mode (read-only, no mutating tools)"); program.action(async (_options, command) => { - const parsed = command.opts() as { resume?: string }; - const args = parsed.resume ? ["--resume", parsed.resume] : []; + const parsed = command.opts() as { resume?: string; plan?: boolean }; + const args: string[] = []; + if (parsed.resume) { + args.push("--resume", parsed.resume); + } + if (parsed.plan) { + args.push("--plan"); + } const { runCli } = await import("./cli/runCli.js"); const { loadConfig, loadProjectInstructions } = await import("./config/index.js"); diff --git a/src/cli/runCli.ts b/src/cli/runCli.ts index 06dee72..9f19d61 100644 --- a/src/cli/runCli.ts +++ b/src/cli/runCli.ts @@ -1,4 +1,5 @@ import type { Config } from "../config/index.js"; +import type { ResolvedConfig } from "../config/types.js"; type RunCliDependencies = { cwd: string; @@ -6,14 +7,7 @@ type RunCliDependencies = { loadConfig: (options?: { cwd?: string }) => Config; loadProjectInstructions: (cwd: string) => string | null; assertResumeTarget: (projectRoot: string, sessionId: string) => Promise; - startRepl: ( - apiKey: string, - config: Config & { - projectInstructions?: string | null; - projectRoot?: string; - resumeSessionId?: string; - }, - ) => Promise; + startRepl: (apiKey: string, config: ResolvedConfig) => Promise; writeError: (message: string) => void; exit: (code: number) => void; }; @@ -27,6 +21,10 @@ function parseResumeSessionId(args: string[]): string | undefined { return args[resumeIndex + 1]; } +function parsePlanMode(args: string[]): boolean { + return args.includes("--plan"); +} + export async function runCli(args: string[], dependencies: RunCliDependencies): Promise { const { cwd, @@ -47,6 +45,7 @@ export async function runCli(args: string[], dependencies: RunCliDependencies): } const resumeSessionId = parseResumeSessionId(args); + const planMode = parsePlanMode(args); const config = loadConfig({ cwd }); const projectInstructions = loadProjectInstructions(cwd); @@ -67,5 +66,6 @@ export async function runCli(args: string[], dependencies: RunCliDependencies): projectInstructions, projectRoot: cwd, resumeSessionId, + planMode, }); } diff --git a/src/config/types.ts b/src/config/types.ts index 7ea69d3..a51eb27 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -10,4 +10,5 @@ export type ResolvedConfig = Config & { projectInstructions?: string | null; projectRoot?: string; resumeSessionId?: string; + planMode?: boolean; }; diff --git a/src/repl.ts b/src/repl.ts index 0d3ee65..09f4929 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -18,7 +18,7 @@ import { } from "./persistence/sessions.js"; import { buildSessionBootstrap } from "./repl/bootstrap.js"; import { handleSlashCommand } from "./repl/commands.js"; -import { createToolRegistry } from "./tools/index.js"; +import { createToolRegistry, enablePlanMode, disablePlanMode } from "./tools/index.js"; import { createSubagentTool } from "./subagent/tool.js"; const BASE_SYSTEM_PROMPT = @@ -84,6 +84,12 @@ export async function startRepl(apiKey: string, config: ResolvedConfig = {}): Pr ), }), ); + let planMode = config.planMode ?? false; + + if (planMode) { + enablePlanMode(toolRegistry); + } + const promptForApproval = createPromptForApproval(rl); const tokenTracker = new TokenTracker(); const model = config.model ?? DEFAULT_MODEL; @@ -130,7 +136,10 @@ export async function startRepl(apiKey: string, config: ResolvedConfig = {}): Pr } console.log(chalk.cyan("AI Coding Agent")); - console.log(chalk.dim('Type "exit" or "quit" to leave. Type /status for context info.\n')); + if (planMode) { + console.log(chalk.yellow("📋 Plan mode active — mutating tools are disabled.")); + } + console.log(chalk.dim('Type "exit" or "quit" to leave. Type /status for context info. Type /plan to toggle plan mode.\n')); try { while (true) { @@ -164,6 +173,16 @@ export async function startRepl(apiKey: string, config: ResolvedConfig = {}): Pr remember, recall, forget, + planMode, + togglePlanMode: () => { + planMode = !planMode; + if (planMode) { + enablePlanMode(toolRegistry); + } else { + disablePlanMode(toolRegistry, config.permissions); + } + return planMode; + }, })) { continue; } diff --git a/src/repl/commands.ts b/src/repl/commands.ts index 9a7e243..882e6cc 100644 --- a/src/repl/commands.ts +++ b/src/repl/commands.ts @@ -10,6 +10,8 @@ type HandleSlashCommandOptions = { remember: (projectRoot: string, text: string) => Promise<{ id: string; text: string }>; recall: (projectRoot: string, query?: string) => Promise; forget: (projectRoot: string, memoryId: string) => Promise<{ removed: boolean }>; + planMode?: boolean; + togglePlanMode?: () => boolean; }; function formatStatus(tracker: TokenTracker): string { @@ -40,13 +42,27 @@ export async function handleSlashCommand( return false; } - const { projectRoot, tracker, writeLine, remember, recall, forget } = options; + const { projectRoot, tracker, writeLine, remember, recall, forget, togglePlanMode } = options; if (trimmed.toLowerCase() === "/status") { writeLine(formatStatus(tracker)); return true; } + if (trimmed.toLowerCase() === "/plan") { + if (togglePlanMode) { + const newState = togglePlanMode(); + writeLine( + newState + ? chalk.yellow("Plan mode ON — mutating tools disabled.") + : chalk.green("Plan mode OFF — mutating tools re-enabled."), + ); + } else { + writeLine(chalk.dim("Plan mode toggle not available.")); + } + return true; + } + if (trimmed.toLowerCase().startsWith("/remember")) { const fact = trimmed.slice("/remember".length).trim(); const stored = await remember(projectRoot, fact); diff --git a/src/tools/index.ts b/src/tools/index.ts index c04b9b3..f3b6806 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -29,6 +29,8 @@ export type ToolRegistry = { getDefinitions: () => ToolDefinition[]; }; +const MUTATING_TOOLS = new Set(["edit_file", "write_file", "bash", "subagent"]); + export function createToolRegistry( permissionOverrides?: Record, ): ToolRegistry { @@ -64,3 +66,36 @@ export function createToolRegistry( return registry; } + +export function isMutatingTool(name: string): boolean { + return MUTATING_TOOLS.has(name); +} + +export function enablePlanMode(registry: ToolRegistry): void { + for (const name of MUTATING_TOOLS) { + const tool = registry.get(name); + if (tool) { + registry.register({ ...tool, permission: "deny" }); + } + } +} + +export function disablePlanMode( + registry: ToolRegistry, + permissionOverrides?: Record, +): void { + const defaults: Record = { + edit_file: "prompt", + write_file: "prompt", + bash: "prompt", + subagent: "prompt", + }; + + for (const name of MUTATING_TOOLS) { + const tool = registry.get(name); + if (tool) { + const permission = permissionOverrides?.[name] ?? defaults[name] ?? "prompt"; + registry.register({ ...tool, permission }); + } + } +} From d72d51a935c4ef86620cab873eab347f67eee949 Mon Sep 17 00:00:00 2001 From: rsimpson2 Date: Thu, 2 Apr 2026 10:06:30 -0400 Subject: [PATCH 2/4] test: add plan-mode unit tests for enablePlanMode/disablePlanMode/isMutatingTool --- src/__tests__/plan-mode.test.ts | 92 +++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/__tests__/plan-mode.test.ts diff --git a/src/__tests__/plan-mode.test.ts b/src/__tests__/plan-mode.test.ts new file mode 100644 index 0000000..6008020 --- /dev/null +++ b/src/__tests__/plan-mode.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { + createToolRegistry, + enablePlanMode, + disablePlanMode, + isMutatingTool, +} from "../tools/index.js"; + +describe("isMutatingTool", () => { + it("identifies mutating tools", () => { + expect(isMutatingTool("edit_file")).toBe(true); + expect(isMutatingTool("write_file")).toBe(true); + expect(isMutatingTool("bash")).toBe(true); + expect(isMutatingTool("subagent")).toBe(true); + }); + + it("identifies read-only tools", () => { + expect(isMutatingTool("read_file")).toBe(false); + expect(isMutatingTool("glob")).toBe(false); + expect(isMutatingTool("grep")).toBe(false); + }); +}); + +describe("enablePlanMode", () => { + it("sets all mutating tools to deny", () => { + const registry = createToolRegistry(); + enablePlanMode(registry); + + expect(registry.get("edit_file")?.permission).toBe("deny"); + expect(registry.get("write_file")?.permission).toBe("deny"); + expect(registry.get("bash")?.permission).toBe("deny"); + }); + + it("leaves read-only tools unchanged", () => { + const registry = createToolRegistry(); + enablePlanMode(registry); + + expect(registry.get("read_file")?.permission).toBe("allow"); + expect(registry.get("glob")?.permission).toBe("allow"); + expect(registry.get("grep")?.permission).toBe("allow"); + }); +}); + +describe("disablePlanMode", () => { + it("restores mutating tools to default prompt permission", () => { + const registry = createToolRegistry(); + enablePlanMode(registry); + disablePlanMode(registry); + + expect(registry.get("edit_file")?.permission).toBe("prompt"); + expect(registry.get("write_file")?.permission).toBe("prompt"); + expect(registry.get("bash")?.permission).toBe("prompt"); + }); + + it("respects permission overrides when restoring", () => { + const registry = createToolRegistry(); + enablePlanMode(registry); + disablePlanMode(registry, { bash: "allow" }); + + expect(registry.get("bash")?.permission).toBe("allow"); + expect(registry.get("edit_file")?.permission).toBe("prompt"); + }); + + it("leaves read-only tools unchanged", () => { + const registry = createToolRegistry(); + enablePlanMode(registry); + disablePlanMode(registry); + + expect(registry.get("read_file")?.permission).toBe("allow"); + expect(registry.get("glob")?.permission).toBe("allow"); + expect(registry.get("grep")?.permission).toBe("allow"); + }); +}); + +describe("plan mode toggle cycle", () => { + it("can toggle plan mode on and off repeatedly", () => { + const registry = createToolRegistry(); + + enablePlanMode(registry); + expect(registry.get("edit_file")?.permission).toBe("deny"); + + disablePlanMode(registry); + expect(registry.get("edit_file")?.permission).toBe("prompt"); + + enablePlanMode(registry); + expect(registry.get("edit_file")?.permission).toBe("deny"); + + disablePlanMode(registry); + expect(registry.get("edit_file")?.permission).toBe("prompt"); + }); +}); From e6d3df523d7c35b018670eda850ce8ad9c2c80dd Mon Sep 17 00:00:00 2001 From: rsimpson2 Date: Thu, 2 Apr 2026 10:06:37 -0400 Subject: [PATCH 3/4] docs: mark plan-mode switch task complete in ROADMAP --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 976c2c4..4f7c755 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,7 +135,7 @@ This roadmap translates `REQUIREMENTS.md` into phased, atomic tasks for building - [x] Give each subagent access to the same tool catalog as the main agent. - [x] Collect subagent outputs and return a merged parent summary. - [x] Keep subagent tool-call traces out of main chat transcript by default. -- [ ] Add a plan-mode switch that disables mutating tools. +- [x] Add a plan-mode switch that disables mutating tools. - [ ] Use a planner-oriented system prompt while in plan mode. - [ ] Add explicit user approval flow to exit plan mode into execution mode. - [ ] Execute approved plans as ordered actionable steps. From ba2f85fb939ae1989c5398fb6a1170c416519569 Mon Sep 17 00:00:00 2001 From: rsimpson2 Date: Thu, 2 Apr 2026 10:11:01 -0400 Subject: [PATCH 4/4] docs: add plan-mode documentation to README and manual tests --- README.md | 24 ++++++++++++++++- docs/MANUAL_TESTING.md | 60 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c56df8e..0386e17 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,35 @@ npm run dev Type a message and press Enter. Responses stream to the terminal as they're generated. Type `exit` or `quit` to leave, or press `Ctrl+C`. -Useful built-in commands: +### Plan mode + +Start in plan mode to explore a codebase without making changes: + +```bash +npm run dev -- --plan +``` + +Plan mode blocks mutating tools (`edit_file`, `write_file`, `bash`, `subagent`) while keeping read-only tools (`read_file`, `glob`, `grep`) available. Use it when you want the agent to investigate, analyze, or propose a plan before executing any changes. + +Toggle plan mode at runtime with the `/plan` slash command: + +``` +> /plan +Plan mode ON — mutating tools disabled. +> /plan +Plan mode OFF — mutating tools re-enabled. +``` + +### Built-in commands - `/status` shows current context usage +- `/plan` toggles plan mode on/off - `/remember ` stores a durable project memory - `/recall [query]` lists stored memories or searches them - `/forget ` removes a stored memory +### Resuming a session + To resume a saved session: ```bash diff --git a/docs/MANUAL_TESTING.md b/docs/MANUAL_TESTING.md index 7768cf9..84e6b2a 100644 --- a/docs/MANUAL_TESTING.md +++ b/docs/MANUAL_TESTING.md @@ -645,3 +645,63 @@ rm -rf .ai-agent **Expected:** The CLI prints an error explaining that the saved session was not found and exits without showing the REPL prompt. **Pass criteria:** No interactive prompt appears. The command exits immediately with an error message. + +--- + +## Step 8 - Plan Mode + +### Test 8.1: Start with `--plan` flag + +**Goal:** Verify the agent starts in plan mode and blocks mutating tools while allowing read-only exploration. + +**Steps:** + +1. Start the agent with `npm run dev -- --plan` +2. Verify the startup banner shows `📋 Plan mode active — mutating tools are disabled.` +3. Type: `Read the contents of package.json` +4. Verify the agent reads the file successfully (no approval prompt — `read_file` is still `allow`) +5. Type: `Create a file at playground/test.txt with the text "hello"` + +**Expected:** The `read_file` call in step 3 works normally. The `write_file` call in step 5 is denied — the agent receives a denial result and should explain that it cannot make changes in plan mode. No approval prompt appears for the denied tool (it is blocked outright, not prompted). + +**Pass criteria:** Read-only tools work. Mutating tools (`write_file`, `edit_file`, `bash`) are denied without prompting. The agent stays alive and continues accepting input. + +--- + +### Test 8.2: Toggle plan mode at runtime with `/plan` + +**Goal:** Verify the `/plan` slash command toggles plan mode on and off. + +**Steps:** + +1. Start the agent normally with `npm run dev` (no `--plan` flag) +2. Type: `/plan` +3. Verify the output: `Plan mode ON — mutating tools disabled.` +4. Type: `Run the command "echo hi" using bash` +5. Verify the bash tool is denied (no approval prompt) +6. Type: `/plan` +7. Verify the output: `Plan mode OFF — mutating tools re-enabled.` +8. Type: `Run the command "echo hi" using bash` +9. Verify the bash tool now prompts for approval as normal + +**Expected:** The first `/plan` enables plan mode and blocks mutating tools. The second `/plan` disables it and restores normal permissions. The toggle can be repeated any number of times. + +**Pass criteria:** Each `/plan` toggles the state. Mutating tools are denied when on, prompted when off. Read-only tools are unaffected throughout. + +--- + +### Test 8.3: Plan mode blocks mutating tools with helpful feedback + +**Goal:** Verify that blocked tools in plan mode return a clear message to the agent (not a crash or silent failure). + +**Steps:** + +1. Start the agent with `npm run dev -- --plan` +2. Type: `Edit the file src/cli.ts and change the first comment to say "modified"` +3. Observe the agent's response +4. Type: `Search for all TypeScript files in src/` +5. Observe the agent's response + +**Expected:** In step 3, the agent acknowledges it cannot make edits because plan mode is active. It may suggest turning off plan mode or describe what it would do. In step 5, the `glob` tool works normally and returns results. + +**Pass criteria:** The agent does not crash or hang when a mutating tool is denied. It communicates the restriction clearly. Read-only tools continue to function.