@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/README.md +22 -8
- package/docs/superpowers/plans/2026-07-21-title-notes-split.md +1586 -0
- package/docs/superpowers/specs/2026-07-21-title-notes-split-design.md +411 -0
- package/docs/todo-SPEC.md +5 -0
- package/extensions/todo.ts +58 -28
- package/package.json +2 -2
- package/src/archive.ts +13 -3
- package/src/health.ts +17 -1
- package/src/migrate.ts +110 -1
- package/src/panel-data.ts +7 -14
- package/src/panel.ts +48 -4
- package/src/todo-store.ts +43 -21
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:
|
|
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:
|
|
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.
|
|
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)
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
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:
|
|
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
|
|
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?.
|
|
240
|
+
this.editInput.setValue(t?.title ?? "");
|
|
211
241
|
this.editInput.onSubmit = (value) => {
|
|
212
|
-
if (value.trim()) {
|
|
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(
|
|
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
|
-
|
|
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:
|
|
47
|
+
version: 3;
|
|
47
48
|
updatedAt: string;
|
|
48
49
|
todos: Todo[];
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export interface AddInput {
|
|
52
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
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");
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
222
|
-
|
|
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
|
-
|
|
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`;
|