feat: Add intelligent auto-router and enhanced integrations

- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
{
"name": "plan-tools",
"version": "0.1.0",
"description": "Implementation planning tools with session-linked plans. Create, read, and update plans tied to your session.",
"author": "Dexto",
"customToolProviders": ["plan-tools"]
}

View File

@@ -0,0 +1,40 @@
{
"name": "@dexto/tools-plan",
"version": "1.5.6",
"description": "Implementation planning tools with session-linked plans",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
".dexto-plugin",
"skills"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@dexto/core": "workspace:*",
"diff": "^7.0.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/diff": "^7.0.0",
"@types/node": "^22.10.5",
"dotenv": "^16.4.7",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
},
"author": "Dexto",
"license": "MIT"
}

View File

@@ -0,0 +1,102 @@
---
name: plan
description: Enter planning mode to create and manage implementation plans
user-invocable: true
---
# Planning Mode - PLAN FIRST, THEN IMPLEMENT
**CRITICAL**: You are in planning mode. You MUST create and get approval for a plan BEFORE writing any code or making any changes.
## MANDATORY WORKFLOW
**DO NOT skip these steps. DO NOT start implementing until the plan is approved.**
1. **Research first** (if needed): Use the explore agent or read relevant files to understand the codebase
2. **Check for existing plan**: Use `plan_read` to see if a plan exists
3. **Create/update plan**: Use `plan_create` or `plan_update` to define your approach
4. **Request review**: Use `plan_review` to get user approval
5. **WAIT for approval**: Only proceed to implementation after user approves
6. **Implement**: Execute the approved plan, updating checkboxes as you go
## Research Phase
Before creating your plan, you should understand the codebase:
- **Use the explore agent** (spawn_agent with subagent_type="Explore") to search for relevant code, patterns, and existing implementations
- **Read key files** to understand the current architecture
- **Identify dependencies** and files that will need changes
This research informs your plan and prevents wasted effort from incorrect assumptions.
## Available Tools
- **plan_create**: Create a new plan (REQUIRED before any implementation)
- **plan_read**: Read the current plan
- **plan_update**: Update the existing plan (shows diff preview)
- **plan_review**: Request user review - returns approve/iterate/reject with feedback
## WHAT YOU MUST DO NOW
1. **Research**: Use the explore agent or read files to understand the relevant parts of the codebase
2. **Check plan**: Use `plan_read` to check if a plan already exists
3. **Create plan**: Use `plan_create` to create a comprehensive plan based on your research
4. **Get approval**: Use `plan_review` to request user approval
5. **STOP and WAIT** - do not write any code until the user approves via plan_review
## Plan Structure
```markdown
# {Title}
## Objective
{Clear statement of what we're building/fixing}
## Steps
### 1. {Step Name}
- [ ] {Task description}
- [ ] {Task description}
Files: `path/to/file.ts`, `path/to/other.ts`
### 2. {Step Name}
- [ ] {Task description}
Files: `path/to/file.ts`
## Considerations
- {Edge cases to handle}
- {Error scenarios}
## Success Criteria
- {How we know we're done}
```
## Guidelines
- **Break down complex tasks** into clear, sequential steps
- **Include specific file paths** that will be created or modified
- **Note dependencies** between steps
- **Keep plans concise** but complete
## Handling Review Responses
After calling `plan_review`, handle the response:
- **approve**: User approved - proceed with implementation
- **iterate**: User wants changes - update the plan based on feedback, then call `plan_review` again
- **reject**: User rejected - ask what they want instead
## DO NOT
- ❌ Start writing code before creating a plan
- ❌ Skip the plan_review step
- ❌ Assume approval - wait for explicit user response
- ❌ Make changes outside the approved plan without updating it first
---
**START NOW**:
1. Research the codebase using the explore agent if needed
2. Use `plan_read` to check for an existing plan
3. Use `plan_create` to create your plan
4. Use `plan_review` to get approval before any implementation

View File

@@ -0,0 +1,118 @@
/**
* Plan Error Factory
*
* Provides typed errors for plan operations following the DextoRuntimeError pattern.
*/
import { DextoRuntimeError, ErrorType } from '@dexto/core';
/**
* Error codes for plan operations
*/
export const PlanErrorCode = {
/** Plan already exists for session */
PLAN_ALREADY_EXISTS: 'PLAN_ALREADY_EXISTS',
/** Plan not found for session */
PLAN_NOT_FOUND: 'PLAN_NOT_FOUND',
/** Invalid plan content */
INVALID_PLAN_CONTENT: 'INVALID_PLAN_CONTENT',
/** Session ID required */
SESSION_ID_REQUIRED: 'SESSION_ID_REQUIRED',
/** Invalid session ID (path traversal attempt) */
INVALID_SESSION_ID: 'INVALID_SESSION_ID',
/** Checkpoint not found */
CHECKPOINT_NOT_FOUND: 'CHECKPOINT_NOT_FOUND',
/** Storage operation failed */
STORAGE_ERROR: 'STORAGE_ERROR',
} as const;
export type PlanErrorCodeType = (typeof PlanErrorCode)[keyof typeof PlanErrorCode];
/**
* Error factory for plan operations
*/
export const PlanError = {
/**
* Plan already exists for the given session
*/
planAlreadyExists(sessionId: string): DextoRuntimeError {
return new DextoRuntimeError(
PlanErrorCode.PLAN_ALREADY_EXISTS,
'plan',
ErrorType.USER,
`A plan already exists for session '${sessionId}'. Use plan_update to modify it.`,
{ sessionId },
'Use plan_update to modify the existing plan, or plan_read to view it.'
);
},
/**
* Plan not found for the given session
*/
planNotFound(sessionId: string): DextoRuntimeError {
return new DextoRuntimeError(
PlanErrorCode.PLAN_NOT_FOUND,
'plan',
ErrorType.NOT_FOUND,
`No plan found for session '${sessionId}'.`,
{ sessionId },
'Use plan_create to create a new plan for this session.'
);
},
/**
* Session ID is required for plan operations
*/
sessionIdRequired(): DextoRuntimeError {
return new DextoRuntimeError(
PlanErrorCode.SESSION_ID_REQUIRED,
'plan',
ErrorType.USER,
'Session ID is required for plan operations.',
{},
'Ensure the tool is called within a valid session context.'
);
},
/**
* Invalid session ID (path traversal attempt)
*/
invalidSessionId(sessionId: string): DextoRuntimeError {
return new DextoRuntimeError(
PlanErrorCode.INVALID_SESSION_ID,
'plan',
ErrorType.USER,
`Invalid session ID: '${sessionId}' contains invalid path characters.`,
{ sessionId },
'Session IDs must not contain path traversal characters like "..".'
);
},
/**
* Checkpoint not found in plan
*/
checkpointNotFound(checkpointId: string, sessionId: string): DextoRuntimeError {
return new DextoRuntimeError(
PlanErrorCode.CHECKPOINT_NOT_FOUND,
'plan',
ErrorType.NOT_FOUND,
`Checkpoint '${checkpointId}' not found in plan for session '${sessionId}'.`,
{ checkpointId, sessionId },
'Use plan_read to view available checkpoints.'
);
},
/**
* Storage operation failed
*/
storageError(operation: string, sessionId: string, cause?: Error): DextoRuntimeError {
return new DextoRuntimeError(
PlanErrorCode.STORAGE_ERROR,
'plan',
ErrorType.SYSTEM,
`Failed to ${operation} plan for session '${sessionId}': ${cause?.message || 'unknown error'}`,
{ operation, sessionId, cause: cause?.message },
'Check file system permissions and try again.'
);
},
};

