@getpipher/armory-todo 0.5.4 → 0.6.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/reap.ts ADDED
@@ -0,0 +1,90 @@
1
+ // Source-aware stale-active reaping (v0.6.0 safety protocol).
2
+ //
3
+ // On session_start, after auto-prune, scans active (open/in_progress) todos:
4
+ // - whose `source` is in config.reap.policy AND stale (updatedAt older than
5
+ // policy[source].reapAfterDays) → archived `cancelled` (immediately restorable).
6
+ // - other active todos older than config.reap.orphanFlagAfterDays → ORPHAN
7
+ // flag (advisory, computed in health.ts — reap does NOT mutate these).
8
+ //
9
+ // Batch: one loadStore, one saveStore. saveStore already calls backupFile +
10
+ // snapshotOnDrop + appendAudit internally (same guardrails as every store write).
11
+
12
+ import { appendFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { loadConfig } from "./config.ts";
15
+ import { loadStore, saveStore, type Todo } from "./todo-store.ts";
16
+ import { loadArchive, saveArchive } from "./archive.ts";
17
+ import { getLivePath } from "./paths.ts";
18
+
19
+ export interface ReapResult {
20
+ reaped: number;
21
+ flagged: number; // non-policy active todos older than orphanFlagAfterDays (advisory count)
22
+ ids: string[]; // reaped ids
23
+ oldestDays: number; // age of the oldest reaped todo (for notify copy)
24
+ }
25
+
26
+ const DAY = 86_400_000;
27
+
28
+ /** Reap stale active todos per config.reap.policy into the archive. Returns
29
+ * the result if any moved, else null (caller stays silent). Non-policy stale
30
+ * todos are flagged via health.ts (ORPHAN) — counted but never mutated. */
31
+ export function reapStaleActive(): ReapResult | null {
32
+ const config = loadConfig();
33
+ const policy = config.reap.policy;
34
+ const orphanAfter = config.reap.orphanFlagAfterDays;
35
+ const now = Date.now();
36
+
37
+ const store = loadStore();
38
+ const reaped: Todo[] = [];
39
+ const kept: Todo[] = [];
40
+ const reapedStaleDays: number[] = []; // stale-age (by updatedAt) at decision time, pre-mutation
41
+ const reapedAt = new Date(now).toISOString();
42
+ let flagged = 0;
43
+
44
+ for (const todo of store.todos) {
45
+ if (todo.status !== "open" && todo.status !== "in_progress") {
46
+ kept.push(todo);
47
+ continue;
48
+ }
49
+ const ageMs = now - Date.parse(todo.updatedAt);
50
+ const ageDays = ageMs / DAY;
51
+ const entry = policy[todo.source];
52
+ if (entry && ageDays >= entry.reapAfterDays) {
53
+ todo.status = "cancelled";
54
+ todo.closedAt = reapedAt;
55
+ todo.updatedAt = reapedAt;
56
+ reaped.push(todo);
57
+ reapedStaleDays.push(ageDays);
58
+ } else {
59
+ if (!entry && ageDays >= orphanAfter) flagged++;
60
+ kept.push(todo);
61
+ }
62
+ }
63
+
64
+ if (reaped.length === 0) return null;
65
+
66
+ // Machine-reaped abandoned runs skip the live terminal-retention window and
67
+ // enter sealed history immediately, so `restoreTodo(id)` works at once.
68
+ const archive = loadArchive();
69
+ const archivedIds = new Set(archive.todos.map((t) => t.id));
70
+ for (const todo of reaped) {
71
+ if (!archivedIds.has(todo.id)) archive.todos.push(todo);
72
+ }
73
+ store.todos = kept;
74
+ // Persist archive first: an interrupted second write may temporarily duplicate
75
+ // an id across boxes, but the next idempotent reap removes the live copy. The
76
+ // inverse order could hide data from both primary stores until backup recovery.
77
+ saveArchive(archive);
78
+ saveStore(store, { intentionalDrop: "reap" }); // backups/snapshot/audit, no false wipe sentinel
79
+
80
+ // Append a reap-specific audit marker line (best-effort, no content)
81
+ try {
82
+ appendFileSync(
83
+ join(dirname(getLivePath()), "todo-audit.log"),
84
+ `REAP reaped=${reaped.length} flagged=${flagged} at ${new Date().toISOString()}\n`,
85
+ );
86
+ } catch { /* audit best-effort */ }
87
+
88
+ const oldestDays = reapedStaleDays.length ? Math.floor(Math.max(...reapedStaleDays)) : 0;
89
+ return { reaped: reaped.length, flagged, ids: reaped.map((t) => t.id), oldestDays };
90
+ }
package/src/todo-store.ts CHANGED
@@ -53,6 +53,12 @@ export interface Store {
53
53
  todos: Todo[];
54
54
  }
55
55
 
56
+ export interface SaveStoreOptions {
57
+ /** Expected count-drop operation. Backups/drop snapshots/audit still run;
58
+ * only the false-positive wipe sentinel is suppressed. */
59
+ intentionalDrop?: "reap" | "prune";
60
+ }
61
+
56
62
  export interface AddInput {
57
63
  title: string;
58
64
  notes?: string;
@@ -148,7 +154,7 @@ export function loadStore(): Store {
148
154
  }
149
155
 
150
156
  /** Atomic, 0600 write. */
151
- export function saveStore(store: Store): void {
157
+ export function saveStore(store: Store, options: SaveStoreOptions = {}): void {
152
158
  store.updatedAt = now();
153
159
  const path = getLivePath();
154
160
  // v0.5.1 write-audit + backup (post data-loss hardening): back up the current
@@ -168,7 +174,7 @@ export function saveStore(store: Store): void {
168
174
  }
169
175
  renameSync(tmp, path);
170
176
  appendAudit("todo", before, after, dropSnap);
171
- if (after < before) writeWipeAlert(before, after, dropSnap);
177
+ if (after < before && !options.intentionalDrop) writeWipeAlert(before, after, dropSnap);
172
178
  }
173
179
 
174
180
  function assertPriority(p: unknown): asserts p is Priority {