@getpipher/armory-todo 0.3.1 → 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/src/panel-data.ts CHANGED
@@ -79,3 +79,34 @@ export function actionsForDoneTodo(d: DoneItem): { label: string; action: string
79
79
  if (d.location === "archive") acts.push({ label: "Restore (from archive)", action: "restore" });
80
80
  return acts;
81
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
+ }
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") {
@@ -335,6 +426,7 @@ export class TodoPanel extends Container {
335
426
  const next = (idx + dir + BOXES.length) % BOXES.length;
336
427
  this.currentBox = BOXES[next]!;
337
428
  this.filterInput.setValue("");
429
+ this.projectFilterName = ""; // reset project scope on tab switch
338
430
  this.actionMode = false;
339
431
  this.actionList = null;
340
432
  this.refreshList();
@@ -344,7 +436,7 @@ export class TodoPanel extends Container {
344
436
  handleInput(data: string): void {
345
437
  if (this.editMode && this.editInput) {
346
438
  if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
347
- this.exitEditMode();
439
+ if (this.projectEditKind) this.exitProjectEdit(); else this.exitEditMode();
348
440
  return;
349
441
  }
350
442
  this.editInput.handleInput(data);
package/src/paths.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // todo.json — live store (open, in_progress, parked)
5
5
  // todo-archive.json — sealed history (done, cancelled)
6
6
  // todo.config.json — prune ages + health thresholds
7
+ // projects.json — project registry (canonical names + maxOpen slots, v0.4.0)
7
8
  //
8
9
  // The legacy v1 single file was ~/.pi/agent/todo.json; migrate.ts handles
9
10
  // moving it into the folder on first load.
@@ -30,6 +31,10 @@ export function getConfigPath(): string {
30
31
  return join(getTodoDir(), "todo.config.json");
31
32
  }
32
33
 
34
+ export function getRegistryPath(): string {
35
+ return join(getTodoDir(), "projects.json");
36
+ }
37
+
33
38
  /** The pre-v2 single-file store location. Used by migrate.ts. */
34
39
  export function getLegacyPath(): string {
35
40
  return LEGACY_PATH;
@@ -0,0 +1,91 @@
1
+ // Per-project scope overview for armory-todo (Feature A). Pure read that
2
+ // reconciles the registry first (lazy sync), then aggregates counts across
3
+ // the live store + archived done. The `projects` action + panel Projects tab
4
+ // + the per-project health flags all consume this shape (or its derivatives).
5
+
6
+ import { loadStore, type Todo } from "./todo-store.ts";
7
+ import { loadArchive } from "./archive.ts";
8
+ import { loadRegistry, reconcileRegistry, saveRegistry, getProjectEntry } from "./registry.ts";
9
+ import { levenshtein } from "./levenshtein.ts";
10
+
11
+ export interface ProjectOverviewRow {
12
+ name: string;
13
+ open: number;
14
+ in_progress: number;
15
+ parked: number;
16
+ done: number; // live done + archived done
17
+ total: number; // open + in_progress + parked + done
18
+ maxOpen: number | null;
19
+ over: boolean; // open > maxOpen (only when maxOpen !== null)
20
+ typo: boolean; // total === 1 AND a near-sibling (levenshtein ≤ 2) exists
21
+ lastUpdated: string; // max updatedAt across the project's live todos (ISO), or "" if none
22
+ }
23
+
24
+ export interface ProjectsOverview {
25
+ rows: ProjectOverviewRow[]; // sorted: open desc → total desc → name asc
26
+ totalTodos: number; // sum of rows' total
27
+ noProject: { count: number; open: number }; // the (no project) bucket, not a row
28
+ }
29
+
30
+ export function projectsOverview(): ProjectsOverview {
31
+ const live = loadStore();
32
+ const archive = loadArchive();
33
+ const archivedDone = archive.todos.filter((t) => t.status === "done");
34
+
35
+ // reconcile registry first (lazy sync), persist iff changed
36
+ const reg = loadRegistry();
37
+ const { reg: synced, changed } = reconcileRegistry(reg, live.todos, archive.todos);
38
+ if (changed) saveRegistry(synced);
39
+
40
+ const liveBy = new Map<string, Todo[]>();
41
+ for (const t of live.todos) {
42
+ const key = t.project.trim();
43
+ const list = liveBy.get(key) ?? [];
44
+ list.push(t);
45
+ liveBy.set(key, list);
46
+ }
47
+ const archivedDoneBy = new Map<string, number>();
48
+ for (const t of archivedDone) {
49
+ const key = t.project.trim();
50
+ archivedDoneBy.set(key, (archivedDoneBy.get(key) ?? 0) + 1);
51
+ }
52
+
53
+ const names = new Set<string>([...liveBy.keys(), ...archivedDoneBy.keys()].filter((n) => n !== ""));
54
+
55
+ let totalTodos = 0;
56
+ const rows: ProjectOverviewRow[] = [];
57
+ for (const name of names) {
58
+ const liveForName = liveBy.get(name) ?? [];
59
+ const open = liveForName.filter((t) => t.status === "open").length;
60
+ const in_progress = liveForName.filter((t) => t.status === "in_progress").length;
61
+ const parked = liveForName.filter((t) => t.status === "parked").length;
62
+ const done = liveForName.filter((t) => t.status === "done").length + (archivedDoneBy.get(name) ?? 0);
63
+ const total = open + in_progress + parked + done;
64
+ totalTodos += total;
65
+ const entry = getProjectEntry(synced, name);
66
+ const maxOpen = entry?.maxOpen ?? null;
67
+ const over = maxOpen !== null && open > maxOpen;
68
+ const lastUpdated = liveForName.length
69
+ ? liveForName.map((t) => t.updatedAt).sort().at(-1) ?? ""
70
+ : "";
71
+ rows.push({ name, open, in_progress, parked, done, total, maxOpen, over, typo: false, lastUpdated });
72
+ }
73
+
74
+ // typo: total === 1 AND a near-sibling (levenshtein ≤ 2) among other names
75
+ for (const row of rows) {
76
+ if (row.total === 1) {
77
+ row.typo = [...names].some((other) => other !== row.name && levenshtein(row.name, other) <= 2);
78
+ }
79
+ }
80
+
81
+ // (no project) bucket
82
+ const noProjectLive = live.todos.filter((t) => t.project.trim() === "");
83
+ const noProjectArchivedDone = archivedDone.filter((t) => t.project.trim() === "");
84
+ const noProject = {
85
+ count: noProjectLive.length + noProjectArchivedDone.length,
86
+ open: noProjectLive.filter((t) => t.status === "open").length,
87
+ };
88
+
89
+ rows.sort((a, b) => b.open - a.open || b.total - a.total || a.name.localeCompare(b.name));
90
+ return { rows, totalTodos, noProject };
91
+ }
@@ -0,0 +1,175 @@
1
+ // Project registry for armory-todo — a sibling file to todo.json holding the
2
+ // canonical list of known projects + their per-project advisory cap slot
3
+ // (`maxOpen`). Advisory in v0.4.0 (drives a health flag); enforcement
4
+ // (block-on-add) graduates in v0.5.0.
5
+ //
6
+ // File: <TODO_DIR>/projects.json (0600, atomic write). Lazy-synced on read:
7
+ // `reconcileRegistry` appends any unknown project strings (live + archived)
8
+ // with maxOpen:null. `loadRegistry` is side-effect-free (missing → empty,
9
+ // no file created); seeding happens on the first reconcile call.
10
+ // No env guard — projects.json always lives under TODO_DIR (temp dir in tests).
11
+
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
+ import { dirname } from "node:path";
14
+ import { getRegistryPath } from "./paths.ts";
15
+ import { loadStore, saveStore, TodoError, type Todo } from "./todo-store.ts";
16
+ import { loadArchive, saveArchive } from "./archive.ts";
17
+
18
+ export interface ProjectEntry {
19
+ name: string;
20
+ maxOpen: number | null; // null = no advisory cap for this project
21
+ createdAt: string;
22
+ updatedAt: string;
23
+ }
24
+
25
+ export interface ProjectRegistry {
26
+ version: 1;
27
+ updatedAt: string;
28
+ projects: ProjectEntry[];
29
+ }
30
+
31
+ function now(): string { return new Date().toISOString(); }
32
+
33
+ function emptyRegistry(): ProjectRegistry {
34
+ return { version: 1, updatedAt: now(), projects: [] };
35
+ }
36
+
37
+ export { getRegistryPath } from "./paths.ts";
38
+
39
+ export function loadRegistry(): ProjectRegistry {
40
+ const path = getRegistryPath();
41
+ if (!existsSync(path)) return emptyRegistry();
42
+ try {
43
+ const raw = readFileSync(path, "utf8");
44
+ const parsed = JSON.parse(raw) as ProjectRegistry;
45
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.projects)) {
46
+ throw new Error("invalid registry shape");
47
+ }
48
+ if (parsed.version !== 1) throw new Error("invalid registry shape");
49
+ return parsed;
50
+ } catch {
51
+ try {
52
+ renameSync(path, `${path}.bad-${Date.now()}`);
53
+ } catch {
54
+ // best-effort backup
55
+ }
56
+ return emptyRegistry();
57
+ }
58
+ }
59
+
60
+ export function saveRegistry(reg: ProjectRegistry): void {
61
+ reg.updatedAt = now();
62
+ const path = getRegistryPath();
63
+ const dir = dirname(path);
64
+ mkdirSync(dir, { recursive: true });
65
+ const tmp = `${path}.tmp`;
66
+ writeFileSync(tmp, JSON.stringify(reg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
67
+ try { chmodSync(tmp, 0o600); } catch { /* fs may ignore mode bits */ }
68
+ renameSync(tmp, path);
69
+ }
70
+
71
+ /**
72
+ * Lazy sync: append any unknown non-empty project strings (from live + archived
73
+ * todos) as new entries with maxOpen:null. Returns { reg, changed }. Caller
74
+ * persists iff changed. Idempotent (a second call with no new names → changed=false).
75
+ */
76
+ export function reconcileRegistry(
77
+ reg: ProjectRegistry,
78
+ liveTodos: Todo[],
79
+ archivedTodos: Todo[],
80
+ ): { reg: ProjectRegistry; changed: boolean } {
81
+ const known = new Set(reg.projects.map((p) => p.name));
82
+ const names = new Set<string>();
83
+ for (const t of liveTodos) { const p = t.project.trim(); if (p) names.add(p); }
84
+ for (const t of archivedTodos) { const p = t.project.trim(); if (p) names.add(p); }
85
+ let changed = false;
86
+ for (const name of names) {
87
+ if (!known.has(name)) {
88
+ reg.projects.push({ name, maxOpen: null, createdAt: now(), updatedAt: now() });
89
+ changed = true;
90
+ }
91
+ }
92
+ if (changed) reg.updatedAt = now();
93
+ return { reg, changed };
94
+ }
95
+
96
+ export function getProjectEntry(reg: ProjectRegistry, name: string): ProjectEntry | undefined {
97
+ return reg.projects.find((p) => p.name === name);
98
+ }
99
+
100
+ /**
101
+ * Set a project's maxOpen slot. `max = null` clears. Creates the entry if the
102
+ * name is unknown (with createdAt/updatedAt = now). Throws if name is "" (the
103
+ * (no project) group can't be capped). Mutates `reg` in place + returns the entry.
104
+ */
105
+ export function setProjectMaxOpen(reg: ProjectRegistry, name: string, max: number | null): ProjectEntry {
106
+ const trimmed = name.trim();
107
+ if (!trimmed) throw new TodoError("cannot set maxOpen on the (no project) group");
108
+ if (max !== null && (!Number.isFinite(max) || max < 0)) {
109
+ throw new TodoError(`maxOpen must be a non-negative number or null (got ${String(max)})`);
110
+ }
111
+ let entry = getProjectEntry(reg, trimmed);
112
+ if (!entry) {
113
+ entry = { name: trimmed, maxOpen: null, createdAt: now(), updatedAt: now() };
114
+ reg.projects.push(entry);
115
+ }
116
+ entry.maxOpen = max;
117
+ entry.updatedAt = now();
118
+ reg.updatedAt = now();
119
+ return entry;
120
+ }
121
+
122
+ export interface RenameResult {
123
+ liveRenamed: number;
124
+ archivedRenamed: number;
125
+ merged: boolean;
126
+ newName: string;
127
+ }
128
+
129
+ /**
130
+ * Rename (or merge) a project: rewrite every live + archived todo whose
131
+ * `project === oldName` to `newName`, remove the `oldName` registry entry,
132
+ * and ensure the `newName` entry exists. Best-effort multi-file (no WAL):
133
+ * live → archive → registry, each saved atomically with backup-on-corrupt.
134
+ * Throws if `oldName` is not in the registry. Self-rename is a no-op success.
135
+ */
136
+ export function renameProject(oldName: string, newName: string): RenameResult {
137
+ const old = oldName.trim();
138
+ const next = newName.trim();
139
+ if (!old) throw new TodoError("oldName is required");
140
+ if (!next) throw new TodoError("newName is required");
141
+ if (old === next) return { liveRenamed: 0, archivedRenamed: 0, merged: false, newName: next };
142
+
143
+ const reg = loadRegistry();
144
+ const oldEntry = getProjectEntry(reg, old);
145
+ if (!oldEntry) throw new TodoError(`no project named '${old}' in the registry`);
146
+ const merged = getProjectEntry(reg, next) !== undefined;
147
+
148
+ // 1. live store
149
+ const live = loadStore();
150
+ let liveRenamed = 0;
151
+ for (const t of live.todos) {
152
+ if (t.project === old) { t.project = next; t.updatedAt = now(); liveRenamed++; }
153
+ }
154
+ if (liveRenamed > 0) saveStore(live);
155
+
156
+ // 2. archive
157
+ const archive = loadArchive();
158
+ let archivedRenamed = 0;
159
+ for (const t of archive.todos) {
160
+ if (t.project === old) { t.project = next; archivedRenamed++; }
161
+ }
162
+ if (archivedRenamed > 0) saveArchive(archive);
163
+
164
+ // 3. registry: remove old, ensure next exists
165
+ reg.projects = reg.projects.filter((p) => p.name !== old);
166
+ if (!getProjectEntry(reg, next)) {
167
+ // non-merge: preserve the renamed project's cap + provenance (don't silently drop maxOpen)
168
+ reg.projects.push({ name: next, maxOpen: oldEntry.maxOpen, createdAt: oldEntry.createdAt, updatedAt: now() });
169
+ }
170
+ // merge (next already exists): keep next's existing entry + cap unchanged
171
+ reg.updatedAt = now();
172
+ saveRegistry(reg);
173
+
174
+ return { liveRenamed, archivedRenamed, merged, newName: next };
175
+ }