@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.
@@ -18,7 +18,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
18
18
 
19
19
  import { parsePlanArgs, registerPlanCommand } from "../command.js";
20
20
  import { resolveSkills } from "../enter.js";
21
- import { PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
21
+ import { createPlanCtx, PLAN_CONTEXT_CUSTOM_TYPE } from "../state.js";
22
22
 
23
23
  const ALL_TOOL_NAMES = ["read", "bash", "grep", "find", "ls", "plan", "write", "edit"];
24
24
 
@@ -132,13 +132,22 @@ describe("resolveSkills(E1 技能枚举比对,双向剥 skill: 前缀归一
132
132
  expect(resolution.missing).toEqual(["tech-desig"]);
133
133
  }
134
134
  });
135
+
136
+ it("empty request = 同族 fail-fast:ok:false + 全量 available + 空 missing(空 --skills 报错材料复用本函数产出)", () => {
137
+ const resolution = resolveSkills(pi, []);
138
+ expect(resolution.ok).toBe(false);
139
+ if (!resolution.ok) {
140
+ // missing 空 → 报错器走「no skill names followed it」文案分支;available 同源枚举
141
+ expect(resolution.missing).toEqual([]);
142
+ expect(resolution.available).toEqual(["skill:tech-design", "skill:dev-flow", "skill:code review"]);
143
+ }
144
+ });
135
145
  });
136
146
 
