/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:
2026-08-25 00:18:14 +00:00
co-authored by Claude Fable 5
parent fdf10f02f7
commit 420cd07507
15 changed files with 409 additions and 149 deletions
+1
View File
@@ -107,6 +107,7 @@ export function App() {
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
<Route path="/todos" element={<Dashboard.TodosScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
@@ -0,0 +1,12 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /todos — the Kanban board over the repo's own `todo/` tree (one file per item, todo/README.md).
// Selection and the theme filter are `?selected=` / `?theme=`, read by the panel itself.
export const TodosScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/todos', defaultLayout);
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['todo-board'], fallback: 'todo-board' }} />;
};
@@ -0,0 +1,8 @@
import type { LayoutNode } from 'officerdev';
// One panel: the board carries its own filter bar and detail pane, driven by the URL.
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'todo-board',
appType: 'todo-board',
};
@@ -0,0 +1 @@
export * from './TodosScreen';
@@ -23,3 +23,4 @@ export * from './Terminal';
export * from './Jobs';
export * from './QrTransfer';
export * from './SendOff';
export * from './Todos';
+93
View File
@@ -0,0 +1,93 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import { isSuperAdmin } from '../../super-admin';
import { TODO_STATUSES, bodyOf, frontmatter, patchFrontmatter, readTree, writeIndex } from './todo-tree';
/*
/api/todos — the board over `todo/`, the platform's own backlog (one file per item, todo/README.md).
The FILESYSTEM is the database, deliberately: the items are already versioned, reviewed and synced
between machines by git, and a second store would be a second truth. So this router is thin — read the
tree, splice frontmatter — and everything it writes is a small, reviewable git diff.
Two rules from the format's contract, enforced here rather than trusted to callers:
- The BODY is the owner's half and is never rewritten. Only `patchFrontmatter` touches a file, and it
stops at the closing fence.
- `TODO.md` is generated output. Every write regenerates it via `writeIndex()`, so the index cannot
drift from the tree — the board never edits it directly.
── Owner only, explicitly ──
`todos` is `kind: 'admin'` in the permission registry, so the global gate already refuses a non-owner.
This is the same belt-and-braces the other admin routers add: a registry mistake must not turn into
members reading — or editing — the platform's own backlog.
*/
export const todosRouter = createRouter();
todosRouter.use(async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('The todo board is owner-only');
return next();
});
/** `<theme>/<slug>`, both kebab-case — also the path traversal guard, since it admits no dot or slash pair. */
const ID_SHAPE = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
const fileForId = (id: string): string => {
if (!ID_SHAPE.test(id)) throw errors.BAD_REQUEST(`not a todo id: ${id}`);
const [theme, slug] = id.split('/');
const path = resolve(process.cwd(), join('todo', theme!, `${slug}.md`));
if (!existsSync(path)) throw errors.NOT_FOUND(`no todo at ${id}`);
return path;
};
/** GET /api/todos — the whole tree, no bodies. What the board renders. */
todosRouter.get('/', (ctx) => ctx.json(readTree()));
/** GET /api/todos/item?id=<theme>/<slug> — one item, body included. Query param because the id holds a slash. */
todosRouter.get('/item', (ctx) => {
const text = readFileSync(fileForId(ctx.req.query('id') ?? ''), 'utf8');
return ctx.json({ meta: frontmatter(text), body: bodyOf(text) });
});
/**
* PATCH /api/todos/item — update an item's frontmatter: `{ id, status?, priority?, due?, order? }`.
* Regenerates TODO.md on every write. The body is untouchable through this API, on purpose.
*/
todosRouter.patch('/item', async (ctx) => {
const { id, status, priority, due, order } = await ctx.req.json<{
id: string;
status?: string;
priority?: string;
due?: string;
order?: number;
}>();
const path = fileForId(id);
const updates: Record<string, string> = {};
if (status !== undefined) {
if (!(TODO_STATUSES as readonly string[]).includes(status)) throw errors.BAD_REQUEST(`not a status: ${status}`);
updates.status = status;
}
// `urgent` or nothing — the format deliberately has no scale (todo/README.md).
if (priority !== undefined) {
if (priority !== '' && priority !== 'urgent') throw errors.BAD_REQUEST(`not a priority: ${priority}`);
updates.priority = priority;
}
if (due !== undefined) {
if (due !== '' && !/^20\d\d-\d\d-\d\d$/.test(due)) throw errors.BAD_REQUEST(`not a date: ${due}`);
updates.due = due;
}
if (order !== undefined) {
if (!Number.isInteger(order) || order < 0) throw errors.BAD_REQUEST(`not an order: ${order}`);
updates.order = String(order);
}
if (Object.keys(updates).length === 0) throw errors.BAD_REQUEST('nothing to update');
writeFileSync(path, patchFrontmatter(readFileSync(path, 'utf8'), updates));
writeIndex();
return ctx.json({ ok: true });
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, test } from 'bun:test';
import { bodyOf, frontmatter, patchFrontmatter } from './todo-tree';
const FILE = `---
id: fleet/an-item
title: "An item, with: punctuation"
theme: fleet
status: open
created: 2026-08-21
order: 2
---
**The body.** Hand-written prose with --- inside it.
- a list
`;
describe('frontmatter', () => {
test('reads plain and JSON-quoted values', () => {
const fm = frontmatter(FILE);
expect(fm.id).toBe('fleet/an-item');
expect(fm.title).toBe('An item, with: punctuation');
expect(fm.order).toBe('2');
});
test('accepts prettier-style single quotes', () => {
expect(frontmatter(`---\ntitle: 'A quoted title'\n---\n`).title).toBe('A quoted title');
});
test('no frontmatter → empty', () => {
expect(frontmatter('just prose')).toEqual({});
});
});
describe('bodyOf', () => {
test('returns everything after the fence, trimmed of leading blank lines', () => {
expect(bodyOf(FILE).startsWith('**The body.**')).toBe(true);
});
});
describe('patchFrontmatter', () => {
test('replaces an existing key in place', () => {
const out = patchFrontmatter(FILE, { status: 'done' });
expect(frontmatter(out).status).toBe('done');
expect(out.match(/^status:/gm)).toHaveLength(1);
});
test('appends a key that was absent', () => {
const out = patchFrontmatter(FILE, { priority: 'urgent' });
expect(frontmatter(out).priority).toBe('urgent');
});
test('never touches the body — byte for byte', () => {
const out = patchFrontmatter(FILE, { status: 'doing', order: '9' });
expect(bodyOf(out)).toBe(bodyOf(FILE));
});
test('a --- inside the body is not mistaken for the fence', () => {
const out = patchFrontmatter(FILE, { status: 'done' });
expect(out).toContain('with --- inside it');
});
});
+176
View File
@@ -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());
}
+2
View File
@@ -10,6 +10,7 @@ import { themeFavoritesRouter } from './api/themes/favorites';
import { waitlistRouter } from './api/waitlist/waitlist';
import { usersRouter } from './api/users/users-router';
import { rolesRouter } from './api/roles/router';
import { todosRouter } from './api/todos/router';
import { apiKeysRouter } from './api/api-keys/router';
import { skillsRouter } from './api/skills/skills';
import { tasksRouter } from './api/tasks/tasks';
@@ -130,6 +131,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/theme-favorites', themeFavoritesRouter],
['/users', usersRouter],
['/roles', rolesRouter],
['/todos', todosRouter],
['/skills', skillsRouter],
['/tasks', tasksRouter],
['/agents', agentsRouter],
+11
View File
@@ -442,6 +442,17 @@ const CORE_REGISTRY: Permission[] = [
// is a real route — a client-side redirect still has to resolve before it can send anyone anywhere.
routes: ['/marketplace', '/plugins'],
},
{
key: 'todos',
label: 'Todo board',
description: "The platform's own backlog — todo/ in the repo, browsed and edited at /todos",
// Admin because the subject is the platform's development, not the account's data: the tree lives in
// the server's own checkout and a write is a git-visible edit to it. The router double-gates on
// isSuperAdmin, same belt-and-braces as the rest of this kind.
kind: 'admin',
api: ['/todos'],
routes: ['/todos'],
},
{
key: 'server-admin',
label: 'Server settings',
@@ -14,6 +14,7 @@ import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
import { appRegistryMetas as davMetas } from '../apps/Dav';
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
import { appRegistryMetas as todoBoardMetas } from '../apps/TodoBoard';
import type { QueryClient } from '@tanstack/react-query';
import { globalQueryKey } from 'hooks/useGlobal';
import { metasToRegistry } from './useAppRegistry';
@@ -32,6 +33,7 @@ export const apps = [
...sendOffMetas,
...davMetas,
...commandHqMetas,
...todoBoardMetas,
...appStoreMetas,
...pluginsMetas,
];