@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.
package/src/todo-store.ts CHANGED
@@ -19,6 +19,10 @@ import {
19
19
  import { dirname } from "node:path";
20
20
  import { getLivePath, getTodoDir, getLegacyPath } from "./paths.ts";
21
21
  import { migrateIfNeeded, migrateV2ToV3 } from "./migrate.ts";
22
+ import { loadConfig } from "./config.ts";
23
+ import { loadRegistry, getProjectEntry } from "./registry.ts";
24
+ import { checkNotesCap, checkProjectCap, overBudgetProjects } from "./caps.ts";
25
+ import { backupFile, snapshotOnDrop, appendAudit, countTodosInFile } from "./backup.ts";
22
26
 
23
27
  export type Priority = "low" | "med" | "high" | "critical";
24
28
  export type Status = "open" | "in_progress" | "parked" | "done" | "cancelled";
@@ -147,6 +151,12 @@ export function loadStore(): Store {
147
151
  export function saveStore(store: Store): void {
148
152
  store.updatedAt = now();
149
153
  const path = getLivePath();
154
+ // v0.5.1 write-audit + backup (post data-loss hardening): back up the current
155
+ // file, snapshot pre-write state on a count drop, then audit-log the save.
156
+ const before = countTodosInFile(path);
157
+ const after = store.todos.length;
158
+ backupFile(path);
159
+ const dropSnap = snapshotOnDrop(path, before, after);
150
160
  const dir = dirname(path);
151
161
  mkdirSync(dir, { recursive: true });
152
162
  const tmp = `${path}.tmp`;
@@ -157,6 +167,7 @@ export function saveStore(store: Store): void {
157
167
  // some filesystems ignore mode bits; not fatal
158
168
  }
159
169
  renameSync(tmp, path);
170
+ appendAudit("todo", before, after, dropSnap);
160
171
  }
161
172
 
162
173
  function assertPriority(p: unknown): asserts p is Priority {
@@ -182,6 +193,19 @@ export function addTodo(input: AddInput): Todo {
182
193
  if (input.priority) assertPriority(input.priority);
183
194
  const notes = (input.notes ?? "").trim();
184
195
  const store = loadStore();
196
+ // v0.5.0 caps — checked BEFORE any mutation (atomic: nothing is written on breach).
197
+ const config = loadConfig();
198
+ checkNotesCap(notes, config.health.maxNotesBytes);
199
+ const projectTrimmed = (input.project ?? "").trim();
200
+ if (projectTrimmed !== "") {
201
+ const reg = loadRegistry();
202
+ const entry = getProjectEntry(reg, projectTrimmed);
203
+ const maxOpen = entry?.maxOpen ?? null;
204
+ if (maxOpen !== null) {
205
+ const currentOpen = store.todos.filter((t) => t.project === projectTrimmed && t.status === "open").length;
206
+ checkProjectCap({ project: projectTrimmed, currentOpen, maxOpen });
207
+ }
208
+ }
185
209
  const todo: Todo = {
186
210
  id: genId(),
187
211
  title,
@@ -237,6 +261,25 @@ export function listTodos(filter: ListFilter = {}): Todo[] {
237
261
  export function updateTodo(id: string, patch: UpdateInput): Todo {
238
262
  const store = loadStore();
239
263
  const todo = findOrFail(store, id);
264
+ // v0.5.0 caps — checked BEFORE any mutation (atomic). Notes re-checked only
265
+ // when notes is being written (so a title edit on a grandfathered oversize
266
+ // note isn't trapped). Project cap re-checked only on a real move of an
267
+ // open/in_progress todo (un-park is intentionally NOT re-checked).
268
+ if (patch.notes !== undefined) {
269
+ checkNotesCap(patch.notes.trim(), loadConfig().health.maxNotesBytes);
270
+ }
271
+ if (patch.project !== undefined) {
272
+ const target = patch.project.trim();
273
+ if (target !== todo.project && (todo.status === "open" || todo.status === "in_progress") && target !== "") {
274
+ const reg = loadRegistry();
275
+ const entry = getProjectEntry(reg, target);
276
+ const maxOpen = entry?.maxOpen ?? null;
277
+ if (maxOpen !== null) {
278
+ const currentOpen = store.todos.filter((t) => t.project === target && t.status === "open" && t.id !== todo.id).length;
279
+ checkProjectCap({ project: target, currentOpen, maxOpen });
280
+ }
281
+ }
282
+ }
240
283
  if (patch.title !== undefined) todo.title = normalizeTitle(patch.title);
241
284
  if (patch.notes !== undefined) todo.notes = patch.notes.trim();
242
285
  if (patch.project !== undefined) todo.project = patch.project.trim();
@@ -285,17 +328,43 @@ export function clearTodos(status: Status = "done"): number {
285
328
  return removed;
286
329
  }
287
330
 
288
- /** Compact markdown summary of open + in_progress TODOs for system-prompt injection. */
289
- export function renderOpenBlock(max = 15): string {
290
- const todos = listTodos(); // actionable set, sorted
331
+ /** Compact markdown summary of open + in_progress TODOs for system-prompt
332
+ * injection. v0.5.0: cap-aware when actionable > activeMaxOpen (from
333
+ * config, or the `max` override), switches to a lean summary (counts +
334
+ * over-budget projects + pointer) instead of the row list, keeping the
335
+ * prompt bounded when bloated. Under cap → the familiar row list (capped
336
+ * at activeMaxOpen rows). */
337
+ export function renderOpenBlock(max?: number): string {
338
+ const todos = listTodos({ limit: Number.MAX_SAFE_INTEGER }); // actionable set, sorted — unpaginated so the count + slice are exact
291
339
  if (todos.length === 0) return "## Open TODOs\n(none — no pending cross-session TODOs)\n";
292
- const shown = todos.slice(0, max);
293
- const lines = shown.map((t) => {
294
- const tag = t.project ? ` (${t.project})` : "";
295
- const pin = t.status === "in_progress" ? " ⏵" : "";
296
- const dot = t.notes.trim() ? " •" : "";
297
- return `- [${t.id}] (${t.priority})${pin}${dot} ${t.title}${tag}`;
298
- });
299
- const overflow = todos.length > max ? `\n- +${todos.length - max} more (use \`todo list\`)` : "";
300
- return `## Open TODOs (${todos.length})\n${lines.join("\n")}${overflow}\n`;
340
+ let cap: number;
341
+ try { cap = max ?? loadConfig().health.activeMaxOpen; } catch { cap = 15; }
342
+ if (todos.length <= cap) {
343
+ const shown = todos.slice(0, cap);
344
+ const lines = shown.map((t) => {
345
+ const tag = t.project ? ` (${t.project})` : "";
346
+ const pin = t.status === "in_progress" ? " ⏵" : "";
347
+ const dot = t.notes.trim() ? " •" : "";
348
+ return `- [${t.id}] (${t.priority})${pin}${dot} ${t.title}${tag}`;
349
+ });
350
+ return `## Open TODOs (${todos.length})\n${lines.join("\n")}\n`;
351
+ }
352
+ // over budget → lean summary (the anti-bloat path)
353
+ let over: { name: string; open: number; maxOpen: number }[] = [];
354
+ try {
355
+ const reg = loadRegistry();
356
+ over = overBudgetProjects(loadStore().todos, reg);
357
+ } catch {
358
+ // fail-open: a bad registry shouldn’t break injection
359
+ }
360
+ const projects = new Set(todos.map((t) => t.project.trim()).filter(Boolean));
361
+ const lines = [
362
+ `## Open TODOs (${todos.length}) — ⚠ over budget (cap ${cap})`,
363
+ `${todos.length} open+in_progress across ${projects.size} project${projects.size === 1 ? "" : "s"}`,
364
+ ];
365
+ if (over.length > 0) {
366
+ lines.push(`over-budget: ${over.map((p) => `${p.name} ${p.open}/${p.maxOpen}`).join(", ")}`);
367
+ }
368
+ lines.push("run `todo list` or `/todo` to see the full list");
369
+ return lines.join("\n") + "\n";
301
370
  }