@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.
- package/package.json +1 -1
- package/src/todo.test.ts +210 -191
- package/src/todo.ts +67 -44
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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
});
|