@getpipher/armory-todo 0.1.0 → 0.3.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.ts ADDED
@@ -0,0 +1,382 @@
1
+ // Interactive /todo TUI panel (SPEC-3) — a Container subclass adopting the
2
+ // @getpipher/cursor + @getpipher/vision pattern. Box tabs (Active / Parked /
3
+ // Archive / Config), a filter Input, a SelectList, an action submenu on Enter,
4
+ // and a SettingsList for config. Live-persist on every change. Non-TUI modes
5
+ // fall back to ctx.ui.notify (handled by the extension, not here).
6
+ //
7
+ // Manual-gate: the pi-tui components need a real terminal. The pure data
8
+ // helpers (panel-data.ts) are unit-tested; this component is verified in a
9
+ // real pi session.
10
+
11
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
12
+ import {
13
+ Container,
14
+ Input,
15
+ SelectList,
16
+ SettingsList,
17
+ Spacer,
18
+ Text,
19
+ matchesKey,
20
+ type SelectItem,
21
+ type Theme,
22
+ } from "@earendil-works/pi-tui";
23
+ import { listTodos, parkTodo, completeTodo, deleteTodo, updateTodo, type Todo, type Status } from "./todo-store.ts";
24
+ import { restoreTodo, archiveSummary, listArchived } from "./archive.ts";
25
+ import { loadConfig, saveConfig, type TodoConfig } from "./config.ts";
26
+ import { healthReport } from "./health.ts";
27
+ import { todoToItem, archiveSummaryToItems, actionsForTodo, configToSettingItems } from "./panel-data.ts";
28
+
29
+ export type Box = "active" | "parked" | "archive" | "config";
30
+ const BOXES: Box[] = ["active", "parked", "archive", "config"];
31
+
32
+ export interface TodoPanelOpts {
33
+ theme: Theme;
34
+ onDone: () => void;
35
+ onNotify: (msg: string, type?: "info" | "warning" | "error") => void;
36
+ }
37
+
38
+ export class TodoPanel extends Container {
39
+ private readonly theme: Theme;
40
+ private readonly onDone: () => void;
41
+ private readonly onNotify: (msg: string, type?: "info" | "warning" | "error") => void;
42
+ private currentBox: Box = "active";
43
+ private filterInput: Input;
44
+ private selectList: SelectList;
45
+ private actionMode = false;
46
+ private actionList: SelectList | null = null;
47
+ private editMode = false;
48
+ private editInput: Input | null = null;
49
+ private editId = "";
50
+ private detailMode = false;
51
+ private detailId = "";
52
+ private settingsList: SettingsList | null = null;
53
+ private config: TodoConfig;
54
+ private healthFlags: string[] = [];
55
+
56
+ constructor(opts: TodoPanelOpts) {
57
+ super();
58
+ this.theme = opts.theme;
59
+ this.onDone = opts.onDone;
60
+ this.onNotify = opts.onNotify;
61
+ this.config = loadConfig();
62
+ try { this.healthFlags = healthReport().flags; } catch { /* optional */ }
63
+
64
+ const accent = (s: string) => this.theme.fg("accent", s);
65
+ this.addChild(new DynamicBorder(accent));
66
+ this.addChild(new Spacer(1));
67
+
68
+ this.filterInput = new Input();
69
+ this.filterInput.onEscape = () => { this.onDone(); };
70
+
71
+ this.selectList = new SelectList([], 12, {
72
+ selectedPrefix: (s) => this.theme.fg("accent", s),
73
+ selectedText: (s) => this.theme.fg("accent", s),
74
+ description: (s) => this.theme.fg("muted", s),
75
+ scrollInfo: (s) => this.theme.fg("dim", s),
76
+ noMatch: (s) => this.theme.fg("warning", s),
77
+ });
78
+ this.selectList.onSelect = (item) => this.onItemSelect(item);
79
+ this.selectList.onCancel = () => { this.onDone(); };
80
+
81
+ this.refreshList();
82
+ this.renderShell();
83
+ }
84
+
85
+ private renderShell(): void {
86
+ // Keep children 0 (top border) + 1 (spacer); rebuild the rest.
87
+ const keep = this.children.slice(0, 2);
88
+ this.children.length = 0;
89
+ this.children.push(...keep);
90
+ this.settingsList = null; // cleared; renderConfigBox sets it if active
91
+
92
+ const accent = (s: string) => this.theme.fg("accent", s);
93
+ const tabs = BOXES.map((b) => b === this.currentBox ? this.theme.fg("accent", this.theme.bold(`[${b}]`)) : this.theme.fg("dim", b)).join(" ");
94
+ this.addChild(new Text(accent(this.theme.bold(" TODO")) + " " + tabs, 0, 0));
95
+ if (this.healthFlags.length > 0) {
96
+ this.addChild(new Text(this.theme.fg("warning", ` ⚠ ${this.healthFlags.length} bloat signals — see Config tab`), 0, 0));
97
+ }
98
+ this.addChild(new Spacer(1));
99
+ this.addChild(new Text(this.theme.fg("muted", " filter:"), 0, 0));
100
+ this.addChild(this.filterInput);
101
+ this.addChild(new Spacer(1));
102
+
103
+ if (this.editMode && this.editInput) {
104
+ this.addChild(new Text(this.theme.fg("accent", ` Edit [${this.editId}]:`), 0, 0));
105
+ this.addChild(this.editInput);
106
+ this.addChild(new Text(this.theme.fg("dim", " enter save • esc cancel"), 0, 0));
107
+ } else if (this.actionMode && this.actionList) {
108
+ this.addChild(new Text(this.theme.fg("accent", " Action:"), 0, 0));
109
+ this.addChild(this.actionList);
110
+ } else if (this.detailMode) {
111
+ const all = listTodos({ status: "all", limit: 200 });
112
+ const t = all.find((x) => x.id === this.detailId);
113
+ if (!t) { this.detailMode = false; this.renderShell(); return; }
114
+ const proj = t.project || "no project";
115
+ const tags = t.tags.length ? t.tags.join(" ") : "(none)";
116
+ const notesText = t.notes || "(empty)";
117
+ this.addChild(new Text(this.theme.fg("accent", " " + t.title), 0, 0));
118
+ this.addChild(new Text(this.theme.fg("muted", " (" + t.priority + "/" + t.status + ") - " + proj + " - #" + tags), 0, 0));
119
+ this.addChild(new Spacer(1));
120
+ this.addChild(new Text(this.theme.fg("dim", " notes:"), 0, 0));
121
+ this.addChild(new Text(" " + notesText, 0, 0));
122
+ this.addChild(new Spacer(1));
123
+ this.addChild(new Text(this.theme.fg("dim", " notes: read-only - todo update <id> notes=... to edit"), 0, 0));
124
+ } else if (this.currentBox === "config") {
125
+ this.renderConfigBox();
126
+ } else {
127
+ this.addChild(this.selectList);
128
+ }
129
+
130
+ this.addChild(new Spacer(1));
131
+ this.addChild(new Text(this.theme.fg("dim", " ↑↓ navigate • enter select/action • tab switch box • esc done"), 0, 0));
132
+ this.addChild(new Spacer(1));
133
+ this.addChild(new DynamicBorder(accent));
134
+ this.invalidate();
135
+ }
136
+
137
+ private refreshList(): void {
138
+ const filter = this.filterInput.getValue();
139
+ if (this.currentBox === "active") {
140
+ const todos = listTodos({ text: filter || undefined, limit: 50 });
141
+ this.setSelectItems(todos.map(todoToItem));
142
+ } else if (this.currentBox === "parked") {
143
+ const todos = listTodos({ status: "parked", text: filter || undefined, limit: 50 });
144
+ this.setSelectItems(todos.map(todoToItem));
145
+ } else if (this.currentBox === "archive") {
146
+ if (!filter) {
147
+ const s = archiveSummary();
148
+ this.setSelectItems(archiveSummaryToItems(s));
149
+ } else {
150
+ const res = listArchived({ text: filter, limit: 50 });
151
+ this.setSelectItems(res.items.map(todoToItem));
152
+ }
153
+ }
154
+ }
155
+
156
+ /** Replace the SelectList's items by reconstructing it (SelectList has no
157
+ * public items setter; setFilter does fuzzy matching on the original list). */
158
+ private setSelectItems(items: SelectItem[]): void {
159
+ const wasSelected = this.selectList.getSelectedItem();
160
+ const fresh = new SelectList(items, 12, {
161
+ selectedPrefix: (s) => this.theme.fg("accent", s),
162
+ selectedText: (s) => this.theme.fg("accent", s),
163
+ description: (s) => this.theme.fg("muted", s),
164
+ scrollInfo: (s) => this.theme.fg("dim", s),
165
+ noMatch: (s) => this.theme.fg("warning", s),
166
+ });
167
+ fresh.onSelect = (item) => this.onItemSelect(item);
168
+ fresh.onCancel = () => { this.onDone(); };
169
+ if (wasSelected) {
170
+ const idx = items.findIndex((i) => i.value === wasSelected.value);
171
+ if (idx >= 0) fresh.setSelectedIndex(idx);
172
+ }
173
+ this.selectList = fresh;
174
+ this.renderShell();
175
+ }
176
+
177
+ private onItemSelect(item: SelectItem): void {
178
+ if (this.currentBox === "archive" && (item.value === "total" || item.value.startsWith("project:") || item.value.startsWith("month:"))) {
179
+ if (item.value.startsWith("project:")) {
180
+ this.filterInput.setValue(item.value.slice("project:".length));
181
+ } else if (item.value.startsWith("month:")) {
182
+ this.filterInput.setValue(item.value.slice("month:".length));
183
+ }
184
+ this.refreshList();
185
+ this.renderShell();
186
+ return;
187
+ }
188
+ this.openActionSubmenu(item.value);
189
+ }
190
+
191
+ private openActionSubmenu(id: string): void {
192
+ const all = listTodos({ status: "all", limit: 200 });
193
+ const todo = all.find((t) => t.id === id);
194
+ if (!todo) {
195
+ this.onNotify("Todo not found in the live store (archive restore: use the archive box).", "info");
196
+ return;
197
+ }
198
+ const acts = [{ label: "View detail", action: "view" }, ...actionsForTodo(todo)];
199
+ const items: SelectItem[] = acts.map((a) => ({ value: a.action, label: a.label }));
200
+ this.actionList = new SelectList(items, 8, {
201
+ selectedPrefix: (s) => this.theme.fg("accent", s),
202
+ selectedText: (s) => this.theme.fg("accent", s),
203
+ description: (s) => this.theme.fg("muted", s),
204
+ scrollInfo: (s) => this.theme.fg("dim", s),
205
+ noMatch: (s) => this.theme.fg("warning", s),
206
+ });
207
+ this.actionList.onSelect = (a) => this.executeAction(id, a.value);
208
+ this.actionList.onCancel = () => { this.actionMode = false; this.actionList = null; this.renderShell(); };
209
+ this.actionMode = true;
210
+ this.renderShell();
211
+ }
212
+
213
+ private viewDetail(id: string): void {
214
+ const all = listTodos({ status: "all", limit: 200 });
215
+ const t = all.find((x) => x.id === id);
216
+ if (!t) { this.onNotify("Todo not found.", "info"); return; }
217
+ this.detailId = id;
218
+ this.detailMode = true;
219
+ this.actionMode = false;
220
+ this.actionList = null;
221
+ this.editMode = false;
222
+ this.editInput = null;
223
+ this.renderShell();
224
+ }
225
+
226
+ private async executeAction(id: string, action: string): Promise<void> {
227
+ try {
228
+ switch (action) {
229
+ case "view": this.viewDetail(id); return;
230
+ case "complete": completeTodo(id); this.onNotify(`Completed ${id}`); break;
231
+ case "park": parkTodo(id); this.onNotify(`Parked ${id}`); break;
232
+ case "open": updateTodo(id, { status: "open" as Status }); this.onNotify(`Re-activated ${id}`); break;
233
+ case "restore": restoreTodo(id); this.onNotify(`Restored ${id}`); break;
234
+ case "delete": deleteTodo(id); this.onNotify(`Cancelled ${id}`); break;
235
+ case "edit": {
236
+ const all = listTodos({ status: "all", limit: 200 });
237
+ const t = all.find((x) => x.id === id);
238
+ this.editId = id;
239
+ this.editInput = new Input();
240
+ this.editInput.setValue(t?.title ?? "");
241
+ this.editInput.onSubmit = (value) => {
242
+ if (value.trim()) {
243
+ try { updateTodo(id, { title: value.trim() }); this.onNotify(`Edited ${id}`); }
244
+ catch (err) { this.onNotify((err as Error).message, "error"); }
245
+ }
246
+ this.exitEditMode();
247
+ };
248
+ this.editInput.onEscape = () => this.exitEditMode();
249
+ this.actionMode = false;
250
+ this.actionList = null;
251
+ this.editMode = true;
252
+ this.renderShell();
253
+ break;
254
+ }
255
+ }
256
+ } catch (err) {
257
+ this.onNotify((err as Error).message, "error");
258
+ }
259
+ this.actionMode = false;
260
+ this.actionList = null;
261
+ this.refreshList();
262
+ this.renderShell();
263
+ }
264
+
265
+ private renderConfigBox(): void {
266
+ const settings = configToSettingItems(this.config);
267
+ const sl = new SettingsList(settings, 12, {
268
+ label: (text, sel) => sel ? this.theme.fg("accent", this.theme.bold(text)) : text,
269
+ value: (text, sel) => sel ? this.theme.fg("accent", text) : this.theme.fg("muted", text),
270
+ description: (text) => this.theme.fg("dim", text),
271
+ cursor: "❯",
272
+ hint: (text) => this.theme.fg("dim", text),
273
+ },
274
+ (id, newValue) => {
275
+ this.applyConfigChange(id, newValue);
276
+ sl.updateValue(id, this.configValueDisplay(id));
277
+ },
278
+ () => { this.onDone(); });
279
+ this.settingsList = sl;
280
+ this.addChild(sl);
281
+ }
282
+
283
+ private configValueDisplay(id: string): string {
284
+ const c = this.config;
285
+ switch (id) {
286
+ case "defaultAgeDays": return String(c.prune.defaultAgeDays);
287
+ case "hardAgeDays": return String(c.prune.hardAgeDays);
288
+ case "activeMaxOpen": return String(c.health.activeMaxOpen);
289
+ case "activeStaleDays": return String(c.health.activeStaleDays);
290
+ case "parkedMax": return String(c.health.parkedMax);
291
+ case "parkedStaleDays": return String(c.health.parkedStaleDays);
292
+ case "archiveMax": return String(c.health.archiveMax);
293
+ case "archiveOldDays": return String(c.health.archiveOldDays);
294
+ default: return "";
295
+ }
296
+ }
297
+
298
+ private applyConfigChange(id: string, value: string): void {
299
+ const n = Number(value);
300
+ if (!Number.isFinite(n)) return;
301
+ switch (id) {
302
+ case "defaultAgeDays": this.config.prune.defaultAgeDays = n; break;
303
+ case "hardAgeDays": this.config.prune.hardAgeDays = n; break;
304
+ case "activeMaxOpen": this.config.health.activeMaxOpen = n; break;
305
+ case "activeStaleDays": this.config.health.activeStaleDays = n; break;
306
+ case "parkedMax": this.config.health.parkedMax = n; break;
307
+ case "parkedStaleDays": this.config.health.parkedStaleDays = n; break;
308
+ case "archiveMax": this.config.health.archiveMax = n; break;
309
+ case "archiveOldDays": this.config.health.archiveOldDays = n; break;
310
+ }
311
+ saveConfig(this.config);
312
+ this.onNotify(`Config saved: ${id} = ${value}`, "info");
313
+ }
314
+
315
+ private exitEditMode(): void {
316
+ this.editMode = false;
317
+ this.editInput = null;
318
+ this.editId = "";
319
+ this.refreshList();
320
+ this.renderShell();
321
+ }
322
+
323
+ private switchBox(dir: 1 | -1): void {
324
+ const idx = BOXES.indexOf(this.currentBox);
325
+ const next = (idx + dir + BOXES.length) % BOXES.length;
326
+ this.currentBox = BOXES[next]!;
327
+ this.filterInput.setValue("");
328
+ this.actionMode = false;
329
+ this.actionList = null;
330
+ this.refreshList();
331
+ this.renderShell();
332
+ }
333
+
334
+ handleInput(data: string): void {
335
+ if (this.editMode && this.editInput) {
336
+ if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
337
+ this.exitEditMode();
338
+ return;
339
+ }
340
+ this.editInput.handleInput(data);
341
+ this.invalidate();
342
+ return;
343
+ }
344
+ if (this.detailMode) {
345
+ if (matchesKey(data, "escape") || matchesKey(data, "esc") || matchesKey(data, "enter") || matchesKey(data, "return")) {
346
+ this.detailMode = false;
347
+ this.detailId = "";
348
+ this.refreshList();
349
+ this.renderShell();
350
+ return;
351
+ }
352
+ this.invalidate();
353
+ return;
354
+ }
355
+ if (this.actionMode && this.actionList) {
356
+ if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
357
+ this.actionMode = false;
358
+ this.actionList = null;
359
+ this.renderShell();
360
+ return;
361
+ }
362
+ this.actionList.handleInput(data);
363
+ this.invalidate();
364
+ return;
365
+ }
366
+ if (matchesKey(data, "escape") || matchesKey(data, "esc")) { this.onDone(); return; }
367
+ if (matchesKey(data, "tab")) { this.switchBox(1); return; }
368
+ if (matchesKey(data, "shift+tab")) { this.switchBox(-1); return; }
369
+ if (matchesKey(data, "up") || matchesKey(data, "down") || matchesKey(data, "enter") || matchesKey(data, "return")) {
370
+ if (this.currentBox === "config" && this.settingsList) {
371
+ this.settingsList.handleInput(data);
372
+ } else {
373
+ this.selectList.handleInput(data);
374
+ }
375
+ this.invalidate();
376
+ return;
377
+ }
378
+ this.filterInput.handleInput(data);
379
+ this.refreshList();
380
+ this.invalidate();
381
+ }
382
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,36 @@
1
+ // Path resolution for the armory-todo folder layout (v2).
2
+ //
3
+ // All store files live under TODO_DIR (default ~/.pi/agent/todo/):
4
+ // todo.json — live store (open, in_progress, parked)
5
+ // todo-archive.json — sealed history (done, cancelled)
6
+ // todo.config.json — prune ages + health thresholds
7
+ //
8
+ // The legacy v1 single file was ~/.pi/agent/todo.json; migrate.ts handles
9
+ // moving it into the folder on first load.
10
+
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ const DEFAULT_DIR = join(homedir(), ".pi", "agent", "todo");
15
+ const LEGACY_PATH = join(homedir(), ".pi", "agent", "todo.json");
16
+
17
+ export function getTodoDir(): string {
18
+ return process.env.TODO_DIR || DEFAULT_DIR;
19
+ }
20
+
21
+ export function getLivePath(): string {
22
+ return join(getTodoDir(), "todo.json");
23
+ }
24
+
25
+ export function getArchivePath(): string {
26
+ return join(getTodoDir(), "todo-archive.json");
27
+ }
28
+
29
+ export function getConfigPath(): string {
30
+ return join(getTodoDir(), "todo.config.json");
31
+ }
32
+
33
+ /** The pre-v2 single-file store location. Used by migrate.ts. */
34
+ export function getLegacyPath(): string {
35
+ return LEGACY_PATH;
36
+ }
package/src/todo-store.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  // Pure, pi-independent TODO store for armory-todo.
2
2
  //
