@getpipher/armory-todo 0.5.0 → 0.5.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@getpipher/armory-todo",
3
- "version": "0.5.0",
4
- "description": "Global, cross-session TODO for pi persists across all sessions and is auto-injected into every prompt. The disk-backed counterpart to branch-scoped pi todo extensions.",
3
+ "version": "0.5.1",
4
+ "description": "Global, cross-session TODO for pi \u2014 persists across all sessions and is auto-injected into every prompt. The disk-backed counterpart to branch-scoped pi todo extensions.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",
@@ -35,7 +35,7 @@
35
35
  ]
36
36
  },
37
37
  "scripts": {
38
- "test": "for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-prune todo-auto-prune registry projects panel-data todo-caps; do node test/$t.test.mts || exit 1; done"
38
+ "test": "for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-prune todo-auto-prune registry projects panel-data todo-caps todo-backup; do node test/$t.test.mts || exit 1; done"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@earendil-works/pi-ai": "*",
@@ -53,4 +53,4 @@
53
53
  "optional": true
54
54
  }
55
55
  }
56
- }
56
+ }
package/src/archive.ts CHANGED
@@ -12,6 +12,7 @@ import { migrateV2ToV3 } from "./migrate.ts";
12
12
  import type { Todo } from "./todo-store.ts";
13
13
  import { loadConfig } from "./config.ts";
14
14
  import { loadStore, saveStore, TodoError } from "./todo-store.ts";
15
+ import { backupFile, snapshotOnDrop, appendAudit, countTodosInFile } from "./backup.ts";
15
16
 
