@nguyenquangthai/pi-todo 0.3.1 → 0.3.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3 (2026-07-16)
4
+
5
+ ### Changed
6
+
7
+ - **Overlay heading**: Shows open, running, and completed counts. Completed
8
+ counts include only `completed` todos; `cancelled` todos remain excluded.
9
+ - **Simplified status display**: Removed the ANSI progress bar and `done/total`
10
+ counter from the overlay.
11
+ - **Documentation and screenshot**: Updated the README, screenshot PNG, and
12
+ screenshot HTML source to match the new heading.
13
+
14
+ ## 0.3.2 (2026-07-16)
15
+
16
+ ### Fixed
17
+
18
+ - **Todo ID integrity**: Prevented id-less todos from taking IDs explicitly
19
+ retained by another todo during a full `todo_write` replacement.
20
+ - **Diagnostics**: Added ID-integrity reporting and `repair_needed` status to
21
+ `todo_diagnose`.
22
+ - **Mutation bounds**: Capped todo mutations and persisted snapshots at 200
23
+ items.
24
+
3
25
  ## 0.3.1 (2026-07-15)
4
26
 
5
27
  ### Fixed
package/README.md CHANGED
@@ -46,12 +46,18 @@ Rules enforced by the tool:
46
46
  - `content` required (non-empty after sanitize); max **500** chars (longer values truncated)
47
47
  - `priority` required: `high` | `medium` | `low`
48
48
  - Status: `pending` | `in_progress` | `completed` | `cancelled`
49
+ - **ID rule:** omit `id` for a new item; the system assigns it. Only include an ID returned by `todo_read` when retaining an existing item. Never invent an ID. Replacing the list does not inherently reset IDs: matching existing items can retain them.
50
+ - For changed, repeated, or long/truncated content, include the exact existing ID rather than relying on automatic content matching.
51
+ - Do not call `todo_write` and a `todo_update` that needs its IDs in the same parallel batch. Wait for the write result, then use returned IDs or call `todo_read`.
52
+ - A mutation can contain at most **200** todos/updates.
49
53
  - Array order is the workflow timeline. Keep existing positions when statuses change; only add or reorder items intentionally.
50
54
  - Tool text echo caps at **40** lines (`+N more` in the text body; full list still in `details` / JSON)
51
55
 
52
56
  ### `todo_update`
53
57
 
54
- Patch existing todos by stable ID without replacing the list or changing its order. Use `todo_read` first to obtain IDs; this tool never deletes items.
58
+ Patch existing todos by stable ID without replacing the list or changing its order. `id` is required, must be a non-empty string, and must match a current todo; use `todo_read` first to obtain it. This tool never deletes items.
59
+
60
+ If an older session returns a todo without `id`, it cannot be patched with `todo_update`. Call `todo_write` with that item but omit `id` to assign one, then use `todo_update` normally.
55
61
 
56
62
  ```json
57
63
  {
@@ -67,7 +73,7 @@ Returns the current list as text + JSON. Prefer the overlay for at-a-glance stat
67
73
 
68
74
  ### `todo_diagnose`
69
75
 
70
- Read-only persistence check for suspected reload, tree-navigation, or compaction drift. It compares the live in-memory snapshot against a replay of the durable session branch and reports `consistent` or `mismatch`; it never changes todos.
76
+ Read-only persistence check for suspected reload, tree-navigation, or compaction drift. It compares the live in-memory snapshot against a replay of the durable session branch and reports `consistent`, `mismatch`, or `repair_needed` when duplicate/missing IDs are found; it never changes todos.
71
77
 
72
78
  ## Overlay
73
79
 
@@ -75,12 +81,12 @@ Shown above the editor while any **open** todo remains (`pending` / `in_progress
75
81
 
76
82
  Hidden when the list is empty or every item is `completed` / `cancelled`.
77
83
 