3
- // A global, cross-session TODO list backed by a single JSON file on disk
4
- // (default ~/.pi/agent/todo.json; override with TODO_STORE_PATH for tests).
5
- // Deliberately NOT pi session-entries this survives across all sessions.
3
+ // A global, cross-session TODO list backed by a JSON file under TODO_DIR
4
+ // (default ~/.pi/agent/todo/todo.json). The live store holds open, in_progress,
5
+ // and parked todos; done/cancelled are moved to todo-archive.json by `prune`
6
+ // (see archive.ts). v1 single-file stores at ~/.pi/agent/todo.json are
7
+ // migrated into the folder on first load (see migrate.ts).
6
8
  //
7
9
  // Kept free of any pi/typebox imports so it can be unit-tested standalone.
8
10
 
@@ -14,24 +16,23 @@ import {
14
16
  renameSync,
15
17
  writeFileSync,
16
18
  } from "node:fs";
17
- import { dirname, join } from "node:path";
18
- import { homedir } from "node:os";
19
-
20
- const DEFAULT_PATH = join(homedir(), ".pi", "agent", "todo.json");
21
- const STORE_PATH = process.env.TODO_STORE_PATH || DEFAULT_PATH;
19
+ import { dirname } from "node:path";
20
+ import { getLivePath, getTodoDir, getLegacyPath } from "./paths.ts";
21
+ import { migrateIfNeeded, migrateV2ToV3 } from "./migrate.ts";
22
22
 
