diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index dea742d..88ee6ee 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -358,6 +358,11 @@ export async function startRepl(opts: ReplOpts): Promise { ctx.mode = 'default'; output.write('\n ▶ Exited plan mode (agent will now execute).\n'); } + // Honor EnterPlanMode tool signal — flip into plan mode (writes blocked). + if (result.modeSignal?.enterPlanMode && ctx.mode !== 'plan') { + ctx.mode = 'plan'; + output.write('\n ◐ Entered plan mode (write tools blocked until you exit).\n'); + } output.write('\n'); if (result.stopReason === 'error') { output.write(' ✕ Error during agent loop. Try again or /status to inspect.\n\n'); diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 7ba9bc5..b999040 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -96,7 +96,7 @@ export interface RunAgentResult { /** Reason the loop terminated. */ stopReason: 'end_turn' | 'max_turns' | 'aborted' | 'error'; /** Mode-control signals flipped by tools during this run (M3c-rest). */ - modeSignal?: { exitPlanMode?: boolean }; + modeSignal?: { exitPlanMode?: boolean; enterPlanMode?: boolean }; } const DEFAULT_MAX_TURNS = 16; @@ -155,9 +155,9 @@ export async function runAgent(opts: RunAgentOptions): Promise { if (opts.session) await opts.session.manager.append(opts.session.id, userMsg); } - // modeSignal is mutable — ExitPlanMode flips exitPlanMode = true; the agent - // loop owner reads this between turns to switch mode plan → default. - const modeSignal: { exitPlanMode?: boolean } = {}; + // modeSignal is mutable — EnterPlanMode / ExitPlanMode flip these; the agent + // loop owner reads them after the run to switch mode (default ⇄ plan). + const modeSignal: { exitPlanMode?: boolean; enterPlanMode?: boolean } = {}; const toolCtx: ToolContext = { cwd: opts.cwd, signal: opts.signal, diff --git a/packages/core/src/tools/enter-plan.test.ts b/packages/core/src/tools/enter-plan.test.ts new file mode 100644 index 0000000..e85d90a --- /dev/null +++ b/packages/core/src/tools/enter-plan.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { EnterPlanModeTool } from './enter-plan.js'; + +describe('EnterPlanModeTool', () => { + it('flips modeSignal.enterPlanMode and echoes the reason', async () => { + const signal: { enterPlanMode?: boolean } = {}; + const r = await EnterPlanModeTool.execute( + { reason: 'the refactor touches many files' }, + { cwd: '/x', modeSignal: signal }, + ); + expect(r.isError).toBeFalsy(); + expect(signal.enterPlanMode).toBe(true); + expect(r.content).toContain('the refactor touches many files'); + expect((r.data as { enterPlanMode: boolean }).enterPlanMode).toBe(true); + }); + + it('still succeeds when no modeSignal is passed (best-effort)', async () => { + const r = await EnterPlanModeTool.execute({}, { cwd: '/x' }); + expect(r.isError).toBeFalsy(); + expect((r.data as { enterPlanMode: boolean }).enterPlanMode).toBe(true); + }); + + it('uses a generic message when no reason is given', async () => { + const r = await EnterPlanModeTool.execute({}, { cwd: '/x' }); + expect(r.content).toMatch(/Entering plan mode/); + }); +}); diff --git a/packages/core/src/tools/enter-plan.ts b/packages/core/src/tools/enter-plan.ts new file mode 100644 index 0000000..b422237 --- /dev/null +++ b/packages/core/src/tools/enter-plan.ts @@ -0,0 +1,41 @@ +// EnterPlanMode tool — signals the host that the agent wants to STOP executing +// and switch into read-only "plan" mode (present a plan before touching files). +// Mirror of ExitPlanMode. The agent-loop owner reads modeSignal.enterPlanMode +// after the run and switches the active mode default → plan. +// Spec: docs/DEVELOPMENT_PLAN.md §3.8 / §0.1 (parity tool) + +import type { ToolContext, ToolHandler, ToolResult } from '../types.js'; + +interface EnterInput { + reason?: string; +} + +export const EnterPlanModeTool: ToolHandler = { + name: 'EnterPlanMode', + definition: { + name: 'EnterPlanMode', + description: + 'Switch into plan mode: stop making changes and instead research + present a plan for approval before executing. Use when a task is ambiguous or risky enough that the user should review the approach first. Write/Edit/Bash become blocked until the user leaves plan mode (or you call ExitPlanMode). Pass `reason` to explain why planning first.', + inputSchema: { + type: 'object', + properties: { + reason: { + type: 'string', + description: 'Why planning first is warranted (shown to the user).', + }, + }, + required: [], + }, + }, + async execute(rawInput: Record, ctx: ToolContext): Promise { + const input = rawInput as unknown as EnterInput; + if (ctx.modeSignal) ctx.modeSignal.enterPlanMode = true; + const reason = input?.reason?.trim() ?? ''; + return { + content: reason + ? `Entering plan mode — ${reason}. I'll research and present a plan before making changes.` + : "Entering plan mode — I'll research and present a plan before making changes.", + data: { enterPlanMode: true, reason }, + }; + }, +}; diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 1d9acff..19d7b96 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -5,6 +5,7 @@ import type { ToolHandler } from '../types.js'; import { AskUserQuestionTool } from './ask-user.js'; import { BashTool } from './bash.js'; import { EditTool } from './edit.js'; +import { EnterPlanModeTool } from './enter-plan.js'; import { ExitPlanModeTool } from './exit-plan.js'; import { GlobTool } from './glob.js'; import { GrepTool } from './grep.js'; @@ -18,7 +19,7 @@ import { WriteTool } from './write.js'; * Built-in tools shipped by default. * · 6 P0 tools from M1 (Read/Write/Edit/Bash/Grep/Glob) * · 3 M3c-rest tools (TodoWrite/WebFetch/WebSearch) - * · 2 agent-control tools (AskUserQuestion/ExitPlanMode) + * · 3 agent-control tools (AskUserQuestion/EnterPlanMode/ExitPlanMode) */ export const BUILTIN_TOOLS: ToolHandler[] = [ ReadTool, @@ -31,6 +32,7 @@ export const BUILTIN_TOOLS: ToolHandler[] = [ WebFetchTool, WebSearchTool, AskUserQuestionTool, + EnterPlanModeTool, ExitPlanModeTool, ]; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6793867..5c71257 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -127,10 +127,11 @@ export interface ToolContext { multiSelect?: boolean; }) => Promise; /** - * Mutable host state that the ExitPlanMode tool flips. The agent loop reads - * this between turns and changes its mode accordingly. + * Mutable host state that the EnterPlanMode / ExitPlanMode tools flip. The + * agent-loop owner reads this after the run and changes the active mode + * accordingly (plan ⇄ default). */ - modeSignal?: { exitPlanMode?: boolean }; + modeSignal?: { exitPlanMode?: boolean; enterPlanMode?: boolean }; } export interface ToolResult {