View File

@@ -0,0 +1,48 @@
/**
* @dexto/tools-plan
*
* Implementation planning tools with session-linked plans.
* Provides tools for creating, reading, updating, and tracking plans.
*
* This package is a Dexto plugin that automatically registers:
* - Custom tool provider: plan-tools
* - Skill: plan (planning mode instructions)
*
* Usage:
* 1. Install the package
* 2. The plugin discovery will find .dexto-plugin/plugin.json
* 3. Tools and skill are automatically registered
*/
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Path to the plugin directory containing .dexto-plugin manifest.
* Use this in image definitions to declare bundled plugins.
*
* @example
* ```typescript
* import { PLUGIN_PATH } from '@dexto/tools-plan';
*
* export default defineImage({
* bundledPlugins: [PLUGIN_PATH],
* // ...
* });
* ```
*/
export const PLUGIN_PATH = path.resolve(__dirname, '..');
// Tool provider (for direct registration if needed)
export { planToolsProvider } from './tool-provider.js';
// Service (for advanced use cases)
export { PlanService } from './plan-service.js';
// Types
export type { Plan, PlanMeta, PlanStatus, PlanServiceOptions, PlanUpdateResult } from './types.js';
// Error utilities
export { PlanError, PlanErrorCode, type PlanErrorCodeType } from './errors.js';

View File

