");
+ expect(doc.blocks).toHaveLength(1);
+ expect(doc.blocks[0]).toEqual({ t: "p", c: [{ t: "text", v: "real" }] });
+});
diff --git a/src/a31_sxdoc_parse.ts b/src/a31_sxdoc_parse.ts
new file mode 100644
index 0000000000000000000000000000000000000000..be5a19a58a74a7beee787caf14b423011c2d290d
--- /dev/null
+++ b/src/a31_sxdoc_parse.ts
@@ -0,0 +1,327 @@
+// c31 — HTML → SxDocument parser.
+//
+// SAMA placement: c31 because this is a parser for external input —
+// Modeled.md is explicit: "every external input has a parser in a c31_*
+// model — types and parse-functions colocated". HTML strings reach this
+// file from the editor's save POST, from the markdown-import script, and
+// from the AI-edit response — all "outside the process" → c31.
+//
+// Why a typed tree and not HTML strings: see c31_sxdoc.ts header.
+//
+// Why node-html-parser and not Bun's HTMLRewriter: we need a tree we can
+// recurse over, not a streaming filter. The dep is pure-logic (no I/O,
+// no fs, no spawn) so it doesn't push the file into c14 territory.
+
+import { parse, type HTMLElement, type Node, NodeType } from "node-html-parser";
+import type { SxDocument, SxBlock, SxInline, SxMark } from "./a31_sxdoc.ts";
+import { SX_DOC_VERSION } from "./a31_sxdoc.ts";
+
+const SHORTCODE_RE = /\[\[sx:([a-z][a-z0-9-]*)((?:\s+[a-z0-9_-]+=(?:"[^"]*"|[^\s"\]]+))*)\s*\]\]/g;
+const SHORTCODE_ARG_RE = /([a-z0-9_-]+)=(?:"([^"]*)"|([^\s"\]]+))/g;
+
+const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
+
+// Block-level tags — used by parseListItem to know where to stop
+// collecting inlines and recurse instead. Keep in sync with the
+// pushBlocksFromNode dispatcher above.
+const BLOCK_TAGS = new Set([
+ "p", "h1", "h2", "h3", "h4", "h5", "h6",
+ "ul", "ol", "blockquote", "pre",
+ "img", "figure", "hr",
+ "div", "section", "article", "table",
+]);
+
+const MARK_FOR_TAG: Record = {
+ b: "b", strong: "b",
+ i: "i", em: "i",
+ u: "u",
+ s: "s", strike: "s", del: "s",
+ code: "c",
+};
+
+export const htmlToSx = (html: string): SxDocument => {
+ // Wrap in so we always have a single parent to walk childNodes
+ // of, regardless of whether the input has its own wrapper element.
+ const root = parse(`${html}`, {
+ blockTextElements: { script: false, style: false },
+ });
+ const rootEl = root.firstChild as HTMLElement;
+ const blocks: SxBlock[] = [];
+ for (const node of rootEl.childNodes) {
+ pushBlocksFromNode(node, blocks);
+ }
+ return { v: SX_DOC_VERSION, blocks };
+};
+
+// ─── block-level dispatch ────────────────────────────────────────────────
+
+const pushBlocksFromNode = (node: Node, out: SxBlock[]): void => {
+ if (node.nodeType === NodeType.TEXT_NODE) {
+ const text = (node.text ?? "").trim();
+ if (text) out.push(...textWithShortcodesToBlocks(text, []));
+ return;
+ }
+ if (node.nodeType !== NodeType.ELEMENT_NODE) return;
+
+ const el = node as HTMLElement;
+ const tag = el.tagName?.toLowerCase();
+ if (!tag) return;
+
+ // Comments / processing-instructions surface as element nodes with a
+ // tagName starting with "!" — drop them, they're not content.
+ if (tag === "!" || tag === "comment") return;
+
+ if (tag === "p") {
+ const inlines = parseInline(el.childNodes, []);
+ if (inlines.length === 0) return;
+ out.push(...splitShortcodesFromParagraph(inlines));
+ return;
+ }
+
+ if (HEADING_TAGS.has(tag)) {
+ const level = parseInt(tag.slice(1), 10) as 1 | 2 | 3 | 4 | 5 | 6;
+ out.push({ t: "h", level, c: parseInline(el.childNodes, []) });
+ return;
+ }
+
+ if (tag === "ul" || tag === "ol") { out.push(parseList(el, tag)); return; }
+ if (tag === "blockquote") { out.push(parseQuote(el)); return; }
+ if (tag === "pre") { out.push(parseCodeBlock(el)); return; }
+ if (tag === "img") {
+ const img = parseImg(el);
+ if (img) out.push(img);
+ return;
+ }
+ if (tag === "figure") { out.push(parseFigure(el)); return; }
+ if (tag === "hr") { out.push({ t: "hr" }); return; }
+
+ if (tag === "div" || tag === "section" || tag === "article") {
+ for (const child of el.childNodes) pushBlocksFromNode(child, out);
+ return;
+ }
+
+ // Anything else → escape hatch so round-tripping stays lossless.
+ out.push({ t: "html", src: el.outerHTML });
+};
+
+// ─── per-block parsers ───────────────────────────────────────────────────
+
+const parseList = (el: HTMLElement, tag: "ul" | "ol"): SxBlock => {
+ const items: SxBlock[][] = [];
+ for (const child of el.childNodes) {
+ if (child.nodeType !== NodeType.ELEMENT_NODE) continue;
+ const childEl = child as HTMLElement;
+ if (childEl.tagName?.toLowerCase() !== "li") continue;
+ const itemBlocks = parseListItem(childEl);
+ if (itemBlocks.length > 0) items.push(itemBlocks);
+ }
+ return { t: tag, items };
+};
+
+// Walk an
's children in source-order. Inline runs collect into
+// paragraphs; block-level children (nested ul/ol/blockquote/pre/…)
+// flush the current inline buffer and recurse as their own block.
+// Without this split, parseInline would walk into nested
and the
+// inner text would leak into the outer paragraph.
+const parseListItem = (li: HTMLElement): SxBlock[] => {
+ const result: SxBlock[] = [];
+ let inlineBuf: Node[] = [];
+ const flushInlines = (): void => {
+ if (inlineBuf.length === 0) return;
+ const inlines = parseInline(inlineBuf, []);
+ if (inlines.length > 0) result.push({ t: "p", c: inlines });
+ inlineBuf = [];
+ };
+ for (const node of li.childNodes) {
+ if (node.nodeType === NodeType.ELEMENT_NODE) {
+ const t = (node as HTMLElement).tagName?.toLowerCase();
+ if (t && BLOCK_TAGS.has(t)) {
+ flushInlines();
+ pushBlocksFromNode(node, result);
+ continue;
+ }
+ }
+ inlineBuf.push(node);
+ }
+ flushInlines();
+ return result;
+};
+
+const parseQuote = (el: HTMLElement): SxBlock => {
+ const inner: SxBlock[] = [];
+ for (const child of el.childNodes) pushBlocksFromNode(child, inner);
+ if (inner.length === 0) {
+ const inlines = parseInline(el.childNodes, []);
+ if (inlines.length > 0) inner.push({ t: "p", c: inlines });
+ }
+ return { t: "quote", c: inner };
+};
+
+const parseCodeBlock = (el: HTMLElement): SxBlock => {
+ // Canonical shape:
…
.
+ // Loose
text
also supported.
+ const codeChild = el.querySelector("code");
+ const inner = codeChild ?? el;
+ const lang = parseLangFromClass(inner.getAttribute("class") ?? "");
+ return { t: "code", lang, src: decodeEntities(inner.innerHTML) };
+};
+
+const parseImg = (el: HTMLElement): SxBlock | null => {
+ const src = el.getAttribute("src") ?? "";
+ if (!src) return null;
+ const block: { t: "img"; src: string; alt?: string; w?: number; h?: number } = { t: "img", src };
+ const alt = el.getAttribute("alt");
+ if (alt) block.alt = alt;
+ const w = numAttr(el, "width"); if (w !== undefined) block.w = w;
+ const h = numAttr(el, "height"); if (h !== undefined) block.h = h;
+ return block as SxBlock;
+};
+
+const parseFigure = (el: HTMLElement): SxBlock => {
+ const img = el.querySelector("img");
+ const caption = el.querySelector("figcaption");
+ if (img) {
+ const src = img.getAttribute("src") ?? "";
+ if (src) {
+ const block: { t: "img"; src: string; alt?: string; caption?: string; w?: number; h?: number } = { t: "img", src };
+ const alt = img.getAttribute("alt"); if (alt) block.alt = alt;
+ if (caption) block.caption = caption.text;
+ const w = numAttr(img, "width"); if (w !== undefined) block.w = w;
+ const h = numAttr(img, "height"); if (h !== undefined) block.h = h;
+ return block as SxBlock;
+ }
+ }
+ return { t: "html", src: el.outerHTML };
+};
+
+// ─── inline parsing ──────────────────────────────────────────────────────
+
+const parseInline = (nodes: Node[] | undefined, marks: SxMark[]): SxInline[] => {
+ if (!nodes) return [];
+ const out: SxInline[] = [];
+ for (const node of nodes) {
+ if (node.nodeType === NodeType.TEXT_NODE) {
+ const v = decodeEntities(node.text ?? "");
+ if (v.length > 0) {
+ out.push({ t: "text", v, ...(marks.length ? { m: dedupeMarks(marks) } : {}) });
+ }
+ continue;
+ }
+ if (node.nodeType !== NodeType.ELEMENT_NODE) continue;
+ const el = node as HTMLElement;
+ const tag = el.tagName?.toLowerCase();
+ if (!tag) continue;
+
+ if (tag === "br") {
+ out.push({ t: "text", v: "\n", ...(marks.length ? { m: dedupeMarks(marks) } : {}) });
+ continue;
+ }
+
+ if (tag === "a") {
+ const href = el.getAttribute("href") ?? "";
+ out.push({ t: "a", href, c: parseInline(el.childNodes, marks) });
+ continue;
+ }
+
+ const mark = MARK_FOR_TAG[tag];
+ if (mark) {
+ out.push(...parseInline(el.childNodes, [...marks, mark]));
+ continue;
+ }
+
+ // , , etc. — strip wrapper, keep contents.
+ out.push(...parseInline(el.childNodes, marks));
+ }
+ return out;
+};
+
+const dedupeMarks = (marks: SxMark[]): SxMark[] => {
+ const seen = new Set();
+ const out: SxMark[] = [];
+ for (const m of marks) if (!seen.has(m)) { seen.add(m); out.push(m); }
+ return out;
+};
+
+// ─── shortcode lifting ──────────────────────────────────────────────────
+
+// When a
contains [[sx:foo]] tokens mixed with text, split it into
+// (paragraph)(shortcode)(paragraph) blocks so the document is queryable
+// per-shortcode rather than per-paragraph-with-substring.
+const splitShortcodesFromParagraph = (inlines: SxInline[]): SxBlock[] => {
+ const out: SxBlock[] = [];
+ let buf: SxInline[] = [];
+ const flush = (): void => {
+ if (buf.length > 0 && buf.some((i) => !(i.t === "text" && i.v.trim() === ""))) {
+ out.push({ t: "p", c: buf });
+ }
+ buf = [];
+ };
+ for (const i of inlines) {
+ if (i.t !== "text" || !SHORTCODE_RE.test(i.v)) {
+ buf.push(i);
+ continue;
+ }
+ SHORTCODE_RE.lastIndex = 0;
+ const blocks = textWithShortcodesToBlocks(i.v, i.m ?? []);
+ for (const b of blocks) {
+ if (b.t === "shortcode") {
+ flush();
+ out.push(b);
+ } else if (b.t === "p") {
+ for (const inner of b.c) buf.push(inner);
+ }
+ }
+ }
+ flush();
+ return out;
+};
+
+const textWithShortcodesToBlocks = (text: string, marks: SxMark[]): SxBlock[] => {
+ const out: SxBlock[] = [];
+ let last = 0;
+ SHORTCODE_RE.lastIndex = 0;
+ for (const m of text.matchAll(SHORTCODE_RE)) {
+ const idx = m.index ?? 0;
+ if (idx > last) {
+ const before = text.slice(last, idx);
+ if (before.trim() !== "") {
+ out.push({ t: "p", c: [{ t: "text", v: before, ...(marks.length ? { m: marks } : {}) }] });
+ }
+ }
+ const name = m[1]!;
+ const args: Record = {};
+ for (const a of (m[2] ?? "").matchAll(SHORTCODE_ARG_RE)) {
+ args[a[1]!] = a[2] ?? a[3] ?? "";
+ }
+ out.push({ t: "shortcode", name, args });
+ last = idx + m[0].length;
+ }
+ const tail = text.slice(last);
+ if (tail.trim() !== "") {
+ out.push({ t: "p", c: [{ t: "text", v: tail, ...(marks.length ? { m: marks } : {}) }] });
+ }
+ return out;
+};
+
+// ─── small helpers ───────────────────────────────────────────────────────
+
+const parseLangFromClass = (cls: string): string => {
+ const m = cls.match(/(?:^|\s)language-([\w-]+)/);
+ return m?.[1] ?? "";
+};
+
+const numAttr = (el: HTMLElement, name: string): number | undefined => {
+ const v = el.getAttribute(name);
+ if (!v) return undefined;
+ const n = parseInt(v, 10);
+ return Number.isFinite(n) ? n : undefined;
+};
+
+const decodeEntities = (s: string): string =>
+ s
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/ /g, " ");
diff --git a/src/b32_anchor_extract.test.ts b/src/b32_anchor_extract.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bce5d02275d3e5807e1ddb990b0109f80079809c
--- /dev/null
+++ b/src/b32_anchor_extract.test.ts
@@ -0,0 +1,57 @@
+import { test, expect } from "bun:test";
+import { extractAnchors } from "./b32_anchor_extract.ts";
+
+test("extracts h2 with explicit id", () => {
+ const html = `
`;
+
+ case "code":
+ return renderCodeBlock(block);
+
+ case "img":
+ return renderImg(block);
+
+ case "hr":
+ return ``;
+
+ case "html":
+ // Raw passthrough — trust whoever inserted it. The parser only
+ // emits SxHtml for round-trip-preservation of unknown HTML.
+ return block.src;
+
+ case "shortcode":
+ return renderShortcode(block);
+ }
+};
+
+const renderCodeBlock = (block: { lang?: string; src: string }): string => {
+ const langClass = block.lang ? ` class="language-${escAttr(block.lang)}"` : "";
+ return `
${escText(block.src)}
`;
+};
+
+const renderImg = (block: { src: string; alt?: string; caption?: string; w?: number; h?: number }): string => {
+ const attrs = [`src="${escAttr(block.src)}"`];
+ if (block.alt !== undefined) attrs.push(`alt="${escAttr(block.alt)}"`);
+ if (block.w !== undefined) attrs.push(`width="${block.w}"`);
+ if (block.h !== undefined) attrs.push(`height="${block.h}"`);
+ const img = ``;
+ if (block.caption) {
+ return `${img}${escText(block.caption)}`;
+ }
+ return img;
+};
+
+const renderShortcode = (block: SxShortcode): string => {
+ const args = Object.entries(block.args)
+ .map(([k, v]) => `${k}="${v.replace(/"/g, """)}"`)
+ .join(" ");
+ return args ? `[[sx:${block.name} ${args}]]` : `[[sx:${block.name}]]`;
+};
+
+// ─── inline ──────────────────────────────────────────────────────────────
+
+// Stable mark order — matters so round-tripping is deterministic. The
+// parser dedupes marks per text-run; renderer wraps them in this fixed
+// order regardless of input ordering.
+const MARK_ORDER: SxMark[] = ["b", "i", "u", "s", "c"];
+const MARK_TAG: Record = {
+ b: "strong", i: "em", u: "u", s: "s", c: "code",
+};
+
+const renderInline = (inlines: SxInline[]): string =>
+ inlines.map(renderOneInline).join("");
+
+const renderOneInline = (inline: SxInline): string => {
+ if (inline.t === "a") {
+ return `${renderInline(inline.c)}`;
+ }
+ // Newline runs render as . Marks on a are meaningless so we
+ // drop them — the parser already emits them on the next text run.
+ if (inline.v === "\n") return " ";
+ let body = escText(inline.v);
+ if (inline.m && inline.m.length > 0) {
+ // MARK_ORDER lists marks outer→inner. Wrap in reverse so the
+ // innermost mark is applied first, leaving the outermost-listed
+ // mark as the outermost tag. Without the reverse, the deepest tag
+ // becomes the outermost — and a re-parse flips the mark order.
+ const sortedMarks = MARK_ORDER.filter((m) => inline.m!.includes(m));
+ for (let i = sortedMarks.length - 1; i >= 0; i--) {
+ const m = sortedMarks[i]!;
+ body = `<${MARK_TAG[m]}>${body}${MARK_TAG[m]}>`;
+ }
+ }
+ return body;
+};
+
+// ─── escape helpers ──────────────────────────────────────────────────────
+
+const escText = (s: string): string =>
+ s
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+
+const escAttr = (s: string): string =>
+ s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
diff --git a/src/c11_server.ts b/src/c11_server.ts
deleted file mode 100644
index 11e9533458f3d67c449ede48929121afde8038fa..0000000000000000000000000000000000000000
--- a/src/c11_server.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-// c11 — server entry: env + Bun.serve startup. No route logic, no SQL,
-// no HTML. The route table, fallback fetch, and error handler live in
-// c21_app.ts; this file just reads PORT and asks createApp() to bind.
-
-import { createApp } from "./c21_app.ts";
-
-const port = Number(process.env.PORT ?? 3000);
-const server = createApp(port);
-
-console.log(`tdd.md → ${server.url}`);
diff --git a/src/c13_database.ts b/src/c13_database.ts
index f7303f9a94423e2056dd75fc7654b1cb6e3d3458..ef953fd141acd14b9db41edc75d80da8d2fe5451 100644
--- a/src/c13_database.ts
+++ b/src/c13_database.ts
@@ -1,7 +1,7 @@
import { Database } from "bun:sqlite";
-import type { ProjectConfig, TestRunner, ProjectRow } from "./c31_project_config.ts";
-import type { SxDocument, SxDocumentSummary } from "./c31_sxdoc.ts";
-import { SX_DOC_VERSION } from "./c31_sxdoc.ts";
+import type { ProjectConfig, TestRunner, ProjectRow } from "./a31_project_config.ts";
+import type { SxDocument, SxDocumentSummary } from "./a31_sxdoc.ts";
+import { SX_DOC_VERSION } from "./a31_sxdoc.ts";
const DB_PATH = process.env.TDD_DB_PATH ?? ":memory:";
diff --git a/src/c14_git.ts b/src/c14_git.ts
index f829ebaeb682e5a67b7bc612c11d66e904d2797f..22c5eda437995d06923feec6fe47166386e44635 100644
--- a/src/c14_git.ts
+++ b/src/c14_git.ts
@@ -22,7 +22,7 @@ import {
parseGitCommits,
parseLsTreeLine,
type GitCommit,
-} from "./c31_git_parse.ts";
+} from "./a31_git_parse.ts";
export const GIT_DIR = process.env.TDD_GIT_DIR ?? "/app/repo";
@@ -34,7 +34,7 @@ import type {
GitCommitOk,
GitCommitFailure,
GitCommitOutcome,
-} from "./c31_git_parse.ts";
+} from "./a31_git_parse.ts";
interface RunOpts {
stdin?: string;
@@ -110,7 +110,7 @@ export const readBlobAtRef = async (ref: string, path: string): Promise => {
// `:` — git lists what's at that tree. For path="" it's
// the repo root.
diff --git a/src/c14_github.ts b/src/c14_github.ts
index bb3192826de952b2b06a95c9954cf9146ccf88be..4d1f3593e50fca01e9de56800c594016f31385eb 100644
--- a/src/c14_github.ts
+++ b/src/c14_github.ts
@@ -8,7 +8,7 @@ import {
PROJECT_CONFIG_PATH,
parseProjectConfig,
type ProjectConfig,
-} from "./c31_project_config.ts";
+} from "./a31_project_config.ts";
const CLIENT_ID = process.env.GITHUB_CLIENT_ID ?? "";
const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET ?? "";
diff --git a/src/c14_judge.ts b/src/c14_judge.ts
index 052cfe842431ab72d0578e3f47bc83b8179e2a92..bef92f5ef282f58e61d42e910763f85151b692a4 100644
--- a/src/c14_judge.ts
+++ b/src/c14_judge.ts
@@ -1,9 +1,9 @@
import { mkdtempSync, rmSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
-import { parseCommit, type Phase } from "./c31_commits.ts";
+import { parseCommit, type Phase } from "./a31_commits.ts";
import { saveRun, type Verdict, type StepVerdict, type RefactorVerdict, type Mode } from "./c13_database.ts";
-import { loadGame, type Game } from "./c31_games.ts";
+import { loadGame, type Game } from "./a31_games.ts";
type TestRunner = "bun" | "none";
diff --git a/src/c14_real_reports.ts b/src/c14_real_reports.ts
index 2cf694ae531ad0f56a2fa62b6b24be057bb6af67..a52fb93b1faec308890d9f368f456b21b6a54f04 100644
--- a/src/c14_real_reports.ts
+++ b/src/c14_real_reports.ts
@@ -8,13 +8,13 @@
// recognised footer is bucketed as "unknown" and reported separately —
// it's still useful for volume context.
-import { parseCommit } from "./c31_commits.ts";
+import { parseCommit } from "./a31_commits.ts";
import { fetchRepoCommits, type GithubCommit } from "./c14_github.ts";
import type {
AgentReport,
FailureSlice,
RecentFlagged,
-} from "./c31_reports_demo.ts";
+} from "./a31_reports_demo.ts";
type LiveAgentSlug = AgentReport["slug"] | "unknown";
diff --git a/src/c14_real_tests.ts b/src/c14_real_tests.ts
index d26637df79241040537bc2d4188bbf4679e5cafd..8afe3beae20ec59e1752b7cfce8a39fc352d4ea7 100644
--- a/src/c14_real_tests.ts
+++ b/src/c14_real_tests.ts
@@ -11,7 +11,7 @@ import type {
TestFailure,
TestSnapshot,
TestStability,
-} from "./c31_reports_demo.ts";
+} from "./a31_reports_demo.ts";
export const detectAgent = (msg: string): AgentReport["slug"] | null => {
if (/Co-Authored-By:.*Claude/i.test(msg)) return "claude-code";
diff --git a/src/c14_sama_profile.ts b/src/c14_sama_profile.ts
index a3343c486a287613b15d899bf17d77f29b2c63db..2ef3d7185e9ecaa1af5ccab29152a7d0d23018a1 100644
--- a/src/c14_sama_profile.ts
+++ b/src/c14_sama_profile.ts
@@ -19,7 +19,7 @@ import type {
ProfileSpec,
SamaV2Input,
Sublayer,
-} from "./c31_sama_v2.ts";
+} from "./a31_sama_v2.ts";
// — TOML subset parser ----------------------------------------------
diff --git a/src/c21_app.ts b/src/c21_app.ts
deleted file mode 100644
index 0b181f7fbfd1c43ee1e294997dfad61dd81952ba..0000000000000000000000000000000000000000
--- a/src/c21_app.ts
+++ /dev/null
@@ -1,458 +0,0 @@
-// c21 — handlers: the route table + fallback fetch. Composes the lower
-// layers (c13 db, c14 secondary I/O, c31 models, c32 logic, c51 render)
-// into the HTTP surface served by Bun.serve in c11_server.
-
-import {
- renderPage,
- renderNotFound,
- htmlResponse,
-} from "./c51_render_layout.ts";
-import { renderDocsPage } from "./c51_render_docs_layout.ts";
-import { listGames, loadGame } from "./c31_games.ts";
-import { ALL_POSTS } from "./c31_blog.ts";
-import { ALL_GUIDES } from "./c31_guides.ts";
-import { ALL_SAMA } from "./c31_sama.ts";
-import {
- getViewer,
- sessionCookieHeader,
-} from "./c32_session.ts";
-import { renderAgentsIndex, renderAgentDetail } from "./c21_handlers_agents.ts";
-import { renderLeaderboard } from "./c21_handlers_leaderboard.ts";
-import { startGithubOauth, handleGithubCallback } from "./c21_handlers_auth.ts";
-import {
- reportsLandingHandler,
- reportsDemoHandler,
- reportsDemoTestsHandler,
- reportsDemoAgentHandler,
- reportsLiveHandler,
- reportsLiveTestsHandler,
- reportsLiveAgentHandler,
-} from "./c21_handlers_reports.ts";
-import {
- skillsSamaMdHandler,
- samaCliResponse,
- samaSkillHandler,
- samaV2Handler,
- samaV2VerifyHandler,
- samaVerifyHandler,
- samaLandingHandler,
- samaSlugHandler,
-} from "./c21_handlers_sama.ts";
-import { editPageHandler } from "./c21_handlers_edit.ts";
-import {
- adminListHandler,
- adminNewHandler,
- adminEditHandler,
- adminDeleteHandler,
-} from "./c21_handlers_admin.ts";
-import { bundleAdminClient } from "./c14_client_bundle.ts";
-import { publicPageHandler } from "./c21_handlers_content.ts";
-import { rawSourceHandler } from "./c21_handlers_source.ts";
-import { commitViewHandler } from "./c21_handlers_commit_view.ts";
-import { appFetch, appError } from "./c21_handlers_fallback.ts";
-import {
- projectsLandingHandler,
- projectsNewHandler,
- projectDetailHandler,
-} from "./c21_handlers_projects.ts";
-import {
- judgeApiHandler,
- agentVisibilityHandler,
-} from "./c21_handlers_api_agents.ts";
-import { forgejoWebhookHandler } from "./c21_handlers_webhook.ts";
-
-const HOME_MD = "./content/home.md";
-const GAME_DIR = "./content/games";
-
-const HOME_DESCRIPTION =
- "SAMA — the architectural standard for AI-agent codebases. Sorted, Architecture, Modeled, Atomic: four pillars your CI verifier enforces so your AI coding agents stop drifting.";
-
-const homeBody = await Bun.file(HOME_MD).text();
-const HOME_HTML = await renderPage({
- title: "SAMA — the architectural standard for AI-agent codebases",
- description: HOME_DESCRIPTION,
- bodyMarkdown: homeBody,
- active: "home",
- jsonLd: {
- "@context": "https://schema.org",
- "@type": "WebSite",
- name: "tdd.md",
- url: "https://tdd.md",
- description: HOME_DESCRIPTION,
- },
-});
-
-const ALL_GAMES = await listGames();
-
-const gamesIndexBody = `# games
-
-${ALL_GAMES.length === 0
- ? "_No katas registered yet._"
- : `| kata | description | steps |\n|---|---|---|\n${ALL_GAMES.map(
- (g) => `| [${g.id}](/games/${g.id}) | ${g.description} | ${g.steps.length} |`,
- ).join("\n")}`
-}
-
-> Ready to play? [Register your agent →](/agents/register)
-> Using a specific agent? See the [agent-specific guides](/guides) — Claude Code, Cursor, Aider.
-`;
-
-const GAMES_INDEX_HTML = await renderPage({
- title: "TDD katas — tdd.md",
- description:
- "Browse the TDD katas. Pick a challenge, push red→green→refactor commits, and earn a public verdict graded against hidden tests.",
- bodyMarkdown: gamesIndexBody,
- ogPath: "https://tdd.md/games",
- active: "games",
-});
-
-const renderKata = async (kata: string): Promise => {
- const file = Bun.file(`${GAME_DIR}/${kata}/spec.md`);
- if (!(await file.exists())) return null;
- const md = await file.text();
- // Pull the kata's own description from spec.ts when available — it's
- // the canonical short copy (rendered on /games + sitemap previews).
- let description: string | undefined;
- try {
- const game = await loadGame(kata);
- description = game.description;
- } catch {
- // unknown kata; use the site default
- }
- const html = await renderPage({
- title: `${kata} TDD kata — tdd.md`,
- description,
- bodyMarkdown: md,
- ogPath: `https://tdd.md/games/${kata}`,
- active: "games",
- });
- return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
-};
-
-const REGISTER_BODY = `# register
-
-> Sign in with GitHub to create your tdd.md agent.
-
-## what we ask GitHub for
-- your username
-- your primary verified email
-
-That's it — no repo access, no anything else.
-
-## what you get
-- a public agent account at \`git.tdd.md/\`
-- a push token (shown once)
-- an empty repo for the first kata, ready to push to
-
-[ sign in with github → ](/auth/github/start)
-`;
-
-const REGISTER_HTML = await renderPage({
- title: "Register your AI agent — tdd.md",
- description:
- "Sign in with GitHub to register your AI agent on tdd.md and start solving TDD katas. Public-signup, verified-identity, no extra forms.",
- bodyMarkdown: REGISTER_BODY,
- ogPath: "https://tdd.md/agents/register",
- active: "agents",
- noindex: true,
-});
-
-// ---------------------------------------------------------------------
-// App factory — c11 calls createApp(port) to start the server. The
-// routes literal stays inline here so Bun's path-parameter inference
-// (`:slug` → `req.params.slug`) flows through to the handler types.
-// ---------------------------------------------------------------------
-
-export const createApp = (port: number) => Bun.serve({
- port,
- error: appError,
- fetch: appFetch,
- routes: {
- "/": htmlResponse(HOME_HTML),
- "/raw": new Response(Bun.file(HOME_MD), {
- headers: { "Content-Type": "text/markdown; charset=utf-8" },
- }),
- "/healthz": new Response("ok"),
-
- "/robots.txt": new Response(
- `User-agent: *\nAllow: /\nDisallow: /auth/\nDisallow: /api/\n\nSitemap: https://tdd.md/sitemap.xml\n`,
- { headers: { "Content-Type": "text/plain; charset=utf-8" } },
- ),
-
- "/sitemap.xml": async () => {
- const today = new Date().toISOString().slice(0, 10);
- const url = (loc: string, priority: string) =>
- `${loc}${today}${priority}`;
- const kataUrls = ALL_GAMES.map((g) =>
- url(`https://tdd.md/games/${g.id}`, "0.8"),
- ).join("\n");
- const guideUrls = ALL_GUIDES.map((g) =>
- url(`https://tdd.md/guides/${g.slug}`, "0.8"),
- ).join("\n");
- const samaUrls = ALL_SAMA.map((d) =>
- url(`https://tdd.md/sama/${d.slug}`, "0.8"),
- ).join("\n");
- const blogUrls = ALL_POSTS.map((p) =>
- url(`https://tdd.md/blog/${p.slug}`, "0.8"),
- ).join("\n");
- const xml = `
-
-${url("https://tdd.md/", "1.0")}
-${url("https://tdd.md/games", "0.9")}
-${kataUrls}
-${url("https://tdd.md/guides", "0.9")}
-${guideUrls}
-${url("https://tdd.md/sama", "0.9")}
-${samaUrls}
-${url("https://tdd.md/sama/skill", "0.8")}
-${url("https://tdd.md/blog", "0.7")}
-${blogUrls}
-${url("https://tdd.md/agents", "0.7")}
-${url("https://tdd.md/leaderboard", "0.7")}
-`;
- return new Response(xml, {
- headers: { "Content-Type": "application/xml; charset=utf-8" },
- });
- },
-
- "/og.svg": new Response(Bun.file("./public/og.svg"), {
- headers: {
- "Content-Type": "image/svg+xml",
- "Cache-Control": "public, max-age=3600",
- },
- }),
-
- "/og.png": new Response(Bun.file("./public/og.png"), {
- headers: {
- "Content-Type": "image/png",
- "Cache-Control": "public, max-age=3600",
- },
- }),
-
- "/games": htmlResponse(GAMES_INDEX_HTML),
-
- "/blog": async () => {
- const rows = ALL_POSTS
- .map((p) => `| ${p.date} | [${p.title}](/blog/${p.slug}) |`)
- .join("\n");
- const body = `# blog
-
-Notes on TDD, agentic coding, and the discipline that ties them together.
-
-| date | post |
-|---|---|
-${rows}
-
-> RSS feed coming when there's a second post.
-
-[← back to tdd.md](/) · [the guides](/guides) · [the katas](/games)
-`;
- const html = await renderDocsPage({
- title: "Blog — tdd.md",
- description: "Posts on test-driven development for AI coding agents — how to apply TDD with Claude Code, Cursor, and Aider, what we learn from the verdicts.",
- bodyMarkdown: body,
- ogPath: "https://tdd.md/blog",
- active: "blog",
- pathForDocs: "/blog",
- editPathOverride: null,
- });
- return htmlResponse(html);
- },
-
- "/blog/:slug": async (req) => {
- const slug = req.params.slug;
- const entry = ALL_POSTS.find((p) => p.slug === slug);
- if (!entry) {
- const html = await renderNotFound(`/blog/${slug}`);
- return htmlResponse(html, 404);
- }
- const file = Bun.file(`./content/blog/${slug}.md`);
- if (!(await file.exists())) {
- const html = await renderNotFound(`/blog/${slug}`);
- return htmlResponse(html, 404);
- }
- const md = await file.text();
- const html = await renderDocsPage({
- title: `${entry.title} — tdd.md`,
- description: entry.description,
- bodyMarkdown: md,
- ogPath: `https://tdd.md/blog/${slug}`,
- active: "blog",
- pathForDocs: `/blog/${slug}`,
- jsonLd: {
- "@context": "https://schema.org",
- "@type": "BlogPosting",
- headline: entry.title,
- description: entry.description,
- datePublished: entry.date,
- url: `https://tdd.md/blog/${slug}`,
- author: { "@type": "Organization", name: "tdd.md" },
- },
- });
- return htmlResponse(html);
- },
-
- "/projects": projectsLandingHandler,
- "/projects/new": projectsNewHandler,
- "/projects/:repoOwner/:repoName": projectDetailHandler,
-
- "/reports": reportsLandingHandler,
- "/reports/demo": reportsDemoHandler,
- "/reports/demo/tests": reportsDemoTestsHandler,
- "/reports/demo/agents/:slug": reportsDemoAgentHandler,
- "/reports/live": reportsLiveHandler,
- "/reports/live/tests": reportsLiveTestsHandler,
- "/reports/live/agents/:slug": reportsLiveAgentHandler,
-
- "/guides": async () => {
- const rows = ALL_GUIDES
- .map((g) => `| [${g.title}](/guides/${g.slug}) | ${g.description} |`)
- .join("\n");
- const body = `# guides
-
-Agent-specific walkthroughs for using tdd.md with the major agentic-coding tools. Each guide covers setup, prompt patterns that keep the agent in TDD, and the common pitfalls that cost score.
-
-| guide | what it covers |
-|---|---|
-${rows}
-
-> Missing your agent? [The mechanics are the same](/) — push commits tagged \`red:\` / \`green:\` / \`refactor:\` to your kata repo. Send a PR with a new guide and we'll list it here.
-
-[← play a kata](/games) · [register your agent →](/you)
-`;
- const html = await renderDocsPage({
- title: "TDD guides for agentic coding tools — tdd.md",
- description: "Practical TDD walkthroughs for Claude Code, Cursor, Aider and other AI coding agents — keep your agent honest with red→green→refactor commits, scored by tdd.md.",
- bodyMarkdown: body,
- ogPath: "https://tdd.md/guides",
- active: "guides",
- pathForDocs: "/guides",
- editPathOverride: null,
- });
- return htmlResponse(html);
- },
-
- "/guides/:slug": async (req) => {
- const slug = req.params.slug;
- const entry = ALL_GUIDES.find((g) => g.slug === slug);
- if (!entry) {
- const html = await renderNotFound(`/guides/${slug}`);
- return htmlResponse(html, 404);
- }
- const file = Bun.file(`./content/guides/${slug}.md`);
- if (!(await file.exists())) {
- const html = await renderNotFound(`/guides/${slug}`);
- return htmlResponse(html, 404);
- }
- const md = await file.text();
- const html = await renderDocsPage({
- title: `${entry.title} — tdd.md`,
- description: entry.description,
- bodyMarkdown: md,
- ogPath: `https://tdd.md/guides/${slug}`,
- active: "guides",
- pathForDocs: `/guides/${slug}`,
- });
- return htmlResponse(html);
- },
-
- "/skills/sama.md": skillsSamaMdHandler,
- "/tools/sama-cli": samaCliResponse(),
-
- "/sama/skill": samaSkillHandler,
-
- "/sama/v2": samaV2Handler,
-
- "/sama/v2/verify": samaV2VerifyHandler,
-
- "/sama/verify": samaVerifyHandler,
-
- "/sama": samaLandingHandler,
-
- "/sama/:slug": samaSlugHandler,
-
- "/games/:kata": async (req) => {
- const res = await renderKata(req.params.kata);
- if (res) return res;
- const html = await renderNotFound(`/games/${req.params.kata}`);
- return htmlResponse(html, 404);
- },
-
- "/agents": () => renderAgentsIndex(),
- "/agents/register": htmlResponse(REGISTER_HTML),
- "/agents/:name": async (req) => {
- const viewer = await getViewer(req);
- return renderAgentDetail(req.params.name, viewer);
- },
- // Redirect the legacy URL to the canonical /:owner/:repo path —
- // /agents/:name/:kata used to render a placeholder before the
- // GitHub-style routing landed.
- "/agents/:name/:kata": (req) =>
- Response.redirect(`/${req.params.name}/${req.params.kata}`, 301),
-
- "/leaderboard": () => renderLeaderboard(),
-
- "/api/judge/:owner/:repo": judgeApiHandler,
- "/api/agents/:name/visibility": agentVisibilityHandler,
- "/api/forgejo/webhook": forgejoWebhookHandler,
-
- "/you": async (req) => {
- const viewer = await getViewer(req);
- const target = viewer ? `/agents/${viewer}` : "/auth/github/start";
- return new Response(null, { status: 302, headers: { Location: target } });
- },
-
- "/auth/logout": (_req) => {
- // Clear the session cookie and bounce back home.
- return new Response(null, {
- status: 302,
- headers: {
- Location: "/",
- "Set-Cookie": sessionCookieHeader("", 0),
- },
- });
- },
-
- "/edit/:section/:slug": editPageHandler,
-
- // Admin UI — sxdoc-backed CRUD on pages + posts. Replaces the legacy
- // /edit flow in Fase 6; both live alongside until migration cutover.
- "/admin": adminListHandler,
- "/admin/new": adminNewHandler,
- "/admin/edit/:type/:slug": adminEditHandler,
- "/admin/delete/:type/:slug": adminDeleteHandler,
- // Public sxdoc-backed pages — single-segment fast path. Multi-segment
- // slugs fall through to appFetch's regex matcher above.
- "/p/:slug": publicPageHandler,
-
- "/admin/assets/blockeditor.js": async (req) => {
- const { code, etag } = await bundleAdminClient();
- if (req.headers.get("if-none-match") === etag) {
- return new Response(null, { status: 304, headers: { ETag: etag } });
- }
- return new Response(code, {
- headers: {
- "Content-Type": "application/javascript; charset=utf-8",
- "ETag": etag,
- "Cache-Control": "no-cache",
- },
- });
- },
-
- // Raw markdown source — replaces the previous git.tdd.md "view source"
- // link so docs pages don't depend on the Forgejo subdomain. The
- // route uses `:filename` (with trailing `.md` validated in the
- // handler) because Bun's parser treats `:slug.md` as a single param.
- "/content/:section/:filename": rawSourceHandler,
-
- // SAMA-native commit view — Bun-rendered alternative to Forgejo's
- // ///commit/ page. The :sha param may carry a
- // trailing ".diff" which the handler handles inline.
- "/GIT/:owner/:repo/commit/:sha": commitViewHandler,
-
- "/auth/github/start": (req) => startGithubOauth(req),
-
- "/auth/github/callback": async (req) => handleGithubCallback(req),
-
- },
-});
diff --git a/src/c21_handlers_admin.ts b/src/c21_handlers_admin.ts
deleted file mode 100644
index 1c115e19a6eda4c74b2f26aab3acd84f3c87b883..0000000000000000000000000000000000000000
--- a/src/c21_handlers_admin.ts
+++ /dev/null
@@ -1,254 +0,0 @@
-// c21 — handlers: CRUD on sxdoc-backed pages + posts.
-//
-// Composes:
-// c13_database listDocuments / loadDocument / saveDocument / deleteDocument
-// c32_session getViewer (admin gate)
-// c31_sxdoc_parse htmlToSx (parse posted HTML → SxDocument)
-// c51_render_sxdoc sxToHtml (project stored doc back to HTML for the form)
-// c31_admin_validation validateEditForm (form → typed input)
-// c51_render_admin shell rendering
-//
-// Routes (mounted in c21_app.ts):
-// GET /admin
-// GET /admin/new
-// POST /admin/new
-// GET /admin/edit/:type/:slug
-// POST /admin/edit/:type/:slug
-// POST /admin/delete/:type/:slug
-//
-// Auth: any non-admin signed-in viewer → 403 wall (matches the legacy
-// /edit handler). Anonymous → 401 login wall.
-
-import { ADMIN_USERNAME } from "./c31_site_config.ts";
-import {
- listDocuments,
- loadDocument,
- saveDocument,
- deleteDocument,
-} from "./c13_database.ts";
-import { getViewer } from "./c32_session.ts";
-import { htmlToSx } from "./c31_sxdoc_parse.ts";
-import { validateEditForm } from "./c31_admin_validation.ts";
-import { htmlResponse } from "./c51_render_layout.ts";
-import {
- renderAdminList,
- renderAdminEdit,
- renderAdminLoginWall,
- renderAdminNonAdminWall,
-} from "./c51_render_admin.ts";
-
-const wantsJson = (req: Request): boolean =>
- (req.headers.get("accept") ?? "").includes("application/json");
-
-const jsonResponse = (body: unknown, status = 200): Response =>
- new Response(JSON.stringify(body), {
- status,
- headers: {
- "Content-Type": "application/json; charset=utf-8",
- "Cache-Control": "no-store",
- },
- });
-
-// ─── auth gate ───────────────────────────────────────────────────────────
-
-interface AuthOk { ok: true; viewer: string; }
-interface AuthDenied { ok: false; response: Response; }
-type AuthResult = AuthOk | AuthDenied;
-
-const requireAdmin = async (req: Request): Promise => {
- const viewer = await getViewer(req);
- if (!viewer) {
- const html = await renderAdminLoginWall();
- return { ok: false, response: htmlResponse(html, 401) };
- }
- if (viewer !== ADMIN_USERNAME) {
- const html = await renderAdminNonAdminWall(viewer);
- return { ok: false, response: htmlResponse(html, 403) };
- }
- return { ok: true, viewer };
-};
-
-// FormData → string-record adapter. The validator lives in c31 and
-// stays browser-agnostic by taking plain string fields.
-const formToRecord = async (req: Request): Promise> => {
- const fd = await req.formData();
- const out: Record = {};
- for (const [k, v] of fd.entries()) out[k] = String(v);
- return out;
-};
-
-// ─── handlers ────────────────────────────────────────────────────────────
-
-export const adminListHandler = async (req: Request): Promise => {
- const auth = await requireAdmin(req);
- if (!auth.ok) return auth.response;
- const documents = listDocuments();
- const html = await renderAdminList(documents);
- return htmlResponse(html);
-};
-
-export const adminNewHandler = async (req: Request): Promise => {
- const auth = await requireAdmin(req);
- if (!auth.ok) return auth.response;
- const json = wantsJson(req);
-
- if (req.method === "POST") {
- const form = await formToRecord(req);
- const v = validateEditForm(form);
- if (!v.ok) {
- if (json) return jsonResponse({ ok: false, error: v.error }, 400);
- const html = await renderAdminEdit({
- mode: "new",
- title: form.title ?? "",
- slug: form.slug ?? "",
- type: form.type === "post" ? "post" : "page",
- doc: htmlToSx(form.html ?? ""),
- status: form.status === "draft" ? "draft" : "published",
- primaryTag: (form.primary_tag ?? "").trim() || null,
- error: v.error,
- });
- return htmlResponse(html, 400);
- }
- if (loadDocument(v.data.slug, v.data.type)) {
- const err = `a ${v.data.type} with slug "${v.data.slug}" already exists`;
- if (json) return jsonResponse({ ok: false, error: err }, 409);
- const html = await renderAdminEdit({
- mode: "new",
- title: v.data.title,
- slug: v.data.slug,
- type: v.data.type,
- doc: htmlToSx(v.data.html),
- status: v.data.status,
- primaryTag: v.data.primaryTag,
- error: err,
- });
- return htmlResponse(html, 409);
- }
- saveDocument({
- slug: v.data.slug,
- type: v.data.type,
- title: v.data.title,
- doc: htmlToSx(v.data.html),
- status: v.data.status,
- primaryTag: v.data.primaryTag,
- });
- if (json) {
- return jsonResponse({ ok: true, ts: Date.now(), slug: v.data.slug, type: v.data.type });
- }
- return new Response(null, {
- status: 303,
- headers: { Location: `/admin/edit/${v.data.type}/${v.data.slug}` },
- });
- }
-
- // GET — empty form
- const html = await renderAdminEdit({
- mode: "new",
- title: "",
- slug: "",
- type: "page",
- doc: htmlToSx("
Hello, world.
"),
- status: "published",
- primaryTag: null,
- });
- return htmlResponse(html);
-};
-
-export const adminEditHandler = async (
- req: Request & { params: { type: string; slug: string } },
-): Promise => {
- const auth = await requireAdmin(req);
- if (!auth.ok) return auth.response;
-
- const type = req.params.type === "post" ? "post" : "page";
- if (req.params.type !== "page" && req.params.type !== "post") {
- return new Response("invalid type", { status: 400 });
- }
- const slug = req.params.slug;
- const existing = loadDocument(slug, type);
- if (!existing) return new Response("not found", { status: 404 });
-
- if (req.method === "POST") {
- const form = await formToRecord(req);
- const json = wantsJson(req);
- const v = validateEditForm(form);
- if (!v.ok) {
- if (json) return jsonResponse({ ok: false, error: v.error }, 400);
- const html = await renderAdminEdit({
- mode: "edit",
- title: form.title ?? existing.title,
- slug: form.slug ?? slug,
- type,
- doc: htmlToSx(form.html ?? ""),
- status: form.status === "draft" ? "draft" : "published",
- primaryTag: (form.primary_tag ?? "").trim() || existing.primaryTag,
- error: v.error,
- });
- return htmlResponse(html, 400);
- }
- // Rename (slug or type changed) — reject collision with another
- // existing doc; otherwise delete the old key before saving the new one.
- if (v.data.slug !== slug || v.data.type !== type) {
- const collision = loadDocument(v.data.slug, v.data.type);
- if (collision && collision.id !== existing.id) {
- const err = `a ${v.data.type} with slug "${v.data.slug}" already exists`;
- if (json) return jsonResponse({ ok: false, error: err }, 409);
- const html = await renderAdminEdit({
- mode: "edit",
- title: v.data.title,
- slug: v.data.slug,
- type: v.data.type,
- doc: htmlToSx(v.data.html),
- status: v.data.status,
- primaryTag: v.data.primaryTag,
- error: err,
- });
- return htmlResponse(html, 409);
- }
- deleteDocument(slug, type);
- }
- saveDocument({
- slug: v.data.slug,
- type: v.data.type,
- title: v.data.title,
- doc: htmlToSx(v.data.html),
- status: v.data.status,
- primaryTag: v.data.primaryTag,
- });
- if (json) {
- return jsonResponse({ ok: true, ts: Date.now(), slug: v.data.slug, type: v.data.type });
- }
- return new Response(null, {
- status: 303,
- headers: { Location: `/admin/edit/${v.data.type}/${v.data.slug}` },
- });
- }
-
- // GET — render the stored sxdoc directly; c51_render_admin computes
- // the textarea HTML projection and embeds the JSON for client hydration.
- const html = await renderAdminEdit({
- mode: "edit",
- title: existing.title,
- slug: existing.slug,
- type: existing.type,
- doc: existing.doc,
- status: existing.status,
- primaryTag: existing.primaryTag,
- });
- return htmlResponse(html);
-};
-
-export const adminDeleteHandler = async (
- req: Request & { params: { type: string; slug: string } },
-): Promise => {
- const auth = await requireAdmin(req);
- if (!auth.ok) return auth.response;
- if (req.method !== "POST") return new Response("POST only", { status: 405 });
-
- const type = req.params.type === "post" ? "post" : "page";
- if (req.params.type !== "page" && req.params.type !== "post") {
- return new Response("invalid type", { status: 400 });
- }
- deleteDocument(req.params.slug, type);
- return new Response(null, { status: 303, headers: { Location: "/admin" } });
-};
diff --git a/src/c21_handlers_agents.ts b/src/c21_handlers_agents.ts
deleted file mode 100644
index 99486b75960829dcbcfd3103418074846c212e01..0000000000000000000000000000000000000000
--- a/src/c21_handlers_agents.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-// c21 (agents) — handlers for /agents (index) and /agents/:name (detail).
-// Both compose Forgejo admin lookups (c14) with kata progress (c31) and
-// the verdict store (c13). The route table in c21_app.ts forwards the
-// matching path here.
-
-import {
- FORGEJO_URL,
- adminApiHeaders,
- type ForgejoUserSummary,
-} from "./c14_forgejo.ts";
-import { computeProgress } from "./c31_commits.ts";
-import { loadGame } from "./c31_games.ts";
-import { allLatestRuns } from "./c13_database.ts";
-import {
- renderPage,
- renderNotFound,
- htmlResponse,
-} from "./c51_render_layout.ts";
-
-export const renderAgentsIndex = async (): Promise => {
- let users: ForgejoUserSummary[] = [];
- const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
- if (adminToken) {
- const r = await fetch(`${FORGEJO_URL}/api/v1/admin/users?limit=200`, {
- headers: adminApiHeaders(),
- });
- if (r.ok) users = (await r.json()) as ForgejoUserSummary[];
- }
- // Drop the admin (id 1) and anyone whose visibility isn't "public" —
- // private and limited agents stay invisible on the public index.
- const agents = users.filter(
- (u) => u.id !== 1 && !u.is_admin && (u.visibility ?? "public") === "public",
- );
-
- // Per-agent score totals from the latest run per repo.
- const allRuns = allLatestRuns();
- const totalsByOwner = new Map();
- for (const r of allRuns) {
- const t = totalsByOwner.get(r.owner) ?? { score: 0, runs: 0 };
- t.score += r.verdict.totalScore;
- t.runs += 1;
- totalsByOwner.set(r.owner, t);
- }
-
- let body: string;
- if (agents.length === 0) {
- body = `# agents
-
-> No agents registered yet. Be the first.
-
-[ Register your agent → ](/agents/register)
-`;
- } else {
- const rows = agents
- .map((u) => {
- const t = totalsByOwner.get(u.login) ?? { score: 0, runs: 0 };
- const sign = t.score >= 0 ? "+" : "";
- return `| [${u.login}](/agents/${u.login}) | ${t.runs} | ${sign}${t.score} |`;
- })
- .join("\n");
- body = `# agents
-
-| agent | attempts | total score |
-|---|---|---|
-${rows}
-
-[ Register your agent → ](/agents/register)
-`;
- }
-
- const description =
- agents.length === 0
- ? "AI agents doing test-driven development on tdd.md — registration is open, sign in with GitHub to play."
- : `${agents.length} AI ${agents.length === 1 ? "agent" : "agents"} doing test-driven development on tdd.md, scored on red→green discipline against hidden tests for agentic coding.`;
-
- const html = await renderPage({
- title: "AI agents on tdd.md",
- description,
- bodyMarkdown: body,
- ogPath: "https://tdd.md/agents",
- active: "agents",
- });
- return htmlResponse(html);
-};
-
-export const renderAgentDetail = async (
- name: string,
- viewer: string | null,
-): Promise => {
- const userRes = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(name)}`, {
- headers: adminApiHeaders(),
- });
- // Treat private/limited users as if they don't exist publicly —
- // unless the logged-in viewer IS the owner. Owner can always see
- // their own dashboard, public or not.
- if (userRes.ok) {
- const u = (await userRes.clone().json()) as ForgejoUserSummary;
- const ownVisibility = u.visibility ?? "public";
- if (ownVisibility !== "public" && viewer !== name) {
- const html = await renderNotFound(`/agents/${name}`);
- return htmlResponse(html, 404);
- }
- }
- if (userRes.status === 404) {
- const html = await renderPage({
- title: `${name} — agents — tdd.md`,
- bodyMarkdown: `# agents / ${name}\n\n> No agent registered with this name.\n\n[← all agents](/agents) · [register your own →](/agents/register)`,
- ogPath: `https://tdd.md/agents/${name}`,
- active: "agents",
- });
- return htmlResponse(html, 404);
- }
- const reposRes = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(name)}/repos?limit=50`, {
- headers: adminApiHeaders(),
- });
- const repos = reposRes.ok ? ((await reposRes.json()) as { name: string; description: string }[]) : [];
-
- const progressByRepo = await Promise.all(
- repos.map(async (r) => {
- const cRes = await fetch(
- `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(name)}/${encodeURIComponent(r.name)}/commits?limit=50&stat=false`,
- { headers: adminApiHeaders() },
- );
- const commits = cRes.ok ? ((await cRes.json()) as { commit: { message: string } }[]) : [];
- return { repo: r, progress: computeProgress(commits) };
- }),
- );
-
- const totals: Record = {};
- for (const r of repos) {
- try {
- const game = await loadGame(r.name);
- totals[r.name] = game.steps.length;
- } catch {
- // unknown kata, no total
- }
- }
-
- const isSelf = viewer === name;
- let body = `# agents / ${name}\n\n`;
- if (isSelf) {
- body += `> Welcome back, ${name}. This is your dashboard — only you and admins see it when your profile is private.\n\n`;
- }
- if (repos.length === 0) {
- body += "> Registered, but no kata attempts yet.\n\n[← all agents](/agents)";
- } else {
- body += "## attempts\n\n";
- body += "| kata | verified | phases |\n|---|---|---|\n";
- for (const { repo: r, progress } of progressByRepo) {
- const total = totals[r.name];
- const verified = progress.verifiedSteps.size;
- const counter = total !== undefined ? `${verified} / ${total}` : `${verified} / ?`;
- const phases = `red ${progress.redCount} · green ${progress.greenCount} · refactor ${progress.refactorCount}`;
- body += `| [${r.name}](/${name}/${r.name}) | ${counter} | ${phases} |\n`;
- }
- }
-
- if (isSelf) {
- body += `\n\n---\n\n[sign out](/auth/logout) · [toggle visibility](#) (POST /api/agents/${name}/visibility with your push token)`;
- }
-
- const verifiedSteps = progressByRepo.reduce((acc, p) => acc + p.progress.verifiedSteps.size, 0);
- const description =
- repos.length === 0
- ? `${name} just registered on tdd.md — no kata attempts yet.`
- : `${name}'s TDD attempts on tdd.md: ${repos.length} ${repos.length === 1 ? "kata" : "katas"} pushed, ${verifiedSteps} verified red→green ${verifiedSteps === 1 ? "step" : "steps"}.`;
- const html = await renderPage({
- title: `${name} · TDD attempts — tdd.md`,
- description,
- bodyMarkdown: body,
- ogPath: `https://tdd.md/agents/${name}`,
- active: "agents",
- });
- return htmlResponse(html);
-};
diff --git a/src/c21_handlers_api_agents.ts b/src/c21_handlers_api_agents.ts
deleted file mode 100644
index 59b9c8d8e7f4cc01937a6768ccec6873ca5e3fcb..0000000000000000000000000000000000000000
--- a/src/c21_handlers_api_agents.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-// c21 — handlers: agent-facing JSON API. Manual judge trigger
-// (admin-token-gated) and the self-service visibility toggle (agent
-// pushes their own Forgejo token to flip public|limited|private).
-// Extracted from c21_app.ts per the SAMA Atomic rule. The push-driven
-// judge entry point lives in c21_handlers_webhook — different auth
-// model (HMAC), different concept.
-
-import { judge } from "./c14_judge.ts";
-import { timingSafeEqual } from "./c32_session.ts";
-import {
- FORGEJO_URL,
- adminApiHeaders,
-} from "./c14_forgejo.ts";
-
-export const judgeApiHandler = async (
- req: Request & { params: { owner: string; repo: string } },
-): Promise => {
- if (req.method !== "POST") {
- return new Response("method not allowed; POST to trigger a judge run", { status: 405 });
- }
- // Manual triggers require the admin token. Push-driven runs come
- // through /api/forgejo/webhook with HMAC signature verification.
- const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
- const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? "";
- if (!adminToken || !timingSafeEqual(provided, adminToken)) {
- return new Response(
- "unauthorized — POST with `Authorization: Bearer `",
- { status: 401 },
- );
- }
- try {
- const verdict = await judge(req.params.owner, req.params.repo);
- return Response.json(verdict);
- } catch (err) {
- return Response.json({ error: (err as Error).message }, { status: 500 });
- }
-};
-
-// Self-service visibility toggle. Agent posts their push token in
-// Authorization, picks "public" | "limited" | "private". We verify
-// the token actually belongs to :name by hitting Forgejo's /user
-// endpoint with it, then PATCH the user via the admin token.
-export const agentVisibilityHandler = async (
- req: Request & { params: { name: string } },
-): Promise => {
- if (req.method !== "POST") return new Response("POST only", { status: 405 });
- const name = req.params.name;
- const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? "";
- if (!provided) return Response.json({ error: "missing bearer token" }, { status: 401 });
-
- // Verify the token belongs to :name (or is the admin token).
- const adminToken = process.env.FORGEJO_ADMIN_TOKEN ?? "";
- let allowed = !!adminToken && timingSafeEqual(provided, adminToken);
- if (!allowed) {
- const meRes = await fetch(`${FORGEJO_URL}/api/v1/user`, {
- headers: { Authorization: `token ${provided}` },
- });
- if (meRes.ok) {
- const me = (await meRes.json()) as { login?: string };
- allowed = me.login === name;
- }
- }
- if (!allowed) return Response.json({ error: "token does not match agent" }, { status: 403 });
-
- let body: { visibility?: string };
- try {
- body = (await req.json()) as { visibility?: string };
- } catch {
- return Response.json({ error: "invalid json" }, { status: 400 });
- }
- const visibility = body.visibility;
- if (visibility !== "public" && visibility !== "limited" && visibility !== "private") {
- return Response.json(
- { error: "visibility must be one of public|limited|private" },
- { status: 400 },
- );
- }
-
- const patchRes = await fetch(
- `${FORGEJO_URL}/api/v1/admin/users/${encodeURIComponent(name)}`,
- {
- method: "PATCH",
- headers: { ...adminApiHeaders(), "Content-Type": "application/json" },
- body: JSON.stringify({ visibility, source_id: 0, login_name: name }),
- },
- );
- if (!patchRes.ok) {
- const text = await patchRes.text();
- return Response.json(
- { error: `forgejo PATCH failed: ${patchRes.status} ${text}` },
- { status: 502 },
- );
- }
- return Response.json({ name, visibility });
-};
diff --git a/src/c21_handlers_auth.ts b/src/c21_handlers_auth.ts
deleted file mode 100644
index 85cd3e68240bc4c6bd21e4025182f3a5cd465a5d..0000000000000000000000000000000000000000
--- a/src/c21_handlers_auth.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-// c21 (auth) — GitHub OAuth start + callback handlers. Composes
-// c14_github (token exchange + user fetch), c14_forgejo (existence check
-// + agent registration), c32_session (sign + cookie), c51 layout for
-// the welcome page rendered after first-time registration.
-
-import * as github from "./c14_github.ts";
-import * as forgejo from "./c14_forgejo.ts";
-import { parseUrl } from "./c14_request_parse.ts";
-import {
- SESSION_TTL_SEC,
- parseCookies,
- signSession,
- sessionCookieHeader,
- timingSafeEqual,
- randomHex,
-} from "./c32_session.ts";
-import { renderPage, errorPage } from "./c51_render_layout.ts";
-
-const BASE_URL = process.env.BASE_URL ?? "https://tdd.md";
-const CALLBACK_URL = `${BASE_URL}/auth/github/callback`;
-
-const CLEAR_OAUTH_STATE =
- "tdd_oauth_state=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0";
-const CLEAR_OAUTH_RETURN =
- "tdd_oauth_return=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0";
-
-// Same-origin internal path. Anything that doesn't start with a single
-// "/" or that contains "//" / ":" is rejected to prevent open-redirect.
-const isSafeReturnTo = (s: string): boolean =>
- s.startsWith("/") && !s.startsWith("//") && !s.includes("\n") && !s.includes("\r") && s.length < 1024;
-
-export const startGithubOauth = (req?: Request): Response => {
- if (!github.isConfigured() || !forgejo.isConfigured()) {
- return new Response("registration is not configured on this server", { status: 503 });
- }
- const nonce = randomHex(16);
- const headers = new Headers();
- headers.append("Set-Cookie", `tdd_oauth_state=${nonce}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`);
-
- // Optional ?to= query — set a return cookie the callback
- // honours after a successful sign-in. Used by /edit and /admin
- // links so the user lands back where they came from.
- if (req) {
- const urlR = parseUrl(req.url);
- const to = urlR.ok ? urlR.value.searchParams.get("to") : null;
- if (to && isSafeReturnTo(to)) {
- headers.append(
- "Set-Cookie",
- `tdd_oauth_return=${encodeURIComponent(to)}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`,
- );
- }
- }
- headers.set("Location", github.authorizeUrl(nonce, CALLBACK_URL));
- return new Response(null, { status: 302, headers });
-};
-
-const welcomeBody = (reg: forgejo.AgentRegistration): string => {
- const verb = reg.isNew ? "created" : "rotated";
- return `# welcome, ${reg.username}
-
-> Your tdd.md agent has been ${verb}. **Save the token below — this page is the only time you'll see it.** If you lose it, [register again](/agents/register) to issue a fresh one (the old one will stop working).
-
-## push token
-
-\`\`\`
-${reg.pushToken}
-\`\`\`
-
-## kata: string-calc
-
-Your repo is at [\`git.tdd.md/${reg.username}/string-calc\`](https://git.tdd.md/${reg.username}/string-calc), already initialized with a default branch \`main\`.
-
-\`\`\`
-git clone ${reg.repoCloneUrl}
-cd string-calc
-
-# play the kata, commit per phase
-# red: commit a failing test
-# green: commit the impl that makes it pass
-# refactor: commit a structural change with tests staying green
-
-git push
-# username: ${reg.username}
-# password:
-\`\`\`
-
-When you push, the judge replays your commits and posts the verdict at [/agents/${reg.username}/string-calc](/agents/${reg.username}/string-calc).
-
-[← spec](/games/string-calc) · [all agents](/agents)
-`;
-};
-
-export const handleGithubCallback = async (req: Request): Promise => {
- const urlR = parseUrl(req.url);
- if (!urlR.ok) return errorPage("invalid callback URL");
- const url = urlR.value;
- const code = url.searchParams.get("code");
- const state = url.searchParams.get("state");
- if (!code || !state) return errorPage("missing code or state");
-
- const cookies = parseCookies(req.headers.get("cookie"));
- const cookieState = cookies.tdd_oauth_state;
- if (!cookieState || !timingSafeEqual(cookieState, state)) {
- return errorPage("state mismatch — open the registration page again and retry");
- }
-
- let username: string;
- let email: string;
- let fullName: string | null;
- try {
- const accessToken = await github.exchangeCode(code, CALLBACK_URL);
- const user = await github.fetchUser(accessToken);
- username = user.login;
- fullName = user.name;
- // GitHub's noreply email format: unique per account, never collides
- // with another Forgejo user. We don't need a deliverable address —
- // agents authenticate by token, not by email reset flow.
- email = `${user.id}+${user.login}@users.noreply.github.com`;
- } catch (err) {
- return errorPage(`github oauth failed: ${(err as Error).message}`, 400);
- }
-
- // Login vs register: if the user already exists in Forgejo, this
- // is a returning visitor — set the session cookie, redirect to
- // their dashboard (or to the cookie-stored returnTo path, when one
- // was set by /auth/github/start?to=...), don't rotate their token.
- const isExisting = await forgejo.userExists(username);
- const sessionToken = await signSession(username);
- const sessionCookie = sessionCookieHeader(sessionToken, SESSION_TTL_SEC);
- const returnToRaw = cookies.tdd_oauth_return ? decodeURIComponent(cookies.tdd_oauth_return) : null;
- const returnTo = returnToRaw && isSafeReturnTo(returnToRaw) ? returnToRaw : null;
-
- if (isExisting) {
- return new Response(null, {
- status: 302,
- headers: new Headers([
- ["Location", returnTo ?? `/agents/${username}`],
- ["Set-Cookie", sessionCookie],
- ["Set-Cookie", CLEAR_OAUTH_STATE],
- ["Set-Cookie", CLEAR_OAUTH_RETURN],
- ]),
- });
- }
-
- let reg: forgejo.AgentRegistration;
- try {
- reg = await forgejo.registerAgent({
- username,
- email,
- fullName: fullName ?? undefined,
- });
- } catch (err) {
- return errorPage(`failed to create your agent: ${(err as Error).message}`, 422);
- }
-
- const html = await renderPage({
- title: `welcome ${reg.username} — tdd.md`,
- bodyMarkdown: welcomeBody(reg),
- active: "agents",
- noindex: true,
- });
- return new Response(html, {
- headers: new Headers([
- ["Content-Type", "text/html; charset=utf-8"],
- ["Set-Cookie", sessionCookie],
- ["Set-Cookie", CLEAR_OAUTH_STATE],
- ["Set-Cookie", CLEAR_OAUTH_RETURN],
- ]),
- });
-};
diff --git a/src/c21_handlers_commit_view.ts b/src/c21_handlers_commit_view.ts
deleted file mode 100644
index 03bf463c61ec34908809971f4c5139cb9098fff4..0000000000000000000000000000000000000000
--- a/src/c21_handlers_commit_view.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-// c21 — handler: SAMA-native commit view at
-// GET /GIT/:owner/:repo/commit/:sha
-// and a raw-diff sibling at
-// GET /GIT/:owner/:repo/commit/:sha.diff
-//
-// Composes c14 (Forgejo HTTP), c31 (diff parser), c51 (render). The
-// route prefix is uppercase /GIT/ to make it visually distinct from
-// the markdown content sections (/sama, /blog, /guides). Visitors who
-// land on git.tdd.md are bounced here by the deploy-time tunnel rule
-// (out of scope for this handler — handler just owns the rendering).
-
-import { renderNotFound, htmlResponse } from "./c51_render_layout.ts";
-import { getCommit, getCommitDiff } from "./c14_git.ts";
-import { LIVE_REPO_OWNER, LIVE_REPO_NAME } from "./c31_site_config.ts";
-import { parseUnifiedDiff } from "./c31_diff_parse.ts";
-import { renderCommitView } from "./c51_render_commit.ts";
-
-// Owner/repo + sha shape — paranoid because these go straight into a
-// Forgejo URL. Owner/repo allow letters/digits/hyphens/underscores/dots;
-// sha is hex 7-64 (Forgejo accepts shortened SHAs but our render assumes
-// full ones because we use them in URLs).
-const SAFE_OWNER_REPO = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
-const SAFE_SHA = /^[a-f0-9]{7,64}$/;
-
-const isValid = (owner: string, repo: string, sha: string): boolean =>
- SAFE_OWNER_REPO.test(owner) && SAFE_OWNER_REPO.test(repo) && SAFE_SHA.test(sha);
-
-export const commitViewHandler = async (
- req: Request & { params: { owner: string; repo: string; sha: string } },
-): Promise => {
- const { owner, repo } = req.params;
- // The :sha param may carry a trailing ".diff" because the route
- // pattern doesn't have a separate one. Normalise + branch.
- const rawSha = req.params.sha;
- const wantsDiff = rawSha.endsWith(".diff");
- const sha = wantsDiff ? rawSha.slice(0, -5) : rawSha;
- const fullPath = `/GIT/${owner}/${repo}/commit/${rawSha}`;
-
- if (!isValid(owner, repo, sha)) {
- const html = await renderNotFound(fullPath);
- return htmlResponse(html, 404);
- }
-
- // /GIT/ now serves only syntaxai/tdd.md (our local bare repo via
- // c14_git). Other (owner, repo) pairs would historically have been
- // proxied to Forgejo for agent katas — that's a separate concern
- // and currently 404s. If we want it back, add a Forgejo fallback
- // branch here keyed on the owner/repo pair.
- if (owner !== LIVE_REPO_OWNER || repo !== LIVE_REPO_NAME) {
- const html = await renderNotFound(fullPath);
- return htmlResponse(html, 404);
- }
-
- if (wantsDiff) {
- const diffText = await getCommitDiff(sha);
- if (diffText === null) {
- const html = await renderNotFound(fullPath);
- return htmlResponse(html, 404);
- }
- return new Response(diffText, {
- headers: {
- "Content-Type": "text/plain; charset=utf-8",
- "Cache-Control": "public, max-age=300",
- },
- });
- }
-
- const commit = await getCommit(sha);
- if (commit === null) {
- const html = await renderNotFound(fullPath);
- return htmlResponse(html, 404);
- }
- const diffText = (await getCommitDiff(sha)) ?? "";
- const diff = parseUnifiedDiff(diffText);
- // c14_git's GitCommit shape matches what c51_render_commit needs
- // (it used to take ForgejoCommitDetail; same field names + types).
- const detail = {
- sha: commit.sha,
- parents: commit.parents,
- authorName: commit.authorName,
- authorEmail: commit.authorEmail,
- authorDate: commit.authorDate,
- committerName: commit.committerName,
- committerEmail: commit.committerEmail,
- committerDate: commit.committerDate,
- message: commit.message,
- };
- const html = await renderCommitView({ owner, repo, detail, diff });
- return htmlResponse(html);
-};
diff --git a/src/c21_handlers_content.ts b/src/c21_handlers_content.ts
deleted file mode 100644
index 4532b75a7d58e2953bd8fd1edad0715bf26a620d..0000000000000000000000000000000000000000
--- a/src/c21_handlers_content.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-// c21 — public read-only render for sxdoc-backed pages.
-//
-// Routes (mounted in c21_app.ts):
-// GET /p/:slug — single-segment fast path via routes table
-// GET /p/ — multi-segment via appFetch regex fallback
-//
-// Composes c13_database (loadDocument), c51_render_sxdoc (sxToHtml),
-// and c51_render_layout (renderPage chrome). Drafts (status=draft) 404
-// publicly — only published pages are reachable.
-//
-// Scope note: posts get their own Ghost-style permalink in Fase 4
-// (/blog/{primary_tag}/{slug}). For now only pages are public. Hitting
-// /p/ when a row exists with type=post still 404's so we can't
-// accidentally leak a draft post-shape via the page route.
-
-import { loadDocument } from "./c13_database.ts";
-import { sxToHtml } from "./c51_render_sxdoc.ts";
-import { htmlResponse, renderPage, renderNotFound } from "./c51_render_layout.ts";
-
-export const publicPageHandler = async (
- req: Request & { params: { slug: string } },
-): Promise => renderPublicPage(req.params.slug);
-
-export const renderPublicPage = async (slug: string): Promise => {
- const row = loadDocument(slug, "page");
- if (!row || row.status !== "published") {
- const html = await renderNotFound(`/p/${slug}`);
- return htmlResponse(html, 404);
- }
- const html = await renderPage({
- title: `${row.title} — tdd.md`,
- bodyHtml: sxToHtml(row.doc),
- ogPath: `https://tdd.md/p/${slug}`,
- });
- return htmlResponse(html);
-};
diff --git a/src/c21_handlers_edit.ts b/src/c21_handlers_edit.ts
deleted file mode 100644
index b5adbc0b511cd5ec42f444b87704592ebcc3c3d5..0000000000000000000000000000000000000000
--- a/src/c21_handlers_edit.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-// c21 — handlers: the self-hosted editor. Admin-only flow:
-// GET → form (login wall + non-admin wall as gates), POST → write
-// commit straight to the local bare git repo via c14_git, then mirror
-// to the container's content/ filesystem so the live page reflects it.
-// Forgejo no longer participates in tdd.md's own repo lifecycle.
-
-import { renderNotFound, htmlResponse } from "./c51_render_layout.ts";
-import { getViewer } from "./c32_session.ts";
-import { resolveEdit, type ResolvedEdit } from "./c32_edit_resolve.ts";
-import {
- validateEditBody,
- isNoOpEdit,
- EditValidationError,
-} from "./c31_edit_validation.ts";
-import { ADMIN_USERNAME } from "./c31_site_config.ts";
-import {
- commitFile,
- getFileBlobSha,
- type GitCommitOutcome,
-} from "./c14_git.ts";
-import { buildCommitMessage, noreplyEmail } from "./c31_commit_meta.ts";
-import {
- renderEditFormPage,
- renderEditLoginWall,
- renderEditNonAdminWall,
- renderEditAppliedLive,
- renderEditCommitFailed,
-} from "./c51_render_edit.ts";
-
-const readCurrentBody = async (filePath: string): Promise => {
- const file = Bun.file(`./${filePath}`);
- if (!(await file.exists())) return null;
- return await file.text();
-};
-
-// Mirror the Forgejo write to the container's local filesystem so the
-// next page render reflects the change without waiting for the next
-// deploy. The deploy script's git-pull-from-Forgejo restores the same
-// bytes on container restart.
-const applyLiveEdit = async (resolved: ResolvedEdit, body: string): Promise => {
- await Bun.write(`./${resolved.filePath}`, body);
-};
-
-// GET + POST /edit/:section/:slug — single handler, branches on method.
-export const editPageHandler = async (req: Request & { params: { section: string; slug: string } }): Promise => {
- const resolved = resolveEdit(req.params.section, req.params.slug);
- if (!resolved) {
- const html = await renderNotFound(`/edit/${req.params.section}/${req.params.slug}`);
- return htmlResponse(html, 404);
- }
-
- const viewer = await getViewer(req);
- if (!viewer) {
- const html = await renderEditLoginWall(resolved);
- return htmlResponse(html, 401);
- }
-
- if (viewer !== ADMIN_USERNAME) {
- const html = await renderEditNonAdminWall(resolved, viewer);
- return htmlResponse(html, 403);
- }
-
- if (req.method === "POST") {
- const form = await req.formData();
- let body: string;
- try {
- body = validateEditBody(form.get("body"));
- } catch (e) {
- if (e instanceof EditValidationError) {
- return new Response(`edit rejected: ${e.message}`, { status: 400 });
- }
- throw e;
- }
- const current = (await readCurrentBody(resolved.filePath)) ?? "";
- if (isNoOpEdit(current, body)) {
- // No diff — skip the Forgejo round-trip and bounce back to the
- // form so the user can either change something or cancel.
- return new Response(null, {
- status: 303,
- headers: { Location: `/edit/${resolved.section}/${resolved.slug}` },
- });
- }
-
- // Git commit FIRST against the local bare repo, then live filesystem
- // write. Git's update-ref gives us free optimistic concurrency
- // (we pass the parent SHA as the expected oldvalue — a concurrent
- // commit fails with kind:"conflict"). Writing FS only after a
- // successful commit avoids the "live but uncommitted" state that
- // would vanish at the next deploy.
- const priorBlobSha = await getFileBlobSha("main", resolved.filePath);
- const outcome: GitCommitOutcome = await commitFile({
- branch: "main",
- path: resolved.filePath,
- content: body,
- priorBlobSha,
- message: buildCommitMessage({
- title: resolved.title,
- author: viewer,
- filePath: resolved.filePath,
- }),
- authorName: viewer,
- authorEmail: noreplyEmail(viewer),
- });
- if (!outcome.ok) {
- // Status 200 (not 5xx): Cloudflare replaces 5xx responses with
- // its own error page, hiding our diagnostic. The HTML body
- // carries the failure semantics; status only affects routing
- // and caching.
- const html = await renderEditCommitFailed(resolved, outcome);
- return htmlResponse(html, outcome.kind === "conflict" ? 409 : 200);
- }
- await applyLiveEdit(resolved, body);
- const html = await renderEditAppliedLive(resolved, outcome);
- return htmlResponse(html);
- }
-
- const current = (await readCurrentBody(resolved.filePath)) ?? "";
- const html = await renderEditFormPage(resolved, current, viewer);
- return htmlResponse(html);
-};
diff --git a/src/c21_handlers_fallback.ts b/src/c21_handlers_fallback.ts
deleted file mode 100644
index afcdc163f03688148966a030221161f5b4f82f7d..0000000000000000000000000000000000000000
--- a/src/c21_handlers_fallback.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-// c21 — handlers: the Bun.serve `fetch` fallback. Catches every request
-// the routes table can't express directly: regex-matched multi-segment
-// slugs (admin edit/delete, /p/), the /GIT browse tree, the
-// bare //.git redirect, the git smart/dumb-HTTP proxy, and
-// the bare // repo view. Extracted from c21_app.ts per the
-// SAMA Atomic rule.
-
-import {
- renderNotFound,
- htmlResponse,
-} from "./c51_render_layout.ts";
-import { proxyToForgejo } from "./c14_forgejo.ts";
-import { parseUrl } from "./c14_request_parse.ts";
-import { getViewer } from "./c32_session.ts";
-import { renderRepoView } from "./c21_handlers_repo_view.ts";
-import {
- adminEditHandler,
- adminDeleteHandler,
-} from "./c21_handlers_admin.ts";
-import { renderPublicPage } from "./c21_handlers_content.ts";
-import {
- parseRepoBrowsePath,
- repoBrowseHandler,
-} from "./c21_handlers_repo_browse.ts";
-
-const isGitProtocol = (pathname: string, search: URLSearchParams): boolean => {
- if (pathname.includes(".git/") || pathname.endsWith(".git")) return true;
- if (
- pathname.endsWith("/info/refs") &&
- (search.get("service") === "git-upload-pack" || search.get("service") === "git-receive-pack")
- ) {
- return true;
- }
- if (pathname.endsWith("/git-upload-pack") || pathname.endsWith("/git-receive-pack")) {
- return true;
- }
- return false;
-};
-
-export const appFetch = async (req: Request): Promise => {
- const urlR = parseUrl(req.url);
- // Bun.serve guarantees req.url is well-formed for routed requests;
- // if parseUrl somehow fails, fall through to a 404 via the default
- // notFound branch at the end of this function.
- if (!urlR.ok) {
- const html = await renderNotFound("/");
- return htmlResponse(html, 404);
- }
- const url = urlR.value;
-
- // Admin edit/delete on multi-segment slugs (company/about, docs/spec/grammar
- // etc.). Bun's `:slug` param can't span "/" so anything with two-or-more
- // segments after the type slot ends up here. Single-segment is handled
- // by the routes table and never reaches this branch.
- const adminEditMulti = url.pathname.match(
- /^\/admin\/edit\/(page|post)\/([a-z0-9_\-/]+?)\/?$/,
- );
- if (adminEditMulti) {
- const reqP = Object.assign(req, {
- params: { type: adminEditMulti[1]!, slug: adminEditMulti[2]! },
- });
- return adminEditHandler(reqP);
- }
- const adminDeleteMulti = url.pathname.match(
- /^\/admin\/delete\/(page|post)\/([a-z0-9_\-/]+?)\/?$/,
- );
- if (adminDeleteMulti) {
- const reqP = Object.assign(req, {
- params: { type: adminDeleteMulti[1]!, slug: adminDeleteMulti[2]! },
- });
- return adminDeleteHandler(reqP);
- }
-
- // Public sxdoc-backed pages on multi-segment slugs (e.g.
- // /p/company/about, /p/docs/spec/grammar). Single-segment goes through
- // the explicit `/p/:slug` route on Bun.serve.
- const publicPageMulti = url.pathname.match(/^\/p\/([a-z0-9_\-/]+?)\/?$/);
- if (publicPageMulti) {
- return renderPublicPage(publicPageMulti[1]!);
- }
-
- // Bare //.git (no sub-path) is what someone gets when
- // they paste the clone URL into a browser. Without intervention our
- // proxy hands it to Forgejo, whose chrome then leaks onto tdd.md.
- // Redirect to the clean URL so the visitor lands on the Bun-native
- // scoreboard. Real git operations always have sub-paths
- // (/info/refs, /git-upload-pack, /objects/...) and continue to be
- // proxied below.
- const bareGitUrl = url.pathname.match(
- /^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\.git\/?$/,
- );
- if (bareGitUrl) {
- return new Response(null, {
- status: 302,
- headers: { Location: `/${bareGitUrl[1]}/${bareGitUrl[2]}` },
- });
- }
-
- // SAMA-native repo browse at /GIT/:owner/:repo/{tree,blob,raw}/:ref/.
- // The wildcard path needs more flexibility than Bun's :param routes
- // give us (no slashes), so we match in the fallback fetch instead.
- const gitBrowseMatch = url.pathname.match(
- /^\/GIT\/([A-Za-z0-9][A-Za-z0-9._-]+)\/([A-Za-z0-9][A-Za-z0-9._-]+)\/(.+)$/,
- );
- if (gitBrowseMatch) {
- const owner = gitBrowseMatch[1]!;
- const repo = gitBrowseMatch[2]!;
- const suffix = gitBrowseMatch[3]!;
- // Skip the commit/ shape — that's c21_handlers_commit_view's
- // turf and lives as an explicit Bun.serve route in c21_app.
- if (!suffix.startsWith("commit/")) {
- const target = parseRepoBrowsePath(suffix);
- if (target !== null) {
- return repoBrowseHandler(req, owner, repo, target);
- }
- }
- }
-
- // Git smart-HTTP and dumb-HTTP — proxy raw to Forgejo.
- if (isGitProtocol(url.pathname, url.searchParams)) {
- return proxyToForgejo(req, url.pathname + url.search);
- }
-
- // Bare repo URL: // — render Bun-native view via Forgejo API.
- // Two segments only, no trailing path. Reserved top-level paths are
- // already matched by explicit routes in c21_app and never reach here.
- const repoMatch = url.pathname.match(/^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/?$/);
- if (repoMatch) {
- const viewer = await getViewer(req);
- return renderRepoView(repoMatch[1]!, repoMatch[2]!, viewer);
- }
-
- const html = await renderNotFound(url.pathname);
- return htmlResponse(html, 404);
-};
-
-export const appError = (err: Error): Response => {
- console.error(err);
- return new Response("internal error", { status: 500 });
-};
diff --git a/src/c21_handlers_leaderboard.ts b/src/c21_handlers_leaderboard.ts
deleted file mode 100644
index 3619449414ab1afdbbc3d44cebdf2c5dc3f61007..0000000000000000000000000000000000000000
--- a/src/c21_handlers_leaderboard.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-// c21 (leaderboard) — handler that ranks tracked agents by their kata
-// verdict totals. Forgejo admin lookup gives us the public/limited
-// filter; c13 supplies the per-repo verdicts.
-
-import {
- FORGEJO_URL,
- adminApiHeaders,
- type ForgejoUserSummary,
-} from "./c14_forgejo.ts";
-import { allLatestRuns } from "./c13_database.ts";
-import {
- renderPage,
- htmlResponse,
-} from "./c51_render_layout.ts";
-
-export const renderLeaderboard = async (): Promise => {
- // Only show runs whose owner is public. Fetch the user list once
- // and build a Set so we can filter without N+1 lookups.
- const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
- const publicOwners = new Set();
- if (adminToken) {
- const r = await fetch(`${FORGEJO_URL}/api/v1/admin/users?limit=200`, {
- headers: adminApiHeaders(),
- });
- if (r.ok) {
- const users = (await r.json()) as ForgejoUserSummary[];
- for (const u of users) {
- if ((u.visibility ?? "public") === "public") publicOwners.add(u.login);
- }
- }
- }
- const runs = allLatestRuns()
- .filter((r) => publicOwners.size === 0 || publicOwners.has(r.owner))
- .sort((a, b) => b.verdict.totalScore - a.verdict.totalScore);
- let body: string;
- if (runs.length === 0) {
- body = `# leaderboard
-
-> No verdicts yet. The first agent to push a red→green pair lands here.
-
-[ Register your agent → ](/agents/register)
-`;
- } else {
- const rows = runs
- .map((r, i) => {
- const sign = r.verdict.totalScore >= 0 ? "+" : "";
- const verified = r.verdict.steps.filter((s) => s.status === "verified").length;
- return `| ${i + 1} | [${r.owner}](/agents/${r.owner}) | [${r.repo}](/${r.owner}/${r.repo}) | ${sign}${r.verdict.totalScore} | ${verified} |`;
- })
- .join("\n");
- body = `# leaderboard
-
-| rank | agent | kata | score | verified steps |
-|---|---|---|---|---|
-${rows}
-`;
- }
- const description =
- runs.length === 0
- ? "TDD leaderboard for AI agents on tdd.md — be the first verdict."
- : `Top AI agents by TDD score on tdd.md — ${runs.length} ranked ${runs.length === 1 ? "submission" : "submissions"} graded on red→green discipline and hidden test pass rate.`;
-
- const html = await renderPage({
- title: "TDD leaderboard — tdd.md",
- description,
- bodyMarkdown: body,
- ogPath: "https://tdd.md/leaderboard",
- active: "leaderboard",
- });
- return htmlResponse(html);
-};
diff --git a/src/c21_handlers_projects.ts b/src/c21_handlers_projects.ts
deleted file mode 100644
index 717d676c875f1b1c073a9aad0dc8447b63ec3344..0000000000000000000000000000000000000000
--- a/src/c21_handlers_projects.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-// c21 — handlers: /projects cluster. Landing page lists every active
-// project from the SQLite store, /projects/new accepts a `owner/repo`
-// form (GitHub source-of-truth check + upsert), /projects/:owner/:name
-// renders the per-project detail page. Extracted from c21_app.ts per
-// the SAMA Atomic rule.
-
-import { parseUrl } from "./c14_request_parse.ts";
-import {
- renderPage,
- renderNotFound,
- htmlResponse,
-} from "./c51_render_layout.ts";
-import {
- projectsLandingMd,
- projectRegisterMd,
- projectDetailMd,
-} from "./c51_render_projects.ts";
-import { parseRepoIdentifier } from "./c31_project_config.ts";
-import { fetchProjectConfig } from "./c14_github.ts";
-import {
- listActiveProjects,
- getProject,
- upsertProject,
-} from "./c13_database.ts";
-import { getViewer } from "./c32_session.ts";
-
-export const projectsLandingHandler = async (): Promise => {
- const projects = listActiveProjects();
- const html = await renderPage({
- title: "Projects — tdd.md",
- description:
- "Real repos opted in to tdd.md scoring. Each project drops .tdd-md.json at its root and gets its commits judged structurally for TDD discipline.",
- bodyMarkdown: projectsLandingMd(projects),
- ogPath: "https://tdd.md/projects",
- });
- return htmlResponse(html);
-};
-
-export const projectsNewHandler = async (req: Request): Promise => {
- const viewer = await getViewer(req);
- if (req.method === "GET") {
- const urlR = parseUrl(req.url);
- const prefilled = urlR.ok ? (urlR.value.searchParams.get("repo") ?? undefined) : undefined;
- const html = await renderPage({
- title: "Register a project — tdd.md",
- description:
- "Onboard a real repo for TDD-discipline scoring. Drops .tdd-md.json at the repo root, register here, and the reports begin tracking commits on its tracked branches.",
- bodyMarkdown: projectRegisterMd(viewer, prefilled),
- ogPath: "https://tdd.md/projects/new",
- noindex: true,
- });
- return htmlResponse(html);
- }
- if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
- if (!viewer) return new Response("unauthorized — sign in first", { status: 401 });
-
- let raw = "";
- try {
- const form = await req.formData();
- raw = String(form.get("repo") ?? "").trim();
- } catch {
- return new Response("invalid form body", { status: 400 });
- }
-
- const renderError = async (message: string, status = 400): Promise => {
- const html = await renderPage({
- title: "Register a project — tdd.md",
- bodyMarkdown: projectRegisterMd(viewer, raw, message),
- ogPath: "https://tdd.md/projects/new",
- noindex: true,
- });
- return htmlResponse(html, status);
- };
-
- let owner: string;
- let repo: string;
- try {
- ({ owner, repo } = parseRepoIdentifier(raw));
- } catch (err) {
- return renderError((err as Error).message);
- }
-
- let config;
- try {
- config = await fetchProjectConfig(owner, repo);
- } catch (err) {
- return renderError((err as Error).message);
- }
-
- upsertProject(viewer, owner, repo, config);
- return new Response(null, {
- status: 303,
- headers: { Location: `/projects/${owner}/${repo}` },
- });
-};
-
-export const projectDetailHandler = async (
- req: Request & { params: { repoOwner: string; repoName: string } },
-): Promise => {
- const { repoOwner, repoName } = req.params;
- const project = getProject(repoOwner, repoName);
- if (!project) {
- const html = await renderNotFound(`/projects/${repoOwner}/${repoName}`);
- return htmlResponse(html, 404);
- }
- const html = await renderPage({
- title: `${project.displayName ?? `${project.repoOwner}/${project.repoName}`} — tdd.md`,
- description: `${project.repoOwner}/${project.repoName} on tdd.md — ${
- project.testRunner === "none" ? "trace-mode" : project.testRunner
- } judging across ${project.trackedBranches.join(", ")}.`,
- bodyMarkdown: projectDetailMd(project),
- ogPath: `https://tdd.md/projects/${project.repoOwner}/${project.repoName}`,
- });
- return htmlResponse(html);
-};
diff --git a/src/c21_handlers_repo_browse.ts b/src/c21_handlers_repo_browse.ts
deleted file mode 100644
index 5b93d403bd896ac2dba3d02df377d63d8bf12377..0000000000000000000000000000000000000000
--- a/src/c21_handlers_repo_browse.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-// c21 — handler: SAMA-native browsable repo at /GIT/.
-// GET /GIT/:owner/:repo/tree/:ref/ → directory listing
-// GET /GIT/:owner/:repo/blob/:ref/ → file viewer (md rendered)
-// GET /GIT/:owner/:repo/raw/:ref/ → raw file content
-//
-// Sits next to c21_handlers_commit_view (commit detail) — the two
-// together replace what visitors used to need git.tdd.md for. Reads
-// from the local bare repo via c14_git.lsTree / c14_git.readBlobAtRef.
-//
-// The owner/repo pair must match the locally-served bare repo
-// (syntaxai/tdd.md). Other pairs 404 — agent kata browse is not in
-// scope here. Path traversal is blocked by validating against
-// patterns that disallow ".." and absolute leading-slash inputs.
-
-import { renderNotFound, htmlResponse } from "./c51_render_layout.ts";
-import { lsTree, readBlobAtRef } from "./c14_git.ts";
-import { LIVE_REPO_OWNER, LIVE_REPO_NAME } from "./c31_site_config.ts";
-import { renderRepoTree, renderRepoBlob } from "./c51_render_repo.ts";
-
-const SAFE_OWNER_REPO = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
-// Refs we accept as :ref. Branch names + full SHAs are common —
-// kept narrow on purpose (no slashes — branches like "feat/foo"
-// would clash with the wildcard path matching).
-const SAFE_REF = /^[A-Za-z0-9][A-Za-z0-9._-]{0,49}$/;
-
-const isAllowedRepo = (owner: string, repo: string): boolean =>
- owner === LIVE_REPO_OWNER &&
- repo === LIVE_REPO_NAME &&
- SAFE_OWNER_REPO.test(owner) &&
- SAFE_OWNER_REPO.test(repo);
-
-// Only allow paths that look like ordinary repo entries — letters,
-// digits, hyphens, underscores, dots, slashes. Reject anything with
-// a ".." segment, leading or trailing slashes, or empty segments.
-const isSafePath = (p: string): boolean => {
- if (p === "") return true; // root
- if (p.startsWith("/") || p.endsWith("/")) return false;
- if (p.includes("//")) return false;
- if (!/^[A-Za-z0-9._\/-]+$/.test(p)) return false;
- for (const seg of p.split("/")) {
- if (seg === "" || seg === "." || seg === "..") return false;
- }
- return true;
-};
-
-// Strip a leading "tree//" or "blob//" or "raw//" off
-// a captured pathname suffix, returning { kind, ref, path } or null.
-// Called from the fallback fetch in c21_app where the URL has been
-// matched only loosely.
-export interface RepoBrowseTarget {
- kind: "tree" | "blob" | "raw";
- ref: string;
- path: string;
-}
-
-export const parseRepoBrowsePath = (suffix: string): RepoBrowseTarget | null => {
- // suffix is what comes after /GIT///
- // e.g. "tree/main", "tree/main/content/blog", "blob/main/content/blog/foo.md"
- const m = /^(tree|blob|raw)\/([^/]+)(?:\/(.*))?$/.exec(suffix);
- if (!m) return null;
- const kind = m[1] as "tree" | "blob" | "raw";
- const ref = m[2]!;
- const path = m[3] ?? "";
- if (!SAFE_REF.test(ref)) return null;
- if (!isSafePath(path)) return null;
- return { kind, ref, path };
-};
-
-export const repoBrowseHandler = async (
- req: Request,
- owner: string,
- repo: string,
- target: RepoBrowseTarget,
-): Promise => {
- const fullPath = `/GIT/${owner}/${repo}/${target.kind}/${target.ref}${target.path ? "/" + target.path : ""}`;
-
- if (!isAllowedRepo(owner, repo)) {
- const html = await renderNotFound(fullPath);
- return htmlResponse(html, 404);
- }
-
- if (target.kind === "tree") {
- const entries = await lsTree(target.ref, target.path);
- if (entries === null) {
- const html = await renderNotFound(fullPath);
- return htmlResponse(html, 404);
- }
- const html = await renderRepoTree({
- owner,
- repo,
- ref: target.ref,
- path: target.path,
- entries,
- });
- return htmlResponse(html);
- }
-
- if (target.kind === "blob") {
- const content = await readBlobAtRef(target.ref, target.path);
- if (content === null) {
- const html = await renderNotFound(fullPath);
- return htmlResponse(html, 404);
- }
- const html = await renderRepoBlob({
- owner,
- repo,
- ref: target.ref,
- path: target.path,
- content,
- });
- return htmlResponse(html);
- }
-
- // raw
- const content = await readBlobAtRef(target.ref, target.path);
- if (content === null) {
- const html = await renderNotFound(fullPath);
- return htmlResponse(html, 404);
- }
- // Markdown files served as text/plain so browsers render them
- // inline; everything else also text/plain (we don't try to detect
- // language types — c14_git already restricts to UTF-8).
- return new Response(content, {
- headers: {
- "Content-Type": "text/plain; charset=utf-8",
- "Cache-Control": "public, max-age=60",
- },
- });
-};
diff --git a/src/c21_handlers_repo_view.ts b/src/c21_handlers_repo_view.ts
deleted file mode 100644
index 63dc7009a9e5b038bb9c2a500ff8e6ceb8b4c0e2..0000000000000000000000000000000000000000
--- a/src/c21_handlers_repo_view.ts
+++ /dev/null
@@ -1,207 +0,0 @@
-// c21 (repo-view) — handler that renders the bare /:owner/:repo page.
-// Composes c14_forgejo (repo + commits via admin API), c31 commits +
-// games (parsing, kata lookup), c13 verdict store, c51 layout helpers.
-// Exposed via the c21_app.ts fallback fetch — reserved top-level routes
-// are matched first, this is the catch-all for //.
-
-import {
- FORGEJO_URL,
- adminApiHeaders,
- getUserVisibility,
-} from "./c14_forgejo.ts";
-import { parseCommit, computeProgress } from "./c31_commits.ts";
-import { loadGame } from "./c31_games.ts";
-import { latestRun } from "./c13_database.ts";
-import {
- renderPage,
- renderNotFound,
- htmlResponse,
- phaseSpan,
- relativeTime,
-} from "./c51_render_layout.ts";
-
-interface ForgejoRepoSummary {
- description: string;
- clone_url: string;
- empty: boolean;
- private: boolean;
-}
-
-interface ForgejoCommit {
- sha: string;
- commit: { message: string; author: { name: string; date: string } };
-}
-
-export const renderRepoView = async (
- owner: string,
- repo: string,
- viewer: string | null,
-): Promise => {
- // Private/limited owners get a 404 to anonymous visitors — but the
- // owner themselves (verified via session cookie) can always see
- // their own pages.
- const ownerVisibility = await getUserVisibility(owner);
- if (ownerVisibility !== null && ownerVisibility !== "public" && viewer !== owner) {
- const html = await renderNotFound(`/${owner}/${repo}`);
- return htmlResponse(html, 404);
- }
-
- const repoApi = `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
- const repoRes = await fetch(repoApi, { headers: adminApiHeaders() });
- if (repoRes.status === 404) {
- const html = await renderNotFound(`/${owner}/${repo}`);
- return htmlResponse(html, 404);
- }
- if (!repoRes.ok) {
- const html = await renderPage({
- title: `${owner}/${repo} — tdd.md`,
- bodyMarkdown: `# ${owner}/${repo}\n\n> repository unavailable`,
- });
- return htmlResponse(html, 502);
- }
- const info = (await repoRes.json()) as ForgejoRepoSummary;
- const cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`;
- const isPrivate = info.private === true;
-
- // The repo name is by convention the kata id. If the kata exists, the
- // header link is meaningful and we know the total step count.
- let totalSteps: number | null = null;
- let kataExists = false;
- try {
- const game = await loadGame(repo);
- totalSteps = game.steps.length;
- kataExists = true;
- } catch {
- // Repo isn't a known kata — still render, just without step totals.
- }
-
- let commits: ForgejoCommit[] = [];
- if (!info.empty) {
- const commitsRes = await fetch(`${repoApi}/commits?limit=50&stat=false`, {
- headers: adminApiHeaders(),
- });
- if (commitsRes.ok) commits = (await commitsRes.json()) as ForgejoCommit[];
- }
- const progress = computeProgress(commits);
- const verified = progress.verifiedSteps.size;
-
- let status: string;
- if (commits.length === 0) {
- status = "awaiting first push";
- } else if (totalSteps !== null && verified >= totalSteps) {
- status = "kata complete";
- } else if (verified > 0) {
- status = "in progress";
- } else {
- status = "no verified steps yet";
- }
- const stepCounter = totalSteps !== null ? `${verified} / ${totalSteps}` : `${verified} / ?`;
-
- let phaseLog: string;
- if (commits.length === 0) {
- phaseLog = "_No commits yet — push your first `red:` commit to start the cycle._";
- } else {
- const rows = commits.map((c) => {
- const sha = c.sha.slice(0, 7);
- const p = parseCommit(c.commit.message);
- const subject = (p.subject || c.commit.message.split("\n")[0] || "").replace(/\|/g, "\\|");
- const stepCell = p.step ? `\`${p.step}\`` : "—";
- return `| \`${sha}\` | ${phaseSpan(p.phase)} | ${stepCell} | ${subject} | ${relativeTime(c.commit.author.date)} |`;
- });
- phaseLog = `| sha | phase | step | message | when |\n|---|---|---|---|---|\n${rows.join("\n")}`;
- }
-
- const kataLink = kataExists
- ? `[\`${repo}\` →](/games/${repo})`
- : `\`${repo}\``;
- const privateBadge = isPrivate ? ` [private]` : "";
-
- const verdict = latestRun(owner, repo);
- const headSha = commits[0]?.sha ?? null;
- const verdictStale = verdict !== null && headSha !== null && verdict.headSha !== headSha;
-
- let scoreSection: string;
- if (verdict === null) {
- scoreSection = `> Not yet judged. The next push triggers a judge run, or [run the judge now](/api/judge/${owner}/${repo}) (POST).\n\nPhase tally: red ${progress.redCount} · green ${progress.greenCount} · refactor ${progress.refactorCount}${progress.untaggedCount > 0 ? ` · untagged ${progress.untaggedCount}` : ""}.`;
- } else {
- const stale = verdictStale ? ` · stale — newer commits not yet judged` : "";
- const sign = verdict.totalScore >= 0 ? "+" : "";
- const statusClass = (status: string): string => {
- if (status === "verified") return "green";
- if (status === "discipline-only") return "blue";
- if (status === "no-green") return "muted";
- return "red";
- };
- const modeLabel = (m: string): string => {
- const cls = m === "strict" ? "red" : m === "pragmatic" ? "blue" : "green";
- return `${m}`;
- };
- const rows = verdict.steps.length === 0
- ? "_No red→green pairs found yet._"
- : `| step | red | green | hidden | status | points | explanation |\n|---|---|---|---|---|---|---|\n` +
- verdict.steps.map((s) => {
- const cls = statusClass(s.status);
- const sign = s.scoreDelta >= 0 ? "+" : "";
- const hiddenCell =
- s.hiddenPassed === true ? `pass` :
- s.hiddenPassed === false ? `fail` :
- `—`;
- const explanation = (s.explanation ?? "").replace(/\|/g, "\\|");
- return `| \`${s.stepId}\` | \`${s.redSha?.slice(0, 7) ?? "—"}\` | \`${s.greenSha?.slice(0, 7) ?? "—"}\` | ${hiddenCell} | ${s.status} | ${sign}${s.scoreDelta} | ${explanation} |`;
- }).join("\n");
- const refactorRows = (verdict.refactors ?? []).length === 0
- ? ""
- : `\n\n### refactors\n\n| sha | step | tests | points | explanation |\n|---|---|---|---|---|\n` +
- verdict.refactors.map((r) => {
- const sign = r.scoreDelta >= 0 ? "+" : "";
- const cls = r.testsPassed ? "green" : "red";
- const verb = r.testsPassed ? "green" : "broke tests";
- const explanation = (r.explanation ?? "").replace(/\|/g, "\\|");
- return `| \`${r.sha.slice(0, 7)}\` | ${r.stepId ? `\`${r.stepId}\`` : "—"} | ${verb} | ${sign}${r.scoreDelta} | ${explanation} |`;
- }).join("\n");
- const modeLine = verdict.mode ? `**mode: ${modeLabel(verdict.mode)}** · ` : "";
- scoreSection = `${modeLine}**total: ${sign}${verdict.totalScore}** · judged ${relativeTime(new Date(verdict.judgedAt).toISOString())}${stale}\n\n${rows}${refactorRows}`;
- }
-
- const body = `# ${owner} · playing ${kataLink}${privateBadge}
-
-> ${status}
-> **${stepCounter}** steps verified
-
-## phase log
-
-${phaseLog}
-
-## score
-
-${scoreSection}
-
-## clone
-
-\`\`\`
-git clone ${cloneUrl}
-\`\`\`
-
-[← /agents/${owner}](/agents/${owner})${kataExists ? ` · [kata spec →](/games/${repo})` : ""}
-`;
-
- // Dynamic description tailored to this attempt — gives every agent
- // run a unique snippet for search results and social previews instead
- // of falling back to the site default.
- const totalSnippet =
- verdict !== null
- ? `, score ${verdict.totalScore >= 0 ? "+" : ""}${verdict.totalScore}`
- : "";
- const description = kataExists
- ? `${owner}'s ${repo} TDD kata attempt on tdd.md — ${verified}${totalSteps !== null ? `/${totalSteps}` : ""} steps verified${totalSnippet}.`
- : `${owner}/${repo} on tdd.md — ${commits.length} ${commits.length === 1 ? "commit" : "commits"} in the phase log${totalSnippet}.`;
-
- const html = await renderPage({
- title: `${owner} · ${repo}${kataExists ? " TDD kata" : ""} — tdd.md`,
- description,
- bodyMarkdown: body,
- ogPath: `https://tdd.md/${owner}/${repo}`,
- active: "agents",
- });
- return htmlResponse(html);
-};
diff --git a/src/c21_handlers_reports.ts b/src/c21_handlers_reports.ts
deleted file mode 100644
index 1c5635414ebfca3b45953797aae6c58b08492c8c..0000000000000000000000000000000000000000
--- a/src/c21_handlers_reports.ts
+++ /dev/null
@@ -1,190 +0,0 @@
-// c21 — handlers: the /reports cluster. Demo mockup pages plus the
-// live readout assembled from the deploy-time commit + test bundles.
-// Extracted from c21_app.ts per the SAMA Atomic rule.
-
-import {
- renderPage,
- renderNotFound,
- htmlResponse,
-} from "./c51_render_layout.ts";
-import {
- reportsLandingMd,
- execSummaryMd,
- agentDrilldownMd,
- testsOverviewMd,
-} from "./c51_render_reports.ts";
-import {
- DEMO_REPORTS,
- DEMO_PERIOD,
- DEMO_ORG,
- DEMO_REPOS,
- DEMO_SNAPSHOTS,
- DEMO_STABILITY,
-} from "./c31_reports_demo.ts";
-import { buildLiveReports } from "./c14_real_reports.ts";
-import { buildLiveTestData } from "./c14_real_tests.ts";
-import {
- LIVE_REPO_OWNER,
- LIVE_REPO_NAME,
- LIVE_FETCH_COUNT,
-} from "./c31_site_config.ts";
-
-// -------- shared banners + context builders --------
-
-const DEMO_BANNER_HTML = `
demo data — design preview with synthetic numbers. Want the real readout? /reports/live renders the same shape from live tdd.md commits. why tdd.md needs this
`;
-
-const LIVE_BANNER_HTML = `
live data — sourced from ${LIVE_REPO_OWNER}/${LIVE_REPO_NAME} via the public commits API (5-min cache). Agent attribution comes from Co-Authored-By: footers; commits without one are excluded. Phase coverage measures % of commits tagged red:/green:/refactor:.
`;
-
-const demoContext = () => ({
- reports: DEMO_REPORTS,
- period: DEMO_PERIOD,
- scopeLabel: `${DEMO_REPOS} repos · ${DEMO_ORG}`,
- bannerHtml: DEMO_BANNER_HTML,
- narrative: {
- changedHeading: "what changed this quarter",
- changedBody:
- "Cursor's score dropped 15 points after agent-mode became default in March; test-deletion incidents climbed from 2% to 14% of refactor commits, concentrated in the `api-gateway` repo. Claude Code's score rose after a phase-tagged commit prefix was added to CLAUDE.md at the end of January. Aider stays steadily high — auto-commit-per-edit prevents most cross-phase cheating on its own.",
- doingHeading: "what we're doing",
- doingBody:
- "- **Cursor in `api-gateway`**: agent-mode disabled for refactor prompts, CONVENTIONS rule \"never delete a test in a refactor commit\" pinned ([details →](/reports/demo/agents/cursor)).\n- **Roll out Claude Code**: copy the CLAUDE.md template that worked in `billing-service` to the other three repos.\n- **Next reading**: 2026-04-30, mid-Q2, to check whether the Cursor fix holds.",
- },
- footerLinks:
- "[per-agent drill-down: Claude Code](/reports/demo/agents/claude-code) · [Cursor](/reports/demo/agents/cursor) · [Aider](/reports/demo/agents/aider) · [tests overview](/reports/demo/tests) · [back to /reports](/reports)",
-});
-
-const liveContext = async () => {
- const live = await buildLiveReports(LIVE_REPO_OWNER, LIVE_REPO_NAME, LIVE_FETCH_COUNT);
- const period = live.earliest && live.latest
- ? `${live.earliest.slice(0, 10)} → ${live.latest.slice(0, 10)}`
- : "no commits fetched";
- const drillLinks = live.reports
- .map((r) => `[${r.name}](/reports/live/agents/${r.slug})`)
- .join(" · ");
- return {
- reports: live.reports,
- period,
- scopeLabel: `${LIVE_REPO_OWNER}/${LIVE_REPO_NAME} · ${live.totalCommits} commits sampled${live.unknownCount > 0 ? ` (${live.unknownCount} unattributed, excluded)` : ""}`,
- bannerHtml: LIVE_BANNER_HTML,
- footerLinks: `${drillLinks ? drillLinks + " · " : ""}[tests overview](/reports/live/tests) · [demo preview](/reports/demo) · [back to /reports](/reports)`,
- };
-};
-
-// -------- /reports landing --------
-
-export const reportsLandingHandler = async (): Promise => {
- const html = await renderPage({
- title: "Reports — tdd.md",
- description: "Per-agent TDD-discipline reporting over real project repos: trend, failure-mode breakdown, and an exec summary fit for a quarterly readout.",
- bodyMarkdown: reportsLandingMd(),
- ogPath: "https://tdd.md/reports",
- noindex: true,
- });
- return htmlResponse(html);
-};
-
-// -------- /reports/demo --------
-
-export const reportsDemoHandler = async (): Promise => {
- const ctx = demoContext();
- const html = await renderPage({
- title: "TDD-discipline report · Q1 2026 (demo) — tdd.md",
- description: "Mockup of the management-level TDD-discipline report — single page, three agents, with trend and narrative.",
- bodyMarkdown: execSummaryMd(ctx),
- ogPath: "https://tdd.md/reports/demo",
- noindex: true,
- });
- return htmlResponse(html);
-};
-
-export const reportsDemoTestsHandler = async (): Promise => {
- const html = await renderPage({
- title: "Tests overview (demo) — tdd.md",
- description: "Mockup of the per-test overview: current pass/fail snapshot per repo plus test stability over the quarter.",
- bodyMarkdown: testsOverviewMd({
- period: DEMO_PERIOD,
- bannerHtml: DEMO_BANNER_HTML,
- snapshots: DEMO_SNAPSHOTS,
- stability: DEMO_STABILITY,
- }),
- ogPath: "https://tdd.md/reports/demo/tests",
- noindex: true,
- });
- return htmlResponse(html);
-};
-
-export const reportsDemoAgentHandler = async (req: { params: { slug: string } }): Promise => {
- const slug = req.params.slug as (typeof DEMO_REPORTS)[number]["slug"];
- const ctx = demoContext();
- const md = agentDrilldownMd(slug, ctx);
- if (!md) {
- const html = await renderNotFound(`/reports/demo/agents/${slug}`);
- return htmlResponse(html, 404);
- }
- const entry = DEMO_REPORTS.find((r) => r.slug === slug)!;
- const html = await renderPage({
- title: `${entry.name} drill-down (demo) — tdd.md`,
- description: `Per-agent drill-down mockup for ${entry.name}: trend, failure-mode breakdown, recent flagged commits with coaching links.`,
- bodyMarkdown: md,
- ogPath: `https://tdd.md/reports/demo/agents/${slug}`,
- noindex: true,
- });
- return htmlResponse(html);
-};
-
-// -------- /reports/live --------
-
-export const reportsLiveHandler = async (): Promise => {
- const ctx = await liveContext();
- const html = await renderPage({
- title: "TDD-discipline report · live — tdd.md",
- description: `Live discipline report built from the real commit history of syntaxai/tdd.md (last ${LIVE_FETCH_COUNT} commits, 5-min cache).`,
- bodyMarkdown: execSummaryMd(ctx),
- ogPath: "https://tdd.md/reports/live",
- noindex: true,
- });
- return htmlResponse(html);
-};
-
-export const reportsLiveTestsHandler = async (): Promise => {
- const data = await buildLiveTestData(LIVE_REPO_OWNER, LIVE_REPO_NAME);
- const ranOn = data.ranAt ? new Date(data.ranAt).toISOString().slice(0, 10) : null;
- const period = data.runsCount === 0
- ? "no runs in bundle"
- : `last run ${ranOn} · ${data.runsCount} run${data.runsCount === 1 ? "" : "s"} cumulative`;
- const unavailableNote = data.runsCount === 0
- ? "No test runs bundled yet. The next deploy will run `bun test --reporter=junit` on the current HEAD and publish the result here. Stability (flaky %, deletion) builds up as more runs land in the bundle — the demo at [/reports/demo/tests](/reports/demo/tests) shows where this is heading."
- : undefined;
- const html = await renderPage({
- title: "Tests overview · live — tdd.md",
- description: `Live test snapshot of ${LIVE_REPO_OWNER}/${LIVE_REPO_NAME} — ${data.runsCount} run${data.runsCount === 1 ? "" : "s"} bundled.`,
- bodyMarkdown: testsOverviewMd({
- period,
- bannerHtml: LIVE_BANNER_HTML,
- snapshots: data.snapshots,
- stability: data.stability,
- unavailableNote,
- placeholderTests: data.placeholderTests,
- }),
- ogPath: "https://tdd.md/reports/live/tests",
- });
- return htmlResponse(html);
-};
-
-export const reportsLiveAgentHandler = async (req: { params: { slug: string } }): Promise => {
- const ctx = await liveContext();
- const slug = req.params.slug as (typeof DEMO_REPORTS)[number]["slug"];
- const md = agentDrilldownMd(slug, ctx);
- if (!md) {
- const html = await renderNotFound(`/reports/live/agents/${slug}`);
- return htmlResponse(html, 404);
- }
- const entry = ctx.reports.find((r) => r.slug === slug)!;
- const html = await renderPage({
- title: `${entry.name} drill-down · live — tdd.md`,
- description: `Live drill-down for ${entry.name} on syntaxai/tdd.md — trend, failure-mode breakdown, recent commits.`,
- bodyMarkdown: md,
- ogPath: `https://tdd.md/reports/live/agents/${slug}`,
- noindex: true,
- });
- return htmlResponse(html);
-};
diff --git a/src/c21_handlers_sama.ts b/src/c21_handlers_sama.ts
deleted file mode 100644
index 5c5a687f1aa73c0c2ec3b0e9f03ba33016ef31a3..0000000000000000000000000000000000000000
--- a/src/c21_handlers_sama.ts
+++ /dev/null
@@ -1,476 +0,0 @@
-// c21 — handlers: the /sama cluster. All routes that live under
-// /sama/* plus the SKILL raw download and the bundled CLI download.
-// Extracted from c21_app.ts per the SAMA Atomic rule (the dispatcher
-// passed the 700-line split threshold).
-//
-// Each export is a handler function the dispatcher in c21_app.ts
-// references inline so Bun.serve still sees literal route keys for
-// path-parameter type inference.
-
-import {
- renderNotFound,
- htmlResponse,
- escape,
-} from "./c51_render_layout.ts";
-import { renderDocsPage } from "./c51_render_docs_layout.ts";
-import { ALL_SAMA } from "./c31_sama.ts";
-import { parseUrl } from "./c14_request_parse.ts";
-import {
- fetchRepoTree,
- fetchRepoRawFile,
-} from "./c14_github.ts";
-import { verifySama, type SamaReport } from "./c32_sama_verify.ts";
-import { LIVE_REPO_OWNER, LIVE_REPO_NAME } from "./c31_site_config.ts";
-
-// -------- /skills/sama.md (raw download) --------
-
-export const skillsSamaMdHandler = async (): Promise => {
- const md = await Bun.file("./content/sama/skill.md").text();
- return new Response(md, {
- headers: {
- "Content-Type": "text/markdown; charset=utf-8",
- "Cache-Control": "public, max-age=300",
- },
- });
-};
-
-// -------- /sama/skill (HTML viewer of the SKILL.md) --------
-
-export const samaSkillHandler = async (): Promise => {
- const raw = await Bun.file("./content/sama/skill.md").text();
- // Strip the YAML frontmatter for the HTML render — the .md raw
- // download keeps it (that's the agent-installable format).
- const stripped = raw.replace(/^---\n[\s\S]*?\n---\n+/, "");
- const installNote = `> **Drop into your agent.** Save the raw markdown to your skills directory:
->
-> \`\`\`bash
-> mkdir -p ~/.claude/skills
-> curl -fsSL https://tdd.md/skills/sama.md -o ~/.claude/skills/sama.md
-> \`\`\`
->
-> The frontmatter at the top of the file (\`name\`, \`description\`) is what your agent's loader keys off — don't edit it. [View raw markdown →](/skills/sama.md)
-`;
- const body = `${installNote}\n\n${stripped}\n\n---\n\n[← /sama](/sama) · [the four disciplines](/sama) · [back to tdd.md](/)\n`;
- const html = await renderDocsPage({
- title: "SAMA skill — drop into your agent — tdd.md",
- description: "An obra/superpowers-style SKILL.md for the SAMA file-naming convention. Save it to ~/.claude/skills/sama.md and your agent will load the layer-prefix discipline on demand.",
- bodyMarkdown: body,
- ogPath: "https://tdd.md/sama/skill",
- active: "sama",
- pathForDocs: "/sama/skill",
- });
- return htmlResponse(html);
-};
-
-// -------- /sama/v2/verify (the v2 dogfood — runs the v2 verifier
-// against this repo using sama.profile.toml) --------
-
-import { buildSamaV2Input } from "./c14_sama_profile.ts";
-import { verifySamaV2 } from "./c32_sama_v2_verify.ts";
-import type { SamaV2Report } from "./c31_sama_v2.ts";
-
-const renderV2Report = (report: SamaV2Report): string => {
- const summary = report.overallPassed
- ? `✓ conforms · profile \`${report.profile}\` · ${report.examined} files examined · ${report.checks.length}/${report.checks.length} checks pass`
- : `${report.checks.filter((c) => c.passed).length}/${report.checks.length} checks pass · profile \`${report.profile}\` · ${report.examined} files examined`;
- const rows = report.checks
- .map((c) => {
- const mark = c.passed ? "✓ pass" : `✗ ${c.violations.length} violation${c.violations.length === 1 ? "" : "s"}`;
- return `| #${c.id} ${c.name} | ${mark} | ${c.examined} |`;
- })
- .join("\n");
- const details = report.checks
- .filter((c) => !c.passed)
- .map((c) => {
- const head = `### ✗ #${c.id} ${c.name}\n`;
- const noteBlock = c.note ? `\n*${c.note}*\n` : "";
- const list = c.violations
- .map((v) => `- \`${v.file}\` — ${v.detail}`)
- .join("\n");
- return `${head}${noteBlock}\n${list}\n`;
- })
- .join("\n");
- return `# SAMA v2 — \`syntaxai/tdd.md\` dogfood
-
-> ${summary}
-
-The verifier in [\`src/c32_sama_v2_verify.ts\`](/GIT/syntaxai/tdd.md/blob/main/src/c32_sama_v2_verify.ts) ingests [\`sama.profile.toml\`](/GIT/syntaxai/tdd.md/blob/main/sama.profile.toml) and runs the seven §4 conformance checks against the current source tree on this server. No clone, no token; the server reads its own \`src/\` and the committed profile, runs the same logic the sibling unit tests cover, and renders the verdict below.
-
-| check | verdict | examined |
-|---|---|---|
-${rows}
-
-${details ? `## Open violations\n\n${details}` : ""}
-
-[← /sama/v2](/sama/v2) · [← /sama](/sama) · [the v1 dogfood](/sama/verify?repo=syntaxai/tdd.md)
-`;
-};
-
-export const samaV2VerifyHandler = async (): Promise => {
- let body: string;
- try {
- const input = await buildSamaV2Input();
- const report = verifySamaV2(input);
- body = renderV2Report(report);
- } catch (err) {
- body = `# SAMA v2 verify — error\n\nThe verifier failed before producing a verdict:\n\n\`\`\`\n${(err as Error).message}\n\`\`\`\n\n[← /sama/v2](/sama/v2)`;
- }
- const html = await renderDocsPage({
- title: "SAMA v2 verify · syntaxai/tdd.md — tdd.md",
- description:
- "Live dogfood: tdd.md's own source tree run through the SAMA v2 verifier. Reads sama.profile.toml + src/*.ts, applies the seven §4 conformance checks, renders the verdict.",
- bodyMarkdown: body,
- ogPath: "https://tdd.md/sama/v2/verify",
- active: "sama",
- pathForDocs: "/sama/v2/verify",
- });
- return htmlResponse(html);
-};
-
-// -------- /sama/v2 (the SAMA v2 Core Specification — draft) --------
-
-export const samaV2Handler = async (): Promise => {
- const md = await Bun.file("./content/sama/v2.md").text();
- const html = await renderDocsPage({
- title: "SAMA v2 — Core Specification (draft) — tdd.md",
- description:
- "Draft of the SAMA v2 Core Specification: four canonical layers (Pure / Core / Adapter / Entry), one frozen import law, profiles as the only extension mechanism. Defines the binary conformance gate and the SAMA-independent core metrics for cross-repo empirical measurement.",
- bodyMarkdown: md,
- ogPath: "https://tdd.md/sama/v2",
- active: "sama",
- pathForDocs: "/sama/v2",
- });
- return htmlResponse(html);
-};
-
-// -------- /sama/verify (form + report + dogfood short-circuit) --------
-
-const VERIFY_FORM_MD = `# SAMA verify
-
-> Paste a public GitHub repo. tdd.md will run the four [SAMA disciplines](/sama) against the default branch — *Sorted* (lower never imports higher), *Architecture* (known layer prefixes), *Modeled* (sibling tests, types in c31_*), *Atomic* (~700-line split + placeholder-test detection) — and return a report. No clone, no token; just one tree-listing API call plus raw-content reads. Cached for an hour per repo.
-
-
-
-
-
-
-Try it on this site: [\`syntaxai/tdd.md\`](/sama/verify?repo=syntaxai/tdd.md) · or any public repo of your own.
-
-Limits: anonymous GitHub API quota is 60 requests/hour per IP. Each verify uses one tree-listing call; the rest of the work goes through raw.githubusercontent.com (uncapped). If the verifier returns "rate limit", come back later or use a token-authenticated proxy.
-
-[← /sama](/sama)
-`;
-
-const verifyLocalDogfood = async (owner: string, name: string): Promise => {
- const { readdirSync, readFileSync } = await import("node:fs");
- const srcDir = "./src";
- const tsFiles = readdirSync(srcDir, { withFileTypes: true })
- .filter((e) => e.isFile() && e.name.endsWith(".ts"))
- .map((e) => e.name)
- .sort();
- const contents = new Map();
- for (const f of tsFiles) {
- if (/^c\d{2}_/.test(f)) {
- contents.set(f, readFileSync(`${srcDir}/${f}`, "utf8"));
- }
- }
- return verifySama({
- repoOwner: owner,
- repoName: name,
- defaultBranch: "main",
- srcPaths: tsFiles,
- contents,
- });
-};
-
-const verifyRemoteRepo = async (owner: string, name: string): Promise => {
- const tree = await fetchRepoTree(owner, name);
- const srcEntries = tree.entries
- .filter((e) => e.type === "blob" && e.path.startsWith("src/") && e.path.endsWith(".ts"))
- .slice(0, 200);
- const srcPaths = srcEntries.map((e) => e.path.slice("src/".length));
- const samaPaths = srcPaths.filter((p) => /^c\d{2}_/.test(p));
- const contents = new Map();
- const fetches = await Promise.all(
- samaPaths.map(async (p) => [p, await fetchRepoRawFile(owner, name, tree.defaultBranch, `src/${p}`)] as const),
- );
- for (const [p, c] of fetches) {
- if (c !== null) contents.set(p, c);
- }
- return verifySama({
- repoOwner: owner,
- repoName: name,
- defaultBranch: tree.defaultBranch,
- srcPaths,
- contents,
- });
-};
-
-const renderVerifyReport = async (report: SamaReport): Promise => {
- const summary = report.overallPassed
- ? `> ✓ All four checks passed for [\`${report.repoSlug}\`](https://github.com/${report.repoSlug}) on \`${report.defaultBranch}\` (${report.samaFiles} SAMA files / ${report.testFiles} tests / ${report.totalSrcFiles} total in src/).`
- : `> ⚠ ${report.checks.filter((c) => !c.passed).length} of 4 checks failed for [\`${report.repoSlug}\`](https://github.com/${report.repoSlug}) on \`${report.defaultBranch}\`.`;
- const checkBlocks = report.checks
- .map((c) => {
- const status = c.passed ? "✓ pass" : `✗ ${c.violations.length} violation${c.violations.length === 1 ? "" : "s"}`;
- const violationsBlock = c.violations.length === 0
- ? ""
- : `\n\n${c.violations.slice(0, 20).map((v) => `- \`${escape(v.file)}\` — ${escape(v.detail)}`).join("\n")}${c.violations.length > 20 ? `\n- _...and ${c.violations.length - 20} more_` : ""}`;
- const noteBlock = c.note ? `\n\n_${escape(c.note)}_` : "";
- return `### ${c.letter} — ${c.property} · ${status}\n\nExamined ${c.examined} file${c.examined === 1 ? "" : "s"}.${violationsBlock}${noteBlock}`;
- })
- .join("\n\n");
- const reportMd = `# SAMA verify · \`${report.repoSlug}\`
-
-${summary}
-
-${checkBlocks}
-
----
-
-[← verify another repo](/sama/verify) · [the four SAMA disciplines →](/sama) · [SAMA skill for your agent →](/sama/skill)
-`;
- return renderDocsPage({
- title: `SAMA verify · ${report.repoSlug} — tdd.md`,
- description: `SAMA verification for ${report.repoSlug}: ${report.overallPassed ? "all four checks passed" : `${report.checks.filter((c) => !c.passed).length}/4 checks failed`}.`,
- bodyMarkdown: reportMd,
- ogPath: `https://tdd.md/sama/verify?repo=${report.repoSlug}`,
- active: "sama",
- pathForDocs: "/sama/verify",
- editPathOverride: null,
- });
-};
-
-export const samaVerifyHandler = async (req: { url: string }): Promise => {
- const urlR = parseUrl(req.url);
- const repoArg = urlR.ok ? (urlR.value.searchParams.get("repo") ?? "").trim() : "";
-
- if (!repoArg) {
- const html = await renderDocsPage({
- title: "SAMA verify — tdd.md",
- description: "Paste a public GitHub repo, get the four SAMA disciplines verified mechanically: sorted (lower never imports higher), architecture (known layer prefixes), modeled (sibling tests), atomic (700-line + placeholder-test detection).",
- bodyMarkdown: VERIFY_FORM_MD,
- ogPath: "https://tdd.md/sama/verify",
- active: "sama",
- pathForDocs: "/sama/verify",
- });
- return htmlResponse(html);
- }
-
- const m = /^([^\/\s]+)\/([^\/\s]+)$/.exec(repoArg);
- if (!m) {
- const html = await renderDocsPage({
- title: "SAMA verify · bad input — tdd.md",
- description: "SAMA verify expects an owner/name repo identifier.",
- bodyMarkdown: `# SAMA verify\n\n> Couldn't parse \`${repoArg}\`. Use the form: \`owner/name\`.\n\n[← back](/sama/verify)\n`,
- pathForDocs: "/sama/verify",
- editPathOverride: null,
- ogPath: "https://tdd.md/sama/verify",
- active: "sama",
- noindex: true,
- });
- return htmlResponse(html, 400);
- }
-
- const [, owner, name] = m;
- let report: SamaReport;
- try {
- // Dogfood short-circuit: tdd.md is a private repo, so the GitHub
- // API can't see it. When asked to verify ourselves, read the
- // source from the bundled `./src/` directory inside the container.
- const isSelf = owner === LIVE_REPO_OWNER && name === LIVE_REPO_NAME;
- report = isSelf ? await verifyLocalDogfood(owner!, name!) : await verifyRemoteRepo(owner!, name!);
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- const html = await renderDocsPage({
- title: `SAMA verify · ${owner}/${name} · error — tdd.md`,
- description: `SAMA verify could not inspect ${owner}/${name}.`,
- bodyMarkdown: `# SAMA verify · \`${owner}/${name}\`\n\n> Couldn't fetch the repo: ${escape(msg)}\n\nMost common causes: the repo is private, the name is wrong, or you've hit GitHub's anonymous rate limit (60/hour). [← try another repo](/sama/verify)\n`,
- ogPath: `https://tdd.md/sama/verify?repo=${owner}/${name}`,
- active: "sama",
- noindex: true,
- pathForDocs: "/sama/verify",
- editPathOverride: null,
- });
- return htmlResponse(html, 502);
- }
-
- const html = await renderVerifyReport(report);
- return htmlResponse(html);
-};
-
-// -------- /sama (landing) --------
-
-const SAMA_LANDING_MD = `# SAMA
-
-> **Sorted, Architecture, Modeled, Atomic.** Four properties of a codebase that an AI agent can navigate, change, and verify without drift. The acronym is the rule set; each letter has a one-paragraph definition and a verification you can run.
-
-This is the file-naming and module-organisation convention this site is built on, shared across two other projects in my workspace. It exists to give an AI agent **one obvious place** for every change — and one mechanical check for every layer rule.
-
-## the four disciplines
-
-| letter | discipline | one-line rule |
-|---|---|---|
-%ROWS%
-
-## reading order
-
-If you're new to this:
-1. Start with **[Sorted](/sama/sorted)** — it has the verification grep that everything else is built around.
-2. Then **[Architecture](/sama/architecture)** — what each layer prefix means.
-3. Then **[Modeled](/sama/modeled)** — where types and tests live.
-4. Then **[Atomic](/sama/atomic)** — the split rule that keeps the rest honest as the codebase grows.
-
-Each page is short, opinionated, and ends with the common mistakes you'll see if the discipline lapses.
-
-## the v2 specification (draft)
-
-The four discipline pages above are the practitioner-facing version. The formal, normative version — frozen core + profile mechanism, written so a deterministic verifier in any language can ingest it — lives at **[/sama/v2](/sama/v2)** (draft for v2.0). That doc defines the four canonical layers (Pure / Core / Adapter / Entry), the single import law, the binary conformance gate, and the SAMA-independent core metrics for cross-repo empirical measurement.
-
-## drop into your agent
-
-For agents that load skills from \`~/.claude/skills/\` (Claude Code, obra/superpowers, etc.), grab the SKILL.md version:
-
-\`\`\`bash
-mkdir -p ~/.claude/skills
-curl -fsSL https://tdd.md/skills/sama.md -o ~/.claude/skills/sama.md
-\`\`\`
-
-The skill is the same content as the four pages here, written in obra/superpowers SKILL.md format with frontmatter, an iron-rule statement, and a verification checklist your agent can run before merging. **[Read it formatted →](/sama/skill)** · **[Raw markdown →](/skills/sama.md)**
-
-## verify any public repo
-
-Want to know whether a repo follows SAMA without reading its source? Paste the \`owner/name\` and tdd.md will run all four checks against the default branch — *Sorted* (the import-direction grep), *Architecture* (known layer prefixes), *Modeled* (sibling tests), *Atomic* (700-line + placeholder-test detection). Pass/fail per discipline, with violation lists. **[verify a repo on the web →](/sama/verify)** · or try it on this site: [\`syntaxai/tdd.md\`](/sama/verify?repo=syntaxai/tdd.md).
-
-## the \`sama\` CLI
-
-The web verifier is good for ad-hoc checks. For CI and pre-commit, install the standalone CLI — same checks, no network needed for local repos:
-
-\`\`\`bash
-mkdir -p ~/.local/bin
-curl -fsSL https://tdd.md/tools/sama-cli -o ~/.local/bin/sama
-chmod +x ~/.local/bin/sama
-sama --help
-\`\`\`
-
-Two subcommands:
-
-\`\`\`bash
-sama check # verify the current repo's src/
-sama check --json # JSON output for piping into CI tooling
-sama verify-repo owner/name # verify a public GitHub repo (no token)
-\`\`\`
-
-Exit codes: \`0\` on pass, \`1\` if any check fails, \`2\` on error. The CLI is a single Bun bundle (~14 KB). [Bun](https://bun.sh) needs to be on \`PATH\`.
-
-### pre-commit hook
-
-Add to \`.git/hooks/pre-commit\` (or via \`husky\`, \`pre-commit\`, \`lefthook\`):
-
-\`\`\`bash
-#!/usr/bin/env bash
-# Block commits that violate SAMA layer/atomic/modeled rules.
-exec sama check
-\`\`\`
-
-### GitHub Action
-
-\`\`\`yaml
-# .github/workflows/sama.yml
-name: sama
-on: [push, pull_request]
-jobs:
- verify:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: oven-sh/setup-bun@v2
- - run: |
- curl -fsSL https://tdd.md/tools/sama-cli -o sama
- chmod +x sama
- ./sama check
-\`\`\`
-
-If the rule lives in a hook or an action that fails the build, the harness can't talk the agent out of it. That is the whole point of the [corpus post](/blog/agentic-coding-corpus-three-patterns) and the next step from the [from-rules-to-checks](/blog/from-rules-to-checks) wrap-up.
-
-## the case behind it
-
-Two long-form pieces that argue *why* SAMA is shaped this way:
-
-- [**The Claude Code harness postmortem read through TDD + SAMA**](/blog/claude-code-harness-postmortem) — ThePaSch's r/ClaudeAI audit (40+ hidden reminders, 5 gag-order sites, 158 prompt versions in 11 days) read against the iron law and the verification grep. *The harness is loud; the diff doesn't have to be.*
-- [**Three patterns ten threads converge on**](/blog/agentic-coding-corpus-three-patterns) — a six-month corpus of r/ClaudeAI, r/ClaudeCode, r/AgentsOfAI failure-mode threads. Per-pattern mitigation tables map each thread to the SAMA / iron-law rule that catches or prevents it.
-
-If you're reading these for the first time, the order to take them is harness postmortem → corpus → back here.
-
-## why these four together
-
-Each property fixes a different failure mode:
-
-- *Sorted* fails when imports go in any direction → grep proves the rule.
-- *Architecture* fails when responsibilities blur → the prefix is the contract.
-- *Modeled* fails when types and tests scatter → siblings are mandatory.
-- *Atomic* fails when files swell → the ~700-line split keeps atoms small.
-
-Pick one and you'll claw back some clarity. Pick all four and the codebase becomes the kind an agent can be left alone with — there is exactly one right place for any change, and a one-line shell command that proves the layer rule.
-
-The blog post [*Red, tokens, atoms*](/blog/three-constraints-agentic-coding) argues SAMA also compounds with TDD and Claude Code's token-saving discipline; the four properties on this page are the *Atomic* / *Modeled* / *Architecture* / *Sorted* halves of that story.
-
-[← back to tdd.md](/) · [the blog](/blog) · [the guides](/guides)
-`;
-
-export const samaLandingHandler = async (): Promise => {
- const rows = ALL_SAMA
- .map((d) => `| **[${d.letter} — ${d.title}](/sama/${d.slug})** | ${d.rule} |`)
- .join("\n");
- const body = SAMA_LANDING_MD.replace("%ROWS%", rows);
- const html = await renderDocsPage({
- title: "SAMA — sorted, architecture, modeled, atomic — tdd.md",
- description: "SAMA is a four-property file-naming and module convention for codebases that AI agents work in: sorted by layer prefix, architecture as a contract, models with siblings, atomic files. One page per discipline.",
- bodyMarkdown: body,
- ogPath: "https://tdd.md/sama",
- active: "sama",
- pathForDocs: "/sama",
- editPathOverride: null,
- });
- return htmlResponse(html);
-};
-
-// -------- /sama/:slug (per-discipline content page) --------
-
-export const samaSlugHandler = async (req: { params: { slug: string } }): Promise => {
- const slug = req.params.slug;
- const entry = ALL_SAMA.find((d) => d.slug === slug);
- if (!entry) {
- const html = await renderNotFound(`/sama/${slug}`);
- return htmlResponse(html, 404);
- }
- const file = Bun.file(`./content/sama/${slug}.md`);
- if (!(await file.exists())) {
- const html = await renderNotFound(`/sama/${slug}`);
- return htmlResponse(html, 404);
- }
- const md = await file.text();
- const html = await renderDocsPage({
- title: `SAMA · ${entry.letter} — ${entry.title} — tdd.md`,
- description: entry.description,
- bodyMarkdown: md,
- ogPath: `https://tdd.md/sama/${slug}`,
- active: "sama",
- pathForDocs: `/sama/${slug}`,
- });
- return htmlResponse(html);
-};
-
-// -------- /tools/sama-cli (binary download) --------
-
-export const samaCliResponse = (): Response =>
- new Response(Bun.file("./public/sama-cli"), {
- headers: {
- "Content-Type": "text/javascript; charset=utf-8",
- "Content-Disposition": 'inline; filename="sama"',
- "Cache-Control": "public, max-age=300",
- },
- });
diff --git a/src/c21_handlers_source.ts b/src/c21_handlers_source.ts
deleted file mode 100644
index 3e8b3cda7ccdd4ef424bead0ca805eeafe539e7d..0000000000000000000000000000000000000000
--- a/src/c21_handlers_source.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-// c21 — handler: serves the raw markdown source of an editable doc
-// page from the main domain. Replaces the previous "view source on
-// git.tdd.md" link so the docs site doesn't depend on the Forgejo
-// subdomain for "view source". Reuses c32_edit_resolve so the same
-// allowlist (sama / guides / blog + safe slug regex) protects both
-// the editor and the raw view from path traversal.
-
-import { resolveEdit } from "./c32_edit_resolve.ts";
-import { renderNotFound, htmlResponse } from "./c51_render_layout.ts";
-
-// The route literal is `/content/:section/:filename` and the handler
-// requires the filename to end in `.md`. We don't use `:slug.md`
-// because Bun's path parser treats that as a single param literally
-// named "slug.md", which makes the URL un-typeable.
-export const rawSourceHandler = async (
- req: Request & { params: { section: string; filename: string } },
-): Promise => {
- const fullPath = `/content/${req.params.section}/${req.params.filename}`;
- const notFound = async (): Promise => {
- const html = await renderNotFound(fullPath);
- return htmlResponse(html, 404);
- };
- if (!req.params.filename.endsWith(".md")) return await notFound();
- const slug = req.params.filename.slice(0, -3);
- const resolved = resolveEdit(req.params.section, slug);
- if (!resolved) return await notFound();
- const file = Bun.file(`./${resolved.filePath}`);
- if (!(await file.exists())) return await notFound();
- // text/plain so browsers render the markdown source inline rather
- // than offering a download. UTF-8 is fixed because the content/ dir
- // is UTF-8 throughout (verified by sama-verify).
- return new Response(await file.text(), {
- headers: {
- "Content-Type": "text/plain; charset=utf-8",
- "Cache-Control": "public, max-age=60",
- },
- });
-};
diff --git a/src/c21_handlers_webhook.ts b/src/c21_handlers_webhook.ts
deleted file mode 100644
index 7a65f88a3dc834b794b6a2a73504198cb31782fb..0000000000000000000000000000000000000000
--- a/src/c21_handlers_webhook.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-// c21 — handlers: Forgejo push-webhook entry point. HMAC-verified, fires
-// `judge()` in the background and acks immediately so the upstream push
-// hook doesn't time out while we're checking out commits. Extracted
-// from c21_app.ts per the SAMA Atomic rule — separate file from the
-// manual /api/judge trigger because the auth model (HMAC vs. bearer)
-// and the failure semantics (ack-and-fire vs. wait-for-verdict) are
-// genuinely different concepts.
-
-import { judge } from "./c14_judge.ts";
-import { parseJson } from "./c14_request_parse.ts";
-import { timingSafeEqual, hmacSha256Hex } from "./c32_session.ts";
-
-export const forgejoWebhookHandler = async (req: Request): Promise => {
- if (req.method !== "POST") return new Response("POST only", { status: 405 });
- const secret = process.env.WEBHOOK_SECRET;
- if (!secret) return new Response("webhook not configured", { status: 503 });
-
- const body = await req.text();
- const provided =
- req.headers.get("x-forgejo-signature") ?? req.headers.get("x-gitea-signature") ?? "";
- const expected = await hmacSha256Hex(secret, body);
- if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
- return new Response("invalid signature", { status: 401 });
- }
-
- const parsed = parseJson<{ repository?: { owner?: { login?: string }; name?: string }; ref?: string }>(body);
- if (!parsed.ok) return new Response("invalid json", { status: 400 });
- const payload = parsed.value;
- const owner = payload.repository?.owner?.login;
- const repo = payload.repository?.name;
- if (!owner || !repo) return new Response("missing owner/repo", { status: 400 });
-
- // Fire the judge in the background; ack immediately so Forgejo
- // doesn't time out while we're checking out commits.
- void judge(owner, repo).catch((err) => {
- console.error(`judge failed for ${owner}/${repo}:`, err);
- });
- return Response.json({ accepted: true, owner, repo });
-};
diff --git a/src/c31_admin_validation.test.ts b/src/c31_admin_validation.test.ts
deleted file mode 100644
index 717b24e8e62e3582169c625ab82b2980b6e24bb3..0000000000000000000000000000000000000000
--- a/src/c31_admin_validation.test.ts
+++ /dev/null
@@ -1,213 +0,0 @@
-import { test, expect } from "bun:test";
-import {
- validateEditForm,
- MAX_ADMIN_HTML_BYTES,
-} from "./c31_admin_validation.ts";
-
-test("accepts a minimally valid form", () => {
- const r = validateEditForm({
- slug: "hello",
- type: "page",
- title: "Hello",
- html: "
");
- expect(doc.blocks).toHaveLength(1);
- expect(doc.blocks[0]).toEqual({ t: "p", c: [{ t: "text", v: "real" }] });
-});
diff --git a/src/c31_sxdoc_parse.ts b/src/c31_sxdoc_parse.ts
deleted file mode 100644
index 1642369e48aa87a62ec29be8b341dad94dc720ff..0000000000000000000000000000000000000000
--- a/src/c31_sxdoc_parse.ts
+++ /dev/null
@@ -1,327 +0,0 @@
-// c31 — HTML → SxDocument parser.
-//
-// SAMA placement: c31 because this is a parser for external input —
-// Modeled.md is explicit: "every external input has a parser in a c31_*
-// model — types and parse-functions colocated". HTML strings reach this
-// file from the editor's save POST, from the markdown-import script, and
-// from the AI-edit response — all "outside the process" → c31.
-//
-// Why a typed tree and not HTML strings: see c31_sxdoc.ts header.
-//
-// Why node-html-parser and not Bun's HTMLRewriter: we need a tree we can
-// recurse over, not a streaming filter. The dep is pure-logic (no I/O,
-// no fs, no spawn) so it doesn't push the file into c14 territory.
-
-import { parse, type HTMLElement, type Node, NodeType } from "node-html-parser";
-import type { SxDocument, SxBlock, SxInline, SxMark } from "./c31_sxdoc.ts";
-import { SX_DOC_VERSION } from "./c31_sxdoc.ts";
-
-const SHORTCODE_RE = /\[\[sx:([a-z][a-z0-9-]*)((?:\s+[a-z0-9_-]+=(?:"[^"]*"|[^\s"\]]+))*)\s*\]\]/g;
-const SHORTCODE_ARG_RE = /([a-z0-9_-]+)=(?:"([^"]*)"|([^\s"\]]+))/g;
-
-const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
-
-// Block-level tags — used by parseListItem to know where to stop
-// collecting inlines and recurse instead. Keep in sync with the
-// pushBlocksFromNode dispatcher above.
-const BLOCK_TAGS = new Set([
- "p", "h1", "h2", "h3", "h4", "h5", "h6",
- "ul", "ol", "blockquote", "pre",
- "img", "figure", "hr",
- "div", "section", "article", "table",
-]);
-
-const MARK_FOR_TAG: Record = {
- b: "b", strong: "b",
- i: "i", em: "i",
- u: "u",
- s: "s", strike: "s", del: "s",
- code: "c",
-};
-
-export const htmlToSx = (html: string): SxDocument => {
- // Wrap in so we always have a single parent to walk childNodes
- // of, regardless of whether the input has its own wrapper element.
- const root = parse(`${html}`, {
- blockTextElements: { script: false, style: false },
- });
- const rootEl = root.firstChild as HTMLElement;
- const blocks: SxBlock[] = [];
- for (const node of rootEl.childNodes) {
- pushBlocksFromNode(node, blocks);
- }
- return { v: SX_DOC_VERSION, blocks };
-};
-
-// ─── block-level dispatch ────────────────────────────────────────────────
-
-const pushBlocksFromNode = (node: Node, out: SxBlock[]): void => {
- if (node.nodeType === NodeType.TEXT_NODE) {
- const text = (node.text ?? "").trim();
- if (text) out.push(...textWithShortcodesToBlocks(text, []));
- return;
- }
- if (node.nodeType !== NodeType.ELEMENT_NODE) return;
-
- const el = node as HTMLElement;
- const tag = el.tagName?.toLowerCase();
- if (!tag) return;
-
- // Comments / processing-instructions surface as element nodes with a
- // tagName starting with "!" — drop them, they're not content.
- if (tag === "!" || tag === "comment") return;
-
- if (tag === "p") {
- const inlines = parseInline(el.childNodes, []);
- if (inlines.length === 0) return;
- out.push(...splitShortcodesFromParagraph(inlines));
- return;
- }
-
- if (HEADING_TAGS.has(tag)) {
- const level = parseInt(tag.slice(1), 10) as 1 | 2 | 3 | 4 | 5 | 6;
- out.push({ t: "h", level, c: parseInline(el.childNodes, []) });
- return;
- }
-
- if (tag === "ul" || tag === "ol") { out.push(parseList(el, tag)); return; }
- if (tag === "blockquote") { out.push(parseQuote(el)); return; }
- if (tag === "pre") { out.push(parseCodeBlock(el)); return; }
- if (tag === "img") {
- const img = parseImg(el);
- if (img) out.push(img);
- return;
- }
- if (tag === "figure") { out.push(parseFigure(el)); return; }
- if (tag === "hr") { out.push({ t: "hr" }); return; }
-
- if (tag === "div" || tag === "section" || tag === "article") {
- for (const child of el.childNodes) pushBlocksFromNode(child, out);
- return;
- }
-
- // Anything else → escape hatch so round-tripping stays lossless.
- out.push({ t: "html", src: el.outerHTML });
-};
-
-// ─── per-block parsers ───────────────────────────────────────────────────
-
-const parseList = (el: HTMLElement, tag: "ul" | "ol"): SxBlock => {
- const items: SxBlock[][] = [];
- for (const child of el.childNodes) {
- if (child.nodeType !== NodeType.ELEMENT_NODE) continue;
- const childEl = child as HTMLElement;
- if (childEl.tagName?.toLowerCase() !== "li") continue;
- const itemBlocks = parseListItem(childEl);
- if (itemBlocks.length > 0) items.push(itemBlocks);
- }
- return { t: tag, items };
-};
-
-// Walk an
's children in source-order. Inline runs collect into
-// paragraphs; block-level children (nested ul/ol/blockquote/pre/…)
-// flush the current inline buffer and recurse as their own block.
-// Without this split, parseInline would walk into nested
and the
-// inner text would leak into the outer paragraph.
-const parseListItem = (li: HTMLElement): SxBlock[] => {
- const result: SxBlock[] = [];
- let inlineBuf: Node[] = [];
- const flushInlines = (): void => {
- if (inlineBuf.length === 0) return;
- const inlines = parseInline(inlineBuf, []);
- if (inlines.length > 0) result.push({ t: "p", c: inlines });
- inlineBuf = [];
- };
- for (const node of li.childNodes) {
- if (node.nodeType === NodeType.ELEMENT_NODE) {
- const t = (node as HTMLElement).tagName?.toLowerCase();
- if (t && BLOCK_TAGS.has(t)) {
- flushInlines();
- pushBlocksFromNode(node, result);
- continue;
- }
- }
- inlineBuf.push(node);
- }
- flushInlines();
- return result;
-};
-
-const parseQuote = (el: HTMLElement): SxBlock => {
- const inner: SxBlock[] = [];
- for (const child of el.childNodes) pushBlocksFromNode(child, inner);
- if (inner.length === 0) {
- const inlines = parseInline(el.childNodes, []);
- if (inlines.length > 0) inner.push({ t: "p", c: inlines });
- }
- return { t: "quote", c: inner };
-};
-
-const parseCodeBlock = (el: HTMLElement): SxBlock => {
- // Canonical shape:
…
.
- // Loose
text
also supported.
- const codeChild = el.querySelector("code");
- const inner = codeChild ?? el;
- const lang = parseLangFromClass(inner.getAttribute("class") ?? "");
- return { t: "code", lang, src: decodeEntities(inner.innerHTML) };
-};
-
-const parseImg = (el: HTMLElement): SxBlock | null => {
- const src = el.getAttribute("src") ?? "";
- if (!src) return null;
- const block: { t: "img"; src: string; alt?: string; w?: number; h?: number } = { t: "img", src };
- const alt = el.getAttribute("alt");
- if (alt) block.alt = alt;
- const w = numAttr(el, "width"); if (w !== undefined) block.w = w;
- const h = numAttr(el, "height"); if (h !== undefined) block.h = h;
- return block as SxBlock;
-};
-
-const parseFigure = (el: HTMLElement): SxBlock => {
- const img = el.querySelector("img");
- const caption = el.querySelector("figcaption");
- if (img) {
- const src = img.getAttribute("src") ?? "";
- if (src) {
- const block: { t: "img"; src: string; alt?: string; caption?: string; w?: number; h?: number } = { t: "img", src };
- const alt = img.getAttribute("alt"); if (alt) block.alt = alt;
- if (caption) block.caption = caption.text;
- const w = numAttr(img, "width"); if (w !== undefined) block.w = w;
- const h = numAttr(img, "height"); if (h !== undefined) block.h = h;
- return block as SxBlock;
- }
- }
- return { t: "html", src: el.outerHTML };
-};
-
-// ─── inline parsing ──────────────────────────────────────────────────────
-
-const parseInline = (nodes: Node[] | undefined, marks: SxMark[]): SxInline[] => {
- if (!nodes) return [];
- const out: SxInline[] = [];
- for (const node of nodes) {
- if (node.nodeType === NodeType.TEXT_NODE) {
- const v = decodeEntities(node.text ?? "");
- if (v.length > 0) {
- out.push({ t: "text", v, ...(marks.length ? { m: dedupeMarks(marks) } : {}) });
- }
- continue;
- }
- if (node.nodeType !== NodeType.ELEMENT_NODE) continue;
- const el = node as HTMLElement;
- const tag = el.tagName?.toLowerCase();
- if (!tag) continue;
-
- if (tag === "br") {
- out.push({ t: "text", v: "\n", ...(marks.length ? { m: dedupeMarks(marks) } : {}) });
- continue;
- }
-
- if (tag === "a") {
- const href = el.getAttribute("href") ?? "";
- out.push({ t: "a", href, c: parseInline(el.childNodes, marks) });
- continue;
- }
-
- const mark = MARK_FOR_TAG[tag];
- if (mark) {
- out.push(...parseInline(el.childNodes, [...marks, mark]));
- continue;
- }
-
- // , , etc. — strip wrapper, keep contents.
- out.push(...parseInline(el.childNodes, marks));
- }
- return out;
-};
-
-const dedupeMarks = (marks: SxMark[]): SxMark[] => {
- const seen = new Set();
- const out: SxMark[] = [];
- for (const m of marks) if (!seen.has(m)) { seen.add(m); out.push(m); }
- return out;
-};
-
-// ─── shortcode lifting ──────────────────────────────────────────────────
-
-// When a
contains [[sx:foo]] tokens mixed with text, split it into
-// (paragraph)(shortcode)(paragraph) blocks so the document is queryable
-// per-shortcode rather than per-paragraph-with-substring.
-const splitShortcodesFromParagraph = (inlines: SxInline[]): SxBlock[] => {
- const out: SxBlock[] = [];
- let buf: SxInline[] = [];
- const flush = (): void => {
- if (buf.length > 0 && buf.some((i) => !(i.t === "text" && i.v.trim() === ""))) {
- out.push({ t: "p", c: buf });
- }
- buf = [];
- };
- for (const i of inlines) {
- if (i.t !== "text" || !SHORTCODE_RE.test(i.v)) {
- buf.push(i);
- continue;
- }
- SHORTCODE_RE.lastIndex = 0;
- const blocks = textWithShortcodesToBlocks(i.v, i.m ?? []);
- for (const b of blocks) {
- if (b.t === "shortcode") {
- flush();
- out.push(b);
- } else if (b.t === "p") {
- for (const inner of b.c) buf.push(inner);
- }
- }
- }
- flush();
- return out;
-};
-
-const textWithShortcodesToBlocks = (text: string, marks: SxMark[]): SxBlock[] => {
- const out: SxBlock[] = [];
- let last = 0;
- SHORTCODE_RE.lastIndex = 0;
- for (const m of text.matchAll(SHORTCODE_RE)) {
- const idx = m.index ?? 0;
- if (idx > last) {
- const before = text.slice(last, idx);
- if (before.trim() !== "") {
- out.push({ t: "p", c: [{ t: "text", v: before, ...(marks.length ? { m: marks } : {}) }] });
- }
- }
- const name = m[1]!;
- const args: Record = {};
- for (const a of (m[2] ?? "").matchAll(SHORTCODE_ARG_RE)) {
- args[a[1]!] = a[2] ?? a[3] ?? "";
- }
- out.push({ t: "shortcode", name, args });
- last = idx + m[0].length;
- }
- const tail = text.slice(last);
- if (tail.trim() !== "") {
- out.push({ t: "p", c: [{ t: "text", v: tail, ...(marks.length ? { m: marks } : {}) }] });
- }
- return out;
-};
-
-// ─── small helpers ───────────────────────────────────────────────────────
-
-const parseLangFromClass = (cls: string): string => {
- const m = cls.match(/(?:^|\s)language-([\w-]+)/);
- return m?.[1] ?? "";
-};
-
-const numAttr = (el: HTMLElement, name: string): number | undefined => {
- const v = el.getAttribute(name);
- if (!v) return undefined;
- const n = parseInt(v, 10);
- return Number.isFinite(n) ? n : undefined;
-};
-
-const decodeEntities = (s: string): string =>
- s
- .replace(/&/g, "&")
- .replace(/</g, "<")
- .replace(/>/g, ">")
- .replace(/"/g, '"')
- .replace(/'/g, "'")
- .replace(/ /g, " ");
diff --git a/src/c32_anchor_extract.test.ts b/src/c32_anchor_extract.test.ts
deleted file mode 100644
index e8d7c654b11d2a1282a119178d7bd86740df185f..0000000000000000000000000000000000000000
--- a/src/c32_anchor_extract.test.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { test, expect } from "bun:test";
-import { extractAnchors } from "./c32_anchor_extract.ts";
-
-test("extracts h2 with explicit id", () => {
- const html = `
elements (markdown would escape them).
-
-import {
- renderPage,
- escape,
-} from "./c51_render_layout.ts";
-import type { ResolvedEdit } from "./c32_edit_resolve.ts";
-import type { GitCommitOk, GitCommitFailure } from "./c31_git_parse.ts";
-
-const layoutWrap = (innerHtml: string): string =>
- `
${innerHtml}
`;
-
-// Override the standard : the edit experience needs
-// full-width form controls, not the doc-layout's three columns.
-const editBodyClass = "edit-body";
-
-const shortSha = (sha: string): string => sha.slice(0, 7);
-
-// SAMA-native commit URL on tdd.md itself. The /GIT/ prefix routes to
-// c21_handlers_commit_view which reads the data from Forgejo's API and
-// renders it through tdd.md's chrome — visitor never leaves the main
-// domain.
-const tddCommitUrl = (sha: string): string =>
- `/GIT/syntaxai/tdd.md/commit/${sha}`;
-
-// -------- /edit/:section/:slug — form for the admin --------
-
-export const renderEditFormPage = async (
- resolved: ResolvedEdit,
- currentBody: string,
- viewer: string,
-): Promise => {
- const inner = `
edit · ${escape(resolved.title)}
-
- Editing ${escape(resolved.filePath)} as ${escape(viewer)}.
- Saving will commit directly to syntaxai/tdd.md@main on git.tdd.md
- and refresh the live page.
- view the live page ·
- log out
-
- This editor commits to git via Forgejo's contents API — the container has
- no .git directory, no SSH keys, only an HTTP token. Every save
- becomes a real commit you can review at git.tdd.md.
-
To edit a page you need to sign in via GitHub. Editing is admin-only — only the site owner's GitHub account can save changes. We use GitHub for identity only; saves commit to git.tdd.md, never to GitHub.
- Commit ${escape(shortSha(sha))}
- landed in the local bare repo (/app/repo in the container,
- ~/repos/tdd.md.git on p620) via git plumbing.
- No HTTP, no Forgejo, no SSH involved — just a real git commit on disk.
-
-
- The container's content/ dir is copied from the working
- tree at image build, and the next deploy fetches new commits from the
- local bare repo before rebuilding — so this commit will outlive any
- container restart.
-
`;
- return renderPage({
- title: `applied · ${resolved.title} — tdd.md`,
- bodyHtml: layoutWrap(inner),
- noindex: true,
- bodyClass: editBodyClass,
- });
-};
-
-// -------- admin commit failed (Forgejo conflict / network / other) --------
-
-export const renderEditCommitFailed = async (
- resolved: ResolvedEdit,
- failure: GitCommitFailure,
-): Promise => {
- const explanation =
- failure.kind === "conflict"
- ? "The branch tip moved while you were editing — someone else committed in between. Refresh the editor to load the latest version, then re-apply your change."
- : failure.kind === "permission"
- ? "The container can't write to the bare repo. Check that /home/scri/repos/tdd.md.git on p620 is mounted read-write into /app/repo."
- : failure.kind === "not_found"
- ? "The 'main' branch doesn't exist in the bare repo. Verify that ~/repos/tdd.md.git on p620 has a refs/heads/main."
- : "git rejected the commit for an unexpected reason. See the message below.";
- const inner = `
");
- });
-
- test("htmlResponse honours the optional status arg", () => {
- const r = htmlResponse("
nope
", 404);
- expect(r.status).toBe(404);
- });
-});
diff --git a/src/c51_render_layout.ts b/src/c51_render_layout.ts
deleted file mode 100644
index a198972cc8f095ec74048439ed5827c6dbf65ccb..0000000000000000000000000000000000000000
--- a/src/c51_render_layout.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-// c51 (layout) — UI: page chrome + small response/format helpers shared
-// across every domain. Bigger per-domain body builders live next to this
-// file as `c51_render_.ts` (projects, reports). Layout exports
-// `escape`, `renderPage`, `renderNotFound`, `htmlResponse`, `errorPage`,
-// `phaseSpan`, `relativeTime`, plus the `Section` + `PageOptions` types.
-// Per the SAMA convention, lower layers don't import from this one.
-
-import { marked } from "marked";
-import type { Phase } from "./c31_commits.ts";
-
-const STYLE_CSS = "./public/style.css";
-const css = await Bun.file(STYLE_CSS).text();
-
-export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard" | "sama";
-
-export interface PageOptions {
- title: string;
- // Provide either bodyMarkdown (parsed by marked) or bodyHtml
- // (passed through as-is). bodyHtml is what the docs layout uses
- // when it has already done its own marked.parse and wrapped the
- // result in sidebar/content/anchor-rail chrome.
- bodyMarkdown?: string;
- bodyHtml?: string;
- description?: string;
- ogPath?: string;
- active?: Section;
- noindex?: boolean;
- jsonLd?: Record;
- bodyClass?: string;
- // Skip the top nav bar (tdd.md · games · guides · sama · blog · agents
- // · leaderboard). Used by the /GIT views which have their own
- // breadcrumb chrome and don't need the site-wide nav competing for
- // space at the top of the page.
- hideNav?: boolean;
-}
-
-const SITE_DESCRIPTION = "SAMA — the architectural standard for AI-agent codebases. Sorted, Architecture, Modeled, Atomic. Four pillars, one CI verifier.";
-
-export const escape = (s: string): string =>
- s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">");
-
-const navLink = (href: string, label: string, active: boolean): string => {
- const cls = active ? ' class="nav-active"' : "";
- return `${label}`;
-};
-
-const nav = (active?: Section): string => ``;
-
-export const renderPage = async (opts: PageOptions): Promise => {
- const body = opts.bodyHtml ?? await marked.parse(opts.bodyMarkdown ?? "", { gfm: true, breaks: false });
- const description = opts.description ?? SITE_DESCRIPTION;
- const bodyClassAttr = opts.bodyClass ? ` class="${escape(opts.bodyClass)}"` : "";
- const ogPath = opts.ogPath ?? "https://tdd.md";
- const robots = opts.noindex ? `\n` : "";
- const jsonLd = opts.jsonLd
- ? `\n`
- : "";
- return `
-
-
-
-
-
-
-${robots}
-
-
-
-
-
-
-
-
-
-
-
-
-
-${escape(opts.title)}
-${jsonLd}
-
-
-${opts.hideNav ? "" : nav(opts.active)}
-
-${body}
-
-
-`;
-};
-
-export const renderNotFound = async (path: string): Promise =>
- renderPage({
- title: "404 — tdd.md",
- bodyMarkdown: `# 404\n\n> No such path: \`${path}\`\n\nTry [home](/), [games](/games), [agents](/agents), or [leaderboard](/leaderboard).`,
- noindex: true,
- });
-
-// ---------------------------------------------------------------------
-// Small response/formatting helpers used by c21 handlers + domain renders.
-// ---------------------------------------------------------------------
-
-export const htmlResponse = (html: string, status = 200): Response =>
- new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
-
-export const errorPage = async (message: string, status = 400): Promise => {
- const html = await renderPage({
- title: "error — tdd.md",
- bodyMarkdown: `# error\n\n> ${message}\n\n[← back](/agents/register)`,
- active: "agents",
- });
- return htmlResponse(html, status);
-};
-
-export const phaseSpan = (p: Phase): string => {
- const cls = p === "red" ? "red" : p === "green" ? "green" : p === "refactor" ? "blue" : "muted";
- return `${p}`;
-};
-
-export const relativeTime = (iso: string): string => {
- const ms = Date.now() - new Date(iso).getTime();
- if (ms < 60_000) return `${Math.max(0, Math.floor(ms / 1000))}s ago`;
- if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`;
- if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`;
- return `${Math.floor(ms / 86_400_000)}d ago`;
-};
diff --git a/src/c51_render_projects.test.ts b/src/c51_render_projects.test.ts
deleted file mode 100644
index 8c8cd1c3bb74f425cc4bfa2b33f22a6491ecb89f..0000000000000000000000000000000000000000
--- a/src/c51_render_projects.test.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-// Sibling test for c51_render_projects.ts (Layer 1, render).
-// projectsLandingMd / projectRegisterMd / projectDetailMd take typed
-// ProjectRow + viewer inputs and return markdown strings. End-to-end
-// shape covered by /projects routes; this pins the pure transform.
-
-import { describe, test, expect } from "bun:test";
-import {
- projectsLandingMd,
- projectRegisterMd,
- projectDetailMd,
-} from "./c51_render_projects.ts";
-import type { ProjectRow } from "./c31_project_config.ts";
-
-const fixture = (): ProjectRow => ({
- id: 1,
- registeredBy: "alice",
- repoOwner: "alice",
- repoName: "demo",
- testRunner: "bun",
- trackedBranches: ["main"],
- displayName: null,
- team: null,
- registeredAt: Date.now(),
- status: "active",
-});
-
-describe("c51_render_projects — projectsLandingMd", () => {
- test("returns a non-empty markdown string for an empty project list", () => {
- const md = projectsLandingMd([]);
- expect(typeof md).toBe("string");
- expect(md.length).toBeGreaterThan(0);
- });
-
- test("includes the owner/name pair when given one project", () => {
- const md = projectsLandingMd([fixture()]);
- expect(md).toContain("alice/demo");
- });
-});
-
-describe("c51_render_projects — projectRegisterMd", () => {
- test("returns markdown that asks an anonymous viewer to sign in", () => {
- const md = projectRegisterMd(null);
- expect(typeof md).toBe("string");
- expect(md.toLowerCase()).toMatch(/sign in|github|register/);
- });
-
- test("includes the viewer's name when signed in", () => {
- const md = projectRegisterMd("alice");
- expect(md).toContain("alice");
- });
-});
-
-describe("c51_render_projects — projectDetailMd", () => {
- test("returns markdown that names the project", () => {
- const md = projectDetailMd(fixture());
- expect(md).toContain("alice/demo");
- });
-});
diff --git a/src/c51_render_projects.ts b/src/c51_render_projects.ts
deleted file mode 100644
index 0ff7f21a0544caf01dd174ed0477252bf0c6e29a..0000000000000000000000000000000000000000
--- a/src/c51_render_projects.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-// c51 (projects) — body builders for /projects, /projects/new,
-// /projects/:owner/:repo. Imports chrome helpers from c51_render_layout.
-
-import type { ProjectRow } from "./c31_project_config.ts";
-import { PROJECT_CONFIG_PATH } from "./c31_project_config.ts";
-import { escape } from "./c51_render_layout.ts";
-
-const projectListRow = (p: ProjectRow): string => {
- const slug = `${p.repoOwner}/${p.repoName}`;
- const display = p.displayName ?? slug;
- const team = p.team ? ` · ${escape(p.team)}` : "";
- const branches = p.trackedBranches.map((b) => `\`${b}\``).join(", ");
- const runner = p.testRunner === "none" ? "trace-only" : p.testRunner;
- return `| [${escape(display)}](/projects/${p.repoOwner}/${p.repoName}) ${team} | ${branches} | ${runner} |`;
-};
-
-export const projectsLandingMd = (projects: ProjectRow[]): string => {
- const rows = projects.length === 0
- ? `| _no projects yet — [register one](/projects/new)_ | | |`
- : projects.map(projectListRow).join("\n");
- return `# projects
-
-> Real repos that opted in to tdd.md scoring. Each project drops \`${PROJECT_CONFIG_PATH}\` at its root, registers here, and from then on its commits on tracked branches get judged structurally — red-fails, green-passes, no test-deletion, no regression. The aggregated scores feed [the reports](/reports).
-
-## tracked
-
-| project | branches | runner |
-|---|---|---|
-${rows}
-
-## register a repo
-
-[Register a project →](/projects/new) — paste a public GitHub URL; tdd.md fetches \`${PROJECT_CONFIG_PATH}\` from the default branch and onboards it.
-
-## the config file
-
-Drop \`${PROJECT_CONFIG_PATH}\` at the root of your repo's default branch:
-
-\`\`\`json
-{
- "version": 1,
- "test_runner": "none",
- "tracked_branches": ["main"],
- "display_name": "API Gateway",
- "team": "platform"
-}
-\`\`\`
-
-- **\`test_runner\`** — \`"none"\` for trace-mode (commit-discipline only, language-agnostic). \`"bun"\` will run the test suite once the sandbox-runner ships.
-- **\`tracked_branches\`** — pushes to these branches get scored. Defaults to \`["main"]\`.
-- **\`display_name\`** / **\`team\`** — optional, only used in the reporting UI.
-
-## what comes next
-
-Registration just stores the project. Per-commit judging (the part that produces score data for the reports) lands in the next sliver — until then the [report pages](/reports) keep showing the demo dataset.
-
-[← back to tdd.md](/) · [the reports](/reports)
-`;
-};
-
-export const projectRegisterMd = (
- viewer: string | null,
- prefilled?: string,
- errorMessage?: string,
-): string => {
- if (!viewer) {
- return `# register a project
-
-> You need to sign in before registering a project. We use your GitHub identity to record who onboarded the repo.
-
-[ sign in with github → ](/auth/github/start)
-
-[← all projects](/projects)
-`;
- }
- const error = errorMessage
- ? `
Couldn't register that repo: ${escape(errorMessage)}
`
- : "";
- const value = prefilled ? ` value="${escape(prefilled)}"` : "";
- return `# register a project
-
-> Paste a public GitHub URL. tdd.md fetches \`${PROJECT_CONFIG_PATH}\` from its default branch, validates it, and onboards the repo. Re-register the same repo to refresh the config.
-
-${error}
-
-
-
-
-
-
-
-> Signed in as ${escape(viewer)}. Don't have \`${PROJECT_CONFIG_PATH}\` yet? [See the format on /projects](/projects#the-config-file).
-
-[← all projects](/projects)
-`;
-};
-
-export const projectDetailMd = (p: ProjectRow): string => {
- const display = p.displayName ?? `${p.repoOwner}/${p.repoName}`;
- const registeredAt = new Date(p.registeredAt).toISOString().slice(0, 10);
- const branches = p.trackedBranches.map((b) => `\`${b}\``).join(", ");
- const runnerNote = p.testRunner === "none"
- ? "Trace-mode — judging looks at commit phase tags, test-count drift, and refactor stability. No test execution."
- : "Bun runner — test suite executes in a sandbox at every tracked-branch commit. (Sandbox-runner ships in the next sliver; meanwhile this falls back to trace-mode.)";
- return `# ${escape(display)}
-
-> [${escape(p.repoOwner)}/${escape(p.repoName)}](https://github.com/${p.repoOwner}/${p.repoName}) · registered by [${escape(p.registeredBy)}](/agents/${p.registeredBy}) on ${registeredAt}.
-
-## config
-
-| key | value |
-|---|---|
-| test_runner | \`${p.testRunner}\` |
-| tracked_branches | ${branches} |
-| display_name | ${p.displayName ? `\`${escape(p.displayName)}\`` : "_(none)_"} |
-| team | ${p.team ? `\`${escape(p.team)}\`` : "_(none)_"} |
-| status | \`${p.status}\` |
-
-${runnerNote}
-
-## scored commits
-
-> _No commits judged yet._ The webhook ingest + judging pipeline lands in the next sliver — once it does, scored commits for tracked branches will appear here grouped by agent.
-
-## refresh
-
-Push an updated \`${PROJECT_CONFIG_PATH}\` to your default branch and [re-register](/projects/new?repo=${encodeURIComponent(`${p.repoOwner}/${p.repoName}`)}) to pick up the new config.
-
-[← all projects](/projects)
-`;
-};
diff --git a/src/c51_render_repo.test.ts b/src/c51_render_repo.test.ts
deleted file mode 100644
index 4f59fa535bc34910ca8c6ad4dadfc9a629f3afbe..0000000000000000000000000000000000000000
--- a/src/c51_render_repo.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-// Sibling test for c51_render_repo.ts (Layer 1, render). End-to-end
-// shape covered by /GIT/syntaxai/tdd.md/tree|blob/main e2e specs.
-// This pins the export surface.
-
-import { describe, test, expect } from "bun:test";
-import { renderRepoTree, renderRepoBlob } from "./c51_render_repo.ts";
-
-describe("c51_render_repo — export shape", () => {
- test("renderRepoTree is exported", () => {
- expect(typeof renderRepoTree).toBe("function");
- });
- test("renderRepoBlob is exported", () => {
- expect(typeof renderRepoBlob).toBe("function");
- });
-});
-
-describe("c51_render_repo — renderRepoTree minimum behaviour", () => {
- test("returns a non-empty string for an empty entry list", async () => {
- const html = await renderRepoTree({
- owner: "syntaxai",
- repo: "tdd.md",
- ref: "main",
- path: "",
- entries: [],
- });
- expect(typeof html).toBe("string");
- expect(html.length).toBeGreaterThan(0);
- });
-});
diff --git a/src/c51_render_repo.ts b/src/c51_render_repo.ts
deleted file mode 100644
index 44f97d12e0c6024350ec7cf6eba289793f79bd57..0000000000000000000000000000000000000000
--- a/src/c51_render_repo.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-// c51 — UI: tree listing + blob viewer for the local bare repo.
-// Visited at /GIT/:owner/:repo/tree/:ref/ and /blob/:ref/.
-// Renders through tdd.md's chrome (renderPage with bodyHtml). Markdown
-// blobs get parsed via marked; everything else is rendered as
-// preformatted source.
-
-import { marked } from "marked";
-import { renderPage, escape } from "./c51_render_layout.ts";
-import type { TreeEntry } from "./c31_git_parse.ts";
-
-const shortSha = (sha: string): string => sha.slice(0, 7);
-
-// Build a breadcrumb: "owner/repo · main · content/blog" with each
-// segment a clickable link to /GIT/.../tree//.
-const renderBreadcrumb = (params: {
- owner: string;
- repo: string;
- ref: string;
- path: string;
- asBlob?: boolean;
-}): string => {
- const { owner, repo, ref, path, asBlob } = params;
- const repoLink = `${escape(owner)}/${escape(repo)}`;
- const refLink = `${escape(ref)}`;
- if (path === "") return `
${repoLink} · ${refLink}
`;
-
- const segments = path.split("/");
- const lastIdx = segments.length - 1;
- const links = segments
- .map((seg, i) => {
- const so_far = segments.slice(0, i + 1).join("/");
- // For blob view, the last segment is the file itself — no link.
- // For tree view, every segment links to the tree at that depth.
- const isLastFile = asBlob && i === lastIdx;
- if (isLastFile) return `${escape(seg)}`;
- return `${escape(seg)}`;
- })
- .join(" / ");
- return `
`;
-};
-
-export const reportsLandingMd = (): string => `# reports
-
-> Per-agent TDD-discipline reporting over real project repos. The judge replays each commit on tracked branches and scores it structurally — red-fails, green-passes, no test-deletion, no regression. The scores roll up per agent over time, with trend, failure-mode breakdown, and an exec summary fit for a quarterly readout.
-
-Two views of the same shape:
-
-- **[/reports/live](/reports/live)** — built from real commit data on \`syntaxai/tdd.md\` (the repo this site runs on), refreshed every 5 minutes from the GitHub commits API. Agent attribution comes from \`Co-Authored-By:\` footers. Phase-coverage is the only metric we can compute without running tests, so the score is a proxy for now.
-- **[/reports/demo](/reports/demo)** — the polished design preview with synthetic data for three agents and four repos. Useful for screenshots and showing the full failure-mode breakdown the live view can't compute yet.
-
-Drill-downs:
-- [live drill-down per agent](/reports/live/agents/claude-code) · [tests overview (live)](/reports/live/tests)
-- [demo drill-down per agent](/reports/demo/agents/cursor) · [tests overview (demo)](/reports/demo/tests)
-
-Want a real repo on this layer? [Register a project →](/projects) — drops \`.tdd-md.json\` at the repo root, onboards in seconds. Per-commit judging on tracked branches lands in a follow-up sliver; live reporting from the GitHub API already works for the dogfood case (the tdd.md repo itself).
-
-## what gets measured
-
-This layer measures **discipline**, not code-quality. Without hidden tests (those only exist on katas), tdd.md can't catch tautologies or weakened assertions on real repos. It *can* catch:
-
-| failure mode | what triggers it | what it costs |
-|---|---|---|
-| \`red-did-not-fail\` | commit tagged \`red:\` but tests pass | -5 / commit |
-| \`test-deleted\` | test count drops between commits | -20 / commit |
-| \`broken refactor\` | tests fail at a \`refactor:\` commit | -5 / commit |
-| \`no phase tag\` | tracked-branch commit missing \`red\\|green\\|refactor:\` | counts against phase-coverage % |
-
-The metric pair that anchors the report is **discipline-score** (0-100) + **phase-coverage %**. An agent with 0% phase-coverage doesn't *do* TDD — its score is N/A, not 0. Don't let a low-volume non-attempt look like a high-volume slip.
-
-## reading the data
-
-For management:
-- the [exec summary](/reports/demo) gives one number per agent + a narrative paragraph. Prints to one page.
-
-For team-leads:
-- the [drill-down](/reports/demo/agents/cursor) shows trend, failure-mix, streak, and the most recent flagged commits with one-click coaching links to the [Claude Code](/blog/claude-code-tdd) / [Cursor](/blog/cursor-tdd) / [Aider](/blog/aider-tdd) posts.
-
-[← back to tdd.md](/) · [the blog](/blog) · [the katas](/games)
-`;
-
-export const execSummaryMd = (ctx: ReportsContext): string => {
- const totalCommits = ctx.reports.reduce((s, a) => s + a.commits, 0);
- const tiles = ctx.reports.length === 0
- ? `
-
-${narrativeBlock}## what this number does *not* measure
-
-Discipline, not code quality. Hidden tests (like the ones on the katas) don't exist for production repos, so *tautological* tests and *weakly-asserted* checks stay invisible to the judge. This number says: "the agent honours the TDD cycle". It says nothing about whether the tests it writes assert the right thing. For that second signal, kata performance ([leaderboard](/leaderboard)) remains the proxy.
-
----
-
-${ctx.footerLinks}
-`;
-};
-
-export const agentDrilldownMd = (
- slug: AgentReport["slug"],
- ctx: ReportsContext,
-): string | null => {
- const a = ctx.reports.find((r) => r.slug === slug);
- if (!a) return null;
- const arr = trendArrow(a.delta);
- const deltaStr = a.delta > 0 ? `+${a.delta}` : `${a.delta}`;
- const recentRows = a.recent.length === 0
- ? `| _no recent attributed activity_ | | | | | |`
- : a.recent
- .map(
- (r) =>
- `| ${r.date} | \`${r.repo}\` | \`${r.sha}\` | ${r.phase} | ${r.failure} | ${r.pts} |`,
- )
- .join("\n");
- return `# ${a.name} · drill-down
-
-${ctx.bannerHtml}
-
-> Discipline score **${a.score} / 100** ${arr.glyph} ${deltaStr} over ${ctx.period}. ${a.commits.toLocaleString()} commits analysed, phase coverage **${a.phaseCoveragePct}%**.
-
-## trend (30 days)
-
-
-${sparkline(a.trend)}
-
-
-${streakBox(a)}
-
-## failure-mode breakdown
-
-${bars(a.failureMix)}
-
-Top issue this quarter: **${escape(a.topIssueLabel)}** (${a.topIssuePct}% of commits).
-
-## recent flagged
-
-| date | repo | sha | phase | failure | pts |
-|---|---|---|---|---|---|
-${recentRows}
-
-## coaching
-
-- ${a.slug === "claude-code" ? `[Claude Code does not do TDD by default](/blog/claude-code-tdd) — CLAUDE.md rules + fresh-context boundaries that prevent \`red-did-not-fail\`.` : a.slug === "cursor" ? `[Cursor knows how to do TDD; users skip the parts that matter](/blog/cursor-tdd) — Plan Mode, fresh chats, \`.cursor/rules\` to stop test-deletion.` : `[Aider is the closest agent to TDD on rails — until \`--auto-test\`](/blog/aider-tdd) — keep auto-test off for green commits, on for refactor.`}
-- [Tweag's TDD handbook needs a judge](/blog/tweag-handbook-tdd) — why local green isn't enough.
-
----
-
-${ctx.footerLinks}
-`;
-};
-
-export const testsOverviewMd = (ctx: TestsOverviewContext): string => {
- if (ctx.unavailableNote) {
- return `# tests overview
-
-${ctx.bannerHtml}
-
-> ${ctx.unavailableNote}
-
-[← exec summary](/reports) · [back to /reports](/reports)
-`;
- }
- const total = ctx.snapshots.reduce((s, r) => s + r.total, 0);
- const passing = ctx.snapshots.reduce((s, r) => s + r.passing, 0);
- const failing = ctx.snapshots.reduce((s, r) => s + r.failing, 0);
- const snapshots = ctx.snapshots.map(snapshotBlock).join("\n");
- const stabRows = ctx.stability.map(stabilityRow).join("\n");
- const placeholders = ctx.placeholderTests ?? [];
- const placeholderBlock = placeholders.length === 0
- ? `## placeholder tests
-
-> No placeholder tests detected at this snapshot. A placeholder is a test whose body contains zero \`expect()\` calls — covered in [the corpus post](/blog/agentic-coding-corpus-three-patterns) as the failure mode from r/ClaudeCode 1qix264 ("90 placeholder tests, 100% pass rate"). Detection runs on every deploy.
-`
- : `## placeholder tests · ⚠ ${placeholders.length} flagged
-
-> A placeholder test is one whose body contains zero \`expect()\` calls — empty body, comment-only stub, or string-literal body. Covered in [the corpus post](/blog/agentic-coding-corpus-three-patterns) as the failure mode from r/ClaudeCode 1qix264. The judge would refuse a merge that includes any of these.
-
-| test | file | reason |
-|---|---|---|
-${placeholders.map((p) => `| ${escape(p.name)} | \`${escape(p.file)}\` | ${escape(p.reason)} |`).join("\n")}
-`;
- return `# tests overview
-
-${ctx.bannerHtml}
-
-> Snapshot of the current test state per repo + stability of individual tests over ${ctx.period}. A high fail count with zero deletions means the test is actively catching regressions; high fail + deletion is the signal that a test is being squeezed — often the trace of an agent making it easier to "win".
-
-## current state · per repo
-
-
-${snapshots}
-
-
-**Total**: ${total.toLocaleString()} tests · ${passing.toLocaleString()} passing · ${failing.toLocaleString()} failing${placeholders.length > 0 ? ` · ${placeholders.length} placeholder ⚠` : ""}.
-
-${placeholderBlock}
-
-## test stability · ${ctx.period}
-
-Top tests by failure activity this period, with pass/fail/deleted counts and the agent who last broke the test.
-
-
-
-
-
test
-
pass
-
fail
-
del
-
last broken by
-
-
-
-${stabRows}
-
-
-
-> ⚠ marks tests where a test-deletion or weakening event has been detected this period. In a real setup, clicking a test name will link through to that test's commit history.
-
-## how to read this
-
-- **Lots of pass, few fail, 0 del**: healthy. The test does what it should, nobody is sabotaging it.
-- **Lots of fail, 0 del**: the test is actively catching regressions. Good news — discipline is working.
-- **Fail and del > 0**: the test is under pressure. Coach the agent that broke it (click the tag icon).
-- **Snapshot red + stability high**: a known, long-running broken test. Separate concern, not necessarily an agent problem.
-
----
-
-[← exec summary](/reports/demo) · [back to /reports](/reports)
-`;
-};
diff --git a/src/c51_render_sxdoc.test.ts b/src/c51_render_sxdoc.test.ts
deleted file mode 100644
index 6d708c79ee269dce4041fd9e370cca4f8d762d63..0000000000000000000000000000000000000000
--- a/src/c51_render_sxdoc.test.ts
+++ /dev/null
@@ -1,240 +0,0 @@
-import { test, expect } from "bun:test";
-import { sxToHtml } from "./c51_render_sxdoc.ts";
-import { htmlToSx } from "./c31_sxdoc_parse.ts";
-import { SX_DOC_VERSION, emptyDocument, type SxDocument } from "./c31_sxdoc.ts";
-
-test("renders the empty document as empty string", () => {
- expect(sxToHtml(emptyDocument())).toBe("");
-});
-
-test("renders a paragraph", () => {
- const out = sxToHtml({
- v: SX_DOC_VERSION,
- blocks: [{ t: "p", c: [{ t: "text", v: "hello" }] }],
- });
- expect(out).toBe("
hello
");
-});
-
-test("renders headings at the correct level", () => {
- for (const level of [1, 2, 3, 4, 5, 6] as const) {
- const out = sxToHtml({
- v: SX_DOC_VERSION,
- blocks: [{ t: "h", level, c: [{ t: "text", v: "X" }] }],
- });
- expect(out).toBe(`X`);
- }
-});
-
-test("renders ul and ol with li wrappers", () => {
- const ul = sxToHtml({
- v: SX_DOC_VERSION,
- blocks: [{
- t: "ul",
- items: [
- [{ t: "p", c: [{ t: "text", v: "one" }] }],
- [{ t: "p", c: [{ t: "text", v: "two" }] }],
- ],
- }],
- });
- expect(ul).toBe("
`;
-
- case "code":
- return renderCodeBlock(block);
-
- case "img":
- return renderImg(block);
-
- case "hr":
- return ``;
-
- case "html":
- // Raw passthrough — trust whoever inserted it. The parser only
- // emits SxHtml for round-trip-preservation of unknown HTML.
- return block.src;
-
- case "shortcode":
- return renderShortcode(block);
- }
-};
-
-const renderCodeBlock = (block: { lang?: string; src: string }): string => {
- const langClass = block.lang ? ` class="language-${escAttr(block.lang)}"` : "";
- return `
${escText(block.src)}
`;
-};
-
-const renderImg = (block: { src: string; alt?: string; caption?: string; w?: number; h?: number }): string => {
- const attrs = [`src="${escAttr(block.src)}"`];
- if (block.alt !== undefined) attrs.push(`alt="${escAttr(block.alt)}"`);
- if (block.w !== undefined) attrs.push(`width="${block.w}"`);
- if (block.h !== undefined) attrs.push(`height="${block.h}"`);
- const img = ``;
- if (block.caption) {
- return `${img}${escText(block.caption)}`;
- }
- return img;
-};
-
-const renderShortcode = (block: SxShortcode): string => {
- const args = Object.entries(block.args)
- .map(([k, v]) => `${k}="${v.replace(/"/g, """)}"`)
- .join(" ");
- return args ? `[[sx:${block.name} ${args}]]` : `[[sx:${block.name}]]`;
-};
-
-// ─── inline ──────────────────────────────────────────────────────────────
-
-// Stable mark order — matters so round-tripping is deterministic. The
-// parser dedupes marks per text-run; renderer wraps them in this fixed
-// order regardless of input ordering.
-const MARK_ORDER: SxMark[] = ["b", "i", "u", "s", "c"];
-const MARK_TAG: Record = {
- b: "strong", i: "em", u: "u", s: "s", c: "code",
-};
-
-const renderInline = (inlines: SxInline[]): string =>
- inlines.map(renderOneInline).join("");
-
-const renderOneInline = (inline: SxInline): string => {
- if (inline.t === "a") {
- return `${renderInline(inline.c)}`;
- }
- // Newline runs render as . Marks on a are meaningless so we
- // drop them — the parser already emits them on the next text run.
- if (inline.v === "\n") return " ";
- let body = escText(inline.v);
- if (inline.m && inline.m.length > 0) {
- // MARK_ORDER lists marks outer→inner. Wrap in reverse so the
- // innermost mark is applied first, leaving the outermost-listed
- // mark as the outermost tag. Without the reverse, the deepest tag
- // becomes the outermost — and a re-parse flips the mark order.
- const sortedMarks = MARK_ORDER.filter((m) => inline.m!.includes(m));
- for (let i = sortedMarks.length - 1; i >= 0; i--) {
- const m = sortedMarks[i]!;
- body = `<${MARK_TAG[m]}>${body}${MARK_TAG[m]}>`;
- }
- }
- return body;
-};
-
-// ─── escape helpers ──────────────────────────────────────────────────────
-
-const escText = (s: string): string =>
- s
- .replace(/&/g, "&")
- .replace(//g, ">");
-
-const escAttr = (s: string): string =>
- s
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """);
diff --git a/src/d11_server.ts b/src/d11_server.ts
new file mode 100644
index 0000000000000000000000000000000000000000..38d1f146853acfd7227bf10fdf05b65978dcaed9
--- /dev/null
+++ b/src/d11_server.ts
@@ -0,0 +1,10 @@
+// c11 — server entry: env + Bun.serve startup. No route logic, no SQL,
+// no HTML. The route table, fallback fetch, and error handler live in
+// c21_app.ts; this file just reads PORT and asks createApp() to bind.
+
+import { createApp } from "./d21_app.ts";
+
+const port = Number(process.env.PORT ?? 3000);
+const server = createApp(port);
+
+console.log(`tdd.md → ${server.url}`);
diff --git a/src/d21_app.ts b/src/d21_app.ts
new file mode 100644
index 0000000000000000000000000000000000000000..682f7473e0fbd87c619d7d9940f6dce51f271ce5
--- /dev/null
+++ b/src/d21_app.ts
@@ -0,0 +1,458 @@
+// c21 — handlers: the route table + fallback fetch. Composes the lower
+// layers (c13 db, c14 secondary I/O, c31 models, c32 logic, c51 render)
+// into the HTTP surface served by Bun.serve in c11_server.
+
+import {
+ renderPage,
+ renderNotFound,
+ htmlResponse,
+} from "./b51_render_layout.ts";
+import { renderDocsPage } from "./b51_render_docs_layout.ts";
+import { listGames, loadGame } from "./a31_games.ts";
+import { ALL_POSTS } from "./a31_blog.ts";
+import { ALL_GUIDES } from "./a31_guides.ts";
+import { ALL_SAMA } from "./a31_sama.ts";
+import {
+ getViewer,
+ sessionCookieHeader,
+} from "./b32_session.ts";
+import { renderAgentsIndex, renderAgentDetail } from "./d21_handlers_agents.ts";
+import { renderLeaderboard } from "./d21_handlers_leaderboard.ts";
+import { startGithubOauth, handleGithubCallback } from "./d21_handlers_auth.ts";
+import {
+ reportsLandingHandler,
+ reportsDemoHandler,
+ reportsDemoTestsHandler,
+ reportsDemoAgentHandler,
+ reportsLiveHandler,
+ reportsLiveTestsHandler,
+ reportsLiveAgentHandler,
+} from "./d21_handlers_reports.ts";
+import {
+ skillsSamaMdHandler,
+ samaCliResponse,
+ samaSkillHandler,
+ samaV2Handler,
+ samaV2VerifyHandler,
+ samaVerifyHandler,
+ samaLandingHandler,
+ samaSlugHandler,
+} from "./d21_handlers_sama.ts";
+import { editPageHandler } from "./d21_handlers_edit.ts";
+import {
+ adminListHandler,
+ adminNewHandler,
+ adminEditHandler,
+ adminDeleteHandler,
+} from "./d21_handlers_admin.ts";
+import { bundleAdminClient } from "./c14_client_bundle.ts";
+import { publicPageHandler } from "./d21_handlers_content.ts";
+import { rawSourceHandler } from "./d21_handlers_source.ts";
+import { commitViewHandler } from "./d21_handlers_commit_view.ts";
+import { appFetch, appError } from "./d21_handlers_fallback.ts";
+import {
+ projectsLandingHandler,
+ projectsNewHandler,
+ projectDetailHandler,
+} from "./d21_handlers_projects.ts";
+import {
+ judgeApiHandler,
+ agentVisibilityHandler,
+} from "./d21_handlers_api_agents.ts";
+import { forgejoWebhookHandler } from "./d21_handlers_webhook.ts";
+
+const HOME_MD = "./content/home.md";
+const GAME_DIR = "./content/games";
+
+const HOME_DESCRIPTION =
+ "SAMA — the architectural standard for AI-agent codebases. Sorted, Architecture, Modeled, Atomic: four pillars your CI verifier enforces so your AI coding agents stop drifting.";
+
+const homeBody = await Bun.file(HOME_MD).text();
+const HOME_HTML = await renderPage({
+ title: "SAMA — the architectural standard for AI-agent codebases",
+ description: HOME_DESCRIPTION,
+ bodyMarkdown: homeBody,
+ active: "home",
+ jsonLd: {
+ "@context": "https://schema.org",
+ "@type": "WebSite",
+ name: "tdd.md",
+ url: "https://tdd.md",
+ description: HOME_DESCRIPTION,
+ },
+});
+
+const ALL_GAMES = await listGames();
+
+const gamesIndexBody = `# games
+
+${ALL_GAMES.length === 0
+ ? "_No katas registered yet._"
+ : `| kata | description | steps |\n|---|---|---|\n${ALL_GAMES.map(
+ (g) => `| [${g.id}](/games/${g.id}) | ${g.description} | ${g.steps.length} |`,
+ ).join("\n")}`
+}
+
+> Ready to play? [Register your agent →](/agents/register)
+> Using a specific agent? See the [agent-specific guides](/guides) — Claude Code, Cursor, Aider.
+`;
+
+const GAMES_INDEX_HTML = await renderPage({
+ title: "TDD katas — tdd.md",
+ description:
+ "Browse the TDD katas. Pick a challenge, push red→green→refactor commits, and earn a public verdict graded against hidden tests.",
+ bodyMarkdown: gamesIndexBody,
+ ogPath: "https://tdd.md/games",
+ active: "games",
+});
+
+const renderKata = async (kata: string): Promise => {
+ const file = Bun.file(`${GAME_DIR}/${kata}/spec.md`);
+ if (!(await file.exists())) return null;
+ const md = await file.text();
+ // Pull the kata's own description from spec.ts when available — it's
+ // the canonical short copy (rendered on /games + sitemap previews).
+ let description: string | undefined;
+ try {
+ const game = await loadGame(kata);
+ description = game.description;
+ } catch {
+ // unknown kata; use the site default
+ }
+ const html = await renderPage({
+ title: `${kata} TDD kata — tdd.md`,
+ description,
+ bodyMarkdown: md,
+ ogPath: `https://tdd.md/games/${kata}`,
+ active: "games",
+ });
+ return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
+};
+
+const REGISTER_BODY = `# register
+
+> Sign in with GitHub to create your tdd.md agent.
+
+## what we ask GitHub for
+- your username
+- your primary verified email
+
+That's it — no repo access, no anything else.
+
+## what you get
+- a public agent account at \`git.tdd.md/\`
+- a push token (shown once)
+- an empty repo for the first kata, ready to push to
+
+[ sign in with github → ](/auth/github/start)
+`;
+
+const REGISTER_HTML = await renderPage({
+ title: "Register your AI agent — tdd.md",
+ description:
+ "Sign in with GitHub to register your AI agent on tdd.md and start solving TDD katas. Public-signup, verified-identity, no extra forms.",
+ bodyMarkdown: REGISTER_BODY,
+ ogPath: "https://tdd.md/agents/register",
+ active: "agents",
+ noindex: true,
+});
+
+// ---------------------------------------------------------------------
+// App factory — c11 calls createApp(port) to start the server. The
+// routes literal stays inline here so Bun's path-parameter inference
+// (`:slug` → `req.params.slug`) flows through to the handler types.
+// ---------------------------------------------------------------------
+
+export const createApp = (port: number) => Bun.serve({
+ port,
+ error: appError,
+ fetch: appFetch,
+ routes: {
+ "/": htmlResponse(HOME_HTML),
+ "/raw": new Response(Bun.file(HOME_MD), {
+ headers: { "Content-Type": "text/markdown; charset=utf-8" },
+ }),
+ "/healthz": new Response("ok"),
+
+ "/robots.txt": new Response(
+ `User-agent: *\nAllow: /\nDisallow: /auth/\nDisallow: /api/\n\nSitemap: https://tdd.md/sitemap.xml\n`,
+ { headers: { "Content-Type": "text/plain; charset=utf-8" } },
+ ),
+
+ "/sitemap.xml": async () => {
+ const today = new Date().toISOString().slice(0, 10);
+ const url = (loc: string, priority: string) =>
+ `${loc}${today}${priority}`;
+ const kataUrls = ALL_GAMES.map((g) =>
+ url(`https://tdd.md/games/${g.id}`, "0.8"),
+ ).join("\n");
+ const guideUrls = ALL_GUIDES.map((g) =>
+ url(`https://tdd.md/guides/${g.slug}`, "0.8"),
+ ).join("\n");
+ const samaUrls = ALL_SAMA.map((d) =>
+ url(`https://tdd.md/sama/${d.slug}`, "0.8"),
+ ).join("\n");
+ const blogUrls = ALL_POSTS.map((p) =>
+ url(`https://tdd.md/blog/${p.slug}`, "0.8"),
+ ).join("\n");
+ const xml = `
+
+${url("https://tdd.md/", "1.0")}
+${url("https://tdd.md/games", "0.9")}
+${kataUrls}
+${url("https://tdd.md/guides", "0.9")}
+${guideUrls}
+${url("https://tdd.md/sama", "0.9")}
+${samaUrls}
+${url("https://tdd.md/sama/skill", "0.8")}
+${url("https://tdd.md/blog", "0.7")}
+${blogUrls}
+${url("https://tdd.md/agents", "0.7")}
+${url("https://tdd.md/leaderboard", "0.7")}
+`;
+ return new Response(xml, {
+ headers: { "Content-Type": "application/xml; charset=utf-8" },
+ });
+ },
+
+ "/og.svg": new Response(Bun.file("./public/og.svg"), {
+ headers: {
+ "Content-Type": "image/svg+xml",
+ "Cache-Control": "public, max-age=3600",
+ },
+ }),
+
+ "/og.png": new Response(Bun.file("./public/og.png"), {
+ headers: {
+ "Content-Type": "image/png",
+ "Cache-Control": "public, max-age=3600",
+ },
+ }),
+
+ "/games": htmlResponse(GAMES_INDEX_HTML),
+
+ "/blog": async () => {
+ const rows = ALL_POSTS
+ .map((p) => `| ${p.date} | [${p.title}](/blog/${p.slug}) |`)
+ .join("\n");
+ const body = `# blog
+
+Notes on TDD, agentic coding, and the discipline that ties them together.
+
+| date | post |
+|---|---|
+${rows}
+
+> RSS feed coming when there's a second post.
+
+[← back to tdd.md](/) · [the guides](/guides) · [the katas](/games)
+`;
+ const html = await renderDocsPage({
+ title: "Blog — tdd.md",
+ description: "Posts on test-driven development for AI coding agents — how to apply TDD with Claude Code, Cursor, and Aider, what we learn from the verdicts.",
+ bodyMarkdown: body,
+ ogPath: "https://tdd.md/blog",
+ active: "blog",
+ pathForDocs: "/blog",
+ editPathOverride: null,
+ });
+ return htmlResponse(html);
+ },
+
+ "/blog/:slug": async (req) => {
+ const slug = req.params.slug;
+ const entry = ALL_POSTS.find((p) => p.slug === slug);
+ if (!entry) {
+ const html = await renderNotFound(`/blog/${slug}`);
+ return htmlResponse(html, 404);
+ }
+ const file = Bun.file(`./content/blog/${slug}.md`);
+ if (!(await file.exists())) {
+ const html = await renderNotFound(`/blog/${slug}`);
+ return htmlResponse(html, 404);
+ }
+ const md = await file.text();
+ const html = await renderDocsPage({
+ title: `${entry.title} — tdd.md`,
+ description: entry.description,
+ bodyMarkdown: md,
+ ogPath: `https://tdd.md/blog/${slug}`,
+ active: "blog",
+ pathForDocs: `/blog/${slug}`,
+ jsonLd: {
+ "@context": "https://schema.org",
+ "@type": "BlogPosting",
+ headline: entry.title,
+ description: entry.description,
+ datePublished: entry.date,
+ url: `https://tdd.md/blog/${slug}`,
+ author: { "@type": "Organization", name: "tdd.md" },
+ },
+ });
+ return htmlResponse(html);
+ },
+
+ "/projects": projectsLandingHandler,
+ "/projects/new": projectsNewHandler,
+ "/projects/:repoOwner/:repoName": projectDetailHandler,
+
+ "/reports": reportsLandingHandler,
+ "/reports/demo": reportsDemoHandler,
+ "/reports/demo/tests": reportsDemoTestsHandler,
+ "/reports/demo/agents/:slug": reportsDemoAgentHandler,
+ "/reports/live": reportsLiveHandler,
+ "/reports/live/tests": reportsLiveTestsHandler,
+ "/reports/live/agents/:slug": reportsLiveAgentHandler,
+
+ "/guides": async () => {
+ const rows = ALL_GUIDES
+ .map((g) => `| [${g.title}](/guides/${g.slug}) | ${g.description} |`)
+ .join("\n");
+ const body = `# guides
+
+Agent-specific walkthroughs for using tdd.md with the major agentic-coding tools. Each guide covers setup, prompt patterns that keep the agent in TDD, and the common pitfalls that cost score.
+
+| guide | what it covers |
+|---|---|
+${rows}
+
+> Missing your agent? [The mechanics are the same](/) — push commits tagged \`red:\` / \`green:\` / \`refactor:\` to your kata repo. Send a PR with a new guide and we'll list it here.
+
+[← play a kata](/games) · [register your agent →](/you)
+`;
+ const html = await renderDocsPage({
+ title: "TDD guides for agentic coding tools — tdd.md",
+ description: "Practical TDD walkthroughs for Claude Code, Cursor, Aider and other AI coding agents — keep your agent honest with red→green→refactor commits, scored by tdd.md.",
+ bodyMarkdown: body,
+ ogPath: "https://tdd.md/guides",
+ active: "guides",
+ pathForDocs: "/guides",
+ editPathOverride: null,
+ });
+ return htmlResponse(html);
+ },
+
+ "/guides/:slug": async (req) => {
+ const slug = req.params.slug;
+ const entry = ALL_GUIDES.find((g) => g.slug === slug);
+ if (!entry) {
+ const html = await renderNotFound(`/guides/${slug}`);
+ return htmlResponse(html, 404);
+ }
+ const file = Bun.file(`./content/guides/${slug}.md`);
+ if (!(await file.exists())) {
+ const html = await renderNotFound(`/guides/${slug}`);
+ return htmlResponse(html, 404);
+ }
+ const md = await file.text();
+ const html = await renderDocsPage({
+ title: `${entry.title} — tdd.md`,
+ description: entry.description,
+ bodyMarkdown: md,
+ ogPath: `https://tdd.md/guides/${slug}`,
+ active: "guides",
+ pathForDocs: `/guides/${slug}`,
+ });
+ return htmlResponse(html);
+ },
+
+ "/skills/sama.md": skillsSamaMdHandler,
+ "/tools/sama-cli": samaCliResponse(),
+
+ "/sama/skill": samaSkillHandler,
+
+ "/sama/v2": samaV2Handler,
+
+ "/sama/v2/verify": samaV2VerifyHandler,
+
+ "/sama/verify": samaVerifyHandler,
+
+ "/sama": samaLandingHandler,
+
+ "/sama/:slug": samaSlugHandler,
+
+ "/games/:kata": async (req) => {
+ const res = await renderKata(req.params.kata);
+ if (res) return res;
+ const html = await renderNotFound(`/games/${req.params.kata}`);
+ return htmlResponse(html, 404);
+ },
+
+ "/agents": () => renderAgentsIndex(),
+ "/agents/register": htmlResponse(REGISTER_HTML),
+ "/agents/:name": async (req) => {
+ const viewer = await getViewer(req);
+ return renderAgentDetail(req.params.name, viewer);
+ },
+ // Redirect the legacy URL to the canonical /:owner/:repo path —
+ // /agents/:name/:kata used to render a placeholder before the
+ // GitHub-style routing landed.
+ "/agents/:name/:kata": (req) =>
+ Response.redirect(`/${req.params.name}/${req.params.kata}`, 301),
+
+ "/leaderboard": () => renderLeaderboard(),
+
+ "/api/judge/:owner/:repo": judgeApiHandler,
+ "/api/agents/:name/visibility": agentVisibilityHandler,
+ "/api/forgejo/webhook": forgejoWebhookHandler,
+
+ "/you": async (req) => {
+ const viewer = await getViewer(req);
+ const target = viewer ? `/agents/${viewer}` : "/auth/github/start";
+ return new Response(null, { status: 302, headers: { Location: target } });
+ },
+
+ "/auth/logout": (_req) => {
+ // Clear the session cookie and bounce back home.
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: "/",
+ "Set-Cookie": sessionCookieHeader("", 0),
+ },
+ });
+ },
+
+ "/edit/:section/:slug": editPageHandler,
+
+ // Admin UI — sxdoc-backed CRUD on pages + posts. Replaces the legacy
+ // /edit flow in Fase 6; both live alongside until migration cutover.
+ "/admin": adminListHandler,
+ "/admin/new": adminNewHandler,
+ "/admin/edit/:type/:slug": adminEditHandler,
+ "/admin/delete/:type/:slug": adminDeleteHandler,
+ // Public sxdoc-backed pages — single-segment fast path. Multi-segment
+ // slugs fall through to appFetch's regex matcher above.
+ "/p/:slug": publicPageHandler,
+
+ "/admin/assets/blockeditor.js": async (req) => {
+ const { code, etag } = await bundleAdminClient();
+ if (req.headers.get("if-none-match") === etag) {
+ return new Response(null, { status: 304, headers: { ETag: etag } });
+ }
+ return new Response(code, {
+ headers: {
+ "Content-Type": "application/javascript; charset=utf-8",
+ "ETag": etag,
+ "Cache-Control": "no-cache",
+ },
+ });
+ },
+
+ // Raw markdown source — replaces the previous git.tdd.md "view source"
+ // link so docs pages don't depend on the Forgejo subdomain. The
+ // route uses `:filename` (with trailing `.md` validated in the
+ // handler) because Bun's parser treats `:slug.md` as a single param.
+ "/content/:section/:filename": rawSourceHandler,
+
+ // SAMA-native commit view — Bun-rendered alternative to Forgejo's
+ // ///commit/ page. The :sha param may carry a
+ // trailing ".diff" which the handler handles inline.
+ "/GIT/:owner/:repo/commit/:sha": commitViewHandler,
+
+ "/auth/github/start": (req) => startGithubOauth(req),
+
+ "/auth/github/callback": async (req) => handleGithubCallback(req),
+
+ },
+});
diff --git a/src/d21_handlers_admin.ts b/src/d21_handlers_admin.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c710a2cb1686c0cc3e431b27d01ded821a47949e
--- /dev/null
+++ b/src/d21_handlers_admin.ts
@@ -0,0 +1,254 @@
+// c21 — handlers: CRUD on sxdoc-backed pages + posts.
+//
+// Composes:
+// c13_database listDocuments / loadDocument / saveDocument / deleteDocument
+// c32_session getViewer (admin gate)
+// c31_sxdoc_parse htmlToSx (parse posted HTML → SxDocument)
+// c51_render_sxdoc sxToHtml (project stored doc back to HTML for the form)
+// c31_admin_validation validateEditForm (form → typed input)
+// c51_render_admin shell rendering
+//
+// Routes (mounted in c21_app.ts):
+// GET /admin
+// GET /admin/new
+// POST /admin/new
+// GET /admin/edit/:type/:slug
+// POST /admin/edit/:type/:slug
+// POST /admin/delete/:type/:slug
+//
+// Auth: any non-admin signed-in viewer → 403 wall (matches the legacy
+// /edit handler). Anonymous → 401 login wall.
+
+import { ADMIN_USERNAME } from "./a31_site_config.ts";
+import {
+ listDocuments,
+ loadDocument,
+ saveDocument,
+ deleteDocument,
+} from "./c13_database.ts";
+import { getViewer } from "./b32_session.ts";
+import { htmlToSx } from "./a31_sxdoc_parse.ts";
+import { validateEditForm } from "./a31_admin_validation.ts";
+import { htmlResponse } from "./b51_render_layout.ts";
+import {
+ renderAdminList,
+ renderAdminEdit,
+ renderAdminLoginWall,
+ renderAdminNonAdminWall,
+} from "./b51_render_admin.ts";
+
+const wantsJson = (req: Request): boolean =>
+ (req.headers.get("accept") ?? "").includes("application/json");
+
+const jsonResponse = (body: unknown, status = 200): Response =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: {
+ "Content-Type": "application/json; charset=utf-8",
+ "Cache-Control": "no-store",
+ },
+ });
+
+// ─── auth gate ───────────────────────────────────────────────────────────
+
+interface AuthOk { ok: true; viewer: string; }
+interface AuthDenied { ok: false; response: Response; }
+type AuthResult = AuthOk | AuthDenied;
+
+const requireAdmin = async (req: Request): Promise => {
+ const viewer = await getViewer(req);
+ if (!viewer) {
+ const html = await renderAdminLoginWall();
+ return { ok: false, response: htmlResponse(html, 401) };
+ }
+ if (viewer !== ADMIN_USERNAME) {
+ const html = await renderAdminNonAdminWall(viewer);
+ return { ok: false, response: htmlResponse(html, 403) };
+ }
+ return { ok: true, viewer };
+};
+
+// FormData → string-record adapter. The validator lives in c31 and
+// stays browser-agnostic by taking plain string fields.
+const formToRecord = async (req: Request): Promise> => {
+ const fd = await req.formData();
+ const out: Record = {};
+ for (const [k, v] of fd.entries()) out[k] = String(v);
+ return out;
+};
+
+// ─── handlers ────────────────────────────────────────────────────────────
+
+export const adminListHandler = async (req: Request): Promise => {
+ const auth = await requireAdmin(req);
+ if (!auth.ok) return auth.response;
+ const documents = listDocuments();
+ const html = await renderAdminList(documents);
+ return htmlResponse(html);
+};
+
+export const adminNewHandler = async (req: Request): Promise => {
+ const auth = await requireAdmin(req);
+ if (!auth.ok) return auth.response;
+ const json = wantsJson(req);
+
+ if (req.method === "POST") {
+ const form = await formToRecord(req);
+ const v = validateEditForm(form);
+ if (!v.ok) {
+ if (json) return jsonResponse({ ok: false, error: v.error }, 400);
+ const html = await renderAdminEdit({
+ mode: "new",
+ title: form.title ?? "",
+ slug: form.slug ?? "",
+ type: form.type === "post" ? "post" : "page",
+ doc: htmlToSx(form.html ?? ""),
+ status: form.status === "draft" ? "draft" : "published",
+ primaryTag: (form.primary_tag ?? "").trim() || null,
+ error: v.error,
+ });
+ return htmlResponse(html, 400);
+ }
+ if (loadDocument(v.data.slug, v.data.type)) {
+ const err = `a ${v.data.type} with slug "${v.data.slug}" already exists`;
+ if (json) return jsonResponse({ ok: false, error: err }, 409);
+ const html = await renderAdminEdit({
+ mode: "new",
+ title: v.data.title,
+ slug: v.data.slug,
+ type: v.data.type,
+ doc: htmlToSx(v.data.html),
+ status: v.data.status,
+ primaryTag: v.data.primaryTag,
+ error: err,
+ });
+ return htmlResponse(html, 409);
+ }
+ saveDocument({
+ slug: v.data.slug,
+ type: v.data.type,
+ title: v.data.title,
+ doc: htmlToSx(v.data.html),
+ status: v.data.status,
+ primaryTag: v.data.primaryTag,
+ });
+ if (json) {
+ return jsonResponse({ ok: true, ts: Date.now(), slug: v.data.slug, type: v.data.type });
+ }
+ return new Response(null, {
+ status: 303,
+ headers: { Location: `/admin/edit/${v.data.type}/${v.data.slug}` },
+ });
+ }
+
+ // GET — empty form
+ const html = await renderAdminEdit({
+ mode: "new",
+ title: "",
+ slug: "",
+ type: "page",
+ doc: htmlToSx("
Hello, world.
"),
+ status: "published",
+ primaryTag: null,
+ });
+ return htmlResponse(html);
+};
+
+export const adminEditHandler = async (
+ req: Request & { params: { type: string; slug: string } },
+): Promise => {
+ const auth = await requireAdmin(req);
+ if (!auth.ok) return auth.response;
+
+ const type = req.params.type === "post" ? "post" : "page";
+ if (req.params.type !== "page" && req.params.type !== "post") {
+ return new Response("invalid type", { status: 400 });
+ }
+ const slug = req.params.slug;
+ const existing = loadDocument(slug, type);
+ if (!existing) return new Response("not found", { status: 404 });
+
+ if (req.method === "POST") {
+ const form = await formToRecord(req);
+ const json = wantsJson(req);
+ const v = validateEditForm(form);
+ if (!v.ok) {
+ if (json) return jsonResponse({ ok: false, error: v.error }, 400);
+ const html = await renderAdminEdit({
+ mode: "edit",
+ title: form.title ?? existing.title,
+ slug: form.slug ?? slug,
+ type,
+ doc: htmlToSx(form.html ?? ""),
+ status: form.status === "draft" ? "draft" : "published",
+ primaryTag: (form.primary_tag ?? "").trim() || existing.primaryTag,
+ error: v.error,
+ });
+ return htmlResponse(html, 400);
+ }
+ // Rename (slug or type changed) — reject collision with another
+ // existing doc; otherwise delete the old key before saving the new one.
+ if (v.data.slug !== slug || v.data.type !== type) {
+ const collision = loadDocument(v.data.slug, v.data.type);
+ if (collision && collision.id !== existing.id) {
+ const err = `a ${v.data.type} with slug "${v.data.slug}" already exists`;
+ if (json) return jsonResponse({ ok: false, error: err }, 409);
+ const html = await renderAdminEdit({
+ mode: "edit",
+ title: v.data.title,
+ slug: v.data.slug,
+ type: v.data.type,
+ doc: htmlToSx(v.data.html),
+ status: v.data.status,
+ primaryTag: v.data.primaryTag,
+ error: err,
+ });
+ return htmlResponse(html, 409);
+ }
+ deleteDocument(slug, type);
+ }
+ saveDocument({
+ slug: v.data.slug,
+ type: v.data.type,
+ title: v.data.title,
+ doc: htmlToSx(v.data.html),
+ status: v.data.status,
+ primaryTag: v.data.primaryTag,
+ });
+ if (json) {
+ return jsonResponse({ ok: true, ts: Date.now(), slug: v.data.slug, type: v.data.type });
+ }
+ return new Response(null, {
+ status: 303,
+ headers: { Location: `/admin/edit/${v.data.type}/${v.data.slug}` },
+ });
+ }
+
+ // GET — render the stored sxdoc directly; c51_render_admin computes
+ // the textarea HTML projection and embeds the JSON for client hydration.
+ const html = await renderAdminEdit({
+ mode: "edit",
+ title: existing.title,
+ slug: existing.slug,
+ type: existing.type,
+ doc: existing.doc,
+ status: existing.status,
+ primaryTag: existing.primaryTag,
+ });
+ return htmlResponse(html);
+};
+
+export const adminDeleteHandler = async (
+ req: Request & { params: { type: string; slug: string } },
+): Promise => {
+ const auth = await requireAdmin(req);
+ if (!auth.ok) return auth.response;
+ if (req.method !== "POST") return new Response("POST only", { status: 405 });
+
+ const type = req.params.type === "post" ? "post" : "page";
+ if (req.params.type !== "page" && req.params.type !== "post") {
+ return new Response("invalid type", { status: 400 });
+ }
+ deleteDocument(req.params.slug, type);
+ return new Response(null, { status: 303, headers: { Location: "/admin" } });
+};
diff --git a/src/d21_handlers_agents.ts b/src/d21_handlers_agents.ts
new file mode 100644
index 0000000000000000000000000000000000000000..41566617cf6187aa4ff8c4dcc05a64339410aa12
--- /dev/null
+++ b/src/d21_handlers_agents.ts
@@ -0,0 +1,175 @@
+// c21 (agents) — handlers for /agents (index) and /agents/:name (detail).
+// Both compose Forgejo admin lookups (c14) with kata progress (c31) and
+// the verdict store (c13). The route table in c21_app.ts forwards the
+// matching path here.
+
+import {
+ FORGEJO_URL,
+ adminApiHeaders,
+ type ForgejoUserSummary,
+} from "./c14_forgejo.ts";
+import { computeProgress } from "./a31_commits.ts";
+import { loadGame } from "./a31_games.ts";
+import { allLatestRuns } from "./c13_database.ts";
+import {
+ renderPage,
+ renderNotFound,
+ htmlResponse,
+} from "./b51_render_layout.ts";
+
+export const renderAgentsIndex = async (): Promise => {
+ let users: ForgejoUserSummary[] = [];
+ const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
+ if (adminToken) {
+ const r = await fetch(`${FORGEJO_URL}/api/v1/admin/users?limit=200`, {
+ headers: adminApiHeaders(),
+ });
+ if (r.ok) users = (await r.json()) as ForgejoUserSummary[];
+ }
+ // Drop the admin (id 1) and anyone whose visibility isn't "public" —
+ // private and limited agents stay invisible on the public index.
+ const agents = users.filter(
+ (u) => u.id !== 1 && !u.is_admin && (u.visibility ?? "public") === "public",
+ );
+
+ // Per-agent score totals from the latest run per repo.
+ const allRuns = allLatestRuns();
+ const totalsByOwner = new Map();
+ for (const r of allRuns) {
+ const t = totalsByOwner.get(r.owner) ?? { score: 0, runs: 0 };
+ t.score += r.verdict.totalScore;
+ t.runs += 1;
+ totalsByOwner.set(r.owner, t);
+ }
+
+ let body: string;
+ if (agents.length === 0) {
+ body = `# agents
+
+> No agents registered yet. Be the first.
+
+[ Register your agent → ](/agents/register)
+`;
+ } else {
+ const rows = agents
+ .map((u) => {
+ const t = totalsByOwner.get(u.login) ?? { score: 0, runs: 0 };
+ const sign = t.score >= 0 ? "+" : "";
+ return `| [${u.login}](/agents/${u.login}) | ${t.runs} | ${sign}${t.score} |`;
+ })
+ .join("\n");
+ body = `# agents
+
+| agent | attempts | total score |
+|---|---|---|
+${rows}
+
+[ Register your agent → ](/agents/register)
+`;
+ }
+
+ const description =
+ agents.length === 0
+ ? "AI agents doing test-driven development on tdd.md — registration is open, sign in with GitHub to play."
+ : `${agents.length} AI ${agents.length === 1 ? "agent" : "agents"} doing test-driven development on tdd.md, scored on red→green discipline against hidden tests for agentic coding.`;
+
+ const html = await renderPage({
+ title: "AI agents on tdd.md",
+ description,
+ bodyMarkdown: body,
+ ogPath: "https://tdd.md/agents",
+ active: "agents",
+ });
+ return htmlResponse(html);
+};
+
+export const renderAgentDetail = async (
+ name: string,
+ viewer: string | null,
+): Promise => {
+ const userRes = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(name)}`, {
+ headers: adminApiHeaders(),
+ });
+ // Treat private/limited users as if they don't exist publicly —
+ // unless the logged-in viewer IS the owner. Owner can always see
+ // their own dashboard, public or not.
+ if (userRes.ok) {
+ const u = (await userRes.clone().json()) as ForgejoUserSummary;
+ const ownVisibility = u.visibility ?? "public";
+ if (ownVisibility !== "public" && viewer !== name) {
+ const html = await renderNotFound(`/agents/${name}`);
+ return htmlResponse(html, 404);
+ }
+ }
+ if (userRes.status === 404) {
+ const html = await renderPage({
+ title: `${name} — agents — tdd.md`,
+ bodyMarkdown: `# agents / ${name}\n\n> No agent registered with this name.\n\n[← all agents](/agents) · [register your own →](/agents/register)`,
+ ogPath: `https://tdd.md/agents/${name}`,
+ active: "agents",
+ });
+ return htmlResponse(html, 404);
+ }
+ const reposRes = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(name)}/repos?limit=50`, {
+ headers: adminApiHeaders(),
+ });
+ const repos = reposRes.ok ? ((await reposRes.json()) as { name: string; description: string }[]) : [];
+
+ const progressByRepo = await Promise.all(
+ repos.map(async (r) => {
+ const cRes = await fetch(
+ `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(name)}/${encodeURIComponent(r.name)}/commits?limit=50&stat=false`,
+ { headers: adminApiHeaders() },
+ );
+ const commits = cRes.ok ? ((await cRes.json()) as { commit: { message: string } }[]) : [];
+ return { repo: r, progress: computeProgress(commits) };
+ }),
+ );
+
+ const totals: Record = {};
+ for (const r of repos) {
+ try {
+ const game = await loadGame(r.name);
+ totals[r.name] = game.steps.length;
+ } catch {
+ // unknown kata, no total
+ }
+ }
+
+ const isSelf = viewer === name;
+ let body = `# agents / ${name}\n\n`;
+ if (isSelf) {
+ body += `> Welcome back, ${name}. This is your dashboard — only you and admins see it when your profile is private.\n\n`;
+ }
+ if (repos.length === 0) {
+ body += "> Registered, but no kata attempts yet.\n\n[← all agents](/agents)";
+ } else {
+ body += "## attempts\n\n";
+ body += "| kata | verified | phases |\n|---|---|---|\n";
+ for (const { repo: r, progress } of progressByRepo) {
+ const total = totals[r.name];
+ const verified = progress.verifiedSteps.size;
+ const counter = total !== undefined ? `${verified} / ${total}` : `${verified} / ?`;
+ const phases = `red ${progress.redCount} · green ${progress.greenCount} · refactor ${progress.refactorCount}`;
+ body += `| [${r.name}](/${name}/${r.name}) | ${counter} | ${phases} |\n`;
+ }
+ }
+
+ if (isSelf) {
+ body += `\n\n---\n\n[sign out](/auth/logout) · [toggle visibility](#) (POST /api/agents/${name}/visibility with your push token)`;
+ }
+
+ const verifiedSteps = progressByRepo.reduce((acc, p) => acc + p.progress.verifiedSteps.size, 0);
+ const description =
+ repos.length === 0
+ ? `${name} just registered on tdd.md — no kata attempts yet.`
+ : `${name}'s TDD attempts on tdd.md: ${repos.length} ${repos.length === 1 ? "kata" : "katas"} pushed, ${verifiedSteps} verified red→green ${verifiedSteps === 1 ? "step" : "steps"}.`;
+ const html = await renderPage({
+ title: `${name} · TDD attempts — tdd.md`,
+ description,
+ bodyMarkdown: body,
+ ogPath: `https://tdd.md/agents/${name}`,
+ active: "agents",
+ });
+ return htmlResponse(html);
+};
diff --git a/src/d21_handlers_api_agents.ts b/src/d21_handlers_api_agents.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fca30bfa68e0ca61f49b0845e6bfdebaa411743f
--- /dev/null
+++ b/src/d21_handlers_api_agents.ts
@@ -0,0 +1,95 @@
+// c21 — handlers: agent-facing JSON API. Manual judge trigger
+// (admin-token-gated) and the self-service visibility toggle (agent
+// pushes their own Forgejo token to flip public|limited|private).
+// Extracted from c21_app.ts per the SAMA Atomic rule. The push-driven
+// judge entry point lives in c21_handlers_webhook — different auth
+// model (HMAC), different concept.
+
+import { judge } from "./c14_judge.ts";
+import { timingSafeEqual } from "./b32_session.ts";
+import {
+ FORGEJO_URL,
+ adminApiHeaders,
+} from "./c14_forgejo.ts";
+
+export const judgeApiHandler = async (
+ req: Request & { params: { owner: string; repo: string } },
+): Promise => {
+ if (req.method !== "POST") {
+ return new Response("method not allowed; POST to trigger a judge run", { status: 405 });
+ }
+ // Manual triggers require the admin token. Push-driven runs come
+ // through /api/forgejo/webhook with HMAC signature verification.
+ const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
+ const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? "";
+ if (!adminToken || !timingSafeEqual(provided, adminToken)) {
+ return new Response(
+ "unauthorized — POST with `Authorization: Bearer `",
+ { status: 401 },
+ );
+ }
+ try {
+ const verdict = await judge(req.params.owner, req.params.repo);
+ return Response.json(verdict);
+ } catch (err) {
+ return Response.json({ error: (err as Error).message }, { status: 500 });
+ }
+};
+
+// Self-service visibility toggle. Agent posts their push token in
+// Authorization, picks "public" | "limited" | "private". We verify
+// the token actually belongs to :name by hitting Forgejo's /user
+// endpoint with it, then PATCH the user via the admin token.
+export const agentVisibilityHandler = async (
+ req: Request & { params: { name: string } },
+): Promise => {
+ if (req.method !== "POST") return new Response("POST only", { status: 405 });
+ const name = req.params.name;
+ const provided = req.headers.get("authorization")?.replace(/^[Bb]earer\s+/, "") ?? "";
+ if (!provided) return Response.json({ error: "missing bearer token" }, { status: 401 });
+
+ // Verify the token belongs to :name (or is the admin token).
+ const adminToken = process.env.FORGEJO_ADMIN_TOKEN ?? "";
+ let allowed = !!adminToken && timingSafeEqual(provided, adminToken);
+ if (!allowed) {
+ const meRes = await fetch(`${FORGEJO_URL}/api/v1/user`, {
+ headers: { Authorization: `token ${provided}` },
+ });
+ if (meRes.ok) {
+ const me = (await meRes.json()) as { login?: string };
+ allowed = me.login === name;
+ }
+ }
+ if (!allowed) return Response.json({ error: "token does not match agent" }, { status: 403 });
+
+ let body: { visibility?: string };
+ try {
+ body = (await req.json()) as { visibility?: string };
+ } catch {
+ return Response.json({ error: "invalid json" }, { status: 400 });
+ }
+ const visibility = body.visibility;
+ if (visibility !== "public" && visibility !== "limited" && visibility !== "private") {
+ return Response.json(
+ { error: "visibility must be one of public|limited|private" },
+ { status: 400 },
+ );
+ }
+
+ const patchRes = await fetch(
+ `${FORGEJO_URL}/api/v1/admin/users/${encodeURIComponent(name)}`,
+ {
+ method: "PATCH",
+ headers: { ...adminApiHeaders(), "Content-Type": "application/json" },
+ body: JSON.stringify({ visibility, source_id: 0, login_name: name }),
+ },
+ );
+ if (!patchRes.ok) {
+ const text = await patchRes.text();
+ return Response.json(
+ { error: `forgejo PATCH failed: ${patchRes.status} ${text}` },
+ { status: 502 },
+ );
+ }
+ return Response.json({ name, visibility });
+};
diff --git a/src/d21_handlers_auth.ts b/src/d21_handlers_auth.ts
new file mode 100644
index 0000000000000000000000000000000000000000..467595e01705e5e010920324cec4d9dab27647e9
--- /dev/null
+++ b/src/d21_handlers_auth.ts
@@ -0,0 +1,170 @@
+// c21 (auth) — GitHub OAuth start + callback handlers. Composes
+// c14_github (token exchange + user fetch), c14_forgejo (existence check
+// + agent registration), c32_session (sign + cookie), c51 layout for
+// the welcome page rendered after first-time registration.
+
+import * as github from "./c14_github.ts";
+import * as forgejo from "./c14_forgejo.ts";
+import { parseUrl } from "./c14_request_parse.ts";
+import {
+ SESSION_TTL_SEC,
+ parseCookies,
+ signSession,
+ sessionCookieHeader,
+ timingSafeEqual,
+ randomHex,
+} from "./b32_session.ts";
+import { renderPage, errorPage } from "./b51_render_layout.ts";
+
+const BASE_URL = process.env.BASE_URL ?? "https://tdd.md";
+const CALLBACK_URL = `${BASE_URL}/auth/github/callback`;
+
+const CLEAR_OAUTH_STATE =
+ "tdd_oauth_state=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0";
+const CLEAR_OAUTH_RETURN =
+ "tdd_oauth_return=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0";
+
+// Same-origin internal path. Anything that doesn't start with a single
+// "/" or that contains "//" / ":" is rejected to prevent open-redirect.
+const isSafeReturnTo = (s: string): boolean =>
+ s.startsWith("/") && !s.startsWith("//") && !s.includes("\n") && !s.includes("\r") && s.length < 1024;
+
+export const startGithubOauth = (req?: Request): Response => {
+ if (!github.isConfigured() || !forgejo.isConfigured()) {
+ return new Response("registration is not configured on this server", { status: 503 });
+ }
+ const nonce = randomHex(16);
+ const headers = new Headers();
+ headers.append("Set-Cookie", `tdd_oauth_state=${nonce}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`);
+
+ // Optional ?to= query — set a return cookie the callback
+ // honours after a successful sign-in. Used by /edit and /admin
+ // links so the user lands back where they came from.
+ if (req) {
+ const urlR = parseUrl(req.url);
+ const to = urlR.ok ? urlR.value.searchParams.get("to") : null;
+ if (to && isSafeReturnTo(to)) {
+ headers.append(
+ "Set-Cookie",
+ `tdd_oauth_return=${encodeURIComponent(to)}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`,
+ );
+ }
+ }
+ headers.set("Location", github.authorizeUrl(nonce, CALLBACK_URL));
+ return new Response(null, { status: 302, headers });
+};
+
+const welcomeBody = (reg: forgejo.AgentRegistration): string => {
+ const verb = reg.isNew ? "created" : "rotated";
+ return `# welcome, ${reg.username}
+
+> Your tdd.md agent has been ${verb}. **Save the token below — this page is the only time you'll see it.** If you lose it, [register again](/agents/register) to issue a fresh one (the old one will stop working).
+
+## push token
+
+\`\`\`
+${reg.pushToken}
+\`\`\`
+
+## kata: string-calc
+
+Your repo is at [\`git.tdd.md/${reg.username}/string-calc\`](https://git.tdd.md/${reg.username}/string-calc), already initialized with a default branch \`main\`.
+
+\`\`\`
+git clone ${reg.repoCloneUrl}
+cd string-calc
+
+# play the kata, commit per phase
+# red: commit a failing test
+# green: commit the impl that makes it pass
+# refactor: commit a structural change with tests staying green
+
+git push
+# username: ${reg.username}
+# password:
+\`\`\`
+
+When you push, the judge replays your commits and posts the verdict at [/agents/${reg.username}/string-calc](/agents/${reg.username}/string-calc).
+
+[← spec](/games/string-calc) · [all agents](/agents)
+`;
+};
+
+export const handleGithubCallback = async (req: Request): Promise => {
+ const urlR = parseUrl(req.url);
+ if (!urlR.ok) return errorPage("invalid callback URL");
+ const url = urlR.value;
+ const code = url.searchParams.get("code");
+ const state = url.searchParams.get("state");
+ if (!code || !state) return errorPage("missing code or state");
+
+ const cookies = parseCookies(req.headers.get("cookie"));
+ const cookieState = cookies.tdd_oauth_state;
+ if (!cookieState || !timingSafeEqual(cookieState, state)) {
+ return errorPage("state mismatch — open the registration page again and retry");
+ }
+
+ let username: string;
+ let email: string;
+ let fullName: string | null;
+ try {
+ const accessToken = await github.exchangeCode(code, CALLBACK_URL);
+ const user = await github.fetchUser(accessToken);
+ username = user.login;
+ fullName = user.name;
+ // GitHub's noreply email format: unique per account, never collides
+ // with another Forgejo user. We don't need a deliverable address —
+ // agents authenticate by token, not by email reset flow.
+ email = `${user.id}+${user.login}@users.noreply.github.com`;
+ } catch (err) {
+ return errorPage(`github oauth failed: ${(err as Error).message}`, 400);
+ }
+
+ // Login vs register: if the user already exists in Forgejo, this
+ // is a returning visitor — set the session cookie, redirect to
+ // their dashboard (or to the cookie-stored returnTo path, when one
+ // was set by /auth/github/start?to=...), don't rotate their token.
+ const isExisting = await forgejo.userExists(username);
+ const sessionToken = await signSession(username);
+ const sessionCookie = sessionCookieHeader(sessionToken, SESSION_TTL_SEC);
+ const returnToRaw = cookies.tdd_oauth_return ? decodeURIComponent(cookies.tdd_oauth_return) : null;
+ const returnTo = returnToRaw && isSafeReturnTo(returnToRaw) ? returnToRaw : null;
+
+ if (isExisting) {
+ return new Response(null, {
+ status: 302,
+ headers: new Headers([
+ ["Location", returnTo ?? `/agents/${username}`],
+ ["Set-Cookie", sessionCookie],
+ ["Set-Cookie", CLEAR_OAUTH_STATE],
+ ["Set-Cookie", CLEAR_OAUTH_RETURN],
+ ]),
+ });
+ }
+
+ let reg: forgejo.AgentRegistration;
+ try {
+ reg = await forgejo.registerAgent({
+ username,
+ email,
+ fullName: fullName ?? undefined,
+ });
+ } catch (err) {
+ return errorPage(`failed to create your agent: ${(err as Error).message}`, 422);
+ }
+
+ const html = await renderPage({
+ title: `welcome ${reg.username} — tdd.md`,
+ bodyMarkdown: welcomeBody(reg),
+ active: "agents",
+ noindex: true,
+ });
+ return new Response(html, {
+ headers: new Headers([
+ ["Content-Type", "text/html; charset=utf-8"],
+ ["Set-Cookie", sessionCookie],
+ ["Set-Cookie", CLEAR_OAUTH_STATE],
+ ["Set-Cookie", CLEAR_OAUTH_RETURN],
+ ]),
+ });
+};
diff --git a/src/d21_handlers_commit_view.ts b/src/d21_handlers_commit_view.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cb8d80bae0554a69ec5b45e2a17a8bc037fb7a2b
--- /dev/null
+++ b/src/d21_handlers_commit_view.ts
@@ -0,0 +1,90 @@
+// c21 — handler: SAMA-native commit view at
+// GET /GIT/:owner/:repo/commit/:sha
+// and a raw-diff sibling at
+// GET /GIT/:owner/:repo/commit/:sha.diff
+//
+// Composes c14 (Forgejo HTTP), c31 (diff parser), c51 (render). The
+// route prefix is uppercase /GIT/ to make it visually distinct from
+// the markdown content sections (/sama, /blog, /guides). Visitors who
+// land on git.tdd.md are bounced here by the deploy-time tunnel rule
+// (out of scope for this handler — handler just owns the rendering).
+
+import { renderNotFound, htmlResponse } from "./b51_render_layout.ts";
+import { getCommit, getCommitDiff } from "./c14_git.ts";
+import { LIVE_REPO_OWNER, LIVE_REPO_NAME } from "./a31_site_config.ts";
+import { parseUnifiedDiff } from "./a31_diff_parse.ts";
+import { renderCommitView } from "./b51_render_commit.ts";
+
+// Owner/repo + sha shape — paranoid because these go straight into a
+// Forgejo URL. Owner/repo allow letters/digits/hyphens/underscores/dots;
+// sha is hex 7-64 (Forgejo accepts shortened SHAs but our render assumes
+// full ones because we use them in URLs).
+const SAFE_OWNER_REPO = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
+const SAFE_SHA = /^[a-f0-9]{7,64}$/;
+
+const isValid = (owner: string, repo: string, sha: string): boolean =>
+ SAFE_OWNER_REPO.test(owner) && SAFE_OWNER_REPO.test(repo) && SAFE_SHA.test(sha);
+
+export const commitViewHandler = async (
+ req: Request & { params: { owner: string; repo: string; sha: string } },
+): Promise => {
+ const { owner, repo } = req.params;
+ // The :sha param may carry a trailing ".diff" because the route
+ // pattern doesn't have a separate one. Normalise + branch.
+ const rawSha = req.params.sha;
+ const wantsDiff = rawSha.endsWith(".diff");
+ const sha = wantsDiff ? rawSha.slice(0, -5) : rawSha;
+ const fullPath = `/GIT/${owner}/${repo}/commit/${rawSha}`;
+
+ if (!isValid(owner, repo, sha)) {
+ const html = await renderNotFound(fullPath);
+ return htmlResponse(html, 404);
+ }
+
+ // /GIT/ now serves only syntaxai/tdd.md (our local bare repo via
+ // c14_git). Other (owner, repo) pairs would historically have been
+ // proxied to Forgejo for agent katas — that's a separate concern
+ // and currently 404s. If we want it back, add a Forgejo fallback
+ // branch here keyed on the owner/repo pair.
+ if (owner !== LIVE_REPO_OWNER || repo !== LIVE_REPO_NAME) {
+ const html = await renderNotFound(fullPath);
+ return htmlResponse(html, 404);
+ }
+
+ if (wantsDiff) {
+ const diffText = await getCommitDiff(sha);
+ if (diffText === null) {
+ const html = await renderNotFound(fullPath);
+ return htmlResponse(html, 404);
+ }
+ return new Response(diffText, {
+ headers: {
+ "Content-Type": "text/plain; charset=utf-8",
+ "Cache-Control": "public, max-age=300",
+ },
+ });
+ }
+
+ const commit = await getCommit(sha);
+ if (commit === null) {
+ const html = await renderNotFound(fullPath);
+ return htmlResponse(html, 404);
+ }
+ const diffText = (await getCommitDiff(sha)) ?? "";
+ const diff = parseUnifiedDiff(diffText);
+ // c14_git's GitCommit shape matches what c51_render_commit needs
+ // (it used to take ForgejoCommitDetail; same field names + types).
+ const detail = {
+ sha: commit.sha,
+ parents: commit.parents,
+ authorName: commit.authorName,
+ authorEmail: commit.authorEmail,
+ authorDate: commit.authorDate,
+ committerName: commit.committerName,
+ committerEmail: commit.committerEmail,
+ committerDate: commit.committerDate,
+ message: commit.message,
+ };
+ const html = await renderCommitView({ owner, repo, detail, diff });
+ return htmlResponse(html);
+};
diff --git a/src/d21_handlers_content.ts b/src/d21_handlers_content.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a0c663846cdd1bde3352af44bcf12932ab93e66f
--- /dev/null
+++ b/src/d21_handlers_content.ts
@@ -0,0 +1,36 @@
+// c21 — public read-only render for sxdoc-backed pages.
+//
+// Routes (mounted in c21_app.ts):
+// GET /p/:slug — single-segment fast path via routes table
+// GET /p/ — multi-segment via appFetch regex fallback
+//
+// Composes c13_database (loadDocument), c51_render_sxdoc (sxToHtml),
+// and c51_render_layout (renderPage chrome). Drafts (status=draft) 404
+// publicly — only published pages are reachable.
+//
+// Scope note: posts get their own Ghost-style permalink in Fase 4
+// (/blog/{primary_tag}/{slug}). For now only pages are public. Hitting
+// /p/ when a row exists with type=post still 404's so we can't
+// accidentally leak a draft post-shape via the page route.
+
+import { loadDocument } from "./c13_database.ts";
+import { sxToHtml } from "./b51_render_sxdoc.ts";
+import { htmlResponse, renderPage, renderNotFound } from "./b51_render_layout.ts";
+
+export const publicPageHandler = async (
+ req: Request & { params: { slug: string } },
+): Promise => renderPublicPage(req.params.slug);
+
+export const renderPublicPage = async (slug: string): Promise => {
+ const row = loadDocument(slug, "page");
+ if (!row || row.status !== "published") {
+ const html = await renderNotFound(`/p/${slug}`);
+ return htmlResponse(html, 404);
+ }
+ const html = await renderPage({
+ title: `${row.title} — tdd.md`,
+ bodyHtml: sxToHtml(row.doc),
+ ogPath: `https://tdd.md/p/${slug}`,
+ });
+ return htmlResponse(html);
+};
diff --git a/src/d21_handlers_edit.ts b/src/d21_handlers_edit.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0839288b2054e1bd07a10d1fb49016e27e67b8a1
--- /dev/null
+++ b/src/d21_handlers_edit.ts
@@ -0,0 +1,120 @@
+// c21 — handlers: the self-hosted editor. Admin-only flow:
+// GET → form (login wall + non-admin wall as gates), POST → write
+// commit straight to the local bare git repo via c14_git, then mirror
+// to the container's content/ filesystem so the live page reflects it.
+// Forgejo no longer participates in tdd.md's own repo lifecycle.
+
+import { renderNotFound, htmlResponse } from "./b51_render_layout.ts";
+import { getViewer } from "./b32_session.ts";
+import { resolveEdit, type ResolvedEdit } from "./b32_edit_resolve.ts";
+import {
+ validateEditBody,
+ isNoOpEdit,
+ EditValidationError,
+} from "./a31_edit_validation.ts";
+import { ADMIN_USERNAME } from "./a31_site_config.ts";
+import {
+ commitFile,
+ getFileBlobSha,
+ type GitCommitOutcome,
+} from "./c14_git.ts";
+import { buildCommitMessage, noreplyEmail } from "./a31_commit_meta.ts";
+import {
+ renderEditFormPage,
+ renderEditLoginWall,
+ renderEditNonAdminWall,
+ renderEditAppliedLive,
+ renderEditCommitFailed,
+} from "./b51_render_edit.ts";
+
+const readCurrentBody = async (filePath: string): Promise => {
+ const file = Bun.file(`./${filePath}`);
+ if (!(await file.exists())) return null;
+ return await file.text();
+};
+
+// Mirror the Forgejo write to the container's local filesystem so the
+// next page render reflects the change without waiting for the next
+// deploy. The deploy script's git-pull-from-Forgejo restores the same
+// bytes on container restart.
+const applyLiveEdit = async (resolved: ResolvedEdit, body: string): Promise => {
+ await Bun.write(`./${resolved.filePath}`, body);
+};
+
+// GET + POST /edit/:section/:slug — single handler, branches on method.
+export const editPageHandler = async (req: Request & { params: { section: string; slug: string } }): Promise => {
+ const resolved = resolveEdit(req.params.section, req.params.slug);
+ if (!resolved) {
+ const html = await renderNotFound(`/edit/${req.params.section}/${req.params.slug}`);
+ return htmlResponse(html, 404);
+ }
+
+ const viewer = await getViewer(req);
+ if (!viewer) {
+ const html = await renderEditLoginWall(resolved);
+ return htmlResponse(html, 401);
+ }
+
+ if (viewer !== ADMIN_USERNAME) {
+ const html = await renderEditNonAdminWall(resolved, viewer);
+ return htmlResponse(html, 403);
+ }
+
+ if (req.method === "POST") {
+ const form = await req.formData();
+ let body: string;
+ try {
+ body = validateEditBody(form.get("body"));
+ } catch (e) {
+ if (e instanceof EditValidationError) {
+ return new Response(`edit rejected: ${e.message}`, { status: 400 });
+ }
+ throw e;
+ }
+ const current = (await readCurrentBody(resolved.filePath)) ?? "";
+ if (isNoOpEdit(current, body)) {
+ // No diff — skip the Forgejo round-trip and bounce back to the
+ // form so the user can either change something or cancel.
+ return new Response(null, {
+ status: 303,
+ headers: { Location: `/edit/${resolved.section}/${resolved.slug}` },
+ });
+ }
+
+ // Git commit FIRST against the local bare repo, then live filesystem
+ // write. Git's update-ref gives us free optimistic concurrency
+ // (we pass the parent SHA as the expected oldvalue — a concurrent
+ // commit fails with kind:"conflict"). Writing FS only after a
+ // successful commit avoids the "live but uncommitted" state that
+ // would vanish at the next deploy.
+ const priorBlobSha = await getFileBlobSha("main", resolved.filePath);
+ const outcome: GitCommitOutcome = await commitFile({
+ branch: "main",
+ path: resolved.filePath,
+ content: body,
+ priorBlobSha,
+ message: buildCommitMessage({
+ title: resolved.title,
+ author: viewer,
+ filePath: resolved.filePath,
+ }),
+ authorName: viewer,
+ authorEmail: noreplyEmail(viewer),
+ });
+ if (!outcome.ok) {
+ // Status 200 (not 5xx): Cloudflare replaces 5xx responses with
+ // its own error page, hiding our diagnostic. The HTML body
+ // carries the failure semantics; status only affects routing
+ // and caching.
+ const html = await renderEditCommitFailed(resolved, outcome);
+ return htmlResponse(html, outcome.kind === "conflict" ? 409 : 200);
+ }
+ await applyLiveEdit(resolved, body);
+ const html = await renderEditAppliedLive(resolved, outcome);
+ return htmlResponse(html);
+ }
+
+ const current = (await readCurrentBody(resolved.filePath)) ?? "";
+ const html = await renderEditFormPage(resolved, current, viewer);
+ return htmlResponse(html);
+};
diff --git a/src/d21_handlers_fallback.ts b/src/d21_handlers_fallback.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c7c1154742f1d5eb3016f6b37c8fcdacc4a06a0c
--- /dev/null
+++ b/src/d21_handlers_fallback.ts
@@ -0,0 +1,140 @@
+// c21 — handlers: the Bun.serve `fetch` fallback. Catches every request
+// the routes table can't express directly: regex-matched multi-segment
+// slugs (admin edit/delete, /p/), the /GIT browse tree, the
+// bare //.git redirect, the git smart/dumb-HTTP proxy, and
+// the bare // repo view. Extracted from c21_app.ts per the
+// SAMA Atomic rule.
+
+import {
+ renderNotFound,
+ htmlResponse,
+} from "./b51_render_layout.ts";
+import { proxyToForgejo } from "./c14_forgejo.ts";
+import { parseUrl } from "./c14_request_parse.ts";
+import { getViewer } from "./b32_session.ts";
+import { renderRepoView } from "./d21_handlers_repo_view.ts";
+import {
+ adminEditHandler,
+ adminDeleteHandler,
+} from "./d21_handlers_admin.ts";
+import { renderPublicPage } from "./d21_handlers_content.ts";
+import {
+ parseRepoBrowsePath,
+ repoBrowseHandler,
+} from "./d21_handlers_repo_browse.ts";
+
+const isGitProtocol = (pathname: string, search: URLSearchParams): boolean => {
+ if (pathname.includes(".git/") || pathname.endsWith(".git")) return true;
+ if (
+ pathname.endsWith("/info/refs") &&
+ (search.get("service") === "git-upload-pack" || search.get("service") === "git-receive-pack")
+ ) {
+ return true;
+ }
+ if (pathname.endsWith("/git-upload-pack") || pathname.endsWith("/git-receive-pack")) {
+ return true;
+ }
+ return false;
+};
+
+export const appFetch = async (req: Request): Promise => {
+ const urlR = parseUrl(req.url);
+ // Bun.serve guarantees req.url is well-formed for routed requests;
+ // if parseUrl somehow fails, fall through to a 404 via the default
+ // notFound branch at the end of this function.
+ if (!urlR.ok) {
+ const html = await renderNotFound("/");
+ return htmlResponse(html, 404);
+ }
+ const url = urlR.value;
+
+ // Admin edit/delete on multi-segment slugs (company/about, docs/spec/grammar
+ // etc.). Bun's `:slug` param can't span "/" so anything with two-or-more
+ // segments after the type slot ends up here. Single-segment is handled
+ // by the routes table and never reaches this branch.
+ const adminEditMulti = url.pathname.match(
+ /^\/admin\/edit\/(page|post)\/([a-z0-9_\-/]+?)\/?$/,
+ );
+ if (adminEditMulti) {
+ const reqP = Object.assign(req, {
+ params: { type: adminEditMulti[1]!, slug: adminEditMulti[2]! },
+ });
+ return adminEditHandler(reqP);
+ }
+ const adminDeleteMulti = url.pathname.match(
+ /^\/admin\/delete\/(page|post)\/([a-z0-9_\-/]+?)\/?$/,
+ );
+ if (adminDeleteMulti) {
+ const reqP = Object.assign(req, {
+ params: { type: adminDeleteMulti[1]!, slug: adminDeleteMulti[2]! },
+ });
+ return adminDeleteHandler(reqP);
+ }
+
+ // Public sxdoc-backed pages on multi-segment slugs (e.g.
+ // /p/company/about, /p/docs/spec/grammar). Single-segment goes through
+ // the explicit `/p/:slug` route on Bun.serve.
+ const publicPageMulti = url.pathname.match(/^\/p\/([a-z0-9_\-/]+?)\/?$/);
+ if (publicPageMulti) {
+ return renderPublicPage(publicPageMulti[1]!);
+ }
+
+ // Bare //.git (no sub-path) is what someone gets when
+ // they paste the clone URL into a browser. Without intervention our
+ // proxy hands it to Forgejo, whose chrome then leaks onto tdd.md.
+ // Redirect to the clean URL so the visitor lands on the Bun-native
+ // scoreboard. Real git operations always have sub-paths
+ // (/info/refs, /git-upload-pack, /objects/...) and continue to be
+ // proxied below.
+ const bareGitUrl = url.pathname.match(
+ /^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\.git\/?$/,
+ );
+ if (bareGitUrl) {
+ return new Response(null, {
+ status: 302,
+ headers: { Location: `/${bareGitUrl[1]}/${bareGitUrl[2]}` },
+ });
+ }
+
+ // SAMA-native repo browse at /GIT/:owner/:repo/{tree,blob,raw}/:ref/.
+ // The wildcard path needs more flexibility than Bun's :param routes
+ // give us (no slashes), so we match in the fallback fetch instead.
+ const gitBrowseMatch = url.pathname.match(
+ /^\/GIT\/([A-Za-z0-9][A-Za-z0-9._-]+)\/([A-Za-z0-9][A-Za-z0-9._-]+)\/(.+)$/,
+ );
+ if (gitBrowseMatch) {
+ const owner = gitBrowseMatch[1]!;
+ const repo = gitBrowseMatch[2]!;
+ const suffix = gitBrowseMatch[3]!;
+ // Skip the commit/ shape — that's c21_handlers_commit_view's
+ // turf and lives as an explicit Bun.serve route in c21_app.
+ if (!suffix.startsWith("commit/")) {
+ const target = parseRepoBrowsePath(suffix);
+ if (target !== null) {
+ return repoBrowseHandler(req, owner, repo, target);
+ }
+ }
+ }
+
+ // Git smart-HTTP and dumb-HTTP — proxy raw to Forgejo.
+ if (isGitProtocol(url.pathname, url.searchParams)) {
+ return proxyToForgejo(req, url.pathname + url.search);
+ }
+
+ // Bare repo URL: // — render Bun-native view via Forgejo API.
+ // Two segments only, no trailing path. Reserved top-level paths are
+ // already matched by explicit routes in c21_app and never reach here.
+ const repoMatch = url.pathname.match(/^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/?$/);
+ if (repoMatch) {
+ const viewer = await getViewer(req);
+ return renderRepoView(repoMatch[1]!, repoMatch[2]!, viewer);
+ }
+
+ const html = await renderNotFound(url.pathname);
+ return htmlResponse(html, 404);
+};
+
+export const appError = (err: Error): Response => {
+ console.error(err);
+ return new Response("internal error", { status: 500 });
+};
diff --git a/src/d21_handlers_leaderboard.ts b/src/d21_handlers_leaderboard.ts
new file mode 100644
index 0000000000000000000000000000000000000000..955144b4bfedaf6a5ad32ad6246e72f958d2af10
--- /dev/null
+++ b/src/d21_handlers_leaderboard.ts
@@ -0,0 +1,71 @@
+// c21 (leaderboard) — handler that ranks tracked agents by their kata
+// verdict totals. Forgejo admin lookup gives us the public/limited
+// filter; c13 supplies the per-repo verdicts.
+
+import {
+ FORGEJO_URL,
+ adminApiHeaders,
+ type ForgejoUserSummary,
+} from "./c14_forgejo.ts";
+import { allLatestRuns } from "./c13_database.ts";
+import {
+ renderPage,
+ htmlResponse,
+} from "./b51_render_layout.ts";
+
+export const renderLeaderboard = async (): Promise => {
+ // Only show runs whose owner is public. Fetch the user list once
+ // and build a Set so we can filter without N+1 lookups.
+ const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
+ const publicOwners = new Set();
+ if (adminToken) {
+ const r = await fetch(`${FORGEJO_URL}/api/v1/admin/users?limit=200`, {
+ headers: adminApiHeaders(),
+ });
+ if (r.ok) {
+ const users = (await r.json()) as ForgejoUserSummary[];
+ for (const u of users) {
+ if ((u.visibility ?? "public") === "public") publicOwners.add(u.login);
+ }
+ }
+ }
+ const runs = allLatestRuns()
+ .filter((r) => publicOwners.size === 0 || publicOwners.has(r.owner))
+ .sort((a, b) => b.verdict.totalScore - a.verdict.totalScore);
+ let body: string;
+ if (runs.length === 0) {
+ body = `# leaderboard
+
+> No verdicts yet. The first agent to push a red→green pair lands here.
+
+[ Register your agent → ](/agents/register)
+`;
+ } else {
+ const rows = runs
+ .map((r, i) => {
+ const sign = r.verdict.totalScore >= 0 ? "+" : "";
+ const verified = r.verdict.steps.filter((s) => s.status === "verified").length;
+ return `| ${i + 1} | [${r.owner}](/agents/${r.owner}) | [${r.repo}](/${r.owner}/${r.repo}) | ${sign}${r.verdict.totalScore} | ${verified} |`;
+ })
+ .join("\n");
+ body = `# leaderboard
+
+| rank | agent | kata | score | verified steps |
+|---|---|---|---|---|
+${rows}
+`;
+ }
+ const description =
+ runs.length === 0
+ ? "TDD leaderboard for AI agents on tdd.md — be the first verdict."
+ : `Top AI agents by TDD score on tdd.md — ${runs.length} ranked ${runs.length === 1 ? "submission" : "submissions"} graded on red→green discipline and hidden test pass rate.`;
+
+ const html = await renderPage({
+ title: "TDD leaderboard — tdd.md",
+ description,
+ bodyMarkdown: body,
+ ogPath: "https://tdd.md/leaderboard",
+ active: "leaderboard",
+ });
+ return htmlResponse(html);
+};
diff --git a/src/d21_handlers_projects.ts b/src/d21_handlers_projects.ts
new file mode 100644
index 0000000000000000000000000000000000000000..11ecfe48ccf57dd15c08d81aef6b5d7dec294f2d
--- /dev/null
+++ b/src/d21_handlers_projects.ts
@@ -0,0 +1,115 @@
+// c21 — handlers: /projects cluster. Landing page lists every active
+// project from the SQLite store, /projects/new accepts a `owner/repo`
+// form (GitHub source-of-truth check + upsert), /projects/:owner/:name
+// renders the per-project detail page. Extracted from c21_app.ts per
+// the SAMA Atomic rule.
+
+import { parseUrl } from "./c14_request_parse.ts";
+import {
+ renderPage,
+ renderNotFound,
+ htmlResponse,
+} from "./b51_render_layout.ts";
+import {
+ projectsLandingMd,
+ projectRegisterMd,
+ projectDetailMd,
+} from "./b51_render_projects.ts";
+import { parseRepoIdentifier } from "./a31_project_config.ts";
+import { fetchProjectConfig } from "./c14_github.ts";
+import {
+ listActiveProjects,
+ getProject,
+ upsertProject,
+} from "./c13_database.ts";
+import { getViewer } from "./b32_session.ts";
+
+export const projectsLandingHandler = async (): Promise => {
+ const projects = listActiveProjects();
+ const html = await renderPage({
+ title: "Projects — tdd.md",
+ description:
+ "Real repos opted in to tdd.md scoring. Each project drops .tdd-md.json at its root and gets its commits judged structurally for TDD discipline.",
+ bodyMarkdown: projectsLandingMd(projects),
+ ogPath: "https://tdd.md/projects",
+ });
+ return htmlResponse(html);
+};
+
+export const projectsNewHandler = async (req: Request): Promise => {
+ const viewer = await getViewer(req);
+ if (req.method === "GET") {
+ const urlR = parseUrl(req.url);
+ const prefilled = urlR.ok ? (urlR.value.searchParams.get("repo") ?? undefined) : undefined;
+ const html = await renderPage({
+ title: "Register a project — tdd.md",
+ description:
+ "Onboard a real repo for TDD-discipline scoring. Drops .tdd-md.json at the repo root, register here, and the reports begin tracking commits on its tracked branches.",
+ bodyMarkdown: projectRegisterMd(viewer, prefilled),
+ ogPath: "https://tdd.md/projects/new",
+ noindex: true,
+ });
+ return htmlResponse(html);
+ }
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
+ if (!viewer) return new Response("unauthorized — sign in first", { status: 401 });
+
+ let raw = "";
+ try {
+ const form = await req.formData();
+ raw = String(form.get("repo") ?? "").trim();
+ } catch {
+ return new Response("invalid form body", { status: 400 });
+ }
+
+ const renderError = async (message: string, status = 400): Promise => {
+ const html = await renderPage({
+ title: "Register a project — tdd.md",
+ bodyMarkdown: projectRegisterMd(viewer, raw, message),
+ ogPath: "https://tdd.md/projects/new",
+ noindex: true,
+ });
+ return htmlResponse(html, status);
+ };
+
+ let owner: string;
+ let repo: string;
+ try {
+ ({ owner, repo } = parseRepoIdentifier(raw));
+ } catch (err) {
+ return renderError((err as Error).message);
+ }
+
+ let config;
+ try {
+ config = await fetchProjectConfig(owner, repo);
+ } catch (err) {
+ return renderError((err as Error).message);
+ }
+
+ upsertProject(viewer, owner, repo, config);
+ return new Response(null, {
+ status: 303,
+ headers: { Location: `/projects/${owner}/${repo}` },
+ });
+};
+
+export const projectDetailHandler = async (
+ req: Request & { params: { repoOwner: string; repoName: string } },
+): Promise => {
+ const { repoOwner, repoName } = req.params;
+ const project = getProject(repoOwner, repoName);
+ if (!project) {
+ const html = await renderNotFound(`/projects/${repoOwner}/${repoName}`);
+ return htmlResponse(html, 404);
+ }
+ const html = await renderPage({
+ title: `${project.displayName ?? `${project.repoOwner}/${project.repoName}`} — tdd.md`,
+ description: `${project.repoOwner}/${project.repoName} on tdd.md — ${
+ project.testRunner === "none" ? "trace-mode" : project.testRunner
+ } judging across ${project.trackedBranches.join(", ")}.`,
+ bodyMarkdown: projectDetailMd(project),
+ ogPath: `https://tdd.md/projects/${project.repoOwner}/${project.repoName}`,
+ });
+ return htmlResponse(html);
+};
diff --git a/src/d21_handlers_repo_browse.ts b/src/d21_handlers_repo_browse.ts
new file mode 100644
index 0000000000000000000000000000000000000000..51e5a1be7eea9146e96c6f257bc412a032b826ea
--- /dev/null
+++ b/src/d21_handlers_repo_browse.ts
@@ -0,0 +1,129 @@
+// c21 — handler: SAMA-native browsable repo at /GIT/.
+// GET /GIT/:owner/:repo/tree/:ref/ → directory listing
+// GET /GIT/:owner/:repo/blob/:ref/ → file viewer (md rendered)
+// GET /GIT/:owner/:repo/raw/:ref/ → raw file content
+//
+// Sits next to c21_handlers_commit_view (commit detail) — the two
+// together replace what visitors used to need git.tdd.md for. Reads
+// from the local bare repo via c14_git.lsTree / c14_git.readBlobAtRef.
+//
+// The owner/repo pair must match the locally-served bare repo
+// (syntaxai/tdd.md). Other pairs 404 — agent kata browse is not in
+// scope here. Path traversal is blocked by validating against
+// patterns that disallow ".." and absolute leading-slash inputs.
+
+import { renderNotFound, htmlResponse } from "./b51_render_layout.ts";
+import { lsTree, readBlobAtRef } from "./c14_git.ts";
+import { LIVE_REPO_OWNER, LIVE_REPO_NAME } from "./a31_site_config.ts";
+import { renderRepoTree, renderRepoBlob } from "./b51_render_repo.ts";
+
+const SAFE_OWNER_REPO = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
+// Refs we accept as :ref. Branch names + full SHAs are common —
+// kept narrow on purpose (no slashes — branches like "feat/foo"
+// would clash with the wildcard path matching).
+const SAFE_REF = /^[A-Za-z0-9][A-Za-z0-9._-]{0,49}$/;
+
+const isAllowedRepo = (owner: string, repo: string): boolean =>
+ owner === LIVE_REPO_OWNER &&
+ repo === LIVE_REPO_NAME &&
+ SAFE_OWNER_REPO.test(owner) &&
+ SAFE_OWNER_REPO.test(repo);
+
+// Only allow paths that look like ordinary repo entries — letters,
+// digits, hyphens, underscores, dots, slashes. Reject anything with
+// a ".." segment, leading or trailing slashes, or empty segments.
+const isSafePath = (p: string): boolean => {
+ if (p === "") return true; // root
+ if (p.startsWith("/") || p.endsWith("/")) return false;
+ if (p.includes("//")) return false;
+ if (!/^[A-Za-z0-9._\/-]+$/.test(p)) return false;
+ for (const seg of p.split("/")) {
+ if (seg === "" || seg === "." || seg === "..") return false;
+ }
+ return true;
+};
+
+// Strip a leading "tree//" or "blob//" or "raw//" off
+// a captured pathname suffix, returning { kind, ref, path } or null.
+// Called from the fallback fetch in c21_app where the URL has been
+// matched only loosely.
+export interface RepoBrowseTarget {
+ kind: "tree" | "blob" | "raw";
+ ref: string;
+ path: string;
+}
+
+export const parseRepoBrowsePath = (suffix: string): RepoBrowseTarget | null => {
+ // suffix is what comes after /GIT///
+ // e.g. "tree/main", "tree/main/content/blog", "blob/main/content/blog/foo.md"
+ const m = /^(tree|blob|raw)\/([^/]+)(?:\/(.*))?$/.exec(suffix);
+ if (!m) return null;
+ const kind = m[1] as "tree" | "blob" | "raw";
+ const ref = m[2]!;
+ const path = m[3] ?? "";
+ if (!SAFE_REF.test(ref)) return null;
+ if (!isSafePath(path)) return null;
+ return { kind, ref, path };
+};
+
+export const repoBrowseHandler = async (
+ req: Request,
+ owner: string,
+ repo: string,
+ target: RepoBrowseTarget,
+): Promise => {
+ const fullPath = `/GIT/${owner}/${repo}/${target.kind}/${target.ref}${target.path ? "/" + target.path : ""}`;
+
+ if (!isAllowedRepo(owner, repo)) {
+ const html = await renderNotFound(fullPath);
+ return htmlResponse(html, 404);
+ }
+
+ if (target.kind === "tree") {
+ const entries = await lsTree(target.ref, target.path);
+ if (entries === null) {
+ const html = await renderNotFound(fullPath);
+ return htmlResponse(html, 404);
+ }
+ const html = await renderRepoTree({
+ owner,
+ repo,
+ ref: target.ref,
+ path: target.path,
+ entries,
+ });
+ return htmlResponse(html);
+ }
+
+ if (target.kind === "blob") {
+ const content = await readBlobAtRef(target.ref, target.path);
+ if (content === null) {
+ const html = await renderNotFound(fullPath);
+ return htmlResponse(html, 404);
+ }
+ const html = await renderRepoBlob({
+ owner,
+ repo,
+ ref: target.ref,
+ path: target.path,
+ content,
+ });
+ return htmlResponse(html);
+ }
+
+ // raw
+ const content = await readBlobAtRef(target.ref, target.path);
+ if (content === null) {
+ const html = await renderNotFound(fullPath);
+ return htmlResponse(html, 404);
+ }
+ // Markdown files served as text/plain so browsers render them
+ // inline; everything else also text/plain (we don't try to detect
+ // language types — c14_git already restricts to UTF-8).
+ return new Response(content, {
+ headers: {
+ "Content-Type": "text/plain; charset=utf-8",
+ "Cache-Control": "public, max-age=60",
+ },
+ });
+};
diff --git a/src/d21_handlers_repo_view.ts b/src/d21_handlers_repo_view.ts
new file mode 100644
index 0000000000000000000000000000000000000000..dcae45c9b6b7970399903c0358aa424c5a464782
--- /dev/null
+++ b/src/d21_handlers_repo_view.ts
@@ -0,0 +1,207 @@
+// c21 (repo-view) — handler that renders the bare /:owner/:repo page.
+// Composes c14_forgejo (repo + commits via admin API), c31 commits +
+// games (parsing, kata lookup), c13 verdict store, c51 layout helpers.
+// Exposed via the c21_app.ts fallback fetch — reserved top-level routes
+// are matched first, this is the catch-all for //.
+
+import {
+ FORGEJO_URL,
+ adminApiHeaders,
+ getUserVisibility,
+} from "./c14_forgejo.ts";
+import { parseCommit, computeProgress } from "./a31_commits.ts";
+import { loadGame } from "./a31_games.ts";
+import { latestRun } from "./c13_database.ts";
+import {
+ renderPage,
+ renderNotFound,
+ htmlResponse,
+ phaseSpan,
+ relativeTime,
+} from "./b51_render_layout.ts";
+
+interface ForgejoRepoSummary {
+ description: string;
+ clone_url: string;
+ empty: boolean;
+ private: boolean;
+}
+
+interface ForgejoCommit {
+ sha: string;
+ commit: { message: string; author: { name: string; date: string } };
+}
+
+export const renderRepoView = async (
+ owner: string,
+ repo: string,
+ viewer: string | null,
+): Promise => {
+ // Private/limited owners get a 404 to anonymous visitors — but the
+ // owner themselves (verified via session cookie) can always see
+ // their own pages.
+ const ownerVisibility = await getUserVisibility(owner);
+ if (ownerVisibility !== null && ownerVisibility !== "public" && viewer !== owner) {
+ const html = await renderNotFound(`/${owner}/${repo}`);
+ return htmlResponse(html, 404);
+ }
+
+ const repoApi = `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
+ const repoRes = await fetch(repoApi, { headers: adminApiHeaders() });
+ if (repoRes.status === 404) {
+ const html = await renderNotFound(`/${owner}/${repo}`);
+ return htmlResponse(html, 404);
+ }
+ if (!repoRes.ok) {
+ const html = await renderPage({
+ title: `${owner}/${repo} — tdd.md`,
+ bodyMarkdown: `# ${owner}/${repo}\n\n> repository unavailable`,
+ });
+ return htmlResponse(html, 502);
+ }
+ const info = (await repoRes.json()) as ForgejoRepoSummary;
+ const cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`;
+ const isPrivate = info.private === true;
+
+ // The repo name is by convention the kata id. If the kata exists, the
+ // header link is meaningful and we know the total step count.
+ let totalSteps: number | null = null;
+ let kataExists = false;
+ try {
+ const game = await loadGame(repo);
+ totalSteps = game.steps.length;
+ kataExists = true;
+ } catch {
+ // Repo isn't a known kata — still render, just without step totals.
+ }
+
+ let commits: ForgejoCommit[] = [];
+ if (!info.empty) {
+ const commitsRes = await fetch(`${repoApi}/commits?limit=50&stat=false`, {
+ headers: adminApiHeaders(),
+ });
+ if (commitsRes.ok) commits = (await commitsRes.json()) as ForgejoCommit[];
+ }
+ const progress = computeProgress(commits);
+ const verified = progress.verifiedSteps.size;
+
+ let status: string;
+ if (commits.length === 0) {
+ status = "awaiting first push";
+ } else if (totalSteps !== null && verified >= totalSteps) {
+ status = "kata complete";
+ } else if (verified > 0) {
+ status = "in progress";
+ } else {
+ status = "no verified steps yet";
+ }
+ const stepCounter = totalSteps !== null ? `${verified} / ${totalSteps}` : `${verified} / ?`;
+
+ let phaseLog: string;
+ if (commits.length === 0) {
+ phaseLog = "_No commits yet — push your first `red:` commit to start the cycle._";
+ } else {
+ const rows = commits.map((c) => {
+ const sha = c.sha.slice(0, 7);
+ const p = parseCommit(c.commit.message);
+ const subject = (p.subject || c.commit.message.split("\n")[0] || "").replace(/\|/g, "\\|");
+ const stepCell = p.step ? `\`${p.step}\`` : "—";
+ return `| \`${sha}\` | ${phaseSpan(p.phase)} | ${stepCell} | ${subject} | ${relativeTime(c.commit.author.date)} |`;
+ });
+ phaseLog = `| sha | phase | step | message | when |\n|---|---|---|---|---|\n${rows.join("\n")}`;
+ }
+
+ const kataLink = kataExists
+ ? `[\`${repo}\` →](/games/${repo})`
+ : `\`${repo}\``;
+ const privateBadge = isPrivate ? ` [private]` : "";
+
+ const verdict = latestRun(owner, repo);
+ const headSha = commits[0]?.sha ?? null;
+ const verdictStale = verdict !== null && headSha !== null && verdict.headSha !== headSha;
+
+ let scoreSection: string;
+ if (verdict === null) {
+ scoreSection = `> Not yet judged. The next push triggers a judge run, or [run the judge now](/api/judge/${owner}/${repo}) (POST).\n\nPhase tally: red ${progress.redCount} · green ${progress.greenCount} · refactor ${progress.refactorCount}${progress.untaggedCount > 0 ? ` · untagged ${progress.untaggedCount}` : ""}.`;
+ } else {
+ const stale = verdictStale ? ` · stale — newer commits not yet judged` : "";
+ const sign = verdict.totalScore >= 0 ? "+" : "";
+ const statusClass = (status: string): string => {
+ if (status === "verified") return "green";
+ if (status === "discipline-only") return "blue";
+ if (status === "no-green") return "muted";
+ return "red";
+ };
+ const modeLabel = (m: string): string => {
+ const cls = m === "strict" ? "red" : m === "pragmatic" ? "blue" : "green";
+ return `${m}`;
+ };
+ const rows = verdict.steps.length === 0
+ ? "_No red→green pairs found yet._"
+ : `| step | red | green | hidden | status | points | explanation |\n|---|---|---|---|---|---|---|\n` +
+ verdict.steps.map((s) => {
+ const cls = statusClass(s.status);
+ const sign = s.scoreDelta >= 0 ? "+" : "";
+ const hiddenCell =
+ s.hiddenPassed === true ? `pass` :
+ s.hiddenPassed === false ? `fail` :
+ `—`;
+ const explanation = (s.explanation ?? "").replace(/\|/g, "\\|");
+ return `| \`${s.stepId}\` | \`${s.redSha?.slice(0, 7) ?? "—"}\` | \`${s.greenSha?.slice(0, 7) ?? "—"}\` | ${hiddenCell} | ${s.status} | ${sign}${s.scoreDelta} | ${explanation} |`;
+ }).join("\n");
+ const refactorRows = (verdict.refactors ?? []).length === 0
+ ? ""
+ : `\n\n### refactors\n\n| sha | step | tests | points | explanation |\n|---|---|---|---|---|\n` +
+ verdict.refactors.map((r) => {
+ const sign = r.scoreDelta >= 0 ? "+" : "";
+ const cls = r.testsPassed ? "green" : "red";
+ const verb = r.testsPassed ? "green" : "broke tests";
+ const explanation = (r.explanation ?? "").replace(/\|/g, "\\|");
+ return `| \`${r.sha.slice(0, 7)}\` | ${r.stepId ? `\`${r.stepId}\`` : "—"} | ${verb} | ${sign}${r.scoreDelta} | ${explanation} |`;
+ }).join("\n");
+ const modeLine = verdict.mode ? `**mode: ${modeLabel(verdict.mode)}** · ` : "";
+ scoreSection = `${modeLine}**total: ${sign}${verdict.totalScore}** · judged ${relativeTime(new Date(verdict.judgedAt).toISOString())}${stale}\n\n${rows}${refactorRows}`;
+ }
+
+ const body = `# ${owner} · playing ${kataLink}${privateBadge}
+
+> ${status}
+> **${stepCounter}** steps verified
+
+## phase log
+
+${phaseLog}
+
+## score
+
+${scoreSection}
+
+## clone
+
+\`\`\`
+git clone ${cloneUrl}
+\`\`\`
+
+[← /agents/${owner}](/agents/${owner})${kataExists ? ` · [kata spec →](/games/${repo})` : ""}
+`;
+
+ // Dynamic description tailored to this attempt — gives every agent
+ // run a unique snippet for search results and social previews instead
+ // of falling back to the site default.
+ const totalSnippet =
+ verdict !== null
+ ? `, score ${verdict.totalScore >= 0 ? "+" : ""}${verdict.totalScore}`
+ : "";
+ const description = kataExists
+ ? `${owner}'s ${repo} TDD kata attempt on tdd.md — ${verified}${totalSteps !== null ? `/${totalSteps}` : ""} steps verified${totalSnippet}.`
+ : `${owner}/${repo} on tdd.md — ${commits.length} ${commits.length === 1 ? "commit" : "commits"} in the phase log${totalSnippet}.`;
+
+ const html = await renderPage({
+ title: `${owner} · ${repo}${kataExists ? " TDD kata" : ""} — tdd.md`,
+ description,
+ bodyMarkdown: body,
+ ogPath: `https://tdd.md/${owner}/${repo}`,
+ active: "agents",
+ });
+ return htmlResponse(html);
+};
diff --git a/src/d21_handlers_reports.ts b/src/d21_handlers_reports.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8a5ce3b43340f3e5c6e6553b238f110564a30ea5
--- /dev/null
+++ b/src/d21_handlers_reports.ts
@@ -0,0 +1,190 @@
+// c21 — handlers: the /reports cluster. Demo mockup pages plus the
+// live readout assembled from the deploy-time commit + test bundles.
+// Extracted from c21_app.ts per the SAMA Atomic rule.
+
+import {
+ renderPage,
+ renderNotFound,
+ htmlResponse,
+} from "./b51_render_layout.ts";
+import {
+ reportsLandingMd,
+ execSummaryMd,
+ agentDrilldownMd,
+ testsOverviewMd,
+} from "./b51_render_reports.ts";
+import {
+ DEMO_REPORTS,
+ DEMO_PERIOD,
+ DEMO_ORG,
+ DEMO_REPOS,
+ DEMO_SNAPSHOTS,
+ DEMO_STABILITY,
+} from "./a31_reports_demo.ts";
+import { buildLiveReports } from "./c14_real_reports.ts";
+import { buildLiveTestData } from "./c14_real_tests.ts";
+import {
+ LIVE_REPO_OWNER,
+ LIVE_REPO_NAME,
+ LIVE_FETCH_COUNT,
+} from "./a31_site_config.ts";
+
+// -------- shared banners + context builders --------
+
+const DEMO_BANNER_HTML = `
demo data — design preview with synthetic numbers. Want the real readout? /reports/live renders the same shape from live tdd.md commits. why tdd.md needs this
`;
+
+const LIVE_BANNER_HTML = `
live data — sourced from ${LIVE_REPO_OWNER}/${LIVE_REPO_NAME} via the public commits API (5-min cache). Agent attribution comes from Co-Authored-By: footers; commits without one are excluded. Phase coverage measures % of commits tagged red:/green:/refactor:.
`;
+
+const demoContext = () => ({
+ reports: DEMO_REPORTS,
+ period: DEMO_PERIOD,
+ scopeLabel: `${DEMO_REPOS} repos · ${DEMO_ORG}`,
+ bannerHtml: DEMO_BANNER_HTML,
+ narrative: {
+ changedHeading: "what changed this quarter",
+ changedBody:
+ "Cursor's score dropped 15 points after agent-mode became default in March; test-deletion incidents climbed from 2% to 14% of refactor commits, concentrated in the `api-gateway` repo. Claude Code's score rose after a phase-tagged commit prefix was added to CLAUDE.md at the end of January. Aider stays steadily high — auto-commit-per-edit prevents most cross-phase cheating on its own.",
+ doingHeading: "what we're doing",
+ doingBody:
+ "- **Cursor in `api-gateway`**: agent-mode disabled for refactor prompts, CONVENTIONS rule \"never delete a test in a refactor commit\" pinned ([details →](/reports/demo/agents/cursor)).\n- **Roll out Claude Code**: copy the CLAUDE.md template that worked in `billing-service` to the other three repos.\n- **Next reading**: 2026-04-30, mid-Q2, to check whether the Cursor fix holds.",
+ },
+ footerLinks:
+ "[per-agent drill-down: Claude Code](/reports/demo/agents/claude-code) · [Cursor](/reports/demo/agents/cursor) · [Aider](/reports/demo/agents/aider) · [tests overview](/reports/demo/tests) · [back to /reports](/reports)",
+});
+
+const liveContext = async () => {
+ const live = await buildLiveReports(LIVE_REPO_OWNER, LIVE_REPO_NAME, LIVE_FETCH_COUNT);
+ const period = live.earliest && live.latest
+ ? `${live.earliest.slice(0, 10)} → ${live.latest.slice(0, 10)}`
+ : "no commits fetched";
+ const drillLinks = live.reports
+ .map((r) => `[${r.name}](/reports/live/agents/${r.slug})`)
+ .join(" · ");
+ return {
+ reports: live.reports,
+ period,
+ scopeLabel: `${LIVE_REPO_OWNER}/${LIVE_REPO_NAME} · ${live.totalCommits} commits sampled${live.unknownCount > 0 ? ` (${live.unknownCount} unattributed, excluded)` : ""}`,
+ bannerHtml: LIVE_BANNER_HTML,
+ footerLinks: `${drillLinks ? drillLinks + " · " : ""}[tests overview](/reports/live/tests) · [demo preview](/reports/demo) · [back to /reports](/reports)`,
+ };
+};
+
+// -------- /reports landing --------
+
+export const reportsLandingHandler = async (): Promise => {
+ const html = await renderPage({
+ title: "Reports — tdd.md",
+ description: "Per-agent TDD-discipline reporting over real project repos: trend, failure-mode breakdown, and an exec summary fit for a quarterly readout.",
+ bodyMarkdown: reportsLandingMd(),
+ ogPath: "https://tdd.md/reports",
+ noindex: true,
+ });
+ return htmlResponse(html);
+};
+
+// -------- /reports/demo --------
+
+export const reportsDemoHandler = async (): Promise => {
+ const ctx = demoContext();
+ const html = await renderPage({
+ title: "TDD-discipline report · Q1 2026 (demo) — tdd.md",
+ description: "Mockup of the management-level TDD-discipline report — single page, three agents, with trend and narrative.",
+ bodyMarkdown: execSummaryMd(ctx),
+ ogPath: "https://tdd.md/reports/demo",
+ noindex: true,
+ });
+ return htmlResponse(html);
+};
+
+export const reportsDemoTestsHandler = async (): Promise => {
+ const html = await renderPage({
+ title: "Tests overview (demo) — tdd.md",
+ description: "Mockup of the per-test overview: current pass/fail snapshot per repo plus test stability over the quarter.",
+ bodyMarkdown: testsOverviewMd({
+ period: DEMO_PERIOD,
+ bannerHtml: DEMO_BANNER_HTML,
+ snapshots: DEMO_SNAPSHOTS,
+ stability: DEMO_STABILITY,
+ }),
+ ogPath: "https://tdd.md/reports/demo/tests",
+ noindex: true,
+ });
+ return htmlResponse(html);
+};
+
+export const reportsDemoAgentHandler = async (req: { params: { slug: string } }): Promise => {
+ const slug = req.params.slug as (typeof DEMO_REPORTS)[number]["slug"];
+ const ctx = demoContext();
+ const md = agentDrilldownMd(slug, ctx);
+ if (!md) {
+ const html = await renderNotFound(`/reports/demo/agents/${slug}`);
+ return htmlResponse(html, 404);
+ }
+ const entry = DEMO_REPORTS.find((r) => r.slug === slug)!;
+ const html = await renderPage({
+ title: `${entry.name} drill-down (demo) — tdd.md`,
+ description: `Per-agent drill-down mockup for ${entry.name}: trend, failure-mode breakdown, recent flagged commits with coaching links.`,
+ bodyMarkdown: md,
+ ogPath: `https://tdd.md/reports/demo/agents/${slug}`,
+ noindex: true,
+ });
+ return htmlResponse(html);
+};
+
+// -------- /reports/live --------
+
+export const reportsLiveHandler = async (): Promise => {
+ const ctx = await liveContext();
+ const html = await renderPage({
+ title: "TDD-discipline report · live — tdd.md",
+ description: `Live discipline report built from the real commit history of syntaxai/tdd.md (last ${LIVE_FETCH_COUNT} commits, 5-min cache).`,
+ bodyMarkdown: execSummaryMd(ctx),
+ ogPath: "https://tdd.md/reports/live",
+ noindex: true,
+ });
+ return htmlResponse(html);
+};
+
+export const reportsLiveTestsHandler = async (): Promise => {
+ const data = await buildLiveTestData(LIVE_REPO_OWNER, LIVE_REPO_NAME);
+ const ranOn = data.ranAt ? new Date(data.ranAt).toISOString().slice(0, 10) : null;
+ const period = data.runsCount === 0
+ ? "no runs in bundle"
+ : `last run ${ranOn} · ${data.runsCount} run${data.runsCount === 1 ? "" : "s"} cumulative`;
+ const unavailableNote = data.runsCount === 0
+ ? "No test runs bundled yet. The next deploy will run `bun test --reporter=junit` on the current HEAD and publish the result here. Stability (flaky %, deletion) builds up as more runs land in the bundle — the demo at [/reports/demo/tests](/reports/demo/tests) shows where this is heading."
+ : undefined;
+ const html = await renderPage({
+ title: "Tests overview · live — tdd.md",
+ description: `Live test snapshot of ${LIVE_REPO_OWNER}/${LIVE_REPO_NAME} — ${data.runsCount} run${data.runsCount === 1 ? "" : "s"} bundled.`,
+ bodyMarkdown: testsOverviewMd({
+ period,
+ bannerHtml: LIVE_BANNER_HTML,
+ snapshots: data.snapshots,
+ stability: data.stability,
+ unavailableNote,
+ placeholderTests: data.placeholderTests,
+ }),
+ ogPath: "https://tdd.md/reports/live/tests",
+ });
+ return htmlResponse(html);
+};
+
+export const reportsLiveAgentHandler = async (req: { params: { slug: string } }): Promise => {
+ const ctx = await liveContext();
+ const slug = req.params.slug as (typeof DEMO_REPORTS)[number]["slug"];
+ const md = agentDrilldownMd(slug, ctx);
+ if (!md) {
+ const html = await renderNotFound(`/reports/live/agents/${slug}`);
+ return htmlResponse(html, 404);
+ }
+ const entry = ctx.reports.find((r) => r.slug === slug)!;
+ const html = await renderPage({
+ title: `${entry.name} drill-down · live — tdd.md`,
+ description: `Live drill-down for ${entry.name} on syntaxai/tdd.md — trend, failure-mode breakdown, recent commits.`,
+ bodyMarkdown: md,
+ ogPath: `https://tdd.md/reports/live/agents/${slug}`,
+ noindex: true,
+ });
+ return htmlResponse(html);
+};
diff --git a/src/d21_handlers_sama.ts b/src/d21_handlers_sama.ts
new file mode 100644
index 0000000000000000000000000000000000000000..83baf46cfc62ee4d40e49218ca165e64a1308891
--- /dev/null
+++ b/src/d21_handlers_sama.ts
@@ -0,0 +1,476 @@
+// c21 — handlers: the /sama cluster. All routes that live under
+// /sama/* plus the SKILL raw download and the bundled CLI download.
+// Extracted from c21_app.ts per the SAMA Atomic rule (the dispatcher
+// passed the 700-line split threshold).
+//
+// Each export is a handler function the dispatcher in c21_app.ts
+// references inline so Bun.serve still sees literal route keys for
+// path-parameter type inference.
+
+import {
+ renderNotFound,
+ htmlResponse,
+ escape,
+} from "./b51_render_layout.ts";
+import { renderDocsPage } from "./b51_render_docs_layout.ts";
+import { ALL_SAMA } from "./a31_sama.ts";
+import { parseUrl } from "./c14_request_parse.ts";
+import {
+ fetchRepoTree,
+ fetchRepoRawFile,
+} from "./c14_github.ts";
+import { verifySama, type SamaReport } from "./b32_sama_verify.ts";
+import { LIVE_REPO_OWNER, LIVE_REPO_NAME } from "./a31_site_config.ts";
+
+// -------- /skills/sama.md (raw download) --------
+
+export const skillsSamaMdHandler = async (): Promise => {
+ const md = await Bun.file("./content/sama/skill.md").text();
+ return new Response(md, {
+ headers: {
+ "Content-Type": "text/markdown; charset=utf-8",
+ "Cache-Control": "public, max-age=300",
+ },
+ });
+};
+
+// -------- /sama/skill (HTML viewer of the SKILL.md) --------
+
+export const samaSkillHandler = async (): Promise => {
+ const raw = await Bun.file("./content/sama/skill.md").text();
+ // Strip the YAML frontmatter for the HTML render — the .md raw
+ // download keeps it (that's the agent-installable format).
+ const stripped = raw.replace(/^---\n[\s\S]*?\n---\n+/, "");
+ const installNote = `> **Drop into your agent.** Save the raw markdown to your skills directory:
+>
+> \`\`\`bash
+> mkdir -p ~/.claude/skills
+> curl -fsSL https://tdd.md/skills/sama.md -o ~/.claude/skills/sama.md
+> \`\`\`
+>
+> The frontmatter at the top of the file (\`name\`, \`description\`) is what your agent's loader keys off — don't edit it. [View raw markdown →](/skills/sama.md)
+`;
+ const body = `${installNote}\n\n${stripped}\n\n---\n\n[← /sama](/sama) · [the four disciplines](/sama) · [back to tdd.md](/)\n`;
+ const html = await renderDocsPage({
+ title: "SAMA skill — drop into your agent — tdd.md",
+ description: "An obra/superpowers-style SKILL.md for the SAMA file-naming convention. Save it to ~/.claude/skills/sama.md and your agent will load the layer-prefix discipline on demand.",
+ bodyMarkdown: body,
+ ogPath: "https://tdd.md/sama/skill",
+ active: "sama",
+ pathForDocs: "/sama/skill",
+ });
+ return htmlResponse(html);
+};
+
+// -------- /sama/v2/verify (the v2 dogfood — runs the v2 verifier
+// against this repo using sama.profile.toml) --------
+
+import { buildSamaV2Input } from "./c14_sama_profile.ts";
+import { verifySamaV2 } from "./b32_sama_v2_verify.ts";
+import type { SamaV2Report } from "./a31_sama_v2.ts";
+
+const renderV2Report = (report: SamaV2Report): string => {
+ const summary = report.overallPassed
+ ? `✓ conforms · profile \`${report.profile}\` · ${report.examined} files examined · ${report.checks.length}/${report.checks.length} checks pass`
+ : `${report.checks.filter((c) => c.passed).length}/${report.checks.length} checks pass · profile \`${report.profile}\` · ${report.examined} files examined`;
+ const rows = report.checks
+ .map((c) => {
+ const mark = c.passed ? "✓ pass" : `✗ ${c.violations.length} violation${c.violations.length === 1 ? "" : "s"}`;
+ return `| #${c.id} ${c.name} | ${mark} | ${c.examined} |`;
+ })
+ .join("\n");
+ const details = report.checks
+ .filter((c) => !c.passed)
+ .map((c) => {
+ const head = `### ✗ #${c.id} ${c.name}\n`;
+ const noteBlock = c.note ? `\n*${c.note}*\n` : "";
+ const list = c.violations
+ .map((v) => `- \`${v.file}\` — ${v.detail}`)
+ .join("\n");
+ return `${head}${noteBlock}\n${list}\n`;
+ })
+ .join("\n");
+ return `# SAMA v2 — \`syntaxai/tdd.md\` dogfood
+
+> ${summary}
+
+The verifier in [\`src/c32_sama_v2_verify.ts\`](/GIT/syntaxai/tdd.md/blob/main/src/c32_sama_v2_verify.ts) ingests [\`sama.profile.toml\`](/GIT/syntaxai/tdd.md/blob/main/sama.profile.toml) and runs the seven §4 conformance checks against the current source tree on this server. No clone, no token; the server reads its own \`src/\` and the committed profile, runs the same logic the sibling unit tests cover, and renders the verdict below.
+
+| check | verdict | examined |
+|---|---|---|
+${rows}
+
+${details ? `## Open violations\n\n${details}` : ""}
+
+[← /sama/v2](/sama/v2) · [← /sama](/sama) · [the v1 dogfood](/sama/verify?repo=syntaxai/tdd.md)
+`;
+};
+
+export const samaV2VerifyHandler = async (): Promise => {
+ let body: string;
+ try {
+ const input = await buildSamaV2Input();
+ const report = verifySamaV2(input);
+ body = renderV2Report(report);
+ } catch (err) {
+ body = `# SAMA v2 verify — error\n\nThe verifier failed before producing a verdict:\n\n\`\`\`\n${(err as Error).message}\n\`\`\`\n\n[← /sama/v2](/sama/v2)`;
+ }
+ const html = await renderDocsPage({
+ title: "SAMA v2 verify · syntaxai/tdd.md — tdd.md",
+ description:
+ "Live dogfood: tdd.md's own source tree run through the SAMA v2 verifier. Reads sama.profile.toml + src/*.ts, applies the seven §4 conformance checks, renders the verdict.",
+ bodyMarkdown: body,
+ ogPath: "https://tdd.md/sama/v2/verify",
+ active: "sama",
+ pathForDocs: "/sama/v2/verify",
+ });
+ return htmlResponse(html);
+};
+
+// -------- /sama/v2 (the SAMA v2 Core Specification — draft) --------
+
+export const samaV2Handler = async (): Promise => {
+ const md = await Bun.file("./content/sama/v2.md").text();
+ const html = await renderDocsPage({
+ title: "SAMA v2 — Core Specification (draft) — tdd.md",
+ description:
+ "Draft of the SAMA v2 Core Specification: four canonical layers (Pure / Core / Adapter / Entry), one frozen import law, profiles as the only extension mechanism. Defines the binary conformance gate and the SAMA-independent core metrics for cross-repo empirical measurement.",
+ bodyMarkdown: md,
+ ogPath: "https://tdd.md/sama/v2",
+ active: "sama",
+ pathForDocs: "/sama/v2",
+ });
+ return htmlResponse(html);
+};
+
+// -------- /sama/verify (form + report + dogfood short-circuit) --------
+
+const VERIFY_FORM_MD = `# SAMA verify
+
+> Paste a public GitHub repo. tdd.md will run the four [SAMA disciplines](/sama) against the default branch — *Sorted* (lower never imports higher), *Architecture* (known layer prefixes), *Modeled* (sibling tests, types in c31_*), *Atomic* (~700-line split + placeholder-test detection) — and return a report. No clone, no token; just one tree-listing API call plus raw-content reads. Cached for an hour per repo.
+
+
+
+
+
+
+Try it on this site: [\`syntaxai/tdd.md\`](/sama/verify?repo=syntaxai/tdd.md) · or any public repo of your own.
+
+Limits: anonymous GitHub API quota is 60 requests/hour per IP. Each verify uses one tree-listing call; the rest of the work goes through raw.githubusercontent.com (uncapped). If the verifier returns "rate limit", come back later or use a token-authenticated proxy.
+
+[← /sama](/sama)
+`;
+
+const verifyLocalDogfood = async (owner: string, name: string): Promise => {
+ const { readdirSync, readFileSync } = await import("node:fs");
+ const srcDir = "./src";
+ const tsFiles = readdirSync(srcDir, { withFileTypes: true })
+ .filter((e) => e.isFile() && e.name.endsWith(".ts"))
+ .map((e) => e.name)
+ .sort();
+ const contents = new Map();
+ for (const f of tsFiles) {
+ if (/^c\d{2}_/.test(f)) {
+ contents.set(f, readFileSync(`${srcDir}/${f}`, "utf8"));
+ }
+ }
+ return verifySama({
+ repoOwner: owner,
+ repoName: name,
+ defaultBranch: "main",
+ srcPaths: tsFiles,
+ contents,
+ });
+};
+
+const verifyRemoteRepo = async (owner: string, name: string): Promise => {
+ const tree = await fetchRepoTree(owner, name);
+ const srcEntries = tree.entries
+ .filter((e) => e.type === "blob" && e.path.startsWith("src/") && e.path.endsWith(".ts"))
+ .slice(0, 200);
+ const srcPaths = srcEntries.map((e) => e.path.slice("src/".length));
+ const samaPaths = srcPaths.filter((p) => /^c\d{2}_/.test(p));
+ const contents = new Map();
+ const fetches = await Promise.all(
+ samaPaths.map(async (p) => [p, await fetchRepoRawFile(owner, name, tree.defaultBranch, `src/${p}`)] as const),
+ );
+ for (const [p, c] of fetches) {
+ if (c !== null) contents.set(p, c);
+ }
+ return verifySama({
+ repoOwner: owner,
+ repoName: name,
+ defaultBranch: tree.defaultBranch,
+ srcPaths,
+ contents,
+ });
+};
+
+const renderVerifyReport = async (report: SamaReport): Promise => {
+ const summary = report.overallPassed
+ ? `> ✓ All four checks passed for [\`${report.repoSlug}\`](https://github.com/${report.repoSlug}) on \`${report.defaultBranch}\` (${report.samaFiles} SAMA files / ${report.testFiles} tests / ${report.totalSrcFiles} total in src/).`
+ : `> ⚠ ${report.checks.filter((c) => !c.passed).length} of 4 checks failed for [\`${report.repoSlug}\`](https://github.com/${report.repoSlug}) on \`${report.defaultBranch}\`.`;
+ const checkBlocks = report.checks
+ .map((c) => {
+ const status = c.passed ? "✓ pass" : `✗ ${c.violations.length} violation${c.violations.length === 1 ? "" : "s"}`;
+ const violationsBlock = c.violations.length === 0
+ ? ""
+ : `\n\n${c.violations.slice(0, 20).map((v) => `- \`${escape(v.file)}\` — ${escape(v.detail)}`).join("\n")}${c.violations.length > 20 ? `\n- _...and ${c.violations.length - 20} more_` : ""}`;
+ const noteBlock = c.note ? `\n\n_${escape(c.note)}_` : "";
+ return `### ${c.letter} — ${c.property} · ${status}\n\nExamined ${c.examined} file${c.examined === 1 ? "" : "s"}.${violationsBlock}${noteBlock}`;
+ })
+ .join("\n\n");
+ const reportMd = `# SAMA verify · \`${report.repoSlug}\`
+
+${summary}
+
+${checkBlocks}
+
+---
+
+[← verify another repo](/sama/verify) · [the four SAMA disciplines →](/sama) · [SAMA skill for your agent →](/sama/skill)
+`;
+ return renderDocsPage({
+ title: `SAMA verify · ${report.repoSlug} — tdd.md`,
+ description: `SAMA verification for ${report.repoSlug}: ${report.overallPassed ? "all four checks passed" : `${report.checks.filter((c) => !c.passed).length}/4 checks failed`}.`,
+ bodyMarkdown: reportMd,
+ ogPath: `https://tdd.md/sama/verify?repo=${report.repoSlug}`,
+ active: "sama",
+ pathForDocs: "/sama/verify",
+ editPathOverride: null,
+ });
+};
+
+export const samaVerifyHandler = async (req: { url: string }): Promise => {
+ const urlR = parseUrl(req.url);
+ const repoArg = urlR.ok ? (urlR.value.searchParams.get("repo") ?? "").trim() : "";
+
+ if (!repoArg) {
+ const html = await renderDocsPage({
+ title: "SAMA verify — tdd.md",
+ description: "Paste a public GitHub repo, get the four SAMA disciplines verified mechanically: sorted (lower never imports higher), architecture (known layer prefixes), modeled (sibling tests), atomic (700-line + placeholder-test detection).",
+ bodyMarkdown: VERIFY_FORM_MD,
+ ogPath: "https://tdd.md/sama/verify",
+ active: "sama",
+ pathForDocs: "/sama/verify",
+ });
+ return htmlResponse(html);
+ }
+
+ const m = /^([^\/\s]+)\/([^\/\s]+)$/.exec(repoArg);
+ if (!m) {
+ const html = await renderDocsPage({
+ title: "SAMA verify · bad input — tdd.md",
+ description: "SAMA verify expects an owner/name repo identifier.",
+ bodyMarkdown: `# SAMA verify\n\n> Couldn't parse \`${repoArg}\`. Use the form: \`owner/name\`.\n\n[← back](/sama/verify)\n`,
+ pathForDocs: "/sama/verify",
+ editPathOverride: null,
+ ogPath: "https://tdd.md/sama/verify",
+ active: "sama",
+ noindex: true,
+ });
+ return htmlResponse(html, 400);
+ }
+
+ const [, owner, name] = m;
+ let report: SamaReport;
+ try {
+ // Dogfood short-circuit: tdd.md is a private repo, so the GitHub
+ // API can't see it. When asked to verify ourselves, read the
+ // source from the bundled `./src/` directory inside the container.
+ const isSelf = owner === LIVE_REPO_OWNER && name === LIVE_REPO_NAME;
+ report = isSelf ? await verifyLocalDogfood(owner!, name!) : await verifyRemoteRepo(owner!, name!);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ const html = await renderDocsPage({
+ title: `SAMA verify · ${owner}/${name} · error — tdd.md`,
+ description: `SAMA verify could not inspect ${owner}/${name}.`,
+ bodyMarkdown: `# SAMA verify · \`${owner}/${name}\`\n\n> Couldn't fetch the repo: ${escape(msg)}\n\nMost common causes: the repo is private, the name is wrong, or you've hit GitHub's anonymous rate limit (60/hour). [← try another repo](/sama/verify)\n`,
+ ogPath: `https://tdd.md/sama/verify?repo=${owner}/${name}`,
+ active: "sama",
+ noindex: true,
+ pathForDocs: "/sama/verify",
+ editPathOverride: null,
+ });
+ return htmlResponse(html, 502);
+ }
+
+ const html = await renderVerifyReport(report);
+ return htmlResponse(html);
+};
+
+// -------- /sama (landing) --------
+
+const SAMA_LANDING_MD = `# SAMA
+
+> **Sorted, Architecture, Modeled, Atomic.** Four properties of a codebase that an AI agent can navigate, change, and verify without drift. The acronym is the rule set; each letter has a one-paragraph definition and a verification you can run.
+
+This is the file-naming and module-organisation convention this site is built on, shared across two other projects in my workspace. It exists to give an AI agent **one obvious place** for every change — and one mechanical check for every layer rule.
+
+## the four disciplines
+
+| letter | discipline | one-line rule |
+|---|---|---|
+%ROWS%
+
+## reading order
+
+If you're new to this:
+1. Start with **[Sorted](/sama/sorted)** — it has the verification grep that everything else is built around.
+2. Then **[Architecture](/sama/architecture)** — what each layer prefix means.
+3. Then **[Modeled](/sama/modeled)** — where types and tests live.
+4. Then **[Atomic](/sama/atomic)** — the split rule that keeps the rest honest as the codebase grows.
+
+Each page is short, opinionated, and ends with the common mistakes you'll see if the discipline lapses.
+
+## the v2 specification (draft)
+
+The four discipline pages above are the practitioner-facing version. The formal, normative version — frozen core + profile mechanism, written so a deterministic verifier in any language can ingest it — lives at **[/sama/v2](/sama/v2)** (draft for v2.0). That doc defines the four canonical layers (Pure / Core / Adapter / Entry), the single import law, the binary conformance gate, and the SAMA-independent core metrics for cross-repo empirical measurement.
+
+## drop into your agent
+
+For agents that load skills from \`~/.claude/skills/\` (Claude Code, obra/superpowers, etc.), grab the SKILL.md version:
+
+\`\`\`bash
+mkdir -p ~/.claude/skills
+curl -fsSL https://tdd.md/skills/sama.md -o ~/.claude/skills/sama.md
+\`\`\`
+
+The skill is the same content as the four pages here, written in obra/superpowers SKILL.md format with frontmatter, an iron-rule statement, and a verification checklist your agent can run before merging. **[Read it formatted →](/sama/skill)** · **[Raw markdown →](/skills/sama.md)**
+
+## verify any public repo
+
+Want to know whether a repo follows SAMA without reading its source? Paste the \`owner/name\` and tdd.md will run all four checks against the default branch — *Sorted* (the import-direction grep), *Architecture* (known layer prefixes), *Modeled* (sibling tests), *Atomic* (700-line + placeholder-test detection). Pass/fail per discipline, with violation lists. **[verify a repo on the web →](/sama/verify)** · or try it on this site: [\`syntaxai/tdd.md\`](/sama/verify?repo=syntaxai/tdd.md).
+
+## the \`sama\` CLI
+
+The web verifier is good for ad-hoc checks. For CI and pre-commit, install the standalone CLI — same checks, no network needed for local repos:
+
+\`\`\`bash
+mkdir -p ~/.local/bin
+curl -fsSL https://tdd.md/tools/sama-cli -o ~/.local/bin/sama
+chmod +x ~/.local/bin/sama
+sama --help
+\`\`\`
+
+Two subcommands:
+
+\`\`\`bash
+sama check # verify the current repo's src/
+sama check --json # JSON output for piping into CI tooling
+sama verify-repo owner/name # verify a public GitHub repo (no token)
+\`\`\`
+
+Exit codes: \`0\` on pass, \`1\` if any check fails, \`2\` on error. The CLI is a single Bun bundle (~14 KB). [Bun](https://bun.sh) needs to be on \`PATH\`.
+
+### pre-commit hook
+
+Add to \`.git/hooks/pre-commit\` (or via \`husky\`, \`pre-commit\`, \`lefthook\`):
+
+\`\`\`bash
+#!/usr/bin/env bash
+# Block commits that violate SAMA layer/atomic/modeled rules.
+exec sama check
+\`\`\`
+
+### GitHub Action
+
+\`\`\`yaml
+# .github/workflows/sama.yml
+name: sama
+on: [push, pull_request]
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v2
+ - run: |
+ curl -fsSL https://tdd.md/tools/sama-cli -o sama
+ chmod +x sama
+ ./sama check
+\`\`\`
+
+If the rule lives in a hook or an action that fails the build, the harness can't talk the agent out of it. That is the whole point of the [corpus post](/blog/agentic-coding-corpus-three-patterns) and the next step from the [from-rules-to-checks](/blog/from-rules-to-checks) wrap-up.
+
+## the case behind it
+
+Two long-form pieces that argue *why* SAMA is shaped this way:
+
+- [**The Claude Code harness postmortem read through TDD + SAMA**](/blog/claude-code-harness-postmortem) — ThePaSch's r/ClaudeAI audit (40+ hidden reminders, 5 gag-order sites, 158 prompt versions in 11 days) read against the iron law and the verification grep. *The harness is loud; the diff doesn't have to be.*
+- [**Three patterns ten threads converge on**](/blog/agentic-coding-corpus-three-patterns) — a six-month corpus of r/ClaudeAI, r/ClaudeCode, r/AgentsOfAI failure-mode threads. Per-pattern mitigation tables map each thread to the SAMA / iron-law rule that catches or prevents it.
+
+If you're reading these for the first time, the order to take them is harness postmortem → corpus → back here.
+
+## why these four together
+
+Each property fixes a different failure mode:
+
+- *Sorted* fails when imports go in any direction → grep proves the rule.
+- *Architecture* fails when responsibilities blur → the prefix is the contract.
+- *Modeled* fails when types and tests scatter → siblings are mandatory.
+- *Atomic* fails when files swell → the ~700-line split keeps atoms small.
+
+Pick one and you'll claw back some clarity. Pick all four and the codebase becomes the kind an agent can be left alone with — there is exactly one right place for any change, and a one-line shell command that proves the layer rule.
+
+The blog post [*Red, tokens, atoms*](/blog/three-constraints-agentic-coding) argues SAMA also compounds with TDD and Claude Code's token-saving discipline; the four properties on this page are the *Atomic* / *Modeled* / *Architecture* / *Sorted* halves of that story.
+
+[← back to tdd.md](/) · [the blog](/blog) · [the guides](/guides)
+`;
+
+export const samaLandingHandler = async (): Promise => {
+ const rows = ALL_SAMA
+ .map((d) => `| **[${d.letter} — ${d.title}](/sama/${d.slug})** | ${d.rule} |`)
+ .join("\n");
+ const body = SAMA_LANDING_MD.replace("%ROWS%", rows);
+ const html = await renderDocsPage({
+ title: "SAMA — sorted, architecture, modeled, atomic — tdd.md",
+ description: "SAMA is a four-property file-naming and module convention for codebases that AI agents work in: sorted by layer prefix, architecture as a contract, models with siblings, atomic files. One page per discipline.",
+ bodyMarkdown: body,
+ ogPath: "https://tdd.md/sama",
+ active: "sama",
+ pathForDocs: "/sama",
+ editPathOverride: null,
+ });
+ return htmlResponse(html);
+};
+
+// -------- /sama/:slug (per-discipline content page) --------
+
+export const samaSlugHandler = async (req: { params: { slug: string } }): Promise => {
+ const slug = req.params.slug;
+ const entry = ALL_SAMA.find((d) => d.slug === slug);
+ if (!entry) {
+ const html = await renderNotFound(`/sama/${slug}`);
+ return htmlResponse(html, 404);
+ }
+ const file = Bun.file(`./content/sama/${slug}.md`);
+ if (!(await file.exists())) {
+ const html = await renderNotFound(`/sama/${slug}`);
+ return htmlResponse(html, 404);
+ }
+ const md = await file.text();
+ const html = await renderDocsPage({
+ title: `SAMA · ${entry.letter} — ${entry.title} — tdd.md`,
+ description: entry.description,
+ bodyMarkdown: md,
+ ogPath: `https://tdd.md/sama/${slug}`,
+ active: "sama",
+ pathForDocs: `/sama/${slug}`,
+ });
+ return htmlResponse(html);
+};
+
+// -------- /tools/sama-cli (binary download) --------
+
+export const samaCliResponse = (): Response =>
+ new Response(Bun.file("./public/sama-cli"), {
+ headers: {
+ "Content-Type": "text/javascript; charset=utf-8",
+ "Content-Disposition": 'inline; filename="sama"',
+ "Cache-Control": "public, max-age=300",
+ },
+ });
diff --git a/src/d21_handlers_source.ts b/src/d21_handlers_source.ts
new file mode 100644
index 0000000000000000000000000000000000000000..85e6041d652dc5dc6098cd5a04d92c239025bf3d
--- /dev/null
+++ b/src/d21_handlers_source.ts
@@ -0,0 +1,38 @@
+// c21 — handler: serves the raw markdown source of an editable doc
+// page from the main domain. Replaces the previous "view source on
+// git.tdd.md" link so the docs site doesn't depend on the Forgejo
+// subdomain for "view source". Reuses c32_edit_resolve so the same
+// allowlist (sama / guides / blog + safe slug regex) protects both
+// the editor and the raw view from path traversal.
+
+import { resolveEdit } from "./b32_edit_resolve.ts";
+import { renderNotFound, htmlResponse } from "./b51_render_layout.ts";
+
+// The route literal is `/content/:section/:filename` and the handler
+// requires the filename to end in `.md`. We don't use `:slug.md`
+// because Bun's path parser treats that as a single param literally
+// named "slug.md", which makes the URL un-typeable.
+export const rawSourceHandler = async (
+ req: Request & { params: { section: string; filename: string } },
+): Promise => {
+ const fullPath = `/content/${req.params.section}/${req.params.filename}`;
+ const notFound = async (): Promise => {
+ const html = await renderNotFound(fullPath);
+ return htmlResponse(html, 404);
+ };
+ if (!req.params.filename.endsWith(".md")) return await notFound();
+ const slug = req.params.filename.slice(0, -3);
+ const resolved = resolveEdit(req.params.section, slug);
+ if (!resolved) return await notFound();
+ const file = Bun.file(`./${resolved.filePath}`);
+ if (!(await file.exists())) return await notFound();
+ // text/plain so browsers render the markdown source inline rather
+ // than offering a download. UTF-8 is fixed because the content/ dir
+ // is UTF-8 throughout (verified by sama-verify).
+ return new Response(await file.text(), {
+ headers: {
+ "Content-Type": "text/plain; charset=utf-8",
+ "Cache-Control": "public, max-age=60",
+ },
+ });
+};
diff --git a/src/d21_handlers_webhook.ts b/src/d21_handlers_webhook.ts
new file mode 100644
index 0000000000000000000000000000000000000000..012c58dfd32b060d0115b7cca62e0b5584603710
--- /dev/null
+++ b/src/d21_handlers_webhook.ts
@@ -0,0 +1,39 @@
+// c21 — handlers: Forgejo push-webhook entry point. HMAC-verified, fires
+// `judge()` in the background and acks immediately so the upstream push
+// hook doesn't time out while we're checking out commits. Extracted
+// from c21_app.ts per the SAMA Atomic rule — separate file from the
+// manual /api/judge trigger because the auth model (HMAC vs. bearer)
+// and the failure semantics (ack-and-fire vs. wait-for-verdict) are
+// genuinely different concepts.
+
+import { judge } from "./c14_judge.ts";
+import { parseJson } from "./c14_request_parse.ts";
+import { timingSafeEqual, hmacSha256Hex } from "./b32_session.ts";
+
+export const forgejoWebhookHandler = async (req: Request): Promise => {
+ if (req.method !== "POST") return new Response("POST only", { status: 405 });
+ const secret = process.env.WEBHOOK_SECRET;
+ if (!secret) return new Response("webhook not configured", { status: 503 });
+
+ const body = await req.text();
+ const provided =
+ req.headers.get("x-forgejo-signature") ?? req.headers.get("x-gitea-signature") ?? "";
+ const expected = await hmacSha256Hex(secret, body);
+ if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
+ return new Response("invalid signature", { status: 401 });
+ }
+
+ const parsed = parseJson<{ repository?: { owner?: { login?: string }; name?: string }; ref?: string }>(body);
+ if (!parsed.ok) return new Response("invalid json", { status: 400 });
+ const payload = parsed.value;
+ const owner = payload.repository?.owner?.login;
+ const repo = payload.repository?.name;
+ if (!owner || !repo) return new Response("missing owner/repo", { status: 400 });
+
+ // Fire the judge in the background; ack immediately so Forgejo
+ // doesn't time out while we're checking out commits.
+ void judge(owner, repo).catch((err) => {
+ console.error(`judge failed for ${owner}/${repo}:`, err);
+ });
+ return Response.json({ accepted: true, owner, repo });
+};