@getpipher/armory-todo 0.2.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/archive.ts CHANGED
@@ -8,12 +8,13 @@
8
8
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
9
  import { dirname } from "node:path";
10
10
  import { getArchivePath } from "./paths.ts";
11
+ import { migrateV2ToV3 } from "./migrate.ts";
11
12
  import type { Todo } from "./todo-store.ts";
12
13
  import { loadConfig } from "./config.ts";
13
14
  import { loadStore, saveStore, TodoError } from "./todo-store.ts";
14
15
 
15
16
  export interface ArchiveStore {
16
- version: 2;
17
+ version: 3;
17
18
  updatedAt: string;
18
19
  todos: Todo[];
19
20
  }
@@ -23,7 +24,7 @@ function now(): string {
23
24
  }
24
25
 
25
26
  function emptyArchive(): ArchiveStore {
26
- return { version: 2, updatedAt: now(), todos: [] };
27
+ return { version: 3, updatedAt: now(), todos: [] };
27
28
  }
28
29
 
29
30
  /** Load the archive. Missing file → empty store (no file created). */
@@ -36,6 +37,15 @@ export function loadArchive(): ArchiveStore {
36
37
  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.todos)) {
37
38
  throw new Error("invalid archive shape");
38
39
  }
40
+ if (parsed.version === 2) {
41
+ // v2 → v3: curated + fallback, persist once (symmetric with the live store).
42
+ const migrated = migrateV2ToV3(parsed as any) as unknown as ArchiveStore;
43
+ saveArchive(migrated);
44
+ return migrated;
45
+ }
46
+ if (parsed.version !== 3) {
47
+ throw new Error("invalid archive shape");
48
+ }
39
49
  return parsed;