137
147
  describe("E1 fail-fast via /plan handler", () => {
138
148
  let pi: ExtensionAPI;
139
149
  let ctx: ExtensionContext;
140
150
  let handler: (args: string, ctx: ExtensionContext) => Promise<void>;
141
- let controllers: Map<string, AbortController>;
142
151
 
143
152
  beforeEach(() => {
144
153
  vi.clearAllMocks();
@@ -157,6 +166,7 @@ describe("E1 fail-fast via /plan handler", () => {
157
166
  cwd: "/tmp/test-project",
158
167
  sessionManager: {
159
168
  getSessionId: () => "test-session",
169
+ getLeafId: () => null,
160
170
  getEntries: () => [] as unknown[],
161
171
  },
162
172
  ui: {
@@ -166,8 +176,7 @@ describe("E1 fail-fast via /plan handler", () => {
166
176
  theme: { fg: (_t: string, text: string) => text },
167
177
  },
168
178
  } as unknown as ExtensionContext;
169
- controllers = new Map();
170
- registerPlanCommand(pi, new Map(), controllers);
179
+ registerPlanCommand(pi, createPlanCtx());
171
180
  handler = capturedHandler!;
172
181
  });
173
182
 
@@ -221,7 +230,7 @@ describe("E1 fail-fast via /plan handler", () => {
221
230
  it("valid skills: enters plan mode, persists skills and injects skill paths in the prompt", async () => {
222
231
  await handler("重构 auth --skills tech-design,code review", ctx);
223
232
 
224
- expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan"]);
233
+ expect(pi.setActiveTools).toHaveBeenCalledWith(["read", "bash", "grep", "find", "ls", "plan", "ask_user"]);
225
234
  // slug 只保留 [a-z0-9]:「重构 auth」→ "auth"
226
235
  expect(fs.mkdirSync).toHaveBeenCalledWith("/tmp/test-project/.tmp/plans/auth", { recursive: true });
227
236
  expect(pi.appendEntry).toHaveBeenCalledWith(
@@ -3,12 +3,13 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { PLAN_SELF_REVIEW_MAX_BYTES } from "@zhushanwen/extension-protocol";
6
7
  import { afterEach, describe, expect, it, vi } from "vitest";
7
8
 
8
9
  import {
9
10
  capPlanRequirement,
10
11
  DEFAULT_PLAN_STATE,
11
- freshAbortController,
12
+ freshPendingSelect,
12
13
  getPlanState,
13
14
  MAX_PLAN_REQUIREMENT_LENGTH,
14
15
  persistPlanState,
@@ -17,6 +18,8 @@ import {
17
18
  type PlanState,
18
19
  reconstructPlanState,
19
20
  resetPlanState,
21
+ clearRoundFields,
22
+ createPlanCtx,
20
23
  } from "../state.js";
21
24
 
22
25
  describe("PlanState", () => {
@@ -27,8 +30,10 @@ describe("PlanState", () => {
27
30
  expect(DEFAULT_PLAN_STATE.templateName).toBe("");
28
31
  expect(DEFAULT_PLAN_STATE.skills).toEqual([]);
29
32
  expect(DEFAULT_PLAN_STATE.docs).toEqual([]);
30
- expect(DEFAULT_PLAN_STATE.reviewState).toBeUndefined();
31
- expect(DEFAULT_PLAN_STATE.reviewStateSource).toBeUndefined();
33
+ // D1/D2:缺省态 = 生命周期 'idle'(无 plan);selfReview/resumeHint 无值
34
+ expect(DEFAULT_PLAN_STATE.state).toBe("idle");
35
+ expect(DEFAULT_PLAN_STATE.selfReview).toBeUndefined();
36
+ expect(DEFAULT_PLAN_STATE.resumeHint).toBeUndefined();
32
37
  });
33
38
 
34
39
  it("getPlanState returns cached state if exists", () => {
@@ -37,7 +42,7 @@ describe("PlanState", () => {
37
42
  sessions.set("session-1", cached);
38
43
 
39
44
  const mockCtx = {
40
- sessionManager: { getEntries: () => [] },
45
+ sessionManager: { getLeafId: () => null, getEntries: () => [] },
41
46
  } as unknown as ExtensionContext;
42
47
 
43
48
  const result = getPlanState(sessions, "session-1", mockCtx);
@@ -48,6 +53,7 @@ describe("PlanState", () => {
48
53
  const sessions: PlanSessionMap = new Map();
49
54
  const mockCtx = {
50
55
  sessionManager: {
56
+ getLeafId: () => null,
51
57
  getEntries: () => [
52
58
  {
53
59
  type: "custom",
@@ -65,7 +71,7 @@ describe("PlanState", () => {
65
71
  });
66
72
 
67
73
  describe("State persistence", () => {
68
- it("persistPlanState calls appendEntry with the full schema (no phase field — D6)", () => {
74
+ it("persistPlanState calls appendEntry with the full schema (state/selfReview/resumeHint 取代式 — D2;无 phase — D6)", () => {
69
75
  const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
70
76
  const state: PlanState = {
71
77
  isActive: true,
@@ -74,39 +80,48 @@ describe("State persistence", () => {
74
80
  templateName: "feature-plan",
75
81
  skills: ["tech-design"],
76
82
  docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 }],
77
- reviewState: "awaiting",
83
+ state: "reviewing",
84
+ selfReview: "covered all 3 requirements",
85
+ resumeHint: "resubmit",
78
86
  };
79
87
 
80
88
  persistPlanState(mockPi, state);
81
89
 
82
- // 精确匹配:新写的 plan-state entry 为全字段 schema(D1:四现状 + skills/docs/reviewState
83
- // + reviewStateSource + lastSubmitReviewDocsFingerprint);无值 optional 字段为 undefined
90
+ // 精确匹配:新写 entry 全字段 schema(D2 取代式——只落 state/resumeHint/selfReview,
91
+ // reviewState/reviewStateSource 停写);无值 optional 字段为 undefined
84
92
  // (JSON 序列化自然消失——D4)
85
93
  expect(mockPi.appendEntry).toHaveBeenCalledWith("plan-state", {
86
94
  isActive: true,
87
95
  planFilePath: ".tmp/plans/test/plan.md",
88
96
  requirement: "test requirement",
89
97
  templateName: "feature-plan",
98
+ templateProvided: undefined,
90
99
  skills: ["tech-design"],
91
100
  docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 }],
92
- reviewState: "awaiting",
93
- reviewStateSource: undefined,
101
+ state: "reviewing",
102
+ selfReview: "covered all 3 requirements",
103
+ resumeHint: "resubmit",
94
104
  lastSubmitReviewDocsFingerprint: undefined,
95
105
  });
106
+ // 停写断言(取代式演进,D2):旧键不得回写
107
+ const entry = (mockPi.appendEntry as ReturnType<typeof vi.fn>).mock.calls[0][1] as Record<string, unknown>;
108
+ expect("reviewState" in entry).toBe(false);
109
+ expect("reviewStateSource" in entry).toBe(false);
96
110
  });
97
111
 
98
112
  it("reconstructPlanState returns DEFAULT_PLAN_STATE when no entries", () => {
99
113
  const mockCtx = {
100
- sessionManager: { getEntries: () => [] },
114
+ sessionManager: { getLeafId: () => null, getEntries: () => [] },
101
115
  } as unknown as ExtensionContext;
102
116
 
103
117
  const state = reconstructPlanState(mockCtx);
104
118
  expect(state).toEqual(DEFAULT_PLAN_STATE);
105
119
  });
106
120
 
107
- it("reconstructPlanState restores the full new-schema state from entries (D1)", () => {
121
+ it("reconstructPlanState restores state/selfReview/resumeHint from new-schema entries (D1/D2)", () => {
108
122
  const mockCtx = {
109
123
  sessionManager: {
124
+ getLeafId: () => null,
110
125
  getEntries: () => [
111
126
  {
112
127
  type: "custom",
@@ -118,7 +133,9 @@ describe("State persistence", () => {
118
133
  templateName: "",
119
134
  skills: ["tech-design", "dev-flow"],
120
135
  docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 1 }],
121
- reviewState: "awaiting",
136
+ state: "dispatching",
137
+ selfReview: "all covered",
138
+ resumeHint: "resubmit",
122
139
  },
123
140
  },
124
141
  ],
@@ -131,12 +148,38 @@ describe("State persistence", () => {
131
148
  expect(state.docs).toEqual([
132
149
  { fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 1 },
133
150
  ]);
134
- expect(state.reviewState).toBe("awaiting");
151
+ expect(state.state).toBe("dispatching");
152
+ expect(state.selfReview).toBe("all covered");
153
+ expect(state.resumeHint).toBe("resubmit");
154
+ });
155
+
156
+ it("旧 entry 无 state:reviewState 映射(awaiting→reviewing / revising→revising)+ reviewStateSource→resumeHint 同义映射(D2 读方①)", () => {
157
+ const entryFor = (data: Record<string, unknown>) => ({
158
+ sessionManager: {
159
+ getEntries: () => [{ type: "custom", customType: "plan-state", data }],
160
+ },
161
+ }) as unknown as ExtensionContext;
162
+
163
+ expect(
164
+ reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "", reviewState: "awaiting" })).state,
165
+ ).toBe("reviewing");
166
+ expect(
167
+ reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "", reviewState: "revising" })).state,
168
+ ).toBe("revising");
169
+ // reviewStateSource:'resubmit' → resumeHint:'resubmit'(同义映射,consumers.md 二①)
170
+ expect(
171
+ reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "", reviewState: "awaiting", reviewStateSource: "resubmit" })).resumeHint,
172
+ ).toBe("resubmit");
173
+ // 新字段优先于旧字段映射(混装过渡格:同 entry 双写形态也取新值)
174
+ expect(
175
+ reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "", state: "approved", reviewState: "awaiting" })).state,
176
+ ).toBe("approved");
135
177
  });
136
178
 
137
179
  it("old four-field entries (no new fields) reconstruct with field-level fallback (D4 兼容读)", () => {
138
180
  const mockCtx = {
139
181
  sessionManager: {
182
+ getLeafId: () => null,
140
183
  getEntries: () => [
141
184
  {
142
185
  type: "custom",
@@ -159,12 +202,14 @@ describe("State persistence", () => {
159
202
  // 新字段降级为空清单/无值(前端据此显示「(未指定)」+ 单文件形态)
160
203
  expect(state.skills).toEqual([]);
161
204
  expect(state.docs).toEqual([]);
162
- expect(state.reviewState).toBeUndefined();
205
+ // D2 读方①:无 state 无 reviewState → 按 isActive 推断(isActive=true → planning)
206
+ expect(state.state).toBe("planning");
163
207
  });
164
208
 
165
209
  it("malformed new-field values are dropped, not propagated (垃圾数据不进内存态)", () => {
166
210
  const mockCtx = {
167
211
  sessionManager: {
212
+ getLeafId: () => null,
168
213
  getEntries: () => [
169
214
  {
170
215
  type: "custom",
@@ -176,6 +221,9 @@ describe("State persistence", () => {
176
221
  templateName: "",
177
222
  skills: ["ok", 42, null],
178
223
  docs: [{ fileName: "good.md", absPath: "/p/good.md", sourceSkill: "", version: 1 }, "junk", { bad: true }],
224
+ state: "bogus-state",
225
+ selfReview: 42,
226
+ resumeHint: "explain",
179
227
  reviewState: "corrupted",
180
228
  },
181
229
  },
@@ -188,13 +236,18 @@ describe("State persistence", () => {
188
236
  expect(state.docs).toEqual([
189
237
  { fileName: "good.md", absPath: "/p/good.md", sourceSkill: "", version: 1 },
190
238
  ]);
191
- expect(state.reviewState).toBeUndefined();
239
+ // state 值域外垃圾按缺失处理 → 落旧字段映射(reviewState 也坏)→ 按 isActive 推断
240
+ expect(state.state).toBe("planning");
241
+ expect(state.selfReview).toBeUndefined();
242
+ // resumeHint 值域守卫:'resubmit' 之外(含旧 'explain' 存量值)按无值处理
243
+ expect(state.resumeHint).toBeUndefined();
192
244
  });
193
245
 
194
246
  it("reconstructPlanState ignores the legacy phase field in old entries (D6 兼容读 — V5②)", () => {
195
247
  // 旧版(含 phase)写的 entry:重开后 plan mode 重建正常,phase 被白名单式读取自然忽略
196
248
  const mockCtx = {
197
249
  sessionManager: {
250
+ getLeafId: () => null,
198
251
  getEntries: () => [
199
252
  {
200
253
  type: "custom",
@@ -212,7 +265,9 @@ describe("State persistence", () => {
212
265
  } as unknown as ExtensionContext;
213
266
 
214
267
  const state = reconstructPlanState(mockCtx);
215
- expect(Object.keys(state).sort()).toEqual(["docs", "isActive", "lastSubmitReviewDocsFingerprint", "planFilePath", "requirement", "reviewState", "reviewStateSource", "skills", "templateName", "templateProvidedPath"]);
268
+ // D2/D9:重建产物键集 = 四现状 + state/selfReview/resumeHint + skills/docs + 指纹
269
+ //(reviewState/reviewStateSource 不再是内存态字段,仅映射读)
270
+ expect(Object.keys(state).sort()).toEqual(["docs", "isActive", "lastSubmitReviewDocsFingerprint", "planFilePath", "requirement", "resumeHint", "selfReview", "skills", "state", "templateName", "templateProvided"]);
216
271
  expect(state.isActive).toBe(true);
217
272
  });
218
273
 
@@ -225,7 +280,7 @@ describe("State persistence", () => {
225
280
  templateName: "",
226
281
  skills: [],
227
282
  docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "", version: 1 }],
228
- reviewState: "awaiting",
283
+ state: "reviewing",
229
284
  lastSubmitReviewDocsFingerprint: "design.md:1",
230
285
  };
231
286
 
@@ -235,13 +290,14 @@ describe("State persistence", () => {
235
290
  // 重提交无变化检测仍能比对到上次基线)
236
291
  const persisted = (mockPi.appendEntry as ReturnType<typeof vi.fn>).mock.calls[0][1];
237
292
  const reopenCtx = {
238
- sessionManager: { getEntries: () => [{ type: "custom", customType: "plan-state", data: persisted }] },
293
+ sessionManager: { getLeafId: () => null, getEntries: () => [{ type: "custom", customType: "plan-state", data: persisted }] },
239
294
  } as unknown as ExtensionContext;
240
295
  expect(reconstructPlanState(reopenCtx).lastSubmitReviewDocsFingerprint).toBe("design.md:1");
241
296
 
242
297
  // 垃圾数据不进内存态:非 string 指纹按无既往提交处理(不警告语义)
243
298
  const badCtx = {
244
299
  sessionManager: {
300
+ getLeafId: () => null,
245
301
  getEntries: () => [
246
302
  {
247
303
  type: "custom",
@@ -254,7 +310,7 @@ describe("State persistence", () => {
254
310
  expect(reconstructPlanState(badCtx).lastSubmitReviewDocsFingerprint).toBeUndefined();
255
311
  });
256
312
 
257
- it("reviewStateSource persists and reconstructs;旧 entry 无字段 / 非法值 / 旧 'explain' 存量值按无值处理(D4 兼容读)", () => {
313
+ it("resumeHint persists and reconstructs(D2);旧 reviewStateSource 同义映射 / 垃圾值按无值处理", () => {
258
314
  const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
259
315
  const state: PlanState = {
260
316
  isActive: true,
@@ -263,8 +319,8 @@ describe("State persistence", () => {
263
319
  templateName: "",
264
320
  skills: [],
265
321
  docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "", version: 1 }],
266
- reviewState: "awaiting",
267
- reviewStateSource: "resubmit",
322
+ state: "reviewing",
323
+ resumeHint: "resubmit",
268
324
  };
269
325
 
270
326
  persistPlanState(mockPi, state);
@@ -272,13 +328,14 @@ describe("State persistence", () => {
272
328
  // 持久化 entry 走冷启动重建:resubmit 等待态跨重开可恢复(E3 重挂同款受益)
273
329
  const persisted = (mockPi.appendEntry as ReturnType<typeof vi.fn>).mock.calls[0][1];
274
330
  const reopenCtx = {
275
- sessionManager: { getEntries: () => [{ type: "custom", customType: "plan-state", data: persisted }] },
331
+ sessionManager: { getLeafId: () => null, getEntries: () => [{ type: "custom", customType: "plan-state", data: persisted }] },
276
332
  } as unknown as ExtensionContext;
277
- expect(reconstructPlanState(reopenCtx).reviewStateSource).toBe("resubmit");
333
+ expect(reconstructPlanState(reopenCtx).resumeHint).toBe("resubmit");
278
334
 
279
- // 旧 entry(升级前落盘)无该字段:reviewState 有值但无来源 → 无值(renderer 渲染通用降级文案)
335
+ // 旧 entry(升级前落盘)无该字段且无旧 reviewStateSource → 无值(renderer 渲染通用降级文案)
280
336
  const legacyCtx = {
281
337
  sessionManager: {
338
+ getLeafId: () => null,
282
339
  getEntries: () => [
283
340
  {
284
341
  type: "custom",
@@ -288,27 +345,114 @@ describe("State persistence", () => {
288
345
  ],
289
346
  },
290
347
  } as unknown as ExtensionContext;
291
- expect(reconstructPlanState(legacyCtx).reviewStateSource).toBeUndefined();
348
+ expect(reconstructPlanState(legacyCtx).resumeHint).toBeUndefined();
292
349
 
293
350
  // 旧版 'explain' 存量值(explain 交互已删)与垃圾值一并按无值处理(值域守卫白名单只认 'resubmit')
294
351
  const explainCtx = {
295
352
  sessionManager: {
353
+ getLeafId: () => null,
296
354
  getEntries: () => [
297
- { type: "custom", customType: "plan-state", data: { ...persisted, reviewStateSource: "explain" } },
355
+ { type: "custom", customType: "plan-state", data: { ...persisted, resumeHint: "explain" } },
298
356
  ],
299
357
  },
300
358
  } as unknown as ExtensionContext;
301
- expect(reconstructPlanState(explainCtx).reviewStateSource).toBeUndefined();
359
+ expect(reconstructPlanState(explainCtx).resumeHint).toBeUndefined();
302
360
 
303
- // 值域守卫:'resubmit' 之外的垃圾值按无值处理(与 readReviewState 同风格)
361
+ // 值域守卫:'resubmit' 之外的垃圾值按无值处理(与 readResumeHint 同风格)
304
362
  const badCtx = {
305
363
  sessionManager: {
364
+ getLeafId: () => null,
306
365
  getEntries: () => [
307
- { type: "custom", customType: "plan-state", data: { ...persisted, reviewStateSource: "bogus" } },
366
+ { type: "custom", customType: "plan-state", data: { ...persisted, resumeHint: "bogus" } },
308
367
  ],
309
368
  },
310
369
  } as unknown as ExtensionContext;
311
- expect(reconstructPlanState(badCtx).reviewStateSource).toBeUndefined();
370
+ expect(reconstructPlanState(badCtx).resumeHint).toBeUndefined();
371
+ });
372
+
373
+ it("templateProvided 直传标记:新字段直读 + 旧 templateProvidedPath 映射(双字段合并后 entry 级旧字段映射——select-template 直传防御行为等价)", () => {
374
+ const entryFor = (data: Record<string, unknown>) => ({
375
+ sessionManager: {
376
+ getEntries: () => [{ type: "custom", customType: "plan-state", data }],
377
+ },
378
+ }) as unknown as ExtensionContext;
379
+
380
+ // 新形态直读:templateProvided=true → 直传格(防御拦截源)
381
+ expect(
382
+ reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "t", templateProvided: true })).templateProvided,
383
+ ).toBe(true);
384
+ // 模板流程格:字段缺失
385
+ expect(
386
+ reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "" })).templateProvided,
387
+ ).toBeUndefined();
388
+ // 旧双字段形态重放:templateProvidedPath 存在即直传(值不保留——路径无消费方)
389
+ expect(
390
+ reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "t", templateProvidedPath: "/x/t.md" })).templateProvided,
391
+ ).toBe(true);
392
+ // 垃圾值不进内存态:非 true 直读值与旧字段非 string 一并按模板流程处理
393
+ expect(
394
+ reconstructPlanState(entryFor({ isActive: true, planFilePath: "/p/plan.md", requirement: "r", templateName: "", templateProvided: "yes", templateProvidedPath: 42 })).templateProvided,
395
+ ).toBeUndefined();
396
+ });
397
+
398
+ it("selfReview 重建读侧 4KB 截断防御(D9③/R3:超长旧 entry 不整段进内存态,E3 回传恒有界)", () => {
399
+ const oversized = "s".repeat(PLAN_SELF_REVIEW_MAX_BYTES + 100);
400
+ const mockCtx = {
401
+ sessionManager: {
402
+ getEntries: () => [
403
+ { type: "custom", customType: "plan-state", data: { isActive: true, state: "reviewing", selfReview: oversized } },
404
+ ],
405
+ },
406
+ } as unknown as ExtensionContext;
407
+ const restored = reconstructPlanState(mockCtx).selfReview;
408
+ expect(restored).toBeDefined();
409
+ expect(restored!.length).toBeLessThan(oversized.length);
410
+ expect(PLAN_SELF_REVIEW_MAX_BYTES - restored!.length).toBeLessThan(4);
411
+ });
412
+ });
413
+
414
+ describe("clearRoundFields(D4 per-round 字段清理单函数出口)", () => {
415
+ /** 构造带全部 per-round 字段残留的状态(上一轮 submit-review + E3 重挂后的形态)。 */
416
+ function stateWithRoundResidue(): PlanState {
417
+ return {
418
+ ...DEFAULT_PLAN_STATE,
419
+ isActive: true,
420
+ state: "reviewing",
421
+ selfReview: "上一轮的 selfReview",
422
+ resumeHint: "resubmit",
423
+ lastSubmitReviewDocsFingerprint: "design.md:2",
424
+ docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 }],
425
+ };
426
+ }
427
+
428
+ it("三字段(selfReview/resumeHint/指纹)一并清除——单出口锚:per-round 字段集只在本函数表达", () => {
429
+ const state = stateWithRoundResidue();
430
+ clearRoundFields(state);
431
+ expect(state.selfReview).toBeUndefined();
432
+ expect(state.resumeHint).toBeUndefined();
433
+ expect(state.lastSubmitReviewDocsFingerprint).toBeUndefined();
434
+ });
435
+
436
+ it("非本轮字段不动:isActive/state/docs 等跨轮字段保持原值", () => {
437
+ const state = stateWithRoundResidue();
438
+ clearRoundFields(state);
439
+ expect(state.isActive).toBe(true);
440
+ expect(state.state).toBe("reviewing");
441
+ expect(state.docs).toEqual([
442
+ { fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 },
443
+ ]);
444
+ });
445
+
446
+ it("幂等:无残留状态上重复调用不抛错、不引入新值", () => {
447
+ const state = stateWithRoundResidue();
448
+ clearRoundFields(state);
449
+ clearRoundFields(state);
450
+ expect(state.resumeHint).toBeUndefined();
451
+ // delete 移除键本身(与 persistPlanState 序列化时 undefined 值键自然消失同形态):
452
+ // 清理后键集 = 跨轮字段集,per-round 三键不残留
453
+ expect(Object.keys(state).sort()).toEqual(
454
+ ["docs", "isActive", "planFilePath", "requirement", "skills", "state", "templateName"],
455
+ );
312
456
  });
313
457
  });
314
458
 
@@ -316,7 +460,7 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
316
460
  function setupActiveSession() {
317
461
  const sessions: PlanSessionMap = new Map();
318
462
  const mockCtx = {
319
- sessionManager: { getEntries: () => [] },
463
+ sessionManager: { getLeafId: () => null, getEntries: () => [] },
320
464
  } as unknown as ExtensionContext;
321
465
  sessions.set("session-1", {
322
466
  isActive: true,
@@ -325,19 +469,26 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
325
469
  templateName: "feature-plan",
326
470
  skills: ["tech-design", "dev-flow"],
327
471
  docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 }],
328
- reviewState: "awaiting",
472
+ state: "reviewing",
473
+ selfReview: "prev self-review",
474
+ resumeHint: "resubmit",
475
+ lastSubmitReviewDocsFingerprint: "design.md:2",
329
476
  });
330
477
  const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
331
478
  return { sessions, mockCtx, mockPi };
332
479
  }
333
480
 
334
- it("isActive=false + reviewState cleared + skills cleared + docs KEPT", () => {
481
+ it("isActive=false + state=terminal(exited) + selfReview/resumeHint/指纹 cleared + skills cleared + docs KEPT", () => {
335
482
  const { sessions, mockCtx, mockPi } = setupActiveSession();
336
483
 
337
484
  const state = resetPlanState(mockPi, sessions, "session-1", mockCtx);
338
485
 
339
486
  expect(state.isActive).toBe(false);
340
- expect(state.reviewState).toBeUndefined();
487
+ // 终态矩阵(D3 连带段):默认 terminal='exited'
488
+ expect(state.state).toBe("exited");
489
+ // selfReview/resumeHint 随退出失效(clearRoundFields 单函数出口的 reset 调用点,S15 断言)
490
+ expect(state.selfReview).toBeUndefined();
491
+ expect(state.resumeHint).toBeUndefined();
341
492
  expect(state.skills).toEqual([]);
342
493
  // docs 保留:产物 tab 与 isActive 解耦,执行期/退出后都可回看产物
343
494
  expect(state.docs).toEqual([
@@ -355,9 +506,13 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
355
506
  planFilePath: "",
356
507
  requirement: "",
357
508
  templateName: "",
509
+ templateProvided: undefined,
358
510
  skills: [],
359
511
  docs: [{ fileName: "design.md", absPath: "/p/design.md", sourceSkill: "tech-design", version: 2 }],
360
- reviewState: undefined,
512
+ state: "exited",
513
+ selfReview: undefined,
514
+ resumeHint: undefined,
515
+ lastSubmitReviewDocsFingerprint: undefined,
361
516
  });
362
517
  expect(sessions.has("session-1")).toBe(false);
363
518
  });
@@ -369,7 +524,7 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
369
524
  // 模拟重开:用 reset 落盘的 entry 数据走冷启动重建
370
525
  const persisted = (mockPi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1];
371
526
  const reopenCtx = {
372
- sessionManager: { getEntries: () => [{ type: "custom", customType: "plan-state", data: persisted }] },
527
+ sessionManager: { getLeafId: () => null, getEntries: () => [{ type: "custom", customType: "plan-state", data: persisted }] },
373
528
  } as unknown as ExtensionContext;
374
529
 
375
530
  const state = reconstructPlanState(reopenCtx);
@@ -393,19 +548,14 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
393
548
  expect(state.lastSubmitReviewDocsFingerprint).toBeUndefined();
394
549
  expect(state.docs).toHaveLength(1);
395
550
  });
396
- it("reviewStateSource is cleared on reset (跨 plan run 残留防护,C-U2 同型缺陷)", () => {
551
+ it("terminal 参数(D3 连带段):complete 终局传 'completed' 不被 reset 覆写为 'exited'", () => {
397
552
  const { sessions, mockCtx, mockPi } = setupActiveSession();
398
- const active = sessions.get("session-1");
399
- if (!active) throw new Error("setupActiveSession must seed session-1");
400
- active.reviewStateSource = "resubmit";
401
553
 
402
- const state = resetPlanState(mockPi, sessions, "session-1", mockCtx);
554
+ const state = resetPlanState(mockPi, sessions, "session-1", mockCtx, "completed");
403
555
 
404
- // 来源标记与 reviewState 同生命周期随退出失效;reset entry 落盘同样无该键
405
- // (undefined JSON 序列化自然消失——D4)
406
- expect(state.reviewStateSource).toBeUndefined();
556
+ expect(state.state).toBe("completed");
407
557
  const lastEntry = (mockPi.appendEntry as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[1] as PlanState;
408
- expect(lastEntry.reviewStateSource).toBeUndefined();
558
+ expect(lastEntry.state).toBe("completed");
409
559
  });
410
560
 
411
561
  describe("空 slug 目录清理(P3-10)", () => {
@@ -431,7 +581,7 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
431
581
  const planFilePath = makePlanRoot(false);
432
582
  const slugDir = join(planFilePath, "..");
433
583
  const sessions: PlanSessionMap = new Map();
434
- const mockCtx = { sessionManager: { getEntries: () => [] } } as unknown as ExtensionContext;
584
+ const mockCtx = { sessionManager: { getLeafId: () => null, getEntries: () => [] } } as unknown as ExtensionContext;
435
585
  sessions.set("s1", { ...DEFAULT_PLAN_STATE, isActive: true, planFilePath, requirement: "r", templateName: "", skills: [], docs: [] });
436
586
  const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
437
587
 
@@ -444,7 +594,7 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
444
594
  const planFilePath = makePlanRoot(true);
445
595
  const slugDir = join(planFilePath, "..");
446
596
  const sessions: PlanSessionMap = new Map();
447
- const mockCtx = { sessionManager: { getEntries: () => [] } } as unknown as ExtensionContext;
597
+ const mockCtx = { sessionManager: { getLeafId: () => null, getEntries: () => [] } } as unknown as ExtensionContext;
448
598
  sessions.set("s1", { ...DEFAULT_PLAN_STATE, isActive: true, planFilePath, requirement: "r", templateName: "", skills: [], docs: [] });
449
599
  const mockPi = { appendEntry: vi.fn() } as unknown as ExtensionAPI;
450
600
 
@@ -455,18 +605,68 @@ describe("resetPlanState 终态矩阵(D5/E10)", () => {
455
605
  });
456
606
  });
457
607
 
458
- describe("freshAbortController(E10 生命周期)", () => {
459
- it("registers the controller per session and creates a fresh one on each call", () => {
608
+ describe("freshPendingSelect(E10 生命周期 + D-B1-2 dissolvedBy 直传原语)", () => {
609
+ it("registers the pending select per session and creates a fresh one on each call", () => {
460
610
  const controllers: PlanAbortControllers = new Map();
461
611
 
462
- const first = freshAbortController(controllers, "s1");
612
+ const first = freshPendingSelect(controllers, "s1");
463
613
  expect(controllers.get("s1")).toBe(first);
464
614
 
465
615
  // 禁复用:第二次调用必须新建(复用已 abort 的 controller 会让 select 瞬时静默取消)
466
- const second = freshAbortController(controllers, "s1");
616
+ const second = freshPendingSelect(controllers, "s1");
467
617
  expect(second).not.toBe(first);
468
618
  expect(controllers.get("s1")).toBe(second);
469
619
  });
620
+
621
+ it("dissolvedBy 缺省 undefined(外部解散不打标——等待处按「非 'self' 即外部」分派)", () => {
622
+ const controllers: PlanAbortControllers = new Map();
623
+ const pending = freshPendingSelect(controllers, "s1");
624
+ expect(pending.dissolvedBy).toBeUndefined();
625
+ });
626
+
627
+ it("markDissolved('self') 一次置位且对同一闭包可读(来源随挂起闭包直达等待处)", () => {
628
+ const controllers: PlanAbortControllers = new Map();
629
+ const pending = freshPendingSelect(controllers, "s1");
630
+ pending.markDissolved("self");
631
+ expect(pending.dissolvedBy).toBe("self");
632
+ // 闭包身份天然携带轮次记忆:另一个挂起(新轮次)的来源变量独立,不受前轮打标污染
633
+ const next = freshPendingSelect(controllers, "s1");
634
+ expect(next.dissolvedBy).toBeUndefined();
635
+ });
636
+ });
637
+
638
+ describe("createPlanCtx / dissolveAll(D-B4-3 单 ctx 对象:两表收敛 + 解散方法承载 exitPlanMode 入口动作)", () => {
639
+ it("两表为空 Map 且独立:states / controllers 各自可读写", () => {
640
+ const planCtx = createPlanCtx();
641
+ expect(planCtx.states.size).toBe(0);
642
+ expect(planCtx.controllers.size).toBe(0);
643
+ const pending = freshPendingSelect(planCtx.controllers, "s1");
644
+ expect(planCtx.controllers.get("s1")).toBe(pending);
645
+ });
646
+
647
+ it("dissolveAll:全部挂起 markDissolved 打标 + controller.abort + 注册表清空(exitPlanMode ① 的承载)", () => {
648
+ const planCtx = createPlanCtx();
649
+ const abortSpy = vi.fn();
650
+ const first = freshPendingSelect(planCtx.controllers, "s1");
651
+ const second = freshPendingSelect(planCtx.controllers, "s2");
652
+ vi.spyOn(first.controller, "abort").mockImplementation(abortSpy);
653
+ vi.spyOn(second.controller, "abort").mockImplementation(abortSpy);
654
+
655
+ planCtx.dissolveAll("self");
656
+
657
+ // 来源随闭包直达等待处(D-B1-2):打标先于 abort 不可在此直接断言(同步序列),
658
+ // 但两挂起都必须置位 + 中止;注册表不留已 dissolved 条目
659
+ expect(first.dissolvedBy).toBe("self");
660
+ expect(second.dissolvedBy).toBe("self");
661
+ expect(abortSpy).toHaveBeenCalledTimes(2);
662
+ expect(planCtx.controllers.size).toBe(0);
663
+ });
664
+
665
+ it("dissolveAll 无挂起时为 no-op(无挂起退出的幂等形态)", () => {
666
+ const planCtx = createPlanCtx();
667
+ expect(() => planCtx.dissolveAll("self")).not.toThrow();
668
+ expect(planCtx.controllers.size).toBe(0);
669
+ });
470
670
  });
471
671
 
472
672
  describe("requirement 长度封顶(MF-1-3:session.planState 帧不登记 LARGE_FIELD_REGISTRY 的有界前提代码化)", () => {
@@ -490,6 +690,7 @@ describe("requirement 长度封顶(MF-1-3:session.planState 帧不登记 LAR
490
690
  const oversized = "z".repeat(MAX_PLAN_REQUIREMENT_LENGTH * 2);
491
691
  const mockCtx = {
492
692
  sessionManager: {
693
+ getLeafId: () => null,
493
694
  getEntries: () => [
494
695
  { type: "custom", customType: "plan-state", data: { isActive: true, requirement: oversized } },
495
696
  ],
@@ -504,6 +705,7 @@ describe("requirement 长度封顶(MF-1-3:session.planState 帧不登记 LAR
504
705
  it("非 string requirement(含缺失)仍归空串(白名单读取语义不变)", () => {
505
706
  const mockCtx = {
506
707
  sessionManager: {
708
+ getLeafId: () => null,
507
709
  getEntries: () => [
508
710
  { type: "custom", customType: "plan-state", data: { isActive: false, requirement: 123 } },
509
711
  ],