@getpipher/armory-todo 0.3.1 → 0.5.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 +33 -8
- package/assets/hero.svg +4 -4
- package/docs/superpowers/plans/2026-07-21-caps-release.md +1113 -0
- package/docs/superpowers/plans/2026-07-21-project-scope-management.md +1537 -0
- package/docs/superpowers/specs/2026-07-21-auto-prune-done-view-design.md +1 -1
- package/docs/superpowers/specs/2026-07-21-caps-release-design.md +222 -0
- package/docs/superpowers/specs/2026-07-21-project-scope-management-design.md +319 -0
- package/extensions/todo.ts +60 -3
- package/package.json +2 -2
- package/src/caps.ts +71 -0
- package/src/config.ts +10 -1
- package/src/health.ts +81 -7
- package/src/levenshtein.ts +22 -0
- package/src/panel-data.ts +32 -0
- package/src/panel.ts +100 -6
- package/src/paths.ts +5 -0
- package/src/projects.ts +91 -0
- package/src/registry.ts +175 -0
- package/src/todo-store.ts +73 -12
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Workstream v0.3.1 — auto-prune on session_start + unified `Done` view
|
|
2
2
|
|
|
3
3
|
**Date:** 2026-07-21
|
|
4
|
-
**Status:**
|
|
4
|
+
**Status:** Shipped (v0.3.1, PR #5, 2026-07-21)
|
|
5
5
|
**Branch:** `feat/auto-prune-done-view` off `main`
|
|
6
6
|
**Predecessor:** v0.3.0 (title + notes split), shipped 2026-07-21 (PR #4)
|
|
7
7
|
**Target ship:** v0.3.1, auto-published via `release.yml` on `v0.3.1` tag (now also creates a GitHub Release)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# v0.5.0 — Caps Release (Feature B, enforcement)
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-07-21
|
|
4
|
+
**Status:** Approved (brainstorm 2026-07-21, all six decisions = A)
|
|
5
|
+
**Issue:** #1 — Project-scope management + self-awareness caps to prevent TODO bloat (Feature B, the forcing-function half)
|
|
6
|
+
**Predecessor:** v0.4.0 (project registry + `projects` overview + per-project `health` flags + advisory `maxOpen` slot + rename/merge). Published `@getpipher/armory-todo@0.4.0`.
|
|
7
|
+
**Branch:** `feat/caps-release` off `main`
|
|
8
|
+
**Semver:** minor (v0.5.0) — a semantic behavior change (advisory `maxOpen` → enforced) plus new enforcement.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 1. Goal
|
|
13
|
+
|
|
14
|
+
Graduate the v0.4.0 advisory `maxOpen` slot into **enforcement** and add two more caps, so the TODO store (and its auto-injected prompt block) cannot bloat silently. One coherent "caps release," all enforcement:
|
|
15
|
+
|
|
16
|
+
1. **Count cap** — per-project `maxOpen` → **block-on-add** (and block on project-move into a capped project).
|
|
17
|
+
2. **Notes cap** — global `maxNotesBytes` → **reject oversize notes at write time** (mirrors the title cap).
|
|
18
|
+
3. **Over-cap injection truncation** — when actionable > `activeMaxOpen`, `renderOpenBlock` switches to a lean summary (counts + over-budget projects + pointer) instead of the row list.
|
|
19
|
+
|
|
20
|
+
Tune defaults using v0.4.0's real per-project usage data (now visible via `projects`/`health`).
|
|
21
|
+
|
|
22
|
+
## 2. Decisions (brainstorm, all A)
|
|
23
|
+
|
|
24
|
+
| Q | Decision | Rationale |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| **Q1 enforcement mode** | Enforce explicitly-set per-project `maxOpen` only (hard block-on-add). Defaults (`activeMaxOpen`, `perProjectDefaultMax`) stay advisory. No `force` hatch. Block fires on `add` + project-`move`, **not** un-park. | v0.4.0 already ships warn-only (the flags). Enforcement must enforce *something*. Only an explicitly-set cap enforces — opt-in via setting `maxOpen`. A global hard block is too aggressive; enforcing a *default* is hostile. Un-park ≠ adding. |
|
|
27
|
+
| **Q2 global cap** | No new global hard cap. `activeMaxOpen` (=15) stays advisory (`ACTIVE_LARGE`) and gains a second job: the **injection-truncation trigger**. | Global budget is a *signal*, not a *gate*. The global lever acts on the prompt (lean injection), not on `add`. |
|
|
28
|
+
| **Q3 notes cap** | Global `health.maxNotesBytes`, default **8192** bytes, hard-reject at `add`/`update` (only when `notes` is written). Grandfather existing. `NOTES_OVER` health flag. Registry schema v1 unchanged. | Notes bloat is per-todo hygiene, not per-project. Active default (8KB ≈ 1–1.5k words) catches pathological agent dumps with no real downside. |
|
|
29
|
+
| **Q4 injection truncation** | `renderOpenBlock` becomes cap-aware: trigger = `activeMaxOpen`. Over → lean summary (counts + `PROJECT_OVER` projects only + pointer). Under → rows up to `activeMaxOpen`. | Keeps the over-budget prompt to ~4 lines regardless of bloat — the anti-israf point. Surfaces only real breaches (explicit `maxOpen`), not soft heuristics. |
|
|
30
|
+
| **Q5 migration** | Zero. Store v3 unchanged. Config gains `health.maxNotesBytes` via forward-merge (no version bump). Registry v1 unchanged. | Caps are enforcement, not data. |
|
|
31
|
+
| **Q6 backwards-compat** | Oversize notes grandfathered (cap on write only). `maxOpen` advisory→enforced is a documented behavior change (minor bump). No v0.4.0 user has capped projects in the known real store, so zero real impact. | Re-setting slots silently is its own surprise. The block message tells users how to raise/clear. |
|
|
32
|
+
|
|
33
|
+
## 3. Architecture
|
|
34
|
+
|
|
35
|
+
Caps are an **enforcement layer on top of the existing store** — no new data, no migration. Three enforcement points:
|
|
36
|
+
|
|
37
|
+
1. **`addTodo`** — title check (existing) + notes-cap check + project-cap check (new), all *before* `store.todos.push` (atomic: no partial write on breach).
|
|
38
|
+
2. **`updateTodo`** — notes-cap check (only when `notes` patch present) + project-cap check on a project **move** (only when the moved todo is `open`/`in_progress`).
|
|
39
|
+
3. **`renderOpenBlock`** — cap-aware truncation (summary mode when over `activeMaxOpen`).
|
|
40
|
+
|
|
41
|
+
A new pure module `src/caps.ts` holds the check logic so it's unit-testable without disk. Config gains `health.maxNotesBytes`. Registry unchanged (schema v1). Zero store migration.
|
|
42
|
+
|
|
43
|
+
## 4. Components
|
|
44
|
+
|
|
45
|
+
### 4.1 `src/caps.ts` (new, pure — no disk I/O)
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
/** Throw if notes exceeds the byte cap. Byte-length (not char-length): notes
|
|
49
|
+
* can hold Unicode ("é" = 2 bytes UTF-8). */
|
|
50
|
+
export function checkNotesCap(notes: string, maxBytes: number): void
|
|
51
|
+
|
|
52
|
+
/** Throw if adding one more open todo to `project` would exceed its cap.
|
|
53
|
+
* `maxOpen === null` → no-op (uncapped). `currentOpen` is the project's
|
|
54
|
+
* current open count, NOT counting the would-be-added todo. */
|
|
55
|
+
export function checkProjectCap(opts: { project: string; currentOpen: number; maxOpen: number | null }): void
|
|
56
|
+
|
|
57
|
+
/** Projects whose open count exceeds their explicit maxOpen (maxOpen non-null).
|
|
58
|
+
* Pure; consumed by renderOpenBlock summary + health (existing PROJECT_OVER). */
|
|
59
|
+
export function overBudgetProjects(liveTodos: Todo[], registry: ProjectRegistry): { name: string; open: number; maxOpen: number }[]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Error messages (actionable, surfaced verbatim to the agent/user via the existing `TodoError` → `Error: …` path):
|
|
63
|
+
|
|
64
|
+
- `project 'X' is at maxOpen 8 (8 open) — close/park one, or raise maxOpen via the /todo panel (Projects tab → Set maxOpen), before adding`
|
|
65
|
+
- `notes 9.2KB > max 8KB (maxNotesBytes 8192) — trim the detail or split into multiple todos`
|
|
66
|
+
|
|
67
|
+
### 4.2 `src/config.ts` (modify)
|
|
68
|
+
|
|
69
|
+
- Add `health.maxNotesBytes: number` (default `8192`).
|
|
70
|
+
- Forward-compatible merge in `loadConfig` (same pattern as `perProjectDefaultMax` in v0.4.0): `{ ...DEFAULT_CONFIG.health, ...parsed.health }`.
|
|
71
|
+
- Defensive: non-positive or non-number `maxNotesBytes` → default. (0 is a valid strict "no notes" choice and is respected; negative/NaN/missing → default.)
|
|
72
|
+
- **No `TodoConfig.version` bump** (stays 1).
|
|
73
|
+
|
|
74
|
+
### 4.3 `src/todo-store.ts` (modify)
|
|
75
|
+
|
|
76
|
+
**`addTodo`:**
|
|
77
|
+
- After `normalizeTitle`, load config + registry.
|
|
78
|
+
- Count the target project's current `open` (todos with `status === "open"` — new todos are `open`, so `in_progress` isn't relevant for adds; count open only).
|
|
79
|
+
- `checkNotesCap(notes, config.health.maxNotesBytes)` then `checkProjectCap({ project, currentOpen, maxOpen })`.
|
|
80
|
+
- All checks precede `store.todos.push` → atomic (no partial write on breach).
|
|
81
|
+
|
|
82
|
+
**`updateTodo`:**
|
|
83
|
+
- If `patch.notes !== undefined` → `checkNotesCap(patch.notes.trim(), config.health.maxNotesBytes)`.
|
|
84
|
+
- If `patch.project` is set, trimmed, and differs from `todo.project` **and** `todo.status` is `open`/`in_progress` → count the **target** project's open (excluding this todo, which is still in the source project at count time) → `checkProjectCap`.
|
|
85
|
+
- Un-park (`parked→open`) is **not** re-checked — reactivation ≠ adding (intentional loophole, documented).
|
|
86
|
+
|
|
87
|
+
**`renderOpenBlock(max?)`:**
|
|
88
|
+
- Read `activeMaxOpen` from config; the `max` param overrides for tests.
|
|
89
|
+
- If `actionable.length > activeMaxOpen` → **summary mode** (see §4.4 shape).
|
|
90
|
+
- Else list up to `activeMaxOpen` rows (drops the hardcoded `max=15` default; aligns the injection budget to the configured cap).
|
|
91
|
+
- The `… +N more` overflow line is removed (summary mode replaces it at the same threshold).
|
|
92
|
+
|
|
93
|
+
### 4.4 `renderOpenBlock` summary shape
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
## Open TODOs (23) — ⚠ over budget (cap 15)
|
|
97
|
+
23 open+in_progress across 5 projects
|
|
98
|
+
over-budget: getpither 9/8, sip-protocol 6/5
|
|
99
|
+
run `todo list` or `/todo` to see the full list
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
- Line 1: header with total + the breach + cap.
|
|
103
|
+
- Line 2: total actionable + project span (distinct projects with any actionable).
|
|
104
|
+
- Line 3: **only projects over their explicit `maxOpen`** (the `PROJECT_OVER` set). Format `name open/max`. Omitted entirely if no project is over its own cap (global over but every project within its slot).
|
|
105
|
+
- Line 4: the pointer.
|
|
106
|
+
|
|
107
|
+
`PROJECT_LARGE` (over `perProjectDefaultMax`, advisory) is deliberately **not** surfaced here — keep the lean summary focused on real breaches, not soft heuristics.
|
|
108
|
+
|
|
109
|
+
### 4.5 `src/health.ts` (modify)
|
|
110
|
+
|
|
111
|
+
- Add `"NOTES_OVER"` to `HealthFlag`.
|
|
112
|
+
- Extend `NotesBytes` with `maxId: string | null` (the id of the worst-offender todo), tracked during the existing reduce over active+parked notes (cheap, no new scan).
|
|
113
|
+
- Push `NOTES_OVER` when `notesBytes.max > config.health.maxNotesBytes`.
|
|
114
|
+
- Suggestion: `notes: largest note <id> is 12KB > cap 8KB → trim via todo update <id> notes:…` (actionable — names the offender id).
|
|
115
|
+
|
|
116
|
+
### 4.6 `src/panel-data.ts` (modify)
|
|
117
|
+
|
|
118
|
+
- Add a Config row for `maxNotesBytes` (editable): label `"Notes max bytes"`, values `["2048","4096","8192","16384","32768"]`, description `"Hard reject at add/update when notes exceeds this (bytes). 0 = no notes."`.
|
|
119
|
+
- Projects tab `OVER` marker unchanged — display is cap-agnostic; enforcement is backend.
|
|
120
|
+
|
|
121
|
+
### 4.7 `extensions/todo.ts` (modify)
|
|
122
|
+
|
|
123
|
+
- `add`/`update` actions: thrown `TodoError`s already caught + surfaced as `Error: …` — actionable messages flow through unchanged. No new code path needed.
|
|
124
|
+
- `before_agent_start`'s `renderOpenBlock()` call: cap-aware now (no call-site change).
|
|
125
|
+
- `promptGuidelines`:
|
|
126
|
+
- Update the `add`/`update` line: "adds are blocked if the target project is at its `maxOpen` cap; raise via the panel (Projects tab → Set maxOpen) or close/park one first. Notes are capped at `maxNotesBytes` (default 8KB)."
|
|
127
|
+
- Rewrite the `project_rename` line: remove "enforcement lands in v0.5.0" → "maxOpen caps are enforced (block-on-add)."
|
|
128
|
+
- `health` action output: `NOTES_OVER` flows through the generic `flags:` line + the new suggestion line (no special rendering).
|
|
129
|
+
|
|
130
|
+
### 4.8 `src/panel.ts` (minimal)
|
|
131
|
+
|
|
132
|
+
No new flows. The Set-maxOpen action already exists; block-on-add surfaces via the tool/slash, not the panel (the panel has no add flow). An "OVER BUDGET" badge on the Active tab header is **YAGNI** — injection + health cover the signal. Skipped.
|
|
133
|
+
|
|
134
|
+
## 5. Data flow
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
add: loadStore → loadConfig + loadRegistry → checkNotesCap → checkProjectCap
|
|
138
|
+
→ (all pass) push → save [checks before any mutation = atomic]
|
|
139
|
+
update: loadStore → find todo → [if notes patch: checkNotesCap]
|
|
140
|
+
→ [if project-move + open/in_progress: checkProjectCap on target] → mutate → save
|
|
141
|
+
inject: renderOpenBlock → loadConfig → actionable > activeMaxOpen ?
|
|
142
|
+
summary (counts + overBudgetProjects + pointer) : rows[:activeMaxOpen]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## 6. Error handling
|
|
146
|
+
|
|
147
|
+
- Every cap failure is a `TodoError` thrown **before** any write — the store is never partially mutated (add: checks precede `push`; update: checks precede field mutation).
|
|
148
|
+
- Corrupt/missing registry → `loadRegistry` returns empty (existing v0.4.0 behavior) → all `maxOpen` null → **cap fails open** (a bad registry never blocks adds; health flags it separately).
|
|
149
|
+
- Corrupt config → `loadConfig` backs up + rewrites defaults (existing) → `maxNotesBytes` defaults to 8192.
|
|
150
|
+
|
|
151
|
+
## 7. Testing
|
|
152
|
+
|
|
153
|
+
### 7.1 New `test/todo-caps.test.mts` (~30 tests, temp `TODO_DIR`)
|
|
154
|
+
|
|
155
|
+
**Notes cap:**
|
|
156
|
+
- Oversized `add` throws with actionable message.
|
|
157
|
+
- Oversized `update` (with `notes` patch) throws.
|
|
158
|
+
- `update` **without** `notes` patch on a grandfathered oversize note survives (no re-check).
|
|
159
|
+
- Boundary: exactly `maxBytes` ok; `maxBytes + 1` throws.
|
|
160
|
+
- Byte vs char: `"é"` (2 bytes) at `maxBytes: 1` throws; at `maxBytes: 2` ok.
|
|
161
|
+
- `notes: ""` always passes (the documented clear path).
|
|
162
|
+
|
|
163
|
+
**Project cap:**
|
|
164
|
+
- Uncapped project (`maxOpen: null`) → add always ok.
|
|
165
|
+
- At-cap project (`maxOpen: 8`, open: 8) → add throws.
|
|
166
|
+
- One-below (`open: 7`) → add ok (lands at 8, not over).
|
|
167
|
+
- Project-move into capped target throws (when moved todo is `open`).
|
|
168
|
+
- Project-move of a **parked** todo into capped target → ok (no open impact).
|
|
169
|
+
- Same-project "move" (no-op) → ok.
|
|
170
|
+
- Un-park (`parked→open`) into a capped project → ok (intentional, not blocked).
|
|
171
|
+
- Error message includes the raise/clear hint.
|
|
172
|
+
|
|
173
|
+
**`renderOpenBlock`:**
|
|
174
|
+
- Under cap → row list (capped at `activeMaxOpen`).
|
|
175
|
+
- Over cap → summary with over-budget line.
|
|
176
|
+
- Over cap but no per-project breaches → summary without the over-budget line.
|
|
177
|
+
- Custom `max` param overrides `activeMaxOpen`.
|
|
178
|
+
|
|
179
|
+
### 7.2 Extend `test/todo-config.test.mts`
|
|
180
|
+
|
|
181
|
+
- `maxNotesBytes` default 8192.
|
|
182
|
+
- Forward-merge: old config without `maxNotesBytes` gets the default.
|
|
183
|
+
- Negative/NaN/missing → default; `0` respected.
|
|
184
|
+
|
|
185
|
+
### 7.3 Extend `test/todo-health.test.mts`
|
|
186
|
+
|
|
187
|
+
- `NOTES_OVER` flag + suggestion when `notesBytes.max > maxNotesBytes`; suggestion names the offender `maxId`.
|
|
188
|
+
- Absent when under.
|
|
189
|
+
|
|
190
|
+
### 7.4 Extend `test/panel-data.test.mts`
|
|
191
|
+
|
|
192
|
+
- `maxNotesBytes` config row present in `configToSettingItems`.
|
|
193
|
+
|
|
194
|
+
**Total:** 331 baseline → ~361–371.
|
|
195
|
+
|
|
196
|
+
## 8. Edge cases & resolved sub-questions
|
|
197
|
+
|
|
198
|
+
- **`maxNotesBytes = 0`** → empty notes (0 bytes) pass, any non-empty fails. Valid strict choice; respected. Negative/non-number → default at load.
|
|
199
|
+
- **Un-park into a capped project** is an intentional loophole — the cap gates *new* work, not reactivation. Documented in README + promptGuidelines.
|
|
200
|
+
- **`update` that only edits `title`** on a grandfathered oversize note: no `notes` patch → no re-check → edit succeeds. (Without this gating, a cap would trap unrelated edits.)
|
|
201
|
+
- **`renderOpenBlock` does 2 extra reads/turn** (config + registry). Negligible (tiny JSON files, already reads the store).
|
|
202
|
+
- **Byte vs char length:** title uses char length (`.length`); notes uses byte length (`Buffer.byteLength`). Documented distinction — the field is `maxNotesBytes`.
|
|
203
|
+
- **Worst-offender id in `NOTES_OVER` suggestion:** `NotesBytes` gains a `maxId: string | null` field, tracked during the existing reduce over active+parked notes (cheap, no new scan). The suggestion names the offender id so it's actionable (`todo update <id> notes:…`).
|
|
204
|
+
|
|
205
|
+
## 9. Backwards-compat & upgrade notes
|
|
206
|
+
|
|
207
|
+
- **Zero migration.** Store v3, config v1, registry v1 all unchanged in shape.
|
|
208
|
+
- **Oversize existing notes:** grandfathered. Cap fires only when `notes` is written.
|
|
209
|
+
- **`maxOpen` advisory → enforced:** semantic behavior change for any v0.4.0 user who set a slot. The known real store has none set (all `null`), so zero real impact. Documented trajectory. Block message tells the user how to raise/clear. Minor version bump (v0.5.0) is the semver signal.
|
|
210
|
+
- **README upgrade note** added: a short "v0.5.0" section noting the advisory→enforced graduation + the new notes cap default.
|
|
211
|
+
|
|
212
|
+
## 10. Flow (same as v0.4.0)
|
|
213
|
+
|
|
214
|
+
brainstorm ✅ → spec (this doc) → RECTOR reviews → writing-plans → executing-plans (inline; pi has no subagent tool) → self-review (fresh-eyes over `git diff`) → autonomous tmux QA (temp `TODO_DIR` for write ops; real-store read-only for final verify — **do not** run rename/setmax/block-on-add against RECTOR's real store) → merge → tag `v0.5.0` → CI auto-publish npm + GitHub Release → `pi install npm:@getpipher/armory-todo@0.5.0` → memory `v0.5.0-shipped.md`.
|
|
215
|
+
|
|
216
|
+
## 11. Constraints
|
|
217
|
+
|
|
218
|
+
- Backwards-compatible with v0.4.0 stores (v3 store, v1 config, v1 registry).
|
|
219
|
+
- Zero runtime deps (node:fs only). 2-space indent. No TODO/FIXME. No AI attribution.
|
|
220
|
+
- Tests: node:test via tsx. `npm test` must stay green (331 baseline + new).
|
|
221
|
+
- Commits: `feat(scope): …` per task. PR → `--merge --delete-branch`.
|
|
222
|
+
- getpither UX mental model (in `~/local-dev/getpipher/AGENTS.md`): interactive first (panel) for humans, CLI-style (tool actions) for the agent. New enforcement surfaces (block-on-add error, over-cap injection summary) follow this — the error is the agent's programmatic surface; the panel needs no add flow.
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# Project-Scope Management — Design (v0.4.0, Workstream C / Feature A)
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-07-21
|
|
4
|
+
**Workstream:** C → Feature A (issue #1's project-scope half)
|
|
5
|
+
**Release:** v0.4.0
|
|
6
|
+
**Predecessors:** v0.3.1 (auto-prune + unified Done view) — 255/255 tests, 9 suites
|
|
7
|
+
**Successor:** v0.5.0 — Feature B (caps enforcement: count cap + notes cap + over-cap injection summary). This spec is **advisory-only**; no enforcement ships here.
|
|
8
|
+
|
|
9
|
+
**Branch:** `feat/project-scope-management` off `main`
|
|
10
|
+
**Commits:** `feat(scope): ...` per task. PR → `--merge --delete-branch`. RECTOR QA gate → tag `v0.4.0` → CI auto-publishes npm + GitHub Release (now automatic).
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 1. Problem (the real one, restated)
|
|
15
|
+
|
|
16
|
+
The injected `## Open TODOs (N)` block is already bounded (v0.3.0 title-only injection + `renderOpenBlock(max=15)` + v0.3.1 auto-prune). What's **still unsolved**:
|
|
17
|
+
|
|
18
|
+
1. **No forcing function on the *count*.** Open can grow to 30/50/100 with no pushback; the injection *hides* the rot behind `+N more`. A cap is a triage signal, not a bytes fix.
|
|
19
|
+
2. **No scope overview.** `project` is free-text, un-aggregated. Working across ZeroClaw, getpipher, vision, etc., you can't see "this project owns 18 of my 22 open" — can't triage *where* to cut. The bigger daily pain than the cap.
|
|
20
|
+
3. **No per-project bloat signal in `health`.** `ACTIVE_LARGE` fires globally at >15; can't say *which* project is over budget.
|
|
21
|
+
4. **No curation surface.** A typo'd project string (`getpither` vs `getpipher`) silently becomes its own "project" with no way to merge.
|
|
22
|
+
|
|
23
|
+
v0.4.0 is the **triage visibility** release: see the backlog by project + know when one's over budget + fix typos. **No enforcement** — that's v0.5.0's "caps release" (count + notes + injection, all block-on-add).
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 2. Scope (locked decisions)
|
|
28
|
+
|
|
29
|
+
| Decision | Choice | Rationale |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| **Release slice** (Q1) | B — v0.4.0 = Feature A only; v0.5.0 = Feature B (caps) | Lands triage visibility (the daily pain) before the cap that needs it. v0.4.0 is zero-enforcement-risk. |
|
|
32
|
+
| **Registry vs dynamic** (Q2) | C — full registry with per-project config slots | Forward-looking for armory-todo; canonical name list + rename/merge (the point of a registry over dynamic derivation). |
|
|
33
|
+
| **`maxOpen` slot behavior in v0.4.0** (Q3) | A — advisory `health` flag now; enforcement (block-on-add) in v0.5.0 | Slot is never inert: graduates advisory → hard block. No lying UI. |
|
|
34
|
+
| **Notes cap in v0.4.0?** (Q4) | A — defer to v0.5.0 | v0.4.0 is purely the project-scope axis; v0.5.0 is "the caps release" (count + notes + injection). |
|
|
35
|
+
| **Registry storage** (Q5) | B — sibling `~/.pi/agent/todo/projects.json` | Registry is *state* (grows, auto-seeds); config is *settings*. Separation avoids a config schema bump + the risky live-store migration. |
|
|
36
|
+
| **Registry entry shape** (Q6) | A — minimal `{ name, maxOpen, createdAt, updatedAt }` | Every field earns its weight; `lastSeenAt`/reserved slots re-introduce the inert-field hazard. |
|
|
37
|
+
| **Registry sync** (Q7) | A — lazy sync-on-read | No writes on `add`/`update`; `projects`/`health` reconcile first. First read seeds; later reads self-heal. No cross-path risk (file lives under `TODO_DIR`). |
|
|
38
|
+
| **Rename/merge** (Q8) | B — included, full consistency (live + archive + registry) | Rename is the point of a registry; half-way (archive sealed) re-splits the project in the view. |
|
|
39
|
+
| **`projects` output** (Q9) | B — counts + cap signal + typo marker | One-stop triage; staleness/notesBytes stay in `health` (whole-store concerns). |
|
|
40
|
+
| **`health` per-project flags** (Q10) | C — `PROJECT_OVER` + `PROJECT_TYPO` + `PROJECT_LARGE` + `PROJECT_STALE` | `PROJECT_LARGE` (global default threshold) makes the per-project signal useful on day 1, not gated behind per-project config. All advisory. |
|
|
41
|
+
| **Action API + slash naming** (Q11) | A — tool `action:'projects'` + `action:'project_rename'`; slash `/todo projects` (thin mirror) | Plural=list, singular=action namespace. No `/todo project rename` slash — rename is panel-only (Q12). |
|
|
42
|
+
| **Panel surface** (Q12) | A — 6th tab `projects` + per-project action submenu | Panel is the primary human surface (getpither UX mental model: interactive first, CLI-style for the agent). |
|
|
43
|
+
| **UX mental model** | Documented in `~/local-dev/getpipher/AGENTS.md` (cross-cutting, all getpither extensions) | Panel-first design order; tool action + slash are secondary/derived. |
|
|
44
|
+
|
|
45
|
+
**Defaults (Q13):** `health.perProjectDefaultMax = 8`; `maxOpen` per-project default `null`; `PROJECT_STALE` reuses `activeStaleDays` (30d); `PROJECT_TYPO` = exactly 1 todo in project (live + archived done); seed scope = live + archive; empty-project group = `(no project)` (excluded from typo detection, no `maxOpen`); rename is best-effort multi-file (live → archive → registry, backup-on-corrupt per file, no cross-file WAL).
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 3. Architecture
|
|
50
|
+
|
|
51
|
+
### 3.1 New file: `projects.json` (registry state)
|
|
52
|
+
|
|
53
|
+
`~/.pi/agent/todo/projects.json` — the project registry. Auto-seeded on first read, lazy-synced on every registry touch.
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"version": 1,
|
|
58
|
+
"updatedAt": "2026-07-21T12:00:00.000Z",
|
|
59
|
+
"projects": [
|
|
60
|
+
{
|
|
61
|
+
"name": "getpipher",
|
|
62
|
+
"maxOpen": 5,
|
|
63
|
+
"createdAt": "2026-07-21T12:00:00.000Z",
|
|
64
|
+
"updatedAt": "2026-07-21T12:00:00.000Z"
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- **`version: 1`** — bump on future schema changes (e.g. v0.5.0 may add per-project `maxNotesBytes`).
|
|
71
|
+
- **`maxOpen: number | null`** — advisory cap slot. `null` (default) → no `PROJECT_OVER` flag for this project. v0.4.0: drives `health` flag only. v0.5.0: enforcement (block-on-add).
|
|
72
|
+
- **`name`** — canonical project string. Unique within `projects[]`. Empty string `""` is NOT a registry entry (the `(no project)` group is implicit, never registered).
|
|
73
|
+
- Atomic 0600 write (tmp + rename), same pattern as `saveStore`/`saveConfig`.
|
|
74
|
+
|
|
75
|
+
### 3.2 New module: `src/registry.ts`
|
|
76
|
+
|
|
77
|
+
Pure, pi-independent (like the other `src/` modules). No new runtime deps.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
export interface ProjectEntry { name: string; maxOpen: number | null; createdAt: string; updatedAt: string; }
|
|
81
|
+
export interface ProjectRegistry { version: 1; updatedAt: string; projects: ProjectEntry[]; }
|
|
82
|
+
|
|
83
|
+
export function getRegistryPath(): string; // <TODO_DIR>/projects.json
|
|
84
|
+
export function loadRegistry(): ProjectRegistry; // corrupt → backup .bad-<ts>, fresh
|
|
85
|
+
export function saveRegistry(reg: ProjectRegistry): void; // atomic 0600
|
|
86
|
+
export function reconcileRegistry(reg, liveTodos, archivedTodos): { reg, changed }; // lazy sync: append unknown project strings with maxOpen:null
|
|
87
|
+
export function getProjectEntry(reg, name): ProjectEntry | undefined;
|
|
88
|
+
export function setProjectMaxOpen(reg: ProjectRegistry, name: string, max: number | null): ProjectEntry; // max=null clears; creates entry if unknown; throws if name === "" (no-project group can't be capped)
|
|
89
|
+
export function renameProject(reg, oldName, newName): { reg, liveTodos, archivedTodos, liveChanged, archivedChanged }; // rewrites registry + both stores in place
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Lazy sync (`reconcileRegistry`):** collect distinct non-empty `project` strings across `liveTodos` + `archivedTodos`; for any not in `reg.projects`, append `{ name, maxOpen: null, createdAt: now, updatedAt: now }`. Bump `reg.updatedAt` iff changed. Caller persists iff `changed`.
|
|
93
|
+
|
|
94
|
+
**Seed:** `loadRegistry()` on a missing file returns an empty registry (`{ version: 1, updatedAt: now, projects: [] }`) and does NOT seed. Seeding happens on the first `reconcileRegistry` call (inside `projectsOverview`/`healthReport`), which persists. This keeps `loadRegistry` side-effect-free (matches `loadConfig`'s "missing → write defaults" pattern is NOT used here — we seed lazily to keep load pure + avoid a write on a bare load).
|
|
95
|
+
|
|
96
|
+
**No env guard:** `projects.json` always lives under `TODO_DIR` (temp dir in tests), so no cross-path migration risk (unlike v1→v2 file-move). Tests get isolated registries for free.
|
|
97
|
+
|
|
98
|
+
### 3.3 New module: `src/projects.ts` (overview)
|
|
99
|
+
|
|
100
|
+
Pure read + reconcile. The `projects` action's brain.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
export interface ProjectOverviewRow {
|
|
104
|
+
name: string;
|
|
105
|
+
open: number;
|
|
106
|
+
in_progress: number;
|
|
107
|
+
parked: number;
|
|
108
|
+
done: number; // live done + archived done
|
|
109
|
+
total: number; // open + in_progress + parked + done
|
|
110
|
+
maxOpen: number | null;
|
|
111
|
+
over: boolean; // open > maxOpen (only when maxOpen !== null)
|
|
112
|
+
typo: boolean; // exactly 1 todo (live + archived done) in the project
|
|
113
|
+
lastUpdated: string; // max updatedAt across the project's live todos (ISO), or "" if none live
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ProjectsOverview {
|
|
117
|
+
rows: ProjectOverviewRow[]; // sorted: open desc → total desc → name asc
|
|
118
|
+
totalTodos: number; // sum of all rows' total
|
|
119
|
+
noProject: { count: number; open: number }; // the (no project) bucket — not a row
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function projectsOverview(): ProjectsOverview;
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Typo nearest-sibling:** `typo: true` rows are reported in `health` suggestions with a nearest-sibling guess (Levenshtein ≤ 2 among other registry names). The `projects` row carries `typo: true`; the suggestion text carries the guess. (Edit-distance helper lives in `projects.ts` or a tiny `src/levenshtein.ts` — small enough to inline.)
|
|
126
|
+
|
|
127
|
+
**`(no project)` bucket:** todos with `project === ""` are aggregated into `noProject`, NOT a row (no `maxOpen`, no typo, no rename target). Surfaced in the overview summary + `health`.
|
|
128
|
+
|
|
129
|
+
### 3.4 `src/health.ts` — extend with per-project flags
|
|
130
|
+
|
|
131
|
+
New flags + a new config field (forward-compatible merge, no schema bump):
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
export type HealthFlag =
|
|
135
|
+
| "ACTIVE_LARGE" | "ACTIVE_STALE"
|
|
136
|
+
| "PARKED_LARGE" | "PARKED_STALE"
|
|
137
|
+
| "ARCHIVE_LARGE" | "ARCHIVE_OLD"
|
|
138
|
+
| "PROJECT_OVER" | "PROJECT_TYPO" | "PROJECT_LARGE" | "PROJECT_STALE"; // new
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`HealthConfig` gains `perProjectDefaultMax: number` (default 8). `loadConfig`'s existing merge (`{ ...DEFAULT_CONFIG.health, ...parsed.health }`) fills it for old configs — no migration.
|
|
142
|
+
|
|
143
|
+
`HealthReport` gains:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
export interface ProjectHealth { name: string; open: number; maxOpen: number | null; over: boolean; typo: boolean; large: boolean; stale: boolean; lastUpdated: string; }
|
|
147
|
+
export interface HealthReport {
|
|
148
|
+
// ...existing fields...
|
|
149
|
+
projects: ProjectHealth[]; // only projects with ≥1 flag, sorted open desc
|
|
150
|
+
noProject: { open: number }; // (no project) open count, for context
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Flag logic per project (live open count is the `open` figure):
|
|
155
|
+
- `PROJECT_OVER` — `maxOpen !== null && open > maxOpen`
|
|
156
|
+
- `PROJECT_LARGE` — `open > config.health.perProjectDefaultMax` (default 8; fires even when `maxOpen` is null — the day-1 signal)
|
|
157
|
+
- `PROJECT_STALE` — `lastUpdated !== "" && daysAgo(lastUpdated) > config.health.activeStaleDays` (30d)
|
|
158
|
+
- `PROJECT_TYPO` — total todos (live + archived done) in the project === 1 AND a near-named sibling (Levenshtein ≤ 2) exists in the registry
|
|
159
|
+
|
|
160
|
+
`healthReport()` calls `reconcileRegistry` first (so the registry is current), persists iff changed, then computes. Suggestions gain per-project actionable lines, e.g.:
|
|
161
|
+
- `project 'getpither' has 1 todo — possible typo of 'getpipher'? → todo project rename getpither getpipher`
|
|
162
|
+
- `project 'getpipher' 12 open (maxOpen 5) → close/park some, or raise maxOpen`
|
|
163
|
+
- `project 'bug-bounty' 9 open (per-project default max 8) → over budget`
|
|
164
|
+
- `project 'vision' untouched 45d → stale, park or close`
|
|
165
|
+
|
|
166
|
+
### 3.5 `src/todo-store.ts` — no schema change, no new writes
|
|
167
|
+
|
|
168
|
+
`addTodo`/`updateTodo` are **unchanged** (no registry write — lazy sync, Q7=A). The live store stays `Store.version: 3`. `Todo.project` stays free-text. No migration.
|
|
169
|
+
|
|
170
|
+
### 3.6 `extensions/todo.ts` — new tool actions + panel tab + thin slash
|
|
171
|
+
|
|
172
|
+
#### Tool actions (agent surface)
|
|
173
|
+
- `action: 'projects'` → returns `ProjectsOverview` (structured). No params.
|
|
174
|
+
- `action: 'project_rename'` → params `oldName: string`, `newName: string`. Returns `{ liveRenamed: number, archivedRenamed: number, merged: boolean, newName: string }`. Throws `TodoError` if `oldName` not in registry. `newName` may equal an existing different project — rename-onto-existing is a **merge** (consolidates `oldName` todos into `newName`, removes the `oldName` registry entry, keeps `newName`'s entry; `merged: true`). This is the typo-cleanup path (`getpither` → `getpipher` where `getpipher` already exists). Self-rename (`newName === oldName`) is a no-op success (`{ liveRenamed: 0, archivedRenamed: 0, merged: false, newName }`).
|
|
175
|
+
|
|
176
|
+
#### Panel (human surface — primary)
|
|
177
|
+
New 6th tab `projects` in the `/todo` panel (existing 5: active/parked/done/archive/config → now 6). Tab label `Projects`.
|
|
178
|
+
|
|
179
|
+
- **Rows:** `ProjectOverviewRow` rendered as: `name open/in_progress/parked/done (total) [max:N or —] OVER? ?typo · lastUpdated`. Box-draw to match existing tab style.
|
|
180
|
+
- **Sort:** open desc → total desc → name asc (Q9).
|
|
181
|
+
- **Action submenu** (per project, via `openActionSubmenu`): `Rename` / `Set maxOpen` / `Filter active to project`.
|
|
182
|
+
- **Rename** — inline `Input` (single-line; pi-tui can't nest `ctx.ui.editor()` inside `ctx.ui.custom()`). Validates `newName` non-empty + not equal to current. Calls `renameProject`. Confirms merge if target exists. Notify on success (`Renamed getpither → getpipher (3 live + 1 archived)`).
|
|
183
|
+
- **Set maxOpen** — inline `Input`, accepts a positive integer or `clear` (→ `null`). Calls `setProjectMaxOpen`. Notify (`getpipher maxOpen = 5` or `getpipher maxOpen cleared`).
|
|
184
|
+
- **Filter active to project** — jumps to the `active` tab with a `project` filter applied (existing list filter already supports `project`).
|
|
185
|
+
- **`(no project)` bucket** — shown as a non-selectable summary row at the top or footer (`(no project): N open`), no submenu.
|
|
186
|
+
|
|
187
|
+
#### Slash (thin mirror only)
|
|
188
|
+
- `/todo projects` — prints the `ProjectsOverview` as text (mirrors `/todo health`'s text-report style). One new sub. **No** `/todo project rename` slash — rename is panel-only (Q12=A + getpither UX mental model).
|
|
189
|
+
|
|
190
|
+
### 3.7 `src/config.ts` — one new field, no schema bump
|
|
191
|
+
|
|
192
|
+
`HealthConfig` gains `perProjectDefaultMax: number` (default 8). `DEFAULT_CONFIG.health.perProjectDefaultMax = 8`. `loadConfig`'s merge fills it for old configs. `TodoConfig.version` stays `1`.
|
|
193
|
+
|
|
194
|
+
### 3.8 No injection change
|
|
195
|
+
|
|
196
|
+
`renderOpenBlock` is **unchanged** in v0.4.0 (no caps, no over-cap summary — that's v0.5.0). The `## Open TODOs (N)` block keeps its current shape (≤15 title-only rows + `+N more`).
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## 4. Data flow
|
|
201
|
+
|
|
202
|
+
### 4.1 `todo projects` (tool) / `/todo projects` (slash) / Projects tab (panel)
|
|
203
|
+
1. `loadStore()` (live) + `loadArchive()` (archived done).
|
|
204
|
+
2. `loadRegistry()` → `reconcileRegistry(reg, live, archive)` → persist iff changed.
|
|
205
|
+
3. `projectsOverview()` computes rows from live + archive + registry.
|
|
206
|
+
4. Return (tool) / render (panel) / print (slash).
|
|
207
|
+
|
|
208
|
+
### 4.2 `todo health`
|
|
209
|
+
1. Same load + reconcile as 4.1.
|
|
210
|
+
2. `healthReport()` extends: existing box diagnostics + new `projects[]` (per-project flags) + `noProject`.
|
|
211
|
+
3. Render (existing text format + new `projects:` section).
|
|
212
|
+
|
|
213
|
+
### 4.3 `todo project_rename` (tool) / panel Rename
|
|
214
|
+
1. `loadRegistry()`, find `oldName` entry (throw if missing).
|
|
215
|
+
2. `loadStore()` + `loadArchive()`.
|
|
216
|
+
3. Validate `newName` (non-empty, trimmed; if equal to `oldName` → no-op success).
|
|
217
|
+
4. Rewrite live todos (`project === oldName` → `newName`, bump `updatedAt`), save store iff changed.
|
|
218
|
+
5. Rewrite archived todos (`project === oldName` → `newName`, bump archive `updatedAt`), save archive iff changed.
|
|
219
|
+
6. Registry: remove `oldName` entry, ensure `newName` entry exists (create if merge target was absent, keep if merge), bump `updatedAt`. Save registry.
|
|
220
|
+
7. Return `{ liveRenamed, archivedRenamed, merged, newName }`.
|
|
221
|
+
8. **Failure semantics:** best-effort, not cross-file transactional (no WAL). Each file write uses the existing backup-on-corrupt pattern. If step N fails, prior writes stand; the result reports what happened. A later `reconcileRegistry` self-heals any drift.
|
|
222
|
+
|
|
223
|
+
### 4.4 `todo update` / `todo add` with a project
|
|
224
|
+
- **Unchanged.** No registry write. The new project string is picked up on the next `projects`/`health` read (lazy sync).
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## 5. Edge cases
|
|
229
|
+
|
|
230
|
+
- **Empty store** — `projectsOverview` returns `{ rows: [], totalTodos: 0, noProject: { count: 0, open: 0 } }`. `healthReport().projects = []`. Panel shows `(no projects)`.
|
|
231
|
+
- **All-done project** — seeded from archive (live has 0 todos for it). Row: `name 0/0/0/N (N) [max:null] · lastUpdated:""`. Not typo (total ≥ 1 but if total === 1 and it's archived done, still typo-eligible — a single archived todo under a near-typo'd name is still a typo). Typo counts live + archived done.
|
|
232
|
+
- **Rename onto self** — no-op success (`{ liveRenamed: 0, archivedRenamed: 0, merged: false, newName }`).
|
|
233
|
+
- **Rename onto existing (merge)** — allowed; `merged: true`; `oldName` entry removed, `newName` entry kept.
|
|
234
|
+
- **Rename to a name that only differs by case** (`getpipher` → `Getpipher`) — allowed (case-sensitive `project` is the existing contract); flags nothing special.
|
|
235
|
+
- **`(no project)` rename** — not a rename target (no registry entry for `""`). `setProjectMaxOpen("")` → throws `TodoError` (no project group can't have a cap).
|
|
236
|
+
- **Corrupt `projects.json`** — `loadRegistry` backs up to `projects.json.bad-<ts>` and returns a fresh empty registry; next reconcile re-seeds. No data loss (todos are the source of truth; the registry is derived + the maxOpen slots — which are the only non-derived data — are lost on corrupt, acceptable, same tradeoff as `todo.config.json`).
|
|
237
|
+
- **`maxOpen = 0`** — semantically "no open todos allowed for this project." v0.4.0: `PROJECT_OVER` fires if open > 0. v0.5.0: blocks any add. Valid (edge but meaningful — "this project is closed").
|
|
238
|
+
- **Two projects with the same name after a case-only rename** — can't happen (rename rewrites all todos to one casing; the registry has one entry per unique name).
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## 6. Testing
|
|
243
|
+
|
|
244
|
+
New suite `test/registry.test.mts` + `test/projects.test.mts`; extend `test/todo-health.test.mts` + `test/panel-data.test.mts`. Baseline 255/255 → target ~300+.
|
|
245
|
+
|
|
246
|
+
### `test/registry.test.mts` (new, ~20)
|
|
247
|
+
- `loadRegistry` missing → empty registry, no file write (lazy seed).
|
|
248
|
+
- `loadRegistry` corrupt → backup `.bad-<ts>`, fresh empty.
|
|
249
|
+
- `saveRegistry` atomic + 0600.
|
|
250
|
+
- `reconcileRegistry` appends unknown (live + archive), bumps `updatedAt` iff changed, idempotent (no change on second call).
|
|
251
|
+
- `reconcileRegistry` ignores empty-string project (not registered).
|
|
252
|
+
- `getProjectEntry` hit/miss.
|
|
253
|
+
- `setProjectMaxOpen` create-if-unknown, set number, `null` clears.
|
|
254
|
+
- `renameProject` rewrites live + archive + registry, removes old, keeps/creates new, `merged` flag, no-op self-rename, throws on unknown old.
|
|
255
|
+
|
|
256
|
+
### `test/projects.test.mts` (new, ~15)
|
|
257
|
+
- `projectsOverview` row counts (open/in_progress/parked/done/total) from live + archive.
|
|
258
|
+
- `maxOpen` carried from registry; `over` only when `maxOpen !== null && open > maxOpen`.
|
|
259
|
+
- `typo` true iff total (live + archived done) === 1 AND near-sibling Levenshtein ≤ 2 exists.
|
|
260
|
+
- `(no project)` bucket aggregated, not a row.
|
|
261
|
+
- Sort: open desc → total desc → name asc.
|
|
262
|
+
- `lastUpdated` = max live `updatedAt`, `""` when no live todos.
|
|
263
|
+
- Empty store → empty overview.
|
|
264
|
+
|
|
265
|
+
### `test/todo-health.test.mts` (extend, +~15)
|
|
266
|
+
- `perProjectDefaultMax` default 8 + override via config.
|
|
267
|
+
- `PROJECT_OVER` (maxOpen set + exceeded), `PROJECT_LARGE` (default threshold, maxOpen null), `PROJECT_STALE` (lastUpdated > activeStaleDays), `PROJECT_TYPO` (1 todo + near-sibling).
|
|
268
|
+
- `healthReport().projects` only includes projects with ≥1 flag, sorted open desc.
|
|
269
|
+
- `noProject` reported.
|
|
270
|
+
- Reconcile runs inside health (registry seeded on first health call).
|
|
271
|
+
- Suggestions actionable (rename hint for typo, maxOpen hint for over).
|
|
272
|
+
|
|
273
|
+
### `test/panel-data.test.mts` (extend, +~10)
|
|
274
|
+
- `projectsOverview` → panel rows rendering (markers `OVER`, `?typo`).
|
|
275
|
+
- Action submenu options per project (Rename/Set maxOpen/Filter).
|
|
276
|
+
- `(no project)` summary row, no submenu.
|
|
277
|
+
- (Panel interactive flows — Rename via inline Input, Set maxOpen — are NOT unit-tested; covered by the autonomous tmux QA harness per v0.3.1 pattern. `panel.ts` is the only non-unit-tested component, same as v0.3.0/v0.3.1.)
|
|
278
|
+
|
|
279
|
+
### Existing suites — regression
|
|
280
|
+
- `todo-store` (44), `todo-title-notes` (31), `todo-archive` (55), `todo-config` (15), `todo-migrate` (26), `todo-hard-prune` (16), `todo-auto-prune` (12) — **unchanged logic**, must stay green. `todo-config` gains the new `perProjectDefaultMax` field assertion (default 8 + merge for old configs).
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## 7. Implementation plan (high-level, for writing-plans)
|
|
285
|
+
|
|
286
|
+
1. `src/registry.ts` + `test/registry.test.mts` — registry load/save/reconcile/setMaxOpen/rename. Pure, no pi.
|
|
287
|
+
2. `src/projects.ts` + `test/projects.test.mts` — overview + Levenshtein typo helper.
|
|
288
|
+
3. `src/config.ts` — add `perProjectDefaultMax` (default 8) + `DEFAULT_CONFIG` + merge test.
|
|
289
|
+
4. `src/health.ts` + extend `test/todo-health.test.mts` — 4 per-project flags + `projects[]` + `noProject` + actionable suggestions; reconcile-first.
|
|
290
|
+
5. `extensions/todo.ts` — tool `projects` + `project_rename` actions; `/todo projects` slash; panel `Projects` tab (rows, action submenu, inline Rename/Set maxOpen/Filter).
|
|
291
|
+
6. `src/panel-data.ts` + extend `test/panel-data.test.mts` — `projectsOverview` → panel row helpers, action-submenu options, `(no project)` row.
|
|
292
|
+
7. `README.md` + `AGENTS.md` — v0.4.0 section (projects view, rename, per-project health flags, `maxOpen` advisory); Known issues (no enforcement until v0.5.0).
|
|
293
|
+
8. RECTOR QA gate (autonomous tmux harness per v0.3.1): panel Projects tab, Rename (incl. merge), Set maxOpen, `/todo projects`, `todo health` per-project section, `/todo project*` slash absence.
|
|
294
|
+
9. Merge → tag `v0.4.0` → CI auto-publish npm + GitHub Release.
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## 8. Out of scope (v0.5.0 / later)
|
|
299
|
+
|
|
300
|
+
- **Enforcement** — block-on-add when open > maxOpen (v0.5.0 graduates the `PROJECT_OVER` flag → throw).
|
|
301
|
+
- **Notes cap** — `maxNotesBytes` config + reject oversize notes at `add`/`update` (v0.5.0).
|
|
302
|
+
- **Over-cap injection summary** — `renderOpenBlock` over-cap truncation (counts + over-budget projects instead of the 15-row list) (v0.5.0).
|
|
303
|
+
- **Project merge as a distinct action** — v0.4.0 merge is a side-effect of rename-onto-existing; a dedicated `project_merge` (merge N → 1) is later if needed.
|
|
304
|
+
- **Project delete/purge** — removing a registry entry without renaming (orphaned todos keep their `project` string; re-registered on next read). Later.
|
|
305
|
+
- **Per-project `maxNotesBytes`** — v0.5.0 may add this to the registry entry (schema v2).
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## 9. Backwards compatibility
|
|
310
|
+
|
|
311
|
+
- **Store:** `Store.version: 3` unchanged. No migration. Existing `todo.json` + `todo-archive.json` load as-is.
|
|
312
|
+
- **Config:** `TodoConfig.version: 1` unchanged. `perProjectDefaultMax` added via forward-compatible merge (old configs get the default 8).
|
|
313
|
+
- **Registry:** new file; missing on first load → empty → seeded lazily. No user action.
|
|
314
|
+
- **`project` free-text:** unchanged. Existing todos with any `project` string keep working; the registry picks them up on first read.
|
|
315
|
+
- **Injection (`## Open TODOs`):** unchanged in v0.4.0.
|
|
316
|
+
- **Tool API:** two new `action` values (`projects`, `project_rename`); no existing action changes.
|
|
317
|
+
- **Slash:** one new sub (`/todo projects`); no existing sub changes.
|
|
318
|
+
|
|
319
|
+
No breaking changes. v0.3.1 → v0.4.0 is a safe in-place upgrade.
|