@pi-archimedes/todo 2.3.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.
@@ -3,35 +3,45 @@
3
3
  *
4
4
  * Wires into the tool via `prepareArguments`, which pi runs BEFORE schema
5
5
  * validation (and before `execute`). The model occasionally sends
6
- * malformed-but-recoverable `manage_todo_list` arguments; observed in practice:
6
+ * malformed-but-recoverable `manage_todo_list` arguments; observed in
7
+ * practice (frozen open-weight models, ADR 0009):
7
8
  *
8
9
  * - `todoList` is a stringified JSON array instead of an array (sometimes
9
- * with mangled escaping, e.g. `"id": 1"` — an extra quote after the number)
10
- * - items are missing `title` because the model considers it redundant
11
- * with `description`
12
- * - items are missing `status` (or it is a non-enum phrase like "doing")
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")
13
20
  * - items are plain strings
14
- * - `id` missing or a numeric string
15
21
  * - stray top-level / per-item keys outside the schema
16
22
  *
17
- * Only *recoverable* deviations are repaired. Anything unrecoverable is passed
18
- * through untouched so the standard validation error still surfaces with the
19
- * real complaint. The public schema stays strict; this module is purely a
20
- * repair layer.
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.
21
27
  */
22
28
 
23
29
  import type { ManageTodoListInput } from "./tool.js";
24
- import type { TodoStatus } from "./types.js";
30
+ import type { TodoItem, TodoStatus } from "./types.js";
25
31
 
26
32
  type Record_ = Record<string, unknown>;
27
33
 
28
34
  const VALID_STATUSES: ReadonlySet<string> = new Set<string>([
29
- "not-started",
30
- "in-progress",
35
+ "pending",
36
+ "in_progress",
31
37
  "completed",
32
38
  ]);
33
39
 
34
- /** LLM synonyms for the three enum values (after case/spacing normalization). */
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
+ */
35
45
  const STATUS_ALIASES: ReadonlyMap<string, TodoStatus> = new Map<string, TodoStatus>([
36
46
  ["done", "completed"],
37
47
  ["complete", "completed"],
@@ -39,28 +49,41 @@ const STATUS_ALIASES: ReadonlyMap<string, TodoStatus> = new Map<string, TodoStat
39
49
  ["closed", "completed"],
40
50
  ["success", "completed"],
41
51
  ["passed", "completed"],
42
- ["doing", "in-progress"],
43
- ["started", "in-progress"],
44
- ["working", "in-progress"],
45
- ["active", "in-progress"],
46
- ["wip", "in-progress"],
47
- ["ongoing", "in-progress"],
48
- ["pending", "not-started"],
49
- ["todo", "not-started"],
50
- ["planned", "not-started"],
51
- ["untouched", "not-started"],
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"],
52
66
  ]);
53
67
 
54
68
  /**
55
- * Keys the model sometimes uses instead of `title`/`description`. Checked
56
- * only when the canonical field is itself missing, so a model that DID send
57
- * `title`/`description` is never overwritten.
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.
58
72
  */
59
- const TITLE_FALLBACK_KEYS = ["content", "task", "text", "name", "label"] as const;
73
+ const CONTENT_FALLBACK_KEYS = ["title", "step", "task", "text", "name", "label", "activeForm"] as const;
60
74
  const DESCRIPTION_FALLBACK_KEYS = ["details", "notes", "summary", "context"] as const;
61
75
 
62
- /** Upper bound for a `title` derived from `description`. */
63
- const DERIVED_TITLE_MAX = 60;
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;
64
87
 