@@ -0,0 +1,266 @@
/**
* Plan Service Tests
*
* Tests for the PlanService CRUD operations and error handling.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { PlanService } from './plan-service.js';
import { PlanErrorCode } from './errors.js';
import { DextoRuntimeError } from '@dexto/core';
// Create mock logger
const createMockLogger = () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
createChild: vi.fn().mockReturnThis(),
});
describe('PlanService', () => {
let mockLogger: ReturnType<typeof createMockLogger>;
let tempDir: string;
let planService: PlanService;
beforeEach(async () => {
mockLogger = createMockLogger();
// Create temp directory for testing
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dexto-plan-test-'));
tempDir = await fs.realpath(rawTempDir);
planService = new PlanService({ basePath: tempDir }, mockLogger as any);
vi.clearAllMocks();
});
afterEach(async () => {
// Cleanup temp directory
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('exists', () => {
it('should return false for non-existent plan', async () => {
const exists = await planService.exists('non-existent-session');
expect(exists).toBe(false);
});
it('should return true for existing plan', async () => {
const sessionId = 'test-session';
await planService.create(sessionId, '# Test Plan');
const exists = await planService.exists(sessionId);
expect(exists).toBe(true);
});
});
describe('create', () => {
it('should create a new plan with content and metadata', async () => {
const sessionId = 'test-session';
const content = '# Implementation Plan\n\n## Steps\n1. First step';
const title = 'Test Plan';
const plan = await planService.create(sessionId, content, { title });
expect(plan.content).toBe(content);
expect(plan.meta.sessionId).toBe(sessionId);
expect(plan.meta.status).toBe('draft');
expect(plan.meta.title).toBe(title);
expect(plan.meta.createdAt).toBeGreaterThan(0);
expect(plan.meta.updatedAt).toBeGreaterThan(0);
});
it('should throw error when plan already exists', async () => {
const sessionId = 'test-session';
await planService.create(sessionId, '# First Plan');
try {
await planService.create(sessionId, '# Second Plan');
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(PlanErrorCode.PLAN_ALREADY_EXISTS);
}
});
it('should store plan files on disk', async () => {
const sessionId = 'test-session';
const content = '# Test Plan';
await planService.create(sessionId, content);
// Verify plan.md exists
const planPath = path.join(tempDir, sessionId, 'plan.md');
const storedContent = await fs.readFile(planPath, 'utf-8');
expect(storedContent).toBe(content);
// Verify plan-meta.json exists
const metaPath = path.join(tempDir, sessionId, 'plan-meta.json');
const metaContent = await fs.readFile(metaPath, 'utf-8');
const meta = JSON.parse(metaContent);
expect(meta.sessionId).toBe(sessionId);
});
});
describe('read', () => {
it('should return null for non-existent plan', async () => {
const plan = await planService.read('non-existent-session');
expect(plan).toBeNull();
});
it('should read existing plan with content and metadata', async () => {
const sessionId = 'test-session';
const content = '# Test Plan';
const title = 'My Plan';
await planService.create(sessionId, content, { title });
const plan = await planService.read(sessionId);
expect(plan).not.toBeNull();
expect(plan!.content).toBe(content);
expect(plan!.meta.sessionId).toBe(sessionId);
expect(plan!.meta.title).toBe(title);
});
it('should handle invalid metadata schema gracefully', async () => {
const sessionId = 'test-session';
await planService.create(sessionId, '# Test');
// Write valid JSON but invalid schema (missing required fields)
const metaPath = path.join(tempDir, sessionId, 'plan-meta.json');
await fs.writeFile(metaPath, JSON.stringify({ invalidField: 'value' }));
const plan = await planService.read(sessionId);
// Should return with default metadata
expect(plan).not.toBeNull();
expect(plan!.meta.sessionId).toBe(sessionId);
expect(plan!.meta.status).toBe('draft');
expect(mockLogger.warn).toHaveBeenCalled();
});
it('should return null for corrupted JSON metadata', async () => {
const sessionId = 'test-session';
await planService.create(sessionId, '# Test');
// Corrupt the metadata with invalid JSON
const metaPath = path.join(tempDir, sessionId, 'plan-meta.json');
await fs.writeFile(metaPath, '{ invalid json }');
const plan = await planService.read(sessionId);
// Should return null and log error
expect(plan).toBeNull();
expect(mockLogger.error).toHaveBeenCalled();
});
});
describe('update', () => {
it('should update plan content', async () => {
const sessionId = 'test-session';
await planService.create(sessionId, '# Original Content');
const result = await planService.update(sessionId, '# Updated Content');
expect(result.oldContent).toBe('# Original Content');
expect(result.newContent).toBe('# Updated Content');
expect(result.meta.updatedAt).toBeGreaterThan(0);
});
it('should preserve metadata when updating content', async () => {
const sessionId = 'test-session';
const plan = await planService.create(sessionId, '# Original', { title: 'My Title' });
const originalCreatedAt = plan.meta.createdAt;
await planService.update(sessionId, '# Updated');
const updatedPlan = await planService.read(sessionId);
expect(updatedPlan!.meta.title).toBe('My Title');
expect(updatedPlan!.meta.createdAt).toBe(originalCreatedAt);
});
it('should throw error when plan does not exist', async () => {
try {
await planService.update('non-existent', '# Content');
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(PlanErrorCode.PLAN_NOT_FOUND);
}
});
});
describe('updateMeta', () => {
it('should update plan status', async () => {
const sessionId = 'test-session';
await planService.create(sessionId, '# Plan');
const meta = await planService.updateMeta(sessionId, { status: 'approved' });
expect(meta.status).toBe('approved');
});
it('should update plan title', async () => {
const sessionId = 'test-session';
await planService.create(sessionId, '# Plan');
const meta = await planService.updateMeta(sessionId, { title: 'New Title' });
expect(meta.title).toBe('New Title');
});
it('should throw error when plan does not exist', async () => {
try {
await planService.updateMeta('non-existent', { status: 'approved' });
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(PlanErrorCode.PLAN_NOT_FOUND);
}
});
});
describe('delete', () => {
it('should delete existing plan', async () => {
const sessionId = 'test-session';
await planService.create(sessionId, '# Plan');
await planService.delete(sessionId);
const exists = await planService.exists(sessionId);
expect(exists).toBe(false);
});
it('should throw error when plan does not exist', async () => {
try {
await planService.delete('non-existent');
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(PlanErrorCode.PLAN_NOT_FOUND);
}
});
it('should remove plan directory from disk', async () => {
const sessionId = 'test-session';
await planService.create(sessionId, '# Plan');
const planDir = path.join(tempDir, sessionId);
await planService.delete(sessionId);
try {
await fs.access(planDir);
expect.fail('Directory should not exist');
} catch {
// Expected - directory should not exist
}
});
});
});

View File

@@ -0,0 +1,273 @@
/**
* Plan Service
*
* Handles storage and retrieval of implementation plans.
* Plans are stored in .dexto/plans/{sessionId}/ with:
* - plan.md: The plan content
* - plan-meta.json: Metadata (status, checkpoints, timestamps)
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { existsSync } from 'node:fs';
import type { IDextoLogger } from '@dexto/core';
import { PlanMetaSchema } from './types.js';
import type { Plan, PlanMeta, PlanServiceOptions, PlanUpdateResult } from './types.js';
import { PlanError } from './errors.js';
const PLAN_FILENAME = 'plan.md';
const META_FILENAME = 'plan-meta.json';
/**
* Service for managing implementation plans.
*/
export class PlanService {
private basePath: string;
private logger: IDextoLogger | undefined;
constructor(options: PlanServiceOptions, logger?: IDextoLogger) {
this.basePath = options.basePath;
this.logger = logger;
}
/**
* Resolves and validates a session directory path.
* Prevents path traversal attacks by ensuring the resolved path stays within basePath.
*/
private resolveSessionDir(sessionId: string): string {
const base = path.resolve(this.basePath);
const resolved = path.resolve(base, sessionId);
const rel = path.relative(base, resolved);
// Check for path traversal (upward traversal)
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw PlanError.invalidSessionId(sessionId);
}
return resolved;
}
/**
* Gets the directory path for a session's plan
*/
private getPlanDir(sessionId: string): string {
return this.resolveSessionDir(sessionId);
}
/**
* Gets the path to the plan content file.
* Public accessor for tools that need to display the path.
*/
public getPlanPath(sessionId: string): string {
return path.join(this.getPlanDir(sessionId), PLAN_FILENAME);
}
/**
* Gets the path to the plan metadata file
*/
private getMetaPath(sessionId: string): string {
return path.join(this.getPlanDir(sessionId), META_FILENAME);
}
/**
* Checks if a plan exists for the given session
*/
async exists(sessionId: string): Promise<boolean> {
const planPath = this.getPlanPath(sessionId);
return existsSync(planPath);
}
/**
* Creates a new plan for the session
*
* @throws PlanError.planAlreadyExists if plan already exists
* @throws PlanError.storageError on filesystem errors
*/
async create(sessionId: string, content: string, options?: { title?: string }): Promise<Plan> {
// Check if plan already exists
if (await this.exists(sessionId)) {
throw PlanError.planAlreadyExists(sessionId);
}
const planDir = this.getPlanDir(sessionId);
const now = Date.now();
// Create metadata
const meta: PlanMeta = {
sessionId,
status: 'draft',
title: options?.title,
createdAt: now,
updatedAt: now,
};
try {
// Ensure directory exists
await fs.mkdir(planDir, { recursive: true });
// Write plan content and metadata
await Promise.all([
fs.writeFile(this.getPlanPath(sessionId), content, 'utf-8'),
fs.writeFile(this.getMetaPath(sessionId), JSON.stringify(meta, null, 2), 'utf-8'),
]);
this.logger?.debug(`Created plan for session ${sessionId}`);
return { content, meta };
} catch (error) {
throw PlanError.storageError('create', sessionId, error as Error);
}
}
/**
* Reads the plan for the given session
*
* @returns The plan or null if not found
*/
async read(sessionId: string): Promise<Plan | null> {
if (!(await this.exists(sessionId))) {
return null;
}
try {
const [content, metaContent] = await Promise.all([
fs.readFile(this.getPlanPath(sessionId), 'utf-8'),
fs.readFile(this.getMetaPath(sessionId), 'utf-8'),
]);
const metaParsed = JSON.parse(metaContent);
const metaResult = PlanMetaSchema.safeParse(metaParsed);
if (!metaResult.success) {
this.logger?.warn(`Invalid plan metadata for session ${sessionId}, using defaults`);
// Return with minimal metadata if parsing fails
return {
content,
meta: {
sessionId,
status: 'draft',
createdAt: Date.now(),
updatedAt: Date.now(),
},
};
}
return { content, meta: metaResult.data };
} catch (error) {
const err = error as NodeJS.ErrnoException;
// ENOENT means file doesn't exist - return null (expected case)
if (err.code === 'ENOENT') {
return null;
}
// JSON parse errors (SyntaxError) mean corrupted data - treat as not found
// but log for debugging
if (error instanceof SyntaxError) {
this.logger?.error(
`Failed to read plan for session ${sessionId}: ${error.message}`
);
return null;
}
// For real I/O errors (permission denied, disk issues), throw to surface the issue
this.logger?.error(
`Failed to read plan for session ${sessionId}: ${err.message ?? String(err)}`
);
throw PlanError.storageError('read', sessionId, err);
}
}
/**
* Updates the plan content for the given session
*
* @throws PlanError.planNotFound if plan doesn't exist
* @throws PlanError.storageError on filesystem errors
*/
async update(sessionId: string, content: string): Promise<PlanUpdateResult> {
const existing = await this.read(sessionId);
if (!existing) {
throw PlanError.planNotFound(sessionId);
}
const oldContent = existing.content;
const now = Date.now();
// Update metadata
const updatedMeta: PlanMeta = {
...existing.meta,
updatedAt: now,
};
try {
await Promise.all([
fs.writeFile(this.getPlanPath(sessionId), content, 'utf-8'),
fs.writeFile(
this.getMetaPath(sessionId),
JSON.stringify(updatedMeta, null, 2),
'utf-8'
),
]);
this.logger?.debug(`Updated plan for session ${sessionId}`);
return {
oldContent,
newContent: content,
meta: updatedMeta,
};
} catch (error) {
throw PlanError.storageError('update', sessionId, error as Error);
}
}
/**
* Updates the plan metadata (status, title)
*
* @throws PlanError.planNotFound if plan doesn't exist
* @throws PlanError.storageError on filesystem errors
*/
async updateMeta(
sessionId: string,
updates: Partial<Pick<PlanMeta, 'status' | 'title'>>
): Promise<PlanMeta> {
const existing = await this.read(sessionId);
if (!existing) {
throw PlanError.planNotFound(sessionId);
}
const updatedMeta: PlanMeta = {
...existing.meta,
...updates,
updatedAt: Date.now(),
};
try {
await fs.writeFile(
this.getMetaPath(sessionId),
JSON.stringify(updatedMeta, null, 2),
'utf-8'
);
this.logger?.debug(`Updated plan metadata for session ${sessionId}`);
return updatedMeta;
} catch (error) {
throw PlanError.storageError('update metadata', sessionId, error as Error);
}
}
/**
* Deletes the plan for the given session
*
* @throws PlanError.planNotFound if plan doesn't exist
* @throws PlanError.storageError on filesystem errors
*/
async delete(sessionId: string): Promise<void> {
if (!(await this.exists(sessionId))) {
throw PlanError.planNotFound(sessionId);
}
try {
await fs.rm(this.getPlanDir(sessionId), { recursive: true, force: true });
this.logger?.debug(`Deleted plan for session ${sessionId}`);
} catch (error) {
throw PlanError.storageError('delete', sessionId, error as Error);
}
}
}

