@gonxped31/agent-kit 0.0.0-stage → 0.3.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,696 @@
1
+ // Minimal todo tracker. The script does the bookkeeping so nobody reads the todo files.
2
+ //
3
+ // node .claude/skills/todo/scripts/todo.mjs <command> [args]
4
+ //
5
+ // add "text" [-p 1-3] [--dep T-A,T-B] [--size S|M|L] [--tag a,b] create; prints the new ID
6
+ // next best eligible line, or "none"
7
+ // list [-p N] [--tag X] [--all] eligible items; --all adds blocked (*)
8
+ // set T-XXX [-p N] [--dep T-A,T-B|-] [--size S|M|L|-] change priority, deps or size
9
+ // detail T-XXX "text" | --file path|- [--append] write the item's detail text ("-": stdin)
10
+ // edit T-XXX "new text" replace an item's description
11
+ // tag T-XXX a,b replace an item's tags ("-" clears)
12
+ // show T-XXX the line, then the detail text if any
13
+ // done T-XXX remove, archive with date; prints items it
14
+ // fully unblocked
15
+ // rm T-XXX remove without archiving
16
+ //
17
+ // stats counts by priority and size, done this week
18
+ // graph dependency tree: what unblocks what
19
+ // html [--out file] a self-contained HTML board (light/dark)
20
+ // help usage, color flags
21
+ //
22
+ // Output is plain unless color is on (see "Colors" below); then list also groups by
23
+ // priority and ends with a summary line.
24
+ //
25
+ // dep is one ID, several comma-separated IDs, or "-". An item is eligible once none of
26
+ // its deps are still open.
27
+ //
28
+ // ---- Data: one file per todo -----------------------------------------------------------
29
+ // Several agents work in parallel git worktrees, so nothing here is shared between items:
30
+ // todos/<ID>.md line 1: "ID | P | dep | size | tags | description"
31
+ // line 2: "created: <ISO time>"
32
+ // then a blank line and the free-form detail text, if any.
33
+ // todo-archive.md finished items, one line each; append-only, merged with
34
+ // `merge=union` (.gitattributes), so two branches' `done`s both stay.
35
+ // New IDs are random (T- plus 4 characters from ID_ALPHABET), checked against every
36
+ // open and archived ID here, so two worktrees never need to agree on a counter. Two
37
+ // branches that each add a todo touch different files, and git merges them cleanly.
38
+ //
39
+ // TODO_ROOT=<dir> points the script at another directory (used by the kit’s tests).
40
+ // TODO_TEST_IDS=a,b,... hands out those ID suffixes in order (tests only).
41
+
42
+ import { randomInt } from "node:crypto";
43
+ import fs from "node:fs";
44
+ import path from "node:path";
45
+ import { fileURLToPath } from "node:url";
46
+
47
+ const here = path.dirname(fileURLToPath(import.meta.url));
48
+ const ROOT = process.env.TODO_ROOT || path.resolve(here, "../../../..");
49
+ const ARCHIVE = path.join(ROOT, "todo-archive.md");
50
+ const DETAILS = path.join(ROOT, "todos");
51
+ /** No 0/o, 1/l/i: an ID is easy to read aloud and retype. 31^4 = 923,521 IDs. */
52
+ const ID_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz";
53
+ const ID_LENGTH = 4;
54
+ const ID_RE = /^T-(\d{3,}|[23456789abcdefghjkmnpqrstuvwxyz]{4})$/;
55
+ const LINE_ID_RE = /^\s*(T-[0-9a-z]+)\s*\|/i;
56
+ const TAGS_RE = /^[a-z0-9-]+(,[a-z0-9-]+)*$/; // line format: ID | P | dep | size | tags | description
57
+
58
+ // ---- Colors (display only) ---------------------------------------------------------
59
+ // Color is on when stdout is a TTY, unless NO_COLOR is set; FORCE_COLOR forces it on.
60
+ // --color=always (= --pretty) and --color=never (= --plain) override both, so --pretty
61
+ // beats NO_COLOR. Piped output, and anything Claude runs, is plain. Every style is named
62
+ // in PALETTE (basic 16-color codes, so they follow the terminal theme) and applied through
63
+ // c(); this block is the only place escape codes are written.
64
+ const ANSI = { bold: 1, dim: 2, red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36, white: 37, gray: 90 };
65
+ const PALETTE = {
66
+ p1: ["bold", "red"], p2: ["yellow"], p3: ["blue"], // priority
67
+ sizeS: ["green"], sizeM: ["yellow"], sizeL: ["bold", "magenta"],
68
+ id: ["cyan"],
69
+ next: ["bold"], arrow: ["green"], // the item `next` picks
70
+ blocked: ["gray"], blockedDep: ["dim", "cyan"],
71
+ num: ["bold", "white"], label: ["dim"], ready: ["green"], blockedCount: ["gray"], urgent: ["red"], // summary line
72
+ marker: ["dim"], // "+" after an ID that has detail text
73
+ done: ["green"], unblocked: ["cyan"],
74
+ cycle: ["red"], rule: ["dim"], error: ["red"], warn: ["yellow"],
75
+ };
76
+ const PRIORITY_STYLE = { 1: PALETTE.p1, 2: PALETTE.p2, 3: PALETTE.p3 };
77
+ const SIZE_STYLE = { S: PALETTE.sizeS, M: PALETTE.sizeM, L: PALETTE.sizeL };
78
+
79
+ let PRETTY = false; // stdout
80
+ let ERR_PRETTY = false; // stderr (error:/warn: prefixes)
81
+
82
+ const paint = (text, styles) => {
83
+ const codes = [...new Set(styles.flat(Infinity))].map((x) => ANSI[x]);
84
+ return text === "" || !codes.length ? text : `\x1b[${codes.join(";")}m${text}\x1b[0m`;
85
+ };
86
+ const c = (text, ...styles) => (PRETTY ? paint(text, styles) : text);
87
+ const ce = (text, ...styles) => (ERR_PRETTY ? paint(text, styles) : text);
88
+ const visible = (text) => text.replace(/\x1b\[[0-9;]*m/g, ""); // widths ignore escape codes
89
+ const width = (text) => [...visible(text)].length;
90
+ const padTo = (text, n) => text + " ".repeat(Math.max(0, n - width(text)));
91
+
92
+ function colorEnabled(mode, stream) {
93
+ if (mode === "always") return true;
94
+ if (mode === "never") return false;
95
+ const force = process.env.FORCE_COLOR;
96
+ if (force) return force !== "0";
97
+ return !process.env.NO_COLOR && Boolean(stream.isTTY);
98
+ }
99
+
100
+ const die = (msg) => {
101
+ console.error(ERR_PRETTY ? `${ce("error:", PALETTE.error)} ${msg}` : msg);
102
+ process.exit(1);
103
+ };
104
+
105
+ const warn = (msg) => console.error(ERR_PRETTY ? `${ce("warn:", PALETTE.warn)} ${msg}` : `warning: ${msg}`);
106
+
107
+ const splitLines = (text) => text.split(/\r?\n/).filter((l, i, a) => i < a.length - 1 || l !== "");
108
+ const readLines = (file) => (fs.existsSync(file) ? splitLines(fs.readFileSync(file, "utf8")) : []);
109
+
110
+ function writeAtomic(file, lines) {
111
+ const tmp = `${file}.${process.pid}.tmp`;
112
+ fs.writeFileSync(tmp, lines.map((l) => `${l}\n`).join(""));
113
+ fs.renameSync(tmp, file);
114
+ }
115
+
116
+ // ---- Items ------------------------------------------------------------------------------
117
+
118
+ // One "ID | P | dep | size | tags | description" line, split on the first 5 pipes only
119
+ // (the description may contain "|"); null if it isn't one.
120
+ function parseLine(raw) {
121
+ if (!raw.trim() || raw.trimStart().startsWith("#")) return null;
122
+ const parts = [];
123
+ let rest = raw;
124
+ for (let n = 0; n < 5; n++) {
125
+ const at = rest.indexOf("|");
126
+ if (at < 0) return null;
127
+ parts.push(rest.slice(0, at).trim());
128
+ rest = rest.slice(at + 1);
129
+ }
130
+ const id = normalizeId(parts[0]);
131
+ if (!ID_RE.test(id)) return null;
132
+ const p = Number.parseInt(parts[1], 10);
133
+ return { id, p: Number.isNaN(p) ? 2 : p, dep: parts[2], size: parts[3], tags: parts[4], desc: rest.trim() };
134
+ }
135
+
136
+ const fmt = (i) => `${i.id} | ${i.p} | ${i.dep} | ${i.size} | ${i.tags} | ${i.desc}`;
137
+ const fileOf = (id) => path.join(DETAILS, `${id}.md`);
138
+
139
+ /** A todo file's parts, or null if the file isn't a todo (e.g. an old-format detail file). */
140
+ function readItemFile(file) {
141
+ const lines = splitLines(fs.readFileSync(file, "utf8"));
142
+ const item = parseLine(lines[0] ?? "");
143
+ if (!item) return null;
144
+ let at = 1;
145
+ const created = /^created:\s*(\S+)\s*$/.exec(lines[1] ?? "")?.[1];
146
+ if (created) at = 2;
147
+ if (lines[at] === "") at++;
148
+ return { item: created ? { ...item, created } : item, body: lines.slice(at).join("\n").trimEnd() };
149
+ }
150
+
151
+ function writeItem(item, body = "") {
152
+ fs.mkdirSync(DETAILS, { recursive: true });
153
+ const lines = [fmt(item)];
154
+ if (item.created) lines.push(`created: ${item.created}`);
155
+ if (body.trim()) lines.push("", ...body.trimEnd().split(/\r?\n/));
156
+ writeAtomic(fileOf(item.id), lines);
157
+ }
158
+
159
+ const bodyOf = (id) => readItemFile(fileOf(id))?.body ?? "";
160
+
161
+ /** Every open todo. Warns about stray files in todos/. */
162
+ function loadItems({ quiet = false } = {}) {
163
+ if (!fs.existsSync(DETAILS)) return [];
164
+ const items = [];
165
+ for (const name of fs.readdirSync(DETAILS).sort()) {
166
+ if (!name.endsWith(".md")) continue;
167
+ const parsed = readItemFile(path.join(DETAILS, name));
168
+ if (!parsed || `${parsed.item.id}.md` !== name) {
169
+ if (!quiet) warn(`todos/${name} is not a todo file (line 1 must be its ID line)`);
170
+ continue;
171
+ }
172
+ items.push({ ...parsed.item, hasBody: parsed.body !== "" });
173
+ }
174
+ return items;
175
+ }
176
+
177
+ function archivedIds() {
178
+ const ids = new Set();
179
+ for (const l of readLines(ARCHIVE)) {
180
+ const m = LINE_ID_RE.exec(l);
181
+ if (m) ids.add(normalizeId(m[1]));
182
+ }
183
+ return ids;
184
+ }
185
+
186
+ /** "t-K7Q3 " -> "T-k7q3": IDs are case-insensitive to type. */
187
+ function normalizeId(s) {
188
+ const t = (s ?? "").trim();
189
+ return /^t-/i.test(t) ? `T-${t.slice(2).toLowerCase()}` : t;
190
+ }
191
+
192
+ const checkId = (s) => {
193
+ const id = normalizeId(s);
194
+ if (!ID_RE.test(id)) die(`bad ID '${s}' (expected T-k7q3, or an old one like T-017)`);
195
+ return id;
196
+ };
197
+
198
+ const testIds = (process.env.TODO_TEST_IDS ?? "").split(",").filter(Boolean);
199
+ /** A random ID not in `taken` (and adds it there). */
200
+ function newId(taken) {
201
+ for (;;) {
202
+ const suffix = testIds.length ? testIds.shift() : Array.from({ length: ID_LENGTH }, () => ID_ALPHABET[randomInt(ID_ALPHABET.length)]).join("");
203
+ const id = `T-${suffix}`;
204
+ if (!taken.has(id)) {
205
+ taken.add(id);
206
+ return id;
207
+ }
208
+ }
209
+ }
210
+
211
+ const depList = (dep) => (dep === "-" ? [] : dep.split(","));
212
+
213
+ // The deps still open, or "-"; list shows these so finished deps don't look stale.
214
+ const openDeps = (item, ids) => depList(item.dep).filter((d) => ids.has(d)).join(",") || "-";
215
+
216
+ const eligible = (items) => {
217
+ const ids = new Set(items.map((i) => i.id));
218
+ return items.filter((i) => depList(i.dep).every((d) => !ids.has(d)));
219
+ };
220
+
221
+ // An item as printed by next, list and show: deps already finished are dropped.
222
+ const display = (item, items) => fmt({ ...item, dep: openDeps(item, new Set(items.map((i) => i.id))) });
223
+
224
+ // Age: old numeric todos (no created time) first, by ID, then by creation time.
225
+ const byAge = (a, b) => (a.created ?? "").localeCompare(b.created ?? "") || a.id.localeCompare(b.id);
226
+ const byPriority = (a, b) => a.p - b.p || byAge(a, b);
227
+
228
+ // True if following deps from `id` leads back to `id`.
229
+ function hasCycle(items, id, dep) {
230
+ const deps = new Map(items.map((i) => [i.id, depList(i.dep)]));
231
+ deps.set(id, depList(dep));
232
+ const seen = new Set();
233
+ const stack = [...deps.get(id)];
234
+ while (stack.length) {
235
+ const cur = stack.pop();
236
+ if (cur === id) return true;
237
+ if (seen.has(cur)) continue;
238
+ seen.add(cur);
239
+ stack.push(...(deps.get(cur) ?? []));
240
+ }
241
+ return false;
242
+ }
243
+
244
+ // Refuses a cycle; warns about deps that are neither open nor in the archive (likely typos).
245
+ function checkDeps(items, id, dep) {
246
+ if (hasCycle(items, id, dep)) die(`circular dependency: ${id} -> ${dep} leads back to ${id}`);
247
+ const known = new Set([...items.map((i) => i.id), ...archivedIds()]);
248
+ const unknown = depList(dep).filter((d) => !known.has(d));
249
+ if (unknown.length) warn(`${unknown.join(", ")} not found (counts as done)`);
250
+ }
251
+
252
+ function parsePriority(value) {
253
+ const p = value === undefined ? 2 : Number(value);
254
+ if (![1, 2, 3].includes(p)) die(`bad priority '${value}' (use 1-3)`);
255
+ return p;
256
+ }
257
+
258
+ function parseSize(value) {
259
+ const size = value ?? "-";
260
+ if (!["S", "M", "L", "-"].includes(size)) die(`bad size '${size}' (use S, M, L or -)`);
261
+ return size;
262
+ }
263
+
264
+ // "T-001,T-k7q3" or "-"; an item can't depend on itself.
265
+ function parseDeps(value, selfId) {
266
+ if (value === undefined || value === "-") return "-";
267
+ const ids = value.replace(/\s+/g, "").split(",").map(checkId);
268
+ if (ids.includes(selfId)) die("task cannot depend on itself");
269
+ return [...new Set(ids)].join(",");
270
+ }
271
+
272
+ function parseTags(value) {
273
+ if (value === undefined || value === "-") return "-";
274
+ const tags = value.replace(/\s+/g, "");
275
+ if (!TAGS_RE.test(tags)) die(`bad tags '${value}' (lowercase, comma-separated, a-z 0-9 -)`);
276
+ return tags;
277
+ }
278
+
279
+ function find(items, raw) {
280
+ const id = checkId(raw);
281
+ return items.find((i) => i.id === id) ?? die(`${id} not found`);
282
+ }
283
+
284
+ // Pulls "-p 1", "--dep T-001", "--all"... out of argv; the rest are positionals.
285
+ function parseArgs(argv, valueFlags, boolFlags = []) {
286
+ const flags = {};
287
+ const pos = [];
288
+ for (let i = 0; i < argv.length; i++) {
289
+ const a = argv[i];
290
+ if (valueFlags.includes(a)) {
291
+ if (i + 1 >= argv.length) die(`${a} needs a value`);
292
+ flags[a] = argv[++i];
293
+ } else if (boolFlags.includes(a)) flags[a] = true;
294
+ else if (a.startsWith("-") && a.length > 1) die(`unknown option '${a}'`);
295
+ else pos.push(a);
296
+ }
297
+ return { flags, pos };
298
+ }
299
+
300
+ // ---- Commands -----------------------------------------------------------------------------
301
+
302
+ function add(argv) {
303
+ const { flags, pos } = parseArgs(argv, ["-p", "--dep", "--size", "--tag"]);
304
+ const text = pos.join(" ").split(/\s+/).filter(Boolean).join(" ");
305
+ if (!text) die("usage: add \"text\" [-p 1-3] [--dep T-A,T-B] [--size S|M|L] [--tag a,b]");
306
+ const p = parsePriority(flags["-p"]);
307
+ const size = parseSize(flags["--size"]);
308
+ const tags = parseTags(flags["--tag"]);
309
+ const items = loadItems();
310
+ const id = newId(new Set([...items.map((i) => i.id), ...archivedIds()]));
311
+ const dep = parseDeps(flags["--dep"], id);
312
+ checkDeps(items, id, dep);
313
+ writeItem({ id, p, dep, size, tags, desc: text, created: new Date().toISOString() });
314
+ console.log(id);
315
+ }
316
+
317
+ const bestOf = (items) => [...eligible(items)].sort(byPriority)[0];
318
+
319
+ // One item as a colored row. `cols` pads the dep and tags columns so rows line up.
320
+ function prettyRow(item, items, { blocked = false, isNext = false, mark = false, cols = { dep: 0, tags: 0 }, lead = "" } = {}) {
321
+ const ids = new Set(items.map((i) => i.id));
322
+ const open = openDeps(item, ids);
323
+ const bold = isNext ? ["bold"] : [];
324
+ const tone = (text, ...styles) => c(text, blocked ? [PALETTE.blocked, bold] : [styles, bold]);
325
+ const sep = tone(" | ");
326
+ const dep = open === "-" ? tone("-") : c(`⏸ ${open}`, blocked ? PALETTE.blockedDep : PALETTE.p2);
327
+ const detailMark = mark ? (item.hasBody ? c("+", PALETTE.marker) : " ") : "";
328
+ const head = [
329
+ lead + tone(item.id, PALETTE.id) + detailMark,
330
+ tone(String(item.p), PRIORITY_STYLE[item.p] ?? []),
331
+ padTo(dep, cols.dep),
332
+ tone(item.size, SIZE_STYLE[item.size] ?? []),
333
+ padTo(tone(item.tags), cols.tags),
334
+ ].join(sep);
335
+ const room = (process.stdout.columns || 0) - width(head) - width(sep);
336
+ const desc = room > 12 && [...item.desc].length > room ? `${[...item.desc].slice(0, room - 1).join("")}…` : item.desc;
337
+ return head + sep + tone(desc);
338
+ }
339
+
340
+ function next() {
341
+ const items = loadItems();
342
+ const best = bestOf(items);
343
+ if (!PRETTY) return console.log(best ? display(best, items) : "none");
344
+ console.log(best ? prettyRow(best, items, { isNext: true, lead: c("▶ ", PALETTE.arrow, "bold") }) : c("none", PALETTE.label));
345
+ }
346
+
347
+ function summary(items) {
348
+ const ready = eligible(items).length;
349
+ const urgent = items.filter((i) => i.p === 1).length;
350
+ const num = (n, style = PALETTE.num) => c(String(n), style);
351
+ return [
352
+ `${num(items.length)} ${c("open", PALETTE.label)}`,
353
+ `${num(ready, PALETTE.ready)} ${c("ready", PALETTE.label)}`,
354
+ `${num(items.length - ready, PALETTE.blockedCount)} ${c("blocked", PALETTE.label)}`,
355
+ `${num(urgent, urgent > 0 ? PALETTE.urgent : PALETTE.num)} ${c("P1", PALETTE.label)}`,
356
+ ].join(c(" · ", PALETTE.label));
357
+ }
358
+
359
+ function listPretty(shown, items, ok) {
360
+ const nextId = bestOf(items)?.id;
361
+ const ids = new Set(items.map((i) => i.id));
362
+ const cols = {
363
+ dep: Math.max(1, ...shown.map((i) => width(openDeps(i, ids) === "-" ? "-" : `⏸ ${openDeps(i, ids)}`))),
364
+ tags: Math.max(1, ...shown.map((i) => width(i.tags))),
365
+ };
366
+ const out = [];
367
+ for (const p of [1, 2, 3]) {
368
+ const group = shown.filter((i) => i.p === p);
369
+ if (!group.length) continue;
370
+ if (out.length) out.push("");
371
+ out.push(c(`P${p} ${"─".repeat(30)}`, PRIORITY_STYLE[p], "bold"));
372
+ for (const item of group) {
373
+ const isNext = item.id === nextId;
374
+ out.push(prettyRow(item, items, {
375
+ blocked: !ok.has(item.id), isNext, mark: true, cols,
376
+ lead: isNext ? c("▶ ", PALETTE.arrow, "bold") : " ",
377
+ }));
378
+ }
379
+ }
380
+ if (out.length) out.push("");
381
+ out.push(summary(items));
382
+ console.log(out.join("\n"));
383
+ }
384
+
385
+ function list(argv) {
386
+ const { flags } = parseArgs(argv, ["-p", "--tag"], ["--all"]);
387
+ const tag = flags["--tag"]?.toLowerCase();
388
+ const p = flags["-p"] === undefined ? null : Number(flags["-p"]);
389
+ if (p !== null && ![1, 2, 3].includes(p)) die(`bad priority '${flags["-p"]}' (use 1-3)`);
390
+ const items = loadItems();
391
+ const ok = new Set(eligible(items).map((i) => i.id));
392
+ const shown = items
393
+ .sort(byPriority)
394
+ .filter((i) => (p === null || i.p === p) && (!tag || i.tags.split(",").includes(tag)) && (flags["--all"] || ok.has(i.id)));
395
+ if (PRETTY) return listPretty(shown, items, ok);
396
+ const out = shown.map((i) => (ok.has(i.id) ? "" : "*") + display(i, items));
397
+ if (out.length) console.log(out.join("\n"));
398
+ }
399
+
400
+ const wrap = (text, w) => {
401
+ const lines = [];
402
+ let line = "";
403
+ for (const word of text.split(" ")) {
404
+ if (line && [...line].length + 1 + [...word].length > w) {
405
+ lines.push(line);
406
+ line = word;
407
+ } else line = line ? `${line} ${word}` : word;
408
+ }
409
+ return line ? [...lines, line] : lines;
410
+ };
411
+
412
+ const clip = (text, n) => ([...text].length > n && n > 1 ? `${[...text].slice(0, n - 1).join("")}…` : text);
413
+
414
+ // The pretty `show`: a small card with title, status, what it waits on and what waits on it.
415
+ function showCard(item, items, detailText) {
416
+ const ids = new Set(items.map((i) => i.id));
417
+ const byId = new Map(items.map((i) => [i.id, i]));
418
+ const cols = process.stdout.columns || 80;
419
+ const label = (text) => c(padTo(text, 9), PALETTE.label);
420
+ const ref = (id) => {
421
+ const other = byId.get(id);
422
+ return other ? `${c(id, other === item ? PALETTE.id : eligible(items).includes(other) ? PALETTE.id : PALETTE.blocked)} ${clip(other.desc, cols - 18)}` : `${c(id, PALETTE.rule)} ${c("(done)", PALETTE.label)}`;
423
+ };
424
+ const open = depList(item.dep).filter((d) => ids.has(d));
425
+ const blocked = open.length > 0;
426
+ const isNext = bestOf(items)?.id === item.id;
427
+ const status = blocked ? c(`blocked by ${open.join(", ")}`, PALETTE.blockedDep) : isNext ? c("▶ ready, picked by next", PALETTE.arrow, "bold") : c("ready", PALETTE.ready);
428
+ const waiting = items.filter((i) => depList(i.dep).includes(item.id)).map((i) => i.id);
429
+ const out = [
430
+ `${c(item.id, PALETTE.id, "bold")}${item.hasBody ? c(" +", PALETTE.marker) : ""}`,
431
+ ...wrap(item.desc, Math.max(30, cols - 2)).map((l) => ` ${c(l, blocked ? PALETTE.blocked : "bold")}`),
432
+ "",
433
+ `${label("priority")}${c(`P${item.p}`, PRIORITY_STYLE[item.p] ?? [])}`,
434
+ `${label("size")}${item.size === "-" ? c("-", PALETTE.label) : c(item.size, SIZE_STYLE[item.size] ?? [])}`,
435
+ `${label("tags")}${item.tags === "-" ? c("-", PALETTE.label) : item.tags.split(",").map((t) => c(t, PALETTE.id)).join(c(", ", PALETTE.label))}`,
436
+ `${label("status")}${status}`,
437
+ ];
438
+ const deps = depList(item.dep);
439
+ if (deps.length) out.push(`${label("waits on")}${deps.map(ref).join(`\n${" ".repeat(9)}`)}`);
440
+ if (waiting.length) out.push(`${label("blocks")}${waiting.map(ref).join(`\n${" ".repeat(9)}`)}`);
441
+ if (detailText) out.push("", c(`── detail ${"─".repeat(Math.max(0, Math.min(cols, 60) - 10))}`, PALETTE.rule));
442
+ console.log(out.join("\n"));
443
+ }
444
+
445
+ function show(argv) {
446
+ const items = loadItems();
447
+ const item = find(items, argv[0]);
448
+ const detailText = bodyOf(item.id);
449
+ if (PRETTY) showCard(item, items, detailText);
450
+ else console.log(display(item, items));
451
+ if (detailText) console.log(detailText);
452
+ }
453
+
454
+ function edit(argv) {
455
+ const item = find(loadItems(), argv[0]);
456
+ const text = argv.slice(1).join(" ").split(/\s+/).filter(Boolean).join(" ");
457
+ if (!text) die('usage: edit T-XXX "new text"');
458
+ writeItem({ ...item, desc: text }, bodyOf(item.id));
459
+ }
460
+
461
+ function set(argv) {
462
+ const { flags, pos } = parseArgs(argv, ["-p", "--dep", "--size"]);
463
+ const items = loadItems();
464
+ const item = find(items, pos[0]);
465
+ if (pos.length !== 1 || !["-p", "--dep", "--size"].some((f) => f in flags)) {
466
+ die("usage: set T-XXX [-p 1-3] [--dep T-A,T-B|-] [--size S|M|L|-]");
467
+ }
468
+ const next = { ...item };
469
+ if ("-p" in flags) next.p = parsePriority(flags["-p"]);
470
+ if ("--size" in flags) next.size = parseSize(flags["--size"]);
471
+ if ("--dep" in flags) {
472
+ next.dep = parseDeps(flags["--dep"], item.id);
473
+ checkDeps(items, item.id, next.dep);
474
+ }
475
+ writeItem(next, bodyOf(item.id));
476
+ }
477
+
478
+ function detail(argv) {
479
+ const { flags, pos } = parseArgs(argv, ["--file"], ["--append"]);
480
+ const item = find(loadItems(), pos[0]);
481
+ let text;
482
+ if (flags["--file"] === "-") text = fs.readFileSync(0, "utf8"); // stdin, e.g. a heredoc
483
+ else if (flags["--file"]) {
484
+ if (!fs.existsSync(flags["--file"])) die(`no such file '${flags["--file"]}'`);
485
+ text = fs.readFileSync(flags["--file"], "utf8");
486
+ } else text = pos.slice(1).join(" ");
487
+ if (!text.trim()) die("usage: detail T-XXX 'text' | --file path|- [--append]");
488
+ const old = flags["--append"] ? bodyOf(item.id) : "";
489
+ writeItem(item, old ? `${old}\n${text.trimEnd()}` : text);
490
+ }
491
+
492
+ function tag(argv) {
493
+ const item = find(loadItems(), argv[0]);
494
+ if (argv.length !== 2) die("usage: tag T-XXX a,b (\"-\" clears)");
495
+ writeItem({ ...item, tags: parseTags(argv[1]) }, bodyOf(item.id));
496
+ }
497
+
498
+ function remove(argv, archive) {
499
+ const item = find(loadItems(), argv[0]);
500
+ fs.rmSync(fileOf(item.id), { force: true });
501
+ if (archive) {
502
+ const date = new Date().toISOString().slice(0, 10);
503
+ const lines = readLines(ARCHIVE);
504
+ writeAtomic(ARCHIVE, [...(lines.length ? lines : ["# Completed: original line | done YYYY-MM-DD"]), `${fmt(item)} | done ${date}`]);
505
+ }
506
+ const left = loadItems({ quiet: true });
507
+ const ids = new Set(left.map((i) => i.id));
508
+ const freed = left
509
+ .filter((i) => depList(i.dep).includes(item.id) && depList(i.dep).every((d) => !ids.has(d)))
510
+ .map((i) => i.id);
511
+ if (archive && PRETTY) console.log(`${c("done", PALETTE.label)} ${c(item.id, PALETTE.done)}`);
512
+ if (freed.length) console.log(`${c("unblocked:", PALETTE.label)} ${freed.map((id) => c(id, PALETTE.unblocked)).join(", ")}`);
513
+ }
514
+
515
+ // ---- stats ---------------------------------------------------------------------------
516
+ const BAR = 20;
517
+ const bar = (n, max, styles) => {
518
+ const len = n === 0 ? 0 : Math.max(1, Math.round((n / max) * BAR));
519
+ return padTo(c("█".repeat(len), styles), BAR);
520
+ };
521
+
522
+ // Archive lines end in "| done YYYY-MM-DD"; count the last 7 days, today included.
523
+ function doneThisWeek() {
524
+ const since = new Date(Date.now() - 6 * 864e5).toISOString().slice(0, 10);
525
+ return readLines(ARCHIVE).filter((l) => {
526
+ const date = /\|\s*done (\d{4}-\d{2}-\d{2})\s*$/.exec(l)?.[1];
527
+ return date !== undefined && date >= since;
528
+ }).length;
529
+ }
530
+
531
+ function stats() {
532
+ const items = loadItems();
533
+ const out = [summary(items), ""];
534
+ const rows = (title, buckets) => {
535
+ const max = Math.max(1, ...buckets.map((b) => b.n));
536
+ out.push(c(title, PALETTE.label));
537
+ for (const b of buckets) out.push(` ${padTo(c(b.label, b.styles), 3)} ${bar(b.n, max, b.styles)} ${c(String(b.n), PALETTE.num)}`);
538
+ out.push("");
539
+ };
540
+ rows("priority", [1, 2, 3].map((p) => ({ label: `P${p}`, n: items.filter((i) => i.p === p).length, styles: PRIORITY_STYLE[p] })));
541
+ rows("size", ["S", "M", "L", "-"].map((z) => ({ label: z, n: items.filter((i) => i.size === z).length, styles: SIZE_STYLE[z] ?? PALETTE.label })));
542
+ out.push(`${c("done this week", PALETTE.label)} ${c(String(doneThisWeek()), PALETTE.done)}`);
543
+ console.log(out.join("\n"));
544
+ }
545
+
546
+ // ---- graph ---------------------------------------------------------------------------
547
+ // A tree per ready item, with the items waiting on it indented below. Items in a cycle
548
+ // (or only reachable through one) are listed last.
549
+ function graph() {
550
+ const items = loadItems().sort(byAge);
551
+ const ids = new Set(items.map((i) => i.id));
552
+ const byId = new Map(items.map((i) => [i.id, i]));
553
+ const open = (i) => depList(i.dep).filter((d) => ids.has(d));
554
+ const children = (id) => items.filter((i) => open(i).includes(id));
555
+ const inCycle = (start) => {
556
+ const seen = new Set();
557
+ const stack = [...open(byId.get(start))];
558
+ while (stack.length) {
559
+ const cur = stack.pop();
560
+ if (cur === start) return true;
561
+ if (seen.has(cur)) continue;
562
+ seen.add(cur);
563
+ stack.push(...open(byId.get(cur)));
564
+ }
565
+ return false;
566
+ };
567
+ const cyclic = new Set(items.filter((i) => inCycle(i.id)).map((i) => i.id));
568
+ const style = (i) => (cyclic.has(i.id) ? PALETTE.cycle : open(i).length ? PALETTE.blocked : PALETTE.id);
569
+ const room = Math.max(30, (process.stdout.columns || 80) - 12);
570
+ const label = (i) => {
571
+ const desc = [...i.desc].length > room ? `${[...i.desc].slice(0, room - 1).join("")}…` : i.desc;
572
+ return `${c(i.id, style(i))} ${c(desc, open(i).length ? PALETTE.blocked : [])}`;
573
+ };
574
+ const printed = new Set();
575
+ const out = [];
576
+ const walk = (i, prefix, last, top) => {
577
+ const again = printed.has(i.id);
578
+ out.push(top ? label(i) : `${prefix}${last ? "└─ " : "├─ "}${label(i)}${again ? c(" …", PALETTE.rule) : ""}`);
579
+ if (again) return;
580
+ printed.add(i.id);
581
+ const kids = children(i.id);
582
+ const below = top ? "" : prefix + (last ? " " : "│ ");
583
+ kids.forEach((k, n) => walk(k, below, n === kids.length - 1, false));
584
+ };
585
+ for (const i of items.filter((x) => !open(x).length)) walk(i, "", true, true);
586
+ const rest = items.filter((i) => !printed.has(i.id));
587
+ if (rest.length) {
588
+ out.push("", c("cycle / unreachable:", PALETTE.cycle));
589
+ for (const i of rest) out.push(` ${label(i)} ${c(`⏸ ${open(i).join(",")}`, PALETTE.blockedDep)}`);
590
+ }
591
+ if (out.length) console.log(out.join("\n"));
592
+ }
593
+
594
+ // ---- html ----------------------------------------------------------------------------
595
+ const esc = (t) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
596
+
597
+ // Colors are CSS variables, mirroring PALETTE, with light and dark sets.
598
+ function html(argv) {
599
+ const { flags } = parseArgs(argv, ["--out"]);
600
+ const items = loadItems().sort(byPriority);
601
+ const ok = new Set(eligible(items).map((i) => i.id));
602
+ const nextId = bestOf(items)?.id;
603
+ const ids = new Set(items.map((i) => i.id));
604
+ const sections = [1, 2, 3].map((p) => {
605
+ const rows = items.filter((i) => i.p === p).map((i) => {
606
+ const open = openDeps(i, ids);
607
+ return `<tr class="${ok.has(i.id) ? "ready" : "blocked"}${i.id === nextId ? " next" : ""}">` +
608
+ `<td class="id">${i.id === nextId ? "▶ " : ""}${i.id}${i.hasBody ? '<span class="mark">+</span>' : ""}</td>` +
609
+ `<td class="p p${i.p}">P${i.p}</td><td class="size size${i.size === "-" ? "x" : i.size}">${esc(i.size)}</td>` +
610
+ `<td class="dep">${open === "-" ? "" : "⏸ " + esc(open)}</td><td class="tags">${esc(i.tags === "-" ? "" : i.tags)}</td>` +
611
+ `<td>${esc(i.desc)}</td></tr>`;
612
+ });
613
+ return rows.length ? `<h2 class="p p${p}">P${p}</h2><table>${rows.join("")}</table>` : "";
614
+ });
615
+ const ready = ok.size;
616
+ const page = `<!doctype html>
617
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
618
+ <title>Todo board</title>
619
+ <style>
620
+ :root { color-scheme: light dark; --bg: #fbfaf7; --fg: #1d1d1b; --muted: #77746b; --line: #e4e1d8;
621
+ --p1: #c4281c; --p2: #a86d00; --p3: #1f5fbf; --sizeS: #1b7f3a; --sizeM: #a86d00; --sizeL: #9c27b0;
622
+ --id: #0b7f8a; --blocked: #8f8c83; --dep: #0b7f8a; --ready: #1b7f3a; }
623
+ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { --bg: #16171a; --fg: #e8e6df; --muted: #8d8a81;
624
+ --line: #2d2f34; --p1: #ff6b60; --p2: #e3b341; --p3: #6ea8ff; --sizeS: #56d364; --sizeM: #e3b341; --sizeL: #d98cf0;
625
+ --id: #56c7d4; --blocked: #6f6c64; --dep: #56c7d4; --ready: #56d364; } }
626
+ :root[data-theme="dark"] { --bg: #16171a; --fg: #e8e6df; --muted: #8d8a81; --line: #2d2f34; --p1: #ff6b60; --p2: #e3b341;
627
+ --p3: #6ea8ff; --sizeS: #56d364; --sizeM: #e3b341; --sizeL: #d98cf0; --id: #56c7d4; --blocked: #6f6c64; --dep: #56c7d4; --ready: #56d364; }
628
+ body { background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; margin: 0 auto; max-width: 960px; padding: 24px 16px; }
629
+ h1 { font-size: 20px; margin: 0 0 4px } .sum { color: var(--muted); margin-bottom: 8px } .sum b { color: var(--fg) } .sum .ready { color: var(--ready) }
630
+ h2 { font-size: 13px; letter-spacing: .08em; margin: 24px 0 6px; border-bottom: 1px solid var(--line); padding-bottom: 4px }
631
+ table { border-collapse: collapse; width: 100% } td { padding: 4px 8px 4px 0; vertical-align: top; border-bottom: 1px solid var(--line) }
632
+ .id { color: var(--id); white-space: nowrap; font-family: ui-monospace, monospace } .mark { color: var(--muted) } .dep { color: var(--dep); white-space: nowrap }
633
+ .tags { color: var(--muted) } .p1 { color: var(--p1); font-weight: 700 } .p2 { color: var(--p2) } .p3 { color: var(--p3) }
634
+ .sizeS { color: var(--sizeS) } .sizeM { color: var(--sizeM) } .sizeL { color: var(--sizeL); font-weight: 700 }
635
+ tr.blocked td { color: var(--blocked) } tr.blocked .id, tr.blocked .p, tr.blocked .size { color: var(--blocked) } tr.blocked .dep { color: var(--dep); opacity: .7 }
636
+ tr.next td { font-weight: 700 } tr.next .id { color: var(--ready) }
637
+ @media (max-width: 600px) { .tags, td.size { display: none } }
638
+ </style></head><body>
639
+ <h1>Todo board</h1>
640
+ <div class="sum"><b>${items.length}</b> open · <span class="ready"><b>${ready}</b> ready</span> · <b>${items.length - ready}</b> blocked · <b>${items.filter((i) => i.p === 1).length}</b> P1</div>
641
+ ${sections.join("\n")}
642
+ </body></html>
643
+ `;
644
+ if (flags["--out"]) fs.writeFileSync(flags["--out"], page);
645
+ else process.stdout.write(page);
646
+ }
647
+
648
+ function help() {
649
+ console.log(`usage: todo.mjs <command> [args] [color flags]
650
+
651
+ commands:
652
+ add "text" [-p 1-3] [--dep T-A,T-B] [--size S|M|L] [--tag a,b] prints the new ID
653
+ next | list [-p N] [--tag X] [--all] | show T-XXX
654
+ set T-XXX [-p N] [--dep ...|-] [--size S|M|L|-] | edit T-XXX "text" | tag T-XXX a,b
655
+ detail T-XXX "text" | --file path|- [--append] ("-" reads stdin)
656
+ done T-XXX | rm T-XXX
657
+ stats | graph | html [--out file]
658
+
659
+ color (display only; output is plain when piped):
660
+ --color=auto|always|never auto = TTY and no NO_COLOR (FORCE_COLOR forces on)
661
+ --pretty same as --color=always; overrides NO_COLOR
662
+ --plain same as --color=never
663
+ With color on, list groups by priority and ends with a summary line.`);
664
+ }
665
+
666
+ let colorMode = "auto";
667
+ const rest = [];
668
+ for (const a of process.argv.slice(2)) {
669
+ if (a === "--pretty") colorMode = "always";
670
+ else if (a === "--plain") colorMode = "never";
671
+ else if (a.startsWith("--color=")) {
672
+ colorMode = a.slice("--color=".length);
673
+ if (!["auto", "always", "never"].includes(colorMode)) die(`bad color mode '${colorMode}' (auto, always or never)`);
674
+ } else rest.push(a);
675
+ }
676
+ PRETTY = colorEnabled(colorMode, process.stdout);
677
+ ERR_PRETTY = colorEnabled(colorMode, process.stderr);
678
+
679
+ const [cmd, ...args] = rest;
680
+ switch (cmd) {
681
+ case "add": add(args); break;
682
+ case "next": next(); break;
683
+ case "list": list(args); break;
684
+ case "set": set(args); break;
685
+ case "detail": detail(args); break;
686
+ case "edit": edit(args); break;
687
+ case "tag": tag(args); break;
688
+ case "show": show(args); break;
689
+ case "done": remove(args, true); break;
690
+ case "rm": remove(args, false); break;
691
+ case "stats": stats(); break;
692
+ case "graph": graph(); break;
693
+ case "html": html(args); break;
694
+ case "help": case "--help": case "-h": help(); break;
695
+ default: die("usage: todo.mjs add|next|list|show|set|detail|edit|tag|done|rm|stats|graph|html|help");
696
+ }