@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/README.md +107 -14
- package/docs/superpowers/plans/2026-07-20-spec-1-store-layer.md +1691 -0
- package/docs/superpowers/plans/2026-07-20-spec-2-health-hard-prune.md +762 -0
- package/docs/superpowers/plans/2026-07-20-spec-3-interactive-panel.md +651 -0
- package/docs/superpowers/plans/2026-07-21-title-notes-split.md +1586 -0
- package/docs/superpowers/specs/2026-07-20-lifecycle-boxes-prune-design.md +323 -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 +273 -36
- package/package.json +2 -2
- package/src/archive.ts +214 -0
- package/src/config.ts +101 -0
- package/src/hard-prune.ts +89 -0
- package/src/health.ts +97 -0
- package/src/migrate.ts +154 -0
- package/src/panel-data.ts +63 -0
- package/src/panel.ts +382 -0
- package/src/paths.ts +36 -0
- package/src/todo-store.ts +86 -37
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
|
|
4
|
-
// (default ~/.pi/agent/todo.json
|
|
5
|
-
//
|
|
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
|
|
18
|
-
import {
|
|
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
|
-
|
|
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;
|
|
@@ -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:
|
|
104
|
+
return { version: 3, updatedAt: now(), todos: [] };
|
|
86
105
|
}
|
|
87
106
|
|
|
88
107
|
export function getStorePath(): string {
|
|
89
|
-
return
|
|
108
|
+
return getLivePath();
|
|
90
109
|
}
|
|
91
110
|
|
|
92
|
-
/** Load the store from disk.
|
|
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 (!
|
|
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(
|
|
97
|
-
|
|
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
|
|
102
|
-
//
|
|
103
|
-
|
|
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(
|
|
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
|
|
149
|
+
const path = getLivePath();
|
|
150
|
+
const dir = dirname(path);
|
|
120
151
|
mkdirSync(dir, { recursive: true });
|
|
121
|
-
const 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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
199
|
-
|
|
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
|
-
|
|
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`;
|