@xynogen/pix-todo 0.1.10 → 0.1.12

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 +179 -191
  3. package/src/todo.ts +57 -25
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.12",
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.
@@ -40,10 +36,7 @@ function makeHost(
40
36
  }>)
41
37
  | null = null;
42
38
  const appendCalls: Array<{ type: string; data: unknown }> = [];
43
- const handlers: Record<
44
- string,
45
- Array<(event: unknown, ctx?: unknown) => unknown>
46
- > = {};
39
+ const handlers: Record<string, Array<(event: unknown, ctx?: unknown) => unknown>> = {};
47
40
 
48
41
  let capturedRender:
49
42
  | ((
@@ -70,8 +63,13 @@ function makeHost(
70
63
  if (!handlers[ev]) handlers[ev] = [];
71
64
  handlers[ev].push(fn);
72
65
  },
73
- async emit(ev: string, event?: unknown, ctx?: unknown) {
74
- for (const fn of handlers[ev] ?? []) await fn(event, ctx);
66
+ async emit(ev: string, event?: unknown, ctx?: unknown): Promise<unknown> {
67
+ let last: unknown;
68
+ for (const fn of handlers[ev] ?? []) {
69
+ const result = await fn(event, ctx);
70
+ if (result !== undefined) last = result;
71
+ }
72
+ return last;
75
73
  },
76
74
  } as never;
77
75
 
@@ -93,8 +91,13 @@ function makeHost(
93
91
  return capturedRender;
94
92
  },
95
93
  appendCalls,
96
- async emit(ev: string, event?: unknown, ctx?: unknown) {
97
- for (const fn of handlers[ev] ?? []) await fn(event, ctx);
94
+ async emit(ev: string, event?: unknown, ctx?: unknown): Promise<unknown> {
95
+ let last: unknown;
96
+ for (const fn of handlers[ev] ?? []) {
97
+ const result = await fn(event, ctx);
98
+ if (result !== undefined) last = result;
99
+ }
100
+ return last;
98
101
  },
99
102
  };
100
103
  }
@@ -122,11 +125,7 @@ describe("todo actions", () => {
122
125
  test("list on empty returns (no todos)", async () => {
123
126
  const host = makeHost();
124
127
  registerTodo(host.pi);
125
- await host.emit(
126
- "session_start",
127
- {},
128
- { sessionManager: host.sessionManager },
129
- );
128
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
130
129
  const result = await run(host.execute, { action: "list" });
131
130
  expect(text(result)).toBe("(no todos)");
132
131
  });
@@ -134,11 +133,7 @@ describe("todo actions", () => {
134
133
  test("set creates items from newline text", async () => {
135
134
  const host = makeHost();
136
135
  registerTodo(host.pi);
137
- await host.emit(
138
- "session_start",
139
- {},
140
- { sessionManager: host.sessionManager },
141
- );
136
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
142
137
  const result = await run(host.execute, {
143
138
  action: "set",
144
139
  items: "alpha\nbravo\ncharlie",
@@ -153,11 +148,7 @@ describe("todo actions", () => {
153
148
  test("set creates items from numbered list", async () => {
154
149
  const host = makeHost();
155
150
  registerTodo(host.pi);
156
- await host.emit(
157
- "session_start",
158
- {},
159
- { sessionManager: host.sessionManager },
160
- );
151
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
161
152
  const result = await run(host.execute, {
162
153
  action: "set",
163
154
  items: "1. alpha\n2. bravo",
@@ -168,11 +159,7 @@ describe("todo actions", () => {
168
159
  test("set creates items from bullet list", async () => {
169
160
  const host = makeHost();
170
161
  registerTodo(host.pi);
171
- await host.emit(
172
- "session_start",
173
- {},
174
- { sessionManager: host.sessionManager },
175
- );
162
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
176
163
  const result = await run(host.execute, {
177
164
  action: "set",
178
165
  items: "- alpha\n* bravo",
@@ -184,11 +171,7 @@ describe("todo actions", () => {
184
171
  test("set ignores empty lines", async () => {
185
172
  const host = makeHost();
186
173
  registerTodo(host.pi);
187
- await host.emit(
188
- "session_start",
189
- {},
190
- { sessionManager: host.sessionManager },
191
- );
174
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
192
175
  const result = await run(host.execute, {
193
176
  action: "set",
194
177
  items: "alpha\n\nbravo\n \ncharlie",
@@ -199,11 +182,7 @@ describe("todo actions", () => {
199
182
  test("set with empty items returns error", async () => {
200
183
  const host = makeHost();
201
184
  registerTodo(host.pi);
202
- await host.emit(
203
- "session_start",
204
- {},
205
- { sessionManager: host.sessionManager },
206
- );
185
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
207
186
  const result = await run(host.execute, { action: "set", items: "" });
208
187
  expect(result.isError).toBe(true);
209
188
  expect(text(result)).toContain("non-empty");
@@ -212,11 +191,7 @@ describe("todo actions", () => {
212
191
  test("set with only whitespace returns error", async () => {
213
192
  const host = makeHost();
214
193
  registerTodo(host.pi);
215
- await host.emit(
216
- "session_start",
217
- {},
218
- { sessionManager: host.sessionManager },
219
- );
194
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
220
195
  const result = await run(host.execute, { action: "set", items: " \n " });
221
196
  expect(result.isError).toBe(true);
222
197
  });
@@ -224,11 +199,7 @@ describe("todo actions", () => {
224
199
  test("set resets ids on re-set", async () => {
225
200
  const host = makeHost();
226
201
  registerTodo(host.pi);
227
- await host.emit(
228
- "session_start",
229
- {},
230
- { sessionManager: host.sessionManager },
231
- );
202
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
232
203
  await run(host.execute, { action: "set", items: "first\nsecond" });
233
204
  const result = await run(host.execute, { action: "set", items: "new" });
234
205
  expect(text(result)).toContain("○ 1. new");
@@ -238,11 +209,7 @@ describe("todo actions", () => {
238
209
  test("add appends items", async () => {
239
210
  const host = makeHost();
240
211
  registerTodo(host.pi);
241
- await host.emit(
242
- "session_start",
243
- {},
244
- { sessionManager: host.sessionManager },
245
- );
212
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
246
213
  await run(host.execute, { action: "set", items: "alpha" });
247
214
  const result = await run(host.execute, {
248
215
  action: "add",
@@ -258,11 +225,7 @@ describe("todo actions", () => {
258
225
  test("add with ids continuing sequence", async () => {
259
226
  const host = makeHost();
260
227
  registerTodo(host.pi);
261
- await host.emit(
262
- "session_start",
263
- {},
264
- { sessionManager: host.sessionManager },
265
- );
228
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
266
229
  await run(host.execute, { action: "set", items: "first\nsecond\nthird" });
267
230
  const result = await run(host.execute, { action: "add", items: "fourth" });
268
231
  expect(text(result)).toContain("○ 4. fourth");
@@ -271,11 +234,7 @@ describe("todo actions", () => {
271
234
  test("add with empty items returns error", async () => {
272
235
  const host = makeHost();
273
236
  registerTodo(host.pi);
274
- await host.emit(
275
- "session_start",
276
- {},
277
- { sessionManager: host.sessionManager },
278
- );
237
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
279
238
  const result = await run(host.execute, { action: "add", items: "" });
280
239
  expect(result.isError).toBe(true);
281
240
  expect(text(result)).toContain("non-empty");
@@ -284,11 +243,7 @@ describe("todo actions", () => {
284
243
  test("update changes status", async () => {
285
244
  const host = makeHost();
286
245
  registerTodo(host.pi);
287
- await host.emit(
288
- "session_start",
289
- {},
290
- { sessionManager: host.sessionManager },
291
- );
246
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
292
247
  await run(host.execute, { action: "set", items: "alpha\nbravo" });
293
248
  const result = await run(host.execute, {
294
249
  action: "update",
@@ -304,11 +259,7 @@ describe("todo actions", () => {
304
259
  test("update changes text", async () => {
305
260
  const host = makeHost();
306
261
  registerTodo(host.pi);
307
- await host.emit(
308
- "session_start",
309
- {},
310
- { sessionManager: host.sessionManager },
311
- );
262
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
312
263
  await run(host.execute, { action: "set", items: "old name" });
313
264
  const result = await run(host.execute, {
314
265
  action: "update",
@@ -321,11 +272,7 @@ describe("todo actions", () => {
321
272
  test("update changes status and text together", async () => {
322
273
  const host = makeHost();
323
274
  registerTodo(host.pi);
324
- await host.emit(
325
- "session_start",
326
- {},
327
- { sessionManager: host.sessionManager },
328
- );
275
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
329
276
  await run(host.execute, { action: "set", items: "alpha" });
330
277
  const result = await run(host.execute, {
331
278
  action: "update",
@@ -341,11 +288,7 @@ describe("todo actions", () => {
341
288
  test("opening a new in_progress closes the previous one", async () => {
342
289
  const host = makeHost();
343
290
  registerTodo(host.pi);
344
- await host.emit(
345
- "session_start",
346
- {},
347
- { sessionManager: host.sessionManager },
348
- );
291
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
349
292
  await run(host.execute, { action: "set", items: "a\nb\nc" });
350
293
  await run(host.execute, { action: "update", id: 1, status: "in_progress" });
351
294
  const result = await run(host.execute, {
@@ -363,11 +306,7 @@ describe("todo actions", () => {
363
306
  test("opening a later item cascade-closes skipped pending items", async () => {
364
307
  const host = makeHost();
365
308
  registerTodo(host.pi);
366
- await host.emit(
367
- "session_start",
368
- {},
369
- { sessionManager: host.sessionManager },
370
- );
309
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
371
310
  await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
372
311
  // Jump straight to id 4 without opening 1-3; they should all auto-close.
373
312
  const result = await run(host.execute, {
@@ -386,11 +325,7 @@ describe("todo actions", () => {
386
325
  test("cascade-close leaves a blocked earlier item untouched", async () => {
387
326
  const host = makeHost();
388
327
  registerTodo(host.pi);
389
- await host.emit(
390
- "session_start",
391
- {},
392
- { sessionManager: host.sessionManager },
393
- );
328
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
394
329
  await run(host.execute, { action: "set", items: "a\nb\nc" });
395
330
  await run(host.execute, { action: "update", id: 1, status: "blocked" });
396
331
  const result = await run(host.execute, {
@@ -407,11 +342,7 @@ describe("todo actions", () => {
407
342
  test("update unknown id returns error", async () => {
408
343
  const host = makeHost();
409
344
  registerTodo(host.pi);
410
- await host.emit(
411
- "session_start",
412
- {},
413
- { sessionManager: host.sessionManager },
414
- );
345
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
415
346
  const result = await run(host.execute, {
416
347
  action: "update",
417
348
  id: 999,
@@ -424,11 +355,7 @@ describe("todo actions", () => {
424
355
  test("update without status or text does nothing", async () => {
425
356
  const host = makeHost();
426
357
  registerTodo(host.pi);
427
- await host.emit(
428
- "session_start",
429
- {},
430
- { sessionManager: host.sessionManager },
431
- );
358
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
432
359
  await run(host.execute, { action: "set", items: "unchanged" });
433
360
  const result = await run(host.execute, { action: "update", id: 1 });
434
361
  expect(text(result)).toContain("○ 1. unchanged");
@@ -437,11 +364,7 @@ describe("todo actions", () => {
437
364
  test("clear empties list", async () => {
438
365
  const host = makeHost();
439
366
  registerTodo(host.pi);
440
- await host.emit(
441
- "session_start",
442
- {},
443
- { sessionManager: host.sessionManager },
444
- );
367
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
445
368
  await run(host.execute, { action: "set", items: "alpha\nbravo" });
446
369
  const result = await run(host.execute, { action: "clear" });
447
370
  expect(text(result)).toContain("Todos cleared");
@@ -453,11 +376,7 @@ describe("todo actions", () => {
453
376
  test("clear resets id counter", async () => {
454
377
  const host = makeHost();
455
378
  registerTodo(host.pi);
456
- await host.emit(
457
- "session_start",
458
- {},
459
- { sessionManager: host.sessionManager },
460
- );
379
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
461
380
  await run(host.execute, { action: "set", items: "alpha\nbravo\ncharlie" });
462
381
  await run(host.execute, { action: "clear" });
463
382
  const result = await run(host.execute, { action: "set", items: "new" });
@@ -467,11 +386,7 @@ describe("todo actions", () => {
467
386
  test("unknown action returns error", async () => {
468
387
  const host = makeHost();
469
388
  registerTodo(host.pi);
470
- await host.emit(
471
- "session_start",
472
- {},
473
- { sessionManager: host.sessionManager },
474
- );
389
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
475
390
  const result = await run(host.execute, { action: "bogus" });
476
391
  expect(result.isError).toBe(true);
477
392
  expect(text(result)).toContain("Unknown action");
@@ -480,11 +395,7 @@ describe("todo actions", () => {
480
395
  test("all status glyphs render correctly", async () => {
481
396
  const host = makeHost();
482
397
  registerTodo(host.pi);
483
- await host.emit(
484
- "session_start",
485
- {},
486
- { sessionManager: host.sessionManager },
487
- );
398
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
488
399
  await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
489
400
  // Open id 2 first (cascade-closes nothing earlier we assert on), then set
490
401
  // the others directly so each glyph is exercised without cascade interfering.
@@ -508,11 +419,7 @@ describe("persistence", () => {
508
419
  test("set persists todos", async () => {
509
420
  const host = makeHost();
510
421
  registerTodo(host.pi);
511
- await host.emit(
512
- "session_start",
513
- {},
514
- { sessionManager: host.sessionManager },
515
- );
422
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
516
423
  host.appendCalls.length = 0;
517
424
  await run(host.execute, { action: "set", items: "alpha\nbravo" });
518
425
  expect(host.appendCalls.length).toBe(1);
@@ -529,11 +436,7 @@ describe("persistence", () => {
529
436
  test("add persists todos", async () => {
530
437
  const host = makeHost();
531
438
  registerTodo(host.pi);
532
- await host.emit(
533
- "session_start",
534
- {},
535
- { sessionManager: host.sessionManager },
536
- );
439
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
537
440
  await run(host.execute, { action: "set", items: "alpha" });
538
441
  host.appendCalls.length = 0;
539
442
  await run(host.execute, { action: "add", items: "bravo" });
@@ -543,11 +446,7 @@ describe("persistence", () => {
543
446
  test("update persists todos", async () => {
544
447
  const host = makeHost();
545
448
  registerTodo(host.pi);
546
- await host.emit(
547
- "session_start",
548
- {},
549
- { sessionManager: host.sessionManager },
550
- );
449
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
551
450
  await run(host.execute, { action: "set", items: "alpha" });
552
451
  host.appendCalls.length = 0;
553
452
  await run(host.execute, { action: "update", id: 1, status: "done" });
@@ -557,11 +456,7 @@ describe("persistence", () => {
557
456
  test("clear persists", async () => {
558
457
  const host = makeHost();
559
458
  registerTodo(host.pi);
560
- await host.emit(
561
- "session_start",
562
- {},
563
- { sessionManager: host.sessionManager },
564
- );
459
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
565
460
  await run(host.execute, { action: "set", items: "alpha" });
566
461
  host.appendCalls.length = 0;
567
462
  await run(host.execute, { action: "clear" });
@@ -590,11 +485,7 @@ describe("restore", () => {
590
485
  },
591
486
  ]);
592
487
  registerTodo(host.pi);
593
- await host.emit(
594
- "session_start",
595
- {},
596
- { sessionManager: host.sessionManager },
597
- );
488
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
598
489
  const result = await run(host.execute, { action: "list" });
599
490
  expect(text(result)).toContain("● 1. restored");
600
491
  expect(text(result)).toContain("Todos 1/1 done");
@@ -612,11 +503,7 @@ describe("restore", () => {
612
503
  },
613
504
  ]);
614
505
  registerTodo(host.pi);
615
- await host.emit(
616
- "session_start",
617
- {},
618
- { sessionManager: host.sessionManager },
619
- );
506
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
620
507
  const result = await run(host.execute, { action: "add", items: "new" });
621
508
  expect(text(result)).toContain("○ 6. new");
622
509
  });
@@ -635,11 +522,7 @@ describe("restore", () => {
635
522
  },
636
523
  ]);
637
524
  registerTodo(host.pi);
638
- await host.emit(
639
- "session_start",
640
- {},
641
- { sessionManager: host.sessionManager },
642
- );
525
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
643
526
  const result = await run(host.execute, { action: "add", items: "next" });
644
527
  expect(text(result)).toContain("○ 8. next");
645
528
  });
@@ -658,11 +541,7 @@ describe("restore", () => {
658
541
  },
659
542
  ]);
660
543
  registerTodo(host.pi);
661
- await host.emit(
662
- "session_start",
663
- {},
664
- { sessionManager: host.sessionManager },
665
- );
544
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
666
545
  const result = await run(host.execute, { action: "list" });
667
546
  expect(text(result)).toContain("○ 1. real");
668
547
  });
@@ -670,11 +549,7 @@ describe("restore", () => {
670
549
  test("no todo-state entries starts empty", async () => {
671
550
  const host = makeHost([{ type: "message", data: "hello" }]);
672
551
  registerTodo(host.pi);
673
- await host.emit(
674
- "session_start",
675
- {},
676
- { sessionManager: host.sessionManager },
677
- );
552
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
678
553
  const result = await run(host.execute, { action: "list" });
679
554
  expect(text(result)).toBe("(no todos)");
680
555
  });
@@ -682,11 +557,7 @@ describe("restore", () => {
682
557
  test("empty entries list starts empty", async () => {
683
558
  const host = makeHost([]);
684
559
  registerTodo(host.pi);
685
- await host.emit(
686
- "session_start",
687
- {},
688
- { sessionManager: host.sessionManager },
689
- );
560
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
690
561
  const result = await run(host.execute, { action: "list" });
691
562
  expect(text(result)).toBe("(no todos)");
692
563
  });
@@ -700,16 +571,139 @@ describe("restore", () => {
700
571
  },
701
572
  ]);
702
573
  registerTodo(host.pi);
703
- await host.emit(
704
- "session_start",
705
- {},
706
- { sessionManager: host.sessionManager },
707
- );
574
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
708
575
  const result = await run(host.execute, { action: "list" });
709
576
  expect(text(result)).toBe("(no todos)");
710
577
  });
711
578
  });
712
579
 
580
+ // ─── Skip-guard ─────────────────────────────────────────────────────────────────────
581
+
582
+ describe("skip-guard on marking done", () => {
583
+ test("warns when marking a later item done with earlier pending items", async () => {
584
+ const host = makeHost();
585
+ registerTodo(host.pi);
586
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
587
+ await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
588
+ // Mark items 1 and 4 done, leaving 2 and 3 pending
589
+ await run(host.execute, { action: "update", id: 1, status: "done" });
590
+ const result = await run(host.execute, { action: "update", id: 4, status: "done" });
591
+ const out = text(result);
592
+ expect(out).toContain("\u26a0 Earlier items still incomplete");
593
+ expect(out).toContain("#2 (b)");
594
+ expect(out).toContain("#3 (c)");
595
+ expect(out).toContain("Mark each done or blocked before proceeding");
596
+ });
597
+
598
+ test("no warning when all earlier items are done", async () => {
599
+ const host = makeHost();
600
+ registerTodo(host.pi);
601
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
602
+ await run(host.execute, { action: "set", items: "a\nb\nc" });
603
+ await run(host.execute, { action: "update", id: 1, status: "done" });
604
+ await run(host.execute, { action: "update", id: 2, status: "done" });
605
+ const result = await run(host.execute, { action: "update", id: 3, status: "done" });
606
+ expect(text(result)).not.toContain("\u26a0");
607
+ });
608
+
609
+ test("no warning when marking the first item done", async () => {
610
+ const host = makeHost();
611
+ registerTodo(host.pi);
612
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
613
+ await run(host.execute, { action: "set", items: "a\nb" });
614
+ const result = await run(host.execute, { action: "update", id: 1, status: "done" });
615
+ expect(text(result)).not.toContain("\u26a0");
616
+ });
617
+
618
+ test("no warning when earlier items are blocked (only pending/in_progress trigger)", async () => {
619
+ const host = makeHost();
620
+ registerTodo(host.pi);
621
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
622
+ await run(host.execute, { action: "set", items: "a\nb\nc" });
623
+ await run(host.execute, { action: "update", id: 1, status: "blocked" });
624
+ await run(host.execute, { action: "update", id: 2, status: "done" });
625
+ const result = await run(host.execute, { action: "update", id: 3, status: "done" });
626
+ // blocked is an explicit decision, not incomplete — no warning
627
+ expect(text(result)).not.toContain("\u26a0");
628
+ });
629
+
630
+ test("warns about in_progress items too (not just pending)", async () => {
631
+ const host = makeHost();
632
+ registerTodo(host.pi);
633
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
634
+ await run(host.execute, { action: "set", items: "a\nb\nc" });
635
+ await run(host.execute, { action: "update", id: 1, status: "in_progress" });
636
+ // Mark item 3 done while item 1 is still in_progress
637
+ const result = await run(host.execute, { action: "update", id: 3, status: "done" });
638
+ const out = text(result);
639
+ expect(out).toContain("\u26a0");
640
+ expect(out).toContain("#1 (a)");
641
+ });
642
+
643
+ test("no skip-guard on in_progress (only on done)", async () => {
644
+ // in_progress uses cascade-close instead, which is different behavior
645
+ const host = makeHost();
646
+ registerTodo(host.pi);
647
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
648
+ await run(host.execute, { action: "set", items: "a\nb\nc" });
649
+ const result = await run(host.execute, { action: "update", id: 3, status: "in_progress" });
650
+ // Should cascade-close, not warn
651
+ expect(text(result)).not.toContain("\u26a0");
652
+ expect(text(result)).toContain("\u25cf 1. a"); // cascade-closed to done
653
+ expect(text(result)).toContain("\u25cf 2. b");
654
+ });
655
+ });
656
+
657
+ // ─── Turn-based reminder ────────────────────────────────────────────────────────────
658
+
659
+ describe("turn-based todo reminder", () => {
660
+ test("injects reminder every 10 turns when incomplete items exist", async () => {
661
+ const host = makeHost();
662
+ registerTodo(host.pi);
663
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
664
+ await run(host.execute, { action: "set", items: "a\nb" });
665
+
666
+ // Simulate 10 turns — only the 10th should inject
667
+ for (let i = 1; i <= 9; i++) {
668
+ const result = await host.emit("before_agent_start", { systemPrompt: "base" });
669
+ // before_agent_start returns undefined when no injection
670
+ expect(result).toBeUndefined();
671
+ }
672
+ // 10th turn should inject
673
+ const result = await host.emit("before_agent_start", { systemPrompt: "base" });
674
+ expect(result).toBeDefined();
675
+ const prompt = (result as { systemPrompt: string }).systemPrompt;
676
+ expect(prompt).toContain("base");
677
+ expect(prompt).toContain("Todo reminder");
678
+ expect(prompt).toContain("1. a");
679
+ expect(prompt).toContain("2. b");
680
+ });
681
+
682
+ test("does not inject when no todos exist", async () => {
683
+ const host = makeHost();
684
+ registerTodo(host.pi);
685
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
686
+
687
+ for (let i = 1; i <= 10; i++) {
688
+ const result = await host.emit("before_agent_start", { systemPrompt: "base" });
689
+ expect(result).toBeUndefined();
690
+ }
691
+ });
692
+
693
+ test("does not inject when all items are done", async () => {
694
+ const host = makeHost();
695
+ registerTodo(host.pi);
696
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
697
+ await run(host.execute, { action: "set", items: "a" });
698
+ await run(host.execute, { action: "update", id: 1, status: "done" });
699
+
700
+ for (let i = 1; i <= 10; i++) {
701
+ const result = await host.emit("before_agent_start", { systemPrompt: "base" });
702
+ expect(result).toBeUndefined();
703
+ }
704
+ });
705
+ });
706
+
713
707
  describe("renderTodoLines (colored TUI render)", () => {
714
708
  const items: TodoItem[] = [
715
709
  { id: 1, text: "alpha", status: "done" },
@@ -754,11 +748,7 @@ describe("renderResult snapshot isolation", () => {
754
748
  test("a rendered card keeps its state after todos mutate", async () => {
755
749
  const host = makeHost();
756
750
  registerTodo(host.pi);
757
- await host.emit(
758
- "session_start",
759
- {},
760
- { sessionManager: host.sessionManager },
761
- );
751
+ await host.emit("session_start", {}, { sessionManager: host.sessionManager });
762
752
  await run(host.execute, { action: "set", items: "alpha\nbravo" });
763
753
 
764
754
  // Render once with a per-row state bag; snapshot is taken here.
@@ -789,8 +779,6 @@ describe("renderTodoSummaryLine (collapsed one-liner)", () => {
789
779
  { id: 1, text: "a", status: "done" },
790
780
  { id: 2, text: "b", status: "pending" },
791
781
  ];
792
- expect(renderTodoSummaryLine(items, tagTheme)).toBe(
793
- "[muted]Todos 1/2 done ✓[/]",
794
- );
782
+ expect(renderTodoSummaryLine(items, tagTheme)).toBe("[muted]Todos 1/2 done ✓[/]");
795
783
  });
796
784
  });
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,6 +174,7 @@ 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({
@@ -179,13 +190,10 @@ export default function registerTodo(pi: ExtensionAPI): void {
179
190
  ),
180
191
  items: Type.Optional(
181
192
  Type.String({
182
- description:
183
- "For set/add: newline-separated or numbered list of todo texts.",
193
+ description: "For set/add: newline-separated or numbered list of todo texts.",
184
194
  }),
185
195
  ),
186
- id: Type.Optional(
187
- Type.Number({ description: "For update: target todo id." }),
188
- ),
196
+ id: Type.Optional(Type.Number({ description: "For update: target todo id." })),
189
197
  status: Type.Optional(
190
198
  Type.Union(
191
199
  [
@@ -220,9 +228,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
220
228
  context.invalidate();
221
229
  }, collapseDelayMs());
222
230
  }
223
- const render = state.collapsed
224
- ? renderTodoSummaryLine
225
- : renderTodoLines;
231
+ const render = state.collapsed ? renderTodoSummaryLine : renderTodoLines;
226
232
  return new Text(render(state.snapshot, theme as TodoTheme), 0, 0);
227
233
  },
228
234
 
@@ -258,8 +264,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
258
264
  case "add": {
259
265
  const texts = parseItems(params.items ?? "");
260
266
  if (!texts.length) return fail("add requires non-empty `items`.");
261
- for (const text of texts)
262
- todos.push({ id: nextTodoId++, text, status: "pending" });
267
+ for (const text of texts) todos.push({ id: nextTodoId++, text, status: "pending" });
263
268
  persistTodos();
264
269
  return ok(todoSummary());
265
270
  }
@@ -267,6 +272,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
267
272
  case "update": {
268
273
  const t = todos.find((x) => x.id === params.id);
269
274
  if (!t) return fail(`No todo with id ${params.id}.`);
275
+ let skipWarning = "";
270
276
  if (params.status) {
271
277
  // Sequential-progress invariant: opening a task means everything
272
278
  // before it is finished. Cascade-close every earlier pending or
@@ -276,15 +282,17 @@ export default function registerTodo(pi: ExtensionAPI): void {
276
282
  for (const other of todos)
277
283
  if (
278
284
  other.id < t.id &&
279
- (other.status === "pending" ||
280
- other.status === "in_progress")
285
+ (other.status === "pending" || other.status === "in_progress")
281
286
  )
282
287
  other.status = "done";
288
+
289
+ if (params.status === "done") skipWarning = buildSkipWarning(todos, t.id);
290
+
283
291
  t.status = params.status;
284
292
  }
285
293
  if (params.text) t.text = params.text;
286
294
  persistTodos();
287
- return ok(todoSummary());
295
+ return ok(todoSummary() + skipWarning);
288
296
  }
289
297
 
290
298
  case "clear":
@@ -299,6 +307,32 @@ export default function registerTodo(pi: ExtensionAPI): void {
299
307
  },
300
308
  });
301
309
 
310
+ // ── Turn-based reminder ─────────────────────────────────────────────
311
+ // Every TODO_REMINDER_INTERVAL turns, inject the current todo summary
312
+ // into the system prompt so the model stays aware of pending work and
313
+ // can't hand-wave or ignore incomplete items.
314
+ const TODO_REMINDER_INTERVAL = 10;
315
+ let todoTurnCount = 0;
316
+
317
+ pi.on("before_agent_start", async (event) => {
318
+ todoTurnCount++;
319
+ // Only inject when there are active (non-empty) todos
320
+ if (todos.length === 0) return;
321
+ // Check if any items are still incomplete
322
+ const hasIncomplete = todos.some((t) => t.status === "pending" || t.status === "in_progress");
323
+ if (!hasIncomplete) return;
324
+ // Fire on every Nth turn
325
+ if (todoTurnCount % TODO_REMINDER_INTERVAL !== 0) return;
326
+
327
+ const reminder =
328
+ "Todo reminder — incomplete items remain:\n" +
329
+ todoSummary() +
330
+ "\nCall `todo(action:'list')` to review, then continue working through pending items.";
331
+
332
+ const existing = event.systemPrompt ?? "";
333
+ return { systemPrompt: existing ? `${existing}\n\n${reminder}` : reminder };
334
+ });
335
+
302
336
  // Restore the checklist from session entries so it survives restart.
303
337
  pi.on("session_start", async (_event, ctx) => {
304
338
  const entries = ctx.sessionManager.getEntries() as Array<{
@@ -311,9 +345,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
311
345
  .pop();
312
346
  if (Array.isArray(lastTodo?.data?.todos)) {
313
347
  todos = lastTodo.data.todos;
314
- nextTodoId =
315
- lastTodo.data.nextTodoId ??
316
- todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
348
+ nextTodoId = lastTodo.data.nextTodoId ?? todos.reduce((m, t) => Math.max(m, t.id + 1), 1);
317
349
  }
318
350
  });
319
351
  });