@getpipher/armory-todo 0.1.0 → 0.3.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.
@@ -3,13 +3,20 @@
3
3
  *
4
4
  * Unlike the existing pi todo extensions (which are conversation-branch-scoped:
5
5
  * they survive compaction/reload *within one session* via appendEntry), this
6
- * one is backed by a single disk file (~/.pi/agent/todo.json) so a TODO added
7
- * in session A is visible in any session B. It also auto-injects an "Open
8
- * TODOs" block into the system prompt on every before_agent_start, so a fresh
9
- * session is proactively aware of pending work instead of starting blind.
6
+ * one is backed by disk files under ~/.pi/agent/todo/ so a TODO added in
7
+ * session A is visible in any session B. It also auto-injects an "Open TODOs"
8
+ * block into the system prompt on every before_agent_start, so a fresh session
9
+ * is proactively aware of pending work instead of starting blind.
10
10
  *
11
- * Surface: `todo` tool (model CRUD), `/todo` slash command (human triage).
12
- * See docs/todo-SPEC.md for the design.
11
+ * Lifecycle boxes (v0.2.0):
12
+ * - active (open, in_progress) → auto-injected into the prompt
13
+ * - parked (parked) → NOT injected; one status flip from active
14
+ * - archive (done, cancelled) → NOT injected; sealed history in
15
+ * todo-archive.json, recoverable via restore
16
+ *
17
+ * Surface: `todo` tool (model CRUD + lifecycle), `/todo` slash command (human
18
+ * triage). See docs/superpowers/specs/2026-07-20-lifecycle-boxes-prune-design.md
19
+ * for the design.
13
20
  */
14
21
 
15
22
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -20,18 +27,40 @@ import {
20
27
  completeTodo,
21
28
  deleteTodo,
22
29
  clearTodos,
30
+ getTodo,
23
31
  listTodos,
24
32
  renderOpenBlock,
25
33
  updateTodo,
34
+ parkTodo,
26
35
  getStorePath,
27
36
  } from "../src/todo-store";
37
+ import { pruneTodos, restoreTodo, listArchived, archiveSummary } from "../src/archive";
38
+ import { healthReport } from "../src/health";
39
+ import { hardPrune } from "../src/hard-prune";
40
+ import { TodoPanel } from "../src/panel";
28
41
 
29
- const ACTIONS = ["list", "add", "update", "complete", "delete", "clear"] as const;
42
+ const ACTIONS = ["list", "add", "update", "get", "complete", "delete", "clear", "park", "prune", "restore", "health"] as const;
30
43
 
31
44
  function fmt(t: ReturnType<typeof listTodos>[number]): string {
32
45
  const tag = t.project ? ` (${t.project})` : "";
33
46
  const pins = t.tags.length ? ` #${t.tags.join(" #")}` : "";
34
- return `- [${t.id}] (${t.priority}/${t.status}) ${t.text}${tag}${pins}`;
47
+ const dot = t.notes.trim() ? " •" : "";
48
+ return `- [${t.id}] (${t.priority}/${t.status})${dot} ${t.title}${tag}${pins}`;
49
+ }
50
+
51
+ function fmtFull(t: ReturnType<typeof getTodo>): string {
52
+ const tag = t.project ? ` (${t.project})` : "";
53
+ const tags = t.tags.length ? ` #${t.tags.join(" #")}` : "";
54
+ return [
55
+ `${t.id} [${t.priority}/${t.status}] ${t.title}${tag}${tags}`,
56
+ `created: ${t.createdAt}`,
57
+ `updated: ${t.updatedAt}`,
58
+ `closed: ${t.closedAt ?? "(open)"}`,
59
+ `source: ${t.source || "(none)"}`,
60
+ "",
61
+ "notes:",
62
+ t.notes || "(empty)",
63
+ ].join("\n");
35
64
  }
36
65
 
