@zhushanwen/pi-plan 0.4.9 → 0.5.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/README.md +41 -13
- package/package.json +15 -5
- package/skills/plan-ext-config/SKILL.md +86 -0
- package/src/__tests__/command.test.ts +290 -10
- package/src/__tests__/compact-criteria-array.test.ts +12 -8
- package/src/__tests__/compact-handler.test.ts +84 -35
- package/src/__tests__/enter.test.ts +172 -0
- package/src/__tests__/exec-skills.test.ts +397 -0
- package/src/__tests__/i18n.test.ts +57 -0
- package/src/__tests__/index.test.ts +228 -0
- package/src/__tests__/prompts.test.ts +74 -0
- package/src/__tests__/review.test.ts +489 -0
- package/src/__tests__/skills.test.ts +258 -0
- package/src/__tests__/state.test.ts +395 -16
- package/src/__tests__/templates.test.ts +203 -15
- package/src/__tests__/tool.test.ts +389 -73
- package/src/command.ts +220 -62
- package/src/compact.ts +43 -27
- package/src/enter.ts +133 -0
- package/src/exec-skills.ts +407 -0
- package/src/i18n.ts +70 -0
- package/src/index.ts +106 -5
- package/src/prompts.ts +151 -0
- package/src/state.ts +230 -8
- package/src/templates.ts +125 -7
- package/src/tool.ts +692 -110
- package/src/widget.ts +6 -1
package/src/command.ts
CHANGED
|
@@ -1,21 +1,107 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
2
3
|
import * as path from "node:path";
|
|
3
4
|
|
|
4
5
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
6
|
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
7
|
+
import { activatePlanMode, resolveSkills } from "./enter.js";
|
|
8
|
+
import type { SkillResolution } from "./enter.js";
|
|
9
|
+
import type { SkillRef } from "./prompts.js";
|
|
10
|
+
import type { PlanAbortControllers, PlanSessionMap, PlanState } from "./state.js";
|
|
11
|
+
import { PLAN_CONTEXT_CUSTOM_TYPE, getPlanState, resetPlanState } from "./state.js";
|
|
8
12
|
import { updatePlanWidget } from "./widget.js";
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
/** /plan 参数解析产物:requirement = 最早 flag 标记前的自由文本;skills / templatePath 为 undefined = 未提供对应 flag */
|
|
15
|
+
export interface ParsedPlanArgs {
|
|
16
|
+
requirement: string;
|
|
17
|
+
skills: string[] | undefined;
|
|
18
|
+
/** --template 的原始值(flag 后至命令结尾整段 trim,不切分——含空格路径天然支持,无需引号约定) */
|
|
19
|
+
templatePath: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* --skills / --template 解析定则:
|
|
24
|
+
* - 标记 = 独立 token(\b 词边界防 `--skillsabc` / `--templatex` 误切);
|
|
25
|
+
* - 值语义两 flag 同构 = flag 之后至命令结尾的整段文本(requirement 取最早出现
|
|
26
|
+
* flag 之前的部分):
|
|
27
|
+
* - `--skills` 按逗号原样切分 + 每项去首尾空格——内部空格保留(「code review」
|
|
28
|
+
* 「中文 技能」不丢字符),切分后空项丢弃(容忍尾逗号/连续逗号);
|
|
29
|
+
* - `--template` 不切分——trim 后整段即路径(D5);
|
|
30
|
+
* - flag 提供但值为空 = 显式错误(E1 fail-fast,不静默忽略——用户显式用了 flag
|
|
31
|
+
* 却没给值多半是打错了);
|
|
32
|
+
* - 两 flag 同给 = 互斥 fail-fast(消费方 handleEnterPlanMode 判定,§3.1——
|
|
33
|
+
* 解析层照常返回两个字段供其判定)。
|
|
34
|
+
*/
|
|
35
|
+
export function parsePlanArgs(args: string): ParsedPlanArgs {
|
|
36
|
+
const skillsMatch = /(?:^|\s)--skills\b/.exec(args);
|
|
37
|
+
const templateMatch = /(?:^|\s)--template\b/.exec(args);
|
|
38
|
+
if (!skillsMatch && !templateMatch) {
|
|
39
|
+
return { requirement: args.trim(), skills: undefined, templatePath: undefined };
|
|
40
|
+
}
|
|
41
|
+
const firstFlagIndex = Math.min(
|
|
42
|
+
skillsMatch?.index ?? Number.POSITIVE_INFINITY,
|
|
43
|
+
templateMatch?.index ?? Number.POSITIVE_INFINITY,
|
|
44
|
+
);
|
|
45
|
+
const requirement = args.slice(0, firstFlagIndex).trim();
|
|
46
|
+
const skills = skillsMatch === null
|
|
47
|
+
? undefined
|
|
48
|
+
: args
|
|
49
|
+
.slice(skillsMatch.index + skillsMatch[0].length)
|
|
50
|
+
.trim()
|
|
51
|
+
.split(",")
|
|
52
|
+
.map((s) => s.trim())
|
|
53
|
+
.filter((s) => s.length > 0);
|
|
54
|
+
const templatePath = templateMatch === null
|
|
55
|
+
? undefined
|
|
56
|
+
: args.slice(templateMatch.index + templateMatch[0].length).trim();
|
|
57
|
+
return { requirement, skills, templatePath };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** --template 校验结果:ok=false 时 problem 为 fail-fast 回复的问题句(自带用法样例) */
|
|
61
|
+
export type TemplateFileResolution =
|
|
62
|
+
| { ok: true; absPath: string }
|
|
63
|
+
| { ok: false; problem: string };
|
|
64
|
+
|
|
65
|
+
/** --template 系报错附带的用法样例(错误 → 纠正闭环) */
|
|
66
|
+
const TEMPLATE_USAGE_SAMPLE = "e.g. /plan <requirement> --template /path/to/template.md";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* --template 值解析与校验(D5):
|
|
70
|
+
* - `~` 前缀展开(os.homedir,仅 `~` 本身与 `~/` 前缀——`~name` 不展开,
|
|
71
|
+
* 无 per-user home 展开语义);
|
|
72
|
+
* - 相对路径相对 ctx.cwd(pi 进程 cwd = 项目根)解析,不做额外路径猜测;
|
|
73
|
+
* - 存在性校验先于 .md 校验,两失败文案分家(not found / not a markdown file),
|
|
74
|
+
* 报错一律带解析后的绝对路径供用户核对。
|
|
75
|
+
*/
|
|
76
|
+
export function resolveTemplateFile(raw: string, projectDir: string): TemplateFileResolution {
|
|
77
|
+
const trimmed = raw.trim();
|
|
78
|
+
if (!trimmed) {
|
|
79
|
+
return { ok: false, problem: `--template was given but no path followed it. ${TEMPLATE_USAGE_SAMPLE}` };
|
|
80
|
+
}
|
|
81
|
+
const expanded = trimmed === "~" || trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(1)) : trimmed;
|
|
82
|
+
const absPath = path.resolve(projectDir, expanded);
|
|
83
|
+
if (!fs.existsSync(absPath)) {
|
|
84
|
+
return { ok: false, problem: `Template file not found: ${absPath}. ${TEMPLATE_USAGE_SAMPLE}` };
|
|
85
|
+
}
|
|
86
|
+
if (!absPath.endsWith(".md")) {
|
|
87
|
+
return { ok: false, problem: `Not a markdown file: ${absPath}. ${TEMPLATE_USAGE_SAMPLE}` };
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, absPath };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* E1 校验与技能名归一已迁至 enter.ts(slash 与 plan(enter) tool 两入口共用)。
|
|
94
|
+
* 本文件只保留 slash 命令的入参解析(--skills/--template flag)与投递通道。
|
|
95
|
+
*/
|
|
11
96
|
|
|
12
97
|
export function registerPlanCommand(
|
|
13
98
|
pi: ExtensionAPI,
|
|
14
99
|
sessions: PlanSessionMap,
|
|
100
|
+
controllers: PlanAbortControllers,
|
|
15
101
|
): void {
|
|
16
102
|
pi.registerCommand("plan", {
|
|
17
103
|
description:
|
|
18
|
-
"Enter plan mode: /plan [description]
|
|
104
|
+
"Enter plan mode: /plan [description] [--skills a,b] or /plan [description] --template <path-to-markdown>. " +
|
|
19
105
|
"Subcommands: /plan abort, /plan status. " +
|
|
20
106
|
"With no args, show status or detect existing plan.",
|
|
21
107
|
getArgumentCompletions(prefix: string) {
|
|
@@ -36,7 +122,7 @@ export function registerPlanCommand(
|
|
|
36
122
|
|
|
37
123
|
// Subcommand: abort
|
|
38
124
|
if (trimmed === "abort") {
|
|
39
|
-
await handleAbort(pi, sessions, sessionId, ctx, state);
|
|
125
|
+
await handleAbort(pi, sessions, controllers, sessionId, ctx, state);
|
|
40
126
|
return;
|
|
41
127
|
}
|
|
42
128
|
|
|
@@ -58,13 +144,14 @@ export function registerPlanCommand(
|
|
|
58
144
|
return;
|
|
59
145
|
}
|
|
60
146
|
|
|
61
|
-
// Reentry: check for existing plan files in .
|
|
147
|
+
// Reentry: check for existing plan files in .tmp/plans/
|
|
62
148
|
if (!state.isActive && !trimmed) {
|
|
63
149
|
const projectDir = ctx.cwd;
|
|
64
|
-
const
|
|
65
|
-
const existingPlans = findExistingPlans(
|
|
150
|
+
const plansDir = path.join(projectDir, ".tmp", "plans");
|
|
151
|
+
const existingPlans = findExistingPlans(plansDir);
|
|
66
152
|
if (existingPlans.length > 0) {
|
|
67
|
-
|
|
153
|
+
sendPlanContextMessage(
|
|
154
|
+
pi,
|
|
68
155
|
`[PLAN MODE] Found existing plan files:\n${existingPlans.map((p, i) => ` ${i + 1}. ${p}`).join("\n")}\n\n` +
|
|
69
156
|
`Choose an option:\n` +
|
|
70
157
|
` a) Continue existing plan\n` +
|
|
@@ -77,7 +164,7 @@ export function registerPlanCommand(
|
|
|
77
164
|
}
|
|
78
165
|
|
|
79
166
|
// Enter plan mode
|
|
80
|
-
handleEnterPlanMode(pi, sessions, sessionId, ctx, state,
|
|
167
|
+
handleEnterPlanMode(pi, sessions, sessionId, ctx, state, args);
|
|
81
168
|
},
|
|
82
169
|
});
|
|
83
170
|
}
|
|
@@ -86,6 +173,7 @@ export function registerPlanCommand(
|
|
|
86
173
|
async function handleAbort(
|
|
87
174
|
pi: ExtensionAPI,
|
|
88
175
|
sessions: PlanSessionMap,
|
|
176
|
+
controllers: PlanAbortControllers,
|
|
89
177
|
sessionId: string,
|
|
90
178
|
ctx: ExtensionContext,
|
|
91
179
|
state: PlanState,
|
|
@@ -94,6 +182,12 @@ async function handleAbort(
|
|
|
94
182
|
ctx.ui.notify("No active plan mode.", "info");
|
|
95
183
|
return;
|
|
96
184
|
}
|
|
185
|
+
// E10 顺序不可反(因果链):controller.abort() → 挂起 select resolve undefined →
|
|
186
|
+
// tool execute 走「已取消」分支返回 → turn 正常结束 → agent_settled 到达 →
|
|
187
|
+
// busy defer 队列恢复投递。若先 reset 后 abort,挂起 select 无人 resolve 且
|
|
188
|
+
// runtime 不超时 → pi turn 永占用 → 后续消息永 defer → session 只能 forceQuit。
|
|
189
|
+
controllers.get(sessionId)?.abort();
|
|
190
|
+
controllers.delete(sessionId);
|
|
97
191
|
const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
|
|
98
192
|
updatePlanWidget(ctx, updatedState);
|
|
99
193
|
// Restore full tool set (SDK does NOT support undefined)
|
|
@@ -110,26 +204,69 @@ function handleStatus(
|
|
|
110
204
|
ctx.ui.notify("No active plan mode.", "info");
|
|
111
205
|
return;
|
|
112
206
|
}
|
|
207
|
+
const skillsLine = state.skills.length > 0 ? `\nSkills: ${state.skills.join(", ")}` : "";
|
|
208
|
+
const docsLine = state.docs.length > 0 ? `\nDocuments: ${state.docs.map((d) => `${d.fileName} (v${d.version})`).join(", ")}` : "";
|
|
113
209
|
ctx.ui.notify(
|
|
114
|
-
`Plan: ${state.planFilePath}\nTemplate: ${state.templateName || "(not selected)"}`,
|
|
210
|
+
`Plan: ${state.planFilePath}\nTemplate: ${state.templateName || "(not selected)"}${skillsLine}${docsLine}`,
|
|
115
211
|
"info",
|
|
116
212
|
);
|
|
117
213
|
}
|
|
118
214
|
|
|
119
|
-
/** Find existing plan.md files in .
|
|
120
|
-
function findExistingPlans(
|
|
215
|
+
/** Find existing plan.md files in .tmp/plans/ subdirectories */
|
|
216
|
+
function findExistingPlans(plansDir: string): string[] {
|
|
121
217
|
try {
|
|
122
|
-
return fs.readdirSync(
|
|
218
|
+
return fs.readdirSync(plansDir)
|
|
123
219
|
.filter((f) => {
|
|
124
|
-
const subDir = path.join(
|
|
220
|
+
const subDir = path.join(plansDir, f);
|
|
125
221
|
return fs.statSync(subDir).isDirectory() && fs.existsSync(path.join(subDir, "plan.md"));
|
|
126
222
|
})
|
|
127
|
-
.map((f) => path.join(
|
|
223
|
+
.map((f) => path.join(plansDir, f, "plan.md"));
|
|
128
224
|
} catch {
|
|
129
225
|
return [];
|
|
130
226
|
}
|
|
131
227
|
}
|
|
132
228
|
|
|
229
|
+
/**
|
|
230
|
+
* plan 上下文消息单点(slash 通道 4 个 sendMessage 位共用形态):custom message 注入
|
|
231
|
+
* (display:false——提示词全文消费者是 LLM,用户感知走 plan widget 状态呈现,不占用户
|
|
232
|
+
* 气泡)+ triggerTurn:true 保留原开轮语义。
|
|
233
|
+
*/
|
|
234
|
+
function sendPlanContextMessage(pi: ExtensionAPI, content: string): void {
|
|
235
|
+
pi.sendMessage(
|
|
236
|
+
{ customType: PLAN_CONTEXT_CUSTOM_TYPE, content, display: false },
|
|
237
|
+
{ triggerTurn: true },
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** E1 fail-fast 回复:不进入计划模式(不写 entry / 不限制工具 / 不注入计划提示词),回复可用技能清单与纠正命令 */
|
|
242
|
+
function reportUnknownSkills(pi: ExtensionAPI, resolution: Extract<SkillResolution, { ok: false }>): void {
|
|
243
|
+
const available = resolution.available.length > 0
|
|
244
|
+
? resolution.available.map((n) => ` - ${n}`).join("\n")
|
|
245
|
+
: " (no skills installed)";
|
|
246
|
+
const problem = resolution.missing.length > 0
|
|
247
|
+
? `unknown skill(s): ${resolution.missing.join(", ")}`
|
|
248
|
+
: "--skills was given but no skill names followed it";
|
|
249
|
+
sendPlanContextMessage(
|
|
250
|
+
pi,
|
|
251
|
+
`[PLAN MODE] Failed to enter: ${problem}.\n\n` +
|
|
252
|
+
`Available skills:\n${available}\n\n` +
|
|
253
|
+
`Do NOT enter plan mode. Reply to the user listing the available skills and the corrected command, e.g. /plan <requirement> --skills <skill1>,<skill2>.`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* --template 系 fail-fast 回复(E1 同款形态):不进入计划模式(不写 entry /
|
|
259
|
+
* 不限制工具 / 不注入计划提示词),回复问题句(互斥/不存在/非 md,problem 内
|
|
260
|
+
* 已带用法样例)。
|
|
261
|
+
*/
|
|
262
|
+
function reportTemplateFlagError(pi: ExtensionAPI, problem: string): void {
|
|
263
|
+
sendPlanContextMessage(
|
|
264
|
+
pi,
|
|
265
|
+
`[PLAN MODE] Failed to enter: ${problem}.\n\n` +
|
|
266
|
+
`Do NOT enter plan mode. Reply to the user with the problem and the corrected command.`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
133
270
|
/** Handle entering plan mode */
|
|
134
271
|
function handleEnterPlanMode(
|
|
135
272
|
pi: ExtensionAPI,
|
|
@@ -137,51 +274,72 @@ function handleEnterPlanMode(
|
|
|
137
274
|
sessionId: string,
|
|
138
275
|
ctx: ExtensionContext,
|
|
139
276
|
state: PlanState,
|
|
140
|
-
|
|
277
|
+
args: string,
|
|
141
278
|
): void {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
279
|
+
// flag 解析 + 校验先行:--template / --skills 任一校验失败 fail-fast,
|
|
280
|
+
// 不产生任何进入动作(PlanModeBar 不出现:不写 entry / 不限制工具 / 不注入提示词)
|
|
281
|
+
const parsed = parsePlanArgs(args);
|
|
282
|
+
|
|
283
|
+
// 互斥 fail-fast(§3.1):两 flag 同给先于一切值校验报错
|
|
284
|
+
if (parsed.skills !== undefined && parsed.templatePath !== undefined) {
|
|
285
|
+
reportTemplateFlagError(
|
|
286
|
+
pi,
|
|
287
|
+
"--template and --skills are mutually exclusive. Use one: /plan <requirement> --skills a,b | /plan <requirement> --template <path>",
|
|
288
|
+
);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let templateAbsPath: string | undefined;
|
|
293
|
+
let templateContent: string | undefined;
|
|
294
|
+
if (parsed.templatePath !== undefined) {
|
|
295
|
+
const resolution = resolveTemplateFile(parsed.templatePath, ctx.cwd);
|
|
296
|
+
if (!resolution.ok) {
|
|
297
|
+
reportTemplateFlagError(pi, resolution.problem);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// 全文在校验通过后、任何进入副作用(entry/工具限制)之前读好——读失败
|
|
301
|
+
// (权限/竞态删除)同族 fail-fast,不留「已持久 isActive 但提示词没注入」
|
|
302
|
+
// 的半进入态(D5)
|
|
303
|
+
try {
|
|
304
|
+
templateContent = fs.readFileSync(resolution.absPath, "utf-8");
|
|
305
|
+
} catch {
|
|
306
|
+
reportTemplateFlagError(pi, `Template file not readable: ${resolution.absPath}. ${TEMPLATE_USAGE_SAMPLE}`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
templateAbsPath = resolution.absPath;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// --skills E1 校验(互斥已判过后,走到这里 templatePath 必为 undefined)
|
|
313
|
+
const requested = parsed.skills ?? [];
|
|
314
|
+
if (parsed.skills !== undefined && requested.length === 0) {
|
|
315
|
+
reportUnknownSkills(pi, { ok: false, available: pi.getCommands().filter((c) => c.source === "skill").map((c) => c.name), missing: [] });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
let resolved: SkillRef[] = [];
|
|
319
|
+
if (requested.length > 0) {
|
|
320
|
+
const resolution = resolveSkills(pi, requested);
|
|
321
|
+
if (!resolution.ok) {
|
|
322
|
+
reportUnknownSkills(pi, resolution);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
resolved = resolution.resolved;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const requirement = parsed.requirement;
|
|
329
|
+
// 进入核心收敛到 enter.ts(plan(enter) tool 与 slash 命令共用);本入口只负责
|
|
330
|
+
// flag 解析/校验(上方)与提示词投递(下方 sendMessage custom message 注入)。
|
|
331
|
+
// state 由 activatePlanMode 就地改 + persist(getPlanState 缓存同一对象)。
|
|
332
|
+
const { prompt } = activatePlanMode(pi, sessions, sessionId, ctx, {
|
|
333
|
+
requirement,
|
|
334
|
+
skills: resolved,
|
|
335
|
+
projectDir: ctx.cwd,
|
|
336
|
+
...(templateAbsPath !== undefined && templateContent !== undefined
|
|
337
|
+
? { template: { absPath: templateAbsPath, content: templateContent } }
|
|
338
|
+
: {}),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Inject plan mode prompt as custom message(display/triggerTurn 语义见 sendPlanContextMessage。
|
|
342
|
+
// 非 streaming 直调 _runAgentPrompt 跳过 prompt() 前置链,首轮 systemPrompt 叠加
|
|
343
|
+
// 差异已登记为可接受——设计 §1.1-⑥ / §2.2 P1。tool 入口走 tool result)
|
|
344
|
+
sendPlanContextMessage(pi, prompt);
|
|
187
345
|
}
|
package/src/compact.ts
CHANGED
|
@@ -6,8 +6,9 @@ import { guardStaleCtx, toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
|
6
6
|
import type { GoalInitFn } from "@zhushanwen/pi-goal";
|
|
7
7
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
8
8
|
|
|
9
|
+
import { t } from "./i18n.js";
|
|
9
10
|
import type { PlanSessionMap, PlanState } from "./state.js";
|
|
10
|
-
import { getPlanState } from "./state.js";
|
|
11
|
+
import { PLAN_CONTEXT_CUSTOM_TYPE, getPlanState } from "./state.js";
|
|
11
12
|
|
|
12
13
|
const logger = getLogger("pi-plan");
|
|
13
14
|
|
|
@@ -115,6 +116,9 @@ function buildPlanSlug(planFilePath: string): string {
|
|
|
115
116
|
|
|
116
117
|
/** step preview 条数上限(1 条总述 + 3 条 preview,合计 ≤4 条,满足 goal schema maxItems:8) */
|
|
117
118
|
const PREVIEW_COUNT = 3;
|
|
119
|
+
|
|
120
|
+
/** skill 档 execMode 前缀(D10:`skill:<name>` 动态项,与 tool.ts 选项构造同源约定) */
|
|
121
|
+
const SKILL_MODE_PREFIX = "skill:";
|
|
118
122
|
/** 单条 preview 最大长度(超出部分截断,以 "..." 结尾) */
|
|
119
123
|
const PREVIEW_MAX_CHARS = 80;
|
|
120
124
|
const ELLIPSIS = "...";
|
|
@@ -151,7 +155,7 @@ export function buildPlanSuccessCriteria(planFilePath: string, tasks: string[]):
|
|
|
151
155
|
|
|
152
156
|
/** goalInit 失败原因——五值与 tryGoalInit 的 5 个失败出口一一对应(设计 §6.2 D2)。 */
|
|
153
157
|
export type GoalBridgeFailureReason =
|
|
154
|
-
| "goal-unavailable" // goal 未加载(slot
|
|
158
|
+
| "goal-unavailable" // goal 未加载(slot 不存在/值非函数——execute 档无条件尝试 goalInit,goal 扩展未装/未挂 slot 时即走此出口,独立 pi 常态分支)
|
|
155
159
|
| "plan-unreadable" // plan 文件读取失败
|
|
156
160
|
| "no-steps" // plan 内容提取到 0 条步骤
|
|
157
161
|
| "init-refused" // goalInit 返回 false(已有 active goal / ctx 缺失)
|
|
@@ -164,7 +168,7 @@ export type GoalBridgeOutcome =
|
|
|
164
168
|
|
|
165
169
|
/** 每个 reason 指向一个具体恢复动作(不做纯日志字符串,设计 §4.2)。 */
|
|
166
170
|
export const GOAL_FAILURE_RECOVERY: Record<GoalBridgeFailureReason, string> = {
|
|
167
|
-
"goal-unavailable": "The goal extension is not loaded —
|
|
171
|
+
"goal-unavailable": "The goal extension is not loaded — execute directly without goal tracking.",
|
|
168
172
|
"plan-unreadable": "Check that the plan file exists and is readable, then call plan(action='complete') again.",
|
|
169
173
|
"no-steps": "Add numbered steps under a '## Implementation Steps' section in the plan file, then call plan(action='complete') again.",
|
|
170
174
|
"init-refused": "An active goal already exists — run /goal clear first, or continue with the existing goal.",
|
|
@@ -241,34 +245,42 @@ export function extractPlanSteps(planContent: string): string[] {
|
|
|
241
245
|
|
|
242
246
|
|
|
243
247
|
/**
|
|
244
|
-
* 投递 complete 后的执行通知(
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
+
* 投递 complete 后的执行通知(2026-09-21 选项集重排:mode 值域 = execute | skill:<name>;
|
|
249
|
+
* execute 档整合 goal 桥 + auto-parallel subagent——goal 可用时先 goalInit 建跟踪,
|
|
250
|
+
* 失败/不可用降级为直接执行的 steer 指令,用户可见失败提示走 notify i18n)。
|
|
251
|
+
* skill 档动态构造 steer(含 skillEntryPath 路径,对齐 register-doc sourceSkill 的 skill 关联
|
|
252
|
+
* 先例;skillEntryPath = skill 入口文件路径——标准形态 SKILL.md 路径 / 散 .md 形态文件本身,
|
|
253
|
+
* 直接 read 不再拼 SKILL.md)。返回 goalInit 的 outcome(非 execute 档为 undefined)。
|
|
248
254
|
*/
|
|
249
255
|
function deliverExecutionNotice(
|
|
250
256
|
pi: ExtensionAPI,
|
|
251
257
|
ctx: ExtensionContext,
|
|
252
258
|
planFilePath: string,
|
|
253
259
|
execMode: string,
|
|
260
|
+
skillEntryPath?: string,
|
|
254
261
|
): GoalBridgeOutcome | undefined {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
subagent: "Execute via subagent-driven development: delegate each task to an independent subagent for parallel execution.",
|
|
259
|
-
goal: "Execute via /goal: set up tracked task decomposition with budget control using the goal extension.",
|
|
260
|
-
"single-agent": "Execute step by step in the current session.",
|
|
261
|
-
};
|
|
262
|
+
// execute 档整合 goal 桥:tryGoalInit 内部含 goal-unavailable gate(goal 未挂载走
|
|
263
|
+
// started:false 降级),无需前置 detectGoalCapability 探测
|
|
264
|
+
const outcome = execMode === "execute" ? tryGoalInit(planFilePath, ctx) : undefined;
|
|
262
265
|
|
|
263
266
|
let modeHint: string;
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
267
|
+
if (execMode.startsWith(SKILL_MODE_PREFIX)) {
|
|
268
|
+
const skillName = execMode.slice(SKILL_MODE_PREFIX.length);
|
|
269
|
+
modeHint = skillEntryPath
|
|
270
|
+
? `Execute via skill: read the ${skillName} skill at ${skillEntryPath} first, then follow its workflow to execute the plan file.`
|
|
271
|
+
: `Execute via skill: load the ${skillName} skill and follow its workflow to execute the plan file.`;
|
|
272
|
+
} else if (outcome?.started) {
|
|
273
|
+
modeHint =
|
|
274
|
+
"Goal tracking is active via /goal — execute the plan through the goal workflow: delegate independent, parallelizable tasks to subagents; run small or tightly-coupled steps in this session.";
|
|
275
|
+
} else if (outcome !== undefined) {
|
|
276
|
+
modeHint =
|
|
277
|
+
`Goal tracking was not started (${outcome.reason}). ${GOAL_FAILURE_RECOVERY[outcome.reason]} ` +
|
|
278
|
+
`Execute directly instead: judge by task complexity — delegate independent, parallelizable tasks to subagents; run small or tightly-coupled steps step by step in the current session.`;
|
|
270
279
|
const detail = outcome.detail ? ` (${outcome.detail})` : "";
|
|
271
|
-
ctx.ui.notify(
|
|
280
|
+
ctx.ui.notify(`${t("exec.goalFailedNotify", { reason: outcome.reason })}${detail}`, "warning");
|
|
281
|
+
} else {
|
|
282
|
+
modeHint =
|
|
283
|
+
"Execute by judging task complexity: delegate independent, parallelizable tasks to subagents; execute small or tightly-coupled steps step by step in the current session.";
|
|
272
284
|
}
|
|
273
285
|
|
|
274
286
|
const executeMessage =
|
|
@@ -277,16 +289,19 @@ function deliverExecutionNotice(
|
|
|
277
289
|
`${modeHint}\n\n` +
|
|
278
290
|
`Read the plan file and start implementing.`;
|
|
279
291
|
|
|
280
|
-
pi.
|
|
292
|
+
pi.sendMessage(
|
|
293
|
+
{ customType: PLAN_CONTEXT_CUSTOM_TYPE, content: executeMessage, display: false },
|
|
294
|
+
{ deliverAs: "steer", triggerTurn: true },
|
|
295
|
+
);
|
|
281
296
|
return outcome;
|
|
282
297
|
}
|
|
283
298
|
|
|
284
299
|
/**
|
|
285
300
|
* complete 的 isolation 分发(D1 后仅 compact | direct,两档都投递执行通知)。
|
|
286
301
|
*
|
|
287
|
-
* 返回值:execMode=
|
|
302
|
+
* 返回值:execMode=execute 且 isolation=direct 时同步返回 goalInit 的 outcome
|
|
288
303
|
* (executeComplete 写进 result content 与 details);其余情形返回 undefined——
|
|
289
|
-
*
|
|
304
|
+
* skill 档无 goalInit,compact 档 goalInit 在 onComplete 回调内执行(goal 状态
|
|
290
305
|
* entry 须在压缩后的世界里创建,提前到 compact 前有被压缩边界丢弃的风险,时序
|
|
291
306
|
* 不动——设计 §6.2 D2),该档 result 已返回,失败报告走 steer + notify 通道。
|
|
292
307
|
*/
|
|
@@ -296,6 +311,7 @@ export function handlePlanComplete(
|
|
|
296
311
|
state: PlanState,
|
|
297
312
|
isolation: string,
|
|
298
313
|
execMode: string,
|
|
314
|
+
skillEntryPath?: string,
|
|
299
315
|
): GoalBridgeOutcome | undefined {
|
|
300
316
|
const planFilePath = state.planFilePath;
|
|
301
317
|
|
|
@@ -311,7 +327,7 @@ export function handlePlanComplete(
|
|
|
311
327
|
// 守卫的 stale 文案兜底(D1 降级语义声明的合法形态)。
|
|
312
328
|
onComplete: () => {
|
|
313
329
|
guardStaleCtx(() => {
|
|
314
|
-
deliverExecutionNotice(pi, ctx, planFilePath, execMode);
|
|
330
|
+
deliverExecutionNotice(pi, ctx, planFilePath, execMode, skillEntryPath);
|
|
315
331
|
}, {
|
|
316
332
|
label: "plan:compact-onComplete",
|
|
317
333
|
onStale: (error) => logger.warn("plan execution notice delivery skipped (stale ctx)", { error: toErrorMessage(error) }),
|
|
@@ -320,7 +336,7 @@ export function handlePlanComplete(
|
|
|
320
336
|
onError: (_error: Error) => {
|
|
321
337
|
guardStaleCtx(() => {
|
|
322
338
|
ctx.ui.notify("Compact failed, continuing without isolation.", "warning");
|
|
323
|
-
deliverExecutionNotice(pi, ctx, planFilePath, execMode);
|
|
339
|
+
deliverExecutionNotice(pi, ctx, planFilePath, execMode, skillEntryPath);
|
|
324
340
|
}, {
|
|
325
341
|
label: "plan:compact-onError",
|
|
326
342
|
onStale: (error) => logger.warn("plan execution notice delivery skipped (stale ctx)", { error: toErrorMessage(error) }),
|
|
@@ -332,7 +348,7 @@ export function handlePlanComplete(
|
|
|
332
348
|
|
|
333
349
|
case "direct":
|
|
334
350
|
default: {
|
|
335
|
-
return deliverExecutionNotice(pi, ctx, planFilePath, execMode);
|
|
351
|
+
return deliverExecutionNotice(pi, ctx, planFilePath, execMode, skillEntryPath);
|
|
336
352
|
}
|
|
337
353
|
}
|
|
338
354
|
}
|
package/src/enter.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { buildPlanModePrompt } from "./prompts.js";
|
|
7
|
+
import type { SkillRef } from "./prompts.js";
|
|
8
|
+
import type { PlanSessionMap, PlanState } from "./state.js";
|
|
9
|
+
import { capPlanRequirement, getPlanState, persistPlanState, PLAN_MODE_TOOLS } from "./state.js";
|
|
10
|
+
import { updatePlanWidget } from "./widget.js";
|
|
11
|
+
|
|
12
|
+
export const MAX_SLUG_LENGTH = 30;
|
|
13
|
+
|
|
14
|
+
/** E1 技能解析结果:ok=false 时携带缺失项与可用清单(fail-fast 回复的材料) */
|
|
15
|
+
export type SkillResolution =
|
|
16
|
+
| { ok: true; resolved: SkillRef[] }
|
|
17
|
+
| { ok: false; available: string[]; missing: string[] };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 技能名归一:剥掉 pi 命令命名空间前导 `skill:` 前缀,得到自然技能名。
|
|
21
|
+
* pi.getCommands() 枚举 skill 类命令时 name 带 `skill:` 前缀(如 `skill:tech-design`),
|
|
22
|
+
* 这是 pi 的实现细节,不得泄漏到输入面——slash `--skills` 与 plan(enter) 的 skills 参数
|
|
23
|
+
* 都用自然技能名。枚举侧与输入侧双向剥前缀后比对,兼容两种形态。
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeSkillName(name: string): string {
|
|
26
|
+
return name.startsWith("skill:") ? name.slice("skill:".length) : name;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* E1 校验:pi.getCommands() 过滤 source === "skill" 枚举比对(技能枚举与路径
|
|
31
|
+
* 经 pi 取得,不自扫描目录——D2)。比对前双向剥 `skill:` 前缀归一:resolved/missing
|
|
32
|
+
* 用归一后的短名;available 维持枚举原形态(错误信息里可直接复制为 pi 命令)。
|
|
33
|
+
* slash 命令与 plan(enter) tool 两入口共用(自 command.ts 迁入 enter.ts)。
|
|
34
|
+
*/
|
|
35
|
+
export function resolveSkills(pi: ExtensionAPI, requested: string[]): SkillResolution {
|
|
36
|
+
const skillCommands = pi.getCommands().filter((c) => c.source === "skill");
|
|
37
|
+
const byShortName = new Map(skillCommands.map((c) => [normalizeSkillName(c.name), c.sourceInfo.path]));
|
|
38
|
+
const available = skillCommands.map((c) => c.name);
|
|
39
|
+
const resolved: SkillRef[] = [];
|
|
40
|
+
const missing: string[] = [];
|
|
41
|
+
for (const raw of requested) {
|
|
42
|
+
const name = normalizeSkillName(raw);
|
|
43
|
+
const skillPath = byShortName.get(name);
|
|
44
|
+
if (skillPath === undefined) {
|
|
45
|
+
missing.push(name);
|
|
46
|
+
} else {
|
|
47
|
+
resolved.push({ name, skillPath });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return missing.length > 0 ? { ok: false, available, missing } : { ok: true, resolved };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 进入 plan 模式的归一入参(slash 命令与 plan(enter) tool 两入口共用) */
|
|
54
|
+
export interface ActivatePlanModeInput {
|
|
55
|
+
requirement: string;
|
|
56
|
+
/** E1 校验后的技能引用(未挂载传空数组) */
|
|
57
|
+
skills: SkillRef[];
|
|
58
|
+
/** 项目根锚点(= ctx.cwd),plan 目录与模板扫描的公共锚 */
|
|
59
|
+
projectDir: string;
|
|
60
|
+
/** --template 直传时给出(与 skills 互斥,命令层已校验并读好全文) */
|
|
61
|
+
template?: { absPath: string; content: string };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 进入产物:状态引用 + 直达模型的 plan 模式提示词(两入口分别走 sendMessage custom message / tool result 投递) */
|
|
65
|
+
export interface ActivatePlanModeOutcome {
|
|
66
|
+
state: PlanState;
|
|
67
|
+
prompt: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 进入 plan 模式的共享核心(plan-mode-agent-enter U1):slash 命令 handler 与 plan(enter)
|
|
72
|
+
* tool 两入口收敛同一实现——状态建立 + 持久化 + widget + 工具收拢 + 提示词构造。
|
|
73
|
+
*
|
|
74
|
+
* 不做的事(留给调用方):
|
|
75
|
+
* - 提示词投递通道:slash 入口经 sendMessage custom message(display:false——提示词
|
|
76
|
+
* 全文消费者是 LLM,用户感知走 plan widget 状态呈现;triggerTurn:true 开轮);tool 入口经
|
|
77
|
+
* tool result content 直返(对本次 tool 调用的直接响应,agent 同轮即见——不赌 steer 排队)。
|
|
78
|
+
* - flag 解析 / 模板文件读取 / 技能名归一:命令层与 tool 层各自的入参形态不同,前置已完成。
|
|
79
|
+
*
|
|
80
|
+
* 副作用顺序钉死:状态字段 → persistPlanState(entry 落盘)→ updatePlanWidget →
|
|
81
|
+
* setActiveTools → 构造 prompt。persist 先于工具收拢,保证「已持久 isActive 但工具未收」
|
|
82
|
+
* 的半进入态不可达(崩溃窗口内重开 session 经 entry 恢复 isActive,session_start hook
|
|
83
|
+
* 会补 setActiveTools)。
|
|
84
|
+
*/
|
|
85
|
+
export function activatePlanMode(
|
|
86
|
+
pi: ExtensionAPI,
|
|
87
|
+
sessions: PlanSessionMap,
|
|
88
|
+
sessionId: string,
|
|
89
|
+
ctx: ExtensionContext,
|
|
90
|
+
input: ActivatePlanModeInput,
|
|
91
|
+
): ActivatePlanModeOutcome {
|
|
92
|
+
const state = getPlanState(sessions, sessionId, ctx);
|
|
93
|
+
const { requirement, skills, projectDir } = input;
|
|
94
|
+
|
|
95
|
+
const slug = requirement
|
|
96
|
+
? requirement.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, MAX_SLUG_LENGTH)
|
|
97
|
+
: "untitled";
|
|
98
|
+
|
|
99
|
+
const planDir = path.join(projectDir, ".tmp", "plans", slug);
|
|
100
|
+
fs.mkdirSync(planDir, { recursive: true });
|
|
101
|
+
const planFilePath = path.join(planDir, "plan.md");
|
|
102
|
+
|
|
103
|
+
state.isActive = true;
|
|
104
|
+
state.planFilePath = planFilePath;
|
|
105
|
+
// state/entry/plan 帧侧 requirement 64KB 封顶(帧有界前提);prompt 仍用未封顶全文直达模型
|
|
106
|
+
state.requirement = capPlanRequirement(requirement);
|
|
107
|
+
// --template 直传:templateName = 去扩展名 basename(GUI / /plan status 展示);
|
|
108
|
+
// 直传事实另落 templateProvidedPath(select-template 防御的判定信号,D7)
|
|
109
|
+
state.templateName = input.template ? path.basename(input.template.absPath, ".md") : "";
|
|
110
|
+
state.templateProvidedPath = input.template?.absPath;
|
|
111
|
+
state.skills = skills.map((s) => s.name);
|
|
112
|
+
state.docs = [];
|
|
113
|
+
// 新轮次重置:reviewState、来源标记与指纹基线随进入失效(与 resetPlanState 对齐;
|
|
114
|
+
// reviewStateSource 残留会让新一轮降级态渲染上一轮来源文案——C-U2 同型残留)
|
|
115
|
+
delete state.reviewState;
|
|
116
|
+
delete state.reviewStateSource;
|
|
117
|
+
delete state.lastSubmitReviewDocsFingerprint;
|
|
118
|
+
|
|
119
|
+
persistPlanState(pi, state);
|
|
120
|
+
updatePlanWidget(ctx, state);
|
|
121
|
+
// 收拢工具到 plan 模式白名单(含 bash——文件写约束来自下方注入的只读纪律提示词)
|
|
122
|
+
pi.setActiveTools(PLAN_MODE_TOOLS);
|
|
123
|
+
|
|
124
|
+
const prompt = buildPlanModePrompt({
|
|
125
|
+
requirement,
|
|
126
|
+
planFilePath,
|
|
127
|
+
projectRoot: projectDir,
|
|
128
|
+
skills,
|
|
129
|
+
...(input.template !== undefined ? { template: input.template } : {}),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return { state, prompt };
|
|
133
|
+
}
|