@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.
@@ -0,0 +1,1113 @@
1
+ # v0.5.0 Caps Release Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Graduate v0.4.0's advisory per-project `maxOpen` slot into enforcement (block-on-add + project-move), add a global `maxNotesBytes` notes cap (reject at write), and make `renderOpenBlock` cap-aware (lean summary when over `activeMaxOpen`) — the forcing-function half of issue #1.
6
+
7
+ **Architecture:** Caps are an enforcement layer on top of the existing store — no new data, no migration. A new pure module `src/caps.ts` holds the check primitives (no disk I/O, unit-testable in isolation). `addTodo`/`updateTodo` call them before any mutation (atomic). `renderOpenBlock` reads config + registry to switch to a lean summary when over budget. Config gains `health.maxNotesBytes` (forward-merge, no version bump). Registry unchanged (schema v1). `health` gains a `NOTES_OVER` flag + a `maxId` on `NotesBytes` so the suggestion names the offender.
8
+
9
+ **Tech Stack:** TypeScript (raw `.ts`, run via tsx at pi runtime — no build step), node:test-style hand-rolled harness (`ok`/`eq` + `mkdtempSync` `TODO_DIR`), node:fs only (zero runtime deps).
10
+
11
+ ## Global Constraints
12
+
13
+ - Backwards-compatible with v0.4.0 stores: store schema v3, config v1, registry v1 — **no version bumps**.
14
+ - Zero runtime deps (node:fs only). 2-space indent. No TODO/FIXME. No AI attribution.
15
+ - Circular imports are safe: `caps.ts` imports types/`TodoError` from `todo-store.ts`; `todo-store.ts` imports `caps.ts`. No module touches another's exports at top-level — all usage is inside functions, so by call-time both are fully loaded.
16
+ - The cap is on the `open` count only (matches the `PROJECT_OVER` health definition; `in_progress` does **not** count toward `maxOpen`).
17
+ - Un-park (`parked→open`) is intentionally **not** cap-checked (reactivation ≠ adding).
18
+ - Tests: `node test/<suite>.test.mts` individually; `npm test` runs all. 331 baseline → ~361+ across 12 suites.
19
+ - Commits: `feat(scope): …` per task. Branch `feat/caps-release` off `main`. PR → `--merge --delete-branch`.
20
+
21
+ ## File Structure
22
+
23
+ - **Create** `src/caps.ts` — pure enforcement primitives (`checkNotesCap`, `checkProjectCap`, `overBudgetProjects`).
24
+ - **Create** `test/todo-caps.test.mts` — pure-function tests (Task 1) + add/update enforcement (Task 3) + `renderOpenBlock` (Task 4).
25
+ - **Modify** `src/config.ts` — add `health.maxNotesBytes` (default 8192) + defensive merge.
26
+ - **Modify** `src/todo-store.ts` — `addTodo` + `updateTodo` enforce caps; `renderOpenBlock` cap-aware.
27
+ - **Modify** `src/health.ts` — `NotesBytes.maxId`, `NOTES_OVER` flag + suggestion.
28
+ - **Modify** `src/panel-data.ts` — `maxNotesBytes` config row.
29
+ - **Modify** `src/panel.ts` — `maxNotesBytes` cases in the config getter + setter switches.
30
+ - **Modify** `extensions/todo.ts` — promptGuidelines rewrite (caps enforced).
31
+ - **Modify** `README.md` — v0.5.0 section + fix stale "capped at 15" / "advisory only" lines.
32
+ - **Modify** `package.json` — version `0.5.0`; `npm test` script adds `todo-caps`.
33
+
34
+ ---
35
+
36
+ ## Task 1: Pure caps primitives (`src/caps.ts`) + test suite scaffold
37
+
38
+ **Files:**
39
+ - Create: `src/caps.ts`
40
+ - Create: `test/todo-caps.test.mts`
41
+ - Modify: `package.json` (the `test` script — add `todo-caps` to the enumeration)
42
+
43
+ **Interfaces:**
44
+ - Consumes: `TodoError`, `Todo` from `./todo-store.ts`; `ProjectRegistry` (type only) from `./registry.ts`.
45
+ - Produces: `checkNotesCap(notes: string, maxBytes: number): void`, `checkProjectCap(opts: { project: string; currentOpen: number; maxOpen: number | null }): void`, `overBudgetProjects(liveTodos: Todo[], registry: ProjectRegistry): OverBudgetProject[]`, type `OverBudgetProject { name: string; open: number; maxOpen: number }`.
46
+
47
+ - [ ] **Step 1: Write `src/caps.ts`**
48
+
49
+ ```ts
50
+ // Caps enforcement primitives for armory-todo (v0.5.0). Pure — no disk I/O,
51
+ // no config/registry loads. Callers (addTodo/updateTodo/renderOpenBlock) load
52
+ // state and pass it in, so these are unit-testable in isolation.
53
+ //
54
+ // Two caps:
55
+ // - notes : per-todo byte ceiling (health.maxNotesBytes), hard-reject at write.
56
+ // - project: per-project open-count ceiling (registry maxOpen), hard-reject
57
+ // on add + project-move (only for open/in_progress todos).
58
+ // Both throw TodoError BEFORE any store mutation (callers ensure atomicity).
59
+ //
60
+ // Circular import note: caps.ts imports TodoError/Todo (types) from
61
+ // todo-store.ts; todo-store.ts imports the cap functions. Safe — no module
62
+ // touches another's exports at top level; all usage is inside functions, so
63
+ // both are fully loaded by call-time.
64
+
65
+ import { TodoError, type Todo } from "./todo-store.ts";
66
+ import type { ProjectRegistry } from "./registry.ts";
67
+
68
+ /** Human-readable byte size for error messages: 512 → "512B", 2048 → "2.0KB". */
69
+ function formatBytes(n: number): string {
70
+ if (n < 1024) return `${n}B`;
71
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
72
+ return `${(n / (1024 * 1024)).toFixed(1)}MB`;
73
+ }
74
+
75
+ /** Throw if notes exceeds the byte cap. Byte-length (not char-length): notes
76
+ * can hold Unicode ("é" = 2 bytes UTF-8). A maxBytes of 0 means "no notes
77
+ * allowed" (only empty notes pass). Negative maxBytes rejects everything
78
+ * (treated as a misconfig; config load clamps negative/NaN to the default). */
79
+ export function checkNotesCap(notes: string, maxBytes: number): void {
80
+ const bytes = Buffer.byteLength(notes, "utf8");
81
+ if (bytes > maxBytes) {
82
+ throw new TodoError(
83
+ `notes ${formatBytes(bytes)} > max ${formatBytes(maxBytes)} (maxNotesBytes ${maxBytes}) — trim the detail or split into multiple todos`,
84
+ );
85
+ }
86
+ }
87
+
88
+ export interface ProjectCapInput {
89
+ project: string; // target project name (already trimmed by caller)
90
+ currentOpen: number; // target's current open count, NOT counting the would-be-added/moved todo
91
+ maxOpen: number | null; // from the registry entry; null = uncapped
92
+ }
93
+
94
+ /** Throw if adding one more open todo to `project` would exceed its cap.
95
+ * `maxOpen === null` → no-op (uncapped). The cap is on the `open` count only
96
+ * (matches the PROJECT_OVER health definition; in_progress does not count). */
97
+ export function checkProjectCap({ project, currentOpen, maxOpen }: ProjectCapInput): void {
98
+ if (maxOpen === null) return;
99
+ if (currentOpen + 1 > maxOpen) {
100
+ throw new TodoError(
101
+ `project '${project}' is at maxOpen ${maxOpen} (${currentOpen} open) — close/park one, or raise maxOpen via the /todo panel (Projects tab → Set maxOpen), before adding`,
102
+ );
103
+ }
104
+ }
105
+
106
+ export interface OverBudgetProject { name: string; open: number; maxOpen: number; }
107
+
108
+ /** Projects whose open count exceeds their explicit maxOpen (maxOpen non-null).
109
+ * Pure; consumed by renderOpenBlock's over-cap summary. `liveTodos` is the
110
+ * full live store array. Open is counted here (status === "open"). Sorted by
111
+ * breach depth (open - maxOpen) desc, then name asc. */
112
+ export function overBudgetProjects(liveTodos: Todo[], registry: ProjectRegistry): OverBudgetProject[] {
113
+ const out: OverBudgetProject[] = [];
114
+ for (const entry of registry.projects) {
115
+ if (entry.maxOpen === null) continue;
116
+ const open = liveTodos.filter((t) => t.project === entry.name && t.status === "open").length;
117
+ if (open > entry.maxOpen) out.push({ name: entry.name, open, maxOpen: entry.maxOpen });
118
+ }
119
+ return out.sort((a, b) => (b.open - b.maxOpen) - (a.open - a.maxOpen) || a.name.localeCompare(b.name));
120
+ }
121
+ ```
122
+
123
+ - [ ] **Step 2: Write `test/todo-caps.test.mts` (pure-function section only — integration tests land in Tasks 3 & 4)**
124
+
125
+ ```ts
126
+ // Suite for v0.5.0 caps enforcement (count + notes + injection truncation).
127
+ // Run: node test/todo-caps.test.mts
128
+ import { mkdtempSync, rmSync } from "node:fs";
129
+ import { tmpdir } from "node:os";
130
+ import { join } from "node:path";
131
+
132
+ let passed = 0;
133
+ let failed = 0;
134
+ function ok(name: string, cond: boolean, extra = ""): void {
135
+ if (cond) { passed++; } else { failed++; console.error(` ✗ ${name} ${extra}`); }
136
+ }
137
+ function eq<T>(name: string, got: T, want: T): void {
138
+ ok(name, got === want, `(got ${JSON.stringify(got)} want ${JSON.stringify(want)})`);
139
+ }
140
+ function throws(name: string, fn: () => void, expectSubstr = ""): void {
141
+ try { fn(); ok(name, false, "(did not throw)"); }
142
+ catch (e) {
143
+ const msg = (e as Error).message;
144
+ ok(name, expectSubstr === "" || msg.includes(expectSubstr), `(msg: ${msg})`);
145
+ }
146
+ }
147
+ function notThrows(name: string, fn: () => void): void {
148
+ try { fn(); ok(name, true); } catch (e) { ok(name, false, `((unexpected: ${(e as Error).message}))`); }
149
+ }
150
+
151
+ // Pure-function imports (no TODO_DIR needed for this section, but set it so
152
+ // later integration sections added in Tasks 3 & 4 can reuse the same tmp).
153
+ const tmp = mkdtempSync(join(tmpdir(), "armory-caps-"));
154
+ process.env.TODO_DIR = tmp;
155
+
156
+ const { checkNotesCap, checkProjectCap, overBudgetProjects } = await import("../src/caps.ts");
157
+ const { TodoError } = await import("../src/todo-store.ts");
158
+
159
+ // ===== checkNotesCap =====
160
+ notThrows("notes under cap ok", () => checkNotesCap("hello", 16));
161
+ throws("notes over cap throws", () => checkNotesCap("x".repeat(100), 50));
162
+ throws("notes over cap message has bytes", () => checkNotesCap("x".repeat(100), 50), "maxNotesBytes");
163
+ notThrows("notes exactly at cap ok", () => checkNotesCap("ab", 2));
164
+ throws("notes cap+1 throws", () => checkNotesCap("abc", 2));
165
+ // byte-length not char-length: "é" = 2 bytes UTF-8
166
+ notThrows("unicode under byte cap ok", () => checkNotesCap("é", 2));
167
+ throws("unicode over byte cap throws", () => checkNotesCap("é", 1));
168
+ // 0 = "no notes allowed": empty ok, any content rejected
169
+ notThrows("maxBytes 0 + empty notes ok", () => checkNotesCap("", 0));
170
+ notThrows("maxBytes 0 + whitespace-only trimmed-empty ok", () => checkNotesCap("", 0));
171
+ throws("maxBytes 0 + content throws", () => checkNotesCap("x", 0));
172
+ throws("negative maxBytes rejects all", () => checkNotesCap("x", -1));
173
+
174
+ // ===== checkProjectCap =====
175
+ notThrows("uncapped (maxOpen null) ok", () => checkProjectCap({ project: "pi", currentOpen: 100, maxOpen: null }));
176
+ notThrows("one-below cap ok (lands at cap, not over)", () => checkProjectCap({ project: "pi", currentOpen: 7, maxOpen: 8 }));
177
+ notThrows("zero open under cap ok", () => checkProjectCap({ project: "pi", currentOpen: 0, maxOpen: 1 }));
178
+ throws("at-cap add throws (currentOpen == maxOpen)", () => checkProjectCap({ project: "pi", currentOpen: 8, maxOpen: 8 }));
179
+ throws("over-cap add throws", () => checkProjectCap({ project: "pi", currentOpen: 12, maxOpen: 8 }));
180
+ throws("project cap message has raise hint", () => checkProjectCap({ project: "pi", currentOpen: 8, maxOpen: 8 }), "raise maxOpen");
181
+ throws("project cap message has project name", () => checkProjectCap({ project: "getpipher", currentOpen: 8, maxOpen: 8 }), "getpipher");
182
+ // maxOpen 0 = no open todos allowed
183
+ throws("maxOpen 0 + any add throws", () => checkProjectCap({ project: "pi", currentOpen: 0, maxOpen: 0 }));
184
+
185
+ // ===== overBudgetProjects =====
186
+ const { loadRegistry, saveRegistry } = await import("../src/registry.ts");
187
+ import type { Todo } from "../src/todo-store.ts";
188
+ const fresh = new Date().toISOString();
189
+ const mk = (id: string, project: string, status: Todo["status"]): Todo => ({
190
+ id, title: id, notes: "", project, tags: [], priority: "med", status, source: "",
191
+ createdAt: fresh, updatedAt: fresh, closedAt: null,
192
+ });
193
+ const liveTodos: Todo[] = [
194
+ mk("a", "pi", "open"), mk("b", "pi", "open"), mk("c", "pi", "open"),
195
+ mk("d", "sip", "open"), mk("e", "sip", "open"),
196
+ mk("f", "sip", "in_progress"), // in_progress does NOT count toward open
197
+ mk("g", "uncapped", "open"),
198
+ ];
199
+ saveRegistry({ version: 1, updatedAt: "x", projects: [
200
+ { name: "pi", maxOpen: 2, createdAt: "x", updatedAt: "x" }, // 3 open > 2 → over
201
+ { name: "sip", maxOpen: 8, createdAt: "x", updatedAt: "x" }, // 2 open ≤ 8 → ok
202
+ { name: "uncapped", maxOpen: null, createdAt: "x", updatedAt: "x" },
203
+ { name: "empty", maxOpen: 3, createdAt: "x", updatedAt: "x" }, // 0 open → not over
204
+ ] });
205
+ const reg = loadRegistry();
206
+ const over = overBudgetProjects(liveTodos, reg);
207
+ eq("overBudget count 1 (only pi)", over.length, 1);
208
+ eq("overBudget pi name", over[0]!.name, "pi");
209
+ eq("overBudget pi open", over[0]!.open, 3);
210
+ eq("overBudget pi maxOpen", over[0]!.maxOpen, 2);
211
+ // empty registry → none over
212
+ eq("overBudget empty registry", overBudgetProjects(liveTodos, { version: 1, updatedAt: "x", projects: [] }).length, 0);
213
+
214
+ rmSync(tmp, { recursive: true, force: true });
215
+ console.log(`\n${passed} passed, ${failed} failed`);
216
+ if (failed > 0) process.exit(1);
217
+ ```
218
+
219
+ - [ ] **Step 3: Add `todo-caps` to the `npm test` script in `package.json`**
220
+
221
+ In `package.json`, the `scripts.test` string enumerates suites. Add `todo-caps` to the loop (order doesn't matter; place it after `panel-data` to keep alphabetical-ish grouping):
222
+
223
+ ```json
224
+ "test": "for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-prune todo-auto-prune registry projects panel-data todo-caps; do node test/$t.test.mts || exit 1; done"
225
+ ```
226
+
227
+ - [ ] **Step 4: Run the new suite — verify it passes**
228
+
229
+ Run: `node test/todo-caps.test.mts`
230
+ Expected: all pure-function tests PASS (the integration sections are added in Tasks 3 & 4).
231
+
232
+ - [ ] **Step 5: Run the full suite — verify nothing regressed**
233
+
234
+ Run: `npm test`
235
+ Expected: all 12 suites green (331 prior + the new pure tests).
236
+
237
+ - [ ] **Step 6: Commit**
238
+
239
+ ```bash
240
+ git add src/caps.ts test/todo-caps.test.mts package.json
241
+ git commit -m "feat(caps): pure enforcement primitives (checkNotesCap, checkProjectCap, overBudgetProjects)"
242
+ ```
243
+
244
+ ---
245
+
246
+ ## Task 2: `health.maxNotesBytes` config field
247
+
248
+ **Files:**
249
+ - Modify: `src/config.ts` (the `HealthConfig` interface, `DEFAULT_CONFIG.health`, and the `loadConfig` merge)
250
+ - Modify: `test/todo-config.test.mts`
251
+
252
+ **Interfaces:**
253
+ - Consumes: nothing new.
254
+ - Produces: `HealthConfig.maxNotesBytes: number` (default `8192`), defensively merged in `loadConfig` (non-number / NaN / negative → default; `0` respected).
255
+
256
+ - [ ] **Step 1: Add the failing tests to `test/todo-config.test.mts`**
257
+
258
+ Append (before the final `rmSync`/summary block) — these assert the default + forward-merge + defensive clamp:
259
+
260
+ ```ts
261
+ // --- v0.5.0: maxNotesBytes ---
262
+ eq("default maxNotesBytes 8192", DEFAULT_CONFIG.health.maxNotesBytes, 8192);
263
+ eq("loadConfig maxNotesBytes default", loadConfig().health.maxNotesBytes, 8192);
264
+
265
+ // forward-merge: an old config (no maxNotesBytes) gets the default
266
+ writeFileSync(join(tmp, "todo.config.json"), JSON.stringify({
267
+ version: 1,
268
+ prune: { defaultAgeDays: 7, hardAgeDays: 180, statuses: ["done", "cancelled"] },
269
+ health: { activeMaxOpen: 15, activeStaleDays: 30, parkedMax: 10, parkedStaleDays: 60, archiveMax: 200, archiveOldDays: 180, perProjectDefaultMax: 8 },
270
+ }, null, 2));
271
+ const mergedOld = loadConfig();
272
+ eq("old config (no maxNotesBytes) → default 8192", mergedOld.health.maxNotesBytes, 8192);
273
+
274
+ // explicit value respected
275
+ saveConfig({ ...mergedOld, health: { ...mergedOld.health, maxNotesBytes: 4096 } });
276
+ eq("explicit maxNotesBytes 4096 respected", loadConfig().health.maxNotesBytes, 4096);
277
+
278
+ // 0 respected (strict no-notes)
279
+ saveConfig({ ...mergedOld, health: { ...mergedOld.health, maxNotesBytes: 0 } });
280
+ eq("maxNotesBytes 0 respected", loadConfig().health.maxNotesBytes, 0);
281
+
282
+ // negative → default
283
+ writeFileSync(join(tmp, "todo.config.json"), JSON.stringify({
284
+ version: 1,
285
+ prune: { defaultAgeDays: 7, hardAgeDays: 180, statuses: ["done", "cancelled"] },
286
+ health: { activeMaxOpen: 15, activeStaleDays: 30, parkedMax: 10, parkedStaleDays: 60, archiveMax: 200, archiveOldDays: 180, perProjectDefaultMax: 8, maxNotesBytes: -5 },
287
+ }, null, 2));
288
+ eq("negative maxNotesBytes → default 8192", loadConfig().health.maxNotesBytes, 8192);
289
+
290
+ // non-number (NaN) → default
291
+ writeFileSync(join(tmp, "todo.config.json"), JSON.stringify({
292
+ version: 1,
293
+ prune: { defaultAgeDays: 7, hardAgeDays: 180, statuses: ["done", "cancelled"] },
294
+ health: { activeMaxOpen: 15, activeStaleDays: 30, parkedMax: 10, parkedStaleDays: 60, archiveMax: 200, archiveOldDays: 180, perProjectDefaultMax: 8, maxNotesBytes: "big" },
295
+ }, null, 2));
296
+ eq("non-number maxNotesBytes → default 8192", loadConfig().health.maxNotesBytes, 8192);
297
+ ```
298
+
299
+ - [ ] **Step 2: Run the tests — verify they fail**
300
+
301
+ Run: `node test/todo-config.test.mts`
302
+ Expected: FAIL — `DEFAULT_CONFIG.health.maxNotesBytes` is `undefined`, not `8192`.
303
+
304
+ - [ ] **Step 3: Implement — add the field to `src/config.ts`**
305
+
306
+ In the `HealthConfig` interface, after `perProjectDefaultMax`:
307
+
308
+ ```ts
309
+ perProjectDefaultMax: number; // v0.4.0: per-project PROJECT_LARGE threshold (advisory)
310
+ maxNotesBytes: number; // v0.5.0: per-todo notes byte cap (hard-reject at add/update)
311
+ ```
312
+
313
+ In `DEFAULT_CONFIG.health`, after `perProjectDefaultMax: 8,`:
314
+
315
+ ```ts
316
+ perProjectDefaultMax: 8,
317
+ maxNotesBytes: 8192,
318
+ ```
319
+
320
+ In `loadConfig`, after the `perProjectDefaultMax` default-fill line:
321
+
322
+ ```ts
323
+ if (health.perProjectDefaultMax === undefined) health.perProjectDefaultMax = DEFAULT_CONFIG.health.perProjectDefaultMax;
324
+ if (health.maxNotesBytes === undefined || typeof health.maxNotesBytes !== "number" || Number.isNaN(health.maxNotesBytes) || health.maxNotesBytes < 0) {
325
+ health.maxNotesBytes = DEFAULT_CONFIG.health.maxNotesBytes;
326
+ }
327
+ ```
328
+
329
+ - [ ] **Step 4: Run the tests — verify they pass**
330
+
331
+ Run: `node test/todo-config.test.mts`
332
+ Expected: PASS (all, including the new 7 assertions).
333
+
334
+ - [ ] **Step 5: Run the full suite**
335
+
336
+ Run: `npm test`
337
+ Expected: all green.
338
+
339
+ - [ ] **Step 6: Commit**
340
+
341
+ ```bash
342
+ git add src/config.ts test/todo-config.test.mts
343
+ git commit -m "feat(config): health.maxNotesBytes (default 8192, defensive merge)"
344
+ ```
345
+
346
+ ---
347
+
348
+ ## Task 3: `addTodo` + `updateTodo` enforce caps
349
+
350
+ **Files:**
351
+ - Modify: `src/todo-store.ts` (imports, `addTodo`, `updateTodo`)
352
+ - Modify: `test/todo-caps.test.mts` (append an integration section)
353
+
354
+ **Interfaces:**
355
+ - Consumes: `checkNotesCap`, `checkProjectCap` from `./caps.ts`; `loadConfig` from `./config.ts`; `loadRegistry`, `getProjectEntry` from `./registry.ts`.
356
+ - Produces: `addTodo`/`updateTodo` now throw `TodoError` on a cap breach **before** any mutation.
357
+
358
+ - [ ] **Step 1: Append the failing integration tests to `test/todo-caps.test.mts`**
359
+
360
+ Insert **before** the final `rmSync`/summary block. These use the real disk store under the temp `TODO_DIR`:
361
+
362
+ ```ts
363
+ // ===== addTodo / updateTodo enforcement (integration, temp TODO_DIR) =====
364
+ const { addTodo, updateTodo, getTodo } = await import("../src/todo-store.ts");
365
+ const { setProjectMaxOpen, saveRegistry, loadRegistry: loadReg } = await import("../src/registry.ts");
366
+
367
+ // reset store + registry between sub-sections
368
+ const { saveStore, loadStore: loadStoreFn } = await import("../src/todo-store.ts");
369
+ function resetStore(): void { saveStore({ version: 3, updatedAt: new Date().toISOString(), todos: [] }); saveRegistry({ version: 1, updatedAt: "x", projects: [] }); }
370
+ function setCap(project: string, max: number | null): void { const r = loadReg(); setProjectMaxOpen(r, project, max); saveRegistry(r); }
371
+
372
+ // --- notes cap on add ---
373
+ resetStore();
374
+ throws("add with oversized notes throws", () => addTodo({ title: "big", notes: "x".repeat(9000) }), "maxNotesBytes");
375
+ eq("oversized add did not persist (atomic)", loadStoreFn().todos.length, 0);
376
+ notThrows("add with under-cap notes ok", () => addTodo({ title: "ok", notes: "x".repeat(100) }));
377
+ // default cap is 8192; exactly 8192 ok, 8193 throws
378
+ resetStore();
379
+ notThrows("add notes exactly 8192 bytes ok", () => addTodo({ title: "edge", notes: "x".repeat(8192) }));
380
+ resetStore();
381
+ throws("add notes 8193 bytes throws", () => addTodo({ title: "edge", notes: "x".repeat(8193) }));
382
+
383
+ // --- notes cap on update (only when notes patch present) ---
384
+ resetStore();
385
+ const big = addTodo({ title: "seeded big", notes: "y".repeat(9000) }); // grandfathered BEFORE cap? No — add would throw.
386
+ // To test grandfathering, seed directly via saveStore bypassing addTodo:
387
+ saveStore({ version: 3, updatedAt: new Date().toISOString(), todos: [{ id: "legacy", title: "legacy", notes: "z".repeat(9000), project: "", tags: [], priority: "med", status: "open", source: "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), closedAt: null }] });
388
+ const legacy = getTodo("legacy");
389
+ eq("grandfathered oversize note present", legacy.notes.length, 9000);
390
+ // editing TITLE only (no notes patch) must NOT re-check notes → succeeds
391
+ notThrows("title edit on grandfathered note ok (no notes re-check)", () => updateTodo("legacy", { title: "new title" }));
392
+ eq("title changed", getTodo("legacy").title, "new title");
393
+ eq("grandfathered notes intact", getTodo("legacy").notes.length, 9000);
394
+ // editing notes (oversize) throws
395
+ throws("update notes oversize throws", () => updateTodo("legacy", { notes: "q".repeat(9000) }), "maxNotesBytes");
396
+ // notes="" always passes
397
+ notThrows("update notes empty clears ok", () => updateTodo("legacy", { notes: "" }));
398
+ eq("notes cleared", getTodo("legacy").notes, "");
399
+
400
+ // --- project cap on add ---
401
+ resetStore();
402
+ setCap("pi", 2);
403
+ notThrows("add #1 to pi (open 0→1) ok", () => addTodo({ title: "p1", project: "pi" }));
404
+ notThrows("add #2 to pi (open 1→2, lands at cap) ok", () => addTodo({ title: "p2", project: "pi" }));
405
+ throws("add #3 to pi (open 2→3 > maxOpen 2) throws", () => addTodo({ title: "p3", project: "pi" }), "maxOpen");
406
+ eq("blocked add not persisted (atomic)", loadStoreFn().todos.filter((t) => t.project === "pi").length, 2);
407
+ // uncapped project always ok
408
+ notThrows("add to uncapped project ok", () => addTodo({ title: "x", project: "other" }));
409
+ // new/unknown project → uncapped (no registry entry) → ok
410
+ resetStore();
411
+ notThrows("add to unknown project (no cap) ok", () => addTodo({ title: "fresh", project: "newproj" }));
412
+
413
+ // --- project cap on move (update project) ---
414
+ resetStore();
415
+ setCap("pi", 2);
416
+ setCap("sip", 1);
417
+ const m1 = addTodo({ title: "m1", project: "pi" }); // pi open=1
418
+ addTodo({ title: "m2", project: "pi" }); // pi open=2 (at cap)
419
+ // move an OPEN todo from pi into sip (sip at 0→1, under cap 1) → ok
420
+ notThrows("move open todo into under-cap target ok", () => updateTodo(m1.id, { project: "sip" }));
421
+ eq("sip now 1 open", loadStoreFn().todos.filter((t) => t.project === "sip" && t.status === "open").length, 1);
422
+ // now move another open todo from pi into sip (sip 1→2 > cap 1) → throws
423
+ const m2 = loadStoreFn().todos.find((t) => t.project === "pi" && t.status === "open")!;
424
+ throws("move open todo into at-cap target throws", () => updateTodo(m2.id, { project: "sip" }), "maxOpen");
425
+ // move a PARKED todo into at-cap target → ok (no open impact)
426
+ resetStore();
427
+ setCap("sip", 1);
428
+ addTodo({ title: "occ", project: "sip" }); // sip open=1 (at cap)
429
+ const pk = addTodo({ title: "pk", project: "pi" });
430
+ updateTodo(pk.id, { status: "parked" }); // park it (still in pi)
431
+ notThrows("move PARKED todo into at-cap target ok (no open impact)", () => updateTodo(pk.id, { project: "sip" }));
432
+ eq("parked move persisted to sip", loadStoreFn().todos.find((t) => t.id === pk.id)!.project, "sip");
433
+ // same-project "move" (no-op) → ok (no cap check)
434
+ resetStore();
435
+ setCap("pi", 1);
436
+ const s = addTodo({ title: "s", project: "pi" }); // pi at cap 1
437
+ notThrows("update same project (no-op) ok", () => updateTodo(s.id, { project: "pi" }));
438
+ // un-park (parked→open) into a capped project → NOT blocked (intentional)
439
+ resetStore();
440
+ setCap("pi", 1);
441
+ const u = addTodo({ title: "u", project: "pi" }); // pi at cap 1
442
+ updateTodo(u.id, { status: "parked" }); // pi open=0
443
+ addTodo({ title: "u2", project: "pi" }); // pi open=1 (at cap again)
444
+ notThrows("un-park into capped project ok (reactivation not blocked)", () => updateTodo(u.id, { status: "open" }));
445
+ eq("un-park persisted (now 2 open, over cap — allowed)", loadStoreFn().todos.filter((t) => t.project === "pi" && t.status === "open").length, 2);
446
+ ```
447
+
448
+ - [ ] **Step 2: Run the tests — verify they fail**
449
+
450
+ Run: `node test/todo-caps.test.mts`
451
+ Expected: FAIL — the new integration section (`add with oversized notes throws`, `add #3 to pi throws`, etc.) fail because `addTodo`/`updateTodo` don't enforce caps yet.
452
+
453
+ - [ ] **Step 3: Implement — add imports to `src/todo-store.ts`**
454
+
455
+ At the top, after the existing imports (`getLivePath, getTodoDir, getLegacyPath` from `./paths.ts` and `migrateIfNeeded, migrateV2ToV3` from `./migrate.ts`), add:
456
+
457
+ ```ts
458
+ import { loadConfig } from "./config.ts";
459
+ import { loadRegistry, getProjectEntry } from "./registry.ts";
460
+ import { checkNotesCap, checkProjectCap } from "./caps.ts";
461
+ ```
462
+
463
+ - [ ] **Step 4: Implement — cap checks in `addTodo`**
464
+
465
+ In `addTodo`, replace the block:
466
+
467
+ ```ts
468
+ const title = normalizeTitle(input.title);
469
+ if (input.priority) assertPriority(input.priority);
470
+ const notes = (input.notes ?? "").trim();
471
+ const store = loadStore();
472
+ ```
473
+
474
+ with:
475
+
476
+ ```ts
477
+ const title = normalizeTitle(input.title);
478
+ if (input.priority) assertPriority(input.priority);
479
+ const notes = (input.notes ?? "").trim();
480
+ const store = loadStore();
481
+ // v0.5.0 caps — checked BEFORE any mutation (atomic: nothing is written on breach).
482
+ const config = loadConfig();
483
+ checkNotesCap(notes, config.health.maxNotesBytes);
484
+ const projectTrimmed = (input.project ?? "").trim();
485
+ if (projectTrimmed !== "") {
486
+ const reg = loadRegistry();
487
+ const entry = getProjectEntry(reg, projectTrimmed);
488
+ const maxOpen = entry?.maxOpen ?? null;
489
+ if (maxOpen !== null) {
490
+ const currentOpen = store.todos.filter((t) => t.project === projectTrimmed && t.status === "open").length;
491
+ checkProjectCap({ project: projectTrimmed, currentOpen, maxOpen });
492
+ }
493
+ }
494
+ ```
495
+
496
+ - [ ] **Step 5: Implement — cap checks in `updateTodo`**
497
+
498
+ In `updateTodo`, after `const todo = findOrFail(store, id);` and before `if (patch.title !== undefined) ...`, insert:
499
+
500
+ ```ts
501
+ // v0.5.0 caps — checked BEFORE any mutation (atomic). Notes re-checked only
502
+ // when notes is being written (so a title edit on a grandfathered oversize
503
+ // note isn't trapped). Project cap re-checked only on a real move of an
504
+ // open/in_progress todo (un-park is intentionally NOT re-checked).
505
+ if (patch.notes !== undefined) {
506
+ checkNotesCap(patch.notes.trim(), loadConfig().health.maxNotesBytes);
507
+ }
508
+ if (patch.project !== undefined) {
509
+ const target = patch.project.trim();
510
+ if (target !== todo.project && (todo.status === "open" || todo.status === "in_progress") && target !== "") {
511
+ const reg = loadRegistry();
512
+ const entry = getProjectEntry(reg, target);
513
+ const maxOpen = entry?.maxOpen ?? null;
514
+ if (maxOpen !== null) {
515
+ const currentOpen = store.todos.filter((t) => t.project === target && t.status === "open" && t.id !== todo.id).length;
516
+ checkProjectCap({ project: target, currentOpen, maxOpen });
517
+ }
518
+ }
519
+ }
520
+ ```
521
+
522
+ - [ ] **Step 6: Run the tests — verify they pass**
523
+
524
+ Run: `node test/todo-caps.test.mts`
525
+ Expected: PASS (pure section from Task 1 + the new integration section).
526
+
527
+ - [ ] **Step 7: Run the full suite**
528
+
529
+ Run: `npm test`
530
+ Expected: all green. (Watch `todo-title-notes` + `todo-store` — they add todos with default projects; `maxOpen` null everywhere so no project cap fires; notes under 8192 so notes cap doesn't fire. Should be unaffected.)
531
+
532
+ - [ ] **Step 8: Commit**
533
+
534
+ ```bash
535
+ git add src/todo-store.ts test/todo-caps.test.mts
536
+ git commit -m "feat(store): enforce notes + project caps in addTodo/updateTodo (block-on-add)"
537
+ ```
538
+
539
+ ---
540
+
541
+ ## Task 4: Cap-aware `renderOpenBlock` (over-budget summary)
542
+
543
+ **Files:**
544
+ - Modify: `src/todo-store.ts` (imports + `renderOpenBlock`)
545
+ - Modify: `test/todo-caps.test.mts` (append a `renderOpenBlock` section)
546
+
547
+ **Interfaces:**
548
+ - Consumes: `loadConfig` (already imported in Task 3), `loadRegistry` + `overBudgetProjects` from `./registry.ts` / `./caps.ts`.
549
+ - Produces: `renderOpenBlock(max?: number)` — when `actionable > activeMaxOpen`, returns a 3–4 line lean summary instead of the row list; the `max` param overrides `activeMaxOpen` (for tests).
550
+
551
+ - [ ] **Step 1: Append the failing `renderOpenBlock` tests to `test/todo-caps.test.mts`**
552
+
553
+ Insert before the final `rmSync`/summary block:
554
+
555
+ ```ts
556
+ // ===== renderOpenBlock cap-aware truncation =====
557
+ const { renderOpenBlock } = await import("../src/todo-store.ts");
558
+ const { loadConfig, saveConfig } = await import("../src/config.ts");
559
+
560
+ resetStore();
561
+ // under cap → row list, no summary, no "… +N more"
562
+ saveConfig({ ...loadConfig(), health: { ...loadConfig().health, activeMaxOpen: 15 } });
563
+ for (let i = 0; i < 3; i++) addTodo({ title: `t${i}`, project: "pi" });
564
+ const under = renderOpenBlock();
565
+ ok("under cap: header present", under.startsWith("## Open TODOs (3)"));
566
+ ok("under cap: row list (no summary)", under.includes("- [td-"));
567
+ ok("under cap: no over-budget marker", !under.includes("over budget"));
568
+
569
+ // over cap → summary mode
570
+ resetStore();
571
+ saveConfig({ ...loadConfig(), health: { ...loadConfig().health, activeMaxOpen: 2 } });
572
+ setCap("pi", 1);
573
+ addTodo({ title: "a", project: "pi" });
574
+ addTodo({ title: "b", project: "pi" }); // pi open=2 > maxOpen 1 → over-budget project
575
+ addTodo({ title: "c", project: "pi" }); // would exceed pi cap 1? pi at 1 → #2 blocked. So use uncapped for c.
576
+ // (c must go to an uncapped project to seed 3 actionable total > activeMaxOpen 2)
577
+ resetStore();
578
+ saveConfig({ ...loadConfig(), health: { ...loadConfig().health, activeMaxOpen: 2 } });
579
+ setCap("pi", 1);
580
+ addTodo({ title: "a", project: "pi" }); // pi open=1 (at cap)
581
+ addTodo({ title: "b", project: "other" }); // other uncapped
582
+ addTodo({ title: "c", project: "other" }); // 3 actionable total > activeMaxOpen 2 → over budget
583
+ // make pi over its own cap: bump pi to 2 via direct store seed (add would throw)
584
+ saveStore({ version: 3, updatedAt: new Date().toISOString(), todos: [
585
+ ...loadStoreFn().todos,
586
+ { id: "td-extra", title: "extra", notes: "", project: "pi", tags: [], priority: "med", status: "open", source: "", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), closedAt: null },
587
+ ] });
588
+ const over = renderOpenBlock();
589
+ ok("over cap: header has over-budget marker", over.includes("over budget (cap 2)"));
590
+ ok("over cap: has actionable count line", over.includes("open+in_progress"));
591
+ ok("over cap: over-budget projects listed (pi 2/1)", over.includes("pi 2/1"));
592
+ ok("over cap: has pointer line", over.includes("todo list") || over.includes("/todo"));
593
+ ok("over cap: no row list (no - [td-)", !over.includes("- [td-"));
594
+
595
+ // over global cap but NO project over its own cap → summary without over-budget line
596
+ resetStore();
597
+ saveConfig({ ...loadConfig(), health: { ...loadConfig().health, activeMaxOpen: 1 } });
598
+ addTodo({ title: "a", project: "pi" }); // pi uncapped (no setCap)
599
+ addTodo({ title: "b", project: "pi" }); // 2 actionable > activeMaxOpen 1, but pi has no maxOpen
600
+ const overNoProj = renderOpenBlock();
601
+ ok("over global, no per-project breach: over-budget header", overNoProj.includes("over budget (cap 1)"));
602
+ ok("over global, no per-project breach: no 'over-budget:' line", !overNoProj.includes("over-budget:"));
603
+
604
+ // custom max param overrides activeMaxOpen
605
+ resetStore();
606
+ saveConfig({ ...loadConfig(), health: { ...loadConfig().health, activeMaxOpen: 50 } });
607
+ for (let i = 0; i < 5; i++) addTodo({ title: `t${i}` });
608
+ const viaParam = renderOpenBlock(3); // 5 actionable > 3 → summary
609
+ ok("custom max param triggers summary", viaParam.includes("over budget (cap 3)"));
610
+ const viaParamUnder = renderOpenBlock(10); // 5 ≤ 10 → row list
611
+ ok("custom max param under → row list", viaParamUnder.includes("- [td-"));
612
+
613
+ // empty store → unchanged
614
+ resetStore();
615
+ eq("empty store render", renderOpenBlock(), "## Open TODOs\n(none — no pending cross-session TODOs)\n");
616
+ ```
617
+
618
+ - [ ] **Step 2: Run the tests — verify they fail**
619
+
620
+ Run: `node test/todo-caps.test.mts`
621
+ Expected: FAIL — the over-cap cases still render the row list + "… +N more" (current behavior), not the summary.
622
+
623
+ - [ ] **Step 3: Implement — update imports in `src/todo-store.ts`**
624
+
625
+ Extend the `caps.ts` import to include `overBudgetProjects` and the type, and add the registry import (already added in Task 3). Change the import line from Task 3:
626
+
627
+ ```ts
628
+ import { checkNotesCap, checkProjectCap } from "./caps.ts";
629
+ ```
630
+
631
+ to:
632
+
633
+ ```ts
634
+ import { checkNotesCap, checkProjectCap, overBudgetProjects } from "./caps.ts";
635
+ ```
636
+
637
+ (`loadRegistry` is already imported in Task 3.)
638
+
639
+ - [ ] **Step 4: Implement — rewrite `renderOpenBlock` in `src/todo-store.ts`**
640
+
641
+ Replace the entire existing `renderOpenBlock` function with:
642
+
643
+ ```ts
644
+ /** Compact markdown summary of open + in_progress TODOs for system-prompt
645
+ * injection. v0.5.0: cap-aware — when actionable > activeMaxOpen (from
646
+ * config, or the `max` override), switches to a lean summary (counts +
647
+ * over-budget projects + pointer) instead of the row list, keeping the
648
+ * prompt bounded when bloated. Under cap → the familiar row list (capped
649
+ * at activeMaxOpen rows). */
650
+ export function renderOpenBlock(max?: number): string {
651
+ const todos = listTodos(); // actionable set, sorted
652
+ if (todos.length === 0) return "## Open TODOs\n(none — no pending cross-session TODOs)\n";
653
+ let cap: number;
654
+ try { cap = max ?? loadConfig().health.activeMaxOpen; } catch { cap = 15; }
655
+ if (todos.length <= cap) {
656
+ const shown = todos.slice(0, cap);
657
+ const lines = shown.map((t) => {
658
+ const tag = t.project ? ` (${t.project})` : "";
659
+ const pin = t.status === "in_progress" ? " ⏵" : "";
660
+ const dot = t.notes.trim() ? " •" : "";
661
+ return `- [${t.id}] (${t.priority})${pin}${dot} ${t.title}${tag}`;
662
+ });
663
+ return `## Open TODOs (${todos.length})\n${lines.join("\n")}\n`;
664
+ }
665
+ // over budget → lean summary (the anti-bloat path)
666
+ let over: { name: string; open: number; maxOpen: number }[] = [];
667
+ try {
668
+ const reg = loadRegistry();
669
+ over = overBudgetProjects(loadStore().todos, reg);
670
+ } catch {
671
+ // fail-open: a bad registry shouldn't break injection
672
+ }
673
+ const projects = new Set(todos.map((t) => t.project.trim()).filter(Boolean));
674
+ const lines = [
675
+ `## Open TODOs (${todos.length}) — ⚠ over budget (cap ${cap})`,
676
+ `${todos.length} open+in_progress across ${projects.size} project${projects.size === 1 ? "" : "s"}`,
677
+ ];
678
+ if (over.length > 0) {
679
+ lines.push(`over-budget: ${over.map((p) => `${p.name} ${p.open}/${p.maxOpen}`).join(", ")}`);
680
+ }
681
+ lines.push("run `todo list` or `/todo` to see the full list");
682
+ return lines.join("\n") + "\n";
683
+ }
684
+ ```
685
+
686
+ - [ ] **Step 5: Run the tests — verify they pass**
687
+
688
+ Run: `node test/todo-caps.test.mts`
689
+ Expected: PASS (pure + add/update + renderOpenBlock sections).
690
+
691
+ - [ ] **Step 6: Run the full suite**
692
+
693
+ Run: `npm test`
694
+ Expected: all green. (The `todo-title-notes` suite calls `renderOpenBlock` with few todos — under cap, so it renders the row list as before. The `todo-auto-prune`/`todo-store` suites likewise stay under 15. No regressions expected.)
695
+
696
+ - [ ] **Step 7: Commit**
697
+
698
+ ```bash
699
+ git add src/todo-store.ts test/todo-caps.test.mts
700
+ git commit -m "feat(store): cap-aware renderOpenBlock — lean summary when over activeMaxOpen"
701
+ ```
702
+
703
+ ---
704
+
705
+ ## Task 5: `health` `NOTES_OVER` flag + `NotesBytes.maxId`
706
+
707
+ **Files:**
708
+ - Modify: `src/health.ts` (the `NotesBytes` interface, the `notesBytes` computation, the `HealthFlag` union, the flags + suggestions blocks)
709
+ - Modify: `test/todo-health.test.mts`
710
+
711
+ **Interfaces:**
712
+ - Consumes: `config.health.maxNotesBytes` (from Task 2).
713
+ - Produces: `NotesBytes.maxId: string | null`; `"NOTES_OVER"` in `HealthFlag`; a suggestion naming the offender id.
714
+
715
+ - [ ] **Step 1: Add the failing tests to `test/todo-health.test.mts`**
716
+
717
+ The existing health suite seeds a v2 store via `saveStore({ version: 2, ... text: ... })` and relies on the v2→v3 migration. For the NOTES_OVER test we need a todo with notes > maxNotesBytes. Append (before the final summary) a self-contained sub-section that sets its own store + config:
718
+
719
+ ```ts
720
+ // ===== v0.5.0: NOTES_OVER flag + maxId =====
721
+ import { saveConfig as saveCfg } from "../src/config.ts";
722
+ const tmp2 = mkdtempSync(join(tmpdir(), "armory-notes-"));
723
+ process.env.TODO_DIR = tmp2;
724
+ process.env.TODO_STORE_PATH = join(tmp2, "todo.json");
725
+ saveCfg({ version: 1, prune: { defaultAgeDays: 7, hardAgeDays: 180, statuses: ["done", "cancelled"] }, health: { activeMaxOpen: 15, activeStaleDays: 30, parkedMax: 10, parkedStaleDays: 60, archiveMax: 200, archiveOldDays: 180, perProjectDefaultMax: 8, maxNotesBytes: 100 } });
726
+ const bigNotes = "z".repeat(500);
727
+ const smallNotes = "y".repeat(20);
728
+ saveStore({ version: 3, updatedAt: fresh, todos: [
729
+ { id: "td-big", title: "big note todo", notes: bigNotes, project: "pi", tags: [], priority: "med", status: "open", source: "", createdAt: fresh, updatedAt: fresh, closedAt: null },
730
+ { id: "td-small", title: "small note todo", notes: smallNotes, project: "pi", tags: [], priority: "med", status: "open", source: "", createdAt: fresh, updatedAt: fresh, closedAt: null },
731
+ ] });
732
+ const rep2 = healthReport();
733
+ ok("NOTES_OVER flag present when max note > cap", rep2.flags.includes("NOTES_OVER"));
734
+ eq("notesBytes.max is the big note size", rep2.notesBytes.max, 500);
735
+ eq("notesBytes.maxId is the big todo", rep2.notesBytes.maxId, "td-big");
736
+ ok("NOTES_OVER suggestion names the offender id", rep2.suggestions.some((s) => s.includes("td-big") && s.includes("trim via todo update")));
737
+
738
+ // under cap → no NOTES_OVER
739
+ saveCfg({ version: 1, prune: { defaultAgeDays: 7, hardAgeDays: 180, statuses: ["done", "cancelled"] }, health: { activeMaxOpen: 15, activeStaleDays: 30, parkedMax: 10, parkedStaleDays: 60, archiveMax: 200, archiveOldDays: 180, perProjectDefaultMax: 8, maxNotesBytes: 8192 } });
740
+ const rep3 = healthReport();
741
+ ok("no NOTES_OVER when under cap", !rep3.flags.includes("NOTES_OVER"));
742
+ // maxId still tracked even when under cap (points to the biggest, which is td-big 500B)
743
+ eq("maxId tracked under cap (biggest note)", rep3.notesBytes.maxId, "td-big");
744
+
745
+ rmSync(tmp2, { recursive: true, force: true });
746
+ // restore the original suite's TODO_DIR for any trailing assertions
747
+ process.env.TODO_DIR = tmp;
748
+ process.env.TODO_STORE_PATH = join(tmp, "todo.json");
749
+ ```
750
+
751
+ - [ ] **Step 2: Run the tests — verify they fail**
752
+
753
+ Run: `node test/todo-health.test.mts`
754
+ Expected: FAIL — `rep2.notesBytes.maxId` is `undefined`; `NOTES_OVER` not in flags.
755
+
756
+ - [ ] **Step 3: Implement — extend `NotesBytes` + the computation in `src/health.ts`**
757
+
758
+ Change the `NotesBytes` interface:
759
+
760
+ ```ts
761
+ export interface NotesBytes {
762
+ total: number;
763
+ max: number;
764
+ maxId: string | null; // v0.5.0: id of the todo with the largest notes (null if no todos)
765
+ avg: number;
766
+ }
767
+ ```
768
+
769
+ Replace the `notesBytes` computation block:
770
+
771
+ ```ts
772
+ // notes bytes across active + parked (archived excluded — sealed history).
773
+ const apTodos = [...openTodos, ...ipTodos, ...parkedTodos];
774
+ const notesSizes = apTodos.map((t) => Buffer.byteLength(t.notes, "utf8"));
775
+ const notesBytes: NotesBytes = {
776
+ total: notesSizes.reduce((a, b) => a + b, 0),
777
+ max: notesSizes.length ? Math.max(...notesSizes) : 0,
778
+ avg: notesSizes.length ? Math.round(notesSizes.reduce((a, b) => a + b, 0) / notesSizes.length) : 0,
779
+ };
780
+ ```
781
+
782
+ with:
783
+
784
+ ```ts
785
+ // notes bytes across active + parked (archived excluded — sealed history).
786
+ // v0.5.0: track the worst-offender id so the NOTES_OVER suggestion is actionable.
787
+ const apTodos = [...openTodos, ...ipTodos, ...parkedTodos];
788
+ let maxId: string | null = null;
789
+ let maxSize = 0;
790
+ let totalBytes = 0;
791
+ for (const t of apTodos) {
792
+ const s = Buffer.byteLength(t.notes, "utf8");
793
+ totalBytes += s;
794
+ if (s > maxSize) { maxSize = s; maxId = t.id; }
795
+ }
796
+ const notesBytes: NotesBytes = {
797
+ total: totalBytes,
798
+ max: maxSize,
799
+ maxId: apTodos.length ? maxId : null,
800
+ avg: apTodos.length ? Math.round(totalBytes / apTodos.length) : 0,
801
+ };
802
+ ```
803
+
804
+ - [ ] **Step 4: Implement — add `NOTES_OVER` to the flag union + the flag/suggestion**
805
+
806
+ In the `HealthFlag` union, add `NOTES_OVER`:
807
+
808
+ ```ts
809
+ export type HealthFlag =
810
+ | "ACTIVE_LARGE" | "ACTIVE_STALE"
811
+ | "PARKED_LARGE" | "PARKED_STALE"
812
+ | "ARCHIVE_LARGE" | "ARCHIVE_OLD"
813
+ | "NOTES_OVER"
814
+ | "PROJECT_OVER" | "PROJECT_TYPO" | "PROJECT_LARGE" | "PROJECT_STALE";
815
+ ```
816
+
817
+ In the flags block, after the archive flags (`if (archiveOld > 0) flags.push("ARCHIVE_OLD");`), add:
818
+
819
+ ```ts
820
+ if (notesBytes.max > h.maxNotesBytes) flags.push("NOTES_OVER");
821
+ ```
822
+
823
+ In the suggestions block, after the `archiveOld` suggestion, add:
824
+
825
+ ```ts
826
+ if (notesBytes.max > h.maxNotesBytes) {
827
+ const id = notesBytes.maxId ?? "<id>";
828
+ suggestions.push(`notes: largest note ${notesBytes.max}B > cap ${h.maxNotesBytes}B (on ${id}) → trim via todo update ${id} notes:…`);
829
+ }
830
+ ```
831
+
832
+ - [ ] **Step 5: Run the tests — verify they pass**
833
+
834
+ Run: `node test/todo-health.test.mts`
835
+ Expected: PASS (all prior + the new NOTES_OVER assertions).
836
+
837
+ - [ ] **Step 6: Run the full suite**
838
+
839
+ Run: `npm test`
840
+ Expected: all green.
841
+
842
+ - [ ] **Step 7: Commit**
843
+
844
+ ```bash
845
+ git add src/health.ts test/todo-health.test.mts
846
+ git commit -m "feat(health): NOTES_OVER flag + NotesBytes.maxId (offender-aware suggestion)"
847
+ ```
848
+
849
+ ---
850
+
851
+ ## Task 6: `maxNotesBytes` config row in the panel
852
+
853
+ **Files:**
854
+ - Modify: `src/panel-data.ts` (`configToSettingItems`)
855
+ - Modify: `src/panel.ts` (the `configValueDisplay` + `applyConfigChange` switches)
856
+ - Modify: `test/panel-data.test.mts`
857
+
858
+ **Interfaces:**
859
+ - Consumes: `TodoConfig.health.maxNotesBytes` (from Task 2).
860
+ - Produces: a new editable Config row "Notes max bytes"; the panel getter/setter handle the `maxNotesBytes` id.
861
+
862
+ - [ ] **Step 1: Add the failing test to `test/panel-data.test.mts`**
863
+
864
+ Append (before the final summary):
865
+
866
+ ```ts
867
+ // v0.5.0: maxNotesBytes config row
868
+ const { DEFAULT_CONFIG: DCFG } = await import("../src/config.ts");
869
+ const cfg = DCFG;
870
+ const items = configToSettingItems(cfg);
871
+ const row = items.find((i) => i.id === "maxNotesBytes");
872
+ ok("maxNotesBytes row present", row !== undefined);
873
+ ok("maxNotesBytes row label", row?.label === "Notes max bytes");
874
+ eq("maxNotesBytes row current value", row?.currentValue, "8192");
875
+ ok("maxNotesBytes row has value options", Array.isArray(row?.values) && row!.values.length > 0);
876
+ ```
877
+
878
+ - [ ] **Step 2: Run the test — verify it fails**
879
+
880
+ Run: `node test/panel-data.test.mts`
881
+ Expected: FAIL — no `maxNotesBytes` row.
882
+
883
+ - [ ] **Step 3: Implement — add the row to `src/panel-data.ts`**
884
+
885
+ In `configToSettingItems`, after the `archiveOldDays` row (the last existing row), append:
886
+
887
+ ```ts
888
+ { id: "archiveOldDays", label: "Archive old (days)", currentValue: String(cfg.health.archiveOldDays), values: ["90", "180", "365"], description: "Bloat flag when archive items older than this." },
889
+ { id: "maxNotesBytes", label: "Notes max bytes", currentValue: String(cfg.health.maxNotesBytes), values: ["2048", "4096", "8192", "16384", "32768"], description: "Hard reject at add/update when notes exceeds this (bytes). 0 = no notes allowed." },
890
+ ```
891
+
892
+ - [ ] **Step 4: Implement — handle the new id in `src/panel.ts`**
893
+
894
+ In `configValueDisplay`, add a case (after `archiveOldDays`):
895
+
896
+ ```ts
897
+ case "archiveOldDays": return String(c.health.archiveOldDays);
898
+ case "maxNotesBytes": return String(c.health.maxNotesBytes);
899
+ ```
900
+
901
+ In `applyConfigChange`, add a case (after `archiveOldDays`):
902
+
903
+ ```ts
904
+ case "archiveOldDays": this.config.health.archiveOldDays = n; break;
905
+ case "maxNotesBytes": this.config.health.maxNotesBytes = n; break;
906
+ ```
907
+
908
+ - [ ] **Step 5: Run the test — verify it passes**
909
+
910
+ Run: `node test/panel-data.test.mts`
911
+ Expected: PASS.
912
+
913
+ - [ ] **Step 6: Run the full suite**
914
+
915
+ Run: `npm test`
916
+ Expected: all green.
917
+
918
+ - [ ] **Step 7: Commit**
919
+
920
+ ```bash
921
+ git add src/panel-data.ts src/panel.ts test/panel-data.test.mts
922
+ git commit -m "feat(panel): editable maxNotesBytes config row"
923
+ ```
924
+
925
+ ---
926
+
927
+ ## Task 7: Extension surfaces — promptGuidelines + health render
928
+
929
+ **Files:**
930
+ - Modify: `extensions/todo.ts` (the `promptGuidelines` array — the `add`/`update` line + the `project_rename` line)
931
+ - Test: no new automated test (the extension is not unit-tested per the existing convention — verified via `node --check` + the autonomous tmux QA gate). The `health` action's `NOTES_OVER` flag flows through the generic `flags:` line automatically (no special rendering needed).
932
+
933
+ **Interfaces:**
934
+ - Consumes: the enforced caps from Tasks 2–4; `NOTES_OVER` from Task 5.
935
+ - Produces: accurate agent guidance (caps enforced, not "lands in v0.5.0"); `health` output reflects `NOTES_OVER` via the existing generic flag list + the new suggestion.
936
+
937
+ - [ ] **Step 1: Update the `add`/`update` promptGuidelines line**
938
+
939
+ In `extensions/todo.ts`, the `promptGuidelines` array, replace:
940
+
941
+ ```ts
942
+ "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.",
943
+ ```
944
+
945
+ with:
946
+
947
+ ```ts
948
+ "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 (capped at health.maxNotesBytes, default 8KB — oversize is rejected at write). Adds are BLOCKED if the target project is at its per-project maxOpen cap (the slot you set via the Projects tab); close/park one or raise maxOpen first.",
949
+ ```
950
+
951
+ - [ ] **Step 2: Update the `project_rename` promptGuidelines line**
952
+
953
+ Replace:
954
+
955
+ ```ts
956
+ "Use todo (action:'project_rename', oldName, newName) to rename or merge a project (rewrites live + archive + registry). Use it to fix typo'd project strings (e.g. getpither → getpipher). Rename onto an existing name merges (consolidates the old project into the new). Advisory maxOpen caps are NOT enforced in v0.4.0 — they only drive a health flag; enforcement lands in v0.5.0.",
957
+ ```
958
+
959
+ with:
960
+
961
+ ```ts
962
+ "Use todo (action:'project_rename', oldName, newName) to rename or merge a project (rewrites live + archive + registry). Use it to fix typo'd project strings (e.g. getpither → getpipher). Rename onto an existing name merges (consolidates the old project into the new). Per-project maxOpen caps are ENFORCED (block-on-add); they also drive a PROJECT_OVER health flag when breached.",
963
+ ```
964
+
965
+ - [ ] **Step 3: Verify syntax**
966
+
967
+ Run: `node --check extensions/todo.ts`
968
+ Expected: no output (syntax OK).
969
+
970
+ - [ ] **Step 4: Run the full suite (unchanged — no test changes here)**
971
+
972
+ Run: `npm test`
973
+ Expected: all green.
974
+
975
+ - [ ] **Step 5: Commit**
976
+
977
+ ```bash
978
+ git add extensions/todo.ts
979
+ git commit -m "feat(extension): promptGuidelines reflect enforced caps (v0.5.0)"
980
+ ```
981
+
982
+ ---
983
+
984
+ ## Task 8: README upgrade notes + version bump + final verification
985
+
986
+ **Files:**
987
+ - Modify: `README.md` (a new "Caps enforcement (v0.5.0)" section + fixes to stale lines)
988
+ - Modify: `package.json` (version `0.5.0`)
989
+
990
+ - [ ] **Step 1: Fix the stale "capped at 15" line in README**
991
+
992
+ In the "How it works → Auto-inject" section, replace:
993
+
994
+ ```md
995
+ - **Auto-inject** — on every `before_agent_start`, a compact `## Open TODOs (N)` block (titles + ids, capped at 15, sorted by priority) is appended to the system prompt, so the agent starts every turn already aware of pending work. Only `open` + `in_progress` are injected — `parked` and archived todos are excluded (the lifecycle-box boundary). Mutations refresh it on the next turn.
996
+ ```
997
+
998
+ with:
999
+
1000
+ ```md
1001
+ - **Auto-inject** — on every `before_agent_start`, a compact `## Open TODOs (N)` block (titles + ids, sorted by priority) is appended to the system prompt, so the agent starts every turn already aware of pending work. The block is **cap-aware** (v0.5.0): under `health.activeMaxOpen` (default 15) it lists the rows; **over** the cap it collapses to a lean summary (counts + over-budget projects + a `todo list` pointer) so the prompt stays bounded when the store bloats. Only `open` + `in_progress` are injected — `parked` and archived todos are excluded (the lifecycle-box boundary). Mutations refresh it on the next turn.
1002
+ ```
1003
+
1004
+ - [ ] **Step 2: Fix the stale "Advisory only in v0.4.0" line**
1005
+
1006
+ In the "Project-scope management (v0.4.0)" section, replace:
1007
+
1008
+ ```md
1009
+ **Advisory only in v0.4.0** — `maxOpen` drives a `health` flag, it does **not** block `add` (enforcement graduates in v0.5.0, alongside count + notes caps + over-cap injection truncation).
1010
+ ```
1011
+
1012
+ with:
1013
+
1014
+ ```md
1015
+ **Advisory in v0.4.0 → enforced in v0.5.0** — `maxOpen` now blocks `add` (and project-move) when a project is at its cap. See the [Caps enforcement (v0.5.0)](#caps-enforcement-v050) section below.
1016
+ ```
1017
+
1018
+ - [ ] **Step 3: Fix the stale "Known issues" line**
1019
+
1020
+ In "Known issues", replace:
1021
+
1022
+ ```md
1023
+ - **No caps enforcement yet (count / notes / injection).** v0.4.0's `maxOpen` slot is advisory only (drives a `health` flag); block-on-add for count + notes caps + over-cap injection truncation land in v0.5.0. Until then, `health` reports counts + notes-bytes as read-only diagnostics.
1024
+ ```
1025
+
1026
+ with:
1027
+
1028
+ ```md
1029
+ - **Caps enforcement shipped in v0.5.0.** Per-project `maxOpen` blocks `add`/move; `health.maxNotesBytes` (default 8KB) rejects oversize notes at write; the auto-injected block collapses to a lean summary over `activeMaxOpen`. See [Caps enforcement (v0.5.0)](#caps-enforcement-v050) above.
1030
+ ```
1031
+
1032
+ - [ ] **Step 4: Add the new "Caps enforcement (v0.5.0)" section**
1033
+
1034
+ Insert a new section immediately after the "Project-scope management (v0.4.0)" section (before "Interactive panel (SPEC-3)"):
1035
+
1036
+ ```md
1037
+ ## Caps enforcement (v0.5.0)
1038
+
1039
+ Three caps keep the store (and its auto-injected prompt block) from bloating silently — the forcing-function half of [issue #1](https://github.com/getpither/armory-todo/issues/1):
1040
+
1041
+ 1. **Count cap (per-project `maxOpen`, enforced).** A project's `maxOpen` slot (set via the Projects tab → Set maxOpen, or `setProjectMaxOpen`) **blocks `add`** when the project is at its cap, and **blocks a project-move** of an `open`/`in_progress` todo into a capped project. The cap is on the `open` count (matches the `PROJECT_OVER` health flag); `in_progress` doesn't count. Un-park (`parked→open`) is intentionally **not** blocked — reactivating deferred work isn't adding new work. The block message tells you how to raise/clear the cap. `maxOpen: null` (default) = uncapped.
1042
+
1043
+ 2. **Notes cap (`health.maxNotesBytes`, default 8192 bytes, enforced).** Oversize notes are rejected at `add`/`update` (only when `notes` is being written — a title edit on a grandfathered oversize note isn't trapped). Byte-length, not char-length (notes can hold Unicode). Existing oversize notes are grandfathered; `health` surfaces the worst offender via the `NOTES_OVER` flag + an actionable `todo update <id> notes:…` suggestion.
1044
+
1045
+ 3. **Over-cap injection truncation.** When actionable > `health.activeMaxOpen` (default 15), the auto-injected `## Open TODOs (N)` block collapses to a ~4-line summary (total + project span + over-budget projects + a `todo list` pointer) instead of the row list. Under the cap → the familiar row list. `activeMaxOpen` itself stays **advisory** (it drives the `ACTIVE_LARGE` flag and the truncation trigger; it is not a hard global block).
1046
+
1047
+ **Backwards-compat:** zero migration (store v3, config v1, registry v1 unchanged in shape). Oversize notes grandfathered. The `maxOpen` advisory→enforced graduation is a documented behavior change for any v0.4.0 user who set a slot (the block message tells them how to raise/clear).
1048
+ ```
1049
+
1050
+ - [ ] **Step 5: Bump the version in `package.json`**
1051
+
1052
+ Change `"version": "0.4.0"` to `"version": "0.5.0"`.
1053
+
1054
+ - [ ] **Step 6: Run the full suite + syntax checks**
1055
+
1056
+ Run: `npm test`
1057
+ Expected: all 12 suites green (~361+ total).
1058
+
1059
+ Run: `node --check extensions/todo.ts && node --check src/panel.ts && node --check src/todo-store.ts && node --check src/health.ts && node --check src/config.ts && node --check src/caps.ts && node --check src/panel-data.ts`
1060
+ Expected: no output (all syntax OK).
1061
+
1062
+ - [ ] **Step 7: Commit**
1063
+
1064
+ ```bash
1065
+ git add README.md package.json
1066
+ git commit -m "docs(v0.5.0): caps enforcement section + version bump"
1067
+ ```
1068
+
1069
+ - [ ] **Step 8: Final verification — push branch + open PR**
1070
+
1071
+ ```bash
1072
+ git push -u origin feat/caps-release
1073
+ gh pr create --base main --head feat/caps-release --title "v0.5.0: caps release (Feature B enforcement)" --body-file - <<'EOF'
1074
+ Graduates v0.4.0's advisory `maxOpen` slot into enforcement + adds a notes cap + cap-aware injection truncation — the forcing-function half of issue #1.
1075
+
1076
+ - **Count cap** — per-project `maxOpen` blocks `add` + project-move (open/in_progress only; un-park not blocked).
1077
+ - **Notes cap** — `health.maxNotesBytes` (default 8192B) rejects oversize notes at write; grandfathered existing; `NOTES_OVER` health flag + offender-id suggestion.
1078
+ - **Injection truncation** — `renderOpenBlock` collapses to a lean summary (counts + over-budget projects + pointer) when actionable > `activeMaxOpen`.
1079
+
1080
+ Zero migration (store v3 / config v1 / registry v1 unchanged in shape). 331 → ~361+ tests across 12 suites (new `todo-caps`). Spec: `docs/superpowers/specs/2026-07-21-caps-release-design.md`.
1081
+ EOF
1082
+ ```
1083
+
1084
+ Expected: PR opened. Then proceed to the self-review + autonomous tmux QA gate (per the spec §10 flow) before merge → tag `v0.5.0` → CI auto-publish.
1085
+
1086
+ ---
1087
+
1088
+ ## Self-Review (run after writing the plan — fix inline, don't re-review)
1089
+
1090
+ **1. Spec coverage:**
1091
+ - §4.1 caps.ts (checkNotesCap, checkProjectCap, overBudgetProjects) → Task 1 ✓
1092
+ - §4.2 config maxNotesBytes → Task 2 ✓
1093
+ - §4.3 addTodo/updateTodo enforcement → Task 3 ✓
1094
+ - §4.3 renderOpenBlock cap-aware + §4.4 summary shape → Task 4 ✓
1095
+ - §4.5 health NOTES_OVER + maxId → Task 5 ✓
1096
+ - §4.6 panel-data maxNotesBytes row → Task 6 ✓
1097
+ - §4.7 extensions promptGuidelines + health render → Task 7 ✓
1098
+ - §9 README upgrade notes + version bump → Task 8 ✓
1099
+ - §6 error handling (atomic, fail-open registry) → covered in Task 3/4 implementations ✓
1100
+ - §7 testing (new + extended suites) → Tasks 1, 2, 3, 4, 5, 6 ✓
1101
+
1102
+ **2. Placeholder scan:** no TBD/TODO/"implement later"/"add appropriate" — all steps contain actual code. ✓
1103
+
1104
+ **3. Type consistency:**
1105
+ - `checkNotesCap(notes: string, maxBytes: number)` — Task 1 def, Task 3 call ✓
1106
+ - `checkProjectCap({ project, currentOpen, maxOpen })` — Task 1 def, Task 3 call ✓
1107
+ - `overBudgetProjects(liveTodos, registry): { name, open, maxOpen }[]` — Task 1 def, Task 4 use ✓
1108
+ - `NotesBytes.maxId: string | null` — Task 5 def + test ✓
1109
+ - `HealthFlag` includes `NOTES_OVER` — Task 5 ✓
1110
+ - `HealthConfig.maxNotesBytes: number` — Task 2 ✓
1111
+ - panel getter/setter id `maxNotesBytes` matches the panel-data row id — Tasks 6 ✓
1112
+
1113
+ No gaps. Plan is complete.