View File

@@ -0,0 +1,234 @@
/**
* Plan Tools Provider Tests
*
* Tests for the planToolsProvider configuration and tool creation.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { planToolsProvider } from './tool-provider.js';
// Create mock logger
const createMockLogger = () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
createChild: vi.fn().mockReturnThis(),
});
// Create mock context with logger and minimal agent
const createMockContext = (logger: ReturnType<typeof createMockLogger>) => ({
logger: logger as any,
agent: {} as any, // Minimal mock - provider only uses logger
});
describe('planToolsProvider', () => {
let mockLogger: ReturnType<typeof createMockLogger>;
let tempDir: string;
let originalCwd: string;
beforeEach(async () => {
mockLogger = createMockLogger();
// Create temp directory for testing
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dexto-provider-test-'));
tempDir = await fs.realpath(rawTempDir);
// Store original cwd and mock process.cwd to return temp dir
originalCwd = process.cwd();
vi.spyOn(process, 'cwd').mockReturnValue(tempDir);
vi.clearAllMocks();
});
afterEach(async () => {
// Restore mocked process.cwd
vi.mocked(process.cwd).mockRestore();
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('provider metadata', () => {
it('should have correct type', () => {
expect(planToolsProvider.type).toBe('plan-tools');
});
it('should have metadata', () => {
expect(planToolsProvider.metadata).toBeDefined();
expect(planToolsProvider.metadata?.displayName).toBe('Plan Tools');
expect(planToolsProvider.metadata?.category).toBe('planning');
});
});
describe('config schema', () => {
it('should validate minimal config', () => {
const result = planToolsProvider.configSchema.safeParse({
type: 'plan-tools',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.basePath).toBe('.dexto/plans');
}
});
it('should validate config with custom basePath', () => {
const result = planToolsProvider.configSchema.safeParse({
type: 'plan-tools',
basePath: '/custom/path',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.basePath).toBe('/custom/path');
}
});
it('should validate config with enabledTools', () => {
const result = planToolsProvider.configSchema.safeParse({
type: 'plan-tools',
enabledTools: ['plan_create', 'plan_read'],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.enabledTools).toEqual(['plan_create', 'plan_read']);
}
});
it('should reject invalid tool names', () => {
const result = planToolsProvider.configSchema.safeParse({
type: 'plan-tools',
enabledTools: ['invalid_tool'],
});
expect(result.success).toBe(false);
});
it('should reject unknown properties', () => {
const result = planToolsProvider.configSchema.safeParse({
type: 'plan-tools',
unknownProp: 'value',
});
expect(result.success).toBe(false);
});
});
describe('create', () => {
it('should create all tools by default', () => {
const config = planToolsProvider.configSchema.parse({
type: 'plan-tools',
});
const tools = planToolsProvider.create(config, createMockContext(mockLogger));
expect(tools).toHaveLength(4);
const toolIds = tools.map((t) => t.id);
expect(toolIds).toContain('plan_create');
expect(toolIds).toContain('plan_read');
expect(toolIds).toContain('plan_update');
expect(toolIds).toContain('plan_review');
});
it('should create only enabled tools', () => {
const config = planToolsProvider.configSchema.parse({
type: 'plan-tools',
enabledTools: ['plan_create', 'plan_read'],
});
const tools = planToolsProvider.create(config, createMockContext(mockLogger));
expect(tools).toHaveLength(2);
const toolIds = tools.map((t) => t.id);
expect(toolIds).toContain('plan_create');
expect(toolIds).toContain('plan_read');
expect(toolIds).not.toContain('plan_update');
});
it('should create single tool', () => {
const config = planToolsProvider.configSchema.parse({
type: 'plan-tools',
enabledTools: ['plan_update'],
});
const tools = planToolsProvider.create(config, createMockContext(mockLogger));
expect(tools).toHaveLength(1);
expect(tools[0]!.id).toBe('plan_update');
});
it('should use relative basePath from cwd', () => {
const config = planToolsProvider.configSchema.parse({
type: 'plan-tools',
basePath: '.dexto/plans',
});
planToolsProvider.create(config, createMockContext(mockLogger));
// Verify debug log was called with resolved path
expect(mockLogger.debug).toHaveBeenCalledWith(
expect.stringContaining(path.join(tempDir, '.dexto/plans'))
);
});
it('should use absolute basePath as-is', () => {
const absolutePath = '/absolute/path/to/plans';
const config = planToolsProvider.configSchema.parse({
type: 'plan-tools',
basePath: absolutePath,
});
planToolsProvider.create(config, createMockContext(mockLogger));
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining(absolutePath));
});
it('should log when creating subset of tools', () => {
const config = planToolsProvider.configSchema.parse({
type: 'plan-tools',
enabledTools: ['plan_create'],
});
planToolsProvider.create(config, createMockContext(mockLogger));
expect(mockLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('Creating subset of plan tools')
);
});
});
describe('tool descriptions', () => {
it('should have descriptions for all tools', () => {
const config = planToolsProvider.configSchema.parse({
type: 'plan-tools',
});
const tools = planToolsProvider.create(config, createMockContext(mockLogger));
for (const tool of tools) {
expect(tool.description).toBeDefined();
expect(tool.description.length).toBeGreaterThan(0);
}
});
it('should have input schemas for all tools', () => {
const config = planToolsProvider.configSchema.parse({
type: 'plan-tools',
});
const tools = planToolsProvider.create(config, createMockContext(mockLogger));
for (const tool of tools) {
expect(tool.inputSchema).toBeDefined();
}
});
});
});

View File

@@ -0,0 +1,100 @@
/**
* Plan Tools Provider
*
* Provides implementation planning tools:
* - plan_create: Create a new plan for the session
* - plan_read: Read the current plan
* - plan_update: Update the existing plan
* - plan_review: Request user review of the plan (shows plan content with approval options)
*/
import * as path from 'node:path';
import { z } from 'zod';
import type { CustomToolProvider, ToolCreationContext, InternalTool } from '@dexto/core';
import { PlanService } from './plan-service.js';
import { createPlanCreateTool } from './tools/plan-create-tool.js';
import { createPlanReadTool } from './tools/plan-read-tool.js';
import { createPlanUpdateTool } from './tools/plan-update-tool.js';
import { createPlanReviewTool } from './tools/plan-review-tool.js';
/**
* Available plan tool names for enabledTools configuration
*/
const PLAN_TOOL_NAMES = ['plan_create', 'plan_read', 'plan_update', 'plan_review'] as const;
type PlanToolName = (typeof PLAN_TOOL_NAMES)[number];
/**
* Configuration schema for Plan tools provider
*/
const PlanToolsConfigSchema = z
.object({
type: z.literal('plan-tools'),
basePath: z
.string()
.default('.dexto/plans')
.describe('Base directory for plan storage (relative to working directory)'),
enabledTools: z
.array(z.enum(PLAN_TOOL_NAMES))
.optional()
.describe(
`Subset of tools to enable. If not specified, all tools are enabled. Available: ${PLAN_TOOL_NAMES.join(', ')}`
),
})
.strict();
type PlanToolsConfig = z.output<typeof PlanToolsConfigSchema>;
/**
* Plan tools provider
*
* Provides implementation planning tools:
* - plan_create: Create a new plan with markdown content
* - plan_read: Read the current plan
* - plan_update: Update existing plan (shows diff preview)
* - plan_review: Request user review of the plan (shows plan with approval options)
*
* Plans are stored in .dexto/plans/{sessionId}/ with:
* - plan.md: Markdown content with checkboxes (- [ ] and - [x])
* - plan-meta.json: Metadata (status, title, timestamps)
*/
export const planToolsProvider: CustomToolProvider<'plan-tools', PlanToolsConfig> = {
type: 'plan-tools',
configSchema: PlanToolsConfigSchema,
create: (config: PlanToolsConfig, context: ToolCreationContext): InternalTool[] => {
const { logger } = context;
// Resolve base path (relative to cwd or absolute)
const basePath = path.isAbsolute(config.basePath)
? config.basePath
: path.join(process.cwd(), config.basePath);
logger.debug(`Creating PlanService with basePath: ${basePath}`);
const planService = new PlanService({ basePath }, logger);
// Build tool map for selective enabling
const toolCreators: Record<PlanToolName, () => InternalTool> = {
plan_create: () => createPlanCreateTool(planService),
plan_read: () => createPlanReadTool(planService),
plan_update: () => createPlanUpdateTool(planService),
plan_review: () => createPlanReviewTool(planService),
};
// Determine which tools to create
const toolsToCreate = config.enabledTools ?? PLAN_TOOL_NAMES;
if (config.enabledTools) {
logger.debug(`Creating subset of plan tools: ${toolsToCreate.join(', ')}`);
}
// Create and return only the enabled tools
return toolsToCreate.map((toolName) => toolCreators[toolName]());
},
metadata: {
displayName: 'Plan Tools',
description: 'Create and manage implementation plans linked to sessions',
category: 'planning',
},
};

