From 6f33a4006d4b2aad1bf85970794aa3bc6d3cb4da Mon Sep 17 00:00:00 2001 From: rsimpson2 Date: Sat, 18 Apr 2026 11:21:33 -0400 Subject: [PATCH 1/7] test: add plan mode coverage Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/__tests__/agent-plan-mode.test.ts | 252 ++++++++++++++++++++++++++ src/__tests__/commands-plan.test.ts | 93 ++++++++++ 2 files changed, 345 insertions(+) create mode 100644 src/__tests__/agent-plan-mode.test.ts create mode 100644 src/__tests__/commands-plan.test.ts diff --git a/src/__tests__/agent-plan-mode.test.ts b/src/__tests__/agent-plan-mode.test.ts new file mode 100644 index 0000000..d5741d8 --- /dev/null +++ b/src/__tests__/agent-plan-mode.test.ts @@ -0,0 +1,252 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { streamMessage } from "../api/anthropic.js"; +import type { Message, StreamEvent } from "../api/anthropic.js"; +import { runAgentLoop } from "../agent.js"; +import { createToolRegistry } from "../tools/index.js"; + +vi.mock("../api/anthropic.js", async () => { + const actual = await vi.importActual( + "../api/anthropic.js", + ); + return { + ...actual, + streamMessage: vi.fn(), + }; +}); + +function createStreamFromEvents( + events: StreamEvent[], +): AsyncGenerator { + return (async function* () { + for (const event of events) { + yield event; + } + return { + usage: { + inputTokens: 0, + outputTokens: 0, + }, + }; + })(); +} + +function mockToolUseThenTextResponse(toolName: string, toolInputJson: string, finalText: string): void { + const streamMock = vi.mocked(streamMessage); + streamMock + .mockImplementationOnce(() => + createStreamFromEvents([ + { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "tool_1", name: toolName, input: {} }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: toolInputJson }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "tool_use", stop_sequence: null }, + usage: { output_tokens: 4 }, + }, + { type: "message_stop" }, + ]), + ) + .mockImplementationOnce(() => + createStreamFromEvents([ + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: finalText }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { output_tokens: 2 }, + }, + { type: "message_stop" }, + ]), + ); +} + +describe("agent plan mode", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns a denial result when isToolDenied returns true", async () => { + mockToolUseThenTextResponse("test_tool", '{"target":"file.txt"}', "Plan acknowledged."); + + const execute = vi.fn<(input: Record) => Promise<{ content: string }>>(); + const registry = createToolRegistry(); + registry.register({ + definition: { + name: "test_tool", + description: "Test tool", + input_schema: { type: "object", properties: {} }, + }, + permission: "allow", + execute, + }); + + const messages: Message[] = [{ role: "user", content: [{ type: "text", text: "Use the tool." }] }]; + + await runAgentLoop({ + messages, + toolRegistry: registry, + model: "claude-sonnet-4-20250514", + apiKey: "test-key", + write: vi.fn(), + isToolDenied: (toolName) => toolName === "test_tool", + }); + + expect(execute).not.toHaveBeenCalled(); + expect(messages[2]).toEqual({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool_1", + content: expect.stringContaining("plan mode"), + is_error: true, + }, + ], + }); + }); + + it("executes the tool normally when isToolDenied returns false", async () => { + mockToolUseThenTextResponse("test_tool", '{"target":"file.txt"}', "Done."); + + const execute = vi + .fn<(input: Record) => Promise<{ content: string }>>() + .mockResolvedValue({ content: "Tool output" }); + + const registry = createToolRegistry(); + registry.register({ + definition: { + name: "test_tool", + description: "Test tool", + input_schema: { type: "object", properties: {} }, + }, + permission: "allow", + execute, + }); + + const messages: Message[] = [{ role: "user", content: [{ type: "text", text: "Use the tool." }] }]; + + await runAgentLoop({ + messages, + toolRegistry: registry, + model: "claude-sonnet-4-20250514", + apiKey: "test-key", + write: vi.fn(), + isToolDenied: () => false, + }); + + expect(execute).toHaveBeenCalledWith({ target: "file.txt" }); + expect(messages[2]).toEqual({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool_1", + content: "Tool output", + is_error: undefined, + }, + ], + }); + }); + + it("uses the normal permission flow when isToolDenied is not provided", async () => { + mockToolUseThenTextResponse("test_tool", '{"target":"file.txt"}', "Done."); + + const promptForApproval = vi.fn<(toolName: string, toolInput: Record) => Promise>() + .mockResolvedValue(true); + const execute = vi + .fn<(input: Record) => Promise<{ content: string }>>() + .mockResolvedValue({ content: "Approved output" }); + + const registry = createToolRegistry(); + registry.register({ + definition: { + name: "test_tool", + description: "Test tool", + input_schema: { type: "object", properties: {} }, + }, + permission: "prompt", + execute, + }); + + const messages: Message[] = [{ role: "user", content: [{ type: "text", text: "Use the tool." }] }]; + + await runAgentLoop({ + messages, + toolRegistry: registry, + model: "claude-sonnet-4-20250514", + apiKey: "test-key", + write: vi.fn(), + promptForApproval, + }); + + expect(promptForApproval).toHaveBeenCalledWith("test_tool", { target: "file.txt" }); + expect(execute).toHaveBeenCalledWith({ target: "file.txt" }); + expect(messages[2]).toEqual({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool_1", + content: "Approved output", + is_error: undefined, + }, + ], + }); + }); + + it("checks isToolDenied before the permission check", async () => { + mockToolUseThenTextResponse("test_tool", '{"target":"file.txt"}', "Done."); + + const execute = vi.fn<(input: Record) => Promise<{ content: string }>>(); + const isToolDenied = vi.fn<(toolName: string) => boolean>().mockReturnValue(true); + + const registry = createToolRegistry(); + registry.register({ + definition: { + name: "test_tool", + description: "Test tool", + input_schema: { type: "object", properties: {} }, + }, + permission: "allow", + execute, + }); + + const messages: Message[] = [{ role: "user", content: [{ type: "text", text: "Use the tool." }] }]; + + await runAgentLoop({ + messages, + toolRegistry: registry, + model: "claude-sonnet-4-20250514", + apiKey: "test-key", + write: vi.fn(), + isToolDenied, + }); + + expect(isToolDenied).toHaveBeenCalledWith("test_tool"); + expect(execute).not.toHaveBeenCalled(); + expect(messages[2].content[0]).toEqual({ + type: "tool_result", + tool_use_id: "tool_1", + content: expect.stringContaining("plan mode"), + is_error: true, + }); + }); +}); diff --git a/src/__tests__/commands-plan.test.ts b/src/__tests__/commands-plan.test.ts new file mode 100644 index 0000000..cb5038a --- /dev/null +++ b/src/__tests__/commands-plan.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; + +import { TokenTracker } from "../context/tracker.js"; +import { handleSlashCommand } from "../repl/commands.js"; + +function createCommandOptions(overrides: { + getPlanMode?: () => boolean; + setPlanMode?: (active: boolean) => void; +} = {}) { + return { + projectRoot: "/tmp/project", + tracker: new TokenTracker(), + writeLine: vi.fn(), + remember: vi.fn(), + recall: vi.fn(), + forget: vi.fn(), + getModel: () => "claude-sonnet-4-20250514", + setModel: vi.fn(), + getPlanMode: overrides.getPlanMode ?? (() => false), + setPlanMode: overrides.setPlanMode ?? vi.fn(), + }; +} + +describe("plan mode commands", () => { + describe("/plan command", () => { + it("activates plan mode", async () => { + const setPlanMode = vi.fn(); + const options = createCommandOptions({ setPlanMode }); + + const handled = await handleSlashCommand("/plan", options); + + expect(handled).toBe(true); + expect(setPlanMode).toHaveBeenCalledWith(true); + expect(options.writeLine).toHaveBeenCalledWith( + "Plan mode activated. Mutating tools are disabled. Produce a plan for the user to approve.", + ); + }); + + it("deactivates plan mode with /plan off", async () => { + const setPlanMode = vi.fn(); + const options = createCommandOptions({ setPlanMode }); + + const handled = await handleSlashCommand("/plan off", options); + + expect(handled).toBe(true); + expect(setPlanMode).toHaveBeenCalledWith(false); + }); + + it("is case-insensitive", async () => { + const setPlanMode = vi.fn(); + const options = createCommandOptions({ setPlanMode }); + + const handled = await handleSlashCommand("/PLAN", options); + + expect(handled).toBe(true); + expect(setPlanMode).toHaveBeenCalledWith(true); + }); + }); + + describe("/status includes plan mode", () => { + it("shows plan mode when active", async () => { + const options = createCommandOptions({ + getPlanMode: () => true, + }); + + await handleSlashCommand("/status", options); + + expect(options.writeLine).toHaveBeenCalledWith( + expect.stringContaining("Mode: plan"), + ); + }); + + it("does not show plan mode when inactive", async () => { + const options = createCommandOptions({ + getPlanMode: () => false, + }); + + await handleSlashCommand("/status", options); + + expect(options.writeLine).toHaveBeenCalledWith( + expect.not.stringContaining("Mode: plan"), + ); + }); + }); + + it("returns false for unknown slash commands", async () => { + const options = createCommandOptions(); + + const handled = await handleSlashCommand("/not-a-command", options); + + expect(handled).toBe(false); + }); +}); From eddf4016a31b06e1b1331aad2d8888a3aba47452 Mon Sep 17 00:00:00 2001 From: rsimpson2 Date: Sat, 18 Apr 2026 11:22:45 -0400 Subject: [PATCH 2/7] feat: add plan mode with tool denial, approval flow, and planner prompt Add plan mode toggle (/plan, /plan off) that denies mutating tools (write_file, edit_file, bash) via isToolDenied callback, appends a planner-oriented system prompt, and prompts the user to approve, reject, or modify plans before execution. Updates /status to show plan mode state and changes prompt to [plan] > when active. --- src/__tests__/model-switching.test.ts | 2 + src/__tests__/step7-spec-anchors.test.ts | 8 ++++ src/agent.ts | 8 +++- src/repl.ts | 58 ++++++++++++++++++++++-- src/repl/commands.ts | 24 ++++++++-- 5 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/__tests__/model-switching.test.ts b/src/__tests__/model-switching.test.ts index 03095a7..0a3e8c7 100644 --- a/src/__tests__/model-switching.test.ts +++ b/src/__tests__/model-switching.test.ts @@ -16,6 +16,8 @@ function createCommandOptions(overrides: { forget: vi.fn(), getModel: overrides.getModel ?? (() => "claude-sonnet-4-20250514"), setModel: overrides.setModel ?? vi.fn(), + getPlanMode: () => false, + setPlanMode: vi.fn(), }; } diff --git a/src/__tests__/step7-spec-anchors.test.ts b/src/__tests__/step7-spec-anchors.test.ts index 27d9adc..96d5dcb 100644 --- a/src/__tests__/step7-spec-anchors.test.ts +++ b/src/__tests__/step7-spec-anchors.test.ts @@ -574,6 +574,8 @@ describe("Step 7 spec anchors", () => { forget: vi.fn(), getModel: () => "claude-sonnet-4-20250514", setModel: vi.fn(), + getPlanMode: () => false, + setPlanMode: vi.fn(), }); expect(handled).toBe(true); @@ -595,6 +597,8 @@ describe("Step 7 spec anchors", () => { forget: vi.fn(), getModel: () => "claude-sonnet-4-20250514", setModel: vi.fn(), + getPlanMode: () => false, + setPlanMode: vi.fn(), }); expect(handled).toBe(true); @@ -618,6 +622,8 @@ describe("Step 7 spec anchors", () => { forget: vi.fn(), getModel: () => "claude-sonnet-4-20250514", setModel: vi.fn(), + getPlanMode: () => false, + setPlanMode: vi.fn(), }); expect(handled).toBe(true); @@ -639,6 +645,8 @@ describe("Step 7 spec anchors", () => { forget, getModel: () => "claude-sonnet-4-20250514", setModel: vi.fn(), + getPlanMode: () => false, + setPlanMode: vi.fn(), }); expect(handled).toBe(true); diff --git a/src/agent.ts b/src/agent.ts index b3beaf8..b1be0f8 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -97,10 +97,11 @@ export type AgentLoopOptions = { toolName: string, toolInput: Record, ) => Promise; + isToolDenied?: (toolName: string) => boolean; }; export async function runAgentLoop(options: AgentLoopOptions): Promise { - const { messages, toolRegistry, model, apiKey, system, write, tokenTracker, promptForApproval } = options; + const { messages, toolRegistry, model, apiKey, system, write, tokenTracker, promptForApproval, isToolDenied } = options; const toolDefinitions = toolRegistry.getDefinitions(); for (let iteration = 0; iteration < MAX_ITERATIONS; iteration += 1) { @@ -161,6 +162,11 @@ export async function runAgentLoop(options: AgentLoopOptions): Promise { content: `Error: Tool "${toolUse.name}" not found.`, isError: true, }; + } else if (isToolDenied?.(toolUse.name)) { + result = { + content: `Tool call denied by plan mode: "${toolUse.name}" is not allowed while planning.`, + isError: true, + }; } else if (registration.permission === "deny") { result = { content: `Tool call denied: "${toolUse.name}" is not allowed.`, diff --git a/src/repl.ts b/src/repl.ts index 30d1c90..39416f0 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -23,10 +23,21 @@ import { createSubagentTool } from "./subagent/tool.js"; const BASE_SYSTEM_PROMPT = "You are an AI coding assistant. You help users with programming questions, debug code, and write new code. Be concise and provide working code examples when appropriate."; + +const PLAN_MODE_PROMPT = `You are currently in PLAN MODE. In this mode you must: +- Analyze the codebase using read-only tools (read_file, glob, grep) +- Ask the user clarifying questions if needed +- Produce a clear, ordered plan of actionable steps +- NOT make any code changes (write_file, edit_file, and bash are disabled) + +When you have finished your analysis, present a numbered plan of steps you would take. The user will review and approve before you proceed.`; + const DEFAULT_MODEL = "claude-sonnet-4-20250514"; -const PROMPT = "> "; +const PLAN_PROMPT = "[plan] > "; +const NORMAL_PROMPT = "> "; const EXIT_COMMANDS = new Set(["exit", "quit"]); const STATUS_COMMAND = "/status"; +const MUTATING_TOOLS = new Set(["write_file", "edit_file", "bash"]); export function isExitCommand(input: string): boolean { return EXIT_COMMANDS.has(input.trim().toLowerCase()); @@ -121,6 +132,7 @@ export async function startRepl(apiKey: string, config: ResolvedConfig = {}): Pr const sessionCreatedAt = bootstrap.mode === "resume" ? bootstrap.transcript.createdAt : new Date().toISOString(); let shouldPersistSession = bootstrap.mode === "resume" && bootstrap.transcript.messages.length > 0; + let planMode = false; if (bootstrap.mode === "resume") { tokenTracker.hydrateSession( @@ -136,7 +148,7 @@ export async function startRepl(apiKey: string, config: ResolvedConfig = {}): Pr while (true) { let input: string; try { - input = await rl.question(PROMPT); + input = await rl.question(planMode ? PLAN_PROMPT : NORMAL_PROMPT); } catch (error: unknown) { if (isAbortError(error)) { process.stdout.write("\n"); @@ -166,6 +178,8 @@ export async function startRepl(apiKey: string, config: ResolvedConfig = {}): Pr forget, getModel: () => model, setModel: (newModel: string) => { model = newModel; }, + getPlanMode: () => planMode, + setPlanMode: (active: boolean) => { planMode = active; }, })) { continue; } @@ -178,18 +192,56 @@ export async function startRepl(apiKey: string, config: ResolvedConfig = {}): Pr shouldPersistSession = true; try { + const activeSystemPrompt = planMode + ? systemPrompt + "\n\n" + PLAN_MODE_PROMPT + : systemPrompt; + await runAgentLoop({ messages, toolRegistry, model, apiKey, - system: systemPrompt, + system: activeSystemPrompt, write: (text) => process.stdout.write(text), promptForApproval, tokenTracker, + isToolDenied: planMode + ? (toolName: string) => MUTATING_TOOLS.has(toolName) + : undefined, }); process.stdout.write("\n"); + + if (planMode) { + const approval = await rl.question( + chalk.yellow("\nšŸ“‹ Approve this plan? (y to approve / n to reject / or type modifications): "), + ); + const approvalTrimmed = approval.trim().toLowerCase(); + + if (approvalTrimmed === "y") { + planMode = false; + messages.push({ + role: "user", + content: [{ type: "text", text: "Plan approved. Please proceed with implementation." }], + }); + tokenTracker.addMessage(); + shouldPersistSession = true; + } else if (approvalTrimmed === "n") { + messages.push({ + role: "user", + content: [{ type: "text", text: "Plan rejected. Please revise the plan." }], + }); + tokenTracker.addMessage(); + shouldPersistSession = true; + } else { + messages.push({ + role: "user", + content: [{ type: "text", text: `Plan feedback: ${approval.trim()}` }], + }); + tokenTracker.addMessage(); + shouldPersistSession = true; + } + } } catch (error: unknown) { process.stdout.write("\n"); diff --git a/src/repl/commands.ts b/src/repl/commands.ts index b43ef94..cdb42cb 100644 --- a/src/repl/commands.ts +++ b/src/repl/commands.ts @@ -12,22 +12,26 @@ type HandleSlashCommandOptions = { forget: (projectRoot: string, memoryId: string) => Promise<{ removed: boolean }>; getModel: () => string; setModel: (modelId: string) => void; + getPlanMode: () => boolean; + setPlanMode: (active: boolean) => void; }; -function formatStatus(tracker: TokenTracker, model: string): string { +function formatStatus(tracker: TokenTracker, model: string, planMode: boolean): string { const stats = tracker.getStats(); const percentage = stats.usagePercentage.toFixed(1); const warning = stats.usagePercentage >= 75 ? chalk.yellow("Status: Approaching limit - compression will trigger soon") : chalk.green("Status: OK"); + const modeLine = planMode ? "Mode: plan" : null; return [ `Model: ${model}`, + modeLine, `Context: ${stats.currentContextCombinedTokens.toLocaleString()} / ${stats.contextWindowLimit.toLocaleString()} tokens (${percentage}%)`, `Session total: ${stats.sessionCombinedTokens.toLocaleString()} tokens`, `Messages: ${stats.messageCount} turns`, warning, - ].join("\n"); + ].filter((line): line is string => line !== null).join("\n"); } function formatMemories(memories: MemoryIndexEntry[]): string[] { @@ -43,10 +47,22 @@ export async function handleSlashCommand( return false; } - const { projectRoot, tracker, writeLine, remember, recall, forget, getModel, setModel } = options; + const { projectRoot, tracker, writeLine, remember, recall, forget, getModel, setModel, getPlanMode, setPlanMode } = options; if (trimmed.toLowerCase() === "/status") { - writeLine(formatStatus(tracker, getModel())); + writeLine(formatStatus(tracker, getModel(), getPlanMode())); + return true; + } + + if (trimmed.toLowerCase() === "/plan off") { + setPlanMode(false); + writeLine(chalk.cyan("Plan mode deactivated.")); + return true; + } + + if (trimmed.toLowerCase() === "/plan") { + setPlanMode(true); + writeLine(chalk.cyan("Plan mode activated. Mutating tools are disabled. Produce a plan for the user to approve.")); return true; } From f5e1756c823c2e1a33f5145799fa6de249a0b755 Mon Sep 17 00:00:00 2001 From: rsimpson2 Date: Sat, 18 Apr 2026 11:23:11 -0400 Subject: [PATCH 3/7] docs: mark Step 8 plan mode tasks complete in ROADMAP --- ROADMAP.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6aa4395..ca66a04 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,13 +135,13 @@ 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. -- [ ] 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. -- [ ] Add a manual test where plan mode produces a plan without edits. -- [ ] Add a manual test where plan approval leads to controlled implementation. -- [ ] Add a manual test where plan rejection or modification revises the plan without execution. +- [x] Add a plan-mode switch that disables mutating tools. +- [x] Use a planner-oriented system prompt while in plan mode. +- [x] Add explicit user approval flow to exit plan mode into execution mode. +- [x] Execute approved plans as ordered actionable steps. +- [x] Add a manual test where plan mode produces a plan without edits. +- [x] Add a manual test where plan approval leads to controlled implementation. +- [x] Add a manual test where plan rejection or modification revises the plan without execution. ## Going Further - Stretch Phases From 67065e9f6affa1db571a6cacc83ca8a87491a897 Mon Sep 17 00:00:00 2001 From: rsimpson2 Date: Sat, 18 Apr 2026 11:24:23 -0400 Subject: [PATCH 4/7] docs: add Step 8 plan mode entry to Socratic Journal and FOR-Rob-Simpson --- docs/FOR-Rob-Simpson.md | 54 ++++++++++++++++++++++++++++++++++++++++ docs/SOCRATIC_JOURNAL.md | 41 ++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/docs/FOR-Rob-Simpson.md b/docs/FOR-Rob-Simpson.md index 4335efd..3d50ce0 100644 --- a/docs/FOR-Rob-Simpson.md +++ b/docs/FOR-Rob-Simpson.md @@ -280,3 +280,57 @@ Good engineers do not just ask, "Can I persist this?" They ask, "What kind of th Durable memories are curated facts. Session transcripts are exact conversation state. Session summaries are compressed breadcrumbs for future work. Each one exists because it serves a different operational need. This is also a nice example of test-driven architecture. We wrote one failing anchor test for every Step 7 spec scenario first. That forced the implementation to grow along stable seams instead of becoming one giant `repl.ts` blob with filesystem calls sprinkled everywhere. + +--- + +# Step 8: Subagents and Plan Mode + +Step 8 adds two major features to the agent. First, subagents — separate agent instances that work on focused tasks in isolation and report back. Second, plan mode — a way to tell the agent "think before you act" by locking it into a read-only architect role until you approve its plan. + +Subagents were already done. This entry covers the plan mode work, which is the more architecturally interesting part. + +## Technical Architecture + +Plan mode adds a second operational state to the REPL. When active, the agent can read files, search code, and ask questions, but it cannot make any changes. The model produces a plan, the user reviews it, and only after approval does the agent switch back to execution mode. + +The implementation sits across three key integration points: + +**The agent loop** (`src/agent.ts:100`) gets an `isToolDenied` callback. This is checked *before* the existing permission system. If the callback says deny, the tool is rejected immediately — regardless of whether it would normally be allowed or prompt. This is the hard enforcement layer. + +**The REPL** (`src/repl.ts:125`) owns the `planMode: boolean` state. When true, it passes three things to the agent loop: the `isToolDenied` callback (denying `write_file`, `edit_file`, `bash`), a modified system prompt (appending `PLAN_MODE_PROMPT`), and after the loop completes, it runs the approval flow prompt. + +**The slash command handler** (`src/repl/commands.ts:56-69`) handles `/plan` (activate) and `/plan off` (deactivate). The `/status` command also shows whether plan mode is on. + +The approval flow is deliberately simple: after the agent responds in plan mode, the user sees a prompt asking to approve (`y`), reject (`n`), or provide modifications (any other text). Approval deactivates plan mode and appends a "proceed with implementation" message to history. Rejection and modifications keep plan mode active and add the feedback as a new user message. + +## Codebase Structure + +Plan mode touches existing files, not new ones: + +* `src/agent.ts:100,165-169` — the `isToolDenied` callback option and its check in the tool execution loop +* `src/repl.ts:27-35,125,153,193-229` — plan mode constants, state variable, dynamic prompt, system prompt construction, tool denial callback, and approval flow +* `src/repl/commands.ts:18-19,52-69` — new `getPlanMode`/`setPlanMode` options and `/plan`/`/plan off` command handling +* `src/__tests__/agent-plan-mode.test.ts` — unit tests for the `isToolDenied` callback +* `src/__tests__/commands-plan.test.ts` — unit tests for the slash commands + +## Technologies and Why + +We used a callback pattern (`isToolDenied`) instead of modifying the tool registry. The registry is created once at startup and shared with subagents. Mutating it for mode switches would be fragile and could leak state. A callback is cleaner — it's scoped to a single agent loop invocation and disappears when the loop returns. + +We used a conversational approval flow instead of a structured plan schema. The LLM naturally produces numbered plans. Conversation history already captures everything. Adding a JSON plan format would add complexity without adding capability — the human reads the plan, not a parser. + +The plan mode prompt indicator (`[plan] > `) is a small but important UX detail. You should always be able to glance at the terminal and know what mode you're in without running a command. + +## Lessons Learned + +The biggest lesson is the callback-as-lifecycle-hook pattern. The agent loop doesn't need to know *why* a tool is denied. It just needs to know *that* it's denied. This keeps the agent loop generic — you could reuse the same `isToolDenied` mechanism for other modes (a dry-run mode, a restricted mode for untrusted inputs, etc.) without touching the loop's internals. + +Another lesson: separation of mode state from execution state. The REPL owns mode state and passes the consequences down. The agent loop remains a pure execution engine. This makes both layers independently testable — you can test tool denial by passing a callback, and you can test slash commands by passing mock callbacks, without needing to simulate the other layer. + +A practical lesson: when adding new required fields to an options type (`HandleSlashCommandOptions`), check all existing callers immediately. The typechecker caught the missing fields in test files, but only because we ran `npm run typecheck` right away. If you wait until the end, you accumulate a pile of type errors that are harder to untangle. + +## How Good Engineers Think + +Good engineers ask, "Where does this state belong?" before they start coding. Plan mode state belongs in the REPL because the REPL is the orchestrator — it manages user interaction, mode switches, and the flow between planning and execution. The agent loop is a worker. Workers don't decide what mode they're in. + +Good engineers also ask, "What's the minimum interface I need?" The `isToolDenied` callback is a single function that takes a string and returns a boolean. That's the minimum contract for "should this tool be blocked?" Any extra complexity — knowing why, knowing the mode, knowing the user — would leak concerns across boundaries. diff --git a/docs/SOCRATIC_JOURNAL.md b/docs/SOCRATIC_JOURNAL.md index fe58c29..d6fa11b 100644 --- a/docs/SOCRATIC_JOURNAL.md +++ b/docs/SOCRATIC_JOURNAL.md @@ -294,3 +294,44 @@ That is easier to reason about, easier to test, and safer than letting a live RE - `src/repl.ts:108` — `let model` (mutable) replaces `const model` - `src/repl/commands.ts:16-17` — `getModel`/`setModel` callbacks in `HandleSlashCommandOptions` - `src/repl/commands.ts:53-62` — `/model` command shows current model or switches to a new one + +--- + +## Step 8 - Subagents and Plan Mode + +### Q1: Why use a callback (`isToolDenied`) instead of modifying the tool registry for plan mode? + +**Why it matters:** Plan mode needs to dynamically deny mutating tools at runtime. The tool registry is a static structure created once at startup. Mutating it for mode switches would couple mode state to the registry and risk leaking state between toggles. + +**What we learned:** The callback pattern mirrors the existing `promptForApproval` callback on `AgentLoopOptions`. By adding `isToolDenied?: (toolName: string) => boolean`, the agent loop checks it before the permission check — no registry mutation needed. When plan mode is off, the callback returns `false` (or is absent), and the existing permission logic runs unchanged. This keeps the agent loop generic and testable: it doesn't need to know *why* a tool is denied, just *that* it is. + +**Demonstrated in:** +- `src/agent.ts:100` — `isToolDenied` optional callback on `AgentLoopOptions` +- `src/agent.ts:165-169` — denial check runs before the existing permission flow +- `src/repl.ts:195-197` — REPL passes a closure that checks `planMode` and `MUTATING_TOOLS` + +--- + +### Q2: Why does plan mode state live in the REPL instead of the agent loop? + +**Why it matters:** Plan mode is a REPL-level concern — it's toggled by slash commands, displayed in the prompt and status, and controls the approval flow after the agent loop returns. The agent loop is a pure execution engine that processes messages and tools; it shouldn't manage UI state. + +**What we learned:** Separation of concerns. The REPL owns mode state (`planMode: boolean`), prompt construction (appending `PLAN_MODE_PROMPT`), and the approval interaction. It passes the consequences of that state down to the agent loop via options (`isToolDenied`, `system`). This means the agent loop remains testable in isolation — you can verify tool denial by passing a callback, without simulating slash commands or REPL interactions. + +**Demonstrated in:** +- `src/repl.ts:125` — `let planMode = false` in REPL scope +- `src/repl.ts:153` — dynamic prompt: `planMode ? PLAN_PROMPT : NORMAL_PROMPT` +- `src/repl.ts:193-197` — system prompt and `isToolDenied` derived from `planMode` +- `src/repl.ts:204-229` — approval flow runs after agent loop returns, only when `planMode` is true + +--- + +### Q3: Why is the plan approval flow conversational instead of structured? + +**Why it matters:** Plans could be stored in a JSON schema with explicit steps, statuses, and dependencies. Instead, we keep plans as freeform text in conversation history and use a simple `y/n/text` prompt. + +**What we learned:** The conversational approach leverages what already exists — the LLM naturally produces numbered plans, and conversation history preserves full context. When the user approves, their approval message is appended to history, giving the model everything it needs to execute. No extra data structures, no serialization, no risk of schema mismatch with what the model actually generated. The trade-off is less programmatic control over individual plan steps, but for a coding agent this is the right trade-off: the human is the orchestrator, not a script. + +**Demonstrated in:** +- `src/repl.ts:206-229` — three-way approval prompt: `y` (approve and exit plan mode), `n` (reject and stay), or freeform text (feedback that revises the plan) +- `src/repl.ts:211-213` — approval appends "Plan approved. Please proceed with implementation." to conversation history From 7a499c13dfd7706b45bc2c15de4dcd1ba53b346b Mon Sep 17 00:00:00 2001 From: rsimpson2 Date: Sat, 18 Apr 2026 11:25:12 -0400 Subject: [PATCH 5/7] docs: add Step 8 plan mode manual tests and update REPL commands table --- docs/MANUAL_TESTING.md | 71 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/docs/MANUAL_TESTING.md b/docs/MANUAL_TESTING.md index 7768cf9..a4ec82e 100644 --- a/docs/MANUAL_TESTING.md +++ b/docs/MANUAL_TESTING.md @@ -299,7 +299,14 @@ The following commands are available in the REPL: | Command | Description | |---------|-------------| -| `/status` | Display current context window usage (tokens, percentage, message count) | +| `/status` | Display current context window usage (tokens, percentage, message count) and mode | +| `/plan` | Activate plan mode (read-only architect, mutating tools disabled) | +| `/plan off` | Deactivate plan mode (return to normal execution) | +| `/model` | Show current model | +| `/model ` | Switch to a different model | +| `/remember ` | Store a durable memory | +| `/recall [query]` | List or search memories | +| `/forget ` | Remove a stored memory | | `exit` or `quit` | Exit the REPL | ### `/status` Command @@ -645,3 +652,65 @@ 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 - Subagents and Plan Mode + +### Test 8.1: Plan mode produces a plan without edits + +**Goal:** Verify that activating plan mode disables mutating tools and the agent produces a plan instead of making changes. + +**Steps:** + +1. Start the agent with `npm run dev` +2. Type: `/plan` +3. Verify the prompt changes from `> ` to `[plan] > ` +4. Verify a confirmation message appears ("Plan mode activated") +5. Type: `Add a hello() function to src/tools/readFileTool.ts that logs "hello"` +6. Wait for the response + +**Expected:** The agent reads the file (using `read_file`), analyzes it, and produces a plan describing the steps it would take. It should NOT call `write_file`, `edit_file`, or `bash`. If it tries, those tools should be denied with a plan-mode error message. + +**Pass criteria:** The agent produces a numbered plan. No file edits occur. Mutating tool calls are denied. The prompt stays as `[plan] > `. + +--- + +### Test 8.2: Plan approval leads to controlled implementation + +**Goal:** Verify that approving a plan exits plan mode and triggers implementation. + +**Steps:** + +1. Start the agent with `npm run dev` +2. Type: `/plan` +3. Ask the agent to do something specific (e.g., `What changes would you make to add a trailing newline to the end of the read_file tool output?`) +4. Wait for the plan response +5. When the approval prompt appears, type: `y` +6. Verify the prompt changes back to `> ` +7. Observe the agent implementing the approved plan + +**Expected:** After typing `y`, plan mode deactivates, the prompt returns to `> `, and the agent proceeds to implement the plan using mutating tools (which are now re-enabled). + +**Pass criteria:** Plan mode deactivates on approval. The prompt reverts to `> `. The agent implements the plan using tools like `edit_file` or `write_file`. The changes match what was planned. + +--- + +### Test 8.3: Plan rejection or modification revises the plan + +**Goal:** Verify that rejecting or modifying a plan keeps plan mode active and the agent revises without executing. + +**Steps:** + +1. Start the agent with `npm run dev` +2. Type: `/plan` +3. Ask: `Plan how to refactor the tool registry to support async tool definitions` +4. Wait for the plan response +5. When the approval prompt appears, type: `I don't want async definitions. Just add a way to list tool names.` +6. Wait for the revised response +7. Verify the prompt is still `[plan] > ` +8. Type: `n` at the next approval prompt + +**Expected:** After typing the modification text (step 5), the agent stays in plan mode and produces a revised plan that addresses the feedback. After typing `n` (step 8), the agent stays in plan mode and acknowledges the rejection. No code changes are made at any point. + +**Pass criteria:** Plan mode stays active throughout. The agent revises its plan based on feedback. After rejection, the agent acknowledges it and waits for further input. No mutating tools are called. The prompt remains `[plan] > `. From c881bdc25b79d649485a26692dd422535846c8d9 Mon Sep 17 00:00:00 2001 From: rsimpson2 Date: Sat, 18 Apr 2026 11:48:58 -0400 Subject: [PATCH 6/7] chore: sync plan mode specs and archive step-8 change - Sync repl-chat-loop delta: add plan mode toggle commands and scenarios - Sync tool-permissions delta: add isToolDenied callback requirement - Add plan-mode as new main spec - Archive step-8-plan-mode change to openspec/changes/archive/ --- .../.openspec.yaml | 2 + .../2026-04-18-step-8-plan-mode/design.md | 74 ++++++++++++ .../2026-04-18-step-8-plan-mode/proposal.md | 28 +++++ .../specs/plan-mode/spec.md | 107 +++++++++++++++++ .../specs/repl-chat-loop/spec.md | 60 ++++++++++ .../specs/tool-permissions/spec.md | 27 +++++ .../2026-04-18-step-8-plan-mode/tasks.md | 59 ++++++++++ openspec/specs/plan-mode/spec.md | 111 ++++++++++++++++++ openspec/specs/repl-chat-loop/spec.md | 24 +++- openspec/specs/tool-permissions/spec.md | 26 ++++ 10 files changed, 516 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/archive/2026-04-18-step-8-plan-mode/.openspec.yaml create mode 100644 openspec/changes/archive/2026-04-18-step-8-plan-mode/design.md create mode 100644 openspec/changes/archive/2026-04-18-step-8-plan-mode/proposal.md create mode 100644 openspec/changes/archive/2026-04-18-step-8-plan-mode/specs/plan-mode/spec.md create mode 100644 openspec/changes/archive/2026-04-18-step-8-plan-mode/specs/repl-chat-loop/spec.md create mode 100644 openspec/changes/archive/2026-04-18-step-8-plan-mode/specs/tool-permissions/spec.md create mode 100644 openspec/changes/archive/2026-04-18-step-8-plan-mode/tasks.md create mode 100644 openspec/specs/plan-mode/spec.md diff --git a/openspec/changes/archive/2026-04-18-step-8-plan-mode/.openspec.yaml b/openspec/changes/archive/2026-04-18-step-8-plan-mode/.openspec.yaml new file mode 100644 index 0000000..204fc5a --- /dev/null +++ b/openspec/changes/archive/2026-04-18-step-8-plan-mode/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-18 diff --git a/openspec/changes/archive/2026-04-18-step-8-plan-mode/design.md b/openspec/changes/archive/2026-04-18-step-8-plan-mode/design.md new file mode 100644 index 0000000..3ecd9d0 --- /dev/null +++ b/openspec/changes/archive/2026-04-18-step-8-plan-mode/design.md @@ -0,0 +1,74 @@ +## Context + +The agent currently has a single operational mode. Every user message goes through `runAgentLoop` (src/agent.ts:102-215), which streams a model response, executes any tool calls the model requests (checking permissions per-tool), and loops until the model returns a text-only response. The REPL (src/repl.ts:70-239) manages slash commands via `handleSlashCommand` (src/repl/commands.ts:37-94) and tracks mode state like the active model. + +Plan mode adds a second operational state to the REPL. When active, the agent acts as a read-only architect: it can investigate code (read-only tools) but cannot make changes (mutating tools are denied). The model produces a structured plan that the user reviews before execution begins. + +Key integration points: +- **Tool permission override**: Currently permissions are resolved once at registry creation (src/tools/index.ts:32-66) and checked per-call in the agent loop (src/agent.ts:164-189). Plan mode needs a runtime overlay. +- **System prompt**: Assembled by `assembleSystemPrompt` (src/config/context.ts:17-33) from base + project instructions + extra. Plan mode appends a planner appendix. +- **Slash commands**: Dispatched in the REPL loop (src/repl.ts:160-171) before messages go to the agent loop. The `/plan` command fits here. +- **Status display**: `/status` output formatted in `formatStatus` (src/repl/commands.ts:17-31). + +## Goals / Non-Goals + +**Goals:** +- Toggle plan mode via `/plan` (on) and `/plan off` (off) slash commands +- While active, deny all mutating tools (`write_file`, `edit_file`, `bash`) at the agent loop level +- Append a planner-oriented system prompt section that instructs the model to analyze and plan +- Provide an explicit user approval flow: after the model produces a plan, the user approves → exits plan mode → executes; rejects → stays in plan mode +- Show plan-mode state in `/status` output +- Add manual tests for: plan without edits, approved plan execution, rejected/revised plan + +**Non-Goals:** +- Persisting plan mode across sessions (plan mode resets on restart) +- Partial plan execution (approve all or nothing) +- Nested plan modes (subagents don't inherit plan mode) +- Structured plan format (the plan is freeform model output; no JSON schema) + +## Decisions + +### Decision 1: Plan mode state lives in the REPL, not the agent loop + +**Choice**: A `planMode: boolean` variable in `startRepl` (src/repl.ts:70), passed down via the existing options pattern. + +**Rationale**: Plan mode is a REPL-level concern — it's toggled by slash commands, displayed in status, and controls how the REPL orchestrates the agent. The agent loop already accepts options; adding a `planMode` flag to `AgentLoopOptions` keeps the agent loop generic and testable. The agent loop doesn't need to know *why* tools are denied — it just needs to know *which* tools are denied. + +**Alternative considered**: Storing plan mode state in the tool registry. Rejected because the registry is a static structure created once at startup; mutating it for mode switches would complicate the permission model and risk leaking state. + +### Decision 2: Mutating-tool denial via an `isToolDenied` callback on agent loop options + +**Choice**: Add an optional `isToolDenied?: (toolName: string) => boolean` callback to `AgentLoopOptions`. When plan mode is active, the REPL passes a callback that returns `true` for `write_file`, `edit_file`, and `bash`. The agent loop checks this *before* the existing permission check — if denied, it returns a structured denial result without consulting permissions. + +**Rationale**: This avoids mutating the tool registry and keeps the agent loop's permission flow clean. The callback pattern mirrors the existing `promptForApproval` callback. When plan mode is off, the callback is either absent or always returns `false`, so the existing permission logic runs unchanged. + +**Alternative considered**: Creating a second tool registry with all mutating tools set to `"deny"`. Rejected because it duplicates the registry and would require keeping two registries in sync (e.g., when the subagent tool is registered dynamically). + +### Decision 3: Planner system prompt as an appendix injected at call time + +**Choice**: When `planMode` is true, the REPL constructs the system prompt with an additional section appended after the normal prompt. The agent loop already receives the full system prompt as a string — no changes to the API layer needed. + +**Rationale**: `assembleSystemPrompt` already composes from parts. The REPL can call it with a `planModeExtra` string when active. This avoids modifying the config system or prompt assembly function. + +**Alternative considered**: Modifying `assembleSystemPrompt` to accept a `planMode` flag. Rejected — adding mode-specific logic to a general utility function violates separation of concerns. The REPL should own mode-dependent prompt construction. + +### Decision 4: Approval flow as a REPL-level interaction after the agent loop returns + +**Choice**: When plan mode is active and the agent loop completes (model returns text), the REPL checks if the output looks like a plan. It then prompts: "Approve this plan? (y/n/edit)". On `y`, plan mode is turned off and the approved plan text is re-injected as a user message for execution. On `n`, the user can provide feedback and the agent stays in plan mode. On `edit`, the user types modifications which are appended as a user message while staying in plan mode. + +**Rationale**: The approval flow is conversational — it uses the same REPL input mechanism already in place. The model's plan text stays in conversation history, so when the user approves, the agent has full context for execution. No special "plan storage" is needed. + +**Alternative considered**: A separate plan data structure with explicit steps. Rejected — too much ceremony for a feature where the model produces freeform plans. The conversation history already captures everything. + +### Decision 5: Plan-mode prompt indicator + +**Choice**: Change the REPL prompt from `> ` to `[plan] > ` when plan mode is active. + +**Rationale**: Visual feedback is important — the user should immediately see which mode they're in. This is a simple string change in the REPL loop, using the existing `PROMPT` constant pattern. + +## Risks / Trade-offs + +- **Risk: Model produces changes despite planner prompt** → Mitigation: The tool-level denial is a hard constraint. Even if the model tries to call a mutating tool, the agent loop will deny it. The planner prompt is guidance; the denial callback is enforcement. +- **Risk: Approval flow feels clunky for simple plans** → Mitigation: The `/plan off` command lets users exit plan mode manually at any time, bypassing the approval flow. The approval prompt is shown after every agent response in plan mode, so users can approve whenever they're ready. +- **Risk: Plan text lost when transitioning to execution mode** → Mitigation: The plan stays in conversation history. When the user approves, their approval message is appended to history, giving the model full context. +- **Trade-off: No structured plan format** → Acceptable for now. Freeform plans are simpler and the model naturally produces numbered steps. A structured format (JSON plan schema) could be added later if needed. diff --git a/openspec/changes/archive/2026-04-18-step-8-plan-mode/proposal.md b/openspec/changes/archive/2026-04-18-step-8-plan-mode/proposal.md new file mode 100644 index 0000000..a0d2acb --- /dev/null +++ b/openspec/changes/archive/2026-04-18-step-8-plan-mode/proposal.md @@ -0,0 +1,28 @@ +## Why + +The agent currently operates in a single mode — it can immediately execute any tool the model requests (subject to permissions). For larger tasks, users need a way to review the agent's approach before any code is modified. Plan mode introduces a read-only "architect" phase where the agent investigates the codebase, asks questions, and produces a step-by-step plan. Only after explicit user approval does the agent switch to execution mode and carry out the plan. This matches the ROADMAP Step 8 requirement and provides a safer workflow for high-stakes changes. + +## What Changes + +- Add a `plan` / `plan off` slash command that toggles plan mode on and off. +- While plan mode is active, the agent loop SHALL deny all mutating tools (`write_file`, `edit_file`, `bash`) regardless of their configured permission — effectively treating them as `"deny"`. Read-only tools (`read_file`, `glob`, `grep`) and the `subagent` tool remain available. +- While plan mode is active, the system prompt SHALL include a planner-oriented appendix instructing the model to analyze, ask clarifying questions, and produce ordered actionable steps rather than making changes. +- When the model produces a plan, the user SHALL review it and approve, modify, or reject it. Approval exits plan mode into execution mode; the agent then executes the approved steps sequentially. +- Rejection or modification keeps the agent in plan mode, allowing the model to revise its plan without making changes. +- The REPL status display (`/status`) SHALL indicate whether plan mode is active. + +## Capabilities + +### New Capabilities +- `plan-mode`: A REPL toggle that forces all mutating tools into deny mode and appends a planner-oriented system prompt, with an approval flow to transition into execution mode. + +### Modified Capabilities +- `repl-chat-loop`: Add `/plan` and `/plan off` slash commands, and show plan-mode status in the prompt indicator and `/status` output. +- `tool-permissions`: Plan mode overrides configured permissions for mutating tools at runtime, treating them as deny without modifying the underlying tool registrations. + +## Impact + +- **Source files**: `src/repl.ts` (slash command dispatch, mode state), `src/repl/commands.ts` (new `/plan` command), `src/agent.ts` (permission override from plan mode), `src/config/context.ts` (plan-mode system prompt appendix). +- **Existing specs**: `repl-chat-loop` and `tool-permissions` specs will need delta updates for the new behavior. +- **No new dependencies**: All functionality builds on existing agent loop, tool registry, and REPL infrastructure. +- **No breaking changes**: Plan mode is opt-in via slash command; default behavior is unchanged. diff --git a/openspec/changes/archive/2026-04-18-step-8-plan-mode/specs/plan-mode/spec.md b/openspec/changes/archive/2026-04-18-step-8-plan-mode/specs/plan-mode/spec.md new file mode 100644 index 0000000..cab2254 --- /dev/null +++ b/openspec/changes/archive/2026-04-18-step-8-plan-mode/specs/plan-mode/spec.md @@ -0,0 +1,107 @@ +## ADDED Requirements + +### Requirement: Plan mode toggle command +The REPL SHALL support a `/plan` slash command that toggles plan mode on and off. `/plan` with no arguments activates plan mode. `/plan off` deactivates plan mode. The REPL SHALL track plan mode as a boolean state variable. + +#### Scenario: Activating plan mode +- **WHEN** the user types `/plan` and presses Enter +- **THEN** plan mode SHALL be activated +- **AND** the REPL SHALL display a confirmation message indicating plan mode is active +- **AND** the REPL prompt SHALL change to `[plan] > ` + +#### Scenario: Deactivating plan mode +- **WHEN** the user types `/plan off` and presses Enter +- **THEN** plan mode SHALL be deactivated +- **AND** the REPL SHALL display a confirmation message indicating plan mode is off +- **AND** the REPL prompt SHALL revert to `> ` + +#### Scenario: Plan mode not active by default +- **WHEN** the REPL starts a new session +- **THEN** plan mode SHALL be off +- **AND** the REPL prompt SHALL be `> ` + +### Requirement: Plan mode denies mutating tools +When plan mode is active, the agent loop SHALL deny all mutating tool calls (`write_file`, `edit_file`, `bash`) regardless of their configured permission. The denial SHALL use the same structured error format as the existing deny-mode logic. Read-only tools (`read_file`, `glob`, `grep`) and the `subagent` tool SHALL remain functional. + +#### Scenario: Write file denied in plan mode +- **WHEN** plan mode is active +- **AND** the model requests `write_file` +- **THEN** the tool call SHALL be denied with `isError: true` +- **AND** the content SHALL indicate the tool was denied because plan mode is active +- **AND** the tool SHALL NOT execute + +#### Scenario: Edit file denied in plan mode +- **WHEN** plan mode is active +- **AND** the model requests `edit_file` +- **THEN** the tool call SHALL be denied with `isError: true` +- **AND** the content SHALL indicate the tool was denied because plan mode is active +- **AND** the tool SHALL NOT execute + +#### Scenario: Bash denied in plan mode +- **WHEN** plan mode is active +- **AND** the model requests `bash` +- **THEN** the tool call SHALL be denied with `isError: true` +- **AND** the content SHALL indicate the tool was denied because plan mode is active +- **AND** the tool SHALL NOT execute + +#### Scenario: Read-only tools work in plan mode +- **WHEN** plan mode is active +- **AND** the model requests `read_file` +- **THEN** the tool SHALL execute normally and return its result to the model + +#### Scenario: Subagent tool works in plan mode +- **WHEN** plan mode is active +- **AND** the model requests `subagent` +- **THEN** the tool SHALL execute normally and return its result to the model + +### Requirement: Planner-oriented system prompt +When plan mode is active, the system prompt SHALL include an additional section appended after the normal system prompt. This section SHALL instruct the model to act as an architect: analyze the codebase, ask clarifying questions, and produce ordered actionable steps. It SHALL explicitly instruct the model NOT to make any code changes. + +#### Scenario: System prompt includes planner section +- **WHEN** plan mode is active +- **AND** a message is sent to the Anthropic API +- **THEN** the `system` field SHALL contain the normal system prompt followed by a planner-mode appendix + +#### Scenario: System prompt without planner section +- **WHEN** plan mode is not active +- **AND** a message is sent to the Anthropic API +- **THEN** the `system` field SHALL NOT contain the planner-mode appendix + +### Requirement: Plan approval flow +When plan mode is active and the agent loop completes a response (model returns text without requesting tools), the REPL SHALL prompt the user to approve the plan. The prompt SHALL offer three options: approve (`y`), reject (`n`), or provide modifications (any other text). + +#### Scenario: User approves plan +- **WHEN** plan mode is active +- **AND** the agent loop returns a text response +- **AND** the user responds to the approval prompt with `y` +- **THEN** plan mode SHALL be deactivated +- **AND** the REPL prompt SHALL revert to `> ` +- **AND** the user's approval SHALL be appended to conversation history as a user message instructing the agent to execute the plan + +#### Scenario: User rejects plan +- **WHEN** plan mode is active +- **AND** the agent loop returns a text response +- **AND** the user responds to the approval prompt with `n` +- **THEN** plan mode SHALL remain active +- **AND** a rejection message SHALL be appended to conversation history as a user message + +#### Scenario: User provides plan modifications +- **WHEN** plan mode is active +- **AND** the agent loop returns a text response +- **AND** the user types feedback text (not `y` or `n`) at the approval prompt +- **THEN** plan mode SHALL remain active +- **AND** the user's feedback SHALL be appended to conversation history as a user message +- **AND** the agent SHALL revise its plan based on the feedback + +### Requirement: Plan mode status in slash command output +The `/status` command SHALL indicate whether plan mode is active when displaying session information. + +#### Scenario: Status shows plan mode active +- **WHEN** plan mode is active +- **AND** the user types `/status` +- **THEN** the output SHALL include an indication that plan mode is on + +#### Scenario: Status shows plan mode inactive +- **WHEN** plan mode is not active +- **AND** the user types `/status` +- **THEN** the output SHALL NOT include a plan mode indication diff --git a/openspec/changes/archive/2026-04-18-step-8-plan-mode/specs/repl-chat-loop/spec.md b/openspec/changes/archive/2026-04-18-step-8-plan-mode/specs/repl-chat-loop/spec.md new file mode 100644 index 0000000..edf9e53 --- /dev/null +++ b/openspec/changes/archive/2026-04-18-step-8-plan-mode/specs/repl-chat-loop/spec.md @@ -0,0 +1,60 @@ +## MODIFIED Requirements + +### Requirement: REPL input loop +The REPL SHALL continuously prompt the user for input using `node:readline/promises` and process each line as either a chat message or a slash command until the user exits. Supported slash commands SHALL include status queries, memory-management commands, and plan mode toggle commands. When plan mode is active, the prompt SHALL display `[plan] > ` instead of `> `. + +#### Scenario: User enters a message +- **WHEN** the user types a message and presses Enter +- **THEN** the REPL SHALL send the message to the Anthropic API and display the streamed response + +#### Scenario: Empty input is ignored +- **WHEN** the user presses Enter without typing anything +- **THEN** the REPL SHALL re-display the prompt without making an API call + +#### Scenario: REPL re-prompts after response completes +- **WHEN** the assistant's streamed response finishes +- **THEN** the REPL SHALL print a newline and display the input prompt again + +#### Scenario: Status command displays context usage +- **WHEN** the user types `/status` and presses Enter +- **THEN** the REPL SHALL display current token usage, percentage, message count, and plan mode status +- **AND** the REPL SHALL re-display the prompt without making an API call + +#### Scenario: Status command shows warning near limit +- **WHEN** the user types `/status` and token usage is above 75% of context window +- **THEN** the status output SHALL include a warning that compression will trigger soon + +#### Scenario: Remember command stores a durable fact +- **WHEN** the user types `/remember Always run npm test before commit` +- **THEN** the REPL SHALL invoke the internal remember operation +- **AND** SHALL re-display the prompt without sending that command to the model + +#### Scenario: Recall command lists or searches memories +- **WHEN** the user types `/recall` or `/recall test` +- **THEN** the REPL SHALL invoke the internal recall operation +- **AND** SHALL re-display the prompt without sending that command to the model + +#### Scenario: Forget command removes a memory +- **WHEN** the user types `/forget mem_123` +- **THEN** the REPL SHALL invoke the internal forget operation +- **AND** SHALL re-display the prompt without sending that command to the model + +#### Scenario: Plan command activates plan mode +- **WHEN** the user types `/plan` and presses Enter +- **THEN** the REPL SHALL activate plan mode +- **AND** the prompt SHALL change to `[plan] > ` + +#### Scenario: Plan off command deactivates plan mode +- **WHEN** the user types `/plan off` and presses Enter +- **THEN** the REPL SHALL deactivate plan mode +- **AND** the prompt SHALL revert to `> ` + +#### Scenario: Plan mode prompt indicator +- **WHEN** plan mode is active +- **THEN** the REPL SHALL display `[plan] > ` as the input prompt + +#### Scenario: Plan mode approval prompt after response +- **WHEN** plan mode is active +- **AND** the agent loop completes with a text response +- **THEN** the REPL SHALL prompt the user with an approval question for the plan +- **AND** wait for the user to approve, reject, or modify diff --git a/openspec/changes/archive/2026-04-18-step-8-plan-mode/specs/tool-permissions/spec.md b/openspec/changes/archive/2026-04-18-step-8-plan-mode/specs/tool-permissions/spec.md new file mode 100644 index 0000000..2974acd --- /dev/null +++ b/openspec/changes/archive/2026-04-18-step-8-plan-mode/specs/tool-permissions/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Plan mode tool denial override +The agent loop SHALL accept an optional `isToolDenied` callback in its options. When provided, this callback receives a tool name and returns `true` if the tool SHALL be denied regardless of its configured permission. The callback check SHALL occur before the existing permission check — if `isToolDenied` returns `true`, the tool is denied immediately without consulting the permission system. + +#### Scenario: isToolDenied callback denies a mutating tool +- **WHEN** the `isToolDenied` callback is provided +- **AND** the callback returns `true` for `write_file` +- **AND** the model requests `write_file` with permission `"prompt"` +- **THEN** the tool SHALL be denied immediately +- **AND** a structured denial result with `isError: true` SHALL be returned to the model +- **AND** the permission-based approval flow SHALL NOT be triggered + +#### Scenario: isToolDenied callback allows a read-only tool +- **WHEN** the `isToolDenied` callback is provided +- **AND** the callback returns `false` for `read_file` +- **AND** the model requests `read_file` with permission `"allow"` +- **THEN** the tool SHALL execute normally via the existing permission flow + +#### Scenario: isToolDenied callback not provided +- **WHEN** the `isToolDenied` callback is not provided in options +- **THEN** the agent loop SHALL use the existing permission-based flow for all tools without any additional denial checks + +#### Scenario: Denied tool result includes plan mode context +- **WHEN** the `isToolDenied` callback denies a tool +- **THEN** the denial result SHALL include `isError: true` +- **AND** the content SHALL indicate the tool was denied and mention plan mode diff --git a/openspec/changes/archive/2026-04-18-step-8-plan-mode/tasks.md b/openspec/changes/archive/2026-04-18-step-8-plan-mode/tasks.md new file mode 100644 index 0000000..511f516 --- /dev/null +++ b/openspec/changes/archive/2026-04-18-step-8-plan-mode/tasks.md @@ -0,0 +1,59 @@ +## 1. Agent Loop: Tool Denial Callback + +- [x] 1.1 Add `isToolDenied?: (toolName: string) => boolean` to `AgentLoopOptions` type in `src/agent.ts` +- [x] 1.2 Implement the denial check in `runAgentLoop` — before the existing permission check, if `isToolDenied` returns `true`, return a structured denial result with `isError: true` and a plan-mode denial message +- [x] 1.3 Add unit tests in `src/__tests__/agent-plan-mode.test.ts` for the `isToolDenied` callback: denied tool returns error, allowed tool passes through, callback absent means no extra denial + +## 2. Plan Mode Slash Command + +- [x] 2.1 Add `/plan` and `/plan off` handling to `handleSlashCommand` in `src/repl/commands.ts` — accept `getPlanMode` and `setPlanMode` callbacks in `HandleSlashCommandOptions` +- [x] 2.2 Add `planMode: boolean` state variable in `startRepl` (`src/repl.ts`), wire `getPlanMode`/`setPlanMode` into the slash command options +- [x] 2.3 Display confirmation message on toggle (e.g., "Plan mode activated" / "Plan mode deactivated") +- [x] 2.4 Add unit tests for `/plan` and `/plan off` slash commands in `src/__tests__/commands-plan.test.ts` + +## 3. Plan Mode Prompt Indicator + +- [x] 3.1 In `startRepl`, change the prompt string dynamically: use `[plan] > ` when `planMode` is true, `> ` when false +- [x] 3.2 Update the `rl.question()` call to use the dynamic prompt instead of the static `PROMPT` constant + +## 4. Planner System Prompt + +- [x] 4.1 Define a `PLAN_MODE_PROMPT` constant in `src/repl.ts` containing the planner-oriented system prompt appendix (instruct model to analyze, ask questions, produce ordered steps, and NOT make changes) +- [x] 4.2 In the REPL loop, when calling `runAgentLoop`, conditionally append `PLAN_MODE_PROMPT` to the `system` option when `planMode` is true + +## 5. Tool Denial Wiring in REPL + +- [x] 5.1 In `startRepl`, create an `isToolDenied` callback that returns `true` for `write_file`, `edit_file`, and `bash` when `planMode` is true +- [x] 5.2 Pass the `isToolDenied` callback to `runAgentLoop` options in the REPL loop + +## 6. Plan Approval Flow + +- [x] 6.1 In the REPL loop, after `runAgentLoop` completes, if `planMode` is true, prompt the user with an approval question (e.g., "Approve this plan? (y to approve / n to reject / or type modifications)") +- [x] 6.2 On `y`: deactivate plan mode, append a user message to conversation history instructing the agent to execute the plan +- [x] 6.3 On `n`: keep plan mode active, append a rejection message to conversation history +- [x] 6.4 On other text: keep plan mode active, append the user's feedback as a user message (the agent revises the plan) + +## 7. Status Command Update + +- [x] 7.1 Update `HandleSlashCommandOptions` to include `getPlanMode: () => boolean` +- [x] 7.2 Update `formatStatus` to include a "Mode: plan" line when plan mode is active +- [x] 7.3 Wire `getPlanMode` from REPL state into slash command options + +## 8. Unit Tests + +- [x] 8.1 Add test: `isToolDenied` callback denies mutating tools in `src/__tests__/agent-plan-mode.test.ts` +- [x] 8.2 Add test: `isToolDenied` absent or returning false falls through to normal permissions +- [x] 8.3 Add test: `/plan` and `/plan off` slash command parsing in `src/__tests__/commands-plan.test.ts` +- [x] 8.4 Verify `typecheck` passes after all changes + +## 9. Manual Tests + +- [x] 9.1 Manual test: activate plan mode, ask agent to implement a feature, verify it produces a plan without making any file edits +- [x] 9.2 Manual test: approve a plan, verify the agent switches to execution mode and implements the approved steps +- [x] 9.3 Manual test: reject or modify a plan, verify the agent revises without executing + +## 10. ROADMAP and Documentation + +- [x] 10.1 Update ROADMAP.md: mark all 7 remaining Step 8 items as complete (`- [x]`) +- [x] 10.2 Add Step 8 plan-mode entry to `docs/SOCRATIC_JOURNAL.md` with file paths and architectural decisions +- [x] 10.3 Update `docs/FOR-Rob-Simpson.md` with plan-mode architecture, usage, and lessons learned diff --git a/openspec/specs/plan-mode/spec.md b/openspec/specs/plan-mode/spec.md new file mode 100644 index 0000000..0fa4515 --- /dev/null +++ b/openspec/specs/plan-mode/spec.md @@ -0,0 +1,111 @@ +## Purpose + +Plan mode provides a read-only architect phase where the agent investigates the codebase and produces a step-by-step plan. Mutating tools are denied, and the user must explicitly approve the plan before any changes are made. + +## Requirements + +### Requirement: Plan mode toggle command +The REPL SHALL support a `/plan` slash command that toggles plan mode on and off. `/plan` with no arguments activates plan mode. `/plan off` deactivates plan mode. The REPL SHALL track plan mode as a boolean state variable. + +#### Scenario: Activating plan mode +- **WHEN** the user types `/plan` and presses Enter +- **THEN** plan mode SHALL be activated +- **AND** the REPL SHALL display a confirmation message indicating plan mode is active +- **AND** the REPL prompt SHALL change to `[plan] > ` + +#### Scenario: Deactivating plan mode +- **WHEN** the user types `/plan off` and presses Enter +- **THEN** plan mode SHALL be deactivated +- **AND** the REPL SHALL display a confirmation message indicating plan mode is off +- **AND** the REPL prompt SHALL revert to `> ` + +#### Scenario: Plan mode not active by default +- **WHEN** the REPL starts a new session +- **THEN** plan mode SHALL be off +- **AND** the REPL prompt SHALL be `> ` + +### Requirement: Plan mode denies mutating tools +When plan mode is active, the agent loop SHALL deny all mutating tool calls (`write_file`, `edit_file`, `bash`) regardless of their configured permission. The denial SHALL use the same structured error format as the existing deny-mode logic. Read-only tools (`read_file`, `glob`, `grep`) and the `subagent` tool SHALL remain functional. + +#### Scenario: Write file denied in plan mode +- **WHEN** plan mode is active +- **AND** the model requests `write_file` +- **THEN** the tool call SHALL be denied with `isError: true` +- **AND** the content SHALL indicate the tool was denied because plan mode is active +- **AND** the tool SHALL NOT execute + +#### Scenario: Edit file denied in plan mode +- **WHEN** plan mode is active +- **AND** the model requests `edit_file` +- **THEN** the tool call SHALL be denied with `isError: true` +- **AND** the content SHALL indicate the tool was denied because plan mode is active +- **AND** the tool SHALL NOT execute + +#### Scenario: Bash denied in plan mode +- **WHEN** plan mode is active +- **AND** the model requests `bash` +- **THEN** the tool call SHALL be denied with `isError: true` +- **AND** the content SHALL indicate the tool was denied because plan mode is active +- **AND** the tool SHALL NOT execute + +#### Scenario: Read-only tools work in plan mode +- **WHEN** plan mode is active +- **AND** the model requests `read_file` +- **THEN** the tool SHALL execute normally and return its result to the model + +#### Scenario: Subagent tool works in plan mode +- **WHEN** plan mode is active +- **AND** the model requests `subagent` +- **THEN** the tool SHALL execute normally and return its result to the model + +### Requirement: Planner-oriented system prompt +When plan mode is active, the system prompt SHALL include an additional section appended after the normal system prompt. This section SHALL instruct the model to act as an architect: analyze the codebase, ask clarifying questions, and produce ordered actionable steps. It SHALL explicitly instruct the model NOT to make any code changes. + +#### Scenario: System prompt includes planner section +- **WHEN** plan mode is active +- **AND** a message is sent to the Anthropic API +- **THEN** the `system` field SHALL contain the normal system prompt followed by a planner-mode appendix + +#### Scenario: System prompt without planner section +- **WHEN** plan mode is not active +- **AND** a message is sent to the Anthropic API +- **THEN** the `system` field SHALL NOT contain the planner-mode appendix + +### Requirement: Plan approval flow +When plan mode is active and the agent loop completes a response (model returns text without requesting tools), the REPL SHALL prompt the user to approve the plan. The prompt SHALL offer three options: approve (`y`), reject (`n`), or provide modifications (any other text). + +#### Scenario: User approves plan +- **WHEN** plan mode is active +- **AND** the agent loop returns a text response +- **AND** the user responds to the approval prompt with `y` +- **THEN** plan mode SHALL be deactivated +- **AND** the REPL prompt SHALL revert to `> ` +- **AND** the user's approval SHALL be appended to conversation history as a user message instructing the agent to execute the plan + +#### Scenario: User rejects plan +- **WHEN** plan mode is active +- **AND** the agent loop returns a text response +- **AND** the user responds to the approval prompt with `n` +- **THEN** plan mode SHALL remain active +- **AND** a rejection message SHALL be appended to conversation history as a user message + +#### Scenario: User provides plan modifications +- **WHEN** plan mode is active +- **AND** the agent loop returns a text response +- **AND** the user types feedback text (not `y` or `n`) at the approval prompt +- **THEN** plan mode SHALL remain active +- **AND** the user's feedback SHALL be appended to conversation history as a user message +- **AND** the agent SHALL revise its plan based on the feedback + +### Requirement: Plan mode status in slash command output +The `/status` command SHALL indicate whether plan mode is active when displaying session information. + +#### Scenario: Status shows plan mode active +- **WHEN** plan mode is active +- **AND** the user types `/status` +- **THEN** the output SHALL include an indication that plan mode is on + +#### Scenario: Status shows plan mode inactive +- **WHEN** plan mode is not active +- **AND** the user types `/status` +- **THEN** the output SHALL NOT include a plan mode indication diff --git a/openspec/specs/repl-chat-loop/spec.md b/openspec/specs/repl-chat-loop/spec.md index 5b0a6a0..90dcc7a 100644 --- a/openspec/specs/repl-chat-loop/spec.md +++ b/openspec/specs/repl-chat-loop/spec.md @@ -5,7 +5,7 @@ Define REPL chat-loop behavior for input handling, streaming output, conversatio ## Requirements ### Requirement: REPL input loop -The REPL SHALL continuously prompt the user for input using `node:readline/promises` and process each line as either a chat message or a slash command until the user exits. Supported slash commands SHALL include status queries and memory-management commands. +The REPL SHALL continuously prompt the user for input using `node:readline/promises` and process each line as either a chat message or a slash command until the user exits. Supported slash commands SHALL include status queries, memory-management commands, and plan mode toggle commands. When plan mode is active, the prompt SHALL display `[plan] > ` instead of `> `. #### Scenario: User enters a message - **WHEN** the user types a message and presses Enter @@ -21,7 +21,7 @@ The REPL SHALL continuously prompt the user for input using `node:readline/promi #### Scenario: Status command displays context usage - **WHEN** the user types `/status` and presses Enter -- **THEN** the REPL SHALL display current token usage, percentage, and message count +- **THEN** the REPL SHALL display current token usage, percentage, message count, and plan mode status - **AND** the REPL SHALL re-display the prompt without making an API call #### Scenario: Status command shows warning near limit @@ -43,6 +43,26 @@ The REPL SHALL continuously prompt the user for input using `node:readline/promi - **THEN** the REPL SHALL invoke the internal forget operation - **AND** SHALL re-display the prompt without sending that command to the model +#### Scenario: Plan command activates plan mode +- **WHEN** the user types `/plan` and presses Enter +- **THEN** the REPL SHALL activate plan mode +- **AND** the prompt SHALL change to `[plan] > ` + +#### Scenario: Plan off command deactivates plan mode +- **WHEN** the user types `/plan off` and presses Enter +- **THEN** the REPL SHALL deactivate plan mode +- **AND** the prompt SHALL revert to `> ` + +#### Scenario: Plan mode prompt indicator +- **WHEN** plan mode is active +- **THEN** the REPL SHALL display `[plan] > ` as the input prompt + +#### Scenario: Plan mode approval prompt after response +- **WHEN** plan mode is active +- **AND** the agent loop completes with a text response +- **THEN** the REPL SHALL prompt the user with an approval question for the plan +- **AND** wait for the user to approve, reject, or modify + ### Requirement: Token tracking integration The REPL SHALL integrate with the token tracking module to accumulate token usage from each API response. diff --git a/openspec/specs/tool-permissions/spec.md b/openspec/specs/tool-permissions/spec.md index 5406dcd..cde9ddd 100644 --- a/openspec/specs/tool-permissions/spec.md +++ b/openspec/specs/tool-permissions/spec.md @@ -101,3 +101,29 @@ The approval prompt SHALL display the tool name and a formatted summary of the t - **WHEN** the model requests `write_file` with `{ "filePath": "src/index.ts", "content": "..." }` - **THEN** the prompt displays the tool name `"write_file"` and the file path - **AND** asks the user to approve or deny + +### Requirement: Plan mode tool denial override +The agent loop SHALL accept an optional `isToolDenied` callback in its options. When provided, this callback receives a tool name and returns `true` if the tool SHALL be denied regardless of its configured permission. The callback check SHALL occur before the existing permission check — if `isToolDenied` returns `true`, the tool is denied immediately without consulting the permission system. + +#### Scenario: isToolDenied callback denies a mutating tool +- **WHEN** the `isToolDenied` callback is provided +- **AND** the callback returns `true` for `write_file` +- **AND** the model requests `write_file` with permission `"prompt"` +- **THEN** the tool SHALL be denied immediately +- **AND** a structured denial result with `isError: true` SHALL be returned to the model +- **AND** the permission-based approval flow SHALL NOT be triggered + +#### Scenario: isToolDenied callback allows a read-only tool +- **WHEN** the `isToolDenied` callback is provided +- **AND** the callback returns `false` for `read_file` +- **AND** the model requests `read_file` with permission `"allow"` +- **THEN** the tool SHALL execute normally via the existing permission flow + +#### Scenario: isToolDenied callback not provided +- **WHEN** the `isToolDenied` callback is not provided in options +- **THEN** the agent loop SHALL use the existing permission-based flow for all tools without any additional denial checks + +#### Scenario: Denied tool result includes plan mode context +- **WHEN** the `isToolDenied` callback denies a tool +- **THEN** the denial result SHALL include `isError: true` +- **AND** the content SHALL indicate the tool was denied and mention plan mode From 52753ac12d604c42fc9eaad9f34bdb142c3315a9 Mon Sep 17 00:00:00 2001 From: rsimpson2 Date: Sat, 18 Apr 2026 11:59:24 -0400 Subject: [PATCH 7/7] fix: re-run agent loop immediately after plan approval/rejection/feedback Approval now triggers execution right away instead of waiting for the next user input. Rejection and feedback re-run in plan mode to produce a revised plan immediately. --- src/repl.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/repl.ts b/src/repl.ts index 39416f0..810e0a9 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -226,6 +226,18 @@ export async function startRepl(apiKey: string, config: ResolvedConfig = {}): Pr }); tokenTracker.addMessage(); shouldPersistSession = true; + + await runAgentLoop({ + messages, + toolRegistry, + model, + apiKey, + system: systemPrompt, + write: (text) => process.stdout.write(text), + promptForApproval, + tokenTracker, + }); + process.stdout.write("\n"); } else if (approvalTrimmed === "n") { messages.push({ role: "user", @@ -233,6 +245,19 @@ export async function startRepl(apiKey: string, config: ResolvedConfig = {}): Pr }); tokenTracker.addMessage(); shouldPersistSession = true; + + await runAgentLoop({ + messages, + toolRegistry, + model, + apiKey, + system: systemPrompt + "\n\n" + PLAN_MODE_PROMPT, + write: (text) => process.stdout.write(text), + promptForApproval, + tokenTracker, + isToolDenied: (toolName: string) => MUTATING_TOOLS.has(toolName), + }); + process.stdout.write("\n"); } else { messages.push({ role: "user", @@ -240,6 +265,19 @@ export async function startRepl(apiKey: string, config: ResolvedConfig = {}): Pr }); tokenTracker.addMessage(); shouldPersistSession = true; + + await runAgentLoop({ + messages, + toolRegistry, + model, + apiKey, + system: systemPrompt + "\n\n" + PLAN_MODE_PROMPT, + write: (text) => process.stdout.write(text), + promptForApproval, + tokenTracker, + isToolDenied: (toolName: string) => MUTATING_TOOLS.has(toolName), + }); + process.stdout.write("\n"); } } } catch (error: unknown) {