@getpipher/armory-todo 0.2.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.
@@ -0,0 +1,411 @@
1
+ # Workstream B — `title` + `notes` schema split (v0.3.0)
2
+
3
+ **Date:** 2026-07-21
4
+ **Status:** Design approved → pending implementation plan
5
+ **Branch:** `feat/title-notes-split` off `main`
6
+ **Predecessor:** v0.2.0 (Workstream A — lifecycle boxes + prune + health + TUI panel), PR #3, shipped 2026-07-21
7
+ **Supersedes (for this scope):** `2026-07-20-lifecycle-boxes-prune-design.md` §14 (which deferred this exact split to Workstream B)
8
+ **Target ship:** v0.3.0, auto-published via `release.yml` on `v0.3.0` tag
9
+
10
+ ---
11
+
12
+ ## 1. Problem
13
+
14
+ v0.2.0 shipped the lifecycle boxes (active / parked / archive) — that fixed the *count* of injected todos. It did not fix the *per-todo size*. The `Todo` schema has a single `text` field that is a junk-drawer: a todo's title AND its running log crammed into one string. The live ZeroClaw todo (`td-mrt3zp9fcnug3p`) is ~1.8KB and is injected verbatim into every fresh session's system prompt via `renderOpenBlock`. RECTOR flagged "why is there no title field?" during v0.2.0 QA; the v0.2.0 panel's ~80-char truncation is a band-aid treating the symptom.
15
+
16
+ This workstream is the real fix: **split `text` into `title` (short, injected) + `notes` (long, not injected).**
17
+
18
+ ## 2. Goals
19
+
20
+ - A todo's **title** is a ≤120-char one-line summary — the only thing injected into the system prompt and shown in compact lists.
21
+ - A todo's **notes** is an arbitrary-length editable body — the running detail, progress log, pointers. Never auto-injected.
22
+ - The model can **read notes on demand** via a new `get` action (so `list` can become compact without losing retrievability).
23
+ - Backwards-compatible with v0.2.0 stores: v2→v3 migration runs on load, curated for the 2 known todos + a deterministic fallback for any others.
24
+ - No re-bloat: a hard cap on `title` at the write boundary prevents the junk-drawer pattern from re-forming inside `title`.
25
+
26
+ ## 3. Non-goals (deferred)
27
+
28
+ - **Append-only `log` array** (timestamped entries). Considered (option B/C in brainstorm) and rejected as YAGNI — the bloat fix only needs a title/body split, not structured history. Clean v0.4.0 addition on top of `notes` if it earns its place.
29
+ - **Notes caps-on-add + project registry** — Workstream C (issue #1's hard-block half). `notes` is uncapped in B; health reports notes-bytes as a diagnostic only.
30
+ - **In-panel multi-line notes editing** — blocked by the v0.2.0 constraint that `ctx.ui.editor()` from inside `ctx.ui.custom()` causes a nested-UI bug (`/todo` won't reopen). Tracked as a known deferred issue; `notes` is model-managed via the `todo` tool until a safe pattern exists.
31
+
32
+ ## 4. Decisions log (from brainstorm Q&A)
33
+
34
+ | # | Question | Decision |
35
+ |---|---|---|
36
+ | Q1 | Shape of the notes side | **A** — `notes` single editable string. No append-only log. |
37
+ | Q2 | Migration of existing `text`-only todos | Hand-curated for the 2 known ids; first-line fallback for any others. No heuristic sentence-split. |
38
+ | Q3 | Panel handling of notes | **C** — detail view shows title+notes read-only; inline Edit edits title only; notes via `todo` tool; limitation tracked as known deferred issue. |
39
+ | Q4 | `add`/`update` tool surface | **A** — clean break. `add` requires `title` (+optional `notes`); `text` param removed. `update` gains `title`+`notes`, drops `text`. `list.text` searches title+notes. |
40
+ | Q5 | Reading notes | **A** — new `get` action returns one todo's full record. `list` shows title + `•` indicator when notes non-empty. |
41
+ | Q6 | Title cap | **A** — hard reject >120 chars at `add`/`update`. `notes` uncapped in B. |
42
+
43
+ ## 5. Schema (`src/todo-store.ts`)
44
+
45
+ ### 5.1 `Todo` interface
46
+
47
+ ```ts
48
+ export interface Todo {
49
+ id: string;
50
+ title: string; // NEW — ≤120 chars, non-empty, trimmed
51
+ notes: string; // NEW — any length, may be ""
52
+ project: string;
53
+ tags: string[];
54
+ priority: Priority;
55
+ status: Status;
56
+ source: string;
57
+ createdAt: string;
58
+ updatedAt: string;
59
+ closedAt: string | null;
60
+ // `text` REMOVED
61
+ }
62
+ ```
63
+
64
+ ### 5.2 `Store`
65
+
66
+ ```ts
67
+ export interface Store {
68
+ version: 3; // was 2
69
+ updatedAt: string;
70
+ todos: Todo[];
71
+ }
72
+ ```
73
+
74
+ `emptyStore()` returns `version: 3`.
75
+
76
+ ### 5.3 Inputs
77
+
78
+ ```ts
79
+ export interface AddInput {
80
+ title: string; // required (was: text)
81
+ notes?: string; // optional, default ""
82
+ project?: string;
83
+ tags?: string[];
84
+ priority?: Priority;
85
+ source?: string;
86
+ // `text` REMOVED
87
+ }
88
+
89
+ export interface UpdateInput {
90
+ title?: string; // non-empty + ≤120 if present
91
+ notes?: string; // "" clears
92
+ project?: string;
93
+ tags?: string[];
94
+ priority?: Priority;
95
+ status?: Status;
96
+ // `text` REMOVED
97
+ }
98
+ ```
99
+
100
+ `ListFilter.text` stays — its semantic is "search query", not "the text field" (see §7.3).
101
+
102
+ ### 5.4 Title cap constant
103
+
104
+ ```ts
105
+ const TITLE_MAX = 120;
106
+ ```
107
+
108
+ Enforced in `addTodo` and `updateTodo`:
109
+
110
+ ```ts
111
+ function normalizeTitle(raw: string): string {
112
+ const t = raw.trim();
113
+ if (!t) throw new TodoError("title is required");
114
+ if (t.length > TITLE_MAX) {
115
+ throw new TodoError(`title must be ≤${TITLE_MAX} chars (got ${t.length}); move detail into notes`);
116
+ }
117
+ return t;
118
+ }
119
+ ```
120
+
121
+ `notes` is trimmed but **not** length-checked. `addTodo` with no `notes` → `notes: ""`. `updateTodo` with `notes: ""` → clears.
122
+
123
+ ## 6. Migration v2→v3 (`src/migrate.ts`)
124
+
125
+ ### 6.1 When it runs
126
+
127
+ In `loadStore`, after the existing v1→v2 file-move migration (which is guarded to default `TODO_DIR`), if `parsed.version === 2`:
128
+
129
+ ```ts
130
+ if (parsed.version === 2) {
131
+ parsed = migrateV2ToV3(parsed); // in-memory transform
132
+ saveStore(parsed); // persist once → v3 on disk
133
+ }
134
+ ```
135
+
136
+ Subsequent loads see `version: 3` and skip migration.
137
+
138
+ ### 6.2 No env guard (unlike v1→v2)
139
+
140
+ The v1→v2 migration moves the *real* legacy file (`~/.pi/agent/todo.json`) — that's why it's guarded to default `TODO_DIR` (the v0.2.0 incident lesson). The v2→v3 migration only touches the *live* path (`<TODO_DIR>/todo.json`) — which in tests is a temp dir. It never reaches the real legacy file, so it does **not** need the env guard. The v1→v2 guard stays as-is.
141
+
142
+ ### 6.3 Curated map (the 2 known todos)
143
+
144
+ Hardcoded by id. The titles + notes were hand-curated during the 2026-07-21 brainstorm session (reformatted for clarity, not a mechanical split):
145
+
146
+ ```ts
147
+ const CURATED_V2_TO_V3: Record<string, { title: string; notes: string }> = {
148
+ "td-mrt3zp9fcnug3p": {
149
+ title: "ZeroClaw×Solana bounty — Phase 4-5: demo video (score bottleneck, unstarted)",
150
+ notes: `superteam.fun/earn/listing/zeroclaw · Superteam Brasil · 5,000 USDG pool / 1st=1,800 · winner Aug 21 2026 · TARGET #1.
151
+
152
+ PHASE 0-2 DONE ✅. PHASE 3 (RESEARCH+SPEC+PLAN + impl alerts+custody+docs) DONE ✅ — slices A-F+H, 45 tests, committed 8fd7483→80614c8, PUSHED, PR #76 retitled "Palinurus — depin-attest + depin-rewards", 17 commits.
153
+
154
+ claim_tx (G) DEFERRED — Helium hotspots are cNFTs → claim needs distribute_compression_rewards_v0 + DAS get_asset_proof (merkle proof), multi-session; PDAs verified, design in README.
155
+
156
+ Decision (score-max): ship alerts core complete, pivot to DEMO track.
157
+
158
+ NEXT (★ Phase 4-5, the score bottleneck — submission REQUIRES a demo video, currently unstarted):
159
+ (1) ASYNC: RECTOR's free Relay Community key → real Helium fixtures + live smoke test;
160
+ (2) Phase 4: wiring SVG (docs/wiring-diagram.svg, dark-mode, NOT ASCII) + marketing site (palinurus.rectorspace.com, Next.js+Tailwind+shadcn) + demo recording guide;
161
+ (3) Phase 5: record demo ≤3min (real ZeroClaw+Telegram, terminal+phone) → ElevenLabs voiceover → ffmpeg → submit on Superteam Earn + engage #solana-bounty Discord.
162
+
163
+ Test totals: 184 (71 palinurus-core + 68 depin-attest + 45 depin-rewards), all clippy+wasm clean.
164
+ HANDOFF: ~/Documents/secret/strategy/zeroclaw-solana/session-handoff-2026-07-21.md
165
+ Docs: {RESEARCH-3,SPEC-3,PLAN-3}-depin-rewards.md (SPEC-3 §4 + PLAN-3 G corrected for cNFT)
166
+ Cwd: ~/local-dev/RECTOR-LABS/zeroclaw-plugins/plugins/depin-rewards
167
+ PR: https://github.com/zeroclaw-labs/zeroclaw-plugins/pull/76`,
168
+ },
169
+ "td-mrt4e1qi9td6jz": {
170
+ title: "armory-todo v0.2.0 — Workstream A shipped (lifecycle boxes + prune + health + TUI)",
171
+ notes: `ALL 3 SPECS DONE ✅. SPEC-1 (store: parked+prune+archive+restore, 12 tasks), SPEC-2 (health+hard-prune, 6 tasks), SPEC-3 (interactive /todo TUI panel, 4 tasks). 147/147 tests across 7 suites. 24 commits on feat/spec-1-lifecycle-boxes, PR #3 retitled to full v0.2.0 scope. Auto-publish CI (release.yml, org NPM_TOKEN).
172
+
173
+ INCIDENT (SPEC-1 Task 9): migration bug destroyed real 52KB/47-todo store (35 done + ~10 open lost, no backup). FIXED (c034509): migration guarded to only run when TODO_DIR is default. RECOVERED: 2 todos.
174
+
175
+ Shipped: merge PR #3 → tag v0.2.0 → CI auto-publish → npm:@getpipher/armory-todo@0.2.0.
176
+ Out of scope: B (title+notes split), C (preventive caps+project registry).`,
177
+ },
178
+ };
179
+ ```
180
+
181
+ ### 6.4 Fallback (any other v2 todo)
182
+
183
+ For a v2 todo whose `id` is not in `CURATED_V2_TO_V3`:
184
+
185
+ 1. Split `text` on the first `\n` → `firstLine`, `rest`.
186
+ 2. If `firstLine.length ≤ TITLE_MAX`: `title = firstLine.trim()`, `notes = rest.trim()`.
187
+ 3. If `firstLine.length > TITLE_MAX`: truncate at the last word boundary ≤ `TITLE_MAX` (fall back to hard cut at `TITLE_MAX` if no boundary). `title` = the truncated form (no `…` suffix — the cap is a hard rule, not a display truncation). `notes` = the **full original `firstLine`** + `\n` + `rest` (nothing lost).
188
+ 4. If `text` is a single line (no `\n`): `title = text.trim()` (capped as in step 2/3), `notes = ""` — unless step 3 truncation applied, in which case `notes` = the full original `text`.
189
+ 5. If `text` is empty/whitespace (shouldn't happen — v2 `addTodo` rejected empty text, but defensively): `title = "(untitled)"`, `notes = ""`.
190
+
191
+ The fallback is deterministic and idempotent — running it twice on the same v2 todo yields the same v3 todo.
192
+
193
+ ### 6.5 `migrateV2ToV3` signature
194
+
195
+ ```ts
196
+ export function migrateV2ToV3(store: { version: 2; updatedAt: string; todos: V2Todo[] }): Store {
197
+ const todos = store.todos.map((t) => {
198
+ const curated = CURATED_V2_TO_V3[t.id];
199
+ if (curated) return { ...t, title: curated.title, notes: curated.notes, text: undefined };
200
+ const { title, notes } = splitTextFallback(t.text);
201
+ return { ...t, title, notes, text: undefined };
202
+ });
203
+ return { version: 3, updatedAt: store.updatedAt, todos } as Store;
204
+ }
205
+ ```
206
+
207
+ `V2Todo` is the v2 shape (with `text`, without `title`/`notes`). The `text: undefined` is stripped by JSON.stringify on save (so v3 files on disk have no `text` key).
208
+
209
+ ## 7. Tool surface (`extensions/todo.ts`)
210
+
211
+ ### 7.1 `ACTIONS`
212
+
213
+ ```ts
214
+ const ACTIONS = ["list", "add", "update", "get", "complete", "delete", "clear", "park", "prune", "restore", "health"] as const;
215
+ ```
216
+
217
+ `get` added.
218
+
219
+ ### 7.2 Input schema (typebox)
220
+
221
+ - `title: Type.Optional(Type.String({ description: "Todo title (add required; update optional). ≤120 chars." }))`
222
+ - `notes: Type.Optional(Type.String({ description: "Todo notes/body (add/update optional; long-form, not injected). Pass \"\" on update to clear." }))`
223
+ - `text`: kept, but description rewritten to `Type.Optional(Type.String({ description: "Search query (list only) — substring match on title OR notes. Not used by add/update." }))`
224
+ - `id`, `project`, `tags`, `priority`, `status`, `archived`, `since`, `before`, `limit`, `page`, `ageDays`, `all`, `hard`, `confirm`, `box`, `olderThan` — unchanged.
225
+
226
+ ### 7.3 Action behaviors
227
+
228
+ **`add`**: require `title` (error if missing/empty). `notes` optional. Calls `addTodo({ title, notes, project, tags, priority, source })`. Output: `Added ${id}: ${title}`.
229
+
230
+ **`update`**: require `id`. Patch `{ title, notes, project, tags, priority, status }` (only fields present). `title`/`notes` flow through `updateTodo`. Output: `Updated ${id}: ${title} [${status}]`.
231
+
232
+ **`get`** (NEW): require `id`. Calls a new exported `getTodo(id)` store function (added to `src/todo-store.ts` — loads the store, finds by id, throws `TodoError` if missing, returns the `Todo`). The extension formats it as a full record:
233
+ ```
234
+ ${id} [${priority}/${status}] ${title}
235
+ project: ${project || "(none)"}
236
+ tags: ${tags.join(", ") || "(none)"}
237
+ created: ${createdAt}
238
+ updated: ${updatedAt}
239
+ closed: ${closedAt || "(open)"}
240
+ source: ${source || "(none)"}
241
+
242
+ notes:
243
+ ${notes || "(empty)"}
244
+ ```
245
+ `TodoError` if id not found → extension returns the error message (existing catch).
246
+
247
+ **`list`**: `text` filter now matches `title` OR `notes` (case-insensitive substring). Output fmt:
248
+ ```
249
+ - [${id}] (${priority}/${status})${notesDot} ${title}${tag}${pins}
250
+ ```
251
+ where `notesDot = notes.trim() ? " •" : ""`, placed immediately after the priority/status parenthetical so the title stays clean and the indicator is discoverable. `tag = project ? " (${project})" : ""`, `pins` unchanged (⏵ for in_progress, etc.).
252
+
253
+ **`complete`/`delete`/`park`/`clear`/`prune`/`restore`**: output lines show `title` instead of `text`. e.g. `Completed ${id}: ${title}`, `Parked ${id}: ${title}`, `Restored ${id}: ${title} [open]`.
254
+
255
+ ### 7.4 Prompt guidelines (rewritten)
256
+
257
+ ```
258
+ Use todo (action:'add', title, notes?, project?, tags?, priority?, source?) when the user says 'put this in our TODO'. title is ≤120 chars (one-line summary); put long detail in notes.
259
+ Use todo (action:'get', id) to read a todo's full notes before acting on it (the • marker in lists means notes exist).
260
+ Use todo (action:'update', id, title?, notes?, …) to edit; notes="" clears.
261
+ Use todo (action:'list', text?) to scan titles (text searches title+notes); add archived:true to query the archive.
262
+ Use todo (action:'park', id) to defer (not injected); (action:'update', id, status:'open') to un-park.
263
+ Use todo (action:'prune') to move done/cancelled to archive (reversible via restore); prune --hard (confirm:true) is the only irreversible action.
264
+ Never put secrets in a TODO — the text reaches the model provider.
265
+ ```
266
+
267
+ ### 7.5 Slash command (`/todo`)
268
+
269
+ `/todo add <title>` — was `/todo add <text>`. The slash parser passes the remainder as `title`. `/todo add <title> | <notes>`? **No** — keep slash add simple (title only); notes added via the tool or panel. (The slash command is for quick human adds; notes are model-managed.) Document this.
270
+
271
+ Other slash subcommands unchanged (just show `title` in output).
272
+
273
+ ## 8. Auto-injection (`renderOpenBlock`)
274
+
275
+ ```ts
276
+ const lines = shown.map((t) => {
277
+ const tag = t.project ? ` (${t.project})` : "";
278
+ const pin = t.status === "in_progress" ? " ⏵" : "";
279
+ const dot = t.notes.trim() ? " •" : "";
280
+ return `- [${t.id}] (${t.priority})${pin}${dot} ${t.title}${tag}`;
281
+ });
282
+ ```
283
+
284
+ **`title` only — never `notes`.** The `•` marker tells the model "this todo has notes; `get` it before acting." The 1.8KB ZeroClaw blob → one line. This is the bloat fix.
285
+
286
+ ## 9. Panel (`src/panel.ts` + `src/panel-data.ts`)
287
+
288
+ ### 9.1 List rows
289
+
290
+ `todoToItem` returns a `SelectListItem` whose `label` is `title` + `•` (when notes non-empty) + project tag + status pin. The v0.2.0 ~80-char truncation hack (`truncateForList`) is **deleted** — `title` is already ≤120 chars by the cap.
291
+
292
+ ### 9.2 Detail view
293
+
294
+ Selecting a todo (Enter on a list row) opens a detail view inside the panel:
295
+ - Bordered, scrollable region rendering:
296
+ ```
297
+ ${title}
298
+ (${priority}/${status}) · ${project || "no project"} · ${tags.join(" ")}
299
+
300
+ notes:
301
+ ${notes}
302
+ ```
303
+ - Footer hint: `notes: read-only · todo update <id> notes=… to edit`
304
+ - Back action returns to the list.
305
+
306
+ ### 9.3 Inline Edit
307
+
308
+ The Edit action (from the action submenu or a detail-view key) edits **`title` only** via a single-line `Input` (same constraint as v0.2.0 — `ctx.ui.editor()` from inside `ctx.ui.custom()` is the known nested-UI bug). Saving writes via `updateTodo(id, { title })`.
309
+
310
+ ### 9.4 Known deferred issue (carried from v0.2.0)
311
+
312
+ > No in-panel multi-line `notes` editing. `notes` is model-managed via the `todo` tool (`action:'update', id, notes`). When a safe `ctx.ui.editor()`-from-`custom()` pattern lands in pi-tui, notes panel-editing is a clean follow-up.
313
+
314
+ Tracked in README "Known issues" + AGENTS.md.
315
+
316
+ ## 10. Health (`src/health.ts`)
317
+
318
+ The bloat report gains a **notes-bytes diagnostic** (read-only — no caps enforced in B):
319
+
320
+ ```
321
+ notes bytes: total=N max=M avg=A (active+parked)
322
+ ```
323
+
324
+ Computed across active + parked todos. Lets you see notes bloat growing (the latent bloat that *isn't* injected). Existing count-based heuristics unchanged. Workstream C will add caps-on-add.
325
+
326
+ ## 11. Archive (`src/archive.ts`)
327
+
328
+ Archived todos carry `title` + `notes` (same `Todo` shape). `archiveSummary` (counts by project/month) is unaffected. `listArchived` fmt shows `title` + `•` (same as `list`). `restoreTodo` returns the todo — output shows `title`. No archive-specific migration: the v2→v3 migration in §6 runs on the *live* store on load; the archive file is v3-only going forward. **Edge case:** if a v2 `todo-archive.json` exists (it doesn't today — the incident wiped the store and only 2 todos were recovered, none archived), `loadArchive` must also migrate v2→v3 on first load using the same `migrateV2ToV3` (curated map + fallback). Add this guard symmetrically.
329
+
330
+ ## 12. Tests
331
+
332
+ Baseline: 151 across 7 suites. Target: ~175+.
333
+
334
+ ### 12.1 New suite `test/todo-title-notes.test.mts`
335
+
336
+ - `addTodo` with `title` only (notes defaults to `""`)
337
+ - `addTodo` with `title` + `notes`
338
+ - `addTodo` rejects empty `title`
339
+ - `addTodo` rejects `title` >120 chars (exact boundary: 120 ok, 121 reject)
340
+ - `addTodo` trims `title` before length check (trailing whitespace doesn't count)
341
+ - `updateTodo` with `title` (non-empty, ≤120)
342
+ - `updateTodo` with `notes` (set, clear with `""`)
343
+ - `updateTodo` rejects `title` >120
344
+ - `getTodo` returns full record (found)
345
+ - `getTodo` missing id → `TodoError`
346
+ - `listTodos` `text` filter matches `title` OR `notes`
347
+ - `listTodos` output includes `•` when notes non-empty, omits when empty
348
+ - `renderOpenBlock` renders `title` only (never `notes`), includes `•`
349
+
350
+ ### 12.2 Extend `test/todo-migrate.test.mts`
351
+
352
+ - v2→v3 curated: the 2 known ids get the hand-written title+notes (exact match)
353
+ - v2→v3 fallback: single-line text → title=whole, notes=""
354
+ - v2→v3 fallback: multi-line text → title=first line, notes=rest
355
+ - v2→v3 fallback: first line >120 → title=truncated at word boundary ≤120, notes=full original first line + rest
356
+ - v2→v3 fallback: first line >120 with no word boundary → title=hard cut at 120, notes=full original
357
+ - v2→v3 idempotent (running on a v3 store is a no-op / `loadStore` skips)
358
+ - v2→v3 persists to disk on first load (second load sees version 3)
359
+ - v2→v3 does **not** require the default-`TODO_DIR` guard (runs in temp-dir test fixtures) — explicit test that it works with `TODO_DIR` set
360
+
361
+ ### 12.3 Extend `test/todo-store.test.mts`
362
+
363
+ - Update existing add/update/list tests that referenced `text` → use `title`/`notes`
364
+ - Add title-cap error cases (mirror 12.1 where they overlap — keep the boundary tests in the dedicated suite, reference cases in store tests)
365
+
366
+ ### 12.4 Extend `test/panel-data.test.mts`
367
+
368
+ - `todoToItem` label uses `title` (no truncation)
369
+ - `todoToItem` label includes `•` when notes non-empty
370
+ - `todoToItem` label omits `•` when notes empty
371
+
372
+ ### 12.5 Extend `test/todo-archive.test.mts`
373
+
374
+ - `listArchived` fmt shows `title` + `•`
375
+ - v2 archive file → v3 migration on load (curated + fallback) — symmetric with live store
376
+ - `restoreTodo` output shows `title`
377
+
378
+ ### 12.6 Extend `test/todo-health.test.mts`
379
+
380
+ - Report includes `notes bytes: total/max/avg` line
381
+ - Values computed across active+parked only (archived excluded)
382
+
383
+ ### 12.7 Existing suites
384
+
385
+ `todo-config.test.mts`, `todo-hard-prune.test.mts` — no title/notes-specific changes, but any test fixtures using v2 `text`-only todos must be updated to v3 shape (`title`+`notes`). Audit + fix.
386
+
387
+ ## 13. Branch + ship
388
+
389
+ - Branch `feat/title-notes-split` off `main`.
390
+ - Commits: `feat(store): ...`, `feat(migrate): ...`, `feat(ext): ...`, `feat(panel): ...`, `feat(health): ...`, `test: ...`, `docs: ...` — one logical change per commit.
391
+ - PR to `main`, `--merge --delete-branch`. No GitLab mirror (getpipher).
392
+ - **QA gate:** RECTOR tests in a real pi session before merge — local install (`pi install npm:@getpipher/armory-todo` or local path) → restart pi → verify:
393
+ 1. v2→v3 migration runs on first load (the 2 known todos get curated titles/notes).
394
+ 2. `## Open TODOs` injection shows title only (+ `•`), not the 1.8KB blob.
395
+ 3. `/todo` panel list shows titles + `•`; detail view shows notes; Edit edits title.
396
+ 4. `todo add` with `title`+`notes` works; `todo get <id>` returns notes; `todo update <id> notes=…` edits notes.
397
+ 5. Title >120 rejects with actionable error.
398
+ - Tag `v0.3.0` → CI auto-publish (`release.yml`, org `NPM_TOKEN`, no OTP).
399
+ - Post-ship: update README (test count, schema section, known issues), AGENTS.md (modules list + known deferred issues), this spec → status "shipped".
400
+
401
+ ## 14. Incident lesson (carried forward from v0.2.0)
402
+
403
+ The v0.2.0 SPEC-1 Task 9 incident: `migrateIfNeeded` ran in test mode (`TODO_DIR` = temp dir), moved the real `~/.pi/agent/todo.json` (52KB, 47 todos) into the temp dir, test cleanup deleted it. Fix: v1→v2 file-move migration is guarded to only run when `TODO_DIR` is the default.
404
+
405
+ **For Workstream B:** the v2→v3 schema migration (§6) does **not** need this guard — it only touches the live path (`<TODO_DIR>/todo.json`), never the real legacy file. But the lesson stands: any future migration that *moves real user data between paths* must be guarded by environment, not test conventions. The v2→v3 transform is in-memory + persist-to-same-path — safe under `TODO_DIR` override.
406
+
407
+ ## 15. Out of scope (future workstreams)
408
+
409
+ - **Workstream C (v0.4.0):** preventive caps-on-add (`notes` length cap, project registry, self-awareness caps) — issue #1's hard-block half.
410
+ - **Append-only `log` (v0.4.0+):** structured timestamped entries on top of `notes`, if it earns its place.
411
+ - **In-panel multi-line notes editor:** blocked on a safe `ctx.ui.editor()`-from-`custom()` pattern in pi-tui.
package/docs/todo-SPEC.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # SPEC — `armory-todo` (global, cross-session TODO for pi)
2
2
 