78
- Heading shows open + running counts + background-color progress bar, e.g. `# Todos (3 open, 1 running) ▓▓▓▓░░░░ 1/4`:
79
-
80
- - **open** = `pending` + `in_progress`
81
- - **running** = `in_progress` only (0 or 1 after a valid write)
82
- - **progress bar** = ANSI background color via reverse video, using theme `accent` (filled) and `muted` (empty)
83
-
84
+ Heading shows open, running, and completed counts, e.g. `# Todos (3 open, 1 running, 1 completed)`:
85
+
86
+ - **open** = `pending` + `in_progress`
87
+ - **running** = `in_progress` only (0 or 1 after a valid write)
88
+ - **completed** = `completed` only; `cancelled` todos are not counted
89
+
84
90
  Items always stay in the array's workflow order; status changes only their marker/color.
85
91
  When space is tight, the overlay shows the earliest checklist items and `+N more`. If the active item is outside that prefix, it is repeated as `Active: [•] …` rather than moved ahead of earlier work.
86
92
  A blank line separates the heading from the first todo row for visual breathing room.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nguyenquangthai/pi-todo",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "OpenCode-like session todo checklist for the pi coding agent — todowrite/todoread with a live TUI overlay",
5
5
  "type": "module",
6
6
  "author": "QuangThai",
package/src/format.ts CHANGED
@@ -2,7 +2,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import type { TodoItem, TodoStatus } from "./types.js";
4
4
  import { MAX_OVERLAY_LINES, MAX_RESULT_LINES } from "./types.js";
5
- import { countOpenTodos, countRunningTodos, hasOpenTodos } from "./validate.js";
5
+ import { countCompletedTodos, countOpenTodos, countRunningTodos, hasOpenTodos } from "./validate.js";
6
6
 
7
7
 
