/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:
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user