@getpipher/armory-todo 0.1.0 → 0.3.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 +107 -14
- package/docs/superpowers/plans/2026-07-20-spec-1-store-layer.md +1691 -0
- package/docs/superpowers/plans/2026-07-20-spec-2-health-hard-prune.md +762 -0
- package/docs/superpowers/plans/2026-07-20-spec-3-interactive-panel.md +651 -0
- package/docs/superpowers/plans/2026-07-21-title-notes-split.md +1586 -0
- package/docs/superpowers/specs/2026-07-20-lifecycle-boxes-prune-design.md +323 -0
- package/docs/superpowers/specs/2026-07-21-title-notes-split-design.md +411 -0
- package/docs/todo-SPEC.md +5 -0
- package/extensions/todo.ts +273 -36
- package/package.json +2 -2
- package/src/archive.ts +214 -0
- package/src/config.ts +101 -0
- package/src/hard-prune.ts +89 -0
- package/src/health.ts +97 -0
- package/src/migrate.ts +154 -0
- package/src/panel-data.ts +63 -0
- package/src/panel.ts +382 -0
- package/src/paths.ts +36 -0
- package/src/todo-store.ts +86 -37
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
# SPEC-2: Self-Awareness — Health Diagnostics + Hard-Prune
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add the self-awareness layer to armory-todo — a `health` action that detects bloat across active/parked/archive boxes and suggests cleanup, plus `prune --hard` (the only irreversible action, gated by a tool-level `confirm: true` flag + prompt-level "always ask first" + `ctx.ui.confirm` on the slash path).
|
|
6
|
+
|
|
7
|
+
**Architecture:** Two new focused modules: `src/health.ts` (pure-read bloat report from live store + archive + config heuristics) and `src/hard-prune.ts` (the only deletion path, `confirm`-gated, can target any box). The extension gains `health` + `prune --hard` tool actions, `/todo health` + `/todo prune --hard` slash subcommands, a session-start bloat nudge, and prompt guidelines instructing the agent to surface health + wait for user confirmation before hard-prune.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Same as SPEC-1 — TypeScript, Node.js (`node:fs`/`node:os`/`node:path`), zero runtime deps. Tests: `node test/*.test.mts`. pi extension API for the extension layer.
|
|
10
|
+
|
|
11
|
+
**Design doc:** `docs/superpowers/specs/2026-07-20-lifecycle-boxes-prune-design.md` §8 (health report + heuristics) + §9 (hard-prune gate) + §11 (session_start nudge).
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- **Zero runtime dependencies** — same as SPEC-1.
|
|
16
|
+
- **`prune --hard` is the ONLY irreversible action.** It must refuse without `confirm: true` (tool-level structural gate). The prompt guidelines instruct the agent to always surface the `health` report + proposed command + wait for explicit user "yes" before passing `confirm: true`.
|
|
17
|
+
- **`health` is a pure read** — no side effects, no writes.
|
|
18
|
+
- **Heuristic thresholds come from `todo.config.json`** (the `health` block added in SPEC-1). Missing config → defaults (already handled by `loadConfig`).
|
|
19
|
+
- **2-space indent**, no TODO/FIXME in delivered code.
|
|
20
|
+
- Tests run via `node test/todo-health.test.mts` + `node test/todo-hard-prune.test.mts` (new) alongside the existing 4 suites.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## File Structure
|
|
25
|
+
|
|
26
|
+
| File | Responsibility | Status |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `src/health.ts` | `healthReport()` — pure-read bloat diagnostics across active/parked/archive, driven by config heuristics. Returns structured report + flags + suggestions. | Create |
|
|
29
|
+
| `src/hard-prune.ts` | `hardPrune(opts)` — the only deletion path. `confirm: true` required (refuses otherwise). Targets `archive`/`active`/`parked` boxes with optional `olderThan`/`project`/`tag` filters. | Create |
|
|
30
|
+
| `extensions/todo.ts` | Add `health` + `prune --hard` tool actions; extend `ACTIONS`; add `confirm`/`box`/`olderThan` params; update prompt guidelines; session_start bloat nudge; `/todo health` + `/todo prune --hard` slash subcommands. | Modify |
|
|
31
|
+
| `test/todo-health.test.mts` | healthReport: correct flags + counts for constructed scenarios (active stale, parked stale, archive large/old); thresholds from config. | Create |
|
|
32
|
+
| `test/todo-hard-prune.test.mts` | hardPrune: refuses without confirm; deletes with confirm; targets boxes + filters; irreversible (gone after). | Create |
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Task 1: `healthReport` — bloat diagnostics (pure read)
|
|
37
|
+
|
|
38
|
+
**Files:**
|
|
39
|
+
- Create: `src/health.ts`
|
|
40
|
+
- Create: `test/todo-health.test.mts`
|
|
41
|
+
|
|
42
|
+
**Interfaces:**
|
|
43
|
+
- Consumes: `loadStore` + `listTodos` from `src/todo-store.ts`, `loadArchive` from `src/archive.ts`, `loadConfig` from `src/config.ts`.
|
|
44
|
+
- Produces: `HealthReport`, `healthReport(): HealthReport`.
|
|
45
|
+
|
|
46
|
+
- [ ] **Step 1: Write the failing test**
|
|
47
|
+
|
|
48
|
+
Create `test/todo-health.test.mts`:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
52
|
+
import { tmpdir } from "node:os";
|
|
53
|
+
import { join } from "node:path";
|
|
54
|
+
|
|
55
|
+
let passed = 0;
|
|
56
|
+
let failed = 0;
|
|
57
|
+
function ok(name: string, cond: boolean, extra = ""): void {
|
|
58
|
+
if (cond) { passed++; } else { failed++; console.error(` ✗ ${name} ${extra}`); }
|
|
59
|
+
}
|
|
60
|
+
function eq<T>(name: string, got: T, want: T): void {
|
|
61
|
+
ok(name, got === want, `(got ${JSON.stringify(got)} want ${JSON.stringify(want)})`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const tmp = mkdtempSync(join(tmpdir(), "armory-health-"));
|
|
65
|
+
process.env.TODO_DIR = tmp;
|
|
66
|
+
process.env.TODO_STORE_PATH = join(tmp, "todo.json");
|
|
67
|
+
|
|
68
|
+
const { healthReport } = await import("../src/health.ts");
|
|
69
|
+
const { saveStore, loadStore } = await import("../src/todo-store.ts");
|
|
70
|
+
const { saveArchive, loadArchive } = await import("../src/archive.ts");
|
|
71
|
+
import type { Todo } from "../src/todo-store.ts";
|
|
72
|
+
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const stale = new Date(now - 45 * 86400_000).toISOString(); // 45 days ago (> 30d threshold)
|
|
75
|
+
const fresh = new Date(now - 5 * 86400_000).toISOString(); // 5 days ago
|
|
76
|
+
const parkedStale = new Date(now - 70 * 86400_000).toISOString(); // 70 days (> 60d)
|
|
77
|
+
const archOld = new Date(now - 200 * 86400_000).toISOString(); // 200 days (> 180d)
|
|
78
|
+
|
|
79
|
+
// Seed a live store: 16 open (1 stale), 3 in_progress, 12 parked (1 stale), 2 done (fresh-closed)
|
|
80
|
+
const liveTodos: Todo[] = [];
|
|
81
|
+
for (let i = 0; i < 16; i++) liveTodos.push({ id: `td-open-${i}`, text: `open ${i}`, project: i < 5 ? "pi" : "", tags: [], priority: "med", status: "open", source: "", createdAt: fresh, updatedAt: i === 0 ? stale : fresh, closedAt: null });
|
|
82
|
+
for (let i = 0; i < 3; i++) liveTodos.push({ id: `td-ip-${i}`, text: `ip ${i}`, project: "", tags: [], priority: "high", status: "in_progress", source: "", createdAt: fresh, updatedAt: fresh, closedAt: null });
|
|
83
|
+
for (let i = 0; i < 12; i++) liveTodos.push({ id: `td-park-${i}`, text: `parked ${i}`, project: "", tags: [], priority: "low", status: "parked", source: "", createdAt: fresh, updatedAt: i === 0 ? parkedStale : fresh, closedAt: null });
|
|
84
|
+
for (let i = 0; i < 2; i++) liveTodos.push({ id: `td-done-${i}`, text: `done ${i}`, project: "", tags: [], priority: "med", status: "done", source: "", createdAt: fresh, updatedAt: fresh, closedAt: fresh });
|
|
85
|
+
saveStore({ version: 2, updatedAt: fresh, todos: liveTodos });
|
|
86
|
+
|
|
87
|
+
// Seed an archive: 210 items (10 older than 180d)
|
|
88
|
+
const archTodos: Todo[] = [];
|
|
89
|
+
for (let i = 0; i < 210; i++) archTodos.push({ id: `td-arch-${i}`, text: `arch ${i}`, project: i < 50 ? "nuntius" : "", tags: [], priority: "med", status: i % 2 === 0 ? "done" : "cancelled", source: "", createdAt: fresh, updatedAt: fresh, closedAt: i < 10 ? archOld : fresh });
|
|
90
|
+
saveArchive({ version: 2, updatedAt: fresh, todos: archTodos });
|
|
91
|
+
|
|
92
|
+
const report = healthReport();
|
|
93
|
+
|
|
94
|
+
// active: 16 open + 3 in_progress = 19 (> 15 threshold → ACTIVE_LARGE); 1 stale (> 30d → ACTIVE_STALE)
|
|
95
|
+
eq("active open count", report.active.open, 16);
|
|
96
|
+
eq("active in_progress count", report.active.in_progress, 3);
|
|
97
|
+
eq("active stale_30d", report.active.stale_30d, 1);
|
|
98
|
+
ok("ACTIVE_LARGE flag", report.flags.includes("ACTIVE_LARGE"));
|
|
99
|
+
ok("ACTIVE_STALE flag", report.flags.includes("ACTIVE_STALE"));
|
|
100
|
+
|
|
101
|
+
// parked: 12 (> 10 → PARKED_LARGE); 1 stale (> 60d → PARKED_STALE)
|
|
102
|
+
eq("parked count", report.parked.count, 12);
|
|
103
|
+
eq("parked stale_60d", report.parked.stale_60d, 1);
|
|
104
|
+
ok("PARKED_LARGE flag", report.flags.includes("PARKED_LARGE"));
|
|
105
|
+
ok("PARKED_STALE flag", report.flags.includes("PARKED_STALE"));
|
|
106
|
+
|
|
107
|
+
// archive: 210 (> 200 → ARCHIVE_LARGE); 10 older than 180d → ARCHIVE_OLD
|
|
108
|
+
eq("archive count", report.archive.count, 210);
|
|
109
|
+
eq("archive older_180d", report.archive.older_180d, 10);
|
|
110
|
+
ok("ARCHIVE_LARGE flag", report.flags.includes("ARCHIVE_LARGE"));
|
|
111
|
+
ok("ARCHIVE_OLD flag", report.flags.includes("ARCHIVE_OLD"));
|
|
112
|
+
|
|
113
|
+
// suggestions present + actionable
|
|
114
|
+
ok("has suggestions", report.suggestions.length >= 0);
|
|
115
|
+
ok("archive suggestion mentions hard-prune", report.suggestions.some((s) => s.includes("hard-prune") || s.includes("prune --hard")));
|
|
116
|
+
ok("active suggestion mentions park or close", report.suggestions.some((s) => s.includes("park") || s.includes("close")));
|
|
117
|
+
|
|
118
|
+
// --- clean store → no flags ---
|
|
119
|
+
saveStore({ version: 2, updatedAt: fresh, todos: [] });
|
|
120
|
+
saveArchive({ version: 2, updatedAt: fresh, todos: [] });
|
|
121
|
+
const clean = healthReport();
|
|
122
|
+
eq("clean active open", clean.active.open, 0);
|
|
123
|
+
eq("clean flags empty", clean.flags.length, 0);
|
|
124
|
+
|
|
125
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
126
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
127
|
+
if (failed > 0) process.exit(1);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
131
|
+
|
|
132
|
+
Run: `node test/todo-health.test.mts`
|
|
133
|
+
Expected: FAIL — `Cannot find module '../src/health.ts'`
|
|
134
|
+
|
|
135
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
136
|
+
|
|
137
|
+
Create `src/health.ts`:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
// Bloat diagnostics for armory-todo — a pure-read report across all three
|
|
141
|
+
// lifecycle boxes (active / parked / archive), driven by the heuristics in
|
|
142
|
+
// todo.config.json. No side effects. The agent surfaces this + suggestions,
|
|
143
|
+
// then waits for user confirmation before any `prune --hard` (SPEC-2).
|
|
144
|
+
|
|
145
|
+
import { loadStore } from "./todo-store.ts";
|
|
146
|
+
import { loadArchive } from "./archive.ts";
|
|
147
|
+
import { loadConfig } from "./config.ts";
|
|
148
|
+
import type { Todo } from "./todo-store.ts";
|
|
149
|
+
|
|
150
|
+
export interface ActiveHealth {
|
|
151
|
+
open: number;
|
|
152
|
+
in_progress: number;
|
|
153
|
+
stale_30d: number; // open todos with updatedAt older than activeStaleDays
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface ParkedHealth {
|
|
157
|
+
count: number;
|
|
158
|
+
stale_60d: number; // parked with updatedAt older than parkedStaleDays
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface ArchiveHealth {
|
|
162
|
+
count: number;
|
|
163
|
+
older_180d: number; // closedAt older than archiveOldDays
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export type HealthFlag =
|
|
167
|
+
| "ACTIVE_LARGE" | "ACTIVE_STALE"
|
|
168
|
+
| "PARKED_LARGE" | "PARKED_STALE"
|
|
169
|
+
| "ARCHIVE_LARGE" | "ARCHIVE_OLD";
|
|
170
|
+
|
|
171
|
+
export interface HealthReport {
|
|
172
|
+
active: ActiveHealth;
|
|
173
|
+
parked: ParkedHealth;
|
|
174
|
+
archive: ArchiveHealth;
|
|
175
|
+
flags: HealthFlag[];
|
|
176
|
+
suggestions: string[];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function daysAgo(iso: string): number {
|
|
180
|
+
return (Date.now() - Date.parse(iso)) / 86400_000;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function healthReport(): HealthReport {
|
|
184
|
+
const config = loadConfig();
|
|
185
|
+
const h = config.health;
|
|
186
|
+
const live = loadStore();
|
|
187
|
+
const archive = loadArchive();
|
|
188
|
+
|
|
189
|
+
const openTodos = live.todos.filter((t) => t.status === "open");
|
|
190
|
+
const ipTodos = live.todos.filter((t) => t.status === "in_progress");
|
|
191
|
+
const parkedTodos = live.todos.filter((t) => t.status === "parked");
|
|
192
|
+
const actionable = [...openTodos, ...ipTodos];
|
|
193
|
+
|
|
194
|
+
const activeStale = openTodos.filter((t) => daysAgo(t.updatedAt) > h.activeStaleDays).length;
|
|
195
|
+
const parkedStale = parkedTodos.filter((t) => daysAgo(t.updatedAt) > h.parkedStaleDays).length;
|
|
196
|
+
const archiveOld = archive.todos.filter((t) => t.closedAt && daysAgo(t.closedAt) > h.archiveOldDays).length;
|
|
197
|
+
|
|
198
|
+
const active: ActiveHealth = {
|
|
199
|
+
open: openTodos.length,
|
|
200
|
+
in_progress: ipTodos.length,
|
|
201
|
+
stale_30d: activeStale,
|
|
202
|
+
};
|
|
203
|
+
const parked: ParkedHealth = { count: parkedTodos.length, stale_60d: parkedStale };
|
|
204
|
+
const arch: ArchiveHealth = { count: archive.todos.length, older_180d: archiveOld };
|
|
205
|
+
|
|
206
|
+
const flags: HealthFlag[] = [];
|
|
207
|
+
if (actionable.length > h.activeMaxOpen) flags.push("ACTIVE_LARGE");
|
|
208
|
+
if (activeStale > 0) flags.push("ACTIVE_STALE");
|
|
209
|
+
if (parkedTodos.length > h.parkedMax) flags.push("PARKED_LARGE");
|
|
210
|
+
if (parkedStale > 0) flags.push("PARKED_STALE");
|
|
211
|
+
if (archive.todos.length > h.archiveMax) flags.push("ARCHIVE_LARGE");
|
|
212
|
+
if (archiveOld > 0) flags.push("ARCHIVE_OLD");
|
|
213
|
+
|
|
214
|
+
const suggestions: string[] = [];
|
|
215
|
+
if (archiveOld > 0) suggestions.push(`archive: ${archiveOld} items older than ${h.archiveOldDays}d → consider \`prune --hard --box archive --older-than ${h.archiveOldDays} --confirm\``);
|
|
216
|
+
if (activeStale > 0) suggestions.push(`active: ${activeStale} open TODOs untouched for ${h.activeStaleDays}d → park or close them`);
|
|
217
|
+
if (parkedStale > 0) suggestions.push(`parked: ${parkedStale} parked > ${h.parkedStaleDays}d → restore or hard-prune`);
|
|
218
|
+
if (actionable.length > h.activeMaxOpen) suggestions.push(`active: ${actionable.length} open+in_progress (max ${h.activeMaxOpen}) → close or park some before adding more`);
|
|
219
|
+
|
|
220
|
+
return { active, parked, archive: arch, flags, suggestions };
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
225
|
+
|
|
226
|
+
Run: `node test/todo-health.test.mts`
|
|
227
|
+
Expected: PASS (all assertions)
|
|
228
|
+
|
|
229
|
+
- [ ] **Step 5: Commit**
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
git add src/health.ts test/todo-health.test.mts
|
|
233
|
+
git commit -m "feat(health): bloat diagnostics — pure-read report across active/parked/archive"
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Task 2: `hardPrune` — the only irreversible deletion (confirm-gated)
|
|
239
|
+
|
|
240
|
+
**Files:**
|
|
241
|
+
- Create: `src/hard-prune.ts`
|
|
242
|
+
- Create: `test/todo-hard-prune.test.mts`
|
|
243
|
+
|
|
244
|
+
**Interfaces:**
|
|
245
|
+
- Consumes: `loadStore`/`saveStore` from `src/todo-store.ts`, `loadArchive`/`saveArchive` from `src/archive.ts`.
|
|
246
|
+
- Produces: `HardPruneInput`, `HardPruneResult`, `hardPrune(opts: HardPruneInput): HardPruneResult`. Refuses (returns `{ refused: true, ... }`) unless `confirm: true`.
|
|
247
|
+
|
|
248
|
+
- [ ] **Step 1: Write the failing test**
|
|
249
|
+
|
|
250
|
+
Create `test/todo-hard-prune.test.mts`:
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
254
|
+
import { tmpdir } from "node:os";
|
|
255
|
+
import { join } from "node:path";
|
|
256
|
+
|
|
257
|
+
let passed = 0;
|
|
258
|
+
let failed = 0;
|
|
259
|
+
function ok(name: string, cond: boolean, extra = ""): void {
|
|
260
|
+
if (cond) { passed++; } else { failed++; console.error(` ✗ ${name} ${extra}`); }
|
|
261
|
+
}
|
|
262
|
+
function eq<T>(name: string, got: T, want: T): void {
|
|
263
|
+
ok(name, got === want, `(got ${JSON.stringify(got)} want ${JSON.stringify(want)})`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const tmp = mkdtempSync(join(tmpdir(), "armory-hardprune-"));
|
|
267
|
+
process.env.TODO_DIR = tmp;
|
|
268
|
+
process.env.TODO_STORE_PATH = join(tmp, "todo.json");
|
|
269
|
+
|
|
270
|
+
const { hardPrune } = await import("../src/hard-prune.ts");
|
|
271
|
+
const { loadStore, saveStore } = await import("../src/todo-store.ts");
|
|
272
|
+
const { loadArchive, saveArchive } = await import("../src/archive.ts");
|
|
273
|
+
import type { Todo } from "../src/todo-store.ts";
|
|
274
|
+
|
|
275
|
+
const now = Date.now();
|
|
276
|
+
const oldDate = new Date(now - 200 * 86400_000).toISOString();
|
|
277
|
+
const fresh = new Date().toISOString();
|
|
278
|
+
|
|
279
|
+
// Seed: archive with 5 old (closedAt 200d ago) + 5 fresh; live with 2 parked + 1 open
|
|
280
|
+
const archTodos: Todo[] = [];
|
|
281
|
+
for (let i = 0; i < 5; i++) archTodos.push({ id: `td-arch-old-${i}`, text: `old ${i}`, project: i < 2 ? "nuntius" : "", tags: [], priority: "med", status: "done", source: "", createdAt: oldDate, updatedAt: oldDate, closedAt: oldDate });
|
|
282
|
+
for (let i = 0; i < 5; i++) archTodos.push({ id: `td-arch-fresh-${i}`, text: `fresh ${i}`, project: "", tags: [], priority: "med", status: "done", source: "", createdAt: fresh, updatedAt: fresh, closedAt: fresh });
|
|
283
|
+
saveArchive({ version: 2, updatedAt: fresh, todos: archTodos });
|
|
284
|
+
|
|
285
|
+
const liveTodos: Todo[] = [
|
|
286
|
+
{ id: "td-park-1", text: "parked one", project: "pi", tags: [], priority: "low", status: "parked", source: "", createdAt: fresh, updatedAt: oldDate, closedAt: null },
|
|
287
|
+
{ id: "td-park-2", text: "parked two", project: "", tags: [], priority: "low", status: "parked", source: "", createdAt: fresh, updatedAt: fresh, closedAt: null },
|
|
288
|
+
{ id: "td-open-1", text: "open one", project: "", tags: [], priority: "med", status: "open", source: "", createdAt: fresh, updatedAt: fresh, closedAt: null },
|
|
289
|
+
];
|
|
290
|
+
saveStore({ version: 2, updatedAt: fresh, todos: liveTodos });
|
|
291
|
+
|
|
292
|
+
// --- refuses without confirm ---
|
|
293
|
+
const refused = hardPrune({ box: "archive", olderThan: 180 });
|
|
294
|
+
ok("refuses without confirm", refused.refused === true);
|
|
295
|
+
eq("refused deleted count", refused.deleted, 0);
|
|
296
|
+
eq("archive untouched after refuse", loadArchive().todos.length, 10);
|
|
297
|
+
|
|
298
|
+
// --- deletes with confirm: archive, olderThan 180 → 5 old deleted ---
|
|
299
|
+
const result = hardPrune({ box: "archive", olderThan: 180, confirm: true });
|
|
300
|
+
ok("not refused with confirm", !result.refused);
|
|
301
|
+
eq("deleted 5 old archive items", result.deleted, 5);
|
|
302
|
+
eq("archive now has 5", loadArchive().todos.length, 5);
|
|
303
|
+
ok("only fresh remain", loadArchive().todos.every((t) => t.id.includes("fresh")));
|
|
304
|
+
|
|
305
|
+
// --- deletes with project filter ---
|
|
306
|
+
saveArchive({ version: 2, updatedAt: fresh, todos: archTodos }); // reset
|
|
307
|
+
const byProject = hardPrune({ box: "archive", olderThan: 180, project: "nuntius", confirm: true });
|
|
308
|
+
eq("project filter deleted 2", byProject.deleted, 2);
|
|
309
|
+
|
|
310
|
+
// --- targets parked box (live store) ---
|
|
311
|
+
saveStore({ version: 2, updatedAt: fresh, todos: liveTodos });
|
|
312
|
+
const parkedPrune = hardPrune({ box: "parked", olderThan: 60, confirm: true });
|
|
313
|
+
eq("parked prune deleted 1 (the stale one)", parkedPrune.deleted, 1);
|
|
314
|
+
const liveAfter = loadStore();
|
|
315
|
+
ok("fresh parked survived", liveAfter.todos.some((t) => t.id === "td-park-2"));
|
|
316
|
+
ok("stale parked gone", !liveAfter.todos.some((t) => t.id === "td-park-1"));
|
|
317
|
+
ok("open survived parked-prune", liveAfter.todos.some((t) => t.id === "td-open-1"));
|
|
318
|
+
|
|
319
|
+
// --- targets active box (open + in_progress only; parked excluded) ---
|
|
320
|
+
saveStore({ version: 2, updatedAt: fresh, todos: [
|
|
321
|
+
{ id: "td-stale-open", text: "stale open", project: "", tags: [], priority: "med", status: "open", source: "", createdAt: fresh, updatedAt: oldDate, closedAt: null },
|
|
322
|
+
{ id: "td-fresh-open", text: "fresh open", project: "", tags: [], priority: "med", status: "open", source: "", createdAt: fresh, updatedAt: fresh, closedAt: null },
|
|
323
|
+
{ id: "td-parked-survives", text: "parked", project: "", tags: [], priority: "low", status: "parked", source: "", createdAt: fresh, updatedAt: fresh, closedAt: null },
|
|
324
|
+
] });
|
|
325
|
+
const activePrune = hardPrune({ box: "active", olderThan: 60, confirm: true });
|
|
326
|
+
eq("active prune deleted 1 (stale open)", activePrune.deleted, 1);
|
|
327
|
+
const liveAfterActive = loadStore();
|
|
328
|
+
ok("fresh open survived", liveAfterActive.todos.some((t) => t.id === "td-fresh-open"));
|
|
329
|
+
ok("parked survived active-prune", liveAfterActive.todos.some((t) => t.id === "td-parked-survives"));
|
|
330
|
+
ok("stale open gone", !liveAfterActive.todos.some((t) => t.id === "td-stale-open"));
|
|
331
|
+
|
|
332
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
333
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
334
|
+
if (failed > 0) process.exit(1);
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
338
|
+
|
|
339
|
+
Run: `node test/todo-hard-prune.test.mts`
|
|
340
|
+
Expected: FAIL — `Cannot find module '../src/hard-prune.ts'`
|
|
341
|
+
|
|
342
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
343
|
+
|
|
344
|
+
Create `src/hard-prune.ts`:
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
// The ONLY irreversible deletion path in armory-todo. Everything else is
|
|
348
|
+
// reversible (park = status flip, prune = archive move, restore = move back).
|
|
349
|
+
// hardPrune permanently deletes todos from the targeted box.
|
|
350
|
+
//
|
|
351
|
+
// Structural gate: refuses to execute unless `confirm: true` is passed. Even
|
|
352
|
+
// if the agent hallucinates intent, the tool demands the flag. The prompt
|
|
353
|
+
// guidelines (extensions/todo.ts) instruct the agent to always surface the
|
|
354
|
+
// `health` report + the exact proposed command and wait for an explicit user
|
|
355
|
+
// "yes" before passing confirm. The slash path uses ctx.ui.confirm.
|
|
356
|
+
|
|
357
|
+
import { loadStore, saveStore } from "./todo-store.ts";
|
|
358
|
+
import { loadArchive, saveArchive } from "./archive.ts";
|
|
359
|
+
import type { Todo } from "./todo-store.ts";
|
|
360
|
+
|
|
361
|
+
export type HardPruneBox = "archive" | "active" | "parked";
|
|
362
|
+
|
|
363
|
+
export interface HardPruneInput {
|
|
364
|
+
confirm: boolean; // REQUIRED — must be true to execute
|
|
365
|
+
box?: HardPruneBox; // default: "archive"
|
|
366
|
+
olderThan?: number; // days; filters by updatedAt (active/parked) or closedAt (archive)
|
|
367
|
+
project?: string;
|
|
368
|
+
tag?: string;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export interface HardPruneResult {
|
|
372
|
+
refused: boolean;
|
|
373
|
+
deleted: number;
|
|
374
|
+
ids: string[];
|
|
375
|
+
message: string;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function daysAgo(iso: string): number {
|
|
379
|
+
return (Date.now() - Date.parse(iso)) / 86400_000;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Permanently delete todos from a box. The only irreversible action.
|
|
384
|
+
* Returns `{ refused: true, deleted: 0, ... }` unless `confirm: true`.
|
|
385
|
+
*/
|
|
386
|
+
export function hardPrune(opts: HardPruneInput): HardPruneResult {
|
|
387
|
+
if (!opts.confirm) {
|
|
388
|
+
return {
|
|
389
|
+
refused: true,
|
|
390
|
+
deleted: 0,
|
|
391
|
+
ids: [],
|
|
392
|
+
message: "Refused: pass confirm:true to execute hard-prune (this permanently deletes).",
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
const box: HardPruneBox = opts.box ?? "archive";
|
|
396
|
+
const cutoff = opts.olderThan ? Date.now() - opts.olderThan * 86400_000 : null;
|
|
397
|
+
|
|
398
|
+
const matches = (t: Todo): boolean => {
|
|
399
|
+
if (opts.project && t.project !== opts.project) return false;
|
|
400
|
+
if (opts.tag && !t.tags.includes(opts.tag)) return false;
|
|
401
|
+
if (cutoff !== null) {
|
|
402
|
+
const dateField = box === "archive" ? (t.closedAt ?? t.updatedAt) : t.updatedAt;
|
|
403
|
+
if (Date.parse(dateField) > cutoff) return false;
|
|
404
|
+
}
|
|
405
|
+
return true;
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
if (box === "archive") {
|
|
409
|
+
const archive = loadArchive();
|
|
410
|
+
const kept: Todo[] = [];
|
|
411
|
+
const deleted: Todo[] = [];
|
|
412
|
+
for (const t of archive.todos) (matches(t) ? deleted : kept).push(t);
|
|
413
|
+
if (deleted.length === 0) return { refused: false, deleted: 0, ids: [], message: "No archived todos matched the criteria." };
|
|
414
|
+
archive.todos = kept;
|
|
415
|
+
saveArchive(archive);
|
|
416
|
+
return { refused: false, deleted: deleted.length, ids: deleted.map((t) => t.id), message: `Permanently deleted ${deleted.length} archived todo${deleted.length === 1 ? "" : "s"}.` };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// active or parked box → live store
|
|
420
|
+
const live = loadStore();
|
|
421
|
+
const targetStatuses = box === "parked" ? ["parked"] : ["open", "in_progress"];
|
|
422
|
+
const kept: Todo[] = [];
|
|
423
|
+
const deleted: Todo[] = [];
|
|
424
|
+
for (const t of live.todos) {
|
|
425
|
+
if (targetStatuses.includes(t.status) && matches(t)) {
|
|
426
|
+
deleted.push(t);
|
|
427
|
+
} else {
|
|
428
|
+
kept.push(t);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (deleted.length === 0) return { refused: false, deleted: 0, ids: [], message: `No ${box} todos matched the criteria.` };
|
|
432
|
+
live.todos = kept;
|
|
433
|
+
saveStore(live);
|
|
434
|
+
return { refused: false, deleted: deleted.length, ids: deleted.map((t) => t.id), message: `Permanently deleted ${deleted.length} ${box} todo${deleted.length === 1 ? "" : "s"}.` };
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
439
|
+
|
|
440
|
+
Run: `node test/todo-hard-prune.test.mts`
|
|
441
|
+
Expected: PASS (all assertions)
|
|
442
|
+
|
|
443
|
+
- [ ] **Step 5: Commit**
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
git add src/hard-prune.ts test/todo-hard-prune.test.mts
|
|
447
|
+
git commit -m "feat(hard-prune): the only irreversible deletion — confirm-gated, targets any box"
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
## Task 3: Extension tool — `health` + `prune --hard` actions + prompt guidelines
|
|
453
|
+
|
|
454
|
+
**Files:**
|
|
455
|
+
- Modify: `extensions/todo.ts`
|
|
456
|
+
|
|
457
|
+
**Interfaces:**
|
|
458
|
+
- Consumes: `healthReport` from `src/health.ts`, `hardPrune` from `src/hard-prune.ts`.
|
|
459
|
+
- Produces: `todo` tool gains `health` + `prune --hard` (via `action: "health"` and `action: "prune"` with `hard: true`); new params `hard`, `confirm`, `box`, `olderThan`.
|
|
460
|
+
|
|
461
|
+
- [ ] **Step 1: No standalone test (extension layer — manual gate).** The store logic is covered by Tasks 1–2. Verify via `node --check extensions/todo.ts` after edits.
|
|
462
|
+
|
|
463
|
+
- [ ] **Step 2: (syntax check is the gate)**
|
|
464
|
+
|
|
465
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
466
|
+
|
|
467
|
+
In `extensions/todo.ts`, make these edits:
|
|
468
|
+
|
|
469
|
+
1. Add imports:
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
import { pruneTodos, restoreTodo, listArchived, archiveSummary } from "../src/archive";
|
|
473
|
+
import { healthReport } from "../src/health";
|
|
474
|
+
import { hardPrune } from "../src/hard-prune";
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
2. Extend `ACTIONS`:
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
const ACTIONS = ["list", "add", "update", "complete", "delete", "clear", "park", "prune", "restore", "health"] as const;
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
3. Extend the tool `parameters` — add `hard`, `confirm`, `box`, `olderThan`:
|
|
484
|
+
|
|
485
|
+
```ts
|
|
486
|
+
// prune options
|
|
487
|
+
ageDays: Type.Optional(Type.Number({ description: "prune: closedAt older than this many days (default from config)" })),
|
|
488
|
+
all: Type.Optional(Type.Boolean({ description: "prune: ignore age, move all done/cancelled" })),
|
|
489
|
+
// hard-prune options (SPEC-2)
|
|
490
|
+
hard: Type.Optional(Type.Boolean({ description: "prune: if true, execute a HARD prune (permanent deletion). Requires confirm:true. The only irreversible action." })),
|
|
491
|
+
confirm: Type.Optional(Type.Boolean({ description: "hard-prune: must be true to execute. Always surface the health report + proposed command and wait for explicit user confirmation first." })),
|
|
492
|
+
box: Type.Optional(StringEnum(["archive", "active", "parked"] as const, { description: "hard-prune: which box to target (default archive)" })),
|
|
493
|
+
olderThan: Type.Optional(Type.Number({ description: "hard-prune: delete items older than this many days (by closedAt for archive, updatedAt for active/parked)" })),
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
4. Update the tool `description` + `promptGuidelines`:
|
|
497
|
+
|
|
498
|
+
```ts
|
|
499
|
+
description:
|
|
500
|
+
"Global cross-session TODO store (persists across ALL pi sessions, not just this one). " +
|
|
501
|
+
"Use when the user says 'put this in our TODO', 'show me the TODO', 'mark <id> done', 'park <id>', 'prune', 'restore <id>', 'how is my todo hygiene?', etc. " +
|
|
502
|
+
"Open TODOs are auto-injected each turn; parked todos are NOT injected (deferred/someday). " +
|
|
503
|
+
"Done/cancelled todos are moved to an archive by `prune` (reversible via `restore`). " +
|
|
504
|
+
"`prune --hard` (hard:true, confirm:true) is the ONLY irreversible action — always run `health` first, surface the report + proposed command, and wait for explicit user confirmation. " +
|
|
505
|
+
"Never put secrets in a TODO — the text reaches the model provider.",
|
|
506
|
+
promptSnippet: "Read/update the global cross-session TODO list (active / parked / archive) + bloat health",
|
|
507
|
+
promptGuidelines: [
|
|
508
|
+
"Use todo (action:'list') when the user asks 'show me the TODO' / 'what's pending'.",
|
|
509
|
+
"Use todo (action:'add', text, project?, tags?, priority?, source?) when the user says 'put this in our TODO'.",
|
|
510
|
+
"Use todo (action:'complete', id) to mark a TODO done; (action:'delete', id) to cancel it.",
|
|
511
|
+
"Use todo (action:'park', id) to defer a TODO (not injected, recoverable); (action:'update', id, status:'open') to un-park.",
|
|
512
|
+
"Use todo (action:'prune') to move done/cancelled todos to the archive (reversible); (action:'prune', all:true) to prune all regardless of age.",
|
|
513
|
+
"Use todo (action:'restore', id) to bring an archived TODO back as open.",
|
|
514
|
+
"Use todo (action:'list', archived:true) to query the archive — bare call returns a summary; add a filter (project/text/since) for specific items.",
|
|
515
|
+
"Use todo (action:'health') to check bloat across all boxes — returns counts + flags + suggestions. Run this when the user asks about hygiene/bloat or before any hard-prune.",
|
|
516
|
+
"Use todo (action:'prune', hard:true, confirm:true, box?, olderThan?) for PERMANENT deletion — the only irreversible action. ALWAYS: run `health` first, show the user the report + the exact proposed command, and wait for an explicit 'yes' before passing confirm:true. Never hard-prune without explicit user confirmation.",
|
|
517
|
+
],
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
5. Update the `execute` switch — extend the `prune` case + add a `health` case. Replace the existing `prune` case:
|
|
521
|
+
|
|
522
|
+
```ts
|
|
523
|
+
case "prune": {
|
|
524
|
+
if (params.hard) {
|
|
525
|
+
const res = hardPrune({
|
|
526
|
+
confirm: params.confirm === true,
|
|
527
|
+
box: params.box,
|
|
528
|
+
olderThan: params.olderThan,
|
|
529
|
+
project: params.projectFilter,
|
|
530
|
+
tag: params.tagFilter,
|
|
531
|
+
});
|
|
532
|
+
return { content: [{ type: "text" as const, text: res.message + (res.refused ? "" : ` Deleted: ${res.ids.join(", ") || "(none)"}`) }] };
|
|
533
|
+
}
|
|
534
|
+
const res = pruneTodos({ ageDays: params.ageDays, all: params.all });
|
|
535
|
+
return { content: [{ type: "text" as const, text: `Pruned ${res.moved} todo${res.moved === 1 ? "" : "s"} to archive: ${res.ids.join(", ") || "(none)"}` }] };
|
|
536
|
+
}
|
|
537
|
+
case "health": {
|
|
538
|
+
const report = healthReport();
|
|
539
|
+
const lines = [
|
|
540
|
+
`## TODO Health Report`,
|
|
541
|
+
`active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale >${"30"}d)`,
|
|
542
|
+
`parked: ${report.parked.count} (${report.parked.stale_60d} stale >${"60"}d)`,
|
|
543
|
+
`archive: ${report.archive.count} (${report.archive.older_180d} older >${"180"}d)`,
|
|
544
|
+
report.flags.length ? `flags: ${report.flags.join(", ")}` : "flags: (none — healthy)",
|
|
545
|
+
...report.suggestions.map((s) => ` → ${s}`),
|
|
546
|
+
];
|
|
547
|
+
return { content: [{ type: "text" as const, text: lines.join("\n") }] };
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
- [ ] **Step 4: Verify syntax**
|
|
552
|
+
|
|
553
|
+
Run: `node --check extensions/todo.ts`
|
|
554
|
+
Expected: exit 0 (syntax valid)
|
|
555
|
+
|
|
556
|
+
- [ ] **Step 5: Commit**
|
|
557
|
+
|
|
558
|
+
```bash
|
|
559
|
+
git add extensions/todo.ts
|
|
560
|
+
git commit -m "feat(ext): health + prune --hard tool actions + prompt guidelines (always-ask-first)"
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
---
|
|
564
|
+
|
|
565
|
+
## Task 4: Extension slash — `/todo health` + `/todo prune --hard` (ctx.ui.confirm gate)
|
|
566
|
+
|
|
567
|
+
**Files:**
|
|
568
|
+
- Modify: `extensions/todo.ts` (slash command handler)
|
|
569
|
+
|
|
570
|
+
- [ ] **Step 1: (manual gate — slash commands need a live TUI)**
|
|
571
|
+
|
|
572
|
+
- [ ] **Step 2: (skipped)**
|
|
573
|
+
|
|
574
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
575
|
+
|
|
576
|
+
In `extensions/todo.ts`, add `health` + `hard` subcommand routing to the slash handler. Insert before the `// default: list open` fallback:
|
|
577
|
+
|
|
578
|
+
```ts
|
|
579
|
+
if (sub === "health") {
|
|
580
|
+
const report = healthReport();
|
|
581
|
+
const lines = [
|
|
582
|
+
`TODO Health:`,
|
|
583
|
+
` active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
|
|
584
|
+
` parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
|
|
585
|
+
` archive: ${report.archive.count} (${report.archive.older_180d} old)`,
|
|
586
|
+
report.flags.length ? ` ⚠ ${report.flags.join(", ")}` : " ✅ healthy",
|
|
587
|
+
...report.suggestions.map((s) => ` → ${s}`),
|
|
588
|
+
];
|
|
589
|
+
if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
And extend the existing `prune` slash case to handle `--hard` with a `ctx.ui.confirm` gate:
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
if (sub === "prune") {
|
|
598
|
+
const isHard = rest.includes("--hard");
|
|
599
|
+
if (isHard) {
|
|
600
|
+
// Parse remaining flags: --box <box> --older-than <N> --project <p>
|
|
601
|
+
const boxIdx = rest.indexOf("--box");
|
|
602
|
+
const olderIdx = rest.indexOf("--older-than");
|
|
603
|
+
const projIdx = rest.indexOf("--project");
|
|
604
|
+
const box = boxIdx >= 0 ? rest[boxIdx + 1] : undefined;
|
|
605
|
+
const olderThan = olderIdx >= 0 ? Number(rest[olderIdx + 1]) : undefined;
|
|
606
|
+
const project = projIdx >= 0 ? rest[projIdx + 1] : undefined;
|
|
607
|
+
const preview = hardPrune({ confirm: false, box: box as any, olderThan, project });
|
|
608
|
+
if (ctx.hasUI) {
|
|
609
|
+
const yes = await ctx.ui.confirm(
|
|
610
|
+
`HARD PRUNE (permanent deletion)\n${preview.message}\nBox: ${box ?? "archive"}${olderThan ? `, older than ${olderThan}d` : ""}${project ? `, project: ${project}` : ""}\n\nProceed?`,
|
|
611
|
+
);
|
|
612
|
+
if (!yes) { ctx.ui.notify("Hard-prune cancelled.", "info"); return; }
|
|
613
|
+
}
|
|
614
|
+
const res = hardPrune({ confirm: true, box: box as any, olderThan, project });
|
|
615
|
+
if (ctx.hasUI) ctx.ui.notify(res.message + ` Deleted: ${res.ids.join(", ") || "(none)"}`, res.refused ? "warning" : "info");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const all = rest.includes("--all");
|
|
619
|
+
const res = pruneTodos({ all });
|
|
620
|
+
if (ctx.hasUI) ctx.ui.notify(`Pruned ${res.moved} todo${res.moved === 1 ? "" : "s"} to archive: ${res.ids.join(", ") || "(none)"}`, "info");
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
Update the command `description` to advertise the new subcommands:
|
|
626
|
+
|
|
627
|
+
```ts
|
|
628
|
+
description:
|
|
629
|
+
"Global cross-session TODO list. " +
|
|
630
|
+
"/todo · /todo all · /todo add <text> · /todo done <id> · /todo rm <id> · " +
|
|
631
|
+
"/todo park <id> · /todo restore <id> · /todo prune [--all|--hard --box <b> --older-than <d>] · " +
|
|
632
|
+
"/todo archive [project:X|text:Y] · /todo health · /todo clean · /todo path",
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
- [ ] **Step 4: Verify syntax**
|
|
636
|
+
|
|
637
|
+
Run: `node --check extensions/todo.ts`
|
|
638
|
+
Expected: exit 0
|
|
639
|
+
|
|
640
|
+
- [ ] **Step 5: Commit**
|
|
641
|
+
|
|
642
|
+
```bash
|
|
643
|
+
git add extensions/todo.ts
|
|
644
|
+
git commit -m "feat(ext): /todo health + /todo prune --hard slash subcommands (ctx.ui.confirm gate)"
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
---
|
|
648
|
+
|
|
649
|
+
## Task 5: session_start bloat nudge
|
|
650
|
+
|
|
651
|
+
**Files:**
|
|
652
|
+
- Modify: `extensions/todo.ts` (the `session_start` handler)
|
|
653
|
+
|
|
654
|
+
- [ ] **Step 1: (manual gate)**
|
|
655
|
+
|
|
656
|
+
- [ ] **Step 2: (skipped)**
|
|
657
|
+
|
|
658
|
+
- [ ] **Step 3: Write minimal implementation**
|
|
659
|
+
|
|
660
|
+
In `extensions/todo.ts`, upgrade the `session_start` handler to surface bloat flags:
|
|
661
|
+
|
|
662
|
+
```ts
|
|
663
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
664
|
+
try {
|
|
665
|
+
const open = listTodos();
|
|
666
|
+
let msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}`;
|
|
667
|
+
try {
|
|
668
|
+
const report = healthReport();
|
|
669
|
+
if (report.flags.length > 0) {
|
|
670
|
+
msg += ` — ⚠ ${report.flags.length} bloat signal${report.flags.length === 1 ? "" : "s"} (run /todo health)`;
|
|
671
|
+
}
|
|
672
|
+
} catch {
|
|
673
|
+
// health check optional — don't crash the session notify
|
|
674
|
+
}
|
|
675
|
+
if (ctx.hasUI) ctx.ui.notify(msg, "info");
|
|
676
|
+
} catch {
|
|
677
|
+
// store unavailable — never crash the session
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
- [ ] **Step 4: Verify syntax**
|
|
683
|
+
|
|
684
|
+
Run: `node --check extensions/todo.ts`
|
|
685
|
+
Expected: exit 0
|
|
686
|
+
|
|
687
|
+
- [ ] **Step 5: Commit**
|
|
688
|
+
|
|
689
|
+
```bash
|
|
690
|
+
git add extensions/todo.ts
|
|
691
|
+
git commit -m "feat(ext): session_start bloat nudge — surface health flags on startup"
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
---
|
|
695
|
+
|
|
696
|
+
## Task 6: README + AGENTS.md update
|
|
697
|
+
|
|
698
|
+
**Files:**
|
|
699
|
+
- Modify: `README.md`, `AGENTS.md`
|
|
700
|
+
|
|
701
|
+
- [ ] **Step 3: Write the updates**
|
|
702
|
+
|
|
703
|
+
In `README.md`, update the tool table + slash section to include `health` + `prune --hard`. Add a "Self-awareness (SPEC-2)" subsection under Lifecycle boxes:
|
|
704
|
+
|
|
705
|
+
```markdown
|
|
706
|
+
## Self-awareness: health + hard-prune (SPEC-2)
|
|
707
|
+
|
|
708
|
+
**`health`** reports bloat across all three boxes — counts, stale items, and
|
|
709
|
+
actionable suggestions (e.g. "archive: 41 items older than 180d → consider
|
|
710
|
+
`prune --hard --box archive --older-than 180 --confirm`"). On `session_start`,
|
|
711
|
+
if any bloat flags are detected, the startup notify appends a `⚠ N bloat
|
|
712
|
+
signals` nudge.
|
|
713
|
+
|
|
714
|
+
**`prune --hard`** is the **only irreversible action** — it permanently deletes
|
|
715
|
+
todos. It's gated three ways:
|
|
716
|
+
1. **Tool-level:** `confirm: true` is required in the tool call; without it the
|
|
717
|
+
action refuses with a clear message.
|
|
718
|
+
2. **Prompt-level:** the agent is instructed to always run `health` first,
|
|
719
|
+
surface the report + the exact proposed command, and wait for an explicit
|
|
720
|
+
user "yes" before passing `confirm: true`.
|
|
721
|
+
3. **Slash-level:** `/todo prune --hard` prompts an interactive `ctx.ui.confirm`
|
|
722
|
+
yes/no dialog before executing.
|
|
723
|
+
|
|
724
|
+
Everything else in armory-todo is reversible. `prune --hard` is the one
|
|
725
|
+
irreversible escape hatch, always user-confirmed.
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
Update the tool table to add:
|
|
729
|
+
```
|
|
730
|
+
| `health` | (none) | bloat report across active/parked/archive + flags + suggestions |
|
|
731
|
+
| `prune` (hard) | `hard:true`, `confirm:true`, `box?`, `olderThan?`, `project?`, `tag?` | PERMANENT deletion — the only irreversible action |
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
Update `AGENTS.md` Notes to mention SPEC-2 is done (health + hard-prune shipped).
|
|
735
|
+
|
|
736
|
+
- [ ] **Step 4: Run all tests**
|
|
737
|
+
|
|
738
|
+
Run: `for t in todo-store todo-archive todo-config todo-migrate todo-health todo-hard-prune; do node test/$t.test.mts || exit 1; done`
|
|
739
|
+
Expected: all 6 suites PASS.
|
|
740
|
+
|
|
741
|
+
- [ ] **Step 5: Commit**
|
|
742
|
+
|
|
743
|
+
```bash
|
|
744
|
+
git add README.md AGENTS.md
|
|
745
|
+
git commit -m "docs(spec-2): health + hard-prune — self-awareness layer"
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
---
|
|
749
|
+
|
|
750
|
+
## Final verification (before declaring SPEC-2 done)
|
|
751
|
+
|
|
752
|
+
- [ ] All 6 test suites pass.
|
|
753
|
+
- [ ] No `TODO`/`FIXME`/`HACK` in delivered code.
|
|
754
|
+
- [ ] `node --check extensions/todo.ts` passes.
|
|
755
|
+
- [ ] `hardPrune` refuses without `confirm: true` (verified by test).
|
|
756
|
+
- [ ] Manual gate (real pi session, deferred to the post-SPEC-3 QA): `/todo health` shows the report; `/todo prune --hard --box archive --older-than 180` prompts a confirm dialog; refusing cancels; accepting deletes.
|
|
757
|
+
|
|
758
|
+
## Out of scope for SPEC-2
|
|
759
|
+
|
|
760
|
+
- **Interactive `/todo` TUI panel** → SPEC-3 (next).
|
|
761
|
+
- **`title` + `notes` split** → Workstream B.
|
|
762
|
+
- **Preventive caps-on-add + project registry** → Workstream C.
|