@getpipher/armory-todo 0.2.0 → 0.3.1

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.
@@ -27,38 +27,77 @@ import {
27
27
  completeTodo,
28
28
  deleteTodo,
29
29
  clearTodos,
30
+ getTodo,
30
31
  listTodos,
31
32
  renderOpenBlock,
32
33
  updateTodo,
33
34
  parkTodo,
34
35
  getStorePath,
35
36
  } from "../src/todo-store";
36
- import { pruneTodos, restoreTodo, listArchived, archiveSummary } from "../src/archive";
37
+ import { pruneTodos, restoreTodo, listArchived, archiveSummary, listDoneUnified } from "../src/archive";
37
38
  import { healthReport } from "../src/health";
38
39
  import { hardPrune } from "../src/hard-prune";
39
40
  import { TodoPanel } from "../src/panel";
41
+ import { autoPruneOnSessionStart } from "../src/auto-prune";
42
+ import { loadConfig } from "../src/config";
40
43
 
41
- const ACTIONS = ["list", "add", "update", "complete", "delete", "clear", "park", "prune", "restore", "health"] as const;
44
+ const ACTIONS = ["list", "add", "update", "get", "complete", "delete", "clear", "park", "prune", "restore", "health"] as const;
42
45
 
43
46
  function fmt(t: ReturnType<typeof listTodos>[number]): string {
44
47
  const tag = t.project ? ` (${t.project})` : "";
45
48
  const pins = t.tags.length ? ` #${t.tags.join(" #")}` : "";
46
- return `- [${t.id}] (${t.priority}/${t.status}) ${t.text}${tag}${pins}`;
49
+ const dot = t.notes.trim() ? " •" : "";
50
+ return `- [${t.id}] (${t.priority}/${t.status})${dot} ${t.title}${tag}${pins}`;
51
+ }
52
+
53
+ function fmtFull(t: ReturnType<typeof getTodo>): string {
54
+ const tag = t.project ? ` (${t.project})` : "";
55
+ const tags = t.tags.length ? ` #${t.tags.join(" #")}` : "";
56
+ return [
57
+ `${t.id} [${t.priority}/${t.status}] ${t.title}${tag}${tags}`,
58
+ `created: ${t.createdAt}`,
59
+ `updated: ${t.updatedAt}`,
60
+ `closed: ${t.closedAt ?? "(open)"}`,
61
+ `source: ${t.source || "(none)"}`,
62
+ "",
63
+ "notes:",
64
+ t.notes || "(empty)",
65
+ ].join("\n");
66
+ }
67
+
68
+ function fmtDone(d: ReturnType<typeof listDoneUnified>[number]): string {
69
+ const tag = d.project ? ` (${d.project})` : "";
70
+ const loc = d.location === "archive" && d.archivedAt
71
+ ? ` [archived ${d.archivedAt.slice(0, 10)}]`
72
+ : ` [live ${d.closedAt ? Math.floor((Date.now() - Date.parse(d.closedAt)) / 86400_000) : 0}d]`;
73
+ return `- [${d.id}] (done)${tag} ${d.title}${loc}`;
47
74
  }
48
75
 
