@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/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 single JSON file on disk
4
- // (default ~/.pi/agent/todo.json; override with TODO_STORE_PATH for tests).
5
- // Deliberately NOT pi session-entries this survives across all sessions.
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, join } from "node:path";
18
- import { homedir } from "node:os";
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: 1;
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: 1, updatedAt: now(), todos: [] };
90
+ return { version: 2, updatedAt: now(), todos: [] };
86
91
  }
87
92
 
88
93
  export function getStorePath(): string {
89
- return STORE_PATH;
94
+ return getLivePath();
90
95
  }
91
96
 
92
- /** Load the store from disk. On corruption, back up the bad file and start fresh. */
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 (!existsSync(STORE_PATH)) return emptyStore();
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(STORE_PATH, "utf8");
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 !== 1) {
102
- // Future: migrate. v1 only reset on unknown version with backup.
103
- throw new Error("unsupported store version: " + String(parsed.version));
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(STORE_PATH, `${STORE_PATH}.bad-${Date.now()}`);
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 dir = dirname(STORE_PATH);
131
+ const path = getLivePath();
132
+ const dir = dirname(path);
120
133
  mkdirSync(dir, { recursive: true });
121
- const tmp = `${STORE_PATH}.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, STORE_PATH);
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
- return out.slice().sort((a, b) => {
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
  }