import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs'; import { join, resolve } from 'node:path'; // The todo tree: `todo//.md`, one item per file, frontmatter + hand-written prose. // `todo/README.md` is the format's contract; the parts that matter here: // // - `id` (`/`) 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 has ONE caller now, and that is deliberate ── // // `scripts/todo-index.ts` — the CLI behind `bun todo:index`, which regenerates TODO.md from a terminal // with no Officer running. The `/api/todos` router and the board are gone: they became the `kanban` // plugin on 2026-08-29, which generalised this reader over a root so it can show any project's folder // rather than only the one the server was started in. // // This copy stayed because the folder it reads is the PLATFORM'S OWN backlog, committed with the // platform's own code. Moving it would have made regenerating this repository's TODO.md depend on a // plugin being installed, which is the wrong way round for a repo's own documentation. // // So there are two copies of this logic now, and they will drift. That is a known cost, recorded here // and in the plugin's README rather than discovered later. The honest fix, on the day it matters, is to // delete the CLI and let the plugin own the format outright. /** 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', 'backburner', '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 { const end = text.indexOf('\n---', 4); if (!text.startsWith('---') || end === -1) return {}; const out: Record = {}; 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 { 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()); }