@xynogen/pix-todo 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/todo.ts +103 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-todo",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Pi tool — durable execution checklist (todo)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/todo.ts CHANGED
@@ -172,6 +172,35 @@ const parseItems = (raw: string): string[] =>
172
172
  .map((l) => l.replace(/^\s*(?:\d+[.)]|[-*•])\s*/, "").trim())
173
173
  .filter(Boolean);
174
174
 
175
+ const STATUSES: readonly TodoStatus[] = ["pending", "in_progress", "done", "blocked"];
176
+
177
+ export interface TodoUpdateOp {
178
+ id: number;
179
+ status: TodoStatus;
180
+ }
181
+
182
+ /**
183
+ * Parse the batch `updates` string: comma/newline separated `id:status` pairs
184
+ * (e.g. "3:done, 4:blocked"). Returns an error string on the first bad token so
185
+ * the model gets one precise correction instead of a silent partial apply.
186
+ */
187
+ export function parseUpdates(raw: string): { ops: TodoUpdateOp[] } | { error: string } {
188
+ const ops: TodoUpdateOp[] = [];
189
+ for (const token of raw.split(/[,\n]/)) {
190
+ const tok = token.trim();
191
+ if (!tok) continue;
192
+ const m = /^#?(\d+)\s*[:=]\s*(\w+)$/.exec(tok);
193
+ if (!m) return { error: `Bad update token "${tok}" — expected "id:status".` };
194
+ const status = m[2] as TodoStatus;
195
+ if (!STATUSES.includes(status))
196
+ return { error: `Bad status "${m[2]}" — expected one of ${STATUSES.join(", ")}.` };
197
+ ops.push({ id: Number(m[1]), status });
198
+ }
199
+ return ops.length
200
+ ? { ops }
201
+ : { error: 'update requires `updates` ("id:status") or `id`+`status`.' };
202
+ }
203
+
175
204
  export default function registerTodo(pi: ExtensionAPI): void {
176
205
  once(pi, "pix-todo", () => {
177
206
  let todos: TodoItem[] = [];
@@ -212,6 +241,16 @@ export default function registerTodo(pi: ExtensionAPI): void {
212
241
  return `Todos ${done}/${todos.length} done:\n${lines.join("\n")}`;
213
242
  }
214
243
 
244
+ /** Compact next-step hint for the delta echo — the full list is already in
245
+ * the transcript and the tool card, so re-sending it is token waste. */
246
+ function todoHint(): string {
247
+ const done = todos.filter((t) => t.status === "done").length;
248
+ const next =
249
+ todos.find((t) => t.status === "in_progress") ?? todos.find((t) => t.status === "pending");
250
+ if (!next) return `${done}/${todos.length} done — all items closed.`;
251
+ return `${done}/${todos.length} done — next #${next.id} ${next.text}`;
252
+ }
253
+
215
254
  // Durable execution checklist for BUILD mode. Survives context compaction
216
255
  // and session restore. Workflows like plan instruct the model to seed it
217
256
  // from a plan's "Implementation Phases" so it stays anchored to plan.md.
@@ -222,12 +261,12 @@ export default function registerTodo(pi: ExtensionAPI): void {
222
261
  // result row and should align its status glyph with other compact tools.
223
262
  renderShell: "self",
224
263
  description:
225
- "Track BUILD-phase execution progress. Durable across context compaction. Actions: list, set (replace all items from newline/numbered text), add, update (change one item's status), clear.",
264
+ "Track BUILD-phase execution progress. Durable across context compaction. Actions: list, set (replace all items from newline/numbered text), add, update (change one or more items' status — batch via `updates`), clear.",
226
265
  promptSnippet:
227
- "todo(action, items?, id?, status?, text?) — action: list|set|add|update|clear. Use to track implementation progress, especially when executing a plan.",
266
+ "todo(action, items?, updates?, id?, status?, text?) — action: list|set|add|update|clear. Use to track implementation progress, especially when executing a plan.",
228
267
  promptGuidelines: [
229
268
  "When you start executing a multi-step plan in BUILD mode, seed the todo list with `todo(action:'set', items: <plan Implementation Phases>)`.",
230
- "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.",
269
+ "Batch status changes in ONE call: `todo(action:'update', updates:'3:done,4:in_progress')`. Opening an item auto-closes earlier ones (ordered lists), so do not send per-item update calls.",
231
270
  "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.",
232
271
  "If the list is NOT a sequential run (items independent, done in any order), pass `ordered:false` on `set` — that disables the cascade-close and skip warning.",
233
272
  "Call `todo(action:'list')` to recover your place after long runs or context compaction.",
@@ -236,14 +275,20 @@ export default function registerTodo(pi: ExtensionAPI): void {
236
275
  action: Type.Enum(["list", "set", "add", "update", "clear"] as const, {
237
276
  type: "string",
238
277
  description:
239
- 'Required operation: "list" shows items; "set" replaces all from items; "add" appends items; "update" changes one item by id; "clear" removes all.',
278
+ 'Required operation: "list" shows items; "set" replaces all from items; "add" appends items; "update" changes one or more items by id; "clear" removes all.',
240
279
  }),
241
280
  items: Type.Optional(
242
281
  Type.String({
243
282
  description: "For set/add: newline-separated or numbered list of todo texts.",
244
283
  }),
245
284
  ),
246
- id: Type.Optional(Type.Number({ description: "For update: target todo id." })),
285
+ updates: Type.Optional(
286
+ Type.String({
287
+ description:
288
+ 'For update (preferred for status changes): comma-separated "id:status" pairs, e.g. "3:done,4:in_progress". Applied in order.',
289
+ }),
290
+ ),
291
+ id: Type.Optional(Type.Number({ description: "For update: single target todo id." })),
247
292
  status: Type.Optional(
248
293
  Type.Enum(["pending", "in_progress", "done", "blocked"] as const, {
249
294
  type: "string",
@@ -342,30 +387,73 @@ export default function registerTodo(pi: ExtensionAPI): void {
342
387
  }
343
388
 
344
389
  case "update": {
345
- const t = todos.find((x) => x.id === params.id);
346
- if (!t) return fail(`No todo with id ${params.id}.`);
390
+ // Batch form (`updates`) is preferred one call closes many items.
391
+ // Single form (`id`+`status`/`text`) stays supported for renames.
392
+ let ops: TodoUpdateOp[];
393
+ if (params.updates) {
394
+ const parsed = parseUpdates(params.updates as string);
395
+ if ("error" in parsed) return fail(parsed.error);
396
+ ops = parsed.ops;
397
+ } else if (params.id !== undefined && params.status) {
398
+ ops = [{ id: params.id as number, status: params.status as TodoStatus }];
399
+ } else {
400
+ ops = [];
401
+ }
402
+
403
+ const missing = ops.filter((o) => !todos.some((t) => t.id === o.id)).map((o) => o.id);
404
+ if (missing.length) return fail(`No todo with id ${missing.join(", ")}.`);
405
+
406
+ // Text-only / no-op single update: keep the legacy tolerant path.
407
+ if (!ops.length) {
408
+ const t = todos.find((x) => x.id === params.id);
409
+ if (!t) return fail(`No todo with id ${params.id}.`);
410
+ if (params.text) {
411
+ t.text = params.text;
412
+ persistTodos();
413
+ }
414
+ return ok(`#${t.id} ${t.status} · ${todoHint()}`);
415
+ }
416
+
417
+ const autoClosed = new Set<number>();
347
418
  let skipWarning = "";
348
- if (params.status) {
419
+ for (const op of ops) {
420
+ const t = todos.find((x) => x.id === op.id) as TodoItem;
349
421
  // Sequential-progress invariant (ordered lists only): opening a task
350
422
  // means everything before it is finished. Cascade-close every earlier
351
423
  // pending or in_progress item so the model never has to mark skipped
352
424
  // steps done by hand. `blocked` is left untouched. Unordered lists
353
425
  // treat each item independently — no cascade, no skip warning.
354
- if (ordered && params.status === "in_progress")
426
+ if (ordered && op.status === "in_progress")
355
427
  for (const other of todos)
356
428
  if (
357
429
  other.id < t.id &&
358
430
  (other.status === "pending" || other.status === "in_progress")
359
- )
431
+ ) {
360
432
  other.status = "done";
433
+ autoClosed.add(other.id);
434
+ }
361
435
 
362
- if (ordered && params.status === "done") skipWarning = buildSkipWarning(todos, t.id);
363
-
364
- t.status = params.status;
436
+ // Only the last op's skip state matters earlier ops in the same
437
+ // batch may legitimately still be settling.
438
+ skipWarning = ordered && op.status === "done" ? buildSkipWarning(todos, t.id) : "";
439
+ t.status = op.status;
440
+ }
441
+ // Text rename only applies to a single-target update.
442
+ const only = ops.length === 1 ? ops[0] : undefined;
443
+ if (params.text && only) {
444
+ const t = todos.find((x) => x.id === only.id) as TodoItem;
445
+ t.text = params.text;
365
446
  }
366
- if (params.text) t.text = params.text;
367
447
  persistTodos();
368
- return ok(todoSummary() + skipWarning);
448
+
449
+ // Delta-only echo: the model already holds the list; re-sending it on
450
+ // every update is pure token waste. The TUI card still renders the
451
+ // full checklist from `details.snapshot`.
452
+ const applied = ops.map((o) => `#${o.id} ${o.status}`).join(", ");
453
+ const auto = autoClosed.size
454
+ ? ` (auto-done ${[...autoClosed].map((i) => `#${i}`).join(",")})`
455
+ : "";
456
+ return ok(`${applied}${auto} · ${todoHint()}${skipWarning}`);
369
457
  }
370
458
 
371
459
  case "clear":