From fb1cf0f6f1cb11159c857ea5c8750041dcb62bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 29 Aug 2026 03:29:58 +0100 Subject: [PATCH] the todo board leaves the platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/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 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- AGENTS.md | 2 +- src/apps/officer-web/App.tsx | 1 - .../Screens/Dashboard/Todos/TodosScreen.tsx | 12 - .../Screens/Dashboard/Todos/defaultLayout.ts | 8 - .../Screens/Dashboard/Todos/index.tsx | 1 - .../officer-web/Screens/Dashboard/index.tsx | 1 - src/servers/api/todos/router.ts | 93 -------- src/servers/api/todos/todo-tree.ts | 16 +- .../src/apps/TodoBoard/TodoBoardPanel.tsx | 215 ------------------ .../officerdev/src/apps/TodoBoard/index.ts | 10 - .../officerdev/src/apps/TodoBoard/useTodos.ts | 52 ----- 11 files changed, 15 insertions(+), 396 deletions(-) delete mode 100644 src/apps/officer-web/Screens/Dashboard/Todos/TodosScreen.tsx delete mode 100644 src/apps/officer-web/Screens/Dashboard/Todos/defaultLayout.ts delete mode 100644 src/apps/officer-web/Screens/Dashboard/Todos/index.tsx delete mode 100644 src/servers/api/todos/router.ts delete mode 100644 src/workspaces/officerdev/src/apps/TodoBoard/TodoBoardPanel.tsx delete mode 100644 src/workspaces/officerdev/src/apps/TodoBoard/index.ts delete mode 100644 src/workspaces/officerdev/src/apps/TodoBoard/useTodos.ts diff --git a/AGENTS.md b/AGENTS.md index 955f4047..1ddf5981 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -243,7 +243,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha - `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds: `core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is - never grantable at any level). **21 entries as of 2026-08-27** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). It was 22 until caldav became a plugin and took `calendar` with it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade. + never grantable at any level). **20 entries as of 2026-08-29** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 5 admin. It was 21 until the todo board became the `kanban` plugin and took `todos` with it — the same shape as caldav below, and for the same reason: a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. It was 22 until caldav became a plugin and took `calendar` with it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade. - `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them, and `confined` stripped for an account with no `osUser`. diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 7d3fea53..590f4062 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -107,7 +107,6 @@ export function App() { {/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */} } /> } /> - } /> } /> {/* Route pair: `/bots` is the new-bot state, `/bots/:sessionId` is one of them. */} } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Todos/TodosScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Todos/TodosScreen.tsx deleted file mode 100644 index ca5192ea..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Todos/TodosScreen.tsx +++ /dev/null @@ -1,12 +0,0 @@ -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('screens/todos', defaultLayout); - - return ; -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Todos/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Todos/defaultLayout.ts deleted file mode 100644 index 1592db18..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Todos/defaultLayout.ts +++ /dev/null @@ -1,8 +0,0 @@ -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', -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Todos/index.tsx b/src/apps/officer-web/Screens/Dashboard/Todos/index.tsx deleted file mode 100644 index 16d8e0de..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Todos/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './TodosScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index a1f0a5a6..25010ece 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -21,5 +21,4 @@ export * from './Terminal'; export * from './Jobs'; export * from './QrTransfer'; export * from './SendOff'; -export * from './Todos'; export * from './Bots'; diff --git a/src/servers/api/todos/router.ts b/src/servers/api/todos/router.ts deleted file mode 100644 index 15bd8dfb..00000000 --- a/src/servers/api/todos/router.ts +++ /dev/null @@ -1,93 +0,0 @@ -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(); -}); - -/** `/`, 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=/ — 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 = {}; - 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 }); -}); diff --git a/src/servers/api/todos/todo-tree.ts b/src/servers/api/todos/todo-tree.ts index 1cd1ce6e..7dd44902 100644 --- a/src/servers/api/todos/todo-tree.ts +++ b/src/servers/api/todos/todo-tree.ts @@ -10,8 +10,20 @@ import { join, resolve } from 'node:path'; // 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. +// ── 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'); diff --git a/src/workspaces/officerdev/src/apps/TodoBoard/TodoBoardPanel.tsx b/src/workspaces/officerdev/src/apps/TodoBoard/TodoBoardPanel.tsx deleted file mode 100644 index 055a0f41..00000000 --- a/src/workspaces/officerdev/src/apps/TodoBoard/TodoBoardPanel.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import type { DragEvent } from 'react'; -import { useState } from 'react'; -import { Link, useSearchParams } from 'react-router'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import { AlertTriangle, CalendarClock, X } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import type { TodoItem } from './useTodos'; -import { useTodoItem, useTodoPatch, useTodoTree } from './useTodos'; - -/** - * The board over `todo/` — the platform's own backlog, one file per item (todo/README.md). - * - * Selection and the theme filter live in the URL (`?selected=`, `?theme=`), per the navigation audit: - * a card is a real link, a filtered board is an addressable page, and back means what it should. - * - * Writes go through PATCH /todos/item, which touches frontmatter only and regenerates TODO.md — this - * board never holds state the files do not; a drag is a file edit and a refetch. - */ - -const COLUMNS = [ - { status: 'open', label: 'Open' }, - { status: 'doing', label: 'Doing' }, - { status: 'blocked', label: 'Blocked' }, - { status: 'backburner', label: 'Backburner' }, - { status: 'done', label: 'Done' }, -] as const; - -export const TodoBoardPanel = () => { - const { data, isLoading, error } = useTodoTree(); - const patch = useTodoPatch(); - const [params] = useSearchParams(); - const selected = params.get('selected'); - const themeFilter = params.get('theme'); - const [dragOver, setDragOver] = useState(null); - - const href = (changes: Record): string => { - const next = new URLSearchParams(params); - for (const [key, value] of Object.entries(changes)) { - if (value === null) next.delete(key); - else next.set(key, value); - } - const qs = next.toString(); - return qs ? `?${qs}` : '.'; - }; - - if (error) { - return ( -
- The todo board is owner-only, and this account was refused. Nothing here is broken — there is simply nothing to - show. -
- ); - } - if (isLoading || !data) { - return
Reading todo/…
; - } - - const themes = data.themes.filter((t) => t.items.length > 0); - const items = themes.flatMap((t) => t.items); - const visible = themeFilter ? items.filter((i) => i.theme === themeFilter) : items; - - const drop = (status: string) => (ev: DragEvent) => { - ev.preventDefault(); - setDragOver(null); - const id = ev.dataTransfer.getData('text/todo-id'); - if (id) patch.mutate({ id, status }); - }; - - return ( -
-
-
- - All · {items.filter((i) => i.status !== 'done').length} - - {themes.map((t) => { - const open = t.items.filter((i) => i.status !== 'done').length; - return ( - - {t.heading.split('—')[0]?.trim()} · {open} - - ); - })} -
- -
- {COLUMNS.map((col) => { - const cards = visible - .filter((i) => i.status === col.status) - .sort((a, b) => (b.priority === 'urgent' ? 1 : 0) - (a.priority === 'urgent' ? 1 : 0)); - return ( -
{ - ev.preventDefault(); - setDragOver(col.status); - }} - onDragLeave={() => setDragOver(null)} - onDrop={drop(col.status)} - className={cn( - 'flex w-64 shrink-0 flex-col rounded-lg border border-border bg-muted/30', - dragOver === col.status && 'border-primary bg-primary/5', - )} - > -
- {col.label} - {cards.length} -
-
- {cards.map((item) => ( - - ))} -
-
- ); - })} -
-
- - {selected ? : null} -
- ); -}; - -type TodoCardProps = { item: TodoItem; href: string; selected: boolean }; - -const TodoCard = ({ item, href, selected }: TodoCardProps) => ( - ev.dataTransfer.setData('text/todo-id', item.id)} - className={cn( - 'block rounded-md border border-border bg-card p-2.5 text-left shadow-sm hover:border-primary/50', - selected && 'border-primary', - )} - > -
{item.title}
-
- {item.theme} - {item.priority === 'urgent' && ( - - urgent - - )} - {item.due && ( - - {item.due} - - )} - {item.created} -
- -); - -type TodoDetailProps = { id: string; closeHref: string }; - -const TodoDetail = ({ id, closeHref }: TodoDetailProps) => { - const { data, isLoading } = useTodoItem(id); - const patch = useTodoPatch(); - const status = data?.meta.status ?? 'open'; - - return ( -
-
- {id} -
- - - - -
-
-
- {isLoading || !data ? ( - Reading… - ) : ( - {data.body} - )} -
-
- ); -}; diff --git a/src/workspaces/officerdev/src/apps/TodoBoard/index.ts b/src/workspaces/officerdev/src/apps/TodoBoard/index.ts deleted file mode 100644 index ce91692d..00000000 --- a/src/workspaces/officerdev/src/apps/TodoBoard/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { AppRegistryMeta } from '../../AppRegistry'; -import { SquareKanban } from 'lucide-react'; -import { TodoBoardPanel } from './TodoBoardPanel'; - -export { TodoBoardPanel } from './TodoBoardPanel'; -export { useTodoTree, useTodoItem, useTodoPatch } from './useTodos'; - -export const appRegistryMetas: AppRegistryMeta[] = [ - { key: 'todo-board', name: 'Todo board', icon: SquareKanban, component: TodoBoardPanel }, -]; diff --git a/src/workspaces/officerdev/src/apps/TodoBoard/useTodos.ts b/src/workspaces/officerdev/src/apps/TodoBoard/useTodos.ts deleted file mode 100644 index a3f62239..00000000 --- a/src/workspaces/officerdev/src/apps/TodoBoard/useTodos.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useClient } from 'hooks/useClient'; - -export type TodoItem = { - id: string; - title: string; - theme: string; - status: string; - created: string; - priority: string; - due: string; - order: number; - file: string; -}; - -export type TodoTheme = { theme: string; heading: string; note: string; items: TodoItem[] }; - -export type TodoPatch = { id: string; status?: string; priority?: string; due?: string; order?: number }; - -export const useTodoTree = () => { - const client = useClient(); - return useQuery({ - queryKey: ['todos'], - queryFn: () => client.get<{ themes: TodoTheme[] }>('/todos'), - // The tree is files on disk that other agents edit while the board is open; refetch keeps the board - // honest without a watcher, which the sync contract allows (todo/README.md — poll is the cheapest - // thing that works). - refetchInterval: 30_000, - }); -}; - -export const useTodoItem = (id: string | null) => { - const client = useClient(); - return useQuery({ - queryKey: ['todos', 'item', id], - queryFn: () => - client.get<{ meta: Record; body: string }>(`/todos/item?id=${encodeURIComponent(id!)}`), - enabled: id !== null, - }); -}; - -export const useTodoPatch = () => { - const client = useClient(); - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (patch: TodoPatch) => client.patch<{ ok: boolean }>('/todos/item', patch), - onSuccess: (_data, patch) => { - void queryClient.invalidateQueries({ queryKey: ['todos'] }); - void queryClient.invalidateQueries({ queryKey: ['todos', 'item', patch.id] }); - }, - }); -};