syntaxai/tdd.md · commit a6c69dc

SAMA per-domain split: c51_render_<domain> + c21_handlers_<domain>

Applies the updated SAMA convention from snowplaza-info's CLAUDE.md
(rule 6 — split layer files past ~700 lines per UI/data domain, same
prefix, no barrel re-exports).

c51 — render split:
  c51_render_layout.ts    chrome + escape, htmlResponse, errorPage,
                          phaseSpan, relativeTime, Section, PageOptions
  c51_render_projects.ts  /projects body builders
  c51_render_reports.ts   /reports body builders + sparkline/tile/bars

c21 — dispatcher + per-cluster handlers:
  c21_app.ts              routes literal + createApp + appFetch + appError
                          (now 657 lines, was 1176)
  c21_handlers_agents.ts       renderAgentsIndex + renderAgentDetail
  c21_handlers_auth.ts         startGithubOauth + handleGithubCallback
                               (welcome body builder)
  c21_handlers_leaderboard.ts  renderLeaderboard
  c21_handlers_repo_view.ts    bare /:owner/:repo render via Forgejo

Largest source file is now c21_app.ts at 657 lines; every other layer
file sits well under the threshold. .gitignore now excludes .claude/.
All 21 probed routes return their previous status (200/302/404).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-08 12:47:03 +01:00
parent
ade29aa
commit
a6c69dca5ba70bf06d0d45a651e0b4120ff10685

10 files changed · +1143 −1064

