@nguyenquangthai/pi-todo 0.2.8 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 (2026-07-15)
4
+
5
+ - **ID invariant hardening**: `validateTodoWrite` rejects items with explicit `id`
6
+ that doesn't exist in current list. New items must omit `id` (auto-assign).
7
+ - **`ensureTodoIds` tuple matching**: Matches by `(content+status+priority)` first,
8
+ then by unique content-only. Duplicate-content items get fresh UUIDs instead
9
+ of risking mis-assignment.
10
+ - **`todo_update` atomicity**: Stale-ctx returns error, real errors propagate,
11
+ setTodos after appendEntry — matching todo_write guarantees.
12
+ - **Status-first shared sort**: `statusPrioritySort()` extracted and shared by
13
+ `formatTodoListText` (tool output) and `selectOverlayLayout` (overlay) —
14
+ in_progress → pending → terminal, regardless of overflow.
15
+ - **Fixed double-count bug**: Terminal items no longer counted in both `+N done`
16
+ AND `+N more` on overlay overflow.
17
+ - **Lifecycle E2E test**: Write → update → tree → compact → shutdown → restart,
18
+ verifying stable IDs and status throughout.
19
+ - **125 tests**, typecheck clean, CI green (Node 20 + 22).
20
+
21
+ ## 0.2.9 (2026-07-15)
22
+
23
+ - **Atomic write ordering**: `setTodos()` happens *after* `pi.appendEntry()`
24
+ succeeds. If appendEntry throws (stale-ctx or persistence error), the in-memory
25
+ store is never mutated — no more desync between live state and durable state.
26
+ - **Stale-ctx now returns error**: Instead of silently swallowing "stale after
27
+ session replacement", the tool returns an error so the LLM knows the write
28
+ was not committed.
29
+ - **4 atomicity tests**: Mock appendEntry rejects with stale-ctx / persistence
30
+ errors and asserts store is unchanged. 102/102 tests passing.
31
+
3
32
  ## 0.2.8 (2026-07-15)
4
33
 