49
76
  export default function (pi: ExtensionAPI) {
50
77
  // Warm + report on session start (every new/resume/fork/reload).
51
78
  pi.on("session_start", async (_event, ctx) => {
52
79
  try {
80
+ let autoMsg = "";
81
+ let ageDays = 7;
82
+ try {
83
+ ageDays = loadConfig().prune.defaultAgeDays;
84
+ const ap = autoPruneOnSessionStart();
85
+ if (ap) {
86
+ const lines = ap.items.map((i) => ` [${i.id}] ${i.status} ${i.title}`);
87
+ autoMsg = ` · auto-pruned ${ap.moved} stale done (>${ageDays}d):\n${lines.join("\n")}\nUndo any with: todo restore <id>`;
88
+ }
89
+ } catch {
90
+ // auto-prune optional — don't crash the session notify
91
+ }
53
92
  const open = listTodos();
54
- let msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}`;
93
+ let msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}${autoMsg}`;
55
94
  try {
56
95
  const report = healthReport();
57
96
  if (report.flags.length > 0) {
58
- msg += ` ${report.flags.length} bloat signal${report.flags.length === 1 ? "" : "s"} (run /todo health)`;
97
+ msg += `${autoMsg ? "\n" : " "}` + `⚠ ${report.flags.length} bloat signal${report.flags.length === 1 ? "" : "s"} (run /todo health)`;
59
98
  }
60
99
  } catch {
61
- // health check optional — don't crash the session notify
100
+ // health check optional
62
101
  }
63
102
  if (ctx.hasUI) ctx.ui.notify(msg, "info");
64
103
  } catch {
@@ -92,20 +131,25 @@ export default function (pi: ExtensionAPI) {
92
131
  "Never put secrets in a TODO — the text reaches the model provider.",
93
132
  promptSnippet: "Read/update the global cross-session TODO list (active / parked / archive) + bloat health",
94
133
  promptGuidelines: [
95
- "Use todo (action:'list') when the user asks 'show me the TODO' / 'what's pending'.",
96
- "Use todo (action:'add', text, project?, tags?, priority?, source?) when the user says 'put this in our TODO'.",
134
+ "Use todo (action:'add', title, notes?, project?, tags?, priority?, source?) when the user says 'put this in our TODO'. title max 120 chars (one-line summary); put long detail in notes.",
135
+ "Use todo (action:'get', id) to read a todo's full notes before acting on it (the bullet marker in lists means notes exist).",
136
+ "Use todo (action:'update', id, title?, notes?, project?, tags?, priority?, status?) to edit; notes empty string clears.",
137
+ "Use todo (action:'list') when the user asks 'show me the TODO' / 'what's pending' (text filter searches title+notes).",
97
138
  "Use todo (action:'complete', id) to mark a TODO done; (action:'delete', id) to cancel it.",
98
139
  "Use todo (action:'park', id) to defer a TODO (not injected, recoverable); (action:'update', id, status:'open') to un-park.",
99
140
  "Use todo (action:'prune') to move done/cancelled todos to the archive (reversible); (action:'prune', all:true) to prune all regardless of age.",
141
+ "Done/cancelled todos older than the prune age (default 7d) auto-archive on session start — you'll see a notify; reversible via todo restore <id>. Use /todo finished or todo list status:'done' to see all finished work (live + archived).",
100
142
  "Use todo (action:'restore', id) to bring an archived TODO back as open.",
101
- "Use todo (action:'list', archived:true) to query the archive bare call returns a summary; add a filter (project/text/since) for specific items.",
102
- "Use todo (action:'health') to check bloat across all boxes — returns counts + flags + suggestions. Run this when the user asks about hygiene/bloat or before any hard-prune.",
103
- "Use todo (action:'prune', hard:true, confirm:true, box?, olderThan?) for PERMANENT deletion the only irreversible action. ALWAYS: run `health` first, show the user the report + the exact proposed command, and wait for an explicit 'yes' before passing confirm:true. Never hard-prune without explicit user confirmation.",
143
+ "Use todo (action:'list', archived:true) to query the archive; bare call returns a summary, add a filter (project/text/since) for specific items.",
144
+ "Use todo (action:'health') to check bloat across all boxes (counts + flags + suggestions). Run when the user asks about hygiene/bloat or before any hard-prune.",
145
+ "Use todo (action:'prune', hard:true, confirm:true, box?, olderThan?) for PERMANENT deletion (the only irreversible action). ALWAYS run health first, show the user the report + the exact proposed command, and wait for an explicit yes before passing confirm:true. Never hard-prune without explicit user confirmation.",
104
146
  ],
105
147
  parameters: Type.Object({
106
148
  action: StringEnum(ACTIONS),
107
- id: Type.Optional(Type.String({ description: "Todo id (for update/complete/delete/park/restore)" })),
108
- text: Type.Optional(Type.String({ description: "Todo text (add) or new text (update); or substring search (list)" })),
149
+ id: Type.Optional(Type.String({ description: "Todo id (for update/complete/delete/park/restore/get)" })),
150
+ title: Type.Optional(Type.String({ description: "Todo title (add required; update optional). Max 120 chars; put detail in notes." })),
151
+ notes: Type.Optional(Type.String({ description: "Todo notes/body (add/update optional; long-form, not injected). Pass empty string on update to clear." })),
152
+ text: Type.Optional(Type.String({ description: "Search query (list only). Substring match on title OR notes. Not used by add/update." })),
109
153
  project: Type.Optional(Type.String({ description: "Project tag, e.g. 'pi', 'sip', or '' for global" })),
110
154
  tags: Type.Optional(Type.Array(Type.String())),
111
155
  priority: Type.Optional(StringEnum(["low", "med", "high", "critical"] as const)),
@@ -132,6 +176,20 @@ export default function (pi: ExtensionAPI) {
132
176
  try {
133
177
  switch (params.action) {
134
178
  case "list": {
179
+ if (params.status === "done" && !params.archived) {
180
+ const items = listDoneUnified({
181
+ text: params.text,
182
+ project: params.projectFilter,
183
+ since: params.since,
184
+ before: params.before,
185
+ limit: params.limit,
186
+ page: params.page,
187
+ });
188
+ if (items.length === 0) {
189
+ return { content: [{ type: "text" as const, text: "No done TODOs (live or archive)." }] };
190
+ }
191
+ return { content: [{ type: "text" as const, text: `Done (${items.length}):\n${items.map(fmtDone).join("\n")}` }] };
192
+ }
135
193
  if (params.archived) {
136
194
  const res = listArchived({
137
195
  project: params.projectFilter,
@@ -173,43 +231,50 @@ export default function (pi: ExtensionAPI) {
173
231
  return { content: [{ type: "text" as const, text: todos.map(fmt).join("\n") }] };
174
232
  }
175
233
  case "add": {
176
- if (!params.text) {
177
- return { content: [{ type: "text" as const, text: "Error: `text` is required for add." }] };
234
+ if (!params.title) {
235
+ return { content: [{ type: "text" as const, text: "Error: `title` is required for add." }] };
178
236
  }
179
237
  const t = addTodo({
180
- text: params.text,
238
+ title: params.title,
239
+ notes: params.notes,
181
240
  project: params.project,
182
241
  tags: params.tags,
183
242
  priority: params.priority as any,
184
243
  source: params.source as any,
185
244
  });
186
- return { content: [{ type: "text" as const, text: `Added ${t.id}: ${t.text}` }] };
245
+ return { content: [{ type: "text" as const, text: `Added ${t.id}: ${t.title}` }] };
187
246
  }
188
247
  case "update": {
189
248
  if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for update." }] };
190
249
  const t = updateTodo(params.id, {
191
- text: params.text,
250
+ title: params.title,
251
+ notes: params.notes,
192
252
  project: params.project,
193
253
  tags: params.tags,
194
254
  priority: params.priority as any,
195
255
  status: params.status as any,
196
256
  });
197
- return { content: [{ type: "text" as const, text: `Updated ${t.id}: ${t.text} [${t.status}]` }] };
257
+ return { content: [{ type: "text" as const, text: `Updated ${t.id}: ${t.title} [${t.status}]` }] };
258
+ }
259
+ case "get": {
260
+ if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for get." }] };
261
+ const t = getTodo(params.id);
262
+ return { content: [{ type: "text" as const, text: fmtFull(t) }] };
198
263
  }
199
264
  case "complete": {
200
265
  if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for complete." }] };
201
266
  const t = completeTodo(params.id);
202
- return { content: [{ type: "text" as const, text: `Completed ${t.id}: ${t.text}` }] };
267
+ return { content: [{ type: "text" as const, text: `Completed ${t.id}: ${t.title}` }] };
203
268
  }
204
269
  case "delete": {
205
270
  if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for delete." }] };
206
271
  const t = deleteTodo(params.id);
207
- return { content: [{ type: "text" as const, text: `Cancelled ${t.id}: ${t.text}` }] };
272
+ return { content: [{ type: "text" as const, text: `Cancelled ${t.id}: ${t.title}` }] };
208
273
  }
209
274
  case "park": {
210
275
  if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for park." }] };
211
276
  const t = parkTodo(params.id);
212
- return { content: [{ type: "text" as const, text: `Parked ${t.id}: ${t.text}` }] };
277
+ return { content: [{ type: "text" as const, text: `Parked ${t.id}: ${t.title}` }] };
213
278
  }
214
279
  case "prune": {
215
280
  if (params.hard) {
@@ -223,7 +288,11 @@ export default function (pi: ExtensionAPI) {
223
288
  return { content: [{ type: "text" as const, text: res.message + (res.refused ? "" : ` Deleted: ${res.ids.join(", ") || "(none)"}`) }] };
224
289
  }
225
290
  const res = pruneTodos({ ageDays: params.ageDays, all: params.all });
226
- return { content: [{ type: "text" as const, text: `Pruned ${res.moved} todo${res.moved === 1 ? "" : "s"} to archive: ${res.ids.join(", ") || "(none)"}` }] };
291
+ if (res.moved === 0) {
292
+ return { content: [{ type: "text" as const, text: "Nothing to prune (no stale done/cancelled)." }] };
293
+ }
294
+ const prunedLines = res.items.map((i) => ` [${i.id}] ${i.status} ${i.title} (was ${i.ageDays}d old)`);
295
+ return { content: [{ type: "text" as const, text: `Pruned ${res.moved} todo${res.moved === 1 ? "" : "s"} to archive:\n${prunedLines.join("\n")}\nUndo any with: todo restore <id>` }] };
227
296
  }
228
297
  case "health": {
229
298
  const report = healthReport();
@@ -232,6 +301,7 @@ export default function (pi: ExtensionAPI) {
232
301
  `active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
233
302
  `parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
234
303
  `archive: ${report.archive.count} (${report.archive.older_180d} old)`,
304
+ `notes: ${report.notesBytes.total}B total · max ${report.notesBytes.max}B · avg ${report.notesBytes.avg}B`,
235
305
  report.flags.length ? `flags: ${report.flags.join(", ")}` : "flags: (none — healthy)",
236
306
  ...report.suggestions.map((s) => ` → ${s}`),
237
307
  ];
@@ -240,7 +310,7 @@ export default function (pi: ExtensionAPI) {
240
310
  case "restore": {
241
311
  if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for restore." }] };
242
312
  const t = restoreTodo(params.id);
243
- return { content: [{ type: "text" as const, text: `Restored ${t.id}: ${t.text} [open]` }] };
313
+ return { content: [{ type: "text" as const, text: `Restored ${t.id}: ${t.title} [open]` }] };
244
314
  }
245
315
  case "clear": {
246
316
  const n = clearTodos((params.status as any) ?? "done");
@@ -259,9 +329,9 @@ export default function (pi: ExtensionAPI) {
259
329
  pi.registerCommand("todo", {
260
330
  description:
261
331
  "Global cross-session TODO list. " +
262
- "/todo · /todo all · /todo add <text> · /todo done <id> · /todo rm <id> · " +
263
- "/todo park <id> · /todo restore <id> · /todo prune [--all|--hard --box <b> --older-than <d>] · " +
264
- "/todo archive [project:X|text:Y] · /todo health · /todo clean · /todo path",
332
+ "/todo / /todo all / /todo add <title> / /todo done <id> / /todo rm <id> / " +
333
+ "/todo park <id> / /todo restore <id> / /todo prune [--all|--hard --box <b> --older-than <d>] / " +
334
+ "/todo archive [project:X|text:Y] / /todo finished / /todo health / /todo clean / /todo path",
265
335
  handler: async (args, ctx) => {
266
336
  const a = (args ?? "").trim();
267
337
  const [sub, ...rest] = a.split(/\s+/);
@@ -273,10 +343,10 @@ export default function (pi: ExtensionAPI) {
273
343
  return;
274
344
  }
275
345
  if (sub === "add") {
276
- const text = rest.join(" ").trim();
277
- if (!text) { if (ctx.hasUI) ctx.ui.notify("usage: /todo add <text>", "warning"); return; }
278
- const t = addTodo({ text, source: "slash" });
279
- if (ctx.hasUI) ctx.ui.notify(`Added ${t.id}: ${t.text}`, "info");
346
+ const title = rest.join(" ").trim();
347
+ if (!title) { if (ctx.hasUI) ctx.ui.notify("usage: /todo add <title> (notes via the todo tool)", "warning"); return; }
348
+ const t = addTodo({ title, source: "slash" });
349
+ if (ctx.hasUI) ctx.ui.notify(`Added ${t.id}: ${t.title}`, "info");
280
350
  return;
281
351
  }
282
352
  if (sub === "done") {
@@ -297,14 +367,14 @@ export default function (pi: ExtensionAPI) {
297
367
  const id = rest[0];
298
368
  if (!id) { if (ctx.hasUI) ctx.ui.notify("usage: /todo park <id>", "warning"); return; }
299
369
  const t = parkTodo(id);
300
- if (ctx.hasUI) ctx.ui.notify(`Parked ${t.id}: ${t.text}`, "info");
370
+ if (ctx.hasUI) ctx.ui.notify(`Parked ${t.id}: ${t.title}`, "info");
301
371
  return;
302
372
  }
303
373
  if (sub === "restore") {
304
374
  const id = rest[0];
305
375
  if (!id) { if (ctx.hasUI) ctx.ui.notify("usage: /todo restore <id>", "warning"); return; }
306
376
  const t = restoreTodo(id);
307
- if (ctx.hasUI) ctx.ui.notify(`Restored ${t.id}: ${t.text}`, "info");
377
+ if (ctx.hasUI) ctx.ui.notify(`Restored ${t.id}: ${t.title}`, "info");
308
378
  return;
309
379
  }
310
380
  if (sub === "prune") {
@@ -330,7 +400,12 @@ export default function (pi: ExtensionAPI) {
330
400
  }
331
401
  const all = rest.includes("--all");
332
402
  const res = pruneTodos({ all });
333
- if (ctx.hasUI) ctx.ui.notify(`Pruned ${res.moved} todo${res.moved === 1 ? "" : "s"} to archive: ${res.ids.join(", ") || "(none)"}`, "info");
403
+ if (ctx.hasUI) {
404
+ const msg = res.moved === 0
405
+ ? "Nothing to prune."
406
+ : `Pruned ${res.moved} to archive:\n${res.items.map((i) => ` [${i.id}] ${i.title} (${i.ageDays}d)`).join("\n")}\nUndo: todo restore <id>`;
407
+ ctx.ui.notify(msg, "info");
408
+ }
334
409
  return;
335
410
  }
336
411
  if (sub === "health") {
@@ -340,6 +415,7 @@ export default function (pi: ExtensionAPI) {
340
415
  ` active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
341
416
  ` parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
342
417
  ` archive: ${report.archive.count} (${report.archive.older_180d} old)`,
418
+ ` notes: ${report.notesBytes.total}B total · max ${report.notesBytes.max}B · avg ${report.notesBytes.avg}B`,
343
419
  report.flags.length ? ` ⚠ ${report.flags.join(", ")}` : " ✅ healthy",
344
420
  ...report.suggestions.map((s) => ` → ${s}`),
345
421
  ];
@@ -372,6 +448,12 @@ export default function (pi: ExtensionAPI) {
372
448
  if (ctx.hasUI) ctx.ui.notify(`Archived (${res.total} total):\n${msg}`, "info");
373
449
  return;
374
450
  }
451
+ if (sub === "finished") {
452
+ const items = listDoneUnified({ text: rest.join(" ").trim() || undefined, limit: 100 });
453
+ const msg = items.length ? `Done (${items.length}):\n${items.map(fmtDone).join("\n")}` : "(no done TODOs)";
454
+ if (ctx.hasUI) ctx.ui.notify(msg, "info");
455
+ return;
456
+ }
375
457
  if (sub === "clean") {
376
458
  const n = clearTodos("done");
377
459
  if (ctx.hasUI) ctx.ui.notify(`Cleared ${n} done TODOs.`, "info");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-todo",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Global, cross-session TODO for pi — persists across all sessions and is auto-injected into every prompt. The disk-backed counterpart to branch-scoped pi todo extensions.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -35,7 +35,7 @@
35
35
  ]
36
36
  },
37
37
  "scripts": {
38
- "test": "for t in todo-store todo-archive todo-config todo-migrate todo-health todo-hard-prune panel-data; do node test/$t.test.mts || exit 1; done"
38
+ "test": "for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-prune todo-auto-prune panel-data; do node test/$t.test.mts || exit 1; done"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@earendil-works/pi-ai": "*",
package/src/archive.ts CHANGED
@@ -8,12 +8,13 @@
8
8
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
9
  import { dirname } from "node:path";
10
10
  import { getArchivePath } from "./paths.ts";
11
+ import { migrateV2ToV3 } from "./migrate.ts";
11
12
  import type { Todo } from "./todo-store.ts";
12
13
  import { loadConfig } from "./config.ts";
13
14
  import { loadStore, saveStore, TodoError } from "./todo-store.ts";
14
15
 
15
16
  export interface ArchiveStore {
16
- version: 2;
17
+ version: 3;
17
18
  updatedAt: string;
18
19
  todos: Todo[];
19
20
  }
@@ -23,7 +24,7 @@ function now(): string {
23
24
  }
24
25
 
25
26
  function emptyArchive(): ArchiveStore {
26
- return { version: 2, updatedAt: now(), todos: [] };
27
+ return { version: 3, updatedAt: now(), todos: [] };
27
28
  }
28
29
 
29
30
  /** Load the archive. Missing file → empty store (no file created). */
@@ -36,6 +37,15 @@ export function loadArchive(): ArchiveStore {
36
37
  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.todos)) {
37
38
  throw new Error("invalid archive shape");
38
39
  }
40
+ if (parsed.version === 2) {
41
+ // v2 → v3: curated + fallback, persist once (symmetric with the live store).
42
+ const migrated = migrateV2ToV3(parsed as any) as unknown as ArchiveStore;
43
+ saveArchive(migrated);
44
+ return migrated;
45
+ }
46
+ if (parsed.version !== 3) {
47
+ throw new Error("invalid archive shape");
48
+ }
39
49
  return parsed;
40
50
  } catch {
41
51
  try {
@@ -69,9 +79,17 @@ export interface PruneInput {
69
79
  statuses?: ("done" | "cancelled")[];
70
80
  }
71
81
 
82
+ export interface PruneItem {
83
+ id: string;
84
+ status: "done" | "cancelled";
85
+ title: string;
86
+ ageDays: number;
87
+ }
88
+
72
89
  export interface PruneResult {
73
90
  moved: number;
74
91
  ids: string[];
92
+ items: PruneItem[];
75
93
  }
76
94
 
77
95
  /**
@@ -108,14 +126,20 @@ export function pruneTodos(opts: PruneInput = {}): PruneResult {
108
126
  moved.push(todo);
109
127
  }
110
128
 
111
- if (moved.length === 0) return { moved: 0, ids: [] };
129
+ if (moved.length === 0) return { moved: 0, ids: [], items: [] };
112
130
 
113
131
  live.todos = kept;
114
132
  archive.todos.push(...moved);
115
133
  saveStore(live);
116
134
  saveArchive(archive);
117
135
 
118
- return { moved: moved.length, ids: moved.map((t) => t.id) };
136
+ const items: PruneItem[] = moved.map((t) => ({
137
+ id: t.id,
138
+ status: t.status as "done" | "cancelled",
139
+ title: t.title,
140
+ ageDays: t.closedAt ? Math.floor((Date.now() - Date.parse(t.closedAt)) / 86400_000) : 0,
141
+ }));
142
+ return { moved: moved.length, ids: moved.map((t) => t.id), items };
119
143
  }
120
144
 
121
145
  /**
@@ -190,7 +214,7 @@ export function listArchived(filter: ArchiveListFilter = {}): ArchiveListResult
190
214
  if (filter.status) out = out.filter((t) => t.status === filter.status);
191
215
  if (filter.text) {
192
216
  const q = filter.text.toLowerCase();
193
- out = out.filter((t) => t.text.toLowerCase().includes(q));
217
+ out = out.filter((t) => t.title.toLowerCase().includes(q) || t.notes.toLowerCase().includes(q));
194
218
  }
195
219
  if (filter.since) out = out.filter((t) => (t.closedAt ?? t.updatedAt) >= (filter.since as string));
196
220
  if (filter.before) out = out.filter((t) => (t.closedAt ?? t.updatedAt) < (filter.before as string));
@@ -201,4 +225,42 @@ export function listArchived(filter: ArchiveListFilter = {}): ArchiveListResult
201
225
  const page = filter.page ?? 1;
202
226
  const start = (page - 1) * limit;
203
227
  return { items: sorted.slice(start, start + limit), total };
204
- }
228
+ }
229
+
230
+ export interface DoneItem extends Todo {
231
+ location: "live" | "archive";
232
+ archivedAt: string | null;
233
+ }
234
+
235
+ export interface DoneFilter {
236
+ text?: string; // title OR notes substring (case-insensitive)
237
+ project?: string;
238
+ since?: string; // closedAt >= since
239
+ before?: string; // closedAt < before
240
+ limit?: number; // default 50
241
+ page?: number; // default 1
242
+ }
243
+
244
+ /** Unified done todos across the live store + the archive. Excludes cancelled
245
+ * (Done = finished work). Sorted newest-closed first. */
246
+ export function listDoneUnified(filter: DoneFilter = {}): DoneItem[] {
247
+ const live = loadStore().todos.filter((t) => t.status === "done");
248
+ const arch = loadArchive().todos.filter((t) => t.status === "done");
249
+ const items: DoneItem[] = [
250
+ ...live.map((t) => ({ ...t, location: "live" as const, archivedAt: null })),
251
+ ...arch.map((t) => ({ ...t, location: "archive" as const, archivedAt: t.closedAt })),
252
+ ];
253
+ let out = items;
254
+ if (filter.text) {
255
+ const q = filter.text.toLowerCase();
256
+ out = out.filter((t) => t.title.toLowerCase().includes(q) || t.notes.toLowerCase().includes(q));
257
+ }
258
+ if (filter.project) out = out.filter((t) => t.project === filter.project);
259
+ if (filter.since) out = out.filter((t) => (t.closedAt ?? t.updatedAt) >= (filter.since as string));
260
+ if (filter.before) out = out.filter((t) => (t.closedAt ?? t.updatedAt) < (filter.before as string));
261
+ const sorted = out.slice().sort((a, b) => (b.closedAt ?? b.updatedAt).localeCompare(a.closedAt ?? a.updatedAt));
262
+ const limit = filter.limit ?? 50;
263
+ const page = filter.page ?? 1;
264
+ const start = (page - 1) * limit;
265
+ return sorted.slice(start, start + limit);
266
+ }
@@ -0,0 +1,15 @@
1
+ // Auto-prune on session_start — the deterministic age-gated prune that runs
2
+ // when the extension loads. Wraps pruneTodos with the config default age; never
3
+ // --all (fresh done <defaultAgeDays stays). Returns the rich PruneResult if
4
+ // anything moved, else null (caller stays silent). Reversible via restore.
5
+
6
+ import { pruneTodos, type PruneResult } from "./archive.ts";
7
+ import { loadConfig } from "./config.ts";
8
+
9
+ /** Prune stale done/cancelled (older than config.prune.defaultAgeDays) on
10
+ * session start. Returns the PruneResult if anything moved, else null. */
11
+ export function autoPruneOnSessionStart(): PruneResult | null {
12
+ const config = loadConfig();
13
+ const res = pruneTodos({ ageDays: config.prune.defaultAgeDays });
14
+ return res.moved > 0 ? res : null;
15
+ }
package/src/health.ts CHANGED
@@ -23,6 +23,12 @@ export interface ArchiveHealth {
23
23
  older_180d: number; // closedAt older than archiveOldDays
24
24
  }
25
25
 
26
+ export interface NotesBytes {
27
+ total: number;
28
+ max: number;
29
+ avg: number;
30
+ }
31
+
26
32
  export type HealthFlag =
27
33
  | "ACTIVE_LARGE" | "ACTIVE_STALE"
28
34
  | "PARKED_LARGE" | "PARKED_STALE"
@@ -32,6 +38,7 @@ export interface HealthReport {
32
38
  active: ActiveHealth;
33
39
  parked: ParkedHealth;
34
40
  archive: ArchiveHealth;
41
+ notesBytes: NotesBytes;
35
42
  flags: HealthFlag[];
36
43
  suggestions: string[];
37
44
  }
@@ -55,6 +62,15 @@ export function healthReport(): HealthReport {
55
62
  const parkedStale = parkedTodos.filter((t) => daysAgo(t.updatedAt) > h.parkedStaleDays).length;
56
63
  const archiveOld = archive.todos.filter((t) => t.closedAt && daysAgo(t.closedAt) > h.archiveOldDays).length;
57
64
 
65
+ // notes bytes across active + parked (archived excluded — sealed history).
66
+ const apTodos = [...openTodos, ...ipTodos, ...parkedTodos];
67
+ const notesSizes = apTodos.map((t) => Buffer.byteLength(t.notes, "utf8"));
68
+ const notesBytes: NotesBytes = {
69
+ total: notesSizes.reduce((a, b) => a + b, 0),
70
+ max: notesSizes.length ? Math.max(...notesSizes) : 0,
71
+ avg: notesSizes.length ? Math.round(notesSizes.reduce((a, b) => a + b, 0) / notesSizes.length) : 0,
72
+ };
73
+
58
74
  const active: ActiveHealth = {
59
75
  open: openTodos.length,
60
76
  in_progress: ipTodos.length,
@@ -77,5 +93,5 @@ export function healthReport(): HealthReport {
77
93
  if (parkedStale > 0) suggestions.push(`parked: ${parkedStale} parked > ${h.parkedStaleDays}d → restore or hard-prune`);
78
94
  if (actionable.length > h.activeMaxOpen) suggestions.push(`active: ${actionable.length} open+in_progress (max ${h.activeMaxOpen}) → close or park some before adding more`);
79
95
 
80
- return { active, parked, archive: arch, flags, suggestions };
96
+ return { active, parked, archive: arch, notesBytes, flags, suggestions };
81
97
  }
package/src/migrate.ts CHANGED
@@ -10,6 +10,36 @@
10
10
  import { copyFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
 
13
+ // v2 → v3 schema migration helpers. splitTextFallback is used by loadStore's
14
+ // inline derivation (Task 1) and by migrateV2ToV3 (Task 2, with the curated
15
+ // map). TITLE_MAX here must match the constant in todo-store.ts.
16
+ const TITLE_MAX = 120;
17
+
18
+ /** Truncate at the last word boundary ≤ TITLE_MAX (hard cut if none). No "…"
19
+ * suffix — the cap is a hard rule, not a display truncation. */
20
+ function truncateWordBoundary(s: string): string {
21
+ if (s.length <= TITLE_MAX) return s;
22
+ const slice = s.slice(0, TITLE_MAX);
23
+ const sp = slice.lastIndexOf(" ");
24
+ return sp > 0 ? slice.slice(0, sp) : slice;
25
+ }
26
+
27
+ /** Derive { title, notes } from a v2 `text` string (the fallback for any v2
28
+ * todo not in the curated map). Deterministic + idempotent. */
29
+ export function splitTextFallback(text: string): { title: string; notes: string } {
30
+ const raw = (text ?? "").trim();
31
+ if (!raw) return { title: "(untitled)", notes: "" };
32
+ const nl = raw.indexOf("\n");
33
+ if (nl < 0) {
34
+ if (raw.length <= TITLE_MAX) return { title: raw, notes: "" };
35
+ return { title: truncateWordBoundary(raw), notes: raw };
36
+ }
37
+ const firstLine = raw.slice(0, nl).trim();
38
+ const rest = raw.slice(nl + 1).trim();
39
+ if (firstLine.length <= TITLE_MAX) return { title: firstLine, notes: rest };
40
+ return { title: truncateWordBoundary(firstLine), notes: `${firstLine}\n${rest}` };
41
+ }
42
+
13
43
  export interface MigrateInput {
14
44
  /** The v2 folder (e.g. ~/.pi/agent/todo/). */
15
45
  todoDir: string;
@@ -42,4 +72,83 @@ export function migrateIfNeeded(input: MigrateInput): void {
42
72
  try { copyFileSync(backup, input.legacyPath); } catch { /* best-effort */ }
43
73
  throw new Error(`migration failed: could not move ${input.legacyPath} → ${target}`);
44
74
  }
45
- }
75
+ }
76
+ // v2 → v3 schema migration: each todo gains title + notes (curated for the
77
+ // 2 ids known at migration time; splitTextFallback for the rest), drops text.
78
+ // Pure — does not touch disk. Deterministic + idempotent on v2 input.
79
+ // (splitTextFallback + TITLE_MAX are defined above, alongside migrateIfNeeded.)
80
+
81
+ /** A v2 todo (has `text`, no `title`/`notes`). */
82
+ export interface V2Todo {
83
+ id: string;
84
+ text: string;
85
+ project: string;
86
+ tags: string[];
87
+ priority: string;
88
+ status: string;
89
+ source: string;
90
+ createdAt: string;
91
+ updatedAt: string;
92
+ closedAt: string | null;
93
+ }
94
+
95
+ /** v2 store shape (input to migrateV2ToV3). */
96
+ export interface V2Store {
97
+ version: 2;
98
+ updatedAt: string;
99
+ todos: V2Todo[];
100
+ }
101
+
102
+ // Hand-curated title + notes for the 2 todos known at v2→v3 migration time
103
+ // (the only survivors of the v0.2.0 incident). Any other v2 todo uses
104
+ // splitTextFallback. Curated notes are reformatted for clarity, not a
105
+ // mechanical split.
106
+ const CURATED_V2_TO_V3: Record<string, { title: string; notes: string }> = {
107
+ "td-mrt3zp9fcnug3p": {
108
+ title: "ZeroClaw×Solana bounty — Phase 4-5: demo video (score bottleneck, unstarted)",
109
+ notes: `superteam.fun/earn/listing/zeroclaw · Superteam Brasil · 5,000 USDG pool / 1st=1,800 · winner Aug 21 2026 · TARGET #1.
110
+
111
+ PHASE 0-2 DONE ✅. PHASE 3 (RESEARCH+SPEC+PLAN + impl alerts+custody+docs) DONE ✅ — slices A-F+H, 45 tests, committed 8fd7483→80614c8, PUSHED, PR #76 retitled "Palinurus — depin-attest + depin-rewards", 17 commits.
112
+
113
+ claim_tx (G) DEFERRED — Helium hotspots are cNFTs → claim needs distribute_compression_rewards_v0 + DAS get_asset_proof (merkle proof), multi-session; PDAs verified, design in README.
114
+
115
+ Decision (score-max): ship alerts core complete, pivot to DEMO track.
116
+
117
+ NEXT (★ Phase 4-5, the score bottleneck — submission REQUIRES a demo video, currently unstarted):
118
+ (1) ASYNC: RECTOR's free Relay Community key → real Helium fixtures + live smoke test;
119
+ (2) Phase 4: wiring SVG (docs/wiring-diagram.svg, dark-mode, NOT ASCII) + marketing site (palinurus.rectorspace.com, Next.js+Tailwind+shadcn) + demo recording guide;
120
+ (3) Phase 5: record demo ≤3min (real ZeroClaw+Telegram, terminal+phone) → ElevenLabs voiceover → ffmpeg → submit on Superteam Earn + engage #solana-bounty Discord.
121
+
122
+ Test totals: 184 (71 palinurus-core + 68 depin-attest + 45 depin-rewards), all clippy+wasm clean.
123
+ HANDOFF: ~/Documents/secret/strategy/zeroclaw-solana/session-handoff-2026-07-21.md
124
+ Docs: {RESEARCH-3,SPEC-3,PLAN-3}-depin-rewards.md (SPEC-3 §4 + PLAN-3 G corrected for cNFT)
125
+ Cwd: ~/local-dev/RECTOR-LABS/zeroclaw-plugins/plugins/depin-rewards
126
+ PR: https://github.com/zeroclaw-labs/zeroclaw-plugins/pull/76`,
127
+ },
128
+ "td-mrt4e1qi9td6jz": {
129
+ title: "armory-todo v0.2.0 — Workstream A shipped (lifecycle boxes + prune + health + TUI)",
130
+ notes: `ALL 3 SPECS DONE ✅. SPEC-1 (store: parked+prune+archive+restore, 12 tasks), SPEC-2 (health+hard-prune, 6 tasks), SPEC-3 (interactive /todo TUI panel, 4 tasks). 147/147 tests across 7 suites. 24 commits on feat/spec-1-lifecycle-boxes, PR #3 retitled to full v0.2.0 scope. Auto-publish CI (release.yml, org NPM_TOKEN).
131
+
132
+ INCIDENT (SPEC-1 Task 9): migration bug destroyed real 52KB/47-todo store (35 done + ~10 open lost, no backup). FIXED (c034509): migration guarded to only run when TODO_DIR is default. RECOVERED: 2 todos.
133
+
134
+ Shipped: merge PR #3 → tag v0.2.0 → CI auto-publish → npm:@getpipher/armory-todo@0.2.0.
135
+ Out of scope: B (title+notes split), C (preventive caps+project registry).`,
136
+ },
137
+ };
138
+
139
+ /** Transform a v2 store into a v3 store: each todo gains title + notes
140
+ * (curated for the 2 known ids, splitTextFallback for the rest), drops text.
141
+ * Pure — does not touch disk. Deterministic + idempotent on v2 input. */
142
+ export function migrateV2ToV3(store: V2Store): { version: 3; updatedAt: string; todos: any[] } {
143
+ const todos = store.todos.map((t) => {
144
+ const curated = CURATED_V2_TO_V3[t.id];
145
+ if (curated) {
146
+ const { text: _drop, ...rest } = t;
147
+ return { ...rest, title: curated.title, notes: curated.notes };
148
+ }
149
+ const { title, notes } = splitTextFallback(t.text ?? "");
150
+ const { text: _drop, ...rest } = t;
151
+ return { ...rest, title, notes };
152
+ });
153
+ return { version: 3, updatedAt: store.updatedAt, todos };
154
+ }