23
23
  export type Priority = "low" | "med" | "high" | "critical";
24
- export type Status = "open" | "in_progress" | "done" | "cancelled";
24
+ export type Status = "open" | "in_progress" | "parked" | "done" | "cancelled";
25
25
 
26
26
  const PRIO_ORDER: Record<Priority, number> = { critical: 0, high: 1, med: 2, low: 3 };
27
27
  const PRIORITIES: Priority[] = ["low", "med", "high", "critical"];
28
- const STATUSES: Status[] = ["open", "in_progress", "done", "cancelled"];
28
+ const STATUSES: Status[] = ["open", "in_progress", "parked", "done", "cancelled"];
29
29
  const PRIO_SET = new Set(PRIORITIES);
30
30
  const STATUS_SET = new Set(STATUSES);
31
31
 
32
32
  export interface Todo {
33
33
  id: string;
34
- text: string;
34
+ title: string; // ≤120 chars, non-empty, trimmed
35
+ notes: string; // any length, may be ""
35
36
  project: string;
36
37
  tags: string[];
37
38
  priority: Priority;
@@ -43,13 +44,14 @@ export interface Todo {
43
44
  }
44
45
 
45
46
  export interface Store {
46
- version: 1;
47
+ version: 3;
47
48
  updatedAt: string;
48
49
  todos: Todo[];
49
50
  }
50
51
 
51
52
  export interface AddInput {
52
- text: string;
53
+ title: string;
54
+ notes?: string;
53
55
  project?: string;
54
56
  tags?: string[];
55
57
  priority?: Priority;
@@ -57,7 +59,8 @@ export interface AddInput {
57
59
  }
58
60
 
59
61
  export interface UpdateInput {
60
- text?: string;
62
+ title?: string;
63
+ notes?: string;
61
64
  project?: string;
62
65
  tags?: string[];
63
66
  priority?: Priority;
@@ -68,6 +71,11 @@ export interface ListFilter {
68
71
  status?: Status | "all";
69
72
  project?: string;
70
73
  tag?: string;
74
+ text?: string; // substring match on todo.text (case-insensitive)
75
+ since?: string; // ISO date; filter createdAt >= since
76
+ before?: string; // ISO date; filter createdAt < before
77
+ limit?: number; // default 20
78
+ page?: number; // default 1 (1-indexed)
71
79
  }
72
80
 
73
81
  export class TodoError extends Error {}
@@ -81,31 +89,53 @@ function genId(): string {
81
89
  return "td-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
82
90
  }
83
91
 
92
+ const TITLE_MAX = 120; // must match the constant in migrate.ts
93
+
94
+ function normalizeTitle(raw: string): string {
95
+ const t = raw.trim();
96
+ if (!t) throw new TodoError("title is required");
97
+ if (t.length > TITLE_MAX) {
98
+ throw new TodoError(`title must be ≤${TITLE_MAX} chars (got ${t.length}); move detail into notes`);
99
+ }
100
+ return t;
101
+ }
102
+
84
103
  function emptyStore(): Store {
85
- return { version: 1, updatedAt: now(), todos: [] };
104
+ return { version: 3, updatedAt: now(), todos: [] };
86
105
  }
87
106
 
88
107
  export function getStorePath(): string {
89
- return STORE_PATH;
108
+ return getLivePath();
90
109
  }
91
110
 
92
- /** Load the store from disk. On corruption, back up the bad file and start fresh. */
111
+ /** Load the live store from disk. Runs v1→v2 migration first but ONLY when
112
+ * using the default TODO_DIR (not overridden by TODO_DIR env, e.g. tests).
113
+ * On corruption, backs up the bad file and starts fresh. */
93
114
  export function loadStore(): Store {
94
- if (!existsSync(STORE_PATH)) return emptyStore();
115
+ if (!process.env.TODO_DIR) {
116
+ migrateIfNeeded({ todoDir: getTodoDir(), legacyPath: getLegacyPath() });
117
+ }
118
+ const path = getLivePath();
119
+ if (!existsSync(path)) return emptyStore();
95
120
  try {
96
- const raw = readFileSync(STORE_PATH, "utf8");
97
- const parsed = JSON.parse(raw) as Store;
121
+ const raw = readFileSync(path, "utf8");
122
+ let parsed = JSON.parse(raw) as Store;
98
123
  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.todos)) {
99
124
  throw new Error("invalid store shape");
100
125
  }
101
- if (parsed.version !== 1) {
102
- // Future: migrate. v1 only reset on unknown version with backup.
103
- throw new Error("unsupported store version: " + String(parsed.version));
126
+ if (parsed.version === 2) {
127
+ // v2 → v3: curated map + fallback, persist once so migration runs a single time.
128
+ const migrated = migrateV2ToV3(parsed as any) as unknown as Store;
129
+ saveStore(migrated);
130
+ return migrated;
131
+ }
132
+ if (parsed.version !== 3) {
133
+ throw new Error("invalid store shape");
104
134
  }
105
135
  return parsed;
106
136
  } catch {
107
137
  try {
108
- renameSync(STORE_PATH, `${STORE_PATH}.bad-${Date.now()}`);
138
+ renameSync(path, `${path}.bad-${Date.now()}`);
109
139
  } catch {
110
140
  // best-effort backup; swallow
111
141
  }
@@ -116,16 +146,17 @@ export function loadStore(): Store {
116
146
  /** Atomic, 0600 write. */
117
147
  export function saveStore(store: Store): void {
118
148
  store.updatedAt = now();
119
- const dir = dirname(STORE_PATH);
149
+ const path = getLivePath();
150
+ const dir = dirname(path);
120
151
  mkdirSync(dir, { recursive: true });
121
- const tmp = `${STORE_PATH}.tmp`;
152
+ const tmp = `${path}.tmp`;
122
153
  writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
123
154
  try {
124
155
  chmodSync(tmp, 0o600);
125
156
  } catch {
126
157
  // some filesystems ignore mode bits; not fatal
127
158
  }
128
- renameSync(tmp, STORE_PATH);
159
+ renameSync(tmp, path);
129
160
  }
130
161
 
131
162
  function assertPriority(p: unknown): asserts p is Priority {
@@ -147,13 +178,14 @@ function findOrFail(store: Store, id: string): Todo {
147
178
  }
148
179
 
149
180
  export function addTodo(input: AddInput): Todo {
150
- const text = (input.text ?? "").trim();
151
- if (!text) throw new TodoError("text is required");
181
+ const title = normalizeTitle(input.title);
152
182
  if (input.priority) assertPriority(input.priority);
183
+ const notes = (input.notes ?? "").trim();
153
184
  const store = loadStore();
154
185
  const todo: Todo = {
155
186
  id: genId(),
156
- text,
187
+ title,
188
+ notes,
157
189
  project: (input.project ?? "").trim(),
158
190
  tags: (input.tags ?? []).map((t) => t.trim()).filter(Boolean),
159
191
  priority: input.priority ?? "med",
@@ -180,7 +212,13 @@ export function listTodos(filter: ListFilter = {}): Todo[] {
180
212
  }
181
213
  if (filter.project) out = out.filter((t) => t.project === filter.project);
182
214
  if (filter.tag) out = out.filter((t) => t.tags.includes(filter.tag as string));
183
- return out.slice().sort((a, b) => {
215
+ if (filter.text) {
216
+ const q = filter.text.toLowerCase();
217
+ out = out.filter((t) => t.title.toLowerCase().includes(q) || t.notes.toLowerCase().includes(q));
218
+ }
219
+ if (filter.since) out = out.filter((t) => t.createdAt >= (filter.since as string));
220
+ if (filter.before) out = out.filter((t) => t.createdAt < (filter.before as string));
221
+ const sorted = out.slice().sort((a, b) => {
184
222
  if (a.status !== b.status) {
185
223
  // in_progress before open (actionable ordering)
186
224
  return a.status === "in_progress" ? -1 : b.status === "in_progress" ? 1 : 0;
@@ -190,16 +228,17 @@ export function listTodos(filter: ListFilter = {}): Todo[] {
190
228
  }
191
229
  return a.createdAt.localeCompare(b.createdAt);
192
230
  });
231
+ const limit = filter.limit ?? 20;
232
+ const page = filter.page ?? 1;
233
+ const start = (page - 1) * limit;
234
+ return sorted.slice(start, start + limit);
193
235
  }
194
236
 
195
237
  export function updateTodo(id: string, patch: UpdateInput): Todo {
196
238
  const store = loadStore();
197
239
  const todo = findOrFail(store, id);
198
- if (patch.text !== undefined) {
199
- const text = patch.text.trim();
200
- if (!text) throw new TodoError("text must not be empty");
201
- todo.text = text;
202
- }
240
+ if (patch.title !== undefined) todo.title = normalizeTitle(patch.title);
241
+ if (patch.notes !== undefined) todo.notes = patch.notes.trim();
203
242
  if (patch.project !== undefined) todo.project = patch.project.trim();
204
243
  if (patch.tags !== undefined) todo.tags = patch.tags.map((t) => t.trim()).filter(Boolean);
205
244
  if (patch.priority !== undefined) {
@@ -219,10 +258,19 @@ export function updateTodo(id: string, patch: UpdateInput): Todo {
219
258
  return todo;
220
259
  }
221
260
 
261
+ export function getTodo(id: string): Todo {
262
+ const store = loadStore();
263
+ return findOrFail(store, id);
264
+ }
265
+
222
266
  export function completeTodo(id: string): Todo {
223
267
  return updateTodo(id, { status: "done" });
224
268
  }
225
269
 
270
+ export function parkTodo(id: string): Todo {
271
+ return updateTodo(id, { status: "parked" });
272
+ }
273
+
226
274
  export function deleteTodo(id: string): Todo {
227
275
  return updateTodo(id, { status: "cancelled" });
228
276
  }
@@ -245,7 +293,8 @@ export function renderOpenBlock(max = 15): string {
245
293
  const lines = shown.map((t) => {
246
294
  const tag = t.project ? ` (${t.project})` : "";
247
295
  const pin = t.status === "in_progress" ? " ⏵" : "";
248
- return `- [${t.id}] (${t.priority})${pin} ${t.text}${tag}`;
296
+ const dot = t.notes.trim() ? " •" : "";
297
+ return `- [${t.id}] (${t.priority})${pin}${dot} ${t.title}${tag}`;
249
298
  });
250
299
  const overflow = todos.length > max ? `\n- … +${todos.length - max} more (use \`todo list\`)` : "";
251
300
  return `## Open TODOs (${todos.length})\n${lines.join("\n")}${overflow}\n`;