todo: one file per item, grouped by theme, with a generated index
TODO.md had reached 1050 lines and 72 open items across 28 sections, read mostly on a phone. It is now a directory: todo/<theme>/<item>.md, one card per file, with frontmatter carrying id, status, created date and order, and the prose body copied across verbatim. TODO.md becomes a generated index (bun run todo:index). Dates: where an item's prose stated one it is used; otherwise its old section's; otherwise 2026-08-20, floored at the day before the split rather than invented. date_source records which. Shaped for a two-way Kanban board, filed as todo/tooling — a card is a file, id is the identity, frontmatter is the machine's half and the body is the owner's. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Regenerate `TODO.md` — the index over `todo/`.
|
||||
*
|
||||
* The index is the one file everybody reads, so it is the one most able to lie. It is generated rather
|
||||
* than maintained: `bun run todo:index` after any change to `todo/`, and it cannot drift from the tree
|
||||
* it describes.
|
||||
*/
|
||||
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const ROOT = new URL('..', import.meta.url).pathname;
|
||||
const OUT = join(ROOT, 'todo');
|
||||
|
||||
export type Item = {
|
||||
id: string;
|
||||
title: string;
|
||||
theme: string;
|
||||
status: string;
|
||||
created: string;
|
||||
order: number;
|
||||
file: string;
|
||||
};
|
||||
|
||||
/** 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('"') ? (JSON.parse(raw) as string) : raw;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function readTree(): { themes: { theme: string; heading: string; items: Item[] }[] } {
|
||||
const themes = readdirSync(OUT)
|
||||
.filter((d) => statSync(join(OUT, d)).isDirectory())
|
||||
.sort()
|
||||
.map((theme) => {
|
||||
const files = readdirSync(join(OUT, theme)).filter((f) => f.endsWith('.md'));
|
||||
const meta = frontmatter(readFileSync(join(OUT, theme, '_index.md'), 'utf8'));
|
||||
const items = files
|
||||
.filter((f) => f !== '_index.md')
|
||||
.map((f) => {
|
||||
const fm = frontmatter(readFileSync(join(OUT, theme, f), 'utf8'));
|
||||
return {
|
||||
id: fm.id ?? `${theme}/${f.replace(/\.md$/, '')}`,
|
||||
title: fm.title ?? f,
|
||||
theme,
|
||||
status: fm.status ?? 'open',
|
||||
created: fm.created ?? '',
|
||||
order: Number(fm.order ?? 0),
|
||||
file: `todo/${theme}/${f}`,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.order - b.order);
|
||||
return { theme, heading: meta.heading ?? theme, 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\`.
|
||||
|
||||
**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.
|
||||
`;
|
||||
|
||||
function render(): string {
|
||||
const { themes } = readTree();
|
||||
const open = themes.reduce((n, t) => n + t.items.filter((i) => i.status !== 'done').length, 0);
|
||||
const done = themes.reduce((n, t) => n + t.items.filter((i) => i.status === 'done').length, 0);
|
||||
|
||||
const rows = themes
|
||||
.filter((t) => t.items.length)
|
||||
.map((t) => {
|
||||
const o = t.items.filter((i) => i.status !== 'done').length;
|
||||
const d = t.items.length - o;
|
||||
return `| [${t.heading}](todo/${t.theme}/) | ${o} | ${d} |`;
|
||||
});
|
||||
|
||||
const recent = themes
|
||||
.flatMap((t) => t.items)
|
||||
.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})`);
|
||||
|
||||
// A theme holding only prose has no row above, and an entry nobody can see is the failure mode this
|
||||
// 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)`);
|
||||
|
||||
return [
|
||||
HEADER,
|
||||
`## 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');
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
writeFileSync(join(ROOT, 'TODO.md'), render());
|
||||
console.log('TODO.md regenerated');
|
||||
}
|
||||
Reference in New Issue
Block a user