@zhushanwen/pi-plan 0.5.2 → 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/tool.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs";
1
2
  import * as path from "node:path";
2
3
 
3
4
  import { StringEnum } from "@earendil-works/pi-ai";
@@ -5,29 +6,42 @@ import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@earendi
5
6
  import { Text } from "@earendil-works/pi-tui";
6
7
  import {
7
8
  firstContentText,
9
+ parsePlanReviewResponse,
8
10
  PLAN_REVIEW_MARKER,
11
+ truncateSelfReview,
9
12
  uiFormInteract,
10
13
  } from "@zhushanwen/extension-protocol";
11
14
  import type {
12
15
  ChoiceQuestion,
13
16
  PlanDocMeta,
14
17
  PlanReviewRequest,
15
- PlanReviewResponse,
16
18
  } from "@zhushanwen/extension-protocol";
19
+ // 执行方式检测单源自 ADR-0074 共享包(plan-mode-audit-remediation 批次 4②b 迁入)
20
+ import { detectExecSkills } from "@zhushanwen/pi-exec-skills";
21
+ import type { ExecSkill } from "@zhushanwen/pi-exec-skills";
17
22
  import { getLogger } from "@zhushanwen/pi-extension-logger";
18
23
  import { Type } from "typebox";
19
24
 
20
- import { GOAL_FAILURE_RECOVERY, handlePlanComplete } from "./compact.js";
21
- import type { GoalBridgeOutcome } from "./compact.js";
25
+ import { GOAL_FAILURE_RECOVERY, handlePlanComplete } from "./execution-notice.js";
26
+ import type { GoalBridgeOutcome } from "./execution-notice.js";
22
27
  import { activatePlanMode, resolveSkills } from "./enter.js";
23
- import { detectExecSkills } from "./exec-skills.js";
24
- import type { ExecSkill } from "./exec-skills.js";
25
28
  import { t } from "./i18n.js";
26
29
  import { formatReviewComments } from "./prompts.js";
27
30
  import type { SkillRef } from "./prompts.js";
28
- import type { PlanAbortControllers, PlanSessionMap, PlanState } from "./state.js";
29
- import { PLAN_CONTEXT_CUSTOM_TYPE, freshAbortController, getPlanState, planDocsFingerprint, persistPlanState, resetPlanState } from "./state.js";
30
- import { listTemplates, loadTemplate } from "./templates.js";
31
+ import { MAX_SKILL_OPTIONS } from "./prompts.js";
32
+ import type { PendingSelect, PlanCtx, PlanState, PlanTerminalState } from "./state.js";
33
+ import {
34
+ applyPlanEvent,
35
+ clearRoundFields,
36
+ isTaijiGuiHost,
37
+ PLAN_CONTEXT_CUSTOM_TYPE,
38
+ freshPendingSelect,
39
+ getPlanState,
40
+ planDocsFingerprint,
41
+ persistPlanState,
42
+ resetPlanState,
43
+ } from "./state.js";
44
+ import { listTemplates } from "./templates.js";
31
45
  import { updatePlanWidget } from "./widget.js";
32
46
 
33
47
  const logger = getLogger("pi-plan");
