@dexto/tools-plan 1.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dexto-plugin/plugin.json +7 -0
- package/LICENSE +44 -0
- package/dist/errors.d.ts +56 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +66 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/plan-service.d.ts +77 -0
- package/dist/plan-service.d.ts.map +1 -0
- package/dist/plan-service.js +227 -0
- package/dist/plan-service.test.d.ts +7 -0
- package/dist/plan-service.test.d.ts.map +1 -0
- package/dist/plan-service.test.js +220 -0
- package/dist/tool-provider.d.ts +44 -0
- package/dist/tool-provider.d.ts.map +1 -0
- package/dist/tool-provider.js +81 -0
- package/dist/tool-provider.test.d.ts +7 -0
- package/dist/tool-provider.test.d.ts.map +1 -0
- package/dist/tool-provider.test.js +185 -0
- package/dist/tools/plan-create-tool.d.ts +13 -0
- package/dist/tools/plan-create-tool.d.ts.map +1 -0
- package/dist/tools/plan-create-tool.js +72 -0
- package/dist/tools/plan-create-tool.test.d.ts +7 -0
- package/dist/tools/plan-create-tool.test.d.ts.map +1 -0
- package/dist/tools/plan-create-tool.test.js +118 -0
- package/dist/tools/plan-read-tool.d.ts +13 -0
- package/dist/tools/plan-read-tool.d.ts.map +1 -0
- package/dist/tools/plan-read-tool.js +40 -0
- package/dist/tools/plan-read-tool.test.d.ts +7 -0
- package/dist/tools/plan-read-tool.test.d.ts.map +1 -0
- package/dist/tools/plan-read-tool.test.js +83 -0
- package/dist/tools/plan-review-tool.d.ts +22 -0
- package/dist/tools/plan-review-tool.d.ts.map +1 -0
- package/dist/tools/plan-review-tool.js +84 -0
- package/dist/tools/plan-update-tool.d.ts +13 -0
- package/dist/tools/plan-update-tool.d.ts.map +1 -0
- package/dist/tools/plan-update-tool.js +72 -0
- package/dist/tools/plan-update-tool.test.d.ts +7 -0
- package/dist/tools/plan-update-tool.test.d.ts.map +1 -0
- package/dist/tools/plan-update-tool.test.js +151 -0
- package/dist/types.d.ts +57 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +26 -0
- package/package.json +40 -0
- package/skills/plan/SKILL.md +102 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan Create Tool Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the plan_create tool including preview generation.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import * as fs from 'node:fs/promises';
|
|
9
|
+
import * as os from 'node:os';
|
|
10
|
+
import { createPlanCreateTool } from './plan-create-tool.js';
|
|
11
|
+
import { PlanService } from '../plan-service.js';
|
|
12
|
+
import { PlanErrorCode } from '../errors.js';
|
|
13
|
+
import { DextoRuntimeError } from '@dexto/core';
|
|
14
|
+
// Create mock logger
|
|
15
|
+
const createMockLogger = () => ({
|
|
16
|
+
debug: vi.fn(),
|
|
17
|
+
info: vi.fn(),
|
|
18
|
+
warn: vi.fn(),
|
|
19
|
+
error: vi.fn(),
|
|
20
|
+
createChild: vi.fn().mockReturnThis(),
|
|
21
|
+
});
|
|
22
|
+
describe('plan_create tool', () => {
|
|
23
|
+
let mockLogger;
|
|
24
|
+
let tempDir;
|
|
25
|
+
let planService;
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
mockLogger = createMockLogger();
|
|
28
|
+
// Create temp directory for testing
|
|
29
|
+
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dexto-plan-create-test-'));
|
|
30
|
+
tempDir = await fs.realpath(rawTempDir);
|
|
31
|
+
planService = new PlanService({ basePath: tempDir }, mockLogger);
|
|
32
|
+
vi.clearAllMocks();
|
|
33
|
+
});
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
try {
|
|
36
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Ignore cleanup errors
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
describe('generatePreview', () => {
|
|
43
|
+
it('should return FileDisplayData for new plan', async () => {
|
|
44
|
+
const tool = createPlanCreateTool(planService);
|
|
45
|
+
const sessionId = 'test-session';
|
|
46
|
+
const content = '# Implementation Plan\n\n## Steps\n1. First step';
|
|
47
|
+
const preview = (await tool.generatePreview({ title: 'Test Plan', content }, { sessionId }));
|
|
48
|
+
expect(preview.type).toBe('file');
|
|
49
|
+
expect(preview.operation).toBe('create');
|
|
50
|
+
// Path is now absolute, check it ends with the expected suffix
|
|
51
|
+
expect(preview.path).toContain(sessionId);
|
|
52
|
+
expect(preview.path).toMatch(/plan\.md$/);
|
|
53
|
+
expect(preview.content).toBe(content);
|
|
54
|
+
expect(preview.lineCount).toBe(4);
|
|
55
|
+
});
|
|
56
|
+
it('should throw error when sessionId is missing', async () => {
|
|
57
|
+
const tool = createPlanCreateTool(planService);
|
|
58
|
+
try {
|
|
59
|
+
await tool.generatePreview({ title: 'Test', content: '# Plan' }, {});
|
|
60
|
+
expect.fail('Should have thrown an error');
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
expect(error).toBeInstanceOf(DextoRuntimeError);
|
|
64
|
+
expect(error.code).toBe(PlanErrorCode.SESSION_ID_REQUIRED);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
it('should throw error when plan already exists', async () => {
|
|
68
|
+
const tool = createPlanCreateTool(planService);
|
|
69
|
+
const sessionId = 'test-session';
|
|
70
|
+
// Create existing plan
|
|
71
|
+
await planService.create(sessionId, '# Existing Plan');
|
|
72
|
+
try {
|
|
73
|
+
await tool.generatePreview({ title: 'New Plan', content: '# New Content' }, { sessionId });
|
|
74
|
+
expect.fail('Should have thrown an error');
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
expect(error).toBeInstanceOf(DextoRuntimeError);
|
|
78
|
+
expect(error.code).toBe(PlanErrorCode.PLAN_ALREADY_EXISTS);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
describe('execute', () => {
|
|
83
|
+
it('should create plan and return success', async () => {
|
|
84
|
+
const tool = createPlanCreateTool(planService);
|
|
85
|
+
const sessionId = 'test-session';
|
|
86
|
+
const content = '# Implementation Plan';
|
|
87
|
+
const title = 'My Plan';
|
|
88
|
+
const result = (await tool.execute({ title, content }, { sessionId }));
|
|
89
|
+
expect(result.success).toBe(true);
|
|
90
|
+
// Path is now absolute, check it ends with the expected suffix
|
|
91
|
+
expect(result.path).toContain(sessionId);
|
|
92
|
+
expect(result.path).toMatch(/plan\.md$/);
|
|
93
|
+
expect(result.status).toBe('draft');
|
|
94
|
+
expect(result.title).toBe(title);
|
|
95
|
+
});
|
|
96
|
+
it('should throw error when sessionId is missing', async () => {
|
|
97
|
+
const tool = createPlanCreateTool(planService);
|
|
98
|
+
try {
|
|
99
|
+
await tool.execute({ title: 'Test', content: '# Plan' }, {});
|
|
100
|
+
expect.fail('Should have thrown an error');
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
expect(error).toBeInstanceOf(DextoRuntimeError);
|
|
104
|
+
expect(error.code).toBe(PlanErrorCode.SESSION_ID_REQUIRED);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
it('should include _display data in result', async () => {
|
|
108
|
+
const tool = createPlanCreateTool(planService);
|
|
109
|
+
const sessionId = 'test-session';
|
|
110
|
+
const content = '# Plan\n## Steps';
|
|
111
|
+
const result = (await tool.execute({ title: 'Plan', content }, { sessionId }));
|
|
112
|
+
expect(result._display).toBeDefined();
|
|
113
|
+
expect(result._display.type).toBe('file');
|
|
114
|
+
expect(result._display.operation).toBe('create');
|
|
115
|
+
expect(result._display.lineCount).toBe(2);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan Read Tool
|
|
3
|
+
*
|
|
4
|
+
* Reads the current implementation plan for the session.
|
|
5
|
+
* No approval needed - read-only operation.
|
|
6
|
+
*/
|
|
7
|
+
import type { InternalTool } from '@dexto/core';
|
|
8
|
+
import type { PlanService } from '../plan-service.js';
|
|
9
|
+
/**
|
|
10
|
+
* Creates the plan_read tool
|
|
11
|
+
*/
|
|
12
|
+
export declare function createPlanReadTool(planService: PlanService): InternalTool;
|
|
13
|
+
//# sourceMappingURL=plan-read-tool.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan-read-tool.d.ts","sourceRoot":"","sources":["../../src/tools/plan-read-tool.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAwB,MAAM,aAAa,CAAC;AACtE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAKtD;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,WAAW,GAAG,YAAY,CAgCzE"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan Read Tool
|
|
3
|
+
*
|
|
4
|
+
* Reads the current implementation plan for the session.
|
|
5
|
+
* No approval needed - read-only operation.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { PlanError } from '../errors.js';
|
|
9
|
+
const PlanReadInputSchema = z.object({}).strict();
|
|
10
|
+
/**
|
|
11
|
+
* Creates the plan_read tool
|
|
12
|
+
*/
|
|
13
|
+
export function createPlanReadTool(planService) {
|
|
14
|
+
return {
|
|
15
|
+
id: 'plan_read',
|
|
16
|
+
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.',
|
|
17
|
+
inputSchema: PlanReadInputSchema,
|
|
18
|
+
execute: async (_input, context) => {
|
|
19
|
+
if (!context?.sessionId) {
|
|
20
|
+
throw PlanError.sessionIdRequired();
|
|
21
|
+
}
|
|
22
|
+
const plan = await planService.read(context.sessionId);
|
|
23
|
+
if (!plan) {
|
|
24
|
+
return {
|
|
25
|
+
exists: false,
|
|
26
|
+
message: `No plan found for this session. Use plan_create to create one.`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
exists: true,
|
|
31
|
+
path: `.dexto/plans/${context.sessionId}/plan.md`,
|
|
32
|
+
content: plan.content,
|
|
33
|
+
status: plan.meta.status,
|
|
34
|
+
title: plan.meta.title,
|
|
35
|
+
createdAt: new Date(plan.meta.createdAt).toISOString(),
|
|
36
|
+
updatedAt: new Date(plan.meta.updatedAt).toISOString(),
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan-read-tool.test.d.ts","sourceRoot":"","sources":["../../src/tools/plan-read-tool.test.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan Read Tool Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the plan_read tool.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import * as fs from 'node:fs/promises';
|
|
9
|
+
import * as os from 'node:os';
|
|
10
|
+
import { createPlanReadTool } from './plan-read-tool.js';
|
|
11
|
+
import { PlanService } from '../plan-service.js';
|
|
12
|
+
import { PlanErrorCode } from '../errors.js';
|
|
13
|
+
import { DextoRuntimeError } from '@dexto/core';
|
|
14
|
+
// Create mock logger
|
|
15
|
+
const createMockLogger = () => ({
|
|
16
|
+
debug: vi.fn(),
|
|
17
|
+
info: vi.fn(),
|
|
18
|
+
warn: vi.fn(),
|
|
19
|
+
error: vi.fn(),
|
|
20
|
+
createChild: vi.fn().mockReturnThis(),
|
|
21
|
+
});
|
|
22
|
+
describe('plan_read tool', () => {
|
|
23
|
+
let mockLogger;
|
|
24
|
+
let tempDir;
|
|
25
|
+
let planService;
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
mockLogger = createMockLogger();
|
|
28
|
+
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dexto-plan-read-test-'));
|
|
29
|
+
tempDir = await fs.realpath(rawTempDir);
|
|
30
|
+
planService = new PlanService({ basePath: tempDir }, mockLogger);
|
|
31
|
+
vi.clearAllMocks();
|
|
32
|
+
});
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
try {
|
|
35
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Ignore cleanup errors
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
describe('execute', () => {
|
|
42
|
+
it('should return exists: false when no plan exists', async () => {
|
|
43
|
+
const tool = createPlanReadTool(planService);
|
|
44
|
+
const sessionId = 'test-session';
|
|
45
|
+
const result = (await tool.execute({}, { sessionId }));
|
|
46
|
+
expect(result.exists).toBe(false);
|
|
47
|
+
expect(result.message).toContain('No plan found');
|
|
48
|
+
});
|
|
49
|
+
it('should return plan content and metadata when plan exists', async () => {
|
|
50
|
+
const tool = createPlanReadTool(planService);
|
|
51
|
+
const sessionId = 'test-session';
|
|
52
|
+
const content = '# My Plan\n\nSome content';
|
|
53
|
+
const title = 'My Plan Title';
|
|
54
|
+
await planService.create(sessionId, content, { title });
|
|
55
|
+
const result = (await tool.execute({}, { sessionId }));
|
|
56
|
+
expect(result.exists).toBe(true);
|
|
57
|
+
expect(result.content).toBe(content);
|
|
58
|
+
expect(result.status).toBe('draft');
|
|
59
|
+
expect(result.title).toBe(title);
|
|
60
|
+
expect(result.path).toBe(`.dexto/plans/${sessionId}/plan.md`);
|
|
61
|
+
});
|
|
62
|
+
it('should return ISO timestamps', async () => {
|
|
63
|
+
const tool = createPlanReadTool(planService);
|
|
64
|
+
const sessionId = 'test-session';
|
|
65
|
+
await planService.create(sessionId, '# Plan');
|
|
66
|
+
const result = (await tool.execute({}, { sessionId }));
|
|
67
|
+
// Should be ISO format
|
|
68
|
+
expect(result.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
69
|
+
expect(result.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
70
|
+
});
|
|
71
|
+
it('should throw error when sessionId is missing', async () => {
|
|
72
|
+
const tool = createPlanReadTool(planService);
|
|
73
|
+
try {
|
|
74
|
+
await tool.execute({}, {});
|
|
75
|
+
expect.fail('Should have thrown an error');
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
expect(error).toBeInstanceOf(DextoRuntimeError);
|
|
79
|
+
expect(error.code).toBe(PlanErrorCode.SESSION_ID_REQUIRED);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan Review Tool
|
|
3
|
+
*
|
|
4
|
+
* Requests user review of the current plan.
|
|
5
|
+
* Shows the plan content for review with approval options:
|
|
6
|
+
* - Approve: Proceed with implementation
|
|
7
|
+
* - Approve + Accept Edits: Proceed and auto-approve file edits
|
|
8
|
+
* - Request Changes: Provide feedback for iteration
|
|
9
|
+
* - Reject: Reject the plan entirely
|
|
10
|
+
*
|
|
11
|
+
* Uses the tool confirmation pattern (not elicitation) so the user
|
|
12
|
+
* can see the full plan content before deciding.
|
|
13
|
+
*/
|
|
14
|
+
import type { InternalTool } from '@dexto/core';
|
|
15
|
+
import type { PlanService } from '../plan-service.js';
|
|
16
|
+
/**
|
|
17
|
+
* Creates the plan_review tool
|
|
18
|
+
*
|
|
19
|
+
* @param planService - Service for plan operations
|
|
20
|
+
*/
|
|
21
|
+
export declare function createPlanReviewTool(planService: PlanService): InternalTool;
|
|
22
|
+
//# sourceMappingURL=plan-review-tool.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan-review-tool.d.ts","sourceRoot":"","sources":["../../src/tools/plan-review-tool.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAyC,MAAM,aAAa,CAAC;AACvF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AActD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,WAAW,GAAG,YAAY,CAoE3E"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan Review Tool
|
|
3
|
+
*
|
|
4
|
+
* Requests user review of the current plan.
|
|
5
|
+
* Shows the plan content for review with approval options:
|
|
6
|
+
* - Approve: Proceed with implementation
|
|
7
|
+
* - Approve + Accept Edits: Proceed and auto-approve file edits
|
|
8
|
+
* - Request Changes: Provide feedback for iteration
|
|
9
|
+
* - Reject: Reject the plan entirely
|
|
10
|
+
*
|
|
11
|
+
* Uses the tool confirmation pattern (not elicitation) so the user
|
|
12
|
+
* can see the full plan content before deciding.
|
|
13
|
+
*/
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { PlanError } from '../errors.js';
|
|
16
|
+
const PlanReviewInputSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
summary: z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('Brief summary of the plan for context (shown above the plan content)'),
|
|
22
|
+
})
|
|
23
|
+
.strict();
|
|
24
|
+
/**
|
|
25
|
+
* Creates the plan_review tool
|
|
26
|
+
*
|
|
27
|
+
* @param planService - Service for plan operations
|
|
28
|
+
*/
|
|
29
|
+
export function createPlanReviewTool(planService) {
|
|
30
|
+
return {
|
|
31
|
+
id: 'plan_review',
|
|
32
|
+
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.',
|
|
33
|
+
inputSchema: PlanReviewInputSchema,
|
|
34
|
+
/**
|
|
35
|
+
* Generate preview showing the plan content for review.
|
|
36
|
+
* The ApprovalPrompt component detects plan_review and shows custom options.
|
|
37
|
+
*/
|
|
38
|
+
generatePreview: async (input, context) => {
|
|
39
|
+
const { summary } = input;
|
|
40
|
+
if (!context?.sessionId) {
|
|
41
|
+
throw PlanError.sessionIdRequired();
|
|
42
|
+
}
|
|
43
|
+
// Read the current plan
|
|
44
|
+
const plan = await planService.read(context.sessionId);
|
|
45
|
+
if (!plan) {
|
|
46
|
+
throw PlanError.planNotFound(context.sessionId);
|
|
47
|
+
}
|
|
48
|
+
// Build content with optional summary header
|
|
49
|
+
let displayContent = plan.content;
|
|
50
|
+
if (summary) {
|
|
51
|
+
displayContent = `## Summary\n${summary}\n\n---\n\n${plan.content}`;
|
|
52
|
+
}
|
|
53
|
+
const lineCount = displayContent.split('\n').length;
|
|
54
|
+
const planPath = planService.getPlanPath(context.sessionId);
|
|
55
|
+
return {
|
|
56
|
+
type: 'file',
|
|
57
|
+
path: planPath,
|
|
58
|
+
operation: 'read', // 'read' indicates this is for viewing, not creating/modifying
|
|
59
|
+
content: displayContent,
|
|
60
|
+
size: Buffer.byteLength(displayContent, 'utf8'),
|
|
61
|
+
lineCount,
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
execute: async (_input, context) => {
|
|
65
|
+
// Tool execution means user approved the plan (selected Approve or Approve + Accept Edits)
|
|
66
|
+
// Request Changes and Reject are handled as denials in the approval flow
|
|
67
|
+
if (!context?.sessionId) {
|
|
68
|
+
throw PlanError.sessionIdRequired();
|
|
69
|
+
}
|
|
70
|
+
// Read plan to verify it still exists
|
|
71
|
+
const plan = await planService.read(context.sessionId);
|
|
72
|
+
if (!plan) {
|
|
73
|
+
throw PlanError.planNotFound(context.sessionId);
|
|
74
|
+
}
|
|
75
|
+
// Update plan status to approved
|
|
76
|
+
await planService.updateMeta(context.sessionId, { status: 'approved' });
|
|
77
|
+
return {
|
|
78
|
+
approved: true,
|
|
79
|
+
message: 'Plan approved. You may now proceed with implementation.',
|
|
80
|
+
planStatus: 'approved',
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan Update Tool
|
|
3
|
+
*
|
|
4
|
+
* Updates the implementation plan for the current session.
|
|
5
|
+
* Shows a diff preview for approval before saving.
|
|
6
|
+
*/
|
|
7
|
+
import type { InternalTool } from '@dexto/core';
|
|
8
|
+
import type { PlanService } from '../plan-service.js';
|
|
9
|
+
/**
|
|
10
|
+
* Creates the plan_update tool
|
|
11
|
+
*/
|
|
12
|
+
export declare function createPlanUpdateTool(planService: PlanService): InternalTool;
|
|
13
|
+
//# sourceMappingURL=plan-update-tool.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan-update-tool.d.ts","sourceRoot":"","sources":["../../src/tools/plan-update-tool.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAyC,MAAM,aAAa,CAAC;AACvF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAkCtD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,WAAW,GAAG,YAAY,CAiD3E"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan Update Tool
|
|
3
|
+
*
|
|
4
|
+
* Updates the implementation plan for the current session.
|
|
5
|
+
* Shows a diff preview for approval before saving.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { createPatch } from 'diff';
|
|
9
|
+
import { PlanError } from '../errors.js';
|
|
10
|
+
const PlanUpdateInputSchema = z
|
|
11
|
+
.object({
|
|
12
|
+
content: z.string().describe('Updated plan content in markdown format'),
|
|
13
|
+
})
|
|
14
|
+
.strict();
|
|
15
|
+
/**
|
|
16
|
+
* Generate diff preview for plan update
|
|
17
|
+
*/
|
|
18
|
+
function generateDiffPreview(filePath, originalContent, newContent) {
|
|
19
|
+
const unified = createPatch(filePath, originalContent, newContent, 'before', 'after', {
|
|
20
|
+
context: 3,
|
|
21
|
+
});
|
|
22
|
+
const additions = (unified.match(/^\+[^+]/gm) || []).length;
|
|
23
|
+
const deletions = (unified.match(/^-[^-]/gm) || []).length;
|
|
24
|
+
return {
|
|
25
|
+
type: 'diff',
|
|
26
|
+
unified,
|
|
27
|
+
filename: filePath,
|
|
28
|
+
additions,
|
|
29
|
+
deletions,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Creates the plan_update tool
|
|
34
|
+
*/
|
|
35
|
+
export function createPlanUpdateTool(planService) {
|
|
36
|
+
return {
|
|
37
|
+
id: 'plan_update',
|
|
38
|
+
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).',
|
|
39
|
+
inputSchema: PlanUpdateInputSchema,
|
|
40
|
+
/**
|
|
41
|
+
* Generate diff preview for approval UI
|
|
42
|
+
*/
|
|
43
|
+
generatePreview: async (input, context) => {
|
|
44
|
+
const { content: newContent } = input;
|
|
45
|
+
if (!context?.sessionId) {
|
|
46
|
+
throw PlanError.sessionIdRequired();
|
|
47
|
+
}
|
|
48
|
+
// Read existing plan
|
|
49
|
+
const existing = await planService.read(context.sessionId);
|
|
50
|
+
if (!existing) {
|
|
51
|
+
throw PlanError.planNotFound(context.sessionId);
|
|
52
|
+
}
|
|
53
|
+
// Generate diff preview
|
|
54
|
+
const planPath = planService.getPlanPath(context.sessionId);
|
|
55
|
+
return generateDiffPreview(planPath, existing.content, newContent);
|
|
56
|
+
},
|
|
57
|
+
execute: async (input, context) => {
|
|
58
|
+
const { content } = input;
|
|
59
|
+
if (!context?.sessionId) {
|
|
60
|
+
throw PlanError.sessionIdRequired();
|
|
61
|
+
}
|
|
62
|
+
const result = await planService.update(context.sessionId, content);
|
|
63
|
+
const planPath = planService.getPlanPath(context.sessionId);
|
|
64
|
+
return {
|
|
65
|
+
success: true,
|
|
66
|
+
path: planPath,
|
|
67
|
+
status: result.meta.status,
|
|
68
|
+
_display: generateDiffPreview(planPath, result.oldContent, result.newContent),
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan-update-tool.test.d.ts","sourceRoot":"","sources":["../../src/tools/plan-update-tool.test.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan Update Tool Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the plan_update tool including diff preview generation.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import * as fs from 'node:fs/promises';
|
|
9
|
+
import * as os from 'node:os';
|
|
10
|
+
import { createPlanUpdateTool } from './plan-update-tool.js';
|
|
11
|
+
import { PlanService } from '../plan-service.js';
|
|
12
|
+
import { PlanErrorCode } from '../errors.js';
|
|
13
|
+
import { DextoRuntimeError } from '@dexto/core';
|
|
14
|
+
// Create mock logger
|
|
15
|
+
const createMockLogger = () => ({
|
|
16
|
+
debug: vi.fn(),
|
|
17
|
+
info: vi.fn(),
|
|
18
|
+
warn: vi.fn(),
|
|
19
|
+
error: vi.fn(),
|
|
20
|
+
createChild: vi.fn().mockReturnThis(),
|
|
21
|
+
});
|
|
22
|
+
describe('plan_update tool', () => {
|
|
23
|
+
let mockLogger;
|
|
24
|
+
let tempDir;
|
|
25
|
+
let planService;
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
mockLogger = createMockLogger();
|
|
28
|
+
const rawTempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dexto-plan-update-test-'));
|
|
29
|
+
tempDir = await fs.realpath(rawTempDir);
|
|
30
|
+
planService = new PlanService({ basePath: tempDir }, mockLogger);
|
|
31
|
+
vi.clearAllMocks();
|
|
32
|
+
});
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
try {
|
|
35
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Ignore cleanup errors
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
describe('generatePreview', () => {
|
|
42
|
+
it('should return DiffDisplayData with unified diff', async () => {
|
|
43
|
+
const tool = createPlanUpdateTool(planService);
|
|
44
|
+
const sessionId = 'test-session';
|
|
45
|
+
const originalContent = '# Plan\n\n## Steps\n1. First step';
|
|
46
|
+
const newContent = '# Plan\n\n## Steps\n1. First step\n2. Second step';
|
|
47
|
+
await planService.create(sessionId, originalContent);
|
|
48
|
+
const preview = (await tool.generatePreview({ content: newContent }, { sessionId }));
|
|
49
|
+
expect(preview.type).toBe('diff');
|
|
50
|
+
// Path is now absolute, check it ends with the expected suffix
|
|
51
|
+
expect(preview.filename).toContain(sessionId);
|
|
52
|
+
expect(preview.filename).toMatch(/plan\.md$/);
|
|
53
|
+
expect(preview.unified).toContain('-1. First step');
|
|
54
|
+
expect(preview.unified).toContain('+1. First step');
|
|
55
|
+
expect(preview.unified).toContain('+2. Second step');
|
|
56
|
+
expect(preview.additions).toBeGreaterThan(0);
|
|
57
|
+
});
|
|
58
|
+
it('should throw error when plan does not exist', async () => {
|
|
59
|
+
const tool = createPlanUpdateTool(planService);
|
|
60
|
+
const sessionId = 'test-session';
|
|
61
|
+
try {
|
|
62
|
+
await tool.generatePreview({ content: '# New Content' }, { sessionId });
|
|
63
|
+
expect.fail('Should have thrown an error');
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
expect(error).toBeInstanceOf(DextoRuntimeError);
|
|
67
|
+
expect(error.code).toBe(PlanErrorCode.PLAN_NOT_FOUND);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
it('should throw error when sessionId is missing', async () => {
|
|
71
|
+
const tool = createPlanUpdateTool(planService);
|
|
72
|
+
try {
|
|
73
|
+
await tool.generatePreview({ content: '# Content' }, {});
|
|
74
|
+
expect.fail('Should have thrown an error');
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
expect(error).toBeInstanceOf(DextoRuntimeError);
|
|
78
|
+
expect(error.code).toBe(PlanErrorCode.SESSION_ID_REQUIRED);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
it('should show deletions in diff', async () => {
|
|
82
|
+
const tool = createPlanUpdateTool(planService);
|
|
83
|
+
const sessionId = 'test-session';
|
|
84
|
+
const originalContent = '# Plan\n\nLine to remove\nKeep this';
|
|
85
|
+
const newContent = '# Plan\n\nKeep this';
|
|
86
|
+
await planService.create(sessionId, originalContent);
|
|
87
|
+
const preview = (await tool.generatePreview({ content: newContent }, { sessionId }));
|
|
88
|
+
expect(preview.deletions).toBeGreaterThan(0);
|
|
89
|
+
expect(preview.unified).toContain('-Line to remove');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('execute', () => {
|
|
93
|
+
it('should update plan content and return success', async () => {
|
|
94
|
+
const tool = createPlanUpdateTool(planService);
|
|
95
|
+
const sessionId = 'test-session';
|
|
96
|
+
const originalContent = '# Original Plan';
|
|
97
|
+
const newContent = '# Updated Plan';
|
|
98
|
+
await planService.create(sessionId, originalContent);
|
|
99
|
+
const result = (await tool.execute({ content: newContent }, { sessionId }));
|
|
100
|
+
expect(result.success).toBe(true);
|
|
101
|
+
// Path is now absolute, check it ends with the expected suffix
|
|
102
|
+
expect(result.path).toContain(sessionId);
|
|
103
|
+
expect(result.path).toMatch(/plan\.md$/);
|
|
104
|
+
// Verify content was updated
|
|
105
|
+
const plan = await planService.read(sessionId);
|
|
106
|
+
expect(plan.content).toBe(newContent);
|
|
107
|
+
});
|
|
108
|
+
it('should include _display data with diff', async () => {
|
|
109
|
+
const tool = createPlanUpdateTool(planService);
|
|
110
|
+
const sessionId = 'test-session';
|
|
111
|
+
await planService.create(sessionId, '# Original');
|
|
112
|
+
const result = (await tool.execute({ content: '# Updated' }, { sessionId }));
|
|
113
|
+
expect(result._display).toBeDefined();
|
|
114
|
+
expect(result._display.type).toBe('diff');
|
|
115
|
+
expect(result._display.unified).toContain('-# Original');
|
|
116
|
+
expect(result._display.unified).toContain('+# Updated');
|
|
117
|
+
});
|
|
118
|
+
it('should throw error when plan does not exist', async () => {
|
|
119
|
+
const tool = createPlanUpdateTool(planService);
|
|
120
|
+
const sessionId = 'non-existent';
|
|
121
|
+
try {
|
|
122
|
+
await tool.execute({ content: '# Content' }, { sessionId });
|
|
123
|
+
expect.fail('Should have thrown an error');
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
expect(error).toBeInstanceOf(DextoRuntimeError);
|
|
127
|
+
expect(error.code).toBe(PlanErrorCode.PLAN_NOT_FOUND);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
it('should throw error when sessionId is missing', async () => {
|
|
131
|
+
const tool = createPlanUpdateTool(planService);
|
|
132
|
+
try {
|
|
133
|
+
await tool.execute({ content: '# Content' }, {});
|
|
134
|
+
expect.fail('Should have thrown an error');
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
expect(error).toBeInstanceOf(DextoRuntimeError);
|
|
138
|
+
expect(error.code).toBe(PlanErrorCode.SESSION_ID_REQUIRED);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
it('should preserve plan status after update', async () => {
|
|
142
|
+
const tool = createPlanUpdateTool(planService);
|
|
143
|
+
const sessionId = 'test-session';
|
|
144
|
+
await planService.create(sessionId, '# Plan');
|
|
145
|
+
await planService.updateMeta(sessionId, { status: 'approved' });
|
|
146
|
+
await tool.execute({ content: '# Updated Plan' }, { sessionId });
|
|
147
|
+
const plan = await planService.read(sessionId);
|
|
148
|
+
expect(plan.meta.status).toBe('approved');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|