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.
This commit is contained in:
Mahima Shanware
2026-02-17 19:44:19 +00:00
parent e51876b108
commit c1dfcd9a2d
15 changed files with 254 additions and 13 deletions
+9
View File
@@ -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,
@@ -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();
+21
View File
@@ -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.',
@@ -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: {
@@ -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(),
);
@@ -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'),
}),
+75
View File
@@ -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<typeof import('fs')>();
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(),
);
});
});
+20 -1
View File
@@ -492,6 +492,7 @@ export interface ConfigParameters {
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
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;
}
@@ -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 });
+10 -2
View File
@@ -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 {
@@ -42,6 +42,7 @@ describe('ExitPlanModeTool', () => {
mockConfig = {
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
getPlanDirectory: vi.fn().mockReturnValue(mockPlansDir),
setApprovalMode: vi.fn(),
setApprovedPlanPath: vi.fn(),
storage: {
+4 -6
View File
@@ -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) {