@zhushanwen/pi-plan 0.4.4 → 0.4.6
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/README.md +37 -0
- package/package.json +7 -3
- package/src/__tests__/command.test.ts +11 -4
- package/src/__tests__/compact-criteria-array.test.ts +22 -13
- package/src/__tests__/compact-handler.test.ts +160 -50
- package/src/__tests__/state.test.ts +31 -13
- package/src/__tests__/templates.test.ts +33 -33
- package/src/__tests__/tool.test.ts +126 -38
- package/src/command.ts +2 -3
- package/src/compact.ts +124 -51
- package/src/index.ts +3 -9
- package/src/state.ts +1 -7
- package/src/templates.ts +6 -28
- package/src/tool.ts +69 -99
- package/templates/bugfix-plan.md +1 -1
- package/templates/implementation-plan.md +3 -5
- package/templates/refactor-plan.md +1 -1
- package/templates/research-plan.md +1 -1
package/src/tool.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
import * as path from "node:path";
|
|
3
|
-
|
|
4
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
5
2
|
import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
6
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { firstContentText } from "@xyz-agent/extension-protocol";
|
|
7
5
|
import { Type } from "typebox";
|
|
8
6
|
|
|
7
|
+
import { detectGoalCapability, GOAL_FAILURE_RECOVERY, handlePlanComplete } from "./compact.js";
|
|
8
|
+
import type { GoalBridgeOutcome } from "./compact.js";
|
|
9
9
|
import type { PlanSessionMap, PlanState } from "./state.js";
|
|
10
10
|
import { getPlanState, persistPlanState, resetPlanState } from "./state.js";
|
|
11
11
|
import { listTemplates, loadTemplate } from "./templates.js";
|
|
@@ -16,7 +16,6 @@ import { updatePlanWidget } from "./widget.js";
|
|
|
16
16
|
export const PLAN_ACTIONS = [
|
|
17
17
|
"list-template",
|
|
18
18
|
"select-template",
|
|
19
|
-
"create-template",
|
|
20
19
|
"complete",
|
|
21
20
|
"abort",
|
|
22
21
|
] as const;
|
|
@@ -31,20 +30,13 @@ export function validateAction(action: string): action is PlanAction {
|
|
|
31
30
|
|
|
32
31
|
interface ListTemplateDetails {
|
|
33
32
|
action: "list-template";
|
|
34
|
-
templates: Array<{ name: string;
|
|
33
|
+
templates: Array<{ name: string; path: string }>;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
interface SelectTemplateDetails {
|
|
38
37
|
action: "select-template";
|
|
39
38
|
templateName: string;
|
|
40
39
|
content: string;
|
|
41
|
-
phase: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface CreateTemplateDetails {
|
|
45
|
-
action: "create-template";
|
|
46
|
-
templateName: string;
|
|
47
|
-
templateDir: string;
|
|
48
40
|
}
|
|
49
41
|
|
|
50
42
|
interface CompleteDetails {
|
|
@@ -52,6 +44,8 @@ interface CompleteDetails {
|
|
|
52
44
|
planFilePath: string;
|
|
53
45
|
isolation: string;
|
|
54
46
|
execMode: string;
|
|
47
|
+
/** D2:direct 档 goalInit 的同步结果;compact 档在 onComplete 回调内执行,不进 result */
|
|
48
|
+
goalOutcome?: GoalBridgeOutcome;
|
|
55
49
|
}
|
|
56
50
|
|
|
57
51
|
interface CompleteCancelledDetails {
|
|
@@ -66,7 +60,6 @@ interface AbortDetails {
|
|
|
66
60
|
type PlanDetails =
|
|
67
61
|
| ListTemplateDetails
|
|
68
62
|
| SelectTemplateDetails
|
|
69
|
-
| CreateTemplateDetails
|
|
70
63
|
| CompleteDetails
|
|
71
64
|
| CompleteCancelledDetails
|
|
72
65
|
| AbortDetails;
|
|
@@ -80,10 +73,8 @@ function restoreFullToolSet(pi: ExtensionAPI): void {
|
|
|
80
73
|
}
|
|
81
74
|
|
|
82
75
|
/** Compact template list for TUI display. Two-column, max 5 lines. */
|
|
83
|
-
function formatTemplateList(
|
|
84
|
-
templates
|
|
85
|
-
): string {
|
|
86
|
-
const names = templates.map((t) => `${t.name} (${t.source})`);
|
|
76
|
+
function formatTemplateList(templates: Array<{ name: string }>): string {
|
|
77
|
+
const names = templates.map((t) => t.name);
|
|
87
78
|
if (names.length === 0) return "No templates available.";
|
|
88
79
|
|
|
89
80
|
const MAX_DISPLAY = 8;
|
|
@@ -120,11 +111,10 @@ function renderPlanResult(
|
|
|
120
111
|
_options: unknown,
|
|
121
112
|
theme: Theme,
|
|
122
113
|
): Text {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
114
|
+
const details = result.details;
|
|
115
|
+
if (!details) {
|
|
116
|
+
return new Text(firstContentText(result), 0, 0);
|
|
117
|
+
}
|
|
128
118
|
|
|
129
119
|
const fg = (token: ThemeColor, text: string) => theme.fg(token, text);
|
|
130
120
|
const NL = "\n";
|
|
@@ -139,15 +129,8 @@ function renderPlanResult(
|
|
|
139
129
|
|
|
140
130
|
case "select-template": {
|
|
141
131
|
const header = fg("success", `✓ ${details.templateName}`) + NL;
|
|
142
|
-
const body = fg("dim", ` brainstorming → ${details.phase}`) + NL;
|
|
143
132
|
const hint = fg("dim", "→ 按模板章节顺序写 plan.md");
|
|
144
|
-
return new Text(header +
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
case "create-template": {
|
|
148
|
-
const header = fg("success", `✓ 已创建: ${details.templateName}`) + NL;
|
|
149
|
-
const body = fg("dim", ` ${details.templateDir}`);
|
|
150
|
-
return new Text(header + body, 0, 0);
|
|
133
|
+
return new Text(header + hint, 0, 0);
|
|
151
134
|
}
|
|
152
135
|
|
|
153
136
|
case "complete": {
|
|
@@ -179,8 +162,8 @@ interface ActionResult {
|
|
|
179
162
|
details: PlanDetails;
|
|
180
163
|
}
|
|
181
164
|
|
|
182
|
-
function executeListTemplate(
|
|
183
|
-
const templates = listTemplates(
|
|
165
|
+
function executeListTemplate(): ActionResult {
|
|
166
|
+
const templates = listTemplates();
|
|
184
167
|
return {
|
|
185
168
|
content: [{ type: "text" as const, text: `${templates.length} templates available` }],
|
|
186
169
|
details: { action: "list-template", templates },
|
|
@@ -191,46 +174,20 @@ function executeSelectTemplate(
|
|
|
191
174
|
pi: ExtensionAPI,
|
|
192
175
|
params: Record<string, unknown>,
|
|
193
176
|
state: PlanState,
|
|
194
|
-
projectDir: string,
|
|
195
177
|
): ActionResult {
|
|
196
178
|
const templateName = params.templateName as string;
|
|
197
179
|
if (!templateName) {
|
|
198
180
|
throw new Error("templateName is required for select-template");
|
|
199
181
|
}
|
|
200
|
-
const content = loadTemplate(templateName
|
|
182
|
+
const content = loadTemplate(templateName);
|
|
201
183
|
if (!content) {
|
|
202
184
|
throw new Error(`Template not found: ${templateName}`);
|
|
203
185
|
}
|
|
204
186
|
state.templateName = templateName;
|
|
205
|
-
state.phase = "writing";
|
|
206
187
|
persistPlanState(pi, state);
|
|
207
188
|
return {
|
|
208
189
|
content: [{ type: "text" as const, text: `Template selected: ${templateName}` }],
|
|
209
|
-
details: { action: "select-template", templateName, content
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function executeCreateTemplate(params: Record<string, unknown>, projectDir: string): ActionResult {
|
|
214
|
-
const templateName = params.templateName as string;
|
|
215
|
-
const templateContent = params.templateContent as string;
|
|
216
|
-
if (!templateName || !templateContent) {
|
|
217
|
-
throw new Error("templateName and templateContent are required for create-template");
|
|
218
|
-
}
|
|
219
|
-
const sanitizedName = templateName.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
220
|
-
if (!sanitizedName) {
|
|
221
|
-
throw new Error("Invalid template name: must contain alphanumeric characters");
|
|
222
|
-
}
|
|
223
|
-
const templateDir = path.join(projectDir, ".pi", "plan-templates");
|
|
224
|
-
fs.mkdirSync(templateDir, { recursive: true });
|
|
225
|
-
const filePath = path.join(templateDir, `${sanitizedName}.md`);
|
|
226
|
-
fs.writeFileSync(filePath, templateContent);
|
|
227
|
-
return {
|
|
228
|
-
content: [{ type: "text" as const, text: `Template created: ${sanitizedName}` }],
|
|
229
|
-
details: {
|
|
230
|
-
action: "create-template",
|
|
231
|
-
templateName: sanitizedName,
|
|
232
|
-
templateDir: relativePath(filePath, projectDir),
|
|
233
|
-
},
|
|
190
|
+
details: { action: "select-template", templateName, content },
|
|
234
191
|
};
|
|
235
192
|
}
|
|
236
193
|
|
|
@@ -249,21 +206,30 @@ function executeAbort(
|
|
|
249
206
|
};
|
|
250
207
|
}
|
|
251
208
|
|
|
209
|
+
/**
|
|
210
|
+
* 执行方式对话框的 label→mode 映射表(发现 8)——SDK `ui.select(title, options: string[])`
|
|
211
|
+
* 只收字符串数组,无法传结构化选项,故用本地查表而非文案反查。
|
|
212
|
+
*/
|
|
213
|
+
const EXEC_MODE_OPTIONS: Array<{ label: string; mode: string }> = [
|
|
214
|
+
{ label: "Subagent-driven execution", mode: "subagent" },
|
|
215
|
+
{ label: "Goal-driven execution (/goal)", mode: "goal" },
|
|
216
|
+
{ label: "Single-agent (current session)", mode: "single-agent" },
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
/** 对话框尾部的两个"留在 plan mode"选项(complete-cancelled 路径) */
|
|
220
|
+
const CANCEL_OPTIONS = ["Modify the plan first", "Save for later"];
|
|
221
|
+
|
|
252
222
|
/** Build execution options filtered by available capabilities. */
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
execOptions.push("Modify the plan first", "Save for later");
|
|
259
|
-
return execOptions;
|
|
223
|
+
function buildExecOptions(): string[] {
|
|
224
|
+
const modeLabels = EXEC_MODE_OPTIONS
|
|
225
|
+
.filter((opt) => opt.mode !== "goal" || detectGoalCapability())
|
|
226
|
+
.map((opt) => opt.label);
|
|
227
|
+
return [...modeLabels, ...CANCEL_OPTIONS];
|
|
260
228
|
}
|
|
261
229
|
|
|
262
|
-
/** Map the user's execution-method choice to the chosenMode string. */
|
|
230
|
+
/** Map the user's execution-method choice (dialog label) to the chosenMode string. */
|
|
263
231
|
function chosenModeFromChoice(choice: string): string {
|
|
264
|
-
|
|
265
|
-
if (choice === "Goal-driven execution (/goal)") return "goal";
|
|
266
|
-
return "single-agent";
|
|
232
|
+
return EXEC_MODE_OPTIONS.find((opt) => opt.label === choice)?.mode ?? "single-agent";
|
|
267
233
|
}
|
|
268
234
|
|
|
269
235
|
/** Outcome of the complete-action execution-method prompt. */
|
|
@@ -276,8 +242,8 @@ type CompleteChoiceOutcome =
|
|
|
276
242
|
* Cancel / "Modify the plan first" / "Save for later" → cancelled with a
|
|
277
243
|
* complete-cancelled result; otherwise the mapped chosenMode.
|
|
278
244
|
*/
|
|
279
|
-
async function resolveCompleteChoice(ctx: ExtensionContext
|
|
280
|
-
const execOptions =
|
|
245
|
+
async function resolveCompleteChoice(ctx: ExtensionContext): Promise<CompleteChoiceOutcome> {
|
|
246
|
+
const execOptions = buildExecOptions();
|
|
281
247
|
|
|
282
248
|
if (typeof ctx.ui.select !== "function") {
|
|
283
249
|
return { kind: "mode", chosenMode: "single-agent" };
|
|
@@ -297,7 +263,20 @@ async function resolveCompleteChoice(ctx: ExtensionContext, pi: ExtensionAPI): P
|
|
|
297
263
|
return { kind: "mode", chosenMode: chosenModeFromChoice(choice) };
|
|
298
264
|
}
|
|
299
265
|
|
|
300
|
-
/**
|
|
266
|
+
/**
|
|
267
|
+
* complete 的 result 正文:direct 档 goalInit 同步完成,追加 goal 结果行(D2);
|
|
268
|
+
* compact 档 goalInit 在 onComplete 回调内执行、result 已返回,不携带(通道差异
|
|
269
|
+
* 为设计 §6.2 D2 登记的终态)。
|
|
270
|
+
*/
|
|
271
|
+
function completeResultText(displayPath: string, goalOutcome: GoalBridgeOutcome | undefined): string {
|
|
272
|
+
const base = `Plan approved. File: ${displayPath}`;
|
|
273
|
+
if (goalOutcome === undefined) return base;
|
|
274
|
+
return goalOutcome.started
|
|
275
|
+
? `${base}\nGoal execution started via /goal.`
|
|
276
|
+
: `${base}\nGoal execution was not started (${goalOutcome.reason}). ${GOAL_FAILURE_RECOVERY[goalOutcome.reason]}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** complete action: prompt for execution mode, restore tools, reset state. */
|
|
301
280
|
async function executeComplete(
|
|
302
281
|
pi: ExtensionAPI,
|
|
303
282
|
ctx: ExtensionContext,
|
|
@@ -307,24 +286,23 @@ async function executeComplete(
|
|
|
307
286
|
sessionId: string,
|
|
308
287
|
projectDir: string,
|
|
309
288
|
): Promise<ActionResult> {
|
|
310
|
-
const choice = await resolveCompleteChoice(ctx
|
|
289
|
+
const choice = await resolveCompleteChoice(ctx);
|
|
311
290
|
if (choice.kind === "cancelled") {
|
|
312
291
|
return choice.result;
|
|
313
292
|
}
|
|
314
293
|
const chosenMode = choice.chosenMode;
|
|
315
294
|
|
|
316
|
-
//
|
|
295
|
+
// D6:原「persist final phase (complete)」为死状态落盘(P1 实证不可观测),
|
|
296
|
+
// phase 删除后该 persist 与上一条 entry 完全重复,随死状态一并移除——
|
|
297
|
+
// 最终态由下方 resetPlanState 的 isActive=false entry 权威记录。
|
|
317
298
|
const planFilePath = state.planFilePath;
|
|
318
299
|
const isolation = (params.isolation as string) ?? "direct";
|
|
319
|
-
state.phase = "complete";
|
|
320
|
-
persistPlanState(pi, state);
|
|
321
300
|
|
|
322
301
|
// Restore full tool set
|
|
323
302
|
restoreFullToolSet(pi);
|
|
324
303
|
|
|
325
|
-
// Execute completion handler (compact/
|
|
326
|
-
const
|
|
327
|
-
handlePlanComplete(pi, ctx, state, isolation, chosenMode);
|
|
304
|
+
// Execute completion handler (compact setup + steer/goalInit delivery)
|
|
305
|
+
const goalOutcome = handlePlanComplete(pi, ctx, state, isolation, chosenMode);
|
|
328
306
|
|
|
329
307
|
// Reset state and clear widget — same as abort
|
|
330
308
|
const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
|
|
@@ -332,8 +310,8 @@ async function executeComplete(
|
|
|
332
310
|
|
|
333
311
|
const displayPath = relativePath(planFilePath, projectDir);
|
|
334
312
|
return {
|
|
335
|
-
content: [{ type: "text" as const, text:
|
|
336
|
-
details: { action: "complete", planFilePath: displayPath, isolation, execMode: chosenMode },
|
|
313
|
+
content: [{ type: "text" as const, text: completeResultText(displayPath, goalOutcome) }],
|
|
314
|
+
details: { action: "complete", planFilePath: displayPath, isolation, execMode: chosenMode, goalOutcome },
|
|
337
315
|
};
|
|
338
316
|
}
|
|
339
317
|
|
|
@@ -349,13 +327,12 @@ export function registerPlanTool(
|
|
|
349
327
|
description:
|
|
350
328
|
"Manages plan mode lifecycle (template selection, state transitions, completion). " +
|
|
351
329
|
"NOT for writing plan content — write plan.md via the bash tool (e.g. cat heredoc). " +
|
|
352
|
-
"Actions: list-template, select-template,
|
|
330
|
+
"Actions: list-template, select-template, complete, abort.",
|
|
353
331
|
parameters: Type.Object({
|
|
354
332
|
action: StringEnum(PLAN_ACTIONS, { description: "Action to perform" }),
|
|
355
333
|
templateName: Type.Optional(Type.String({ description: "Template name (for select-template)" })),
|
|
356
|
-
templateContent: Type.Optional(Type.String({ description: "Template content (for create-template)" })),
|
|
357
334
|
isolation: Type.Optional(
|
|
358
|
-
StringEnum(["compact", "
|
|
335
|
+
StringEnum(["compact", "direct"], {
|
|
359
336
|
description: "Isolation mode for plan execution (for complete action)",
|
|
360
337
|
}),
|
|
361
338
|
),
|
|
@@ -363,7 +340,7 @@ export function registerPlanTool(
|
|
|
363
340
|
promptSnippet:
|
|
364
341
|
"## When to use this tool vs the bash tool\n" +
|
|
365
342
|
"Use 'plan' tool ONLY for plan mode state management:\n" +
|
|
366
|
-
"- list-template / select-template
|
|
343
|
+
"- list-template / select-template — template operations\n" +
|
|
367
344
|
"- complete — user approved plan, exit plan mode\n" +
|
|
368
345
|
"- abort — cancel plan mode\n" +
|
|
369
346
|
"\n" +
|
|
@@ -372,15 +349,11 @@ export function registerPlanTool(
|
|
|
372
349
|
"## End-to-end workflow example\n" +
|
|
373
350
|
"1. /plan 'add dark mode' — user enters plan mode\n" +
|
|
374
351
|
"2. AI explores codebase (read, grep, bash) — brainstorming\n" +
|
|
375
|
-
"3. plan(action='list-template')
|
|
376
|
-
"4.
|
|
377
|
-
"5.
|
|
378
|
-
"6. User reviews → plan(action='complete', isolation='compact') — exit plan mode\n" +
|
|
352
|
+
"3. plan(action='list-template') → user picks → plan(action='select-template', templateName='...')\n" +
|
|
353
|
+
"4. bash: cat > \"$PLAN_FILE\" <<'EOF' ... EOF — write plan content\n" +
|
|
354
|
+
"5. User reviews → plan(action='complete', isolation='compact') — exit plan mode\n" +
|
|
379
355
|
"\n" +
|
|
380
|
-
"## Common mistakes\n" +
|
|
381
356
|
"❌ plan(action='complete') to 'write the plan' — WRONG, write plan.md via the bash tool\n" +
|
|
382
|
-
"❌ Calling plan tool when user says 'write plan to file' — use the bash tool\n" +
|
|
383
|
-
"✅ plan(action='list-template') to discover templates\n" +
|
|
384
357
|
"✅ plan(action='complete') AFTER plan.md is written AND user approves",
|
|
385
358
|
renderResult(
|
|
386
359
|
result: { content: Array<{ type: string; text?: string }>; details?: PlanDetails },
|
|
@@ -407,13 +380,10 @@ export function registerPlanTool(
|
|
|
407
380
|
|
|
408
381
|
switch (action) {
|
|
409
382
|
case "list-template":
|
|
410
|
-
return executeListTemplate(
|
|
383
|
+
return executeListTemplate();
|
|
411
384
|
|
|
412
385
|
case "select-template":
|
|
413
|
-
return executeSelectTemplate(pi, params, state
|
|
414
|
-
|
|
415
|
-
case "create-template":
|
|
416
|
-
return executeCreateTemplate(params, projectDir);
|
|
386
|
+
return executeSelectTemplate(pi, params, state);
|
|
417
387
|
|
|
418
388
|
case "complete":
|
|
419
389
|
return await executeComplete(pi, ctx, params, state, sessions, sessionId, projectDir);
|
package/templates/bugfix-plan.md
CHANGED