@zhushanwen/pi-todo 0.2.0 → 0.4.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/tool.ts CHANGED
@@ -2,14 +2,14 @@
2
2
  * Todo tool 注册 + execute dispatcher + 5 个 action handler。
3
3
  */
4
4
 
5
- import { StringEnum } from "@mariozechner/pi-ai";
5
+ import { StringEnum } from "@earendil-works/pi-ai";
6
+ import { Text } from "@earendil-works/pi-tui";
6
7
  import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
7
- import { Text } from "@mariozechner/pi-tui";
8
8
  import { type Static, Type } from "typebox";
9
9
 
10
10
  import {
11
11
  addTodos,
12
- buildRender,
12
+ buildGui,
13
13
  formatTodoLine,
14
14
  type Todo,
15
15
  type TodoDetails,
@@ -28,58 +28,44 @@ export interface TodoActionParams {
28
28
  texts?: string[];
29
29
  ids?: number[];
30
30
  status?: string;
31
+ isVerification?: boolean;
31
32
  updates?: Array<{ id: number; status?: string; text?: string }>;
32
33
  }
33
34
 
34
35
  // ── TodoParams schema ────────────────────────────────
35
36
 
36
- export const TodoParams = Type.Object({
37
+ const TodoParams = Type.Object({
37
38
  action: StringEnum(["list", "add", "update", "delete", "clear"] as const),
38
39
  text: Type.Optional(Type.String({ description: "Todo text (for update action)" })),
39
40
  id: Type.Optional(Type.Number({ description: "Todo ID (for update action)" })),
40
41
  texts: Type.Optional(Type.Array(Type.String(), { description: "Todo text list (for add action)" })),
41
42
  ids: Type.Optional(Type.Array(Type.Number(), { description: "Todo ID list (for delete action)" })),
42
43
  status: Type.Optional(
43
- StringEnum(VALID_STATUSES, { description: "Target status (for update action)" }),
44
+ StringEnum(VALID_STATUSES, { description: "Target status (for update action)" }),
45
+ ),
46
+ isVerification: Type.Optional(
47
+ Type.Boolean({
48
+ description: "Mark added todos as verification tasks (for add action). Verification todos must be completed (not cancelled) before goal completion.",
49
+ }),
44
50
  ),
45
51
  updates: Type.Optional(
46
- Type.Array(
47
- Type.Object({
48
- id: Type.Number({ description: "Todo ID to update" }),
49
- status: Type.Optional(
50
- Type.String({ description: "Target status; one of pending/in_progress/completed" }),
51
- ),
52
- text: Type.Optional(Type.String({ description: "New todo text" })),
53
- }),
54
- { description: "Batch updates array (takes priority over single id/status/text)" },
52
+ Type.Array(
53
+ Type.Object({
54
+ id: Type.Number({ description: "Todo ID to update" }),
55
+ status: Type.Optional(
56
+ Type.String({ description: "Target status; one of pending/in_progress/completed/cancelled" }),
57
+ ),
58
+ text: Type.Optional(Type.String({ description: "New todo text" })),
59
+ }),
60
+ { description: "Batch updates array (takes priority over single id/status/text)" },
61
+ ),
55
62
  ),
56
- ),
57
- });
58
-
59
- // ── 错误结果构造 helper ──────────────────────────────
60
-
61
- function errorResult(
62
- action: TodoDetails["action"],
63
- state: TodoSessionState,
64
- errorText: string,
65
- errorCode: string,
66
- ): {
67
- content: Array<{ type: "text"; text: string }>;
68
- details: TodoDetails;
69
- } {
70
- return {
71
- content: [{ type: "text" as const, text: errorText }],
72
- details: {
73
- action,
74
- todos: [...state.todos],
75
- nextId: state.nextId,
76
- error: errorCode,
77
- _render: buildRender(state.todos),
78
- } as TodoDetails,
79
- };
80
- }
63
+ });
81
64
 
82
65
  // ── 5 个 action handler ──────────────────────────────
66
+ // 错误处理约定(见 CLAUDE.md「Tool 设计」):handler 失败直接 throw,
67
+ // 不返回「错误成功模式」。model 层纯函数返回 Result 对象(合法),
68
+ // 由 dispatcher 在拿到 error 时 throw,把友好文案交给 Pi 框架展示。
83
69
 
84
70
  /** list action */
85
71
  function handleList(state: TodoSessionState): string {
@@ -88,76 +74,53 @@ function handleList(state: TodoSessionState): string {
88
74
  : "No todos";
89
75
  }
90
76
 
91
- /** add action */
92
- function handleAdd(
93
- state: TodoSessionState,
94
- params: TodoActionParams,
95
- ): { resultText: string; error?: string } {
77
+ /** add action — 失败抛错 */
78
+ function handleAdd(state: TodoSessionState, params: TodoActionParams): string {
96
79
  if (!params.texts || params.texts.length === 0) {
97
- return { resultText: "", error: "texts required" };
80
+ throw new Error("add requires texts parameter (non-empty array)");
98
81
  }
99
-
100
- const addResult = addTodos(state.todos, state.nextId, params.texts);
101
- if (addResult.error) {
102
- return { resultText: addResult.resultText || "", error: addResult.error };
103
- }
104
-
105
- state.todos = addResult.newTodos;
106
- state.nextId = addResult.newNextId;
107
- return { resultText: addResult.resultText || "" };
82
+ const r = addTodos(state.todos, state.nextId, params.texts, params.isVerification);
83
+ if (r.error) throw new Error(r.resultText);
84
+ state.todos = r.newTodos;
85
+ state.nextId = r.newNextId;
86
+ return r.resultText!;
108
87
  }
109
88
 
110
- /** update action: batch */
111
- function handleBatchUpdate(
112
- state: TodoSessionState,
113
- params: TodoActionParams,
114
- ): { resultText: string; error?: string; earlyReturn?: { content: Array<{ type: "text"; text: string }>; details: TodoDetails } } {
115
- const result = updateTodos(state.todos, params.updates ?? []);
116
- if (result.error) {
117
- return {
118
- resultText: result.resultText || "",
119
- error: result.error,
120
- earlyReturn: {
121
- content: [{ type: "text" as const, text: result.resultText || "" }],
122
- details: {
123
- action: "update" as const,
124
- todos: [...state.todos],
125
- nextId: state.nextId,
126
- error: result.error,
127
- _render: buildRender(state.todos),
128
- } as TodoDetails,
129
- },
130
- };
131
- }
132
- state.todos = result.updatedTodos;
133
- return { resultText: result.resultText || "" };
89
+ /** update action: batch — 失败抛错 */
90
+ function handleBatchUpdate(state: TodoSessionState, params: TodoActionParams): string {
91
+ const r = updateTodos(state.todos, params.updates ?? []);
92
+ if (r.error) throw new Error(r.resultText);
93
+ state.todos = r.updatedTodos;
94
+ return r.resultText!;
134
95
  }
135
96
 
136
- /** update action: single */
137
- function handleSingleUpdate(
138
- state: TodoSessionState,
139
- params: TodoActionParams,
140
- ): { resultText: string; error?: string } {
141
- if (params.id === undefined) return { resultText: "", error: "id required" };
142
- if (params.status === undefined && params.text === undefined) return { resultText: "", error: "need status or text" };
143
- if (params.text !== undefined && params.text === "") return { resultText: "", error: "text empty" };
97
+ /** update action: single — 失败抛错 */
98
+ export function handleSingleUpdate(state: TodoSessionState, params: TodoActionParams): string {
99
+ if (params.id === undefined) throw new Error("update requires id parameter");
100
+ if (params.status === undefined && params.text === undefined)
101
+ throw new Error("update requires at least status or text parameter");
102
+ if (params.text !== undefined && params.text === "") throw new Error("text cannot be empty string");
144
103
  if (
145
104
  params.status !== undefined &&
146
105
  !VALID_STATUSES.includes(params.status as (typeof VALID_STATUSES)[number])
147
106
  ) {
148
- return { resultText: "", error: `invalid status: ${params.status}` };
107
+ throw new Error(`status only accepts ${VALID_STATUSES.join(" / ")}`);
149
108
  }
150
109
 
151
110
  const todo = state.todos.find((t) => t.id === params.id);
152
- if (!todo) return { resultText: "", error: `#${params.id} not found` };
111
+ if (!todo) throw new Error(`Todo #${params.id} not found`);
153
112
 
154
- if (params.status !== undefined) {
155
- todo.status = params.status as Todo["status"];
113
+ // FR-6 不变量守卫(失败抛错):(a) cancelled 不可恢复;(b) 验证任务不可 cancelled
114
+ if (todo.status === "cancelled" && params.status !== undefined) {
115
+ throw new Error(`#${params.id} is cancelled (cannot restore)`);
156
116
  }
157
- if (params.text !== undefined) {
158
- todo.text = params.text;
117
+ if (todo.isVerification && params.status === "cancelled") {
118
+ throw new Error(`#${params.id} is verification todo (cannot cancel)`);
159
119
  }
160
120
 
121
+ if (params.status !== undefined) todo.status = params.status as Todo["status"];
122
+ if (params.text !== undefined) todo.text = params.text;
123
+
161
124
  const parts: string[] = [`Updated todo #${todo.id}`];
162
125
  if (params.status !== undefined) parts.push(`status → ${params.status}`);
163
126
  if (params.text !== undefined) parts.push(`text → "${todo.text}"`);
@@ -165,36 +128,26 @@ function handleSingleUpdate(
165
128
  // 最后一个完成提示
166
129
  const incompleteAfter = state.todos.filter((t) => t.status !== "completed");
167
130
  if (params.status === "completed" && incompleteAfter.length === 0) {
168
- return { resultText: parts.join(", ") + "\n\nAll todos completed. Please summarize your work." };
131
+ return parts.join(", ") + "\n\nAll todos completed. Please summarize your work.";
169
132
  }
170
- return { resultText: parts.join(", ") };
133
+ return parts.join(", ");
171
134
  }
172
135
 
173
- /** update action: dispatcher */
174
- function handleUpdate(
175
- state: TodoSessionState,
176
- params: TodoActionParams,
177
- ):
178
- | { resultText: string; error?: string; earlyReturn?: { content: Array<{ type: "text"; text: string }>; details: TodoDetails } }
179
- | undefined {
180
- if (params.updates && params.updates.length > 0) {
181
- return handleBatchUpdate(state, params);
182
- }
136
+ /** update action: dispatcher — batch 优先于 single */
137
+ function handleUpdate(state: TodoSessionState, params: TodoActionParams): string {
138
+ if (params.updates && params.updates.length > 0) return handleBatchUpdate(state, params);
183
139
  return handleSingleUpdate(state, params);
184
140
  }
185
141
 
186
- /** delete action */
187
- function handleDelete(
188
- state: TodoSessionState,
189
- params: TodoActionParams,
190
- ): { resultText: string; error?: string } {
142
+ /** delete action — 失败抛错;部分 id 缺失则整体拒绝(原子性) */
143
+ function handleDelete(state: TodoSessionState, params: TodoActionParams): string {
191
144
  if (!params.ids || params.ids.length === 0) {
192
- return { resultText: "", error: "ids required" };
145
+ throw new Error("delete requires ids parameter (non-empty array)");
193
146
  }
194
147
  const uniqueIds = [...new Set(params.ids)];
195
148
  const missing = uniqueIds.filter((id) => !state.todos.some((t) => t.id === id));
196
149
  if (missing.length > 0) {
197
- return { resultText: "", error: `#${missing.map((id) => id).join(", #")} not found` };
150
+ throw new Error(`Todo #${missing.join(", #")} not found`);
198
151
  }
199
152
  const removedIds: number[] = [];
200
153
  for (const id of uniqueIds) {
@@ -204,7 +157,7 @@ function handleDelete(
204
157
  removedIds.push(id);
205
158
  }
206
159
  }
207
- return { resultText: `Deleted ${removedIds.length} items (#${removedIds.join(", #")}), ${state.todos.length} remaining` };
160
+ return `Deleted ${removedIds.length} items (#${removedIds.join(", #")}), ${state.todos.length} remaining`;
208
161
  }
209
162
 
210
163
  /** clear action */
@@ -219,7 +172,7 @@ function handleClear(state: TodoSessionState): string {
219
172
 
220
173
  // ── Dispatcher ───────────────────────────────────────
221
174
 
222
- export function executeTodoAction(
175
+ function executeTodoAction(
223
176
  params: TodoActionParams,
224
177
  state: TodoSessionState,
225
178
  ctx: ExtensionContext,
@@ -231,94 +184,45 @@ export function executeTodoAction(
231
184
  state.lastTodoCallCount = state.userMessageCount;
232
185
  state.stallNotified = false;
233
186
 
234
- let resultText = "";
235
-
187
+ let resultText: string;
236
188
  switch (params.action) {
237
- case "list": {
189
+ case "list":
238
190
  resultText = handleList(state);
239
191
  break;
240
- }
241
-
242
- case "add": {
243
- const r = handleAdd(state, params);
244
- if (r.error === "texts required") {
245
- return errorResult("add", state, "Error: add requires texts parameter (non-empty array)", r.error);
246
- }
247
- if (r.error) {
248
- return errorResult("add", state, r.resultText, r.error);
249
- }
250
- resultText = r.resultText;
192
+ case "add":
193
+ resultText = handleAdd(state, params);
251
194
  break;
252
- }
253
-
254
- case "update": {
255
- const r = handleUpdate(state, params);
256
- if (!r) {
257
- resultText = "Unknown error";
258
- break;
259
- }
260
- if (r.earlyReturn) return r.earlyReturn;
261
- if (r.error) {
262
- const errorText = mapUpdateErrorText(state, params, r.error);
263
- return errorResult("update", state, errorText, r.error);
264
- }
265
- resultText = r.resultText;
195
+ case "update":
196
+ resultText = handleUpdate(state, params);
266
197
  break;
267
- }
268
-
269
- case "delete": {
270
- const r = handleDelete(state, params);
271
- if (r.error === "ids required") {
272
- return errorResult("delete", state, "Error: delete requires ids parameter (non-empty array)", r.error);
273
- }
274
- if (r.error) {
275
- return errorResult("delete", state, `Error: Todo ${r.error.replace(/^#/, "#")}`, r.error);
276
- }
277
- resultText = r.resultText;
198
+ case "delete":
199
+ resultText = handleDelete(state, params);
278
200
  break;
279
- }
280
-
281
- case "clear": {
201
+ case "clear":
282
202
  resultText = handleClear(state);
283
203
  break;
284
- }
285
-
286
204
  default:
287
- return errorResult("list", state, `Unknown action: ${params.action}`, `unknown action: ${params.action}`);
205
+ throw new Error(`Unknown action: ${params.action}`);
288
206
  }
289
207
 
290
208
  refreshDisplay(ctx);
291
209
 
210
+ const details: TodoDetails = {
211
+ action: params.action as TodoDetails["action"],
212
+ todos: [...state.todos],
213
+ nextId: state.nextId,
214
+ };
215
+ // RPC 模式(xyz-agent GUI)附加 __gui__,前端按 list-tree 渲染。
216
+ // TUI/print/json 模式走原生文本渲染(resultText 已在 content 中)。
217
+ if (ctx.mode === "rpc") {
218
+ details.__gui__ = buildGui(state.todos);
219
+ }
292
220
  return {
293
221
  content: [{ type: "text" as const, text: resultText }],
294
- details: {
295
- action: params.action as TodoDetails["action"],
296
- todos: [...state.todos],
297
- nextId: state.nextId,
298
- _render: buildRender(state.todos),
299
- } as TodoDetails,
222
+ details,
300
223
  };
301
224
  }
302
225
 
303
- function mapUpdateErrorText(state: TodoSessionState, _params: TodoActionParams, code: string): string {
304
- switch (code) {
305
- case "id required":
306
- return "Error: update requires id parameter";
307
- case "need status or text":
308
- return "Error: update requires at least status or text parameter";
309
- case "text empty":
310
- return "Error: text cannot be empty string";
311
- default:
312
- if (code.startsWith("invalid status:")) {
313
- return `Error: status only accepts ${VALID_STATUSES.join(" / ")}`;
314
- }
315
- if (code.startsWith("#") && code.includes("not found")) {
316
- return `Error: Todo ${code} not found`;
317
- }
318
- return `Error: ${code}`;
319
- }
320
- }
321
-
322
226
  // ── Tool 注册入口 ─────────────────────────────────────
323
227
 
324
228
  export function registerTodoTool(
@@ -333,44 +237,24 @@ export function registerTodoTool(
333
237
  "Manage a todo list." +
334
238
  "\n\nAvailable actions:" +
335
239
  "\n- list: View all todos" +
336
- "\n- add: Batch add todos (requires texts array)" +
337
- "\n- update: Update a todo (requires id, optional status/text)" +
240
+ "\n- add: Batch add todos (requires texts array; optional isVerification marks verification tasks)" +
241
+ "\n- update: Update todo(s) — single (id + optional status/text) or batch (updates[], takes priority)" +
338
242
  "\n- delete: Batch delete todos (requires ids array)" +
339
- "\n- clear: Clear all todos and reset IDs"
340
- + "\nWhen /goal is active, do NOT use this tool — use goal_manager's add_subtasks instead.",
341
- promptSnippet: "Use todo when breaking multi-step work into trackable items during normal (non-goal) conversation. Not for single-step operations.",
243
+ "\n- clear: Clear all todos and reset IDs",
244
+ promptSnippet: "Use todo when breaking multi-step work into trackable items. Add verification todos (isVerification=true) for checks like running tests.",
342
245
  promptGuidelines: [
343
246
  "[Usage] 多步骤工作(3+步)时使用。AI 自发创建,无需用户触发",
344
- "[Goal 冲突] /goal 激活后禁止使用 todo — 改用 add_subtasks",
247
+ "[验证任务] 执行任务 + 验证任务(isVerification=true,如 run tests / typecheck)一起建",
345
248
  "[批量优先] 完成多项任务时使用 updates[] 批量更新,减少工具调用次数",
346
249
  "[自动闭合] 全部完成后工具自动清理,无需手动 clear",
347
- "[Not for] 单步操作、简单对话、/goal 已激活时",
250
+ "[Not for] 单步操作、简单对话",
348
251
  ],
252
+ executionMode: "sequential",
349
253
  parameters: TodoParams,
350
254
 
351
255
  async execute(_toolCallId: string, params: Static<typeof TodoParams>, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
352
- if (signal?.aborted) {
353
- return {
354
- content: [{ type: "text" as const, text: "Todo call aborted by signal." }],
355
- details: {
356
- action: "list" as const,
357
- todos: [],
358
- nextId: 1,
359
- error: "aborted",
360
- _render: undefined,
361
- } as TodoDetails,
362
- };
363
- }
364
- const result = executeTodoAction(params as TodoActionParams, state, ctx, refreshDisplay);
365
- const details = result.details as { error?: string } | undefined;
366
- if (details?.error) {
367
- const textPart = result.content[0];
368
- if (textPart?.type === "text") {
369
- const inputSummary = JSON.stringify(params);
370
- textPart.text += `\nInput: ${inputSummary}`;
371
- }
372
- }
373
- return result;
256
+ if (signal?.aborted) throw new Error("Todo call aborted by signal.");
257
+ return executeTodoAction(params as TodoActionParams, state, ctx, refreshDisplay);
374
258
  },
375
259
 
376
260
  renderCall(args: Record<string, unknown>, theme: Theme, _context?: unknown) {