@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.
- package/README.md +91 -12
- 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/specs/2026-07-20-lifecycle-boxes-prune-design.md +323 -0
- package/extensions/todo.ts +228 -21
- package/package.json +2 -2
- package/src/archive.ts +204 -0
- package/src/config.ts +101 -0
- package/src/hard-prune.ts +89 -0
- package/src/health.ts +81 -0
- package/src/migrate.ts +45 -0
- package/src/panel-data.ts +70 -0
- package/src/panel.ts +338 -0
- package/src/paths.ts +36 -0
- package/src/todo-store.ts +51 -24
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,18 +16,16 @@ 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 } 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
|
|
|
@@ -43,7 +43,7 @@ export interface Todo {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export interface Store {
|
|
46
|
-
version:
|
|
46
|
+
version: 2;
|
|
47
47
|
updatedAt: string;
|
|
48
48
|
todos: Todo[];
|
|
49
49
|
}
|
|
@@ -68,6 +68,11 @@ export interface ListFilter {
|
|
|
68
68
|
status?: Status | "all";
|
|
69
69
|
project?: string;
|
|
70
70
|
tag?: string;
|
|
71
|
+
text?: string; // substring match on todo.text (case-insensitive)
|
|
72
|
+
since?: string; // ISO date; filter createdAt >= since
|
|
73
|
+
before?: string; // ISO date; filter createdAt < before
|
|
74
|
+
limit?: number; // default 20
|
|
75
|
+
page?: number; // default 1 (1-indexed)
|
|
71
76
|
}
|
|
72
77
|
|
|
73
78
|
export class TodoError extends Error {}
|
|
@@ -82,30 +87,37 @@ function genId(): string {
|
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
function emptyStore(): Store {
|
|
85
|
-
return { version:
|
|
90
|
+
return { version: 2, updatedAt: now(), todos: [] };
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
export function getStorePath(): string {
|
|
89
|
-
return
|
|
94
|
+
return getLivePath();
|
|
90
95
|
}
|
|
91
96
|
|
|
92
|
-
/** Load the store from disk.
|
|
97
|
+
/** Load the live store from disk. Runs v1→v2 migration first — but ONLY when
|
|
98
|
+
* using the default TODO_DIR (not overridden by TODO_DIR env, e.g. tests).
|
|
99
|
+
* On corruption, backs up the bad file and starts fresh. */
|
|
93
100
|
export function loadStore(): Store {
|
|
94
|
-
if (!
|
|
101
|
+
if (!process.env.TODO_DIR) {
|
|
102
|
+
migrateIfNeeded({ todoDir: getTodoDir(), legacyPath: getLegacyPath() });
|
|
103
|
+
}
|
|
104
|
+
const path = getLivePath();
|
|
105
|
+
if (!existsSync(path)) return emptyStore();
|
|
95
106
|
try {
|
|
96
|
-
const raw = readFileSync(
|
|
107
|
+
const raw = readFileSync(path, "utf8");
|
|
97
108
|
const parsed = JSON.parse(raw) as Store;
|
|
98
109
|
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.todos)) {
|
|
99
110
|
throw new Error("invalid store shape");
|
|
100
111
|
}
|
|
101
|
-
if (parsed.version !==
|
|
102
|
-
//
|
|
103
|
-
|
|
112
|
+
if (parsed.version !== 2) {
|
|
113
|
+
// v1 → v2: 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;
|
|
104
116
|
}
|
|
105
117
|
return parsed;
|
|
106
118
|
} catch {
|
|
107
119
|
try {
|
|
108
|
-
renameSync(
|
|
120
|
+
renameSync(path, `${path}.bad-${Date.now()}`);
|
|
109
121
|
} catch {
|
|
110
122
|
// best-effort backup; swallow
|
|
111
123
|
}
|
|
@@ -116,16 +128,17 @@ export function loadStore(): Store {
|
|
|
116
128
|
/** Atomic, 0600 write. */
|
|
117
129
|
export function saveStore(store: Store): void {
|
|
118
130
|
store.updatedAt = now();
|
|
119
|
-
const
|
|
131
|
+
const path = getLivePath();
|
|
132
|
+
const dir = dirname(path);
|
|
120
133
|
mkdirSync(dir, { recursive: true });
|
|
121
|
-
const tmp = `${
|
|
134
|
+
const tmp = `${path}.tmp`;
|
|
122
135
|
writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
123
136
|
try {
|
|
124
137
|
chmodSync(tmp, 0o600);
|
|
125
138
|
} catch {
|
|
126
139
|
// some filesystems ignore mode bits; not fatal
|
|
127
140
|
}
|
|
128
|
-
renameSync(tmp,
|
|
141
|
+
renameSync(tmp, path);
|
|
129
142
|
}
|
|
130
143
|
|
|
131
144
|
function assertPriority(p: unknown): asserts p is Priority {
|
|
@@ -180,7 +193,13 @@ export function listTodos(filter: ListFilter = {}): Todo[] {
|
|
|
180
193
|
}
|
|
181
194
|
if (filter.project) out = out.filter((t) => t.project === filter.project);
|
|
182
195
|
if (filter.tag) out = out.filter((t) => t.tags.includes(filter.tag as string));
|
|
183
|
-
|
|
196
|
+
if (filter.text) {
|
|
197
|
+
const q = filter.text.toLowerCase();
|
|
198
|
+
out = out.filter((t) => t.text.toLowerCase().includes(q));
|
|
199
|
+
}
|
|
200
|
+
if (filter.since) out = out.filter((t) => t.createdAt >= (filter.since as string));
|
|
201
|
+
if (filter.before) out = out.filter((t) => t.createdAt < (filter.before as string));
|
|
202
|
+
const sorted = out.slice().sort((a, b) => {
|
|
184
203
|
if (a.status !== b.status) {
|
|
185
204
|
// in_progress before open (actionable ordering)
|
|
186
205
|
return a.status === "in_progress" ? -1 : b.status === "in_progress" ? 1 : 0;
|
|
@@ -190,6 +209,10 @@ export function listTodos(filter: ListFilter = {}): Todo[] {
|
|
|
190
209
|
}
|
|
191
210
|
return a.createdAt.localeCompare(b.createdAt);
|
|
192
211
|
});
|
|
212
|
+
const limit = filter.limit ?? 20;
|
|
213
|
+
const page = filter.page ?? 1;
|
|
214
|
+
const start = (page - 1) * limit;
|
|
215
|
+
return sorted.slice(start, start + limit);
|
|
193
216
|
}
|
|
194
217
|
|
|
195
218
|
export function updateTodo(id: string, patch: UpdateInput): Todo {
|
|
@@ -223,6 +246,10 @@ export function completeTodo(id: string): Todo {
|
|
|
223
246
|
return updateTodo(id, { status: "done" });
|
|
224
247
|
}
|
|
225
248
|
|
|
249
|
+
export function parkTodo(id: string): Todo {
|
|
250
|
+
return updateTodo(id, { status: "parked" });
|
|
251
|
+
}
|
|
252
|
+
|
|
226
253
|
export function deleteTodo(id: string): Todo {
|
|
227
254
|
return updateTodo(id, { status: "cancelled" });
|
|
228
255
|
}
|