16
17
  export interface ArchiveStore {
17
18
  version: 3;
@@ -61,6 +62,11 @@ export function loadArchive(): ArchiveStore {
61
62
  export function saveArchive(store: ArchiveStore): void {
62
63
  store.updatedAt = now();
63
64
  const path = getArchivePath();
65
+ // v0.5.1 write-audit + backup (post data-loss hardening).
66
+ const before = countTodosInFile(path);
67
+ const after = store.todos.length;
68
+ backupFile(path);
69
+ const dropSnap = snapshotOnDrop(path, before, after);
64
70
  const dir = dirname(path);
65
71
  mkdirSync(dir, { recursive: true });
66
72
  const tmp = `${path}.tmp`;
@@ -71,6 +77,7 @@ export function saveArchive(store: ArchiveStore): void {
71
77
  // some filesystems ignore mode bits
72
78
  }
73
79
  renameSync(tmp, path);
80
+ appendAudit("archive", before, after, dropSnap);
74
81
  }
75
82
 
76
83
  export interface PruneInput {
package/src/backup.ts ADDED
@@ -0,0 +1,83 @@
1
+ // Write-audit + backup for armory-todo (v0.5.1 hardening, post v0.2.0 data-loss
2
+ // incident). Every saveStore / saveArchive now:
3
+ // 1. backs up the current file to <path>.bak (rolling previous version — the
4
+ // immediate recovery target);
5
+ // 2. if the save would DROP the todo count (after < before), also snapshots
6
+ // the pre-write file to <path>.bak-drop-<ts> (timestamped, never
7
+ // overwritten — the preserved pre-wipe state, the trap);
8
+ // 3. appends one compact line to <TODO_DIR>/todo-audit.log (counts only, no
9
+ // todo content — privacy-safe) flagging drops with ⚠ DROP.
10
+ //
11
+ // This means the v0.2.0-style "migration wiped the store" incident is now
12
+ // recoverable: the .bak-drop-<ts> holds the pre-wipe state, and the audit log
13
+ // names the moment + the count delta.
14
+ //
15
+ // Pure fs helpers (no pi imports) so they're unit-testable in isolation.
16
+
17
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, appendFileSync, statSync, readFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { getTodoDir } from "./paths.ts";
20
+
21
+ /** Copy the current file to <path>.bak (rolling previous version). No-op if the
22
+ * file doesn't exist yet (first-ever save). Returns true if a backup was made. */
23
+ export function backupFile(path: string): boolean {
24
+ if (!existsSync(path)) return false;
25
+ try {
26
+ copyFileSync(path, `${path}.bak`);
27
+ return true;
28
+ } catch {
29
+ // best-effort; a failed backup must not block the write
30
+ return false;
31
+ }
32
+ }
33
+
34
+ /** If a count drop is detected (after < before), snapshot the pre-write file to
35
+ * <path>.bak-drop-<ts> (preserved — never overwritten by the rolling .bak).
36
+ * Returns the snapshot path, or null if no snapshot was taken. */
37
+ export function snapshotOnDrop(path: string, before: number, after: number): string | null {
38
+ if (before <= after) return null; // no drop (growth or steady)
39
+ if (!existsSync(path)) return null; // nothing to snapshot
40
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
41
+ const snap = `${path}.bak-drop-${ts}`;
42
+ try {
43
+ copyFileSync(path, snap);
44
+ return snap;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** Append one compact audit line. Counts only (no todo content). Flags drops.
51
+ * `box` is "todo" or "archive" (which file was saved). */
52
+ export function appendAudit(box: "todo" | "archive", before: number, after: number, dropSnap: string | null): void {
53
+ try {
54
+ const dir = getTodoDir();
55
+ mkdirSync(dir, { recursive: true });
56
+ const logPath = join(dir, "todo-audit.log");
57
+ const ts = new Date().toISOString();
58
+ const delta = after - before;
59
+ const flag = after < before ? " [⚠ DROP]" : "";
60
+ const snap = dropSnap ? ` snap=${dropSnap.split("/").pop()}` : "";
61
+ appendFileSync(logPath, `${ts} save ${box}.json ${before}→${after} ${delta >= 0 ? "+" : ""}${delta}${flag}${snap}\n`, { encoding: "utf8" });
62
+ try { chmodSync(logPath, 0o600); } catch { /* fs may ignore mode */ }
63
+ } catch {
64
+ // best-effort; audit must not block the write
65
+ }
66
+ }
67
+
68
+ /** Count todos in an existing store file (for the before-count). Returns 0 if the
69
+ * file is missing/unreadable/not a valid store. */
70
+ export function countTodosInFile(path: string): number {
71
+ try {
72
+ if (!existsSync(path)) return 0;
73
+ const raw = statSync(path).size === 0 ? null : readJson(path);
74
+ if (!raw || !Array.isArray(raw.todos)) return 0;
75
+ return raw.todos.length;
76
+ } catch {
77
+ return 0;
78
+ }
79
+ }
80
+
81
+ function readJson(path: string): any {
82
+ return JSON.parse(readFileSync(path, "utf8"));
83
+ }
package/src/todo-store.ts CHANGED
@@ -22,6 +22,7 @@ import { migrateIfNeeded, migrateV2ToV3 } from "./migrate.ts";
22
22
  import { loadConfig } from "./config.ts";
23
23
  import { loadRegistry, getProjectEntry } from "./registry.ts";
24
24
  import { checkNotesCap, checkProjectCap, overBudgetProjects } from "./caps.ts";
25
+ import { backupFile, snapshotOnDrop, appendAudit, countTodosInFile } from "./backup.ts";
25
26
 
26
27
  export type Priority = "low" | "med" | "high" | "critical";
27
28
  export type Status = "open" | "in_progress" | "parked" | "done" | "cancelled";
@@ -150,6 +151,12 @@ export function loadStore(): Store {
150
151
  export function saveStore(store: Store): void {
151
152
  store.updatedAt = now();
152
153
  const path = getLivePath();
154
+ // v0.5.1 write-audit + backup (post data-loss hardening): back up the current
155
+ // file, snapshot pre-write state on a count drop, then audit-log the save.
156
+ const before = countTodosInFile(path);
157
+ const after = store.todos.length;
158
+ backupFile(path);
159
+ const dropSnap = snapshotOnDrop(path, before, after);
153
160
  const dir = dirname(path);
154
161
  mkdirSync(dir, { recursive: true });
155
162
  const tmp = `${path}.tmp`;
@@ -160,6 +167,7 @@ export function saveStore(store: Store): void {
160
167
  // some filesystems ignore mode bits; not fatal
161
168
  }
162
169
  renameSync(tmp, path);
170
+ appendAudit("todo", before, after, dropSnap);
163
171
  }
164
172
 
165
173
  function assertPriority(p: unknown): asserts p is Priority {