View File

@@ -0,0 +1,152 @@
/**
* Plan Create Tool Tests
*
* Tests for the plan_create tool including preview generation.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { createPlanCreateTool } from './plan-create-tool.js';
import { PlanService } from '../plan-service.js';
import { PlanErrorCode } from '../errors.js';
import { DextoRuntimeError } from '@dexto/core';
import type { FileDisplayData } from '@dexto/core';
// Create mock logger
const createMockLogger = () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
createChild: vi.fn().mockReturnThis(),
});
describe('plan_create tool', () => {
let mockLogger: ReturnType<typeof createMockLogger>;
let tempDir: string;
let planService: PlanService;
beforeEach(async () => {
mockLogger = createMockLogger();
// Create temp directory for testing
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dexto-plan-create-test-'));
tempDir = await fs.realpath(rawTempDir);
planService = new PlanService({ basePath: tempDir }, mockLogger as any);
vi.clearAllMocks();
});
afterEach(async () => {
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('generatePreview', () => {
it('should return FileDisplayData for new plan', async () => {
const tool = createPlanCreateTool(planService);
const sessionId = 'test-session';
const content = '# Implementation Plan\n\n## Steps\n1. First step';
const preview = (await tool.generatePreview!(
{ title: 'Test Plan', content },
{ sessionId }
)) as FileDisplayData;
expect(preview.type).toBe('file');
expect(preview.operation).toBe('create');
// Path is now absolute, check it ends with the expected suffix
expect(preview.path).toContain(sessionId);
expect(preview.path).toMatch(/plan\.md$/);
expect(preview.content).toBe(content);
expect(preview.lineCount).toBe(4);
});
it('should throw error when sessionId is missing', async () => {
const tool = createPlanCreateTool(planService);
try {
await tool.generatePreview!({ title: 'Test', content: '# Plan' }, {});
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(PlanErrorCode.SESSION_ID_REQUIRED);
}
});
it('should throw error when plan already exists', async () => {
const tool = createPlanCreateTool(planService);
const sessionId = 'test-session';
// Create existing plan
await planService.create(sessionId, '# Existing Plan');
try {
await tool.generatePreview!(
{ title: 'New Plan', content: '# New Content' },
{ sessionId }
);
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(PlanErrorCode.PLAN_ALREADY_EXISTS);
}
});
});
describe('execute', () => {
it('should create plan and return success', async () => {
const tool = createPlanCreateTool(planService);
const sessionId = 'test-session';
const content = '# Implementation Plan';
const title = 'My Plan';
const result = (await tool.execute({ title, content }, { sessionId })) as {
success: boolean;
path: string;
status: string;
title: string;
};
expect(result.success).toBe(true);
// Path is now absolute, check it ends with the expected suffix
expect(result.path).toContain(sessionId);
expect(result.path).toMatch(/plan\.md$/);
expect(result.status).toBe('draft');
expect(result.title).toBe(title);
});
it('should throw error when sessionId is missing', async () => {
const tool = createPlanCreateTool(planService);
try {
await tool.execute({ title: 'Test', content: '# Plan' }, {});
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(PlanErrorCode.SESSION_ID_REQUIRED);
}
});
it('should include _display data in result', async () => {
const tool = createPlanCreateTool(planService);
const sessionId = 'test-session';
const content = '# Plan\n## Steps';
const result = (await tool.execute({ title: 'Plan', content }, { sessionId })) as {
_display: FileDisplayData;
};
expect(result._display).toBeDefined();
expect(result._display.type).toBe('file');
expect(result._display.operation).toBe('create');
expect(result._display.lineCount).toBe(2);
});
});
});

View File

@@ -0,0 +1,93 @@
/**
* Plan Create Tool
*
* Creates a new implementation plan for the current session.
* Shows a preview for approval before saving.
*/
import { z } from 'zod';
import type { InternalTool, ToolExecutionContext, FileDisplayData } from '@dexto/core';
import type { PlanService } from '../plan-service.js';
import { PlanError } from '../errors.js';
const PlanCreateInputSchema = z
.object({
title: z.string().describe('Plan title (e.g., "Add User Authentication")'),
content: z
.string()
.describe(
'Plan content in markdown format. Use - [ ] and - [x] for checkboxes to track progress.'
),
})
.strict();
type PlanCreateInput = z.input<typeof PlanCreateInputSchema>;
/**
* Creates the plan_create tool
*/
export function createPlanCreateTool(planService: PlanService): InternalTool {
return {
id: 'plan_create',
description:
'Create a new implementation plan for the current session. Shows the plan for approval before saving. Use markdown format for the plan content with clear steps and file references.',
inputSchema: PlanCreateInputSchema,
/**
* Generate preview for approval UI
*/
generatePreview: async (
input: unknown,
context?: ToolExecutionContext
): Promise<FileDisplayData> => {
const { content } = input as PlanCreateInput;
if (!context?.sessionId) {
throw PlanError.sessionIdRequired();
}
// Check if plan already exists
const exists = await planService.exists(context.sessionId);
if (exists) {
throw PlanError.planAlreadyExists(context.sessionId);
}
// Return preview for approval UI
const lineCount = content.split('\n').length;
const planPath = planService.getPlanPath(context.sessionId);
return {
type: 'file',
path: planPath,
operation: 'create',
content,
size: content.length,
lineCount,
};
},
execute: async (input: unknown, context?: ToolExecutionContext) => {
const { title, content } = input as PlanCreateInput;
if (!context?.sessionId) {
throw PlanError.sessionIdRequired();
}
const plan = await planService.create(context.sessionId, content, { title });
const planPath = planService.getPlanPath(context.sessionId);
return {
success: true,
path: planPath,
status: plan.meta.status,
title: plan.meta.title,
_display: {
type: 'file',
path: planPath,
operation: 'create',
size: content.length,
lineCount: content.split('\n').length,
} as FileDisplayData,
};
},
};
}