5
34
  - **Replay**: revert broken timestamp-based hardening; Pi guarantees getBranch() is chronological, so original last-entry-wins algorithm is correct and simpler.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  OpenCode-style session todo checklist for the [pi coding agent](https://pi.dev).
8
8
 
9
- Adds `todo_write` / `todo_read`, a live `# Todos` overlay above the editor (`[ ]` / `[•]` / `[✓]` / `[×]`), and branch-replay persistence (survives `/reload`, tree nav, and custom-entry durability across compaction).
9
+ Adds `todo_write` / `todo_read` / `todo_diagnose`, a live `# Todos` overlay above the editor (`[ ]` / `[•]` / `[✓]` / `[×]`), and branch-replay persistence (survives `/reload`, tree nav, and custom-entry durability across compaction).
10
10
 
11
11
  ## Install
12
12
 
@@ -50,7 +50,11 @@ Rules enforced by the tool:
50
50
 
51
51
  ### `todo_read`
52
52
 
53
- Returns the current list as text + JSON. Prefer the overlay for at-a-glance status; avoid calling it in the same parallel batch as `todo_write`.
53
+ Returns the current list as text + JSON. Prefer the overlay for at-a-glance status; avoid calling it in the same parallel batch as `todo_write`.
54
+
55
+ ### `todo_diagnose`
56
+
57
+ 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.
54
58
 
55
59
  ## Overlay
56
60
 
@@ -66,7 +70,8 @@ Heading shows open + running counts + background-color progress bar, e.g. `# Tod
66
70
 
67
71
  Items within each status group are sorted by priority (high → medium → low).
68
72
  When space is tight, completed/cancelled items collapse into `+N done`.
69
- A blank line separates the heading from the first todo row for visual breathing room.
73
+ A blank line separates the heading from the first todo row for visual breathing room.
74
+ Successful `todo_write` results display `✓ Saved`, meaning the durable checkpoint was accepted before the in-memory snapshot was updated.
70
75
 
71
76
  ## Development
72
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nguyenquangthai/pi-todo",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
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",
@@ -41,7 +41,9 @@
41
41
  "prepublishOnly": "npm test && npm run typecheck"
42
42
  },
43
43
  "pi": {
44
- "extensions": ["./src/index.ts"],
44
+ "extensions": [
45
+ "./src/index.ts"
46
+ ],
45
47
  "image": "https://raw.githubusercontent.com/QuangThai/pi-todo/main/media/screenshot.png"
46
48
  },
47
49
  "peerDependencies": {
package/src/format.ts CHANGED
@@ -14,6 +14,18 @@ function prioritySort(a: TodoItem, b: TodoItem): number {
14
14
  return (PRIORITY_RANK[a.priority] ?? 2) - (PRIORITY_RANK[b.priority] ?? 2);
15
15
  }
16
16
 
17
+ /** Active work always leads the overlay, then pending, then terminal work. */
18
+ function statusPrioritySort(a: TodoItem, b: TodoItem): number {
19
+ const statusRank: Record<TodoStatus, number> = {
20
+ in_progress: 0,
21
+ pending: 1,
22
+ completed: 2,
23
+ cancelled: 2,
24
+ };
25
+ const diff = statusRank[a.status] - statusRank[b.status];
26
+ return diff !== 0 ? diff : prioritySort(a, b);
27
+ }
28
+
17
29
  export function getTodoMarker(status: TodoStatus): string {
18
30
  switch (status) {
19
31
  case "completed":
@@ -36,18 +48,7 @@ export function formatTodoListText(todos: readonly TodoItem[], summary: string):
36
48
  if (todos.length === 0) return summary;
37
49
 
38
50
  // in_progress first, then pending (sorted by priority), then terminal
39
- const sorted = [...todos].sort((a, b) => {
40
- const statusRank: Record<string, number> = {
41
- in_progress: 0,
42
- pending: 1,
43
- completed: 2,
44
- cancelled: 2,
45
- };
46
- const diff =
47
- (statusRank[a.status] ?? 2) - (statusRank[b.status] ?? 2);
48
- if (diff !== 0) return diff;
49
- return prioritySort(a, b);
50
- });
51
+ const sorted = [...todos].sort(statusPrioritySort);
51
52
 
52
53
  if (todos.length <= MAX_RESULT_LINES) {
53
54
  return [summary, ...sorted.map(formatPlainTodoLine)].join("\n");
@@ -99,7 +100,7 @@ export function selectOverlayLayout(
99
100
  const bodyBudget = Math.max(1, maxLines - 1);
100
101
  if (todos.length <= bodyBudget) {
101
102
  // All fit — show everything
102
- return { visible: [...todos].sort(prioritySort), hiddenCount: 0, terminalCount: 0 };
103
+ return { visible: [...todos].sort(statusPrioritySort), hiddenCount: 0, terminalCount: 0 };
103
104
  }
104
105
 
105
106
  const innerBudget = Math.max(1, bodyBudget - 1);
@@ -125,7 +126,9 @@ export function selectOverlayLayout(
125
126
 
126
127
  const terminalCollapsed = terminal.length > 0 && picked.length >= innerBudget;
127
128
  const remaining = todos.length - picked.length;
128
- const hiddenCount = terminalCollapsed ? remaining : Math.max(0, remaining - terminal.length);
129
+ const hiddenCount = terminalCollapsed
130
+ ? remaining - terminal.length
131
+ : Math.max(0, remaining - terminal.length);
129
132
 
130
133
  return { visible: picked, hiddenCount, terminalCount: terminalCollapsed ? terminal.length : 0 };
131
134
  }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * pi-todo — OpenCode-like session todo checklist for Pi.
3
3
  *
4
- * Tools: todo_write (full replace), todo_read
4
+ * Tools: todo_write (full replace), todo_read, todo_diagnose (read-only)
5
5
  * Overlay: # Todos with [ ]/[•]/[✓]/[×] above the editor
6
6
  * Persistence: toolResult details + custom entry, replayed from branch
7
7
  * Reminder: pi-tasks-style cadence → transient <system-reminder> via context
@@ -31,11 +31,13 @@ import {
31
31
  import { replayFromBranch } from "./replay.js";
32
32
  import { clearTodos, getTodos, setTodos } from "./store.js";
33
33
  import { registerTodoReadTool } from "./tools/todoread.js";
34
+ import { registerTodoDiagnoseTool } from "./tools/tododiagnose.js";
34
35
  import { registerTodoWriteTool } from "./tools/todowrite.js";
35
- import { TOOL_READ, TOOL_WRITE } from "./types.js";
36
+ import { registerTodoUpdateTool } from "./tools/todoupdate.js";
37
+ import { TOOL_DIAGNOSE, TOOL_READ, TOOL_UPDATE, TOOL_WRITE } from "./types.js";
36
38
  import { hasOpenTodos, isOpenTodo } from "./validate.js";
37
39
 
38
- const TODO_TOOL_NAMES = new Set([TOOL_WRITE, TOOL_READ]);
40
+ const TODO_TOOL_NAMES = new Set([TOOL_WRITE, TOOL_READ, TOOL_UPDATE, TOOL_DIAGNOSE]);
39
41
 
40
42
  const cadenceConfig: CadenceConfig = {
41
43
  reminderInterval: REMINDER_INTERVAL,
@@ -86,7 +88,9 @@ export default function (pi: ExtensionAPI): void {
86
88
  };
87
89
 
88
90
  registerTodoWriteTool(pi, { onCommit: refreshOverlay });
91
+ registerTodoUpdateTool(pi, { onCommit: refreshOverlay });
89
92
  registerTodoReadTool(pi);
93
+ registerTodoDiagnoseTool(pi);
90
94
 
91
95
  // Cold-start + completion nudges. Tool description alone is often ignored;
92
96
  // prompt-aware section + one-shot context reminder (pi-tasks style) is stronger.
package/src/prompt.ts CHANGED
@@ -44,11 +44,14 @@ Do **not** skip just because the request is "explain" or "review" — if the ans
44
44
  export const TODOREAD_DESCRIPTION =
45
45
  "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).";
46
46
 
47
+ export const TODODIAGNOSE_DESCRIPTION =
48
+ "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.";
49
+
47
50
  export const TODOWRITE_GUIDELINES = [
48
51
  "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.",
49
52
  "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.",
50
53
  "When finishing a step or when the user confirms done, call todo_write in that same turn to mark completed and advance the next pending item if work continues.",
51
- "Do not invent TaskCreate/TaskUpdate tools — use todo_write/todo_read only.",
54
+ "Do not invent TaskCreate/TaskUpdate tools — use todo_write/todo_read; todo_diagnose is read-only troubleshooting.",
52
55
  "Do not call todo_write and todo_read in the same parallel tool batch.",
53
56
  ];
54
57
 
package/src/schema.ts CHANGED
@@ -2,7 +2,8 @@ import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import { Type, type Static } from "typebox";
3
3
  import { TODO_PRIORITIES, TODO_STATUSES } from "./types.js";
4
4
 
5
- export const TodoItemSchema = Type.Object({
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
7
  content: Type.String({ description: "Brief description of the task" }),
7
8
  status: StringEnum([...TODO_STATUSES], {
8
9
  description: "pending | in_progress | completed | cancelled",
@@ -18,7 +19,22 @@ export const TodoWriteParams = Type.Object({
18
19
  }),
19
20
  });
20
21
 
21
- export const TodoReadParams = Type.Object({});
22
+ export const TodoReadParams = Type.Object({});
23
+
24
+ /** Read-only persistence check; deliberately accepts no mutation input. */
25
+ export const TodoDiagnoseParams = Type.Object({});
26
+
27
+ export const TodoUpdateParams = Type.Object({
28
+ updates: Type.Array(
29
+ Type.Object({
30
+ id: Type.String({ minLength: 1 }),
31
+ content: Type.Optional(Type.String()),
32
+ status: Type.Optional(StringEnum([...TODO_STATUSES])),
33
+ priority: Type.Optional(StringEnum([...TODO_PRIORITIES])),
34
+ }),
35
+ { minItems: 1, description: "Patch existing todos by stable ID" },
36
+ ),
37
+ });
22
38
 
23
39
  export type TodoWriteInput = Static<typeof TodoWriteParams>;
24
40
  export type TodoItemInput = Static<typeof TodoItemSchema>;
@@ -0,0 +1,55 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { TODODIAGNOSE_DESCRIPTION } from "../prompt.js";
4
+ import { replayFromBranch } from "../replay.js";
5
+ import { TodoDiagnoseParams } from "../schema.js";
6
+ import { getTodos, withStoreLock } from "../store.js";
7
+ import type { TodoItem } from "../types.js";
8
+ import { TOOL_DIAGNOSE } from "../types.js";
9
+ import { todosEqual } from "../validate.js";
10
+
11
+ interface TodoDiagnoseDetails {
12
+ status: "consistent" | "mismatch";
13
+ storeTodos: TodoItem[];
14
+ replayedTodos: TodoItem[];
15
+ }
16
+
17
+ /** Registers a read-only comparison between live state and durable branch replay. */
18
+ export function registerTodoDiagnoseTool(pi: ExtensionAPI): void {
19
+ pi.registerTool({
20
+ name: TOOL_DIAGNOSE,
21
+ label: "Todo Diagnose",
22
+ description: TODODIAGNOSE_DESCRIPTION,
23
+ promptSnippet: "Diagnose todo persistence without changing todos",
24
+ parameters: TodoDiagnoseParams,
25
+
26
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
27
+ return withStoreLock(() => {
28
+ const storeTodos = getTodos();
29
+ 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.";
36
+
37
+ return {
38
+ content: [{ type: "text", text: `${text}\n\n${JSON.stringify(details, null, 2)}` }],
39
+ details,
40
+ };
41
+ });
42
+ },
43
+
44
+ renderCall(_args, theme) {
45
+ return new Text(theme.fg("toolTitle", theme.bold("todo_diagnose")), 0, 0);
46
+ },
47
+
48
+ renderResult(result, _opts, theme) {
49
+ const details = result.details as TodoDiagnoseDetails | undefined;
50
+ const text = details?.status === "consistent" ? "✓ Consistent" : "! Mismatch";
51
+ const color = details?.status === "consistent" ? "success" : "warning";
52
+ return new Text(theme.fg(color, text), 0, 0);
53
+ },
54
+ });
55
+ }
@@ -0,0 +1,43 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { TodoUpdateParams } from "../schema.js";
4
+ import { getTodos, setTodos, withStoreLock } from "../store.js";
5
+ import type { TodoWriteDetails } from "../types.js";
6
+ import { TODO_STATE_ENTRY_TYPE, TOOL_UPDATE } from "../types.js";
7
+ import { ensureTodoIds, validateTodoUpdate } from "../validate.js";
8
+
9
+ export function registerTodoUpdateTool(pi: ExtensionAPI, options: { onCommit?: () => void }): void {
10
+ pi.registerTool({
11
+ name: TOOL_UPDATE,
12
+ label: "Todo Update",
13
+ description: "Patch existing todos by stable ID. Use todo_read first to obtain IDs. This never deletes items.",
14
+ promptSnippet: "Patch one or more existing todos by ID without replacing the full list",
15
+ parameters: TodoUpdateParams,
16
+ async execute(_toolCallId, params) {
17
+ return withStoreLock(() => {
18
+ const current = getTodos();
19
+ const result = validateTodoUpdate(params.updates, current);
20
+ if (!result.ok) return { content: [{ type: "text", text: `Error: ${result.error}` }], details: { todos: current, error: result.error } as TodoWriteDetails };
21
+ const todos = ensureTodoIds(result.todos, current);
22
+ if (!result.unchanged) {
23
+ try { pi.appendEntry(TODO_STATE_ENTRY_TYPE, { todos }); }
24
+ catch (e) {
25
+ if (/stale after session replacement/i.test(String(e))) {
26
+ return { content: [{ type: "text", text: "Error: session was replaced — state not committed. Please retry todo_update." }], details: { todos: current, error: "stale session replacement" } as TodoWriteDetails };
27
+ }
28
+ throw e;
29
+ }
30
+ }
31
+ setTodos(todos);
32
+ options.onCommit?.();
33
+ return { content: [{ type: "text", text: result.unchanged ? "No change" : `Updated ${params.updates.length} todo(s)` }], details: { todos, ...(result.unchanged ? { unchanged: true } : {}) } as TodoWriteDetails };
34
+ });
35
+ },
36
+ renderCall(args, theme) { return new Text(theme.fg("toolTitle", theme.bold("todo_update ")) + theme.fg("accent", `${Array.isArray(args.updates) ? args.updates.length : 0} patch(es)`), 0, 0); },
37
+ renderResult(result, _opts, theme) {
38
+ const details = result.details as TodoWriteDetails | undefined;
39
+ if (details?.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
40
+ return new Text(theme.fg("success", details?.unchanged ? "No change" : "✓ Saved"), 0, 0);
41
+ },
42
+ });
43
+ }
@@ -6,7 +6,7 @@ import { TodoWriteParams } from "../schema.js";
6
6
  import { getTodos, setTodos, withStoreLock } from "../store.js";
7
7
  import type { TodoWriteDetails } from "../types.js";
8
8
  import { TODO_STATE_ENTRY_TYPE, TOOL_WRITE } from "../types.js";
9
- import { countOpenTodos, validateTodoWrite } from "../validate.js";
9
+ import { countOpenTodos, ensureTodoIds, todosEqual, validateTodoWrite } from "../validate.js";
10
10
 
11
11
  export function registerTodoWriteTool(
12
12
  pi: ExtensionAPI,
@@ -33,37 +33,48 @@ export function registerTodoWriteTool(
33
33
  };
34
34
  }
35
35
 
36
- setTodos(result.todos);
37
-
38
- // Durable custom entry (not sent to LLM) — survives compaction better than tool details alone.
39
- // Skip no-op rewrites to avoid session noise.
40
- if (!result.unchanged) {
36
+ const todos = result.unchanged ? result.todos : ensureTodoIds(result.todos, current);
37
+ const unchanged = result.unchanged || todosEqual(todos, current);
38
+ // 1. Persist durable state BEFORE updating in-memory store.
39
+ // If appendEntry fails (stale ctx, persistence error), we abort
40
+ // so in-memory store never diverges from durable state.
41
+ if (!unchanged) {
41
42
  try {
42
- pi.appendEntry(TODO_STATE_ENTRY_TYPE, { todos: result.todos });
43
+ pi.appendEntry(TODO_STATE_ENTRY_TYPE, { todos });
43
44
  } catch (e) {
44
- // Only swallow host-reject errors (stale context, unsupported custom entry).
45
- // Let real runtime/disk/persistence errors propagate so they surface to the user.
45
+ // Stale session: discard this write entirely returning error
46
+ // so the LLM knows the state was not committed.
46
47
  if (/stale after session replacement/i.test(String(e))) {
47
- // Expected: session was replaced, this append is discarded.
48
- // toolResult details still provide branch replay.
49
- } else {
50
- throw e;
48
+ return {
49
+ content: [
50
+ {
51
+ type: "text",
52
+ text: "Error: session was replaced — state not committed. Please retry todo_write.",
53
+ },
54
+ ],
55
+ details: { todos: current, error: "stale session replacement" } satisfies TodoWriteDetails,
56
+ };
51
57
  }
58
+ // Real persistence/disk/runtime error: propagate.
59
+ throw e;
52
60
  }
53
61
  }
54
62
 
63
+ // 2. Durable write succeeded (or no-op) — now update in-memory store.
64
+ setTodos(todos);
65
+
55
66
  options.onCommit?.();
56
67
 
57
- const open = countOpenTodos(result.todos);
58
- const summary = result.unchanged
68
+ const open = countOpenTodos(todos);
69
+ const summary = unchanged
59
70
  ? "No change"
60
- : `${open} open / ${result.todos.length} total`;
71
+ : `${open} open / ${todos.length} total`;
61
72
  const body =
62
- result.todos.length === 0 ? "Cleared todos" : formatTodoListText(result.todos, summary);
73
+ todos.length === 0 ? "Cleared todos" : formatTodoListText(todos, summary);
63
74
 
64
75
  const details: TodoWriteDetails = {
65
- todos: result.todos,
66
- ...(result.unchanged ? { unchanged: true } : {}),
76
+ todos,
77
+ ...(unchanged ? { unchanged: true } : {}),
67
78
  };
68
79
 
69
80
  return {
@@ -94,7 +105,11 @@ export function registerTodoWriteTool(
94
105
  if (!details?.error && !details?.unchanged) {
95
106
  const open = details?.todos ? details.todos.filter((t: any) => t.status === "pending" || t.status === "in_progress").length : 0;
96
107
  const total = details?.todos?.length ?? 0;
97
- return new Text(theme.fg("success", "✓ ") + theme.fg("muted", `${open} open / ${total} total`), 0, 0);
108
+ return new Text(
109
+ theme.fg("success", "✓ Saved") + theme.fg("muted", ` · ${open} open / ${total} total`),
110
+ 0,
111
+ 0,
112
+ );
98
113
  }
99
114
  const msg = text?.type === "text" ? text.text.split("\n")[0] ?? "Updated" : "Updated";
100
115
  return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
package/src/types.ts CHANGED
@@ -7,6 +7,8 @@ export type TodoPriority = (typeof TODO_PRIORITIES)[number];
7
7
  export const TERMINAL_STATUSES: ReadonlySet<TodoStatus> = new Set(["completed", "cancelled"]);
8
8
 
9
9
  export interface TodoItem {
10
+ /** Stable identity; absent only in legacy session entries until their next mutation. */
11
+ id?: string;
10
12
  content: string;
11
13
  status: TodoStatus;
12
14
  priority: TodoPriority;
@@ -21,6 +23,8 @@ export interface TodoWriteDetails {
21
23
  export const TODO_STATE_ENTRY_TYPE = "pi-todo.state";
22
24
  export const TOOL_WRITE = "todo_write";
23
25
  export const TOOL_READ = "todo_read";
26
+ export const TOOL_DIAGNOSE = "todo_diagnose";
27
+ export const TOOL_UPDATE = "todo_update";
24
28
  export const WIDGET_KEY = "pi-todo";
25
29
  export const MAX_OVERLAY_LINES = 12;
26
30
  /** Max characters per todo content after sanitize (context/tool safety). */
package/src/validate.ts CHANGED
@@ -1,105 +1,201 @@
1
- import { sanitizeTodoText } from "./sanitize.js";
2
- import type { TodoItem, TodoPriority, TodoStatus } from "./types.js";
3
- import { MAX_CONTENT_LENGTH, TODO_PRIORITIES, TODO_STATUSES } from "./types.js";
4
-
5
- export type ValidateOk = { ok: true; todos: TodoItem[]; unchanged: boolean };
6
- export type ValidateErr = { ok: false; error: string };
7
- export type ValidateResult = ValidateOk | ValidateErr;
8
-
9
- function isStatus(value: unknown): value is TodoStatus {
10
- return typeof value === "string" && (TODO_STATUSES as readonly string[]).includes(value);
11
- }
12
-
13
- function isPriority(value: unknown): value is TodoPriority {
14
- return typeof value === "string" && (TODO_PRIORITIES as readonly string[]).includes(value);
15
- }
16
-
17
- export function todosEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean {
18
- if (a.length !== b.length) return false;
19
- return a.every(
20
- (item, i) =>
21
- item.content === b[i].content && item.status === b[i].status && item.priority === b[i].priority,
22
- );
23
- }
24
-
25
- /**
26
- * Validate and normalize a full-replace payload.
27
- * Hard-enforces at most one `in_progress`. Does not mutate `current`.
28
- */
29
- export function validateTodoWrite(
30
- rawTodos: unknown,
31
- current: readonly TodoItem[],
32
- ): ValidateResult {
33
- if (!Array.isArray(rawTodos)) {
34
- return { ok: false, error: "todos must be an array" };
35
- }
36
-
37
- const todos: TodoItem[] = [];
38
- let inProgressCount = 0;
39
-
40
- for (let i = 0; i < rawTodos.length; i++) {
41
- const item = rawTodos[i];
42
- if (!item || typeof item !== "object") {
43
- return { ok: false, error: `todos[${i}] must be an object` };
44
- }
45
- const rec = item as Record<string, unknown>;
46
-
47
- if (typeof rec.content !== "string") {
48
- return { ok: false, error: `todos[${i}].content must be a string` };
49
- }
50
- let content = sanitizeTodoText(rec.content);
51
- if (!content) {
52
- return { ok: false, error: `todos[${i}].content must be non-empty` };
53
- }
54
- if (content.length > MAX_CONTENT_LENGTH) {
55
- content = `${content.slice(0, MAX_CONTENT_LENGTH - 1)}…`;
56
- }
57
-
58
- if (!isStatus(rec.status)) {
59
- return {
60
- ok: false,
61
- error: `todos[${i}].status must be one of: ${TODO_STATUSES.join(", ")}`,
62
- };
63
- }
64
- if (!isPriority(rec.priority)) {
65
- return {
66
- ok: false,
67
- error: `todos[${i}].priority must be one of: ${TODO_PRIORITIES.join(", ")}`,
68
- };
69
- }
70
-
71
- if (rec.status === "in_progress") inProgressCount += 1;
72
-
73
- todos.push({ content, status: rec.status, priority: rec.priority });
74
- }
75
-
76
- if (inProgressCount > 1) {
77
- return {
78
- ok: false,
79
- error: `exactly one in_progress allowed (got ${inProgressCount}); keep only the active task in_progress`,
80
- };
81
- }
82
-
83
- return { ok: true, todos, unchanged: todosEqual(todos, current) };
84
- }
85
-
86
- export function isTerminalStatus(status: TodoStatus): boolean {
87
- return status === "completed" || status === "cancelled";
88
- }
89
-
90
- export function isOpenTodo(todo: TodoItem): boolean {
91
- return !isTerminalStatus(todo.status);
92
- }
93
-
94
- export function hasOpenTodos(todos: readonly TodoItem[]): boolean {
95
- return todos.some(isOpenTodo);
96
- }
97
-
98
- export function countOpenTodos(todos: readonly TodoItem[]): number {
99
- return todos.filter(isOpenTodo).length;
100
- }
101
-
102
- /** Count of `in_progress` items (shown as "running" in the overlay heading). */
103
- export function countRunningTodos(todos: readonly TodoItem[]): number {
104
- return todos.filter((t) => t.status === "in_progress").length;
105
- }
1
+ import { sanitizeTodoText } from "./sanitize.js";
2
+ import { randomUUID } from "node:crypto";
3
+ import type { TodoItem, TodoPriority, TodoStatus } from "./types.js";
4
+ import { MAX_CONTENT_LENGTH, TODO_PRIORITIES, TODO_STATUSES } from "./types.js";
5
+
6
+ export type ValidateOk = { ok: true; todos: TodoItem[]; unchanged: boolean };
7
+ export type ValidateErr = { ok: false; error: string };
8
+ export type ValidateResult = ValidateOk | ValidateErr;
9
+
10
+ function isStatus(value: unknown): value is TodoStatus {
11
+ return typeof value === "string" && (TODO_STATUSES as readonly string[]).includes(value);
12
+ }
13
+
14
+ function isPriority(value: unknown): value is TodoPriority {
15
+ return typeof value === "string" && (TODO_PRIORITIES as readonly string[]).includes(value);
16
+ }
17
+
18
+ export function todosEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean {
19
+ if (a.length !== b.length) return false;
20
+ return a.every(
21
+ (item, i) =>
22
+ item.id === b[i].id && item.content === b[i].content && item.status === b[i].status && item.priority === b[i].priority,
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Validate and normalize a full-replace payload.
28
+ * Hard-enforces at most one `in_progress`. Does not mutate `current`.
29
+ */
30
+ export function validateTodoWrite(
31
+ rawTodos: unknown,
32
+ current: readonly TodoItem[],
33
+ ): ValidateResult {
34
+ if (!Array.isArray(rawTodos)) {
35
+ return { ok: false, error: "todos must be an array" };
36
+ }
37
+
38
+ const todos: TodoItem[] = [];
39
+ let inProgressCount = 0;
40
+ const seenIds = new Set<string>();
41
+
42
+ for (let i = 0; i < rawTodos.length; i++) {
43
+ const item = rawTodos[i];
44
+ if (!item || typeof item !== "object") {
45
+ return { ok: false, error: `todos[${i}] must be an object` };
46
+ }
47
+ const rec = item as Record<string, unknown>;
48
+
49
+ if (typeof rec.content !== "string") {
50
+ return { ok: false, error: `todos[${i}].content must be a string` };
51
+ }
52
+ let content = sanitizeTodoText(rec.content);
53
+ if (!content) {
54
+ return { ok: false, error: `todos[${i}].content must be non-empty` };
55
+ }
56
+ if (content.length > MAX_CONTENT_LENGTH) {
57
+ content = `${content.slice(0, MAX_CONTENT_LENGTH - 1)}…`;
58
+ }
59
+
60
+ if (!isStatus(rec.status)) {
61
+ return {
62
+ ok: false,
63
+ error: `todos[${i}].status must be one of: ${TODO_STATUSES.join(", ")}`,
64
+ };
65
+ }
66
+ if (!isPriority(rec.priority)) {
67
+ return {
68
+ ok: false,
69
+ error: `todos[${i}].priority must be one of: ${TODO_PRIORITIES.join(", ")}`,
70
+ };
71
+ }
72
+
73
+ if (rec.status === "in_progress") inProgressCount += 1;
74
+
75
+ if (rec.id !== undefined) {
76
+ if (typeof rec.id !== "string" || !rec.id.trim()) {
77
+ return { ok: false, error: `todos[${i}].id must be a non-empty string when provided` };
78
+ }
79
+ if (seenIds.has(rec.id)) {
80
+ return { ok: false, error: `todos[${i}].id "${rec.id}" is duplicated` };
81
+ }
82
+ seenIds.add(rec.id);
83
+ }
84
+ todos.push({ ...(typeof rec.id === "string" ? { id: rec.id } : {}), content, status: rec.status, priority: rec.priority });
85
+ }
86
+
87
+ if (inProgressCount > 1) {
88
+ return {
89
+ ok: false,
90
+ error: `exactly one in_progress allowed (got ${inProgressCount}); keep only the active task in_progress`,
91
+ };
92
+ }
93
+
94
+ // Reject explicit IDs that don't exist in current list
95
+ // (caller must omit `id` for new items so the system auto-assigns).
96
+ const currentIds = new Set(current.map((t) => t.id).filter(Boolean));
97
+ for (let i = 0; i < todos.length; i++) {
98
+ if (todos[i].id && !currentIds.has(todos[i].id)) {
99
+ return {
100
+ ok: false,
101
+ error: `todos[${i}].id "${todos[i].id}" does not match any existing todo; omit id for new items`,
102
+ };
103
+ }
104
+ }
105
+
106
+ return { ok: true, todos, unchanged: todosEqual(todos, current) };
107
+ }
108
+
109
+ /** Assign IDs once at mutation time.
110
+ *
111
+ * - Items with an explicit `id` keep it (must already exist in `current`, verified
112
+ * by `validateTodoWrite`).
113
+ * - Items without `id` try to match a prior item: first by full tuple
114
+ * (content+status+priority), then by unique content. If ambiguous (multiple
115
+ * same-content items) or no prior match, a new UUID is generated.
116
+ */
117
+ export function ensureTodoIds(todos: readonly TodoItem[], current: readonly TodoItem[]): TodoItem[] {
118
+ const claimed = new Set<string>();
119
+
120
+ // Pre-index content uniqueness: content that appears only once in `current`
121
+ // is safe for content-only fallback.
122
+ const contentCounts = new Map<string, number>();
123
+ for (const c of current) {
124
+ contentCounts.set(c.content, (contentCounts.get(c.content) ?? 0) + 1);
125
+ }
126
+
127
+ return todos.map((todo) => {
128
+ let id = todo.id;
129
+ if (!id) {
130
+ // 1. Exact tuple match (content + status + priority)
131
+ const byTuple = current.find(
132
+ (c) => !!c.id && !claimed.has(c.id) &&
133
+ c.content === todo.content &&
134
+ c.status === todo.status &&
135
+ c.priority === todo.priority,
136
+ );
137
+ if (byTuple) {
138
+ id = byTuple.id;
139
+ } else {
140
+ // 2. Content-only fallback — only when content is unique in current
141
+ const count = contentCounts.get(todo.content) ?? 0;
142
+ if (count === 1) {
143
+ const byContent = current.find(
144
+ (c) => !!c.id && !claimed.has(c.id) && c.content === todo.content,
145
+ );
146
+ if (byContent) id = byContent.id;
147
+ }
148
+ }
149
+ // 3. No match or ambiguous → fresh ID
150
+ if (!id) id = randomUUID();
151
+ }
152
+ claimed.add(id);
153
+ return { ...todo, id };
154
+ });
155
+ }
156
+
157
+ export function validateTodoUpdate(rawUpdates: unknown, current: readonly TodoItem[]): ValidateResult {
158
+ if (!Array.isArray(rawUpdates) || rawUpdates.length === 0) {
159
+ return { ok: false, error: "updates must be a non-empty array" };
160
+ }
161
+ const next = current.map((todo) => ({ ...todo }));
162
+ const seen = new Set<string>();
163
+ for (let i = 0; i < rawUpdates.length; i++) {
164
+ const update = rawUpdates[i];
165
+ if (!update || typeof update !== "object") return { ok: false, error: `updates[${i}] must be an object` };
166
+ const rec = update as Record<string, unknown>;
167
+ if (typeof rec.id !== "string" || !rec.id) return { ok: false, error: `updates[${i}].id must be a non-empty string` };
168
+ if (seen.has(rec.id)) return { ok: false, error: `updates[${i}].id is duplicated` };
169
+ seen.add(rec.id);
170
+ const target = next.find((todo) => todo.id === rec.id);
171
+ if (!target) return { ok: false, error: `updates[${i}].id does not match an existing todo` };
172
+ if (rec.content === undefined && rec.status === undefined && rec.priority === undefined) {
173
+ return { ok: false, error: `updates[${i}] must change content, status, or priority` };
174
+ }
175
+ if (rec.content !== undefined) target.content = rec.content as string;
176
+ if (rec.status !== undefined) target.status = rec.status as TodoStatus;
177
+ if (rec.priority !== undefined) target.priority = rec.priority as TodoPriority;
178
+ }
179
+ return validateTodoWrite(next, current);
180
+ }
181
+
182
+ export function isTerminalStatus(status: TodoStatus): boolean {
183
+ return status === "completed" || status === "cancelled";
184
+ }
185
+
186
+ export function isOpenTodo(todo: TodoItem): boolean {
187
+ return !isTerminalStatus(todo.status);
188
+ }
189
+
190
+ export function hasOpenTodos(todos: readonly TodoItem[]): boolean {
191
+ return todos.some(isOpenTodo);
192
+ }
193
+
194
+ export function countOpenTodos(todos: readonly TodoItem[]): number {
195
+ return todos.filter(isOpenTodo).length;
196
+ }
197
+
198
+ /** Count of `in_progress` items (shown as "running" in the overlay heading). */
199
+ export function countRunningTodos(todos: readonly TodoItem[]): number {
200
+ return todos.filter((t) => t.status === "in_progress").length;
201
+ }