@zhushanwen/pi-plan 1.0.0 → 1.1.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/package.json +12 -12
- package/src/__tests__/command.test.ts +47 -0
- package/src/command.ts +18 -7
- package/src/execution-notice.ts +1 -1
- package/src/state.ts +3 -3
- package/src/tool.ts +8 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-plan",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Lightweight plan mode for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -21,11 +21,11 @@
|
|
|
21
21
|
],
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"peerDependencies": {
|
|
24
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
25
|
-
"@earendil-works/pi-tui": "^0.
|
|
26
|
-
"typebox": "
|
|
27
|
-
"@earendil-works/pi-ai": "^0.
|
|
28
|
-
"@zhushanwen/pi-goal": "0.14.
|
|
24
|
+
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
25
|
+
"@earendil-works/pi-tui": "^1.0.0",
|
|
26
|
+
"typebox": "1.3.27",
|
|
27
|
+
"@earendil-works/pi-ai": "^1.0.0",
|
|
28
|
+
"@zhushanwen/pi-goal": "0.14.11"
|
|
29
29
|
},
|
|
30
30
|
"peerDependenciesMeta": {
|
|
31
31
|
"@earendil-works/pi-tui": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"@earendil-works/pi-tui": "^0.
|
|
42
|
+
"@earendil-works/pi-tui": "^1.0.0",
|
|
43
43
|
"@vitest/coverage-v8": "^4.1.9",
|
|
44
44
|
"vitest": "^4.1.8"
|
|
45
45
|
},
|
|
@@ -50,11 +50,11 @@
|
|
|
50
50
|
"templates/"
|
|
51
51
|
],
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@zhushanwen/extension-protocol": "0.
|
|
54
|
-
"@zhushanwen/pi-exec-skills": "0.2.
|
|
55
|
-
"@zhushanwen/pi-ext-guards": "0.4.
|
|
56
|
-
"@zhushanwen/pi-extension-logger": "0.6.
|
|
57
|
-
"@zhushanwen/pi-llm-shared": "0.11.
|
|
53
|
+
"@zhushanwen/extension-protocol": "0.18.0",
|
|
54
|
+
"@zhushanwen/pi-exec-skills": "0.2.1",
|
|
55
|
+
"@zhushanwen/pi-ext-guards": "0.4.3",
|
|
56
|
+
"@zhushanwen/pi-extension-logger": "0.6.2",
|
|
57
|
+
"@zhushanwen/pi-llm-shared": "0.11.1",
|
|
58
58
|
"@zhushanwen/pi-session-path": "0.1.0"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
@@ -295,6 +295,53 @@ describe("registerPlanCommand", () => {
|
|
|
295
295
|
return payload.content;
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
// --- D10②:命令反馈 notify(display:false 注入仅 LLM 可见,用户可见性由 notify 承担) ---
|
|
299
|
+
|
|
300
|
+
it("重入分支(存在既有 plan 文件):notify 提示 + 选项清单仍走 display:false 注入", async () => {
|
|
301
|
+
vi.mocked(fs.readdirSync).mockReturnValue(["old-plan"] as unknown as ReturnType<typeof fs.readdirSync>);
|
|
302
|
+
vi.mocked(fs.statSync).mockImplementation(() => ({ isDirectory: () => true }) as unknown as fs.Stats);
|
|
303
|
+
vi.mocked(fs.existsSync).mockImplementation((p) => String(p).endsWith("/.tmp/plans/old-plan/plan.md"));
|
|
304
|
+
try {
|
|
305
|
+
await handler("", ctx);
|
|
306
|
+
} finally {
|
|
307
|
+
vi.mocked(fs.existsSync).mockImplementation(() => false);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const notify = (ctx as unknown as { ui: { notify: ReturnType<typeof vi.fn> } }).ui.notify;
|
|
311
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("1 existing plan file(s)"), "info");
|
|
312
|
+
// LLM 注入通路不变:custom message display:false + 选项清单全文
|
|
313
|
+
const message = sentMessage();
|
|
314
|
+
expect(message).toContain("[PLAN MODE] Found existing plan files:");
|
|
315
|
+
const calls = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
|
316
|
+
const payload = calls[0][0] as { display: boolean };
|
|
317
|
+
expect(payload.display).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("--skills 未知技能 fail-fast:notify error + 注入通路保留", async () => {
|
|
321
|
+
await handler("复盘 --skills nope", ctx);
|
|
322
|
+
|
|
323
|
+
const notify = (ctx as unknown as { ui: { notify: ReturnType<typeof vi.fn> } }).ui.notify;
|
|
324
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("unknown skill(s): nope"), "error");
|
|
325
|
+
expect(sentMessage()).toContain("Failed to enter: unknown skill(s): nope");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("--template 文件缺失 fail-fast:notify error", async () => {
|
|
329
|
+
await handler("复盘 --template tpl.md", ctx);
|
|
330
|
+
|
|
331
|
+
const notify = (ctx as unknown as { ui: { notify: ReturnType<typeof vi.fn> } }).ui.notify;
|
|
332
|
+
expect(notify).toHaveBeenCalledWith(expect.stringContaining("Template file not found"), "error");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("进入成功:notify info 携带 planFilePath(命令反馈通道,widget 之外补可达)", async () => {
|
|
336
|
+
await handler("复盘需求", ctx);
|
|
337
|
+
|
|
338
|
+
const notify = (ctx as unknown as { ui: { notify: ReturnType<typeof vi.fn> } }).ui.notify;
|
|
339
|
+
expect(notify).toHaveBeenCalledWith(
|
|
340
|
+
expect.stringContaining("Plan mode entered: /tmp/test-project/.tmp/plans/"),
|
|
341
|
+
"info",
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
298
345
|
describe("--template via /plan handler", () => {
|
|
299
346
|
afterEach(() => {
|
|
300
347
|
// 还原默认 existsSync=false,避免路径实现泄漏到后续用例
|
package/src/command.ts
CHANGED
|
@@ -149,6 +149,9 @@ export function registerPlanCommand(
|
|
|
149
149
|
const plansDir = path.join(projectDir, ".tmp", "plans");
|
|
150
150
|
const existingPlans = findExistingPlans(plansDir);
|
|
151
151
|
if (existingPlans.length > 0) {
|
|
152
|
+
// D10②:选项清单经 display:false 注入(LLM 消费),用户可见性由 notify 承担
|
|
153
|
+
// (现状 display:false 消息用户零可见——反馈矩阵修复)
|
|
154
|
+
ctx.ui.notify(`Found ${existingPlans.length} existing plan file(s) in .tmp/plans — options sent to the model.`, "info");
|
|
152
155
|
sendPlanContextMessage(
|
|
153
156
|
pi,
|
|
154
157
|
`[PLAN MODE] Found existing plan files:\n${existingPlans.map((p, i) => ` ${i + 1}. ${p}`).join("\n")}\n\n` +
|
|
@@ -229,13 +232,15 @@ function sendPlanContextMessage(pi: ExtensionAPI, content: string): void {
|
|
|
229
232
|
}
|
|
230
233
|
|
|
231
234
|
/** E1 fail-fast 回复:不进入计划模式(不写 entry / 不限制工具 / 不注入计划提示词),回复可用技能清单与纠正命令 */
|
|
232
|
-
function reportUnknownSkills(pi: ExtensionAPI, resolution: Extract<SkillResolution, { ok: false }>): void {
|
|
235
|
+
function reportUnknownSkills(pi: ExtensionAPI, ctx: ExtensionContext, resolution: Extract<SkillResolution, { ok: false }>): void {
|
|
233
236
|
const available = resolution.available.length > 0
|
|
234
237
|
? resolution.available.map((n) => ` - ${n}`).join("\n")
|
|
235
238
|
: " (no skills installed)";
|
|
236
239
|
const problem = resolution.missing.length > 0
|
|
237
240
|
? `unknown skill(s): ${resolution.missing.join(", ")}`
|
|
238
241
|
: "--skills was given but no skill names followed it";
|
|
242
|
+
// D10②:fail-fast 错误的用户可见性由 notify 承担(display:false 注入仅 LLM 可见)
|
|
243
|
+
ctx.ui.notify(`Plan mode not entered: ${problem}`, "error");
|
|
239
244
|
sendPlanContextMessage(
|
|
240
245
|
pi,
|
|
241
246
|
`[PLAN MODE] Failed to enter: ${problem}.\n\n` +
|
|
@@ -249,7 +254,9 @@ function reportUnknownSkills(pi: ExtensionAPI, resolution: Extract<SkillResoluti
|
|
|
249
254
|
* 不限制工具 / 不注入计划提示词),回复问题句(互斥/不存在/非 md,problem 内
|
|
250
255
|
* 已带用法样例)。
|
|
251
256
|
*/
|
|
252
|
-
function reportTemplateFlagError(pi: ExtensionAPI, problem: string): void {
|
|
257
|
+
function reportTemplateFlagError(pi: ExtensionAPI, ctx: ExtensionContext, problem: string): void {
|
|
258
|
+
// D10②:fail-fast 错误的用户可见性由 notify 承担(display:false 注入仅 LLM 可见)
|
|
259
|
+
ctx.ui.notify(`Plan mode not entered: ${problem}`, "error");
|
|
253
260
|
sendPlanContextMessage(
|
|
254
261
|
pi,
|
|
255
262
|
`[PLAN MODE] Failed to enter: ${problem}.\n\n` +
|
|
@@ -274,6 +281,7 @@ function handleEnterPlanMode(
|
|
|
274
281
|
if (parsed.skills !== undefined && parsed.templatePath !== undefined) {
|
|
275
282
|
reportTemplateFlagError(
|
|
276
283
|
pi,
|
|
284
|
+
ctx,
|
|
277
285
|
"--template and --skills are mutually exclusive. Use one: /plan <requirement> --skills a,b | /plan <requirement> --template <path>",
|
|
278
286
|
);
|
|
279
287
|
return;
|
|
@@ -284,7 +292,7 @@ function handleEnterPlanMode(
|
|
|
284
292
|
if (parsed.templatePath !== undefined) {
|
|
285
293
|
const resolution = resolveTemplateFile(parsed.templatePath, ctx.cwd);
|
|
286
294
|
if (!resolution.ok) {
|
|
287
|
-
reportTemplateFlagError(pi, resolution.problem);
|
|
295
|
+
reportTemplateFlagError(pi, ctx, resolution.problem);
|
|
288
296
|
return;
|
|
289
297
|
}
|
|
290
298
|
// 全文在校验通过后、任何进入副作用(entry/工具限制)之前读好——读失败
|
|
@@ -293,7 +301,7 @@ function handleEnterPlanMode(
|
|
|
293
301
|
try {
|
|
294
302
|
templateContent = fs.readFileSync(resolution.absPath, "utf-8");
|
|
295
303
|
} catch {
|
|
296
|
-
reportTemplateFlagError(pi, `Template file not readable: ${resolution.absPath}. ${TEMPLATE_USAGE_SAMPLE}`);
|
|
304
|
+
reportTemplateFlagError(pi, ctx, `Template file not readable: ${resolution.absPath}. ${TEMPLATE_USAGE_SAMPLE}`);
|
|
297
305
|
return;
|
|
298
306
|
}
|
|
299
307
|
templateAbsPath = resolution.absPath;
|
|
@@ -309,14 +317,14 @@ function handleEnterPlanMode(
|
|
|
309
317
|
if (failed.ok) {
|
|
310
318
|
throw new Error("plan: empty --skills must fail skill resolution (invariant)");
|
|
311
319
|
}
|
|
312
|
-
reportUnknownSkills(pi, failed);
|
|
320
|
+
reportUnknownSkills(pi, ctx, failed);
|
|
313
321
|
return;
|
|
314
322
|
}
|
|
315
323
|
let resolved: SkillRef[] = [];
|
|
316
324
|
if (requested.length > 0) {
|
|
317
325
|
const resolution = resolveSkills(pi, requested);
|
|
318
326
|
if (!resolution.ok) {
|
|
319
|
-
reportUnknownSkills(pi, resolution);
|
|
327
|
+
reportUnknownSkills(pi, ctx, resolution);
|
|
320
328
|
return;
|
|
321
329
|
}
|
|
322
330
|
resolved = resolution.resolved;
|
|
@@ -326,7 +334,7 @@ function handleEnterPlanMode(
|
|
|
326
334
|
// 进入核心收敛到 enter.ts(plan(enter) tool 与 slash 命令共用);本入口只负责
|
|
327
335
|
// flag 解析/校验(上方)与提示词投递(下方 sendMessage custom message 注入)。
|
|
328
336
|
// state 由 activatePlanMode 就地改 + persist(getPlanState 缓存同一对象)。
|
|
329
|
-
const { prompt } = activatePlanMode(pi, planCtx.states, sessionId, ctx, {
|
|
337
|
+
const { state: activated, prompt } = activatePlanMode(pi, planCtx.states, sessionId, ctx, {
|
|
330
338
|
requirement,
|
|
331
339
|
skills: resolved,
|
|
332
340
|
projectDir: ctx.cwd,
|
|
@@ -334,6 +342,9 @@ function handleEnterPlanMode(
|
|
|
334
342
|
? { template: { absPath: templateAbsPath, content: templateContent } }
|
|
335
343
|
: {}),
|
|
336
344
|
});
|
|
345
|
+
// D10②:进入成功的用户可见性由 notify 承担(提示词注入 display:false 仅 LLM 可见;
|
|
346
|
+
// widget 已呈激活态,notify 补命令反馈通道)
|
|
347
|
+
ctx.ui.notify(`Plan mode entered: ${activated.planFilePath}`, "info");
|
|
337
348
|
|
|
338
349
|
// Inject plan mode prompt as custom message(display/triggerTurn 语义见 sendPlanContextMessage。
|
|
339
350
|
// 非 streaming 直调 _runAgentPrompt 跳过 prompt() 前置链,首轮 systemPrompt 叠加
|
package/src/execution-notice.ts
CHANGED
|
@@ -41,7 +41,7 @@ const GOAL_INIT_SLOT_KEY = Symbol.for("@zhushanwen/pi-goal.goalInit");
|
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* goal 桥的单一断言点:goal 扩展挂在 globalThis slot 上的编程式接口(发现 7——
|
|
44
|
-
* 桥通道从 pi API 对象挂载迁到 slot:pi
|
|
44
|
+
* 桥通道从 pi API 对象挂载迁到 slot:pi per-extension API 隔离(1.0.0 复核)使
|
|
45
45
|
* pi.__goalInit 形态跨扩展恒不可见,slot 是 C-ext-06 惯例的进程级共享形态)。
|
|
46
46
|
*/
|
|
47
47
|
function getGoalInit(): GoalInitFn | undefined {
|
package/src/state.ts
CHANGED
|
@@ -91,9 +91,9 @@ export const DEFAULT_PLAN_STATE: PlanState = {
|
|
|
91
91
|
*
|
|
92
92
|
* ask_user(D10/F9):提示词 Phase B 本就指示「Use ask_user tool if available」,白名单
|
|
93
93
|
* 曾把它排除(结构性禁言)。feature-tier 依赖降级:ask-user 是可禁扩展,pi setActiveTools
|
|
94
|
-
* 对不存在的工具名静默跳过——pi
|
|
94
|
+
* 对不存在的工具名静默跳过——pi 实装锚点(1.0.0 复核):dist/core/agent-session.js
|
|
95
95
|
* setActiveToolsByName docstring "Only tools in the registry can be enabled. Unknown
|
|
96
|
-
* tool names are ignored."
|
|
96
|
+
* and hidden tool names are ignored."——被禁时不报错不生效,提示词 "if available"
|
|
97
97
|
* 条件语义即降级(回退对话流提问)。
|
|
98
98
|
*/
|
|
99
99
|
export const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "plan", "ask_user"];
|
|
@@ -176,7 +176,7 @@ export type PlanAbortControllers = Map<string, PendingSelect>;
|
|
|
176
176
|
* 发挂起 select 前新建 PendingSelect 并登记。禁复用已 abort 的 controller——
|
|
177
177
|
* pi 实装对已 abort 的 signal 在 createDialogPromise 首行短路立即 resolve undefined,
|
|
178
178
|
* 复用会让退出后再入 plan 的 submit-review 瞬时静默取消。
|
|
179
|
-
* pi 实装锚点:dist/modes/rpc/rpc-mode.js
|
|
179
|
+
* pi 实装锚点:dist/modes/rpc/rpc-mode.js createDialogPromise 首行
|
|
180
180
|
* `opts?.signal?.aborted` 即 `return Promise.resolve(defaultValue)`,select 的
|
|
181
181
|
* defaultValue = undefined(E10 生命周期设计依据)。
|
|
182
182
|
*/
|
package/src/tool.ts
CHANGED
|
@@ -895,8 +895,8 @@ async function executeSubmitReview(
|
|
|
895
895
|
|
|
896
896
|
case "revise": {
|
|
897
897
|
// custom message 形态注入:streaming 时显式
|
|
898
|
-
// deliverAs:'steer' 排队至下一次 LLM
|
|
899
|
-
//
|
|
898
|
+
// deliverAs:'steer' 排队至下一次 LLM 调用(语义登记 PS-58,verifiedWith 以
|
|
899
|
+
// pi-semantics.json 为准——isStreaming 分支 steer 走 _queueSteer;sendMessage 缺省
|
|
900
900
|
// deliverAs 同为 steer,显式传保持排队语义自明);非 streaming 由 triggerTurn:true 开轮
|
|
901
901
|
// 状态写走 transition()(D1 'revise' 边:reviewing → revising)且先于 steer 注入:
|
|
902
902
|
// ok:false = 审批已被交错操作推进,此时注入评论并报成功会让 agent 空跑一轮——
|
|
@@ -1072,7 +1072,7 @@ async function resolveCompleteChoice(
|
|
|
1072
1072
|
// 泛型组件工厂签名比 GuiContext 的宽松形状窄,整 ctx 直传类型不兼容)。
|
|
1073
1073
|
// select 必须 .bind(ctx.ui)(对齐 ask-user / scheduler 同协议形态):callMarkerRpc 先
|
|
1074
1074
|
// 解构再裸调用,this 依赖 pi 实装 select 为箭头闭包——显式 bind 消除该隐式依赖
|
|
1075
|
-
// (pi 实装锚点:dist/modes/rpc/rpc-mode.js
|
|
1075
|
+
// (pi 实装锚点:dist/modes/rpc/rpc-mode.js select 为箭头函数闭包)。
|
|
1076
1076
|
const form = await uiFormInteract(
|
|
1077
1077
|
{ mode: ctx.mode, hasUI: ctx.hasUI, ui: { select: ctx.ui.select.bind(ctx.ui) } },
|
|
1078
1078
|
[question],
|
|
@@ -1279,11 +1279,11 @@ export function registerPlanTool(
|
|
|
1279
1279
|
name: "plan",
|
|
1280
1280
|
label: "Plan Mode",
|
|
1281
1281
|
// 串行声明(D-B1-4):全部 action 就地突变共享 PlanState,声明串行消除并行交错类
|
|
1282
|
-
//(A1 死锁机理的调度半边)。pi
|
|
1283
|
-
// 使整批工具顺序执行。pi
|
|
1284
|
-
// executionMode
|
|
1285
|
-
// with other tool calls)+ @earendil-works/pi-agent-core dist/agent-loop.js
|
|
1286
|
-
//(
|
|
1282
|
+
//(A1 死锁机理的调度半边)。pi 声明粒度是工具级——同批任一 sequential 工具
|
|
1283
|
+
// 使整批工具顺序执行。pi 实装锚点(1.0.0 复核):dist/core/extensions/types.d.ts
|
|
1284
|
+
// executionMode(per-tool 声明,"sequential" = this tool must execute one at a time
|
|
1285
|
+
// with other tool calls)+ @earendil-works/pi-agent-core dist/agent-loop.js
|
|
1286
|
+
//(hasSequentialToolCall = toolCalls.some(...) 命中即整批走
|
|
1287
1287
|
// executeToolCallsSequential)。单行回退通道 = 移除本声明即回默认并行。
|
|
1288
1288
|
executionMode: "sequential",
|
|
1289
1289
|
description:
|