modified .gitignore +1 −0
@@ -4,3 +4,4 @@ node_modules/
44 .env
55 .env.local
66 .bun-cache/
7+.claude/
modified src/c21_app.ts +17 −536
@@ -6,58 +6,49 @@ import {
66 renderPage,
77 renderNotFound,
88 htmlResponse,
9- errorPage,
10- phaseSpan,
11- relativeTime,
9+} from "./c51_render_layout.ts";
10+import {
11+ projectsLandingMd,
12+ projectRegisterMd,
13+ projectDetailMd,
14+} from "./c51_render_projects.ts";
15+import {
1216 reportsLandingMd,
1317 execSummaryMd,
1418 agentDrilldownMd,
1519 testsOverviewMd,
16- projectsLandingMd,
17- projectRegisterMd,
18- projectDetailMd,
19-} from "./c51_render.ts";
20-import * as github from "./c14_github.ts";
21-import * as forgejo from "./c14_forgejo.ts";
20+} from "./c51_render_reports.ts";
2221 import {
2322 FORGEJO_URL,
2423 adminApiHeaders,
25- getUserVisibility,
2624 proxyToForgejo,
27- type ForgejoUserSummary,
2825 } from "./c14_forgejo.ts";
29-import { parseCommit, computeProgress } from "./c31_commits.ts";
30-import { loadGame, listGames } from "./c31_games.ts";
26+import { fetchProjectConfig } from "./c14_github.ts";
27+import { listGames, loadGame } from "./c31_games.ts";
3128 import { ALL_POSTS } from "./c31_blog.ts";
3229 import { ALL_GUIDES } from "./c31_guides.ts";
3330 import { DEMO_REPORTS } from "./c31_reports_demo.ts";
3431 import { parseRepoIdentifier } from "./c31_project_config.ts";
35-import { fetchProjectConfig } from "./c14_github.ts";
3632 import { judge } from "./c32_judge.ts";
3733 import {
38- SESSION_TTL_SEC,
3934 getViewer,
40- randomHex,
41- parseCookies,
42- signSession,
4335 sessionCookieHeader,
4436 timingSafeEqual,
4537 hmacSha256Hex,
4638 } from "./c32_session.ts";
4739 import {
48- latestRun,
49- allLatestRuns,
5040 listActiveProjects,
5141 getProject,
5242 upsertProject,
5343 } from "./c13_database.ts";
44+import { renderRepoView } from "./c21_handlers_repo_view.ts";
45+import { renderAgentsIndex, renderAgentDetail } from "./c21_handlers_agents.ts";
46+import { renderLeaderboard } from "./c21_handlers_leaderboard.ts";
47+import { startGithubOauth, handleGithubCallback } from "./c21_handlers_auth.ts";
5448
5549 const HOME_MD = "./content/home.md";
5650 const GAME_DIR = "./content/games";
5751
58-const BASE_URL = process.env.BASE_URL ?? "https://tdd.md";
59-const CALLBACK_URL = `${BASE_URL}/auth/github/callback`;
60-
6152 const HOME_DESCRIPTION =
6253 "Test-driven development for agentic coding. Your AI agent practices on scored katas; the judge replays its commits against hidden tests and posts a public verdict on the discipline.";
6354
@@ -123,129 +114,6 @@ const renderKata = async (kata: string): Promise<Response | null> => {
123114 return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
124115 };
125116
126-const renderAgentsIndex = async (): Promise<Response> => {
127- let users: ForgejoUserSummary[] = [];
128- const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
129- if (adminToken) {
130- const r = await fetch(`${FORGEJO_URL}/api/v1/admin/users?limit=200`, {
131- headers: adminApiHeaders(),
132- });
133- if (r.ok) users = (await r.json()) as ForgejoUserSummary[];
134- }
135- // Drop the admin (id 1) and anyone whose visibility isn't "public" —
136- // private and limited agents stay invisible on the public index.
137- const agents = users.filter(
138- (u) => u.id !== 1 && !u.is_admin && (u.visibility ?? "public") === "public",
139- );
140-
141- // Per-agent score totals from the latest run per repo.
142- const allRuns = allLatestRuns();
143- const totalsByOwner = new Map<string, { score: number; runs: number }>();
144- for (const r of allRuns) {
145- const t = totalsByOwner.get(r.owner) ?? { score: 0, runs: 0 };
146- t.score += r.verdict.totalScore;
147- t.runs += 1;
148- totalsByOwner.set(r.owner, t);
149- }
150-
151- let body: string;
152- if (agents.length === 0) {
153- body = `# agents
154-
155-> No agents registered yet. Be the first.
156-
157-[ Register your agent → ](/agents/register)
158-`;
159- } else {
160- const rows = agents
161- .map((u) => {
162- const t = totalsByOwner.get(u.login) ?? { score: 0, runs: 0 };
163- const sign = t.score >= 0 ? "+" : "";
164- return `| [${u.login}](/agents/${u.login}) | ${t.runs} | ${sign}${t.score} |`;
165- })
166- .join("\n");
167- body = `# agents
168-
169-| agent | attempts | total score |
170-|---|---|---|
171-${rows}
172-
173-[ Register your agent → ](/agents/register)
174-`;
175- }
176-
177- const description =
178- agents.length === 0
179- ? "AI agents doing test-driven development on tdd.md — registration is open, sign in with GitHub to play."
180- : `${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.`;
181-
182- const html = await renderPage({
183- title: "AI agents on tdd.md",
184- description,
185- bodyMarkdown: body,
186- ogPath: "https://tdd.md/agents",
187- active: "agents",
188- });
189- return htmlResponse(html);
190-};
191-
192-const renderLeaderboard = async (): Promise<Response> => {
193- // Only show runs whose owner is public. Fetch the user list once
194- // and build a Set so we can filter without N+1 lookups.
195- const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
196- const publicOwners = new Set<string>();
197- if (adminToken) {
198- const r = await fetch(`${FORGEJO_URL}/api/v1/admin/users?limit=200`, {
199- headers: adminApiHeaders(),
200- });
201- if (r.ok) {
202- const users = (await r.json()) as ForgejoUserSummary[];
203- for (const u of users) {
204- if ((u.visibility ?? "public") === "public") publicOwners.add(u.login);
205- }
206- }
207- }
208- const runs = allLatestRuns()
209- .filter((r) => publicOwners.size === 0 || publicOwners.has(r.owner))
210- .sort((a, b) => b.verdict.totalScore - a.verdict.totalScore);
211- let body: string;
212- if (runs.length === 0) {
213- body = `# leaderboard
214-
215-> No verdicts yet. The first agent to push a red→green pair lands here.
216-
217-[ Register your agent → ](/agents/register)
218-`;
219- } else {
220- const rows = runs
221- .map((r, i) => {
222- const sign = r.verdict.totalScore >= 0 ? "+" : "";
223- const verified = r.verdict.steps.filter((s) => s.status === "verified").length;
224- return `| ${i + 1} | [${r.owner}](/agents/${r.owner}) | [${r.repo}](/${r.owner}/${r.repo}) | ${sign}${r.verdict.totalScore} | ${verified} |`;
225- })
226- .join("\n");
227- body = `# leaderboard
228-
229-| rank | agent | kata | score | verified steps |
230-|---|---|---|---|---|
231-${rows}
232-`;
233- }
234- const description =
235- runs.length === 0
236- ? "TDD leaderboard for AI agents on tdd.md — be the first verdict."
237- : `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.`;
238-
239- const html = await renderPage({
240- title: "TDD leaderboard — tdd.md",
241- description,
242- bodyMarkdown: body,
243- ogPath: "https://tdd.md/leaderboard",
244- active: "leaderboard",
245- });
246- return htmlResponse(html);
247-};
248-
249117 const REGISTER_BODY = `# register
250118
251119 > Sign in with GitHub to create your tdd.md agent.
@@ -274,191 +142,6 @@ const REGISTER_HTML = await renderPage({
274142 noindex: true,
275143 });
276144
277-interface ForgejoRepoSummary {
278- description: string;
279- clone_url: string;
280- empty: boolean;
281- private: boolean;
282-}
283-
284-interface ForgejoCommit {
285- sha: string;
286- commit: { message: string; author: { name: string; date: string } };
287-}
288-
289-const renderRepoView = async (
290- owner: string,
291- repo: string,
292- viewer: string | null,
293-): Promise<Response> => {
294- // Private/limited owners get a 404 to anonymous visitors — but the
295- // owner themselves (verified via session cookie) can always see
296- // their own pages.
297- const ownerVisibility = await getUserVisibility(owner);
298- if (ownerVisibility !== null && ownerVisibility !== "public" && viewer !== owner) {
299- const html = await renderNotFound(`/${owner}/${repo}`);
300- return htmlResponse(html, 404);
301- }
302-
303- const repoApi = `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
304- const repoRes = await fetch(repoApi, { headers: adminApiHeaders() });
305- if (repoRes.status === 404) {
306- const html = await renderNotFound(`/${owner}/${repo}`);
307- return htmlResponse(html, 404);
308- }
309- if (!repoRes.ok) {
310- const html = await renderPage({
311- title: `${owner}/${repo} — tdd.md`,
312- bodyMarkdown: `# ${owner}/${repo}\n\n> repository unavailable`,
313- });
314- return htmlResponse(html, 502);
315- }
316- const info = (await repoRes.json()) as ForgejoRepoSummary;
317- const cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`;
318- const isPrivate = info.private === true;
319-
320- // The repo name is by convention the kata id. If the kata exists, the
321- // header link is meaningful and we know the total step count.
322- let totalSteps: number | null = null;
323- let kataExists = false;
324- try {
325- const game = await loadGame(repo);
326- totalSteps = game.steps.length;
327- kataExists = true;
328- } catch {
329- // Repo isn't a known kata — still render, just without step totals.
330- }
331-
332- let commits: ForgejoCommit[] = [];
333- if (!info.empty) {
334- const commitsRes = await fetch(`${repoApi}/commits?limit=50&stat=false`, {
335- headers: adminApiHeaders(),
336- });
337- if (commitsRes.ok) commits = (await commitsRes.json()) as ForgejoCommit[];
338- }
339- const progress = computeProgress(commits);
340- const verified = progress.verifiedSteps.size;
341-
342- let status: string;
343- if (commits.length === 0) {
344- status = "awaiting first push";
345- } else if (totalSteps !== null && verified >= totalSteps) {
346- status = "kata complete";
347- } else if (verified > 0) {
348- status = "in progress";
349- } else {
350- status = "no verified steps yet";
351- }
352- const stepCounter = totalSteps !== null ? `${verified} / ${totalSteps}` : `${verified} / ?`;
353-
354- let phaseLog: string;
355- if (commits.length === 0) {
356- phaseLog = "_No commits yet — push your first `red:` commit to start the cycle._";
357- } else {
358- const rows = commits.map((c) => {
359- const sha = c.sha.slice(0, 7);
360- const p = parseCommit(c.commit.message);
361- const subject = (p.subject || c.commit.message.split("\n")[0] || "").replace(/\|/g, "\\|");
362- const stepCell = p.step ? `\`${p.step}\`` : "—";
363- return `| \`${sha}\` | ${phaseSpan(p.phase)} | ${stepCell} | ${subject} | ${relativeTime(c.commit.author.date)} |`;
364- });
365- phaseLog = `| sha | phase | step | message | when |\n|---|---|---|---|---|\n${rows.join("\n")}`;
366- }
367-
368- const kataLink = kataExists
369- ? `[\`${repo}\` →](/games/${repo})`
370- : `\`${repo}\``;
371- const privateBadge = isPrivate ? ` <span class="muted">[private]</span>` : "";
372-
373- const verdict = latestRun(owner, repo);
374- const headSha = commits[0]?.sha ?? null;
375- const verdictStale = verdict !== null && headSha !== null && verdict.headSha !== headSha;
376-
377- let scoreSection: string;
378- if (verdict === null) {
379- scoreSection = `> Not yet judged. The next push triggers a judge run, or [run the judge now](/api/judge/${owner}/${repo}) (POST).\n\nPhase tally: <span class="red">red ${progress.redCount}</span> · <span class="green">green ${progress.greenCount}</span> · <span class="blue">refactor ${progress.refactorCount}</span>${progress.untaggedCount > 0 ? ` · <span class="muted">untagged ${progress.untaggedCount}</span>` : ""}.`;
380- } else {
381- const stale = verdictStale ? ` · <span class="muted">stale — newer commits not yet judged</span>` : "";
382- const sign = verdict.totalScore >= 0 ? "+" : "";
383- const statusClass = (status: string): string => {
384- if (status === "verified") return "green";
385- if (status === "discipline-only") return "blue";
386- if (status === "no-green") return "muted";
387- return "red";
388- };
389- const modeLabel = (m: string): string => {
390- const cls = m === "strict" ? "red" : m === "pragmatic" ? "blue" : "green";
391- return `<span class="${cls}">${m}</span>`;
392- };
393- const rows = verdict.steps.length === 0
394- ? "_No red→green pairs found yet._"
395- : `| step | red | green | hidden | status | points | explanation |\n|---|---|---|---|---|---|---|\n` +
396- verdict.steps.map((s) => {
397- const cls = statusClass(s.status);
398- const sign = s.scoreDelta >= 0 ? "+" : "";
399- const hiddenCell =
400- s.hiddenPassed === true ? `<span class="green">pass</span>` :
401- s.hiddenPassed === false ? `<span class="red">fail</span>` :
402- `<span class="muted">—</span>`;
403- const explanation = (s.explanation ?? "").replace(/\|/g, "\\|");
404- return `| \`${s.stepId}\` | \`${s.redSha?.slice(0, 7) ?? "—"}\` | \`${s.greenSha?.slice(0, 7) ?? "—"}\` | ${hiddenCell} | <span class="${cls}">${s.status}</span> | ${sign}${s.scoreDelta} | ${explanation} |`;
405- }).join("\n");
406- const refactorRows = (verdict.refactors ?? []).length === 0
407- ? ""
408- : `\n\n### refactors\n\n| sha | step | tests | points | explanation |\n|---|---|---|---|---|\n` +
409- verdict.refactors.map((r) => {
410- const sign = r.scoreDelta >= 0 ? "+" : "";
411- const cls = r.testsPassed ? "green" : "red";
412- const verb = r.testsPassed ? "green" : "broke tests";
413- const explanation = (r.explanation ?? "").replace(/\|/g, "\\|");
414- return `| \`${r.sha.slice(0, 7)}\` | ${r.stepId ? `\`${r.stepId}\`` : "—"} | <span class="${cls}">${verb}</span> | ${sign}${r.scoreDelta} | ${explanation} |`;
415- }).join("\n");
416- const modeLine = verdict.mode ? `**mode: ${modeLabel(verdict.mode)}** · ` : "";
417- scoreSection = `${modeLine}**total: ${sign}${verdict.totalScore}** · judged ${relativeTime(new Date(verdict.judgedAt).toISOString())}${stale}\n\n${rows}${refactorRows}`;
418- }
419-
420- const body = `# ${owner} · playing ${kataLink}${privateBadge}
421-
422-> ${status}
423-> **${stepCounter}** steps verified
424-
425-## phase log
426-
427-${phaseLog}
428-
429-## score
430-
431-${scoreSection}
432-
433-## clone
434-
435-\`\`\`
436-git clone ${cloneUrl}
437-\`\`\`
438-
439-[← /agents/${owner}](/agents/${owner})${kataExists ? ` · [kata spec →](/games/${repo})` : ""}
440-`;
441-
442- // Dynamic description tailored to this attempt — gives every agent
443- // run a unique snippet for search results and social previews instead
444- // of falling back to the site default.
445- const totalSnippet =
446- verdict !== null
447- ? `, score ${verdict.totalScore >= 0 ? "+" : ""}${verdict.totalScore}`
448- : "";
449- const description = kataExists
450- ? `${owner}'s ${repo} TDD kata attempt on tdd.md — ${verified}${totalSteps !== null ? `/${totalSteps}` : ""} steps verified${totalSnippet}.`
451- : `${owner}/${repo} on tdd.md — ${commits.length} ${commits.length === 1 ? "commit" : "commits"} in the phase log${totalSnippet}.`;
452-
453- const html = await renderPage({
454- title: `${owner} · ${repo}${kataExists ? " TDD kata" : ""} — tdd.md`,
455- description,
456- bodyMarkdown: body,
457- ogPath: `https://tdd.md/${owner}/${repo}`,
458- active: "agents",
459- });
460- return htmlResponse(html);
461-};
462145
463146 const isGitProtocol = (pathname: string, search: URLSearchParams): boolean => {
464147 if (pathname.includes(".git/") || pathname.endsWith(".git")) return true;
@@ -832,93 +515,8 @@ ${rows}
832515 "/agents": () => renderAgentsIndex(),
833516 "/agents/register": htmlResponse(REGISTER_HTML),
834517 "/agents/:name": async (req) => {
835- const name = req.params.name;
836518 const viewer = await getViewer(req);
837- const userRes = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(name)}`, {
838- headers: adminApiHeaders(),
839- });
840- // Treat private/limited users as if they don't exist publicly —
841- // unless the logged-in viewer IS the owner. Owner can always see
842- // their own dashboard, public or not.
843- if (userRes.ok) {
844- const u = (await userRes.clone().json()) as ForgejoUserSummary;
845- const ownVisibility = u.visibility ?? "public";
846- if (ownVisibility !== "public" && viewer !== name) {
847- const html = await renderNotFound(`/agents/${name}`);
848- return htmlResponse(html, 404);
849- }
850- }
851- if (userRes.status === 404) {
852- const html = await renderPage({
853- title: `${name} — agents — tdd.md`,
854- bodyMarkdown: `# agents / ${name}\n\n> No agent registered with this name.\n\n[← all agents](/agents) · [register your own →](/agents/register)`,
855- ogPath: `https://tdd.md/agents/${name}`,
856- active: "agents",
857- });
858- return htmlResponse(html, 404);
859- }
860- const reposRes = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(name)}/repos?limit=50`, {
861- headers: adminApiHeaders(),
862- });
863- const repos = reposRes.ok ? ((await reposRes.json()) as { name: string; description: string }[]) : [];
864-
865- const progressByRepo = await Promise.all(
866- repos.map(async (r) => {
867- const cRes = await fetch(
868- `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(name)}/${encodeURIComponent(r.name)}/commits?limit=50&stat=false`,
869- { headers: adminApiHeaders() },
870- );
871- const commits = cRes.ok ? ((await cRes.json()) as { commit: { message: string } }[]) : [];
872- return { repo: r, progress: computeProgress(commits) };
873- }),
874- );
875-
876- const totals: Record<string, number> = {};
877- for (const r of repos) {
878- try {
879- const game = await loadGame(r.name);
880- totals[r.name] = game.steps.length;
881- } catch {
882- // unknown kata, no total
883- }
884- }
885-
886- const isSelf = viewer === name;
887- let body = `# agents / ${name}\n\n`;
888- if (isSelf) {
889- body += `> Welcome back, ${name}. This is your dashboard — only you and admins see it when your profile is private.\n\n`;
890- }
891- if (repos.length === 0) {
892- body += "> Registered, but no kata attempts yet.\n\n[← all agents](/agents)";
893- } else {
894- body += "## attempts\n\n";
895- body += "| kata | verified | phases |\n|---|---|---|\n";
896- for (const { repo: r, progress } of progressByRepo) {
897- const total = totals[r.name];
898- const verified = progress.verifiedSteps.size;
899- const counter = total !== undefined ? `${verified} / ${total}` : `${verified} / ?`;
900- const phases = `<span class="red">red ${progress.redCount}</span> · <span class="green">green ${progress.greenCount}</span> · <span class="blue">refactor ${progress.refactorCount}</span>`;
901- body += `| [${r.name}](/${name}/${r.name}) | ${counter} | ${phases} |\n`;
902- }
903- }
904-
905- if (isSelf) {
906- body += `\n\n---\n\n[sign out](/auth/logout) · [toggle visibility](#) <span class="muted">(POST /api/agents/${name}/visibility with your push token)</span>`;
907- }
908-
909- const verifiedSteps = progressByRepo.reduce((acc, p) => acc + p.progress.verifiedSteps.size, 0);
910- const description =
911- repos.length === 0
912- ? `${name} just registered on tdd.md — no kata attempts yet.`
913- : `${name}'s TDD attempts on tdd.md: ${repos.length} ${repos.length === 1 ? "kata" : "katas"} pushed, ${verifiedSteps} verified red→green ${verifiedSteps === 1 ? "step" : "steps"}.`;
914- const html = await renderPage({
915- title: `${name} · TDD attempts — tdd.md`,
916- description,
917- bodyMarkdown: body,
918- ogPath: `https://tdd.md/agents/${name}`,
919- active: "agents",
920- });
921- return htmlResponse(html);
519+ return renderAgentDetail(req.params.name, viewer);
922520 },
923521 // Redirect the legacy URL to the canonical /:owner/:repo path —
924522 // /agents/:name/:kata used to render a placeholder before the
@@ -1051,126 +649,9 @@ ${rows}
1051649 });
1052650 },
1053651
1054- "/auth/github/start": (_req) => {
1055- if (!github.isConfigured() || !forgejo.isConfigured()) {
1056- return errorPage("registration is not configured on this server", 503);
1057- }
1058- const nonce = randomHex(16);
1059- return new Response(null, {
1060- status: 302,
1061- headers: {
1062- Location: github.authorizeUrl(nonce, CALLBACK_URL),
1063- "Set-Cookie": `tdd_oauth_state=${nonce}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`,
1064- },
1065- });
1066- },
1067-
1068- "/auth/github/callback": async (req) => {
1069- const url = new URL(req.url);
1070- const code = url.searchParams.get("code");
1071- const state = url.searchParams.get("state");
1072- if (!code || !state) return errorPage("missing code or state");
1073-
1074- const cookies = parseCookies(req.headers.get("cookie"));
1075- const cookieState = cookies.tdd_oauth_state;
1076- if (!cookieState || !timingSafeEqual(cookieState, state)) {
1077- return errorPage("state mismatch — open the registration page again and retry");
1078- }
1079-
1080- let username: string;
1081- let email: string;
1082- let fullName: string | null;
1083- try {
1084- const accessToken = await github.exchangeCode(code, CALLBACK_URL);
1085- const user = await github.fetchUser(accessToken);
1086- username = user.login;
1087- fullName = user.name;
1088- // GitHub's noreply email format: unique per account, never collides
1089- // with another Forgejo user. We don't need a deliverable address —
1090- // agents authenticate by token, not by email reset flow.
1091- email = `${user.id}+${user.login}@users.noreply.github.com`;
1092- } catch (err) {
1093- return errorPage(`github oauth failed: ${(err as Error).message}`, 400);
1094- }
1095-
1096- // Login vs register: if the user already exists in Forgejo, this
1097- // is a returning visitor — set the session cookie, redirect to
1098- // their dashboard, don't rotate their token.
1099- const isExisting = await forgejo.userExists(username);
1100- const sessionToken = await signSession(username);
1101- const sessionCookie = sessionCookieHeader(sessionToken, SESSION_TTL_SEC);
1102- const clearOauthState =
1103- "tdd_oauth_state=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0";
1104-
1105- if (isExisting) {
1106- return new Response(null, {
1107- status: 302,
1108- headers: new Headers([
1109- ["Location", `/agents/${username}`],
1110- ["Set-Cookie", sessionCookie],
1111- ["Set-Cookie", clearOauthState],
1112- ]),
1113- });
1114- }
1115-
1116- let reg: forgejo.AgentRegistration;
1117- try {
1118- reg = await forgejo.registerAgent({
1119- username,
1120- email,
1121- fullName: fullName ?? undefined,
1122- });
1123- } catch (err) {
1124- return errorPage(`failed to create your agent: ${(err as Error).message}`, 422);
1125- }
1126-
1127- const verb = reg.isNew ? "created" : "rotated";
1128- const body = `# welcome, ${reg.username}
1129-
1130-> 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).
1131-
1132-## push token
1133-
1134-\`\`\`
1135-${reg.pushToken}
1136-\`\`\`
1137-
1138-## kata: string-calc
1139-
1140-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\`.
1141-
1142-\`\`\`
1143-git clone ${reg.repoCloneUrl}
1144-cd string-calc
1145-
1146-# play the kata, commit per phase
1147-# red: commit a failing test
1148-# green: commit the impl that makes it pass
1149-# refactor: commit a structural change with tests staying green
652+ "/auth/github/start": (_req) => startGithubOauth(),
1150653
1151-git push
1152-# username: ${reg.username}
1153-# password: <paste the token above>
1154-\`\`\`
654+ "/auth/github/callback": async (req) => handleGithubCallback(req),
1155655
1156-When you push, the judge replays your commits and posts the verdict at [/agents/${reg.username}/string-calc](/agents/${reg.username}/string-calc).
1157-
1158-[← spec](/games/string-calc) · [all agents](/agents)
1159-`;
1160-
1161- const html = await renderPage({
1162- title: `welcome ${reg.username} — tdd.md`,
1163- bodyMarkdown: body,
1164- active: "agents",
1165- noindex: true,
1166- });
1167- return new Response(html, {
1168- headers: new Headers([
1169- ["Content-Type", "text/html; charset=utf-8"],
1170- ["Set-Cookie", sessionCookie],
1171- ["Set-Cookie", clearOauthState],
1172- ]),
1173- });
1174- },
1175656 },
1176657 });
added src/c21_handlers_agents.ts +175 −0
@@ -0,0 +1,175 @@
1+// c21 (agents) — handlers for /agents (index) and /agents/:name (detail).
2+// Both compose Forgejo admin lookups (c14) with kata progress (c31) and
3+// the verdict store (c13). The route table in c21_app.ts forwards the
4+// matching path here.
5+
6+import {
7+ FORGEJO_URL,
8+ adminApiHeaders,
9+ type ForgejoUserSummary,
10+} from "./c14_forgejo.ts";
11+import { computeProgress } from "./c31_commits.ts";
12+import { loadGame } from "./c31_games.ts";
13+import { allLatestRuns } from "./c13_database.ts";
14+import {
15+ renderPage,
16+ renderNotFound,
17+ htmlResponse,
18+} from "./c51_render_layout.ts";
19+
20+export const renderAgentsIndex = async (): Promise<Response> => {
21+ let users: ForgejoUserSummary[] = [];
22+ const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
23+ if (adminToken) {
24+ const r = await fetch(`${FORGEJO_URL}/api/v1/admin/users?limit=200`, {
25+ headers: adminApiHeaders(),
26+ });
27+ if (r.ok) users = (await r.json()) as ForgejoUserSummary[];
28+ }
29+ // Drop the admin (id 1) and anyone whose visibility isn't "public" —
30+ // private and limited agents stay invisible on the public index.
31+ const agents = users.filter(
32+ (u) => u.id !== 1 && !u.is_admin && (u.visibility ?? "public") === "public",
33+ );
34+
35+ // Per-agent score totals from the latest run per repo.
36+ const allRuns = allLatestRuns();
37+ const totalsByOwner = new Map<string, { score: number; runs: number }>();
38+ for (const r of allRuns) {
39+ const t = totalsByOwner.get(r.owner) ?? { score: 0, runs: 0 };
40+ t.score += r.verdict.totalScore;
41+ t.runs += 1;
42+ totalsByOwner.set(r.owner, t);
43+ }
44+
45+ let body: string;
46+ if (agents.length === 0) {
47+ body = `# agents
48+
49+> No agents registered yet. Be the first.
50+
51+[ Register your agent → ](/agents/register)
52+`;
53+ } else {
54+ const rows = agents
55+ .map((u) => {
56+ const t = totalsByOwner.get(u.login) ?? { score: 0, runs: 0 };
57+ const sign = t.score >= 0 ? "+" : "";
58+ return `| [${u.login}](/agents/${u.login}) | ${t.runs} | ${sign}${t.score} |`;
59+ })
60+ .join("\n");
61+ body = `# agents
62+
63+| agent | attempts | total score |
64+|---|---|---|
65+${rows}
66+
67+[ Register your agent → ](/agents/register)
68+`;
69+ }
70+
71+ const description =
72+ agents.length === 0
73+ ? "AI agents doing test-driven development on tdd.md — registration is open, sign in with GitHub to play."
74+ : `${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.`;
75+
76+ const html = await renderPage({
77+ title: "AI agents on tdd.md",
78+ description,
79+ bodyMarkdown: body,
80+ ogPath: "https://tdd.md/agents",
81+ active: "agents",
82+ });
83+ return htmlResponse(html);
84+};
85+
86+export const renderAgentDetail = async (
87+ name: string,
88+ viewer: string | null,
89+): Promise<Response> => {
90+ const userRes = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(name)}`, {
91+ headers: adminApiHeaders(),
92+ });
93+ // Treat private/limited users as if they don't exist publicly —
94+ // unless the logged-in viewer IS the owner. Owner can always see
95+ // their own dashboard, public or not.
96+ if (userRes.ok) {
97+ const u = (await userRes.clone().json()) as ForgejoUserSummary;
98+ const ownVisibility = u.visibility ?? "public";
99+ if (ownVisibility !== "public" && viewer !== name) {
100+ const html = await renderNotFound(`/agents/${name}`);
101+ return htmlResponse(html, 404);
102+ }
103+ }
104+ if (userRes.status === 404) {
105+ const html = await renderPage({
106+ title: `${name} — agents — tdd.md`,
107+ bodyMarkdown: `# agents / ${name}\n\n> No agent registered with this name.\n\n[← all agents](/agents) · [register your own →](/agents/register)`,
108+ ogPath: `https://tdd.md/agents/${name}`,
109+ active: "agents",
110+ });
111+ return htmlResponse(html, 404);
112+ }
113+ const reposRes = await fetch(`${FORGEJO_URL}/api/v1/users/${encodeURIComponent(name)}/repos?limit=50`, {
114+ headers: adminApiHeaders(),
115+ });
116+ const repos = reposRes.ok ? ((await reposRes.json()) as { name: string; description: string }[]) : [];
117+
118+ const progressByRepo = await Promise.all(
119+ repos.map(async (r) => {
120+ const cRes = await fetch(
121+ `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(name)}/${encodeURIComponent(r.name)}/commits?limit=50&stat=false`,
122+ { headers: adminApiHeaders() },
123+ );
124+ const commits = cRes.ok ? ((await cRes.json()) as { commit: { message: string } }[]) : [];
125+ return { repo: r, progress: computeProgress(commits) };
126+ }),
127+ );
128+
129+ const totals: Record<string, number> = {};
130+ for (const r of repos) {
131+ try {
132+ const game = await loadGame(r.name);
133+ totals[r.name] = game.steps.length;
134+ } catch {
135+ // unknown kata, no total
136+ }
137+ }
138+
139+ const isSelf = viewer === name;
140+ let body = `# agents / ${name}\n\n`;
141+ if (isSelf) {
142+ body += `> Welcome back, ${name}. This is your dashboard — only you and admins see it when your profile is private.\n\n`;
143+ }
144+ if (repos.length === 0) {
145+ body += "> Registered, but no kata attempts yet.\n\n[← all agents](/agents)";
146+ } else {
147+ body += "## attempts\n\n";
148+ body += "| kata | verified | phases |\n|---|---|---|\n";
149+ for (const { repo: r, progress } of progressByRepo) {
150+ const total = totals[r.name];
151+ const verified = progress.verifiedSteps.size;
152+ const counter = total !== undefined ? `${verified} / ${total}` : `${verified} / ?`;
153+ const phases = `<span class="red">red ${progress.redCount}</span> · <span class="green">green ${progress.greenCount}</span> · <span class="blue">refactor ${progress.refactorCount}</span>`;
154+ body += `| [${r.name}](/${name}/${r.name}) | ${counter} | ${phases} |\n`;
155+ }
156+ }
157+
158+ if (isSelf) {
159+ body += `\n\n---\n\n[sign out](/auth/logout) · [toggle visibility](#) <span class="muted">(POST /api/agents/${name}/visibility with your push token)</span>`;
160+ }
161+
162+ const verifiedSteps = progressByRepo.reduce((acc, p) => acc + p.progress.verifiedSteps.size, 0);
163+ const description =
164+ repos.length === 0
165+ ? `${name} just registered on tdd.md — no kata attempts yet.`
166+ : `${name}'s TDD attempts on tdd.md: ${repos.length} ${repos.length === 1 ? "kata" : "katas"} pushed, ${verifiedSteps} verified red→green ${verifiedSteps === 1 ? "step" : "steps"}.`;
167+ const html = await renderPage({
168+ title: `${name} · TDD attempts — tdd.md`,
169+ description,
170+ bodyMarkdown: body,
171+ ogPath: `https://tdd.md/agents/${name}`,
172+ active: "agents",
173+ });
174+ return htmlResponse(html);
175+};
added src/c21_handlers_auth.ts +145 −0
@@ -0,0 +1,145 @@
1+// c21 (auth) — GitHub OAuth start + callback handlers. Composes
2+// c14_github (token exchange + user fetch), c14_forgejo (existence check
3+// + agent registration), c32_session (sign + cookie), c51 layout for
4+// the welcome page rendered after first-time registration.
5+
6+import * as github from "./c14_github.ts";
7+import * as forgejo from "./c14_forgejo.ts";
8+import {
9+ SESSION_TTL_SEC,
10+ parseCookies,
11+ signSession,
12+ sessionCookieHeader,
13+ timingSafeEqual,
14+ randomHex,
15+} from "./c32_session.ts";
16+import { renderPage, errorPage } from "./c51_render_layout.ts";
17+
18+const BASE_URL = process.env.BASE_URL ?? "https://tdd.md";
19+const CALLBACK_URL = `${BASE_URL}/auth/github/callback`;
20+
21+const CLEAR_OAUTH_STATE =
22+ "tdd_oauth_state=; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=0";
23+
24+export const startGithubOauth = (): Response => {
25+ if (!github.isConfigured() || !forgejo.isConfigured()) {
26+ // errorPage is async; we wrap below.
27+ return new Response("registration is not configured on this server", { status: 503 });
28+ }
29+ const nonce = randomHex(16);
30+ return new Response(null, {
31+ status: 302,
32+ headers: {
33+ Location: github.authorizeUrl(nonce, CALLBACK_URL),
34+ "Set-Cookie": `tdd_oauth_state=${nonce}; Path=/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=600`,
35+ },
36+ });
37+};
38+
39+const welcomeBody = (reg: forgejo.AgentRegistration): string => {
40+ const verb = reg.isNew ? "created" : "rotated";
41+ return `# welcome, ${reg.username}
42+
43+> 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).
44+
45+## push token
46+
47+\`\`\`
48+${reg.pushToken}
49+\`\`\`
50+
51+## kata: string-calc
52+
53+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\`.
54+
55+\`\`\`
56+git clone ${reg.repoCloneUrl}
57+cd string-calc
58+
59+# play the kata, commit per phase
60+# red: commit a failing test
61+# green: commit the impl that makes it pass
62+# refactor: commit a structural change with tests staying green
63+
64+git push
65+# username: ${reg.username}
66+# password: <paste the token above>
67+\`\`\`
68+
69+When you push, the judge replays your commits and posts the verdict at [/agents/${reg.username}/string-calc](/agents/${reg.username}/string-calc).
70+
71+[← spec](/games/string-calc) · [all agents](/agents)
72+`;
73+};
74+
75+export const handleGithubCallback = async (req: Request): Promise<Response> => {
76+ const url = new URL(req.url);
77+ const code = url.searchParams.get("code");
78+ const state = url.searchParams.get("state");
79+ if (!code || !state) return errorPage("missing code or state");
80+
81+ const cookies = parseCookies(req.headers.get("cookie"));
82+ const cookieState = cookies.tdd_oauth_state;
83+ if (!cookieState || !timingSafeEqual(cookieState, state)) {
84+ return errorPage("state mismatch — open the registration page again and retry");
85+ }
86+
87+ let username: string;
88+ let email: string;
89+ let fullName: string | null;
90+ try {
91+ const accessToken = await github.exchangeCode(code, CALLBACK_URL);
92+ const user = await github.fetchUser(accessToken);
93+ username = user.login;
94+ fullName = user.name;
95+ // GitHub's noreply email format: unique per account, never collides
96+ // with another Forgejo user. We don't need a deliverable address —
97+ // agents authenticate by token, not by email reset flow.
98+ email = `${user.id}+${user.login}@users.noreply.github.com`;
99+ } catch (err) {
100+ return errorPage(`github oauth failed: ${(err as Error).message}`, 400);
101+ }
102+
103+ // Login vs register: if the user already exists in Forgejo, this
104+ // is a returning visitor — set the session cookie, redirect to
105+ // their dashboard, don't rotate their token.
106+ const isExisting = await forgejo.userExists(username);
107+ const sessionToken = await signSession(username);
108+ const sessionCookie = sessionCookieHeader(sessionToken, SESSION_TTL_SEC);
109+
110+ if (isExisting) {
111+ return new Response(null, {
112+ status: 302,
113+ headers: new Headers([
114+ ["Location", `/agents/${username}`],
115+ ["Set-Cookie", sessionCookie],
116+ ["Set-Cookie", CLEAR_OAUTH_STATE],
117+ ]),
118+ });
119+ }
120+
121+ let reg: forgejo.AgentRegistration;
122+ try {
123+ reg = await forgejo.registerAgent({
124+ username,
125+ email,
126+ fullName: fullName ?? undefined,
127+ });
128+ } catch (err) {
129+ return errorPage(`failed to create your agent: ${(err as Error).message}`, 422);
130+ }
131+
132+ const html = await renderPage({
133+ title: `welcome ${reg.username} — tdd.md`,
134+ bodyMarkdown: welcomeBody(reg),
135+ active: "agents",
136+ noindex: true,
137+ });
138+ return new Response(html, {
139+ headers: new Headers([
140+ ["Content-Type", "text/html; charset=utf-8"],
141+ ["Set-Cookie", sessionCookie],
142+ ["Set-Cookie", CLEAR_OAUTH_STATE],
143+ ]),
144+ });
145+};
added src/c21_handlers_leaderboard.ts +71 −0
@@ -0,0 +1,71 @@
1+// c21 (leaderboard) — handler that ranks tracked agents by their kata
2+// verdict totals. Forgejo admin lookup gives us the public/limited
3+// filter; c13 supplies the per-repo verdicts.
4+
5+import {
6+ FORGEJO_URL,
7+ adminApiHeaders,
8+ type ForgejoUserSummary,
9+} from "./c14_forgejo.ts";
10+import { allLatestRuns } from "./c13_database.ts";
11+import {
12+ renderPage,
13+ htmlResponse,
14+} from "./c51_render_layout.ts";
15+
16+export const renderLeaderboard = async (): Promise<Response> => {
17+ // Only show runs whose owner is public. Fetch the user list once
18+ // and build a Set so we can filter without N+1 lookups.
19+ const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
20+ const publicOwners = new Set<string>();
21+ if (adminToken) {
22+ const r = await fetch(`${FORGEJO_URL}/api/v1/admin/users?limit=200`, {
23+ headers: adminApiHeaders(),
24+ });
25+ if (r.ok) {
26+ const users = (await r.json()) as ForgejoUserSummary[];
27+ for (const u of users) {
28+ if ((u.visibility ?? "public") === "public") publicOwners.add(u.login);
29+ }
30+ }
31+ }
32+ const runs = allLatestRuns()
33+ .filter((r) => publicOwners.size === 0 || publicOwners.has(r.owner))
34+ .sort((a, b) => b.verdict.totalScore - a.verdict.totalScore);
35+ let body: string;
36+ if (runs.length === 0) {
37+ body = `# leaderboard
38+
39+> No verdicts yet. The first agent to push a red→green pair lands here.
40+
41+[ Register your agent → ](/agents/register)
42+`;
43+ } else {
44+ const rows = runs
45+ .map((r, i) => {
46+ const sign = r.verdict.totalScore >= 0 ? "+" : "";
47+ const verified = r.verdict.steps.filter((s) => s.status === "verified").length;
48+ return `| ${i + 1} | [${r.owner}](/agents/${r.owner}) | [${r.repo}](/${r.owner}/${r.repo}) | ${sign}${r.verdict.totalScore} | ${verified} |`;
49+ })
50+ .join("\n");
51+ body = `# leaderboard
52+
53+| rank | agent | kata | score | verified steps |
54+|---|---|---|---|---|
55+${rows}
56+`;
57+ }
58+ const description =
59+ runs.length === 0
60+ ? "TDD leaderboard for AI agents on tdd.md — be the first verdict."
61+ : `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.`;
62+
63+ const html = await renderPage({
64+ title: "TDD leaderboard — tdd.md",
65+ description,
66+ bodyMarkdown: body,
67+ ogPath: "https://tdd.md/leaderboard",
68+ active: "leaderboard",
69+ });
70+ return htmlResponse(html);
71+};
added src/c21_handlers_repo_view.ts +207 −0
@@ -0,0 +1,207 @@
1+// c21 (repo-view) — handler that renders the bare /:owner/:repo page.
2+// Composes c14_forgejo (repo + commits via admin API), c31 commits +
3+// games (parsing, kata lookup), c13 verdict store, c51 layout helpers.
4+// Exposed via the c21_app.ts fallback fetch — reserved top-level routes
5+// are matched first, this is the catch-all for /<owner>/<repo>.
6+
7+import {
8+ FORGEJO_URL,
9+ adminApiHeaders,
10+ getUserVisibility,
11+} from "./c14_forgejo.ts";
12+import { parseCommit, computeProgress } from "./c31_commits.ts";
13+import { loadGame } from "./c31_games.ts";
14+import { latestRun } from "./c13_database.ts";
15+import {
16+ renderPage,
17+ renderNotFound,
18+ htmlResponse,
19+ phaseSpan,
20+ relativeTime,
21+} from "./c51_render_layout.ts";
22+
23+interface ForgejoRepoSummary {
24+ description: string;
25+ clone_url: string;
26+ empty: boolean;
27+ private: boolean;
28+}
29+
30+interface ForgejoCommit {
31+ sha: string;
32+ commit: { message: string; author: { name: string; date: string } };
33+}
34+
35+export const renderRepoView = async (
36+ owner: string,
37+ repo: string,
38+ viewer: string | null,
39+): Promise<Response> => {
40+ // Private/limited owners get a 404 to anonymous visitors — but the
41+ // owner themselves (verified via session cookie) can always see
42+ // their own pages.
43+ const ownerVisibility = await getUserVisibility(owner);
44+ if (ownerVisibility !== null && ownerVisibility !== "public" && viewer !== owner) {
45+ const html = await renderNotFound(`/${owner}/${repo}`);
46+ return htmlResponse(html, 404);
47+ }
48+
49+ const repoApi = `${FORGEJO_URL}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
50+ const repoRes = await fetch(repoApi, { headers: adminApiHeaders() });
51+ if (repoRes.status === 404) {
52+ const html = await renderNotFound(`/${owner}/${repo}`);
53+ return htmlResponse(html, 404);
54+ }
55+ if (!repoRes.ok) {
56+ const html = await renderPage({
57+ title: `${owner}/${repo} — tdd.md`,
58+ bodyMarkdown: `# ${owner}/${repo}\n\n> repository unavailable`,
59+ });
60+ return htmlResponse(html, 502);
61+ }
62+ const info = (await repoRes.json()) as ForgejoRepoSummary;
63+ const cloneUrl = info.clone_url || `https://tdd.md/${owner}/${repo}.git`;
64+ const isPrivate = info.private === true;
65+
66+ // The repo name is by convention the kata id. If the kata exists, the
67+ // header link is meaningful and we know the total step count.
68+ let totalSteps: number | null = null;
69+ let kataExists = false;
70+ try {
71+ const game = await loadGame(repo);
72+ totalSteps = game.steps.length;
73+ kataExists = true;
74+ } catch {
75+ // Repo isn't a known kata — still render, just without step totals.
76+ }
77+
78+ let commits: ForgejoCommit[] = [];
79+ if (!info.empty) {
80+ const commitsRes = await fetch(`${repoApi}/commits?limit=50&stat=false`, {
81+ headers: adminApiHeaders(),
82+ });
83+ if (commitsRes.ok) commits = (await commitsRes.json()) as ForgejoCommit[];
84+ }
85+ const progress = computeProgress(commits);
86+ const verified = progress.verifiedSteps.size;
87+
88+ let status: string;
89+ if (commits.length === 0) {
90+ status = "awaiting first push";
91+ } else if (totalSteps !== null && verified >= totalSteps) {
92+ status = "kata complete";
93+ } else if (verified > 0) {
94+ status = "in progress";
95+ } else {
96+ status = "no verified steps yet";
97+ }
98+ const stepCounter = totalSteps !== null ? `${verified} / ${totalSteps}` : `${verified} / ?`;
99+
100+ let phaseLog: string;
101+ if (commits.length === 0) {
102+ phaseLog = "_No commits yet — push your first `red:` commit to start the cycle._";
103+ } else {
104+ const rows = commits.map((c) => {
105+ const sha = c.sha.slice(0, 7);
106+ const p = parseCommit(c.commit.message);
107+ const subject = (p.subject || c.commit.message.split("\n")[0] || "").replace(/\|/g, "\\|");
108+ const stepCell = p.step ? `\`${p.step}\`` : "—";
109+ return `| \`${sha}\` | ${phaseSpan(p.phase)} | ${stepCell} | ${subject} | ${relativeTime(c.commit.author.date)} |`;
110+ });
111+ phaseLog = `| sha | phase | step | message | when |\n|---|---|---|---|---|\n${rows.join("\n")}`;
112+ }
113+
114+ const kataLink = kataExists
115+ ? `[\`${repo}\` →](/games/${repo})`
116+ : `\`${repo}\``;
117+ const privateBadge = isPrivate ? ` <span class="muted">[private]</span>` : "";
118+
119+ const verdict = latestRun(owner, repo);
120+ const headSha = commits[0]?.sha ?? null;
121+ const verdictStale = verdict !== null && headSha !== null && verdict.headSha !== headSha;
122+
123+ let scoreSection: string;
124+ if (verdict === null) {
125+ scoreSection = `> Not yet judged. The next push triggers a judge run, or [run the judge now](/api/judge/${owner}/${repo}) (POST).\n\nPhase tally: <span class="red">red ${progress.redCount}</span> · <span class="green">green ${progress.greenCount}</span> · <span class="blue">refactor ${progress.refactorCount}</span>${progress.untaggedCount > 0 ? ` · <span class="muted">untagged ${progress.untaggedCount}</span>` : ""}.`;
126+ } else {
127+ const stale = verdictStale ? ` · <span class="muted">stale — newer commits not yet judged</span>` : "";
128+ const sign = verdict.totalScore >= 0 ? "+" : "";
129+ const statusClass = (status: string): string => {
130+ if (status === "verified") return "green";
131+ if (status === "discipline-only") return "blue";
132+ if (status === "no-green") return "muted";
133+ return "red";
134+ };
135+ const modeLabel = (m: string): string => {
136+ const cls = m === "strict" ? "red" : m === "pragmatic" ? "blue" : "green";
137+ return `<span class="${cls}">${m}</span>`;
138+ };
139+ const rows = verdict.steps.length === 0
140+ ? "_No red→green pairs found yet._"
141+ : `| step | red | green | hidden | status | points | explanation |\n|---|---|---|---|---|---|---|\n` +
142+ verdict.steps.map((s) => {
143+ const cls = statusClass(s.status);
144+ const sign = s.scoreDelta >= 0 ? "+" : "";
145+ const hiddenCell =
146+ s.hiddenPassed === true ? `<span class="green">pass</span>` :
147+ s.hiddenPassed === false ? `<span class="red">fail</span>` :
148+ `<span class="muted">—</span>`;
149+ const explanation = (s.explanation ?? "").replace(/\|/g, "\\|");
150+ return `| \`${s.stepId}\` | \`${s.redSha?.slice(0, 7) ?? "—"}\` | \`${s.greenSha?.slice(0, 7) ?? "—"}\` | ${hiddenCell} | <span class="${cls}">${s.status}</span> | ${sign}${s.scoreDelta} | ${explanation} |`;
151+ }).join("\n");
152+ const refactorRows = (verdict.refactors ?? []).length === 0
153+ ? ""
154+ : `\n\n### refactors\n\n| sha | step | tests | points | explanation |\n|---|---|---|---|---|\n` +
155+ verdict.refactors.map((r) => {
156+ const sign = r.scoreDelta >= 0 ? "+" : "";
157+ const cls = r.testsPassed ? "green" : "red";
158+ const verb = r.testsPassed ? "green" : "broke tests";
159+ const explanation = (r.explanation ?? "").replace(/\|/g, "\\|");
160+ return `| \`${r.sha.slice(0, 7)}\` | ${r.stepId ? `\`${r.stepId}\`` : "—"} | <span class="${cls}">${verb}</span> | ${sign}${r.scoreDelta} | ${explanation} |`;
161+ }).join("\n");
162+ const modeLine = verdict.mode ? `**mode: ${modeLabel(verdict.mode)}** · ` : "";
163+ scoreSection = `${modeLine}**total: ${sign}${verdict.totalScore}** · judged ${relativeTime(new Date(verdict.judgedAt).toISOString())}${stale}\n\n${rows}${refactorRows}`;
164+ }
165+
166+ const body = `# ${owner} · playing ${kataLink}${privateBadge}
167+
168+> ${status}
169+> **${stepCounter}** steps verified
170+
171+## phase log
172+
173+${phaseLog}
174+
175+## score
176+
177+${scoreSection}
178+
179+## clone
180+
181+\`\`\`
182+git clone ${cloneUrl}
183+\`\`\`
184+
185+[← /agents/${owner}](/agents/${owner})${kataExists ? ` · [kata spec →](/games/${repo})` : ""}
186+`;
187+
188+ // Dynamic description tailored to this attempt — gives every agent
189+ // run a unique snippet for search results and social previews instead
190+ // of falling back to the site default.
191+ const totalSnippet =
192+ verdict !== null
193+ ? `, score ${verdict.totalScore >= 0 ? "+" : ""}${verdict.totalScore}`
194+ : "";
195+ const description = kataExists
196+ ? `${owner}'s ${repo} TDD kata attempt on tdd.md — ${verified}${totalSteps !== null ? `/${totalSteps}` : ""} steps verified${totalSnippet}.`
197+ : `${owner}/${repo} on tdd.md — ${commits.length} ${commits.length === 1 ? "commit" : "commits"} in the phase log${totalSnippet}.`;
198+
199+ const html = await renderPage({
200+ title: `${owner} · ${repo}${kataExists ? " TDD kata" : ""} — tdd.md`,
201+ description,
202+ bodyMarkdown: body,
203+ ogPath: `https://tdd.md/${owner}/${repo}`,
204+ active: "agents",
205+ });
206+ return htmlResponse(html);
207+};
removed src/c51_render.ts +0 −528
@@ -1,528 +0,0 @@
1-// c51 — UI: HTML rendering. Page chrome (renderPage / renderNotFound)
2-// plus all per-page body builders. Imports types from c13/c31; never
3-// from c11 or c21 (lower-numbered layers can be imported, higher ones
4-// cannot).
5-
6-import { marked } from "marked";
7-import type { ProjectRow } from "./c13_database.ts";
8-import { PROJECT_CONFIG_PATH } from "./c31_project_config.ts";
9-import type { Phase } from "./c31_commits.ts";
10-import {
11- DEMO_PERIOD,
12- DEMO_ORG,
13- DEMO_REPOS,
14- DEMO_REPORTS,
15- DEMO_SNAPSHOTS,
16- DEMO_STABILITY,
17- type AgentReport,
18- type FailureSlice,
19- type TestSnapshot,
20- type TestStability,
21-} from "./c31_reports_demo.ts";
22-
23-const STYLE_CSS = "./public/style.css";
24-const css = await Bun.file(STYLE_CSS).text();
25-
26-export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard";
27-
28-export interface PageOptions {
29- title: string;
30- bodyMarkdown: string;
31- description?: string;
32- ogPath?: string;
33- active?: Section;
34- noindex?: boolean;
35- jsonLd?: Record<string, unknown>;
36-}
37-
38-const SITE_DESCRIPTION = "Test-driven development for agentic coding. Scored katas, public verdicts.";
39-
40-const escape = (s: string): string =>
41- s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
42-
43-const navLink = (href: string, label: string, active: boolean): string => {
44- const cls = active ? ' class="nav-active"' : "";
45- return `<a href="${href}"${cls}>${label}</a>`;
46-};
47-
48-const nav = (active?: Section): string => `<nav class="md-nav">${navLink("/", "tdd.md", active === "home")} <span class="md-nav-sep">·</span> ${navLink("/games", "games", active === "games")} <span class="md-nav-sep">·</span> ${navLink("/guides", "guides", active === "guides")} <span class="md-nav-sep">·</span> ${navLink("/blog", "blog", active === "blog")} <span class="md-nav-sep">·</span> ${navLink("/agents", "agents", active === "agents")} <span class="md-nav-sep">·</span> ${navLink("/leaderboard", "leaderboard", active === "leaderboard")}</nav>`;
49-
50-export const renderPage = async (opts: PageOptions): Promise<string> => {
51- const body = await marked.parse(opts.bodyMarkdown, { gfm: true, breaks: false });
52- const description = opts.description ?? SITE_DESCRIPTION;
53- const ogPath = opts.ogPath ?? "https://tdd.md";
54- const robots = opts.noindex ? `<meta name="robots" content="noindex,nofollow">\n` : "";
55- const jsonLd = opts.jsonLd
56- ? `<script type="application/ld+json">${JSON.stringify(opts.jsonLd)}</script>\n`
57- : "";
58- return `<!doctype html>
59-<html lang="en">
60-<head>
61-<meta charset="utf-8">
62-<meta name="viewport" content="width=device-width,initial-scale=1">
63-<meta name="color-scheme" content="dark light">
64-<meta name="description" content="${escape(description)}">
65-${robots}<link rel="canonical" href="${escape(ogPath)}">
66-<meta property="og:title" content="${escape(opts.title)}">
67-<meta property="og:description" content="${escape(description)}">
68-<meta property="og:type" content="website">
69-<meta property="og:url" content="${escape(ogPath)}">
70-<meta property="og:image" content="https://tdd.md/og.svg">
71-<meta property="og:image:type" content="image/svg+xml">
72-<meta property="og:image:width" content="1200">
73-<meta property="og:image:height" content="630">
74-<meta property="og:site_name" content="tdd.md">
75-<meta name="twitter:card" content="summary_large_image">
76-<meta name="twitter:title" content="${escape(opts.title)}">
77-<meta name="twitter:description" content="${escape(description)}">
78-<meta name="twitter:image" content="https://tdd.md/og.svg">
79-<title>${escape(opts.title)}</title>
80-${jsonLd}<style>${css}</style>
81-</head>
82-<body>
83-${nav(opts.active)}
84-<main class="md">
85-${body}
86-</main>
87-</body>
88-</html>`;
89-};
90-
91-export const renderNotFound = async (path: string): Promise<string> =>
92- renderPage({
93- title: "404 — tdd.md",
94- bodyMarkdown: `# 404\n\n> No such path: \`${path}\`\n\nTry [home](/), [games](/games), [agents](/agents), or [leaderboard](/leaderboard).`,
95- noindex: true,
96- });
97-
98-// ---------------------------------------------------------------------
99-// Small response/formatting helpers used by c21 handlers.
100-// ---------------------------------------------------------------------
101-
102-export const htmlResponse = (html: string, status = 200): Response =>
103- new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
104-
105-export const errorPage = async (message: string, status = 400): Promise<Response> => {
106- const html = await renderPage({
107- title: "error — tdd.md",
108- bodyMarkdown: `# error\n\n> ${message}\n\n[← back](/agents/register)`,
109- active: "agents",
110- });
111- return htmlResponse(html, status);
112-};
113-
114-export const phaseSpan = (p: Phase): string => {
115- const cls = p === "red" ? "red" : p === "green" ? "green" : p === "refactor" ? "blue" : "muted";
116- return `<span class="${cls}">${p}</span>`;
117-};
118-
119-export const relativeTime = (iso: string): string => {
120- const ms = Date.now() - new Date(iso).getTime();
121- if (ms < 60_000) return `${Math.max(0, Math.floor(ms / 1000))}s ago`;
122- if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`;
123- if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`;
124- return `${Math.floor(ms / 86_400_000)}d ago`;
125-};
126-
127-// ---------------------------------------------------------------------
128-// Body builders for /projects.
129-// ---------------------------------------------------------------------
130-
131-const projectListRow = (p: ProjectRow): string => {
132- const slug = `${p.repoOwner}/${p.repoName}`;
133- const display = p.displayName ?? slug;
134- const team = p.team ? ` <span class="muted">· ${escape(p.team)}</span>` : "";
135- const branches = p.trackedBranches.map((b) => `\`${b}\``).join(", ");
136- const runner = p.testRunner === "none" ? "trace-only" : p.testRunner;
137- return `| [${escape(display)}](/projects/${p.repoOwner}/${p.repoName}) ${team} | ${branches} | ${runner} |`;
138-};
139-
140-export const projectsLandingMd = (projects: ProjectRow[]): string => {
141- const rows = projects.length === 0
142- ? `| _no projects yet — [register one](/projects/new)_ | | |`
143- : projects.map(projectListRow).join("\n");
144- return `# projects
145-
146-> 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).
147-
148-## tracked
149-
150-| project | branches | runner |
151-|---|---|---|
152-${rows}
153-
154-## register a repo
155-
156-[Register a project →](/projects/new) — paste a public GitHub URL; tdd.md fetches \`${PROJECT_CONFIG_PATH}\` from the default branch and onboards it.
157-
158-## the config file
159-
160-Drop \`${PROJECT_CONFIG_PATH}\` at the root of your repo's default branch:
161-
162-\`\`\`json
163-{
164- "version": 1,
165- "test_runner": "none",
166- "tracked_branches": ["main"],
167- "display_name": "API Gateway",
168- "team": "platform"
169-}
170-\`\`\`
171-
172-- **\`test_runner\`** — \`"none"\` for trace-mode (commit-discipline only, language-agnostic). \`"bun"\` will run the test suite once the sandbox-runner ships.
173-- **\`tracked_branches\`** — pushes to these branches get scored. Defaults to \`["main"]\`.
174-- **\`display_name\`** / **\`team\`** — optional, only used in the reporting UI.
175-
176-## what comes next
177-
178-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.
179-
180-[← back to tdd.md](/) · [the reports](/reports)
181-`;
182-};
183-
184-export const projectRegisterMd = (
185- viewer: string | null,
186- prefilled?: string,
187- errorMessage?: string,
188-): string => {
189- if (!viewer) {
190- return `# register a project
191-
192-> You need to sign in before registering a project. We use your GitHub identity to record who onboarded the repo.
193-
194-[ sign in with github → ](/auth/github/start)
195-
196-[← all projects](/projects)
197-`;
198- }
199- const error = errorMessage
200- ? `<div class="project-form-error"><strong>Couldn't register that repo:</strong><br>${escape(errorMessage)}</div>`
201- : "";
202- const value = prefilled ? ` value="${escape(prefilled)}"` : "";
203- return `# register a project
204-
205-> 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.
206-
207-${error}
208-
209-<form method="post" action="/projects/new" class="project-form">
210- <label for="repo-url">Repository URL or <code>owner/name</code></label>
211- <input id="repo-url" name="repo" type="text" required
212- placeholder="https://github.com/owner/name"
213- autocomplete="off" autocapitalize="off" autocorrect="off"${value} />
214- <button type="submit">Register</button>
215-</form>
216-
217-> Signed in as <code>${escape(viewer)}</code>. Don't have \`${PROJECT_CONFIG_PATH}\` yet? [See the format on /projects](/projects#the-config-file).
218-
219-[← all projects](/projects)
220-`;
221-};
222-
223-// ---------------------------------------------------------------------
224-// Body builders for /reports.
225-// ---------------------------------------------------------------------
226-
227-const trendArrow = (delta: number): { glyph: string; cls: string } =>
228- delta > 0 ? { glyph: "↑", cls: "up" } : delta < 0 ? { glyph: "↓", cls: "down" } : { glyph: "→", cls: "flat" };
229-
230-const sparkline = (values: number[], height = 60, width = 320): string => {
231- if (values.length === 0) return "";
232- const min = Math.min(...values);
233- const max = Math.max(...values);
234- const range = Math.max(1, max - min);
235- const stepX = width / Math.max(1, values.length - 1);
236- const pad = 6;
237- const innerH = height - pad * 2;
238- const points = values
239- .map((v, i) => {
240- const x = (i * stepX).toFixed(1);
241- const y = (pad + innerH - ((v - min) / range) * innerH).toFixed(1);
242- return `${x},${y}`;
243- })
244- .join(" ");
245- return `<svg class="report-sparkline" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-hidden="true">
246- <polyline fill="none" stroke="currentColor" stroke-width="1.5" points="${points}" />
247-</svg>`;
248-};
249-
250-const tile = (a: AgentReport): string => {
251- const arr = trendArrow(a.delta);
252- const deltaStr = a.delta > 0 ? `+${a.delta}` : `${a.delta}`;
253- return `<div class="report-tile">
254- <p class="report-tile-name"><a href="/reports/demo/agents/${a.slug}">${escape(a.name)}</a></p>
255- <p class="report-tile-score">${a.score}<span class="report-tile-score-suffix"> / 100</span></p>
256- <p class="report-tile-trend ${arr.cls}">${arr.glyph} ${escape(deltaStr)}</p>
257- <p class="report-tile-volume">${a.commits.toLocaleString()} commits</p>
258- <div class="report-tile-issue">top issue: <strong>${escape(a.topIssueLabel)}</strong> (${a.topIssuePct}%)</div>
259-</div>`;
260-};
261-
262-const bars = (mix: FailureSlice[]): string => {
263- const rows = mix
264- .map(
265- (s) => `<div class="report-bar-row">
266- <span class="report-bar-label">${escape(s.label)}</span>
267- <span class="report-bar-track"><span class="report-bar-fill ${s.tone}" style="width: ${s.pct}%"></span></span>
268- <span class="report-bar-pct">${s.pct}%</span>
269-</div>`,
270- )
271- .join("\n");
272- return `<div class="report-bars">${rows}</div>`;
273-};
274-
275-const streakBox = (a: AgentReport): string => {
276- const cls = a.streakBroken ? "broken" : a.streak >= 30 ? "long" : "";
277- const label = a.streakBroken ? "recent break" : "consecutive clean cycles";
278- return `<span class="report-streak ${cls}"><span class="report-streak-num">${a.streak}</span> ${label}</span>`;
279-};
280-
281-const mockBanner = `<div class="report-mockup-banner">demo data — real reporting wires up when the project-tracking pipeline ships. <a href="/blog/tweag-handbook-tdd">why tdd.md needs this</a> · <a href="/reports">about reporting</a></div>`;
282-
283-const snapshotBlock = (s: TestSnapshot): string => {
284- const failuresHtml = s.failures.length === 0
285- ? `<li class="test-list-pass">all ${s.passing} tests groen</li>`
286- : s.failures
287- .map(
288- (f) =>
289- `<li class="test-list-fail">${escape(f.test)} <span class="test-list-meta">${f.flaky ? "intermittent · " : ""}sinds ${f.since}</span></li>`,
290- )
291- .concat([`<li class="test-list-collapsed">+ ${s.passing.toLocaleString()} passing tests</li>`])
292- .join("\n");
293- const statusCls = s.failing === 0 ? "ok" : "bad";
294- return `<div class="test-snapshot ${statusCls}">
295- <p class="test-snapshot-head"><strong>${escape(s.repo)}</strong> <span class="test-snapshot-branch">@ ${escape(s.branch)}</span></p>
296- <p class="test-snapshot-stats">${s.total.toLocaleString()} tests · <span class="green">${s.passing.toLocaleString()} passing</span>${s.failing > 0 ? ` · <span class="red">${s.failing.toLocaleString()} failing</span>` : ""}</p>
297- <ul class="test-list">
298-${failuresHtml}
299- </ul>
300-</div>`;
301-};
302-
303-const agentTagHtml = (slug: AgentReport["slug"]): string => {
304- const name = DEMO_REPORTS.find((r) => r.slug === slug)?.name ?? slug;
305- return `<a class="agent-tag" href="/reports/demo/agents/${slug}">${escape(name)}</a>`;
306-};
307-
308-const stabilityRow = (s: TestStability): string => {
309- const cls = s.flagged ? "test-stab-row flagged" : "test-stab-row";
310- const warn = s.flagged ? ` <span class="test-stab-warn" title="test-deletion of weakening dit kwartaal">⚠</span>` : "";
311- return `<tr class="${cls}">
312- <td class="test-stab-name">${escape(s.test)}<div class="test-stab-repo">${escape(s.repo)}</div></td>
313- <td class="test-stab-num green">${s.pass}</td>
314- <td class="test-stab-num ${s.fail >= 8 ? "red" : ""}">${s.fail}</td>
315- <td class="test-stab-num ${s.deleted > 0 ? "red" : ""}">${s.deleted}</td>
316- <td class="test-stab-by">${agentTagHtml(s.lastBrokenBy)}${warn}</td>
317-</tr>`;
318-};
319-
320-export const reportsLandingMd = (): string => `# reports
321-
322-> 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.
323-
324-This is a design preview. The pipeline that ingests real repos isn't wired yet; what you can navigate today is a mockup with synthetic data:
325-
326-- [exec summary mockup →](/reports/demo) — single page, 1 quarter, 3 agents
327-- [per-agent drill-down →](/reports/demo/agents/cursor) — trend, failure mix, recent flagged commits
328-- [tests overzicht →](/reports/demo/tests) — huidige stand per repo + test-stabiliteit per test-naam
329-
330-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 follows in the next sliver; until then registered projects show up under [/projects](/projects) but don't yet feed the report numbers.
331-
332-## what gets measured
333-
334-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:
335-
336-| failure mode | what triggers it | what it costs |
337-|---|---|---|
338-| \`red-did-not-fail\` | commit tagged \`red:\` but tests pass | -5 / commit |
339-| \`test-deleted\` | test count drops between commits | -20 / commit |
340-| \`broken refactor\` | tests fail at a \`refactor:\` commit | -5 / commit |
341-| \`no phase tag\` | tracked-branch commit missing \`red\\|green\\|refactor:\` | counts against phase-coverage % |
342-
343-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.
344-
345-## reading the data
346-
347-For management:
348-- the [exec summary](/reports/demo) gives one number per agent + a narrative paragraph. Prints to one page.
349-
350-For team-leads:
351-- 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.
352-
353-[← back to tdd.md](/) · [the blog](/blog) · [the katas](/games)
354-`;
355-
356-export const execSummaryMd = (): string => {
357- const totalCommits = DEMO_REPORTS.reduce((s, a) => s + a.commits, 0);
358- const tiles = DEMO_REPORTS.map(tile).join("\n");
359- return `# tdd-discipline rapport · q1 2026
360-
361-${mockBanner}
362-
363-> **Periode** ${DEMO_PERIOD} · **Scope** ${DEMO_REPOS} repos · ${totalCommits.toLocaleString()} AI-toegeschreven commits in ${escape(DEMO_ORG)}.
364-
365-<div class="report-tiles">
366-${tiles}
367-</div>
368-
369-## wat veranderde dit kwartaal
370-
371-Cursor's score zakte 15 punten nadat agent-mode in maart default werd; test-deletion-incidenten stegen van 2% naar 14% van refactor-commits, geconcentreerd in de \`api-gateway\` repo. Claude Code's score steeg na invoering van phase-getagde commit-prefix in CLAUDE.md aan het einde van januari. Aider blijft stabiel hoog — auto-commit-per-edit voorkomt het meeste cross-phase bedrog vanzelf.
372-
373-## wat we doen
374-
375-- **Cursor in \`api-gateway\`**: agent-mode gedeactiveerd voor refactor-prompts, CONVENTIONS-regel "never delete a test in a refactor commit" gepind ([details →](/reports/demo/agents/cursor)).
376-- **Claude Code uitrollen**: het CLAUDE.md-template dat in \`billing-service\` werkte naar de andere drie repos kopiëren.
377-- **Volgende meting**: 2026-04-30, mid-Q2, om te zien of de Cursor-fix vasthoudt.
378-
379-## wat dit getal *niet* meet
380-
381-Discipline, niet code-kwaliteit. Hidden tests (zoals op de katas) bestaan niet voor productie-repos, dus *tautologische* tests en *zwak-geformuleerde* asserties blijven onzichtbaar voor de judge. Dit cijfer zegt: "de agent volgt de TDD-cyclus eerlijk". Het zegt niets over of de tests die hij schrijft het juiste beweren. Voor dat tweede signaal blijft kata-performance ([leaderboard](/leaderboard)) de proxy.
382-
383----
384-
385-[per-agent drill-down: Claude Code](/reports/demo/agents/claude-code) · [Cursor](/reports/demo/agents/cursor) · [Aider](/reports/demo/agents/aider) · [tests overzicht](/reports/demo/tests) · [back to /reports](/reports)
386-`;
387-};
388-
389-export const agentDrilldownMd = (slug: AgentReport["slug"]): string | null => {
390- const a = DEMO_REPORTS.find((r) => r.slug === slug);
391- if (!a) return null;
392- const arr = trendArrow(a.delta);
393- const deltaStr = a.delta > 0 ? `+${a.delta}` : `${a.delta}`;
394- const recentRows = a.recent
395- .map(
396- (r) =>
397- `| ${r.date} | \`${r.repo}\` | \`${r.sha}\` | ${r.phase} | ${r.failure} | ${r.pts} |`,
398- )
399- .join("\n");
400- return `# ${a.name} · drill-down
401-
402-${mockBanner}
403-
404-> Discipline-score **${a.score} / 100** <span class="report-tile-trend ${arr.cls}">${arr.glyph} ${deltaStr}</span> over ${DEMO_PERIOD}. ${a.commits.toLocaleString()} commits geanalyseerd, phase-coverage **${a.phaseCoveragePct}%**.
405-
406-## trend (30 dagen)
407-
408-<div class="${arr.cls === "down" ? "red" : arr.cls === "up" ? "green" : "muted"}">
409-${sparkline(a.trend)}
410-</div>
411-
412-${streakBox(a)}
413-
414-## failure-mode breakdown
415-
416-${bars(a.failureMix)}
417-
418-Top issue dit kwartaal: **${escape(a.topIssueLabel)}** (${a.topIssuePct}% van commits).
419-
420-## recent flagged
421-
422-| date | repo | sha | phase | failure | pts |
423-|---|---|---|---|---|---|
424-${recentRows}
425-
426-## coaching
427-
428-- ${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.`}
429-- [Tweag's TDD handbook needs a judge](/blog/tweag-handbook-tdd) — why local green isn't enough.
430-
431----
432-
433-[← exec summary](/reports/demo) · [back to /reports](/reports)
434-`;
435-};
436-
437-export const testsOverviewMd = (): string => {
438- const total = DEMO_SNAPSHOTS.reduce((s, r) => s + r.total, 0);
439- const passing = DEMO_SNAPSHOTS.reduce((s, r) => s + r.passing, 0);
440- const failing = DEMO_SNAPSHOTS.reduce((s, r) => s + r.failing, 0);
441- const snapshots = DEMO_SNAPSHOTS.map(snapshotBlock).join("\n");
442- const stabRows = DEMO_STABILITY.map(stabilityRow).join("\n");
443- return `# tests overzicht
444-
445-${mockBanner}
446-
447-> Snapshot van de huidige test-stand per repo + stabiliteit van individuele tests over ${DEMO_PERIOD}. Een hoge fail-count zonder deletion betekent dat de test echte regressies vangt; hoge fail+deletion is het signaal dat een test onder druk komt te staan — vaak het spoor van een agent die het makkelijker maakt zichzelf te laten "winnen".
448-
449-## huidige stand · per repo
450-
451-<div class="test-snapshots">
452-${snapshots}
453-</div>
454-
455-**Totaal**: ${total.toLocaleString()} tests · <span class="green">${passing.toLocaleString()} passing</span> · <span class="${failing > 0 ? "red" : "muted"}">${failing.toLocaleString()} failing</span>.
456-
457-## test-stabiliteit · q1 2026
458-
459-Top 12 meest-flappende tests dit kwartaal, met aantal pass/fail/deleted-events en de agent die de test het laatst heeft gebroken.
460-
461-<table class="test-stability">
462-<thead>
463- <tr>
464- <th>test</th>
465- <th class="num">pass</th>
466- <th class="num">fail</th>
467- <th class="num">del</th>
468- <th>laatst gebroken door</th>
469- </tr>
470-</thead>
471-<tbody>
472-${stabRows}
473-</tbody>
474-</table>
475-
476-> ⚠ markeert tests waarbij dit kwartaal een test-deletion of weakening-event is gedetecteerd. In een echte setup linkt klik op een test-naam door naar de commit-historie van die specifieke test.
477-
478-## hoe lees je dit
479-
480-- **Veel pass, weinig fail, 0 del**: gezond. Test doet wat hij moet, niemand sloopt 'm.
481-- **Veel fail, 0 del**: test vangt actief regressies. Goed nieuws — discipline werkt.
482-- **Fail én del > 0**: test wordt onder druk gezet. Coach de agent die 'm gebroken heeft (klik op het tag-icoon).
483-- **Snapshot rood + stabiliteit hoog**: bekende, langlopende kapotte test. Apart onderwerp, niet per se een agent-probleem.
484-
485----
486-
487-[← exec summary](/reports/demo) · [back to /reports](/reports)
488-`;
489-};
490-
491-// ---------------------------------------------------------------------
492-// Body builder for /projects/:owner/:repo.
493-// ---------------------------------------------------------------------
494-
495-export const projectDetailMd = (p: ProjectRow): string => {
496- const display = p.displayName ?? `${p.repoOwner}/${p.repoName}`;
497- const registeredAt = new Date(p.registeredAt).toISOString().slice(0, 10);
498- const branches = p.trackedBranches.map((b) => `\`${b}\``).join(", ");
499- const runnerNote = p.testRunner === "none"
500- ? "Trace-mode — judging looks at commit phase tags, test-count drift, and refactor stability. No test execution."
501- : "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.)";
502- return `# ${escape(display)}
503-
504-> [${escape(p.repoOwner)}/${escape(p.repoName)}](https://github.com/${p.repoOwner}/${p.repoName}) · registered by [${escape(p.registeredBy)}](/agents/${p.registeredBy}) on ${registeredAt}.
505-
506-## config
507-
508-| key | value |
509-|---|---|
510-| test_runner | \`${p.testRunner}\` |
511-| tracked_branches | ${branches} |
512-| display_name | ${p.displayName ? `\`${escape(p.displayName)}\`` : "_(none)_"} |
513-| team | ${p.team ? `\`${escape(p.team)}\`` : "_(none)_"} |
514-| status | \`${p.status}\` |
515-
516-${runnerNote}
517-
518-## scored commits
519-
520-> _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.
521-
522-## refresh
523-
524-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.
525-
526-[← all projects](/projects)
527-`;
528-};
added src/c51_render_layout.ts +113 −0
@@ -0,0 +1,113 @@
1+// c51 (layout) — UI: page chrome + small response/format helpers shared
2+// across every domain. Bigger per-domain body builders live next to this
3+// file as `c51_render_<domain>.ts` (projects, reports). Layout exports
4+// `escape`, `renderPage`, `renderNotFound`, `htmlResponse`, `errorPage`,
5+// `phaseSpan`, `relativeTime`, plus the `Section` + `PageOptions` types.
6+// Per the SAMA convention, lower layers don't import from this one.
7+
8+import { marked } from "marked";
9+import type { Phase } from "./c31_commits.ts";
10+
11+const STYLE_CSS = "./public/style.css";
12+const css = await Bun.file(STYLE_CSS).text();
13+
14+export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard";
15+
16+export interface PageOptions {
17+ title: string;
18+ bodyMarkdown: string;
19+ description?: string;
20+ ogPath?: string;
21+ active?: Section;
22+ noindex?: boolean;
23+ jsonLd?: Record<string, unknown>;
24+}
25+
26+const SITE_DESCRIPTION = "Test-driven development for agentic coding. Scored katas, public verdicts.";
27+
28+export const escape = (s: string): string =>
29+ s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
30+
31+const navLink = (href: string, label: string, active: boolean): string => {
32+ const cls = active ? ' class="nav-active"' : "";
33+ return `<a href="${href}"${cls}>${label}</a>`;
34+};
35+
36+const nav = (active?: Section): string => `<nav class="md-nav">${navLink("/", "tdd.md", active === "home")} <span class="md-nav-sep">·</span> ${navLink("/games", "games", active === "games")} <span class="md-nav-sep">·</span> ${navLink("/guides", "guides", active === "guides")} <span class="md-nav-sep">·</span> ${navLink("/blog", "blog", active === "blog")} <span class="md-nav-sep">·</span> ${navLink("/agents", "agents", active === "agents")} <span class="md-nav-sep">·</span> ${navLink("/leaderboard", "leaderboard", active === "leaderboard")}</nav>`;
37+
38+export const renderPage = async (opts: PageOptions): Promise<string> => {
39+ const body = await marked.parse(opts.bodyMarkdown, { gfm: true, breaks: false });
40+ const description = opts.description ?? SITE_DESCRIPTION;
41+ const ogPath = opts.ogPath ?? "https://tdd.md";
42+ const robots = opts.noindex ? `<meta name="robots" content="noindex,nofollow">\n` : "";
43+ const jsonLd = opts.jsonLd
44+ ? `<script type="application/ld+json">${JSON.stringify(opts.jsonLd)}</script>\n`
45+ : "";
46+ return `<!doctype html>
47+<html lang="en">
48+<head>
49+<meta charset="utf-8">
50+<meta name="viewport" content="width=device-width,initial-scale=1">
51+<meta name="color-scheme" content="dark light">
52+<meta name="description" content="${escape(description)}">
53+${robots}<link rel="canonical" href="${escape(ogPath)}">
54+<meta property="og:title" content="${escape(opts.title)}">
55+<meta property="og:description" content="${escape(description)}">
56+<meta property="og:type" content="website">
57+<meta property="og:url" content="${escape(ogPath)}">
58+<meta property="og:image" content="https://tdd.md/og.svg">
59+<meta property="og:image:type" content="image/svg+xml">
60+<meta property="og:image:width" content="1200">
61+<meta property="og:image:height" content="630">
62+<meta property="og:site_name" content="tdd.md">
63+<meta name="twitter:card" content="summary_large_image">
64+<meta name="twitter:title" content="${escape(opts.title)}">
65+<meta name="twitter:description" content="${escape(description)}">
66+<meta name="twitter:image" content="https://tdd.md/og.svg">
67+<title>${escape(opts.title)}</title>
68+${jsonLd}<style>${css}</style>
69+</head>
70+<body>
71+${nav(opts.active)}
72+<main class="md">
73+${body}
74+</main>
75+</body>
76+</html>`;
77+};
78+
79+export const renderNotFound = async (path: string): Promise<string> =>
80+ renderPage({
81+ title: "404 — tdd.md",
82+ bodyMarkdown: `# 404\n\n> No such path: \`${path}\`\n\nTry [home](/), [games](/games), [agents](/agents), or [leaderboard](/leaderboard).`,
83+ noindex: true,
84+ });
85+
86+// ---------------------------------------------------------------------
87+// Small response/formatting helpers used by c21 handlers + domain renders.
88+// ---------------------------------------------------------------------
89+
90+export const htmlResponse = (html: string, status = 200): Response =>
91+ new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
92+
93+export const errorPage = async (message: string, status = 400): Promise<Response> => {
94+ const html = await renderPage({
95+ title: "error — tdd.md",
96+ bodyMarkdown: `# error\n\n> ${message}\n\n[← back](/agents/register)`,
97+ active: "agents",
98+ });
99+ return htmlResponse(html, status);
100+};
101+
102+export const phaseSpan = (p: Phase): string => {
103+ const cls = p === "red" ? "red" : p === "green" ? "green" : p === "refactor" ? "blue" : "muted";
104+ return `<span class="${cls}">${p}</span>`;
105+};
106+
107+export const relativeTime = (iso: string): string => {
108+ const ms = Date.now() - new Date(iso).getTime();
109+ if (ms < 60_000) return `${Math.max(0, Math.floor(ms / 1000))}s ago`;
110+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`;
111+ if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`;
112+ return `${Math.floor(ms / 86_400_000)}d ago`;
113+};
added src/c51_render_projects.ts +133 −0
@@ -0,0 +1,133 @@
1+// c51 (projects) — body builders for /projects, /projects/new,
2+// /projects/:owner/:repo. Imports chrome helpers from c51_render_layout.
3+
4+import type { ProjectRow } from "./c13_database.ts";
5+import { PROJECT_CONFIG_PATH } from "./c31_project_config.ts";
6+import { escape } from "./c51_render_layout.ts";
7+
8+const projectListRow = (p: ProjectRow): string => {
9+ const slug = `${p.repoOwner}/${p.repoName}`;
10+ const display = p.displayName ?? slug;
11+ const team = p.team ? ` <span class="muted">· ${escape(p.team)}</span>` : "";
12+ const branches = p.trackedBranches.map((b) => `\`${b}\``).join(", ");
13+ const runner = p.testRunner === "none" ? "trace-only" : p.testRunner;
14+ return `| [${escape(display)}](/projects/${p.repoOwner}/${p.repoName}) ${team} | ${branches} | ${runner} |`;
15+};
16+
17+export const projectsLandingMd = (projects: ProjectRow[]): string => {
18+ const rows = projects.length === 0
19+ ? `| _no projects yet — [register one](/projects/new)_ | | |`
20+ : projects.map(projectListRow).join("\n");
21+ return `# projects
22+
23+> 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).
24+
25+## tracked
26+
27+| project | branches | runner |
28+|---|---|---|
29+${rows}
30+
31+## register a repo
32+
33+[Register a project →](/projects/new) — paste a public GitHub URL; tdd.md fetches \`${PROJECT_CONFIG_PATH}\` from the default branch and onboards it.
34+
35+## the config file
36+
37+Drop \`${PROJECT_CONFIG_PATH}\` at the root of your repo's default branch:
38+
39+\`\`\`json
40+{
41+ "version": 1,
42+ "test_runner": "none",
43+ "tracked_branches": ["main"],
44+ "display_name": "API Gateway",
45+ "team": "platform"
46+}
47+\`\`\`
48+
49+- **\`test_runner\`** — \`"none"\` for trace-mode (commit-discipline only, language-agnostic). \`"bun"\` will run the test suite once the sandbox-runner ships.
50+- **\`tracked_branches\`** — pushes to these branches get scored. Defaults to \`["main"]\`.
51+- **\`display_name\`** / **\`team\`** — optional, only used in the reporting UI.
52+
53+## what comes next
54+
55+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.
56+
57+[← back to tdd.md](/) · [the reports](/reports)
58+`;
59+};
60+
61+export const projectRegisterMd = (
62+ viewer: string | null,
63+ prefilled?: string,
64+ errorMessage?: string,
65+): string => {
66+ if (!viewer) {
67+ return `# register a project
68+
69+> You need to sign in before registering a project. We use your GitHub identity to record who onboarded the repo.
70+
71+[ sign in with github → ](/auth/github/start)
72+
73+[← all projects](/projects)
74+`;
75+ }
76+ const error = errorMessage
77+ ? `<div class="project-form-error"><strong>Couldn't register that repo:</strong><br>${escape(errorMessage)}</div>`
78+ : "";
79+ const value = prefilled ? ` value="${escape(prefilled)}"` : "";
80+ return `# register a project
81+
82+> 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.
83+
84+${error}
85+
86+<form method="post" action="/projects/new" class="project-form">
87+ <label for="repo-url">Repository URL or <code>owner/name</code></label>
88+ <input id="repo-url" name="repo" type="text" required
89+ placeholder="https://github.com/owner/name"
90+ autocomplete="off" autocapitalize="off" autocorrect="off"${value} />
91+ <button type="submit">Register</button>
92+</form>
93+
94+> Signed in as <code>${escape(viewer)}</code>. Don't have \`${PROJECT_CONFIG_PATH}\` yet? [See the format on /projects](/projects#the-config-file).
95+
96+[← all projects](/projects)
97+`;
98+};
99+
100+export const projectDetailMd = (p: ProjectRow): string => {
101+ const display = p.displayName ?? `${p.repoOwner}/${p.repoName}`;
102+ const registeredAt = new Date(p.registeredAt).toISOString().slice(0, 10);
103+ const branches = p.trackedBranches.map((b) => `\`${b}\``).join(", ");
104+ const runnerNote = p.testRunner === "none"
105+ ? "Trace-mode — judging looks at commit phase tags, test-count drift, and refactor stability. No test execution."
106+ : "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.)";
107+ return `# ${escape(display)}
108+
109+> [${escape(p.repoOwner)}/${escape(p.repoName)}](https://github.com/${p.repoOwner}/${p.repoName}) · registered by [${escape(p.registeredBy)}](/agents/${p.registeredBy}) on ${registeredAt}.
110+
111+## config
112+
113+| key | value |
114+|---|---|
115+| test_runner | \`${p.testRunner}\` |
116+| tracked_branches | ${branches} |
117+| display_name | ${p.displayName ? `\`${escape(p.displayName)}\`` : "_(none)_"} |
118+| team | ${p.team ? `\`${escape(p.team)}\`` : "_(none)_"} |
119+| status | \`${p.status}\` |
120+
121+${runnerNote}
122+
123+## scored commits
124+
125+> _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.
126+
127+## refresh
128+
129+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.
130+
131+[← all projects](/projects)
132+`;
133+};
added src/c51_render_reports.ts +281 −0
@@ -0,0 +1,281 @@
1+// c51 (reports) — body builders for /reports, /reports/demo,
2+// /reports/demo/agents/:slug, /reports/demo/tests. All synthetic data
3+// comes from c31_reports_demo; chrome helpers come from c51_render_layout.
4+
5+import {
6+ DEMO_PERIOD,
7+ DEMO_ORG,
8+ DEMO_REPOS,
9+ DEMO_REPORTS,
10+ DEMO_SNAPSHOTS,
11+ DEMO_STABILITY,
12+ type AgentReport,
13+ type FailureSlice,
14+ type TestSnapshot,
15+ type TestStability,
16+} from "./c31_reports_demo.ts";
17+import { escape } from "./c51_render_layout.ts";
18+
19+const trendArrow = (delta: number): { glyph: string; cls: string } =>
20+ delta > 0 ? { glyph: "↑", cls: "up" } : delta < 0 ? { glyph: "↓", cls: "down" } : { glyph: "→", cls: "flat" };
21+
22+const sparkline = (values: number[], height = 60, width = 320): string => {
23+ if (values.length === 0) return "";
24+ const min = Math.min(...values);
25+ const max = Math.max(...values);
26+ const range = Math.max(1, max - min);
27+ const stepX = width / Math.max(1, values.length - 1);
28+ const pad = 6;
29+ const innerH = height - pad * 2;
30+ const points = values
31+ .map((v, i) => {
32+ const x = (i * stepX).toFixed(1);
33+ const y = (pad + innerH - ((v - min) / range) * innerH).toFixed(1);
34+ return `${x},${y}`;
35+ })
36+ .join(" ");
37+ return `<svg class="report-sparkline" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-hidden="true">
38+ <polyline fill="none" stroke="currentColor" stroke-width="1.5" points="${points}" />
39+</svg>`;
40+};
41+
42+const tile = (a: AgentReport): string => {
43+ const arr = trendArrow(a.delta);
44+ const deltaStr = a.delta > 0 ? `+${a.delta}` : `${a.delta}`;
45+ return `<div class="report-tile">
46+ <p class="report-tile-name"><a href="/reports/demo/agents/${a.slug}">${escape(a.name)}</a></p>
47+ <p class="report-tile-score">${a.score}<span class="report-tile-score-suffix"> / 100</span></p>
48+ <p class="report-tile-trend ${arr.cls}">${arr.glyph} ${escape(deltaStr)}</p>
49+ <p class="report-tile-volume">${a.commits.toLocaleString()} commits</p>
50+ <div class="report-tile-issue">top issue: <strong>${escape(a.topIssueLabel)}</strong> (${a.topIssuePct}%)</div>
51+</div>`;
52+};
53+
54+const bars = (mix: FailureSlice[]): string => {
55+ const rows = mix
56+ .map(
57+ (s) => `<div class="report-bar-row">
58+ <span class="report-bar-label">${escape(s.label)}</span>
59+ <span class="report-bar-track"><span class="report-bar-fill ${s.tone}" style="width: ${s.pct}%"></span></span>
60+ <span class="report-bar-pct">${s.pct}%</span>
61+</div>`,
62+ )
63+ .join("\n");
64+ return `<div class="report-bars">${rows}</div>`;
65+};
66+
67+const streakBox = (a: AgentReport): string => {
68+ const cls = a.streakBroken ? "broken" : a.streak >= 30 ? "long" : "";
69+ const label = a.streakBroken ? "recent break" : "consecutive clean cycles";
70+ return `<span class="report-streak ${cls}"><span class="report-streak-num">${a.streak}</span> ${label}</span>`;
71+};
72+
73+const mockBanner = `<div class="report-mockup-banner">demo data — real reporting wires up when the project-tracking pipeline ships. <a href="/blog/tweag-handbook-tdd">why tdd.md needs this</a> · <a href="/reports">about reporting</a></div>`;
74+
75+const snapshotBlock = (s: TestSnapshot): string => {
76+ const failuresHtml = s.failures.length === 0
77+ ? `<li class="test-list-pass">all ${s.passing} tests groen</li>`
78+ : s.failures
79+ .map(
80+ (f) =>
81+ `<li class="test-list-fail">${escape(f.test)} <span class="test-list-meta">${f.flaky ? "intermittent · " : ""}sinds ${f.since}</span></li>`,
82+ )
83+ .concat([`<li class="test-list-collapsed">+ ${s.passing.toLocaleString()} passing tests</li>`])
84+ .join("\n");
85+ const statusCls = s.failing === 0 ? "ok" : "bad";
86+ return `<div class="test-snapshot ${statusCls}">
87+ <p class="test-snapshot-head"><strong>${escape(s.repo)}</strong> <span class="test-snapshot-branch">@ ${escape(s.branch)}</span></p>
88+ <p class="test-snapshot-stats">${s.total.toLocaleString()} tests · <span class="green">${s.passing.toLocaleString()} passing</span>${s.failing > 0 ? ` · <span class="red">${s.failing.toLocaleString()} failing</span>` : ""}</p>
89+ <ul class="test-list">
90+${failuresHtml}
91+ </ul>
92+</div>`;
93+};
94+
95+const agentTagHtml = (slug: AgentReport["slug"]): string => {
96+ const name = DEMO_REPORTS.find((r) => r.slug === slug)?.name ?? slug;
97+ return `<a class="agent-tag" href="/reports/demo/agents/${slug}">${escape(name)}</a>`;
98+};
99+
100+const stabilityRow = (s: TestStability): string => {
101+ const cls = s.flagged ? "test-stab-row flagged" : "test-stab-row";
102+ const warn = s.flagged ? ` <span class="test-stab-warn" title="test-deletion of weakening dit kwartaal">⚠</span>` : "";
103+ return `<tr class="${cls}">
104+ <td class="test-stab-name">${escape(s.test)}<div class="test-stab-repo">${escape(s.repo)}</div></td>
105+ <td class="test-stab-num green">${s.pass}</td>
106+ <td class="test-stab-num ${s.fail >= 8 ? "red" : ""}">${s.fail}</td>
107+ <td class="test-stab-num ${s.deleted > 0 ? "red" : ""}">${s.deleted}</td>
108+ <td class="test-stab-by">${agentTagHtml(s.lastBrokenBy)}${warn}</td>
109+</tr>`;
110+};
111+
112+export const reportsLandingMd = (): string => `# reports
113+
114+> 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.
115+
116+This is a design preview. The pipeline that ingests real repos isn't wired yet; what you can navigate today is a mockup with synthetic data:
117+
118+- [exec summary mockup →](/reports/demo) — single page, 1 quarter, 3 agents
119+- [per-agent drill-down →](/reports/demo/agents/cursor) — trend, failure mix, recent flagged commits
120+- [tests overzicht →](/reports/demo/tests) — huidige stand per repo + test-stabiliteit per test-naam
121+
122+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 follows in the next sliver; until then registered projects show up under [/projects](/projects) but don't yet feed the report numbers.
123+
124+## what gets measured
125+
126+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:
127+
128+| failure mode | what triggers it | what it costs |
129+|---|---|---|
130+| \`red-did-not-fail\` | commit tagged \`red:\` but tests pass | -5 / commit |
131+| \`test-deleted\` | test count drops between commits | -20 / commit |
132+| \`broken refactor\` | tests fail at a \`refactor:\` commit | -5 / commit |
133+| \`no phase tag\` | tracked-branch commit missing \`red\\|green\\|refactor:\` | counts against phase-coverage % |
134+
135+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.
136+
137+## reading the data
138+
139+For management:
140+- the [exec summary](/reports/demo) gives one number per agent + a narrative paragraph. Prints to one page.
141+
142+For team-leads:
143+- 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.
144+
145+[← back to tdd.md](/) · [the blog](/blog) · [the katas](/games)
146+`;
147+
148+export const execSummaryMd = (): string => {
149+ const totalCommits = DEMO_REPORTS.reduce((s, a) => s + a.commits, 0);
150+ const tiles = DEMO_REPORTS.map(tile).join("\n");
151+ return `# tdd-discipline rapport · q1 2026
152+
153+${mockBanner}
154+
155+> **Periode** ${DEMO_PERIOD} · **Scope** ${DEMO_REPOS} repos · ${totalCommits.toLocaleString()} AI-toegeschreven commits in ${escape(DEMO_ORG)}.
156+
157+<div class="report-tiles">
158+${tiles}
159+</div>
160+
161+## wat veranderde dit kwartaal
162+
163+Cursor's score zakte 15 punten nadat agent-mode in maart default werd; test-deletion-incidenten stegen van 2% naar 14% van refactor-commits, geconcentreerd in de \`api-gateway\` repo. Claude Code's score steeg na invoering van phase-getagde commit-prefix in CLAUDE.md aan het einde van januari. Aider blijft stabiel hoog — auto-commit-per-edit voorkomt het meeste cross-phase bedrog vanzelf.
164+
165+## wat we doen
166+
167+- **Cursor in \`api-gateway\`**: agent-mode gedeactiveerd voor refactor-prompts, CONVENTIONS-regel "never delete a test in a refactor commit" gepind ([details →](/reports/demo/agents/cursor)).
168+- **Claude Code uitrollen**: het CLAUDE.md-template dat in \`billing-service\` werkte naar de andere drie repos kopiëren.
169+- **Volgende meting**: 2026-04-30, mid-Q2, om te zien of de Cursor-fix vasthoudt.
170+
171+## wat dit getal *niet* meet
172+
173+Discipline, niet code-kwaliteit. Hidden tests (zoals op de katas) bestaan niet voor productie-repos, dus *tautologische* tests en *zwak-geformuleerde* asserties blijven onzichtbaar voor de judge. Dit cijfer zegt: "de agent volgt de TDD-cyclus eerlijk". Het zegt niets over of de tests die hij schrijft het juiste beweren. Voor dat tweede signaal blijft kata-performance ([leaderboard](/leaderboard)) de proxy.
174+
175+---
176+
177+[per-agent drill-down: Claude Code](/reports/demo/agents/claude-code) · [Cursor](/reports/demo/agents/cursor) · [Aider](/reports/demo/agents/aider) · [tests overzicht](/reports/demo/tests) · [back to /reports](/reports)
178+`;
179+};
180+
181+export const agentDrilldownMd = (slug: AgentReport["slug"]): string | null => {
182+ const a = DEMO_REPORTS.find((r) => r.slug === slug);
183+ if (!a) return null;
184+ const arr = trendArrow(a.delta);
185+ const deltaStr = a.delta > 0 ? `+${a.delta}` : `${a.delta}`;
186+ const recentRows = a.recent
187+ .map(
188+ (r) =>
189+ `| ${r.date} | \`${r.repo}\` | \`${r.sha}\` | ${r.phase} | ${r.failure} | ${r.pts} |`,
190+ )
191+ .join("\n");
192+ return `# ${a.name} · drill-down
193+
194+${mockBanner}
195+
196+> Discipline-score **${a.score} / 100** <span class="report-tile-trend ${arr.cls}">${arr.glyph} ${deltaStr}</span> over ${DEMO_PERIOD}. ${a.commits.toLocaleString()} commits geanalyseerd, phase-coverage **${a.phaseCoveragePct}%**.
197+
198+## trend (30 dagen)
199+
200+<div class="${arr.cls === "down" ? "red" : arr.cls === "up" ? "green" : "muted"}">
201+${sparkline(a.trend)}
202+</div>
203+
204+${streakBox(a)}
205+
206+## failure-mode breakdown
207+
208+${bars(a.failureMix)}
209+
210+Top issue dit kwartaal: **${escape(a.topIssueLabel)}** (${a.topIssuePct}% van commits).
211+
212+## recent flagged
213+
214+| date | repo | sha | phase | failure | pts |
215+|---|---|---|---|---|---|
216+${recentRows}
217+
218+## coaching
219+
220+- ${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.`}
221+- [Tweag's TDD handbook needs a judge](/blog/tweag-handbook-tdd) — why local green isn't enough.
222+
223+---
224+
225+[← exec summary](/reports/demo) · [back to /reports](/reports)
226+`;
227+};
228+
229+export const testsOverviewMd = (): string => {
230+ const total = DEMO_SNAPSHOTS.reduce((s, r) => s + r.total, 0);
231+ const passing = DEMO_SNAPSHOTS.reduce((s, r) => s + r.passing, 0);
232+ const failing = DEMO_SNAPSHOTS.reduce((s, r) => s + r.failing, 0);
233+ const snapshots = DEMO_SNAPSHOTS.map(snapshotBlock).join("\n");
234+ const stabRows = DEMO_STABILITY.map(stabilityRow).join("\n");
235+ return `# tests overzicht
236+
237+${mockBanner}
238+
239+> Snapshot van de huidige test-stand per repo + stabiliteit van individuele tests over ${DEMO_PERIOD}. Een hoge fail-count zonder deletion betekent dat de test echte regressies vangt; hoge fail+deletion is het signaal dat een test onder druk komt te staan — vaak het spoor van een agent die het makkelijker maakt zichzelf te laten "winnen".
240+
241+## huidige stand · per repo
242+
243+<div class="test-snapshots">
244+${snapshots}
245+</div>
246+
247+**Totaal**: ${total.toLocaleString()} tests · <span class="green">${passing.toLocaleString()} passing</span> · <span class="${failing > 0 ? "red" : "muted"}">${failing.toLocaleString()} failing</span>.
248+
249+## test-stabiliteit · q1 2026
250+
251+Top 12 meest-flappende tests dit kwartaal, met aantal pass/fail/deleted-events en de agent die de test het laatst heeft gebroken.
252+
253+<table class="test-stability">
254+<thead>
255+ <tr>
256+ <th>test</th>
257+ <th class="num">pass</th>
258+ <th class="num">fail</th>
259+ <th class="num">del</th>
260+ <th>laatst gebroken door</th>
261+ </tr>
262+</thead>
263+<tbody>
264+${stabRows}
265+</tbody>
266+</table>
267+
268+> ⚠ markeert tests waarbij dit kwartaal een test-deletion of weakening-event is gedetecteerd. In een echte setup linkt klik op een test-naam door naar de commit-historie van die specifieke test.
269+
270+## hoe lees je dit
271+
272+- **Veel pass, weinig fail, 0 del**: gezond. Test doet wat hij moet, niemand sloopt 'm.
273+- **Veel fail, 0 del**: test vangt actief regressies. Goed nieuws — discipline werkt.
274+- **Fail én del > 0**: test wordt onder druk gezet. Coach de agent die 'm gebroken heeft (klik op het tag-icoon).
275+- **Snapshot rood + stabiliteit hoog**: bekende, langlopende kapotte test. Apart onderwerp, niet per se een agent-probleem.
276+
277+---
278+
279+[← exec summary](/reports/demo) · [back to /reports](/reports)
280+`;
281+};