@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/tool.ts CHANGED
@@ -1,25 +1,51 @@
1
+ import * as path from "node:path";
2
+
1
3
  import { StringEnum } from "@earendil-works/pi-ai";
2
4
  import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
3
5
  import { Text } from "@earendil-works/pi-tui";
4
- import { firstContentText } from "@zhushanwen/extension-protocol";
6
+ import {
7
+ firstContentText,
8
+ PLAN_REVIEW_MARKER,
9
+ uiFormInteract,
10
+ } from "@zhushanwen/extension-protocol";
11
+ import type {
12
+ ChoiceQuestion,
13
+ PlanDocMeta,
14
+ PlanReviewRequest,
15
+ PlanReviewResponse,
16
+ } from "@zhushanwen/extension-protocol";
17
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
5
18
  import { Type } from "typebox";
6
19
 
7
- import { detectGoalCapability, GOAL_FAILURE_RECOVERY, handlePlanComplete } from "./compact.js";
20
+ import { GOAL_FAILURE_RECOVERY, handlePlanComplete } from "./compact.js";
8
21
  import type { GoalBridgeOutcome } from "./compact.js";
9
- import type { PlanSessionMap, PlanState } from "./state.js";
10
- import { getPlanState, persistPlanState, resetPlanState } from "./state.js";
22
+ import { activatePlanMode, resolveSkills } from "./enter.js";
23
+ import { detectExecSkills } from "./exec-skills.js";
24
+ import type { ExecSkill } from "./exec-skills.js";
25
+ import { t } from "./i18n.js";
26
+ import { formatReviewComments } from "./prompts.js";
27
+ 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";
11
30
  import { listTemplates, loadTemplate } from "./templates.js";
12
31
  import { updatePlanWidget } from "./widget.js";
13
32
 
33
+ const logger = getLogger("pi-plan");
34
+
14
35
  // ── Action types ───────────────────────────────────────────────────
15
36
 
16
37
  export const PLAN_ACTIONS = [
17
- "list-template",
38
+ "enter",
18
39
  "select-template",
19
40
  "complete",
20
41
  "abort",
42
+ "register-doc",
43
+ "submit-review",
21
44
  ] as const;
22
45
 
46
+ // PLAN_MODE_TOOLS 已迁至 state.ts(叶模块,避免 enter↔tool 循环依赖);此处再导出保持兼容
47
+ export { PLAN_MODE_TOOLS } from "./state.js";
48
+
23
49
  export type PlanAction = (typeof PLAN_ACTIONS)[number];
24
50
 
