@dreki-gg/pi-plan-mode 0.13.0 → 0.14.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 +6 -0
- package/extensions/plan-mode/__tests__/atomic-write.test.ts +43 -0
- package/extensions/plan-mode/__tests__/html-render.test.ts +43 -0
- package/extensions/plan-mode/__tests__/plans-manifest.test.ts +37 -0
- package/extensions/plan-mode/__tests__/task-storage.test.ts +44 -0
- package/extensions/plan-mode/__tests__/types.test.ts +55 -0
- package/extensions/plan-mode/constants.ts +2 -1
- package/extensions/plan-mode/html/render.ts +67 -0
- package/extensions/plan-mode/html/templates/plan.pug +58 -0
- package/extensions/plan-mode/index.ts +60 -46
- package/extensions/plan-mode/plan-storage.ts +1 -80
- package/extensions/plan-mode/prompts.ts +28 -32
- package/extensions/plan-mode/pug.d.ts +5 -0
- package/extensions/plan-mode/resume.ts +38 -37
- package/extensions/plan-mode/storage/atomic-write.ts +53 -0
- package/extensions/plan-mode/storage/plan-storage.ts +47 -0
- package/extensions/plan-mode/storage/plans-manifest.ts +57 -0
- package/extensions/plan-mode/storage/task-storage.ts +44 -0
- package/extensions/plan-mode/tools/submit-plan.ts +39 -81
- package/extensions/plan-mode/tools/update-task.ts +82 -0
- package/extensions/plan-mode/types.ts +56 -3
- package/extensions/plan-mode/ui.ts +10 -10
- package/package.json +3 -2
- package/extensions/plan-mode/plans-json.ts +0 -48
- package/extensions/plan-mode/tools/update-step.ts +0 -146
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* submit_plan tool — available during the plan phase.
|
|
3
|
-
*
|
|
4
|
-
* The planner calls this to submit a structured plan with typed steps.
|
|
5
|
-
* Writes `.plans/<name>/plan.json` and updates the plans.json manifest.
|
|
6
3
|
*/
|
|
7
4
|
|
|
8
5
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
9
6
|
import { Text } from '@earendil-works/pi-tui';
|
|
10
7
|
import { Type } from 'typebox';
|
|
11
8
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
12
|
-
import {
|
|
13
|
-
import { saveHandoff } from '../plan-storage.js';
|
|
9
|
+
import { renderPlanHtml } from '../html/render.js';
|
|
10
|
+
import { saveHandoff } from '../storage/plan-storage.js';
|
|
11
|
+
import { writeTasksJsonl } from '../storage/task-storage.js';
|
|
12
|
+
import { upsertPlanEntry } from '../storage/plans-manifest.js';
|
|
14
13
|
import { toKebabCase } from '../utils.js';
|
|
15
|
-
import type { PlanData,
|
|
14
|
+
import type { PlanData, TaskMeta, TaskRecord } from '../types.js';
|
|
16
15
|
|
|
17
16
|
export interface SubmitPlanCallbacks {
|
|
18
17
|
onPlanSubmitted: (planDir: string, plan: PlanData) => void;
|
|
@@ -22,80 +21,56 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
|
|
|
22
21
|
pi.registerTool({
|
|
23
22
|
name: 'submit_plan',
|
|
24
23
|
label: 'Submit Plan',
|
|
25
|
-
description:
|
|
26
|
-
|
|
27
|
-
'Each step has a short description (for progress display) and detailed implementation instructions. ' +
|
|
28
|
-
'This finalizes the plan, writes .plans/<name>/plan.json and .plans/<name>/HANDOFF.md.',
|
|
29
|
-
promptSnippet:
|
|
30
|
-
'Submit a structured plan with title, handoff (what/why/context), and steps',
|
|
24
|
+
description: 'Finalize a conversational plan with task IDs, JSONL storage, HANDOFF.md, and generated plan.html.',
|
|
25
|
+
promptSnippet: 'Finalize the plan with title, handoff, tasks, dependencies, and optional prototype Pug',
|
|
31
26
|
promptGuidelines: [
|
|
32
|
-
'
|
|
33
|
-
'Each
|
|
34
|
-
'The
|
|
27
|
+
'Only call submit_plan after shared understanding has been reached with the user.',
|
|
28
|
+
'Each task needs an id like t-001, a short description, detailed implementation instructions, and optional depends_on task IDs.',
|
|
29
|
+
'The handoff must be thorough enough that both a human reviewer and executor agent with zero prior context can understand the plan.',
|
|
35
30
|
],
|
|
36
31
|
parameters: Type.Object({
|
|
37
|
-
name: Type.String({
|
|
38
|
-
description: 'Short kebab-case name for the plan (e.g. "add-auth-middleware")',
|
|
39
|
-
}),
|
|
32
|
+
name: Type.String({ description: 'Short kebab-case name for the plan (e.g. "add-auth-middleware")' }),
|
|
40
33
|
title: Type.String({ description: 'Human-readable plan title' }),
|
|
41
|
-
handoff: Type.String({
|
|
42
|
-
|
|
43
|
-
'Markdown content for HANDOFF.md — explains what change is being made, why, and includes relevant codebase context for the executor',
|
|
44
|
-
}),
|
|
45
|
-
steps: Type.Array(
|
|
34
|
+
handoff: Type.String({ description: 'Markdown content for HANDOFF.md' }),
|
|
35
|
+
tasks: Type.Array(
|
|
46
36
|
Type.Object({
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}),
|
|
50
|
-
|
|
51
|
-
description: 'Full implementation instructions for this step',
|
|
52
|
-
}),
|
|
37
|
+
id: Type.String({ description: 'Stable task ID, e.g. t-001' }),
|
|
38
|
+
description: Type.String({ description: 'Short task label for progress display (≤60 chars)' }),
|
|
39
|
+
details: Type.String({ description: 'Full implementation instructions for this task' }),
|
|
40
|
+
depends_on: Type.Optional(Type.Array(Type.String({ description: 'Dependency task ID' }))),
|
|
53
41
|
}),
|
|
54
42
|
{ minItems: 1 },
|
|
55
43
|
),
|
|
44
|
+
prototype: Type.Optional(Type.String({ description: 'Optional Pug markup for the prototype section in plan.html' })),
|
|
56
45
|
}),
|
|
57
46
|
|
|
58
47
|
async execute(_toolCallId, params) {
|
|
59
48
|
const planName = toKebabCase(params.name);
|
|
60
49
|
const planDir = `.plans/${planName}`;
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
50
|
+
const now = new Date().toISOString();
|
|
51
|
+
const meta: TaskMeta = { _type: 'meta', title: params.title, plan_name: planName, created_at: now };
|
|
52
|
+
const tasks: TaskRecord[] = params.tasks.map((task) => ({
|
|
53
|
+
_type: 'task',
|
|
54
|
+
id: task.id,
|
|
55
|
+
description: task.description.slice(0, 60),
|
|
56
|
+
details: task.details,
|
|
57
|
+
status: 'pending',
|
|
58
|
+
depends_on: task.depends_on,
|
|
59
|
+
created_at: now,
|
|
60
|
+
updated_at: now,
|
|
66
61
|
}));
|
|
62
|
+
const plan: PlanData = { title: params.title, planName, handoff: params.handoff, tasks };
|
|
67
63
|
|
|
68
|
-
const plan: PlanData = {
|
|
69
|
-
title: params.title,
|
|
70
|
-
handoff: params.handoff,
|
|
71
|
-
steps,
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
// Write plan.json and HANDOFF.md
|
|
75
64
|
await mkdir(planDir, { recursive: true });
|
|
76
|
-
await
|
|
65
|
+
await writeTasksJsonl(planDir, meta, tasks);
|
|
77
66
|
await saveHandoff(planDir, params.handoff);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const manifest = await readPlansJson();
|
|
81
|
-
const now = new Date().toISOString();
|
|
82
|
-
manifest[planName] = {
|
|
83
|
-
status: 'in-progress',
|
|
84
|
-
title: params.title,
|
|
85
|
-
created: manifest[planName]?.created ?? now,
|
|
86
|
-
completed: null,
|
|
87
|
-
};
|
|
88
|
-
await writeFile('.plans/plans.json', serializePlansJson(manifest), 'utf-8');
|
|
67
|
+
await writeFile(`${planDir}/plan.html`, renderPlanHtml(plan, params.prototype), 'utf-8');
|
|
68
|
+
await upsertPlanEntry(planName, { status: 'in-progress', title: params.title });
|
|
89
69
|
|
|
90
70
|
callbacks.onPlanSubmitted(planDir, plan);
|
|
91
71
|
|
|
92
72
|
return {
|
|
93
|
-
content: [
|
|
94
|
-
{
|
|
95
|
-
type: 'text' as const,
|
|
96
|
-
text: `Plan "${params.title}" saved with ${steps.length} steps. Waiting for user to review and execute.`,
|
|
97
|
-
},
|
|
98
|
-
],
|
|
73
|
+
content: [{ type: 'text' as const, text: `Plan "${params.title}" saved with ${tasks.length} tasks. Review ${planDir}/plan.html, then execute when ready.` }],
|
|
99
74
|
details: { planDir, plan },
|
|
100
75
|
terminate: true,
|
|
101
76
|
};
|
|
@@ -106,32 +81,15 @@ export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCa
|
|
|
106
81
|
const title = (args as { title?: string }).title ?? '';
|
|
107
82
|
let content = theme.fg('toolTitle', theme.bold('submit_plan '));
|
|
108
83
|
content += theme.fg('accent', name);
|
|
109
|
-
if (title) {
|
|
110
|
-
content += ' ' + theme.fg('dim', `"${title}"`);
|
|
111
|
-
}
|
|
84
|
+
if (title) content += ' ' + theme.fg('dim', `"${title}"`);
|
|
112
85
|
return new Text(content, 0, 0);
|
|
113
86
|
},
|
|
114
87
|
|
|
115
88
|
renderResult(result, _options, theme) {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const lines: string[] = [];
|
|
123
|
-
|
|
124
|
-
// Title
|
|
125
|
-
lines.push(theme.fg('success', '✓ ') + theme.fg('accent', theme.bold(plan.title)));
|
|
126
|
-
lines.push('');
|
|
127
|
-
|
|
128
|
-
// Steps
|
|
129
|
-
for (let i = 0; i < plan.steps.length; i++) {
|
|
130
|
-
const step = plan.steps[i];
|
|
131
|
-
const num = theme.fg('muted', `${i + 1}.`);
|
|
132
|
-
lines.push(` ${num} ${step.description}`);
|
|
133
|
-
}
|
|
134
|
-
|
|
89
|
+
const plan = (result.details as { plan?: PlanData } | undefined)?.plan;
|
|
90
|
+
if (!plan) return new Text(theme.fg('success', '✓ Plan saved'), 0, 0);
|
|
91
|
+
const lines = [theme.fg('success', '✓ ') + theme.fg('accent', theme.bold(plan.title)), ''];
|
|
92
|
+
for (const task of plan.tasks) lines.push(` ${theme.fg('muted', task.id)} ${task.description}`);
|
|
135
93
|
return new Text(lines.join('\n'), 0, 0);
|
|
136
94
|
},
|
|
137
95
|
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* update_task tool — available during the execution phase.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import { StringEnum } from '@earendil-works/pi-ai';
|
|
7
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
8
|
+
import { Type } from 'typebox';
|
|
9
|
+
import type { PlanData, TaskStatus } from '../types.js';
|
|
10
|
+
|
|
11
|
+
export interface UpdateTaskCallbacks {
|
|
12
|
+
getPlan: () => PlanData | undefined;
|
|
13
|
+
onTaskUpdated: (taskId: string, status: Exclude<TaskStatus, 'pending'>, notes?: string) => void | Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function registerUpdateTaskTool(pi: ExtensionAPI, callbacks: UpdateTaskCallbacks): void {
|
|
17
|
+
pi.registerTool({
|
|
18
|
+
name: 'update_task',
|
|
19
|
+
label: 'Update Task',
|
|
20
|
+
description: 'Mark a plan task as done, skipped, or blocked. If blocked, execution pauses for user intervention.',
|
|
21
|
+
promptSnippet: 'Mark a plan task as done, skipped, or blocked',
|
|
22
|
+
promptGuidelines: [
|
|
23
|
+
'Call update_task after completing each plan task before moving to the next.',
|
|
24
|
+
'Always include notes summarizing what was done, why skipped, or why blocked.',
|
|
25
|
+
'Use update_task with status "blocked" and explain the reason in notes if a task cannot be completed.',
|
|
26
|
+
],
|
|
27
|
+
parameters: Type.Object({
|
|
28
|
+
task_id: Type.String({ description: 'Task ID (for example, t-001)' }),
|
|
29
|
+
status: StringEnum(['done', 'skipped', 'blocked'] as const),
|
|
30
|
+
notes: Type.Optional(Type.String({ description: 'What was done, why skipped, or why blocked' })),
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
async execute(_toolCallId, params) {
|
|
34
|
+
const plan = callbacks.getPlan();
|
|
35
|
+
if (!plan) throw new Error('No active plan. Cannot update task.');
|
|
36
|
+
|
|
37
|
+
const task = plan.tasks.find((candidate) => candidate.id === params.task_id);
|
|
38
|
+
if (!task) throw new Error(`Task not found: ${params.task_id}`);
|
|
39
|
+
if (task.status !== 'pending') {
|
|
40
|
+
throw new Error(`Task ${params.task_id} is already "${task.status}". Only pending tasks can be updated.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await callbacks.onTaskUpdated(params.task_id, params.status, params.notes);
|
|
44
|
+
|
|
45
|
+
const details = { task_id: params.task_id, status: params.status, notes: params.notes, description: task.description };
|
|
46
|
+
if (params.status === 'blocked') {
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: 'text' as const, text: `Task ${params.task_id} blocked. Execution paused — waiting for user input.` }],
|
|
49
|
+
details,
|
|
50
|
+
terminate: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const done = plan.tasks.filter((candidate) => candidate.status === 'done').length;
|
|
55
|
+
const skipped = plan.tasks.filter((candidate) => candidate.status === 'skipped').length;
|
|
56
|
+
const resolved = done + skipped;
|
|
57
|
+
const next = plan.tasks.find((candidate) => candidate.status === 'pending');
|
|
58
|
+
const statusEmoji = params.status === 'done' ? '✓' : '⊘';
|
|
59
|
+
let text = `${statusEmoji} Task ${params.task_id} ${params.status}. Progress: ${resolved}/${plan.tasks.length}`;
|
|
60
|
+
if (params.notes) text += ` — ${params.notes}`;
|
|
61
|
+
text += next ? `\n\nNext task ${next.id}: ${next.description}` : '\n\nAll tasks resolved!';
|
|
62
|
+
|
|
63
|
+
return { content: [{ type: 'text' as const, text }], details, terminate: !next };
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
renderCall(args, theme) {
|
|
67
|
+
const taskId = (args as { task_id?: string }).task_id ?? '?';
|
|
68
|
+
const status = (args as { status?: string }).status ?? '';
|
|
69
|
+
let content = theme.fg('toolTitle', theme.bold('update_task '));
|
|
70
|
+
content += theme.fg('muted', taskId);
|
|
71
|
+
if (status) content += ' ' + theme.fg(status === 'done' ? 'success' : status === 'skipped' ? 'warning' : 'error', status);
|
|
72
|
+
return new Text(content, 0, 0);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
renderResult(result, _options, theme) {
|
|
76
|
+
const details = result.details as { task_id?: string; status?: string; description?: string } | undefined;
|
|
77
|
+
if (!details) return new Text(theme.fg('dim', 'Updated'), 0, 0);
|
|
78
|
+
const statusMap: Record<string, string> = { done: theme.fg('success', '✓'), skipped: theme.fg('warning', '⊘'), blocked: theme.fg('error', '✗') };
|
|
79
|
+
return new Text(`${statusMap[details.status ?? ''] ?? ''} Task ${details.task_id}: ${details.description ?? ''}`, 0, 0);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -2,17 +2,32 @@
|
|
|
2
2
|
* Shared types for plan mode.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
export
|
|
5
|
+
export type TaskStatus = 'pending' | 'done' | 'skipped' | 'blocked';
|
|
6
|
+
|
|
7
|
+
export interface TaskRecord {
|
|
8
|
+
_type: 'task';
|
|
9
|
+
id: string;
|
|
6
10
|
description: string;
|
|
7
11
|
details: string;
|
|
8
|
-
status:
|
|
12
|
+
status: TaskStatus;
|
|
13
|
+
depends_on?: string[];
|
|
9
14
|
notes?: string;
|
|
15
|
+
created_at: string;
|
|
16
|
+
updated_at: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TaskMeta {
|
|
20
|
+
_type: 'meta';
|
|
21
|
+
title: string;
|
|
22
|
+
plan_name: string;
|
|
23
|
+
created_at: string;
|
|
10
24
|
}
|
|
11
25
|
|
|
12
26
|
export interface PlanData {
|
|
13
27
|
title: string;
|
|
28
|
+
planName: string;
|
|
14
29
|
handoff: string;
|
|
15
|
-
|
|
30
|
+
tasks: TaskRecord[];
|
|
16
31
|
}
|
|
17
32
|
|
|
18
33
|
export type ThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
|
@@ -29,3 +44,41 @@ export interface PersistedState {
|
|
|
29
44
|
plan: PlanData | undefined;
|
|
30
45
|
executionStartIdx: number | undefined;
|
|
31
46
|
}
|
|
47
|
+
|
|
48
|
+
const TASK_STATUSES = new Set<TaskStatus>(['pending', 'done', 'skipped', 'blocked']);
|
|
49
|
+
|
|
50
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
51
|
+
return typeof value === 'object' && value !== null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isStringArray(value: unknown): value is string[] {
|
|
55
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isTaskRecord(value: unknown): value is TaskRecord {
|
|
59
|
+
if (!isRecord(value)) return false;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
value._type === 'task' &&
|
|
63
|
+
typeof value.id === 'string' &&
|
|
64
|
+
typeof value.description === 'string' &&
|
|
65
|
+
typeof value.details === 'string' &&
|
|
66
|
+
typeof value.status === 'string' &&
|
|
67
|
+
TASK_STATUSES.has(value.status as TaskStatus) &&
|
|
68
|
+
(value.depends_on === undefined || isStringArray(value.depends_on)) &&
|
|
69
|
+
(value.notes === undefined || typeof value.notes === 'string') &&
|
|
70
|
+
typeof value.created_at === 'string' &&
|
|
71
|
+
typeof value.updated_at === 'string'
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isTaskMeta(value: unknown): value is TaskMeta {
|
|
76
|
+
if (!isRecord(value)) return false;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
value._type === 'meta' &&
|
|
80
|
+
typeof value.title === 'string' &&
|
|
81
|
+
typeof value.plan_name === 'string' &&
|
|
82
|
+
typeof value.created_at === 'string'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plan mode UI — status bar and
|
|
2
|
+
* Plan mode UI — status bar and task widget rendering.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
@@ -9,8 +9,8 @@ export function updateUI(state: PlanModeState, ctx: ExtensionContext): void {
|
|
|
9
9
|
const { theme } = ctx.ui;
|
|
10
10
|
|
|
11
11
|
if (state.executing && state.plan) {
|
|
12
|
-
const done = state.plan.
|
|
13
|
-
const total = state.plan.
|
|
12
|
+
const done = state.plan.tasks.filter((task) => task.status === 'done').length;
|
|
13
|
+
const total = state.plan.tasks.length;
|
|
14
14
|
ctx.ui.setStatus('plan-mode', theme.fg('accent', `📋 exec ${done}/${total}`));
|
|
15
15
|
} else if (state.planEnabled) {
|
|
16
16
|
ctx.ui.setStatus('plan-mode', theme.fg('warning', '📝 plan'));
|
|
@@ -19,17 +19,17 @@ export function updateUI(state: PlanModeState, ctx: ExtensionContext): void {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
if (state.executing && state.plan) {
|
|
22
|
-
const lines = state.plan.
|
|
23
|
-
const
|
|
24
|
-
switch (
|
|
22
|
+
const lines = state.plan.tasks.map((task) => {
|
|
23
|
+
const label = `${task.id} ${task.description}`;
|
|
24
|
+
switch (task.status) {
|
|
25
25
|
case 'done':
|
|
26
|
-
return theme.fg('success', '✓ ') + theme.fg('muted', theme.strikethrough(
|
|
26
|
+
return theme.fg('success', '✓ ') + theme.fg('muted', theme.strikethrough(label));
|
|
27
27
|
case 'skipped':
|
|
28
|
-
return theme.fg('warning', '⊘ ') + theme.fg('muted', theme.strikethrough(
|
|
28
|
+
return theme.fg('warning', '⊘ ') + theme.fg('muted', theme.strikethrough(label));
|
|
29
29
|
case 'blocked':
|
|
30
|
-
return theme.fg('error', '✗ ') + theme.fg('error',
|
|
30
|
+
return theme.fg('error', '✗ ') + theme.fg('error', label);
|
|
31
31
|
default:
|
|
32
|
-
return theme.fg('muted', '☐ ') +
|
|
32
|
+
return theme.fg('muted', '☐ ') + label;
|
|
33
33
|
}
|
|
34
34
|
});
|
|
35
35
|
ctx.ui.setWidget('plan-todos', lines);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreki-gg/pi-plan-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Two-phase planning workflow for pi — plan with claude-opus-4-6:medium, execute with gpt-5.5:low, with .plans/ file-based handoff",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
]
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@dreki-gg/pi-command-sandbox": "^0.2.0"
|
|
43
|
+
"@dreki-gg/pi-command-sandbox": "^0.2.0",
|
|
44
|
+
"pug": "^3.0.3"
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
47
|
"@types/node": "24",
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reads and writes `.plans/plans.json` — the tracking manifest for plan lifecycle.
|
|
3
|
-
*
|
|
4
|
-
* Schema:
|
|
5
|
-
* ```json
|
|
6
|
-
* {
|
|
7
|
-
* "<plan-name>": {
|
|
8
|
-
* "status": "in-progress" | "done",
|
|
9
|
-
* "title": "Human-readable plan title",
|
|
10
|
-
* "created": "2026-05-08T12:00:00.000Z",
|
|
11
|
-
* "completed": "2026-05-08T13:00:00.000Z" | null
|
|
12
|
-
* }
|
|
13
|
-
* }
|
|
14
|
-
* ```
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
export interface PlanEntry {
|
|
18
|
-
status: 'in-progress' | 'done';
|
|
19
|
-
title: string;
|
|
20
|
-
created: string;
|
|
21
|
-
completed: string | null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export type PlansManifest = Record<string, PlanEntry>;
|
|
25
|
-
|
|
26
|
-
import { readFile } from 'node:fs/promises';
|
|
27
|
-
|
|
28
|
-
const PLANS_JSON = '.plans/plans.json';
|
|
29
|
-
|
|
30
|
-
/** Read plans.json, returning current manifest (empty object if missing). */
|
|
31
|
-
export async function readPlansJson(): Promise<PlansManifest> {
|
|
32
|
-
try {
|
|
33
|
-
const text = await readFile(PLANS_JSON, 'utf-8');
|
|
34
|
-
if (text.trim()) {
|
|
35
|
-
return JSON.parse(text) as PlansManifest;
|
|
36
|
-
}
|
|
37
|
-
} catch {
|
|
38
|
-
// File doesn't exist or isn't valid JSON
|
|
39
|
-
}
|
|
40
|
-
return {};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Serialize the manifest to a formatted JSON string. */
|
|
44
|
-
export function serializePlansJson(manifest: PlansManifest): string {
|
|
45
|
-
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* update_step tool — available during the execution phase.
|
|
3
|
-
*
|
|
4
|
-
* The executor calls this to mark a plan step as done, skipped, or blocked.
|
|
5
|
-
* On "blocked", returns a stop signal so the executor pauses for user intervention.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
9
|
-
import { StringEnum } from '@earendil-works/pi-ai';
|
|
10
|
-
import { Text } from '@earendil-works/pi-tui';
|
|
11
|
-
import { Type } from 'typebox';
|
|
12
|
-
import type { PlanData } from '../types.js';
|
|
13
|
-
|
|
14
|
-
export interface UpdateStepCallbacks {
|
|
15
|
-
getPlan: () => PlanData | undefined;
|
|
16
|
-
onStepUpdated: (step: number, status: 'done' | 'skipped' | 'blocked', notes?: string) => void;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function registerUpdateStepTool(pi: ExtensionAPI, callbacks: UpdateStepCallbacks): void {
|
|
20
|
-
pi.registerTool({
|
|
21
|
-
name: 'update_step',
|
|
22
|
-
label: 'Update Step',
|
|
23
|
-
description:
|
|
24
|
-
'Mark a plan step as done, skipped, or blocked. ' +
|
|
25
|
-
'Call this after completing each step. ' +
|
|
26
|
-
'If a step is blocked, execution will pause for user intervention.',
|
|
27
|
-
promptSnippet: 'Mark a plan step as done, skipped, or blocked',
|
|
28
|
-
promptGuidelines: [
|
|
29
|
-
'Call update_step after completing each plan step to mark it done before moving to the next.',
|
|
30
|
-
'Always include notes when calling update_step — summarize what was done (files changed, key decisions made) for done steps, why it was unnecessary for skipped steps, or what went wrong for blocked steps.',
|
|
31
|
-
'Use update_step with status "skipped" if a step is unnecessary after inspecting the code.',
|
|
32
|
-
'Use update_step with status "blocked" and explain the reason in notes if a step cannot be completed — execution will pause for user input.',
|
|
33
|
-
],
|
|
34
|
-
parameters: Type.Object({
|
|
35
|
-
step: Type.Number({ description: 'Step number (1-indexed)' }),
|
|
36
|
-
status: StringEnum(['done', 'skipped', 'blocked'] as const),
|
|
37
|
-
notes: Type.Optional(
|
|
38
|
-
Type.String({ description: 'What was done, why skipped, or why blocked' }),
|
|
39
|
-
),
|
|
40
|
-
}),
|
|
41
|
-
|
|
42
|
-
async execute(_toolCallId, params) {
|
|
43
|
-
const plan = callbacks.getPlan();
|
|
44
|
-
if (!plan) {
|
|
45
|
-
throw new Error('No active plan. Cannot update step.');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const stepIdx = params.step - 1;
|
|
49
|
-
if (stepIdx < 0 || stepIdx >= plan.steps.length) {
|
|
50
|
-
throw new Error(
|
|
51
|
-
`Invalid step number ${params.step}. Plan has ${plan.steps.length} steps.`,
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const step = plan.steps[stepIdx];
|
|
56
|
-
if (step.status !== 'pending') {
|
|
57
|
-
throw new Error(
|
|
58
|
-
`Step ${params.step} is already "${step.status}". Only pending steps can be updated.`,
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
callbacks.onStepUpdated(params.step, params.status, params.notes);
|
|
63
|
-
|
|
64
|
-
const details = {
|
|
65
|
-
step: params.step,
|
|
66
|
-
status: params.status,
|
|
67
|
-
notes: params.notes,
|
|
68
|
-
description: step.description,
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
if (params.status === 'blocked') {
|
|
72
|
-
return {
|
|
73
|
-
content: [
|
|
74
|
-
{
|
|
75
|
-
type: 'text' as const,
|
|
76
|
-
text: `Step ${params.step} blocked. Execution paused — waiting for user input.`,
|
|
77
|
-
},
|
|
78
|
-
],
|
|
79
|
-
details,
|
|
80
|
-
terminate: true,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Build progress info
|
|
85
|
-
const done = plan.steps.filter((s) => s.status === 'done').length;
|
|
86
|
-
const skipped = plan.steps.filter((s) => s.status === 'skipped').length;
|
|
87
|
-
const resolved = done + skipped;
|
|
88
|
-
|
|
89
|
-
const statusEmoji = params.status === 'done' ? '✓' : '⊘';
|
|
90
|
-
let text = `${statusEmoji} Step ${params.step} ${params.status}. Progress: ${resolved}/${plan.steps.length}`;
|
|
91
|
-
if (params.notes) {
|
|
92
|
-
text += ` — ${params.notes}`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Find next pending step
|
|
96
|
-
const next = plan.steps.find((s) => s.status === 'pending');
|
|
97
|
-
if (next) {
|
|
98
|
-
const nextIdx = plan.steps.indexOf(next) + 1;
|
|
99
|
-
text += `\n\nNext step ${nextIdx}: ${next.description}`;
|
|
100
|
-
} else {
|
|
101
|
-
text += '\n\nAll steps resolved!';
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return {
|
|
105
|
-
content: [{ type: 'text' as const, text }],
|
|
106
|
-
details,
|
|
107
|
-
// Stop the agent when all steps are done so agent_end fires immediately
|
|
108
|
-
terminate: !next,
|
|
109
|
-
};
|
|
110
|
-
},
|
|
111
|
-
|
|
112
|
-
renderCall(args, theme) {
|
|
113
|
-
const step = (args as { step?: number }).step ?? '?';
|
|
114
|
-
const status = (args as { status?: string }).status ?? '';
|
|
115
|
-
let content = theme.fg('toolTitle', theme.bold('update_step '));
|
|
116
|
-
content += theme.fg('muted', `#${step}`);
|
|
117
|
-
if (status) {
|
|
118
|
-
const color = status === 'done' ? 'success' : status === 'skipped' ? 'warning' : 'error';
|
|
119
|
-
content += ' ' + theme.fg(color, status);
|
|
120
|
-
}
|
|
121
|
-
return new Text(content, 0, 0);
|
|
122
|
-
},
|
|
123
|
-
|
|
124
|
-
renderResult(result, _options, theme) {
|
|
125
|
-
const details = result.details as {
|
|
126
|
-
step?: number;
|
|
127
|
-
status?: string;
|
|
128
|
-
description?: string;
|
|
129
|
-
} | undefined;
|
|
130
|
-
|
|
131
|
-
if (!details) {
|
|
132
|
-
return new Text(theme.fg('dim', 'Updated'), 0, 0);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const statusMap: Record<string, string> = {
|
|
136
|
-
done: theme.fg('success', '✓'),
|
|
137
|
-
skipped: theme.fg('warning', '⊘'),
|
|
138
|
-
blocked: theme.fg('error', '✗'),
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
const icon = statusMap[details.status ?? ''] ?? '';
|
|
142
|
-
const desc = details.description ?? '';
|
|
143
|
-
return new Text(`${icon} Step ${details.step}: ${desc}`, 0, 0);
|
|
144
|
-
},
|
|
145
|
-
});
|
|
146
|
-
}
|