@dreki-gg/pi-plan-mode 0.20.1 → 0.21.0
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/CHANGELOG.md +9 -0
- package/README.md +2 -0
- package/extensions/plan-mode/__tests__/revise-plan.test.ts +171 -0
- package/extensions/plan-mode/__tests__/set-active-plan.test.ts +72 -0
- package/extensions/plan-mode/constants.ts +3 -0
- package/extensions/plan-mode/index.ts +17 -5
- package/extensions/plan-mode/prompts.ts +2 -0
- package/extensions/plan-mode/resolve-plan.ts +18 -0
- package/extensions/plan-mode/tools/plan-status.ts +2 -1
- package/extensions/plan-mode/tools/revise-plan.ts +189 -0
- package/extensions/plan-mode/tools/set-active-plan.ts +86 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# @dreki-gg/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 0.21.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add two plan-management tools:
|
|
8
|
+
|
|
9
|
+
- `revise_plan` — sister of `submit_plan` that rewrites an existing plan in place by name. All content fields (title, handoff, tasks) are optional, so you pass only what changes. When tasks are supplied they fully replace the set, but `status` and `notes` are preserved for any task whose id is unchanged; registry status is re-derived from task state. Use when a plan was submitted prematurely and follow-up changes arrive, instead of creating a new plan.
|
|
10
|
+
- `set_active_plan` — tool form of the `/plan focus` command. Pins a plan into session state so subsequent `plan_status` / `update_task` / `add_task` calls target it. Useful when `plan_status` reports multiple in-progress plans and the agent needs to select one programmatically. Available in both plan and execution phases.
|
|
11
|
+
|
|
3
12
|
## 0.20.1
|
|
4
13
|
|
|
5
14
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -26,10 +26,12 @@ pi install npm:@dreki-gg/pi-questionnaire
|
|
|
26
26
|
| Command | `/plan focus <name>` | Pin a plan so tracking calls default to it (multi-plan repos) |
|
|
27
27
|
| Command | `/todos` | Show current plan progress |
|
|
28
28
|
| Shortcut | `Ctrl+Alt+P` | Toggle plan mode |
|
|
29
|
+
| Tool | `revise_plan` | Rewrite an existing plan in place (title/handoff/tasks) |
|
|
29
30
|
| Tool | `update_task` | Mark a task done / skipped / blocked |
|
|
30
31
|
| Tool | `update_tasks` | Mark several tasks done / skipped in one call |
|
|
31
32
|
| Tool | `add_task` | Capture a discovered follow-up (deferred) |
|
|
32
33
|
| Tool | `plan_status` | Read-only snapshot; progress table when many plans are active |
|
|
34
|
+
| Tool | `set_active_plan` | Pin a plan as active (tool form of `/plan focus`) so tracking calls target it |
|
|
33
35
|
| Tool | `update_plan` | Close/reopen a plan: done, superseded, abandoned, in-progress |
|
|
34
36
|
| Tool | `reconcile_plans` | Detect & repair drift between tasks.jsonl and the registry |
|
|
35
37
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { chdir } from 'node:process';
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { makePlanRuntime } from '../effects/runtime.js';
|
|
7
|
+
import { registerRevisePlanTool } from '../tools/revise-plan.js';
|
|
8
|
+
import { readTasksJsonl, writeTasksJsonl } from '../storage/task-storage.js';
|
|
9
|
+
import { saveHandoff, loadHandoff } from '../storage/plan-storage.js';
|
|
10
|
+
import { readPlansManifest, upsertPlanEntry } from '../storage/plans-manifest.js';
|
|
11
|
+
import type { PlanData, TaskRecord } from '../types.js';
|
|
12
|
+
|
|
13
|
+
const runPlanIO = makePlanRuntime();
|
|
14
|
+
const now = '2026-05-27T12:00:00.000Z';
|
|
15
|
+
|
|
16
|
+
interface CapturedTool {
|
|
17
|
+
execute: (
|
|
18
|
+
id: string,
|
|
19
|
+
params: {
|
|
20
|
+
plan: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
handoff?: string;
|
|
23
|
+
tasks?: Array<{
|
|
24
|
+
id: string;
|
|
25
|
+
description: string;
|
|
26
|
+
details?: string;
|
|
27
|
+
depends_on?: string[];
|
|
28
|
+
}>;
|
|
29
|
+
},
|
|
30
|
+
) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function setup(plan: PlanData | undefined): { tool: CapturedTool; revised: PlanData[] } {
|
|
34
|
+
let tool: CapturedTool | undefined;
|
|
35
|
+
const revised: PlanData[] = [];
|
|
36
|
+
const pi = {
|
|
37
|
+
registerTool: (config: CapturedTool) => {
|
|
38
|
+
tool = config;
|
|
39
|
+
},
|
|
40
|
+
} as unknown as Parameters<typeof registerRevisePlanTool>[0];
|
|
41
|
+
|
|
42
|
+
registerRevisePlanTool(pi, runPlanIO, {
|
|
43
|
+
resolvePlan: async () => ({ plan, candidates: plan ? [] : ['other'] }),
|
|
44
|
+
onPlanRevised: (_dir, p) => {
|
|
45
|
+
revised.push(p);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return { tool: tool!, revised };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const task = (id: string, over: Partial<TaskRecord> = {}): TaskRecord => ({
|
|
53
|
+
_type: 'task',
|
|
54
|
+
id,
|
|
55
|
+
description: `task ${id}`,
|
|
56
|
+
details: '',
|
|
57
|
+
status: 'pending',
|
|
58
|
+
origin: 'plan',
|
|
59
|
+
created_at: now,
|
|
60
|
+
updated_at: now,
|
|
61
|
+
...over,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
async function seed(plan: PlanData): Promise<void> {
|
|
65
|
+
const dir = `.plans/${plan.planName}`;
|
|
66
|
+
await runPlanIO(
|
|
67
|
+
writeTasksJsonl(
|
|
68
|
+
dir,
|
|
69
|
+
{ _type: 'meta', title: plan.title, plan_name: plan.planName, created_at: now },
|
|
70
|
+
plan.tasks,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
await runPlanIO(saveHandoff(dir, plan.handoff));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const originalCwd = process.cwd();
|
|
77
|
+
let dir: string;
|
|
78
|
+
beforeEach(async () => {
|
|
79
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-revise-plan-'));
|
|
80
|
+
chdir(dir);
|
|
81
|
+
});
|
|
82
|
+
afterEach(async () => {
|
|
83
|
+
chdir(originalCwd);
|
|
84
|
+
await rm(dir, { recursive: true, force: true });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('revise_plan tool', () => {
|
|
88
|
+
test('rewrites handoff + title only, leaving tasks untouched', async () => {
|
|
89
|
+
const plan: PlanData = {
|
|
90
|
+
title: 'Old title',
|
|
91
|
+
planName: 'p',
|
|
92
|
+
handoff: 'old handoff',
|
|
93
|
+
tasks: [task('t-001'), task('t-002')],
|
|
94
|
+
};
|
|
95
|
+
await seed(plan);
|
|
96
|
+
await runPlanIO(upsertPlanEntry('p', { status: 'in-progress', title: 'Old title' }));
|
|
97
|
+
|
|
98
|
+
const { tool } = setup(plan);
|
|
99
|
+
await tool.execute('c', { plan: 'p', title: 'New title', handoff: 'new handoff' });
|
|
100
|
+
|
|
101
|
+
const snapshot = await runPlanIO(readTasksJsonl('.plans/p'));
|
|
102
|
+
expect(snapshot?.meta.title).toBe('New title');
|
|
103
|
+
expect(snapshot?.tasks.map((t) => t.id)).toEqual(['t-001', 't-002']);
|
|
104
|
+
expect(await runPlanIO(loadHandoff('.plans/p'))).toBe('new handoff');
|
|
105
|
+
const [entry] = await runPlanIO(readPlansManifest());
|
|
106
|
+
expect(entry.title).toBe('New title');
|
|
107
|
+
expect(entry.status).toBe('in-progress');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('replaces task set but preserves status/notes for matching ids', async () => {
|
|
111
|
+
const plan: PlanData = {
|
|
112
|
+
title: 'P',
|
|
113
|
+
planName: 'p',
|
|
114
|
+
handoff: 'h',
|
|
115
|
+
tasks: [
|
|
116
|
+
task('t-001', { status: 'done', notes: 'shipped' }),
|
|
117
|
+
task('t-002', { status: 'pending' }),
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
await seed(plan);
|
|
121
|
+
await runPlanIO(upsertPlanEntry('p', { status: 'in-progress', title: 'P' }));
|
|
122
|
+
|
|
123
|
+
const { tool } = setup(plan);
|
|
124
|
+
await tool.execute('c', {
|
|
125
|
+
plan: 'p',
|
|
126
|
+
tasks: [
|
|
127
|
+
{ id: 't-001', description: 'reworded but same id' },
|
|
128
|
+
{ id: 't-003', description: 'brand new task' },
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const snapshot = await runPlanIO(readTasksJsonl('.plans/p'));
|
|
133
|
+
const tasks = snapshot!.tasks;
|
|
134
|
+
expect(tasks.map((t) => t.id)).toEqual(['t-001', 't-003']);
|
|
135
|
+
const t1 = tasks.find((t) => t.id === 't-001')!;
|
|
136
|
+
expect(t1.status).toBe('done');
|
|
137
|
+
expect(t1.notes).toBe('shipped');
|
|
138
|
+
expect(t1.description).toBe('reworded but same id');
|
|
139
|
+
const t3 = tasks.find((t) => t.id === 't-003')!;
|
|
140
|
+
expect(t3.status).toBe('pending');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('reopens an all-done plan when a pending task is added', async () => {
|
|
144
|
+
const plan: PlanData = {
|
|
145
|
+
title: 'P',
|
|
146
|
+
planName: 'p',
|
|
147
|
+
handoff: 'h',
|
|
148
|
+
tasks: [task('t-001', { status: 'done' })],
|
|
149
|
+
};
|
|
150
|
+
await seed(plan);
|
|
151
|
+
await runPlanIO(upsertPlanEntry('p', { status: 'done', title: 'P' }));
|
|
152
|
+
|
|
153
|
+
const { tool } = setup(plan);
|
|
154
|
+
await tool.execute('c', {
|
|
155
|
+
plan: 'p',
|
|
156
|
+
tasks: [
|
|
157
|
+
{ id: 't-001', description: 'done one' },
|
|
158
|
+
{ id: 't-002', description: 'new pending work' },
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const [entry] = await runPlanIO(readPlansManifest());
|
|
163
|
+
expect(entry.status).toBe('in-progress');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('reports not_found for an unknown plan (no throw)', async () => {
|
|
167
|
+
const { tool } = setup(undefined);
|
|
168
|
+
const result = await tool.execute('c', { plan: 'ghost', title: 'x' });
|
|
169
|
+
expect((result.details as { error?: string }).error).toBe('not_found');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { registerSetActivePlanTool } from '../tools/set-active-plan.js';
|
|
3
|
+
import type { PlanData } from '../types.js';
|
|
4
|
+
import type { ResolvedPlan } from '../resolve-plan.js';
|
|
5
|
+
|
|
6
|
+
const now = '2026-05-27T12:00:00.000Z';
|
|
7
|
+
|
|
8
|
+
interface CapturedTool {
|
|
9
|
+
execute: (
|
|
10
|
+
id: string,
|
|
11
|
+
params: { plan: string },
|
|
12
|
+
) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function setup(resolved: ResolvedPlan): { tool: CapturedTool; requested: string[] } {
|
|
16
|
+
let tool: CapturedTool | undefined;
|
|
17
|
+
const requested: string[] = [];
|
|
18
|
+
const pi = {
|
|
19
|
+
registerTool: (config: CapturedTool) => {
|
|
20
|
+
tool = config;
|
|
21
|
+
},
|
|
22
|
+
} as unknown as Parameters<typeof registerSetActivePlanTool>[0];
|
|
23
|
+
|
|
24
|
+
registerSetActivePlanTool(pi, {
|
|
25
|
+
setActivePlan: async (name) => {
|
|
26
|
+
requested.push(name);
|
|
27
|
+
return resolved;
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return { tool: tool!, requested };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const plan: PlanData = {
|
|
35
|
+
title: 'My Plan',
|
|
36
|
+
planName: 'my-plan',
|
|
37
|
+
handoff: '',
|
|
38
|
+
tasks: [
|
|
39
|
+
{
|
|
40
|
+
_type: 'task',
|
|
41
|
+
id: 't-001',
|
|
42
|
+
description: 'do',
|
|
43
|
+
status: 'pending',
|
|
44
|
+
created_at: now,
|
|
45
|
+
updated_at: now,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe('set_active_plan tool', () => {
|
|
51
|
+
test('pins the plan and confirms when resolution succeeds', async () => {
|
|
52
|
+
const { tool, requested } = setup({ plan, candidates: [] });
|
|
53
|
+
const result = await tool.execute('c', { plan: '.plans/my-plan' });
|
|
54
|
+
|
|
55
|
+
expect(requested).toEqual(['.plans/my-plan']);
|
|
56
|
+
const details = result.details as { active?: boolean; plan_name?: string; title?: string };
|
|
57
|
+
expect(details.active).toBe(true);
|
|
58
|
+
expect(details.plan_name).toBe('my-plan');
|
|
59
|
+
expect(details.title).toBe('My Plan');
|
|
60
|
+
expect(result.content?.[0]?.text).toMatch(/my-plan/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('reports not_found with candidates (no throw) when unresolved', async () => {
|
|
64
|
+
const { tool } = setup({ plan: undefined, candidates: ['alpha', 'beta'] });
|
|
65
|
+
const result = await tool.execute('c', { plan: 'ghost' });
|
|
66
|
+
|
|
67
|
+
const details = result.details as { error?: string; candidates?: string[] };
|
|
68
|
+
expect(details.error).toBe('not_found');
|
|
69
|
+
expect(details.candidates).toEqual(['alpha', 'beta']);
|
|
70
|
+
expect(result.content?.[0]?.text).toMatch(/alpha, beta/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -10,12 +10,14 @@ export const PLAN_TOOLS = [
|
|
|
10
10
|
'find',
|
|
11
11
|
'ls',
|
|
12
12
|
'submit_plan',
|
|
13
|
+
'revise_plan',
|
|
13
14
|
'preview_prototype',
|
|
14
15
|
'write',
|
|
15
16
|
'questionnaire',
|
|
16
17
|
'search_skills',
|
|
17
18
|
'subagent',
|
|
18
19
|
'plan_status',
|
|
20
|
+
'set_active_plan',
|
|
19
21
|
'update_plan',
|
|
20
22
|
'reconcile_plans',
|
|
21
23
|
];
|
|
@@ -29,6 +31,7 @@ export const EXEC_TOOLS = [
|
|
|
29
31
|
'update_tasks',
|
|
30
32
|
'add_task',
|
|
31
33
|
'plan_status',
|
|
34
|
+
'set_active_plan',
|
|
32
35
|
'update_plan',
|
|
33
36
|
'reconcile_plans',
|
|
34
37
|
];
|
|
@@ -38,14 +38,16 @@ import { filterExecutionMessages, filterStalePlanMessages } from './context-filt
|
|
|
38
38
|
import { activeTasksResolved, deferredTasks, isPlanFinalizable } from './task-status.js';
|
|
39
39
|
import { enterPlanMode, exitPlanMode, switchModel } from './phase-transitions.js';
|
|
40
40
|
import { resumePlan, executeInNewSession } from './resume.js';
|
|
41
|
-
import { resolveActivePlan } from './resolve-plan.js';
|
|
41
|
+
import { resolveActivePlan, focusActivePlan } from './resolve-plan.js';
|
|
42
42
|
import { collectPlanDrift } from './reconcile.js';
|
|
43
43
|
import { registerSubmitPlanTool } from './tools/submit-plan.js';
|
|
44
|
+
import { registerRevisePlanTool } from './tools/revise-plan.js';
|
|
44
45
|
import { registerPreviewPrototypeTool } from './tools/preview-prototype.js';
|
|
45
46
|
import { registerUpdateTaskTool } from './tools/update-task.js';
|
|
46
47
|
import { registerUpdateTasksTool } from './tools/update-tasks.js';
|
|
47
48
|
import { registerAddTaskTool } from './tools/add-task.js';
|
|
48
49
|
import { registerPlanStatusTool } from './tools/plan-status.js';
|
|
50
|
+
import { registerSetActivePlanTool } from './tools/set-active-plan.js';
|
|
49
51
|
import { registerUpdatePlanTool } from './tools/update-plan.js';
|
|
50
52
|
import { registerReconcilePlansTool } from './tools/reconcile-plans.js';
|
|
51
53
|
import { isSafeCommand, isPlanPath } from './utils.js';
|
|
@@ -71,6 +73,15 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
71
73
|
},
|
|
72
74
|
});
|
|
73
75
|
|
|
76
|
+
registerRevisePlanTool(pi, runPlanIO, {
|
|
77
|
+
resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
|
|
78
|
+
onPlanRevised: (dir, revisedPlan) => {
|
|
79
|
+
state.planDir = dir;
|
|
80
|
+
state.plan = revisedPlan;
|
|
81
|
+
state.persist(pi);
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
74
85
|
registerPreviewPrototypeTool(pi, runPlanIO);
|
|
75
86
|
|
|
76
87
|
// Shared task-write closure: mutate the in-memory task, persist tasks.jsonl,
|
|
@@ -169,6 +180,10 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
169
180
|
},
|
|
170
181
|
});
|
|
171
182
|
|
|
183
|
+
registerSetActivePlanTool(pi, {
|
|
184
|
+
setActivePlan: (name) => focusActivePlan(state, pi, runPlanIO, name),
|
|
185
|
+
});
|
|
186
|
+
|
|
172
187
|
registerUpdatePlanTool(pi, runPlanIO);
|
|
173
188
|
registerReconcilePlansTool(pi, runPlanIO);
|
|
174
189
|
|
|
@@ -220,10 +235,7 @@ export default function planMode(pi: ExtensionAPI): void {
|
|
|
220
235
|
ctx.ui.notify('Usage: /plan focus <name>', 'info');
|
|
221
236
|
return;
|
|
222
237
|
}
|
|
223
|
-
|
|
224
|
-
state.plan = undefined;
|
|
225
|
-
state.planDir = undefined;
|
|
226
|
-
const { plan, candidates } = await resolveActivePlan(state, pi, runPlanIO, { name });
|
|
238
|
+
const { plan, candidates } = await focusActivePlan(state, pi, runPlanIO, name);
|
|
227
239
|
if (plan) {
|
|
228
240
|
ctx.ui.notify(`Focused plan: ${plan.title} (${plan.planName})`, 'info');
|
|
229
241
|
} else {
|
|
@@ -33,6 +33,8 @@ Plan weight:
|
|
|
33
33
|
|
|
34
34
|
submit_plan is finalization, not the starting point. It records tasks and the handoff — it does not generate HTML.
|
|
35
35
|
|
|
36
|
+
If a plan with the same name already exists and the user asks for follow-up changes (e.g. you submitted prematurely), call revise_plan instead of submit_plan. It rewrites the existing plan in place — pass only the fields that change (title, handoff, and/or tasks); status and notes are preserved for tasks whose id is unchanged.
|
|
37
|
+
|
|
36
38
|
For visual/UI/layout/style work, build a prototype with preview_prototype DURING planning, before submit_plan, so the user can react to the visual before the plan hardens. The visual-prototype skill covers when and how.
|
|
37
39
|
|
|
38
40
|
When facing a significant technical decision with multiple viable approaches (architecture, API design, implementation strategy), use the technical-options skill: you generate the competing proposals yourself, then use the subagent tool to fan out voting agents for evaluation. Do not delegate the entire workflow to a subagent — you are the planner, you drive the process.`;
|
|
@@ -116,3 +116,21 @@ export async function resolveActivePlan(
|
|
|
116
116
|
|
|
117
117
|
return { plan: undefined, candidates: inProgress.map((entry) => entry.name) };
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Pin a plan as the active one (the tool/command form of `/plan focus`).
|
|
122
|
+
*
|
|
123
|
+
* Clears any stale in-memory plan first so the hint always re-attaches from
|
|
124
|
+
* disk, then resolves by name. Returns the same `ResolvedPlan` shape so callers
|
|
125
|
+
* can report success or surface in-progress candidates on a miss.
|
|
126
|
+
*/
|
|
127
|
+
export async function focusActivePlan(
|
|
128
|
+
state: PlanModeState,
|
|
129
|
+
pi: ExtensionAPI,
|
|
130
|
+
runPlanIO: RunPlanIO,
|
|
131
|
+
name: string,
|
|
132
|
+
): Promise<ResolvedPlan> {
|
|
133
|
+
state.plan = undefined;
|
|
134
|
+
state.planDir = undefined;
|
|
135
|
+
return resolveActivePlan(state, pi, runPlanIO, { name });
|
|
136
|
+
}
|
|
@@ -49,6 +49,7 @@ export function registerPlanStatusTool(pi: ExtensionAPI, callbacks: PlanStatusCa
|
|
|
49
49
|
promptGuidelines: [
|
|
50
50
|
'Call plan_status when unsure whether a plan is active or which task ids exist — it is read-only and never mutates state.',
|
|
51
51
|
'Prefer it over guessing task ids; the returned ids are what update_task expects.',
|
|
52
|
+
'When it reports multiple in-progress plans, call set_active_plan to pin one before update_task / add_task.',
|
|
52
53
|
],
|
|
53
54
|
parameters: Type.Object({
|
|
54
55
|
plan: Type.Optional(
|
|
@@ -74,7 +75,7 @@ export function registerPlanStatusTool(pi: ExtensionAPI, callbacks: PlanStatusCa
|
|
|
74
75
|
})
|
|
75
76
|
.join('\n');
|
|
76
77
|
const text =
|
|
77
|
-
`No single active plan — ${summaries.length} in-progress. Pass { plan: "<name>" } to target one.\n` +
|
|
78
|
+
`No single active plan — ${summaries.length} in-progress. Pass { plan: "<name>" } or call set_active_plan to target one.\n` +
|
|
78
79
|
`Progress:\n${rows}`;
|
|
79
80
|
return {
|
|
80
81
|
content: [{ type: 'text' as const, text }],
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* revise_plan tool — sister of submit_plan, available during the plan phase.
|
|
3
|
+
*
|
|
4
|
+
* Re-opens an EXISTING plan by name and rewrites its contents in place. Use
|
|
5
|
+
* when a plan was submitted (often prematurely) and follow-up changes arrive:
|
|
6
|
+
* instead of creating a new plan, revise the existing one.
|
|
7
|
+
*
|
|
8
|
+
* All content fields are optional — only what you pass is overwritten. When
|
|
9
|
+
* `tasks` is supplied it fully defines the new task set (tasks not listed are
|
|
10
|
+
* dropped); for any task whose `id` matches an existing one, its `status`,
|
|
11
|
+
* `notes`, `origin`, and `created_at` are preserved so progress survives a
|
|
12
|
+
* re-plan. Registry status is then re-derived from task state.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
16
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
17
|
+
import { Type } from 'typebox';
|
|
18
|
+
import { Effect } from 'effect';
|
|
19
|
+
import { saveHandoff } from '../storage/plan-storage.js';
|
|
20
|
+
import { writeTasksJsonl } from '../storage/task-storage.js';
|
|
21
|
+
import {
|
|
22
|
+
readPlansManifest,
|
|
23
|
+
reconcilePlanStatus,
|
|
24
|
+
upsertPlanEntry,
|
|
25
|
+
} from '../storage/plans-manifest.js';
|
|
26
|
+
import { isPlanFinalizable } from '../task-status.js';
|
|
27
|
+
import type { RunPlanIO } from '../effects/runtime.js';
|
|
28
|
+
import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
|
|
29
|
+
|
|
30
|
+
export interface RevisePlanCallbacks {
|
|
31
|
+
resolvePlan: (opts: { name?: string }) => Promise<{ plan?: PlanData; candidates: string[] }>;
|
|
32
|
+
onPlanRevised: (planDir: string, plan: PlanData) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function registerRevisePlanTool(
|
|
36
|
+
pi: ExtensionAPI,
|
|
37
|
+
runPlanIO: RunPlanIO,
|
|
38
|
+
callbacks: RevisePlanCallbacks,
|
|
39
|
+
): void {
|
|
40
|
+
pi.registerTool({
|
|
41
|
+
name: 'revise_plan',
|
|
42
|
+
label: 'Revise Plan',
|
|
43
|
+
description:
|
|
44
|
+
'Rewrite an existing plan in place by name — change its title, handoff, and/or tasks. Use after submit_plan when follow-up changes arrive, instead of creating a new plan.',
|
|
45
|
+
promptSnippet: 'Rewrite an existing plan in place (title/handoff/tasks) by name',
|
|
46
|
+
promptGuidelines: [
|
|
47
|
+
'Use revise_plan instead of submit_plan when a plan with the same name already exists and the user asks for follow-up changes.',
|
|
48
|
+
'All content fields are optional — pass only what changes; omitted title/handoff/tasks are left as-is.',
|
|
49
|
+
'When you pass tasks, it fully replaces the task set (tasks you omit are dropped). Status and notes are preserved for any task whose id is unchanged.',
|
|
50
|
+
],
|
|
51
|
+
parameters: Type.Object({
|
|
52
|
+
plan: Type.String({ description: 'Plan name (or .plans/<name>) to revise' }),
|
|
53
|
+
title: Type.Optional(Type.String({ description: 'New human-readable plan title' })),
|
|
54
|
+
handoff: Type.Optional(Type.String({ description: 'New markdown content for HANDOFF.md' })),
|
|
55
|
+
tasks: Type.Optional(
|
|
56
|
+
Type.Array(
|
|
57
|
+
Type.Object({
|
|
58
|
+
id: Type.String({ description: 'Stable task ID, e.g. t-001' }),
|
|
59
|
+
description: Type.String({
|
|
60
|
+
description: 'Short task label for progress display (≤60 chars)',
|
|
61
|
+
}),
|
|
62
|
+
details: Type.Optional(
|
|
63
|
+
Type.String({ description: 'Full implementation instructions for this task.' }),
|
|
64
|
+
),
|
|
65
|
+
depends_on: Type.Optional(
|
|
66
|
+
Type.Array(Type.String({ description: 'Dependency task ID' })),
|
|
67
|
+
),
|
|
68
|
+
}),
|
|
69
|
+
{ minItems: 1 },
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
}),
|
|
73
|
+
|
|
74
|
+
async execute(_toolCallId, params) {
|
|
75
|
+
const { plan, candidates } = await callbacks.resolvePlan({ name: params.plan });
|
|
76
|
+
if (!plan) {
|
|
77
|
+
const notFound: Record<string, unknown> = {
|
|
78
|
+
error: 'not_found',
|
|
79
|
+
plan: params.plan,
|
|
80
|
+
candidates,
|
|
81
|
+
};
|
|
82
|
+
const hint = candidates.length ? ` In-progress: ${candidates.join(', ')}.` : '';
|
|
83
|
+
return {
|
|
84
|
+
content: [
|
|
85
|
+
{ type: 'text' as const, text: `Plan not found: ${params.plan}.${hint}` },
|
|
86
|
+
],
|
|
87
|
+
details: notFound,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const now = new Date().toISOString();
|
|
92
|
+
const newTitle = params.title ?? plan.title;
|
|
93
|
+
const newHandoff = params.handoff ?? plan.handoff;
|
|
94
|
+
|
|
95
|
+
let tasks = plan.tasks;
|
|
96
|
+
if (params.tasks) {
|
|
97
|
+
const previous = new Map(plan.tasks.map((task) => [task.id, task]));
|
|
98
|
+
tasks = params.tasks.map((task): TaskRecord => {
|
|
99
|
+
const existing = previous.get(task.id);
|
|
100
|
+
return {
|
|
101
|
+
_type: 'task',
|
|
102
|
+
id: task.id,
|
|
103
|
+
description: task.description.slice(0, 60),
|
|
104
|
+
details: task.details ?? '',
|
|
105
|
+
// Preserve progress for tasks whose id is unchanged.
|
|
106
|
+
status: existing?.status ?? 'pending',
|
|
107
|
+
origin: existing?.origin ?? 'plan',
|
|
108
|
+
depends_on: task.depends_on,
|
|
109
|
+
notes: existing?.notes,
|
|
110
|
+
created_at: existing?.created_at ?? now,
|
|
111
|
+
updated_at: now,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const meta: TaskMeta = {
|
|
117
|
+
_type: 'meta',
|
|
118
|
+
title: newTitle,
|
|
119
|
+
plan_name: plan.planName,
|
|
120
|
+
created_at: plan.tasks[0]?.created_at ?? now,
|
|
121
|
+
};
|
|
122
|
+
const revised: PlanData = {
|
|
123
|
+
title: newTitle,
|
|
124
|
+
planName: plan.planName,
|
|
125
|
+
handoff: newHandoff,
|
|
126
|
+
tasks,
|
|
127
|
+
};
|
|
128
|
+
const planDir = `.plans/${plan.planName}`;
|
|
129
|
+
|
|
130
|
+
await runPlanIO(
|
|
131
|
+
Effect.gen(function* () {
|
|
132
|
+
yield* writeTasksJsonl(planDir, meta, tasks);
|
|
133
|
+
yield* saveHandoff(planDir, newHandoff);
|
|
134
|
+
// Persist a title change without flipping status, then let task state
|
|
135
|
+
// re-derive in-progress ⇄ done (never clobbers superseded/abandoned).
|
|
136
|
+
const manifest = yield* readPlansManifest();
|
|
137
|
+
const current = manifest.find((entry) => entry.name === plan.planName);
|
|
138
|
+
yield* upsertPlanEntry(plan.planName, {
|
|
139
|
+
status: current?.status ?? 'in-progress',
|
|
140
|
+
title: newTitle,
|
|
141
|
+
});
|
|
142
|
+
yield* reconcilePlanStatus(plan.planName, isPlanFinalizable(tasks), newTitle);
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
callbacks.onPlanRevised(planDir, revised);
|
|
147
|
+
|
|
148
|
+
const changed = [
|
|
149
|
+
params.title ? 'title' : undefined,
|
|
150
|
+
params.handoff ? 'handoff' : undefined,
|
|
151
|
+
params.tasks ? 'tasks' : undefined,
|
|
152
|
+
].filter(Boolean);
|
|
153
|
+
return {
|
|
154
|
+
content: [
|
|
155
|
+
{
|
|
156
|
+
type: 'text' as const,
|
|
157
|
+
text: `Plan "${newTitle}" revised (${changed.join(', ') || 'no changes'}) in ${planDir}.`,
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
details: { planDir, plan: revised, changed },
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
renderCall(args, theme) {
|
|
165
|
+
const name = (args as { plan?: string }).plan ?? 'plan';
|
|
166
|
+
let content = theme.fg('toolTitle', theme.bold('revise_plan '));
|
|
167
|
+
content += theme.fg('accent', name);
|
|
168
|
+
return new Text(content, 0, 0);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
renderResult(result, _options, theme) {
|
|
172
|
+
const details = result.details as
|
|
173
|
+
| { plan?: PlanData; changed?: string[] }
|
|
174
|
+
| undefined;
|
|
175
|
+
const plan = details?.plan;
|
|
176
|
+
if (!plan) return new Text(theme.fg('success', '✓ Plan revised'), 0, 0);
|
|
177
|
+
const changed = details?.changed?.length ? ` (${details.changed.join(', ')})` : '';
|
|
178
|
+
const lines = [
|
|
179
|
+
theme.fg('success', '✓ ') +
|
|
180
|
+
theme.fg('accent', theme.bold(plan.title)) +
|
|
181
|
+
theme.fg('dim', changed),
|
|
182
|
+
'',
|
|
183
|
+
];
|
|
184
|
+
for (const task of plan.tasks)
|
|
185
|
+
lines.push(` ${theme.fg('muted', task.id)} ${task.description}`);
|
|
186
|
+
return new Text(lines.join('\n'), 0, 0);
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* set_active_plan tool — pin a plan as the active one.
|
|
3
|
+
*
|
|
4
|
+
* The tool-callable form of `/plan focus <name>`. Attaches the named plan into
|
|
5
|
+
* session state so subsequent plan_status / update_task / add_task calls target
|
|
6
|
+
* it. Use when plan_status reports multiple in-progress plans and the agent
|
|
7
|
+
* needs to select one programmatically instead of waiting for `/plan focus`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
11
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
12
|
+
import { Type } from 'typebox';
|
|
13
|
+
import type { ResolvedPlan } from '../resolve-plan.js';
|
|
14
|
+
|
|
15
|
+
export interface SetActivePlanCallbacks {
|
|
16
|
+
/** Pin the named plan into state (clears stale plan, re-attaches from disk). */
|
|
17
|
+
setActivePlan: (name: string) => Promise<ResolvedPlan>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerSetActivePlanTool(
|
|
21
|
+
pi: ExtensionAPI,
|
|
22
|
+
callbacks: SetActivePlanCallbacks,
|
|
23
|
+
): void {
|
|
24
|
+
pi.registerTool({
|
|
25
|
+
name: 'set_active_plan',
|
|
26
|
+
label: 'Set Active Plan',
|
|
27
|
+
description:
|
|
28
|
+
'Pin a plan as the active one so plan_status / update_task / add_task target it. Use when multiple plans are in-progress and you need to select one.',
|
|
29
|
+
promptSnippet: 'Pin a plan as active so tracking calls target it',
|
|
30
|
+
promptGuidelines: [
|
|
31
|
+
'Call set_active_plan when plan_status reports multiple in-progress plans and you need to select one before update_task / add_task.',
|
|
32
|
+
'It is the tool form of the /plan focus command — it attaches the named plan into session state.',
|
|
33
|
+
],
|
|
34
|
+
parameters: Type.Object({
|
|
35
|
+
plan: Type.String({ description: 'Plan name (or .plans/<name>) to pin as active' }),
|
|
36
|
+
}),
|
|
37
|
+
|
|
38
|
+
async execute(_toolCallId, params) {
|
|
39
|
+
const { plan, candidates } = await callbacks.setActivePlan(params.plan);
|
|
40
|
+
if (!plan) {
|
|
41
|
+
const hint = candidates.length ? ` In-progress: ${candidates.join(', ')}.` : '';
|
|
42
|
+
const notFound: Record<string, unknown> = {
|
|
43
|
+
error: 'not_found',
|
|
44
|
+
plan: params.plan,
|
|
45
|
+
candidates,
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: 'text' as const, text: `Plan not found: ${params.plan}.${hint}` }],
|
|
49
|
+
details: notFound,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
content: [
|
|
55
|
+
{
|
|
56
|
+
type: 'text' as const,
|
|
57
|
+
text: `Active plan set to ${plan.title} (${plan.planName}).`,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
details: { active: true, plan_name: plan.planName, title: plan.title },
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
renderCall(args, theme) {
|
|
65
|
+
const name = (args as { plan?: string }).plan ?? 'plan';
|
|
66
|
+
return new Text(
|
|
67
|
+
theme.fg('toolTitle', theme.bold('set_active_plan ')) + theme.fg('accent', name),
|
|
68
|
+
0,
|
|
69
|
+
0,
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
renderResult(result, _options, theme) {
|
|
74
|
+
const details = result.details as
|
|
75
|
+
| { active?: boolean; plan_name?: string; title?: string }
|
|
76
|
+
| undefined;
|
|
77
|
+
if (!details?.active) return new Text(theme.fg('dim', 'No plan set'), 0, 0);
|
|
78
|
+
return new Text(
|
|
79
|
+
theme.fg('success', '✓ ') +
|
|
80
|
+
theme.fg('accent', theme.bold(details.title ?? details.plan_name ?? 'plan')),
|
|
81
|
+
0,
|
|
82
|
+
0,
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
package/package.json
CHANGED