@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.
- package/README.md +91 -12
- package/docs/superpowers/plans/2026-07-20-spec-1-store-layer.md +1691 -0
- package/docs/superpowers/plans/2026-07-20-spec-2-health-hard-prune.md +762 -0
- package/docs/superpowers/plans/2026-07-20-spec-3-interactive-panel.md +651 -0
- package/docs/superpowers/specs/2026-07-20-lifecycle-boxes-prune-design.md +323 -0
- package/extensions/todo.ts +228 -21
- package/package.json +2 -2
- package/src/archive.ts +204 -0
- package/src/config.ts +101 -0
- package/src/hard-prune.ts +89 -0
- package/src/health.ts +81 -0
- package/src/migrate.ts +45 -0
- package/src/panel-data.ts +70 -0
- package/src/panel.ts +338 -0
- package/src/paths.ts +36 -0
- package/src/todo-store.ts +51 -24
package/extensions/todo.ts
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
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
|
|
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
|
|
258
|
+
// Human slash command.
|
|
152
259
|
pi.registerCommand("todo", {
|
|
153
|
-
description:
|
|
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.
|
|
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": "
|
|
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
|
+
}
|