25
51
  export function validateAction(action: string): action is PlanAction {
@@ -28,15 +54,15 @@ export function validateAction(action: string): action is PlanAction {
28
54
 
29
55
  // ── Details types ──────────────────────────────────────────────────
30
56
 
31
- interface ListTemplateDetails {
32
- action: "list-template";
33
- templates: Array<{ name: string; path: string }>;
34
- }
35
-
36
57
  interface SelectTemplateDetails {
37
58
  action: "select-template";
38
59
  templateName: string;
39
- content: string;
60
+ }
61
+
62
+ interface EnterDetails {
63
+ action: "enter";
64
+ requirement: string;
65
+ skills: string[];
40
66
  }
41
67
 
42
68
  interface CompleteDetails {
@@ -57,12 +83,39 @@ interface AbortDetails {
57
83
  action: "abort";
58
84
  }
59
85
 
86
+ interface RegisterDocDetails {
87
+ action: "register-doc";
88
+ fileName: string;
89
+ version: number;
90
+ }
91
+
92
+ interface SubmitReviewDetails {
93
+ action: "submit-review";
94
+ /** gui = taiji 形态挂 PLAN_REVIEW_MARKER select;text = 独立 pi 软门(E8) */
95
+ channel: "gui" | "text";
96
+ docsCount: number;
97
+ /**
98
+ * 重提交无变化信号:false = 本次提交与上次 submit-review 之间无任何 register-doc
99
+ * (警告行已追加到 result 文本)。正常提交不携带该字段(缺失 = 无警告)。
100
+ */
101
+ changed?: false;
102
+ }
103
+
104
+ /** 审阅闭环的失败出口(E5/E6/取消)——details 与 content 文本都带恢复动作 */
105
+ interface ReviewErrorDetails {
106
+ action: "review-error";
107
+ reason: "no-docs" | "inactive" | "bad-response" | "cancelled";
108
+ }
109
+
60
110
  type PlanDetails =
61
- | ListTemplateDetails
111
+ | EnterDetails
62
112
  | SelectTemplateDetails
63
113
  | CompleteDetails
64
114
  | CompleteCancelledDetails
65
- | AbortDetails;
115
+ | AbortDetails
116
+ | RegisterDocDetails
117
+ | SubmitReviewDetails
118
+ | ReviewErrorDetails;
66
119
 
67
120
  // ── Helpers ────────────────────────────────────────────────────────
68
121
 
@@ -72,30 +125,6 @@ function restoreFullToolSet(pi: ExtensionAPI): void {
72
125
  pi.setActiveTools(allToolNames);
73
126
  }
74
127
 
75
- /** Compact template list for TUI display. Two-column, max 5 lines. */
76
- function formatTemplateList(templates: Array<{ name: string }>): string {
77
- const names = templates.map((t) => t.name);
78
- if (names.length === 0) return "No templates available.";
79
-
80
- const MAX_DISPLAY = 8;
81
- const HALF = 2;
82
- const truncated = names.length > MAX_DISPLAY;
83
- const display = names.slice(0, MAX_DISPLAY);
84
-
85
- // Two-column layout
86
- const half = Math.ceil(display.length / HALF);
87
- const col1 = display.slice(0, half);
88
- const col2 = display.slice(half);
89
- const lines: string[] = [];
90
- for (let i = 0; i < half; i++) {
91
- const right = col2[i] ? ` ${half + i + 1} ${col2[i]}` : "";
92
- lines.push(` ${i + 1} ${col1[i] ?? ""}` + right);
93
- }
94
-
95
- if (truncated) lines.push(` ... ${names.length - MAX_DISPLAY} more`);
96
- return lines.join("\n");
97
- }
98
-
99
128
  /** Relative path from project dir */
100
129
  function relativePath(fullPath: string, projectDir: string): string {
101
130
  if (fullPath.startsWith(projectDir)) {
@@ -104,6 +133,47 @@ function relativePath(fullPath: string, projectDir: string): string {
104
133
  return fullPath;
105
134
  }
106
135
 
136
+ /**
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 并纳入恒注入,不得沿用本名。
150
+ */
151
+ function isTaijiHost(): boolean {
152
+ return process.env.TAIJI_AGENT_EXT_LOG === "1";
153
+ }
154
+
155
+ /**
156
+ * 重提交无变化警告(E8 机制级兜底):独立 pi 实测(mimo-v2.5-pro,干净 session × 3)
157
+ * LLM 收到用户修改意见后不 rewrite/re-register 直接重调 submit-review——result 文本
158
+ * 已带的修订闭环指令不足以纠正,升级为确定性检测(docs 快照指纹比对)后逐次警告。
159
+ * 提示词级引导保留:警告行是追加信号,不替换原有闭环指令。
160
+ */
161
+ const UNCHANGED_RESUBMIT_WARNING =
162
+ "Note: no documents changed since the last submit-review. " +
163
+ "If the user requested changes, you MUST rewrite the file(s) and re-register each via plan(action='register-doc') BEFORE calling submit-review again.";
164
+
165
+ /** 警告命中时把警告行追加为 result 文本末行(「追加一行」契约),未命中原文返回 */
166
+ function withUnchangedWarning(text: string, unchangedResubmit: boolean): string {
167
+ return unchangedResubmit ? `${text}\n${UNCHANGED_RESUBMIT_WARNING}` : text;
168
+ }
169
+
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 };
175
+ }
176
+
107
177
  // ── renderResult ───────────────────────────────────────────────────
108
178
 
109
179
  function renderPlanResult(
@@ -120,11 +190,11 @@ function renderPlanResult(
120
190
  const NL = "\n";
121
191
 
122
192
  switch (details.action) {
123
- case "list-template": {
124
- const header = fg("accent", `${details.templates.length} 个模板可用`) + NL;
125
- const body = formatTemplateList(details.templates) + NL;
126
- const hint = fg("dim", "→ plan(select-template, templateName='xxx')");
127
- return new Text(header + body + hint, 0, 0);
193
+ case "enter": {
194
+ const header = fg("success", `✓ 已进入计划模式`) + NL;
195
+ const skillsLine = details.skills.length > 0 ? fg("dim", ` 技能: ${details.skills.join(" · ")}`) + NL : "";
196
+ const hint = fg("dim", " 只读规划:读代码、产文档,不改源码");
197
+ return new Text(header + skillsLine + hint, 0, 0);
128
198
  }
129
199
 
130
200
  case "select-template": {
@@ -151,43 +221,159 @@ function renderPlanResult(
151
221
  const body = fg("dim", " 工具集已恢复");
152
222
  return new Text(header + body, 0, 0);
153
223
  }
224
+
225
+ case "register-doc": {
226
+ const header = fg("success", `✓ ${details.fileName} (v${details.version})`) + NL;
227
+ const hint = fg("dim", "→ 产物已登记 · 全部完成后 plan(submit-review)");
228
+ return new Text(header + hint, 0, 0);
229
+ }
230
+
231
+ case "submit-review": {
232
+ const header = fg("success", `✓ 审阅请求已提交(${details.docsCount} 份文档)`) + NL;
233
+ const body = details.channel === "gui"
234
+ ? fg("dim", " → 等待用户在 GUI 审批条操作")
235
+ : fg("dim", " → 文档已就绪,等待用户在对话中反馈");
236
+ return new Text(header + body, 0, 0);
237
+ }
238
+
239
+ case "review-error": {
240
+ const header = fg("warning", "✗ 审阅请求未提交") + NL;
241
+ const body = fg("dim", ` ${details.reason}`);
242
+ return new Text(header + body, 0, 0);
243
+ }
244
+
245
+ // 兜底:旧版本持久化 entry 的 details 形态(如已删除的 list-template)不在
246
+ // 现版 PlanDetails 联合内——switch 落空返回 undefined 会让 pi TUI 渲染循环
247
+ // 对 undefined 调 .render() 直接 TypeError(pi-tui box.js render 无守卫)。
248
+ // 历史会话重开同样要渲染旧 entry,任意历史形态都必须产出组件。
249
+ default:
250
+ return new Text(firstContentText(result), 0, 0);
154
251
  }
155
252
  }
156
253
 
157
254
  // ── Action executors (one per switch case) ─────────────────────────
158
255
 
256
+ /**
257
+ * 把 pi execute 的 turn abort 信号级联到挂起 select 的 controller(对齐同协议
258
+ * 家族 ask-user / scheduler 的 signal 透传):turn abort(用户 stop / goal 取消 /
259
+ * compaction)时解散挂起的 submit-review / complete 对话框——select resolve
260
+ * undefined 走各处既有的 cancelled 分支,语义与 controllers registry 联动 abort
261
+ * 一致。controller 已 settled 后再 abort 是 no-op;listener 挂在 turn 生命周期
262
+ * 内的 signal 上且 once,无跨 turn 泄漏。
263
+ */
264
+ function cascadeTurnAbort(controller: AbortController, signal: AbortSignal | undefined): void {
265
+ if (signal?.aborted) {
266
+ // 进入 execute 时 turn 已 abort:直接置 abort 态,select 首行短路 resolve undefined
267
+ controller.abort();
268
+ return;
269
+ }
270
+ signal?.addEventListener("abort", () => controller.abort(), { once: true });
271
+ }
272
+
159
273
  /** Execute result envelope (shared shape returned by every action). */
160
274
  interface ActionResult {
161
275
  content: Array<{ type: "text"; text: string }>;
162
276
  details: PlanDetails;
163
277
  }
164
278
 
165
- function executeListTemplate(): ActionResult {
166
- const templates = listTemplates();
279
+ /**
280
+ * enter(plan-mode-agent-enter U1):agent 自动进入 plan 模式,无需用户确认。
281
+ * plan 模式是只读子集(只读代码、产文档、不改源码),进入它不是危险操作,
282
+ * 故不设确认闸门——进入事实由 GUI PlanModeBar 显形(投影链广播 isActive=true),
283
+ * 用户随时可经 PlanModeBar 退出。进入核心复用 activatePlanMode(与 slash 命令同源);
284
+ * plan 模式提示词经 tool result content 直返(对本次调用的直接响应,同轮即见,
285
+ * 不走对话流消息注入/steer 排队——slash 入口才经 sendMessage 注入)。已在 plan
286
+ * 模式时幂等返回,不重复进入。
287
+ */
288
+ function executeEnter(
289
+ pi: ExtensionAPI,
290
+ params: Record<string, unknown>,
291
+ state: PlanState,
292
+ sessions: PlanSessionMap,
293
+ sessionId: string,
294
+ ctx: ExtensionContext,
295
+ ): ActionResult {
296
+ if (state.isActive) {
297
+ return {
298
+ content: [{
299
+ type: "text" as const,
300
+ text: "Already in plan mode. Continue: write each deliverable into the plan directory, register it via plan(action='register-doc'), then call plan(action='submit-review') when all are done.",
301
+ }],
302
+ details: { action: "enter", requirement: state.requirement, skills: state.skills },
303
+ };
304
+ }
305
+
306
+ const requirement = typeof params.requirement === "string" ? params.requirement.trim() : "";
307
+ // skills 可选(数组逐项 string 白名单);缺失/空 = 不挂载技能走模板流程
308
+ const requestedSkills = Array.isArray(params.skills)
309
+ ? params.skills.filter((s): s is string => typeof s === "string")
310
+ : [];
311
+ let resolved: SkillRef[] = [];
312
+ if (requestedSkills.length > 0) {
313
+ const resolution = resolveSkills(pi, requestedSkills);
314
+ if (!resolution.ok) {
315
+ // 未知技能名 = 使用错误(同 executeSelectTemplate 错名先例 throw,带可用清单自愈)
316
+ throw new Error(
317
+ `Unknown skill(s): ${resolution.missing.join(", ")}. Available skills: ${resolution.available.join(", ") || "(none)"}. ` +
318
+ `Retry plan(action='enter') with valid skill names or omit the skills parameter.`,
319
+ );
320
+ }
321
+ resolved = resolution.resolved;
322
+ }
323
+
324
+ const { prompt } = activatePlanMode(pi, sessions, sessionId, ctx, {
325
+ requirement,
326
+ skills: resolved,
327
+ projectDir: ctx.cwd,
328
+ });
167
329
  return {
168
- content: [{ type: "text" as const, text: `${templates.length} templates available` }],
169
- details: { action: "list-template", templates },
330
+ content: [{ type: "text" as const, text: prompt }],
331
+ details: { action: "enter", requirement, skills: resolved.map((s) => s.name) },
170
332
  };
171
333
  }
172
334
 
335
+ /**
336
+ * select-template(D7):三源合并视图解析 + content 携带胜者文件全文。
337
+ * - 合并视图与注入段同源:两轨都以 ctx.cwd 为 projectRoot 调 listTemplates
338
+ * (注入段经 PlanPromptInput.projectRoot 由命令层显式传入,不从 planFilePath
339
+ * 逆推层级)——模型看到什么清单就能选中什么(含用户级/项目级投放)。
340
+ * - content 全文直达模型可见通道(现状全文放 details 不进模型,选完没骨架——
341
+ * §2.2 第二处错位收口);details 不再携带全文(零消费方,避免双份持久化)。
342
+ * - 错名报错带可用名字清单:模型当场从报错自愈,无需任何查询 action(D3)。
343
+ * - --template 直传防御:模板已由用户指定并全文内嵌注入,select-template 是
344
+ * 画蛇添足——报错不带三源清单(直传文件不在清单里,清单会误导改选内置
345
+ * 模板、偏离用户意图)。
346
+ */
173
347
  function executeSelectTemplate(
174
348
  pi: ExtensionAPI,
175
349
  params: Record<string, unknown>,
176
350
  state: PlanState,
351
+ projectDir: string,
177
352
  ): ActionResult {
178
353
  const templateName = params.templateName as string;
179
354
  if (!templateName) {
180
355
  throw new Error("templateName is required for select-template");
181
356
  }
182
- const content = loadTemplate(templateName);
183
- if (!content) {
184
- throw new Error(`Template not found: ${templateName}`);
357
+ if (state.templateProvidedPath !== undefined) {
358
+ throw new Error("template was provided via --template, write the plan following the file above");
359
+ }
360
+ const templates = listTemplates({ projectRoot: projectDir });
361
+ const winner = templates.find((t) => t.name === templateName);
362
+ if (!winner) {
363
+ throw new Error(`Template not found: ${templateName}. Available: ${templates.map((t) => t.name).join(", ")}`);
364
+ }
365
+ const content = loadTemplate(templateName, { projectRoot: projectDir });
366
+ if (content === null) {
367
+ throw new Error(`Template not readable: ${winner.path}`);
185
368
  }
186
369
  state.templateName = templateName;
187
370
  persistPlanState(pi, state);
188
371
  return {
189
- content: [{ type: "text" as const, text: `Template selected: ${templateName}` }],
190
- details: { action: "select-template", templateName, content },
372
+ content: [{
373
+ type: "text" as const,
374
+ text: `Template selected: ${templateName} (${winner.path}). Write the plan following the template's chapter structure below.\n\n<template>\n${content}\n</template>`,
375
+ }],
376
+ details: { action: "select-template", templateName },
191
377
  };
192
378
  }
193
379
 
@@ -207,60 +393,425 @@ function executeAbort(
207
393
  }
208
394
 
209
395
  /**
210
- * 执行方式对话框的 label→mode 映射表(发现 8)——SDK `ui.select(title, options: string[])`
211
- * 只收字符串数组,无法传结构化选项,故用本地查表而非文案反查。
396
+ * register-doc(D10 保留独立 action):登记单份产物文档。同 fileName 重登 =
397
+ * version+1 原位覆盖(drawer tab 顺序不跳动)。absPath 由 extension 从 plan
398
+ * 目录推导(产物写盘位置本身是纪律的一部分,不信任 LLM 申报的路径)。
399
+ * isActive=false 时的守卫与 submit-review 同风格——前置条件不满足用 tool result
400
+ * 错误(错误即纠偏指令),不用 throw(throw 留给参数缺失类编程错误)。
401
+ */
402
+ function executeRegisterDoc(
403
+ pi: ExtensionAPI,
404
+ params: Record<string, unknown>,
405
+ state: PlanState,
406
+ ): ActionResult {
407
+ if (!state.isActive) {
408
+ return {
409
+ content: [{
410
+ type: "text" as const,
411
+ text: "Plan mode is not active — there is nothing to register. Start a plan with /plan <requirement> first.",
412
+ }],
413
+ details: { action: "review-error", reason: "inactive" },
414
+ };
415
+ }
416
+
417
+ const fileName = typeof params.fileName === "string" ? params.fileName.trim() : "";
418
+ if (!fileName) {
419
+ throw new Error("fileName is required for register-doc (e.g. plan(action='register-doc', fileName='design.md'))");
420
+ }
421
+ // 穿越守卫:fileName 必须是 plan 目录内的纯文件名(absPath = join(planDir, fileName),
422
+ // 放开分隔符/'..' 可把 plan 目录外任意可读文件挂进用户审阅界面——PlanDocsPanel 文档
423
+ // tab 按 absPath 直读渲染)。参数形态错误 throw 带纠正样例(同上方缺参分支风格)。
424
+ if (fileName.includes("/") || fileName.includes("\\") || fileName === "." || fileName === "..") {
425
+ throw new Error(
426
+ "fileName must be a plain file name inside the plan directory — path separators and '.'/'..' segments are not allowed (e.g. plan(action='register-doc', fileName='design.md'))",
427
+ );
428
+ }
429
+ const sourceSkill = typeof params.sourceSkill === "string" ? params.sourceSkill.trim() : "";
430
+
431
+ const existing = state.docs.find((d) => d.fileName === fileName);
432
+ const version = existing ? existing.version + 1 : 1;
433
+ const meta: PlanDocMeta = {
434
+ fileName,
435
+ absPath: path.join(path.dirname(state.planFilePath), fileName),
436
+ sourceSkill,
437
+ version,
438
+ };
439
+
440
+ if (existing) {
441
+ // 原位置覆盖:drawer 文档 tab 按产出顺序展示,重登记不改变顺序
442
+ state.docs.splice(state.docs.indexOf(existing), 1, meta);
443
+ } else {
444
+ state.docs.push(meta);
445
+ }
446
+ persistPlanState(pi, state);
447
+
448
+ return {
449
+ content: [{ type: "text" as const, text: `Registered ${fileName} (v${version})` }],
450
+ details: { action: "register-doc", fileName, version },
451
+ };
452
+ }
453
+
454
+ /**
455
+ * PlanReviewResponse 形状守卫(E5 的垃圾数据判据):
456
+ * parse 成功但形状不合法同样按解析失败处理——垃圾数据不进对话流。
212
457
  */
213
- const EXEC_MODE_OPTIONS: Array<{ label: string; mode: string }> = [
214
- { label: "Subagent-driven execution", mode: "subagent" },
215
- { label: "Goal-driven execution (/goal)", mode: "goal" },
216
- { label: "Single-agent (current session)", mode: "single-agent" },
217
- ];
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
+ }
218
472
 
219
- /** 对话框尾部的两个"留在 plan mode"选项(complete-cancelled 路径) */
220
- const CANCEL_OPTIONS = ["Modify the plan first", "Save for later"];
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
+ }
221
481
 
222
- /** Build execution options filtered by available capabilities. */
223
- function buildExecOptions(): string[] {
224
- const modeLabels = EXEC_MODE_OPTIONS
225
- .filter((opt) => opt.mode !== "goal" || detectGoalCapability())
226
- .map((opt) => opt.label);
227
- return [...modeLabels, ...CANCEL_OPTIONS];
482
+ /** E5/E6/取消共用错误 result 构造(content 文本即恢复动作) */
483
+ function reviewErrorResult(reason: ReviewErrorDetails["reason"], recovery: string): ActionResult {
484
+ return {
485
+ content: [{ type: "text" as const, text: recovery }],
486
+ details: { action: "review-error", reason },
487
+ };
228
488
  }
229
489
 
230
- /** Map the user's execution-method choice (dialog label) to the chosenMode string. */
231
- function chosenModeFromChoice(choice: string): string {
232
- return EXEC_MODE_OPTIONS.find((opt) => opt.label === choice)?.mode ?? "single-agent";
490
+ /**
491
+ * submit-review(D5/E6/E8/E10):
492
+ * - E6 双守卫:isActive=false → 错误不挂 select;docs 空 → 错误提示先 register-doc。
493
+ * - 挂起前落 reviewState='awaiting'(崩溃恢复 E3 依赖此持久态)+ docs 快照指纹
494
+ * (重提交无变化检测基线,text/gui 两分支共用;快照缺失 = 无既往提交不警告)。
495
+ * - 宿主分流:taiji rpc 宿主(TAIJI_AGENT_EXT_LOG=1 且 mode==='rpc')发
496
+ * PLAN_REVIEW_MARKER select;其余形态(独立 pi / env 泄漏的非 rpc)返回 E8 文本软门。
497
+ * - select 挂 signal(E10),resolve 后 E5 解析守卫,再按 decision 消费。
498
+ */
499
+ async function executeSubmitReview(
500
+ pi: ExtensionAPI,
501
+ ctx: ExtensionContext,
502
+ state: PlanState,
503
+ sessions: PlanSessionMap,
504
+ sessionId: string,
505
+ projectDir: string,
506
+ controllers: PlanAbortControllers,
507
+ signal: AbortSignal | undefined,
508
+ ): Promise<ActionResult> {
509
+ // E6 双守卫:状态门优先于内容门(退出后任何动作都不该发生)。
510
+ // 文本语义按 A9 真机事故收紧:旧文「Wrap up the current task directly」被 LLM
511
+ // 误读为「已批准,开始实施」——未激活 ≠ 批准,必须显式禁止实施并指向等待用户。
512
+ if (!state.isActive) {
513
+ return reviewErrorResult(
514
+ "inactive",
515
+ "Plan mode is not active (it already exited, or no plan was started). No plan has been approved — do not implement any changes. Briefly tell the user that plan mode is no longer active, then wait for further user instructions.",
516
+ );
517
+ }
518
+ if (state.docs.length === 0) {
519
+ return reviewErrorResult(
520
+ "no-docs",
521
+ "No documents registered yet. Call plan(action='register-doc', fileName='...') for each deliverable document first, then call submit-review again.",
522
+ );
523
+ }
524
+
525
+ // 重提交无变化检测:指纹与上次快照相同 ⇔ 上次 submit-review 后无任何 register-doc。
526
+ // 快照缺失 = 无既往提交(首次 / reset 后新轮次 / 旧版 entry 重挂),不警告。
527
+ const fingerprint = planDocsFingerprint(state.docs);
528
+ const unchangedResubmit =
529
+ state.lastSubmitReviewDocsFingerprint !== undefined &&
530
+ state.lastSubmitReviewDocsFingerprint === fingerprint;
531
+
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";
539
+ state.lastSubmitReviewDocsFingerprint = fingerprint;
540
+ persistPlanState(pi, state);
541
+
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")) {
547
+ return {
548
+ content: [{
549
+ type: "text" as const,
550
+ text: withUnchangedWarning(
551
+ `Documents are ready for review (${state.docs.length} registered). ` +
552
+ `Tell the user the documents are ready and ask them to give feedback directly in the conversation. ` +
553
+ `When they request changes: rewrite the file for each comment, then re-register it via plan(action='register-doc') (version bumps so the UI refreshes) — after ALL comments are addressed, call plan(action='submit-review') again. ` +
554
+ `When the user is satisfied, they confirm and you call plan(action='complete').`,
555
+ unchangedResubmit,
556
+ ),
557
+ }],
558
+ details: submitReviewDetails("text", state.docs.length, unchangedResubmit),
559
+ };
560
+ }
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);
570
+
571
+ if (choice === undefined) {
572
+ // 取消分支覆盖两条路径:abort 联动(/plan abort,resetPlanState 已由命令
573
+ // handler 执行,本分支正常返回让 turn 结束)与 TUI 手动取消(reviewState
574
+ // 保持 awaiting)。语义按 A9 真机事故收紧:旧文「dismissed → 重挂」被 LLM
575
+ // 当成批准信号,重挂撞 inactive 守卫后开始实施源码改动——取消 ≠ 批准,
576
+ // 禁止重挂、禁止实施,停止审批循环等用户指示。
577
+ 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.",
580
+ );
581
+ }
582
+
583
+ // echo 检测(同 uiFormInteract 先例 extension-protocol ui-form/helpers):旧 taiji
584
+ // 宿主不识别 PLAN_REVIEW_MARKER 时 select 降级普通单选项,用户点选回显 payload 本身。
585
+ // 收包与发送 payload 逐字节相等 = 确定性识别该不支持组合;判定必须先于 parse
586
+ //(payload 是合法 JSON,parse 会成功但形状守卫必败)——否则 bad-response 分支引导
587
+ // 重挂 → 宿主同样回显 → 重复弹错循环。命中返回升级指引,不引导重挂。
588
+ if (choice === payload) {
589
+ logger.warn("plan: submit-review select echoed the request payload (host does not understand PLAN_REVIEW_MARKER)");
590
+ return reviewErrorResult(
591
+ "bad-response",
592
+ "The taiji host does not understand the plan review marker (taiji is older than this extension). " +
593
+ "Do NOT call submit-review again — the review dialog will fail the same way. " +
594
+ "Tell the user to upgrade taiji (or pin the plan extension version) and wait for their instructions.",
595
+ );
596
+ }
597
+
598
+ let response: PlanReviewResponse;
599
+ try {
600
+ response = parsePlanReviewResponse(choice);
601
+ } catch (error) {
602
+ // E5:解析失败 / 形状不合法 → logger.warn + 提示 agent 重挂审批;
603
+ // 不把可能损坏的数据按任何 decision 注入对话(垃圾数据不进流)
604
+ logger.warn("plan: submit-review select response parse failed", {
605
+ error: error instanceof Error ? error.message : String(error),
606
+ });
607
+ return reviewErrorResult(
608
+ "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.",
610
+ );
611
+ }
612
+
613
+ switch (response.decision) {
614
+ case "approve":
615
+ // approve → 走现状 complete 流程(执行方式 select 同样挂 signal)
616
+ return await executeComplete(pi, ctx, {}, state, sessions, sessionId, projectDir, controllers, signal);
617
+
618
+ case "revise": {
619
+ // custom message 形态注入:streaming 时显式
620
+ // deliverAs:'steer' 排队至下一次 LLM 调用(pi 实装锚点:dist/core/agent-session.js
621
+ // :859-868(0.84.4)——isStreaming 分支 steer 走 :868 _queueSteer;sendMessage 缺省
622
+ // deliverAs 同为 steer,显式传保持排队语义自明);非 streaming 由 triggerTurn:true 开轮
623
+ pi.sendMessage(
624
+ {
625
+ customType: PLAN_CONTEXT_CUSTOM_TYPE,
626
+ content: formatReviewComments(response.comments),
627
+ display: false,
628
+ },
629
+ { deliverAs: "steer", triggerTurn: true },
630
+ );
631
+ state.reviewState = "revising";
632
+ persistPlanState(pi, state);
633
+ return {
634
+ content: [{
635
+ type: "text" as const,
636
+ 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.`,
638
+ unchangedResubmit,
639
+ ),
640
+ }],
641
+ details: submitReviewDetails("gui", state.docs.length, unchangedResubmit),
642
+ };
643
+ }
644
+
645
+ default: {
646
+ // 判别联合穷尽性守卫:isPlanReviewResponse 已收窄 decision 值域,此处不可达;
647
+ // 编码期新增 decision 漏改 switch 会被 never 断言在编译期拦截
648
+ const unreachable: never = response;
649
+ throw new Error(`plan: unhandled review decision: ${JSON.stringify(unreachable)}`);
650
+ }
651
+ }
652
+ }
653
+
654
+ /**
655
+ * 执行方式选项(2026-09-21 用户裁决重排):选项集 = 检测到的 plan-exec skills
656
+ * (root 序前 2 个,label `用技能「name」执行`)+ 普通执行(execute 档——compact 侧
657
+ * 整合 goal 桥与 auto-parallel subagent,见 deliverExecutionNotice)+ 暂不执行
658
+ * (留在 plan mode)。四段固定结构,UI 文案经 i18n(ui-preferences locale 通道)。
659
+ * 动态构造:skill 项随 complete 时检测产出,label→mode 映射随选项集携带
660
+ * (`skill:<name>` 动态项不进静态表)。
661
+ */
662
+ interface ExecOption {
663
+ label: string;
664
+ mode: string;
665
+ description?: string;
666
+ /** skill 档携带(skill 入口文件路径,标准形态 SKILL.md / 散 .md 形态文件本身;CompleteChoiceOutcome 数据通路 → steer 文案 read 该路径) */
667
+ skillEntryPath?: string;
233
668
  }
234
669
 
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[] {
675
+ const options: ExecOption[] = execSkills.slice(0, MAX_SKILL_OPTIONS).map((skill) => ({
676
+ label: t("exec.viaSkill", { name: skill.name }),
677
+ mode: `skill:${skill.name}`,
678
+ description: skill.description ?? t("exec.viaSkillDesc", { name: skill.name }),
679
+ skillEntryPath: skill.skillEntryPath,
680
+ }));
681
+ options.push({
682
+ label: t("exec.execute"),
683
+ mode: "execute",
684
+ description: t("exec.executeDesc"),
685
+ });
686
+ options.push({
687
+ label: t("exec.later"),
688
+ mode: "later",
689
+ description: t("exec.laterDesc"),
690
+ });
691
+ return options;
692
+ }
693
+
694
+ /**
695
+ * 「暂不执行」档的 mode 值(选项集内的固定成员,label 经 i18n;选中即 complete-cancelled,
696
+ * 留在 plan mode——原 CANCEL_OPTIONS 英文双选项的收敛形态)。
697
+ */
698
+ const LATER_MODE = "later";
699
+
235
700
  /** Outcome of the complete-action execution-method prompt. */
236
701
  type CompleteChoiceOutcome =
237
702
  | { kind: "cancelled"; result: ActionResult }
238
- | { kind: "mode"; chosenMode: string };
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
+ }
239
734
 
240
735
  /**
241
- * Prompt the user for an execution method (no-op selection when headless).
242
- * Cancel / "Modify the plan first" / "Save for later" → cancelled with a
243
- * complete-cancelled result; otherwise the mapped chosenMode.
736
+ * Prompt the user for an execution method(D4 三路分流 + 2026-09-21 选项集重排):
737
+ * 1. `!ctx.hasUI`(print/json headless,noOp UI)→ 默认 execute,不进任何 select——
738
+ * 替换失效的 `typeof ctx.ui.select` 软门(noOp 的 select 是返回 undefined 的函数,
739
+ * 函数存在性不可判形态,pi runner.js 实码);
740
+ * 2. taiji rpc 宿主(TAIJI_AGENT_EXT_LOG=1 且 mode==='rpc')→ uiFormInteract 单
741
+ * choice 问题(FormOverlay 单视图);mode 收紧 rpc = helper 的 RPC-only 契约 +
742
+ * TUI 保留原生 select(D8)——env 异常置位的 TUI 落回第 3 路而非 throw;
743
+ * 3. else(TUI / 独立 pi)→ pi 原生 plain select。
744
+ * 四态折叠:cancelled/timeout → cancelled result;channel-error/non-json → 同折
745
+ * cancelled result + 通道失败说明(plan 是流程对话,通道故障不炸 turn 也不默认执行)。
746
+ * UI 文案(question/header/选项 label)经 i18n;answers key = header(协议 fallback
747
+ * 规则 key = header ?? question),header 与读取同源 t() 生成,本地化不破坏取值。
244
748
  */
245
- async function resolveCompleteChoice(ctx: ExtensionContext): Promise<CompleteChoiceOutcome> {
246
- const execOptions = buildExecOptions();
247
-
248
- if (typeof ctx.ui.select !== "function") {
249
- return { kind: "mode", chosenMode: "single-agent" };
749
+ async function resolveCompleteChoice(
750
+ ctx: ExtensionContext,
751
+ controllers: PlanAbortControllers,
752
+ sessionId: string,
753
+ signal: AbortSignal | undefined,
754
+ ): Promise<CompleteChoiceOutcome> {
755
+ if (!ctx.hasUI) {
756
+ return { kind: "mode", chosenMode: "execute" };
250
757
  }
251
- const choice = await ctx.ui.select("Plan is ready. Choose execution method:", execOptions);
252
- if (!choice || choice === "Modify the plan first" || choice === "Save for later") {
253
- return {
254
- kind: "cancelled",
255
- result: {
256
- content: [
257
- { type: "text" as const, text: `User chose: ${choice ?? "cancelled"}. Staying in plan mode.` },
258
- ],
259
- details: { action: "complete-cancelled", reason: choice ?? "cancelled" },
260
- },
758
+
759
+ // complete 时现扫 plan-exec skill(无缓存,技能热装可见;检测自带降级规格,
760
+ // 最坏 = skill 选项空集,绝不炸本流程)。选项构造时一次解析,skillEntryPath 随 outcome 流转
761
+ const execSkills = detectExecSkills({ cwd: ctx.cwd, trusted: ctx.isProjectTrusted() });
762
+ const execOptions = buildExecOptions(execSkills);
763
+
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,
261
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) };
793
+ }
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
+ }
802
+
803
+ if (!chosenLabel) {
804
+ return { kind: "cancelled", result: cancelledByUserResult(undefined) };
262
805
  }
263
- return { kind: "mode", chosenMode: chosenModeFromChoice(choice) };
806
+ const option = execOptions.find((opt) => opt.label === chosenLabel);
807
+ if (!option) {
808
+ // 选项集与 labels 同源构造,选中的 label 必在集内——找不到即编码 bug,fail-fast
809
+ throw new Error(`plan: unknown execution choice label: ${chosenLabel}`);
810
+ }
811
+ if (option.mode === LATER_MODE) {
812
+ return { kind: "cancelled", result: cancelledByUserResult(chosenLabel) };
813
+ }
814
+ return { kind: "mode", chosenMode: option.mode, skillEntryPath: option.skillEntryPath };
264
815
  }
265
816
 
266
817
  /**
@@ -272,8 +823,8 @@ function completeResultText(displayPath: string, goalOutcome: GoalBridgeOutcome
272
823
  const base = `Plan approved. File: ${displayPath}`;
273
824
  if (goalOutcome === undefined) return base;
274
825
  return goalOutcome.started
275
- ? `${base}\nGoal execution started via /goal.`
276
- : `${base}\nGoal execution was not started (${goalOutcome.reason}). ${GOAL_FAILURE_RECOVERY[goalOutcome.reason]}`;
826
+ ? `${base}\nGoal tracking started via /goal.`
827
+ : `${base}\nGoal tracking was not started (${goalOutcome.reason}). ${GOAL_FAILURE_RECOVERY[goalOutcome.reason]}`;
277
828
  }
278
829
 
279
830
  /** complete action: prompt for execution mode, restore tools, reset state. */
@@ -285,8 +836,17 @@ async function executeComplete(
285
836
  sessions: PlanSessionMap,
286
837
  sessionId: string,
287
838
  projectDir: string,
839
+ controllers: PlanAbortControllers,
840
+ signal: AbortSignal | undefined,
288
841
  ): Promise<ActionResult> {
289
- const choice = await resolveCompleteChoice(ctx);
842
+ // approve 已消费挂起审批,执行方式选择挂起前清 reviewState 并落盘(状态审查 P2-3):
843
+ // 否则「awaiting 无挂起」窗口会让右区误导渲染降级态「等待 agent 重新提交审批」
844
+ // (实际在等执行方式选择)。清后审批条 shouldRender=false 即消失,右区只剩 form。
845
+ delete state.reviewState;
846
+ delete state.reviewStateSource;
847
+ persistPlanState(pi, state);
848
+
849
+ const choice = await resolveCompleteChoice(ctx, controllers, sessionId, signal);
290
850
  if (choice.kind === "cancelled") {
291
851
  return choice.result;
292
852
  }
@@ -302,7 +862,7 @@ async function executeComplete(
302
862
  restoreFullToolSet(pi);
303
863
 
304
864
  // Execute completion handler (compact setup + steer/goalInit delivery)
305
- const goalOutcome = handlePlanComplete(pi, ctx, state, isolation, chosenMode);
865
+ const goalOutcome = handlePlanComplete(pi, ctx, state, isolation, chosenMode, choice.skillEntryPath);
306
866
 
307
867
  // Reset state and clear widget — same as abort
308
868
  const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
@@ -320,17 +880,22 @@ async function executeComplete(
320
880
  export function registerPlanTool(
321
881
  pi: ExtensionAPI,
322
882
  sessions: PlanSessionMap,
883
+ controllers: PlanAbortControllers,
323
884
  ): void {
324
885
  pi.registerTool({
325
886
  name: "plan",
326
887
  label: "Plan Mode",
327
888
  description:
328
- "Manages plan mode lifecycle (template selection, state transitions, completion). " +
329
- "NOT for writing plan content — write plan.md via the bash tool (e.g. cat heredoc). " +
330
- "Actions: list-template, select-template, complete, abort.",
889
+ "Manages plan mode lifecycle (enter, template selection, document registration, review, state transitions). " +
890
+ "NOT for writing document content — write documents via the bash tool (e.g. cat heredoc). " +
891
+ "Actions: enter, select-template, register-doc, submit-review, complete, abort.",
331
892
  parameters: Type.Object({
332
893
  action: StringEnum(PLAN_ACTIONS, { description: "Action to perform" }),
894
+ requirement: Type.Optional(Type.String({ description: "Plan requirement / task description (for enter)" })),
895
+ skills: Type.Optional(Type.Array(Type.String(), { description: "Skill names to mount (for enter; omit to use the template-discovery flow)" })),
333
896
  templateName: Type.Optional(Type.String({ description: "Template name (for select-template)" })),
897
+ fileName: Type.Optional(Type.String({ description: "Document file name to register (for register-doc, e.g. 'design.md')" })),
898
+ sourceSkill: Type.Optional(Type.String({ description: "Name of the mounted skill that produced this document (for register-doc; omit in template flow)" })),
334
899
  isolation: Type.Optional(
335
900
  StringEnum(["compact", "direct"], {
336
901
  description: "Isolation mode for plan execution (for complete action)",
@@ -338,23 +903,32 @@ export function registerPlanTool(
338
903
  ),
339
904
  }),
340
905
  promptSnippet:
906
+ "## Entering plan mode\n" +
907
+ "For large-scale refactoring, cross-module changes, or other high-risk work, proactively enter plan mode yourself: " +
908
+ "plan(action='enter', requirement='<what the user wants>', skills=[...]). Plan mode is read-only for source code — " +
909
+ "you read code and write plan documents, then the user reviews before any implementation. No user confirmation is needed to enter.\n" +
910
+ "\n" +
341
911
  "## When to use this tool vs the bash tool\n" +
342
912
  "Use 'plan' tool ONLY for plan mode state management:\n" +
343
- "- list-template / select-template — template operations\n" +
913
+ "- enter — enter plan mode (self-service; requirement + optional skills)\n" +
914
+ "- select-template — template selection\n" +
915
+ "- 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" +
344
917
  "- complete — user approved plan, exit plan mode\n" +
345
918
  "- abort — cancel plan mode\n" +
346
919
  "\n" +
347
- "Use the bash tool for ALL plan content: writing plan.md, updating plan chapters (e.g. cat heredoc).\n" +
920
+ "Use the bash tool for ALL document content: writing files, updating chapters (e.g. cat heredoc).\n" +
348
921
  "\n" +
349
922
  "## End-to-end workflow example\n" +
350
- "1. /plan 'add dark mode' — user enters plan mode\n" +
351
- "2. AI explores codebase (read, grep, bash) — brainstorming\n" +
352
- "3. plan(action='list-template') → user picks → plan(action='select-template', templateName='...')\n" +
353
- "4. bash: cat > \"$PLAN_FILE\" <<'EOF' ... EOF — write plan content\n" +
354
- "5. User reviews → plan(action='complete', isolation='compact') — exit plan mode\n" +
923
+ "1. plan(action='enter', requirement='add dark mode') — you (the agent) enter plan mode\n" +
924
+ "2. Explore codebase (read, grep, bash) — brainstorming\n" +
925
+ "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" +
355
929
  "\n" +
356
- "❌ plan(action='complete') to 'write the plan' — WRONG, write plan.md via the bash tool\n" +
357
- "✅ plan(action='complete') AFTER plan.md is written AND user approves",
930
+ "❌ plan(action='complete') to 'write the plan' — WRONG, write documents via the bash tool\n" +
931
+ "✅ plan(action='complete') AFTER documents are written AND user approves",
358
932
  renderResult(
359
933
  result: { content: Array<{ type: string; text?: string }>; details?: PlanDetails },
360
934
  options: unknown,
@@ -365,7 +939,7 @@ export function registerPlanTool(
365
939
  async execute(
366
940
  _toolCallId: string,
367
941
  params: Record<string, unknown>,
368
- _signal: AbortSignal | undefined,
942
+ signal: AbortSignal | undefined,
369
943
  _onUpdate: unknown,
370
944
  ctx: ExtensionContext,
371
945
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: PlanDetails }> {
@@ -379,14 +953,20 @@ export function registerPlanTool(
379
953
  const projectDir = ctx.cwd;
380
954
 
381
955
  switch (action) {
382
- case "list-template":
383
- return executeListTemplate();
956
+ case "enter":
957
+ return executeEnter(pi, params, state, sessions, sessionId, ctx);
384
958
 
385
959
  case "select-template":
386
- return executeSelectTemplate(pi, params, state);
960
+ return executeSelectTemplate(pi, params, state, projectDir);
961
+
962
+ case "register-doc":
963
+ return executeRegisterDoc(pi, params, state);
964
+
965
+ case "submit-review":
966
+ return await executeSubmitReview(pi, ctx, state, sessions, sessionId, projectDir, controllers, signal);
387
967
 
388
968
  case "complete":
389
- return await executeComplete(pi, ctx, params, state, sessions, sessionId, projectDir);
969
+ return await executeComplete(pi, ctx, params, state, sessions, sessionId, projectDir, controllers, signal);
390
970
 
391
971
  case "abort":
392
972
  return executeAbort(pi, sessions, sessionId, ctx);
@@ -394,3 +974,5 @@ export function registerPlanTool(
394
974
  },
395
975
  });
396
976
  }
977
+
978
+