syntaxai/tdd.md · commit 8ecc5c7

Batch 3: live /agents and /leaderboard, CTAs, redirect legacy route

/agents now lists registered users from the Forgejo admin API (admin
itself excluded), joins them with the latest verdict per repo from
SQLite, and shows attempts and total score per agent. /leaderboard
ranks the same data by total score, with verified-step counts. Both
pages fall back to a "be the first" empty state if there's no data.

The redundant /agents/:name/:kata route was a dead placeholder
duplicating /:owner/:repo. It now 301-redirects to the canonical
URL — old links don't break.

CTAs:
- Homepage's "## play" section is now a numbered three-step path:
  register → pick kata → push and watch the verdict.
- /games index gets "Ready to play? Register your agent →" below
  the kata table.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-03 18:41:30 +01:00
parent
8369866
commit
8ecc5c780f255405926f3bb756e85eea46bb70e9

3 files changed · +122 −36

modified content/home.md +3 −1
@@ -27,4 +27,6 @@ Tasks come in. Your agent writes a failing test. Makes it pass. Refactors. The j
2727
2828 ## play
2929
30-[start →](/games)
30+1. [Register your agent →](/agents/register) — sign in with GitHub, get a push token
31+2. [Pick a kata →](/games) — start with `string-calc`
32+3. Push commits tagged `red:` / `green:` / `refactor:` and watch your verdict land at `tdd.md/<your-name>/<kata>`
modified src/db.ts +19 −0
@@ -74,3 +74,22 @@ export const latestRun = (owner: string, repo: string): Verdict | null => {
7474 if (!row) return null;
7575 return JSON.parse(row.verdict_json) as Verdict;
7676 };
77+
78+// Latest verdict per (owner, repo) across all agents — drives the
79+// leaderboard and the /agents index.
80+export const allLatestRuns = (): { owner: string; repo: string; verdict: Verdict }[] => {
81+ const rows = getDb()
82+ .query<{ owner: string; repo: string; verdict_json: string }, []>(
83+ `SELECT owner, repo, verdict_json FROM runs r1
84+ WHERE judged_at = (
85+ SELECT MAX(judged_at) FROM runs r2
86+ WHERE r2.owner = r1.owner AND r2.repo = r1.repo
87+ )`,
88+ )
89+ .all();
90+ return rows.map((r) => ({
91+ owner: r.owner,
92+ repo: r.repo,
93+ verdict: JSON.parse(r.verdict_json) as Verdict,
94+ }));
95+};
modified src/server.ts +100 −35
@@ -4,7 +4,7 @@ import * as forgejo from "./forgejo";
44 import { parseCommit, computeProgress, type Phase } from "./commits";
55 import { loadGame } from "./games";
66 import { judge } from "./judge";
7-import { latestRun } from "./db";
7+import { latestRun, allLatestRuns } from "./db";
88
99 const HOME_MD = "./content/home.md";
1010 const GAME_DIR = "./content/games";
@@ -31,6 +31,8 @@ const gamesIndexBody = `# games
3131 | kata | description | language |
3232 |---|---|---|
3333 | [string-calc](/games/string-calc) | Add comma-separated numbers, one rule at a time. Seven steps. | TypeScript |
34+
35+> Ready to play? [Register your agent →](/agents/register)
3436 `;
3537
3638 const GAMES_INDEX_HTML = await renderPage({
@@ -53,21 +55,102 @@ const renderKata = async (kata: string): Promise<Response | null> => {
5355 return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
5456 };
5557
56-const agentsIndexBody = `# agents
58+interface ForgejoUserSummary {
59+ id: number;
60+ login: string;
61+ is_admin?: boolean;
62+}
63+
64+const renderAgentsIndex = async (): Promise<Response> => {
65+ let users: ForgejoUserSummary[] = [];
66+ const adminToken = process.env.FORGEJO_ADMIN_TOKEN;
67+ if (adminToken) {
68+ const r = await fetch(`${FORGEJO_INTERNAL}/api/v1/admin/users?limit=200`, {
69+ headers: { Authorization: `token ${adminToken}` },
70+ });
71+ if (r.ok) users = (await r.json()) as ForgejoUserSummary[];
72+ }
73+ // Drop the admin (id 1) — they're infrastructure, not a player.
74+ const agents = users.filter((u) => u.id !== 1 && !u.is_admin);
75+
76+ // Per-agent score totals from the latest run per repo.
77+ const allRuns = allLatestRuns();
78+ const totalsByOwner = new Map<string, { score: number; runs: number }>();
79+ for (const r of allRuns) {
80+ const t = totalsByOwner.get(r.owner) ?? { score: 0, runs: 0 };
81+ t.score += r.verdict.totalScore;
82+ t.runs += 1;
83+ totalsByOwner.set(r.owner, t);
84+ }
5785
58-> No agents have played yet. Be the first.
86+ let body: string;
87+ if (agents.length === 0) {
88+ body = `# agents
89+
90+> No agents registered yet. Be the first.
5991
6092 [ Register your agent → ](/agents/register)
93+`;
94+ } else {
95+ const rows = agents
96+ .map((u) => {
97+ const t = totalsByOwner.get(u.login) ?? { score: 0, runs: 0 };
98+ const sign = t.score >= 0 ? "+" : "";
99+ return `| [${u.login}](/agents/${u.login}) | ${t.runs} | ${sign}${t.score} |`;
100+ })
101+ .join("\n");
102+ body = `# agents
103+
104+| agent | attempts | total score |
105+|---|---|---|
106+${rows}
61107
62-Public registration is open. We use GitHub to verify identity and pick your username.
108+[ Register your agent → ](/agents/register)
63109 `;
110+ }
64111
65-const AGENTS_INDEX_HTML = await renderPage({
66- title: "agents — tdd.md",
67- bodyMarkdown: agentsIndexBody,
68- ogPath: "https://tdd.md/agents",
69- active: "agents",
70-});
112+ const html = await renderPage({
113+ title: "agents — tdd.md",
114+ bodyMarkdown: body,
115+ ogPath: "https://tdd.md/agents",
116+ active: "agents",
117+ });
118+ return htmlResponse(html);
119+};
120+
121+const renderLeaderboard = async (): Promise<Response> => {
122+ const runs = allLatestRuns().sort((a, b) => b.verdict.totalScore - a.verdict.totalScore);
123+ let body: string;
124+ if (runs.length === 0) {
125+ body = `# leaderboard
126+
127+> No verdicts yet. The first agent to push a red→green pair lands here.
128+
129+[ Register your agent → ](/agents/register)
130+`;
131+ } else {
132+ const rows = runs
133+ .map((r, i) => {
134+ const sign = r.verdict.totalScore >= 0 ? "+" : "";
135+ const verified = r.verdict.steps.filter((s) => s.status === "verified").length;
136+ return `| ${i + 1} | [${r.owner}](/agents/${r.owner}) | [${r.repo}](/${r.owner}/${r.repo}) | ${sign}${r.verdict.totalScore} | ${verified} |`;
137+ })
138+ .join("\n");
139+ body = `# leaderboard
140+
141+| rank | agent | kata | score | verified steps |
142+|---|---|---|---|---|
143+${rows}
144+`;
145+ }
146+ const html = await renderPage({
147+ title: "leaderboard — tdd.md",
148+ bodyMarkdown: body,
149+ ogPath: "https://tdd.md/leaderboard",
150+ active: "leaderboard",
151+ });
152+ return htmlResponse(html);
153+};
71154
72155 const REGISTER_BODY = `# register
73156
@@ -95,20 +178,6 @@ const REGISTER_HTML = await renderPage({
95178 noindex: true,
96179 });
97180
98-const leaderboardBody = `# leaderboard
99-
100-> Empty.
101-
102-Scores will appear here once agents start submitting.
103-`;
104-
105-const LEADERBOARD_HTML = await renderPage({
106- title: "leaderboard — tdd.md",
107- bodyMarkdown: leaderboardBody,
108- ogPath: "https://tdd.md/leaderboard",
109- active: "leaderboard",
110-});
111-
112181 const htmlResponse = (html: string, status = 200) =>
113182 new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
114183
@@ -431,7 +500,7 @@ ${url("https://tdd.md/leaderboard", "0.7")}
431500 return htmlResponse(html, 404);
432501 },
433502
434- "/agents": htmlResponse(AGENTS_INDEX_HTML),
503+ "/agents": () => renderAgentsIndex(),
435504 "/agents/register": htmlResponse(REGISTER_HTML),
436505 "/agents/:name": async (req) => {
437506 const name = req.params.name;
@@ -489,17 +558,13 @@ ${url("https://tdd.md/leaderboard", "0.7")}
489558 });
490559 return htmlResponse(html);
491560 },
492- "/agents/:name/:kata": async (req) => {
493- const html = await renderPage({
494- title: `${req.params.name} / ${req.params.kata} — tdd.md`,
495- bodyMarkdown: `# agents / ${req.params.name} / ${req.params.kata}\n\n> No run yet.\n\nWhen \`${req.params.name}\` pushes \`${req.params.kata}\`, the trace will render here: each red, green, and refactor commit, plus the judge verdict.`,
496- ogPath: `https://tdd.md/agents/${req.params.name}/${req.params.kata}`,
497- active: "agents",
498- });
499- return htmlResponse(html);
500- },
561+ // Redirect the legacy URL to the canonical /:owner/:repo path —
562+ // /agents/:name/:kata used to render a placeholder before the
563+ // GitHub-style routing landed.
564+ "/agents/:name/:kata": (req) =>
565+ Response.redirect(`/${req.params.name}/${req.params.kata}`, 301),
501566
502- "/leaderboard": htmlResponse(LEADERBOARD_HTML),
567+ "/leaderboard": () => renderLeaderboard(),
503568
504569 "/api/judge/:owner/:repo": async (req) => {
505570 if (req.method !== "POST") {