@zhushanwen/pi-plan 0.5.1 → 1.0.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/state.ts CHANGED
@@ -1,29 +1,32 @@
1
1
  import { readdirSync, rmdirSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
 
4
- import type { PlanDocMeta } from "@zhushanwen/extension-protocol";
4
+ import type {
5
+ PlanDocMeta,
6
+ PlanLifecycleEvent,
7
+ PlanLifecycleState,
8
+ PlanTransitionResult,
9
+ } from "@zhushanwen/extension-protocol";
10
+ import { PLAN_STATE_CUSTOM_TYPE, readLifecycleState, readResumeHint, transition, truncateSelfReview } from "@zhushanwen/extension-protocol";
5
11
  import type { CustomEntry, ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
6
12
  import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
7
13
  import { getLogger } from "@zhushanwen/pi-extension-logger";
14
+ import { filterActivePath } from "@zhushanwen/pi-session-path";
8
15
 
9
16
  const logger = getLogger("pi-plan");
10
17
 
11
18
  /**
12
- * 审阅态两值(D1)——awaiting = 文档就绪等审批;revising = 修订中;
13
- * 无值 = 进行中。approve 不设中间态:直接走 complete → resetPlanState 落
14
- * isActive=false,PlanModeBar 消失由 isActive 驱动。
19
+ * 降级态等待原因(D2 resumeHint,取代旧 reviewStateSource 语义):'resubmit' =
20
+ * 会话重启(E3)后 agent 尚未重新提交审批。写入点 = index.ts E3 steer 重挂处;
21
+ * 清除 = clearRoundFields 单函数出口(D4 收敛,三处轮次边界调用:resetPlanState /
22
+ * activatePlanMode / submit-review 重挂起点)——不变量:resumeHint 只描述当前降级
23
+ * 等待的原因,不跨轮残留(缺清除 = 跨 plan run 残留,C-U2 同型缺陷)。字面量与
24
+ * shared PlanStateView.resumeHint 严格一致。
15
25
  */
16
- export type PlanReviewState = "awaiting" | "revising";
26
+ export type PlanResumeHint = "resubmit";
17
27
 
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";
28
+ /** 终态两值(D1):completed = 批准并派发执行;exited = 主动退出。共享全部终态规则。 */
29
+ export type PlanTerminalState = Extract<PlanLifecycleState, "completed" | "exited">;
27
30
 
28
31
  export interface PlanState {
29
32
  isActive: boolean;
@@ -31,19 +34,38 @@ export interface PlanState {
31
34
  requirement: string;
32
35
  templateName: string;
33
36
  /**
34
- * --template 直传标记(D7 select-template 防御的判定信号):直传进入时 =
35
- * 展开后模板文件绝对路径;模板流程进入缺失。templateName 字段两流程共用
36
- * (直传存 basename / 选中存模板名),单看它无法区分「已直传」与「已选中」,
37
- * 故独立持久化直传事实——重启经 entry 恢复,reset 时随退出失效。
37
+ * --template 直传标记(D7 select-template 防御的判定信号;templateName/templateProvidedPath
38
+ * 双字段合并后的独立判定字段——templateName 值域不变,直传事实不再以路径字段持久化):
39
+ * true = 直传进入;缺失 = 模板流程(进入与选中共用缺失态,防御只拦直传格)。
40
+ * 重启经 entry 恢复,reset 时随退出失效。
41
+ *
42
+ * 双向版本错配登记(dev-link 窗口可达,生产被 Q-4 原子发版覆盖):
43
+ * ① 新扩展读旧 entry(双字段形态):读侧把 templateProvidedPath 存在映射回本字段
44
+ * (readTemplateProvided),直传防御行为等价;
45
+ * ② 旧扩展读新 entry(单字段形态):旧读侧对缺失按模板流程降级,直传防御静默少拦
46
+ * (不误拦)——恢复动作 = dev-link 重新对齐版本后重试;生产出现该错配 = 原子发版
47
+ * 被破坏的反证,重审本合并(候选替代 = 恢复 templateProvidedPath 双字段形态)。
38
48
  */
39
- templateProvidedPath?: string;
49
+ templateProvided?: boolean;
40
50
  /** 挂载技能名清单(--skills 解析产物;模板流程为空数组——挂载声明,reset 时随退出失效) */
41
51
  skills: string[];
42
52
  /** 产物文档清单(register-doc 登记;reset 时保留——产物 tab 与 isActive 解耦,跨重开留存) */
43
53
  docs: PlanDocMeta[];
44
- reviewState?: PlanReviewState;
45
- /** 降级态来源标记(见 PlanReviewStateSource);仅 reviewState 有值时有语义,无值 = 来源未知 */
46
- reviewStateSource?: PlanReviewStateSource;
54
+ /**
55
+ * 生命周期状态(D1 八值,D2 取代式演进):每次转移落盘,取代 reviewState/reviewStateSource
56
+ * 的隐含编码(两旧键停写,仅旧 entry 映射读——见 applyPlanStateEntry)。生命周期写唯一
57
+ * 通道 = applyPlanEvent(transition 纯函数);登记例外 = resetPlanState 的终态直写
58
+ * (归口落点,非第二转移通道——因果序与幂等性质见其函数注释)。
59
+ */
60
+ state: PlanLifecycleState;
61
+ /**
62
+ * 上次 submit-review 的 selfReview(D9①④):单字段双角色——E3 重挂回传源 + 防照抄
63
+ * 比较基线(同值同写点无分歧路径)。写入点 = submit-review 转移落盘(写侧 4KB 截断);
64
+ * 清除 = clearRoundFields(D4 单函数出口)。
65
+ */
66
+ selfReview?: string;
67
+ /** 降级态等待原因(见 PlanResumeHint);清除 = clearRoundFields(D4 单函数出口) */
68
+ resumeHint?: PlanResumeHint;
47
69
  /**
48
70
  * 上次 submit-review 时的 docs 快照指纹(planDocsFingerprint 产物)。缺失 = 无既往
49
71
  * 提交(首次提交 / reset 后 / 旧版 entry 重挂),重提交无变化检测不警告;reset 随
@@ -59,18 +81,26 @@ export const DEFAULT_PLAN_STATE: PlanState = {
59
81
  templateName: "",
60
82
  skills: [],
61
83
  docs: [],
84
+ state: "idle",
62
85
  };
63
86
 
64
87
  /**
65
88
  * 计划态工具白名单(进入计划模式三处共用——slash 命令 / plan(enter) tool / session_start
66
89
  * 恢复;bash 在白名单内,文件写约束来自注入的计划模式提示词,见 pi-ext-021)。
67
90
  * 放在 state.ts(叶模块)而非 tool.ts:enter.ts 与本常量互需会造成 enter↔tool 循环依赖。
91
+ *
92
+ * ask_user(D10/F9):提示词 Phase B 本就指示「Use ask_user tool if available」,白名单
93
+ * 曾把它排除(结构性禁言)。feature-tier 依赖降级:ask-user 是可禁扩展,pi setActiveTools
94
+ * 对不存在的工具名静默跳过——pi 实装锚点:dist/core/agent-session.js:655(0.84.4,
95
+ * setActiveToolsByName docstring "Only tools in the registry can be enabled. Unknown
96
+ * tool names are ignored.")——被禁时不报错不生效,提示词 "if available"
97
+ * 条件语义即降级(回退对话流提问)。
68
98
  */
69
- export const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "plan"];
99
+ export const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "plan", "ask_user"];
70
100
 
71
101
  /**
72
102
  * plan 包注入消息的 customType(pi.sendMessage custom message 注入的 8 处调用
73
- * 统一使用)。命名对齐本包 entry customType 字面量 'plan-state' 的连字符风格
103
+ * 统一使用)。命名对齐本包 entry customType 'plan-state' 的连字符风格
74
104
  * (设计 §2.1 双命名范式决策:各包跟随所在包 entry 惯例,跨包不统一)。
75
105
  * 放 state.ts(叶模块)理由同 PLAN_MODE_TOOLS。
76
106
  */
@@ -79,6 +109,146 @@ export const PLAN_CONTEXT_CUSTOM_TYPE = "plan-context";
79
109
  /** Per-session state cache. Keyed by sessionId. */
80
110
  export type PlanSessionMap = Map<string, PlanState>;
81
111
 
112
+ /**
113
+ * D9 宿主分流信号:taiji runtime 对托管 pi 恒注入 TAIJI_AGENT_EXT_LOG=1;
114
+ * 独立 pi 无此信号 → submit-review 走 E8 文本软门(不发 marker select——
115
+ * pi TUI 原生渲染 \x00 控制符 title + JSON options 成乱码对话)。
116
+ * 不用 TAIJI_RUNTIME_TOKEN:它在 SPAWN_ENV 出站 deny list 被强制剥除
117
+ * (C-proc-09),pi 子进程 env 里恒不可见,照抄即分流静默失效且诱导实施者
118
+ * 动 deny list 造成安全回归。每次调用时读(不可模块加载时缓存——测试与
119
+ * 运行中 env 都可能变化)。
120
+ *
121
+ * [双语义耦合登记,2026-09-20 R1] 本 env 名义语义是扩展日志开关
122
+ * (extension-logger 见之落盘 INFO,恒注入点 packages/pi-rpc/src/env.ts),本函数
123
+ * 是第二消费方(宿主分流,submit-review / complete / 引导门三处)——若日志开关
124
+ * 走向可配置(值不再是恒 '1'),三处分流同帧静默失效,届时必须拆专用宿主信号
125
+ * env 并纳入恒注入,不得沿用本名。
126
+ *
127
+ * export(F-W3-1):index.ts E3 reviewing 恢复分支复用同一宿主信号做 GUI 分流
128
+ * (taiji GUI 宿主有 degraded 恢复按钮,不自动重挂审批;独立 pi 无按钮保留 steer)——
129
+ * 第四消费方,同帧失效约束随登记面扩展。
130
+ *
131
+ * 放本叶模块(D-B1-7 随 isTaijiGuiHost 收敛迁入,原 tool.ts):宿主判定信号与
132
+ * per-session 注册表/挂起原语同域承载,不经 tool.js 大模块中转。
133
+ */
134
+ export function isTaijiHost(): boolean {
135
+ return process.env.TAIJI_AGENT_EXT_LOG === "1";
136
+ }
137
+
138
+ /**
139
+ * taiji GUI 宿主判定(D-B1-7,B5 过渡步——批次 4 askInteractively 原语再收交互分流):
140
+ * 三处 `isTaijiHost() && ctx.mode === "rpc"` 复写(submit-review marker select /
141
+ * complete 执行方式 form / E3 恢复分流)收敛为本谓词单点。mode 收紧 rpc 的理由
142
+ * 同 isTaijiHost 双语义耦合登记:env 信号只证明 taiji runtime 在上游,宿主没有
143
+ * marker 路由的形态(TUI / json / print)不发 select。
144
+ */
145
+ export function isTaijiGuiHost(ctx: ExtensionContext): boolean {
146
+ return isTaijiHost() && ctx.mode === "rpc";
147
+ }
148
+
149
+ /**
150
+ * 挂起 select 的解散来源直传原语(D-B1-2,取代旧世代计数推断):判别所需信息
151
+ * 「是不是经 exitPlanMode 入口解散」产生于入口调用点,随挂起闭包直达等待处,
152
+ * 消费侧不再按世代计数推断。
153
+ *
154
+ * 纪律:① dissolvedBy 由 markDissolved **一次赋值**(exitPlanMode 入口打 'self';
155
+ * 'external' 留给不经入口的外部解散显式打标,现无打标点——等待处按「非 'self'
156
+ * 即外部」分派);② 闭包身份天然携带轮次记忆——每个挂起 select 持有独立来源变量,
157
+ * 跨轮迟到解散按各自闭包判别,无跨轮残留面;③ 禁入 PlanState/entry(进程内存态,
158
+ * 随 controllers 注册表同生命周期)。
159
+ */
160
+ export interface PendingSelect {
161
+ controller: AbortController;
162
+ /** 解散来源:undefined = 尚未解散(或外部级联未打标——turn abort 级联只 abort 不打标) */
163
+ readonly dissolvedBy: "self" | "external" | undefined;
164
+ /** 一次赋值:解散来源置位(重复调用以末次为准,正常路径仅入口打标一次) */
165
+ markDissolved(source: "self" | "external"): void;
166
+ }
167
+
168
+ /**
169
+ * 挂起 select 的 per-session 注册表(E10;值 = PendingSelect 含解散来源槽位,
170
+ * 不新建注册表)。生命周期钉死:每次发挂起 select 前 fresh 一个(见 freshPendingSelect),
171
+ * select settled 即弃;session_start / session_shutdown / exitPlanMode 联动处清理。
172
+ */
173
+ export type PlanAbortControllers = Map<string, PendingSelect>;
174
+
175
+ /**
176
+ * 发挂起 select 前新建 PendingSelect 并登记。禁复用已 abort 的 controller——
177
+ * pi 实装对已 abort 的 signal 在 createDialogPromise 首行短路立即 resolve undefined,
178
+ * 复用会让退出后再入 plan 的 submit-review 瞬时静默取消。
179
+ * pi 实装锚点:dist/modes/rpc/rpc-mode.js:48(0.84.4)——createDialogPromise 首行
180
+ * `opts?.signal?.aborted` 即 `return Promise.resolve(defaultValue)`,select 的
181
+ * defaultValue = undefined(E10 生命周期设计依据)。
182
+ */
183
+ export function freshPendingSelect(
184
+ controllers: PlanAbortControllers,
185
+ sessionId: string,
186
+ ): PendingSelect {
187
+ // dissolvedBy 走闭包变量(getter 透出):markDissolved 即便被解构调用也指向同一存储
188
+ let dissolvedBy: "self" | "external" | undefined;
189
+ const pending: PendingSelect = {
190
+ controller: new AbortController(),
191
+ get dissolvedBy() {
192
+ return dissolvedBy;
193
+ },
194
+ markDissolved(source) {
195
+ dissolvedBy = source;
196
+ },
197
+ };
198
+ controllers.set(sessionId, pending);
199
+ return pending;
200
+ }
201
+
202
+ /**
203
+ * 单 ctx 对象(D-B4-3,批次 4 注册表签名收敛):既有 per-session Map 注册表从散参签名
204
+ * (sessions + controllers 两表逐层直传)收敛为本载体——states(per-session 生命周期态
205
+ * 缓存)+ controllers(挂起 select 注册表,值 = PendingSelect 含解散来源槽位)两表收敛,
206
+ * 「解散挂起 select」做成其方法(dissolveAll——exitPlanMode 入口打 'self';'external'
207
+ * 留给不经入口的外部解散显式打标)。widget.ts 无 per-session 存量(直调 ctx.ui.*),
208
+ * 不在收敛面——GUI 侧呈现态是 renderer 派生投影。
209
+ */
210
+ export interface PlanCtx {
211
+ /** per-session 生命周期态缓存(收敛前的 sessions 表) */
212
+ states: PlanSessionMap;
213
+ /** 挂起 select 注册表(E10,值 = PendingSelect 含解散来源槽位) */
214
+ controllers: PlanAbortControllers;
215
+ /**
216
+ * 解散全部挂起 select:markDissolved 一次打标 + controller.abort(E10 因果链:打标
217
+ * 先行保证等待归口读到来源)+ 注册表清空(不留已 dissolved 的条目)。单活跃 session
218
+ * 不变量下(pi 进程同时只有一个活跃 session;session_start/shutdown 联动清理)与
219
+ * 「按 sessionId 解散单条」等价,全表遍历 + clear 是方法语义的自然形态。
220
+ */
221
+ dissolveAll(source: "self" | "external"): void;
222
+ }
223
+
224
+ /** 组合根入口(index.ts):创建空注册表并装配解散方法 */
225
+ export function createPlanCtx(): PlanCtx {
226
+ const planCtx: PlanCtx = {
227
+ states: new Map(),
228
+ controllers: new Map(),
229
+ dissolveAll(source) {
230
+ for (const pending of planCtx.controllers.values()) {
231
+ pending.markDissolved(source);
232
+ pending.controller.abort();
233
+ }
234
+ planCtx.controllers.clear();
235
+ },
236
+ };
237
+ return planCtx;
238
+ }
239
+
240
+ /**
241
+ * 生命周期写唯一通道(D1):六 action 的状态写全走本函数——转移成功才改 state.state,
242
+ * 副作用(persist / 注入 / 工具集)由调用方内联在转移成功后执行;`ok:false` 不落盘、
243
+ * 不执行副作用(终态上 ok:false 不落盘 = 归口点的兜底保险)。登记例外 = resetPlanState
244
+ * 的终态直写(归口落点,见其函数注释)。
245
+ */
246
+ export function applyPlanEvent(state: PlanState, event: PlanLifecycleEvent): PlanTransitionResult {
247
+ const result = transition(state.state, event);
248
+ if (result.ok) state.state = result.next;
249
+ return result;
250
+ }
251
+
82
252
  /**
83
253
  * requirement 长度封顶(64KB):session.planState 帧在 runtime 出站守卫(outbound-frame-registry)
84
254
  * 按「requirement 有界」归入不登记 LARGE_FIELD_REGISTRY 的标量/小列表类——超 32MB 的未登记帧
@@ -106,30 +276,6 @@ export function planDocsFingerprint(docs: PlanDocMeta[]): string {
106
276
  return docs.map((d) => `${d.fileName}:${d.version}`).join("|");
107
277
  }
108
278
 
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
-
133
279
  /**
134
280
  * Get plan state for a session. Returns cached state if available,
135
281
  * otherwise reconstructs from sessionManager and caches it.
@@ -148,31 +294,65 @@ export function getPlanState(
148
294
  }
149
295
 
150
296
  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 向后兼容)。
155
- pi.appendEntry("plan-state", {
297
+ // customType 判别键 = canonical 常量 PLAN_STATE_CUSTOM_TYPE(extension-protocol 导出,
298
+ // 值冻结——改即历史会话全部失联,冻结锚测试见 legacy-entries.test.ts);runtime 投影链
299
+ // (plan-state-extractor / session-records)消费同一常量。
300
+ // optional 字段(templateProvided / selfReview / resumeHint / lastSubmitReviewDocsFingerprint)
301
+ // 为 undefined 时 JSON 序列化自然消失,旧 entry 消费方对该字段惰性(D4 向后兼容)。
302
+ // D2 取代式演进:reviewState / reviewStateSource 停写——新写只落 state / resumeHint /
303
+ // selfReview;读侧对旧 entry 的映射见 applyPlanStateEntry(D2 读方①)。
304
+ pi.appendEntry(PLAN_STATE_CUSTOM_TYPE, {
156
305
  isActive: state.isActive,
157
306
  planFilePath: state.planFilePath,
158
307
  requirement: state.requirement,
159
308
  templateName: state.templateName,
160
- templateProvidedPath: state.templateProvidedPath,
309
+ templateProvided: state.templateProvided,
161
310
  skills: state.skills,
162
311
  docs: state.docs,
163
- reviewState: state.reviewState,
164
- reviewStateSource: state.reviewStateSource,
312
+ state: state.state,
313
+ selfReview: state.selfReview,
314
+ resumeHint: state.resumeHint,
165
315
  lastSubmitReviewDocsFingerprint: state.lastSubmitReviewDocsFingerprint,
166
316
  });
167
317
  }
168
318
 
169
319
  /**
170
- * Reset plan state to idle, persist, and clean up session cache.
320
+ * per-round 字段清理单函数(D4 clearRoundFields):selfReview / resumeHint /
321
+ * lastSubmitReviewDocsFingerprint 三个「只描述当前 plan 轮次」的字段随轮次边界
322
+ * (退出 reset / 新轮进入 / submit-review 重挂起点)统一在此清除——三处清除点
323
+ * 此前各自内联 delete,字段集的「同生命周期」约束靠三处注释互指维持;收敛后
324
+ * 不变量单点表达:新增 per-round 字段只改本函数(grep `delete state.resumeHint`
325
+ * 旧触点 = 本函数单出口)。
171
326
  *
172
- * 终态矩阵(D5/E10):isActive=false + reviewState 清空 + skills 清空(挂载声明失效)+
327
+ * executeSubmitReview 调用点随后置位 selfReview / 指纹(重挂起点写新值)——
328
+ * delete→set 与原「仅 delete resumeHint」的属性终态一致,行为等价。
329
+ */
330
+ export function clearRoundFields(state: PlanState): void {
331
+ // selfReview:E3 回传源 + 防照抄比较基线只在本轮内有效,跨轮残留会误触新鲜度门
332
+ delete state.selfReview;
333
+ // resumeHint 只描述当前降级等待的原因,不跨轮残留(缺清除 = 跨 plan run 残留,
334
+ // C-U2 同型缺陷:渲染上一轮「会话已重启」降级文案)
335
+ delete state.resumeHint;
336
+ // 指纹快照随轮次失效:新 plan 轮次从「无既往提交」重新计数,首次 submit-review
337
+ // 不触发无变化警告(docs 虽保留供回看,但不作为检测基线)
338
+ delete state.lastSubmitReviewDocsFingerprint;
339
+ }
340
+
341
+ /**
342
+ * Reset plan state to its inactive resting form, persist, and clean up session cache.
343
+ *
344
+ * 终态矩阵(D5/E10/D3 连带段):isActive=false + state=terminal(默认 'exited',complete
345
+ * 终局传 'completed')+ selfReview/resumeHint/指纹清空 + skills 清空(挂载声明失效)+
173
346
  * docs 保留——产物 tab 由 docs.length 驱动、与 isActive 解耦,执行期(approve 后)与
174
347
  * 退出后(abort 后)都可回看产物文档;reset entry 持久,重开 session 后冷启动首拉仍恢复
175
348
  * docs 显示,至下次 /plan 同 slug 覆写。
349
+ *
350
+ * 终态直写例外登记(applyPlanEvent 之外唯一 state.state 写点):因果序 = 正常路径调用点
351
+ * 先行经 applyPlanEvent 完成转移(exitPlanMode 的 'exit' / executeComplete 的
352
+ * 'exec_chosen'),本函数随后幂等重写同值(落 isActive=false 清洗 entry,state 值不变);
353
+ * 坏数据清洗腿(isActive=true 的终态/idle 残留,exitPlanMode ok:false 例外)无先行转移
354
+ * 可依赖,终态落点由本直写承载。非第二转移通道——不经本函数产生任何 FSM 边,19 边表
355
+ * 语义不受影响。
176
356
  */
177
357
  /** 空 slug 目录清理(状态审查 P3-10):enter 即 mkdir,未产任何文档即退出会留空目录残盘。
178
358
  * 仅删「真正为空」的 slug 目录——目录里有任何残留文件(含未登记杂文件)一律保留(保守,
@@ -193,6 +373,7 @@ export function resetPlanState(
193
373
  sessions: PlanSessionMap,
194
374
  sessionId: string,
195
375
  ctx: ExtensionContext,
376
+ terminal: PlanTerminalState = "exited",
196
377
  ): PlanState {
197
378
  const state = getPlanState(sessions, sessionId, ctx);
198
379
  state.isActive = false;
@@ -201,31 +382,40 @@ export function resetPlanState(
201
382
  state.planFilePath = "";
202
383
  state.requirement = "";
203
384
  state.templateName = "";
204
- delete state.templateProvidedPath;
385
+ delete state.templateProvided;
205
386
  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;
387
+ // 终态参数(D3 连带段):默认 'exited',complete 终局传 'completed'——防 reset 覆写
388
+ // completed(终态两值仅留诊断/审计区分,共享全部终态规则)
389
+ state.state = terminal;
390
+ // per-round 字段随退出失效(D4 单函数出口,字段语义见 clearRoundFields)
391
+ clearRoundFields(state);
213
392
  persistPlanState(pi, state);
214
393
  sessions.delete(sessionId);
215
394
  return state;
216
395
  }
217
396
 
218
- function isPlanStateEntry(entry: SessionEntry): entry is CustomEntry<Partial<PlanState>> & { customType: "plan-state" } {
397
+ function isPlanStateEntry(entry: SessionEntry): entry is CustomEntry<LegacyPlanEntryData> & { customType: typeof PLAN_STATE_CUSTOM_TYPE } {
219
398
  // 判别式收窄(type === "custom")后可直接访问 customType/data,无需 cast。
220
399
  // 「字段存在即合法」:新字段全部 optional,旧四字段 entry 同样合法(D4 向后兼容)。
221
400
  return (
222
401
  entry.type === "custom" &&
223
- entry.customType === "plan-state" &&
402
+ entry.customType === PLAN_STATE_CUSTOM_TYPE &&
224
403
  typeof entry.data === "object" &&
225
404
  entry.data !== null
226
405
  );
227
406
  }
228
407
 
408
+ /**
409
+ * 旧 schema entry data(D2 读方①映射源):reviewState/reviewStateSource 是已停写的历史字段
410
+ * (取代式演进——新写只落 state/resumeHint/selfReview),只在重建读侧映射消费;
411
+ * templateProvidedPath 是双字段合并前已停写的直传路径字段(读侧映射 readTemplateProvided)。
412
+ */
413
+ type LegacyPlanEntryData = Partial<PlanState> & {
414
+ reviewState?: unknown;
415
+ reviewStateSource?: unknown;
416
+ templateProvidedPath?: unknown;
417
+ };
418
+
229
419
  /** skills 白名单式读取:数组 + 逐项 string 守卫,垃圾项丢弃(垃圾数据不进内存态) */
230
420
  function readSkills(data: Partial<PlanState>): string[] {
231
421
  if (!Array.isArray(data.skills)) return [];
@@ -245,18 +435,19 @@ function readDocs(data: Partial<PlanState>): PlanDocMeta[] {
245
435
  return data.docs.filter(isPlanDocMeta);
246
436
  }
247
437
 
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
- }
438
+ /**
439
+ * 生命周期状态与 resumeHint 读取(D2 读方①):readLifecycleState / readResumeHint 直引
440
+ * extension-protocol legacy-entries(D-B4-1 映射单源下沉——runtime 读方②同引一份,原本地
441
+ * 拷贝删除;映射契约断言面 = protocol 包内 legacy-entries.test.ts)。
442
+ */
254
443
 
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;
444
+ /**
445
+ * selfReview 白名单式读取 + 有界防御:非 string(含缺失)归无值;读侧同样 4KB 截断
446
+ * (对齐 readRequirement 读侧防御先例——保证派生/回传恒有界,封顶前旧 entry 超长文本
447
+ * 在重建时同样封顶)。
448
+ */
449
+ function readSelfReview(data: LegacyPlanEntryData): string | undefined {
450
+ return typeof data.selfReview === "string" ? truncateSelfReview(data.selfReview) : undefined;
260
451
  }
261
452
 
262
453
  /** requirement 白名单式读取 + 长度封顶:非 string(含缺失)归空串;封顶前旧版 entry 的
@@ -272,16 +463,21 @@ function readDocsFingerprint(data: Partial<PlanState>): string | undefined {
272
463
  : undefined;
273
464
  }
274
465
 
275
- /** 直传标记白名单式读取:非 string(含缺失)按模板流程处理(D4 字段级降级) */
276
- function readTemplateProvidedPath(data: Partial<PlanState>): string | undefined {
277
- return typeof data.templateProvidedPath === "string" ? data.templateProvidedPath : undefined;
466
+ /**
467
+ * 直传标记读取(新旧 entry 双形态):新字段直读(严格 === true,垃圾值按无值);
468
+ * 旧 entry 的 templateProvidedPath(合并前已停写路径字段,保留边界「entry 级旧字段映射」)
469
+ * 存在即直传——直传防御行为等价,双向版本错配登记见 PlanState.templateProvided 注释。
470
+ */
471
+ function readTemplateProvided(data: LegacyPlanEntryData): boolean | undefined {
472
+ if (data.templateProvided === true) return true;
473
+ return typeof data.templateProvidedPath === "string" ? true : undefined;
278
474
  }
279
475
 
280
476
  /**
281
477
  * 单条 plan-state entry 数据的逐字段应用(调用方已过 isPlanStateEntry 门,data 为
282
478
  * 非空对象;`?? {}` 仅为 data?: T 的类型 shim)。
283
479
  */
284
- function applyPlanStateEntry(state: PlanState, data: Partial<PlanState> | undefined): void {
480
+ function applyPlanStateEntry(state: PlanState, data: LegacyPlanEntryData | undefined): void {
285
481
  // 逐字段 ?? 白名单式读取:旧版 entry 残留的 phase 字段被自然忽略(D6 兼容读);
286
482
  // 新字段缺失(旧 entry)归一为空清单/无值(D4 字段级降级)
287
483
  const entryData = data ?? {};
@@ -289,17 +485,24 @@ function applyPlanStateEntry(state: PlanState, data: Partial<PlanState> | undefi
289
485
  state.planFilePath = entryData.planFilePath ?? "";
290
486
  state.requirement = readRequirement(entryData);
291
487
  state.templateName = entryData.templateName ?? "";
292
- state.templateProvidedPath = readTemplateProvidedPath(entryData);
488
+ state.templateProvided = readTemplateProvided(entryData);
293
489
  state.skills = readSkills(entryData);
294
490
  state.docs = readDocs(entryData);
295
- state.reviewState = readReviewState(entryData);
296
- state.reviewStateSource = readReviewStateSource(entryData);
491
+ // 生命周期状态:新字段直读 + 旧 reviewState 映射(isActive 兜底,D2 读方①)
492
+ state.state = readLifecycleState(entryData, state.isActive);
493
+ state.selfReview = readSelfReview(entryData);
494
+ state.resumeHint = readResumeHint(entryData);
297
495
  state.lastSubmitReviewDocsFingerprint = readDocsFingerprint(entryData);
298
496
  }
299
497
 
498
+ // 活跃路径裁剪(leafId 沿 parentId 回溯,撤回后被撤子树的 plan-state entry 不进
499
+ // 重建输入)收敛于 @zhushanwen/pi-session-path 单一实现(四包同构副本收编,防御
500
+ // 语义与回退口径见该包 filterActivePath 注释;runtime 侧 plan-state 提取器保持
501
+ // 独立——extension 不能 import runtime 包)。
502
+
300
503
  export function reconstructPlanState(ctx: ExtensionContext): PlanState {
301
504
  const state = { ...DEFAULT_PLAN_STATE };
302
- const entries = ctx.sessionManager.getEntries();
505
+ const entries = filterActivePath(ctx.sessionManager);
303
506
 
304
507
  for (let i = entries.length - 1; i >= 0; i--) {
305
508
  // entries[i] 是复杂表达式(TS 不收窄),守卫移到 const 变量上
package/src/templates.ts CHANGED
@@ -102,21 +102,6 @@ export function listTemplates(sources?: TemplateSources): TemplateInfo[] {
102
102
  return [...merged.values()];
103
103
  }
104
104
 
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);
111
- if (!template) return null;
112
-
113
- try {
114
- return fs.readFileSync(template.path, "utf-8");
115
- } catch {
116
- return null;
117
- }
118
- }
119
-
120
105
  // ── <available-plans> 段拼装(D6) ─────────────────────────────────
121
106
 
122
107
  /** XML 特殊字符转义:注入段进 LLM 上下文,字段含 < > & 等会破坏 XML 结构(自写,不引 subagent-core——plan 是轻量 universal 包) */