@getpipher/armory-todo 0.5.0 → 0.5.2

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.
@@ -42,6 +42,7 @@ import { autoPruneOnSessionStart } from "../src/auto-prune";
42
42
  import { loadConfig } from "../src/config";
43
43
  import { projectsOverview } from "../src/projects";
44
44
  import { renameProject } from "../src/registry";
45
+ import { readAndClearWipeAlert } from "../src/backup";
45
46
 
46
47
  const ACTIONS = ["list", "add", "update", "get", "complete", "delete", "clear", "park", "prune", "restore", "health", "projects", "project_rename"] as const;
47
48
 
@@ -79,6 +80,16 @@ export default function (pi: ExtensionAPI) {
79
80
  // Warm + report on session start (every new/resume/fork/reload).
80
81
  pi.on("session_start", async (_event, ctx) => {
81
82
  try {
83
+ // v0.5.2: surface a pending wipe-alert sentinel FIRST (most prominent).
84
+ let wipeMsg = "";
85
+ try {
86
+ const alert = readAndClearWipeAlert();
87
+ if (alert) {
88
+ wipeMsg = `⚠ WIPE RECOVERED: ${alert.before}→${alert.after} at ${alert.at}${alert.snap ? `, snap=${alert.snap}` : ""} — check ~/.pi/agent/todo/todo-audit.log + run \`ps aux | grep -iE 'tsx|pi'\` to catch the wiper live`;
89
+ }
90
+ } catch {
91
+ // alert optional
92
+ }
82
93
  let autoMsg = "";
83
94
  let ageDays = 7;
84
95
  try {
@@ -101,7 +112,7 @@ export default function (pi: ExtensionAPI) {
101
112
  } catch {
102
113
  // health check optional
103
114
  }
104
- if (ctx.hasUI) ctx.ui.notify(msg, "info");
115
+ if (ctx.hasUI) ctx.ui.notify((wipeMsg ? wipeMsg + "\n" : "") + msg, "info");
105
116
  } catch {
106
117
  // store unavailable — never crash the session
107
118
  }
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.2",
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,123 @@
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, writeFileSync, unlinkSync } 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
+ }
84
+
85
+ // --- v0.5.2 wipe alert (one-shot sentinel) ---
86
+ const WIPE_ALERT_PATH = join(getTodoDir(), ".wipe-alert");
87
+
88
+ /** On a drop, write a one-shot sentinel at <TODO_DIR>/.wipe-alert so the next
89
+ * pi session_start can surface the recovery prominently. Overwritten by each
90
+ * new drop (the latest wins). The session_start handler reads + deletes it. */
91
+ export function writeWipeAlert(before: number, after: number, snap: string | null): void {
92
+ try {
93
+ const dir = getTodoDir();
94
+ mkdirSync(dir, { recursive: true });
95
+ const path = join(dir, ".wipe-alert");
96
+ const payload = {
97
+ at: new Date().toISOString(),
98
+ before,
99
+ after,
100
+ snap: snap ? snap.split("/").pop() : null,
101
+ snapPath: snap,
102
+ };
103
+ writeFileSync(path, JSON.stringify(payload, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
104
+ try { chmodSync(path, 0o600); } catch { /* fs may ignore mode */ }
105
+ } catch {
106
+ // best-effort; alert must not block the write
107
+ }
108
+ }
109
+
110
+ /** Read + delete the wipe-alert sentinel (one-shot). Returns the payload if a
111
+ * pending alert exists, else null. The caller surfaces it in session_start
112
+ * then it's gone (until the next drop). */
113
+ export function readAndClearWipeAlert(): { at: string; before: number; after: number; snap: string | null; snapPath: string | null } | null {
114
+ try {
115
+ const path = join(getTodoDir(), ".wipe-alert");
116
+ if (!existsSync(path)) return null;
117
+ const payload = JSON.parse(readFileSync(path, "utf8"));
118
+ try { unlinkSync(path); } catch { /* best-effort clear */ }
119
+ return payload;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
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, writeWipeAlert } 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,8 @@ 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);
171
+ if (after < before) writeWipeAlert(before, after, dropSnap);
163
172
  }
164
173
 
165
174
  function assertPriority(p: unknown): asserts p is Priority {