@getpipher/armory-todo 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.
@@ -133,7 +133,7 @@ export default function (pi: ExtensionAPI) {
133
133
  "Never put secrets in a TODO — the text reaches the model provider.",
134
134
  promptSnippet: "Read/update the global cross-session TODO list (active / parked / archive) + bloat health",
135
135
  promptGuidelines: [
136
- "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.",
136
+ "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.",
137
137
  "Use todo (action:'get', id) to read a todo's full notes before acting on it (the bullet marker in lists means notes exist).",
138
138
  "Use todo (action:'update', id, title?, notes?, project?, tags?, priority?, status?) to edit; notes empty string clears.",
139
139
  "Use todo (action:'list') when the user asks 'show me the TODO' / 'what's pending' (text filter searches title+notes).",
@@ -146,7 +146,7 @@ export default function (pi: ExtensionAPI) {
146
146
  "Use todo (action:'health') to check bloat across all boxes (counts + flags + suggestions). Run when the user asks about hygiene/bloat or before any hard-prune.",
147
147
  "Use todo (action:'prune', hard:true, confirm:true, box?, olderThan?) for PERMANENT deletion (the only irreversible action). ALWAYS run health first, show the user the report + the exact proposed command, and wait for an explicit yes before passing confirm:true. Never hard-prune without explicit user confirmation.",
148
148
  "Use todo (action:'projects') for a per-project scope overview (open/in_progress/parked/done counts + maxOpen + OVER/?typo markers). Run when the user asks 'which projects have open work' or to see backlog shape by project.",
149
- "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.",
149
+ "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.",
150
150
  ],
151
151
  parameters: Type.Object({
152
152
  action: StringEnum(ACTIONS),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@getpipher/armory-todo",
3
- "version": "0.4.0",
4
- "description": "Global, cross-session TODO for pi persists across all sessions and is auto-injected into every prompt. The disk-backed counterpart to branch-scoped pi todo extensions.",
3
+ "version": "0.5.1",
4
+ "description": "Global, cross-session TODO for pi \u2014 persists across all sessions and is auto-injected into every prompt. The disk-backed counterpart to branch-scoped pi todo extensions.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",
@@ -35,7 +35,7 @@
35
35
  ]
36
36
  },
37
37
  "scripts": {
38
- "test": "for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-prune todo-auto-prune registry projects panel-data; do node test/$t.test.mts || exit 1; done"
38
+ "test": "for t in todo-store todo-title-notes todo-archive todo-config todo-migrate todo-health todo-hard-prune todo-auto-prune registry projects panel-data todo-caps todo-backup; do node test/$t.test.mts || exit 1; done"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@earendil-works/pi-ai": "*",
@@ -53,4 +53,4 @@
53
53
  "optional": true
54
54
  }
55
55
  }
56
- }
56
+ }
package/src/archive.ts CHANGED
@@ -12,6 +12,7 @@ import { migrateV2ToV3 } from "./migrate.ts";
12
12
  import type { Todo } from "./todo-store.ts";
13
13
  import { loadConfig } from "./config.ts";
14
14
  import { loadStore, saveStore, TodoError } from "./todo-store.ts";
15
+ import { backupFile, snapshotOnDrop, appendAudit, countTodosInFile } from "./backup.ts";
15
16
 
