syntaxai/tdd.md · commit 21a1c4a

Build CONTRIBUTING.md as canonical contributor on-ramp

Implements /blog/sama-v2-on-ramp-gap. Closes the procedural-artifact
hole in the empirical chain: every output had a URL, the path to
authoring them didn't.

- ./CONTRIBUTING.md at repo root — links to canonical sources, never
  restates rules. Sections: Before-you-start, /goal workflow, SAMA
  layer convention (links to §1.1), how-to-add (blog post, /goal,
  image, Layer-1 helper, top-level dir), anti-fudge defaults, branch/
  deploy flow.
- src/d21_handlers_contributing.ts — single closure, reads
  ./CONTRIBUTING.md, renders via docs layout.
- Route /contributing wired in src/d21_app.ts.
- Section type extended with "contributing"; nav strip gets the link.
- STATIC_PATHS in src/b32_sitemap.ts adds /contributing; sibling
  test updated to expect 13 entries.
- Containerfile: CONTRIBUTING.md added to the COPY line so the file
  ships in the runtime image (PR #46 lessons-learned applied).
- src/b32_contributing_drift.test.ts — 7-case drift-detection: must
  link to /sama/v2, /goals/migrate-historical-goals, the two drama
  posts, git.tdd.md/issues, /sama/v2/verify; bloat ceiling 250 lines.

The file goes live at three URLs:
- /contributing (canonical)
- /GIT/tdd.md/blob/main/CONTRIBUTING.md (auto via the source viewer)
- root of the repo (raw markdown for Forgejo / git clones)

No GitHub-as-canonical-surface framing — external contributors engage
via git.tdd.md/issues per b51_render_edit.ts:73-76.

Dogfooded: first commit of this branch was goals/contributing-md.md
with status: pending; this PR body embeds the verbatim /goal under
## /goal per feedback_goal_authoring_workflow.md. Final commit
before deploy flips status: shipped + fills merge_sha.

Tests: 409/409 pass (402 → 409, +7 drift cases).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
author
syntaxai <[email protected]>
date
2026-05-25 15:34:15 +01:00
parent
c458bf5
commit
21a1c4a701989eb40d9643b70589062ec647c013

8 files changed · +208 −4

added CONTRIBUTING.md +124 −0
@@ -0,0 +1,124 @@
1+# Contributing to tdd.md
2+
3+This file is the **on-ramp** for new contributors — human or agent. It links to canonical sources rather than restating them. If something here drifts from what the spec or workflow memory says, the canonical artifact wins; please file an issue.
4+
5+Two URLs, one file:
6+
7+- **Browse**: [`/GIT/tdd.md/blob/main/CONTRIBUTING.md`](/GIT/tdd.md/blob/main/CONTRIBUTING.md) — via the site's own source viewer
8+- **Canonical**: [`/contributing`](/contributing) — the rendered permalink
9+
10+## Before you start
11+
12+Read in this order:
13+
14+1. [`/sama/v2`](/sama/v2) — the architectural spec (rules, profile, verifier, §5 metrics, §6 evolution policy)
15+2. [`/blog/sama-v2-on-ramp-gap`](/blog/sama-v2-on-ramp-gap) — why this file exists
16+3. [`/blog/sama-v2-goal-chain-gap`](/blog/sama-v2-goal-chain-gap) — why `/goal`s are now in git
17+
18+Then check the live state: [`/sama/v2/verify`](/sama/v2/verify) must report **7/7 ✓** before and after every merge. This is the load-bearing anti-fudge gate.
19+
20+## How contribution works on this codebase
21+
22+This is a self-hosted project. The canonical surfaces are all on `tdd.md`:
23+
24+- **Read** the source: [`/GIT/tdd.md/tree/main`](/GIT/tdd.md/tree/main)
25+- **Issues + discussion**: [git.tdd.md/syntaxai/tdd.md/issues](https://git.tdd.md/syntaxai/tdd.md/issues) (the self-hosted Forgejo)
26+- **Clone (read-only)**: `git clone https://tdd.md/syntaxai/tdd.md.git`
27+
28+Editing the live site is **admin-only** by design — see [`src/b51_render_edit.ts`](/GIT/tdd.md/blob/main/src/b51_render_edit.ts). The admin uses the GitHub PR flow internally; external contributors engage through the issue tracker on Forgejo, not via GitHub PRs.
29+
30+## The `/goal` workflow
31+
32+The contract for every PR on this site is a `/goal` slash command. The canonical workflow definition lives at [`/goals/migrate-historical-goals`](/goals/migrate-historical-goals); a 5-line summary:
33+
34+1. User fires `/goal` with `Goal: ... Done when: ... Constraints ... Load-bearing files ...` structure
35+2. Agent's **first** action: write the verbatim text to `goals/<slug>.md` with `status: pending`, branch + commit
36+3. Implementation work happens in subsequent commits
37+4. PR body MUST include the verbatim `/goal` under a `## /goal` heading (defense-in-depth)
38+5. Final commit before deploy flips `status: shipped` + fills `merge_sha`
39+
40+Every shipped `/goal` is browseable at [`/goals`](/goals).
41+
42+## SAMA layer convention
43+
44+The file prefix encodes the layer. The canonical definition is in [`/sama/v2 §1.1`](/sama/v2#11-layers); the table below shows concrete examples from this repo:
45+
46+| Prefix | Layer | Means | Example |
47+|---|---|---|---|
48+| `a*_` | 0 — Pure | data + types, no I/O | [`a31_blog.ts`](/GIT/tdd.md/blob/main/src/a31_blog.ts) |
49+| `b*_` | 1 — Core | pure logic, no I/O | [`b32_sitemap.ts`](/GIT/tdd.md/blob/main/src/b32_sitemap.ts) |
50+| `c*_` | 2 — Adapter | parses boundaries; DB, network, fs | [`c14_git.ts`](/GIT/tdd.md/blob/main/src/c14_git.ts) |
51+| `d*_` | 3 — Entry | route handlers, app bootstrap | [`d21_app.ts`](/GIT/tdd.md/blob/main/src/d21_app.ts) |
52+| `b51_` | 1 — Core (render) | pure HTML rendering | [`b51_render_layout.ts`](/GIT/tdd.md/blob/main/src/b51_render_layout.ts) |
53+
54+The Law (§1.2): imports flow downward only. The verifier rejects upward or sideways edges mechanically.
55+
56+## How to add a blog post
57+
58+1. Write `content/blog/<slug>.md` with the body
59+2. Add an entry to `ALL_POSTS` in [`src/a31_blog.ts`](/GIT/tdd.md/blob/main/src/a31_blog.ts) with `{ slug, title, description, date }`
60+3. The sitemap, blog index, and `/blog/<slug>` route pick it up automatically — registry is the single source of truth
61+4. Branch → PR → merge → deploy
62+
63+Worked example: [`/blog/sama-v2-sitemap-implementation-plan`](/blog/sama-v2-sitemap-implementation-plan).
64+
65+## How to add a `/goal`
66+
67+Type `/goal` in conversation with the agent. The agent handles the rest per the workflow above.
68+
69+If you're not the admin: file an issue at [git.tdd.md/syntaxai/tdd.md/issues](https://git.tdd.md/syntaxai/tdd.md/issues) using the `Goal: ... Done when: ... Constraints ... Load-bearing files ...` shape. The admin (or agent on their behalf) fires it.
70+
71+## How to add an image
72+
73+1. Write `public/images/<name>.svg` with a `https://tdd.md` watermark text somewhere on the canvas (typically bottom-right in a muted color)
74+2. Render PNG: `rsvg-convert -w 1200 -h 600 public/images/<name>.svg -o public/images/<name>.png`
75+3. Reference from markdown: `![alt](/images/<name>.png?v=1)`
76+
77+The `/images/*` wildcard is served by the fallback handler in [`d21_handlers_fallback.ts`](/GIT/tdd.md/blob/main/src/d21_handlers_fallback.ts) — no per-image route registration needed.
78+
79+## How to add a Layer-1 helper
80+
81+1. Write `src/b32_<name>.ts` — pure, no I/O, single export per concern
82+2. Write the **sibling** `src/b32_<name>.test.ts` — required by [`§4.3 Modeled-tests`](/sama/v2#43-modeled-tests)
83+3. Caller (typically Layer 2 or Layer 3) imports from `./b32_<name>.ts`
84+
85+Worked example: [`b32_sitemap.ts`](/GIT/tdd.md/blob/main/src/b32_sitemap.ts) + [`b32_sitemap.test.ts`](/GIT/tdd.md/blob/main/src/b32_sitemap.test.ts).
86+
87+## How to add a top-level directory
88+
89+⚠ **Important**: every new top-level directory needs a corresponding `COPY <dir> ./<dir>` line in [`Containerfile`](/GIT/tdd.md/blob/main/Containerfile). Without it, the directory doesn't ship with the runtime image and request-time disk reads return 404. This was discovered in PR #46 when `/goals/<slug>` returned 404 in production.
90+
91+Checklist for a new top-level dir:
92+1. Create the directory and any initial files
93+2. Add `COPY <dir> ./<dir>` to `Containerfile` (after the existing COPY lines)
94+3. Live-verify a file from the new directory loads after deploy
95+
96+## Anti-fudge defaults
97+
98+Every PR satisfies these without exception:
99+
100+- **Site language**: English only. No Dutch in user-facing strings (blog bodies, banners, error text, page copy)
101+- **Tests stay green**: `bun test` must pass; new behaviour gets a sibling test
102+- **`/sama/v2/verify` stays 7/7 ✓**: structural choices must preserve the verifier verdict
103+- **No `--no-verify`, no force-push to main**: hooks exist for a reason
104+- **GitHub flow via `flatpak-spawn`**: the sandbox doesn't have `gh`; use `flatpak-spawn --host gh ...`
105+
106+## Branch / deploy flow (admin path)
107+
108+The full sequence the agent (or admin) runs end-to-end:
109+
110+1. `git checkout -b <slug>` — branch from main
111+2. Implementation work + sibling tests
112+3. `git push -u origin <slug>` + `gh pr create` (body includes `## /goal` verbatim per workflow)
113+4. `gh pr merge --merge --delete-branch` after CI green
114+5. `git checkout main && git pull origin main` — sync local
115+6. `git push p620 main` — push to self-hosted bare repo
116+7. `flatpak-spawn --host /var/home/scri/Documents/tdd.md/scripts/p620/deploy-tdd-md.sh` — rebuild + redeploy container
117+8. Live-verify: curl the changed URLs, confirm `/sama/v2/verify` still 7/7 ✓
118+
119+## When in doubt
120+
121+- The spec ([`/sama/v2`](/sama/v2)) is the single source of truth for architectural rules
122+- The verifier ([`/sama/v2/verify`](/sama/v2/verify)) is the single source of truth for whether a commit is conformant
123+- The `/goals` registry ([`/goals`](/goals)) is the single source of truth for what every PR was held against
124+- This file links to all three. If it contradicts any of them, the canonical artifact wins; please file an issue at [git.tdd.md/syntaxai/tdd.md/issues](https://git.tdd.md/syntaxai/tdd.md/issues).
modified Containerfile +1 −1
@@ -13,7 +13,7 @@ FROM docker.io/oven/bun:1-alpine AS runtime
1313 RUN apk add --no-cache git
1414 WORKDIR /app
1515 COPY --from=deps /app/node_modules ./node_modules
16-COPY package.json bun.lock tsconfig.json sama.profile.toml ./
16+COPY package.json bun.lock tsconfig.json sama.profile.toml CONTRIBUTING.md ./
1717 COPY src ./src
1818 COPY content ./content
1919 COPY goals ./goals
added src/b32_contributing_drift.test.ts +45 −0
@@ -0,0 +1,45 @@
1+// Drift-detection sibling test for ./CONTRIBUTING.md.
2+// CONTRIBUTING.md is a SYNTHESIS that links to canonical sources —
3+// it must not become a restater. These tests check that the load-
4+// bearing links are still present + the file hasn't bloated past
5+// a ceiling that would suggest someone copied rule definitions
6+// inline instead of linking out.
7+//
8+// Lives next to b32_*.ts as a Layer-1-adjacent meta-test (it reads
9+// disk at test time, which is allowed for sibling tests per /sama/v2
10+// §4.3 — see b32_sama_verify.test.ts for the same shape).
11+
12+import { describe, expect, test } from "bun:test";
13+
14+const contributingMd = await Bun.file("./CONTRIBUTING.md").text();
15+
16+describe("CONTRIBUTING.md drift detection", () => {
17+ test("links to the SAMA v2 spec (canonical rules)", () => {
18+ expect(contributingMd).toContain("/sama/v2");
19+ });
20+
21+ test("links to the /goal workflow's canonical /goal file", () => {
22+ expect(contributingMd).toContain("/goals/migrate-historical-goals");
23+ });
24+
25+ test("links to the on-ramp gap drama post", () => {
26+ expect(contributingMd).toContain("/blog/sama-v2-on-ramp-gap");
27+ });
28+
29+ test("links to the chain-gap drama post (prior context)", () => {
30+ expect(contributingMd).toContain("/blog/sama-v2-goal-chain-gap");
31+ });
32+
33+ test("links to the external-engagement issue tracker", () => {
34+ expect(contributingMd).toContain("git.tdd.md/syntaxai/tdd.md/issues");
35+ });
36+
37+ test("links to /sama/v2/verify as the anti-fudge gate", () => {
38+ expect(contributingMd).toContain("/sama/v2/verify");
39+ });
40+
41+ test("does not exceed bloat ceiling (250 lines)", () => {
42+ const lines = contributingMd.split("\n").length;
43+ expect(lines).toBeLessThan(250);
44+ });
45+});
modified src/b32_sitemap.test.ts +2 −1
@@ -89,10 +89,11 @@ describe("renderSitemap", () => {
8989 });
9090
9191 describe("STATIC_PATHS", () => {
92- test("covers the load-bearing routes (incl. /goals from goal #1)", () => {
92+ test("covers the load-bearing routes (incl. /goals + /contributing)", () => {
9393 expect(STATIC_PATHS).toEqual([
9494 "/",
9595 "/blog",
96+ "/contributing",
9697 "/games",
9798 "/goals",
9899 "/leaderboard",
modified src/b32_sitemap.ts +1 −0
@@ -16,6 +16,7 @@ export interface SitemapUrl {
1616 export const STATIC_PATHS: ReadonlyArray<string> = [
1717 "/",
1818 "/blog",
19+ "/contributing",
1920 "/games",
2021 "/goals",
2122 "/leaderboard",
modified src/b51_render_layout.ts +2 −2
@@ -11,7 +11,7 @@ import type { Phase } from "./a31_commits.ts";
1111 const STYLE_CSS = "./public/style.css";
1212 const css = await Bun.file(STYLE_CSS).text();
1313
14-export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard" | "sama" | "goals";
14+export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard" | "sama" | "goals" | "contributing";
1515
1616 export interface PageOptions {
1717 title: string;
@@ -44,7 +44,7 @@ const navLink = (href: string, label: string, active: boolean): string => {
4444 return `<a href="${href}"${cls}>${label}</a>`;
4545 };
4646
47-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("/sama", "sama", active === "sama")} <span class="md-nav-sep">·</span> ${navLink("/goals", "goals", active === "goals")} <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>`;
47+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("/sama", "sama", active === "sama")} <span class="md-nav-sep">·</span> ${navLink("/goals", "goals", active === "goals")} <span class="md-nav-sep">·</span> ${navLink("/contributing", "contributing", active === "contributing")} <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>`;
4848
4949 export const renderPage = async (opts: PageOptions): Promise<string> => {
5050 const body = opts.bodyHtml ?? await marked.parse(opts.bodyMarkdown ?? "", { gfm: true, breaks: false });
modified src/d21_app.ts +3 −0
@@ -51,6 +51,7 @@ import {
5151 goalsLandingHandler,
5252 goalSlugHandler,
5353 } from "./d21_handlers_goals.ts";
54+import { contributingHandler } from "./d21_handlers_contributing.ts";
5455 import { editPageHandler } from "./d21_handlers_edit.ts";
5556 import {
5657 adminListHandler,
@@ -420,6 +421,8 @@ ${rows}
420421
421422 "/goals/:slug": goalSlugHandler,
422423
424+ "/contributing": contributingHandler,
425+
423426 "/games/:kata": async (req) => {
424427 const res = await renderKata(req.params.kata);
425428 if (res) return res;
added src/d21_handlers_contributing.ts +30 −0
@@ -0,0 +1,30 @@
1+// c21 — handler: /contributing reads ./CONTRIBUTING.md from the repo
2+// root and renders it through the docs layout. Mirrors the shape of
3+// other content-driven pages (samaLandingHandler, blogLandingHandler)
4+// but reads from the project-meta root file rather than from a
5+// per-section content/ subdirectory. The file is also browseable at
6+// /GIT/tdd.md/blob/main/CONTRIBUTING.md via the SAMA-native source
7+// viewer — same content, two URLs.
8+
9+import { renderDocsPage } from "./b51_render_docs_layout.ts";
10+import { htmlResponse, renderNotFound } from "./b51_render_layout.ts";
11+
12+export const contributingHandler = async (): Promise<Response> => {
13+ const file = Bun.file("./CONTRIBUTING.md");
14+ if (!(await file.exists())) {
15+ const html = await renderNotFound("/contributing");
16+ return htmlResponse(html, 404);
17+ }
18+ const body = await file.text();
19+ const html = await renderDocsPage({
20+ title: "Contributing — tdd.md",
21+ description:
22+ "How to add a feature, blog post, /goal, image, or top-level directory to tdd.md. Single-source-of-truth on-ramp that links to the canonical artifacts; doesn't restate them.",
23+ bodyMarkdown: body,
24+ ogPath: "https://tdd.md/contributing",
25+ active: "contributing",
26+ pathForDocs: "/contributing",
27+ editPathOverride: null,
28+ });
29+ return htmlResponse(html);
30+};