/todos: a kanban board over the todo/ tree, owner-only

The filesystem is the database: GET reads the tree through the same module the
index generator now imports, PATCH splices frontmatter (status, priority, due,
order) and never touches a body byte, and every write regenerates TODO.md.
Columns open/doing/blocked/done, native drag between them, selection and theme
filter in the URL. Registry entry 'todos' (kind admin, 22 entries, docs:check
green), isSuperAdmin double-gate on the router.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 00:18:14 +00:00
co-authored by Claude Fable 5
parent fdf10f02f7
commit 420cd07507
15 changed files with 409 additions and 149 deletions
+176
View File
@@ -0,0 +1,176 @@
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
import { join, resolve } from 'node:path';
// The todo tree: `todo/<theme>/<item>.md`, one item per file, frontmatter + hand-written prose.
// `todo/README.md` is the format's contract; the parts that matter here:
//
// - `id` (`<theme>/<slug>`) is the identity and the filename. Everything keys on it.
// - Frontmatter is the machine's half. The BODY IS THE OWNER'S and is never rewritten by code —
// `patchFrontmatter` below splices keys into the frontmatter block and must not touch a byte past
// the closing fence.
// - `TODO.md` at the repo root is GENERATED output (`writeIndex`), never a write target.
//
// This module is the one reader and one writer. `scripts/todo-index.ts` (the CLI) and the `/api/todos`
// router both import it, so the board and the generated index cannot disagree about what the tree holds.
/** Resolved per call, not at module load: the server asserts its cwd is the repo root at boot. */
const todoDir = () => resolve(process.cwd(), 'todo');
export const TODO_STATUSES = ['open', 'doing', 'blocked', 'done'] as const;
export type TodoStatus = (typeof TODO_STATUSES)[number];
export type TodoItem = {
id: string;
title: string;
theme: string;
status: string;
created: string;
/** `urgent` or empty — deliberately no scale, see todo/README.md. */
priority: string;
due: string;
order: number;
file: string;
};
export type TodoTheme = { theme: string; heading: string; note: string; items: TodoItem[] };
/** Minimal frontmatter reader — `key: value`, values optionally JSON-quoted. No YAML dependency. */
export function frontmatter(text: string): Record<string, string> {
const end = text.indexOf('\n---', 4);
if (!text.startsWith('---') || end === -1) return {};
const out: Record<string, string> = {};
for (const line of text.slice(4, end).split('\n')) {
const m = line.match(/^([a-z_]+):\s*(.*)$/);
if (!m || !m[1]) continue;
const raw = (m[2] ?? '').trim();
out[m[1]] = raw.startsWith('"') || raw.startsWith("'") ? (JSON.parse(normalizeQuotes(raw)) as string) : raw;
}
return out;
}
// Prettier rewrites the frontmatter's double quotes to single, which JSON.parse refuses. Both mean the
// same string; accept both rather than fighting the formatter.
const normalizeQuotes = (raw: string) =>
raw.startsWith("'") ? JSON.stringify(raw.slice(1, -1).replace(/\\'/g, "'")) : raw;
/** The body: everything after the frontmatter block's closing fence and its trailing blank lines. */
export function bodyOf(text: string): string {
const end = text.indexOf('\n---', 4);
if (!text.startsWith('---') || end === -1) return text;
return text.slice(end + 4).replace(/^\n+/, '');
}
/**
* Splice `updates` into the frontmatter block, touching nothing else — the body is hand-written prose
* and a rewrite would produce a diff nobody can read. An existing key is replaced in place; a new one
* is appended just before the closing fence. Returns the text unchanged if there is no frontmatter.
*/
export function patchFrontmatter(text: string, updates: Record<string, string>): string {
const end = text.indexOf('\n---', 4);
if (!text.startsWith('---') || end === -1) return text;
let block = text.slice(4, end);
for (const [key, value] of Object.entries(updates)) {
const line = `${key}: ${value}`;
const re = new RegExp(`^${key}:.*$`, 'm');
block = re.test(block) ? block.replace(re, line) : `${block}\n${line}`;
}
return `---\n${block}${text.slice(end)}`;
}
export function readTree(): { themes: TodoTheme[] } {
const root = todoDir();
const themes = readdirSync(root)
.filter((d) => statSync(join(root, d)).isDirectory())
.sort()
.map((theme) => {
const files = readdirSync(join(root, theme)).filter((f) => f.endsWith('.md'));
const indexText = readFileSync(join(root, theme, '_index.md'), 'utf8');
const meta = frontmatter(indexText);
const items = files
.filter((f) => f !== '_index.md')
.map((f) => {
const fm = frontmatter(readFileSync(join(root, theme, f), 'utf8'));
return {
id: fm.id ?? `${theme}/${f.replace(/\.md$/, '')}`,
title: fm.title ?? f,
theme,
status: fm.status ?? 'open',
created: fm.created ?? '',
priority: fm.priority ?? '',
due: fm.due ?? '',
order: Number(fm.order ?? 0),
file: `todo/${theme}/${f}`,
};
})
.sort((a, b) => a.order - b.order);
return { theme, heading: meta.heading ?? theme, note: bodyOf(indexText).trim(), items };
});
return { themes };
}
const HEADER = `# TODO — the index
Deferred work. **The items live in \`todo/\`, one file each**, grouped by theme; this file is the map
over them and is GENERATED — run \`bun run todo:index\` after any change rather than editing it here.
Format and the two-way-sync contract: \`todo/README.md\`. Browse and edit it at \`/todos\` in the app.
**Context, corrected 2026-08-07.** This list used to open by saying Officer was "collapsing from
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
indirection as accidental complexity. **That direction was reversed.** The permission model shipped on
2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each one their own
Gitea account through the platform. Per-user scoping is now a requirement, and the items that proposed
deleting it have been removed rather than left to mislead the next reader.
What did NOT reverse: \`execution\` permissions (terminal, chat, tasks, files, desktop, browser) run as
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
`;
export function renderIndex(): string {
const { themes } = readTree();
const all = themes.flatMap((t) => t.items);
const open = all.filter((i) => i.status !== 'done').length;
const done = all.length - open;
const urgent = all
.filter((i) => i.status !== 'done' && i.priority === 'urgent')
.sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999'))
.map((i) => `- ${i.due ? `**due ${i.due}** — ` : ''}**${i.title}** — [${i.id}](${i.file})`);
const rows = themes
.filter((t) => t.items.length)
.map((t) => {
const o = t.items.filter((i) => i.status !== 'done').length;
return `| [${t.heading}](todo/${t.theme}/) | ${o} | ${t.items.length - o} |`;
});
// A theme holding only prose has no row above, and an entry nobody can see is the failure mode the
// whole split risks. List them explicitly rather than letting them age out of view.
const notes = themes.filter((t) => !t.items.length).map((t) => `- [${t.heading}](todo/${t.theme}/_index.md)`);
const recent = all
.filter((i) => i.status !== 'done')
.sort((a, b) => b.created.localeCompare(a.created))
.slice(0, 10)
.map((i) => `- \`${i.created}\` **${i.title}** — [${i.id}](${i.file})`);
return [
HEADER,
...(urgent.length ? [`## Urgent\n`, ...urgent, ``] : []),
`## Themes\n`,
`**${open} open, ${done} closed**, across ${rows.length} themes.\n`,
`| Theme | Open | Closed |`,
`| --- | --- | --- |`,
...rows,
``,
`## Records — no items, kept for the reasoning\n`,
...notes,
``,
`## Newest ten\n`,
...recent,
``,
].join('\n');
}
export function writeIndex(): void {
writeFileSync(resolve(process.cwd(), 'TODO.md'), renderIndex());
}