View File

@@ -0,0 +1,114 @@
/**
* Plan Read Tool Tests
*
* Tests for the plan_read tool.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { createPlanReadTool } from './plan-read-tool.js';
import { PlanService } from '../plan-service.js';
import { PlanErrorCode } from '../errors.js';
import { DextoRuntimeError } from '@dexto/core';
// Create mock logger
const createMockLogger = () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
createChild: vi.fn().mockReturnThis(),
});
describe('plan_read tool', () => {
let mockLogger: ReturnType<typeof createMockLogger>;
let tempDir: string;
let planService: PlanService;
beforeEach(async () => {
mockLogger = createMockLogger();
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dexto-plan-read-test-'));
tempDir = await fs.realpath(rawTempDir);
planService = new PlanService({ basePath: tempDir }, mockLogger as any);
vi.clearAllMocks();
});
afterEach(async () => {
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('execute', () => {
it('should return exists: false when no plan exists', async () => {
const tool = createPlanReadTool(planService);
const sessionId = 'test-session';
const result = (await tool.execute({}, { sessionId })) as {
exists: boolean;
message: string;
};
expect(result.exists).toBe(false);
expect(result.message).toContain('No plan found');
});
it('should return plan content and metadata when plan exists', async () => {
const tool = createPlanReadTool(planService);
const sessionId = 'test-session';
const content = '# My Plan\n\nSome content';
const title = 'My Plan Title';
await planService.create(sessionId, content, { title });
const result = (await tool.execute({}, { sessionId })) as {
exists: boolean;
content: string;
status: string;
title: string;
path: string;
};
expect(result.exists).toBe(true);
expect(result.content).toBe(content);
expect(result.status).toBe('draft');
expect(result.title).toBe(title);
expect(result.path).toBe(`.dexto/plans/${sessionId}/plan.md`);
});
it('should return ISO timestamps', async () => {
const tool = createPlanReadTool(planService);
const sessionId = 'test-session';
await planService.create(sessionId, '# Plan');
const result = (await tool.execute({}, { sessionId })) as {
createdAt: string;
updatedAt: string;
};
// Should be ISO format
expect(result.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(result.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it('should throw error when sessionId is missing', async () => {
const tool = createPlanReadTool(planService);
try {
await tool.execute({}, {});
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(PlanErrorCode.SESSION_ID_REQUIRED);
}
});
});
});

View File

@@ -0,0 +1,50 @@
/**
* Plan Read Tool
*
* Reads the current implementation plan for the session.
* No approval needed - read-only operation.
*/
import { z } from 'zod';
import type { InternalTool, ToolExecutionContext } from '@dexto/core';
import type { PlanService } from '../plan-service.js';
import { PlanError } from '../errors.js';
const PlanReadInputSchema = z.object({}).strict();
/**
* Creates the plan_read tool
*/
export function createPlanReadTool(planService: PlanService): InternalTool {
return {
id: 'plan_read',
description:
'Read the current implementation plan for this session. Returns the plan content and metadata including status. Use markdown checkboxes (- [ ] and - [x]) in the content to track progress.',
inputSchema: PlanReadInputSchema,
execute: async (_input: unknown, context?: ToolExecutionContext) => {
if (!context?.sessionId) {
throw PlanError.sessionIdRequired();
}
const plan = await planService.read(context.sessionId);
if (!plan) {
return {
exists: false,
message: `No plan found for this session. Use plan_create to create one.`,
};
}
return {
exists: true,
path: `.dexto/plans/${context.sessionId}/plan.md`,
content: plan.content,
status: plan.meta.status,
title: plan.meta.title,
createdAt: new Date(plan.meta.createdAt).toISOString(),
updatedAt: new Date(plan.meta.updatedAt).toISOString(),
};
},
};
}

