@getpipher/armory-todo 0.3.0 → 0.3.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/README.md +11 -2
- package/docs/superpowers/plans/2026-07-21-auto-prune-done-view.md +893 -0
- package/docs/superpowers/specs/2026-07-21-auto-prune-done-view-design.md +217 -0
- package/docs/superpowers/specs/2026-07-21-title-notes-split-design.md +1 -1
- package/extensions/todo.ts +59 -7
- package/package.json +2 -2
- package/src/archive.ts +55 -3
- package/src/auto-prune.ts +15 -0
- package/src/panel-data.ts +19 -1
- package/src/panel.ts +20 -10
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Workstream v0.3.1 — auto-prune on session_start + unified `Done` view
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-07-21
|
|
4
|
+
**Status:** Design approved → pending implementation plan
|
|
5
|
+
**Branch:** `feat/auto-prune-done-view` off `main`
|
|
6
|
+
**Predecessor:** v0.3.0 (title + notes split), shipped 2026-07-21 (PR #4)
|
|
7
|
+
**Target ship:** v0.3.1, auto-published via `release.yml` on `v0.3.1` tag (now also creates a GitHub Release)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 1. Problem
|
|
12
|
+
|
|
13
|
+
v0.3.0 split title/notes and fixed per-todo injection bloat, but two friction points remain in the *lifecycle* of finished work:
|
|
14
|
+
|
|
15
|
+
1. **Prune is 100% manual.** Done/cancelled todos sit in the live `todo.json` forever until someone runs `/todo prune`. On a busy stretch they pile up; the `session_start` bloat nudge only *suggests* pruning — it never does it. The user is the prune trigger.
|
|
16
|
+
2. **"Done" is split across two stores after pruning.** Recent done (<7d) lives in the live store; older done lives in `todo-archive.json`. There's no single "show me my finished work" view — you query live and archive separately. The `/todo` panel's Archive tab shows only the archive file (sealed history: done + cancelled), not recent live done.
|
|
17
|
+
|
|
18
|
+
## 2. Goals
|
|
19
|
+
|
|
20
|
+
- **Auto-prune:** done/cancelled todos older than `config.prune.defaultAgeDays` (default 7d) archive *themselves* on `session_start`, without the user asking. Deterministic (the extension does it, not the model remembering to) — but **silent when there's nothing stale**.
|
|
21
|
+
- **Transparency:** every prune (auto or manual) reports a **rich result** (ids + titles + age + `restore` hint), so the user is never blind to what moved — especially when the agent auto-pruned without an explicit trigger.
|
|
22
|
+
- **Unified `Done` view:** one command + one panel tab to see all finished work (`status: done`), spanning live (recent) + archive (old), annotated by location, filterable.
|
|
23
|
+
- **Preserve the injection contract unchanged:** only `open` + `in_progress` are auto-injected into the prompt. Parked / done / archive are not injected. v0.3.1 does not alter this boundary.
|
|
24
|
+
|
|
25
|
+
## 3. Non-goals (deferred)
|
|
26
|
+
|
|
27
|
+
- **`cancelled`-in-live visibility** — a pre-existing gap (cancelled todos <7d are in live but shown in no panel tab). Cancelled shows in the Archive tab once pruned. Separate concern; not addressed here.
|
|
28
|
+
- **`prune --all` / `prune --hard`** — unchanged (manual only; `--hard` stays the only irreversible, gated action).
|
|
29
|
+
- **Auto-prune of `parked`** — parked is deferred-not-finished, not "done"; auto-prune targets `done`/`cancelled` only (same as manual `prune`).
|
|
30
|
+
- **Time-based/scheduled prune outside session_start** — no cron; auto-prune runs only on the `session_start` hook (cold start / resume / fork / reload).
|
|
31
|
+
|
|
32
|
+
## 4. Decisions log (from brainstorm Q&A)
|
|
33
|
+
|
|
34
|
+
| # | Question | Decision |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| Q1 | Where does "done" live in the `/todo` panel? | **A** — new 5th box tab `Done` (status `done` only, unified live+archive). `done` (status) ≠ `archive` (location) — different axes; `Done` = workflow view, `Archive` = storage vault. |
|
|
37
|
+
| Q2 | Auto-prune trigger mechanism | Deterministic on `session_start` (extension code), age-gated (>`defaultAgeDays`), **never `--all`**. Not prompt-driven (the model doesn't have to remember). |
|
|
38
|
+
| Q3 | Confirm gate for auto-prune? | No gate — reversible + age-gated, not destructive. The `--hard` gate stays untouched. |
|
|
39
|
+
| Q4 | Visibility when prune runs | Rich result (ids + titles + age + `restore` hint) for BOTH auto and manual prune. |
|
|
40
|
+
| Q5 | Unified `done` listing | `todo list status:done` spans live+archive in one call; `/todo done` slash shortcut. |
|
|
41
|
+
| Q6 | Injection contract | **Unchanged** — only `open`+`in_progress` injected. Auto-prune moves done→archive (both non-injected); zero injection impact. The Done tab is a panel view only. |
|
|
42
|
+
|
|
43
|
+
## 5. Auto-prune on `session_start`
|
|
44
|
+
|
|
45
|
+
### 5.1 Trigger
|
|
46
|
+
|
|
47
|
+
The extension's existing `session_start` handler (in `extensions/todo.ts`) currently: loads open todos, computes `healthReport()`, and notifies `armory-todo: N open TODOs` (+ a `⚠ N bloat signals` suffix if flags). v0.3.1 inserts an **auto-prune step** before the notify:
|
|
48
|
+
|
|
49
|
+
1. Load the live store.
|
|
50
|
+
2. Compute the set of done/cancelled todos with `closedAt` older than `config.prune.defaultAgeDays` (default 7d) — i.e. exactly what `pruneTodos({})` (age-gated, no `--all`) would move.
|
|
51
|
+
3. **If that set is non-empty → call `pruneTodos({})`** (age-gated; **never `--all`**) and capture the moved ids + todos.
|
|
52
|
+
4. Notify once, with the rich format (§6).
|
|
53
|
+
5. **If empty → skip** (just the normal `N open TODOs` notify; no prune noise).
|
|
54
|
+
|
|
55
|
+
This runs on every `session_start` (cold start / resume / fork / `/reload`). Because it's age-gated, it's a no-op on a clean store — no churn, no prune spam. Idempotent: once pruned, the todos are in the archive; the next start sees nothing stale.
|
|
56
|
+
|
|
57
|
+
### 5.2 Why deterministic (not prompt-driven)
|
|
58
|
+
|
|
59
|
+
"Agent thinks" = the agent's *awareness* drives it, but relying on the model to remember to call `prune` every session is fragile. Wiring it into the `session_start` hook makes it **just happen** reliably — the extension acts on the user's behalf at session start, deterministically. The model still *sees* the result (via the notify) and can act on it (e.g. mention it, or `restore` if the user asks). This matches the user's intent ("automatically triggered") without depending on model compliance.
|
|
60
|
+
|
|
61
|
+
### 5.3 Config
|
|
62
|
+
|
|
63
|
+
Reuses `config.prune.defaultAgeDays` (7) — already tunable via the Config tab / `todo.config.json`. **No new config key.** A future v0.4.x could add `config.prune.autoOnSessionStart: boolean` to disable auto-prune, but v0.3.1 ships it always-on (age-gated).
|
|
64
|
+
|
|
65
|
+
### 5.4 Notify format
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
armory-todo: 2 open TODOs · auto-pruned 3 stale done (>7d):
|
|
69
|
+
[td-…] done armory-todo v0.2.0 — Workstream A shipped…
|
|
70
|
+
[td-…] done Task 1: Store schema break…
|
|
71
|
+
[td-…] cancelled QA smoke v0.3.0
|
|
72
|
+
Undo any with: todo restore <id>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
(When nothing was pruned, just `armory-todo: N open TODOs` — unchanged from v0.3.0, optionally + the existing `⚠ bloat` suffix if health flags fire.)
|
|
76
|
+
|
|
77
|
+
The notify is a **transient `ctx.ui.notify` message**, NOT a system-prompt injection. The `## Open TODOs` prompt block is untouched.
|
|
78
|
+
|
|
79
|
+
## 6. Rich prune result (auto + manual)
|
|
80
|
+
|
|
81
|
+
Both the auto-prune (§5) and manual `prune` (tool + `/todo prune` slash) produce the same structured output. New helper in `src/archive.ts`:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
export interface PruneDetail {
|
|
85
|
+
moved: number;
|
|
86
|
+
items: { id: string; status: "done" | "cancelled"; title: string; ageDays: number }[];
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`pruneTodos` gains an optional `detail: true` flag (default false for back-compat) that returns `items` + `ageDays` (computed from `closedAt`). When `detail` is true, the caller (extension) formats:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
Pruned 3 todos to archive:
|
|
94
|
+
[td-…] done <title> (was 9d old)
|
|
95
|
+
[td-…] cancelled <title> (was 12d old)
|
|
96
|
+
Undo any with: todo restore <id>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- **Manual `prune`** (tool + slash): always uses `detail: true` → rich output.
|
|
100
|
+
- **Auto-prune** (session_start): uses `detail: true` → rich notify.
|
|
101
|
+
- The `--all` path: same rich format (age shown as `(--all)` or the real age; minor).
|
|
102
|
+
|
|
103
|
+
The existing `PruneResult { moved, ids }` shape stays (back-compat for any caller); `PruneDetail` is additive.
|
|
104
|
+
|
|
105
|
+
## 7. Unified `Done` listing
|
|
106
|
+
|
|
107
|
+
### 7.1 Store layer (`src/archive.ts` + `src/todo-store.ts`)
|
|
108
|
+
|
|
109
|
+
New `listDoneUnified(filter?): DoneItem[]` (in `archive.ts` — it already coordinates live + archive via `loadStore`/`loadArchive`):
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
export interface DoneItem extends Todo {
|
|
113
|
+
location: "live" | "archive";
|
|
114
|
+
archivedAt: string | null; // closedAt for live; closedAt for archive (the prune time ≈ closedAt)
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- Pulls done todos from the live store (`loadStore().todos.filter(status === "done")`) tagged `location: "live"`, plus done todos from the archive (`loadArchive().todos.filter(status === "done")`) tagged `location: "archive"`.
|
|
119
|
+
- **Excludes `cancelled`** (Done = finished work; cancelled = abandoned → lives in the Archive tab only).
|
|
120
|
+
- Sorts newest-`closedAt` first.
|
|
121
|
+
- Filter: `text` (title|notes substring), `project`, `since`, `before`, `limit`, `page` (same shape as `listTodos`/`listArchived` filters).
|
|
122
|
+
|
|
123
|
+
### 7.2 Tool (`extensions/todo.ts`)
|
|
124
|
+
|
|
125
|
+
- **`todo list status:done`** now returns the **unified** set (live + archive) via `listDoneUnified`, formatted:
|
|
126
|
+
```
|
|
127
|
+
- [td-…] done <title> [live 3d]
|
|
128
|
+
- [td-…] done <title> [archived 2026-07-15]
|
|
129
|
+
```
|
|
130
|
+
(When `status:done` is set, the `archived` param is ignored — `done` is always unified. Other status values keep current behavior: `archived:true` queries the archive, default queries live.)
|
|
131
|
+
|
|
132
|
+
### 7.3 Slash (`/todo done`)
|
|
133
|
+
|
|
134
|
+
New slash subcommand: `/todo done` → prints the unified done list (recent first), same row format as §7.2. `/todo done project:bug-bounty` → filtered.
|
|
135
|
+
|
|
136
|
+
## 8. `/todo` panel — new `Done` box tab
|
|
137
|
+
|
|
138
|
+
### 8.1 Tabs
|
|
139
|
+
|
|
140
|
+
`BOXES` becomes `["active", "parked", "done", "archive", "config"]` (5 tabs).
|
|
141
|
+
|
|
142
|
+
### 8.2 Done tab content
|
|
143
|
+
|
|
144
|
+
- Uses `listDoneUnified({ text: filter })` (the panel's filter input searches title|notes across the unified set).
|
|
145
|
+
- `todoToItem` rows: `[id] (done) (project) • <title>` + a location tag suffix `[live N]` or `[archived <YYYY-MM-DD>]`. (Add a `todoDoneItem` helper in `panel-data.ts` that extends `todoToItem` with the location tag, or add an optional `location` param to `todoToItem`.)
|
|
146
|
+
- Sorted newest-closed first.
|
|
147
|
+
- Action submenu on Enter: **View detail** + (if location is archive) **Restore** (brings it back as open). Live done rows offer **View detail** only (no Restore — they are already in the live store; use `todo update <id> status:open` to reopen, or leave as done). No **Delete** for done rows (already finished; deletion/cancel is for open todos).
|
|
148
|
+
|
|
149
|
+
### 8.3 Archive tab — unchanged
|
|
150
|
+
|
|
151
|
+
Still the sealed-vault browse view: summary-first (counts by project + month across done + cancelled), drill-down with a filter. Unchanged from v0.3.0.
|
|
152
|
+
|
|
153
|
+
### 8.4 Footer hint
|
|
154
|
+
|
|
155
|
+
Update the panel footer to reflect 5 tabs: `↑↓ navigate • enter select/action • tab switch box • esc done`.
|
|
156
|
+
|
|
157
|
+
## 9. Injection contract (unchanged — explicit)
|
|
158
|
+
|
|
159
|
+
`renderOpenBlock()` still calls `listTodos()` with the default filter (`status === "open" || status === "in_progress"`). v0.3.1 does NOT touch `renderOpenBlock` or `listTodos`'s default. Therefore:
|
|
160
|
+
|
|
161
|
+
- Only `open` + `in_progress` are auto-injected (title + `•`, capped 15).
|
|
162
|
+
- Parked / done / archive are NOT injected — available via `list`/`get`/panel only.
|
|
163
|
+
- Auto-prune moves done (not injected) → archive (not injected): **zero injection impact**.
|
|
164
|
+
- The Done tab is a panel view; it does not inject.
|
|
165
|
+
|
|
166
|
+
The only new thing the *agent* sees at session start is the transient `ctx.ui.notify` (§5.4), not a system-prompt change.
|
|
167
|
+
|
|
168
|
+
## 10. Tests
|
|
169
|
+
|
|
170
|
+
Baseline: 220 across 8 suites. Target: ~240+.
|
|
171
|
+
|
|
172
|
+
### 10.1 New suite `test/todo-auto-prune.test.mts`
|
|
173
|
+
- `session_start` auto-prune: stale done (>7d) → moved to archive; fresh done (<7d) → stays in live; open/in_progress/parked untouched.
|
|
174
|
+
- Auto-prune never uses `--all` (fresh done <7d stays even if `--all` would move it).
|
|
175
|
+
- Auto-prune is a no-op when nothing is stale (live store unchanged, no prune notify payload).
|
|
176
|
+
- Idempotent: running twice (two session_starts) → second is a no-op.
|
|
177
|
+
- `cancelled` also auto-pruned when stale.
|
|
178
|
+
- Rich result: `PruneDetail.items` has id + status + title + ageDays.
|
|
179
|
+
|
|
180
|
+
### 10.2 Extend `test/todo-archive.test.mts`
|
|
181
|
+
- `pruneTodos({ detail: true })` returns `items` with `ageDays` computed from `closedAt`.
|
|
182
|
+
- `listDoneUnified`: live done + archived done merged, `cancelled` excluded, sorted newest-closed first, `location` tag correct, filters (text/project/since/before/limit/page) work.
|
|
183
|
+
- `listDoneUnified` empty when no done todos.
|
|
184
|
+
|
|
185
|
+
### 10.3 Extend `test/panel-data.test.mts`
|
|
186
|
+
- `todoDoneItem` (or `todoToItem` with location): label includes `[live N]` / `[archived <date>]`.
|
|
187
|
+
|
|
188
|
+
### 10.4 Extend `test/todo-store.test.mts` (if `list status:done` routing changes)
|
|
189
|
+
- `listTodos({ status: "done" })` still returns live done (back-compat) — the unification happens at the extension layer for the `todo list status:done` tool call, OR move unification into the store. **Decision:** keep `listTodos({status:"done"})` as live-only (back-compat); the unification lives in `listDoneUnified` (archive.ts), called by the extension for `todo list status:done`. So no `todo-store` change for routing — test that `listTodos({status:"done"})` is unchanged.
|
|
190
|
+
|
|
191
|
+
### 10.5 Manual-gate (no unit test)
|
|
192
|
+
- Extension `session_start` auto-prune + notify format.
|
|
193
|
+
- `/todo` panel Done tab (rendering, filter, action submenu, restore-from-archive).
|
|
194
|
+
- `/todo done` slash.
|
|
195
|
+
- `todo list status:done` unified output.
|
|
196
|
+
|
|
197
|
+
Verified by RECTOR in a real pi session before merge (same gate as v0.3.0).
|
|
198
|
+
|
|
199
|
+
## 11. Branch + ship
|
|
200
|
+
|
|
201
|
+
- Branch `feat/auto-prune-done-view` off `main`.
|
|
202
|
+
- Commits: `feat(auto-prune): ...`, `feat(prune): rich result ...`, `feat(done): unified listing ...`, `feat(panel): Done tab ...`, `test: ...`, `docs: ...` — one logical change per commit.
|
|
203
|
+
- PR to `main`, `--merge --delete-branch`. No GitLab mirror (getpipher).
|
|
204
|
+
- **QA gate:** RECTOR tests in a real pi session: stale done auto-prunes on start (fresh done stays); the notify shows the rich result; `/todo done` + the Done tab show unified done (live + archive, tagged); `todo restore <id>` undoes an auto-prune; injection unchanged (only active injected).
|
|
205
|
+
- Tag `v0.3.1` → CI auto-publish (npm) + auto-create GitHub Release (the v0.3.0-post workflow change).
|
|
206
|
+
- Post-ship: bump README/AGENTS test count + add the Done tab / auto-prune to the docs; mark this spec shipped.
|
|
207
|
+
|
|
208
|
+
## 12. Open question (resolve at implementation)
|
|
209
|
+
|
|
210
|
+
- **`pruneTodos` `detail` flag vs always-rich:** simplest is to make the rich `items` always returned (no flag) and have callers that only need `{moved, ids}` ignore `items`. Decide during implementation — leaning toward always-return for simplicity, since the cost is computing `ageDays` (trivial) per moved todo.
|
|
211
|
+
|
|
212
|
+
## 13. Out of scope (future)
|
|
213
|
+
|
|
214
|
+
- **`cancelled`-in-live panel visibility** — a pre-existing gap; could add a `Cancelled` filter or fold into the Archive tab's live portion in a future workstream.
|
|
215
|
+
- **`config.prune.autoOnSessionStart` toggle** — disable auto-prune (v0.4.x).
|
|
216
|
+
- **Auto-prune notify dedup** — if `/reload` fires multiple times in a session, the notify repeats; acceptable for v0.3.1 (it's a transient inform).
|
|
217
|
+
- **Workstream C (v0.4.0):** preventive caps-on-add (notes length cap + project registry) — still the next major milestone.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Workstream B — `title` + `notes` schema split (v0.3.0)
|
|
2
2
|
|
|
3
3
|
**Date:** 2026-07-21
|
|
4
|
-
**Status:**
|
|
4
|
+
**Status:** Shipped (v0.3.0, PR #4, 2026-07-21)
|
|
5
5
|
**Branch:** `feat/title-notes-split` off `main`
|
|
6
6
|
**Predecessor:** v0.2.0 (Workstream A — lifecycle boxes + prune + health + TUI panel), PR #3, shipped 2026-07-21
|
|
7
7
|
**Supersedes (for this scope):** `2026-07-20-lifecycle-boxes-prune-design.md` §14 (which deferred this exact split to Workstream B)
|
package/extensions/todo.ts
CHANGED
|
@@ -34,10 +34,12 @@ import {
|
|
|
34
34
|
parkTodo,
|
|
35
35
|
getStorePath,
|
|
36
36
|
} from "../src/todo-store";
|
|
37
|
-
import { pruneTodos, restoreTodo, listArchived, archiveSummary } from "../src/archive";
|
|
37
|
+
import { pruneTodos, restoreTodo, listArchived, archiveSummary, listDoneUnified } from "../src/archive";
|
|
38
38
|
import { healthReport } from "../src/health";
|
|
39
39
|
import { hardPrune } from "../src/hard-prune";
|
|
40
40
|
import { TodoPanel } from "../src/panel";
|
|
41
|
+
import { autoPruneOnSessionStart } from "../src/auto-prune";
|
|
42
|
+
import { loadConfig } from "../src/config";
|
|
41
43
|
|
|
42
44
|
const ACTIONS = ["list", "add", "update", "get", "complete", "delete", "clear", "park", "prune", "restore", "health"] as const;
|
|
43
45
|
|
|
@@ -63,19 +65,39 @@ function fmtFull(t: ReturnType<typeof getTodo>): string {
|
|
|
63
65
|
].join("\n");
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
function fmtDone(d: ReturnType<typeof listDoneUnified>[number]): string {
|
|
69
|
+
const tag = d.project ? ` (${d.project})` : "";
|
|
70
|
+
const loc = d.location === "archive" && d.archivedAt
|
|
71
|
+
? ` [archived ${d.archivedAt.slice(0, 10)}]`
|
|
72
|
+
: ` [live ${d.closedAt ? Math.floor((Date.now() - Date.parse(d.closedAt)) / 86400_000) : 0}d]`;
|
|
73
|
+
return `- [${d.id}] (done)${tag} ${d.title}${loc}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
66
76
|
export default function (pi: ExtensionAPI) {
|
|
67
77
|
// Warm + report on session start (every new/resume/fork/reload).
|
|
68
78
|
pi.on("session_start", async (_event, ctx) => {
|
|
69
79
|
try {
|
|
80
|
+
let autoMsg = "";
|
|
81
|
+
let ageDays = 7;
|
|
82
|
+
try {
|
|
83
|
+
ageDays = loadConfig().prune.defaultAgeDays;
|
|
84
|
+
const ap = autoPruneOnSessionStart();
|
|
85
|
+
if (ap) {
|
|
86
|
+
const lines = ap.items.map((i) => ` [${i.id}] ${i.status} ${i.title}`);
|
|
87
|
+
autoMsg = ` · auto-pruned ${ap.moved} stale done (>${ageDays}d):\n${lines.join("\n")}\nUndo any with: todo restore <id>`;
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// auto-prune optional — don't crash the session notify
|
|
91
|
+
}
|
|
70
92
|
const open = listTodos();
|
|
71
|
-
let msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}`;
|
|
93
|
+
let msg = `armory-todo: ${open.length} open TODO${open.length === 1 ? "" : "s"}${autoMsg}`;
|
|
72
94
|
try {
|
|
73
95
|
const report = healthReport();
|
|
74
96
|
if (report.flags.length > 0) {
|
|
75
|
-
msg +=
|
|
97
|
+
msg += `${autoMsg ? "\n" : " — "}` + `⚠ ${report.flags.length} bloat signal${report.flags.length === 1 ? "" : "s"} (run /todo health)`;
|
|
76
98
|
}
|
|
77
99
|
} catch {
|
|
78
|
-
// health check optional
|
|
100
|
+
// health check optional
|
|
79
101
|
}
|
|
80
102
|
if (ctx.hasUI) ctx.ui.notify(msg, "info");
|
|
81
103
|
} catch {
|
|
@@ -116,6 +138,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
116
138
|
"Use todo (action:'complete', id) to mark a TODO done; (action:'delete', id) to cancel it.",
|
|
117
139
|
"Use todo (action:'park', id) to defer a TODO (not injected, recoverable); (action:'update', id, status:'open') to un-park.",
|
|
118
140
|
"Use todo (action:'prune') to move done/cancelled todos to the archive (reversible); (action:'prune', all:true) to prune all regardless of age.",
|
|
141
|
+
"Done/cancelled todos older than the prune age (default 7d) auto-archive on session start — you'll see a notify; reversible via todo restore <id>. Use /todo finished or todo list status:'done' to see all finished work (live + archived).",
|
|
119
142
|
"Use todo (action:'restore', id) to bring an archived TODO back as open.",
|
|
120
143
|
"Use todo (action:'list', archived:true) to query the archive; bare call returns a summary, add a filter (project/text/since) for specific items.",
|
|
121
144
|
"Use todo (action:'health') to check bloat across all boxes (counts + flags + suggestions). Run when the user asks about hygiene/bloat or before any hard-prune.",
|
|
@@ -153,6 +176,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
153
176
|
try {
|
|
154
177
|
switch (params.action) {
|
|
155
178
|
case "list": {
|
|
179
|
+
if (params.status === "done" && !params.archived) {
|
|
180
|
+
const items = listDoneUnified({
|
|
181
|
+
text: params.text,
|
|
182
|
+
project: params.projectFilter,
|
|
183
|
+
since: params.since,
|
|
184
|
+
before: params.before,
|
|
185
|
+
limit: params.limit,
|
|
186
|
+
page: params.page,
|
|
187
|
+
});
|
|
188
|
+
if (items.length === 0) {
|
|
189
|
+
return { content: [{ type: "text" as const, text: "No done TODOs (live or archive)." }] };
|
|
190
|
+
}
|
|
191
|
+
return { content: [{ type: "text" as const, text: `Done (${items.length}):\n${items.map(fmtDone).join("\n")}` }] };
|
|
192
|
+
}
|
|
156
193
|
if (params.archived) {
|
|
157
194
|
const res = listArchived({
|
|
158
195
|
project: params.projectFilter,
|
|
@@ -251,7 +288,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
251
288
|
return { content: [{ type: "text" as const, text: res.message + (res.refused ? "" : ` Deleted: ${res.ids.join(", ") || "(none)"}`) }] };
|
|
252
289
|
}
|
|
253
290
|
const res = pruneTodos({ ageDays: params.ageDays, all: params.all });
|
|
254
|
-
|
|
291
|
+
if (res.moved === 0) {
|
|
292
|
+
return { content: [{ type: "text" as const, text: "Nothing to prune (no stale done/cancelled)." }] };
|
|
293
|
+
}
|
|
294
|
+
const prunedLines = res.items.map((i) => ` [${i.id}] ${i.status} ${i.title} (was ${i.ageDays}d old)`);
|
|
295
|
+
return { content: [{ type: "text" as const, text: `Pruned ${res.moved} todo${res.moved === 1 ? "" : "s"} to archive:\n${prunedLines.join("\n")}\nUndo any with: todo restore <id>` }] };
|
|
255
296
|
}
|
|
256
297
|
case "health": {
|
|
257
298
|
const report = healthReport();
|
|
@@ -290,7 +331,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
290
331
|
"Global cross-session TODO list. " +
|
|
291
332
|
"/todo / /todo all / /todo add <title> / /todo done <id> / /todo rm <id> / " +
|
|
292
333
|
"/todo park <id> / /todo restore <id> / /todo prune [--all|--hard --box <b> --older-than <d>] / " +
|
|
293
|
-
"/todo archive [project:X|text:Y] / /todo health / /todo clean / /todo path",
|
|
334
|
+
"/todo archive [project:X|text:Y] / /todo finished / /todo health / /todo clean / /todo path",
|
|
294
335
|
handler: async (args, ctx) => {
|
|
295
336
|
const a = (args ?? "").trim();
|
|
296
337
|
const [sub, ...rest] = a.split(/\s+/);
|
|
@@ -359,7 +400,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
359
400
|
}
|
|
360
401
|
const all = rest.includes("--all");
|
|
361
402
|
const res = pruneTodos({ all });
|
|
362
|
-
if (ctx.hasUI)
|
|
403
|
+
if (ctx.hasUI) {
|
|
404
|
+
const msg = res.moved === 0
|
|
405
|
+
? "Nothing to prune."
|
|
406
|
+
: `Pruned ${res.moved} to archive:\n${res.items.map((i) => ` [${i.id}] ${i.title} (${i.ageDays}d)`).join("\n")}\nUndo: todo restore <id>`;
|
|
407
|
+
ctx.ui.notify(msg, "info");
|
|
408
|
+
}
|
|
363
409
|
return;
|
|
364
410
|
}
|
|
365
411
|
if (sub === "health") {
|
|
@@ -402,6 +448,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
402
448
|
if (ctx.hasUI) ctx.ui.notify(`Archived (${res.total} total):\n${msg}`, "info");
|
|
403
449
|
return;
|
|
404
450
|
}
|
|
451
|
+
if (sub === "finished") {
|
|
452
|
+
const items = listDoneUnified({ text: rest.join(" ").trim() || undefined, limit: 100 });
|
|
453
|
+
const msg = items.length ? `Done (${items.length}):\n${items.map(fmtDone).join("\n")}` : "(no done TODOs)";
|
|
454
|
+
if (ctx.hasUI) ctx.ui.notify(msg, "info");
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
405
457
|
if (sub === "clean") {
|
|
406
458
|
const n = clearTodos("done");
|
|
407
459
|
if (ctx.hasUI) ctx.ui.notify(`Cleared ${n} done TODOs.`, "info");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-todo",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Global, cross-session TODO for pi — 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",
|
|
@@ -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 panel-data; 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 panel-data; do node test/$t.test.mts || exit 1; done"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"@earendil-works/pi-ai": "*",
|
package/src/archive.ts
CHANGED
|
@@ -79,9 +79,17 @@ export interface PruneInput {
|
|
|
79
79
|
statuses?: ("done" | "cancelled")[];
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
export interface PruneItem {
|
|
83
|
+
id: string;
|
|
84
|
+
status: "done" | "cancelled";
|
|
85
|
+
title: string;
|
|
86
|
+
ageDays: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
82
89
|
export interface PruneResult {
|
|
83
90
|
moved: number;
|
|
84
91
|
ids: string[];
|
|
92
|
+
items: PruneItem[];
|
|
85
93
|
}
|
|
86
94
|
|
|
87
95
|
/**
|
|
@@ -118,14 +126,20 @@ export function pruneTodos(opts: PruneInput = {}): PruneResult {
|
|
|
118
126
|
moved.push(todo);
|
|
119
127
|
}
|
|
120
128
|
|
|
121
|
-
if (moved.length === 0) return { moved: 0, ids: [] };
|
|
129
|
+
if (moved.length === 0) return { moved: 0, ids: [], items: [] };
|
|
122
130
|
|
|
123
131
|
live.todos = kept;
|
|
124
132
|
archive.todos.push(...moved);
|
|
125
133
|
saveStore(live);
|
|
126
134
|
saveArchive(archive);
|
|
127
135
|
|
|
128
|
-
|
|
136
|
+
const items: PruneItem[] = moved.map((t) => ({
|
|
137
|
+
id: t.id,
|
|
138
|
+
status: t.status as "done" | "cancelled",
|
|
139
|
+
title: t.title,
|
|
140
|
+
ageDays: t.closedAt ? Math.floor((Date.now() - Date.parse(t.closedAt)) / 86400_000) : 0,
|
|
141
|
+
}));
|
|
142
|
+
return { moved: moved.length, ids: moved.map((t) => t.id), items };
|
|
129
143
|
}
|
|
130
144
|
|
|
131
145
|
/**
|
|
@@ -211,4 +225,42 @@ export function listArchived(filter: ArchiveListFilter = {}): ArchiveListResult
|
|
|
211
225
|
const page = filter.page ?? 1;
|
|
212
226
|
const start = (page - 1) * limit;
|
|
213
227
|
return { items: sorted.slice(start, start + limit), total };
|
|
214
|
-
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface DoneItem extends Todo {
|
|
231
|
+
location: "live" | "archive";
|
|
232
|
+
archivedAt: string | null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface DoneFilter {
|
|
236
|
+
text?: string; // title OR notes substring (case-insensitive)
|
|
237
|
+
project?: string;
|
|
238
|
+
since?: string; // closedAt >= since
|
|
239
|
+
before?: string; // closedAt < before
|
|
240
|
+
limit?: number; // default 50
|
|
241
|
+
page?: number; // default 1
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Unified done todos across the live store + the archive. Excludes cancelled
|
|
245
|
+
* (Done = finished work). Sorted newest-closed first. */
|
|
246
|
+
export function listDoneUnified(filter: DoneFilter = {}): DoneItem[] {
|
|
247
|
+
const live = loadStore().todos.filter((t) => t.status === "done");
|
|
248
|
+
const arch = loadArchive().todos.filter((t) => t.status === "done");
|
|
249
|
+
const items: DoneItem[] = [
|
|
250
|
+
...live.map((t) => ({ ...t, location: "live" as const, archivedAt: null })),
|
|
251
|
+
...arch.map((t) => ({ ...t, location: "archive" as const, archivedAt: t.closedAt })),
|
|
252
|
+
];
|
|
253
|
+
let out = items;
|
|
254
|
+
if (filter.text) {
|
|
255
|
+
const q = filter.text.toLowerCase();
|
|
256
|
+
out = out.filter((t) => t.title.toLowerCase().includes(q) || t.notes.toLowerCase().includes(q));
|
|
257
|
+
}
|
|
258
|
+
if (filter.project) out = out.filter((t) => t.project === filter.project);
|
|
259
|
+
if (filter.since) out = out.filter((t) => (t.closedAt ?? t.updatedAt) >= (filter.since as string));
|
|
260
|
+
if (filter.before) out = out.filter((t) => (t.closedAt ?? t.updatedAt) < (filter.before as string));
|
|
261
|
+
const sorted = out.slice().sort((a, b) => (b.closedAt ?? b.updatedAt).localeCompare(a.closedAt ?? a.updatedAt));
|
|
262
|
+
const limit = filter.limit ?? 50;
|
|
263
|
+
const page = filter.page ?? 1;
|
|
264
|
+
const start = (page - 1) * limit;
|
|
265
|
+
return sorted.slice(start, start + limit);
|
|
266
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Auto-prune on session_start — the deterministic age-gated prune that runs
|
|
2
|
+
// when the extension loads. Wraps pruneTodos with the config default age; never
|
|
3
|
+
// --all (fresh done <defaultAgeDays stays). Returns the rich PruneResult if
|
|
4
|
+
// anything moved, else null (caller stays silent). Reversible via restore.
|
|
5
|
+
|
|
6
|
+
import { pruneTodos, type PruneResult } from "./archive.ts";
|
|
7
|
+
import { loadConfig } from "./config.ts";
|
|
8
|
+
|
|
9
|
+
/** Prune stale done/cancelled (older than config.prune.defaultAgeDays) on
|
|
10
|
+
* session start. Returns the PruneResult if anything moved, else null. */
|
|
11
|
+
export function autoPruneOnSessionStart(): PruneResult | null {
|
|
12
|
+
const config = loadConfig();
|
|
13
|
+
const res = pruneTodos({ ageDays: config.prune.defaultAgeDays });
|
|
14
|
+
return res.moved > 0 ? res : null;
|
|
15
|
+
}
|
package/src/panel-data.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { SelectItem, SettingItem } from "@earendil-works/pi-tui";
|
|
6
6
|
import type { Todo } from "./todo-store.ts";
|
|
7
|
+
import type { DoneItem } from "./archive.ts";
|
|
7
8
|
import type { ArchiveSummary } from "./archive.ts";
|
|
8
9
|
import type { TodoConfig } from "./config.ts";
|
|
9
10
|
|
|
@@ -60,4 +61,21 @@ export function configToSettingItems(cfg: TodoConfig): SettingItem[] {
|
|
|
60
61
|
{ id: "archiveMax", label: "Archive max", currentValue: String(cfg.health.archiveMax), values: ["100", "200", "500"], description: "Bloat flag when archive exceeds this." },
|
|
61
62
|
{ id: "archiveOldDays", label: "Archive old (days)", currentValue: String(cfg.health.archiveOldDays), values: ["90", "180", "365"], description: "Bloat flag when archive items older than this." },
|
|
62
63
|
];
|
|
63
|
-
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Format a done todo (live or archived) as a SelectList item with a
|
|
67
|
+
* location tag: "[live Nd]" or "[archived YYYY-MM-DD]". */
|
|
68
|
+
export function todoDoneItem(d: DoneItem): SelectItem {
|
|
69
|
+
const proj = d.project ? ` (${d.project})` : "";
|
|
70
|
+
const loc = d.location === "archive" && d.archivedAt
|
|
71
|
+
? ` [archived ${d.archivedAt.slice(0, 10)}]`
|
|
72
|
+
: ` [live ${d.closedAt ? Math.floor((Date.now() - Date.parse(d.closedAt)) / 86400_000) : 0}d]`;
|
|
73
|
+
return { value: d.id, label: `[${d.id}] (done)${proj}${loc} ${d.title}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Actions for a done todo: View detail always; Restore only if archived. */
|
|
77
|
+
export function actionsForDoneTodo(d: DoneItem): { label: string; action: string }[] {
|
|
78
|
+
const acts: { label: string; action: string }[] = [{ label: "View detail", action: "view" }];
|
|
79
|
+
if (d.location === "archive") acts.push({ label: "Restore (from archive)", action: "restore" });
|
|
80
|
+
return acts;
|
|
81
|
+
}
|
package/src/panel.ts
CHANGED
|
@@ -21,13 +21,13 @@ import {
|
|
|
21
21
|
type Theme,
|
|
22
22
|
} from "@earendil-works/pi-tui";
|
|
23
23
|
import { listTodos, parkTodo, completeTodo, deleteTodo, updateTodo, type Todo, type Status } from "./todo-store.ts";
|
|
24
|
-
import { restoreTodo, archiveSummary, listArchived } from "./archive.ts";
|
|
24
|
+
import { restoreTodo, archiveSummary, listArchived, listDoneUnified } from "./archive.ts";
|
|
25
25
|
import { loadConfig, saveConfig, type TodoConfig } from "./config.ts";
|
|
26
26
|
import { healthReport } from "./health.ts";
|
|
27
|
-
import { todoToItem, archiveSummaryToItems, actionsForTodo, configToSettingItems } from "./panel-data.ts";
|
|
27
|
+
import { todoToItem, archiveSummaryToItems, actionsForTodo, configToSettingItems, todoDoneItem, actionsForDoneTodo } from "./panel-data.ts";
|
|
28
28
|
|
|
29
|
-
export type Box = "active" | "parked" | "archive" | "config";
|
|
30
|
-
const BOXES: Box[] = ["active", "parked", "archive", "config"];
|
|
29
|
+
export type Box = "active" | "parked" | "done" | "archive" | "config";
|
|
30
|
+
const BOXES: Box[] = ["active", "parked", "done", "archive", "config"];
|
|
31
31
|
|
|
32
32
|
export interface TodoPanelOpts {
|
|
33
33
|
theme: Theme;
|
|
@@ -142,6 +142,9 @@ export class TodoPanel extends Container {
|
|
|
142
142
|
} else if (this.currentBox === "parked") {
|
|
143
143
|
const todos = listTodos({ status: "parked", text: filter || undefined, limit: 50 });
|
|
144
144
|
this.setSelectItems(todos.map(todoToItem));
|
|
145
|
+
} else if (this.currentBox === "done") {
|
|
146
|
+
const items = listDoneUnified({ text: filter || undefined, limit: 50 });
|
|
147
|
+
this.setSelectItems(items.map(todoDoneItem));
|
|
145
148
|
} else if (this.currentBox === "archive") {
|
|
146
149
|
if (!filter) {
|
|
147
150
|
const s = archiveSummary();
|
|
@@ -189,13 +192,20 @@ export class TodoPanel extends Container {
|
|
|
189
192
|
}
|
|
190
193
|
|
|
191
194
|
private openActionSubmenu(id: string): void {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
this.onNotify("
|
|
196
|
-
|
|
195
|
+
let acts: { label: string; action: string }[];
|
|
196
|
+
if (this.currentBox === "done") {
|
|
197
|
+
const d = listDoneUnified({}).find((x) => x.id === id);
|
|
198
|
+
if (!d) { this.onNotify("Done todo not found.", "info"); return; }
|
|
199
|
+
acts = actionsForDoneTodo(d);
|
|
200
|
+
} else {
|
|
201
|
+
const all = listTodos({ status: "all", limit: 200 });
|
|
202
|
+
const todo = all.find((t) => t.id === id);
|
|
203
|
+
if (!todo) {
|
|
204
|
+
this.onNotify("Todo not found in the live store (archive restore: use the archive box).", "info");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
acts = [{ label: "View detail", action: "view" }, ...actionsForTodo(todo)];
|
|
197
208
|
}
|
|
198
|
-
const acts = [{ label: "View detail", action: "view" }, ...actionsForTodo(todo)];
|
|
199
209
|
const items: SelectItem[] = acts.map((a) => ({ value: a.action, label: a.label }));
|
|
200
210
|
this.actionList = new SelectList(items, 8, {
|
|
201
211
|
selectedPrefix: (s) => this.theme.fg("accent", s),
|