Adds priority/due to the item format and an Urgent section to the generated index. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
141 lines
5.4 KiB
TypeScript
141 lines
5.4 KiB
TypeScript
/**
|
|
* 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;
|
|
/** `urgent` or empty — see todo/README.md for why there is no scale. */
|
|
priority: string;
|
|
due: 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 ?? '',
|
|
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, 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)`);
|
|
|
|
// Urgent goes above everything. An item the owner called urgent, sorted in among seventy-five others
|
|
// by theme, is an item nobody sees on the day it matters.
|
|
const urgent = themes
|
|
.flatMap((t) => t.items)
|
|
.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})`);
|
|
|
|
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');
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
writeFileSync(join(ROOT, 'TODO.md'), render());
|
|
console.log('TODO.md regenerated');
|
|
}
|