@pi-archimedes/todo 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/todo",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -19,7 +19,7 @@
19
19
  "./prepare-args": "./src/prepare-args.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@pi-archimedes/core": "2.8.0"
22
+ "@pi-archimedes/core": "2.9.0"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "@earendil-works/pi-ai": ">=0.1.0",
@@ -27,9 +27,9 @@
27
27
  "@earendil-works/pi-tui": ">=0.1.0"
28
28
  },
29
29
  "devDependencies": {
30
- "@earendil-works/pi-ai": "^0.85.1",
31
- "@earendil-works/pi-coding-agent": "^0.85.1",
32
- "@earendil-works/pi-tui": "^0.85.1",
30
+ "@earendil-works/pi-ai": "^0.87.0",
31
+ "@earendil-works/pi-coding-agent": "^0.87.0",
32
+ "@earendil-works/pi-tui": "^0.87.0",
33
33
  "typescript": "^6.0.0"
34
34
  },
35
35
  "pi": {
@@ -168,6 +168,64 @@ describe("prepareTodoArguments", () => {
168
168
  expect(Object.hasOwn(out, "todoList")).toBe(false);
169
169
  });
170
170
 
171
+ it("recovers a list sent under the snake_case key todo_list (stringified JSON)", () => {
172
+ const out = prepareTodoArguments({
173
+ operation: "write",
174
+ todo_list: '[{"content": "Fix auth", "status": "pending"}]',
175
+ });
176
+ expect(out).toEqual({ operation: "write", todoList: [{ content: "Fix auth", status: "pending" }] });
177
+ });
178
+
179
+ it("recovers a list sent under the alias key todos (array, legacy item shape)", () => {
180
+ const out = prepareTodoArguments({ operation: "write", todos: [LEGACY_ITEM] });
181
+ expect(out).toEqual({ operation: "write", todoList: [{ content: "Fix auth", description: "Y", status: "pending" }] });
182
+ });
183
+
184
+ it("prefers the canonical todoList key over fallback keys", () => {
185
+ const out = prepareTodoArguments({
186
+ operation: "write",
187
+ todoList: [{ content: "canonical", status: "pending" }],
188
+ todo_list: [{ content: "ignored", status: "pending" }],
189
+ });
190
+ expect(out.todoList).toEqual([{ content: "canonical", status: "pending" }]);
191
+ });
192
+
193
+ it("skips a null fallback key and uses the next present one", () => {
194
+ const out = prepareTodoArguments({
195
+ operation: "write",
196
+ todo_list: null,
197
+ todos: [{ content: "X", status: "pending" }],
198
+ });
199
+ expect(out.todoList).toEqual([{ content: "X", status: "pending" }]);
200
+ });
201
+
202
+ it("drops an explicit null todoList even when a fallback key is present", () => {
203
+ const out = prepareTodoArguments({
204
+ operation: "write",
205
+ todoList: null,
206
+ todos: [{ content: "X", status: "pending" }],
207
+ });
208
+ expect(out).toEqual({ operation: "write" });
209
+ expect(Object.hasOwn(out, "todoList")).toBe(false);
210
+ });
211
+
212
+ it("infers operation=write from a fallback-key list", () => {
213
+ expect(prepareTodoArguments({ todos: ["x"] }).operation).toBe("write");
214
+ });
215
+
216
+ it("recovers a double-encoded (stringified) arguments object", () => {
217
+ const out = prepareTodoArguments('{"operation":"write","todo_list":"[\\"a\\"]"}');
218
+ expect(out).toEqual({ operation: "write", todoList: [{ content: "a", status: "pending" }] });
219
+ });
220
+
221
+ it("passes an unparseable top-level string through untouched", () => {
222
+ expect(prepareTodoArguments("not json at all")).toBe("not json at all");
223
+ });
224
+
225
+ it("keeps a top-level string that parses to a non-object untouched", () => {
226
+ expect(prepareTodoArguments("[1, 2]")).toBe("[1, 2]");
227
+ });
228
+
171
229
  it("wraps a single bare object in an array and drops extra keys (item + top level)", () => {
172
230
  const out = prepareTodoArguments({
173
231
  operation: "write",
@@ -9,6 +9,11 @@
9
9
  * - `todoList` is a stringified JSON array instead of an array (sometimes
10
10
  * with mangled escaping, e.g. `"id": 1"` — an extra quote after the
11
11
  * number)
12
+ * - `todoList` is sent under a different top-level key — `todo_list`
13
+ * (snake_case), `todos` (the parameter name of other harnesses' todo
14
+ * tools) — observed in real sessions; the payload is dropped entirely
15
+ * and the write fails with "todoList required" without this repair
16
+ * - the whole arguments object is double-encoded as a JSON string
12
17
  * - items put the task text under `title`/`step` (shapes borrowed from
13
18
  * other harnesses) instead of the canonical `content`
14
19
  * - `id` is invented, omitted, or nulled — the canonical schema has NO
@@ -73,6 +78,24 @@ const STATUS_ALIASES: ReadonlyMap<string, TodoStatus> = new Map<string, TodoStat
73
78
  const CONTENT_FALLBACK_KEYS = ["title", "step", "task", "text", "name", "label", "activeForm"] as const;
74
79
  const DESCRIPTION_FALLBACK_KEYS = ["details", "notes", "summary", "context"] as const;
75
80
 
81
+ /**
82
+ * Keys the model sometimes uses for the top-level list argument instead of
83
+ * `todoList` (shapes borrowed from other harnesses: `todo_list` is the
84
+ * snake_case form, `todos` is the parameter of other harnesses' todo tools).
85
+ * Checked only when `todoList` itself is absent; first present wins.
86
+ */
87
+ const TODO_LIST_FALLBACK_KEYS = [
88
+ "todo_list",
89
+ "todos",
90
+ "todo",
91
+ "todoItems",
92
+ "todo_items",
93
+ "todo-items",
94
+ "task_list",
95
+ "taskList",
96
+ "tasks",
97
+ ] as const;
98
+
76
99
  /** Reserved keys the last-resort text scan must never pick up. */
77
100
  const LAST_RESORT_SKIP_KEYS = new Set<string>([
78
101
  "content",
@@ -263,6 +286,23 @@ function normalizeTodoList(raw: unknown): { value: unknown; kept: boolean } {
263
286
  return { value, kept: true }; // Unmendable — original validation error stands
264
287
  }
265
288
 
289
+ /**
290
+ * Pick the raw list value out of a record of tool arguments: the canonical
291
+ * `todoList` key wins when present (even null — an explicit null is honored
292
+ * over a stray alias); otherwise the first non-nullish fallback key. Returns
293
+ * undefined when no key is present. Shared with the subagent stream, which
294
+ * reads raw (pre-prepareArguments) child tool-call args.
295
+ */
296
+ export function pickTodoListRaw(args: Record_ | undefined | null): unknown {
297
+ if (!args) return undefined;
298
+ if (args.todoList !== undefined) return args.todoList;
299
+ for (const key of TODO_LIST_FALLBACK_KEYS) {
300
+ const value = args[key];
301
+ if (value !== undefined && value !== null) return value;
302
+ }
303
+ return undefined;
304
+ }
305
+
266
306
  function normalizeOperation(raw: unknown, listPresent: boolean): "write" | "read" {
267
307
  if (raw === "write" || raw === "read") return raw;
268
308
  if (typeof raw === "string") {
@@ -278,11 +318,23 @@ function normalizeOperation(raw: unknown, listPresent: boolean): "write" | "read
278
318
  * Never throws, never mutates its input, and passes through anything it
279
319
  * cannot recover.
280
320
  */
281
- export function prepareTodoArguments(args: unknown): ManageTodoListInput {
321
+ export function prepareTodoArguments(rawArgs: unknown): ManageTodoListInput {
322
+ let args: unknown = rawArgs;
323
+ if (typeof args === "string") {
324
+ // The model double-encoded the whole arguments object as a JSON string.
325
+ // Unparseable, or parsed to a scalar/array — keep the string so the
326
+ // schema error stays meaningful.
327
+ try {
328
+ const parsed: unknown = JSON.parse(args);
329
+ if (isRecord(parsed)) args = parsed;
330
+ } catch {
331
+ // passthrough
332
+ }
333
+ }
282
334
  if (!isRecord(args)) return args as ManageTodoListInput;
283
335
 
284
- const normalized =
285
- args.todoList === undefined ? undefined : normalizeTodoList(args.todoList);
336
+ const rawList = pickTodoListRaw(args);
337
+ const normalized = rawList === undefined ? undefined : normalizeTodoList(rawList);
286
338
  const present =
287
339
  normalized !== undefined && normalized.kept && normalized.value !== undefined;
288
340