@@ -68,15 +82,29 @@ interface EnterDetails {
68
82
  interface CompleteDetails {
69
83
  action: "complete";
70
84
  planFilePath: string;
71
- isolation: string;
72
85
  execMode: string;
73
- /** D2:direct 档 goalInit 的同步结果;compact 档在 onComplete 回调内执行,不进 result */
86
+ /**
87
+ * 执行方式的选定来源(D7②):'headless' = 无 UI 默认 execute;'no-exec-skills' =
88
+ * 无 plan-exec 技能直通(不挂执行方式表单);'dialog' = 表单/选择器选定。
89
+ */
90
+ execModeSource?: "headless" | "no-exec-skills" | "dialog";
91
+ /** D2:execute 档 goalInit 的同步结果;skill 档无 goalInit,不进 result */
74
92
  goalOutcome?: GoalBridgeOutcome;
75
93
  }
76
94
 
77
95
  interface CompleteCancelledDetails {
78
96
  action: "complete-cancelled";
97
+ /** 原始构造点原因(choice 空/cancel/timeout 折叠 'cancelled';channel-error / non-json 等透传) */
79
98
  reason: string;
99
+ /** 解散源(D-B1-3 via 侧迁移:与审批 select 的 dissolvedBy 统一为同一来源直传原语值域)——'self' = 入口解散(exitPlanMode 已介入,归口 no-op);'external' = 外部解散(review_aborted 已落盘) */
100
+ source: "self" | "external";
101
+ }
102
+
103
+ /** later 档结果(D3 连带段):dispatching --later--> approved 已落盘——用户显式「暂不执行」,不是解散 */
104
+ interface CompleteLaterDetails {
105
+ action: "complete-later";
106
+ /** 用户点选的 label(i18n 文案,诊断用) */
107
+ choice: string;
80
108
  }
81
109
 
82
110
  interface AbortDetails {
@@ -94,17 +122,32 @@ interface SubmitReviewDetails {
94
122
  /** gui = taiji 形态挂 PLAN_REVIEW_MARKER select;text = 独立 pi 软门(E8) */
95
123
  channel: "gui" | "text";
96
124
  docsCount: number;
97
- /**
98
- * 重提交无变化信号:false = 本次提交与上次 submit-review 之间无任何 register-doc
99
- * (警告行已追加到 result 文本)。正常提交不携带该字段(缺失 = 无警告)。
100
- */
101
- changed?: false;
102
125
  }
103
126
 
104
- /** 审阅闭环的失败出口(E5/E6/取消)——details 与 content 文本都带恢复动作 */
127
+ /** 审阅闭环的失败出口(E5/E6/取消/门拒收)——details 与 content 文本都带恢复动作 */
105
128
  interface ReviewErrorDetails {
106
129
  action: "review-error";
107
- reason: "no-docs" | "inactive" | "bad-response" | "cancelled";
130
+ reason:
131
+ | "no-docs"
132
+ | "inactive"
133
+ /** malformed(E5 垃圾数据)——引导重挂 */
134
+ | "bad-response"
135
+ /** 自审硬门拒收(D9①):selfReview 缺失/空 */
136
+ | "no-self-review"
137
+ /** 防照抄启发式拒收(D9①):文档已变而 selfReview 与上次逐字节相同 */
138
+ | "stale-self-review"
139
+ /** FSM 合法性兜底(D1 边表):事件与现态不构成合法边(如未经审批闸口就 complete) */
140
+ | "out-of-order"
141
+ /** 归口①命令解散:reset 已介入、终态已落盘,归口 no-op */
142
+ | "cancelled"
143
+ /** 归口①外部解散:reviewing --review_aborted--> planning 已落盘 */
144
+ | "review-interrupted";
145
+ }
146
+
147
+ /** dismiss 搁置结果(D3):reviewing --dismiss--> planning 已落盘,plan 模式保持、进度不变 */
148
+ interface ReviewDismissedDetails {
149
+ action: "review-dismissed";
150
+ docsCount: number;
108
151
  }
109
152
 
110
153
  type PlanDetails =
@@ -112,9 +155,11 @@ type PlanDetails =
112
155
  | SelectTemplateDetails
113
156
  | CompleteDetails
114
157
  | CompleteCancelledDetails
158
+ | CompleteLaterDetails
115
159
  | AbortDetails
116
160
  | RegisterDocDetails
117
161
  | SubmitReviewDetails
162
+ | ReviewDismissedDetails
118
163
  | ReviewErrorDetails;
119
164
 
120
165
  // ── Helpers ────────────────────────────────────────────────────────
@@ -125,31 +170,17 @@ function restoreFullToolSet(pi: ExtensionAPI): void {
125
170
  pi.setActiveTools(allToolNames);
126
171
  }
127
172
 
128
- /** Relative path from project dir */
129
- function relativePath(fullPath: string, projectDir: string): string {
130
- if (fullPath.startsWith(projectDir)) {
131
- return fullPath.slice(projectDir.length + 1);
132
- }
133
- return fullPath;
134
- }
135
-
136
173
  /**
137
- * D9 宿主分流信号:taiji runtime 对托管 pi 恒注入 TAIJI_AGENT_EXT_LOG=1;
138
- * 独立 pi 无此信号 → submit-review 走 E8 文本软门(不发 marker select——
139
- * pi TUI 原生渲染 \x00 控制符 title + JSON options 成乱码对话)。
140
- * 不用 TAIJI_RUNTIME_TOKEN:它在 SPAWN_ENV 出站 deny list 被强制剥除
141
- * (C-proc-09),pi 子进程 env 里恒不可见,照抄即分流静默失效且诱导实施者
142
- * 动 deny list 造成安全回归。每次调用时读(不可模块加载时缓存——测试与
143
- * 运行中 env 都可能变化)。
144
- *
145
- * [双语义耦合登记,2026-09-20 R1] 本 env 名义语义是扩展日志开关
146
- * (extension-logger 见之落盘 INFO,恒注入点 packages/pi-rpc/src/env.ts),本函数
147
- * 是第二消费方(宿主分流,submit-review / complete / 引导门三处)——若日志开关
148
- * 走向可配置(值不再是恒 '1'),三处分流同帧静默失效,届时必须拆专用宿主信号
149
- * env 并纳入恒注入,不得沿用本名。
174
+ * Relative path from project dir。
175
+ * A2 边界修复:裸 `startsWith` 会把兄弟前缀目录(/a/proj-2 ⊂ /a/proj)误判为项目内
176
+ * 路径并错切显示路径——仅「projectDir 后紧随路径分隔符」才认定内部路径,其余(含
177
+ * 全等、前缀不含分隔符边界)原样返回。
150
178
  */
151
- function isTaijiHost(): boolean {
152
- return process.env.TAIJI_AGENT_EXT_LOG === "1";
179
+ function relativePath(fullPath: string, projectDir: string): string {
180
+ if (!fullPath.startsWith(projectDir)) return fullPath;
181
+ if (fullPath.length === projectDir.length) return fullPath;
182
+ if (fullPath[projectDir.length] !== path.sep) return fullPath;
183
+ return fullPath.slice(projectDir.length + path.sep.length);
153
184
  }
154
185
 
155
186
  /**
@@ -167,11 +198,9 @@ function withUnchangedWarning(text: string, unchangedResubmit: boolean): string
167
198
  return unchangedResubmit ? `${text}\n${UNCHANGED_RESUBMIT_WARNING}` : text;
168
199
  }
169
200
 
170
- /** submit-review details 构造:警告命中时附 changed=false(正常提交字段缺失) */
171
- function submitReviewDetails(channel: "gui" | "text", docsCount: number, unchangedResubmit: boolean): SubmitReviewDetails {
172
- return unchangedResubmit
173
- ? { action: "submit-review", channel, docsCount, changed: false }
174
- : { action: "submit-review", channel, docsCount };
201
+ /** submit-review details 构造(重提交无变化信号只走 result 文本警告行,无 details 字段) */
202
+ function submitReviewDetails(channel: "gui" | "text", docsCount: number): SubmitReviewDetails {
203
+ return { action: "submit-review", channel, docsCount };
175
204
  }
176
205
 
177
206
  // ── renderResult ───────────────────────────────────────────────────
@@ -206,13 +235,29 @@ function renderPlanResult(
206
235
  case "complete": {
207
236
  const header = fg("success", `✓ Plan 已批准 → ${details.execMode}`) + NL;
208
237
  const body = fg("dim", ` ${details.planFilePath}`) + NL;
209
- const info = fg("dim", ` isolation: ${details.isolation} · 工具集已恢复`);
210
- return new Text(header + body + info, 0, 0);
238
+ // D7② 直通明示(无 plan-exec 技能不挂表单):渲染同样不静默——用户知道为何没弹表单
239
+ const direct = details.execModeSource === "no-exec-skills"
240
+ ? fg("dim", " 无 plan-exec 技能,直接执行(未弹执行方式表单)") + NL
241
+ : "";
242
+ const info = fg("dim", " 工具集已恢复");
243
+ return new Text(header + body + direct + info, 0, 0);
211
244
  }
212
245
 
213
246
  case "complete-cancelled": {
214
- const header = fg("warning", `✗ 用户选择: ${details.reason}`) + NL;
215
- const body = fg("dim", " 继续在 plan mode 中");
247
+ // D3 连带段归口判别分源渲染:入口解散(reset 已介入)vs 外部解散(批准事实保留)
248
+ const header = details.source === "self"
249
+ ? fg("error", "✗ Plan mode 已退出") + NL
250
+ : fg("warning", "✗ 执行方式选择已中断") + NL;
251
+ const body = details.source === "self"
252
+ ? fg("dim", " 工具集已恢复")
253
+ : fg("dim", ` ${details.reason} · 计划保持已批准态,可再调 plan(complete)`);
254
+ return new Text(header + body, 0, 0);
255
+ }
256
+
257
+ case "complete-later": {
258
+ // later 档(D1 later 边):用户显式「暂不执行」——不是解散,不进解散文案桶(A9 反向)
259
+ const header = fg("success", `✓ 计划已批准 · 未派发执行`) + NL;
260
+ const body = fg("dim", ` ${details.choice} · 需要时说『执行』或再调 plan(complete)`);
216
261
  return new Text(header + body, 0, 0);
217
262
  }
218
263
 
@@ -236,6 +281,13 @@ function renderPlanResult(
236
281
  return new Text(header + body, 0, 0);
237
282
  }
238
283
 
284
+ case "review-dismissed": {
285
+ // dismiss(D3):审阅已搁置,plan 模式保持——渲染区别于失败出口(不是 error)
286
+ const header = fg("success", `✓ 审阅已搁置(${details.docsCount} 份文档保留)`) + NL;
287
+ const body = fg("dim", " plan 模式保持,进度不变 · 等用户指示");
288
+ return new Text(header + body, 0, 0);
289
+ }
290
+
239
291
  case "review-error": {
240
292
  const header = fg("warning", "✗ 审阅请求未提交") + NL;
241
293
  const body = fg("dim", ` ${details.reason}`);
@@ -270,6 +322,38 @@ function cascadeTurnAbort(controller: AbortController, signal: AbortSignal | und
270
322
  signal?.addEventListener("abort", () => controller.abort(), { once: true });
271
323
  }
272
324
 
325
+ /**
326
+ * 双宿主挂起提问原语(D-B4-2):宿主判定与挂起登记簿记收进本函数,业务代码零宿主分流
327
+ * ——调用方只提供两宿主各自的提问形态(gui = taiji rpc 宿主走 marker 通道 FormOverlay;
328
+ * tui = 独立 pi / TUI 走原生交互形态),不判定宿主。E8 审批挂起与 complete 执行方式
329
+ * 两处挂起点共用;宿主分流的唯一显式保留点 = index.ts E3 恢复分流(那是宿主行为策略
330
+ * 不是交互形态,设计 D-B4-2 边界)。
331
+ * 挂起 signal 经形态回调入参透出(两宿主形态都挂在同一个 PendingSelect.controller 上,
332
+ * turn abort 级联与 exitPlanMode 解散对两宿主同样可达)。
333
+ */
334
+ async function askInteractively<S>(
335
+ ctx: ExtensionContext,
336
+ planCtx: PlanCtx,
337
+ sessionId: string,
338
+ signal: AbortSignal | undefined,
339
+ forms: {
340
+ /** taiji GUI 宿主形态(marker 通道);入参 = 挂起 signal */
341
+ gui: (pendingSignal: AbortSignal) => Promise<S>;
342
+ /** 独立 pi / TUI 宿主形态;入参 = 挂起 signal */
343
+ tui: (pendingSignal: AbortSignal) => Promise<S>;
344
+ },
345
+ ): Promise<{ value: S; pending: PendingSelect }> {
346
+ // E10 生命周期钉死:每次发挂起交互前新建 PendingSelect(禁复用已 abort 的——
347
+ // pi 对已 abort signal 短路立即 resolve undefined)
348
+ const pending = freshPendingSelect(planCtx.controllers, sessionId);
349
+ cascadeTurnAbort(pending.controller, signal);
350
+ const form = isTaijiGuiHost(ctx) ? forms.gui : forms.tui;
351
+ const value = await form(pending.controller.signal);
352
+ // 交互已 settled,注册表条目即弃(不留已 settled 的挂起)
353
+ planCtx.controllers.delete(sessionId);
354
+ return { value, pending };
355
+ }
356
+
273
357
  /** Execute result envelope (shared shape returned by every action). */
274
358
  interface ActionResult {
275
359
  content: Array<{ type: "text"; text: string }>;
@@ -289,7 +373,7 @@ function executeEnter(
289
373
  pi: ExtensionAPI,
290
374
  params: Record<string, unknown>,
291
375
  state: PlanState,
292
- sessions: PlanSessionMap,
376
+ planCtx: PlanCtx,
293
377
  sessionId: string,
294
378
  ctx: ExtensionContext,
295
379
  ): ActionResult {
@@ -321,7 +405,7 @@ function executeEnter(
321
405
  resolved = resolution.resolved;
322
406
  }
323
407
 
324
- const { prompt } = activatePlanMode(pi, sessions, sessionId, ctx, {
408
+ const { prompt } = activatePlanMode(pi, planCtx.states, sessionId, ctx, {
325
409
  requirement,
326
410
  skills: resolved,
327
411
  projectDir: ctx.cwd,
@@ -337,12 +421,15 @@ function executeEnter(
337
421
  * - 合并视图与注入段同源:两轨都以 ctx.cwd 为 projectRoot 调 listTemplates
338
422
  * (注入段经 PlanPromptInput.projectRoot 由命令层显式传入,不从 planFilePath
339
423
  * 逆推层级)——模型看到什么清单就能选中什么(含用户级/项目级投放)。
424
+ * - 单次扫描复用(C1 去重):清单查找与内容读取共用同一次扫描产物(胜者 path
425
+ * 直读——utf-8、失败 null)。
340
426
  * - content 全文直达模型可见通道(现状全文放 details 不进模型,选完没骨架——
341
427
  * §2.2 第二处错位收口);details 不再携带全文(零消费方,避免双份持久化)。
342
428
  * - 错名报错带可用名字清单:模型当场从报错自愈,无需任何查询 action(D3)。
343
429
  * - --template 直传防御:模板已由用户指定并全文内嵌注入,select-template 是
344
430
  * 画蛇添足——报错不带三源清单(直传文件不在清单里,清单会误导改选内置
345
- * 模板、偏离用户意图)。
431
+ * 模板、偏离用户意图)。判定信号 = templateProvided(独立布尔字段,双字段
432
+ * 合并后形态)。
346
433
  */
347
434
  function executeSelectTemplate(
348
435
  pi: ExtensionAPI,
@@ -354,7 +441,7 @@ function executeSelectTemplate(
354
441
  if (!templateName) {
355
442
  throw new Error("templateName is required for select-template");
356
443
  }
357
- if (state.templateProvidedPath !== undefined) {
444
+ if (state.templateProvided === true) {
358
445
  throw new Error("template was provided via --template, write the plan following the file above");
359
446
  }
360
447
  const templates = listTemplates({ projectRoot: projectDir });
@@ -362,10 +449,18 @@ function executeSelectTemplate(
362
449
  if (!winner) {
363
450
  throw new Error(`Template not found: ${templateName}. Available: ${templates.map((t) => t.name).join(", ")}`);
364
451
  }
365
- const content = loadTemplate(templateName, { projectRoot: projectDir });
452
+ // 胜者内容直读(单次扫描复用——readFileSync utf-8 / 失败 null)
453
+ let content: string | null;
454
+ try {
455
+ content = fs.readFileSync(winner.path, "utf-8");
456
+ } catch {
457
+ content = null;
458
+ }
366
459
  if (content === null) {
367
460
  throw new Error(`Template not readable: ${winner.path}`);
368
461
  }
462
+ // 无生命周期事件(9 事件表无 select-template 员)——不写生命周期态,persist 携带
463
+ // state 现值(生命周期写唯一通道 = applyPlanEvent,本 action 无转移)
369
464
  state.templateName = templateName;
370
465
  persistPlanState(pi, state);
371
466
  return {
@@ -377,17 +472,102 @@ function executeSelectTemplate(
377
472
  };
378
473
  }
379
474
 
380
- function executeAbort(
475
+ // ── exit 链单入口(D-B1-1)────────────────────────────────────────
476
+
477
+ /** exit 触发方:'command' = /plan abort 命令路径;'tool' = plan 工具 abort action 路径 */
478
+ export type ExitReason = "command" | "tool";
479
+
480
+ /** exitPlanMode 的结果(§3.1 终态):落盘转移结果 + 面向用户的反馈文案 */
481
+ export interface ExitResult {
482
+ /** 'exit' 转移是否 ok(false = 非活跃格幂等退出——状态值未写;坏格 isActive 清洗例外除外) */
483
+ moved: boolean;
484
+ /** 面向用户的反馈文案:成功 = 退出确认(tool 路作 result 文本、command 路作 notify 内容);ok:false = warn 纠偏文案 */
485
+ message: string;
486
+ /** notify 级别:成功 info;ok:false warn(两通道统一 warn 纠偏,D-B1-1 行为变更声明) */
487
+ level: "info" | "warning";
488
+ }
489
+
490
+ /** ok:false 分派文案(按格分家,A9 错误即纠偏指令):idle 常态格 vs 终态格 */
491
+ const EXIT_IDLE_MESSAGE = "No active plan mode.";
492
+ const EXIT_INACTIVE_MESSAGE =
493
+ "Plan mode is not active (it already completed or exited) — nothing to abort. " +
494
+ "Do not implement any changes; wait for further user instructions.";
495
+
496
+ /**
497
+ * exit 链唯一入口(D-B1-1):/plan abort(handleAbort)与 plan 工具 abort action
498
+ * (executeAbort)双路收敛于此,动作清单由入口统一编排(顺序是结构的一部分):
499
+ * ① 解散全部挂起 select(markDissolved('self') 一次打标 + controller.abort()——E10
500
+ * 因果链:abort 触发的 select 解析是微任务,等待处归口必在本函数同步段之后运行,
501
+ * 打标先行保证归口读到 'self');
502
+ * ② 转移 + 落盘:单守卫 = transition('exit') 合法性(非活跃格全谱:终态格 + idle 删边
503
+ * 后均 ok:false)——ok:false 跳过状态值写入(completed 终态记录不被 exited 覆写);
504
+ * 坏数据格例外(isActive=true 的终态/idle 残留):仍落一条清洗 entry——isActive=false
505
+ * 投影复位是用户级恢复通道(免覆写与投影清洗正交可分),state 值按当前态分派
506
+ * (终态残留取当前终态值不变、idle 残留落 'exited',resetPlanState 既有 terminal
507
+ * 参数直接承载);
508
+ * ③ widget 更新 ④ 工具集恢复(单份实现;ok:false 时同样执行——幂等恢复,已恢复则 no-op)
509
+ * ⑤ 反馈文案。
510
+ * 守卫极性统一「ok:false → warn + 幂等退出」:退出是用户显式意图,非活跃态下退出
511
+ * 即目标态;①③④⑤照常(解散残余挂起正是消灭「卡在等待应答」形态的动作)。
512
+ * ① 的承载 = PlanCtx.dissolveAll(D-B4-3 单 ctx 对象方法——批次 1 的内联遍历打标
513
+ * 在本批随 ctx 载体落地改调它)。
514
+ */
515
+ export function exitPlanMode(
381
516
  pi: ExtensionAPI,
382
- sessions: PlanSessionMap,
517
+ planCtx: PlanCtx,
383
518
  sessionId: string,
384
519
  ctx: ExtensionContext,
385
- ): ActionResult {
386
- const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
520
+ state: PlanState,
521
+ reason: ExitReason,
522
+ ): ExitResult {
523
+ // ① 解散挂起(含 markDissolved):来源随闭包直达等待处(D-B1-2 直传,旧世代计数机制退役)
524
+ planCtx.dissolveAll("self");
525
+
526
+ // ② 转移 + 落盘(ok:false 跳过状态值写入;坏格 isActive=true 清洗例外)
527
+ const moved = applyPlanEvent(state, "exit");
528
+ let updatedState = state;
529
+ if (moved.ok) {
530
+ updatedState = resetPlanState(pi, planCtx.states, sessionId, ctx, "exited");
531
+ } else if (state.isActive) {
532
+ // 坏数据格清洗例外:终态残留(completed/exited)取当前终态值不变;idle 残留落
533
+ // 'exited'(abort 是用户显式退出意图,exited 是本次动作的真实记录)——活跃态
534
+ // 在此不可达(exit 边合法不会 ok:false),三元兜底仅满足类型收窄
535
+ const current = state.state;
536
+ const terminal: PlanTerminalState = current === "completed" || current === "exited" ? current : "exited";
537
+ updatedState = resetPlanState(pi, planCtx.states, sessionId, ctx, terminal);
538
+ }
539
+
540
+ // ③ widget 更新 ④ 工具集恢复(幂等:已恢复则 no-op)
387
541
  updatePlanWidget(ctx, updatedState);
388
542
  restoreFullToolSet(pi);
543
+
544
+ // ⑤ 反馈
545
+ if (!moved.ok) {
546
+ return { moved: false, message: state.state === "idle" ? EXIT_IDLE_MESSAGE : EXIT_INACTIVE_MESSAGE, level: "warning" };
547
+ }
548
+ return {
549
+ moved: true,
550
+ message: reason === "tool" ? "Plan mode aborted. Full tool access restored." : "Plan mode aborted.",
551
+ level: "info",
552
+ };
553
+ }
554
+
555
+ function executeAbort(
556
+ pi: ExtensionAPI,
557
+ planCtx: PlanCtx,
558
+ sessionId: string,
559
+ ctx: ExtensionContext,
560
+ state: PlanState,
561
+ ): ActionResult {
562
+ // 单入口接管(D-B1-1):动作清单(解散/转移落盘/widget/工具集恢复)全在 exitPlanMode
563
+ // 内;本函数只负责把 ExitResult 投影为 tool result 形态(纠偏文案复用 reason='inactive'
564
+ // 出口——错误即纠偏指令,A9)
565
+ const result = exitPlanMode(pi, planCtx, sessionId, ctx, state, "tool");
566
+ if (!result.moved) {
567
+ return reviewErrorResult("inactive", result.message);
568
+ }
389
569
  return {
390
- content: [{ type: "text" as const, text: "Plan mode aborted. Full tool access restored." }],
570
+ content: [{ type: "text" as const, text: result.message }],
391
571
  details: { action: "abort" },
392
572
  };
393
573
  }
@@ -443,6 +623,8 @@ function executeRegisterDoc(
443
623
  } else {
444
624
  state.docs.push(meta);
445
625
  }
626
+ // 无生命周期事件(9 事件表无 register-doc 员)——不写生命周期态,persist 携带
627
+ // state 现值(生命周期写唯一通道 = applyPlanEvent,本 action 无转移)
446
628
  persistPlanState(pi, state);
447
629
 
448
630
  return {
@@ -452,32 +634,10 @@ function executeRegisterDoc(
452
634
  }
453
635
 
454
636
  /**
455
- * PlanReviewResponse 形状守卫(E5 的垃圾数据判据):
456
- * parse 成功但形状不合法同样按解析失败处理——垃圾数据不进对话流。
637
+ * PlanReviewResponse 值域守卫与解析已接 canonical(consumers.md ①:本地守卫删除迁移)——
638
+ * `@zhushanwen/extension-protocol` 的 `parsePlanReviewResponse`(unknown-decision / malformed
639
+ * 双分源降级)。运行时守卫 TS 穷尽性管不到,值域单一权威不得在消费侧镜像。
457
640
  */
458
- export function isPlanReviewResponse(value: unknown): value is PlanReviewResponse {
459
- if (typeof value !== "object" || value === null || !("decision" in value)) return false;
460
- const decision = value.decision;
461
- if (decision === "approve") return true;
462
- if (decision === "revise") {
463
- if (!("comments" in value) || !Array.isArray(value.comments)) return false;
464
- return value.comments.every((c) => {
465
- if (typeof c !== "object" || c === null) return false;
466
- if (!("quote" in c) || !("comment" in c)) return false;
467
- return typeof c.quote === "string" && typeof c.comment === "string";
468
- });
469
- }
470
- return false;
471
- }
472
-
473
- /** parse + 形状守卫合一:任一失败 throw(E5 捕获点统一) */
474
- export function parsePlanReviewResponse(raw: string): PlanReviewResponse {
475
- const value: unknown = JSON.parse(raw);
476
- if (!isPlanReviewResponse(value)) {
477
- throw new Error("select response does not match PlanReviewResponse shape");
478
- }
479
- return value;
480
- }
481
641
 
482
642
  /** E5/E6/取消共用错误 result 构造(content 文本即恢复动作) */
483
643
  function reviewErrorResult(reason: ReviewErrorDetails["reason"], recovery: string): ActionResult {
@@ -488,23 +648,33 @@ function reviewErrorResult(reason: ReviewErrorDetails["reason"], recovery: strin
488
648
  }
489
649
 
490
650
  /**
491
- * submit-review(D5/E6/E8/E10):
651
+ * submit-review(D5/E6/E8/E10/D9/D3):
492
652
  * - E6 双守卫:isActive=false → 错误不挂 select;docs 空 → 错误提示先 register-doc。
493
- * - 挂起前落 reviewState='awaiting'(崩溃恢复 E3 依赖此持久态)+ docs 快照指纹
494
- * (重提交无变化检测基线,text/gui 两分支共用;快照缺失 = 无既往提交不警告)。
653
+ * - D9① 自审硬门(无豁免):每次 submit-review 必带非空 selfReview(含修订后重挂;E3 重挂
654
+ * 复用既有结论但过门义务不豁免)+ 防照抄启发式(逐字节陈旧拒收,gate 判定先于一切写入、
655
+ * 拒收不更新任何快照——拒收后重试仍触发)。
656
+ * - 挂起前落 state='reviewing'(transition 'submit')+ selfReview(写侧 4KB 截断)+ docs
657
+ * 快照指纹 + resumeHint 清除(重挂起点)——同一记录点,text/gui 两分支共用。
495
658
  * - 宿主分流:taiji rpc 宿主(TAIJI_AGENT_EXT_LOG=1 且 mode==='rpc')发
496
659
  * PLAN_REVIEW_MARKER select;其余形态(独立 pi / env 泄漏的非 rpc)返回 E8 文本软门。
497
- * - select 挂 signal(E10),resolve 后 E5 解析守卫,再按 decision 消费。
660
+ * - select 挂 signal(E10),resolve 后:echo 判定先于 parse → canonical 值域解析
661
+ * (unknown-decision / malformed 双分源)→ 按 decision 消费(approve/revise/dismiss);
662
+ * choice===undefined 走归口①(D-B1-2:dissolvedBy 直传判别)。
498
663
  */
664
+ /** submit-review 挂起提问的形态应答(D-B4-2 原语消费面):select = marker 通道应答;text-gate = E8 软门(不挂交互) */
665
+ type SubmitReviewAskResult =
666
+ | { kind: "select"; choice: string | undefined; payload: string }
667
+ | { kind: "text-gate" };
668
+
499
669
  async function executeSubmitReview(
500
670
  pi: ExtensionAPI,
501
671
  ctx: ExtensionContext,
502
672
  state: PlanState,
503
- sessions: PlanSessionMap,
673
+ planCtx: PlanCtx,
504
674
  sessionId: string,
505
675
  projectDir: string,
506
- controllers: PlanAbortControllers,
507
676
  signal: AbortSignal | undefined,
677
+ params: Record<string, unknown>,
508
678
  ): Promise<ActionResult> {
509
679
  // E6 双守卫:状态门优先于内容门(退出后任何动作都不该发生)。
510
680
  // 文本语义按 A9 真机事故收紧:旧文「Wrap up the current task directly」被 LLM
@@ -522,28 +692,80 @@ async function executeSubmitReview(
522
692
  );
523
693
  }
524
694
 
695
+ // D9① 自审硬门(结构保证层:存在性非空必填,无豁免):缺失/空 → tool result 错误纠偏
696
+ //(不 throw)——提示词是引导不是闸门(F10 教训),门语义三层边界见设计 D9
697
+ const selfReview = typeof params.selfReview === "string" ? params.selfReview.trim() : "";
698
+ if (selfReview === "") {
699
+ return reviewErrorResult(
700
+ "no-self-review",
701
+ "submit-review requires a non-empty selfReview. First do a self-review: (1) check every requirement item is covered, (2) audit assumptions ([UNVERIFIED] cleared or explicitly listed), (3) check chapter completeness against the template, (4) verify the acceptance scenarios are truly executable. Fix any document issues found DURING the self-review first, then call plan(action='submit-review', selfReview='<your self-review conclusions>') again.",
702
+ );
703
+ }
704
+
705
+ const fingerprint = planDocsFingerprint(state.docs);
706
+ // D9① 防照抄启发式(启发式层:逐字节陈旧拒收,微改文本即绕过不假装能验证思想):
707
+ // 文档已变(指纹与上次快照不同)而 selfReview 与上次提交值逐字节相同 → 拒收。比较按
708
+ // 写侧截断口径(超长输入截断后比,防截断缝隙绕过)。gate 判定先于一切写入、拒收不更新
709
+ // 任何快照(拒收后重试仍触发——契约测试锁定);E3 重挂(指纹未变 + 同值)不触本门。
710
+ const boundedSelfReview = truncateSelfReview(selfReview);
711
+ const docsChangedSinceLastSubmit =
712
+ state.lastSubmitReviewDocsFingerprint !== undefined &&
713
+ state.lastSubmitReviewDocsFingerprint !== fingerprint;
714
+ if (docsChangedSinceLastSubmit && state.selfReview !== undefined && boundedSelfReview === state.selfReview) {
715
+ return reviewErrorResult(
716
+ "stale-self-review",
717
+ "The documents have changed since the last submit-review, but selfReview is byte-identical to the previous submission. You MUST redo the self-review against the NEW document versions (re-read the revised files first). Fix any issues found, then call plan(action='submit-review', selfReview='<the new self-review>') again.",
718
+ );
719
+ }
720
+
525
721
  // 重提交无变化检测:指纹与上次快照相同 ⇔ 上次 submit-review 后无任何 register-doc。
526
722
  // 快照缺失 = 无既往提交(首次 / reset 后新轮次 / 旧版 entry 重挂),不警告。
527
- const fingerprint = planDocsFingerprint(state.docs);
528
723
  const unchangedResubmit =
529
724
  state.lastSubmitReviewDocsFingerprint !== undefined &&
530
725
  state.lastSubmitReviewDocsFingerprint === fingerprint;
531
726
 
532
- // 挂起 select 前落 awaiting:select 挂起期间 entry 已持久(崩溃恢复 E3 依赖此持久态)。
533
- // 指纹快照同点更新:单一记录点,text/gui 两检测分支共用(快照 = 「上次 submit-review
534
- // 时的 docs」)。重挂起新 pending 前清上一轮 source——不变量 = source 只描述当前降级
535
- // 等待的原因,此处即将挂起真审批,降级等待尚未发生(残留 'resubmit' 会在降级态渲染
536
- // 上一轮「会话已重启」文案,与 C-U2 同型残留)
537
- delete state.reviewStateSource;
538
- state.reviewState = "awaiting";
727
+ // 状态写走 transition()(D1 'submit' 边:planning|revising → reviewing)。reviewing 重挂
728
+ //(E3 恢复 / D8「重新提交审批」按钮)走 reviewing --submit--> reviewing 自环(D-B1-8
729
+ // 补边——自环前后落盘产物等价:state 值不变、照常落盘重挂;resumeHint 清除与快照更新
730
+ // 同点)。其余 ok:false = FSM 合法性兜底,不落盘直接纠偏
731
+ const moved = applyPlanEvent(state, "submit");
732
+ if (!moved.ok) {
733
+ return reviewErrorResult(
734
+ "out-of-order",
735
+ `The plan is in state '${state.state}' and cannot be submitted for review (it is past the review stage). ` +
736
+ `To execute, call plan(action='complete') to choose an execution method. ` +
737
+ `To start a fresh planning round, call plan(action='abort') first, then plan(action='enter'). ` +
738
+ `Do not implement any changes the user has not approved.`,
739
+ );
740
+ }
741
+
742
+ // 挂起 select 前落盘(单一记录点):state(reviewing)+ selfReview(E3 回传源 + 防照抄
743
+ // 比较基线——单字段双角色,同值同写点无分歧路径)+ 指纹快照(text/gui 两检测分支共用)+
744
+ // per-round 字段清除(D4 单函数出口——不变量:resumeHint 只描述当前降级等待的原因,
745
+ // 此处即将挂起真审批,残留 'resubmit' 会渲染上一轮「会话已重启」文案,C-U2 同型残留);
746
+ // selfReview / 指纹紧随置位本轮新值(delete→set 属性终态与原仅清 resumeHint 等价)
747
+ clearRoundFields(state);
748
+ state.selfReview = boundedSelfReview;
539
749
  state.lastSubmitReviewDocsFingerprint = fingerprint;
540
750
  persistPlanState(pi, state);
541
751
 
542
- // E8 宿主分流(与下方 resolveCompleteChoice 的 taiji 判定对齐 = isTaijiHost() &&
543
- // ctx.mode === 'rpc'):env 信号只证明 taiji runtime 在上游,mode 非 rpc(env 泄漏
544
- // 到独立 pi TUI / json / print)时宿主没有 marker 路由,pi TUI 会把 \x00 title +
545
- // JSON options 渲染成乱码对话——不发 select,审批退化为自然语言软门
546
- if (!(isTaijiHost() && ctx.mode === "rpc")) {
752
+ // 双宿主挂起提问(D-B4-2 原语:宿主判定收进 askInteractively,此处零分流)——
753
+ // taiji GUI 宿主:PLAN_REVIEW_MARKER marker 通道 select(payload 惰性构造,软门路不构造);
754
+ // 独立 pi / TUI 宿主:E8 文本软门(不发 marker select——env 信号只证明 taiji runtime
755
+ // 在上游,mode 非 rpc(env 泄漏到独立 pi TUI / json / print)时宿主没有 marker 路由,
756
+ // pi TUI 会把 \x00 title + JSON options 渲染成乱码对话),审批退化为自然语言软门,
757
+ // 不挂交互。软门路的 PendingSelect 登记、settle、清理由原语瞬时完成(无交互挂起窗口)
758
+ const { value: ask, pending } = await askInteractively(ctx, planCtx, sessionId, signal, {
759
+ gui: async (pendingSignal): Promise<SubmitReviewAskResult> => {
760
+ // selfReview 随 payload 投影(D9③:截断点 = payload 构造单点,透传层不截)
761
+ const payload = JSON.stringify({ docs: state.docs, selfReview: boundedSelfReview } satisfies PlanReviewRequest);
762
+ const choice = await ctx.ui.select(PLAN_REVIEW_MARKER, [payload], { signal: pendingSignal });
763
+ return { kind: "select", choice, payload };
764
+ },
765
+ tui: async (): Promise<SubmitReviewAskResult> => ({ kind: "text-gate" }),
766
+ });
767
+
768
+ if (ask.kind === "text-gate") {
547
769
  return {
548
770
  content: [{
549
771
  type: "text" as const,
@@ -555,28 +777,33 @@ async function executeSubmitReview(
555
777
  unchangedResubmit,
556
778
  ),
557
779
  }],
558
- details: submitReviewDetails("text", state.docs.length, unchangedResubmit),
780
+ details: submitReviewDetails("text", state.docs.length),
559
781
  };
560
782
  }
561
-
562
- // E10 生命周期钉死:每次发挂起 select 新建 controller(禁复用已 abort 的——
563
- // pi 对已 abort signal 短路立即 resolve undefined)
564
- const controller = freshAbortController(controllers, sessionId);
565
- cascadeTurnAbort(controller, signal);
566
- const payload = JSON.stringify({ docs: state.docs } satisfies PlanReviewRequest);
567
- const choice = await ctx.ui.select(PLAN_REVIEW_MARKER, [payload], { signal: controller.signal });
568
- // select 已 settled,controller 即弃(注册表不留已 settled 的 controller)
569
- controllers.delete(sessionId);
783
+ const { choice, payload } = ask;
570
784
 
571
785
  if (choice === undefined) {
572
- // 取消分支覆盖两条路径:abort 联动(/plan abort,resetPlanState 已由命令
573
- // handler 执行,本分支正常返回让 turn 结束)与 TUI 手动取消(reviewState
574
- // 保持 awaiting)。语义按 A9 真机事故收紧:旧文「dismissed → 重挂」被 LLM
575
- // 当成批准信号,重挂撞 inactive 守卫后开始实施源码改动——取消 ≠ 批准,
576
- // 禁止重挂、禁止实施,停止审批循环等用户指示。
786
+ // 归口①(审批 select 无选择解散,两归口点之一):解散来源直传判别(D-B1-2)——
787
+ // exitPlanMode 入口解散时 markDissolved('self') 已随闭包打标(同步先于 abort、
788
+ // 归口在微任务侧运行时必已置位)→ 命令解散,reset 终态已由入口落盘 → 归口 no-op
789
+ // 不落盘;非 'self'(undefined——turn abort 级联 / TUI 手动取消不经入口不打标)→
790
+ // 外部解散。读-转移-落盘同一同步临界段(getPlanState 现值读取,禁闭包快照跨 await
791
+ // 复用);状态机守卫(transition 合法性,终态上 ok:false 不落盘)保留为结构兜底
792
+ if (pending.dissolvedBy === "self") {
793
+ // 文案按判别分源(A9 教训:文案是行为触发面)
794
+ return reviewErrorResult(
795
+ "cancelled",
796
+ "Plan mode has been exited and the full tool set is restored. This is NOT an approval. Do not implement any changes. Briefly tell the user you have stopped, then wait for further user instructions.",
797
+ );
798
+ }
799
+ // 外部解散:reviewing --review_aborted--> planning。取消 ≠ 批准(A9 收紧):禁止实施、
800
+ // 等用户指示(重挂由用户触发——D8 按钮 / 提示词纪律,不再引导 agent 自动重挂)
801
+ const current = getPlanState(planCtx.states, sessionId, ctx);
802
+ const aborted = applyPlanEvent(current, "review_aborted");
803
+ if (aborted.ok) persistPlanState(pi, current);
577
804
  return reviewErrorResult(
578
- "cancelled",
579
- "The review was cancelled — the user dismissed the approval dialog or exited plan mode. This is NOT an approval. Do not implement any changes. Stop the review loop: briefly tell the user you have stopped, then wait for further user instructions.",
805
+ "review-interrupted",
806
+ "The review was interrupted — plan mode is still active (planning). This is NOT an approval. Do not implement any changes. Wait for the user's instructions; when they ask to re-submit, call plan(action='submit-review', selfReview='...') again.",
580
807
  );
581
808
  }
582
809
 
@@ -595,31 +822,93 @@ async function executeSubmitReview(
595
822
  );
596
823
  }
597
824
 
598
- let response: PlanReviewResponse;
825
+ // canonical 值域解析(consumers.md ①:本地守卫删迁移,值域单一权威不镜像):JSON 解析
826
+ // 与形状/值域守卫分离,unknown-decision 与 malformed 双分源降级(文案语义相反)
827
+ let parsed: unknown;
599
828
  try {
600
- response = parsePlanReviewResponse(choice);
829
+ parsed = JSON.parse(choice);
601
830
  } catch (error) {
602
- // E5:解析失败 / 形状不合法 → logger.warn + 提示 agent 重挂审批;
831
+ // E5:解析失败 → logger.warn + 提示 agent 重挂审批;
603
832
  // 不把可能损坏的数据按任何 decision 注入对话(垃圾数据不进流)
604
833
  logger.warn("plan: submit-review select response parse failed", {
605
834
  error: error instanceof Error ? error.message : String(error),
606
835
  });
607
836
  return reviewErrorResult(
608
837
  "bad-response",
609
- "The review response could not be parsed. No action was taken — call plan(action='submit-review') again to re-hang the review for the user.",
838
+ "The review response could not be parsed. No action was taken — call plan(action='submit-review', selfReview='...') again to re-hang the review for the user.",
839
+ );
840
+ }
841
+ const envelope = parsePlanReviewResponse(parsed);
842
+ if (!envelope.ok) {
843
+ // unknown-decision(已知形状、未知值域)与 malformed 同款 bad-response 出口(条目 7
844
+ // 降级:独立 version-mismatch 报错删除)——原子发版(Q-4)下「新扩展 + 旧宿主」不可达,
845
+ // 唯一窗口 = dev-link 版本错开,该窗口恢复动作 = dev-link 重新对齐版本后重试。
846
+ // warn 留痕版本错配信号:生产环境出现 = 错配组合真实可达的反证,重审本条删除
847
+ //(候选替代 = 恢复独立 version-mismatch 不引导重挂出口)。
848
+ if (envelope.code === "unknown-decision") {
849
+ logger.warn(
850
+ "plan: submit-review response decision out of domain (possible host/extension version mismatch — re-align versions via dev-link, then retry)",
851
+ { decision: envelope.decision },
852
+ );
853
+ } else {
854
+ // malformed(E5 垃圾数据判据):形状不合法按解析失败同款处理,引导重挂
855
+ logger.warn("plan: submit-review select response shape invalid (malformed)");
856
+ }
857
+ return reviewErrorResult(
858
+ "bad-response",
859
+ "The review response does not match the PlanReviewResponse shape. No action was taken — call plan(action='submit-review', selfReview='...') again to re-hang the review for the user.",
610
860
  );
611
861
  }
862
+ const response = envelope.response;
612
863
 
613
864
  switch (response.decision) {
614
865
  case "approve":
615
- // approve → 走现状 complete 流程(执行方式 select 同样挂 signal)
616
- return await executeComplete(pi, ctx, {}, state, sessions, sessionId, projectDir, controllers, signal);
866
+ // approve → 走 complete 流程(执行方式 select 同样挂 signal);approve 边
867
+ //(reviewing --approve--> dispatching,D5)由 executeComplete 入口的 transition 承载
868
+ return await executeComplete(pi, ctx, {}, state, planCtx, sessionId, projectDir, signal);
869
+
870
+ case "dismiss": {
871
+ // D3 搁置(协议级 dismiss 决策,取代「忽略 = 杀 turn」):reviewing --dismiss--> planning
872
+ // 落盘——被搁置的审批不复活(F1/F2/F3 构造性消除);非破坏动作(不杀 turn、不丢状态),
873
+ // 不需要确认 Popover(F16 守卫倒挂随之消解)。
874
+ // 非归口分支的交错安全:select settle → 消费 continuation 先于下一消息 handler 执行
875
+ //(D3 实施注释级钉死),闭包 state 此处即现值。转移先于文案:ok:false = 审批已被
876
+ // 交错操作推进,此时仍报成功会让 agent 拿着与真实状态相反的反馈空跑一轮——
877
+ // 不落盘,按 out-of-order 返回(与 submit-review 同款出口)
878
+ const movedDismiss = applyPlanEvent(state, "dismiss");
879
+ if (!movedDismiss.ok) {
880
+ return reviewErrorResult(
881
+ "out-of-order",
882
+ `The plan is in state '${state.state}' and the review can no longer be set aside (it is not pending). ` +
883
+ `Check the current plan state and continue from there. Do not implement any changes.`,
884
+ );
885
+ }
886
+ persistPlanState(pi, state);
887
+ return {
888
+ content: [{
889
+ type: "text" as const,
890
+ text: "The user set this review aside (dismissed). Plan mode stays active and the documents and progress are unchanged. Briefly tell the user the review was set aside and ask what they want next (continue refining / wait for instructions). Do not implement any changes.",
891
+ }],
892
+ details: { action: "review-dismissed", docsCount: state.docs.length },
893
+ };
894
+ }
617
895
 
618
896
  case "revise": {
619
897
  // custom message 形态注入:streaming 时显式
620
898
  // deliverAs:'steer' 排队至下一次 LLM 调用(pi 实装锚点:dist/core/agent-session.js
621
899
  // :859-868(0.84.4)——isStreaming 分支 steer 走 :868 _queueSteer;sendMessage 缺省
622
900
  // deliverAs 同为 steer,显式传保持排队语义自明);非 streaming 由 triggerTurn:true 开轮
901
+ // 状态写走 transition()(D1 'revise' 边:reviewing → revising)且先于 steer 注入:
902
+ // ok:false = 审批已被交错操作推进,此时注入评论并报成功会让 agent 空跑一轮——
903
+ // 评论不进队、不落盘,按 out-of-order 返回(与 submit-review 同款出口);交错安全同 dismiss 分支注释
904
+ const movedRevise = applyPlanEvent(state, "revise");
905
+ if (!movedRevise.ok) {
906
+ return reviewErrorResult(
907
+ "out-of-order",
908
+ `The plan is in state '${state.state}' and revision comments cannot be injected (no review is pending). ` +
909
+ `Check the current plan state and continue from there. Do not implement any changes.`,
910
+ );
911
+ }
623
912
  pi.sendMessage(
624
913
  {
625
914
  customType: PLAN_CONTEXT_CUSTOM_TYPE,
@@ -628,22 +917,21 @@ async function executeSubmitReview(
628
917
  },
629
918
  { deliverAs: "steer", triggerTurn: true },
630
919
  );
631
- state.reviewState = "revising";
632
920
  persistPlanState(pi, state);
633
921
  return {
634
922
  content: [{
635
923
  type: "text" as const,
636
924
  text: withUnchangedWarning(
637
- `User requested revision with ${response.comments.length} comment(s) — injected into the conversation. Handle the comments, re-register revised documents, then re-submit for review.`,
925
+ `User requested revision with ${response.comments.length} comment(s) — injected into the conversation. Handle the comments, re-register revised documents, then re-submit for review (with a fresh selfReview for the revised documents).`,
638
926
  unchangedResubmit,
639
927
  ),
640
928
  }],
641
- details: submitReviewDetails("gui", state.docs.length, unchangedResubmit),
929
+ details: submitReviewDetails("gui", state.docs.length),
642
930
  };
643
931
  }
644
932
 
645
933
  default: {
646
- // 判别联合穷尽性守卫:isPlanReviewResponse 已收窄 decision 值域,此处不可达;
934
+ // 判别联合穷尽性守卫:parsePlanReviewResponse 已收窄 decision 值域,此处不可达;
647
935
  // 编码期新增 decision 漏改 switch 会被 never 断言在编译期拦截
648
936
  const unreachable: never = response;
649
937
  throw new Error(`plan: unhandled review decision: ${JSON.stringify(unreachable)}`);
@@ -653,8 +941,8 @@ async function executeSubmitReview(
653
941
 
654
942
  /**
655
943
  * 执行方式选项(2026-09-21 用户裁决重排):选项集 = 检测到的 plan-exec skills
656
- * (root 序前 2 个,label `用技能「name」执行`)+ 普通执行(execute 档——compact 侧
657
- * 整合 goal 桥与 auto-parallel subagent,见 deliverExecutionNotice)+ 暂不执行
944
+ * (root 序前 2 个,label `用技能「name」执行`)+ 普通执行(execute 档——经
945
+ * deliverExecutionNotice 整合 goal 桥与 auto-parallel subagent 委派指导)+ 暂不执行
658
946
  * (留在 plan mode)。四段固定结构,UI 文案经 i18n(ui-preferences locale 通道)。
659
947
  * 动态构造:skill 项随 complete 时检测产出,label→mode 映射随选项集携带
660
948
  * (`skill:<name>` 动态项不进静态表)。
@@ -667,11 +955,9 @@ interface ExecOption {
667
955
  skillEntryPath?: string;
668
956
  }
669
957
 
670
- /** skill 选项上限(用户裁决:第一/第二两个 skill 排位;检测再多不进选项) */
671
- const MAX_SKILL_OPTIONS = 2;
672
-
673
- /** Build execution options: up to 2 detected plan-exec skills + execute + not-now. */
674
- function buildExecOptions(execSkills: ExecSkill[]): ExecOption[] {
958
+ /** Build execution options: up to MAX_SKILL_OPTIONS detected plan-exec skills + execute + not-now.
959
+ * (上限常量 SSOT 在 prompts.ts——PHASE_D 散文与这里同源;导出供对齐测试消费。) */
960
+ export function buildExecOptions(execSkills: ExecSkill[]): ExecOption[] {
675
961
  const options: ExecOption[] = execSkills.slice(0, MAX_SKILL_OPTIONS).map((skill) => ({
676
962
  label: t("exec.viaSkill", { name: skill.name }),
677
963
  mode: `skill:${skill.name}`,
@@ -692,45 +978,39 @@ function buildExecOptions(execSkills: ExecSkill[]): ExecOption[] {
692
978
  }
693
979
 
694
980
  /**
695
- * 「暂不执行」档的 mode 值(选项集内的固定成员,label 经 i18n;选中即 complete-cancelled,
696
- * 留在 plan mode——原 CANCEL_OPTIONS 英文双选项的收敛形态)。
981
+ * 「暂不执行」档的 mode 值(选项集内的固定成员,label 经 i18n;选中即 later 边——
982
+ * dispatching --later--> approved,留在 plan mode)。
697
983
  */
698
984
  const LATER_MODE = "later";
699
985
 
700
- /** Outcome of the complete-action execution-method prompt. */
986
+ /**
987
+ * 执行方式选择的 outcome(D3 连带段)——**显式 `via: 'later' | 'dissolved'` 枚举**,
988
+ * 五构造点各自声明、归口点直读判别,**不从 result/details 反推**:choice 被压平进
989
+ * details.reason 后两构造函数产出同形 reason,字面按「reason 非空」判会把 channel-error
990
+ * (reason 非空且 ≈ 'cancelled')误入 later 桶(A9 反向误分类)。
991
+ * 五构造点:① LATER_MODE(用户显式「暂不执行」→ via 'later',是明确选择不是解散);
992
+ * ② choice 空 / ③ uiFormInteract cancel / ④ timeout / ⑤ channel-error、non-json 等非 ok
993
+ * 其余 reason → 均 via 'dissolved'(无选择解散,归口点执行 review_aborted 转移义务)。
994
+ * mode 分支的 `pickedBy`(D7②):'headless' = 无 UI 默认 execute;'no-exec-skills' =
995
+ * 无 plan-exec 技能直通(不挂表单);'dialog' = 表单/选择器选定。
996
+ */
701
997
  type CompleteChoiceOutcome =
702
- | { kind: "cancelled"; result: ActionResult }
703
- | { kind: "mode"; chosenMode: string; skillEntryPath?: string };
704
-
705
- /** complete-cancelled result(用户选暂不执行 / 通道取消,reason = 点选 label 或 cancelled) */
706
- function cancelledByUserResult(choice: string | undefined): ActionResult {
707
- // 文案按取消来源分情境(状态审查 P2-3):choice 非空 = 用户在选项里主动点「暂不执行」,
708
- // 留在 plan mode 是用户意图;choice 空 = select 被 abort 联动解散(用户点了 PlanModeBar
709
- // 退出 / turn 中止)——此时 plan mode 已退出,旧文「Staying in plan mode」与已 reset 的
710
- // 状态双向矛盾(LLM 按 plan mode 行事,后续每个 plan 调用吃 inactive 错误才自纠)
711
- const text =
712
- choice === undefined
713
- ? "Plan mode was exited while the execution-method prompt was pending. The plan has NOT been dispatched for execution — do not implement any changes. Briefly tell the user you have stopped, then wait for further user instructions."
714
- : `User chose: ${choice}. Staying in plan mode. The plan has NOT been dispatched for execution.`;
715
- return {
716
- content: [{ type: "text" as const, text }],
717
- details: { action: "complete-cancelled", reason: choice ?? "cancelled" },
718
- };
719
- }
720
-
721
- /** 通道失败折叠 result(D4 四态折叠表):不 throw 炸 turn、也不默认执行,注明失败态留 plan mode */
722
- function cancelledByChannelResult(reason: string, message?: string): ActionResult {
723
- const detail = message ? ` (${message})` : "";
724
- return {
725
- content: [
726
- {
727
- type: "text" as const,
728
- text: `Interaction channel failed: ${reason}${detail}. Staying in plan mode — call plan(action='complete') again once the host/connection is fixed.`,
729
- },
730
- ],
731
- details: { action: "complete-cancelled", reason },
732
- };
733
- }
998
+ | { kind: "mode"; chosenMode: string; skillEntryPath?: string; pickedBy: "headless" | "no-exec-skills" | "dialog" }
999
+ | { kind: "cancelled"; via: "later"; chosenLabel: string }
1000
+ | {
1001
+ kind: "cancelled";
1002
+ via: "dissolved";
1003
+ /** 构造点原始原因(②③④ 折叠 'cancelled';⑤ 透传 form.reason) */
1004
+ reason: string;
1005
+ /** 通道失败补充说明(构造点⑤携带,归口文案拼接) */
1006
+ message?: string;
1007
+ /**
1008
+ * 解散来源(D-B1-3 via 侧迁移:与审批 select 的 dissolvedBy 统一为同一来源直传
1009
+ * 原语,值域 self|external——挂起 settle 后从 PendingSelect 槽位一次性读定,
1010
+ * 非 'self' 即外部;归口按 by 分派,不再持有挂起句柄反查)
1011
+ */
1012
+ by: "self" | "external";
1013
+ };
734
1014
 
735
1015
  /**
736
1016
  * Prompt the user for an execution method(D4 三路分流 + 2026-09-21 选项集重排):
@@ -746,132 +1026,246 @@ function cancelledByChannelResult(reason: string, message?: string): ActionResul
746
1026
  * UI 文案(question/header/选项 label)经 i18n;answers key = header(协议 fallback
747
1027
  * 规则 key = header ?? question),header 与读取同源 t() 生成,本地化不破坏取值。
748
1028
  */
1029
+ /** 双宿主形态的统一应答(D-B4-2 原语消费面):answered = 拿到 label(空/缺省 = 无选择);channel-failure = GUI 通道非 ok */
1030
+ type ChoiceAskResult =
1031
+ | { kind: "answered"; label: string | undefined }
1032
+ | { kind: "channel-failure"; reason: string; message?: string };
1033
+
749
1034
  async function resolveCompleteChoice(
750
1035
  ctx: ExtensionContext,
751
- controllers: PlanAbortControllers,
1036
+ planCtx: PlanCtx,
752
1037
  sessionId: string,
753
1038
  signal: AbortSignal | undefined,
754
1039
  ): Promise<CompleteChoiceOutcome> {
755
1040
  if (!ctx.hasUI) {
756
- return { kind: "mode", chosenMode: "execute" };
1041
+ return { kind: "mode", chosenMode: "execute", pickedBy: "headless" };
757
1042
  }
758
1043
 
759
1044
  // complete 时现扫 plan-exec skill(无缓存,技能热装可见;检测自带降级规格,
760
1045
  // 最坏 = skill 选项空集,绝不炸本流程)。选项构造时一次解析,skillEntryPath 随 outcome 流转
761
1046
  const execSkills = detectExecSkills({ cwd: ctx.cwd, trusted: ctx.isProjectTrusted() });
1047
+ // D7②(设计 §3.1 终态 6 / S4):无 plan-exec 技能**不挂执行方式表单**——「确认执行」
1048
+ // 按钮语义自洽,approve 直通 execute 走完执行派发链(goal 桥/直执 steer);「暂不执行」
1049
+ // 需求由「不点确认执行 / 搁置」覆盖。恒两项死表单(F6 用户可感形态)构造性消除。
1050
+ if (execSkills.length === 0) {
1051
+ return { kind: "mode", chosenMode: "execute", pickedBy: "no-exec-skills" };
1052
+ }
762
1053
  const execOptions = buildExecOptions(execSkills);
763
1054
 
764
- // E10:执行方式 select 与 submit-review 审批 select 同为挂起点,同样挂 signal——
765
- // approve 后的挂起窗口内用户点 PlanModeBar 退出(确认 Popover 后)必须可达(abort → resolve undefined → cancelled)
766
- const controller = freshAbortController(controllers, sessionId);
767
- cascadeTurnAbort(controller, signal);
768
-
769
- let chosenLabel: string | undefined;
770
- if (isTaijiHost() && ctx.mode === "rpc") {
771
- const questionHeader = t("exec.header");
772
- const question: ChoiceQuestion = {
773
- type: "choice",
774
- header: questionHeader,
775
- question: t("exec.question"),
776
- options: execOptions.map((opt) => ({ label: opt.label, description: opt.description })),
777
- allowOther: false,
778
- };
779
- // 收窄传参面:只投影 helper 需要的 GuiContext 成员(pi ExtensionContext.ui.custom 的
780
- // 泛型组件工厂签名比 GuiContext 的宽松形状窄,整 ctx 直传类型不兼容)。
781
- // select 必须 .bind(ctx.ui)(对齐 ask-user / scheduler 同协议形态):callMarkerRpc 先
782
- // 解构再裸调用,this 依赖 pi 实装 select 为箭头闭包——显式 bind 消除该隐式依赖
783
- // (pi 实装锚点:dist/modes/rpc/rpc-mode.js:84(0.84.4)——select 为箭头函数闭包)。
784
- const form = await uiFormInteract(
785
- { mode: ctx.mode, hasUI: ctx.hasUI, ui: { select: ctx.ui.select.bind(ctx.ui) } },
786
- [question],
787
- { signal: controller.signal },
788
- );
789
- controllers.delete(sessionId);
790
- if (!form.ok) {
791
- if (form.reason === "cancelled" || form.reason === "timeout") {
792
- return { kind: "cancelled", result: cancelledByUserResult(undefined) };
1055
+ // 双宿主挂起提问(D-B4-2 原语:宿主判定收进 askInteractively,此处零分流)——
1056
+ // taiji GUI 宿主:uiFormInteract 单 choice 问题(FormOverlay 单视图);独立 pi / TUI
1057
+ // 宿主:pi 原生 plain select。headless 与无技能直通两路是宿主策略/业务直通(非交互
1058
+ // 形态分派),留在原语之前。E10:执行方式 select 与 submit-review 审批 select 同为
1059
+ // 挂起点(原语统一挂 signal)——approve 后的挂起窗口内用户点 PlanModeBar 退出
1060
+ //(确认 Popover 后)必须可达(abort → resolve undefined → cancelled)
1061
+ const { value: ask, pending } = await askInteractively(ctx, planCtx, sessionId, signal, {
1062
+ gui: async (pendingSignal): Promise<ChoiceAskResult> => {
1063
+ const questionHeader = t("exec.header");
1064
+ const question: ChoiceQuestion = {
1065
+ type: "choice",
1066
+ header: questionHeader,
1067
+ question: t("exec.question"),
1068
+ options: execOptions.map((opt) => ({ label: opt.label, description: opt.description })),
1069
+ allowOther: false,
1070
+ };
1071
+ // 收窄传参面:只投影 helper 需要的 GuiContext 成员(pi ExtensionContext.ui.custom 的
1072
+ // 泛型组件工厂签名比 GuiContext 的宽松形状窄,整 ctx 直传类型不兼容)。
1073
+ // select 必须 .bind(ctx.ui)(对齐 ask-user / scheduler 同协议形态):callMarkerRpc 先
1074
+ // 解构再裸调用,this 依赖 pi 实装 select 为箭头闭包——显式 bind 消除该隐式依赖
1075
+ // (pi 实装锚点:dist/modes/rpc/rpc-mode.js:84(0.84.4)——select 为箭头函数闭包)。
1076
+ const form = await uiFormInteract(
1077
+ { mode: ctx.mode, hasUI: ctx.hasUI, ui: { select: ctx.ui.select.bind(ctx.ui) } },
1078
+ [question],
1079
+ { signal: pendingSignal },
1080
+ );
1081
+ if (!form.ok) {
1082
+ // 构造点③(cancel)与④(timeout):库层以 signal.aborted 判 cancelled,GUI 用户取消
1083
+ // resolve undefined 与超时不可区分——两构造点分开声明、同折 reason 'cancelled'
1084
+ const reason = form.reason === "cancelled" || form.reason === "timeout" ? "cancelled" : form.reason;
1085
+ return { kind: "channel-failure", reason, message: form.message };
793
1086
  }
794
- return { kind: "cancelled", result: cancelledByChannelResult(form.reason, form.message) };
795
- }
796
- chosenLabel = form.answers[questionHeader];
797
- } else {
798
- const labels = execOptions.map((opt) => opt.label);
799
- chosenLabel = await ctx.ui.select(t("exec.question"), labels, { signal: controller.signal });
800
- controllers.delete(sessionId);
801
- }
1087
+ return { kind: "answered", label: form.answers[questionHeader] };
1088
+ },
1089
+ tui: async (pendingSignal): Promise<ChoiceAskResult> => {
1090
+ const labels = execOptions.map((opt) => opt.label);
1091
+ return { kind: "answered", label: await ctx.ui.select(t("exec.question"), labels, { signal: pendingSignal }) };
1092
+ },
1093
+ });
802
1094
 
803
- if (!chosenLabel) {
804
- return { kind: "cancelled", result: cancelledByUserResult(undefined) };
1095
+ // 构造点⑤(channel-error / non-json 等非 ok 其余 reason):via 'dissolved'
1096
+ //(交互通道故障折叠,不炸 turn 也不默认执行)
1097
+ if (ask.kind === "channel-failure") {
1098
+ return {
1099
+ kind: "cancelled",
1100
+ via: "dissolved",
1101
+ reason: ask.reason,
1102
+ message: ask.message,
1103
+ by: pending.dissolvedBy ?? "external",
1104
+ };
805
1105
  }
1106
+ // 构造点②(choice 空):无选择解散 → via 'dissolved'
1107
+ if (!ask.label) {
1108
+ return { kind: "cancelled", via: "dissolved", reason: "cancelled", by: pending.dissolvedBy ?? "external" };
1109
+ }
1110
+ const chosenLabel = ask.label;
806
1111
  const option = execOptions.find((opt) => opt.label === chosenLabel);
807
1112
  if (!option) {
808
1113
  // 选项集与 labels 同源构造,选中的 label 必在集内——找不到即编码 bug,fail-fast
809
1114
  throw new Error(`plan: unknown execution choice label: ${chosenLabel}`);
810
1115
  }
1116
+ // 构造点①(LATER_MODE):用户显式「暂不执行」→ via 'later'(明确选择,不是解散)
811
1117
  if (option.mode === LATER_MODE) {
812
- return { kind: "cancelled", result: cancelledByUserResult(chosenLabel) };
1118
+ return { kind: "cancelled", via: "later", chosenLabel };
813
1119
  }
814
- return { kind: "mode", chosenMode: option.mode, skillEntryPath: option.skillEntryPath };
1120
+ return { kind: "mode", chosenMode: option.mode, skillEntryPath: option.skillEntryPath, pickedBy: "dialog" };
815
1121
  }
816
1122
 
817
1123
  /**
818
- * complete 的 result 正文:direct 档 goalInit 同步完成,追加 goal 结果行(D2);
819
- * compact 档 goalInit 在 onComplete 回调内执行、result 已返回,不携带(通道差异
820
- * 为设计 §6.2 D2 登记的终态)。
1124
+ * complete 的 result 正文:goalInit 同步完成,追加 goal 结果行(D2)。
1125
+ * D7② 直通(no-exec-skills)明示一行——工具结果文案不得静默吞掉「没弹表单」的事实。
821
1126
  */
822
- function completeResultText(displayPath: string, goalOutcome: GoalBridgeOutcome | undefined): string {
1127
+ function completeResultText(
1128
+ displayPath: string,
1129
+ goalOutcome: GoalBridgeOutcome | undefined,
1130
+ execModeSource: "headless" | "no-exec-skills" | "dialog",
1131
+ ): string {
823
1132
  const base = `Plan approved. File: ${displayPath}`;
824
- if (goalOutcome === undefined) return base;
1133
+ const directLine =
1134
+ execModeSource === "no-exec-skills"
1135
+ ? `\nNo plan-exec skill was detected — executed directly (no execution-method dialog).`
1136
+ : "";
1137
+ if (goalOutcome === undefined) return base + directLine;
825
1138
  return goalOutcome.started
826
- ? `${base}\nGoal tracking started via /goal.`
827
- : `${base}\nGoal tracking was not started (${goalOutcome.reason}). ${GOAL_FAILURE_RECOVERY[goalOutcome.reason]}`;
1139
+ ? `${base}${directLine}\nGoal tracking started via /goal.`
1140
+ : `${base}${directLine}\nGoal tracking was not started (${goalOutcome.reason}). ${GOAL_FAILURE_RECOVERY[goalOutcome.reason]}`;
828
1141
  }
829
1142
 
830
- /** complete action: prompt for execution mode, restore tools, reset state. */
1143
+ /**
1144
+ * complete action(D5/D3 连带段):approve 边入 dispatching → 选档 → 归口② / exec_chosen 终局。
1145
+ * 双入口共用:① submit-review 的 approve 决策消费(reviewing --approve--> dispatching);
1146
+ * ② agent 再调 complete 重新选择执行方式(approved --approve--> dispatching,事件命名裁决
1147
+ * consumers.md §三B)。
1148
+ */
831
1149
  async function executeComplete(
832
1150
  pi: ExtensionAPI,
833
1151
  ctx: ExtensionContext,
834
1152
  params: Record<string, unknown>,
835
1153
  state: PlanState,
836
- sessions: PlanSessionMap,
1154
+ planCtx: PlanCtx,
837
1155
  sessionId: string,
838
1156
  projectDir: string,
839
- controllers: PlanAbortControllers,
840
1157
  signal: AbortSignal | undefined,
841
1158
  ): Promise<ActionResult> {
842
- // approve 已消费挂起审批,执行方式选择挂起前清 reviewState 并落盘(状态审查 P2-3):
843
- // 否则「awaiting 无挂起」窗口会让右区误导渲染降级态「等待 agent 重新提交审批」
844
- // (实际在等执行方式选择)。清后审批条 shouldRender=false 即消失,右区只剩 form。
845
- delete state.reviewState;
846
- delete state.reviewStateSource;
1159
+ // E6 同族状态门(退出/终局后任何动作都不该发生)
1160
+ if (!state.isActive) {
1161
+ return reviewErrorResult(
1162
+ "inactive",
1163
+ "Plan mode is not active (it already completed or exited) — nothing to complete. No plan has been approved for execution here — do not implement any changes. Briefly tell the user, then wait for further user instructions.",
1164
+ );
1165
+ }
1166
+ // 状态写走 transition()(D1):'approve' 双入口同边。D5:审批消费后、执行方式表单挂起前
1167
+ // 落盘 dispatching(取代「先清 reviewState」——挂起期间 derivePhase(dispatching)='approved'
1168
+ // 映射「③审阅确认·已完成」,阶段不倒退,F5 不复活;崩溃后 E3 按 state 查表可恢复)
1169
+ const movedApprove = applyPlanEvent(state, "approve");
1170
+ if (!movedApprove.ok) {
1171
+ // FSM 合法性兜底:未经审批闸口(planning/revising 等)不进执行方式选择——纠偏指回
1172
+ // submit-review(ok:false 不落盘、不执行副作用;执行前的用户审批闸口是结构性保证)
1173
+ return reviewErrorResult(
1174
+ "out-of-order",
1175
+ `The plan is in state '${state.state}' and has not been approved — do NOT execute anything. ` +
1176
+ `Present the plan for review first: when all documents are done, call plan(action='submit-review', selfReview='<your self-review>') and wait for the user's approval.`,
1177
+ );
1178
+ }
847
1179
  persistPlanState(pi, state);
848
1180
 
849
- const choice = await resolveCompleteChoice(ctx, controllers, sessionId, signal);
1181
+ const choice = await resolveCompleteChoice(ctx, planCtx, sessionId, signal);
850
1182
  if (choice.kind === "cancelled") {
851
- return choice.result;
1183
+ // 归口②(两归口点之二):via 判别**先于**解散源判别(later 是显式选择,
1184
+ // 不涉解散源判别)。读-转移-落盘同一同步临界段(禁闭包快照跨 await 复用)
1185
+ const current = getPlanState(planCtx.states, sessionId, ctx);
1186
+ if (choice.via === "later") {
1187
+ // later 边(D1):dispatching --later--> approved——用户显式「暂不执行」是明确选择,
1188
+ // **不是解散**,不得进 review_aborted/外部解散文案桶(否则 A9 反向重演)。
1189
+ const movedLater = applyPlanEvent(current, "later");
1190
+ // ok:false(fresh-read 后已非 dispatching,如 exitPlanMode 交错已落 exited)不落盘
1191
+ //(终态上不落盘 = 归口点双保险),走与下方 exec_chosen ok:false 同型的中性
1192
+ // state-changed 出口——此时复用 APPROVED 成功文案会与真实状态相反,误导 agent 下一步
1193
+ if (!movedLater.ok) {
1194
+ return reviewErrorResult(
1195
+ "out-of-order",
1196
+ "The plan state changed while the execution-method prompt was pending — nothing was dispatched and no changes were made. Check the current plan state with the user before proceeding.",
1197
+ );
1198
+ }
1199
+ persistPlanState(pi, current);
1200
+ return {
1201
+ content: [{
1202
+ type: "text" as const,
1203
+ text: `User chose: ${choice.chosenLabel}. The plan is APPROVED but NOT dispatched for execution — do not implement any changes yet. Stay available: when the user wants to execute (e.g. says "execute"), call plan(action='complete') again to choose an execution method.`,
1204
+ }],
1205
+ details: { action: "complete-later", choice: choice.chosenLabel },
1206
+ };
1207
+ }
1208
+ const detail = choice.message ? ` (${choice.message})` : "";
1209
+ if (choice.by === "self") {
1210
+ // 入口解散(exitPlanMode 打 'self' 标,reset 终态已由入口落盘)→ 归口 no-op,不落盘
1211
+ //(D-B1-3:via 侧迁移后与审批 select 同源判别——outcome 携带直传来源)
1212
+ return {
1213
+ content: [{
1214
+ type: "text" as const,
1215
+ text: "Plan mode has been exited while the execution-method prompt was pending, and the full tool set is restored. The plan has NOT been dispatched for execution — do not implement any changes. Briefly tell the user you have stopped, then wait for further user instructions.",
1216
+ }],
1217
+ details: { action: "complete-cancelled", reason: choice.reason, source: "self" },
1218
+ };
1219
+ }
1220
+ // 外部解散(turn abort 级联 / 表单取消 / 超时 / 通道故障折叠):
1221
+ // dispatching --review_aborted--> approved(批准事实保留——不把「已批准」打回规划期,F5 不复活)
1222
+ const movedDissolve = applyPlanEvent(current, "review_aborted");
1223
+ if (movedDissolve.ok) persistPlanState(pi, current);
1224
+ return {
1225
+ content: [{
1226
+ type: "text" as const,
1227
+ text: `The execution-method choice was interrupted (${choice.reason}${detail}). Plan mode stays active and the plan remains APPROVED. The plan has NOT been dispatched — do not implement any changes. Wait for the user's instructions; when they want to execute, call plan(action='complete') again to choose an execution method.`,
1228
+ }],
1229
+ details: { action: "complete-cancelled", reason: choice.reason, source: "external" },
1230
+ };
852
1231
  }
853
1232
  const chosenMode = choice.chosenMode;
854
1233
 
855
1234
  // D6:原「persist final phase (complete)」为死状态落盘(P1 实证不可观测),
856
1235
  // phase 删除后该 persist 与上一条 entry 完全重复,随死状态一并移除——
857
- // 最终态由下方 resetPlanState 的 isActive=false entry 权威记录。
1236
+ // 最终态由下方 resetPlanState 的终态 entry 权威记录。
858
1237
  const planFilePath = state.planFilePath;
859
- const isolation = (params.isolation as string) ?? "direct";
1238
+
1239
+ // exec_chosen(D1):dispatching --exec_chosen--> completed(选定执行方式并派发,终态)。
1240
+ // 副作用内联在转移成功后;ok:false(reset 介入等异常格)→ 不派发不落盘的降级出口
1241
+ const movedExec = applyPlanEvent(state, "exec_chosen");
1242
+ if (!movedExec.ok) {
1243
+ return reviewErrorResult(
1244
+ "out-of-order",
1245
+ "The plan state changed while the execution-method prompt was pending — nothing was dispatched and no changes were made. Check the current plan state with the user before proceeding.",
1246
+ );
1247
+ }
860
1248
 
861
1249
  // Restore full tool set
862
1250
  restoreFullToolSet(pi);
863
1251
 
864
- // Execute completion handler (compact setup + steer/goalInit delivery)
865
- const goalOutcome = handlePlanComplete(pi, ctx, state, isolation, chosenMode, choice.skillEntryPath);
1252
+ // Execute completion handler (steer/goalInit delivery)
1253
+ const goalOutcome = handlePlanComplete(pi, ctx, state, chosenMode, choice.skillEntryPath);
866
1254
 
867
- // Reset state and clear widget — same as abort
868
- const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
1255
+ // 终局 reset(D3 连带段):terminal 传 'completed'——防 reset 覆写 completed 终态
1256
+ const updatedState = resetPlanState(pi, planCtx.states, sessionId, ctx, "completed");
869
1257
  updatePlanWidget(ctx, updatedState);
870
1258
 
871
1259
  const displayPath = relativePath(planFilePath, projectDir);
872
1260
  return {
873
- content: [{ type: "text" as const, text: completeResultText(displayPath, goalOutcome) }],
874
- details: { action: "complete", planFilePath: displayPath, isolation, execMode: chosenMode, goalOutcome },
1261
+ content: [{ type: "text" as const, text: completeResultText(displayPath, goalOutcome, choice.pickedBy) }],
1262
+ details: {
1263
+ action: "complete",
1264
+ planFilePath: displayPath,
1265
+ execMode: chosenMode,
1266
+ execModeSource: choice.pickedBy,
1267
+ goalOutcome,
1268
+ },
875
1269
  };
876
1270
  }
877
1271
 
@@ -879,12 +1273,19 @@ async function executeComplete(
879
1273
 
880
1274
  export function registerPlanTool(
881
1275
  pi: ExtensionAPI,
882
- sessions: PlanSessionMap,
883
- controllers: PlanAbortControllers,
1276
+ planCtx: PlanCtx,
884
1277
  ): void {
885
1278
  pi.registerTool({
886
1279
  name: "plan",
887
1280
  label: "Plan Mode",
1281
+ // 串行声明(D-B1-4):全部 action 就地突变共享 PlanState,声明串行消除并行交错类
1282
+ //(A1 死锁机理的调度半边)。pi 0.84.4 声明粒度是工具级——同批任一 sequential 工具
1283
+ // 使整批工具顺序执行。pi 实装锚点:dist/core/extensions/types.d.ts:363-370(0.84.4,
1284
+ // executionMode 是 per-tool 声明,"sequential" = this tool must execute one at a time
1285
+ // with other tool calls)+ @earendil-works/pi-agent-core dist/agent-loop.js:287-288
1286
+ //(0.84.4,hasSequentialToolCall = toolCalls.some(...) 命中即整批走
1287
+ // executeToolCallsSequential)。单行回退通道 = 移除本声明即回默认并行。
1288
+ executionMode: "sequential",
888
1289
  description:
889
1290
  "Manages plan mode lifecycle (enter, template selection, document registration, review, state transitions). " +
890
1291
  "NOT for writing document content — write documents via the bash tool (e.g. cat heredoc). " +
@@ -896,9 +1297,13 @@ export function registerPlanTool(
896
1297
  templateName: Type.Optional(Type.String({ description: "Template name (for select-template)" })),
897
1298
  fileName: Type.Optional(Type.String({ description: "Document file name to register (for register-doc, e.g. 'design.md')" })),
898
1299
  sourceSkill: Type.Optional(Type.String({ description: "Name of the mounted skill that produced this document (for register-doc; omit in template flow)" })),
899
- isolation: Type.Optional(
900
- StringEnum(["compact", "direct"], {
901
- description: "Isolation mode for plan execution (for complete action)",
1300
+ selfReview: Type.Optional(
1301
+ Type.String({
1302
+ description:
1303
+ "Your self-review conclusions (REQUIRED for submit-review, no exceptions — including re-submissions after revisions). " +
1304
+ "Before submitting: check every requirement item is covered, audit assumptions ([UNVERIFIED] cleared or explicitly listed), " +
1305
+ "check chapter completeness against the template, and verify the acceptance scenarios are executable. " +
1306
+ "Fix document issues found during self-review first, then pass the conclusions here.",
902
1307
  }),
903
1308
  ),
904
1309
  }),
@@ -913,8 +1318,8 @@ export function registerPlanTool(
913
1318
  "- enter — enter plan mode (self-service; requirement + optional skills)\n" +
914
1319
  "- select-template — template selection\n" +
915
1320
  "- register-doc — register a produced document (call after writing each deliverable; re-call after revisions to bump its version)\n" +
916
- "- submit-review — all documents done, request user review\n" +
917
- "- complete — user approved plan, exit plan mode\n" +
1321
+ "- submit-review — all documents done, request user review (selfReview is REQUIRED: your self-review conclusions first)\n" +
1322
+ "- complete — user approved plan, choose the execution method and exit plan mode\n" +
918
1323
  "- abort — cancel plan mode\n" +
919
1324
  "\n" +
920
1325
  "Use the bash tool for ALL document content: writing files, updating chapters (e.g. cat heredoc).\n" +
@@ -923,9 +1328,12 @@ export function registerPlanTool(
923
1328
  "1. plan(action='enter', requirement='add dark mode') — you (the agent) enter plan mode\n" +
924
1329
  "2. Explore codebase (read, grep, bash) — brainstorming\n" +
925
1330
  "3. Write each document, then plan(action='register-doc', fileName='...') for it\n" +
926
- "4. plan(action='submit-review') — user reviews in the review UI or conversation\n" +
927
- "5. Address revision comments (rewrite + re-register), re-submit until approved\n" +
928
- "6. plan(action='complete', isolation='compact') — exit plan mode\n" +
1331
+ "4. Self-review (coverage / assumptions / completeness / acceptance), fix issues, then\n" +
1332
+ " plan(action='submit-review', selfReview='<your self-review conclusions>') — user reviews in the review UI or conversation\n" +
1333
+ "5. Address revision comments (rewrite + re-register), redo the self-review for the revised documents, re-submit until approved\n" +
1334
+ "6. plan(action='complete') — choose the execution method and exit plan mode\n" +
1335
+ "\n" +
1336
+ "When the user asks to re-submit the plan review (e.g. a re-submit notice), call plan(action='submit-review', selfReview='...') immediately — carry back your previous self-review verbatim when the documents are unchanged.\n" +
929
1337
  "\n" +
930
1338
  "❌ plan(action='complete') to 'write the plan' — WRONG, write documents via the bash tool\n" +
931
1339
  "✅ plan(action='complete') AFTER documents are written AND user approves",
@@ -949,12 +1357,12 @@ export function registerPlanTool(
949
1357
  }
950
1358
 
951
1359
  const sessionId = ctx.sessionManager.getSessionId();
952
- const state = getPlanState(sessions, sessionId, ctx);
1360
+ const state = getPlanState(planCtx.states, sessionId, ctx);
953
1361
  const projectDir = ctx.cwd;
954
1362
 
955
1363
  switch (action) {
956
1364
  case "enter":
957
- return executeEnter(pi, params, state, sessions, sessionId, ctx);
1365
+ return executeEnter(pi, params, state, planCtx, sessionId, ctx);
958
1366
 
959
1367
  case "select-template":
960
1368
  return executeSelectTemplate(pi, params, state, projectDir);
@@ -963,13 +1371,13 @@ export function registerPlanTool(
963
1371
  return executeRegisterDoc(pi, params, state);
964
1372
 
965
1373
  case "submit-review":
966
- return await executeSubmitReview(pi, ctx, state, sessions, sessionId, projectDir, controllers, signal);
1374
+ return await executeSubmitReview(pi, ctx, state, planCtx, sessionId, projectDir, signal, params);
967
1375
 
968
1376
  case "complete":
969
- return await executeComplete(pi, ctx, params, state, sessions, sessionId, projectDir, controllers, signal);
1377
+ return await executeComplete(pi, ctx, params, state, planCtx, sessionId, projectDir, signal);
970
1378
 
971
1379
  case "abort":
972
- return executeAbort(pi, sessions, sessionId, ctx);
1380
+ return executeAbort(pi, planCtx, sessionId, ctx, state);
973
1381
  }
974
1382
  },
975
1383
  });