From c1dfcd9a2d3b303ff08184ef0f2d399a5395696c Mon Sep 17 00:00:00 2001 From: Mahima Shanware Date: Tue, 17 Feb 2026 19:44:19 +0000 Subject: [PATCH] feat: implement extensible plan mode with custom directory configuration - Adds 'general.plan' configuration object for plan settings (directory). - Updates 'experimental.plan' to a boolean flag for enablement. - Implements dynamic high-priority policy for custom plan directories in core. - Adds migration logic for previous configuration formats. - Updates documentation and schema. --- docs/cli/plan-mode.md | 56 ++++++++++++++ docs/get-started/configuration.md | 4 + packages/cli/src/config/config.ts | 9 +++ .../cli/src/config/settingsSchema.test.ts | 12 +++ packages/cli/src/config/settingsSchema.ts | 21 ++++++ .../ui/components/ExitPlanModeDialog.test.tsx | 2 + .../src/ui/components/ExitPlanModeDialog.tsx | 4 +- .../components/ToolConfirmationQueue.test.tsx | 1 + packages/core/src/config/config.test.ts | 75 +++++++++++++++++++ packages/core/src/config/config.ts | 21 +++++- .../core/src/tools/enter-plan-mode.test.ts | 21 +++++- packages/core/src/tools/enter-plan-mode.ts | 12 ++- .../core/src/tools/exit-plan-mode.test.ts | 1 + packages/core/src/tools/exit-plan-mode.ts | 10 +-- schemas/settings.schema.json | 18 +++++ 15 files changed, 254 insertions(+), 13 deletions(-) diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 1f283a63aa..3423579d45 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -107,6 +107,62 @@ These are the only allowed tools: - **Skills:** [`activate_skill`] (allows loading specialized instructions and resources in a read-only manner) +### Customizing the Plan Directory + +By default, plans are stored in a temporary directory within `~/.gemini/tmp/`. +You can customize this location, but doing so requires **two steps**: +configuring the setting and adding a policy rule. + +**Important:** If you only update `settings.json`, the agent will be blocked +from writing to your custom directory by the default safety policies. + +#### 1. Configure the directory in `settings.json` + +Add the `plan.directory` setting to your `~/.gemini/settings.json` file. This +path can be **absolute** or **relative** to your project root. + +```json +{ + "general": { + "plan": { + "directory": "conductor" + } + } +} +``` + +#### 2. Add a policy to allow writing to that directory + +Create a policy file (e.g., `~/.gemini/policies/custom-plans.toml`) to +explicitly allow the agent to write files to your custom directory while in Plan +Mode. + +The `argsPattern` in your policy must match the `file_path` (or `path`) argument +passed to the tool. + +```toml +[[rule]] +toolName = ["write_file", "replace"] +# Allow writing to any path within the "conductor/" directory +# This regex matches a relative path. +argsPattern = "\"(?:file_path|path)\":\"conductor/[^\"]+\"" +decision = "allow" +priority = 100 +modes = ["plan"] +``` + +**Relative vs. Absolute Paths:** + +- **Relative Paths:** If you use a relative path like `"conductor"` in + `settings.json`, the agent will typically use `conductor/plan.md`. Your + `argsPattern` should reflect this relative structure. +- **Absolute Paths:** If you use an absolute path like `"/usr/local/plans"`, + your `argsPattern` must match that absolute path: + `\"(?:file_path|path)\":\"/usr/local/plans/[^\"]+\"`. + +> **Tip:** For Windows users, the regex pattern must match double-backslashes in +> the JSON-stringified arguments: `conductor\\\\[^"]+`. + ### Customizing Planning with Skills You can leverage [Agent Skills](./skills.md) to customize how Gemini CLI diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 26831693db..17b9097cae 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -142,6 +142,10 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`general.plan`** (object): + - **Description:** Configuration for planning features. + - **Default:** `{}` + - **`general.enablePromptCompletion`** (boolean): - **Description:** Enable AI-powered prompt completion suggestions while typing. diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 8f4857c83f..92273d45cc 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -833,6 +833,15 @@ export async function loadCliConfig( enableExtensionReloading: settings.experimental?.extensionReloading, enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, + planDirectory: + settings.general && + 'plan' in settings.general && + typeof settings.general.plan === 'object' && + settings.general.plan !== null && + 'directory' in settings.general.plan && + typeof settings.general.plan.directory === 'string' + ? settings.general.plan.directory + : undefined, enableEventDrivenScheduler: true, skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 2a2b535eea..45ba95c070 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -401,6 +401,18 @@ describe('SettingsSchema', () => { ); }); + it('should have plan config in general schema', () => { + const setting = getSettingsSchema().general.properties.plan; + expect(setting).toBeDefined(); + expect(setting.type).toBe('object'); + expect(setting.category).toBe('General'); + expect(setting.default).toStrictEqual({}); + expect(setting.requiresRestart).toBe(false); + expect(setting.showInDialog).toBe(false); + expect(setting.ref).toBe('PlanConfig'); + expect(setting.description).toBe('Configuration for planning features.'); + }); + it('should have hooksConfig.notifications setting in schema', () => { const setting = getSettingsSchema().hooksConfig?.properties.notifications; expect(setting).toBeDefined(); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index a684b5553a..11d555e60f 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -266,6 +266,16 @@ const SETTINGS_SCHEMA = { }, }, }, + plan: { + type: 'object', + label: 'Plan Configuration', + category: 'General', + requiresRestart: false, + default: {}, + description: 'Configuration for planning features.', + showInDialog: false, + ref: 'PlanConfig', + }, enablePromptCompletion: { type: 'boolean', label: 'Enable Prompt Completion', @@ -2131,6 +2141,17 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< }, }, }, + PlanConfig: { + type: 'object', + description: 'Planning features configuration.', + additionalProperties: false, + properties: { + directory: { + type: 'string', + description: 'Custom directory for implementation plans.', + }, + }, + }, TelemetrySettings: { type: 'object', description: 'Telemetry configuration for Gemini CLI.', diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index 36c7bb3437..b016912716 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -151,6 +151,7 @@ Implement a comprehensive authentication system with multiple providers. ...options, config: { getTargetDir: () => mockTargetDir, + getPlanDirectory: () => mockPlansDir, getIdeMode: () => false, isTrustedFolder: () => true, storage: { @@ -426,6 +427,7 @@ Implement a comprehensive authentication system with multiple providers. useAlternateBuffer, config: { getTargetDir: () => mockTargetDir, + getPlanDirectory: () => mockPlansDir, getIdeMode: () => false, isTrustedFolder: () => true, storage: { diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx index 9fc1adfc23..ab1cc99617 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx @@ -65,7 +65,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState { try { const pathError = await validatePlanPath( planPath, - config.storage.getProjectTempPlansDir(), + config.getPlanDirectory(), config.getTargetDir(), ); if (ignore) return; @@ -83,7 +83,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState { const result = await processSingleFileContent( planPath, - config.storage.getProjectTempPlansDir(), + config.getPlanDirectory(), config.getFileSystemService(), ); diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx index d1e855b5c3..1752f20035 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx @@ -45,6 +45,7 @@ describe('ToolConfirmationQueue', () => { getModel: () => 'gemini-pro', getDebugMode: () => false, getTargetDir: () => '/mock/target/dir', + getPlanDirectory: () => '/mock/temp/plans', getFileSystemService: () => ({ readFile: vi.fn().mockResolvedValue('Plan content'), }), diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index c297a20ef6..f49ce54471 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -43,6 +43,10 @@ import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js'; import { DEFAULT_GEMINI_MODEL } from './models.js'; import { Storage } from './storage.js'; +vi.mock('glob', () => ({ + glob: vi.fn().mockResolvedValue([]), +})); + vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); return { @@ -2510,6 +2514,25 @@ describe('Plans Directory Initialization', () => { expect(context.getDirectories()).toContain(plansDir); }); + it('should create custom plans directory and add it to workspace context when plan.directory is provided', async () => { + const customDir = 'custom-plans'; + const config = new Config({ + ...baseParams, + plan: true, + planDirectory: customDir, + }); + + await config.initialize(); + + const expectedDir = path.resolve(baseParams.targetDir, customDir); + expect(fs.promises.mkdir).toHaveBeenCalledWith(expectedDir, { + recursive: true, + }); + + const context = config.getWorkspaceContext(); + expect(context.getDirectories()).toContain(expectedDir); + }); + it('should NOT create plans directory or add it to workspace context when plan is disabled', async () => { const config = new Config({ ...baseParams, @@ -2628,3 +2651,55 @@ describe('syncPlanModeTools', () => { expect(setToolsSpy).toHaveBeenCalled(); }); }); + +describe('Plan Directory', () => { + const baseParams: ConfigParameters = { + sessionId: 'test', + targetDir: '/tmp/project', + debugMode: false, + model: 'test-model', + cwd: '/tmp/project', + }; + + it('should return custom plan directory when provided', () => { + const customDir = 'custom-plans'; + const params: ConfigParameters = { + ...baseParams, + planDirectory: customDir, + }; + const config = new Config(params); + expect(config.getPlanDirectory()).toBe( + path.resolve(baseParams.targetDir, customDir), + ); + }); + + it('should return default plans directory when no custom directory is provided', () => { + const config = new Config(baseParams); + // storage.getProjectTempPlansDir() is tested in storage.test.ts, here we just check it matches + expect(config.getPlanDirectory()).toBe( + config.storage.getProjectTempPlansDir(), + ); + }); + + it('should support absolute paths for custom plans directory within workspace', () => { + const absolutePath = path.resolve(baseParams.targetDir, 'plans'); + const params: ConfigParameters = { + ...baseParams, + planDirectory: absolutePath, + }; + const config = new Config(params); + expect(config.getPlanDirectory()).toBe(absolutePath); + }); + + it('should fallback to default plans directory when configured path is outside workspace', () => { + const outsidePath = '/outside/workspace/plans'; + const params: ConfigParameters = { + ...baseParams, + planDirectory: outsidePath, + }; + const config = new Config(params); + expect(config.getPlanDirectory()).toBe( + config.storage.getProjectTempPlansDir(), + ); + }); +}); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ac7028b0be..c2e3d91f0e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -492,6 +492,7 @@ export interface ConfigParameters { toolOutputMasking?: Partial; disableLLMCorrection?: boolean; plan?: boolean; + planDirectory?: string; modelSteering?: boolean; onModelChange?: (model: string) => void; mcpEnabled?: boolean; @@ -685,6 +686,7 @@ export class Config { private readonly experimentalJitContext: boolean; private readonly disableLLMCorrection: boolean; private readonly planEnabled: boolean; + private readonly planDirectory?: string; private readonly modelSteering: boolean; private contextManager?: ContextManager; private terminalBackground: string | undefined = undefined; @@ -774,6 +776,7 @@ export class Config { this.agents = params.agents ?? {}; this.disableLLMCorrection = params.disableLLMCorrection ?? true; this.planEnabled = params.plan ?? false; + this.planDirectory = params.planDirectory; this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true; this.skillsSupport = params.skillsSupport ?? true; this.disabledSkills = params.disabledSkills ?? []; @@ -963,7 +966,7 @@ export class Config { // Add plans directory to workspace context for plan file storage if (this.planEnabled) { - const plansDir = this.storage.getProjectTempPlansDir(); + const plansDir = this.getPlanDirectory(); await fs.promises.mkdir(plansDir, { recursive: true }); this.workspaceContext.addDirectory(plansDir); } @@ -2042,6 +2045,22 @@ export class Config { return this.planEnabled; } + getPlanDirectory(): string { + if (this.planDirectory) { + const resolvedPath = path.resolve( + this.getTargetDir(), + this.planDirectory, + ); + if (isSubpath(this.getTargetDir(), resolvedPath)) { + return resolvedPath; + } + debugLogger.warn( + `Configured plan directory '${resolvedPath}' is outside the project root. Falling back to default temporary directory.`, + ); + } + return this.storage.getProjectTempPlansDir(); + } + getApprovedPlanPath(): string | undefined { return this.approvedPlanPath; } diff --git a/packages/core/src/tools/enter-plan-mode.test.ts b/packages/core/src/tools/enter-plan-mode.test.ts index 0b1d0a37f0..f644842822 100644 --- a/packages/core/src/tools/enter-plan-mode.test.ts +++ b/packages/core/src/tools/enter-plan-mode.test.ts @@ -23,6 +23,8 @@ describe('EnterPlanModeTool', () => { mockConfig = { setApprovalMode: vi.fn(), + getPlanDirectory: vi.fn().mockReturnValue('/mock/plans/dir'), + validatePathAccess: vi.fn().mockReturnValue(null), storage: { getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), } as unknown as Config['storage'], @@ -60,7 +62,7 @@ describe('EnterPlanModeTool', () => { expect(result.title).toBe('Enter Plan Mode'); if (result.type === 'info') { expect(result.prompt).toBe( - 'This will restrict the agent to read-only tools to allow for safe planning.', + 'This will switch to Plan Mode. The agent will be primarily restricted to read-only tools, but will have write access to its designated plans directory: /mock/plans/dir', ); } }); @@ -101,7 +103,7 @@ describe('EnterPlanModeTool', () => { }); describe('execute', () => { - it('should set approval mode to PLAN and return message', async () => { + it('should set approval mode to PLAN', async () => { const invocation = tool.build({}); const result = await invocation.execute(new AbortController().signal); @@ -113,6 +115,21 @@ describe('EnterPlanModeTool', () => { expect(result.returnDisplay).toBe('Switching to Plan mode'); }); + it('should throw error if plan directory validation fails', async () => { + const invocation = tool.build({}); + mockConfig.validatePathAccess = vi + .fn() + .mockReturnValue('Path outside workspace'); + + await expect( + invocation.execute(new AbortController().signal), + ).rejects.toThrow( + 'Invalid plan directory configuration: Path outside workspace', + ); + + expect(mockConfig.setApprovalMode).not.toHaveBeenCalled(); + }); + it('should include optional reason in output display but not in llmContent', async () => { const reason = 'Design new database schema'; const invocation = tool.build({ reason }); diff --git a/packages/core/src/tools/enter-plan-mode.ts b/packages/core/src/tools/enter-plan-mode.ts index feccb81089..4b8eef2416 100644 --- a/packages/core/src/tools/enter-plan-mode.ts +++ b/packages/core/src/tools/enter-plan-mode.ts @@ -97,12 +97,13 @@ export class EnterPlanModeInvocation extends BaseToolInvocation< ); } + const plansDir = this.config.getPlanDirectory(); + // ASK_USER return { type: 'info', title: 'Enter Plan Mode', - prompt: - 'This will restrict the agent to read-only tools to allow for safe planning.', + prompt: `This will switch to Plan Mode. The agent will be primarily restricted to read-only tools, but will have write access to its designated plans directory: ${plansDir}`, onConfirm: async (outcome: ToolConfirmationOutcome) => { this.confirmationOutcome = outcome; await this.publishPolicyUpdate(outcome); @@ -118,6 +119,13 @@ export class EnterPlanModeInvocation extends BaseToolInvocation< }; } + const plansDir = this.config.getPlanDirectory(); + // Validate that the plan directory is safe (within workspace) + const pathError = this.config.validatePathAccess(plansDir, 'write'); + if (pathError) { + throw new Error(`Invalid plan directory configuration: ${pathError}`); + } + this.config.setApprovalMode(ApprovalMode.PLAN); return { diff --git a/packages/core/src/tools/exit-plan-mode.test.ts b/packages/core/src/tools/exit-plan-mode.test.ts index 3e226c5142..aa7517fe2d 100644 --- a/packages/core/src/tools/exit-plan-mode.test.ts +++ b/packages/core/src/tools/exit-plan-mode.test.ts @@ -42,6 +42,7 @@ describe('ExitPlanModeTool', () => { mockConfig = { getTargetDir: vi.fn().mockReturnValue(tempRootDir), + getPlanDirectory: vi.fn().mockReturnValue(mockPlansDir), setApprovalMode: vi.fn(), setApprovedPlanPath: vi.fn(), storage: { diff --git a/packages/core/src/tools/exit-plan-mode.ts b/packages/core/src/tools/exit-plan-mode.ts index a0540b11e3..ce487a4db0 100644 --- a/packages/core/src/tools/exit-plan-mode.ts +++ b/packages/core/src/tools/exit-plan-mode.ts @@ -57,7 +57,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< private config: Config, messageBus: MessageBus, ) { - const plansDir = config.storage.getProjectTempPlansDir(); + const plansDir = config.getPlanDirectory(); const definition = getExitPlanModeDefinition(plansDir); super( EXIT_PLAN_MODE_TOOL_NAME, @@ -78,9 +78,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< // Since validateToolParamValues is synchronous, we use a basic synchronous check // for path traversal safety. High-level async validation is deferred to shouldConfirmExecute. - const plansDir = resolveToRealPath( - this.config.storage.getProjectTempPlansDir(), - ); + const plansDir = resolveToRealPath(this.config.getPlanDirectory()); const resolvedPath = path.resolve( this.config.getTargetDir(), params.plan_path, @@ -111,7 +109,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< } override getSchema(modelId?: string) { - const plansDir = this.config.storage.getProjectTempPlansDir(); + const plansDir = this.config.getPlanDirectory(); return resolveToolDeclaration(getExitPlanModeDefinition(plansDir), modelId); } } @@ -141,7 +139,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation< const pathError = await validatePlanPath( this.params.plan_path, - this.config.storage.getProjectTempPlansDir(), + this.config.getPlanDirectory(), this.config.getTargetDir(), ); if (pathError) { diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index f885990a58..57c59b2c3b 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -105,6 +105,13 @@ }, "additionalProperties": false }, + "plan": { + "title": "Plan Configuration", + "description": "Configuration for planning features.", + "markdownDescription": "Configuration for planning features.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "$ref": "#/$defs/PlanConfig" + }, "enablePromptCompletion": { "title": "Enable Prompt Completion", "description": "Enable AI-powered prompt completion suggestions while typing.", @@ -1971,6 +1978,17 @@ } } }, + "PlanConfig": { + "type": "object", + "description": "Planning features configuration.", + "additionalProperties": false, + "properties": { + "directory": { + "type": "string", + "description": "Custom directory for implementation plans." + } + } + }, "TelemetrySettings": { "type": "object", "description": "Telemetry configuration for Gemini CLI.",