View File

@@ -0,0 +1,104 @@
/**
* Plan Review Tool
*
* Requests user review of the current plan.
* Shows the plan content for review with approval options:
* - Approve: Proceed with implementation
* - Approve + Accept Edits: Proceed and auto-approve file edits
* - Request Changes: Provide feedback for iteration
* - Reject: Reject the plan entirely
*
* Uses the tool confirmation pattern (not elicitation) so the user
* can see the full plan content before deciding.
*/
import { z } from 'zod';
import type { InternalTool, ToolExecutionContext, FileDisplayData } from '@dexto/core';
import type { PlanService } from '../plan-service.js';
import { PlanError } from '../errors.js';
const PlanReviewInputSchema = z
.object({
summary: z
.string()
.optional()
.describe('Brief summary of the plan for context (shown above the plan content)'),
})
.strict();
type PlanReviewInput = z.input<typeof PlanReviewInputSchema>;
/**
* Creates the plan_review tool
*
* @param planService - Service for plan operations
*/
export function createPlanReviewTool(planService: PlanService): InternalTool {
return {
id: 'plan_review',
description:
'Request user review of the current plan. Shows the full plan content for review with options to approve, request changes, or reject. Use after creating or updating a plan to get user approval before implementation.',
inputSchema: PlanReviewInputSchema,
/**
* Generate preview showing the plan content for review.
* The ApprovalPrompt component detects plan_review and shows custom options.
*/
generatePreview: async (
input: unknown,
context?: ToolExecutionContext
): Promise<FileDisplayData> => {
const { summary } = input as PlanReviewInput;
if (!context?.sessionId) {
throw PlanError.sessionIdRequired();
}
// Read the current plan
const plan = await planService.read(context.sessionId);
if (!plan) {
throw PlanError.planNotFound(context.sessionId);
}
// Build content with optional summary header
let displayContent = plan.content;
if (summary) {
displayContent = `## Summary\n${summary}\n\n---\n\n${plan.content}`;
}
const lineCount = displayContent.split('\n').length;
const planPath = planService.getPlanPath(context.sessionId);
return {
type: 'file',
path: planPath,
operation: 'read', // 'read' indicates this is for viewing, not creating/modifying
content: displayContent,
size: Buffer.byteLength(displayContent, 'utf8'),
lineCount,
};
},
execute: async (_input: unknown, context?: ToolExecutionContext) => {
// Tool execution means user approved the plan (selected Approve or Approve + Accept Edits)
// Request Changes and Reject are handled as denials in the approval flow
if (!context?.sessionId) {
throw PlanError.sessionIdRequired();
}
// Read plan to verify it still exists
const plan = await planService.read(context.sessionId);
if (!plan) {
throw PlanError.planNotFound(context.sessionId);
}
// Update plan status to approved
await planService.updateMeta(context.sessionId, { status: 'approved' });
return {
approved: true,
message: 'Plan approved. You may now proceed with implementation.',
planStatus: 'approved',
};
},
};
}

View File

