diff --git a/CLAUDE.md b/CLAUDE.md index e1b20e5f..3661fac8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,7 +212,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-24** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 3 app, 4 confined, 3 execution, 5 admin. `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). **22 entries as of 2026-08-24** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 3 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). `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/TODO.md b/TODO.md index dfb11281..96d18977 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,7 @@ 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`. +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 @@ -20,12 +20,12 @@ the owner's OS user and can never be granted. Indirection there really is accide ## Themes -**90 open, 28 closed**, across 31 themes. +**89 open, 29 closed**, across 31 themes. | Theme | Open | Closed | | ------------------------------------------------------------------------------------------ | ---- | ------ | | [Affordances](todo/affordances/) | 1 | 0 | -| ['Agents coordinating agents'](todo/agent-coordination/) | 2 | 0 | +| [Agents coordinating agents](todo/agent-coordination/) | 2 | 0 | | [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 | | [Backburner](todo/backburner/) | 2 | 0 | | [Background jobs](todo/background-jobs/) | 2 | 1 | @@ -45,12 +45,12 @@ the owner's OS user and can never be granted. Indirection there really is accide | [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 | | [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 | | [Prompt library](todo/prompt-library/) | 1 | 0 | -| ['SendOff'](todo/sendoff/) | 1 | 0 | +| [SendOff](todo/sendoff/) | 1 | 0 | | [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 | | [Shell — spotlight and the dock](todo/shell/) | 4 | 1 | | [Sub-agents — agents spawning agents](todo/sub-agents/) | 0 | 2 | | [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 | -| [Tooling — the list about the list](todo/tooling/) | 1 | 0 | +| [Tooling — the list about the list](todo/tooling/) | 0 | 1 | | [Transmission](todo/transmission/) | 6 | 0 | | [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 | | [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 | @@ -65,11 +65,11 @@ the owner's OS user and can never be granted. Indirection there really is accide ## Newest ten -- `2026-08-23` **'A worker cannot verify that the owner delegated to a manager, so it has to ask every time'** — [agent-coordination/a-peer-cannot-verify-a-delegation](todo/agent-coordination/a-peer-cannot-verify-a-delegation.md) -- `2026-08-23` **'An agent cannot learn its own transcript id, so it cannot sign its own work'** — [agent-coordination/an-agent-cannot-learn-its-own-session-id](todo/agent-coordination/an-agent-cannot-learn-its-own-session-id.md) -- `2026-08-23` **'The SendOff file size limit is one constant for everybody'** — [sendoff/per-user-size-limit](todo/sendoff/per-user-size-limit.md) -- `2026-08-22` **'A commander picks the cheapest model that will do the job — for itself and for what it spawns'** — [background-jobs/a-commander-picks-the-cheapest-model-that-will](todo/background-jobs/a-commander-picks-the-cheapest-model-that-will.md) -- `2026-08-22` **'Revisit the bun 1.3 pin once 1.4 has been watched for a while'** — [infra/revisit-the-bun-1-3-pin-once-1-4-has-been](todo/infra/revisit-the-bun-1-3-pin-once-1-4-has-been.md) +- `2026-08-23` **A worker cannot verify that the owner delegated to a manager, so it has to ask every time** — [agent-coordination/a-peer-cannot-verify-a-delegation](todo/agent-coordination/a-peer-cannot-verify-a-delegation.md) +- `2026-08-23` **An agent cannot learn its own transcript id, so it cannot sign its own work** — [agent-coordination/an-agent-cannot-learn-its-own-session-id](todo/agent-coordination/an-agent-cannot-learn-its-own-session-id.md) +- `2026-08-23` **The SendOff file size limit is one constant for everybody** — [sendoff/per-user-size-limit](todo/sendoff/per-user-size-limit.md) +- `2026-08-22` **A commander picks the cheapest model that will do the job — for itself and for what it spawns** — [background-jobs/a-commander-picks-the-cheapest-model-that-will](todo/background-jobs/a-commander-picks-the-cheapest-model-that-will.md) +- `2026-08-22` **Revisit the bun 1.3 pin once 1.4 has been watched for a while** — [infra/revisit-the-bun-1-3-pin-once-1-4-has-been](todo/infra/revisit-the-bun-1-3-pin-once-1-4-has-been.md) - `2026-08-22` **A plugin's permission should be on for every role by default, off only on request** — [multi-user/a-plugin-s-permission-should-be-on-for-every](todo/multi-user/a-plugin-s-permission-should-be-on-for-every.md) - `2026-08-22` **A user's settings should export and import as a plain file** — [settings/a-user-s-settings-should-export-and-import-as](todo/settings/a-user-s-settings-should-export-and-import-as.md) - `2026-08-22` **The same for system settings, owned by the Super Admin** — [settings/the-same-for-system-settings-owned-by-the](todo/settings/the-same-for-system-settings-owned-by-the.md) diff --git a/scripts/todo-index.ts b/scripts/todo-index.ts index ea1eeeb6..0b25ce39 100644 --- a/scripts/todo-index.ts +++ b/scripts/todo-index.ts @@ -1,140 +1,10 @@ /** - * Regenerate `TODO.md` — the index over `todo/`. + * Regenerate `TODO.md` — the index over `todo/`. `bun run todo:index` after any change to the tree. * - * The index is the one file everybody reads, so it is the one most able to lie. It is generated rather - * than maintained: `bun run todo:index` after any change to `todo/`, and it cannot drift from the tree - * it describes. + * The logic lives in `src/servers/api/todos/todo-tree.ts`, shared with the `/api/todos` router so the + * board and this CLI cannot disagree about the format; this file is only the command. */ -import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs'; -import { join } from 'node:path'; +import { writeIndex } from '../src/servers/api/todos/todo-tree'; -const ROOT = new URL('..', import.meta.url).pathname; -const OUT = join(ROOT, 'todo'); - -export type Item = { - id: string; - title: string; - theme: string; - status: string; - created: string; - /** `urgent` or empty — see todo/README.md for why there is no scale. */ - priority: string; - due: string; - order: number; - file: string; -}; - -/** 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('"') ? (JSON.parse(raw) as string) : raw; - } - return out; -} - -export function readTree(): { themes: { theme: string; heading: string; items: Item[] }[] } { - const themes = readdirSync(OUT) - .filter((d) => statSync(join(OUT, d)).isDirectory()) - .sort() - .map((theme) => { - const files = readdirSync(join(OUT, theme)).filter((f) => f.endsWith('.md')); - const meta = frontmatter(readFileSync(join(OUT, theme, '_index.md'), 'utf8')); - const items = files - .filter((f) => f !== '_index.md') - .map((f) => { - const fm = frontmatter(readFileSync(join(OUT, 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, 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\`. - -**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. -`; - -function render(): string { - const { themes } = readTree(); - const open = themes.reduce((n, t) => n + t.items.filter((i) => i.status !== 'done').length, 0); - const done = themes.reduce((n, t) => n + t.items.filter((i) => i.status === 'done').length, 0); - - const rows = themes - .filter((t) => t.items.length) - .map((t) => { - const o = t.items.filter((i) => i.status !== 'done').length; - const d = t.items.length - o; - return `| [${t.heading}](todo/${t.theme}/) | ${o} | ${d} |`; - }); - - const recent = themes - .flatMap((t) => t.items) - .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})`); - - // A theme holding only prose has no row above, and an entry nobody can see is the failure mode this - // 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)`); - - // Urgent goes above everything. An item the owner called urgent, sorted in among seventy-five others - // by theme, is an item nobody sees on the day it matters. - const urgent = themes - .flatMap((t) => t.items) - .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})`); - - 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'); -} - -if (import.meta.main) { - writeFileSync(join(ROOT, 'TODO.md'), render()); - console.log('TODO.md regenerated'); -} +writeIndex(); +console.log('TODO.md regenerated'); diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 65c83f7c..c52abe40 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -107,6 +107,7 @@ export function App() { {/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */} } /> } /> + } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Todos/TodosScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Todos/TodosScreen.tsx new file mode 100644 index 00000000..ca5192ea --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Todos/TodosScreen.tsx @@ -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('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 new file mode 100644 index 00000000..1592db18 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Todos/defaultLayout.ts @@ -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', +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Todos/index.tsx b/src/apps/officer-web/Screens/Dashboard/Todos/index.tsx new file mode 100644 index 00000000..16d8e0de --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Todos/index.tsx @@ -0,0 +1 @@ +export * from './TodosScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx index a97f2bfe..ac9eaac8 100644 --- a/src/apps/officer-web/Screens/Dashboard/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/index.tsx @@ -23,3 +23,4 @@ export * from './Terminal'; export * from './Jobs'; export * from './QrTransfer'; export * from './SendOff'; +export * from './Todos'; diff --git a/src/servers/api/todos/router.ts b/src/servers/api/todos/router.ts new file mode 100644 index 00000000..15bd8dfb --- /dev/null +++ b/src/servers/api/todos/router.ts @@ -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(); +}); + +/** `/`, 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.test.ts b/src/servers/api/todos/todo-tree.test.ts new file mode 100644 index 00000000..9255d4f3 --- /dev/null +++ b/src/servers/api/todos/todo-tree.test.ts @@ -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'); + }); +}); diff --git a/src/servers/api/todos/todo-tree.ts b/src/servers/api/todos/todo-tree.ts new file mode 100644 index 00000000..120b777a --- /dev/null +++ b/src/servers/api/todos/todo-tree.ts @@ -0,0 +1,176 @@ +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 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 { + 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()); +} diff --git a/src/servers/hono.ts b/src/servers/hono.ts index e6e97b26..6d3898b0 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -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 ['/theme-favorites', themeFavoritesRouter], ['/users', usersRouter], ['/roles', rolesRouter], + ['/todos', todosRouter], ['/skills', skillsRouter], ['/tasks', tasksRouter], ['/agents', agentsRouter], diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index 5f45be5b..356569d1 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -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', diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 1e3388d4..b99d9dec 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -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, ]; diff --git a/todo/tooling/a-kanban-board-over-todo-synchronised-both.md b/todo/tooling/a-kanban-board-over-todo-synchronised-both.md index d9715e73..4d09d034 100644 --- a/todo/tooling/a-kanban-board-over-todo-synchronised-both.md +++ b/todo/tooling/a-kanban-board-over-todo-synchronised-both.md @@ -1,8 +1,8 @@ --- id: tooling/a-kanban-board-over-todo-synchronised-both -title: "A Kanban board over `todo/`, synchronised both ways" +title: 'A Kanban board over `todo/`, synchronised both ways' theme: tooling -status: open +status: done created: 2026-08-21 date_source: stated order: 1 @@ -26,3 +26,24 @@ Open, and deliberately not decided here: which columns (`status` today holds onl `doing` and `blocked` are new values whenever the board wants them); whether it is an Officer screen, a panel app or a plugin; and how it notices a change made on disk while the board is open — a watcher, a poll, or a refresh button, which is the cheapest thing that works. + +--- + +**Built 2026-08-24 — `/todos`, a core screen, deliberately not a plugin.** The tree lives in the +platform's own repo, and dev tooling about the platform's backlog has nothing to gain from being +installable elsewhere. + +What v1 is: `GET /api/todos` reads the tree (shared module `api/todos/todo-tree.ts`, which +`scripts/todo-index.ts` now also imports, so the board and the generated index cannot disagree); +`PATCH /api/todos/item` splices frontmatter — status, priority, due, order — and never touches a body +byte, with tests asserting exactly that. Columns are open / doing / blocked / done; drag a card to move +it, and every write regenerates `TODO.md`. Selection and theme filter are `?selected=` / `?theme=`, so +cards are real links and a filtered board is an addressable page. Owner-only: `todos` is `kind: 'admin'` +in the registry (now 22 entries, docs:check green) with an `isSuperAdmin` gate on the router as the +second lock. + +Answers to this item's open questions: the columns are those four statuses, new values are one edit to +`TODO_STATUSES`; it is a screen, not a plugin; change detection is a 30s refetch — the cheapest thing +that works, as predicted. Not done: creating or editing items from the board (the body is the owner's +half, written in an editor), and drag does not reorder within a column — `order` is PATCHable but no +gesture drives it yet.