@xynogen/pix-todo 0.1.9 → 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.
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/todo.test.ts +179 -191
- package/src/todo.ts +112 -27
package/README.md
CHANGED
|
@@ -6,6 +6,22 @@ Pi tool — durable execution checklist (`todo`).
|
|
|
6
6
|
|
|
7
7
|
Registers the `todo` tool, which gives the agent a persistent task checklist that survives context compaction and session restore. The checklist is seeded by the model via the `set` action and tracks items through four statuses: `pending` (○), `in_progress` (◐), `done` (●), and `blocked` (⊘). State is persisted via Pi's `appendEntry("todo-state")` so the agent can recover its position after long runs or compaction events. The agent calls `todo(action:"list")` to resume where it left off. Actions: `list`, `set`, `add`, `update`, `clear`.
|
|
8
8
|
|
|
9
|
+
## Auto-collapse
|
|
10
|
+
|
|
11
|
+
The checklist card auto-collapses after a configurable delay (default 10 seconds, previously hardcoded). The delay and the per-tool toggle are read from `~/.pi/agent/pix.json`:
|
|
12
|
+
|
|
13
|
+
```jsonc
|
|
14
|
+
{
|
|
15
|
+
"collapse": {
|
|
16
|
+
"enabled": true,
|
|
17
|
+
"delayMs": 10000,
|
|
18
|
+
"tools": { "todo": true }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Set `collapse.tools.todo: false` to keep the checklist always expanded. See `@xynogen/pix-data/collapse` for the full API.
|
|
24
|
+
|
|
9
25
|
## Install
|
|
10
26
|
|
|
11
27
|
```bash
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
@@ -9,14 +9,65 @@
|
|
|
9
9
|
* checklist is seeded by the model via the tool's `set` action.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
12
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
15
|
import { Text } from "@earendil-works/pi-tui";
|
|
14
16
|
import { Type } from "typebox";
|
|
15
17
|
|
|
16
18
|
import { once } from "./once.ts";
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
// ── Collapse config from ~/.pi/agent/pix.json ────────────────────────────────
|
|
21
|
+
|
|
22
|
+
interface CollapseConf {
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
delaySec: number;
|
|
25
|
+
tools: Record<string, boolean | undefined>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_COLLAPSE: CollapseConf = {
|
|
29
|
+
enabled: true,
|
|
30
|
+
delaySec: 10,
|
|
31
|
+
tools: {},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function readCollapseConfig(): CollapseConf {
|
|
35
|
+
try {
|
|
36
|
+
const home = process.env.HOME ?? "";
|
|
37
|
+
if (!home) return DEFAULT_COLLAPSE;
|
|
38
|
+
const p = join(home, ".pi/agent", "pix.json");
|
|
39
|
+
if (!existsSync(p)) return DEFAULT_COLLAPSE;
|
|
40
|
+
const raw = JSON.parse(readFileSync(p, "utf-8")) as Record<string, unknown>;
|
|
41
|
+
const c = raw?.collapse as Record<string, unknown> | undefined;
|
|
42
|
+
if (!c || typeof c !== "object") return DEFAULT_COLLAPSE;
|
|
43
|
+
return {
|
|
44
|
+
enabled: typeof c.enabled === "boolean" ? c.enabled : true,
|
|
45
|
+
delaySec: typeof c.delaySec === "number" && c.delaySec > 0 ? c.delaySec : 10,
|
|
46
|
+
tools:
|
|
47
|
+
c.tools && typeof c.tools === "object"
|
|
48
|
+
? (c.tools as Record<string, boolean | undefined>)
|
|
49
|
+
: {},
|
|
50
|
+
};
|
|
51
|
+
} catch {
|
|
52
|
+
return DEFAULT_COLLAPSE;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let collapseConf: CollapseConf | null = null;
|
|
57
|
+
function getCollapseConfig(): CollapseConf {
|
|
58
|
+
if (!collapseConf) collapseConf = readCollapseConfig();
|
|
59
|
+
return collapseConf;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function shouldCollapseTodo(): boolean {
|
|
63
|
+
const c = getCollapseConfig();
|
|
64
|
+
const perTool = c.tools.todo;
|
|
65
|
+
return typeof perTool === "boolean" ? perTool : c.enabled;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function collapseDelayMs(): number {
|
|
69
|
+
return getCollapseConfig().delaySec * 1000;
|
|
70
|
+
}
|
|
20
71
|
|
|
21
72
|
export type TodoStatus = "pending" | "in_progress" | "done" | "blocked";
|
|
22
73
|
|
|
@@ -47,10 +98,7 @@ export type TodoTheme = {
|
|
|
47
98
|
};
|
|
48
99
|
|
|
49
100
|
/** One-line dim summary used once a card has collapsed. */
|
|
50
|
-
export function renderTodoSummaryLine(
|
|
51
|
-
items: TodoItem[],
|
|
52
|
-
theme: TodoTheme,
|
|
53
|
-
): string {
|
|
101
|
+
export function renderTodoSummaryLine(items: TodoItem[], theme: TodoTheme): string {
|
|
54
102
|
if (!items.length) return theme.fg("muted", "(no todos)");
|
|
55
103
|
const done = items.filter((t) => t.status === "done").length;
|
|
56
104
|
return theme.fg("muted", `Todos ${done}/${items.length} done ✓`);
|
|
@@ -75,6 +123,22 @@ export function renderTodoLines(items: TodoItem[], theme: TodoTheme): string {
|
|
|
75
123
|
return `${head}\n${lines.join("\n")}`;
|
|
76
124
|
}
|
|
77
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
|
+
|
|
78
142
|
const parseItems = (raw: string): string[] =>
|
|
79
143
|
raw
|
|
80
144
|
.split("\n")
|
|
@@ -93,9 +157,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
93
157
|
function todoSummary(): string {
|
|
94
158
|
if (!todos.length) return "(no todos)";
|
|
95
159
|
const done = todos.filter((t) => t.status === "done").length;
|
|
96
|
-
const lines = todos.map(
|
|
97
|
-
(t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`,
|
|
98
|
-
);
|
|
160
|
+
const lines = todos.map((t) => `${TODO_GLYPH[t.status]} ${t.id}. ${t.text}`);
|
|
99
161
|
return `Todos ${done}/${todos.length} done:\n${lines.join("\n")}`;
|
|
100
162
|
}
|
|
101
163
|
|
|
@@ -112,6 +174,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
112
174
|
promptGuidelines: [
|
|
113
175
|
"When you start executing a multi-step plan in BUILD mode, seed the todo list with `todo(action:'set', items: <plan Implementation Phases>)`.",
|
|
114
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.",
|
|
115
178
|
"Call `todo(action:'list')` to recover your place after long runs or context compaction.",
|
|
116
179
|
],
|
|
117
180
|
parameters: Type.Object({
|
|
@@ -127,13 +190,10 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
127
190
|
),
|
|
128
191
|
items: Type.Optional(
|
|
129
192
|
Type.String({
|
|
130
|
-
description:
|
|
131
|
-
"For set/add: newline-separated or numbered list of todo texts.",
|
|
193
|
+
description: "For set/add: newline-separated or numbered list of todo texts.",
|
|
132
194
|
}),
|
|
133
195
|
),
|
|
134
|
-
id: Type.Optional(
|
|
135
|
-
Type.Number({ description: "For update: target todo id." }),
|
|
136
|
-
),
|
|
196
|
+
id: Type.Optional(Type.Number({ description: "For update: target todo id." })),
|
|
137
197
|
status: Type.Optional(
|
|
138
198
|
Type.Union(
|
|
139
199
|
[
|
|
@@ -161,15 +221,14 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
161
221
|
};
|
|
162
222
|
if (!state.snapshot) state.snapshot = todos.map((t) => ({ ...t }));
|
|
163
223
|
// Start the collapse timer once per row; invalidate() triggers rerender.
|
|
164
|
-
|
|
224
|
+
// Config-driven: reads from ~/.pi/agent/pix.json collapse section.
|
|
225
|
+
if (shouldCollapseTodo() && !state.collapsed && !state.timer) {
|
|
165
226
|
state.timer = setTimeout(() => {
|
|
166
227
|
state.collapsed = true;
|
|
167
228
|
context.invalidate();
|
|
168
|
-
},
|
|
229
|
+
}, collapseDelayMs());
|
|
169
230
|
}
|
|
170
|
-
const render = state.collapsed
|
|
171
|
-
? renderTodoSummaryLine
|
|
172
|
-
: renderTodoLines;
|
|
231
|
+
const render = state.collapsed ? renderTodoSummaryLine : renderTodoLines;
|
|
173
232
|
return new Text(render(state.snapshot, theme as TodoTheme), 0, 0);
|
|
174
233
|
},
|
|
175
234
|
|
|
@@ -205,8 +264,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
205
264
|
case "add": {
|
|
206
265
|
const texts = parseItems(params.items ?? "");
|
|
207
266
|
if (!texts.length) return fail("add requires non-empty `items`.");
|
|
208
|
-
for (const text of texts)
|
|
209
|
-
todos.push({ id: nextTodoId++, text, status: "pending" });
|
|
267
|
+
for (const text of texts) todos.push({ id: nextTodoId++, text, status: "pending" });
|
|
210
268
|
persistTodos();
|
|
211
269
|
return ok(todoSummary());
|
|
212
270
|
}
|
|
@@ -214,6 +272,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
214
272
|
case "update": {
|
|
215
273
|
const t = todos.find((x) => x.id === params.id);
|
|
216
274
|
if (!t) return fail(`No todo with id ${params.id}.`);
|
|
275
|
+
let skipWarning = "";
|
|
217
276
|
if (params.status) {
|
|
218
277
|
// Sequential-progress invariant: opening a task means everything
|
|
219
278
|
// before it is finished. Cascade-close every earlier pending or
|
|
@@ -223,15 +282,17 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
223
282
|
for (const other of todos)
|
|
224
283
|
if (
|
|
225
284
|
other.id < t.id &&
|
|
226
|
-
(other.status === "pending" ||
|
|
227
|
-
other.status === "in_progress")
|
|
285
|
+
(other.status === "pending" || other.status === "in_progress")
|
|
228
286
|
)
|
|
229
287
|
other.status = "done";
|
|
288
|
+
|
|
289
|
+
if (params.status === "done") skipWarning = buildSkipWarning(todos, t.id);
|
|
290
|
+
|
|
230
291
|
t.status = params.status;
|
|
231
292
|
}
|
|
232
293
|
if (params.text) t.text = params.text;
|
|
233
294
|
persistTodos();
|
|
234
|
-
return ok(todoSummary());
|
|
295
|
+
return ok(todoSummary() + skipWarning);
|
|
235
296
|
}
|
|
236
297
|
|
|
237
298
|
case "clear":
|
|
@@ -246,6 +307,32 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
246
307
|
},
|
|
247
308
|
});
|
|
248
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
|
+
|
|
249
336
|
// Restore the checklist from session entries so it survives restart.
|
|
250
337
|
pi.on("session_start", async (_event, ctx) => {
|
|
251
338
|
const entries = ctx.sessionManager.getEntries() as Array<{
|
|
@@ -258,9 +345,7 @@ export default function registerTodo(pi: ExtensionAPI): void {
|
|
|
258
345
|
.pop();
|
|
259
346
|
if (Array.isArray(lastTodo?.data?.todos)) {
|
|
260
347
|
todos = lastTodo.data.todos;
|
|
261
|
-
nextTodoId =
|
|
262
|
-
lastTodo.data.nextTodoId ??
|
|
263
|
-
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);
|
|
264
349
|
}
|
|
265
350
|
});
|
|
266
351
|
});
|