@zhushanwen/pi-todo 0.2.0 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-todo",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "AI-driven todo list for Pi — stateful task management with session persistence and /todos command.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -11,6 +11,8 @@ import {
11
11
  VALID_STATUSES,
12
12
  } from "../model";
13
13
  import { renderWidgetLines } from "../render";
14
+ import { createTodoSessionState } from "../state";
15
+ import { handleSingleUpdate } from "../tool";
14
16
 
15
17
  // ── 数据模型 + 向后兼容 ──────────────────────────────
16
18
 
@@ -24,8 +26,8 @@ describe("Todo data model", () => {
24
26
  expect(migrated.id).toBe(1);
25
27
  });
26
28
 
27
- it("should include exactly three valid statuses", () => {
28
- expect(VALID_STATUSES).toEqual(["pending", "in_progress", "completed"]);
29
+ it("should include exactly four valid statuses", () => {
30
+ expect(VALID_STATUSES).toEqual(["pending", "in_progress", "completed", "cancelled"]);
29
31
  });
30
32
 
31
33
  it("should migrate verifying → in_progress", () => {
@@ -51,6 +53,18 @@ describe("Todo data model", () => {
51
53
  const migrated = migrateTodo(veryOldTodo);
52
54
  expect(migrated.status).toBe("pending");
53
55
  });
56
+
57
+ it("should preserve isVerification flag (FR-6)", () => {
58
+ const todo = { id: 1, text: "run tests", status: "pending", isVerification: true } as unknown as Todo;
59
+ const migrated = migrateTodo(todo);
60
+ expect(migrated.isVerification).toBe(true);
61
+ });
62
+
63
+ it("should preserve cancelled status (FR-1 four-state)", () => {
64
+ const todo = { id: 1, text: "dropped", status: "cancelled" } as unknown as Todo;
65
+ const migrated = migrateTodo(todo);
66
+ expect(migrated.status).toBe("cancelled");
67
+ });
54
68
  });
55
69
 
56
70
  // ── todo add ────────────────────────────────────────
@@ -90,6 +104,25 @@ describe("todo add", () => {
90
104
  expect(result.error).toBeUndefined();
91
105
  expect(result.newTodos[0].text).toBe("new task");
92
106
  });
107
+
108
+ it("should mark todos as verification when isVerification=true (FR-6)", () => {
109
+ const result = addTodos([], 1, ["run tests", "typecheck"], true);
110
+ expect(result.error).toBeUndefined();
111
+ expect(result.newTodos[0].isVerification).toBe(true);
112
+ expect(result.newTodos[1].isVerification).toBe(true);
113
+ });
114
+
115
+ it("should not set isVerification when omitted", () => {
116
+ const result = addTodos([], 1, ["regular task"]);
117
+ expect(result.error).toBeUndefined();
118
+ expect(result.newTodos[0].isVerification).toBeUndefined();
119
+ });
120
+
121
+ it("should not set isVerification when isVerification=false", () => {
122
+ const result = addTodos([], 1, ["regular task"], false);
123
+ expect(result.error).toBeUndefined();
124
+ expect(result.newTodos[0].isVerification).toBeUndefined();
125
+ });
93
126
  });
94
127
 
95
128
  // ── todo update batch ───────────────────────────────
@@ -144,6 +177,40 @@ describe("todo update batch", () => {
144
177
  const result = updateTodos(todos, [{ id: 1, status: "banana" }]);
145
178
  expect(result.error).toContain("invalid status");
146
179
  });
180
+
181
+ it("FR-6: cancelled todo 不可恢复(status 更新拒绝)", () => {
182
+ const todos: Todo[] = [{ id: 1, text: "dropped", status: "cancelled" }];
183
+ const result = updateTodos(todos, [{ id: 1, status: "pending" }]);
184
+ expect(result.error).toBe("id 1 is cancelled");
185
+ expect(result.resultText).toContain("cannot be restored");
186
+ expect(result.updatedTodos).toEqual(todos);
187
+ });
188
+
189
+ it("FR-6: 验证任务不可 cancelled", () => {
190
+ const todos: Todo[] = [{ id: 2, text: "run tests", status: "in_progress", isVerification: true }];
191
+ const result = updateTodos(todos, [{ id: 2, status: "cancelled" }]);
192
+ expect(result.error).toBe("id 2 is verification todo");
193
+ expect(result.resultText).toContain("cannot be cancelled");
194
+ expect(result.updatedTodos).toEqual(todos);
195
+ });
196
+ });
197
+
198
+ // ── handleSingleUpdate FR-6 守卫(tool 单条路径)────
199
+
200
+ describe("handleSingleUpdate FR-6 guards (tool single path)", () => {
201
+ it("FR-6: cancelled todo + status → cannot restore", () => {
202
+ const state = createTodoSessionState();
203
+ state.todos = [{ id: 1, text: "dropped", status: "cancelled" }];
204
+ const result = handleSingleUpdate(state, { action: "update", id: 1, status: "pending" });
205
+ expect(result.error).toBe("#1 is cancelled (cannot restore)");
206
+ });
207
+
208
+ it("FR-6: verification todo + status=cancelled → cannot cancel", () => {
209
+ const state = createTodoSessionState();
210
+ state.todos = [{ id: 2, text: "run tests", status: "in_progress", isVerification: true }];
211
+ const result = handleSingleUpdate(state, { action: "update", id: 2, status: "cancelled" });
212
+ expect(result.error).toBe("#2 is verification todo (cannot cancel)");
213
+ });
147
214
  });
