@pi-archimedes/todo 2.2.0 → 2.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.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Compatibility shim for LLM tool-call arguments.
3
+ *
4
+ * Wires into the tool via `prepareArguments`, which pi runs BEFORE schema
5
+ * validation (and before `execute`). The model occasionally sends
6
+ * malformed-but-recoverable `manage_todo_list` arguments; observed in
7
+ * practice (frozen open-weight models, ADR 0009):
8
+ *
9
+ * - `todoList` is a stringified JSON array instead of an array (sometimes
10
+ * with mangled escaping, e.g. `"id": 1"` — an extra quote after the
11
+ * number)
12
+ * - items put the task text under `title`/`step` (shapes borrowed from
13
+ * other harnesses) instead of the canonical `content`
14
+ * - `id` is invented, omitted, or nulled — the canonical schema has NO
15
+ * `id`; display numbering is array position, so it is stripped, not
16
+ * repaired
17
+ * - statuses come out dashed (a hyphenated variant of a canonical
18
+ * value that then collapses to a no-separator form), or as plain
19
+ * english synonyms ("doing", "done")
20
+ * - items are plain strings
21
+ * - stray top-level / per-item keys outside the schema
22
+ *
23
+ * Only *recoverable* deviations are repaired. Anything unrecoverable is
24
+ * passed through untouched so the standard validation error still surfaces
25
+ * with the real complaint. The public schema stays strict; this module is
26
+ * purely a repair layer.
27
+ */
28
+
29
+ import type { ManageTodoListInput } from "./tool.js";
30
+ import type { TodoItem, TodoStatus } from "./types.js";
31
+
32
+ type Record_ = Record<string, unknown>;
33
+
34
+ const VALID_STATUSES: ReadonlySet<string> = new Set<string>([
35
+ "pending",
36
+ "in_progress",
37
+ "completed",
38
+ ]);
39
+
40
+ /**
41
+ * LLM synonyms for the three enum values, keyed by the collapsed form
42
+ * (lowercase, all whitespace/underscore/dash separators removed) so dashed,
43
+ * spaced, and underscored variants all land on the same key.
44
+ */
45
+ const STATUS_ALIASES: ReadonlyMap<string, TodoStatus> = new Map<string, TodoStatus>([
46
+ ["done", "completed"],
47
+ ["complete", "completed"],
48
+ ["finished", "completed"],
49
+ ["closed", "completed"],
50
+ ["success", "completed"],
51
+ ["passed", "completed"],
52
+ ["doing", "in_progress"],
53
+ ["started", "in_progress"],
54
+ ["working", "in_progress"],
55
+ ["active", "in_progress"],
56
+ ["wip", "in_progress"],
57
+ ["ongoing", "in_progress"],
58
+ ["inprogress", "in_progress"],
59
+ ["pending", "pending"],
60
+ ["todo", "pending"],
61
+ ["planned", "pending"],
62
+ ["untouched", "pending"],
63
+ ["notstarted", "pending"],
64
+ ["unstarted", "pending"],
65
+ ["open", "pending"],
66
+ ]);
67
+
68
+ /**
69
+ * Keys the model sometimes uses instead of `content`. Checked only when
70
+ * `content` itself is missing/empty, so a model that DID send `content` is
71
+ * never overwritten.
72
+ */
73
+ const CONTENT_FALLBACK_KEYS = ["title", "step", "task", "text", "name", "label", "activeForm"] as const;
74
+ const DESCRIPTION_FALLBACK_KEYS = ["details", "notes", "summary", "context"] as const;
75
+
76
+ /** Reserved keys the last-resort text scan must never pick up. */
77
+ const LAST_RESORT_SKIP_KEYS = new Set<string>([
78
+ "content",
79
+ ...(CONTENT_FALLBACK_KEYS as readonly string[]),
80
+ "description",
81
+ ...(DESCRIPTION_FALLBACK_KEYS as readonly string[]),
82
+ "status",
83
+ "id",
84
+ ]);
85
+
86
+ const DERIVED_CONTENT_MAX = 60;
87
+
88
+ function isRecord(value: unknown): value is Record_ {
89
+ return typeof value === "object" && value !== null && !Array.isArray(value);
90
+ }
91
+
92
+ function firstStringOf(record: Record_, keys: readonly string[]): string {
93
+ for (const key of keys) {
94
+ const value = record[key];
95
+ if (typeof value === "string" && value.trim() !== "") return value;
96
+ }
97
+ return "";
98
+ }
99
+
100
+ /**
101
+ * Collapse whitespace and cut at a word boundary so a derived `content`
102
+ * stays a short, human-readable label in the widget.
103
+ */
104
+ export function deriveTitleFromDescription(description: string): string {
105
+ const flat = description.replace(/\s+/g, " ").trim();
106
+ if (flat.length <= DERIVED_CONTENT_MAX) return flat;
107
+ const cut = flat.slice(0, DERIVED_CONTENT_MAX);
108
+ const lastSpace = cut.lastIndexOf(" ");
109
+ const head = lastSpace > 0 ? cut.slice(0, lastSpace) : cut;
110
+ return `${head.trimEnd()}…`;
111
+ }
112
+
113
+ /**
114
+ * Normalize a raw status to the canonical enum. Accepts dashed/spaced/
115
+ * underscored variants and plain-english synonyms; anything unrecognized
116
+ * (incl. non-strings) falls back to `"pending"`. Kept permissive — this is
117
+ * the REPAIR layer. `state-manager.validate()` stays canonical-strict.
118
+ */
119
+ export function normalizeStatus(raw: unknown): TodoStatus {
120
+ if (typeof raw !== "string") {
121
+ // Missing, null, number, … — no signal about intent.
122
+ return "pending";
123
+ }
124
+ const s = raw.trim().toLowerCase();
125
+ if (VALID_STATUSES.has(s)) return s as TodoStatus;
126
+ const collapsed = s.replace(/[\s_-]+/g, "");
127
+ return STATUS_ALIASES.get(collapsed) ?? "pending";
128
+ }
129
+
130
+ /**
131
+ * Normalize one todo item. Returns a fresh object containing ONLY the
132
+ * canonical keys `{content, description?, status}` — `id` is always dropped,
133
+ * extra keys are dropped (the schema rejects them with "must not have
134
+ * additional properties"). Unrecoverable items are returned as-is so the
135
+ * schema error keeps its meaning.
136
+ */
137
+ export function normalizeTodoItem(raw: unknown, index: number): unknown {
138
+ // Laziest form: ["write tests", "deploy"]
139
+ if (typeof raw === "string") {
140
+ const text = raw.trim();
141
+ if (text === "") return raw;
142
+ return { content: deriveTitleFromDescription(text), status: "pending" as TodoStatus };
143
+ }
144
+
145
+ if (!isRecord(raw)) return raw; // null / number / nested array → schema error
146
+
147
+ const description =
148
+ (typeof raw.description === "string" ? raw.description.trim() : "") ||
149
+ firstStringOf(raw, DESCRIPTION_FALLBACK_KEYS);
150
+ let content =
151
+ (typeof raw.content === "string" ? raw.content.trim() : "") ||
152
+ firstStringOf(raw, CONTENT_FALLBACK_KEYS);
153
+ if (content === "" && description !== "") {
154
+ content = deriveTitleFromDescription(description);
155
+ }
156
+ // Last resort: any remaining non-empty string field that isn't a known
157
+ // alias/status/id. This alone is the catch-all.
158
+ if (content === "") {
159
+ for (const [key, value] of Object.entries(raw)) {
160
+ if (!LAST_RESORT_SKIP_KEYS.has(key) && typeof value === "string" && value.trim() !== "") {
161
+ content = value;
162
+ break;
163
+ }
164
+ }
165
+ }
166
+
167
+ // If both are empty this returns { content: "", status } — deliberately
168
+ // keeps an empty (valid-string) field so state-manager.validate() reports
169
+ // the missing `content` instead of the schema masking it.
170
+ return {
171
+ content,
172
+ ...(description !== "" ? { description } : {}),
173
+ status: normalizeStatus(raw.status),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Normalize a raw todo list (e.g. from bus payloads) into canonical
179
+ * TodoItems. Returns [] for an empty input array, undefined for anything
180
+ * unrecoverable. Used by the bus consumer (index.ts) and the subagent
181
+ * stream — where prepareArguments is NOT in the call path.
182
+ */
183
+ export function normalizeTodoItems(raw: unknown): TodoItem[] | undefined {
184
+ let list: unknown[] | undefined;
185
+ if (Array.isArray(raw)) {
186
+ list = raw;
187
+ } else if (raw != null && looksLikeTodoItem(raw)) {
188
+ list = [raw];
189
+ } else {
190
+ return undefined;
191
+ }
192
+
193
+ const items = list.map((item, i) => normalizeTodoItem(item, i));
194
+ const surviving = items.filter(
195
+ (item): item is TodoItem =>
196
+ isRecord(item) &&
197
+ typeof item.content === "string" &&
198
+ item.content.trim() !== "" &&
199
+ typeof item.status === "string" &&
200
+ VALID_STATUSES.has(item.status),
201
+ );
202
+ if (items.length === 0) return [];
203
+ if (surviving.length === 0) return undefined;
204
+ return surviving;
205
+ }
206
+
207
+ /**
208
+ * Repair the occasional mangled JSON: `"id": 1"` (an extra quote after the
209
+ * number). The replacement is safe to apply blindly — that shape is invalid
210
+ * JSON to begin with.
211
+ */
212
+ // The long backslash sequence is spelled out as char codes for robustness.
213
+ const ID_FIX_PATTERN = String.fromCharCode(40, 34, 105, 100, 34, 92, 115, 42, 58, 92, 115, 42, 92, 100, 43, 41, 92, 34);
214
+ const ID_QUOTE_FIX = new RegExp(ID_FIX_PATTERN, "g");
215
+ function repairStringifiedJson(s: string): string {
216
+ return s.replace(ID_QUOTE_FIX, "$1");
217
+ }
218
+
219
+ function parseStringifiedList(raw: string): unknown {
220
+ try {
221
+ return JSON.parse(repairStringifiedJson(raw));
222
+ } catch {
223
+ return raw; // Unparseable — keep the string; schema error says "must be array"
224
+ }
225
+ }
226
+
227
+ function looksLikeTodoItem(value: unknown): boolean {
228
+ return (
229
+ isRecord(value) &&
230
+ ("content" in value ||
231
+ "title" in value ||
232
+ "step" in value ||
233
+ "description" in value ||
234
+ "status" in value)
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Normalize the `todoList` argument. `kept` is false only when the field was
240
+ * absent and must not appear in the output object at all.
241
+ */
242
+ function normalizeTodoList(raw: unknown): { value: unknown; kept: boolean } {
243
+ let value: unknown = raw;
244
+ if (typeof raw === "string") value = parseStringifiedList(raw);
245
+
246
+ if (value === null) {
247
+ // Required-schema fields reject null; dropping the key yields the clearer
248
+ // "todoList is required for write operation" from execute().
249
+ return { value: undefined, kept: true };
250
+ }
251
+
252
+ if (value === undefined) return { value, kept: false };
253
+
254
+ if (Array.isArray(value)) {
255
+ return { value: value.map((item, i) => normalizeTodoItem(item, i)), kept: true };
256
+ }
257
+
258
+ // A single bare item instead of an array.
259
+ if (looksLikeTodoItem(value)) {
260
+ return { value: [normalizeTodoItem(value, 0)], kept: true };
261
+ }
262
+
263
+ return { value, kept: true }; // Unmendable — original validation error stands
264
+ }
265
+
266
+ function normalizeOperation(raw: unknown, listPresent: boolean): "write" | "read" {
267
+ if (raw === "write" || raw === "read") return raw;
268
+ if (typeof raw === "string") {
269
+ const lowered = raw.trim().toLowerCase();
270
+ if (lowered === "write" || lowered === "read") return lowered;
271
+ }
272
+ // Model omitted/garbled the operation: a list implies a write.
273
+ return listPresent ? "write" : "read";
274
+ }
275
+
276
+ /**
277
+ * Repair raw `manage_todo_list` call arguments before schema validation.
278
+ * Never throws, never mutates its input, and passes through anything it
279
+ * cannot recover.
280
+ */
281
+ export function prepareTodoArguments(args: unknown): ManageTodoListInput {
282
+ if (!isRecord(args)) return args as ManageTodoListInput;
283
+
284
+ const normalized =
285
+ args.todoList === undefined ? undefined : normalizeTodoList(args.todoList);
286
+ const present =
287
+ normalized !== undefined && normalized.kept && normalized.value !== undefined;
288
+
289
+ const out: Record_ = {
290
+ operation: normalizeOperation(args.operation, present),
291
+ };
292
+ if (present) {
293
+ out.todoList = (normalized as { value: unknown }).value;
294
+ }
295
+ return out as ManageTodoListInput;
296
+ }
@@ -1,12 +1,12 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import * as fc from "fast-check";
3
3
  import { TodoStateManager } from "./state-manager.js";
4
- import type { TodoItem } from "./types.js";
4
+ import type { TodoItem, TodoStatus } from "./types.js";
5
5
 
6
6
  // ── Helpers ─────────────────────────────────────────────────────────────────
7
7
 
8
- function makeTodo(id: number, title: string, description: string, status: TodoItem["status"]): TodoItem {
9
- return { id, title, description, status };
8
+ function makeTodo(content: string, status: TodoStatus, description?: string): TodoItem {
9
+ return description === undefined ? { content, status } : { content, status, description };
10
10
  }
11
11
 
12
12
  // ── TodoStateManager ────────────────────────────────────────────────────────
@@ -29,9 +29,9 @@ describe("TodoStateManager", () => {
29
29
  });
30
30
 
31
31
  it("returns copy (mutations don't affect internal state)", () => {
32
- manager.write([makeTodo(1, "Test", "desc", "not-started")]);
32
+ manager.write([makeTodo("Task", "pending")]);
33
33
  const copy = manager.read();
34
- copy.push(makeTodo(2, "Injected", "desc", "not-started"));
34
+ copy.push(makeTodo("Injected", "pending"));
35
35
  expect(manager.read()).toHaveLength(1);
36
36
  });
37
37
  });
@@ -39,16 +39,16 @@ describe("TodoStateManager", () => {
39
39
  describe("write", () => {
40
40
  it("stores todos correctly", () => {
41
41
  manager.write([
42
- makeTodo(1, "Task 1", "desc 1", "not-started"),
43
- makeTodo(2, "Task 2", "desc 2", "in-progress"),
42
+ makeTodo("Task 1", "pending"),
43
+ makeTodo("Task 2", "in_progress"),
44
44
  ]);
45
45
  expect(manager.read()).toHaveLength(2);
46
- expect(manager.read()[0]?.title).toBe("Task 1");
47
- expect(manager.read()[1]?.status).toBe("in-progress");
46
+ expect(manager.read()[0]?.content).toBe("Task 1");
47
+ expect(manager.read()[1]?.status).toBe("in_progress");
48
48
  });
49
49
 
50
50
  it("with all completed schedules auto-clear", () => {
51
- manager.write([makeTodo(1, "Done", "desc", "completed")]);
51
+ manager.write([makeTodo("Done", "completed")]);
52
52
  expect(manager.read()).toHaveLength(1);
53
53
  vi.advanceTimersByTime(2000);
54
54
  expect(manager.read()).toHaveLength(0);
@@ -56,8 +56,8 @@ describe("TodoStateManager", () => {
56
56
 
57
57
  it("does not schedule auto-clear when not all completed", () => {
58
58
  manager.write([
59
- makeTodo(1, "Done", "desc", "completed"),
60
- makeTodo(2, "Pending", "desc", "not-started"),
59
+ makeTodo("Done", "completed"),
60
+ makeTodo("Pending", "pending"),
61
61
  ]);
62
62
  vi.advanceTimersByTime(2000);
63
63
  expect(manager.read()).toHaveLength(2);
@@ -66,25 +66,25 @@ describe("TodoStateManager", () => {
66
66
 
67
67
  describe("clear", () => {
68
68
  it("empties todos", () => {
69
- manager.write([makeTodo(1, "Task", "desc", "not-started")]);
69
+ manager.write([makeTodo("Task", "pending")]);
70
70
  manager.clear();
71
71
  expect(manager.read()).toEqual([]);
72
72
  });
73
73
  });
74
74
 
75
75
  describe("getStats", () => {
76
- it("computes correct counts", () => {
76
+ it("computes correct counts incl. pending", () => {
77
77
  manager.write([
78
- makeTodo(1, "A", "desc", "not-started"),
79
- makeTodo(2, "B", "desc", "in-progress"),
80
- makeTodo(3, "C", "desc", "completed"),
81
- makeTodo(4, "D", "desc", "completed"),
78
+ makeTodo("A", "pending"),
79
+ makeTodo("B", "in_progress"),
80
+ makeTodo("C", "completed"),
81
+ makeTodo("D", "completed"),
82
82
  ]);
83
83
  expect(manager.getStats()).toEqual({
84
84
  total: 4,
85
85
  completed: 2,
86
86
  inProgress: 1,
87
- notStarted: 1,
87
+ pending: 1,
88
88
  });
89
89
  });
90
90
 
@@ -93,38 +93,56 @@ describe("TodoStateManager", () => {
93
93
  total: 0,
94
94
  completed: 0,
95
95
  inProgress: 0,
96
- notStarted: 0,
96
+ pending: 0,
97
97
  });
98
98
  });
99
99
  });
100
100
 
101
101
  describe("validate", () => {
102
- it("catches missing fields", () => {
103
- const result = manager.validate([{ id: 1, title: "", description: "", status: "" } as any]);
102
+ it("accepts canonical items with optional description", () => {
103
+ const result = manager.validate([
104
+ { content: "Task", status: "pending" },
105
+ { content: "Task 2", status: "in_progress", description: "desc" },
106
+ { content: "Task 3", status: "completed" },
107
+ ]);
108
+ expect(result.valid).toBe(true);
109
+ expect(result.errors).toEqual([]);
110
+ });
111
+
112
+ it("rejects empty content", () => {
113
+ const result = manager.validate([{ content: " ", status: "pending" }]);
104
114
  expect(result.valid).toBe(false);
105
- expect(result.errors.length).toBeGreaterThan(0);
115
+ expect(result.errors.some((e) => e.includes("content"))).toBe(true);
106
116
  });
107
117
 
108
- it("catches invalid status values", () => {
109
- const result = manager.validate([{ id: 1, title: "Test", description: "desc", status: "invalid" } as any]);
118
+ it("rejects missing content", () => {
119
+ const result = manager.validate([{ status: "pending" } as unknown as TodoItem]);
110
120
  expect(result.valid).toBe(false);
111
- expect(result.errors[0]).toContain("status");
121
+ expect(result.errors.some((e) => e.includes("content"))).toBe(true);
112
122
  });
113
123
 
114
- it("catches non-array input", () => {
115
- const result = manager.validate("not an array" as any);
124
+ it("rejects dashed legacy statuses (validate stays canonical-strict)", () => {
125
+ const result = manager.validate([{ content: "Test", status: "in-progress" } as unknown as TodoItem]);
116
126
  expect(result.valid).toBe(false);
117
- expect(result.errors[0]).toBe("todoList must be an array");
127
+ expect(result.errors.some((e) => e.includes("pending, in_progress, completed"))).toBe(true);
118
128
  });
119
129
 
120
- it("accepts valid input", () => {
121
- const result = manager.validate([
122
- makeTodo(1, "Task", "desc", "not-started"),
123
- makeTodo(2, "Task 2", "desc", "in-progress"),
124
- makeTodo(3, "Task 3", "desc", "completed"),
125
- ]);
126
- expect(result.valid).toBe(true);
127
- expect(result.errors).toEqual([]);
130
+ it("rejects non-string description", () => {
131
+ const result = manager.validate([{ content: "x", status: "pending", description: 42 } as unknown as TodoItem]);
132
+ expect(result.valid).toBe(false);
133
+ expect(result.errors.some((e) => e.includes("description"))).toBe(true);
134
+ });
135
+
136
+ it("rejects undefined items", () => {
137
+ const result = manager.validate([undefined, { content: "x", status: "pending" }] as unknown as TodoItem[]);
138
+ expect(result.valid).toBe(false);
139
+ expect(result.errors.some((e) => e.includes("undefined item"))).toBe(true);
140
+ });
141
+
142
+ it("catches non-array input", () => {
143
+ const result = manager.validate("not an array" as unknown as TodoItem[]);
144
+ expect(result.valid).toBe(false);
145
+ expect(result.errors[0]).toBe("todoList must be an array");
128
146
  });
129
147
 
130
148
  it("property: validate(write(x)).valid === true after write (round-trip)", () => {
@@ -132,15 +150,14 @@ describe("TodoStateManager", () => {
132
150
  fc.property(
133
151
  fc.array(
134
152
  fc.record({
135
- id: fc.nat({ max: 1000 }),
136
- title: fc.string({ minLength: 1, maxLength: 50 }),
137
- description: fc.string({ minLength: 1, maxLength: 100 }),
138
- status: fc.oneof(fc.constant("not-started"), fc.constant("in-progress"), fc.constant("completed")),
153
+ content: fc.string({ minLength: 1, maxLength: 50 }),
154
+ description: fc.string({ minLength: 0, maxLength: 100 }),
155
+ status: fc.oneof(fc.constant("pending"), fc.constant("in_progress"), fc.constant("completed")),
139
156
  }),
140
157
  ),
141
158
  (todos) => {
142
159
  const mgr = new TodoStateManager();
143
- mgr.write(todos as TodoItem[]);
160
+ mgr.write(todos.filter((t) => t.content.trim() !== "") as TodoItem[]);
144
161
  const readTodos = mgr.read();
145
162
  const result = mgr.validate(readTodos);
146
163
  if (!result.valid) {
@@ -152,4 +169,58 @@ describe("TodoStateManager", () => {
152
169
  );
153
170
  });
154
171
  });
172
+
173
+ describe("loadFromSession", () => {
174
+ function fakeCtx(branch: unknown[]): unknown {
175
+ return { sessionManager: { getBranch: () => branch } };
176
+ }
177
+
178
+ function toolResultEntry(details: unknown): unknown {
179
+ return { type: "message", message: { role: "toolResult", toolName: "manage_todo_list", details } };
180
+ }
181
+
182
+ it("normalizes legacy persisted items to canonical shape (id dropped)", () => {
183
+ const ctx = fakeCtx([
184
+ toolResultEntry({
185
+ todos: [{ id: 1, title: "Old", status: "not-started" }],
186
+ }),
187
+ ]);
188
+ manager.loadFromSession(ctx as never);
189
+ expect(manager.read()).toEqual([{ content: "Old", status: "pending" }]);
190
+ });
191
+
192
+ it("keeps current canonical items verbatim", () => {
193
+ const ctx = fakeCtx([
194
+ toolResultEntry({
195
+ todos: [
196
+ { content: "A", status: "in_progress" },
197
+ { content: "B", status: "completed", description: "d" },
198
+ ],
199
+ }),
200
+ ]);
201
+ manager.loadFromSession(ctx as never);
202
+ expect(manager.read()).toEqual([
203
+ { content: "A", status: "in_progress" },
204
+ { content: "B", status: "completed", description: "d" },
205
+ ]);
206
+ });
207
+
208
+ it("ignores other tools and non-message entries", () => {
209
+ const ctx = fakeCtx([
210
+ { type: "message", message: { role: "toolResult", toolName: "run_read", details: { todos: [] } } },
211
+ toolResultEntry({ todos: [{ content: "X", status: "pending" }] }),
212
+ ]);
213
+ manager.loadFromSession(ctx as never);
214
+ expect(manager.read()).toEqual([{ content: "X", status: "pending" }]);
215
+ });
216
+
217
+ it("ignores unrecoverable persisted entries (leaves earlier state)", () => {
218
+ const ctx = fakeCtx([
219
+ toolResultEntry({ todos: [{ content: "Good", status: "pending" }] }),
220
+ toolResultEntry({ todos: "a bare string" }),
221
+ ]);
222
+ manager.loadFromSession(ctx as never);
223
+ expect(manager.read()).toEqual([{ content: "Good", status: "pending" }]);
224
+ });
225
+ });
155
226
  });
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { normalizeTodoItems } from "./prepare-args.js";
2
3
  import type { TodoItem, TodoStats, ValidationResult } from "./types.js";
3
4
 
4
5
  /** Manages the in-memory todo list state. */
@@ -27,9 +28,9 @@ export class TodoStateManager {
27
28
  getStats(): TodoStats {
28
29
  const total = this.todos.length;
29
30
  const completed = this.todos.filter((t) => t.status === "completed").length;
30
- const inProgress = this.todos.filter((t) => t.status === "in-progress").length;
31
- const notStarted = this.todos.filter((t) => t.status === "not-started").length;
32
- return { total, completed, inProgress, notStarted };
31
+ const inProgress = this.todos.filter((t) => t.status === "in_progress").length;
32
+ const pending = this.todos.filter((t) => t.status === "pending").length;
33
+ return { total, completed, inProgress, pending };
33
34
  }
34
35
 
35
36
  validate(todos: TodoItem[]): ValidationResult {
@@ -37,7 +38,7 @@ export class TodoStateManager {
37
38
  if (!Array.isArray(todos)) {
38
39
  return { valid: false, errors: ["todoList must be an array"] };
39
40
  }
40
- const validStatuses = new Set(["not-started", "in-progress", "completed"]);
41
+ const validStatuses = new Set(["pending", "in_progress", "completed"]);
41
42
  for (let i = 0; i < todos.length; i++) {
42
43
  const item = todos[i];
43
44
  const prefix = `Item ${i + 1}`;
@@ -45,19 +46,14 @@ export class TodoStateManager {
45
46
  errors.push(`${prefix}: undefined item`);
46
47
  continue;
47
48
  }
48
- if (item.id == null) {
49
- errors.push(`${prefix}: missing 'id'`);
50
- } else if (typeof item.id !== "number") {
51
- errors.push(`${prefix}: 'id' must be a number`);
49
+ if (typeof item.content !== "string" || item.content.trim() === "") {
50
+ errors.push(`${prefix}: missing or invalid 'content'`);
52
51
  }
53
- if (!item.title || typeof item.title !== "string") {
54
- errors.push(`${prefix}: missing or invalid 'title'`);
52
+ if (typeof item.status !== "string" || !validStatuses.has(item.status)) {
53
+ errors.push(`${prefix}: 'status' must be one of: pending, in_progress, completed`);
55
54
  }
56
- if (!item.description || typeof item.description !== "string") {
57
- errors.push(`${prefix}: missing or invalid 'description'`);
58
- }
59
- if (!item.status || !validStatuses.has(item.status)) {
60
- errors.push(`${prefix}: 'status' must be one of: not-started, in-progress, completed`);
55
+ if (item.description !== undefined && typeof item.description !== "string") {
56
+ errors.push(`${prefix}: 'description' must be a string`);
61
57
  }
62
58
  }
63
59
  return { valid: errors.length === 0, errors };
@@ -69,9 +65,14 @@ export class TodoStateManager {
69
65
  if (entry.type !== "message") continue;
70
66
  const msg = entry.message;
71
67
  if (msg.role !== "toolResult" || msg.toolName !== "manage_todo_list") continue;
72
- const details = msg.details as { todos?: TodoItem[] } | undefined;
73
- if (details?.todos) {
74
- this.todos = details.todos.map((t) => ({ ...t }));
68
+ const details = msg.details as { todos?: unknown } | undefined;
69
+ // Legacy persisted items (id / title / dashed or synonym
70
+ // statuses) and newer canonical items both go through the same
71
+ // normalizer. Unrecoverable entries
72
+ // leave state untouched for that entry.
73
+ const items = normalizeTodoItems(details?.todos);
74
+ if (items) {
75
+ this.todos = items.map((t) => ({ ...t }));
75
76
  }
76
77
  }
77
78
  }