the owner pointed a project at the platform's repo root — an easy mistake, since `todo/` sits inside one — and got "the todo board is owner-only, and this account was refused". three things were wrong and only one of them was the path. A THEME IS NOW A DIRECTORY WITH AN `_index.md`. readTree assumed every subdirectory was one, which was safe when the only possible root was the platform's own todo folder and is not safe now. against a repo root it read `src/_index.md`, threw ENOENT, and the route 500'd. skipping non-themes is also just correct for a real todo folder, which may hold a README beside its themes. THE ERROR MESSAGE WAS A LIE IT INHERITED. "owner-only, and this account was refused" was true of `/todos` in the platform, where the permission was `kind: 'admin'`. here it is grantable and there is no such gate — so the sentence survived the extraction describing a rule that no longer exists, and the first real failure it met was reported as a permission problem. it shows the actual error now. a wrong explanation is worse than none: it sent the owner to check their own account when the answer was in the panel beside them. AND AN EMPTY FOLDER SAYS SO. no themes at all is almost always this same mistake, so it names it — a theme is a directory with an `_index.md`, and if you pointed at a repository the folder you want is usually `todo/` inside it. an empty board otherwise reads as "no work outstanding", which is the opposite of the truth. verified: repo root now 200 with 0 themes and the explanation; the real folder reads 38 themes and 139 items. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
8.9 KiB
TypeScript
201 lines
8.9 KiB
TypeScript
import { existsSync, 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.
|
|
|
|
/*
|
|
THE ONE THING THAT CHANGED IN THIS FILE.
|
|
|
|
It read `resolve(process.cwd(), 'todo')` — the platform's own todo folder, found by assuming the
|
|
server's working directory. That is not a setting; it is the belief that there is exactly one project
|
|
and it is the one the process happens to have been started in.
|
|
|
|
Every function below now takes the directory. Everything else here — the frontmatter reader that
|
|
never touches the body, the index writer, the ordering — is unchanged and was already right.
|
|
*/
|
|
|
|
export const TODO_STATUSES = ['open', 'doing', 'blocked', 'backburner', 'done'] as const;
|
|
export type TodoStatus = (typeof TODO_STATUSES)[number];
|
|
|
|
export type TodoItem = {
|
|
id: string;
|
|
title: string;
|
|
theme: string;
|
|
status: string;
|
|
created: string;
|
|
/** `urgent` or empty — deliberately no scale, see todo/README.md. */
|
|
priority: string;
|
|
due: string;
|
|
order: number;
|
|
file: string;
|
|
};
|
|
|
|
export type TodoTheme = { theme: string; heading: string; note: string; items: TodoItem[] };
|
|
|
|
/** Minimal frontmatter reader — `key: value`, values optionally JSON-quoted. No YAML dependency. */
|
|
export function frontmatter(text: string): Record<string, string> {
|
|
const end = text.indexOf('\n---', 4);
|
|
if (!text.startsWith('---') || end === -1) return {};
|
|
const out: Record<string, string> = {};
|
|
for (const line of text.slice(4, end).split('\n')) {
|
|
const m = line.match(/^([a-z_]+):\s*(.*)$/);
|
|
if (!m || !m[1]) continue;
|
|
const raw = (m[2] ?? '').trim();
|
|
out[m[1]] = raw.startsWith('"') || raw.startsWith("'") ? (JSON.parse(normalizeQuotes(raw)) as string) : raw;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Prettier rewrites the frontmatter's double quotes to single, which JSON.parse refuses. Both mean the
|
|
// same string; accept both rather than fighting the formatter.
|
|
const normalizeQuotes = (raw: string) =>
|
|
raw.startsWith("'") ? JSON.stringify(raw.slice(1, -1).replace(/\\'/g, "'")) : raw;
|
|
|
|
/** The body: everything after the frontmatter block's closing fence and its trailing blank lines. */
|
|
export function bodyOf(text: string): string {
|
|
const end = text.indexOf('\n---', 4);
|
|
if (!text.startsWith('---') || end === -1) return text;
|
|
return text.slice(end + 4).replace(/^\n+/, '');
|
|
}
|
|
|
|
/**
|
|
* Splice `updates` into the frontmatter block, touching nothing else — the body is hand-written prose
|
|
* and a rewrite would produce a diff nobody can read. An existing key is replaced in place; a new one
|
|
* is appended just before the closing fence. Returns the text unchanged if there is no frontmatter.
|
|
*/
|
|
export function patchFrontmatter(text: string, updates: Record<string, string>): string {
|
|
const end = text.indexOf('\n---', 4);
|
|
if (!text.startsWith('---') || end === -1) return text;
|
|
let block = text.slice(4, end);
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
const line = `${key}: ${value}`;
|
|
const re = new RegExp(`^${key}:.*$`, 'm');
|
|
block = re.test(block) ? block.replace(re, line) : `${block}\n${line}`;
|
|
}
|
|
return `---\n${block}${text.slice(end)}`;
|
|
}
|
|
|
|
export function readTree(root: string): { themes: TodoTheme[] } {
|
|
const themes = readdirSync(root)
|
|
.filter((d) => statSync(join(root, d)).isDirectory())
|
|
/*
|
|
A THEME is a directory with an `_index.md`. Anything else under the folder is ignored.
|
|
|
|
This used to assume every subdirectory was one, which was safe when the only possible root was the
|
|
platform's own `todo/`. It is not safe now: point a project at a repository root — an easy mistake,
|
|
since `todo/` sits inside one — and this read `src/_index.md`, threw ENOENT, and the board showed a
|
|
500 that the panel reported as a permission problem. Skipping is also just correct for a real todo
|
|
folder, which may hold a README or a script beside its themes.
|
|
*/
|
|
.filter((d) => existsSync(join(root, d, '_index.md')))
|
|
.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(root: string): string {
|
|
const { themes } = readTree(root);
|
|
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');
|
|
}
|
|
|
|
/**
|
|
* The generated map, written beside the tree rather than at `process.cwd()`.
|
|
*
|
|
* It used to go to the platform's own repo root, which was the same assumption as `todoDir` and wrong
|
|
* the same way: with two projects it would have had every board overwriting one file. `<root>/../TODO.md`
|
|
* keeps the map next to the folder it describes, which is where somebody reading that repository looks.
|
|
*/
|
|
export function writeIndex(root: string): void {
|
|
writeFileSync(resolve(root, '..', 'TODO.md'), renderIndex(root));
|
|
}
|