@zhushanwen/pi-plan 0.5.2 → 1.1.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 +9 -9
- package/package.json +13 -11
- package/skills/plan-ext-config/SKILL.md +33 -18
- package/src/__tests__/active-path.test.ts +204 -0
- package/src/__tests__/command.test.ts +102 -32
- package/src/__tests__/enter.test.ts +58 -20
- package/src/__tests__/exec-options-alignment.test.ts +56 -0
- package/src/__tests__/execution-notice.test.ts +197 -0
- package/src/__tests__/{compact.test.ts → extract-plan-steps.test.ts} +24 -15
- package/src/__tests__/fsm-consumption.test.ts +388 -0
- package/src/__tests__/index.test.ts +175 -75
- package/src/__tests__/prompts.test.ts +66 -0
- package/src/__tests__/review-contract-boundary.test.ts +123 -0
- package/src/__tests__/review.test.ts +264 -97
- package/src/__tests__/skills.test.ts +14 -5
- package/src/__tests__/state.test.ts +254 -52
- package/src/__tests__/{compact-criteria-array.test.ts → success-criteria-array.test.ts} +29 -70
- package/src/__tests__/templates.test.ts +8 -25
- package/src/__tests__/tool.test.ts +269 -121
- package/src/command.ts +42 -34
- package/src/enter.ts +31 -50
- package/src/{compact.ts → execution-notice.ts} +30 -118
- package/src/index.ts +100 -88
- package/src/prompts.ts +42 -12
- package/src/state.ts +290 -87
- package/src/templates.ts +0 -15
- package/src/tool.ts +665 -257
- package/src/__tests__/compact-handler.test.ts +0 -348
- package/src/__tests__/exec-skills.test.ts +0 -397
- package/src/exec-skills.ts +0 -407
package/src/command.ts
CHANGED
|
@@ -7,9 +7,9 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
7
7
|
import { activatePlanMode, resolveSkills } from "./enter.js";
|
|
8
8
|
import type { SkillResolution } from "./enter.js";
|
|
9
9
|
import type { SkillRef } from "./prompts.js";
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
10
|
+
import { exitPlanMode } from "./tool.js";
|
|
11
|
+
import type { PlanCtx, PlanState } from "./state.js";
|
|
12
|
+
import { PLAN_CONTEXT_CUSTOM_TYPE, getPlanState } from "./state.js";
|
|
13
13
|
|
|
14
14
|
/** /plan 参数解析产物:requirement = 最早 flag 标记前的自由文本;skills / templatePath 为 undefined = 未提供对应 flag */
|
|
15
15
|
export interface ParsedPlanArgs {
|
|
@@ -96,8 +96,7 @@ export function resolveTemplateFile(raw: string, projectDir: string): TemplateFi
|
|
|
96
96
|
|
|
97
97
|
export function registerPlanCommand(
|
|
98
98
|
pi: ExtensionAPI,
|
|
99
|
-
|
|
100
|
-
controllers: PlanAbortControllers,
|
|
99
|
+
planCtx: PlanCtx,
|
|
101
100
|
): void {
|
|
102
101
|
pi.registerCommand("plan", {
|
|
103
102
|
description:
|
|
@@ -118,11 +117,11 @@ export function registerPlanCommand(
|
|
|
118
117
|
handler: async (args: string, ctx: ExtensionContext) => {
|
|
119
118
|
const trimmed = args.trim();
|
|
120
119
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
121
|
-
const state = getPlanState(
|
|
120
|
+
const state = getPlanState(planCtx.states, sessionId, ctx);
|
|
122
121
|
|
|
123
122
|
// Subcommand: abort
|
|
124
123
|
if (trimmed === "abort") {
|
|
125
|
-
await handleAbort(pi,
|
|
124
|
+
await handleAbort(pi, planCtx, sessionId, ctx, state);
|
|
126
125
|
return;
|
|
127
126
|
}
|
|
128
127
|
|
|
@@ -150,6 +149,9 @@ export function registerPlanCommand(
|
|
|
150
149
|
const plansDir = path.join(projectDir, ".tmp", "plans");
|
|
151
150
|
const existingPlans = findExistingPlans(plansDir);
|
|
152
151
|
if (existingPlans.length > 0) {
|
|
152
|
+
// D10②:选项清单经 display:false 注入(LLM 消费),用户可见性由 notify 承担
|
|
153
|
+
// (现状 display:false 消息用户零可见——反馈矩阵修复)
|
|
154
|
+
ctx.ui.notify(`Found ${existingPlans.length} existing plan file(s) in .tmp/plans — options sent to the model.`, "info");
|
|
153
155
|
sendPlanContextMessage(
|
|
154
156
|
pi,
|
|
155
157
|
`[PLAN MODE] Found existing plan files:\n${existingPlans.map((p, i) => ` ${i + 1}. ${p}`).join("\n")}\n\n` +
|
|
@@ -164,35 +166,26 @@ export function registerPlanCommand(
|
|
|
164
166
|
}
|
|
165
167
|
|
|
166
168
|
// Enter plan mode
|
|
167
|
-
handleEnterPlanMode(pi,
|
|
169
|
+
handleEnterPlanMode(pi, planCtx, sessionId, ctx, state, args);
|
|
168
170
|
},
|
|
169
171
|
});
|
|
170
172
|
}
|
|
171
173
|
|
|
172
|
-
/**
|
|
174
|
+
/**
|
|
175
|
+
* Handle /plan abort subcommand——exit 链单入口(D-B1-1)的 command 路投影:
|
|
176
|
+
* 动作清单(解散挂起/转移落盘/widget/工具集恢复/反馈文案)全在 exitPlanMode 内,
|
|
177
|
+
* 本函数只把 ExitResult 投影为 notify。非活跃格全谱(idle/终态)统一 warn 纠偏
|
|
178
|
+
* 文案(原 isActive 前置守卫的 info no-op 已由单入口守卫极性取代,D-B1-1 行为变更)。
|
|
179
|
+
*/
|
|
173
180
|
async function handleAbort(
|
|
174
181
|
pi: ExtensionAPI,
|
|
175
|
-
|
|
176
|
-
controllers: PlanAbortControllers,
|
|
182
|
+
planCtx: PlanCtx,
|
|
177
183
|
sessionId: string,
|
|
178
184
|
ctx: ExtensionContext,
|
|
179
185
|
state: PlanState,
|
|
180
186
|
): Promise<void> {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
return;
|
|
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);
|
|
191
|
-
const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
|
|
192
|
-
updatePlanWidget(ctx, updatedState);
|
|
193
|
-
// Restore full tool set (SDK does NOT support undefined)
|
|
194
|
-
pi.setActiveTools(pi.getAllTools().map((t: { name: string }) => t.name));
|
|
195
|
-
ctx.ui.notify("Plan mode aborted.", "info");
|
|
187
|
+
const result = exitPlanMode(pi, planCtx, sessionId, ctx, state, "command");
|
|
188
|
+
ctx.ui.notify(result.message, result.level);
|
|
196
189
|
}
|
|
197
190
|
|
|
198
191
|
/** Handle /plan status subcommand */
|
|
@@ -239,13 +232,15 @@ function sendPlanContextMessage(pi: ExtensionAPI, content: string): void {
|
|
|
239
232
|
}
|
|
240
233
|
|
|
241
234
|
/** E1 fail-fast 回复:不进入计划模式(不写 entry / 不限制工具 / 不注入计划提示词),回复可用技能清单与纠正命令 */
|
|
242
|
-
function reportUnknownSkills(pi: ExtensionAPI, resolution: Extract<SkillResolution, { ok: false }>): void {
|
|
235
|
+
function reportUnknownSkills(pi: ExtensionAPI, ctx: ExtensionContext, resolution: Extract<SkillResolution, { ok: false }>): void {
|
|
243
236
|
const available = resolution.available.length > 0
|
|
244
237
|
? resolution.available.map((n) => ` - ${n}`).join("\n")
|
|
245
238
|
: " (no skills installed)";
|
|
246
239
|
const problem = resolution.missing.length > 0
|
|
247
240
|
? `unknown skill(s): ${resolution.missing.join(", ")}`
|
|
248
241
|
: "--skills was given but no skill names followed it";
|
|
242
|
+
// D10②:fail-fast 错误的用户可见性由 notify 承担(display:false 注入仅 LLM 可见)
|
|
243
|
+
ctx.ui.notify(`Plan mode not entered: ${problem}`, "error");
|
|
249
244
|
sendPlanContextMessage(
|
|
250
245
|
pi,
|
|
251
246
|
`[PLAN MODE] Failed to enter: ${problem}.\n\n` +
|
|
@@ -259,7 +254,9 @@ function reportUnknownSkills(pi: ExtensionAPI, resolution: Extract<SkillResoluti
|
|
|
259
254
|
* 不限制工具 / 不注入计划提示词),回复问题句(互斥/不存在/非 md,problem 内
|
|
260
255
|
* 已带用法样例)。
|
|
261
256
|
*/
|
|
262
|
-
function reportTemplateFlagError(pi: ExtensionAPI, problem: string): void {
|
|
257
|
+
function reportTemplateFlagError(pi: ExtensionAPI, ctx: ExtensionContext, problem: string): void {
|
|
258
|
+
// D10②:fail-fast 错误的用户可见性由 notify 承担(display:false 注入仅 LLM 可见)
|
|
259
|
+
ctx.ui.notify(`Plan mode not entered: ${problem}`, "error");
|
|
263
260
|
sendPlanContextMessage(
|
|
264
261
|
pi,
|
|
265
262
|
`[PLAN MODE] Failed to enter: ${problem}.\n\n` +
|
|
@@ -270,7 +267,7 @@ function reportTemplateFlagError(pi: ExtensionAPI, problem: string): void {
|
|
|
270
267
|
/** Handle entering plan mode */
|
|
271
268
|
function handleEnterPlanMode(
|
|
272
269
|
pi: ExtensionAPI,
|
|
273
|
-
|
|
270
|
+
planCtx: PlanCtx,
|
|
274
271
|
sessionId: string,
|
|
275
272
|
ctx: ExtensionContext,
|
|
276
273
|
state: PlanState,
|
|
@@ -284,6 +281,7 @@ function handleEnterPlanMode(
|
|
|
284
281
|
if (parsed.skills !== undefined && parsed.templatePath !== undefined) {
|
|
285
282
|
reportTemplateFlagError(
|
|
286
283
|
pi,
|
|
284
|
+
ctx,
|
|
287
285
|
"--template and --skills are mutually exclusive. Use one: /plan <requirement> --skills a,b | /plan <requirement> --template <path>",
|
|
288
286
|
);
|
|
289
287
|
return;
|
|
@@ -294,7 +292,7 @@ function handleEnterPlanMode(
|
|
|
294
292
|
if (parsed.templatePath !== undefined) {
|
|
295
293
|
const resolution = resolveTemplateFile(parsed.templatePath, ctx.cwd);
|
|
296
294
|
if (!resolution.ok) {
|
|
297
|
-
reportTemplateFlagError(pi, resolution.problem);
|
|
295
|
+
reportTemplateFlagError(pi, ctx, resolution.problem);
|
|
298
296
|
return;
|
|
299
297
|
}
|
|
300
298
|
// 全文在校验通过后、任何进入副作用(entry/工具限制)之前读好——读失败
|
|
@@ -303,7 +301,7 @@ function handleEnterPlanMode(
|
|
|
303
301
|
try {
|
|
304
302
|
templateContent = fs.readFileSync(resolution.absPath, "utf-8");
|
|
305
303
|
} catch {
|
|
306
|
-
reportTemplateFlagError(pi, `Template file not readable: ${resolution.absPath}. ${TEMPLATE_USAGE_SAMPLE}`);
|
|
304
|
+
reportTemplateFlagError(pi, ctx, `Template file not readable: ${resolution.absPath}. ${TEMPLATE_USAGE_SAMPLE}`);
|
|
307
305
|
return;
|
|
308
306
|
}
|
|
309
307
|
templateAbsPath = resolution.absPath;
|
|
@@ -312,14 +310,21 @@ function handleEnterPlanMode(
|
|
|
312
310
|
// --skills E1 校验(互斥已判过后,走到这里 templatePath 必为 undefined)
|
|
313
311
|
const requested = parsed.skills ?? [];
|
|
314
312
|
if (parsed.skills !== undefined && requested.length === 0) {
|
|
315
|
-
|
|
313
|
+
// 空 --skills 报错复用 resolveSkills 单源(C1 去重:空请求 → ok:false + 全量
|
|
314
|
+
// available + 空 missing,报错文案不变)。TS 无法静态收窄「空请求恒 ok:false」,
|
|
315
|
+
// 运行时 fail-fast 承载该不变量(tool.ts switch default 不可达断言同风格)
|
|
316
|
+
const failed = resolveSkills(pi, requested);
|
|
317
|
+
if (failed.ok) {
|
|
318
|
+
throw new Error("plan: empty --skills must fail skill resolution (invariant)");
|
|
319
|
+
}
|
|
320
|
+
reportUnknownSkills(pi, ctx, failed);
|
|
316
321
|
return;
|
|
317
322
|
}
|
|
318
323
|
let resolved: SkillRef[] = [];
|
|
319
324
|
if (requested.length > 0) {
|
|
320
325
|
const resolution = resolveSkills(pi, requested);
|
|
321
326
|
if (!resolution.ok) {
|
|
322
|
-
reportUnknownSkills(pi, resolution);
|
|
327
|
+
reportUnknownSkills(pi, ctx, resolution);
|
|
323
328
|
return;
|
|
324
329
|
}
|
|
325
330
|
resolved = resolution.resolved;
|
|
@@ -329,7 +334,7 @@ function handleEnterPlanMode(
|
|
|
329
334
|
// 进入核心收敛到 enter.ts(plan(enter) tool 与 slash 命令共用);本入口只负责
|
|
330
335
|
// flag 解析/校验(上方)与提示词投递(下方 sendMessage custom message 注入)。
|
|
331
336
|
// state 由 activatePlanMode 就地改 + persist(getPlanState 缓存同一对象)。
|
|
332
|
-
const { prompt } = activatePlanMode(pi,
|
|
337
|
+
const { state: activated, prompt } = activatePlanMode(pi, planCtx.states, sessionId, ctx, {
|
|
333
338
|
requirement,
|
|
334
339
|
skills: resolved,
|
|
335
340
|
projectDir: ctx.cwd,
|
|
@@ -337,6 +342,9 @@ function handleEnterPlanMode(
|
|
|
337
342
|
? { template: { absPath: templateAbsPath, content: templateContent } }
|
|
338
343
|
: {}),
|
|
339
344
|
});
|
|
345
|
+
// D10②:进入成功的用户可见性由 notify 承担(提示词注入 display:false 仅 LLM 可见;
|
|
346
|
+
// widget 已呈激活态,notify 补命令反馈通道)
|
|
347
|
+
ctx.ui.notify(`Plan mode entered: ${activated.planFilePath}`, "info");
|
|
340
348
|
|
|
341
349
|
// Inject plan mode prompt as custom message(display/triggerTurn 语义见 sendPlanContextMessage。
|
|
342
350
|
// 非 streaming 直调 _runAgentPrompt 跳过 prompt() 前置链,首轮 systemPrompt 叠加
|
package/src/enter.ts
CHANGED
|
@@ -3,53 +3,21 @@ import * as path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
|
|
6
|
+
// 技能执行门禁(resolveSkills + SkillResolution)单源在 ADR-0074 共享包
|
|
7
|
+
// @zhushanwen/pi-exec-skills(plan-mode-audit-remediation 批次 4②b 迁入,含空请求
|
|
8
|
+
// fail-fast 契约);经此处 re-export 维持既有消费路径(tool.ts / command.ts / 测试
|
|
9
|
+
// import "./enter.js")不动
|
|
10
|
+
export { resolveSkills } from "@zhushanwen/pi-exec-skills";
|
|
11
|
+
export type { SkillResolution } from "@zhushanwen/pi-exec-skills";
|
|
12
|
+
|
|
6
13
|
import { buildPlanModePrompt } from "./prompts.js";
|
|
7
14
|
import type { SkillRef } from "./prompts.js";
|
|
8
15
|
import type { PlanSessionMap, PlanState } from "./state.js";
|
|
9
|
-
import { capPlanRequirement, getPlanState, persistPlanState, PLAN_MODE_TOOLS } from "./state.js";
|
|
16
|
+
import { applyPlanEvent, capPlanRequirement, clearRoundFields, getPlanState, persistPlanState, PLAN_MODE_TOOLS } from "./state.js";
|
|
10
17
|
import { updatePlanWidget } from "./widget.js";
|
|
11
18
|
|
|
12
19
|
export const MAX_SLUG_LENGTH = 30;
|
|
13
20
|
|
|
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
21
|
/** 进入 plan 模式的归一入参(slash 命令与 plan(enter) tool 两入口共用) */
|
|
54
22
|
export interface ActivatePlanModeInput {
|
|
55
23
|
requirement: string;
|
|
@@ -80,7 +48,8 @@ export interface ActivatePlanModeOutcome {
|
|
|
80
48
|
* 副作用顺序钉死:状态字段 → persistPlanState(entry 落盘)→ updatePlanWidget →
|
|
81
49
|
* setActiveTools → 构造 prompt。persist 先于工具收拢,保证「已持久 isActive 但工具未收」
|
|
82
50
|
* 的半进入态不可达(崩溃窗口内重开 session 经 entry 恢复 isActive,session_start hook
|
|
83
|
-
* 会补 setActiveTools
|
|
51
|
+
* 会补 setActiveTools)。生命周期写唯一通道 = applyPlanEvent('enter')(D1:idle|终态
|
|
52
|
+
* --enter--> planning 新一轮)。
|
|
84
53
|
*/
|
|
85
54
|
export function activatePlanMode(
|
|
86
55
|
pi: ExtensionAPI,
|
|
@@ -104,17 +73,29 @@ export function activatePlanMode(
|
|
|
104
73
|
state.planFilePath = planFilePath;
|
|
105
74
|
// state/entry/plan 帧侧 requirement 64KB 封顶(帧有界前提);prompt 仍用未封顶全文直达模型
|
|
106
75
|
state.requirement = capPlanRequirement(requirement);
|
|
107
|
-
// --template 直传:templateName = 去扩展名 basename(GUI / /plan status
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
76
|
+
// --template 直传:templateName = 去扩展名 basename(GUI / /plan status 展示,值域不变);
|
|
77
|
+
// 直传事实落独立布尔字段 templateProvided(select-template 防御的判定信号,D7——
|
|
78
|
+
// 双字段合并后的形态,双向版本错配登记见 PlanState.templateProvided 注释)
|
|
79
|
+
if (input.template) {
|
|
80
|
+
state.templateName = path.basename(input.template.absPath, ".md");
|
|
81
|
+
state.templateProvided = true;
|
|
82
|
+
} else {
|
|
83
|
+
state.templateName = "";
|
|
84
|
+
delete state.templateProvided;
|
|
85
|
+
}
|
|
111
86
|
state.skills = skills.map((s) => s.name);
|
|
112
87
|
state.docs = [];
|
|
113
|
-
// 新轮次重置:
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
88
|
+
// 新轮次重置:per-round 字段随进入失效(D4 单函数出口,与 resetPlanState 同源——
|
|
89
|
+
// 跨轮残留会误触新鲜度门 / 渲染上一轮降级文案(C-U2 同型残留))
|
|
90
|
+
clearRoundFields(state);
|
|
91
|
+
|
|
92
|
+
// 状态写走 transition()(D1 'enter' 边:idle|终态
|
|
93
|
+
// --enter--> planning 新一轮)。不一致格 isActive=false 且 state 落活跃族
|
|
94
|
+
//(仅坏数据/旧映射残留可达,P-6 单写入方下正常不可达)按 ok:false 落穿:不归一、不重试,
|
|
95
|
+
// 后续副作用照常(persist 携带现值)——原「归一 idle 再进」自愈分支已随
|
|
96
|
+
// plan-mode-audit-remediation 批次 3 删除(行为变更仅坏数据格可达,V5 对账归垃圾格口径);
|
|
97
|
+
// 正常路径恒单通道 applyPlanEvent。
|
|
98
|
+
applyPlanEvent(state, "enter");
|
|
118
99
|
|
|
119
100
|
persistPlanState(pi, state);
|
|
120
101
|
updatePlanWidget(ctx, state);
|
|
@@ -1,65 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plan complete 后的执行通知投递 + goal 桥。
|
|
3
|
+
*
|
|
4
|
+
* execute 档经 goal 桥 slot 注册执行跟踪(失败显式化五出口,不阻断执行),
|
|
5
|
+
* steer 指令承载执行方式裁决(goal 工作流 / skill / 直接执行三形态)。
|
|
6
|
+
* 压缩/分叉时的 plan 摘要由 pi 内置压缩机制承载(确定性文件操作清单 +
|
|
7
|
+
* keepRecentTokens 近端原文保留),本扩展不再接管压缩摘要。
|
|
8
|
+
*/
|
|
1
9
|
import * as fs from "node:fs";
|
|
2
|
-
import { basename } from "node:path";
|
|
10
|
+
import { basename, dirname } from "node:path";
|
|
3
11
|
|
|
4
|
-
import type { ExtensionAPI, ExtensionContext
|
|
5
|
-
import {
|
|
12
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
6
14
|
import type { GoalInitFn } from "@zhushanwen/pi-goal";
|
|
7
15
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
8
16
|
|
|
9
17
|
import { t } from "./i18n.js";
|
|
10
|
-
import type {
|
|
11
|
-
import { PLAN_CONTEXT_CUSTOM_TYPE
|
|
18
|
+
import type { PlanState } from "./state.js";
|
|
19
|
+
import { PLAN_CONTEXT_CUSTOM_TYPE } from "./state.js";
|
|
12
20
|
|
|
13
21
|
const logger = getLogger("pi-plan");
|
|
14
22
|
|
|
15
|
-
export function registerPlanEventHandlers(
|
|
16
|
-
pi: ExtensionAPI,
|
|
17
|
-
sessions: PlanSessionMap,
|
|
18
|
-
): void {
|
|
19
|
-
pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
|
|
20
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
21
|
-
const state = getPlanState(sessions, sessionId, ctx);
|
|
22
|
-
if (!state.isActive) return {};
|
|
23
|
-
|
|
24
|
-
const prep = event.preparation;
|
|
25
|
-
|
|
26
|
-
// Read plan file content for recovery after compact
|
|
27
|
-
const planContent = readPlanFileSafe(state.planFilePath);
|
|
28
|
-
|
|
29
|
-
// handler 已有 isActive 门——能走到这里的 plan 必然进行中(D6:phase 删除,原 phase="complete" 分支为死状态)
|
|
30
|
-
const progressNote = "\nPlan was in progress — review and continue.";
|
|
31
|
-
|
|
32
|
-
return {
|
|
33
|
-
compaction: {
|
|
34
|
-
summary:
|
|
35
|
-
`Plan mode active. Plan file: ${state.planFilePath}\n\n` +
|
|
36
|
-
`## Plan Content\n${planContent}\n\n` +
|
|
37
|
-
`Requirement: ${state.requirement}` +
|
|
38
|
-
progressNote,
|
|
39
|
-
firstKeptEntryId: prep?.firstKeptEntryId,
|
|
40
|
-
tokensBefore: prep?.tokensBefore,
|
|
41
|
-
},
|
|
42
|
-
};
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
pi.on("session_before_tree", async (_event: SessionBeforeTreeEvent, ctx: ExtensionContext) => {
|
|
46
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
47
|
-
const state = getPlanState(sessions, sessionId, ctx);
|
|
48
|
-
if (!state.isActive) return {};
|
|
49
|
-
|
|
50
|
-
const planContent = readPlanFileSafe(state.planFilePath);
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
summary: {
|
|
54
|
-
summary:
|
|
55
|
-
`Plan mode active. Plan file: ${state.planFilePath}\n\n` +
|
|
56
|
-
`## Plan Content\n${planContent}\n\n` +
|
|
57
|
-
`Read the plan file and execute the implementation.`,
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
23
|
/** Read plan file: ok=false 是显式信号(GoalBridgeOutcome 的 plan-unreadable 出口消费),消除哨兵字符串比较 */
|
|
64
24
|
type PlanFileContent = { ok: true; content: string } | { ok: false };
|
|
65
25
|
|
|
@@ -71,12 +31,6 @@ function readPlanFile(planFilePath: string): PlanFileContent {
|
|
|
71
31
|
}
|
|
72
32
|
}
|
|
73
33
|
|
|
74
|
-
/** Read plan file, return content or human-readable marker (for prompt embedding) */
|
|
75
|
-
function readPlanFileSafe(planFilePath: string): string {
|
|
76
|
-
const result = readPlanFile(planFilePath);
|
|
77
|
-
return result.ok ? result.content : "(plan file could not be read)";
|
|
78
|
-
}
|
|
79
|
-
|
|
80
34
|
/**
|
|
81
35
|
* goalInit slot key——goal 扩展的跨扩展编程式入口(goal-bridge-cross-extension.md)。
|
|
82
36
|
* ⚠️ 必须与 `extensions/universal/goal/src/index.ts` 的 GOAL_INIT_SLOT_KEY 字符串完全一致:
|
|
@@ -87,8 +41,7 @@ const GOAL_INIT_SLOT_KEY = Symbol.for("@zhushanwen/pi-goal.goalInit");
|
|
|
87
41
|
|
|
88
42
|
/**
|
|
89
43
|
* goal 桥的单一断言点:goal 扩展挂在 globalThis slot 上的编程式接口(发现 7——
|
|
90
|
-
*
|
|
91
|
-
* 桥通道从 pi API 对象挂载迁到 slot:pi 0.84.4 per-extension API 隔离使
|
|
44
|
+
* 桥通道从 pi API 对象挂载迁到 slot:pi per-extension API 隔离(1.0.0 复核)使
|
|
92
45
|
* pi.__goalInit 形态跨扩展恒不可见,slot 是 C-ext-06 惯例的进程级共享形态)。
|
|
93
46
|
*/
|
|
94
47
|
function getGoalInit(): GoalInitFn | undefined {
|
|
@@ -96,18 +49,16 @@ function getGoalInit(): GoalInitFn | undefined {
|
|
|
96
49
|
return typeof fn === "function" ? (fn as GoalInitFn) : undefined;
|
|
97
50
|
}
|
|
98
51
|
|
|
99
|
-
/** Detect whether goal extension is available via its programming interface */
|
|
100
|
-
export function detectGoalCapability(): boolean {
|
|
101
|
-
return getGoalInit() !== undefined;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
52
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
53
|
+
* goal 桥侧 slug(仅 widget 标题 + history 展示用,不注入 prompt)。生产输入恒为
|
|
54
|
+
* <project>/.tmp/plans/<slug>/plan.md(enter.ts 构造)——展示名取目录名(requirement
|
|
55
|
+
* slug 段,2026-09-27 用户裁决;已归档缺陷档案(plan-cjk-slug-data-loss) 缺陷 3 同源修复)。
|
|
56
|
+
* kebab 链对 enter slug 幂等,只防 planFilePath 非生产形态的通用输入;提取为空回
|
|
57
|
+
* "plan-execution"。中文需求且缺陷 2(enter 期目录唯一性)未修的窗口内,plan.md 直落
|
|
58
|
+
* .tmp/plans 使本函数取到骨架名 "plans"——该形态随缺陷 2 修复消除。
|
|
107
59
|
*/
|
|
108
60
|
function buildPlanSlug(planFilePath: string): string {
|
|
109
|
-
const stem = basename(planFilePath)
|
|
110
|
-
.replace(/\.md$/i, "")
|
|
61
|
+
const stem = basename(dirname(planFilePath))
|
|
111
62
|
.toLowerCase()
|
|
112
63
|
.replace(/[^a-z0-9]+/g, "-")
|
|
113
64
|
.replace(/^-+|-+$/g, "");
|
|
@@ -137,12 +88,12 @@ function truncatePreview(text: string): string {
|
|
|
137
88
|
* 从 plan 步骤构造可检查的 successCriteria(plan 完成 = 所有步骤执行并验证)。
|
|
138
89
|
* goal 的 complete 判定会对照本字段逐条做证据审计。
|
|
139
90
|
*
|
|
140
|
-
* 形态固定:1 条总述 `All N steps of <
|
|
91
|
+
* 形态固定:1 条总述 `All N steps of <plan slug> executed and verified`
|
|
141
92
|
* + 前 PREVIEW_COUNT 条 step preview(编号前缀、单条截断 ≤PREVIEW_MAX_CHARS),
|
|
142
93
|
* 合计 ≤4 条(goal schema maxItems:8),每条单行不含 \r\n。
|
|
143
94
|
*/
|
|
144
95
|
export function buildPlanSuccessCriteria(planFilePath: string, tasks: string[]): string[] {
|
|
145
|
-
const planName = toSingleLine(
|
|
96
|
+
const planName = toSingleLine(buildPlanSlug(planFilePath));
|
|
146
97
|
const items = [`All ${tasks.length} steps of ${planName} executed and verified`];
|
|
147
98
|
const previews = tasks
|
|
148
99
|
.slice(0, PREVIEW_COUNT)
|
|
@@ -260,7 +211,7 @@ function deliverExecutionNotice(
|
|
|
260
211
|
skillEntryPath?: string,
|
|
261
212
|
): GoalBridgeOutcome | undefined {
|
|
262
213
|
// execute 档整合 goal 桥:tryGoalInit 内部含 goal-unavailable gate(goal 未挂载走
|
|
263
|
-
// started:false
|
|
214
|
+
// started:false 降级),无需前置探测
|
|
264
215
|
const outcome = execMode === "execute" ? tryGoalInit(planFilePath, ctx) : undefined;
|
|
265
216
|
|
|
266
217
|
let modeHint: string;
|
|
@@ -297,58 +248,19 @@ function deliverExecutionNotice(
|
|
|
297
248
|
}
|
|
298
249
|
|
|
299
250
|
/**
|
|
300
|
-
* complete
|
|
251
|
+
* complete 后的执行通知投递(plan-mode-audit-remediation D-B1-6:执行前压缩档
|
|
252
|
+
* 砍除——原 compact | direct 两档分发退化为单一直接投递路径)。
|
|
301
253
|
*
|
|
302
|
-
* 返回值:execMode=execute
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
* entry 须在压缩后的世界里创建,提前到 compact 前有被压缩边界丢弃的风险,时序
|
|
306
|
-
* 不动——设计 §6.2 D2),该档 result 已返回,失败报告走 steer + notify 通道。
|
|
254
|
+
* 返回值:execMode=execute 时同步返回 goalInit 的 outcome(executeComplete 写进
|
|
255
|
+
* result content 与 details);skill 档无 goalInit,返回 undefined——该档 result
|
|
256
|
+
* 已返回,失败报告走 steer + notify 通道。
|
|
307
257
|
*/
|
|
308
258
|
export function handlePlanComplete(
|
|
309
259
|
pi: ExtensionAPI,
|
|
310
260
|
ctx: ExtensionContext,
|
|
311
261
|
state: PlanState,
|
|
312
|
-
isolation: string,
|
|
313
262
|
execMode: string,
|
|
314
263
|
skillEntryPath?: string,
|
|
315
264
|
): GoalBridgeOutcome | undefined {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
switch (isolation) {
|
|
319
|
-
case "compact": {
|
|
320
|
-
ctx.compact({
|
|
321
|
-
customInstructions: `Plan file: ${planFilePath}. Read plan and execute implementation.`,
|
|
322
|
-
// E1 同构崩溃点(crash-resilience D1):onComplete/onError 由 compact 内部
|
|
323
|
-
// Promise 链异步调用、不在 pi runner emit() 的 try/catch 内——压缩进行中用户
|
|
324
|
-
// 切换/重载 session 后,回调触碰捕获的 pi/ctx 命中 stale 同步抛错即杀 pi 进程。
|
|
325
|
-
// 守卫 stale 静默降级(执行消息不投递,用户可手动 Read plan 文件执行),非
|
|
326
|
-
// stale 错误原样上抛。plan 未注入代际计数器(低频路径),分诊依赖 PS-30 门禁
|
|
327
|
-
// 守卫的 stale 文案兜底(D1 降级语义声明的合法形态)。
|
|
328
|
-
onComplete: () => {
|
|
329
|
-
guardStaleCtx(() => {
|
|
330
|
-
deliverExecutionNotice(pi, ctx, planFilePath, execMode, skillEntryPath);
|
|
331
|
-
}, {
|
|
332
|
-
label: "plan:compact-onComplete",
|
|
333
|
-
onStale: (error) => logger.warn("plan execution notice delivery skipped (stale ctx)", { error: toErrorMessage(error) }),
|
|
334
|
-
});
|
|
335
|
-
},
|
|
336
|
-
onError: (_error: Error) => {
|
|
337
|
-
guardStaleCtx(() => {
|
|
338
|
-
ctx.ui.notify("Compact failed, continuing without isolation.", "warning");
|
|
339
|
-
deliverExecutionNotice(pi, ctx, planFilePath, execMode, skillEntryPath);
|
|
340
|
-
}, {
|
|
341
|
-
label: "plan:compact-onError",
|
|
342
|
-
onStale: (error) => logger.warn("plan execution notice delivery skipped (stale ctx)", { error: toErrorMessage(error) }),
|
|
343
|
-
});
|
|
344
|
-
},
|
|
345
|
-
});
|
|
346
|
-
return undefined;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
case "direct":
|
|
350
|
-
default: {
|
|
351
|
-
return deliverExecutionNotice(pi, ctx, planFilePath, execMode, skillEntryPath);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
265
|
+
return deliverExecutionNotice(pi, ctx, state.planFilePath, execMode, skillEntryPath);
|
|
354
266
|
}
|