@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/src/prompts.ts ADDED
@@ -0,0 +1,151 @@
1
+ import type { PlanReviewComment } from "@zhushanwen/extension-protocol";
2
+
3
+ import { formatAvailablePlans, listTemplates } from "./templates.js";
4
+
5
+ /**
6
+ * 提示词注入的技能引用:名字 + SKILL.md 路径。
7
+ * 路径来自 pi.getCommands() 的 source === "skill" 条目的 sourceInfo.path——
8
+ * 技能枚举与路径经 pi 取得(不自扫描 7 源目录,D2),AI 自行 read 技能文件。
9
+ */
10
+ export interface SkillRef {
11
+ name: string;
12
+ skillPath: string;
13
+ }
14
+
15
+ /** --template 直传的模板材料(与 --skills 互斥,命令层校验通过并读好全文后传入) */
16
+ export interface ProvidedTemplate {
17
+ /** 展开后模板文件绝对路径(声明行展示用) */
18
+ absPath: string;
19
+ /** 模板文件全文(内嵌注入——不赌模型自发 read,D5) */
20
+ content: string;
21
+ }
22
+
23
+ /** buildPlanModePrompt 的入参(requirement/planFilePath/projectRoot 来自命令解析,skills 来自 E1 校验后的解析结果) */
24
+ export interface PlanPromptInput {
25
+ requirement: string;
26
+ planFilePath: string;
27
+ /**
28
+ * 项目级模板源锚点(设计 D2 锚定 = 命令层 ctx.cwd),注入段扫描
29
+ * <projectRoot>/.agents/plans。显式传入而非从 planFilePath 逆推层级——
30
+ * planFilePath = <ctx.cwd>/.tmp/plans/<slug>/plan.md(slug 目录嵌套),层级逆推
31
+ * 曾差层级致项目级源恒扫空(U1),且与
32
+ * select-template 侧(listTemplates({ projectRoot: ctx.cwd }))同锚点双轨同源。
33
+ */
34
+ projectRoot: string;
35
+ skills: SkillRef[];
36
+ /** --template 直传时给出(D5):抑制 <available-plans> 清单段 + 提示词内嵌全文 */
37
+ template?: ProvidedTemplate;
38
+ }
39
+
40
+ /** Phase B/D 与模板来源无关,清单流程与直传流程共用 */
41
+ const PHASE_B_SECTION =
42
+ `## Phase B: Brainstorming\n` +
43
+ `1. **Quick Overview**: ls project root, read README, package.json — build context (< 30s).\n` +
44
+ `2. **Explore before asking**: grep/read code first. Only ask user for preferences, not code-fact questions.\n` +
45
+ `3. **Progressive questioning**: Ask 2-3 questions at a time. Use ask_user tool if available.\n` +
46
+ `4. **Propose 2-3 approaches** with trade-offs + recommendation.\n` +
47
+ `5. **Assumption audit**: Grep-verify interfaces/types exist. Mark [UNVERIFIED] what can't be verified.`;
48
+
49
+ // Phase D 第 3 点的选项集描述必须与 buildExecOptions(tool.ts)产出的实际选项一致
50
+ // (≤2 个 plan-exec skill 档 + Execute + Not now,label 与 i18n exec.* 词典同源)——
51
+ // 选项集重排后此处描述漂移会诱导模型向用户预告不存在的执行方式。
52
+ const PHASE_D_SECTION =
53
+ `## Phase D: Completion\n` +
54
+ `1. Ask user to review the complete plan.\n` +
55
+ `2. Call plan tool (complete) with isolation method (compact/direct).\n` +
56
+ `3. After plan complete: the user picks an execution method in the completion dialog — up to 2 detected plan-exec skills (Execute via skill: <name>, when detected), Execute (goal tracking integrated when available), or Not now (stay in plan mode).`;
57
+
58
+ /**
59
+ * /plan 命令的提示词四段注入(D2):
60
+ * ① 技能指令(仅挂载 --skills 时)——按技能 SKILL.md 流程产出计划文档;
61
+ * ② 产物纪律(恒注入)——register-doc 登记 / 修订重调 version+1 / 全部完成调
62
+ * submit-review / submit-review 被消费后当轮回应完重挂直到确认;
63
+ * ③ 只读纪律(恒注入,现状 pi-ext-021 提示词保持);
64
+ * ④ 模板流程(仅未指定 --skills 时)——<available-plans> 三源清单注入(内置 5
65
+ * + 用户级 + 项目级,模型自选模板,D8);空发现不注入段(warn 另落)。
66
+ * --template 直传(D5)走 ④ 的直传变体:不注入清单段(guide 行「Pick a
67
+ * template」与「模板已指定」并存会诱导模型画蛇添足调 select-template),
68
+ * 改为内嵌模板全文——文件内容直达模型,不赌自发 read。
69
+ */
70
+ export function buildPlanModePrompt(input: PlanPromptInput): string {
71
+ const sections: string[] = [`[PLAN MODE] Entered plan mode.\n\nRequirement: ${input.requirement || "(from conversation context)"}\nPlan manifest file: ${input.planFilePath}`];
72
+
73
+ // ① 技能指令
74
+ if (input.skills.length > 0) {
75
+ const skillLines = input.skills
76
+ .map((s) => `- ${s.name}: read ${s.skillPath} and follow its workflow to produce the plan documents it specifies.`)
77
+ .join("\n");
78
+ sections.push(
79
+ `## Skill Workflow\n` +
80
+ `You are working through the following skills (mounted via --skills), in order:\n` +
81
+ `${skillLines}\n` +
82
+ `Read each skill file FIRST before producing any document. The skills define WHAT documents to produce and their structure; the plan-mode rules below define WHERE to write them and HOW to register them.`,
83
+ );
84
+ }
85
+
86
+ // ② 产物纪律
87
+ sections.push(
88
+ `## Deliverable Discipline\n` +
89
+ `- Write every deliverable document into the plan directory (.tmp/plans/<slug>/), then register it: plan(action='register-doc', fileName='design.md', sourceSkill='<skill name or omit>').\n` +
90
+ `- After revising a document, REWRITE the file and re-register it with plan(action='register-doc', fileName=...) again — the version is bumped so the UI refreshes its content.\n` +
91
+ `- When ALL documents are done, call plan(action='submit-review') to request user review.\n` +
92
+ `- After submit-review is consumed (a revision request), finish responding for the current turn, then call plan(action='submit-review') again to re-hang the review — repeat until the user confirms execution.`,
93
+ );
94
+
95
+ // ③ 只读纪律(现状 pi-ext-021 提示词保持)
96
+ sections.push(
97
+ `## Constraints\n` +
98
+ `- READ-ONLY: Do NOT edit any files except plan documents under the plan directory.\n` +
99
+ `- Do NOT run write commands (mkdir, echo, sed, etc.) on non-plan files.\n` +
100
+ `- All plan content goes to plan documents only.`,
101
+ );
102
+
103
+ // ④ 模板流程(无 --skills 回落;--template 直传走变体分支)
104
+ if (input.template !== undefined) {
105
+ sections.push(
106
+ `${PHASE_B_SECTION}\n\n` +
107
+ `## Phase C: Writing\n` +
108
+ `1. The template was provided via --template — its full content is embedded below. Do NOT call plan(action='select-template'); the template is already chosen.\n` +
109
+ `2. Write chapters in template order — do NOT skip unwritten chapters.\n` +
110
+ `3. Write all chapters in one turn, then ask user to review.\n\n` +
111
+ `## Template (via --template): ${input.template.absPath}\n\n` +
112
+ `<template>\n${input.template.content}\n</template>` +
113
+ `\n\n${PHASE_D_SECTION}`,
114
+ );
115
+ } else if (input.skills.length === 0) {
116
+ // <available-plans> 三源清单随本提示词一次性注入(D3:选型期一次性信息,
117
+ // select-template 报错自带清单兜底自愈)
118
+ const plansSection = formatAvailablePlans(listTemplates({ projectRoot: input.projectRoot }));
119
+ const pickTemplateStep = plansSection !== ""
120
+ ? `1. Pick the template that best fits this requirement from the <available-plans> list below and call plan tool (select-template, templateName='<name>') — you pick the template yourself; the user can override by replying.`
121
+ : `1. No plan templates were discovered — structure the plan document with your own chapter skeleton (e.g. Overview / Requirements / Implementation Steps).`;
122
+ sections.push(
123
+ `${PHASE_B_SECTION}\n\n` +
124
+ `## Phase C: Writing\n` +
125
+ `${pickTemplateStep}\n` +
126
+ `2. Write chapters in template order — do NOT skip unwritten chapters.\n` +
127
+ `3. Write all chapters in one turn, then ask user to review.` +
128
+ (plansSection !== "" ? `\n\n${plansSection}` : "") +
129
+ `\n\n${PHASE_D_SECTION}`,
130
+ );
131
+ }
132
+
133
+ return sections.join("\n\n");
134
+ }
135
+
136
+ /**
137
+ * revise decision 的评论清单注入文本(D5 decision 消费;explain 决策已删,
138
+ * PlanReviewDecision 收敛为 'approve' | 'revise',approve 走 complete 流程不经本函数)。
139
+ * 评论语义:quote 是用户划选引文(agent 定位段落用),comment 是评语。
140
+ */
141
+ export function formatReviewComments(comments: PlanReviewComment[]): string {
142
+ const header = `[PLAN REVIEW] The user submitted ${comments.length} comment(s) and requested document revision. For each comment: locate the quoted passage, apply the requested change, rewrite the file, then re-register it via plan(action='register-doc') — after ALL comments are addressed, call plan(action='submit-review') again to re-hang the review.`;
143
+
144
+ const lines = comments.map((c, i) => {
145
+ const quote = c.quote.trim() || "(no selection)";
146
+ const comment = c.comment.trim() || "(no text)";
147
+ return `${i + 1}. Quote: "${quote}"\n Comment: ${comment}`;
148
+ });
149
+
150
+ return `${header}\n\n${lines.join("\n")}`;
151
+ }
package/src/state.ts CHANGED
@@ -1,10 +1,55 @@
1
+ import { readdirSync, rmdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+
4
+ import type { PlanDocMeta } from "@zhushanwen/extension-protocol";
1
5
  import type { CustomEntry, ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
6
+ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
7
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
8
+
9
+ const logger = getLogger("pi-plan");
10
+
11
+ /**
12
+ * 审阅态两值(D1)——awaiting = 文档就绪等审批;revising = 修订中;
13
+ * 无值 = 进行中。approve 不设中间态:直接走 complete → resetPlanState 落
14
+ * isActive=false,PlanModeBar 消失由 isActive 驱动。
15
+ */
16
+ export type PlanReviewState = "awaiting" | "revising";
17
+
18
+ /**
19
+ * 降级态来源标记(plan-mode-ux-refactor §3.4):reviewState='awaiting' 且无挂起 select
20
+ * 时区分等待原因——'resubmit' = 会话重启(E3)后 agent 尚未重新提交。写入点 =
21
+ * index.ts E3 steer 重挂处;清除点三处(resetPlanState 终态清理组 / activatePlanMode
22
+ * 新轮次重置组 / submit-review 重挂起点重置)——缺清除 = 跨 plan run 残留(C-U2 同型
23
+ * 缺陷:bad-response 等罕见路径可渲染上一轮的来源文案)。字面量与 shared
24
+ * PlanStateView.reviewStateSource 严格一致。
25
+ */
26
+ export type PlanReviewStateSource = "resubmit";
2
27
 
3
28
  export interface PlanState {
4
29
  isActive: boolean;
5
30
  planFilePath: string;
6
31
  requirement: string;
7
32
  templateName: string;
33
+ /**
34
+ * --template 直传标记(D7 select-template 防御的判定信号):直传进入时 =
35
+ * 展开后模板文件绝对路径;模板流程进入缺失。templateName 字段两流程共用
36
+ * (直传存 basename / 选中存模板名),单看它无法区分「已直传」与「已选中」,
37
+ * 故独立持久化直传事实——重启经 entry 恢复,reset 时随退出失效。
38
+ */
39
+ templateProvidedPath?: string;
40
+ /** 挂载技能名清单(--skills 解析产物;模板流程为空数组——挂载声明,reset 时随退出失效) */
41
+ skills: string[];
42
+ /** 产物文档清单(register-doc 登记;reset 时保留——产物 tab 与 isActive 解耦,跨重开留存) */
43
+ docs: PlanDocMeta[];
44
+ reviewState?: PlanReviewState;
45
+ /** 降级态来源标记(见 PlanReviewStateSource);仅 reviewState 有值时有语义,无值 = 来源未知 */
46
+ reviewStateSource?: PlanReviewStateSource;
47
+ /**
48
+ * 上次 submit-review 时的 docs 快照指纹(planDocsFingerprint 产物)。缺失 = 无既往
49
+ * 提交(首次提交 / reset 后 / 旧版 entry 重挂),重提交无变化检测不警告;reset 随
50
+ * 退出失效(新 plan 轮次重新计数),重开 session 经 entry 恢复(E3 重挂同款受益)。
51
+ */
52
+ lastSubmitReviewDocsFingerprint?: string;
8
53
  }
9
54
 
10
55
  export const DEFAULT_PLAN_STATE: PlanState = {
@@ -12,11 +57,79 @@ export const DEFAULT_PLAN_STATE: PlanState = {
12
57
  planFilePath: "",
13
58
  requirement: "",
14
59
  templateName: "",
60
+ skills: [],
61
+ docs: [],
15
62
  };
16
63
 
64
+ /**
65
+ * 计划态工具白名单(进入计划模式三处共用——slash 命令 / plan(enter) tool / session_start
66
+ * 恢复;bash 在白名单内,文件写约束来自注入的计划模式提示词,见 pi-ext-021)。
67
+ * 放在 state.ts(叶模块)而非 tool.ts:enter.ts 与本常量互需会造成 enter↔tool 循环依赖。
68
+ */
69
+ export const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "plan"];
70
+
71
+ /**
72
+ * plan 包注入消息的 customType(pi.sendMessage custom message 注入的 8 处调用
73
+ * 统一使用)。命名对齐本包 entry customType 字面量 'plan-state' 的连字符风格
74
+ * (设计 §2.1 双命名范式决策:各包跟随所在包 entry 惯例,跨包不统一)。
75
+ * 放 state.ts(叶模块)理由同 PLAN_MODE_TOOLS。
76
+ */
77
+ export const PLAN_CONTEXT_CUSTOM_TYPE = "plan-context";
78
+
17
79
  /** Per-session state cache. Keyed by sessionId. */
18
80
  export type PlanSessionMap = Map<string, PlanState>;
19
81
 
82
+ /**
83
+ * requirement 长度封顶(64KB):session.planState 帧在 runtime 出站守卫(outbound-frame-registry)
84
+ * 按「requirement 有界」归入不登记 LARGE_FIELD_REGISTRY 的标量/小列表类——超 32MB 的未登记帧
85
+ * 会被整帧丢弃(前端 plan 面板缺失)。本封顶把该隐含前提变为代码保障(进入写侧 + entry 重建
86
+ * 读侧双点)。截断只影响 state/entry/帧;进入提示词仍携带全文直达模型(message content 通路
87
+ * 有既有注册表登记兜底)。
88
+ */
89
+ // eslint-disable-next-line no-magic-numbers -- 64KB = 64 * 1024 字节换算常数(同 event-journal.ts 32KB 先例)
90
+ export const MAX_PLAN_REQUIREMENT_LENGTH = 64 * 1024;
91
+
92
+ /** requirement 封顶:超长截断 + 省略标记(含被省略字符数),短文本原样返回 */
93
+ export function capPlanRequirement(requirement: string): string {
94
+ if (requirement.length <= MAX_PLAN_REQUIREMENT_LENGTH) return requirement;
95
+ const omitted = requirement.length - MAX_PLAN_REQUIREMENT_LENGTH;
96
+ return `${requirement.slice(0, MAX_PLAN_REQUIREMENT_LENGTH)}\n[requirement truncated: ${omitted} characters omitted]`;
97
+ }
98
+
99
+ /**
100
+ * docs 快照指纹:fileName:version 按登记序拼接。register-doc 任何形态(新增 /
101
+ * 同名原位 version+1)都会改变指纹 ⇒「与上次 submit-review 快照相同 ⇔ 期间无任何
102
+ * register-doc」。空 docs 的指纹是空串,但 submit-review 的 no-docs 守卫先行拦截,
103
+ * 空串指纹不会入库。
104
+ */
105
+ export function planDocsFingerprint(docs: PlanDocMeta[]): string {
106
+ return docs.map((d) => `${d.fileName}:${d.version}`).join("|");
107
+ }
108
+
109
+ /**
110
+ * 挂起 select 的 per-session AbortController 注册表(E10)。
111
+ * 生命周期钉死:每次发挂起 select 前 fresh 一个(见 freshAbortController),
112
+ * select settled 即弃;session_start / session_shutdown / abort 联动处清理。
113
+ */
114
+ export type PlanAbortControllers = Map<string, AbortController>;
115
+
116
+ /**
117
+ * 发挂起 select 前新建 controller 并登记。禁复用已 abort 的 controller——
118
+ * pi 实装对已 abort 的 signal 在 createDialogPromise 首行短路立即 resolve undefined,
119
+ * 复用会让退出后再入 plan 的 submit-review 瞬时静默取消。
120
+ * pi 实装锚点:dist/modes/rpc/rpc-mode.js:48(0.84.4)——createDialogPromise 首行
121
+ * `opts?.signal?.aborted` 即 `return Promise.resolve(defaultValue)`,select 的
122
+ * defaultValue = undefined(E10 生命周期设计依据)。
123
+ */
124
+ export function freshAbortController(
125
+ controllers: PlanAbortControllers,
126
+ sessionId: string,
127
+ ): AbortController {
128
+ const controller = new AbortController();
129
+ controllers.set(sessionId, controller);
130
+ return controller;
131
+ }
132
+
20
133
  /**
21
134
  * Get plan state for a session. Returns cached state if available,
22
135
  * otherwise reconstructs from sessionManager and caches it.
@@ -35,15 +148,46 @@ export function getPlanState(
35
148
  }
36
149
 
37
150
  export function persistPlanState(pi: ExtensionAPI, state: PlanState): void {
151
+ // customType 字面量 'plan-state' 是 runtime 投影链的派生锚点(u1-proj 侧用同字面量
152
+ // 派生扫描),两侧独立常量,勿改字面量。optional 字段(reviewState /
153
+ // reviewStateSource / lastSubmitReviewDocsFingerprint)为 undefined 时 JSON 序列化
154
+ // 自然消失,旧 entry 消费方对该字段惰性(D4 向后兼容)。
38
155
  pi.appendEntry("plan-state", {
39
156
  isActive: state.isActive,
40
157
  planFilePath: state.planFilePath,
41
158
  requirement: state.requirement,
42
159
  templateName: state.templateName,
160
+ templateProvidedPath: state.templateProvidedPath,
161
+ skills: state.skills,
162
+ docs: state.docs,
163
+ reviewState: state.reviewState,
164
+ reviewStateSource: state.reviewStateSource,
165
+ lastSubmitReviewDocsFingerprint: state.lastSubmitReviewDocsFingerprint,
43
166
  });
44
167
  }
45
168
 
46
- /** Reset plan state to idle, persist, and clean up session cache. */
169
+ /**
170
+ * Reset plan state to idle, persist, and clean up session cache.
171
+ *
172
+ * 终态矩阵(D5/E10):isActive=false + reviewState 清空 + skills 清空(挂载声明失效)+
173
+ * docs 保留——产物 tab 由 docs.length 驱动、与 isActive 解耦,执行期(approve 后)与
174
+ * 退出后(abort 后)都可回看产物文档;reset entry 持久,重开 session 后冷启动首拉仍恢复
175
+ * docs 显示,至下次 /plan 同 slug 覆写。
176
+ */
177
+ /** 空 slug 目录清理(状态审查 P3-10):enter 即 mkdir,未产任何文档即退出会留空目录残盘。
178
+ * 仅删「真正为空」的 slug 目录——目录里有任何残留文件(含未登记杂文件)一律保留(保守,
179
+ * 不做递归删除);目录不存在 / 非空 / 不可读均降级跳过(warn 留痕),清理失败不影响退出主流程。
180
+ */
181
+ function removeEmptyPlanDir(planFilePath: string): void {
182
+ if (!planFilePath) return;
183
+ try {
184
+ const dir = dirname(planFilePath);
185
+ if (readdirSync(dir).length === 0) rmdirSync(dir);
186
+ } catch (error) {
187
+ logger.warn("plan: empty plan dir cleanup skipped", { error: toErrorMessage(error) });
188
+ }
189
+ }
190
+
47
191
  export function resetPlanState(
48
192
  pi: ExtensionAPI,
49
193
  sessions: PlanSessionMap,
@@ -52,16 +196,28 @@ export function resetPlanState(
52
196
  ): PlanState {
53
197
  const state = getPlanState(sessions, sessionId, ctx);
54
198
  state.isActive = false;
199
+ // 空目录清理必须在 planFilePath 清空之前(路径是唯一的目录推导来源)
200
+ removeEmptyPlanDir(state.planFilePath);
55
201
  state.planFilePath = "";
56
202
  state.requirement = "";
57
203
  state.templateName = "";
204
+ delete state.templateProvidedPath;
205
+ state.skills = [];
206
+ delete state.reviewState;
207
+ // 来源标记与 reviewState 同生命周期随退出失效:残留 'explain' 会让下一轮经
208
+ // bad-response 等罕见路径渲染「已收到你的问题」而本轮无人提问(C-U2 同型残留)
209
+ delete state.reviewStateSource;
210
+ // 指纹快照随退出失效:approve/abort 后的新 plan 轮次从「无既往提交」重新计数,
211
+ // 首次 submit-review 不触发无变化警告(docs 虽保留供回看,但不作为检测基线)
212
+ delete state.lastSubmitReviewDocsFingerprint;
58
213
  persistPlanState(pi, state);
59
214
  sessions.delete(sessionId);
60
215
  return state;
61
216
  }
62
217
 
63
218
  function isPlanStateEntry(entry: SessionEntry): entry is CustomEntry<Partial<PlanState>> & { customType: "plan-state" } {
64
- // 判别式收窄(type === "custom")后可直接访问 customType/data,无需 cast
219
+ // 判别式收窄(type === "custom")后可直接访问 customType/data,无需 cast。
220
+ // 「字段存在即合法」:新字段全部 optional,旧四字段 entry 同样合法(D4 向后兼容)。
65
221
  return (
66
222
  entry.type === "custom" &&
67
223
  entry.customType === "plan-state" &&
@@ -70,6 +226,77 @@ function isPlanStateEntry(entry: SessionEntry): entry is CustomEntry<Partial<Pla
70
226
  );
71
227
  }
72
228
 
229
+ /** skills 白名单式读取:数组 + 逐项 string 守卫,垃圾项丢弃(垃圾数据不进内存态) */
230
+ function readSkills(data: Partial<PlanState>): string[] {
231
+ if (!Array.isArray(data.skills)) return [];
232
+ return data.skills.filter((s): s is string => typeof s === "string");
233
+ }
234
+
235
+ /** PlanDocMeta 最小结构守卫:fileName/absPath 必要字段(渲染与 file.read 的锚点) */
236
+ function isPlanDocMeta(value: unknown): value is PlanDocMeta {
237
+ if (typeof value !== "object" || value === null) return false;
238
+ if (!("fileName" in value) || !("absPath" in value)) return false;
239
+ return typeof value.fileName === "string" && typeof value.absPath === "string";
240
+ }
241
+
242
+ /** docs 白名单式读取:逐项校验 PlanDocMeta 必要字段(fileName/absPath),缺字段的条目丢弃 */
243
+ function readDocs(data: Partial<PlanState>): PlanDocMeta[] {
244
+ if (!Array.isArray(data.docs)) return [];
245
+ return data.docs.filter(isPlanDocMeta);
246
+ }
247
+
248
+ /** reviewState 值域守卫:'awaiting' | 'revising' 之外的值按无值处理 */
249
+ function readReviewState(data: Partial<PlanState>): PlanReviewState | undefined {
250
+ return data.reviewState === "awaiting" || data.reviewState === "revising"
251
+ ? data.reviewState
252
+ : undefined;
253
+ }
254
+
255
+ /** reviewStateSource 值域守卫:域外值按无值处理(与 readReviewState 同风格)。
256
+ * 旧版 entry 的 'explain' 存量值同样归无值——explain 交互已删(2026-09-21 审批两键
257
+ * 收敛),renderer 缺省分支据此渲染通用文案,无需为存量值保留枚举成员。 */
258
+ function readReviewStateSource(data: Partial<PlanState>): PlanReviewStateSource | undefined {
259
+ return data.reviewStateSource === "resubmit" ? data.reviewStateSource : undefined;
260
+ }
261
+
262
+ /** requirement 白名单式读取 + 长度封顶:非 string(含缺失)归空串;封顶前旧版 entry 的
263
+ * 超长文本在重建时同样封顶(读侧防御,保证派生 plan 帧恒有界) */
264
+ function readRequirement(data: Partial<PlanState>): string {
265
+ return typeof data.requirement === "string" ? capPlanRequirement(data.requirement) : "";
266
+ }
267
+
268
+ /** 快照指纹白名单式读取:非 string(含缺失)按无既往提交处理(D4 字段级降级) */
269
+ function readDocsFingerprint(data: Partial<PlanState>): string | undefined {
270
+ return typeof data.lastSubmitReviewDocsFingerprint === "string"
271
+ ? data.lastSubmitReviewDocsFingerprint
272
+ : undefined;
273
+ }
274
+
275
+ /** 直传标记白名单式读取:非 string(含缺失)按模板流程处理(D4 字段级降级) */
276
+ function readTemplateProvidedPath(data: Partial<PlanState>): string | undefined {
277
+ return typeof data.templateProvidedPath === "string" ? data.templateProvidedPath : undefined;
278
+ }
279
+
280
+ /**
281
+ * 单条 plan-state entry 数据的逐字段应用(调用方已过 isPlanStateEntry 门,data 为
282
+ * 非空对象;`?? {}` 仅为 data?: T 的类型 shim)。
283
+ */
284
+ function applyPlanStateEntry(state: PlanState, data: Partial<PlanState> | undefined): void {
285
+ // 逐字段 ?? 白名单式读取:旧版 entry 残留的 phase 字段被自然忽略(D6 兼容读);
286
+ // 新字段缺失(旧 entry)归一为空清单/无值(D4 字段级降级)
287
+ const entryData = data ?? {};
288
+ state.isActive = entryData.isActive ?? false;
289
+ state.planFilePath = entryData.planFilePath ?? "";
290
+ state.requirement = readRequirement(entryData);
291
+ state.templateName = entryData.templateName ?? "";
292
+ state.templateProvidedPath = readTemplateProvidedPath(entryData);
293
+ state.skills = readSkills(entryData);
294
+ state.docs = readDocs(entryData);
295
+ state.reviewState = readReviewState(entryData);
296
+ state.reviewStateSource = readReviewStateSource(entryData);
297
+ state.lastSubmitReviewDocsFingerprint = readDocsFingerprint(entryData);
298
+ }
299
+
73
300
  export function reconstructPlanState(ctx: ExtensionContext): PlanState {
74
301
  const state = { ...DEFAULT_PLAN_STATE };
75
302
  const entries = ctx.sessionManager.getEntries();
@@ -78,12 +305,7 @@ export function reconstructPlanState(ctx: ExtensionContext): PlanState {
78
305
  // entries[i] 是复杂表达式(TS 不收窄),守卫移到 const 变量上
79
306
  const entry = entries[i];
80
307
  if (!isPlanStateEntry(entry)) continue;
81
- const data = entry.data;
82
- // 逐字段 ?? 白名单式读取:旧版 entry 残留的 phase 字段被自然忽略(D6 兼容读)
83
- state.isActive = data?.isActive ?? false;
84
- state.planFilePath = data?.planFilePath ?? "";
85
- state.requirement = data?.requirement ?? "";
86
- state.templateName = data?.templateName ?? "";
308
+ applyPlanStateEntry(state, entry.data);
87
309
  break;
88
310
  }
89
311
 
package/src/templates.ts CHANGED
@@ -1,35 +1,113 @@
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
  import { fileURLToPath } from "node:url";
4
5
 
6
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
7
+
5
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
9
 
10
+ const logger = getLogger("pi-plan");
11
+
7
12
  export interface TemplateInfo {
8
13
  name: string;
9
14
  path: string;
10
15
  }
11
16
 
17
+ /**
18
+ * 内置模板目录双形态探测:本文件在两种布局下被加载,templates/ 相对位置不同——
19
+ * - 源码形态(npm 安装 / dev jiti 直接加载 src/templates.ts):__dirname = <pkg>/src,
20
+ * 模板在 `../templates`(包根 templates/,随 files 白名单发布)。
21
+ * - bundle 形态(staged 打包内置,esbuild 把本文件 inline 进包根 index.js):
22
+ * __dirname = …/extensions/@zhushanwen/pi-plan,模板由 bundle-extensions.mjs
23
+ * 专项拷贝到同级 `templates/`。若仍按 `../templates` 推导会错位一级解析到
24
+ * …/@zhushanwen/templates(不存在)→ 打包版模板发现恒空、select-template
25
+ * 恒 null(scanTemplateDir 的 existsSync 防御不崩溃但静默失效)。
26
+ * 探测先 bundle 形态后源码形态:两形态目录互斥(staged 无 src/;源码布局 src/ 下
27
+ * 无 templates/),existsSync 判别即可定位;均缺失时回退源码形态路径,由
28
+ * scanTemplateDir 的存在性防御兜底(保持既有静默语义,不抛错)。
29
+ */
12
30
  export function getBuiltinTemplateDir(): string {
31
+ const bundleForm = path.resolve(__dirname, "templates");
32
+ if (fs.existsSync(bundleForm)) return bundleForm;
13
33
  return path.resolve(__dirname, "..", "templates");
14
34
  }
15
35
 
36
+ /**
37
+ * 单源目录扫描:纯 readdir 枚举 .md 文件,不读内容。existsSync 通过后 readdir
38
+ * 仍可能失败(权限 / TOCTOU 竞态)——此处属发现/注入增强面,降级为已扫到的
39
+ * 清单(空/部分)+ warn 不向上抛(U3):命令层进入顺序 persist → setActiveTools
40
+ * → buildPlanModePrompt(内含本扫描),throw 会留下「entry 已写、工具已限、
41
+ * 提示词未注入」半进入态;与 --template 直传 readFileSync 的 fail-fast(用户
42
+ * 显式输入,R3)形成对称降级边界——显式输入 fail-fast,增强面降级。
43
+ */
16
44
  function scanTemplateDir(dir: string): TemplateInfo[] {
17
45
  const results: TemplateInfo[] = [];
18
46
  if (!fs.existsSync(dir)) return results;
19
- for (const file of fs.readdirSync(dir)) {
20
- if (file.endsWith(".md")) {
21
- results.push({ name: file.replace(/\.md$/, ""), path: path.join(dir, file) });
47
+ try {
48
+ for (const file of fs.readdirSync(dir)) {
49
+ if (file.endsWith(".md")) {
50
+ results.push({ name: file.replace(/\.md$/, ""), path: path.join(dir, file) });
51
+ }
22
52
  }
53
+ } catch (error) {
54
+ logger.warn("plan: template source scan failed — source degraded to empty, entering plan mode continues", {
55
+ dir,
56
+ error: error instanceof Error ? error.message : String(error),
57
+ });
23
58
  }
24
59
  return results;
25
60
  }
26
61
 
27
- export function listTemplates(): TemplateInfo[] {
28
- return scanTemplateDir(getBuiltinTemplateDir());
62
+ /**
63
+ * 三源目录参数(D2/D4)——测试隔离注入点:默认路径会读运行机真实
64
+ * ~/.agents/plans 与项目根,「恰 N 条」类全集断言必须注入 tmp 目录才确定。
65
+ */
66
+ export interface TemplateSources {
67
+ /** 内置源目录;缺省 getBuiltinTemplateDir()(双形态探测,本函数零改动沿用) */
68
+ builtinDir?: string;
69
+ /** 用户级源目录;缺省 ~/.agents/plans(os.homedir() 内部推导,G2 个人级投放点) */
70
+ userPlansDir?: string;
71
+ /** 项目根;给出则扫描 <projectRoot>/.agents/plans(项目级源锚点 = 调用方 ctx.cwd,G2 项目级投放点) */
72
+ projectRoot?: string;
73
+ }
74
+
75
+ /**
76
+ * 三源合并发现器(D2/D4):内置 → 用户级 → 项目级逐源扫描,同名
77
+ * last-writer-wins(项目 > 用户 > 内置,<location> 指向胜者路径——注入段里
78
+ * 同名只出现一条,模型不会选到被遮蔽版本)。纯 readdir 不读文件内容(D6
79
+ * 砍 description 的连带收益:frontmatter 解析与坏文件防御整面不存在)。
80
+ * 内置源扫描为空时经 pi-extension-logger 落 warn——不是不可达态,是 bundle
81
+ * 布局错位的事故信号(staged 错位曾致打包版模板恒 0,见 getBuiltinTemplateDir
82
+ * 注释);即使外部源有模板遮蔽该信号也照落(内置丢失本身就该被看见)。
83
+ */
84
+ export function listTemplates(sources?: TemplateSources): TemplateInfo[] {
85
+ const builtinDir = sources?.builtinDir ?? getBuiltinTemplateDir();
86
+ const userPlansDir = sources?.userPlansDir ?? path.join(os.homedir(), ".agents", "plans");
87
+ const projectPlansDir = sources?.projectRoot !== undefined
88
+ ? path.join(sources.projectRoot, ".agents", "plans")
89
+ : undefined;
90
+
91
+ const builtin = scanTemplateDir(builtinDir);
92
+ if (builtin.length === 0) {
93
+ logger.warn("plan: builtin template source resolved empty — bundle layout mismatch? templates/ missing next to the extension", { builtinDir });
94
+ }
95
+
96
+ const merged = new Map<string, TemplateInfo>();
97
+ for (const template of builtin) merged.set(template.name, template);
98
+ for (const template of scanTemplateDir(userPlansDir)) merged.set(template.name, template);
99
+ if (projectPlansDir !== undefined) {
100
+ for (const template of scanTemplateDir(projectPlansDir)) merged.set(template.name, template);
101
+ }
102
+ return [...merged.values()];
29
103
  }
30
104
 
31
- export function loadTemplate(name: string): string | null {
32
- const template = listTemplates().find((t) => t.name === name);
105
+ /**
106
+ * 按名解析模板内容(发现视图的读取侧)。sources 透传给 listTemplates——
107
+ * 测试与后续合并视图消费方(select-template)注入目录用;缺省走默认路径。
108
+ */
109
+ export function loadTemplate(name: string, sources?: TemplateSources): string | null {
110
+ const template = listTemplates(sources).find((t) => t.name === name);
33
111
  if (!template) return null;
34
112
 
35
113
  try {
@@ -38,3 +116,43 @@ export function loadTemplate(name: string): string | null {
38
116
  return null;
39
117
  }
40
118
  }
119
+
120
+ // ── <available-plans> 段拼装(D6) ─────────────────────────────────
121
+
122
+ /** XML 特殊字符转义:注入段进 LLM 上下文,字段含 < > & 等会破坏 XML 结构(自写,不引 subagent-core——plan 是轻量 universal 包) */
123
+ function escapeXml(str: string): string {
124
+ return str
125
+ .replace(/&/g, "&amp;")
126
+ .replace(/</g, "&lt;")
127
+ .replace(/>/g, "&gt;")
128
+ .replace(/"/g, "&quot;")
129
+ .replace(/'/g, "&apos;");
130
+ }
131
+
132
+ /** 码点序比较(禁 localeCompare——宿主 locale 差异会破坏跨环境字节一致) */
133
+ function compareByCodepoint(a: string, b: string): number {
134
+ return a < b ? -1 : a > b ? 1 : 0;
135
+ }
136
+
137
+ /** 段内 guide 行(选型主体 = 模型自选,D8:名字自解释,一次选中;用户可对话覆盖) */
138
+ const AVAILABLE_PLANS_GUIDE =
139
+ "Pick a template from this list, then call plan(action='select-template', templateName='<name>').";
140
+
141
+ /**
142
+ * <available-plans> 段拼装纯函数(D6):条目 <plan><name/><location/></plan>
143
+ * 两字段必有(不含 description——name 是 select-template 唯一键,location 是
144
+ * 绝对路径);全字段 escapeXml;name 码点序排序(与 readdir 枚举序解耦);
145
+ * 空清单返回空串——调用方据此不注入段(防御分支,空发现另有 warn 信号)。
146
+ */
147
+ export function formatAvailablePlans(templates: TemplateInfo[]): string {
148
+ if (templates.length === 0) return "";
149
+ const items = [...templates]
150
+ .sort((a, b) => compareByCodepoint(a.name, b.name))
151
+ .map((t) => ` <plan><name>${escapeXml(t.name)}</name><location>${escapeXml(t.path)}</location></plan>`);
152
+ return [
153
+ "<available-plans>",
154
+ AVAILABLE_PLANS_GUIDE,
155
+ ...items,
156
+ "</available-plans>",
157
+ ].join("\n");
158
+ }