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]>
8 files changed · +208 −4
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: `` | |
| 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). | |
Containerfile
+1
−1
| @@ -13,7 +13,7 @@ FROM docker.io/oven/bun:1-alpine AS runtime | ||
| 13 | 13 | RUN apk add --no-cache git |
| 14 | 14 | WORKDIR /app |
| 15 | 15 | 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 ./ | |
| 17 | 17 | COPY src ./src |
| 18 | 18 | COPY content ./content |
| 19 | 19 | COPY goals ./goals |
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 | +}); | |
src/b32_sitemap.test.ts
+2
−1
| @@ -89,10 +89,11 @@ describe("renderSitemap", () => { | ||
| 89 | 89 | }); |
| 90 | 90 | |
| 91 | 91 | 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)", () => { | |
| 93 | 93 | expect(STATIC_PATHS).toEqual([ |
| 94 | 94 | "/", |
| 95 | 95 | "/blog", |
| 96 | + "/contributing", | |
| 96 | 97 | "/games", |
| 97 | 98 | "/goals", |
| 98 | 99 | "/leaderboard", |
src/b32_sitemap.ts
+1
−0
| @@ -16,6 +16,7 @@ export interface SitemapUrl { | ||
| 16 | 16 | export const STATIC_PATHS: ReadonlyArray<string> = [ |
| 17 | 17 | "/", |
| 18 | 18 | "/blog", |
| 19 | + "/contributing", | |
| 19 | 20 | "/games", |
| 20 | 21 | "/goals", |
| 21 | 22 | "/leaderboard", |
src/b51_render_layout.ts
+2
−2
| @@ -11,7 +11,7 @@ import type { Phase } from "./a31_commits.ts"; | ||
| 11 | 11 | const STYLE_CSS = "./public/style.css"; |
| 12 | 12 | const css = await Bun.file(STYLE_CSS).text(); |
| 13 | 13 | |
| 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"; | |
| 15 | 15 | |
| 16 | 16 | export interface PageOptions { |
| 17 | 17 | title: string; |
| @@ -44,7 +44,7 @@ const navLink = (href: string, label: string, active: boolean): string => { | ||
| 44 | 44 | return `<a href="${href}"${cls}>${label}</a>`; |
| 45 | 45 | }; |
| 46 | 46 | |
| 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>`; | |
| 48 | 48 | |
| 49 | 49 | export const renderPage = async (opts: PageOptions): Promise<string> => { |
| 50 | 50 | const body = opts.bodyHtml ?? await marked.parse(opts.bodyMarkdown ?? "", { gfm: true, breaks: false }); |
src/d21_app.ts
+3
−0
| @@ -51,6 +51,7 @@ import { | ||
| 51 | 51 | goalsLandingHandler, |
| 52 | 52 | goalSlugHandler, |
| 53 | 53 | } from "./d21_handlers_goals.ts"; |
| 54 | +import { contributingHandler } from "./d21_handlers_contributing.ts"; | |
| 54 | 55 | import { editPageHandler } from "./d21_handlers_edit.ts"; |
| 55 | 56 | import { |
| 56 | 57 | adminListHandler, |
| @@ -420,6 +421,8 @@ ${rows} | ||
| 420 | 421 | |
| 421 | 422 | "/goals/:slug": goalSlugHandler, |
| 422 | 423 | |
| 424 | + "/contributing": contributingHandler, | |
| 425 | + | |
| 423 | 426 | "/games/:kata": async (req) => { |
| 424 | 427 | const res = await renderKata(req.params.kata); |
| 425 | 428 | if (res) return res; |
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 | +}; | |