@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 +4 -4
- package/src/archive.ts +7 -0
- package/src/backup.ts +83 -0
- package/src/todo-store.ts +8 -0
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.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 {
|