@getpipher/armory-todo 0.3.0 → 0.4.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 +28 -6
- package/assets/hero.svg +4 -4
- package/docs/superpowers/plans/2026-07-21-auto-prune-done-view.md +893 -0
- package/docs/superpowers/plans/2026-07-21-project-scope-management.md +1537 -0
- package/docs/superpowers/specs/2026-07-21-auto-prune-done-view-design.md +217 -0
- package/docs/superpowers/specs/2026-07-21-project-scope-management-design.md +319 -0
- package/docs/superpowers/specs/2026-07-21-title-notes-split-design.md +1 -1
- package/extensions/todo.ts +117 -8
- package/package.json +2 -2
- package/src/archive.ts +55 -3
- package/src/auto-prune.ts +15 -0
- package/src/config.ts +5 -1
- package/src/health.ts +60 -2
- package/src/levenshtein.ts +22 -0
- package/src/panel-data.ts +50 -1
- package/src/panel.ts +115 -13
- package/src/paths.ts +5 -0
- package/src/projects.ts +91 -0
- package/src/registry.ts +175 -0
package/extensions/todo.ts
CHANGED
|
@@ -34,12 +34,16 @@ import {
|
|
|
34
34
|
parkTodo,
|
|
35
35
|
getStorePath,
|
|
36
36
|
} from "../src/todo-store";
|
|
37
|
-
import { pruneTodos, restoreTodo, listArchived, archiveSummary } from "../src/archive";
|
|
37
|
+
import { pruneTodos, restoreTodo, listArchived, archiveSummary, listDoneUnified } from "../src/archive";
|
|
38
38
|
import { healthReport } from "../src/health";
|
|
39
39
|
import { hardPrune } from "../src/hard-prune";
|
|
40
40
|
import { TodoPanel } from "../src/panel";
|
|
41
|
+
import { autoPruneOnSessionStart } from "../src/auto-prune";
|
|
42
|
+
import { loadConfig } from "../src/config";
|
|
43
|
+
import { projectsOverview } from "../src/projects";
|
|
44
|
+
import { renameProject } from "../src/registry";
|
|
41
45
|
|
|
42
|
-
const ACTIONS = ["list", "add", "update", "get", "complete", "delete", "clear", "park", "prune", "restore", "health"] as const;
|
|
46
|
+
const ACTIONS = ["list", "add", "update", "get", "complete", "delete", "clear", "park", "prune", "restore", "health", "projects", "project_rename"] as const;
|
|
43
47
|
|
|
44
48
|
function fmt(t: ReturnType<typeof listTodos>[number]): string {
|
|
45
49
|
const tag = t.project ? ` (${t.project})` : "";
|
|
@@ -63,19 +67,39 @@ function fmtFull(t: ReturnType<typeof getTodo>): string {
|
|
|
63
67
|
].join("\n");
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
function fmtDone(d: ReturnType<typeof listDoneUnified>[number]): string {
|
|
71
|
+
const tag = d.project ? ` (${d.project})` : "";
|
|
72
|
+
const loc = d.location === "archive" && d.archivedAt
|
|
73
|
+
? ` [archived ${d.archivedAt.slice(0, 10)}]`
|
|
74
|
+
: ` [live ${d.closedAt ? Math.floor((Date.now() - Date.parse(d.closedAt)) / 86400_000) : 0}d]`;
|
|
75
|
+
return `- [${d.id}] (done)${tag} ${d.title}${loc}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
66
78
|
export default function (pi: ExtensionAPI) {
|
|
67
79
|
// Warm + report on session start (every new/resume/fork/reload).
|
|
68
80
|
pi.on("session_start", async (_event, ctx) => {
|
|
69
81
|
try {
|
|
82
|
+
let autoMsg = "";
|
|
83
|
+
let ageDays = 7;
|
|
84
|
+
try {
|
|
85
|
+
ageDays = loadConfig().prune.defaultAgeDays;
|
|
86
|
+
const ap = autoPruneOnSessionStart();
|
|
87
|
+
if (ap) {
|
|
88
|
+
const lines = ap.items.map((i) => ` [${i.id}] ${i.status} ${i.title}`);
|
|
89
|
+
autoMsg = ` · auto-pruned ${ap.moved} stale done (>${ageDays}d):\n${lines.join("\n")}\nUndo any with: todo restore <id>`;
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// auto-prune optional — don't crash the session notify
|
|
93
|
+
}
|
|
70
94
|
const open = listTodos();
|
|
71
|
-
let msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}`;
|
|
95
|
+
let msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}${autoMsg}`;
|
|
72
96
|
try {
|
|
73
97
|
const report = healthReport();
|
|
74
98
|
if (report.flags.length > 0) {
|
|
75
|
-
msg +=
|
|
99
|
+
msg += `${autoMsg ? "\n" : " — "}` + `⚠ ${report.flags.length} bloat signal${report.flags.length === 1 ? "" : "s"} (run /todo health)`;
|
|
76
100
|
}
|
|
77
101
|
} catch {
|
|
78
|
-
// health check optional
|
|
102
|
+
// health check optional
|
|
79
103
|
}
|
|
80
104
|
if (ctx.hasUI) ctx.ui.notify(msg, "info");
|
|
81
105
|
} catch {
|
|
@@ -116,10 +140,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
116
140
|
"Use todo (action:'complete', id) to mark a TODO done; (action:'delete', id) to cancel it.",
|
|
117
141
|
"Use todo (action:'park', id) to defer a TODO (not injected, recoverable); (action:'update', id, status:'open') to un-park.",
|
|
118
142
|
"Use todo (action:'prune') to move done/cancelled todos to the archive (reversible); (action:'prune', all:true) to prune all regardless of age.",
|
|
143
|
+
"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).",
|
|
119
144
|
"Use todo (action:'restore', id) to bring an archived TODO back as open.",
|
|
120
145
|
"Use todo (action:'list', archived:true) to query the archive; bare call returns a summary, add a filter (project/text/since) for specific items.",
|
|
121
146
|
"Use todo (action:'health') to check bloat across all boxes (counts + flags + suggestions). Run when the user asks about hygiene/bloat or before any hard-prune.",
|
|
122
147
|
"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.",
|
|
148
|
+
"Use todo (action:'projects') for a per-project scope overview (open/in_progress/parked/done counts + maxOpen + OVER/?typo markers). Run when the user asks 'which projects have open work' or to see backlog shape by project.",
|
|
149
|
+
"Use todo (action:'project_rename', oldName, newName) to rename or merge a project (rewrites live + archive + registry). Use it to fix typo'd project strings (e.g. getpither → getpipher). Rename onto an existing name merges (consolidates the old project into the new). Advisory maxOpen caps are NOT enforced in v0.4.0 — they only drive a health flag; enforcement lands in v0.5.0.",
|
|
123
150
|
],
|
|
124
151
|
parameters: Type.Object({
|
|
125
152
|
action: StringEnum(ACTIONS),
|
|
@@ -148,11 +175,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
148
175
|
confirm: Type.Optional(Type.Boolean({ description: "hard-prune: must be true to execute. Always surface the health report + proposed command and wait for explicit user confirmation first." })),
|
|
149
176
|
box: Type.Optional(StringEnum(["archive", "active", "parked"] as const, { description: "hard-prune: which box to target (default archive)" })),
|
|
150
177
|
olderThan: Type.Optional(Type.Number({ description: "hard-prune: delete items older than this many days (by closedAt for archive, updatedAt for active/parked)" })),
|
|
178
|
+
// project actions (v0.4.0)
|
|
179
|
+
oldName: Type.Optional(Type.String({ description: "project_rename: current project name" })),
|
|
180
|
+
newName: Type.Optional(Type.String({ description: "project_rename: new project name (merge if it already exists)" })),
|
|
151
181
|
}),
|
|
152
182
|
async execute(_toolCallId, params) {
|
|
153
183
|
try {
|
|
154
184
|
switch (params.action) {
|
|
155
185
|
case "list": {
|
|
186
|
+
if (params.status === "done" && !params.archived) {
|
|
187
|
+
const items = listDoneUnified({
|
|
188
|
+
text: params.text,
|
|
189
|
+
project: params.projectFilter,
|
|
190
|
+
since: params.since,
|
|
191
|
+
before: params.before,
|
|
192
|
+
limit: params.limit,
|
|
193
|
+
page: params.page,
|
|
194
|
+
});
|
|
195
|
+
if (items.length === 0) {
|
|
196
|
+
return { content: [{ type: "text" as const, text: "No done TODOs (live or archive)." }] };
|
|
197
|
+
}
|
|
198
|
+
return { content: [{ type: "text" as const, text: `Done (${items.length}):\n${items.map(fmtDone).join("\n")}` }] };
|
|
199
|
+
}
|
|
156
200
|
if (params.archived) {
|
|
157
201
|
const res = listArchived({
|
|
158
202
|
project: params.projectFilter,
|
|
@@ -251,17 +295,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
251
295
|
return { content: [{ type: "text" as const, text: res.message + (res.refused ? "" : ` Deleted: ${res.ids.join(", ") || "(none)"}`) }] };
|
|
252
296
|
}
|
|
253
297
|
const res = pruneTodos({ ageDays: params.ageDays, all: params.all });
|
|
254
|
-
|
|
298
|
+
if (res.moved === 0) {
|
|
299
|
+
return { content: [{ type: "text" as const, text: "Nothing to prune (no stale done/cancelled)." }] };
|
|
300
|
+
}
|
|
301
|
+
const prunedLines = res.items.map((i) => ` [${i.id}] ${i.status} ${i.title} (was ${i.ageDays}d old)`);
|
|
302
|
+
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>` }] };
|
|
255
303
|
}
|
|
256
304
|
case "health": {
|
|
257
305
|
const report = healthReport();
|
|
306
|
+
const projLines = report.projects.length
|
|
307
|
+
? [`projects:`, ...report.projects.map((p) => {
|
|
308
|
+
const cap = p.maxOpen !== null ? ` [max:${p.maxOpen}]` : "";
|
|
309
|
+
const flags = [p.over && "OVER", p.large && "LARGE", p.stale && "STALE", p.typo && "TYPO"].filter(Boolean).join(" ");
|
|
310
|
+
return ` ${p.name} ${p.open} open${cap}${flags ? ` ${flags}` : ""}`;
|
|
311
|
+
})]
|
|
312
|
+
: [];
|
|
258
313
|
const lines = [
|
|
259
314
|
`## TODO Health Report`,
|
|
260
315
|
`active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
|
|
261
316
|
`parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
|
|
262
317
|
`archive: ${report.archive.count} (${report.archive.older_180d} old)`,
|
|
263
318
|
`notes: ${report.notesBytes.total}B total · max ${report.notesBytes.max}B · avg ${report.notesBytes.avg}B`,
|
|
319
|
+
`(no project): ${report.noProject.open} open`,
|
|
264
320
|
report.flags.length ? `flags: ${report.flags.join(", ")}` : "flags: (none — healthy)",
|
|
321
|
+
...projLines,
|
|
265
322
|
...report.suggestions.map((s) => ` → ${s}`),
|
|
266
323
|
];
|
|
267
324
|
return { content: [{ type: "text" as const, text: lines.join("\n") }] };
|
|
@@ -275,6 +332,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
275
332
|
const n = clearTodos((params.status as any) ?? "done");
|
|
276
333
|
return { content: [{ type: "text" as const, text: `Cleared ${n} '${params.status ?? "done"}' TODOs.` }] };
|
|
277
334
|
}
|
|
335
|
+
case "projects": {
|
|
336
|
+
const o = projectsOverview();
|
|
337
|
+
const rows = o.rows.map((r) => {
|
|
338
|
+
const cap = r.maxOpen !== null ? ` [max:${r.maxOpen}]` : "";
|
|
339
|
+
const over = r.over ? " OVER" : "";
|
|
340
|
+
const typo = r.typo ? " ?typo" : "";
|
|
341
|
+
return ` ${r.name} ${r.open}o/${r.in_progress}i/${r.parked}p/${r.done}d (total ${r.total})${cap}${over}${typo}`;
|
|
342
|
+
});
|
|
343
|
+
const np = `(no project): ${o.noProject.count} total · ${o.noProject.open} open`;
|
|
344
|
+
const text = rows.length ? `Projects (${o.rows.length}):\n${rows.join("\n")}\n${np}` : `Projects: (none)\n${np}`;
|
|
345
|
+
return { content: [{ type: "text" as const, text }] };
|
|
346
|
+
}
|
|
347
|
+
case "project_rename": {
|
|
348
|
+
if (!params.oldName || !params.newName) {
|
|
349
|
+
return { content: [{ type: "text" as const, text: "Error: `oldName` and `newName` are required for project_rename." }] };
|
|
350
|
+
}
|
|
351
|
+
const r = renameProject(params.oldName, params.newName);
|
|
352
|
+
return { content: [{ type: "text" as const, text: `Renamed ${params.oldName} → ${r.newName}: ${r.liveRenamed} live + ${r.archivedRenamed} archived${r.merged ? " (merged)" : ""}` }] };
|
|
353
|
+
}
|
|
278
354
|
default:
|
|
279
355
|
return { content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }] };
|
|
280
356
|
}
|
|
@@ -290,7 +366,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
290
366
|
"Global cross-session TODO list. " +
|
|
291
367
|
"/todo / /todo all / /todo add <title> / /todo done <id> / /todo rm <id> / " +
|
|
292
368
|
"/todo park <id> / /todo restore <id> / /todo prune [--all|--hard --box <b> --older-than <d>] / " +
|
|
293
|
-
"/todo archive [project:X|text:Y] / /todo health / /todo clean / /todo path",
|
|
369
|
+
"/todo archive [project:X|text:Y] / /todo finished / /todo projects / /todo health / /todo clean / /todo path",
|
|
294
370
|
handler: async (args, ctx) => {
|
|
295
371
|
const a = (args ?? "").trim();
|
|
296
372
|
const [sub, ...rest] = a.split(/\s+/);
|
|
@@ -359,18 +435,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
359
435
|
}
|
|
360
436
|
const all = rest.includes("--all");
|
|
361
437
|
const res = pruneTodos({ all });
|
|
362
|
-
if (ctx.hasUI)
|
|
438
|
+
if (ctx.hasUI) {
|
|
439
|
+
const msg = res.moved === 0
|
|
440
|
+
? "Nothing to prune."
|
|
441
|
+
: `Pruned ${res.moved} to archive:\n${res.items.map((i) => ` [${i.id}] ${i.title} (${i.ageDays}d)`).join("\n")}\nUndo: todo restore <id>`;
|
|
442
|
+
ctx.ui.notify(msg, "info");
|
|
443
|
+
}
|
|
363
444
|
return;
|
|
364
445
|
}
|
|
365
446
|
if (sub === "health") {
|
|
366
447
|
const report = healthReport();
|
|
448
|
+
const projLines = report.projects.length
|
|
449
|
+
? [` projects:`, ...report.projects.map((p) => {
|
|
450
|
+
const cap = p.maxOpen !== null ? ` [max:${p.maxOpen}]` : "";
|
|
451
|
+
const flags = [p.over && "OVER", p.large && "LARGE", p.stale && "STALE", p.typo && "TYPO"].filter(Boolean).join(" ");
|
|
452
|
+
return ` ${p.name} ${p.open} open${cap}${flags ? ` ${flags}` : ""}`;
|
|
453
|
+
})]
|
|
454
|
+
: [];
|
|
367
455
|
const lines = [
|
|
368
456
|
`TODO Health:`,
|
|
369
457
|
` active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
|
|
370
458
|
` parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
|
|
371
459
|
` archive: ${report.archive.count} (${report.archive.older_180d} old)`,
|
|
372
460
|
` notes: ${report.notesBytes.total}B total · max ${report.notesBytes.max}B · avg ${report.notesBytes.avg}B`,
|
|
461
|
+
` (no project): ${report.noProject.open} open`,
|
|
373
462
|
report.flags.length ? ` ⚠ ${report.flags.join(", ")}` : " ✅ healthy",
|
|
463
|
+
...projLines,
|
|
374
464
|
...report.suggestions.map((s) => ` → ${s}`),
|
|
375
465
|
];
|
|
376
466
|
if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
|
|
@@ -402,6 +492,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
402
492
|
if (ctx.hasUI) ctx.ui.notify(`Archived (${res.total} total):\n${msg}`, "info");
|
|
403
493
|
return;
|
|
404
494
|
}
|
|
495
|
+
if (sub === "finished") {
|
|
496
|
+
const items = listDoneUnified({ text: rest.join(" ").trim() || undefined, limit: 100 });
|
|
497
|
+
const msg = items.length ? `Done (${items.length}):\n${items.map(fmtDone).join("\n")}` : "(no done TODOs)";
|
|
498
|
+
if (ctx.hasUI) ctx.ui.notify(msg, "info");
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
405
501
|
if (sub === "clean") {
|
|
406
502
|
const n = clearTodos("done");
|
|
407
503
|
if (ctx.hasUI) ctx.ui.notify(`Cleared ${n} done TODOs.`, "info");
|
|
@@ -411,6 +507,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
411
507
|
if (ctx.hasUI) ctx.ui.notify(`store: ${getStorePath()}`, "info");
|
|
412
508
|
return;
|
|
413
509
|
}
|
|
510
|
+
if (sub === "projects") {
|
|
511
|
+
const o = projectsOverview();
|
|
512
|
+
const rows = o.rows.map((r) => {
|
|
513
|
+
const cap = r.maxOpen !== null ? ` [max:${r.maxOpen}]` : "";
|
|
514
|
+
const over = r.over ? " OVER" : "";
|
|
515
|
+
const typo = r.typo ? " ?typo" : "";
|
|
516
|
+
return ` ${r.name} ${r.open}o/${r.in_progress}i/${r.parked}p/${r.done}d (total ${r.total})${cap}${over}${typo}`;
|
|
517
|
+
});
|
|
518
|
+
const np = `(no project): ${o.noProject.count} total · ${o.noProject.open} open`;
|
|
519
|
+
const msg = rows.length ? `Projects (${o.rows.length}):\n${rows.join("\n")}\n${np}` : `Projects: (none)\n${np}`;
|
|
520
|
+
if (ctx.hasUI) ctx.ui.notify(msg, "info");
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
414
523
|
// default: open the interactive panel (TUI) or list open (non-TUI)
|
|
415
524
|
if (ctx.mode === "tui") {
|
|
416
525
|
await ctx.ui.custom<boolean>((_tui, theme, _kb, done) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-todo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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": "for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-prune panel-data; do node test/$t.test.mts || exit 1; done"
|
|
38
|
+
"test": "for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-prune todo-auto-prune registry projects 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
|
@@ -79,9 +79,17 @@ export interface PruneInput {
|
|
|
79
79
|
statuses?: ("done" | "cancelled")[];
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
export interface PruneItem {
|
|
83
|
+
id: string;
|
|
84
|
+
status: "done" | "cancelled";
|
|
85
|
+
title: string;
|
|
86
|
+
ageDays: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
82
89
|
export interface PruneResult {
|
|
83
90
|
moved: number;
|
|
84
91
|
ids: string[];
|
|
92
|
+
items: PruneItem[];
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
/**
|
|
@@ -118,14 +126,20 @@ export function pruneTodos(opts: PruneInput = {}): PruneResult {
|
|
|
118
126
|
moved.push(todo);
|
|
119
127
|
}
|
|
120
128
|
|
|
121
|
-
if (moved.length === 0) return { moved: 0, ids: [] };
|
|
129
|
+
if (moved.length === 0) return { moved: 0, ids: [], items: [] };
|
|
122
130
|
|
|
123
131
|
live.todos = kept;
|
|
124
132
|
archive.todos.push(...moved);
|
|
125
133
|
saveStore(live);
|
|
126
134
|
saveArchive(archive);
|
|
127
135
|
|
|
128
|
-
|
|
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 };
|
|
129
143
|
}
|
|
130
144
|
|
|
131
145
|
/**
|
|
@@ -211,4 +225,42 @@ export function listArchived(filter: ArchiveListFilter = {}): ArchiveListResult
|
|
|
211
225
|
const page = filter.page ?? 1;
|
|
212
226
|
const start = (page - 1) * limit;
|
|
213
227
|
return { items: sorted.slice(start, start + limit), total };
|
|
214
|
-
}
|
|
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/config.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface HealthConfig {
|
|
|
24
24
|
parkedStaleDays: number;
|
|
25
25
|
archiveMax: number;
|
|
26
26
|
archiveOldDays: number;
|
|
27
|
+
perProjectDefaultMax: number; // v0.4.0: per-project PROJECT_LARGE threshold (advisory)
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export interface TodoConfig {
|
|
@@ -46,6 +47,7 @@ export const DEFAULT_CONFIG: TodoConfig = {
|
|
|
46
47
|
parkedStaleDays: 60,
|
|
47
48
|
archiveMax: 200,
|
|
48
49
|
archiveOldDays: 180,
|
|
50
|
+
perProjectDefaultMax: 8,
|
|
49
51
|
},
|
|
50
52
|
};
|
|
51
53
|
|
|
@@ -68,10 +70,12 @@ export function loadConfig(): TodoConfig {
|
|
|
68
70
|
throw new Error("invalid config shape");
|
|
69
71
|
}
|
|
70
72
|
// Merge with defaults so new fields get filled in on upgrade.
|
|
73
|
+
const health = { ...DEFAULT_CONFIG.health, ...parsed.health };
|
|
74
|
+
if (health.perProjectDefaultMax === undefined) health.perProjectDefaultMax = DEFAULT_CONFIG.health.perProjectDefaultMax;
|
|
71
75
|
return {
|
|
72
76
|
version: 1,
|
|
73
77
|
prune: { ...DEFAULT_CONFIG.prune, ...parsed.prune },
|
|
74
|
-
health
|
|
78
|
+
health,
|
|
75
79
|
};
|
|
76
80
|
} catch {
|
|
77
81
|
try {
|
package/src/health.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { loadStore } from "./todo-store.ts";
|
|
7
7
|
import { loadArchive } from "./archive.ts";
|
|
8
8
|
import { loadConfig } from "./config.ts";
|
|
9
|
+
import { loadRegistry, reconcileRegistry, saveRegistry, getProjectEntry } from "./registry.ts";
|
|
10
|
+
import { levenshtein } from "./levenshtein.ts";
|
|
9
11
|
|
|
10
12
|
export interface ActiveHealth {
|
|
11
13
|
open: number;
|
|
@@ -32,7 +34,19 @@ export interface NotesBytes {
|
|
|
32
34
|
export type HealthFlag =
|
|
33
35
|
| "ACTIVE_LARGE" | "ACTIVE_STALE"
|
|
34
36
|
| "PARKED_LARGE" | "PARKED_STALE"
|
|
35
|
-
| "ARCHIVE_LARGE" | "ARCHIVE_OLD"
|
|
37
|
+
| "ARCHIVE_LARGE" | "ARCHIVE_OLD"
|
|
38
|
+
| "PROJECT_OVER" | "PROJECT_TYPO" | "PROJECT_LARGE" | "PROJECT_STALE";
|
|
39
|
+
|
|
40
|
+
export interface ProjectHealth {
|
|
41
|
+
name: string;
|
|
42
|
+
open: number;
|
|
43
|
+
maxOpen: number | null;
|
|
44
|
+
over: boolean;
|
|
45
|
+
typo: boolean;
|
|
46
|
+
large: boolean;
|
|
47
|
+
stale: boolean;
|
|
48
|
+
lastUpdated: string;
|
|
49
|
+
}
|
|
36
50
|
|
|
37
51
|
export interface HealthReport {
|
|
38
52
|
active: ActiveHealth;
|
|
@@ -41,6 +55,8 @@ export interface HealthReport {
|
|
|
41
55
|
notesBytes: NotesBytes;
|
|
42
56
|
flags: HealthFlag[];
|
|
43
57
|
suggestions: string[];
|
|
58
|
+
projects: ProjectHealth[]; // only projects with ≥1 flag, sorted open desc
|
|
59
|
+
noProject: { open: number }; // (no project) open count, for context
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
function daysAgo(iso: string): number {
|
|
@@ -53,6 +69,11 @@ export function healthReport(): HealthReport {
|
|
|
53
69
|
const live = loadStore();
|
|
54
70
|
const archive = loadArchive();
|
|
55
71
|
|
|
72
|
+
// reconcile registry first (lazy sync), persist iff changed
|
|
73
|
+
const reg = loadRegistry();
|
|
74
|
+
const { reg: synced, changed } = reconcileRegistry(reg, live.todos, archive.todos);
|
|
75
|
+
if (changed) saveRegistry(synced);
|
|
76
|
+
|
|
56
77
|
const openTodos = live.todos.filter((t) => t.status === "open");
|
|
57
78
|
const ipTodos = live.todos.filter((t) => t.status === "in_progress");
|
|
58
79
|
const parkedTodos = live.todos.filter((t) => t.status === "parked");
|
|
@@ -93,5 +114,42 @@ export function healthReport(): HealthReport {
|
|
|
93
114
|
if (parkedStale > 0) suggestions.push(`parked: ${parkedStale} parked > ${h.parkedStaleDays}d → restore or hard-prune`);
|
|
94
115
|
if (actionable.length > h.activeMaxOpen) suggestions.push(`active: ${actionable.length} open+in_progress (max ${h.activeMaxOpen}) → close or park some before adding more`);
|
|
95
116
|
|
|
96
|
-
|
|
117
|
+
// per-project flags (v0.4.0)
|
|
118
|
+
const archivedDone = archive.todos.filter((t) => t.status === "done");
|
|
119
|
+
const projectNames = new Set<string>();
|
|
120
|
+
for (const t of live.todos) { const p = t.project.trim(); if (p) projectNames.add(p); }
|
|
121
|
+
for (const t of archivedDone) { const p = t.project.trim(); if (p) projectNames.add(p); }
|
|
122
|
+
|
|
123
|
+
const projectHealth: ProjectHealth[] = [];
|
|
124
|
+
for (const name of projectNames) {
|
|
125
|
+
const liveForName = live.todos.filter((t) => t.project.trim() === name);
|
|
126
|
+
const open = liveForName.filter((t) => t.status === "open").length;
|
|
127
|
+
const entry = getProjectEntry(synced, name);
|
|
128
|
+
const maxOpen = entry?.maxOpen ?? null;
|
|
129
|
+
const over = maxOpen !== null && open > maxOpen;
|
|
130
|
+
const large = open > h.perProjectDefaultMax;
|
|
131
|
+
const lastUpdated = liveForName.length ? liveForName.map((t) => t.updatedAt).sort().at(-1) ?? "" : "";
|
|
132
|
+
const stale = lastUpdated !== "" && daysAgo(lastUpdated) > h.activeStaleDays;
|
|
133
|
+
const totalForName = liveForName.length + archivedDone.filter((t) => t.project.trim() === name).length;
|
|
134
|
+
const typo = totalForName === 1 && [...projectNames].some((o) => o !== name && levenshtein(name, o) <= 2);
|
|
135
|
+
if (over || large || stale || typo) {
|
|
136
|
+
projectHealth.push({ name, open, maxOpen, over, typo, large, stale, lastUpdated });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
projectHealth.sort((a, b) => b.open - a.open || a.name.localeCompare(b.name));
|
|
140
|
+
|
|
141
|
+
for (const p of projectHealth) {
|
|
142
|
+
if (p.over) { flags.push("PROJECT_OVER"); suggestions.push(`project '${p.name}' ${p.open} open (maxOpen ${p.maxOpen}) → close/park some, or raise maxOpen`); }
|
|
143
|
+
if (p.large) { flags.push("PROJECT_LARGE"); suggestions.push(`project '${p.name}' ${p.open} open (per-project default max ${h.perProjectDefaultMax}) → over budget`); }
|
|
144
|
+
if (p.stale) { flags.push("PROJECT_STALE"); suggestions.push(`project '${p.name}' untouched > ${h.activeStaleDays}d → park or close`); }
|
|
145
|
+
if (p.typo) {
|
|
146
|
+
flags.push("PROJECT_TYPO");
|
|
147
|
+
const sib = [...projectNames].find((o) => o !== p.name && levenshtein(p.name, o) <= 2);
|
|
148
|
+
suggestions.push(`project '${p.name}' has 1 todo — possible typo of '${sib}'? → todo project rename ${p.name} ${sib}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const noProject = { open: live.todos.filter((t) => t.project.trim() === "" && t.status === "open").length };
|
|
153
|
+
|
|
154
|
+
return { active, parked, archive: arch, notesBytes, flags, suggestions, projects: projectHealth, noProject };
|
|
97
155
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Tiny Levenshtein edit-distance helper for project-typo nearest-sibling
|
|
2
|
+
// detection. Kept dependency-free and allocation-light (two rolling rows).
|
|
3
|
+
|
|
4
|
+
export function levenshtein(a: string, b: string): number {
|
|
5
|
+
const m = a.length;
|
|
6
|
+
const n = b.length;
|
|
7
|
+
if (m === 0) return n;
|
|
8
|
+
if (n === 0) return m;
|
|
9
|
+
let prev = new Array<number>(n + 1);
|
|
10
|
+
let curr = new Array<number>(n + 1);
|
|
11
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
12
|
+
for (let i = 1; i <= m; i++) {
|
|
13
|
+
curr[0] = i;
|
|
14
|
+
const ca = a.charCodeAt(i - 1);
|
|
15
|
+
for (let j = 1; j <= n; j++) {
|
|
16
|
+
const cost = ca === b.charCodeAt(j - 1) ? 0 : 1;
|
|
17
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
18
|
+
}
|
|
19
|
+
[prev, curr] = [curr, prev];
|
|
20
|
+
}
|
|
21
|
+
return prev[n];
|
|
22
|
+
}
|
package/src/panel-data.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { SelectItem, SettingItem } from "@earendil-works/pi-tui";
|
|
6
6
|
import type { Todo } from "./todo-store.ts";
|
|
7
|
+
import type { DoneItem } from "./archive.ts";
|
|
7
8
|
import type { ArchiveSummary } from "./archive.ts";
|
|
8
9
|
import type { TodoConfig } from "./config.ts";
|
|
9
10
|
|
|
@@ -60,4 +61,52 @@ export function configToSettingItems(cfg: TodoConfig): SettingItem[] {
|
|
|
60
61
|
{ id: "archiveMax", label: "Archive max", currentValue: String(cfg.health.archiveMax), values: ["100", "200", "500"], description: "Bloat flag when archive exceeds this." },
|
|
61
62
|
{ id: "archiveOldDays", label: "Archive old (days)", currentValue: String(cfg.health.archiveOldDays), values: ["90", "180", "365"], description: "Bloat flag when archive items older than this." },
|
|
62
63
|
];
|
|
63
|
-
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Format a done todo (live or archived) as a SelectList item with a
|
|
67
|
+
* location tag: "[live Nd]" or "[archived YYYY-MM-DD]". */
|
|
68
|
+
export function todoDoneItem(d: DoneItem): SelectItem {
|
|
69
|
+
const proj = 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 { value: d.id, label: `[${d.id}] (done)${proj}${loc} ${d.title}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Actions for a done todo: View detail always; Restore only if archived. */
|
|
77
|
+
export function actionsForDoneTodo(d: DoneItem): { label: string; action: string }[] {
|
|
78
|
+
const acts: { label: string; action: string }[] = [{ label: "View detail", action: "view" }];
|
|
79
|
+
if (d.location === "archive") acts.push({ label: "Restore (from archive)", action: "restore" });
|
|
80
|
+
return acts;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// v0.4.0 — project overview (Projects tab) helpers.
|
|
84
|
+
import type { ProjectsOverview } from "./projects.ts";
|
|
85
|
+
|
|
86
|
+
/** Format the projects overview into SelectList items. Markers: OVER / typo. */
|
|
87
|
+
export function projectOverviewToItems(o: ProjectsOverview): SelectItem[] {
|
|
88
|
+
return o.rows.map((r) => {
|
|
89
|
+
const cap = r.maxOpen !== null ? ` [max:${r.maxOpen}]` : "";
|
|
90
|
+
const over = r.over ? " OVER" : "";
|
|
91
|
+
const typo = r.typo ? " ?typo" : "";
|
|
92
|
+
const last = r.lastUpdated ? ` · ${r.lastUpdated.slice(0, 10)}` : " · (no live)";
|
|
93
|
+
return {
|
|
94
|
+
value: r.name,
|
|
95
|
+
label: `${r.name} ${r.open}/${r.in_progress}/${r.parked}/${r.done} (total ${r.total})${cap}${over}${typo}${last}`,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Actions for a project row in the Projects tab. */
|
|
101
|
+
export function actionsForProject(): { label: string; action: string }[] {
|
|
102
|
+
return [
|
|
103
|
+
{ label: "Rename / merge", action: "rename" },
|
|
104
|
+
{ label: "Set maxOpen", action: "setmax" },
|
|
105
|
+
{ label: "Filter active to project", action: "filter" },
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The (no project) summary row — non-selectable (no submenu). */
|
|
110
|
+
export function noProjectSummaryItem(o: ProjectsOverview): SelectItem {
|
|
111
|
+
return { value: "__noproject__", label: `(no project): ${o.noProject.count} total · ${o.noProject.open} open` };
|
|
112
|
+
}
|