3
+ > **⚠ SUPERSEDED (v0.2.0):** This is the original v0.1.0 design spec. For the v0.2.0 lifecycle-boxes
4
+ > design (active/parked/archive, prune, health, hard-prune, interactive TUI panel), see
5
+ > [`docs/superpowers/specs/2026-07-20-lifecycle-boxes-prune-design.md`](superpowers/specs/2026-07-20-lifecycle-boxes-prune-design.md).
6
+ > This doc is kept for historical context (the v0.1.0 architecture + decisions).
7
+
3
8
  **Repo:** `getpipher/armory` (`~/local-dev/armory`) · **Extension file:** `extensions/todo.ts`
4
9
  **Status:** Draft (2026-06-23) — pending RECTOR sign-off on the flagged Decisions.
5
10
  **Related:** closes the "cross-session TODO" pain recorded in `~/Documents/secret/claude-strategy/pi/session-handoff-2026-06-23.md` (Next steps #7 is the canonical TODO this spec exists to never lose again).
@@ -27,6 +27,7 @@ import {
27
27
  completeTodo,
28
28
  deleteTodo,
29
29
  clearTodos,
30
+ getTodo,
30
31
  listTodos,
31
32
  renderOpenBlock,
32
33
  updateTodo,
@@ -38,12 +39,28 @@ import { healthReport } from "../src/health";
38
39
  import { hardPrune } from "../src/hard-prune";
39
40
  import { TodoPanel } from "../src/panel";
40
41
 
41
- const ACTIONS = ["list", "add", "update", "complete", "delete", "clear", "park", "prune", "restore", "health"] as const;
42
+ const ACTIONS = ["list", "add", "update", "get", "complete", "delete", "clear", "park", "prune", "restore", "health"] as const;
42
43
 
43
44
  function fmt(t: ReturnType<typeof listTodos>[number]): string {
44
45
  const tag = t.project ? ` (${t.project})` : "";
45
46
  const pins = t.tags.length ? ` #${t.tags.join(" #")}` : "";
46
- return `- [${t.id}] (${t.priority}/${t.status}) ${t.text}${tag}${pins}`;
47
+ const dot = t.notes.trim() ? " •" : "";
48
+ return `- [${t.id}] (${t.priority}/${t.status})${dot} ${t.title}${tag}${pins}`;
49
+ }
50
+
51
+ function fmtFull(t: ReturnType<typeof getTodo>): string {
52
+ const tag = t.project ? ` (${t.project})` : "";
53
+ const tags = t.tags.length ? ` #${t.tags.join(" #")}` : "";
54
+ return [
55
+ `${t.id} [${t.priority}/${t.status}] ${t.title}${tag}${tags}`,
56
+ `created: ${t.createdAt}`,
57
+ `updated: ${t.updatedAt}`,
58
+ `closed: ${t.closedAt ?? "(open)"}`,
59
+ `source: ${t.source || "(none)"}`,
60
+ "",
61
+ "notes:",
62
+ t.notes || "(empty)",
63
+ ].join("\n");
47
64
  }
48
65
 
49
66
  export default function (pi: ExtensionAPI) {
@@ -92,20 +109,24 @@ export default function (pi: ExtensionAPI) {
92
109
  "Never put secrets in a TODO — the text reaches the model provider.",
93
110
  promptSnippet: "Read/update the global cross-session TODO list (active / parked / archive) + bloat health",
94
111
  promptGuidelines: [
95
- "Use todo (action:'list') when the user asks 'show me the TODO' / 'what's pending'.",
96
- "Use todo (action:'add', text, project?, tags?, priority?, source?) when the user says 'put this in our TODO'.",
112
+ "Use todo (action:'add', title, notes?, project?, tags?, priority?, source?) when the user says 'put this in our TODO'. title max 120 chars (one-line summary); put long detail in notes.",
113
+ "Use todo (action:'get', id) to read a todo's full notes before acting on it (the bullet marker in lists means notes exist).",
114
+ "Use todo (action:'update', id, title?, notes?, project?, tags?, priority?, status?) to edit; notes empty string clears.",
115
+ "Use todo (action:'list') when the user asks 'show me the TODO' / 'what's pending' (text filter searches title+notes).",
97
116
  "Use todo (action:'complete', id) to mark a TODO done; (action:'delete', id) to cancel it.",
98
117
  "Use todo (action:'park', id) to defer a TODO (not injected, recoverable); (action:'update', id, status:'open') to un-park.",
99
118
  "Use todo (action:'prune') to move done/cancelled todos to the archive (reversible); (action:'prune', all:true) to prune all regardless of age.",
100
119
  "Use todo (action:'restore', id) to bring an archived TODO back as open.",
101
- "Use todo (action:'list', archived:true) to query the archive bare call returns a summary; add a filter (project/text/since) for specific items.",
102
- "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.",
103
- "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.",
120
+ "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
+ "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.",
122
+ "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.",
104
123
  ],
105
124
  parameters: Type.Object({
106
125
  action: StringEnum(ACTIONS),
107
- id: Type.Optional(Type.String({ description: "Todo id (for update/complete/delete/park/restore)" })),
108
- text: Type.Optional(Type.String({ description: "Todo text (add) or new text (update); or substring search (list)" })),
126
+ id: Type.Optional(Type.String({ description: "Todo id (for update/complete/delete/park/restore/get)" })),
127
+ title: Type.Optional(Type.String({ description: "Todo title (add required; update optional). Max 120 chars; put detail in notes." })),
128
+ notes: Type.Optional(Type.String({ description: "Todo notes/body (add/update optional; long-form, not injected). Pass empty string on update to clear." })),
129
+ text: Type.Optional(Type.String({ description: "Search query (list only). Substring match on title OR notes. Not used by add/update." })),
109
130
  project: Type.Optional(Type.String({ description: "Project tag, e.g. 'pi', 'sip', or '' for global" })),
110
131
  tags: Type.Optional(Type.Array(Type.String())),
111
132
  priority: Type.Optional(StringEnum(["low", "med", "high", "critical"] as const)),
@@ -173,43 +194,50 @@ export default function (pi: ExtensionAPI) {
173
194
  return { content: [{ type: "text" as const, text: todos.map(fmt).join("\n") }] };
174
195
  }
175
196
  case "add": {
176
- if (!params.text) {
177
- return { content: [{ type: "text" as const, text: "Error: `text` is required for add." }] };
197
+ if (!params.title) {
198
+ return { content: [{ type: "text" as const, text: "Error: `title` is required for add." }] };
178
199
  }
179
200
  const t = addTodo({
180
- text: params.text,
201
+ title: params.title,
202
+ notes: params.notes,
181
203
  project: params.project,
182
204
  tags: params.tags,
183
205
  priority: params.priority as any,
184
206
  source: params.source as any,
185
207
  });
186
- return { content: [{ type: "text" as const, text: `Added ${t.id}: ${t.text}` }] };
208
+ return { content: [{ type: "text" as const, text: `Added ${t.id}: ${t.title}` }] };
187
209
  }
188
210
  case "update": {
189
211
  if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for update." }] };
190
212
  const t = updateTodo(params.id, {
191
- text: params.text,
213
+ title: params.title,
214
+ notes: params.notes,
192
215
  project: params.project,
193
216
  tags: params.tags,
194
217
  priority: params.priority as any,
195
218
  status: params.status as any,
196
219
  });
197
- return { content: [{ type: "text" as const, text: `Updated ${t.id}: ${t.text} [${t.status}]` }] };
220
+ return { content: [{ type: "text" as const, text: `Updated ${t.id}: ${t.title} [${t.status}]` }] };
221
+ }
222
+ case "get": {
223
+ if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for get." }] };
224
+ const t = getTodo(params.id);
225
+ return { content: [{ type: "text" as const, text: fmtFull(t) }] };
198
226
  }
199
227
  case "complete": {
200
228
  if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for complete." }] };
201
229
  const t = completeTodo(params.id);
202
- return { content: [{ type: "text" as const, text: `Completed ${t.id}: ${t.text}` }] };
230
+ return { content: [{ type: "text" as const, text: `Completed ${t.id}: ${t.title}` }] };
203
231
  }
204
232
  case "delete": {
205
233
  if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for delete." }] };
206
234
  const t = deleteTodo(params.id);
207
- return { content: [{ type: "text" as const, text: `Cancelled ${t.id}: ${t.text}` }] };
235
+ return { content: [{ type: "text" as const, text: `Cancelled ${t.id}: ${t.title}` }] };
208
236
  }
209
237
  case "park": {
210
238
  if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for park." }] };
211
239
  const t = parkTodo(params.id);
212
- return { content: [{ type: "text" as const, text: `Parked ${t.id}: ${t.text}` }] };
240
+ return { content: [{ type: "text" as const, text: `Parked ${t.id}: ${t.title}` }] };
213
241
  }
214
242
  case "prune": {
215
243
  if (params.hard) {
@@ -232,6 +260,7 @@ export default function (pi: ExtensionAPI) {
232
260
  `active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
233
261
  `parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
234
262
  `archive: ${report.archive.count} (${report.archive.older_180d} old)`,
263
+ `notes: ${report.notesBytes.total}B total · max ${report.notesBytes.max}B · avg ${report.notesBytes.avg}B`,
235
264
  report.flags.length ? `flags: ${report.flags.join(", ")}` : "flags: (none — healthy)",
236
265
  ...report.suggestions.map((s) => ` → ${s}`),
237
266
  ];
@@ -240,7 +269,7 @@ export default function (pi: ExtensionAPI) {
240
269
  case "restore": {
241
270
  if (!params.id) return { content: [{ type: "text" as const, text: "Error: `id` is required for restore." }] };
242
271
  const t = restoreTodo(params.id);
243
- return { content: [{ type: "text" as const, text: `Restored ${t.id}: ${t.text} [open]` }] };
272
+ return { content: [{ type: "text" as const, text: `Restored ${t.id}: ${t.title} [open]` }] };
244
273
  }
245
274
  case "clear": {
246
275
  const n = clearTodos((params.status as any) ?? "done");
@@ -259,9 +288,9 @@ export default function (pi: ExtensionAPI) {
259
288
  pi.registerCommand("todo", {
260
289
  description:
261
290
  "Global cross-session TODO list. " +
262
- "/todo · /todo all · /todo add <text> · /todo done <id> · /todo rm <id> · " +
263
- "/todo park <id> · /todo restore <id> · /todo prune [--all|--hard --box <b> --older-than <d>] · " +
264
- "/todo archive [project:X|text:Y] · /todo health · /todo clean · /todo path",
291
+ "/todo / /todo all / /todo add <title> / /todo done <id> / /todo rm <id> / " +
292
+ "/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",
265
294
  handler: async (args, ctx) => {
266
295
  const a = (args ?? "").trim();
267
296
  const [sub, ...rest] = a.split(/\s+/);
@@ -273,10 +302,10 @@ export default function (pi: ExtensionAPI) {
273
302
  return;
274
303
  }
275
304
  if (sub === "add") {
276
- const text = rest.join(" ").trim();
277
- if (!text) { if (ctx.hasUI) ctx.ui.notify("usage: /todo add <text>", "warning"); return; }
278
- const t = addTodo({ text, source: "slash" });
279
- if (ctx.hasUI) ctx.ui.notify(`Added ${t.id}: ${t.text}`, "info");
305
+ const title = rest.join(" ").trim();
306
+ if (!title) { if (ctx.hasUI) ctx.ui.notify("usage: /todo add <title> (notes via the todo tool)", "warning"); return; }
307
+ const t = addTodo({ title, source: "slash" });
308
+ if (ctx.hasUI) ctx.ui.notify(`Added ${t.id}: ${t.title}`, "info");
280
309
  return;
281
310
  }
282
311
  if (sub === "done") {
@@ -297,14 +326,14 @@ export default function (pi: ExtensionAPI) {
297
326
  const id = rest[0];
298
327
  if (!id) { if (ctx.hasUI) ctx.ui.notify("usage: /todo park <id>", "warning"); return; }
299
328
  const t = parkTodo(id);
300
- if (ctx.hasUI) ctx.ui.notify(`Parked ${t.id}: ${t.text}`, "info");
329
+ if (ctx.hasUI) ctx.ui.notify(`Parked ${t.id}: ${t.title}`, "info");
301
330
  return;
302
331
  }
303
332
  if (sub === "restore") {
304
333
  const id = rest[0];
305
334
  if (!id) { if (ctx.hasUI) ctx.ui.notify("usage: /todo restore <id>", "warning"); return; }
306
335
  const t = restoreTodo(id);
307
- if (ctx.hasUI) ctx.ui.notify(`Restored ${t.id}: ${t.text}`, "info");
336
+ if (ctx.hasUI) ctx.ui.notify(`Restored ${t.id}: ${t.title}`, "info");
308
337
  return;
309
338
  }
310
339
  if (sub === "prune") {
@@ -340,6 +369,7 @@ export default function (pi: ExtensionAPI) {
340
369
  ` active: ${report.active.open} open + ${report.active.in_progress} in_progress (${report.active.stale_30d} stale)`,
341
370
  ` parked: ${report.parked.count} (${report.parked.stale_60d} stale)`,
342
371
  ` archive: ${report.archive.count} (${report.archive.older_180d} old)`,
372
+ ` notes: ${report.notesBytes.total}B total · max ${report.notesBytes.max}B · avg ${report.notesBytes.avg}B`,
343
373
  report.flags.length ? ` ⚠ ${report.flags.join(", ")}` : " ✅ healthy",
344
374
  ...report.suggestions.map((s) => ` → ${s}`),
345
375
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-todo",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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-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 panel-data; do node test/$t.test.mts || exit 1; done"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@earendil-works/pi-ai": "*",