@getpipher/armory-todo 0.1.0 → 0.2.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";
@@ -23,10 +30,15 @@ import {
23
30
  listTodos,
24
31
  renderOpenBlock,
25
32
  updateTodo,
33
+ parkTodo,
26
34
  getStorePath,
27
35
  } from "../src/todo-store";
36
+ import { pruneTodos, restoreTodo, listArchived, archiveSummary } from "../src/archive";
37
+ import { healthReport } from "../src/health";
38
+ import { hardPrune } from "../src/hard-prune";
39
+ import { TodoPanel } from "../src/panel";
28
40
 
29
- const ACTIONS = ["list", "add", "update", "complete", "delete", "clear"] as const;
41
+ const ACTIONS = ["list", "add", "update", "complete", "delete", "clear", "park", "prune", "restore", "health"] as const;
30
42
 
31
43
  function fmt(t: ReturnType<typeof listTodos>[number]): string {
32
44
  const tag = t.project ? ` (${t.project})` : "";
@@ -39,16 +51,24 @@ export default function (pi: ExtensionAPI) {
39
51
  pi.on("session_start", async (_event, ctx) => {
40
52
  try {
41
53
  const open = listTodos();
42
- if (ctx.hasUI) {
43
- ctx.ui.notify(`armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}`, "info");
54
+ let msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}`;
55
+ try {
56
+ const report = healthReport();
57
+ if (report.flags.length > 0) {
58
+ msg += ` — ⚠ ${report.flags.length} bloat signal${report.flags.length === 1 ? "" : "s"} (run /todo health)`;
59
+ }
60
+ } catch {
61
+ // health check optional — don't crash the session notify
44
62
  }
63
+ if (ctx.hasUI) ctx.ui.notify(msg, "info");
45
64
  } catch {
46
65
  // store unavailable — never crash the session
47
66
  }
48
67
  });
49
68
 
50
69
  // Auto-inject the open-TODO block into the system prompt every turn so the
51
- // agent is always aware of pending cross-session work.
70
+ // agent is always aware of pending cross-session work. Only open + in_progress
71
+ // are injected — parked and archived are excluded (the lifecycle-box boundary).
52
72
  pi.on("before_agent_start", async (event: any) => {
53
73
  try {
54
74
  const base = (event?.systemPrompt as string | undefined) ?? "";
@@ -65,36 +85,87 @@ export default function (pi: ExtensionAPI) {
65
85
  label: "TODO",
66
86
  description:
67
87
  "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. " +
88
+ "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. " +
89
+ "Open TODOs are auto-injected each turn; parked todos are NOT injected (deferred/someday). " +
90
+ "Done/cancelled todos are moved to an archive by `prune` (reversible via `restore`). " +
91
+ "`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
92
  "Never put secrets in a TODO — the text reaches the model provider.",
71
- promptSnippet: "Read/update the global cross-session TODO list",
93
+ promptSnippet: "Read/update the global cross-session TODO list (active / parked / archive) + bloat health",
72
94
  promptGuidelines: [
73
95
  "Use todo (action:'list') when the user asks 'show me the TODO' / 'what's pending'.",
74
96
  "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.",
97
+ "Use todo (action:'complete', id) to mark a TODO done; (action:'delete', id) to cancel it.",
98
+ "Use todo (action:'park', id) to defer a TODO (not injected, recoverable); (action:'update', id, status:'open') to un-park.",
99
+ "Use todo (action:'prune') to move done/cancelled todos to the archive (reversible); (action:'prune', all:true) to prune all regardless of age.",
100
+ "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.",
76
104
  ],
77
105
  parameters: Type.Object({
78
106
  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)" })),
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)" })),
81
109
  project: Type.Optional(Type.String({ description: "Project tag, e.g. 'pi', 'sip', or '' for global" })),
82
110
  tags: Type.Optional(Type.Array(Type.String())),
83
111
  priority: Type.Optional(StringEnum(["low", "med", "high", "critical"] as const)),
84
- status: Type.Optional(StringEnum(["open", "in_progress", "done", "cancelled"] as const)),
112
+ status: Type.Optional(StringEnum(["open", "in_progress", "parked", "done", "cancelled"] as const)),
85
113
  // list filters
86
- statusFilter: Type.Optional(StringEnum(["open", "in_progress", "done", "cancelled", "all"] as const)),
114
+ statusFilter: Type.Optional(StringEnum(["open", "in_progress", "parked", "done", "cancelled", "all"] as const)),
87
115
  projectFilter: Type.Optional(Type.String()),
88
116
  tagFilter: Type.Optional(Type.String()),
117
+ 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." })),
118
+ since: Type.Optional(Type.String({ description: "ISO date filter (createdAt for live, closedAt for archive)" })),
119
+ before: Type.Optional(Type.String({ description: "ISO date filter (createdAt for live, closedAt for archive)" })),
120
+ limit: Type.Optional(Type.Number({ description: "Page size (default 20)" })),
121
+ page: Type.Optional(Type.Number({ description: "1-indexed page number (default 1)" })),
122
+ // prune options
123
+ ageDays: Type.Optional(Type.Number({ description: "prune: closedAt older than this many days (default from config)" })),
124
+ all: Type.Optional(Type.Boolean({ description: "prune: ignore age, move all done/cancelled" })),
125
+ // hard-prune options (SPEC-2)
126
+ hard: Type.Optional(Type.Boolean({ description: "prune: if true, execute a HARD prune (permanent deletion). Requires confirm:true. The only irreversible action." })),
127
+ 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." })),
128
+ box: Type.Optional(StringEnum(["archive", "active", "parked"] as const, { description: "hard-prune: which box to target (default archive)" })),
129
+ olderThan: Type.Optional(Type.Number({ description: "hard-prune: delete items older than this many days (by closedAt for archive, updatedAt for active/parked)" })),
89
130
  }),
90
131
  async execute(_toolCallId, params) {
91
132
  try {
92
133
  switch (params.action) {
93
134
  case "list": {
135
+ if (params.archived) {
136
+ const res = listArchived({
137
+ project: params.projectFilter,
138
+ tag: params.tagFilter,
139
+ status: params.statusFilter as any,
140
+ text: params.text,
141
+ since: params.since,
142
+ before: params.before,
143
+ limit: params.limit,
144
+ page: params.page,
145
+ });
146
+ if (res.summary) {
147
+ const lines = [
148
+ `## Archive summary (${res.total} total)`,
149
+ "By project:",
150
+ ...Object.entries(res.summary.byProject).map(([p, n]) => ` ${p}: ${n}`),
151
+ "By month:",
152
+ ...Object.entries(res.summary.byMonth).map(([m, n]) => ` ${m}: ${n}`),
153
+ "Use a filter (project/tag/text/since/before) to list specific items.",
154
+ ];
155
+ return { content: [{ type: "text" as const, text: lines.join("\n") }] };
156
+ }
157
+ const lines = res.items.map(fmt);
158
+ return { content: [{ type: "text" as const, text: `Archived (${res.total} total, page ${params.page ?? 1}):\n${lines.join("\n")}` }] };
159
+ }
94
160
  const todos = listTodos({
95
161
  status: params.statusFilter as any,
96
162
  project: params.projectFilter,
97
163
  tag: params.tagFilter,
164
+ text: params.text,
165
+ since: params.since,
166
+ before: params.before,
167
+ limit: params.limit,
168
+ page: params.page,
98
169
  });
99
170
  if (todos.length === 0) {
100
171
  return { content: [{ type: "text" as const, text: "No matching TODOs." }] };
@@ -135,6 +206,42 @@ export default function (pi: ExtensionAPI) {
135
206
  const t = deleteTodo(params.id);
136
207
  return { content: [{ type: "text" as const, text: `Cancelled ${t.id}: ${t.text}` }] };
137
208
  }
209
+ case "park": {
210
+ if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for park." }] };
211
+ const t = parkTodo(params.id);
212
+ return { content: [{ type: "text" as const, text: `Parked ${t.id}: ${t.text}` }] };
213
+ }
214
+ case "prune": {
215
+ if (params.hard) {
216
+ const res = hardPrune({
217
+ confirm: params.confirm === true,
218
+ box: params.box,
219
+ olderThan: params.olderThan,
220
+ project: params.projectFilter,
221
+ tag: params.tagFilter,
222
+ });
223
+ return { content: [{ type: "text" as const, text: res.message + (res.refused ? "" : ` Deleted: ${res.ids.join(", ") || "(none)"}`) }] };
224
+ }
225
+ 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)"}` }] };
227
+ }
228
+ case "health": {
229
+ const report = healthReport();
230
+ const lines = [
231
+ `## TODO Health Report`,
232
+ `active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
233
+ `parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
234
+ `archive: ${report.archive.count} (${report.archive.older_180d} old)`,
235
+ report.flags.length ? `flags: ${report.flags.join(", ")}` : "flags: (none — healthy)",
236
+ ...report.suggestions.map((s) => ` → ${s}`),
237
+ ];
238
+ return { content: [{ type: "text" as const, text: lines.join("\n") }] };
239
+ }
240
+ case "restore": {
241
+ if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for restore." }] };
242
+ const t = restoreTodo(params.id);
243
+ return { content: [{ type: "text" as const, text: `Restored ${t.id}: ${t.text} [open]` }] };
244
+ }
138
245
  case "clear": {
139
246
  const n = clearTodos((params.status as any) ?? "done");
140
247
  return { content: [{ type: "text" as const, text: `Cleared ${n} '${params.status ?? "done"}' TODOs.` }] };
@@ -148,9 +255,13 @@ export default function (pi: ExtensionAPI) {
148
255
  },
149
256
  });
150
257
 
151
- // Human slash command: /todo [all|add <text>|done <id>|rm <id>|clean|path]
258
+ // Human slash command.
152
259
  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",
260
+ description:
261
+ "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",
154
265
  handler: async (args, ctx) => {
155
266
  const a = (args ?? "").trim();
156
267
  const [sub, ...rest] = a.split(/\s+/);
@@ -182,6 +293,85 @@ export default function (pi: ExtensionAPI) {
182
293
  if (ctx.hasUI) ctx.ui.notify(`Cancelled ${t.id}`, "info");
183
294
  return;
184
295
  }
296
+ if (sub === "park") {
297
+ const id = rest[0];
298
+ if (!id) { if (ctx.hasUI) ctx.ui.notify("usage: /todo park <id>", "warning"); return; }
299
+ const t = parkTodo(id);
300
+ if (ctx.hasUI) ctx.ui.notify(`Parked ${t.id}: ${t.text}`, "info");
301
+ return;
302
+ }
303
+ if (sub === "restore") {
304
+ const id = rest[0];
305
+ if (!id) { if (ctx.hasUI) ctx.ui.notify("usage: /todo restore <id>", "warning"); return; }
306
+ const t = restoreTodo(id);
307
+ if (ctx.hasUI) ctx.ui.notify(`Restored ${t.id}: ${t.text}`, "info");
308
+ return;
309
+ }
310
+ if (sub === "prune") {
311
+ const isHard = rest.includes("--hard");
312
+ if (isHard) {
313
+ const boxIdx = rest.indexOf("--box");
314
+ const olderIdx = rest.indexOf("--older-than");
315
+ const projIdx = rest.indexOf("--project");
316
+ const box = boxIdx >= 0 ? rest[boxIdx + 1] : undefined;
317
+ const olderThan = olderIdx >= 0 ? Number(rest[olderIdx + 1]) : undefined;
318
+ const project = projIdx >= 0 ? rest[projIdx + 1] : undefined;
319
+ const preview = hardPrune({ confirm: false, box: box as any, olderThan, project });
320
+ if (ctx.hasUI) {
321
+ const yes = await ctx.ui.confirm(
322
+ "Hard Prune",
323
+ `HARD PRUNE (permanent deletion)\n${preview.message}\nBox: ${box ?? "archive"}${olderThan ? `, older than ${olderThan}d` : ""}${project ? `, project: ${project}` : ""}\n\nProceed?`,
324
+ );
325
+ if (!yes) { ctx.ui.notify("Hard-prune cancelled.", "info"); return; }
326
+ }
327
+ const res = hardPrune({ confirm: true, box: box as any, olderThan, project });
328
+ if (ctx.hasUI) ctx.ui.notify(res.message + ` Deleted: ${res.ids.join(", ") || "(none)"}`, res.refused ? "warning" : "info");
329
+ return;
330
+ }
331
+ const all = rest.includes("--all");
332
+ 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");
334
+ return;
335
+ }
336
+ if (sub === "health") {
337
+ const report = healthReport();
338
+ const lines = [
339
+ `TODO Health:`,
340
+ ` active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
341
+ ` parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
342
+ ` archive: ${report.archive.count} (${report.archive.older_180d} old)`,
343
+ report.flags.length ? ` ⚠ ${report.flags.join(", ")}` : " ✅ healthy",
344
+ ...report.suggestions.map((s) => ` → ${s}`),
345
+ ];
346
+ if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
347
+ return;
348
+ }
349
+ if (sub === "archive") {
350
+ const filterArg = rest.join(" ").trim();
351
+ if (!filterArg) {
352
+ const s = archiveSummary();
353
+ const lines = [
354
+ `Archive summary (${s.total} total):`,
355
+ "By project:",
356
+ ...Object.entries(s.byProject).map(([p, n]) => ` ${p}: ${n}`),
357
+ "By month:",
358
+ ...Object.entries(s.byMonth).map(([m, n]) => ` ${m}: ${n}`),
359
+ "Use /todo archive project:<name> or text:<query> to list specific items.",
360
+ ];
361
+ if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
362
+ return;
363
+ }
364
+ // parse "project:foo" or "text:query" (simple key:value)
365
+ const parts = filterArg.split(":");
366
+ const key = parts[0]?.trim();
367
+ const val = parts.slice(1).join(":").trim();
368
+ const res = key === "project" ? listArchived({ project: val, limit: 50 })
369
+ : key === "text" ? listArchived({ text: val, limit: 50 })
370
+ : listArchived({ text: filterArg, limit: 50 });
371
+ const msg = res.items.length ? res.items.map(fmt).join("\n") : "(no archived items match)";
372
+ if (ctx.hasUI) ctx.ui.notify(`Archived (${res.total} total):\n${msg}`, "info");
373
+ return;
374
+ }
185
375
  if (sub === "clean") {
186
376
  const n = clearTodos("done");
187
377
  if (ctx.hasUI) ctx.ui.notify(`Cleared ${n} done TODOs.`, "info");
@@ -191,7 +381,24 @@ export default function (pi: ExtensionAPI) {
191
381
  if (ctx.hasUI) ctx.ui.notify(`store: ${getStorePath()}`, "info");
192
382
  return;
193
383
  }
194
- // default: list open
384
+ // default: open the interactive panel (TUI) or list open (non-TUI)
385
+ if (ctx.mode === "tui") {
386
+ await ctx.ui.custom<boolean>((_tui, theme, _kb, done) => {
387
+ const panel = new TodoPanel({
388
+ theme: theme as any,
389
+ onDone: () => done(true),
390
+ onNotify: (msg, type) => ctx.ui.notify(msg, type ?? "info"),
391
+ });
392
+ return {
393
+ render: (width: number) => panel.render(width),
394
+ invalidate: () => panel.invalidate(),
395
+ handleInput: (data: string) => panel.handleInput(data),
396
+ dispose: () => { panel.dispose?.(); },
397
+ } as any;
398
+ });
399
+ return;
400
+ }
401
+ // non-TUI fallback: list open as text
195
402
  const todos = listTodos();
196
403
  const msg = todos.length ? todos.map(fmt).join("\n") : "(no open TODOs)";
197
404
  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.2.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-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,204 @@
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 type { Todo } from "./todo-store.ts";
12
+ import { loadConfig } from "./config.ts";
13
+ import { loadStore, saveStore, TodoError } from "./todo-store.ts";
14
+
15
+ export interface ArchiveStore {
16
+ version: 2;
17
+ updatedAt: string;
18
+ todos: Todo[];
19
+ }
20
+
21
+ function now(): string {
22
+ return new Date().toISOString();
23
+ }
24
+
25
+ function emptyArchive(): ArchiveStore {
26
+ return { version: 2, updatedAt: now(), todos: [] };
27
+ }
28
+
29
+ /** Load the archive. Missing file → empty store (no file created). */
30
+ export function loadArchive(): ArchiveStore {
31
+ const path = getArchivePath();
32
+ if (!existsSync(path)) return emptyArchive();
33
+ try {
34
+ const raw = readFileSync(path, "utf8");
35
+ const parsed = JSON.parse(raw) as ArchiveStore;
36
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.todos)) {
37
+ throw new Error("invalid archive shape");
38
+ }
39
+ return parsed;
40
+ } catch {
41
+ try {
42
+ renameSync(path, `${path}.bad-${Date.now()}`);
43
+ } catch {
44
+ // best-effort backup
45
+ }
46
+ return emptyArchive();
47
+ }
48
+ }
49
+
50
+ /** Atomic, 0600 write. */
51
+ export function saveArchive(store: ArchiveStore): void {
52
+ store.updatedAt = now();
53
+ const path = getArchivePath();
54
+ const dir = dirname(path);
55
+ mkdirSync(dir, { recursive: true });
56
+ const tmp = `${path}.tmp`;
57
+ writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
58
+ try {
59
+ chmodSync(tmp, 0o600);
60
+ } catch {
61
+ // some filesystems ignore mode bits
62
+ }
63
+ renameSync(tmp, path);
64
+ }
65
+
66
+ export interface PruneInput {
67
+ ageDays?: number;
68
+ all?: boolean;
69
+ statuses?: ("done" | "cancelled")[];
70
+ }
71
+
72
+ export interface PruneResult {
73
+ moved: number;
74
+ ids: string[];
75
+ }
76
+
77
+ /**
78
+ * Move done/cancelled todos from the live store to the archive.
79
+ *
80
+ * A todo qualifies when:
81
+ * - its status is in `statuses` (default: config.prune.statuses = done+cancelled), AND
82
+ * - `all` is true, OR its `closedAt` is older than `ageDays` days ago
83
+ * (default: config.prune.defaultAgeDays).
84
+ *
85
+ * Both stores are saved atomically. Reversible via `restoreTodo`.
86
+ */
87
+ export function pruneTodos(opts: PruneInput = {}): PruneResult {
88
+ const config = loadConfig();
89
+ const ageDays = opts.ageDays ?? config.prune.defaultAgeDays;
90
+ const statuses = new Set(opts.statuses ?? config.prune.statuses);
91
+ const cutoff = opts.all ? null : Date.now() - ageDays * 86400_000;
92
+
93
+ const live = loadStore();
94
+ const archive = loadArchive();
95
+
96
+ const moved: Todo[] = [];
97
+ const kept: Todo[] = [];
98
+ for (const todo of live.todos) {
99
+ if (!statuses.has(todo.status as "done" | "cancelled")) {
100
+ kept.push(todo);
101
+ continue;
102
+ }
103
+ if (cutoff !== null && todo.closedAt && Date.parse(todo.closedAt) > cutoff) {
104
+ // too fresh — keep in live
105
+ kept.push(todo);
106
+ continue;
107
+ }
108
+ moved.push(todo);
109
+ }
110
+
111
+ if (moved.length === 0) return { moved: 0, ids: [] };
112
+
113
+ live.todos = kept;
114
+ archive.todos.push(...moved);
115
+ saveStore(live);
116
+ saveArchive(archive);
117
+
118
+ return { moved: moved.length, ids: moved.map((t) => t.id) };
119
+ }
120
+
121
+ /**
122
+ * Move an archived todo back to the live store as `open` (closedAt cleared).
123
+ * Throws TodoError if the id is not in the archive. Both stores are saved.
124
+ */
125
+ export function restoreTodo(id: string): Todo {
126
+ const archive = loadArchive();
127
+ const idx = archive.todos.findIndex((t) => t.id === id);
128
+ if (idx < 0) throw new TodoError(`not in archive: ${id}`);
129
+ const [todo] = archive.todos.splice(idx, 1);
130
+ const live = loadStore();
131
+ todo.status = "open";
132
+ todo.closedAt = null;
133
+ todo.updatedAt = now();
134
+ live.todos.push(todo);
135
+ saveStore(live);
136
+ saveArchive(archive);
137
+ return todo;
138
+ }
139
+
140
+ export interface ArchiveListFilter {
141
+ project?: string;
142
+ tag?: string;
143
+ status?: "done" | "cancelled";
144
+ text?: string;
145
+ since?: string; // by closedAt
146
+ before?: string; // by closedAt
147
+ limit?: number; // default 20
148
+ page?: number; // default 1
149
+ }
150
+
151
+ export interface ArchiveSummary {
152
+ total: number;
153
+ byProject: Record<string, number>;
154
+ byMonth: Record<string, number>;
155
+ }
156
+
157
+ export interface ArchiveListResult {
158
+ items: Todo[];
159
+ total: number; // total matching the filter (before pagination)
160
+ summary?: ArchiveSummary; // present only on a bare call (no filters)
161
+ }
162
+
163
+ /** Counts by project + by closedAt-month, for the summary-first default. */
164
+ export function archiveSummary(): ArchiveSummary {
165
+ const archive = loadArchive();
166
+ const byProject: Record<string, number> = {};
167
+ const byMonth: Record<string, number> = {};
168
+ for (const t of archive.todos) {
169
+ const proj = t.project || "(none)";
170
+ byProject[proj] = (byProject[proj] ?? 0) + 1;
171
+ const month = t.closedAt ? t.closedAt.slice(0, 7) : "(none)"; // YYYY-MM
172
+ byMonth[month] = (byMonth[month] ?? 0) + 1;
173
+ }
174
+ return { total: archive.todos.length, byProject, byMonth };
175
+ }
176
+
177
+ /**
178
+ * Query the archive with filters + pagination. A bare call (no filters)
179
+ * returns summary-only (items: []) — drill down with a filter to get rows.
180
+ */
181
+ export function listArchived(filter: ArchiveListFilter = {}): ArchiveListResult {
182
+ const hasFilter = Boolean(filter.project || filter.tag || filter.status || filter.text || filter.since || filter.before);
183
+ if (!hasFilter) {
184
+ const summary = archiveSummary();
185
+ return { items: [], total: summary.total, summary };
186
+ }
187
+ let out = loadArchive().todos;
188
+ if (filter.project) out = out.filter((t) => t.project === filter.project);
189
+ if (filter.tag) out = out.filter((t) => t.tags.includes(filter.tag as string));
190
+ if (filter.status) out = out.filter((t) => t.status === filter.status);
191
+ if (filter.text) {
192
+ const q = filter.text.toLowerCase();
193
+ out = out.filter((t) => t.text.toLowerCase().includes(q));
194
+ }
195
+ if (filter.since) out = out.filter((t) => (t.closedAt ?? t.updatedAt) >= (filter.since as string));
196
+ if (filter.before) out = out.filter((t) => (t.closedAt ?? t.updatedAt) < (filter.before as string));
197
+ // sort newest-closed first
198
+ const sorted = out.slice().sort((a, b) => (b.closedAt ?? b.updatedAt).localeCompare(a.closedAt ?? a.updatedAt));
199
+ const total = sorted.length;
200
+ const limit = filter.limit ?? 20;
201
+ const page = filter.page ?? 1;
202
+ const start = (page - 1) * limit;
203
+ return { items: sorted.slice(start, start + limit), total };
204
+ }
package/src/config.ts ADDED
@@ -0,0 +1,101 @@
1
+ // Prune + health configuration for armory-todo.
2
+ //
3
+ // Stored at <TODO_DIR>/todo.config.json. Missing or corrupt → defaults are
4
+ // rewritten (the bad file is backed up to todo.config.json.bad-<ts>). All
5
+ // values are editable (later, via the SPEC-3 /todo Config panel).
6
+
7
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import { getConfigPath } from "./paths.ts";
10
+
11
+ export interface PruneConfig {
12
+ /** Closed todos older than this (by closedAt) are moved to archive on `prune`. */
13
+ defaultAgeDays: number;
14
+ /** Archive items older than this are flagged for hard-prune suggestion. */
15
+ hardAgeDays: number;
16
+ /** Which terminal statuses get pruned. */
17
+ statuses: ("done" | "cancelled")[];
18
+ }
19
+
20
+ export interface HealthConfig {
21
+ activeMaxOpen: number;
22
+ activeStaleDays: number;
23
+ parkedMax: number;
24
+ parkedStaleDays: number;
25
+ archiveMax: number;
26
+ archiveOldDays: number;
27
+ }
28
+
29
+ export interface TodoConfig {
30
+ version: 1;
31
+ prune: PruneConfig;
32
+ health: HealthConfig;
33
+ }
34
+
35
+ export const DEFAULT_CONFIG: TodoConfig = {
36
+ version: 1,
37
+ prune: {
38
+ defaultAgeDays: 7,
39
+ hardAgeDays: 180,
40
+ statuses: ["done", "cancelled"],
41
+ },
42
+ health: {
43
+ activeMaxOpen: 15,
44
+ activeStaleDays: 30,
45
+ parkedMax: 10,
46
+ parkedStaleDays: 60,
47
+ archiveMax: 200,
48
+ archiveOldDays: 180,
49
+ },
50
+ };
51
+
52
+ /** Deep clone of DEFAULT_CONFIG (so callers can't mutate the constant). */
53
+ function freshDefaults(): TodoConfig {
54
+ return JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as TodoConfig;
55
+ }
56
+
57
+ export function loadConfig(): TodoConfig {
58
+ const path = getConfigPath();
59
+ if (!existsSync(path)) {
60
+ const cfg = freshDefaults();
61
+ saveConfig(cfg);
62
+ return cfg;
63
+ }
64
+ try {
65
+ const raw = readFileSync(path, "utf8");
66
+ const parsed = JSON.parse(raw) as TodoConfig;
67
+ if (!parsed || typeof parsed !== "object" || !parsed.prune || !parsed.health) {
68
+ throw new Error("invalid config shape");
69
+ }
70
+ // Merge with defaults so new fields get filled in on upgrade.
71
+ return {
72
+ version: 1,
73
+ prune: { ...DEFAULT_CONFIG.prune, ...parsed.prune },
74
+ health: { ...DEFAULT_CONFIG.health, ...parsed.health },
75
+ };
76
+ } catch {
77
+ try {
78
+ renameSync(path, `${path}.bad-${Date.now()}`);
79
+ } catch {
80
+ // best-effort backup
81
+ }
82
+ const cfg = freshDefaults();
83
+ saveConfig(cfg);
84
+ return cfg;
85
+ }
86
+ }
87
+
88
+ /** Atomic, 0600 write. */
89
+ export function saveConfig(config: TodoConfig): void {
90
+ const path = getConfigPath();
91
+ const dir = dirname(path);
92
+ mkdirSync(dir, { recursive: true });
93
+ const tmp = `${path}.tmp`;
94
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
95
+ try {
96
+ chmodSync(tmp, 0o600);
97
+ } catch {
98
+ // some filesystems ignore mode bits
99
+ }
100
+ renameSync(tmp, path);
101
+ }