@@ -0,0 +1,196 @@
/**
* Plan Update Tool Tests
*
* Tests for the plan_update tool including diff preview generation.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { createPlanUpdateTool } from './plan-update-tool.js';
import { PlanService } from '../plan-service.js';
import { PlanErrorCode } from '../errors.js';
import { DextoRuntimeError } from '@dexto/core';
import type { DiffDisplayData } from '@dexto/core';
// Create mock logger
const createMockLogger = () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
createChild: vi.fn().mockReturnThis(),
});
describe('plan_update tool', () => {
let mockLogger: ReturnType<typeof createMockLogger>;
let tempDir: string;
let planService: PlanService;
beforeEach(async () => {
mockLogger = createMockLogger();
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dexto-plan-update-test-'));
tempDir = await fs.realpath(rawTempDir);
planService = new PlanService({ basePath: tempDir }, mockLogger as any);
vi.clearAllMocks();
});
afterEach(async () => {
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('generatePreview', () => {
it('should return DiffDisplayData with unified diff', async () => {
const tool = createPlanUpdateTool(planService);
const sessionId = 'test-session';
const originalContent = '# Plan\n\n## Steps\n1. First step';
const newContent = '# Plan\n\n## Steps\n1. First step\n2. Second step';
await planService.create(sessionId, originalContent);
const preview = (await tool.generatePreview!(
{ content: newContent },
{ sessionId }
)) as DiffDisplayData;
expect(preview.type).toBe('diff');
// Path is now absolute, check it ends with the expected suffix
expect(preview.filename).toContain(sessionId);
expect(preview.filename).toMatch(/plan\.md$/);
expect(preview.unified).toContain('-1. First step');
expect(preview.unified).toContain('+1. First step');
expect(preview.unified).toContain('+2. Second step');
expect(preview.additions).toBeGreaterThan(0);
});
it('should throw error when plan does not exist', async () => {
const tool = createPlanUpdateTool(planService);
const sessionId = 'test-session';
try {
await tool.generatePreview!({ content: '# New Content' }, { sessionId });
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(PlanErrorCode.PLAN_NOT_FOUND);
}
});
it('should throw error when sessionId is missing', async () => {
const tool = createPlanUpdateTool(planService);
try {
await tool.generatePreview!({ content: '# Content' }, {});
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(PlanErrorCode.SESSION_ID_REQUIRED);
}
});
it('should show deletions in diff', async () => {
const tool = createPlanUpdateTool(planService);
const sessionId = 'test-session';
const originalContent = '# Plan\n\nLine to remove\nKeep this';
const newContent = '# Plan\n\nKeep this';
await planService.create(sessionId, originalContent);
const preview = (await tool.generatePreview!(
{ content: newContent },
{ sessionId }
)) as DiffDisplayData;
expect(preview.deletions).toBeGreaterThan(0);
expect(preview.unified).toContain('-Line to remove');
});
});
describe('execute', () => {
it('should update plan content and return success', async () => {
const tool = createPlanUpdateTool(planService);
const sessionId = 'test-session';
const originalContent = '# Original Plan';
const newContent = '# Updated Plan';
await planService.create(sessionId, originalContent);
const result = (await tool.execute({ content: newContent }, { sessionId })) as {
success: boolean;
path: string;
status: string;
};
expect(result.success).toBe(true);
// Path is now absolute, check it ends with the expected suffix
expect(result.path).toContain(sessionId);
expect(result.path).toMatch(/plan\.md$/);
// Verify content was updated
const plan = await planService.read(sessionId);
expect(plan!.content).toBe(newContent);
});
it('should include _display data with diff', async () => {
const tool = createPlanUpdateTool(planService);
const sessionId = 'test-session';
await planService.create(sessionId, '# Original');
const result = (await tool.execute({ content: '# Updated' }, { sessionId })) as {
_display: DiffDisplayData;
};
expect(result._display).toBeDefined();
expect(result._display.type).toBe('diff');
expect(result._display.unified).toContain('-# Original');
expect(result._display.unified).toContain('+# Updated');
});
it('should throw error when plan does not exist', async () => {
const tool = createPlanUpdateTool(planService);
const sessionId = 'non-existent';
try {
await tool.execute({ content: '# Content' }, { sessionId });
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(PlanErrorCode.PLAN_NOT_FOUND);
}
});
it('should throw error when sessionId is missing', async () => {
const tool = createPlanUpdateTool(planService);
try {
await tool.execute({ content: '# Content' }, {});
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(DextoRuntimeError);
expect((error as DextoRuntimeError).code).toBe(PlanErrorCode.SESSION_ID_REQUIRED);
}
});
it('should preserve plan status after update', async () => {
const tool = createPlanUpdateTool(planService);
const sessionId = 'test-session';
await planService.create(sessionId, '# Plan');
await planService.updateMeta(sessionId, { status: 'approved' });
await tool.execute({ content: '# Updated Plan' }, { sessionId });
const plan = await planService.read(sessionId);
expect(plan!.meta.status).toBe('approved');
});
});
});

View File

@@ -0,0 +1,97 @@
/**
* Plan Update Tool
*
* Updates the implementation plan for the current session.
* Shows a diff preview for approval before saving.
*/
import { z } from 'zod';
import { createPatch } from 'diff';
import type { InternalTool, ToolExecutionContext, DiffDisplayData } from '@dexto/core';
import type { PlanService } from '../plan-service.js';
import { PlanError } from '../errors.js';
const PlanUpdateInputSchema = z
.object({
content: z.string().describe('Updated plan content in markdown format'),
})
.strict();
type PlanUpdateInput = z.input<typeof PlanUpdateInputSchema>;
/**
* Generate diff preview for plan update
*/
function generateDiffPreview(
filePath: string,
originalContent: string,
newContent: string
): DiffDisplayData {
const unified = createPatch(filePath, originalContent, newContent, 'before', 'after', {
context: 3,
});
const additions = (unified.match(/^\+[^+]/gm) || []).length;
const deletions = (unified.match(/^-[^-]/gm) || []).length;
return {
type: 'diff',
unified,
filename: filePath,
additions,
deletions,
};
}
/**
* Creates the plan_update tool
*/
export function createPlanUpdateTool(planService: PlanService): InternalTool {
return {
id: 'plan_update',
description:
'Update the existing implementation plan for this session. Shows a diff preview for approval before saving. The plan must already exist (use plan_create first).',
inputSchema: PlanUpdateInputSchema,
/**
* Generate diff preview for approval UI
*/
generatePreview: async (
input: unknown,
context?: ToolExecutionContext
): Promise<DiffDisplayData> => {
const { content: newContent } = input as PlanUpdateInput;
if (!context?.sessionId) {
throw PlanError.sessionIdRequired();
}
// Read existing plan
const existing = await planService.read(context.sessionId);
if (!existing) {
throw PlanError.planNotFound(context.sessionId);
}
// Generate diff preview
const planPath = planService.getPlanPath(context.sessionId);
return generateDiffPreview(planPath, existing.content, newContent);
},
execute: async (input: unknown, context?: ToolExecutionContext) => {
const { content } = input as PlanUpdateInput;
if (!context?.sessionId) {
throw PlanError.sessionIdRequired();
}
const result = await planService.update(context.sessionId, content);
const planPath = planService.getPlanPath(context.sessionId);
return {
success: true,
path: planPath,
status: result.meta.status,
_display: generateDiffPreview(planPath, result.oldContent, result.newContent),
};
},
};
}

View File

@@ -0,0 +1,58 @@
/**
* Plan Types and Schemas
*
* Defines the structure of plans and their metadata.
*/
import { z } from 'zod';
/**
* Plan status values
*/
export const PlanStatusSchema = z.enum([
'draft',
'approved',
'in_progress',
'completed',
'abandoned',
]);
export type PlanStatus = z.infer<typeof PlanStatusSchema>;
/**
* Plan metadata stored alongside the plan content
*/
export const PlanMetaSchema = z.object({
sessionId: z.string().describe('Session ID this plan belongs to'),
status: PlanStatusSchema.default('draft').describe('Current plan status'),
title: z.string().optional().describe('Plan title'),
createdAt: z.number().describe('Unix timestamp when plan was created'),
updatedAt: z.number().describe('Unix timestamp when plan was last updated'),
});
export type PlanMeta = z.infer<typeof PlanMetaSchema>;
/**
* Complete plan with content and metadata
*/
export interface Plan {
content: string;
meta: PlanMeta;
}
/**
* Options for the plan service
*/
export interface PlanServiceOptions {
/** Base directory for plan storage */
basePath: string;
}
/**
* Result of a plan update operation
*/
export interface PlanUpdateResult {
oldContent: string;
newContent: string;
meta: PlanMeta;
}

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
"declaration": true,
"declarationMap": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,5 @@
import { config } from 'dotenv';
// Load .env file for integration tests
// This ensures environment variables are available during test execution
config();