Skip to content

Commit 6dea914

Browse files
oratisclaude
andauthored
feat(core): EnterPlanMode tool (parity with Claude Code) (#97)
The agent could ExitPlanMode but had no way to ENTER plan mode itself (a §0.1 parity tool). Add EnterPlanModeTool — mirror of ExitPlanMode — that flips a new modeSignal.enterPlanMode; the CLI repl reads it after the run and switches default → plan (write tools then blocked until the user/agent exits). Registered in BUILTIN_TOOLS. +3 tests. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bb12791 commit 6dea914

6 files changed

Lines changed: 84 additions & 8 deletions

File tree

apps/cli/src/repl.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,11 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
358358
ctx.mode = 'default';
359359
output.write('\n ▶ Exited plan mode (agent will now execute).\n');
360360
}
361+
// Honor EnterPlanMode tool signal — flip into plan mode (writes blocked).
362+
if (result.modeSignal?.enterPlanMode && ctx.mode !== 'plan') {
363+
ctx.mode = 'plan';
364+
output.write('\n ◐ Entered plan mode (write tools blocked until you exit).\n');
365+
}
361366
output.write('\n');
362367
if (result.stopReason === 'error') {
363368
output.write(' ✕ Error during agent loop. Try again or /status to inspect.\n\n');

packages/core/src/agent.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export interface RunAgentResult {
9696
/** Reason the loop terminated. */
9797
stopReason: 'end_turn' | 'max_turns' | 'aborted' | 'error';
9898
/** Mode-control signals flipped by tools during this run (M3c-rest). */
99-
modeSignal?: { exitPlanMode?: boolean };
99+
modeSignal?: { exitPlanMode?: boolean; enterPlanMode?: boolean };
100100
}
101101

102102
const DEFAULT_MAX_TURNS = 16;
@@ -155,9 +155,9 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
155155
if (opts.session) await opts.session.manager.append(opts.session.id, userMsg);
156156
}
157157

158-
// modeSignal is mutable — ExitPlanMode flips exitPlanMode = true; the agent
159-
// loop owner reads this between turns to switch mode plan → default.
160-
const modeSignal: { exitPlanMode?: boolean } = {};
158+
// modeSignal is mutable — EnterPlanMode / ExitPlanMode flip these; the agent
159+
// loop owner reads them after the run to switch mode (default ⇄ plan).
160+
const modeSignal: { exitPlanMode?: boolean; enterPlanMode?: boolean } = {};
161161
const toolCtx: ToolContext = {
162162
cwd: opts.cwd,
163163
signal: opts.signal,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { EnterPlanModeTool } from './enter-plan.js';
3+
4+
describe('EnterPlanModeTool', () => {
5+
it('flips modeSignal.enterPlanMode and echoes the reason', async () => {
6+
const signal: { enterPlanMode?: boolean } = {};
7+
const r = await EnterPlanModeTool.execute(
8+
{ reason: 'the refactor touches many files' },
9+
{ cwd: '/x', modeSignal: signal },
10+
);
11+
expect(r.isError).toBeFalsy();
12+
expect(signal.enterPlanMode).toBe(true);
13+
expect(r.content).toContain('the refactor touches many files');
14+
expect((r.data as { enterPlanMode: boolean }).enterPlanMode).toBe(true);
15+
});
16+
17+
it('still succeeds when no modeSignal is passed (best-effort)', async () => {
18+
const r = await EnterPlanModeTool.execute({}, { cwd: '/x' });
19+
expect(r.isError).toBeFalsy();
20+
expect((r.data as { enterPlanMode: boolean }).enterPlanMode).toBe(true);
21+
});
22+
23+
it('uses a generic message when no reason is given', async () => {
24+
const r = await EnterPlanModeTool.execute({}, { cwd: '/x' });
25+
expect(r.content).toMatch(/Entering plan mode/);
26+
});
27+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// EnterPlanMode tool — signals the host that the agent wants to STOP executing
2+
// and switch into read-only "plan" mode (present a plan before touching files).
3+
// Mirror of ExitPlanMode. The agent-loop owner reads modeSignal.enterPlanMode
4+
// after the run and switches the active mode default → plan.
5+
// Spec: docs/DEVELOPMENT_PLAN.md §3.8 / §0.1 (parity tool)
6+
7+
import type { ToolContext, ToolHandler, ToolResult } from '../types.js';
8+
9+
interface EnterInput {
10+
reason?: string;
11+
}
12+
13+
export const EnterPlanModeTool: ToolHandler = {
14+
name: 'EnterPlanMode',
15+
definition: {
16+
name: 'EnterPlanMode',
17+
description:
18+
'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.',
19+
inputSchema: {
20+
type: 'object',
21+
properties: {
22+
reason: {
23+
type: 'string',
24+
description: 'Why planning first is warranted (shown to the user).',
25+
},
26+
},
27+
required: [],
28+
},
29+
},
30+
async execute(rawInput: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
31+
const input = rawInput as unknown as EnterInput;
32+
if (ctx.modeSignal) ctx.modeSignal.enterPlanMode = true;
33+
const reason = input?.reason?.trim() ?? '';
34+
return {
35+
content: reason
36+
? `Entering plan mode — ${reason}. I'll research and present a plan before making changes.`
37+
: "Entering plan mode — I'll research and present a plan before making changes.",
38+
data: { enterPlanMode: true, reason },
39+
};
40+
},
41+
};

packages/core/src/tools/registry.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ToolHandler } from '../types.js';
55
import { AskUserQuestionTool } from './ask-user.js';
66
import { BashTool } from './bash.js';
77
import { EditTool } from './edit.js';
8+
import { EnterPlanModeTool } from './enter-plan.js';
89
import { ExitPlanModeTool } from './exit-plan.js';
910
import { GlobTool } from './glob.js';
1011
import { GrepTool } from './grep.js';
@@ -18,7 +19,7 @@ import { WriteTool } from './write.js';
1819
* Built-in tools shipped by default.
1920
* · 6 P0 tools from M1 (Read/Write/Edit/Bash/Grep/Glob)
2021
* · 3 M3c-rest tools (TodoWrite/WebFetch/WebSearch)
21-
* · 2 agent-control tools (AskUserQuestion/ExitPlanMode)
22+
* · 3 agent-control tools (AskUserQuestion/EnterPlanMode/ExitPlanMode)
2223
*/
2324
export const BUILTIN_TOOLS: ToolHandler[] = [
2425
ReadTool,
@@ -31,6 +32,7 @@ export const BUILTIN_TOOLS: ToolHandler[] = [
3132
WebFetchTool,
3233
WebSearchTool,
3334
AskUserQuestionTool,
35+
EnterPlanModeTool,
3436
ExitPlanModeTool,
3537
];
3638

packages/core/src/types.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,11 @@ export interface ToolContext {
127127
multiSelect?: boolean;
128128
}) => Promise<string>;
129129
/**
130-
* Mutable host state that the ExitPlanMode tool flips. The agent loop reads
131-
* this between turns and changes its mode accordingly.
130+
* Mutable host state that the EnterPlanMode / ExitPlanMode tools flip. The
131+
* agent-loop owner reads this after the run and changes the active mode
132+
* accordingly (plan ⇄ default).
132133
*/
133-
modeSignal?: { exitPlanMode?: boolean };
134+
modeSignal?: { exitPlanMode?: boolean; enterPlanMode?: boolean };
134135
}
135136

136137
export interface ToolResult {

0 commit comments

Comments
 (0)