40
50
  } catch {
41
51
  try {
@@ -190,7 +200,7 @@ export function listArchived(filter: ArchiveListFilter = {}): ArchiveListResult
190
200
  if (filter.status) out = out.filter((t) => t.status === filter.status);
191
201
  if (filter.text) {
192
202
  const q = filter.text.toLowerCase();
193
- out = out.filter((t) => t.text.toLowerCase().includes(q));
203
+ out = out.filter((t) => t.title.toLowerCase().includes(q) || t.notes.toLowerCase().includes(q));
194
204
  }
195
205
  if (filter.since) out = out.filter((t) => (t.closedAt ?? t.updatedAt) >= (filter.since as string));
196
206
  if (filter.before) out = out.filter((t) => (t.closedAt ?? t.updatedAt) < (filter.before as string));
package/src/health.ts CHANGED
@@ -23,6 +23,12 @@ export interface ArchiveHealth {
23
23
  older_180d: number; // closedAt older than archiveOldDays
24
24
  }
25
25
 
26
+ export interface NotesBytes {
27
+ total: number;
28
+ max: number;
29
+ avg: number;
30
+ }
31
+
26
32
  export type HealthFlag =
27
33
  | "ACTIVE_LARGE" | "ACTIVE_STALE"
28
34
  | "PARKED_LARGE" | "PARKED_STALE"
@@ -32,6 +38,7 @@ export interface HealthReport {
32
38
  active: ActiveHealth;
33
39
  parked: ParkedHealth;
34
40
  archive: ArchiveHealth;
41
+ notesBytes: NotesBytes;
35
42
  flags: HealthFlag[];
36
43
  suggestions: string[];
37
44
  }
@@ -55,6 +62,15 @@ export function healthReport(): HealthReport {
55
62
  const parkedStale = parkedTodos.filter((t) => daysAgo(t.updatedAt) > h.parkedStaleDays).length;
56
63
  const archiveOld = archive.todos.filter((t) => t.closedAt && daysAgo(t.closedAt) > h.archiveOldDays).length;
57
64
 
65
+ // notes bytes across active + parked (archived excluded — sealed history).
66
+ const apTodos = [...openTodos, ...ipTodos, ...parkedTodos];
67
+ const notesSizes = apTodos.map((t) => Buffer.byteLength(t.notes, "utf8"));
68
+ 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,
72
+ };
73
+
58
74
  const active: ActiveHealth = {
59
75
  open: openTodos.length,
60
76
  in_progress: ipTodos.length,
@@ -77,5 +93,5 @@ export function healthReport(): HealthReport {
77
93
  if (parkedStale > 0) suggestions.push(`parked: ${parkedStale} parked > ${h.parkedStaleDays}d → restore or hard-prune`);
78
94
  if (actionable.length > h.activeMaxOpen) suggestions.push(`active: ${actionable.length} open+in_progress (max ${h.activeMaxOpen}) → close or park some before adding more`);
79
95
 
80
- return { active, parked, archive: arch, flags, suggestions };
96
+ return { active, parked, archive: arch, notesBytes, flags, suggestions };
81
97
  }
package/src/migrate.ts CHANGED
@@ -10,6 +10,36 @@
10
10
  import { copyFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
 
13
+ // v2 → v3 schema migration helpers. splitTextFallback is used by loadStore's
14
+ // inline derivation (Task 1) and by migrateV2ToV3 (Task 2, with the curated
15
+ // map). TITLE_MAX here must match the constant in todo-store.ts.
16
+ const TITLE_MAX = 120;
17
+
18
+ /** Truncate at the last word boundary ≤ TITLE_MAX (hard cut if none). No "…"
19
+ * suffix — the cap is a hard rule, not a display truncation. */
20
+ function truncateWordBoundary(s: string): string {
21
+ if (s.length <= TITLE_MAX) return s;
22
+ const slice = s.slice(0, TITLE_MAX);
23
+ const sp = slice.lastIndexOf(" ");
24
+ return sp > 0 ? slice.slice(0, sp) : slice;
25
+ }
26
+
27
+ /** Derive { title, notes } from a v2 `text` string (the fallback for any v2
28
+ * todo not in the curated map). Deterministic + idempotent. */
29
+ export function splitTextFallback(text: string): { title: string; notes: string } {
30
+ const raw = (text ?? "").trim();
31
+ if (!raw) return { title: "(untitled)", notes: "" };
32
+ const nl = raw.indexOf("\n");
33
+ if (nl < 0) {
34
+ if (raw.length <= TITLE_MAX) return { title: raw, notes: "" };
35
+ return { title: truncateWordBoundary(raw), notes: raw };
36
+ }
37
+ const firstLine = raw.slice(0, nl).trim();
38
+ const rest = raw.slice(nl + 1).trim();
39
+ if (firstLine.length <= TITLE_MAX) return { title: firstLine, notes: rest };
40
+ return { title: truncateWordBoundary(firstLine), notes: `${firstLine}\n${rest}` };
41
+ }
42
+
13
43
  export interface MigrateInput {
14
44
  /** The v2 folder (e.g. ~/.pi/agent/todo/). */
15
45
  todoDir: string;
@@ -42,4 +72,83 @@ export function migrateIfNeeded(input: MigrateInput): void {
42
72
  try { copyFileSync(backup, input.legacyPath); } catch { /* best-effort */ }
43
73
  throw new Error(`migration failed: could not move ${input.legacyPath} → ${target}`);
44
74
  }
45
- }
75
+ }
76
+ // v2 → v3 schema migration: each todo gains title + notes (curated for the
77
+ // 2 ids known at migration time; splitTextFallback for the rest), drops text.
78
+ // Pure — does not touch disk. Deterministic + idempotent on v2 input.
79
+ // (splitTextFallback + TITLE_MAX are defined above, alongside migrateIfNeeded.)
80
+
81
+ /** A v2 todo (has `text`, no `title`/`notes`). */
82
+ export interface V2Todo {
83
+ id: string;
84
+ text: string;
85
+ project: string;
86
+ tags: string[];
87
+ priority: string;
88
+ status: string;
89
+ source: string;
90
+ createdAt: string;
91
+ updatedAt: string;
92
+ closedAt: string | null;
93
+ }
94
+
95
+ /** v2 store shape (input to migrateV2ToV3). */
96
+ export interface V2Store {
97
+ version: 2;
98
+ updatedAt: string;
99
+ todos: V2Todo[];
100
+ }
101
+
102
+ // Hand-curated title + notes for the 2 todos known at v2→v3 migration time
103
+ // (the only survivors of the v0.2.0 incident). Any other v2 todo uses
104
+ // splitTextFallback. Curated notes are reformatted for clarity, not a
105
+ // mechanical split.
106
+ const CURATED_V2_TO_V3: Record<string, { title: string; notes: string }> = {
107
+ "td-mrt3zp9fcnug3p": {
108
+ title: "ZeroClaw×Solana bounty — Phase 4-5: demo video (score bottleneck, unstarted)",
109
+ notes: `superteam.fun/earn/listing/zeroclaw · Superteam Brasil · 5,000 USDG pool / 1st=1,800 · winner Aug 21 2026 · TARGET #1.
110
+
111
+ PHASE 0-2 DONE ✅. PHASE 3 (RESEARCH+SPEC+PLAN + impl alerts+custody+docs) DONE ✅ — slices A-F+H, 45 tests, committed 8fd7483→80614c8, PUSHED, PR #76 retitled "Palinurus — depin-attest + depin-rewards", 17 commits.
112
+
113
+ claim_tx (G) DEFERRED — Helium hotspots are cNFTs → claim needs distribute_compression_rewards_v0 + DAS get_asset_proof (merkle proof), multi-session; PDAs verified, design in README.
114
+
115
+ Decision (score-max): ship alerts core complete, pivot to DEMO track.
116
+
117
+ NEXT (★ Phase 4-5, the score bottleneck — submission REQUIRES a demo video, currently unstarted):
118
+ (1) ASYNC: RECTOR's free Relay Community key → real Helium fixtures + live smoke test;
119
+ (2) Phase 4: wiring SVG (docs/wiring-diagram.svg, dark-mode, NOT ASCII) + marketing site (palinurus.rectorspace.com, Next.js+Tailwind+shadcn) + demo recording guide;
120
+ (3) Phase 5: record demo ≤3min (real ZeroClaw+Telegram, terminal+phone) → ElevenLabs voiceover → ffmpeg → submit on Superteam Earn + engage #solana-bounty Discord.
121
+
122
+ Test totals: 184 (71 palinurus-core + 68 depin-attest + 45 depin-rewards), all clippy+wasm clean.
123
+ HANDOFF: ~/Documents/secret/strategy/zeroclaw-solana/session-handoff-2026-07-21.md
124
+ Docs: {RESEARCH-3,SPEC-3,PLAN-3}-depin-rewards.md (SPEC-3 §4 + PLAN-3 G corrected for cNFT)
125
+ Cwd: ~/local-dev/RECTOR-LABS/zeroclaw-plugins/plugins/depin-rewards
126
+ PR: https://github.com/zeroclaw-labs/zeroclaw-plugins/pull/76`,
127
+ },
128
+ "td-mrt4e1qi9td6jz": {
129
+ title: "armory-todo v0.2.0 — Workstream A shipped (lifecycle boxes + prune + health + TUI)",
130
+ notes: `ALL 3 SPECS DONE ✅. SPEC-1 (store: parked+prune+archive+restore, 12 tasks), SPEC-2 (health+hard-prune, 6 tasks), SPEC-3 (interactive /todo TUI panel, 4 tasks). 147/147 tests across 7 suites. 24 commits on feat/spec-1-lifecycle-boxes, PR #3 retitled to full v0.2.0 scope. Auto-publish CI (release.yml, org NPM_TOKEN).
131
+
132
+ INCIDENT (SPEC-1 Task 9): migration bug destroyed real 52KB/47-todo store (35 done + ~10 open lost, no backup). FIXED (c034509): migration guarded to only run when TODO_DIR is default. RECOVERED: 2 todos.
133
+
134
+ Shipped: merge PR #3 → tag v0.2.0 → CI auto-publish → npm:@getpipher/armory-todo@0.2.0.
135
+ Out of scope: B (title+notes split), C (preventive caps+project registry).`,
136
+ },
137
+ };
138
+
139
+ /** Transform a v2 store into a v3 store: each todo gains title + notes
140
+ * (curated for the 2 known ids, splitTextFallback for the rest), drops text.
141
+ * Pure — does not touch disk. Deterministic + idempotent on v2 input. */
142
+ export function migrateV2ToV3(store: V2Store): { version: 3; updatedAt: string; todos: any[] } {
143
+ const todos = store.todos.map((t) => {
144
+ const curated = CURATED_V2_TO_V3[t.id];
145
+ if (curated) {
146
+ const { text: _drop, ...rest } = t;
147
+ return { ...rest, title: curated.title, notes: curated.notes };
148
+ }
149
+ const { title, notes } = splitTextFallback(t.text ?? "");
150
+ const { text: _drop, ...rest } = t;
151
+ return { ...rest, title, notes };
152
+ });
153
+ return { version: 3, updatedAt: store.updatedAt, todos };
154
+ }
package/src/panel-data.ts CHANGED
@@ -7,24 +7,17 @@ import type { Todo } from "./todo-store.ts";
7
7
  import type { ArchiveSummary } from "./archive.ts";
8
8
  import type { TodoConfig } from "./config.ts";
9
9
 
10
- /** Format a todo as a SelectList item: "[id] (prio)⏵ (project) text…".
11
- * The project tag is placed BEFORE the text so it's always visible (not
12
- * clipped at the right edge). The text is truncated to a readable summary
13
- * (first ~80 chars) so long running-log todos don't blow out the row width.
14
- * The full text is still accessible via the "Edit text" action in the panel. */
10
+ /** Format a todo as a SelectList item: "[id] (prio)⏵ (project) • title".
11
+ * title is already ≤120 chars (enforced at write time), so no truncation is
12
+ * needed. The marker shows when notes is non-empty (signals "open the
13
+ * detail view / use `todo get` for context"). */
15
14
  export function todoToItem(t: Todo): SelectItem {
16
15
  const pin = t.status === "in_progress" ? " ⏵" : "";
17
16
  const proj = t.project ? ` (${t.project})` : "";
18
- const prefix = `[${t.id}] (${t.priority})${pin}${proj}`;
19
- const maxText = 80;
20
- let text = t.text;
21
- if (text.length > maxText) {
22
- const firstLine = text.split("\n")[0]!;
23
- text = firstLine.length > maxText ? firstLine.slice(0, maxText - 1) + "…" : firstLine + "…";
24
- }
17
+ const dot = t.notes.trim() ? " •" : "";
25
18
  return {
26
19
  value: t.id,
27
- label: `${prefix} ${text}`,
20
+ label: `[${t.id}] (${t.priority})${pin}${proj}${dot} ${t.title}`,
28
21
  };
29
22
  }
30
23
 
@@ -50,7 +43,7 @@ export function actionsForTodo(t: Todo): { label: string; action: string }[] {
50
43
  if (t.status === "done" || t.status === "cancelled") {
51
44
  actions.push({ label: "Restore (from archive)", action: "restore" });
52
45
  }
53
- actions.push({ label: "Edit text", action: "edit" });
46
+ actions.push({ label: "Edit title", action: "edit" });
54
47
  actions.push({ label: "Delete (cancel)", action: "delete" });
55
48
  return actions;
56
49
  }
package/src/panel.ts CHANGED
@@ -47,6 +47,8 @@ export class TodoPanel extends Container {
47
47
  private editMode = false;
48
48
  private editInput: Input | null = null;
49
49
  private editId = "";
50
+ private detailMode = false;
51
+ private detailId = "";
50
52
  private settingsList: SettingsList | null = null;
51
53
  private config: TodoConfig;
52
54
  private healthFlags: string[] = [];
@@ -105,6 +107,20 @@ export class TodoPanel extends Container {
105
107
  } else if (this.actionMode && this.actionList) {
106
108
  this.addChild(new Text(this.theme.fg("accent", " Action:"), 0, 0));
107
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));
108
124
  } else if (this.currentBox === "config") {
109
125
  this.renderConfigBox();
110
126
  } else {
@@ -179,7 +195,7 @@ export class TodoPanel extends Container {
179
195
  this.onNotify("Todo not found in the live store (archive restore: use the archive box).", "info");
180
196
  return;
181
197
  }
182
- const acts = actionsForTodo(todo);
198
+ const acts = [{ label: "View detail", action: "view" }, ...actionsForTodo(todo)];
183
199
  const items: SelectItem[] = acts.map((a) => ({ value: a.action, label: a.label }));
184
200
  this.actionList = new SelectList(items, 8, {
185
201
  selectedPrefix: (s) => this.theme.fg("accent", s),
@@ -194,9 +210,23 @@ export class TodoPanel extends Container {
194
210
  this.renderShell();
195
211
  }
196
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
+
197
226
  private async executeAction(id: string, action: string): Promise<void> {
198
227
  try {
199
228
  switch (action) {
229
+ case "view": this.viewDetail(id); return;
200
230
  case "complete": completeTodo(id); this.onNotify(`Completed ${id}`); break;
201
231
  case "park": parkTodo(id); this.onNotify(`Parked ${id}`); break;
202
232
  case "open": updateTodo(id, { status: "open" as Status }); this.onNotify(`Re-activated ${id}`); break;
@@ -207,9 +237,12 @@ export class TodoPanel extends Container {
207
237
  const t = all.find((x) => x.id === id);
208
238
  this.editId = id;
209
239
  this.editInput = new Input();
210
- this.editInput.setValue(t?.text ?? "");
240
+ this.editInput.setValue(t?.title ?? "");
211
241
  this.editInput.onSubmit = (value) => {
212
- if (value.trim()) { updateTodo(id, { text: value.trim() }); this.onNotify(`Edited ${id}`); }
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
+ }
213
246
  this.exitEditMode();
214
247
  };
215
248
  this.editInput.onEscape = () => this.exitEditMode();
@@ -221,7 +254,7 @@ export class TodoPanel extends Container {
221
254
  }
222
255
  }
223
256
  } catch (err) {
224
- this.onNotify(`Error: ${(err as Error).message}`, "error");
257
+ this.onNotify((err as Error).message, "error");
225
258
  }
226
259
  this.actionMode = false;
227
260
  this.actionList = null;
@@ -308,6 +341,17 @@ export class TodoPanel extends Container {
308
341
  this.invalidate();
309
342
  return;
310
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
+ }
311
355
  if (this.actionMode && this.actionList) {
312
356
  if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
313
357
  this.actionMode = false;
package/src/todo-store.ts CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  } from "node:fs";
19
19
  import { dirname } from "node:path";
20
20
  import { getLivePath, getTodoDir, getLegacyPath } from "./paths.ts";
21
- import { migrateIfNeeded } from "./migrate.ts";
21
+ import { migrateIfNeeded, migrateV2ToV3 } from "./migrate.ts";
22
22
 
23
23
  export type Priority = "low" | "med" | "high" | "critical";
24
24
  export type Status = "open" | "in_progress" | "parked" | "done" | "cancelled";
@@ -31,7 +31,8 @@ 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: 2;
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;
@@ -86,8 +89,19 @@ function genId(): string {
86
89
  return "td-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
87
90
  }
88
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
+
89
103
  function emptyStore(): Store {
90
- return { version: 2, updatedAt: now(), todos: [] };
104
+ return { version: 3, updatedAt: now(), todos: [] };
91
105
  }
92
106
 
93
107
  export function getStorePath(): string {
@@ -105,14 +119,18 @@ export function loadStore(): Store {
105
119
  if (!existsSync(path)) return emptyStore();
106
120
  try {
107
121
  const raw = readFileSync(path, "utf8");
108
- const parsed = JSON.parse(raw) as Store;
122
+ let parsed = JSON.parse(raw) as Store;
109
123
  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.todos)) {
110
124
  throw new Error("invalid store shape");
111
125
  }
112
- if (parsed.version !== 2) {
113
- // v1v2: accept it (the migration moved the file), just bump the version in memory.
114
- // The data shape is otherwise identical; parked status is new but old todos won't have it.
115
- parsed.version = 2;
126
+ if (parsed.version === 2) {
127
+ // v2v3: 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");
116
134
  }
117
135
  return parsed;
118
136
  } catch {
@@ -160,13 +178,14 @@ function findOrFail(store: Store, id: string): Todo {
160
178
  }
161
179
 
162
180
  export function addTodo(input: AddInput): Todo {
163
- const text = (input.text ?? "").trim();
164
- if (!text) throw new TodoError("text is required");
181
+ const title = normalizeTitle(input.title);
165
182
  if (input.priority) assertPriority(input.priority);
183
+ const notes = (input.notes ?? "").trim();
166
184
  const store = loadStore();
167
185
  const todo: Todo = {
168
186
  id: genId(),
169
- text,
187
+ title,
188
+ notes,
170
189
  project: (input.project ?? "").trim(),
171
190
  tags: (input.tags ?? []).map((t) => t.trim()).filter(Boolean),
172
191
  priority: input.priority ?? "med",
@@ -195,7 +214,7 @@ export function listTodos(filter: ListFilter = {}): Todo[] {
195
214
  if (filter.tag) out = out.filter((t) => t.tags.includes(filter.tag as string));
196
215
  if (filter.text) {
197
216
  const q = filter.text.toLowerCase();
198
- out = out.filter((t) => t.text.toLowerCase().includes(q));
217
+ out = out.filter((t) => t.title.toLowerCase().includes(q) || t.notes.toLowerCase().includes(q));
199
218
  }
200
219
  if (filter.since) out = out.filter((t) => t.createdAt >= (filter.since as string));
201
220
  if (filter.before) out = out.filter((t) => t.createdAt < (filter.before as string));
@@ -218,11 +237,8 @@ export function listTodos(filter: ListFilter = {}): Todo[] {
218
237
  export function updateTodo(id: string, patch: UpdateInput): Todo {
219
238
  const store = loadStore();
220
239
  const todo = findOrFail(store, id);
221
- if (patch.text !== undefined) {
222
- const text = patch.text.trim();
223
- if (!text) throw new TodoError("text must not be empty");
224
- todo.text = text;
225
- }
240
+ if (patch.title !== undefined) todo.title = normalizeTitle(patch.title);
241
+ if (patch.notes !== undefined) todo.notes = patch.notes.trim();
226
242
  if (patch.project !== undefined) todo.project = patch.project.trim();
227
243
  if (patch.tags !== undefined) todo.tags = patch.tags.map((t) => t.trim()).filter(Boolean);
228
244
  if (patch.priority !== undefined) {
@@ -242,6 +258,11 @@ export function updateTodo(id: string, patch: UpdateInput): Todo {
242
258
  return todo;
243
259
  }
244
260
 
261
+ export function getTodo(id: string): Todo {
262
+ const store = loadStore();
263
+ return findOrFail(store, id);
264
+ }
265
+
245
266
  export function completeTodo(id: string): Todo {
246
267
  return updateTodo(id, { status: "done" });
247
268
  }
@@ -272,7 +293,8 @@ export function renderOpenBlock(max = 15): string {
272
293
  const lines = shown.map((t) => {
273
294
  const tag = t.project ? ` (${t.project})` : "";
274
295
  const pin = t.status === "in_progress" ? " ⏵" : "";
275
- 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}`;
276
298
  });
277
299
  const overflow = todos.length > max ? `\n- … +${todos.length - max} more (use \`todo list\`)` : "";
278
300
  return `## Open TODOs (${todos.length})\n${lines.join("\n")}${overflow}\n`;