@zhushanwen/pi-plan 0.4.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/command.ts CHANGED
@@ -1,21 +1,107 @@
1
1
  import * as fs from "node:fs";
2
+ import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
 
4
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
6
 
6
- import type { PlanSessionMap, PlanState } from "./state.js";
7
- import { getPlanState, persistPlanState, resetPlanState } from "./state.js";
7
+ import { activatePlanMode, resolveSkills } from "./enter.js";
8
+ import type { SkillResolution } from "./enter.js";
9
+ import type { SkillRef } from "./prompts.js";
10
+ import type { PlanAbortControllers, PlanSessionMap, PlanState } from "./state.js";
11
+ import { PLAN_CONTEXT_CUSTOM_TYPE, getPlanState, resetPlanState } from "./state.js";
8
12
  import { updatePlanWidget } from "./widget.js";
9
13
 
10
- const MAX_SLUG_LENGTH = 30;
14
+ /** /plan 参数解析产物:requirement = 最早 flag 标记前的自由文本;skills / templatePath 为 undefined = 未提供对应 flag */
15
+ export interface ParsedPlanArgs {
16
+ requirement: string;
17
+ skills: string[] | undefined;
18
+ /** --template 的原始值(flag 后至命令结尾整段 trim,不切分——含空格路径天然支持,无需引号约定) */
19
+ templatePath: string | undefined;
20
+ }
21
+
22
+ /**
23
+ * --skills / --template 解析定则:
24
+ * - 标记 = 独立 token(\b 词边界防 `--skillsabc` / `--templatex` 误切);
25
+ * - 值语义两 flag 同构 = flag 之后至命令结尾的整段文本(requirement 取最早出现
26
+ * flag 之前的部分):
27
+ * - `--skills` 按逗号原样切分 + 每项去首尾空格——内部空格保留(「code review」
28
+ * 「中文 技能」不丢字符),切分后空项丢弃(容忍尾逗号/连续逗号);
29
+ * - `--template` 不切分——trim 后整段即路径(D5);
30
+ * - flag 提供但值为空 = 显式错误(E1 fail-fast,不静默忽略——用户显式用了 flag
31
+ * 却没给值多半是打错了);
32
+ * - 两 flag 同给 = 互斥 fail-fast(消费方 handleEnterPlanMode 判定,§3.1——
33
+ * 解析层照常返回两个字段供其判定)。
34
+ */
35
+ export function parsePlanArgs(args: string): ParsedPlanArgs {
36
+ const skillsMatch = /(?:^|\s)--skills\b/.exec(args);
37
+ const templateMatch = /(?:^|\s)--template\b/.exec(args);
38
+ if (!skillsMatch && !templateMatch) {
39
+ return { requirement: args.trim(), skills: undefined, templatePath: undefined };
40
+ }
41
+ const firstFlagIndex = Math.min(
42
+ skillsMatch?.index ?? Number.POSITIVE_INFINITY,
43
+ templateMatch?.index ?? Number.POSITIVE_INFINITY,
44
+ );
45
+ const requirement = args.slice(0, firstFlagIndex).trim();
46
+ const skills = skillsMatch === null
47
+ ? undefined
48
+ : args
49
+ .slice(skillsMatch.index + skillsMatch[0].length)
50
+ .trim()
51
+ .split(",")
52
+ .map((s) => s.trim())
53
+ .filter((s) => s.length > 0);
54
+ const templatePath = templateMatch === null
55
+ ? undefined
56
+ : args.slice(templateMatch.index + templateMatch[0].length).trim();
57
+ return { requirement, skills, templatePath };
58
+ }
59
+
60
+ /** --template 校验结果:ok=false 时 problem 为 fail-fast 回复的问题句(自带用法样例) */
61
+ export type TemplateFileResolution =
62
+ | { ok: true; absPath: string }
63
+ | { ok: false; problem: string };
64
+
65
+ /** --template 系报错附带的用法样例(错误 → 纠正闭环) */
66
+ const TEMPLATE_USAGE_SAMPLE = "e.g. /plan <requirement> --template /path/to/template.md";
67
+
68
+ /**
69
+ * --template 值解析与校验(D5):
70
+ * - `~` 前缀展开(os.homedir,仅 `~` 本身与 `~/` 前缀——`~name` 不展开,
71
+ * 无 per-user home 展开语义);
72
+ * - 相对路径相对 ctx.cwd(pi 进程 cwd = 项目根)解析,不做额外路径猜测;
73
+ * - 存在性校验先于 .md 校验,两失败文案分家(not found / not a markdown file),
74
+ * 报错一律带解析后的绝对路径供用户核对。
75
+ */
76
+ export function resolveTemplateFile(raw: string, projectDir: string): TemplateFileResolution {
77
+ const trimmed = raw.trim();
78
+ if (!trimmed) {
79
+ return { ok: false, problem: `--template was given but no path followed it. ${TEMPLATE_USAGE_SAMPLE}` };
80
+ }
81
+ const expanded = trimmed === "~" || trimmed.startsWith("~/") ? path.join(os.homedir(), trimmed.slice(1)) : trimmed;
82
+ const absPath = path.resolve(projectDir, expanded);
83
+ if (!fs.existsSync(absPath)) {
84
+ return { ok: false, problem: `Template file not found: ${absPath}. ${TEMPLATE_USAGE_SAMPLE}` };
85
+ }
86
+ if (!absPath.endsWith(".md")) {
87
+ return { ok: false, problem: `Not a markdown file: ${absPath}. ${TEMPLATE_USAGE_SAMPLE}` };
88
+ }
89
+ return { ok: true, absPath };
90
+ }
91
+
92
+ /**
93
+ * E1 校验与技能名归一已迁至 enter.ts(slash 与 plan(enter) tool 两入口共用)。
94
+ * 本文件只保留 slash 命令的入参解析(--skills/--template flag)与投递通道。
95
+ */
11
96
 
12
97
  export function registerPlanCommand(
13
98
  pi: ExtensionAPI,
14
99
  sessions: PlanSessionMap,
100
+ controllers: PlanAbortControllers,
15
101
  ): void {
16
102
  pi.registerCommand("plan", {
17
103
  description:
18
- "Enter plan mode: /plan [description]. " +
104
+ "Enter plan mode: /plan [description] [--skills a,b] or /plan [description] --template <path-to-markdown>. " +
19
105
  "Subcommands: /plan abort, /plan status. " +
20
106
  "With no args, show status or detect existing plan.",
21
107
  getArgumentCompletions(prefix: string) {
@@ -36,7 +122,7 @@ export function registerPlanCommand(
36
122
 
37
123
  // Subcommand: abort
38
124
  if (trimmed === "abort") {
39
- await handleAbort(pi, sessions, sessionId, ctx, state);
125
+ await handleAbort(pi, sessions, controllers, sessionId, ctx, state);
40
126
  return;
41
127
  }
42
128
 
@@ -58,13 +144,14 @@ export function registerPlanCommand(
58
144
  return;
59
145
  }
60
146
 
61
- // Reentry: check for existing plan files in .taiji-harness/
147
+ // Reentry: check for existing plan files in .tmp/plans/
62
148
  if (!state.isActive && !trimmed) {
63
149
  const projectDir = ctx.cwd;
64
- const harnessDir = path.join(projectDir, ".taiji-harness");
65
- const existingPlans = findExistingPlans(harnessDir);
150
+ const plansDir = path.join(projectDir, ".tmp", "plans");
151
+ const existingPlans = findExistingPlans(plansDir);
66
152
  if (existingPlans.length > 0) {
67
- pi.sendUserMessage(
153
+ sendPlanContextMessage(
154
+ pi,
68
155
  `[PLAN MODE] Found existing plan files:\n${existingPlans.map((p, i) => ` ${i + 1}. ${p}`).join("\n")}\n\n` +
69
156
  `Choose an option:\n` +
70
157
  ` a) Continue existing plan\n` +
@@ -77,7 +164,7 @@ export function registerPlanCommand(
77
164
  }
78
165
 
79
166
  // Enter plan mode
80
- handleEnterPlanMode(pi, sessions, sessionId, ctx, state, trimmed);
167
+ handleEnterPlanMode(pi, sessions, sessionId, ctx, state, args);
81
168
  },
82
169
  });
83
170
  }
@@ -86,6 +173,7 @@ export function registerPlanCommand(
86
173
  async function handleAbort(
87
174
  pi: ExtensionAPI,
88
175
  sessions: PlanSessionMap,
176
+ controllers: PlanAbortControllers,
89
177
  sessionId: string,
90
178
  ctx: ExtensionContext,
91
179
  state: PlanState,
@@ -94,6 +182,12 @@ async function handleAbort(
94
182
  ctx.ui.notify("No active plan mode.", "info");
95
183
  return;
96
184
  }
185
+ // E10 顺序不可反(因果链):controller.abort() → 挂起 select resolve undefined →
186
+ // tool execute 走「已取消」分支返回 → turn 正常结束 → agent_settled 到达 →
187
+ // busy defer 队列恢复投递。若先 reset 后 abort,挂起 select 无人 resolve 且
188
+ // runtime 不超时 → pi turn 永占用 → 后续消息永 defer → session 只能 forceQuit。
189
+ controllers.get(sessionId)?.abort();
190
+ controllers.delete(sessionId);
97
191
  const updatedState = resetPlanState(pi, sessions, sessionId, ctx);
98
192
  updatePlanWidget(ctx, updatedState);
99
193
  // Restore full tool set (SDK does NOT support undefined)
@@ -110,26 +204,69 @@ function handleStatus(
110
204
  ctx.ui.notify("No active plan mode.", "info");
111
205
  return;
112
206
  }
207
+ const skillsLine = state.skills.length > 0 ? `\nSkills: ${state.skills.join(", ")}` : "";
208
+ const docsLine = state.docs.length > 0 ? `\nDocuments: ${state.docs.map((d) => `${d.fileName} (v${d.version})`).join(", ")}` : "";
113
209
  ctx.ui.notify(
114
- `Plan: ${state.planFilePath}\nTemplate: ${state.templateName || "(not selected)"}`,
210
+ `Plan: ${state.planFilePath}\nTemplate: ${state.templateName || "(not selected)"}${skillsLine}${docsLine}`,
115
211
  "info",
116
212
  );
117
213
  }
118
214
 
119
- /** Find existing plan.md files in .taiji-harness/ subdirectories */
120
- function findExistingPlans(harnessDir: string): string[] {
215
+ /** Find existing plan.md files in .tmp/plans/ subdirectories */
216
+ function findExistingPlans(plansDir: string): string[] {
121
217
  try {
122
- return fs.readdirSync(harnessDir)
218
+ return fs.readdirSync(plansDir)
123
219
  .filter((f) => {
124
- const subDir = path.join(harnessDir, f);
220
+ const subDir = path.join(plansDir, f);
125
221
  return fs.statSync(subDir).isDirectory() && fs.existsSync(path.join(subDir, "plan.md"));
126
222
  })
127
- .map((f) => path.join(harnessDir, f, "plan.md"));
223
+ .map((f) => path.join(plansDir, f, "plan.md"));
128
224
  } catch {
129
225
  return [];
130
226
  }
131
227
  }
132
228
 
229
+ /**
230
+ * plan 上下文消息单点(slash 通道 4 个 sendMessage 位共用形态):custom message 注入
231
+ * (display:false——提示词全文消费者是 LLM,用户感知走 plan widget 状态呈现,不占用户
232
+ * 气泡)+ triggerTurn:true 保留原开轮语义。
233
+ */
234
+ function sendPlanContextMessage(pi: ExtensionAPI, content: string): void {
235
+ pi.sendMessage(
236
+ { customType: PLAN_CONTEXT_CUSTOM_TYPE, content, display: false },
237
+ { triggerTurn: true },
238
+ );
239
+ }
240
+
241
+ /** E1 fail-fast 回复:不进入计划模式(不写 entry / 不限制工具 / 不注入计划提示词),回复可用技能清单与纠正命令 */
242
+ function reportUnknownSkills(pi: ExtensionAPI, resolution: Extract<SkillResolution, { ok: false }>): void {
243
+ const available = resolution.available.length > 0
244
+ ? resolution.available.map((n) => ` - ${n}`).join("\n")
245
+ : " (no skills installed)";
246
+ const problem = resolution.missing.length > 0
247
+ ? `unknown skill(s): ${resolution.missing.join(", ")}`
248
+ : "--skills was given but no skill names followed it";
249
+ sendPlanContextMessage(
250
+ pi,
251
+ `[PLAN MODE] Failed to enter: ${problem}.\n\n` +
252
+ `Available skills:\n${available}\n\n` +
253
+ `Do NOT enter plan mode. Reply to the user listing the available skills and the corrected command, e.g. /plan <requirement> --skills <skill1>,<skill2>.`,
254
+ );
255
+ }
256
+
257
+ /**
258
+ * --template 系 fail-fast 回复(E1 同款形态):不进入计划模式(不写 entry /
259
+ * 不限制工具 / 不注入计划提示词),回复问题句(互斥/不存在/非 md,problem 内
260
+ * 已带用法样例)。
261
+ */
262
+ function reportTemplateFlagError(pi: ExtensionAPI, problem: string): void {
263
+ sendPlanContextMessage(
264
+ pi,
265
+ `[PLAN MODE] Failed to enter: ${problem}.\n\n` +
266
+ `Do NOT enter plan mode. Reply to the user with the problem and the corrected command.`,
267
+ );
268
+ }
269
+
133
270
  /** Handle entering plan mode */
134
271
  function handleEnterPlanMode(
135
272
  pi: ExtensionAPI,
@@ -137,51 +274,72 @@ function handleEnterPlanMode(
137
274
  sessionId: string,
138
275
  ctx: ExtensionContext,
139
276
  state: PlanState,
140
- requirement: string,
277
+ args: string,
141
278
  ): void {
142
- const slug = requirement
143
- ? requirement.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, MAX_SLUG_LENGTH)
144
- : "untitled";
145
-
146
- const projectDir = ctx.cwd;
147
- const planDir = path.join(projectDir, ".taiji-harness", slug);
148
- fs.mkdirSync(planDir, { recursive: true });
149
- const planFilePath = path.join(planDir, "plan.md");
150
-
151
- state.isActive = true;
152
- state.planFilePath = planFilePath;
153
- state.requirement = requirement;
154
- state.templateName = "";
155
-
156
- persistPlanState(pi, state);
157
- updatePlanWidget(ctx, state);
158
-
159
- // Restrict tools to the plan-mode set (includes bash — file-write constraints come from the injected plan mode prompt below)
160
- pi.setActiveTools(["read", "bash", "grep", "find", "ls", "plan"]);
161
-
162
- // Inject plan mode system prompt inline
163
- pi.sendUserMessage(
164
- `[PLAN MODE] Entered plan mode.\n\n` +
165
- `Requirement: ${requirement || "(from conversation context)"}\n` +
166
- `Plan file: ${planFilePath}\n\n` +
167
- `## Constraints\n` +
168
- `- READ-ONLY: Do NOT edit any files except the plan file (${planFilePath}).\n` +
169
- `- Do NOT run write commands (mkdir, echo, sed, etc.) on non-plan files.\n` +
170
- `- All plan content goes to the plan file only.\n\n` +
171
- `## Phase B: Brainstorming\n` +
172
- `1. **Quick Overview**: ls project root, read README, package.json — build context (< 30s).\n` +
173
- `2. **Explore before asking**: grep/read code first. Only ask user for preferences, not code-fact questions.\n` +
174
- `3. **Progressive questioning**: Ask 2-3 questions at a time. Use ask_user tool if available.\n` +
175
- `4. **Propose 2-3 approaches** with trade-offs + recommendation.\n` +
176
- `5. **Assumption audit**: Grep-verify interfaces/types exist. Mark [UNVERIFIED] what can't be verified.\n\n` +
177
- `## Phase C: Writing\n` +
178
- `1. Call plan tool (list-template) to show available templates.\n` +
179
- `2. After user selects template, call plan tool (select-template).\n` +
180
- `3. Write chapters in template order — do NOT skip unwritten chapters.\n` +
181
- `4. Write all chapters in one turn, then ask user to review.\n\n` +
182
- `## Phase D: Completion\n` +
183
- `1. Ask user to review the complete plan.\n` +
184
- `2. Call plan tool (complete) with isolation method (compact/direct).\n` +
185
- `3. After plan complete: the user picks an execution path (subagent-driven / goal-driven / single-agent) via the completion dialog.`,
186
- );
279
+ // flag 解析 + 校验先行:--template / --skills 任一校验失败 fail-fast,
280
+ // 不产生任何进入动作(PlanModeBar 不出现:不写 entry / 不限制工具 / 不注入提示词)
281
+ const parsed = parsePlanArgs(args);
282
+
283
+ // 互斥 fail-fast(§3.1):两 flag 同给先于一切值校验报错
284
+ if (parsed.skills !== undefined && parsed.templatePath !== undefined) {
285
+ reportTemplateFlagError(
286
+ pi,
287
+ "--template and --skills are mutually exclusive. Use one: /plan <requirement> --skills a,b | /plan <requirement> --template <path>",
288
+ );
289
+ return;
290
+ }
291
+
292
+ let templateAbsPath: string | undefined;
293
+ let templateContent: string | undefined;
294
+ if (parsed.templatePath !== undefined) {
295
+ const resolution = resolveTemplateFile(parsed.templatePath, ctx.cwd);
296
+ if (!resolution.ok) {
297
+ reportTemplateFlagError(pi, resolution.problem);
298
+ return;
299
+ }
300
+ // 全文在校验通过后、任何进入副作用(entry/工具限制)之前读好——读失败
301
+ // (权限/竞态删除)同族 fail-fast,不留「已持久 isActive 但提示词没注入」
302
+ // 的半进入态(D5)
303
+ try {
304
+ templateContent = fs.readFileSync(resolution.absPath, "utf-8");
305
+ } catch {
306
+ reportTemplateFlagError(pi, `Template file not readable: ${resolution.absPath}. ${TEMPLATE_USAGE_SAMPLE}`);
307
+ return;
308
+ }
309
+ templateAbsPath = resolution.absPath;
310
+ }
311
+
312
+ // --skills E1 校验(互斥已判过后,走到这里 templatePath 必为 undefined)
313
+ const requested = parsed.skills ?? [];
314
+ if (parsed.skills !== undefined && requested.length === 0) {
315
+ reportUnknownSkills(pi, { ok: false, available: pi.getCommands().filter((c) => c.source === "skill").map((c) => c.name), missing: [] });
316
+ return;
317
+ }
318
+ let resolved: SkillRef[] = [];
319
+ if (requested.length > 0) {
320
+ const resolution = resolveSkills(pi, requested);
321
+ if (!resolution.ok) {
322
+ reportUnknownSkills(pi, resolution);
323
+ return;
324
+ }
325
+ resolved = resolution.resolved;
326
+ }
327
+
328
+ const requirement = parsed.requirement;
329
+ // 进入核心收敛到 enter.ts(plan(enter) tool 与 slash 命令共用);本入口只负责
330
+ // flag 解析/校验(上方)与提示词投递(下方 sendMessage custom message 注入)。
331
+ // state 由 activatePlanMode 就地改 + persist(getPlanState 缓存同一对象)。
332
+ const { prompt } = activatePlanMode(pi, sessions, sessionId, ctx, {
333
+ requirement,
334
+ skills: resolved,
335
+ projectDir: ctx.cwd,
336
+ ...(templateAbsPath !== undefined && templateContent !== undefined
337
+ ? { template: { absPath: templateAbsPath, content: templateContent } }
338
+ : {}),
339
+ });
340
+
341
+ // Inject plan mode prompt as custom message(display/triggerTurn 语义见 sendPlanContextMessage。
342
+ // 非 streaming 直调 _runAgentPrompt 跳过 prompt() 前置链,首轮 systemPrompt 叠加
343
+ // 差异已登记为可接受——设计 §1.1-⑥ / §2.2 P1。tool 入口走 tool result)
344
+ sendPlanContextMessage(pi, prompt);
187
345
  }
package/src/compact.ts CHANGED
@@ -6,8 +6,9 @@ import { guardStaleCtx, toErrorMessage } from "@zhushanwen/pi-ext-guards";
6
6
  import type { GoalInitFn } from "@zhushanwen/pi-goal";
7
7
  import { getLogger } from "@zhushanwen/pi-extension-logger";
8
8
 
9
+ import { t } from "./i18n.js";
9
10
  import type { PlanSessionMap, PlanState } from "./state.js";
10
- import { getPlanState } from "./state.js";
11
+ import { PLAN_CONTEXT_CUSTOM_TYPE, getPlanState } from "./state.js";
11
12
 
12
13
  const logger = getLogger("pi-plan");
13
14
 
@@ -115,6 +116,9 @@ function buildPlanSlug(planFilePath: string): string {
115
116
 
116
117
  /** step preview 条数上限(1 条总述 + 3 条 preview,合计 ≤4 条,满足 goal schema maxItems:8) */
117
118
  const PREVIEW_COUNT = 3;
119
+
120
+ /** skill 档 execMode 前缀(D10:`skill:<name>` 动态项,与 tool.ts 选项构造同源约定) */
121
+ const SKILL_MODE_PREFIX = "skill:";
118
122
  /** 单条 preview 最大长度(超出部分截断,以 "..." 结尾) */
119
123
  const PREVIEW_MAX_CHARS = 80;
120
124
  const ELLIPSIS = "...";
@@ -151,7 +155,7 @@ export function buildPlanSuccessCriteria(planFilePath: string, tasks: string[]):
151
155
 
152
156
  /** goalInit 失败原因——五值与 tryGoalInit 的 5 个失败出口一一对应(设计 §6.2 D2)。 */
153
157
  export type GoalBridgeFailureReason =
154
- | "goal-unavailable" // goal 未加载(slot 不存在/值非函数——桥修复后是真实可达的防御分支:goal 档仅在 detectGoalCapability 通过时出现,但 slot 残留 fn 失效等窗口仍可能触发)
158
+ | "goal-unavailable" // goal 未加载(slot 不存在/值非函数——execute 档无条件尝试 goalInit,goal 扩展未装/未挂 slot 时即走此出口,独立 pi 常态分支)
155
159
  | "plan-unreadable" // plan 文件读取失败
156
160
  | "no-steps" // plan 内容提取到 0 条步骤
157
161
  | "init-refused" // goalInit 返回 false(已有 active goal / ctx 缺失)
@@ -164,7 +168,7 @@ export type GoalBridgeOutcome =
164
168
 
165
169
  /** 每个 reason 指向一个具体恢复动作(不做纯日志字符串,设计 §4.2)。 */
166
170
  export const GOAL_FAILURE_RECOVERY: Record<GoalBridgeFailureReason, string> = {
167
- "goal-unavailable": "The goal extension is not loaded — choose another execution method.",
171
+ "goal-unavailable": "The goal extension is not loaded — execute directly without goal tracking.",
168
172
  "plan-unreadable": "Check that the plan file exists and is readable, then call plan(action='complete') again.",
169
173
  "no-steps": "Add numbered steps under a '## Implementation Steps' section in the plan file, then call plan(action='complete') again.",
170
174
  "init-refused": "An active goal already exists — run /goal clear first, or continue with the existing goal.",
@@ -241,34 +245,42 @@ export function extractPlanSteps(planContent: string): string[] {
241
245
 
242
246
 
243
247
  /**
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
+ * 投递 complete 后的执行通知(2026-09-21 选项集重排:mode 值域 = execute | skill:<name>;
249
+ * execute 档整合 goal 桥 + auto-parallel subagent——goal 可用时先 goalInit 建跟踪,
250
+ * 失败/不可用降级为直接执行的 steer 指令,用户可见失败提示走 notify i18n)。
251
+ * skill 档动态构造 steer(含 skillEntryPath 路径,对齐 register-doc sourceSkill 的 skill 关联
252
+ * 先例;skillEntryPath = skill 入口文件路径——标准形态 SKILL.md 路径 / 散 .md 形态文件本身,
253
+ * 直接 read 不再拼 SKILL.md)。返回 goalInit 的 outcome(非 execute 档为 undefined)。
248
254
  */
249
255
  function deliverExecutionNotice(
250
256
  pi: ExtensionAPI,
251
257
  ctx: ExtensionContext,
252
258
  planFilePath: string,
253
259
  execMode: string,
260
+ skillEntryPath?: string,
254
261
  ): GoalBridgeOutcome | undefined {
255
- const outcome = execMode === "goal" ? tryGoalInit(planFilePath, ctx) : undefined;
256
-
257
- const modeMessages: Record<string, string> = {
258
- subagent: "Execute via subagent-driven development: delegate each task to an independent subagent for parallel execution.",
259
- goal: "Execute via /goal: set up tracked task decomposition with budget control using the goal extension.",
260
- "single-agent": "Execute step by step in the current session.",
261
- };
262
+ // execute 档整合 goal 桥:tryGoalInit 内部含 goal-unavailable gate(goal 未挂载走
263
+ // started:false 降级),无需前置 detectGoalCapability 探测
264
+ const outcome = execMode === "execute" ? tryGoalInit(planFilePath, ctx) : undefined;
262
265
 
263
266
  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.`;
267
+ if (execMode.startsWith(SKILL_MODE_PREFIX)) {
268
+ const skillName = execMode.slice(SKILL_MODE_PREFIX.length);
269
+ modeHint = skillEntryPath
270
+ ? `Execute via skill: read the ${skillName} skill at ${skillEntryPath} first, then follow its workflow to execute the plan file.`
271
+ : `Execute via skill: load the ${skillName} skill and follow its workflow to execute the plan file.`;
272
+ } else if (outcome?.started) {
273
+ modeHint =
274
+ "Goal tracking is active via /goal — execute the plan through the goal workflow: delegate independent, parallelizable tasks to subagents; run small or tightly-coupled steps in this session.";
275
+ } else if (outcome !== undefined) {
276
+ modeHint =
277
+ `Goal tracking was not started (${outcome.reason}). ${GOAL_FAILURE_RECOVERY[outcome.reason]} ` +
278
+ `Execute directly instead: judge by task complexity — delegate independent, parallelizable tasks to subagents; run small or tightly-coupled steps step by step in the current session.`;
270
279
  const detail = outcome.detail ? ` (${outcome.detail})` : "";
271
- ctx.ui.notify(`Goal execution was not started (${outcome.reason})${detail}. ${GOAL_FAILURE_RECOVERY[outcome.reason]}`, "warning");
280
+ ctx.ui.notify(`${t("exec.goalFailedNotify", { reason: outcome.reason })}${detail}`, "warning");
281
+ } else {
282
+ modeHint =
283
+ "Execute by judging task complexity: delegate independent, parallelizable tasks to subagents; execute small or tightly-coupled steps step by step in the current session.";
272
284
  }
273
285
 
274
286
  const executeMessage =
@@ -277,16 +289,19 @@ function deliverExecutionNotice(
277
289
  `${modeHint}\n\n` +
278
290
  `Read the plan file and start implementing.`;
279
291
 
280
- pi.sendUserMessage(executeMessage, { deliverAs: "steer" });
292
+ pi.sendMessage(
293
+ { customType: PLAN_CONTEXT_CUSTOM_TYPE, content: executeMessage, display: false },
294
+ { deliverAs: "steer", triggerTurn: true },
295
+ );
281
296
  return outcome;
282
297
  }
283
298
 
284
299
  /**
285
300
  * complete 的 isolation 分发(D1 后仅 compact | direct,两档都投递执行通知)。
286
301
  *
287
- * 返回值:execMode=goal 且 isolation=direct 时同步返回 goalInit 的 outcome
302
+ * 返回值:execMode=execute 且 isolation=direct 时同步返回 goalInit 的 outcome
288
303
  * (executeComplete 写进 result content 与 details);其余情形返回 undefined——
289
- * 非 goal 档无 goalInit,compact 档 goalInit 在 onComplete 回调内执行(goal 状态
304
+ * skill 档无 goalInit,compact 档 goalInit 在 onComplete 回调内执行(goal 状态
290
305
  * entry 须在压缩后的世界里创建,提前到 compact 前有被压缩边界丢弃的风险,时序
291
306
  * 不动——设计 §6.2 D2),该档 result 已返回,失败报告走 steer + notify 通道。
292
307
  */
@@ -296,6 +311,7 @@ export function handlePlanComplete(
296
311
  state: PlanState,
297
312
  isolation: string,
298
313
  execMode: string,
314
+ skillEntryPath?: string,
299
315
  ): GoalBridgeOutcome | undefined {
300
316
  const planFilePath = state.planFilePath;
301
317
 
@@ -311,7 +327,7 @@ export function handlePlanComplete(
311
327
  // 守卫的 stale 文案兜底(D1 降级语义声明的合法形态)。
312
328
  onComplete: () => {
313
329
  guardStaleCtx(() => {
314
- deliverExecutionNotice(pi, ctx, planFilePath, execMode);
330
+ deliverExecutionNotice(pi, ctx, planFilePath, execMode, skillEntryPath);
315
331
  }, {
316
332
  label: "plan:compact-onComplete",
317
333
  onStale: (error) => logger.warn("plan execution notice delivery skipped (stale ctx)", { error: toErrorMessage(error) }),
@@ -320,7 +336,7 @@ export function handlePlanComplete(
320
336
  onError: (_error: Error) => {
321
337
  guardStaleCtx(() => {
322
338
  ctx.ui.notify("Compact failed, continuing without isolation.", "warning");
323
- deliverExecutionNotice(pi, ctx, planFilePath, execMode);
339
+ deliverExecutionNotice(pi, ctx, planFilePath, execMode, skillEntryPath);
324
340
  }, {
325
341
  label: "plan:compact-onError",
326
342
  onStale: (error) => logger.warn("plan execution notice delivery skipped (stale ctx)", { error: toErrorMessage(error) }),
@@ -332,7 +348,7 @@ export function handlePlanComplete(
332
348
 
333
349
  case "direct":
334
350
  default: {
335
- return deliverExecutionNotice(pi, ctx, planFilePath, execMode);
351
+ return deliverExecutionNotice(pi, ctx, planFilePath, execMode, skillEntryPath);
336
352
  }
337
353
  }
338
354
  }
package/src/enter.ts ADDED
@@ -0,0 +1,133 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
6
+ import { buildPlanModePrompt } from "./prompts.js";
7
+ import type { SkillRef } from "./prompts.js";
8
+ import type { PlanSessionMap, PlanState } from "./state.js";
9
+ import { capPlanRequirement, getPlanState, persistPlanState, PLAN_MODE_TOOLS } from "./state.js";
10
+ import { updatePlanWidget } from "./widget.js";
11
+
12
+ export const MAX_SLUG_LENGTH = 30;
13
+
14
+ /** E1 技能解析结果:ok=false 时携带缺失项与可用清单(fail-fast 回复的材料) */
15
+ export type SkillResolution =
16
+ | { ok: true; resolved: SkillRef[] }
17
+ | { ok: false; available: string[]; missing: string[] };
18
+
19
+ /**
20
+ * 技能名归一:剥掉 pi 命令命名空间前导 `skill:` 前缀,得到自然技能名。
21
+ * pi.getCommands() 枚举 skill 类命令时 name 带 `skill:` 前缀(如 `skill:tech-design`),
22
+ * 这是 pi 的实现细节,不得泄漏到输入面——slash `--skills` 与 plan(enter) 的 skills 参数
23
+ * 都用自然技能名。枚举侧与输入侧双向剥前缀后比对,兼容两种形态。
24
+ */
25
+ export function normalizeSkillName(name: string): string {
26
+ return name.startsWith("skill:") ? name.slice("skill:".length) : name;
27
+ }
28
+
29
+ /**
30
+ * E1 校验:pi.getCommands() 过滤 source === "skill" 枚举比对(技能枚举与路径
31
+ * 经 pi 取得,不自扫描目录——D2)。比对前双向剥 `skill:` 前缀归一:resolved/missing
32
+ * 用归一后的短名;available 维持枚举原形态(错误信息里可直接复制为 pi 命令)。
33
+ * slash 命令与 plan(enter) tool 两入口共用(自 command.ts 迁入 enter.ts)。
34
+ */
35
+ export function resolveSkills(pi: ExtensionAPI, requested: string[]): SkillResolution {
36
+ const skillCommands = pi.getCommands().filter((c) => c.source === "skill");
37
+ const byShortName = new Map(skillCommands.map((c) => [normalizeSkillName(c.name), c.sourceInfo.path]));
38
+ const available = skillCommands.map((c) => c.name);
39
+ const resolved: SkillRef[] = [];
40
+ const missing: string[] = [];
41
+ for (const raw of requested) {
42
+ const name = normalizeSkillName(raw);
43
+ const skillPath = byShortName.get(name);
44
+ if (skillPath === undefined) {
45
+ missing.push(name);
46
+ } else {
47
+ resolved.push({ name, skillPath });
48
+ }
49
+ }
50
+ return missing.length > 0 ? { ok: false, available, missing } : { ok: true, resolved };
51
+ }
52
+
53
+ /** 进入 plan 模式的归一入参(slash 命令与 plan(enter) tool 两入口共用) */
54
+ export interface ActivatePlanModeInput {
55
+ requirement: string;
56
+ /** E1 校验后的技能引用(未挂载传空数组) */
57
+ skills: SkillRef[];
58
+ /** 项目根锚点(= ctx.cwd),plan 目录与模板扫描的公共锚 */
59
+ projectDir: string;
60
+ /** --template 直传时给出(与 skills 互斥,命令层已校验并读好全文) */
61
+ template?: { absPath: string; content: string };
62
+ }
63
+
64
+ /** 进入产物:状态引用 + 直达模型的 plan 模式提示词(两入口分别走 sendMessage custom message / tool result 投递) */
65
+ export interface ActivatePlanModeOutcome {
66
+ state: PlanState;
67
+ prompt: string;
68
+ }
69
+
70
+ /**
71
+ * 进入 plan 模式的共享核心(plan-mode-agent-enter U1):slash 命令 handler 与 plan(enter)
72
+ * tool 两入口收敛同一实现——状态建立 + 持久化 + widget + 工具收拢 + 提示词构造。
73
+ *
74
+ * 不做的事(留给调用方):
75
+ * - 提示词投递通道:slash 入口经 sendMessage custom message(display:false——提示词
76
+ * 全文消费者是 LLM,用户感知走 plan widget 状态呈现;triggerTurn:true 开轮);tool 入口经
77
+ * tool result content 直返(对本次 tool 调用的直接响应,agent 同轮即见——不赌 steer 排队)。
78
+ * - flag 解析 / 模板文件读取 / 技能名归一:命令层与 tool 层各自的入参形态不同,前置已完成。
79
+ *
80
+ * 副作用顺序钉死:状态字段 → persistPlanState(entry 落盘)→ updatePlanWidget →
81
+ * setActiveTools → 构造 prompt。persist 先于工具收拢,保证「已持久 isActive 但工具未收」
82
+ * 的半进入态不可达(崩溃窗口内重开 session 经 entry 恢复 isActive,session_start hook
83
+ * 会补 setActiveTools)。
84
+ */
85
+ export function activatePlanMode(
86
+ pi: ExtensionAPI,
87
+ sessions: PlanSessionMap,
88
+ sessionId: string,
89
+ ctx: ExtensionContext,
90
+ input: ActivatePlanModeInput,
91
+ ): ActivatePlanModeOutcome {
92
+ const state = getPlanState(sessions, sessionId, ctx);
93
+ const { requirement, skills, projectDir } = input;
94
+
95
+ const slug = requirement
96
+ ? requirement.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, MAX_SLUG_LENGTH)
97
+ : "untitled";
98
+
99
+ const planDir = path.join(projectDir, ".tmp", "plans", slug);
100
+ fs.mkdirSync(planDir, { recursive: true });
101
+ const planFilePath = path.join(planDir, "plan.md");
102
+
103
+ state.isActive = true;
104
+ state.planFilePath = planFilePath;
105
+ // state/entry/plan 帧侧 requirement 64KB 封顶(帧有界前提);prompt 仍用未封顶全文直达模型
106
+ state.requirement = capPlanRequirement(requirement);
107
+ // --template 直传:templateName = 去扩展名 basename(GUI / /plan status 展示);
108
+ // 直传事实另落 templateProvidedPath(select-template 防御的判定信号,D7)
109
+ state.templateName = input.template ? path.basename(input.template.absPath, ".md") : "";
110
+ state.templateProvidedPath = input.template?.absPath;
111
+ state.skills = skills.map((s) => s.name);
112
+ state.docs = [];
113
+ // 新轮次重置:reviewState、来源标记与指纹基线随进入失效(与 resetPlanState 对齐;
114
+ // reviewStateSource 残留会让新一轮降级态渲染上一轮来源文案——C-U2 同型残留)
115
+ delete state.reviewState;
116
+ delete state.reviewStateSource;
117
+ delete state.lastSubmitReviewDocsFingerprint;
118
+
119
+ persistPlanState(pi, state);
120
+ updatePlanWidget(ctx, state);
121
+ // 收拢工具到 plan 模式白名单(含 bash——文件写约束来自下方注入的只读纪律提示词)
122
+ pi.setActiveTools(PLAN_MODE_TOOLS);
123
+
124
+ const prompt = buildPlanModePrompt({
125
+ requirement,
126
+ planFilePath,
127
+ projectRoot: projectDir,
128
+ skills,
129
+ ...(input.template !== undefined ? { template: input.template } : {}),
130
+ });
131
+
132
+ return { state, prompt };
133
+ }