@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.
- package/extensions/todo.ts +12 -1
- package/package.json +4 -4
- package/src/archive.ts +7 -0
- package/src/backup.ts +123 -0
- package/src/todo-store.ts +9 -0
package/extensions/todo.ts
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "Global, cross-session TODO for pi
|
|
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 {
|