`/todos` is now the `kanban` plugin. removed here: the router, the screen, the route, the TodoBoard panel, its AppRegistry entry and the `todos` permission. WHAT STAYED, and it is a seam rather than an oversight: `todo/` and `scripts/todo-index.ts`. that folder is the platform's OWN backlog, committed with the platform's own code, and the CLI regenerates TODO.md from a terminal with no Officer running. moving either would have made this repository's own documentation depend on a plugin being installed. so `todo-tree.ts` stays too, with one caller instead of two, and there are now two copies of that logic which will drift. recorded in the file itself and in the plugin's README rather than discovered in six months. the honest fix on the day it matters is to delete the CLI and let the plugin own the format. THE TOTALITY CHECK EARNED ITS KEEP. removing the router left `todos` in permissions/registry.ts and the server REFUSED TO BOOT — exactly what that guard is for. a stale permission naming a route that no longer exists is the kind of thing that otherwise sits there for months granting access to nothing. docs:check then caught two more: the generated platform skill still listed `/todos`, and AGENTS.md claimed 21 permission entries against a registry now holding 20. both fixed, and the AGENTS.md note now explains the drop the same way it already explains caldav's. verified: server boots, `bun todo:index` still regenerates TODO.md, 1665 tests pass (up from 1657 — the ported tree tests run in the plugin now), typecheck clean, docs:check green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
189 lines
8.2 KiB
TypeScript
189 lines
8.2 KiB
TypeScript
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 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<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());
|
|
}
|