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:
7
dexto/packages/tools-plan/.dexto-plugin/plugin.json
Normal file
7
dexto/packages/tools-plan/.dexto-plugin/plugin.json
Normal 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"]
|
||||
}
|
||||
40
dexto/packages/tools-plan/package.json
Normal file
40
dexto/packages/tools-plan/package.json
Normal 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"
|
||||
}
|
||||
102
dexto/packages/tools-plan/skills/plan/SKILL.md
Normal file
102
dexto/packages/tools-plan/skills/plan/SKILL.md
Normal 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
|
||||
118
dexto/packages/tools-plan/src/errors.ts
Normal file
118
dexto/packages/tools-plan/src/errors.ts
Normal 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.'
|
||||
);
|
||||
},
|
||||
};
|
||||
48
dexto/packages/tools-plan/src/index.ts
Normal file
48
dexto/packages/tools-plan/src/index.ts
Normal 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';
|
||||
266
dexto/packages/tools-plan/src/plan-service.test.ts
Normal file
266
dexto/packages/tools-plan/src/plan-service.test.ts
Normal 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
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
273
dexto/packages/tools-plan/src/plan-service.ts
Normal file
273
dexto/packages/tools-plan/src/plan-service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
234
dexto/packages/tools-plan/src/tool-provider.test.ts
Normal file
234
dexto/packages/tools-plan/src/tool-provider.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
100
dexto/packages/tools-plan/src/tool-provider.ts
Normal file
100
dexto/packages/tools-plan/src/tool-provider.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
152
dexto/packages/tools-plan/src/tools/plan-create-tool.test.ts
Normal file
152
dexto/packages/tools-plan/src/tools/plan-create-tool.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
93
dexto/packages/tools-plan/src/tools/plan-create-tool.ts
Normal file
93
dexto/packages/tools-plan/src/tools/plan-create-tool.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
114
dexto/packages/tools-plan/src/tools/plan-read-tool.test.ts
Normal file
114
dexto/packages/tools-plan/src/tools/plan-read-tool.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
50
dexto/packages/tools-plan/src/tools/plan-read-tool.ts
Normal file
50
dexto/packages/tools-plan/src/tools/plan-read-tool.ts
Normal 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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
104
dexto/packages/tools-plan/src/tools/plan-review-tool.ts
Normal file
104
dexto/packages/tools-plan/src/tools/plan-review-tool.ts
Normal 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',
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
196
dexto/packages/tools-plan/src/tools/plan-update-tool.test.ts
Normal file
196
dexto/packages/tools-plan/src/tools/plan-update-tool.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
97
dexto/packages/tools-plan/src/tools/plan-update-tool.ts
Normal file
97
dexto/packages/tools-plan/src/tools/plan-update-tool.ts
Normal 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),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
58
dexto/packages/tools-plan/src/types.ts
Normal file
58
dexto/packages/tools-plan/src/types.ts
Normal 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;
|
||||
}
|
||||
14
dexto/packages/tools-plan/tsconfig.json
Normal file
14
dexto/packages/tools-plan/tsconfig.json
Normal 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"]
|
||||
}
|
||||
5
dexto/packages/tools-plan/vitest.setup.ts
Normal file
5
dexto/packages/tools-plan/vitest.setup.ts
Normal 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();
|
||||
Reference in New Issue
Block a user