65
88
  function isRecord(value: unknown): value is Record_ {
66
89
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -75,58 +98,48 @@ function firstStringOf(record: Record_, keys: readonly string[]): string {
75
98
  }
76
99
 
77
100
  /**
78
- * Collapse whitespace and cut at a word boundary so a derived title stays a
79
- * short, human-readable label in the widget.
101
+ * Collapse whitespace and cut at a word boundary so a derived `content`
102
+ * stays a short, human-readable label in the widget.
80
103
  */
81
104
  export function deriveTitleFromDescription(description: string): string {
82
105
  const flat = description.replace(/\s+/g, " ").trim();
83
- if (flat.length <= DERIVED_TITLE_MAX) return flat;
84
- const cut = flat.slice(0, DERIVED_TITLE_MAX);
106
+ if (flat.length <= DERIVED_CONTENT_MAX) return flat;
107
+ const cut = flat.slice(0, DERIVED_CONTENT_MAX);
85
108
  const lastSpace = cut.lastIndexOf(" ");
86
109
  const head = lastSpace > 0 ? cut.slice(0, lastSpace) : cut;
87
110
  return `${head.trimEnd()}…`;
88
111
  }
89
112
 
90
- function normalizeId(raw: unknown, index: number): number {
91
- let n: number;
92
- if (typeof raw === "number") {
93
- n = raw;
94
- } else if (typeof raw === "string" && raw.trim() !== "") {
95
- n = Number(raw);
96
- } else {
97
- n = Number.NaN;
98
- }
99
- return Number.isFinite(n) ? Math.trunc(n) : index + 1;
100
- }
101
-
102
- function normalizeStatus(raw: unknown): TodoStatus {
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 {
103
120
  if (typeof raw !== "string") {
104
121
  // Missing, null, number, … — no signal about intent.
105
- return "not-started";
122
+ return "pending";
106
123
  }
107
- // "in progress" / "In_Progress" / "IN-PROGRESS" → "in-progress"
108
- const normalized = raw.trim().toLowerCase().replace(/[_\s]+/g, "-");
109
- if (VALID_STATUSES.has(normalized)) return normalized as TodoStatus;
110
- return STATUS_ALIASES.get(normalized) ?? "not-started";
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";
111
128
  }
112
129
 
113
130
  /**
114
- * Normalize one todo item. Returns a fresh object containing ONLY the four
115
- * schema keys (extra keys are dropped — the schema rejects them with "must
116
- * not have additional properties"). Unrecoverable items are returned as-is so
117
- * the schema error keeps its meaning.
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.
118
136
  */
119
137
  export function normalizeTodoItem(raw: unknown, index: number): unknown {
120
138
  // Laziest form: ["write tests", "deploy"]
121
139
  if (typeof raw === "string") {
122
140
  const text = raw.trim();
123
141
  if (text === "") return raw;
124
- return {
125
- id: index + 1,
126
- title: deriveTitleFromDescription(text),
127
- description: text,
128
- status: "not-started" as TodoStatus,
129
- };
142
+ return { content: deriveTitleFromDescription(text), status: "pending" as TodoStatus };
130
143
  }
131
144
 
132
145
  if (!isRecord(raw)) return raw; // null / number / nested array → schema error
@@ -134,20 +147,63 @@ export function normalizeTodoItem(raw: unknown, index: number): unknown {
134
147
  const description =
135
148
  (typeof raw.description === "string" ? raw.description.trim() : "") ||
136
149
  firstStringOf(raw, DESCRIPTION_FALLBACK_KEYS);
137
- let title = typeof raw.title === "string" ? raw.title.trim() : "";
138
- if (title === "") title = firstStringOf(raw, TITLE_FALLBACK_KEYS);
139
- if (title === "" && description !== "") title = deriveTitleFromDescription(description);
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
+ }
140
166
 
141
- // No text at all: keep the (empty) fields so the schema error lists the
142
- // missing `title`/`description` instead of masking it with a derivation.
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.
143
170
  return {
144
- id: normalizeId(raw.id, index),
145
- title,
146
- description: description || title,
171
+ content,
172
+ ...(description !== "" ? { description } : {}),
147
173
  status: normalizeStatus(raw.status),
148
174
  };
149
175
  }
150
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
+
151
207
  /**
152
208
  * Repair the occasional mangled JSON: `"id": 1"` (an extra quote after the
153
209
  * number). The replacement is safe to apply blindly — that shape is invalid
@@ -171,7 +227,11 @@ function parseStringifiedList(raw: string): unknown {
171
227
  function looksLikeTodoItem(value: unknown): boolean {
172
228
  return (
173
229
  isRecord(value) &&
174
- ("id" in value || "title" in value || "description" in value || "status" in value)
230
+ ("content" in value ||
231
+ "title" in value ||
232
+ "step" in value ||
233
+ "description" in value ||
234
+ "status" in value)
175
235
  );
176
236
  }
177
237
 
@@ -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
  }