/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:
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user