@getpipher/armory-todo 0.3.1 → 0.5.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.
@@ -40,8 +40,10 @@ import { hardPrune } from "../src/hard-prune";
40
40
  import { TodoPanel } from "../src/panel";
41
41
  import { autoPruneOnSessionStart } from "../src/auto-prune";
42
42
  import { loadConfig } from "../src/config";
43
+ import { projectsOverview } from "../src/projects";
44
+ import { renameProject } from "../src/registry";
43
45
 
44
- 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;
45
47
 
46
48
  function fmt(t: ReturnType<typeof listTodos>[number]): string {
47
49
  const tag = t.project ? ` (${t.project})` : "";
@@ -131,7 +133,7 @@ export default function (pi: ExtensionAPI) {
131
133
  "Never put secrets in a TODO — the text reaches the model provider.",
132
134
  promptSnippet: "Read/update the global cross-session TODO list (active / parked / archive) + bloat health",
133
135
  promptGuidelines: [
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.",
136
+ "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 (capped at health.maxNotesBytes, default 8KB — oversize is rejected at write). Adds are BLOCKED if the target project is at its per-project maxOpen cap (the slot you set via the Projects tab); close/park one or raise maxOpen first.",
135
137
  "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
138
  "Use todo (action:'update', id, title?, notes?, project?, tags?, priority?, status?) to edit; notes empty string clears.",
137
139
  "Use todo (action:'list') when the user asks 'show me the TODO' / 'what's pending' (text filter searches title+notes).",
@@ -143,6 +145,8 @@ export default function (pi: ExtensionAPI) {
143
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.",
144
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.",
145
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). Per-project maxOpen caps are ENFORCED (block-on-add); they also drive a PROJECT_OVER health flag when breached.",
146
150
  ],
147
151
  parameters: Type.Object({
148
152
  action: StringEnum(ACTIONS),
@@ -171,6 +175,9 @@ export default function (pi: ExtensionAPI) {
171
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." })),
172
176
  box: Type.Optional(StringEnum(["archive", "active", "parked"] as const, { description: "hard-prune: which box to target (default archive)" })),
173
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)" })),
174
181
  }),
175
182
  async execute(_toolCallId, params) {
176
183
  try {
@@ -296,13 +303,22 @@ export default function (pi: ExtensionAPI) {
296
303
  }
297
304
  case "health": {
298
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
+ : [];
299
313
  const lines = [
300
314
  `## TODO Health Report`,
301
315
  `active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
302
316
  `parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
303
317
  `archive: ${report.archive.count} (${report.archive.older_180d} old)`,
304
318
  `notes: ${report.notesBytes.total}B total · max ${report.notesBytes.max}B · avg ${report.notesBytes.avg}B`,
319
+ `(no project): ${report.noProject.open} open`,
305
320
  report.flags.length ? `flags: ${report.flags.join(", ")}` : "flags: (none — healthy)",
321
+ ...projLines,
306
322
  ...report.suggestions.map((s) => ` → ${s}`),
307
323
  ];
308
324
  return { content: [{ type: "text" as const, text: lines.join("\n") }] };
@@ -316,6 +332,25 @@ export default function (pi: ExtensionAPI) {
316
332
  const n = clearTodos((params.status as any) ?? "done");
317
333
  return { content: [{ type: "text" as const, text: `Cleared ${n} '${params.status ?? "done"}' TODOs.` }] };
318
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
+ }
319
354
  default:
320
355
  return { content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }] };
321
356
  }
@@ -331,7 +366,7 @@ export default function (pi: ExtensionAPI) {
331
366
  "Global cross-session TODO list. " +
332
367
  "/todo / /todo all / /todo add <title> / /todo done <id> / /todo rm <id> / " +
333
368
  "/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",
369
+ "/todo archive [project:X|text:Y] / /todo finished / /todo projects / /todo health / /todo clean / /todo path",
335
370
  handler: async (args, ctx) => {
336
371
  const a = (args ?? "").trim();
337
372
  const [sub, ...rest] = a.split(/\s+/);
@@ -410,13 +445,22 @@ export default function (pi: ExtensionAPI) {
410
445
  }
411
446
  if (sub === "health") {
412
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
+ : [];
413
455
  const lines = [
414
456
  `TODO Health:`,
415
457
  ` active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
416
458
  ` parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
417
459
  ` archive: ${report.archive.count} (${report.archive.older_180d} old)`,
418
460
  ` notes: ${report.notesBytes.total}B total · max ${report.notesBytes.max}B · avg ${report.notesBytes.avg}B`,
461
+ ` (no project): ${report.noProject.open} open`,
419
462
  report.flags.length ? ` ⚠ ${report.flags.join(", ")}` : " ✅ healthy",
463
+ ...projLines,
420
464
  ...report.suggestions.map((s) => ` → ${s}`),
421
465
  ];
422
466
  if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
@@ -463,6 +507,19 @@ export default function (pi: ExtensionAPI) {
463
507
  if (ctx.hasUI) ctx.ui.notify(`store: ${getStorePath()}`, "info");
464
508
  return;
465
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
+ }
466
523
  // default: open the interactive panel (TUI) or list open (non-TUI)
467
524
  if (ctx.mode === "tui") {
468
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.1",
3
+ "version": "0.5.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 todo-auto-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 todo-caps; do node test/$t.test.mts || exit 1; done"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@earendil-works/pi-ai": "*",
package/src/caps.ts ADDED
@@ -0,0 +1,71 @@
1
+ // Caps enforcement primitives for armory-todo (v0.5.0). Pure — no disk I/O,
2
+ // no config/registry loads. Callers (addTodo/updateTodo/renderOpenBlock) load
3
+ // state and pass it in, so these are unit-testable in isolation.
4
+ //
5
+ // Two caps:
6
+ // - notes : per-todo byte ceiling (health.maxNotesBytes), hard-reject at write.
7
+ // - project: per-project open-count ceiling (registry maxOpen), hard-reject
8
+ // on add + project-move (only for open/in_progress todos).
9
+ // Both throw TodoError BEFORE any store mutation (callers ensure atomicity).
10
+ //
11
+ // Circular import note: caps.ts imports TodoError/Todo (types) from
12
+ // todo-store.ts; todo-store.ts imports the cap functions. Safe — no module
13
+ // touches another's exports at top level; all usage is inside functions, so
14
+ // both are fully loaded by call-time.
15
+
16
+ import { TodoError, type Todo } from "./todo-store.ts";
17
+ import type { ProjectRegistry } from "./registry.ts";
18
+
19
+ /** Human-readable byte size for error messages: 512 -> "512B", 2048 -> "2.0KB". */
20
+ function formatBytes(n: number): string {
21
+ if (n < 1024) return `${n}B`;
22
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
23
+ return `${(n / (1024 * 1024)).toFixed(1)}MB`;
24
+ }
25
+
26
+ /** Throw if notes exceeds the byte cap. Byte-length (not char-length): notes
27
+ * can hold Unicode ("é" = 2 bytes UTF-8). A maxBytes of 0 means "no notes
28
+ * allowed" (only empty notes pass). Negative maxBytes rejects everything
29
+ * (treated as a misconfig; config load clamps negative/NaN to the default). */
30
+ export function checkNotesCap(notes: string, maxBytes: number): void {
31
+ const bytes = Buffer.byteLength(notes, "utf8");
32
+ if (bytes > maxBytes) {
33
+ throw new TodoError(
34
+ `notes ${formatBytes(bytes)} > max ${formatBytes(maxBytes)} (maxNotesBytes ${maxBytes}) — trim the detail or split into multiple todos`,
35
+ );
36
+ }
37
+ }
38
+
39
+ export interface ProjectCapInput {
40
+ project: string; // target project name (already trimmed by caller)
41
+ currentOpen: number; // target's current open count, NOT counting the would-be-added/moved todo
42
+ maxOpen: number | null; // from the registry entry; null = uncapped
43
+ }
44
+
45
+ /** Throw if adding one more open todo to `project` would exceed its cap.
46
+ * `maxOpen === null` -> no-op (uncapped). The cap is on the `open` count only
47
+ * (matches the PROJECT_OVER health definition; in_progress does not count). */
48
+ export function checkProjectCap({ project, currentOpen, maxOpen }: ProjectCapInput): void {
49
+ if (maxOpen === null) return;
50
+ if (currentOpen + 1 > maxOpen) {
51
+ throw new TodoError(
52
+ `project '${project}' is at maxOpen ${maxOpen} (${currentOpen} open) — close/park one, or raise maxOpen via the /todo panel (Projects tab -> Set maxOpen), before adding`,
53
+ );
54
+ }
55
+ }
56
+
57
+ export interface OverBudgetProject { name: string; open: number; maxOpen: number; }
58
+
59
+ /** Projects whose open count exceeds their explicit maxOpen (maxOpen non-null).
60
+ * Pure; consumed by renderOpenBlock's over-cap summary. `liveTodos` is the
61
+ * full live store array. Open is counted here (status === "open"). Sorted by
62
+ * breach depth (open - maxOpen) desc, then name asc. */
63
+ export function overBudgetProjects(liveTodos: Todo[], registry: ProjectRegistry): OverBudgetProject[] {
64
+ const out: OverBudgetProject[] = [];
65
+ for (const entry of registry.projects) {
66
+ if (entry.maxOpen === null) continue;
67
+ const open = liveTodos.filter((t) => t.project === entry.name && t.status === "open").length;
68
+ if (open > entry.maxOpen) out.push({ name: entry.name, open, maxOpen: entry.maxOpen });
69
+ }
70
+ return out.sort((a, b) => (b.open - b.maxOpen) - (a.open - a.maxOpen) || a.name.localeCompare(b.name));
71
+ }
package/src/config.ts CHANGED
@@ -24,6 +24,8 @@ 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)
28
+ maxNotesBytes: number; // v0.5.0: per-todo notes byte cap (hard-reject at add/update)
27
29
  }
28
30
 
29
31
  export interface TodoConfig {
@@ -46,6 +48,8 @@ export const DEFAULT_CONFIG: TodoConfig = {
46
48
  parkedStaleDays: 60,
47
49
  archiveMax: 200,
48
50
  archiveOldDays: 180,
51
+ perProjectDefaultMax: 8,
52
+ maxNotesBytes: 8192,
49
53
  },
50
54
  };
51
55
 
@@ -68,10 +72,15 @@ export function loadConfig(): TodoConfig {
68
72
  throw new Error("invalid config shape");
69
73
  }
70
74
  // Merge with defaults so new fields get filled in on upgrade.
75
+ const health = { ...DEFAULT_CONFIG.health, ...parsed.health };
76
+ if (health.perProjectDefaultMax === undefined) health.perProjectDefaultMax = DEFAULT_CONFIG.health.perProjectDefaultMax;
77
+ if (health.maxNotesBytes === undefined || typeof health.maxNotesBytes !== "number" || Number.isNaN(health.maxNotesBytes) || health.maxNotesBytes < 0) {
78
+ health.maxNotesBytes = DEFAULT_CONFIG.health.maxNotesBytes;
79
+ }
71
80
  return {
72
81
  version: 1,
73
82
  prune: { ...DEFAULT_CONFIG.prune, ...parsed.prune },
74
- health: { ...DEFAULT_CONFIG.health, ...parsed.health },
83
+ health,
75
84
  };
76
85
  } catch {
77
86
  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;
@@ -26,13 +28,27 @@ export interface ArchiveHealth {
26
28
  export interface NotesBytes {
27
29
  total: number;
28
30
  max: number;
31
+ maxId: string | null; // v0.5.0: id of the todo with the largest notes (null if no todos)
29
32
  avg: number;
30
33
  }
31
34
 
32
35
  export type HealthFlag =
33
36
  | "ACTIVE_LARGE" | "ACTIVE_STALE"
34
37
  | "PARKED_LARGE" | "PARKED_STALE"
35
- | "ARCHIVE_LARGE" | "ARCHIVE_OLD";
38
+ | "ARCHIVE_LARGE" | "ARCHIVE_OLD"
39
+ | "NOTES_OVER"
40
+ | "PROJECT_OVER" | "PROJECT_TYPO" | "PROJECT_LARGE" | "PROJECT_STALE";
41
+
42
+ export interface ProjectHealth {
43
+ name: string;
44
+ open: number;
45
+ maxOpen: number | null;
46
+ over: boolean;
47
+ typo: boolean;
48
+ large: boolean;
49
+ stale: boolean;
50
+ lastUpdated: string;
51
+ }
36
52
 
37
53
  export interface HealthReport {
38
54
  active: ActiveHealth;
@@ -41,6 +57,8 @@ export interface HealthReport {
41
57
  notesBytes: NotesBytes;
42
58
  flags: HealthFlag[];
43
59
  suggestions: string[];
60
+ projects: ProjectHealth[]; // only projects with ≥1 flag, sorted open desc
61
+ noProject: { open: number }; // (no project) open count, for context
44
62
  }
45
63
 
46
64
  function daysAgo(iso: string): number {
@@ -53,6 +71,11 @@ export function healthReport(): HealthReport {
53
71
  const live = loadStore();
54
72
  const archive = loadArchive();
55
73
 
74
+ // reconcile registry first (lazy sync), persist iff changed
75
+ const reg = loadRegistry();
76
+ const { reg: synced, changed } = reconcileRegistry(reg, live.todos, archive.todos);
77
+ if (changed) saveRegistry(synced);
78
+
56
79
  const openTodos = live.todos.filter((t) => t.status === "open");
57
80
  const ipTodos = live.todos.filter((t) => t.status === "in_progress");
58
81
  const parkedTodos = live.todos.filter((t) => t.status === "parked");
@@ -63,12 +86,21 @@ export function healthReport(): HealthReport {
63
86
  const archiveOld = archive.todos.filter((t) => t.closedAt && daysAgo(t.closedAt) > h.archiveOldDays).length;
64
87
 
65
88
  // notes bytes across active + parked (archived excluded — sealed history).
89
+ // v0.5.0: track the worst-offender id so the NOTES_OVER suggestion is actionable.
66
90
  const apTodos = [...openTodos, ...ipTodos, ...parkedTodos];
67
- const notesSizes = apTodos.map((t) => Buffer.byteLength(t.notes, "utf8"));
91
+ let maxId: string | null = null;
92
+ let maxSize = 0;
93
+ let totalBytes = 0;
94
+ for (const t of apTodos) {
95
+ const s = Buffer.byteLength(t.notes, "utf8");
96
+ totalBytes += s;
97
+ if (s > maxSize) { maxSize = s; maxId = t.id; }
98
+ }
68
99
  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,
100
+ total: totalBytes,
101
+ max: maxSize,
102
+ maxId: apTodos.length ? maxId : null,
103
+ avg: apTodos.length ? Math.round(totalBytes / apTodos.length) : 0,
72
104
  };
73
105
 
74
106
  const active: ActiveHealth = {
@@ -81,6 +113,7 @@ export function healthReport(): HealthReport {
81
113
 
82
114
  const flags: HealthFlag[] = [];
83
115
  if (actionable.length > h.activeMaxOpen) flags.push("ACTIVE_LARGE");
116
+ if (notesBytes.max > h.maxNotesBytes) flags.push("NOTES_OVER");
84
117
  if (activeStale > 0) flags.push("ACTIVE_STALE");
85
118
  if (parkedTodos.length > h.parkedMax) flags.push("PARKED_LARGE");
86
119
  if (parkedStale > 0) flags.push("PARKED_STALE");
@@ -92,6 +125,47 @@ export function healthReport(): HealthReport {
92
125
  if (activeStale > 0) suggestions.push(`active: ${activeStale} open TODOs untouched for ${h.activeStaleDays}d → park or close them`);
93
126
  if (parkedStale > 0) suggestions.push(`parked: ${parkedStale} parked > ${h.parkedStaleDays}d → restore or hard-prune`);
94
127
  if (actionable.length > h.activeMaxOpen) suggestions.push(`active: ${actionable.length} open+in_progress (max ${h.activeMaxOpen}) → close or park some before adding more`);
95
-
96
- return { active, parked, archive: arch, notesBytes, flags, suggestions };
128
+ if (notesBytes.max > h.maxNotesBytes) {
129
+ const id = notesBytes.maxId ?? "<id>";
130
+ suggestions.push(`notes: largest note ${notesBytes.max}B > cap ${h.maxNotesBytes}B (on ${id}) → trim via todo update ${id} notes:…`);
131
+ }
132
+
133
+ // per-project flags (v0.4.0)
134
+ const archivedDone = archive.todos.filter((t) => t.status === "done");
135
+ const projectNames = new Set<string>();
136
+ for (const t of live.todos) { const p = t.project.trim(); if (p) projectNames.add(p); }
137
+ for (const t of archivedDone) { const p = t.project.trim(); if (p) projectNames.add(p); }
138
+
139
+ const projectHealth: ProjectHealth[] = [];
140
+ for (const name of projectNames) {
141
+ const liveForName = live.todos.filter((t) => t.project.trim() === name);
142
+ const open = liveForName.filter((t) => t.status === "open").length;
143
+ const entry = getProjectEntry(synced, name);
144
+ const maxOpen = entry?.maxOpen ?? null;
145
+ const over = maxOpen !== null && open > maxOpen;
146
+ const large = open > h.perProjectDefaultMax;
147
+ const lastUpdated = liveForName.length ? liveForName.map((t) => t.updatedAt).sort().at(-1) ?? "" : "";
148
+ const stale = lastUpdated !== "" && daysAgo(lastUpdated) > h.activeStaleDays;
149
+ const totalForName = liveForName.length + archivedDone.filter((t) => t.project.trim() === name).length;
150
+ const typo = totalForName === 1 && [...projectNames].some((o) => o !== name && levenshtein(name, o) <= 2);
151
+ if (over || large || stale || typo) {
152
+ projectHealth.push({ name, open, maxOpen, over, typo, large, stale, lastUpdated });
153
+ }
154
+ }
155
+ projectHealth.sort((a, b) => b.open - a.open || a.name.localeCompare(b.name));
156
+
157
+ for (const p of projectHealth) {
158
+ if (p.over) { flags.push("PROJECT_OVER"); suggestions.push(`project '${p.name}' ${p.open} open (maxOpen ${p.maxOpen}) → close/park some, or raise maxOpen`); }
159
+ if (p.large) { flags.push("PROJECT_LARGE"); suggestions.push(`project '${p.name}' ${p.open} open (per-project default max ${h.perProjectDefaultMax}) → over budget`); }
160
+ if (p.stale) { flags.push("PROJECT_STALE"); suggestions.push(`project '${p.name}' untouched > ${h.activeStaleDays}d → park or close`); }
161
+ if (p.typo) {
162
+ flags.push("PROJECT_TYPO");
163
+ const sib = [...projectNames].find((o) => o !== p.name && levenshtein(p.name, o) <= 2);
164
+ suggestions.push(`project '${p.name}' has 1 todo — possible typo of '${sib}'? → todo project rename ${p.name} ${sib}`);
165
+ }
166
+ }
167
+
168
+ const noProject = { open: live.todos.filter((t) => t.project.trim() === "" && t.status === "open").length };
169
+
170
+ return { active, parked, archive: arch, notesBytes, flags, suggestions, projects: projectHealth, noProject };
97
171
  }
@@ -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
@@ -60,6 +60,7 @@ export function configToSettingItems(cfg: TodoConfig): SettingItem[] {
60
60
  { id: "parkedStaleDays", label: "Parked stale (days)", currentValue: String(cfg.health.parkedStaleDays), values: ["30", "60", "90"], description: "Bloat flag when parked longer than this." },
61
61
  { id: "archiveMax", label: "Archive max", currentValue: String(cfg.health.archiveMax), values: ["100", "200", "500"], description: "Bloat flag when archive exceeds this." },
62
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." },
63
+ { id: "maxNotesBytes", label: "Notes max bytes", currentValue: String(cfg.health.maxNotesBytes), values: ["2048", "4096", "8192", "16384", "32768"], description: "Hard reject at add/update when notes exceeds this (bytes). 0 = no notes allowed." },
63
64
  ];
64
65
  }
65
66
 
@@ -79,3 +80,34 @@ export function actionsForDoneTodo(d: DoneItem): { label: string; action: string
79
80
  if (d.location === "archive") acts.push({ label: "Restore (from archive)", action: "restore" });
80
81
  return acts;
81
82
  }
83
+
84
+ // v0.4.0 — project overview (Projects tab) helpers.
85
+ import type { ProjectsOverview } from "./projects.ts";
86
+
87
+ /** Format the projects overview into SelectList items. Markers: OVER / typo. */
88
+ export function projectOverviewToItems(o: ProjectsOverview): SelectItem[] {
89
+ return o.rows.map((r) => {
90
+ const cap = r.maxOpen !== null ? ` [max:${r.maxOpen}]` : "";
91
+ const over = r.over ? " OVER" : "";
92
+ const typo = r.typo ? " ?typo" : "";
93
+ const last = r.lastUpdated ? ` · ${r.lastUpdated.slice(0, 10)}` : " · (no live)";
94
+ return {
95
+ value: r.name,
96
+ label: `${r.name} ${r.open}/${r.in_progress}/${r.parked}/${r.done} (total ${r.total})${cap}${over}${typo}${last}`,
97
+ };
98
+ });
99
+ }
100
+
101
+ /** Actions for a project row in the Projects tab. */
102
+ export function actionsForProject(): { label: string; action: string }[] {
103
+ return [
104
+ { label: "Rename / merge", action: "rename" },
105
+ { label: "Set maxOpen", action: "setmax" },
106
+ { label: "Filter active to project", action: "filter" },
107
+ ];
108
+ }
109
+
110
+ /** The (no project) summary row — non-selectable (no submenu). */
111
+ export function noProjectSummaryItem(o: ProjectsOverview): SelectItem {
112
+ return { value: "__noproject__", label: `(no project): ${o.noProject.count} total · ${o.noProject.open} open` };
113
+ }
package/src/panel.ts CHANGED
@@ -24,10 +24,12 @@ import { listTodos, parkTodo, completeTodo, deleteTodo, updateTodo, type Todo, t
24
24
  import { restoreTodo, archiveSummary, listArchived, listDoneUnified } from "./archive.ts";
25
25
  import { loadConfig, saveConfig, type TodoConfig } from "./config.ts";
26
26
  import { healthReport } from "./health.ts";
27
- import { todoToItem, archiveSummaryToItems, actionsForTodo, configToSettingItems, todoDoneItem, actionsForDoneTodo } from "./panel-data.ts";
27
+ import { projectsOverview } from "./projects.ts";
28
+ import { renameProject, setProjectMaxOpen, loadRegistry, saveRegistry } from "./registry.ts";
29
+ import { todoToItem, archiveSummaryToItems, actionsForTodo, configToSettingItems, todoDoneItem, actionsForDoneTodo, projectOverviewToItems, actionsForProject, noProjectSummaryItem } from "./panel-data.ts";
28
30
 
29
- export type Box = "active" | "parked" | "done" | "archive" | "config";
30
- const BOXES: Box[] = ["active", "parked", "done", "archive", "config"];
31
+ export type Box = "active" | "parked" | "done" | "archive" | "projects" | "config";
32
+ const BOXES: Box[] = ["active", "parked", "done", "archive", "projects", "config"];
31
33
 
32
34
  export interface TodoPanelOpts {
33
35
  theme: Theme;
@@ -52,6 +54,9 @@ export class TodoPanel extends Container {
52
54
  private settingsList: SettingsList | null = null;
53
55
  private config: TodoConfig;
54
56
  private healthFlags: string[] = [];
57
+ private projectFilterName = ""; // set by the "Filter active to project" action
58
+ private projectEditKind: "rename" | "setmax" | null = null;
59
+ private projectEditName = ""; // which project is being edited
55
60
 
56
61
  constructor(opts: TodoPanelOpts) {
57
62
  super();
@@ -101,7 +106,10 @@ export class TodoPanel extends Container {
101
106
  this.addChild(new Spacer(1));
102
107
 
103
108
  if (this.editMode && this.editInput) {
104
- this.addChild(new Text(this.theme.fg("accent", ` Edit [${this.editId}]:`), 0, 0));
109
+ const prompt = this.projectEditKind === "rename" ? ` Rename project '${this.projectEditName}' to:`
110
+ : this.projectEditKind === "setmax" ? ` Set maxOpen for '${this.projectEditName}' (number or 'clear'):`
111
+ : ` Edit [${this.editId}]:`;
112
+ this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
105
113
  this.addChild(this.editInput);
106
114
  this.addChild(new Text(this.theme.fg("dim", " enter save • esc cancel"), 0, 0));
107
115
  } else if (this.actionMode && this.actionList) {
@@ -137,7 +145,8 @@ export class TodoPanel extends Container {
137
145
  private refreshList(): void {
138
146
  const filter = this.filterInput.getValue();
139
147
  if (this.currentBox === "active") {
140
- const todos = listTodos({ text: filter || undefined, limit: 50 });
148
+ const project = this.projectFilterName || undefined;
149
+ const todos = listTodos({ project, text: filter || undefined, limit: 50 });
141
150
  this.setSelectItems(todos.map(todoToItem));
142
151
  } else if (this.currentBox === "parked") {
143
152
  const todos = listTodos({ status: "parked", text: filter || undefined, limit: 50 });
@@ -153,6 +162,10 @@ export class TodoPanel extends Container {
153
162
  const res = listArchived({ text: filter, limit: 50 });
154
163
  this.setSelectItems(res.items.map(todoToItem));
155
164
  }
165
+ } else if (this.currentBox === "projects") {
166
+ const overview = projectsOverview();
167
+ const rows = projectOverviewToItems(overview);
168
+ this.setSelectItems([noProjectSummaryItem(overview), ...rows]);
156
169
  }
157
170
  }
158
171
 
@@ -188,9 +201,87 @@ export class TodoPanel extends Container {
188
201
  this.renderShell();
189
202
  return;
190
203
  }
204
+ if (this.currentBox === "projects") {
205
+ if (item.value === "__noproject__") return; // (no project) summary — no submenu
206
+ this.openProjectSubmenu(item.value);
207
+ return;
208
+ }
191
209
  this.openActionSubmenu(item.value);
192
210
  }
193
211
 
212
+ private openProjectSubmenu(name: string): void {
213
+ const acts = actionsForProject();
214
+ const items: SelectItem[] = acts.map((a) => ({ value: a.action, label: a.label }));
215
+ this.actionList = new SelectList(items, 8, {
216
+ selectedPrefix: (s) => this.theme.fg("accent", s),
217
+ selectedText: (s) => this.theme.fg("accent", s),
218
+ description: (s) => this.theme.fg("muted", s),
219
+ scrollInfo: (s) => this.theme.fg("dim", s),
220
+ noMatch: (s) => this.theme.fg("warning", s),
221
+ });
222
+ this.actionList.onSelect = (a) => this.executeProjectAction(name, a.value);
223
+ this.actionList.onCancel = () => { this.actionMode = false; this.actionList = null; this.renderShell(); };
224
+ this.actionMode = true;
225
+ this.renderShell();
226
+ }
227
+
228
+ private async executeProjectAction(name: string, action: string): Promise<void> {
229
+ try {
230
+ if (action === "filter") {
231
+ this.projectFilterName = name;
232
+ this.currentBox = "active";
233
+ this.filterInput.setValue(""); // clear text filter; scope is via projectFilterName
234
+ this.actionMode = false; this.actionList = null;
235
+ this.refreshList();
236
+ this.renderShell();
237
+ this.onNotify(`Filtered active to project: ${name}`);
238
+ return;
239
+ }
240
+ if (action === "rename" || action === "setmax") {
241
+ this.projectEditKind = action;
242
+ this.projectEditName = name;
243
+ this.editInput = new Input();
244
+ this.editInput.setValue(""); // don't pre-fill: setValue leaves cursor at 0 (typing would prepend); the prompt labels the target
245
+ this.editInput.onSubmit = (value) => {
246
+ try {
247
+ if (this.projectEditKind === "rename") {
248
+ const r = renameProject(this.projectEditName, value.trim());
249
+ this.onNotify(`Renamed ${this.projectEditName} → ${r.newName} (${r.liveRenamed} live + ${r.archivedRenamed} archived${r.merged ? ", merged" : ""})`);
250
+ } else if (this.projectEditKind === "setmax") {
251
+ const v = value.trim().toLowerCase();
252
+ const max = v === "clear" || v === "" ? null : Number(v);
253
+ if (max !== null && !Number.isFinite(max)) throw new Error("maxOpen must be a number or 'clear'");
254
+ const reg = loadRegistry();
255
+ setProjectMaxOpen(reg, this.projectEditName, max);
256
+ saveRegistry(reg);
257
+ this.onNotify(`${this.projectEditName} maxOpen = ${max === null ? "cleared" : max}`);
258
+ }
259
+ } catch (err) { this.onNotify((err as Error).message, "error"); }
260
+ this.exitProjectEdit();
261
+ };
262
+ this.editInput.onEscape = () => this.exitProjectEdit();
263
+ this.actionMode = false; this.actionList = null;
264
+ this.editMode = true;
265
+ this.renderShell();
266
+ return;
267
+ }
268
+ } catch (err) {
269
+ this.onNotify((err as Error).message, "error");
270
+ }
271
+ this.actionMode = false; this.actionList = null;
272
+ this.refreshList();
273
+ this.renderShell();
274
+ }
275
+
276
+ private exitProjectEdit(): void {
277
+ this.editMode = false;
278
+ this.editInput = null;
279
+ this.projectEditKind = null;
280
+ this.projectEditName = "";
281
+ this.refreshList();
282
+ this.renderShell();
283
+ }
284
+
194
285
  private openActionSubmenu(id: string): void {
195
286
  let acts: { label: string; action: string }[];
196
287
  if (this.currentBox === "done") {
@@ -301,6 +392,7 @@ export class TodoPanel extends Container {
301
392
  case "parkedStaleDays": return String(c.health.parkedStaleDays);
302
393
  case "archiveMax": return String(c.health.archiveMax);
303
394
  case "archiveOldDays": return String(c.health.archiveOldDays);
395
+ case "maxNotesBytes": return String(c.health.maxNotesBytes);
304
396
  default: return "";
305
397
  }
306
398
  }
@@ -317,6 +409,7 @@ export class TodoPanel extends Container {
317
409
  case "parkedStaleDays": this.config.health.parkedStaleDays = n; break;
318
410
  case "archiveMax": this.config.health.archiveMax = n; break;
319
411
  case "archiveOldDays": this.config.health.archiveOldDays = n; break;
412
+ case "maxNotesBytes": this.config.health.maxNotesBytes = n; break;
320
413
  }
321
414
  saveConfig(this.config);
322
415
  this.onNotify(`Config saved: ${id} = ${value}`, "info");
@@ -335,6 +428,7 @@ export class TodoPanel extends Container {
335
428
  const next = (idx + dir + BOXES.length) % BOXES.length;
336
429
  this.currentBox = BOXES[next]!;
337
430
  this.filterInput.setValue("");
431
+ this.projectFilterName = ""; // reset project scope on tab switch
338
432
  this.actionMode = false;
339
433
  this.actionList = null;
340
434
  this.refreshList();
@@ -344,7 +438,7 @@ export class TodoPanel extends Container {
344
438
  handleInput(data: string): void {
345
439
  if (this.editMode && this.editInput) {
346
440
  if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
347
- this.exitEditMode();
441
+ if (this.projectEditKind) this.exitProjectEdit(); else this.exitEditMode();
348
442
  return;
349
443
  }
350
444
  this.editInput.handleInput(data);