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