@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.
@@ -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 += ` ${report.flags.length} bloat signal${report.flags.length === 1 ? "" : "s"} (run /todo health)`;
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 — don't crash the session notify
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
- return { content: [{ type: "text" as const, text: `Pruned ${res.moved} todo${res.moved === 1 ? "" : "s"} to archive: ${res.ids.join(", ") || "(none)"}` }] };
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) ctx.ui.notify(`Pruned ${res.moved} todo${res.moved === 1 ? "" : "s"} to archive: ${res.ids.join(", ") || "(none)"}`, "info");
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.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
- return { moved: moved.length, ids: moved.map((t) => t.id) };
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: { ...DEFAULT_CONFIG.health, ...parsed.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
- return { active, parked, archive: arch, notesBytes, flags, suggestions };
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
+ }