@zhushanwen/pi-plan 0.4.5 → 0.4.7

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.
@@ -1,21 +1,26 @@
1
1
  import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
2
 
5
3
  import { describe, expect, it } from "vitest";
6
4
 
5
+ import { extractPlanSteps } from "../compact.js";
7
6
  import { getBuiltinTemplateDir, listTemplates, loadTemplate } from "../templates.js";
8
7
 
9
- describe("Template system", () => {
10
- it("listTemplates returns builtin templates", () => {
8
+ const BUILTIN_TEMPLATE_NAMES = [
9
+ "feature-plan",
10
+ "bugfix-plan",
11
+ "refactor-plan",
12
+ "research-plan",
13
+ "implementation-plan",
14
+ ];
15
+
16
+ describe("Template system (builtin single source, D3)", () => {
17
+ it("listTemplates returns exactly the 5 builtin templates with no source field", () => {
11
18
  const templates = listTemplates();
12
- expect(templates.length).toBeGreaterThanOrEqual(5);
13
- const names = templates.map((t) => t.name);
14
- expect(names).toContain("feature-plan");
15
- expect(names).toContain("bugfix-plan");
16
- expect(names).toContain("refactor-plan");
17
- expect(names).toContain("research-plan");
18
- expect(names).toContain("implementation-plan");
19
+ expect(templates.map((t) => t.name).sort()).toEqual([...BUILTIN_TEMPLATE_NAMES].sort());
20
+ // 单源化后 TemplateInfo 只有 name/path —— source 等多源残留字段即红
21
+ for (const t of templates) {
22
+ expect(t).toEqual({ name: t.name, path: t.path });
23
+ }
19
24
  });
20
25
 
21
26
  it("loadTemplate returns content for existing builtin template", () => {
@@ -33,28 +38,23 @@ describe("Template system", () => {
33
38
  const dir = getBuiltinTemplateDir();
34
39
  expect(fs.existsSync(dir)).toBe(true);
35
40
  });
41
+ });
36
42
 
37
- it("TC8: PI_CODING_AGENT_DIR 隔离目录下扫到全局模板(getAgentDir 派生,不依赖 ~/.pi/agent)", () => {
38
- const origEnv = process.env.PI_CODING_AGENT_DIR;
39
- const isolated = fs.mkdtempSync(path.join(os.tmpdir(), "plan-tpl-"));
40
- try {
41
- process.env.PI_CODING_AGENT_DIR = isolated;
42
- fs.mkdirSync(path.join(isolated, "plan-templates"), { recursive: true });
43
- fs.writeFileSync(path.join(isolated, "plan-templates", "isolated-template.md"), "# t");
44
-
45
- const templates = listTemplates();
46
- const names = templates.map((t) => t.name);
47
- expect(names).toContain("isolated-template");
48
- // 隔离目录模板 source 为 global(getAgentDir 直接返回 PI_CODING_AGENT_DIR 值,无 .pi/agent 嵌套)
49
- const tpl = templates.find((t) => t.name === "isolated-template");
50
- expect(tpl?.source).toBe("global");
51
- expect(tpl?.path.startsWith(isolated)).toBe(true);
52
- // 仍能扫到 builtin 模板(最低优先级)
53
- expect(names).toContain("feature-plan");
54
- } finally {
55
- if (origEnv === undefined) delete process.env.PI_CODING_AGENT_DIR;
56
- else process.env.PI_CODING_AGENT_DIR = origEnv;
57
- fs.rmSync(isolated, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
58
- }
43
+ describe("template extractPlanSteps alignment guard (D4)", () => {
44
+ // 守卫对象:模板生成端的步骤节标题与解析端正则同仓同测,漂移即红——
45
+ // 模板标题改名 恰一节断言失败;
46
+ // ② 解析正则与标题脱节 → 提取退化到 fallback 收进其他节的噪音项 → toEqual 失败。
47
+ it.each(BUILTIN_TEMPLATE_NAMES)("template '%s' has exactly one '## Implementation Steps' section that extractPlanSteps consumes", (name) => {
48
+ const content = loadTemplate(name);
49
+ expect(content).not.toBeNull();
50
+
51
+ expect(content!.match(/^## Implementation Steps$/gm)).toHaveLength(1);
52
+
53
+ const plan = content!
54
+ // 在第一个非步骤节标题后放噪音编号项(模拟 Requirements 等节含编号列表)
55
+ .replace(/^(## (?!Implementation Steps).+)$/m, "$1\n1. noise-from-other-section")
56
+ // 在步骤节标题后填编号步骤(模拟 AI 按模板写 plan.md)
57
+ .replace(/^## Implementation Steps$/m, "## Implementation Steps\n1. Real step A\n2. Real step B");
58
+ expect(extractPlanSteps(plan)).toEqual(["Real step A", "Real step B"]);
59
59
  });
60
60
  });
@@ -14,26 +14,23 @@ vi.mock("@earendil-works/pi-ai", () => ({
14
14
  StringEnum: (values: readonly string[]) => ({ type: "string", enum: [...values] }),
15
15
  }));
16
16
 
17
- // Mock compact.js (dynamically imported by complete action)
18
- vi.mock("../compact.js", () => ({
19
- handlePlanComplete: vi.fn(),
20
- detectGoalCapability: vi.fn(() => false),
21
- }));
17
+ // Mock compact.js (statically imported since 06-u1)
18
+ vi.mock("../compact.js", async () => {
19
+ // GOAL_FAILURE_RECOVERY 与真实实现同文案——completeResultText 在 failure 断言里消费它
20
+ const { GOAL_FAILURE_RECOVERY } = await vi.importActual<typeof import("../compact.js")>("../compact.js");
21
+ return {
22
+ handlePlanComplete: vi.fn(),
23
+ detectGoalCapability: vi.fn(() => false),
24
+ GOAL_FAILURE_RECOVERY,
25
+ };
26
+ });
22
27
 
23
28
  // Mock widget (imported by abort)
24
29
  vi.mock("../widget.js", () => ({
25
30
  updatePlanWidget: vi.fn(),
26
31
  }));
27
32
 
28
- // Mock node:fs — ESM namespace isn't configurable, so we use vi.mock
29
- vi.mock("node:fs", async () => {
30
- const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
31
- return { ...actual, mkdirSync: vi.fn(), writeFileSync: vi.fn() };
32
- });
33
-
34
- import * as fs from "node:fs";
35
-
36
- import { handlePlanComplete } from "../compact.js";
33
+ import { detectGoalCapability, handlePlanComplete } from "../compact.js";
37
34
  import { PLAN_ACTIONS, registerPlanTool, validateAction } from "../tool.js";
38
35
  import { updatePlanWidget } from "../widget.js";
39
36
 
@@ -78,6 +75,19 @@ describe("registerPlanTool", () => {
78
75
  expect(res.details.action).toBe("list-template");
79
76
  expect(Array.isArray(res.details.templates)).toBe(true);
80
77
  });
78
+
79
+ it("returns exactly the 5 builtin templates with no source field (D3 / V4)", async () => {
80
+ const { exec } = setup();
81
+ const res = await exec({ action: "list-template" });
82
+ const templates = res.details.templates as Array<{ name: string; source?: string; path: string }>;
83
+ expect(templates.map((t) => t.name).sort()).toEqual(
84
+ ["feature-plan", "bugfix-plan", "refactor-plan", "research-plan", "implementation-plan"].sort(),
85
+ );
86
+ for (const t of templates) {
87
+ expect(t.source).toBeUndefined();
88
+ expect(t).toEqual({ name: t.name, path: t.path });
89
+ }
90
+ });
81
91
  });
82
92
 
83
93
  // --- select-template ---
@@ -92,7 +102,7 @@ describe("registerPlanTool", () => {
92
102
  await expect(exec({ action: "select-template", templateName: "nonexistent" })).rejects.toThrow("Template not found");
93
103
  });
94
104
 
95
- it("sets phase to writing and persists", async () => {
105
+ it("sets templateName and persists (D6:无 phase 写入)", async () => {
96
106
  const { exec, pi, sessions } = setup();
97
107
  // Use a builtin template name — find one first
98
108
  const listRes = await exec({ action: "list-template" });
@@ -103,39 +113,46 @@ describe("registerPlanTool", () => {
103
113
  const res = await exec({ action: "select-template", templateName: name });
104
114
  expect(res.details.templateName).toBe(name);
105
115
  expect(res.details.action).toBe("select-template");
106
- expect(pi.appendEntry).toHaveBeenCalled();
107
- const state = sessions.get("test-session");
108
- expect(state?.phase).toBe("writing");
116
+ expect(pi.appendEntry).toHaveBeenCalledWith("plan-state", expect.objectContaining({ templateName: name }));
117
+ const state = sessions.get("test-session") as { templateName?: string; isActive?: boolean };
109
118
  expect(state?.templateName).toBe(name);
110
119
  });
111
120
  });
112
121
 
113
- // --- create-template ---
114
- describe("create-template", () => {
115
- beforeEach(() => { (fs.mkdirSync as ReturnType<typeof vi.fn>).mockClear(); (fs.writeFileSync as ReturnType<typeof vi.fn>).mockClear(); });
116
-
117
- it("throws when parameters are missing", async () => {
122
+ // --- removed action (D3) ---
123
+ describe("create-template removal", () => {
124
+ it("rejects plan(action='create-template') as an unknown action (D3 / V4)", async () => {
118
125
  const { exec } = setup();
119
- await expect(exec({ action: "create-template" })).rejects.toThrow("templateName and templateContent are required");
126
+ await expect(
127
+ exec({ action: "create-template", templateName: "my-plan", templateContent: "# hello" }),
128
+ ).rejects.toThrow(
129
+ "Unknown plan action: create-template. Valid actions: list-template, select-template, complete, abort",
130
+ );
120
131
  });
132
+ });
121
133
 
122
- it("throws when name sanitizes to empty", async () => {
123
- const { exec } = setup();
124
- await expect(exec({ action: "create-template", templateName: "!!!", templateContent: "x" }))
125
- .rejects.toThrow("Invalid template name");
134
+ // --- complete ---
135
+ describe("complete", () => {
136
+ beforeEach(() => {
137
+ // 默认桥不可达(与真实 pi 0.84.4 现状一致);goal 档用例显式 mock 桥可达
138
+ (detectGoalCapability as ReturnType<typeof vi.fn>).mockReturnValue(false);
139
+ (handlePlanComplete as ReturnType<typeof vi.fn>).mockReset();
126
140
  });
127
141
 
128
- it("writes file with sanitized name", async () => {
129
- const { exec } = setup();
130
- const res = await exec({ action: "create-template", templateName: "My Plan v2!", templateContent: "# hello" });
131
- expect(res.details.templateName).toBe("MyPlanv2");
132
- expect(fs.mkdirSync).toHaveBeenCalledWith("/tmp/test-project/.pi/plan-templates", { recursive: true });
133
- expect(fs.writeFileSync).toHaveBeenCalledWith("/tmp/test-project/.pi/plan-templates/MyPlanv2.md", "# hello");
142
+ /** 注册时捕获的工具定义(schema 检查用)。 */
143
+ function registeredTool(pi: { registerTool: unknown }): Record<string, unknown> {
144
+ return ((pi.registerTool as ReturnType<typeof vi.fn>).mock.calls[0][0]) as Record<string, unknown>;
145
+ }
146
+
147
+ it("rejects isolation='tree' at the schema level: enum is exactly compact|direct (D1 / V3①)", async () => {
148
+ const { pi } = setup();
149
+ const parameters = registeredTool(pi).parameters as {
150
+ properties: { isolation: { enum: string[] } };
151
+ };
152
+ expect(parameters.properties.isolation.enum).toEqual(["compact", "direct"]);
153
+ expect(parameters.properties.isolation.enum).not.toContain("tree");
134
154
  });
135
- });
136
155
 
137
- // --- complete ---
138
- describe("complete", () => {
139
156
  it("does not advance when user cancels", async () => {
140
157
  const { exec, ctx, pi } = setup();
141
158
  (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Modify the plan first");
@@ -154,6 +171,74 @@ describe("registerPlanTool", () => {
154
171
  expect(handlePlanComplete).toHaveBeenCalled();
155
172
  expect(res.details.planFilePath).toBeDefined();
156
173
  });
174
+
175
+ it("dialog options exclude the goal tier when the bridge is unavailable", async () => {
176
+ const { exec, ctx } = setup();
177
+ (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Single-agent (current session)");
178
+ await exec({ action: "complete" });
179
+ const options = (ctx.ui.select as ReturnType<typeof vi.fn>).mock.calls[0][1] as string[];
180
+ expect(options).not.toContain("Goal-driven execution (/goal)");
181
+ expect(options).toEqual([
182
+ "Subagent-driven execution",
183
+ "Single-agent (current session)",
184
+ "Modify the plan first",
185
+ "Save for later",
186
+ ]);
187
+ });
188
+
189
+ it("dialog options include the goal tier when the bridge is reachable (mocked goalInit slot world)", async () => {
190
+ const { exec, ctx } = setup();
191
+ (detectGoalCapability as ReturnType<typeof vi.fn>).mockReturnValue(true);
192
+ (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Goal-driven execution (/goal)");
193
+ const res = await exec({ action: "complete" });
194
+ const options = (ctx.ui.select as ReturnType<typeof vi.fn>).mock.calls[0][1] as string[];
195
+ expect(options).toEqual([
196
+ "Subagent-driven execution",
197
+ "Goal-driven execution (/goal)",
198
+ "Single-agent (current session)",
199
+ "Modify the plan first",
200
+ "Save for later",
201
+ ]);
202
+ expect(res.details.execMode).toBe("goal"); // EXEC_MODE_OPTIONS 查表映射(发现 8)
203
+ });
204
+
205
+ it("maps 'Single-agent (current session)' choice to execMode single-agent", async () => {
206
+ const { exec, ctx } = setup();
207
+ (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Single-agent (current session)");
208
+ const res = await exec({ action: "complete" });
209
+ expect(res.details.execMode).toBe("single-agent");
210
+ });
211
+
212
+ it("direct tier carries the goal outcome into result content and details (D2)", async () => {
213
+ const { exec, ctx } = setup();
214
+ (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Goal-driven execution (/goal)");
215
+ (handlePlanComplete as ReturnType<typeof vi.fn>).mockReturnValue({ started: false, reason: "no-steps" });
216
+ const res = await exec({ action: "complete", isolation: "direct" });
217
+ expect(handlePlanComplete).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), "direct", "goal");
218
+ expect(res.content[0].text).toContain("Goal execution was not started (no-steps)");
219
+ expect(res.content[0].text).toContain("Implementation Steps"); // 恢复动作
220
+ expect(res.details.goalOutcome).toEqual({ started: false, reason: "no-steps" });
221
+ });
222
+
223
+ it("successful goal outcome appends the started line", async () => {
224
+ const { exec, ctx } = setup();
225
+ (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Goal-driven execution (/goal)");
226
+ (handlePlanComplete as ReturnType<typeof vi.fn>).mockReturnValue({ started: true });
227
+ const res = await exec({ action: "complete", isolation: "direct" });
228
+ expect(res.content[0].text).toContain("Goal execution started via /goal");
229
+ expect(res.details.goalOutcome).toEqual({ started: true });
230
+ });
231
+
232
+ it("compact tier outcome is deferred (undefined): result keeps the plain approved line", async () => {
233
+ const { exec, ctx } = setup();
234
+ (ctx.ui.select as ReturnType<typeof vi.fn>).mockResolvedValue("Subagent-driven execution");
235
+ (handlePlanComplete as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
236
+ const res = await exec({ action: "complete", isolation: "compact" });
237
+ expect(res.content[0].text).toMatch(/^Plan approved\. File: /);
238
+ expect(res.content[0].text).not.toContain("Goal execution");
239
+ expect(res.details.goalOutcome).toBeUndefined();
240
+ expect(res.details.isolation).toBe("compact");
241
+ });
157
242
  });
158
243
 
159
244
  // --- abort ---
@@ -161,7 +246,7 @@ describe("registerPlanTool", () => {
161
246
  it("resets state and cleans up session", async () => {
162
247
  const { exec, pi, sessions } = setup();
163
248
  // Pre-populate a session
164
- sessions.set("test-session", { isActive: true, phase: "writing", planFilePath: "/tmp/plan.md", requirement: "test", templateName: "t" });
249
+ sessions.set("test-session", { isActive: true, planFilePath: "/tmp/plan.md", requirement: "test", templateName: "t" });
165
250
  const res = await exec({ action: "abort" });
166
251
  expect(res.details.action).toBe("abort");
167
252
  expect(pi.setActiveTools).toHaveBeenCalledWith(ALL_TOOL_NAMES);
@@ -178,4 +263,7 @@ describe("validateAction", () => {
178
263
  it("rejects invalid", () => {
179
264
  expect(validateAction("bogus")).toBe(false);
180
265
  });
266
+ it("action list no longer contains create-template (D3)", () => {
267
+ expect(PLAN_ACTIONS).not.toContain("create-template");
268
+ });
181
269
  });
package/src/command.ts CHANGED
@@ -58,10 +58,10 @@ export function registerPlanCommand(
58
58
  return;
59
59
  }
60
60
 
61
- // Reentry: check for existing plan files in .xyz-harness/
61
+ // Reentry: check for existing plan files in .taiji-harness/
62
62
  if (!state.isActive && !trimmed) {
63
63
  const projectDir = ctx.cwd;
64
- const harnessDir = path.join(projectDir, ".xyz-harness");
64
+ const harnessDir = path.join(projectDir, ".taiji-harness");
65
65
  const existingPlans = findExistingPlans(harnessDir);
66
66
  if (existingPlans.length > 0) {
67
67
  pi.sendUserMessage(
@@ -111,12 +111,12 @@ function handleStatus(
111
111
  return;
112
112
  }
113
113
  ctx.ui.notify(
114
- `Plan Mode: ${state.phase}\nPlan: ${state.planFilePath}\nTemplate: ${state.templateName || "(not selected)"}`,
114
+ `Plan: ${state.planFilePath}\nTemplate: ${state.templateName || "(not selected)"}`,
115
115
  "info",
116
116
  );
117
117
  }
118
118
 
119
- /** Find existing plan.md files in .xyz-harness/ subdirectories */
119
+ /** Find existing plan.md files in .taiji-harness/ subdirectories */
120
120
  function findExistingPlans(harnessDir: string): string[] {
121
121
  try {
122
122
  return fs.readdirSync(harnessDir)
@@ -144,12 +144,11 @@ function handleEnterPlanMode(
144
144
  : "untitled";
145
145
 
146
146
  const projectDir = ctx.cwd;
147
- const planDir = path.join(projectDir, ".xyz-harness", slug);
147
+ const planDir = path.join(projectDir, ".taiji-harness", slug);
148
148
  fs.mkdirSync(planDir, { recursive: true });
149
149
  const planFilePath = path.join(planDir, "plan.md");
150
150
 
151
151
  state.isActive = true;
152
- state.phase = "brainstorming";
153
152
  state.planFilePath = planFilePath;
154
153
  state.requirement = requirement;
155
154
  state.templateName = "";
@@ -182,7 +181,7 @@ function handleEnterPlanMode(
182
181
  `4. Write all chapters in one turn, then ask user to review.\n\n` +
183
182
  `## Phase D: Completion\n` +
184
183
  `1. Ask user to review the complete plan.\n` +
185
- `2. Call plan tool (complete) with isolation method (compact/tree/direct).\n` +
184
+ `2. Call plan tool (complete) with isolation method (compact/direct).\n` +
186
185
  `3. After plan complete: the user picks an execution path (subagent-driven / goal-driven / single-agent) via the completion dialog.`,
187
186
  );
188
187
  }
package/src/compact.ts CHANGED
@@ -25,18 +25,16 @@ export function registerPlanEventHandlers(
25
25
  // Read plan file content for recovery after compact
26
26
  const planContent = readPlanFileSafe(state.planFilePath);
27
27
 
28
- // Include phase info for non-complete phases
29
- const phaseNote = state.phase !== "complete"
30
- ? `\nPhase: ${state.phase}. Plan was in progress — review and continue.`
31
- : "\nAwaiting user decision on execution. Do NOT auto-proceed.";
28
+ // handler 已有 isActive 门——能走到这里的 plan 必然进行中(D6:phase 删除,原 phase="complete" 分支为死状态)
29
+ const progressNote = "\nPlan was in progress — review and continue.";
32
30
 
33
31
  return {
34
32
  compaction: {
35
33
  summary:
36
- `Plan mode active (${state.phase}). Plan file: ${state.planFilePath}\n\n` +
34
+ `Plan mode active. Plan file: ${state.planFilePath}\n\n` +
37
35
  `## Plan Content\n${planContent}\n\n` +
38
36
  `Requirement: ${state.requirement}` +
39
- phaseNote,
37
+ progressNote,
40
38
  firstKeptEntryId: prep?.firstKeptEntryId,
41
39
  tokensBefore: prep?.tokensBefore,
42
40
  },
@@ -53,7 +51,7 @@ export function registerPlanEventHandlers(
53
51
  return {
54
52
  summary: {
55
53
  summary:
56
- `Plan mode active (${state.phase}). Plan file: ${state.planFilePath}\n\n` +
54
+ `Plan mode active. Plan file: ${state.planFilePath}\n\n` +
57
55
  `## Plan Content\n${planContent}\n\n` +
58
56
  `Read the plan file and execute the implementation.`,
59
57
  },
@@ -61,24 +59,45 @@ export function registerPlanEventHandlers(
61
59
  });
62
60
  }
63
61
 
64
- /** Read plan file, return content or error message */
65
- function readPlanFileSafe(planFilePath: string): string {
62
+ /** Read plan file: ok=false 是显式信号(GoalBridgeOutcome plan-unreadable 出口消费),消除哨兵字符串比较 */
63
+ type PlanFileContent = { ok: true; content: string } | { ok: false };
64
+
65
+ function readPlanFile(planFilePath: string): PlanFileContent {
66
66
  try {
67
- return fs.readFileSync(planFilePath, "utf-8");
67
+ return { ok: true, content: fs.readFileSync(planFilePath, "utf-8") };
68
68
  } catch {
69
- return "(plan file could not be read)";
69
+ return { ok: false };
70
70
  }
71
71
  }
72
72
 
73
+ /** Read plan file, return content or human-readable marker (for prompt embedding) */
74
+ function readPlanFileSafe(planFilePath: string): string {
75
+ const result = readPlanFile(planFilePath);
76
+ return result.ok ? result.content : "(plan file could not be read)";
77
+ }
78
+
79
+ /**
80
+ * goalInit slot key——goal 扩展的跨扩展编程式入口(goal-bridge-cross-extension.md)。
81
+ * ⚠️ 必须与 `extensions/universal/goal/src/index.ts` 的 GOAL_INIT_SLOT_KEY 字符串完全一致:
82
+ * 两侧不共享运行时模块(pi-goal 是 optional peer),靠同一字符串拿到同一 globalThis slot。
83
+ * 改名必须两侧同步。
84
+ */
85
+ const GOAL_INIT_SLOT_KEY = Symbol.for("@zhushanwen/pi-goal.goalInit");
86
+
87
+ /**
88
+ * goal 桥的单一断言点:goal 扩展挂在 globalThis slot 上的编程式接口(发现 7——
89
+ * 此前 detectGoalCapability / tryGoalInit 两处 inline 断言收敛于此;
90
+ * 桥通道从 pi API 对象挂载迁到 slot:pi 0.84.4 per-extension API 隔离使
91
+ * pi.__goalInit 形态跨扩展恒不可见,slot 是 C-ext-06 惯例的进程级共享形态)。
92
+ */
93
+ function getGoalInit(): GoalInitFn | undefined {
94
+ const fn = Reflect.get(globalThis, GOAL_INIT_SLOT_KEY);
95
+ return typeof fn === "function" ? (fn as GoalInitFn) : undefined;
96
+ }
97
+
73
98
  /** Detect whether goal extension is available via its programming interface */
74
- export function detectGoalCapability(pi: ExtensionAPI): boolean {
75
- try {
76
- // 交叉类型单步断言(ExtensionAPI 可赋给 ExtensionAPI & { __goalInit? })
77
- const api = pi as ExtensionAPI & { __goalInit?: GoalInitFn };
78
- return typeof api.__goalInit === "function";
79
- } catch {
80
- return false;
81
- }
99
+ export function detectGoalCapability(): boolean {
100
+ return getGoalInit() !== undefined;
82
101
  }
83
102
 
84
103
  /**
@@ -128,29 +147,56 @@ export function buildPlanSuccessCriteria(planFilePath: string, tasks: string[]):
128
147
  return items;
129
148
  }
130
149
 
131
- /** Try to initialize goal via programming interface */
132
- function tryGoalInit(pi: ExtensionAPI, planFilePath: string, ctx: ExtensionContext): boolean {
150
+ // ── goal outcome(D2:失败显式化)───────────────────────────────
151
+
152
+ /** goalInit 失败原因——五值与 tryGoalInit 的 5 个失败出口一一对应(设计 §6.2 D2)。 */
153
+ export type GoalBridgeFailureReason =
154
+ | "goal-unavailable" // goal 未加载(slot 不存在/值非函数——桥修复后是真实可达的防御分支:goal 档仅在 detectGoalCapability 通过时出现,但 slot 残留 fn 失效等窗口仍可能触发)
155
+ | "plan-unreadable" // plan 文件读取失败
156
+ | "no-steps" // plan 内容提取到 0 条步骤
157
+ | "init-refused" // goalInit 返回 false(已有 active goal / ctx 缺失)
158
+ | "internal-error"; // goalInit 抛出意外异常(catch 出口,含 slot 残留 fn 调用失效)
159
+
160
+ /** tryGoalInit 的结构化结果:失败分支携带 reason(+ internal-error 的异常文本)。 */
161
+ export type GoalBridgeOutcome =
162
+ | { started: true }
163
+ | { started: false; reason: GoalBridgeFailureReason; detail?: string };
164
+
165
+ /** 每个 reason 指向一个具体恢复动作(不做纯日志字符串,设计 §4.2)。 */
166
+ export const GOAL_FAILURE_RECOVERY: Record<GoalBridgeFailureReason, string> = {
167
+ "goal-unavailable": "The goal extension is not loaded — choose another execution method.",
168
+ "plan-unreadable": "Check that the plan file exists and is readable, then call plan(action='complete') again.",
169
+ "no-steps": "Add numbered steps under a '## Implementation Steps' section in the plan file, then call plan(action='complete') again.",
170
+ "init-refused": "An active goal already exists — run /goal clear first, or continue with the existing goal.",
171
+ "internal-error": "goalInit threw an unexpected exception (details in the warning notification and logs) — falling back to step-by-step execution.",
172
+ };
173
+
174
+ /** Try to initialize goal via programming interface; never throws (catch 出口 → internal-error). */
175
+ function tryGoalInit(planFilePath: string, ctx: ExtensionContext): GoalBridgeOutcome {
133
176
  try {
134
- const api = pi as ExtensionAPI & { __goalInit?: GoalInitFn };
135
- const goalInit = api.__goalInit;
136
- if (typeof goalInit !== "function") return false;
177
+ const goalInit = getGoalInit();
178
+ if (!goalInit) return { started: false, reason: "goal-unavailable" };
137
179
 
138
- const planContent = readPlanFileSafe(planFilePath);
139
- if (planContent.startsWith("(")) return false; // read failed
180
+ const planFile = readPlanFile(planFilePath);
181
+ if (!planFile.ok) return { started: false, reason: "plan-unreadable" };
140
182
 
141
183
  const objective = `Execute plan: ${planFilePath}`;
142
- const tasks = extractPlanSteps(planContent);
143
- if (tasks.length === 0) return false;
184
+ const tasks = extractPlanSteps(planFile.content);
185
+ if (tasks.length === 0) return { started: false, reason: "no-steps" };
144
186
 
145
- return goalInit(
187
+ const started = goalInit(
146
188
  objective,
147
189
  undefined,
148
190
  ctx,
149
191
  buildPlanSlug(planFilePath),
150
192
  buildPlanSuccessCriteria(planFilePath, tasks),
151
193
  );
152
- } catch {
153
- return false;
194
+ return started
195
+ ? { started: true }
196
+ : { started: false, reason: "init-refused" };
197
+ } catch (error) {
198
+ logger.warn("plan: goalInit threw unexpectedly", { error: toErrorMessage(error) });
199
+ return { started: false, reason: "internal-error", detail: toErrorMessage(error) };
154
200
  }
155
201
  }
156
202
 
@@ -194,22 +240,36 @@ export function extractPlanSteps(planContent: string): string[] {
194
240
  }
195
241
 
196
242
 
197
- export function handlePlanComplete(
243
+ /**
244
+ * 投递 complete 后的执行通知(D2:goal 档先 goalInit、后按结果选 steer)。
245
+ * 成功发 goal steer——「Execute via /goal」只在 goal 真实创建成功时说出;失败发
246
+ * 含 reason 与恢复动作的降级 steer + warning notify。非 goal 档无 goalInit,按
247
+ * execMode 组 steer。返回 goalInit 的 outcome(非 goal 档为 undefined)。
248
+ */
249
+ function deliverExecutionNotice(
198
250
  pi: ExtensionAPI,
199
251
  ctx: ExtensionContext,
200
- state: PlanState,
201
- isolation: string,
252
+ planFilePath: string,
202
253
  execMode: string,
203
- ): void {
204
- const planFilePath = state.planFilePath;
254
+ ): GoalBridgeOutcome | undefined {
255
+ const outcome = execMode === "goal" ? tryGoalInit(planFilePath, ctx) : undefined;
205
256
 
206
- // Build mode-specific steer
207
257
  const modeMessages: Record<string, string> = {
208
258
  subagent: "Execute via subagent-driven development: delegate each task to an independent subagent for parallel execution.",
209
259
  goal: "Execute via /goal: set up tracked task decomposition with budget control using the goal extension.",
210
260
  "single-agent": "Execute step by step in the current session.",
211
261
  };
212
- const modeHint = modeMessages[execMode] ?? modeMessages["single-agent"];
262
+
263
+ let modeHint: string;
264
+ if (outcome === undefined) {
265
+ modeHint = modeMessages[execMode] ?? modeMessages["single-agent"];
266
+ } else if (outcome.started) {
267
+ modeHint = modeMessages.goal;
268
+ } else {
269
+ modeHint = `Goal execution was not started (${outcome.reason}). ${GOAL_FAILURE_RECOVERY[outcome.reason]} Execute step by step in the current session.`;
270
+ const detail = outcome.detail ? ` (${outcome.detail})` : "";
271
+ ctx.ui.notify(`Goal execution was not started (${outcome.reason})${detail}. ${GOAL_FAILURE_RECOVERY[outcome.reason]}`, "warning");
272
+ }
213
273
 
214
274
  const executeMessage =
215
275
  `Plan approved by user. Plan file: ${planFilePath}\n\n` +
@@ -217,6 +277,28 @@ export function handlePlanComplete(
217
277
  `${modeHint}\n\n` +
218
278
  `Read the plan file and start implementing.`;
219
279
 
280
+ pi.sendUserMessage(executeMessage, { deliverAs: "steer" });
281
+ return outcome;
282
+ }
283
+
284
+ /**
285
+ * complete 的 isolation 分发(D1 后仅 compact | direct,两档都投递执行通知)。
286
+ *
287
+ * 返回值:execMode=goal 且 isolation=direct 时同步返回 goalInit 的 outcome
288
+ * (executeComplete 写进 result content 与 details);其余情形返回 undefined——
289
+ * 非 goal 档无 goalInit,compact 档 goalInit 在 onComplete 回调内执行(goal 状态
290
+ * entry 须在压缩后的世界里创建,提前到 compact 前有被压缩边界丢弃的风险,时序
291
+ * 不动——设计 §6.2 D2),该档 result 已返回,失败报告走 steer + notify 通道。
292
+ */
293
+ export function handlePlanComplete(
294
+ pi: ExtensionAPI,
295
+ ctx: ExtensionContext,
296
+ state: PlanState,
297
+ isolation: string,
298
+ execMode: string,
299
+ ): GoalBridgeOutcome | undefined {
300
+ const planFilePath = state.planFilePath;
301
+
220
302
  switch (isolation) {
221
303
  case "compact": {
222
304
  ctx.compact({
@@ -229,8 +311,7 @@ export function handlePlanComplete(
229
311
  // 守卫的 stale 文案兜底(D1 降级语义声明的合法形态)。
230
312
  onComplete: () => {
231
313
  guardStaleCtx(() => {
232
- pi.sendUserMessage(executeMessage, { deliverAs: "steer" });
233
- tryGoalInit(pi, planFilePath, ctx);
314
+ deliverExecutionNotice(pi, ctx, planFilePath, execMode);
234
315
  }, {
235
316
  label: "plan:compact-onComplete",
236
317
  onStale: (error) => logger.warn("plan execution notice delivery skipped (stale ctx)", { error: toErrorMessage(error) }),
@@ -239,27 +320,19 @@ export function handlePlanComplete(
239
320
  onError: (_error: Error) => {
240
321
  guardStaleCtx(() => {
241
322
  ctx.ui.notify("Compact failed, continuing without isolation.", "warning");
242
- pi.sendUserMessage(executeMessage, { deliverAs: "steer" });
243
- tryGoalInit(pi, planFilePath, ctx);
323
+ deliverExecutionNotice(pi, ctx, planFilePath, execMode);
244
324
  }, {
245
325
  label: "plan:compact-onError",
246
326
  onStale: (error) => logger.warn("plan execution notice delivery skipped (stale ctx)", { error: toErrorMessage(error) }),
247
327
  });
248
328
  },
249
329
  });
250
- break;
251
- }
252
-
253
- case "tree": {
254
- ctx.ui.notify("Use /tree to manually navigate back. Plan file: " + planFilePath, "info");
255
- break;
330
+ return undefined;
256
331
  }
257
332
 
258
333
  case "direct":
259
334
  default: {
260
- pi.sendUserMessage(executeMessage, { deliverAs: "steer" });
261
- tryGoalInit(pi, planFilePath, ctx);
262
- break;
335
+ return deliverExecutionNotice(pi, ctx, planFilePath, execMode);
263
336
  }
264
337
  }
265
338
  }