@xynogen/pix-todo 0.1.10 → 0.1.13

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/todo.test.ts +210 -191
  3. package/src/todo.ts +67 -44
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-todo",
3
- "version": "0.1.10",
3
+ "version": "0.1.13",
4
4
  "description": "Pi tool — durable execution checklist (todo)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/todo.test.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  import { beforeEach, describe, expect, test } from "bun:test";
2
- import registerTodo, {
3
- renderTodoLines,
4
- renderTodoSummaryLine,
5
- type TodoItem,
6
- } from "./todo.ts";
2
+ import registerTodo, { renderTodoLines, renderTodoSummaryLine, type TodoItem } from "./todo.ts";
7
3
 
8
4
  // registerTodo wraps its body in once(pi, "pix-todo") — a per-instance
9
5
  // WeakMap guard that dedupes activation across pix-core + a standalone install.
@@ -30,6 +26,7 @@ function makeHost(
30
26
  data?: unknown;
31
27
  }> = [],
32
28
  ) {
29
+ let capturedParameters: unknown;
33
30
  let capturedExecute:
34
31
  | ((
35
32
  id: string,
@@ -40,10 +37,7 @@ function makeHost(
40
37
  }>)
41
38
  | null = null;
42
39
  const appendCalls: Array<{ type: string; data: unknown }> = [];
43
- const handlers: Record<
44
- string,
45
- Array<(event: unknown, ctx?: unknown) => unknown>
46
- > = {};
40
+ const handlers: Record<string, Array<(event: unknown, ctx?: unknown) => unknown>> = {};
47
41
 
48
42
  let capturedRender:
49
43
  | ((
@@ -57,9 +51,11 @@ function makeHost(
57
51
  const pi = {
58
52
  registerTool(def: {
59
53
  name: string;
54
+ parameters: unknown;
60
55
  execute: typeof capturedExecute;
61
56
  renderResult?: typeof capturedRender;
62
57
  }) {
58
+ capturedParameters = def.parameters;
63
59
  capturedExecute = def.execute;
64
60
  if (def.renderResult) capturedRender = def.renderResult;
65
61
  },
@@ -70,8 +66,13 @@ function makeHost(
70
66
  if (!handlers[ev]) handlers[ev] = [];
71
67
  handlers[ev].push(fn);
72
68
  },
73
- async emit(ev: string, event?: unknown, ctx?: unknown) {
74
- for (const fn of handlers[ev] ?? []) await fn(event, ctx);
69
+ async emit(ev: string, event?: unknown, ctx?: unknown): Promise<unknown> {
70
+ let last: unknown;
71
+ for (const fn of handlers[ev] ?? []) {
72
+ const result = await fn(event, ctx);
73
+ if (result !== undefined) last = result;
74
+ }
75
+ return last;
75
76
  },
76
77
  } as never;
77
78
 
@@ -84,6 +85,10 @@ function makeHost(
84
85
  return {
85
86
  pi,
86
87
  sessionManager,
88
+ get parameters() {
89
+ if (!capturedParameters) throw new Error("parameters not captured");
90
+ return capturedParameters;
91
+ },
87
92
  get execute() {
88
93
  if (!capturedExecute) throw new Error("execute not captured");
89
94
  return capturedExecute;
@@ -93,8 +98,13 @@ function makeHost(
93
98
  return capturedRender;
94
99
  },
95
100
  appendCalls,
96
- async emit(ev: string, event?: unknown, ctx?: unknown) {
97
- for (const fn of handlers[ev] ?? []) await fn(event, ctx);
101
+ async emit(ev: string, event?: unknown, ctx?: unknown): Promise<unknown> {
102
+ let last: unknown;
103
+ for (const fn of handlers[ev] ?? []) {
104
+ const result = await fn(event, ctx);
105
+ if (result !== undefined) last = result;
106
+ }
107
+ return last;
98
108
  },
99
109
  };
100
110
  }
@@ -116,17 +126,37 @@ function text(result: { content: Array<{ type: string; text: string }> }) {
116
126
  return result.content.map((c) => c.text).join("\n");
117
127
  }
118
128
 
129
+ // ─── Tool schema ────────────────────────────────────────────────────────────
130
+
131
+ test("todo exposes action and status as guided string enums", () => {
132
+ const host = makeHost();
133
+ registerTodo(host.pi);
134
+ const schema = host.parameters as {
135
+ properties: {
136
+ action: { type?: string; enum?: string[]; description?: string };
137
+ status: { type?: string; enum?: string[]; description?: string };
138
+ };
139
+ };
140
+ const action = schema.properties.action;
141
+ const status = schema.properties.status;
142
+
143
+ expect(action.type).toBe("string");
144
+ expect(action.enum).toEqual(["list", "set", "add", "update", "clear"]);
145
+ expect(action.description).toContain('"list" shows items');
146
+ expect(action.description).toContain('"update" changes one item by id');
147
+ expect(status?.type).toBe("string");
148
+ expect(status?.enum).toEqual(["pending", "in_progress", "done", "blocked"]);
149
+ expect(status?.description).toContain('"pending" = not started');
150
+ expect(status?.description).toContain('"blocked" = cannot proceed');
151
+ });
152
+
119
153
  // ─── parseItems (via set/add) ───────────────────────────────────────────────
120
154
 
121
155
  describe("todo actions", () => {
122
156
  test("list on empty returns (no todos)", async () => {
123
157
  const host = makeHost();
124
158
  registerTodo(host.pi);
125
- await host.emit(
126
- "session_start",
127
- {},
128
- { sessionManager: host.sessionManager },
129
- );
159
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
130
160
  const result = await run(host.execute, { action: "list" });
131
161
  expect(text(result)).toBe("(no todos)");
132
162
  });
@@ -134,11 +164,7 @@ describe("todo actions", () => {
134
164
  test("set creates items from newline text", async () => {
135
165
  const host = makeHost();
136
166
  registerTodo(host.pi);
137
- await host.emit(
138
- "session_start",
139
- {},
140
- { sessionManager: host.sessionManager },
141
- );
167
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
142
168
  const result = await run(host.execute, {
143
169
  action: "set",
144
170
  items: "alpha\nbravo\ncharlie",
@@ -153,11 +179,7 @@ describe("todo actions", () => {
153
179
  test("set creates items from numbered list", async () => {
154
180
  const host = makeHost();
155
181
  registerTodo(host.pi);
156
- await host.emit(
157
- "session_start",
158
- {},
159
- { sessionManager: host.sessionManager },
160
- );
182
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
161
183
  const result = await run(host.execute, {
162
184
  action: "set",
163
185
  items: "1. alpha\n2. bravo",
@@ -168,11 +190,7 @@ describe("todo actions", () => {
168
190
  test("set creates items from bullet list", async () => {
169
191
  const host = makeHost();
170
192
  registerTodo(host.pi);
171
- await host.emit(
172
- "session_start",
173
- {},
174
- { sessionManager: host.sessionManager },
175
- );
193
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
176
194
  const result = await run(host.execute, {
177
195
  action: "set",
178
196
  items: "- alpha\n* bravo",
@@ -184,11 +202,7 @@ describe("todo actions", () => {
184
202
  test("set ignores empty lines", async () => {
185
203
  const host = makeHost();
186
204
  registerTodo(host.pi);
187
- await host.emit(
188
- "session_start",
189
- {},
190
- { sessionManager: host.sessionManager },
191
- );
205
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
192
206
  const result = await run(host.execute, {
193
207
  action: "set",
194
208
  items: "alpha\n\nbravo\n \ncharlie",
@@ -199,11 +213,7 @@ describe("todo actions", () => {
199
213
  test("set with empty items returns error", async () => {
200
214
  const host = makeHost();
201
215
  registerTodo(host.pi);
202
- await host.emit(
203
- "session_start",
204
- {},
205
- { sessionManager: host.sessionManager },
206
- );
216
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
207
217
  const result = await run(host.execute, { action: "set", items: "" });
208
218
  expect(result.isError).toBe(true);
209
219
  expect(text(result)).toContain("non-empty");
@@ -212,11 +222,7 @@ describe("todo actions", () => {
212
222
  test("set with only whitespace returns error", async () => {
213
223
  const host = makeHost();
214
224
  registerTodo(host.pi);
215
- await host.emit(
216
- "session_start",
217
- {},
218
- { sessionManager: host.sessionManager },
219
- );
225
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
220
226
  const result = await run(host.execute, { action: "set", items: " \n " });
221
227
  expect(result.isError).toBe(true);
222
228
  });
@@ -224,11 +230,7 @@ describe("todo actions", () => {
224
230
  test("set resets ids on re-set", async () => {
225
231
  const host = makeHost();
226
232
  registerTodo(host.pi);
227
- await host.emit(
228
- "session_start",
229
- {},
230
- { sessionManager: host.sessionManager },
231
- );
233
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
232
234
  await run(host.execute, { action: "set", items: "first\nsecond" });
233
235
  const result = await run(host.execute, { action: "set", items: "new" });
234
236
  expect(text(result)).toContain("○ 1. new");
@@ -238,11 +240,7 @@ describe("todo actions", () => {
238
240
  test("add appends items", async () => {
239
241
  const host = makeHost();
240
242
  registerTodo(host.pi);
241
- await host.emit(
242
- "session_start",
243
- {},
244
- { sessionManager: host.sessionManager },
245
- );
243
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
246
244
  await run(host.execute, { action: "set", items: "alpha" });
247
245
  const result = await run(host.execute, {
248
246
  action: "add",
@@ -258,11 +256,7 @@ describe("todo actions", () => {
258
256
  test("add with ids continuing sequence", async () => {
259
257
  const host = makeHost();
260
258
  registerTodo(host.pi);
261
- await host.emit(
262
- "session_start",
263
- {},
264
- { sessionManager: host.sessionManager },
265
- );
259
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
266
260
  await run(host.execute, { action: "set", items: "first\nsecond\nthird" });
267
261
  const result = await run(host.execute, { action: "add", items: "fourth" });
268
262
  expect(text(result)).toContain("○ 4. fourth");
@@ -271,11 +265,7 @@ describe("todo actions", () => {
271
265
  test("add with empty items returns error", async () => {
272
266
  const host = makeHost();
273
267
  registerTodo(host.pi);
274
- await host.emit(
275
- "session_start",
276
- {},
277
- { sessionManager: host.sessionManager },
278
- );
268
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
279
269
  const result = await run(host.execute, { action: "add", items: "" });
280
270
  expect(result.isError).toBe(true);
281
271
  expect(text(result)).toContain("non-empty");
@@ -284,11 +274,7 @@ describe("todo actions", () => {
284
274
  test("update changes status", async () => {
285
275
  const host = makeHost();
286
276
  registerTodo(host.pi);
287
- await host.emit(
288
- "session_start",
289
- {},
290
- { sessionManager: host.sessionManager },
291
- );
277
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
292
278
  await run(host.execute, { action: "set", items: "alpha\nbravo" });
293
279
  const result = await run(host.execute, {
294
280
  action: "update",
@@ -304,11 +290,7 @@ describe("todo actions", () => {
304
290
  test("update changes text", async () => {
305
291
  const host = makeHost();
306
292
  registerTodo(host.pi);
307
- await host.emit(
308
- "session_start",
309
- {},
310
- { sessionManager: host.sessionManager },
311
- );
293
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
312
294
  await run(host.execute, { action: "set", items: "old name" });
313
295
  const result = await run(host.execute, {
314
296
  action: "update",
@@ -321,11 +303,7 @@ describe("todo actions", () => {
321
303
  test("update changes status and text together", async () => {
322
304
  const host = makeHost();
323
305
  registerTodo(host.pi);
324
- await host.emit(
325
- "session_start",
326
- {},
327
- { sessionManager: host.sessionManager },
328
- );
306
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
329
307
  await run(host.execute, { action: "set", items: "alpha" });
330
308
  const result = await run(host.execute, {
331
309
  action: "update",
@@ -341,11 +319,7 @@ describe("todo actions", () => {
341
319
  test("opening a new in_progress closes the previous one", async () => {
342
320
  const host = makeHost();
343
321
  registerTodo(host.pi);
344
- await host.emit(
345
- "session_start",
346
- {},
347
- { sessionManager: host.sessionManager },
348
- );
322
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
349
323
  await run(host.execute, { action: "set", items: "a\nb\nc" });
350
324
  await run(host.execute, { action: "update", id: 1, status: "in_progress" });
351
325
  const result = await run(host.execute, {
@@ -363,11 +337,7 @@ describe("todo actions", () => {
363
337
  test("opening a later item cascade-closes skipped pending items", async () => {
364
338
  const host = makeHost();
365
339
  registerTodo(host.pi);
366
- await host.emit(
367
- "session_start",
368
- {},
369
- { sessionManager: host.sessionManager },
370
- );
340
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
371
341
  await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
372
342
  // Jump straight to id 4 without opening 1-3; they should all auto-close.
373
343
  const result = await run(host.execute, {
@@ -386,11 +356,7 @@ describe("todo actions", () => {
386
356
  test("cascade-close leaves a blocked earlier item untouched", async () => {
387
357
  const host = makeHost();
388
358
  registerTodo(host.pi);
389
- await host.emit(
390
- "session_start",
391
- {},
392
- { sessionManager: host.sessionManager },
393
- );
359
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
394
360
  await run(host.execute, { action: "set", items: "a\nb\nc" });
395
361
  await run(host.execute, { action: "update", id: 1, status: "blocked" });
396
362
  const result = await run(host.execute, {
@@ -407,11 +373,7 @@ describe("todo actions", () => {
407
373
  test("update unknown id returns error", async () => {
408
374
  const host = makeHost();
409
375
  registerTodo(host.pi);
410
- await host.emit(
411
- "session_start",
412
- {},
413
- { sessionManager: host.sessionManager },
414
- );
376
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
415
377
  const result = await run(host.execute, {
416
378
  action: "update",
417
379
  id: 999,
@@ -424,11 +386,7 @@ describe("todo actions", () => {
424
386
  test("update without status or text does nothing", async () => {
425
387
  const host = makeHost();
426
388
  registerTodo(host.pi);
427
- await host.emit(
428
- "session_start",
429
- {},
430
- { sessionManager: host.sessionManager },
431
- );
389
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
432
390
  await run(host.execute, { action: "set", items: "unchanged" });
433
391
  const result = await run(host.execute, { action: "update", id: 1 });
434
392
  expect(text(result)).toContain("○ 1. unchanged");
@@ -437,11 +395,7 @@ describe("todo actions", () => {
437
395
  test("clear empties list", async () => {
438
396
  const host = makeHost();
439
397
  registerTodo(host.pi);
440
- await host.emit(
441
- "session_start",
442
- {},
443
- { sessionManager: host.sessionManager },
444
- );
398
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
445
399
  await run(host.execute, { action: "set", items: "alpha\nbravo" });
446
400
  const result = await run(host.execute, { action: "clear" });
447
401
  expect(text(result)).toContain("Todos cleared");
@@ -453,11 +407,7 @@ describe("todo actions", () => {
453
407
  test("clear resets id counter", async () => {
454
408
  const host = makeHost();
455
409
  registerTodo(host.pi);
456
- await host.emit(
457
- "session_start",
458
- {},
459
- { sessionManager: host.sessionManager },
460
- );
410
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
461
411
  await run(host.execute, { action: "set", items: "alpha\nbravo\ncharlie" });
462
412
  await run(host.execute, { action: "clear" });
463
413
  const result = await run(host.execute, { action: "set", items: "new" });
@@ -467,11 +417,7 @@ describe("todo actions", () => {
467
417
  test("unknown action returns error", async () => {
468
418
  const host = makeHost();
469
419
  registerTodo(host.pi);
470
- await host.emit(
471
- "session_start",
472
- {},
473
- { sessionManager: host.sessionManager },
474
- );
420
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
475
421
  const result = await run(host.execute, { action: "bogus" });
476
422
  expect(result.isError).toBe(true);
477
423
  expect(text(result)).toContain("Unknown action");
@@ -480,11 +426,7 @@ describe("todo actions", () => {
480
426
  test("all status glyphs render correctly", async () => {
481
427
  const host = makeHost();
482
428
  registerTodo(host.pi);
483
- await host.emit(
484
- "session_start",
485
- {},
486
- { sessionManager: host.sessionManager },
487
- );
429
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
488
430
  await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
489
431
  // Open id 2 first (cascade-closes nothing earlier we assert on), then set
490
432
  // the others directly so each glyph is exercised without cascade interfering.
@@ -508,11 +450,7 @@ describe("persistence", () => {
508
450
  test("set persists todos", async () => {
509
451
  const host = makeHost();
510
452
  registerTodo(host.pi);
511
- await host.emit(
512
- "session_start",
513
- {},
514
- { sessionManager: host.sessionManager },
515
- );
453
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
516
454
  host.appendCalls.length = 0;
517
455
  await run(host.execute, { action: "set", items: "alpha\nbravo" });
518
456
  expect(host.appendCalls.length).toBe(1);
@@ -529,11 +467,7 @@ describe("persistence", () => {
529
467
  test("add persists todos", async () => {
530
468
  const host = makeHost();
531
469
  registerTodo(host.pi);
532
- await host.emit(
533
- "session_start",
534
- {},
535
- { sessionManager: host.sessionManager },
536
- );
470
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
537
471
  await run(host.execute, { action: "set", items: "alpha" });
538
472
  host.appendCalls.length = 0;
539
473
  await run(host.execute, { action: "add", items: "bravo" });
@@ -543,11 +477,7 @@ describe("persistence", () => {
543
477
  test("update persists todos", async () => {
544
478
  const host = makeHost();
545
479
  registerTodo(host.pi);
546
- await host.emit(
547
- "session_start",
548
- {},
549
- { sessionManager: host.sessionManager },
550
- );
480
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
551
481
  await run(host.execute, { action: "set", items: "alpha" });
552
482
  host.appendCalls.length = 0;
553
483
  await run(host.execute, { action: "update", id: 1, status: "done" });
@@ -557,11 +487,7 @@ describe("persistence", () => {
557
487
  test("clear persists", async () => {
558
488
  const host = makeHost();
559
489
  registerTodo(host.pi);
560
- await host.emit(
561
- "session_start",
562
- {},
563
- { sessionManager: host.sessionManager },
564
- );
490
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
565
491
  await run(host.execute, { action: "set", items: "alpha" });
566
492
  host.appendCalls.length = 0;
567
493
  await run(host.execute, { action: "clear" });
@@ -590,11 +516,7 @@ describe("restore", () => {
590
516
  },
591
517
  ]);
592
518
  registerTodo(host.pi);
593
- await host.emit(
594
- "session_start",
595
- {},
596
- { sessionManager: host.sessionManager },
597
- );
519
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
598
520
  const result = await run(host.execute, { action: "list" });
599
521
  expect(text(result)).toContain("● 1. restored");
600
522
  expect(text(result)).toContain("Todos 1/1 done");
@@ -612,11 +534,7 @@ describe("restore", () => {
612
534
  },
613
535
  ]);
614
536
  registerTodo(host.pi);
615
- await host.emit(
616
- "session_start",
617
- {},
618
- { sessionManager: host.sessionManager },
619
- );
537
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
620
538
  const result = await run(host.execute, { action: "add", items: "new" });
621
539
  expect(text(result)).toContain("○ 6. new");
622
540
  });
@@ -635,11 +553,7 @@ describe("restore", () => {
635
553
  },
636
554
  ]);
637
555
  registerTodo(host.pi);
638
- await host.emit(
639
- "session_start",
640
- {},
641
- { sessionManager: host.sessionManager },
642
- );
556
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
643
557
  const result = await run(host.execute, { action: "add", items: "next" });
644
558
  expect(text(result)).toContain("○ 8. next");
645
559
  });
@@ -658,11 +572,7 @@ describe("restore", () => {
658
572
  },
659
573
  ]);
660
574
  registerTodo(host.pi);
661
- await host.emit(
662
- "session_start",
663
- {},
664
- { sessionManager: host.sessionManager },
665
- );
575
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
666
576
  const result = await run(host.execute, { action: "list" });
667
577
  expect(text(result)).toContain("○ 1. real");
668
578
  });
@@ -670,11 +580,7 @@ describe("restore", () => {
670
580
  test("no todo-state entries starts empty", async () => {
671
581
  const host = makeHost([{ type: "message", data: "hello" }]);
672
582
  registerTodo(host.pi);
673
- await host.emit(
674
- "session_start",
675
- {},
676
- { sessionManager: host.sessionManager },
677
- );
583
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
678
584
  const result = await run(host.execute, { action: "list" });
679
585
  expect(text(result)).toBe("(no todos)");
680
586
  });
@@ -682,11 +588,7 @@ describe("restore", () => {
682
588
  test("empty entries list starts empty", async () => {
683
589
  const host = makeHost([]);
684
590
  registerTodo(host.pi);
685
- await host.emit(
686
- "session_start",
687
- {},
688
- { sessionManager: host.sessionManager },
689
- );
591
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
690
592
  const result = await run(host.execute, { action: "list" });
691
593
  expect(text(result)).toBe("(no todos)");
692
594
  });
@@ -700,16 +602,139 @@ describe("restore", () => {
700
602
  },
701
603
  ]);
702
604
  registerTodo(host.pi);
703
- await host.emit(
704
- "session_start",
705
- {},
706
- { sessionManager: host.sessionManager },
707
- );
605
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
708
606
  const result = await run(host.execute, { action: "list" });
709
607
  expect(text(result)).toBe("(no todos)");
710
608
  });
711
609
  });
712
610
 
611
+ // ─── Skip-guard ─────────────────────────────────────────────────────────────────────
612
+
613
+ describe("skip-guard on marking done", () => {
614
+ test("warns when marking a later item done with earlier pending items", async () => {
615
+ const host = makeHost();
616
+ registerTodo(host.pi);
617
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
618
+ await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
619
+ // Mark items 1 and 4 done, leaving 2 and 3 pending
620
+ await run(host.execute, { action: "update", id: 1, status: "done" });
621
+ const result = await run(host.execute, { action: "update", id: 4, status: "done" });
622
+ const out = text(result);
623
+ expect(out).toContain("\u26a0 Earlier items still incomplete");
624
+ expect(out).toContain("#2 (b)");
625
+ expect(out).toContain("#3 (c)");
626
+ expect(out).toContain("Mark each done or blocked before proceeding");
627
+ });
628
+
629
+ test("no warning when all earlier items are done", async () => {
630
+ const host = makeHost();
631
+ registerTodo(host.pi);
632
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
633
+ await run(host.execute, { action: "set", items: "a\nb\nc" });
634
+ await run(host.execute, { action: "update", id: 1, status: "done" });
635
+ await run(host.execute, { action: "update", id: 2, status: "done" });
636
+ const result = await run(host.execute, { action: "update", id: 3, status: "done" });
637
+ expect(text(result)).not.toContain("\u26a0");
638
+ });
639
+
640
+ test("no warning when marking the first item done", async () => {
641
+ const host = makeHost();
642
+ registerTodo(host.pi);
643
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
644
+ await run(host.execute, { action: "set", items: "a\nb" });
645
+ const result = await run(host.execute, { action: "update", id: 1, status: "done" });
646
+ expect(text(result)).not.toContain("\u26a0");
647
+ });
648
+
649
+ test("no warning when earlier items are blocked (only pending/in_progress trigger)", async () => {
650
+ const host = makeHost();
651
+ registerTodo(host.pi);
652
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
653
+ await run(host.execute, { action: "set", items: "a\nb\nc" });
654
+ await run(host.execute, { action: "update", id: 1, status: "blocked" });
655
+ await run(host.execute, { action: "update", id: 2, status: "done" });
656
+ const result = await run(host.execute, { action: "update", id: 3, status: "done" });
657
+ // blocked is an explicit decision, not incomplete — no warning
658
+ expect(text(result)).not.toContain("\u26a0");
659
+ });
660
+
661
+ test("warns about in_progress items too (not just pending)", async () => {
662
+ const host = makeHost();
663
+ registerTodo(host.pi);
664
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
665
+ await run(host.execute, { action: "set", items: "a\nb\nc" });
666
+ await run(host.execute, { action: "update", id: 1, status: "in_progress" });
667
+ // Mark item 3 done while item 1 is still in_progress
668
+ const result = await run(host.execute, { action: "update", id: 3, status: "done" });
669
+ const out = text(result);
670
+ expect(out).toContain("\u26a0");
671
+ expect(out).toContain("#1 (a)");
672
+ });
673
+
674
+ test("no skip-guard on in_progress (only on done)", async () => {
675
+ // in_progress uses cascade-close instead, which is different behavior
676
+ const host = makeHost();
677
+ registerTodo(host.pi);
678
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
679
+ await run(host.execute, { action: "set", items: "a\nb\nc" });
680
+ const result = await run(host.execute, { action: "update", id: 3, status: "in_progress" });
681
+ // Should cascade-close, not warn
682
+ expect(text(result)).not.toContain("\u26a0");
683
+ expect(text(result)).toContain("\u25cf 1. a"); // cascade-closed to done
684
+ expect(text(result)).toContain("\u25cf 2. b");
685
+ });
686
+ });
687
+
688
+ // ─── Turn-based reminder ────────────────────────────────────────────────────────────
689
+
690
+ describe("turn-based todo reminder", () => {
691
+ test("injects reminder every 10 turns when incomplete items exist", async () => {
692
+ const host = makeHost();
693
+ registerTodo(host.pi);
694
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
695
+ await run(host.execute, { action: "set", items: "a\nb" });
696
+
697
+ // Simulate 10 turns — only the 10th should inject
698
+ for (let i = 1; i <= 9; i++) {
699
+ const result = await host.emit("before_agent_start", { systemPrompt: "base" });
700
+ // before_agent_start returns undefined when no injection
701
+ expect(result).toBeUndefined();
702
+ }
703
+ // 10th turn should inject
704
+ const result = await host.emit("before_agent_start", { systemPrompt: "base" });
705
+ expect(result).toBeDefined();
706
+ const prompt = (result as { systemPrompt: string }).systemPrompt;
707
+ expect(prompt).toContain("base");
708
+ expect(prompt).toContain("Todo reminder");
709
+ expect(prompt).toContain("1. a");
710
+ expect(prompt).toContain("2. b");
711
+ });
712
+
713
+ test("does not inject when no todos exist", async () => {
714
+ const host = makeHost();
715
+ registerTodo(host.pi);
716
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
717
+
718
+ for (let i = 1; i <= 10; i++) {
719
+ const result = await host.emit("before_agent_start", { systemPrompt: "base" });
720
+ expect(result).toBeUndefined();
721
+ }
722
+ });
723
+
724
+ test("does not inject when all items are done", async () => {
725
+ const host = makeHost();
726
+ registerTodo(host.pi);
727
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
728
+ await run(host.execute, { action: "set", items: "a" });
729
+ await run(host.execute, { action: "update", id: 1, status: "done" });
730
+
731
+ for (let i = 1; i <= 10; i++) {
732
+ const result = await host.emit("before_agent_start", { systemPrompt: "base" });
733
+ expect(result).toBeUndefined();
734
+ }
735
+ });
736
+ });
737
+
713
738
  describe("renderTodoLines (colored TUI render)", () => {
714
739
  const items: TodoItem[] = [
715
740
  { id: 1, text: "alpha", status: "done" },
@@ -754,11 +779,7 @@ describe("renderResult snapshot isolation", () => {
754
779
  test("a rendered card keeps its state after todos mutate", async () => {
755
780
  const host = makeHost();
756
781
  registerTodo(host.pi);
757
- await host.emit(
758
- "session_start",
759
- {},
760
- { sessionManager: host.sessionManager },
761
- );
782
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
762
783
  await run(host.execute, { action: "set", items: "alpha\nbravo" });
763
784
 
764
785
  // Render once with a per-row state bag; snapshot is taken here.
@@ -789,8 +810,6 @@ describe("renderTodoSummaryLine (collapsed one-liner)", () => {
789
810
  { id: 1, text: "a", status: "done" },
790
811
  { id: 2, text: "b", status: "pending" },
791
812
  ];
792
- expect(renderTodoSummaryLine(items, tagTheme)).toBe(
793
- "[muted]Todos 1/2 done ✓[/]",
794
- );
813
+ expect(renderTodoSummaryLine(items, tagTheme)).toBe("[muted]Todos 1/2 done ✓[/]");
795
814
  });
796
815
  });
package/src/todo.ts CHANGED
@@ -42,8 +42,7 @@ function readCollapseConfig(): CollapseConf {
42
42
  if (!c || typeof c !== "object") return DEFAULT_COLLAPSE;
43
43
  return {
44
44
  enabled: typeof c.enabled === "boolean" ? c.enabled : true,
45
- delaySec:
46
- typeof c.delaySec === "number" && c.delaySec > 0 ? c.delaySec : 10,
45
+ delaySec: typeof c.delaySec === "number" && c.delaySec > 0 ? c.delaySec : 10,
47
46
  tools:
48
47
  c.tools && typeof c.tools === "object"
49
48
  ? (c.tools as Record<string, boolean | undefined>)
@@ -99,10 +98,7 @@ export type TodoTheme = {
99
98
  };
100
99
 
101
100
  /** One-line dim summary used once a card has collapsed. */
102
- export function renderTodoSummaryLine(
103
- items: TodoItem[],
104
- theme: TodoTheme,
105
- ): string {
101
+ export function renderTodoSummaryLine(items: TodoItem[], theme: TodoTheme): string {
106
102
  if (!items.length) return theme.fg("muted", "(no todos)");
107
103
  const done = items.filter((t) => t.status === "done").length;
108
104
  return theme.fg("muted", `Todos ${done}/${items.length} done ✓`);
@@ -127,6 +123,22 @@ export function renderTodoLines(items: TodoItem[], theme: TodoTheme): string {
127
123
  return `${head}\n${lines.join("\n")}`;
128
124
  }
129
125
 
126
+ /**
127
+ * Skip-guard: when marking an item done, check for earlier items still
128
+ * pending or in_progress. Returns a warning string or "" if none skipped.
129
+ */
130
+ function buildSkipWarning(items: TodoItem[], targetId: number): string {
131
+ const skipped = items.filter(
132
+ (o) => o.id < targetId && (o.status === "pending" || o.status === "in_progress"),
133
+ );
134
+ if (skipped.length === 0) return "";
135
+ const ids = skipped.map((s) => `#${s.id} (${s.text})`).join(", ");
136
+ return (
137
+ `\n\n\u26a0 Earlier items still incomplete: ${ids}. ` +
138
+ "Mark each done or blocked before proceeding."
139
+ );
140
+ }
141
+
130
142
  const parseItems = (raw: string): string[] =>
131
143
  raw
132
144
  .split("\n")
@@ -145,9 +157,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
145
157
  function todoSummary(): string {
146
158
  if (!todos.length) return "(no todos)";
147
159
  const done = todos.filter((t) => t.status === "done").length;
148
- const lines = todos.map(
149
- (t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`,
150
- );
160
+ const lines = todos.map((t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`);
151
161
  return `Todos ${done}/${todos.length} done:\n${lines.join("\n")}`;
152
162
  }
153
163
 
@@ -164,38 +174,27 @@ export default function registerTodo(pi: ExtensionAPI): void {
164
174
  promptGuidelines: [
165
175
  "When you start executing a multi-step plan in BUILD mode, seed the todo list with `todo(action:'set', items: <plan Implementation Phases>)`.",
166
176
  "Mark each item in_progress before working it via `todo(action:'update', id, status)`; opening one auto-closes every earlier item, so just open the next and skipped steps mark done themselves.",
177
+ "When marking an item done, the tool checks for earlier incomplete items and warns you — resolve each skipped item (mark done or blocked) before moving on.",
167
178
  "Call `todo(action:'list')` to recover your place after long runs or context compaction.",
168
179
  ],
169
180
  parameters: Type.Object({
170
- action: Type.Union(
171
- [
172
- Type.Literal("list"),
173
- Type.Literal("set"),
174
- Type.Literal("add"),
175
- Type.Literal("update"),
176
- Type.Literal("clear"),
177
- ],
178
- { description: "Operation to perform" },
179
- ),
181
+ action: Type.Enum(["list", "set", "add", "update", "clear"] as const, {
182
+ type: "string",
183
+ description:
184
+ 'Required operation: "list" shows items; "set" replaces all from items; "add" appends items; "update" changes one item by id; "clear" removes all.',
185
+ }),
180
186
  items: Type.Optional(
181
187
  Type.String({
182
- description:
183
- "For set/add: newline-separated or numbered list of todo texts.",
188
+ description: "For set/add: newline-separated or numbered list of todo texts.",
184
189
  }),
185
190
  ),
186
- id: Type.Optional(
187
- Type.Number({ description: "For update: target todo id." }),
188
- ),
191
+ id: Type.Optional(Type.Number({ description: "For update: target todo id." })),
189
192
  status: Type.Optional(
190
- Type.Union(
191
- [
192
- Type.Literal("pending"),
193
- Type.Literal("in_progress"),
194
- Type.Literal("done"),
195
- Type.Literal("blocked"),
196
- ],
197
- { description: "For update: new status." },
198
- ),
193
+ Type.Enum(["pending", "in_progress", "done", "blocked"] as const, {
194
+ type: "string",
195
+ description:
196
+ 'For update: "pending" = not started; "in_progress" = active; "done" = finished; "blocked" = cannot proceed.',
197
+ }),
199
198
  ),
200
199
  text: Type.Optional(
201
200
  Type.String({
@@ -220,9 +219,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
220
219
  context.invalidate();
221
220
  }, collapseDelayMs());
222
221
  }
223
- const render = state.collapsed
224
- ? renderTodoSummaryLine
225
- : renderTodoLines;
222
+ const render = state.collapsed ? renderTodoSummaryLine : renderTodoLines;
226
223
  return new Text(render(state.snapshot, theme as TodoTheme), 0, 0);
227
224
  },
228
225
 
@@ -258,8 +255,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
258
255
  case "add": {
259
256
  const texts = parseItems(params.items ?? "");
260
257
  if (!texts.length) return fail("add requires non-empty `items`.");
261
- for (const text of texts)
262
- todos.push({ id: nextTodoId++, text, status: "pending" });
258
+ for (const text of texts) todos.push({ id: nextTodoId++, text, status: "pending" });
263
259
  persistTodos();
264
260
  return ok(todoSummary());
265
261
  }
@@ -267,6 +263,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
267
263
  case "update": {
268
264
  const t = todos.find((x) => x.id === params.id);
269
265
  if (!t) return fail(`No todo with id ${params.id}.`);
266
+ let skipWarning = "";
270
267
  if (params.status) {
271
268
  // Sequential-progress invariant: opening a task means everything
272
269
  // before it is finished. Cascade-close every earlier pending or
@@ -276,15 +273,17 @@ export default function registerTodo(pi: ExtensionAPI): void {
276
273
  for (const other of todos)
277
274
  if (
278
275
  other.id < t.id &&
279
- (other.status === "pending" ||
280
- other.status === "in_progress")
276
+ (other.status === "pending" || other.status === "in_progress")
281
277
  )
282
278
  other.status = "done";
279
+
280
+ if (params.status === "done") skipWarning = buildSkipWarning(todos, t.id);
281
+
283
282
  t.status = params.status;
284
283
  }
285
284
  if (params.text) t.text = params.text;
286
285
  persistTodos();
287
- return ok(todoSummary());
286
+ return ok(todoSummary() + skipWarning);
288
287
  }
289
288
 
290
289
  case "clear":
@@ -299,6 +298,32 @@ export default function registerTodo(pi: ExtensionAPI): void {
299
298
  },
300
299
  });
301
300
 
301
+ // ── Turn-based reminder ─────────────────────────────────────────────
302
+ // Every TODO_REMINDER_INTERVAL turns, inject the current todo summary
303
+ // into the system prompt so the model stays aware of pending work and
304
+ // can't hand-wave or ignore incomplete items.
305
+ const TODO_REMINDER_INTERVAL = 10;
306
+ let todoTurnCount = 0;
307
+
308
+ pi.on("before_agent_start", async (event) => {
309
+ todoTurnCount++;
310
+ // Only inject when there are active (non-empty) todos
311
+ if (todos.length === 0) return;
312
+ // Check if any items are still incomplete
313
+ const hasIncomplete = todos.some((t) => t.status === "pending" || t.status === "in_progress");
314
+ if (!hasIncomplete) return;
315
+ // Fire on every Nth turn
316
+ if (todoTurnCount % TODO_REMINDER_INTERVAL !== 0) return;
317
+
318
+ const reminder =
319
+ "Todo reminder — incomplete items remain:\n" +
320
+ todoSummary() +
321
+ "\nCall `todo(action:'list')` to review, then continue working through pending items.";
322
+
323
+ const existing = event.systemPrompt ?? "";
324
+ return { systemPrompt: existing ? `${existing}\n\n${reminder}` : reminder };
325
+ });
326
+
302
327
  // Restore the checklist from session entries so it survives restart.
303
328
  pi.on("session_start", async (_event, ctx) => {
304
329
  const entries = ctx.sessionManager.getEntries() as Array<{
@@ -311,9 +336,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
311
336
  .pop();
312
337
  if (Array.isArray(lastTodo?.data?.todos)) {
313
338
  todos = lastTodo.data.todos;
314
- nextTodoId =
315
- lastTodo.data.nextTodoId ??
316
- todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
339
+ nextTodoId = lastTodo.data.nextTodoId ?? todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
317
340
  }
318
341
  });
319
342
  });