37
66
  export default function (pi: ExtensionAPI) {
@@ -39,16 +68,24 @@ export default function (pi: ExtensionAPI) {
39
68
  pi.on("session_start", async (_event, ctx) => {
40
69
  try {
41
70
  const open = listTodos();
42
- if (ctx.hasUI) {
43
- ctx.ui.notify(`armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}`, "info");
71
+ let msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}`;
72
+ try {
73
+ const report = healthReport();
74
+ if (report.flags.length > 0) {
75
+ msg += ` — ⚠ ${report.flags.length} bloat signal${report.flags.length === 1 ? "" : "s"} (run /todo health)`;
76
+ }
77
+ } catch {
78
+ // health check optional — don't crash the session notify
44
79
  }
80
+ if (ctx.hasUI) ctx.ui.notify(msg, "info");
45
81
  } catch {
46
82
  // store unavailable — never crash the session
47
83
  }
48
84
  });
49
85
 
50
86
  // Auto-inject the open-TODO block into the system prompt every turn so the
51
- // agent is always aware of pending cross-session work.
87
+ // agent is always aware of pending cross-session work. Only open + in_progress
88
+ // are injected — parked and archived are excluded (the lifecycle-box boundary).
52
89
  pi.on("before_agent_start", async (event: any) => {
53
90
  try {
54
91
  const base = (event?.systemPrompt as string | undefined) ?? "";
@@ -65,36 +102,91 @@ export default function (pi: ExtensionAPI) {
65
102
  label: "TODO",
66
103
  description:
67
104
  "Global cross-session TODO store (persists across ALL pi sessions, not just this one). " +
68
- "Use when the user says 'put this in our TODO', 'show me the TODO', 'mark <id> done', etc. " +
69
- "Open TODOs are also auto-injected into your context each turn. " +
105
+ "Use when the user says 'put this in our TODO', 'show me the TODO', 'mark <id> done', 'park <id>', 'prune', 'restore <id>', 'how is my todo hygiene?', etc. " +
106
+ "Open TODOs are auto-injected each turn; parked todos are NOT injected (deferred/someday). " +
107
+ "Done/cancelled todos are moved to an archive by `prune` (reversible via `restore`). " +
108
+ "`prune --hard` (hard:true, confirm:true) is the ONLY irreversible action — always run `health` first, surface the report + proposed command, and wait for explicit user confirmation. " +
70
109
  "Never put secrets in a TODO — the text reaches the model provider.",
71
- promptSnippet: "Read/update the global cross-session TODO list",
110
+ promptSnippet: "Read/update the global cross-session TODO list (active / parked / archive) + bloat health",
72
111
  promptGuidelines: [
73
- "Use todo (action:'list') when the user asks 'show me the TODO' / 'what's pending'.",
74
- "Use todo (action:'add', text, project?, tags?, priority?, source?) when the user says 'put this in our TODO'.",
75
- "Use todo (action:'complete', id) to mark a TODO done, and (action:'update', id, …) to edit one.",
112
+ "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.",
113
+ "Use todo (action:'get', id) to read a todo's full notes before acting on it (the bullet marker in lists means notes exist).",
114
+ "Use todo (action:'update', id, title?, notes?, project?, tags?, priority?, status?) to edit; notes empty string clears.",
115
+ "Use todo (action:'list') when the user asks 'show me the TODO' / 'what's pending' (text filter searches title+notes).",
116
+ "Use todo (action:'complete', id) to mark a TODO done; (action:'delete', id) to cancel it.",
117
+ "Use todo (action:'park', id) to defer a TODO (not injected, recoverable); (action:'update', id, status:'open') to un-park.",
118
+ "Use todo (action:'prune') to move done/cancelled todos to the archive (reversible); (action:'prune', all:true) to prune all regardless of age.",
119
+ "Use todo (action:'restore', id) to bring an archived TODO back as open.",
120
+ "Use todo (action:'list', archived:true) to query the archive; bare call returns a summary, add a filter (project/text/since) for specific items.",
121
+ "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.",
122
+ "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.",
76
123
  ],
77
124
  parameters: Type.Object({
78
125
  action: StringEnum(ACTIONS),
79
- id: Type.Optional(Type.String({ description: "Todo id (for update/complete/delete)" })),
80
- text: Type.Optional(Type.String({ description: "Todo text (add) or new text (update)" })),
126
+ id: Type.Optional(Type.String({ description: "Todo id (for update/complete/delete/park/restore/get)" })),
127
+ title: Type.Optional(Type.String({ description: "Todo title (add required; update optional). Max 120 chars; put detail in notes." })),
128
+ notes: Type.Optional(Type.String({ description: "Todo notes/body (add/update optional; long-form, not injected). Pass empty string on update to clear." })),
129
+ text: Type.Optional(Type.String({ description: "Search query (list only). Substring match on title OR notes. Not used by add/update." })),
81
130
  project: Type.Optional(Type.String({ description: "Project tag, e.g. 'pi', 'sip', or '' for global" })),
82
131
  tags: Type.Optional(Type.Array(Type.String())),
83
132
  priority: Type.Optional(StringEnum(["low", "med", "high", "critical"] as const)),
84
- status: Type.Optional(StringEnum(["open", "in_progress", "done", "cancelled"] as const)),
133
+ status: Type.Optional(StringEnum(["open", "in_progress", "parked", "done", "cancelled"] as const)),
85
134
  // list filters
86
- statusFilter: Type.Optional(StringEnum(["open", "in_progress", "done", "cancelled", "all"] as const)),
135
+ statusFilter: Type.Optional(StringEnum(["open", "in_progress", "parked", "done", "cancelled", "all"] as const)),
87
136
  projectFilter: Type.Optional(Type.String()),
88
137
  tagFilter: Type.Optional(Type.String()),
138
+ archived: Type.Optional(Type.Boolean({ description: "If true, query the archive instead of the live store. Bare archived:true (no other filter) returns a summary." })),
139
+ since: Type.Optional(Type.String({ description: "ISO date filter (createdAt for live, closedAt for archive)" })),
140
+ before: Type.Optional(Type.String({ description: "ISO date filter (createdAt for live, closedAt for archive)" })),
141
+ limit: Type.Optional(Type.Number({ description: "Page size (default 20)" })),
142
+ page: Type.Optional(Type.Number({ description: "1-indexed page number (default 1)" })),
143
+ // prune options
144
+ ageDays: Type.Optional(Type.Number({ description: "prune: closedAt older than this many days (default from config)" })),
145
+ all: Type.Optional(Type.Boolean({ description: "prune: ignore age, move all done/cancelled" })),
146
+ // hard-prune options (SPEC-2)
147
+ hard: Type.Optional(Type.Boolean({ description: "prune: if true, execute a HARD prune (permanent deletion). Requires confirm:true. The only irreversible action." })),
148
+ confirm: Type.Optional(Type.Boolean({ description: "hard-prune: must be true to execute. Always surface the health report + proposed command and wait for explicit user confirmation first." })),
149
+ box: Type.Optional(StringEnum(["archive", "active", "parked"] as const, { description: "hard-prune: which box to target (default archive)" })),
150
+ olderThan: Type.Optional(Type.Number({ description: "hard-prune: delete items older than this many days (by closedAt for archive, updatedAt for active/parked)" })),
89
151
  }),
90
152
  async execute(_toolCallId, params) {
91
153
  try {
92
154
  switch (params.action) {
93
155
  case "list": {
156
+ if (params.archived) {
157
+ const res = listArchived({
158
+ project: params.projectFilter,
159
+ tag: params.tagFilter,
160
+ status: params.statusFilter as any,
161
+ text: params.text,
162
+ since: params.since,
163
+ before: params.before,
164
+ limit: params.limit,
165
+ page: params.page,
166
+ });
167
+ if (res.summary) {
168
+ const lines = [
169
+ `## Archive summary (${res.total} total)`,
170
+ "By project:",
171
+ ...Object.entries(res.summary.byProject).map(([p, n]) => ` ${p}: ${n}`),
172
+ "By month:",
173
+ ...Object.entries(res.summary.byMonth).map(([m, n]) => ` ${m}: ${n}`),
174
+ "Use a filter (project/tag/text/since/before) to list specific items.",
175
+ ];
176
+ return { content: [{ type: "text" as const, text: lines.join("\n") }] };
177
+ }
178
+ const lines = res.items.map(fmt);
179
+ return { content: [{ type: "text" as const, text: `Archived (${res.total} total, page ${params.page ?? 1}):\n${lines.join("\n")}` }] };
180
+ }
94
181
  const todos = listTodos({
95
182
  status: params.statusFilter as any,
96
183
  project: params.projectFilter,
97
184
  tag: params.tagFilter,
185
+ text: params.text,
186
+ since: params.since,
187
+ before: params.before,
188
+ limit: params.limit,
189
+ page: params.page,
98
190
  });
99
191
  if (todos.length === 0) {
100
192
  return { content: [{ type: "text" as const, text: "No matching TODOs." }] };
@@ -102,38 +194,82 @@ export default function (pi: ExtensionAPI) {
102
194
  return { content: [{ type: "text" as const, text: todos.map(fmt).join("\n") }] };
103
195
  }
104
196
  case "add": {
105
- if (!params.text) {
106
- return { content: [{ type: "text" as const, text: "Error: `text` is required for add." }] };
197
+ if (!params.title) {
198
+ return { content: [{ type: "text" as const, text: "Error: `title` is required for add." }] };
107
199
  }
108
200
  const t = addTodo({
109
- text: params.text,
201
+ title: params.title,
202
+ notes: params.notes,
110
203
  project: params.project,
111
204
  tags: params.tags,
112
205
  priority: params.priority as any,
113
206
  source: params.source as any,
114
207
  });
115
- return { content: [{ type: "text" as const, text: `Added ${t.id}: ${t.text}` }] };
208
+ return { content: [{ type: "text" as const, text: `Added ${t.id}: ${t.title}` }] };
116
209
  }
117
210
  case "update": {
118
211
  if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for update." }] };
119
212
  const t = updateTodo(params.id, {
120
- text: params.text,
213
+ title: params.title,
214
+ notes: params.notes,
121
215
  project: params.project,
122
216
  tags: params.tags,
123
217
  priority: params.priority as any,
124
218
  status: params.status as any,
125
219
  });
126
- return { content: [{ type: "text" as const, text: `Updated ${t.id}: ${t.text} [${t.status}]` }] };
220
+ return { content: [{ type: "text" as const, text: `Updated ${t.id}: ${t.title} [${t.status}]` }] };
221
+ }
222
+ case "get": {
223
+ if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for get." }] };
224
+ const t = getTodo(params.id);
225
+ return { content: [{ type: "text" as const, text: fmtFull(t) }] };
127
226
  }
128
227
  case "complete": {
129
228
  if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for complete." }] };
130
229
  const t = completeTodo(params.id);
131
- return { content: [{ type: "text" as const, text: `Completed ${t.id}: ${t.text}` }] };
230
+ return { content: [{ type: "text" as const, text: `Completed ${t.id}: ${t.title}` }] };
132
231
  }
133
232
  case "delete": {
134
233
  if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for delete." }] };
135
234
  const t = deleteTodo(params.id);
136
- return { content: [{ type: "text" as const, text: `Cancelled ${t.id}: ${t.text}` }] };
235
+ return { content: [{ type: "text" as const, text: `Cancelled ${t.id}: ${t.title}` }] };
236
+ }
237
+ case "park": {
238
+ if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for park." }] };
239
+ const t = parkTodo(params.id);
240
+ return { content: [{ type: "text" as const, text: `Parked ${t.id}: ${t.title}` }] };
241
+ }
242
+ case "prune": {
243
+ if (params.hard) {
244
+ const res = hardPrune({
245
+ confirm: params.confirm === true,
246
+ box: params.box,
247
+ olderThan: params.olderThan,
248
+ project: params.projectFilter,
249
+ tag: params.tagFilter,
250
+ });
251
+ return { content: [{ type: "text" as const, text: res.message + (res.refused ? "" : ` Deleted: ${res.ids.join(", ") || "(none)"}`) }] };
252
+ }
253
+ const res = pruneTodos({ ageDays: params.ageDays, all: params.all });
254
+ return { content: [{ type: "text" as const, text: `Pruned ${res.moved} todo${res.moved === 1 ? "" : "s"} to archive: ${res.ids.join(", ") || "(none)"}` }] };
255
+ }
256
+ case "health": {
257
+ const report = healthReport();
258
+ const lines = [
259
+ `## TODO Health Report`,
260
+ `active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
261
+ `parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
262
+ `archive: ${report.archive.count} (${report.archive.older_180d} old)`,
263
+ `notes: ${report.notesBytes.total}B total · max ${report.notesBytes.max}B · avg ${report.notesBytes.avg}B`,
264
+ report.flags.length ? `flags: ${report.flags.join(", ")}` : "flags: (none — healthy)",
265
+ ...report.suggestions.map((s) => ` → ${s}`),
266
+ ];
267
+ return { content: [{ type: "text" as const, text: lines.join("\n") }] };
268
+ }
269
+ case "restore": {
270
+ if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for restore." }] };
271
+ const t = restoreTodo(params.id);
272
+ return { content: [{ type: "text" as const, text: `Restored ${t.id}: ${t.title} [open]` }] };
137
273
  }
138
274
  case "clear": {
139
275
  const n = clearTodos((params.status as any) ?? "done");
@@ -148,9 +284,13 @@ export default function (pi: ExtensionAPI) {
148
284
  },
149
285
  });
150
286
 
151
- // Human slash command: /todo [all|add <text>|done <id>|rm <id>|clean|path]
287
+ // Human slash command.
152
288
  pi.registerCommand("todo", {
153
- description: "Global cross-session TODO list. /todo · /todo all · /todo add <text> · /todo done <id> · /todo rm <id> · /todo clean · /todo path",
289
+ description:
290
+ "Global cross-session TODO list. " +
291
+ "/todo / /todo all / /todo add <title> / /todo done <id> / /todo rm <id> / " +
292
+ "/todo park <id> / /todo restore <id> / /todo prune [--all|--hard --box <b> --older-than <d>] / " +
293
+ "/todo archive [project:X|text:Y] / /todo health / /todo clean / /todo path",
154
294
  handler: async (args, ctx) => {
155
295
  const a = (args ?? "").trim();
156
296
  const [sub, ...rest] = a.split(/\s+/);
@@ -162,10 +302,10 @@ export default function (pi: ExtensionAPI) {
162
302
  return;
163
303
  }
164
304
  if (sub === "add") {
165
- const text = rest.join(" ").trim();
166
- if (!text) { if (ctx.hasUI) ctx.ui.notify("usage: /todo add <text>", "warning"); return; }
167
- const t = addTodo({ text, source: "slash" });
168
- if (ctx.hasUI) ctx.ui.notify(`Added ${t.id}: ${t.text}`, "info");
305
+ const title = rest.join(" ").trim();
306
+ if (!title) { if (ctx.hasUI) ctx.ui.notify("usage: /todo add <title> (notes via the todo tool)", "warning"); return; }
307
+ const t = addTodo({ title, source: "slash" });
308
+ if (ctx.hasUI) ctx.ui.notify(`Added ${t.id}: ${t.title}`, "info");
169
309
  return;
170
310
  }
171
311
  if (sub === "done") {
@@ -182,6 +322,86 @@ export default function (pi: ExtensionAPI) {
182
322
  if (ctx.hasUI) ctx.ui.notify(`Cancelled ${t.id}`, "info");
183
323
  return;
184
324
  }
325
+ if (sub === "park") {
326
+ const id = rest[0];
327
+ if (!id) { if (ctx.hasUI) ctx.ui.notify("usage: /todo park <id>", "warning"); return; }
328
+ const t = parkTodo(id);
329
+ if (ctx.hasUI) ctx.ui.notify(`Parked ${t.id}: ${t.title}`, "info");
330
+ return;
331
+ }
332
+ if (sub === "restore") {
333
+ const id = rest[0];
334
+ if (!id) { if (ctx.hasUI) ctx.ui.notify("usage: /todo restore <id>", "warning"); return; }
335
+ const t = restoreTodo(id);
336
+ if (ctx.hasUI) ctx.ui.notify(`Restored ${t.id}: ${t.title}`, "info");
337
+ return;
338
+ }
339
+ if (sub === "prune") {
340
+ const isHard = rest.includes("--hard");
341
+ if (isHard) {
342
+ const boxIdx = rest.indexOf("--box");
343
+ const olderIdx = rest.indexOf("--older-than");
344
+ const projIdx = rest.indexOf("--project");
345
+ const box = boxIdx >= 0 ? rest[boxIdx + 1] : undefined;
346
+ const olderThan = olderIdx >= 0 ? Number(rest[olderIdx + 1]) : undefined;
347
+ const project = projIdx >= 0 ? rest[projIdx + 1] : undefined;
348
+ const preview = hardPrune({ confirm: false, box: box as any, olderThan, project });
349
+ if (ctx.hasUI) {
350
+ const yes = await ctx.ui.confirm(
351
+ "Hard Prune",
352
+ `HARD PRUNE (permanent deletion)\n${preview.message}\nBox: ${box ?? "archive"}${olderThan ? `, older than ${olderThan}d` : ""}${project ? `, project: ${project}` : ""}\n\nProceed?`,
353
+ );
354
+ if (!yes) { ctx.ui.notify("Hard-prune cancelled.", "info"); return; }
355
+ }
356
+ const res = hardPrune({ confirm: true, box: box as any, olderThan, project });
357
+ if (ctx.hasUI) ctx.ui.notify(res.message + ` Deleted: ${res.ids.join(", ") || "(none)"}`, res.refused ? "warning" : "info");
358
+ return;
359
+ }
360
+ const all = rest.includes("--all");
361
+ const res = pruneTodos({ all });
362
+ if (ctx.hasUI) ctx.ui.notify(`Pruned ${res.moved} todo${res.moved === 1 ? "" : "s"} to archive: ${res.ids.join(", ") || "(none)"}`, "info");
363
+ return;
364
+ }
365
+ if (sub === "health") {
366
+ const report = healthReport();
367
+ const lines = [
368
+ `TODO Health:`,
369
+ ` active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
370
+ ` parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
371
+ ` archive: ${report.archive.count} (${report.archive.older_180d} old)`,
372
+ ` notes: ${report.notesBytes.total}B total · max ${report.notesBytes.max}B · avg ${report.notesBytes.avg}B`,
373
+ report.flags.length ? ` ⚠ ${report.flags.join(", ")}` : " ✅ healthy",
374
+ ...report.suggestions.map((s) => ` → ${s}`),
375
+ ];
376
+ if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
377
+ return;
378
+ }
379
+ if (sub === "archive") {
380
+ const filterArg = rest.join(" ").trim();
381
+ if (!filterArg) {
382
+ const s = archiveSummary();
383
+ const lines = [
384
+ `Archive summary (${s.total} total):`,
385
+ "By project:",
386
+ ...Object.entries(s.byProject).map(([p, n]) => ` ${p}: ${n}`),
387
+ "By month:",
388
+ ...Object.entries(s.byMonth).map(([m, n]) => ` ${m}: ${n}`),
389
+ "Use /todo archive project:<name> or text:<query> to list specific items.",
390
+ ];
391
+ if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
392
+ return;
393
+ }
394
+ // parse "project:foo" or "text:query" (simple key:value)
395
+ const parts = filterArg.split(":");
396
+ const key = parts[0]?.trim();
397
+ const val = parts.slice(1).join(":").trim();
398
+ const res = key === "project" ? listArchived({ project: val, limit: 50 })
399
+ : key === "text" ? listArchived({ text: val, limit: 50 })
400
+ : listArchived({ text: filterArg, limit: 50 });
401
+ const msg = res.items.length ? res.items.map(fmt).join("\n") : "(no archived items match)";
402
+ if (ctx.hasUI) ctx.ui.notify(`Archived (${res.total} total):\n${msg}`, "info");
403
+ return;
404
+ }
185
405
  if (sub === "clean") {
186
406
  const n = clearTodos("done");
187
407
  if (ctx.hasUI) ctx.ui.notify(`Cleared ${n} done TODOs.`, "info");
@@ -191,7 +411,24 @@ export default function (pi: ExtensionAPI) {
191
411
  if (ctx.hasUI) ctx.ui.notify(`store: ${getStorePath()}`, "info");
192
412
  return;
193
413
  }
194
- // default: list open
414
+ // default: open the interactive panel (TUI) or list open (non-TUI)
415
+ if (ctx.mode === "tui") {
416
+ await ctx.ui.custom<boolean>((_tui, theme, _kb, done) => {
417
+ const panel = new TodoPanel({
418
+ theme: theme as any,
419
+ onDone: () => done(true),
420
+ onNotify: (msg, type) => ctx.ui.notify(msg, type ?? "info"),
421
+ });
422
+ return {
423
+ render: (width: number) => panel.render(width),
424
+ invalidate: () => panel.invalidate(),
425
+ handleInput: (data: string) => panel.handleInput(data),
426
+ dispose: () => { panel.dispose?.(); },
427
+ } as any;
428
+ });
429
+ return;
430
+ }
431
+ // non-TUI fallback: list open as text
195
432
  const todos = listTodos();
196
433
  const msg = todos.length ? todos.map(fmt).join("\n") : "(no open TODOs)";
197
434
  if (ctx.hasUI) ctx.ui.notify(msg, "info");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-todo",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
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": "node test/todo-store.test.mts"
38
+ "test": "for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-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 ADDED
@@ -0,0 +1,214 @@
1
+ // Sealed history store for armory-todo — holds done/cancelled todos moved
2
+ // here by `prune`. Recoverable via `restore`; permanently deletable only via
3
+ // `prune --hard` (SPEC-2). Never auto-injected into the system prompt.
4
+ //
5
+ // File: <TODO_DIR>/todo-archive.json (0600, atomic write). Missing on disk
6
+ // → empty store returned; the file is created on first save, not first load.
7
+
8
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import { dirname } from "node:path";
10
+ import { getArchivePath } from "./paths.ts";
11
+ import { migrateV2ToV3 } from "./migrate.ts";
12
+ import type { Todo } from "./todo-store.ts";
13
+ import { loadConfig } from "./config.ts";
14
+ import { loadStore, saveStore, TodoError } from "./todo-store.ts";
15
+
16
+ export interface ArchiveStore {
17
+ version: 3;
18
+ updatedAt: string;
19
+ todos: Todo[];
20
+ }
21
+
22
+ function now(): string {
23
+ return new Date().toISOString();
24
+ }
25
+
26
+ function emptyArchive(): ArchiveStore {
27
+ return { version: 3, updatedAt: now(), todos: [] };
28
+ }
29
+
30
+ /** Load the archive. Missing file → empty store (no file created). */
31
+ export function loadArchive(): ArchiveStore {
32
+ const path = getArchivePath();
33
+ if (!existsSync(path)) return emptyArchive();
34
+ try {
35
+ const raw = readFileSync(path, "utf8");
36
+ const parsed = JSON.parse(raw) as ArchiveStore;
37
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.todos)) {
38
+ throw new Error("invalid archive shape");
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
+ }
49
+ return parsed;
50
+ } catch {
51
+ try {
52
+ renameSync(path, `${path}.bad-${Date.now()}`);
53
+ } catch {
54
+ // best-effort backup
55
+ }
56
+ return emptyArchive();
57
+ }
58
+ }
59
+
60
+ /** Atomic, 0600 write. */
61
+ export function saveArchive(store: ArchiveStore): void {
62
+ store.updatedAt = now();
63
+ const path = getArchivePath();
64
+ const dir = dirname(path);
65
+ mkdirSync(dir, { recursive: true });
66
+ const tmp = `${path}.tmp`;
67
+ writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
68
+ try {
69
+ chmodSync(tmp, 0o600);
70
+ } catch {
71
+ // some filesystems ignore mode bits
72
+ }
73
+ renameSync(tmp, path);
74
+ }
75
+
76
+ export interface PruneInput {
77
+ ageDays?: number;
78
+ all?: boolean;
79
+ statuses?: ("done" | "cancelled")[];
80
+ }
81
+
82
+ export interface PruneResult {
83
+ moved: number;
84
+ ids: string[];
85
+ }
86
+
87
+ /**
88
+ * Move done/cancelled todos from the live store to the archive.
89
+ *
90
+ * A todo qualifies when:
91
+ * - its status is in `statuses` (default: config.prune.statuses = done+cancelled), AND
92
+ * - `all` is true, OR its `closedAt` is older than `ageDays` days ago
93
+ * (default: config.prune.defaultAgeDays).
94
+ *
95
+ * Both stores are saved atomically. Reversible via `restoreTodo`.
96
+ */
97
+ export function pruneTodos(opts: PruneInput = {}): PruneResult {
98
+ const config = loadConfig();
99
+ const ageDays = opts.ageDays ?? config.prune.defaultAgeDays;
100
+ const statuses = new Set(opts.statuses ?? config.prune.statuses);
101
+ const cutoff = opts.all ? null : Date.now() - ageDays * 86400_000;
102
+
103
+ const live = loadStore();
104
+ const archive = loadArchive();
105
+
106
+ const moved: Todo[] = [];
107
+ const kept: Todo[] = [];
108
+ for (const todo of live.todos) {
109
+ if (!statuses.has(todo.status as "done" | "cancelled")) {
110
+ kept.push(todo);
111
+ continue;
112
+ }
113
+ if (cutoff !== null && todo.closedAt && Date.parse(todo.closedAt) > cutoff) {
114
+ // too fresh — keep in live
115
+ kept.push(todo);
116
+ continue;
117
+ }
118
+ moved.push(todo);
119
+ }
120
+
121
+ if (moved.length === 0) return { moved: 0, ids: [] };
122
+
123
+ live.todos = kept;
124
+ archive.todos.push(...moved);
125
+ saveStore(live);
126
+ saveArchive(archive);
127
+
128
+ return { moved: moved.length, ids: moved.map((t) => t.id) };
129
+ }
130
+
131
+ /**
132
+ * Move an archived todo back to the live store as `open` (closedAt cleared).
133
+ * Throws TodoError if the id is not in the archive. Both stores are saved.
134
+ */
135
+ export function restoreTodo(id: string): Todo {
136
+ const archive = loadArchive();
137
+ const idx = archive.todos.findIndex((t) => t.id === id);
138
+ if (idx < 0) throw new TodoError(`not in archive: ${id}`);
139
+ const [todo] = archive.todos.splice(idx, 1);
140
+ const live = loadStore();
141
+ todo.status = "open";
142
+ todo.closedAt = null;
143
+ todo.updatedAt = now();
144
+ live.todos.push(todo);
145
+ saveStore(live);
146
+ saveArchive(archive);
147
+ return todo;
148
+ }
149
+
150
+ export interface ArchiveListFilter {
151
+ project?: string;
152
+ tag?: string;
153
+ status?: "done" | "cancelled";
154
+ text?: string;
155
+ since?: string; // by closedAt
156
+ before?: string; // by closedAt
157
+ limit?: number; // default 20
158
+ page?: number; // default 1
159
+ }
160
+
161
+ export interface ArchiveSummary {
162
+ total: number;
163
+ byProject: Record<string, number>;
164
+ byMonth: Record<string, number>;
165
+ }
166
+
167
+ export interface ArchiveListResult {
168
+ items: Todo[];
169
+ total: number; // total matching the filter (before pagination)
170
+ summary?: ArchiveSummary; // present only on a bare call (no filters)
171
+ }
172
+
173
+ /** Counts by project + by closedAt-month, for the summary-first default. */
174
+ export function archiveSummary(): ArchiveSummary {
175
+ const archive = loadArchive();
176
+ const byProject: Record<string, number> = {};
177
+ const byMonth: Record<string, number> = {};
178
+ for (const t of archive.todos) {
179
+ const proj = t.project || "(none)";
180
+ byProject[proj] = (byProject[proj] ?? 0) + 1;
181
+ const month = t.closedAt ? t.closedAt.slice(0, 7) : "(none)"; // YYYY-MM
182
+ byMonth[month] = (byMonth[month] ?? 0) + 1;
183
+ }
184
+ return { total: archive.todos.length, byProject, byMonth };
185
+ }
186
+
187
+ /**
188
+ * Query the archive with filters + pagination. A bare call (no filters)
189
+ * returns summary-only (items: []) — drill down with a filter to get rows.
190
+ */
191
+ export function listArchived(filter: ArchiveListFilter = {}): ArchiveListResult {
192
+ const hasFilter = Boolean(filter.project || filter.tag || filter.status || filter.text || filter.since || filter.before);
193
+ if (!hasFilter) {
194
+ const summary = archiveSummary();
195
+ return { items: [], total: summary.total, summary };
196
+ }
197
+ let out = loadArchive().todos;
198
+ if (filter.project) out = out.filter((t) => t.project === filter.project);
199
+ if (filter.tag) out = out.filter((t) => t.tags.includes(filter.tag as string));
200
+ if (filter.status) out = out.filter((t) => t.status === filter.status);
201
+ if (filter.text) {
202
+ const q = filter.text.toLowerCase();
203
+ out = out.filter((t) => t.title.toLowerCase().includes(q) || t.notes.toLowerCase().includes(q));
204
+ }
205
+ if (filter.since) out = out.filter((t) => (t.closedAt ?? t.updatedAt) >= (filter.since as string));
206
+ if (filter.before) out = out.filter((t) => (t.closedAt ?? t.updatedAt) < (filter.before as string));
207
+ // sort newest-closed first
208
+ const sorted = out.slice().sort((a, b) => (b.closedAt ?? b.updatedAt).localeCompare(a.closedAt ?? a.updatedAt));
209
+ const total = sorted.length;
210
+ const limit = filter.limit ?? 20;
211
+ const page = filter.page ?? 1;
212
+ const start = (page - 1) * limit;
213
+ return { items: sorted.slice(start, start + limit), total };
214
+ }