@dreki-gg/pi-plan-mode 0.6.4 → 0.10.1
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 +101 -0
- package/extensions/plan-mode/__tests__/agent-end-safety.test.ts +125 -0
- package/extensions/plan-mode/{utils.test.ts → __tests__/utils.test.ts} +7 -1
- package/extensions/plan-mode/constants.ts +33 -0
- package/extensions/plan-mode/context-filter.ts +32 -0
- package/extensions/plan-mode/index.ts +193 -515
- package/extensions/plan-mode/phase-transitions.ts +87 -0
- package/extensions/plan-mode/plan-storage.ts +67 -0
- package/extensions/plan-mode/plans-json.ts +1 -5
- package/extensions/plan-mode/prompts.ts +67 -0
- package/extensions/plan-mode/resume.ts +121 -0
- package/extensions/plan-mode/state.ts +47 -0
- package/extensions/plan-mode/tools/submit-plan.ts +144 -0
- package/extensions/plan-mode/tools/update-step.ts +145 -0
- package/extensions/plan-mode/types.ts +32 -0
- package/extensions/plan-mode/ui.ts +39 -0
- package/extensions/plan-mode/utils.ts +1 -82
- package/package.json +4 -8
- package/node_modules/@dreki-gg/pi-command-sandbox/dist/index.d.mts +0 -104
- package/node_modules/@dreki-gg/pi-command-sandbox/dist/index.mjs +0 -396
- package/node_modules/@dreki-gg/pi-command-sandbox/package.json +0 -42
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase transitions — enter/exit plan mode, start execution, switch models.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import type { PlanModeState } from './state.js';
|
|
7
|
+
import type { ThinkingLevel } from './types.js';
|
|
8
|
+
import { PLAN_TOOLS, EXEC_TOOLS, PLAN_MODEL, PLAN_THINKING, EXEC_MODEL, EXEC_THINKING } from './constants.js';
|
|
9
|
+
import { updateUI } from './ui.js';
|
|
10
|
+
|
|
11
|
+
export async function switchModel(
|
|
12
|
+
pi: ExtensionAPI,
|
|
13
|
+
ctx: ExtensionContext,
|
|
14
|
+
preset: { provider: string; id: string },
|
|
15
|
+
): Promise<boolean> {
|
|
16
|
+
const model = ctx.modelRegistry.find(preset.provider, preset.id);
|
|
17
|
+
if (!model) {
|
|
18
|
+
ctx.ui.notify(`Model ${preset.provider}/${preset.id} not found`, 'error');
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const ok = await pi.setModel(model);
|
|
22
|
+
if (!ok) {
|
|
23
|
+
ctx.ui.notify(`No API key for ${preset.provider}/${preset.id}`, 'error');
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function enterPlanMode(
|
|
30
|
+
state: PlanModeState,
|
|
31
|
+
pi: ExtensionAPI,
|
|
32
|
+
ctx: ExtensionContext,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
state.planEnabled = true;
|
|
35
|
+
state.executing = false;
|
|
36
|
+
state.planDir = undefined;
|
|
37
|
+
state.plan = undefined;
|
|
38
|
+
state.previousThinking = pi.getThinkingLevel() as ThinkingLevel;
|
|
39
|
+
state.previousModel = ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined;
|
|
40
|
+
pi.setActiveTools(PLAN_TOOLS);
|
|
41
|
+
await switchModel(pi, ctx, PLAN_MODEL);
|
|
42
|
+
pi.setThinkingLevel(PLAN_THINKING);
|
|
43
|
+
ctx.ui.notify(
|
|
44
|
+
`Plan mode ON — ${PLAN_MODEL.provider}/${PLAN_MODEL.id}:${PLAN_THINKING}`,
|
|
45
|
+
'info',
|
|
46
|
+
);
|
|
47
|
+
updateUI(state, ctx);
|
|
48
|
+
state.persist(pi);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function exitPlanMode(
|
|
52
|
+
state: PlanModeState,
|
|
53
|
+
pi: ExtensionAPI,
|
|
54
|
+
ctx: ExtensionContext,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
const { previousModel, previousThinking } = state;
|
|
57
|
+
state.reset();
|
|
58
|
+
pi.setActiveTools(EXEC_TOOLS);
|
|
59
|
+
if (previousModel) {
|
|
60
|
+
await switchModel(pi, ctx, previousModel);
|
|
61
|
+
}
|
|
62
|
+
if (previousThinking) {
|
|
63
|
+
pi.setThinkingLevel(previousThinking);
|
|
64
|
+
}
|
|
65
|
+
ctx.ui.notify('Plan mode OFF — original model restored', 'info');
|
|
66
|
+
updateUI(state, ctx);
|
|
67
|
+
state.persist(pi);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function startExecution(
|
|
71
|
+
state: PlanModeState,
|
|
72
|
+
pi: ExtensionAPI,
|
|
73
|
+
ctx: ExtensionContext,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
state.planEnabled = false;
|
|
76
|
+
state.executing = true;
|
|
77
|
+
state.executionStartIdx = ctx.sessionManager.getEntries().length;
|
|
78
|
+
pi.setActiveTools(EXEC_TOOLS);
|
|
79
|
+
await switchModel(pi, ctx, EXEC_MODEL);
|
|
80
|
+
pi.setThinkingLevel(EXEC_THINKING);
|
|
81
|
+
ctx.ui.notify(
|
|
82
|
+
`Executing plan — ${EXEC_MODEL.provider}/${EXEC_MODEL.id}:${EXEC_THINKING}`,
|
|
83
|
+
'info',
|
|
84
|
+
);
|
|
85
|
+
updateUI(state, ctx);
|
|
86
|
+
state.persist(pi);
|
|
87
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan disk I/O — save/load plans, exec-pending markers, and manifest updates.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { readPlansJson, serializePlansJson } from './plans-json.js';
|
|
7
|
+
import type { PlanData, ExecPendingConfig } from './types.js';
|
|
8
|
+
import { EXEC_PENDING_FILE } from './constants.js';
|
|
9
|
+
|
|
10
|
+
export async function savePlanToDisk(planDir: string, plan: PlanData): Promise<void> {
|
|
11
|
+
await mkdir(planDir, { recursive: true });
|
|
12
|
+
await writeFile(`${planDir}/plan.json`, JSON.stringify(plan, null, 2) + '\n', 'utf-8');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function loadPlanFromDisk(dir: string): Promise<PlanData | undefined> {
|
|
16
|
+
try {
|
|
17
|
+
const text = await readFile(`${dir}/plan.json`, 'utf-8');
|
|
18
|
+
return JSON.parse(text) as PlanData;
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function writeExecPending(dir: string, config: ExecPendingConfig): Promise<void> {
|
|
25
|
+
await mkdir(dir, { recursive: true });
|
|
26
|
+
await writeFile(`${dir}/${EXEC_PENDING_FILE}`, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function readAndClearExecPending(): Promise<{ planDir: string; config: ExecPendingConfig } | undefined> {
|
|
30
|
+
try {
|
|
31
|
+
const entries = await readdir('.plans', { withFileTypes: true });
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (!entry.isDirectory()) continue;
|
|
34
|
+
const dir = `.plans/${entry.name}`;
|
|
35
|
+
const markerPath = `${dir}/${EXEC_PENDING_FILE}`;
|
|
36
|
+
try {
|
|
37
|
+
const text = await readFile(markerPath, 'utf-8');
|
|
38
|
+
const config = JSON.parse(text) as ExecPendingConfig;
|
|
39
|
+
await unlink(markerPath);
|
|
40
|
+
return { planDir: dir, config };
|
|
41
|
+
} catch {
|
|
42
|
+
// No marker in this directory
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// .plans/ doesn't exist
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function updatePlansManifest(
|
|
52
|
+
planName: string,
|
|
53
|
+
status: 'in-progress' | 'done',
|
|
54
|
+
title?: string,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
const manifest = await readPlansJson();
|
|
57
|
+
const existing = manifest[planName];
|
|
58
|
+
const now = new Date().toISOString();
|
|
59
|
+
manifest[planName] = {
|
|
60
|
+
status,
|
|
61
|
+
title: title ?? existing?.title ?? 'Untitled plan',
|
|
62
|
+
created: existing?.created ?? now,
|
|
63
|
+
completed: status === 'done' ? now : null,
|
|
64
|
+
};
|
|
65
|
+
await mkdir('.plans', { recursive: true });
|
|
66
|
+
await writeFile('.plans/plans.json', serializePlansJson(manifest), 'utf-8');
|
|
67
|
+
}
|
|
@@ -45,8 +45,4 @@ export function serializePlansJson(manifest: PlansManifest): string {
|
|
|
45
45
|
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
export function extractPlanTitle(planContent: string): string {
|
|
50
|
-
const match = planContent.match(/^#\s+(.+)$/m);
|
|
51
|
-
return match ? match[1].trim() : 'Untitled plan';
|
|
52
|
-
}
|
|
48
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt builders for plan and execution phases.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { PlanData } from './types.js';
|
|
6
|
+
import { PLAN_TOOLS } from './constants.js';
|
|
7
|
+
|
|
8
|
+
export function buildPlanModePrompt(): string {
|
|
9
|
+
return `[PLAN MODE ACTIVE]
|
|
10
|
+
You are in plan mode — a planning phase with strict bash restrictions.
|
|
11
|
+
|
|
12
|
+
Restrictions:
|
|
13
|
+
- Available tools: ${PLAN_TOOLS.join(', ')}
|
|
14
|
+
- Bash is restricted to read-only commands (ls, grep, git status, etc.)
|
|
15
|
+
|
|
16
|
+
Your task:
|
|
17
|
+
1. Analyze the codebase thoroughly using the available read-only tools
|
|
18
|
+
2. Ask clarifying questions if needed (use the questionnaire tool)
|
|
19
|
+
3. Produce a detailed, concrete plan
|
|
20
|
+
|
|
21
|
+
When you are ready to finalize the plan, call the submit_plan tool with:
|
|
22
|
+
- name: a short kebab-case name (e.g. "add-auth-middleware")
|
|
23
|
+
- title: a human-readable plan title
|
|
24
|
+
- context: complete codebase context including relevant file paths, APIs, patterns, constraints, and gotchas — this must be thorough enough that an implementor with zero prior context can execute the plan
|
|
25
|
+
- steps: an array of steps, each with a short description (≤60 chars for display) and detailed implementation instructions
|
|
26
|
+
- risks: any open questions, assumptions, or concerns
|
|
27
|
+
|
|
28
|
+
Do NOT attempt to make product code changes — only analyze and plan.
|
|
29
|
+
Do NOT write files manually — use submit_plan to finalize the plan.`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildExecutionPrompt(plan: PlanData): string | undefined {
|
|
33
|
+
const remaining = plan.steps
|
|
34
|
+
.map((s, i) => ({ ...s, num: i + 1 }))
|
|
35
|
+
.filter((s) => s.status === 'pending');
|
|
36
|
+
|
|
37
|
+
if (remaining.length === 0) return undefined;
|
|
38
|
+
|
|
39
|
+
const stepList = remaining
|
|
40
|
+
.map((s) => `${s.num}. ${s.description}\n Details: ${s.details}`)
|
|
41
|
+
.join('\n\n');
|
|
42
|
+
|
|
43
|
+
const currentStep = remaining[0];
|
|
44
|
+
|
|
45
|
+
return `[EXECUTING PLAN — FOLLOW THE PLAN EXACTLY]
|
|
46
|
+
|
|
47
|
+
You are executing a structured plan. Your ONLY job is to implement the plan steps below, one at a time.
|
|
48
|
+
|
|
49
|
+
Rules:
|
|
50
|
+
- Work on ONE step at a time, starting with step ${currentStep.num}
|
|
51
|
+
- After completing each step, IMMEDIATELY call update_step to mark it done
|
|
52
|
+
- Do NOT run diagnostics, linters, test suites, or skills unless a step explicitly asks for it
|
|
53
|
+
- Do NOT explore the codebase beyond what the current step requires
|
|
54
|
+
- Do NOT deviate from the plan — if something seems wrong, call update_step with status "blocked"
|
|
55
|
+
|
|
56
|
+
## Current step
|
|
57
|
+
Step ${currentStep.num}: ${currentStep.description}
|
|
58
|
+
Details: ${currentStep.details}
|
|
59
|
+
|
|
60
|
+
## Codebase context
|
|
61
|
+
${plan.context}
|
|
62
|
+
|
|
63
|
+
## All remaining steps
|
|
64
|
+
${stepList}
|
|
65
|
+
|
|
66
|
+
Start with step ${currentStep.num} NOW. When done, call update_step(step=${currentStep.num}, status="done").`;
|
|
67
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume and execution handoff — pick up in-progress plans, model picker, new session handoff.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import type { PlanModeState } from './state.js';
|
|
7
|
+
import type { PlanData } from './types.js';
|
|
8
|
+
import { EXEC_THINKING, EXEC_MODEL_OPTIONS } from './constants.js';
|
|
9
|
+
import { readPlansJson } from './plans-json.js';
|
|
10
|
+
import { loadPlanFromDisk, writeExecPending, savePlanToDisk } from './plan-storage.js';
|
|
11
|
+
import { enterPlanMode } from './phase-transitions.js';
|
|
12
|
+
|
|
13
|
+
export async function pickExecutionModel(
|
|
14
|
+
ctx: ExtensionContext,
|
|
15
|
+
): Promise<{ provider: string; id: string } | undefined> {
|
|
16
|
+
const labels = EXEC_MODEL_OPTIONS.map((o) => o.label);
|
|
17
|
+
const choice = await ctx.ui.select('Execute with:', labels);
|
|
18
|
+
if (!choice) return undefined;
|
|
19
|
+
return EXEC_MODEL_OPTIONS.find((o) => o.label === choice)?.model;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function executeInNewSession(
|
|
23
|
+
ctx: ExtensionCommandContext,
|
|
24
|
+
dir: string,
|
|
25
|
+
_planData: PlanData,
|
|
26
|
+
kickoff: string,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const selectedModel = await pickExecutionModel(ctx);
|
|
29
|
+
if (!selectedModel) return;
|
|
30
|
+
|
|
31
|
+
await writeExecPending(dir, { model: selectedModel, thinking: EXEC_THINKING });
|
|
32
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
33
|
+
|
|
34
|
+
await ctx.newSession({
|
|
35
|
+
parentSession,
|
|
36
|
+
withSession: async (newCtx) => {
|
|
37
|
+
await newCtx.sendUserMessage(kickoff);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function resumePlan(
|
|
43
|
+
state: PlanModeState,
|
|
44
|
+
pi: ExtensionAPI,
|
|
45
|
+
ctx: ExtensionCommandContext,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
const manifest = await readPlansJson();
|
|
48
|
+
const inProgress = Object.entries(manifest).filter(([, e]) => e.status === 'in-progress');
|
|
49
|
+
|
|
50
|
+
if (inProgress.length === 0) {
|
|
51
|
+
ctx.ui.notify('No in-progress plans found in .plans/plans.json', 'info');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const options = inProgress.map(([name, entry]) => `${name} — ${entry.title}`);
|
|
56
|
+
options.push('Cancel');
|
|
57
|
+
|
|
58
|
+
const choice = await ctx.ui.select('Resume which plan?', options);
|
|
59
|
+
if (!choice || choice === 'Cancel') return;
|
|
60
|
+
|
|
61
|
+
const planName = choice.split(' — ')[0];
|
|
62
|
+
const dir = `.plans/${planName}`;
|
|
63
|
+
const loaded = await loadPlanFromDisk(dir);
|
|
64
|
+
|
|
65
|
+
if (!loaded) {
|
|
66
|
+
ctx.ui.notify(`Could not load ${dir}/plan.json`, 'error');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
state.planDir = dir;
|
|
71
|
+
state.plan = loaded;
|
|
72
|
+
|
|
73
|
+
const doneCount = state.plan.steps.filter((s) => s.status === 'done' || s.status === 'skipped').length;
|
|
74
|
+
const pendingCount = state.plan.steps.filter((s) => s.status === 'pending').length;
|
|
75
|
+
const blockedCount = state.plan.steps.filter((s) => s.status === 'blocked').length;
|
|
76
|
+
|
|
77
|
+
if (pendingCount === 0 && blockedCount === 0) {
|
|
78
|
+
ctx.ui.notify(`Plan "${state.plan.title}" is already complete (${doneCount}/${state.plan.steps.length} done).`, 'info');
|
|
79
|
+
state.plan = undefined;
|
|
80
|
+
state.planDir = undefined;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const summary = `${doneCount}/${state.plan.steps.length} done, ${pendingCount} pending${blockedCount ? `, ${blockedCount} blocked` : ''}`;
|
|
85
|
+
const action = await ctx.ui.select(
|
|
86
|
+
`Resume "${state.plan.title}" (${summary}) — what next?`,
|
|
87
|
+
['Continue execution', 'Re-plan from scratch', 'Cancel'],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (!action || action === 'Cancel') {
|
|
91
|
+
state.plan = undefined;
|
|
92
|
+
state.planDir = undefined;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (action === 'Re-plan from scratch') {
|
|
97
|
+
const planTitle = state.plan.title;
|
|
98
|
+
const planDirPath = state.planDir;
|
|
99
|
+
await enterPlanMode(state, pi, ctx);
|
|
100
|
+
pi.sendUserMessage(
|
|
101
|
+
`There is an existing plan "${planTitle}" at ${planDirPath}/plan.json. Review it and create a revised plan using submit_plan. Keep the same plan name ("${planName}").`,
|
|
102
|
+
);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Unblock any blocked steps
|
|
107
|
+
if (blockedCount > 0) {
|
|
108
|
+
for (const step of state.plan.steps) {
|
|
109
|
+
if (step.status === 'blocked') step.status = 'pending';
|
|
110
|
+
}
|
|
111
|
+
await savePlanToDisk(dir, state.plan);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const remaining = state.plan.steps
|
|
115
|
+
.map((s, i) => ({ ...s, num: i + 1 }))
|
|
116
|
+
.filter((s) => s.status === 'pending');
|
|
117
|
+
const stepList = remaining.map((s) => `${s.num}. ${s.description}`).join('\n');
|
|
118
|
+
const kickoff = `Resuming plan: "${state.plan.title}"\n\nCompleted: ${doneCount}/${state.plan.steps.length} steps\n\nRemaining steps:\n${stepList}\n\nContinue from step ${remaining[0].num}. Call update_step after completing each step.`;
|
|
119
|
+
|
|
120
|
+
await executeInNewSession(ctx, dir, state.plan, kickoff);
|
|
121
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encapsulates all mutable plan-mode state with persistence helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import type { PlanData, PersistedState, ThinkingLevel } from './types.js';
|
|
7
|
+
|
|
8
|
+
export class PlanModeState {
|
|
9
|
+
planEnabled = false;
|
|
10
|
+
executing = false;
|
|
11
|
+
planDir: string | undefined;
|
|
12
|
+
plan: PlanData | undefined;
|
|
13
|
+
executionStartIdx: number | undefined;
|
|
14
|
+
previousThinking: ThinkingLevel | undefined;
|
|
15
|
+
previousModel: { provider: string; id: string } | undefined;
|
|
16
|
+
|
|
17
|
+
persist(pi: ExtensionAPI): void {
|
|
18
|
+
pi.appendEntry<PersistedState>('plan-mode', {
|
|
19
|
+
planEnabled: this.planEnabled,
|
|
20
|
+
executing: this.executing,
|
|
21
|
+
planDir: this.planDir,
|
|
22
|
+
plan: this.plan,
|
|
23
|
+
executionStartIdx: this.executionStartIdx,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
restore(entries: Array<{ type: string; customType?: string; data?: PersistedState }>): void {
|
|
28
|
+
const saved = entries
|
|
29
|
+
.filter((e) => e.type === 'custom' && e.customType === 'plan-mode')
|
|
30
|
+
.pop();
|
|
31
|
+
if (saved?.data) {
|
|
32
|
+
this.planEnabled = saved.data.planEnabled ?? this.planEnabled;
|
|
33
|
+
this.executing = saved.data.executing ?? this.executing;
|
|
34
|
+
this.planDir = saved.data.planDir ?? this.planDir;
|
|
35
|
+
this.plan = saved.data.plan ?? this.plan;
|
|
36
|
+
this.executionStartIdx = saved.data.executionStartIdx ?? this.executionStartIdx;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
reset(): void {
|
|
41
|
+
this.planEnabled = false;
|
|
42
|
+
this.executing = false;
|
|
43
|
+
this.planDir = undefined;
|
|
44
|
+
this.plan = undefined;
|
|
45
|
+
this.executionStartIdx = undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
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
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
10
|
+
import { Type } from 'typebox';
|
|
11
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { readPlansJson, serializePlansJson } from '../plans-json.js';
|
|
13
|
+
import { toKebabCase } from '../utils.js';
|
|
14
|
+
import type { PlanData, PlanStep } from '../types.js';
|
|
15
|
+
|
|
16
|
+
export interface SubmitPlanCallbacks {
|
|
17
|
+
onPlanSubmitted: (planDir: string, plan: PlanData) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerSubmitPlanTool(pi: ExtensionAPI, callbacks: SubmitPlanCallbacks): void {
|
|
21
|
+
pi.registerTool({
|
|
22
|
+
name: 'submit_plan',
|
|
23
|
+
label: 'Submit Plan',
|
|
24
|
+
description:
|
|
25
|
+
'Submit a structured plan with a title, context, numbered steps, and risks. ' +
|
|
26
|
+
'Each step has a short description (for progress display) and detailed implementation instructions. ' +
|
|
27
|
+
'This finalizes the plan and writes it to .plans/<name>/plan.json.',
|
|
28
|
+
promptSnippet:
|
|
29
|
+
'Submit a structured plan with title, context, steps (description + details), and risks',
|
|
30
|
+
promptGuidelines: [
|
|
31
|
+
'Call submit_plan once when you have finished analyzing the codebase and are ready to finalize your plan.',
|
|
32
|
+
'Each submit_plan step description should be a short label (≤60 chars). Put full implementation instructions in the details field.',
|
|
33
|
+
'The submit_plan context field must include all codebase findings, relevant file paths, APIs, patterns, and constraints so an executor with zero prior context can implement the plan.',
|
|
34
|
+
],
|
|
35
|
+
parameters: Type.Object({
|
|
36
|
+
name: Type.String({
|
|
37
|
+
description: 'Short kebab-case name for the plan (e.g. "add-auth-middleware")',
|
|
38
|
+
}),
|
|
39
|
+
title: Type.String({ description: 'Human-readable plan title' }),
|
|
40
|
+
context: Type.String({
|
|
41
|
+
description:
|
|
42
|
+
'Complete codebase context: relevant file paths, APIs, patterns, constraints, and gotchas',
|
|
43
|
+
}),
|
|
44
|
+
steps: Type.Array(
|
|
45
|
+
Type.Object({
|
|
46
|
+
description: Type.String({
|
|
47
|
+
description: 'Short step label for progress display (≤60 chars)',
|
|
48
|
+
}),
|
|
49
|
+
details: Type.String({
|
|
50
|
+
description: 'Full implementation instructions for this step',
|
|
51
|
+
}),
|
|
52
|
+
}),
|
|
53
|
+
{ minItems: 1 },
|
|
54
|
+
),
|
|
55
|
+
risks: Type.String({ description: 'Open questions, assumptions, and risks' }),
|
|
56
|
+
}),
|
|
57
|
+
|
|
58
|
+
async execute(_toolCallId, params) {
|
|
59
|
+
const planName = toKebabCase(params.name);
|
|
60
|
+
const planDir = `.plans/${planName}`;
|
|
61
|
+
|
|
62
|
+
const steps: PlanStep[] = params.steps.map((s) => ({
|
|
63
|
+
description: s.description.slice(0, 60),
|
|
64
|
+
details: s.details,
|
|
65
|
+
status: 'pending' as const,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
const plan: PlanData = {
|
|
69
|
+
title: params.title,
|
|
70
|
+
context: params.context,
|
|
71
|
+
steps,
|
|
72
|
+
risks: params.risks,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Write plan.json
|
|
76
|
+
await mkdir(planDir, { recursive: true });
|
|
77
|
+
await writeFile(`${planDir}/plan.json`, JSON.stringify(plan, null, 2) + '\n', 'utf-8');
|
|
78
|
+
|
|
79
|
+
// Update plans.json manifest
|
|
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');
|
|
89
|
+
|
|
90
|
+
callbacks.onPlanSubmitted(planDir, plan);
|
|
91
|
+
|
|
92
|
+
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
|
+
],
|
|
99
|
+
details: { planDir, plan },
|
|
100
|
+
terminate: true,
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
renderCall(args, theme) {
|
|
105
|
+
const name = (args as { name?: string }).name ?? 'plan';
|
|
106
|
+
const title = (args as { title?: string }).title ?? '';
|
|
107
|
+
let content = theme.fg('toolTitle', theme.bold('submit_plan '));
|
|
108
|
+
content += theme.fg('accent', name);
|
|
109
|
+
if (title) {
|
|
110
|
+
content += ' ' + theme.fg('dim', `"${title}"`);
|
|
111
|
+
}
|
|
112
|
+
return new Text(content, 0, 0);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
renderResult(result, _options, theme) {
|
|
116
|
+
const details = result.details as { plan?: PlanData } | undefined;
|
|
117
|
+
const plan = details?.plan;
|
|
118
|
+
if (!plan) {
|
|
119
|
+
return new Text(theme.fg('success', '✓ Plan saved'), 0, 0);
|
|
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
|
+
|
|
135
|
+
// Risks
|
|
136
|
+
if (plan.risks) {
|
|
137
|
+
lines.push('');
|
|
138
|
+
lines.push(theme.fg('warning', '⚠ Risks: ') + theme.fg('dim', plan.risks));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return new Text(lines.join('\n'), 0, 0);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
'Use update_step with status "skipped" if a step is unnecessary after inspecting the code.',
|
|
31
|
+
'Use update_step with status "blocked" and explain the reason in notes if a step cannot be completed — execution will pause for user input.',
|
|
32
|
+
],
|
|
33
|
+
parameters: Type.Object({
|
|
34
|
+
step: Type.Number({ description: 'Step number (1-indexed)' }),
|
|
35
|
+
status: StringEnum(['done', 'skipped', 'blocked'] as const),
|
|
36
|
+
notes: Type.Optional(
|
|
37
|
+
Type.String({ description: 'What was done, why skipped, or why blocked' }),
|
|
38
|
+
),
|
|
39
|
+
}),
|
|
40
|
+
|
|
41
|
+
async execute(_toolCallId, params) {
|
|
42
|
+
const plan = callbacks.getPlan();
|
|
43
|
+
if (!plan) {
|
|
44
|
+
throw new Error('No active plan. Cannot update step.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const stepIdx = params.step - 1;
|
|
48
|
+
if (stepIdx < 0 || stepIdx >= plan.steps.length) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Invalid step number ${params.step}. Plan has ${plan.steps.length} steps.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const step = plan.steps[stepIdx];
|
|
55
|
+
if (step.status !== 'pending') {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Step ${params.step} is already "${step.status}". Only pending steps can be updated.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
callbacks.onStepUpdated(params.step, params.status, params.notes);
|
|
62
|
+
|
|
63
|
+
const details = {
|
|
64
|
+
step: params.step,
|
|
65
|
+
status: params.status,
|
|
66
|
+
notes: params.notes,
|
|
67
|
+
description: step.description,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (params.status === 'blocked') {
|
|
71
|
+
return {
|
|
72
|
+
content: [
|
|
73
|
+
{
|
|
74
|
+
type: 'text' as const,
|
|
75
|
+
text: `Step ${params.step} blocked. Execution paused — waiting for user input.`,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
details,
|
|
79
|
+
terminate: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Build progress info
|
|
84
|
+
const done = plan.steps.filter((s) => s.status === 'done').length;
|
|
85
|
+
const skipped = plan.steps.filter((s) => s.status === 'skipped').length;
|
|
86
|
+
const resolved = done + skipped;
|
|
87
|
+
|
|
88
|
+
const statusEmoji = params.status === 'done' ? '✓' : '⊘';
|
|
89
|
+
let text = `${statusEmoji} Step ${params.step} ${params.status}. Progress: ${resolved}/${plan.steps.length}`;
|
|
90
|
+
if (params.notes) {
|
|
91
|
+
text += ` — ${params.notes}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Find next pending step
|
|
95
|
+
const next = plan.steps.find((s) => s.status === 'pending');
|
|
96
|
+
if (next) {
|
|
97
|
+
const nextIdx = plan.steps.indexOf(next) + 1;
|
|
98
|
+
text += `\n\nNext step ${nextIdx}: ${next.description}`;
|
|
99
|
+
} else {
|
|
100
|
+
text += '\n\nAll steps resolved!';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
content: [{ type: 'text' as const, text }],
|
|
105
|
+
details,
|
|
106
|
+
// Stop the agent when all steps are done so agent_end fires immediately
|
|
107
|
+
terminate: !next,
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
renderCall(args, theme) {
|
|
112
|
+
const step = (args as { step?: number }).step ?? '?';
|
|
113
|
+
const status = (args as { status?: string }).status ?? '';
|
|
114
|
+
let content = theme.fg('toolTitle', theme.bold('update_step '));
|
|
115
|
+
content += theme.fg('muted', `#${step}`);
|
|
116
|
+
if (status) {
|
|
117
|
+
const color = status === 'done' ? 'success' : status === 'skipped' ? 'warning' : 'error';
|
|
118
|
+
content += ' ' + theme.fg(color, status);
|
|
119
|
+
}
|
|
120
|
+
return new Text(content, 0, 0);
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
renderResult(result, _options, theme) {
|
|
124
|
+
const details = result.details as {
|
|
125
|
+
step?: number;
|
|
126
|
+
status?: string;
|
|
127
|
+
description?: string;
|
|
128
|
+
} | undefined;
|
|
129
|
+
|
|
130
|
+
if (!details) {
|
|
131
|
+
return new Text(theme.fg('dim', 'Updated'), 0, 0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const statusMap: Record<string, string> = {
|
|
135
|
+
done: theme.fg('success', '✓'),
|
|
136
|
+
skipped: theme.fg('warning', '⊘'),
|
|
137
|
+
blocked: theme.fg('error', '✗'),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const icon = statusMap[details.status ?? ''] ?? '';
|
|
141
|
+
const desc = details.description ?? '';
|
|
142
|
+
return new Text(`${icon} Step ${details.step}: ${desc}`, 0, 0);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|