@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/README.md +15 -3
- package/docs/superpowers/plans/2026-07-21-caps-release.md +1113 -0
- package/docs/superpowers/specs/2026-07-21-caps-release-design.md +222 -0
- package/extensions/todo.ts +2 -2
- package/package.json +4 -4
- package/src/archive.ts +7 -0
- package/src/backup.ts +83 -0
- package/src/caps.ts +71 -0
- package/src/config.ts +5 -0
- package/src/health.ts +20 -4
- package/src/panel-data.ts +1 -0
- package/src/panel.ts +2 -0
- package/src/todo-store.ts +81 -12
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
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
}
|