@zhushanwen/pi-plan 0.5.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/package.json +7 -5
- package/skills/plan-ext-config/SKILL.md +33 -18
- package/src/__tests__/active-path.test.ts +204 -0
- package/src/__tests__/command.test.ts +55 -32
- package/src/__tests__/enter.test.ts +58 -20
- package/src/__tests__/exec-options-alignment.test.ts +56 -0
- package/src/__tests__/execution-notice.test.ts +197 -0
- package/src/__tests__/{compact.test.ts → extract-plan-steps.test.ts} +24 -15
- package/src/__tests__/fsm-consumption.test.ts +388 -0
- package/src/__tests__/index.test.ts +175 -75
- package/src/__tests__/prompts.test.ts +66 -0
- package/src/__tests__/review-contract-boundary.test.ts +123 -0
- package/src/__tests__/review.test.ts +264 -97
- package/src/__tests__/skills.test.ts +14 -5
- package/src/__tests__/state.test.ts +254 -52
- package/src/__tests__/{compact-criteria-array.test.ts → success-criteria-array.test.ts} +29 -70
- package/src/__tests__/templates.test.ts +8 -25
- package/src/__tests__/tool.test.ts +269 -121
- package/src/command.ts +26 -29
- package/src/enter.ts +31 -50
- package/src/{compact.ts → execution-notice.ts} +29 -117
- package/src/index.ts +100 -88
- package/src/prompts.ts +42 -12
- package/src/state.ts +290 -87
- package/src/templates.ts +0 -15
- package/src/tool.ts +664 -256
- package/src/__tests__/compact-handler.test.ts +0 -348
- package/src/__tests__/exec-skills.test.ts +0 -397
- package/src/exec-skills.ts +0 -407
package/src/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 "./
|
|
21
|
-
import type { GoalBridgeOutcome } from "./
|
|
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
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
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
|
-
/**
|
|
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
|
|
127
|
+
/** 审阅闭环的失败出口(E5/E6/取消/门拒收)——details 与 content 文本都带恢复动作 */
|
|
105
128
|
interface ReviewErrorDetails {
|
|
106
129
|
action: "review-error";
|
|
107
|
-
reason:
|
|
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,35 +170,21 @@ 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
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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
|
|
152
|
-
|
|
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
|
/**
|
|
156
|
-
* 重提交无变化警告(E8 机制级兜底):独立 pi
|
|
187
|
+
* 重提交无变化警告(E8 机制级兜底):独立 pi 实测(干净 session × 3)
|
|
157
188
|
* LLM 收到用户修改意见后不 rewrite/re-register 直接重调 submit-review——result 文本
|
|
158
189
|
* 已带的修订闭环指令不足以纠正,升级为确定性检测(docs 快照指纹比对)后逐次警告。
|
|
159
190
|
* 提示词级引导保留:警告行是追加信号,不替换原有闭环指令。
|
|
@@ -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
|
|
171
|
-
function submitReviewDetails(channel: "gui" | "text", docsCount: number
|
|
172
|
-
return
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
215
|
-
const
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
517
|
+
planCtx: PlanCtx,
|
|
383
518
|
sessionId: string,
|
|
384
519
|
ctx: ExtensionContext,
|
|
385
|
-
|
|
386
|
-
|
|
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:
|
|
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
|
|
456
|
-
*
|
|
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
|
-
* -
|
|
494
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
533
|
-
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
|
|
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
|
|
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
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
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
|
-
"
|
|
579
|
-
"The review was
|
|
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
|
-
|
|
825
|
+
// canonical 值域解析(consumers.md ①:本地守卫删迁移,值域单一权威不镜像):JSON 解析
|
|
826
|
+
// 与形状/值域守卫分离,unknown-decision 与 malformed 双分源降级(文案语义相反)
|
|
827
|
+
let parsed: unknown;
|
|
599
828
|
try {
|
|
600
|
-
|
|
829
|
+
parsed = JSON.parse(choice);
|
|
601
830
|
} catch (error) {
|
|
602
|
-
// E5:解析失败
|
|
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 →
|
|
616
|
-
|
|
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
|
|
929
|
+
details: submitReviewDetails("gui", state.docs.length),
|
|
642
930
|
};
|
|
643
931
|
}
|
|
644
932
|
|
|
645
933
|
default: {
|
|
646
|
-
// 判别联合穷尽性守卫:
|
|
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
|
|
657
|
-
* 整合 goal 桥与 auto-parallel subagent
|
|
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
|
-
/**
|
|
671
|
-
|
|
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;选中即
|
|
696
|
-
*
|
|
981
|
+
* 「暂不执行」档的 mode 值(选项集内的固定成员,label 经 i18n;选中即 later 边——
|
|
982
|
+
* dispatching --later--> approved,留在 plan mode)。
|
|
697
983
|
*/
|
|
698
984
|
const LATER_MODE = "later";
|
|
699
985
|
|
|
700
|
-
/**
|
|
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: "
|
|
703
|
-
| { kind: "
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
765
|
-
//
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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: "
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
804
|
-
|
|
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",
|
|
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 正文:
|
|
819
|
-
*
|
|
820
|
-
* 为设计 §6.2 D2 登记的终态)。
|
|
1124
|
+
* complete 的 result 正文:goalInit 同步完成,追加 goal 结果行(D2)。
|
|
1125
|
+
* D7② 直通(no-exec-skills)明示一行——工具结果文案不得静默吞掉「没弹表单」的事实。
|
|
821
1126
|
*/
|
|
822
|
-
function completeResultText(
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
1154
|
+
planCtx: PlanCtx,
|
|
837
1155
|
sessionId: string,
|
|
838
1156
|
projectDir: string,
|
|
839
|
-
controllers: PlanAbortControllers,
|
|
840
1157
|
signal: AbortSignal | undefined,
|
|
841
1158
|
): Promise<ActionResult> {
|
|
842
|
-
//
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
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,
|
|
1181
|
+
const choice = await resolveCompleteChoice(ctx, planCtx, sessionId, signal);
|
|
850
1182
|
if (choice.kind === "cancelled") {
|
|
851
|
-
|
|
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
|
|
1236
|
+
// 最终态由下方 resetPlanState 的终态 entry 权威记录。
|
|
858
1237
|
const planFilePath = state.planFilePath;
|
|
859
|
-
|
|
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 (
|
|
865
|
-
const goalOutcome = handlePlanComplete(pi, ctx, state,
|
|
1252
|
+
// Execute completion handler (steer/goalInit delivery)
|
|
1253
|
+
const goalOutcome = handlePlanComplete(pi, ctx, state, chosenMode, choice.skillEntryPath);
|
|
866
1254
|
|
|
867
|
-
//
|
|
868
|
-
const updatedState = resetPlanState(pi,
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
description:
|
|
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.
|
|
927
|
-
"
|
|
928
|
-
"
|
|
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(
|
|
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,
|
|
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,
|
|
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,
|
|
1377
|
+
return await executeComplete(pi, ctx, params, state, planCtx, sessionId, projectDir, signal);
|
|
970
1378
|
|
|
971
1379
|
case "abort":
|
|
972
|
-
return executeAbort(pi,
|
|
1380
|
+
return executeAbort(pi, planCtx, sessionId, ctx, state);
|
|
973
1381
|
}
|
|
974
1382
|
},
|
|
975
1383
|
});
|