148
215
 
149
216
  // ── completed 无拦截 ────────────────────────────────
@@ -195,6 +262,11 @@ describe("formatTodoLine", () => {
195
262
  const todo: Todo = { id: 3, text: "task C", status: "completed" };
196
263
  expect(formatTodoLine(todo)).toBe("[x] #3: task C");
197
264
  });
265
+
266
+ it("should format cancelled todo", () => {
267
+ const todo: Todo = { id: 4, text: "task D", status: "cancelled" };
268
+ expect(formatTodoLine(todo)).toBe("[-] #4: task D");
269
+ });
198
270
  });
199
271
 
200
272
  // ── buildRender ─────────────────────────────────────
package/src/handlers.ts CHANGED
@@ -55,7 +55,7 @@ function buildBeforeAgentStartMessage(state: TodoSessionState): { message: { cus
55
55
 
56
56
  // ── 状态重建 ────────────────────────────────────────
57
57
 
58
- export function reconstructState(state: TodoSessionState, ctx: ExtensionContext): void {
58
+ function reconstructState(state: TodoSessionState, ctx: ExtensionContext): void {
59
59
  state.todos = [];
60
60
  state.nextId = 1;
61
61
  state.userMessageCount = 0;
package/src/index.ts CHANGED
@@ -35,6 +35,9 @@ export default function (pi: ExtensionAPI) {
35
35
  // ── 闭包内状态(session 隔离) ─────────────────────
36
36
  const state = createTodoSessionState();
37
37
 
38
+ // 全解耦:不再暴露 pi.__todoGetList 跨扩展 API(goal 不再读 todo 状态)。
39
+ // todo 进度由 AI 自行管理,goal 不做强制检查。
40
+
38
41
  // ── 刷新显示(依赖闭包 state) ─────────────────────
39
42
  function refreshDisplay(ctx: ExtensionContext): void {
40
43
  const statusText = renderStatusText(state.todos, ctx.ui.theme);
package/src/model.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Todo 数据模型 — 纯函数,不依赖 Pi 运行时。
3
- * 三态: pending → in_progress → completed
3
+ * 四态: pending → in_progress → completed;任一状态 → cancelled
4
+ * (cancelled 不可恢复;isVerification 标记验证任务,FR-6 completion audit 用)
4
5
  */
5
6
 
6
7
  // ── 数据模型 ─────────────────────────────────────────
@@ -8,7 +9,9 @@
8
9
  export interface Todo {
9
10
  id: number;
10
11
  text: string;
11
- status: "pending" | "in_progress" | "completed";
12
+ status: "pending" | "in_progress" | "completed" | "cancelled";
13
+ /** 验证任务标记(FR-6 completion audit)。验证任务必须 completed,不可 cancelled。 */
14
+ isVerification?: boolean;
12
15
  }
13
16
 
14
17
  export interface TodoDetails {
@@ -26,21 +29,12 @@ export interface TodoDetails {
26
29
  };
27
30
  }
28
31
 
29
- export const VALID_STATUSES = ["pending", "in_progress", "completed"] as const;
32
+ export const VALID_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const;
30
33
 
31
34
  export type ValidStatus = (typeof VALID_STATUSES)[number];
32
35
 
33
36
  // ── 迁移/兼容 ───────────────────────────────────────
34
37
 
35
- const STALE_CONTEXT_PATTERNS = ["aborted", "context canceled", "stale context", "stalecontext", "extension context no longer active"];
36
-
37
- /** 检查错误是否表示 stale / canceled context */
38
- export function isStaleContextError(error: Error | unknown): boolean {
39
- const msg = error instanceof Error ? error.message : String(error);
40
- const lower = msg.toLowerCase();
41
- return STALE_CONTEXT_PATTERNS.some((p) => lower.includes(p));
42
- }
43
-
44
38
  /** 旧格式迁移:verifying → in_progress,failed → pending,done:boolean → status */
45
39
  export function migrateTodo(raw: Todo): Todo {
46
40
  const record = raw as unknown as Record<string, unknown>;
@@ -66,6 +60,8 @@ export function migrateTodo(raw: Todo): Todo {
66
60
  id: record.id as number,
67
61
  text: record.text as string,
68
62
  status,
63
+ // FR-6: 保留 isVerification 标记(可选字段,旧数据可能缺失)
64
+ isVerification: record.isVerification === true ? true : undefined,
69
65
  };
70
66
  }
71
67
 
@@ -101,6 +97,7 @@ export function addTodos(
101
97
  currentTodos: Todo[],
102
98
  currentNextId: number,
103
99
  texts: string[],
100
+ isVerification?: boolean,
104
101
  ): AddResult {
105
102
  if (!texts || texts.length === 0) {
106
103
  return {
@@ -129,6 +126,8 @@ export function addTodos(
129
126
  id: nextId++,
130
127
  text: trimmed[i],
131
128
  status: "pending" as const,
129
+ // FR-6: isVerification 标记验证任务(可选,仅 add 时可设)
130
+ isVerification: isVerification === true ? true : undefined,
132
131
  });
133
132
  }
134
133
  const endId = nextId - 1;
@@ -183,6 +182,21 @@ export function updateTodos(
183
182
  resultText: `Error: invalid status '${u.status}' for update item id ${u.id}`,
184
183
  };
185
184
  }
185
+ // FR-6 不变量守卫:(a) cancelled 不可恢复;(b) 验证任务不可 cancelled
186
+ if (todo.status === "cancelled" && u.status !== undefined) {
187
+ return {
188
+ updatedTodos: currentTodos,
189
+ error: `id ${u.id} is cancelled`,
190
+ resultText: `Error: Todo #${u.id} is cancelled and cannot be restored`,
191
+ };
192
+ }
193
+ if (todo.isVerification && u.status === "cancelled") {
194
+ return {
195
+ updatedTodos: currentTodos,
196
+ error: `id ${u.id} is verification todo`,
197
+ resultText: `Error: Todo #${u.id} is a verification todo and cannot be cancelled`,
198
+ };
199
+ }
186
200
  }
187
201
 
188
202
  const updated = currentTodos.map((t) => {
@@ -207,6 +221,8 @@ export function formatTodoLine(t: Todo): string {
207
221
  ? "x"
208
222
  : t.status === "in_progress"
209
223
  ? "~"
210
- : " ";
224
+ : t.status === "cancelled"
225
+ ? "-"
226
+ : " ";
211
227
  return `[${mark}] #${t.id}: ${t.text}`;
212
228
  }
package/src/render.ts CHANGED
@@ -6,7 +6,6 @@ import type { Theme } from "@mariozechner/pi-coding-agent";
6
6
  import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
7
7
 
8
8
  import {
9
- buildRender,
10
9
  getDisplayStatus,
11
10
  type Todo,
12
11
  type TodoDetails,
@@ -55,22 +54,25 @@ export function renderStatusText(todoList: Todo[], th: Theme): string {
55
54
  // ── Widget 双列渲染 ──────────────────────────────────
56
55
 
57
56
  /** 渲染单条 todo 的 widget 行(不含缩进),供 component.ts 复用 */
58
- export function renderWidgetItem(t: Todo, th: Theme): string {
57
+ function renderWidgetItem(t: Todo, th: Theme): string {
59
58
  const mark =
60
59
  t.status === "completed"
61
60
  ? th.fg("success", "\u2713")
62
61
  : t.status === "in_progress"
63
62
  ? th.fg("warning", "\u25cf")
64
- : th.fg("dim", "\u25cb");
63
+ : t.status === "cancelled"
64
+ ? th.fg("error", "\u2715")
65
+ : th.fg("dim", "\u25cb");
65
66
  const id = th.fg("accent", `#${t.id}`);
66
- const text = t.status === "completed" ? th.fg("dim", t.text) : th.fg("text", t.text);
67
+ const text =
68
+ t.status === "completed" || t.status === "cancelled" ? th.fg("dim", t.text) : th.fg("text", t.text);
67
69
  return `${mark} ${id} ${text}`;
68
70
  }
69
71
 
70
72
  /** 单列布局渲染(widget 少量任务时使用) */
71
73
  const PI_TEXT_PADDING = 2;
72
74
 
73
- export function renderSingleColumn(
75
+ function renderSingleColumn(
74
76
  todos: Todo[],
75
77
  th: Theme,
76
78
  termWidth: number,
@@ -136,7 +138,7 @@ export function renderWidgetLines(
136
138
 
137
139
  // ── 列表渲染辅助函数 ─────────────────────────────────
138
140
 
139
- export function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, theme: Theme): string {
141
+ function buildTodoListText(todoList: Todo[], options: { expanded: boolean }, theme: Theme): string {
140
142
  if (todoList.length === 0) {
141
143
  return theme.fg("dim", "No todos");
142
144
  }
@@ -149,9 +151,11 @@ export function buildTodoListText(todoList: Todo[], options: { expanded: boolean
149
151
  ? theme.fg("success", "\u2713")
150
152
  : status === "in_progress"
151
153
  ? theme.fg("warning", "\u25cf")
152
- : theme.fg("dim", "\u25cb");
154
+ : status === "cancelled"
155
+ ? theme.fg("error", "\u2715")
156
+ : theme.fg("dim", "\u25cb");
153
157
  const itemText =
154
- status === "completed" ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
158
+ status === "completed" || status === "cancelled" ? theme.fg("dim", t.text) : theme.fg("muted", t.text);
155
159
  listText += `\n${mark} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
156
160
  }
157
161
  if (!options.expanded && todoList.length > MAX_COLLAPSED_ITEMS) {
@@ -205,4 +209,3 @@ export function renderTodoResult(result: unknown, options: { expanded: boolean }
205
209
  }
206
210
  }
207
211
 
208
- export { buildRender };
package/src/tool.ts CHANGED
@@ -28,33 +28,39 @@ 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
- });
63
+ });
58
64
 
59
65
  // ── 错误结果构造 helper ──────────────────────────────
60
66
 
@@ -97,7 +103,7 @@ function handleAdd(
97
103
  return { resultText: "", error: "texts required" };
98
104
  }
99
105
 
100
- const addResult = addTodos(state.todos, state.nextId, params.texts);
106
+ const addResult = addTodos(state.todos, state.nextId, params.texts, params.isVerification);
101
107
  if (addResult.error) {
102
108
  return { resultText: addResult.resultText || "", error: addResult.error };
103
109
  }
@@ -134,7 +140,7 @@ function handleBatchUpdate(
134
140
  }
135
141
 
136
142
  /** update action: single */
137
- function handleSingleUpdate(
143
+ export function handleSingleUpdate(
138
144
  state: TodoSessionState,
139
145
  params: TodoActionParams,
140
146
  ): { resultText: string; error?: string } {
@@ -151,6 +157,14 @@ function handleSingleUpdate(
151
157
  const todo = state.todos.find((t) => t.id === params.id);
152
158
  if (!todo) return { resultText: "", error: `#${params.id} not found` };
153
159
 
160
+ // FR-6 不变量守卫:(a) cancelled 不可恢复;(b) 验证任务不可 cancelled
161
+ if (todo.status === "cancelled" && params.status !== undefined) {
162
+ return { resultText: "", error: `#${params.id} is cancelled (cannot restore)` };
163
+ }
164
+ if (todo.isVerification && params.status === "cancelled") {
165
+ return { resultText: "", error: `#${params.id} is verification todo (cannot cancel)` };
166
+ }
167
+
154
168
  if (params.status !== undefined) {
155
169
  todo.status = params.status as Todo["status"];
156
170
  }
@@ -219,7 +233,7 @@ function handleClear(state: TodoSessionState): string {
219
233
 
220
234
  // ── Dispatcher ───────────────────────────────────────
221
235
 
222
- export function executeTodoAction(
236
+ function executeTodoAction(
223
237
  params: TodoActionParams,
224
238
  state: TodoSessionState,
225
239
  ctx: ExtensionContext,
@@ -333,19 +347,19 @@ export function registerTodoTool(
333
347
  "Manage a todo list." +
334
348
  "\n\nAvailable actions:" +
335
349
  "\n- list: View all todos" +
336
- "\n- add: Batch add todos (requires texts array)" +
350
+ "\n- add: Batch add todos (requires texts array; optional isVerification marks verification tasks)" +
337
351
  "\n- update: Update a todo (requires id, optional status/text)" +
338
352
  "\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.",
353
+ "\n- clear: Clear all todos and reset IDs",
354
+ promptSnippet: "Use todo when breaking multi-step work into trackable items. Add verification todos (isVerification=true) for checks like running tests.",
342
355
  promptGuidelines: [
343
356
  "[Usage] 多步骤工作(3+步)时使用。AI 自发创建,无需用户触发",
344
- "[Goal 冲突] /goal 激活后禁止使用 todo 改用 add_subtasks",
357
+ "[验证任务] 执行任务 + 验证任务(isVerification=true,如 run tests / typecheck)一起建",
345
358
  "[批量优先] 完成多项任务时使用 updates[] 批量更新,减少工具调用次数",
346
359
  "[自动闭合] 全部完成后工具自动清理,无需手动 clear",
347
- "[Not for] 单步操作、简单对话、/goal 已激活时",
360
+ "[Not for] 单步操作、简单对话",
348
361
  ],
362
+ executionMode: "sequential",
349
363
  parameters: TodoParams,
350
364
 
351
365
  async execute(_toolCallId: string, params: Static<typeof TodoParams>, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {