@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.
@@ -0,0 +1,407 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
+
5
+ import {
6
+ CONFIG_DIR_NAME,
7
+ getAgentDir,
8
+ loadSkillsFromDir,
9
+ parseFrontmatter,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import type { Skill } from "@earendil-works/pi-coding-agent";
12
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
13
+
14
+ const logger = getLogger("pi-plan");
15
+
16
+ /**
17
+ * plan-exec skill 检测(D10 选项集 v2 的 skill 数据源)。
18
+ *
19
+ * 检测语义 = 「向用户提议的执行 skill」,以 pi 本体加载集为基线做意图/信任对齐:
20
+ * - 单根枚举复用 pi 公开导出 loadSkillsFromDir——单根内横切规则(ignore / description
21
+ * 必填门 / name 派生 / 递归遍历 / symlink / node_modules / 隐藏文件)随 pi 升级
22
+ * 自动同步,零漂移面(对照表①③⑥⑦ 的 SKILL.md 目录形态侧);
23
+ * - 跨根组装自实现(pi 的四根拼装逻辑未导出,对照表②④⑤):根序四枚按 pi 本体
24
+ * `package-manager.js addAutoDiscoveredResources`——`.pi/skills` → 祖先链
25
+ * `.agents/skills`(近→远,git root 级含)→ `<agentDir>/skills` → `~/.agents/skills`
26
+ * (HOME 直取,dev-flow 等用户级 skill 标准安装位);untrusted 跳过前两族(④);
27
+ * settings overrides 的 `+/-/!` 三级优先级 disable 复刻(②);同名 first-writer-wins
28
+ * + realPath 去重(⑤);
29
+ * - plan-exec marker 过滤 = 对过 description 门的 skill 二次读 frontmatter
30
+ * (`plan-exec: true`;pi 的 Skill 对象不保留原始 frontmatter)。skill 侧自愿
31
+ * opt-in,`disable-model-invocation` 与 plan-exec 并存不过滤(用户显式选择是
32
+ * 另一通路,设计 D10 顺手裁决)。
33
+ * - steer 执行走路径读取(read `<skillEntryPath>` = skill 入口文件路径),不依赖 pi skill 注册。
34
+ *
35
+ * 已登记偏差(对照表⑦散 .md 形态):与 pi auto 源(collectSkillEntries)方向相反——
36
+ * agents 源根级散 .md 检测收而 pi 不收(多提议)、子目录散 .md pi 收而检测不收
37
+ * (漏提议);`.pi/skills` 源两侧同构。settings npm 源不扫(现无 npm 发布形态
38
+ * plan-exec skill,出现时补第五根)。
39
+ *
40
+ * 降级规格(检测失败最坏后果 = skill 选项空集,绝不允许炸 executeComplete 的
41
+ * 核心交互闭环;pi 对坏 skill 也只产 diagnostics 不中断加载):每根 existsSync
42
+ * 守卫 + 整根 try/catch(EACCES 等任何 fs 错 → 跳过该根 + warn);单 skill
43
+ * frontmatter 读/解析失败 → 跳过该项 + warn;settings 读取/解析失败 → 视为
44
+ * 无 overrides。
45
+ */
46
+
47
+ /** 降级留痕(warn 级):缺省走 pi-extension-logger,测试注入收集器 */
48
+ type LogFn = (msg: string, detail?: object) => void;
49
+
50
+ function defaultLog(msg: string, detail?: object): void {
51
+ logger.warn(msg, detail);
52
+ }
53
+
54
+ /** pi getHomeDir 同式(package-manager.js:78):HOME 优先,缺省 os.homedir() */
55
+ function defaultHomeDir(): string {
56
+ return process.env.HOME || os.homedir();
57
+ }
58
+
59
+ /** pi canonicalizePath 同式:realpath 失败回退原路径 */
60
+ function canonicalizePath(p: string): string {
61
+ try {
62
+ return fs.realpathSync(p);
63
+ } catch {
64
+ return p;
65
+ }
66
+ }
67
+
68
+ function toPosixPath(p: string): string {
69
+ // 与 pi toPosixPath 同构:Windows 分隔符归一(POSIX 上恒等)
70
+ return sep === "\\" ? p.split("\\").join("/") : p;
71
+ }
72
+
73
+ function isRecord(value: unknown): value is Record<string, unknown> {
74
+ return typeof value === "object" && value !== null;
75
+ }
76
+
77
+ /** pi findGitRepoRoot 同式:.git 目录或 .git 文件(worktree)均算 root;无 git 推到文件系统根 */
78
+ function findGitRepoRoot(startDir: string): string | null {
79
+ let dir = resolve(startDir);
80
+ while (true) {
81
+ if (fs.existsSync(join(dir, ".git"))) return dir;
82
+ const parent = dirname(dir);
83
+ if (parent === dir) return null;
84
+ dir = parent;
85
+ }
86
+ }
87
+
88
+ /** pi collectAncestorAgentsSkillDirs 同式:startDir 起每级 `.agents/skills`,近→远,git root 级含 */
89
+ function collectAncestorAgentsSkillDirs(startDir: string): string[] {
90
+ const skillDirs: string[] = [];
91
+ const resolvedStartDir = resolve(startDir);
92
+ const gitRepoRoot = findGitRepoRoot(resolvedStartDir);
93
+ let dir = resolvedStartDir;
94
+ while (true) {
95
+ skillDirs.push(join(dir, ".agents", "skills"));
96
+ if (gitRepoRoot && dir === gitRepoRoot) break;
97
+ const parent = dirname(dir);
98
+ if (parent === dir) break;
99
+ dir = parent;
100
+ }
101
+ return skillDirs;
102
+ }
103
+
104
+ /** BOM 剥离(pi stripBom 同构):带 BOM 的 settings.json 直接 JSON.parse 会 throw */
105
+ function stripBom(text: string): string {
106
+ return text.replace(/^\uFEFF/, "");
107
+ }
108
+
109
+ /**
110
+ * 读 settings.json 的 skills overrides(`+/-/!` pattern 数组)。
111
+ * 读取/解析失败 → [] + warn(pi tryLoadFromStorage 同折叠:错误不中断加载)。
112
+ * pi 旧形态 skills 为对象(customDirectories)——其条目无 `+/-/!` 前缀,对 override
113
+ * 匹配等价于无,此处按非数组折叠为 [] 即可。
114
+ */
115
+ function readSkillsOverrides(settingsPath: string, log: LogFn): string[] {
116
+ try {
117
+ if (!fs.existsSync(settingsPath)) return [];
118
+ const parsed: unknown = JSON.parse(stripBom(fs.readFileSync(settingsPath, "utf-8")));
119
+ if (!isRecord(parsed)) return [];
120
+ const skills: unknown = parsed.skills;
121
+ if (!Array.isArray(skills)) return [];
122
+ return skills.filter((p): p is string => typeof p === "string");
123
+ } catch (error) {
124
+ log("plan: exec-skill settings parse failed (treated as no overrides)", {
125
+ path: settingsPath,
126
+ error: error instanceof Error ? error.message : String(error),
127
+ });
128
+ return [];
129
+ }
130
+ }
131
+
132
+ /**
133
+ * minimatch 子集 glob → RegExp:`**`(跨目录,段间匹配零段)/ `*`(段内任意)/ `?`
134
+ * (单字符,不跨 /)/ `[...]` 字符类;不支持 brace 展开等高级语法。已登记偏差:
135
+ * 极端 disable pattern(brace 等)可能与 pi 求值不一致——常用形态(裸名 / `x-*` /
136
+ * `dir/**`)语义一致。
137
+ */
138
+ function globToRegExp(pattern: string): RegExp {
139
+ let source = "^";
140
+ for (let i = 0; i < pattern.length; i++) {
141
+ const ch = pattern[i];
142
+ if (ch === "*") {
143
+ if (pattern[i + 1] === "*") {
144
+ while (pattern[i + 1] === "*") i++;
145
+ // 段间 `**/` 允许零段(minimatch globstar 语义),其余 `**` 跨目录任意
146
+ if (source.endsWith("/") && pattern[i + 1] === "/") {
147
+ source = `${source.slice(0, -1)}(?:.*/)?`;
148
+ i++;
149
+ } else {
150
+ source += ".*";
151
+ }
152
+ } else {
153
+ source += "[^/]*";
154
+ }
155
+ } else if (ch === "?") {
156
+ source += "[^/]";
157
+ } else if (ch === "[") {
158
+ const end = pattern.indexOf("]", i + 1);
159
+ if (end === -1) {
160
+ source += "\\[";
161
+ } else {
162
+ let cls = pattern.slice(i + 1, end);
163
+ if (cls.startsWith("!")) cls = `^${cls.slice(1)}`;
164
+ source += `[${cls}]`;
165
+ i = end;
166
+ }
167
+ } else {
168
+ source += ch.replace(/[\\^$.+(){}|]/g, "\\$&");
169
+ }
170
+ }
171
+ return new RegExp(`${source}$`);
172
+ }
173
+
174
+ /** pi normalizeExactPattern 同式:剥前导 `./`(+/- 精确匹配形态归一,只剥一次) */
175
+ function normalizeExactPattern(pattern: string): string {
176
+ return toPosixPath(pattern.replace(/^\.\/|^\.\\/, ""));
177
+ }
178
+
179
+ /**
180
+ * pi matchesAnyPattern 同构(`!` 排除侧的 glob 匹配):rel / 文件名 / 绝对路径,
181
+ * SKILL.md 再加父目录的 rel / 名 / 绝对路径三形态——`!dev-flow` 即靠父目录名命中。
182
+ */
183
+ function matchesAnyGlobPattern(filePath: string, patterns: string[], baseDir: string): boolean {
184
+ if (patterns.length === 0) return false;
185
+ const regexes = patterns.map(globToRegExp);
186
+ const candidates = globMatchCandidates(filePath, baseDir);
187
+ return candidates.some((candidate) => regexes.some((re) => re.test(candidate)));
188
+ }
189
+
190
+ /** 匹配候选形态(glob 侧 6 形态 / 精确侧 4 形态的公共部分按需取用) */
191
+ function globMatchCandidates(filePath: string, baseDir: string): string[] {
192
+ const rel = toPosixPath(relative(baseDir, filePath));
193
+ const name = basename(filePath);
194
+ const filePathPosix = toPosixPath(filePath);
195
+ const isSkillFile = name === "SKILL.md";
196
+ if (!isSkillFile) return [rel, name, filePathPosix];
197
+ const parentDir = dirname(filePath);
198
+ return [
199
+ rel,
200
+ name,
201
+ filePathPosix,
202
+ toPosixPath(relative(baseDir, parentDir)),
203
+ basename(parentDir),
204
+ toPosixPath(parentDir),
205
+ ];
206
+ }
207
+
208
+ /** pi matchesAnyExactPattern 同构(`+`/`-` 精确侧):rel / 绝对路径 + SKILL.md 父目录两形态,无 glob */
209
+ function matchesAnyExactPattern(filePath: string, patterns: string[], baseDir: string): boolean {
210
+ if (patterns.length === 0) return false;
211
+ const rel = toPosixPath(relative(baseDir, filePath));
212
+ const filePathPosix = toPosixPath(filePath);
213
+ const isSkillFile = basename(filePath) === "SKILL.md";
214
+ const parentDir = isSkillFile ? dirname(filePath) : undefined;
215
+ const parentRel = parentDir !== undefined ? toPosixPath(relative(baseDir, parentDir)) : undefined;
216
+ const parentDirPosix = parentDir !== undefined ? toPosixPath(parentDir) : undefined;
217
+ return patterns.some((pattern) => {
218
+ const normalized = normalizeExactPattern(pattern);
219
+ if (normalized === rel || normalized === filePathPosix) return true;
220
+ return (parentRel !== undefined && normalized === parentRel)
221
+ || (parentDirPosix !== undefined && normalized === parentDirPosix);
222
+ });
223
+ }
224
+
225
+ /** pi isEnabledByOverrides 同构:三级优先级 `-` > `+` > `!` > 默认启用(横切全部根) */
226
+ export function isEnabledByOverrides(filePath: string, patterns: string[], baseDir: string): boolean {
227
+ const overrides = patterns.filter((p) => p.startsWith("!") || p.startsWith("+") || p.startsWith("-"));
228
+ const excludes = overrides.filter((p) => p.startsWith("!")).map((p) => p.slice(1));
229
+ const forceIncludes = overrides.filter((p) => p.startsWith("+")).map((p) => p.slice(1));
230
+ const forceExcludes = overrides.filter((p) => p.startsWith("-")).map((p) => p.slice(1));
231
+ let enabled = true;
232
+ if (excludes.length > 0 && matchesAnyGlobPattern(filePath, excludes, baseDir)) {
233
+ enabled = false;
234
+ }
235
+ if (forceIncludes.length > 0 && matchesAnyExactPattern(filePath, forceIncludes, baseDir)) {
236
+ enabled = true;
237
+ }
238
+ if (forceExcludes.length > 0 && matchesAnyExactPattern(filePath, forceExcludes, baseDir)) {
239
+ enabled = false;
240
+ }
241
+ return enabled;
242
+ }
243
+
244
+ /** plan-exec marker 的 frontmatter 键(skill 侧自愿 opt-in:SKILL.md 加一行 `plan-exec: true`) */
245
+ const PLAN_EXEC_MARKER_FIELD = "plan-exec";
246
+
247
+ /**
248
+ * 二次读 frontmatter 判 plan-exec marker(pi 的 Skill 对象不保留原始 frontmatter;
249
+ * pi 加载器对未知字段忽略不拒,marker 字段约定可行)。读/解析失败 → false + warn
250
+ * (降级:跳过该项)。严格 `=== true`:字符串 "true" 等 YAML 形态不误判。
251
+ */
252
+ export function hasPlanExecMarker(skillFilePath: string, log: LogFn = defaultLog): boolean {
253
+ try {
254
+ const { frontmatter } = parseFrontmatter(fs.readFileSync(skillFilePath, "utf-8"));
255
+ return frontmatter[PLAN_EXEC_MARKER_FIELD] === true;
256
+ } catch (error) {
257
+ log("plan: exec-skill frontmatter read failed (skill skipped)", {
258
+ path: skillFilePath,
259
+ error: error instanceof Error ? error.message : String(error),
260
+ });
261
+ return false;
262
+ }
263
+ }
264
+
265
+ /** 检测产物:一个可提议为执行方式的 skill(name 与 pi prompt 列表一致,⑥) */
266
+ export interface ExecSkill {
267
+ name: string;
268
+ description: string;
269
+ /** skill 入口文件路径(标准形态 = SKILL.md 路径,散 .md 形态 = 文件本身;steer 指引 read 该路径,两种形态统一无分支) */
270
+ skillEntryPath: string;
271
+ }
272
+
273
+ export interface DetectExecSkillsOptions {
274
+ /** 项目目录(`<CONFIG_DIR_NAME>/skills` 根 + 祖先链起点,目录名跟 pi CONFIG_DIR_NAME 导出)。生产调用传 ctx.cwd */
275
+ cwd: string;
276
+ /** 项目信任态(untrusted 跳过 `.pi/skills` 与祖先链两族,④)。生产调用传 ctx.isProjectTrusted() */
277
+ trusted: boolean;
278
+ /** pi agentDir(`<agentDir>/skills` 根 + 全局 settings 位置)。缺省 pi getAgentDir()(读 PI_CODING_AGENT_DIR);测试注入 */
279
+ agentDir?: string;
280
+ /** 用户 home(`~/.agents/skills` 根)。缺省 `process.env.HOME || os.homedir()`(pi getHomeDir 同式);测试注入 */
281
+ homeDir?: string;
282
+ /** 降级留痕(warn 级),缺省 pi-extension-logger */
283
+ log?: LogFn;
284
+ }
285
+
286
+ /** 检测的单根描述:目录 + overrides 求值 baseDir(pi 按根挂各自 baseDir)+ 该根适用的 overrides */
287
+ interface SkillRoot {
288
+ dir: string;
289
+ baseDir: string;
290
+ overrides: string[];
291
+ }
292
+
293
+ /**
294
+ * 检测入口:枚举 pi 本体加载集的四根,返回带 plan-exec marker 的 skill 清单
295
+ * (root 序去重后)。complete 时现扫、无缓存(低频路径 + 技能热装可见性)。
296
+ * 永不 throw(降级规格)。
297
+ */
298
+ export function detectExecSkills(options: DetectExecSkillsOptions): ExecSkill[] {
299
+ const log: LogFn = options.log ?? defaultLog;
300
+ try {
301
+ return detectExecSkillsInternal(options, log);
302
+ } catch (error) {
303
+ log("plan: exec-skill detection failed (degraded to empty option set)", {
304
+ error: error instanceof Error ? error.message : String(error),
305
+ });
306
+ return [];
307
+ }
308
+ }
309
+
310
+ /**
311
+ * trusted 门内两族根(④):`.pi/skills` 根 + 祖先链;overrides 挂 project settings。
312
+ * 祖先链每根 baseDir = 各自的 `.agents` 目录;与 `~/.agents/skills`
313
+ * 同路径的项滤掉(pi :1979 同款——cwd 在 HOME 下时防与第四根重复)。
314
+ */
315
+ function collectTrustedProjectRoots(cwd: string, homeDir: string, projectOverrides: string[]): SkillRoot[] {
316
+ const roots: SkillRoot[] = [];
317
+ roots.push({ dir: join(cwd, CONFIG_DIR_NAME, "skills"), baseDir: join(cwd, CONFIG_DIR_NAME), overrides: projectOverrides });
318
+ const userAgentsSkillsDir = join(homeDir, ".agents", "skills");
319
+ for (const dir of collectAncestorAgentsSkillDirs(cwd)) {
320
+ if (resolve(dir) === resolve(userAgentsSkillsDir)) continue;
321
+ roots.push({ dir, baseDir: dirname(dir), overrides: projectOverrides });
322
+ }
323
+ return roots;
324
+ }
325
+
326
+ /** 四根 root 序组装(pi 本体加载集序,见 detectExecSkills 头注);overrides 按根挂载 */
327
+ function collectSkillRoots(
328
+ options: DetectExecSkillsOptions,
329
+ cwd: string,
330
+ agentDir: string,
331
+ homeDir: string,
332
+ log: LogFn,
333
+ ): SkillRoot[] {
334
+ const userOverrides = readSkillsOverrides(join(agentDir, "settings.json"), log);
335
+ // project settings 仅 trusted 时参与(pi loadFromStorage 对 untrusted 的 project 恒 {})
336
+ const projectOverrides = options.trusted
337
+ ? readSkillsOverrides(join(cwd, CONFIG_DIR_NAME, "settings.json"), log)
338
+ : [];
339
+ const roots: SkillRoot[] = options.trusted
340
+ ? collectTrustedProjectRoots(cwd, homeDir, projectOverrides)
341
+ : [];
342
+ roots.push({ dir: join(agentDir, "skills"), baseDir: agentDir, overrides: userOverrides });
343
+ roots.push({ dir: join(homeDir, ".agents", "skills"), baseDir: join(homeDir, ".agents"), overrides: userOverrides });
344
+ return roots;
345
+ }
346
+
347
+ /**
348
+ * 单根扫描:overrides 过滤(②)→ first-writer-wins 去重(⑤,seen 集跨根共享)→
349
+ * marker 过滤。根目录缺失 / 整根读失败 → 空集(降级规格,不中断其余根)。
350
+ */
351
+ function scanSkillRoot(
352
+ root: SkillRoot,
353
+ seenNames: Set<string>,
354
+ seenRealPaths: Set<string>,
355
+ log: LogFn,
356
+ ): ExecSkill[] {
357
+ if (!fs.existsSync(root.dir)) return []; // 合法缺省(~/.agents/skills 不存在是常态主路径)
358
+ let skills: Skill[];
359
+ try {
360
+ skills = loadSkillsFromDir({ dir: root.dir, source: "detect" }).skills;
361
+ } catch (error) {
362
+ log("plan: exec-skill root scan failed (root skipped)", {
363
+ dir: root.dir,
364
+ error: error instanceof Error ? error.message : String(error),
365
+ });
366
+ return [];
367
+ }
368
+ const found: ExecSkill[] = [];
369
+ for (const skill of skills) {
370
+ // ② 用户显式 disable 的 skill 不提议(意图对齐)
371
+ if (!isEnabledByOverrides(skill.filePath, root.overrides, root.baseDir)) continue;
372
+ // ⑤ first-writer-wins(含非 marker skill 占名——pi 的 collision 语义以加载集为准,
373
+ // 后根同名不因 marker 缺席而让位)
374
+ const realPath = canonicalizePath(skill.filePath);
375
+ if (seenRealPaths.has(realPath) || seenNames.has(skill.name)) continue;
376
+ seenRealPaths.add(realPath);
377
+ seenNames.add(skill.name);
378
+ if (!hasPlanExecMarker(skill.filePath, log)) continue;
379
+ found.push({
380
+ name: skill.name,
381
+ description: skill.description,
382
+ // 入口文件路径两种形态统一:标准形态 filePath 即 SKILL.md 路径、散 .md 形态即
383
+ // 文件本身——接收端(steer 文案)直接 read 该值,不再拼 SKILL.md(散 .md 形态
384
+ // 拼接会得到不存在的 `<skills根>/SKILL.md` 悬空指引)
385
+ skillEntryPath: skill.filePath,
386
+ });
387
+ }
388
+ return found;
389
+ }
390
+
391
+ function detectExecSkillsInternal(options: DetectExecSkillsOptions, log: LogFn): ExecSkill[] {
392
+ const cwd = resolve(options.cwd);
393
+ const agentDir = resolve(options.agentDir ?? getAgentDir());
394
+ const homeDir = resolve(options.homeDir ?? defaultHomeDir());
395
+
396
+ const roots = collectSkillRoots(options, cwd, agentDir, homeDir, log);
397
+
398
+ const seenNames = new Set<string>();
399
+ const seenRealPaths = new Set<string>();
400
+ const found: ExecSkill[] = [];
401
+ for (const root of roots) {
402
+ for (const skill of scanSkillRoot(root, seenNames, seenRealPaths, log)) {
403
+ found.push(skill);
404
+ }
405
+ }
406
+ return found;
407
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * plan 包 i18n 层(u-locale-channel 消费端):只本地化「给人看的交互面」——complete
3
+ * 执行方式选择的 FormOverlay 单 choice 问题(question/header/options)与 ctx.ui.notify
4
+ * 提示。tool result 里的英文是给 LLM 的执行指令,不进词典(agent 会以会话语言转述,
5
+ * 指令语言 ≠ UI 语言)。
6
+ *
7
+ * locale 读取(readUiLocale + mtime/size 缓存 + 降级)下沉在
8
+ * `@zhushanwen/pi-llm-shared` 单一实现(@data-owner #39 注解随实现持有),本包
9
+ * re-export;env `TAIJI_AGENT_DATA_DIR` 缺失 / 文件缺失 / JSON 损坏 → 回落 `en-US`
10
+ * (与 runtime readUiPreferences 的降级口径一致)。
11
+ */
12
+ import { DEFAULT_UI_LOCALE, readUiLocale, type UiLocale } from "@zhushanwen/pi-llm-shared/ui-locale";
13
+
14
+ export { DEFAULT_UI_LOCALE, readUiLocale, type UiLocale };
15
+
16
+ /** 词典 key 集合(exec-choice 交互面 + notify;键缺失回落链见 t())。 */
17
+ type Dictionary = Record<string, string>;
18
+
19
+ const EN_US: Dictionary = {
20
+ "exec.question": "Plan is ready. Choose how to execute:",
21
+ "exec.header": "Execution method",
22
+ "exec.viaSkill": "Execute via skill: {name}",
23
+ "exec.viaSkillDesc": "Load the {name} skill and follow its workflow to execute the plan.",
24
+ "exec.execute": "Execute",
25
+ "exec.executeDesc":
26
+ "Set up goal tracking when available, then execute: delegate independent tasks to subagents, run small or tightly-coupled steps in this session.",
27
+ "exec.later": "Not now",
28
+ "exec.laterDesc": "Keep the plan and stay in plan mode. You can execute it later.",
29
+ "exec.goalFailedNotify": "Goal tracking was not started ({reason}).",
30
+ };
31
+
32
+ const ZH_CN: Dictionary = {
33
+ "exec.question": "计划已就绪,请选择执行方式:",
34
+ "exec.header": "执行方式",
35
+ "exec.viaSkill": "用技能「{name}」执行",
36
+ "exec.viaSkillDesc": "加载 {name} 技能并按其工作流执行该计划。",
37
+ "exec.execute": "执行",
38
+ "exec.executeDesc": "可用时先建立 goal 跟踪,再自动执行:独立任务派发 subagent,小而紧耦合的步骤在本会话逐步完成。",
39
+ "exec.later": "暂不执行",
40
+ "exec.laterDesc": "保留计划并留在计划模式,稍后可再执行。",
41
+ "exec.goalFailedNotify": "Goal 跟踪未能启动({reason})。",
42
+ };
43
+
44
+ const DICTIONARIES: Record<UiLocale, Dictionary> = {
45
+ "zh-CN": ZH_CN,
46
+ "en-US": EN_US,
47
+ };
48
+
49
+ /** 指定语言的词典 key 集合(测试用于 zh/en 双侧对齐断言)。 */
50
+ export function dictionaryKeys(locale: UiLocale): string[] {
51
+ return Object.keys(DICTIONARIES[locale]);
52
+ }
53
+
54
+ /**
55
+ * 渲染词典模板。回落链:当前 locale → en-US → 键名本身(禁返回空串,scheduler r4 S1
56
+ * 同款)。占位符缺参时保留 `{name}` 原样。
57
+ */
58
+ export function t(key: string, params?: Record<string, string | number>, locale?: UiLocale): string {
59
+ const resolved = locale ?? readUiLocale();
60
+ const template = DICTIONARIES[resolved][key] ?? DICTIONARIES[DEFAULT_UI_LOCALE][key] ?? key;
61
+ return interpolate(template, params);
62
+ }
63
+
64
+ function interpolate(template: string, params?: Record<string, string | number>): string {
65
+ if (params === undefined) return template;
66
+ return template.replace(/\{(\w+)\}/g, (match: string, name: string): string => {
67
+ const value = params[name];
68
+ return value === undefined ? match : String(value);
69
+ });
70
+ }
package/src/index.ts CHANGED
@@ -1,18 +1,49 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type {
2
+ BeforeAgentStartEvent,
3
+ BeforeAgentStartEventResult,
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ } from "@earendil-works/pi-coding-agent";
7
+
8
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
2
9
 
3
10
  import { registerPlanCommand } from "./command.js";
4
11
  import { registerPlanEventHandlers } from "./compact.js";
5
- import { type PlanSessionMap, reconstructPlanState } from "./state.js";
12
+ import {
13
+ type PlanAbortControllers,
14
+ type PlanSessionMap,
15
+ PLAN_CONTEXT_CUSTOM_TYPE,
16
+ PLAN_MODE_TOOLS,
17
+ persistPlanState,
18
+ reconstructPlanState,
19
+ } from "./state.js";
6
20
  import { registerPlanTool } from "./tool.js";
7
21
  import { updatePlanWidget } from "./widget.js";
8
22
 
23
+ const logger = getLogger("pi-plan");
24
+
25
+ /**
26
+ * D9 引导文案(约 60 token):仅在 taiji 宿主注入。plan 模式是只读子集(读代码、产
27
+ * 文档、不改源码),进入它不是危险操作——agent 可在合适时机**自行进入**(plan-mode-
28
+ * agent-enter U1 后 enter 是 tool action,无需用户确认;用户随时可经 PlanModeBar 退出)。
29
+ */
30
+ const PLAN_MODE_SUGGESTION_PROMPT =
31
+ "\n\n" +
32
+ "When the user's request involves large-scale refactoring, cross-module changes, or other high-risk modifications, " +
33
+ "proactively enter plan mode yourself by calling plan(action='enter', requirement='<the task>', skills=[...relevant skills]). " +
34
+ "Plan mode is read-only for source code: you explore and write plan documents, then the user reviews before implementation. " +
35
+ "Do not ask for permission to enter — entering plan mode is safe and reversible (the user can exit anytime).";
36
+
9
37
  export default function planExtension(pi: ExtensionAPI) {
10
38
  // Per-session state cache — keyed by sessionId
11
39
  const sessions: PlanSessionMap = new Map();
40
+ // 挂起 select 的 per-session AbortController(E10:submit-review 审批 + complete
41
+ // 执行方式选择两处共用注册表;发挂起 select 前 fresh,settled/重建/关闭即弃)
42
+ const abortControllers: PlanAbortControllers = new Map();
12
43
 
13
44
  // Register tool and command
14
- registerPlanTool(pi, sessions);
15
- registerPlanCommand(pi, sessions);
45
+ registerPlanTool(pi, sessions, abortControllers);
46
+ registerPlanCommand(pi, sessions, abortControllers);
16
47
 
17
48
  // Register compact/tree event handlers
18
49
  registerPlanEventHandlers(pi, sessions);
@@ -22,10 +53,79 @@ export default function planExtension(pi: ExtensionAPI) {
22
53
  const sessionId = ctx.sessionManager.getSessionId();
23
54
  const state = reconstructPlanState(ctx);
24
55
  sessions.set(sessionId, state);
56
+ // session 重建即弃旧 controller:挂起 select 只存在于 tool execute 进行中的
57
+ // turn,pi 重生后不可能有存活的 select;残留的已 abort controller 禁止复用
58
+ // (发起新挂起 select 会 fresh 新建,此处删除是防御性清理)
59
+ abortControllers.delete(sessionId);
25
60
  updatePlanWidget(ctx, state);
26
61
  // If plan mode was active, re-restrict tools to the plan-mode set (includes bash — file-write constraints come from the injected plan mode prompt)
27
62
  if (state.isActive) {
28
- pi.setActiveTools(["read", "bash", "grep", "find", "ls", "plan"]);
63
+ pi.setActiveTools(PLAN_MODE_TOOLS);
64
+
65
+ // E3:挂起交互点(审批 select / 执行方式 form)随 pi 进程消亡。本 hook 跑在
66
+ // pi 懒重生之后(taiji 冷启动不拉 pi 进程——用户重开 session 发首条消息时 pi
67
+ // 才重生、hook 才跑),此时冷启动扫描已恢复持久态但挂起交互不复存在(上方
68
+ // delete 已清残留 controller,pi 重生后不可能有存活的挂起)→ 按 reviewState
69
+ // 三态 steer 恢复(恢复动作全部在 extension 侧,前端不造失败信号链):
70
+ // - awaiting:重调 submit-review 重挂审批;
71
+ // - revising:继续按评论修订并重新提交(P1-1:原实现只覆盖 awaiting,revising
72
+ // 崩溃后审批条恒显假「修订中」且无任何恢复指引)。
73
+ // 不覆盖「approve 后执行方式选择窗口中断」:该态(isActive + docs>0 + 无
74
+ // reviewState)与「探索期 pi 重生」在持久态上不可区分,steer「计划已批准」会
75
+ // 对正常探索期误报——防误报优先,该窗口中断由用户重发消息自然恢复。
76
+ if (state.reviewState === "awaiting" || state.reviewState === "revising") {
77
+ const revising = state.reviewState === "revising";
78
+ pi.sendMessage(
79
+ {
80
+ customType: PLAN_CONTEXT_CUSTOM_TYPE,
81
+ content: revising
82
+ ? "[PLAN MODE] A revision round was interrupted (session restarted). The user's revision comments were already injected — continue handling them: rewrite the documents, re-register each revised document via plan(action='register-doc'), then re-submit via plan(action='submit-review')."
83
+ : "[PLAN MODE] A previous review request was interrupted (session restarted). " +
84
+ "The registered documents are still waiting for user approval. " +
85
+ "Call plan(action='submit-review') now to re-hang the review dialog.",
86
+ display: false,
87
+ },
88
+ { deliverAs: "steer", triggerTurn: true },
89
+ );
90
+ if (!revising) {
91
+ // E3 重挂即落 'resubmit' 源标记 + persist(此处原只发 steer 不落盘,renderer
92
+ // 冷启动扫描的 View 恒无 source、恒渲染通用文案;落盘后才能渲染「会话已重启,
93
+ // 尚未重新提交」分支)。无条件覆盖既有 source 为有意——「会话已重启」语义
94
+ // 优先成立(P3-9 登记;旧 entry 存量的其他 source 值一并被覆盖归一)。
95
+ state.reviewStateSource = "resubmit";
96
+ persistPlanState(pi, state);
97
+ }
98
+ }
99
+ }
100
+ });
101
+
102
+ // D9:AI 主动建议(轻量引导),仅 taiji 形态注入。
103
+ // 信号用 TAIJI_AGENT_EXT_LOG 而非 TAIJI_RUNTIME_TOKEN——后者在 SPAWN_ENV 出站
104
+ // deny list 被强制剥除(C-proc-09),pi 子进程 env 里恒不可见,照抄即引导
105
+ // 静默失效且诱导实施者动 deny list 造成安全回归;前者由 taiji runtime 对托管
106
+ // pi 恒注入(rpc-client buildPiOutboundEnv)。独立 pi(信号缺失)不注入——
107
+ // universal 包语义不变。
108
+ pi.on("before_agent_start", (event: BeforeAgentStartEvent): BeforeAgentStartEventResult | undefined => {
109
+ if (process.env.TAIJI_AGENT_EXT_LOG !== "1") return undefined;
110
+ // Never block the agent loop(system-prompt extension 同款先例)
111
+ try {
112
+ return { systemPrompt: event.systemPrompt + PLAN_MODE_SUGGESTION_PROMPT };
113
+ } catch (error) {
114
+ const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
115
+ // 注入失败只损失一句引导,不值得中断 agent loop——先走 extension-logger 落盘
116
+ // (~/.pi/agent/logs/ 文件通道,诊断可见);logger 自身抛错才降级 stderr
117
+ // (system-prompt logHookFailure 同款形态:logger 首选、stderr 内层兜底)
118
+ try {
119
+ logger.warn("plan: before_agent_start injection failed", { error: msg });
120
+ } catch (nestedErr) {
121
+ try {
122
+ process.stderr.write(`[pi-plan] before_agent_start injection log also failed: ${String(nestedErr)}\n`);
123
+ } catch (finalErr) {
124
+ // 完全静默:两层兜底都失败时无处可写
125
+ void finalErr;
126
+ }
127
+ }
128
+ return undefined;
29
129
  }
30
130
  });
31
131
 
@@ -33,5 +133,6 @@ export default function planExtension(pi: ExtensionAPI) {
33
133
  pi.on("session_shutdown", async (_event: unknown, ctx: ExtensionContext) => {
34
134
  const sessionId = ctx.sessionManager.getSessionId();
35
135
  sessions.delete(sessionId);
136
+ abortControllers.delete(sessionId);
36
137
  });
37
138
  }