@getpipher/armory-todo 0.1.0 → 0.2.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.
@@ -0,0 +1,89 @@
1
+ // The ONLY irreversible deletion path in armory-todo. Everything else is
2
+ // reversible (park = status flip, prune = archive move, restore = move back).
3
+ // hardPrune permanently deletes todos from the targeted box.
4
+ //
5
+ // Structural gate: refuses to execute unless `confirm: true` is passed. Even
6
+ // if the agent hallucinates intent, the tool demands the flag. The prompt
7
+ // guidelines (extensions/todo.ts) instruct the agent to always surface the
8
+ // `health` report + the exact proposed command and wait for an explicit user
9
+ // "yes" before passing confirm. The slash path uses ctx.ui.confirm.
10
+
11
+ import { loadStore, saveStore } from "./todo-store.ts";
12
+ import { loadArchive, saveArchive } from "./archive.ts";
13
+ import type { Todo } from "./todo-store.ts";
14
+
15
+ export type HardPruneBox = "archive" | "active" | "parked";
16
+
17
+ export interface HardPruneInput {
18
+ confirm: boolean; // REQUIRED — must be true to execute
19
+ box?: HardPruneBox; // default: "archive"
20
+ olderThan?: number; // days; filters by updatedAt (active/parked) or closedAt (archive)
21
+ project?: string;
22
+ tag?: string;
23
+ }
24
+
25
+ export interface HardPruneResult {
26
+ refused: boolean;
27
+ deleted: number;
28
+ ids: string[];
29
+ message: string;
30
+ }
31
+
32
+ function daysAgo(iso: string): number {
33
+ return (Date.now() - Date.parse(iso)) / 86400_000;
34
+ }
35
+
36
+ /**
37
+ * Permanently delete todos from a box. The only irreversible action.
38
+ * Returns `{ refused: true, deleted: 0, ... }` unless `confirm: true`.
39
+ */
40
+ export function hardPrune(opts: HardPruneInput): HardPruneResult {
41
+ if (!opts.confirm) {
42
+ return {
43
+ refused: true,
44
+ deleted: 0,
45
+ ids: [],
46
+ message: "Refused: pass confirm:true to execute hard-prune (this permanently deletes).",
47
+ };
48
+ }
49
+ const box: HardPruneBox = opts.box ?? "archive";
50
+ const cutoff = opts.olderThan ? Date.now() - opts.olderThan * 86400_000 : null;
51
+
52
+ const matches = (t: Todo): boolean => {
53
+ if (opts.project && t.project !== opts.project) return false;
54
+ if (opts.tag && !t.tags.includes(opts.tag)) return false;
55
+ if (cutoff !== null) {
56
+ const dateField = box === "archive" ? (t.closedAt ?? t.updatedAt) : t.updatedAt;
57
+ if (Date.parse(dateField) > cutoff) return false;
58
+ }
59
+ return true;
60
+ };
61
+
62
+ if (box === "archive") {
63
+ const archive = loadArchive();
64
+ const kept: Todo[] = [];
65
+ const deleted: Todo[] = [];
66
+ for (const t of archive.todos) (matches(t) ? deleted : kept).push(t);
67
+ if (deleted.length === 0) return { refused: false, deleted: 0, ids: [], message: "No archived todos matched the criteria." };
68
+ archive.todos = kept;
69
+ saveArchive(archive);
70
+ return { refused: false, deleted: deleted.length, ids: deleted.map((t) => t.id), message: `Permanently deleted ${deleted.length} archived todo${deleted.length === 1 ? "" : "s"}.` };
71
+ }
72
+
73
+ // active or parked box → live store
74
+ const live = loadStore();
75
+ const targetStatuses = box === "parked" ? ["parked"] : ["open", "in_progress"];
76
+ const kept: Todo[] = [];
77
+ const deleted: Todo[] = [];
78
+ for (const t of live.todos) {
79
+ if (targetStatuses.includes(t.status) && matches(t)) {
80
+ deleted.push(t);
81
+ } else {
82
+ kept.push(t);
83
+ }
84
+ }
85
+ if (deleted.length === 0) return { refused: false, deleted: 0, ids: [], message: `No ${box} todos matched the criteria.` };
86
+ live.todos = kept;
87
+ saveStore(live);
88
+ return { refused: false, deleted: deleted.length, ids: deleted.map((t) => t.id), message: `Permanently deleted ${deleted.length} ${box} todo${deleted.length === 1 ? "" : "s"}.` };
89
+ }
package/src/health.ts ADDED
@@ -0,0 +1,81 @@
1
+ // Bloat diagnostics for armory-todo — a pure-read report across all three
2
+ // lifecycle boxes (active / parked / archive), driven by the heuristics in
3
+ // todo.config.json. No side effects. The agent surfaces this + suggestions,
4
+ // then waits for user confirmation before any `prune --hard` (SPEC-2).
5
+
6
+ import { loadStore } from "./todo-store.ts";
7
+ import { loadArchive } from "./archive.ts";
8
+ import { loadConfig } from "./config.ts";
9
+
10
+ export interface ActiveHealth {
11
+ open: number;
12
+ in_progress: number;
13
+ stale_30d: number; // open todos with updatedAt older than activeStaleDays
14
+ }
15
+
16
+ export interface ParkedHealth {
17
+ count: number;
18
+ stale_60d: number; // parked with updatedAt older than parkedStaleDays
19
+ }
20
+
21
+ export interface ArchiveHealth {
22
+ count: number;
23
+ older_180d: number; // closedAt older than archiveOldDays
24
+ }
25
+
26
+ export type HealthFlag =
27
+ | "ACTIVE_LARGE" | "ACTIVE_STALE"
28
+ | "PARKED_LARGE" | "PARKED_STALE"
29
+ | "ARCHIVE_LARGE" | "ARCHIVE_OLD";
30
+
31
+ export interface HealthReport {
32
+ active: ActiveHealth;
33
+ parked: ParkedHealth;
34
+ archive: ArchiveHealth;
35
+ flags: HealthFlag[];
36
+ suggestions: string[];
37
+ }
38
+
39
+ function daysAgo(iso: string): number {
40
+ return (Date.now() - Date.parse(iso)) / 86400_000;
41
+ }
42
+
43
+ export function healthReport(): HealthReport {
44
+ const config = loadConfig();
45
+ const h = config.health;
46
+ const live = loadStore();
47
+ const archive = loadArchive();
48
+
49
+ const openTodos = live.todos.filter((t) => t.status === "open");
50
+ const ipTodos = live.todos.filter((t) => t.status === "in_progress");
51
+ const parkedTodos = live.todos.filter((t) => t.status === "parked");
52
+ const actionable = [...openTodos, ...ipTodos];
53
+
54
+ const activeStale = openTodos.filter((t) => daysAgo(t.updatedAt) > h.activeStaleDays).length;
55
+ const parkedStale = parkedTodos.filter((t) => daysAgo(t.updatedAt) > h.parkedStaleDays).length;
56
+ const archiveOld = archive.todos.filter((t) => t.closedAt && daysAgo(t.closedAt) > h.archiveOldDays).length;
57
+
58
+ const active: ActiveHealth = {
59
+ open: openTodos.length,
60
+ in_progress: ipTodos.length,
61
+ stale_30d: activeStale,
62
+ };
63
+ const parked: ParkedHealth = { count: parkedTodos.length, stale_60d: parkedStale };
64
+ const arch: ArchiveHealth = { count: archive.todos.length, older_180d: archiveOld };
65
+
66
+ const flags: HealthFlag[] = [];
67
+ if (actionable.length > h.activeMaxOpen) flags.push("ACTIVE_LARGE");
68
+ if (activeStale > 0) flags.push("ACTIVE_STALE");
69
+ if (parkedTodos.length > h.parkedMax) flags.push("PARKED_LARGE");
70
+ if (parkedStale > 0) flags.push("PARKED_STALE");
71
+ if (archive.todos.length > h.archiveMax) flags.push("ARCHIVE_LARGE");
72
+ if (archiveOld > 0) flags.push("ARCHIVE_OLD");
73
+
74
+ const suggestions: string[] = [];
75
+ if (archiveOld > 0) suggestions.push(`archive: ${archiveOld} items older than ${h.archiveOldDays}d → consider \`prune --hard --box archive --older-than ${h.archiveOldDays} --confirm\``);
76
+ if (activeStale > 0) suggestions.push(`active: ${activeStale} open TODOs untouched for ${h.activeStaleDays}d → park or close them`);
77
+ if (parkedStale > 0) suggestions.push(`parked: ${parkedStale} parked > ${h.parkedStaleDays}d → restore or hard-prune`);
78
+ if (actionable.length > h.activeMaxOpen) suggestions.push(`active: ${actionable.length} open+in_progress (max ${h.activeMaxOpen}) → close or park some before adding more`);
79
+
80
+ return { active, parked, archive: arch, flags, suggestions };
81
+ }
package/src/migrate.ts ADDED
@@ -0,0 +1,45 @@
1
+ // One-time v1 → v2 migration: move the legacy single-file store
2
+ // (~/.pi/agent/todo.json) into the v2 folder layout (~/.pi/agent/todo/todo.json).
3
+ //
4
+ // Pure + testable: takes explicit paths rather than reading env, so tests can
5
+ // point at temp dirs without touching the real home directory.
6
+ //
7
+ // Idempotent: if the target todo.json already exists, do nothing (the user
8
+ // already migrated, or started fresh on v2).
9
+
10
+ import { copyFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ export interface MigrateInput {
14
+ /** The v2 folder (e.g. ~/.pi/agent/todo/). */
15
+ todoDir: string;
16
+ /** The pre-v2 single file (e.g. ~/.pi/agent/todo.json). */
17
+ legacyPath: string;
18
+ }
19
+
20
+ /**
21
+ * If `<todoDir>/todo.json` does not exist but `legacyPath` does, create the
22
+ * folder and move the legacy file in. Atomic-ish: the legacy file is copied
23
+ * first (as a .bak), then moved, so a mid-move failure leaves the legacy file
24
+ * intact. Safe to call on every load.
25
+ */
26
+ export function migrateIfNeeded(input: MigrateInput): void {
27
+ const target = join(input.todoDir, "todo.json");
28
+ if (existsSync(target)) return; // already v2
29
+ if (!existsSync(input.legacyPath)) return; // nothing to migrate
30
+
31
+ mkdirSync(input.todoDir, { recursive: true });
32
+ // Copy-then-rename so the legacy file survives a crash between copy + rename.
33
+ const backup = `${input.legacyPath}.migrate-bak-${Date.now()}`;
34
+ copyFileSync(input.legacyPath, backup);
35
+ try {
36
+ renameSync(input.legacyPath, target);
37
+ // success → remove the backup
38
+ try { unlinkSync(backup); } catch { /* best-effort */ }
39
+ } catch {
40
+ // rename failed → restore from backup (legacy file may have been moved
41
+ // on some filesystems; copy it back to be safe)
42
+ try { copyFileSync(backup, input.legacyPath); } catch { /* best-effort */ }
43
+ throw new Error(`migration failed: could not move ${input.legacyPath} → ${target}`);
44
+ }
45
+ }
@@ -0,0 +1,70 @@
1
+ // Pure data helpers for the /todo TUI panel (SPEC-3). Kept separate from
2
+ // panel.ts so they're unit-testable without a terminal — the panel component
3
+ // itself is manual-gate only.
4
+
5
+ import type { SelectItem, SettingItem } from "@earendil-works/pi-tui";
6
+ import type { Todo } from "./todo-store.ts";
7
+ import type { ArchiveSummary } from "./archive.ts";
8
+ import type { TodoConfig } from "./config.ts";
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. */
15
+ export function todoToItem(t: Todo): SelectItem {
16
+ const pin = t.status === "in_progress" ? " ⏵" : "";
17
+ 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
+ }
25
+ return {
26
+ value: t.id,
27
+ label: `${prefix} ${text}`,
28
+ };
29
+ }
30
+
31
+ /** Format an archive summary into SelectList items (project + month buckets). */
32
+ export function archiveSummaryToItems(s: ArchiveSummary): SelectItem[] {
33
+ const items: SelectItem[] = [{ value: "total", label: `Total: ${s.total}` }];
34
+ for (const [p, n] of Object.entries(s.byProject)) items.push({ value: `project:${p}`, label: ` project ${p}: ${n}` });
35
+ for (const [m, n] of Object.entries(s.byMonth)) items.push({ value: `month:${m}`, label: ` ${m}: ${n}` });
36
+ return items;
37
+ }
38
+
39
+ /** Available actions for a todo, depending on its status. */
40
+ export function actionsForTodo(t: Todo): { label: string; action: string }[] {
41
+ const actions: { label: string; action: string }[] = [];
42
+ if (t.status === "open" || t.status === "in_progress") {
43
+ actions.push({ label: "Complete", action: "complete" });
44
+ actions.push({ label: "Park (defer)", action: "park" });
45
+ }
46
+ if (t.status === "parked") {
47
+ actions.push({ label: "Re-activate (open)", action: "open" });
48
+ actions.push({ label: "Complete", action: "complete" });
49
+ }
50
+ if (t.status === "done" || t.status === "cancelled") {
51
+ actions.push({ label: "Restore (from archive)", action: "restore" });
52
+ }
53
+ actions.push({ label: "Edit text", action: "edit" });
54
+ actions.push({ label: "Delete (cancel)", action: "delete" });
55
+ return actions;
56
+ }
57
+
58
+ /** Config → SettingsList rows (editable, live-persist). */
59
+ export function configToSettingItems(cfg: TodoConfig): SettingItem[] {
60
+ return [
61
+ { id: "defaultAgeDays", label: "Prune age (days)", currentValue: String(cfg.prune.defaultAgeDays), values: ["3", "7", "14", "30"], description: "Done/cancelled older than this → archive on prune." },
62
+ { id: "hardAgeDays", label: "Hard-prune age (days)", currentValue: String(cfg.prune.hardAgeDays), values: ["90", "180", "365"], description: "Archive items older than this → suggested for hard-prune." },
63
+ { id: "activeMaxOpen", label: "Active max open", currentValue: String(cfg.health.activeMaxOpen), values: ["10", "15", "20", "25"], description: "Bloat flag when open+in_progress exceeds this." },
64
+ { id: "activeStaleDays", label: "Active stale (days)", currentValue: String(cfg.health.activeStaleDays), values: ["14", "30", "60"], description: "Bloat flag when open todos untouched longer than this." },
65
+ { id: "parkedMax", label: "Parked max", currentValue: String(cfg.health.parkedMax), values: ["5", "10", "15"], description: "Bloat flag when parked exceeds this." },
66
+ { id: "parkedStaleDays", label: "Parked stale (days)", currentValue: String(cfg.health.parkedStaleDays), values: ["30", "60", "90"], description: "Bloat flag when parked longer than this." },
67
+ { id: "archiveMax", label: "Archive max", currentValue: String(cfg.health.archiveMax), values: ["100", "200", "500"], description: "Bloat flag when archive exceeds this." },
68
+ { id: "archiveOldDays", label: "Archive old (days)", currentValue: String(cfg.health.archiveOldDays), values: ["90", "180", "365"], description: "Bloat flag when archive items older than this." },
69
+ ];
70
+ }
package/src/panel.ts ADDED
@@ -0,0 +1,338 @@
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 settingsList: SettingsList | null = null;
51
+ private config: TodoConfig;
52
+ private healthFlags: string[] = [];
53
+
54
+ constructor(opts: TodoPanelOpts) {
55
+ super();
56
+ this.theme = opts.theme;
57
+ this.onDone = opts.onDone;
58
+ this.onNotify = opts.onNotify;
59
+ this.config = loadConfig();
60
+ try { this.healthFlags = healthReport().flags; } catch { /* optional */ }
61
+
62
+ const accent = (s: string) => this.theme.fg("accent", s);
63
+ this.addChild(new DynamicBorder(accent));
64
+ this.addChild(new Spacer(1));
65
+
66
+ this.filterInput = new Input();
67
+ this.filterInput.onEscape = () => { this.onDone(); };
68
+
69
+ this.selectList = new SelectList([], 12, {
70
+ selectedPrefix: (s) => this.theme.fg("accent", s),
71
+ selectedText: (s) => this.theme.fg("accent", s),
72
+ description: (s) => this.theme.fg("muted", s),
73
+ scrollInfo: (s) => this.theme.fg("dim", s),
74
+ noMatch: (s) => this.theme.fg("warning", s),
75
+ });
76
+ this.selectList.onSelect = (item) => this.onItemSelect(item);
77
+ this.selectList.onCancel = () => { this.onDone(); };
78
+
79
+ this.refreshList();
80
+ this.renderShell();
81
+ }
82
+
83
+ private renderShell(): void {
84
+ // Keep children 0 (top border) + 1 (spacer); rebuild the rest.
85
+ const keep = this.children.slice(0, 2);
86
+ this.children.length = 0;
87
+ this.children.push(...keep);
88
+ this.settingsList = null; // cleared; renderConfigBox sets it if active
89
+
90
+ const accent = (s: string) => this.theme.fg("accent", s);
91
+ const tabs = BOXES.map((b) => b === this.currentBox ? this.theme.fg("accent", this.theme.bold(`[${b}]`)) : this.theme.fg("dim", b)).join(" ");
92
+ this.addChild(new Text(accent(this.theme.bold(" TODO")) + " " + tabs, 0, 0));
93
+ if (this.healthFlags.length > 0) {
94
+ this.addChild(new Text(this.theme.fg("warning", ` ⚠ ${this.healthFlags.length} bloat signals — see Config tab`), 0, 0));
95
+ }
96
+ this.addChild(new Spacer(1));
97
+ this.addChild(new Text(this.theme.fg("muted", " filter:"), 0, 0));
98
+ this.addChild(this.filterInput);
99
+ this.addChild(new Spacer(1));
100
+
101
+ if (this.editMode && this.editInput) {
102
+ this.addChild(new Text(this.theme.fg("accent", ` Edit [${this.editId}]:`), 0, 0));
103
+ this.addChild(this.editInput);
104
+ this.addChild(new Text(this.theme.fg("dim", " enter save • esc cancel"), 0, 0));
105
+ } else if (this.actionMode && this.actionList) {
106
+ this.addChild(new Text(this.theme.fg("accent", " Action:"), 0, 0));
107
+ this.addChild(this.actionList);
108
+ } else if (this.currentBox === "config") {
109
+ this.renderConfigBox();
110
+ } else {
111
+ this.addChild(this.selectList);
112
+ }
113
+
114
+ this.addChild(new Spacer(1));
115
+ this.addChild(new Text(this.theme.fg("dim", " ↑↓ navigate • enter select/action • tab switch box • esc done"), 0, 0));
116
+ this.addChild(new Spacer(1));
117
+ this.addChild(new DynamicBorder(accent));
118
+ this.invalidate();
119
+ }
120
+
121
+ private refreshList(): void {
122
+ const filter = this.filterInput.getValue();
123
+ if (this.currentBox === "active") {
124
+ const todos = listTodos({ text: filter || undefined, limit: 50 });
125
+ this.setSelectItems(todos.map(todoToItem));
126
+ } else if (this.currentBox === "parked") {
127
+ const todos = listTodos({ status: "parked", text: filter || undefined, limit: 50 });
128
+ this.setSelectItems(todos.map(todoToItem));
129
+ } else if (this.currentBox === "archive") {
130
+ if (!filter) {
131
+ const s = archiveSummary();
132
+ this.setSelectItems(archiveSummaryToItems(s));
133
+ } else {
134
+ const res = listArchived({ text: filter, limit: 50 });
135
+ this.setSelectItems(res.items.map(todoToItem));
136
+ }
137
+ }
138
+ }
139
+
140
+ /** Replace the SelectList's items by reconstructing it (SelectList has no
141
+ * public items setter; setFilter does fuzzy matching on the original list). */
142
+ private setSelectItems(items: SelectItem[]): void {
143
+ const wasSelected = this.selectList.getSelectedItem();
144
+ const fresh = new SelectList(items, 12, {
145
+ selectedPrefix: (s) => this.theme.fg("accent", s),
146
+ selectedText: (s) => this.theme.fg("accent", s),
147
+ description: (s) => this.theme.fg("muted", s),
148
+ scrollInfo: (s) => this.theme.fg("dim", s),
149
+ noMatch: (s) => this.theme.fg("warning", s),
150
+ });
151
+ fresh.onSelect = (item) => this.onItemSelect(item);
152
+ fresh.onCancel = () => { this.onDone(); };
153
+ if (wasSelected) {
154
+ const idx = items.findIndex((i) => i.value === wasSelected.value);
155
+ if (idx >= 0) fresh.setSelectedIndex(idx);
156
+ }
157
+ this.selectList = fresh;
158
+ this.renderShell();
159
+ }
160
+
161
+ private onItemSelect(item: SelectItem): void {
162
+ if (this.currentBox === "archive" && (item.value === "total" || item.value.startsWith("project:") || item.value.startsWith("month:"))) {
163
+ if (item.value.startsWith("project:")) {
164
+ this.filterInput.setValue(item.value.slice("project:".length));
165
+ } else if (item.value.startsWith("month:")) {
166
+ this.filterInput.setValue(item.value.slice("month:".length));
167
+ }
168
+ this.refreshList();
169
+ this.renderShell();
170
+ return;
171
+ }
172
+ this.openActionSubmenu(item.value);
173
+ }
174
+
175
+ private openActionSubmenu(id: string): void {
176
+ const all = listTodos({ status: "all", limit: 200 });
177
+ const todo = all.find((t) => t.id === id);
178
+ if (!todo) {
179
+ this.onNotify("Todo not found in the live store (archive restore: use the archive box).", "info");
180
+ return;
181
+ }
182
+ const acts = actionsForTodo(todo);
183
+ const items: SelectItem[] = acts.map((a) => ({ value: a.action, label: a.label }));
184
+ this.actionList = new SelectList(items, 8, {
185
+ selectedPrefix: (s) => this.theme.fg("accent", s),
186
+ selectedText: (s) => this.theme.fg("accent", s),
187
+ description: (s) => this.theme.fg("muted", s),
188
+ scrollInfo: (s) => this.theme.fg("dim", s),
189
+ noMatch: (s) => this.theme.fg("warning", s),
190
+ });
191
+ this.actionList.onSelect = (a) => this.executeAction(id, a.value);
192
+ this.actionList.onCancel = () => { this.actionMode = false; this.actionList = null; this.renderShell(); };
193
+ this.actionMode = true;
194
+ this.renderShell();
195
+ }
196
+
197
+ private async executeAction(id: string, action: string): Promise<void> {
198
+ try {
199
+ switch (action) {
200
+ case "complete": completeTodo(id); this.onNotify(`Completed ${id}`); break;
201
+ case "park": parkTodo(id); this.onNotify(`Parked ${id}`); break;
202
+ case "open": updateTodo(id, { status: "open" as Status }); this.onNotify(`Re-activated ${id}`); break;
203
+ case "restore": restoreTodo(id); this.onNotify(`Restored ${id}`); break;
204
+ case "delete": deleteTodo(id); this.onNotify(`Cancelled ${id}`); break;
205
+ case "edit": {
206
+ const all = listTodos({ status: "all", limit: 200 });
207
+ const t = all.find((x) => x.id === id);
208
+ this.editId = id;
209
+ this.editInput = new Input();
210
+ this.editInput.setValue(t?.text ?? "");
211
+ this.editInput.onSubmit = (value) => {
212
+ if (value.trim()) { updateTodo(id, { text: value.trim() }); this.onNotify(`Edited ${id}`); }
213
+ this.exitEditMode();
214
+ };
215
+ this.editInput.onEscape = () => this.exitEditMode();
216
+ this.actionMode = false;
217
+ this.actionList = null;
218
+ this.editMode = true;
219
+ this.renderShell();
220
+ break;
221
+ }
222
+ }
223
+ } catch (err) {
224
+ this.onNotify(`Error: ${(err as Error).message}`, "error");
225
+ }
226
+ this.actionMode = false;
227
+ this.actionList = null;
228
+ this.refreshList();
229
+ this.renderShell();
230
+ }
231
+
232
+ private renderConfigBox(): void {
233
+ const settings = configToSettingItems(this.config);
234
+ const sl = new SettingsList(settings, 12, {
235
+ label: (text, sel) => sel ? this.theme.fg("accent", this.theme.bold(text)) : text,
236
+ value: (text, sel) => sel ? this.theme.fg("accent", text) : this.theme.fg("muted", text),
237
+ description: (text) => this.theme.fg("dim", text),
238
+ cursor: "❯",
239
+ hint: (text) => this.theme.fg("dim", text),
240
+ },
241
+ (id, newValue) => {
242
+ this.applyConfigChange(id, newValue);
243
+ sl.updateValue(id, this.configValueDisplay(id));
244
+ },
245
+ () => { this.onDone(); });
246
+ this.settingsList = sl;
247
+ this.addChild(sl);
248
+ }
249
+
250
+ private configValueDisplay(id: string): string {
251
+ const c = this.config;
252
+ switch (id) {
253
+ case "defaultAgeDays": return String(c.prune.defaultAgeDays);
254
+ case "hardAgeDays": return String(c.prune.hardAgeDays);
255
+ case "activeMaxOpen": return String(c.health.activeMaxOpen);
256
+ case "activeStaleDays": return String(c.health.activeStaleDays);
257
+ case "parkedMax": return String(c.health.parkedMax);
258
+ case "parkedStaleDays": return String(c.health.parkedStaleDays);
259
+ case "archiveMax": return String(c.health.archiveMax);
260
+ case "archiveOldDays": return String(c.health.archiveOldDays);
261
+ default: return "";
262
+ }
263
+ }
264
+
265
+ private applyConfigChange(id: string, value: string): void {
266
+ const n = Number(value);
267
+ if (!Number.isFinite(n)) return;
268
+ switch (id) {
269
+ case "defaultAgeDays": this.config.prune.defaultAgeDays = n; break;
270
+ case "hardAgeDays": this.config.prune.hardAgeDays = n; break;
271
+ case "activeMaxOpen": this.config.health.activeMaxOpen = n; break;
272
+ case "activeStaleDays": this.config.health.activeStaleDays = n; break;
273
+ case "parkedMax": this.config.health.parkedMax = n; break;
274
+ case "parkedStaleDays": this.config.health.parkedStaleDays = n; break;
275
+ case "archiveMax": this.config.health.archiveMax = n; break;
276
+ case "archiveOldDays": this.config.health.archiveOldDays = n; break;
277
+ }
278
+ saveConfig(this.config);
279
+ this.onNotify(`Config saved: ${id} = ${value}`, "info");
280
+ }
281
+
282
+ private exitEditMode(): void {
283
+ this.editMode = false;
284
+ this.editInput = null;
285
+ this.editId = "";
286
+ this.refreshList();
287
+ this.renderShell();
288
+ }
289
+
290
+ private switchBox(dir: 1 | -1): void {
291
+ const idx = BOXES.indexOf(this.currentBox);
292
+ const next = (idx + dir + BOXES.length) % BOXES.length;
293
+ this.currentBox = BOXES[next]!;
294
+ this.filterInput.setValue("");
295
+ this.actionMode = false;
296
+ this.actionList = null;
297
+ this.refreshList();
298
+ this.renderShell();
299
+ }
300
+
301
+ handleInput(data: string): void {
302
+ if (this.editMode && this.editInput) {
303
+ if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
304
+ this.exitEditMode();
305
+ return;
306
+ }
307
+ this.editInput.handleInput(data);
308
+ this.invalidate();
309
+ return;
310
+ }
311
+ if (this.actionMode && this.actionList) {
312
+ if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
313
+ this.actionMode = false;
314
+ this.actionList = null;
315
+ this.renderShell();
316
+ return;
317
+ }
318
+ this.actionList.handleInput(data);
319
+ this.invalidate();
320
+ return;
321
+ }
322
+ if (matchesKey(data, "escape") || matchesKey(data, "esc")) { this.onDone(); return; }
323
+ if (matchesKey(data, "tab")) { this.switchBox(1); return; }
324
+ if (matchesKey(data, "shift+tab")) { this.switchBox(-1); return; }
325
+ if (matchesKey(data, "up") || matchesKey(data, "down") || matchesKey(data, "enter") || matchesKey(data, "return")) {
326
+ if (this.currentBox === "config" && this.settingsList) {
327
+ this.settingsList.handleInput(data);
328
+ } else {
329
+ this.selectList.handleInput(data);
330
+ }
331
+ this.invalidate();
332
+ return;
333
+ }
334
+ this.filterInput.handleInput(data);
335
+ this.refreshList();
336
+ this.invalidate();
337
+ }
338
+ }
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
+ }