8
8
  export function getTodoMarker(status: TodoStatus): string {
@@ -107,13 +107,9 @@ export interface RenderOverlayOptions {
107
107
  maxLines?: number;
108
108
  }
109
109
 
110
- /**
111
- * Heading counts (when overlay is visible):
112
- * - open = pending + in_progress
113
- * - running = in_progress only (0 or 1 after a valid todo_write)
114
- * Overlay is hidden when open === 0, so "(0 open, 0 running)" is never shown.
115
- * Includes a background-color progress bar using theme colors.
116
- */
110
+ /**
111
+ * The overlay is hidden when no work remains open.
112
+ */
117
113
  export function renderOverlayLines(
118
114
  todos: readonly TodoItem[],
119
115
  theme: Theme,
@@ -123,27 +119,15 @@ export function renderOverlayLines(
123
119
  if (!shouldShowOverlay(todos)) return [];
124
120
 
125
121
  const maxLines = Math.max(1, options.maxLines ?? MAX_OVERLAY_LINES);
126
- const truncate = (line: string) => truncateToWidth(line, width, "…");
127
- const open = countOpenTodos(todos);
128
- const running = countRunningTodos(todos);
129
-
130
- // Background-color progress bar via reverse video — uses theme accent/dim
131
- const total = todos.length;
132
- const done = total - open;
133
- const barWidth = Math.max(4, Math.min(10, Math.floor(width / 8)));
134
- const filled = Math.round((barWidth * done) / total);
135
- const REV = "\x1b[7m";
136
- const filledBar = REV + theme.fg("accent", " ".repeat(filled));
137
- const emptyBar =
138
- filled < barWidth ? REV + theme.fg("muted", " ".repeat(barWidth - filled)) : "";
139
- const bar = filledBar + emptyBar + "\x1b[0m";
140
-
141
- const heading = truncate(
142
- theme.fg("accent", theme.bold(`# Todos`)) +
143
- theme.fg("dim", ` (${open} open, ${running} running)`) +
144
- ` ${bar}` + theme.fg("dim", ` ${done}/${total}`),
145
- );
146
-
122
+ const truncate = (line: string) => truncateToWidth(line, width, "…");
123
+ const open = countOpenTodos(todos);
124
+ const running = countRunningTodos(todos);
125
+ const completed = countCompletedTodos(todos);
126
+ const heading = truncate(
127
+ theme.fg("accent", theme.bold("# Todos")) +
128
+ theme.fg("dim", ` (${open} open, ${running} running, ${completed} completed)`),
129
+ );
130
+
147
131
  // Small gap between heading and first row — budget -1 to account for the blank line
148
132
  const layout = selectOverlayLayout(todos, Math.max(3, maxLines - 1));
149
133
  const lines: string[] = [heading, ""];
package/src/prompt.ts CHANGED
@@ -32,6 +32,8 @@ Do **not** skip just because the request is "explain" or "review" — if the ans
32
32
  ## Rules
33
33
 
34
34
  - Each call **REPLACES** the entire list (full replace). Always pass the complete todos array.
35
+ - **ID rule:** omit \`id\` for every new item — the system assigns it. Only preserve an \`id\` returned by \`todo_read\` for an item that already exists; never invent an ID. For changed, repeated, or long/truncated content, preserve the exact existing ID instead of relying on content matching. Full replacement does not inherently reset IDs: matching existing items can retain them.
36
+ - Do not call \`todo_write\` and a dependent \`todo_update\` in the same parallel batch. Complete the write, then use its returned IDs (or \`todo_read\`) for the update.
35
37
  - Update status in real time; don't batch completions across multiple finished steps.
36
38
  - Mark \`completed\` only after the work is actually done (including verification) — never on intent alone.
37
39
  - Keep exactly one \`in_progress\` while actively working. Never leave a stale \`in_progress\` after that step is finished.
@@ -43,7 +45,7 @@ Do **not** skip just because the request is "explain" or "review" — if the ans
43
45
  - Prefer the live overlay for status; use todo_read only when you need the JSON snapshot.`;
44
46
 
45
47
  export const TODOREAD_DESCRIPTION =
46
- "Read the current session todo list. Returns JSON of all todos with status and priority. Prefer the overlay for at-a-glance status; call after todo_write settles (not in the same parallel batch).";
48
+ "Read the current session todo list. Returns JSON of all todos with status, priority, and stable IDs. Prefer the overlay for at-a-glance status; call after todo_write settles (not in the same parallel batch). A legacy item without an ID cannot be patched by todo_update; rewrite it with todo_write and omit id to assign one.";
47
49
 
48
50
  export const TODODIAGNOSE_DESCRIPTION =
49
51
  "Read-only persistence diagnostic. Compares the current in-memory todo snapshot with the durable session-branch replay. Use only to investigate suspected reload, tree-navigation, or compaction state drift; it never changes todos.";
@@ -51,6 +53,8 @@ export const TODODIAGNOSE_DESCRIPTION =
51
53
  export const TODOWRITE_GUIDELINES = [
52
54
  "For multi-step work (3+ steps), codebase explain/explore/review, or when the user gives a list of tasks, call todo_write BEFORE starting — do not skip tracking.",
53
55
  "Pass the full list every todo_write call (full replace). Keep exactly one todo in_progress; mark completed immediately when a step finishes — never leave a stale in_progress.",
56
+ "ID rule: for todo_write, omit id for new items so the system assigns one. Supply an id only to preserve an existing item, using the exact ID from todo_read; never invent IDs. For changed, repeated, or long/truncated content, preserve that exact ID rather than relying on content matching. For todo_update, id is required and must match a current todo.",
57
+ "Never call todo_write and a todo_update that depends on its IDs in the same parallel batch. Wait for todo_write to finish, then use its returned IDs or call todo_read. If todo_read shows a legacy item without id, rewrite it with todo_write and omit id to assign one before using todo_update.",
54
58
  "When finishing a step or when the user confirms done, use todo_update for an ID-based patch when possible; use todo_write only when replacing the full checklist. Advance the next pending item in the same mutation if work continues.",
55
59
  "Treat todo array order as the workflow timeline: preserve positions when updating statuses, and only add or reorder items intentionally.",
56
60
  "Use todo_write for full replacement and todo_update for ID-based patches; todo_read exposes IDs and todo_diagnose is read-only troubleshooting.",
@@ -64,7 +68,7 @@ export const TODOWRITE_GUIDELINES = [
64
68
  */
65
69
  export const TASK_MANAGEMENT_SECTION = `
66
70
  <Task_Management>
67
- todo_write/todo_update/todo_read are the coordination layer for multi-step work. Use todo_write for the initial/full checklist and todo_update for a targeted patch by stable ID. The TUI overlay only appears after a todo mutation — without it the user cannot see plan/progress.
71
+ todo_write/todo_update/todo_read are the coordination layer for multi-step work. Use todo_write for the initial/full checklist and todo_update for a targeted patch by stable ID. In todo_write, omit id for new items; only retain an exact ID returned by todo_read for an existing item. In todo_update, id is required and must match a current todo. Do not call a write and an update that depends on its IDs in the same parallel batch: wait for the write result first. The TUI overlay only appears after a todo mutation — without it the user cannot see plan/progress.
68
72
 
69
73
  Required cold start: for explain/explore/review/implement/refactor/audit/debug/fix/polish/setup of a codebase, feature, UI, or multi-file ask — your FIRST tool call must be todo_write with a short checklist (WHAT/WHERE), exactly one in_progress, then continue. Do not start with read/bash/search alone on those asks.
70
74
 
package/src/schema.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import { Type, type Static } from "typebox";
3
- import { TODO_PRIORITIES, TODO_STATUSES } from "./types.js";
3
+ import { MAX_TODO_ITEMS, TODO_PRIORITIES, TODO_STATUSES } from "./types.js";
4
4
 
5
5
  export const TodoItemSchema = Type.Object({
6
- id: Type.Optional(Type.String({ minLength: 1, description: "Stable todo ID; preserve it when rewriting an existing item" })),
6
+ id: Type.Optional(Type.String({ minLength: 1, description: "Stable ID of an existing todo. Omit for new items; never invent an ID. Preserve only an ID returned by todo_read/current list." })),
7
7
  content: Type.String({ description: "Brief description of the task" }),
8
8
  status: StringEnum([...TODO_STATUSES], {
9
9
  description: "pending | in_progress | completed | cancelled",
@@ -14,8 +14,9 @@ export const TodoItemSchema = Type.Object({
14
14
  });
15
15
 
16
16
  export const TodoWriteParams = Type.Object({
17
- todos: Type.Array(TodoItemSchema, {
18
- description: "The complete updated todo list (full replace)",
17
+ todos: Type.Array(TodoItemSchema, {
18
+ maxItems: MAX_TODO_ITEMS,
19
+ description: `The complete updated todo list (full replace, at most ${MAX_TODO_ITEMS} items). Any supplied ID must already exist; omit ID for a new item.`,
19
20
  }),
20
21
  });
21
22
 
@@ -27,12 +28,12 @@ export const TodoDiagnoseParams = Type.Object({});
27
28
  export const TodoUpdateParams = Type.Object({
28
29
  updates: Type.Array(
29
30
  Type.Object({
30
- id: Type.String({ minLength: 1 }),
31
+ id: Type.String({ minLength: 1, description: "Existing stable ID from todo_read; it must match a current todo" }),
31
32
  content: Type.Optional(Type.String()),
32
33
  status: Type.Optional(StringEnum([...TODO_STATUSES])),
33
34
  priority: Type.Optional(StringEnum([...TODO_PRIORITIES])),
34
35
  }),
35
- { minItems: 1, description: "Patch existing todos by stable ID" },
36
+ { minItems: 1, maxItems: MAX_TODO_ITEMS, description: `Patch at most ${MAX_TODO_ITEMS} existing todos by stable ID` },
36
37
  ),
37
38
  });
38
39
 
@@ -6,12 +6,13 @@ import { TodoDiagnoseParams } from "../schema.js";
6
6
  import { getTodos, withStoreLock } from "../store.js";
7
7
  import type { TodoItem } from "../types.js";
8
8
  import { TOOL_DIAGNOSE } from "../types.js";
9
- import { todosEqual } from "../validate.js";
9
+ import { getTodoIntegrityIssues, todosEqual } from "../validate.js";
10
10
 
11
11
  interface TodoDiagnoseDetails {
12
- status: "consistent" | "mismatch";
12
+ status: "consistent" | "mismatch" | "repair_needed";
13
13
  storeTodos: TodoItem[];
14
14
  replayedTodos: TodoItem[];
15
+ integrityIssues: string[];
15
16
  }
16
17
 
17
18
  /** Registers a read-only comparison between live state and durable branch replay. */
@@ -27,12 +28,20 @@ export function registerTodoDiagnoseTool(pi: ExtensionAPI): void {
27
28
  return withStoreLock(() => {
28
29
  const storeTodos = getTodos();
29
30
  const replayedTodos = replayFromBranch(ctx);
30
- const status = todosEqual(storeTodos, replayedTodos) ? "consistent" : "mismatch";
31
- const details: TodoDiagnoseDetails = { status, storeTodos, replayedTodos };
32
- const text =
33
- status === "consistent"
34
- ? "Persistence check: consistent current todo snapshot matches durable branch replay."
35
- : "Persistence check: MISMATCH — current todo snapshot differs from durable branch replay. No state was changed.";
31
+ const integrityIssues = [
32
+ ...getTodoIntegrityIssues(storeTodos).map((issue) => `current: ${issue}`),
33
+ ...getTodoIntegrityIssues(replayedTodos).map((issue) => `durable: ${issue}`),
34
+ ];
35
+ const status = integrityIssues.length > 0
36
+ ? "repair_needed"
37
+ : todosEqual(storeTodos, replayedTodos) ? "consistent" : "mismatch";
38
+ const details: TodoDiagnoseDetails = { status, storeTodos, replayedTodos, integrityIssues };
39
+ const text =
40
+ status === "consistent"
41
+ ? "Persistence check: consistent — current todo snapshot matches durable branch replay."
42
+ : status === "repair_needed"
43
+ ? "Integrity check: REPAIR NEEDED — duplicate or missing IDs were found. Use todo_write with the complete list and omit ID for each affected item; no state was changed."
44
+ : "Persistence check: MISMATCH — current todo snapshot differs from durable branch replay. No state was changed.";
36
45
 
37
46
  return {
38
47
  content: [{ type: "text", text: `${text}\n\n${JSON.stringify(details, null, 2)}` }],
@@ -47,7 +56,7 @@ export function registerTodoDiagnoseTool(pi: ExtensionAPI): void {
47
56
 
48
57
  renderResult(result, _opts, theme) {
49
58
  const details = result.details as TodoDiagnoseDetails | undefined;
50
- const text = details?.status === "consistent" ? "✓ Consistent" : "! Mismatch";
59
+ const text = details?.status === "consistent" ? "✓ Consistent" : details?.status === "repair_needed" ? "! Repair needed" : "! Mismatch";
51
60
  const color = details?.status === "consistent" ? "success" : "warning";
52
61
  return new Text(theme.fg(color, text), 0, 0);
53
62
  },
package/src/types.ts CHANGED
@@ -29,5 +29,7 @@ export const WIDGET_KEY = "pi-todo";
29
29
  export const MAX_OVERLAY_LINES = 12;
30
30
  /** Max characters per todo content after sanitize (context/tool safety). */
31
31
  export const MAX_CONTENT_LENGTH = 500;
32
+ /** Bound mutation payloads and persisted snapshots to keep tool context manageable. */
33
+ export const MAX_TODO_ITEMS = 200;
32
34
  /** Max todo lines echoed in todowrite/todoread text (full list still in details/JSON). */
33
35
  export const MAX_RESULT_LINES = 40;
package/src/validate.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { sanitizeTodoText } from "./sanitize.js";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import type { TodoItem, TodoPriority, TodoStatus } from "./types.js";
4
- import { MAX_CONTENT_LENGTH, TODO_PRIORITIES, TODO_STATUSES } from "./types.js";
4
+ import { MAX_CONTENT_LENGTH, MAX_TODO_ITEMS, TODO_PRIORITIES, TODO_STATUSES } from "./types.js";
5
5
 
6
6
  export type ValidateOk = { ok: true; todos: TodoItem[]; unchanged: boolean };
7
7
  export type ValidateErr = { ok: false; error: string };
@@ -34,6 +34,9 @@ export function validateTodoWrite(
34
34
  if (!Array.isArray(rawTodos)) {
35
35
  return { ok: false, error: "todos must be an array" };
36
36
  }
37
+ if (rawTodos.length > MAX_TODO_ITEMS) {
38
+ return { ok: false, error: `todos must contain at most ${MAX_TODO_ITEMS} items` };
39
+ }
37
40
 
38
41
  const todos: TodoItem[] = [];
39
42
  let inProgressCount = 0;
@@ -110,11 +113,16 @@ export function validateTodoWrite(
110
113
  *
111
114
  * - Items with an explicit `id` keep it (must already exist in `current`, verified
112
115
  * by `validateTodoWrite`).
116
+ * - Explicit IDs are reserved before matching so an id-less item cannot claim
117
+ * an ID that another incoming item explicitly preserves.
113
118
  * - Items without `id` try to match a prior item: first by full tuple
114
119
  * (content+status+priority), then by unique content. If ambiguous (multiple
115
120
  * same-content items) or no prior match, a new UUID is generated.
116
121
  */
117
122
  export function ensureTodoIds(todos: readonly TodoItem[], current: readonly TodoItem[]): TodoItem[] {
123
+ // Reserve every explicit ID up front. Incoming order must not decide whether an
124
+ // id-less item steals an ID that a later item explicitly retains.
125
+ const reserved = new Set(todos.flatMap((todo) => todo.id ? [todo.id] : []));
118
126
  const claimed = new Set<string>();
119
127
 
120
128
  // Pre-index content uniqueness: content that appears only once in `current`
@@ -129,7 +137,7 @@ export function ensureTodoIds(todos: readonly TodoItem[], current: readonly Todo
129
137
  if (!id) {
130
138
  // 1. Exact tuple match (content + status + priority)
131
139
  const byTuple = current.find(
132
- (c) => !!c.id && !claimed.has(c.id) &&
140
+ (c) => !!c.id && !reserved.has(c.id) && !claimed.has(c.id) &&
133
141
  c.content === todo.content &&
134
142
  c.status === todo.status &&
135
143
  c.priority === todo.priority,
@@ -141,23 +149,44 @@ export function ensureTodoIds(todos: readonly TodoItem[], current: readonly Todo
141
149
  const count = contentCounts.get(todo.content) ?? 0;
142
150
  if (count === 1) {
143
151
  const byContent = current.find(
144
- (c) => !!c.id && !claimed.has(c.id) && c.content === todo.content,
152
+ (c) => !!c.id && !reserved.has(c.id) && !claimed.has(c.id) && c.content === todo.content,
145
153
  );
146
154
  if (byContent) id = byContent.id;
147
155
  }
148
156
  }
149
157
  // 3. No match or ambiguous → fresh ID
150
- if (!id) id = randomUUID();
158
+ if (!id) {
159
+ do id = randomUUID(); while (reserved.has(id) || claimed.has(id));
160
+ }
151
161
  }
152
162
  claimed.add(id);
153
163
  return { ...todo, id };
154
164
  });
155
165
  }
156
166
 
167
+ /** Report persisted-state identity problems without mutating legacy snapshots. */
168
+ export function getTodoIntegrityIssues(todos: readonly TodoItem[]): string[] {
169
+ const issues: string[] = [];
170
+ const seen = new Set<string>();
171
+ for (let i = 0; i < todos.length; i++) {
172
+ const id = todos[i].id;
173
+ if (typeof id !== "string" || !id.trim()) {
174
+ issues.push(`todos[${i}] has no stable ID`);
175
+ continue;
176
+ }
177
+ if (seen.has(id)) issues.push(`todos[${i}].id "${id}" is duplicated`);
178
+ seen.add(id);
179
+ }
180
+ return issues;
181
+ }
182
+
157
183
  export function validateTodoUpdate(rawUpdates: unknown, current: readonly TodoItem[]): ValidateResult {
158
184
  if (!Array.isArray(rawUpdates) || rawUpdates.length === 0) {
159
185
  return { ok: false, error: "updates must be a non-empty array" };
160
186
  }
187
+ if (rawUpdates.length > MAX_TODO_ITEMS) {
188
+ return { ok: false, error: `updates must contain at most ${MAX_TODO_ITEMS} items` };
189
+ }
161
190
  const next = current.map((todo) => ({ ...todo }));
162
191
  const seen = new Set<string>();
163
192
  for (let i = 0; i < rawUpdates.length; i++) {
@@ -199,3 +228,8 @@ export function countOpenTodos(todos: readonly TodoItem[]): number {
199
228
  export function countRunningTodos(todos: readonly TodoItem[]): number {
200
229
  return todos.filter((t) => t.status === "in_progress").length;
201
230
  }
231
+
232
+ /** Count successfully finished items; cancelled items are intentionally excluded. */
233
+ export function countCompletedTodos(todos: readonly TodoItem[]): number {
234
+ return todos.filter((todo) => todo.status === "completed").length;
235
+ }