@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/README.md +46 -5
- package/docs/superpowers/plans/2026-07-29-reap-safety-protocol.md +858 -0
- package/docs/superpowers/specs/2026-07-29-reap-safety-protocol-design.md +187 -0
- package/extensions/todo.ts +40 -11
- package/package.json +2 -2
- package/src/archive.ts +1 -1
- package/src/config.ts +47 -0
- package/src/health.ts +21 -3
- package/src/index.d.ts +5 -1
- package/src/index.ts +1 -0
- package/src/panel-data.ts +26 -2
- package/src/panel.ts +22 -6
- package/src/reap.ts +90 -0
- package/src/todo-store.ts +8 -2
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 {
|