@getpipher/armory-todo 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -8
- package/assets/hero.svg +4 -4
- package/docs/superpowers/plans/2026-07-21-caps-release.md +1113 -0
- package/docs/superpowers/plans/2026-07-21-project-scope-management.md +1537 -0
- package/docs/superpowers/specs/2026-07-21-auto-prune-done-view-design.md +1 -1
- package/docs/superpowers/specs/2026-07-21-caps-release-design.md +222 -0
- package/docs/superpowers/specs/2026-07-21-project-scope-management-design.md +319 -0
- package/extensions/todo.ts +60 -3
- package/package.json +2 -2
- package/src/caps.ts +71 -0
- package/src/config.ts +10 -1
- package/src/health.ts +81 -7
- package/src/levenshtein.ts +22 -0
- package/src/panel-data.ts +32 -0
- package/src/panel.ts +100 -6
- package/src/paths.ts +5 -0
- package/src/projects.ts +91 -0
- package/src/registry.ts +175 -0
- package/src/todo-store.ts +73 -12
package/src/paths.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// todo.json — live store (open, in_progress, parked)
|
|
5
5
|
// todo-archive.json — sealed history (done, cancelled)
|
|
6
6
|
// todo.config.json — prune ages + health thresholds
|
|
7
|
+
// projects.json — project registry (canonical names + maxOpen slots, v0.4.0)
|
|
7
8
|
//
|
|
8
9
|
// The legacy v1 single file was ~/.pi/agent/todo.json; migrate.ts handles
|
|
9
10
|
// moving it into the folder on first load.
|
|
@@ -30,6 +31,10 @@ export function getConfigPath(): string {
|
|
|
30
31
|
return join(getTodoDir(), "todo.config.json");
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
export function getRegistryPath(): string {
|
|
35
|
+
return join(getTodoDir(), "projects.json");
|
|
36
|
+
}
|
|
37
|
+
|
|
33
38
|
/** The pre-v2 single-file store location. Used by migrate.ts. */
|
|
34
39
|
export function getLegacyPath(): string {
|
|
35
40
|
return LEGACY_PATH;
|
package/src/projects.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Per-project scope overview for armory-todo (Feature A). Pure read that
|
|
2
|
+
// reconciles the registry first (lazy sync), then aggregates counts across
|
|
3
|
+
// the live store + archived done. The `projects` action + panel Projects tab
|
|
4
|
+
// + the per-project health flags all consume this shape (or its derivatives).
|
|
5
|
+
|
|
6
|
+
import { loadStore, type Todo } from "./todo-store.ts";
|
|
7
|
+
import { loadArchive } from "./archive.ts";
|
|
8
|
+
import { loadRegistry, reconcileRegistry, saveRegistry, getProjectEntry } from "./registry.ts";
|
|
9
|
+
import { levenshtein } from "./levenshtein.ts";
|
|
10
|
+
|
|
11
|
+
export interface ProjectOverviewRow {
|
|
12
|
+
name: string;
|
|
13
|
+
open: number;
|
|
14
|
+
in_progress: number;
|
|
15
|
+
parked: number;
|
|
16
|
+
done: number; // live done + archived done
|
|
17
|
+
total: number; // open + in_progress + parked + done
|
|
18
|
+
maxOpen: number | null;
|
|
19
|
+
over: boolean; // open > maxOpen (only when maxOpen !== null)
|
|
20
|
+
typo: boolean; // total === 1 AND a near-sibling (levenshtein ≤ 2) exists
|
|
21
|
+
lastUpdated: string; // max updatedAt across the project's live todos (ISO), or "" if none
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ProjectsOverview {
|
|
25
|
+
rows: ProjectOverviewRow[]; // sorted: open desc → total desc → name asc
|
|
26
|
+
totalTodos: number; // sum of rows' total
|
|
27
|
+
noProject: { count: number; open: number }; // the (no project) bucket, not a row
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function projectsOverview(): ProjectsOverview {
|
|
31
|
+
const live = loadStore();
|
|
32
|
+
const archive = loadArchive();
|
|
33
|
+
const archivedDone = archive.todos.filter((t) => t.status === "done");
|
|
34
|
+
|
|
35
|
+
// reconcile registry first (lazy sync), persist iff changed
|
|
36
|
+
const reg = loadRegistry();
|
|
37
|
+
const { reg: synced, changed } = reconcileRegistry(reg, live.todos, archive.todos);
|
|
38
|
+
if (changed) saveRegistry(synced);
|
|
39
|
+
|
|
40
|
+
const liveBy = new Map<string, Todo[]>();
|
|
41
|
+
for (const t of live.todos) {
|
|
42
|
+
const key = t.project.trim();
|
|
43
|
+
const list = liveBy.get(key) ?? [];
|
|
44
|
+
list.push(t);
|
|
45
|
+
liveBy.set(key, list);
|
|
46
|
+
}
|
|
47
|
+
const archivedDoneBy = new Map<string, number>();
|
|
48
|
+
for (const t of archivedDone) {
|
|
49
|
+
const key = t.project.trim();
|
|
50
|
+
archivedDoneBy.set(key, (archivedDoneBy.get(key) ?? 0) + 1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const names = new Set<string>([...liveBy.keys(), ...archivedDoneBy.keys()].filter((n) => n !== ""));
|
|
54
|
+
|
|
55
|
+
let totalTodos = 0;
|
|
56
|
+
const rows: ProjectOverviewRow[] = [];
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
const liveForName = liveBy.get(name) ?? [];
|
|
59
|
+
const open = liveForName.filter((t) => t.status === "open").length;
|
|
60
|
+
const in_progress = liveForName.filter((t) => t.status === "in_progress").length;
|
|
61
|
+
const parked = liveForName.filter((t) => t.status === "parked").length;
|
|
62
|
+
const done = liveForName.filter((t) => t.status === "done").length + (archivedDoneBy.get(name) ?? 0);
|
|
63
|
+
const total = open + in_progress + parked + done;
|
|
64
|
+
totalTodos += total;
|
|
65
|
+
const entry = getProjectEntry(synced, name);
|
|
66
|
+
const maxOpen = entry?.maxOpen ?? null;
|
|
67
|
+
const over = maxOpen !== null && open > maxOpen;
|
|
68
|
+
const lastUpdated = liveForName.length
|
|
69
|
+
? liveForName.map((t) => t.updatedAt).sort().at(-1) ?? ""
|
|
70
|
+
: "";
|
|
71
|
+
rows.push({ name, open, in_progress, parked, done, total, maxOpen, over, typo: false, lastUpdated });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// typo: total === 1 AND a near-sibling (levenshtein ≤ 2) among other names
|
|
75
|
+
for (const row of rows) {
|
|
76
|
+
if (row.total === 1) {
|
|
77
|
+
row.typo = [...names].some((other) => other !== row.name && levenshtein(row.name, other) <= 2);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// (no project) bucket
|
|
82
|
+
const noProjectLive = live.todos.filter((t) => t.project.trim() === "");
|
|
83
|
+
const noProjectArchivedDone = archivedDone.filter((t) => t.project.trim() === "");
|
|
84
|
+
const noProject = {
|
|
85
|
+
count: noProjectLive.length + noProjectArchivedDone.length,
|
|
86
|
+
open: noProjectLive.filter((t) => t.status === "open").length,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
rows.sort((a, b) => b.open - a.open || b.total - a.total || a.name.localeCompare(b.name));
|
|
90
|
+
return { rows, totalTodos, noProject };
|
|
91
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Project registry for armory-todo — a sibling file to todo.json holding the
|
|
2
|
+
// canonical list of known projects + their per-project advisory cap slot
|
|
3
|
+
// (`maxOpen`). Advisory in v0.4.0 (drives a health flag); enforcement
|
|
4
|
+
// (block-on-add) graduates in v0.5.0.
|
|
5
|
+
//
|
|
6
|
+
// File: <TODO_DIR>/projects.json (0600, atomic write). Lazy-synced on read:
|
|
7
|
+
// `reconcileRegistry` appends any unknown project strings (live + archived)
|
|
8
|
+
// with maxOpen:null. `loadRegistry` is side-effect-free (missing → empty,
|
|
9
|
+
// no file created); seeding happens on the first reconcile call.
|
|
10
|
+
// No env guard — projects.json always lives under TODO_DIR (temp dir in tests).
|
|
11
|
+
|
|
12
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { dirname } from "node:path";
|
|
14
|
+
import { getRegistryPath } from "./paths.ts";
|
|
15
|
+
import { loadStore, saveStore, TodoError, type Todo } from "./todo-store.ts";
|
|
16
|
+
import { loadArchive, saveArchive } from "./archive.ts";
|
|
17
|
+
|
|
18
|
+
export interface ProjectEntry {
|
|
19
|
+
name: string;
|
|
20
|
+
maxOpen: number | null; // null = no advisory cap for this project
|
|
21
|
+
createdAt: string;
|
|
22
|
+
updatedAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ProjectRegistry {
|
|
26
|
+
version: 1;
|
|
27
|
+
updatedAt: string;
|
|
28
|
+
projects: ProjectEntry[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function now(): string { return new Date().toISOString(); }
|
|
32
|
+
|
|
33
|
+
function emptyRegistry(): ProjectRegistry {
|
|
34
|
+
return { version: 1, updatedAt: now(), projects: [] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export { getRegistryPath } from "./paths.ts";
|
|
38
|
+
|
|
39
|
+
export function loadRegistry(): ProjectRegistry {
|
|
40
|
+
const path = getRegistryPath();
|
|
41
|
+
if (!existsSync(path)) return emptyRegistry();
|
|
42
|
+
try {
|
|
43
|
+
const raw = readFileSync(path, "utf8");
|
|
44
|
+
const parsed = JSON.parse(raw) as ProjectRegistry;
|
|
45
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.projects)) {
|
|
46
|
+
throw new Error("invalid registry shape");
|
|
47
|
+
}
|
|
48
|
+
if (parsed.version !== 1) throw new Error("invalid registry shape");
|
|
49
|
+
return parsed;
|
|
50
|
+
} catch {
|
|
51
|
+
try {
|
|
52
|
+
renameSync(path, `${path}.bad-${Date.now()}`);
|
|
53
|
+
} catch {
|
|
54
|
+
// best-effort backup
|
|
55
|
+
}
|
|
56
|
+
return emptyRegistry();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function saveRegistry(reg: ProjectRegistry): void {
|
|
61
|
+
reg.updatedAt = now();
|
|
62
|
+
const path = getRegistryPath();
|
|
63
|
+
const dir = dirname(path);
|
|
64
|
+
mkdirSync(dir, { recursive: true });
|
|
65
|
+
const tmp = `${path}.tmp`;
|
|
66
|
+
writeFileSync(tmp, JSON.stringify(reg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
67
|
+
try { chmodSync(tmp, 0o600); } catch { /* fs may ignore mode bits */ }
|
|
68
|
+
renameSync(tmp, path);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Lazy sync: append any unknown non-empty project strings (from live + archived
|
|
73
|
+
* todos) as new entries with maxOpen:null. Returns { reg, changed }. Caller
|
|
74
|
+
* persists iff changed. Idempotent (a second call with no new names → changed=false).
|
|
75
|
+
*/
|
|
76
|
+
export function reconcileRegistry(
|
|
77
|
+
reg: ProjectRegistry,
|
|
78
|
+
liveTodos: Todo[],
|
|
79
|
+
archivedTodos: Todo[],
|
|
80
|
+
): { reg: ProjectRegistry; changed: boolean } {
|
|
81
|
+
const known = new Set(reg.projects.map((p) => p.name));
|
|
82
|
+
const names = new Set<string>();
|
|
83
|
+
for (const t of liveTodos) { const p = t.project.trim(); if (p) names.add(p); }
|
|
84
|
+
for (const t of archivedTodos) { const p = t.project.trim(); if (p) names.add(p); }
|
|
85
|
+
let changed = false;
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
if (!known.has(name)) {
|
|
88
|
+
reg.projects.push({ name, maxOpen: null, createdAt: now(), updatedAt: now() });
|
|
89
|
+
changed = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (changed) reg.updatedAt = now();
|
|
93
|
+
return { reg, changed };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getProjectEntry(reg: ProjectRegistry, name: string): ProjectEntry | undefined {
|
|
97
|
+
return reg.projects.find((p) => p.name === name);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Set a project's maxOpen slot. `max = null` clears. Creates the entry if the
|
|
102
|
+
* name is unknown (with createdAt/updatedAt = now). Throws if name is "" (the
|
|
103
|
+
* (no project) group can't be capped). Mutates `reg` in place + returns the entry.
|
|
104
|
+
*/
|
|
105
|
+
export function setProjectMaxOpen(reg: ProjectRegistry, name: string, max: number | null): ProjectEntry {
|
|
106
|
+
const trimmed = name.trim();
|
|
107
|
+
if (!trimmed) throw new TodoError("cannot set maxOpen on the (no project) group");
|
|
108
|
+
if (max !== null && (!Number.isFinite(max) || max < 0)) {
|
|
109
|
+
throw new TodoError(`maxOpen must be a non-negative number or null (got ${String(max)})`);
|
|
110
|
+
}
|
|
111
|
+
let entry = getProjectEntry(reg, trimmed);
|
|
112
|
+
if (!entry) {
|
|
113
|
+
entry = { name: trimmed, maxOpen: null, createdAt: now(), updatedAt: now() };
|
|
114
|
+
reg.projects.push(entry);
|
|
115
|
+
}
|
|
116
|
+
entry.maxOpen = max;
|
|
117
|
+
entry.updatedAt = now();
|
|
118
|
+
reg.updatedAt = now();
|
|
119
|
+
return entry;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface RenameResult {
|
|
123
|
+
liveRenamed: number;
|
|
124
|
+
archivedRenamed: number;
|
|
125
|
+
merged: boolean;
|
|
126
|
+
newName: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Rename (or merge) a project: rewrite every live + archived todo whose
|
|
131
|
+
* `project === oldName` to `newName`, remove the `oldName` registry entry,
|
|
132
|
+
* and ensure the `newName` entry exists. Best-effort multi-file (no WAL):
|
|
133
|
+
* live → archive → registry, each saved atomically with backup-on-corrupt.
|
|
134
|
+
* Throws if `oldName` is not in the registry. Self-rename is a no-op success.
|
|
135
|
+
*/
|
|
136
|
+
export function renameProject(oldName: string, newName: string): RenameResult {
|
|
137
|
+
const old = oldName.trim();
|
|
138
|
+
const next = newName.trim();
|
|
139
|
+
if (!old) throw new TodoError("oldName is required");
|
|
140
|
+
if (!next) throw new TodoError("newName is required");
|
|
141
|
+
if (old === next) return { liveRenamed: 0, archivedRenamed: 0, merged: false, newName: next };
|
|
142
|
+
|
|
143
|
+
const reg = loadRegistry();
|
|
144
|
+
const oldEntry = getProjectEntry(reg, old);
|
|
145
|
+
if (!oldEntry) throw new TodoError(`no project named '${old}' in the registry`);
|
|
146
|
+
const merged = getProjectEntry(reg, next) !== undefined;
|
|
147
|
+
|
|
148
|
+
// 1. live store
|
|
149
|
+
const live = loadStore();
|
|
150
|
+
let liveRenamed = 0;
|
|
151
|
+
for (const t of live.todos) {
|
|
152
|
+
if (t.project === old) { t.project = next; t.updatedAt = now(); liveRenamed++; }
|
|
153
|
+
}
|
|
154
|
+
if (liveRenamed > 0) saveStore(live);
|
|
155
|
+
|
|
156
|
+
// 2. archive
|
|
157
|
+
const archive = loadArchive();
|
|
158
|
+
let archivedRenamed = 0;
|
|
159
|
+
for (const t of archive.todos) {
|
|
160
|
+
if (t.project === old) { t.project = next; archivedRenamed++; }
|
|
161
|
+
}
|
|
162
|
+
if (archivedRenamed > 0) saveArchive(archive);
|
|
163
|
+
|
|
164
|
+
// 3. registry: remove old, ensure next exists
|
|
165
|
+
reg.projects = reg.projects.filter((p) => p.name !== old);
|
|
166
|
+
if (!getProjectEntry(reg, next)) {
|
|
167
|
+
// non-merge: preserve the renamed project's cap + provenance (don't silently drop maxOpen)
|
|
168
|
+
reg.projects.push({ name: next, maxOpen: oldEntry.maxOpen, createdAt: oldEntry.createdAt, updatedAt: now() });
|
|
169
|
+
}
|
|
170
|
+
// merge (next already exists): keep next's existing entry + cap unchanged
|
|
171
|
+
reg.updatedAt = now();
|
|
172
|
+
saveRegistry(reg);
|
|
173
|
+
|
|
174
|
+
return { liveRenamed, archivedRenamed, merged, newName: next };
|
|
175
|
+
}
|
package/src/todo-store.ts
CHANGED
|
@@ -19,6 +19,9 @@ 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";
|
|
22
25
|
|
|
23
26
|
export type Priority = "low" | "med" | "high" | "critical";
|
|
24
27
|
export type Status = "open" | "in_progress" | "parked" | "done" | "cancelled";
|
|
@@ -182,6 +185,19 @@ export function addTodo(input: AddInput): Todo {
|
|
|
182
185
|
if (input.priority) assertPriority(input.priority);
|
|
183
186
|
const notes = (input.notes ?? "").trim();
|
|
184
187
|
const store = loadStore();
|
|
188
|
+
// v0.5.0 caps — checked BEFORE any mutation (atomic: nothing is written on breach).
|
|
189
|
+
const config = loadConfig();
|
|
190
|
+
checkNotesCap(notes, config.health.maxNotesBytes);
|
|
191
|
+
const projectTrimmed = (input.project ?? "").trim();
|
|
192
|
+
if (projectTrimmed !== "") {
|
|
193
|
+
const reg = loadRegistry();
|
|
194
|
+
const entry = getProjectEntry(reg, projectTrimmed);
|
|
195
|
+
const maxOpen = entry?.maxOpen ?? null;
|
|
196
|
+
if (maxOpen !== null) {
|
|
197
|
+
const currentOpen = store.todos.filter((t) => t.project === projectTrimmed && t.status === "open").length;
|
|
198
|
+
checkProjectCap({ project: projectTrimmed, currentOpen, maxOpen });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
185
201
|
const todo: Todo = {
|
|
186
202
|
id: genId(),
|
|
187
203
|
title,
|
|
@@ -237,6 +253,25 @@ export function listTodos(filter: ListFilter = {}): Todo[] {
|
|
|
237
253
|
export function updateTodo(id: string, patch: UpdateInput): Todo {
|
|
238
254
|
const store = loadStore();
|
|
239
255
|
const todo = findOrFail(store, id);
|
|
256
|
+
// v0.5.0 caps — checked BEFORE any mutation (atomic). Notes re-checked only
|
|
257
|
+
// when notes is being written (so a title edit on a grandfathered oversize
|
|
258
|
+
// note isn't trapped). Project cap re-checked only on a real move of an
|
|
259
|
+
// open/in_progress todo (un-park is intentionally NOT re-checked).
|
|
260
|
+
if (patch.notes !== undefined) {
|
|
261
|
+
checkNotesCap(patch.notes.trim(), loadConfig().health.maxNotesBytes);
|
|
262
|
+
}
|
|
263
|
+
if (patch.project !== undefined) {
|
|
264
|
+
const target = patch.project.trim();
|
|
265
|
+
if (target !== todo.project && (todo.status === "open" || todo.status === "in_progress") && target !== "") {
|
|
266
|
+
const reg = loadRegistry();
|
|
267
|
+
const entry = getProjectEntry(reg, target);
|
|
268
|
+
const maxOpen = entry?.maxOpen ?? null;
|
|
269
|
+
if (maxOpen !== null) {
|
|
270
|
+
const currentOpen = store.todos.filter((t) => t.project === target && t.status === "open" && t.id !== todo.id).length;
|
|
271
|
+
checkProjectCap({ project: target, currentOpen, maxOpen });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
240
275
|
if (patch.title !== undefined) todo.title = normalizeTitle(patch.title);
|
|
241
276
|
if (patch.notes !== undefined) todo.notes = patch.notes.trim();
|
|
242
277
|
if (patch.project !== undefined) todo.project = patch.project.trim();
|
|
@@ -285,17 +320,43 @@ export function clearTodos(status: Status = "done"): number {
|
|
|
285
320
|
return removed;
|
|
286
321
|
}
|
|
287
322
|
|
|
288
|
-
/** Compact markdown summary of open + in_progress TODOs for system-prompt
|
|
289
|
-
|
|
290
|
-
|
|
323
|
+
/** Compact markdown summary of open + in_progress TODOs for system-prompt
|
|
324
|
+
* injection. v0.5.0: cap-aware — when actionable > activeMaxOpen (from
|
|
325
|
+
* config, or the `max` override), switches to a lean summary (counts +
|
|
326
|
+
* over-budget projects + pointer) instead of the row list, keeping the
|
|
327
|
+
* prompt bounded when bloated. Under cap → the familiar row list (capped
|
|
328
|
+
* at activeMaxOpen rows). */
|
|
329
|
+
export function renderOpenBlock(max?: number): string {
|
|
330
|
+
const todos = listTodos({ limit: Number.MAX_SAFE_INTEGER }); // actionable set, sorted — unpaginated so the count + slice are exact
|
|
291
331
|
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
|
-
|
|
332
|
+
let cap: number;
|
|
333
|
+
try { cap = max ?? loadConfig().health.activeMaxOpen; } catch { cap = 15; }
|
|
334
|
+
if (todos.length <= cap) {
|
|
335
|
+
const shown = todos.slice(0, cap);
|
|
336
|
+
const lines = shown.map((t) => {
|
|
337
|
+
const tag = t.project ? ` (${t.project})` : "";
|
|
338
|
+
const pin = t.status === "in_progress" ? " ⏵" : "";
|
|
339
|
+
const dot = t.notes.trim() ? " •" : "";
|
|
340
|
+
return `- [${t.id}] (${t.priority})${pin}${dot} ${t.title}${tag}`;
|
|
341
|
+
});
|
|
342
|
+
return `## Open TODOs (${todos.length})\n${lines.join("\n")}\n`;
|
|
343
|
+
}
|
|
344
|
+
// over budget → lean summary (the anti-bloat path)
|
|
345
|
+
let over: { name: string; open: number; maxOpen: number }[] = [];
|
|
346
|
+
try {
|
|
347
|
+
const reg = loadRegistry();
|
|
348
|
+
over = overBudgetProjects(loadStore().todos, reg);
|
|
349
|
+
} catch {
|
|
350
|
+
// fail-open: a bad registry shouldn’t break injection
|
|
351
|
+
}
|
|
352
|
+
const projects = new Set(todos.map((t) => t.project.trim()).filter(Boolean));
|
|
353
|
+
const lines = [
|
|
354
|
+
`## Open TODOs (${todos.length}) — ⚠ over budget (cap ${cap})`,
|
|
355
|
+
`${todos.length} open+in_progress across ${projects.size} project${projects.size === 1 ? "" : "s"}`,
|
|
356
|
+
];
|
|
357
|
+
if (over.length > 0) {
|
|
358
|
+
lines.push(`over-budget: ${over.map((p) => `${p.name} ${p.open}/${p.maxOpen}`).join(", ")}`);
|
|
359
|
+
}
|
|
360
|
+
lines.push("run `todo list` or `/todo` to see the full list");
|
|
361
|
+
return lines.join("\n") + "\n";
|
|
301
362
|
}
|