16
17
  export interface ArchiveStore {
17
18
  version: 3;
@@ -61,6 +62,11 @@ export function loadArchive(): ArchiveStore {
61
62
  export function saveArchive(store: ArchiveStore): void {
62
63
  store.updatedAt = now();
63
64
  const path = getArchivePath();
65
+ // v0.5.1 write-audit + backup (post data-loss hardening).
66
+ const before = countTodosInFile(path);
67
+ const after = store.todos.length;
68
+ backupFile(path);
69
+ const dropSnap = snapshotOnDrop(path, before, after);
64
70
  const dir = dirname(path);
65
71
  mkdirSync(dir, { recursive: true });
66
72
  const tmp = `${path}.tmp`;
@@ -71,6 +77,7 @@ export function saveArchive(store: ArchiveStore): void {
71
77
  // some filesystems ignore mode bits
72
78
  }
73
79
  renameSync(tmp, path);
80
+ appendAudit("archive", before, after, dropSnap);
74
81
  }
75
82
 
76
83
  export interface PruneInput {
package/src/backup.ts ADDED
@@ -0,0 +1,83 @@
1
+ // Write-audit + backup for armory-todo (v0.5.1 hardening, post v0.2.0 data-loss
2
+ // incident). Every saveStore / saveArchive now:
3
+ // 1. backs up the current file to <path>.bak (rolling previous version — the
4
+ // immediate recovery target);
5
+ // 2. if the save would DROP the todo count (after < before), also snapshots
6
+ // the pre-write file to <path>.bak-drop-<ts> (timestamped, never
7
+ // overwritten — the preserved pre-wipe state, the trap);
8
+ // 3. appends one compact line to <TODO_DIR>/todo-audit.log (counts only, no
9
+ // todo content — privacy-safe) flagging drops with ⚠ DROP.
10
+ //
11
+ // This means the v0.2.0-style "migration wiped the store" incident is now
12
+ // recoverable: the .bak-drop-<ts> holds the pre-wipe state, and the audit log
13
+ // names the moment + the count delta.
14
+ //
15
+ // Pure fs helpers (no pi imports) so they're unit-testable in isolation.
16
+
17
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, appendFileSync, statSync, readFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { getTodoDir } from "./paths.ts";
20
+
21
+ /** Copy the current file to <path>.bak (rolling previous version). No-op if the
22
+ * file doesn't exist yet (first-ever save). Returns true if a backup was made. */
23
+ export function backupFile(path: string): boolean {
24
+ if (!existsSync(path)) return false;
25
+ try {
26
+ copyFileSync(path, `${path}.bak`);
27
+ return true;
28
+ } catch {
29
+ // best-effort; a failed backup must not block the write
30
+ return false;
31
+ }
32
+ }
33
+
34
+ /** If a count drop is detected (after < before), snapshot the pre-write file to
35
+ * <path>.bak-drop-<ts> (preserved — never overwritten by the rolling .bak).
36
+ * Returns the snapshot path, or null if no snapshot was taken. */
37
+ export function snapshotOnDrop(path: string, before: number, after: number): string | null {
38
+ if (before <= after) return null; // no drop (growth or steady)
39
+ if (!existsSync(path)) return null; // nothing to snapshot
40
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
41
+ const snap = `${path}.bak-drop-${ts}`;
42
+ try {
43
+ copyFileSync(path, snap);
44
+ return snap;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** Append one compact audit line. Counts only (no todo content). Flags drops.
51
+ * `box` is "todo" or "archive" (which file was saved). */
52
+ export function appendAudit(box: "todo" | "archive", before: number, after: number, dropSnap: string | null): void {
53
+ try {
54
+ const dir = getTodoDir();
55
+ mkdirSync(dir, { recursive: true });
56
+ const logPath = join(dir, "todo-audit.log");
57
+ const ts = new Date().toISOString();
58
+ const delta = after - before;
59
+ const flag = after < before ? " [⚠ DROP]" : "";
60
+ const snap = dropSnap ? ` snap=${dropSnap.split("/").pop()}` : "";
61
+ appendFileSync(logPath, `${ts} save ${box}.json ${before}→${after} ${delta >= 0 ? "+" : ""}${delta}${flag}${snap}\n`, { encoding: "utf8" });
62
+ try { chmodSync(logPath, 0o600); } catch { /* fs may ignore mode */ }
63
+ } catch {
64
+ // best-effort; audit must not block the write
65
+ }
66
+ }
67
+
68
+ /** Count todos in an existing store file (for the before-count). Returns 0 if the
69
+ * file is missing/unreadable/not a valid store. */
70
+ export function countTodosInFile(path: string): number {
71
+ try {
72
+ if (!existsSync(path)) return 0;
73
+ const raw = statSync(path).size === 0 ? null : readJson(path);
74
+ if (!raw || !Array.isArray(raw.todos)) return 0;
75
+ return raw.todos.length;
76
+ } catch {
77
+ return 0;
78
+ }
79
+ }
80
+
81
+ function readJson(path: string): any {
82
+ return JSON.parse(readFileSync(path, "utf8"));
83
+ }
package/src/caps.ts ADDED
@@ -0,0 +1,71 @@
1
+ // Caps enforcement primitives for armory-todo (v0.5.0). Pure — no disk I/O,
2
+ // no config/registry loads. Callers (addTodo/updateTodo/renderOpenBlock) load
3
+ // state and pass it in, so these are unit-testable in isolation.
4
+ //
5
+ // Two caps:
6
+ // - notes : per-todo byte ceiling (health.maxNotesBytes), hard-reject at write.
7
+ // - project: per-project open-count ceiling (registry maxOpen), hard-reject
8
+ // on add + project-move (only for open/in_progress todos).
9
+ // Both throw TodoError BEFORE any store mutation (callers ensure atomicity).
10
+ //
11
+ // Circular import note: caps.ts imports TodoError/Todo (types) from
12
+ // todo-store.ts; todo-store.ts imports the cap functions. Safe — no module
13
+ // touches another's exports at top level; all usage is inside functions, so
14
+ // both are fully loaded by call-time.
15
+
16
+ import { TodoError, type Todo } from "./todo-store.ts";
17
+ import type { ProjectRegistry } from "./registry.ts";
18
+
19
+ /** Human-readable byte size for error messages: 512 -> "512B", 2048 -> "2.0KB". */
20
+ function formatBytes(n: number): string {
21
+ if (n < 1024) return `${n}B`;
22
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
23
+ return `${(n / (1024 * 1024)).toFixed(1)}MB`;
24
+ }
25
+
26
+ /** Throw if notes exceeds the byte cap. Byte-length (not char-length): notes
27
+ * can hold Unicode ("é" = 2 bytes UTF-8). A maxBytes of 0 means "no notes
28
+ * allowed" (only empty notes pass). Negative maxBytes rejects everything
29
+ * (treated as a misconfig; config load clamps negative/NaN to the default). */
30
+ export function checkNotesCap(notes: string, maxBytes: number): void {
31
+ const bytes = Buffer.byteLength(notes, "utf8");
32
+ if (bytes > maxBytes) {
33
+ throw new TodoError(
34
+ `notes ${formatBytes(bytes)} > max ${formatBytes(maxBytes)} (maxNotesBytes ${maxBytes}) — trim the detail or split into multiple todos`,
35
+ );
36
+ }
37
+ }
38
+
39
+ export interface ProjectCapInput {
40
+ project: string; // target project name (already trimmed by caller)
41
+ currentOpen: number; // target's current open count, NOT counting the would-be-added/moved todo
42
+ maxOpen: number | null; // from the registry entry; null = uncapped
43
+ }
44
+
45
+ /** Throw if adding one more open todo to `project` would exceed its cap.
46
+ * `maxOpen === null` -> no-op (uncapped). The cap is on the `open` count only
47
+ * (matches the PROJECT_OVER health definition; in_progress does not count). */
48
+ export function checkProjectCap({ project, currentOpen, maxOpen }: ProjectCapInput): void {
49
+ if (maxOpen === null) return;
50
+ if (currentOpen + 1 > maxOpen) {
51
+ throw new TodoError(
52
+ `project '${project}' is at maxOpen ${maxOpen} (${currentOpen} open) — close/park one, or raise maxOpen via the /todo panel (Projects tab -> Set maxOpen), before adding`,
53
+ );
54
+ }
55
+ }
56
+
57
+ export interface OverBudgetProject { name: string; open: number; maxOpen: number; }
58
+
59
+ /** Projects whose open count exceeds their explicit maxOpen (maxOpen non-null).
60
+ * Pure; consumed by renderOpenBlock's over-cap summary. `liveTodos` is the
61
+ * full live store array. Open is counted here (status === "open"). Sorted by
62
+ * breach depth (open - maxOpen) desc, then name asc. */
63
+ export function overBudgetProjects(liveTodos: Todo[], registry: ProjectRegistry): OverBudgetProject[] {
64
+ const out: OverBudgetProject[] = [];
65
+ for (const entry of registry.projects) {
66
+ if (entry.maxOpen === null) continue;
67
+ const open = liveTodos.filter((t) => t.project === entry.name && t.status === "open").length;
68
+ if (open > entry.maxOpen) out.push({ name: entry.name, open, maxOpen: entry.maxOpen });
69
+ }
70
+ return out.sort((a, b) => (b.open - b.maxOpen) - (a.open - a.maxOpen) || a.name.localeCompare(b.name));
71
+ }
package/src/config.ts CHANGED
@@ -25,6 +25,7 @@ export interface HealthConfig {
25
25
  archiveMax: number;
26
26
  archiveOldDays: number;
27
27
  perProjectDefaultMax: number; // v0.4.0: per-project PROJECT_LARGE threshold (advisory)
28
+ maxNotesBytes: number; // v0.5.0: per-todo notes byte cap (hard-reject at add/update)
28
29
  }
29
30
 
30
31
  export interface TodoConfig {
@@ -48,6 +49,7 @@ export const DEFAULT_CONFIG: TodoConfig = {
48
49
  archiveMax: 200,
49
50
  archiveOldDays: 180,
50
51
  perProjectDefaultMax: 8,
52
+ maxNotesBytes: 8192,
51
53
  },
52
54
  };
53
55
 
@@ -72,6 +74,9 @@ export function loadConfig(): TodoConfig {
72
74
  // Merge with defaults so new fields get filled in on upgrade.
73
75
  const health = { ...DEFAULT_CONFIG.health, ...parsed.health };
74
76
  if (health.perProjectDefaultMax === undefined) health.perProjectDefaultMax = DEFAULT_CONFIG.health.perProjectDefaultMax;
77
+ if (health.maxNotesBytes === undefined || typeof health.maxNotesBytes !== "number" || Number.isNaN(health.maxNotesBytes) || health.maxNotesBytes < 0) {
78
+ health.maxNotesBytes = DEFAULT_CONFIG.health.maxNotesBytes;
79
+ }
75
80
  return {
76
81
  version: 1,
77
82
  prune: { ...DEFAULT_CONFIG.prune, ...parsed.prune },
package/src/health.ts CHANGED
@@ -28,6 +28,7 @@ export interface ArchiveHealth {
28
28
  export interface NotesBytes {
29
29
  total: number;
30
30
  max: number;
31
+ maxId: string | null; // v0.5.0: id of the todo with the largest notes (null if no todos)
31
32
  avg: number;
32
33
  }
33
34
 
@@ -35,6 +36,7 @@ export type HealthFlag =
35
36
  | "ACTIVE_LARGE" | "ACTIVE_STALE"
36
37
  | "PARKED_LARGE" | "PARKED_STALE"
37
38
  | "ARCHIVE_LARGE" | "ARCHIVE_OLD"
39
+ | "NOTES_OVER"
38
40
  | "PROJECT_OVER" | "PROJECT_TYPO" | "PROJECT_LARGE" | "PROJECT_STALE";
39
41
 
40
42
  export interface ProjectHealth {
@@ -84,12 +86,21 @@ export function healthReport(): HealthReport {
84
86
  const archiveOld = archive.todos.filter((t) => t.closedAt && daysAgo(t.closedAt) > h.archiveOldDays).length;
85
87
 
86
88
  // notes bytes across active + parked (archived excluded — sealed history).
89
+ // v0.5.0: track the worst-offender id so the NOTES_OVER suggestion is actionable.
87
90
  const apTodos = [...openTodos, ...ipTodos, ...parkedTodos];
88
- const notesSizes = apTodos.map((t) => Buffer.byteLength(t.notes, "utf8"));
91
+ let maxId: string | null = null;
92
+ let maxSize = 0;
93
+ let totalBytes = 0;
94
+ for (const t of apTodos) {
95
+ const s = Buffer.byteLength(t.notes, "utf8");
96
+ totalBytes += s;
97
+ if (s > maxSize) { maxSize = s; maxId = t.id; }
98
+ }
89
99
  const notesBytes: NotesBytes = {
90
- total: notesSizes.reduce((a, b) => a + b, 0),
91
- max: notesSizes.length ? Math.max(...notesSizes) : 0,
92
- avg: notesSizes.length ? Math.round(notesSizes.reduce((a, b) => a + b, 0) / notesSizes.length) : 0,
100
+ total: totalBytes,
101
+ max: maxSize,
102
+ maxId: apTodos.length ? maxId : null,
103
+ avg: apTodos.length ? Math.round(totalBytes / apTodos.length) : 0,
93
104
  };
94
105
 
95
106
  const active: ActiveHealth = {
@@ -102,6 +113,7 @@ export function healthReport(): HealthReport {
102
113
 
103
114
  const flags: HealthFlag[] = [];
104
115
  if (actionable.length > h.activeMaxOpen) flags.push("ACTIVE_LARGE");
116
+ if (notesBytes.max > h.maxNotesBytes) flags.push("NOTES_OVER");
105
117
  if (activeStale > 0) flags.push("ACTIVE_STALE");
106
118
  if (parkedTodos.length > h.parkedMax) flags.push("PARKED_LARGE");
107
119
  if (parkedStale > 0) flags.push("PARKED_STALE");
@@ -113,6 +125,10 @@ export function healthReport(): HealthReport {
113
125
  if (activeStale > 0) suggestions.push(`active: ${activeStale} open TODOs untouched for ${h.activeStaleDays}d → park or close them`);
114
126
  if (parkedStale > 0) suggestions.push(`parked: ${parkedStale} parked > ${h.parkedStaleDays}d → restore or hard-prune`);
115
127
  if (actionable.length > h.activeMaxOpen) suggestions.push(`active: ${actionable.length} open+in_progress (max ${h.activeMaxOpen}) → close or park some before adding more`);
128
+ if (notesBytes.max > h.maxNotesBytes) {
129
+ const id = notesBytes.maxId ?? "<id>";
130
+ suggestions.push(`notes: largest note ${notesBytes.max}B > cap ${h.maxNotesBytes}B (on ${id}) → trim via todo update ${id} notes:…`);
131
+ }
116
132
 
117
133
  // per-project flags (v0.4.0)
118
134
  const archivedDone = archive.todos.filter((t) => t.status === "done");
package/src/panel-data.ts CHANGED
@@ -60,6 +60,7 @@ export function configToSettingItems(cfg: TodoConfig): SettingItem[] {
60
60
  { id: "parkedStaleDays", label: "Parked stale (days)", currentValue: String(cfg.health.parkedStaleDays), values: ["30", "60", "90"], description: "Bloat flag when parked longer than this." },
61
61
  { id: "archiveMax", label: "Archive max", currentValue: String(cfg.health.archiveMax), values: ["100", "200", "500"], description: "Bloat flag when archive exceeds this." },
62
62
  { id: "archiveOldDays", label: "Archive old (days)", currentValue: String(cfg.health.archiveOldDays), values: ["90", "180", "365"], description: "Bloat flag when archive items older than this." },
63
+ { 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." },
63
64
  ];
64
65
  }
65
66
 
package/src/panel.ts CHANGED
@@ -392,6 +392,7 @@ export class TodoPanel extends Container {
392
392
  case "parkedStaleDays": return String(c.health.parkedStaleDays);
393
393
  case "archiveMax": return String(c.health.archiveMax);
394
394
  case "archiveOldDays": return String(c.health.archiveOldDays);
395
+ case "maxNotesBytes": return String(c.health.maxNotesBytes);
395
396
  default: return "";
396
397
  }
397
398
  }
@@ -408,6 +409,7 @@ export class TodoPanel extends Container {
408
409
  case "parkedStaleDays": this.config.health.parkedStaleDays = n; break;
409
410
  case "archiveMax": this.config.health.archiveMax = n; break;
410
411
  case "archiveOldDays": this.config.health.archiveOldDays = n; break;
412
+ case "maxNotesBytes": this.config.health.maxNotesBytes = n; break;
411
413
  }
412
414
  saveConfig(this.config);
413
415
  this.onNotify(`Config saved: ${id} = ${value}`, "info");